From ddb0926dba6d4c751b54f7c73b4a313360f1117a Mon Sep 17 00:00:00 2001 From: "simon.franken" Date: Mon, 23 Feb 2026 15:48:07 +0100 Subject: [PATCH] Implement soft-delete for client targets and balance corrections Deleting a target or correction sets deletedAt instead of hard-deleting. Creating a target for a user+client that has a soft-deleted record reactivates it (clears deletedAt, applies new weeklyHours/startDate) rather than failing the unique constraint. All reads filter deletedAt = null on the target, its corrections, and the parent client. --- .../migration.sql | 5 +++ backend/prisma/schema.prisma | 22 ++++++------ backend/src/services/clientTarget.service.ts | 36 ++++++++++++++----- frontend/src/types/index.ts | 1 + 4 files changed, 45 insertions(+), 19 deletions(-) create mode 100644 backend/prisma/migrations/20260223210000_add_soft_delete_targets_corrections/migration.sql diff --git a/backend/prisma/migrations/20260223210000_add_soft_delete_targets_corrections/migration.sql b/backend/prisma/migrations/20260223210000_add_soft_delete_targets_corrections/migration.sql new file mode 100644 index 0000000..21c1ec4 --- /dev/null +++ b/backend/prisma/migrations/20260223210000_add_soft_delete_targets_corrections/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable: add deleted_at column to client_targets +ALTER TABLE "client_targets" ADD COLUMN "deleted_at" TIMESTAMP(3); + +-- AlterTable: add deleted_at column to balance_corrections +ALTER TABLE "balance_corrections" ADD COLUMN "deleted_at" TIMESTAMP(3); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 5f8fc9e..83b49fb 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -100,11 +100,12 @@ model OngoingTimer { } model ClientTarget { - id String @id @default(uuid()) - weeklyHours Float @map("weekly_hours") - startDate DateTime @map("start_date") @db.Date // Always a Monday - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id String @id @default(uuid()) + weeklyHours Float @map("weekly_hours") + startDate DateTime @map("start_date") @db.Date // Always a Monday + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") userId String @map("user_id") @db.VarChar(255) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -120,12 +121,13 @@ model ClientTarget { } model BalanceCorrection { - id String @id @default(uuid()) - date DateTime @map("date") @db.Date + id String @id @default(uuid()) + date DateTime @map("date") @db.Date hours Float - description String? @db.VarChar(255) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + description String? @db.VarChar(255) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") clientTargetId String @map("client_target_id") clientTarget ClientTarget @relation(fields: [clientTargetId], references: [id], onDelete: Cascade) diff --git a/backend/src/services/clientTarget.service.ts b/backend/src/services/clientTarget.service.ts index cf79a64..e2e0419 100644 --- a/backend/src/services/clientTarget.service.ts +++ b/backend/src/services/clientTarget.service.ts @@ -68,10 +68,10 @@ export interface ClientTargetWithBalance { export class ClientTargetService { async findAll(userId: string): Promise { const targets = await prisma.clientTarget.findMany({ - where: { userId, client: { deletedAt: null } }, + where: { userId, deletedAt: null, client: { deletedAt: null } }, include: { client: { select: { id: true, name: true } }, - corrections: { orderBy: { date: 'asc' } }, + corrections: { where: { deletedAt: null }, orderBy: { date: 'asc' } }, }, orderBy: { client: { name: 'asc' } }, }); @@ -81,10 +81,10 @@ export class ClientTargetService { async findById(id: string, userId: string) { return prisma.clientTarget.findFirst({ - where: { id, userId, client: { deletedAt: null } }, + where: { id, userId, deletedAt: null, client: { deletedAt: null } }, include: { client: { select: { id: true, name: true } }, - corrections: { orderBy: { date: 'asc' } }, + corrections: { where: { deletedAt: null }, orderBy: { date: 'asc' } }, }, }); } @@ -106,6 +106,18 @@ export class ClientTargetService { // Check for existing target (unique per user+client) const existing = await prisma.clientTarget.findFirst({ where: { userId, clientId: data.clientId } }); if (existing) { + if (existing.deletedAt !== null) { + // Reactivate the soft-deleted target with the new settings + const reactivated = await prisma.clientTarget.update({ + where: { id: existing.id }, + data: { deletedAt: null, weeklyHours: data.weeklyHours, startDate }, + include: { + client: { select: { id: true, name: true } }, + corrections: { where: { deletedAt: null }, orderBy: { date: 'asc' } }, + }, + }); + return this.computeBalance(reactivated); + } throw new BadRequestError('A target already exists for this client. Delete the existing one first or update it.'); } @@ -118,7 +130,7 @@ export class ClientTargetService { }, include: { client: { select: { id: true, name: true } }, - corrections: { orderBy: { date: 'asc' } }, + corrections: { where: { deletedAt: null }, orderBy: { date: 'asc' } }, }, }); @@ -148,7 +160,7 @@ export class ClientTargetService { data: updateData, include: { client: { select: { id: true, name: true } }, - corrections: { orderBy: { date: 'asc' } }, + corrections: { where: { deletedAt: null }, orderBy: { date: 'asc' } }, }, }); @@ -158,7 +170,10 @@ export class ClientTargetService { async delete(id: string, userId: string): Promise { const existing = await this.findById(id, userId); if (!existing) throw new NotFoundError('Client target not found'); - await prisma.clientTarget.delete({ where: { id } }); + await prisma.clientTarget.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); } async addCorrection(targetId: string, userId: string, data: CreateCorrectionInput) { @@ -188,11 +203,14 @@ export class ClientTargetService { if (!target) throw new NotFoundError('Client target not found'); const correction = await prisma.balanceCorrection.findFirst({ - where: { id: correctionId, clientTargetId: targetId }, + where: { id: correctionId, clientTargetId: targetId, deletedAt: null }, }); if (!correction) throw new NotFoundError('Correction not found'); - await prisma.balanceCorrection.delete({ where: { id: correctionId } }); + await prisma.balanceCorrection.update({ + where: { id: correctionId }, + data: { deletedAt: new Date() }, + }); } private async computeBalance(target: { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index eceee0c..ea39d9e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -152,6 +152,7 @@ export interface BalanceCorrection { hours: number; description: string | null; createdAt: string; + deletedAt: string | null; } export interface WeekBalance {