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:
parent
4333cd23ef
commit
3806fa917b
6 changed files with 265 additions and 26 deletions
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue