import type { FastifyInstance, FastifyRequest } from "fastify"; import { randomBytes } from "node:crypto"; import { z } from "zod"; import { pool } from "../db.js"; import { requireScope } from "../auth.js"; import { extractDataUrls } from "../assetExtract.js"; const stepPatch = z.object({ hotspot: z .object({ x: z.number(), y: z.number(), w: z.number(), h: z.number() }) .nullable() .optional(), annotation: z .object({ title: z.string().max(200), body: z.string().max(4000) }) .nullable() .optional(), position: z.number().int().min(0).optional(), lead_gate: z .object({ enabled: z.boolean(), fields: z .array(z.object({ name: z.string().max(50), label: z.string().max(100), required: z.boolean() })) .max(10), }) .nullable() .optional(), }); export async function demoRoutes(app: FastifyInstance): Promise { app.get("/api/demos", { preHandler: requireScope("read") }, async () => { const r = await pool.query( "SELECT id, name, status, share_token, created_at, updated_at FROM demos ORDER BY updated_at DESC", ); return r.rows; }); app.get("/api/demos/:id", { preHandler: requireScope("read") }, async (req, reply) => { const { id } = req.params as { id: string }; const d = await pool.query("SELECT * FROM demos WHERE id=$1", [id]); if (!d.rowCount) return reply.code(404).send({ error: "not found" }); const s = await pool.query( "SELECT id, position, snapshot_type, asset_id, hotspot, annotation, lead_gate FROM steps WHERE demo_id=$1 ORDER BY position", [id], ); return { ...d.rows[0], steps: s.rows }; }); app.post("/api/demos", { preHandler: requireScope("author") }, async (req) => { const body = z .object({ name: z.string().min(1).max(200), capture_id: z.string().uuid().optional() }) .parse(req.body); const r = await pool.query( "INSERT INTO demos (name, capture_id) VALUES ($1,$2) RETURNING *", [body.name, body.capture_id ?? null], ); return r.rows[0]; }); app.post("/api/demos/:id/steps", { preHandler: requireScope("author") }, async (req, reply) => { const { id } = req.params as { id: string }; const body = z .object({ snapshot_type: z.enum(["dom", "image"]), asset_id: z.string().uuid().optional(), dom_html: z.string().optional(), position: z.number().int().min(0).optional(), }) .parse(req.body); if (body.snapshot_type === "image" && !body.asset_id) return reply.code(400).send({ error: "image step requires asset_id" }); if (body.snapshot_type === "dom" && !body.dom_html) return reply.code(400).send({ error: "dom step requires dom_html" }); let domHtml = body.dom_html ?? null; if (domHtml) domHtml = await extractDataUrls(domHtml); // R3 server half const pos = body.position ?? (await pool.query("SELECT COALESCE(MAX(position)+1,0) AS p FROM steps WHERE demo_id=$1", [id])) .rows[0].p; const r = await pool.query( "INSERT INTO steps (demo_id, position, snapshot_type, asset_id, dom_html) VALUES ($1,$2,$3,$4,$5) RETURNING id, position, snapshot_type, asset_id", [id, pos, body.snapshot_type, body.asset_id ?? null, domHtml], ); await pool.query("UPDATE demos SET updated_at=now() WHERE id=$1", [id]); return r.rows[0]; }); app.patch("/api/steps/:stepId", { preHandler: requireScope("author") }, async (req, reply) => { const { stepId } = req.params as { stepId: string }; const body = stepPatch.parse(req.body); const sets: string[] = []; const vals: unknown[] = []; let i = 1; if (body.hotspot !== undefined) { sets.push(`hotspot=$${i++}`); vals.push(body.hotspot ? JSON.stringify(body.hotspot) : null); } if (body.annotation !== undefined) { sets.push(`annotation=$${i++}`); vals.push(body.annotation ? JSON.stringify(body.annotation) : null); } if (body.lead_gate !== undefined) { sets.push(`lead_gate=$${i++}`); vals.push(body.lead_gate ? JSON.stringify(body.lead_gate) : null); } if (body.position !== undefined) { sets.push(`position=$${i++}`); vals.push(body.position); } if (!sets.length) return reply.code(400).send({ error: "empty patch" }); vals.push(stepId); const r = await pool.query( `UPDATE steps SET ${sets.join(",")} WHERE id=$${i} RETURNING id, position, hotspot, annotation`, vals, ); if (!r.rowCount) return reply.code(404).send({ error: "not found" }); return r.rows[0]; }); app.delete("/api/steps/:stepId", { preHandler: requireScope("author") }, async (req) => { const { stepId } = req.params as { stepId: string }; await pool.query("DELETE FROM steps WHERE id=$1", [stepId]); return { ok: true }; }); app.post("/api/demos/:id/reorder", { preHandler: requireScope("author") }, async (req, reply) => { const { id } = req.params as { id: string }; const body = z.object({ ordered_step_ids: z.array(z.string().uuid()).min(1) }).parse(req.body); await pool.query("BEGIN"); try { for (let i = 0; i < body.ordered_step_ids.length; i++) await pool.query("UPDATE steps SET position=$1 WHERE id=$2 AND demo_id=$3", [i, body.ordered_step_ids[i], id]); await pool.query("UPDATE demos SET updated_at=now() WHERE id=$1", [id]); await pool.query("COMMIT"); } catch (e) { await pool.query("ROLLBACK"); throw e; } return { ok: true }; }); app.post("/api/demos/:id/publish", { preHandler: requireScope("publish") }, async (req, reply) => { const { id } = req.params as { id: string }; const token = randomBytes(12).toString("hex"); const r = await pool.query( "UPDATE demos SET status='published', share_token=COALESCE(share_token,$2), updated_at=now() WHERE id=$1 RETURNING share_token", [id, token], ); if (!r.rowCount) return reply.code(404).send({ error: "not found" }); return { share_token: r.rows[0].share_token, url: `/p/${r.rows[0].share_token}` }; }); app.post("/api/demos/:id/unpublish", { preHandler: requireScope("publish") }, async (req) => { const { id } = req.params as { id: string }; await pool.query("UPDATE demos SET status='draft', updated_at=now() WHERE id=$1", [id]); return { ok: true }; }); }