285 lines
7.5 KiB
TypeScript
285 lines
7.5 KiB
TypeScript
import { Prisma } from "@capakraken/db";
|
|
import { TRPCError } from "@trpc/server";
|
|
import { z } from "zod";
|
|
import type { TRPCContext } from "../trpc.js";
|
|
import {
|
|
buildResourceMonthTemplateCompleteness,
|
|
type ResourceMonthTemplateCompleteness,
|
|
} from "./report-blueprints-support.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;
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
return buildResourceMonthTemplateCompleteness(config.columns);
|
|
}
|
|
|
|
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}` });
|
|
}
|
|
}
|