diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index cb484fd..d3f50c3 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -514,7 +514,7 @@ ] } }, - "/employees": { + "/employees/create": { "post": { "operationId": "EmployeesController_createEmployee", "parameters": [], @@ -538,6 +538,20 @@ ] } }, + "/employees/update": { + "patch": { + "operationId": "EmployeesController_updateEmployee", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Employees" + ] + } + }, "/preferences/update": { "patch": { "operationId": "PreferencesController_updatePreferences", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 779dc74..5dc2348 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -348,7 +348,7 @@ model Preferences { user_id String @unique @db.Uuid notifications Boolean @default(true) - is_dark_mode Boolean @default(false) + is_dark_mode Boolean? @default(false) display_language String @default("fr-FR") //'fr-FR' | 'en-CA'; is_lefty_mode Boolean @default(false) is_employee_list_grid Boolean @default(true) diff --git a/src/common/shared/role-groupes.ts b/src/common/shared/role-groupes.ts index 7f769c4..dadeaa9 100644 --- a/src/common/shared/role-groupes.ts +++ b/src/common/shared/role-groupes.ts @@ -1,20 +1,20 @@ -import { Roles as RoleEnum } from ".prisma/client"; +// import { Roles as RoleEnum } from ".prisma/client"; -export const GLOBAL_CONTROLLER_ROLES: readonly RoleEnum[] = [ - RoleEnum.EMPLOYEE, - RoleEnum.ACCOUNTING, - RoleEnum.HR, - RoleEnum.SUPERVISOR, - RoleEnum.ADMIN, -]; +// export const GLOBAL_CONTROLLER_ROLES: readonly RoleEnum[] = [ +// RoleEnum.EMPLOYEE, +// RoleEnum.ACCOUNTING, +// RoleEnum.HR, +// RoleEnum.SUPERVISOR, +// RoleEnum.ADMIN, +// ]; -export const MANAGER_ROLES: readonly RoleEnum[] = [ - RoleEnum.HR, - RoleEnum.SUPERVISOR, - RoleEnum.ADMIN, -] +// export const MANAGER_ROLES: readonly RoleEnum[] = [ +// RoleEnum.HR, +// RoleEnum.SUPERVISOR, +// RoleEnum.ADMIN, +// ] -export const PAY_SERVICE: readonly RoleEnum[] = [ - RoleEnum.HR, - RoleEnum.ACCOUNTING, -] \ No newline at end of file +// export const PAY_SERVICE: readonly RoleEnum[] = [ +// RoleEnum.HR, +// RoleEnum.ACCOUNTING, +// ] \ No newline at end of file diff --git a/src/identity-and-account/employees/controllers/employees.controller.ts b/src/identity-and-account/employees/controllers/employees.controller.ts index d1ce485..dc88bac 100644 --- a/src/identity-and-account/employees/controllers/employees.controller.ts +++ b/src/identity-and-account/employees/controllers/employees.controller.ts @@ -1,54 +1,50 @@ -import { Controller, Get, Query, Body, Post } from "@nestjs/common"; +import { Controller, Get, Query, Body, Post, Patch } from "@nestjs/common"; import { Access } from "src/common/decorators/module-access.decorators"; import { Result } from "src/common/errors/result-error.factory"; import { EmployeeDetailedDto } from "src/identity-and-account/employees/dtos/employee-detailed.dto"; import { EmployeeDto } from "src/identity-and-account/employees/dtos/employee.dto"; -import { EmployeesService } from "src/identity-and-account/employees/services/employees.service"; +import { EmployeesGetService } from "src/identity-and-account/employees/services/employees-get.service"; import { ModuleAccessAllowed } from "src/common/decorators/modules-guard.decorators"; import { Modules as ModulesEnum } from ".prisma/client"; - -//TODO: create a custom decorator to replace the findModuleAcces call function +import { EmployeesCreateService } from "src/identity-and-account/employees/services/employees-create.service"; +import { EmployeesUpdateService } from "src/identity-and-account/employees/services/employees-update.service"; @Controller('employees') export class EmployeesController { - constructor(private readonly employeesService: EmployeesService) { } + constructor( + private readonly getService: EmployeesGetService, + private readonly createService: EmployeesCreateService, + private readonly updateService: EmployeesUpdateService, + ) { } @Get('personal-profile') @ModuleAccessAllowed(ModulesEnum.personal_profile) async findOwnProfile(@Access('email') email: string): Promise, string>> { - return await this.employeesService.findOwnProfile(email); + return await this.getService.findOwnProfile(email); } @Get('profile') @ModuleAccessAllowed(ModulesEnum.employee_management) async findProfile(@Access('email') email: string, @Query('employee_email') employee_email?: string, ): Promise, string>> { - return await this.employeesService.findOneDetailedProfile(email, employee_email); + return await this.getService.findOneDetailedProfile(email, employee_email); } @Get('employee-list') @ModuleAccessAllowed(ModulesEnum.employee_list) async findListEmployees(): Promise> { - return this.employeesService.findListEmployees(); + return this.getService.findListEmployees(); } - @Post() + @Post('create') @ModuleAccessAllowed(ModulesEnum.employee_management) async createEmployee(@Body() dto: EmployeeDetailedDto): Promise> { - return await this.employeesService.createEmployee(dto); + return await this.createService.createEmployee(dto); } - // @Patch() - // async updateOrArchiveOrRestore(@Req() req, @Body() dto: UpdateEmployeeDto,) { - // // if last_work_day is set => archive the employee - // // else if employee is archived and first_work_day or last_work_day = null => restore - // //otherwise => standard update - // const email = req.user?.email; - // const result = await this.archiveService.patchEmployee(email, dto); - // if (!result) { - // throw new NotFoundException(`Employee with email: ${email} is not found in active or archive.`) - // } - // return result; - // - + @Patch('update') + @ModuleAccessAllowed(ModulesEnum.employee_management) + async updateEmployee(@Access('email') email:string, dto:EmployeeDetailedDto, employee_email?: string){ + return await this.updateService.updateEmployee(email, dto, employee_email); + } } diff --git a/src/identity-and-account/employees/employees.module.ts b/src/identity-and-account/employees/employees.module.ts index 9ad88eb..5b65c72 100644 --- a/src/identity-and-account/employees/employees.module.ts +++ b/src/identity-and-account/employees/employees.module.ts @@ -1,13 +1,21 @@ import { Module } from '@nestjs/common'; import { EmployeesController } from './controllers/employees.controller'; -import { EmployeesService } from './services/employees.service'; +import { EmployeesGetService } from './services/employees-get.service'; import { AccessGetService } from 'src/identity-and-account/user-module-access/services/module-access-get.service'; import { EmailToIdResolver } from 'src/common/mappers/email-id.mapper'; +import { EmployeesUpdateService } from 'src/identity-and-account/employees/services/employees-update.service'; +import { EmployeesCreateService } from 'src/identity-and-account/employees/services/employees-create.service'; @Module({ imports: [], controllers: [EmployeesController], - providers: [EmployeesService, AccessGetService, EmailToIdResolver], - exports: [EmployeesService ], + providers: [ + EmployeesGetService, + EmployeesUpdateService, + EmployeesCreateService, + AccessGetService, + EmailToIdResolver + ], + exports: [EmployeesGetService], }) -export class EmployeesModule {} +export class EmployeesModule { } diff --git a/src/identity-and-account/employees/services/employees-create.service.ts b/src/identity-and-account/employees/services/employees-create.service.ts new file mode 100644 index 0000000..16d2d0d --- /dev/null +++ b/src/identity-and-account/employees/services/employees-create.service.ts @@ -0,0 +1,61 @@ +import { Injectable } from "@nestjs/common"; +import { Users } from "@prisma/client"; +import { Result } from "src/common/errors/result-error.factory"; +import { toBooleanFromString } from "src/common/mappers/module-access.mapper"; +import { EmployeeDetailedDto } from "src/identity-and-account/employees/dtos/employee-detailed.dto"; +import { toCompanyCodeFromString } from "src/identity-and-account/employees/utils/employee.utils"; +import { PrismaService } from "src/prisma/prisma.service"; + +@Injectable() +export class EmployeesCreateService { + constructor(private readonly prisma: PrismaService) { } + + async createEmployee(dto: EmployeeDetailedDto): Promise> { + const normalized_access = toBooleanFromString(dto.user_module_access); + const supervisor_id = await this.toIdFromFullName(dto.supervisor_full_name); + const company_code = toCompanyCodeFromString(dto.company_name) + await this.prisma.$transaction(async (tx) => { + const user: Users = await tx.users.create({ + data: { + first_name: dto.first_name, + last_name: dto.last_name, + email: dto.email, + phone_number: dto.phone_number, + residence: dto.residence, + user_module_access: { + create: { + dashboard: normalized_access.dashboard, + employee_list: normalized_access.employee_list, + employee_management: normalized_access.employee_management, + personal_profile: normalized_access.personal_profile, + timesheets: normalized_access.timesheets, + timesheets_approval: normalized_access.timesheets_approval, + }, + }, + }, + }); + return tx.employees.create({ + data: { + user_id: user.id, + company_code: company_code, + job_title: dto.job_title, + first_work_day: dto.first_work_day, + last_work_day: dto.last_work_day, + is_supervisor: dto.is_supervisor, + supervisor_id: supervisor_id, + }, + }); + }); + return { success: true, data: true } + } + + private toIdFromFullName = async (full_name: string) => { + const [first_name, last_name] = full_name.split(' ', 2); + let supervisor = await this.prisma.users.findFirst({ + where: { first_name, last_name }, + select: { employee: { select: { id: true } } } + }); + if (!supervisor) supervisor = null; + return supervisor?.employee?.id; + } +} \ No newline at end of file diff --git a/src/identity-and-account/employees/services/employees.service.ts b/src/identity-and-account/employees/services/employees-get.service.ts similarity index 76% rename from src/identity-and-account/employees/services/employees.service.ts rename to src/identity-and-account/employees/services/employees-get.service.ts index 0ebaed4..619474d 100644 --- a/src/identity-and-account/employees/services/employees.service.ts +++ b/src/identity-and-account/employees/services/employees-get.service.ts @@ -1,15 +1,15 @@ import { Injectable } from "@nestjs/common"; -import { Users } from "@prisma/client"; import { Result } from "src/common/errors/result-error.factory"; import { EmailToIdResolver } from "src/common/mappers/email-id.mapper"; -import { module_list, Modules, toBooleanFromString, toStringFromBoolean } from "src/common/mappers/module-access.mapper"; +import { Modules, toStringFromBoolean } from "src/common/mappers/module-access.mapper"; import { toStringFromDate } from "src/common/utils/date-utils"; import { EmployeeDetailedDto } from "src/identity-and-account/employees/dtos/employee-detailed.dto"; import { EmployeeDto } from "src/identity-and-account/employees/dtos/employee.dto"; +import { toStringFromCompanyCode } from "src/identity-and-account/employees/utils/employee.utils"; import { PrismaService } from "src/prisma/prisma.service"; @Injectable() -export class EmployeesService { +export class EmployeesGetService { constructor( private readonly prisma: PrismaService, private readonly emailResolver: EmailToIdResolver, @@ -88,11 +88,7 @@ export class EmployeesService { }); if (!existing_profile) return { success: false, error: 'EMPLOYEE_NOT_FOUND' }; - let company_name = 'Solucom'; - if (existing_profile.company_code === 271583) { - company_name = 'Targo'; - } - + const company_name = toStringFromCompanyCode(existing_profile.company_code); return { success: true, data: { first_name: existing_profile.user.first_name, @@ -159,7 +155,7 @@ export class EmployeesService { let module_access_array: Modules[] = []; if (employee.user.user_module_access) { - module_access_array = toStringFromBoolean(employee.user.user_module_access); + module_access_array = toStringFromBoolean(employee.user.user_module_access); } let company_name = 'Solucom'; @@ -186,49 +182,4 @@ export class EmployeesService { }, }; } - - async createEmployee(dto: EmployeeDetailedDto): Promise> { - let company_code = 271585; - if (dto.company_name === 'Targo') { - company_code = 271583; - } - const normalized_access = toBooleanFromString(dto.user_module_access) - - await this.prisma.$transaction(async (tx) => { - const user: Users = await tx.users.create({ - data: { - first_name: dto.first_name, - last_name: dto.last_name, - email: dto.email, - phone_number: dto.phone_number, - residence: dto.residence, - user_module_access: { - create: { - dashboard: normalized_access.dashboard, - employee_list: normalized_access.employee_list, - employee_management: normalized_access.employee_management, - personal_profile: normalized_access.personal_profile, - timesheets: normalized_access.timesheets, - timesheets_approval: normalized_access.timesheets_approval, - }, - }, - }, - }); - return tx.employees.create({ - data: { - user_id: user.id, - company_code: company_code, - job_title: dto.job_title, - first_work_day: dto.first_work_day, - last_work_day: dto.last_work_day, - is_supervisor: dto.is_supervisor, - }, - }) - }); - return { success: true, data: true } - } - - // async updateEmployeeProfile = () => { - - // } } \ No newline at end of file diff --git a/src/identity-and-account/employees/services/employees-update.service.ts b/src/identity-and-account/employees/services/employees-update.service.ts new file mode 100644 index 0000000..aca67e7 --- /dev/null +++ b/src/identity-and-account/employees/services/employees-update.service.ts @@ -0,0 +1,72 @@ +import { Injectable } from "@nestjs/common"; +import { Result } from "src/common/errors/result-error.factory"; +import { EmailToIdResolver } from "src/common/mappers/email-id.mapper"; +import { toBooleanFromString } from "src/common/mappers/module-access.mapper"; +import { EmployeeDetailedDto } from "src/identity-and-account/employees/dtos/employee-detailed.dto"; +import { toCompanyCodeFromString } from "src/identity-and-account/employees/utils/employee.utils"; +import { PrismaService } from "src/prisma/prisma.service"; + +@Injectable() +export class EmployeesUpdateService { + constructor( + private readonly prisma: PrismaService, + private readonly emailResolver: EmailToIdResolver, + ) { } + + async updateEmployee(email: string, dto: EmployeeDetailedDto, employee_email?: string): Promise> { + const account_email = employee_email ?? email; + const user_id = await this.emailResolver.resolveUserIdWithEmail(account_email); + if (!user_id.success) return { success: false, error: 'EMPLOYEE_NOT_FOUND' } + + const company_code = toCompanyCodeFromString(dto.company_name); + const supervisor_id = await this.toIdFromFullName(dto.supervisor_full_name); + const normalized_access = await toBooleanFromString(dto.user_module_access); + + await this.prisma.$transaction(async (tx) => { + await tx.users.update({ + where: { id: user_id.data }, + data: { + first_name: dto.first_name, + last_name: dto.last_name, + email: dto.email, + phone_number: dto.phone_number, + residence: dto.residence, + user_module_access: { + update: { + dashboard: normalized_access.dashboard, + employee_list: normalized_access.employee_list, + employee_management: normalized_access.employee_management, + personal_profile: normalized_access.personal_profile, + timesheets: normalized_access.timesheets, + timesheets_approval: normalized_access.timesheets_approval, + }, + }, + }, + }); + return tx.employees.update({ + where: { user_id: user_id.data }, + data: { + company_code: company_code, + job_title: dto.job_title, + first_work_day: dto.first_work_day, + last_work_day: dto.last_work_day, + is_supervisor: dto.is_supervisor, + supervisor_id: supervisor_id, + }, + }); + + }); + return { success: true, data: true }; + + } + + private toIdFromFullName = async (full_name: string) => { + const [first_name, last_name] = full_name.split(' ', 2); + let supervisor = await this.prisma.users.findFirst({ + where: { first_name, last_name }, + select: { employee: { select: { id: true } } } + }); + if (!supervisor) supervisor = null; + return supervisor?.employee?.id; + } +} \ No newline at end of file diff --git a/src/identity-and-account/employees/utils/employee.utils.ts b/src/identity-and-account/employees/utils/employee.utils.ts index 3534f3d..79acdc4 100644 --- a/src/identity-and-account/employees/utils/employee.utils.ts +++ b/src/identity-and-account/employees/utils/employee.utils.ts @@ -6,4 +6,20 @@ export function toDateOrNull(v?: string | null): Date | null { export function toDateOrUndefined(v?: string | null): Date | undefined { const day = toDateOrNull(v ?? undefined); return day === null ? undefined : day; +} + +export function toCompanyCodeFromString(company_name: string) { + let company_code = 271585; + if (company_name === 'Targo') { + company_code = 271583; + } + return company_code; +} + +export function toStringFromCompanyCode(company_code: number) { + let company_name = 'Solucom'; + if (company_code === 271583) { + company_name = 'Targo'; + } + return company_name; } \ No newline at end of file diff --git a/src/identity-and-account/identity-and-account.module.ts b/src/identity-and-account/identity-and-account.module.ts index 076c176..b67a3f8 100644 --- a/src/identity-and-account/identity-and-account.module.ts +++ b/src/identity-and-account/identity-and-account.module.ts @@ -2,7 +2,7 @@ import { Module } from "@nestjs/common"; import { EmailToIdResolver } from "src/common/mappers/email-id.mapper"; import { EmployeesController } from "src/identity-and-account/employees/controllers/employees.controller"; import { EmployeesModule } from "src/identity-and-account/employees/employees.module"; -import { EmployeesService } from "src/identity-and-account/employees/services/employees.service"; +import { EmployeesGetService } from "src/identity-and-account/employees/services/employees-get.service"; import { PreferencesController } from "src/identity-and-account/preferences/controllers/preferences.controller"; import { PreferencesModule } from "src/identity-and-account/preferences/preferences.module"; import { PreferencesService } from "src/identity-and-account/preferences/services/preferences.service"; @@ -12,6 +12,8 @@ import { AccessGetService } from "src/identity-and-account/user-module-access/se import { AccessUpdateService } from "src/identity-and-account/user-module-access/services/module-access-update.service"; import { UsersService } from "src/identity-and-account/users-management/services/users.service"; import { UsersModule } from "src/identity-and-account/users-management/users.module"; +import { EmployeesCreateService } from "src/identity-and-account/employees/services/employees-create.service"; +import { EmployeesUpdateService } from "src/identity-and-account/employees/services/employees-update.service"; @Module({ imports: [ @@ -26,7 +28,9 @@ import { UsersModule } from "src/identity-and-account/users-management/users.mod ModuleAccessController, ], providers: [ - EmployeesService, + EmployeesGetService, + EmployeesCreateService, + EmployeesUpdateService, PreferencesService, UsersService, EmailToIdResolver, diff --git a/src/identity-and-account/preferences/controllers/preferences.controller.ts b/src/identity-and-account/preferences/controllers/preferences.controller.ts index b1aa246..a426c26 100644 --- a/src/identity-and-account/preferences/controllers/preferences.controller.ts +++ b/src/identity-and-account/preferences/controllers/preferences.controller.ts @@ -1,20 +1,24 @@ -import { Body, Controller, Get, Patch, Query, Req } from "@nestjs/common"; +import { Body, Controller, Get, Patch, Query } from "@nestjs/common"; import { PreferencesService } from "../services/preferences.service"; import { PreferencesDto } from "../dtos/preferences.dto"; import { Result } from "src/common/errors/result-error.factory"; import { Access } from "src/common/decorators/module-access.decorators"; +import { ModuleAccessAllowed } from "src/common/decorators/modules-guard.decorators"; +import { Modules as ModulesEnum } from ".prisma/client"; @Controller('preferences') export class PreferencesController { constructor(private readonly service: PreferencesService) { } @Patch('update') + @ModuleAccessAllowed(ModulesEnum.personal_profile) async updatePreferences(@Access('email') email: string, @Body() payload: PreferencesDto ): Promise> { return this.service.updatePreferences(email, payload); } @Get() + @ModuleAccessAllowed(ModulesEnum.personal_profile) async findPreferences(@Access('email') email: string, @Query() employee_email?: string) { return this.service.findPreferences(email, employee_email); } diff --git a/src/identity-and-account/preferences/services/preferences.service.ts b/src/identity-and-account/preferences/services/preferences.service.ts index eda3523..cb7caf4 100644 --- a/src/identity-and-account/preferences/services/preferences.service.ts +++ b/src/identity-and-account/preferences/services/preferences.service.ts @@ -50,7 +50,7 @@ export class PreferencesService { where: { user_id: user_id.data }, data: { notifications: dto.notifications, - is_dark_mode: dto.is_dark_mode ?? undefined, + is_dark_mode: dto.is_dark_mode, display_language: dto.display_language, is_lefty_mode: dto.is_lefty_mode, is_employee_list_grid: dto.is_employee_list_grid, diff --git a/src/identity-and-account/user-module-access/controllers/module-access.controller.ts b/src/identity-and-account/user-module-access/controllers/module-access.controller.ts index 4f792a3..5e9ca0f 100644 --- a/src/identity-and-account/user-module-access/controllers/module-access.controller.ts +++ b/src/identity-and-account/user-module-access/controllers/module-access.controller.ts @@ -4,6 +4,8 @@ import { Result } from "src/common/errors/result-error.factory"; import { ModuleAccess } from "src/identity-and-account/user-module-access/dtos/module-acces.dto"; import { AccessGetService } from "src/identity-and-account/user-module-access/services/module-access-get.service"; import { AccessUpdateService } from "src/identity-and-account/user-module-access/services/module-access-update.service"; +import { ModuleAccessAllowed } from "src/common/decorators/modules-guard.decorators"; +import { Modules as ModulesEnum } from ".prisma/client"; @Controller('module_access') export class ModuleAccessController { @@ -13,24 +15,17 @@ export class ModuleAccessController { ) { } @Get() + @ModuleAccessAllowed(ModulesEnum.employee_management) async findAccess(@Access('email') email: string, @Query('employee_email') employee_email?: string ): Promise> { - const granted_access = await this.getService.findModuleAccess(email); - if (!granted_access.success) return { success: false, error: 'INVALID_USER' }; - if (!granted_access.data.employee_management) return { success: false, error: 'UNAUTHORIZED_ACCESS' }; - await this.getService.findModuleAccess(email, employee_email); return { success: true, data: true }; }; @Patch('update') + @ModuleAccessAllowed(ModulesEnum.employee_management) async updateAccess(@Access('email') email: string, @Body() dto: ModuleAccess, @Query('employee_email') employee_email?: string ): Promise> { - const granted_access = await this.getService.findModuleAccess(email); - if (!granted_access.success) return { success: false, error: 'INVALID_USER' }; - //check if credentials are enough to use this resource - if (!granted_access.data.employee_management) return { success: false, error: 'UNAUTHORIZED_ACCESS' }; - await this.updateService.updateModuleAccess(email, dto, employee_email); return { success: true, data: true }; }; diff --git a/src/modules/attachments/attachments.module.ts b/src/modules/attachments/attachments.module.ts index 9db15ea..06fa52f 100644 --- a/src/modules/attachments/attachments.module.ts +++ b/src/modules/attachments/attachments.module.ts @@ -1,22 +1,26 @@ -import { ArchivalAttachmentService } from "src/modules/attachments/services/archival-attachment.service"; +import { AttachmentArchivalService } from "src/modules/attachments/services/attachment-archival.service"; import { GarbargeCollectorService } from "src/modules/attachments/services/garbage-collector.service"; import { AttachmentsController } from "src/modules/attachments/controllers/attachments.controller"; import { DiskStorageService } from "src/modules/attachments/services/disk-storage.service"; -// import { ScheduleModule } from "@nestjs/schedule"; import { VariantsQueue } from "src/modules/attachments/services/variants.queue"; import { Module } from "@nestjs/common"; +import { AttachmentDeleteService } from "src/modules/attachments/services/attachment-delete.service"; +import { AttachmentUploadService } from "src/modules/attachments/services/attachment-upload.service"; +import { AttachmentGetService } from "src/modules/attachments/services/attachment-get.service"; @Module({ - // imports: [ScheduleModule.forRoot()], controllers: [ AttachmentsController], providers: [ - ArchivalAttachmentService, + AttachmentArchivalService, GarbargeCollectorService, DiskStorageService, VariantsQueue, + AttachmentDeleteService, + AttachmentUploadService, + AttachmentGetService, ], exports: [ - ArchivalAttachmentService, + AttachmentArchivalService, GarbargeCollectorService ], }) diff --git a/src/modules/attachments/config/upload.config.ts b/src/modules/attachments/config/upload.config.ts index 3684d01..c53194c 100644 --- a/src/modules/attachments/config/upload.config.ts +++ b/src/modules/attachments/config/upload.config.ts @@ -1,5 +1,6 @@ -export const maxUploadBytes = () => +export const maxUploadBytes = () => (Number(process.env.MAX_UPLOAD_MB || 25)) * 1024 * 1024; -export const allowedMimes = () => - (process.env.ALLOWED_MIME || 'image/jpeg,image/png,image/webp,application/pdf').split(',').map(s =>s.trim()).filter(Boolean); \ No newline at end of file +export const allowedMimes = () => + (process.env.ALLOWED_MIME || 'image/jpeg,image/png,image/webp,application/pdf') + .split(',').map(s => s.trim()).filter(Boolean); \ No newline at end of file diff --git a/src/modules/attachments/controllers/attachments.controller.ts b/src/modules/attachments/controllers/attachments.controller.ts index 963bb5c..70d97b7 100644 --- a/src/modules/attachments/controllers/attachments.controller.ts +++ b/src/modules/attachments/controllers/attachments.controller.ts @@ -1,212 +1,45 @@ +import { Controller, UseInterceptors, Post, Get, Param, Res, UploadedFile, Body, Delete, Query } from "@nestjs/common"; import { FileInterceptor } from "@nestjs/platform-express"; -import { DiskStorageService } from "../services/disk-storage.service"; -import { - Controller,NotFoundException, UseInterceptors, Post, Get, Param, Res, - UploadedFile, BadRequestException, UnsupportedMediaTypeException, Body, Delete, - Query, -} from "@nestjs/common"; -import { maxUploadBytes, allowedMimes } from "../config/upload.config"; -import { memoryStorage } from 'multer'; -import { fileTypeFromBuffer, fileTypeFromFile } from "file-type"; -import { Readable } from "node:stream"; -import { PrismaService } from "src/prisma/prisma.service"; +import { AttachmentDeleteService } from "src/modules/attachments/services/attachment-delete.service"; +import { AttachmentUploadService } from "src/modules/attachments/services/attachment-upload.service"; +import { AttachmentGetService } from "src/modules/attachments/services/attachment-get.service"; import { UploadMetaAttachmentsDto } from "../dtos/upload-meta-attachments.dto"; -import { resolveAttachmentsRoot } from "src/config/attachment.config"; -import * as path from 'node:path'; -import { promises as fsp } from 'node:fs'; -import { createReadStream } from "node:fs"; -import { Response } from 'express'; -import { VariantsQueue } from "../services/variants.queue"; -import { AdminSearchDto } from "../dtos/admin-search.dto"; +import { AdminSearchDto } from "../dtos/search-filters.dto"; +import { maxUploadBytes } from "../config/upload.config"; +import { memoryStorage } from 'multer'; +import { Response } from 'express'; @Controller('attachments') export class AttachmentsController { constructor( - private readonly disk: DiskStorageService, - private readonly prisma: PrismaService, - private readonly variantsQ: VariantsQueue, - ) {} + private readonly uploadService: AttachmentUploadService, + private readonly deleteService: AttachmentDeleteService, + private readonly getService: AttachmentGetService, + ) { } @Get(':id') - async getById( - @Param('id') id: string, - @Query('variant') variant: string | undefined, - @Res() res: Response, - ) { - const num_id = Number(id); - if(!Number.isFinite(num_id)) throw new NotFoundException('Invalid id'); - - const attachment = await this.prisma.attachments.findUnique({ - where: { id: num_id }, - include: { blob: true }, - }); - if (!attachment) throw new NotFoundException(); - - const relative = variant ? `${attachment.blob.storage_path}.${variant}` : attachment.blob.storage_path; - const abs = path.join(resolveAttachmentsRoot(), relative); - - let stat; - try { - stat = await fsp.stat(abs); - }catch { - throw new NotFoundException('File not found'); - } - - let mime = attachment.blob.mime; - try { - const kind = await fileTypeFromFile(abs); - if(kind?.mime) mime = kind.mime; - } catch {} - res.set('Content-Type', mime); - res.set('Content-Length', String(stat.size)); - res.set('ETag', `"sha256-${attachment.blob.sha256}${variant ? '.'+variant : ''}"`); - res.set('Last-Modified', stat.mtime.toUTCString()); - res.set('Cache-Control', 'private, max-age=31536000, immutable'); - res.set('X-Content-Type-Options', 'nosniff'); - - createReadStream(abs).pipe(res); + async getById(@Param('id') id: string, @Query('variant') variant: string | undefined, @Res() res: Response) { + return await this.getService.findAttachmentById(id, variant, res) } @Get('variants/:id') - async listVariants(@Param('id')id: string) { - const num_id = Number(id); - if(!Number.isFinite(num_id)) throw new NotFoundException('Invalid variant id'); - return this.prisma.attachmentVariants.findMany({ - where: { attachment_id: num_id }, - orderBy: { variant: 'asc'}, - select: { variant: true, bytes: true, width: true, height: true, path: true, created_at: true }, - }); + async getlistVariantsById(@Param('id') id: string) { + return await this.getService.getListVariants(id); } @Delete(':id') async remove(@Param('id') id: string) { - const result = await this.prisma.$transaction(async (tx) => { - const att = await tx.attachments.findUnique({ where: { id: Number(id) } }); - if (!att) throw new NotFoundException(); - - // soft-delete - await tx.attachments.update({ - where: { id: Number(id) }, - data: { status: 'DELETED' }, - }); - - // decrement refcount - const dec = await tx.$executeRaw - `UPDATE "Blobs" SET refcount = refcount - 1 - WHERE sha256 = ${att.sha256} AND refcount > 0` - ; - - return { ok: true, decremented: dec > 0 }; - }); - return result; + return await this.deleteService.deleteAttachment(id); } - @Post() - @UseInterceptors( - FileInterceptor('file', { storage: memoryStorage(), limits: { fileSize: maxUploadBytes() }}) - ) - async upload( - @UploadedFile() file?: Express.Multer.File, - @Body() meta?: UploadMetaAttachmentsDto, - ) { - if(!file) throw new BadRequestException('No file found'); - - //magic detection using binary signature - const kind = await fileTypeFromBuffer(file.buffer).catch(() => null); - const detected_mime = kind?.mime || file.mimetype || 'application/octet-stream'; - - //strict whitelist - if(!allowedMimes().includes(detected_mime)) { - throw new UnsupportedMediaTypeException(`This type is not supported: ${detected_mime}`); - } - - //Saving FS (hash + CAS + unDupes) - const stream = Readable.from(file.buffer); - const { sha256, storage_path, size } = await this.disk.saveStreamAndHash(stream); - - const now = new Date(); - const attachment = await this.prisma.$transaction(async (tx) => { - //upsert blob: +1 ref - await tx.blobs.upsert({ - where: { sha256 }, - create: { - sha256, - storage_path: storage_path, - size, - mime: detected_mime, - refcount: 1, - created_at: now, - }, - update: { //only increment, does not change the storage path - refcount: { increment: 1 }, - mime: detected_mime, //update mime and size to keep last image - size, - }, - }); - - const att = await tx.attachments.create({ - data: { - sha256, - owner_type: meta?.owner_type ?? 'EXPENSE', - owner_id: meta?.owner_id ?? 'unknown', - original_name: file.originalname, - status: 'ACTIVE', - retention_policy: (meta?.retention_policy ?? 'EXPENSE_7Y') as any, - created_by: meta?.created_by ?? 'system', - created_at: now, - }, - }); - return att; - }); - - await this.variantsQ.enqueue(attachment.id, detected_mime); - - return { - ok: true, - id: attachment.id, - sha256, - storage_path: storage_path, - size, - mime: detected_mime, - original_name: file.originalname, - owner_type: attachment.owner_type, - owner_id: attachment.owner_id, - }; + @UseInterceptors(FileInterceptor('file', { storage: memoryStorage(), limits: { fileSize: maxUploadBytes() } })) + async upload(@UploadedFile() file?: Express.Multer.File, @Body() meta?: UploadMetaAttachmentsDto) { + return await this.uploadService.uploadAttachment(file, meta); } - @Get('/admin/search') - async adminSearch( - @Query() query: AdminSearchDto ) { - const where: any = {}; - if (query.owner_type) where.owner_type = query.owner_type; - if (query.owner_id) where.owner_id = query.owner_id; - - if (query.date_from || query.date_to) { - where.created_at = {}; - if (query.date_from) where.created_at.gte = new Date(query.date_from + 'T00:00:00Z'); - if (query.date_to) where.created_at.lte = new Date(query.date_to + 'T23:59:59Z'); - } - - const page = query.page ?? 1; - const page_size = query.page_size ?? 50; - const skip = (page - 1)* page_size; - const take = page_size; - - const [items, total] = await this.prisma.$transaction([ - this.prisma.attachments.findMany({ - where, - orderBy: { created_at: 'desc' }, - skip, take, - include: { - blob: { - select: { mime: true, size: true, storage_path: true, sha256: true }, - }, - }, - }), - this.prisma.attachments.count({ where }), - ]); - - return { page, page_size: take, total, items }; + @Get('search/filters') + async searchWithFilters(@Query() dto: AdminSearchDto) { + return await this.getService.searchAttachmentWithFilters(dto); } } diff --git a/src/modules/attachments/dtos/admin-search.dto.ts b/src/modules/attachments/dtos/admin-search.dto.ts deleted file mode 100644 index ca1edc6..0000000 --- a/src/modules/attachments/dtos/admin-search.dto.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Type } from "class-transformer"; -import { IsInt, IsISO8601, IsOptional, IsString, Max, Min } from "class-validator"; - -export class AdminSearchDto { - - @IsOptional() - @IsString() - owner_type?: string; - - @IsOptional() - @IsString() - owner_id?: string; - - @IsOptional() - @IsISO8601() - date_from?: string; - - @IsOptional() - @IsISO8601() - date_to?: string; - - @IsOptional() - @Type(()=> Number) - @IsInt() - @Min(1) - page?: number = 1; - - @IsOptional() - @Type(()=> Number) - @IsInt() - @Min(1) - @Max(200) - page_size?: number = 50; -} \ No newline at end of file diff --git a/src/modules/attachments/dtos/search-filters.dto.ts b/src/modules/attachments/dtos/search-filters.dto.ts new file mode 100644 index 0000000..52ea6f0 --- /dev/null +++ b/src/modules/attachments/dtos/search-filters.dto.ts @@ -0,0 +1,10 @@ +import { IsInt, IsOptional, IsString, Max, Min } from "class-validator"; + +export class AdminSearchDto { + @IsOptional() @IsString() owner_type?: string; + @IsOptional() @IsString() owner_id?: string; + @IsOptional() date_from?: string; + @IsOptional() date_to?: string; + @IsOptional() @IsInt() @Min(1) page?: number = 1; + @IsOptional() @IsInt() @Min(1) @Max(200) page_size?: number = 50; +} \ No newline at end of file diff --git a/src/modules/attachments/services/archival-attachment.service.ts b/src/modules/attachments/services/attachment-archival.service.ts similarity index 60% rename from src/modules/attachments/services/archival-attachment.service.ts rename to src/modules/attachments/services/attachment-archival.service.ts index 501923d..4f3f3fb 100644 --- a/src/modules/attachments/services/archival-attachment.service.ts +++ b/src/modules/attachments/services/attachment-archival.service.ts @@ -1,45 +1,41 @@ -import { Injectable, Logger } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; import { Cron } from "@nestjs/schedule"; +import { startOfYear } from "src/modules/attachments/utils/cas.util"; import { PrismaService } from "src/prisma/prisma.service"; @Injectable() -export class ArchivalAttachmentService { - private readonly logger = new Logger(ArchivalAttachmentService.name) +export class AttachmentArchivalService { private readonly batch_size = Number(process.env.ARCHIVE_BATCH_SIZE || 1000); private readonly cron_expression = process.env.ARCHIVE_CRON || '0 3 * * 1'; - constructor( private readonly prisma: PrismaService) {} + constructor(private readonly prisma: PrismaService) { } - private startOfYear(): Date { - const now = new Date(); - return new Date(Date.UTC(now.getUTCFullYear(), 0, 1, 0, 0, 0, 0)); - } - @Cron(function (this: ArchivalAttachmentService) { return this.cron_expression; } as any) + @Cron(function (this: AttachmentArchivalService) { return this.cron_expression; } as any) async runScheduled() { await this.archiveCutoffToStartOfYear(); } //archive everything before current year async archiveCutoffToStartOfYear() { - const cutoff = this.startOfYear(); - this.logger.log(`Archival: cutoff=${cutoff.toISOString()} batch=${this.batch_size}`); + const cutoff = startOfYear(); + console.log(`Archival: cutoff=${cutoff.toISOString()} batch=${this.batch_size}`); let moved = 0, total = 0, i = 0; do { moved = await this.archiveBatch(cutoff, this.batch_size); total += moved; i++; - if(moved > 0) this.logger.log(`Batch #${i}: moved ${moved}`); - }while (moved === this.batch_size); + if (moved > 0) console.log(`Batch #${i}: moved ${moved}`); + } while (moved === this.batch_size); - this.logger.log(`Archival done: total moved : ${total}`); + console.log(`Archival done: total moved : ${total}`); return { moved: total }; } //only moves table content to archive and not blobs. private async archiveBatch(cutoff: Date, batch_size: number): Promise { - const moved = await this.prisma.$executeRaw` + const moved = await this.prisma.$executeRaw ` WITH moved AS ( DELETE FROM "attachments" WHERE id IN ( @@ -52,9 +48,8 @@ export class ArchivalAttachmentService { ) INSERT INTO archive.attachments_archive (id, sha256, owner_type, owner_id, original_name, status, retention_policy, created_by, created_at) - SELECT * FROM moved; - `; - return Number(moved) || 0; + SELECT * FROM moved;`; + return Number(moved) || 0; } - + } \ No newline at end of file diff --git a/src/modules/attachments/services/attachment-delete.service.ts b/src/modules/attachments/services/attachment-delete.service.ts new file mode 100644 index 0000000..a982931 --- /dev/null +++ b/src/modules/attachments/services/attachment-delete.service.ts @@ -0,0 +1,26 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { Result } from "src/common/errors/result-error.factory"; +import { PrismaService } from "src/prisma/prisma.service"; + +@Injectable() +export class AttachmentDeleteService { + constructor(private readonly prisma: PrismaService) { } + + async deleteAttachment(id: string): Promise> { + await this.prisma.$transaction(async (tx) => { + const attachment = await tx.attachments.findUnique({ where: { id: Number(id) } }); + if (!attachment) return { success: false, error: 'ATTACHMENT_NOT_FOUND' }; + + // soft-delete + await tx.attachments.update({ where: { id: Number(id) }, data: { status: 'DELETED' } }); + + // decrement refcount + const dec = await tx.$executeRaw ` + UPDATE "Blobs" SET refcount = refcount - 1 + WHERE sha256 = ${attachment.sha256} AND refcount > 0;`; + + return { ok: true, decremented: dec > 0 }; + }); + return { success: true, data: true }; + } +} \ No newline at end of file diff --git a/src/modules/attachments/services/attachment-get.service.ts b/src/modules/attachments/services/attachment-get.service.ts new file mode 100644 index 0000000..7b5efb1 --- /dev/null +++ b/src/modules/attachments/services/attachment-get.service.ts @@ -0,0 +1,100 @@ +import { Injectable } from "@nestjs/common"; +import { Response } from "express"; +import { AdminSearchDto } from "src/modules/attachments/dtos/search-filters.dto"; +import { PrismaService } from "src/prisma/prisma.service"; +import { resolveAttachmentsRoot } from "src/config/attachment.config"; +import * as path from 'node:path'; +import { promises as fsp } from 'node:fs'; +import { createReadStream } from "node:fs"; +import { fileTypeFromFile } from "file-type"; +import { Result } from "src/common/errors/result-error.factory"; + +@Injectable() +export class AttachmentGetService { + constructor( + private readonly prisma: PrismaService, + + ) { } + + async getListVariants(id: string): Promise> { + const num_id = Number(id); + if (!Number.isFinite(num_id)) return { success: false, error: 'INVALID_ATTACHMENTS' }; + const variants = await this.prisma.attachmentVariants.findMany({ + where: { attachment_id: num_id }, + orderBy: { variant: 'asc' }, + select: { variant: true, bytes: true, width: true, height: true, path: true, created_at: true }, + }); + return { success: true, data: variants }; + } + + async searchAttachmentWithFilters(dto: AdminSearchDto): Promise> { + const where: any = {}; + if (dto.owner_type) where.owner_type = dto.owner_type; + if (dto.owner_id) where.owner_id = dto.owner_id; + + if (dto.date_from || dto.date_to) { + where.created_at = {}; + if (dto.date_from) where.created_at.gte = new Date(dto.date_from + 'T00:00:00Z'); + if (dto.date_to) where.created_at.lte = new Date(dto.date_to + 'T23:59:59Z'); + } + + const page = dto.page ?? 1; + const page_size = dto.page_size ?? 50; + const skip = (page - 1) * page_size; + const take = page_size; + + const [items, total] = await this.prisma.$transaction([ + this.prisma.attachments.findMany({ + where, + orderBy: { created_at: 'desc' }, + skip, take, + include: { + blob: { + select: { mime: true, size: true, storage_path: true, sha256: true }, + }, + }, + }), + this.prisma.attachments.count({ where }), + ]); + + return { success: true, data: { page, page_size: take, total, items } }; + } + + + async findAttachmentById(id: string, variant: string | undefined, res: Response): Promise> { + const num_id = Number(id); + if (!Number.isFinite(num_id)) return { success: false, error: 'INVALID_ATTACHMENTS' }; + + const attachment = await this.prisma.attachments.findUnique({ + where: { id: num_id }, + include: { blob: true }, + }); + if (!attachment) return { success: false, error: 'ATTACHMENT_NOT_FOUND' }; + + const relative = variant ? `${attachment.blob.storage_path}.${variant}` : attachment.blob.storage_path; + const abs = path.join(resolveAttachmentsRoot(), relative); + + let stat; + try { + stat = await fsp.stat(abs); + } catch { + return { success: false, error: 'INVALID_FILE_PATH' }; + } + + let mime = attachment.blob.mime; + try { + const kind = await fileTypeFromFile(abs); + if (kind?.mime) mime = kind.mime; + } catch { } + res.set('Content-Type', mime); + res.set('Content-Length', String(stat.size)); + res.set('ETag', `"sha256-${attachment.blob.sha256}${variant ? '.' + variant : ''}"`); + res.set('Last-Modified', stat.mtime.toUTCString()); + res.set('Cache-Control', 'private, max-age=31536000, immutable'); + res.set('X-Content-Type-Options', 'nosniff'); + + createReadStream(abs).pipe(res); + return { success: true, data: true }; + } + +} diff --git a/src/modules/attachments/services/attachment-upload.service.ts b/src/modules/attachments/services/attachment-upload.service.ts new file mode 100644 index 0000000..fe08ba7 --- /dev/null +++ b/src/modules/attachments/services/attachment-upload.service.ts @@ -0,0 +1,87 @@ +import { Injectable } from "@nestjs/common"; +import { allowedMimes } from "src/modules/attachments/config/upload.config"; +import { UploadMetaAttachmentsDto } from "src/modules/attachments/dtos/upload-meta-attachments.dto"; +import { Readable } from "node:stream"; +import { PrismaService } from "src/prisma/prisma.service"; +import { fileTypeFromBuffer } from "file-type"; +import { DiskStorageService } from "src/modules/attachments/services/disk-storage.service"; +import { VariantsQueue } from "src/modules/attachments/services/variants.queue"; +import { Result } from "src/common/errors/result-error.factory"; + +@Injectable() +export class AttachmentUploadService { + constructor( + private readonly prisma: PrismaService, + private readonly disk: DiskStorageService, + private readonly variantsQ: VariantsQueue, + ) { } + + async uploadAttachment(file?: Express.Multer.File, meta?: UploadMetaAttachmentsDto): Promise> { + if (!file) return { success: false, error: 'FILE_NOT_FOUND' }; + + //magic detection using binary signature + const kind = await fileTypeFromBuffer(file.buffer).catch(() => null); + const detected_mime = kind?.mime || file.mimetype || 'application/octet-stream'; + + //strict whitelist + if (!allowedMimes().includes(detected_mime)) { + return { success: false, error: 'INVALID_ATTACHMENT_TYPE' }; + } + + //Saving FS (hash + CAS + unDupes) + const stream = Readable.from(file.buffer); + const { sha256, storage_path, size } = await this.disk.saveStreamAndHash(stream); + + const now = new Date(); + const attachment = await this.prisma.$transaction(async (tx) => { + //upsert blob: +1 ref + await tx.blobs.upsert({ + where: { sha256 }, + create: { + sha256, + storage_path: storage_path, + size, + mime: detected_mime, + refcount: 1, + created_at: now, + }, + update: { //only increment, does not change the storage path + refcount: { increment: 1 }, + mime: detected_mime, //update mime and size to keep last image + size, + }, + }); + + const att = await tx.attachments.create({ + data: { + sha256, + owner_type: meta?.owner_type ?? 'EXPENSE', + owner_id: meta?.owner_id ?? 'unknown', + original_name: file.originalname, + status: 'ACTIVE', + retention_policy: (meta?.retention_policy ?? 'EXPENSE_7Y') as any, + created_by: meta?.created_by ?? 'system', + created_at: now, + }, + }); + return att; + }); + + await this.variantsQ.enqueue(attachment.id, detected_mime); + + return { + success: true, + data: { + ok: true, + id: attachment.id, + sha256, + storage_path: storage_path, + size, + mime: detected_mime, + original_name: file.originalname, + owner_type: attachment.owner_type, + owner_id: attachment.owner_id, + } + }; + } +} \ No newline at end of file diff --git a/src/modules/attachments/services/disk-storage.service.ts b/src/modules/attachments/services/disk-storage.service.ts index 53639ba..7c05da8 100644 --- a/src/modules/attachments/services/disk-storage.service.ts +++ b/src/modules/attachments/services/disk-storage.service.ts @@ -4,32 +4,21 @@ import { promises as fsp } from 'node:fs'; import { createWriteStream, statSync, existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { pipeline } from 'node:stream/promises'; -import { ATT_TMP_DIR, resolveAttachmentsRoot } from 'src/config/attachment.config'; +import { ATT_TMP_DIR } from 'src/config/attachment.config'; +import { casPathFor, getAbsolutePath } from 'src/modules/attachments/utils/cas.util'; -export type SaveResult = { sha256:string, storage_path:string, size:number}; +export type SaveResult = { sha256: string, storage_path: string, size: number }; @Injectable() export class DiskStorageService { - private root = resolveAttachmentsRoot(); - - private casPath(hash: string) { - const a = hash.slice(0,2), b = hash.slice(2,4); - return `sha256/${a}/${b}/${hash}`; - } - - //chemin absolue du storage - getAbsolutePath(storagePathRel: string) { - return join(this.root, storagePathRel); - } - - async exists(storagePathRel: string) { - try { - statSync(this.getAbsolutePath(storagePathRel)); - return true; - }catch { - return false; - } - } + // async exists(storagePathRel: string) { + // try { + // statSync(getAbsolutePath(storagePathRel)); + // return true; + // } catch { + // return false; + // } + // } //adds file and hash it async saveStreamAndHash(input: NodeJS.ReadableStream): Promise { @@ -44,25 +33,25 @@ export class DiskStorageService { await pipeline(input, tmpOut); //await end of writing stream const sha = hash.digest('hex'); - const rel = this.casPath(sha); - const finalAbs = this.getAbsolutePath(rel); + const rel = casPathFor(sha); + const finalAbs = getAbsolutePath(rel); // 2- is there is no destination => move (atomic renaming on the same volume) - if(!existsSync(finalAbs)) { - await fsp.mkdir(dirname(finalAbs), { recursive:true }); + if (!existsSync(finalAbs)) { + await fsp.mkdir(dirname(finalAbs), { recursive: true }); try { await fsp.rename(tmpPath, finalAbs); - }catch (e) { + } catch (e) { //if someone is faster and used the same hash - if(existsSync(finalAbs)) { - await fsp.rm(tmpPath, { force:true }); + if (existsSync(finalAbs)) { + await fsp.rm(tmpPath, { force: true }); } else { throw e; } } } else { //remove duplicata if already exists - await fsp.rm(tmpPath, { force:true }); + await fsp.rm(tmpPath, { force: true }); } const size = statSync(finalAbs).size; diff --git a/src/modules/attachments/services/garbage-collector.service.ts b/src/modules/attachments/services/garbage-collector.service.ts index 13e8c21..f019f12 100644 --- a/src/modules/attachments/services/garbage-collector.service.ts +++ b/src/modules/attachments/services/garbage-collector.service.ts @@ -16,10 +16,10 @@ export class GarbargeCollectorService { //fetchs root of storage private readonly root = resolveAttachmentsRoot(); - constructor(private readonly prisma: PrismaService) {} + constructor(private readonly prisma: PrismaService) { } //planif for the Cronjob - @Cron(function(this:GarbargeCollectorService) { return this.cron_expression; } as any) + @Cron(function (this: GarbargeCollectorService) { return this.cron_expression; } as any) async runScheduled() { await this.collect(); } @@ -28,15 +28,15 @@ export class GarbargeCollectorService { async collect() { let total = 0, round = 0; //infinit loop (;;) with break - for(;;) { + for (; ;) { round++; const num = await this.collectBatch(); total += num; this.logger.log(`Garbage Collector round #${round} removed ${num}`); - if(num < this.batch_size) break; //breaks if not a full batch + if (num < this.batch_size) break; //breaks if not a full batch } this.logger.log(`Garbage Collecting done: total removed ${total}`); - return { removed:total }; + return { removed: total }; } //Manage a single lot of orphan blobs @@ -44,35 +44,35 @@ export class GarbargeCollectorService { const blobs = await this.prisma.blobs.findMany({ where: { refcount: { lte: 0 } }, select: { sha256: true, storage_path: true }, - take: this.batch_size, + take: this.batch_size, }); - if(blobs.length === 0) return 0; + if (blobs.length === 0) return 0; // delete original file and all its variants in the same file await Promise.all( - blobs.map(async (blob)=> { + blobs.map(async (blob) => { const absolute_path = path.join(this.root, blob.storage_path); await this.deleteFileIfExists(absolute_path); //tries to delete original file if found const dir = path.dirname(absolute_path); const base = path.basename(absolute_path); try { - const entries = await fsp.readdir(dir, { withFileTypes: true}); + const entries = await fsp.readdir(dir, { withFileTypes: true }); const targets = entries.filter(entry => entry.isFile() && entry.name.startsWith(base + '.')) - .map(entry => path.join(dir, entry.name)); + .map(entry => path.join(dir, entry.name)); //deletes all variants await Promise.all(targets.map(target => this.deleteFileIfExists(target))); - } catch {} + } catch { } }) ); //deletes blobs lignes if file is deleted const hashes = blobs.map(blob => blob.sha256); - await this.prisma.blobs.deleteMany({where: { sha256: { in: hashes } } }); + await this.prisma.blobs.deleteMany({ where: { sha256: { in: hashes } } }); return blobs.length; } //helper: deletes path if exists and ignore errors private async deleteFileIfExists(path: string) { - try { await fsp.unlink(path); } catch {} + try { await fsp.unlink(path); } catch { } } } \ No newline at end of file diff --git a/src/modules/attachments/services/variants.queue.ts b/src/modules/attachments/services/variants.queue.ts index 2c261f0..507b1dd 100644 --- a/src/modules/attachments/services/variants.queue.ts +++ b/src/modules/attachments/services/variants.queue.ts @@ -3,20 +3,19 @@ import { Queue } from "bullmq"; @Injectable() export class VariantsQueue { - private queue : Queue; + private queue: Queue; constructor() { const name = `${process.env.BULL_PREFIX || 'attachments'}:variants`; this.queue = new Queue(name, { connection: { url: process.env.REDIS_URL! } }); } - + enqueue(attachment_id: number, mime: string) { - if(!mime.startsWith('image/')) { - return Promise.resolve(); - } - return this.queue.add('generate', - { attachment_id, mime }, - { attempts: 3, backoff: { type: 'exponential', delay:2000 } } + if (!mime.startsWith('image/')) return Promise.resolve(); + return this.queue.add( + 'generate', + { attachment_id, mime }, + { attempts: 3, backoff: { type: 'exponential', delay: 2000 } } ); } } diff --git a/src/modules/attachments/utils/cas.util.ts b/src/modules/attachments/utils/cas.util.ts index 19558db..dfe9671 100644 --- a/src/modules/attachments/utils/cas.util.ts +++ b/src/modules/attachments/utils/cas.util.ts @@ -1,4 +1,17 @@ +import { join } from "node:path"; + export function casPathFor(hash: string) { - const a = hash.slice(0,2), b = hash.slice(2,4); + const a = hash.slice(0, 2), b = hash.slice(2, 4); return `sha256/${a}/${b}/${hash}`; } + +//chemin absolue du storage +export function getAbsolutePath(storagePathRel: string) { + return join(this.root, storagePathRel); +} + + +export function startOfYear(): Date { + const now = new Date(); + return new Date(Date.UTC(now.getUTCFullYear(), 0, 1, 0, 0, 0, 0)); +} \ No newline at end of file diff --git a/src/modules/exports/dtos/export-csv-options.dto.ts b/src/modules/exports/dtos/export-csv-options.dto.ts index f2a2b49..eb37b7d 100644 --- a/src/modules/exports/dtos/export-csv-options.dto.ts +++ b/src/modules/exports/dtos/export-csv-options.dto.ts @@ -1,7 +1,7 @@ import { Transform } from "class-transformer"; import { IsBoolean, IsInt, IsOptional, Max, Min } from "class-validator"; -function toBoolean(v: any) { +const toBoolean = (v: any) => { if(typeof v === 'boolean') return v; if(typeof v === 'string') return ['true', '1', 'on','yes'].includes(v.toLowerCase()); return false; diff --git a/src/time-and-attendance/leave-requests/dtos/leave-request.dto.ts b/src/time-and-attendance/leave-requests/dtos/leave-request.dto.ts index 9e0c822..14306ba 100644 --- a/src/time-and-attendance/leave-requests/dtos/leave-request.dto.ts +++ b/src/time-and-attendance/leave-requests/dtos/leave-request.dto.ts @@ -1,19 +1,17 @@ -import { IsEmail, IsArray, ArrayNotEmpty, ArrayUnique, IsISO8601, IsOptional, IsString, IsNumber, Min, Max, IsEnum } from "class-validator"; +import { IsEmail, IsArray, IsOptional, IsString, IsNumber, IsEnum, IsDateString } from "class-validator"; import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; import { Type } from "class-transformer"; //sets wich types to use -export const REQUEST_TYPES = Object.values(LeaveTypes) as readonly LeaveTypes[]; -export type RequestTypes = (typeof REQUEST_TYPES)[number]; +export const REQUEST_TYPES = Object.values(LeaveTypes) as readonly LeaveTypes[]; +export type RequestTypes = (typeof REQUEST_TYPES)[number]; export class LeaveRequestDto { - @IsEmail() + @IsEmail() @IsString() email!: string; @IsArray() - @ArrayNotEmpty() - @ArrayUnique() - @IsISO8601({}, { each: true }) + @IsDateString() dates!: string[]; @IsEnum(LeaveTypes) @@ -25,8 +23,6 @@ export class LeaveRequestDto { @IsOptional() @Type(() => Number) @IsNumber({ maxDecimalPlaces: 2 }) - @Min(0) - @Max(24) requested_hours?: number; @IsEnum(LeaveApprovalStatus) diff --git a/src/time-and-attendance/leave-requests/mappers/leave-requests.mapper.ts b/src/time-and-attendance/leave-requests/mappers/leave-requests.mapper.ts deleted file mode 100644 index 8999100..0000000 --- a/src/time-and-attendance/leave-requests/mappers/leave-requests.mapper.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto"; -import { Prisma } from "@prisma/client"; -import { leave_requests_select } from "src/time-and-attendance/utils/selects.utils"; - -type LeaveRequestRow = Prisma.LeaveRequestsGetPayload<{ select: typeof leave_requests_select}>; - -const toNum = (value?: Prisma.Decimal | null) => - value !== null && value !== undefined ? Number(value) : undefined; - -// export function mapRowToView(row: LeaveRequestRow): LeaveRequestViewDto { -// const iso_date = row.dates?.toISOString().slice(0, 10); -// if (!iso_date) throw new Error(`Leave request #${row.id} has no date set.`); - -// return { -// id: row.id, -// leave_type: row.leave_type, -// date: iso_date, -// payable_hours: toNum(row.payable_hours), -// requested_hours: toNum(row.requested_hours), -// comment: row.comment, -// approval_status: row.approval_status, -// email: row.employee.user.email, -// employee_full_name: `${row.employee.user.first_name} ${row.employee.user.last_name}`, -// }; -// } \ No newline at end of file diff --git a/src/time-and-attendance/pay-period/controllers/pay-periods.controller.ts b/src/time-and-attendance/pay-period/controllers/pay-periods.controller.ts index 31e398a..40c38b9 100644 --- a/src/time-and-attendance/pay-period/controllers/pay-periods.controller.ts +++ b/src/time-and-attendance/pay-period/controllers/pay-periods.controller.ts @@ -58,7 +58,6 @@ export class PayPeriodsController { @Param('periodNumber', ParseIntPipe) period_no: number, @Query('includeSubtree', new ParseBoolPipe({ optional: true })) include_subtree = false, ): Promise> { - if (!email) throw new UnauthorizedException(`Session infos not found`); return this.queryService.getCrewOverview(year, period_no, email, include_subtree); } diff --git a/src/time-and-attendance/schedule-presets/services/schedule-presets-apply.service.ts b/src/time-and-attendance/schedule-presets/services/schedule-presets-apply.service.ts index a6b17c0..be7ad2e 100644 --- a/src/time-and-attendance/schedule-presets/services/schedule-presets-apply.service.ts +++ b/src/time-and-attendance/schedule-presets/services/schedule-presets-apply.service.ts @@ -10,12 +10,12 @@ import { Result } from "src/common/errors/result-error.factory"; @Injectable() export class SchedulePresetsApplyService { constructor( - private readonly prisma: PrismaService, + private readonly prisma: PrismaService, private readonly emailResolver: EmailToIdResolver ) { } async applyToTimesheet(email: string, id: number, start_date_iso: string): Promise> { - if (!DATE_ISO_FORMAT.test(start_date_iso)) return { success: false, error: 'start_date must be of format :YYYY-MM-DD' }; + if (!DATE_ISO_FORMAT.test(start_date_iso)) return { success: false, error: 'INVALID_PRESET' }; const employee_id = await this.emailResolver.findIdByEmail(email); if (!employee_id.success) return { success: false, error: employee_id.error } @@ -37,7 +37,7 @@ export class SchedulePresetsApplyService { }, }, }); - if (!preset) return { success: false, error: `Schedule preset with id: ${id} not found` }; + if (!preset) return { success: false, error: `PRESET_NOT_FOUND` }; const start_date = new Date(`${start_date_iso}T00:00:00.000Z`); @@ -91,7 +91,7 @@ export class SchedulePresetsApplyService { if (shift.end_time.getTime() <= shift.start_time.getTime()) { return { success: false, - error: `Invalid time range in preset day: ${week_day}, order: ${shift.sort_order}` + error: `INVALID_PRESET_SHIFT` }; } const conflict = existing.find((existe) => overlaps( @@ -101,7 +101,7 @@ export class SchedulePresetsApplyService { if (conflict) return { success: false, - error: `[SHIFT_OVERLAP] :Preset shift overlaps existing shift on ${start_date} + ${i}(week day ${week_day}) ` + error: `OVERLAPING_SHIFT` }; payload.push({ diff --git a/src/time-and-attendance/schedule-presets/services/schedule-presets-create.service.ts b/src/time-and-attendance/schedule-presets/services/schedule-presets-create.service.ts index 6207ba4..1daf617 100644 --- a/src/time-and-attendance/schedule-presets/services/schedule-presets-create.service.ts +++ b/src/time-and-attendance/schedule-presets/services/schedule-presets-create.service.ts @@ -23,103 +23,79 @@ export class SchedulePresetsCreateService { //validate email and fetch employee_id const employee_id = await this.emailResolver.findIdByEmail(email); if (!employee_id.success) return { success: false, error: employee_id.error }; - + //validate new unique name const existing = await this.prisma.schedulePresets.findFirst({ where: { name: dto.name, employee_id: employee_id.data }, select: { name: true }, }); if (!existing) return { success: false, error: 'INVALID_SCHEDULE_PRESET' }; - + const normalized_shifts = dto.preset_shifts.map((shift) => ({ - ...shift, - start: toDateFromHHmm(shift.start_time), - end: toDateFromHHmm(shift.end_time), - })); - - for (const preset_shifts of normalized_shifts) { - for (const other_shifts of normalized_shifts) { - //skip if same object or id week_day is not the same - if (preset_shifts === other_shifts) continue; - if (preset_shifts.week_day !== other_shifts.week_day) continue; - //check overlaping possibilities - const has_overlap = overlaps( - { start: preset_shifts.start, end: preset_shifts.end }, - { start: other_shifts.start, end: other_shifts.end }, - ) - if (has_overlap) return { success: false, error: 'SCHEDULE_PRESET_OVERLAP' }; + ...shift, + start: toDateFromHHmm(shift.start_time), + end: toDateFromHHmm(shift.end_time), + })); + + for (const preset_shifts of normalized_shifts) { + for (const other_shifts of normalized_shifts) { + //skip if same object or id week_day is not the same + if (preset_shifts === other_shifts) continue; + if (preset_shifts.week_day !== other_shifts.week_day) continue; + //check overlaping possibilities + const has_overlap = overlaps( + { start: preset_shifts.start, end: preset_shifts.end }, + { start: other_shifts.start, end: other_shifts.end }, + ) + if (has_overlap) return { success: false, error: 'SCHEDULE_PRESET_OVERLAP' }; + } } - } - - //validate bank_code_id/type and map them - const bank_code_results = await Promise.all(dto.preset_shifts.map((shift) => - this.typeResolver.findBankCodeIDByType(shift.type), - )); - for (const result of bank_code_results) { - if (!result.success) return { success: false, error: 'INVALID_SCHEDULE_PRESET' } - } - - await this.prisma.$transaction(async (tx) => { - //check if employee chose this preset has a default preset and ensure all others are false - if (dto.is_default) { - await tx.schedulePresets.updateMany({ - where: { employee_id: employee_id.data, is_default: true }, - data: { is_default: false }, - }); - } - - await tx.schedulePresets.create({ - data: { - employee_id: employee_id.data, - name: dto.name, - is_default: dto.is_default ?? false, - shifts: { - create: dto.preset_shifts.map((shift, index) => { - //validated bank_codes sent as a Result Array to access its data - const result = bank_code_results[index] as { success: true, data: number }; - return { - week_day: shift.week_day, - sort_order: shift.sort_order, - start_time: toDateFromHHmm(shift.start_time), - end_time: toDateFromHHmm(shift.end_time), - is_remote: shift.is_remote ?? false, - bank_code: { - //connect uses the FK links to set the bank_code_id - connect: { id: result.data }, - }, - } - }), + + //validate bank_code_id/type and map them + const bank_code_results = await Promise.all(dto.preset_shifts.map((shift) => + this.typeResolver.findBankCodeIDByType(shift.type), + )); + for (const result of bank_code_results) { + if (!result.success) return { success: false, error: 'INVALID_SCHEDULE_PRESET' } + } + + await this.prisma.$transaction(async (tx) => { + //check if employee chose this preset has a default preset and ensure all others are false + if (dto.is_default) { + await tx.schedulePresets.updateMany({ + where: { employee_id: employee_id.data, is_default: true }, + data: { is_default: false }, + }); + } + + await tx.schedulePresets.create({ + data: { + employee_id: employee_id.data, + name: dto.name, + is_default: dto.is_default ?? false, + shifts: { + create: dto.preset_shifts.map((shift, index) => { + //validated bank_codes sent as a Result Array to access its data + const result = bank_code_results[index] as { success: true, data: number }; + return { + week_day: shift.week_day, + sort_order: shift.sort_order, + start_time: toDateFromHHmm(shift.start_time), + end_time: toDateFromHHmm(shift.end_time), + is_remote: shift.is_remote ?? false, + bank_code: { + //connect uses the FK links to set the bank_code_id + connect: { id: result.data }, + }, + } + }), + }, }, - }, + }); }); - }); - return { success: true, data: true } + return { success: true, data: true } } catch (error) { - return { success: false, error: 'INVALID_SCHEDULE_PRESET'} + return { success: false, error: 'INVALID_SCHEDULE_PRESET' } } } - - - // //PRIVATE HELPERS - - //resolve bank_code_id using type and convert hours to TIME and valid shifts end/start - // private async normalizePresetShifts(preset_shift: SchedulePresetShiftsDto, schedul_preset: SchedulePresetsDto): Promise> { - - // const bank_code = await this.typeResolver.findIdAndModifierByType(preset_shift.type); - // if (!bank_code.success) return { success: false, error: 'INVALID_SCHEDULE_PRESET_SHIFT' }; - - // const start = await toDateFromHHmm(preset_shift.start_time); - // const end = await toDateFromHHmm(preset_shift.end_time); - - // //TODO: add a way to fetch - - - // const normalized_preset_shift:Normalized = { - // date: , - // start_time : start, - // end_time: end, - // bank_code_id: bank_code.data.id, - // } - // return { success: true data: normalized_preset_shift } - // } }