refactor(api): strengthen report template persistence

This commit is contained in:
2026-03-31 22:35:15 +02:00
parent f2bcf4b7f0
commit cb8669c489
2 changed files with 324 additions and 29 deletions
@@ -28,6 +28,63 @@ type ReportTemplateRecord = {
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({
@@ -75,7 +132,6 @@ export async function saveReportTemplate(
validateReportInput(input.config);
const payload = input.config as unknown as Prisma.InputJsonValue;
const entity = toTemplateEntity(input.config.entity);
const writeData = buildTemplateWriteData(input, entity, payload);
if (input.id) {
const existing = await ctx.db.reportTemplate.findUnique({
@@ -87,27 +143,32 @@ export async function saveReportTemplate(
throw new TRPCError({ code: "FORBIDDEN", message: "Template cannot be updated" });
}
return ctx.db.reportTemplate.update({
where: { id: input.id },
data: writeData,
select: { id: true, updatedAt: true },
});
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);
}
}
return ctx.db.reportTemplate.upsert({
where: {
ownerId_name: {
ownerId,
name: input.name,
try {
return await ctx.db.reportTemplate.upsert({
where: {
ownerId_name: {
ownerId,
name: input.name,
},
},
},
update: omitNameFromTemplateWriteData(writeData),
create: {
ownerId,
...writeData,
},
select: { id: true, updatedAt: true },
});
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(
@@ -128,40 +189,139 @@ export async function deleteReportTemplate(
return { ok: true };
}
function buildTemplateWriteData(
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,
) {
return {
) : Prisma.ReportTemplateUncheckedCreateInput {
const description = normalizeTemplateDescription(input.description);
const data: Prisma.ReportTemplateUncheckedCreateInput = {
ownerId,
name: input.name,
entity,
config,
isShared: input.isShared,
...(input.description !== undefined ? { description: input.description } : {}),
};
if (description !== undefined) {
data.description = description;
}
return data;
}
function omitNameFromTemplateWriteData(
data: ReturnType<typeof buildTemplateWriteData>,
) {
const { name: _name, ...updateData } = data;
return updateData;
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: ReportTemplateConfigSchema.parse(template.config),
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":