@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.
- package/CHANGELOG.md +36 -0
- package/cli/main.mjs +2 -1
- package/cli/migrations.generated.mjs +59 -6
- package/docs/calendar.md +104 -2
- package/docs/google-calendar.md +302 -0
- package/docs/mobile.md +3 -1
- package/docs/server.md +58 -0
- package/integration.mjs +38 -0
- package/mobile-template/client/AGENTS.md +1 -0
- package/mobile-template/client/README.md +1 -1
- package/mobile-template/recipes/event-request/files/app/request.tsx +305 -0
- package/mobile-template/recipes/event-request/files/components/DayPicker.tsx +66 -0
- package/mobile-template/recipes/event-request/files/components/RequestEventLink.tsx +19 -0
- package/mobile-template/recipes/event-request/files/components/RequestEventRow.tsx +20 -0
- package/mobile-template/recipes/event-request/recipe.json +21 -0
- package/package.json +1 -1
- package/src/components/LegalSettings.tsx +1 -1
- package/src/components/MobileApp.tsx +2 -2
- package/src/components/editor/event/SingleDates.tsx +12 -2
- package/src/layouts/Admin.astro +7 -0
- 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/request-admin.ts +211 -0
- package/src/lib/calendar/request-config.ts +8 -0
- package/src/lib/calendar/request-mail.ts +158 -0
- package/src/lib/calendar/request-page.ts +52 -0
- package/src/lib/calendar/request-service.ts +535 -0
- package/src/lib/calendar/request-store.ts +2 -2
- package/src/lib/calendar/request-words.ts +263 -0
- package/src/lib/calendar/schedule.ts +8 -8
- package/src/lib/display/board.ts +3 -1
- package/src/lib/email/service.ts +17 -2
- package/src/lib/email/templates.ts +8 -0
- package/src/lib/legal/admin.ts +1 -1
- package/src/lib/legal/app-privacy.ts +2 -2
- package/src/lib/legal/facts.ts +7 -0
- package/src/lib/legal/text-en.ts +7 -0
- package/src/lib/legal/text-es.ts +7 -0
- package/src/lib/live/crypto.ts +7 -28
- package/src/lib/mobile/http.ts +2 -1
- package/src/lib/mobile/openapi.ts +8 -0
- package/src/lib/mobile/requests.ts +27 -0
- package/src/lib/mobile/schemas.ts +14 -3
- package/src/lib/mobile/site.ts +3 -0
- package/src/lib/push/types.ts +1 -1
- 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 +6 -0
- package/src/lib/schema.ts +43 -2
- package/src/lib/sealed.ts +46 -0
- package/src/pages/admin/calendar/events/[id].astro +42 -1
- package/src/pages/admin/calendar/events/index.astro +2 -0
- package/src/pages/admin/calendar/google/match.astro +160 -0
- package/src/pages/admin/calendar/google.astro +107 -0
- package/src/pages/admin/calendar/requests.astro +298 -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/pages/api/v1/requests/index.ts +6 -0
- package/src/pages/api/v1/requests/options.ts +7 -0
- package/src/pages/request/index.astro +182 -0
- package/src/server/index.ts +105 -1
- package/src/styles/site.css +29 -0
- package/src/virtual.d.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.56
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 191830f: Anyone can now ask for an event, or for a change to one, without an account. Turn it on with `acms({ requests: true })`; it needs the calendar.
|
|
8
|
+
|
|
9
|
+
- **On the website**, a form at `/request` (and `/es/request` and so on), in the visitor's language and themed through the site's tokens. It asks what they're planning, the kind, date and times, how it repeats, rooms (or "Not sure"), how many people, and anything else. "Change to an event" asks which event, optionally which date, and what should change. A live line says whether the chosen rooms look free then (free or busy only). `requests: { path: '/ask' }` moves it; `requests: { page: false }` drops it but keeps the rest, and a site file at the path replaces it.
|
|
10
|
+
- **In apps**, `GET /api/v1/requests/options` (open or closed, the kinds and requestable rooms, the reply time, notice and office phone, in the asked language) and `POST /api/v1/requests` (Idempotency-Key; returns `{ id }`; refusals carry a code such as `title`, `past`, `room`, `closed` or `busy`). `site.features.requests` says the site has it. 10 requests a minute and 20 a day per address, 5 a day per email address, plus the site's daily cap.
|
|
11
|
+
- **Calendar → Requests** lists requests from the app, the website and hosts on My events (new events, suggested changes and date moves, each answered there) in one place, with Pending, Approved and Declined tabs and, on each new request, what takes its rooms then (12 weeks for weekly ones). **Approve** makes a draft that's already filled in: title, times, weekly when asked, the kind's tag and calendar, and the rooms held while it's a draft. The person's details stay on the request, never on the event. Approving a change opens the event, or that date's Move… form. **Reply** and **Decline** (with an optional reason) email the person in the language they asked in. **Delete as spam** removes one without an email. The list shows 50 at a time. Reading the list needs "Create and edit content"; answering needs "Publish content".
|
|
12
|
+
- **Settings** on that page (needs "Manage rooms and calendars"): open or closed (closed forms and apps say so and show the office phone, and never disappear), the kinds with their label in each language, tag and calendar, the reply time and opening notice in each language, the office phone, who hears about new requests (else `members.notify`, else the owners), an optional push to their linked phones, the daily cap (100) and how long requests are kept (180 days after the answer, or after they arrive if nobody answers). Rooms marked **Anyone can check if it's free** are the ones people can choose.
|
|
13
|
+
- **Emails:** the person gets "We got your request" in their language. It never repeats what they typed, so the form can't be used to send someone else a message, and each address gets at most 3 a day. Staff get every detail, with Reply-To set to the person, and only while the request still waits.
|
|
14
|
+
- **Privacy:** the generated privacy notice gets an "Event requests" section (what's kept, why, and when it's deleted), and the app store answers list event requests under contact info and user content. Requests are in backups.
|
|
15
|
+
- **Server API** (`@acms-subir/core/server`): `requestOptions`, `submitRequest` (for a site's own form), `listRequests`, `approveRequest`, `declineRequest` and `replyToRequest`. See `npx acms docs calendar` and `npx acms docs server`.
|
|
16
|
+
|
|
17
|
+
Requests use the `event_requests` table that came with hosts' date moves (migration 68); this release adds no migration. After updating, a site that wants requests adds `requests: true`, marks the rooms people may ask for as **Anyone can check if it's free**, and sets the kinds, reply time and office phone in Calendar → Requests → Settings. Update the privacy notice and the app's store answers, which now include requests.
|
|
18
|
+
|
|
19
|
+
## 0.3.55
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- 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`.
|
|
24
|
+
|
|
25
|
+
- **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.
|
|
26
|
+
- **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.
|
|
27
|
+
- **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.
|
|
28
|
+
- **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.
|
|
29
|
+
- **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.
|
|
30
|
+
- **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**.
|
|
31
|
+
- **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.
|
|
32
|
+
- **"This and following"** edits in Google are listed for staff, and the site series stays whole until "from this date on" exists.
|
|
33
|
+
- **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.
|
|
34
|
+
- **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 }`.
|
|
35
|
+
- The privacy notice gets a Google Calendar section, in English and Spanish, once Google is connected.
|
|
36
|
+
|
|
37
|
+
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".
|
|
38
|
+
|
|
3
39
|
## 0.3.54
|
|
4
40
|
|
|
5
41
|
### 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
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
# Calendar: repeats, single dates, rooms and
|
|
1
|
+
# Calendar: repeats, single dates, rooms, clashes and requests
|
|
2
2
|
|
|
3
|
-
How events repeat, how one date of a series is changed,
|
|
3
|
+
How events repeat, how one date of a series is changed, how events hold rooms, and
|
|
4
|
+
how people ask for events.
|
|
4
5
|
Read it with `npx acms docs calendar`. The functions named here come from
|
|
5
6
|
`@acms-subir/core/server` (`npx acms docs server` has their signatures), and the app
|
|
6
7
|
API is in `docs/mobile-api-v1.md` in the framework repository.
|
|
@@ -286,3 +287,104 @@ A theme's `theme.css` loads after the board's own styles. The simplest way to re
|
|
|
286
287
|
```
|
|
287
288
|
|
|
288
289
|
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.
|
|
290
|
+
|
|
291
|
+
## Google Calendar sync
|
|
292
|
+
|
|
293
|
+
Calendar settings → **Synced with Google** keeps linked site calendars and Google
|
|
294
|
+
calendars in step, both ways, within a few minutes. The full guide is
|
|
295
|
+
`acms docs google-calendar`. What it means for the calendar features above:
|
|
296
|
+
|
|
297
|
+
- **Single dates.** A date deleted or moved in Google goes through `cancelOccurrence`
|
|
298
|
+
and `moveOccurrence` with the `GOOGLE_SYNC` actor, so RSVPs, room holds and pushes
|
|
299
|
+
follow as they do for staff. A taken room only warns. Cancelled and moved dates on the
|
|
300
|
+
site change that date's instance in Google.
|
|
301
|
+
- **Rooms.** A link can stand for one or more rooms: events from that Google calendar
|
|
302
|
+
hold them, and site events holding them go there. One site calendar can have a link
|
|
303
|
+
per room.
|
|
304
|
+
- **Deleted in Google** means **cancelled** on the site (struck through, room freed,
|
|
305
|
+
restorable), never deleted. The exception is an event with another linked copy: only
|
|
306
|
+
that link's room or calendar comes off it.
|
|
307
|
+
- **Cancelled on the site** shows in Google as "Cancelled: " in front of the title.
|
|
308
|
+
Unpublished or deleted on the site removes the Google copy.
|
|
309
|
+
- **Writes from the sync** keep staged edits staged (`updateEvent(id, data, { keepDraft: true })`).
|
|
310
|
+
Changes from Google push to members like an admin edit, except during the first match
|
|
311
|
+
and full resyncs (`quiet`).
|
|
312
|
+
- **Booked appointments** from Google booking pages come in as "Booked" with the
|
|
313
|
+
internal tag `#google-booking` (`GOOGLE_BOOKING_TAG`), holding the link's room. Treat
|
|
314
|
+
them as bookings: the tag never shows publicly.
|
|
315
|
+
- **"This and following"** in Google is listed for staff, and the site series stays whole
|
|
316
|
+
until "from this date on" exists.
|
|
317
|
+
- Site code can read an event's Google copies with `googleCopies(repo, eventId)`, and
|
|
318
|
+
Recent changes with `googleSyncHistory(repo)`.
|
|
319
|
+
|
|
320
|
+
## Requests without an account
|
|
321
|
+
|
|
322
|
+
`acms({ requests: true })` lets anyone ask for a new event, or for a change to one,
|
|
323
|
+
without an account:
|
|
324
|
+
|
|
325
|
+
- from the website's form at `/request` (and `/es/request` and so on);
|
|
326
|
+
- from an app (`GET /api/v1/requests/options`, `POST /api/v1/requests`);
|
|
327
|
+
- from the site's own form, through `submitRequest`.
|
|
328
|
+
|
|
329
|
+
Staff answer them on **Calendar → Requests**. That one list also shows hosts'
|
|
330
|
+
requests from My events. `requests: { path: '/ask' }` moves the form, and
|
|
331
|
+
`requests: { page: false }` drops it but keeps the API and the admin. A site file at
|
|
332
|
+
the form's path replaces it.
|
|
333
|
+
|
|
334
|
+
Each request is a row of its own (`RequestRecord`), not an event:
|
|
335
|
+
|
|
336
|
+
- `kind`:
|
|
337
|
+
- `'new'`: `data` has `type`, `title`, `start`, `end`, `repeat` (`once`, `weekly`
|
|
338
|
+
or `other` with `repeatText`), `roomIds`, `roomsUnsure` and `people`;
|
|
339
|
+
- `'change'`: `eventId`, plus `key` for one date (`''` = every date).
|
|
340
|
+
- `state`: `pending`, `approved`, `declined` or `withdrawn`.
|
|
341
|
+
- `source`: `app`, `web` or `member`.
|
|
342
|
+
- `requester`: `{ name, email, phone, locale }`, for a person without an account.
|
|
343
|
+
- `note`: their own words to staff.
|
|
344
|
+
- `reply`: staff's last answer.
|
|
345
|
+
|
|
346
|
+
Hosts' date moves (C3/C4) use the same table with kinds `move` and `from-date`.
|
|
347
|
+
|
|
348
|
+
**Settings** (Calendar → Requests → Settings, in `settings.calendar.requests`):
|
|
349
|
+
|
|
350
|
+
- **Open or closed.** Closed forms and apps say so and show the office phone; they never
|
|
351
|
+
disappear.
|
|
352
|
+
- **Kinds.** Each has a label per language, plus the tag and calendar an approved
|
|
353
|
+
draft gets.
|
|
354
|
+
- **Reply time and notice**, per language.
|
|
355
|
+
- **Office phone.**
|
|
356
|
+
- **Who hears about new requests.** Their email, else `members.notify`, else the owners.
|
|
357
|
+
Optionally a push to their linked phones, without names.
|
|
358
|
+
- **A daily cap** on new requests (100), on top of 10 a minute and 20 a day per
|
|
359
|
+
address and 5 a day per email address.
|
|
360
|
+
- **How long requests are kept** (180 days) after their answer, or after they
|
|
361
|
+
arrived if nobody answered. The scheduler deletes them then.
|
|
362
|
+
|
|
363
|
+
**Rooms** people can choose are the ones marked **Anyone can check if it’s free**
|
|
364
|
+
(`requestable`). The form's live "free then" line uses `GET /api/v1/rooms/free`, which
|
|
365
|
+
says free or busy, never what or who.
|
|
366
|
+
|
|
367
|
+
**Answering** (needs **Publish content**; reading the list needs **Create and edit
|
|
368
|
+
content**):
|
|
369
|
+
|
|
370
|
+
- **Approve** a new request: makes a draft that's already filled in. It has the
|
|
371
|
+
title and times, `FREQ=WEEKLY` when asked, the kind's tag and calendar (else the
|
|
372
|
+
default calendar), and the rooms held while it's a draft. The draft opens in the
|
|
373
|
+
editor. The person's details stay on the request, never on the event.
|
|
374
|
+
- **Approve** a change: opens the event, or that date's Move… form
|
|
375
|
+
(`/admin/calendar/events/<id>?move=<key>`).
|
|
376
|
+
- Both email the person, with an optional message, unless you untick it.
|
|
377
|
+
- **Decline**, with an optional reason, and **Reply** email the person in the
|
|
378
|
+
language they asked in. A reply leaves the request as it is. **Delete as spam**
|
|
379
|
+
removes a request from the app or the website without emailing anyone.
|
|
380
|
+
- Each row shows what takes its rooms then. Weekly requests are checked for 12
|
|
381
|
+
weeks.
|
|
382
|
+
|
|
383
|
+
**Emails.** The person gets "We got your request" in their language. It never
|
|
384
|
+
repeats what they typed (the title or message), so the form can't be used to send
|
|
385
|
+
someone else a message, and each address gets at most 3 a day. Staff get every
|
|
386
|
+
detail in the site's main language, with Reply-To set to the person.
|
|
387
|
+
|
|
388
|
+
**Privacy.** The generated privacy notice gets an "Event requests" section, and
|
|
389
|
+
the store answers list event requests under contact info and user content.
|
|
390
|
+
Requests are in backups.
|
|
@@ -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/mobile.md
CHANGED
|
@@ -114,7 +114,7 @@ Read the app's `AGENTS.md` too: it holds the same rules, next to the code.
|
|
|
114
114
|
|
|
115
115
|
| Hook | For |
|
|
116
116
|
| --- | --- |
|
|
117
|
-
| `useSite()` | name, menu, languages, features (`calendar`, `bookings`, `rsvp`, `blog`, `search`, `bookingChanges`, `guestSignIn`, `forms`, `links`, `live`) |
|
|
117
|
+
| `useSite()` | name, menu, languages, features (`calendar`, `bookings`, `rsvp`, `blog`, `search`, `bookingChanges`, `guestSignIn`, `forms`, `links`, `live`, `announcements`, `requests`) |
|
|
118
118
|
| `usePage(slug)` | a published page: `mode` (`sections`/`content`), `sections`, `body.html`; 404 when missing |
|
|
119
119
|
| `usePosts({ collection, limit, q })` / `usePost(slug)` | posts (infinite: `items`, `fetchNextPage`); `q` gives ranked search matches |
|
|
120
120
|
| `useSearch(text, { types, collection, limit })` | site-wide search: `data.posts`, `data.pages`, `data.events` (each with `snippet.text` + `snippet.highlights`), `data.more`; debounced, blank text fetches nothing |
|
|
@@ -128,6 +128,7 @@ Read the app's `AGENTS.md` too: it holds the same rules, next to the code.
|
|
|
128
128
|
| `useLive()` | live video: `snapshot`, `status` (`live`/`upcoming`/`offline`), `isLive`, localized `view` text, the owner's `display` choices (`style`, `bar`, `header`, `dock`). Polls every 30 s only in the foreground, rechecks on resume, drops an expired "live" even offline; idle while `features.live` is false. Render `<LiveCard>` / `<LiveTag>` |
|
|
129
129
|
| `useResources()` / `useAvailability({ resource, link })` / `useBooking()` | bookings |
|
|
130
130
|
| `useRoomFree({ room, start, end })` | bookings: is a requestable room free (no names) |
|
|
131
|
+
| `useRequestOptions()` / `useSubmitRequest()` | event requests without an account (`features.requests`): the form's kinds, rooms, words and office phone (`open: false` = say so and show the phone, never hide the screen), and sending one with its own Idempotency-Key; `problem` and `field` say what to fix |
|
|
131
132
|
| `useLocale()`, `useTheme()`, `useLayout()`, `useRefresh()`, `useOpenLink()` | language, tokens, layout, refresh, links |
|
|
132
133
|
| `usePushRegistration({ topics })` / `usePushTopics()` / `useNewPushTopics()` / `useNotificationTaps()` | push notifications (see Push notifications); topic names come in the app's language |
|
|
133
134
|
| `useAnnouncements({ kind, pinned })` / `useAnnouncement(id)` / `useAnnouncementState()` | announcements up right now; unread and closed banners kept on the phone (a dot, never a number); idle while `features.announcements` is false |
|
|
@@ -150,6 +151,7 @@ npx acms mobile recipe add news # posts list, article
|
|
|
150
151
|
npx acms mobile recipe add booking # resources → slots → form → confirmation (expo-crypto)
|
|
151
152
|
npx acms mobile recipe add push # notifications: turn on, topic toggles, taps open the page (expo-notifications, expo-device)
|
|
152
153
|
npx acms mobile recipe add announcements # list with kind tabs and unread dots, detail, home banners (site needs announcements on)
|
|
154
|
+
npx acms mobile recipe add event-request # "Request an event" screen, a More row and an events-list link, no account (site needs requests on; expo-crypto, expo-secure-store)
|
|
153
155
|
```
|
|
154
156
|
|
|
155
157
|
Each prints the `npx expo install …` line for its packages: run it (doctor
|