import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { COLUMN_MAP, type ColumnDef, RESOURCE_MONTH_COLUMNS } from "./report-columns.js"; export const ENTITY_MAP = { resource: "resource", project: "project", assignment: "assignment", resource_month: "resource_month", } as const; export type EntityKey = keyof typeof ENTITY_MAP; const PERIOD_MONTH_PATTERN = /^\d{4}-(0[1-9]|1[0-2])$/; const ALLOWED_SCALAR_FIELDS: Record> = { resource: new Set([ "id", "eid", "displayName", "email", "chapter", "resourceType", "lcrCents", "ucrCents", "currency", "chargeabilityTarget", "fte", "isActive", "chgResponsibility", "rolledOff", "departed", "postalCode", "federalState", "createdAt", "updatedAt", ]), project: new Set([ "id", "shortCode", "name", "orderType", "allocationType", "status", "winProbability", "budgetCents", "startDate", "endDate", "responsiblePerson", "createdAt", "updatedAt", ]), assignment: new Set([ "id", "startDate", "endDate", "hoursPerDay", "percentage", "role", "dailyCostCents", "status", "createdAt", "updatedAt", ]), resource_month: new Set(RESOURCE_MONTH_COLUMNS.map((column) => column.key)), }; export function getValidScalarField(entity: EntityKey, field: string): string | null { if (ALLOWED_SCALAR_FIELDS[entity].has(field)) { return field; } return null; } export function getColumnDef(entity: EntityKey, columnKey: string): ColumnDef | undefined { return COLUMN_MAP[entity].find((column) => column.key === columnKey); } function assertKnownColumns(entity: EntityKey, columns: string[]): void { const invalidColumns = columns.filter((column) => !getColumnDef(entity, column)); if (invalidColumns.length > 0) { throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown columns for ${entity}: ${invalidColumns.join(", ")}`, }); } } function assertValidFilterField(entity: EntityKey, field: string): string { if (entity === "resource_month") { if (!getColumnDef(entity, field)) { throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown filter field for ${entity}: ${field}`, }); } return field; } const validField = getValidScalarField(entity, field); if (!validField) { throw new TRPCError({ code: "BAD_REQUEST", message: `Unsupported filter field for ${entity}: ${field}`, }); } return validField; } function assertValidSortField(entity: EntityKey, field: string): void { if (entity === "resource_month") { if (!getColumnDef(entity, field)) { throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown sort field for ${entity}: ${field}`, }); } return; } if (!getValidScalarField(entity, field)) { throw new TRPCError({ code: "BAD_REQUEST", message: `Unsupported sort field for ${entity}: ${field}`, }); } } function assertValidGroupField(entity: EntityKey, field: string): void { const knownField = entity === "resource_month" ? getColumnDef(entity, field)?.key : getValidScalarField(entity, field); if (!knownField) { throw new TRPCError({ code: "BAD_REQUEST", message: `Unsupported group field for ${entity}: ${field}`, }); } } function parseFilterValueOrThrow(def: ColumnDef, value: string): unknown { if (def.dataType === "number") { const parsed = Number(value); if (Number.isNaN(parsed)) { throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid numeric filter value for ${def.key}: ${value}`, }); } return parsed; } if (def.dataType === "boolean") { if (value !== "true" && value !== "false") { throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid boolean filter value for ${def.key}: ${value}`, }); } return value === "true"; } if (def.dataType === "date") { const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) { throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid date filter value for ${def.key}: ${value}`, }); } return parsed; } return value; } export const reportEntitySchema = z.enum(["resource", "project", "assignment", "resource_month"]); export const FilterSchema = z.object({ field: z.string().min(1), op: z.enum(["eq", "neq", "gt", "lt", "gte", "lte", "contains", "in"]), value: z.string(), }); export const ReportInputSchema = z.object({ entity: reportEntitySchema, columns: z.array(z.string()).min(1), filters: z.array(FilterSchema).default([]), groupBy: z.string().optional(), sortBy: z.string().optional(), sortDir: z.enum(["asc", "desc"]).default("asc"), periodMonth: z.string().regex(PERIOD_MONTH_PATTERN).optional(), limit: z.number().int().min(1).max(5000).default(50), offset: z.number().int().min(0).default(0), }); export const ReportTemplateConfigSchema = ReportInputSchema.omit({ limit: true, offset: true }); export type ReportInput = z.infer; export type FilterInput = z.infer; export interface ReportGroupSummary { key: string; label: string; rowCount: number; startIndex: number; } export interface ReportQueryResult { rows: Record[]; columns: string[]; totalCount: number; groups: ReportGroupSummary[]; } export function validateReportInput(input: ReportInput | z.infer): void { assertKnownColumns(input.entity, input.columns); if (input.periodMonth && !PERIOD_MONTH_PATTERN.test(input.periodMonth)) { throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid periodMonth: ${input.periodMonth}. Expected YYYY-MM with a month between 01 and 12.`, }); } if (input.entity !== "resource_month" && input.periodMonth) { throw new TRPCError({ code: "BAD_REQUEST", message: "periodMonth is only supported for resource_month reports", }); } if (input.sortBy) { assertValidSortField(input.entity, input.sortBy); } if (input.groupBy) { assertValidGroupField(input.entity, input.groupBy); } for (const filter of input.filters) { const field = assertValidFilterField(input.entity, filter.field); const def = getColumnDef(input.entity, field); if (!def) { throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown filter field for ${input.entity}: ${filter.field}`, }); } if (filter.op === "contains" || filter.op === "in") { if (def.dataType !== "string") { throw new TRPCError({ code: "BAD_REQUEST", message: `Filter operator ${filter.op} is only supported for string fields like ${def.key}`, }); } continue; } void parseFilterValueOrThrow(def, filter.value); } } export function buildSelect(entity: EntityKey, columns: string[]): Record { const entityColumns = COLUMN_MAP[entity]; const select: Record = { id: true }; for (const colKey of columns) { const def = entityColumns.find((column) => column.key === colKey); if (!def) { continue; } if (colKey.includes(".")) { const relationName = def.prismaPath ?? colKey.split(".")[0]!; const existing = select[relationName]; const fieldSegments = colKey.split(".").slice(1); const relationSelect = existing && typeof existing === "object" && existing !== null && "select" in existing ? (existing as { select: Record }).select : {}; mergeSelectPath(relationSelect, fieldSegments); select[relationName] = { select: relationSelect }; } else { select[colKey] = true; } } return select; } function mergeSelectPath(target: Record, segments: string[]): void { const [head, ...tail] = segments; if (!head) { return; } if (tail.length === 0) { target[head] = true; return; } const existing = target[head]; const nestedSelect = existing && typeof existing === "object" && existing !== null && "select" in existing ? (existing as { select: Record }).select : {}; mergeSelectPath(nestedSelect, tail); target[head] = { select: nestedSelect }; } export function buildWhere( entity: EntityKey, filters: Array<{ field: string; op: string; value: string }>, ): Record { const where: Record = {}; for (const filter of filters) { const field = assertValidFilterField(entity, filter.field); const colDef = getColumnDef(entity, field); if (!colDef) { throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown filter field for ${entity}: ${filter.field}`, }); } const parsedValue = parseFilterValueOrThrow(colDef, filter.value); switch (filter.op) { case "eq": where[field] = parsedValue; break; case "neq": where[field] = { not: parsedValue }; break; case "gt": where[field] = { gt: parsedValue }; break; case "lt": where[field] = { lt: parsedValue }; break; case "gte": where[field] = { gte: parsedValue }; break; case "lte": where[field] = { lte: parsedValue }; break; case "contains": if (colDef.dataType !== "string") { throw new TRPCError({ code: "BAD_REQUEST", message: `Filter operator contains is only supported for string fields like ${field}`, }); } where[field] = { contains: filter.value, mode: "insensitive" }; break; case "in": if (colDef.dataType !== "string") { throw new TRPCError({ code: "BAD_REQUEST", message: `Filter operator in is only supported for string fields like ${field}`, }); } where[field] = { in: filter.value.split(",").map((value) => value.trim()) }; break; default: throw new TRPCError({ code: "BAD_REQUEST", message: `Unsupported filter operator: ${filter.op}`, }); } } return where; } export function flattenRow(row: Record, prefix = ""): Record { const result: Record = {}; for (const [key, value] of Object.entries(row)) { const fullKey = prefix ? `${prefix}.${key}` : key; if (value !== null && typeof value === "object" && !(value instanceof Date) && !Array.isArray(value)) { Object.assign(result, flattenRow(value as Record, fullKey)); } else { result[fullKey] = value; } } return result; } export function csvEscape(value: unknown): string { if (value === null || value === undefined) { return ""; } if (value instanceof Date) { return value.toISOString(); } const str = String(value); if (str.includes(",") || str.includes('"') || str.includes("\n")) { return `"${str.replace(/"/g, '""')}"`; } return str; }