@acms-subir/core 0.3.53 → 0.3.55

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 (62) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/cli/main.mjs +2 -1
  3. package/cli/migrations.generated.mjs +76 -6
  4. package/docs/calendar.md +49 -2
  5. package/docs/google-calendar.md +302 -0
  6. package/docs/server.md +68 -1
  7. package/integration.mjs +15 -5
  8. package/package.json +1 -1
  9. package/src/components/LegalSettings.tsx +1 -1
  10. package/src/lib/api.ts +3 -0
  11. package/src/lib/backup/content.ts +4 -2
  12. package/src/lib/calendar/google/client.ts +167 -0
  13. package/src/lib/calendar/google/connect.ts +161 -0
  14. package/src/lib/calendar/google/context.ts +50 -0
  15. package/src/lib/calendar/google/inbound.ts +493 -0
  16. package/src/lib/calendar/google/links.ts +139 -0
  17. package/src/lib/calendar/google/map.ts +543 -0
  18. package/src/lib/calendar/google/match.ts +103 -0
  19. package/src/lib/calendar/google/migration.ts +71 -0
  20. package/src/lib/calendar/google/outbound.ts +272 -0
  21. package/src/lib/calendar/google/service.ts +193 -0
  22. package/src/lib/calendar/google/store.ts +241 -0
  23. package/src/lib/calendar/google/types.ts +188 -0
  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/calendar/schedule.ts +8 -8
  33. package/src/lib/display/board.ts +3 -1
  34. package/src/lib/email/service.ts +22 -2
  35. package/src/lib/email/templates.ts +20 -2
  36. package/src/lib/legal/admin.ts +1 -1
  37. package/src/lib/legal/facts.ts +3 -0
  38. package/src/lib/legal/text-en.ts +4 -0
  39. package/src/lib/legal/text-es.ts +4 -0
  40. package/src/lib/live/crypto.ts +7 -28
  41. package/src/lib/repo/migrations.ts +8 -0
  42. package/src/lib/repo/sql.ts +25 -9
  43. package/src/lib/repo/types.ts +12 -3
  44. package/src/lib/scheduler.ts +3 -0
  45. package/src/lib/schema.ts +47 -0
  46. package/src/lib/sealed.ts +46 -0
  47. package/src/pages/admin/calendar/events/[id].astro +42 -1
  48. package/src/pages/admin/calendar/events/index.astro +82 -6
  49. package/src/pages/admin/calendar/google/match.astro +160 -0
  50. package/src/pages/admin/calendar/google.astro +107 -0
  51. package/src/pages/admin/calendar/settings.astro +273 -4
  52. package/src/pages/admin/index.astro +6 -2
  53. package/src/pages/admin/integrations.astro +6 -0
  54. package/src/pages/api/calendar/google/oauth/callback.ts +16 -0
  55. package/src/pages/members/_EventForm.astro +33 -27
  56. package/src/pages/members/_Layout.astro +25 -5
  57. package/src/pages/members/_http.ts +29 -6
  58. package/src/pages/members/events/[id].astro +21 -20
  59. package/src/pages/members/events/index.astro +295 -101
  60. package/src/pages/members/events/new.astro +11 -10
  61. package/src/server/index.ts +102 -0
  62. package/src/virtual.d.ts +1 -1
@@ -0,0 +1,247 @@
1
+ // Hosts moving single dates of the events they host (C3), on top of
2
+ // lib/calendar/moves.ts. acms({ members: { hostMoves } }) decides how:
3
+ // - 'direct' (default): the move applies at once while the date's rooms are
4
+ // free, and staff get the "Date moved" email. A taken room refuses it
5
+ // (409 room-taken); the host can send that same move as a request instead;
6
+ // - 'approve': every host move, and every move back, is a request.
7
+ // Requests are rows in event_requests (repo.requests, RequestRecord kind
8
+ // 'move', data: MoveRequestData) that staff who can publish approve (the move
9
+ // runs as them, recorded as the host's) or decline with a note.
10
+ //
11
+ // Like requests.ts, everything checks who is acting, so the built-in My
12
+ // events page and a site's own pages share the rules. Hosts need no
13
+ // permission beyond being in hostIds; staff ("Create and edit content") who
14
+ // host an event always move at once.
15
+ import type { ContentRepository } from '../repo/types';
16
+ import type { CalEvent, RequestRecord } from '../schema';
17
+ import { ApiError, coded } from '../api';
18
+ import type { SaveHooks } from '../content-service';
19
+ import { GOOGLE_SYNC, checkMove, moveOccurrence, moveOccurrenceBack, type MoveInput, type MoveResult } from './moves';
20
+ import { eventTimeZone, findOccurrence, type Occurrence } from './events';
21
+ import { MEMBERS } from './members-config';
22
+ import { siteLocale } from '../dates';
23
+ import { kickEmail } from '../email/service';
24
+ import { waitUntilFrom } from '../webhooks';
25
+ import type { BusySlot } from '../booking/holds';
26
+
27
+ /** who is acting: currentUser() from @acms-subir/core/server fits */
28
+ export interface HostActor { id: string; can(permission: string): boolean }
29
+
30
+ /** RequestRecord.data of a 'move' request: the move to make (MoveInput), or `back` to put the date back */
31
+ export interface MoveRequestData {
32
+ start: string;
33
+ end: string;
34
+ roomIds?: string[];
35
+ /** the public note shown with the moved date */
36
+ note: string;
37
+ /** put a moved date back where the series has it (start and end are '') */
38
+ back?: boolean;
39
+ }
40
+
41
+ /** What a host's move did: moved at once (`moved`), or became a request (`request`). */
42
+ export type HostMoveResult = { moved: MoveResult; request?: undefined } | { request: RequestRecord; moved?: undefined };
43
+
44
+ const DAY = 86_400_000;
45
+ const LIMITS = { note: 1000, pending: 20 };
46
+ const text = (v: unknown, max: number) => String(v ?? '').replace(/\r\n?/g, '\n').replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, '').trim().slice(0, max);
47
+
48
+ const staff = (who: HostActor) => who.can('content.edit');
49
+
50
+ async function hosted(repo: ContentRepository, who: HostActor | null, id: string): Promise<CalEvent> {
51
+ if (!who) throw coded(401, 'sign-in', 'Sign in to continue.');
52
+ const e = await repo.getEvent(id);
53
+ if (!e || e.status === 'archived' || (!e.hostIds.includes(who.id) && !staff(who))) throw coded(404, 'not-found', 'Event not found.');
54
+ return e;
55
+ }
56
+
57
+ /**
58
+ * A host moves one date of an event they host. With hostMoves 'direct' (or
59
+ * when they're staff) it applies at once and refuses a taken room (409,
60
+ * code room-taken); with 'approve' it's sent to staff as a request.
61
+ */
62
+ export async function moveHostedDate(repo: ContentRepository, who: HostActor | null, id: string, key: string, input: MoveInput, hooks: SaveHooks): Promise<HostMoveResult> {
63
+ await hosted(repo, who, id);
64
+ if (MEMBERS.hostMoves === 'approve' && !staff(who!)) return { request: await requestDateMove(repo, who, id, key, input, '', hooks) };
65
+ const moved = await moveOccurrence(repo, who, id, key, input, hooks);
66
+ await settleWaiting(repo, who!, id, key);
67
+ return { moved };
68
+ }
69
+
70
+ // a move the host made at once replaces any move of that date they asked staff for
71
+ async function settleWaiting(repo: ContentRepository, who: HostActor, eventId: string, key: string) {
72
+ for (const r of await repo.requests.list({ eventId, userId: who.id, kinds: ['move'], states: ['pending'], limit: -1 })) {
73
+ if (r.key === key) await repo.requests.update(r.id, { state: 'withdrawn', reviewedAt: Date.now() }, { state: 'pending' });
74
+ }
75
+ }
76
+
77
+ /** A host puts back a date they moved: at once ('direct') or as a request ('approve'). */
78
+ export async function moveHostedDateBack(repo: ContentRepository, who: HostActor | null, id: string, key: string, hooks: SaveHooks): Promise<HostMoveResult> {
79
+ await hosted(repo, who, id);
80
+ if (MEMBERS.hostMoves === 'approve' && !staff(who!)) return { request: await requestDateMove(repo, who, id, key, null, '', hooks) };
81
+ const moved = await moveOccurrenceBack(repo, who, id, key, hooks);
82
+ await settleWaiting(repo, who!, id, key);
83
+ return { moved };
84
+ }
85
+
86
+ /**
87
+ * A host asks staff to move one date (`input`), or to put it back (null):
88
+ * checked like the move itself, then saved as a pending request. A taken
89
+ * room is fine here: staff decide. Asking again about the same date replaces
90
+ * the waiting request. `message` is for staff only.
91
+ */
92
+ export async function requestDateMove(repo: ContentRepository, who: HostActor | null, id: string, key: string, input: MoveInput | null, message: string, hooks: SaveHooks): Promise<RequestRecord> {
93
+ const e = await hosted(repo, who, id);
94
+ if (!e.hostIds.includes(who!.id)) throw coded(404, 'not-found', 'Event not found.');
95
+ // as the host: the same refusals a move gets, except the taken room (a request carries no place of its own)
96
+ const check = await checkMove(repo, who, id, key, input ? { ...input, location: undefined } : null);
97
+ if (!check.changed) throw coded(400, 'unchanged', input ? 'That date is already there.' : 'That date isn’t moved.');
98
+ 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);
99
+ // moving it to where the series has it is asking to move it back
100
+ const data: MoveRequestData = input && !check.back
101
+ ? { start: check.start!, end: check.end!, ...(input.roomIds !== undefined ? { roomIds: [...new Set(input.roomIds)] } : {}), note: check.note ?? '' }
102
+ : { start: '', end: '', note: '', back: true };
103
+ const note = text(message, LIMITS.note);
104
+ const waiting = (await repo.requests.list({ eventId: id, userId: who!.id, kinds: ['move'], states: ['pending'], limit: -1 })).find((r) => r.key === key);
105
+ let saved: RequestRecord | null;
106
+ if (waiting) saved = await repo.requests.update(waiting.id, { data: { ...data }, note }, { state: 'pending' });
107
+ else {
108
+ if (await repo.requests.count({ userId: who!.id, states: ['pending'] }) >= LIMITS.pending) throw coded(429, 'too-many-waiting', 'You have many requests waiting already. Wait for the team to answer them first.');
109
+ const now = Date.now();
110
+ saved = await repo.requests.create({ id: crypto.randomUUID(), kind: 'move', state: 'pending', source: 'member', userId: who!.id, requester: null,
111
+ eventId: id, key, data: { ...data }, note, reply: '', reviewedBy: '', reviewedAt: 0, createdAt: now, updatedAt: now });
112
+ }
113
+ if (!saved) throw coded(409, 'answered', 'That request was just answered. Look at My events again.');
114
+ await alertStaff(repo, saved, hooks);
115
+ return saved;
116
+ }
117
+
118
+ /** A host's own move requests (all states, newest first): `eventId` narrows to one event. */
119
+ export async function myDateRequests(repo: ContentRepository, userId: string, eventId?: string): Promise<RequestRecord[]> {
120
+ return repo.requests.list({ userId, kinds: ['move', 'from-date'], ...(eventId ? { eventId } : {}), limit: 200 });
121
+ }
122
+
123
+ /** A host takes back a waiting request of theirs. */
124
+ export async function withdrawDateRequest(repo: ContentRepository, who: HostActor | null, requestId: string): Promise<void> {
125
+ if (!who) throw coded(401, 'sign-in', 'Sign in to continue.');
126
+ const r = await repo.requests.get(requestId);
127
+ if (!r || r.userId !== who.id || (r.kind !== 'move' && r.kind !== 'from-date')) throw coded(404, 'request-not-found', 'Request not found.');
128
+ if (r.state !== 'pending') return;
129
+ await repo.requests.update(requestId, { state: 'withdrawn', reviewedAt: Date.now() }, { state: 'pending' });
130
+ }
131
+
132
+ /** A host clears an answered request from My events (it's deleted with the others later anyway). */
133
+ export async function dismissDateRequest(repo: ContentRepository, who: HostActor | null, requestId: string): Promise<void> {
134
+ if (!who) throw coded(401, 'sign-in', 'Sign in to continue.');
135
+ const r = await repo.requests.get(requestId);
136
+ if (!r || r.userId !== who.id || (r.kind !== 'move' && r.kind !== 'from-date')) throw coded(404, 'request-not-found', 'Request not found.');
137
+ if (r.state === 'declined' || r.state === 'approved') await repo.requests.delete(requestId);
138
+ }
139
+
140
+ /** What else takes the rooms of a waiting move request at its new time (names included: staff only). */
141
+ export async function dateRequestClashes(repo: ContentRepository, r: RequestRecord): Promise<BusySlot[]> {
142
+ if (r.kind !== 'move' || r.state !== 'pending') return [];
143
+ const d = r.data as MoveRequestData;
144
+ try {
145
+ return (await checkMove(repo, GOOGLE_SYNC, r.eventId, r.key, d.back ? null : { start: d.start, end: d.end, roomIds: d.roomIds, note: d.note })).clashes;
146
+ } catch { return []; }
147
+ }
148
+
149
+ // ---- answering (staff who can publish) ---------------------------------------
150
+
151
+ function requireApprover(who: HostActor | null): asserts who is HostActor {
152
+ if (!who) throw coded(401, 'sign-in', 'Sign in to continue.');
153
+ if (!who.can('content.publish')) throw new ApiError(403, 'Your role doesn’t include “Publish content”, which approving requests needs.');
154
+ }
155
+
156
+ async function answerable(repo: ContentRepository, id: string, seen?: number): Promise<RequestRecord> {
157
+ const r = await repo.requests.get(id);
158
+ if (!r || (r.kind !== 'move' && r.kind !== 'from-date')) throw coded(404, 'request-not-found', 'Request not found.');
159
+ if (r.state !== 'pending') throw coded(409, 'answered', 'This request was already answered or withdrawn.');
160
+ if (seen !== undefined && r.updatedAt !== seen) throw coded(409, 'changed', 'This request changed after you opened it. Look at it again, then answer.');
161
+ return r;
162
+ }
163
+
164
+ /** Staff approve a host's move request: the move runs as them (room clashes warn), recorded as the host's, and the host is emailed. Pass `seen` (the request's updatedAt they reviewed). */
165
+ export async function approveDateRequest(repo: ContentRepository, who: HostActor | null, id: string, hooks: SaveHooks, seen?: number): Promise<{ request: RequestRecord; moved: MoveResult }> {
166
+ requireApprover(who);
167
+ const r = await answerable(repo, id, seen);
168
+ if (r.kind !== 'move') throw coded(400, 'unavailable', 'Open the event to apply this change.');
169
+ const d = r.data as MoveRequestData;
170
+ // worded before it moves: "where it was → where it goes"
171
+ const when = await dateRequestWhen(repo, r);
172
+ const request = await claim(repo, r, who);
173
+ let moved: MoveResult;
174
+ try {
175
+ moved = d.back
176
+ ? await moveOccurrenceBack(repo, who, r.eventId, r.key, hooks)
177
+ : await moveOccurrence(repo, who, r.eventId, r.key, { start: d.start, end: d.end, roomIds: d.roomIds, note: d.note }, hooks, { by: r.userId });
178
+ } catch (err) { await release(repo, id); throw err; }
179
+ await tellHost(repo, request, 'move-approved', '', when, hooks);
180
+ return { request, moved };
181
+ }
182
+
183
+ // Approving claims the request first (only as staff saw it, and only once),
184
+ // so two approvals at once, or a host's edit meanwhile, can't both go
185
+ // through; a change that then fails puts it back to waiting.
186
+ async function claim(repo: ContentRepository, r: RequestRecord, who: HostActor): Promise<RequestRecord> {
187
+ const claimed = await repo.requests.update(r.id, { state: 'approved', reviewedBy: who.id, reviewedAt: Date.now() }, { state: 'pending', updatedAt: r.updatedAt });
188
+ if (!claimed) throw coded(409, 'changed', 'This request was just answered or changed. Look at it again, then answer.');
189
+ return claimed;
190
+ }
191
+ const release = (repo: ContentRepository, id: string) => repo.requests.update(id, { state: 'pending', reviewedBy: '', reviewedAt: 0 }, { state: 'approved' });
192
+
193
+ /** Staff decline a host's request, with an optional note the host reads. */
194
+ export async function declineDateRequest(repo: ContentRepository, who: HostActor | null, id: string, reply: string, hooks: SaveHooks, seen?: number): Promise<RequestRecord> {
195
+ requireApprover(who);
196
+ const r = await answerable(repo, id, seen);
197
+ const answer = text(reply, LIMITS.note);
198
+ const request = await repo.requests.update(id, { state: 'declined', reply: answer, reviewedBy: who.id, reviewedAt: Date.now() }, { state: 'pending', updatedAt: r.updatedAt });
199
+ if (!request) throw coded(409, 'changed', 'This request changed after you opened it. Look at it again, then answer.');
200
+ await tellHost(repo, request, r.kind === 'move' ? 'move-declined' : 'from-date-declined', answer, await dateRequestWhen(repo, r), hooks);
201
+ return request;
202
+ }
203
+
204
+ // ---- email ------------------------------------------------------------
205
+
206
+ /** Where a move request's date is now and where it would go ("Thu, Jan 10, 6:00 PM", "Wed, Jan 9, 5:00 PM"), in the site's language; '' when it isn't a date any more. */
207
+ export async function dateRequestTimes(repo: ContentRepository, r: RequestRecord): Promise<{ from: string; to: string }> {
208
+ const e = await repo.getEvent(r.eventId);
209
+ if (!e) return { from: '', to: '' };
210
+ const settings = await repo.getSettings();
211
+ const d = r.data as MoveRequestData;
212
+ const tz = e.allDay ? settings.timezone || 'UTC' : eventTimeZone(e, settings);
213
+ const fmt = (o: Occurrence | null) => {
214
+ if (!o) return '';
215
+ try { return new Intl.DateTimeFormat(siteLocale(settings), e.allDay ? { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz } : { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: tz }).format(o.start); }
216
+ catch { return new Date(o.start).toISOString(); }
217
+ };
218
+ const to = d.back ? findOccurrence({ ...e, movedDates: {} }, r.key, settings)
219
+ : findOccurrence({ ...e, movedDates: { ...e.movedDates, [r.key]: { start: d.start, end: d.end, note: d.note, by: '', at: 0, ...(d.roomIds ? { roomIds: d.roomIds } : {}) } } }, r.key, settings);
220
+ return { from: fmt(findOccurrence(e, r.key, settings)), to: fmt(to) };
221
+ }
222
+
223
+ /** "Thu, Jan 10, 6:00 PM → Wed, Jan 9, 5:00 PM" for a move request (dateRequestTimes) */
224
+ export async function dateRequestWhen(repo: ContentRepository, r: RequestRecord): Promise<string> {
225
+ const t = await dateRequestTimes(repo, r);
226
+ return t.from && t.to ? `${t.from} → ${t.to}` : t.from || t.to;
227
+ }
228
+
229
+ // staff: "Date move to review" (members.notify, else the owners)
230
+ async function alertStaff(repo: ContentRepository, r: RequestRecord, hooks: SaveHooks) {
231
+ const to = MEMBERS.notify.length ? MEMBERS.notify : (await repo.listUsers()).filter((u) => u.role === 'owner' && u.email).map((u) => u.email);
232
+ await queue(repo, r, to, r.kind === 'move' ? 'staff-move-request' : 'staff-from-date-request', '', await dateRequestWhen(repo, r), hooks);
233
+ }
234
+ async function tellHost(repo: ContentRepository, r: RequestRecord, kind: string, reply: string, when: string, hooks: SaveHooks) {
235
+ const host = r.userId ? await repo.getUser(r.userId) : null;
236
+ if (host?.email) await queue(repo, r, [host.email], kind, reply, when, hooks);
237
+ }
238
+ // `when` is worded now: by the time it's sent the date may have moved
239
+ async function queue(repo: ContentRepository, r: RequestRecord, to: string[], kind: string, reply: string, when: string, hooks: SaveHooks) {
240
+ if (!to.length) return;
241
+ const version = (await repo.email.config().catch(() => null))?.version ?? 0;
242
+ for (const recipient of to) {
243
+ await repo.email.enqueue({ dedupe: `date-request:${r.id}:${r.updatedAt}:${kind}:${recipient}`, kind: 'event-request', recipient,
244
+ payload: JSON.stringify({ eventId: r.eventId, requestId: r.id, kind, at: r.updatedAt, reply, when }), configVersion: version, expiresAt: Date.now() + 7 * DAY });
245
+ }
246
+ await kickEmail(repo, hooks.locals, waitUntilFrom(hooks.locals)).catch(() => {});
247
+ }
@@ -1,7 +1,8 @@
1
- import type { CalEvent, EventRequest } from '../schema';
1
+ import type { CalEvent, EventRequest, RequestRecord } from '../schema';
2
2
  import type { EventRequestInput } from './requests';
3
3
  import { CALENDAR, type EventFieldConfig } from './config';
4
4
  import { describeRepeat } from './rrule';
5
+ import { memberStrings, type MemberStrings } from './member-words';
5
6
 
6
7
  // The event request form on the built-in "My events" pages, as pure
7
8
  // functions: the HTML form's fields → EventRequestInput (requests.ts checks
@@ -30,7 +31,7 @@ export interface MemberEventForm {
30
31
  note: string;
31
32
  }
32
33
 
33
- /** the request form's "Repeats" choices */
34
+ /** the request form's "Repeats" choices (labels in English; repeatChoices words them in the page's language) */
34
35
  export const REPEAT_CHOICES: readonly { value: string; label: string }[] = [
35
36
  { value: '', label: 'Does not repeat' },
36
37
  { value: 'FREQ=DAILY', label: 'Every day' },
@@ -38,6 +39,7 @@ export const REPEAT_CHOICES: readonly { value: string; label: string }[] = [
38
39
  { value: 'FREQ=WEEKLY;INTERVAL=2', label: 'Every 2 weeks' },
39
40
  { value: 'FREQ=MONTHLY', label: 'Every month' },
40
41
  ];
42
+ const CHOICE_WORD = ['none', 'daily', 'weekly', 'biweekly', 'monthly'] as const;
41
43
 
42
44
  /** the patterns requests.ts accepts from members (kept in step with its REPEAT) */
43
45
  const MEMBER_REPEAT = /^FREQ=(DAILY|WEEKLY|MONTHLY)(;INTERVAL=[1-9]\d?)?(;COUNT=[1-9]\d{0,2})?$/;
@@ -134,19 +136,23 @@ export function eventFormValues(e: FormEvent, opts: { description: string; calen
134
136
  /**
135
137
  * The "Repeats" select for a value: the standard choices, plus the event's
136
138
  * own pattern (e.g. every 3 weeks, or one only the admin can set, like weekly
137
- * on two days), selected, so sending the form unchanged keeps it.
139
+ * on two days), selected, so sending the form unchanged keeps it. `lang`
140
+ * words them (memberStrings; English by default).
138
141
  */
139
- export function repeatChoices(current: string, start: string): { options: { value: string; label: string }[]; value: string; note: string } {
142
+ export function repeatChoices(current: string, start: string, lang?: string): { options: { value: string; label: string }[]; value: string; note: string } {
143
+ const w = memberStrings(lang).form;
140
144
  const rule = current.trim().replace(/^RRULE:/i, '').toUpperCase();
141
- const options = [...REPEAT_CHOICES];
145
+ const options = REPEAT_CHOICES.map((o, i) => ({ value: o.value, label: w.repeat[CHOICE_WORD[i]] }));
142
146
  if (!rule || options.some((o) => o.value === rule)) return { options, value: rule, note: '' };
143
- if (MEMBER_REPEAT.test(rule)) return { options: [...options, { value: rule, label: describeRepeat(rule, start) }], value: rule, note: '' };
147
+ if (MEMBER_REPEAT.test(rule)) return { options: [...options, { value: rule, label: describeRepeat(rule, start, undefined, lang) }], value: rule, note: '' };
144
148
  // set up by the team: members can keep it (requests.ts accepts an unchanged rule) or pick another
145
- return { options: [...options, { value: rule, label: `${describeRepeat(rule, start)} (as set by the team)` }], value: rule, note: '' };
149
+ return { options: [...options, { value: rule, label: w.asSetByTeam.replace('{repeat}', describeRepeat(rule, start, undefined, lang)) }], value: rule, note: '' };
146
150
  }
147
151
 
148
- /** how a request reads on "My events" */
149
- export function requestLabel(r: Pick<EventRequest, 'kind' | 'state'>): string {
150
- if (r.kind === 'changes') return r.state === 'pending' ? 'Changes waiting for approval' : 'Changes declined';
151
- return r.state === 'pending' ? 'Waiting for approval' : 'Declined';
152
+ /** how a request reads on "My events": a member's request on its event (EventRequest), or a host's date request (RequestRecord 'move' / 'from-date') */
153
+ export function requestLabel(r: Pick<EventRequest, 'kind' | 'state'> | Pick<RequestRecord, 'kind' | 'state'>, w: MemberStrings = memberStrings('en')): string {
154
+ if (r.kind === 'changes') return r.state === 'pending' ? w.changesWaiting : w.changesDeclined;
155
+ if (r.kind === 'move') return r.state === 'pending' ? w.moveWaiting : r.state === 'approved' ? w.moveApproved : w.moveDeclined;
156
+ if (r.kind === 'from-date') return r.state === 'pending' ? w.fromDateWaiting : r.state === 'approved' ? w.fromDateApproved : w.fromDateDeclined;
157
+ return r.state === 'pending' ? w.waiting : w.declined;
152
158
  }