diff --git a/CHANGELOG.md b/CHANGELOG.md index a244f71a..79fedd68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: · ") 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. diff --git a/CLAUDE.md b/CLAUDE.md index a9d62153..28338d6a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/docs/telemetry.md b/docs/telemetry.md index f30f261e..fef2d1e5 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -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`) | @@ -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 | @@ -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. diff --git a/scripts/render-email-templates.ts b/scripts/render-email-templates.ts index af4b0220..7bb76036 100644 --- a/scripts/render-email-templates.ts +++ b/scripts/render-email-templates.ts @@ -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]> = [ [ @@ -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, + }), + ], ]; /** @@ -69,6 +85,7 @@ const expected: Record = { '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 { diff --git a/src/app/(legal)/privacy/page.tsx b/src/app/(legal)/privacy/page.tsx index 74cca928..dfc61f67 100644 --- a/src/app/(legal)/privacy/page.tsx +++ b/src/app/(legal)/privacy/page.tsx @@ -23,7 +23,7 @@ export default function PrivacyPage() { <>

Privacy policy

-

Last updated: 20 October 2026

+

Last updated: 23 October 2026

@@ -88,8 +88,9 @@ export default function PrivacyPage() {
  • Display name.
  • 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.
  • @@ -133,15 +134,17 @@ export default function PrivacyPage() {

    Email delivery

    - {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'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'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't forward them. Reminders go out the day before the slot, + and a moved-slot email about 10 minutes after the organizer'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. diff --git a/src/app/s/[slug]/unsubscribe/page.tsx b/src/app/s/[slug]/unsubscribe/page.tsx index d46ae3e0..af0dec61 100644 --- a/src/app/s/[slug]/unsubscribe/page.tsx +++ b/src/app/s/[slug]/unsubscribe/page.tsx @@ -92,15 +92,16 @@ export default async function UnsubscribePage({ params, searchParams }: PagePara

    {done === 'off' ? ( <> - You won't get any more reminder emails for {target.signupTitle}. - Your slot is unchanged — if you also want to give it up, open the link in your + You won't get any more reminders for {target.signupTitle}, or + emails when the organizer changes your slot'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 {target.signupTitle} are off {target.participantEmail ? ` for ${target.participantEmail}` : null}. You won't - get any reminder emails about it. + get reminders about it, or emails when the organizer changes your slot's date or + time. )}

    @@ -136,14 +137,16 @@ export default async function UnsubscribePage({ params, searchParams }: PagePara {done === 'on' ? ( <> You'll get reminders for {target.signupTitle} again - {target.participantEmail ? ` at ${target.participantEmail}` : null}. You can stop them + {target.participantEmail ? ` at ${target.participantEmail}` : null}, and emails when your + slot's date or time changes. You can stop them any time from the link in one. ) : ( <> We'll stop sending{target.participantEmail ? ` ${target.participantEmail}` : null}{' '} - reminders for {target.signupTitle}. This only affects this signup, and - your slot stays as it is. + reminders for {target.signupTitle}, and emails when the organizer + changes your slot's date or time. This only affects this signup, and your slot + stays as it is. )}

    diff --git a/src/components/build-wysiwyg/BuildWysiwyg.tsx b/src/components/build-wysiwyg/BuildWysiwyg.tsx index a96fa91d..bf235ce7 100644 --- a/src/components/build-wysiwyg/BuildWysiwyg.tsx +++ b/src/components/build-wysiwyg/BuildWysiwyg.tsx @@ -209,6 +209,7 @@ export function BuildWysiwyg({ displayFields={displayFields} fields={state.fields} takenBySlot={committedBySlot} + remindersOn={state.reminderFieldRef !== null} expandedRowId={expandedRowId} onExpandRow={setExpandedRowId} onEditCell={editCell} diff --git a/src/components/build-wysiwyg/InlineFieldForm.tsx b/src/components/build-wysiwyg/InlineFieldForm.tsx index b5603f62..e3c72053 100644 --- a/src/components/build-wysiwyg/InlineFieldForm.tsx +++ b/src/components/build-wysiwyg/InlineFieldForm.tsx @@ -1,7 +1,8 @@ 'use client'; import { useEffect, useId, useRef, useState } from 'react'; -import { ArrowLeft, Info } from 'lucide-react'; +import { ArrowLeft } from 'lucide-react'; +import { InlineNote } from '@/components/ui/inline-note'; import { FIELD_TYPE_META } from '../build-shared/fieldTypes'; import { FIELD_TYPES, @@ -186,17 +187,10 @@ export function InlineFieldForm({ {reminderBlocked && ( -
    -
    + + {reminderFieldLabel} is already the + reminder field for this signup. Turn it off there to use this one instead. + )} )} diff --git a/src/components/build-wysiwyg/SlotEditor.test.tsx b/src/components/build-wysiwyg/SlotEditor.test.tsx index b6ce432a..5d2a17f2 100644 --- a/src/components/build-wysiwyg/SlotEditor.test.tsx +++ b/src/components/build-wysiwyg/SlotEditor.test.tsx @@ -28,6 +28,8 @@ function makeRow(overrides: Partial = {}): BuildRow { function renderEditor(overrides: { row?: BuildRow; fields?: BuildField[]; + taken?: number; + remindersOn?: boolean; onCellChange?: Mock<(fieldRef: string, value: string) => void>; onCapacity?: Mock<(capacity: number | null) => void>; onAddEnumOption?: Mock<(fieldId: string, value: string) => void | Promise>; @@ -45,6 +47,8 @@ function renderEditor(overrides: { { + it('says nothing about sign-ups on a slot nobody holds', () => { + renderEditor({ taken: 0 }); + expect(screen.queryByRole('note')).toBeNull(); + }); + + it('warns that the people holding the slot will be emailed about a move', () => { + renderEditor({ taken: 2, remindersOn: true }); + expect(screen.getByRole('note').textContent).toBe( + '2 people are signed up for this slot. If you change its date or time, we\u2019ll email them to let them know.', + ); + }); + + it('warns that nobody is told when reminders are off', () => { + renderEditor({ taken: 1, remindersOn: false }); + expect(screen.getByRole('note').textContent).toBe( + '1 person is signed up for this slot. Reminders are off, so we won\u2019t email them if you change it.', + ); + }); + it("stops a text value at its field's maxLength", () => { renderEditor({ fields: [ diff --git a/src/components/build-wysiwyg/SlotEditor.tsx b/src/components/build-wysiwyg/SlotEditor.tsx index 4ae28ff4..00ec7de1 100644 --- a/src/components/build-wysiwyg/SlotEditor.tsx +++ b/src/components/build-wysiwyg/SlotEditor.tsx @@ -3,12 +3,21 @@ import { useId } from 'react'; import { Check, Copy, Hash, Trash2 } from 'lucide-react'; import { FIELD_TYPE_META, FIELD_VALUE_INPUT_CLASS, FIELD_VALUE_INPUTS } from '../build-shared/fieldTypes'; +import { InlineNote } from '@/components/ui/inline-note'; import { EnumPicker } from './EnumPicker'; import type { BuildField, BuildRow } from '../build-shared/useBuildState'; type SlotEditorProps = { row: BuildRow; fields: BuildField[]; + /** Spots taken on this slot when the page loaded (`committedBySlot`). */ + taken: number; + /** + * Whether the people holding this slot are emailed when its date or time + * changes: the signup sends reminders. The change email follows the same + * switch (src/jobs/slot-changes.ts). + */ + remindersOn: boolean; onCellChange: (fieldRef: string, value: string) => void; onCapacity: (capacity: number | null) => void; /** @@ -30,6 +39,8 @@ type SlotEditorProps = { export function SlotEditor({ row, fields, + taken, + remindersOn, onCellChange, onCapacity, onAddEnumOption, @@ -71,6 +82,14 @@ export function SlotEditor({ + {taken > 0 && ( + + {signedUpLine(taken)}{' '} + {remindersOn + ? 'If you change its date or time, we\u2019ll email them to let them know.' + : 'Reminders are off, so we won\u2019t email them if you change it.'} + + )}
    {fields.map((f) => { const fieldType = f.config.fieldType; @@ -143,3 +162,9 @@ export function SlotEditor({
    ); } + +function signedUpLine(taken: number): string { + return taken === 1 + ? '1 person is signed up for this slot.' + : `${taken} people are signed up for this slot.`; +} diff --git a/src/components/build-wysiwyg/WysiwygGroup.test.tsx b/src/components/build-wysiwyg/WysiwygGroup.test.tsx index 7edbc67d..c4f366c1 100644 --- a/src/components/build-wysiwyg/WysiwygGroup.test.tsx +++ b/src/components/build-wysiwyg/WysiwygGroup.test.tsx @@ -64,6 +64,7 @@ function renderGroup(overrides: { displayFields={overrides.displayFields ?? []} fields={overrides.fields ?? (groupField ? [groupField] : [])} takenBySlot={{}} + remindersOn={false} expandedRowId={overrides.expandedRowId ?? null} onExpandRow={onExpandRow} onEditCell={onEditCell} @@ -215,6 +216,7 @@ describe('WysiwygGroup', () => { displayFields={[]} fields={[makeField()]} takenBySlot={{}} + remindersOn={false} expandedRowId={null} onExpandRow={vi.fn()} onEditCell={vi.fn()} diff --git a/src/components/build-wysiwyg/WysiwygGroup.tsx b/src/components/build-wysiwyg/WysiwygGroup.tsx index 914591c0..26d157c6 100644 --- a/src/components/build-wysiwyg/WysiwygGroup.tsx +++ b/src/components/build-wysiwyg/WysiwygGroup.tsx @@ -28,6 +28,8 @@ type WysiwygGroupProps = { fields: BuildField[]; /** Spots taken per slot id; a slot with none is absent. */ takenBySlot: Record; + /** Whether the signup sends reminders, and so emails about a moved slot. */ + remindersOn: boolean; expandedRowId: string | null; onExpandRow: (rowId: string | null) => void; onEditCell: (rowId: string, fieldRef: string, value: string) => void; @@ -50,6 +52,7 @@ export function WysiwygGroup({ displayFields, fields, takenBySlot, + remindersOn, expandedRowId, onExpandRow, onEditCell, @@ -98,6 +101,7 @@ export function WysiwygGroup({ key={row.id} row={row} taken={takenBySlot[row.id] ?? 0} + remindersOn={remindersOn} fields={fields} displayFields={displayFields} expanded={expandedRowId === row.id} diff --git a/src/components/build-wysiwyg/WysiwygSlot.test.tsx b/src/components/build-wysiwyg/WysiwygSlot.test.tsx index a60bcc28..0db090d5 100644 --- a/src/components/build-wysiwyg/WysiwygSlot.test.tsx +++ b/src/components/build-wysiwyg/WysiwygSlot.test.tsx @@ -61,6 +61,7 @@ function renderSlot(overrides: RenderProps = {}) { +