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.
-
-
+
+ {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.'}
+
+ )}
);
}
+
+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 = {}) {
+
+ {children}
+
+ );
+}
diff --git a/src/db/schema/activity.ts b/src/db/schema/activity.ts
index f5c79827..80728bfa 100644
--- a/src/db/schema/activity.ts
+++ b/src/db/schema/activity.ts
@@ -45,6 +45,8 @@ export const ACTIVITY_EVENTS = [
'slot.updated',
'slot.deleted',
'slot.reordered',
+ 'slot.rescheduled',
+ 'slot.change_sent',
'field.created',
'field.updated',
'field.reordered',
diff --git a/src/email/send.ts b/src/email/send.ts
index d5ef870f..cda59dd2 100644
--- a/src/email/send.ts
+++ b/src/email/send.ts
@@ -1,11 +1,12 @@
import { createElement } from 'react';
import { getEmailTransport } from './index';
-import { confirmationSubject, reminderSubject } from './subjects';
+import { confirmationSubject, reminderSubject, slotChangedSubject } from './subjects';
import {
CommitmentConfirmationEmail,
type CommitmentConfirmationEmailProps,
} from './templates/commitment-confirmation';
import { ReminderEmail, type ReminderEmailProps } from './templates/reminder';
+import { SlotChangedEmail, type SlotChangedEmailProps } from './templates/slot-changed';
import { renderEmail } from './render';
export async function sendCommitmentConfirmation(
@@ -48,3 +49,22 @@ export async function sendReminder(
: {}),
});
}
+
+/** Same one-click unsubscribe as a reminder: one opt-out silences both. */
+export async function sendSlotChanged(
+ to: string,
+ props: SlotChangedEmailProps,
+ opts: { unsubscribePostUrl: string },
+) {
+ const { html, text } = await renderEmail(createElement(SlotChangedEmail, props));
+ return getEmailTransport().send({
+ to,
+ subject: slotChangedSubject(props.signupTitle, props.slotSummary),
+ html,
+ text,
+ headers: {
+ 'List-Unsubscribe': `<${opts.unsubscribePostUrl}>`,
+ 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
+ },
+ });
+}
diff --git a/src/email/subjects.test.ts b/src/email/subjects.test.ts
index 74e57f93..f3d13580 100644
--- a/src/email/subjects.test.ts
+++ b/src/email/subjects.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { confirmationSubject, reminderSubject } from './subjects';
+import { confirmationSubject, reminderSubject, slotChangedSubject } from './subjects';
describe('email subjects', () => {
it('leads with the signup title, then the slot values', () => {
@@ -17,6 +17,12 @@ describe('email subjects', () => {
);
});
+ it('says the time changed when the organizer moves the slot', () => {
+ expect(slotChangedSubject('Spring Book Fair Volunteers', 'Thu, Sep 10 · 13:00')).toBe(
+ 'Time changed: Spring Book Fair Volunteers · Thu, Sep 10 · 13:00',
+ );
+ });
+
it('omits the separator when the slot has nothing to summarise', () => {
expect(confirmationSubject('Bake Sale', '')).toBe("You're signed up: Bake Sale");
expect(reminderSubject('Bake Sale', '')).toBe('Reminder: Bake Sale');
diff --git a/src/email/subjects.ts b/src/email/subjects.ts
index 6dcdbb6d..d63827bd 100644
--- a/src/email/subjects.ts
+++ b/src/email/subjects.ts
@@ -17,10 +17,15 @@ export function confirmationSubject(signupTitle: string, slotSummary: string): s
return withSummary("You're signed up", signupTitle, slotSummary);
}
+/** Same prefix as a reminder for a moved slot: both say the time changed. */
+export function slotChangedSubject(signupTitle: string, slotSummary: string): string {
+ return withSummary('Time changed', signupTitle, slotSummary);
+}
+
/**
- * `changed` is for a reminder that follows an earlier one for a different
- * slot time: the organizer moved the slot, and the subject has to say so or
- * the email reads as a duplicate of the last.
+ * `changed` is for a reminder whose participant was last emailed a different
+ * slot time: the organizer moved the slot since, and the subject has to say so
+ * or the email reads as a duplicate of the last.
*/
export function reminderSubject(signupTitle: string, slotSummary: string, changed = false): string {
return withSummary(changed ? 'Time changed' : 'Reminder', signupTitle, slotSummary);
diff --git a/src/email/templates/reminder.tsx b/src/email/templates/reminder.tsx
index 5249e866..77d00a25 100644
--- a/src/email/templates/reminder.tsx
+++ b/src/email/templates/reminder.tsx
@@ -18,11 +18,12 @@ export interface ReminderEmailProps {
/** The same values on one line, from `summarizeSlotValues()`, for the preview text. */
slotSummary: string;
notes?: string | null;
- /** Per-signup opt-out link. Reminders are the only email it silences. */
+ /** Per-signup opt-out link. It silences reminders and slot-change emails. */
unsubscribeUrl: string;
/**
- * An earlier reminder went out for a different slot time: the organizer has
- * moved the slot since, so the email says so rather than repeating itself.
+ * The last email the participant got was for a different slot time: the
+ * organizer has moved the slot since, so the email says so rather than
+ * repeating itself.
*/
changed?: boolean;
}
@@ -43,7 +44,7 @@ export function ReminderEmail({
{changed ? (
Hi {participantName}, the organizer changed your slot for {signupTitle}{' '}
- after your last reminder. Here are the new details.
+ since we last emailed you. Here are the new details.
) : (
diff --git a/src/email/templates/slot-changed.tsx b/src/email/templates/slot-changed.tsx
new file mode 100644
index 00000000..c671bebc
--- /dev/null
+++ b/src/email/templates/slot-changed.tsx
@@ -0,0 +1,57 @@
+import { Link, Text } from '@react-email/components';
+import { slotChangedSubject } from '../subjects';
+import type { SlotDetail } from '@/lib/slot-label';
+import { EmailButton, EmailHeading, EmailIntro, EmailLayout, NotesText } from './layout';
+import { SlotDetailsText } from './slot-details';
+
+export interface SlotChangedEmailProps {
+ participantName: string;
+ signupTitle: string;
+ /** The participant's own token-bearing link, as in the reminder. */
+ manageUrl: string;
+ /** Every field value for the slot, from `slotDetails()`. */
+ slotDetails: readonly SlotDetail[];
+ /** The same values on one line, from `summarizeSlotValues()`, for the preview text. */
+ slotSummary: string;
+ notes?: string | null;
+ /** Per-signup opt-out link. It silences reminders and these emails alike. */
+ unsubscribeUrl: string;
+}
+
+/** Sent when the organizer moves a slot to a new date or time. */
+export function SlotChangedEmail({
+ participantName,
+ signupTitle,
+ manageUrl,
+ slotDetails,
+ slotSummary,
+ notes,
+ unsubscribeUrl,
+}: SlotChangedEmailProps) {
+ return (
+
+ Your slot has changed
+
+ Hi {participantName}, the organizer of {signupTitle} moved your slot to a
+ new date or time. Here are the new details.
+
+
+
+ View or change your slot
+
+ Can't make the new time? Tap the button above to change or cancel. It opens your spot
+ directly, no password needed. Keep this email to yourself: anyone with the link can change
+ your slot.
+
+
+ You'll still get a confirmation if you sign up for something new.{' '}
+ {/* Nothing may follow this link in the sentence: see reminder.tsx. */}
+
+ Stop reminders and updates for {signupTitle}
+
+
+
+ );
+}
+
+export default SlotChangedEmail;
diff --git a/src/email/templates/worker-render.test.ts b/src/email/templates/worker-render.test.ts
index b07864b4..20d9dbeb 100644
--- a/src/email/templates/worker-render.test.ts
+++ b/src/email/templates/worker-render.test.ts
@@ -51,6 +51,7 @@ describe('email templates under the worker toolchain', () => {
expect(stdout).toContain('ok magic-link');
expect(stdout).toContain('ok commitment-confirmation');
expect(stdout).toContain('ok reminder');
+ expect(stdout).toContain('ok slot-changed');
}, 60_000);
it('ships every tsconfig the worker script needs in the runner image', async () => {
diff --git a/src/help/articles/create-and-publish-a-signup.tsx b/src/help/articles/create-and-publish-a-signup.tsx
index e21dafb9..c9850b49 100644
--- a/src/help/articles/create-and-publish-a-signup.tsx
+++ b/src/help/articles/create-and-publish-a-signup.tsx
@@ -1,4 +1,4 @@
-import { REMINDER_SETTLE_HOURS } from '@/lib/reminder-eligibility';
+import { REMINDER_SETTLE_HOURS, SLOT_CHANGE_SETTLE_MINUTES } from '@/lib/reminder-eligibility';
import { INSTANCE_NAME } from '@/lib/site-config';
import { REMINDER_LEAD_HOURS } from '@/schemas/signups';
import { Note, Screenshot, Step, Steps, Ui } from '../components';
@@ -12,6 +12,9 @@ const REMINDER_WHEN =
// A sign-up newer than this isn't picked up yet (src/jobs/reminders.ts).
const SETTLE = REMINDER_SETTLE_HOURS === 1 ? 'an hour' : `${REMINDER_SETTLE_HOURS} hours`;
+// How long after an organizer's last change a moved-slot email goes (src/jobs/slot-changes.ts).
+const SETTLE_CHANGE = `${SLOT_CHANGE_SETTLE_MINUTES} minutes`;
+
export function CreateAndPublishASignup() {
return (
<>
@@ -196,6 +199,11 @@ export function CreateAndPublishASignup() {
signing up, unless the slot is less than {SETTLE} away. Each reminder has a link to stop
them.
+
+ If you change the date or time of a slot people have taken, they get an email. It comes
+ about {SETTLE_CHANGE} after your last change, with the new details. This goes with
+ reminders: if you turn reminders off, nobody is told when you move a slot.
+
Reminders are on for every new signup. To turn them off:
diff --git a/src/jobs/notify-rules.ts b/src/jobs/notify-rules.ts
new file mode 100644
index 00000000..eff87916
--- /dev/null
+++ b/src/jobs/notify-rules.ts
@@ -0,0 +1,132 @@
+import { and, eq, isNotNull, isNull, or, sql, type SQL } from 'drizzle-orm';
+import type { Db } from '@/db/client';
+import { commitments } from '@/db/schema/commitments';
+import { participants } from '@/db/schema/participants';
+import { signups } from '@/db/schema/signups';
+import { slots } from '@/db/schema/slots';
+import { holdsPlace } from '@/services/commitments';
+
+/**
+ * Who the worker may email about a slot they hold, as named SQL rules over a
+ * query joining commitments, participants, slots and signups. Reminders and
+ * slot-change emails both start from these, so the reminders setting and a
+ * participant's unsubscribe silence both, and a change to one rule is a
+ * change to both. Each rule holds when the email may go out, and its name says
+ * why it can't when it doesn't.
+ *
+ * The confirmation's promise of a reminder is the TypeScript side of the same
+ * switches (`willSendReminder` in src/lib/reminder-eligibility.ts).
+ */
+export const notifiableRules = {
+ 'commitment not active': holdsPlace,
+ // 'closed' too, not just 'open'. Closing a signup means "no longer
+ // collecting responses" — the commitments already made stay valid and
+ // their participants still need the reminder. Excluding it silently
+ // cancelled reminders whenever an organizer tidied up after sign-ups
+ // ended, which the shorter default lead makes far more likely: at 24h,
+ // closing the evening before the event now lands inside the window.
+ // 'draft' and 'archived' stay excluded — never public, and put away.
+ 'signup not open or closed': or(eq(signups.status, 'open'), eq(signups.status, 'closed'))!,
+ 'signup deleted': isNull(signups.deletedAt),
+ 'slot has no date': isNotNull(slots.slotAt),
+ 'slot passed': sql`${slots.slotAt} > now()`,
+ 'reminders off': sql`COALESCE((${signups.settings}->>'sendReminders')::boolean, true) = true`,
+ // Participants who unsubscribed from this signup's reminders.
+ 'participant opted out': isNull(participants.remindersOptedOutAt),
+ // Participants who gave no email: there is nowhere to send one.
+ 'participant has no email': isNotNull(participants.email),
+} satisfies Record;
+
+/**
+ * The names of the rules in `rules` a commitment fails right now, or null when
+ * there is no such commitment. Only for logging a skipped send: it reads the
+ * same rules the condition is built from, so the reasons cannot drift from
+ * what the scan and the job decide.
+ */
+export async function skipReasons(
+ db: Db,
+ commitmentId: string,
+ rules: Record,
+): Promise {
+ const failed = Object.entries(rules).map(
+ ([reason, rule]) => sql`CASE WHEN coalesce(${rule}, false) THEN NULL ELSE ${reason}::text END`,
+ );
+ const [row] = await db
+ .select({
+ reasons: sql`array_remove(ARRAY[${sql.join(failed, sql`, `)}], NULL)`,
+ })
+ .from(commitments)
+ .innerJoin(slots, eq(slots.id, commitments.slotId))
+ .innerJoin(signups, eq(signups.id, commitments.signupId))
+ .innerJoin(participants, eq(participants.id, commitments.participantId))
+ .where(eq(commitments.id, commitmentId))
+ .limit(1);
+ return row ? row.reasons : null;
+}
+
+/** Every rule in `rules`, joined with AND. */
+export function allOf(rules: Record): SQL {
+ return and(...Object.values(rules))!;
+}
+
+/**
+ * The emails that tell a participant their slot's time: the confirmation, a
+ * reminder and a slot-change email. The newest of them is what the
+ * participant was last told.
+ */
+const TOLD_EVENTS = sql`('commitment.confirmation_sent', 'reminder.sent', 'slot.change_sent')`;
+
+/**
+ * Whether the slot time the participant was last told differs from the slot's
+ * time now. SQL over the same four-table join as the rules above.
+ *
+ * The newest told email is found in the activity log. A reminder or change
+ * email names its `slotAt`. A confirmation (and a reminder from before
+ * reminders named their time) does not, so its time is read off the slot's
+ * moves instead: the slot was at the `from` of the first `slot.rescheduled`
+ * after that email, or, with no move since, where it is now. No told email at
+ * all (the confirmation failed) counts from the commitment's creation, the
+ * same way. Both sides are cut to milliseconds, the precision of the ISO
+ * strings in the payloads.
+ */
+export const toldDifferentTime: SQL = (() => {
+ const lastTold = (column: SQL) => sql`(
+ SELECT ${column} FROM activity t
+ WHERE t.event_type IN ${TOLD_EVENTS}
+ AND t.signup_id = ${commitments.signupId}
+ AND (t.payload->>'commitmentId') = ${commitments.id}
+ ORDER BY t.occurred_at DESC
+ LIMIT 1
+ )`;
+ const toldAt = sql`coalesce(${lastTold(sql`t.occurred_at`)}, ${commitments.createdAt})`;
+ const toldSlotAt = lastTold(sql`t.payload->'slotAt'`);
+ // jsonb, so "no move since" (SQL NULL) and "moved from no time" (JSON null)
+ // stay apart.
+ const firstMoveFrom = sql`(
+ SELECT m.payload->'from' FROM activity m
+ WHERE m.event_type = 'slot.rescheduled'
+ AND m.signup_id = ${commitments.signupId}
+ AND (m.payload->>'slotId') = ${slots.id}
+ AND m.occurred_at > ${toldAt}
+ ORDER BY m.occurred_at ASC
+ LIMIT 1
+ )`;
+ const now = sql`date_trunc('milliseconds', ${slots.slotAt})`;
+ return sql`(CASE
+ WHEN ${toldSlotAt} IS NOT NULL
+ THEN (${toldSlotAt} #>> '{}')::timestamptz IS DISTINCT FROM ${now}
+ ELSE ${firstMoveFrom} IS NOT NULL
+ AND (${firstMoveFrom} #>> '{}')::timestamptz IS DISTINCT FROM ${now}
+ END)`;
+})();
+
+/** `toldDifferentTime` for one commitment. False when there is no such commitment. */
+export async function wasToldDifferentTime(db: Db, commitmentId: string): Promise {
+ const [row] = await db
+ .select({ differs: sql`${toldDifferentTime}` })
+ .from(commitments)
+ .innerJoin(slots, eq(slots.id, commitments.slotId))
+ .where(eq(commitments.id, commitmentId))
+ .limit(1);
+ return row?.differs === true;
+}
diff --git a/src/jobs/queue.ts b/src/jobs/queue.ts
index 45b767a7..1f5cf86d 100644
--- a/src/jobs/queue.ts
+++ b/src/jobs/queue.ts
@@ -8,6 +8,9 @@ declare global {
export const QUEUES = {
reminderDispatch: 'reminders.dispatch',
reminderSend: 'reminders.send',
+ /** Finds slots that moved and settled, and queues an email per participant. */
+ slotChangeDispatch: 'slot-changes.dispatch',
+ slotChangeSend: 'slot-changes.send',
/** Hourly deletion of expired OAuth records and closed rate-limit windows. */
housekeeping: 'housekeeping.sweep',
} as const;
@@ -32,3 +35,7 @@ export async function getBoss(): Promise {
export interface ReminderSendPayload {
commitmentId: string;
}
+
+export interface SlotChangeSendPayload {
+ commitmentId: string;
+}
diff --git a/src/jobs/reminders.db.test.ts b/src/jobs/reminders.db.test.ts
index 97da88f8..3930f642 100644
--- a/src/jobs/reminders.db.test.ts
+++ b/src/jobs/reminders.db.test.ts
@@ -463,7 +463,7 @@ describe('selectDueReminders (db)', () => {
const second = send.mock.calls[1]![0];
expect(second.subject).toMatch(/^Time changed: /);
expect(second.html).toContain('Your slot has changed');
- expect(second.text).toContain('after your last reminder. Here are the new details.');
+ expect(second.text).toContain('since we last emailed you. Here are the new details.');
} finally {
send.mockRestore();
}
diff --git a/src/jobs/reminders.ts b/src/jobs/reminders.ts
index af92da85..f5030b8d 100644
--- a/src/jobs/reminders.ts
+++ b/src/jobs/reminders.ts
@@ -1,4 +1,4 @@
-import { and, eq, isNotNull, isNull, or, sql, type SQL } from 'drizzle-orm';
+import { and, eq, sql, type SQL } from 'drizzle-orm';
import { getDb, type Db } from '@/db/client';
import { activity } from '@/db/schema/activity';
import { commitments } from '@/db/schema/commitments';
@@ -11,21 +11,20 @@ import { commitmentEditUrl, reminderUnsubscribePostUrl, reminderUnsubscribeUrl }
import { REMINDER_SETTLE_HOURS } from '@/lib/reminder-eligibility';
import { editTokenFor } from '@/lib/token';
import { REMINDER_LEAD_HOURS } from '@/schemas/signups';
-import { holdsPlace } from '@/services/commitments';
import { reminderOptOutTokenFor } from '@/services/reminder-optout';
import { alreadyRecorded, loadCommitmentRow, slotText } from '@/email/commitment-row';
import { sendReminder } from '@/email/send';
+import { allOf, notifiableRules, skipReasons, wasToldDifferentTime } from './notify-rules';
import { getBoss, QUEUES, type ReminderSendPayload } from './queue';
/** The reminder lead time as a SQL interval. Fixed for every signup. */
const leadInterval = sql`make_interval(hours => ${REMINDER_LEAD_HOURS})`;
/**
- * When a commitment's reminder is due, as named SQL rules over a query joining
- * commitments, participants, slots and signups. Each rule holds when the
- * reminder may go out, and its name says why it can't when it doesn't. All
- * but the reminder.sent check, which the scan and the send job each make on
- * their own terms.
+ * When a commitment's reminder is due: the rules for emailing a participant at
+ * all (`notifiableRules`), plus the reminder's own timing. All but the
+ * reminder.sent check, which the scan and the send job each make on their own
+ * terms.
*
* The scan queues jobs with them and the send job re-reads its row through
* them, because the job runs later: seconds normally, hours when the worker is
@@ -35,27 +34,11 @@ const leadInterval = sql`make_interval(hours => ${REMINDER_LEAD_HOURS})`;
* and the names let the job log which rule a skipped reminder failed.
*/
const reminderDueRules = {
- 'commitment not active': holdsPlace,
- // 'closed' too, not just 'open'. Closing a signup means "no longer
- // collecting responses" — the commitments already made stay valid and
- // their participants still need the reminder. Excluding it silently
- // cancelled reminders whenever an organizer tidied up after sign-ups
- // ended, which the shorter default lead makes far more likely: at 24h,
- // closing the evening before the event now lands inside the window.
- // 'draft' and 'archived' stay excluded — never public, and put away.
- 'signup not open or closed': or(eq(signups.status, 'open'), eq(signups.status, 'closed'))!,
- 'signup deleted': isNull(signups.deletedAt),
- 'slot has no date': isNotNull(slots.slotAt),
- // Still ahead of the participant, and inside the reminder lead time.
- 'slot passed': sql`${slots.slotAt} > now()`,
+ ...notifiableRules,
+ // Inside the reminder lead time.
'slot beyond the lead time': sql`${slots.slotAt} <= now() + ${leadInterval}`,
// Not still in the afterglow of their own confirmation (see selectDueReminders).
'signed up too recently': sql`${commitments.createdAt} < now() - make_interval(hours => ${REMINDER_SETTLE_HOURS})`,
- 'reminders off': sql`COALESCE((${signups.settings}->>'sendReminders')::boolean, true) = true`,
- // Participants who unsubscribed from this signup's reminders.
- 'participant opted out': isNull(participants.remindersOptedOutAt),
- // Participants who gave no email: there is nowhere to send one.
- 'participant has no email': isNotNull(participants.email),
} satisfies Record;
/**
@@ -71,30 +54,7 @@ function remindedForTime(payload: SQL, slotAt: SQL): SQL {
}
/** Every rule in `reminderDueRules`: the reminder is due now. */
-const reminderDueCondition = and(...Object.values(reminderDueRules))!;
-
-/**
- * The names of the rules in `reminderDueRules` a commitment's reminder fails
- * right now, or null when there is no such commitment. Only for logging a
- * skipped send: it reads the same rules the condition is built from, so the
- * reasons cannot drift from what the scan and the job decide.
- */
-async function reminderSkipReasons(db: Db, commitmentId: string): Promise {
- const failed = Object.entries(reminderDueRules).map(
- ([reason, rule]) => sql`CASE WHEN coalesce(${rule}, false) THEN NULL ELSE ${reason}::text END`,
- );
- const [row] = await db
- .select({
- reasons: sql`array_remove(ARRAY[${sql.join(failed, sql`, `)}], NULL)`,
- })
- .from(commitments)
- .innerJoin(slots, eq(slots.id, commitments.slotId))
- .innerJoin(signups, eq(signups.id, commitments.signupId))
- .innerJoin(participants, eq(participants.id, commitments.participantId))
- .where(eq(commitments.id, commitmentId))
- .limit(1);
- return row ? row.reasons : null;
-}
+const reminderDueCondition = allOf(reminderDueRules);
/**
* Selects the commitments whose reminder is *due* — slot still in the future,
@@ -168,7 +128,7 @@ export async function sendReminderJob(payload: ReminderSendPayload): Promise {
+ const db = getDb();
+ const organizerId = makeId('org');
+ const workspaceId = makeId('ws');
+ const slug = `chg-${workspaceId.slice(-8).toLowerCase()}`;
+ const email = `${slug}@example.test`;
+
+ await db.transaction(async (tx) => {
+ await tx.insert(organizers).values({ id: organizerId, email, name: 'Change Org' });
+ await tx.insert(workspaces).values({
+ id: workspaceId,
+ slug,
+ name: 'Change Workspace',
+ type: 'personal',
+ plan: 'free',
+ });
+ await tx.insert(workspaceMembers).values({
+ id: makeId('mem'),
+ workspaceId,
+ organizerId,
+ role: 'owner',
+ status: 'active',
+ });
+ });
+
+ return {
+ db,
+ workspaceId,
+ organizerId,
+ actor: {
+ kind: 'organizer',
+ id: organizerId,
+ email,
+ workspaceIds: [workspaceId],
+ workspaceRoles: { [workspaceId]: 'owner' },
+ },
+ };
+}
+
+/** A date `days` from today, as a date field stores it. */
+function dayFromNow(days: number): string {
+ return new Date(Date.now() + days * 24 * 3600_000).toISOString().slice(0, 10);
+}
+
+interface Scenario {
+ signupId: string;
+ slotId: string;
+ commitmentIds: string[];
+}
+
+/**
+ * An open signup with a date field, one slot dated 20 days out, and `people`
+ * participants holding it, each committed a week ago.
+ */
+async function makeScenario(
+ fx: Fixture,
+ title: string,
+ opts: { people?: number; settings?: Record } = {},
+): Promise {
+ const created = await createSignup(
+ fx.db,
+ fx.actor,
+ fx.workspaceId,
+ {
+ title,
+ description: '',
+ tags: [],
+ visibility: 'unlisted' as const,
+ settings: opts.settings ?? {},
+ },
+ {
+ template: {
+ id: 'slot-change-test',
+ fields: [
+ {
+ ref: 'day',
+ label: 'Day',
+ fieldType: 'date',
+ sortOrder: 0,
+ config: { fieldType: 'date' },
+ },
+ ],
+ slots: [],
+ },
+ },
+ );
+ if (!created.ok) throw new Error(`createSignup failed: ${created.error.message}`);
+ const signupId = created.value.id;
+
+ const slot = await addSlot(fx.db, fx.actor, signupId, {
+ values: { day: dayFromNow(20) },
+ capacity: 5,
+ });
+ if (!slot.ok) throw new Error(`addSlot failed: ${slot.error.message}`);
+ const pub = await publishSignup(fx.db, fx.actor, signupId);
+ if (!pub.ok) throw new Error(`publishSignup failed: ${pub.error.message}`);
+
+ const commitmentIds: string[] = [];
+ for (let i = 0; i < (opts.people ?? 1); i++) {
+ const commit = await commitToSlot(fx.db, slot.value.id, {
+ name: `Change Tester ${i}`,
+ email: `p${i}-${slot.value.id.slice(-10).toLowerCase()}@example.test`,
+ quantity: 1,
+ });
+ if (!commit.ok) throw new Error(`commitToSlot failed: ${commit.error.message}`);
+ commitmentIds.push(commit.value.commitment.id);
+ }
+ await fx.db
+ .update(commitments)
+ .set({ createdAt: sql`now() - make_interval(days => 7)` })
+ .where(eq(commitments.slotId, slot.value.id));
+
+ return { signupId, slotId: slot.value.id, commitmentIds };
+}
+
+async function moveSlot(fx: Fixture, slotId: string, day: string) {
+ const moved = await updateSlot(fx.db, fx.actor, slotId, { values: { day } });
+ if (!moved.ok) throw new Error(`updateSlot failed: ${moved.error.message}`);
+ return moved.value;
+}
+
+/** Pushes the slot's recorded moves `minutes` into the past, as if time passed. */
+async function ageMoves(db: Db, signupId: string, minutes: number) {
+ await db
+ .update(activity)
+ .set({ occurredAt: sql`${activity.occurredAt} - make_interval(mins => ${minutes})` })
+ .where(and(eq(activity.signupId, signupId), eq(activity.eventType, 'slot.rescheduled')));
+}
+
+async function changeSentRows(db: Db, commitmentId: string) {
+ return db
+ .select({ payload: activity.payload })
+ .from(activity)
+ .where(
+ and(
+ eq(activity.eventType, 'slot.change_sent'),
+ sql`(${activity.payload}->>'commitmentId') = ${commitmentId}`,
+ ),
+ );
+}
+
+describe('slot-change emails (db)', () => {
+ let fx: Fixture;
+
+ beforeAll(async () => {
+ fx = await setupWorkspace();
+ });
+
+ afterAll(async () => {
+ await fx.db.delete(workspaces).where(eq(workspaces.id, fx.workspaceId));
+ await fx.db.delete(organizers).where(eq(organizers.id, fx.organizerId));
+ });
+
+ async function dueIds(): Promise {
+ return (await selectDueSlotChanges(fx.db)).map((r) => r.commitmentId);
+ }
+
+ /** Runs one job per due commitment of this scenario, as the dispatcher would. */
+ async function runDue(scenario: Scenario) {
+ const due = (await dueIds()).filter((id) => scenario.commitmentIds.includes(id));
+ for (const commitmentId of due) await sendSlotChangeJob({ commitmentId });
+ return due;
+ }
+
+ it('records where a slot moved from and to, and nothing for other edits', async () => {
+ const s = await makeScenario(fx, 'Records moves');
+ const before = await fx.db.select().from(slots).where(eq(slots.id, s.slotId));
+ const capacityOnly = await updateSlot(fx.db, fx.actor, s.slotId, { capacity: 6 });
+ expect(capacityOnly.ok).toBe(true);
+ const sameDay = await updateSlot(fx.db, fx.actor, s.slotId, { values: { day: dayFromNow(20) } });
+ expect(sameDay.ok).toBe(true);
+ const moved = await moveSlot(fx, s.slotId, dayFromNow(21));
+
+ const rows = await fx.db
+ .select({ payload: activity.payload })
+ .from(activity)
+ .where(and(eq(activity.signupId, s.signupId), eq(activity.eventType, 'slot.rescheduled')));
+ expect(rows.map((r) => r.payload)).toEqual([
+ {
+ slotId: s.slotId,
+ from: before[0]!.slotAt!.toISOString(),
+ to: moved.slotAt!.toISOString(),
+ },
+ ]);
+ });
+
+ it('emails each participant once, about 10 minutes after the move', async () => {
+ const s = await makeScenario(fx, 'One email each', { people: 2 });
+ const newDay = dayFromNow(22);
+ const moved = await moveSlot(fx, s.slotId, newDay);
+
+ // Still inside the settle time: the organizer may not be done.
+ expect(await dueIds()).not.toContain(s.commitmentIds[0]);
+ await ageMoves(fx.db, s.signupId, 11);
+ expect((await dueIds()).filter((id) => s.commitmentIds.includes(id)).sort()).toEqual(
+ [...s.commitmentIds].sort(),
+ );
+
+ const send = vi.spyOn(getEmailTransport(), 'send');
+ try {
+ await runDue(s);
+ // A second job for the same commitment, as a later scan could queue.
+ await sendSlotChangeJob({ commitmentId: s.commitmentIds[0]! });
+ expect(send).toHaveBeenCalledTimes(2);
+ const mail = send.mock.calls[0]![0];
+ expect(mail.subject).toMatch(/^Time changed: One email each · /);
+ expect(mail.text).toMatch(/moved your slot to a new\s+date or time/);
+ expect(mail.text).toMatch(/Stop reminders\s+and updates for One email each/);
+ expect(mail.headers?.['List-Unsubscribe-Post']).toBe('List-Unsubscribe=One-Click');
+ } finally {
+ send.mockRestore();
+ }
+
+ for (const id of s.commitmentIds) {
+ const rows = await changeSentRows(fx.db, id);
+ expect(rows.map((r) => (r.payload as { slotAt: string }).slotAt)).toEqual([
+ moved.slotAt!.toISOString(),
+ ]);
+ }
+ expect(await dueIds()).not.toContain(s.commitmentIds[0]);
+ });
+
+ it('sends one email for several quick moves, for where the slot ended up', async () => {
+ const s = await makeScenario(fx, 'Quick moves');
+ await moveSlot(fx, s.slotId, dayFromNow(23));
+ await ageMoves(fx.db, s.signupId, 5);
+ const final = await moveSlot(fx, s.slotId, dayFromNow(24));
+ // The second move restarts the wait.
+ await ageMoves(fx.db, s.signupId, 6);
+ expect(await dueIds()).not.toContain(s.commitmentIds[0]);
+ await ageMoves(fx.db, s.signupId, 5);
+
+ const send = vi.spyOn(getEmailTransport(), 'send');
+ try {
+ expect(await runDue(s)).toEqual(s.commitmentIds);
+ expect(send).toHaveBeenCalledTimes(1);
+ } finally {
+ send.mockRestore();
+ }
+ const rows = await changeSentRows(fx.db, s.commitmentIds[0]!);
+ expect((rows[0]!.payload as { slotAt: string }).slotAt).toBe(final.slotAt!.toISOString());
+ });
+
+ it('sends nothing when the slot is moved back before anyone is told', async () => {
+ const s = await makeScenario(fx, 'Moved back');
+ await moveSlot(fx, s.slotId, dayFromNow(25));
+ await moveSlot(fx, s.slotId, dayFromNow(20));
+ await ageMoves(fx.db, s.signupId, 11);
+ expect(await dueIds()).not.toContain(s.commitmentIds[0]);
+ });
+
+ it('tells a participant about a second move after the first email', async () => {
+ const s = await makeScenario(fx, 'Moved twice');
+ await moveSlot(fx, s.slotId, dayFromNow(26));
+ await ageMoves(fx.db, s.signupId, 11);
+ await runDue(s);
+ expect(await dueIds()).not.toContain(s.commitmentIds[0]);
+
+ await moveSlot(fx, s.slotId, dayFromNow(27));
+ await ageMoves(fx.db, s.signupId, 11);
+ expect(await dueIds()).toContain(s.commitmentIds[0]);
+ });
+
+ it('sends nothing when a field change re-times a slot that moved lately', async () => {
+ const s = await makeScenario(fx, 'Re-timed by a field');
+ await moveSlot(fx, s.slotId, dayFromNow(26));
+ await ageMoves(fx.db, s.signupId, 11);
+ expect(await runDue(s)).toEqual(s.commitmentIds);
+
+ // A second date field, filled in without moving the slot, then made the
+ // one reminders count from: the slot's time changes with no move.
+ const field = await addField(fx.db, fx.actor, s.signupId, {
+ ref: 'other',
+ label: 'Other day',
+ fieldType: 'date',
+ config: { fieldType: 'date' },
+ });
+ if (!field.ok) throw new Error(`addField failed: ${field.error.message}`);
+ await updateSlot(fx.db, fx.actor, s.slotId, {
+ values: { day: dayFromNow(26), other: dayFromNow(40) },
+ });
+ const switched = await updateSignup(fx.db, fx.actor, s.signupId, {
+ settings: { reminderFromFieldRef: 'other' },
+ });
+ if (!switched.ok) throw new Error(`updateSignup failed: ${switched.error.message}`);
+ const [slot] = await fx.db.select().from(slots).where(eq(slots.id, s.slotId));
+ expect(slot!.slotAt!.toISOString().slice(0, 10)).toBe(dayFromNow(40));
+
+ expect(await dueIds()).not.toContain(s.commitmentIds[0]);
+ });
+
+ it('does not email someone who signed up after the move', async () => {
+ const s = await makeScenario(fx, 'Signed up after', { people: 0 });
+ await moveSlot(fx, s.slotId, dayFromNow(28));
+ await ageMoves(fx.db, s.signupId, 11);
+ const commit = await commitToSlot(fx.db, s.slotId, {
+ name: 'Late Tester',
+ email: `late-${s.slotId.slice(-10).toLowerCase()}@example.test`,
+ quantity: 1,
+ });
+ if (!commit.ok) throw new Error(commit.error.message);
+ expect(await dueIds()).not.toContain(commit.value.commitment.id);
+ });
+
+ it('does not email a cancelled commitment', async () => {
+ const s = await makeScenario(fx, 'Cancelled');
+ await fx.db
+ .update(commitments)
+ .set({ status: 'cancelled' })
+ .where(eq(commitments.id, s.commitmentIds[0]!));
+ await moveSlot(fx, s.slotId, dayFromNow(29));
+ await ageMoves(fx.db, s.signupId, 11);
+ expect(await dueIds()).not.toContain(s.commitmentIds[0]);
+ });
+
+ it('does not email anyone about a slot moved into the past', async () => {
+ const s = await makeScenario(fx, 'Into the past');
+ await moveSlot(fx, s.slotId, dayFromNow(-2));
+ await ageMoves(fx.db, s.signupId, 11);
+ expect(await dueIds()).not.toContain(s.commitmentIds[0]);
+ });
+
+ it('does not email when the signup has reminders off', async () => {
+ const s = await makeScenario(fx, 'Reminders off', { settings: { sendReminders: false } });
+ await moveSlot(fx, s.slotId, dayFromNow(30));
+ await ageMoves(fx.db, s.signupId, 11);
+ expect(await dueIds()).not.toContain(s.commitmentIds[0]);
+ });
+
+ it('does not email a participant who unsubscribed', async () => {
+ const s = await makeScenario(fx, 'Unsubscribed');
+ await fx.db
+ .update(participants)
+ .set({ remindersOptedOutAt: new Date() })
+ .where(eq(participants.signupId, s.signupId));
+ await moveSlot(fx, s.slotId, dayFromNow(31));
+ await ageMoves(fx.db, s.signupId, 11);
+ expect(await dueIds()).not.toContain(s.commitmentIds[0]);
+ });
+
+ // Changed while the job waits in the queue: the job asks the scan's rules
+ // again and logs the one that failed.
+ async function expectNoSend(commitmentId: string, reason: string) {
+ const send = vi.spyOn(getEmailTransport(), 'send');
+ const info = vi.spyOn(log, 'info');
+ try {
+ await sendSlotChangeJob({ commitmentId });
+ expect(send).not.toHaveBeenCalled();
+ expect(info).toHaveBeenCalledWith(
+ { commitmentId, reasons: [reason] },
+ 'slot-change email no longer due; skipping',
+ );
+ } finally {
+ send.mockRestore();
+ info.mockRestore();
+ }
+ expect(await changeSentRows(fx.db, commitmentId)).toEqual([]);
+ }
+
+ async function dueScenario(title: string) {
+ const s = await makeScenario(fx, title);
+ await moveSlot(fx, s.slotId, dayFromNow(32));
+ await ageMoves(fx.db, s.signupId, 11);
+ expect(await dueIds()).toContain(s.commitmentIds[0]);
+ return s;
+ }
+
+ it('skips the send when reminders were turned off after being enqueued', async () => {
+ const s = await dueScenario('Reminders off mid-flight');
+ const off = await updateSignup(fx.db, fx.actor, s.signupId, {
+ settings: { sendReminders: false },
+ });
+ expect(off.ok).toBe(true);
+ await expectNoSend(s.commitmentIds[0]!, 'reminders off');
+ });
+
+ it('skips the send when the participant unsubscribed after being enqueued', async () => {
+ const s = await dueScenario('Unsubscribed mid-flight');
+ await fx.db
+ .update(participants)
+ .set({ remindersOptedOutAt: new Date() })
+ .where(eq(participants.signupId, s.signupId));
+ await expectNoSend(s.commitmentIds[0]!, 'participant opted out');
+ });
+
+ it('skips the send when the signup was deleted after being enqueued', async () => {
+ const s = await dueScenario('Deleted mid-flight');
+ const deleted = await deleteSignup(fx.db, fx.actor, s.signupId);
+ expect(deleted.ok).toBe(true);
+ await expectNoSend(s.commitmentIds[0]!, 'signup deleted');
+ });
+
+ it('skips the send when the slot moved again after being enqueued', async () => {
+ const s = await dueScenario('Moved mid-flight');
+ await moveSlot(fx, s.slotId, dayFromNow(33));
+ await expectNoSend(s.commitmentIds[0]!, 'slot still moving');
+ });
+
+ describe('the reminder that follows', () => {
+ /** Brings the slot, and the times recorded for it, 20 hours from now. */
+ async function bringWithinReminderLead(s: Scenario) {
+ const [slot] = await fx.db.select().from(slots).where(eq(slots.id, s.slotId));
+ const shift = slot!.slotAt!.getTime() - (Date.now() + 20 * 3600_000);
+ await fx.db
+ .update(slots)
+ .set({ slotAt: new Date(slot!.slotAt!.getTime() - shift) })
+ .where(eq(slots.id, s.slotId));
+ await fx.db
+ .update(activity)
+ .set({
+ payload: sql`jsonb_set(${activity.payload}, '{slotAt}', to_jsonb(
+ ((${activity.payload}->>'slotAt')::timestamptz - make_interval(secs => ${shift / 1000}))
+ ))`,
+ })
+ .where(
+ and(eq(activity.signupId, s.signupId), eq(activity.eventType, 'slot.change_sent')),
+ );
+ // And the move that put it there, so the slot is still where it was moved to.
+ await fx.db
+ .update(activity)
+ .set({
+ payload: sql`jsonb_set(${activity.payload}, '{to}', to_jsonb(
+ ((${activity.payload}->>'to')::timestamptz - make_interval(secs => ${shift / 1000}))
+ ))`,
+ })
+ .where(
+ and(eq(activity.signupId, s.signupId), eq(activity.eventType, 'slot.rescheduled')),
+ );
+ }
+
+ it('reads as a normal reminder once the participant was told the new time', async () => {
+ const s = await dueScenario('Told, then reminded');
+ await runDue(s);
+ await bringWithinReminderLead(s);
+ const send = vi.spyOn(getEmailTransport(), 'send');
+ try {
+ await sendReminderJob({ commitmentId: s.commitmentIds[0]! });
+ expect(send).toHaveBeenCalledTimes(1);
+ expect(send.mock.calls[0]![0].subject).toMatch(/^Reminder: /);
+ } finally {
+ send.mockRestore();
+ }
+ // And the reminder, being for the time they were told, sends no change email.
+ expect(await dueIds()).not.toContain(s.commitmentIds[0]);
+ });
+
+ it('says the time changed when no change email went out first', async () => {
+ const s = await dueScenario('Reminded before the change email');
+ await bringWithinReminderLead(s);
+ const send = vi.spyOn(getEmailTransport(), 'send');
+ try {
+ await sendReminderJob({ commitmentId: s.commitmentIds[0]! });
+ expect(send).toHaveBeenCalledTimes(1);
+ expect(send.mock.calls[0]![0].subject).toMatch(/^Time changed: /);
+ expect(send.mock.calls[0]![0].text).toMatch(/since\s+we\s+last\s+emailed\s+you/);
+ } finally {
+ send.mockRestore();
+ }
+ // The reminder told them the new time, so no change email follows.
+ expect(await dueIds()).not.toContain(s.commitmentIds[0]);
+ });
+ });
+});
diff --git a/src/jobs/slot-changes.ts b/src/jobs/slot-changes.ts
new file mode 100644
index 00000000..97934ee0
--- /dev/null
+++ b/src/jobs/slot-changes.ts
@@ -0,0 +1,170 @@
+import { and, eq, sql, type SQL } from 'drizzle-orm';
+import { getDb, type Db } from '@/db/client';
+import { commitments } from '@/db/schema/commitments';
+import { participants } from '@/db/schema/participants';
+import { signups } from '@/db/schema/signups';
+import { slots } from '@/db/schema/slots';
+import { recordActivity } from '@/lib/activity';
+import { log } from '@/lib/log';
+import { commitmentEditUrl, reminderUnsubscribePostUrl, reminderUnsubscribeUrl } from '@/lib/links';
+import { SLOT_CHANGE_SETTLE_MINUTES } from '@/lib/reminder-eligibility';
+import { editTokenFor } from '@/lib/token';
+import { reminderOptOutTokenFor } from '@/services/reminder-optout';
+import { loadCommitmentRow, slotText } from '@/email/commitment-row';
+import { sendSlotChanged } from '@/email/send';
+import { allOf, notifiableRules, skipReasons, toldDifferentTime } from './notify-rules';
+import { getBoss, QUEUES, type SlotChangeSendPayload } from './queue';
+
+/**
+ * Moves older than this are not looked for. An email about a slot that moved
+ * more than a week ago, and was never sent because the worker was down all
+ * that time, would be news to nobody; the reminder for the new time still
+ * says it changed. It also keeps the scan off the whole history of moves.
+ */
+const LOOKBACK_DAYS = 7;
+
+/** A `slot.rescheduled` row for the slot in the joined query. */
+const moveOfThisSlot = sql`m.event_type = 'slot.rescheduled'
+ AND m.signup_id = ${commitments.signupId}
+ AND (m.payload->>'slotId') = ${slots.id}`;
+
+/**
+ * When a commitment's slot-change email is due: the participant could get a
+ * reminder (`notifiableRules`: same switches, so reminders off or an
+ * unsubscribe means no change email either), the slot has moved and then sat
+ * still for `SLOT_CHANGE_SETTLE_MINUTES`, it is still where that move put it,
+ * and the time the participant was last told is not where the slot is now. A slot moved and moved back is
+ * settled where they were told, and sends nothing.
+ *
+ * As with reminders, the scan queues jobs with these rules and the send job
+ * asks them again of the row as it is then, so an unsubscribe, a reminders
+ * switch or another move in between is seen by both the same way.
+ */
+const slotChangeDueRules = {
+ ...notifiableRules,
+ 'slot not moved': sql`EXISTS (
+ SELECT 1 FROM activity m
+ WHERE ${moveOfThisSlot}
+ AND m.occurred_at > now() - make_interval(days => ${LOOKBACK_DAYS})
+ )`,
+ 'slot still moving': sql`NOT EXISTS (
+ SELECT 1 FROM activity m
+ WHERE ${moveOfThisSlot}
+ AND m.occurred_at > now() - make_interval(mins => ${SLOT_CHANGE_SETTLE_MINUTES})
+ )`,
+ // A field change re-times slots (`rebuildSlotAt`) without moving them, and
+ // records no move. Without this, a slot moved in the last week and then
+ // re-timed that way would pass the two rules above, and everyone holding it
+ // would hear it had moved. Only a time the organizer moved the slot to is
+ // news: the newest move's `to` must be where the slot is now, cut to
+ // milliseconds as in `toldDifferentTime`.
+ 'slot re-timed by a field change': sql`(
+ SELECT (m.payload->>'to')::timestamptz FROM activity m
+ WHERE ${moveOfThisSlot}
+ ORDER BY m.occurred_at DESC
+ LIMIT 1
+ ) = date_trunc('milliseconds', ${slots.slotAt})`,
+ 'participant already knows the time': toldDifferentTime,
+} satisfies Record;
+
+const slotChangeDueCondition = allOf(slotChangeDueRules);
+
+/** The commitments whose slot-change email is due now. */
+export async function selectDueSlotChanges(db: Db): Promise<{ commitmentId: string }[]> {
+ return db
+ .select({ commitmentId: commitments.id })
+ .from(commitments)
+ .innerJoin(participants, eq(participants.id, commitments.participantId))
+ .innerJoin(slots, eq(slots.id, commitments.slotId))
+ .innerJoin(signups, eq(signups.id, commitments.signupId))
+ .where(
+ and(
+ // Only slots moved lately, found through the event index before any
+ // commitment is looked at.
+ sql`${slots.id} IN (
+ SELECT a.payload->>'slotId' FROM activity a
+ WHERE a.event_type = 'slot.rescheduled'
+ AND a.occurred_at > now() - make_interval(days => ${LOOKBACK_DAYS})
+ )`,
+ slotChangeDueCondition,
+ ),
+ );
+}
+
+/** Enqueues one slot-change send job per due commitment. */
+export async function dispatchSlotChanges(): Promise<{ enqueued: number }> {
+ const rows = await selectDueSlotChanges(getDb());
+
+ if (rows.length === 0) return { enqueued: 0 };
+ const boss = await getBoss();
+ let enqueued = 0;
+ for (const r of rows) {
+ const payload: SlotChangeSendPayload = { commitmentId: r.commitmentId };
+ // As with reminders, the queue dedupes nothing: a commitment still due at
+ // the next scan gets another job. The slot.change_sent row the first send
+ // records makes the participant's last-told time the slot's time, which
+ // drops the commitment from later scans and stops a second job here.
+ const jobId = await boss.send(QUEUES.slotChangeSend, payload);
+ if (jobId) enqueued++;
+ }
+ log.info({ enqueued, scanned: rows.length }, 'slot-change emails enqueued');
+ return { enqueued };
+}
+
+export async function sendSlotChangeJob(payload: SlotChangeSendPayload): Promise {
+ const db = getDb();
+ const row = await loadCommitmentRow(db, payload.commitmentId, slotChangeDueCondition);
+
+ // The scan's rules asked again, and the only send guard: a second job for
+ // the same commitment finds the slot.change_sent row the first one recorded
+ // and its time the slot's own. A check, not a lock, so it holds while the
+ // instance runs one worker, as for reminders.
+ if (!row) {
+ const reasons = await skipReasons(db, payload.commitmentId, slotChangeDueRules);
+ if (reasons === null) {
+ log.warn({ commitmentId: payload.commitmentId }, 'commitment not found for slot change');
+ } else {
+ log.info(
+ { commitmentId: payload.commitmentId, reasons },
+ 'slot-change email no longer due; skipping',
+ );
+ }
+ return;
+ }
+ // The rules require an email and a slot time, so this only narrows the types.
+ const to = row.participant.email;
+ const slotAt = row.slot.slotAt;
+ if (to === null || slotAt === null) return;
+
+ const optOutToken = reminderOptOutTokenFor(row.participant.id);
+
+ await sendSlotChanged(
+ to,
+ {
+ participantName: row.participant.name,
+ signupTitle: row.signup.title,
+ manageUrl: commitmentEditUrl(
+ row.signup.slug,
+ row.commitment.id,
+ editTokenFor(row.commitment.id),
+ ),
+ unsubscribeUrl: reminderUnsubscribeUrl(row.signup.slug, row.participant.id, optOutToken),
+ ...(await slotText(db, row.signup.id, row.slot.values)),
+ notes: row.commitment.notes,
+ },
+ { unsubscribePostUrl: reminderUnsubscribePostUrl(row.participant.id, optOutToken) },
+ );
+
+ await recordActivity(db, {
+ signupId: row.signup.id,
+ workspaceId: row.signup.workspaceId,
+ actor: { actorId: null, actorType: 'system' },
+ eventType: 'slot.change_sent',
+ payload: {
+ commitmentId: row.commitment.id,
+ participantId: row.participant.id,
+ channel: 'email',
+ slotAt: slotAt.toISOString(),
+ },
+ });
+}
diff --git a/src/jobs/worker.ts b/src/jobs/worker.ts
index 1e66ded4..077b14c6 100644
--- a/src/jobs/worker.ts
+++ b/src/jobs/worker.ts
@@ -3,8 +3,9 @@ config({ path: '.env.local' });
config({ path: '.env' });
import { log } from '@/lib/log';
-import { getBoss, QUEUES, type ReminderSendPayload } from './queue';
+import { getBoss, QUEUES, type ReminderSendPayload, type SlotChangeSendPayload } from './queue';
import { dispatchReminders, sendReminderJob } from './reminders';
+import { dispatchSlotChanges, sendSlotChangeJob } from './slot-changes';
import { runHousekeeping } from './housekeeping';
async function main() {
@@ -32,6 +33,29 @@ async function main() {
}
});
+ await boss.work(QUEUES.slotChangeSend, async (jobs) => {
+ for (const job of jobs) {
+ try {
+ await sendSlotChangeJob(job.data);
+ } catch (err) {
+ log.error({ err, commitmentId: job.data.commitmentId }, 'slot-change email failed');
+ throw err; // retried as a reminder send is
+ }
+ }
+ });
+
+ // Slot changes: every 2 minutes, so an email goes out between 10 and 12
+ // minutes after the organizer's last move (SLOT_CHANGE_SETTLE_MINUTES).
+ await boss.schedule(QUEUES.slotChangeDispatch, '*/2 * * * *');
+ await boss.work(QUEUES.slotChangeDispatch, async () => {
+ try {
+ await dispatchSlotChanges();
+ } catch (err) {
+ log.error({ err }, 'slot-change dispatch failed');
+ throw err;
+ }
+ });
+
// Housekeeping: hourly. Reads already ignore expired rows, so a missed run
// costs disk, never correctness.
await boss.schedule(QUEUES.housekeeping, '17 * * * *');
@@ -44,7 +68,7 @@ async function main() {
}
});
- log.info('signup worker started · reminderDispatch + reminderSend + housekeeping queues online');
+ log.info('signup worker started · reminder, slot-change and housekeeping queues online');
const shutdown = async (signal: string) => {
log.info({ signal }, 'stopping worker');
diff --git a/src/lib/reminder-eligibility.ts b/src/lib/reminder-eligibility.ts
index edac14ce..233fa28b 100644
--- a/src/lib/reminder-eligibility.ts
+++ b/src/lib/reminder-eligibility.ts
@@ -20,6 +20,15 @@
*/
export const REMINDER_SETTLE_HOURS = 1;
+/**
+ * How long a slot must sit still after its last move before the people holding
+ * it are emailed about it (src/jobs/slot-changes.ts). Builder edits save field
+ * by field, and typing in a date input can save the days in between, so one
+ * move by the organizer is often several. Waiting for the slot to settle turns
+ * them into one email, for where it ended up.
+ */
+export const SLOT_CHANGE_SETTLE_MINUTES = 10;
+
const MS_PER_HOUR = 3_600_000;
/**
diff --git a/src/services/slots.ts b/src/services/slots.ts
index d034cea8..767a97b9 100644
--- a/src/services/slots.ts
+++ b/src/services/slots.ts
@@ -391,6 +391,22 @@ export async function updateSlot(
eventType: 'slot.updated',
payload: { slotId, changed: Object.keys(data) },
});
+ // A new date or time: the row the worker reads to tell the people holding
+ // the slot (src/jobs/slot-changes.ts), with where it was, so it can tell
+ // what a participant was last told from where the slot is now.
+ if (data.values !== undefined && !sameInstant(locked.slotAt, row.slotAt)) {
+ await recordActivity(tx, {
+ signupId: row.signupId,
+ workspaceId: locked.workspaceId,
+ actor: activityActor(actor),
+ eventType: 'slot.rescheduled',
+ payload: {
+ slotId,
+ from: locked.slotAt?.toISOString() ?? null,
+ to: row.slotAt?.toISOString() ?? null,
+ },
+ });
+ }
return ok(row);
});
}
@@ -512,3 +528,7 @@ export async function pickAvailableRef(
}
return `${base}-${Date.now()}`;
}
+
+function sameInstant(a: Date | null, b: Date | null): boolean {
+ return a === null || b === null ? a === b : a.getTime() === b.getTime();
+}