@acms-subir/core 0.3.55 → 0.3.57

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 (73) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/cli/migrations.generated.mjs +16 -4
  3. package/docs/calendar.md +189 -2
  4. package/docs/mobile.md +3 -1
  5. package/docs/server.md +84 -1
  6. package/integration.mjs +34 -0
  7. package/mobile-template/client/AGENTS.md +1 -0
  8. package/mobile-template/client/README.md +1 -1
  9. package/mobile-template/recipes/event-request/files/app/request.tsx +305 -0
  10. package/mobile-template/recipes/event-request/files/components/DayPicker.tsx +66 -0
  11. package/mobile-template/recipes/event-request/files/components/RequestEventLink.tsx +19 -0
  12. package/mobile-template/recipes/event-request/files/components/RequestEventRow.tsx +20 -0
  13. package/mobile-template/recipes/event-request/recipe.json +21 -0
  14. package/package.json +1 -1
  15. package/src/actions/index.ts +52 -9
  16. package/src/components/EventPage.astro +6 -5
  17. package/src/components/MobileApp.tsx +2 -2
  18. package/src/components/calendar/CalendarBoard.tsx +6 -3
  19. package/src/components/editor/event/SingleDates.tsx +87 -17
  20. package/src/layouts/Admin.astro +7 -0
  21. package/src/lib/calendar/api.ts +23 -2
  22. package/src/lib/calendar/board.ts +3 -2
  23. package/src/lib/calendar/cards.ts +4 -4
  24. package/src/lib/calendar/change-migration.ts +17 -3
  25. package/src/lib/calendar/data.ts +9 -2
  26. package/src/lib/calendar/event-words.ts +8 -0
  27. package/src/lib/calendar/events.ts +128 -15
  28. package/src/lib/calendar/google/map.ts +1 -1
  29. package/src/lib/calendar/host-requests.ts +65 -10
  30. package/src/lib/calendar/ics-write.ts +65 -15
  31. package/src/lib/calendar/member-words.ts +14 -1
  32. package/src/lib/calendar/moves.ts +15 -9
  33. package/src/lib/calendar/request-admin.ts +212 -0
  34. package/src/lib/calendar/request-config.ts +8 -0
  35. package/src/lib/calendar/request-mail.ts +158 -0
  36. package/src/lib/calendar/request-page.ts +52 -0
  37. package/src/lib/calendar/request-service.ts +535 -0
  38. package/src/lib/calendar/request-store.ts +2 -2
  39. package/src/lib/calendar/request-words.ts +263 -0
  40. package/src/lib/calendar/schedule.ts +42 -8
  41. package/src/lib/calendar/segments.ts +522 -0
  42. package/src/lib/editor/schedule.ts +2 -2
  43. package/src/lib/email/service.ts +6 -1
  44. package/src/lib/legal/app-privacy.ts +2 -2
  45. package/src/lib/legal/facts.ts +4 -0
  46. package/src/lib/legal/text-en.ts +3 -0
  47. package/src/lib/legal/text-es.ts +3 -0
  48. package/src/lib/mobile/calendar.ts +1 -0
  49. package/src/lib/mobile/http.ts +2 -1
  50. package/src/lib/mobile/openapi.ts +8 -0
  51. package/src/lib/mobile/requests.ts +27 -0
  52. package/src/lib/mobile/schemas.ts +24 -6
  53. package/src/lib/mobile/site.ts +3 -0
  54. package/src/lib/push/service.ts +42 -10
  55. package/src/lib/push/types.ts +3 -1
  56. package/src/lib/repo/migrations.ts +5 -2
  57. package/src/lib/repo/sql.ts +24 -8
  58. package/src/lib/repo/types.ts +3 -1
  59. package/src/lib/scheduler.ts +3 -0
  60. package/src/lib/schema.ts +67 -2
  61. package/src/lib/sections.ts +1 -1
  62. package/src/lib/sync/catalog.ts +1 -0
  63. package/src/pages/admin/calendar/events/index.astro +11 -8
  64. package/src/pages/admin/calendar/requests.astro +298 -0
  65. package/src/pages/api/calendar/events/index.ts +2 -1
  66. package/src/pages/api/v1/requests/index.ts +6 -0
  67. package/src/pages/api/v1/requests/options.ts +7 -0
  68. package/src/pages/members/events/index.astro +84 -6
  69. package/src/pages/request/index.astro +182 -0
  70. package/src/server/index.ts +102 -4
  71. package/src/styles/editor.css +2 -0
  72. package/src/styles/site.css +29 -0
  73. package/src/virtual.d.ts +1 -0
@@ -20,7 +20,7 @@ import type { ContentRepository } from '../repo/types';
20
20
  import { EventLocation, type CalEvent, type MovedDate } from '../schema';
21
21
  import { ApiError, coded } from '../api';
22
22
  import { eventSideEffects, type SaveHooks } from '../content-service';
23
- import { addWallDays, addWallMinutes, eventRoomIds, 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, partOf, partMinutes, type SeriesPart } 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';
@@ -103,14 +103,14 @@ const started = () => refuse(400, 'started', 'That date has already started or p
103
103
 
104
104
  // The new wall times: `start` in the event's form, `end` defaulting to the
105
105
  // date's own length (all-day: the same number of days)
106
- function newTimes(e: CalEvent, input: MoveInput): { start: string; end: string } {
106
+ function newTimes(e: CalEvent, input: MoveInput, part: SeriesPart): { start: string; end: string } {
107
107
  const s = parseWall(input.start);
108
108
  if (!s) throw refuse(400, 'bad-time', e.allDay ? 'Pick a date (YYYY-MM-DD).' : 'Pick a date and time (YYYY-MM-DDTHH:mm).');
109
109
  if (s.dateOnly !== e.allDay) throw refuse(400, 'bad-time', e.allDay ? 'This event lasts all day: give a date only (YYYY-MM-DD).' : 'Give a date and a time (YYYY-MM-DDTHH:mm).');
110
110
  let end = input.end;
111
111
  if (!end) {
112
- const es = parseWall(e.start), ee = parseWall(e.end) ?? es;
113
- const length = es && ee ? wallMinutesBetween(es, ee) : 60;
112
+ // the length of its part's dates (a change from a date on may have its own)
113
+ const length = partMinutes(part);
114
114
  end = formatWall(e.allDay ? addWallDays(s, Math.round(length / 1440)) : addWallMinutes(s, Math.max(1, length)));
115
115
  }
116
116
  try {
@@ -162,7 +162,9 @@ async function planMove(repo: ContentRepository, actor: MoveActor | null, id: st
162
162
  const settings = await repo.getSettings();
163
163
  const current = dateToChange(e, key, settings);
164
164
  const prior = e.movedDates[key];
165
- const times = newTimes(e, input);
165
+ // its part of the series (CalEvent.segments): the length, rooms and place it has without a move
166
+ const part = partOf(e, current);
167
+ const times = newTimes(e, input, part);
166
168
 
167
169
  let roomIds = input.roomIds === undefined ? prior?.roomIds : [...new Set(input.roomIds)];
168
170
  if (roomIds !== undefined) {
@@ -175,11 +177,11 @@ async function planMove(repo: ContentRepository, actor: MoveActor | null, id: st
175
177
  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
178
  }
177
179
  // the event's own rooms aren't an override
178
- if (sameRooms(roomIds, e.roomHold.resourceIds)) roomIds = undefined;
180
+ if (sameRooms(roomIds, part.roomIds)) roomIds = undefined;
179
181
  }
180
182
  let location = input.location === undefined ? prior?.location : input.location === null ? undefined : EventLocation.parse({ ...e.location, ...input.location });
181
183
  if (location && !location.name.trim() && !location.address.trim()) throw new ApiError(400, 'Give the place a name or an address.');
182
- if (location && JSON.stringify(location) === JSON.stringify(e.location)) location = undefined;
184
+ if (location && JSON.stringify(location) === JSON.stringify(part.location ?? e.location)) location = undefined;
183
185
  const note = input.note === undefined ? prior?.note ?? '' : String(input.note).replace(/\s+/g, ' ').trim().slice(0, NOTE_MAX);
184
186
 
185
187
  const entry: MovedDate = { start: times.start, end: times.end, ...(roomIds ? { roomIds } : {}), ...(location ? { location } : {}), note, by: by || actorId(actor!), at: now };
@@ -306,6 +308,8 @@ export interface DateRow {
306
308
  /** "Thu, Jan 10 · 6:00 – 7:00 PM" */
307
309
  when: string;
308
310
  cancelled: boolean;
311
+ /** cancelled because every date from some date on is (a cancelled part, CalEvent.segments) */
312
+ partCancelled: boolean;
309
313
  /** moved: "Thu, Jan 10 · 6:00 PM" (where it was) and the note */
310
314
  moved: { from: string; note: string } | null;
311
315
  roomIds: string[];
@@ -319,19 +323,21 @@ export function upcomingDates(e: CalEvent, settings: Awaited<ReturnType<ContentR
319
323
  const day = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
320
324
  const time = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
321
325
  const range = (a: number, b: number) => { try { return time.formatRange(a, b); } catch { return `${time.format(a)} – ${time.format(b)}`; } };
322
- const es = parseWall(e.start), ee = parseWall(e.end) ?? es;
323
- const length = es && ee ? wallMinutesBetween(es, ee) : 60;
324
326
  return expandEvents([e], settings, { displayTz: settings.timezone || 'UTC', now, windowStartMs: now, windowEndMs: now + 400 * DAY })
325
327
  .slice(0, limit)
326
328
  .map((o) => {
327
329
  const m = o.moved ? e.movedDates[o.key] : undefined;
328
330
  const k = parseWall(o.key)!;
331
+ const part = partOf(e, o);
332
+ const length = partMinutes(part);
329
333
  return {
330
334
  key: o.key,
331
335
  start: m?.start ?? o.key,
332
336
  end: m?.end ?? formatWall(e.allDay ? addWallDays(k, Math.round(length / 1440)) : addWallMinutes(k, length)),
333
337
  when: e.allDay ? day.format(o.start) : `${day.format(o.start)} · ${range(o.start, o.end)}`,
334
338
  cancelled: o.cancelled,
339
+ // cancelled with the rest of its part (cancelFromDate): restored from the change, not one by one
340
+ partCancelled: part.cancelled,
335
341
  moved: o.moved ? { from: e.allDay ? day.format(o.moved.from.start) : `${day.format(o.moved.from.start)} · ${time.format(o.moved.from.start)}`, note: o.moved.note } : null,
336
342
  roomIds: o.roomIds,
337
343
  };
@@ -0,0 +1,212 @@
1
+ import type { ContentRepository } from '../repo/types';
2
+ import { RequestSettings, slugify, type CalEvent, type RequestRecord, type Settings } from '../schema';
3
+ import { ApiError } from '../api';
4
+ import { validAddress } from '../email/config';
5
+ import { extraLocales, localeName } from '../i18n';
6
+ import { siteLocale } from '../dates';
7
+ import { mergeEventDraft } from '../drafts';
8
+ import { eventClashes } from '../booking/holds';
9
+ import { featuredOccurrence, formatOccurrence } from './events';
10
+ import { requestWhen } from './request-mail';
11
+ import { requestClashes, requestSettings, type NewEventDetails } from './request-service';
12
+ import { pendingRequests } from './requests';
13
+ import { dateRequestClashes, dateRequestWhen } from './host-requests';
14
+ import { MEMBERS } from './members-config';
15
+
16
+ // Calendar → Requests (pages/admin/calendar/requests.astro): one list of
17
+ // requests from the app, the website and hosts (rows of their own, plus
18
+ // members' requests staged on events), each with what takes its rooms then,
19
+ // and the settings form. Staff see who asked; the public never does.
20
+
21
+ export type RequestTab = 'pending' | 'approved' | 'declined';
22
+
23
+ export interface RequestRow {
24
+ /** 'r:<id>' for request rows, 'e:<eventId>' for members' requests on an event */
25
+ key: string;
26
+ /** the same as an HTML id ('r-<id>', 'e-<eventId>'): a request's id is the sender's key, so it can't share a namespace with event ids */
27
+ dom: string;
28
+ id: string;
29
+ kind: RequestRecord['kind'] | 'member-new' | 'member-changes';
30
+ /** "New event · from the app · 2h ago" */
31
+ head: string;
32
+ title: string;
33
+ line: string;
34
+ who: string;
35
+ /** where Reply goes ('' = nowhere to write) */
36
+ email: string;
37
+ note: string;
38
+ /** "Sanctuary is free then" / "Sanctuary on Wed, Dec 2: Advent rehearsal 4:30 – 6:00 PM" */
39
+ clash: { text: string; taken: boolean } | null;
40
+ /** staff's answer, for decided ones: "Approved by Ana · 2d ago" and their words */
41
+ answer: string;
42
+ reply: string;
43
+ /** the event it's about, or the draft it became */
44
+ href: string;
45
+ /** the version staff are looking at (the `seen` guard) */
46
+ at: number;
47
+ canReply: boolean;
48
+ }
49
+
50
+ const ago = (now: number, ts: number) => {
51
+ const s = Math.max(0, Math.floor((now - ts) / 1000));
52
+ if (s < 60) return 'just now';
53
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
54
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
55
+ return `${Math.floor(s / 86400)}d ago`;
56
+ };
57
+ const SOURCE = { app: 'from the app', web: 'from the website', member: 'from a host' } as const;
58
+ const KIND = { new: 'New event', change: 'Change', move: 'Move a date', 'from-date': 'Change from a date on' } as const;
59
+
60
+ function fmt(settings: Settings) {
61
+ const tz = settings.timezone || 'UTC', locale = siteLocale(settings);
62
+ const f = (o: Intl.DateTimeFormatOptions) => {
63
+ try { return new Intl.DateTimeFormat(locale, { ...o, timeZone: tz }); }
64
+ catch { return new Intl.DateTimeFormat('en-US', { ...o, timeZone: 'UTC' }); }
65
+ };
66
+ const day = f({ weekday: 'short', month: 'short', day: 'numeric' });
67
+ const time = f({ hour: 'numeric', minute: '2-digit' });
68
+ return { locale, tz, slot: (s: number, e: number) => `${day.format(s)}: ${time.formatRange(s, e)}`, key: (k: string) => {
69
+ try { return new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' }).format(new Date(`${k.slice(0, 10)}T12:00:00Z`)); }
70
+ catch { return k.slice(0, 10); }
71
+ } };
72
+ }
73
+
74
+ export const ROWS_PER_PAGE = 50;
75
+
76
+ /**
77
+ * One page of a tab (ROWS_PER_PAGE), newest answers first; pending ones oldest
78
+ * first (first come, first answered). `more`: there's a next page. Members'
79
+ * requests come on the first page.
80
+ */
81
+ export async function requestRows(repo: ContentRepository, settings: Settings, tab: RequestTab, now = Date.now(), page = 1): Promise<{ rows: RequestRow[]; more: boolean }> {
82
+ const f = fmt(settings);
83
+ const [found, users, rooms, events] = await Promise.all([
84
+ repo.requests.list({ states: tab === 'pending' ? ['pending'] : [tab], order: tab === 'pending' ? 'oldest' : 'decided', limit: ROWS_PER_PAGE + 1, offset: (page - 1) * ROWS_PER_PAGE }),
85
+ repo.listUsers(), repo.listResources(), repo.listEvents(),
86
+ ]);
87
+ const userName = new Map(users.map((u) => [u.id, u.name || u.email]));
88
+ const roomName = new Map(rooms.map((r) => [r.id, r.name]));
89
+ const eventById = new Map(events.map((e) => [e.id, e]));
90
+ const types = requestSettings(settings).types;
91
+ const records = found.slice(0, ROWS_PER_PAGE);
92
+ const rows: (RequestRow & { sort: number })[] = [];
93
+ for (const q of records) {
94
+ const who = q.requester
95
+ ? [q.requester.name, q.requester.email, q.requester.phone, q.requester.locale ? localeName(q.requester.locale) : ''].filter(Boolean).join(' · ')
96
+ : userName.get(q.userId) ?? 'A member';
97
+ const e = q.eventId ? eventById.get(q.eventId) : undefined;
98
+ let title = '', line = '', clash: RequestRow['clash'] = null;
99
+ if (q.kind === 'new') {
100
+ const d = q.data as unknown as NewEventDetails;
101
+ const type = types.find((t) => t.id === d.type);
102
+ title = d.title;
103
+ line = [requestWhen(d.start, d.end, settings, f.locale), d.repeat === 'weekly' ? 'every week' : d.repeat === 'other' ? d.repeatText : '',
104
+ d.roomsUnsure ? 'room: not sure' : d.roomIds.map((id) => roomName.get(id) ?? 'a deleted room').join(', '),
105
+ d.people ? `about ${d.people} people` : '', type?.label ?? ''].filter(Boolean).join(' · ');
106
+ if (tab === 'pending' && d.roomIds.length) {
107
+ const found = await requestClashes(repo, q, settings, rooms);
108
+ const names = d.roomIds.map((id) => roomName.get(id)).filter(Boolean).join(' and ');
109
+ clash = found.length
110
+ ? { taken: true, text: `${found[0].room} on ${f.slot(found[0].start, found[0].end)}${found[0].title ? ` (${found[0].title})` : ''}${found.length > 1 ? ` and ${found.length - 1} more` : ''}` }
111
+ : { taken: false, text: `${names} ${d.roomIds.length > 1 ? 'are' : 'is'} free then` };
112
+ }
113
+ } else if (q.kind === 'move' || q.kind === 'from-date') {
114
+ // a host's date move, or change from a date on ("Thu, Jan 17, 6:00 PM → Wed, Jan 16, 5:00 PM · Weekly on Wednesday")
115
+ title = e?.title ?? 'A deleted event';
116
+ line = await dateRequestWhen(repo, q);
117
+ if (tab === 'pending' && e) {
118
+ const found = await dateRequestClashes(repo, q);
119
+ clash = found.length
120
+ ? { taken: true, text: `${roomName.get(found[0].resourceId) ?? 'A room'} on ${f.slot(found[0].start, found[0].end)}${found[0].title ? ` (${found[0].title})` : ''}${found.length > 1 ? ` and ${found.length - 1} more` : ''}` }
121
+ : { taken: false, text: 'Its rooms are free then' };
122
+ }
123
+ } else {
124
+ title = e?.title ?? String(q.data.title ?? 'A deleted event');
125
+ line = q.key ? f.key(q.key) : 'every date';
126
+ }
127
+ const answer = q.state === 'pending' ? '' : `${q.state === 'approved' ? 'Approved' : q.state === 'declined' ? 'Declined' : 'Withdrawn'}${q.reviewedBy ? ` by ${userName.get(q.reviewedBy) ?? 'staff'}` : ''} · ${ago(now, q.reviewedAt || q.updatedAt)}`;
128
+ rows.push({
129
+ key: `r:${q.id}`, dom: `r-${q.id}`, id: q.id, kind: q.kind, head: `${KIND[q.kind]} · ${SOURCE[q.source]} · ${ago(now, q.createdAt)}`,
130
+ title, line, who, email: q.requester?.email ?? users.find((u) => u.id === q.userId)?.email ?? '', note: q.note, clash, answer, reply: q.reply,
131
+ href: q.eventId ? `/admin/calendar/events/${q.eventId}` : '', at: q.updatedAt, canReply: true, sort: q.state === 'pending' ? q.createdAt : q.reviewedAt,
132
+ });
133
+ }
134
+ // members' requests staged on their events (lib/calendar/requests.ts): waiting ones, and declined ones until the host dismisses them
135
+ if (tab !== 'approved' && MEMBERS.enabled && page === 1) {
136
+ const staged = tab === 'pending' ? await pendingRequests(repo) : events.filter((e) => e.request?.state === 'declined');
137
+ for (const e of staged) rows.push({ ...await memberRow(repo, e, settings, userName, now), sort: e.request!.at });
138
+ }
139
+ rows.sort((a, b) => (tab === 'pending' ? a.sort - b.sort : b.sort - a.sort));
140
+ return { rows: rows.map(({ sort: _, ...r }) => r), more: found.length > ROWS_PER_PAGE };
141
+ }
142
+
143
+ async function memberRow(repo: ContentRepository, e: CalEvent, settings: Settings, userName: Map<string, string>, now: number): Promise<RequestRow> {
144
+ const req = e.request!;
145
+ const shown = mergeEventDraft(e);
146
+ const o = featuredOccurrence(shown, settings, now);
147
+ const f = fmt(settings);
148
+ let clash: RequestRow['clash'] = null;
149
+ if (req.state === 'pending' && shown.roomHold.resourceIds.length) {
150
+ const { clashes } = await eventClashes(repo, settings, { ...shown, status: 'published' } as CalEvent, { now, horizonDays: 120 });
151
+ clash = clashes.length ? { taken: true, text: `${clashes.length} date${clashes.length === 1 ? '' : 's'} clash with other events (open it to see)` } : { taken: false, text: 'Its rooms are free then' };
152
+ }
153
+ return {
154
+ key: `e:${e.id}`, dom: `e-${e.id}`, id: e.id, kind: req.kind === 'new' ? 'member-new' : 'member-changes',
155
+ head: `${req.kind === 'new' ? 'New event' : 'Changes'} · from a host on My events · ${ago(now, req.at)}`,
156
+ title: shown.title || 'Untitled event', line: o ? `${formatOccurrence(o, f.tz, f.locale).date} · ${formatOccurrence(o, f.tz, f.locale).time}` : '',
157
+ who: userName.get(req.by) ?? 'A member', email: '', note: req.note, clash,
158
+ answer: req.state === 'declined' ? `Declined${req.reviewedBy ? ` by ${userName.get(req.reviewedBy) ?? 'staff'}` : ''} · ${ago(now, req.reviewedAt)}` : '',
159
+ reply: req.reply, href: `/admin/calendar/events/${e.id}`, at: req.at, canReply: false,
160
+ };
161
+ }
162
+
163
+ /** How many wait, for the tab's count: rows of their own plus members' requests. */
164
+ export async function pendingCount(repo: ContentRepository): Promise<number> {
165
+ return (await repo.requests.count({ states: ['pending'] })) + (MEMBERS.enabled ? (await pendingRequests(repo)).length : 0);
166
+ }
167
+
168
+ const lines = (v: FormDataEntryValue | null) => String(v ?? '').split(/[\s,;]+/).map((x) => x.trim().toLowerCase()).filter(Boolean);
169
+ const num = (v: FormDataEntryValue | null, fallback: number) => { const n = Number.parseInt(String(v ?? ''), 10); return Number.isFinite(n) ? n : fallback; };
170
+
171
+ /**
172
+ * The settings form: types as rows (type-label-N, type-tag-N, type-cal-N and
173
+ * type-<locale>-N; a row without a label is dropped), the words in each
174
+ * language, the phone, who's told, the daily cap and how long answered
175
+ * requests are kept.
176
+ */
177
+ export function settingsFromForm(data: FormData, current: RequestSettings, calendarIds: string[]): RequestSettings {
178
+ const types: RequestSettings['types'] = [];
179
+ // ids of the kinds already saved, so a new kind never takes one (renamed kinds keep theirs)
180
+ const keptIds = new Set(Array.from({ length: 12 }, (_, i) => String(data.get(`type-id-${i}`) ?? '')).filter(Boolean));
181
+ for (let i = 0; i < 12; i++) {
182
+ const label = String(data.get(`type-label-${i}`) ?? '').trim().slice(0, 60);
183
+ if (!label) continue;
184
+ if (types.some((t) => t.label.toLowerCase() === label.toLowerCase())) throw new ApiError(400, `Two kinds are called “${label}”. Give each kind its own name.`);
185
+ // a kind keeps its id when renamed (requests point at it); a new one gets a free id from its name
186
+ const kept = String(data.get(`type-id-${i}`) ?? '');
187
+ const base = slugify(label.normalize('NFKD').replace(/[\u0300-\u036f]/g, '')).slice(0, 36) || 'kind';
188
+ let id = kept && !types.some((t) => t.id === kept) ? kept : base;
189
+ for (let n = 2; types.some((t) => t.id === id) || (!kept && keptIds.has(id)); n++) id = `${base}-${n}`;
190
+ const cal = String(data.get(`type-cal-${i}`) ?? '');
191
+ types.push({ id, label, tag: String(data.get(`type-tag-${i}`) ?? '').trim().slice(0, 60), calendarId: calendarIds.includes(cal) ? cal : '',
192
+ translations: Object.fromEntries(extraLocales().map((l) => [l, String(data.get(`type-${l}-${i}`) ?? '').trim().slice(0, 60)]).filter(([, v]) => v)) });
193
+ }
194
+ const notify = lines(data.get('notify'));
195
+ const bad = notify.find((a) => !validAddress(a));
196
+ if (bad) throw new ApiError(400, `“${bad.slice(0, 80)}” isn’t an email address.`);
197
+ if (notify.length > 20) throw new ApiError(400, 'Add up to 20 addresses.');
198
+ const parsed = RequestSettings.safeParse({
199
+ open: data.get('open') === '1', types,
200
+ replyTime: String(data.get('replyTime') ?? '').trim(), notice: String(data.get('notice') ?? '').trim(),
201
+ translations: Object.fromEntries(extraLocales().map((l) => [l, { replyTime: String(data.get(`replyTime-${l}`) ?? '').trim(), notice: String(data.get(`notice-${l}`) ?? '').trim() }])),
202
+ phone: String(data.get('phone') ?? '').trim(), notify, phones: data.has('phones') ? data.getAll('phones').includes('1') : current.phones,
203
+ perDay: num(data.get('perDay'), current.perDay), keepDays: num(data.get('keepDays'), current.keepDays),
204
+ });
205
+ if (!parsed.success) {
206
+ const field = String(parsed.error.issues[0]?.path[0] ?? '');
207
+ const why: Record<string, string> = { replyTime: 'Keep the reply time under 60 characters.', notice: 'Keep the notice under 400 characters.', phone: 'Keep the phone number under 40 characters.',
208
+ perDay: 'Pick a daily limit from 0 to 10,000.', keepDays: 'Keep answered requests from 7 to 1,095 days.', translations: 'Keep each translation as short as the original.' };
209
+ throw new ApiError(400, why[field] ?? 'Check the request settings.');
210
+ }
211
+ return parsed.data;
212
+ }
@@ -0,0 +1,8 @@
1
+ import { userConfig } from 'virtual:acms-config';
2
+
3
+ // acms({ requests }) config (integration.mjs resolves it): off unless a site
4
+ // turns it on, and only with the calendar. `path` is the public form ('/request'
5
+ // by default, '/es/request' in Spanish); `page: false` keeps the app API and
6
+ // Calendar → Requests but drops that form, so a site can build its own.
7
+ export const REQUESTS: { enabled: boolean; path: string; page: boolean } =
8
+ { enabled: false, path: '/request', page: true, ...userConfig.requests };
@@ -0,0 +1,158 @@
1
+ import type { ContentRepository } from '../repo/types';
2
+ import type { RequestRecord, Settings } from '../schema';
3
+ import type { Mail } from '../email/types';
4
+ import { siteLang, siteLocale } from '../dates';
5
+ import { I18N, localeName } from '../i18n';
6
+ import { fill } from '../live/words';
7
+ import { parseWall } from './events';
8
+ import { zonedToUtc } from './ics';
9
+ import { requestSettings, type NewEventDetails, type RequestMailPayload } from './request-service';
10
+ import { requestStaffWords, requestStrings } from './request-words';
11
+
12
+ // The emails about event requests (queued by lib/calendar/request-service.ts
13
+ // as kind 'calendar-request') and the staff push. To the person: in the
14
+ // language they asked in, and never with words they typed (their title or
15
+ // message), so the form can't be used to email someone else a message; only
16
+ // the date, time, kind, rooms and number of people, or the event's own title.
17
+ // Staff get everything, in the site's main language, with Reply-To set to
18
+ // the person. Plain HTML like the other emails, plus a text part.
19
+
20
+ const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!));
21
+ const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim();
22
+
23
+ /** the site's main language, and the locale for its dates (keeping the site's region when it's the same language) */
24
+ function language(settings: Settings, lang = '') {
25
+ const l = lang || (I18N.enabled && I18N.defaultLocale) || siteLang(settings);
26
+ const base = l.toLowerCase().split('-')[0];
27
+ return { lang: l, locale: base === siteLang(settings) ? siteLocale(settings) : l };
28
+ }
29
+
30
+ /** "Sat, Nov 7 · 2:00 – 5:00 PM" for site-local wall times, in a language */
31
+ export function requestWhen(start: string, end: string, settings: Settings, locale: string): string {
32
+ const tz = settings.timezone || 'UTC';
33
+ const s = parseWall(start), e = parseWall(end);
34
+ if (!s || s.dateOnly) return start;
35
+ const at = (w: NonNullable<typeof s>) => zonedToUtc(w.y, w.m, w.d, w.hh, w.mm, 0, tz);
36
+ const f = (o: Intl.DateTimeFormatOptions) => {
37
+ try { return new Intl.DateTimeFormat(locale, { ...o, timeZone: tz }); }
38
+ catch { return new Intl.DateTimeFormat('en-US', { ...o, timeZone: 'UTC' }); }
39
+ };
40
+ const date = f({ weekday: 'short', month: 'short', day: 'numeric' }).format(at(s));
41
+ const time = f({ hour: 'numeric', minute: '2-digit' });
42
+ return `${date} · ${e && !e.dateOnly ? time.formatRange(at(s), at(e)) : time.format(at(s))}`;
43
+ }
44
+
45
+ /** a date (YYYY-MM-DD…) as "Sat, Nov 7" */
46
+ function dayText(key: string, settings: Settings, locale: string) {
47
+ const w = parseWall(key.slice(0, 10));
48
+ if (!w) return key;
49
+ try { return new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' }).format(Date.UTC(w.y, w.m - 1, w.d)); }
50
+ catch { return key.slice(0, 10); }
51
+ }
52
+
53
+ interface Summary { what: string; lines: string[] }
54
+ /** the request as the person may see it again: never their own words */
55
+ async function personSummary(repo: ContentRepository, q: RequestRecord, settings: Settings, lang: string, locale: string): Promise<Summary> {
56
+ const w = requestStrings(lang);
57
+ if (q.kind === 'change') {
58
+ const e = q.eventId ? await repo.getEvent(q.eventId) : null;
59
+ const title = e?.title ?? String(q.data.title ?? '');
60
+ return { what: fill(w.changeTo, { title }), lines: [q.key ? dayText(q.key, settings, locale) : w.everyDate] };
61
+ }
62
+ if (q.kind !== 'new') return { what: '', lines: [] };
63
+ const d = q.data as unknown as NewEventDetails;
64
+ const rooms = await repo.listResources();
65
+ const type = requestSettings(settings).types.find((t) => t.id === d.type);
66
+ const parts = [
67
+ requestWhen(d.start, d.end, settings, locale),
68
+ ...(d.repeat === 'weekly' ? [w.weekly] : []),
69
+ d.roomIds.map((id) => rooms.find((r) => r.id === id)?.name).filter(Boolean).join(', '),
70
+ d.people ? fill(w.aboutPeople, { count: String(d.people) }) : '',
71
+ type ? (lang && type.translations[lang]) || type.label : '',
72
+ ].filter(Boolean);
73
+ return { what: '', lines: [parts.join(' · ')] };
74
+ }
75
+
76
+ function page(site: string, heading: string, paras: string[], quote: string, button?: { label: string; url: string }, foot = '') {
77
+ const box = quote ? `<p style="background:#efebe4;border-radius:8px;padding:10px 12px;white-space:pre-line">${esc(quote)}</p>` : '';
78
+ const btn = button ? `<p><a style="display:inline-block;background:#ee5d20;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none" href="${esc(button.url)}">${esc(button.label)}</a></p>` : '';
79
+ return `<!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">${esc(site)}</p><h1 style="font-size:22px">${esc(heading)}</h1>`
80
+ + paras.map((p) => `<p>${esc(p)}</p>`).join('') + box + btn + (foot ? `<p style="color:#7d776e;font-size:14px">${esc(foot)}</p>` : '') + '</div></body></html>';
81
+ }
82
+
83
+ /** One queued request email, or null when it no longer applies. */
84
+ export async function requestMail(repo: ContentRepository, recipient: string, p: RequestMailPayload, origin: string): Promise<Mail | null> {
85
+ const q = await repo.requests.get(p.requestId);
86
+ if (!q) return null;
87
+ const settings = await repo.getSettings();
88
+ const site = settings.siteTitle;
89
+ if (p.kind === 'staff-new') {
90
+ // staff hear only about requests still waiting
91
+ if (q.state !== 'pending') return null;
92
+ return staffMail(repo, q, recipient, settings, origin);
93
+ }
94
+ const { lang, locale } = language(settings, q.requester?.locale);
95
+ const w = requestStrings(lang).mail;
96
+ const s = await personSummary(repo, q, settings, lang, locale);
97
+ const rq = requestSettings(settings);
98
+ const replyTime = (q.requester?.locale && rq.translations[q.requester.locale]?.replyTime) || rq.replyTime;
99
+ const change = q.kind === 'change';
100
+ const [subject, intro, quote] = p.kind === 'received' ? [w.receivedSubject, [w.received, ...(replyTime ? [fill(w.within, { time: replyTime })] : [])], '']
101
+ : p.kind === 'reply' ? [w.replySubject, [w.reply], p.message ?? '']
102
+ : p.kind === 'approved' ? [w.approvedSubject, [change ? w.approvedChange : w.approved], p.message ? `${w.fromTeam} ${p.message}` : '']
103
+ : [w.declinedSubject, [change ? w.declinedChange : w.declined], p.message ? `${w.fromTeam} ${p.message}` : ''];
104
+ const details = [s.what, ...s.lines].filter(Boolean);
105
+ const foot = p.kind === 'received' ? w.notYou : '';
106
+ return {
107
+ to: recipient, subject: `${subject} · ${site}`,
108
+ text: [subject, '', ...intro, '', ...details, ...(quote ? ['', quote] : []), ...(foot ? ['', foot] : [])].join('\n'),
109
+ html: page(site, subject, [...intro, details.join(' — ')], quote, undefined, foot),
110
+ };
111
+ }
112
+
113
+ async function staffMail(repo: ContentRepository, q: RequestRecord, to: string, settings: Settings, origin: string): Promise<Mail> {
114
+ const { lang, locale } = language(settings);
115
+ const w = requestStaffWords(lang);
116
+ const site = settings.siteTitle;
117
+ const user = q.userId ? await repo.getUser(q.userId) : null;
118
+ const who = q.requester ?? { name: user?.name ?? '', email: user?.email ?? '', phone: '', locale: '' };
119
+ const rows: [string, string][] = [];
120
+ let subject: string;
121
+ if (q.kind === 'new') {
122
+ const d = q.data as unknown as NewEventDetails;
123
+ const rooms = await repo.listResources();
124
+ const type = requestSettings(settings).types.find((t) => t.id === d.type);
125
+ subject = fill(w.subjectNew, { title: oneLine(d.title) });
126
+ rows.push([w.when, requestWhen(d.start, d.end, settings, locale)]);
127
+ if (d.repeat !== 'once') rows.push([w.repeats, d.repeat === 'weekly' ? requestStrings(lang).weekly : d.repeatText]);
128
+ rows.push([w.rooms, d.roomsUnsure ? w.roomsUnsure : d.roomIds.map((id) => rooms.find((r) => r.id === id)?.name ?? '').filter(Boolean).join(', ') || '—']);
129
+ if (d.people) rows.push([w.people, String(d.people)]);
130
+ if (type) rows.push([w.type, type.label]);
131
+ } else {
132
+ const e = q.eventId ? await repo.getEvent(q.eventId) : null;
133
+ const title = e?.title ?? String(q.data.title ?? '');
134
+ subject = fill(q.kind === 'change' ? w.subjectChange : w.subjectMove, { title: oneLine(title) });
135
+ rows.push([w.date, q.key ? dayText(q.key, settings, locale) : w.everyDate]);
136
+ const d = q.data as { start?: string; end?: string };
137
+ if (q.kind !== 'change' && d.start && d.end) rows.push([w.when, requestWhen(d.start, d.end, settings, locale)]);
138
+ }
139
+ rows.push([w.from, [who.name, who.email, who.phone].filter(Boolean).join(' · ')]);
140
+ if (who.locale) rows.push([w.language, localeName(who.locale, true)]);
141
+ const quote = q.note ? `${q.kind === 'change' ? w.whatChange : w.message}: “${q.note}”` : '';
142
+ const url = `${origin}/admin/calendar/requests#r-${q.id}`;
143
+ const intro = w.intro[q.source];
144
+ return {
145
+ to, subject: `${subject} · ${site}`, ...(who.email ? { replyTo: who.email } : {}),
146
+ text: [subject, '', intro, '', ...rows.map(([k, v]) => `${k}: ${v}`), ...(quote ? ['', quote] : []), '', `${w.open}: ${url}`, '', w.why].join('\n'),
147
+ html: page(site, subject, [intro, ...rows.map(([k, v]) => `${k}: ${v}`)], quote, { label: w.open, url }, w.why),
148
+ };
149
+ }
150
+
151
+ /** The staff push: that a request arrived and for when, nothing the person typed. */
152
+ export async function requestPush(_repo: ContentRepository, q: RequestRecord, settings: Settings): Promise<{ title: string; body: string }> {
153
+ const { lang, locale } = language(settings);
154
+ const w = requestStaffWords(lang);
155
+ const d = q.data as { start?: string; end?: string };
156
+ const when = d.start && d.end ? requestWhen(d.start, d.end, settings, locale) : q.key ? dayText(q.key, settings, locale) : '';
157
+ return { title: w.pushTitle, body: when ? fill(w.pushBody, { when }) : '' };
158
+ }
@@ -0,0 +1,52 @@
1
+ import type { ContentRepository } from '../repo/types';
2
+ import type { RequestRecord, Settings } from '../schema';
3
+ import { requestWhen } from './request-mail';
4
+ import { requestSettings, type NewEventDetails, type RequestInput } from './request-service';
5
+ import { requestStrings } from './request-words';
6
+ import { fill } from '../live/words';
7
+
8
+ // The built-in request form (pages/request/index.astro): what the posted
9
+ // form means as a request, and the "Request sent" summary. A site's own form
10
+ // can use the server API instead.
11
+
12
+ const str = (data: FormData, name: string) => String(data.get(name) ?? '');
13
+
14
+ /** The posted form as RequestInput (the date and times come as separate fields). */
15
+ export function formRequest(data: FormData, locale: string): RequestInput {
16
+ const kind = str(data, 'kind') === 'change' ? 'change' : 'new';
17
+ const common = { name: str(data, 'name'), email: str(data, 'email'), phone: str(data, 'phone'), locale, website: str(data, 'website') };
18
+ if (kind === 'change') return { kind, event: str(data, 'event'), date: str(data, 'onDate'), message: str(data, 'whatChange'), ...common };
19
+ const date = str(data, 'date');
20
+ const repeat = str(data, 'repeat');
21
+ const people = Number.parseInt(str(data, 'people'), 10);
22
+ return {
23
+ kind, type: str(data, 'type'), title: str(data, 'title'),
24
+ start: date && str(data, 'start') ? `${date}T${str(data, 'start')}` : date, end: str(data, 'end'),
25
+ repeat: repeat === 'weekly' || repeat === 'other' ? repeat : 'once', repeatText: str(data, 'repeatText'),
26
+ rooms: data.getAll('rooms').map(String).slice(0, 10), roomsUnsure: data.get('roomsUnsure') === '1',
27
+ people: Number.isFinite(people) && people > 0 ? Math.min(people, 1_000_000) : null, message: str(data, 'message'), ...common,
28
+ };
29
+ }
30
+
31
+ /** "Request sent": the request as the person sent it, in their language. */
32
+ export async function sentSummary(repo: ContentRepository, q: RequestRecord, settings: Settings, lang: string, locale: string): Promise<{ title: string; line: string }> {
33
+ const w = requestStrings(lang);
34
+ if (q.kind === 'change') {
35
+ const e = await repo.getEvent(q.eventId);
36
+ const day = q.key ? new Date(`${q.key.slice(0, 10)}T12:00:00Z`) : null;
37
+ let date = w.everyDate;
38
+ if (day) { try { date = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' }).format(day); } catch { date = q.key.slice(0, 10); } }
39
+ return { title: fill(w.changeTo, { title: e?.title ?? String(q.data.title ?? '') }), line: date };
40
+ }
41
+ const d = q.data as unknown as NewEventDetails;
42
+ const rooms = await repo.listResources();
43
+ const type = requestSettings(settings).types.find((t) => t.id === d.type);
44
+ const line = [
45
+ requestWhen(d.start, d.end, settings, locale),
46
+ d.repeat === 'weekly' ? w.weekly : d.repeat === 'other' ? d.repeatText : '',
47
+ d.roomIds.map((id) => rooms.find((r) => r.id === id)?.name).filter(Boolean).join(', '),
48
+ d.people ? fill(w.aboutPeople, { count: String(d.people) }) : '',
49
+ type ? (lang && type.translations[lang]) || type.label : '',
50
+ ].filter(Boolean).join(' · ');
51
+ return { title: d.title, line };
52
+ }