@acms-subir/core 0.3.54 → 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 (42) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/cli/main.mjs +2 -1
  3. package/cli/migrations.generated.mjs +59 -6
  4. package/docs/calendar.md +29 -0
  5. package/docs/google-calendar.md +302 -0
  6. package/docs/server.md +7 -0
  7. package/integration.mjs +4 -0
  8. package/package.json +1 -1
  9. package/src/components/LegalSettings.tsx +1 -1
  10. package/src/lib/calendar/google/client.ts +167 -0
  11. package/src/lib/calendar/google/connect.ts +161 -0
  12. package/src/lib/calendar/google/context.ts +50 -0
  13. package/src/lib/calendar/google/inbound.ts +493 -0
  14. package/src/lib/calendar/google/links.ts +139 -0
  15. package/src/lib/calendar/google/map.ts +543 -0
  16. package/src/lib/calendar/google/match.ts +103 -0
  17. package/src/lib/calendar/google/migration.ts +71 -0
  18. package/src/lib/calendar/google/outbound.ts +272 -0
  19. package/src/lib/calendar/google/service.ts +193 -0
  20. package/src/lib/calendar/google/store.ts +241 -0
  21. package/src/lib/calendar/google/types.ts +188 -0
  22. package/src/lib/calendar/schedule.ts +8 -8
  23. package/src/lib/display/board.ts +3 -1
  24. package/src/lib/email/service.ts +11 -1
  25. package/src/lib/email/templates.ts +8 -0
  26. package/src/lib/legal/admin.ts +1 -1
  27. package/src/lib/legal/facts.ts +3 -0
  28. package/src/lib/legal/text-en.ts +4 -0
  29. package/src/lib/legal/text-es.ts +4 -0
  30. package/src/lib/live/crypto.ts +7 -28
  31. package/src/lib/repo/migrations.ts +4 -0
  32. package/src/lib/repo/sql.ts +18 -9
  33. package/src/lib/repo/types.ts +6 -2
  34. package/src/lib/scheduler.ts +3 -0
  35. package/src/lib/sealed.ts +46 -0
  36. package/src/pages/admin/calendar/events/[id].astro +42 -1
  37. package/src/pages/admin/calendar/google/match.astro +160 -0
  38. package/src/pages/admin/calendar/google.astro +107 -0
  39. package/src/pages/admin/calendar/settings.astro +273 -4
  40. package/src/pages/admin/integrations.astro +6 -0
  41. package/src/pages/api/calendar/google/oauth/callback.ts +16 -0
  42. package/src/server/index.ts +49 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.55
4
+
5
+ ### Patch Changes
6
+
7
+ - 5ae8091: Two-way Google Calendar sync. Site calendars and Google calendars now stay in step, both ways: anything created, changed, moved, cancelled or deleted on one side shows up on the other within a few minutes, and Google keeps a full working copy if the site is ever down. It's in **Calendar settings → Synced with Google**, and the guide is `npx acms docs google-calendar`.
8
+
9
+ - **Connect Google** with its own OAuth client (it can share YouTube's Google Cloud project; disconnecting YouTube never cuts off the calendar). The sign-in works like YouTube's: a one-use pending state, a callback at `/api/calendar/google/oauth/callback` that only parks the code, and the signed-in admin finishing it. Tokens are sealed with `LIVE_ENCRYPTION_KEY`, else `SESSION_SECRET`, under their own key. No new environment variable.
10
+ - **Links:** one site calendar ⇄ one Google calendar, both ways, site → Google, or Google → site. A link can stand for rooms (events from it hold them, and site events holding them go there), so one site calendar can have a link per room, and one link can also take the events that hold no room. Title prefix ⇄ tag rules (`CLASS:` ⇄ `Class`) work both ways, with the prefix kept in the title.
11
+ - **First match:** a new link writes nothing until someone reviews what's on both sides and presses Start syncing. Pairs are found by the hidden link on Google copies, by Google's calendar ID (`sourceUid`), by title and time; same time and repeat with a different title is suggested. Linked pairs keep both titles as they are. Nothing sends member notifications.
12
+ - **What goes where:** title (main language), times and time zone, repeat rule and skipped dates, cancelled and moved single dates (through `cancelOccurrence` / `moveOccurrence` as `GOOGLE_SYNC`), place, description (Google's while the event is Google's, then the site's with "Details: <event page>"), rooms. A cancelled site event shows "Cancelled: " in Google. Unpublished or deleted on the site removes the Google copy. Deleted in Google cancels the site event (struck through, room freed, restorable), or, when the event has another linked copy, takes just that link's room or calendar off it. Hosts, RSVPs, private fields, pictures and other tags are never sent, guests are never added, and every write uses `sendUpdates=none`. Only owned fields are patched.
13
+ - **Titles:** cancelling or changing a tag on the site adjusts Google's own title rather than replacing it. Staged edits stay staged, and a staged field still at its old value follows Google's change.
14
+ - **Clashes:** field by field, each side keeps what it changed. When both changed a field, the site wins, and Google's value waits in Recent changes and the event editor with **Use Google's version**.
15
+ - **Booked appointments** from Google booking pages come in as "Booked", holding the link's room, with the internal tag `#google-booking`: a link names its booking pages, since Google's API has no marker for them.
16
+ - **"This and following"** edits in Google are listed for staff, and the site series stays whole until "from this date on" exists.
17
+ - **Safety:** links run only on the Site URL they were made on, pause after a restore until someone resumes them, and are never copied by environment sync or backups. Unlinking and disconnecting keep both copies. Google's "full sync required" (410) reads everything again without doubles or pushes. A revoked sign-in shows "Reconnect Google" and changes wait. Staff get one email when a link has failed for an hour. Recent changes are kept for 90 days.
18
+ - **For site code:** `googleCopies(repo, eventId)`, `googleSyncHistory(repo)`, `syncGoogleCalendar(repo, { locals })` and `GOOGLE_BOOKING_TAG` in `@acms-subir/core/server`. The event repository's `updateEvent` takes `{ keepDraft, quiet }`, and `cancelOccurrence` / `restoreOccurrence` take `{ quiet }`.
19
+ - The privacy notice gets a Google Calendar section, in English and Spanish, once Google is connected.
20
+
21
+ Migration 69 adds the sync's tables (`gcal_account`, `gcal_oauth_states`, `gcal_links`, `gcal_items`, `gcal_queue`, `gcal_log`) and triggers on `events` that queue changes for links. Sites with no links are unaffected. After `npx acms update`, nothing changes until someone connects Google. Set up the Google Cloud client as the guide describes. For a church on Google Workspace, use the "Internal" audience if the project belongs to the organization; otherwise use "External", set to "In production".
22
+
3
23
  ## 0.3.54
4
24
 
5
25
  ### Patch Changes
package/cli/main.mjs CHANGED
@@ -10,7 +10,7 @@ import { addMobile, mobileCommand, listApps } from './mobile.mjs';
10
10
  import { mobileDoctor, renderDoctor } from './mobile-doctor.mjs';
11
11
  import { monitorCommand, renderMonitor } from './monitor.mjs';
12
12
  const HELP=`acms — ACMS project, theme and agent tools
13
- Discovery: inspect | schema theme | docs theme | docs sections | docs mobile | docs sync | docs server | docs calendar
13
+ Discovery: inspect | schema theme | docs theme | docs sections | docs mobile | docs sync | docs server | docs calendar | docs google-calendar
14
14
  agent init [--mobile] write .agents/skills (theme; mobile when an app exists)
15
15
  Check: check [--build] validate every project theme, refresh section types,
16
16
  report sections without a native app version
@@ -141,6 +141,7 @@ export async function main() {
141
141
  else if(cmd==='docs'&&sub==='sync')data={markdown:await readFile(resolve(CORE,'docs/environment-sync.md'),'utf8')};
142
142
  else if(cmd==='docs'&&sub==='server')data={markdown:await readFile(resolve(CORE,'docs/server.md'),'utf8')};
143
143
  else if(cmd==='docs'&&sub==='calendar')data={markdown:await readFile(resolve(CORE,'docs/calendar.md'),'utf8')};
144
+ else if(cmd==='docs'&&sub==='google-calendar')data={markdown:await readFile(resolve(CORE,'docs/google-calendar.md'),'utf8')};
144
145
  else if(cmd==='section')data=await sectionCommand(sub,rest,flags);
145
146
  else if(cmd==='check') {
146
147
  const p=await project(),themes=(await listLocalThemes(p)).filter(t=>t.origin==='project');
@@ -461,6 +461,56 @@ var REQUEST_TABLE = [
461
461
  `CREATE INDEX event_requests_event ON event_requests (event_id)`
462
462
  ];
463
463
 
464
+ // src/lib/calendar/google/migration.ts
465
+ var now5 = "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
466
+ var guard = "(SELECT suppressed FROM webhook_control WHERE id=1)=0 AND EXISTS (SELECT 1 FROM gcal_links)";
467
+ var enqueue3 = (id) => `INSERT INTO gcal_queue (event_id,version,due_at,attempts,claimed_until,error) VALUES (${id},1,${now5},0,0,'')
468
+ ON CONFLICT(event_id) DO UPDATE SET version=version+1,due_at=excluded.due_at,attempts=0,error='';`;
469
+ var GCAL_MIGRATION = { version: 69, statements: [
470
+ `CREATE TABLE gcal_account (
471
+ id INTEGER PRIMARY KEY CHECK(id=1), client_id TEXT NOT NULL, encrypted TEXT NOT NULL, email TEXT NOT NULL DEFAULT '',
472
+ status TEXT NOT NULL DEFAULT 'setup', error TEXT NOT NULL DEFAULT '', site_url TEXT NOT NULL DEFAULT '',
473
+ calendars TEXT NOT NULL DEFAULT '[]', calendars_at INTEGER NOT NULL DEFAULT 0, connected_by TEXT NOT NULL DEFAULT '',
474
+ connected_at INTEGER NOT NULL DEFAULT 0, revision INTEGER NOT NULL DEFAULT 1, updated_at INTEGER NOT NULL DEFAULT 0
475
+ )`,
476
+ `CREATE TABLE gcal_oauth_states (hash TEXT PRIMARY KEY, session_hash TEXT NOT NULL, revision INTEGER NOT NULL, encrypted TEXT NOT NULL, expires_at INTEGER NOT NULL)`,
477
+ `CREATE TABLE gcal_links (
478
+ id TEXT PRIMARY KEY, calendar_id TEXT NOT NULL, google_id TEXT NOT NULL UNIQUE, google_name TEXT NOT NULL DEFAULT '', google_tz TEXT NOT NULL DEFAULT '',
479
+ direction TEXT NOT NULL DEFAULT 'both' CHECK(direction IN ('both','out','in')), room_ids TEXT NOT NULL DEFAULT '[]',
480
+ no_room INTEGER NOT NULL DEFAULT 0, rules TEXT NOT NULL DEFAULT '[]', booking_titles TEXT NOT NULL DEFAULT '[]', start_date TEXT NOT NULL,
481
+ state TEXT NOT NULL DEFAULT 'matching' CHECK(state IN ('matching','active','paused')), paused_reason TEXT NOT NULL DEFAULT '',
482
+ site_url TEXT NOT NULL, matched_at INTEGER NOT NULL DEFAULT 0, match TEXT,
483
+ sync_token TEXT NOT NULL DEFAULT '', full_sync INTEGER NOT NULL DEFAULT 1,
484
+ lease TEXT NOT NULL DEFAULT '', lease_until INTEGER NOT NULL DEFAULT 0, next_at INTEGER NOT NULL DEFAULT 0,
485
+ failures INTEGER NOT NULL DEFAULT 0, failing_since INTEGER NOT NULL DEFAULT 0, alerted_at INTEGER NOT NULL DEFAULT 0,
486
+ last_ok_at INTEGER NOT NULL DEFAULT 0, error TEXT NOT NULL DEFAULT '', revision INTEGER NOT NULL DEFAULT 1,
487
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
488
+ )`,
489
+ `CREATE TABLE gcal_items (
490
+ link_id TEXT NOT NULL, google_id TEXT NOT NULL, event_id TEXT NOT NULL, ical_uid TEXT NOT NULL DEFAULT '',
491
+ kind TEXT NOT NULL DEFAULT 'single' CHECK(kind IN ('series','single','segment')), segment_from TEXT NOT NULL DEFAULT '',
492
+ etag TEXT NOT NULL DEFAULT '', html_link TEXT NOT NULL DEFAULT '', site TEXT NOT NULL DEFAULT '{}', google TEXT NOT NULL DEFAULT '{}',
493
+ desc_google INTEGER NOT NULL DEFAULT 0, booking INTEGER NOT NULL DEFAULT 0, gone INTEGER NOT NULL DEFAULT 0, conflict TEXT,
494
+ synced_at INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL,
495
+ PRIMARY KEY (link_id, google_id), UNIQUE (link_id, event_id, segment_from)
496
+ )`,
497
+ `CREATE INDEX gcal_items_event ON gcal_items (event_id)`,
498
+ `CREATE TABLE gcal_queue (
499
+ event_id TEXT PRIMARY KEY, version INTEGER NOT NULL DEFAULT 1, due_at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0,
500
+ claimed_until INTEGER NOT NULL DEFAULT 0, error TEXT NOT NULL DEFAULT ''
501
+ )`,
502
+ `CREATE TABLE gcal_log (
503
+ id INTEGER PRIMARY KEY AUTOINCREMENT, at INTEGER NOT NULL, link_id TEXT NOT NULL DEFAULT '', event_id TEXT NOT NULL DEFAULT '',
504
+ google_id TEXT NOT NULL DEFAULT '', direction TEXT NOT NULL CHECK(direction IN ('in','out','clash','info')), action TEXT NOT NULL,
505
+ title TEXT NOT NULL DEFAULT '', summary TEXT NOT NULL DEFAULT '', data TEXT, resolved INTEGER NOT NULL DEFAULT 0
506
+ )`,
507
+ `CREATE INDEX gcal_log_at ON gcal_log (at)`,
508
+ `CREATE INDEX gcal_log_event ON gcal_log (event_id, at)`,
509
+ `CREATE TRIGGER gcal_queue_insert AFTER INSERT ON events WHEN ${guard} BEGIN ${enqueue3("NEW.id")} END`,
510
+ `CREATE TRIGGER gcal_queue_update AFTER UPDATE ON events WHEN ${guard} BEGIN ${enqueue3("NEW.id")} END`,
511
+ `CREATE TRIGGER gcal_queue_delete AFTER DELETE ON events WHEN ${guard} BEGIN ${enqueue3("OLD.id")} END`
512
+ ] };
513
+
464
514
  // src/lib/repo/migrations.ts
465
515
  var CONTENT_VERSION_COLUMNS = {
466
516
  settings: [
@@ -1298,7 +1348,10 @@ var MIGRATIONS = [
1298
1348
  SCREENS_MIGRATION,
1299
1349
  // calendar requests on their own rows (RequestRecord): new events and
1300
1350
  // changes asked for without an account, and hosts' date moves
1301
- { version: 68, statements: REQUEST_TABLE }
1351
+ { version: 68, statements: REQUEST_TABLE },
1352
+ // two-way Google Calendar sync: the account, links, pairs, outgoing queue
1353
+ // (triggers on events) and Recent changes. Not public, so no cache trigger.
1354
+ GCAL_MIGRATION
1302
1355
  ];
1303
1356
  var CURRENT_CONTENT_VERSION_COLUMNS = {
1304
1357
  ...CONTENT_VERSION_COLUMNS,
@@ -1327,7 +1380,7 @@ async function pendingMigrations(db) {
1327
1380
  return { applied, pending: MIGRATIONS.filter((m) => m.version > applied) };
1328
1381
  }
1329
1382
  async function runMigrations(db, opts = {}) {
1330
- const now5 = opts.now ?? Date.now;
1383
+ const now6 = opts.now ?? Date.now;
1331
1384
  const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1332
1385
  const first = await pendingMigrations(db);
1333
1386
  if (!first.pending.length) return { from: first.applied, to: first.applied, applied: [] };
@@ -1335,10 +1388,10 @@ async function runMigrations(db, opts = {}) {
1335
1388
  if (refused) return { from: first.applied, to: first.applied, applied: [], deferred: refused.version };
1336
1389
  await db.run(MIGRATION_LOCK_TABLE);
1337
1390
  const owner = crypto.randomUUID();
1338
- const deadline = now5() + (opts.waitMs ?? 6e4);
1391
+ const deadline = now6() + (opts.waitMs ?? 6e4);
1339
1392
  let waited = false;
1340
1393
  for (; ; ) {
1341
- const t = now5();
1394
+ const t = now6();
1342
1395
  await db.run(
1343
1396
  `INSERT INTO migration_lock (id, owner, expires_at) VALUES (1, ?, ?)
1344
1397
  ON CONFLICT(id) DO UPDATE SET owner = excluded.owner, expires_at = excluded.expires_at WHERE migration_lock.expires_at < ?`,
@@ -1356,13 +1409,13 @@ async function runMigrations(db, opts = {}) {
1356
1409
  try {
1357
1410
  const { applied, pending } = await pendingMigrations(db);
1358
1411
  if (applied === 1 && !await appliedVersion(db)) {
1359
- await db.run("INSERT INTO migrations (version, applied_at) VALUES (1, ?)", [now5()]);
1412
+ await db.run("INSERT INTO migrations (version, applied_at) VALUES (1, ?)", [now6()]);
1360
1413
  }
1361
1414
  for (const m of pending) {
1362
1415
  opts.log?.(`Applying migration ${m.version}`);
1363
1416
  await db.batch([
1364
1417
  ...m.statements.map((sql) => ({ sql })),
1365
- { sql: "INSERT INTO migrations (version, applied_at) VALUES (?, ?)", args: [m.version, now5()] }
1418
+ { sql: "INSERT INTO migrations (version, applied_at) VALUES (?, ?)", args: [m.version, now6()] }
1366
1419
  ]);
1367
1420
  }
1368
1421
  return { from: applied, to: pending.length ? pending[pending.length - 1].version : applied, applied: pending.map((m) => m.version) };
package/docs/calendar.md CHANGED
@@ -286,3 +286,32 @@ A theme's `theme.css` loads after the board's own styles. The simplest way to re
286
286
  ```
287
287
 
288
288
  Keep blocks flat: the built-in look has no accent stripes or gradients. The display leaves out the site's header, footer, head code and analytics. A site file at `src/pages/display/calendar.astro` (or `display/s/[key].astro`) replaces that page.
289
+
290
+ ## Google Calendar sync
291
+
292
+ Calendar settings → **Synced with Google** keeps linked site calendars and Google
293
+ calendars in step, both ways, within a few minutes. The full guide is
294
+ `acms docs google-calendar`. What it means for the calendar features above:
295
+
296
+ - **Single dates.** A date deleted or moved in Google goes through `cancelOccurrence`
297
+ and `moveOccurrence` with the `GOOGLE_SYNC` actor, so RSVPs, room holds and pushes
298
+ follow as they do for staff. A taken room only warns. Cancelled and moved dates on the
299
+ site change that date's instance in Google.
300
+ - **Rooms.** A link can stand for one or more rooms: events from that Google calendar
301
+ hold them, and site events holding them go there. One site calendar can have a link
302
+ per room.
303
+ - **Deleted in Google** means **cancelled** on the site (struck through, room freed,
304
+ restorable), never deleted. The exception is an event with another linked copy: only
305
+ that link's room or calendar comes off it.
306
+ - **Cancelled on the site** shows in Google as "Cancelled: " in front of the title.
307
+ Unpublished or deleted on the site removes the Google copy.
308
+ - **Writes from the sync** keep staged edits staged (`updateEvent(id, data, { keepDraft: true })`).
309
+ Changes from Google push to members like an admin edit, except during the first match
310
+ and full resyncs (`quiet`).
311
+ - **Booked appointments** from Google booking pages come in as "Booked" with the
312
+ internal tag `#google-booking` (`GOOGLE_BOOKING_TAG`), holding the link's room. Treat
313
+ them as bookings: the tag never shows publicly.
314
+ - **"This and following"** in Google is listed for staff, and the site series stays whole
315
+ until "from this date on" exists.
316
+ - Site code can read an event's Google copies with `googleCopies(repo, eventId)`, and
317
+ Recent changes with `googleSyncHistory(repo)`.
@@ -0,0 +1,302 @@
1
+ # Google Calendar sync
2
+
3
+ Keeps site calendars and Google calendars in step, both ways. Anything
4
+ created, changed, moved, cancelled or deleted on one side shows up on the
5
+ other within a few minutes, and Google keeps a full working copy if the site
6
+ is ever down. The site is the main calendar.
7
+
8
+ It's set up in the admin, under **Calendar settings → Synced with Google**.
9
+ Only calendars you link are synced.
10
+
11
+ - Site changes go out on every scheduler run (once a minute).
12
+ - Each link asks Google what changed every 2 minutes (`events.list` with a sync token).
13
+ - The scheduler has to run: on Node, the `acms-email-runner` sidecar; on
14
+ Workers, the cron trigger.
15
+
16
+ ## Google Cloud setup
17
+
18
+ The sync uses its **own OAuth client**. It can live in the same Google Cloud
19
+ project as YouTube live video, but it needs a separate client, so
20
+ disconnecting YouTube never cuts off the calendar.
21
+
22
+ 1. In Google Cloud console, open the project. Under **APIs & Services →
23
+ Credentials**, create an **OAuth client ID** of type **Web application**
24
+ named "Calendar".
25
+ 2. Enable the **Google Calendar API** in the same project.
26
+ 3. Add the return address shown in Calendar settings as an **Authorized
27
+ redirect URI**: `https://<your site>/api/calendar/google/oauth/callback`.
28
+ 4. Set the **Audience** (OAuth consent screen):
29
+ - **Internal** if the project belongs to your Google Workspace
30
+ organization (Google Cloud console → IAM & Admin → Settings shows it).
31
+ There's no "unverified app" screen and no expiry. Internal applies to the
32
+ whole project, so YouTube must also be connected with an account in that
33
+ organization.
34
+ - Otherwise **External**, set to **In production**. In "Testing", Google
35
+ ends the connection every 7 days. You click through "Google hasn't
36
+ verified this app" once.
37
+ 5. If your Workspace admin restricts third-party apps, mark this client as
38
+ trusted: Google Admin console → Security → API controls.
39
+ 6. Paste the client ID and secret into Calendar settings → Synced with Google
40
+ and press **Connect Google**. Sign in with the account that owns the
41
+ calendars (for a church, its Workspace account).
42
+
43
+ The site asks for two permissions:
44
+
45
+ - `calendar.events`: see and edit events on calendars the account can edit.
46
+ - `calendar.calendarlist.readonly`: list those calendars for the link picker.
47
+
48
+ The tokens are sealed with `LIVE_ENCRYPTION_KEY`, else `SESSION_SECRET`
49
+ (32+ characters), using their own key and data. There's no new environment
50
+ variable. Changing that secret means connecting Google again.
51
+
52
+ Permissions:
53
+
54
+ - **Connected services** (`services.manage`) connects and disconnects Google.
55
+ - **Manage rooms and calendars** (`schedule.manage`) links, pauses, resumes
56
+ and reviews matches.
57
+
58
+ ## Linking a calendar
59
+
60
+ **+ Link a Google calendar** pairs one site calendar with one Google calendar
61
+ the account can edit. The settings:
62
+
63
+ - **Direction**:
64
+ - Both ways.
65
+ - Site → Google only: Google mirrors the site. Start here, then switch.
66
+ - Google → site only: site edits stay on the site.
67
+ - **Rooms**: the room(s) this Google calendar stands for. Events that come in
68
+ from it hold those rooms. Site events holding one of them go to it.
69
+ - **Also gets this calendar's events that hold no room**: one link per site
70
+ calendar may have this.
71
+ - A link with no rooms carries its whole site calendar.
72
+ - **Titles that set a tag**: a title prefix paired with a site tag, both ways,
73
+ for example `CLASS:` ⇄ `Class`.
74
+ - A Google title starting with the prefix gives the site event the tag.
75
+ - A tagged site event gets the prefix in Google.
76
+ - The prefix stays in the title on both sides. The tag stays the source of
77
+ truth for site features (class schedule, "Class changes" pushes).
78
+ - **Booking pages**: the titles of the Google calendar's appointment
79
+ schedules. See below.
80
+ - **Start from**: default 30 days ago. Events from that date on are synced,
81
+ plus every repeating series still running.
82
+
83
+ One site calendar can have several links, one per room. Example: the room
84
+ calendar ⇄ "Santuario | Sanctuary" holding the Sanctuary, and the room
85
+ calendar ⇄ "Multimedia Room #1" holding that room.
86
+
87
+ - An event in two linked site calendars, or holding two linked rooms, gets
88
+ one Google copy per link. An edit to any copy updates the site event, and
89
+ the other copies follow.
90
+ - Deleting one copy in Google removes only that link's room, or calendar,
91
+ from the event. When neither applies (a link that carries the whole
92
+ calendar, which another link also has), that Google calendar just keeps the
93
+ event out. The event is cancelled (below) only when no linked copy is left.
94
+
95
+ ### The first match
96
+
97
+ A new link writes nothing until someone reviews it. The match page lists:
98
+
99
+ - **On both**:
100
+ - Paired for sure, each with **Undo**: a Google copy carrying the site's hidden link; the same
101
+ calendar ID (copies made with "Make an editable copy…" or imported from
102
+ Google's .ics keep Google's ID); or the same title (prefixes and case
103
+ aside) at the same time.
104
+ - Suggested: same time and repeat, different title. **Link** or **Not the
105
+ same**.
106
+ - **Only on the site**: copied to Google when you start.
107
+ - **Only in Google**: come to the site as published events in the linked
108
+ calendar, holding its rooms.
109
+
110
+ Decisions are saved as you make them. **Start syncing** needs every
111
+ suggestion decided.
112
+
113
+ - Linked pairs keep **both titles as they are**: a Spanish Google title and
114
+ an English site title both stay. Every field, and every changed date of a
115
+ series, is recorded as the agreed starting point, and only a later edit on
116
+ one side copies over. A pair whose site event has no description keeps
117
+ Google's.
118
+ - A pair whose site event was unpublished, deleted or moved to another
119
+ calendar or room after the review is left alone: it isn't paired, deleted
120
+ or copied, and Recent changes names it.
121
+ - "Not the same" pairs are treated as two events.
122
+ - No member notifications are sent for any of it.
123
+
124
+ ## What goes where
125
+
126
+ | Site | Google | Direction |
127
+ | --- | --- | --- |
128
+ | Title (main language) | Title | both ways. A Google title edit sets the main-language title as typed; translations stay on the site |
129
+ | Cancelled event | "Cancelled: " in front of Google's title | site → Google |
130
+ | Date, time, all day, time zone | start, end | both ways (the site's inclusive all-day end ⇄ Google's exclusive one; each event keeps its zone) |
131
+ | Repeat rule, skipped dates | `RRULE`, `EXDATE` | both ways |
132
+ | Cancelled single date | that date's instance removed | both ways |
133
+ | Moved single date (new time or place) | that date's instance changed | both ways |
134
+ | Place name and address | location | both ways (from Google, the text becomes the place name; saved places and pins aren't guessed) |
135
+ | Description | plain-text description | while the event is Google's, from Google (below); after that, site → Google |
136
+ | Map or meeting link, event page | lines in the description | site → Google |
137
+ | Tag of a prefix rule | title prefix | both ways |
138
+ | Rooms | which linked Google calendar | both ways |
139
+ | Draft, scheduled | nothing | not sent until published, scheduled publishing included |
140
+ | Unpublished or deleted | deleted in Google | site → Google |
141
+ | Deleted in Google | **cancelled** on the site (struck through, room freed, restorable) | Google → site |
142
+ | Hidden link | `extendedProperties.private` `{ acmsEventId, acmsSite }` | site → Google |
143
+
144
+ Never sent:
145
+
146
+ - hosts, RSVPs, private fields, member requests, pictures, buttons, custom
147
+ fields, other tags;
148
+ - guests. Every write uses `sendUpdates=none`, so Google emails no one.
149
+
150
+ Only the fields above are patched. Guests, reminders, colours, attachments
151
+ and Meet links someone added in Google are left alone. Cancelling an event or
152
+ changing a rule's tag on the site adjusts Google's own title (adds or removes
153
+ "Cancelled: " or the prefix) rather than replacing it, so a Spanish Google
154
+ title stays Spanish until someone edits the title itself.
155
+
156
+ Changes from Google keep staff's staged edits staged. A staged field still at
157
+ its old value follows Google's change, so pressing Update later doesn't undo
158
+ it. A field staff did change stays as they staged it.
159
+
160
+ If Google refuses to change one event (for example, someone else's event
161
+ that only invites the room's calendar), Recent changes lists it once and the
162
+ other links still get the change.
163
+
164
+ **Descriptions.** An event created in Google takes its description from
165
+ Google, as simple paragraphs, until someone edits the description on the
166
+ site. From then on the site's text wins, and Google gets a plain-text copy
167
+ plus "Details: <event page>". A Google description edit after that is listed
168
+ under Recent changes with **Use Google's version**.
169
+
170
+ **New events from Google** are published on the site at once, in the linked
171
+ calendar, holding its rooms. Changes from Google send member pushes the way an
172
+ admin edit does, except during the first match and full resyncs. RSVP emails
173
+ always go, because those people's own booking changed.
174
+
175
+ ## Repeating events
176
+
177
+ A series is one event with a rule on both sides. Single dates:
178
+
179
+ - **Cancelled.** Deleting one date in Google cancels it on the site with
180
+ `cancelOccurrence`: struck through, room freed, RSVPs cancelled with their
181
+ email. A date cancelled on the site is deleted from the Google series.
182
+ - **Moved.** A Google date with a new time, day or place becomes a moved date
183
+ on the site, through `moveOccurrence` with the `GOOGLE_SYNC` actor (see
184
+ [calendar](calendar.md)): RSVPs move and get the "new time" email, and a
185
+ taken room only warns. A date moved on the site moves that Google instance.
186
+ - **Per-date titles or descriptions** from Google aren't applied, because the
187
+ site keeps one title for every date. They're listed under Recent changes.
188
+ - **"This and following"** in Google splits the series in two. Until the site
189
+ has "from this date on", the new Google series is linked to the same site
190
+ event as a later part. The split is listed for staff, and the site series
191
+ stays whole: its time and rule don't change from either side. Likewise, a
192
+ series Google ends sooner keeps its later dates on the site, listed for
193
+ staff to end or cancel.
194
+
195
+ ## Clashes
196
+
197
+ Changes are compared field by field against what the pair last agreed:
198
+
199
+ - Each side keeps what it changed.
200
+ - If both sides changed the same field, **the site wins**. Google's value is
201
+ kept in Recent changes and in the event editor's bar, with **Use Google's
202
+ version**.
203
+ - A change from Google that puts an event on a room another event holds still
204
+ applies, because it already happened in Google. The clash shows in Recent
205
+ changes and the event editor.
206
+
207
+ ## Booking pages (appointment schedules)
208
+
209
+ Google's API has no marker for appointments booked through a booking page.
210
+ Its event types don't include them, and open booking times aren't returned at
211
+ all, so those are never copied. Google copies the booking page's title into
212
+ each booked event. So a link names its booking pages ("Band", "Rehearsal
213
+ Time"), and an event with one of those titles (alone, or followed by
214
+ " (…)", " - …" or ": …") and a guest is treated as a booked appointment. So
215
+ is an event whose description links to a Google booking page
216
+ (`calendar.app.google`, `…/calendar/appointments/…`).
217
+
218
+ Booked appointments come in:
219
+
220
+ - titled "Booked" (in the site's main language), with no guests, place or
221
+ description;
222
+ - holding the link's room, with the internal tag `#google-booking`, which is
223
+ never shown publicly.
224
+
225
+ They're Google's: never written back, and deleting one on the site only
226
+ unpairs it.
227
+
228
+ Check this against a real booking before relying on it: book one and look
229
+ at it in Recent changes and on the site.
230
+
231
+ ## Safety
232
+
233
+ - **Nothing before review.** A new link only reads, until Start syncing.
234
+ - **Only the real site writes.** A link remembers the Site URL it was made
235
+ on, and runs only while Settings → Site URL matches. A restored backup, a
236
+ staging copy or a local copy with another Site URL pauses its links ("This
237
+ copy isn't the site that linked Google").
238
+ - A raw database copy keeps the same Site URL. Change the copy's Site URL,
239
+ or run it with another `SESSION_SECRET`, so it can't use the sign-in.
240
+ - **Restore** pauses every link until someone presses Resume. Resuming reads
241
+ Google again from scratch, pairing by the hidden links:
242
+ - Google's copies take the restored details, because the site is the main
243
+ calendar. That replaces changes made since the backup, on either side.
244
+ - Events Google has that the backup doesn't come back to the site: an older
245
+ backup never deletes anything in Google.
246
+ - **Environment sync and backups** never copy the Google account, links or
247
+ pairs. Nothing is stored on the event row.
248
+ - **Unlink** and **Disconnect** keep both copies of every event. Linking again
249
+ pairs them by their hidden links.
250
+ - **Loops**: each pair stores a hash of every synced field, per side, as last
251
+ agreed. A change that already matches is never sent back.
252
+ - **Failures** back off like YouTube's, and honour Google's rate limits:
253
+ - when Google refuses the saved sign-in, the account shows "Reconnect
254
+ Google" and changes wait in the queue;
255
+ - a link failing for an hour emails the owners and people who can manage
256
+ rooms and calendars, once;
257
+ - a Google "full sync required" (410) reads everything again quietly, with
258
+ no doubles and no pushes.
259
+ - **Recent changes** keeps every change the sync made, and its clashes, for
260
+ 90 days.
261
+
262
+ ## For site code
263
+
264
+ `@acms-subir/core/server`:
265
+
266
+ - `googleCopies(repo, eventId)`: an event's Google copies: calendar, link,
267
+ last sync, whether its description is Google's, an unresolved clash.
268
+ - `googleSyncHistory(repo, { eventId?, limit? })`: Recent changes.
269
+ - `syncGoogleCalendar(repo, { locals })`: run the sync once now.
270
+ - `GOOGLE_BOOKING_TAG`: `'#google-booking'`.
271
+
272
+ Inside the framework, the sync lives in `lib/calendar/google/`:
273
+
274
+ - `map.ts`: the field mapping, as pure functions;
275
+ - `outbound.ts`, `inbound.ts`, `match.ts`;
276
+ - `client.ts`: Google's API over `fetch`;
277
+ - `service.ts`: the scheduler job and Sync now.
278
+
279
+ Its tables (migration 69): `gcal_account`, `gcal_oauth_states`, `gcal_links`,
280
+ `gcal_items` (one row per pair), `gcal_queue` (filled by triggers on every
281
+ insert, update and delete of `events`) and `gcal_log`.
282
+
283
+ ## Not yet
284
+
285
+ - **Google push notifications** (`events.watch`) for near-instant updates.
286
+ Reading every 2 minutes is the mechanism.
287
+ - **"This and following"** as a real change on the site, once "from this date
288
+ on" exists. Until then, splits are listed and both Google series stay linked
289
+ to the one site event.
290
+
291
+ ## Troubleshooting
292
+
293
+ - **"Reconnect Google"**: Google refused the saved sign-in. It was revoked,
294
+ unused for 6 months, the project is still in "Testing", or a Workspace admin
295
+ policy applies. Press Reconnect; changes made meanwhile go out afterwards.
296
+ - **"This copy isn't the site that linked Google"**: Site URL differs from the
297
+ one the link was made on. Set it back, or unlink on this copy.
298
+ - **A Google calendar is missing from the picker**: the account needs "Make
299
+ changes to events" on it. Share it with the account, then **Refresh
300
+ calendars**.
301
+ - **Nothing syncs**: check that the scheduler runs. Calendar settings warns
302
+ when it hasn't run for a few minutes.
package/docs/server.md CHANGED
@@ -41,6 +41,12 @@ moveOccurrenceBack(repo: Repo, who: ServerUser | null | typeof GOOGLE_SYNC, even
41
41
  eventRoomClashes(repo: Repo, event: string | CalEvent, opts?: { horizonDays?: number; now?: Date | number }): Promise<EventClash[]>
42
42
  roomBusy(repo: Repo, q: { roomIds: string[]; from: Date | number; to: Date | number; ignore?: { eventId: string; key?: string } }): Promise<BusySlot[]>
43
43
 
44
+ // Google Calendar sync (acms docs google-calendar): read-only, names Google calendars, staff only
45
+ googleCopies(repo: Repo, eventId: string): Promise<GoogleCopy[]>
46
+ googleSyncHistory(repo: Repo, opts?: { eventId?: string; limit?: number }): Promise<{ id: number; at: number; eventId: string; direction: 'in' | 'out' | 'clash' | 'info'; action: string; title: string; summary: string; resolved: boolean }[]>
47
+ syncGoogleCalendar(repo: Repo, ctx: { locals: unknown }): Promise<number>
48
+ GOOGLE_BOOKING_TAG = '#google-booking'
49
+
44
50
  // event requests: `who` is currentUser()'s result (anything with `id` and `can()` works)
45
51
  listMyEvents(repo: Repo, who: ServerUser | null): Promise<CalEvent[]>
46
52
  listEventRequests(repo: Repo, who: ServerUser | null): Promise<CalEvent[]>
@@ -72,6 +78,7 @@ interface Hold { resourceId: string; eventId: string; key: string; title: string
72
78
  interface EventClash { hold: Hold; other: Hold }
73
79
  interface BusySlot { resourceId: string; start: number; end: number; kind: 'event' | 'booking' | 'block' | 'calendar'; title: string; eventId?: string; key?: string; bookingId?: string }
74
80
  interface WriteContext { url: URL; locals: unknown }
81
+ interface GoogleCopy { googleCalendar: string; googleCalendarId: string; htmlLink: string; syncedAt: number; descriptionFromGoogle: boolean; booking: boolean; clash: { field: string; site: string; google: string; at: number } | null }
75
82
  ```
76
83
 
77
84
  The types `Repo`, `Role`, `ServerUser`, `CalEvent`, `Occurrence`, `ListEventsOptions`,
package/integration.mjs CHANGED
@@ -888,6 +888,10 @@ export default function acms(options = {}) {
888
888
  ['/admin/calendar/events/[id]', 'admin/calendar/events/[id].astro'],
889
889
  ['/admin/calendar/calendars', 'admin/calendar/calendars.astro'],
890
890
  ['/admin/calendar/integrations', 'admin/calendar/integrations.astro'],
891
+ // two-way Google Calendar sync: the forms' handler, the first-match review, Google's sign-in callback
892
+ ['/admin/calendar/google', 'admin/calendar/google.astro'],
893
+ ['/admin/calendar/google/match', 'admin/calendar/google/match.astro'],
894
+ ['/api/calendar/google/oauth/callback', 'api/calendar/google/oauth/callback.ts'],
891
895
  ['/api/calendar/docs', 'api/calendar/docs.ts'],
892
896
  [`${calendar.bookBase}/manage/[token]`, 'book/manage/[token].astro'],
893
897
  // the booker's own booking from an app (token in the Booking-Token header)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.54",
3
+ "version": "0.3.55",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -29,7 +29,7 @@ function covered(c: LegalAdminState['covers']): string[] {
29
29
  ...(c.guestSignIn ? ['email-code sign-in in the app'] : []),
30
30
  ...(c.push ? ['the app and push notifications'] : []),
31
31
  ...(c.email ? [`email (${c.email})`] : []), ...(c.maps ? [`maps (${c.maps})`] : []),
32
- ...(c.live ? ['live video'] : []),
32
+ ...(c.live ? ['live video'] : []), ...(c.googleCalendar ? ['Google Calendar sync'] : []),
33
33
  ...(c.analytics.umami ? ['Umami statistics'] : []), ...(c.analytics.meta ? ['Meta Pixel'] : []), ...(c.analytics.tiktok ? ['TikTok Pixel'] : []),
34
34
  ...(c.ai ? ['AI writing tools'] : []),
35
35
  'embedded content and Google Fonts', 'cookies', 'service providers', 'retention', 'rights (including ARCO)', 'children',