Files
Nexus/packages/api/src/router/resource-mutations.ts
T
Hartmut 40ca0c3046
CI / Architecture Guardrails (pull_request) Successful in 2m6s
CI / Lint (pull_request) Successful in 7m29s
CI / Typecheck (pull_request) Successful in 8m3s
CI / Unit Tests (pull_request) Successful in 8m11s
CI / Build (pull_request) Successful in 5m24s
CI / E2E Tests (pull_request) Successful in 5m25s
CI / Fresh-Linux Docker Deploy (pull_request) Successful in 6m30s
CI / Release Images (pull_request) Has been skipped
CI / Assistant Split Regression (pull_request) Successful in 3m47s
security: bound Zod inputs, add SSE per-user cap and tRPC body limit (#51)
Mechanical .max() bounds across 9 router schemas per the convention in
#51: IDs at 64, names at 200, search/filter strings at 500, arrays at
100-5000 depending on domain. Webhook secret bounded at min(16)/max(256).

Reports route now validates startDate/endDate via zod with year bounds
and rejects end<start. SSE timeline route enforces a per-user connection
cap of 8 (returns 429 with Retry-After). tRPC route rejects bodies over
2 MiB via Content-Length check before auth/DB work.

Covers 12 call-sites listed in #51. ESLint rule and zod conventions doc
remain as follow-up.
2026-04-18 13:31:18 +02:00

467 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
BlueprintTarget,
CreateResourceSchema,
PermissionKey,
ResourceRoleSchema,
UpdateResourceSchema,
inferStateFromPostalCode,
} from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { ROLE_BRIEF_SELECT } from "../db/selects.js";
import { adminProcedure, managerProcedure, requirePermission } from "../trpc.js";
import {
assertBlueprintDynamicFields,
getAllowedDynamicFieldKeys,
} from "./blueprint-validation.js";
export const resourceMutationProcedures = {
create: managerProcedure
.input(CreateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const existing = await ctx.db.resource.findFirst({
where: { OR: [{ eid: input.eid }, { email: input.email }] },
});
if (existing) {
throw new TRPCError({
code: "CONFLICT",
message: `Resource with EID "${input.eid}" or email "${input.email}" already exists`,
});
}
await assertBlueprintDynamicFields({
db: ctx.db,
blueprintId: input.blueprintId,
dynamicFields: input.dynamicFields,
target: BlueprintTarget.RESOURCE,
});
const primaryCount = (input.roles ?? []).filter((role) => role.isPrimary).length;
if (primaryCount > 1) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "A resource can have at most one primary role",
});
}
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 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;
}),
update: managerProcedure
.input(
z.object({
id: z.string(),
data: UpdateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() }),
}),
)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const existing = await findUniqueOrThrow(
ctx.db.resource.findUnique({ where: { id: input.id } }),
"Resource",
);
const nextBlueprintId = input.data.blueprintId ?? existing.blueprintId ?? undefined;
const nextDynamicFields = (input.data.dynamicFields ??
existing.dynamicFields ??
{}) as Record<string, unknown>;
await assertBlueprintDynamicFields({
db: ctx.db,
blueprintId: nextBlueprintId,
dynamicFields: nextDynamicFields,
target: BlueprintTarget.RESOURCE,
});
if (input.data.roles !== undefined) {
const primaryCount = input.data.roles.filter((role) => role.isPrimary).length;
if (primaryCount > 1) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "A resource can have at most one primary role",
});
}
}
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 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 tx.auditLog.create({
data: {
entityType: "Resource",
entityId: input.id,
action: "UPDATE",
changes: { before: existing, after: result },
},
});
return result;
});
return updated;
}),
deactivate: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const resource = await ctx.db.$transaction(async (tx) => {
const result = await tx.resource.update({
where: { id: input.id },
data: { isActive: false, deletedAt: new Date() },
});
await tx.auditLog.create({
data: {
entityType: "Resource",
entityId: input.id,
action: "UPDATE",
changes: { after: { isActive: false } },
},
});
return result;
});
return resource;
}),
batchDeactivate: managerProcedure
.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(async (tx) => {
const results = await Promise.all(
input.ids.map((id) =>
tx.resource.update({ where: { id }, data: { isActive: false, deletedAt: new Date() } }),
),
);
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 };
}),
batchUpdateCustomFields: managerProcedure
.input(
z.object({
ids: z.array(z.string()).min(1).max(100),
fields: z
.record(
z.string().min(1).max(128),
z.union([z.string().max(8_000), z.number(), z.boolean(), z.null()]),
)
.refine((r) => Object.keys(r).length <= 100, {
message: "Too many custom-field keys in one batch (max 100)",
}),
}),
)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
// Whitelist input keys against the union of (each resource's blueprint
// field defs) (all active global RESOURCE blueprints). Rejects any key
// that is not explicitly defined for every target resource — blocks
// namespace pollution and privilege escalation via admin-tool
// interpretation of attacker-placed JSONB keys.
const resources = await ctx.db.resource.findMany({
where: { id: { in: input.ids } },
select: { id: true, blueprintId: true },
});
if (resources.length !== input.ids.length) {
throw new TRPCError({ code: "NOT_FOUND", message: "One or more resources not found" });
}
const inputKeys = Object.keys(input.fields);
for (const resource of resources) {
const allowed = await getAllowedDynamicFieldKeys({
db: ctx.db,
blueprintId: resource.blueprintId ?? undefined,
target: BlueprintTarget.RESOURCE,
});
// If no blueprint at all is registered for this resource, `allowed` is
// empty — we still enforce the whitelist to refuse any key rather than
// silently accepting arbitrary JSONB. This is stricter than the legacy
// create/update paths but correct for a bulk endpoint.
const unknownKey = inputKeys.find((k) => !allowed.has(k));
if (unknownKey !== undefined) {
throw new TRPCError({
code: "UNPROCESSABLE_CONTENT",
message: `Unknown dynamic-field key "${unknownKey}" for resource ${resource.id}`,
});
}
// Still validate values via the existing per-key typed validator.
await assertBlueprintDynamicFields({
db: ctx.db,
blueprintId: resource.blueprintId ?? undefined,
dynamicFields: input.fields,
target: BlueprintTarget.RESOURCE,
});
}
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 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 };
}),
hardDelete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const resource = await ctx.db.resource.findUnique({
where: { id: input.id },
select: { id: true, displayName: true, eid: true },
});
if (!resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
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 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 };
}),
batchHardDelete: adminProcedure
.input(z.object({ ids: z.array(z.string().max(64)).min(1).max(500) }))
.mutation(async ({ ctx, input }) => {
const resources = await ctx.db.resource.findMany({
where: { id: { in: input.ids } },
select: { id: true, displayName: true, eid: true },
});
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 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 };
}),
};