From a9358048eff9e0265d143c34285af509d7668b84 Mon Sep 17 00:00:00 2001 From: Shane Maynard Date: Sun, 6 Sep 2026 16:58:41 -0400 Subject: [PATCH] Add independent roast plan cloning from the plans table --- public/js/plans.js | 15 ++++++++++++++- server/app.js | 16 ++++++++++++++++ server/openapi.js | 1 + test/plans-table.test.js | 9 +++++++++ test/report-browser.test.js | 6 ++++++ 5 files changed, 46 insertions(+), 1 deletion(-) diff --git a/public/js/plans.js b/public/js/plans.js index 7adf1e9..fc700e1 100644 --- a/public/js/plans.js +++ b/public/js/plans.js @@ -60,6 +60,19 @@ async function load() { ); const actions = document.createElement("td"); actions.className = "data-table-actions"; + const clone = node("button", "Clone", "ghost-btn small"); + clone.type = "button"; + clone.setAttribute("aria-label", "Clone " + plan.title); + clone.onclick = async () => { + clone.disabled = true; + try { + const result = await api(`/api/plans/${plan.id}/clone`, {method:"POST"}); + location.assign("/app?plan=" + encodeURIComponent(result.plan.id)); + } catch (error) { + showToast(error.message, "fail"); + clone.disabled = false; + } + }; const rename = node("button", "Rename", "ghost-btn small"); rename.type = "button"; rename.setAttribute("aria-label", "Rename " + plan.title); @@ -88,7 +101,7 @@ async function load() { showToast(e.message, "fail"); } }; - actions.append(rename, remove); + actions.append(clone, rename, remove); tr.append(actions); body.append(tr); } diff --git a/server/app.js b/server/app.js index 86750b0..7ce06aa 100644 --- a/server/app.js +++ b/server/app.js @@ -1,3 +1,4 @@ +import { blankPlan } from "../shared/fields.js"; import { sanitizeObservations } from "../shared/roast-comparison.js"; import express from "express"; import path from "node:path"; @@ -790,6 +791,21 @@ export function createApp({ res.json({ok:true,plan:row}); } catch(error) {next(error);} }); + app.post("/api/plans/:id/clone", requireAuth, csrf, requireUuidParam("id"), async (req, res, next) => { + try { + const source = (await db.query("SELECT plan,name FROM roast_plans WHERE id=$1 AND user_id=$2", [req.params.id, req.user.id])).rows[0]; + if (!source) return res.status(404).json({ok:false, code:"not_found"}); + const plan = structuredClone(source.plan); + const defaults = blankPlan(); + plan.planActual = defaults.planActual; + plan.afterRoast = defaults.afterRoast; + plan.inventory = {...defaults.inventory, ...plan.inventory, consumed:null}; + const title = source.name || plan.fields?.["0.1"] || "Untitled plan"; + const name = `${title.slice(0, 113)} (copy)`; + const copy = (await db.query("INSERT INTO roast_plans(user_id,plan,name) VALUES($1,$2,$3) RETURNING id,plan,name,created_at,updated_at", [req.user.id, plan, name])).rows[0]; + res.status(201).json({ok:true, plan:copy}); + } catch (error) { next(error); } + }); app.post("/api/plans", requireAuth, csrf, async (req, res, next) => { try { if (!req.body.plan || typeof req.body.plan !== "object") diff --git a/server/openapi.js b/server/openapi.js index f52870c..ce58069 100644 --- a/server/openapi.js +++ b/server/openapi.js @@ -95,6 +95,7 @@ export function buildOpenApiSpec({ origin = "" } = {}) { get: { tags: ["roast plans"], summary: "List your roast plans; view=summary returns paginated lightweight rows", parameters: ["view","q","process","level","sort","page","limit"].map(name=>({name,in:"query",schema:{type:"string"}})), responses: { 200: ok("Plans", obj({ ok: bool, plans: arr({ type: "object" }) })) } }, post: { tags: ["roast plans"], summary: "Create a roast plan", requestBody: jsonBody(obj({ plan: { type: "object" }, name: { ...str, description: "Optional custom display name (falls back to worksheet field 0.1 when blank)" } }, ["plan"])), responses: { 201: ok("Created") } }, }, + "/api/plans/{id}/clone": { post: { tags: ["roast plans"], summary: "Clone your plan for a new roast; clears recorded outcomes and inventory consumption", parameters: [idParam], responses: { 201: ok("Independent copy created"), 404: err("Not found") } } }, "/api/plans/{id}": { get: {tags:["roast plans"],summary:"Read one of your plans",parameters:[idParam],responses:{200:ok("Plan"),404:err("Not found")}}, put: { tags: ["roast plans"], summary: "Update a roast plan and/or rename it (send `plan`, `name`, or both — omitted fields are left unchanged)", parameters: [idParam], requestBody: jsonBody(obj({ plan: { type: "object" }, name: str })), responses: { 200: ok("Updated"), 404: err("Not found") } }, diff --git a/test/plans-table.test.js b/test/plans-table.test.js index 0465369..05ba23f 100644 --- a/test/plans-table.test.js +++ b/test/plans-table.test.js @@ -89,3 +89,12 @@ test("summary search, filters and pagination exclude full telemetry and other ac 0, ); }); + +test('clone creates an independent owned plan without copying roast outcomes or consumption', async () => { + const {app,agent}=await setup();const {csrf}=await signup(agent,'clone@example.com');const other=request.agent(app);const {csrf:otherCsrf}=await signup(other,'other-clone@example.com'); + const plan=blankPlan();plan.fields['0.1']='Burundi';plan.fields['4.1']='light';plan.temps.fc.tempC='190';plan.inventory={lotId:'lot',lotLabel:'Burundi lot',consumed:{weightG:500}};plan.afterRoast.cupNotes='Original cup';plan.planActual.fc.actualTime='8:20'; + const original=(await agent.post('/api/plans').set('x-csrf-token',csrf).send({plan,name:'Fruity'})).body.plan; + const route='/api/plans/'+original.id+'/clone';assert.equal((await request(app).post(route)).status,401);assert.equal((await agent.post(route)).status,403);assert.equal((await other.post(route).set('x-csrf-token',otherCsrf)).status,404);assert.equal((await agent.post('/api/plans/not-a-uuid/clone').set('x-csrf-token',csrf)).status,404); + const response=await agent.post(route).set('x-csrf-token',csrf);assert.equal(response.status,201);const copy=response.body.plan;assert.notEqual(copy.id,original.id);assert.equal(copy.name,'Fruity (copy)');assert.deepEqual(copy.plan.fields,plan.fields);assert.deepEqual(copy.plan.temps,plan.temps);assert.equal(copy.plan.inventory.lotId,'lot');assert.equal(copy.plan.inventory.consumed,null);assert.deepEqual(copy.plan.afterRoast,blankPlan().afterRoast);assert.deepEqual(copy.plan.planActual,blankPlan().planActual); + copy.plan.fields['4.1']='medium';assert.equal((await agent.put('/api/plans/'+copy.id).set('x-csrf-token',csrf).send({plan:copy.plan})).status,200);assert.deepEqual((await agent.get('/api/plans/'+original.id)).body.plan.plan,plan);assert.equal((await other.get('/api/plans/'+copy.id)).status,404); +}); diff --git a/test/report-browser.test.js b/test/report-browser.test.js index dd9119c..2736592 100644 --- a/test/report-browser.test.js +++ b/test/report-browser.test.js @@ -286,3 +286,9 @@ test('browser: rejected sync shows the cause and keeps the edited plan open', br await page.locator('#plan-form input[name="0.4"]').fill('550');await page.getByRole('link',{name:'▤ Your plans',exact:true}).click();await page.getByText(/Your sign-in session needs refreshing/).waitFor();assert.ok(page.url().includes('/app?plan='+saved.id));assert.equal((await db.query('SELECT COUNT(*) AS n FROM roast_plans')).rows[0].n,1); const draft=await page.evaluate(()=>JSON.parse(localStorage.getItem(Object.keys(localStorage).find(k=>k.startsWith('roastPlannerPlan.v2:')&&!k.endsWith(':last-user')))));assert.equal(draft.plan.fields['0.4'],'550'); }); + +test('browser: Clone opens an independent plan whose roast level can change', browserOptions, async t => { + const {page,base,create,db,errors}=await session(t);const original=await create(warningPlan(),'Fruit original');await page.goto(base+'/plans');await page.getByRole('button',{name:'Clone Fruit original',exact:true}).click();await page.waitForURL('**/app?plan=*');await page.locator('#header-coffee-name').filter({hasText:'Fruit original (copy)'}).waitFor(); + const id=new URL(page.url()).searchParams.get('plan');assert.notEqual(id,original.id);await page.locator('#plan-form label').filter({has:page.locator('input[name="4.1"][value="medium"]')}).click();await page.getByRole('link',{name:'▤ Your plans',exact:true}).click();await page.waitForURL('**/plans*');await page.getByRole('link',{name:'Fruit original (copy)',exact:true}).waitFor(); + const rows=(await db.query('SELECT id,plan FROM roast_plans')).rows;assert.equal(rows.length,2);assert.equal(rows.find(r=>r.id===original.id).plan.fields['4.1'],'light');assert.equal(rows.find(r=>r.id===id).plan.fields['4.1'],'medium');assert.deepEqual(errors,[]); +});