diff --git a/packages/api/src/__tests__/report-template-procedure-support.test.ts b/packages/api/src/__tests__/report-template-procedure-support.test.ts new file mode 100644 index 0000000..f284d69 --- /dev/null +++ b/packages/api/src/__tests__/report-template-procedure-support.test.ts @@ -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) { + 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", + }); + }); +}); diff --git a/packages/api/src/router/report-template-procedure-support.ts b/packages/api/src/router/report-template-procedure-support.ts new file mode 100644 index 0000000..c43dd7b --- /dev/null +++ b/packages/api/src/router/report-template-procedure-support.ts @@ -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; + +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, +) { + 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, +) { + 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, + entity: ReportTemplateEntity, + config: Prisma.InputJsonValue, +) { + return { + name: input.name, + entity, + config, + isShared: input.isShared, + ...(input.description !== undefined ? { description: input.description } : {}), + }; +} + +function omitNameFromTemplateWriteData( + data: ReturnType, +) { + 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}` }); + } +} diff --git a/packages/api/src/router/report.ts b/packages/api/src/router/report.ts index 42e6c33..2c4515e 100644 --- a/packages/api/src/router/report.ts +++ b/packages/api/src/router/report.ts @@ -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 { - type EntityKey, - ReportTemplateConfigSchema, - validateReportInput, -} from "./report-query-config.js"; import { reportQueryProcedures } from "./report-query-engine.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; -}; - -function getReportTemplateDelegate(db: unknown) { - return (db as { - reportTemplate: { - findMany: (args: unknown) => Promise; - 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; - }; - }).reportTemplate; -} +import { + DeleteReportTemplateInputSchema, + deleteReportTemplate, + listReportTemplates, + SaveReportTemplateInputSchema, + saveReportTemplate, +} from "./report-template-procedure-support.js"; export const reportRouter = createTRPCRouter({ ...reportQueryProcedures, - listTemplates: controllerProcedure.query(async ({ 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, - })); - }), + listTemplates: controllerProcedure.query(({ ctx }) => listReportTemplates(ctx)), saveTemplate: controllerProcedure - .input(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, - })) - .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 }, - }); - }), + .input(SaveReportTemplateInputSchema) + .mutation(({ ctx, input }) => saveReportTemplate(ctx, input)), deleteTemplate: controllerProcedure - .input(z.object({ id: z.string() })) - .mutation(async ({ 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 }; - }), + .input(DeleteReportTemplateInputSchema) + .mutation(({ ctx, input }) => deleteReportTemplate(ctx, input)), }); - -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}` }); - } -}