@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.
- package/CHANGELOG.md +20 -0
- package/cli/main.mjs +2 -1
- package/cli/migrations.generated.mjs +59 -6
- package/docs/calendar.md +29 -0
- package/docs/google-calendar.md +302 -0
- package/docs/server.md +7 -0
- package/integration.mjs +4 -0
- package/package.json +1 -1
- package/src/components/LegalSettings.tsx +1 -1
- package/src/lib/calendar/google/client.ts +167 -0
- package/src/lib/calendar/google/connect.ts +161 -0
- package/src/lib/calendar/google/context.ts +50 -0
- package/src/lib/calendar/google/inbound.ts +493 -0
- package/src/lib/calendar/google/links.ts +139 -0
- package/src/lib/calendar/google/map.ts +543 -0
- package/src/lib/calendar/google/match.ts +103 -0
- package/src/lib/calendar/google/migration.ts +71 -0
- package/src/lib/calendar/google/outbound.ts +272 -0
- package/src/lib/calendar/google/service.ts +193 -0
- package/src/lib/calendar/google/store.ts +241 -0
- package/src/lib/calendar/google/types.ts +188 -0
- package/src/lib/calendar/schedule.ts +8 -8
- package/src/lib/display/board.ts +3 -1
- package/src/lib/email/service.ts +11 -1
- package/src/lib/email/templates.ts +8 -0
- package/src/lib/legal/admin.ts +1 -1
- package/src/lib/legal/facts.ts +3 -0
- package/src/lib/legal/text-en.ts +4 -0
- package/src/lib/legal/text-es.ts +4 -0
- package/src/lib/live/crypto.ts +7 -28
- package/src/lib/repo/migrations.ts +4 -0
- package/src/lib/repo/sql.ts +18 -9
- package/src/lib/repo/types.ts +6 -2
- package/src/lib/scheduler.ts +3 -0
- package/src/lib/sealed.ts +46 -0
- package/src/pages/admin/calendar/events/[id].astro +42 -1
- package/src/pages/admin/calendar/google/match.astro +160 -0
- package/src/pages/admin/calendar/google.astro +107 -0
- package/src/pages/admin/calendar/settings.astro +273 -4
- package/src/pages/admin/integrations.astro +6 -0
- package/src/pages/api/calendar/google/oauth/callback.ts +16 -0
- 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
|
|
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 =
|
|
1391
|
+
const deadline = now6() + (opts.waitMs ?? 6e4);
|
|
1339
1392
|
let waited = false;
|
|
1340
1393
|
for (; ; ) {
|
|
1341
|
-
const t =
|
|
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, ?)", [
|
|
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,
|
|
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
|
@@ -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',
|