refactor(api): extract report template procedures

This commit is contained in:
2026-03-31 19:35:01 +02:00
parent f7fe5c6d19
commit ded8f1a163
3 changed files with 463 additions and 178 deletions
@@ -0,0 +1,258 @@
import { describe, expect, it, vi } from "vitest";
import {
DeleteReportTemplateInputSchema,
deleteReportTemplate,
listReportTemplates,
SaveReportTemplateInputSchema,
saveReportTemplate,
} from "../router/report-template-procedure-support.js";
function createContext(reportTemplate: Record<string, unknown>) {
return {
db: {
reportTemplate,
},
dbUser: {
id: "user_controller",
systemRole: "CONTROLLER",
permissionOverrides: null,
},
} as const;
}
describe("report template procedure support", () => {
it("lists shared and owned templates with parsed config and ownership flags", async () => {
const updatedAt = new Date("2026-03-31T10:00:00.000Z");
const ctx = createContext({
findMany: vi.fn().mockResolvedValue([
{
id: "tpl_1",
name: "Owned resource month",
description: "Monthly default",
entity: "RESOURCE_MONTH",
config: {
entity: "resource_month",
columns: ["displayName", "monthlySahHours"],
filters: [],
periodMonth: "2026-03",
},
isShared: false,
ownerId: "user_controller",
updatedAt,
},
{
id: "tpl_2",
name: "Shared project",
description: null,
entity: "PROJECT",
config: {
entity: "project",
columns: ["name"],
filters: [],
},
isShared: true,
ownerId: "user_other",
updatedAt,
},
]),
});
const result = await listReportTemplates(ctx);
expect(ctx.db.reportTemplate.findMany).toHaveBeenCalledWith({
where: {
OR: [
{ ownerId: "user_controller" },
{ isShared: true },
],
},
orderBy: [{ name: "asc" }],
select: {
id: true,
name: true,
description: true,
entity: true,
config: true,
isShared: true,
ownerId: true,
updatedAt: true,
},
});
expect(result).toEqual([
{
id: "tpl_1",
name: "Owned resource month",
description: "Monthly default",
entity: "resource_month",
config: {
entity: "resource_month",
columns: ["displayName", "monthlySahHours"],
filters: [],
periodMonth: "2026-03",
sortDir: "asc",
},
isShared: false,
isOwner: true,
updatedAt,
},
{
id: "tpl_2",
name: "Shared project",
description: null,
entity: "project",
config: {
entity: "project",
columns: ["name"],
filters: [],
sortDir: "asc",
},
isShared: true,
isOwner: false,
updatedAt,
},
]);
});
it("upserts new templates under the current owner with converted entity values", async () => {
const upsert = vi.fn().mockResolvedValue({
id: "tpl_new",
updatedAt: new Date("2026-03-31T11:00:00.000Z"),
});
const ctx = createContext({
upsert,
});
const input = SaveReportTemplateInputSchema.parse({
name: "Monthly default",
description: "Team-wide default",
isShared: true,
config: {
entity: "resource_month",
columns: ["displayName", "monthlyTargetHours"],
filters: [],
periodMonth: "2026-03",
},
});
const result = await saveReportTemplate(ctx, input);
expect(result).toEqual({
id: "tpl_new",
updatedAt: new Date("2026-03-31T11:00:00.000Z"),
});
expect(upsert).toHaveBeenCalledWith({
where: {
ownerId_name: {
ownerId: "user_controller",
name: "Monthly default",
},
},
update: {
description: "Team-wide default",
entity: "RESOURCE_MONTH",
config: {
entity: "resource_month",
columns: ["displayName", "monthlyTargetHours"],
filters: [],
periodMonth: "2026-03",
sortDir: "asc",
},
isShared: true,
},
create: {
ownerId: "user_controller",
name: "Monthly default",
description: "Team-wide default",
entity: "RESOURCE_MONTH",
config: {
entity: "resource_month",
columns: ["displayName", "monthlyTargetHours"],
filters: [],
periodMonth: "2026-03",
sortDir: "asc",
},
isShared: true,
},
select: { id: true, updatedAt: true },
});
});
it("updates owned templates and rejects updates for foreign templates", async () => {
const findUnique = vi.fn()
.mockResolvedValueOnce({ ownerId: "user_controller" })
.mockResolvedValueOnce({ ownerId: "user_other" });
const update = vi.fn().mockResolvedValue({
id: "tpl_1",
updatedAt: new Date("2026-03-31T12:00:00.000Z"),
});
const ctx = createContext({
findUnique,
update,
});
const ownedResult = await saveReportTemplate(ctx, SaveReportTemplateInputSchema.parse({
id: "tpl_1",
name: "Owned template",
description: "Updated",
config: {
entity: "project",
columns: ["name"],
filters: [],
},
}));
expect(ownedResult).toEqual({
id: "tpl_1",
updatedAt: new Date("2026-03-31T12:00:00.000Z"),
});
expect(update).toHaveBeenCalledWith({
where: { id: "tpl_1" },
data: {
name: "Owned template",
description: "Updated",
entity: "PROJECT",
config: {
entity: "project",
columns: ["name"],
filters: [],
sortDir: "asc",
},
isShared: false,
},
select: { id: true, updatedAt: true },
});
await expect(saveReportTemplate(ctx, SaveReportTemplateInputSchema.parse({
id: "tpl_2",
name: "Foreign template",
config: {
entity: "resource",
columns: ["displayName"],
filters: [],
},
}))).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Template cannot be updated",
});
});
it("deletes only owned templates", async () => {
const findUnique = vi.fn()
.mockResolvedValueOnce({ ownerId: "user_controller" })
.mockResolvedValueOnce({ ownerId: "user_other" });
const del = vi.fn().mockResolvedValue(undefined);
const ctx = createContext({
findUnique,
delete: del,
});
const result = await deleteReportTemplate(ctx, DeleteReportTemplateInputSchema.parse({ id: "tpl_1" }));
expect(result).toEqual({ ok: true });
expect(del).toHaveBeenCalledWith({ where: { id: "tpl_1" } });
await expect(deleteReportTemplate(ctx, DeleteReportTemplateInputSchema.parse({ id: "tpl_2" }))).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Template cannot be deleted",
});
});
});
@@ -0,0 +1,193 @@
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;
};
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);
const writeData = buildTemplateWriteData(input, entity, payload);
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" });
}
return ctx.db.reportTemplate.update({
where: { id: input.id },
data: writeData,
select: { id: true, updatedAt: true },
});
}
return ctx.db.reportTemplate.upsert({
where: {
ownerId_name: {
ownerId,
name: input.name,
},
},
update: omitNameFromTemplateWriteData(writeData),
create: {
ownerId,
...writeData,
},
select: { id: true, updatedAt: true },
});
}
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 buildTemplateWriteData(
input: z.infer<typeof SaveReportTemplateInputSchema>,
entity: ReportTemplateEntity,
config: Prisma.InputJsonValue,
) {
return {
name: input.name,
entity,
config,
isShared: input.isShared,
...(input.description !== undefined ? { description: input.description } : {}),
};
}
function omitNameFromTemplateWriteData(
data: ReturnType<typeof buildTemplateWriteData>,
) {
const { name: _name, ...updateData } = data;
return updateData;
}
function mapTemplateRecord(template: ReportTemplateRecord, ownerId: string) {
return {
id: template.id,
name: template.name,
description: template.description,
entity: fromTemplateEntity(template.entity),
config: ReportTemplateConfigSchema.parse(template.config),
isShared: template.isShared,
isOwner: template.ownerId === ownerId,
updatedAt: template.updatedAt,
};
}
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}` });
}
}
+12 -178
View File
@@ -1,189 +1,23 @@
import { Prisma } from "@capakraken/db";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { controllerProcedure, createTRPCRouter } from "../trpc.js"; import { controllerProcedure, createTRPCRouter } from "../trpc.js";
import {
type EntityKey,
ReportTemplateConfigSchema,
validateReportInput,
} from "./report-query-config.js";
import { reportQueryProcedures } from "./report-query-engine.js"; import { reportQueryProcedures } from "./report-query-engine.js";
import {
const ReportTemplateEntity = { DeleteReportTemplateInputSchema,
RESOURCE: "RESOURCE", deleteReportTemplate,
PROJECT: "PROJECT", listReportTemplates,
ASSIGNMENT: "ASSIGNMENT", SaveReportTemplateInputSchema,
RESOURCE_MONTH: "RESOURCE_MONTH", saveReportTemplate,
} as const; } from "./report-template-procedure-support.js";
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;
};
function getReportTemplateDelegate(db: unknown) {
return (db as {
reportTemplate: {
findMany: (args: unknown) => Promise<ReportTemplateRecord[]>;
findUnique: (args: unknown) => Promise<{ ownerId: string } | null>;
update: (args: unknown) => Promise<{ id: string; updatedAt: Date }>;
upsert: (args: unknown) => Promise<{ id: string; updatedAt: Date }>;
delete: (args: unknown) => Promise<unknown>;
};
}).reportTemplate;
}
export const reportRouter = createTRPCRouter({ export const reportRouter = createTRPCRouter({
...reportQueryProcedures, ...reportQueryProcedures,
listTemplates: controllerProcedure.query(async ({ ctx }) => { listTemplates: controllerProcedure.query(({ ctx }) => listReportTemplates(ctx)),
const reportTemplate = getReportTemplateDelegate(ctx.db);
const templates = await reportTemplate.findMany({
where: {
OR: [
{ ownerId: ctx.dbUser!.id },
{ 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: ReportTemplateRecord) => ({
id: template.id,
name: template.name,
description: template.description,
entity: fromTemplateEntity(template.entity),
config: ReportTemplateConfigSchema.parse(template.config),
isShared: template.isShared,
isOwner: template.ownerId === ctx.dbUser!.id,
updatedAt: template.updatedAt,
}));
}),
saveTemplate: controllerProcedure saveTemplate: controllerProcedure
.input(z.object({ .input(SaveReportTemplateInputSchema)
id: z.string().optional(), .mutation(({ ctx, input }) => saveReportTemplate(ctx, input)),
name: z.string().trim().min(1).max(120),
description: z.string().trim().max(500).optional(),
isShared: z.boolean().default(false),
config: ReportTemplateConfigSchema,
}))
.mutation(async ({ ctx, input }) => {
validateReportInput(input.config);
const reportTemplate = getReportTemplateDelegate(ctx.db);
const payload = input.config as unknown as Prisma.InputJsonValue;
const entity = toTemplateEntity(input.config.entity);
if (input.id) {
const existing = await reportTemplate.findUnique({
where: { id: input.id },
select: { ownerId: true },
});
if (!existing || existing.ownerId !== ctx.dbUser!.id) {
throw new TRPCError({ code: "FORBIDDEN", message: "Template cannot be updated" });
}
return reportTemplate.update({
where: { id: input.id },
data: {
name: input.name,
description: input.description,
entity,
config: payload,
isShared: input.isShared,
},
select: { id: true, updatedAt: true },
});
}
return reportTemplate.upsert({
where: {
ownerId_name: {
ownerId: ctx.dbUser!.id,
name: input.name,
},
},
update: {
description: input.description,
entity,
config: payload,
isShared: input.isShared,
},
create: {
ownerId: ctx.dbUser!.id,
name: input.name,
description: input.description,
entity,
config: payload,
isShared: input.isShared,
},
select: { id: true, updatedAt: true },
});
}),
deleteTemplate: controllerProcedure deleteTemplate: controllerProcedure
.input(z.object({ id: z.string() })) .input(DeleteReportTemplateInputSchema)
.mutation(async ({ ctx, input }) => { .mutation(({ ctx, input }) => deleteReportTemplate(ctx, input)),
const reportTemplate = getReportTemplateDelegate(ctx.db);
const existing = await reportTemplate.findUnique({
where: { id: input.id },
select: { ownerId: true },
});
if (!existing || existing.ownerId !== ctx.dbUser!.id) {
throw new TRPCError({ code: "FORBIDDEN", message: "Template cannot be deleted" });
}
await reportTemplate.delete({ where: { id: input.id } });
return { ok: true };
}),
}); });
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}` });
}
}