Public: anyone can read this; the crew can change it.

Propose, read and withdraw: a proposal with options replaces the note #12

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

A proposal — a title, a paragraph and two to eight options — is now what this app is about at every door, and the template's note is gone.

shipped production is serving it deploy 2adbe7d

opened this

A proposal — a title, a paragraph and two to eight options — is now what this app is about at every door, and the template's note is gone.

Two appended migrations do the schema: proposal (title 1–120, body 1–2000, rule default majority, state checked against open/decided/withdrawn, nullable decided_option_id, author, timestamps) with option (label 1–80, position, cascading from its proposal) and an index on option(proposal_id); then DROP TABLE note. Migration 1 is untouched, and src/db.test.ts builds a database that stopped at the note and migrates it forward, so production's existing file is covered.

src/actions.ts is listProposals, getProposal, propose, withdrawProposal and an exported canWithdraw. propose refuses anon, a long title, a long paragraph, fewer than two or more than eight options, blank labels, over-long labels and duplicates (compared case-insensitively) — each invalid with a sentence naming what to change — trims every field and writes the proposal with its options in one transaction, so a refused proposal writes nothing. withdrawProposal is the author or an admin and only while open, so a second withdrawal is invalid rather than silently fine. Both emit, and both pages carry data-live="proposal.opened proposal.withdrawn".

The doors follow and decide nothing: src/pages/layout.ts + home.ts + proposal.ts (/ lists open proposals with the composer, /p/:id shows the paragraph, the options in order and a Withdraw form for whoever may; a guest sees both and no forms, every value through escape), the form and JSON routes in src/routes.ts (anything that is not a proposal is a 404 in the layout; statuses follow Failure through answer), list_proposals/get_proposal/propose/withdraw_proposal over MCP with limits in their schemas, and a terminal where j/k moves, Enter opens, n asks for a title then the paragraph then options one per line until a blank line, w withdraws and Esc cancels.

Two things I chose not to do. Only majority is an accepted rule — the other rules arrive with the rules module (#3), and until then an unknown rule is invalid rather than silently stored. And the composer's blank lines are dropped by the route when it splits the textarea into a list, which is wire shaping, not a rule: the action still refuses a blank label, which is what the API and MCP see. back() gained a location so a form can return to /p/:id; src/server.ts's same-origin refusal and rate limit are exactly as they were. Worth a look in the preview: propose something, watch it appear on / in another tab without a reload, then withdraw it and see it leave the list but stay readable at its own address. bun test is green (26 tests), no dependencies added.

🤖 Generated with Claude Code

Closes #2

commented

probe

Approved

probe

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs