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(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 });
});
@@ -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 });
});
@@ -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 }),
}),
);
});
@@ -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() },
}),
),
);
@@ -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({
@@ -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() } }),
),
);
@@ -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 } } },
});
@@ -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",