@acms-subir/core 0.3.54 → 0.3.56

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 (75) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/cli/main.mjs +2 -1
  3. package/cli/migrations.generated.mjs +59 -6
  4. package/docs/calendar.md +104 -2
  5. package/docs/google-calendar.md +302 -0
  6. package/docs/mobile.md +3 -1
  7. package/docs/server.md +58 -0
  8. package/integration.mjs +38 -0
  9. package/mobile-template/client/AGENTS.md +1 -0
  10. package/mobile-template/client/README.md +1 -1
  11. package/mobile-template/recipes/event-request/files/app/request.tsx +305 -0
  12. package/mobile-template/recipes/event-request/files/components/DayPicker.tsx +66 -0
  13. package/mobile-template/recipes/event-request/files/components/RequestEventLink.tsx +19 -0
  14. package/mobile-template/recipes/event-request/files/components/RequestEventRow.tsx +20 -0
  15. package/mobile-template/recipes/event-request/recipe.json +21 -0
  16. package/package.json +1 -1
  17. package/src/components/LegalSettings.tsx +1 -1
  18. package/src/components/MobileApp.tsx +2 -2
  19. package/src/components/editor/event/SingleDates.tsx +12 -2
  20. package/src/layouts/Admin.astro +7 -0
  21. package/src/lib/calendar/google/client.ts +167 -0
  22. package/src/lib/calendar/google/connect.ts +161 -0
  23. package/src/lib/calendar/google/context.ts +50 -0
  24. package/src/lib/calendar/google/inbound.ts +493 -0
  25. package/src/lib/calendar/google/links.ts +139 -0
  26. package/src/lib/calendar/google/map.ts +543 -0
  27. package/src/lib/calendar/google/match.ts +103 -0
  28. package/src/lib/calendar/google/migration.ts +71 -0
  29. package/src/lib/calendar/google/outbound.ts +272 -0
  30. package/src/lib/calendar/google/service.ts +193 -0
  31. package/src/lib/calendar/google/store.ts +241 -0
  32. package/src/lib/calendar/google/types.ts +188 -0
  33. package/src/lib/calendar/request-admin.ts +211 -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 +8 -8
  41. package/src/lib/display/board.ts +3 -1
  42. package/src/lib/email/service.ts +17 -2
  43. package/src/lib/email/templates.ts +8 -0
  44. package/src/lib/legal/admin.ts +1 -1
  45. package/src/lib/legal/app-privacy.ts +2 -2
  46. package/src/lib/legal/facts.ts +7 -0
  47. package/src/lib/legal/text-en.ts +7 -0
  48. package/src/lib/legal/text-es.ts +7 -0
  49. package/src/lib/live/crypto.ts +7 -28
  50. package/src/lib/mobile/http.ts +2 -1
  51. package/src/lib/mobile/openapi.ts +8 -0
  52. package/src/lib/mobile/requests.ts +27 -0
  53. package/src/lib/mobile/schemas.ts +14 -3
  54. package/src/lib/mobile/site.ts +3 -0
  55. package/src/lib/push/types.ts +1 -1
  56. package/src/lib/repo/migrations.ts +4 -0
  57. package/src/lib/repo/sql.ts +18 -9
  58. package/src/lib/repo/types.ts +6 -2
  59. package/src/lib/scheduler.ts +6 -0
  60. package/src/lib/schema.ts +43 -2
  61. package/src/lib/sealed.ts +46 -0
  62. package/src/pages/admin/calendar/events/[id].astro +42 -1
  63. package/src/pages/admin/calendar/events/index.astro +2 -0
  64. package/src/pages/admin/calendar/google/match.astro +160 -0
  65. package/src/pages/admin/calendar/google.astro +107 -0
  66. package/src/pages/admin/calendar/requests.astro +298 -0
  67. package/src/pages/admin/calendar/settings.astro +273 -4
  68. package/src/pages/admin/integrations.astro +6 -0
  69. package/src/pages/api/calendar/google/oauth/callback.ts +16 -0
  70. package/src/pages/api/v1/requests/index.ts +6 -0
  71. package/src/pages/api/v1/requests/options.ts +7 -0
  72. package/src/pages/request/index.astro +182 -0
  73. package/src/server/index.ts +105 -1
  74. package/src/styles/site.css +29 -0
  75. package/src/virtual.d.ts +1 -0
@@ -0,0 +1,27 @@
1
+ import { z } from 'zod';
2
+ import { ApiError, readJson } from '../api';
3
+ import { REQUESTS } from '../calendar/request-config';
4
+ import { requestOptions, submitRequest } from '../calendar/request-service';
5
+ import { queryParams } from './http';
6
+ import type { PublicContext } from './route';
7
+
8
+ // Event requests from apps (acms({ requests })): the options the form needs,
9
+ // and sending one. No account; an Idempotency-Key per request (retries never
10
+ // duplicate); 10 a minute per address and the site's daily cap
11
+ // (lib/calendar/request-service.ts holds the rules). Never cached: "closed"
12
+ // and the rooms must show at once.
13
+
14
+ export const requestOptionsQuery = z.object({ locale: z.string().max(35).optional() }).strict();
15
+
16
+ export async function getRequestOptions(c: PublicContext) {
17
+ if (!REQUESTS.enabled) throw new ApiError(404, 'Not found.');
18
+ const q = requestOptionsQuery.parse(queryParams(c.url));
19
+ return requestOptions(c.repo, q.locale);
20
+ }
21
+
22
+ export async function postRequest(c: PublicContext) {
23
+ const key = c.request.headers.get('idempotency-key') ?? '';
24
+ const body = await readJson(c.request, 20_000);
25
+ const { id } = await submitRequest(c.repo, body, { source: 'app', key, address: c.address, locals: c.locals });
26
+ return { id };
27
+ }
@@ -9,7 +9,7 @@ const Palette = z.object({ bg: str, ink: str, muted: str, line: str, accent: str
9
9
  export const Site = z.object({
10
10
  name: str, description: str, logo: str, logoDark: str, icon: str, menu: z.array(z.object({ label: str, href: str })),
11
11
  languages: z.object({ default: locale, locales: z.array(z.object({ code: str, name: str })) }),
12
- features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str, redirectFrom: strings })), calendar: bool, bookings: bool, rsvp: bool, search: bool, bookingChanges: bool, guestSignIn: bool, forms: bool, links: bool, live: bool, announcements: bool }),
12
+ features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str, redirectFrom: strings })), calendar: bool, bookings: bool, rsvp: bool, search: bool, bookingChanges: bool, guestSignIn: bool, forms: bool, links: bool, live: bool, announcements: bool, requests: bool }),
13
13
  legal: z.object({ privacy: str.nullable(), terms: str.nullable() }), apiVersion: z.literal(1),
14
14
  });
15
15
  export const Theme = z.object({
@@ -159,8 +159,19 @@ export const LiveStatus = z.object({
159
159
  snapshot: LiveSnapshot.nullable(), serverNow: ms,
160
160
  display: z.object({ style: z.enum(['poster', 'split', 'row']), bar: bool, header: bool, dock: bool }),
161
161
  });
162
+ // GET /requests/options: types and rooms to offer, the site's words (in the asked language), the phone for when it's closed
163
+ export const RequestOptions = z.object({
164
+ open: bool, types: z.array(z.object({ id: str, label: str })), rooms: z.array(z.object({ slug: str, name: str, color: str })),
165
+ replyTime: str, notice: str, phone: str, timezone: str,
166
+ // what a change can be about: public events by title, each with its dates (site-local days) in the next 120 days
167
+ events: z.array(z.object({ slug: str, title: str, dates: z.array(str) })),
168
+ });
169
+ export const RequestReceipt = z.object({ id: str });
162
170
  export const Error = z.object({ version: z.literal(1), error: z.object({
163
- // duplicate (409): that email already has an RSVP for the date
164
- code: z.enum(['invalid_request', 'unauthorized', 'forbidden', 'not_found', 'method_not_allowed', 'conflict', 'duplicate', 'gone', 'payload_too_large', 'unsupported_media_type', 'rate_limited', 'upstream_error', 'internal_error']),
171
+ // duplicate (409): that email already has an RSVP for the date. POST /requests refusals say
172
+ // which field (title, type, start, end, past, far, repeat, room, people, event, date, what,
173
+ // name, email, phone; 400), closed or changed (409), busy or full (429), trap (400)
174
+ code: z.enum(['invalid_request', 'unauthorized', 'forbidden', 'not_found', 'method_not_allowed', 'conflict', 'duplicate', 'gone', 'payload_too_large', 'unsupported_media_type', 'rate_limited', 'upstream_error', 'internal_error',
175
+ 'closed', 'trap', 'busy', 'full', 'changed', 'title', 'type', 'start', 'end', 'past', 'far', 'repeat', 'room', 'people', 'event', 'date', 'what', 'name', 'email', 'phone']),
165
176
  message: str, issues: z.array(z.object({ path: z.array(z.union([str, z.number()])), message: str })).optional(),
166
177
  }) });
@@ -4,6 +4,7 @@ import { CONTENT, legacyBases } from '../content';
4
4
  import { I18N, localeName } from '../i18n';
5
5
  import { CALENDAR, bookingsEnabled } from '../calendar/config';
6
6
  import { ANNOUNCEMENTS } from '../announcements/config';
7
+ import { REQUESTS } from '../calendar/request-config';
7
8
  import { SEARCH } from '../search-config';
8
9
  import { siteOrigin } from '../seo';
9
10
  import { resolveDesign } from '../theme';
@@ -51,6 +52,8 @@ export async function getSite(repo: ContentRepository, url: URL, _id = '', local
51
52
  live: settings.live.mode !== 'off',
52
53
  // acms({ announcements }): GET /api/v1/announcements
53
54
  announcements: ANNOUNCEMENTS.enabled,
55
+ // acms({ requests }): GET /api/v1/requests/options, POST /api/v1/requests (on even while requests are closed)
56
+ requests: REQUESTS.enabled,
54
57
  },
55
58
  // in the requested language (?locale=es → /es/privacy)
56
59
  legal: { privacy: legalUrl('privacy'), terms: legalUrl('terms') },
@@ -57,7 +57,7 @@ export interface PushDevice {
57
57
  * new form response, to the linked phones of staff on its alert list. Neither
58
58
  * counts in the weekly pace.
59
59
  */
60
- export type PushSource = 'manual' | 'post' | 'event' | 'soon' | 'live' | 'announcement' | 'change' | 'test' | 'form';
60
+ export type PushSource = 'manual' | 'post' | 'event' | 'soon' | 'live' | 'announcement' | 'change' | 'test' | 'form' | 'request';
61
61
  export interface PushNotification {
62
62
  id: string; title: string; body: string; url: string;
63
63
  /** ['*'] = everyone */
@@ -12,6 +12,7 @@ import { SYNC_MIGRATION } from '../sync/migration';
12
12
  import { ANNOUNCEMENT_TABLE, ANNOUNCEMENT_VISIBLE_COLUMNS } from '../announcements/migration';
13
13
  import { SCREENS_MIGRATION } from '../display/store';
14
14
  import { REQUEST_TABLE } from '../calendar/request-migration';
15
+ import { GCAL_MIGRATION } from '../calendar/google/migration';
15
16
  // Versioned schema migrations shared by the two SQLite-dialect adapters
16
17
  // (D1 and libSQL). Applied versions are recorded in the `migrations` table;
17
18
  // each adapter's runner applies whatever is newer, in order, at startup.
@@ -734,6 +735,9 @@ export const MIGRATIONS: Migration[] = [
734
735
  // calendar requests on their own rows (RequestRecord): new events and
735
736
  // changes asked for without an account, and hosts' date moves
736
737
  { version: 68, statements: REQUEST_TABLE },
738
+ // two-way Google Calendar sync: the account, links, pairs, outgoing queue
739
+ // (triggers on events) and Recent changes. Not public, so no cache trigger.
740
+ GCAL_MIGRATION,
737
741
  ];
738
742
 
739
743
  // Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
@@ -4,6 +4,7 @@ import { AuthStore } from '../auth/store';
4
4
  import { FormStore, formInsert, submissionInsert } from '../forms/store';
5
5
  import { GuestStore } from '../mobile/guest-store';
6
6
  import { LiveStore } from '../live/store';
7
+ import { GcalStore } from '../calendar/google/store';
7
8
  import { PushStore } from '../push/store';
8
9
  import { AnnouncementStore, announcementInsert } from '../announcements/store';
9
10
  import { ScreenStore, screenInsert, screenKey } from '../display/store';
@@ -159,6 +160,10 @@ function parseMovedDates(raw: unknown): CalEvent['movedDates'] {
159
160
  for (const [key, entry] of Object.entries(value)) { const m = MovedDate.safeParse(entry); if (m.success) out[key] = m.data; }
160
161
  return out;
161
162
  }
163
+ // quiet: the event change it records sends its webhook but no member push
164
+ // (push_control.quiet shares the write's transaction)
165
+ const quietly = (write: SqlStatement, quiet?: boolean): SqlStatement[] =>
166
+ quiet ? [{ sql: 'UPDATE push_control SET quiet=1 WHERE id=1' }, write, { sql: 'UPDATE push_control SET quiet=0 WHERE id=1' }] : [write];
162
167
  function rowToEvent(r: Row): CalEvent {
163
168
  return CalEvent.parse({
164
169
  id: r.id, slug: r.slug, title: r.title, bodyJson: r.body_json ?? '', bodyHtml: r.body_html ?? '',
@@ -293,6 +298,7 @@ export class SqlRepository implements ContentRepository {
293
298
  readonly forms: FormStore;
294
299
  readonly guests: GuestStore;
295
300
  readonly live: LiveStore;
301
+ readonly gcal: GcalStore;
296
302
  readonly push: PushStore;
297
303
  readonly announcements: AnnouncementStore;
298
304
  readonly requests: RequestStore;
@@ -307,6 +313,7 @@ export class SqlRepository implements ContentRepository {
307
313
  this.forms = new FormStore(async () => { await this.ready; return this.driver; });
308
314
  this.guests = new GuestStore(async () => { await this.ready; return this.driver; });
309
315
  this.live = new LiveStore(async () => { await this.ready; return this.driver; });
316
+ this.gcal = new GcalStore(async () => { await this.ready; return this.driver; });
310
317
  this.push = new PushStore(async () => { await this.ready; return this.driver; });
311
318
  this.announcements = new AnnouncementStore(async () => { await this.ready; return this.driver; });
312
319
  this.requests = new RequestStore(async () => { await this.ready; return this.driver; });
@@ -880,13 +887,14 @@ export class SqlRepository implements ContentRepository {
880
887
  await this.syncEventIndex(id);
881
888
  return (await this.getEvent(id))!;
882
889
  }
883
- async updateEvent(id: string, data: EventUpdate) {
890
+ async updateEvent(id: string, data: EventUpdate, opts: { keepDraft?: boolean; quiet?: boolean } = {}) {
884
891
  await this.ready;
885
892
  const current = await this.getEvent(id);
886
893
  if (!current) return;
887
894
  // a direct live write supersedes any staged edits (applyEventDraft routes
888
- // through here too, so applying clears the snapshot in the same UPDATE)
889
- const sets: string[] = ['updated_at = ?', 'draft = NULL'];
895
+ // through here too, so applying clears the snapshot in the same UPDATE);
896
+ // keepDraft: a change made elsewhere (the Google sync) leaves them staged
897
+ const sets: string[] = opts.keepDraft ? ['updated_at = ?'] : ['updated_at = ?', 'draft = NULL'];
890
898
  const args: unknown[] = [Date.now()];
891
899
  const plain: Record<string, string> = {
892
900
  title: 'title', slug: 'slug', bodyJson: 'body_json', bodyHtml: 'body_html', excerpt: 'excerpt',
@@ -946,8 +954,8 @@ export class SqlRepository implements ContentRepository {
946
954
  start_at=CASE WHEN ? THEN (SELECT start_at FROM events WHERE id=?) ELSE start_at END,
947
955
  end_at=CASE WHEN ? THEN (SELECT end_at FROM events WHERE id=?) ELSE end_at END
948
956
  WHERE event_id=? AND kind='rsvp' AND status IN ('waitlisted','offered')`,args:[cancelled?1:0,id,siteTimezone,cancelled?1:0,single?1:0,id,single?1:0,id,id]},
949
- ...notifications]);
950
- } else await this.driver.batch([eventWrite]);
957
+ ...notifications].flatMap((st) => st === eventWrite ? quietly(eventWrite, opts.quiet) : [st]));
958
+ } else await this.driver.batch(quietly(eventWrite, opts.quiet));
951
959
  await this.syncEventIndex(id);
952
960
  }
953
961
  async saveEventDraft(id: string, data: Parameters<ContentRepository['saveEventDraft']>[1]) {
@@ -967,17 +975,16 @@ export class SqlRepository implements ContentRepository {
967
975
  await this.updateEvent(id, pickEventDraft(draft));
968
976
  }
969
977
  // per-date cancellations apply at once and never touch a staged draft
970
- async setEventCancelledDates(id: string, dates: string[]) {
978
+ async setEventCancelledDates(id: string, dates: string[], opts: { quiet?: boolean } = {}) {
971
979
  await this.ready;
972
- await this.driver.run('UPDATE events SET cancelled_dates = ?, updated_at = ? WHERE id = ?', [JSON.stringify(dates), Date.now(), id]);
980
+ await this.driver.batch(quietly({ sql: 'UPDATE events SET cancelled_dates = ?, updated_at = ? WHERE id = ?', args: [JSON.stringify(dates), Date.now(), id] }, opts.quiet));
973
981
  }
974
982
  async setEventMovedDates(id: string, moved: CalEvent['movedDates'], cancelledDates?: string[], opts: { quiet?: boolean } = {}) {
975
983
  await this.ready;
976
984
  const write = cancelledDates
977
985
  ? { sql: 'UPDATE events SET moved_dates = ?, cancelled_dates = ?, updated_at = ? WHERE id = ?', args: [JSON.stringify(moved), JSON.stringify(cancelledDates), Date.now(), id] }
978
986
  : { sql: 'UPDATE events SET moved_dates = ?, updated_at = ? WHERE id = ?', args: [JSON.stringify(moved), Date.now(), id] };
979
- // quiet: the change it records sends its webhook but no member push (the flag shares this transaction)
980
- await this.driver.batch(opts.quiet ? [{ sql: 'UPDATE push_control SET quiet=1 WHERE id=1' }, write, { sql: 'UPDATE push_control SET quiet=0 WHERE id=1' }] : [write]);
987
+ await this.driver.batch(quietly(write, opts.quiet));
981
988
  }
982
989
  async cancelOccurrenceRsvps(id: string, start: number) {
983
990
  await this.ready;
@@ -2008,6 +2015,8 @@ export class SqlRepository implements ContentRepository {
2008
2015
  if (authRecovery) stmts.push({ sql: `UPDATE auth_identities SET disabled=0 WHERE issuer=? AND subject=? AND user_id=? AND EXISTS(SELECT 1 FROM users WHERE id=? AND role='owner')`, args: [authRecovery.issuer, authRecovery.subject, authRecovery.userId, authRecovery.userId] });
2009
2016
  const settingsWrite = this.settingsStatement({ ...content.settings, live: { ...LiveConfig.parse(content.settings.live ?? {}), mode: 'off', manual: { watchUrl: '', title: 'We’re live', until: 0 } } });
2010
2017
  stmts.push({ sql: "UPDATE live_connections SET enabled=0,tested_at=0,revision=revision+1,lease='',lease_until=0,snapshot=NULL,upcoming=NULL,error='Restored content. Test this connection before enabling automatic checks.'" }, { sql: 'DELETE FROM live_oauth_states' });
2018
+ // Google Calendar links pause until someone checks them (the restored events may be older than Google's)
2019
+ stmts.push(...GcalStore.restoreStatements());
2011
2020
  if (settingsWrite) stmts.push(settingsWrite);
2012
2021
  stmts.push(
2013
2022
  { sql: 'UPDATE webhook_control SET suppressed=0 WHERE id=1' },
@@ -5,6 +5,7 @@ import type { BackupStore } from '../backup/store';
5
5
  import type { GuestStore } from '../mobile/guest-store';
6
6
  import type { SavedForm, FormSubmission } from '../forms/schema';
7
7
  import type { LiveStore } from '../live/store';
8
+ import type { GcalStore } from '../calendar/google/store';
8
9
  import type { BookingRequest } from '../backup/content';
9
10
  import type { EmailStore } from '../email/store';
10
11
  import type { RsvpStaffNotice } from '../email/types';
@@ -81,6 +82,8 @@ export interface ContentRepository {
81
82
  readonly backups: BackupStore;
82
83
  readonly guests: GuestStore;
83
84
  readonly live: LiveStore;
85
+ /** two-way Google Calendar sync (lib/calendar/google/) */
86
+ readonly gcal: GcalStore;
84
87
  readonly push: PushStore;
85
88
  readonly announcements: AnnouncementStore;
86
89
  // calendar requests on their own rows (lib/calendar/request-store.ts)
@@ -163,7 +166,8 @@ export interface ContentRepository {
163
166
  getEvent(id: string): Promise<CalEvent | null>;
164
167
  getEventBySlug(slug: string): Promise<CalEvent | null>;
165
168
  createEvent(data: Partial<Omit<CalEvent, 'id' | 'draft' | 'startAt' | 'endAt' | 'createdAt' | 'updatedAt'>> & { title: string; slug: string; start: string }): Promise<CalEvent>;
166
- updateEvent(id: string, data: EventUpdate): Promise<void>;
169
+ /** keepDraft: leave staged edits staged (a change made elsewhere); quiet: no member push for it */
170
+ updateEvent(id: string, data: EventUpdate, opts?: { keepDraft?: boolean; quiet?: boolean }): Promise<void>;
167
171
  saveEventDraft(id: string, data: Omit<EventUpdate, 'status' | 'scheduledAt' | 'publishedAt'>): Promise<void>;
168
172
  /** the Team members who host an event (lib/calendar/requests.ts) */
169
173
  setEventHosts(id: string, hostIds: string[]): Promise<void>;
@@ -172,7 +176,7 @@ export interface ContentRepository {
172
176
  applyEventDraft(id: string): Promise<void>;
173
177
  discardEventDraft(id: string): Promise<void>;
174
178
  /** replace an event's cancelled occurrence keys at once, keeping any staged draft */
175
- setEventCancelledDates(id: string, dates: string[]): Promise<void>;
179
+ setEventCancelledDates(id: string, dates: string[], opts?: { quiet?: boolean }): Promise<void>;
176
180
  /** replace an event's moved dates (and cancelled dates, when given) at once, keeping any staged draft; quiet: no member push for it */
177
181
  setEventMovedDates(id: string, moved: CalEvent['movedDates'], cancelledDates?: string[], opts?: { quiet?: boolean }): Promise<void>;
178
182
  /** Cancels the RSVPs for one date of an event (they get the "cancelled" email). Returns how many. */
@@ -6,10 +6,12 @@ import { dispatchEmail } from './email/service';
6
6
  import { decideOverdue, sendDueReminders, sweepWaitlist } from './booking/service';
7
7
  import { runPushJobs } from './push/service';
8
8
  import { runAnnouncementJobs } from './announcements/service';
9
+ import { pruneRequests } from './calendar/request-service';
9
10
  import { GUEST_IDLE_MS } from './mobile/guest-store';
10
11
  import { pushHeartbeat, pushUrlFor } from './monitoring';
11
12
  import { syncCachePurge } from './cache/purge';
12
13
  import { runOffsiteBackup } from './backup/offsite';
14
+ import { syncGoogle } from './calendar/google/service';
13
15
 
14
16
  // All work is awaited on both hosts. Traffic-triggered fallbacks do not mark
15
17
  // this heartbeat: monitoring must prove the independent scheduler ran.
@@ -20,6 +22,8 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
20
22
  publish: () => repo.publishDuePosts(startedAt),
21
23
  // changes to published events (scheduled publishing above included): webhooks, change pushes, redirects
22
24
  events: () => flushEventChanges(repo),
25
+ // two-way Google Calendar sync: site changes go out every run, each link reads Google every 2 minutes
26
+ gcal: () => syncGoogle(repo, locals, { now: startedAt }),
23
27
  webhooks: () => processDueDeliveries(repo),
24
28
  reminders: () => sendDueReminders(repo, { origin: settings.siteUrl, locals, now: startedAt }),
25
29
  // pending bookings past their approval deadline (Calendar settings → Bookings)
@@ -32,6 +36,8 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
32
36
  announcements: () => runAnnouncementJobs(repo, { locals, now: startedAt }),
33
37
  // queued first-publish notices, starting-soon reminders, receipts, retention
34
38
  push: () => runPushJobs(repo, { locals, now: startedAt }),
39
+ // event requests past Calendar → Requests' retention (after the answer, or after arriving if nobody answered)
40
+ requests: () => pruneRequests(repo, startedAt),
35
41
  // expired guest sign-in codes and app sessions unused for 90 days
36
42
  guests: () => repo.guests.purge(GUEST_IDLE_MS, startedAt).then(() => 0),
37
43
  // clear shared caches after scheduled publishes, and retry a purge that
package/src/lib/schema.ts CHANGED
@@ -406,8 +406,10 @@ export type EventRequest = z.infer<typeof EventRequest>;
406
406
  // on (C3/C4); `data` holds the move.
407
407
  // `userId` is the member who asked; `requester` is the person without an
408
408
  // account (never both). `note` is their own words to staff, `reply` staff's
409
- // last answer. Decided rows (and withdrawn ones, which set reviewedAt too)
410
- // are deleted some days after `reviewedAt` (CalendarSettings.requests).
409
+ // last answer. `data.sent` on new and change requests is a hash of what was
410
+ // sent (a retry must match it). Rows are deleted some days after
411
+ // `reviewedAt` (withdrawing sets it too), or after `createdAt` while nobody
412
+ // answered (CalendarSettings.requests).
411
413
  export const RequestKind = z.enum(['new', 'change', 'move', 'from-date']);
412
414
  export type RequestKind = z.infer<typeof RequestKind>;
413
415
  export const RequestState = z.enum(['pending', 'approved', 'declined', 'withdrawn']);
@@ -651,6 +653,44 @@ export type ApprovalRules = z.infer<typeof ApprovalRules>;
651
653
  export const EventImageUrl = z.string().trim().max(2000).refine((value) => !value || Boolean(safeEventImageUrl(value)), 'Choose an image or use an http(s) URL or site-relative image path.');
652
654
 
653
655
  // Site-wide calendar preferences (Calendar settings → Defaults / Bookings)
656
+ // Event requests from people without an account (acms({ requests }),
657
+ // lib/calendar/request-service.ts), set on Calendar → Requests. A type is
658
+ // what people pick under "Kind" (Class, Meeting…): approving a request makes
659
+ // a draft with its tag, in its calendar.
660
+ export const RequestType = z.object({
661
+ id: z.string().regex(/^[a-z0-9][a-z0-9-]{0,39}$/),
662
+ label: z.string().min(1).max(60),
663
+ // the label in each other site language
664
+ translations: z.record(z.string().max(60)).default({}),
665
+ // what an approved draft gets: this tag ('' = none), this calendar ('' = the default calendar)
666
+ tag: z.string().max(60).default(''),
667
+ calendarId: z.string().default(''),
668
+ });
669
+ export type RequestType = z.infer<typeof RequestType>;
670
+ export const RequestWords = z.object({ replyTime: z.string().max(60).default(''), notice: z.string().max(400).default('') });
671
+ export const RequestSettings = z.object({
672
+ // closed: the form says so (with the phone number) and new requests are refused
673
+ open: z.boolean().default(true),
674
+ types: z.array(RequestType).max(12).default([]),
675
+ // "We usually answer within {replyTime}" ("3 days"); '' leaves the sentence out
676
+ replyTime: z.string().max(60).default(''),
677
+ // the form's opening line ('' = the built-in "It isn't on the calendar until…")
678
+ notice: z.string().max(400).default(''),
679
+ // replyTime and notice in each other site language
680
+ translations: z.record(RequestWords).default({}),
681
+ // the office phone, shown when requests are closed and on "Request sent"
682
+ phone: z.string().max(40).default(''),
683
+ // who hears about new requests; [] = acms({ members.notify }), else the owners
684
+ notify: z.array(z.string().max(254)).max(20).default([]),
685
+ // staff on that list who linked a phone also get a push (with no details)
686
+ phones: z.boolean().default(false),
687
+ // new requests a day from the app and the website together; 0 = no cap
688
+ perDay: z.number().int().min(0).max(10000).default(100),
689
+ // requests are deleted this many days after the answer (or after they arrived, if nobody answered)
690
+ keepDays: z.number().int().min(7).max(1095).default(180),
691
+ });
692
+ export type RequestSettings = z.infer<typeof RequestSettings>;
693
+
654
694
  export const CalendarSettings = z.object({
655
695
  // Display-only fallback: event covers remain empty until an image is explicitly chosen.
656
696
  eventFallbackImage: EventImageUrl.default(''),
@@ -663,6 +703,7 @@ export const CalendarSettings = z.object({
663
703
  approvalRules: ApprovalRules.default({}),
664
704
  // show bookings on the Schedule grid
665
705
  showBookings: z.boolean().default(true),
706
+ requests: RequestSettings.default({}),
666
707
  });
667
708
  export type CalendarSettings = z.infer<typeof CalendarSettings>;
668
709
 
@@ -0,0 +1,46 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
2
+ import { env } from './env';
3
+ import { ApiError } from './api';
4
+
5
+ // Credentials for connected services (YouTube/Twitch live video, Google
6
+ // Calendar) are sealed with AES-256-GCM under a key derived from
7
+ // LIVE_ENCRYPTION_KEY, else SESSION_SECRET. Each service has its own domain:
8
+ // a different derived key and associated data, so one service's ciphertext
9
+ // never opens as another's. `context` narrows it further (a provider, a
10
+ // pending OAuth state).
11
+ export interface Sealer {
12
+ ready(locals?: unknown): boolean;
13
+ encrypt(value: unknown, context: string, locals?: unknown): string;
14
+ decrypt<T>(encrypted: string, context: string, locals?: unknown): T;
15
+ }
16
+
17
+ /** `what` names the credentials in the errors people see ("live credentials"). */
18
+ export function sealer(domain: string, what: string): Sealer {
19
+ function key(locals?: unknown) {
20
+ const runtime = (locals as { runtime?: { env?: Record<string, unknown> } } | undefined)?.runtime?.env;
21
+ const secret = String(runtime?.LIVE_ENCRYPTION_KEY || env('LIVE_ENCRYPTION_KEY') || runtime?.SESSION_SECRET || env('SESSION_SECRET'));
22
+ if (secret.length < 32) throw new ApiError(503, `Set LIVE_ENCRYPTION_KEY or SESSION_SECRET to at least 32 random characters on your host before storing ${what}.`);
23
+ return createHash('sha256').update(`acms:${domain}:v1:${secret}`).digest();
24
+ }
25
+ return {
26
+ ready(locals) { try { key(locals); return true; } catch { return false; } },
27
+ encrypt(value, context, locals) {
28
+ const iv = randomBytes(12), cipher = createCipheriv('aes-256-gcm', key(locals), iv);
29
+ cipher.setAAD(Buffer.from(`acms:${domain}:${context}`));
30
+ const data = Buffer.concat([cipher.update(JSON.stringify(value), 'utf8'), cipher.final()]);
31
+ return [iv, cipher.getAuthTag(), data].map(v => v.toString('base64url')).join('.');
32
+ },
33
+ decrypt<T>(encrypted: string, context: string, locals?: unknown): T {
34
+ try {
35
+ const parts = encrypted.split('.');
36
+ if (parts.length !== 3) throw new Error();
37
+ const [iv, tag, data] = parts.map(v => Buffer.from(v, 'base64url'));
38
+ const cipher = createDecipheriv('aes-256-gcm', key(locals), iv);
39
+ cipher.setAAD(Buffer.from(`acms:${domain}:${context}`)); cipher.setAuthTag(tag);
40
+ return JSON.parse(Buffer.concat([cipher.update(data), cipher.final()]).toString('utf8'));
41
+ } catch { throw new ApiError(503, `Saved ${what} cannot be decrypted. Re-enter the credentials and reconnect.`); }
42
+ },
43
+ };
44
+ }
45
+ export const hash = (value: string) => createHash('sha256').update(value).digest('hex');
46
+ export const randomToken = () => randomBytes(32).toString('base64url');
@@ -14,6 +14,8 @@ import { accessFor, forbiddenUnless } from '../../../../lib/permissions/access';
14
14
  import type { Permission } from '../../../../lib/permissions/catalog';
15
15
  import { ApiError } from '../../../../lib/api';
16
16
  import { roleLabel } from '../../../../lib/roles';
17
+ import { eventClashes } from '../../../../lib/booking/holds';
18
+ import { fieldWord } from '../../../../lib/calendar/google/map';
17
19
  import ThemeScript from '../../../../components/ThemeScript.astro';
18
20
  import ClerkSession from '../../../../components/auth/ClerkSession.astro';
19
21
  import Modal from '../../../../components/Modal.astro';
@@ -92,7 +94,20 @@ const hosts = event.hostIds.map((id) => ({ id, name: nameOf(id) })).filter((h) =
92
94
  const people = [...users].sort((a, b) => (a.name || a.email).localeCompare(b.name || b.email));
93
95
  const notice = Astro.url.searchParams.get('notice') ?? '';
94
96
  const error = Astro.url.searchParams.get('error') ?? '';
95
- const showBar = Boolean(req) || canEdit || hosts.length > 0 || Boolean(notice || error);
97
+ // Synced with Google (lib/calendar/google/): which Google calendars have a copy, the latest
98
+ // field both sides changed (the site kept its value; "Use Google's version" takes Google's),
99
+ // whether the description still comes from Google (Q8), and room clashes a change from Google made
100
+ const gItems = (await repo.gcal.itemsForEvent(event.id)).filter((i) => i.kind !== 'segment' && !i.gone);
101
+ const gLinks = gItems.length ? new Map((await repo.gcal.listLinks()).map((l) => [l.id, l])) : new Map();
102
+ const synced = gItems.map((i) => ({ ...i, googleName: gLinks.get(i.linkId)?.googleName ?? 'Google' }));
103
+ const gConflict = synced.map((i) => i.conflict).filter((c): c is NonNullable<typeof c> => !!c).sort((a, b) => b.at - a.at)[0] ?? null;
104
+ const gDescFromGoogle = synced.some((i) => i.descGoogle);
105
+ const gTz = settings.timezone || 'UTC';
106
+ const gTime = (ts: number) => new Intl.DateTimeFormat(settings.locale || 'en', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: gTz }).format(ts);
107
+ const gClashes = synced.length && event.status === 'published' && !event.cancelled && event.roomHold.resourceIds.length
108
+ ? (await eventClashes(repo, settings, event, { horizonDays: 120 }).catch(() => ({ clashes: [] }))).clashes : [];
109
+ const gRooms = gClashes.length ? new Map((await repo.listResources()).map((r) => [r.id, r.name])) : new Map<string, string>();
110
+ const showBar = Boolean(req) || canEdit || hosts.length > 0 || Boolean(notice || error) || synced.length > 0;
96
111
  const ago = (ts: number) => {
97
112
  const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
98
113
  if (s < 60) return 'just now';
@@ -162,6 +177,27 @@ const approveLabel = pending?.kind === 'changes' ? 'Approve changes' : isLive ?
162
177
  {declined.kind === 'new' && !isLive && ` It stays a draft until ${requester} sends it again.`}
163
178
  </p>
164
179
  )}
180
+ {synced.length > 0 && (
181
+ <div class="evreq-gcal">
182
+ <p class="evreq-line">
183
+ <span class="evreq-label">Synced with Google</span>
184
+ {synced.map((i) => <span class="evreq-chip">{i.googleName}{i.syncedAt ? ` · ${gTime(i.syncedAt)}` : ''}{i.htmlLink && <> · <a href={i.htmlLink} target="_blank" rel="noopener">Open in Google ↗</a></>}</span>)}
185
+ </p>
186
+ {gConflict && (
187
+ <div class="evreq-gcal-clash">
188
+ <span>Changed in both places at {gTime(gConflict.at)}. The site kept its {fieldWord(gConflict.field).toLowerCase()}, “{gConflict.site}”. Google had “{gConflict.google}”.</span>
189
+ {(canPublish || access?.can('schedule.manage')) && (
190
+ <form method="post" action="/admin/calendar/google" data-evreq>
191
+ <input type="hidden" name="op" value="use" /><input type="hidden" name="log" value={gConflict.logId} /><input type="hidden" name="return" value={self} />
192
+ <button class="btn sm" type="submit">Use Google’s version</button>
193
+ </form>
194
+ )}
195
+ </div>
196
+ )}
197
+ {gDescFromGoogle && <p class="evreq-muted">Created in Google. Its description comes from Google until someone edits it here.</p>}
198
+ {gClashes.length > 0 && <p class="evreq-gcal-room">Room clash: {gRooms.get(gClashes[0].hold.resourceId) ?? 'a room'} is also held by “{gClashes[0].other.title}” on {gTime(gClashes[0].hold.eventStart)}{gClashes.length > 1 ? ` (and ${gClashes.length - 1} more)` : ''}. Changes from Google keep their rooms: check Bookings.</p>}
199
+ </div>
200
+ )}
165
201
  <div class="evreq-hosts">
166
202
  <span class="evreq-label">Hosts</span>
167
203
  {hosts.length ? hosts.map((h) => <span class="evreq-chip">{h.name}</span>) : <span class="evreq-muted">None</span>}
@@ -306,6 +342,11 @@ const approveLabel = pending?.kind === 'changes' ? 'Approve changes' : isLive ?
306
342
  .evreq-hosts { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-left: auto; }
307
343
  .evreq-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); margin-right: 2px; }
308
344
  .evreq-chip { font-size: 12px; font-weight: 500; color: var(--text-2); background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-full); padding: 1px 9px; white-space: nowrap; }
345
+ .evreq-gcal { flex: 1 1 480px; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
346
+ .evreq-gcal .evreq-chip a { color: var(--accent-deep); }
347
+ .evreq-gcal-clash { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; color: var(--text); }
348
+ .evreq-gcal-clash form { margin: 0; }
349
+ .evreq-gcal-room { color: var(--red); }
309
350
  .evreq-modal-hint { margin: 0 0 12px; }
310
351
  .evreq-find { width: 100%; margin-bottom: 10px; }
311
352
  .evreq-people { max-height: min(320px, 50vh); overflow-y: auto; margin: 0 -4px; padding: 2px 4px; }
@@ -25,6 +25,7 @@ import { approveEventRequest, declineEventRequest, pendingRequests } from '../..
25
25
  import { approveDateRequest, dateRequestClashes, dateRequestTimes, declineDateRequest, type MoveRequestData } from '../../../../lib/calendar/host-requests';
26
26
  import { mergeEventDraft } from '../../../../lib/drafts';
27
27
  import { ApiError } from '../../../../lib/api';
28
+ import { REQUESTS } from '../../../../lib/calendar/request-config';
28
29
 
29
30
  // Schedule → List: upcoming first, grouped by the month each event next
30
31
  // happens. Subscribed calendars appear as read-only rows with a "Make an
@@ -527,6 +528,7 @@ const subBits = [
527
528
  ))}
528
529
  </section>
529
530
  )}
531
+ {REQUESTS.enabled && <p class="hint">Every request, including those from the app and the website, is on <a href="/admin/calendar/requests">Requests</a>.</p>}
530
532
  {(requestRows.length > 0 || dateRows.length > 0) && !canApprove && <p class="hint">Someone whose role can publish content approves these.</p>}
531
533
  {canApprove && dateRows.map((r) => (
532
534
  <Modal id={`decline-date-${r.id}`} title="Decline this date change">