import { Prisma } from "@capakraken/db"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import type { TRPCContext } from "../trpc.js"; import { type EntityKey, ReportTemplateConfigSchema, validateReportInput, } from "./report-query-config.js"; const ReportTemplateEntity = { RESOURCE: "RESOURCE", PROJECT: "PROJECT", ASSIGNMENT: "ASSIGNMENT", RESOURCE_MONTH: "RESOURCE_MONTH", } as const; type ReportTemplateEntity = (typeof ReportTemplateEntity)[keyof typeof ReportTemplateEntity]; type ReportTemplateRecord = { id: string; name: string; description: string | null; entity: ReportTemplateEntity; config: unknown; isShared: boolean; ownerId: string; updatedAt: Date; }; const RESOURCE_MONTH_RECOMMENDED_COLUMNS = [ "monthKey", "displayName", "eid", "chapter", "countryCode", "countryName", "federalState", "metroCityName", "orgUnitName", "managementLevelGroupName", "monthlyBaseWorkingDays", "monthlyEffectiveWorkingDays", "monthlyBaseAvailableHours", "monthlyPublicHolidayCount", "monthlyPublicHolidayWorkdayCount", "monthlyPublicHolidayHoursDeduction", "monthlyAbsenceDayEquivalent", "monthlyAbsenceHoursDeduction", "monthlySahHours", "monthlyChargeabilityTargetPct", "monthlyTargetHours", "monthlyActualBookedHours", "monthlyExpectedBookedHours", "monthlyActualChargeabilityPct", "monthlyExpectedChargeabilityPct", "monthlyUnassignedHours", ] as const; const RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS = [ "monthKey", "displayName", "countryName", "federalState", "metroCityName", "monthlyPublicHolidayCount", "monthlyPublicHolidayHoursDeduction", "monthlyAbsenceDayEquivalent", "monthlyAbsenceHoursDeduction", "monthlySahHours", "monthlyTargetHours", "monthlyActualBookedHours", "monthlyUnassignedHours", ] as const; type ResourceMonthTemplateCompleteness = { scope: "resource_month"; isAuditReady: boolean; isRecommendedComplete: boolean; recommendedColumnCount: number; selectedRecommendedColumnCount: number; minimumAuditColumnCount: number; selectedMinimumAuditColumnCount: number; missingRecommendedColumns: string[]; missingMinimumAuditColumns: string[]; }; type ReportTemplateContext = Pick; export const SaveReportTemplateInputSchema = z.object({ id: z.string().optional(), name: z.string().trim().min(1).max(120), description: z.string().trim().max(500).optional(), isShared: z.boolean().default(false), config: ReportTemplateConfigSchema, }); export const DeleteReportTemplateInputSchema = z.object({ id: z.string(), }); export async function listReportTemplates(ctx: ReportTemplateContext) { const ownerId = ctx.dbUser!.id; const templates = await ctx.db.reportTemplate.findMany({ where: { OR: [ { ownerId }, { isShared: true }, ], }, orderBy: [{ name: "asc" }], select: { id: true, name: true, description: true, entity: true, config: true, isShared: true, ownerId: true, updatedAt: true, }, }); return templates.map((template) => mapTemplateRecord(template, ownerId)); } export async function saveReportTemplate( ctx: ReportTemplateContext, input: z.infer, ) { const ownerId = ctx.dbUser!.id; validateReportInput(input.config); const payload = input.config as unknown as Prisma.InputJsonValue; const entity = toTemplateEntity(input.config.entity); if (input.id) { const existing = await ctx.db.reportTemplate.findUnique({ where: { id: input.id }, select: { ownerId: true }, }); if (!existing || existing.ownerId !== ownerId) { throw new TRPCError({ code: "FORBIDDEN", message: "Template cannot be updated" }); } try { return await ctx.db.reportTemplate.update({ where: { id: input.id }, data: buildTemplateUpdateData(input, entity, payload), select: { id: true, updatedAt: true }, }); } catch (error) { throw mapTemplateWriteError(error, input.name); } } try { return await ctx.db.reportTemplate.upsert({ where: { ownerId_name: { ownerId, name: input.name, }, }, update: buildTemplateUpsertUpdateData(input, entity, payload), create: buildTemplateCreateData(ownerId, input, entity, payload), select: { id: true, updatedAt: true }, }); } catch (error) { throw mapTemplateWriteError(error, input.name); } } export async function deleteReportTemplate( ctx: ReportTemplateContext, input: z.infer, ) { const ownerId = ctx.dbUser!.id; const existing = await ctx.db.reportTemplate.findUnique({ where: { id: input.id }, select: { ownerId: true }, }); if (!existing || existing.ownerId !== ownerId) { throw new TRPCError({ code: "FORBIDDEN", message: "Template cannot be deleted" }); } await ctx.db.reportTemplate.delete({ where: { id: input.id } }); return { ok: true }; } function normalizeTemplateDescription(description: string | undefined): string | null | undefined { if (description === undefined) { return undefined; } return description.length > 0 ? description : null; } function mapTemplateWriteError(error: unknown, templateName: string): TRPCError { if ( typeof error === "object" && error !== null && "code" in error && error.code === "P2002" ) { return new TRPCError({ code: "CONFLICT", message: `A report template named "${templateName}" already exists`, }); } if (error instanceof TRPCError) { return error; } return new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to save report template", cause: error, }); } function buildTemplateCreateData( ownerId: string, input: z.infer, entity: ReportTemplateEntity, config: Prisma.InputJsonValue, ) : Prisma.ReportTemplateUncheckedCreateInput { const description = normalizeTemplateDescription(input.description); const data: Prisma.ReportTemplateUncheckedCreateInput = { ownerId, name: input.name, entity, config, isShared: input.isShared, }; if (description !== undefined) { data.description = description; } return data; } function buildTemplateUpdateData( input: z.infer, entity: ReportTemplateEntity, config: Prisma.InputJsonValue, ): Prisma.ReportTemplateUncheckedUpdateInput { const description = normalizeTemplateDescription(input.description); const data: Prisma.ReportTemplateUncheckedUpdateInput = { name: input.name, entity, config, isShared: input.isShared, }; if (description !== undefined) { data.description = description; } return data; } function buildTemplateUpsertUpdateData( input: z.infer, entity: ReportTemplateEntity, config: Prisma.InputJsonValue, ): Prisma.ReportTemplateUncheckedUpdateInput { const description = normalizeTemplateDescription(input.description); const data: Prisma.ReportTemplateUncheckedUpdateInput = { entity, config, isShared: input.isShared, }; if (description !== undefined) { data.description = description; } return data; } function mapTemplateRecord(template: ReportTemplateRecord, ownerId: string) { const config = ReportTemplateConfigSchema.parse(template.config); return { id: template.id, name: template.name, description: template.description, entity: fromTemplateEntity(template.entity), config, isShared: template.isShared, isOwner: template.ownerId === ownerId, completeness: getTemplateCompleteness(config), updatedAt: template.updatedAt, }; } function getTemplateCompleteness( config: z.infer, ): ResourceMonthTemplateCompleteness | null { if (config.entity !== "resource_month") { return null; } const selectedColumns = new Set(config.columns); const missingRecommendedColumns = RESOURCE_MONTH_RECOMMENDED_COLUMNS .filter((column) => !selectedColumns.has(column)); const missingMinimumAuditColumns = RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS .filter((column) => !selectedColumns.has(column)); return { scope: "resource_month", isAuditReady: missingMinimumAuditColumns.length === 0, isRecommendedComplete: missingRecommendedColumns.length === 0, recommendedColumnCount: RESOURCE_MONTH_RECOMMENDED_COLUMNS.length, selectedRecommendedColumnCount: RESOURCE_MONTH_RECOMMENDED_COLUMNS.length - missingRecommendedColumns.length, minimumAuditColumnCount: RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS.length, selectedMinimumAuditColumnCount: RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS.length - missingMinimumAuditColumns.length, missingRecommendedColumns, missingMinimumAuditColumns, }; } function toTemplateEntity(entity: EntityKey): ReportTemplateEntity { switch (entity) { case "resource": return ReportTemplateEntity.RESOURCE; case "project": return ReportTemplateEntity.PROJECT; case "assignment": return ReportTemplateEntity.ASSIGNMENT; case "resource_month": return ReportTemplateEntity.RESOURCE_MONTH; default: throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown entity: ${entity}` }); } } function fromTemplateEntity(entity: ReportTemplateEntity): EntityKey { switch (entity) { case ReportTemplateEntity.RESOURCE: return "resource"; case ReportTemplateEntity.PROJECT: return "project"; case ReportTemplateEntity.ASSIGNMENT: return "assignment"; case ReportTemplateEntity.RESOURCE_MONTH: return "resource_month"; default: throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown entity: ${entity}` }); } }