refactor(api): extract report query engine
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
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<EntityKey, Set<string>> = {
|
||||
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<typeof ReportInputSchema>;
|
||||
export type FilterInput = z.infer<typeof FilterSchema>;
|
||||
|
||||
export interface ReportGroupSummary {
|
||||
key: string;
|
||||
label: string;
|
||||
rowCount: number;
|
||||
startIndex: number;
|
||||
}
|
||||
|
||||
export interface ReportQueryResult {
|
||||
rows: Record<string, unknown>[];
|
||||
columns: string[];
|
||||
totalCount: number;
|
||||
groups: ReportGroupSummary[];
|
||||
}
|
||||
|
||||
export function validateReportInput(input: ReportInput | z.infer<typeof ReportTemplateConfigSchema>): 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<string, unknown> {
|
||||
const entityColumns = COLUMN_MAP[entity];
|
||||
const select: Record<string, unknown> = { 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<string, unknown> }).select
|
||||
: {};
|
||||
mergeSelectPath(relationSelect, fieldSegments);
|
||||
select[relationName] = { select: relationSelect };
|
||||
} else {
|
||||
select[colKey] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return select;
|
||||
}
|
||||
|
||||
function mergeSelectPath(target: Record<string, unknown>, 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<string, unknown> }).select
|
||||
: {};
|
||||
|
||||
mergeSelectPath(nestedSelect, tail);
|
||||
target[head] = { select: nestedSelect };
|
||||
}
|
||||
|
||||
export function buildWhere(
|
||||
entity: EntityKey,
|
||||
filters: Array<{ field: string; op: string; value: string }>,
|
||||
): Record<string, unknown> {
|
||||
const where: Record<string, unknown> = {};
|
||||
|
||||
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<string, unknown>, prefix = ""): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
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<string, unknown>, 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;
|
||||
}
|
||||
Reference in New Issue
Block a user