import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { and, asc, desc, eq } from "drizzle-orm"; import { formResponses, forms, objects, propertyDefinitions, propertyValues, } from "@tasks/database/schema"; import { type Context, router, protectedProcedure } from "@/server/trpc"; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; type FormFieldRow = { id: string; type?: string; mappedProperty?: string | null; }; async function resolvePropertyDefId( db: Pick, workspaceId: string, mappedProperty: string, ): Promise { if (UUID_RE.test(mappedProperty)) { const [def] = await db .select({ id: propertyDefinitions.id }) .from(propertyDefinitions) .where( and( eq(propertyDefinitions.id, mappedProperty), eq(propertyDefinitions.workspaceId, workspaceId), ), ) .limit(1); return def?.id ?? null; } const [def] = await db .select({ id: propertyDefinitions.id }) .from(propertyDefinitions) .where( and( eq(propertyDefinitions.workspaceId, workspaceId), eq(propertyDefinitions.name, mappedProperty), ), ) .limit(1); return def?.id ?? null; } export const formsRouter = router({ list: protectedProcedure .input(z.object({ workspaceId: z.string().uuid() })) .query(async ({ ctx, input }) => { const rows = await ctx.db .select() .from(forms) .where(eq(forms.workspaceId, input.workspaceId)) .orderBy(desc(forms.updatedAt), asc(forms.id)); return { forms: rows }; }), getById: protectedProcedure .input(z.object({ id: z.string().uuid() })) .query(async ({ ctx, input }) => { const row = await ctx.db.query.forms.findFirst({ where: eq(forms.id, input.id), }); if (!row) { throw new TRPCError({ code: "NOT_FOUND", message: "Form not found" }); } return row; }), create: protectedProcedure .input( z.object({ workspaceId: z.string().uuid(), title: z.string().min(1).max(500), description: z.string().optional(), coverImage: z.string().optional(), objectId: z.string().uuid().nullable().optional(), targetType: z.string().max(50).optional().default("task"), fields: z.array(z.unknown()).optional().default([]), settings: z.record(z.unknown()).optional().default({}), isPublished: z.boolean().optional().default(false), }), ) .mutation(async ({ ctx, input }) => { const userId = ctx.session.user.id; if (!userId) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Missing user id", }); } const now = new Date(); const [created] = await ctx.db .insert(forms) .values({ workspaceId: input.workspaceId, title: input.title, description: input.description ?? null, coverImage: input.coverImage ?? null, objectId: input.objectId ?? null, targetType: input.targetType ?? "task", fields: input.fields ?? [], settings: input.settings ?? {}, isPublished: input.isPublished ?? false, createdBy: userId, createdAt: now, updatedAt: now, }) .returning(); if (!created) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create form", }); } return created; }), update: protectedProcedure .input( z.object({ id: z.string().uuid(), title: z.string().min(1).max(500).optional(), description: z.string().nullable().optional(), coverImage: z.string().nullable().optional(), objectId: z.string().uuid().nullable().optional(), targetType: z.string().max(50).optional(), fields: z.array(z.unknown()).optional(), settings: z.record(z.unknown()).optional(), isPublished: z.boolean().optional(), }), ) .mutation(async ({ ctx, input }) => { const { id, ...patch } = input; const now = new Date(); const [updated] = await ctx.db .update(forms) .set({ ...(patch.title !== undefined ? { title: patch.title } : {}), ...(patch.description !== undefined ? { description: patch.description } : {}), ...(patch.coverImage !== undefined ? { coverImage: patch.coverImage } : {}), ...(patch.objectId !== undefined ? { objectId: patch.objectId } : {}), ...(patch.targetType !== undefined ? { targetType: patch.targetType } : {}), ...(patch.fields !== undefined ? { fields: patch.fields } : {}), ...(patch.settings !== undefined ? { settings: patch.settings } : {}), ...(patch.isPublished !== undefined ? { isPublished: patch.isPublished } : {}), updatedAt: now, }) .where(eq(forms.id, id)) .returning(); if (!updated) { throw new TRPCError({ code: "NOT_FOUND", message: "Form not found" }); } return updated; }), delete: protectedProcedure .input(z.object({ id: z.string().uuid() })) .mutation(async ({ ctx, input }) => { const deleted = await ctx.db .delete(forms) .where(eq(forms.id, input.id)) .returning({ id: forms.id }); if (deleted.length === 0) { throw new TRPCError({ code: "NOT_FOUND", message: "Form not found" }); } }), submit: protectedProcedure .input( z.object({ formId: z.string().uuid(), data: z.record(z.unknown()), }), ) .mutation(async ({ ctx, input }) => { const userId = ctx.session.user.id; if (!userId) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Missing user id", }); } const form = await ctx.db.query.forms.findFirst({ where: eq(forms.id, input.formId), }); if (!form) { throw new TRPCError({ code: "NOT_FOUND", message: "Form not found" }); } const fieldRows = Array.isArray(form.fields) ? (form.fields as FormFieldRow[]) : []; const workspaceId = form.workspaceId; let title = form.title; let description: string | null | undefined; let icon: string | null | undefined; let status: string | null | undefined; for (const field of fieldRows) { const key = field.id; if (!key || !(key in input.data)) continue; const raw = input.data[key]; const mapKey = field.mappedProperty?.trim(); if (!mapKey) continue; const lower = mapKey.toLowerCase(); if (lower === "title") { title = raw == null ? title : String(raw); continue; } if (lower === "description") { description = raw == null ? null : String(raw); continue; } if (lower === "icon") { icon = raw == null ? null : String(raw); continue; } if (lower === "status") { status = raw == null ? null : String(raw); continue; } } const now = new Date(); const result = await ctx.db.transaction(async (tx) => { const [createdObject] = await tx .insert(objects) .values({ type: form.targetType, title, parentId: form.objectId ?? null, workspaceId, ...(description !== undefined ? { description } : {}), ...(icon !== undefined ? { icon } : {}), ...(status !== undefined ? { status } : {}), createdBy: userId, createdAt: now, updatedAt: now, }) .returning(); if (!createdObject) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create object from form", }); } for (const field of fieldRows) { const key = field.id; if (!key || !(key in input.data)) continue; const skipTypes = new Set(["section_header", "divider"]); if (field.type && skipTypes.has(field.type)) continue; const mapKey = field.mappedProperty?.trim(); if (!mapKey) continue; const lower = mapKey.toLowerCase(); if (["title", "description", "icon", "status"].includes(lower)) { continue; } const propertyDefId = await resolvePropertyDefId(tx, workspaceId, mapKey); if (!propertyDefId) continue; const value = input.data[key]; await tx .insert(propertyValues) .values({ objectId: createdObject.id, propertyDefId, value: value as unknown, createdAt: now, updatedAt: now, }) .onConflictDoUpdate({ target: [propertyValues.objectId, propertyValues.propertyDefId], set: { value: value as unknown, updatedAt: now, }, }); } const [responseRow] = await tx .insert(formResponses) .values({ formId: form.id, respondentId: userId, createdObjectId: createdObject.id, data: input.data, submittedAt: now, }) .returning(); if (!responseRow) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to record form response", }); } return { object: createdObject, response: responseRow }; }); return result; }), listResponses: protectedProcedure .input( z.object({ formId: z.string().uuid(), limit: z.number().int().positive().max(500).optional(), offset: z.number().int().nonnegative().optional(), }), ) .query(async ({ ctx, input }) => { const limit = input.limit ?? 50; const offset = input.offset ?? 0; const form = await ctx.db.query.forms.findFirst({ where: eq(forms.id, input.formId), columns: { id: true }, }); if (!form) { throw new TRPCError({ code: "NOT_FOUND", message: "Form not found" }); } const rows = await ctx.db .select() .from(formResponses) .where(eq(formResponses.formId, input.formId)) .orderBy(desc(formResponses.submittedAt), asc(formResponses.id)) .limit(limit) .offset(offset); return { responses: rows }; }), });