Lalabase
Tickets, Doku, Zeiterfassung und Kundengespräche an einem Ort, durchsuchbar per KI. Für Softwarefirmen mit Kundenprojekten. Open Source: auf deinem eigenen Server oder in unserer Cloud in Deutschland.
翻譯準備中
尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證製作者的介紹僅有上面的一句話。 更多詳情請閱讀儲存庫 README。 儲存庫 README ↗
正在載入完整 README。
檢視完整 README ↗Branded proposal, contract and event planning portal for independent DJs. The source of truth for scope and behavior is docs/Flux-DJ-V1-Spec.md.
Current status: Phase 1 complete (steps 1 to 6) and Phase 2 step 1 (contract templates and contract drafts). The stack is scaffolded and the data foundation is in place:
Tenancy: tenants, memberships, clients, events, event contacts and event access.
Catalog: gear items with private image/video media, packages with included gear, logistics questions with constrained rules, and reusable proposal templates.
Offers and pricing: a shared deterministic pricing module, one editable proposal draft per event, previews, and offer snapshots that freeze once.
Staff interface: magic-link login, and screens for gear (with photos and videos), packages, questions and rules, templates, clients and events. It also has a proposal builder with a responsive live preview.
Proposals end to end: staff send an offer, the client opens a private link with no account, edits with autosave, and submits for review. Staff then approve the exact submission or send a revised offer. A durable email outbox delivers locally through Mailpit.
Contracts, step 1: versioned contract templates with immutable published versions, and contract drafts generated from an approved selection and previewed by staff. Drafts are frozen when generated. They are never sent.
Contracts, later steps: sending with verified client access, resend and void, signing by the frozen signer with immutable evidence, and signed PDFs emailed to both parties. Each template version is published either as DEMO (test wording, labelled everywhere) or for client use (the business's own agreement, approved by the owner).
Manual payment tracking: staff record payments received elsewhere, invalidate wrong entries with a reason, and add an optional invoice link. Totals come from the authoritative contract; the client sees a read-only summary.
Booking confirmation: each business chooses (owner only) whether a booking is confirmed when the contract is signed, or when it is signed and the deposit is received (the default). Each contract freezes the policy, and Flux DJ confirms the booking automatically, once, with a confirmation email.
Planning foundation: reusable planning templates (Wedding and Simple Party starters, added on request), one plan per event created when it is booked, stages and moments in event order, a small Event basics editor, frozen proposal answers shown as "Already provided", and progress computed by the database over the sections that exist so far.
Stage details: timing, location and a few preparation details inside the ceremony, cocktail, reception entrance, dinner, party and closing stages, with explicit next-day times and timing warnings.
Planning deadline: client edits close a set number of days before the event (owner setting, 14 by default), enforced by the database; staff keep editing, and can move one event's deadline or reopen client editing temporarily, with a reason, all audited.
DJ run sheet: a staff-only live view for gig night and an on-demand PDF, both from one read-only projection of the latest saved plan.
Invite-only DJ onboarding: a platform administrator invites a DJ business owner by email; the DJ verifies that address, names the business and chooses its web address, and gets a new, empty workspace. See "DJ invitations and new workspaces".
Workspace suspension: a platform administrator can suspend a workspace (blocking all access to it without deleting anything) and restore it. See "Suspending and restoring a workspace".
Business branding: each owner uploads a logo and picks a primary colour in Settings; the workspace header and client pages use them. See "Business branding".
Everything has row-level security and tests. Not built yet: payment processing, PWA and offline access, reminders, cancellations and amendments. Approval and contract drafts are not bookings.
Next.js (App Router) · TypeScript · Tailwind CSS · shadcn/ui · Supabase
(Postgres, Auth) · Zod. Package versions are pinned in package.json and
pnpm-lock.yaml.
packageManager)The Supabase CLI is a dev dependency, so no global install is needed.
pnpm install
pnpm db:start # starts local Supabase, applies migrations and seed.sql
cp .env.example .env.local
pnpm exec supabase status # copy the local API URL, anon key and service role key into .env.local
pnpm dev # http://127.0.0.1:3000
The first pnpm db:start downloads the Supabase Docker images and takes a
few minutes. Local Studio runs at http://127.0.0.1:54323 and captured emails
(magic links) at http://127.0.0.1:54324.
Useful commands:
| Command | What it does |
|---|---|
pnpm db:reset | Recreate the local database from migrations, then apply supabase/seed.sql |
pnpm db:types | Regenerate src/lib/supabase/database.types.ts after a migration |
pnpm db:stop | Stop the local Supabase containers |
pnpm db:versions | Show the running service versions next to the hosted ones (read only; fails if they differ) |
Local should run the same service versions as the hosted project (this is
how a PostgREST behaviour difference was caught; see "Version conflicts"
below). When the repo is linked, supabase link records the hosted
versions in the git-ignored supabase/.temp/*-version files, and
supabase start uses them. So:
pnpm db:start (or a stop/start), run pnpm db:versions. It
lists PostgREST, Auth, Storage and Postgres as running locally next to the
hosted versions, and exits non-zero if they differ.pnpm db:stop && pnpm db:start. Data is
kept in the Docker volumes; db:reset is never needed for this.pnpm exec supabase link --project-ref <ref>, then restart and check
again. Nothing changes those files silently: the link command is the only
writer.rest-version and storage-version out of
supabase/.temp, restart, put them back, and restart again before normal
work. pnpm db:versions shows which state you are in.Saves that carry a version ("Someone else saved changes first") raise
SQLSTATE PT409 in the database, which PostgREST answers at once with HTTP
409 and code PT409; describeDbError turns it into the conflict message,
and forms keep what was typed. Never use serialization_failure (40001)
for this: PostgREST v14 (the hosted version) retries 40001 as a transient
failure, so a stale save would never answer and the database would roll
back thousands of transactions a second. Migration
20261017000100_version_conflict_errors.sql converted every such check; the
pgTAP guard 32_version_conflicts fails if one comes back. Genuine
serialization failures raised by Postgres itself are not affected.
Everything above touches only the local Docker database. A CLI link to the
hosted project lives only in the git-ignored supabase/.temp. Commands with
--linked, db push above all, act on the hosted database: run
db push --linked --dry-run first, and never db reset or --include-seed
against it. See "Hosted setup" below.
supabase/seed.sql creates two isolated tenants for local development:
| Tenant slug | Owner login | Notes |
|---|---|---|
bouprod | [email protected] | One event with a verified client |
other-dj | [email protected] | One event for the same couple, with no client access |
Both tenants have tax categories configured: BOUPROD maps standard to
GST and QST, and Other DJ maps it to HST.
BOUPROD also gets a demo catalog: five gear items, three packages (Essential, Signature, Premium), four logistics questions with three rules, and a "Wedding (DEMO)" template. Every record is tagged DEMO. Prices and wording are placeholders that Pavel has not reviewed. The catalog mirrors the spec's pricing example, where separate ceremony and cocktail spaces each require an additional-location speaker. No media files are seeded.
The client [email protected] has verified access to the BOUPROD event
only. Seeded users have no passwords. Login is magic-link only, and the login
screens arrive in Phase 2.
With pnpm db:start done and .env.local filled in, follow these steps:
pnpm dev and open http://127.0.0.1:3000/login. Use 127.0.0.1, not
localhost, so the magic-link cookie matches the configured site URL.[email protected] and click Email me a sign-in link.[email protected] signs in to the second tenant. Its workspace is at
/staff/other-dj, and each owner gets a 404 for the other's workspace.
Unknown emails get the same on-screen message, but no email and no account.
| Screen | URL (BOUPROD) |
|---|---|
| Dashboard | http://127.0.0.1:3000/staff/bouprod |
| Gear (photos and videos on each item) | http://127.0.0.1:3000/staff/bouprod/gear |
| Packages | http://127.0.0.1:3000/staff/bouprod/packages |
| Questions and rules | http://127.0.0.1:3000/staff/bouprod/questions |
| Templates | http://127.0.0.1:3000/staff/bouprod/templates |
| Contract templates | http://127.0.0.1:3000/staff/bouprod/contract-templates |
| Clients | http://127.0.0.1:3000/staff/bouprod/clients |
| Events, which is where proposals start | http://127.0.0.1:3000/staff/bouprod/events |
The seeded event "Alex & Sam Wedding" can start a proposal from the
"Wedding (DEMO)" template. Each proposal builder links to a full-page
preview at /staff/bouprod/proposals/{id}/preview.
[email protected]), event and expiry, then click Send
proposal now.http://127.0.0.1:3000/bouprod/p#…./bouprod/proposals/{id} with no account and
no token in the URL.{{client.nickname}} into a section and click Save
draft to see it rejected. Remove it and save./staff/bouprod/contracts/{id}. Check the totals,
the 50% deposit and the balance, then narrow the window to phone width.Generating a draft sends no email, does not book the event and gives the client no access. Clients and anonymous users cannot see templates or drafts.
Delivery problems show under Emails
(http://127.0.0.1:3000/staff/bouprod/emails), with Retry and Deliver
due emails now buttons. Emails go out right after each action. To retry
pending mail in the background, run pnpm outbox:work alongside
pnpm dev.
https:// invoice link from any tool (Wave or other) and save./my or the contract page
and follow Plan your event. Event basics, stage details, songs,
participants, the MC, speeches, contacts, preferences, activities and
dedications save automatically.How it works:
private.planning_library()), separate from labels and order. A plan is a
copy, so template edits never reach existing plans.private.ensure_event_plan from a trigger on
booking_confirmed_at, in the booking transaction (one plan per event,
under the event lock, never overwritten). The migration backfills events
already booked (Event basics only).event_plan_imports). Nothing is mapped
into planning fields by guessing.client_planning_view and client_save_plan_basics with their
own session: verified event access, booked, nothing archived. Payment
corrections after booking keep access.Editors are chosen by stage key (ceremony, cocktail, reception_entrance,
dinner, party, closing), never by label, and save on the stage item, so
hiding a stage keeps its answers. The moments they cover (ceremony_details,
cocktail_details, dinner_details, closing_instructions) show "Included
in the details above". Fields are a fixed typed list
(private.planning_editor_fields), mirrored in src/lib/planning/stages.ts.
| Stage | Fields | Needed to complete |
|---|---|---|
| Ceremony | location (same as the event venue, or another place) and room/outdoor area; guest arrival, start, end; officiant name and contact; microphones (not needed / needed / not sure, discuss with DJ) and who speaks; instructions | location, guest arrival time, start time, microphone needs ("discuss" stays open) |
| Cocktail | location and area; start, end; atmosphere or music style; instructions | location, start time |
| Reception entrance | when guests enter the reception space; where the reception takes place (free text, optional); "Are you planning a formal grand entrance?" (yes / no / not sure yet); while yes, the planned grand entrance time | guest entry time (whatever the decision); the decision; while yes, the grand entrance time. No is not applicable; not sure stays open ("Not decided yet") |
| Dinner | location and area; start, end; meal style; guest count (Event basics' count, or another number) | location, start time, guest count |
| Party | location and area; start, end; additional evening guests | location, start time |
| Closing | finish (a time, Event basics' end time, or discuss with DJ); closing instructions | finish time ("discuss" stays open) |
TimeField): the mark is shown only where it can
matter (offersNextDay: already set, a time before 06:00, an end that isn't
after its start, or an error), so daytime and evening times are a single
time picker. It is never checked for the client, and saved answers are
unchanged.private.plan_timeline_warnings compares visible stages in the staff's
order and flags a stage that starts before the previous one starts or ends
(and guest arrival or entrance order inside a stage). Times never reorder
stages.Songs are typed in by hand: title and artist, with an optional version, link and notes. There is no music service: nothing is searched, fetched or played, and an optional link never replaces title and artist. Each editor is chosen by moment key and opens as a card inside its stage, in the plan's order; staff see the same editors under Stage details and music.
| Editor | Moments | Alternative to entering songs |
|---|---|---|
| Background music | arrival_music, pre_ceremony_music, cocktail_music, dinner_music | DJ's choice |
| Requests | must_play, play_if_possible | No requests |
| Do not play | do_not_play | Nothing to exclude |
| Moment songs (with an optional cue label and instructions per song) | processional (with who walks in, below), couple_entrance, ceremony_signing, recessional, entrance_music, cake_cutting, first_dance, family_dances, other_dances, last_dances, final_song | DJ's choice, Not applicable, Not sure (discuss with DJ) |
To try it: open Plan your event, open Party, then Must play. Use Add a song, Move up / Move down, Edit, Remove (with Undo) and reload. In Play if possible, open Paste a list, paste a few lines (one with no dash) and Preview. Correct the flagged line, then Import. Add a Must play song to Do not play to see the warning. On a phone-sized window nothing should scroll sideways.
How it works:
event_plan_responses.answers, revision-checked, like stage details) as
{"songs": [...], "choice": "..."}. A song has an id (a UUID made in the
browser, unique in its list), title, artist and optional version,
link, notes and, for moment songs, cue. The id is the only identity,
never the title or position; order is the array order. No catalog table:
the same title can be entered in several lists.private.normalize_plan_music, mirrored in
src/lib/planning/music.ts). Title and artist are needed (200 characters
each); version 100, cue 80, notes 500, link 1,000. Links must be
https:// with a host name and no credentials (private.is_https_url).
They are shown as "Open link (host)" (on the run sheet, web and PDF, as
"Song link (host)"), open in a new tab with
noopener noreferrer, and are never fetched. A list holds up to 150 songs,
a moment up to 12. Unknown fields, repeated or malformed ids and other
editors' choices are refused.Artist - Title (a hyphen, en
dash or em dash with spaces around it). Blank lines and leading numbers or
bullets are ignored. A line without a separator, or with more than one,
is flagged. Flagged lines can't be imported until they are corrected or
marked "Looks right". The preview gives every line its id up front, and
importing closes the preview. The songs then save like typed ones, through
the same validation and access checks.Names and pronunciation guides are plain text (no recordings or speech synthesis). Each editor opens as a card inside its stage, like songs.
| Moment | Editor | Each entry | Alternatives |
|---|---|---|---|
| Processional (Ceremony) | Who walks in, in order, either partner included, each with their song; then the processional's songs, each once | Name or names (a person, a pair or a group, for example "Alex with their mother Dana"); role or relationship, a song (one already listed, one typed in right there, or none yet), instructions for the DJ (all optional). No pronunciation guide: nobody is announced while walking in (older guides are kept and shown to staff) | Discuss with DJ; the moment's "not applicable" covers both |
| Grand entrance order (Introductions, Reception entrance) | Everyone introduced, in order, each with their song; then the grand entrance songs, each once. Shown and counted only while a grand entrance is planned | Names exactly as announced; role, name pronunciation, how to announce them, instructions for the DJ or MC (all optional); music (needed): Choose a song (one already listed, an older plan's Entrance music song, or one typed in right there), DJ's choice, or Not decided yet (the default; stays open). Stored as song_id, "music": "dj_choice" or neither; an entry without a song is never read as the DJ's choice (20261113000100) | Nobody introduced; discuss with DJ |
| MC (Reception entrance) | Who handles MC duties: your DJ, someone else, your DJ and another MC sharing them, or not sure yet (discuss with your DJ). The DJ choices name the event's DJ when one is configured ("DJ Boukine (your DJ) will be the MC"), otherwise "Your DJ"; staff see "the DJ" (20261121000100) | Someone else or shared: the MC's name (needed), phone or email; instructions. No pronunciation guide (older guides are kept and shown to staff). "No MC" is no longer offered; a saved one stays selected and valid until changed | |
| Speeches and toasts (Dinner, Program or Party) | Speakers in order | Speaker; role, pronunciation; timing: an exact time, a moment ("After the main course") or not decided; minutes, microphone/AV notes, instructions (all optional) | No speeches; discuss with DJ |
Every place a client types a song uses the same fields
(src/components/planning/song-fields.tsx): title, artist, version and
link, plus notes or a cue where that editor keeps them. Enter a new song…
with a Processional or grand entrance entry takes a version and a link too,
checked like the songs list before the song joins it.
To try it: in Ceremony → Processional, Add person or group with Enter a new song…, then add another who walks to the same song (pick it from the list), and change a listed person's song with Use this song. In Reception entrance, add songs to Entrance music, then Add introduction and pick one of them (two introductions can share a song). Try Remove on a linked song (it's disabled, naming who uses it), rename it, and see the introduction follow. (New Wedding plans: choose Yes, we're planning a grand entrance, then add people to the Grand entrance order with Enter a new song…; No or We're not sure yet set the order aside, keeping it.) Set the MC, and in Dinner → Speeches and toasts add speakers with a time (tick Next day after midnight), a moment, or "Not decided yet".
How it works:
{"songs", "choice", "participants", "participants_choice"};
saving people keeps its songs and the reverse. Introductions and speeches
store {"entries", "choice"}; the MC {"mc", "name", "pronunciation", "contact", "notes"} (someone else's details only while someone else is
the MC). Entries have stable ids like songs.withNewSongs). People
share a song by linking the same one. The Wedding starter has no separate
Couple entrance since 2026-11-11 (20261111000100); plans and templates
that have one keep it, and the couple's Processional entry can still link
its song. At the reception, the grand entrance order (Introductions)
is the participant editor and works the same way (addNewSongs), with an
optional name pronunciation and announcement; the Wedding starter has no
separate Participants and names or Entrance music since 2026-11-12.
In older plans, Participants and names shows "Included in Introductions",
saves nothing and isn't counted. The MC's
contact stays with the MC; a later contacts editor can read it from there.song_id. Processional entries can
point at Processional's or Couple entrance's songs (same ceremony, same
plan); introductions at their own songs or an older plan's Entrance music.
Titles, cues and order are read through the link, so renaming or
reordering songs never rewrites names. On every save, under the plan row
lock all plan saves take, the database refuses a link to anything else
(another moment, another event, a removed song) and refuses removing a
linked song (from Processional, Couple entrance or Entrance music), naming
the entries using it. The UI disables Remove on a linked song and says
where to change the link. A stale tab that removes one gets the refusal and
can Undo. A hidden Couple entrance or Entrance music keeps its songs and
the links; the entries show the song as not in the active plan.| Section | Editor | Needed to complete |
|---|---|---|
| Contacts and vendors (general) | Day-of contact: one of the event's contacts (by reference, with an optional phone for the day), someone else (name, phone, relationship), or "Not decided yet". Vendors in order: role (planner or coordinator, venue, photographer, videographer, caterer, live musician, other), person's name and/or business, a phone or an email (at least one), and coordination notes shared with the client | A day-of contact with a phone; vendors listed, or "No additional vendor contacts" (not applicable) |
| DJ expectations and overall preferences (general) | Atmosphere and what matters most; DJ microphone interaction (minimal, a little interaction, energetic and interactive, not sure yet; a saved "occasional announcements" stays valid but is no longer offered); announcement language (stored with Event basics); explicit lyrics (clean only, allowed, discuss); guest requests (welcome, never anything on Do not play; not welcome; discuss); practical preferences | Interaction, announcement language, lyrics and requests; "discuss" stays open |
| Music preferences (Party) | Styles (pop, dance, hip-hop and R&B, rock, disco and funk, country, Latin, house and electronic, throwbacks) and another style, or DJ's choice; favourite artists; dance-floor atmosphere; slow songs (DJ's choice, none, a few, discuss) | Styles or DJ's choice; a slow-song choice ("discuss" stays open) |
To try it: open Contacts and vendors. The MC and officiant appear under "Already in your plan" without being entered again. Choose One of the event's contacts, pick someone without a phone on file and add Phone for the day. Add vendors, including one with a bad email and one with no phone or email (refused), then reorder and remove with Undo. In DJ expectations, choose the announcement language; Event basics doesn't ask it again. In Party → Music preferences, pick styles (DJ's choice is disabled until they're cleared).
How it works:
contacts, preferences, music_style).
Vendors have stable ids; reordering, retries and stale tabs behave as
elsewhere.event_clients joined with clients, not archived) with name and phone
only. Choosing one stores only its client id. Every save checks, under the
plan lock, that the id is one of this event's current contacts, so another
event's or another business's contact is refused. People are never
matched or merged by name.LanguageBinding), so the two sections never
save it separately; when DJ expectations is hidden or absent, Event basics
asks it (languageAskedInPreferences). Nothing is moved or rewritten.
Specific songs stay in Must play, Play if possible and Do not play.+, spaces, dots,
dashes or brackets (private.is_phone). Emails: a plausible address
(private.is_email); nothing is ever contacted. A vendor needs a role, a
person's name or a business, and a phone or an email. The last is checked
when saving (private.check_plan_vendor_reach, client and staff) against
the stored answers: a vendor stored without either, before that rule, stays
as it is, even renamed, so it never blocks the section's other answers; once
it has one it keeps one. Optional fields never block completion.| Moment | Fields | Needed to complete |
|---|---|---|
| Arrival details (Guest arrival) | Location: same as the event venue, same as the ceremony, or another place, plus room or area; arrival time: same as the ceremony's guest arrival, or a start and end time; welcome instructions; announcement wording; or "No separate arrival arrangements" | Location and arrival time, each resolved; or "no separate arrangements" (not applicable) |
| Program details (Speeches and program) | Optional overall start and end, host and pronunciation, instructions; ordered agenda items: title, timing, minutes, presenter, pronunciation, instructions | Every item has a time or a moment; or "No formal program" (not applicable) |
| Activities (Dinner and Party, one shared editor) | Ordered activities: name (suggestions such as Shoe game, Bouquet toss or Centrepiece giveaway, or anything typed, such as a cultural tradition), timing, minutes, host, participants, instructions, optional song under Music (optional) → Add a song (title, artist, version, link; shown when editing an activity that has one; Remove song keeps the activity). No pronunciation guide (older guides are kept and shown to staff) | Every activity has a time or a moment; or "No activities" |
| Dedications (Party) | Ordered dedications: for whom, relationship, pronunciation, announcement message, song (Music (needed to complete) → Add a song, as for activities), timing (any time during the party, a time, a moment or undecided), instructions | Every dedication has a song and a timing; or "No dedications" |
Timing everywhere is an exact time (local to the event, with an explicit Next day after midnight), a moment such as "After dessert", or "Not decided yet", which keeps the section open. "Discuss with DJ" also stays open. Speeches, cake cutting, special dances and arrival music keep their own cards.
To try it: staff add Guest arrival and Speeches and program (with their detail moments) to a plan under Structure. As the client, in Arrival details choose "Same as the ceremony" for the place and time. In Program details add an agenda item with a time and one with a moment. In Dinner → Activities add the Shoe game with a song. In Party → Dedications add one without a song, and see it stay open until a song is added.
How it works:
arrival, program, activities, dedications). Entries have stable
ids; reordering, Undo, retries and stale tabs behave as elsewhere.location_source ceremony or
event_venue, and time_source ceremony, store the choice, never a
copy. They resolve when read from the visible Ceremony's details. While
the Ceremony lacks them or is hidden, they stay open ("Not in the Ceremony
details yet"). Ceremony guest arrival stays in the Ceremony; this card is
for general arrival. Times are validated like stage details (the end after
the start, after midnight only with Next day).https:// and are never fetched.
Unfinished dedications are kept as entered: no song or time is invented.progressPercent) over the database's counts: nearest whole percent, never
0% once something is answered nor 100% while something is open. It is a
display only; completion rules are the database's.The client can change planning until the event's deadline; after it, planning is read-only for them ("Planning is read-only. Contact your DJ for changes.") and every saved answer stays readable. Staff edit as before.
| Setting or action | Who | Limits |
|---|---|---|
| Business setting: days before the event (Settings → Planning deadline) | Owner | Whole days, 0 to 365 (0 closes at the start of the event day). Default 14. Versioned |
| Change this event's deadline (days before the event) | Staff | 0 to 365 days; a reason (1 to 500 characters) |
| Recalculate the deadline after the event's date or time zone changed | Staff | Explicit confirmation and a reason |
| Reopen client editing until a date and time | Staff | Only after the deadline; in the future, at most 14 days from now; event's local time; a reason |
| Close client editing now (ends a reopening early) | Staff | A reason. Never closes a plan before its deadline |
To try it: as the owner, open Settings → Planning deadline. On an event's planning page, the Client editing card shows Open, Read-only or Reopened, the deadline in the event's time zone, the actions above (each in a confirmation explaining its effect) and the history with reasons and who. The event page's Planning card shows the same state in one line. As the client, the planning page says when editing closes, or that it is read-only, or until when it was reopened.
How it works:
event_plans.client_cutoff_days) and stores
the deadline (events.planning_lock_at). Changing the setting affects plans
set up afterwards only. Changing the event's date or time zone never moves
it: staff see "no longer matches" with what the current schedule would give,
and recalculate explicitly.client_save_plan_item, client_save_plan_basics) go through one gate
that keeps the existing checks (verified access, booked, enabled item,
nothing archived) and then compares the deadline and any reopening, read
from the locked event and plan rows, with clock_timestamp() taken after
the locks. A request that waited on a lock across the deadline is refused.
A refusal returns {"status":"locked","editing":{...}} and changes
nothing; it is checked before validation, so even invalid or stale input
gets the lock. No scheduled job is involved. Staff saves have no deadline.events.planning_override_until) is per event, never moves
the deadline, ends by itself at its time (database clock), and never
bypasses archiving or revoked access, which are checked first. Answers are
never cleared.client_cutoff_version: a stale tab gets a conflict, while a
repeated identical click is reported as unchanged. Direct column writes are
revoked and a trigger refuses any change outside these functions.private, no-store and every
save is decided by the database, so a cached or stale page can't write.Staff open Run sheet from the event page's Planning card or the planning page. It is read-only (edit links go back to planning) and built for a phone on gig night:
Download run sheet PDF generates the same content on demand: header and gig overview (contacts, preferences, warnings, a time/cue, moment and people, song, instructions table per stage with the column header repeated on each page), then planning details (stage details, vendors, the frozen proposal answers under their own heading) and the full music lists in order. Pages are numbered, and the footer repeats the revision and the as-of time.
How it works:
src/lib/run-sheet/model.ts) is shared by the page
and the PDF. It reads one staff_planning_view call: a STABLE function, so
every answer, revision and song link comes from one database snapshot even
during concurrent saves. Hidden stages and moments are left out. Reuse is
resolved from what was saved: "same as the event venue", the guest count
and end time from Event basics, the ceremony's place and arrival time,
Processional and Introductions links to Couple entrance and Entrance music
songs. People sharing one linked song are grouped so the song shows once,
and Entrance music only lists songs not already shown with an
introduction. Nothing is written; nothing comes from browser form state or
the current catalog./run-sheet/pdf recheck the staff session and
membership on every request (RLS and the planning functions decide).
Signed-out visitors, clients and other businesses get a 404; the PDF is
streamed with private, no-store and never stored. Internal event notes,
payments, signing evidence, client emails, audit reasons and staff
identities are not part of the projection./my
shows "Your event is booked. Let's plan the details." Staff follow the
answers and open the run sheet and its PDF.Platform administration is a separate grant, never part of a business role (see "DJ invitations and new workspaces"). To try it locally, grant one of your local identities once (it needs a verified email, as the seeded owners have):
docker exec -i supabase_db_flux-dj psql -U postgres -c \
"select private.grant_platform_admin('[email protected]', 'Local operator');"
/staff/{slug}/setup).Revoke the local grant with select private.revoke_platform_admin('…');.
/staff/{slug}/team).With a local platform administrator (above) who does not belong to the workspace you suspend (an administrator can't suspend their own; use a throwaway workspace created through an invitation, never BOUPROD):
Flux DJ can be added to a phone's home screen and opens without browser controls. It is the same website, always online: nothing is stored for offline use and there is no service worker.
app/manifest.ts), opening at the
neutral /start, scoped to the whole site. /start sends signed-out people
to sign in and everyone else to /staff, which already routes staff to
their business (or the chooser) and clients to /my. No business, event,
token or personal data is in the manifest, start URL or icons. Inside the
app each DJ's pages keep their own branding.assets/brand/flux-app-icon.png,
1080 x 1080) is scaled, never redrawn, by node scripts/generate-app-icons.mjs
into the 180 Apple touch icon, the 192 and 512 icons (rounded corners), a
maskable 512 (padded with the icon's own background so the artwork stays
inside the safe circle) and the 16/32/48 favicon. The outputs are committed;
builds never run the script. To change the icon, replace the source (any
square, ideally 1024 px or more) and run the script again; it warns if it
would upscale. Installed iPhone apps keep their old icon until they are
removed and added to the Home Screen again./my. It
offers the browser's own install prompt where there is one (Chrome, Edge,
Android), explains Safari → Share → Add to Home Screen on iPhone and iPad
(and to open Safari from other iPhone browsers), shows nothing elsewhere,
and never appears inside the installed app. "Not now" hides it for good on
that device (a single localStorage flag; no private data).sign_in_code), on top of Supabase's own
limits.pnpm check
This runs, in order:
| Step | Command | Needs Docker |
|---|---|---|
| ESLint | pnpm lint | No |
| TypeScript | pnpm typecheck | No |
| Pricing unit tests (Vitest) | pnpm test:unit | No |
| Postgres schema lint | pnpm db:lint | Yes |
| Database tests (pgTAP) | pnpm db:test | Yes |
| Integration tests (Vitest) | pnpm test:integration | Yes, with the Storage service running |
| Browser tests (Playwright) | pnpm test:e2e | Yes. It starts pnpm dev if it isn't running |
Before the first browser test run, install the browser once with
pnpm exec playwright install chromium.
Tests never write to the seeded BOUPROD or Other DJ tenants, so your manual work there is safe while they run.
e2e-…) with the DEMO catalog
copied from the seed (tests/e2e/tenant.ts).it-…).@example.test owner, so it
never appears in your staff workspace. It is archived when the suite ends.pnpm db:reset removes them along with
everything else.pnpm build also verifies the production build. It is safe while pnpm dev
is running: Next.js 16 writes development output to .next/dev and builds
to .next, and a build's cleanup keeps .next/dev. Run the build with
next start stopped (next start serves .next).
Every pull request into main, and every commit on main, runs GitHub
Actions on a fresh local Supabase stack (no secrets, no hosted project):
| Check | Workflow | Runs |
|---|---|---|
| App checks (required) | ci.yml | pnpm lint, pnpm typecheck, pnpm test:unit |
| Database tests (required) | ci.yml | every migration and the seed, then pnpm db:lint, pnpm db:test, pnpm test:integration, pnpm db:schema:check |
| Browser smoke (advisory for now) | browser-smoke.yml | pnpm test:e2e:smoke |
The full browser suite isn't in CI; run it locally before releases that touch
authentication, contracts, payments, booking, planning access or migrations.
supabase/schema-snapshot.sql is a generated, schema-only view of the current
database for reading. The migrations remain authoritative. Release and
recovery steps are in docs/operations.md.
Run the smallest check that covers the change. Full regression runs are reserved for release checkpoints.
| While you… | Run |
|---|---|
| Work on one browser test | pnpm test:e2e tests/e2e/planning-flow.spec.ts:342 (the test's line) |
| Work on one browser spec | pnpm test:e2e tests/e2e/planning-flow.spec.ts |
| Rerun only what just failed | pnpm test:e2e --last-failed |
| Need screenshots for review | E2E_SCREENSHOTS=1 pnpm test:e2e <spec> (PNG files under test-results/) |
| Change unit-tested code | pnpm test:unit (about a second), or pnpm exec vitest related --run <changed files> |
| Change server code with integration tests | pnpm exec vitest run tests/integration/planning.test.ts --no-file-parallelism (pnpm test:integration with a file still runs every integration file) |
| Change SQL | pnpm db:test (pgTAP, about 12 seconds) and pnpm db:lint |
| Restarted local Supabase | pnpm db:versions (local must match the hosted versions) |
| Add or change a migration | pnpm db:types and pnpm db:schema, then commit both (CI fails if either is stale) |
| Touch a critical client flow | pnpm test:e2e:smoke (proposal, signing, booking, planning: the specs CI runs) |
| Finish an individual task | pnpm lint, pnpm typecheck, affected unit tests and the specifically affected browser/integration specs |
| Reach an explicitly requested release checkpoint | pnpm check once, then pnpm build |
pnpm check, the unfiltered browser suite (about 20 minutes)
or integration suite, or a local pnpm build for an individual task.
Those are for an explicitly requested release checkpoint, or a change
broad enough to genuinely need them. Report which broad suites were
deferred; targeted checks are not a full regression pass.Most browser specs are describe.serial: later tests build on earlier ones.
Selecting a single test that depends on earlier ones (by line or -g) skips
those earlier tests, so it fails for the wrong reason. In that case, run the
spec. Failures always keep a screenshot and a trace in test-results/
(pnpm exec playwright show-trace <trace.zip>), so you never need to rerun a
spec just to see what happened.
Sign-in and rate limits. The app limits sign-in requests per IP
(sign_in_link: 30 per 10 minutes at /login; sign_in_code: 20 code
attempts per 15 minutes at /login; contract_sign_in: 10 per 15 minutes on
the contract invitation page). Every local browser comes from
127.0.0.1, so the tests share these limits with your own browser.
signInStaff, signInWithLink or
verifyContractInvitation (tests/e2e/support.ts). These create a real
single-use Supabase link, exactly as the contract verification email does,
and open it through the real /auth/confirm page. The session, routes
and RLS are real. Only requesting the link through a form and reading it
from Mailpit are skipped, so these specs use no rate-limit budget and can
run repeatedly.staff-flow.spec.ts (login form, unknown emails, link scanners,
JavaScript off, sign-in with the emailed code), contract-send-flow.spec.ts
(invitation request, verification, returning-client login) and
platform-onboarding-flow.spec.ts (the DJ invitation request) and
team-flow.spec.ts (the staff invitation request) use the real forms. A
full run uses 5 sign_in_link, 2 sign_in_code, 1 contract_sign_in, 2
platform_sign_in (10 per 15 minutes), 1 staff_sign_in (10 per 15
minutes) and 3 workspace_create (20 per 15 minutes).12_…, 17_…).Test data. Each spec or suite creates its own e2e-… or it-… tenants
and archives them when it ends, even after a failure. Integration suites
first give up their tenants' leftover signed-PDF jobs, so neither the
scheduled worker nor pnpm outbox:work spends time on them before real jobs.
Archived tenants never send email. Specs that need a PDF claim only their own
contract's job.
Keep workers: 1. Specs share the per-IP limits, Mailpit and the dev server,
so parallel runs would trade a little time for flaky failures.
Long runs and logs. A full browser run takes about 20 minutes. To keep a
log, redirect to a file and read it while it runs, rather than piping through
tail (which shows nothing until the end):
pnpm test:e2e > /tmp/e2e.log 2>&1; echo "exit $?"
A pipe reports the last command's status. If you pipe test output through
grep, tail or tee, run set -o pipefail (zsh: setopt pipefail) first
so a failure still exits non-zero.
The tests in supabase/tests/database/ run against the real local Postgres
with real RLS. Each file switches to the authenticated or anon role with
JWT claims, as PostgREST does, and rolls everything back afterwards. Nothing
is mocked. Shared fixtures in _fixtures.psql create two tenants and users
in every relevant relationship.
| File | Proves |
|---|---|
01_schema_guards | RLS is on for every table; anon has no grants; security definer functions pin search_path; no RLS-bypassing views; the client projection omits staff fields |
02_staff_tenant_isolation | Staff of tenant A cannot SELECT, INSERT, UPDATE or DELETE tenant B rows; tenant_id cannot change, even for privileged code |
03_integrity_constraints | Composite foreign keys block cross-tenant references even when RLS is bypassed; one primary contact and one signer per event; access revocation is one-way; value validation |
04_membership_and_privileges | Staff cannot escalate; nobody writes memberships directly (only audited functions); lifecycle and access columns are not directly writable |
46_platform_active_membership | The workspace list's membership flag and the self-suspension guard count active owners and staff, never removed members; administrators of other businesses and existing suspension permissions unchanged; the removed row is kept |
48_reply_to_settings | Owner-only reply-to (staff, other businesses, clients refused; no direct writes), validation, normalization, audit without the address, notifications follow it, frozen per email at the first attempt, explicit "no reply-to", suspended workspaces refused |
47_email_branding_delivery | Branding frozen at the first attempt only (proposal emails keep the proposal's frozen branding after the logo changes), never changed afterwards, never another business's logo, none for platform emails. Provider events: service role only, forward-only by rank, duplicates and late events ignored, unknown ids ignored, no change to status or retries. Staff of the business read and retry (failed only, once); other businesses, clients, anon and removed staff see and retry nothing; platform emails stay invisible |
45_staff_invitations | Owner-only invitations, resends, cancellations, removals and team overview (staff, other businesses, anon refused); no direct table access. Request-id replays, one open invitation per address, members not invited, hashed tokens only, emails for the business claimed only by the staff worker. Masked address for other accounts, unverified sign-ins learn nothing, the business named only to the verified invited address. Acceptance once (replays), restored memberships after removal, owners unremovable even by privileged code, removed members lose access at once but keep client events. Cancelled, expired and resent links. Suspended workspaces can't invite, remove or accept |
| 10_offer_snapshots | Offer creation rules: exactly three distinct packages and one most popular; every referenced package, gear item and question is active; only active rules of offered questions are frozen; every tax category resolves. Also covers authorization, template prefill, and snapshots and hashes that survive every catalog, tax and branding change |
| 11_selection_recording | Only the service role records selections; optimistic versions; immutable selections and lines. The database rejects tampered prices, line totals, subtotals, tax rates, tax rounding, currency, offer hash and packages |
_catalog_fixtures.psql adds catalog rows and Storage objects for both test
tenants. _offer_fixtures.psql adds a third package and an offer-input
helper.
The selection tests force deferred constraint checks with
set constraints all immediate. The test transaction never commits, so
otherwise those checks would never run.
tests/unit/pricing.test.ts covers the pricing module:
tests/e2e/staff-flow.spec.ts drives the real UI in Chromium:
.png is rejected and deleted from Storage.Browser tests in tests/e2e/proposal-flow.spec.ts run the client side in a
signed-out browser:
tests/unit/contract-templates.test.ts covers the "## Heading" editor
format, the DEMO text markers and the error messages.tests/integration/contracts.test.ts runs through PostgREST with real
sessions. It checks that the DEMO text is accepted, and that contract
amounts equal the TypeScript pricing engine's. Two simultaneous generations
create one draft. Later catalog, client and event edits leave the contract
unchanged. Other tenants, the event's own verified client and anonymous
REST calls get nothing.tests/e2e/contract-flow.spec.ts drives the staff UI. It covers
publishing a template with a rejected placeholder, approving a real
submission, missing-information guidance, and generation with a
double-click. It checks the preview, including a client name with markup
shown literally, and the layout at 390px with no sideways scrolling. It
also covers confirmed replacement, signed-out redirects and 404s for the
other DJ.tests/unit/contract-pdf.test.ts renders a long French agreement
(multiline addresses, accents, 14 sections, signature) and a short one,
checks the extracted text page by page (page numbers, DEMO labels, parties,
terms, both hashes, consent, IP or "Not recorded"), checks that the PDF does
not contain its own hash, and writes every page as PNG to
test-results/pdf-samples/ for visual review. A client-use agreement has no
DEMO wording and still says only the client signed; DEMO labels follow the
frozen signing mode, not the title.tests/integration/contract-documents.test.ts uses real rendering, Storage
and the outbox. It covers:
client-v1, rendered without DEMOtests/e2e/contract-signing-flow.spec.ts continues after signing: both
emails arrive in Mailpit with the same PDF attached (hash checked), the
signer and staff download identical bytes with private headers, and
signed-out and other-tenant requests get no PDF.tests/unit/signature-image.test.ts covers the PNG validator (blank,
tiny, filled, palette, 16-bit, interlaced, bad CRC, trailing bytes,
decompression bombs, SVG and JPEG, size limits), clean re-encoding, and
trusted IP capture.tests/integration/contract-signing.test.ts runs against real Storage and
Postgres: the stored bytes match the evidence hash, Storage access for staff,
other tenants, the client and anon, replays after a lost response,
concurrent attempts, expired sessions, failed uploads, a void or revision
winning the race before the commit, a crash leaving only an unreadable
orphan, and real sign-versus-void races.tests/e2e/contract-signing-flow.spec.ts signs on a phone-sized browser
with touch input: validation, clear, scroll and rotation without losing the
drawing, a failed request that keeps every input, consent and confirmation,
then the signed views for the client and staff.tests/e2e/client-use-signing.spec.ts: the owner publishes their own
agreement for client use (publish stays disabled until the responsibility
statement is checked), generates and sends a contract, and the verified
client signs with the client-v1 consent. No DEMO wording appears on the
page or in the emailed PDF.supabase/tests/database/25_planning.test.sql,
26_stage_details.test.sql, 27_music.test.sql,
28_participants.test.sql, 29_contacts_preferences.test.sql and
30_remaining_editors.test.sql cover the rules (see the table above).tests/unit/planning-stages.test.ts: the browser-side stage validation
(next day, impossible intervals, bounds, normalization).tests/unit/planning-music.test.ts: the browser-side song validation,
answers compared regardless of key order, the paste parser and its flags,
duplicate and play / do-not-play matching, and views from a database
without music editors.tests/unit/planning-participants.test.ts: the browser-side validation
of Processional people, introductions, speeches (timing fields, next day,
durations) and the MC, round trips, and song-link lookups.tests/unit/planning-timed.test.ts: arrival reuse and overnight times,
timed entries (timing fields, songs, links, durations, choices),
dedications' any-time timing and round trips.tests/e2e/remaining-flow.spec.ts: on a phone, arrival reusing the
ceremony then its own overnight times; a program agenda with a time and a
moment, reorder and Undo; Dinner and Party activities with songs and "No
activities"; dedications open until song and timing, with pending, failed
and stale saves. Staff edit on a phone, hide and restore the program,
archived read-only; contract, payments and booking stay unchanged.tests/unit/planning-contacts.test.ts: phone and email checks, the
day-of source's fields, vendor checks, preferences and music styles.tests/e2e/contacts-flow.spec.ts: on a phone, the MC and officiant shown,
not re-entered. A day-of contact by reference with a phone for the day (the
client record unchanged), then someone else. Vendors with checked phone and
email, edit, reorder, undo and reload. A contact leaving the event. The
announcement language read from Event basics. Music styles with pending,
failed and stale saves. Staff edit on a phone; hiding keeps answers;
contract, payments and booking stay unchanged.tests/e2e/people-flow.spec.ts: on a phone, Processional people in order,
each with a song typed in right there or shared, an unfinished new song
refused, a listed person's new song with Use this song, a changed song,
reordering, removal and reload (linked songs can't be removed); no
pronunciation field, older guides kept for staff; a plan with a Couple
entrance (added by staff) still linking the couple's entry. The grand
entrance decision (yes, no, not sure, yes again, keeping its time and
order), guest entry kept separate; the grand entrance order with songs
typed in or shared, pronunciation and announcements, Use this song,
reordering and removal; an older plan's Entrance music linked, with a
stale tab's refused removal and Undo. The MC, and speeches with an exact time, next day, a cue
and undecided timing, with pending, failed and stale saves and Undo. Staff
edit on a phone; contract, payments and booking stay unchanged.supabase/tests/database/24_booking.test.sql covers the rules (see the
table above).tests/integration/booking.test.ts: a deposit paid before signing books
the event when the client signs (real signing code); five concurrent
payments and three concurrent checks give one booking, one audit event and
one email; the booking email goes through the real Resend adapter (HTTP
answered locally) and its retry after a business rename is byte for byte
the same; a worker from before booking emails doesn't claim it.tests/e2e/payments-flow.spec.ts continues: the client signs, the event
awaits the deposit, recording the rest books it and the confirmation email
arrives in Mailpit, and invalidating that payment keeps the booking with a
warning while the client sees what is outstanding.supabase/tests/database/23_manual_payments.test.sql covers the rules and
totals (see the table above).tests/integration/payments.test.ts sends truly concurrent requests
through the API: six identical submissions record one payment, and two
different submissions with the same amount and date record one while the
other asks for confirmation. Clients and other tenants can't record,
invalidate, write, read or set the invoice link.tests/e2e/payments-flow.spec.ts records payments on the event page
(double click, duplicate confirmation, invalidation with a reason), saves an
invoice link (http refused), shows sent-contract terms, and checks the
client's phone-width summary has no references, notes or reasons.tests/integration/proposal-flow.test.ts covers the outbox:
It also covers concurrent submissions, where exactly one wins, and checks
that the client view and links leak nothing and grant no event access.
tests/integration/support/fixtures.ts holds the shared setup.
Integration tests run with Vitest against the local stack and refuse any non-local URL.
tests/integration/offers-pricing.test.ts runs the whole step 4 flow:
Proposals can't be deleted, so this test leaves its uniquely named tenant
behind, archived. pnpm db:reset clears it.
tests/integration/storage-gear-media.test.ts runs through the real Storage
HTTP API. It creates throwaway users and gets real sessions through the
magic-link token flow, so there are no passwords and no hand-made JWTs. It
checks uploads, downloads, signed URLs, cross-tenant access, overwrites,
deletes, MIME types, clients and anonymous users, then removes everything it
created. It refuses to run unless the Supabase URL is local.
Two pairs keep the app and the database from drifting apart (see
docs/database-architecture.md, "TypeScript types and Zod schemas"):
tests/integration/db-responses.test.ts parses the local database's real
contract, signing, payment and approved-offer answers with the app's Zod
schemas. It also checks that a signing commit with a malformed answer keeps
the signature image and replays on retry. tests/unit/db-responses.test.ts
covers malformed answers and checks that failures never log values.tests/integration/rule-parity.test.ts and tests/unit/rule-parity.test.ts
run the database's functions and the app's own checks against the same
cases in tests/support/rule-cases.ts: https links, phones, emails, keys,
playlist duplicates, rule conditions, discount rounding, draft discounts
and tax settings.Isolation is enforced by the database, not by application code.
tenant_id
and exposes unique (tenant_id, id). Children reference parents with
(tenant_id, parent_id), so a row can never point at another tenant's
row.id, tenant_id and
parent references for every role, including the service role.events.lifecycle_status changes only through future transactional server
operations.anon
and authenticated. Every new table needs explicit grants, RLS and tests.supabase/config.toml, so the admin API creates
every user. The login form never creates accounts and always shows the
same message./auth/confirm,
which needs a click on Sign in (a POST) before it verifies the token.
A link scanner that prefetches the URL therefore can't use it up.src/proxy.ts refreshes the session and sends
signed-out visitors to /login. That is a convenience only. Every staff
page and action calls requireStaff(slug), which checks the user with the
auth server and the membership through RLS.Files go straight from the browser to Storage, because Server Actions are capped at 1 MB and hosting platforms limit request bodies.
gear_media row created. Anything else is deleted with
the service role. Offers only reference registered media, so they never
include an unchecked file.proposals.draft_offer,
saved in place with an optimistic draft_version. Staff use
open_proposal_draft, update_proposal_draft and
proposal_offer_input_from_template. Editing never freezes anything or
creates revisions.preview_proposal_offer builds the snapshot in memory,
using exactly the validation and builder that freezing uses. It stores
nothing.send_proposal freezes a snapshot, inside the
send transaction, and it does so exactly once. Under a row lock it
enforces the offer rules and copies everything into
proposals.offer_snapshot:
offer_sha256 itselfsrc/lib/pricing is pure, deterministic TypeScript, shared by
the server and a future browser preview. It follows spec section 5:
max(optional, required extra), so it is never charged twiceround_half_up(line_total × rate_ppm / 1,000,000), summed per code. Every
configured rate is listed, including zero amounts.tenants.tax_categories maps a category key to tax
codes. There is no implicit default, and an unmapped category blocks offer
creation.rate_ppm (no floats).
update_tax_settings is the only write path. It checks the owner role and
tax_settings_version (stale tabs get a conflict), refuses a tax still
used by a category and unmapping a category used by active gear or
packages, and audits each change. Each category is not configured, no tax
([]), or a set of taxes. Only new offers read these settings.send_proposal runs as the staff user, with membership
checked inside, in one transaction:
events.active_proposal_id at the new offer, and a partial
unique index allows only one actionable proposal per eventbase64url(HMAC-SHA256(PROPOSAL_LINK_SECRET, "flux:proposal-link:v1:" + id)),
which is 256 bits. The database stores only sha256(token), in
access_links.token_hash, which staff can't even read. The outbox stores
only the link id. An email retry re-derives the identical token, so no
plaintext or encrypted token is ever stored and retries never create new
links. Before sending, the worker compares the hash, so a rotated secret
makes undelivered proposal emails fail visibly instead of sending a dead
link./bouprod/p#token). Browsers never send fragments, so the token can't
reach request logs or analytics, and scanners that don't run JavaScript
can't open sessions.
/{tenant}/p/exchange./{tenant}/proposals/{id}, stored only as
a hash. It then redirects to the clean URL.client_proposal_view, client_save_selection_draft and
client_submit_selection.
Clients can read a sent contract. They cannot sign it yet.
Send. Send contract… on the review screen runs send_contract in
one transaction:
private.contract_send_problemssent_at stamped once by the databaseaccess_links with purpose contract) that
expires after 14 days and is scoped to the tenant, event, contract and
frozen signercontract_sent:{link}
and writes an audit recordRepeated or concurrent sends return the existing result. Content and
hashes never change, and the event stays awaiting signature. Only this
function can move a contract to sent; the transition trigger requires a
marker that only it sets.
Invitation token. It is HMAC(PROPOSAL_LINK_SECRET, "flux:contract-invite:v1:" + link id). Only its SHA-256 is stored, and the
worker re-derives it at delivery, as proposal links do. The email link is
/{tenant}/invite#token: the token stays in the fragment, out of server
logs, and is removed from the address bar. The token is not proof of
identity. Its only power is asking for a verification email to the frozen
signer address, rate-limited per IP and per invitation, three every 15
minutes. That first step needs JavaScript.
Verification email. The contract_sign_in outbox email gets its
Supabase link from the worker at delivery time, through the Auth admin
API:
invite when the address has no verified identity. Trusted code creates
the identity, and public signup stays disabled.magiclink when a verified identity exists, for example a client of
another DJ or a staff user.The link goes to /auth/confirm with type and next. Both are
allow-listed, and next may only be an invitation, contract, /my or
/staff path. Verification needs the explicit Sign in POST, which
works without JavaScript and sends a real Origin (strict-origin). It
works in any browser or device.
Acceptance. /{tenant}/invitations/{id} shows a page, and only its
Open my contract POST runs accept_contract_invitation. That
function:
Ordering and recovery. Postgres commits business state first: the
contract, invitation, queued email and audit record. Email delivery and
Supabase Auth calls happen afterwards in the worker, through after() or
pnpm outbox:work, and are never inside a transaction.
contract_sign_in attempt creates
a new Supabase link, which invalidates the previous one. A crash after
sending but before recording can therefore resend one email, and only the
newest link works. Supabase verification links expire after one hour, and
invitations after 14 days.No new environment variables are needed. Invitations reuse
PROPOSAL_LINK_SECRET with their own domain prefix, and rotating it fails
undelivered contract emails visibly.
Layout. /staff/{tenant}/settings is one page in five sections with
section links (a column from 1024 px, wrapped links below): Business
profile (display name, time zone, currency and reply-to shown read-only, as
no screen changes them; legal details and deposit), Branding (#branding),
Bookings and taxes (#booking, #taxes, and how a proposal becomes a
booking), Planning (#planning deadline, and the default planning template
per event type, set on each template), and Access (your role and the
number of owners and staff). Every form and action is unchanged.
Access. Your role and the number of active owners and staff; owners get Manage team (see "Staff team and invitations").
Owner-only settings. The owner manages /staff/{tenant}/settings:
tenants.business_name)The display name stays the client-facing brand. Changes go only through
update_business_settings, which checks the owner role in the database.
Staff can see the values but not edit them.
DJ name (Planning, #dj-name). Optional tenants.dj_name, set by the
owner through update_planning_dj_name (20261121000100). An event can
name its own DJ on its details (events.dj_name, any staff). Planning and
the run sheet name the DJ only where they ask who does what (the MC
choices), as events.dj_name, else tenants.dj_name, else "your DJ" /
"The DJ"; the display name is never used as the DJ's name. Generic wording
("Not sure yet — discuss with your DJ") never names anyone. Leave both
empty when different DJs handle the business's events.
New placeholders. {{business.legal_name}}, {{business.address}}
and {{business.email}}. {{business.name}} is kept for existing
templates and means the legal name.
Deposit. It is round_half_up(total × percent / 100) in integer cents,
and the balance is the total minus the deposit. Each new contract freezes
the percentage (contracts.deposit_percent and the commercial snapshot),
the amounts and the business identity.
Existing contracts. They were backfilled at 50%, which their snapshots record. No snapshot or hash was rewritten.
Who signs. Only the contract's frozen signer, signed in with a current,
verified Supabase session whose email matches the frozen signer email, and
holding event access for that signer. The server reads the identity from
auth.getUser(), which checks the session with the Auth server. An
invitation token is never signer identity. There is no second email check
per signature.
What the client provides. A typed name, a drawn signature
(signature_pad, exported as a 900 × 300 PNG), the consent checkbox and the
content hash of the contract they were shown. The server and database derive
the signer email, user id, time, consent text and request context.
Signing modes. Every published template version has an explicit
usage, chosen when publishing and immutable like the text:
demo: test wording. Contracts, the signing page and signed PDFs are
labelled "DEMO, NOT FOR CLIENT USE", and the client accepts the demo-v1
consent. Any staff member may publish DEMO versions.client_use: the business's own agreement. Only the owner can publish
it, after checking the responsibility statement (client-use-v1: the
owner is responsible for the agreement and approves it for clients;
Flux DJ has not reviewed or approved it and gives no legal advice). The
exact statement, the owner's user id and the database time are stored on
the version. The client accepts the client-v1 consent. No DEMO labels.publish_contract_template_version(id, draft version) remains for older callers: it publishes DEMO-titled versions as
DEMO, replays versions already published, and refuses everything else, so
nothing is ever approved for client use without the owner's confirmation.legacy: versions published before modes existed that were not DEMO.
Contracts can't be generated from them; open a new draft version (the
text is copied) and publish it for client use.A contract freezes signing_mode and consent_version from its template
version when it is generated (contracts_signing_terms); nobody can choose
or change them. The client view shows that exact consent, unchecked, and
sign_contract accepts only that version and stores its text in the
evidence. Changing wording or usage always needs a new version, and
publishing sends or signs nothing.
Existing contracts. The migration classified existing data with the old
title rule ("DEMO, NOT FOR CLIENT USE"): DEMO versions and contracts stay
DEMO with demo-v1, and signatures, evidence, hashes and PDFs are
unchanged. Other contracts get : they can't be signed
or sent (review lists the problem), and the contract page explains how to
publish a client-use version and regenerate (or void and replace a sent
one). Nothing old becomes signable automatically.
What the PDF shows. It is rendered with @react-pdf/renderer only from
the frozen contract (text, parties, terms, content hash) and the signing
evidence. It includes:
DEMO contracts carry DEMO labels on every page; client-use contracts have
none (decided by the frozen signing_mode, never the wording). Both state
that the PDF records the client's signature only, with no signature from
the business. Fonts are bundled in
assets/fonts/dejavu (Latin and French accents, no network fetch) and
traced into the deployed functions by next.config.ts.
Two different hashes. contracts.content_sha256 identifies the agreed
text, terms and parties. contract_documents.pdf_sha256 is the SHA-256 of
the exact final PDF file. The PDF never contains its own hash.
Durable job. sign_contract queues one document_jobs row in the
signing transaction. Rendering happens later, so a failure never touches
the signature. The worker (src/lib/contracts/documents.server.ts) runs
right after signing and after Generate signed PDF, for that contract's
job only (the contract id comes from the database's answer, never the
browser), so other tenants' backlog never delays it. The scheduled
/api/internal/outbox route drains everything else, including anything
the immediate run failed or never finished:
{tenant}/{contract}/{uuid}.pdf in the private
contract-documents bucket.commit_contract_document: atomically records the one canonical
document, marks the job succeeded and queues the signed-copy emails.Retries back off (1, 2, 4… minutes) for up to 5 attempts, after which staff see the error and can retry.
Concurrency and recovery.
event_payments: amount in integer cents (positive, parsed
without floating point), currency, date received (not in the future, in
the business time zone), optional reference and internal note, and the
recording staff member (user id, membership, email) and database time.
Rows are never updated or deleted. invalidate_event_payment marks a wrong
entry once with a required reason, who and when; a correction is a new
entry. Only record_event_payment and invalidate_event_payment write,
for staff of the event's business; clients and other tenants can't read or
write anything. Recording and invalidating are blocked while the event is
archived, and are audited.private.event_payment_summary is the only place they are
computed: received (valid payments in the terms' currency), deposit still
outstanding and remaining balance (never below zero), and the amount
received above the total, shown as a credit. A zero-percent deposit shows
"No deposit required".event_billing.invoice_url, optional, HTTPS with a host
name and no credentials (checked by the database), versioned so a stale tab
gets a conflict. It is rendered as a link with
rel="noopener noreferrer nofollow" and its host shown; Flux DJ never
fetches it.client_payment_summary returns the figures and the
invoice link only for a contract the caller can already read (verified
signer, live access, nothing archived). No references, notes, staff
identities, invalidations or record ids.tenants.booking_confirmation_policy: on_deposit (signed and
the required deposit received; the default) or on_signature (signed is
enough; payment may still be due). Only the owner changes it, in Settings,
through update_booking_policy (versioned; the column has no direct write
grant). Before this, no screen could set it, so every business still had
the old column default on_signature, which nobody chose; the migration
moved them to on_deposit.contracts.booking_policy is copied from the
business when the contract is generated and never changes, so changing the
setting only affects contracts generated afterwards. It is not part of the
content hash, so hashes, evidence and PDFs are unchanged.private.evaluate_booking runs inside the
transactions that sign a contract and that record or invalidate a payment,
after locking the event (the lock order everywhere is event, proposal,
contract). It reads only the database: the event's signed contract and its
frozen deposit, valid payments in its currency (payments recorded before
signing count), and whether the event or business is archived. Under the
lock it moves the event to awaiting_deposit (deposit still due) or to
booked, setting booking_confirmed_at once, writing one audit event and
queuing one booking-confirmation email in the same transaction. A 0%
deposit is satisfied by signing. Repeated or concurrent requests can't
book twice. A trigger refuses any other way of booking, changing
booking_confirmed_at, or leaving booked. Archived events are never
booked. Rendering and email happen after the transaction, so they can't
block or undo a booking.booking_confirmed_at stays, and the event page warns staff when
valid payments no longer cover the required deposit. The client sees the
accurate amount outstanding. Cancellation, refunds and unbooking are not
built.booking_policy null)
are never booked automatically, not by the migration and not by payments.
The event page shows Check booking (check_event_booking, staff
only), which evaluates them as on_deposit, whatever the business's
current setting. If the deposit is still due, the event moves to
awaiting_deposit and later payments are then evaluated automatically.set_event_archived, which checks membership. Staff have no direct grant
on events.archived_at.Templates. contract_templates holds a name and an active flag.
contract_template_versions holds numbered versions of the text: a title
and 1 to 60 plain-text {heading, body} sections.
draft_version checks. At most one draft exists per template.content_sha256, the SHA-256 of
the canonical jsonb text of {"title", "sections"}. After that no role
can change, unpublish or delete the version.Placeholders. The only dynamic syntax is {{group.name}} from the fixed
registry private.contract_placeholders(). The editor shows the same list.
## Heading lines start
sections.Generation. generate_contract_draft renders the contract inside the
database, in one transaction under the event lock. It uses only three
sources:
party_snapshotSubstitution is a single pass, so a client name containing {{...}}
or <b> is stored and shown literally. React renders it as text.
Missing values. Every placeholder a template uses must have a non-blank value, and the event needs a signer. Otherwise nothing is stored and staff get a list of what to complete.
Balance due date. Staff confirm it when the template uses
{{payment.balance_due_date}} (refused when it doesn't, or when it's in the
past). The field starts as the current draft's own date when regenerating
(never silently replaced), otherwise as the event date (a calendar date,
never shifted by time zones) unless that day has passed; left empty,
generation asks for it. It is only a default: nothing changes the
business's settings or existing contracts. The deposit is separate (due on
signing).
Gear images and videos live in the private gear-media bucket. The object
path is {tenant_id}/gear-items/{gear_item_id}/{random uuid}.{ext}.
gear_media row must agree on path, kind, MIME type and extension.Migration 20261018000100_platform_invitations.sql. Business owners join by
invitation only; public signup stays disabled.
Platform administrators. public.platform_admins lists the users who may
invite DJs. Nothing derives it from a tenant role, an email address or a
slug, and it gives no access to any business's clients, events, emails or
settings (no tenant policy refers to it; an administrator without a staff
role who opens a workspace is treated like any other non-member). It is
granted only by SQL run as the database owner; the app, the API and even the
service role cannot write it. Every invitation function checks it again in
the database, and the /platform/invitations page answers 404 to everyone
else.
One-time grant on hosted (not applied yet). After the migration is deployed, in the Supabase dashboard's SQL editor for the production project:
-- Exactly one verified Auth user must have this email; it is audited.
select private.grant_platform_admin('<your sign-in email>', 'Flux DJ operator');
-- Check:
select a.user_id, u.email, a.granted_at from public.platform_admins a join auth.users u on u.id = a.user_id;
Undo with select private.revoke_platform_admin('<email>');. Grants and
revocations are recorded in public.platform_audit_events.
Lifecycle.
create_platform_invitation). The administrator enters an
address and reviews the normalized recipient and the expiry before
sending. The invitation expires after 14 days. One open (unaccepted,
unrevoked) invitation per address: another attempt points to the existing
one, expired or not. At most 50 new invitations per administrator per day.
The token is HMAC(PROPOSAL_LINK_SECRET, "flux:platform-invite:v1:" + link id); only its SHA-256 is stored. The email ("platform_invitation",
from "Flux DJ", no reply-to) is queued in the same transaction and links to
/join#token, with the token in the fragment.resend_platform_invitation, also for an expired invitation).
It issues a new link id and token with a new 14-day expiry, so the
previous link stops working at once, and cancels queued emails for the
invitation. Limited to one per 2 minutes and 10 emails a day.revoke_platform_invitation). Only before acceptance; the link
stops working and queued emails are cancelled. A new invitation to the
same address is then possible.request_platform_sign_in, service role). /join
reads the token from the fragment, removes it from the address bar and
sends nothing until Email me a sign-in link. The token only queues a
verification email ("platform_sign_in") to the invited address: limited
to 10 per IP per 15 minutes (platform_sign_in) and 3 per invitation per
15 minutes. At delivery the worker asks Supabase Auth for a fresh link,
exactly as for contract verification: invite creates the identity when
none exists, magiclink reuses an existing client or staff identity (no
duplicate identities). The link opens /auth/confirm with
next=/join/{invitation}, so verification keeps the explicit Sign in
POST that scanners can't trigger.accept_platform_invitation). /join/{invitation}
asks for the business name (2 to 100 characters) and web address. Only
the Create my workspace POST, limited to 20 per IP per 15 minutes
(workspace_create), calls the function, which locks the invitation row
and rechecks the signed-in user's verified email, expiry, revocation and
acceptance. It then creates the tenant, the owner membership for the
signed-in user and the accepted invitation in one transaction, and audits
the platform and the new business. The browser supplies only the name and
address, never a user or tenant id. A taken address (including archived
businesses) or a reserved one (private.is_claimable_tenant_slug: every
top-level route such as , , , , )
changes nothing and leaves the invitation open. Repeated or concurrent
submissions by the same user return the same workspace; an accepted
invitation never creates another, and nobody else can use it.Delivery. Platform emails use email_outbox with tenant_id null (a
check constraint allows that only for these two types; staff never see them).
claim_email_outbox joins tenants, so workers deployed before this migration
never claim them; claim_platform_email_outbox rechecks that the invitation
is still open and unexpired and that the row belongs to its current link and
address. The invitation email is rebuilt from the link id on every attempt,
with a provider idempotency key; the verification email gets a new Supabase
link on every attempt. No token is stored in rows, logs or errors. Platform
emails have no staff Retry; resend the invitation instead.
Signed in somewhere else. A wrong signed-in account sees only the masked invited address and a Sign out button. The verification link signs in whichever browser opens it, which is usually Safari on a phone: a Home Screen app keeps its own sign-in, so the welcome card tells the new owner to sign in there with the emailed code at the app's sign-in page. Nothing about the invitation is kept in browser storage.
New workspace defaults. No clients, events, gear, packages, questions, proposal, contract or planning templates are created or copied. Values are the column defaults the other businesses started with: time zone America/Toronto, currency CAD, 50% deposit, bookings confirmed on signature and deposit, planning deadline 14 days, no taxes configured, no reply-to address (staff notifications go to the owner's sign-in email), no logo or colours.
Incomplete setup. tenants.business_name (the legal name) is required,
so it starts as the display name, as a placeholder. The business address and
contact email start empty, and update_business_settings always saves all
three together, so "address or contact email missing" means the legal
identity was never confirmed. Existing checks keep contracts unsendable in
that state (business_settings_incomplete, and business_identity_missing
for drafts generated before). The dashboard says setup isn't finished, and
Settings shows the legal-name field empty with a note naming the placeholder.
Migration 20261020000100_business_branding.sql. Each business owner sets a
logo and a primary colour in Settings → Branding; other staff see them
read-only. The active workspace decides what is shown, never the signed-in
user: someone in two businesses sees each one's own branding.
Sources of truth. The existing columns, now written only through
update_tenant_branding (owner only, checked in the database, versioned by
tenants.branding_version, stale saves raise PT409, audited):
tenants.logo_storage_path: the active logo, or null (the display name is
shown). A foreign key makes it a registered logo of the same business.tenants.brand_colors.primary: #rrggbb, or absent for the default dark
grey. Other keys are kept.Suspended businesses can't change branding (PT423); archived ones follow
their existing settings rules.
Logo uploads.
src/lib/branding/logo.server.ts).src/lib/media/sniff.ts) and must agree with what
the decoder finds; the declared type and extension are never trusted.sharp: EXIF
orientation applied, metadata dropped, sRGB, fitted inside 1024 by 1024
pixels without enlarging, stretching or cropping, transparency kept, saved
as PNG (also usable by the PDF renderer later). Fully transparent images
are refused; mostly light artwork is marked to be shown on a dark backdrop.serverActions.bodySizeLimit is 4.5 MB,
under Vercel's request limit), which uploads only the re-encoded PNG to the
private tenant-logos bucket as {tenant}/logos/{uuid}.png, then
registers it (register_tenant_logo, service role only, which rechecks the
owner and the stored object). Only then can the save make it active.Where branding appears.
| Where | Branding |
|---|---|
| Staff workspace header | Current logo (alt text: the business name), else the name |
| Client contract and planning pages | Current logo and colour (invitation pages show the name only) |
| Proposals | Frozen when sent (offer_snapshot.branding): a sent proposal keeps its logo and colour after the owner changes them; staff previews of drafts use the current ones |
| Run sheet (page and PDF) | Current colour (unchanged) |
| Signed contract PDFs, emails | Unchanged; no logo (see below) |
Buttons and headers on a brand colour use white or black text, whichever
contrasts more (always at least 4.5:1, src/lib/branding/colors.ts). Errors,
warnings and disabled controls keep their own styles.
Logos are never deleted. A replacement gets a new path, so frozen
proposals keep resolving their logo, and registered logos can't be updated or
deleted by any role. private.unused_tenant_logos() (database owner only)
lists registered logos neither active nor frozen into a proposal, and stored
objects that never became a registered logo (an upload whose registration
failed and couldn't be removed). Clean-up stays a deliberate manual step.
Deferred. Signed contract PDFs and emails carry no logo. Adding one needs the logo frozen into the contract's party snapshot at generation (the snapshot point for contracts); canonical PDFs and contract hashes must not change, so it is a separate task. The run-sheet PDF could use the current logo (live branding) later. The Flux app icon, manifest and platform identity stay shared by every business (see "Staff layout and navigation").
Every page under /staff/[tenant] and /platform uses one shell
(src/components/app/app-shell.tsx); client proposal, contract and planning
pages don't.
public/brand/, shared platform artwork
generated from assets/brand/ by scripts/generate-app-icons.mjs, never a
business's logo) links to the business dashboard. The active business's logo,
or its display name, is centred on the viewport (equal side columns), with
the existing light-logo backdrop. Platform pages show "Platform
administration" instead. The account menu shows the email, role and business,
switches between the user's businesses (suspended ones are listed as
unavailable), links to /my when the user also has client access, and signs
out.src/lib/navigation.ts): Main (Dashboard, Events, Clients),
Catalog (Gear, Packages), Templates (Proposal, Contract and Planning
templates, Questions & rules), Business (Emails, Settings), and Admin (DJ
invitations, Workspaces) for platform administrators only, never for a
tenant role. Links only point at existing routes and grant nothing; every
page checks access again. Contracts, payments, planning and run sheets are
reached through their event and highlight Events.globals.css (--shell grey canvas, tinted
--sidebar, white content surfaces). The staff shell looks the same for
every business; business colours brand client pages./staff/[tenant] answers "what's coming up, and what needs my attention?"
with one read-only call, public.staff_dashboard (migration
20261021000100_staff_dashboard.sql). The function returns facts; the
wording, order and links are in src/lib/dashboard.ts. It is STABLE, so it
can't change events, set up plans, confirm bookings, queue jobs or send
email. Only staff of the business can call it (others get "not found", and a
suspended workspace gets workspace_suspended). Archived events never
appear.
Upcoming events: dated today or later in each event's own time zone,
by date then title, the next 8, with client, venue ("Venue not set" when
missing) and the existing status label (eventStatusLabel); booked events
are shown apart. "View all events" opens the full Events list (not a
filtered one).
Needs attention (one item per task; an event can have several different tasks):
private.plan_progress). Shown as urgent.awaiting_deposit, which
only booking evaluation sets (deposit policy, deposit not received),
with the outstanding amount from private.event_payment_summary. Signature
policies, 0% deposits, booked events and unpaid balances never appear.The first 6 show; "Show all N" (?attention=all) lists the rest.
Email failures: a notice when this business's emails have status
failed (retries used up), linking to Emails. Pending and sending emails,
including normal retries, and platform invitation emails are not counted.
Setup card (shown until the required steps are saved; from saved data
only, requiredSetup in src/lib/setup.ts): lists only the required
steps still missing, with Continue setup to the setup guide. Required
means the app refuses the workflow without it: the legal name, address and
contact email (contracts); taxes (at least one tax category, and every
category used by active gear or packages configured; an empty list is a
valid "no tax") and three active packages with no archived included gear
(proposals; every offer has three); a contract template version published for client use (contracts).
A proposal template is recommended, not required: a draft can pick its
three packages directly. Staff see which items only the owner can complete.
Deploying. The migration only adds public.staff_dashboard (nothing
existing changes), and apps before it never call it, so apply it to the
hosted database first (db push --linked --dry-run, then db push --linked), then deploy the app. An app deployed before the migration can't
load the dashboard; every other page works.
Migration 20261101000100_email_branding_delivery_status.sql.
Design. Every email (src/lib/email/templates.ts) uses one layout
(layout.ts): the business's logo (or its name with a brand-colour mark) at
the top, one heading, short paragraphs, a details box (event, date, amounts
the email already carries), one button in the brand colour (text colour
chosen for contrast, brandTheme), short notes, and a footer ("Sent by
{business} with Flux DJ"; "Reply to this email…" only when the business has
a reply-to). Table layout with inline styles for Gmail, Outlook and Apple
Mail; no remote images, no tracking pixels. The same content renders the
plain-text alternative, so both always carry the same information and link.
Subjects are unchanged. DJ (platform) invitations are Flux-branded.
Logos. Never a URL: the worker (branding.server.ts) reads the logo from
private Storage, only from the row's own business folder, fits it into
440 x 112 pixels (no stretching, cropping or enlarging), centres it on an
opaque rounded plate with a hairline edge (white, or near-black when the logo
was marked "needs a dark background" at upload), and attaches the PNG (at most
120 KB) inline (cid:business-logo; Resend content_id, Mailpit
ContentID). Any failure leaves the logo out and the email still goes, with
the business name.
Dark mode. Clients disagree: Apple Mail keeps the light layout, Outlook and Gmail recolour backgrounds and text, and Gmail's apps may invert the whole message. None of them recolour images, so the plate is part of the image rather than a background colour in the HTML: a dark logo on a transparent background stays visible in every client and mode. One logo per business is enough; no separate dark-mode upload exists or is needed.
Which branding. Frozen per outbox row at its first attempt
(freeze_email_branding, service role; a trigger keeps it unchanged):
proposal emails use the branding frozen into the proposal's offer when it
was sent; all other business emails use the business's branding at the first
attempt. Retries therefore render the identical email (provider idempotency
keys stay valid), and changing the logo never alters an email already
attempted.
History. Each event page has an Emails section (#communications):
every email queued for the event's proposals and contracts (up to the latest
100, with the true total), newest first (ties in a fixed order). The latest
three show at first, with Show all emails (n) and Show fewer in
place; an older email that needs attention stays listed below them while
folded. Times are in the event's time zone. A soft bounce (the provider's
"Transient" category) shows as "Temporarily refused" and isn't an attention
item, matching the dashboard. If the email the client needs for the current
step (the sent proposal, or the contract invitation and its sign-in link)
needs attention, the event's next step says so and links here. Each entry has
its purpose, recipient (or "To your team"), time and status, expandable for
details, a link to the proposal or contract, and Retry this email for
failed ones. The Emails page uses the same statuses
(src/lib/email/history.ts): Waiting to send, Retrying, Sending, Couldn't be
sent, Not sent (Flux's own cancellation reason), Accepted by email provider,
and, only when the provider reported it, Delivered, Delivery delayed,
Bounced, Marked as spam, Not delivered (suppressed) or Provider couldn't
deliver. "Accepted by email provider" never claims inbox delivery; opens and
clicks are never shown. Provider error text is never displayed (an HTTP status
at most). Retry is retry_email_outbox: staff of the business only, failed
rows only, atomically, then the normal worker sends it. Team invitations stay
on Team; platform emails are never visible to businesses.
Provider feedback. POST /api/webhooks/resend (404 unless
RESEND_WEBHOOK_SECRET is set) verifies the Svix signature (svix-id,
svix-timestamp, svix-signature, five-minute tolerance), keeps delivered,
delivery_delayed, bounced, complained, failed and suppressed events, and
records them with record_email_provider_event (service role) on the row
with that provider message id. The status only moves forward (delayed <
delivered < failed, bounced, suppressed < complained), so duplicates and
out-of-order events change nothing, and the outbox status, attempts and
schedule are never touched: nothing is retried after a bounce. Only a short
category is stored (for example "Permanent · General"), never addresses or
content.
Reply-to. Owners set it in Settings → Business profile (#reply-to),
through update_reply_to_email (migration 20261102000100_reply_to_settings.sql:
owner only, validated, audited without the address; the direct column grant
is revoked). It is the Reply-To of client emails and where staff
notifications go; empty means no Reply-To and notifications to the owner's
sign-in email. Each email freezes the reply-to at its first attempt with its
branding, so a retry keeps it.
Dashboard. The email notice also counts, for the last 30 days, emails the provider reported as not delivered (hard bounces, provider failures, suppressions) and spam complaints, once per email. Acceptance alone never counts.
Sign-in template. supabase/templates/magic_link.html uses the same look
and keeps the exact link ({{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email&redirect_to={{ .RedirectTo }})
and code ({{ .Token }}). Supabase renders templates with Go's
html/template, which strips HTML comments, so the template uses a
width="560" attribute (not <!--[if mso]> comments) for Outlook. Locally,
after editing it, restart both supabase_kong_flux-dj and
supabase_auth_flux-dj (the Auth server caches the template). The hosted
template is pasted in the Supabase dashboard (Authentication → Email
Templates → Magic Link).
Previews. With pnpm dev, open http://127.0.0.1:3000/api/dev/email-preview
(?variant=logo|dark|none|long): every template with invented businesses, at
desktop and phone width, with its plain text. It answers 404 outside
development and reads nothing from the database.
Migration 20261030000100_staff_invitations.sql. Separate from DJ (platform)
invitations, which create a business: a staff invitation only grants a staff
membership in an existing one. /staff/{tenant}/team (linked from Settings →
Access; it highlights Settings, no new menu item).
Owner only. Members (email, role, active or removed), pending and recent
invitations, Send invitation (email; the role is always Staff),
Resend, Cancel invitation and Remove, each confirmed first. Every
function checks the owner role again in the database
(private.require_owner_of): staff get a permission error, other businesses
and removed members "not found", a suspended workspace workspace_suspended.
Staff see their own role on Team and in Settings → Access. Owners can't be
removed or invite another owner; there is no ownership transfer.
Memberships. Removal is soft (tenant_memberships.removed_at,
removed_by): business records point at memberships for attribution (who
created a proposal, approved it, generated or published a contract, recorded
a payment), several of those columns are immutable, so a deleted membership
would either fail or lose that history. Removed rows grant nothing:
member_tenant_ids, require_staff_of, my_workspaces and
tenant_write_guard ignore them, so access ends on the next request, whatever
the browser session. The Auth account, other businesses and client events are
untouched. A check constraint keeps owner rows unremovable. Signed-in users
can no longer insert, update or delete memberships directly (the app never
did; the old owner policies allowed adding any known user id unaudited);
memberships change only through these functions and DJ onboarding.
Lifecycle (public.staff_invitations, audited in audit_events):
create_staff_invitation). The invitation's id is the form's
request id, so a retried submission returns the same invitation and sends
nothing more. One open invitation per business and address (unique index);
active members get "already has access"; 30 new invitations per business
a day. Expires after 7 days. Token: HMAC(PROPOSAL_LINK_SECRET, "flux:staff-invite:v1:" + link id), only its SHA-256 stored, in the
/join/team#token fragment.resend_staff_invitation, also when expired): a new link id
(the resend form's request id, so a retry is a replay), a new 7-day
expiry, queued emails for the old link cancelled. One per 2 minutes, 10 a
day.cancel_staff_invitation): under the invitation's row lock,
which acceptance also takes, so a racing acceptance either finished first
or is refused. Queued emails are cancelled./join/team sends nothing until Continue. Someone signed in
with the verified invited address goes straight to acceptance
(staff_invitation_for_token); anyone else gets "Confirm your email to
join …" sent to the invited address (request_staff_sign_in, service
role; 10 per IP per 15 minutes as staff_sign_in, 3 per invitation). At
delivery the worker asks Supabase Auth for an invite link (new person)
or a magic link (existing account; no duplicate identities), returning to
/join/team/{invitation} through /auth/confirm.accept_staff_invitation, explicit POST): under the row lock,
rechecks expiry, cancellation, the verified email and that the business
is neither suspended nor archived, then creates a staff membership,
restores a removed one, or keeps an existing one unchanged. Repeats
replay; nobody else can use an accepted invitation. The business is named
only to the verified invited address; wrong accounts see a masked address.Emails. staff_invitation and staff_sign_in rows in email_outbox
carry the business's tenant_id (suspension cancels them, archived and
suspended businesses send nothing, they show on Emails), but only
claim_staff_email_outbox claims them: claim_email_outbox now skips them,
so a worker deployed before this migration never renders them as something
else. Sent as "{business} via Flux DJ" with its reply-to; the link is rebuilt
from the link id on every attempt (provider idempotency key).
Platform administration. 20261031000100_platform_active_membership.sql
makes the "your own workspace" suspension guard and the workspace list's
membership flag count only active memberships, like every other check.
Deploying. Apply both migrations first, then deploy the app. The previous app keeps working: it never wrote memberships, its worker skips staff emails (they wait for the new one), and the gates it relies on only got stricter.
/staff/[tenant]/setup (linked from the dashboard's setup card and from
Settings; it highlights Settings in the navigation and adds no menu item).
Read-only: it reuses staff_dashboard's setup facts and a few RLS-scoped
counts, so there is no onboarding state, no migration and nothing to dismiss.
Every step links to the existing screen with ?from=setup, which shows a
Back to setup guide link there (and nowhere else).
| Step | Level | Ready when |
|---|---|---|
| Business details | required (contracts) | legal identity saved |
| Customize branding | optional | a logo or a primary colour is saved (the step says which) |
| Deposit and booking confirmation | recommended | legal details (with the deposit) saved and booking_policy_version > 0 (the owner saved the policy once; the default is otherwise "not reviewed") |
| Taxes | required (proposals) | as on the dashboard |
| Gear and services | optional | an active gear item |
| Three packages | required (proposals) | three active packages whose included gear is all active (build_offer_snapshot refuses archived gear) |
| Proposal template | recommended | an active template whose three packages are active |
| Questions & rules | optional | an active question |
| Contract template | required (contracts) | a version published for client use |
| Planning template | recommended | an active template is the default for an event type |
| Your first event | optional | an unarchived event |
Statuses are Ready, Needs attention (required or recommended, partly done), Not started, and Optional. The summary counts only the four required steps and says what sending proposals and contracts still needs. Steps check what is saved, not whether terms or prices are right; each proposal and contract is still validated when it is previewed and sent. Suspended workspaces get the existing unavailable page; other businesses get 404.
/staff/[tenant]/events/[eventId] is an event workspace: a compact header
(date shown as a calendar date, never shifted; venue or "Venue not set";
type; the existing status label; Archived), one next step, four
summaries (proposal, contract, payments, planning), then the sections. The
rules are in src/lib/events/workspace.ts (unit-tested); rendering only
reads, so it never sets up a plan, books or sends anything. Every suggested
action is a link to the existing screen or form, where the database still
checks eligibility and asks for confirmation.
Next step, from the actual records (first match):
Payments summaries come only from private.event_payment_summary (signed or
sent terms, labelled; received; deposit and what's still due; balance or
credit). Planning shows progress, the editing state and its closing time in
the event's time zone.
Where things are: Proposal (current revision, the start/revise form, older
revisions under "Proposal history"); Contract (current contract, generation,
older drafts and void contracts under "Contract history"); Payments (booking
state, summary, history; "Record a payment" and "Invoice link" open on
demand); Planning (Edit planning, Open run sheet); Contacts (primary and
signer labelled; "Add a contact" says whom a new primary replaces); Event
details (staff-only notes; "Edit details"); Manage event (archive). Anchors:
#proposal, #contract, #payments, #record-payment, #invoice-link,
#planning, #contacts, #add-contact, #details, #edit-details,
#manage. A link to a closed section opens it; a section with unsaved
edits or a failed save can't be collapsed. The dashboard's deposit and
booking-check items link to #record-payment and #payments.
Both lists share one pattern (src/components/app/list.tsx, filters in
list-filters.tsx): title and create action, a search and filter bar that is
a plain GET form (state in the URL; selects and checkboxes apply at once),
a result line, stacked rows, distinct empty states ("No events yet", "No
events match these filters", archived matches hidden) with Clear filters,
and 25 rows per page. Reads go through public.staff_event_list and
public.staff_client_list (migration
20261022000100_staff_lists_client_archiving.sql): read-only, staff of the
business only, searching every record server-side.
?show=all link still lists every date with archived events.eventDisplayStatus in src/lib/events/status.ts): the stored lifecycle
decides the stage, and the proposal and contract records only refine the
label within it ("Proposal sent", "Choices submitted · review", "Contract
draft", "Signed · awaiting deposit", "Booked"…). Nothing is stored and no
event moves forward because of a label; a signed contract still waiting
for its deposit is never shown as booked.#add-client link)
reveals the form with its first field focused; Cancel asks before
discarding typed details.searchClients returns at most 8 active
clients of the business. A client's page has New event, which opens
the form with that client chosen (/events/new?client=<id>).Client details. Saving carries the record's updated_at; a save made
elsewhere meanwhile is refused with "Someone else changed this client's
details…" (the update only applies to the version the form was opened
with). Event details work the same way, comparing only the fields the form
edits, so status, booking and planning changes never count as conflicts.
The same email as an existing client of the business (any case, archived
included) asks for confirmation and links the existing client; nothing is
merged, and other businesses are never searched. Changing an email that
already has sign-ins, a sent contract or live proposal links asks for
confirmation and explains that none of it moves: event access belongs to
the person who signed in, a sent contract only accepts the address frozen
into it (the send check then reports signer_changed), and proposal links
keep working until they expire or are replaced. Every real email change is audited
(client_email_changed, migration 20261104000100_client_email_audit.sql):
an AFTER UPDATE trigger records the business, client, acting staff user and
membership in the same transaction, storing neither address; unchanged,
stale, retried or refused saves record nothing (pgTAP 50_client_email_audit).
Client phone numbers. The phone is optional and nothing in the schema,
proposals, sending or signing needs it. It is needed only when the
business's contract template prints {{client.phone}} (the starter DEMO
template does): generate_contract_draft refuses to generate while any
placeholder the template uses has no value. The app says so where it
matters: the phone hint on client forms (when a published template uses
it), the signer's line under Contacts, the event's Contract tile, a blocker
on "Prepare the contract", and an inline "Save phone number" form in the
contract step (it only fills an empty number). Numbers are checked in the
app (src/lib/phone.ts: 7 to 20 digits, a leading +, spaces, brackets,
dots, dashes and an optional extension) and stored as typed; an unchanged
older number is never re-checked. A contract draft keeps the details it
was generated with: when the event's title, date, venue or the signer's
phone change afterwards, the event page and the send review say so and
suggest regenerating; sent and signed contracts never change.
Client archiving. On the client page, Archive… asks for confirmation and
calls public.set_client_archived: it sets archived_at once (a repeat
changes nothing), records an audit event, and changes nothing else: events,
contact links, proposals, contracts, signed documents and client sign-ins
stay. Archived clients can't be chosen for new events or contacts; a
proposal can't be sent to an archived primary contact, nor a contract to an
archived signer, until they're restored (sending says so: "signer_archived",
migration 20261022000200_archived_signer_reason.sql, distinct from a changed
signer, which still says to regenerate). Saving a client's details no longer
touches the archived state.
Deploying. The migrations only add functions and replace one private
check's wording, so apply them before the app
(db push --linked --dry-run, then db push --linked); the previous app
keeps working on the migrated database.
Event contacts. Adding a contact goes through public.add_event_contact
(migration 20261105000100_event_contacts_dashboard_status.sql): one
transaction under the event's row lock moves the primary and/or signer role
and inserts the contact together, so a failure (a duplicate from a stale tab,
an archived or foreign client) leaves the previous primary and signer as they
were. The form names the primary it showed; if it changed meanwhile the save
is refused (PT409). public.remove_event_contact only removes a contact of the
named event. Both are staff-only, audited and refused for removed staff or a
suspended workspace; contacts stay editable on archived events (adding one
grants no access) and generated contracts keep their frozen signer. The same
migration adds the proposal and contract records to staff_dashboard's
upcoming events so the dashboard shows the shared derived status. Apply it
before the app; the direct event_clients grants stay for the previous app
(revoking them is deferred). pgTAP: 51_event_contacts; integration:
event-contacts.test.ts.
20261103000100_client_event_lists.sql replaces staff_event_list and
staff_client_list with versions that take extra optional parameters and
return extra keys; every earlier parameter and key is unchanged, so apply it
before the app as well (the previous app keeps working on it, and the new
app needs it for the type, range, order and phone search). pgTAP:
49_client_event_lists; browser: client-event-management-flow.spec.ts.
Both catalog lists follow the Events and Clients list pattern: search over
name and description on the server (containsFilter in src/lib/lists.ts:
quoted, with % and _ literal), Include archived, 25 per page, all in the
URL. Item pages show an overview with Edit details, "Used in" links and a
separate Manage section for archiving.
Package pricing. A package has one base price (before tax, in its tax
category), charged as a single line. Its included gear and quantities are
shown on proposals but never charged; extras the client adds, and any
quantity a rule requires beyond what's included, are charged at each gear
item's unit price. A package's price is not the sum of its gear.
packages.is_popular is kept in the database but no longer shown or
edited (nothing reads it, and saving details leaves it unchanged): each
template (its recommended package) and proposal chooses the most popular
package.
Included gear. Each gear item once, quantity 1 to 100. A new package
and its gear are saved together by public.create_package (migration
20261023000200_package_create.sql): both or neither, so a failed save
leaves nothing behind and keeps the form as typed. The form makes a request
id once per visit and the function uses it as the package id, so a retry
after a lost response returns the package already created ("This package
was already saved…") instead of creating another. Editing a package's gear
uses public.set_package_items, which replaces it in one call (all or
nothing). Archived gear already included stays listed and flagged;
proposals with that package can't be previewed or sent until it's removed or
restored.
Package archiving. Archive… asks for confirmation and calls
public.set_package_archived (migration
20261023000100_package_archiving.sql): it sets active only, records an
audit event, and treats a repeat as a no-op. Included gear, templates and
sent proposals are unchanged; an archived package can't be chosen for
templates or drafts, and proposals that offer it can't be previewed or sent
until another package is chosen or it's restored. Saving details never
changes the archived state. (Gear archiving is still a direct, unaudited
update of gear_items.active.)
Deploying. The two package migrations only add functions, so apply them
before the app (db push --linked --dry-run, then db push --linked); the
previous app keeps working on the migrated database. The new app needs
them to create packages and to archive or restore them.
Gear photos on proposals. The frozen offer stores, per gear item, its
active media at freeze time (storage_path, kind, content_type,
alt_text, in sort order). Only originals are stored; the proposal's media
routes (src/lib/media/proposal-media.server.ts) re-check access on every
request and resize on the fly to 400–1600 pixels (WebP, transparency
flattened onto white, private, max-age=3600 plus an ETag, never a shared
cache). On the proposal (src/components/proposal/gear-media.tsx) gear
photos sit whole on a white mat (contain); a gear item with several
photos and videos can be browsed on its card (arrows, arrow keys, swipe) and
opens the full-screen viewer at the one shown. Package photos fill their
card (cover, from the centre) and open the viewer, which always shows the
whole photo. Speed: pointing at a photo or an arrow loads what is likely
next, the viewer shows the card's copy at once while the sharper one loads,
then loads its neighbours. New photos must be at least 400 × 300 pixels;
under 1200 pixels on the long side they are accepted with a "may look
blurry when enlarged" warning (src/lib/media/resolution.ts; the server's
decode check in verify-upload.server.ts is authoritative). Existing photos
are never re-validated.
Both lists follow the catalog list pattern (search on the server, Include archived, 25 per page, all in the URL). Templates search name and intro; questions search the question.
Templates. A template offers three packages in positions 1 to 3 (the
order clients see), at most one recommended package among them (shown as
"Most popular"; the database requires it to be one of the three), optional
extras (gear with a preselected quantity and a maximum) and questions in the
order clients see them. Its contents are saved together by
public.set_proposal_template_composition; a new template and its contents
by public.create_proposal_template (migration
20261024000100_proposal_template_create.sql), keyed by a per-visit request
id like create_package, so a failed save leaves nothing and a retry after a
lost response returns the template already created. Archived packages,
questions and gear a template already uses stay listed and flagged (saving
never drops them), and the overview lists what would stop a proposal from
being sent, mirroring build_offer_snapshot, with a link to fix each. A
proposal draft copies the template when it's started or the template is
applied, so later template edits don't reach existing drafts. Archiving a
template only hides it from "Start from template".
Questions. Four answer types (yes/no, one choice, several choices, short
text); the type and key never change once saved. Choices keep their value
when relabelled; a new choice's value is made from its label. A choice that
any rule uses (active or archived) can't be removed: the database refuses the
change (validate_question_rules). Optional questions left blank apply no
rules. Question and rule edits reach every proposal previewed or sent from
then on that asks the question, drafts included; sent proposals never change.
Rules. "When [question] is [answer], require [quantity] × [gear]", with
a reason shown to the client. Conditions: is Yes/No, is one of the choices,
or (several choices) includes a choice. Short text can't have rules. In
pricing (src/lib/pricing/price-selection.ts), the quantities of all
matching rules add up per gear item; units the chosen package includes count
toward that total and only the remainder is charged, at the gear's unit
price; if the client also picked that gear as an extra, the larger quantity
is charged, not both. Rules can be edited, archived and restored, not
deleted. The database never keeps two identical active rules (same
question, answer, gear, quantity and reason; "any of" values in any order):
trigger logistics_rules_no_duplicates (migration
20261024000200_logistics_rule_duplicates.sql) refuses such an insert, edit
or restore, serialized per question, because a duplicate would double the
requirement. Duplicates saved before it are left alone and can be archived or
edited. A retried "Add rule" whose first save went through says so.
Deploying. The two migrations (20261024000100_proposal_template_create
and 20261024000200_logistics_rule_duplicates) only add functions and a
trigger, so apply them before the app (db push --linked --dry-run, then
db push --linked). The previous app keeps working on the migrated database
(an identical rule it tries to add gets a generic "already used" message).
The new app needs them to create templates and to report duplicate rules.
Migration 20261019000100_workspace_suspension.sql. A platform
administrator suspends a workspace from /platform/workspaces (business
name, address, status, creation and suspension details only; no business
data) and restores it later. Suspension is separate from archiving
(archived_at keeps its meaning, and either can change without the other).
Changing it. suspend_workspace and restore_workspace take the
workspace's suspension_version and a required internal reason (3 to 500
characters). They lock the tenant row, so concurrent and repeated requests
take effect once: a repeat of a change that already happened returns it
(replayed), and a stale version for a change that didn't is a conflict
(PT409). Each change records the administrator, database time, reason and
new version in platform_audit_events. An administrator can't suspend a
workspace they belong to, and their administration never depends on their
own workspaces. tenants.suspended_at and suspension_version can't be
written any other way, by any role (a trigger checks a marker only these
functions set). Nothing is deleted: memberships, Auth identities and all
records stay.
While suspended, for the workspace only:
member_tenant_ids and
owner_tenant_ids leave it out), so tables, Storage gear media and
signature images, staff functions and the staff PDF routes all refuse.
require_staff_of answers members with workspace_suspended (PT423).
Staff pages and Server Actions send members to /unavailable ("This
workspace is unavailable"), on the next request of any open tab. /staff
lists the workspace as unavailable. Non-members learn nothing.client_event_ids), contracts,
signing, signed PDFs, signature images and payment summaries
(signer_can_access_contract), planning, and contract invitations
(contract_invitation): pages show their usual "not available" state.
Proposal links open no session and existing sessions show and save
nothing; both say "temporarily unavailable". Their events at other
businesses are unaffected.private.tenant_write_guard, PT423), except the append-only audit log
and outbox and PDF-job bookkeeping. The guard takes a key-share lock on
the tenant row, which the suspension's row lock waits for: a write already
in progress finishes first, every later one is refused. Unsaved form input
is never reported as saved./platform/workspaces and in the platform audit log, never in member or
client responses, emails or error text.Background work.
email_outbox_dispatch_allowed). An email already handed to
the provider can't be recalled; that window is the provider call itself.
Platform emails and other businesses' emails are unaffected.Restoring only lifts the block. It sends no email, creates no link, extends no link, session, invitation or planning deadline (expiry dates kept running during the suspension), revives nothing that was revoked or cancelled, and changes no booking, payment, signature or contract. Access returns under the usual identity, membership, expiry, event, contract and planning rules.
Links are blocked, not revoked. Proposal links and sessions and contract invitations keep their rows and expiry, so a still-valid link works again after restoration. Revoking them would force every client to be re-sent links for a reversible, administrative action; the database blocks them without exception while suspended.
Migrations 20261115000100_external_bookings.sql and
20261116000100_external_booking_payment_guard.sql.
events.booking_source is flux (default, every earlier event) or
external, chosen by staff when inserting the event and never changed
(events_immutable). A check constraint keeps external events at
lead with no booking confirmation or active proposal, and proposals
can't be started for them, so no contract either.private.event_booked_for_planning: booked by Flux, or booked elsewhere.
client_can_access_plan keeps every other condition (verified access,
nothing archived or suspended, a plan, the setup gate, editing rules).planning_invitations, built like
contract invitations: the emailed token (HMAC, domain
flux:planning-invite:v1:) only asks for a verification email to the
invited address; accept_planning_invitation grants access to the
verified, current primary contact. Emails are claimed only by
claim_planning_email_outbox.event_payments and event_billing refuse every write for
external events (payment_invalid), whatever the role or path.Clients never read events directly, because RLS limits rows, not columns.
They call public.my_events(), a hardened security definer function that
returns only client-safe columns for events with active access and a verified
email.
It hides archived events and events of archived or suspended businesses,
like every other client function (migration 20261025000100).
/my)One card per event the signed-in client can open, upcoming first (by the
event's own time zone; date-only values are never shifted), past events
collapsed. The page itself stays neutral (Flux), and each card carries its
own business's current logo and colour, since a client can have events with
several DJs. Every fact comes from client views that check access in the
database: my_events, my_contracts (signer only; it names each contract's
event_id), client_contract_view, client_payment_summary, my_plans
and client_planning_view. src/lib/client-home.ts only maps those facts
to wording and one recommended action:
Someone with access who isn't the signer sees statuses and planning, never
contract, payment or signing links (contracts are readable only by their
signer). Anonymous proposal links are not connected to /my.
event_access rows can only be created by trusted server code using the
service role, after magic-link email verification (Phase 2). Staff can revoke
access but not grant it.
See .env.example. Only NEXT_PUBLIC_* values reach the browser.
| Variable | Purpose |
|---|---|
NEXT_PUBLIC_SUPABASE_URL | Supabase API URL (public) |
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY | Supabase publishable key, sb_publishable_… (public). The legacy NEXT_PUBLIC_SUPABASE_ANON_KEY is still read as a fallback. A secret or service-role key here is refused at startup. |
SUPABASE_SECRET_KEY | Supabase secret key, sb_secret_…. Server only; bypasses RLS. The legacy SUPABASE_SERVICE_ROLE_KEY is still read as a fallback. A publishable or anon key here is refused. |
NEXT_PUBLIC_APP_URL | The app's own origin, used in emailed links |
PROPOSAL_LINK_SECRET | Derives proposal link tokens. 32 or more random characters. Rotating it fails undelivered proposal emails visibly; links already delivered keep working. |
EMAIL_TRANSPORT | mailpit (local default), resend (hosted) or disabled (queue only) |
EMAIL_FROM_ADDRESS | Sender address. Emails show "{DJ} via Flux DJ" with the DJ's reply-to address. |
MAILPIT_URL | Local Mailpit, http://127.0.0.1:54324 |
RESEND_API_KEY | Required only with EMAIL_TRANSPORT=resend |
OUTBOX_WORKER_SECRET | Bearer secret for /api/internal/outbox. The endpoint returns 404 when it isn't set. |
RESEND_WEBHOOK_SECRET | Optional. The Resend webhook signing secret (whsec_…) for /api/webhooks/resend (delivery, bounce and complaint events). The endpoint returns 404 when it isn't set. |
MONITOR_HEARTBEAT_URL | Optional. An external heartbeat monitor's https ping URL (e.g. Healthchecks.io), pinged by the outbox cron after each run (/fail appended when the run had a worker-level problem). It alerts when runs stop or fail. Unset: no ping. See docs/operations.md, "Alerts". |
Hosted configuration, still to review before real clients:
/api/internal/outbox with the worker secret, for example with
Vercel Cron.X-Forwarded-For, which rate
limiting uses.The secret key bypasses RLS. It is read only through
src/lib/env.server.ts, which is marked server-only, so importing it into
client code fails the build. Never commit .env.local.
The production order, as followed for the hosted project. Apply migrations only after reviewing a dry run, and keep the review gates in spec section 12 before real clients.
supabase/migrations
applies them to the hosted database. Keep it off until you intend to
migrate.pnpm exec supabase login, then
pnpm exec supabase link --project-ref <ref>. Linking records the project
in supabase/.temp, which is git-ignored, and applies nothing.pnpm exec supabase migration list --linked should
show every migration as local-only. pnpm exec supabase db push --dry-run
previews the push without applying it.supabase config push,
which would upload the local values, including the 127.0.0.1 URLs:
https://<domain>/auth/confirm to the redirect URLs.supabase/templates/magic_link.html into the Magic Link template.
It carries both the link and the 6-digit code ({{ .Token }}) that the
home-screen app signs in with; keep both when editing it.supabase/seed.sql on hosted. It creates DEMO users and
tenants. db push does not seed unless you pass --include-seed.PROPOSAL_LINK_SECRET and OUTBOX_WORKER_SECRET, each 32 or more
random characters, never reused from localEMAIL_TRANSPORT=resend, RESEND_API_KEY and a verified
EMAIL_FROM_ADDRESS20261018000100 is applied, grant your own identity once from the SQL
editor (see "DJ invitations and new workspaces"). No new environment
variables or Auth settings are needed: invitations reuse
PROPOSAL_LINK_SECRET with their own prefix, and verification links use
the existing /auth/confirm redirect.Locally verified with the new key formats: service-role RPCs, Auth admin
(generateLink), Storage, anon denial, magic-link verification, RLS reads,
the proxy's getClaims, the SSR client, and the integration suite through
SUPABASE_SECRET_KEY and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY. The local
gateway does not reject secret keys sent from a browser; hosted Supabase
does.
docs/ Product and data specification
src/app/ Next.js routes (placeholder home page for now)
src/components/ui/ shadcn/ui components
.github/workflows/ CI (required checks) and the browser smoke run
docs/operations.md Release, migration and recovery runbook
docs/database-architecture.md Current schema map: authority, domains, access, lifecycles, migration workflow
supabase/schema-snapshot.sql Generated schema-only snapshot (pnpm db:schema); migrations are authoritative
src/lib/env.ts, env.server.ts Validated public and server-only environment
src/lib/supabase/ Browser, server and admin clients, generated DB types
supabase/migrations/ SQL migrations (source of truth for the schema)
supabase/tests/database/ pgTAP RLS, constraint and Storage policy tests
src/lib/pricing/ Pure pricing module and offer snapshot schema
src/lib/proposals/ Server-only selection pricing and recording
src/app/staff/ Staff screens and Server Actions
src/app/login, src/app/auth/ Magic-link login and confirmation
src/app/platform/ Platform administrators: DJ invitations and workspace suspension
src/app/unavailable/ Shown to members of a suspended workspace
src/lib/branding/ Brand colours and contrast; logo verification, storage and signed URLs
src/app/join/ Invited DJs: verification request and workspace creation
src/components/proposal/ Responsive proposal preview (live pricing)
src/lib/media/ File-signature detection for uploads
src/lib/proposals/ Link tokens and the client proposal session
src/lib/email/ Email templates, transports (Mailpit/Resend) and outbox worker
src/lib/contracts/ Template text format, rendered-content schema, DEMO template text
src/components/contract/ Readable, escaped contract document view
src/app/staff/[tenant]/contract-templates/ Template list, editor and publishing
src/app/staff/[tenant]/contracts/ Contract generation panel and draft preview
src/app/[tenant]/p/ Link opener and token exchange (public)
src/app/[tenant]/proposals/ Client proposal page (session cookie)
src/app/api/internal/outbox/ Outbox worker endpoint (secret required)
scripts/outbox-worker.mjs Local worker loop (pnpm outbox:work)
tests/unit/ Vitest unit tests (pricing, uploads, money)
tests/e2e/ Playwright browser tests
tests/integration/ Vitest tests against local Supabase (offers, Storage)
supabase/seed.sql Local-only seed data
tenant_id uuid not null, unique (tenant_id, id) and composite
foreign keys to parents.set_updated_at and forbid_column_changes triggers.
Add stable keys and parent references to the immutable list.authenticated and service_role. Default privileges deny it, and a
schema guard test fails if it is missing.active.pnpm db:types and pnpm check.2026111200010005_client_access | A verified client sees only their linked events, including at two different DJs; unverified, revoked or email-only matches grant nothing; clients cannot alter staff data |
06_anonymous_access | Anon has no table or function access |
07_catalog_isolation | Every catalog and template table is visible and writable only by staff of the owning tenant; clients and anon get nothing |
08_catalog_integrity | Cross-tenant composite keys for package contents, rules, templates and media; the default package must belong to its template; at most three packages per template; immutable keys; rule conditions limited to equality and membership, and checked against the question's options; media type, extension and path checks |
12_send_submit_approve | Send authorization, stale versions and missing contacts; staff can't see token hashes or sessions. Link exchange across tenants, unknown, revoked and superseded links. Safe client view, draft conflicts and invalid input. Submissions with omitted required gear, out-of-range quantities, missing answers or tampered prices, stale tabs, idempotency, approval, revision, expiry while open, the outbox and rate limits |
15_event_archiving | Only staff of the event's tenant can archive or unarchive, never directly. Links and sessions are revoked and pending client emails cancelled, while history and lifecycle status stay. Viewing, saving, submitting, link exchange, sending, approval and contract generation are blocked while archived. Unarchiving is audited and doesn't resurrect links |
17_contract_sending_client_access | Send authorization, eligibility, idempotency and frozen content. Only send_contract can send, and generation is blocked while a contract is sent. The invitation token is hashed and scoped, and only asks for verification emails, rate limited. Wrong, unverified and multi-tenant accounts. Idempotent acceptance with no staff membership. Safe DTO fields, no leakage, client and anon reads. Dispatch rechecks. Resend, void and replacement with access kept. Archive and unarchive. Revised offers void sent contracts. Expired invitations |
18_contract_void_notice | One notice per voided sent contract, none on repeats. Frozen signer, no link, no reason. Cancelled invitation emails. Memberships, identities, access and other events untouched. Deliverable at dispatch. Archiving neither sends nor cancels it. Revised-offer voids notify. Unsent drafts never do |
19_tax_settings | Owner-only tax settings for staff, other tenants, clients and anon; no direct column writes. Malformed, fractional, negative and over-100% rates, codes, names, keys and unknown taxes. Stale versions. Multiple taxes on standard. Removal rules for used taxes and categories. Explicit no tax versus missing. New offers read the new settings; the sent snapshot and hash are unchanged. Audit |
20_contract_signing | Only the service role signs. Wrong signer, other event's client, staff, stranger, no identity, wrong tenant, unverified or changed email, revoked access, archived event. Consent, consent version, displayed hash, typed name, never-stored, wrong-size, non-PNG, oversized and out-of-folder images. The atomic signed state and evidence, one audit event, no booking. Replays return the first signature to the signer only. Immutable evidence and content; void, resend, regeneration and revisions refused. Client and staff reads, Storage visibility, orphans. Archiving keeps evidence. Void and superseded contracts can't be signed |
22_client_use_signing | Usage is required when publishing; legacy can't be chosen. Client use is owner-only, needs the current statement, and stores the exact statement, owner and database time; staff publish DEMO only; other tenants get nothing. Published usage and confirmation are immutable; drafts carry none; a new version copies text, not usage. Contracts freeze mode and consent, immutably. Signing with client-v1 (the DEMO consent is refused) and exact evidence; demo-v1 unchanged. Legacy versions can't generate; none contracts can't be sent or signed and the review explains how to regenerate. Signed-copy senders are frozen when queued and at the first attempt, never change, keep older rows' first values, ignore sent rows and are service-role only |
23_manual_payments | Owner and staff record; other tenants, clients and anon can't, and no role writes rows directly. Amount, date, reference validation. Idempotent replays, reused keys, duplicate confirmation, exact cents. Invalidation with reasons, preserved history, immutability, audit. Drafts give no terms; sent terms; void and replacement without double counting; signed terms frozen after business, tax and catalog changes; overpayment as credit; zero-percent deposit. Client summary fields, other clients, void contracts, staff and anon. HTTPS invoice links, stale versions, unsafe URLs. Archiving blocks writes and the client view. No email, status, booking or contract change |
26_stage_details | Library editors and covered moments; validation (times, explicit next day, impossible and equal intervals, choices, limits, unknown fields, conflicting entrance answers); partial answers and normalization; completion (a Ceremony needs its guest arrival time; the Reception entrance needs its grand entrance decision, and its time only while planned, with older "no formal entrance" and entrance-time answers read as before, a time kept while skipped and left off the timeline) with venue and Event basics reuse, not applicable and "discuss with DJ"; overnight party and closing; chronology warnings in staff order without reordering; hidden stages keep answers; covered moments; Simple Party; other clients, other events' items, other businesses, anon and archived events; staff saves; no staff identity, payment references or notes for clients; contract, booking, payments, proposal and imports untouched; the Event basics entry point kept |
27_music | Music editors by moment key and Entrance music as the single source for reception entrance songs. Song validation: title and artist needed, limits, https links without credentials, unknown fields, cues on moment songs only, repeated or malformed ids, list and moment limits. Choices per editor; alternatives refused while songs exist. Completion: songs, DJ's choice, No requests, Nothing to exclude, not applicable without changing the structure, discuss with DJ open even with songs. Order kept by id. An identical retried import is saved once; a stale different list conflicts; a concurrent identical first save is the same save; stage details keep strict revisions. Hidden lists and stages keep their songs and leave views and progress. Other clients, other events' items, other businesses, anon and archived events; staff edits. Contract, booking, payments, proposal and imports untouched |
28_participants | Editors by moment key and Participants and names covered by Introductions (not saved, not counted); speeches wherever the library allows. Wedding starter plans without a separate Couple entrance (added by staff where wanted). Processional people saved with its songs: individual, pair and group entries without fixed labels, trimmed names and pronunciation in order, links to its own or Couple entrance songs only (another stage's or another event's refused), linked songs not removable from either (also from a stale revision), Couple entrance renames and hiding keeping links, not applicable refused while people exist, renamed and reordered songs leaving people untouched, discuss open. The grand entrance order with its own songs (shared, retried once, not removable while linked) and counted only while planned (none and not sure leave it out, keeping it); introductions sharing Entrance music songs; links to another moment, another event or malformed ids refused; removing a linked song refused naming the entries, also from a stale revision; renames, reorders and unlinked removals allowed; hidden Entrance music keeping valid links; No introductions and discuss. MC choices, details kept only for someone else. Speech timing (exact time, next day, cue, undecided), bounds, completion. Retried adds saved once; hidden moments; other clients, other events, other businesses, anon and archived events; staff edits; contract, booking, payments, proposal and imports untouched |
29_contacts_preferences | Editors on their sections; this event's contacts in the view with name and phone only. Day-of contact: not decided (open), someone else needing a phone, phone checks, an event contact by id only (no copy), a phone for the day without changing the client, contacts of another event or business and names refused, a contact leaving the event (kept, unavailable, not resaved). Vendors: name or business, roles, phone and email checks, a phone or an email needed (new vendors and vendors that had one; older vendors without either kept, renamed and never blocking other answers; staff saves too), order, none refused while listed, retried adds once, stale conflicts. Preferences: no language copy, language from Event basics, discuss open, unknown questions refused. Music styles: list order once, unknown styles, DJ's choice exclusive, other style, slow songs. Hidden sections, other clients, events and businesses, anon, archived events, staff edits; no staff ids, notes, payment references or signing evidence; clients, event contacts and contractual records untouched |
30_remaining_editors | Editors in their library places (Dinner and Party sharing one); only moment-holding stages without an editor; nothing "not available". Arrival: ceremony reuse storing no copy, open while the Ceremony lacks it or is hidden, other place with overnight times and next day, event venue unknown, "no separate arrangements" refused with details and alone not applicable. Program: own times, agenda with exact, overnight, cue and undecided timings, titles needed, "No formal program" refused with entries. Activities: songs need title and artist and safe links, custom names, Party counted separately, stale tabs, unknown fields. Dedications: unfinished kept without invented songs, any time, songs needed, no durations, "No dedications". Retried adds once, stale conflicts, hidden stages, other clients, events and businesses, anon, archived events, staff edits, stage order unchanged; contractual records untouched |
33_platform_invitations | Only the database owner grants platform administration (not the app, the API or the service role), only to one verified identity, audited. Tenant owners, staff, clients, strangers, anon and unverified administrators can't invite, list, resend or revoke; administrators see no business, client, event or email. Normalized addresses, one open invitation per address (also enforced by a unique index), hashed tokens only, emails with no tenant and only the link id. The tenant worker never claims them. Resend limits, link rotation, cancelled queued emails, audit. Verification requests: service role only, old and malformed links, masked address, 3 per 15 minutes, invited address only. Wrong, unverified, revoked and expired accounts and invitations. Names, reserved and malformed addresses, existing businesses (untouched, invitation still open). One business and one owner membership for the signed-in user, defaults, an empty workspace, replays returning the same workspace, final accepted invitations, no deletes. Existing clients and staff keep their access; other owners see nothing. Archived businesses keep their address and a retry never creates another |
34_workspace_suspension | Only platform administrators suspend, restore or list workspaces (owners, staff, clients, strangers and anon can't); the suspension columns can't be written directly by any role. Reasons required, stale versions (PT409), repeats, and an administrator can't suspend their own workspace. While suspended: staff of the workspace read and write nothing (tables, settings, payments, planning, proposals, contracts, PDF requests, private media and uploads), learning only that it is suspended; staff of other businesses and strangers get the usual refusals; other businesses unaffected. Clients: events of the other business kept, contracts, invitations, signing, signed PDFs, signature images, payment summaries and planning unavailable. Proposal links open no session; sessions show and save nothing; expired sessions stay invalid. Every write refused for every role, including the service role, except the audit log. Undelivered emails cancelled (no reason in them), nothing claimed, a claimed email stopped before sending, other businesses' emails delivered. PDF jobs not claimed; a racing commit pauses without losing an attempt. Archiving stays independent. Restore: audited, sends and revives nothing, PDF job resumes, contracts, signatures, bookings, deadlines and payments unchanged, access back, revoked and expired links still invalid, no extended expiry |
35_business_branding | Logos are registered only by the service role after the app's checks, only for the business's owner, only when the stored PNG exists with the declared size, under the business's own folder; registered logos are immutable. Staff, other owners, clients and anon can't change branding; nobody writes the logo or colour columns directly, and even privileged code can only activate a registered logo of the same business. Stale versions (PT409), colour format, another business's logo, normalization, kept colours, audit. Logo rows readable by the business's staff only; client pages get the live logo and colour of that business only. Sent proposals keep their logo and colour after replacement and removal; the frozen logo and its file stay; unused and unregistered logos are listed for clean-up. Suspended businesses can't change branding or add logos; archived ones follow their existing rules |
32_version_conflicts | No function raises serialization_failure (40001); every optimistic-version check raises PT409; the re-created functions keep their grants |
31_planning_cutoff | Owner-only, versioned setting with limits (staff, other businesses, clients, anon, direct column writes). Plans copy the days and store the deadline at setup; later setting changes leave them; a deadline already on the event is kept. Midnight in the event's zone: DST start and end days in Toronto, skipped and repeated midnight (Havana, Beirut), quarter-hour offsets, 0 days, zones east of UTC, events without a start time. States a microsecond before, exactly at and after the deadline and a reopening's end. Client view fields; saves before; after: reads continue, both save functions refused before validation and revision checks, nothing changed, progress unchanged; staff saves continue. Reopening: already open, reason, future, 14-day maximum, stale version, clients, other businesses, anon; stored expiry, deadline kept, client saves, replays, audit (staff, time, reason, before/after), nothing leaked to clients, staff history; expiry; closing early, repeats, never closing an open plan. Deadline changes and stale tabs; date and zone changes don't move it, mismatch shown, explicit recalculation. Direct writes and the trigger; archived and revoked access during a reopening; other clients and slugs; every plan has a deadline; booking and emails untouched |
25_planning | Read-only tables for staff; starter templates added explicitly and once; template ownership across businesses, clients and direct writes; rename, move, remove and add with stable keys and versions; library placement enforced by trigger; duplicate and archive; one default per event type. Booking (real signing and deposit) creates one plan from the event-type default; the Basics fallback; setup before booking kept at booking; repeats and already-booked backfill. Frozen imports after catalog changes. Client access: booked, unbooked, other client, stranger, wrong slug, revoked, unverified, two DJs, anon, archived and unarchived, payment invalidation. Basics validation, conflicts, normalization, progress (imported and not applicable versus unanswered, unavailable sections excluded). Disable and restore keep answers and order and change nothing contractual. Non-destructive template replacement. Integrity |
24_booking | Owner-only, versioned policy setting; no direct writes; only two policies. Frozen policy per contract, unchanged by later setting changes. On signature: booked when signed, with payment still due. On deposit: payments before signing count, partial payments await the deposit to the cent, the completing payment books, later payments and checks don't book again (one audit event, one email). Zero deposit. Overpayment. Corrections keep the booking and booking_confirmed_at and warn staff; the client sees the amount outstanding. Legacy contracts: nothing automatic, staff check as on_deposit (not the business's on_signature), then automatic. Archived events refused. Guards against booking outside the function, changing the date or unbooking. Tenant isolation. Older workers never claim booking emails |
21_signed_contract_pdfs | Signing queues one PDF job (none on replay) and no email. Only the service role runs jobs. Leases: no double claim, expired-lease recovery, stale leases can't fail a job. Commit validation (missing, wrong size, wrong type, wrong folder, wrong signature hash). One canonical immutable document; a second upload is "exists" with no duplicate emails. One email per party with separate dedup keys, no paths or tokens in payloads. Staff, other tenants, signer, other clients and anon. Archiving blocks the signer and cancels undelivered copies but keeps the PDF. Recipient-confirmed resend that never repeats a delivered copy. Contracts signed before PDFs: explicit generation without email |
16_business_settings_send_review | Owner-only settings for staff, other tenants, clients and anon. Validation and 0% and 100% boundaries. Half-up deposit rounding, and equality with the original 50% formula. Existing contracts are byte-for-byte unchanged with valid hashes. Explicit regeneration freezes new terms and identity. New placeholders and missing values. Every review rejection reason. Sending stays impossible |
13_contract_templates | Templates are visible and writable only by staff of their tenant; clients and anon get nothing. Unknown, malformed or expression-like placeholders are rejected, also by a CHECK constraint. Optimistic draft versions, publishing, and published versions that no role can edit, unpublish or delete. Editing opens one new draft version |
14_contract_generation | Deposit rounding. Authorization for other tenants, clients, strangers and anon. Unpublished and other tenants' template versions. Missing values listed with what to complete, and no signer. Amounts and lines from the approved selection, the documented hash, no booking and no access. Repeat clicks, explicit replacement and conflicts. Snapshot independence after client, event, business, tax, catalog and template changes. Frozen rows, cross-tenant foreign keys with the guard trigger disabled, literal rendering of client values, and superseded approvals |
09_storage_gear_media | Storage policies on the gear-media bucket: private bucket, tenant-scoped reads, uploads only under the uploader's own gear items, no overwrite or delete |
tests/e2e/music-flow.spec.ts: on a phone, the client adds, edits,
reorders and removes songs (with undo) and reloads. It pastes a list,
corrects flagged lines and imports once. It sees duplicate and
do-not-play warnings, chooses DJ's choice, not applicable and discuss with
truthful progress, and enters cue songs with instructions. Saves pending,
failing and from a stale tab are covered. Staff edit the same songs, hide
and restore a list with its songs, and see archived plans read-only;
another business gets a 404. Contract, payments and booking stay
unchanged.supabase/tests/database/31_planning_cutoff.test.sql covers the deadline
and reopening rules (see the table above).tests/unit/planning-cutoff.test.ts: deadline display in the event's zone
across DST, datetime-local values, reopening input, reasons, the locked save
result and client-safe parsing.tests/integration/planning-cutoff.test.ts: a client save that waits on a
row lock (held by another session) across the deadline is refused, and one
whose wait ends before it saves; every write path refused after the
deadline with client-safe fields, staff still saving, direct REST writes
refused; reopening expiring by database time; saves racing "close now"
(all or nothing); two staff changing the deadline or reopening at once (one
wins, one conflict, one audit), double clicks; archiving racing reopening
and saving; anon. Deadlines are placed seconds away with trusted SQL
(tests/integration/support/sql.ts), never waited for.tests/e2e/cutoff-flow.spec.ts: on phones, the owner setting (existing
plan unchanged), the client's deadline in the event's zone, a tab left open
across the deadline (locked, typed value kept, all editors read-only),
staff editing after it, reopening with a reason and expiry, the client
editing until the shown end, closing early refusing a stale tab, a moved
event date and explicit recalculation, no reasons or staff emails for the
client, contract, payments and booking unchanged.tests/unit/run-sheet.test.ts: the projection on a detailed French
wedding, a Simple Party and an incomplete plan (stage order, hidden items,
reuse, shared songs shown once, cues, overnight times, explicit choices,
conflicts, open answers, no staff-only data), then real PDFs: extracted text
checked for sections, pronunciation beside names, repeated column headers
only on overview pages, and no undefined, null, ids or contract wording.
Pages are written to review-samples/run-sheet/ for review.tests/integration/run-sheet.test.ts: linked songs resolved through the
real staff session; two readers during 15 rounds of concurrent relinking
never see a broken link; a staff change after the client deadline appears
with a new revision; clients, other businesses and anon get nothing;
archived events stay readable for staff.tests/integration/version-conflicts.test.ts: stale versions through
the real PostgREST HTTP API (booking policy, taxes, planning deadline days,
plan and template structure, contract template draft) answer 409 PT409
within 2 seconds, write nothing and cause no retry loop. Set
FLUX_REST_V14_URL to a second PostgREST on the same database to check
another version too.supabase/tests/database/36_staff_dashboard.test.sql: who may read the
dashboard (anon, other businesses, clients, suspended workspaces), each
event's own date (Kiritimati and Pago Pago), archived and past events,
ordering and the limit, submitted versus approved proposals, deposits under
the deposit policy (partial, received), 0% deposits and the signature
policy, legacy contracts before and after their check, planning closing,
later, closed, reopened, expired reopenings, past, archived, unbooked and
complete plans, email failures versus retries, other businesses and
platform emails, setup facts, and that reading changes nothing.tests/e2e/dashboard-flow.spec.ts: a new workspace (checklist, owner and
staff wording, empty states, quick actions) and a busy business (order,
a real submitted proposal, planning, a deposit, an email failure, the
attention cap and "Show all", every link) on desktop and a phone.supabase/tests/database/37_staff_lists_client_archiving.test.sql and
tests/e2e/list-pages-flow.spec.ts: who may list and archive, each
event's own date, ordering, status, search (literal % and _), archived
inclusion and counts, pages, client archive and restore (no restamp,
audited, links kept), empty states, URL state across Back, the dashboard's
Add client link, another business refused, and phone layouts.tests/e2e/event-workspace-flow.spec.ts: twelve real event states (new,
draft, sent, submitted, approved, regenerated contract draft, sent, signed
awaiting the deposit, booked, legacy, deposit corrected after booking,
archived): the next step and its links, summaries from the payment
summary, history, deep links opening sections, unsaved edits and errors
kept visible, the legacy booking check, and phone layouts at 390 and 320 px.tests/e2e/navigation-shell-flow.spec.ts: the staff and platform shell:
every sidebar link and the active item on detail pages, owner, staff and
platform-administrator menus, two businesses with wide and light logos and
one without (centred, never stretched), the account menu and business
switch, sign-out, the phone drawer's focus trap, Escape and scroll lock,
320 px, landscape and installed-app layouts. E2E_SCREENSHOTS=1 also keeps
review screenshots.tests/e2e/pwa-flow.spec.ts (browser emulation, not a real iPhone): the
manifest and icons (fields, sizes, types, nothing personal) and Chromium's
installability check with no service worker; /start for signed-out,
staff, client, both-role and several-business users; install help with the
native prompt, iPhone Safari, Chrome on iPhone, Android without a prompt,
dismissal and standalone; in standalone, the run sheet PDF through the
share sheet with the app's session, an honest error and notice offline,
ways out of every page, and no service worker, caches or private storage;
the client's planning and contract pages standalone on a phone.tests/e2e/run-sheet-flow.spec.ts: on a phone, from the event page to the
read-only run sheet (no inputs, collapsed lists, Do not play one tap away,
no sideways scrolling); the PDF downloaded through the app (same revision,
private no-store headers, content and exclusions, pages rendered to
review-samples/run-sheet/app-download/); a change after the
deadline on refresh and in a new export; signed-out, client and other
business refused; nothing contractual changed.tests/integration/planning.test.ts: concurrent payments and checks create
one plan; repeated checks and setup never replace it; concurrent setup and
starter installs; concurrent client saves (one wins, the rest conflict);
frozen wording after catalog changes; REST isolation for clients and other
businesses; a client of two DJs.tests/e2e/planning-flow.spec.ts: the Basics-only fallback and staff
prompt; starter templates, renaming, reordering, removing, a stale tab and
duplicating; the client on a phone (imported answers, autosave, edits during
a pending save, failed save and retry, stale tab); applying a template
without losing answers; stage details on a phone (venue reuse, discuss with
DJ, completion, reload, overnight times, timing warnings, edits during a
pending save, failed save, stale tab); staff edits reusing Event basics;
hiding and restoring stages with their answers; contract, payments and
booking unchanged; template edits not reaching the plan; archiving and
unarchiving.56_external_bookings (pgTAP): the booking source set once by active
staff only; no proposal, contract, payment or Flux booking state; planning
eligible only through the shared rule, after setup and an accepted
invitation; invitations refused to other businesses, clients, removed staff
and suspended workspaces; wrong and unverified accounts; replacement and
archiving; Flux events still waiting for their booking.57_external_booking_payment_guard (pgTAP): no payment, invalidation or
invoice link for an event booked elsewhere, by any role or path, with
nothing written and no status change; Flux payments unchanged; invitations
stop at a primary contact or email change.tests/integration/external-bookings.test.ts: the staff insert path, the
real invitation and verification emails, acceptance, client saves, the web
and PDF run sheet, payment refusals with the app's message, and a Flux event
still needing its booking.tests/e2e/external-booking-flow.spec.ts: the New event form, setup and
invitation from the event page, the emailed link, the client planning and
home, staff review and run sheet, lists and dashboard, and a Flux event
with an existing client still starting with a proposal.proposal_selection_drafts and is autosaved with optimistic versions.
Immutable proposal_selections rows are submissions only.approve_proposal_selection approves the exact latest
submission. The approval records the selection's SHA-256, and repeats
return the same approval. It moves the event to awaiting signature, not
booked, and the client is told the contract will follow. To change terms,
start a revised offer: it opens a draft prefilled from the current offer,
and the client reviews and submits again.after(), and
from pnpm outbox:work, which calls POST /api/internal/outbox with
OUTBOX_WORKER_SECRET. A hosted cron job can call the same endpoint.FOR UPDATE SKIP LOCKED with expiring locks, and retries
back off from 1 minute up to 1 hour.src/lib/email/transport.server.ts: Mailpit's
HTTP API locally, and Resend behind the same server-only interface for
hosting. EMAIL_TRANSPORT=resend with RESEND_API_KEY. Resend isn't used
locally or in tests.event_access idempotently, to the exact tenant, event and
signer clientA wrong account is shown only that the invitation belongs to a masked other address. No client data is accepted from the browser.
Reading. /{tenant}/contracts/{id} calls client_contract_view, a
safe DTO of frozen data. It rechecks on every read:
/my lists the client's events (see "Client home" below). Returning
clients sign in at /login with the same magic-link form and land on
/my. Signing in never grants planning access or confirms a booking.
Resend (resend_contract). It revokes earlier invitations, cancels
their pending emails, issues a fresh invitation and email, and is audited.
sent_at, content, hashes and granted access are kept. It is limited to
once every 2 minutes and 10 times a day.
Void (void_contract, with a reason). It works on sent, unsigned
contracts only. Invitations are revoked and pending contract emails
cancelled. Clients can no longer read the contract, while event access,
other events and identities are untouched. A replacement can then be
generated; generation is refused while a contract is sent.
Revised offers and archiving. A revised offer voids the sent contract automatically. Archiving revokes invitations and cancels contract emails, and unarchiving revives neither.
Withdrawn notice (contract_voided). Every void of a sent contract,
whether by staff or by a revised offer, queues one email to the frozen
signer in the same transaction, deduplicated per contract. It says the
contract is no longer available and the DJ will follow up. It carries no
link and no internal reason, and doesn't suggest the event is cancelled.
Archiving never queues or cancels it.
One sign-in per browser. Supabase keeps a single session cookie, so
opening a client sign-in link in a browser where staff are signed in
replaces the staff session. Staff pages and actions then redirect to
/login?notice=no-staff-access, which names the signed-in account and
explains this, instead of a bare 404. Nothing is changed. The redirect
happens only when the account has no staff role anywhere, so it reveals
nothing about tenants; staff of another tenant still get 404. For manual
testing, open client links in a private window or another browser.
Regeneration. Changing settings never alters a draft. Regenerate it explicitly to pick up new values.
Version choice. Generation defaults to the latest published template version, which is labelled, and staff can choose another. The balance due date is still entered per contract.
Review and send. Review and send… on a contract draft runs
review_contract_for_send, read-only. It shows:
private.contract_send_problems: archived event,
not the current draft, superseded approval, changed or missing signer,
incomplete settings, or business details or deposit changed since
generationSending. Send contract… sends from the review screen once every check passes. See "Contract sending and verified client access".
signing_mode = noneImage checks (server). Only a base64 PNG data URL of at most 256 KiB. The server accepts plain 8-bit, non-interlaced PNG only, with CRCs checked, no unknown critical chunks or trailing bytes, an exact inflate cap, dimensions within 300–1800 × 100–600, and enough dark ink over a minimum span. It then re-encodes the pixels into a fresh PNG, so nothing else from the upload is stored.
Ordering and retries. Storage and the database can't share a transaction, so the order is:
contract-signatures bucket
at a new {tenant}/{contract}/{uuid}.png, never overwriting.sign_contract (service role only). One transaction locks event,
proposal and contract, rechecks everything, and confirms the object
exists with the declared size and type and is unused. It then inserts
the immutable contract_signatures row, moves the contract
sent → signed, retires invitations and writes one audit event.private.contract_signature_orphans
lists it for manual cleanup. No automatic cleanup exists yet.Evidence. Typed name, verified email and user id, image path, SHA-256,
size and dimensions, database signing time, frozen content hash, exact
consent text and version, and user agent. The IP is recorded only on Vercel,
from x-vercel-forwarded-for (set by Vercel, not spoofable). Anywhere else
it is stored as unavailable. Evidence rows can't be updated or deleted, and
evidence stays out of the audit log and app logs.
Viewing. Staff of the tenant read evidence through row-level security,
and get two-minute signed URLs for committed signatures only, through a
Storage policy. The signer sees the signed name and time and a two-minute
link issued by the server after client_signature_object authorizes it.
Clients and anon have no Storage policy and can't read or list the bucket.
Nobody but the service role writes to it.
Protected paths. Nothing leaves signed. Void, resend, regeneration
and replacement refuse signed contracts. A signed contract blocks
commercial revisions: open_proposal_draft refuses, and a trigger refuses
superseding the signed proposal, so send_proposal rolls back. Archiving
hides the event and cuts client access but keeps the agreement and
evidence; unarchiving restores the signer's read access.
Booking. Signing confirms the booking only as the contract's frozen booking policy allows (see "Booking confirmation"). It grants no planning access and records no payment. The client sees "Contract signed.", the booking state, and "Your DJ will follow up with the next steps."
Retention is undecided. Nothing deletes signatures or evidence, and no retention period is set. Decide one (and how to handle archived tenants and deletion requests) before real clients sign. Collecting this evidence does not by itself establish legal compliance for electronic signatures; that needs review of the agreement, the consent wording and the process.
existsprivate.contract_document_orphans and are readable only by the service
role. No automatic cleanup exists yet.Downloads. /{tenant}/contracts/{id}/signed-pdf (signer) and
/staff/{tenant}/contracts/{id}/signed-pdf (staff) recheck access on every
request. They then stream the bytes after checking them against
pdf_sha256, with Content-Disposition: attachment and
Cache-Control: private, no-store. A mismatch returns an error and never
the file. The bucket has no Storage policies and there are no signed URLs.
Archiving blocks the signer's download but keeps the PDF; staff can still
download it.
Emails. A commit queues contract_signed_copy for the frozen signer
email and the frozen business contact email. Each recipient gets its own
row, dedup key and status, so a retry for one never resends the other. The
worker attaches the committed PDF after the same hash check; a mismatch
fails permanently. No bytes, paths, URLs or tokens are stored in the
outbox. Resend receives Idempotency-Key: flux-signed-copy-{row id}, which
closes the crash-after-send window for 24 hours. After that, or with
Mailpit, a crash between sending and recording can resend one copy. Mailpit
(local) and Resend (hosted) both send real attachments.
Frozen sender. Resend refuses a reused key with a different request, so
every attempt must be identical. Each row's sender freezes the business
display name (also used in the text), the from name and the reply-to when
queued, and the from address (EMAIL_FROM_ADDRESS) at the first attempt
(freeze_email_sender). Keys are set once and never change. Retries are
built only from the payload, the frozen sender and the canonical PDF, so
changing Business settings between attempts changes nothing. Rows queued
before this existed freeze the then-current settings at their next attempt;
if such a row had already reached Resend and the settings changed since,
that one retry can still get a 409 within the 24 hours. Sent rows are never
changed.
Contracts signed before PDFs. They have no job. Staff click Generate signed PDF, which sends no email. Send signed copies… is a separate step whose confirmation lists both recipients; the database checks that they match the frozen addresses.
booking_confirmed, one per event (dedup key), to the
frozen signer email, with the sender frozen like signed copies and a
Resend idempotency key. It confirms the booking and states the total, what
was received and what remains (and the deposit, if not yet received) as of
the booking, and promises no planning access. It is delivered right after
the transaction by the signing or payment action, or by the scheduled
worker, and only while the event is booked and not archived. Workers from
before this change never claim it (claim_email_outbox takes
p_include_booking, default false), so an older deployed app can't send it
as another kind of email.Deposit. It is 50% of the approved total including taxes, rounded half
up to the cent (private.contract_deposit_cents). The balance is the total
minus the deposit, and a CHECK constraint enforces that they add up.
One draft per event. An identical request returns the existing draft, which covers double clicks and concurrent calls.
replaced with replaces_id provenance.Stale terms. Generation requires the event's active proposal to be
approved. When a revised offer is sent, a trigger marks that proposal's
drafts superseded in the same transaction.
Freezing. Every contract row is content-immutable from insert, for
every role including the service role. That covers rendered content,
commercial and party snapshots, provenance, signer, amounts and hash. Only
status moves, and this step allows only draft to replaced or
superseded.
sent, signed and void statuses exist for later steps, but no
transition reaches them yet.Content hash. contracts.content_sha256 is the SHA-256 (hex) of the
UTF-8 bytes of the canonical Postgres jsonb text of:
{"schema_version": 1,
"content": rendered_content, -- title and sections exactly as shown
"commercial": commercial_snapshot, -- lines, taxes, totals, deposit, balance, due date, source approval
"parties": party_snapshot, -- business, signer, event, venue
"template": {"version_id", "content_sha256"}}
The database computes it on insert. Verify it in SQL with
private.contract_content_sha256(rendered_content, commercial_snapshot, party_snapshot, template_version_id, template_content_sha256). It is
separate from the future hash of signed PDF bytes.
Access. Staff of the tenant can read templates, versions and contracts. All writes go through membership-checked functions, except renaming or archiving a template. Clients and anonymous users have no access.
staffloginjoinplatformstart/staff/{slug}?welcome=1: a welcome card lists the
required setup steps still missing and opens the setup guide (see "Setup
guide"). Nothing is mandatory; the whole workspace stays usable.Quick actions: New event and Add client open the existing screens. The compact install help stays.
maxDurationvercel.json/api/internal/outboxAuthorization: Bearer $CRON_SECRETCRON_SECRETBearer $OUTBOX_WORKER_SECRETCRON_SECRETOUTBOX_WORKER_SECREThttps://<domain>/api/webhooks/resend with the events email.delivered,
email.delivery_delayed, email.bounced, email.complained,
email.failed and email.suppressed, then set its signing secret as
RESEND_WEBHOOK_SECRET (Production, Sensitive) and redeploy. Without it,
sent emails show "Accepted by email provider" only. Check: an unsigned
POST to the endpoint answers 401 when the secret is set, 404 when it is
missing, 503 when it isn't a whsec_… value.