diff --git a/docs/PRODUCT-ROADMAP.md b/docs/PRODUCT-ROADMAP.md
index 0af4620..e0a1948 100644
--- a/docs/PRODUCT-ROADMAP.md
+++ b/docs/PRODUCT-ROADMAP.md
@@ -46,6 +46,12 @@ instead (see subsystem F).
| H | Guided tour & example wedding | — | ✅ **built** — [spec](superpowers/specs/2026-09-07-guided-tour-design.md), [plan](superpowers/plans/2026-09-07-guided-tour.md) complete 2026-09-07 |
| I | Retention sweep for account weddings | B | ⬜ **not started** — the 24-month sweep covers synced weddings only; the privacy page deliberately does not claim it for accounts |
| G | Multi-tenant suite mechanics | A, B | ✅ **built** — [spec](superpowers/specs/2026-09-02-multitenant-mechanics-design.md), [plan](superpowers/plans/2026-09-07-multitenant-mechanics.md) complete 2026-09-07 |
+| J | Planner role and many weddings per account | A, G | ⬜ planned — [master plan](superpowers/specs/2026-09-28-expansion-master-plan.md), phases 1 and 3 |
+| K | Setup flow and signing in safely | A, B | ⬜ planned — master plan, phase 1 |
+| L | Design language and shared kit | — | ⬜ planned — master plan, phase 0 |
+| M | Windows around the tools: Overview, Guests, Money, Checklist, Sync & history, palette | E, L | ⬜ planned — master plan, phase 2 |
+| N | Day-of binder and vendor links | E, J | ⬜ planned — master plan, phase 3 |
+| O | One live document: tools stop keeping copies; real-time sync | — | ⬜ planned — master plan, phase 4 |
## Decisions log
@@ -118,6 +124,40 @@ against, not a discussion to reopen without a reason.
- **2026-09-02** — Accounts-to-weddings: one active wedding per account for
v1, no switcher UI. Additive-safe — can extend to multiple weddings per
account later without redesigning the model.
+- **2026-09-28** — A **planner role**, alongside the two partners. An account
+ may be a partner in one wedding and a planner in any number; a wedding has
+ at most one planner in v1. This is the "extend later" the entry above left
+ room for. Detail in the
+ [master plan](superpowers/specs/2026-09-28-expansion-master-plan.md).
+- **2026-09-28** — Sides are named after the partners, not "bride" and
+ "groom".
+- **2026-09-28** — Phones get a read-only day-of binder; the editing tools
+ stay desktop.
+- **2026-09-28** — RSVPs stay with Joy and similar services; Trousseau imports
+ the result through one importer that never unseats or silently deletes.
+- **2026-09-28** — The guest link moves onto the account with no passphrase,
+ and stays current by itself once published. `lib/sync` goes when it does.
+- **2026-09-28** — Privacy promise restated: data may leave the device; nobody
+ reads a couple's plans or their guests' names, and guest data never goes to
+ a third party.
+- **2026-09-28** — Every tool converges on the live document (no private
+ copies, one undo). Seating goes last and becomes TypeScript as part of it.
+ Supersedes the incremental-only Tableaux migration for its store layer.
+- **2026-09-28** — Signing in never replaces a wedding silently. A device
+ stores which account wedding it belongs to and what the two last agreed;
+ two different weddings with work in both are asked about (whole weddings,
+ not per part), and the one not chosen is kept as a copy on the device. A
+ wedding is created at sign-in, except on the way to an invite. Replaces the
+ offline write queue.
+- **2026-09-28** — Roles: a wedding has up to two partners and one planner; an
+ account is a partner in one wedding and a planner in any number. The couple
+ sees who has access and can remove their planner. Each device holds one
+ wedding at a time and switching is a swap through `/open`, never a merge.
+- **2026-09-28** — The guest link moved onto the account and keeps itself
+ current; the passphrase sync is deleted. The link's key is stored with the
+ wedding, so it is as readable to the server's operator as the wedding is —
+ stated in the Privacy Policy, which no longer describes the passphrase
+ system.
## Subsystem H — Guided tour & example wedding
diff --git a/docs/superpowers/specs/2026-09-28-expansion-master-plan.md b/docs/superpowers/specs/2026-09-28-expansion-master-plan.md
new file mode 100644
index 0000000..0d672ca
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-28-expansion-master-plan.md
@@ -0,0 +1,556 @@
+# Trousseau — the expansion: planners, setup, and the windows around the tools
+
+Date: 2026-09-28
+Status: direction approved by the maintainer (answers recorded below). Each
+phase gets its own dated implementation plan before it is built.
+Scope: the whole suite — the setup flow, accounts, a planner role, new views,
+and the architecture those need underneath them.
+
+## Why
+
+An audit of the suite on 2026-09-28 found that the five tools are individually
+strong and that the weak places are all *between* them: where a shared fact is
+edited, where a device meets an account, where a new couple starts. The setup
+flow runs straight through every one of those seams.
+
+The audit's findings are listed below with how each was established, because
+several turned out differently once tested than they looked when read.
+
+- **Reproduced** — shown failing in a test or a real browser.
+- **Traced** — every caller read; not run.
+- **Seen** — visible in a screenshot of a production build.
+
+### Setup and accounts
+
+| # | Finding | How established |
+|---|---|---|
+| S1 | The Data panel's edits (guest import, names, date) were written back over by whichever of Seating or Timeline was open, on that tool's next save. | Reproduced — unit, and Playwright against a production build. **Fixed 2026-09-28**, see below. |
+| S2 | A failed local save sets the store's `error`, but both components that read it render it only for a failed *read*. Nothing shows a failed save. | Traced |
+| S3 | Creating a wedding or accepting an invite does not start cloud sync until a full reload: `startCloudSync` has one caller, on mount. | Traced. **Fixed 2026-09-28**: both now end in a full load, where sync starts. |
+| S4 | Accepting an invite silently replaces the invitee's local wedding on their next load. | Reproduced — unit, with storage that survives a reload. **Fixed 2026-09-28**, see *Signing in safely*. |
+| S5 | A magic link opened in the wrong browser sends an invitee to `/account`, whose main button is **Create your wedding** — which then blocks the invite for good (`already-in-a-wedding`). | Reproduced — component tests: `/login` dropped `next`, and the invite's "Sign in" linked to bare `/login`. **Fixed 2026-09-28**. |
+| S6 | Loading the example wedding while signed in pushes it over the shared wedding; the confirmation says it replaces "the wedding in this browser". The emptiness check counts only guests and blocks. | Reproduced — unit (a wedding of group shots only was replaced unasked). **Fixed 2026-09-28**. |
+| S12 | Found while reproducing S4: an edit made while the account answered "unavailable" (a 500, say) was lost at the next start, which replaced the device's document with the account's; an edit made offline went up on reconnect at version 0 and conflicted over every slice. The agreed baseline lived only in memory. | Reproduced — unit. **Fixed 2026-09-28**: the baseline is stored. |
+| S7 | Names, date and venue have three editors (Data panel, Timeline's Day panel, Seating's write-back). Guest import has two implementations with different rules. | Traced, seen |
+| S8 | The guest link needs a second credential — an unrecoverable passphrase — even for a signed-in couple, and goes stale silently when seats change. | Traced, seen |
+| S9 | "Take a tour" runs the six-step front-page chapter and stops; the other 23 steps are reachable only one tool at a time. | Traced. **Fixed 2026-09-28**: the tour walks every chapter. |
+| S11 | Found while merging the importers: the Data panel's importer stored diets as the file's words ("Vegetarian", "None") where Seating reads a key ("vegetarian"). Seating's Vegetarian filter found none of the example wedding's thirteen vegetarians, and its breakdown listed "None" as a diet. | Reproduced — Playwright. **Fixed 2026-09-28** with the one importer. |
+| S10 | The example wedding has 0 of 100 guests seated, no crew, no jobs, no shots and no card design. The promise it exists to demonstrate cannot be shown from it. | Reproduced (fixture counted), seen. **Fixed 2026-09-28**, see Phase 1.5. |
+| S13 | Found with the fuller example: a guest who declined was still a guest to seat and feed. Place cards printed them a card; What is left, Seating's header, its Overview and the Unassigned chip counted them unseated; Seating's dietary tally counted their meal; the front page's Seated figure counted them in the total. Seating's own exports already left them out, so the screen disagreed with the printout. | Reproduced — unit, and seen (9 unseated, 97 / 106, and 3 left to do, on one wedding). **Fixed 2026-09-28**: one `isComing`, used everywhere. |
+| S14 | Delegation called a job with no block — a task, as the 2026-09-08 design made them — an orphan: a clash that held the print run, drawn in red. | Reproduced — unit. **Fixed 2026-09-28**. |
+| S15 | What is left said the card design had "nowhere to show" 83 dietary requirements that the design drew as icons: it counted text tokens and not the column an icon is drawn from, which Plaque itself counts. | Reproduced — unit. **Fixed 2026-09-28**. |
+| S16 | Since sides were named after the partners, the place cards' Side column carried the stored "a" and "b": a card binding `{{Side}}` printed a letter. | Reproduced — unit. **Fixed 2026-09-28**: "Alex’s side". |
+| S17 | Seating's "worth checking" note counted a guest who answered "None" as having no dietary note. Six of its seven notes on the example were those guests. | Reproduced — unit. **Fixed 2026-09-28**. |
+| S18 | The Privacy Policy says "no analytics … and no third-party scripts", with Sentry the only third party; but on a Vercel deployment the root layout renders Vercel Web Analytics, added by Vercel's bot on 2026-09-08. The policy is false there. | Traced (`app/layout.tsx`, commit e63d4be). **Open — the maintainer's call**: remove the analytics, or say in the policy what it counts. Found with it, and fixed: the command palette put a guest's name in the address (`/guests?q=…`); it now goes by id. |
+| S19 | In Place cards, undoing any edit — even adding a rectangle — silently dropped the card's row scope (per-table menus went back to one card per guest) and every per-row tweak: its undo history kept only the elements and the background. | Reproduced against the old store. **Fixed** by Place cards moving onto the wedding's one history, which puts back the whole slice; the reproduction is now a test. |
+| S20 | In Seating, a zone, a pillar, a room shape or a calibration line could not be started on the floor of a room — only off it. The canvas gave every press on a room to the room before any tool ran, and the room took it as a click or a pan. | Reproduced with a browser test (no zone drawn inside the room; a pillar only off the floor). **Fixed**: the tools act wherever they are pressed, and a press on a room is the room's only for the select tool; the tests now draw a zone and put a pillar down on the floor. |
+| S21 | In Seating, a pair of guests could be given a second seating rule — the same one again, or its opposite ("apart" and "together") — which only ever added a warning that could not be cleared. Recorded in the ux audit (#G21); the unused earlier port had refused it. | Reproduced as a test against Seating's commands. **Fixed**: one rule to a pair, and none about someone and themselves; the rules window says why Add is off. |
+
+### Architecture
+
+- **Six state containers, five undo systems.** The shared store, four tool
+ stores each seeded once on mount, and Group shots reading live.
+- **Every partner change remounts the open tool.** A pulled change swaps the
+ document, which resets the tool's undo history and selection even when the
+ partner touched an unrelated slice. Traced.
+- **Undo differs per page.** Group shots uses the shared history, which also
+ holds the Data panel's import — so Undo there can take back an import made
+ elsewhere. Traced.
+- **Conflicts are per whole slice and shown blind.** Two partners editing
+ different guests conflict on "guests"; the choice has no diff; the state is
+ visible only inside the Data dialog. The server keeps version history
+ (`wedding_document_history`) with no UI. Traced.
+- **Three `Button`s, four modal patterns, two `window.confirm`s, three CSV
+ parsers**, and the legacy passphrase sync (`lib/sync`) kept alive only for the
+ guest link.
+
+### UX
+
+The front page has five competing starts and no primary action; the Data
+dialog does four unrelated jobs with the most important one last; at the
+declared minimum width Seating's Overview covers the canvas; three sidebar
+idioms; empty states that claim success ("Every job has somebody" with no
+jobs); "Bride's side / Groom's side" hard-coded in seven places; every tool
+walled off below 1024px. The tour overlay declares `aria-modal` and never moves
+focus. `/seat` — the one page guests use — is not in the axe run.
+
+## Decisions
+
+The maintainer's answers, 2026-09-28. Where the answer delegated the choice,
+the choice made is recorded with its reason.
+
+| # | Question | Decision |
+|---|---|---|
+| 1 | Who uses it | The couple (two partners) **plus a wedding planner role**. Planners are a market in their own right: one account, many client weddings, and a library of their own designs to reuse. |
+| 2 | Real usage today | None yet. Migrations may be bold; clarity beats compatibility. |
+| 3 | Phones | A phone-first, read-only **day-of binder**. The editing tools stay desktop. |
+| 4 | S1 | Fix now. Done — see *S1, fixed*. |
+| 5 | Bride/groom | Replace with **sides named after the partners**. |
+| 6 | Look | Keep it. |
+| 7 | Window types | Delegated. See *Design language*. |
+| 8 | Theme | Light only. |
+| 9 | Device vs account weddings | Delegated: see *Signing in safely*. Nothing is replaced without a restorable copy. |
+| 10 | Create the wedding automatically | Delegated: **yes**, on first sign-in — except when arriving through an invite. Removes S5's trap. |
+| 11 | One importer | Delegated: **the suite's `lib/data/guestImport.ts`** is the engine; one dialog with a preview. Adds and updates, never unseats, never deletes silently — people missing from the new file are listed and can be removed there, explicitly. RSVPs arrive this way from Joy and similar, so their exports become golden fixtures. |
+| 12 | Guest link | Delegated: **on the account, no passphrase, and live** — once published it republishes itself as seats change, with a visible "updated" time and a take-down. A stale seat link sends a guest to the wrong table, which is worse than no link. |
+| 13 | Priorities | RSVP stays with Joy and friends (imported). Build money, checklist, vendor portal, day-of binder and real-time sync. |
+| 14 | Privacy | Data may leave the device. The promise is **nobody is reading your plans or your friends' names**: no admin view, no content analytics, guest data never sent to a third party. Venue-level data (an address to find sunset times) may be, and only when the feature is used. |
+| 15 | Tableaux | Delegated: **converge every tool on the live document** (see Phase 4). Seating goes last and becomes TypeScript as part of it. |
+
+## S1, fixed
+
+A tool reads the wedding once, into a store of its own, and writes that copy
+back on every save. The generation guard in `lib/store/toolGeneration.ts`
+already stopped a stale copy being written after a *whole* document was
+swapped. It did not cover one slice being written from outside — which is
+exactly what the Data panel does, over whichever tool is on screen.
+
+Now each tool's gate (`WhenDocumentReady`) declares what the tool copies
+(`HOLDS`), each tool tags its own writes (`by`), and a write to a held slice from
+anywhere else starts a new generation. The tool remounts onto the current
+document and its stale save is refused. One rule in the store covers every
+writer: the Data panel, the post-load reconcile, and the legacy sync client.
+
+Verified by unit tests on the rule, bridge-level tests for Seating and
+Timeline, and an end-to-end test (`e2e/persistence.spec.ts`) that fails with
+the rule disabled and passes with it.
+
+**Cost:** a Data-panel edit remounts the open tool, which resets its undo
+history. Phase 4 removes the copies and with them the remount.
+
+**Found on the way, and not caused by it:** under heavy parallel load the
+existing e2e test "a Seating edit survives an immediate reload" fails now and
+then. An edit made less than 400ms before a reload is still inside Seating's
+autosave delay, so it depends on a save issued as the page unloads — and the
+page is sometimes gone before IndexedDB commits it. Measured on untouched
+`main` at 2 in 80 runs and with this fix at 5 in 56, which is not a
+significant difference; an instrumented failing run showed the new rule never
+fired. Phase 4 removes the cause: an edit reaches the shared store, and so
+IndexedDB, when it is made.
+
+## Design language
+
+The look stays. What is decided here is its grammar, so that every new window
+is one of a small number of known kinds.
+
+**Paper on a desk.** Parchment is the page, stone is a panel, white is reserved
+for what prints or stands for a card. **One display voice**: Marcellus for the
+wedding's name and a page's single heading, Lato everywhere else, figures
+tabular. **One accent per area**, and semantic colour — ok, warn, danger —
+never changes hue between areas. **Quiet until it needs you**: the one loud
+thing on a screen is the thing that needs a decision.
+
+### Kinds of window
+
+| Kind | Use it when | Examples | Rules |
+|---|---|---|---|
+| **Page** | Somewhere you work for minutes, or want to link to | The tools, Guests, Money, Checklist, Binder, a planner's Weddings, Setup | Its own route and heading. Reached from the header or the front page. |
+| **Slide-over** | Look at or act on something without losing your place | Sync & history, Guest link, one guest from anywhere | Right edge, one width, the page behind inert, Escape closes, addressable by `?panel=`. |
+| **Dialog** | A decision, or a short flow that must finish or be cancelled | Confirmations, import, delete, "two weddings" | Native `
- You haven’t created a wedding yet — this is where you and your partner will share one.
+ You’re not on a wedding yet. If your partner invited you, open the link in
+ their email instead — one of the couple is on one wedding at a time, so starting
+ your own now means you cannot join theirs.
-
- ) : (
-
-
Invite your partner
-
-
- )}
-
- {state.weddingId && (
-
-
Your data
- Download everything saved to your account as one file — guests, seating, the day,
- the crew and the stationery. It opens in Trousseau anywhere, including your own
- copy if you ever run one.
+ Planning weddings for clients? Your weddings
+ You are on {state.weddings.length === 1 ? "one wedding" : `${state.weddings.length} weddings`}.{" "}
+
+ {open === null ? "Open one" : "See them all"}
+
+
+ ) : null}
+ {open !== null ? (
+ <>
+
+
+
Your data
+
+ Download the wedding open here as one file — guests, seating, the day, the
+ crew and the stationery. It opens in Trousseau anywhere, including your own
+ copy if you ever run one.
+
+ Keep the wedding on this device, or remove it? On a computer that isn’t
+ yours, remove it — it stays on your account either way.
+
+
+ void signOut(false)} icon={LogOut}>
+ Sign out and keep it here
+
+ void signOut(true)} icon={Trash2} tone="danger">
+ Sign out and remove it
+
+ setSigningOut(false)}>Cancel
+
+
+ ) : (
+ setSigningOut(true)} icon={LogOut}>
+ Sign out
+
+ )}
void deleteAccount()} tone="danger" icon={Trash2}>
Delete my account
diff --git a/suite/app/(app)/checklist/page.tsx b/suite/app/(app)/checklist/page.tsx
new file mode 100644
index 0000000..0c95784
--- /dev/null
+++ b/suite/app/(app)/checklist/page.tsx
@@ -0,0 +1,11 @@
+import type { Metadata } from "next";
+import { ChecklistPage } from "@/components/checklist/ChecklistPage";
+
+export const metadata: Metadata = {
+ title: "Checklist",
+ description: "What to have done before the day, each with a date to be done by.",
+};
+
+export default function Checklist() {
+ return ;
+}
diff --git a/suite/app/(app)/guests/page.tsx b/suite/app/(app)/guests/page.tsx
new file mode 100644
index 0000000..f1625be
--- /dev/null
+++ b/suite/app/(app)/guests/page.tsx
@@ -0,0 +1,17 @@
+import type { Metadata } from "next";
+import { Suspense } from "react";
+import { GuestsPage } from "@/components/guests/GuestsPage";
+
+export const metadata: Metadata = {
+ title: "Guests",
+ description: "Everyone on the list: replies, sides, food and tables, changed one at a time or many at once.",
+};
+
+export default function Guests() {
+ // The page reads `?select=` from the address, which waits for the browser.
+ return (
+
+
+
+ );
+}
diff --git a/suite/app/(app)/invite/[token]/page.test.tsx b/suite/app/(app)/invite/[token]/page.test.tsx
new file mode 100644
index 0000000..2e6982d
--- /dev/null
+++ b/suite/app/(app)/invite/[token]/page.test.tsx
@@ -0,0 +1,52 @@
+import { act, cleanup, render, screen } from "@testing-library/react";
+import { Suspense } from "react";
+import { afterEach, beforeEach, expect, test, vi } from "vitest";
+
+let user: { id: string } | null = null;
+vi.mock("@/lib/accounts/browserClient", () => ({
+ browserClient: () => ({ auth: { getUser: async () => ({ data: { user } }) } }),
+}));
+
+const { default: InvitePage } = await import("./page");
+
+const assign = vi.fn();
+beforeEach(() => {
+ user = null;
+ assign.mockClear();
+ vi.stubGlobal("location", { ...window.location, search: "", assign });
+});
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+async function open(token: string) {
+ const params = Promise.resolve({ token });
+ await act(async () => {
+ render(
+
+
+ ,
+ );
+ });
+}
+
+test("signing in from an invite comes back to the invite", async () => {
+ await open("abc123");
+ const link = await screen.findByRole("link", { name: "Sign in" });
+ expect(link.getAttribute("href")).toBe(`/login?next=${encodeURIComponent("/invite/abc123")}`);
+});
+
+test("a link opened in another browser says so, rather than just asking to sign in", async () => {
+ vi.stubGlobal("location", { ...window.location, search: "?signin=failed", assign });
+ await open("abc123");
+ expect(await screen.findByText(/did not sign you in/i)).toBeTruthy();
+});
+
+test("accepting opens the wedding afresh, so it starts syncing at once", async () => {
+ user = { id: "u1" };
+ vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ weddingId: "w1" }), { status: 200 })));
+ await open("abc123");
+ await vi.waitFor(() => expect(assign).toHaveBeenCalledWith("/open/w1"));
+});
diff --git a/suite/app/(app)/invite/[token]/page.tsx b/suite/app/(app)/invite/[token]/page.tsx
index 54d6f3e..4116475 100644
--- a/suite/app/(app)/invite/[token]/page.tsx
+++ b/suite/app/(app)/invite/[token]/page.tsx
@@ -3,6 +3,7 @@
import { use, useEffect, useState } from "react";
import Link from "next/link";
import { browserClient } from "@/lib/accounts/browserClient";
+import { SignInFailed } from "@/components/shell/SignInFailed";
export default function InvitePage({ params }: { params: Promise<{ token: string }> }) {
const { token } = use(params);
@@ -30,9 +31,13 @@ export default function InvitePage({ params }: { params: Promise<{ token: string
// An unhandled server error comes back as HTML, and parsing that would
// reject inside this callback — leaving the page stuck on "One moment…"
// with nothing on screen to explain why.
- const body = (await response.json().catch(() => null)) as { error?: string } | null;
- if (response.ok) {
+ const body = (await response.json().catch(() => null)) as { error?: string; weddingId?: string } | null;
+ if (response.ok && body?.weddingId) {
setStatus("done");
+ // Through `/open`, a full load: this device opens the wedding just
+ // joined, sync starts with it, and a wedding already on this device
+ // is asked about rather than replaced.
+ window.location.assign(`/open/${body.weddingId}`);
} else {
setStatus("error");
setMessage(body?.error ?? "That invite could not be accepted.");
@@ -56,9 +61,10 @@ export default function InvitePage({ params }: { params: Promise<{ token: string
{status === "signed-out" && (
-
Sign in with the email this invite was sent to, then come back to this link.
+
+
Sign in with the email this invite was sent to. The link we send brings you back here.
)}
{status === "error" && (
diff --git a/suite/app/(app)/layout.tsx b/suite/app/(app)/layout.tsx
index 0b17b26..82eddeb 100644
--- a/suite/app/(app)/layout.tsx
+++ b/suite/app/(app)/layout.tsx
@@ -1,8 +1,12 @@
import { Footer } from "@/components/shell/Footer";
import { Header } from "@/components/shell/Header";
import { StoreHydrator } from "@/lib/store/StoreHydrator";
+import { LiveWedding } from "@/components/shell/LiveWedding";
+import { GuestLinkKeeper } from "@/components/shell/GuestLinkKeeper";
+import { SupplierLinkKeeper } from "@/components/shell/SupplierLinkKeeper";
import { TourProvider } from "@/lib/tour/useTour";
import { TourOverlay } from "@/components/tour/TourOverlay";
+import { ConfirmProvider } from "@/components/ui/Confirm";
/**
* The planning application: the header, the tools, and the local document.
@@ -18,24 +22,30 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
// to fill what is left of the viewport, so the two can never disagree.
- {/* Above the route content, so a chapter that walks from Seating to
- Timeline keeps its place across the navigation. */}
-
- {/* Before the header, so it is the first thing Tab reaches. */}
-
- Skip to content
-
-
- {/* The page's one main landmark. The tools and pages inside render into
- it rather than each bringing their own, which put two mains, or
- none, on a page. */}
- {children}
-
-
-
+
+
+
+ {/* One confirmation dialog for the whole app — see `components/ui/Confirm`. */}
+
+ {/* Above the route content, so a chapter that walks from Seating to
+ Timeline keeps its place across the navigation. */}
+
+ {/* Before the header, so it is the first thing Tab reaches. */}
+
+ Skip to content
+
+
+ {/* The page's one main landmark. The tools and pages inside render into
+ it rather than each bringing their own, which put two mains, or
+ none, on a page. */}
+ {children}
+
+
+
+
);
}
diff --git a/suite/app/(app)/library/page.tsx b/suite/app/(app)/library/page.tsx
new file mode 100644
index 0000000..de255b3
--- /dev/null
+++ b/suite/app/(app)/library/page.tsx
@@ -0,0 +1,11 @@
+import type { Metadata } from "next";
+import { LibraryPage } from "@/components/library/LibraryPage";
+
+export const metadata: Metadata = {
+ title: "Library",
+ description: "Card designs, running orders, rooms and checklists kept to use again, for any wedding you plan.",
+};
+
+export default function Library() {
+ return ;
+}
diff --git a/suite/app/(app)/login/page.test.tsx b/suite/app/(app)/login/page.test.tsx
new file mode 100644
index 0000000..f2efce3
--- /dev/null
+++ b/suite/app/(app)/login/page.test.tsx
@@ -0,0 +1,29 @@
+import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, expect, test, vi } from "vitest";
+
+const signInWithOtp = vi.fn(async (_: unknown) => ({ error: null }));
+vi.mock("@/lib/accounts/browserClient", () => ({
+ browserClient: () => ({ auth: { signInWithOtp } }),
+}));
+
+const { default: LoginPage } = await import("./page");
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+test("the sign-in link returns to where the person was going", async () => {
+ vi.stubGlobal("location", { ...window.location, origin: "https://app.example", search: "?next=%2Finvite%2Fabc123" });
+ await act(async () => {
+ render();
+ });
+ fireEvent.change(screen.getByLabelText("Email"), { target: { value: "sam@example.com" } });
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button", { name: /sign-in link/i }));
+ });
+ expect(signInWithOtp).toHaveBeenCalledWith({
+ email: "sam@example.com",
+ options: { emailRedirectTo: `https://app.example/auth/callback?next=${encodeURIComponent("/invite/abc123")}` },
+ });
+});
diff --git a/suite/app/(app)/login/page.tsx b/suite/app/(app)/login/page.tsx
index f32eef1..2a1504f 100644
--- a/suite/app/(app)/login/page.tsx
+++ b/suite/app/(app)/login/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useEffect, useState } from "react";
import { Mail } from "lucide-react";
import { browserClient } from "@/lib/accounts/browserClient";
import { Button, TextField } from "@/components/ui/controls";
@@ -12,6 +12,12 @@ export default function LoginPage() {
const [busy, setBusy] = useState(false);
const client = browserClient();
+ // Where the person was going — an invite, usually. Carried through the
+ // link, or it lands on /account, which for an invitee is the wrong door.
+ const [next, setNext] = useState(null);
+ useEffect(() => {
+ setNext(new URLSearchParams(window.location.search).get("next"));
+ }, []);
async function sendLink() {
setError(null);
@@ -23,9 +29,10 @@ export default function LoginPage() {
// The link has to come back through `/auth/callback`, which exchanges its
// code for a real session; without a redirect target there is nowhere for
// that exchange to happen and signing in never takes effect.
+ const callback = `${window.location.origin}/auth/callback${next ? `?next=${encodeURIComponent(next)}` : ""}`;
const { error: sendError } = await client.auth.signInWithOtp({
email,
- options: { emailRedirectTo: `${window.location.origin}/auth/callback` },
+ options: { emailRedirectTo: callback },
});
setBusy(false);
if (sendError) {
@@ -56,7 +63,13 @@ export default function LoginPage() {
}}
className="mt-6 space-y-4"
>
-
We’ll email you a link — no password to remember.
+
+ {next?.startsWith("/invite/")
+ ? "Use the address your invite was sent to. We’ll email you a link that brings you back to it."
+ : next === "/weddings"
+ ? "Sign in to see your clients’ weddings. We’ll email you a link — no password to remember."
+ : "We’ll email you a link — no password to remember."}
+
void sendLink()} tone="primary" icon={Mail} disabled={busy || !email}>
{busy ? "Sending…" : "Send me a sign-in link"}
diff --git a/suite/app/(app)/money/page.tsx b/suite/app/(app)/money/page.tsx
new file mode 100644
index 0000000..c6dcd22
--- /dev/null
+++ b/suite/app/(app)/money/page.tsx
@@ -0,0 +1,11 @@
+import type { Metadata } from "next";
+import { MoneyPage } from "@/components/money/MoneyPage";
+
+export const metadata: Metadata = {
+ title: "Money",
+ description: "What the suppliers cost, what has been paid, and what is still to pay.",
+};
+
+export default function Money() {
+ return ;
+}
diff --git a/suite/app/(app)/page.tsx b/suite/app/(app)/page.tsx
index 5d11520..3b921e1 100644
--- a/suite/app/(app)/page.tsx
+++ b/suite/app/(app)/page.tsx
@@ -1,8 +1,8 @@
import type { Metadata } from "next";
-import { QuickStats } from "@/components/shell/QuickStats";
-import { WhatIsLeft } from "@/components/shell/WhatIsLeft";
+import { Overview } from "@/components/shell/Overview";
import { Countdown } from "@/components/shell/Countdown";
import { WeddingPack } from "@/components/shell/WeddingPack";
+import { SetupPrompt } from "@/components/shell/SetupPrompt";
export const metadata: Metadata = {
// `absolute` so the root template does not append the suffix to the name it
@@ -27,15 +27,9 @@ export default function Home() {
return (
+
-
-
-
-
-
-
What is left
-
-
+
diff --git a/suite/app/(app)/setup/page.tsx b/suite/app/(app)/setup/page.tsx
new file mode 100644
index 0000000..7c4f38d
--- /dev/null
+++ b/suite/app/(app)/setup/page.tsx
@@ -0,0 +1,294 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import Link from "next/link";
+import { ArrowRight, ClipboardPaste, FileUp } from "lucide-react";
+import { browserClient } from "@/lib/accounts/browserClient";
+import { eventChange } from "@/lib/model/useSuite";
+import { readGuests } from "@/lib/model/slices";
+import type { Guest } from "@/lib/model/types";
+import { startingRoom, SEATS, tablesFor, withPasted, type StartingTable } from "@/lib/setup/draft";
+import { useTrousseauStore } from "@/lib/store/useTrousseauStore";
+import { Button, TextField } from "@/components/ui/controls";
+import { useGuestImport, type ImportTarget } from "@/components/shell/guestImportPanel";
+import { WeddingPeople } from "@/components/shell/WeddingPeople";
+
+const STEPS = [
+ { id: "you", title: "The two of you" },
+ { id: "guests", title: "Your guests" },
+ { id: "room", title: "The room" },
+ { id: "together", title: "Planning together" },
+] as const;
+type Step = (typeof STEPS)[number]["id"];
+
+interface Draft {
+ partners: [string, string];
+ date: string;
+ venue: string;
+ guests: Record;
+ seating: Record;
+}
+
+/**
+ * Setting the wedding up, in the order a couple would: who, then who is
+ * coming, then where they sit, then who else plans it.
+ *
+ * Nothing is written until the room step: the first three are a draft,
+ * committed as one change — one undo step, one push. The last step can leave
+ * the page to sign in, so it comes after the commit, never before.
+ */
+export default function SetupPage() {
+ const status = useTrousseauStore((s) => s.status);
+ if (status !== "ready") return ;
+ return ;
+}
+
+function Setup() {
+ // From the wedding as it is: running setup again adds to it, and never
+ // throws away what is there.
+ const [draft, setDraft] = useState(() => {
+ const { doc, raw } = useTrousseauStore.getState();
+ const seating = raw["seating"];
+ return {
+ partners: doc.event.partners,
+ date: doc.event.date,
+ venue: doc.event.venueName,
+ guests: readGuests(doc),
+ seating: typeof seating === "object" && seating !== null ? (seating as Record) : {},
+ };
+ });
+ const [step, setStep] = useState("you");
+ const at = STEPS.findIndex((s) => s.id === step);
+ const next = () => setStep(STEPS[at + 1]!.id);
+
+ return (
+
+ {count > 0
+ ? `${count} ${count === 1 ? "guest" : "guests"} so far.`
+ : "A list from wherever the replies arrive — Joy, Zola, a spreadsheet — or just names."}
+
+ To plan it with your partner, or your planner, sign in: the wedding is kept on your
+ account and on each of your devices. Nobody else reads it.
+
+
+ Sign in to share it
+
+
+ ) : weddingId ? (
+
+ {notice ? (
+
+ {notice.text}
+
+ ) : null}
+ setNotice({ text, tone })} />
+
+ ) : null}
+ {done}
+
+ );
+}
diff --git a/suite/app/(app)/weddings/page.tsx b/suite/app/(app)/weddings/page.tsx
new file mode 100644
index 0000000..886353f
--- /dev/null
+++ b/suite/app/(app)/weddings/page.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { Plus } from "lucide-react";
+import { browserClient } from "@/lib/accounts/browserClient";
+import { useTrousseauStore } from "@/lib/store/useTrousseauStore";
+import type { WeddingListing } from "@/lib/accounts/handlers";
+import { Button } from "@/components/ui/controls";
+import { todayIso } from "@/lib/dates";
+import { WeddingList } from "@/components/weddings/WeddingList";
+
+
+/**
+ * Every wedding the account is on — for a planner, one per client.
+ *
+ * Also the planners' door: signing in with this as the destination starts no
+ * wedding of their own (see `/auth/callback`'s `startsAWedding`).
+ */
+export default function WeddingsPage() {
+ const client = browserClient();
+ const open = useTrousseauStore((s) => s.weddingId);
+ const [weddings, setWeddings] = useState(null);
+ const [problem, setProblem] = useState(null);
+
+ useEffect(() => {
+ if (!client) return;
+ void client.auth.getUser().then(async ({ data }) => {
+ if (!data.user) {
+ setWeddings("signed-out");
+ return;
+ }
+ // Our own date, for what has fallen due.
+ const response = await fetch(`/api/accounts/weddings?today=${todayIso()}`);
+ const body = (await response.json().catch(() => null)) as { weddings?: WeddingListing[]; error?: string } | null;
+ if (response.ok && body?.weddings) setWeddings(body.weddings);
+ else setProblem(body?.error ?? "Your weddings could not be loaded.");
+ });
+ }, [client]);
+
+ async function startClientWedding() {
+ const response = await fetch("/api/accounts/weddings", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ role: "planner" }),
+ });
+ const body = (await response.json().catch(() => null)) as { weddingId?: string; error?: string } | null;
+ if (!response.ok || !body?.weddingId) {
+ setProblem(body?.error ?? "The wedding could not be started.");
+ return;
+ }
+ window.location.assign(`/open/${body.weddingId}`);
+ }
+
+ return (
+
+
Trousseau
+
Your weddings
+
+ {!client ? (
+
Accounts are not set up on this deployment.
+ ) : weddings === "signed-out" ? (
+
+
Planning weddings for clients? Sign in to see them all in one place.
+
+ Sign in
+
+
+ ) : weddings === null ? (
+
{problem ?? "Loading…"}
+ ) : (
+
+ {problem ? (
+
+ {problem}
+
+ ) : null}
+ {weddings.length === 0 ? (
+
None yet. Start one for a client, and invite the couple to it.
+ ) : (
+
+ )}
+
+ void startClientWedding()}>
+ Start a client’s wedding
+
+
+ Your library of designs to use again
+
+
+
+ )}
+
+ );
+}
diff --git a/suite/app/api/accounts/delete/route.ts b/suite/app/api/accounts/delete/route.ts
index ef5a467..c593d89 100644
--- a/suite/app/api/accounts/delete/route.ts
+++ b/suite/app/api/accounts/delete/route.ts
@@ -4,7 +4,7 @@ import { env, accountsConfigured } from "@/lib/env";
import { deleteAccountHandler } from "@/lib/accounts/handlers";
import { accountsStore } from "@/lib/accounts/supabaseStore";
import { currentUser, serverClient } from "@/lib/accounts/serverClient";
-import { allow, CREATE_LIMIT } from "@/lib/sync/rateLimit";
+import { allow, CREATE_LIMIT } from "@/lib/server/rateLimit";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
diff --git a/suite/app/api/accounts/invite/[token]/route.ts b/suite/app/api/accounts/invite/[token]/route.ts
index 1a22cb8..f630461 100644
--- a/suite/app/api/accounts/invite/[token]/route.ts
+++ b/suite/app/api/accounts/invite/[token]/route.ts
@@ -4,7 +4,7 @@ import { acceptInviteHandler } from "@/lib/accounts/handlers";
import { accountsStore } from "@/lib/accounts/supabaseStore";
import { currentUser, serverClient } from "@/lib/accounts/serverClient";
import { check, tokenSchema } from "@/lib/accounts/schemas";
-import { allow, AUTH_LIMIT } from "@/lib/sync/rateLimit";
+import { allow, AUTH_LIMIT } from "@/lib/server/rateLimit";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
diff --git a/suite/app/api/accounts/invite/route.test.ts b/suite/app/api/accounts/invite/route.test.ts
index abe731e..f24769f 100644
--- a/suite/app/api/accounts/invite/route.test.ts
+++ b/suite/app/api/accounts/invite/route.test.ts
@@ -14,12 +14,13 @@ vi.mock("@/lib/accounts/supabaseStore", () => ({ accountsStore: () => store }));
const { POST } = await import("./route");
+let weddingId = "";
const post = () =>
POST(
new Request("http://localhost/api/accounts/invite", {
method: "POST",
headers: { "content-type": "application/json" },
- body: JSON.stringify({ email: "partner@example.com" }),
+ body: JSON.stringify({ weddingId, email: "partner@example.com", role: "partner" }),
}),
);
@@ -27,7 +28,7 @@ const post = () =>
// the in-memory store and the limiter's window map are shared across this file.
beforeEach(async () => {
currentUserResult = { id: `user-${Math.random()}`, email: "a@example.com" };
- await store.createWedding(currentUserResult.id);
+ weddingId = (await store.createWedding(currentUserResult.id, "partner")).id;
});
test("invites past the limit are throttled, per account", async () => {
diff --git a/suite/app/api/accounts/invite/route.ts b/suite/app/api/accounts/invite/route.ts
index bde4463..47f2abf 100644
--- a/suite/app/api/accounts/invite/route.ts
+++ b/suite/app/api/accounts/invite/route.ts
@@ -3,8 +3,8 @@ import { accountsConfigured } from "@/lib/env";
import { createInviteHandler } from "@/lib/accounts/handlers";
import { accountsStore } from "@/lib/accounts/supabaseStore";
import { currentUser, serverClient } from "@/lib/accounts/serverClient";
-import { check, inviteEmailSchema } from "@/lib/accounts/schemas";
-import { allow, INVITE_LIMIT } from "@/lib/sync/rateLimit";
+import { check, inviteSchema } from "@/lib/accounts/schemas";
+import { allow, INVITE_LIMIT } from "@/lib/server/rateLimit";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -36,19 +36,14 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "That was not JSON." }, { status: 400 });
}
- const input = check(inviteEmailSchema, body);
+ const input = check(inviteSchema, body);
if (!input.ok) return NextResponse.json({ error: input.error }, { status: 400 });
const client = await serverClient();
if (!client) return unconfigured();
- const store = accountsStore(client);
- const membership = await store.memberOf(user.id);
- if (!membership) {
- return NextResponse.json({ error: "You don't have a wedding yet." }, { status: 404 });
- }
-
- const reply = await createInviteHandler(store, membership.weddingId, user.id, input.value.email);
+ const { weddingId, email, role } = input.value;
+ const reply = await createInviteHandler(accountsStore(client), weddingId, user.id, email, role);
if (reply.status !== 200) return NextResponse.json(reply.body, { status: reply.status });
const { token } = reply.body as { token: string };
@@ -59,7 +54,7 @@ export async function POST(request: Request) {
const origin = new URL(request.url).origin;
const next = encodeURIComponent(`/invite/${token}`);
const { error: sendError } = await client.auth.signInWithOtp({
- email: input.value.email,
+ email,
options: { emailRedirectTo: `${origin}/auth/callback?next=${next}` },
});
if (sendError) {
diff --git a/suite/app/api/accounts/members/route.ts b/suite/app/api/accounts/members/route.ts
new file mode 100644
index 0000000..e2cc0a1
--- /dev/null
+++ b/suite/app/api/accounts/members/route.ts
@@ -0,0 +1,55 @@
+import { NextResponse } from "next/server";
+import { accountsConfigured } from "@/lib/env";
+import { peopleHandler, removeMemberHandler } from "@/lib/accounts/handlers";
+import { accountsStore } from "@/lib/accounts/supabaseStore";
+import { currentUser, serverClient } from "@/lib/accounts/serverClient";
+import { check, removeMemberSchema } from "@/lib/accounts/schemas";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+const unconfigured = () =>
+ NextResponse.json({ error: "Accounts are not set up on this deployment." }, { status: 501 });
+const unauthenticated = () => NextResponse.json({ error: "Sign in first." }, { status: 401 });
+/** See `../weddings/route.ts`: an uncaught throw becomes an HTML 500 the UI can't parse. */
+const failed = (where: string, error: unknown) => {
+ console.error(`[accounts] ${where}`, error);
+ return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 });
+};
+
+/** Who has access to `?wedding=`: the couple and their planner, with addresses. */
+export async function GET(request: Request) {
+ try {
+ if (!accountsConfigured()) return unconfigured();
+ const user = await currentUser();
+ if (!user) return unauthenticated();
+ const client = await serverClient();
+ if (!client) return unconfigured();
+
+ const weddingId = new URL(request.url).searchParams.get("wedding") ?? "";
+ const reply = await peopleHandler(accountsStore(client), weddingId, user.id);
+ return NextResponse.json(reply.body, { status: reply.status });
+ } catch (error) {
+ return failed("GET /api/accounts/members", error);
+ }
+}
+
+/** Leave a wedding, or — one of the couple — remove its planner. */
+export async function DELETE(request: Request) {
+ try {
+ if (!accountsConfigured()) return unconfigured();
+ const user = await currentUser();
+ if (!user) return unauthenticated();
+
+ const input = check(removeMemberSchema, await request.json().catch(() => null));
+ if (!input.ok) return NextResponse.json({ error: input.error }, { status: 400 });
+
+ const client = await serverClient();
+ if (!client) return unconfigured();
+
+ const reply = await removeMemberHandler(accountsStore(client), input.value.weddingId, user.id, input.value.userId);
+ return NextResponse.json(reply.body, { status: reply.status });
+ } catch (error) {
+ return failed("DELETE /api/accounts/members", error);
+ }
+}
diff --git a/suite/app/api/accounts/wedding/route.ts b/suite/app/api/accounts/wedding/route.ts
deleted file mode 100644
index e2609fd..0000000
--- a/suite/app/api/accounts/wedding/route.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { NextResponse } from "next/server";
-import { accountsConfigured } from "@/lib/env";
-import { createWeddingHandler } from "@/lib/accounts/handlers";
-import { accountsStore } from "@/lib/accounts/supabaseStore";
-import { currentUser, serverClient } from "@/lib/accounts/serverClient";
-import { allow, CREATE_LIMIT } from "@/lib/sync/rateLimit";
-
-export const runtime = "nodejs";
-export const dynamic = "force-dynamic";
-
-const unconfigured = () =>
- NextResponse.json({ error: "Accounts are not set up on this deployment." }, { status: 501 });
-
-const unauthenticated = () =>
- NextResponse.json({ error: "Sign in first." }, { status: 401 });
-
-const throttled = () =>
- NextResponse.json({ error: "Too many requests. Wait a while and try again." }, { status: 429 });
-
-/**
- * Anything thrown past the specific checks above is a genuine surprise — a
- * failed RPC, a database that isn't answering. Without this the exception
- * escapes into Next's default 500, whose body is HTML: the browser's
- * `response.json()` then rejects and the calling page hangs on its loading
- * state forever.
- */
-const failed = (where: string, error: unknown) => {
- console.error(`[accounts] ${where}`, error);
- return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 });
-};
-
-/** The caller's current wedding, so a returning member's page can pick up where they left off. */
-export async function GET() {
- try {
- if (!accountsConfigured()) return unconfigured();
- const user = await currentUser();
- if (!user) return unauthenticated();
-
- const client = await serverClient();
- if (!client) return unconfigured();
-
- const membership = await accountsStore(client).memberOf(user.id);
- return NextResponse.json({ weddingId: membership?.weddingId ?? null });
- } catch (error) {
- return failed("GET /api/accounts/wedding", error);
- }
-}
-
-export async function POST() {
- try {
- if (!accountsConfigured()) return unconfigured();
- const user = await currentUser();
- if (!user) return unauthenticated();
-
- if (!allow(`accounts:create-wedding:${user.id}`, CREATE_LIMIT)) return throttled();
-
- const client = await serverClient();
- if (!client) return unconfigured();
-
- const reply = await createWeddingHandler(accountsStore(client), user.id);
- return NextResponse.json(reply.body, { status: reply.status });
- } catch (error) {
- return failed("POST /api/accounts/wedding", error);
- }
-}
diff --git a/suite/app/api/accounts/wedding/route.test.ts b/suite/app/api/accounts/weddings/route.test.ts
similarity index 57%
rename from suite/app/api/accounts/wedding/route.test.ts
rename to suite/app/api/accounts/weddings/route.test.ts
index f72cf4d..b14a1c7 100644
--- a/suite/app/api/accounts/wedding/route.test.ts
+++ b/suite/app/api/accounts/weddings/route.test.ts
@@ -14,26 +14,34 @@ vi.mock("@/lib/accounts/supabaseStore", () => ({ accountsStore: () => store }));
const { POST } = await import("./route");
+const start = (role: "partner" | "planner") =>
+ POST(
+ new Request("http://localhost/api/accounts/weddings", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ role }),
+ }),
+ );
+
beforeEach(() => {
currentUserResult = { id: "same-user", email: "a@example.com" };
});
test("wedding creation past the limit is throttled, per account", async () => {
- // CREATE_LIMIT is 5 an hour. The store also refuses a second wedding for
- // the same user (handlers.ts's createWeddingHandler returns 409 — see the
- // `conflict("You already have a wedding.")` reply at handlers.ts:18), so
- // with the SAME user id the first call succeeds and calls 2-5 come back
- // 409, not 200 — but allow() still counts all five toward the budget. Call
- // 6 must be throttled specifically (429), not merely non-200, or a
- // regression that throttles too early would pass a looser assertion.
- const first = await POST();
+ // CREATE_LIMIT is 5 an hour. The store also refuses a second partner
+ // wedding for the same user (createWeddingHandler's 409), so with the SAME
+ // user id the first call succeeds and calls 2-5 come back 409, not 200 —
+ // but allow() still counts all five toward the budget. Call 6 must be
+ // throttled specifically (429), not merely non-200, or a regression that
+ // throttles too early would pass a looser assertion.
+ const first = await start("partner");
expect(first.status).toBe(200);
for (let i = 0; i < 4; i += 1) {
- const again = await POST();
+ const again = await start("partner");
expect(again.status).toBe(409);
}
- const sixth = await POST();
+ const sixth = await start("planner");
expect(sixth.status).toBe(429);
});
diff --git a/suite/app/api/accounts/weddings/route.ts b/suite/app/api/accounts/weddings/route.ts
new file mode 100644
index 0000000..786af78
--- /dev/null
+++ b/suite/app/api/accounts/weddings/route.ts
@@ -0,0 +1,71 @@
+import { NextResponse } from "next/server";
+import { accountsConfigured } from "@/lib/env";
+import { createWeddingHandler, listWeddingsHandler } from "@/lib/accounts/handlers";
+import { accountsStore } from "@/lib/accounts/supabaseStore";
+import { documentStore } from "@/lib/documents/supabaseStore";
+import { currentUser, serverClient } from "@/lib/accounts/serverClient";
+import { check, newWeddingSchema } from "@/lib/accounts/schemas";
+import { allow, CREATE_LIMIT } from "@/lib/server/rateLimit";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+const unconfigured = () =>
+ NextResponse.json({ error: "Accounts are not set up on this deployment." }, { status: 501 });
+const unauthenticated = () => NextResponse.json({ error: "Sign in first." }, { status: 401 });
+const throttled = () =>
+ NextResponse.json({ error: "Too many requests. Wait a while and try again." }, { status: 429 });
+
+/**
+ * Anything thrown past the specific checks is a genuine surprise — a failed
+ * RPC, a database that isn't answering. Without this the exception escapes
+ * into Next's default 500, whose body is HTML: the browser's `response.json()`
+ * then rejects and the calling page hangs on its loading state forever.
+ */
+const failed = (where: string, error: unknown) => {
+ console.error(`[accounts] ${where}`, error);
+ return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 });
+};
+
+/**
+ * Every wedding this account is on — the couple's own, and a planner's
+ * clients. `?today=` is the asker's own date, for what has fallen due.
+ */
+export async function GET(request: Request) {
+ try {
+ if (!accountsConfigured()) return unconfigured();
+ const user = await currentUser();
+ if (!user) return unauthenticated();
+ const client = await serverClient();
+ if (!client) return unconfigured();
+
+ const asked = new URL(request.url).searchParams.get("today") ?? "";
+ const today = /^\d{4}-\d{2}-\d{2}$/.test(asked) ? asked : new Date().toISOString().slice(0, 10);
+ const reply = await listWeddingsHandler(accountsStore(client), documentStore(client), user.id, today);
+ return NextResponse.json(reply.body, { status: reply.status });
+ } catch (error) {
+ return failed("GET /api/accounts/weddings", error);
+ }
+}
+
+/** Start a wedding: the couple's own (made at sign-in), or a planner's for a client. */
+export async function POST(request: Request) {
+ try {
+ if (!accountsConfigured()) return unconfigured();
+ const user = await currentUser();
+ if (!user) return unauthenticated();
+
+ if (!allow(`accounts:create-wedding:${user.id}`, CREATE_LIMIT)) return throttled();
+
+ const input = check(newWeddingSchema, await request.json().catch(() => null));
+ if (!input.ok) return NextResponse.json({ error: input.error }, { status: 400 });
+
+ const client = await serverClient();
+ if (!client) return unconfigured();
+
+ const reply = await createWeddingHandler(accountsStore(client), user.id, input.value.role);
+ return NextResponse.json(reply.body, { status: reply.status });
+ } catch (error) {
+ return failed("POST /api/accounts/weddings", error);
+ }
+}
diff --git a/suite/app/api/cron/sweep/route.ts b/suite/app/api/cron/sweep/route.ts
index c6704b0..680c283 100644
--- a/suite/app/api/cron/sweep/route.ts
+++ b/suite/app/api/cron/sweep/route.ts
@@ -1,16 +1,12 @@
import { NextResponse } from "next/server";
import { env } from "@/lib/env";
-import { sweepAbandoned } from "@/lib/sync/handlers";
-import { supabaseStore } from "@/lib/sync/supabaseStore";
import { sweepAbandonedDocuments } from "@/lib/documents/handlers";
import { adminDocumentsClient, documentStore } from "@/lib/documents/supabaseStore";
/**
- * Retention, once a day.
- *
- * The only endpoint here that deletes without a passphrase, which is the point:
- * it exists for weddings whose passphrase is gone, and which therefore nobody
- * can ask to have removed. Everything about it is written to fail closed.
+ * Retention, once a day: account weddings nobody has written to inside the
+ * period the Privacy Policy states. It deletes unattended, so everything about
+ * it is written to fail closed.
*
* No `CRON_SECRET` means every request is refused, including Vercel's. An
* endpoint that deletes and has no credential configured must do nothing rather
@@ -31,24 +27,14 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "No." }, { status: 401 });
}
- const db = supabaseStore();
- if (!db) return NextResponse.json({ error: "No backend." }, { status: 501 });
+ const adminClient = adminDocumentsClient();
+ if (!adminClient) return NextResponse.json({ error: "No backend." }, { status: 501 });
try {
- const { deleted } = await sweepAbandoned(db);
-
- const adminClient = adminDocumentsClient();
- const documentsDeleted = adminClient
- ? (await sweepAbandonedDocuments(documentStore(adminClient))).deleted
- : [];
-
- // Ids only. They identify a row, not a person, and the server could not say
- // whose wedding it was even if it wanted to.
- const total = deleted.length + documentsDeleted.length;
- console.info(
- `[Trousseau] retention sweep removed ${deleted.length} passphrase wedding(s), ${documentsDeleted.length} account wedding(s)`,
- );
- return NextResponse.json({ deleted: total });
+ const { deleted } = await sweepAbandonedDocuments(documentStore(adminClient));
+ // A count only: ids identify rows, and a log is no place for them.
+ console.info(`[Trousseau] retention sweep removed ${deleted.length} wedding(s)`);
+ return NextResponse.json({ deleted: deleted.length });
} catch (cause) {
// A failed sweep must be loud: it deletes, it runs unattended, and silence
// here means data kept past the period the Privacy Policy states.
diff --git a/suite/app/api/documents/export/route.test.ts b/suite/app/api/documents/export/route.test.ts
index 083b302..11d7334 100644
--- a/suite/app/api/documents/export/route.test.ts
+++ b/suite/app/api/documents/export/route.test.ts
@@ -21,9 +21,15 @@ vi.mock("@/lib/accounts/serverClient", () => ({
}));
vi.mock("@/lib/accounts/supabaseStore", () => ({
- accountsStore: () => ({ memberOf: async () => membership }),
+ accountsStore: () => ({
+ membersOf: async (weddingId: string) =>
+ membership?.weddingId === weddingId && currentUserResult ? [{ userId: currentUserResult.id }] : [],
+ }),
}));
+const exported = () =>
+ route.GET(new Request(`http://localhost/api/documents/export?wedding=${membership?.weddingId ?? "not-mine"}`));
+
vi.mock("@/lib/documents/supabaseStore", () => ({
documentStore: () => store,
}));
@@ -49,7 +55,7 @@ test("a member downloads their own wedding as an attachment", async () => {
const document = wedding("Charis & Jacob");
await store.saveDocument(membership!.weddingId, document, 0);
- const response = await route.GET();
+ const response = await exported();
expect(response.status).toBe(200);
expect(response.headers.get("content-disposition")).toBe(
'attachment; filename="charis-and-jacob.trousseau.json"',
@@ -61,21 +67,21 @@ test("a member downloads their own wedding as an attachment", async () => {
test("a signed-out caller gets nothing", async () => {
currentUserResult = null;
- const response = await route.GET();
+ const response = await exported();
expect(response.status).toBe(401);
});
-test("a caller who belongs to no wedding cannot export one", async () => {
+test("a caller cannot export a wedding they are not on", async () => {
// The application-layer half of the spec's negative test. The database half
// already exists in lib/documents/migrations.test.ts, against real Postgres.
membership = null;
- const response = await route.GET();
+ const response = await exported();
expect(response.status).toBe(404);
expect(response.headers.get("content-disposition")).toBeNull();
});
test("a member of a wedding that has never been saved gets 404, not an empty file", async () => {
- const response = await route.GET();
+ const response = await exported();
expect(response.status).toBe(404);
});
@@ -84,13 +90,13 @@ test("downloads past the limit are throttled, per account", async () => {
// EXPORT_LIMIT is 20 an hour.
for (let i = 0; i < 20; i += 1) {
- expect((await route.GET()).status).toBe(200);
+ expect((await exported()).status).toBe(200);
}
- expect((await route.GET()).status).toBe(429);
+ expect((await exported()).status).toBe(429);
// A different account still gets theirs.
currentUserResult = { id: "someone-else", email: "b@example.com" };
membership = { weddingId: "someone-elses-wedding" };
await store.saveDocument("someone-elses-wedding", wedding("Ana & Bo"), 0);
- expect((await route.GET()).status).toBe(200);
+ expect((await exported()).status).toBe(200);
});
diff --git a/suite/app/api/documents/export/route.ts b/suite/app/api/documents/export/route.ts
index c7ca9e6..2d9a431 100644
--- a/suite/app/api/documents/export/route.ts
+++ b/suite/app/api/documents/export/route.ts
@@ -1,10 +1,10 @@
import { NextResponse } from "next/server";
import { accountsConfigured } from "@/lib/env";
import { currentUser, serverClient } from "@/lib/accounts/serverClient";
-import { accountsStore } from "@/lib/accounts/supabaseStore";
+import { requestedWedding } from "@/lib/accounts/requestedWedding";
import { documentStore } from "@/lib/documents/supabaseStore";
import { exportDocumentHandler } from "@/lib/documents/handlers";
-import { allow, EXPORT_LIMIT } from "@/lib/sync/rateLimit";
+import { allow, EXPORT_LIMIT } from "@/lib/server/rateLimit";
/**
* "Download my wedding" — the honest answer to "can I get my data out".
@@ -21,7 +21,7 @@ export const dynamic = "force-dynamic";
const unconfigured = () =>
NextResponse.json({ error: "Accounts are not set up on this deployment." }, { status: 501 });
-export async function GET() {
+export async function GET(request: Request) {
try {
if (!accountsConfigured()) return unconfigured();
@@ -40,14 +40,14 @@ export async function GET() {
const client = await serverClient();
if (!client) return unconfigured();
- // A caller with no membership resolves to no wedding, so there is nothing
- // to export. RLS enforces the same thing a second time at the database.
- const membership = await accountsStore(client).memberOf(user.id);
- if (!membership?.weddingId) {
- return NextResponse.json({ error: "You don't have a wedding yet." }, { status: 404 });
+ // Only a wedding the caller is on. RLS enforces the same thing a second
+ // time at the database.
+ const weddingId = await requestedWedding(request, client, user.id);
+ if (!weddingId) {
+ return NextResponse.json({ error: "That is not a wedding you are on." }, { status: 404 });
}
- const reply = await exportDocumentHandler(documentStore(client), membership.weddingId);
+ const reply = await exportDocumentHandler(documentStore(client), weddingId);
if (reply.status === 404) return NextResponse.json(reply.body, { status: 404 });
return new NextResponse(reply.file.text, {
diff --git a/suite/app/api/documents/history/[id]/route.ts b/suite/app/api/documents/history/[id]/route.ts
new file mode 100644
index 0000000..0ec17ef
--- /dev/null
+++ b/suite/app/api/documents/history/[id]/route.ts
@@ -0,0 +1,33 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { accountsConfigured } from "@/lib/env";
+import { currentUser, serverClient } from "@/lib/accounts/serverClient";
+import { requestedWedding } from "@/lib/accounts/requestedWedding";
+import { documentStore } from "@/lib/documents/supabaseStore";
+import { historyDocumentHandler } from "@/lib/documents/handlers";
+import { check } from "@/lib/server/check";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+/** One saved version of the wedding, to look at or put back. */
+export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
+ try {
+ if (!accountsConfigured()) return NextResponse.json({ error: "Accounts are not set up on this deployment." }, { status: 501 });
+ const user = await currentUser();
+ if (!user) return NextResponse.json({ error: "Sign in first." }, { status: 401 });
+ const client = await serverClient();
+ if (!client) return NextResponse.json({ error: "Accounts are not set up on this deployment." }, { status: 501 });
+ const weddingId = await requestedWedding(request, client, user.id);
+ if (!weddingId) return NextResponse.json({ error: "That is not a wedding you are on." }, { status: 404 });
+
+ // A version is a uuid; anything else is not one, rather than a database error.
+ const id = check(z.string().uuid(), (await params).id);
+ if (!id.ok) return NextResponse.json({ error: "That version is not in this wedding's history." }, { status: 404 });
+ const reply = await historyDocumentHandler(documentStore(client), weddingId, id.value);
+ return NextResponse.json(reply.body, { status: reply.status });
+ } catch (error) {
+ console.error("[documents] GET /api/documents/history/[id]", error);
+ return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 });
+ }
+}
diff --git a/suite/app/api/documents/history/route.ts b/suite/app/api/documents/history/route.ts
new file mode 100644
index 0000000..7c206f7
--- /dev/null
+++ b/suite/app/api/documents/history/route.ts
@@ -0,0 +1,30 @@
+import { NextResponse } from "next/server";
+import { accountsConfigured } from "@/lib/env";
+import { currentUser, serverClient } from "@/lib/accounts/serverClient";
+import { requestedWedding } from "@/lib/accounts/requestedWedding";
+import { accountsStore } from "@/lib/accounts/supabaseStore";
+import { documentStore } from "@/lib/documents/supabaseStore";
+import { historyHandler } from "@/lib/documents/handlers";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+/** The wedding's saved versions, newest first, and who saved each. */
+export async function GET(request: Request) {
+ try {
+ if (!accountsConfigured()) return NextResponse.json({ error: "Accounts are not set up on this deployment." }, { status: 501 });
+ const user = await currentUser();
+ if (!user) return NextResponse.json({ error: "Sign in first." }, { status: 401 });
+ const client = await serverClient();
+ if (!client) return NextResponse.json({ error: "Accounts are not set up on this deployment." }, { status: 501 });
+ const weddingId = await requestedWedding(request, client, user.id);
+ if (!weddingId) return NextResponse.json({ error: "That is not a wedding you are on." }, { status: 404 });
+
+ const people = await accountsStore(client).peopleOf(weddingId, user.id);
+ const reply = await historyHandler(documentStore(client), people, weddingId, user.id);
+ return NextResponse.json(reply.body, { status: reply.status });
+ } catch (error) {
+ console.error("[documents] GET /api/documents/history", error);
+ return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 });
+ }
+}
diff --git a/suite/app/api/documents/route.test.ts b/suite/app/api/documents/route.test.ts
index 0f6edcb..fdf08f8 100644
--- a/suite/app/api/documents/route.test.ts
+++ b/suite/app/api/documents/route.test.ts
@@ -23,7 +23,10 @@ vi.mock("@/lib/accounts/serverClient", () => ({
}));
vi.mock("@/lib/accounts/supabaseStore", () => ({
- accountsStore: () => ({ memberOf: async () => membership }),
+ accountsStore: () => ({
+ membersOf: async (weddingId: string) =>
+ membership?.weddingId === weddingId && currentUserResult ? [{ userId: currentUserResult.id }] : [],
+ }),
}));
vi.mock("@/lib/documents/supabaseStore", () => ({
@@ -34,7 +37,7 @@ const route = await import("./route");
const put = (document: unknown, expectedVersion: number) =>
route.PUT(
- new Request("http://localhost/api/documents", {
+ new Request(`http://localhost/api/documents?wedding=${membership?.weddingId ?? "not-mine"}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ document, expectedVersion }),
@@ -67,7 +70,7 @@ test("a signed-out caller is refused before any document work", async () => {
expect(response.status).toBe(401);
});
-test("an account with no wedding gets 404, not a crash", async () => {
+test("a wedding the caller is not on gets 404, not a crash", async () => {
membership = null;
const response = await put({ kind: "trousseau", version: 1 }, 0);
expect(response.status).toBe(404);
@@ -89,3 +92,14 @@ test("writes past the limit are throttled, and the budget is per account", async
const other = await put({ kind: "trousseau", version: 1 }, 0);
expect(other.status).toBe(200);
});
+
+test("a request that names no wedding gets 404 — an account may be on several", async () => {
+ const response = await route.GET(new Request("http://localhost/api/documents"));
+ expect(response.status).toBe(404);
+});
+
+test("the wedding travels with its document", async () => {
+ await put({ kind: "trousseau", version: 1 }, 0);
+ const response = await route.GET(new Request(`http://localhost/api/documents?wedding=${membership!.weddingId}`));
+ expect(await response.json()).toMatchObject({ weddingId: membership!.weddingId, version: 1 });
+});
diff --git a/suite/app/api/documents/route.ts b/suite/app/api/documents/route.ts
index a0a3fef..6ca3434 100644
--- a/suite/app/api/documents/route.ts
+++ b/suite/app/api/documents/route.ts
@@ -1,10 +1,10 @@
import { NextResponse } from "next/server";
import { accountsConfigured } from "@/lib/env";
import { currentUser, serverClient } from "@/lib/accounts/serverClient";
-import { accountsStore } from "@/lib/accounts/supabaseStore";
+import { requestedWedding } from "@/lib/accounts/requestedWedding";
import { documentStore } from "@/lib/documents/supabaseStore";
import { getDocumentHandler, saveDocumentHandler } from "@/lib/documents/handlers";
-import { allow, WRITE_LIMIT } from "@/lib/sync/rateLimit";
+import { allow, WRITE_LIMIT } from "@/lib/server/rateLimit";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -15,7 +15,7 @@ const unconfigured = () =>
const unauthenticated = () => NextResponse.json({ error: "Sign in first." }, { status: 401 });
const noWedding = () =>
- NextResponse.json({ error: "You don't have a wedding yet." }, { status: 404 });
+ NextResponse.json({ error: "That is not a wedding you are on." }, { status: 404 });
const throttled = () =>
NextResponse.json({ error: "Too many requests. Wait a minute and try again." }, { status: 429 });
@@ -25,12 +25,7 @@ const failed = (where: string, error: unknown) => {
return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 });
};
-async function resolveWeddingId(client: NonNullable>>, userId: string) {
- const membership = await accountsStore(client).memberOf(userId);
- return membership?.weddingId ?? null;
-}
-
-export async function GET() {
+export async function GET(request: Request) {
try {
if (!accountsConfigured()) return unconfigured();
const user = await currentUser();
@@ -39,7 +34,7 @@ export async function GET() {
const client = await serverClient();
if (!client) return unconfigured();
- const weddingId = await resolveWeddingId(client, user.id);
+ const weddingId = await requestedWedding(request, client, user.id);
if (!weddingId) return noWedding();
const reply = await getDocumentHandler(documentStore(client), weddingId);
@@ -71,7 +66,7 @@ export async function PUT(request: Request) {
const client = await serverClient();
if (!client) return unconfigured();
- const weddingId = await resolveWeddingId(client, user.id);
+ const weddingId = await requestedWedding(request, client, user.id);
if (!weddingId) return noWedding();
let body: PutBody;
diff --git a/suite/app/api/library/[id]/route.ts b/suite/app/api/library/[id]/route.ts
new file mode 100644
index 0000000..cc5bd54
--- /dev/null
+++ b/suite/app/api/library/[id]/route.ts
@@ -0,0 +1,53 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { accountsConfigured } from "@/lib/env";
+import { currentUser, serverClient } from "@/lib/accounts/serverClient";
+import { libraryStore } from "@/lib/library/supabaseStore";
+import { getHandler, removeHandler } from "@/lib/library/handlers";
+import { check } from "@/lib/server/check";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+const unconfigured = () => NextResponse.json({ error: "Accounts are not set up on this deployment." }, { status: 501 });
+const missing = () => NextResponse.json({ error: "That is not in your library." }, { status: 404 });
+const failed = (where: string, error: unknown) => {
+ console.error(`[library] ${where}`, error);
+ return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 });
+};
+
+async function owner(): Promise<{ id: string; client: NonNullable>> } | NextResponse> {
+ if (!accountsConfigured()) return unconfigured();
+ const user = await currentUser();
+ if (!user) return NextResponse.json({ error: "Sign in first." }, { status: 401 });
+ const client = await serverClient();
+ if (!client) return unconfigured();
+ return { id: user.id, client };
+}
+
+/** One kept design, to put into the open wedding. */
+export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
+ try {
+ const who = await owner();
+ if (who instanceof NextResponse) return who;
+ const id = check(z.string().uuid(), (await params).id);
+ if (!id.ok) return missing();
+ const reply = await getHandler(libraryStore(who.client), who.id, id.value);
+ return NextResponse.json(reply.body, { status: reply.status });
+ } catch (error) {
+ return failed("GET /api/library/[id]", error);
+ }
+}
+
+export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
+ try {
+ const who = await owner();
+ if (who instanceof NextResponse) return who;
+ const id = check(z.string().uuid(), (await params).id);
+ if (!id.ok) return missing();
+ const reply = await removeHandler(libraryStore(who.client), who.id, id.value);
+ return NextResponse.json(reply.body, { status: reply.status });
+ } catch (error) {
+ return failed("DELETE /api/library/[id]", error);
+ }
+}
diff --git a/suite/app/api/library/route.ts b/suite/app/api/library/route.ts
new file mode 100644
index 0000000..ba6fe30
--- /dev/null
+++ b/suite/app/api/library/route.ts
@@ -0,0 +1,49 @@
+import { NextResponse } from "next/server";
+import { accountsConfigured } from "@/lib/env";
+import { currentUser, serverClient } from "@/lib/accounts/serverClient";
+import { libraryStore } from "@/lib/library/supabaseStore";
+import { listHandler, saveHandler } from "@/lib/library/handlers";
+import { allow, LIBRARY_LIMIT } from "@/lib/server/rateLimit";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+const unconfigured = () => NextResponse.json({ error: "Accounts are not set up on this deployment." }, { status: 501 });
+const unauthenticated = () => NextResponse.json({ error: "Sign in first." }, { status: 401 });
+const failed = (where: string, error: unknown) => {
+ console.error(`[library] ${where}`, error);
+ return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 });
+};
+
+/** What this account has kept, newest first. */
+export async function GET() {
+ try {
+ if (!accountsConfigured()) return unconfigured();
+ const user = await currentUser();
+ if (!user) return unauthenticated();
+ const client = await serverClient();
+ if (!client) return unconfigured();
+ const reply = await listHandler(libraryStore(client), user.id);
+ return NextResponse.json(reply.body, { status: reply.status });
+ } catch (error) {
+ return failed("GET /api/library", error);
+ }
+}
+
+/** Keep a design: `{ kind, name, content }`, already stripped of anything personal. */
+export async function POST(request: Request) {
+ try {
+ if (!accountsConfigured()) return unconfigured();
+ const user = await currentUser();
+ if (!user) return unauthenticated();
+ if (!allow(`library:save:${user.id}`, LIBRARY_LIMIT)) {
+ return NextResponse.json({ error: "Too many saves. Wait a while and try again." }, { status: 429 });
+ }
+ const client = await serverClient();
+ if (!client) return unconfigured();
+ const reply = await saveHandler(libraryStore(client), user.id, await request.json().catch(() => null));
+ return NextResponse.json(reply.body, { status: reply.status });
+ } catch (error) {
+ return failed("POST /api/library", error);
+ }
+}
diff --git a/suite/app/api/share/[token]/route.ts b/suite/app/api/share/[token]/route.ts
new file mode 100644
index 0000000..6b407ef
--- /dev/null
+++ b/suite/app/api/share/[token]/route.ts
@@ -0,0 +1,33 @@
+import { NextResponse } from "next/server";
+import { accountsConfigured } from "@/lib/env";
+import { serverClient } from "@/lib/accounts/serverClient";
+import { check } from "@/lib/server/check";
+import { tokenSchema } from "@/lib/share/schemas";
+import { shareStore } from "@/lib/share/supabaseStore";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+/**
+ * What a guest's link fetches: the sealed snapshot, and nothing that says
+ * whose wedding it is. Anyone with the token may ask; only the key after the
+ * `#` opens it.
+ */
+export async function GET(_request: Request, { params }: { params: Promise<{ token: string }> }) {
+ try {
+ if (!accountsConfigured()) {
+ return NextResponse.json({ error: "Guest links are not set up on this deployment." }, { status: 501 });
+ }
+ const token = check(tokenSchema, (await params).token);
+ if (!token.ok) return NextResponse.json({ error: token.error }, { status: 404 });
+
+ const client = await serverClient();
+ if (!client) return NextResponse.json({ error: "Guest links are not set up on this deployment." }, { status: 501 });
+ const sealed = await shareStore(client).read(token.value);
+ if (!sealed) return NextResponse.json({ error: "This link is not live." }, { status: 404 });
+ return NextResponse.json(sealed, { headers: { "cache-control": "no-store" } });
+ } catch (error) {
+ console.error("[share] GET /api/share/[token]", error);
+ return NextResponse.json({ error: "Something went wrong." }, { status: 500 });
+ }
+}
diff --git a/suite/app/api/share/route.test.ts b/suite/app/api/share/route.test.ts
new file mode 100644
index 0000000..43d72dd
--- /dev/null
+++ b/suite/app/api/share/route.test.ts
@@ -0,0 +1,67 @@
+// @vitest-environment node
+import { beforeEach, expect, test, vi } from "vitest";
+import { memoryStore } from "@/lib/share/store";
+
+/** The routes' wiring, with only the Supabase seams faked. The rules are the database's. */
+
+const store = memoryStore();
+const WEDDING = "0b7c2d36-5a3e-4f0e-9d1a-2c6b8e4f1a90";
+let user: { id: string; email: string } | null = { id: "alice", email: "alice@example.com" };
+let members = ["alice"];
+
+vi.mock("@/lib/env", () => ({ accountsConfigured: () => true }));
+vi.mock("@/lib/accounts/serverClient", () => ({ currentUser: async () => user, serverClient: async () => ({}) }));
+vi.mock("@/lib/accounts/supabaseStore", () => ({
+ accountsStore: () => ({ membersOf: async () => members.map((userId) => ({ userId })) }),
+}));
+vi.mock("@/lib/share/supabaseStore", () => ({ shareStore: () => store }));
+
+const route = await import("./route");
+const byToken = await import("./[token]/route");
+
+const KEY = "a".repeat(43);
+const put = (body: Record) =>
+ route.PUT(new Request("http://localhost/api/share", { method: "PUT", body: JSON.stringify(body) }));
+const publishing = (key = KEY, ciphertext = "c2VhbGVk") => ({ weddingId: WEDDING, key, showPlan: false, ciphertext, iv: "aXY=", fingerprint: "fp" });
+
+beforeEach(async () => {
+ user = { id: "alice", email: "alice@example.com" };
+ members = ["alice"];
+ await store.takeDown(WEDDING);
+});
+
+test("a member publishes, and a guest reads the sealed snapshot by token — nothing else", async () => {
+ const published = await put(publishing());
+ expect(published.status).toBe(200);
+ const { token } = (await published.json()) as { token: string };
+
+ const read = await byToken.GET(new Request(`http://localhost/api/share/${token}`), { params: Promise.resolve({ token }) });
+ expect(await read.json()).toEqual({ ciphertext: "c2VhbGVk", iv: "aXY=" });
+});
+
+test("members read the link with its key; anyone else is told it is not theirs", async () => {
+ await put(publishing());
+ const mine = await route.GET(new Request(`http://localhost/api/share?wedding=${WEDDING}`));
+ expect(((await mine.json()) as { link: { key: string } }).link.key).toBe(KEY);
+
+ members = [];
+ expect((await route.GET(new Request(`http://localhost/api/share?wedding=${WEDDING}`))).status).toBe(404);
+ expect((await put(publishing())).status).toBe(404);
+});
+
+test("a republish under another key is refused, so the next seal uses the published one", async () => {
+ await put(publishing());
+ expect((await put(publishing("b".repeat(43)))).status).toBe(409);
+});
+
+test("a token that is not one is simply not found", async () => {
+ const response = await byToken.GET(new Request("http://localhost/api/share/nope"), { params: Promise.resolve({ token: "../x" }) });
+ expect(response.status).toBe(404);
+});
+
+test("taking it down stops the link", async () => {
+ const { token } = (await (await put(publishing())).json()) as { token: string };
+ await route.DELETE(new Request("http://localhost/api/share", { method: "DELETE", body: JSON.stringify({ weddingId: WEDDING }) }));
+ const read = await byToken.GET(new Request(`http://localhost/api/share/${token}`), { params: Promise.resolve({ token }) });
+ expect(read.status).toBe(404);
+});
diff --git a/suite/app/api/share/route.ts b/suite/app/api/share/route.ts
new file mode 100644
index 0000000..2a2dffe
--- /dev/null
+++ b/suite/app/api/share/route.ts
@@ -0,0 +1,85 @@
+import { NextResponse } from "next/server";
+import { accountsConfigured } from "@/lib/env";
+import { currentUser, serverClient } from "@/lib/accounts/serverClient";
+import { isOnWedding, requestedWedding } from "@/lib/accounts/requestedWedding";
+import { check } from "@/lib/server/check";
+import { allow, SHARE_LIMIT } from "@/lib/server/rateLimit";
+import { publishSchema, takeDownSchema } from "@/lib/share/schemas";
+import { shareStore } from "@/lib/share/supabaseStore";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+const unconfigured = () =>
+ NextResponse.json({ error: "Accounts are not set up on this deployment." }, { status: 501 });
+const unauthenticated = () => NextResponse.json({ error: "Sign in first." }, { status: 401 });
+const notYours = () => NextResponse.json({ error: "That is not a wedding you are on." }, { status: 404 });
+/** An uncaught throw becomes an HTML 500 the page cannot read. */
+const failed = (where: string, error: unknown) => {
+ console.error(`[share] ${where}`, error);
+ return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 });
+};
+
+/** The wedding's guest link, key included — for its members, who republish it. */
+export async function GET(request: Request) {
+ try {
+ if (!accountsConfigured()) return unconfigured();
+ const user = await currentUser();
+ if (!user) return unauthenticated();
+ const client = await serverClient();
+ if (!client) return unconfigured();
+
+ const weddingId = await requestedWedding(request, client, user.id);
+ if (!weddingId) return notYours();
+ return NextResponse.json({ link: await shareStore(client).linkOf(weddingId) });
+ } catch (error) {
+ return failed("GET /api/share", error);
+ }
+}
+
+/** Publish, or republish under the same token and key. */
+export async function PUT(request: Request) {
+ try {
+ if (!accountsConfigured()) return unconfigured();
+ const user = await currentUser();
+ if (!user) return unauthenticated();
+ if (!allow(`share:publish:${user.id}`, SHARE_LIMIT)) {
+ return NextResponse.json({ error: "Too many updates. Wait a while and try again." }, { status: 429 });
+ }
+
+ const input = check(publishSchema, await request.json().catch(() => null));
+ if (!input.ok) return NextResponse.json({ error: input.error }, { status: 400 });
+
+ const client = await serverClient();
+ if (!client) return unconfigured();
+ const { weddingId, ...publish } = input.value;
+ if (!(await isOnWedding(client, user.id, weddingId))) return notYours();
+ const published = await shareStore(client).publish(weddingId, publish);
+ // Another device published first, under its own key: read the link again
+ // and seal with that one.
+ if (!published) return NextResponse.json({ error: "The link was published from elsewhere." }, { status: 409 });
+ return NextResponse.json(published);
+ } catch (error) {
+ return failed("PUT /api/share", error);
+ }
+}
+
+/** Take the link down: every copy of it stops working. */
+export async function DELETE(request: Request) {
+ try {
+ if (!accountsConfigured()) return unconfigured();
+ const user = await currentUser();
+ if (!user) return unauthenticated();
+
+ const input = check(takeDownSchema, await request.json().catch(() => null));
+ if (!input.ok) return NextResponse.json({ error: input.error }, { status: 400 });
+
+ const client = await serverClient();
+ if (!client) return unconfigured();
+ if (!(await isOnWedding(client, user.id, input.value.weddingId))) return notYours();
+ await shareStore(client).takeDown(input.value.weddingId);
+ return NextResponse.json({});
+ } catch (error) {
+ return failed("DELETE /api/share", error);
+ }
+}
diff --git a/suite/app/api/suppliers/[token]/route.ts b/suite/app/api/suppliers/[token]/route.ts
new file mode 100644
index 0000000..16e5454
--- /dev/null
+++ b/suite/app/api/suppliers/[token]/route.ts
@@ -0,0 +1,52 @@
+import { NextResponse } from "next/server";
+import { accountsConfigured } from "@/lib/env";
+import { serverClient } from "@/lib/accounts/serverClient";
+import { check } from "@/lib/server/check";
+import { allow, CONFIRM_LIMIT } from "@/lib/server/rateLimit";
+import { tokenSchema } from "@/lib/suppliers/schemas";
+import { supplierStore } from "@/lib/suppliers/supabaseStore";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+const unconfigured = () => NextResponse.json({ error: "Supplier links are not set up on this deployment." }, { status: 501 });
+const gone = () => NextResponse.json({ error: "This link is not live." }, { status: 404 });
+const failed = (where: string, error: unknown) => {
+ console.error(`[suppliers] ${where}`, error);
+ return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 });
+};
+
+/** What a supplier's link fetches: their sheet, sealed, and when they confirmed it. */
+export async function GET(_request: Request, { params }: { params: Promise<{ token: string }> }) {
+ try {
+ if (!accountsConfigured()) return unconfigured();
+ const token = check(tokenSchema, (await params).token);
+ if (!token.ok) return gone();
+ const client = await serverClient();
+ if (!client) return unconfigured();
+ const sealed = await supplierStore(client).read(token.value);
+ if (!sealed) return gone();
+ return NextResponse.json(sealed, { headers: { "cache-control": "no-store" } });
+ } catch (error) {
+ return failed("GET /api/suppliers/[token]", error);
+ }
+}
+
+/** The supplier says they have it: when, recorded against their link and nothing else. */
+export async function POST(_request: Request, { params }: { params: Promise<{ token: string }> }) {
+ try {
+ if (!accountsConfigured()) return unconfigured();
+ const token = check(tokenSchema, (await params).token);
+ if (!token.ok) return gone();
+ if (!allow(`suppliers:confirm:${token.value}`, CONFIRM_LIMIT)) {
+ return NextResponse.json({ error: "Confirmed already. Wait a while to confirm again." }, { status: 429 });
+ }
+ const client = await serverClient();
+ if (!client) return unconfigured();
+ const confirmedAt = await supplierStore(client).confirm(token.value);
+ if (!confirmedAt) return gone();
+ return NextResponse.json({ confirmedAt });
+ } catch (error) {
+ return failed("POST /api/suppliers/[token]", error);
+ }
+}
diff --git a/suite/app/api/suppliers/route.test.ts b/suite/app/api/suppliers/route.test.ts
new file mode 100644
index 0000000..550fbfb
--- /dev/null
+++ b/suite/app/api/suppliers/route.test.ts
@@ -0,0 +1,89 @@
+// @vitest-environment node
+import { beforeEach, expect, test, vi } from "vitest";
+import { memoryStore } from "@/lib/suppliers/store";
+
+/** The routes' wiring, with only the Supabase seams faked. The rules are the database's. */
+
+const store = memoryStore();
+const WEDDING = "0b7c2d36-5a3e-4f0e-9d1a-2c6b8e4f1a90";
+let user: { id: string; email: string } | null = { id: "alice", email: "alice@example.com" };
+let members = ["alice"];
+
+vi.mock("@/lib/env", () => ({ accountsConfigured: () => true }));
+vi.mock("@/lib/accounts/serverClient", () => ({ currentUser: async () => user, serverClient: async () => ({}) }));
+vi.mock("@/lib/accounts/supabaseStore", () => ({
+ accountsStore: () => ({ membersOf: async () => members.map((userId) => ({ userId })) }),
+}));
+vi.mock("@/lib/suppliers/supabaseStore", () => ({ supplierStore: () => store }));
+
+const route = await import("./route");
+const byToken = await import("./[token]/route");
+
+const KEY = "a".repeat(43);
+const put = (body: Record) =>
+ route.PUT(new Request("http://localhost/api/suppliers", { method: "PUT", body: JSON.stringify(body) }));
+const publishing = (teamId = "team-photo", key = KEY) => ({ weddingId: WEDDING, teamId, key, ciphertext: "c2VhbGVk", iv: "aXY=", fingerprint: "fp" });
+const at = (token: string) => ({ params: Promise.resolve({ token }) });
+
+beforeEach(async () => {
+ user = { id: "alice", email: "alice@example.com" };
+ members = ["alice"];
+ for (const link of await store.linksOf(WEDDING)) await store.takeDown(WEDDING, link.teamId);
+});
+
+test("a member publishes, and the supplier reads their sealed sheet by token — nothing else", async () => {
+ const { token } = (await (await put(publishing())).json()) as { token: string };
+ const read = (await (await byToken.GET(new Request("http://localhost"), at(token))).json()) as Record;
+ expect(Object.keys(read).sort()).toEqual(["ciphertext", "confirmedAt", "iv", "publishedAt"]);
+ expect(read.ciphertext).toBe("c2VhbGVk");
+ expect(read.confirmedAt).toBeNull();
+});
+
+test("each supplier has their own link", async () => {
+ const photo = (await (await put(publishing("team-photo"))).json()) as { token: string };
+ const flowers = (await (await put(publishing("team-flowers", "b".repeat(43)))).json()) as { token: string };
+ expect(photo.token).not.toBe(flowers.token);
+ const listed = (await (await route.GET(new Request(`http://localhost/api/suppliers?wedding=${WEDDING}`))).json()) as {
+ links: { teamId: string; key: string }[];
+ };
+ expect(listed.links.map((link) => [link.teamId, link.key]).sort()).toEqual([
+ ["team-flowers", "b".repeat(43)],
+ ["team-photo", KEY],
+ ]);
+});
+
+test("the supplier confirms through their link, and members see when", async () => {
+ const { token } = (await (await put(publishing())).json()) as { token: string };
+ const confirmed = (await (await byToken.POST(new Request("http://localhost", { method: "POST" }), at(token))).json()) as {
+ confirmedAt: string;
+ };
+ const listed = (await (await route.GET(new Request(`http://localhost/api/suppliers?wedding=${WEDDING}`))).json()) as {
+ links: { confirmedAt: string }[];
+ };
+ expect(listed.links[0].confirmedAt).toBe(confirmed.confirmedAt);
+});
+
+test("anyone not on the wedding is told it is not theirs", async () => {
+ members = [];
+ expect((await route.GET(new Request(`http://localhost/api/suppliers?wedding=${WEDDING}`))).status).toBe(404);
+ expect((await put(publishing())).status).toBe(404);
+ user = null;
+ expect((await put(publishing())).status).toBe(401);
+});
+
+test("a republish under another key is refused", async () => {
+ await put(publishing());
+ expect((await put(publishing("team-photo", "b".repeat(43)))).status).toBe(409);
+});
+
+test("a token that is not one, or a link taken down, is simply not found", async () => {
+ expect((await byToken.GET(new Request("http://localhost"), at("../x"))).status).toBe(404);
+ expect((await byToken.POST(new Request("http://localhost", { method: "POST" }), at("../x"))).status).toBe(404);
+
+ const { token } = (await (await put(publishing())).json()) as { token: string };
+ await route.DELETE(
+ new Request("http://localhost/api/suppliers", { method: "DELETE", body: JSON.stringify({ weddingId: WEDDING, teamId: "team-photo" }) }),
+ );
+ expect((await byToken.GET(new Request("http://localhost"), at(token))).status).toBe(404);
+ expect((await byToken.POST(new Request("http://localhost", { method: "POST" }), at(token))).status).toBe(404);
+});
diff --git a/suite/app/api/suppliers/route.ts b/suite/app/api/suppliers/route.ts
new file mode 100644
index 0000000..39bb921
--- /dev/null
+++ b/suite/app/api/suppliers/route.ts
@@ -0,0 +1,76 @@
+import { NextResponse } from "next/server";
+import { accountsConfigured } from "@/lib/env";
+import { currentUser, serverClient } from "@/lib/accounts/serverClient";
+import { isOnWedding, requestedWedding } from "@/lib/accounts/requestedWedding";
+import { check } from "@/lib/server/check";
+import { allow, SHARE_LIMIT } from "@/lib/server/rateLimit";
+import { publishSchema, takeDownSchema } from "@/lib/suppliers/schemas";
+import { supplierStore } from "@/lib/suppliers/supabaseStore";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+const unconfigured = () => NextResponse.json({ error: "Accounts are not set up on this deployment." }, { status: 501 });
+const unauthenticated = () => NextResponse.json({ error: "Sign in first." }, { status: 401 });
+const notYours = () => NextResponse.json({ error: "That is not a wedding you are on." }, { status: 404 });
+const failed = (where: string, error: unknown) => {
+ console.error(`[suppliers] ${where}`, error);
+ return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 });
+};
+
+/** The wedding's suppliers' links, keys and confirmations included — for its members. */
+export async function GET(request: Request) {
+ try {
+ if (!accountsConfigured()) return unconfigured();
+ const user = await currentUser();
+ if (!user) return unauthenticated();
+ const client = await serverClient();
+ if (!client) return unconfigured();
+ const weddingId = await requestedWedding(request, client, user.id);
+ if (!weddingId) return notYours();
+ return NextResponse.json({ links: await supplierStore(client).linksOf(weddingId) });
+ } catch (error) {
+ return failed("GET /api/suppliers", error);
+ }
+}
+
+/** Publish one supplier's sheet, or republish it under the same token and key. */
+export async function PUT(request: Request) {
+ try {
+ if (!accountsConfigured()) return unconfigured();
+ const user = await currentUser();
+ if (!user) return unauthenticated();
+ if (!allow(`suppliers:publish:${user.id}`, SHARE_LIMIT)) {
+ return NextResponse.json({ error: "Too many updates. Wait a while and try again." }, { status: 429 });
+ }
+ const input = check(publishSchema, await request.json().catch(() => null));
+ if (!input.ok) return NextResponse.json({ error: input.error }, { status: 400 });
+ const client = await serverClient();
+ if (!client) return unconfigured();
+ const { weddingId, ...publish } = input.value;
+ if (!(await isOnWedding(client, user.id, weddingId))) return notYours();
+ const published = await supplierStore(client).publish(weddingId, publish);
+ if (!published) return NextResponse.json({ error: "The link was published from elsewhere." }, { status: 409 });
+ return NextResponse.json(published);
+ } catch (error) {
+ return failed("PUT /api/suppliers", error);
+ }
+}
+
+/** Take one supplier's link down: their copy stops working. */
+export async function DELETE(request: Request) {
+ try {
+ if (!accountsConfigured()) return unconfigured();
+ const user = await currentUser();
+ if (!user) return unauthenticated();
+ const input = check(takeDownSchema, await request.json().catch(() => null));
+ if (!input.ok) return NextResponse.json({ error: input.error }, { status: 400 });
+ const client = await serverClient();
+ if (!client) return unconfigured();
+ if (!(await isOnWedding(client, user.id, input.value.weddingId))) return notYours();
+ await supplierStore(client).takeDown(input.value.weddingId, input.value.teamId);
+ return NextResponse.json({});
+ } catch (error) {
+ return failed("DELETE /api/suppliers", error);
+ }
+}
diff --git a/suite/app/api/sync/[...route]/route.ts b/suite/app/api/sync/[...route]/route.ts
deleted file mode 100644
index 369ff83..0000000
--- a/suite/app/api/sync/[...route]/route.ts
+++ /dev/null
@@ -1,237 +0,0 @@
-import { NextResponse } from "next/server";
-import { env } from "@/lib/env";
-import {
- createWedding,
- deleteShare,
- deleteWedding,
- getBlob,
- getSalt,
- getShare,
- listBlobs,
- pull,
- push,
- putBlob,
- putShare,
- type Reply,
-} from "@/lib/sync/handlers";
-import {
- allow,
- authAttemptsRemain,
- callerKey,
- CREATE_LIMIT,
- noteAuthFailure,
- WRITE_LIMIT,
-} from "@/lib/sync/rateLimit";
-import {
- blobSchema,
- check,
- createSchema,
- params,
- pushSchema,
- shareSchema,
-} from "@/lib/sync/schemas";
-import { safely } from "@/lib/sync/safely";
-import { memoryStore, type SyncStore } from "@/lib/sync/store";
-import { supabaseStore } from "@/lib/sync/supabaseStore";
-
-/**
- * The whole backend, in one route.
- *
- * Every path here moves ciphertext. Nothing on this server can read a guest
- * name, and the decisions it does make — who may write, and which of two
- * concurrent writes wins — live in `lib/sync/handlers`, tested against an
- * in-memory store.
- *
- * Sharing is entirely optional. With no Supabase configured the app is exactly
- * what it was: local-first, no account, nothing leaving the device.
- */
-
-export const runtime = "nodejs";
-// Never cached, never prerendered: every response is either a secret-checked
-// read or a write.
-export const dynamic = "force-dynamic";
-
-function store(): SyncStore | null {
- const configured = supabaseStore();
- if (configured) return configured;
- if (env().SYNC_IN_MEMORY === "1") return devStore();
- return null;
-}
-
-let dev: SyncStore | null = null;
-function devStore(): SyncStore {
- dev ??= memoryStore();
- return dev;
-}
-
-const send = (reply: Reply) => NextResponse.json(reply.body, { status: reply.status });
-
-const unconfigured = () =>
- NextResponse.json(
- {
- error:
- "Sharing is not set up on this deployment. Everything still works on this device; " +
- "only publishing a link and syncing between machines need a backend.",
- },
- { status: 501 },
- );
-
-/**
- * A 400 for anything that is not the right shape.
- *
- * Path segments are as attacker-controlled as bodies are — an id out of the URL
- * used to reach a primary key with neither length nor shape checked — so both
- * go through `lib/sync/schemas` before a handler sees them.
- */
-const malformed = (error: string) => NextResponse.json({ error }, { status: 400 });
-
-const throttled = () =>
- NextResponse.json(
- { error: "Too many requests. Wait a minute and try again." },
- { status: 429 },
- );
-
-
-/** The passphrase-derived write token. Never the passphrase, never the content key. */
-function tokenOf(request: Request): string | null {
- const header = request.headers.get("authorization") ?? "";
- return header.startsWith("Bearer ") ? header.slice(7) : null;
-}
-
-/**
- * Run an authorised handler, counting failures against the caller.
- *
- * A 403 from any of these means a passphrase did not check out, and repeated
- * ones are somebody guessing. Counting only failures means an honest client
- * syncing all day is never throttled by its own success.
- */
-async function guarded(request: Request, work: () => Promise): Promise {
- const caller = callerKey(request);
- if (!authAttemptsRemain(caller)) return throttled();
- if (!allow(`rw:${caller}`, WRITE_LIMIT)) return throttled();
-
- return safely(async () => {
- const reply = await work();
- if (reply.status === 403) noteAuthFailure(caller);
- return reply;
- });
-}
-
-export async function GET(request: Request, context: { params: Promise<{ route: string[] }> }) {
- const db = store();
- if (!db) return unconfigured();
- const { route } = await context.params;
- const [head, id, tail, extra] = route;
-
- if (head === "wedding" && id && tail === "salt") {
- // Counted before it is parsed, so a malformed id is not a free request.
- // Public, but still counted: the salt endpoint is the first step of a
- // guessing run, and it is the cheapest place to slow one down.
- if (!allow(`salt:${callerKey(request)}`, WRITE_LIMIT)) return throttled();
- const weddingId = check(params.weddingId, id);
- if (!weddingId.ok) return malformed(weddingId.error);
- return safely(() => getSalt(db, weddingId.value));
- }
- if (head === "wedding" && id && tail === "slices") {
- const weddingId = check(params.weddingId, id);
- if (!weddingId.ok) return malformed(weddingId.error);
- return guarded(request, () => pull(db, weddingId.value, tokenOf(request)));
- }
- if (head === "wedding" && id && tail === "blobs") {
- const weddingId = check(params.weddingId, id);
- if (!weddingId.ok) return malformed(weddingId.error);
- return guarded(request, () => listBlobs(db, weddingId.value, tokenOf(request)));
- }
- if (head === "wedding" && id && tail === "blob" && extra) {
- const weddingId = check(params.weddingId, id);
- if (!weddingId.ok) return malformed(weddingId.error);
- const asset = check(params.blobId, extra);
- if (!asset.ok) return malformed(asset.error);
- return guarded(request, () => getBlob(db, weddingId.value, tokenOf(request), asset.value));
- }
- if (head === "share" && id) {
- if (!allow(`share:${callerKey(request)}`, WRITE_LIMIT)) return throttled();
- const token = check(params.shareToken, id);
- if (!token.ok) return malformed(token.error);
- return safely(() => getShare(db, token.value));
- }
-
- return NextResponse.json({ error: "No such endpoint." }, { status: 404 });
-}
-
-export async function POST(request: Request, context: { params: Promise<{ route: string[] }> }) {
- const db = store();
- if (!db) return unconfigured();
- const { route } = await context.params;
- const [head, id, tail, extra] = route;
-
- let body: unknown;
- try {
- body = await request.json();
- } catch {
- return NextResponse.json({ error: "That was not JSON." }, { status: 400 });
- }
-
- if (head === "wedding" && !id) {
- // The one unauthenticated write there is, so it gets the tightest limit.
- if (!allow(`create:${callerKey(request)}`, CREATE_LIMIT)) return throttled();
- const input = check(createSchema, body);
- if (!input.ok) return malformed(input.error);
- return safely(() => createWedding(db, input.value));
- }
-
- if (head === "wedding" && id && tail === "slices") {
- const weddingId = check(params.weddingId, id);
- if (!weddingId.ok) return malformed(weddingId.error);
- const input = check(pushSchema, body);
- if (!input.ok) return malformed(input.error);
- return guarded(request, () => push(db, weddingId.value, tokenOf(request), input.value.writes));
- }
-
- if (head === "wedding" && id && tail === "blob" && extra) {
- const weddingId = check(params.weddingId, id);
- if (!weddingId.ok) return malformed(weddingId.error);
- const asset = check(params.blobId, extra);
- if (!asset.ok) return malformed(asset.error);
- const input = check(blobSchema, body);
- if (!input.ok) return malformed(input.error);
- return guarded(request, () =>
- putBlob(db, weddingId.value, tokenOf(request), asset.value, input.value.sealed),
- );
- }
-
- if (head === "wedding" && id && tail === "share") {
- const weddingId = check(params.weddingId, id);
- if (!weddingId.ok) return malformed(weddingId.error);
- const input = check(shareSchema, body);
- if (!input.ok) return malformed(input.error);
- return guarded(request, () => putShare(db, weddingId.value, tokenOf(request), input.value));
- }
-
- return NextResponse.json({ error: "No such endpoint." }, { status: 404 });
-}
-
-export async function DELETE(request: Request, context: { params: Promise<{ route: string[] }> }) {
- const db = store();
- if (!db) return unconfigured();
- const { route } = await context.params;
- const [head, id, tail, shareToken] = route;
-
- if (head === "wedding" && id && !tail) {
- const weddingId = check(params.weddingId, id);
- if (!weddingId.ok) return malformed(weddingId.error);
- return guarded(request, () => deleteWedding(db, weddingId.value, tokenOf(request)));
- }
-
- if (head === "wedding" && id && tail === "share" && shareToken) {
- const weddingId = check(params.weddingId, id);
- if (!weddingId.ok) return malformed(weddingId.error);
- const token = check(params.shareToken, shareToken);
- if (!token.ok) return malformed(token.error);
- return guarded(request, () =>
- deleteShare(db, weddingId.value, tokenOf(request), token.value),
- );
- }
-
- return NextResponse.json({ error: "No such endpoint." }, { status: 404 });
-}
diff --git a/suite/app/api/sync/route.test.ts b/suite/app/api/sync/route.test.ts
deleted file mode 100644
index e35f020..0000000
--- a/suite/app/api/sync/route.test.ts
+++ /dev/null
@@ -1,169 +0,0 @@
-// @vitest-environment node
-import { beforeAll, expect, test } from "vitest";
-
-/**
- * The sync API, driven the way a browser drives it.
- *
- * Everything under `lib/sync` is tested against the in-memory store, and the
- * migrations against a real Postgres — but nothing exercised the route itself:
- * the parsing, the ordering of the rate limit against validation, the bearer
- * header, the path matching. A 500 reported from the running application came
- * from that gap, so it is now covered end to end.
- */
-
-// Read at module load by `lib/env`, so it has to be set before the route is
-// imported. `NODE_ENV` is "test" here, so the production guard does not fire.
-process.env["SYNC_IN_MEMORY"] = "1";
-
-const route = await import("./[...route]/route");
-
-const b64 = (bytes: Uint8Array) => Buffer.from(bytes).toString("base64");
-const random = (n: number) => crypto.getRandomValues(new Uint8Array(n));
-
-const BASE = "http://localhost/api/sync";
-
-/** The route takes its path from `params`, exactly as Next hands it over. */
-const params = (path: string) => ({ params: Promise.resolve({ route: path.split("/") }) });
-
-let id: string;
-let salt: string;
-let token: string;
-let authHash: string;
-let auth: Record;
-
-async function hashOf(writeToken: string): Promise {
- const digest = await crypto.subtle.digest("SHA-256", Buffer.from(writeToken, "base64"));
- return b64(new Uint8Array(digest));
-}
-
-const get = (path: string, headers: Record = {}) =>
- route.GET(new Request(`${BASE}/${path}`, { headers }), params(path));
-
-const post = (path: string, body: unknown, headers: Record = {}) =>
- route.POST(
- new Request(`${BASE}/${path}`, {
- method: "POST",
- headers: { "content-type": "application/json", ...headers },
- body: typeof body === "string" ? body : JSON.stringify(body),
- }),
- params(path),
- );
-
-const del = (path: string, headers: Record = {}) =>
- route.DELETE(new Request(`${BASE}/${path}`, { method: "DELETE", headers }), params(path));
-
-beforeAll(async () => {
- id = Buffer.from(random(16)).toString("base64url");
- salt = b64(random(16));
- token = b64(random(32));
- authHash = await hashOf(token);
- auth = { authorization: `Bearer ${token}` };
-});
-
-test("the whole passphrase flow works, from create to erasure", async () => {
- // Create. The one unauthenticated write.
- const created = await post("wedding", { id, salt, authHash });
- expect(created.status, await created.clone().text()).toBe(200);
-
- // Join from the other machine starts by fetching the salt.
- const gotSalt = await get(`wedding/${id}/salt`);
- expect(gotSalt.status).toBe(200);
- await expect(gotSalt.json()).resolves.toEqual({ salt });
-
- // First write of a slice expects version 0 and lands at 1.
- const pushed = await post(
- `wedding/${id}/slices`,
- { writes: [{ slice: "guests", sealed: { ciphertext: "AAAA", iv: "BBBB" }, expectedVersion: 0 }] },
- auth,
- );
- expect(pushed.status).toBe(200);
- await expect(pushed.json()).resolves.toMatchObject({
- accepted: [{ slice: "guests", version: 1 }],
- });
-
- const pulled = await get(`wedding/${id}/slices`, auth);
- expect(pulled.status).toBe(200);
-
- // An uploaded typeface, under an id shaped the way Plaque composes them.
- const uploaded = await post(
- `wedding/${id}/blob/font:crimson.ttf:1234`,
- { sealed: { ciphertext: "AAAA", iv: "BBBB" } },
- auth,
- );
- expect(uploaded.status).toBe(200);
-
- const published = await post(
- `wedding/${id}/share`,
- { token: "sharetoken1", sealed: { ciphertext: "AAAA", iv: "BBBB" } },
- auth,
- );
- expect(published.status).toBe(200);
-
- // A guest reads it with no passphrase at all.
- const guest = await get("share/sharetoken1");
- expect(guest.status).toBe(200);
-
- const erased = await del(`wedding/${id}`, auth);
- expect(erased.status).toBe(200);
-
- // Gone, and the guest link with it.
- expect((await get(`wedding/${id}/slices`, auth)).status).toBe(403);
- expect((await get("share/sharetoken1")).status).toBe(404);
-});
-
-test("a wedding id cannot be taken twice", async () => {
- const first = await post("wedding", { id: `${id}dup`, salt, authHash });
- expect(first.status).toBe(200);
- const second = await post("wedding", { id: `${id}dup`, salt, authHash });
- expect(second.status).toBe(400);
-});
-
-test("a wrong passphrase is refused, and says nothing about what exists", async () => {
- const mine = `${id}auth`;
- await post("wedding", { id: mine, salt, authHash });
-
- const wrong = { authorization: `Bearer ${b64(random(32))}` };
- const refused = await get(`wedding/${mine}/slices`, wrong);
- expect(refused.status).toBe(403);
-
- // A wedding that does not exist answers identically.
- const absent = await get(`wedding/${"z".repeat(22)}/slices`, wrong);
- expect(absent.status).toBe(403);
- expect(await refused.json()).toEqual(await absent.json());
-});
-
-test("no bearer header at all is refused rather than crashing", async () => {
- expect((await get(`wedding/${id}nohdr/slices`)).status).toBe(403);
-});
-
-/** The casts these replaced were the source of two 500s. */
-
-test("a body that is not JSON is a 400", async () => {
- const response = await post("wedding", "{not json");
- expect(response.status).toBe(400);
-});
-
-test("a null body is a 400, not a TypeError", async () => {
- const response = await post("wedding", null);
- expect(response.status).toBe(400);
-});
-
-test("a non-integer expectedVersion never reaches the store", async () => {
- const mine = `${id}ver`;
- await post("wedding", { id: mine, salt, authHash });
- const response = await post(
- `wedding/${mine}/slices`,
- { writes: [{ slice: "g", sealed: { ciphertext: "AA", iv: "BB" }, expectedVersion: "banana" }] },
- auth,
- );
- expect(response.status).toBe(400);
-});
-
-test("an id that is not a wedding id is refused before any lookup", async () => {
- const response = await get("wedding/..%2F..%2Fetc/salt");
- expect(response.status).toBe(400);
-});
-
-test("an unknown path is a 404, not a crash", async () => {
- expect((await get("nonsense")).status).toBe(404);
-});
diff --git a/suite/app/auth/callback/route.test.ts b/suite/app/auth/callback/route.test.ts
index 867a1ce..d4042bd 100644
--- a/suite/app/auth/callback/route.test.ts
+++ b/suite/app/auth/callback/route.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { sameOriginPath } from "./route";
+import { sameOriginPath, startsAWedding } from "./route";
const origin = "https://good.example";
@@ -35,3 +35,18 @@ describe("sameOriginPath", () => {
expect(sameOriginPath("http://good.example", origin)).toBe("/account");
});
});
+
+describe("startsAWedding", () => {
+ it("starts one on an ordinary sign-in", () => {
+ expect(startsAWedding("/account")).toBe(true);
+ expect(startsAWedding("/seating")).toBe(true);
+ });
+
+ it("does not on the way to an invite, which would block joining it", () => {
+ expect(startsAWedding("/invite/abc123")).toBe(false);
+ });
+
+ it("does not for a planner arriving at their clients' weddings", () => {
+ expect(startsAWedding("/weddings")).toBe(false);
+ });
+});
diff --git a/suite/app/auth/callback/route.ts b/suite/app/auth/callback/route.ts
index 52a37e6..154e143 100644
--- a/suite/app/auth/callback/route.ts
+++ b/suite/app/auth/callback/route.ts
@@ -1,5 +1,7 @@
import { NextResponse } from "next/server";
import { serverClient } from "@/lib/accounts/serverClient";
+import { accountsStore } from "@/lib/accounts/supabaseStore";
+import { firstSignInHandler } from "@/lib/accounts/handlers";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -36,36 +38,65 @@ export function sameOriginPath(next: string | null, origin: string): string {
}
}
+/**
+ * Whether signing in should start a wedding for someone who has none.
+ *
+ * Yes, except on the way to an invite — someone arriving to join their
+ * partner's wedding who was handed one of their own first could never join,
+ * a partner being on one wedding at a time — and except for a planner
+ * arriving at their weddings, who has clients rather than a wedding of their
+ * own.
+ */
+export function startsAWedding(destination: string): boolean {
+ return !destination.startsWith("/invite/") && !destination.startsWith("/weddings");
+}
+
export async function GET(request: Request) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const tokenHash = url.searchParams.get("token_hash");
const next = url.searchParams.get("next");
+ const destination = sameOriginPath(next, url.origin);
let failed = false;
+ let client: Awaited> = null;
+ let userId: string | null = null;
try {
- const client = await serverClient();
+ client = await serverClient();
if (!client) {
failed = Boolean(code || tokenHash);
} else if (tokenHash) {
// The email template can send a token hash instead of a PKCE code. This
// one carries everything needed with it, so the link works in whatever
// browser it is opened in — including the one inside a mail app.
- const { error } = await client.auth.verifyOtp({ token_hash: tokenHash, type: "email" });
+ const { data, error } = await client.auth.verifyOtp({ token_hash: tokenHash, type: "email" });
failed = Boolean(error);
+ userId = data.user?.id ?? null;
} else if (code) {
// PKCE. The verifier lives in a cookie set when the link was requested,
// so this only succeeds in the browser that asked for it.
- const { error } = await client.auth.exchangeCodeForSession(code);
+ const { data, error } = await client.auth.exchangeCodeForSession(code);
failed = Boolean(error);
+ userId = data.user?.id ?? null;
}
+
} catch (error) {
console.error("[accounts] GET /auth/callback", error);
failed = true;
}
- const destination = sameOriginPath(next, url.origin);
+ if (client && userId && !failed && startsAWedding(destination)) {
+ // Apart from the sign-in: a wedding that could not be started is not a
+ // link that did not work, and must not be reported as one. The account
+ // page offers to start it by hand.
+ try {
+ await firstSignInHandler(accountsStore(client), userId);
+ } catch (error) {
+ console.error("[accounts] GET /auth/callback: starting a wedding", error);
+ }
+ }
+
const target = new URL(destination, url.origin);
// A silent failure here is the worst outcome: the user clicked a link, was
// returned to a page saying "sign in", and had no way to know the link had
diff --git a/suite/app/binder/layout.tsx b/suite/app/binder/layout.tsx
new file mode 100644
index 0000000..e67724d
--- /dev/null
+++ b/suite/app/binder/layout.tsx
@@ -0,0 +1,18 @@
+import { LiveWedding } from "@/components/shell/LiveWedding";
+import { StoreHydrator } from "@/lib/store/StoreHydrator";
+
+/**
+ * The Binder stands apart from the planning app: no header of tools, nothing
+ * to edit — the wedding on a phone, on the day. It loads the wedding as the
+ * app does, from this phone first and then from the account when there is
+ * signal.
+ */
+export default function BinderLayout({ children }: { children: React.ReactNode }) {
+ return (
+ <>
+
+
+ {children}
+ >
+ );
+}
diff --git a/suite/app/binder/page.tsx b/suite/app/binder/page.tsx
new file mode 100644
index 0000000..f55c326
--- /dev/null
+++ b/suite/app/binder/page.tsx
@@ -0,0 +1,14 @@
+import type { Metadata, Viewport } from "next";
+import { Binder } from "@/components/binder/Binder";
+
+export const metadata: Metadata = {
+ title: "Binder",
+ description: "The wedding on the day, on a phone: what is on now, who to ring, where a guest sits, the shots to take.",
+};
+
+// The page colour, so the phone's own bar matches it.
+export const viewport: Viewport = { themeColor: "#fdfbf7" };
+
+export default function BinderPage() {
+ return ;
+}
diff --git a/suite/app/open/[wedding]/page.tsx b/suite/app/open/[wedding]/page.tsx
new file mode 100644
index 0000000..0352ed8
--- /dev/null
+++ b/suite/app/open/[wedding]/page.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+import { use, useEffect, useState } from "react";
+import { openWedding } from "@/lib/store/openWedding";
+
+/**
+ * Opening another of the account's weddings on this device.
+ *
+ * Outside the app's layout on purpose, like `/seat`: no store and no tool is
+ * loaded here, so nothing can write the wedding being left over the one being
+ * opened. The page it came from handed over its last edit as it unloaded, the
+ * same as any reload; this one swaps what is stored, then loads the app.
+ */
+export default function OpenWeddingPage({ params }: { params: Promise<{ wedding: string }> }) {
+ const { wedding } = use(params);
+ const [problem, setProblem] = useState(null);
+
+ useEffect(() => {
+ openWedding(wedding)
+ .then(() => window.location.replace("/"))
+ .catch((cause: unknown) => setProblem(cause instanceof Error ? cause.message : String(cause)));
+ }, [wedding]);
+
+ return (
+
+
Trousseau
+ {problem ? (
+
+ That wedding could not be opened: {problem}
+
+ ) : (
+
+ Opening the wedding…
+
+ )}
+
+ );
+}
diff --git a/suite/app/robots.ts b/suite/app/robots.ts
index 3d205eb..cd53308 100644
--- a/suite/app/robots.ts
+++ b/suite/app/robots.ts
@@ -5,11 +5,12 @@ import { siteUrl } from "@/lib/env";
* A guest link in a search index is the one genuinely damaging leak available
* here, so `/seat` is refused twice: the page sets `robots: noindex` itself,
* and it is disallowed here as well. The fragment that decrypts it would never
- * reach a crawler, but the token and the names it resolves to would.
+ * reach a crawler, but the token and the names it resolves to would. A
+ * supplier's link, under `/supplier`, is refused the same way.
*/
export default function robots(): MetadataRoute.Robots {
return {
- rules: [{ userAgent: "*", allow: "/", disallow: ["/seat/", "/api/"] }],
+ rules: [{ userAgent: "*", allow: "/", disallow: ["/seat/", "/supplier/", "/api/"] }],
sitemap: `${siteUrl()}/sitemap.xml`,
};
}
diff --git a/suite/app/supplier/[token]/page.tsx b/suite/app/supplier/[token]/page.tsx
new file mode 100644
index 0000000..166c5f3
--- /dev/null
+++ b/suite/app/supplier/[token]/page.tsx
@@ -0,0 +1,22 @@
+import type { Metadata } from "next";
+import { SupplierSheet } from "@/components/suppliers/SupplierSheet";
+import { Footer } from "@/components/shell/Footer";
+
+export const metadata: Metadata = {
+ title: "Your call sheet",
+ description: "When to arrive, and what you are doing.",
+ // A supplier's link is not for a search index, and the fragment that
+ // decrypts it would never reach one anyway.
+ robots: { index: false, follow: false },
+};
+
+export default async function SupplierPage({ params }: { params: Promise<{ token: string }> }) {
+ const { token } = await params;
+ return (
+ <>
+
+ {/* What a supplier's link publishes, and what it keeps back, is in the policy. */}
+
+ >
+ );
+}
diff --git a/suite/apps/brigade/App.tsx b/suite/apps/brigade/App.tsx
index 79ce6c3..43bcd26 100644
--- a/suite/apps/brigade/App.tsx
+++ b/suite/apps/brigade/App.tsx
@@ -1,9 +1,8 @@
"use client";
-import { useEffect, useState } from "react";
+import { useSelectFromAddress } from "@/components/shell/useSelectFromAddress";
import { Board } from "./render/screen/Board";
-import { createPersister, restore } from "./state/persist";
-import { getDoc, useStore } from "./state/store";
+import { useBrigadeDoc, useStore } from "./state/store";
import { Announcer } from "./ui/Announcer";
import { Button } from "@/components/ui/fields";
import { ChromeFill } from "@/components/shell/chrome";
@@ -13,61 +12,24 @@ import { Sidebar } from "./ui/Sidebar";
import { WarningsList } from "./ui/WarningsList";
import styles from "./App.module.css";
-const persister = createPersister();
-
export function App() {
- const doc = useStore(getDoc);
+ const doc = useBrigadeDoc();
const notice = useStore((state) => state.notice);
const filter = useStore((state) => state.filter);
const setFilter = useStore((state) => state.setFilter);
const setNotice = useStore((state) => state.setNotice);
- // Bring back the last session once, on boot.
- /**
- * Nothing is written until the saved day has been read back.
- *
- * The autosave effect below runs on the first render, when the store still
- * holds the empty document it was created with — and the effect that restores
- * the real one has only just run, so this render's `doc` is still the empty
- * one. Standalone, that was harmless: a second render followed immediately
- * and replaced the pending write before the debounce elapsed, and in any case
- * a write that arrived too early was dropped by a store that had not loaded.
- *
- * Neither of those safety nets exists now. The shared document is ready
- * before the tool mounts, so an early write lands, and it lands on a real
- * wedding — blanking the day and, through the mirror, the couple and venue
- * with it. A restore is a read; writing before it finishes is never right.
- */
- const [restored, setRestored] = useState(false);
- const canUndo = useStore((state) => state.canUndo());
- const canRedo = useStore((state) => state.canRedo());
-
- useEffect(() => {
- useStore.getState().loadDoc(restore());
- setRestored(true);
- }, []);
+ // Delegation keeps no copy and no history of its own: its edits are on the
+ // wedding's, so that is the one the header's undo drives. The stack is
+ // shared, so saying what the next undo takes back is what makes it safe.
- // Autosave into the shared wedding, debounced, and flushed if the window goes
- // away mid-edit.
- useEffect(() => {
- if (!restored) return;
- persister.schedule(doc);
- }, [doc, restored]);
- useEffect(() => {
- const flush = () => persister.flush();
- window.addEventListener("beforeunload", flush);
- // The listener is not enough on its own. Each tool used to *be* the page,
- // so unmounting only ever happened as the page went away and `beforeunload`
- // had already flushed. They are tabs now: switching to another tool unmounts
- // this one with no unload event, which would drop whatever the debounce was
- // still holding.
- return () => {
- window.removeEventListener("beforeunload", flush);
- flush();
- };
- }, []);
+ // A link to one job — the command palette's — opens on it.
+ useSelectFromAddress(useStore.getState().select);
- const unassigned = doc.jobs.filter((job) => job.personIds.length === 0).length;
+ // The jobs on the day. A task before it with nobody named is the couple's
+ // own, kept on the Checklist, and is not a gap in the crew.
+ const onTheDay = doc.jobs.filter((job) => job.blockId !== null);
+ const unassigned = onTheDay.filter((job) => job.personIds.length === 0).length;
return (
@@ -81,11 +43,11 @@ export function App() {
{/* Nothing to report leaves nothing behind, rather than an empty
styled span sitting in the header as a stray mark. */}
- {doc.jobs.length > 0 && (
+ {onTheDay.length > 0 && (
0 ? styles.over : styles.slack}>
{unassigned > 0
- ? `${unassigned} of ${doc.jobs.length} jobs have nobody`
- : `${doc.jobs.length} jobs, all covered`}
+ ? `${unassigned} of ${onTheDay.length} jobs have nobody`
+ : `${onTheDay.length} jobs, all covered`}
)}
- useStore.getState().undo()}
- onRedo={() => useStore.getState().redo()}
- />
+
{notice && (
diff --git a/suite/apps/brigade/core/import/reconcile.ts b/suite/apps/brigade/core/import/reconcile.ts
index 759b3dc..0f290e2 100644
--- a/suite/apps/brigade/core/import/reconcile.ts
+++ b/suite/apps/brigade/core/import/reconcile.ts
@@ -51,6 +51,7 @@ export function reconcile(
deposit: null,
depositPaidOn: "",
balanceDueOn: "",
+ balancePaidOn: "",
confirmedOn: "",
}));
diff --git a/suite/apps/brigade/core/jobs/coverage.test.ts b/suite/apps/brigade/core/jobs/coverage.test.ts
index 403a0b0..69a87e7 100644
--- a/suite/apps/brigade/core/jobs/coverage.test.ts
+++ b/suite/apps/brigade/core/jobs/coverage.test.ts
@@ -7,7 +7,7 @@ function withJobs(doc: BrigadeDoc, jobs: Job[]): BrigadeDoc {
return { ...doc, jobs };
}
-function job(id: string, blockId: string, personIds: string[], teamId: string | null = null): Job {
+function job(id: string, blockId: string | null, personIds: string[], teamId: string | null = null): Job {
return { id, blockId, label: id, notes: "", teamId, personIds };
}
@@ -85,4 +85,14 @@ describe("coverage", () => {
expect(found[0]?.kind).toBe("orphaned");
expect(blocking(found)).toHaveLength(1);
});
+
+ it("leaves a job that was never on the day alone: it is a task, not a lost one", () => {
+ const doc = withJobs(sampleDoc(), [job("job-a", null, ["per-ana"])]);
+ expect(coverage(doc)).toEqual([]);
+ });
+
+ it("says nothing of a task with nobody named: it is the couple's own to do", () => {
+ const doc = withJobs(sampleDoc(), [job("job-a", null, [])]);
+ expect(coverage(doc)).toEqual([]);
+ });
});
diff --git a/suite/apps/brigade/core/jobs/coverage.ts b/suite/apps/brigade/core/jobs/coverage.ts
index 6aad446..0a5d64b 100644
--- a/suite/apps/brigade/core/jobs/coverage.ts
+++ b/suite/apps/brigade/core/jobs/coverage.ts
@@ -44,7 +44,9 @@ export function coverage(doc: BrigadeDoc): Warning[] {
continue;
}
- if (job.personIds.length > 0) continue;
+ // A task off the day with nobody named is the couple's own to do, as What
+ // is left reads it; the Checklist keeps track of those.
+ if (job.personIds.length > 0 || job.blockId === null) continue;
found.push(
job.teamId === null
? {
diff --git a/suite/apps/brigade/core/model/types.ts b/suite/apps/brigade/core/model/types.ts
index bba6dcb..32d8ff7 100644
--- a/suite/apps/brigade/core/model/types.ts
+++ b/suite/apps/brigade/core/model/types.ts
@@ -51,6 +51,7 @@ export interface Team {
/** ISO dates, or "" for "not yet". */
depositPaidOn: string;
balanceDueOn: string;
+ balancePaidOn: string;
confirmedOn: string;
}
@@ -104,10 +105,11 @@ export interface BrigadeDoc {
/**
* A job whose block is no longer in the day. Derived, never stored: the day is
- * what changes, and a stored flag would go stale the moment it did.
+ * what changes, and a stored flag would go stale the moment it did. A job with
+ * no block was never on the day, and is a task, not an orphan.
*/
export function isOrphan(doc: BrigadeDoc, job: Job): boolean {
- return !doc.day?.blocks.some((block) => block.id === job.blockId);
+ return job.blockId !== null && !doc.day?.blocks.some((block) => block.id === job.blockId);
}
/** The block a job hangs off, or null if the day no longer has it. */
diff --git a/suite/apps/brigade/render/screen/Board.module.css b/suite/apps/brigade/render/screen/Board.module.css
index 838788a..7fb61d3 100644
--- a/suite/apps/brigade/render/screen/Board.module.css
+++ b/suite/apps/brigade/render/screen/Board.module.css
@@ -33,6 +33,7 @@
}
.block,
+.tasks,
.orphans {
max-width: 780px;
margin: 0 auto var(--sp-3);
@@ -45,15 +46,35 @@
border-color: var(--danger);
}
+.tasksHead,
.orphanHead {
margin: 0;
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border);
- color: var(--danger);
+ color: var(--text-muted);
font-size: var(--text-sm);
font-weight: 600;
}
+.orphanHead {
+ color: var(--danger);
+}
+
+.tasksHead {
+ cursor: pointer;
+}
+
+.tasks:not([open]) > .tasksHead {
+ border-bottom: 0;
+}
+
+.tasksNote {
+ margin: 0;
+ padding: var(--sp-2) var(--sp-3) 0;
+ font-size: var(--text-sm);
+ color: var(--text-muted);
+}
+
.head {
display: flex;
align-items: baseline;
diff --git a/suite/apps/brigade/render/screen/Board.tsx b/suite/apps/brigade/render/screen/Board.tsx
index a0f185e..fac5018 100644
--- a/suite/apps/brigade/render/screen/Board.tsx
+++ b/suite/apps/brigade/render/screen/Board.tsx
@@ -1,7 +1,8 @@
+import Link from "next/link";
import { useMemo } from "react";
import { assigneeNames, type DayBlock, type Job } from "../../core/model/types";
import { formatClock } from "../../core/time/minutes";
-import { getDoc, selectCover, useStore } from "../../state/store";
+import { useBrigadeDoc, useCover, useStore } from "../../state/store";
import styles from "./Board.module.css";
/**
@@ -12,8 +13,8 @@ import styles from "./Board.module.css";
* stay visible, because an empty block is where the next job goes.
*/
export function Board() {
- const doc = useStore(getDoc);
- const cover = useStore(selectCover);
+ const doc = useBrigadeDoc();
+ const cover = useCover();
const selectedJobId = useStore((state) => state.selectedJobId);
const filter = useStore((state) => state.filter);
const select = useStore((state) => state.select);
@@ -33,7 +34,8 @@ export function Board() {
const held = job.teamId === filter.teamId || job.personIds.some((id) => members.includes(id));
if (!held) return false;
}
- if (filter.unassignedOnly && job.personIds.length > 0) return false;
+ // Jobs on the day with nobody on them: a task before it is the couple's own.
+ if (filter.unassignedOnly && (job.personIds.length > 0 || job.blockId === null)) return false;
return true;
};
@@ -62,17 +64,24 @@ export function Board() {
return (
+ {/* Folded: the tasks before the day are the Checklist's, and here only
+ to hand one to somebody. Open while filtering, or with one picked. */}
{tasks.length > 0 && (
-
-
- Not tied to the day — {tasks.length} job{tasks.length === 1 ? "" : "s"}
-
+ job.id === selectedJobId)}>
+
+ Before the day — {tasks.length} task{tasks.length === 1 ? "" : "s"}
+
+
+ Kept on the
+ Checklist
+ . Pick one to give it to somebody.
+