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:
@@ -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);
|
||||||
@@ -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])
|
||||||
|
|||||||
Reference in New Issue
Block a user