fix(api): wrap audit log writes inside their parent transactions

Prevents mutations from committing without an audit trail if the
auditLog.create call fails after the main write already succeeded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 16:40:10 +02:00
parent a01f99561d
commit 3c0179fcec
25 changed files with 758 additions and 656 deletions
+196 -178
View File
@@ -34,62 +34,66 @@ export const resourceMutationProcedures = {
throw new TRPCError({ code: "BAD_REQUEST", message: "A resource can have at most one primary role" });
}
const resource = await ctx.db.resource.create({
data: {
eid: input.eid,
displayName: input.displayName,
email: input.email,
chapter: input.chapter,
lcrCents: input.lcrCents,
ucrCents: input.ucrCents,
currency: input.currency,
chargeabilityTarget: input.chargeabilityTarget,
availability: input.availability,
skills: input.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue,
dynamicFields: input.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue,
blueprintId: input.blueprintId,
portfolioUrl: input.portfolioUrl || undefined,
roleId: input.roleId || undefined,
...(input.postalCode !== undefined ? { postalCode: input.postalCode } : {}),
...(input.postalCode && !input.federalState
? { federalState: inferStateFromPostalCode(input.postalCode) }
: input.federalState !== undefined
? { federalState: input.federalState }
: {}),
...(input.countryId !== undefined ? { countryId: input.countryId || null } : {}),
...(input.metroCityId !== undefined ? { metroCityId: input.metroCityId || null } : {}),
...(input.orgUnitId !== undefined ? { orgUnitId: input.orgUnitId || null } : {}),
...(input.managementLevelGroupId !== undefined ? { managementLevelGroupId: input.managementLevelGroupId || null } : {}),
...(input.managementLevelId !== undefined ? { managementLevelId: input.managementLevelId || null } : {}),
...(input.resourceType !== undefined ? { resourceType: input.resourceType } : {}),
...(input.chgResponsibility !== undefined ? { chgResponsibility: input.chgResponsibility } : {}),
...(input.rolledOff !== undefined ? { rolledOff: input.rolledOff } : {}),
...(input.departed !== undefined ? { departed: input.departed } : {}),
...(input.enterpriseId !== undefined ? { enterpriseId: input.enterpriseId || null } : {}),
...(input.clientUnitId !== undefined ? { clientUnitId: input.clientUnitId || null } : {}),
...(input.fte !== undefined ? { fte: input.fte } : {}),
resourceRoles: input.roles?.length
? {
create: input.roles.map((role) => ({
roleId: role.roleId,
isPrimary: role.isPrimary,
})),
}
: undefined,
} as unknown as Parameters<typeof ctx.db.resource.create>[0]["data"],
include: {
resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } } },
},
});
const resource = await ctx.db.$transaction(async (tx) => {
const created = await tx.resource.create({
data: {
eid: input.eid,
displayName: input.displayName,
email: input.email,
chapter: input.chapter,
lcrCents: input.lcrCents,
ucrCents: input.ucrCents,
currency: input.currency,
chargeabilityTarget: input.chargeabilityTarget,
availability: input.availability,
skills: input.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue,
dynamicFields: input.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue,
blueprintId: input.blueprintId,
portfolioUrl: input.portfolioUrl || undefined,
roleId: input.roleId || undefined,
...(input.postalCode !== undefined ? { postalCode: input.postalCode } : {}),
...(input.postalCode && !input.federalState
? { federalState: inferStateFromPostalCode(input.postalCode) }
: input.federalState !== undefined
? { federalState: input.federalState }
: {}),
...(input.countryId !== undefined ? { countryId: input.countryId || null } : {}),
...(input.metroCityId !== undefined ? { metroCityId: input.metroCityId || null } : {}),
...(input.orgUnitId !== undefined ? { orgUnitId: input.orgUnitId || null } : {}),
...(input.managementLevelGroupId !== undefined ? { managementLevelGroupId: input.managementLevelGroupId || null } : {}),
...(input.managementLevelId !== undefined ? { managementLevelId: input.managementLevelId || null } : {}),
...(input.resourceType !== undefined ? { resourceType: input.resourceType } : {}),
...(input.chgResponsibility !== undefined ? { chgResponsibility: input.chgResponsibility } : {}),
...(input.rolledOff !== undefined ? { rolledOff: input.rolledOff } : {}),
...(input.departed !== undefined ? { departed: input.departed } : {}),
...(input.enterpriseId !== undefined ? { enterpriseId: input.enterpriseId || null } : {}),
...(input.clientUnitId !== undefined ? { clientUnitId: input.clientUnitId || null } : {}),
...(input.fte !== undefined ? { fte: input.fte } : {}),
resourceRoles: input.roles?.length
? {
create: input.roles.map((role) => ({
roleId: role.roleId,
isPrimary: role.isPrimary,
})),
}
: undefined,
} as unknown as Parameters<typeof tx.resource.create>[0]["data"],
include: {
resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } } },
},
});
await ctx.db.auditLog.create({
data: {
entityType: "Resource",
entityId: resource.id,
action: "CREATE",
userId: ctx.dbUser?.id,
changes: { after: resource },
} as unknown as Parameters<typeof ctx.db.auditLog.create>[0]["data"],
await tx.auditLog.create({
data: {
entityType: "Resource",
entityId: created.id,
action: "CREATE",
userId: ctx.dbUser?.id,
changes: { after: created },
} as unknown as Parameters<typeof tx.auditLog.create>[0]["data"],
});
return created;
});
return resource;
@@ -121,67 +125,71 @@ export const resourceMutationProcedures = {
}
}
const updated = await ctx.db.resource.update({
where: { id: input.id },
data: {
...(input.data.displayName !== undefined ? { displayName: input.data.displayName } : {}),
...(input.data.email !== undefined ? { email: input.data.email } : {}),
...(input.data.chapter !== undefined ? { chapter: input.data.chapter } : {}),
...(input.data.lcrCents !== undefined ? { lcrCents: input.data.lcrCents } : {}),
...(input.data.ucrCents !== undefined ? { ucrCents: input.data.ucrCents } : {}),
...(input.data.currency !== undefined ? { currency: input.data.currency } : {}),
...(input.data.chargeabilityTarget !== undefined ? { chargeabilityTarget: input.data.chargeabilityTarget } : {}),
...(input.data.availability !== undefined ? { availability: input.data.availability as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}),
...(input.data.skills !== undefined ? { skills: input.data.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}),
...(input.data.dynamicFields !== undefined ? { dynamicFields: input.data.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}),
...(input.data.blueprintId !== undefined ? { blueprintId: input.data.blueprintId } : {}),
...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}),
...(input.data.portfolioUrl !== undefined ? { portfolioUrl: input.data.portfolioUrl || null } : {}),
...(input.data.roleId !== undefined ? { roleId: input.data.roleId || null } : {}),
...(input.data.postalCode !== undefined ? { postalCode: input.data.postalCode } : {}),
...(input.data.postalCode && !input.data.federalState
? { federalState: inferStateFromPostalCode(input.data.postalCode) }
: input.data.federalState !== undefined
? { federalState: input.data.federalState }
: {}),
...(input.data.countryId !== undefined ? { countryId: input.data.countryId || null } : {}),
...(input.data.metroCityId !== undefined ? { metroCityId: input.data.metroCityId || null } : {}),
...(input.data.orgUnitId !== undefined ? { orgUnitId: input.data.orgUnitId || null } : {}),
...(input.data.managementLevelGroupId !== undefined ? { managementLevelGroupId: input.data.managementLevelGroupId || null } : {}),
...(input.data.managementLevelId !== undefined ? { managementLevelId: input.data.managementLevelId || null } : {}),
...(input.data.resourceType !== undefined ? { resourceType: input.data.resourceType } : {}),
...(input.data.chgResponsibility !== undefined ? { chgResponsibility: input.data.chgResponsibility } : {}),
...(input.data.rolledOff !== undefined ? { rolledOff: input.data.rolledOff } : {}),
...(input.data.departed !== undefined ? { departed: input.data.departed } : {}),
...(input.data.enterpriseId !== undefined ? { enterpriseId: input.data.enterpriseId || null } : {}),
...(input.data.clientUnitId !== undefined ? { clientUnitId: input.data.clientUnitId || null } : {}),
...(input.data.fte !== undefined ? { fte: input.data.fte } : {}),
} as unknown as Parameters<typeof ctx.db.resource.update>[0]["data"],
include: {
resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } } },
},
});
const updated = await ctx.db.$transaction(async (tx) => {
const result = await tx.resource.update({
where: { id: input.id },
data: {
...(input.data.displayName !== undefined ? { displayName: input.data.displayName } : {}),
...(input.data.email !== undefined ? { email: input.data.email } : {}),
...(input.data.chapter !== undefined ? { chapter: input.data.chapter } : {}),
...(input.data.lcrCents !== undefined ? { lcrCents: input.data.lcrCents } : {}),
...(input.data.ucrCents !== undefined ? { ucrCents: input.data.ucrCents } : {}),
...(input.data.currency !== undefined ? { currency: input.data.currency } : {}),
...(input.data.chargeabilityTarget !== undefined ? { chargeabilityTarget: input.data.chargeabilityTarget } : {}),
...(input.data.availability !== undefined ? { availability: input.data.availability as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}),
...(input.data.skills !== undefined ? { skills: input.data.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}),
...(input.data.dynamicFields !== undefined ? { dynamicFields: input.data.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}),
...(input.data.blueprintId !== undefined ? { blueprintId: input.data.blueprintId } : {}),
...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}),
...(input.data.portfolioUrl !== undefined ? { portfolioUrl: input.data.portfolioUrl || null } : {}),
...(input.data.roleId !== undefined ? { roleId: input.data.roleId || null } : {}),
...(input.data.postalCode !== undefined ? { postalCode: input.data.postalCode } : {}),
...(input.data.postalCode && !input.data.federalState
? { federalState: inferStateFromPostalCode(input.data.postalCode) }
: input.data.federalState !== undefined
? { federalState: input.data.federalState }
: {}),
...(input.data.countryId !== undefined ? { countryId: input.data.countryId || null } : {}),
...(input.data.metroCityId !== undefined ? { metroCityId: input.data.metroCityId || null } : {}),
...(input.data.orgUnitId !== undefined ? { orgUnitId: input.data.orgUnitId || null } : {}),
...(input.data.managementLevelGroupId !== undefined ? { managementLevelGroupId: input.data.managementLevelGroupId || null } : {}),
...(input.data.managementLevelId !== undefined ? { managementLevelId: input.data.managementLevelId || null } : {}),
...(input.data.resourceType !== undefined ? { resourceType: input.data.resourceType } : {}),
...(input.data.chgResponsibility !== undefined ? { chgResponsibility: input.data.chgResponsibility } : {}),
...(input.data.rolledOff !== undefined ? { rolledOff: input.data.rolledOff } : {}),
...(input.data.departed !== undefined ? { departed: input.data.departed } : {}),
...(input.data.enterpriseId !== undefined ? { enterpriseId: input.data.enterpriseId || null } : {}),
...(input.data.clientUnitId !== undefined ? { clientUnitId: input.data.clientUnitId || null } : {}),
...(input.data.fte !== undefined ? { fte: input.data.fte } : {}),
} as unknown as Parameters<typeof tx.resource.update>[0]["data"],
include: {
resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } } },
},
});
if (input.data.roles !== undefined) {
await ctx.db.resourceRole.deleteMany({ where: { resourceId: input.id } });
if (input.data.roles.length > 0) {
await ctx.db.resourceRole.createMany({
data: input.data.roles.map((role) => ({
resourceId: input.id,
roleId: role.roleId,
isPrimary: role.isPrimary,
})),
});
if (input.data.roles !== undefined) {
await tx.resourceRole.deleteMany({ where: { resourceId: input.id } });
if (input.data.roles.length > 0) {
await tx.resourceRole.createMany({
data: input.data.roles.map((role) => ({
resourceId: input.id,
roleId: role.roleId,
isPrimary: role.isPrimary,
})),
});
}
}
}
await ctx.db.auditLog.create({
data: {
entityType: "Resource",
entityId: input.id,
action: "UPDATE",
changes: { before: existing, after: updated },
},
await tx.auditLog.create({
data: {
entityType: "Resource",
entityId: input.id,
action: "UPDATE",
changes: { before: existing, after: result },
},
});
return result;
});
return updated;
@@ -191,18 +199,22 @@ export const resourceMutationProcedures = {
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const resource = await ctx.db.resource.update({
where: { id: input.id },
data: { isActive: false },
});
const resource = await ctx.db.$transaction(async (tx) => {
const result = await tx.resource.update({
where: { id: input.id },
data: { isActive: false },
});
await ctx.db.auditLog.create({
data: {
entityType: "Resource",
entityId: input.id,
action: "UPDATE",
changes: { after: { isActive: false } },
},
await tx.auditLog.create({
data: {
entityType: "Resource",
entityId: input.id,
action: "UPDATE",
changes: { after: { isActive: false } },
},
});
return result;
});
return resource;
@@ -212,19 +224,23 @@ export const resourceMutationProcedures = {
.input(z.object({ ids: z.array(z.string()).min(1).max(100) }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const updated = await ctx.db.$transaction(
input.ids.map((id) =>
ctx.db.resource.update({ where: { id }, data: { isActive: false } }),
),
);
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 } }),
),
);
await ctx.db.auditLog.create({
data: {
entityType: "Resource",
entityId: input.ids.join(","),
action: "UPDATE",
changes: { after: { isActive: false, ids: input.ids } },
},
await tx.auditLog.create({
data: {
entityType: "Resource",
entityId: input.ids.join(","),
action: "UPDATE",
changes: { after: { isActive: false, ids: input.ids } },
},
});
return results;
});
return { count: updated.length };
@@ -238,23 +254,25 @@ export const resourceMutationProcedures = {
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
await ctx.db.$transaction(
input.ids.map((id) =>
ctx.db.$executeRaw`
UPDATE "Resource"
SET "dynamicFields" = "dynamicFields" || ${JSON.stringify(input.fields)}::jsonb
WHERE id = ${id}
`,
),
);
await ctx.db.$transaction(async (tx) => {
await Promise.all(
input.ids.map((id) =>
tx.$executeRaw`
UPDATE "Resource"
SET "dynamicFields" = "dynamicFields" || ${JSON.stringify(input.fields)}::jsonb
WHERE id = ${id}
`,
),
);
await ctx.db.auditLog.create({
data: {
entityType: "Resource",
entityId: input.ids.join(","),
action: "UPDATE",
changes: { after: { dynamicFields: input.fields, ids: input.ids } } as unknown as import("@capakraken/db").Prisma.InputJsonValue,
},
await tx.auditLog.create({
data: {
entityType: "Resource",
entityId: input.ids.join(","),
action: "UPDATE",
changes: { after: { dynamicFields: input.fields, ids: input.ids } } as unknown as import("@capakraken/db").Prisma.InputJsonValue,
},
});
});
return { updated: input.ids.length };
@@ -271,20 +289,20 @@ export const resourceMutationProcedures = {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
await ctx.db.$transaction([
ctx.db.assignment.deleteMany({ where: { resourceId: input.id } }),
ctx.db.vacation.deleteMany({ where: { resourceId: input.id } }),
ctx.db.resource.delete({ where: { id: input.id } }),
]);
await ctx.db.$transaction(async (tx) => {
await tx.assignment.deleteMany({ where: { resourceId: input.id } });
await tx.vacation.deleteMany({ where: { resourceId: input.id } });
await tx.resource.delete({ where: { id: input.id } });
await ctx.db.auditLog.create({
data: {
entityType: "Resource",
entityId: input.id,
action: "DELETE",
userId: ctx.dbUser?.id,
changes: { before: resource } as unknown as import("@capakraken/db").Prisma.InputJsonValue,
},
await tx.auditLog.create({
data: {
entityType: "Resource",
entityId: input.id,
action: "DELETE",
userId: ctx.dbUser?.id,
changes: { before: resource } as unknown as import("@capakraken/db").Prisma.InputJsonValue,
},
});
});
return { deleted: true };
@@ -298,20 +316,20 @@ export const resourceMutationProcedures = {
select: { id: true, displayName: true, eid: true },
});
await ctx.db.$transaction([
ctx.db.assignment.deleteMany({ where: { resourceId: { in: input.ids } } }),
ctx.db.vacation.deleteMany({ where: { resourceId: { in: input.ids } } }),
ctx.db.resource.deleteMany({ where: { id: { in: input.ids } } }),
]);
await ctx.db.$transaction(async (tx) => {
await tx.assignment.deleteMany({ where: { resourceId: { in: input.ids } } });
await tx.vacation.deleteMany({ where: { resourceId: { in: input.ids } } });
await tx.resource.deleteMany({ where: { id: { in: input.ids } } });
await ctx.db.auditLog.createMany({
data: resources.map((r) => ({
entityType: "Resource",
entityId: r.id,
action: "DELETE",
userId: ctx.dbUser?.id,
changes: { before: r } as unknown as import("@capakraken/db").Prisma.InputJsonValue,
})),
await tx.auditLog.createMany({
data: resources.map((r) => ({
entityType: "Resource",
entityId: r.id,
action: "DELETE",
userId: ctx.dbUser?.id,
changes: { before: r } as unknown as import("@capakraken/db").Prisma.InputJsonValue,
})),
});
});
return { deleted: resources.length };