diff --git a/prisma/migrations/20250804192610_rename_o_auth_table/migration.sql b/prisma/migrations/20250804192610_rename_o_auth_table/migration.sql new file mode 100644 index 0000000..82c4572 --- /dev/null +++ b/prisma/migrations/20250804192610_rename_o_auth_table/migration.sql @@ -0,0 +1,37 @@ +/* + Warnings: + + - You are about to drop the `refresh_tokens` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "refresh_tokens" DROP CONSTRAINT "refresh_tokens_user_id_fkey"; + +-- DropTable +DROP TABLE "refresh_tokens"; + +-- CreateTable +CREATE TABLE "oauth_sessions" ( + "id" TEXT NOT NULL, + "user_id" UUID NOT NULL, + "application" TEXT NOT NULL, + "access_token" TEXT NOT NULL, + "refresh_token" TEXT NOT NULL, + "access_token_expiry" TIMESTAMP(3) NOT NULL, + "refresh_token_expiry" TIMESTAMP(3), + "is_revoked" BOOLEAN NOT NULL DEFAULT false, + "scopes" JSONB NOT NULL DEFAULT '[]', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3), + + CONSTRAINT "oauth_sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "oauth_sessions_access_token_key" ON "oauth_sessions"("access_token"); + +-- CreateIndex +CREATE UNIQUE INDEX "oauth_sessions_refresh_token_key" ON "oauth_sessions"("refresh_token"); + +-- AddForeignKey +ALTER TABLE "oauth_sessions" ADD CONSTRAINT "oauth_sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20250804192837_added_sid_column_to_o_auth_sessions/migration.sql b/prisma/migrations/20250804192837_added_sid_column_to_o_auth_sessions/migration.sql new file mode 100644 index 0000000..abce895 --- /dev/null +++ b/prisma/migrations/20250804192837_added_sid_column_to_o_auth_sessions/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - A unique constraint covering the columns `[sid]` on the table `oauth_sessions` will be added. If there are existing duplicate values, this will fail. + - Added the required column `sid` to the `oauth_sessions` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "oauth_sessions" ADD COLUMN "sid" TEXT NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "oauth_sessions_sid_key" ON "oauth_sessions"("sid"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index edb3ca1..3d317ee 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -25,7 +25,7 @@ model Users { employee Employees? @relation("UserEmployee") customer Customers? @relation("UserCustomer") - oauth_access_tokens OAuthAccessTokens[] @relation("UserOAuthAccessToken") + oauth_sessions OAuthSessions[] @relation("UserOAuthSessions") employees_archive EmployeesArchive[] @relation("UsersToEmployeesToArchive") customer_archive CustomersArchive[] @relation("UserToCustomersToArchive") @@ -245,13 +245,14 @@ model ExpensesArchive { @@map("expenses_archive") } -model OAuthAccessTokens { +model OAuthSessions { id String @id @default(cuid()) - user Users @relation("UserOAuthAccessToken", fields: [user_id], references: [id]) + user Users @relation("UserOAuthSessions", fields: [user_id], references: [id]) user_id String @db.Uuid application String access_token String @unique refresh_token String @unique + sid String @unique access_token_expiry DateTime refresh_token_expiry DateTime? is_revoked Boolean @default(false) @@ -259,7 +260,7 @@ model OAuthAccessTokens { created_at DateTime @default(now()) updated_at DateTime? - @@map("refresh_tokens") + @@map("oauth_sessions") } enum Roles { diff --git a/src/modules/exports/controllers/csv-exports.controller.ts b/src/modules/exports/controllers/csv-exports.controller.ts new file mode 100644 index 0000000..60de449 --- /dev/null +++ b/src/modules/exports/controllers/csv-exports.controller.ts @@ -0,0 +1,45 @@ +import { Controller, Get, Header, Query, UseGuards } from "@nestjs/common"; +import { RolesGuard } from "src/common/guards/roles.guard"; +import { Roles as RoleEnum } from '.prisma/client'; +import { CsvExportService } from "../services/csv-exports.service"; +import { ExportCompany, ExportCsvOptionsDto, ExportType } from "../dtos/export-csv-options.dto"; +import { RolesAllowed } from "src/common/decorators/roles.decorators"; + + +@Controller('exports') +@UseGuards(RolesGuard) +export class CsvExportController { + constructor(private readonly csvService: CsvExportService) {} + + @Get('csv') + @Header('Content-Type', 'text/csv; charset=utf-8') + @Header('Content-Dispoition', 'attachment; filename="export.csv"') + @RolesAllowed(RoleEnum.ADMIN, RoleEnum.ACCOUNTING, RoleEnum.HR) + async exportCsv(@Query() options: ExportCsvOptionsDto, + @Query('period') periodId: string ): Promise { + + //sets default values + const companies = options.companies && options.companies.length ? options.companies : + [ ExportCompany.TARGO, ExportCompany.SOLUCOM]; + const types = options.type && options.type.length ? options.type : + Object.values(ExportType); + + //collects all + const all = await this.csvService.collectTransaction(Number(periodId), companies); + + //filters by type + const filtered = all.filter(r => { + switch (r.bankCode.toLocaleLowerCase()) { + case 'holiday' : return types.includes(ExportType.HOLIDAY); + case 'vacation' : return types.includes(ExportType.VACATION); + case 'sick-leave': return types.includes(ExportType.SICK_LEAVE); + case 'expenses' : return types.includes(ExportType.EXPENSES); + default : return types.includes(ExportType.SHIFTS); + } + }); + + //generating the csv file + return this.csvService.generateCsv(filtered); + } + +} \ No newline at end of file diff --git a/src/modules/exports/csv-exports.module.ts b/src/modules/exports/csv-exports.module.ts new file mode 100644 index 0000000..92a5a96 --- /dev/null +++ b/src/modules/exports/csv-exports.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { CsvExportController } from "./controllers/csv-exports.controller"; +import { CsvExportService } from "./services/csv-exports.service"; + +@Module({ + providers:[CsvExportService], + controllers: [CsvExportController], +}) +export class CsvExportModule {} + + diff --git a/src/modules/exports/dtos/export-csv-options.dto.ts b/src/modules/exports/dtos/export-csv-options.dto.ts new file mode 100644 index 0000000..dc969ad --- /dev/null +++ b/src/modules/exports/dtos/export-csv-options.dto.ts @@ -0,0 +1,26 @@ +import { IsArray, IsEnum, IsOptional } from "class-validator"; + +export enum ExportType { + SHIFTS = 'Quart de travail', + EXPENSES = 'Depenses', + HOLIDAY = 'Ferie', + VACATION = 'Vacance', + SICK_LEAVE = 'Absence' +} + +export enum ExportCompany { + TARGO = 'Targo', + SOLUCOM = 'Solucom', +} + +export class ExportCsvOptionsDto { + @IsOptional() + @IsArray() + @IsEnum(ExportCompany, { each: true }) + companies?: ExportCompany[]; + + @IsOptional() + @IsArray() + @IsEnum(ExportType, { each: true }) + type?: ExportType[]; +} \ No newline at end of file diff --git a/src/modules/exports/services/csv-exports.service.ts b/src/modules/exports/services/csv-exports.service.ts new file mode 100644 index 0000000..30c15c1 --- /dev/null +++ b/src/modules/exports/services/csv-exports.service.ts @@ -0,0 +1,188 @@ +import { PrismaService } from "src/prisma/prisma.service"; +import { ExportCompany } from "../dtos/export-csv-options.dto"; +import { Injectable, NotFoundException } from "@nestjs/common"; + +export interface CsvRow { + companyCode: number; + externalPayrollId: number; + fullName: string; + bankCode: string; + quantityHours?: number; + amount?: number; + weekNumber: number; + payDate: string; + holidayDate?: string; +} + +@Injectable() +export class CsvExportService { + constructor(private readonly prisma: PrismaService) {} + + async collectTransaction(periodId: number, companies: ExportCompany[]): Promise { + const companyCodes = companies.map(c => c === ExportCompany.TARGO ? 1 : 2); + + const period = await this.prisma.payPeriods.findUnique({ + where: { period_number: periodId }, + }); + if(!period) { + throw new NotFoundException(`Pay period ${periodId} not found`); + } + + const startDate = period.start_date; + const endDate = period.end_date; + + //fetching shifts + const shifts = await this.prisma.shifts.findMany({ + where: { date: { gte: startDate, lte: endDate }, + timesheet: { employee: { company_code: { in: companyCodes} } }, + }, + include: { bank_code: true, + timesheet: { include: {employee: { include: { user:true, + supervisor: { include: { user:true } }, + }}, + }}, + }, + }); + + //fetching expenses + const expenses = await this.prisma.expenses.findMany({ + where: { date: { gte: startDate, lte: endDate }, + timesheet: { employee: { company_code: { in: companyCodes} } }, + }, + include: { bank_code: true, + timesheet: { include: { employee: { include: { user: true, + supervisor: { include: { user:true } }, + } }, + } }, + }, + }); + + //fetching leave requests + const leaves = await this.prisma.leaveRequests.findMany({ + where : { start_date_time: { gte: startDate, lte: endDate }, + employee: { company_code: { in: companyCodes } }, + }, + include: { bank_code: true, + employee: { include: { user: true, + supervisor: { include: { user: true } }, + }}, + }, + }); + + const rows: CsvRow[] = []; + + //Shifts Mapping + for (const s of shifts) { + const emp = s.timesheet.employee; + const weekNumber = this.computeWeekNumber(startDate, s.date); + const hours = this.computeHours(s.start_time, s.end_time); + + rows.push({ + companyCode: emp.company_code, + externalPayrollId: emp.external_payroll_id, + fullName: `${emp.user.first_name} ${emp.user.last_name}`, + bankCode: s.bank_code.bank_code, + quantityHours: hours, + amount: undefined, + weekNumber, + payDate: this.formatDate(endDate), + holidayDate: undefined, + }); + } + + //Expenses Mapping + for (const e of expenses) { + const emp = e.timesheet.employee; + const weekNumber = this.computeWeekNumber(startDate, e.date); + + rows.push({ + companyCode: emp.company_code, + externalPayrollId: emp.external_payroll_id, + fullName: `${emp.user.first_name} ${emp.user.last_name}`, + bankCode: e.bank_code.bank_code, + quantityHours: undefined, + amount: Number(e.amount), + weekNumber, + payDate: this.formatDate(endDate), + holidayDate: undefined, + }); + } + + //Leaves Mapping + for(const l of leaves) { + if(!l.bank_code) continue; + const emp = l.employee; + const start = l.start_date_time; + const end = l.end_date_time ?? start; + + const weekNumber = this.computeWeekNumber(startDate, start); + const hours = this.computeHours(start, end); + + rows.push({ + companyCode: emp.company_code, + externalPayrollId: emp.external_payroll_id, + fullName: `${emp.user.first_name} ${emp.user.last_name}`, + bankCode: l.bank_code.bank_code, + quantityHours: hours, + amount: undefined, + weekNumber, + payDate: this.formatDate(endDate), + holidayDate: undefined, + }); + } + + //Final Mapping and sorts + return rows.sort((a,b) => { + if(a.externalPayrollId !== b.externalPayrollId) { + return a.externalPayrollId - b.externalPayrollId; + } + if(a.bankCode !== b.bankCode) { + return a.bankCode.localeCompare(b.bankCode); + } + return a.weekNumber - b.weekNumber; + }); + } + + generateCsv(rows: CsvRow[]): Buffer { + const header = [ + 'companyCode', + 'externalPayrolId', + 'fullName', + 'bankCode', + 'quantityHours', + 'amount', + 'weekNumber', + 'payDate', + 'holidayDate', + ].join(',') + '\n'; + + const body = rows.map(r => [ + r.companyCode, + r.externalPayrollId, + `${r.fullName.replace(/"/g, '""')}"`, + r.bankCode, + r.quantityHours?.toFixed(2) ?? '', + r.weekNumber, + r.payDate, + r.holidayDate ?? '', + ].join(',')).join('\n'); + + return Buffer.from('\uFEFF' + header + body, 'utf8'); + } + + + private computeHours(start: Date, end: Date): number { + const diffMs = end.getTime() - start.getTime(); + return +(diffMs / 1000 / 3600).toFixed(2); + } + + private computeWeekNumber(start: Date, date: Date): number { + const days = Math.floor((date.getTime() - start.getTime()) / (1000*60*60*24)); + return Math.floor(days / 7 ) + 1; + } + + private formatDate(d:Date): string { + return d.toISOString().split('T')[0]; + } + +}