Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ All notable changes to OpenSignup are documented here. Format follows [Keep a Ch
## [Unreleased]

### Added
- Participants are emailed when the organizer moves their slot to a new date or time (#524). The email ("Time changed: <signup> · <slot>") prints the slot's new details and the participant's link to change or cancel, and goes about 10 minutes after the organizer's last change, so a date and then a time, or a date typed day by day, send one email for where the slot ended up, and a slot moved back before then sends none. It follows the reminders setting: with reminders off, or for someone who unsubscribed, nothing is sent, and the unsubscribe link and page now say they stop these emails too. A reminder for a time the participant was already told about reads as a normal "Reminder"; "Time changed" now means their last email, whatever it was, was for another time. Moves are made through the Build tab, a group heading or the `update_slot` tool; a field change that re-times every slot (a deleted time field, a new reminder date) sends nothing. In the Build tab, a slot's editor now says how many people are signed up for it and whether they'll be told if it moves. Each move writes a `slot.rescheduled` activity row (`{ slotId, from, to }`) and each email a `slot.change_sent` row. The worker gains two queues, `slot-changes.dispatch` (every 2 minutes) and `slot-changes.send`; it creates them itself when it starts, so there is nothing to migrate.
- `pnpm eval:mcp`, an opt-in eval of what a connected AI assistant builds through the MCP tools (#254). It sends each case in `evals/magic-compose/dev-evals.json` to a tool-calling model with the server instructions and the tool list, takes the arguments of its `create_signup` call, and scores them with the checks the Magic Compose evals use, now shared in `scripts/lib/eval-harness.ts`. It reuses the `LLM_*` settings and needs no database. Like `pnpm eval:magic-compose`, it is run by hand, never in CI. Two new cases check that a signup for parents at each game gets one dated slot per game, with the right capacity, and no fields for names, emails or what someone brings.
- Follow-up cases for `pnpm eval:mcp` (#477), in `evals/mcp/followup-evals.json`: the organizer says more after the assistant has made the signup, as in "add an earlier slot for setup", "each dish needs to feed 20 people, about 200 people in total" and "show me what you made". A case lists those messages in `followUps`. Every tool call in these cases is answered by the real MCP tools against the local Postgres (`DATABASE_URL`, as for `pnpm test:db`), with a new organizer and workspace for each case, and the case is scored on the tools called after the first message, the signup's slots before and after, and the last reply (a Markdown table with a row per slot, and `links.edit`). The single-message cases run as before and still need no database. `pnpm eval:mcp --tag=follow_up` runs only the new ones.
- An instance can add its own links to the site footer, shown before Help, by listing them in `src/lib/footer-links.ts`. The list is empty here, so the footer is unchanged; a deployment that serves pages of its own fills in its own copy, and every page links to them without that deployment editing the footer.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ Participant emails (confirmation and reminder) describe a slot by printing **eve

### Jobs

pg-boss runs against the same Postgres (schema `pgboss`). The Next.js server **does not** run the worker — `pnpm worker` (`src/jobs/worker.ts`) is a separate process. Three queues: `housekeeping` (hourly cron; `src/jobs/housekeeping.ts` deletes expired `oauth_records`, closed `rate_limits` windows, used or expired login codes (`magic_links`) and expired Auth.js `verification_tokens` — reads already ignore expired rows, so a missed run costs disk, not correctness), `reminderDispatch` (cron every 10 min, selects commitments whose reminder has come due — slot still ahead and within the fixed `REMINDER_LEAD_HOURS` of 24; `sendReminders` in the signup's settings is the only off switch) and `reminderSend` (per-commitment send with retries). Nothing is scheduled at commit time: the dispatch scan is the scheduler, and a `reminder.sent` activity row for the slot's current time (its `slotAt`; a row without one counts for any time) is what stops a commitment being selected again, so moving a slot after its reminder went out re-arms it. The `singletonKey: commitmentId` on each send dedupes nothing (pg-boss enforces it only on `short`, `singleton` and `stately` queues or with `singletonSeconds`, and these queues are `standard`), so a commitment still due at the next scan gets another job; what stops a second email is `sendReminderJob` checking for that `reminder.sent` row before it sends. That check is not a lock: it holds only while a single worker process runs send jobs one at a time. Two `pnpm worker` processes could each take a job for the same commitment, both find no row, and both send, so an instance runs exactly one worker (README and `docker-compose.prod.yml` say so). A slot's instant comes from `slots.slot_at`, derived from the date field `settings.reminderFromFieldRef` names (see `src/lib/reminder-fields.ts`); the field services keep that ref valid. A date-only slot anchors at 12:00Z rather than midnight, so the 24h-before reminder lands on the day before in every timezone from UTC-11 to UTC+11; that instant is for *scheduling only*, and anything that must know whether a slot has a real time of day has to ask `slotTimeOfDay` rather than read the instant, since a genuine 12:00 slot stores the same value. Emails never render `slot_at` — they print the slot's own field values (see Email below), which sidesteps the question. Worker liveness is observable from the web process: `GET /api/public/health` reports `worker: ok | stale | unknown` by checking `pgboss.job` for a recent dispatch completion (`src/lib/worker-health.ts`) — informational only, never fails the HTTP check.
pg-boss runs against the same Postgres (schema `pgboss`). The Next.js server **does not** run the worker — `pnpm worker` (`src/jobs/worker.ts`) is a separate process. Five queues: `housekeeping` (hourly cron; `src/jobs/housekeeping.ts` deletes expired `oauth_records`, closed `rate_limits` windows, used or expired login codes (`magic_links`) and expired Auth.js `verification_tokens` — reads already ignore expired rows, so a missed run costs disk, not correctness), `reminderDispatch` (cron every 10 min, selects commitments whose reminder has come due — slot still ahead and within the fixed `REMINDER_LEAD_HOURS` of 24; `sendReminders` in the signup's settings is the only off switch) `reminderSend` (per-commitment send with retries), and `slotChangeDispatch` (cron every 2 min) with `slotChangeSend`, which email the people holding a slot the organizer moved to a new date or time (`src/jobs/slot-changes.ts`). `updateSlot` records a `slot.rescheduled` row (`from`, `to`) whenever a slot's instant changes; the scan picks a commitment once its slot has gone `SLOT_CHANGE_SETTLE_MINUTES` (10) without another move and the time the participant was last told (`toldDifferentTime` in `src/jobs/notify-rules.ts`: the newest confirmation, reminder or `slot.change_sent`) is not the slot's time now. Change emails follow the reminders' switches, `notifiableRules` in the same file, so `sendReminders` off or a participant's unsubscribe stops both, and a reminder reads "Time changed" only when the participant's last email was for another time. Nothing is scheduled at commit time: the dispatch scan is the scheduler, and a `reminder.sent` activity row for the slot's current time (its `slotAt`; a row without one counts for any time) is what stops a commitment being selected again, so moving a slot after its reminder went out re-arms it. The `singletonKey: commitmentId` on each send dedupes nothing (pg-boss enforces it only on `short`, `singleton` and `stately` queues or with `singletonSeconds`, and these queues are `standard`), so a commitment still due at the next scan gets another job; what stops a second email is `sendReminderJob` checking for that `reminder.sent` row before it sends. That check is not a lock: it holds only while a single worker process runs send jobs one at a time. Two `pnpm worker` processes could each take a job for the same commitment, both find no row, and both send, so an instance runs exactly one worker (README and `docker-compose.prod.yml` say so). A slot's instant comes from `slots.slot_at`, derived from the date field `settings.reminderFromFieldRef` names (see `src/lib/reminder-fields.ts`); the field services keep that ref valid. A date-only slot anchors at 12:00Z rather than midnight, so the 24h-before reminder lands on the day before in every timezone from UTC-11 to UTC+11; that instant is for *scheduling only*, and anything that must know whether a slot has a real time of day has to ask `slotTimeOfDay` rather than read the instant, since a genuine 12:00 slot stores the same value. Emails never render `slot_at` — they print the slot's own field values (see Email below), which sidesteps the question. Worker liveness is observable from the web process: `GET /api/public/health` reports `worker: ok | stale | unknown` by checking `pgboss.job` for a recent dispatch completion (`src/lib/worker-health.ts`) — informational only, never fails the HTTP check.

### Magic Compose (AI-drafted signups)

Expand Down
9 changes: 9 additions & 0 deletions docs/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ by `clientDomain` instead.
|---|---|---|---|
| `slot.created` | organizer | `{ slotId }` from `addSlot`; `{ count, bulk: true, slotIds, beforeSlotId? }` from `addSlotsBulk` (the bulk endpoint and the `add_slots` tool), one row per call | `services/slots.ts` |
| `slot.updated` | organizer | `{ slotId, changed }` (the keys the update sent) | `services/slots.ts` |
| `slot.rescheduled` | organizer | `{ slotId, from, to }` (the slot's time before and after, ISO or `null`), alongside the `slot.updated` of an edit that moved it | `services/slots.ts` (`updateSlot`; a field change that re-times every slot writes none) |
| `slot.reordered` | organizer | `{ slotIds }` (every slot, in the new order; written even when the order did not change) | `services/slots.ts` (`reorderSlots`, from the `reorder_slots` tool; a drag in the Build tab writes a `slot.updated` per moved slot instead) |
| `slot.deleted` | organizer | `{ slotId, commitmentsRemoved, places }` (the sign-ups deleted with it, as commitments and as places) | `services/slots.ts` |
| `commitment.removed` | organizer | `{ commitmentId, participantId, slotId }` (an organizer took the person off their slot on the Responses tab; nobody is emailed) | `services/commitments.ts` (`removeCommitment`) |
Expand Down Expand Up @@ -100,6 +101,7 @@ without one has not always had a send fail.
| event | actor | payload | fired from |
|---|---|---|---|
| `reminder.sent` | system | `{ commitmentId, participantId, channel: 'email', slotAt }` | `jobs/reminders.ts` |
| `slot.change_sent` | system | `{ commitmentId, participantId, channel: 'email', slotAt }` | `jobs/slot-changes.ts` |
| `reminder.opted_out` | participant | `{ participantId }` | `services/reminder-optout.ts`, from a reminder's unsubscribe link or the mail provider's one-click unsubscribe |
| `reminder.opted_in` | participant | `{ participantId }` | `services/reminder-optout.ts`, from the unsubscribe page, turning reminders back on |

Expand All @@ -108,6 +110,13 @@ reminder only for that time, so a slot the organizer moves after its reminder
went out gets one more for the new time. Rows from before `slotAt` was recorded
have none and stop any further reminder for that commitment.

`slot.change_sent` is the email telling a participant their slot moved, sent
once the slot has gone 10 minutes without another `slot.rescheduled`. Its
`slotAt` is the time it told them. What a participant was last told is the
newest of their confirmation, reminders and change emails; a confirmation
names no time, so its time is the `from` of the first `slot.rescheduled` after
it. Reminders off or an opt-out stops change emails too.

The opt-out rows are written only when the setting changes, so a repeated
unsubscribe adds nothing.

Expand Down
17 changes: 17 additions & 0 deletions scripts/render-email-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { renderEmail } from '@/email/render';
import { CommitmentConfirmationEmail } from '@/email/templates/commitment-confirmation';
import { MagicLinkEmail } from '@/email/templates/magic-link';
import { ReminderEmail } from '@/email/templates/reminder';
import { SlotChangedEmail } from '@/email/templates/slot-changed';

const cases: Array<[string, ReactElement]> = [
[
Expand Down Expand Up @@ -57,6 +58,21 @@ const cases: Array<[string, ReactElement]> = [
notes: 'Bringing grapes',
}),
],
[
'slot-changed',
createElement(SlotChangedEmail, {
participantName: 'Dana',
signupTitle: 'Saturday Snack Rotation',
manageUrl: 'https://example.test/s/snacks/c/com_1?token=abc',
unsubscribeUrl: 'https://example.test/s/snacks/unsubscribe?p=par_1&token=abc',
slotDetails: [
{ label: 'Date', value: 'Sun, Sep 6' },
{ label: 'Snack', value: 'Fruit' },
],
slotSummary: 'Sun, Sep 6 · Fruit',
notes: null,
}),
],
];

/**
Expand All @@ -69,6 +85,7 @@ const expected: Record<string, string> = {
'magic-link': 'https://example.test/login/confirm?token=abc',
'commitment-confirmation': 'https://example.test/s/snacks/c/com_1?token=abc',
reminder: 'Sat, Sep 5',
'slot-changed': 'Sun, Sep 6',
};

async function main(): Promise<void> {
Expand Down
23 changes: 13 additions & 10 deletions src/app/(legal)/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export default function PrivacyPage() {
<>
<header className="space-y-2">
<h1 className="text-3xl font-semibold tracking-tight">Privacy policy</h1>
<p className="text-sm text-ink-muted">Last updated: 20 October 2026</p>
<p className="text-sm text-ink-muted">Last updated: 23 October 2026</p>
</header>

<section className="space-y-3">
Expand Down Expand Up @@ -88,8 +88,9 @@ export default function PrivacyPage() {
<li>Display name.</li>
<li>
Email address, if you give one. The organizer decides whether a signup requires it. We
use it to send you a confirmation with your link to change or cancel, and reminders if
the organizer has them on. If you leave it blank where that is allowed, we send you no
use it to send you a confirmation with your link to change or cancel, and, if the
organizer has reminders on, reminders and an email when your slot moves to a new date
or time. If you leave it blank where that is allowed, we send you no
emails. Your link is then shown on screen after you sign up, and only that browser
remembers it: for 60 days, or until a week after your slot if that is later.
</li>
Expand Down Expand Up @@ -133,15 +134,17 @@ export default function PrivacyPage() {
<section className="space-y-3">
<h2 className="text-xl font-semibold tracking-tight">Email delivery</h2>
<p>
{INSTANCE_NAME} sends three kinds of email: organizer magic-link sign-in emails, a
confirmation to a participant when they commit to a slot, and a reminder before a dated
slot. A participant gets these only if they gave an email address. Moving to a different
{INSTANCE_NAME} sends four kinds of email: organizer magic-link sign-in emails, a
confirmation to a participant when they commit to a slot, a reminder before a dated
slot, and an email when the organizer moves a participant&apos;s slot to a new date or
time. A participant gets these only if they gave an email address. Moving to a different
slot gives your spot a new private link, and sends a fresh confirmation with it. The
old link then leads to the new one. Confirmations and reminders each contain a private
old link then leads to the new one. Each of these participant emails contains a private
link that lets you change or cancel that commitment without signing in, so treat them as
you would a password and don&apos;t forward them. Reminders go out the day before the slot;
organizers can turn them off for a signup entirely. Every reminder carries a link that stops
reminders for that signup; we record only the time you opted out, and it does not affect
you would a password and don&apos;t forward them. Reminders go out the day before the slot,
and a moved-slot email about 10 minutes after the organizer&apos;s last change to its date
or time; organizers can turn both off for a signup entirely, with the reminders setting.
Every reminder and moved-slot email carries a link that stops both for that signup; we record only the time you opted out, and it does not affect
any other signup or your slot itself. Delivery uses whichever transport the operator has
configured (SMTP, a transactional provider, or local console output in
development). No marketing email is ever sent.
Expand Down
15 changes: 9 additions & 6 deletions src/app/s/[slug]/unsubscribe/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,15 +92,16 @@ export default async function UnsubscribePage({ params, searchParams }: PagePara
<p className="text-sm text-ink-muted">
{done === 'off' ? (
<>
You won&apos;t get any more reminder emails for <strong>{target.signupTitle}</strong>.
Your slot is unchanged — if you also want to give it up, open the link in your
You won&apos;t get any more reminders for <strong>{target.signupTitle}</strong>, or
emails when the organizer changes your slot&apos;s date or time. Your slot is unchanged — if you also want to give it up, open the link in your
confirmation email and cancel there.
</>
) : (
<>
Reminders for <strong>{target.signupTitle}</strong> are off
{target.participantEmail ? ` for ${target.participantEmail}` : null}. You won&apos;t
get any reminder emails about it.
get reminders about it, or emails when the organizer changes your slot&apos;s date or
time.
</>
)}
</p>
Expand Down Expand Up @@ -136,14 +137,16 @@ export default async function UnsubscribePage({ params, searchParams }: PagePara
{done === 'on' ? (
<>
You&apos;ll get reminders for <strong>{target.signupTitle}</strong> again
{target.participantEmail ? ` at ${target.participantEmail}` : null}. You can stop them
{target.participantEmail ? ` at ${target.participantEmail}` : null}, and emails when your
slot&apos;s date or time changes. You can stop them
any time from the link in one.
</>
) : (
<>
We&apos;ll stop sending{target.participantEmail ? ` ${target.participantEmail}` : null}{' '}
reminders for <strong>{target.signupTitle}</strong>. This only affects this signup, and
your slot stays as it is.
reminders for <strong>{target.signupTitle}</strong>, and emails when the organizer
changes your slot&apos;s date or time. This only affects this signup, and your slot
stays as it is.
</>
)}
</p>
Expand Down
1 change: 1 addition & 0 deletions src/components/build-wysiwyg/BuildWysiwyg.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ export function BuildWysiwyg({
displayFields={displayFields}
fields={state.fields}
takenBySlot={committedBySlot}
remindersOn={state.reminderFieldRef !== null}
expandedRowId={expandedRowId}
onExpandRow={setExpandedRowId}
onEditCell={editCell}
Expand Down
Loading
Loading