feat(module): added shifts-validation module. service to export to csv file

This commit is contained in:
Matthieu Haineault 2025-08-04 11:25:45 -04:00
parent 36be6fb2f1
commit ee059429f8
9 changed files with 577 additions and 383 deletions

View File

@ -29,125 +29,6 @@
]
}
},
"/bank-codes": {
"post": {
"operationId": "BankCodesControllers_create",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateBankCodeDto"
}
}
}
},
"responses": {
"201": {
"description": "Bank code successfully created."
},
"400": {
"description": "Invalid input data."
}
},
"summary": "Create a new bank code",
"tags": [
"BankCodesControllers"
]
},
"get": {
"operationId": "BankCodesControllers_findAll",
"parameters": [],
"responses": {
"200": {
"description": "List of bank codes."
}
},
"summary": "Retrieve all bank codes",
"tags": [
"BankCodesControllers"
]
}
},
"/bank-codes/{id}": {
"get": {
"operationId": "BankCodesControllers_findOne",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"404": {
"description": "Bank code not found."
}
},
"summary": "Retrieve a bank code by its ID",
"tags": [
"BankCodesControllers"
]
},
"patch": {
"operationId": "BankCodesControllers_update",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateBankCodeDto"
}
}
}
},
"responses": {
"404": {
"description": "Bank code not found."
}
},
"summary": "Update an existing bank code",
"tags": [
"BankCodesControllers"
]
},
"delete": {
"operationId": "BankCodesControllers_remove",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"404": {
"description": "Bank code not found."
}
},
"summary": "Delete a bank code",
"tags": [
"BankCodesControllers"
]
}
},
"/archives/employees": {
"get": {
"operationId": "EmployeesArchiveController_findOneArchived",
@ -1246,124 +1127,107 @@
]
}
},
"/oauth-access-tokens": {
"/auth/login": {
"get": {
"operationId": "AuthController_login",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Auth"
]
}
},
"/auth/callback": {
"get": {
"operationId": "AuthController_loginCallback",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Auth"
]
}
},
"/bank-codes": {
"post": {
"operationId": "OauthAccessTokensController_create",
"operationId": "BankCodesControllers_create",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateOauthAccessTokenDto"
"$ref": "#/components/schemas/CreateBankCodeDto"
}
}
}
},
"responses": {
"201": {
"description": "OAuth access token created",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OAuthAccessTokenEntity"
}
}
}
"description": "Bank code successfully created."
},
"400": {
"description": "Incomplete task or invalid data"
"description": "Invalid input data."
}
},
"security": [
{
"access-token": []
}
],
"summary": "Create OAuth access token",
"summary": "Create a new bank code",
"tags": [
"OAuth Access Tokens"
"BankCodesControllers"
]
},
"get": {
"operationId": "OauthAccessTokensController_findAll",
"operationId": "BankCodesControllers_findAll",
"parameters": [],
"responses": {
"201": {
"description": "List of OAuth access token found",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/OAuthAccessTokenEntity"
}
}
}
"200": {
"description": "List of bank codes."
}
},
"400": {
"description": "List of OAuth access token not found"
}
},
"security": [
{
"access-token": []
}
],
"summary": "Find all OAuth access token",
"summary": "Retrieve all bank codes",
"tags": [
"OAuth Access Tokens"
"BankCodesControllers"
]
}
},
"/oauth-access-tokens/{id}": {
"/bank-codes/{id}": {
"get": {
"operationId": "OauthAccessTokensController_findOne",
"operationId": "BankCodesControllers_findOne",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "string"
"type": "number"
}
}
],
"responses": {
"201": {
"description": "OAuth access token found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OAuthAccessTokenEntity"
}
}
"404": {
"description": "Bank code not found."
}
},
"400": {
"description": "OAuth access token not found"
}
},
"security": [
{
"access-token": []
}
],
"summary": "Find OAuth access token",
"summary": "Retrieve a bank code by its ID",
"tags": [
"OAuth Access Tokens"
"BankCodesControllers"
]
},
"patch": {
"operationId": "OauthAccessTokensController_update",
"operationId": "BankCodesControllers_update",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "string"
"type": "number"
}
}
],
@ -1372,71 +1236,41 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateOauthAccessTokenDto"
"$ref": "#/components/schemas/UpdateBankCodeDto"
}
}
}
},
"responses": {
"201": {
"description": "OAuth access token updated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OAuthAccessTokenEntity"
}
}
"404": {
"description": "Bank code not found."
}
},
"400": {
"description": "OAuth access token not found"
}
},
"security": [
{
"access-token": []
}
],
"summary": "Update OAuth access token",
"summary": "Update an existing bank code",
"tags": [
"OAuth Access Tokens"
"BankCodesControllers"
]
},
"delete": {
"operationId": "OauthAccessTokensController_remove",
"operationId": "BankCodesControllers_remove",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "string"
"type": "number"
}
}
],
"responses": {
"201": {
"description": "OAuth access token deleted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OAuthAccessTokenEntity"
}
}
"404": {
"description": "Bank code not found."
}
},
"400": {
"description": "OAuth access token not found"
}
},
"security": [
{
"access-token": []
}
],
"summary": "Delete OAuth access token",
"summary": "Delete a bank code",
"tags": [
"OAuth Access Tokens"
"BankCodesControllers"
]
}
},
@ -1634,31 +1468,197 @@
]
}
},
"/auth/login": {
"get": {
"operationId": "AuthController_login",
"/oauth-access-tokens": {
"post": {
"operationId": "OauthAccessTokensController_create",
"parameters": [],
"responses": {
"200": {
"description": ""
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateOauthAccessTokenDto"
}
}
}
},
"responses": {
"201": {
"description": "OAuth access token created",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OAuthAccessTokenEntity"
}
}
}
},
"400": {
"description": "Incomplete task or invalid data"
}
},
"security": [
{
"access-token": []
}
],
"summary": "Create OAuth access token",
"tags": [
"Auth"
"OAuth Access Tokens"
]
},
"get": {
"operationId": "OauthAccessTokensController_findAll",
"parameters": [],
"responses": {
"201": {
"description": "List of OAuth access token found",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/OAuthAccessTokenEntity"
}
}
}
}
},
"400": {
"description": "List of OAuth access token not found"
}
},
"security": [
{
"access-token": []
}
],
"summary": "Find all OAuth access token",
"tags": [
"OAuth Access Tokens"
]
}
},
"/auth/callback": {
"/oauth-access-tokens/{id}": {
"get": {
"operationId": "AuthController_loginCallback",
"parameters": [],
"operationId": "OauthAccessTokensController_findOne",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
"201": {
"description": "OAuth access token found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OAuthAccessTokenEntity"
}
}
}
},
"400": {
"description": "OAuth access token not found"
}
},
"security": [
{
"access-token": []
}
],
"summary": "Find OAuth access token",
"tags": [
"Auth"
"OAuth Access Tokens"
]
},
"patch": {
"operationId": "OauthAccessTokensController_update",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateOauthAccessTokenDto"
}
}
}
},
"responses": {
"201": {
"description": "OAuth access token updated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OAuthAccessTokenEntity"
}
}
}
},
"400": {
"description": "OAuth access token not found"
}
},
"security": [
{
"access-token": []
}
],
"summary": "Update OAuth access token",
"tags": [
"OAuth Access Tokens"
]
},
"delete": {
"operationId": "OauthAccessTokensController_remove",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"201": {
"description": "OAuth access token deleted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OAuthAccessTokenEntity"
}
}
}
},
"400": {
"description": "OAuth access token not found"
}
},
"security": [
{
"access-token": []
}
],
"summary": "Delete OAuth access token",
"tags": [
"OAuth Access Tokens"
]
}
},
@ -1850,14 +1850,6 @@
}
},
"schemas": {
"CreateBankCodeDto": {
"type": "object",
"properties": {}
},
"UpdateBankCodeDto": {
"type": "object",
"properties": {}
},
"CreateEmployeeDto": {
"type": "object",
"properties": {
@ -2337,6 +2329,130 @@
}
}
},
"CreateBankCodeDto": {
"type": "object",
"properties": {}
},
"UpdateBankCodeDto": {
"type": "object",
"properties": {}
},
"CreateCustomerDto": {
"type": "object",
"properties": {
"first_name": {
"type": "string",
"example": "Gandalf",
"description": "Customer`s first name"
},
"last_name": {
"type": "string",
"example": "TheGray",
"description": "Customer`s last name"
},
"email": {
"type": "string",
"example": "you_shall_not_pass@middleEarth.com",
"description": "Customer`s email"
},
"phone_number": {
"type": "number",
"example": "8436637464",
"description": "Customer`s phone number"
},
"residence": {
"type": "string",
"example": "1 Ringbearer`s way, Mount Doom city, ME, T1R 1N6 ",
"description": "Customer`s residence"
},
"invoice_id": {
"type": "number",
"example": "4263253",
"description": "Customer`s invoice number"
}
},
"required": [
"first_name",
"last_name",
"email",
"phone_number"
]
},
"CustomerEntity": {
"type": "object",
"properties": {
"id": {
"type": "number",
"example": 1,
"description": "Unique ID of a customer(primary-key, auto-incremented)"
},
"user_id": {
"type": "string",
"example": "0e6e2e1f-b157-4c7c-ae3f-999b3e4f914d",
"description": "UUID of the user linked to that customer"
},
"email": {
"type": "string",
"example": "you_shall_not_pass@middleEarth.com",
"description": "customer`s email (optional)"
},
"phone_number": {
"type": "number",
"example": 8436637464,
"description": "customer`s phone number (numbers only)"
},
"residence": {
"type": "string",
"example": "1 Ringbearers way, Mount Doom city, ME, T1R 1N6",
"description": "customer`s residence address (optional)"
},
"invoice_id": {
"type": "number",
"example": 4263253,
"description": "customer`s invoice number (optionnal, unique)"
}
},
"required": [
"id",
"user_id",
"phone_number"
]
},
"UpdateCustomerDto": {
"type": "object",
"properties": {
"first_name": {
"type": "string",
"example": "Gandalf",
"description": "Customer`s first name"
},
"last_name": {
"type": "string",
"example": "TheGray",
"description": "Customer`s last name"
},
"email": {
"type": "string",
"example": "you_shall_not_pass@middleEarth.com",
"description": "Customer`s email"
},
"phone_number": {
"type": "number",
"example": "8436637464",
"description": "Customer`s phone number"
},
"residence": {
"type": "string",
"example": "1 Ringbearer`s way, Mount Doom city, ME, T1R 1N6 ",
"description": "Customer`s residence"
},
"invoice_id": {
"type": "number",
"example": "4263253",
"description": "Customer`s invoice number"
}
}
},
"CreateOauthAccessTokenDto": {
"type": "object",
"properties": {
@ -2515,122 +2631,6 @@
}
}
},
"CreateCustomerDto": {
"type": "object",
"properties": {
"first_name": {
"type": "string",
"example": "Gandalf",
"description": "Customer`s first name"
},
"last_name": {
"type": "string",
"example": "TheGray",
"description": "Customer`s last name"
},
"email": {
"type": "string",
"example": "you_shall_not_pass@middleEarth.com",
"description": "Customer`s email"
},
"phone_number": {
"type": "number",
"example": "8436637464",
"description": "Customer`s phone number"
},
"residence": {
"type": "string",
"example": "1 Ringbearer`s way, Mount Doom city, ME, T1R 1N6 ",
"description": "Customer`s residence"
},
"invoice_id": {
"type": "number",
"example": "4263253",
"description": "Customer`s invoice number"
}
},
"required": [
"first_name",
"last_name",
"email",
"phone_number"
]
},
"CustomerEntity": {
"type": "object",
"properties": {
"id": {
"type": "number",
"example": 1,
"description": "Unique ID of a customer(primary-key, auto-incremented)"
},
"user_id": {
"type": "string",
"example": "0e6e2e1f-b157-4c7c-ae3f-999b3e4f914d",
"description": "UUID of the user linked to that customer"
},
"email": {
"type": "string",
"example": "you_shall_not_pass@middleEarth.com",
"description": "customer`s email (optional)"
},
"phone_number": {
"type": "number",
"example": 8436637464,
"description": "customer`s phone number (numbers only)"
},
"residence": {
"type": "string",
"example": "1 Ringbearers way, Mount Doom city, ME, T1R 1N6",
"description": "customer`s residence address (optional)"
},
"invoice_id": {
"type": "number",
"example": 4263253,
"description": "customer`s invoice number (optionnal, unique)"
}
},
"required": [
"id",
"user_id",
"phone_number"
]
},
"UpdateCustomerDto": {
"type": "object",
"properties": {
"first_name": {
"type": "string",
"example": "Gandalf",
"description": "Customer`s first name"
},
"last_name": {
"type": "string",
"example": "TheGray",
"description": "Customer`s last name"
},
"email": {
"type": "string",
"example": "you_shall_not_pass@middleEarth.com",
"description": "Customer`s email"
},
"phone_number": {
"type": "number",
"example": "8436637464",
"description": "Customer`s phone number"
},
"residence": {
"type": "string",
"example": "1 Ringbearer`s way, Mount Doom city, ME, T1R 1N6 ",
"description": "Customer`s residence"
},
"invoice_id": {
"type": "number",
"example": "4263253",
"description": "Customer`s invoice number"
}
}
},
"PayPeriodEntity": {
"type": "object",
"properties": {

View File

@ -0,0 +1,51 @@
import { Controller, Get, Header, Query } from "@nestjs/common";
import { ShiftsValidationService, ValidationRow } from "../services/shifts-validation.service";
import { GetShiftsValidationDto } from "../dtos/get-shifts-validation.dto";
@Controller()
export class ShiftsValidationController {
constructor(private readonly shiftsValidationService: ShiftsValidationService) {}
@Get()
async getSummary( @Query() query: GetShiftsValidationDto): Promise<ValidationRow[]> {
return this.shiftsValidationService.getSummary(query.periodId);
}
@Get('export.csv')
@Header('Content-Type', 'text/csv; charset=utf-8')
@Header('Content-Disposition', 'attachment; filename="shifts-validation.csv"')
async exportCsv(@Query() query: GetShiftsValidationDto): Promise<Buffer>{
const rows = await this.shiftsValidationService.getSummary(query.periodId);
//CSV Headers
const header = [
'fullName',
'supervisor',
'totalRegularHrs',
'totalEveningHrs',
'totalOvertimeHrs',
'totalExpenses',
'totalMileage',
'isValidated'
].join(',') + '\n';
//CSV rows
const body = rows.map(r => {
const esc = (str: string) => `"${str.replace(/"/g, '""')}"`;
return [
esc(r.fullName),
esc(r.supervisor),
r.totalRegularHrs.toFixed(2),
r.totalEveningHrs.toFixed(2),
r.totalOvertimeHrs.toFixed(2),
r.totalExpenses.toFixed(2),
r.totalMileage.toFixed(2),
r.isValidated,
].join(',');
}).join('\n');
return Buffer.from(header + body, 'utf8');
}
}

View File

@ -0,0 +1,10 @@
import { Type } from "class-transformer";
import { IsInt, Min, Max } from "class-validator";
export class GetShiftsValidationDto {
@Type(()=> Number)
@IsInt()
@Min(1)
@Max(26)
periodId: number;
}

View File

@ -0,0 +1,122 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
export interface ValidationRow {
fullName: string;
supervisor: string;
totalRegularHrs: number;
totalEveningHrs: number;
totalOvertimeHrs: number;
totalExpenses: number;
totalMileage: number;
isValidated: boolean;
}
@Injectable()
export class ShiftsValidationService {
constructor(private readonly prisma: PrismaService) {}
private computeHours(start: Date, end: Date): number {
const diffMs = end.getTime() - start.getTime();
const hours = diffMs / 1000 / 3600;
return parseFloat(hours.toFixed(2));
}
async getSummary(periodId: number): Promise<ValidationRow[]> {
//fetch pay-period to display
const period = await this.prisma.payPeriods.findUnique({
where: { period_number: periodId },
});
if(!period) {
throw new NotFoundException(`pay-period ${periodId} not found`);
}
const { start_date, end_date } = period;
//prepare shifts and expenses for display
const shifts = await this.prisma.shifts.findMany({
where: { date: { gte: start_date, lte: end_date } },
include: {
bank_code: true,
timesheet: { include: { employee: {
include: { user:true,
supervisor: { include: { user: true } },
} },
} },
},
});
const expenses = await this.prisma.expenses.findMany({
where: { date: { gte: start_date, lte: end_date } },
include: {
bank_code: true,
timesheet: { include: { employee: {
include: { user:true,
supervisor: { include: { user:true } },
} },
} },
},
});
const mapRow = new Map<string, ValidationRow>();
for(const s of shifts) {
const employeeId = s.timesheet.employee.user_id;
const user = s.timesheet.employee.user;
const sup = s.timesheet.employee.supervisor?.user;
let row = mapRow.get(employeeId);
if(!row) {
row = {
fullName: `${user.first_name} ${user.last_name}`,
supervisor: sup? `${sup.first_name} ${sup.last_name }` : '',
totalRegularHrs: 0,
totalEveningHrs: 0,
totalOvertimeHrs: 0,
totalExpenses: 0,
totalMileage: 0,
isValidated: false,
};
}
const hours = this.computeHours(s.start_time, s.end_time);
switch(s.bank_code.type) {
case 'regular' : row.totalRegularHrs += hours;
break;
case 'evening' : row.totalEveningHrs += hours;
break;
case 'overtime' : row.totalOvertimeHrs += hours;
break;
default: row.totalRegularHrs += hours;
}
mapRow.set(employeeId, row);
}
for(const e of expenses) {
const employeeId = e.timesheet.employee.user_id;
const user = e.timesheet.employee.user;
const sup = e.timesheet.employee.supervisor?.user;
let row = mapRow.get(employeeId);
if(!row) {
row = {
fullName: `${user.first_name} ${user.last_name}`,
supervisor: sup? `${sup.first_name} ${sup.last_name }` : '',
totalRegularHrs: 0,
totalEveningHrs: 0,
totalOvertimeHrs: 0,
totalExpenses: 0,
totalMileage: 0,
isValidated: false,
};
}
const amount = Number(e.amount);
row.totalExpenses += amount;
if(e.bank_code.type === 'mileage') {
row.totalMileage += amount;
}
mapRow.set(employeeId, row);
}
//return by default the list of employee in ascending alphabetical order
return Array.from(mapRow.values()).sort((a,b) => a.fullName.localeCompare(b.fullName));
}
}

View File

@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { ShiftsValidationController } from "./controllers/shifts-validation.controller";
import { ShiftsValidationService } from "./services/shifts-validation.service";
import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module";
@Module({
imports: [BusinessLogicsModule],
controllers: [ShiftsValidationController],
providers: [ShiftsValidationService],
})
export class ShiftsValidationModule {}