openvibers/openvibe.fun Commits
Shared places and date polls from Tours (#110)
Changes
nnew file mode 100644@@@ -0,0 +1,29 @@++-- phase: expand++-- An unlisted list is shared through its random link; signed-in members may add sourced places.++CREATE TABLE fun_place_lists (++ id text COLLATE "C" PRIMARY KEY,++ owner_subject text COLLATE "C",++ title text NOT NULL,++ created_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT fun_place_lists_owner CHECK (owner_subject IS NULL OR owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT fun_place_lists_title CHECK (length(title) BETWEEN 3 AND 120)++);++CREATE TABLE fun_place_list_members (++ list_id text COLLATE "C" NOT NULL REFERENCES fun_place_lists(id) ON DELETE CASCADE,++ subject text COLLATE "C" NOT NULL,++ joined_at timestamptz NOT NULL DEFAULT now(),++ PRIMARY KEY (list_id, subject),++ CONSTRAINT fun_place_members_subject CHECK (subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$')++);++CREATE INDEX fun_place_list_members_subject ON fun_place_list_members(subject);++CREATE TABLE fun_place_list_items (++ id text COLLATE "C" PRIMARY KEY,++ list_id text COLLATE "C" NOT NULL REFERENCES fun_place_lists(id) ON DELETE CASCADE,++ place_name text NOT NULL,++ place_url text NOT NULL,++ added_by text COLLATE "C",++ created_at timestamptz NOT NULL DEFAULT now(),++ UNIQUE (list_id, place_url),++ CONSTRAINT fun_place_items_name CHECK (length(place_name) BETWEEN 1 AND 200),++ CONSTRAINT fun_place_items_url CHECK (place_url ~ '^https://openvibe\.tours/place/[a-z0-9-]+-plc_[0-9A-HJKMNP-TV-Z]{26}$')++);@@@ -53,6 +53,8 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex.fu-field { display: grid; gap: 6px; font-size: .82rem; font-weight: 700; color: var(--text-secondary, #96a7c2); min-width: 0; }.fu-field input, .fu-field select, .fu-field textarea { width: 100%; min-height: 42px; padding: 9px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: var(--text-primary, #e6edf7); font: inherit; font-size: .95rem; font-weight: 500; }.fu-field input:focus, .fu-field select:focus, .fu-field textarea:focus { outline: 2px solid var(--room-accent); outline-offset: 1px; border-color: transparent; }++fieldset.fu-options { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; min-width: 0; margin: 0; padding: 14px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; }++fieldset.fu-options legend { padding: 0 6px; font-weight: 700; }.fu-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin: 20px 0 0; }.fu-facts > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); min-width: 0; }.fu-facts dt { font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }@@@ -223,6 +223,7 @@ async function createApp(opts = {}) {agentLayer.mount(agent, { descriptor: require('./agent-descriptor'), render: require('./agent-pages').createAgentPages(ctx).renderPublicPage });app.use(agent);}++ app.use(require('./places/lists').createPlaceListRoutes(ctx));app.use(createPageRoutes(ctx));app.use((req, res) => send(res, 404, {viewer: req.viewer, config, path: req.originalUrl, title: 'Not found',@@@ -282,21 +282,29 @@ function createPageRoutes(ctx) {});r.get('/plans/new', (req, res) => {-- if (!signedIn(req)) return page(req, res, { title: 'Start a plan', body: raw(views.signIn({ title: 'Start a group plan', text: 'A plan belongs to your OpenVibe account; the group can vote with the link.', next: '/plans/new' })) }, 401);-- return page(req, res, { index: false, title: 'Start a group plan', body: raw(views.planNew({ values: { timezone: 'UTC' } })) });++ if (!signedIn(req)) return page(req, res, { title: 'Start a plan', body: raw(views.signIn({ title: 'Start a group plan', text: 'A plan belongs to your OpenVibe account; the group can vote with the link.', next: req.originalUrl })) }, 401);++ const place = typeof req.query.place === 'string' ? req.query.place.slice(0, 120) : '';++ const city = typeof req.query.city === 'string' ? req.query.city.slice(0, 120) : '';++ const placeUrl = typeof req.query.url === 'string' && /^https:\/\/openvibe\.tours\/place\/[a-z0-9-]+-plc_[0-9A-HJKMNP-TV-Z]{26}$/.test(req.query.url) ? req.query.url : '';++ return page(req, res, { index: false, title: 'Start a group plan', body: raw(views.planNew({ values: {++ timezone: 'UTC', place, city, place_url: placeUrl,++ title: place ? `Go to ${place} together` : '', option1_title: place ? `Visit ${place}` : '',++ lat: typeof req.query.lat === 'string' ? req.query.lat : '', lon: typeof req.query.lon === 'string' ? req.query.lon : '',++ } })) });});r.post('/plans', guard, async (req, res) => {const b = req.body || {};-- const options = [1, 2, 3].map((i) => String(b[`option${i}_title`] || '').trim()).filter(Boolean).map((title) => ({ title }));++ const options = [1, 2, 3].map((i) => ({ title: String(b[`option${i}_title`] || '').trim(), starts_at: b[`option${i}_starts_at`] || null,++ place_name: b.place || null, lat: b.lat || null, lon: b.lon || null })).filter((o) => o.title);const out = await store.createPlan({ownerSubject: req.viewer.subject, ownerUsername: req.viewer.username,-- title: b.title, summary: b.summary, city: b.city, timezone: b.timezone, startsAt: b.starts_at, closesAt: b.closes_at,++ title: b.title, summary: b.summary, city: b.city, lat: b.lat, lon: b.lon, timezone: b.timezone, startsAt: b.starts_at, closesAt: b.closes_at,visibility: b.visibility, chatUsername: b.chat_username, options,});if (!out.ok) {-- const values = { ...formValues(b, ['title', 'summary', 'city', 'starts_at', 'closes_at', 'timezone', 'visibility', 'chat_username']) };-- for (const i of [1, 2, 3]) values[`option${i}_title`] = b[`option${i}_title`] || '';++ const values = { ...formValues(b, ['title', 'summary', 'city', 'starts_at', 'closes_at', 'timezone', 'visibility', 'chat_username', 'place', 'place_url', 'lat', 'lon']) };++ for (const i of [1, 2, 3]) { values[`option${i}_title`] = b[`option${i}_title`] || ''; values[`option${i}_starts_at`] = b[`option${i}_starts_at`] || ''; }return page(req, res, { title: 'Start a group plan', body: raw(views.planNew({ values, problem: out.message })) }, out.status || 400);}return res.redirect(303, `/plans/${encodeURIComponent(out.plan.slug)}`);@@@ -24,6 +24,9 @@ const TABLES = [{ table: 'fun_plans', subject: 'owner_subject', file: 'plans.json', erase: { anonymize: { owner_username: null } } },{ table: 'fun_plan_options', subject: 'created_by', file: 'plan-options.json', erase: { anonymize: {} } },{ table: 'fun_plan_votes', subject: 'subject', file: 'plan-votes.json', erase: 'delete' },++ { table: 'fun_place_lists', subject: 'owner_subject', file: 'place-lists.json', erase: 'delete' },++ { table: 'fun_place_list_members', subject: 'subject', file: 'place-list-memberships.json', erase: 'delete' },++ { table: 'fun_place_list_items', subject: 'added_by', file: 'place-list-items.json', erase: 'delete' },{ table: 'fun_reports', subject: 'subject', file: 'reports.json', erase: 'delete' },];nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++++const express = require('express');++const { asyncRouter } = require('../http/router');++const { sameOrigin } = require('../http/principal');++const { esc, raw } = require('../render/html');++const { send } = require('../render/layout');++++const validPlace = (url) => /^https:\/\/openvibe\.tours\/place\/[a-z0-9-]+-plc_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(url || ''));++++function createPlaceListRoutes({ config, s }) {++ const r = asyncRouter();++ r.use(express.urlencoded({ extended: false, limit: '16kb' }));++ const person = (req) => req.viewer?.kind === 'user' ? req.viewer.subject : null;++ const page = (req, res, title, body, status = 200) => send(res, status, {++ viewer: req.viewer, config, path: req.originalUrl, index: false, cache: null, title, body: raw(body),++ });++ const signIn = (req, res) => page(req, res, 'Our places', `<div class="fu-wrap fu-page-pad fu-narrow"><h1>Our places</h1><p>Sign in to make or join a shared list.</p><a class="fu-btn" href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in</a></div>`, 401);++ const guarded = (req, res, next) => {++ if (!person(req)) return signIn(req, res);++ if (!sameOrigin(req, config.baseUrl)) return page(req, res, 'Not from this site', '<div class="fu-wrap"><h1>Use the form on OpenVibe.Fun</h1></div>', 403);++ next();++ };++++ r.get('/places/new', (req, res) => {++ if (!person(req)) return signIn(req, res);++ const name = String(req.query.place || '').slice(0, 200);++ const url = validPlace(req.query.url) ? String(req.query.url) : '';++ return page(req, res, 'Our places', `<div class="fu-wrap fu-narrow" id="fu-places-new"><header class="fu-page-h"><p class="fu-kicker">Together</p><h1>Our places</h1><p class="fu-lede">Keep a list with the people you want to go with. Send its private link; each person joins with their OpenVibe account.</p></header>++<form class="fu-form fu-card" method="post" action="/places"><label class="fu-field"><span>List name</span><input name="title" maxlength="120" required value="Our places"></label>++${url ? `<input type="hidden" name="place_url" value="${esc(url)}"><input type="hidden" name="place_name" value="${esc(name)}"><p>Start with <a href="${esc(url)}" rel="noopener">${esc(name)}</a></p>` : ''}++<button class="fu-btn" type="submit">Create our list</button></form></div>`);++ });++++ r.post('/places', guarded, async (req, res) => {++ const title = String(req.body.title || '').trim().slice(0, 120);++ if (title.length < 3) return page(req, res, 'Our places', '<div class="fu-wrap"><h1>Give your list a name</h1></div>', 400);++ const id = s.newId('lst');++ const subject = person(req);++ await s.tx(async (t) => {++ await t.exec('INSERT INTO fun_place_lists(id, owner_subject, title) VALUES ($1,$2,$3)', [id, subject, title]);++ await t.exec('INSERT INTO fun_place_list_members(list_id, subject) VALUES ($1,$2)', [id, subject]);++ if (validPlace(req.body.place_url) && String(req.body.place_name || '').trim()) {++ await t.exec('INSERT INTO fun_place_list_items(id, list_id, place_name, place_url, added_by) VALUES ($1,$2,$3,$4,$5)',++ [s.newId('lpi'), id, String(req.body.place_name).trim().slice(0, 200), req.body.place_url, subject]);++ }++ });++ return res.redirect(303, `/places/${id}`);++ });++++ r.get('/places/:id', async (req, res) => {++ const id = String(req.params.id || '');++ const list = await s.db.maybe('SELECT id, title FROM fun_place_lists WHERE id=$1', [id]);++ if (!list) return page(req, res, 'List not found', '<div class="fu-wrap"><h1>List not found</h1></div>', 404);++ const items = await s.db.many('SELECT place_name, place_url FROM fun_place_list_items WHERE list_id=$1 ORDER BY created_at, id', [id]);++ const members = await s.db.value('SELECT COUNT(*)::int FROM fun_place_list_members WHERE list_id=$1', [id]);++ const joined = person(req) && await s.db.maybe('SELECT 1 FROM fun_place_list_members WHERE list_id=$1 AND subject=$2', [id, person(req)]);++ return page(req, res, list.title, `<div class="fu-wrap fu-narrow" id="fu-places-list"><header class="fu-page-h"><p class="fu-kicker">Together · ${Number(members)} ${Number(members) === 1 ? 'person' : 'people'}</p><h1>${esc(list.title)}</h1><p class="fu-lede">Share this page with the people you want to go with. Signed-in members can add places.</p></header>++${items.length ? `<ol class="fu-places">${items.map((item) => `<li><a href="${esc(item.place_url)}" rel="noopener">${esc(item.place_name)}</a></li>`).join('')}</ol>` : '<p>No place saved yet.</p>'}++${joined ? `<form class="fu-form fu-card" method="post" action="/places/${esc(id)}/items"><label class="fu-field"><span>Place name</span><input name="place_name" maxlength="200" required></label><label class="fu-field"><span>OpenVibe.Tours place link</span><input name="place_url" type="url" required placeholder="https://openvibe.tours/place/..."></label><button class="fu-btn" type="submit">Add place</button></form>` : person(req) ? `<form method="post" action="/places/${esc(id)}/join"><button class="fu-btn" type="submit">Join this list</button></form>` : `<a class="fu-btn" href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in to join</a>`}++<p><a href="https://openvibe.tours/cities">Find more places</a></p></div>`);++ });++++ r.post('/places/:id/join', guarded, async (req, res) => {++ const id = String(req.params.id || '');++ const list = await s.db.maybe('SELECT id FROM fun_place_lists WHERE id=$1', [id]);++ if (!list) return page(req, res, 'List not found', '<div class="fu-wrap"><h1>List not found</h1></div>', 404);++ await s.db.exec('INSERT INTO fun_place_list_members(list_id, subject) VALUES ($1,$2) ON CONFLICT DO NOTHING', [id, person(req)]);++ return res.redirect(303, `/places/${id}`);++ });++++ r.post('/places/:id/items', guarded, async (req, res) => {++ const id = String(req.params.id || '');++ const member = await s.db.maybe('SELECT 1 FROM fun_place_list_members WHERE list_id=$1 AND subject=$2', [id, person(req)]);++ if (!member) return page(req, res, 'Join first', '<div class="fu-wrap"><h1>Join this list first</h1></div>', 403);++ const name = String(req.body.place_name || '').trim().slice(0, 200);++ const url = String(req.body.place_url || '');++ if (!name || !validPlace(url)) return page(req, res, 'Choose a Tours place', '<div class="fu-wrap"><h1>Use an OpenVibe.Tours place link</h1></div>', 400);++ await s.db.exec(`INSERT INTO fun_place_list_items(id, list_id, place_name, place_url, added_by)++ VALUES ($1,$2,$3,$4,$5) ON CONFLICT(list_id, place_url) DO NOTHING`, [s.newId('lpi'), id, name, url, person(req)]);++ return res.redirect(303, `/places/${id}`);++ });++ return r;++}++++module.exports = { createPlaceListRoutes, validPlace };@@@ -593,7 +593,9 @@ function createStore(s, { search = { sync: async () => null }, config = {}, sharif (!rows.length) return refusal(409, 'fun.plan_no_votes', 'Nobody has voted yet.');const votes = rows.map((r) => ({ option_id: r.option_id, votes: Number(r.votes) || 0 }));if (votes.length > 1 && votes[0].votes === votes[1].votes) return refusal(409, 'fun.plan_tie', 'The vote is tied; add a tie-break or ask one more person to vote.');-- const updated = await t.maybe("UPDATE fun_plans SET status = 'decided', winner_option_id = $2, revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *", [plan.id, votes[0].option_id]);++ const updated = await t.maybe(`UPDATE fun_plans SET status = 'decided', winner_option_id = $2,++ starts_at = COALESCE((SELECT starts_at FROM fun_plan_options WHERE id = $2 AND plan_id = $1), starts_at),++ revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *`, [plan.id, votes[0].option_id]);const options = (await t.many('SELECT * FROM fun_plan_options WHERE plan_id = $1 ORDER BY position', [plan.id])).map((o) => wireOption(o));const out = wirePlan(updated, { options, votes });if (updated.visibility === 'public') await search.sync(t, 'plan', out, options);@@@ -319,19 +319,20 @@ function planNew({ values = {}, problem = null } = {}) {<p class="fu-lede">Add two or three things to do, send the link to the group, and let the votes choose. The winner can go to your own Today in one press; Fun never writes to anyone else's calendar.</p></header>${problem ? `<p class="fu-note is-bad" role="alert">${esc(problem)}</p>` : ''}<form id="fu-plan-form" class="fu-form fu-card" method="post" action="/plans">++${v.place ? `<p class="fu-note">Planning a visit to ${esc(v.place)}${v.place_url ? ` · <a href="${esc(v.place_url)}" rel="noopener">See the place</a>` : ''}</p><input type="hidden" name="place" value="${esc(v.place)}"><input type="hidden" name="place_url" value="${esc(v.place_url || '')}"><input type="hidden" name="lat" value="${esc(v.lat || '')}"><input type="hidden" name="lon" value="${esc(v.lon || '')}">` : ''}<label class="fu-field"><span>Plan title</span><input name="title" type="text" maxlength="120" value="${esc(v.title || '')}" required placeholder="Friday with the group"></label><label class="fu-field"><span>Why / notes (optional)</span><textarea name="summary" rows="2" maxlength="1200">${esc(v.summary || '')}</textarea></label><label class="fu-field"><span>City</span><input name="city" type="text" maxlength="120" value="${esc(v.city || '')}" placeholder="Portland, Oregon"></label>--<label class="fu-field"><span>Starts at (ISO time)</span><input name="starts_at" type="text" maxlength="40" value="${esc(v.starts_at || '')}" required placeholder="2026-10-30T19:00:00Z"></label>--<label class="fu-field"><span>Voting closes at (ISO time, optional)</span><input name="closes_at" type="text" maxlength="40" value="${esc(v.closes_at || '')}" placeholder="2026-10-29T19:00:00Z"></label>++<label class="fu-field"><span>Starts at (ISO time)</span><input name="starts_at" type="text" maxlength="40" value="${esc(v.starts_at || '')}" required placeholder="YYYY-MM-DDTHH:MM:SSZ"></label>++<label class="fu-field"><span>Voting closes at (ISO time, optional)</span><input name="closes_at" type="text" maxlength="40" value="${esc(v.closes_at || '')}" placeholder="YYYY-MM-DDTHH:MM:SSZ"></label><label class="fu-field"><span>Timezone</span><input name="timezone" type="text" maxlength="80" value="${esc(v.timezone || 'UTC')}" placeholder="America/Los_Angeles"></label><label class="fu-field"><span>Who can see it?</span><select name="visibility"><option value="unlisted"${v.visibility === 'unlisted' ? ' selected' : ''}>Anyone with the link</option><option value="public"${v.visibility === 'public' ? ' selected' : ''}>Public</option><option value="hidden"${v.visibility === 'hidden' ? ' selected' : ''}>Only me</option></select></label><label class="fu-field"><span>Chat channel for votes (optional)</span><input name="chat_username" type="text" maxlength="64" value="${esc(v.chat_username || '')}" placeholder="ada"></label><fieldset class="fu-options"><legend>Options</legend>--${opts.map((o, i) => `<label class="fu-field"><span>Option ${i + 1}${i === 0 ? '' : ' (optional)'}</span><input name="option${i + 1}_title" type="text" maxlength="120" value="${esc(o)}" placeholder="${i === 0 ? 'The mural walk' : ''}"></label>`).join('')}++${opts.map((o, i) => `<label class="fu-field"><span>Option ${i + 1}${i === 0 ? '' : ' (optional)'}</span><input name="option${i + 1}_title" type="text" maxlength="120" value="${esc(o)}" placeholder="${i === 0 ? 'The mural walk' : ''}"></label><label class="fu-field"><span>Option ${i + 1} date and time (optional)</span><input name="option${i + 1}_starts_at" type="datetime-local" value="${esc(v[`option${i + 1}_starts_at`] || '')}"></label>`).join('')}</fieldset><div class="fu-actions"><button class="fu-btn" type="submit">Start the plan</button></div>--<p class="fu-fine">Times are UTC unless the timezone says otherwise. Voting closes one hour before the start when left blank.</p>++<p class="fu-fine">The group can vote on dates as well as activities. The selected option's date becomes the plan date. Enter ISO time for the main start; voting closes one hour before it when left blank.</p></form></div>`;}@@@ -360,7 +361,7 @@ ${problem ? `<p class="fu-note is-bad" role="alert">${esc(problem)}</p>` : ''}<section class="fu-section" id="fu-plan-options" aria-labelledby="fu-plan-options-h"><div class="fu-head-row"><h2 id="fu-plan-options-h">The options</h2><span class="fu-count">${options.length} ${options.length === 1 ? 'option' : 'options'}</span></div>${options.length ? `<ol class="fu-options-list">${options.map((o) => `<li class="fu-option${o.id === (winner && winner.id) ? ' is-winner' : ''}${o.id === myOptionId ? ' is-mine' : ''}" data-option="${esc(o.id)}"><div class="fu-option-body"><h3>${Number(o.position)}. ${esc(o.title)}${o.id === (winner && winner.id) ? ' <span class="fu-winner">chosen</span>' : ''}</h3>--${o.details ? `<p>${esc(o.details)}</p>` : ''}++${o.details ? `<p>${esc(o.details)}</p>` : ''}${o.starts_at ? `<p>${when(o.starts_at)}</p>` : ''}<p class="fu-option-meta">${esc(String(o.votes || 0))} ${Number(o.votes) === 1 ? 'vote' : 'votes'} · ${pct(o.votes, totalVotes)}%${o.accessible ? ' · <span class="fu-accessible">Accessible</span>' : ''}${o.price_note ? ` · ${esc(o.price_note)}` : ''}${o.place_name ? ` · ${esc(o.place_name)}` : ''}</p><div class="fu-vote-bar" role="img" aria-label="${esc(String(o.votes || 0))} votes"><span style="width:${pct(o.votes, totalVotes)}%"></span></div></div>${signedIn && p.status === 'voting' ? `<form method="post" action="/plans/${esc(p.slug)}/vote" class="fu-inline"><input type="hidden" name="option_id" value="${esc(o.id)}"><button class="fu-btn fu-btn-small" type="submit"${o.id === myOptionId ? ' disabled' : ''}>${o.id === myOptionId ? 'Your vote' : 'Vote'}</button></form>` : ''}nnew file mode 100644@@@ -0,0 +1,58 @@++'use strict';++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ try {++ const kim = t.network.addUser('kim');++ const ada = t.network.addUser('ada');++ const place = 'https://openvibe.tours/place/kubota-garden-plc_0123456789ABCDEFGHJKMNPQRS';++ await check('Tours place fills the plan form and signed-out redirect keeps context', async () => {++ const url = `/plans/new?${new URLSearchParams({ place: 'Kubota Garden', city: 'Seattle', url: place })}`;++ const signedOut = await t.get(url);++ assert.strictEqual(signedOut.status, 401);++ assert.match(signedOut.text, /Kubota/);++ const form = await t.get(url, { as: kim });++ assert.match(form.text, /Go to Kubota Garden together/);++ assert.match(form.text, /Option 1 date and time/);++ assert.match(form.text, /See the place/);++ });++ await check('a voted date becomes the plan date', async () => {++ const first = new Date(Date.now() + 3 * 86_400_000).toISOString();++ const second = new Date(Date.now() + 4 * 86_400_000).toISOString();++ const created = await t.get('/api/v1/plans', { as: kim, json: {++ title: 'Go to Kubota Garden together', city: 'Seattle', starts_at: first, timezone: 'UTC',++ options: [{ title: 'Saturday', place_name: 'Kubota Garden', starts_at: first }, { title: 'Sunday', place_name: 'Kubota Garden', starts_at: second }],++ } });++ assert.strictEqual(created.status, 201, created.text);++ const plan = created.json();++ assert.strictEqual((await t.get(`/api/v1/plans/${plan.slug}/vote`, { as: ada, json: { option_id: plan.options[1].id } })).status, 200);++ assert.strictEqual((await t.get(`/api/v1/plans/${plan.slug}/decide`, { as: kim, json: {} })).status, 200);++ const decided = (await t.get(`/api/v1/plans/${plan.slug}`, { as: kim })).json();++ assert.strictEqual(decided.plan.starts_at, second);++ });++ let listPath;++ await check('two people can share a sourced list and add a place', async () => {++ const created = await t.get('/places', { as: kim, form: { title: 'Our places', place_name: 'Kubota Garden', place_url: place } });++ assert.strictEqual(created.status, 303, created.text);++ listPath = created.headers.get('location');++ assert.match(listPath, /^\/places\/lst_/);++ assert.match((await t.get(listPath)).text, /Kubota Garden/);++ const joined = await t.get(`${listPath}/join`, { as: ada, form: {} , method: 'POST' });++ assert.strictEqual(joined.status, 303);++ const after = await t.get(listPath, { as: ada });++ assert.match(after.text, /2 people/);++ const added = await t.get(`${listPath}/items`, { as: ada, form: { place_name: 'Another place', place_url: 'https://openvibe.tours/place/another-place-plc_0123456789ABCDEFGHJKMNPQRT' } });++ assert.strictEqual(added.status, 303);++ assert.match((await t.get(listPath)).text, /Another place/);++ });++ await check('a nonmember, another origin and an unrelated URL cannot add a place', async () => {++ const bo = t.network.addUser('bo');++ assert.strictEqual((await t.get(`${listPath}/items`, { as: bo, form: { place_name: 'No', place_url: place } })).status, 403);++ assert.strictEqual((await t.get(`${listPath}/items`, { as: ada, origin: 'https://elsewhere.example', form: { place_name: 'No', place_url: place } })).status, 403);++ assert.strictEqual((await t.get(`${listPath}/items`, { as: ada, form: { place_name: 'No', place_url: 'https://elsewhere.example/' } })).status, 400);++ });++ } finally { await t.close(); }++ done();++})().catch((err) => { process.stdout.write(String(err.stack || err)); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
+-- phase: expand
+-- An unlisted list is shared through its random link; signed-in members may add sourced places.
+CREATE TABLE fun_place_lists (
+ id text COLLATE "C" PRIMARY KEY,
+ owner_subject text COLLATE "C",
+ title text NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT fun_place_lists_owner CHECK (owner_subject IS NULL OR owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT fun_place_lists_title CHECK (length(title) BETWEEN 3 AND 120)
+);
+CREATE TABLE fun_place_list_members (
+ list_id text COLLATE "C" NOT NULL REFERENCES fun_place_lists(id) ON DELETE CASCADE,
+ subject text COLLATE "C" NOT NULL,
+ joined_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (list_id, subject),
+ CONSTRAINT fun_place_members_subject CHECK (subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$')
+);
+CREATE INDEX fun_place_list_members_subject ON fun_place_list_members(subject);
+CREATE TABLE fun_place_list_items (
+ id text COLLATE "C" PRIMARY KEY,
+ list_id text COLLATE "C" NOT NULL REFERENCES fun_place_lists(id) ON DELETE CASCADE,
+ place_name text NOT NULL,
+ place_url text NOT NULL,
+ added_by text COLLATE "C",
+ created_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (list_id, place_url),
+ CONSTRAINT fun_place_items_name CHECK (length(place_name) BETWEEN 1 AND 200),
+ CONSTRAINT fun_place_items_url CHECK (place_url ~ '^https://openvibe\.tours/place/[a-z0-9-]+-plc_[0-9A-HJKMNP-TV-Z]{26}$')
+);
@@ -53,6 +53,8 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
@@ -53,6 +53,8 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
.fu-field { display: grid; gap: 6px; font-size: .82rem; font-weight: 700; color: var(--text-secondary, #96a7c2); min-width: 0; }
.fu-field { display: grid; gap: 6px; font-size: .82rem; font-weight: 700; color: var(--text-secondary, #96a7c2); min-width: 0; }
.fu-field input, .fu-field select, .fu-field textarea { width: 100%; min-height: 42px; padding: 9px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: var(--text-primary, #e6edf7); font: inherit; font-size: .95rem; font-weight: 500; }
.fu-field input, .fu-field select, .fu-field textarea { width: 100%; min-height: 42px; padding: 9px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: var(--text-primary, #e6edf7); font: inherit; font-size: .95rem; font-weight: 500; }
.fu-field input:focus, .fu-field select:focus, .fu-field textarea:focus { outline: 2px solid var(--room-accent); outline-offset: 1px; border-color: transparent; }
.fu-field input:focus, .fu-field select:focus, .fu-field textarea:focus { outline: 2px solid var(--room-accent); outline-offset: 1px; border-color: transparent; }
+fieldset.fu-options { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; min-width: 0; margin: 0; padding: 14px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; }
+fieldset.fu-options legend { padding: 0 6px; font-weight: 700; }
.fu-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin: 20px 0 0; }
.fu-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin: 20px 0 0; }
.fu-facts > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); min-width: 0; }
.fu-facts > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); min-width: 0; }
.fu-facts dt { font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
.fu-facts dt { font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
@@ -223,6 +223,7 @@ async function createApp(opts = {}) {
@@ -223,6 +223,7 @@ async function createApp(opts = {}) {
agentLayer.mount(agent, { descriptor: require('./agent-descriptor'), render: require('./agent-pages').createAgentPages(ctx).renderPublicPage });
agentLayer.mount(agent, { descriptor: require('./agent-descriptor'), render: require('./agent-pages').createAgentPages(ctx).renderPublicPage });
app.use(agent);
app.use(agent);
}
}
+ app.use(require('./places/lists').createPlaceListRoutes(ctx));
app.use(createPageRoutes(ctx));
app.use(createPageRoutes(ctx));
app.use((req, res) => send(res, 404, {
app.use((req, res) => send(res, 404, {
viewer: req.viewer, config, path: req.originalUrl, title: 'Not found',
viewer: req.viewer, config, path: req.originalUrl, title: 'Not found',
@@ -282,21 +282,29 @@ function createPageRoutes(ctx) {
@@ -282,21 +282,29 @@ function createPageRoutes(ctx) {
});
});
r.get('/plans/new', (req, res) => {
r.get('/plans/new', (req, res) => {
- if (!signedIn(req)) return page(req, res, { title: 'Start a plan', body: raw(views.signIn({ title: 'Start a group plan', text: 'A plan belongs to your OpenVibe account; the group can vote with the link.', next: '/plans/new' })) }, 401);
- return page(req, res, { index: false, title: 'Start a group plan', body: raw(views.planNew({ values: { timezone: 'UTC' } })) });
+ if (!signedIn(req)) return page(req, res, { title: 'Start a plan', body: raw(views.signIn({ title: 'Start a group plan', text: 'A plan belongs to your OpenVibe account; the group can vote with the link.', next: req.originalUrl })) }, 401);
+ const place = typeof req.query.place === 'string' ? req.query.place.slice(0, 120) : '';
+ const city = typeof req.query.city === 'string' ? req.query.city.slice(0, 120) : '';
+ const placeUrl = typeof req.query.url === 'string' && /^https:\/\/openvibe\.tours\/place\/[a-z0-9-]+-plc_[0-9A-HJKMNP-TV-Z]{26}$/.test(req.query.url) ? req.query.url : '';
+ return page(req, res, { index: false, title: 'Start a group plan', body: raw(views.planNew({ values: {
+ timezone: 'UTC', place, city, place_url: placeUrl,
+ title: place ? `Go to ${place} together` : '', option1_title: place ? `Visit ${place}` : '',
+ lat: typeof req.query.lat === 'string' ? req.query.lat : '', lon: typeof req.query.lon === 'string' ? req.query.lon : '',
+ } })) });
});
});
r.post('/plans', guard, async (req, res) => {
r.post('/plans', guard, async (req, res) => {
const b = req.body || {};
const b = req.body || {};
- const options = [1, 2, 3].map((i) => String(b[`option${i}_title`] || '').trim()).filter(Boolean).map((title) => ({ title }));
+ const options = [1, 2, 3].map((i) => ({ title: String(b[`option${i}_title`] || '').trim(), starts_at: b[`option${i}_starts_at`] || null,
+ place_name: b.place || null, lat: b.lat || null, lon: b.lon || null })).filter((o) => o.title);
const out = await store.createPlan({
const out = await store.createPlan({
ownerSubject: req.viewer.subject, ownerUsername: req.viewer.username,
ownerSubject: req.viewer.subject, ownerUsername: req.viewer.username,
- title: b.title, summary: b.summary, city: b.city, timezone: b.timezone, startsAt: b.starts_at, closesAt: b.closes_at,
+ title: b.title, summary: b.summary, city: b.city, lat: b.lat, lon: b.lon, timezone: b.timezone, startsAt: b.starts_at, closesAt: b.closes_at,
visibility: b.visibility, chatUsername: b.chat_username, options,
visibility: b.visibility, chatUsername: b.chat_username, options,
});
});
if (!out.ok) {
if (!out.ok) {
- const values = { ...formValues(b, ['title', 'summary', 'city', 'starts_at', 'closes_at', 'timezone', 'visibility', 'chat_username']) };
- for (const i of [1, 2, 3]) values[`option${i}_title`] = b[`option${i}_title`] || '';
+ const values = { ...formValues(b, ['title', 'summary', 'city', 'starts_at', 'closes_at', 'timezone', 'visibility', 'chat_username', 'place', 'place_url', 'lat', 'lon']) };
+ for (const i of [1, 2, 3]) { values[`option${i}_title`] = b[`option${i}_title`] || ''; values[`option${i}_starts_at`] = b[`option${i}_starts_at`] || ''; }
return page(req, res, { title: 'Start a group plan', body: raw(views.planNew({ values, problem: out.message })) }, out.status || 400);
return page(req, res, { title: 'Start a group plan', body: raw(views.planNew({ values, problem: out.message })) }, out.status || 400);
}
}
return res.redirect(303, `/plans/${encodeURIComponent(out.plan.slug)}`);
return res.redirect(303, `/plans/${encodeURIComponent(out.plan.slug)}`);
@@ -24,6 +24,9 @@ const TABLES = [
@@ -24,6 +24,9 @@ const TABLES = [
{ table: 'fun_plans', subject: 'owner_subject', file: 'plans.json', erase: { anonymize: { owner_username: null } } },
{ table: 'fun_plans', subject: 'owner_subject', file: 'plans.json', erase: { anonymize: { owner_username: null } } },
{ table: 'fun_plan_options', subject: 'created_by', file: 'plan-options.json', erase: { anonymize: {} } },
{ table: 'fun_plan_options', subject: 'created_by', file: 'plan-options.json', erase: { anonymize: {} } },
{ table: 'fun_plan_votes', subject: 'subject', file: 'plan-votes.json', erase: 'delete' },
{ table: 'fun_plan_votes', subject: 'subject', file: 'plan-votes.json', erase: 'delete' },
+ { table: 'fun_place_lists', subject: 'owner_subject', file: 'place-lists.json', erase: 'delete' },
+ { table: 'fun_place_list_members', subject: 'subject', file: 'place-list-memberships.json', erase: 'delete' },
+ { table: 'fun_place_list_items', subject: 'added_by', file: 'place-list-items.json', erase: 'delete' },
{ table: 'fun_reports', subject: 'subject', file: 'reports.json', erase: 'delete' },
{ table: 'fun_reports', subject: 'subject', file: 'reports.json', erase: 'delete' },
];
];
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+
+const express = require('express');
+const { asyncRouter } = require('../http/router');
+const { sameOrigin } = require('../http/principal');
+const { esc, raw } = require('../render/html');
+const { send } = require('../render/layout');
+
+const validPlace = (url) => /^https:\/\/openvibe\.tours\/place\/[a-z0-9-]+-plc_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(url || ''));
+
+function createPlaceListRoutes({ config, s }) {
+ const r = asyncRouter();
+ r.use(express.urlencoded({ extended: false, limit: '16kb' }));
+ const person = (req) => req.viewer?.kind === 'user' ? req.viewer.subject : null;
+ const page = (req, res, title, body, status = 200) => send(res, status, {
+ viewer: req.viewer, config, path: req.originalUrl, index: false, cache: null, title, body: raw(body),
+ });
+ const signIn = (req, res) => page(req, res, 'Our places', `<div class="fu-wrap fu-page-pad fu-narrow"><h1>Our places</h1><p>Sign in to make or join a shared list.</p><a class="fu-btn" href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in</a></div>`, 401);
+ const guarded = (req, res, next) => {
+ if (!person(req)) return signIn(req, res);
+ if (!sameOrigin(req, config.baseUrl)) return page(req, res, 'Not from this site', '<div class="fu-wrap"><h1>Use the form on OpenVibe.Fun</h1></div>', 403);
+ next();
+ };
+
+ r.get('/places/new', (req, res) => {
+ if (!person(req)) return signIn(req, res);
+ const name = String(req.query.place || '').slice(0, 200);
+ const url = validPlace(req.query.url) ? String(req.query.url) : '';
+ return page(req, res, 'Our places', `<div class="fu-wrap fu-narrow" id="fu-places-new"><header class="fu-page-h"><p class="fu-kicker">Together</p><h1>Our places</h1><p class="fu-lede">Keep a list with the people you want to go with. Send its private link; each person joins with their OpenVibe account.</p></header>
+<form class="fu-form fu-card" method="post" action="/places"><label class="fu-field"><span>List name</span><input name="title" maxlength="120" required value="Our places"></label>
+${url ? `<input type="hidden" name="place_url" value="${esc(url)}"><input type="hidden" name="place_name" value="${esc(name)}"><p>Start with <a href="${esc(url)}" rel="noopener">${esc(name)}</a></p>` : ''}
+<button class="fu-btn" type="submit">Create our list</button></form></div>`);
+ });
+
+ r.post('/places', guarded, async (req, res) => {
+ const title = String(req.body.title || '').trim().slice(0, 120);
+ if (title.length < 3) return page(req, res, 'Our places', '<div class="fu-wrap"><h1>Give your list a name</h1></div>', 400);
+ const id = s.newId('lst');
+ const subject = person(req);
+ await s.tx(async (t) => {
+ await t.exec('INSERT INTO fun_place_lists(id, owner_subject, title) VALUES ($1,$2,$3)', [id, subject, title]);
+ await t.exec('INSERT INTO fun_place_list_members(list_id, subject) VALUES ($1,$2)', [id, subject]);
+ if (validPlace(req.body.place_url) && String(req.body.place_name || '').trim()) {
+ await t.exec('INSERT INTO fun_place_list_items(id, list_id, place_name, place_url, added_by) VALUES ($1,$2,$3,$4,$5)',
+ [s.newId('lpi'), id, String(req.body.place_name).trim().slice(0, 200), req.body.place_url, subject]);
+ }
+ });
+ return res.redirect(303, `/places/${id}`);
+ });
+
+ r.get('/places/:id', async (req, res) => {
+ const id = String(req.params.id || '');
+ const list = await s.db.maybe('SELECT id, title FROM fun_place_lists WHERE id=$1', [id]);
+ if (!list) return page(req, res, 'List not found', '<div class="fu-wrap"><h1>List not found</h1></div>', 404);
+ const items = await s.db.many('SELECT place_name, place_url FROM fun_place_list_items WHERE list_id=$1 ORDER BY created_at, id', [id]);
+ const members = await s.db.value('SELECT COUNT(*)::int FROM fun_place_list_members WHERE list_id=$1', [id]);
+ const joined = person(req) && await s.db.maybe('SELECT 1 FROM fun_place_list_members WHERE list_id=$1 AND subject=$2', [id, person(req)]);
+ return page(req, res, list.title, `<div class="fu-wrap fu-narrow" id="fu-places-list"><header class="fu-page-h"><p class="fu-kicker">Together · ${Number(members)} ${Number(members) === 1 ? 'person' : 'people'}</p><h1>${esc(list.title)}</h1><p class="fu-lede">Share this page with the people you want to go with. Signed-in members can add places.</p></header>
+${items.length ? `<ol class="fu-places">${items.map((item) => `<li><a href="${esc(item.place_url)}" rel="noopener">${esc(item.place_name)}</a></li>`).join('')}</ol>` : '<p>No place saved yet.</p>'}
+${joined ? `<form class="fu-form fu-card" method="post" action="/places/${esc(id)}/items"><label class="fu-field"><span>Place name</span><input name="place_name" maxlength="200" required></label><label class="fu-field"><span>OpenVibe.Tours place link</span><input name="place_url" type="url" required placeholder="https://openvibe.tours/place/..."></label><button class="fu-btn" type="submit">Add place</button></form>` : person(req) ? `<form method="post" action="/places/${esc(id)}/join"><button class="fu-btn" type="submit">Join this list</button></form>` : `<a class="fu-btn" href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in to join</a>`}
+<p><a href="https://openvibe.tours/cities">Find more places</a></p></div>`);
+ });
+
+ r.post('/places/:id/join', guarded, async (req, res) => {
+ const id = String(req.params.id || '');
+ const list = await s.db.maybe('SELECT id FROM fun_place_lists WHERE id=$1', [id]);
+ if (!list) return page(req, res, 'List not found', '<div class="fu-wrap"><h1>List not found</h1></div>', 404);
+ await s.db.exec('INSERT INTO fun_place_list_members(list_id, subject) VALUES ($1,$2) ON CONFLICT DO NOTHING', [id, person(req)]);
+ return res.redirect(303, `/places/${id}`);
+ });
+
+ r.post('/places/:id/items', guarded, async (req, res) => {
+ const id = String(req.params.id || '');
+ const member = await s.db.maybe('SELECT 1 FROM fun_place_list_members WHERE list_id=$1 AND subject=$2', [id, person(req)]);
+ if (!member) return page(req, res, 'Join first', '<div class="fu-wrap"><h1>Join this list first</h1></div>', 403);
+ const name = String(req.body.place_name || '').trim().slice(0, 200);
+ const url = String(req.body.place_url || '');
+ if (!name || !validPlace(url)) return page(req, res, 'Choose a Tours place', '<div class="fu-wrap"><h1>Use an OpenVibe.Tours place link</h1></div>', 400);
+ await s.db.exec(`INSERT INTO fun_place_list_items(id, list_id, place_name, place_url, added_by)
+ VALUES ($1,$2,$3,$4,$5) ON CONFLICT(list_id, place_url) DO NOTHING`, [s.newId('lpi'), id, name, url, person(req)]);
+ return res.redirect(303, `/places/${id}`);
+ });
+ return r;
+}
+
+module.exports = { createPlaceListRoutes, validPlace };
@@ -593,7 +593,9 @@ function createStore(s, { search = { sync: async () => null }, config = {}, shar
@@ -593,7 +593,9 @@ function createStore(s, { search = { sync: async () => null }, config = {}, shar
if (!rows.length) return refusal(409, 'fun.plan_no_votes', 'Nobody has voted yet.');
if (!rows.length) return refusal(409, 'fun.plan_no_votes', 'Nobody has voted yet.');
const votes = rows.map((r) => ({ option_id: r.option_id, votes: Number(r.votes) || 0 }));
const votes = rows.map((r) => ({ option_id: r.option_id, votes: Number(r.votes) || 0 }));
if (votes.length > 1 && votes[0].votes === votes[1].votes) return refusal(409, 'fun.plan_tie', 'The vote is tied; add a tie-break or ask one more person to vote.');
if (votes.length > 1 && votes[0].votes === votes[1].votes) return refusal(409, 'fun.plan_tie', 'The vote is tied; add a tie-break or ask one more person to vote.');
- const updated = await t.maybe("UPDATE fun_plans SET status = 'decided', winner_option_id = $2, revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *", [plan.id, votes[0].option_id]);
+ const updated = await t.maybe(`UPDATE fun_plans SET status = 'decided', winner_option_id = $2,
+ starts_at = COALESCE((SELECT starts_at FROM fun_plan_options WHERE id = $2 AND plan_id = $1), starts_at),
+ revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *`, [plan.id, votes[0].option_id]);
const options = (await t.many('SELECT * FROM fun_plan_options WHERE plan_id = $1 ORDER BY position', [plan.id])).map((o) => wireOption(o));
const options = (await t.many('SELECT * FROM fun_plan_options WHERE plan_id = $1 ORDER BY position', [plan.id])).map((o) => wireOption(o));
const out = wirePlan(updated, { options, votes });
const out = wirePlan(updated, { options, votes });
if (updated.visibility === 'public') await search.sync(t, 'plan', out, options);
if (updated.visibility === 'public') await search.sync(t, 'plan', out, options);
@@ -319,19 +319,20 @@ function planNew({ values = {}, problem = null } = {}) {
@@ -319,19 +319,20 @@ function planNew({ values = {}, problem = null } = {}) {
<p class="fu-lede">Add two or three things to do, send the link to the group, and let the votes choose. The winner can go to your own Today in one press; Fun never writes to anyone else's calendar.</p></header>
<p class="fu-lede">Add two or three things to do, send the link to the group, and let the votes choose. The winner can go to your own Today in one press; Fun never writes to anyone else's calendar.</p></header>
${problem ? `<p class="fu-note is-bad" role="alert">${esc(problem)}</p>` : ''}
${problem ? `<p class="fu-note is-bad" role="alert">${esc(problem)}</p>` : ''}
<form id="fu-plan-form" class="fu-form fu-card" method="post" action="/plans">
<form id="fu-plan-form" class="fu-form fu-card" method="post" action="/plans">
+${v.place ? `<p class="fu-note">Planning a visit to ${esc(v.place)}${v.place_url ? ` · <a href="${esc(v.place_url)}" rel="noopener">See the place</a>` : ''}</p><input type="hidden" name="place" value="${esc(v.place)}"><input type="hidden" name="place_url" value="${esc(v.place_url || '')}"><input type="hidden" name="lat" value="${esc(v.lat || '')}"><input type="hidden" name="lon" value="${esc(v.lon || '')}">` : ''}
<label class="fu-field"><span>Plan title</span><input name="title" type="text" maxlength="120" value="${esc(v.title || '')}" required placeholder="Friday with the group"></label>
<label class="fu-field"><span>Plan title</span><input name="title" type="text" maxlength="120" value="${esc(v.title || '')}" required placeholder="Friday with the group"></label>
<label class="fu-field"><span>Why / notes (optional)</span><textarea name="summary" rows="2" maxlength="1200">${esc(v.summary || '')}</textarea></label>
<label class="fu-field"><span>Why / notes (optional)</span><textarea name="summary" rows="2" maxlength="1200">${esc(v.summary || '')}</textarea></label>
<label class="fu-field"><span>City</span><input name="city" type="text" maxlength="120" value="${esc(v.city || '')}" placeholder="Portland, Oregon"></label>
<label class="fu-field"><span>City</span><input name="city" type="text" maxlength="120" value="${esc(v.city || '')}" placeholder="Portland, Oregon"></label>
-<label class="fu-field"><span>Starts at (ISO time)</span><input name="starts_at" type="text" maxlength="40" value="${esc(v.starts_at || '')}" required placeholder="2026-10-30T19:00:00Z"></label>
-<label class="fu-field"><span>Voting closes at (ISO time, optional)</span><input name="closes_at" type="text" maxlength="40" value="${esc(v.closes_at || '')}" placeholder="2026-10-29T19:00:00Z"></label>
+<label class="fu-field"><span>Starts at (ISO time)</span><input name="starts_at" type="text" maxlength="40" value="${esc(v.starts_at || '')}" required placeholder="YYYY-MM-DDTHH:MM:SSZ"></label>
+<label class="fu-field"><span>Voting closes at (ISO time, optional)</span><input name="closes_at" type="text" maxlength="40" value="${esc(v.closes_at || '')}" placeholder="YYYY-MM-DDTHH:MM:SSZ"></label>
<label class="fu-field"><span>Timezone</span><input name="timezone" type="text" maxlength="80" value="${esc(v.timezone || 'UTC')}" placeholder="America/Los_Angeles"></label>
<label class="fu-field"><span>Timezone</span><input name="timezone" type="text" maxlength="80" value="${esc(v.timezone || 'UTC')}" placeholder="America/Los_Angeles"></label>
<label class="fu-field"><span>Who can see it?</span><select name="visibility"><option value="unlisted"${v.visibility === 'unlisted' ? ' selected' : ''}>Anyone with the link</option><option value="public"${v.visibility === 'public' ? ' selected' : ''}>Public</option><option value="hidden"${v.visibility === 'hidden' ? ' selected' : ''}>Only me</option></select></label>
<label class="fu-field"><span>Who can see it?</span><select name="visibility"><option value="unlisted"${v.visibility === 'unlisted' ? ' selected' : ''}>Anyone with the link</option><option value="public"${v.visibility === 'public' ? ' selected' : ''}>Public</option><option value="hidden"${v.visibility === 'hidden' ? ' selected' : ''}>Only me</option></select></label>
<label class="fu-field"><span>Chat channel for votes (optional)</span><input name="chat_username" type="text" maxlength="64" value="${esc(v.chat_username || '')}" placeholder="ada"></label>
<label class="fu-field"><span>Chat channel for votes (optional)</span><input name="chat_username" type="text" maxlength="64" value="${esc(v.chat_username || '')}" placeholder="ada"></label>
<fieldset class="fu-options"><legend>Options</legend>
<fieldset class="fu-options"><legend>Options</legend>
-${opts.map((o, i) => `<label class="fu-field"><span>Option ${i + 1}${i === 0 ? '' : ' (optional)'}</span><input name="option${i + 1}_title" type="text" maxlength="120" value="${esc(o)}" placeholder="${i === 0 ? 'The mural walk' : ''}"></label>`).join('')}
+${opts.map((o, i) => `<label class="fu-field"><span>Option ${i + 1}${i === 0 ? '' : ' (optional)'}</span><input name="option${i + 1}_title" type="text" maxlength="120" value="${esc(o)}" placeholder="${i === 0 ? 'The mural walk' : ''}"></label><label class="fu-field"><span>Option ${i + 1} date and time (optional)</span><input name="option${i + 1}_starts_at" type="datetime-local" value="${esc(v[`option${i + 1}_starts_at`] || '')}"></label>`).join('')}
</fieldset>
</fieldset>
<div class="fu-actions"><button class="fu-btn" type="submit">Start the plan</button></div>
<div class="fu-actions"><button class="fu-btn" type="submit">Start the plan</button></div>
-<p class="fu-fine">Times are UTC unless the timezone says otherwise. Voting closes one hour before the start when left blank.</p>
+<p class="fu-fine">The group can vote on dates as well as activities. The selected option's date becomes the plan date. Enter ISO time for the main start; voting closes one hour before it when left blank.</p>
</form></div>`;
</form></div>`;
}
}
@@ -360,7 +361,7 @@ ${problem ? `<p class="fu-note is-bad" role="alert">${esc(problem)}</p>` : ''}
@@ -360,7 +361,7 @@ ${problem ? `<p class="fu-note is-bad" role="alert">${esc(problem)}</p>` : ''}
<section class="fu-section" id="fu-plan-options" aria-labelledby="fu-plan-options-h"><div class="fu-head-row"><h2 id="fu-plan-options-h">The options</h2><span class="fu-count">${options.length} ${options.length === 1 ? 'option' : 'options'}</span></div>
<section class="fu-section" id="fu-plan-options" aria-labelledby="fu-plan-options-h"><div class="fu-head-row"><h2 id="fu-plan-options-h">The options</h2><span class="fu-count">${options.length} ${options.length === 1 ? 'option' : 'options'}</span></div>
${options.length ? `<ol class="fu-options-list">${options.map((o) => `<li class="fu-option${o.id === (winner && winner.id) ? ' is-winner' : ''}${o.id === myOptionId ? ' is-mine' : ''}" data-option="${esc(o.id)}">
${options.length ? `<ol class="fu-options-list">${options.map((o) => `<li class="fu-option${o.id === (winner && winner.id) ? ' is-winner' : ''}${o.id === myOptionId ? ' is-mine' : ''}" data-option="${esc(o.id)}">
<div class="fu-option-body"><h3>${Number(o.position)}. ${esc(o.title)}${o.id === (winner && winner.id) ? ' <span class="fu-winner">chosen</span>' : ''}</h3>
<div class="fu-option-body"><h3>${Number(o.position)}. ${esc(o.title)}${o.id === (winner && winner.id) ? ' <span class="fu-winner">chosen</span>' : ''}</h3>
-${o.details ? `<p>${esc(o.details)}</p>` : ''}
+${o.details ? `<p>${esc(o.details)}</p>` : ''}${o.starts_at ? `<p>${when(o.starts_at)}</p>` : ''}
<p class="fu-option-meta">${esc(String(o.votes || 0))} ${Number(o.votes) === 1 ? 'vote' : 'votes'} · ${pct(o.votes, totalVotes)}%${o.accessible ? ' · <span class="fu-accessible">Accessible</span>' : ''}${o.price_note ? ` · ${esc(o.price_note)}` : ''}${o.place_name ? ` · ${esc(o.place_name)}` : ''}</p>
<p class="fu-option-meta">${esc(String(o.votes || 0))} ${Number(o.votes) === 1 ? 'vote' : 'votes'} · ${pct(o.votes, totalVotes)}%${o.accessible ? ' · <span class="fu-accessible">Accessible</span>' : ''}${o.price_note ? ` · ${esc(o.price_note)}` : ''}${o.place_name ? ` · ${esc(o.place_name)}` : ''}</p>
<div class="fu-vote-bar" role="img" aria-label="${esc(String(o.votes || 0))} votes"><span style="width:${pct(o.votes, totalVotes)}%"></span></div></div>
<div class="fu-vote-bar" role="img" aria-label="${esc(String(o.votes || 0))} votes"><span style="width:${pct(o.votes, totalVotes)}%"></span></div></div>
${signedIn && p.status === 'voting' ? `<form method="post" action="/plans/${esc(p.slug)}/vote" class="fu-inline"><input type="hidden" name="option_id" value="${esc(o.id)}"><button class="fu-btn fu-btn-small" type="submit"${o.id === myOptionId ? ' disabled' : ''}>${o.id === myOptionId ? 'Your vote' : 'Vote'}</button></form>` : ''}
${signedIn && p.status === 'voting' ? `<form method="post" action="/plans/${esc(p.slug)}/vote" class="fu-inline"><input type="hidden" name="option_id" value="${esc(o.id)}"><button class="fu-btn fu-btn-small" type="submit"${o.id === myOptionId ? ' disabled' : ''}>${o.id === myOptionId ? 'Your vote' : 'Vote'}</button></form>` : ''}
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+'use strict';
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ try {
+ const kim = t.network.addUser('kim');
+ const ada = t.network.addUser('ada');
+ const place = 'https://openvibe.tours/place/kubota-garden-plc_0123456789ABCDEFGHJKMNPQRS';
+ await check('Tours place fills the plan form and signed-out redirect keeps context', async () => {
+ const url = `/plans/new?${new URLSearchParams({ place: 'Kubota Garden', city: 'Seattle', url: place })}`;
+ const signedOut = await t.get(url);
+ assert.strictEqual(signedOut.status, 401);
+ assert.match(signedOut.text, /Kubota/);
+ const form = await t.get(url, { as: kim });
+ assert.match(form.text, /Go to Kubota Garden together/);
+ assert.match(form.text, /Option 1 date and time/);
+ assert.match(form.text, /See the place/);
+ });
+ await check('a voted date becomes the plan date', async () => {
+ const first = new Date(Date.now() + 3 * 86_400_000).toISOString();
+ const second = new Date(Date.now() + 4 * 86_400_000).toISOString();
+ const created = await t.get('/api/v1/plans', { as: kim, json: {
+ title: 'Go to Kubota Garden together', city: 'Seattle', starts_at: first, timezone: 'UTC',
+ options: [{ title: 'Saturday', place_name: 'Kubota Garden', starts_at: first }, { title: 'Sunday', place_name: 'Kubota Garden', starts_at: second }],
+ } });
+ assert.strictEqual(created.status, 201, created.text);
+ const plan = created.json();
+ assert.strictEqual((await t.get(`/api/v1/plans/${plan.slug}/vote`, { as: ada, json: { option_id: plan.options[1].id } })).status, 200);
+ assert.strictEqual((await t.get(`/api/v1/plans/${plan.slug}/decide`, { as: kim, json: {} })).status, 200);
+ const decided = (await t.get(`/api/v1/plans/${plan.slug}`, { as: kim })).json();
+ assert.strictEqual(decided.plan.starts_at, second);
+ });
+ let listPath;
+ await check('two people can share a sourced list and add a place', async () => {
+ const created = await t.get('/places', { as: kim, form: { title: 'Our places', place_name: 'Kubota Garden', place_url: place } });
+ assert.strictEqual(created.status, 303, created.text);
+ listPath = created.headers.get('location');
+ assert.match(listPath, /^\/places\/lst_/);
+ assert.match((await t.get(listPath)).text, /Kubota Garden/);
+ const joined = await t.get(`${listPath}/join`, { as: ada, form: {} , method: 'POST' });
+ assert.strictEqual(joined.status, 303);
+ const after = await t.get(listPath, { as: ada });
+ assert.match(after.text, /2 people/);
+ const added = await t.get(`${listPath}/items`, { as: ada, form: { place_name: 'Another place', place_url: 'https://openvibe.tours/place/another-place-plc_0123456789ABCDEFGHJKMNPQRT' } });
+ assert.strictEqual(added.status, 303);
+ assert.match((await t.get(listPath)).text, /Another place/);
+ });
+ await check('a nonmember, another origin and an unrelated URL cannot add a place', async () => {
+ const bo = t.network.addUser('bo');
+ assert.strictEqual((await t.get(`${listPath}/items`, { as: bo, form: { place_name: 'No', place_url: place } })).status, 403);
+ assert.strictEqual((await t.get(`${listPath}/items`, { as: ada, origin: 'https://elsewhere.example', form: { place_name: 'No', place_url: place } })).status, 403);
+ assert.strictEqual((await t.get(`${listPath}/items`, { as: ada, form: { place_name: 'No', place_url: 'https://elsewhere.example/' } })).status, 400);
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((err) => { process.stdout.write(String(err.stack || err)); process.exit(1); });