@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
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// v69: two-way Google Calendar sync (lib/calendar/google/). Nothing goes on
|
|
2
|
+
// the events row: environment sync copies event fields and backups carry
|
|
3
|
+
// them, and a pairing belongs to one site and one Google account. None of
|
|
4
|
+
// these tables are exported, backed up or copied by environment sync.
|
|
5
|
+
//
|
|
6
|
+
// - gcal_account: the one connected Google account (its own OAuth client,
|
|
7
|
+
// credentials sealed by lib/sealed.ts) and the calendars it can edit
|
|
8
|
+
// - gcal_oauth_states: one-use pending sign-ins (YouTube keeps its own)
|
|
9
|
+
// - gcal_links: a site calendar ⇄ a Google calendar, with its rooms, rules,
|
|
10
|
+
// sync token and the live module's lease/next_at/failures/revision fields
|
|
11
|
+
// - gcal_items: one row per pair (a site event ⇄ a Google event in one link),
|
|
12
|
+
// with the hashes of each side's fields as last agreed
|
|
13
|
+
// - gcal_queue: events waiting to go out, one row per event (coalesced),
|
|
14
|
+
// filled by the triggers below from every write path; `version` lets a
|
|
15
|
+
// worker finish only the change it handled
|
|
16
|
+
// - gcal_log: Recent changes, kept 90 days
|
|
17
|
+
//
|
|
18
|
+
// The triggers fire on every insert, update and delete of an event (no
|
|
19
|
+
// column list, so later events columns are covered without recreating them),
|
|
20
|
+
// except while a restore runs (webhook_control.suppressed) and on sites with
|
|
21
|
+
// no links. Loop prevention isn't a flag here: a change that already matches
|
|
22
|
+
// what the pair last agreed is never sent (lib/calendar/google/outbound.ts).
|
|
23
|
+
const now = "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
|
|
24
|
+
const guard = '(SELECT suppressed FROM webhook_control WHERE id=1)=0 AND EXISTS (SELECT 1 FROM gcal_links)';
|
|
25
|
+
const enqueue = (id: string) => `INSERT INTO gcal_queue (event_id,version,due_at,attempts,claimed_until,error) VALUES (${id},1,${now},0,0,'')
|
|
26
|
+
ON CONFLICT(event_id) DO UPDATE SET version=version+1,due_at=excluded.due_at,attempts=0,error='';`;
|
|
27
|
+
|
|
28
|
+
export const GCAL_MIGRATION = { version: 69, statements: [
|
|
29
|
+
`CREATE TABLE gcal_account (
|
|
30
|
+
id INTEGER PRIMARY KEY CHECK(id=1), client_id TEXT NOT NULL, encrypted TEXT NOT NULL, email TEXT NOT NULL DEFAULT '',
|
|
31
|
+
status TEXT NOT NULL DEFAULT 'setup', error TEXT NOT NULL DEFAULT '', site_url TEXT NOT NULL DEFAULT '',
|
|
32
|
+
calendars TEXT NOT NULL DEFAULT '[]', calendars_at INTEGER NOT NULL DEFAULT 0, connected_by TEXT NOT NULL DEFAULT '',
|
|
33
|
+
connected_at INTEGER NOT NULL DEFAULT 0, revision INTEGER NOT NULL DEFAULT 1, updated_at INTEGER NOT NULL DEFAULT 0
|
|
34
|
+
)`,
|
|
35
|
+
`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)`,
|
|
36
|
+
`CREATE TABLE gcal_links (
|
|
37
|
+
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 '',
|
|
38
|
+
direction TEXT NOT NULL DEFAULT 'both' CHECK(direction IN ('both','out','in')), room_ids TEXT NOT NULL DEFAULT '[]',
|
|
39
|
+
no_room INTEGER NOT NULL DEFAULT 0, rules TEXT NOT NULL DEFAULT '[]', booking_titles TEXT NOT NULL DEFAULT '[]', start_date TEXT NOT NULL,
|
|
40
|
+
state TEXT NOT NULL DEFAULT 'matching' CHECK(state IN ('matching','active','paused')), paused_reason TEXT NOT NULL DEFAULT '',
|
|
41
|
+
site_url TEXT NOT NULL, matched_at INTEGER NOT NULL DEFAULT 0, match TEXT,
|
|
42
|
+
sync_token TEXT NOT NULL DEFAULT '', full_sync INTEGER NOT NULL DEFAULT 1,
|
|
43
|
+
lease TEXT NOT NULL DEFAULT '', lease_until INTEGER NOT NULL DEFAULT 0, next_at INTEGER NOT NULL DEFAULT 0,
|
|
44
|
+
failures INTEGER NOT NULL DEFAULT 0, failing_since INTEGER NOT NULL DEFAULT 0, alerted_at INTEGER NOT NULL DEFAULT 0,
|
|
45
|
+
last_ok_at INTEGER NOT NULL DEFAULT 0, error TEXT NOT NULL DEFAULT '', revision INTEGER NOT NULL DEFAULT 1,
|
|
46
|
+
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
|
47
|
+
)`,
|
|
48
|
+
`CREATE TABLE gcal_items (
|
|
49
|
+
link_id TEXT NOT NULL, google_id TEXT NOT NULL, event_id TEXT NOT NULL, ical_uid TEXT NOT NULL DEFAULT '',
|
|
50
|
+
kind TEXT NOT NULL DEFAULT 'single' CHECK(kind IN ('series','single','segment')), segment_from TEXT NOT NULL DEFAULT '',
|
|
51
|
+
etag TEXT NOT NULL DEFAULT '', html_link TEXT NOT NULL DEFAULT '', site TEXT NOT NULL DEFAULT '{}', google TEXT NOT NULL DEFAULT '{}',
|
|
52
|
+
desc_google INTEGER NOT NULL DEFAULT 0, booking INTEGER NOT NULL DEFAULT 0, gone INTEGER NOT NULL DEFAULT 0, conflict TEXT,
|
|
53
|
+
synced_at INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL,
|
|
54
|
+
PRIMARY KEY (link_id, google_id), UNIQUE (link_id, event_id, segment_from)
|
|
55
|
+
)`,
|
|
56
|
+
`CREATE INDEX gcal_items_event ON gcal_items (event_id)`,
|
|
57
|
+
`CREATE TABLE gcal_queue (
|
|
58
|
+
event_id TEXT PRIMARY KEY, version INTEGER NOT NULL DEFAULT 1, due_at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0,
|
|
59
|
+
claimed_until INTEGER NOT NULL DEFAULT 0, error TEXT NOT NULL DEFAULT ''
|
|
60
|
+
)`,
|
|
61
|
+
`CREATE TABLE gcal_log (
|
|
62
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, at INTEGER NOT NULL, link_id TEXT NOT NULL DEFAULT '', event_id TEXT NOT NULL DEFAULT '',
|
|
63
|
+
google_id TEXT NOT NULL DEFAULT '', direction TEXT NOT NULL CHECK(direction IN ('in','out','clash','info')), action TEXT NOT NULL,
|
|
64
|
+
title TEXT NOT NULL DEFAULT '', summary TEXT NOT NULL DEFAULT '', data TEXT, resolved INTEGER NOT NULL DEFAULT 0
|
|
65
|
+
)`,
|
|
66
|
+
`CREATE INDEX gcal_log_at ON gcal_log (at)`,
|
|
67
|
+
`CREATE INDEX gcal_log_event ON gcal_log (event_id, at)`,
|
|
68
|
+
`CREATE TRIGGER gcal_queue_insert AFTER INSERT ON events WHEN ${guard} BEGIN ${enqueue('NEW.id')} END`,
|
|
69
|
+
`CREATE TRIGGER gcal_queue_update AFTER UPDATE ON events WHEN ${guard} BEGIN ${enqueue('NEW.id')} END`,
|
|
70
|
+
`CREATE TRIGGER gcal_queue_delete AFTER DELETE ON events WHEN ${guard} BEGIN ${enqueue('OLD.id')} END`,
|
|
71
|
+
] };
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// Site → Google. Every write to an event, from any path, queues it
|
|
2
|
+
// (gcal_queue, filled by triggers); each run works the queue and, per link,
|
|
3
|
+
// makes the Google copy match: created when the event starts belonging to the
|
|
4
|
+
// link (published, in its calendar, holding its room), patched field by field
|
|
5
|
+
// when the site changed something since the pair last agreed, and deleted
|
|
6
|
+
// when it stops belonging (unpublished, deleted, another calendar or room:
|
|
7
|
+
// Q7). A cancelled event stays in Google with "Cancelled: " in front of its
|
|
8
|
+
// title. Only the fields the sync owns are patched; guests are never added
|
|
9
|
+
// and Google never emails anyone (sendUpdates=none).
|
|
10
|
+
//
|
|
11
|
+
// A change that already matches what the pair agreed is never sent, which is
|
|
12
|
+
// what stops loops: the site's write of a change that came from Google leaves
|
|
13
|
+
// nothing to send back to that link (only to the event's other links).
|
|
14
|
+
import { hasTag, type CalEvent } from '../../schema';
|
|
15
|
+
import type { SyncContext } from './context';
|
|
16
|
+
import { entry } from './context';
|
|
17
|
+
import { GoogleError } from './client';
|
|
18
|
+
import { applyGoogleChange } from './inbound';
|
|
19
|
+
import {
|
|
20
|
+
BOOKING_TAG, NONE, changed, dateHash, fieldHashes, fieldWord, googleBody, googleFields, googleIdFor, googlePatch, inWindow, instanceBody,
|
|
21
|
+
instanceState, keyStart, linkCarries, originalStart, siteFields, windowStart, type DateState, type Fields,
|
|
22
|
+
} from './map';
|
|
23
|
+
import type { GcalItem, GcalLink, GoogleEvent, Hashes, NewLogEntry } from './types';
|
|
24
|
+
|
|
25
|
+
const LEASE = 5 * 60_000;
|
|
26
|
+
/** a change Google kept refusing is dropped (and listed) after this many runs */
|
|
27
|
+
const MAX_ATTEMPTS = 8;
|
|
28
|
+
|
|
29
|
+
/** Works the outgoing queue. Returns how many events it finished. Throws GoogleError('auth') when Google refused the sign-in (everything waits). */
|
|
30
|
+
export async function runOutbound(ctx: SyncContext, limit = 25): Promise<number> {
|
|
31
|
+
const rows = await ctx.repo.gcal.claimQueue(ctx.now, limit, LEASE);
|
|
32
|
+
let done = 0;
|
|
33
|
+
for (let i = 0; i < rows.length; i++) {
|
|
34
|
+
const row = rows[i];
|
|
35
|
+
try {
|
|
36
|
+
await pushEvent(ctx, row.eventId);
|
|
37
|
+
await ctx.repo.gcal.finishQueue(row.eventId, row.version);
|
|
38
|
+
done++;
|
|
39
|
+
} catch (e) {
|
|
40
|
+
const g = e instanceof GoogleError ? e : null;
|
|
41
|
+
const message = g?.message ?? 'The change couldn’t be sent to Google.';
|
|
42
|
+
if (!g) console.error('acms google sync: outgoing change failed', e);
|
|
43
|
+
if (g?.code === 'auth' || g?.code === 'quota') {
|
|
44
|
+
// the rest of this batch waits too: Google refuses everything for now
|
|
45
|
+
const at = ctx.now + (g.code === 'quota' ? g.retryAfter : 60_000);
|
|
46
|
+
for (const r of rows.slice(i)) await ctx.repo.gcal.retryQueue(r.eventId, r.version, at, message);
|
|
47
|
+
if (g.code === 'auth') throw e;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
if (row.attempts >= MAX_ATTEMPTS) {
|
|
51
|
+
const event = await ctx.repo.getEvent(row.eventId).catch(() => null);
|
|
52
|
+
await ctx.repo.gcal.log([{ direction: 'out', action: 'failed', eventId: row.eventId, title: event?.title ?? '', summary: `Not sent to Google after ${MAX_ATTEMPTS} tries: ${message} Edit the event to try again.` }], ctx.now);
|
|
53
|
+
await ctx.repo.gcal.finishQueue(row.eventId, row.version);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
await ctx.repo.gcal.retryQueue(row.eventId, row.version, ctx.now + Math.min(3600_000, 60_000 * 2 ** row.attempts), message);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return done;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* One event against every link that may carry it. Each link is tried on its
|
|
64
|
+
* own: one link's error doesn't hold up the others (the event is retried, and
|
|
65
|
+
* the links already done have nothing left to send). Google refusing one
|
|
66
|
+
* event (403: someone else's event that only invites the calendar) is listed
|
|
67
|
+
* once and left as it is in Google.
|
|
68
|
+
*/
|
|
69
|
+
export async function pushEvent(ctx: SyncContext, eventId: string) {
|
|
70
|
+
const e = await ctx.repo.getEvent(eventId);
|
|
71
|
+
const items = await ctx.repo.gcal.itemsForEvent(eventId);
|
|
72
|
+
let failure: unknown = null;
|
|
73
|
+
for (const link of ctx.links) {
|
|
74
|
+
if (link.state !== 'active' || link.direction === 'in') continue;
|
|
75
|
+
const mine = items.filter((i) => i.linkId === link.id);
|
|
76
|
+
try { await pushToLink(ctx, link, e, mine); }
|
|
77
|
+
catch (err) {
|
|
78
|
+
if (err instanceof GoogleError && (err.code === 'auth' || err.code === 'quota')) throw err;
|
|
79
|
+
if (err instanceof GoogleError && err.code === 'forbidden') { await refused(ctx, link, e, mine.find((i) => i.kind !== 'segment'), err); continue; }
|
|
80
|
+
failure ??= err;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (failure) throw failure;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function pushToLink(ctx: SyncContext, link: GcalLink, e: CalEvent | null, mine: GcalItem[]) {
|
|
87
|
+
const main = mine.find((i) => i.kind !== 'segment');
|
|
88
|
+
const carried = !!e && linkCarries(e, link);
|
|
89
|
+
// a booked appointment is Google's: never written back, only unpaired
|
|
90
|
+
if (main?.booking) { if (!carried) await unpair(ctx, link, main, e); return; }
|
|
91
|
+
if (!carried) { if (main) await removeCopy(ctx, link, main, mine, e); return; }
|
|
92
|
+
if (!main) {
|
|
93
|
+
if (!hasTag(e.tags, BOOKING_TAG) && inWindow(e, windowStart(link, ctx.settings), ctx.settings)) await createCopy(ctx, link, e);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
// deleted from this Google calendar while the event stays in its other copies: it stays out
|
|
97
|
+
if (main.leftOut) return;
|
|
98
|
+
// deleted in Google (and so cancelled here, Q6): made again only once the site brings it back
|
|
99
|
+
if (main.gone) { if (!e.cancelled) await createCopy(ctx, link, e, main); return; }
|
|
100
|
+
await updateCopy(ctx, link, e, main, mine.some((i) => i.kind === 'segment'));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Google won't let the account change this event there: listed once, and the site's change counts as seen (no retries). */
|
|
104
|
+
async function refused(ctx: SyncContext, link: GcalLink, e: CalEvent | null, item: GcalItem | undefined, err: GoogleError) {
|
|
105
|
+
if (item && e) await ctx.repo.gcal.patchItem(link.id, item.googleId, { site: fieldHashes(siteFields(e, link, ctx.map)), syncedAt: ctx.now });
|
|
106
|
+
await ctx.repo.gcal.log([entry(link, item ?? (e ? { googleId: '', eventId: e.id } : null), e?.title ?? '', { direction: 'out', action: 'failed', summary: `${err.message} The change stays on the site; ${link.googleName} keeps its copy as it is.` })], ctx.now);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const removedWhy = (e: CalEvent | null, link: GcalLink) =>
|
|
110
|
+
!e ? 'Deleted on the site' : e.status !== 'published' ? 'Unpublished on the site' : !e.calendarIds.includes(link.calendarId) ? 'Taken out of the linked calendar' : 'No longer holds this calendar’s room';
|
|
111
|
+
|
|
112
|
+
async function unpair(ctx: SyncContext, link: GcalLink, item: GcalItem, e: CalEvent | null) {
|
|
113
|
+
await ctx.repo.gcal.deleteItem(link.id, item.googleId);
|
|
114
|
+
await ctx.repo.gcal.log([entry(link, item, e?.title ?? '', { direction: 'out', action: 'unpaired', summary: `${removedWhy(e, link)}. The booking stays in Google.` })], ctx.now);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function removeCopy(ctx: SyncContext, link: GcalLink, item: GcalItem, all: GcalItem[], e: CalEvent | null) {
|
|
118
|
+
// the event's later parts from a Google split go with it
|
|
119
|
+
for (const i of all) {
|
|
120
|
+
if (!i.gone) await ctx.client.deleteEvent(link.googleId, i.googleId);
|
|
121
|
+
await ctx.repo.gcal.deleteItem(link.id, i.googleId);
|
|
122
|
+
}
|
|
123
|
+
if (!item.gone) await ctx.repo.gcal.log([entry(link, item, e?.title ?? '', { direction: 'out', action: 'removed', summary: `${removedWhy(e, link)}: removed from ${link.googleName}.` })], ctx.now);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The site's changed fields worth sending: rules no longer on the link are
|
|
128
|
+
* forgotten, and a rule just added to it (no agreed value on either side yet)
|
|
129
|
+
* starts from what each side has, like the first match, without a write.
|
|
130
|
+
*/
|
|
131
|
+
function pendingSite(cur: Hashes, item: GcalItem, link: GcalLink, gh: Hashes) {
|
|
132
|
+
const site = { ...item.site }, google = { ...item.google };
|
|
133
|
+
const rules = new Set(link.rules.map((r) => `rule:${r.prefix}`));
|
|
134
|
+
for (const n of Object.keys({ ...site, ...cur })) {
|
|
135
|
+
if (!n.startsWith('rule:')) continue;
|
|
136
|
+
if (!rules.has(n)) { delete site[n]; delete google[n]; continue; }
|
|
137
|
+
if (site[n] === undefined) { site[n] = cur[n]; if (gh[n] !== undefined) google[n] = gh[n]; }
|
|
138
|
+
}
|
|
139
|
+
return { item: { ...item, site, google }, ch: changed(cur, site, (n) => !n.startsWith('rule:') || rules.has(n)) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Google's hash names a patch overwrote. */
|
|
143
|
+
function written(patch: Record<string, unknown>, rules: GcalLink['rules']): string[] {
|
|
144
|
+
const out: string[] = [];
|
|
145
|
+
if ('summary' in patch) out.push('title', ...rules.map((r) => `rule:${r.prefix}`));
|
|
146
|
+
if ('description' in patch) out.push('desc');
|
|
147
|
+
if ('location' in patch) out.push('place');
|
|
148
|
+
if ('start' in patch) out.push('when');
|
|
149
|
+
if ('recurrence' in patch) out.push('repeat');
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
const masterHashes = (h: Hashes) => Object.fromEntries(Object.entries(h).filter(([k]) => !k.startsWith('date:')));
|
|
153
|
+
|
|
154
|
+
/** A new Google copy (or a new one after Google deleted the old: `previous`), with the series' changed dates. */
|
|
155
|
+
export async function createCopy(ctx: SyncContext, link: GcalLink, e: CalEvent, previous?: GcalItem) {
|
|
156
|
+
const f = siteFields(e, link, ctx.map);
|
|
157
|
+
const body = googleBody(e, f, link, ctx.map, { desc: true });
|
|
158
|
+
const id = googleIdFor(e.id, previous ? Math.floor(ctx.now / 1000) : 0);
|
|
159
|
+
let g: GoogleEvent;
|
|
160
|
+
try { g = await ctx.client.insertEvent(link.googleId, id ? { id, ...body } : body); }
|
|
161
|
+
catch (err) {
|
|
162
|
+
if (!(err instanceof GoogleError && err.code === 'conflict' && id)) throw err;
|
|
163
|
+
// that id is taken: our own copy from a run that stopped before saving the pair, or one Google deleted (ids stay taken)
|
|
164
|
+
const found = await ctx.client.getEvent(link.googleId, id).catch(() => null);
|
|
165
|
+
g = found && found.status !== 'cancelled' && found.extendedProperties?.private?.acmsEventId === e.id
|
|
166
|
+
? await ctx.client.patchEvent(link.googleId, id, body)
|
|
167
|
+
: await ctx.client.insertEvent(link.googleId, body);
|
|
168
|
+
}
|
|
169
|
+
const gf = googleFields(g, link, ctx.settings);
|
|
170
|
+
const google: Hashes = gf ? fieldHashes(gf) : {};
|
|
171
|
+
const notes: NewLogEntry[] = [];
|
|
172
|
+
const inSiteZone = googleFields(g, link, ctx.settings, f.when.tz);
|
|
173
|
+
if (gf && inSiteZone) for (const [key, state] of Object.entries(f.dates)) {
|
|
174
|
+
if (keyStart(key, f.when, ctx.settings) <= ctx.now) continue;
|
|
175
|
+
const after = await writeDate(ctx, link, g.id, key, state, f, inSiteZone);
|
|
176
|
+
if (after === null) notes.push(entry(link, { googleId: g.id, eventId: e.id }, e.title, { direction: 'info', action: 'date', summary: `Google has no ${key.replace('T', ' ')} in this series, so that date’s change stays on the site only.` }));
|
|
177
|
+
else google[`date:${key}`] = dateHash(after);
|
|
178
|
+
}
|
|
179
|
+
const item: GcalItem = {
|
|
180
|
+
linkId: link.id, googleId: g.id, eventId: e.id, icalUid: g.iCalUID ?? '', kind: e.rrule ? 'series' : 'single', segmentFrom: '', etag: g.etag ?? '', htmlLink: g.htmlLink ?? '',
|
|
181
|
+
site: fieldHashes(f), google, descGoogle: false, booking: false, gone: false, leftOut: false, conflict: null, syncedAt: ctx.now, createdAt: previous?.createdAt ?? ctx.now,
|
|
182
|
+
};
|
|
183
|
+
if (previous) await ctx.repo.gcal.replaceItem(previous.googleId, item); else await ctx.repo.gcal.putItem(item);
|
|
184
|
+
await ctx.repo.gcal.log([entry(link, item, e.title, { direction: 'out', action: 'created',
|
|
185
|
+
summary: previous ? `Back on the site: made again in ${link.googleName}.` : `Copied to ${link.googleName}${e.cancelled ? ' as cancelled' : ''}.` }), ...notes], ctx.now);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Sets one date of the Google series; returns its state there afterwards (null: Google has no such date). */
|
|
189
|
+
async function writeDate(ctx: SyncContext, link: GcalLink, masterId: string, key: string, want: DateState | 'none', site: Fields, google: Fields): Promise<DateState | 'none' | null> {
|
|
190
|
+
const missing = (e: unknown) => { if (e instanceof GoogleError && (e.code === 'notfound' || e.code === 'gone' || e.code === 'invalid')) return null; throw e; };
|
|
191
|
+
const start = originalStart(key, site.when);
|
|
192
|
+
// asked by its original start (documented); else by the id Google is seen to give dates ("<series>_<UTC start>", "<series>_<date>")
|
|
193
|
+
const inst = await ctx.client.instance(link.googleId, masterId, start).catch(missing)
|
|
194
|
+
?? await ctx.client.getEvent(link.googleId, `${masterId}_${start.replace(/[-:]/g, '')}`).catch(missing);
|
|
195
|
+
if (!inst || inst.recurringEventId !== masterId) return null;
|
|
196
|
+
const before = instanceState(inst, google, key);
|
|
197
|
+
if (want === 'cancelled') {
|
|
198
|
+
if (before !== 'cancelled') await ctx.client.deleteEvent(link.googleId, inst.id);
|
|
199
|
+
return 'cancelled';
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
return instanceState(await ctx.client.patchEvent(link.googleId, inst.id, instanceBody(want, site, key)), google, key);
|
|
203
|
+
} catch (e) {
|
|
204
|
+
// a date Google deleted may not come back (its instance stays cancelled there)
|
|
205
|
+
if (e instanceof GoogleError && (e.code === 'notfound' || e.code === 'gone') && before === 'cancelled') return 'cancelled';
|
|
206
|
+
throw e;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Sends the site's changes since the pair last agreed (Google's own changes are taken first, field by field). */
|
|
211
|
+
async function updateCopy(ctx: SyncContext, link: GcalLink, e: CalEvent, item: GcalItem, split: boolean) {
|
|
212
|
+
let f = siteFields(e, link, ctx.map);
|
|
213
|
+
if (!pendingSite(fieldHashes(f), item, link, {}).ch.length) return;
|
|
214
|
+
let g: GoogleEvent | null = await ctx.client.getEvent(link.googleId, item.googleId).catch((err) => {
|
|
215
|
+
if (err instanceof GoogleError && (err.code === 'notfound' || err.code === 'gone')) return null;
|
|
216
|
+
throw err;
|
|
217
|
+
});
|
|
218
|
+
if (!g || g.status === 'cancelled') {
|
|
219
|
+
// deleted in Google meanwhile: the incoming side applies that (Q6); a site → Google link puts the copy back
|
|
220
|
+
if (link.direction === 'out') await createCopy(ctx, link, e, item);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
let gf = googleFields(g, link, ctx.settings);
|
|
224
|
+
// Google changed fields too: take those first (the site wins where both changed, Q4), then send what's left
|
|
225
|
+
if (link.direction === 'both' && gf && changed(masterHashes(fieldHashes(gf)), masterHashes(item.google)).length) {
|
|
226
|
+
await applyGoogleChange(ctx, link, item, g);
|
|
227
|
+
const [again, fresh] = await Promise.all([ctx.repo.gcal.getItem(link.id, item.googleId), ctx.repo.getEvent(e.id)]);
|
|
228
|
+
if (!again || !fresh || again.gone) return;
|
|
229
|
+
item = again; e = fresh; f = siteFields(e, link, ctx.map);
|
|
230
|
+
}
|
|
231
|
+
const cur = fieldHashes(f);
|
|
232
|
+
const pending = pendingSite(cur, item, link, gf ? fieldHashes(gf) : {});
|
|
233
|
+
item = pending.item;
|
|
234
|
+
const ch = pending.ch;
|
|
235
|
+
if (!ch.length) { await ctx.repo.gcal.patchItem(link.id, item.googleId, { site: item.site, google: item.google }); return; }
|
|
236
|
+
const notes: NewLogEntry[] = [];
|
|
237
|
+
let descGoogle = item.descGoogle;
|
|
238
|
+
if (descGoogle && ch.includes('desc')) {
|
|
239
|
+
descGoogle = false;
|
|
240
|
+
notes.push(entry(link, item, e.title, { direction: 'info', action: 'description', summary: 'Description edited on the site: from now on Google gets the site’s text (Q8).' }));
|
|
241
|
+
}
|
|
242
|
+
// a series Google split ("this and following") keeps its time and rule until the site has "from this date on"
|
|
243
|
+
const held: string[] = split ? ch.filter((n) => n === 'when' || n === 'repeat') : [];
|
|
244
|
+
if (held.length) notes.push(entry(link, item, e.title, { direction: 'info', action: 'split', summary: `${held.map(fieldWord).join(' and ')} changed on the site, but Google split this series (“this and following”), so ${held.length > 1 ? 'they weren’t' : 'it wasn’t'} sent. Change the later part in Google, or both by hand.` }));
|
|
245
|
+
const fields = ch.filter((n) => !n.startsWith('date:') && !held.includes(n) && !((n === 'desc' || n === 'link') && descGoogle));
|
|
246
|
+
const patch = fields.length ? googlePatch(e, f, link, ctx.map, fields, { desc: !descGoogle, googleSummary: g.summary ?? '' }) : {};
|
|
247
|
+
if (Object.keys(patch).length) g = await ctx.client.patchEvent(link.googleId, item.googleId, patch);
|
|
248
|
+
gf = googleFields(g, link, ctx.settings) ?? gf;
|
|
249
|
+
const gh = gf ? fieldHashes(gf) : {};
|
|
250
|
+
const google: Hashes = { ...item.google };
|
|
251
|
+
for (const n of written(patch, link.rules)) if (gh[n] !== undefined) google[n] = gh[n];
|
|
252
|
+
|
|
253
|
+
// single dates, once the series itself is right (read in the site event's zone, where its keys are)
|
|
254
|
+
const inSiteZone = googleFields(g, link, ctx.settings, f.when.tz);
|
|
255
|
+
if (!split && inSiteZone) for (const n of ch.filter((x) => x.startsWith('date:'))) {
|
|
256
|
+
const key = n.slice(5);
|
|
257
|
+
if (keyStart(key, f.when, ctx.settings) <= ctx.now) continue;
|
|
258
|
+
const want = f.dates[key] ?? 'none';
|
|
259
|
+
const after = await writeDate(ctx, link, item.googleId, key, want, f, inSiteZone);
|
|
260
|
+
if (after === null) { notes.push(entry(link, item, e.title, { direction: 'info', action: 'date', summary: `Google has no ${key.replace('T', ' ')} in this series, so that date’s change stays on the site only.` })); continue; }
|
|
261
|
+
const h = dateHash(after);
|
|
262
|
+
if (h === NONE) delete google[n]; else google[n] = h;
|
|
263
|
+
}
|
|
264
|
+
const site: Hashes = { ...item.site };
|
|
265
|
+
for (const n of ch) { if (cur[n] === undefined) delete site[n]; else site[n] = cur[n]; }
|
|
266
|
+
// the clash note stays until the site edits that field again (sending the site's winning value isn't an edit)
|
|
267
|
+
const conflict = !item.conflict || !ch.includes(item.conflict.field) ? item.conflict : item.conflict.sent ? null : { ...item.conflict, sent: true };
|
|
268
|
+
await ctx.repo.gcal.patchItem(link.id, item.googleId, { site, google, descGoogle, conflict, etag: g.etag ?? item.etag, htmlLink: g.htmlLink ?? item.htmlLink, kind: e.rrule ? 'series' : 'single', syncedAt: ctx.now });
|
|
269
|
+
const sent = [...new Set(ch.filter((n) => !held.includes(n)).map((n) => n.startsWith('date:') ? 'dates' : n.startsWith('rule:') ? 'title' : n === 'status' ? (e.cancelled ? 'cancelled' : 'restored') : n === 'link' ? 'desc' : n))];
|
|
270
|
+
if (sent.length) notes.unshift(entry(link, item, e.title, { direction: 'out', action: 'updated', summary: `${sent.map((n) => n === 'dates' ? 'Single dates' : n === 'cancelled' ? 'Cancelled' : n === 'restored' ? 'No longer cancelled' : fieldWord(n)).join(', ')} → ${link.googleName}` }));
|
|
271
|
+
await ctx.repo.gcal.log(notes, ctx.now);
|
|
272
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// The Google Calendar sync as a whole: the scheduler job (outgoing changes on
|
|
2
|
+
// every tick, each link's incoming changes every 2 minutes), "Sync now",
|
|
3
|
+
// failures (backoff as YouTube does; staff get an email once a link has
|
|
4
|
+
// failed for an hour), and what the admin shows.
|
|
5
|
+
import { ApiError } from '../../api';
|
|
6
|
+
import type { ContentRepository } from '../../repo/types';
|
|
7
|
+
import { principalCan } from '../../permissions/access';
|
|
8
|
+
import { kickEmail } from '../../email/service';
|
|
9
|
+
import { waitUntilFrom } from '../../webhooks';
|
|
10
|
+
import { GoogleError } from './client';
|
|
11
|
+
import { accountClient, CALLBACK_PATH, gcalSeal, markReconnect, siteOriginOf } from './connect';
|
|
12
|
+
import { siteContext, syncContext, type SyncContext } from './context';
|
|
13
|
+
import { syncIncoming, useGoogleValue } from './inbound';
|
|
14
|
+
import { PAUSE_WORDS, siteGuard } from './links';
|
|
15
|
+
import { buildMatch, startSyncing } from './match';
|
|
16
|
+
import { runOutbound } from './outbound';
|
|
17
|
+
import type { GcalLink } from './types';
|
|
18
|
+
|
|
19
|
+
const READ_EVERY = 120_000;
|
|
20
|
+
const ALERT_AFTER = 3600_000;
|
|
21
|
+
|
|
22
|
+
function delayFor(e: unknown, failures: number) {
|
|
23
|
+
if (e instanceof GoogleError && e.code === 'quota') return Math.max(e.retryAfter, 600_000);
|
|
24
|
+
if (e instanceof GoogleError && (e.code === 'auth' || e.code === 'forbidden')) return 600_000;
|
|
25
|
+
return Math.min(3600_000, 60_000 * 2 ** Math.min(failures, 6));
|
|
26
|
+
}
|
|
27
|
+
const messageOf = (e: unknown) => e instanceof GoogleError || e instanceof ApiError ? e.message : 'The sync didn’t finish. It tries again shortly.';
|
|
28
|
+
|
|
29
|
+
async function noteFailure(repo: ContentRepository, link: GcalLink, e: unknown, now: number, lease?: string) {
|
|
30
|
+
const failures = link.failures + 1;
|
|
31
|
+
await repo.gcal.patchLink(link.id, { failures, failingSince: link.failingSince || now, error: messageOf(e), nextAt: now + delayFor(e, failures) + Math.floor(Math.random() * 5000) }, link.revision, lease);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The sync's scheduler job. Returns how many changes it handled. */
|
|
35
|
+
export async function syncGoogle(repo: ContentRepository, locals?: unknown, opts: { now?: number } = {}): Promise<number> {
|
|
36
|
+
const now = opts.now ?? Date.now();
|
|
37
|
+
let links = await repo.gcal.listLinks();
|
|
38
|
+
if (!links.length) return 0;
|
|
39
|
+
const settings = await repo.getSettings();
|
|
40
|
+
// a link only runs on the site that made it (a copy elsewhere must not write into the real calendar)
|
|
41
|
+
for (const l of links) if (l.state !== 'paused' && siteGuard(l, settings)) await repo.gcal.patchLink(l.id, { state: 'paused', pausedReason: 'other-site' }, l.revision);
|
|
42
|
+
await repo.gcal.purgeLog(now);
|
|
43
|
+
links = await repo.gcal.listLinks();
|
|
44
|
+
const active = links.filter((l) => l.state === 'active');
|
|
45
|
+
if (!active.length) return 0;
|
|
46
|
+
let ctx: SyncContext;
|
|
47
|
+
try {
|
|
48
|
+
const { client } = await accountClient(repo, AbortSignal.timeout(110_000), locals);
|
|
49
|
+
ctx = syncContext(repo, client, settings, siteOriginOf(settings.siteUrl)!, links, locals, now);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
// no usable sign-in: changes wait in the queue, and staff hear after an hour
|
|
52
|
+
for (const l of active) if (l.nextAt <= now) await noteFailure(repo, l, e, now);
|
|
53
|
+
await alertFailing(repo, locals, now);
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
let handled = 0;
|
|
57
|
+
try {
|
|
58
|
+
handled += await runOutbound(ctx);
|
|
59
|
+
} catch (e) {
|
|
60
|
+
if (e instanceof GoogleError && e.code === 'auth') {
|
|
61
|
+
await markReconnect(repo, e.message);
|
|
62
|
+
for (const l of active) await noteFailure(repo, l, e, now);
|
|
63
|
+
await alertFailing(repo, locals, now);
|
|
64
|
+
return handled;
|
|
65
|
+
}
|
|
66
|
+
throw e;
|
|
67
|
+
}
|
|
68
|
+
for (const l of active) {
|
|
69
|
+
if (l.direction === 'out') {
|
|
70
|
+
// nothing to read: it's healthy while its changes go out
|
|
71
|
+
if (l.error || l.lastOkAt < now - READ_EVERY) await repo.gcal.patchLink(l.id, { failures: 0, failingSince: 0, error: '', lastOkAt: now }, l.revision);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
// the real clock, not the run's start: earlier jobs and the outgoing changes took time
|
|
75
|
+
const claimed = await repo.gcal.claimLink(l.id, Math.max(now, Date.now()));
|
|
76
|
+
if (!claimed) continue;
|
|
77
|
+
try {
|
|
78
|
+
const r = await syncIncoming(ctx, claimed);
|
|
79
|
+
handled += r.handled;
|
|
80
|
+
await repo.gcal.patchLink(l.id, { syncToken: r.syncToken, fullSync: false, failures: 0, failingSince: 0, error: '', lastOkAt: now, nextAt: now + READ_EVERY + Math.floor(Math.random() * 10_000) }, claimed.revision, claimed.lease);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
if (!(e instanceof GoogleError)) console.error('acms google sync: reading a calendar failed', e);
|
|
83
|
+
await noteFailure(repo, claimed, e, now, claimed.lease);
|
|
84
|
+
if (e instanceof GoogleError && e.code === 'auth') { await markReconnect(repo, e.message); break; }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
await alertFailing(repo, locals, now);
|
|
88
|
+
return handled;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** "Sync now" (and the first match's "Start syncing", `start`): one link, at once, both ways. Errors come back as ApiErrors to show. */
|
|
92
|
+
export async function syncLinkNow(repo: ContentRepository, linkId: string, locals?: unknown, opts: { start?: boolean; now?: number } = {}) {
|
|
93
|
+
const now = opts.now ?? Date.now();
|
|
94
|
+
const settings = await repo.getSettings();
|
|
95
|
+
const link = await repo.gcal.getLink(linkId);
|
|
96
|
+
if (!link) throw new ApiError(404, 'That link no longer exists.');
|
|
97
|
+
if (siteGuard(link, settings)) throw new ApiError(400, PAUSE_WORDS['other-site']);
|
|
98
|
+
if (link.state === 'paused') throw new ApiError(400, 'Resume the link first.');
|
|
99
|
+
if (link.state === 'matching' && !opts.start) throw new ApiError(400, 'Review the first match first.');
|
|
100
|
+
const claimed = await repo.gcal.claimLink(link.id, Math.max(now, Date.now()), true);
|
|
101
|
+
if (!claimed) throw new ApiError(409, 'This link is syncing right now. Try again in a minute.');
|
|
102
|
+
try {
|
|
103
|
+
const { client } = await accountClient(repo, AbortSignal.timeout(110_000), locals);
|
|
104
|
+
const ctx = syncContext(repo, client, settings, siteOriginOf(settings.siteUrl)!, await repo.gcal.listLinks(), locals, now);
|
|
105
|
+
if (opts.start) {
|
|
106
|
+
await startSyncing(ctx, claimed);
|
|
107
|
+
ctx.links = await repo.gcal.listLinks();
|
|
108
|
+
await runOutbound(ctx, 50);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const r = link.direction === 'out' ? null : await syncIncoming(ctx, claimed);
|
|
112
|
+
await repo.gcal.patchLink(link.id, { ...(r ? { syncToken: r.syncToken, fullSync: false } : {}), failures: 0, failingSince: 0, error: '', lastOkAt: now, nextAt: now + READ_EVERY }, claimed.revision, claimed.lease);
|
|
113
|
+
await runOutbound(ctx, 50);
|
|
114
|
+
} catch (e) {
|
|
115
|
+
const fresh = await repo.gcal.getLink(link.id);
|
|
116
|
+
if (fresh?.lease === claimed.lease) await noteFailure(repo, fresh, e, now, claimed.lease);
|
|
117
|
+
if (e instanceof GoogleError && e.code === 'auth') await markReconnect(repo, e.message);
|
|
118
|
+
if (e instanceof ApiError) throw e;
|
|
119
|
+
throw new ApiError(400, messageOf(e));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Builds (or rebuilds) a link's first-match review. Writes nothing to either calendar. */
|
|
124
|
+
export async function reviewMatch(repo: ContentRepository, linkId: string, locals?: unknown) {
|
|
125
|
+
const settings = await repo.getSettings();
|
|
126
|
+
const link = await repo.gcal.getLink(linkId);
|
|
127
|
+
if (!link || link.state !== 'matching') throw new ApiError(409, 'This link isn’t waiting for a match.');
|
|
128
|
+
if (siteGuard(link, settings)) throw new ApiError(400, PAUSE_WORDS['other-site']);
|
|
129
|
+
try {
|
|
130
|
+
const { client } = await accountClient(repo, AbortSignal.timeout(110_000), locals);
|
|
131
|
+
const ctx = syncContext(repo, client, settings, siteOriginOf(settings.siteUrl)!, await repo.gcal.listLinks(), locals);
|
|
132
|
+
const match = await buildMatch(ctx, link);
|
|
133
|
+
if (!await repo.gcal.patchLink(link.id, { match }, link.revision)) throw new ApiError(409, 'The link changed meanwhile. Reload and try again.');
|
|
134
|
+
return match;
|
|
135
|
+
} catch (e) {
|
|
136
|
+
if (e instanceof GoogleError && e.code === 'auth') await markReconnect(repo, e.message);
|
|
137
|
+
if (e instanceof ApiError) throw e;
|
|
138
|
+
throw new ApiError(400, messageOf(e));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** "Use Google's version" on a clash in Recent changes or the event's note. */
|
|
143
|
+
export async function useGoogleVersion(repo: ContentRepository, logId: number, url: URL, locals?: unknown) {
|
|
144
|
+
const settings = await repo.getSettings();
|
|
145
|
+
const origin = siteOriginOf(settings.siteUrl) ?? url.origin;
|
|
146
|
+
try { return await useGoogleValue(siteContext(repo, settings, origin, locals), logId); }
|
|
147
|
+
catch (e) {
|
|
148
|
+
if (e instanceof ApiError) throw e;
|
|
149
|
+
throw new ApiError((e as { status?: number }).status ?? 400, e instanceof Error ? e.message : 'That didn’t work. Try again.');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Staff hear once when a link has been failing for an hour (and again if it recovers and fails again). */
|
|
154
|
+
async function alertFailing(repo: ContentRepository, locals: unknown, now: number) {
|
|
155
|
+
const due = (await repo.gcal.listLinks()).filter((l) => l.state === 'active' && l.failingSince && now - l.failingSince >= ALERT_AFTER && l.alertedAt < l.failingSince);
|
|
156
|
+
if (!due.length) return;
|
|
157
|
+
const users = await repo.listUsers();
|
|
158
|
+
const recipients: string[] = [];
|
|
159
|
+
for (const u of users) if (u.email && (u.role === 'owner' || await principalCan(repo, { user: u }, 'schedule.manage').catch(() => false))) recipients.push(u.email);
|
|
160
|
+
const version = (await repo.email.config().catch(() => null))?.version ?? 0;
|
|
161
|
+
for (const l of due) {
|
|
162
|
+
for (const to of [...new Set(recipients)]) {
|
|
163
|
+
await repo.email.enqueue({ dedupe: `gcal-alert:${l.id}:${l.failingSince}:${to}`, kind: 'gcal-alert', recipient: to,
|
|
164
|
+
payload: JSON.stringify({ linkId: l.id, since: l.failingSince }), configVersion: version, expiresAt: now + 2 * 86_400_000 });
|
|
165
|
+
}
|
|
166
|
+
await repo.gcal.patchLink(l.id, { alertedAt: now }, l.revision);
|
|
167
|
+
}
|
|
168
|
+
if (recipients.length) await kickEmail(repo, locals, waitUntilFrom(locals)).catch(() => {});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** What Calendar settings → Synced with Google shows. */
|
|
172
|
+
export async function googleSyncState(repo: ContentRepository, url: URL, locals?: unknown) {
|
|
173
|
+
const [settings, account, links, counts, queue, log, calendars, resources, scheduler] = await Promise.all([
|
|
174
|
+
repo.getSettings(), repo.gcal.account(), repo.gcal.listLinks(), repo.gcal.countItems(), repo.gcal.queueStats(), repo.gcal.listLog({ limit: 25 }),
|
|
175
|
+
repo.listCalendars(), repo.listResources(), repo.email.schedulerStatus().catch(() => null),
|
|
176
|
+
]);
|
|
177
|
+
const origin = siteOriginOf(settings.siteUrl);
|
|
178
|
+
let authorized = false;
|
|
179
|
+
if (account) { try { authorized = !!gcalSeal.decrypt<{ refreshToken?: string }>(account.encrypted, 'account', locals).refreshToken; } catch { /* needs reconnect */ } }
|
|
180
|
+
return {
|
|
181
|
+
origin,
|
|
182
|
+
callbackUrl: `${origin ?? url.origin}${CALLBACK_PATH}`,
|
|
183
|
+
encryptionReady: gcalSeal.ready(locals),
|
|
184
|
+
account: account && { clientId: account.clientId, email: account.email, status: account.status, error: account.error, authorized, calendars: account.calendars, calendarsAt: account.calendarsAt, connectedAt: account.connectedAt, siteUrl: account.siteUrl },
|
|
185
|
+
links: links.map(({ lease: _l, leaseUntil: _u, syncToken: _t, match: _m, ...l }) => ({ ...l, pairs: counts.get(l.id) ?? 0, guard: siteGuard(l, settings), matching: l.state === 'matching' })),
|
|
186
|
+
calendars: calendars.filter((c) => c.kind === 'native'),
|
|
187
|
+
resources,
|
|
188
|
+
queue,
|
|
189
|
+
log,
|
|
190
|
+
scheduler: scheduler ? { lastAt: scheduler.finishedAt, healthy: scheduler.finishedAt > Date.now() - 300_000 } : { lastAt: 0, healthy: false },
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
export type GoogleSyncState = Awaited<ReturnType<typeof googleSyncState>>;
|