openvibers/openvibe.food Commits
Food: plan together (a saved plan shared with a household, a shopping list everyone ticks off) (#39)
Changes
@@@ -2,8 +2,8 @@> What should we eat?--**Status:** the product works — food near you, a meal planner, the food database, the pantry and recipe ideas from the network's AI are live and tested.--Delivery comparison (the manifests' remaining pillar) is not built yet.++**Status:** the product works — food near you, a meal planner, the food database, the pantry, recipe ideas from the network's AI and plan together (a saved plan shared with a household, with a shopping list everyone ticks off) are live and tested.++Delivery price comparison is not built: the delivery apps publish no open prices.**Domain:** `openvibe.food` · **Port:** 4970 · **Service id:** `food` · **Env prefix:** `FOOD`**License:** AGPL-3.0 (same as every OpenVibe service).nnew file mode 100644@@@ -0,0 +1,28 @@++-- phase: expand++-- OpenVibe.Food: plan together (the "Plan together" pillar). A saved plan can be shared with a household: its owner++-- makes a share link (only the SHA-256 of its secret is kept, share_hash), anyone signed in who opens it joins as a++-- member, and every member ticks the shopping list off for everyone. Deleting a plan deletes its members and ticks.++-- Names are the display name a person had when they joined or ticked, shown to the household; nothing here is a++-- credential, a key or a token.++++ALTER TABLE food_plans ADD COLUMN share_hash text COLLATE "C"; -- sha256 hex of the share link's secret++CREATE UNIQUE INDEX food_plans_share ON food_plans (share_hash) WHERE share_hash IS NOT NULL;++++CREATE TABLE food_plan_members (++ plan_id text COLLATE "C" NOT NULL REFERENCES food_plans (id) ON DELETE CASCADE,++ member text COLLATE "C" NOT NULL, -- user:usr_…++ name text, -- their display name when they joined++ joined_at text COLLATE "C" NOT NULL,++ PRIMARY KEY (plan_id, member)++);++CREATE INDEX food_plan_members_by_member ON food_plan_members (member, joined_at DESC);++++CREATE TABLE food_plan_ticks (++ plan_id text COLLATE "C" NOT NULL REFERENCES food_plans (id) ON DELETE CASCADE,++ item text COLLATE "C" NOT NULL, -- the shopping list item's food slug++ ticked_by text COLLATE "C" NOT NULL, -- user:usr_…++ name text, -- their display name when they ticked it++ ticked_at text COLLATE "C" NOT NULL,++ PRIMARY KEY (plan_id, item)++);++CREATE INDEX food_plan_ticks_by_person ON food_plan_ticks (ticked_by);nnew file mode 100644@@@ -0,0 +1,24 @@++/* OpenVibe.Food — a saved plan's page (/plan/p/:id) and joining a household (/plan/join/…) only, so the site-wide++ stylesheet stays inside its budget. */++.shop-list { list-style: none; padding: 0; margin: 8px 0 18px; display: grid; gap: 6px; }++.shop-list li { display: flex; align-items: center; gap: 12px; padding: 8px 12px; border: 1px solid var(--border, #1f2d47); border-radius: 10px; background: var(--bg-card, #131c2e); }++.shop-list li.got .shop-name { text-decoration: line-through; opacity: .7; }++.shop-list form { margin: 0; }++.shop-list .tick { width: 28px; height: 28px; min-width: 28px; padding: 0; margin: 0; border-radius: 8px; border: 2px solid var(--border-light, #2c3d5c); background: transparent; color: var(--on-accent-strong, #fff); font-weight: 700; line-height: 1; cursor: pointer; }++.shop-list li.got .tick { background: var(--accent-strong, #2563eb); border-color: var(--accent-strong, #2563eb); }++.shop-list .tick:focus-visible { outline: 3px solid var(--accent, #3b82f6); outline-offset: 2px; }++.shop-name { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }++.shop-meta { text-align: right; }++@media (max-width: 560px) {++ .shop-list li { flex-wrap: wrap; row-gap: 2px; }++ .shop-name { flex-basis: calc(100% - 40px); }++ .shop-meta { flex-basis: 100%; padding-left: 40px; text-align: left; }++}++.household { list-style: none; padding: 0; margin: 8px 0 14px; }++.household li { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 6px 0; border-bottom: 1px solid var(--border, #1f2d47); }++.household form { margin: 0 0 0 auto; }++.share-link { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 8px 0; }++.share-link input { flex: 1 1 320px; min-width: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }++.plan-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 22px; }++.plan-actions form { margin: 0; }++.sc-btn.danger { border-color: var(--danger, #ef4444); color: var(--danger, #ef4444); }@@@ -57,6 +57,46 @@ async function listPlans(s, owner, { limit = 20 } = {}) {}const deletePlan = (s, owner, id) => s.db.exec('DELETE FROM food_plans WHERE owner = $1 AND id = $2', [owner, id]);++// ── Plan together: households (migrations/0005_plan_households.sql) ──++const MEMBERS_MAX = 12; // people in one household besides its owner++const SHARED_PLANS_MAX = 50; // households one person may belong to++++/** The plan and the viewer's place in it ('owner' or 'member'), or null when it is neither theirs nor shared with them. */++async function planForViewer(s, viewer, id) {++ const row = await s.db.maybe('SELECT * FROM food_plans WHERE id = $1', [id]);++ if (!row) return null;++ if (row.owner === viewer) return { row, role: 'owner' };++ const m = await s.db.maybe('SELECT 1 AS ok FROM food_plan_members WHERE plan_id = $1 AND member = $2', [id, viewer]);++ return m ? { row, role: 'member' } : null;++}++const setShareHash = (s, owner, id, hash) => s.db.exec('UPDATE food_plans SET share_hash = $3 WHERE owner = $1 AND id = $2', [owner, id, hash]);++const planByShareHash = (s, hash) => s.db.maybe('SELECT * FROM food_plans WHERE share_hash = $1', [hash]);++const listMembers = (s, planId) => s.db.many('SELECT member, name, joined_at FROM food_plan_members WHERE plan_id = $1 ORDER BY joined_at', [planId]);++const listSharedPlans = (s, member) => s.db.many(++ 'SELECT p.*, m.joined_at FROM food_plan_members m JOIN food_plans p ON p.id = m.plan_id WHERE m.member = $1 ORDER BY m.joined_at DESC LIMIT $2',++ [member, SHARED_PLANS_MAX]);++++/** Join a household: 'joined', 'already', or a refusal code (food.plan.full, food.plan.too_many). */++async function addMember(s, planId, member, name, at) {++ return s.tx(async () => {++ if (await s.db.maybe('SELECT 1 AS ok FROM food_plan_members WHERE plan_id = $1 AND member = $2', [planId, member])) return 'already';++ const n = await s.db.maybe('SELECT COUNT(*)::int AS n FROM food_plan_members WHERE plan_id = $1', [planId]);++ if (Number(n.n) >= MEMBERS_MAX) return 'food.plan.full';++ const mine = await s.db.maybe('SELECT COUNT(*)::int AS n FROM food_plan_members WHERE member = $1', [member]);++ if (Number(mine.n) >= SHARED_PLANS_MAX) return 'food.plan.too_many';++ await s.db.exec('INSERT INTO food_plan_members (plan_id, member, name, joined_at) VALUES ($1, $2, $3, $4)', [planId, member, name || null, at]);++ return 'joined';++ });++}++const removeMember = (s, planId, member) => s.db.exec('DELETE FROM food_plan_members WHERE plan_id = $1 AND member = $2', [planId, member]);++++const listTicks = (s, planId) => s.db.many('SELECT item, ticked_by, name, ticked_at FROM food_plan_ticks WHERE plan_id = $1', [planId]);++const tick = (s, planId, item, by, name, at) => s.db.exec(++ `INSERT INTO food_plan_ticks (plan_id, item, ticked_by, name, ticked_at) VALUES ($1, $2, $3, $4, $5)++ ON CONFLICT (plan_id, item) DO UPDATE SET ticked_by = EXCLUDED.ticked_by, name = EXCLUDED.name, ticked_at = EXCLUDED.ticked_at`,++ [planId, item, by, name || null, at]);++const untick = (s, planId, item) => s.db.exec('DELETE FROM food_plan_ticks WHERE plan_id = $1 AND item = $2', [planId, item]);++const planToWire = (row) => ({id: row.id, title: row.title || null, people: Number(row.people), days: Number(row.days),budget: row.budget == null ? null : Number(row.budget), created_at: row.created_at, plan: parse(row.plan),@@@ -102,6 +142,8 @@ const recipeSuggestionToWire = (row) => ({module.exports = {cacheGet, cachePut, cachePrune, startCachePrune,insertPlan, planRow, listPlans, deletePlan, planToWire,++ planForViewer, setShareHash, planByShareHash, listMembers, listSharedPlans, addMember, removeMember, listTicks, tick, untick,++ MEMBERS_MAX, SHARED_PLANS_MAX,getPantry, pantryRows, setPantry,insertRecipeSuggestion, recipeSuggestionRow, listRecipeSuggestions, countRecipeSuggestionsSince, recipeSuggestionToWire,};@@@ -17,7 +17,7 @@ const foods = require('../food/foods');const SITE_NAME = 'OpenVibe.Food';const DESCRIPTION = 'OpenVibe.Food — What should we eat? Find food banks and budget grocery stores near you, plan meals, and cook from what is in your cupboard.';--const DISALLOW = ['/auth/', '/api/', '/pantry', '/plan/saved'];++const DISALLOW = ['/auth/', '/api/', '/pantry', '/plan/saved', '/plan/p/', '/plan/join/'];const PAGE_TEXT = {'/': ['OpenVibe.Food home', 'OpenVibe.Food: What should we eat? Find food near you, plan budget meals and cook from your cupboard.'],@@@ -14,6 +14,7 @@* Crawl artifacts (robots.txt, sitemap.xml, llms.txt, llms-full.txt, JSON-LD) are http/discovery.js.* Every page that shows OpenStreetMap data carries the attribution ODbL requires, linked back.*/++const crypto = require('crypto');const express = require('express');const ovServe = require('openvibe-shared/serve');const frame = require('openvibe-shared/frame');@@@ -107,11 +108,12 @@ ${res_.phone ? html`<p class="small">Phone: <a href="tel:${String(res_.phone).rebody: html`${raw(hero)}${raw(showcase.features({title: 'Everything food',-- lede: 'Five things, all working today: no card, no account needed to look around.',++ lede: 'Six things, all working today: no card, no account needed to look around.',items: [{ icon: 'ov:location', title: 'Food near you', text: 'Food banks, soup kitchens, community fridges and free pantry boxes, plus the budget grocery stores, with distance, address and opening hours.', href: '/near' },{ icon: 'ov:page', title: 'A meal planner', text: 'One person, a couple or a group, for up to fourteen days. It covers all six food groups, writes the shopping list and estimates what it costs.', href: '/plan' },{ icon: 'ov:tools', title: 'A food list you can read', text: 'Every item with its nutrition per serving and per 100 g, the typical price band and the cheapest of the five budget chains.', href: '/foods' },++ { icon: 'ov:check', title: 'Plan together', text: 'Share a saved plan with your household: everyone sees it, and ticks the shopping list off as they buy.', href: '/plan/saved' },{ icon: 'ov:account', title: 'Your cupboard', text: 'Keep a list of what you have. Sign in and it says plainly which simple things you can already make, and what one item would unlock.', href: '/pantry' },{ icon: 'ov:ai', title: 'Recipe ideas', text: 'Tell it what you feel like and it suggests up to three recipes from your cupboard, with what you would still need to buy and its typical cost. Suggested by AI, so check before you cook.', href: '/recipes' },],@@@ -193,7 +195,7 @@ ${osmNote(out.fetched_at)}</form>`;}-- function planBody(plan) {++ function planBody(plan, { shopping = true } = {}) {return html`<section class="plan-summary"><h2>${plan.spec.people} ${plan.spec.people === 1 ? 'person' : 'people'}, ${plan.spec.days} ${plan.spec.days === 1 ? 'day' : 'days'}</h2><ul class="stats">@@@ -205,9 +207,9 @@ ${osmNote(out.fetched_at)}</ul>${plan.spec.budget != null ? html`<p class="muted small">Budget ${money(plan.spec.budget)}${plan.remaining_budget != null ? html` · ${money(plan.remaining_budget)} left` : ''}.</p>` : ''}</section>--<h2>The shopping list</h2>++${shopping ? html`<h2>The shopping list</h2>${table(['Item', 'Packs', 'Where', 'Cost'], plan.shopping_list.map((i) => [html`<a href="/foods/${i.slug}">${i.name}</a>`, String(i.packs), i.store_name || '—', money(i.cost)]), { empty: 'Nothing in the plan.' })}--<p class="muted small">Cheapest total ${money(plan.totals.cost)} at ${plan.by_store.length} ${plan.by_store.length === 1 ? 'store' : 'stores'}: ${plan.by_store.map((x) => `${x.store_name} ${money(x.cost)}`).join(', ')}. ${plan.totals.packs} packs.</p>++<p class="muted small">Cheapest total ${money(plan.totals.cost)} at ${plan.by_store.length} ${plan.by_store.length === 1 ? 'store' : 'stores'}: ${plan.by_store.map((x) => `${x.store_name} ${money(x.cost)}`).join(', ')}. ${plan.totals.packs} packs.</p>` : ''}<h2>What to eat</h2>${plan.menu.map((d) => html`<section class="plan-day"><h3>Day ${d.day}</h3><ul>${[['breakfast', 'Breakfast'], ['lunch', 'Lunch'], ['dinner', 'Dinner'], ['snack', 'Snack']].map(([slot, label]) => html`<li><b>${label}:</b> ${d[slot].join(', ') || '—'}</li>`)}</ul></section>`)}@@@ -228,7 +230,7 @@ ${plan.notes.map((n) => html`<p class="muted small">${n}</p>`)}`;body: html`<h1>Plan meals</h1><p class="lede">It buys one item from each of the six food groups first, cheapest per serving, then fills up with the best calories per dollar. Deterministic: the same numbers always give the same plan.</p>${planForm(spec)}--${savedId ? notice('Saved. It is in your plans below.', 'ok') : ''}++${savedId && /^pln_[0-9A-Z]{26}$/.test(savedId) ? notice(html`Saved. <a href="/plan/p/${savedId}">Open it</a> to tick the shopping list off or share it with your household.`, 'ok') : ''}${problem ? notice(problem, 'warn') : ''}${plan ? planBody(plan) : ''}${plan && signedIn(req) ? html`<h2>Save this plan</h2>@@@ -261,18 +263,161 @@ ${plan && !signedIn(req) ? html`<p class="muted small"><a href="/auth/login?next// Saved plans sit under the same page: nothing personal is ever in a public cache.r.get('/plan/saved', async (req, res) => {if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fplan%2Fsaved');-- const rows = await store.listPlans(s, ownerOf(req), { limit: 50 });++ const [rows, shared] = await Promise.all([store.listPlans(s, ownerOf(req), { limit: 50 }), store.listSharedPlans(s, ownerOf(req))]);++ const cells = (row, when) => {++ const plan = typeof row.plan === 'string' ? JSON.parse(row.plan) : row.plan;++ return [html`<a href="/plan/p/${row.id}">${planTitle(row)}</a>`, `${row.people} people, ${row.days} days`, money(plan.totals.cost), time(when)];++ };page(req, res, {title: 'Your plans', crumbs: [{ label: 'Meal plan', href: '/plan' }, { label: 'Saved' }],body: html`<h1>Your saved plans</h1>--${table(['Name', 'For', 'Cost', 'Saved'], rows.map((row) => {-- const plan = typeof row.plan === 'string' ? JSON.parse(row.plan) : row.plan;-- return [row.title || `Plan ${String(row.id).slice(-6)}`, `${row.people} people, ${row.days} days`, money(plan.totals.cost), time(row.created_at)];-- }), { empty: 'No saved plans yet. Make one on the plan page and save it.' })}++${table(['Name', 'For', 'Cost', 'Saved'], rows.map((row) => cells(row, row.created_at)), { empty: 'No saved plans yet. Make one on the plan page and save it.' })}++${shared.length ? html`<h2>Shared with you</h2>++${table(['Name', 'For', 'Cost', 'Joined'], shared.map((row) => cells(row, row.joined_at)))}` : ''}<p><a href="/plan">Plan another</a></p>`,});});++ // ── Plan together: a saved plan's page, its household and the shared shopping list ──++ // The owner makes a share link; anyone signed in who opens it and says yes joins the household, sees the plan and++ // ticks the shopping list off for everyone. Only the SHA-256 of the link's secret is kept, so a link is shown once;++ // a new one replaces it and turning it off stops new joins (members stay until removed). Every change is a POST++ // from openvibe.food itself.++ const PLAN_ID = /^pln_[0-9A-Z]{26}$/;++ const SECRET = /^[A-Za-z0-9_-]{43}$/;++ const hashOf = (secret) => crypto.createHash('sha256').update(secret).digest('hex');++ const nameOf = (req) => String(req.viewer.displayName || req.viewer.username || 'someone').slice(0, 80);++ const planTitle = (row) => row.title || `Plan ${String(row.id).slice(-6)}`;++ const PLAN_STYLES = { styles: [showcase.STYLESHEET], localStyles: ['css/plan.css'] };++ const form = express.urlencoded({ extended: false, limit: '16kb' });++ const toLogin = (req, res) => res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl.slice(0, 400))}`);++ const notOurs = (req, res) => res.status(403).type('text/plain').send('A plan is changed from openvibe.food itself.');++++ async function loadPlan(req, res) {++ const id = str(req.params.id, 40);++ const got = id && PLAN_ID.test(id) ? await store.planForViewer(s, ownerOf(req), id) : null;++ if (got) return got;++ page(req, res, { title: 'Plan not found', crumbs: [{ label: 'Meal plan', href: '/plan' }, { label: 'Not found' }],++ body: html`<h1>Plan not found</h1><p>It is not yours or shared with you, or it was deleted.</p><p><a href="/plan/saved">Your plans</a></p>` }, 404);++ return null;++ }++++ async function planPage(req, res, got, { link = null, problem = null, status = 200 } = {}) {++ const { row, role } = got;++ const plan = typeof row.plan === 'string' ? JSON.parse(row.plan) : row.plan;++ const [members, ticks] = await Promise.all([store.listMembers(s, row.id), store.listTicks(s, row.id)]);++ const ticked = new Map(ticks.map((t) => [t.item, t]));++ const got_ = plan.shopping_list.filter((i) => ticked.has(i.slug)).length;++ const me = ownerOf(req);++ const btn = (action, label, { cls = 'sc-btn', fields = {} } = {}) => html`<form method="post" action="/plan/p/${row.id}/${action}">${Object.entries(fields).map(([k, v]) => html`<input type="hidden" name="${k}" value="${v}">`)}<button class="${cls}" type="submit">${label}</button></form>`;++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ page(req, res, {++ title: planTitle(row), crumbs: [{ label: 'Meal plan', href: '/plan' }, { label: 'Your plans', href: '/plan/saved' }, { label: planTitle(row) }], ...PLAN_STYLES,++ body: html`<h1>${planTitle(row)}</h1>++<p class="lede">${role === 'owner' ? 'Your plan' : 'Shared with you'} · ${members.length + 1} ${members.length ? 'people in this household' : 'person so far'}.</p>++${problem ? notice(problem, 'warn') : ''}++<h2>The shopping list</h2>++<p class="muted small">${got_} of ${plan.shopping_list.length} got. A tick shows for everyone in the household.</p>++<ul class="shop-list">${plan.shopping_list.map((i) => {++ const t = ticked.get(i.slug);++ return html`<li class="${t ? 'got' : ''}"><form method="post" action="/plan/p/${row.id}/tick"><input type="hidden" name="item" value="${i.slug}"><input type="hidden" name="done" value="${t ? '0' : '1'}"><button class="tick" type="submit" aria-pressed="${t ? 'true' : 'false'}" aria-label="${t ? 'Not got yet' : 'Got it'}: ${i.name}">${t ? '✓' : ''}</button></form>++<span class="shop-name"><a href="/foods/${i.slug}">${i.name}</a> × ${String(i.packs)}</span><span class="shop-meta muted small">${i.store_name || ''} ${money(i.cost)}${t ? html` · got by ${t.ticked_by === me ? 'you' : (t.name || 'someone')}` : ''}</span></li>`;++ })}</ul>++${planBody(plan, { shopping: false })}++<h2 id="household">The household</h2>++<ul class="household">++<li>${role === 'owner' ? 'You' : 'The plan’s owner'} <span class="badge">owner</span></li>++${members.map((m) => html`<li>${m.member === me ? 'You' : (m.name || 'Someone')} <span class="muted small">joined ${time(m.joined_at)}</span>${role === 'owner' ? btn('remove', 'Remove', { fields: { member: m.member } }) : ''}</li>`)}++</ul>++${role === 'owner' ? html`${link ? html`<div class="notice ok" role="status"><p><b>Your share link.</b> Anyone signed in with OpenVibe who opens it can join this household. It is shown once: copy it now. A new link replaces it.</p>++<p class="share-link"><input type="text" readonly value="${link}" aria-label="Share link"></p></div>` : ''}++<p class="muted small">${row.share_hash ? 'A share link is on.' : 'No share link is on.'} Members see this plan and tick the shopping list; only you change the household.</p>++<div class="plan-actions">${btn('share', row.share_hash ? 'Make a new share link' : 'Make a share link', { cls: 'sc-btn sc-primary' })}${row.share_hash ? btn('unshare', 'Turn the link off') : ''}${btn('delete', 'Delete this plan', { cls: 'sc-btn danger' })}</div>`++ : html`<div class="plan-actions">${btn('leave', 'Leave this household', { cls: 'sc-btn danger' })}</div>`}`,++ }, status);++ }++++ r.get('/plan/p/:id', async (req, res) => {++ if (!signedIn(req)) return toLogin(req, res);++ const got = await loadPlan(req, res);++ if (got) await planPage(req, res, got);++ });++++ r.post('/plan/p/:id/:action', form, async (req, res) => {++ if (!signedIn(req)) return toLogin(req, res);++ if (!sameOrigin(req, config.baseUrl)) return notOurs(req, res);++ const got = await loadPlan(req, res);++ if (!got) return undefined;++ const { row, role } = got;++ const body = req.body || {};++ const back = () => res.redirect(303, `/plan/p/${row.id}`);++ switch (req.params.action) {++ case 'tick': {++ const plan = typeof row.plan === 'string' ? JSON.parse(row.plan) : row.plan;++ const item = str(body.item, 80);++ if (!item || !plan.shopping_list.some((i) => i.slug === item)) return res.status(400).type('text/plain').send('That is not on this shopping list.');++ if (body.done === '0') await store.untick(s, row.id, item);++ else await store.tick(s, row.id, item, ownerOf(req), nameOf(req), s.iso());++ return back();++ }++ case 'leave':++ if (role === 'member') await store.removeMember(s, row.id, ownerOf(req));++ return res.redirect(303, '/plan/saved');++ case 'share': case 'unshare': case 'remove': case 'delete':++ if (role !== 'owner') return res.status(403).type('text/plain').send('Only the plan’s owner changes the household.');++ break;++ default:++ return res.status(404).type('text/plain').send('Not found.');++ }++ if (req.params.action === 'share') {++ const secret = crypto.randomBytes(32).toString('base64url');++ await store.setShareHash(s, ownerOf(req), row.id, hashOf(secret));++ return planPage(req, res, { row: { ...row, share_hash: 'on' }, role }, { link: `${config.baseUrl}/plan/join/${secret}` });++ }++ if (req.params.action === 'unshare') await store.setShareHash(s, ownerOf(req), row.id, null);++ if (req.params.action === 'remove') { const m = str(body.member, 80); if (m) await store.removeMember(s, row.id, m); }++ if (req.params.action === 'delete') { await store.deletePlan(s, ownerOf(req), row.id); return res.redirect(303, '/plan/saved'); }++ return back();++ });++++ async function joinTarget(req, res) {++ const secret = String(req.params.secret || '');++ const row = SECRET.test(secret) ? await store.planByShareHash(s, hashOf(secret)) : null;++ if (row) return row;++ page(req, res, { title: 'Link not working', crumbs: [{ label: 'Meal plan', href: '/plan' }, { label: 'Join' }],++ body: html`<h1>This link does not work any more</h1><p>Its owner turned it off or made a new one. Ask them for the new link.</p><p><a href="/plan">Make your own plan</a></p>` }, 404);++ return null;++ }++++ r.get('/plan/join/:secret', async (req, res) => {++ if (!signedIn(req)) return toLogin(req, res);++ const row = await joinTarget(req, res);++ if (!row) return undefined;++ if (await store.planForViewer(s, ownerOf(req), row.id)) return res.redirect(303, `/plan/p/${row.id}`);++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ return page(req, res, {++ title: 'Join a household', crumbs: [{ label: 'Meal plan', href: '/plan' }, { label: 'Join' }], ...PLAN_STYLES,++ body: html`<h1>Join “${planTitle(row)}”?</h1>++<p class="lede">You will see this meal plan (${row.people} people, ${row.days} days) and tick its shopping list off with everyone in the household. The household sees your name, ${nameOf(req)}. You can leave at any time.</p>++<form method="post" action="/plan/join/${req.params.secret}"><button class="sc-btn sc-primary" type="submit">Join the household</button></form>`,++ });++ });++++ r.post('/plan/join/:secret', form, async (req, res) => {++ if (!signedIn(req)) return toLogin(req, res);++ if (!sameOrigin(req, config.baseUrl)) return notOurs(req, res);++ const row = await joinTarget(req, res);++ if (!row) return undefined;++ if (row.owner !== ownerOf(req)) {++ const out = await store.addMember(s, row.id, ownerOf(req), nameOf(req), s.iso());++ if (out === 'food.plan.full' || out === 'food.plan.too_many') {++ return page(req, res, { title: 'Could not join', crumbs: [{ label: 'Meal plan', href: '/plan' }, { label: 'Join' }],++ body: html`<h1>Could not join</h1><p>${out === 'food.plan.full' ? `This household is full (${store.MEMBERS_MAX} people besides its owner).` : `You are already in ${store.SHARED_PLANS_MAX} households; leave one first.`}</p><p><a href="/plan/saved">Your plans</a></p>` }, 409);++ }++ }++ return res.redirect(303, `/plan/p/${row.id}`);++ });++// ── The food list ────────────────────────────────────────r.get('/foods', (req, res) => {const group = foods.GROUPS[req.query.group] ? req.query.group : null;@@@ -1,16 +1,17 @@'use strict';/**-- * Account export and deletion → Food (ADR-033; openvibe-sdk/account-data). Food holds three things a person made:-- * a saved meal plan, their pantry list and their recipe suggestions, each keyed by the person (plans and pantry under-- * `owner = user:usr_…`, recipe suggestions under `subject_id = user:usr_…`). All three are the person's own — nothing-- * anyone else hangs under them — so all three are deleted whole and nothing is kept.++ * Account export and deletion → Food (ADR-033; openvibe-sdk/account-data). Food holds five things of a person's:++ * their saved meal plans, their pantry list and their recipe suggestions (plans and pantry under++ * `owner = user:usr_…`, recipe suggestions under `subject_id = user:usr_…`), and, from plan together, the households++ * they joined (`member`) and the shopping-list items they ticked (`ticked_by`). All are deleted whole and nothing is++ * kept: a plan's own members and ticks go with it (ON DELETE CASCADE); a member's tick on someone else's plan goes,++ * and that item shows as not yet got.*-- * network.account.export_requested the person's plans (plans.json), pantry (pantry.json) and recipe suggestions-- * (recipe-suggestions.json), newest first, pushed to Network-- * (POST /internal/account-exports/:id/parts) with this service's token.-- * network.account.deleted the person's plans, pantry and recipe suggestions go, and Food confirms with-- * counts.++ * network.account.export_requested plans.json, pantry.json, recipe-suggestions.json, households.json and++ * shopping-ticks.json, pushed to Network (POST /internal/account-exports/:id/parts)++ * with this service's token.++ * network.account.deleted the same rows go, and Food confirms with counts.** The Nominatim/Overpass cache (food_geo_cache) holds no person's rows: it is keyed by the query, not by a subject, so* it is neither exported nor erased (see migrations/0002_food.sql). Nothing here is a secret: no token, key or@@@ -19,13 +20,17 @@const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');/**-- * The tables that hold a person's rows, with the value the subject column really stores. All three store++ * The tables that hold a person's rows, with the value the subject column really stores. All five store* `user:usr_…` (server/food/store.js takes the owner straight from the principal's requester).*/const TABLES = [{ table: 'food_plans', subject: 'owner', value: (usr) => `user:${usr}`, file: 'plans.json' },{ table: 'food_pantry', subject: 'owner', value: (usr) => `user:${usr}`, file: 'pantry.json' },{ table: 'food_recipe_suggestions', subject: 'subject_id', value: (usr) => `user:${usr}`, file: 'recipe-suggestions.json' },++ // Plan together: the households a person joined, and the shopping-list items they ticked on any plan. Their own++ // plans' members and ticks go with the plan (ON DELETE CASCADE).++ { table: 'food_plan_members', subject: 'member', value: (usr) => `user:${usr}`, file: 'households.json' },++ { table: 'food_plan_ticks', subject: 'ticked_by', value: (usr) => `user:${usr}`, file: 'shopping-ticks.json' },];/** The account-data handle for Food's store (server/db.js createStore). */@@@ -52,6 +52,15 @@ async function makeRows(t, subject, title) {await foodStore.insertRecipeSuggestion(s, { id: s.newId('rcp'), subject_id: `user:${subject}`, created_at: s.iso(), ask: `ask-${title}`, people: 2, ai_run_id: 'run_1', recipes: [{ title }] });}++/** Plan together: A joins B's household and ticks an item off B's shopping list. */++async function joinB(t) {++ const s = t.ctx.s;++ const bPlan = (await s.db.maybe('SELECT id FROM food_plans WHERE owner = $1', [`user:${B}`])).id;++ await foodStore.addMember(s, bPlan, `user:${A}`, 'Person A', s.iso());++ await foodStore.tick(s, bPlan, 'oats', `user:${A}`, 'Person A', s.iso());++ return bPlan;++}++(async () => {await check('an export part carries only the person\'s plans and pantry, and no secret', async () => {const stub = await startNetworkStub();@@@ -60,6 +69,7 @@ async function makeRows(t, subject, title) {try {await makeRows(t, A, 'person-a');await makeRows(t, B, 'person-b');++ const bPlan = await joinB(t);const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });assert.strictEqual(res.status, 200);@@@ -69,7 +79,9 @@ async function makeRows(t, subject, title) {assert.ok(part, 'the part was pushed to Network');assert.strictEqual(part.auth, 'Bearer tok_food', 'with this service\'s own token');assert.strictEqual(part.body.subject, A);-- assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['pantry.json', 'plans.json', 'recipe-suggestions.json']);++ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['households.json', 'pantry.json', 'plans.json', 'recipe-suggestions.json', 'shopping-ticks.json']);++ assert.deepStrictEqual(part.body.files.find((f) => f.name === 'households.json').content.map((m) => m.plan_id), [bPlan], 'the household A joined');++ assert.deepStrictEqual(part.body.files.find((f) => f.name === 'shopping-ticks.json').content.map((x) => x.item), ['oats'], 'what A ticked');const plans = part.body.files.find((f) => f.name === 'plans.json').content;assert.strictEqual(plans.length, 1);assert.strictEqual(plans[0].title, 'person-a');@@@ -88,6 +100,7 @@ async function makeRows(t, subject, title) {try {await makeRows(t, A, 'person-a');await makeRows(t, B, 'person-b');++ const bPlan = await joinB(t);const gone = async (subject) => await t.ctx.s.db.value('SELECT count(*)::int FROM food_plans WHERE owner = $1', [`user:${subject}`]);const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });@@@ -97,10 +110,12 @@ async function makeRows(t, subject, title) {assert.strictEqual(await t.ctx.s.db.value('SELECT count(*)::int FROM food_pantry WHERE owner = $1', [`user:${A}`]), 0, 'the pantry is gone');assert.strictEqual(await t.ctx.s.db.value('SELECT count(*)::int FROM food_recipe_suggestions WHERE subject_id = $1', [`user:${A}`]), 0, 'the recipe suggestions are gone');assert.strictEqual(await gone(B), 1, 'someone else\'s plan stays');++ assert.strictEqual(await t.ctx.s.db.value('SELECT count(*)::int FROM food_plan_members WHERE plan_id = $1', [bPlan]), 0, 'A left B\'s household');++ assert.strictEqual(await t.ctx.s.db.value('SELECT count(*)::int FROM food_plan_ticks WHERE plan_id = $1', [bPlan]), 0, 'A\'s tick on B\'s list is gone');const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);assert.ok(confirmation, 'the confirmation was sent');-- assert.deepStrictEqual(confirmation.body.erased, { food_plans: 1, food_pantry: 1, food_recipe_suggestions: 1 });++ assert.deepStrictEqual(confirmation.body.erased, { food_plans: 1, food_pantry: 1, food_recipe_suggestions: 1, food_plan_members: 1, food_plan_ticks: 1 });assert.deepStrictEqual(confirmation.body.retained, {});assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));@@@ -14,7 +14,7 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');// Budgets are set from the numbers measured on 2026-10-08 (the real ones, fresh database), rounded up with about 10%// headroom; file counts are the measured counts and external files stay at 0.const BUDGETS = {-- htmlRawKB: 28.5, // measured 27.6 with Shared 4.1.0's manifest + apple-touch-icon links (24.9 before; the home page: hero, features, steps, cta, JSON-LD)++ htmlRawKB: 29, // measured 28.7 with plan together as the sixth feature (2026-10-15); 27.6 with Shared 4.1.0's manifest + apple-touch-icon links (24.9 before; the home page: hero, features, steps, cta, JSON-LD)htmlBrotliKB: 7.3, // 7.0 (6.2 before 4.1.0)jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)jsRawKB: 264, // 239.8nnew file mode 100644@@@ -0,0 +1,123 @@++'use strict';++/**++ * Plan together (migrations/0005_plan_households.sql, server/http/pages.js): a saved plan has its own page; its++ * owner makes a share link (shown once, only its hash kept); someone signed in who opens it and says yes joins the++ * household, sees the plan and ticks the shopping list off for everyone; only the owner shares, rotates, turns the++ * link off, removes people or deletes the plan; a member can leave; every change is a same-origin POST; nobody else++ * sees the plan; and the private pages stay out of robots.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const SAME = { 'sec-fetch-site': 'same-origin' };++const CROSS = { 'sec-fetch-site': 'cross-site' };++++(async () => {++ const t = await boot();++ const kim = t.network.addUser('kim');++ const lee = t.network.addUser('lee');++ const ana = t.network.addUser('ana');++ let id = null;++ let secret = null;++ let item = null;++ const linkOf = (text) => (/https:\/\/openvibe\.food\/plan\/join\/([A-Za-z0-9_-]{43})/.exec(text) || [])[1] || null;++ try {++ await check('a saved plan has its own page, for its owner only', async () => {++ const posted = await t.get('/plan', { as: kim, method: 'POST', form: { people: '3', days: '2', title: 'Our week' }, headers: SAME });++ assert.strictEqual(posted.status, 303);++ id = new URL(posted.headers.get('location'), t.base).searchParams.get('saved');++ assert.match(id, /^pln_[0-9A-Z]{26}$/);++ const back = await t.get(posted.headers.get('location'), { as: kim });++ assert.ok(back.text.includes(`href="/plan/p/${id}"`), 'the saved notice links the plan');++++ const mine = await t.get(`/plan/p/${id}`, { as: kim });++ assert.strictEqual(mine.status, 200);++ assert.match(mine.headers.get('cache-control'), /private/);++ assert.ok(mine.text.includes('Our week') && mine.text.includes('The household') && mine.text.includes('Make a share link'));++ item = (/name="item" value="([a-z0-9-]+)"/.exec(mine.text) || [])[1];++ assert.ok(item, 'the shopping list has tick buttons');++ assert.ok(mine.text.includes('css/plan.css') || mine.text.includes('plan.css'), 'its own stylesheet');++++ assert.strictEqual((await t.get(`/plan/p/${id}`, { as: lee })).status, 404, 'not shared: not found');++ const out = await t.get(`/plan/p/${id}`);++ assert.strictEqual(out.status, 303);++ assert.match(out.headers.get('location'), /^\/auth\/login\?next=%2Fplan%2Fp%2F/);++ const listed = await t.get('/plan/saved', { as: kim });++ assert.ok(listed.text.includes(`/plan/p/${id}`), 'the saved list links it');++ });++++ await check('the owner makes a share link, shown once; someone signed in joins with it', async () => {++ assert.strictEqual((await t.get(`/plan/p/${id}/share`, { as: kim, method: 'POST', form: {}, headers: CROSS })).status, 403, 'not from another site');++ const shared = await t.get(`/plan/p/${id}/share`, { as: kim, method: 'POST', form: {}, headers: SAME });++ assert.strictEqual(shared.status, 200);++ secret = linkOf(shared.text);++ assert.ok(secret, 'the link is on the page');++ assert.ok(!(await t.dbDump()).includes(secret), 'only its hash is stored');++ assert.ok(!linkOf((await t.get(`/plan/p/${id}`, { as: kim })).text), 'shown once');++++ const ask = await t.get(`/plan/join/${secret}`, { as: lee });++ assert.strictEqual(ask.status, 200);++ assert.ok(ask.text.includes('Join “Our week”?') && ask.text.includes('Join the household'));++ const joined = await t.get(`/plan/join/${secret}`, { as: lee, method: 'POST', form: {}, headers: SAME });++ assert.strictEqual(joined.status, 303);++ assert.strictEqual(joined.headers.get('location'), `/plan/p/${id}`);++ const theirs = await t.get(`/plan/p/${id}`, { as: lee });++ assert.strictEqual(theirs.status, 200);++ assert.ok(theirs.text.includes('Shared with you') && theirs.text.includes('Leave this household'));++ assert.ok(!theirs.text.includes('Make a new share link'), 'a member does not share');++ assert.ok((await t.get('/plan/saved', { as: lee })).text.includes('Shared with you'));++ const again = await t.get(`/plan/join/${secret}`, { as: lee });++ assert.strictEqual(again.status, 303, 'a member opening the link goes to the plan');++ });++++ await check('a tick shows for everyone; only items on the list; a member cannot change the household', async () => {++ const ticked = await t.get(`/plan/p/${id}/tick`, { as: lee, method: 'POST', form: { item, done: '1' }, headers: SAME });++ assert.strictEqual(ticked.status, 303);++ const seen = await t.get(`/plan/p/${id}`, { as: kim });++ assert.ok(/<li class="got">/.test(seen.text) && seen.text.includes('got by'), 'the owner sees the tick');++ assert.ok(seen.text.includes('1 of '), 'and the count');++ assert.strictEqual((await t.get(`/plan/p/${id}/tick`, { as: lee, method: 'POST', form: { item: 'not-on-the-list', done: '1' }, headers: SAME })).status, 400);++ await t.get(`/plan/p/${id}/tick`, { as: kim, method: 'POST', form: { item, done: '0' }, headers: SAME });++ assert.ok(!/<li class="got">/.test((await t.get(`/plan/p/${id}`, { as: lee })).text), 'untick shows for everyone too');++ for (const action of ['share', 'unshare', 'remove', 'delete']) {++ assert.strictEqual((await t.get(`/plan/p/${id}/${action}`, { as: lee, method: 'POST', form: { member: 'x' }, headers: SAME })).status, 403, action);++ }++ assert.strictEqual((await t.get(`/plan/p/${id}/tick`, { as: ana, method: 'POST', form: { item, done: '1' }, headers: SAME })).status, 404, 'an outsider ticks nothing');++ });++++ await check('a new link replaces the old one; turning it off stops joins; removed and leaving people lose the plan', async () => {++ const fresh = linkOf((await t.get(`/plan/p/${id}/share`, { as: kim, method: 'POST', form: {}, headers: SAME })).text);++ assert.ok(fresh && fresh !== secret);++ assert.strictEqual((await t.get(`/plan/join/${secret}`, { as: ana })).status, 404, 'the old link is dead');++ assert.strictEqual((await t.get(`/plan/join/${fresh}`, { as: ana, method: 'POST', form: {}, headers: SAME })).status, 303);++ assert.strictEqual((await t.get(`/plan/p/${id}`, { as: ana })).status, 200);++ await t.get(`/plan/p/${id}/unshare`, { as: kim, method: 'POST', form: {}, headers: SAME });++ assert.strictEqual((await t.get(`/plan/join/${fresh}`, { as: ana })).status, 404, 'turned off');++ assert.strictEqual((await t.get(`/plan/join/${'x'.repeat(43)}`, { as: ana })).status, 404);++++ const page = (await t.get(`/plan/p/${id}`, { as: kim })).text;++ const lees = (/name="member" value="(user:[^"]+)"/.exec(page) || [])[1];++ assert.ok(lees);++ await t.get(`/plan/p/${id}/remove`, { as: kim, method: 'POST', form: { member: lees }, headers: SAME });++ const both = [(await t.get(`/plan/p/${id}`, { as: lee })).status, (await t.get(`/plan/p/${id}`, { as: ana })).status].sort();++ assert.deepStrictEqual(both, [200, 404], 'one removed, one still in');++ const stay = (await t.get(`/plan/p/${id}`, { as: lee })).status === 200 ? lee : ana;++ assert.strictEqual((await t.get(`/plan/p/${id}/leave`, { as: stay, method: 'POST', form: {}, headers: SAME })).headers.get('location'), '/plan/saved');++ assert.strictEqual((await t.get(`/plan/p/${id}`, { as: stay })).status, 404, 'left');++ });++++ await check('deleting the plan deletes its household and ticks; the private pages stay out of robots.txt', async () => {++ const link = linkOf((await t.get(`/plan/p/${id}/share`, { as: kim, method: 'POST', form: {}, headers: SAME })).text);++ await t.get(`/plan/join/${link}`, { as: lee, method: 'POST', form: {}, headers: SAME });++ await t.get(`/plan/p/${id}/tick`, { as: lee, method: 'POST', form: { item, done: '1' }, headers: SAME });++ const del = await t.get(`/plan/p/${id}/delete`, { as: kim, method: 'POST', form: {}, headers: SAME });++ assert.strictEqual(del.headers.get('location'), '/plan/saved');++ assert.strictEqual(await t.ctx.s.db.value('SELECT count(*)::int FROM food_plan_members WHERE plan_id = $1', [id]), 0);++ assert.strictEqual(await t.ctx.s.db.value('SELECT count(*)::int FROM food_plan_ticks WHERE plan_id = $1', [id]), 0);++ assert.strictEqual((await t.get(`/plan/p/${id}`, { as: lee })).status, 404);++ const robots = (await t.get('/robots.txt')).text;++ assert.ok(robots.includes('Disallow: /plan/p/') && robots.includes('Disallow: /plan/join/'));++ });++ } finally { await t.close(); }++ done();++})();
@@ -2,8 +2,8 @@
@@ -2,8 +2,8 @@
> What should we eat?
> What should we eat?
-**Status:** the product works — food near you, a meal planner, the food database, the pantry and recipe ideas from the network's AI are live and tested.
-Delivery comparison (the manifests' remaining pillar) is not built yet.
+**Status:** the product works — food near you, a meal planner, the food database, the pantry, recipe ideas from the network's AI and plan together (a saved plan shared with a household, with a shopping list everyone ticks off) are live and tested.
+Delivery price comparison is not built: the delivery apps publish no open prices.
**Domain:** `openvibe.food` · **Port:** 4970 · **Service id:** `food` · **Env prefix:** `FOOD`
**Domain:** `openvibe.food` · **Port:** 4970 · **Service id:** `food` · **Env prefix:** `FOOD`
**License:** AGPL-3.0 (same as every OpenVibe service).
**License:** AGPL-3.0 (same as every OpenVibe service).
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
+-- phase: expand
+-- OpenVibe.Food: plan together (the "Plan together" pillar). A saved plan can be shared with a household: its owner
+-- makes a share link (only the SHA-256 of its secret is kept, share_hash), anyone signed in who opens it joins as a
+-- member, and every member ticks the shopping list off for everyone. Deleting a plan deletes its members and ticks.
+-- Names are the display name a person had when they joined or ticked, shown to the household; nothing here is a
+-- credential, a key or a token.
+
+ALTER TABLE food_plans ADD COLUMN share_hash text COLLATE "C"; -- sha256 hex of the share link's secret
+CREATE UNIQUE INDEX food_plans_share ON food_plans (share_hash) WHERE share_hash IS NOT NULL;
+
+CREATE TABLE food_plan_members (
+ plan_id text COLLATE "C" NOT NULL REFERENCES food_plans (id) ON DELETE CASCADE,
+ member text COLLATE "C" NOT NULL, -- user:usr_…
+ name text, -- their display name when they joined
+ joined_at text COLLATE "C" NOT NULL,
+ PRIMARY KEY (plan_id, member)
+);
+CREATE INDEX food_plan_members_by_member ON food_plan_members (member, joined_at DESC);
+
+CREATE TABLE food_plan_ticks (
+ plan_id text COLLATE "C" NOT NULL REFERENCES food_plans (id) ON DELETE CASCADE,
+ item text COLLATE "C" NOT NULL, -- the shopping list item's food slug
+ ticked_by text COLLATE "C" NOT NULL, -- user:usr_…
+ name text, -- their display name when they ticked it
+ ticked_at text COLLATE "C" NOT NULL,
+ PRIMARY KEY (plan_id, item)
+);
+CREATE INDEX food_plan_ticks_by_person ON food_plan_ticks (ticked_by);
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+/* OpenVibe.Food — a saved plan's page (/plan/p/:id) and joining a household (/plan/join/…) only, so the site-wide
+ stylesheet stays inside its budget. */
+.shop-list { list-style: none; padding: 0; margin: 8px 0 18px; display: grid; gap: 6px; }
+.shop-list li { display: flex; align-items: center; gap: 12px; padding: 8px 12px; border: 1px solid var(--border, #1f2d47); border-radius: 10px; background: var(--bg-card, #131c2e); }
+.shop-list li.got .shop-name { text-decoration: line-through; opacity: .7; }
+.shop-list form { margin: 0; }
+.shop-list .tick { width: 28px; height: 28px; min-width: 28px; padding: 0; margin: 0; border-radius: 8px; border: 2px solid var(--border-light, #2c3d5c); background: transparent; color: var(--on-accent-strong, #fff); font-weight: 700; line-height: 1; cursor: pointer; }
+.shop-list li.got .tick { background: var(--accent-strong, #2563eb); border-color: var(--accent-strong, #2563eb); }
+.shop-list .tick:focus-visible { outline: 3px solid var(--accent, #3b82f6); outline-offset: 2px; }
+.shop-name { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
+.shop-meta { text-align: right; }
+@media (max-width: 560px) {
+ .shop-list li { flex-wrap: wrap; row-gap: 2px; }
+ .shop-name { flex-basis: calc(100% - 40px); }
+ .shop-meta { flex-basis: 100%; padding-left: 40px; text-align: left; }
+}
+.household { list-style: none; padding: 0; margin: 8px 0 14px; }
+.household li { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 6px 0; border-bottom: 1px solid var(--border, #1f2d47); }
+.household form { margin: 0 0 0 auto; }
+.share-link { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 8px 0; }
+.share-link input { flex: 1 1 320px; min-width: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
+.plan-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 22px; }
+.plan-actions form { margin: 0; }
+.sc-btn.danger { border-color: var(--danger, #ef4444); color: var(--danger, #ef4444); }
@@ -57,6 +57,46 @@ async function listPlans(s, owner, { limit = 20 } = {}) {
@@ -57,6 +57,46 @@ async function listPlans(s, owner, { limit = 20 } = {}) {
}
}
const deletePlan = (s, owner, id) => s.db.exec('DELETE FROM food_plans WHERE owner = $1 AND id = $2', [owner, id]);
const deletePlan = (s, owner, id) => s.db.exec('DELETE FROM food_plans WHERE owner = $1 AND id = $2', [owner, id]);
+// ── Plan together: households (migrations/0005_plan_households.sql) ──
+const MEMBERS_MAX = 12; // people in one household besides its owner
+const SHARED_PLANS_MAX = 50; // households one person may belong to
+
+/** The plan and the viewer's place in it ('owner' or 'member'), or null when it is neither theirs nor shared with them. */
+async function planForViewer(s, viewer, id) {
+ const row = await s.db.maybe('SELECT * FROM food_plans WHERE id = $1', [id]);
+ if (!row) return null;
+ if (row.owner === viewer) return { row, role: 'owner' };
+ const m = await s.db.maybe('SELECT 1 AS ok FROM food_plan_members WHERE plan_id = $1 AND member = $2', [id, viewer]);
+ return m ? { row, role: 'member' } : null;
+}
+const setShareHash = (s, owner, id, hash) => s.db.exec('UPDATE food_plans SET share_hash = $3 WHERE owner = $1 AND id = $2', [owner, id, hash]);
+const planByShareHash = (s, hash) => s.db.maybe('SELECT * FROM food_plans WHERE share_hash = $1', [hash]);
+const listMembers = (s, planId) => s.db.many('SELECT member, name, joined_at FROM food_plan_members WHERE plan_id = $1 ORDER BY joined_at', [planId]);
+const listSharedPlans = (s, member) => s.db.many(
+ 'SELECT p.*, m.joined_at FROM food_plan_members m JOIN food_plans p ON p.id = m.plan_id WHERE m.member = $1 ORDER BY m.joined_at DESC LIMIT $2',
+ [member, SHARED_PLANS_MAX]);
+
+/** Join a household: 'joined', 'already', or a refusal code (food.plan.full, food.plan.too_many). */
+async function addMember(s, planId, member, name, at) {
+ return s.tx(async () => {
+ if (await s.db.maybe('SELECT 1 AS ok FROM food_plan_members WHERE plan_id = $1 AND member = $2', [planId, member])) return 'already';
+ const n = await s.db.maybe('SELECT COUNT(*)::int AS n FROM food_plan_members WHERE plan_id = $1', [planId]);
+ if (Number(n.n) >= MEMBERS_MAX) return 'food.plan.full';
+ const mine = await s.db.maybe('SELECT COUNT(*)::int AS n FROM food_plan_members WHERE member = $1', [member]);
+ if (Number(mine.n) >= SHARED_PLANS_MAX) return 'food.plan.too_many';
+ await s.db.exec('INSERT INTO food_plan_members (plan_id, member, name, joined_at) VALUES ($1, $2, $3, $4)', [planId, member, name || null, at]);
+ return 'joined';
+ });
+}
+const removeMember = (s, planId, member) => s.db.exec('DELETE FROM food_plan_members WHERE plan_id = $1 AND member = $2', [planId, member]);
+
+const listTicks = (s, planId) => s.db.many('SELECT item, ticked_by, name, ticked_at FROM food_plan_ticks WHERE plan_id = $1', [planId]);
+const tick = (s, planId, item, by, name, at) => s.db.exec(
+ `INSERT INTO food_plan_ticks (plan_id, item, ticked_by, name, ticked_at) VALUES ($1, $2, $3, $4, $5)
+ ON CONFLICT (plan_id, item) DO UPDATE SET ticked_by = EXCLUDED.ticked_by, name = EXCLUDED.name, ticked_at = EXCLUDED.ticked_at`,
+ [planId, item, by, name || null, at]);
+const untick = (s, planId, item) => s.db.exec('DELETE FROM food_plan_ticks WHERE plan_id = $1 AND item = $2', [planId, item]);
+
const planToWire = (row) => ({
const planToWire = (row) => ({
id: row.id, title: row.title || null, people: Number(row.people), days: Number(row.days),
id: row.id, title: row.title || null, people: Number(row.people), days: Number(row.days),
budget: row.budget == null ? null : Number(row.budget), created_at: row.created_at, plan: parse(row.plan),
budget: row.budget == null ? null : Number(row.budget), created_at: row.created_at, plan: parse(row.plan),
@@ -102,6 +142,8 @@ const recipeSuggestionToWire = (row) => ({
@@ -102,6 +142,8 @@ const recipeSuggestionToWire = (row) => ({
module.exports = {
module.exports = {
cacheGet, cachePut, cachePrune, startCachePrune,
cacheGet, cachePut, cachePrune, startCachePrune,
insertPlan, planRow, listPlans, deletePlan, planToWire,
insertPlan, planRow, listPlans, deletePlan, planToWire,
+ planForViewer, setShareHash, planByShareHash, listMembers, listSharedPlans, addMember, removeMember, listTicks, tick, untick,
+ MEMBERS_MAX, SHARED_PLANS_MAX,
getPantry, pantryRows, setPantry,
getPantry, pantryRows, setPantry,
insertRecipeSuggestion, recipeSuggestionRow, listRecipeSuggestions, countRecipeSuggestionsSince, recipeSuggestionToWire,
insertRecipeSuggestion, recipeSuggestionRow, listRecipeSuggestions, countRecipeSuggestionsSince, recipeSuggestionToWire,
};
};
@@ -17,7 +17,7 @@ const foods = require('../food/foods');
@@ -17,7 +17,7 @@ const foods = require('../food/foods');
const SITE_NAME = 'OpenVibe.Food';
const SITE_NAME = 'OpenVibe.Food';
const DESCRIPTION = 'OpenVibe.Food — What should we eat? Find food banks and budget grocery stores near you, plan meals, and cook from what is in your cupboard.';
const DESCRIPTION = 'OpenVibe.Food — What should we eat? Find food banks and budget grocery stores near you, plan meals, and cook from what is in your cupboard.';
-const DISALLOW = ['/auth/', '/api/', '/pantry', '/plan/saved'];
+const DISALLOW = ['/auth/', '/api/', '/pantry', '/plan/saved', '/plan/p/', '/plan/join/'];
const PAGE_TEXT = {
const PAGE_TEXT = {
'/': ['OpenVibe.Food home', 'OpenVibe.Food: What should we eat? Find food near you, plan budget meals and cook from your cupboard.'],
'/': ['OpenVibe.Food home', 'OpenVibe.Food: What should we eat? Find food near you, plan budget meals and cook from your cupboard.'],
@@ -14,6 +14,7 @@
@@ -14,6 +14,7 @@
* Crawl artifacts (robots.txt, sitemap.xml, llms.txt, llms-full.txt, JSON-LD) are http/discovery.js.
* Crawl artifacts (robots.txt, sitemap.xml, llms.txt, llms-full.txt, JSON-LD) are http/discovery.js.
* Every page that shows OpenStreetMap data carries the attribution ODbL requires, linked back.
* Every page that shows OpenStreetMap data carries the attribution ODbL requires, linked back.
*/
*/
+const crypto = require('crypto');
const express = require('express');
const express = require('express');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
@@ -107,11 +108,12 @@ ${res_.phone ? html`<p class="small">Phone: <a href="tel:${String(res_.phone).re
@@ -107,11 +108,12 @@ ${res_.phone ? html`<p class="small">Phone: <a href="tel:${String(res_.phone).re
body: html`${raw(hero)}
body: html`${raw(hero)}
${raw(showcase.features({
${raw(showcase.features({
title: 'Everything food',
title: 'Everything food',
- lede: 'Five things, all working today: no card, no account needed to look around.',
+ lede: 'Six things, all working today: no card, no account needed to look around.',
items: [
items: [
{ icon: 'ov:location', title: 'Food near you', text: 'Food banks, soup kitchens, community fridges and free pantry boxes, plus the budget grocery stores, with distance, address and opening hours.', href: '/near' },
{ icon: 'ov:location', title: 'Food near you', text: 'Food banks, soup kitchens, community fridges and free pantry boxes, plus the budget grocery stores, with distance, address and opening hours.', href: '/near' },
{ icon: 'ov:page', title: 'A meal planner', text: 'One person, a couple or a group, for up to fourteen days. It covers all six food groups, writes the shopping list and estimates what it costs.', href: '/plan' },
{ icon: 'ov:page', title: 'A meal planner', text: 'One person, a couple or a group, for up to fourteen days. It covers all six food groups, writes the shopping list and estimates what it costs.', href: '/plan' },
{ icon: 'ov:tools', title: 'A food list you can read', text: 'Every item with its nutrition per serving and per 100 g, the typical price band and the cheapest of the five budget chains.', href: '/foods' },
{ icon: 'ov:tools', title: 'A food list you can read', text: 'Every item with its nutrition per serving and per 100 g, the typical price band and the cheapest of the five budget chains.', href: '/foods' },
+ { icon: 'ov:check', title: 'Plan together', text: 'Share a saved plan with your household: everyone sees it, and ticks the shopping list off as they buy.', href: '/plan/saved' },
{ icon: 'ov:account', title: 'Your cupboard', text: 'Keep a list of what you have. Sign in and it says plainly which simple things you can already make, and what one item would unlock.', href: '/pantry' },
{ icon: 'ov:account', title: 'Your cupboard', text: 'Keep a list of what you have. Sign in and it says plainly which simple things you can already make, and what one item would unlock.', href: '/pantry' },
{ icon: 'ov:ai', title: 'Recipe ideas', text: 'Tell it what you feel like and it suggests up to three recipes from your cupboard, with what you would still need to buy and its typical cost. Suggested by AI, so check before you cook.', href: '/recipes' },
{ icon: 'ov:ai', title: 'Recipe ideas', text: 'Tell it what you feel like and it suggests up to three recipes from your cupboard, with what you would still need to buy and its typical cost. Suggested by AI, so check before you cook.', href: '/recipes' },
],
],
@@ -193,7 +195,7 @@ ${osmNote(out.fetched_at)}
@@ -193,7 +195,7 @@ ${osmNote(out.fetched_at)}
</form>`;
</form>`;
}
}
- function planBody(plan) {
+ function planBody(plan, { shopping = true } = {}) {
return html`<section class="plan-summary">
return html`<section class="plan-summary">
<h2>${plan.spec.people} ${plan.spec.people === 1 ? 'person' : 'people'}, ${plan.spec.days} ${plan.spec.days === 1 ? 'day' : 'days'}</h2>
<h2>${plan.spec.people} ${plan.spec.people === 1 ? 'person' : 'people'}, ${plan.spec.days} ${plan.spec.days === 1 ? 'day' : 'days'}</h2>
<ul class="stats">
<ul class="stats">
@@ -205,9 +207,9 @@ ${osmNote(out.fetched_at)}
@@ -205,9 +207,9 @@ ${osmNote(out.fetched_at)}
</ul>
</ul>
${plan.spec.budget != null ? html`<p class="muted small">Budget ${money(plan.spec.budget)}${plan.remaining_budget != null ? html` · ${money(plan.remaining_budget)} left` : ''}.</p>` : ''}
${plan.spec.budget != null ? html`<p class="muted small">Budget ${money(plan.spec.budget)}${plan.remaining_budget != null ? html` · ${money(plan.remaining_budget)} left` : ''}.</p>` : ''}
</section>
</section>
-<h2>The shopping list</h2>
+${shopping ? html`<h2>The shopping list</h2>
${table(['Item', 'Packs', 'Where', 'Cost'], plan.shopping_list.map((i) => [html`<a href="/foods/${i.slug}">${i.name}</a>`, String(i.packs), i.store_name || '—', money(i.cost)]), { empty: 'Nothing in the plan.' })}
${table(['Item', 'Packs', 'Where', 'Cost'], plan.shopping_list.map((i) => [html`<a href="/foods/${i.slug}">${i.name}</a>`, String(i.packs), i.store_name || '—', money(i.cost)]), { empty: 'Nothing in the plan.' })}
-<p class="muted small">Cheapest total ${money(plan.totals.cost)} at ${plan.by_store.length} ${plan.by_store.length === 1 ? 'store' : 'stores'}: ${plan.by_store.map((x) => `${x.store_name} ${money(x.cost)}`).join(', ')}. ${plan.totals.packs} packs.</p>
+<p class="muted small">Cheapest total ${money(plan.totals.cost)} at ${plan.by_store.length} ${plan.by_store.length === 1 ? 'store' : 'stores'}: ${plan.by_store.map((x) => `${x.store_name} ${money(x.cost)}`).join(', ')}. ${plan.totals.packs} packs.</p>` : ''}
<h2>What to eat</h2>
<h2>What to eat</h2>
${plan.menu.map((d) => html`<section class="plan-day"><h3>Day ${d.day}</h3>
${plan.menu.map((d) => html`<section class="plan-day"><h3>Day ${d.day}</h3>
<ul>${[['breakfast', 'Breakfast'], ['lunch', 'Lunch'], ['dinner', 'Dinner'], ['snack', 'Snack']].map(([slot, label]) => html`<li><b>${label}:</b> ${d[slot].join(', ') || '—'}</li>`)}</ul></section>`)}
<ul>${[['breakfast', 'Breakfast'], ['lunch', 'Lunch'], ['dinner', 'Dinner'], ['snack', 'Snack']].map(([slot, label]) => html`<li><b>${label}:</b> ${d[slot].join(', ') || '—'}</li>`)}</ul></section>`)}
@@ -228,7 +230,7 @@ ${plan.notes.map((n) => html`<p class="muted small">${n}</p>`)}`;
@@ -228,7 +230,7 @@ ${plan.notes.map((n) => html`<p class="muted small">${n}</p>`)}`;
body: html`<h1>Plan meals</h1>
body: html`<h1>Plan meals</h1>
<p class="lede">It buys one item from each of the six food groups first, cheapest per serving, then fills up with the best calories per dollar. Deterministic: the same numbers always give the same plan.</p>
<p class="lede">It buys one item from each of the six food groups first, cheapest per serving, then fills up with the best calories per dollar. Deterministic: the same numbers always give the same plan.</p>
${planForm(spec)}
${planForm(spec)}
-${savedId ? notice('Saved. It is in your plans below.', 'ok') : ''}
+${savedId && /^pln_[0-9A-Z]{26}$/.test(savedId) ? notice(html`Saved. <a href="/plan/p/${savedId}">Open it</a> to tick the shopping list off or share it with your household.`, 'ok') : ''}
${problem ? notice(problem, 'warn') : ''}
${problem ? notice(problem, 'warn') : ''}
${plan ? planBody(plan) : ''}
${plan ? planBody(plan) : ''}
${plan && signedIn(req) ? html`<h2>Save this plan</h2>
${plan && signedIn(req) ? html`<h2>Save this plan</h2>
@@ -261,18 +263,161 @@ ${plan && !signedIn(req) ? html`<p class="muted small"><a href="/auth/login?next
@@ -261,18 +263,161 @@ ${plan && !signedIn(req) ? html`<p class="muted small"><a href="/auth/login?next
// Saved plans sit under the same page: nothing personal is ever in a public cache.
// Saved plans sit under the same page: nothing personal is ever in a public cache.
r.get('/plan/saved', async (req, res) => {
r.get('/plan/saved', async (req, res) => {
if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fplan%2Fsaved');
if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fplan%2Fsaved');
- const rows = await store.listPlans(s, ownerOf(req), { limit: 50 });
+ const [rows, shared] = await Promise.all([store.listPlans(s, ownerOf(req), { limit: 50 }), store.listSharedPlans(s, ownerOf(req))]);
+ const cells = (row, when) => {
+ const plan = typeof row.plan === 'string' ? JSON.parse(row.plan) : row.plan;
+ return [html`<a href="/plan/p/${row.id}">${planTitle(row)}</a>`, `${row.people} people, ${row.days} days`, money(plan.totals.cost), time(when)];
+ };
page(req, res, {
page(req, res, {
title: 'Your plans', crumbs: [{ label: 'Meal plan', href: '/plan' }, { label: 'Saved' }],
title: 'Your plans', crumbs: [{ label: 'Meal plan', href: '/plan' }, { label: 'Saved' }],
body: html`<h1>Your saved plans</h1>
body: html`<h1>Your saved plans</h1>
-${table(['Name', 'For', 'Cost', 'Saved'], rows.map((row) => {
- const plan = typeof row.plan === 'string' ? JSON.parse(row.plan) : row.plan;
- return [row.title || `Plan ${String(row.id).slice(-6)}`, `${row.people} people, ${row.days} days`, money(plan.totals.cost), time(row.created_at)];
- }), { empty: 'No saved plans yet. Make one on the plan page and save it.' })}
+${table(['Name', 'For', 'Cost', 'Saved'], rows.map((row) => cells(row, row.created_at)), { empty: 'No saved plans yet. Make one on the plan page and save it.' })}
+${shared.length ? html`<h2>Shared with you</h2>
+${table(['Name', 'For', 'Cost', 'Joined'], shared.map((row) => cells(row, row.joined_at)))}` : ''}
<p><a href="/plan">Plan another</a></p>`,
<p><a href="/plan">Plan another</a></p>`,
});
});
});
});
+ // ── Plan together: a saved plan's page, its household and the shared shopping list ──
+ // The owner makes a share link; anyone signed in who opens it and says yes joins the household, sees the plan and
+ // ticks the shopping list off for everyone. Only the SHA-256 of the link's secret is kept, so a link is shown once;
+ // a new one replaces it and turning it off stops new joins (members stay until removed). Every change is a POST
+ // from openvibe.food itself.
+ const PLAN_ID = /^pln_[0-9A-Z]{26}$/;
+ const SECRET = /^[A-Za-z0-9_-]{43}$/;
+ const hashOf = (secret) => crypto.createHash('sha256').update(secret).digest('hex');
+ const nameOf = (req) => String(req.viewer.displayName || req.viewer.username || 'someone').slice(0, 80);
+ const planTitle = (row) => row.title || `Plan ${String(row.id).slice(-6)}`;
+ const PLAN_STYLES = { styles: [showcase.STYLESHEET], localStyles: ['css/plan.css'] };
+ const form = express.urlencoded({ extended: false, limit: '16kb' });
+ const toLogin = (req, res) => res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl.slice(0, 400))}`);
+ const notOurs = (req, res) => res.status(403).type('text/plain').send('A plan is changed from openvibe.food itself.');
+
+ async function loadPlan(req, res) {
+ const id = str(req.params.id, 40);
+ const got = id && PLAN_ID.test(id) ? await store.planForViewer(s, ownerOf(req), id) : null;
+ if (got) return got;
+ page(req, res, { title: 'Plan not found', crumbs: [{ label: 'Meal plan', href: '/plan' }, { label: 'Not found' }],
+ body: html`<h1>Plan not found</h1><p>It is not yours or shared with you, or it was deleted.</p><p><a href="/plan/saved">Your plans</a></p>` }, 404);
+ return null;
+ }
+
+ async function planPage(req, res, got, { link = null, problem = null, status = 200 } = {}) {
+ const { row, role } = got;
+ const plan = typeof row.plan === 'string' ? JSON.parse(row.plan) : row.plan;
+ const [members, ticks] = await Promise.all([store.listMembers(s, row.id), store.listTicks(s, row.id)]);
+ const ticked = new Map(ticks.map((t) => [t.item, t]));
+ const got_ = plan.shopping_list.filter((i) => ticked.has(i.slug)).length;
+ const me = ownerOf(req);
+ const btn = (action, label, { cls = 'sc-btn', fields = {} } = {}) => html`<form method="post" action="/plan/p/${row.id}/${action}">${Object.entries(fields).map(([k, v]) => html`<input type="hidden" name="${k}" value="${v}">`)}<button class="${cls}" type="submit">${label}</button></form>`;
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ page(req, res, {
+ title: planTitle(row), crumbs: [{ label: 'Meal plan', href: '/plan' }, { label: 'Your plans', href: '/plan/saved' }, { label: planTitle(row) }], ...PLAN_STYLES,
+ body: html`<h1>${planTitle(row)}</h1>
+<p class="lede">${role === 'owner' ? 'Your plan' : 'Shared with you'} · ${members.length + 1} ${members.length ? 'people in this household' : 'person so far'}.</p>
+${problem ? notice(problem, 'warn') : ''}
+<h2>The shopping list</h2>
+<p class="muted small">${got_} of ${plan.shopping_list.length} got. A tick shows for everyone in the household.</p>
+<ul class="shop-list">${plan.shopping_list.map((i) => {
+ const t = ticked.get(i.slug);
+ return html`<li class="${t ? 'got' : ''}"><form method="post" action="/plan/p/${row.id}/tick"><input type="hidden" name="item" value="${i.slug}"><input type="hidden" name="done" value="${t ? '0' : '1'}"><button class="tick" type="submit" aria-pressed="${t ? 'true' : 'false'}" aria-label="${t ? 'Not got yet' : 'Got it'}: ${i.name}">${t ? '✓' : ''}</button></form>
+<span class="shop-name"><a href="/foods/${i.slug}">${i.name}</a> × ${String(i.packs)}</span><span class="shop-meta muted small">${i.store_name || ''} ${money(i.cost)}${t ? html` · got by ${t.ticked_by === me ? 'you' : (t.name || 'someone')}` : ''}</span></li>`;
+ })}</ul>
+${planBody(plan, { shopping: false })}
+<h2 id="household">The household</h2>
+<ul class="household">
+<li>${role === 'owner' ? 'You' : 'The plan’s owner'} <span class="badge">owner</span></li>
+${members.map((m) => html`<li>${m.member === me ? 'You' : (m.name || 'Someone')} <span class="muted small">joined ${time(m.joined_at)}</span>${role === 'owner' ? btn('remove', 'Remove', { fields: { member: m.member } }) : ''}</li>`)}
+</ul>
+${role === 'owner' ? html`${link ? html`<div class="notice ok" role="status"><p><b>Your share link.</b> Anyone signed in with OpenVibe who opens it can join this household. It is shown once: copy it now. A new link replaces it.</p>
+<p class="share-link"><input type="text" readonly value="${link}" aria-label="Share link"></p></div>` : ''}
+<p class="muted small">${row.share_hash ? 'A share link is on.' : 'No share link is on.'} Members see this plan and tick the shopping list; only you change the household.</p>
+<div class="plan-actions">${btn('share', row.share_hash ? 'Make a new share link' : 'Make a share link', { cls: 'sc-btn sc-primary' })}${row.share_hash ? btn('unshare', 'Turn the link off') : ''}${btn('delete', 'Delete this plan', { cls: 'sc-btn danger' })}</div>`
+ : html`<div class="plan-actions">${btn('leave', 'Leave this household', { cls: 'sc-btn danger' })}</div>`}`,
+ }, status);
+ }
+
+ r.get('/plan/p/:id', async (req, res) => {
+ if (!signedIn(req)) return toLogin(req, res);
+ const got = await loadPlan(req, res);
+ if (got) await planPage(req, res, got);
+ });
+
+ r.post('/plan/p/:id/:action', form, async (req, res) => {
+ if (!signedIn(req)) return toLogin(req, res);
+ if (!sameOrigin(req, config.baseUrl)) return notOurs(req, res);
+ const got = await loadPlan(req, res);
+ if (!got) return undefined;
+ const { row, role } = got;
+ const body = req.body || {};
+ const back = () => res.redirect(303, `/plan/p/${row.id}`);
+ switch (req.params.action) {
+ case 'tick': {
+ const plan = typeof row.plan === 'string' ? JSON.parse(row.plan) : row.plan;
+ const item = str(body.item, 80);
+ if (!item || !plan.shopping_list.some((i) => i.slug === item)) return res.status(400).type('text/plain').send('That is not on this shopping list.');
+ if (body.done === '0') await store.untick(s, row.id, item);
+ else await store.tick(s, row.id, item, ownerOf(req), nameOf(req), s.iso());
+ return back();
+ }
+ case 'leave':
+ if (role === 'member') await store.removeMember(s, row.id, ownerOf(req));
+ return res.redirect(303, '/plan/saved');
+ case 'share': case 'unshare': case 'remove': case 'delete':
+ if (role !== 'owner') return res.status(403).type('text/plain').send('Only the plan’s owner changes the household.');
+ break;
+ default:
+ return res.status(404).type('text/plain').send('Not found.');
+ }
+ if (req.params.action === 'share') {
+ const secret = crypto.randomBytes(32).toString('base64url');
+ await store.setShareHash(s, ownerOf(req), row.id, hashOf(secret));
+ return planPage(req, res, { row: { ...row, share_hash: 'on' }, role }, { link: `${config.baseUrl}/plan/join/${secret}` });
+ }
+ if (req.params.action === 'unshare') await store.setShareHash(s, ownerOf(req), row.id, null);
+ if (req.params.action === 'remove') { const m = str(body.member, 80); if (m) await store.removeMember(s, row.id, m); }
+ if (req.params.action === 'delete') { await store.deletePlan(s, ownerOf(req), row.id); return res.redirect(303, '/plan/saved'); }
+ return back();
+ });
+
+ async function joinTarget(req, res) {
+ const secret = String(req.params.secret || '');
+ const row = SECRET.test(secret) ? await store.planByShareHash(s, hashOf(secret)) : null;
+ if (row) return row;
+ page(req, res, { title: 'Link not working', crumbs: [{ label: 'Meal plan', href: '/plan' }, { label: 'Join' }],
+ body: html`<h1>This link does not work any more</h1><p>Its owner turned it off or made a new one. Ask them for the new link.</p><p><a href="/plan">Make your own plan</a></p>` }, 404);
+ return null;
+ }
+
+ r.get('/plan/join/:secret', async (req, res) => {
+ if (!signedIn(req)) return toLogin(req, res);
+ const row = await joinTarget(req, res);
+ if (!row) return undefined;
+ if (await store.planForViewer(s, ownerOf(req), row.id)) return res.redirect(303, `/plan/p/${row.id}`);
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ return page(req, res, {
+ title: 'Join a household', crumbs: [{ label: 'Meal plan', href: '/plan' }, { label: 'Join' }], ...PLAN_STYLES,
+ body: html`<h1>Join “${planTitle(row)}”?</h1>
+<p class="lede">You will see this meal plan (${row.people} people, ${row.days} days) and tick its shopping list off with everyone in the household. The household sees your name, ${nameOf(req)}. You can leave at any time.</p>
+<form method="post" action="/plan/join/${req.params.secret}"><button class="sc-btn sc-primary" type="submit">Join the household</button></form>`,
+ });
+ });
+
+ r.post('/plan/join/:secret', form, async (req, res) => {
+ if (!signedIn(req)) return toLogin(req, res);
+ if (!sameOrigin(req, config.baseUrl)) return notOurs(req, res);
+ const row = await joinTarget(req, res);
+ if (!row) return undefined;
+ if (row.owner !== ownerOf(req)) {
+ const out = await store.addMember(s, row.id, ownerOf(req), nameOf(req), s.iso());
+ if (out === 'food.plan.full' || out === 'food.plan.too_many') {
+ return page(req, res, { title: 'Could not join', crumbs: [{ label: 'Meal plan', href: '/plan' }, { label: 'Join' }],
+ body: html`<h1>Could not join</h1><p>${out === 'food.plan.full' ? `This household is full (${store.MEMBERS_MAX} people besides its owner).` : `You are already in ${store.SHARED_PLANS_MAX} households; leave one first.`}</p><p><a href="/plan/saved">Your plans</a></p>` }, 409);
+ }
+ }
+ return res.redirect(303, `/plan/p/${row.id}`);
+ });
+
// ── The food list ────────────────────────────────────────
// ── The food list ────────────────────────────────────────
r.get('/foods', (req, res) => {
r.get('/foods', (req, res) => {
const group = foods.GROUPS[req.query.group] ? req.query.group : null;
const group = foods.GROUPS[req.query.group] ? req.query.group : null;
@@ -1,16 +1,17 @@
@@ -1,16 +1,17 @@
'use strict';
'use strict';
/**
/**
- * Account export and deletion → Food (ADR-033; openvibe-sdk/account-data). Food holds three things a person made:
- * a saved meal plan, their pantry list and their recipe suggestions, each keyed by the person (plans and pantry under
- * `owner = user:usr_…`, recipe suggestions under `subject_id = user:usr_…`). All three are the person's own — nothing
- * anyone else hangs under them — so all three are deleted whole and nothing is kept.
+ * Account export and deletion → Food (ADR-033; openvibe-sdk/account-data). Food holds five things of a person's:
+ * their saved meal plans, their pantry list and their recipe suggestions (plans and pantry under
+ * `owner = user:usr_…`, recipe suggestions under `subject_id = user:usr_…`), and, from plan together, the households
+ * they joined (`member`) and the shopping-list items they ticked (`ticked_by`). All are deleted whole and nothing is
+ * kept: a plan's own members and ticks go with it (ON DELETE CASCADE); a member's tick on someone else's plan goes,
+ * and that item shows as not yet got.
*
*
- * network.account.export_requested the person's plans (plans.json), pantry (pantry.json) and recipe suggestions
- * (recipe-suggestions.json), newest first, pushed to Network
- * (POST /internal/account-exports/:id/parts) with this service's token.
- * network.account.deleted the person's plans, pantry and recipe suggestions go, and Food confirms with
- * counts.
+ * network.account.export_requested plans.json, pantry.json, recipe-suggestions.json, households.json and
+ * shopping-ticks.json, pushed to Network (POST /internal/account-exports/:id/parts)
+ * with this service's token.
+ * network.account.deleted the same rows go, and Food confirms with counts.
*
*
* The Nominatim/Overpass cache (food_geo_cache) holds no person's rows: it is keyed by the query, not by a subject, so
* The Nominatim/Overpass cache (food_geo_cache) holds no person's rows: it is keyed by the query, not by a subject, so
* it is neither exported nor erased (see migrations/0002_food.sql). Nothing here is a secret: no token, key or
* it is neither exported nor erased (see migrations/0002_food.sql). Nothing here is a secret: no token, key or
@@ -19,13 +20,17 @@
@@ -19,13 +20,17 @@
const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
/**
/**
- * The tables that hold a person's rows, with the value the subject column really stores. All three store
+ * The tables that hold a person's rows, with the value the subject column really stores. All five store
* `user:usr_…` (server/food/store.js takes the owner straight from the principal's requester).
* `user:usr_…` (server/food/store.js takes the owner straight from the principal's requester).
*/
*/
const TABLES = [
const TABLES = [
{ table: 'food_plans', subject: 'owner', value: (usr) => `user:${usr}`, file: 'plans.json' },
{ table: 'food_plans', subject: 'owner', value: (usr) => `user:${usr}`, file: 'plans.json' },
{ table: 'food_pantry', subject: 'owner', value: (usr) => `user:${usr}`, file: 'pantry.json' },
{ table: 'food_pantry', subject: 'owner', value: (usr) => `user:${usr}`, file: 'pantry.json' },
{ table: 'food_recipe_suggestions', subject: 'subject_id', value: (usr) => `user:${usr}`, file: 'recipe-suggestions.json' },
{ table: 'food_recipe_suggestions', subject: 'subject_id', value: (usr) => `user:${usr}`, file: 'recipe-suggestions.json' },
+ // Plan together: the households a person joined, and the shopping-list items they ticked on any plan. Their own
+ // plans' members and ticks go with the plan (ON DELETE CASCADE).
+ { table: 'food_plan_members', subject: 'member', value: (usr) => `user:${usr}`, file: 'households.json' },
+ { table: 'food_plan_ticks', subject: 'ticked_by', value: (usr) => `user:${usr}`, file: 'shopping-ticks.json' },
];
];
/** The account-data handle for Food's store (server/db.js createStore). */
/** The account-data handle for Food's store (server/db.js createStore). */
@@ -52,6 +52,15 @@ async function makeRows(t, subject, title) {
@@ -52,6 +52,15 @@ async function makeRows(t, subject, title) {
await foodStore.insertRecipeSuggestion(s, { id: s.newId('rcp'), subject_id: `user:${subject}`, created_at: s.iso(), ask: `ask-${title}`, people: 2, ai_run_id: 'run_1', recipes: [{ title }] });
await foodStore.insertRecipeSuggestion(s, { id: s.newId('rcp'), subject_id: `user:${subject}`, created_at: s.iso(), ask: `ask-${title}`, people: 2, ai_run_id: 'run_1', recipes: [{ title }] });
}
}
+/** Plan together: A joins B's household and ticks an item off B's shopping list. */
+async function joinB(t) {
+ const s = t.ctx.s;
+ const bPlan = (await s.db.maybe('SELECT id FROM food_plans WHERE owner = $1', [`user:${B}`])).id;
+ await foodStore.addMember(s, bPlan, `user:${A}`, 'Person A', s.iso());
+ await foodStore.tick(s, bPlan, 'oats', `user:${A}`, 'Person A', s.iso());
+ return bPlan;
+}
+
(async () => {
(async () => {
await check('an export part carries only the person\'s plans and pantry, and no secret', async () => {
await check('an export part carries only the person\'s plans and pantry, and no secret', async () => {
const stub = await startNetworkStub();
const stub = await startNetworkStub();
@@ -60,6 +69,7 @@ async function makeRows(t, subject, title) {
@@ -60,6 +69,7 @@ async function makeRows(t, subject, title) {
try {
try {
await makeRows(t, A, 'person-a');
await makeRows(t, A, 'person-a');
await makeRows(t, B, 'person-b');
await makeRows(t, B, 'person-b');
+ const bPlan = await joinB(t);
const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });
const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });
assert.strictEqual(res.status, 200);
assert.strictEqual(res.status, 200);
@@ -69,7 +79,9 @@ async function makeRows(t, subject, title) {
@@ -69,7 +79,9 @@ async function makeRows(t, subject, title) {
assert.ok(part, 'the part was pushed to Network');
assert.ok(part, 'the part was pushed to Network');
assert.strictEqual(part.auth, 'Bearer tok_food', 'with this service\'s own token');
assert.strictEqual(part.auth, 'Bearer tok_food', 'with this service\'s own token');
assert.strictEqual(part.body.subject, A);
assert.strictEqual(part.body.subject, A);
- assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['pantry.json', 'plans.json', 'recipe-suggestions.json']);
+ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['households.json', 'pantry.json', 'plans.json', 'recipe-suggestions.json', 'shopping-ticks.json']);
+ assert.deepStrictEqual(part.body.files.find((f) => f.name === 'households.json').content.map((m) => m.plan_id), [bPlan], 'the household A joined');
+ assert.deepStrictEqual(part.body.files.find((f) => f.name === 'shopping-ticks.json').content.map((x) => x.item), ['oats'], 'what A ticked');
const plans = part.body.files.find((f) => f.name === 'plans.json').content;
const plans = part.body.files.find((f) => f.name === 'plans.json').content;
assert.strictEqual(plans.length, 1);
assert.strictEqual(plans.length, 1);
assert.strictEqual(plans[0].title, 'person-a');
assert.strictEqual(plans[0].title, 'person-a');
@@ -88,6 +100,7 @@ async function makeRows(t, subject, title) {
@@ -88,6 +100,7 @@ async function makeRows(t, subject, title) {
try {
try {
await makeRows(t, A, 'person-a');
await makeRows(t, A, 'person-a');
await makeRows(t, B, 'person-b');
await makeRows(t, B, 'person-b');
+ const bPlan = await joinB(t);
const gone = async (subject) => await t.ctx.s.db.value('SELECT count(*)::int FROM food_plans WHERE owner = $1', [`user:${subject}`]);
const gone = async (subject) => await t.ctx.s.db.value('SELECT count(*)::int FROM food_plans WHERE owner = $1', [`user:${subject}`]);
const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });
const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });
@@ -97,10 +110,12 @@ async function makeRows(t, subject, title) {
@@ -97,10 +110,12 @@ async function makeRows(t, subject, title) {
assert.strictEqual(await t.ctx.s.db.value('SELECT count(*)::int FROM food_pantry WHERE owner = $1', [`user:${A}`]), 0, 'the pantry is gone');
assert.strictEqual(await t.ctx.s.db.value('SELECT count(*)::int FROM food_pantry WHERE owner = $1', [`user:${A}`]), 0, 'the pantry is gone');
assert.strictEqual(await t.ctx.s.db.value('SELECT count(*)::int FROM food_recipe_suggestions WHERE subject_id = $1', [`user:${A}`]), 0, 'the recipe suggestions are gone');
assert.strictEqual(await t.ctx.s.db.value('SELECT count(*)::int FROM food_recipe_suggestions WHERE subject_id = $1', [`user:${A}`]), 0, 'the recipe suggestions are gone');
assert.strictEqual(await gone(B), 1, 'someone else\'s plan stays');
assert.strictEqual(await gone(B), 1, 'someone else\'s plan stays');
+ assert.strictEqual(await t.ctx.s.db.value('SELECT count(*)::int FROM food_plan_members WHERE plan_id = $1', [bPlan]), 0, 'A left B\'s household');
+ assert.strictEqual(await t.ctx.s.db.value('SELECT count(*)::int FROM food_plan_ticks WHERE plan_id = $1', [bPlan]), 0, 'A\'s tick on B\'s list is gone');
const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);
const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);
assert.ok(confirmation, 'the confirmation was sent');
assert.ok(confirmation, 'the confirmation was sent');
- assert.deepStrictEqual(confirmation.body.erased, { food_plans: 1, food_pantry: 1, food_recipe_suggestions: 1 });
+ assert.deepStrictEqual(confirmation.body.erased, { food_plans: 1, food_pantry: 1, food_recipe_suggestions: 1, food_plan_members: 1, food_plan_ticks: 1 });
assert.deepStrictEqual(confirmation.body.retained, {});
assert.deepStrictEqual(confirmation.body.retained, {});
assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));
assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));
@@ -14,7 +14,7 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
@@ -14,7 +14,7 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
// Budgets are set from the numbers measured on 2026-10-08 (the real ones, fresh database), rounded up with about 10%
// Budgets are set from the numbers measured on 2026-10-08 (the real ones, fresh database), rounded up with about 10%
// headroom; file counts are the measured counts and external files stay at 0.
// headroom; file counts are the measured counts and external files stay at 0.
const BUDGETS = {
const BUDGETS = {
- htmlRawKB: 28.5, // measured 27.6 with Shared 4.1.0's manifest + apple-touch-icon links (24.9 before; the home page: hero, features, steps, cta, JSON-LD)
+ htmlRawKB: 29, // measured 28.7 with plan together as the sixth feature (2026-10-15); 27.6 with Shared 4.1.0's manifest + apple-touch-icon links (24.9 before; the home page: hero, features, steps, cta, JSON-LD)
htmlBrotliKB: 7.3, // 7.0 (6.2 before 4.1.0)
htmlBrotliKB: 7.3, // 7.0 (6.2 before 4.1.0)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 264, // 239.8
jsRawKB: 264, // 239.8
new file mode 100644
new file mode 100644
@@ -0,0 +1,123 @@
@@ -0,0 +1,123 @@
+'use strict';
+/**
+ * Plan together (migrations/0005_plan_households.sql, server/http/pages.js): a saved plan has its own page; its
+ * owner makes a share link (shown once, only its hash kept); someone signed in who opens it and says yes joins the
+ * household, sees the plan and ticks the shopping list off for everyone; only the owner shares, rotates, turns the
+ * link off, removes people or deletes the plan; a member can leave; every change is a same-origin POST; nobody else
+ * sees the plan; and the private pages stay out of robots.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const SAME = { 'sec-fetch-site': 'same-origin' };
+const CROSS = { 'sec-fetch-site': 'cross-site' };
+
+(async () => {
+ const t = await boot();
+ const kim = t.network.addUser('kim');
+ const lee = t.network.addUser('lee');
+ const ana = t.network.addUser('ana');
+ let id = null;
+ let secret = null;
+ let item = null;
+ const linkOf = (text) => (/https:\/\/openvibe\.food\/plan\/join\/([A-Za-z0-9_-]{43})/.exec(text) || [])[1] || null;
+ try {
+ await check('a saved plan has its own page, for its owner only', async () => {
+ const posted = await t.get('/plan', { as: kim, method: 'POST', form: { people: '3', days: '2', title: 'Our week' }, headers: SAME });
+ assert.strictEqual(posted.status, 303);
+ id = new URL(posted.headers.get('location'), t.base).searchParams.get('saved');
+ assert.match(id, /^pln_[0-9A-Z]{26}$/);
+ const back = await t.get(posted.headers.get('location'), { as: kim });
+ assert.ok(back.text.includes(`href="/plan/p/${id}"`), 'the saved notice links the plan');
+
+ const mine = await t.get(`/plan/p/${id}`, { as: kim });
+ assert.strictEqual(mine.status, 200);
+ assert.match(mine.headers.get('cache-control'), /private/);
+ assert.ok(mine.text.includes('Our week') && mine.text.includes('The household') && mine.text.includes('Make a share link'));
+ item = (/name="item" value="([a-z0-9-]+)"/.exec(mine.text) || [])[1];
+ assert.ok(item, 'the shopping list has tick buttons');
+ assert.ok(mine.text.includes('css/plan.css') || mine.text.includes('plan.css'), 'its own stylesheet');
+
+ assert.strictEqual((await t.get(`/plan/p/${id}`, { as: lee })).status, 404, 'not shared: not found');
+ const out = await t.get(`/plan/p/${id}`);
+ assert.strictEqual(out.status, 303);
+ assert.match(out.headers.get('location'), /^\/auth\/login\?next=%2Fplan%2Fp%2F/);
+ const listed = await t.get('/plan/saved', { as: kim });
+ assert.ok(listed.text.includes(`/plan/p/${id}`), 'the saved list links it');
+ });
+
+ await check('the owner makes a share link, shown once; someone signed in joins with it', async () => {
+ assert.strictEqual((await t.get(`/plan/p/${id}/share`, { as: kim, method: 'POST', form: {}, headers: CROSS })).status, 403, 'not from another site');
+ const shared = await t.get(`/plan/p/${id}/share`, { as: kim, method: 'POST', form: {}, headers: SAME });
+ assert.strictEqual(shared.status, 200);
+ secret = linkOf(shared.text);
+ assert.ok(secret, 'the link is on the page');
+ assert.ok(!(await t.dbDump()).includes(secret), 'only its hash is stored');
+ assert.ok(!linkOf((await t.get(`/plan/p/${id}`, { as: kim })).text), 'shown once');
+
+ const ask = await t.get(`/plan/join/${secret}`, { as: lee });
+ assert.strictEqual(ask.status, 200);
+ assert.ok(ask.text.includes('Join “Our week”?') && ask.text.includes('Join the household'));
+ const joined = await t.get(`/plan/join/${secret}`, { as: lee, method: 'POST', form: {}, headers: SAME });
+ assert.strictEqual(joined.status, 303);
+ assert.strictEqual(joined.headers.get('location'), `/plan/p/${id}`);
+ const theirs = await t.get(`/plan/p/${id}`, { as: lee });
+ assert.strictEqual(theirs.status, 200);
+ assert.ok(theirs.text.includes('Shared with you') && theirs.text.includes('Leave this household'));
+ assert.ok(!theirs.text.includes('Make a new share link'), 'a member does not share');
+ assert.ok((await t.get('/plan/saved', { as: lee })).text.includes('Shared with you'));
+ const again = await t.get(`/plan/join/${secret}`, { as: lee });
+ assert.strictEqual(again.status, 303, 'a member opening the link goes to the plan');
+ });
+
+ await check('a tick shows for everyone; only items on the list; a member cannot change the household', async () => {
+ const ticked = await t.get(`/plan/p/${id}/tick`, { as: lee, method: 'POST', form: { item, done: '1' }, headers: SAME });
+ assert.strictEqual(ticked.status, 303);
+ const seen = await t.get(`/plan/p/${id}`, { as: kim });
+ assert.ok(/<li class="got">/.test(seen.text) && seen.text.includes('got by'), 'the owner sees the tick');
+ assert.ok(seen.text.includes('1 of '), 'and the count');
+ assert.strictEqual((await t.get(`/plan/p/${id}/tick`, { as: lee, method: 'POST', form: { item: 'not-on-the-list', done: '1' }, headers: SAME })).status, 400);
+ await t.get(`/plan/p/${id}/tick`, { as: kim, method: 'POST', form: { item, done: '0' }, headers: SAME });
+ assert.ok(!/<li class="got">/.test((await t.get(`/plan/p/${id}`, { as: lee })).text), 'untick shows for everyone too');
+ for (const action of ['share', 'unshare', 'remove', 'delete']) {
+ assert.strictEqual((await t.get(`/plan/p/${id}/${action}`, { as: lee, method: 'POST', form: { member: 'x' }, headers: SAME })).status, 403, action);
+ }
+ assert.strictEqual((await t.get(`/plan/p/${id}/tick`, { as: ana, method: 'POST', form: { item, done: '1' }, headers: SAME })).status, 404, 'an outsider ticks nothing');
+ });
+
+ await check('a new link replaces the old one; turning it off stops joins; removed and leaving people lose the plan', async () => {
+ const fresh = linkOf((await t.get(`/plan/p/${id}/share`, { as: kim, method: 'POST', form: {}, headers: SAME })).text);
+ assert.ok(fresh && fresh !== secret);
+ assert.strictEqual((await t.get(`/plan/join/${secret}`, { as: ana })).status, 404, 'the old link is dead');
+ assert.strictEqual((await t.get(`/plan/join/${fresh}`, { as: ana, method: 'POST', form: {}, headers: SAME })).status, 303);
+ assert.strictEqual((await t.get(`/plan/p/${id}`, { as: ana })).status, 200);
+ await t.get(`/plan/p/${id}/unshare`, { as: kim, method: 'POST', form: {}, headers: SAME });
+ assert.strictEqual((await t.get(`/plan/join/${fresh}`, { as: ana })).status, 404, 'turned off');
+ assert.strictEqual((await t.get(`/plan/join/${'x'.repeat(43)}`, { as: ana })).status, 404);
+
+ const page = (await t.get(`/plan/p/${id}`, { as: kim })).text;
+ const lees = (/name="member" value="(user:[^"]+)"/.exec(page) || [])[1];
+ assert.ok(lees);
+ await t.get(`/plan/p/${id}/remove`, { as: kim, method: 'POST', form: { member: lees }, headers: SAME });
+ const both = [(await t.get(`/plan/p/${id}`, { as: lee })).status, (await t.get(`/plan/p/${id}`, { as: ana })).status].sort();
+ assert.deepStrictEqual(both, [200, 404], 'one removed, one still in');
+ const stay = (await t.get(`/plan/p/${id}`, { as: lee })).status === 200 ? lee : ana;
+ assert.strictEqual((await t.get(`/plan/p/${id}/leave`, { as: stay, method: 'POST', form: {}, headers: SAME })).headers.get('location'), '/plan/saved');
+ assert.strictEqual((await t.get(`/plan/p/${id}`, { as: stay })).status, 404, 'left');
+ });
+
+ await check('deleting the plan deletes its household and ticks; the private pages stay out of robots.txt', async () => {
+ const link = linkOf((await t.get(`/plan/p/${id}/share`, { as: kim, method: 'POST', form: {}, headers: SAME })).text);
+ await t.get(`/plan/join/${link}`, { as: lee, method: 'POST', form: {}, headers: SAME });
+ await t.get(`/plan/p/${id}/tick`, { as: lee, method: 'POST', form: { item, done: '1' }, headers: SAME });
+ const del = await t.get(`/plan/p/${id}/delete`, { as: kim, method: 'POST', form: {}, headers: SAME });
+ assert.strictEqual(del.headers.get('location'), '/plan/saved');
+ assert.strictEqual(await t.ctx.s.db.value('SELECT count(*)::int FROM food_plan_members WHERE plan_id = $1', [id]), 0);
+ assert.strictEqual(await t.ctx.s.db.value('SELECT count(*)::int FROM food_plan_ticks WHERE plan_id = $1', [id]), 0);
+ assert.strictEqual((await t.get(`/plan/p/${id}`, { as: lee })).status, 404);
+ const robots = (await t.get('/robots.txt')).text;
+ assert.ok(robots.includes('Disallow: /plan/p/') && robots.includes('Disallow: /plan/join/'));
+ });
+ } finally { await t.close(); }
+ done();
+})();