Skip to content

Aim a bulk email at one day of a listing booked by the day - #2067

Merged
stefan-burke merged 5 commits into
mainfrom
claude/bulk-email-date-target
Aug 10, 2026
Merged

Aim a bulk email at one day of a listing booked by the day#2067
stefan-burke merged 5 commits into
mainfrom
claude/bulk-email-date-target

Conversation

@stefan-burke

@stefan-burke stefan-burke commented Aug 10, 2026

Copy link
Copy Markdown
Member

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:

  1. The target registry was split into a folder. bulk-email-targets.ts was already 418 lines and this change would have taken it to 508, past the ~400-line limit. It is now bulk-email-targets/{types,audience,listings,attendee,registry}.ts, following the src/shared/ledger/ layout, with the registry and dispatchers as a thin shell. No behaviour moved with it.
  2. The roster's day picker and day filter now span a booking's covered days. They read only the start date before, so day 2 of a three-day stay could not be selected at all — which made the new action unreachable for exactly the bookings the recipient query handles most carefully. This changes the roster and the CSV export, not only the new action: a three-day stay now appears on all three of its days. That matches how capacity already counts it and who is at the door on day 2, but it is the one behaviour change here that reaches beyond bulk email, so it is easy to pull out if you would rather it landed separately.
  3. The action is withheld unless it would work. /admin/emails is 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.
  4. A request naming a day must produce a valid day target or be refused. Previously a blank day fell through to the whole listing and a day with no listing fell through to the default audience — both quietly reaching more people than the request asked for.

Behaviour contract

Trusted facts

Fact Why it may be trusted
listing_id in the request Resolved through getListingWithCount; a missing listing yields null and the route 404s, exactly as the existing listing target does
day in the request Parsed with the existing IsoDateSchema, which rejects both malformed and impossible days (2026-02-30)
A booking's start_at / end_at Written by the booking path from dateToRange, the same helper the capacity checks use

The 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.

type BulkEmailTarget =
  | AudienceTarget      // a named audience
  | ListingTarget       // { listingId }            — every day
  | ListingDayTarget    // { listingId, day }       — one day
  | AttendeeTarget      // { token }

listingId is 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

Starting state Command or event Required result
Attendee list filtered to a date Follow "Email this date's attendees" Compose page targeting {listingId, day}
Compose page for a day Preview Recipients are the people whose booking covers that day
Compose page for a day Send Written to exactly those people; logged against the listing
Compose page for a day Listing deleted meanwhile 404, via the same null path as the listing target
Any listing Follow the listing's own Email action Unchanged — every day's attendees

Failure table

Work completed Failure Required result Retry owner
Nothing day is not a real calendar day 404, no recipients resolved Request
Nothing day present but blank, or without a listing 404 — refused rather than widened to the listing or the default audience Request
Nothing Listing gone 404, as for the whole-listing target Request
Recipients resolved No one booked that day 404 (allowEmpty: false), matching the listing target rather than sending to nobody Request
Send attempted Provider fails Unchanged from every other target — this change adds no new external boundary Existing send path

No 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_at is reachable on no day at all: getDailyListingAttendeeDates and 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 Marco
  • writing.the-whole-listing-still-reaches-every-day — the same two people, addressed as a listing, both hear
  • writing.a-stay-hears-about-every-day-it-covers — Priya books days 1–3 and hears about day 3, the last day the stay covers

They 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.ts covers parsing and recipients including both ends of the half-open range; test/integration/server/bulk-email/compose.test.ts proves the six 404 cases through the real route; test/ui/templates/admin/listings/attendees/email-day-href.test.ts covers when the link is offered, including as a manager; and test/features/admin/listings-view/multi-day.test.ts covers the roster's view of a stay.

Checks

  • deno task specs — 232 scenarios, 1525 steps, all pass
  • deno task test — passes, including the coverage gate
  • deno task lint — clean
  • deno 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; and types.ts names TargetForm/TargetPiiBlobs so the registry imports its vocabulary from one place rather than repeating an import block
  • deno task check:copy — passes
  • deno task typecheck — the 5 pre-existing errors in scripts/ and uptime-kuma/socket.ts, unchanged; verified identical on the base commit

Notes for review

  • src/locales/en/listings-table.json gains one string, "Email this date's attendees".
  • src/ui/static/style.scss makes .table-actions a 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.ts is 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

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.
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The 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.

Changes

Multi-day attendee filtering and email targeting

Layer / File(s) Summary
Booking date coverage and roster filtering
src/shared/dates.ts, src/features/admin/listings-view.ts, src/features/admin/listing-page-data.ts, test/shared/dates/covered-days.test.ts, test/features/admin/listings-view/multi-day.test.ts
Booking intervals expand to covered calendar days. Roster filtering and date options include multi-day bookings while excluding undated attendees and non-daily listings.
Bulk-email target specifications and registry
src/shared/bulk-email-targets/*, src/shared/db/attendees/queries.ts, src/shared/bulk-email.ts, src/features/admin/listing-page.ts, test/shared/bulk-email-targets.test.ts
Bulk-email target contracts and specifications are split into modules. The registry resolves audience, listing, listing-day, and attendee targets. Listing-day targets load recipients whose bookings overlap the selected day.
Admin listing-day email action
src/ui/templates/admin/listings/attendees.tsx, src/ui/templates/admin/listings/roster.tsx, src/locales/en/listings-table.json, src/ui/static/style.scss, test/ui/templates/admin/listings/attendees/email-day-href.test.ts
Owners can open a date-specific email flow when the selected day has ticketed attendees with usable email addresses. The action appears beside CSV export.
Listing-day behavior validation
test/integration/bulk-email/listing-day.test.ts, test/specs/steps/bulk-email.ts, test/specs/support/bulk-email.ts, specs/attendees/writing-to-the-people-who-booked.feature
Tests cover parsing, serialization, invalid inputs, day-specific recipients, whole-listing recipients, and bookings spanning multiple days.

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
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: targeting a bulk email to attendees booked for one listing day.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/bulk-email-date-target
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch claude/bulk-email-date-target

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +250 to +251
{dateFilter && (
<a href={`/admin/emails?listing=${listingId}&day=${dateFilter}`}>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

Comment on lines +250 to +251
{dateFilter && (
<a href={`/admin/emails?listing=${listingId}&day=${dateFilter}`}>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

Comment on lines +250 to +251
{dateFilter && (
<a href={`/admin/emails?listing=${listingId}&day=${dateFilter}`}>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

Comment thread src/shared/bulk-email-targets.ts Outdated
Comment on lines +354 to +356
if (!rawListing || !rawDay) return;
const id = parsePositiveIntId(rawListing);
if (id === null || !isIsoDate(rawDay)) return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

Comment on lines +250 to +252
{dateFilter && (
<a href={`/admin/emails?listing=${listingId}&day=${dateFilter}`}>
{t("listings_table.email_this_date")}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

Comment thread src/shared/bulk-email-targets.ts Outdated
Comment on lines +345 to +350
// ── 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 (

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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 the TargetSpec interface
  • audience.ts (78), listings.ts (121), attendee.ts (40) — one module per kind
  • registry.ts (116) — REGISTRY, specOf and 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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread src/shared/db/attendees/queries.ts Outdated
Comment on lines +304 to +305
WHERE listing_id = ? AND quantity > 0
AND start_at < ? AND end_at > ?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 5473ed5 and 71a794d.

📒 Files selected for processing (24)
  • specs/attendees/writing-to-the-people-who-booked.feature
  • src/features/admin/listing-page-data.ts
  • src/features/admin/listing-page.ts
  • src/features/admin/listings-view.ts
  • src/locales/en/listings-table.json
  • src/shared/bulk-email-targets.ts
  • src/shared/bulk-email-targets/attendee.ts
  • src/shared/bulk-email-targets/audience.ts
  • src/shared/bulk-email-targets/listings.ts
  • src/shared/bulk-email-targets/registry.ts
  • src/shared/bulk-email-targets/types.ts
  • src/shared/bulk-email.ts
  • src/shared/dates.ts
  • src/shared/db/attendees/queries.ts
  • src/ui/static/style.scss
  • src/ui/templates/admin/listings/attendees.tsx
  • src/ui/templates/admin/listings/roster.tsx
  • test/features/admin/listings-view/multi-day.test.ts
  • test/integration/bulk-email/listing-day.test.ts
  • test/shared/bulk-email-targets.test.ts
  • test/shared/dates/covered-days.test.ts
  • test/specs/steps/bulk-email.ts
  • test/specs/support/bulk-email.ts
  • test/ui/templates/admin/listings/attendees/email-day-href.test.ts
💤 Files with no reviewable changes (1)
  • src/shared/bulk-email-targets.ts

Comment thread src/shared/bulk-email-targets/audience.ts
Comment thread src/shared/bulk-email-targets/types.ts
Comment thread src/shared/dates.ts Outdated
Comment thread src/shared/db/attendees/queries.ts
Comment thread src/ui/templates/admin/listings/attendees.tsx Outdated
Comment thread test/integration/bulk-email/listing-day.test.ts
Comment thread test/integration/bulk-email/listing-day.test.ts
Comment thread test/shared/bulk-email-targets.test.ts
claude added 2 commits August 10, 2026 13:56
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
@stefan-burke
stefan-burke added this pull request to the merge queue Aug 10, 2026
Merged via the queue into main with commit 14975b7 Aug 10, 2026
2 checks passed
@stefan-burke
stefan-burke deleted the claude/bulk-email-date-target branch August 10, 2026 14:32
stefan-burke pushed a commit to chobbledotcom/tickets-site that referenced this pull request Aug 10, 2026
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
stefan-burke added a commit to chobbledotcom/tickets-site that referenced this pull request Aug 10, 2026
* 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>
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.

2 participants