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 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 20:03:38 +02:00
parent f7407bd882
commit 1a8ea11331
10 changed files with 43 additions and 25 deletions
@@ -135,14 +135,18 @@ describe("blueprint procedure support", () => {
); );
expect($transaction).toHaveBeenCalledTimes(1); expect($transaction).toHaveBeenCalledTimes(1);
expect(update).toHaveBeenNthCalledWith(1, { expect(update).toHaveBeenNthCalledWith(1,
where: { id: "bp_1" }, expect.objectContaining({
data: { isActive: false }, where: { id: "bp_1" },
}); data: expect.objectContaining({ isActive: false }),
expect(update).toHaveBeenNthCalledWith(2, { }),
where: { id: "bp_2" }, );
data: { isActive: false }, expect(update).toHaveBeenNthCalledWith(2,
}); expect.objectContaining({
where: { id: "bp_2" },
data: expect.objectContaining({ isActive: false }),
}),
);
expect(createAuditEntry).toHaveBeenCalledTimes(2); expect(createAuditEntry).toHaveBeenCalledTimes(2);
expect(result).toEqual({ count: 2 }); expect(result).toEqual({ count: 2 });
}); });
@@ -227,14 +227,18 @@ describe("blueprint router", () => {
const result = await caller.batchDelete({ ids: ["bp_1", "bp_2"] }); const result = await caller.batchDelete({ ids: ["bp_1", "bp_2"] });
expect(transaction).toHaveBeenCalledTimes(1); expect(transaction).toHaveBeenCalledTimes(1);
expect(update).toHaveBeenNthCalledWith(1, { expect(update).toHaveBeenNthCalledWith(1,
where: { id: "bp_1" }, expect.objectContaining({
data: { isActive: false }, where: { id: "bp_1" },
}); data: expect.objectContaining({ isActive: false }),
expect(update).toHaveBeenNthCalledWith(2, { }),
where: { id: "bp_2" }, );
data: { isActive: false }, expect(update).toHaveBeenNthCalledWith(2,
}); expect.objectContaining({
where: { id: "bp_2" },
data: expect.objectContaining({ isActive: false }),
}),
);
expect(auditCreate).toHaveBeenCalledTimes(2); expect(auditCreate).toHaveBeenCalledTimes(2);
expect(result).toEqual({ count: 2 }); expect(result).toEqual({ count: 2 });
}); });
@@ -460,7 +460,7 @@ describe("resource router CRUD", () => {
expect(db.resource.update).toHaveBeenCalledWith( expect(db.resource.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
where: { id: "res_1" }, where: { id: "res_1" },
data: { isActive: false }, data: expect.objectContaining({ isActive: false }),
}), }),
); );
}); });
@@ -192,7 +192,7 @@ export async function deleteBlueprint(ctx: BlueprintProcedureContext, input: Blu
const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id); const audit = makeAuditLogger(ctx.db, ctx.dbUser?.id);
const deleted = await ctx.db.blueprint.update({ const deleted = await ctx.db.blueprint.update({
where: { id: input.id }, where: { id: input.id },
data: { isActive: false }, data: { isActive: false, deletedAt: new Date() },
}); });
audit({ audit({
@@ -214,7 +214,7 @@ export async function batchDeleteBlueprints(
input.ids.map((id) => input.ids.map((id) =>
ctx.db.blueprint.update({ ctx.db.blueprint.update({
where: { id }, where: { id },
data: { isActive: false }, data: { isActive: false, deletedAt: new Date() },
}), }),
), ),
); );
@@ -199,7 +199,7 @@ export async function updateClient(ctx: ClientProcedureContext, input: ClientUpd
export async function deactivateClient(ctx: ClientProcedureContext, input: ClientIdInput) { export async function deactivateClient(ctx: ClientProcedureContext, input: ClientIdInput) {
const updated = await ctx.db.client.update({ const updated = await ctx.db.client.update({
where: { id: input.id }, where: { id: input.id },
data: { isActive: false }, data: { isActive: false, deletedAt: new Date() },
}); });
void createAuditEntry({ void createAuditEntry({
@@ -202,7 +202,7 @@ export const resourceMutationProcedures = {
const resource = await ctx.db.$transaction(async (tx) => { const resource = await ctx.db.$transaction(async (tx) => {
const result = await tx.resource.update({ const result = await tx.resource.update({
where: { id: input.id }, where: { id: input.id },
data: { isActive: false }, data: { isActive: false, deletedAt: new Date() },
}); });
await tx.auditLog.create({ await tx.auditLog.create({
@@ -227,7 +227,7 @@ export const resourceMutationProcedures = {
const updated = await ctx.db.$transaction(async (tx) => { const updated = await ctx.db.$transaction(async (tx) => {
const results = await Promise.all( const results = await Promise.all(
input.ids.map((id) => input.ids.map((id) =>
tx.resource.update({ where: { id }, data: { isActive: false } }), tx.resource.update({ where: { id }, data: { isActive: false, deletedAt: new Date() } }),
), ),
); );
@@ -247,7 +247,7 @@ export async function deactivateRole(
const role = await ctx.db.$transaction(async (tx) => { const role = await ctx.db.$transaction(async (tx) => {
const result = await tx.role.update({ const result = await tx.role.update({
where: { id: input.id }, where: { id: input.id },
data: { isActive: false }, data: { isActive: false, deletedAt: new Date() },
include: { _count: { select: { resourceRoles: true } } }, include: { _count: { select: { resourceRoles: true } } },
}); });
@@ -473,7 +473,7 @@ export async function deactivateUser(
throw new TRPCError({ code: "BAD_REQUEST", message: "User is already inactive." }); 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 // Invalidate all existing sessions so the user is logged out immediately
await ctx.db.activeSession.deleteMany({ where: { userId: input.userId } }); 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." }); 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({ audit({
entityType: "User", entityType: "User",
@@ -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);
+5
View File
@@ -188,6 +188,7 @@ model User {
totpSecret String? // Base32 TOTP secret totpSecret String? // Base32 TOTP secret
totpEnabled Boolean @default(false) totpEnabled Boolean @default(false)
isActive Boolean @default(true) isActive Boolean @default(true)
deletedAt DateTime?
accounts Account[] accounts Account[]
sessions Session[] sessions Session[]
@@ -709,6 +710,7 @@ model Client {
parent Client? @relation("ClientTree", fields: [parentId], references: [id]) parent Client? @relation("ClientTree", fields: [parentId], references: [id])
children Client[] @relation("ClientTree") children Client[] @relation("ClientTree")
isActive Boolean @default(true) isActive Boolean @default(true)
deletedAt DateTime?
sortOrder Int @default(0) sortOrder Int @default(0)
tags String[] @default([]) tags String[] @default([])
@@ -771,6 +773,7 @@ model Blueprint {
// rolePresets: StaffingRequirement[] — default roles for project creation wizard // rolePresets: StaffingRequirement[] — default roles for project creation wizard
rolePresets Json @db.JsonB @default("[]") rolePresets Json @db.JsonB @default("[]")
isActive Boolean @default(true) isActive Boolean @default(true)
deletedAt DateTime?
isGlobal Boolean @default(false) isGlobal Boolean @default(false)
resources Resource[] resources Resource[]
@@ -792,6 +795,7 @@ model Role {
description String? description String?
color String? // hex color e.g. "#6366f1" color String? // hex color e.g. "#6366f1"
isActive Boolean @default(true) isActive Boolean @default(true)
deletedAt DateTime?
resourceRoles ResourceRole[] resourceRoles ResourceRole[]
demandRequirements DemandRequirement[] demandRequirements DemandRequirement[]
@@ -847,6 +851,7 @@ model Resource {
blueprintId String? blueprintId String?
blueprint Blueprint? @relation(fields: [blueprintId], references: [id]) blueprint Blueprint? @relation(fields: [blueprintId], references: [id])
isActive Boolean @default(true) isActive Boolean @default(true)
deletedAt DateTime?
userId String? @unique userId String? @unique
user User? @relation(fields: [userId], references: [id]) user User? @relation(fields: [userId], references: [id])