Skip to content

Let organizers make the participant email optional from the Settings tab - #389

Merged
richshaw merged 24 commits into
mainfrom
claude/issue-378-email-setting
Oct 10, 2026
Merged

richshaw merged 24 commits into
mainfrom
claude/issue-378-email-setting

Conversation

@richshaw

@richshaw richshaw commented Oct 4, 2026 •

Copy link
Copy Markdown
Owner

Fixes #378. Part of the work for #347.

Stacked on #385. Must merge after #383 too: the Settings help text and the help page point to removing someone on the Responses tab, which #383 adds. The full order is #380 → #383 → #379 → #381 → #385 → #389.

The problem

  • A signup's requireEmail setting could only be changed through the AI connector or the API. Organizers had no way to make the email optional themselves.
  • The "Sorry, this link isn't working" page told everyone to try the link in their confirmation email. Someone who signed up without an email has none.
  • Nothing told an organizer what to do when someone without an email loses their link.

The fix

  • The Settings tab has a new "Sign-up form" section: "Ask for an email address", Required (the default) or Optional, then Save. After a save, "Saved" shows next to the button.
  • A line under the choices says what the one picked means:
    • Required: "We email everyone their link to change or cancel."
    • Optional: "People can leave it blank. They get no emails, not even reminders, so they need to save their link. If they lose it, they can sign up again and you can remove the old one on the Responses tab."
    • CSS shows the line for the radio picked (group-has-[…:checked]), so it changes as soon as the radio does, before a save, with no script. Each radio is described by its own line for screen readers.
  • It is a plain server form, so the tab adds no script. It sends only requireEmail, which updateSignup merges over the stored settings.
  • A refused save shows on the tab in plain words, not the service's developer message.
    • A viewer reads "You don’t have edit access." The Build tab used to say "You no longer have edit access.", though a viewer never had it. Both tabs now share the new words.
  • Delete on the same tab now puts its refusal in the same plain words, for example "No longer available.", instead of "signup not found".
  • The Build tab's "Saved" look and its error copy move into one shared, server-safe module (src/components/ui/save-notice.tsx). Settings reuses them instead of adding a third style.
  • The form spaces its parts with a gap, so it doesn't shift after a save.
  • The "link isn't working" page now tells everyone to contact the organizer. It mentions the confirmation email only for those who got one.
  • Help, "Create and publish your first signup": a new section, "Let people sign up without an email", with a screenshot.
    • Its first step warns first that a phone's Settings link at the very top is for the account, then points at the signup's Settings tab.
    • It says the email box will read "Email (optional)" before the reader chooses Save.
    • When someone loses their link: ask them to sign up again. If their slot is full, remove them on the Responses tab first. Otherwise remove the old one, which is higher up the list (it's oldest first).
    • It says "email address" for what people type, and says everyone sees their link on screen after signing up. The passages Let participants sign up without an email where the organizer allows it #385 added get the same words.
  • docs/writing-help.md: "sign-up" joins the words not to use for a spot. Beside "signup" it reads as the same word spelt two ways, and the participant pages now say "spot" (Show that a sign-up was cancelled when its edit link is opened again #380).

Tests

  • actions.db.test.ts runs the actions against Postgres:
    • an owner's save is stored and comes back saying Saved;
    • a viewer is refused with "You don’t have edit access.", and nothing is written or revalidated;
    • a missing signup reads "No longer available.", for Save and for Delete.
  • SaveStatus.test.tsx: the Build tab tells a refused viewer "You don’t have edit access."
  • organizer.spec.ts:
    • only the line for the choice picked shows, it swaps when the radio changes before a save, and each radio is described by its own line;
    • Optional, then a participant signs up with no email, then Required again.
  • The help walkthrough clicks through the new steps, sees the sign-up on the Responses tab, and takes the screenshot.
  • Both specs set up their signup through a shared createPublishedSignup helper.
  • gone-message.test.ts checks the new wording.
  • All of these pass:
    • pnpm lint && pnpm typecheck && pnpm knip && pnpm test (1088)
    • pnpm test:db (401)
    • pnpm build && pnpm budgets (the Settings page is 123.6 kB of 125; /s/[slug] is 130.2 kB of 131)
    • e2e: organizer, public-commit, edit-token, a11y, commit-sheet, signup-not-found and every help walkthrough

Left out on purpose

  • "Saved" stays on screen until the next navigation, like ?published=1 and ?disconnected=1 elsewhere.
  • Save writes the choice on screen even if another tab changed the setting since. An explicit Save of what you see should win, and the form only sends the one setting it owns.
  • Publish, Close and Delete still send a viewer to the error page, because the refusal is thrown rather than returned. That predates this PR and is A viewer who chooses Publish, Close or Delete gets the error page #390.
  • The help page doesn't name or link the Remove button: it comes from Let organizers remove one person from a slot #383, which isn't in this branch.
  • Renaming the phone header's "Settings" link to "Account", which would let the help drop its warning. It and the account page's heading are on main, and another help page and three tests use the name, so it's a separate change.

🤖 Generated with Claude Code

https://claude.ai/code/session_0159b8Nfy3w7F8BVwvn37Fc3


Generated by Claude Code

claude added 8 commits October 4, 2026 14:59
Moving a commitment to another slot cancelled it and signed up again
through commitToSlot with the participant's email, which looked the
participant up by that email. A following change makes the email
optional, and without one the move would add a second participant row
(and a participant.created event) each time.

The move now commits the participant it already has, through an
internal commitParticipantToSlot that commitToSlot also calls after
parsing its input. The move's name, notes and quantity are still
checked by the sign-up rules. The sign-up request sends the same
statements as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b8Nfy3w7F8BVwvn37Fc3
Groundwork for #378, which lets organizers make the participant email
optional. Nothing writes a participant without an email yet, so nothing
users see changes. This makes the column nullable and every reader of it
ready for the null, so the next PR only has to allow it.

participants.email and email_lower may both be null, and a check keeps
them null together. The unique index on (signup_id, email_lower) stays
as it is: NULLs are distinct there, so many such participants can share
a signup. Such a participant gets no confirmation email and no reminder
(the scan skips them and the send job checks again), their edit page
leaves the address out, and the Responses tab shows a dash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b8Nfy3w7F8BVwvn37Fc3
Review fixes for the slot move. The move took its quantity and notes from
the read made before its transaction, so a change from another tab could
be undone. It now takes them from the row its cancel locks, unless the
request sends its own.

A name the move sends is checked before anything is written, and the
error is returned rather than thrown. A name it leaves out is not checked
or written back, so a blank stored name can still move and a rename made
meanwhile is kept.

The participant update now runs once for an existing participant, by
either path, and only matches a participant in the slot's signup and
workspace. Anything else is refused as forbidden, as a move to another
signup's slot is. The sign-up request sends the same statements as
before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b8Nfy3w7F8BVwvn37Fc3
Brings in the move fixes: quantity and notes from the locked row, the
name checked before the transaction and written only when sent, and the
participant update scoped to the slot's signup and workspace. A
participant without an email still moves, through the same scoped
update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b8Nfy3w7F8BVwvn37Fc3
The participants table already takes a null email (#381). This makes it
real: the signup's existing requireEmail setting decides whether the
sign-up form asks for one. With it off, a blank email adds a new
participant with no email and the confirmation screen says the link
will not be emailed. With it on, a blank email is refused before
anything is written. A move never checks it, so people who signed up
without an email keep their spots if the organizer turns it back on.

The AI connector can now read and set requireEmail, and the privacy and
cookies pages say what happens without an email. The Settings tab switch
comes next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b8Nfy3w7F8BVwvn37Fc3
Review fixes for the participant email groundwork. A participant without
an email gets no confirmation of a move, and the move's response and
cookie did not carry the new commitment either, so they lost their way
back to it. A move now returns the new edit token, edit link and links
as signing up does, and swaps the new commitment into the returning-
participant cookie for the old one.

No email now means exactly null: the check also refuses a blank email or
email_lower, and the send guards test for null as the reminder scan
does. Inserts take both columns from participantEmailColumns, whose type
keeps the pair together. The migration adds the check NOT VALID and then
validates it.

Also: the reminder scan selects the email column without a cast, the dev
reminder script and the telemetry doc say a participant without an email
gets nothing, the Responses tab's dash reads "No email" to a screen
reader, and the changelog says GET /api/commitments/[id] can return a
null participantEmail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b8Nfy3w7F8BVwvn37Fc3
Brings in the stricter participant email check (no email means null, never
blank), participantEmailColumns, the move's new edit link and cookie, and
the null guards on sends. The sign-up insert now takes its email columns
from participantEmailColumns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b8Nfy3w7F8BVwvn37Fc3
The signup's requireEmail setting could only be changed through the AI
connector or the API. The Settings tab now has a "Sign-up form" section
with "Ask for an email address": Required (the default) or Optional,
saved by a plain server form so the tab ships no new script. It sends
only requireEmail, which updateSignup merges over the stored settings,
and a viewer's refusal shows as the tab's error.

The "link isn't working" page no longer tells everyone to try the link
in their confirmation email, since someone who signed up without an
email has none. The help article explains the setting, and its
walkthrough clicks through it and takes the new picture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b8Nfy3w7F8BVwvn37Fc3
Comment thread src/app/s/[slug]/gone-message.ts Resolved
Comment thread CHANGELOG.md Resolved
Comment thread tests/e2e/organizer.spec.ts Resolved
Comment thread tests/e2e/organizer.spec.ts Resolved
Review fixes for signing up without an email.

- null in the email field now means no email, as blank and missing do.
- The sign-up form no longer fills a remembered email into an optional
  box, and a blank sign-up keeps the remembered email instead of wiping it.
- If the organizer makes the email required after the page loaded, the
  refused blank email turns the box required, label and help text included.
- update_signup no longer advertises defaults for its settings, so a client
  that fills them in cannot make the email required again by accident.
- The success screen said the link was the only way back, but the browser
  remembers it too. It now says so, in one paragraph for both cases.
- The privacy page says the organizer decides whether the email is required,
  and the help pages no longer say everyone gives an email and gets one; the
  AI assistant page lists the setting it can now change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b8Nfy3w7F8BVwvn37Fc3

@richshaw richshaw left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

I reviewed only this PR's own commit (7c86ffa), diffed against its merge-base with #385's branch (e9bf7b9). The code-review pass posted 14 inline comments. I checked each one against the code: 10 hold up and are ranked below, and I withdrew 4 in place (reasons at the end). I found no blocking problems. The policy guard still judges the save (updateSignup calls requireWorkspaceWrite), the action sends only requireEmail, and the screenshot is exactly twice its stated size. src/help/articles.test.tsx and gone-message.test.ts pass at this head (26 tests).

Findings, ranked

Medium

  1. settings/page.tsx:83: The on-screen help says "If they lose it, you can remove them on the Responses tab". On this branch the Responses tab has no remove action. That comes from #383, which is still open and is stacked on a different branch (claude/issue-375-cancelled-edit-link). If this PR merges first, the UI promises a way out that doesn't exist. The PR body already keeps Remove out of the help page for this reason, but not out of the Settings text.

Low
2. create-and-publish-a-signup.tsx:191 (posted on line 178, because 191 isn't in the diff): The Reminders paragraph still says "If a slot has a date, people who take it get a reminder email". The new section says the opposite for anyone who left the email blank.
3. gone-message.ts:36: In "If you got a confirmation email, try its link again, or contact the organizer…", the "contact the organizer" part can be read as falling under the "if". Someone with no email then has no clear next step.
4. settings/page.tsx:93: "Saved" comes from ?saved=1 and is never cleared. It stays next to a radio that was changed but not saved, and comes back on reload. ?published=1 and ?disconnected=1 work the same way, so this is minor.
5. tests/e2e/organizer.spec.ts:101: The PR body and CHANGELOG say a viewer is refused and sees the tab's error, but no test runs the new ServiceException catch.

Nits
6. CHANGELOG.md:21: The CHANGELOG still says "Settings keeps only the danger zone".
7. actions.ts:45: The tab's error shows raw service messages, such as "your role cannot modify this workspace". deleteSignupAction and Connected apps already do the same, so this is polish, not a regression. The comment's "legacy groupByFieldRefs with two entries" case is speculative: the schema has always capped that list at one.
8. actions.ts:47: Only this action catches the guard's throw. publishAction and deleteSignupAction still send a viewer to the error page. A shared helper would cover all of them. This gap was there before the PR.
9. settings/page.tsx:94: This is another one-off "Saved" notice (CLAUDE.md asks to reuse banners and notices). SaveStatus is a client component, so reusing it as it is would add script to this page. Extracting a small server-safe notice is the option that keeps the page script-free.
10. tests/e2e/organizer.spec.ts:107: The post() helper and the create, add-slot and publish setup are duplicated in the help walkthrough. Inline setup is already the usual pattern across the e2e specs.

Withdrawn after verification (edited in place)


Generated by Claude Code

claude added 5 commits October 4, 2026 16:36
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b8Nfy3w7F8BVwvn37Fc3
Brings in #382, #386 and #388 (edit-token ops metered and read in one place).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b8Nfy3w7F8BVwvn37Fc3
…tice

The Settings tab showed the service's own message when a save was
refused ("your role cannot modify this workspace"). It now maps the
error code to the same words the Build tab uses, which move with the
Build tab's "Saved" look into one server-safe module, so the Settings
"Saved" is no longer a third style and the page ships no new script. A
database test runs the action as a viewer: the refusal comes back in
those words and nothing is written.

The form spaces its parts with a gap rather than sibling margins: the
server's render starts it with React's hidden action inputs and a client
render doesn't, so the fieldset moved 4px after a save. The help step
names the signup's Settings tab, since a phone also shows an account
Settings link at the top.

The "link isn't working" page now sends everyone to the organizer, and
only those who got one to their confirmation email. The two e2e specs
that set up a published signup share one helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b8Nfy3w7F8BVwvn37Fc3
Brings in main (#382, #386, #388) by way of #379; route.ts takes authorizeTokenOp and keeps the move's edit link and cookie swap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b8Nfy3w7F8BVwvn37Fc3
Brings in main (#382, #386, #388) by way of #379 and #381.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b8Nfy3w7F8BVwvn37Fc3
claude added 3 commits October 4, 2026 19:52
…slot

Fixes from the whole-stack review, for someone who signs up without an email.

- The success screen offers Copy link in place of Share link. On a phone,
  Share link opened the share sheet with the private link that can cancel
  the spot. Copy link says "Link copied." in a live region, or says to copy
  the link by hand when the browser won't. With an email, nothing changes.
- The optional email box's help no longer says "optional" a second time. It
  says what an email brings, the link and a reminder (only where that slot
  will get one, by the rule the confirmation email uses), and to save the
  link without one.
- The os_commit cookie remembers each sign-up until a week after its slot,
  and never less than 60 days, up to the browser's 400. Someone who signed
  up early for a slot more than 60 days away lost their way back. Each entry
  carries its own expiry; old entries still read, and get 60 days on the
  next rewrite, which also drops expired ones. It holds 36 entries, down
  from 40, to stay under 4096 bytes. The slot's instant comes back with the
  commit and the move, from the row already locked, so no new statement.
- The privacy page no longer says the link is shown only once; the cookies
  page gives the new lifetime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b8Nfy3w7F8BVwvn37Fc3
Brings in #385's review fixes (Copy link, the optional email's help, the
cookie that lasts until after the slot) and main's #388 change to
/api/commitments/[id]. The one conflict was the CHANGELOG's #378 entry:
kept #389's Settings tab wording and took #385's new sentences about the
help text and Copy link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b8Nfy3w7F8BVwvn37Fc3
Fixes from the whole-stack review.

- The Settings tab's help said people without an email see their link
  "only once", and offered only removing them. It now says they see it on
  screen and only that browser remembers it, and that if they lose it they
  can sign up again while the organizer removes the old sign-up.
- The help page says the same, and says to remove the old sign-up first
  when the slot is full. Its walkthrough opens the Responses tab, and the
  Settings screenshot is re-taken for the longer help text.
- A viewer was told "You no longer have edit access", though they never had
  it. Both tabs now say "You don't have edit access."
- Delete on the Settings tab showed the service's own message ("signup not
  found") while Save on the same tab used plain words. It maps the refusal
  by its code the same way. A viewer's Delete still throws; that is #390.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b8Nfy3w7F8BVwvn37Fc3
claude added 2 commits October 4, 2026 20:26
Once #383 lands, each Responses row also has a Remove cell named
"Remove Sam Example from …", so a partial match on Sam Example finds two
cells and the walkthrough fails in strict mode. Found by running the
whole stack combined.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b8Nfy3w7F8BVwvn37Fc3
The line under "Ask for an email address" described leaving the email
blank even while Required was selected. Each choice now has its own
shorter line: Required says everyone is emailed their link, Optional
says what leaving it blank means and what to do about a lost link.
CSS shows the line for the radio picked, so it follows the choice
before a save with no script, and each radio is described by its own
line for screen readers. The help screenshot is re-taken.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b8Nfy3w7F8BVwvn37Fc3
A participant without an email showed a dash, with "No email" for
screen readers only. A bare dash means nothing without its column
heading, and a row laid out for a phone has none, so the cell now
says "No email" to everyone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b8Nfy3w7F8BVwvn37Fc3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b8Nfy3w7F8BVwvn37Fc3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b8Nfy3w7F8BVwvn37Fc3
A fact-check and a style review of the help page found:

- Losing a link said to remove the old "sign-up" after they sign up
  again, then that a full slot needs the removal first. It now says the
  full-slot case first, and that the old one is higher up the list,
  which is oldest first. "Sign-up" next to "signup" read as one word
  spelt two ways; the participant pages already say "spot".
- "Email (optional)" changes what everyone sees, so it now comes before
  Save rather than after it.
- On a phone, the header's Settings link comes before the tab, so the
  step warns about it first.
- "Email" meant both what people type and the message. Passages this
  stack wrote now say "email address" for what people type, and say
  everyone sees their link on screen, not only people without an email.

docs/writing-help.md adds "sign-up" to the words not to use for a spot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b8Nfy3w7F8BVwvn37Fc3
Base automatically changed from claude/issue-378-optional-email-signup to main October 10, 2026 20:53
Main now has the rest of the stack. Merged against #385's last head,
since main has #385 squashed, which leaves two conflicts, both with
#383:

- actions.ts keeps this branch's setRequireEmailAction and #383's
  removeCommitmentAction, with both sets of imports;
- the Settings page imports both setRequireEmailAction and FormError.

The tree is the combined one the whole stack was tested on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b8Nfy3w7F8BVwvn37Fc3
@richshaw
richshaw merged commit 73e17bf into main Oct 10, 2026
5 checks passed
@richshaw
richshaw deleted the claude/issue-378-email-setting branch October 10, 2026 20:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Let organizers make the participant email optional

2 participants