Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/Expenses/RF32: Endpoint for expenseReport data by id #205

Merged
merged 9 commits into from
May 23, 2024
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
Warnings:

- You are about to drop the column `id_file` on the `expense` table. All the data in the column will be lost.
- You are about to drop the column `total_amount` on the `expense_report` table. All the data in the column will be lost.
- You are about to drop the `file` table. If the table is not empty, all the data it contains will be lost.
- Added the required column `title` to the `expense_report` table without a default value. This is not possible if the table is not empty.

*/
-- DropForeignKey
ALTER TABLE "expense" DROP CONSTRAINT "expense_id_file_fkey";

-- AlterTable
ALTER TABLE "expense" DROP COLUMN "id_file",
ADD COLUMN "url_file" VARCHAR(512);

-- AlterTable
ALTER TABLE "expense_report" DROP COLUMN "total_amount",
ADD COLUMN "title" VARCHAR(70) NOT NULL;

-- DropTable
DROP TABLE "file";
16 changes: 3 additions & 13 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
generator client {
provider = "prisma-client-js"
previewFeatures = ["omitApi"]
}

datasource db {
Expand Down Expand Up @@ -104,35 +105,24 @@ model expense {
created_at DateTime @default(now()) @db.Timestamp(6)
updated_at DateTime? @updatedAt @db.Timestamp(6)
id_report String @db.Uuid
id_file String? @db.Uuid
file file? @relation(fields: [id_file], references: [id], onDelete: NoAction, onUpdate: NoAction)
url_file String? @db.VarChar(512)
expense_report expense_report @relation(fields: [id_report], references: [id], onDelete: NoAction, onUpdate: NoAction)
}

model expense_report {
id String @id @db.Uuid
title String @db.VarChar(70)
description String @db.VarChar(255)
start_date DateTime @db.Date
end_date DateTime? @db.Date
status String? @db.VarChar(256)
total_amount Decimal? @db.Decimal(8, 2)
created_at DateTime @default(now()) @db.Timestamp(6)
updated_at DateTime? @updatedAt @db.Timestamp(6)
id_employee String @db.Uuid
expense expense[]
employee employee @relation(fields: [id_employee], references: [id], onDelete: NoAction, onUpdate: NoAction)
}

model file {
id String @id @db.Uuid
description String? @db.VarChar(256)
format String @default(".zip") @db.VarChar(256)
url String @unique @db.VarChar(256)
created_at DateTime @default(now()) @db.Timestamp(6)
updated_at DateTime? @updatedAt @db.Timestamp(6)
expense expense[]
}

model form {
id String @id @db.Uuid
title String @db.VarChar(70)
Expand Down
30 changes: 30 additions & 0 deletions src/api/controllers/expense.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Request, Response } from 'express';
import { z } from 'zod';
import { ExpenseService } from '../../core/app/services/expense.service';

const idSchema = z.object({
id: z.string().uuid(),
});

/**
* A function that handles the request to obtain expense report details by its id
* @param req HTTP Request
* @param res Server response
*/
async function getReportById(req: Request, res: Response) {
try {
const { id } = idSchema.parse({ id: req.params.id });
const expenseDetails = await ExpenseService.getReportById(id, req.body.auth.email);
if (expenseDetails) {
res.status(200).json(expenseDetails);
}
} catch (error: any) {
if (error.message === 'Unauthorized employee') {
res.status(403).json({ message: error.message });
} else {
res.status(500).json({ message: error.message });
}
}
}

export const ExpenseController = { getReportById };
11 changes: 11 additions & 0 deletions src/api/routes/expense.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Router } from 'express';
import { SupportedRoles } from '../../utils/enums';
import { ExpenseController } from '../controllers/expense.controller';
import { checkAuthRole } from '../middlewares/rbac.middleware';

const router = Router();

router.use(checkAuthRole([SupportedRoles.ACCOUNTING, SupportedRoles.LEGAL, SupportedRoles.ADMIN]));
router.get('/report/:id', ExpenseController.getReportById);

export { router as ExpenseRouter };
4 changes: 4 additions & 0 deletions src/api/routes/index.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AdminRouter } from './admin.routes';
import { CompanyRouter } from './company.routes';
import { DepartmentRouter } from './department.routes';
import { EmployeeRouter } from './employee.routes';
import { ExpenseRouter } from './expense.routes';
import { HomeRouter } from './home.routes';
import { NotificationRouter } from './notification.routes';
import { ProjectRouter } from './project.routes';
Expand Down Expand Up @@ -39,6 +40,9 @@ baseRouter.use(`${V1_PATH}/company`, CompanyRouter);
// Notification
baseRouter.use(`${V1_PATH}/notification`, NotificationRouter);

// Expense
baseRouter.use(`${V1_PATH}/expense`, ExpenseRouter);

// Health check
baseRouter.use(`${V1_PATH}/health`, (_req, res) => res.send('OK'));

Expand Down
77 changes: 77 additions & 0 deletions src/core/app/services/__tests__/expense.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { faker } from '@faker-js/faker';
import { expect } from 'chai';
import { randomUUID } from 'crypto';
import { default as Sinon, default as sinon } from 'sinon';
import { ExpenseReportStatus, SupportedRoles } from '../../../../utils/enums';

import { EmployeeRepository } from '../../../infra/repositories/employee.repository';
import { ExpenseRepository } from '../../../infra/repositories/expense.repository';
import { RoleRepository } from '../../../infra/repositories/role.repository';
import { ExpenseService } from '../expense.service';

describe('ExpenseService', () => {
let findEmployeeByEmailStub: Sinon.SinonStub;
let findRoleByEmailStub: Sinon.SinonStub;
let findExpenseByIdStub: Sinon.SinonStub;

beforeEach(() => {
findEmployeeByEmailStub = sinon.stub(EmployeeRepository, 'findByEmail');
findRoleByEmailStub = sinon.stub(RoleRepository, 'findByEmail');
findExpenseByIdStub = sinon.stub(ExpenseRepository, 'findById');
});

afterEach(() => {
sinon.restore();
});

describe('getReportById', () => {
it('Should return the expense report details', async () => {
const reportId = randomUUID();
const userEmail = faker.internet.email();
const userId = randomUUID();

const employee = {
id: userId,
email: userEmail,
name: faker.lorem.words(2),
role: SupportedRoles.ADMIN,
};
const role = {
title: SupportedRoles.ADMIN,
};
const expenses = [
{
id: randomUUID(),
title: faker.lorem.words(3),
justification: faker.lorem.words(10),
totalAmount: faker.number.float(),
date: new Date(),
createdAt: new Date(),
idReport: reportId,
},
];
const existingReport = {
id: reportId,
title: faker.lorem.words(3),
description: faker.lorem.words(10),
startDate: new Date(),
endDate: new Date(),
status: ExpenseReportStatus.PENDING,
createdAt: new Date(),
idEmployee: userId,
expenses: expenses,
};

findEmployeeByEmailStub.resolves(employee);
findRoleByEmailStub.resolves(role);
findExpenseByIdStub.resolves(existingReport);

const res = await ExpenseService.getReportById(reportId, userEmail);

expect(res).to.exist;
expect(res).to.be.equal(existingReport);
expect(res.id).to.equal(reportId);
expect(res.expenses?.length).to.equal(expenses.length);
});
});
});
45 changes: 45 additions & 0 deletions src/core/app/services/expense.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Decimal } from '@prisma/client/runtime/library';
import { SupportedRoles } from '../../../utils/enums';
import { ExpenseReport } from '../../domain/entities/expense.entity';
import { EmployeeRepository } from '../../infra/repositories/employee.repository';
import { ExpenseRepository } from '../../infra/repositories/expense.repository';
import { RoleRepository } from '../../infra/repositories/role.repository';

/**
*
* @param getReportById the id of the expense report we want the details
* @param email the email of the user
* @returns {Promise<ExpenseReport>} a promise that resolves the details of the expense report
* @throws {Error} if an unexpected error occurs
*/

async function getReportById(reportId: string, email: string): Promise<ExpenseReport> {
try {
const [employee, role, expenseReport] = await Promise.all([
EmployeeRepository.findByEmail(email),
RoleRepository.findByEmail(email),
ExpenseRepository.findById(reportId),
]);

if (role.title.toUpperCase() != SupportedRoles.ADMIN.toUpperCase() && expenseReport.idEmployee != employee?.id) {
throw new Error('Unauthorized employee');
}

let totalAmount = new Decimal(0);
if (expenseReport.expenses) {
expenseReport.expenses.forEach(expense => {
totalAmount = totalAmount.add(expense.totalAmount);
});
}
expenseReport.totalAmount = totalAmount;

return expenseReport;
} catch (error: any) {
if (error.message === 'Unauthorized employee') {
throw error;
}
throw new Error('An unexpected error occured');
}
}

export const ExpenseService = { getReportById };
Loading
Loading