Aim a bulk email at one day of a listing booked by the day - #2067
Conversation
A listing booked by the day holds several days' worth of people, and news about one of those days is not news for the rest. Bulk email could only be addressed to a whole listing, so an organiser running a term date by date had no way to write to one session without writing to all of them. Add a listing-day target beside the listing one. Both stay: a day for news about that session, the whole listing for news about the lot. The day's recipients come from the same half-open overlap predicate the capacity checks use, so a booking spanning several days answers to each of them rather than only to the day it starts on. The way in is a link on a listing's attendee list while a date is chosen, which is the page an organiser is already on when they are thinking about one day.
📝 WalkthroughWalkthroughThe change adds multi-day booking coverage to roster filtering and date options. It introduces modular bulk-email targets, including listing-day targets, database recipient selection, and owner-facing date-specific email actions. Tests cover unit, integration, and acceptance scenarios. ChangesMulti-day attendee filtering and email targeting
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Owner
participant Roster as ListingRosterPanel
participant Actions as AttendeesSection
participant Registry as target registry
participant Database as attendee queries
Owner->>Roster: Select a listing day
Roster->>Actions: Provide date-specific email URL
Owner->>Actions: Open “Email this date's attendees”
Actions->>Registry: Resolve listing-day target
Registry->>Database: Load overlapping attendee PII
Database-->>Registry: Return encrypted PII blobs
Registry-->>Owner: Open compose flow for selected day
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
✨ Simplify code
Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cae7d5ab81
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| {dateFilter && ( | ||
| <a href={`/admin/emails?listing=${listingId}&day=${dateFilter}`}> |
There was a problem hiding this comment.
Gate the date-email link to owners
When a manager views a daily listing's Attendees tab, dateFilter is enough to render this link because the tab is available to all staff, but /admin/emails is guarded by ownerResponsePage; clicking it therefore sends the manager to a page their role cannot open. Pass the session role through and render this action only for owners, matching the existing listing-level Email action.
AGENTS.md reference: AGENTS.md:L297-L303
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in src/ui/templates/admin/listings/attendees.tsx and src/ui/templates/admin/listings/roster.tsx.
The link is no longer built inline. emailDayHrefFor returns the href or undefined, and the section renders nothing when it gets undefined. Owner is one of its three conditions. ListingRosterPanel already received isOwner (set from isOwnerRole(ctx.session.adminLevel) in listing-page-data.ts), so nothing new had to be threaded through.
test/ui/templates/admin/listings/attendees/email-day-href.test.ts covers the manager case directly rather than only rendering the page as an owner.
Generated by Claude Code
| {dateFilter && ( | ||
| <a href={`/admin/emails?listing=${listingId}&day=${dateFilter}`}> |
There was a problem hiding this comment.
Suppress the link for days with no email recipients
When everyone booked on the selected day left the email field blank—or when a valid but empty date is requested manually—this condition still renders the action, while the listing-day target has allowEmpty: false and the compose handler returns a 404 after resolving zero recipients. Gate the link on the selected day having at least one emailable attendee, as the existing whole-listing Email action already does.
AGENTS.md reference: AGENTS.md:L290-L294
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in src/ui/templates/admin/listings/attendees.tsx.
emailDayHrefFor also requires the day to have someone with a ticket quantity and a non-blank email, which is what getAttendeePiiBlobsForListingDay plus dedupeEmails resolve to. It reads the day's already-decrypted attendees rather than re-resolving recipients, so unlike listingHasEmailableAttendees it costs nothing beyond the scan.
Covered in email-day-href.test.ts: no attendees, blank emails, whitespace-only emails, and a quantity-0 row.
Generated by Claude Code
| {dateFilter && ( | ||
| <a href={`/admin/emails?listing=${listingId}&day=${dateFilter}`}> |
There was a problem hiding this comment.
Make every covered day selectable for multi-day bookings
When a booking spans multiple days, this action is reachable only after selecting a dateFilter, but availableDatesFor builds the selector solely from each attendee's start date, and filterByDate also compares only that start date. Thus an operator cannot select day 2 or day 3 through the UI to use the new action, even though the new recipient query correctly considers the booking present on those days; expand the roster's selectable dates and filtering across the half-open date/end_date range.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in src/features/admin/listings-view.ts and src/shared/dates.ts.
A new coveredDays(date, endDate) enumerates the days of the half-open range, and both the picker and the filter read it. filterByDate keeps an attendee whose covered days include the chosen day, and the picker offers the union of every booking's covered days. A booking with no end_date covers only its start day, which is every standard booking and the legacy daily rows written before end dates were stored, so nothing changes for those.
This also removed a pre-existing duplicate: availableDatesFor in listing-page-data.ts and getUniqueDates in listings-view.ts were the same function. There is now one dateOptionsFor, and the roster and the CSV export share it.
Worth flagging that this changes the roster and the CSV export, not only the new action: a three-day stay now appears on the roster for all three of its days. That matches how capacity already counts it and who is actually at the door on day 2, but it is wider than "add a date filter to bulk email", so it is easy to drop from this PR if you would rather it landed separately.
test/features/admin/listings-view/multi-day.test.ts covers a middle day nobody starts on, the exclusive end day, a stay listed alongside single days inside it, the picker's ordering and de-duplication, and an undated booking. test/shared/dates/covered-days.test.ts covers coveredDays itself.
Generated by Claude Code
| if (!rawListing || !rawDay) return; | ||
| const id = parsePositiveIntId(rawListing); | ||
| if (id === null || !isIsoDate(rawDay)) return null; |
There was a problem hiding this comment.
Reject incomplete day targets instead of widening them
When a listing-day form arrives with a blank day, this early undefined lets the listing parser claim listing_id, silently widening the target from one day to the whole listing; when day is present but listing_id is missing, parsing falls through to the default active audience instead. A missing or damaged hidden field can therefore select far more recipients instead of failing validation, so distinguish an absent day field used by a genuine whole-listing form from a supplied-but-invalid listing-day target and return null for the latter.
AGENTS.md reference: AGENTS.md:L448-L456
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in src/shared/bulk-email-targets/listings.ts.
Naming a day field at all is now what claims a request for this target, so the parser takes the params object rather than two raw strings and asks params.has("day"). FormParams extends URLSearchParams, so the same check distinguishes an absent field from a blank one on both sources. An absent field returns undefined and falls through, which is how the whole-listing form keeps working. A present field that cannot produce a valid target returns null, so both widenings you named now 404 instead: a blank day beside a listing_id, and a day with no listing beside it.
test/integration/bulk-email/listing-day.test.ts asserts null for a blank day, a blank listing id, a missing listing id, three malformed days and a listing that is gone, on both the query and form paths, plus that a request naming no day is still the whole listing.
Generated by Claude Code
| {dateFilter && ( | ||
| <a href={`/admin/emails?listing=${listingId}&day=${dateFilter}`}> | ||
| {t("listings_table.email_this_date")} |
There was a problem hiding this comment.
Separate the two attendee-table actions
When a date is selected, the custom JSX runtime joins these sibling anchors with an empty string, producing </a><a ...> with no intervening whitespace or separator. The browser consequently presents the labels as the single run “Export CSVEmail this date's attendees,” with adjacent hit targets; add a separator or a flex container with a gap so the two actions remain visually distinct.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in src/ui/static/style.scss.
.table-actions is now display: flex; flex-wrap: wrap; gap: 1rem, so the gap belongs to the container rather than to a separator character between two particular anchors. Every other AttendeeTableBlock passes a single action, so nothing else changes appearance.
Generated by Claude Code
| // ── One day of a listing's recipients ─────────────────────────────── | ||
|
|
||
| /** Resolve a listing id and a day to a target, or null if either is no good. | ||
| * Both parts have to be there: a listing without a day is the whole-listing | ||
| * target, which is a different (and still offered) way to choose. */ | ||
| const listingDayTargetFrom = async ( |
There was a problem hiding this comment.
Split the target registry back under the file-size limit
This change expands bulk-email-targets.ts from 418 to 508 lines while substantially refactoring the listing-target machinery, leaving the file well beyond the repository's approximately 400-line code-file limit. Split the audience, listing, and attendee target specs into focused modules with the registry/dispatchers as a thin shell, rather than continuing to grow this already overloaded file.
AGENTS.md reference: AGENTS.md:L182-L192
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed. src/shared/bulk-email-targets.ts is gone and is now a folder, following the src/shared/ledger/ layout AGENTS.md points at:
types.ts(213) — the audience catalogue, the per-kind schemas, and theTargetSpecinterfaceaudience.ts(78),listings.ts(121),attendee.ts(40) — one module per kindregistry.ts(116) —REGISTRY,specOfand the dispatchers, as the thin shell
Only three files imported the old module, and #shared/bulk-email.ts is still the single entry point for routes and templates.
While splitting, jscpd flagged the two files' import blocks as a clone. Rather than an ignore tag, types.ts now names TargetForm and TargetPiiBlobs, which TargetSpec and the dispatchers both use, so registry.ts imports its vocabulary from one place.
test/integration/bulk-email.test.ts was 633 lines with the new block in it, so that block moved to test/integration/bulk-email/listing-day.test.ts (176). The remainder is 501 — still over the limit, but that predates this PR and splitting it is not this change's job.
Generated by Claude Code
Follows review on the listing-day bulk-email target. The target registry was one 508-line file, past the repo's ~400-line limit. It is now a folder: the audience, listing and attendee specs each in their own module, with the registry and its dispatchers as a thin shell over them. A request that names a `day` field now has to produce a valid day target or be refused. It previously fell through when the day was blank or the listing id was missing, which reached the whole listing or the default audience — far more people than the request asked for. A form that omits the field entirely is still the whole-listing target, which is how that way in keeps working. The roster's "Email this date's attendees" link is withheld unless the viewer is an owner and the day has someone with an email on it. `/admin/emails` is owner-only and 404s on an empty recipient set, so a manager or an empty day was being shown a link that breaks on click. The two table actions now sit in a flex row with a gap; adjacent anchors were rendering as one run of text. The roster's day picker and day filter now read every day a booking covers rather than only the day it starts, which is how capacity already counts a multi-day stay. Day 2 of a three-day stay was unreachable in the UI even though the recipient query considered the booking present on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UU8geYkKNGrFu5ArzfxSC6
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 71a794d652
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| WHERE listing_id = ? AND quantity > 0 | ||
| AND start_at < ? AND end_at > ? |
There was a problem hiding this comment.
Include legacy one-day rows in the recipient query
When a legacy daily booking has start_at but a null end_at, the new coveredDays branch explicitly places it on its start date, so the roster renders the attendee and emailDayHrefFor offers the new action. This SQL predicate can never match that same row because NULL > ? is false, leaving the compose route with zero recipients and a 404. Make recipient selection mirror the roster's start-day fallback for missing end dates.
AGENTS.md reference: AGENTS.md:L290-L294
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch, and fixed — but in the link rather than in the query.
The recipient query is deliberately the same half-open predicate the capacity checks use (capacity.ts:72 and :195), and getDailyListingAttendeeDates requires start_at IS NOT NULL AND end_at IS NOT NULL for the same reason. A row with no stored end is counted for no day anywhere in the app, capacity included. Making recipient selection alone disagree with that would give a day's message a different membership from the day's capacity, which is worse than the dead link.
So the broken promise was mine: coveredDays' start-day fallback let the roster place such a row on a day, and the link followed it. emailDayHrefFor now asks what the query asks — a ticket, an email, and a stored [date, end_date) range covering the day — in wouldBeEmailed (src/ui/templates/admin/listings/attendees.tsx). The roster's own filter keeps the fallback, so nothing disappears from the list; only the link is withheld.
email-day-href.test.ts covers a row with end_date: null and both ends of a three-day range.
Generated by Claude Code
There was a problem hiding this comment.
Actionable comments posted: 8
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/shared/bulk-email-targets/audience.ts`:
- Around line 23-29: Update isUpcomingListing to use Temporal for UTC day-start
comparison instead of mutating a Date. In the audience-selection logic,
distinguish an absent audience from a present blank or invalid value: preserve
"active" only when absent, and return null for blank or invalid values. Add
regression coverage for both invalid and blank audience inputs.
In `@src/shared/bulk-email-targets/types.ts`:
- Around line 210-213: Update src/shared/bulk-email-targets/types.ts lines
210-213 so fromRawField distinguishes nullish input from an explicit empty
string, returns undefined only for null or undefined, and declares its exported
curried return type. Update src/shared/bulk-email-targets/attendee.ts lines
12-16 to return null for an explicit empty token. Update
src/shared/bulk-email-targets/audience.ts lines 44-48 to default only when
audience is absent and return null for blank or invalid explicit values. Add
regression coverage in src/shared/bulk-email-targets/listings.ts lines 67-70
proving blank listing_id or listing is treated as an invalid claimed target and
cannot fall through to audience parsing.
In `@src/shared/dates.ts`:
- Around line 402-408: Shorten the comment in src/shared/dates.ts lines 402-408
to state only the non-obvious [date, endDate) contract. Remove the file header
at test/shared/dates/covered-days.test.ts lines 1-5 and the fixture narrative at
line 22; remove the comments restating getUniqueDates and dateOptionsFor in
src/features/admin/listings-view.ts lines 49-60.
In `@src/shared/db/attendees/queries.ts`:
- Around line 301-307: Update the SQL subquery in selectAudiencePiiBlobs to
alias listing_attendees as listing_attendee using AS, and qualify attendee_id,
listing_id, quantity, start_at, and end_at with that alias.
In `@src/ui/templates/admin/listings/attendees.tsx`:
- Around line 35-46: Shorten the comment above the email-action visibility logic
to one concise statement that the link must only be shown when the compose page
is reachable. In src/ui/templates/admin/listings/attendees.tsx lines 35-46,
remove the branch and route-policy explanation; in lines 242-244, remove the
duplicated explanation and retain only a brief property description.
In `@test/integration/bulk-email/listing-day.test.ts`:
- Around line 150-166: Update test/integration/bulk-email/listing-day.test.ts
lines 150-166 to assert recipients on March 4, 2026, the final covered day, and
no recipients on March 5, 2026, the following day; retain deterministic coverage
of both booking-range boundaries. Update
specs/attendees/writing-to-the-people-who-booked.feature lines 141-148 to target
day 3 so the acceptance scenario verifies the final covered day is reachable.
- Around line 110-136: Extend the “refuses a day it cannot honour” integration
test with requests to /admin/emails, asserting a 404 for an invalid day, a
deleted or unknown listing, and a valid selected day that has no recipients.
Keep the existing targetFromQuery and targetFromForm assertions, and use the
established test request/authentication helpers to verify the compose-page
contract.
In `@test/shared/bulk-email-targets.test.ts`:
- Line 3: Extend the guard tests around isBulkEmailTarget to include valid
listing-day targets with a valid ISO day, plus invalid-day cases such as
malformed or otherwise unsupported day values. Keep the existing target-kind
coverage and make the new assertions deterministic so listing-day validation
cannot regress unnoticed.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 1aa57f4a-47ec-4ea0-8db5-f5996dc48e63
📒 Files selected for processing (24)
specs/attendees/writing-to-the-people-who-booked.featuresrc/features/admin/listing-page-data.tssrc/features/admin/listing-page.tssrc/features/admin/listings-view.tssrc/locales/en/listings-table.jsonsrc/shared/bulk-email-targets.tssrc/shared/bulk-email-targets/attendee.tssrc/shared/bulk-email-targets/audience.tssrc/shared/bulk-email-targets/listings.tssrc/shared/bulk-email-targets/registry.tssrc/shared/bulk-email-targets/types.tssrc/shared/bulk-email.tssrc/shared/dates.tssrc/shared/db/attendees/queries.tssrc/ui/static/style.scsssrc/ui/templates/admin/listings/attendees.tsxsrc/ui/templates/admin/listings/roster.tsxtest/features/admin/listings-view/multi-day.test.tstest/integration/bulk-email/listing-day.test.tstest/shared/bulk-email-targets.test.tstest/shared/dates/covered-days.test.tstest/specs/steps/bulk-email.tstest/specs/support/bulk-email.tstest/ui/templates/admin/listings/attendees/email-day-href.test.ts
💤 Files with no reviewable changes (1)
- src/shared/bulk-email-targets.ts
Follows the second review round. The recipient query reads a booking's stored [date, end_date) range, and a row without one is counted for no day anywhere, capacity included. The roster's day filter places such a row on its start day, so the link was offered for a day whose compose page then 404s. The link now asks the same question the query does. Also: the recipient subquery aliases listing_attendees as listingAttendee and qualifies its columns, matching the neighbouring queries; the day target's compose control and description are covered by unit tests rather than only by the specs; the multi-day tests and the acceptance case now assert both ends of the half-open range; and the guard suite covers listing-day targets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UU8geYkKNGrFu5ArzfxSC6
The parser tests stopped at targetFromQuery. The compose page is where a refused day is actually felt, so it now answers for itself: a malformed day, a day that is not a real date, a blank day, a day with no listing, a listing that is gone, and a real day nobody booked all 404, and a day that was booked names the listing and the day it is aimed at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UU8geYkKNGrFu5ArzfxSC6
The coverage gate reads test helpers too, and both new seeds guarded a create that always succeeds. The compose-page seed now goes through createDailyTestAttendee, which is the repo's helper for exactly this and casts rather than branches; the listing-day seed drops its guard, since the assertions that follow are what report a failed seed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UU8geYkKNGrFu5ArzfxSC6
chobbledotcom/tickets#2067 is merged, so bulk email can be aimed at one date of a listing sold date by date. Three pages documented the absence of that: the online events page told organisers a session with its own link needs its own listing, the performers page told them to give a date its own listing, and the bulk email feature page listed the targets without it. That limit is what the app change was raised to close, so the pages that named it are the ones that change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UU8geYkKNGrFu5ArzfxSC6
* Cover venue, performer, free and online audiences in Perfect For The Perfect For section segmented uses by what an organiser sells, which left out two identities that search for ticketing by who they are: a room that runs a programme, and a performer selling their own dates. Free events and online events had no coverage at all. Add six pages: pubs and taprooms, music venues, community centres and village halls, performers and artists, free events, and online and hybrid events. Each states what Chobble Tickets does for that audience and what it does not, including no table diary or hourly slots, no seat map, no settlement split, no marketplace, and no joining-link field for an online session. Give every existing Perfect For page the kinds of event it covers and a Related uses block, so a reader can tell whether a page is theirs and move between neighbouring ones. Document the staff calendar feed on the feeds page and the signed, short-lived booking code on the QR page. Both exist in the app and were missing from the site. Cache the Iconify SVGs the pages need so a build does not depend on fetching them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UU8geYkKNGrFu5ArzfxSC6 * Correct promotion and news email copy, and manager access Bulk email only skips unsubscribed recipients and appends the unsubscribe footer when a message is marked as a promotion. News about something the person booked reaches everyone booked, which is what a moved start time or a cancelled session is. Five pages said otherwise, in both directions, and the online events page said an unsubscribed attendee would not receive the joining link, which would have left a paying attendee locked out. Document the distinction on the bulk email page and fix each page that got it wrong. Managers reach most of the back office, not only events and the calendar. Owner-only covers team accounts, API keys, holiday dates, confirmation settings and the public site's own pages. Correct the users page, which understated the role, and the community centres page, which overstated it. Define an API key where the staff calendar feed introduces it, state that payment-provider charges are deducted before a venue settles with an act, say that purchasable listings take one-off payments rather than recurring ones, and split a four-sentence paragraph. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UU8geYkKNGrFu5ArzfxSC6 * Correct role, retention and feed wording from review Door staff cannot work the door as editors, because check-in reads the attendee record and an editor holds no data key. Name the manager role for the door and keep the editor for a promoter who only writes listings. Records can be cleared out when a deleted listing leaves them behind, and erasing a contact leaves their bookings alone. Two pages described a general purge of any record no longer needed, which is not a control the product has. Name the feeds that update with an event change, now that the page describes four rather than two. Replace three contrast-flip headings and labels with plain ones, drop an unexplained acronym for the word it stands for, and bring the joining-link illustration brief into line with the page it illustrates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UU8geYkKNGrFu5ArzfxSC6 * Remove the ledger export claim and two more jargon terms The ledger has date filtering and no export route, so the record a treasurer reports from is read on screen. Two pages said it exports as CSV. Point at the listing and attendee exports instead, which are the ones that exist and which carry the revenue figures. Drop API keys from the sentence about who holds which login, since a hall committee choosing between an editor and a manager has no reason to meet the term, and replace an alliterative heading with a plain one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UU8geYkKNGrFu5ArzfxSC6 * Require an email address before promising to reach everyone Contact fields are configurable, so a listing that never asks for an email leaves bulk email with nowhere to send. The bulk email page said news reaches everyone booked without that condition, and the online events page turned it into a promise that nobody who paid would miss the joining link. Qualify both, and tell an online organiser to ask for an email address, since that is the case where it is the only way in. Point the listing export at the catalogue transfer page rather than backups, which documents database backups and not the portable file. Scope the no-fee sentence on the music venues page to the advance ticket, so it is not read as fixing the walk-in price. Replace an absolute heading about what a free event gets, the alliteration left in a hero after its heading was fixed, and the last unexplained mention of API keys. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UU8geYkKNGrFu5ArzfxSC6 * Scope the music venues icon label to match its paragraph The label still said the ticket price is the door price after the paragraph beside it was scoped to the advance ticket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UU8geYkKNGrFu5ArzfxSC6 * Say what online check-in lacks without denying the manual route The hybrid paragraph still said online attendees are not checked in after the general attendance wording was corrected. An online attendee has no ticket to scan, and the organiser can still mark them attended by searching the list. On the page that tells organisers to collect less, qualify the email promise by the addresses actually collected, and say plainly that the email is where a moved session is announced. The condition belongs to bulk email and stays documented there for the pages that do not raise contact fields. Replace two more headings that assert more than the section shows: free and paid nights do not run the same way, and a privacy heading should name its subject rather than instruct. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UU8geYkKNGrFu5ArzfxSC6 * Stop telling organisers a staff feed is scoped per agent API keys are created by the owner and inherit that user's access, so a calendar subscribed with one shows every booking on the site. The feeds page said a delivery agent subscribing to the staff feed would see only their own jobs, which would lead an organiser to hand a driver a key that discloses every attendee. The per-agent filtering exists, but it reads the session's role, and a calendar client authenticates with a key rather than a browser session. Say what the key grants, and point a driver at the run sheet. State the condition the door code needs before it skips the booking form, since a night that asks for an email address does not. Describe the feeds by what they do rather than by format name, and stop the online events card asserting free and paid registration behave identically. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UU8geYkKNGrFu5ArzfxSC6 * Say what running your own server means, and split a paragraph "Self-hosting the software costs no licence fee" named a thing the page never explained and left out the part a treasurer needs, which is that the server still has to be paid for. Say it plainly and link the deployment page. Stating the door code's condition took its paragraph to four sentences. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UU8geYkKNGrFu5ArzfxSC6 * Address email to a listing, not to one of its dates Bulk email targets an audience, a listing or one person. There is no date target, so the online events page recommending a twelve-session daily listing and a per-session joining link in the same breath would have sent one week's link to the whole term. Recommend a listing per session when each has its own link, and say why. Three other pages described emailing the people booked on one night or one session, which is the same mistake with a milder result. An online attendee holds the same QR ticket as anyone else; what is missing is somebody to scan it. Chobble Tickets does not run auctions, so drop that from the charity scope sentence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UU8geYkKNGrFu5ArzfxSC6 * Address a listing, and take membership payments once Two pages carried mistakes already fixed elsewhere: the performers page offered to email the people who booked a particular date, and the fundraising page listed subscriptions among the things it collects. Email is addressed to a listing, and payments are taken once rather than renewed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UU8geYkKNGrFu5ArzfxSC6 * Say that a date can be emailed on its own, now that it can chobbledotcom/tickets#2067 is merged, so bulk email can be aimed at one date of a listing sold date by date. Three pages documented the absence of that: the online events page told organisers a session with its own link needs its own listing, the performers page told them to give a date its own listing, and the bulk email feature page listed the targets without it. That limit is what the app change was raised to close, so the pages that named it are the ones that change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UU8geYkKNGrFu5ArzfxSC6 * Drop the last two statements of the limit that has gone The online events page still told organisers to split a course into one listing per session, and the free events page still said a repeating session's whole audience hears from you. Both are the same removed limit as the previous commit, in places its wording did not match. Swept the rest of pages/ and guide-pages/ for the claim rather than waiting for the next review to name another one; what is left describes the listing target, which is still there and still correct. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UU8geYkKNGrFu5ArzfxSC6 * Illustrate the online events page with a real capture The first of the new Perfect For pages to get an evidence screenshot rather than an icon list. It is taken from writing.one-day-hears-and-the-others-do-not, the case behind the app change this PR's copy now describes, so the picture proves the sentence next to it: the compose page names the Course and the one day, and counts the single person that day reaches. The theme is this repository's, in evidence-themes/one-days-audience.css. It hides the message editor, because what the case proves is who the writing would reach rather than the writing. Also stamped stay-length-on-the-page as read again. Its story changed in tickets 5473ed5, which landed before any of this, so the site's record of having read the two together predated the current wording. The words still describe the case, and importing a fresh artifact is what surfaced it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UU8geYkKNGrFu5ArzfxSC6 * Give the social card a plain label and fix the backlog total The card's heading was "Write to one date, not the whole term", which is the contrast-flip the style guide bans. It is now "Email one date of a course", which says the same thing without setting something up to knock it down. The card is drawn from that copy, so the evidence was re-read and re-imported rather than edited underneath a picture nobody redrew. The illustration backlog sentence still said 97 editorial pages after the table above it moved to 96. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UU8geYkKNGrFu5ArzfxSC6 --------- Co-authored-by: Claude <noreply@anthropic.com>
Current-system value
An organiser running a term or a residency as one daily listing can only address bulk email to the whole listing, so writing to Tuesday week 5 also writes to everyone booked on the other eleven weeks. This adds a way to write to one day, on the production compose route
/admin/emails.It came out of documenting the feature on the marketing site: the guidance I wrote told organisers to email a session's joining link to "that listing's attendees", which would have sent one week's link to the whole term. The docs now say the constraint out loud (tickets-site#146); this closes it instead.
What the PR grew into
Review turned up four things that had to come with the feature. Each is small, but none is "add a date filter", so they are called out rather than buried:
bulk-email-targets.tswas already 418 lines and this change would have taken it to 508, past the ~400-line limit. It is nowbulk-email-targets/{types,audience,listings,attendee,registry}.ts, following thesrc/shared/ledger/layout, with the registry and dispatchers as a thin shell. No behaviour moved with it./admin/emailsis owner-only and 404s on an empty recipient set, so a manager, an empty day, or a booking with no stored date range would each have been shown a link that breaks on click.daymust produce a valid day target or be refused. Previously a blankdayfell through to the whole listing and adaywith no listing fell through to the default audience — both quietly reaching more people than the request asked for.Behaviour contract
Trusted facts
listing_idin the requestgetListingWithCount; a missing listing yieldsnulland the route 404s, exactly as the existing listing target doesdayin the requestIsoDateSchema, which rejects both malformed and impossible days (2026-02-30)start_at/end_atdateToRange, the same helper the capacity checks useThe day is an observed request parameter, never inferred from the listing.
Valid states
The target union gains a fourth variant; nothing about the existing three changes.
listingIdis one shared field declaration, so a listing id cannot mean two different things across the two listing-scoped variants. A day without a listing is not representable, and a listing without a day is the existing whole-listing target rather than a half-built day one.Commands and events
{listingId, day}nullpath as the listing targetFailure table
dayis not a real calendar daydaypresent but blank, or without a listingallowEmpty: false), matching the listing target rather than sending to nobodyNo new state transitions, no new external calls, no new persistence. This is a read-side narrowing of an existing recipient query.
The multi-day decision
A booking spanning several days answers to each day it covers, so a hall booked Friday to Sunday hears about Saturday. The query uses the same half-open overlap predicate as
buildDailyListingCountSql(start_at < endAt AND end_at > startAt), so the people a day's message reaches are exactly the people that day counts against capacity. Matching only the start date would have been simpler and wrong for hire.The same predicate is why a booking with no stored
end_atis reachable on no day at all:getDailyListingAttendeeDatesand the capacity checks already exclude such rows, and having recipient selection alone disagree would give a day's message a different membership from the day's capacity. The link asks the same question the query does, so it is withheld rather than promising a page that 404s.Spec
Three scenarios under a new Rule in
specs/attendees/writing-to-the-people-who-booked.feature:writing.one-day-hears-and-the-others-do-not— Rachel books day 1, Marco day 8; writing to day 1 reaches Rachel and not Marcowriting.the-whole-listing-still-reaches-every-day— the same two people, addressed as a listing, both hearwriting.a-stay-hears-about-every-day-it-covers— Priya books days 1–3 and hears about day 3, the last day the stay coversThey drive the real UI, including following the new link rather than constructing its URL, so removing the way in fails the story.
Beyond the specs:
test/integration/bulk-email/listing-day.test.tscovers parsing and recipients including both ends of the half-open range;test/integration/server/bulk-email/compose.test.tsproves the six 404 cases through the real route;test/ui/templates/admin/listings/attendees/email-day-href.test.tscovers when the link is offered, including as a manager; andtest/features/admin/listings-view/multi-day.test.tscovers the roster's view of a stay.Checks
deno task specs— 232 scenarios, 1525 steps, all passdeno task test— passes, including the coverage gatedeno task lint— cleandeno task cpd— 0 clones. It found some in earlier drafts, so the two listing-scoped specs share their description, their log/single-recipient members and their query prefix; the spec-support writers share one function; andtypes.tsnamesTargetForm/TargetPiiBlobsso the registry imports its vocabulary from one place rather than repeating an import blockdeno task check:copy— passesdeno task typecheck— the 5 pre-existing errors inscripts/anduptime-kuma/socket.ts, unchanged; verified identical on the base commitNotes for review
src/locales/en/listings-table.jsongains one string, "Email this date's attendees".src/ui/static/style.scssmakes.table-actionsa flex row with a gap. The JSX runtime joins sibling anchors with no separator, so two actions rendered as one run of text.test/integration/bulk-email.test.tsis still over the 400-line limit at 501. That predates this PR; the new block went into its own file rather than growing it further.Generated by Claude Code