Files
CapaKraken/packages/api/src/router/report-template-procedure-support.ts
T

354 lines
9.7 KiB
TypeScript

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<TRPCContext, "db" | "dbUser">;
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<typeof SaveReportTemplateInputSchema>,
) {
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<typeof DeleteReportTemplateInputSchema>,
) {
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<typeof SaveReportTemplateInputSchema>,
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<typeof SaveReportTemplateInputSchema>,
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<typeof SaveReportTemplateInputSchema>,
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<typeof ReportTemplateConfigSchema>,
): 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}` });
}
}