From 1a8ea11331a833baf15cf20d31285db4369f74cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 9 Apr 2026 20:03:38 +0200 Subject: [PATCH] feat(db): add deletedAt audit timestamp to soft-deletable models Add deletedAt DateTime? to User, Client, Role, Resource, and Blueprint models for GDPR-compliant deactivation audit trail. Soft-delete mutations now stamp deletedAt: new Date() on deactivation and clear it on reactivation. Migration and test assertions updated accordingly. Co-Authored-By: Claude Sonnet 4.6 --- .../blueprint-procedure-support.test.ts | 20 +++++++++++-------- .../src/__tests__/blueprint-router.test.ts | 20 +++++++++++-------- .../__tests__/resource-router-crud.test.ts | 2 +- .../src/router/blueprint-procedure-support.ts | 4 ++-- .../src/router/client-procedure-support.ts | 2 +- packages/api/src/router/resource-mutations.ts | 4 ++-- .../api/src/router/role-procedure-support.ts | 2 +- .../api/src/router/user-procedure-support.ts | 4 ++-- .../migrations/20260409_add_deleted_at.sql | 5 +++++ packages/db/prisma/schema.prisma | 5 +++++ 10 files changed, 43 insertions(+), 25 deletions(-) create mode 100644 packages/db/prisma/migrations/20260409_add_deleted_at.sql diff --git a/packages/api/src/__tests__/blueprint-procedure-support.test.ts b/packages/api/src/__tests__/blueprint-procedure-support.test.ts index 97d69bf..8459518 100644 --- a/packages/api/src/__tests__/blueprint-procedure-support.test.ts +++ b/packages/api/src/__tests__/blueprint-procedure-support.test.ts @@ -135,14 +135,18 @@ describe("blueprint procedure support", () => { ); expect($transaction).toHaveBeenCalledTimes(1); - expect(update).toHaveBeenNthCalledWith(1, { - where: { id: "bp_1" }, - data: { isActive: false }, - }); - expect(update).toHaveBeenNthCalledWith(2, { - where: { id: "bp_2" }, - data: { isActive: false }, - }); + expect(update).toHaveBeenNthCalledWith(1, + expect.objectContaining({ + where: { id: "bp_1" }, + data: expect.objectContaining({ isActive: false }), + }), + ); + expect(update).toHaveBeenNthCalledWith(2, + expect.objectContaining({ + where: { id: "bp_2" }, + data: expect.objectContaining({ isActive: false }), + }), + ); expect(createAuditEntry).toHaveBeenCalledTimes(2); expect(result).toEqual({ count: 2 }); }); diff --git a/packages/api/src/__tests__/blueprint-router.test.ts b/packages/api/src/__tests__/blueprint-router.test.ts index b684a84..b2e9924 100644 --- a/packages/api/src/__tests__/blueprint-router.test.ts +++ b/packages/api/src/__tests__/blueprint-router.test.ts @@ -227,14 +227,18 @@ describe("blueprint router", () => { const result = await caller.batchDelete({ ids: ["bp_1", "bp_2"] }); expect(transaction).toHaveBeenCalledTimes(1); - expect(update).toHaveBeenNthCalledWith(1, { - where: { id: "bp_1" }, - data: { isActive: false }, - }); - expect(update).toHaveBeenNthCalledWith(2, { - where: { id: "bp_2" }, - data: { isActive: false }, - }); + expect(update).toHaveBeenNthCalledWith(1, + expect.objectContaining({ + where: { id: "bp_1" }, + data: expect.objectContaining({ isActive: false }), + }), + ); + expect(update).toHaveBeenNthCalledWith(2, + expect.objectContaining({ + where: { id: "bp_2" }, + data: expect.objectContaining({ isActive: false }), + }), + ); expect(auditCreate).toHaveBeenCalledTimes(2); expect(result).toEqual({ count: 2 }); }); diff --git a/packages/api/src/__tests__/resource-router-crud.test.ts b/packages/api/src/__tests__/resource-router-crud.test.ts index 163b132..4dd4394 100644 --- a/packages/api/src/__tests__/resource-router-crud.test.ts +++ b/packages/api/src/__tests__/resource-router-crud.test.ts @@ -460,7 +460,7 @@ describe("resource router CRUD", () => { expect(db.resource.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: "res_1" }, - data: { isActive: false }, + data: expect.objectContaining({ isActive: false }), }), ); }); diff --git a/packages/api/src/router/blueprint-procedure-support.ts b/packages/api/src/router/blueprint-procedure-support.ts index c67263c..f56844d 100644 --- a/packages/api/src/router/blueprint-procedure-support.ts +++ b/packages/api/src/router/blueprint-procedure-support.ts @@ -192,7 +192,7 @@ export async function deleteBlueprint(ctx: BlueprintProcedureContext, input: Blu const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id); const deleted = await ctx.db.blueprint.update({ where: { id: input.id }, - data: { isActive: false }, + data: { isActive: false, deletedAt: new Date() }, }); audit({ @@ -214,7 +214,7 @@ export async function batchDeleteBlueprints( input.ids.map((id) => ctx.db.blueprint.update({ where: { id }, - data: { isActive: false }, + data: { isActive: false, deletedAt: new Date() }, }), ), ); diff --git a/packages/api/src/router/client-procedure-support.ts b/packages/api/src/router/client-procedure-support.ts index a354612..a88bd54 100644 --- a/packages/api/src/router/client-procedure-support.ts +++ b/packages/api/src/router/client-procedure-support.ts @@ -199,7 +199,7 @@ export async function updateClient(ctx: ClientProcedureContext, input: ClientUpd export async function deactivateClient(ctx: ClientProcedureContext, input: ClientIdInput) { const updated = await ctx.db.client.update({ where: { id: input.id }, - data: { isActive: false }, + data: { isActive: false, deletedAt: new Date() }, }); void createAuditEntry({ diff --git a/packages/api/src/router/resource-mutations.ts b/packages/api/src/router/resource-mutations.ts index ad710e7..952173d 100644 --- a/packages/api/src/router/resource-mutations.ts +++ b/packages/api/src/router/resource-mutations.ts @@ -202,7 +202,7 @@ export const resourceMutationProcedures = { const resource = await ctx.db.$transaction(async (tx) => { const result = await tx.resource.update({ where: { id: input.id }, - data: { isActive: false }, + data: { isActive: false, deletedAt: new Date() }, }); await tx.auditLog.create({ @@ -227,7 +227,7 @@ export const resourceMutationProcedures = { const updated = await ctx.db.$transaction(async (tx) => { const results = await Promise.all( input.ids.map((id) => - tx.resource.update({ where: { id }, data: { isActive: false } }), + tx.resource.update({ where: { id }, data: { isActive: false, deletedAt: new Date() } }), ), ); diff --git a/packages/api/src/router/role-procedure-support.ts b/packages/api/src/router/role-procedure-support.ts index 170bd3e..1958770 100644 --- a/packages/api/src/router/role-procedure-support.ts +++ b/packages/api/src/router/role-procedure-support.ts @@ -247,7 +247,7 @@ export async function deactivateRole( const role = await ctx.db.$transaction(async (tx) => { const result = await tx.role.update({ where: { id: input.id }, - data: { isActive: false }, + data: { isActive: false, deletedAt: new Date() }, include: { _count: { select: { resourceRoles: true } } }, }); diff --git a/packages/api/src/router/user-procedure-support.ts b/packages/api/src/router/user-procedure-support.ts index 1731078..e3d6b6c 100644 --- a/packages/api/src/router/user-procedure-support.ts +++ b/packages/api/src/router/user-procedure-support.ts @@ -473,7 +473,7 @@ export async function deactivateUser( throw new TRPCError({ code: "BAD_REQUEST", message: "User is already inactive." }); } - await ctx.db.user.update({ where: { id: input.userId }, data: { isActive: false } }); + await ctx.db.user.update({ where: { id: input.userId }, data: { isActive: false, deletedAt: new Date() } }); // Invalidate all existing sessions so the user is logged out immediately await ctx.db.activeSession.deleteMany({ where: { userId: input.userId } }); @@ -506,7 +506,7 @@ export async function reactivateUser( throw new TRPCError({ code: "BAD_REQUEST", message: "User is already active." }); } - await ctx.db.user.update({ where: { id: input.userId }, data: { isActive: true } }); + await ctx.db.user.update({ where: { id: input.userId }, data: { isActive: true, deletedAt: null } }); audit({ entityType: "User", diff --git a/packages/db/prisma/migrations/20260409_add_deleted_at.sql b/packages/db/prisma/migrations/20260409_add_deleted_at.sql new file mode 100644 index 0000000..98ac561 --- /dev/null +++ b/packages/db/prisma/migrations/20260409_add_deleted_at.sql @@ -0,0 +1,5 @@ +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3); +ALTER TABLE "Client" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3); +ALTER TABLE "Role" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3); +ALTER TABLE "Resource" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3); +ALTER TABLE "Blueprint" ADD COLUMN IF NOT EXISTS "deletedAt" TIMESTAMP(3); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 8b8e262..a9a5ff3 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -188,6 +188,7 @@ model User { totpSecret String? // Base32 TOTP secret totpEnabled Boolean @default(false) isActive Boolean @default(true) + deletedAt DateTime? accounts Account[] sessions Session[] @@ -709,6 +710,7 @@ model Client { parent Client? @relation("ClientTree", fields: [parentId], references: [id]) children Client[] @relation("ClientTree") isActive Boolean @default(true) + deletedAt DateTime? sortOrder Int @default(0) tags String[] @default([]) @@ -771,6 +773,7 @@ model Blueprint { // rolePresets: StaffingRequirement[] — default roles for project creation wizard rolePresets Json @db.JsonB @default("[]") isActive Boolean @default(true) + deletedAt DateTime? isGlobal Boolean @default(false) resources Resource[] @@ -792,6 +795,7 @@ model Role { description String? color String? // hex color e.g. "#6366f1" isActive Boolean @default(true) + deletedAt DateTime? resourceRoles ResourceRole[] demandRequirements DemandRequirement[] @@ -847,6 +851,7 @@ model Resource { blueprintId String? blueprint Blueprint? @relation(fields: [blueprintId], references: [id]) isActive Boolean @default(true) + deletedAt DateTime? userId String? @unique user User? @relation(fields: [userId], references: [id])