import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { and, asc, desc, eq, sql } from "drizzle-orm"; import { objects, propertyDefinitions, propertyValues, templates, } from "@tasks/database/schema"; import { type Context, router, protectedProcedure } from "@/server/trpc"; const templatePropertySchema = z.object({ name: z.string().min(1).max(255), fieldType: z.string().min(1).max(50), defaultValue: z.unknown().optional(), }); export const templateSchemaJson = z .object({ properties: z.array(templatePropertySchema).optional(), defaultContent: z.string().optional(), }) .optional(); export type TemplateSchemaJson = z.infer; function escapeHtml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function defaultContentToHtml(text: string): string { const trimmed = text.trim(); if (!trimmed) return "

"; const paragraphs = trimmed.split(/\n\n+/); return paragraphs .map((p) => `

${escapeHtml(p).replace(/\n/g, "
")}

`) .join(""); } async function getMaxPropertySortOrder( db: Context["db"], workspaceId: string, ): Promise { const [row] = await db .select({ m: sql`coalesce(max(${propertyDefinitions.sortOrder}), 0)`.mapWith( Number, ), }) .from(propertyDefinitions) .where(eq(propertyDefinitions.workspaceId, workspaceId)); return row?.m ?? 0; } export const templatesRouter = router({ list: protectedProcedure .input( z.object({ workspaceId: z.string().uuid(), targetType: z.string().max(50).optional(), }), ) .query(async ({ ctx, input }) => { const conditions = [eq(templates.workspaceId, input.workspaceId)]; if (input.targetType !== undefined) { conditions.push(eq(templates.targetType, input.targetType)); } const rows = await ctx.db .select() .from(templates) .where(and(...conditions)) .orderBy(asc(templates.name), desc(templates.updatedAt)); return { templates: rows }; }), getById: protectedProcedure .input(z.object({ id: z.string().uuid() })) .query(async ({ ctx, input }) => { const row = await ctx.db.query.templates.findFirst({ where: eq(templates.id, input.id), }); if (!row) { throw new TRPCError({ code: "NOT_FOUND", message: "Template not found" }); } return row; }), create: protectedProcedure .input( z.object({ workspaceId: z.string().uuid(), name: z.string().min(1).max(255), targetType: z.string().min(1).max(50), schema: templateSchemaJson, }), ) .mutation(async ({ ctx, input }) => { const now = new Date(); const [created] = await ctx.db .insert(templates) .values({ workspaceId: input.workspaceId, name: input.name, targetType: input.targetType, schema: input.schema ?? null, createdAt: now, updatedAt: now, }) .returning(); if (!created) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create template", }); } return created; }), update: protectedProcedure .input( z.object({ id: z.string().uuid(), name: z.string().min(1).max(255).optional(), schema: templateSchemaJson, }), ) .mutation(async ({ ctx, input }) => { const { id, ...patch } = input; const now = new Date(); const [updated] = await ctx.db .update(templates) .set({ ...(patch.name !== undefined ? { name: patch.name } : {}), ...(patch.schema !== undefined ? { schema: patch.schema } : {}), updatedAt: now, }) .where(eq(templates.id, id)) .returning(); if (!updated) { throw new TRPCError({ code: "NOT_FOUND", message: "Template not found" }); } return updated; }), delete: protectedProcedure .input(z.object({ id: z.string().uuid() })) .mutation(async ({ ctx, input }) => { const deleted = await ctx.db .delete(templates) .where(eq(templates.id, input.id)) .returning({ id: templates.id }); if (deleted.length === 0) { throw new TRPCError({ code: "NOT_FOUND", message: "Template not found" }); } }), applyTemplate: protectedProcedure .input( z.object({ objectId: z.string().uuid(), templateId: z.string().uuid(), }), ) .mutation(async ({ ctx, input }) => { const template = await ctx.db.query.templates.findFirst({ where: eq(templates.id, input.templateId), }); if (!template) { throw new TRPCError({ code: "NOT_FOUND", message: "Template not found" }); } const obj = await ctx.db.query.objects.findFirst({ where: eq(objects.id, input.objectId), }); if (!obj) { throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" }); } if (obj.workspaceId !== template.workspaceId) { throw new TRPCError({ code: "BAD_REQUEST", message: "Template belongs to a different workspace than the object", }); } const schema = (template.schema ?? {}) as { properties?: { name: string; fieldType: string; defaultValue?: unknown }[]; defaultContent?: string; }; const workspaceId = template.workspaceId; let nextSort = (await getMaxPropertySortOrder(ctx.db, workspaceId)) + 1; const now = new Date(); const contentPatch = typeof schema.defaultContent === "string" && schema.defaultContent.trim() !== "" ? { content: defaultContentToHtml(schema.defaultContent) } : {}; const [updatedObject] = await ctx.db .update(objects) .set({ templateId: template.id, ...contentPatch, updatedAt: now, }) .where(eq(objects.id, input.objectId)) .returning(); if (!updatedObject) { throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" }); } let propertyCount = 0; const props = schema.properties ?? []; for (const prop of props) { let def = await ctx.db.query.propertyDefinitions.findFirst({ where: and( eq(propertyDefinitions.workspaceId, workspaceId), eq(propertyDefinitions.name, prop.name), ), }); if (!def) { const [createdDef] = await ctx.db .insert(propertyDefinitions) .values({ workspaceId, name: prop.name, fieldType: prop.fieldType, sortOrder: nextSort++, }) .returning(); def = createdDef; } if (!def) continue; await ctx.db .insert(propertyValues) .values({ objectId: input.objectId, propertyDefId: def.id, value: prop.defaultValue ?? null, updatedAt: now, }) .onConflictDoUpdate({ target: [propertyValues.objectId, propertyValues.propertyDefId], set: { value: prop.defaultValue ?? null, updatedAt: now, }, }); propertyCount += 1; } return { object: updatedObject, applied: { propertyCount }, }; }), });