145 lines
4.6 KiB
TypeScript
145 lines
4.6 KiB
TypeScript
import { TRPCError } from "@trpc/server";
|
|
import { z } from "zod";
|
|
import { controllerProcedure } from "../trpc.js";
|
|
import {
|
|
buildSelect,
|
|
buildWhere,
|
|
csvEscape,
|
|
type EntityKey,
|
|
flattenRow,
|
|
getValidScalarField,
|
|
reportEntitySchema,
|
|
ReportInputSchema,
|
|
type ReportInput,
|
|
type ReportQueryResult,
|
|
validateReportInput,
|
|
} from "./report-query-config.js";
|
|
import { COLUMN_MAP } from "./report-columns.js";
|
|
import { buildReportGroups, pickColumns, sortInMemoryRows } from "./report-query-utils.js";
|
|
import { executeResourceMonthReport } from "./report-resource-month-query.js";
|
|
|
|
export const reportQueryProcedures = {
|
|
getAvailableColumns: controllerProcedure
|
|
.input(z.object({ entity: reportEntitySchema }))
|
|
.query(({ input }) => {
|
|
const columns = COLUMN_MAP[input.entity];
|
|
if (!columns) {
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown entity: ${input.entity}` });
|
|
}
|
|
return columns.map(({ key, label, dataType }) => ({ key, label, dataType }));
|
|
}),
|
|
|
|
getReportData: controllerProcedure
|
|
.input(ReportInputSchema)
|
|
.query(async ({ ctx, input }) => executeReportQuery(ctx.db, input)),
|
|
|
|
exportReport: controllerProcedure
|
|
.input(ReportInputSchema.omit({ offset: true }).extend({
|
|
limit: z.number().int().min(1).max(50000).default(5000),
|
|
}))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const result = await executeReportQuery(ctx.db, { ...input, offset: 0 });
|
|
const outputColumns = result.columns;
|
|
const entityColumns = COLUMN_MAP[input.entity];
|
|
const headerLabels = outputColumns.map((key) => {
|
|
const def = entityColumns.find((column) => column.key === key);
|
|
return def?.label ?? key;
|
|
});
|
|
|
|
const csvLines = [headerLabels.map(csvEscape).join(",")];
|
|
const groupByLabel = input.groupBy
|
|
? entityColumns.find((column) => column.key === input.groupBy)?.label ?? input.groupBy
|
|
: null;
|
|
const groupStartByIndex = new Map(
|
|
result.groups.map((group) => [group.startIndex, group] as const),
|
|
);
|
|
|
|
result.rows.forEach((row, index) => {
|
|
const group = groupStartByIndex.get(index);
|
|
if (group && groupByLabel) {
|
|
csvLines.push(outputColumns.map((_, columnIndex) => (
|
|
columnIndex === 0 ? csvEscape(`${groupByLabel}: ${group.label} (${group.rowCount})`) : ""
|
|
)).join(","));
|
|
}
|
|
|
|
csvLines.push(outputColumns.map((column) => csvEscape(row[column])).join(","));
|
|
});
|
|
|
|
return { csv: csvLines.join("\n"), rowCount: result.rows.length };
|
|
}),
|
|
};
|
|
|
|
async function executeReportQuery(
|
|
db: any,
|
|
input: ReportInput,
|
|
): Promise<ReportQueryResult> {
|
|
validateReportInput(input);
|
|
|
|
if (input.entity === "resource_month") {
|
|
return executeResourceMonthReport(db, input);
|
|
}
|
|
|
|
const { entity, columns, filters, sortBy, sortDir, limit, offset } = input;
|
|
const select = buildSelect(entity, columns);
|
|
const where = buildWhere(entity, filters);
|
|
|
|
const orderBy: Record<string, string>[] = [];
|
|
if (input.groupBy) {
|
|
const groupField = getValidScalarField(entity, input.groupBy);
|
|
if (!groupField) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: `Unsupported group field for ${entity}: ${input.groupBy}`,
|
|
});
|
|
}
|
|
orderBy.push({ [groupField]: "asc" });
|
|
}
|
|
if (sortBy) {
|
|
const validField = getValidScalarField(entity, sortBy);
|
|
if (!validField) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: `Unsupported sort field for ${entity}: ${sortBy}`,
|
|
});
|
|
}
|
|
orderBy.push({ [validField]: sortDir });
|
|
}
|
|
|
|
const modelDelegate = getModelDelegate(db, entity);
|
|
const [rawRows, totalCount] = await Promise.all([
|
|
modelDelegate.findMany({
|
|
select,
|
|
where,
|
|
...(orderBy.length > 0 ? { orderBy } : {}),
|
|
take: limit,
|
|
skip: offset,
|
|
}),
|
|
modelDelegate.count({ where }),
|
|
]);
|
|
|
|
const flattenedRows = rawRows.map((row: Record<string, unknown>) => flattenRow(row));
|
|
const rows = sortInMemoryRows(flattenedRows, input.groupBy, sortBy, sortDir, COLUMN_MAP[entity]);
|
|
const outputColumns = ["id", ...columns.filter((column) => column !== "id")];
|
|
const groups = buildReportGroups(rows, input.groupBy);
|
|
|
|
return {
|
|
rows: rows.map((row) => pickColumns(row, outputColumns)),
|
|
columns: outputColumns,
|
|
totalCount,
|
|
groups,
|
|
};
|
|
}
|
|
|
|
function getModelDelegate(db: any, entity: EntityKey) {
|
|
switch (entity) {
|
|
case "resource":
|
|
return db.resource;
|
|
case "project":
|
|
return db.project;
|
|
case "assignment":
|
|
return db.assignment;
|
|
default:
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown entity: ${entity}` });
|
|
}
|
|
}
|