v0.4.0-alpha: duplicate_demo, editor UI for branches/chapters, delete, personalized links

- duplicate: REST + MCP tool + editor button. Deep copy with branch targets
  and chapter targets remapped into the copy; lead gates + raster cache
  carried; copy is always a private draft (no inherited share token).
  Verified: 3 steps / 2 chapters / 1 branch all internally consistent.
- delete demo: REST + editor button; FK cascade verified (0 orphans)
- Editor: draw-mode toggle (hotspot vs branch) with target-step picker and
  branch list; chapters manager panel; personalized-link generator;
  duplicate/delete on demo list
- MCP surface: 16 tools
- USER-GUIDE updated: new editor features + personalization prompt now uses
  duplicate_demo
This commit is contained in:
Scot Thom 2026-07-18 06:35:48 +00:00
parent 4333cd23ef
commit 3806fa917b
6 changed files with 265 additions and 26 deletions

View file

@ -165,6 +165,55 @@ function buildServer(key: AuthedKey): McpServer {
},
);
s.tool(
"duplicate_demo",
"Duplicate a demo as a new draft ('Copy of ...'): all steps, hotspots, annotations, lead gates, branches (retargeted), and chapters (retargeted). Assets are shared. Use before personalizing for a specific prospect.",
{ demo_id: z.string().uuid() },
async ({ demo_id }) => {
const d = await pool.query("SELECT * FROM demos WHERE id=$1", [demo_id]);
if (!d.rowCount) return text({ error: "not found" });
const src = d.rows[0];
const steps = await pool.query("SELECT * FROM steps WHERE demo_id=$1 ORDER BY position", [demo_id]);
await pool.query("BEGIN");
try {
const nd = await pool.query(
"INSERT INTO demos (name, capture_id, theme) VALUES ($1,$2,$3) RETURNING id",
[`Copy of ${src.name}`.slice(0, 200), src.capture_id, src.theme],
);
const newId = nd.rows[0].id;
const idMap = new Map<string, string>();
for (const st of steps.rows) {
const ns = await pool.query(
`INSERT INTO steps (demo_id, position, snapshot_type, asset_id, dom_html, hotspot, annotation, lead_gate, raster_asset_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`,
[newId, st.position, st.snapshot_type, st.asset_id, st.dom_html, st.hotspot, st.annotation, st.lead_gate, st.raster_asset_id],
);
idMap.set(st.id, ns.rows[0].id);
}
for (const st of steps.rows) {
if (!st.branches) continue;
const remapped = (st.branches as { target_step_id: string }[])
.map((b) => ({ ...b, target_step_id: idMap.get(b.target_step_id) }))
.filter((b) => b.target_step_id);
await pool.query("UPDATE steps SET branches=$2 WHERE id=$1", [
idMap.get(st.id), remapped.length ? JSON.stringify(remapped) : null,
]);
}
if (src.chapters) {
const ch = (src.chapters as { title: string; step_id: string }[])
.map((c) => ({ ...c, step_id: idMap.get(c.step_id) }))
.filter((c) => c.step_id);
await pool.query("UPDATE demos SET chapters=$2 WHERE id=$1", [newId, ch.length ? JSON.stringify(ch) : null]);
}
await pool.query("COMMIT");
return text({ id: newId, name: `Copy of ${src.name}`, steps: steps.rowCount });
} catch (e) {
await pool.query("ROLLBACK");
throw e;
}
},
);
s.tool(
"set_step_branches",
"Set branch hotspots on a step: extra clickable regions that jump to a target step (percent coordinates). Pass an empty array to clear.",

View file

@ -144,6 +144,60 @@ export async function demoRoutes(app: FastifyInstance): Promise<void> {
return { ok: true };
});
app.post("/api/demos/:id/duplicate", { preHandler: requireScope("author") }, 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 src = d.rows[0];
const steps = await pool.query("SELECT * FROM steps WHERE demo_id=$1 ORDER BY position", [id]);
await pool.query("BEGIN");
try {
const nd = await pool.query(
"INSERT INTO demos (name, capture_id, theme) VALUES ($1,$2,$3) RETURNING id",
[`Copy of ${src.name}`.slice(0, 200), src.capture_id, src.theme],
);
const newId = nd.rows[0].id;
const idMap = new Map<string, string>();
for (const st of steps.rows) {
const ns = await pool.query(
`INSERT INTO steps (demo_id, position, snapshot_type, asset_id, dom_html, hotspot, annotation, lead_gate, raster_asset_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`,
[newId, st.position, st.snapshot_type, st.asset_id, st.dom_html, st.hotspot, st.annotation, st.lead_gate, st.raster_asset_id],
);
idMap.set(st.id, ns.rows[0].id);
}
// second pass: remap branch targets
for (const st of steps.rows) {
if (!st.branches) continue;
const remapped = (st.branches as { target_step_id: string }[])
.map((b) => ({ ...b, target_step_id: idMap.get(b.target_step_id) }))
.filter((b) => b.target_step_id);
await pool.query("UPDATE steps SET branches=$2 WHERE id=$1", [
idMap.get(st.id), remapped.length ? JSON.stringify(remapped) : null,
]);
}
// remap chapters
if (src.chapters) {
const ch = (src.chapters as { title: string; step_id: string }[])
.map((c) => ({ ...c, step_id: idMap.get(c.step_id) }))
.filter((c) => c.step_id);
await pool.query("UPDATE demos SET chapters=$2 WHERE id=$1", [newId, ch.length ? JSON.stringify(ch) : null]);
}
await pool.query("COMMIT");
return { id: newId, name: `Copy of ${src.name}`.slice(0, 200), status: "draft", steps: steps.rowCount };
} catch (e) {
await pool.query("ROLLBACK");
throw e;
}
});
app.delete("/api/demos/:id", { preHandler: requireScope("author") }, async (req) => {
const { id } = req.params as { id: string };
await pool.query("DELETE FROM demos WHERE id=$1", [id]); // steps/analytics/leads cascade
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);