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[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[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; 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[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 }; }), };