@acms-subir/core 0.3.52 → 0.3.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/cli/migrations.generated.mjs +18 -1
  3. package/docs/calendar.md +20 -2
  4. package/docs/mobile.md +1 -1
  5. package/docs/server.md +61 -1
  6. package/integration.mjs +11 -5
  7. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +2 -2
  8. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +2 -2
  9. package/package.json +1 -1
  10. package/src/components/ThemeSections.astro +2 -2
  11. package/src/components/editor/PostEditor.tsx +2 -0
  12. package/src/components/editor/event/EventSheet.tsx +6 -2
  13. package/src/components/editor/event/RegistrationCard.tsx +3 -3
  14. package/src/components/editor/event/RegistrationFormEditor.tsx +6 -3
  15. package/src/components/forms/Canvas.tsx +31 -19
  16. package/src/components/forms/FormBuildPage.tsx +12 -6
  17. package/src/components/forms/FormBuilder.tsx +60 -11
  18. package/src/components/forms/Inspector.tsx +116 -8
  19. package/src/components/forms/TypeIcon.tsx +1 -0
  20. package/src/components/forms/meta.ts +71 -9
  21. package/src/lib/api.ts +3 -0
  22. package/src/lib/backup/content.ts +4 -2
  23. package/src/lib/calendar/api.ts +3 -1
  24. package/src/lib/calendar/host-requests.ts +247 -0
  25. package/src/lib/calendar/member-form.ts +17 -11
  26. package/src/lib/calendar/member-words.ts +473 -0
  27. package/src/lib/calendar/members-config.ts +2 -2
  28. package/src/lib/calendar/moves.ts +81 -33
  29. package/src/lib/calendar/request-migration.ts +17 -0
  30. package/src/lib/calendar/request-store.ts +104 -0
  31. package/src/lib/calendar/requests.ts +24 -24
  32. package/src/lib/dynamicBlocks.ts +4 -2
  33. package/src/lib/email/service.ts +12 -2
  34. package/src/lib/email/templates.ts +12 -2
  35. package/src/lib/forms/fields.ts +14 -9
  36. package/src/lib/forms/localize.ts +68 -0
  37. package/src/lib/forms/render.ts +8 -2
  38. package/src/lib/forms/schema.ts +31 -2
  39. package/src/lib/forms/service.ts +9 -1
  40. package/src/lib/forms/validate.ts +16 -4
  41. package/src/lib/forms/words.ts +32 -0
  42. package/src/lib/mobile/forms.ts +45 -13
  43. package/src/lib/mobile/openapi.ts +3 -3
  44. package/src/lib/mobile/rsvp.ts +4 -2
  45. package/src/lib/mobile/schemas.ts +3 -1
  46. package/src/lib/repo/migrations.ts +4 -0
  47. package/src/lib/repo/sql.ts +7 -0
  48. package/src/lib/repo/types.ts +6 -1
  49. package/src/lib/schema.ts +47 -0
  50. package/src/pages/[...slug].astro +1 -1
  51. package/src/pages/admin/calendar/events/index.astro +82 -6
  52. package/src/pages/admin/forms/[id].astro +6 -0
  53. package/src/pages/admin/index.astro +6 -2
  54. package/src/pages/blog/[slug].astro +1 -1
  55. package/src/pages/events/[slug].astro +9 -3
  56. package/src/pages/forms/[id].astro +22 -4
  57. package/src/pages/index.astro +1 -1
  58. package/src/pages/members/_EventForm.astro +33 -27
  59. package/src/pages/members/_Layout.astro +25 -5
  60. package/src/pages/members/_http.ts +29 -6
  61. package/src/pages/members/events/[id].astro +21 -20
  62. package/src/pages/members/events/index.astro +295 -101
  63. package/src/pages/members/events/new.astro +11 -10
  64. package/src/server/index.ts +53 -0
  65. package/src/styles/forms.css +8 -0
  66. package/src/virtual.d.ts +1 -1
@@ -18,9 +18,9 @@
18
18
  // RSVP emails always go, because those people's own booking changed.
19
19
  import type { ContentRepository } from '../repo/types';
20
20
  import { EventLocation, type CalEvent, type MovedDate } from '../schema';
21
- import { ApiError } from '../api';
21
+ import { ApiError, coded } from '../api';
22
22
  import { eventSideEffects, type SaveHooks } from '../content-service';
23
- import { addWallDays, addWallMinutes, eventTimeZone, expandEvents, findOccurrence, formatWall, movedInstants, normalizeEventTimes, parseWall, wallMinutesBetween, type Occurrence } from './events';
23
+ import { addWallDays, addWallMinutes, eventRoomIds, eventTimeZone, expandEvents, findOccurrence, formatWall, movedInstants, normalizeEventTimes, parseWall, wallMinutesBetween, type Occurrence } from './events';
24
24
  import { holdsFromEvents, roomBusy, type BusySlot } from '../booking/holds';
25
25
  import { kickEmail } from '../email/service';
26
26
  import { waitUntilFrom } from '../webhooks';
@@ -47,6 +47,8 @@ export interface MoveInput {
47
47
  export interface MoveOptions {
48
48
  /** no app notice and no staff email (a sync catching up); RSVP emails still go */
49
49
  quiet?: boolean;
50
+ /** who to record as having moved it (MovedDate.by), when not the actor: the host whose request staff approved */
51
+ by?: string;
50
52
  now?: number;
51
53
  }
52
54
  export interface MoveResult {
@@ -62,10 +64,11 @@ export interface MoveResult {
62
64
  * repeating event), cancelled-event, not-a-date (the key isn't one of its
63
65
  * dates), cancelled-date, started (the date has started or passed),
64
66
  * original-passed (moving back to a time that has passed), past (the new time
65
- * has passed), bad-time, room-taken (a host, 409).
67
+ * has passed), bad-time, room-taken (a host, 409), room-not-allowed (a host
68
+ * picking a room the event doesn't use and nobody may request, 403).
66
69
  */
67
- export type MoveRefusal = 'one-off' | 'cancelled-event' | 'not-a-date' | 'cancelled-date' | 'started' | 'original-passed' | 'past' | 'bad-time' | 'room-taken';
68
- const refuse = (status: number, code: MoveRefusal, message: string) => Object.assign(new ApiError(status, message), { code });
70
+ export type MoveRefusal = 'one-off' | 'cancelled-event' | 'not-a-date' | 'cancelled-date' | 'started' | 'original-passed' | 'past' | 'bad-time' | 'room-taken' | 'room-not-allowed';
71
+ const refuse = (status: number, code: MoveRefusal, message: string) => coded(status, code, message);
69
72
 
70
73
  const DAY = 86_400_000;
71
74
  const NOTE_MAX = 500;
@@ -73,16 +76,16 @@ const NOTE_MAX = 500;
73
76
  type Role = 'staff' | 'host';
74
77
  function roleOf(actor: MoveActor | null, e: CalEvent): Role {
75
78
  if (actor === GOOGLE_SYNC) return 'staff';
76
- if (!actor) throw new ApiError(401, 'Sign in to continue.');
79
+ if (!actor) throw coded(401, 'sign-in', 'Sign in to continue.');
77
80
  if (actor.can('content.edit')) return 'staff';
78
81
  if (e.hostIds.includes(actor.id)) return 'host';
79
- throw new ApiError(404, 'Event not found.');
82
+ throw coded(404, 'not-found', 'Event not found.');
80
83
  }
81
84
  const actorId = (actor: MoveActor) => (actor === GOOGLE_SYNC ? GOOGLE_SYNC : actor.id);
82
85
 
83
86
  async function load(repo: ContentRepository, id: string) {
84
87
  const e = await repo.getEvent(id);
85
- if (!e || e.status === 'archived') throw new ApiError(404, 'Event not found.');
88
+ if (!e || e.status === 'archived') throw coded(404, 'not-found', 'Event not found.');
86
89
  return e;
87
90
  }
88
91
 
@@ -137,7 +140,7 @@ async function refuseForHost(repo: ContentRepository, clashes: BusySlot[]) {
137
140
  const names = new Map((await repo.listResources()).map((r) => [r.id, r.name]));
138
141
  const rooms = [...new Set(clashes.map((c) => names.get(c.resourceId) ?? 'The room'))];
139
142
  const list = rooms.length > 1 ? `${rooms.slice(0, -1).join(', ')} and ${rooms[rooms.length - 1]}` : rooms[0];
140
- throw refuse(409, 'room-taken', `${list} ${rooms.length > 1 ? 'are' : 'is'} taken at that time. Pick another time${rooms.length ? ' or room' : ''}, or ask the team.`);
143
+ throw Object.assign(refuse(409, 'room-taken', `${list} ${rooms.length > 1 ? 'are' : 'is'} taken at that time. Pick another time${rooms.length ? ' or room' : ''}, or ask the team.`), { rooms });
141
144
  }
142
145
 
143
146
  const sameRooms = (a: string[] | undefined, b: string[]) => a !== undefined && a.length === b.length && a.every((x, i) => x === b[i]);
@@ -145,14 +148,15 @@ const sameRooms = (a: string[] | undefined, b: string[]) => a !== undefined && a
145
148
  const sameMove = (a: MovedDate, b: MovedDate) => a.start === b.start && a.end === b.end && a.note === b.note
146
149
  && JSON.stringify(a.roomIds ?? null) === JSON.stringify(b.roomIds ?? null) && JSON.stringify(a.location ?? null) === JSON.stringify(b.location ?? null);
147
150
 
148
- /**
149
- * Move one date of a repeating event to a new time (and maybe other rooms or
150
- * another place). Moving an already moved date replaces its move; moving it
151
- * back to exactly where it was is moveOccurrenceBack. Moving it to where it
152
- * already is changes nothing (`changed: false`): retries never email twice.
153
- */
154
- export async function moveOccurrence(repo: ContentRepository, actor: MoveActor | null, id: string, key: string, input: MoveInput, hooks: SaveHooks, opts: MoveOptions = {}): Promise<MoveResult> {
155
- const now = opts.now ?? Date.now();
151
+ // What a move would do, checked but not written: the same refusals as
152
+ // moveOccurrence, except a host's taken room, which comes back as clashes
153
+ type MovePlan =
154
+ | { kind: 'same'; e: CalEvent }
155
+ | { kind: 'isBack' }
156
+ | { kind: 'move'; e: CalEvent; role: Role; settings: Settings; current: Occurrence; next: CalEvent; note: string; clashes: BusySlot[] };
157
+ type Settings = Awaited<ReturnType<ContentRepository['getSettings']>>;
158
+
159
+ async function planMove(repo: ContentRepository, actor: MoveActor | null, id: string, key: string, input: MoveInput, now: number, by?: string): Promise<MovePlan> {
156
160
  const e = await load(repo, id);
157
161
  const role = roleOf(actor, e);
158
162
  const settings = await repo.getSettings();
@@ -162,8 +166,14 @@ export async function moveOccurrence(repo: ContentRepository, actor: MoveActor |
162
166
 
163
167
  let roomIds = input.roomIds === undefined ? prior?.roomIds : [...new Set(input.roomIds)];
164
168
  if (roomIds !== undefined) {
165
- const known = new Set((await repo.listResources()).map((r) => r.id));
169
+ const resources = await repo.listResources();
170
+ const known = new Set(resources.map((r) => r.id));
166
171
  roomIds = roomIds.filter((r) => known.has(r));
172
+ // hosts pick among the rooms the series already uses and the ones anyone may ask for
173
+ if (role === 'host') {
174
+ const allowed = new Set([...eventRoomIds(e), ...resources.filter((r) => r.requestable).map((r) => r.id)]);
175
+ if (roomIds.some((r) => !allowed.has(r))) throw refuse(403, 'room-not-allowed', 'Pick from the rooms this event uses, or ask the team for another room.');
176
+ }
167
177
  // the event's own rooms aren't an override
168
178
  if (sameRooms(roomIds, e.roomHold.resourceIds)) roomIds = undefined;
169
179
  }
@@ -172,34 +182,29 @@ export async function moveOccurrence(repo: ContentRepository, actor: MoveActor |
172
182
  if (location && JSON.stringify(location) === JSON.stringify(e.location)) location = undefined;
173
183
  const note = input.note === undefined ? prior?.note ?? '' : String(input.note).replace(/\s+/g, ' ').trim().slice(0, NOTE_MAX);
174
184
 
175
- const entry: MovedDate = { start: times.start, end: times.end, ...(roomIds ? { roomIds } : {}), ...(location ? { location } : {}), note, by: actorId(actor!), at: now };
176
- if (prior && sameMove(prior, entry)) return { event: e, clashes: [], changed: false };
185
+ const entry: MovedDate = { start: times.start, end: times.end, ...(roomIds ? { roomIds } : {}), ...(location ? { location } : {}), note, by: by || actorId(actor!), at: now };
186
+ if (prior && sameMove(prior, entry)) return { kind: 'same', e };
177
187
  const at = movedInstants(e, entry, settings);
178
188
  if (!at) throw refuse(400, 'bad-time', 'Pick a valid time.');
179
189
 
180
190
  // back where the series puts it, with nothing else different: that's moving it back
181
191
  const series = findOccurrence({ ...e, movedDates: {} }, key, settings)!;
182
- if (at.start === series.start && at.end === series.end && !roomIds && !location) return moveOccurrenceBack(repo, actor, id, key, hooks, opts);
192
+ if (at.start === series.start && at.end === series.end && !roomIds && !location) return { kind: 'isBack' };
183
193
  if (current.start <= now) throw started();
184
194
  if (at.start <= now) throw refuse(400, 'past', 'Pick a time that hasn’t passed yet.');
185
195
 
186
196
  const next: CalEvent = { ...e, movedDates: { ...e.movedDates, [key]: entry } };
187
- const clashes = await clashesFor(repo, next, key, at, settings);
188
- if (clashes.length && role === 'host') await refuseForHost(repo, clashes);
189
-
190
- await repo.setEventMovedDates(id, next.movedDates, undefined, { quiet: opts.quiet });
191
- const after = (await repo.getEvent(id))!;
192
- const moved = findOccurrence(after, key, settings)!;
193
- await afterChange(repo, after, current, moved, { role, actor: actor!, note, hooks, opts, settings });
194
- return { event: after, clashes, changed: true };
197
+ return { kind: 'move', e, role, settings, current, next, note, clashes: await clashesFor(repo, next, key, at, settings) };
195
198
  }
196
199
 
197
- /** Put a moved date back where the series has it (idempotent: a date that isn't moved is `changed: false`). Hosts are refused if its rooms are taken there now. */
198
- export async function moveOccurrenceBack(repo: ContentRepository, actor: MoveActor | null, id: string, key: string, hooks: SaveHooks, opts: MoveOptions = {}): Promise<MoveResult> {
199
- const now = opts.now ?? Date.now();
200
+ type BackPlan =
201
+ | { kind: 'same'; e: CalEvent }
202
+ | { kind: 'back'; e: CalEvent; role: Role; settings: Settings; rest: CalEvent['movedDates']; current: Occurrence | null; back: Occurrence | null; clashes: BusySlot[] };
203
+
204
+ async function planBack(repo: ContentRepository, actor: MoveActor | null, id: string, key: string, now: number): Promise<BackPlan> {
200
205
  const e = await load(repo, id);
201
206
  const role = roleOf(actor, e);
202
- if (!Object.hasOwn(e.movedDates, key)) return { event: e, clashes: [], changed: false };
207
+ if (!Object.hasOwn(e.movedDates, key)) return { kind: 'same', e };
203
208
  const settings = await repo.getSettings();
204
209
  const { [key]: _gone, ...rest } = e.movedDates;
205
210
  const next: CalEvent = { ...e, movedDates: rest };
@@ -211,6 +216,49 @@ export async function moveOccurrenceBack(repo: ContentRepository, actor: MoveAct
211
216
  if (back.start <= now) throw refuse(400, 'original-passed', 'Its original time has already passed.');
212
217
  }
213
218
  const clashes = back ? await clashesFor(repo, next, key, back, settings) : [];
219
+ return { kind: 'back', e, role, settings, rest, current, back, clashes };
220
+ }
221
+
222
+ /** What moveOccurrence (or, `input` null, moveOccurrenceBack) would do, without doing it: the same refusals, except that a host's taken room comes back in `clashes` instead of a 409. A move gives the date's new wall times and note too. */
223
+ export async function checkMove(repo: ContentRepository, actor: MoveActor | null, id: string, key: string, input: MoveInput | null, opts: Pick<MoveOptions, 'now'> = {}): Promise<{ clashes: BusySlot[]; changed: boolean; back?: boolean; start?: string; end?: string; note?: string }> {
224
+ const now = opts.now ?? Date.now();
225
+ const plan = input ? await planMove(repo, actor, id, key, input, now) : await planBack(repo, actor, id, key, now);
226
+ if (plan.kind === 'same') return { clashes: [], changed: false };
227
+ // a move to where the series has it is a move back
228
+ if (plan.kind === 'isBack') return { ...(await checkMove(repo, actor, id, key, null, opts)), back: true };
229
+ if (plan.kind === 'move') {
230
+ const m = plan.next.movedDates[key];
231
+ return { clashes: plan.clashes, changed: true, start: m.start, end: m.end, note: m.note };
232
+ }
233
+ return { clashes: plan.clashes, changed: true };
234
+ }
235
+
236
+ /**
237
+ * Move one date of a repeating event to a new time (and maybe other rooms or
238
+ * another place). Moving an already moved date replaces its move; moving it
239
+ * back to exactly where it was is moveOccurrenceBack. Moving it to where it
240
+ * already is changes nothing (`changed: false`): retries never email twice.
241
+ * Hosts pick rooms among the event's own and the requestable ones.
242
+ */
243
+ export async function moveOccurrence(repo: ContentRepository, actor: MoveActor | null, id: string, key: string, input: MoveInput, hooks: SaveHooks, opts: MoveOptions = {}): Promise<MoveResult> {
244
+ const plan = await planMove(repo, actor, id, key, input, opts.now ?? Date.now(), opts.by);
245
+ if (plan.kind === 'same') return { event: plan.e, clashes: [], changed: false };
246
+ if (plan.kind === 'isBack') return moveOccurrenceBack(repo, actor, id, key, hooks, opts);
247
+ const { role, settings, current, next, note, clashes } = plan;
248
+ if (clashes.length && role === 'host') await refuseForHost(repo, clashes);
249
+
250
+ await repo.setEventMovedDates(id, next.movedDates, undefined, { quiet: opts.quiet });
251
+ const after = (await repo.getEvent(id))!;
252
+ const moved = findOccurrence(after, key, settings)!;
253
+ await afterChange(repo, after, current, moved, { role, actor: actor!, note, hooks, opts, settings });
254
+ return { event: after, clashes, changed: true };
255
+ }
256
+
257
+ /** Put a moved date back where the series has it (idempotent: a date that isn't moved is `changed: false`). Hosts are refused if its rooms are taken there now. */
258
+ export async function moveOccurrenceBack(repo: ContentRepository, actor: MoveActor | null, id: string, key: string, hooks: SaveHooks, opts: MoveOptions = {}): Promise<MoveResult> {
259
+ const plan = await planBack(repo, actor, id, key, opts.now ?? Date.now());
260
+ if (plan.kind === 'same') return { event: plan.e, clashes: [], changed: false };
261
+ const { role, settings, rest, current, back, clashes } = plan;
214
262
  if (clashes.length && role === 'host') await refuseForHost(repo, clashes);
215
263
  await repo.setEventMovedDates(id, rest, undefined, { quiet: opts.quiet });
216
264
  const after = (await repo.getEvent(id))!;
@@ -0,0 +1,17 @@
1
+ // v67: calendar requests on their own rows (RequestRecord in schema.ts,
2
+ // stored by lib/calendar/request-store.ts). Not public (nothing here reaches
3
+ // pages or the public API), so no content version or event_changes
4
+ // triggers; included in backups. No imports: the CLI's migrate runner
5
+ // bundles this file.
6
+ export const REQUEST_TABLE = [
7
+ `CREATE TABLE event_requests (
8
+ id TEXT PRIMARY KEY, kind TEXT NOT NULL, state TEXT NOT NULL, source TEXT NOT NULL,
9
+ user_id TEXT NOT NULL DEFAULT '', requester TEXT,
10
+ event_id TEXT NOT NULL DEFAULT '', occurrence_key TEXT NOT NULL DEFAULT '',
11
+ data TEXT NOT NULL DEFAULT '{}', note TEXT NOT NULL DEFAULT '', reply TEXT NOT NULL DEFAULT '',
12
+ reviewed_by TEXT NOT NULL DEFAULT '', reviewed_at INTEGER NOT NULL DEFAULT 0,
13
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
14
+ )`,
15
+ `CREATE INDEX event_requests_state ON event_requests (state, created_at)`,
16
+ `CREATE INDEX event_requests_event ON event_requests (event_id)`,
17
+ ];
@@ -0,0 +1,104 @@
1
+ import type { SqlDriver, SqlStatement } from '../repo/sql';
2
+ import { RequestRecord, type RequestKind, type RequestSource, type RequestState } from '../schema';
3
+
4
+ // Storage for calendar requests kept on their own rows (RequestRecord in
5
+ // schema.ts). Shared by the D1 and libSQL adapters (plain SQLite). The rules
6
+ // (who may ask, approve or decline) live in lib/calendar/requests.ts.
7
+
8
+ type Row = Record<string, unknown>;
9
+ const json = (raw: unknown) => { try { return raw == null ? null : JSON.parse(String(raw)); } catch { return null; } };
10
+ const record = (r: Row): RequestRecord => RequestRecord.parse({
11
+ id: r.id, kind: r.kind, state: r.state, source: r.source, userId: r.user_id, requester: json(r.requester),
12
+ eventId: r.event_id, key: r.occurrence_key, data: json(r.data) ?? {}, note: r.note, reply: r.reply,
13
+ reviewedBy: r.reviewed_by, reviewedAt: Number(r.reviewed_at), createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
14
+ });
15
+
16
+ /** The INSERT for one request (create and backup restore). */
17
+ export const requestInsert = (q: RequestRecord): SqlStatement => ({
18
+ sql: `INSERT INTO event_requests (id,kind,state,source,user_id,requester,event_id,occurrence_key,data,note,reply,reviewed_by,reviewed_at,created_at,updated_at)
19
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
20
+ args: [q.id, q.kind, q.state, q.source, q.userId, q.requester ? JSON.stringify(q.requester) : null, q.eventId, q.key,
21
+ JSON.stringify(q.data), q.note, q.reply, q.reviewedBy, q.reviewedAt, q.createdAt, q.updatedAt],
22
+ });
23
+
24
+ export interface RequestQuery {
25
+ states?: RequestState[];
26
+ kinds?: RequestKind[];
27
+ sources?: RequestSource[];
28
+ eventId?: string;
29
+ userId?: string;
30
+ /** 'oldest' / 'newest' by when they were sent; 'decided' = latest answer first */
31
+ order?: 'oldest' | 'newest' | 'decided';
32
+ /** -1 = all (backups) */
33
+ limit?: number;
34
+ offset?: number;
35
+ }
36
+ /** what changes when someone answers, withdraws or edits a request */
37
+ export type RequestPatch = Partial<Pick<RequestRecord, 'state' | 'reply' | 'reviewedBy' | 'reviewedAt' | 'eventId' | 'data' | 'note'>>;
38
+ const PATCH: [keyof RequestPatch, string][] = [
39
+ ['state', 'state'], ['reply', 'reply'], ['reviewedBy', 'reviewed_by'], ['reviewedAt', 'reviewed_at'], ['eventId', 'event_id'], ['data', 'data'], ['note', 'note'],
40
+ ];
41
+
42
+ function where(q: RequestQuery) {
43
+ const sql: string[] = [], args: unknown[] = [];
44
+ const any = (col: string, values?: string[]) => {
45
+ if (values === undefined) return;
46
+ // an empty list matches nothing
47
+ sql.push(values.length ? `${col} IN (${values.map(() => '?').join(',')})` : '0');
48
+ args.push(...values);
49
+ };
50
+ any('state', q.states); any('kind', q.kinds); any('source', q.sources);
51
+ if (q.eventId !== undefined) { sql.push('event_id=?'); args.push(q.eventId); }
52
+ if (q.userId !== undefined) { sql.push('user_id=?'); args.push(q.userId); }
53
+ return { sql: sql.length ? `WHERE ${sql.join(' AND ')}` : '', args };
54
+ }
55
+ const ORDER = { oldest: 'created_at ASC, id', newest: 'created_at DESC, id', decided: 'reviewed_at DESC, created_at DESC, id' };
56
+
57
+ export class RequestStore {
58
+ constructor(private driver: () => Promise<SqlDriver>) {}
59
+ private async all(sql: string, args: unknown[] = []) { return (await this.driver()).all(sql, args); }
60
+
61
+ /** Saves a new request; null when one with this id exists already (an idempotent retry: read it with get). */
62
+ async create(q: RequestRecord): Promise<RequestRecord | null> {
63
+ const insert = requestInsert(RequestRecord.parse(q));
64
+ const rows = await this.all(`${insert.sql} ON CONFLICT(id) DO NOTHING RETURNING *`, insert.args);
65
+ return rows[0] ? record(rows[0]) : null;
66
+ }
67
+ async get(id: string): Promise<RequestRecord | null> {
68
+ const r = await this.all('SELECT * FROM event_requests WHERE id=?', [id]);
69
+ return r[0] ? record(r[0]) : null;
70
+ }
71
+ async list(q: RequestQuery = {}): Promise<RequestRecord[]> {
72
+ const w = where(q);
73
+ return (await this.all(`SELECT * FROM event_requests ${w.sql} ORDER BY ${ORDER[q.order ?? 'newest']} LIMIT ? OFFSET ?`,
74
+ [...w.args, q.limit ?? 100, q.offset ?? 0])).map(record);
75
+ }
76
+ async count(q: Pick<RequestQuery, 'states' | 'kinds' | 'sources' | 'eventId' | 'userId'> = {}): Promise<number> {
77
+ const w = where(q);
78
+ return Number((await this.all(`SELECT COUNT(*) AS n FROM event_requests ${w.sql}`, w.args))[0]?.n ?? 0);
79
+ }
80
+ /**
81
+ * Writes the given fields and bumps updatedAt (so a reviewer's `seen` guard
82
+ * notices an edit). `onlyIf` makes it a compare-and-set: null when the row
83
+ * is no longer in that state or was changed since `updatedAt`.
84
+ */
85
+ async update(id: string, patch: RequestPatch, onlyIf: { state?: RequestState; updatedAt?: number } = {}): Promise<RequestRecord | null> {
86
+ const set = PATCH.filter(([k]) => patch[k] !== undefined);
87
+ const args: unknown[] = set.map(([k]) => (k === 'data' ? JSON.stringify(patch.data) : patch[k]));
88
+ // strictly later than before, so two writes in one millisecond still differ
89
+ const sql = [...set.map(([, c]) => `${c}=?`), 'updated_at=MAX(?, updated_at + 1)'];
90
+ args.push(Date.now(), id);
91
+ const guard: string[] = [];
92
+ if (onlyIf.state) { guard.push('state=?'); args.push(onlyIf.state); }
93
+ if (onlyIf.updatedAt !== undefined) { guard.push('updated_at=?'); args.push(onlyIf.updatedAt); }
94
+ const rows = await this.all(`UPDATE event_requests SET ${sql.join(',')} WHERE id=?${guard.map((g) => ` AND ${g}`).join('')} RETURNING *`, args);
95
+ return rows[0] ? record(rows[0]) : null;
96
+ }
97
+ async delete(id: string): Promise<boolean> {
98
+ return (await this.all('DELETE FROM event_requests WHERE id=? RETURNING id', [id])).length > 0;
99
+ }
100
+ /** Deletes requests answered or withdrawn before `before` (ms); pending ones stay. Returns how many went. */
101
+ async prune(before: number): Promise<number> {
102
+ return (await this.all(`DELETE FROM event_requests WHERE state<>'pending' AND reviewed_at>0 AND reviewed_at<? RETURNING id`, [before])).length;
103
+ }
104
+ }
@@ -1,7 +1,7 @@
1
1
  import type { ContentRepository } from '../repo/types';
2
2
  import type { CalEvent, EventRequest } from '../schema';
3
3
  import { EventLocation } from '../schema';
4
- import { ApiError } from '../api';
4
+ import { ApiError, coded } from '../api';
5
5
  import { applyEventUpdate, flushEventHooks, saveEvent, type SaveHooks } from '../content-service';
6
6
  import { CALENDAR } from './config';
7
7
  import { cancelOccurrence, restoreOccurrence, uniqueSlug } from './schedule';
@@ -74,38 +74,38 @@ export function bodyToText(e: Pick<CalEvent, 'bodyHtml'>): string {
74
74
  }
75
75
 
76
76
  function requireMember(who: Actor | null): asserts who is Actor {
77
- if (!who) throw new ApiError(401, 'Sign in to continue.');
78
- if (!who.can('events.request')) throw new ApiError(403, 'Your role doesn’t include “Request events”. Ask the site’s team if you need it.');
77
+ if (!who) throw coded(401, 'sign-in', 'Sign in to continue.');
78
+ if (!who.can('events.request')) throw coded(403, 'no-request-role', 'Your role doesn’t include “Request events”. Ask the site’s team if you need it.');
79
79
  }
80
80
  function requireApprover(who: Actor | null): asserts who is Actor {
81
- if (!who) throw new ApiError(401, 'Sign in to continue.');
81
+ if (!who) throw coded(401, 'sign-in', 'Sign in to continue.');
82
82
  if (!who.can('content.publish')) throw new ApiError(403, 'Your role doesn’t include “Publish content”, which approving requests needs.');
83
83
  }
84
84
  /** a host of this event, or the member who asked for it */
85
85
  export const isHost = (e: CalEvent, userId: string) => e.hostIds.includes(userId) || (e.request?.by === userId && e.request.kind === 'new');
86
86
  async function hostedEvent(repo: ContentRepository, who: Actor, id: string) {
87
87
  const e = await repo.getEvent(id);
88
- if (!e || e.status === 'archived' || !isHost(e, who.id)) throw new ApiError(404, 'Event not found.');
88
+ if (!e || e.status === 'archived' || !isHost(e, who.id)) throw coded(404, 'not-found', 'Event not found.');
89
89
  return e;
90
90
  }
91
91
 
92
92
  /** the request form's input → event fields, validated (`current`: the event being edited, whose own values may be kept) */
93
93
  async function clean(repo: ContentRepository, input: EventRequestInput, current?: CalEvent) {
94
94
  const title = line(input.title, LIMITS.title);
95
- if (!title) throw new ApiError(400, 'Give the event a title.');
95
+ if (!title) throw coded(400, 'title', 'Give the event a title.');
96
96
  const start = text(input.start, 16), allDay = input.allDay === true;
97
- if (!(allDay ? /^\d{4}-\d{2}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/).test(start)) throw new ApiError(400, 'Pick a date and start time.');
97
+ if (!(allDay ? /^\d{4}-\d{2}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/).test(start)) throw coded(400, 'start', 'Pick a date and start time.');
98
98
  const end = text(input.end, 16);
99
- if (end && !(allDay ? /^\d{4}-\d{2}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/).test(end)) throw new ApiError(400, 'Pick a valid end time.');
100
- if (end && end < start) throw new ApiError(400, 'The end has to come after the start.');
99
+ if (end && !(allDay ? /^\d{4}-\d{2}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/).test(end)) throw coded(400, 'end', 'Pick a valid end time.');
100
+ if (end && end < start) throw coded(400, 'end-before-start', 'The end has to come after the start.');
101
101
  const rrule = text(input.repeat, 80).toUpperCase();
102
102
  // a pattern staff set (weekly on two days, yearly…) may be kept as it is
103
- if (rrule && !REPEAT.test(rrule) && rrule !== current?.rrule.toUpperCase()) throw new ApiError(400, 'Choose how often it repeats from the list.');
103
+ if (rrule && !REPEAT.test(rrule) && rrule !== current?.rrule.toUpperCase()) throw coded(400, 'repeat', 'Choose how often it repeats from the list.');
104
104
  // members pick among the public calendars; any other calendars staff put it in stay
105
105
  const calendars = await repo.listCalendars();
106
106
  const open = new Set(calendars.filter((c) => c.kind === 'native' && c.visibility === 'public').map((c) => c.id));
107
107
  const chosen = input.calendarId ? String(input.calendarId) : '';
108
- if (chosen && !open.has(chosen) && !current?.calendarIds.includes(chosen)) throw new ApiError(400, 'Choose a calendar from the list.');
108
+ if (chosen && !open.has(chosen) && !current?.calendarIds.includes(chosen)) throw coded(400, 'calendar', 'Choose a calendar from the list.');
109
109
  const calendarIds = current && (!chosen || current.calendarIds.includes(chosen))
110
110
  ? current.calendarIds
111
111
  : [...(current?.calendarIds ?? []).filter((id) => !open.has(id)), ...(chosen ? [chosen] : [])];
@@ -113,7 +113,7 @@ async function clean(repo: ContentRepository, input: EventRequestInput, current?
113
113
  for (const f of CALENDAR.eventFields) {
114
114
  if (f.private || input.fields?.[f.key] === undefined) continue;
115
115
  const value = text(input.fields[f.key], LIMITS.field);
116
- if (f.type === 'select' && value && !f.options.includes(value)) throw new ApiError(400, `Choose ${f.label} from the list.`);
116
+ if (f.type === 'select' && value && !f.options.includes(value)) throw Object.assign(coded(400, 'field', `Choose ${f.label} from the list.`), { label: f.label });
117
117
  fields[f.key] = value; // '' clears it
118
118
  }
119
119
  return { title, start, end: end || undefined, allDay, rrule, calendarIds, fields, description: text(input.description, LIMITS.description), where: line(input.where, LIMITS.where) };
@@ -139,9 +139,9 @@ export async function pendingRequests(repo: ContentRepository): Promise<CalEvent
139
139
  /** a member asks for a new event: a draft they host, waiting for approval */
140
140
  export async function submitEventRequest(repo: ContentRepository, who: Actor | null, input: EventRequestInput, hooks: SaveHooks): Promise<CalEvent> {
141
141
  requireMember(who);
142
- if (!await repo.email.rateLimit(`event-request:${who.id}`, 20, 3_600_000)) throw new ApiError(429, 'That’s a lot of requests in an hour. Try again later.', 3600);
142
+ if (!await repo.email.rateLimit(`event-request:${who.id}`, 20, 3_600_000)) throw coded(429, 'rate-limit', 'That’s a lot of requests in an hour. Try again later.', 3600);
143
143
  const open = (await hostedEvents(repo, who.id)).filter((e) => e.request?.state === 'pending').length;
144
- if (open >= LIMITS.pending) throw new ApiError(429, 'You have many requests waiting already. Wait for the team to answer them first.');
144
+ if (open >= LIMITS.pending) throw coded(429, 'too-many-waiting', 'You have many requests waiting already. Wait for the team to answer them first.');
145
145
  const c = await clean(repo, input);
146
146
  const created = await repo.createEvent({
147
147
  title: c.title, slug: await uniqueSlug(repo, c.title), start: c.start, allDay: c.allDay, status: 'draft',
@@ -165,8 +165,8 @@ export async function updateEventRequest(repo: ContentRepository, who: Actor | n
165
165
  const e = await hostedEvent(repo, who, id);
166
166
  // staff have unpublished edits of their own on this live event: don't mix a
167
167
  // host's suggestion into them (declining it would throw both away)
168
- if (live(e) && e.draft && !hostChanges(e)) throw new ApiError(409, 'The team is editing this event right now. Try again later.');
169
- if (!await repo.email.rateLimit(`event-request:${who.id}`, 20, 3_600_000)) throw new ApiError(429, 'That’s a lot of changes in an hour. Try again later.', 3600);
168
+ if (live(e) && e.draft && !hostChanges(e)) throw coded(409, 'staff-editing', 'The team is editing this event right now. Try again later.');
169
+ if (!await repo.email.rateLimit(`event-request:${who.id}`, 20, 3_600_000)) throw coded(429, 'rate-limit', 'That’s a lot of changes in an hour. Try again later.', 3600);
170
170
  // what the host sees is the event with their own waiting changes on top
171
171
  const current = (hostChanges(e) ? { ...e, ...(e.draft ?? {}) } : e) as CalEvent;
172
172
  const c = await clean(repo, input, current);
@@ -186,7 +186,7 @@ export async function updateEventRequest(repo: ContentRepository, who: Actor | n
186
186
  export async function withdrawEventRequest(repo: ContentRepository, who: Actor | null, id: string, hooks: SaveHooks): Promise<{ deleted: boolean }> {
187
187
  requireMember(who);
188
188
  const e = await hostedEvent(repo, who, id);
189
- if (!e.request) throw new ApiError(400, 'There’s no request on this event to withdraw.');
189
+ if (!e.request) throw coded(400, 'nothing-to-withdraw', 'There’s no request on this event to withdraw.');
190
190
  // only an event the member's own request made is deleted; a draft staff wrote stays
191
191
  if (e.request.kind === 'new' && e.request.created && !live(e) && !e.publishedAt) {
192
192
  await repo.deleteEvent(id);
@@ -201,8 +201,8 @@ export async function withdrawEventRequest(repo: ContentRepository, who: Actor |
201
201
  /** the request staff are answering, unchanged since they opened it (`seen`: its `at`) */
202
202
  async function answerable(repo: ContentRepository, id: string, seen?: number) {
203
203
  const e = await repo.getEvent(id);
204
- if (!e?.request || e.request.state !== 'pending') throw new ApiError(409, 'This request was already answered or withdrawn.');
205
- if (seen !== undefined && e.request.at !== seen) throw new ApiError(409, 'This request changed after you opened it. Look at it again, then answer.');
204
+ if (!e?.request || e.request.state !== 'pending') throw coded(409, 'answered', 'This request was already answered or withdrawn.');
205
+ if (seen !== undefined && e.request.at !== seen) throw coded(409, 'changed', 'This request changed after you opened it. Look at it again, then answer.');
206
206
  return e as CalEvent & { request: EventRequest };
207
207
  }
208
208
 
@@ -231,25 +231,25 @@ export async function declineEventRequest(repo: ContentRepository, who: Actor |
231
231
 
232
232
  /** a host clears a declined answer they've read (a declined new request stays as a draft) */
233
233
  export async function dismissEventRequest(repo: ContentRepository, who: Actor | null, id: string): Promise<void> {
234
- if (!who) throw new ApiError(401, 'Sign in to continue.');
234
+ if (!who) throw coded(401, 'sign-in', 'Sign in to continue.');
235
235
  const e = await hostedEvent(repo, who, id);
236
236
  if (e.request?.state === 'declined' && e.request.kind === 'changes') await repo.setEventRequest(id, null);
237
237
  }
238
238
 
239
239
  /** hosts cancel or restore one date of their own event at once (no permission needed beyond hosting it) */
240
240
  export async function setHostedOccurrence(repo: ContentRepository, who: Actor | null, id: string, key: string, cancelled: boolean, hooks: SaveHooks): Promise<CalEvent> {
241
- if (!who) throw new ApiError(401, 'Sign in to continue.');
241
+ if (!who) throw coded(401, 'sign-in', 'Sign in to continue.');
242
242
  const e = await hostedEvent(repo, who, id);
243
- if (!e.hostIds.includes(who.id)) throw new ApiError(404, 'Event not found.');
243
+ if (!e.hostIds.includes(who.id)) throw coded(404, 'not-found', 'Event not found.');
244
244
  return (cancelled ? cancelOccurrence : restoreOccurrence)(repo, id, key, hooksFor(hooks));
245
245
  }
246
246
 
247
247
  /** staff set who hosts an event (Team members only) */
248
248
  export async function setEventHosts(repo: ContentRepository, who: Actor | null, id: string, hostIds: string[]): Promise<CalEvent> {
249
- if (!who) throw new ApiError(401, 'Sign in to continue.');
249
+ if (!who) throw coded(401, 'sign-in', 'Sign in to continue.');
250
250
  if (!who.can('content.edit')) throw new ApiError(403, 'Your role doesn’t include “Create and edit content”.');
251
251
  const e = await repo.getEvent(id);
252
- if (!e) throw new ApiError(404, 'Event not found.');
252
+ if (!e) throw coded(404, 'not-found', 'Event not found.');
253
253
  const known = new Set((await repo.listUsers()).map((u) => u.id));
254
254
  const ids = [...new Set(hostIds)].filter((h) => known.has(h)).slice(0, 10);
255
255
  await repo.setEventHosts(id, ids);
@@ -5,6 +5,7 @@ import { formatDate } from './dates';
5
5
  import { CONTENT, collectionUrl } from './content';
6
6
  import { renderCalendarBlock, isNativeSource } from './calendar/render';
7
7
  import { renderEventCards, type EventCardAttrs } from './calendar/cards';
8
+ import { localePath } from './i18n';
8
9
 
9
10
  // Dynamic blocks: bodyHtml is a save-time cache, so cards whose content must
10
11
  // stay fresh (post lists, calendars) serialize as placeholder divs and get
@@ -131,7 +132,8 @@ function eventCardAttrs(tag: string): EventCardAttrs {
131
132
  };
132
133
  }
133
134
 
134
- export async function expandDynamicBlocks(html: string, repo: ContentRepository): Promise<string> {
135
+ // locale: the page's language; a form block opens the form in it (/es/forms/<id>)
136
+ export async function expandDynamicBlocks(html: string, repo: ContentRepository, locale = ''): Promise<string> {
135
137
  html = stampHeadingAnchors(html);
136
138
  const hasForms = html.includes('data-dyn="form"');
137
139
  const hasPostList = html.includes('data-dyn="post-list"');
@@ -168,7 +170,7 @@ export async function expandDynamicBlocks(html: string, repo: ContentRepository)
168
170
  // A same-origin frame gives each visitor an uncached form/receipt even
169
171
  // when the containing content page is served from a shared cache.
170
172
  calendarHtml.set(tag, form?.kind === 'form' && form.purpose === 'general' && form.status === 'published'
171
- ? `<iframe class="pc-form-embed" src="/forms/${encodeURIComponent(form.id)}?embed=1" title="${esc(form.definition.title || form.name)}" loading="lazy"></iframe>` : '');
173
+ ? `<iframe class="pc-form-embed" src="${localePath(`/forms/${encodeURIComponent(form.id)}`, locale)}?embed=1" title="${esc(form.definition.title || form.name)}" loading="lazy"></iframe>` : '');
172
174
  }));
173
175
  }
174
176
  const anchored = html;
@@ -1,7 +1,7 @@
1
1
  import type { ContentRepository } from '../repo/types';
2
2
  import { resolveConfig, decrypt, encrypt, runtimeValue } from './config';
3
3
  import { sendWithProvider } from './providers';
4
- import { accountMail, canonicalOrigin, eventRequestMail, guestCodeMail, phoneLinkedMail, EmailLinkError } from './templates';
4
+ import { accountMail, canonicalOrigin, eventRequestMail, guestCodeMail, phoneLinkedMail, EmailLinkError, type EventRequestMailKind } from './templates';
5
5
  import { formatDate, siteLang } from '../dates';
6
6
  import { I18N } from '../i18n';
7
7
  import { bookingMail } from '../booking/service';
@@ -47,9 +47,19 @@ async function render(repo: ContentRepository, job: EmailJob, locals?: unknown,
47
47
  return accountMail(payload.purpose, job.recipient, settings.siteTitle, `${origin}/account/${payload.purpose}?token=${encodeURIComponent(raw)}`);
48
48
  }
49
49
  if (job.kind === 'event-request') {
50
- const p = JSON.parse(job.payload) as { eventId: string; kind: 'staff-new' | 'staff-changes' | 'staff-moved' | 'approved' | 'declined'; at: number; reply: string; by?: string; note?: string; when?: string };
50
+ const p = JSON.parse(job.payload) as { eventId: string; requestId?: string; kind: EventRequestMailKind; at: number; reply: string; by?: string; note?: string; when?: string };
51
51
  const e = await repo.getEvent(p.eventId);
52
52
  if (!e) return null;
53
+ // a host's date request (lib/calendar/host-requests.ts): staff hear about it while it waits unchanged, the host about its answer
54
+ if (p.requestId) {
55
+ const r = await repo.requests.get(p.requestId);
56
+ const staff = p.kind.startsWith('staff');
57
+ if (!r || (staff ? r.state !== 'pending' || r.updatedAt !== p.at : r.state === 'pending' || r.state === 'withdrawn')) return null;
58
+ const by = r.userId ? await repo.getUser(r.userId) : null;
59
+ const { memberEventsPath, MEMBERS } = await import('../calendar/requests');
60
+ const url = staff ? `${origin}/admin/calendar/events?scope=requests` : `${origin}${MEMBERS.enabled ? memberEventsPath() : '/'}`;
61
+ return eventRequestMail(p.kind, job.recipient, settings.siteTitle, { title: e.title, when: p.when ?? '', by: by?.name ?? '', note: r.note }, p.reply, url);
62
+ }
53
63
  // a host moved one date (lib/calendar/moves.ts): the words were fixed when it happened
54
64
  if (p.kind === 'staff-moved') {
55
65
  const by = p.by ? await repo.getUser(p.by) : null;
@@ -12,17 +12,27 @@ export function accountMail(kind: 'invite'|'reset'|'welcome'|'password-changed'|
12
12
  return { to, subject: `${copy[0]} · ${site}`, text: [copy[0], '', copy[1], ...(url ? ['',`${copy[2]}: ${url}`] : [])].join('\n'),
13
13
  html: `<!doctype html><html><body style="margin:0;background:#f6f5f2;color:#24211c;font:16px/1.6 system-ui,sans-serif"><div style="max-width:520px;margin:auto;padding:32px 24px"><p style="color:#7d776e">${escape(site)}</p><h1 style="font-size:24px">${escape(copy[0])}</h1><p>${escape(copy[1])}</p>${url ? `<p><a style="display:inline-block;background:#ee5d20;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none" href="${escape(url)}">${escape(copy[2])}</a></p>` : ''}</div></body></html>` };
14
14
  }
15
+ /** the kinds of eventRequestMail */
16
+ export type EventRequestMailKind = 'staff-new' | 'staff-changes' | 'staff-moved' | 'approved' | 'declined'
17
+ | 'staff-move-request' | 'move-approved' | 'move-declined' | 'staff-from-date-request' | 'from-date-approved' | 'from-date-declined';
15
18
  /**
16
19
  * members' event requests (lib/calendar/requests.ts): to staff when one arrives, to the member when it's answered;
17
- * staff-moved: a host moved one date (lib/calendar/moves.ts), `when` = "old → new"
20
+ * staff-moved: a host moved one date (lib/calendar/moves.ts), `when` = "old → new";
21
+ * a host's date move or "from this date on" request (lib/calendar/host-requests.ts): to staff, then its answer to the host
18
22
  */
19
- export function eventRequestMail(kind: 'staff-new' | 'staff-changes' | 'staff-moved' | 'approved' | 'declined', to: string, site: string, event: { title: string; when: string; by: string; note: string }, reply: string, url: string): Mail {
23
+ export function eventRequestMail(kind: EventRequestMailKind, to: string, site: string, event: { title: string; when: string; by: string; note: string }, reply: string, url: string): Mail {
20
24
  const copy = {
21
25
  'staff-new': [`New event request: ${event.title}`, `${event.by || 'A member'} asked for “${event.title}” (${event.when}). It stays a draft until someone approves it.`, 'Review it'],
22
26
  'staff-changes': [`Changes to review: ${event.title}`, `${event.by || 'A host'} suggested changes to “${event.title}” (${event.when}). The live event stays as it is until someone approves them.`, 'Review the changes'],
23
27
  'staff-moved': [`Date moved: ${event.title}`, `${event.by || 'A host'} moved one date of “${event.title}”: ${event.when}. People who signed up for it were emailed the new time.`, 'Open the event'],
24
28
  approved: [`Approved: ${event.title}`, `Your event “${event.title}” (${event.when}) was approved and is now on the calendar.`, 'See your events'],
25
29
  declined: [`Not approved: ${event.title}`, `Your request for “${event.title}” (${event.when}) wasn’t approved.`, 'See your events'],
30
+ 'staff-move-request': [`Date move to review: ${event.title}`, `${event.by || 'A host'} asked to move one date of “${event.title}”: ${event.when}. Nothing changes until someone approves it.`, 'Review it'],
31
+ 'move-approved': [`Date moved: ${event.title}`, `Your request to move one date of “${event.title}” (${event.when}) was approved. People who signed up were emailed the new time.`, 'See your events'],
32
+ 'move-declined': [`Date not moved: ${event.title}`, `Your request to move one date of “${event.title}” (${event.when}) wasn’t approved. The date stays as it was.`, 'See your events'],
33
+ 'staff-from-date-request': [`Schedule change to review: ${event.title}`, `${event.by || 'A host'} asked to change “${event.title}” from one date on: ${event.when}. Nothing changes until someone applies it.`, 'Review it'],
34
+ 'from-date-approved': [`Schedule changed: ${event.title}`, `Your request to change “${event.title}” from one date on (${event.when}) was applied. People who signed up were emailed their new dates.`, 'See your events'],
35
+ 'from-date-declined': [`Schedule not changed: ${event.title}`, `Your request to change “${event.title}” from one date on (${event.when}) wasn’t approved. The schedule stays as it was.`, 'See your events'],
26
36
  }[kind];
27
37
  const extra = kind.startsWith('staff') ? (event.note ? `Their note: “${event.note}”` : '') : reply ? `From the team: “${reply}”` : '';
28
38
  return { to, subject: `${copy[0]} · ${site}`, text: [copy[0], '', copy[1], ...(extra ? ['', extra] : []), '', `${copy[2]}: ${url}`].join('\n'),