From 77f065f37fe1894c6548320e89137d5fa8db91ea Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Wed, 1 Oct 2025 16:35:40 -0400 Subject: [PATCH] feat(expense): link expense with attachment. --- .../migration.sql | 25 ++++++++ prisma/schema.prisma | 11 +++- .../expenses/dtos/upsert-expense.dto.ts | 21 ++++++- .../services/expenses-command.service.ts | 63 ++++++++++++++++--- 4 files changed, 109 insertions(+), 11 deletions(-) create mode 100644 prisma/migrations/20251001193437_link_expense_attachments/migration.sql diff --git a/prisma/migrations/20251001193437_link_expense_attachments/migration.sql b/prisma/migrations/20251001193437_link_expense_attachments/migration.sql new file mode 100644 index 0000000..a74952c --- /dev/null +++ b/prisma/migrations/20251001193437_link_expense_attachments/migration.sql @@ -0,0 +1,25 @@ +/* + Warnings: + + - You are about to drop the column `attachement` on the `expenses` table. All the data in the column will be lost. + - You are about to drop the column `attachement` on the `expenses_archive` table. All the data in the column will be lost. + - Made the column `comment` on table `expenses` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "public"."expenses" DROP COLUMN "attachement", +ADD COLUMN "attachment" INTEGER, +ADD COLUMN "mileage" DECIMAL(65,30), +ALTER COLUMN "comment" SET NOT NULL; + +-- AlterTable +ALTER TABLE "public"."expenses_archive" DROP COLUMN "attachement", +ADD COLUMN "attachment" INTEGER, +ADD COLUMN "mileage" DECIMAL(65,30), +ALTER COLUMN "amount" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "public"."expenses" ADD CONSTRAINT "expenses_attachment_fkey" FOREIGN KEY ("attachment") REFERENCES "public"."attachments"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."expenses_archive" ADD CONSTRAINT "expenses_archive_attachment_fkey" FOREIGN KEY ("attachment") REFERENCES "public"."attachments"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9e56a04..fb0414b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -225,9 +225,10 @@ model Expenses { bank_code BankCodes @relation("ExpenseBankCodes", fields: [bank_code_id], references: [id]) bank_code_id Int date DateTime @db.Date - amount Decimal @db.Money + amount Decimal @db.Money mileage Decimal? - attachment String? + attachment Int? + attachment_record Attachments? @relation("ExpenseAttachment", fields: [attachment], references: [id], onDelete: SetNull) comment String is_approved Boolean @default(false) supervisor_comment String? @@ -247,7 +248,8 @@ model ExpensesArchive { date DateTime @db.Date amount Decimal? @db.Money mileage Decimal? - attachment String? + attachment Int? + attachment_record Attachments? @relation("ExpenseArchiveAttachment", fields: [attachment], references: [id], onDelete: SetNull) comment String? is_approved Boolean supervisor_comment String? @@ -298,6 +300,9 @@ model Attachments { created_by String created_at DateTime @default(now()) + expenses Expenses[] @relation("ExpenseAttachment") + expenses_archive ExpensesArchive[] @relation("ExpenseArchiveAttachment") + @@index([owner_type, owner_id, created_at]) @@index([sha256]) @@map("attachments") diff --git a/src/modules/expenses/dtos/upsert-expense.dto.ts b/src/modules/expenses/dtos/upsert-expense.dto.ts index c79ffd9..6ec007e 100644 --- a/src/modules/expenses/dtos/upsert-expense.dto.ts +++ b/src/modules/expenses/dtos/upsert-expense.dto.ts @@ -1,5 +1,14 @@ import { Transform, Type } from "class-transformer"; -import { IsNumber, IsOptional, IsString, MaxLength, Min, ValidateIf, ValidateNested } from "class-validator"; +import { + IsNumber, + IsOptional, + IsString, + Matches, + MaxLength, + Min, + ValidateIf, + ValidateNested +} from "class-validator"; export class ExpensePayloadDto { @IsString() @@ -21,7 +30,17 @@ export class ExpensePayloadDto { comment!: string; @IsOptional() + @Transform(({ value }) => { + if (value === null || value === undefined || value === '') return undefined; + if (typeof value === 'number') return value.toString(); + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed.length ? trimmed : undefined; + } + return undefined; + }) @IsString() + @Matches(/^\d+$/) @MaxLength(255) attachment?: string; } diff --git a/src/modules/expenses/services/expenses-command.service.ts b/src/modules/expenses/services/expenses-command.service.ts index dea5bea..13ccd84 100644 --- a/src/modules/expenses/services/expenses-command.service.ts +++ b/src/modules/expenses/services/expenses-command.service.ts @@ -1,4 +1,3 @@ -import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { BaseApprovalService } from "src/common/shared/base-approval.service"; import { Expenses, Prisma } from "@prisma/client"; import { PrismaService } from "src/prisma/prisma.service"; @@ -7,8 +6,21 @@ import { BankCodesRepo } from "../repos/bank-codes.repo"; import { TimesheetsRepo } from "../repos/timesheets.repo"; import { EmployeesRepo } from "../repos/employee.repo"; import { toDateOnlyUTC } from "src/modules/shifts/helpers/shifts-date-time-helpers"; -import { assertAndTrimComment, computeMileageAmount, mapDbExpenseToDayResponse, normalizeType as normalizeTypeUtil } from "../utils/expenses.utils"; -import { DayExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces"; +import { + BadRequestException, + Injectable, + NotFoundException +} from "@nestjs/common"; +import { + DayExpenseResponse, + UpsertAction +} from "../types and interfaces/expenses.types.interfaces"; +import { + assertAndTrimComment, + computeMileageAmount, + mapDbExpenseToDayResponse, + normalizeType as normalizeTypeUtil +} from "../utils/expenses.utils"; @Injectable() export class ExpensesCommandService extends BaseApprovalService { @@ -88,18 +100,18 @@ export class ExpensesCommandService extends BaseApprovalService { amount?: number; mileage?: number; comment: string; - attachment?: string; + attachment?: string | number; }): Promise<{ type: string; bank_code_id: number; amount: Prisma.Decimal; mileage: number | null; comment: string; - attachment: string | null; + attachment: number | null; }> => { const type = this.normalizeType(payload.type); const comment = this.assertAndTrimComment(payload.comment); - const attachment = payload.attachment?.trim()?.length ? payload.attachment.trim() : null; + const attachment = this.parseAttachmentId(payload.attachment); const { id: bank_code_id, modifier } = await this.lookupBankCodeOrThrow(tx, type); let amount = this.computeAmountDecimal(type, payload, modifier); @@ -121,6 +133,16 @@ export class ExpensesCommandService extends BaseApprovalService { amount = new Prisma.Decimal(payload.amount); } + if (attachment !== null) { + const attachmentRow = await tx.attachments.findUnique({ + where: { id: attachment }, + select: { status: true }, + }); + if (!attachmentRow || attachmentRow.status !== 'ACTIVE') { + throw new BadRequestException('Attachment not found or inactive'); + } + } + return { type, bank_code_id, @@ -136,7 +158,7 @@ export class ExpensesCommandService extends BaseApprovalService { amount: Prisma.Decimal; mileage: number | null; comment: string; - attachment: string | null; + attachment: number | null; }) => { return tx.expenses.findFirst({ where: { @@ -225,6 +247,33 @@ export class ExpensesCommandService extends BaseApprovalService { private readonly assertAndTrimComment = (comment: string): string => assertAndTrimComment(comment); + private readonly parseAttachmentId = (value: unknown): number | null => { + if (value == null) { + return null; + } + + if (typeof value === 'number') { + if (!Number.isInteger(value) || value <= 0) { + throw new BadRequestException('Invalid attachment id'); + } + return value; + } + + if (typeof value === 'string') { + + const trimmed = value.trim(); + if (!trimmed.length) return null; + if (!/^\d+$/.test(trimmed)) throw new BadRequestException('Invalid attachment id'); + + const parsed = Number(trimmed); + if (parsed <= 0) throw new BadRequestException('Invalid attachment id'); + + return parsed; + } + throw new BadRequestException('Invalid attachment id'); + }; + + private readonly resolveEmployeeIdByEmail = async (email: string): Promise => this.employeesRepo.findIdByEmail(email);