@acms-subir/core 0.3.54 → 0.3.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/cli/main.mjs +2 -1
  3. package/cli/migrations.generated.mjs +59 -6
  4. package/docs/calendar.md +29 -0
  5. package/docs/google-calendar.md +302 -0
  6. package/docs/server.md +7 -0
  7. package/integration.mjs +4 -0
  8. package/package.json +1 -1
  9. package/src/components/LegalSettings.tsx +1 -1
  10. package/src/lib/calendar/google/client.ts +167 -0
  11. package/src/lib/calendar/google/connect.ts +161 -0
  12. package/src/lib/calendar/google/context.ts +50 -0
  13. package/src/lib/calendar/google/inbound.ts +493 -0
  14. package/src/lib/calendar/google/links.ts +139 -0
  15. package/src/lib/calendar/google/map.ts +543 -0
  16. package/src/lib/calendar/google/match.ts +103 -0
  17. package/src/lib/calendar/google/migration.ts +71 -0
  18. package/src/lib/calendar/google/outbound.ts +272 -0
  19. package/src/lib/calendar/google/service.ts +193 -0
  20. package/src/lib/calendar/google/store.ts +241 -0
  21. package/src/lib/calendar/google/types.ts +188 -0
  22. package/src/lib/calendar/schedule.ts +8 -8
  23. package/src/lib/display/board.ts +3 -1
  24. package/src/lib/email/service.ts +11 -1
  25. package/src/lib/email/templates.ts +8 -0
  26. package/src/lib/legal/admin.ts +1 -1
  27. package/src/lib/legal/facts.ts +3 -0
  28. package/src/lib/legal/text-en.ts +4 -0
  29. package/src/lib/legal/text-es.ts +4 -0
  30. package/src/lib/live/crypto.ts +7 -28
  31. package/src/lib/repo/migrations.ts +4 -0
  32. package/src/lib/repo/sql.ts +18 -9
  33. package/src/lib/repo/types.ts +6 -2
  34. package/src/lib/scheduler.ts +3 -0
  35. package/src/lib/sealed.ts +46 -0
  36. package/src/pages/admin/calendar/events/[id].astro +42 -1
  37. package/src/pages/admin/calendar/google/match.astro +160 -0
  38. package/src/pages/admin/calendar/google.astro +107 -0
  39. package/src/pages/admin/calendar/settings.astro +273 -4
  40. package/src/pages/admin/integrations.astro +6 -0
  41. package/src/pages/api/calendar/google/oauth/callback.ts +16 -0
  42. package/src/server/index.ts +49 -0
@@ -0,0 +1,167 @@
1
+ // Google Calendar API v3 over fetch, modelled on the live module's request()
2
+ // (lib/live/providers.ts): no redirects, a timeout and a response-size cap on
3
+ // every call, quota errors with Retry-After, one token refresh per call, and
4
+ // Google refusing the saved sign-in (invalid_grant) surfacing as "auth" so the
5
+ // account shows "Reconnect Google". Every write passes sendUpdates=none: the
6
+ // site never adds guests and never makes Google email anyone.
7
+ import { z } from 'zod';
8
+ import { readResponseJson } from '../../api';
9
+ import { GoogleCalendarListPage, GoogleEvent, GoogleEventPage, type GcalCredentials, type GoogleCalendarEntry } from './types';
10
+
11
+ export const GOOGLE_API = 'https://www.googleapis.com/calendar/v3';
12
+ export const GOOGLE_TOKEN = 'https://oauth2.googleapis.com/token';
13
+ export const GOOGLE_REVOKE = 'https://oauth2.googleapis.com/revoke';
14
+ export const GOOGLE_AUTH = 'https://accounts.google.com/o/oauth2/v2/auth';
15
+ /** see and edit events on calendars the account can edit, and list those calendars for the link picker */
16
+ export const GOOGLE_SCOPES = ['https://www.googleapis.com/auth/calendar.events', 'https://www.googleapis.com/auth/calendar.calendarlist.readonly'];
17
+
18
+ export type GoogleErrorCode = 'auth' | 'quota' | 'gone' | 'notfound' | 'conflict' | 'forbidden' | 'invalid' | 'unavailable';
19
+ export class GoogleError extends Error {
20
+ constructor(public code: GoogleErrorCode, message: string, public status = 0, public retryAfter = 0) { super(message); }
21
+ }
22
+
23
+ const QUOTA = ['quotaExceeded', 'dailyLimitExceeded', 'rateLimitExceeded', 'userRateLimitExceeded', 'usageLimits', 'calendarUsageLimitsExceeded'];
24
+ const ErrorBody = z.object({
25
+ error: z.union([z.string(), z.object({ message: z.string().max(2000).optional(), errors: z.array(z.object({ reason: z.string().max(200).optional() })).max(20).optional() })]).optional(),
26
+ });
27
+
28
+ function retryAfterMs(r: Response) {
29
+ const header = r.headers.get('retry-after');
30
+ const seconds = header && /^\d+$/.test(header) ? Number(header) : header ? (Date.parse(header) - Date.now()) / 1000 : 0;
31
+ return Math.min(86_400_000, Math.max(60_000, Number.isFinite(seconds) ? seconds * 1000 : 0));
32
+ }
33
+
34
+ async function request(url: URL | string, init: RequestInit, signal: AbortSignal, cap = 4_000_000): Promise<unknown> {
35
+ let r: Response;
36
+ try {
37
+ r = await fetch(url, { ...init, redirect: 'error', signal: AbortSignal.any([signal, AbortSignal.timeout(20_000)]) });
38
+ } catch {
39
+ throw new GoogleError('unavailable', 'Google didn’t answer in time. The sync tries again shortly.');
40
+ }
41
+ if (r.status === 204) { await r.body?.cancel(); return null; }
42
+ const body = await readResponseJson(r, cap).catch(() => null);
43
+ if (r.ok) {
44
+ if (body === null) throw new GoogleError('unavailable', 'Google sent an answer the sync couldn’t read. It tries again shortly.', r.status);
45
+ return body;
46
+ }
47
+ const parsed = ErrorBody.safeParse(body);
48
+ const error = parsed.success ? parsed.data.error : undefined;
49
+ const reasons = typeof error === 'object' ? (error.errors ?? []).map((e) => e.reason ?? '') : [];
50
+ if (r.status === 429 || (r.status === 403 && reasons.some((x) => QUOTA.includes(x)))) {
51
+ throw new GoogleError('quota', 'Google’s rate limit was reached. The sync waits and tries again.', r.status, retryAfterMs(r));
52
+ }
53
+ if (r.status === 401 || error === 'invalid_grant' || error === 'invalid_client' || error === 'unauthorized_client') {
54
+ throw new GoogleError('auth', 'Google refused the saved sign-in. Reconnect Google in Calendar settings.', r.status);
55
+ }
56
+ if (r.status === 403 && reasons.includes('forbiddenForNonOrganizer')) throw new GoogleError('forbidden', 'Google only lets the organizer change this event: someone invited the calendar to their own event.', r.status);
57
+ if (r.status === 403) throw new GoogleError('forbidden', 'Google says this account can’t change that calendar. Check its sharing settings, or link another calendar.', r.status);
58
+ if (r.status === 404) throw new GoogleError('notfound', 'Google doesn’t have that event or calendar any more.', r.status);
59
+ if (r.status === 410) throw new GoogleError('gone', 'Google asked for a full sync.', r.status);
60
+ if (r.status === 409 || r.status === 412) throw new GoogleError('conflict', 'The Google event changed meanwhile.', r.status);
61
+ if (r.status >= 400 && r.status < 500) {
62
+ // Google's own words help here ("Invalid recurrence rule"); they never carry secrets
63
+ const detail = typeof error === 'object' && error.message ? `: ${error.message.slice(0, 200)}` : '';
64
+ throw new GoogleError('invalid', `Google didn’t accept the change (HTTP ${r.status})${detail}.`, r.status);
65
+ }
66
+ throw new GoogleError('unavailable', `Google couldn’t complete the request (HTTP ${r.status}). The sync tries again shortly.`, r.status);
67
+ }
68
+
69
+ const Token = z.object({ access_token: z.string().min(1).max(8192), expires_in: z.number().positive(), refresh_token: z.string().max(8192).optional(), scope: z.string().max(4000).optional() });
70
+
71
+ /** Exchanges the authorization code (with its PKCE verifier) for tokens. Both scopes and offline access are required. */
72
+ export async function exchangeCode(clientId: string, clientSecret: string, code: string, verifier: string, callbackUrl: string, signal: AbortSignal): Promise<GcalCredentials> {
73
+ const token = Token.parse(await request(GOOGLE_TOKEN, { method: 'POST', body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, code, code_verifier: verifier, redirect_uri: callbackUrl, grant_type: 'authorization_code' }) }, signal, 64_000));
74
+ if (!token.refresh_token) throw new GoogleError('auth', 'Google didn’t give lasting access. Connect again and approve the calendar permissions.');
75
+ const granted = (token.scope ?? '').split(' ');
76
+ if (!GOOGLE_SCOPES.every((s) => granted.includes(s))) throw new GoogleError('auth', 'Google didn’t grant both calendar permissions. Connect again and leave both boxes ticked.');
77
+ return { clientSecret, accessToken: token.access_token, refreshToken: token.refresh_token, tokenExpiresAt: Date.now() + token.expires_in * 1000, scope: token.scope, callbackUrl };
78
+ }
79
+
80
+ /** Best effort: disconnecting always removes the local credentials. */
81
+ export async function revokeToken(creds: GcalCredentials) {
82
+ const token = creds.refreshToken || creds.accessToken;
83
+ if (!token) return;
84
+ try { await fetch(GOOGLE_REVOKE, { method: 'POST', body: new URLSearchParams({ token }), redirect: 'error', signal: AbortSignal.timeout(5_000) }); } catch { /* nothing about Google in logs */ }
85
+ }
86
+
87
+ export interface ListQuery { syncToken?: string; timeMin?: string; pageToken?: string; showDeleted?: boolean }
88
+
89
+ /**
90
+ * One account's API calls. `creds` is refreshed in place; `onTokens` saves a
91
+ * refreshed token (sealed) so the next run doesn't refresh again.
92
+ */
93
+ export class GoogleClient {
94
+ private refreshed = false;
95
+ constructor(private clientId: string, private creds: GcalCredentials, private signal: AbortSignal, private onTokens?: (creds: GcalCredentials) => Promise<void>) {}
96
+
97
+ private async token(force = false) {
98
+ if (!force && this.creds.accessToken && (this.creds.tokenExpiresAt ?? 0) > Date.now() + 60_000) return this.creds.accessToken;
99
+ if (!this.creds.refreshToken) throw new GoogleError('auth', 'Connect Google first.');
100
+ const token = Token.parse(await request(GOOGLE_TOKEN, { method: 'POST', body: new URLSearchParams({ client_id: this.clientId, client_secret: this.creds.clientSecret, refresh_token: this.creds.refreshToken, grant_type: 'refresh_token' }) }, this.signal, 64_000));
101
+ this.creds.accessToken = token.access_token;
102
+ this.creds.tokenExpiresAt = Date.now() + token.expires_in * 1000;
103
+ if (token.refresh_token) this.creds.refreshToken = token.refresh_token;
104
+ await this.onTokens?.(this.creds);
105
+ return token.access_token;
106
+ }
107
+
108
+ async call(method: 'GET' | 'POST' | 'PATCH' | 'DELETE', path: string, opts: { query?: Record<string, string | undefined>; body?: unknown; cap?: number } = {}): Promise<unknown> {
109
+ const url = new URL(`${GOOGLE_API}${path}`);
110
+ for (const [k, v] of Object.entries(opts.query ?? {})) if (v !== undefined) url.searchParams.set(k, v);
111
+ const send = async (token: string) => request(url, {
112
+ method, headers: { Authorization: `Bearer ${token}`, ...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
113
+ ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
114
+ }, this.signal, opts.cap);
115
+ try { return await send(await this.token()); }
116
+ catch (e) {
117
+ // an access token Google stopped taking: one refresh, never a loop
118
+ if (!(e instanceof GoogleError) || e.code !== 'auth' || e.status !== 401 || this.refreshed) throw e;
119
+ this.refreshed = true;
120
+ return send(await this.token(true));
121
+ }
122
+ }
123
+
124
+ /** Calendars the account can edit (owner or writer), for the link picker. */
125
+ async calendars(): Promise<GoogleCalendarEntry[]> {
126
+ const out: GoogleCalendarEntry[] = [];
127
+ let page: string | undefined;
128
+ for (let i = 0; i < 10; i++) {
129
+ const data = GoogleCalendarListPage.parse(await this.call('GET', '/users/me/calendarList', { query: { minAccessRole: 'writer', maxResults: '250', pageToken: page } }));
130
+ for (const c of data.items) {
131
+ if (c.deleted || !['owner', 'writer'].includes(c.accessRole ?? '')) continue;
132
+ out.push({ id: c.id, name: c.summaryOverride || c.summary || c.id, timeZone: c.timeZone ?? '', accessRole: c.accessRole ?? '', primary: !!c.primary, color: c.backgroundColor ?? '' });
133
+ }
134
+ page = data.nextPageToken;
135
+ if (!page) break;
136
+ }
137
+ return out.sort((a, b) => Number(b.primary) - Number(a.primary) || a.name.localeCompare(b.name));
138
+ }
139
+
140
+ /** One page of events: everything from timeMin (a full sync), or what changed since syncToken. Repeating events come as one series plus their changed dates. */
141
+ async listEvents(calendarId: string, q: ListQuery) {
142
+ return GoogleEventPage.parse(await this.call('GET', `/calendars/${encodeURIComponent(calendarId)}/events`, { query: {
143
+ singleEvents: 'false', maxResults: '250', showDeleted: q.syncToken || q.showDeleted ? 'true' : undefined,
144
+ syncToken: q.syncToken || undefined, timeMin: q.syncToken ? undefined : q.timeMin, pageToken: q.pageToken,
145
+ }, cap: 8_000_000 }));
146
+ }
147
+ async getEvent(calendarId: string, eventId: string) {
148
+ return GoogleEvent.parse(await this.call('GET', `/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`));
149
+ }
150
+ async insertEvent(calendarId: string, body: Record<string, unknown>) {
151
+ return GoogleEvent.parse(await this.call('POST', `/calendars/${encodeURIComponent(calendarId)}/events`, { query: { sendUpdates: 'none' }, body }));
152
+ }
153
+ /** Patch semantics: only the fields given change (Google keeps guests, reminders, colours, attachments and conference data). */
154
+ async patchEvent(calendarId: string, eventId: string, body: Record<string, unknown>) {
155
+ return GoogleEvent.parse(await this.call('PATCH', `/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`, { query: { sendUpdates: 'none' }, body }));
156
+ }
157
+ /** Deleting what's already gone counts as done. */
158
+ async deleteEvent(calendarId: string, eventId: string) {
159
+ try { await this.call('DELETE', `/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`, { query: { sendUpdates: 'none' } }); }
160
+ catch (e) { if (!(e instanceof GoogleError) || (e.code !== 'notfound' && e.code !== 'gone')) throw e; }
161
+ }
162
+ /** One date of a series by its original start (cancelled ones included), or null when the series has no such date. */
163
+ async instance(calendarId: string, masterId: string, originalStart: string) {
164
+ const data = GoogleEventPage.parse(await this.call('GET', `/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(masterId)}/instances`, { query: { originalStart, showDeleted: 'true', maxResults: '5' } }));
165
+ return data.items[0] ?? null;
166
+ }
167
+ }
@@ -0,0 +1,161 @@
1
+ // Connecting a Google account for the calendar sync: its own OAuth client
2
+ // (in the same Google Cloud project as YouTube, if you like), so
3
+ // disconnecting YouTube never cuts off the calendar, and the same sign-in
4
+ // flow as the YouTube callback: a one-use sealed pending state, a callback
5
+ // that only parks Google's code, and the signed-in admin page finishing it
6
+ // with a same-origin POST bound to the session that began it.
7
+ import { ApiError } from '../../api';
8
+ import type { ContentRepository } from '../../repo/types';
9
+ import { hash, randomToken, sealer } from '../../sealed';
10
+ import { exchangeCode, GOOGLE_AUTH, GOOGLE_SCOPES, GoogleClient, GoogleError, revokeToken } from './client';
11
+ import type { GcalAccount, GcalCredentials } from './types';
12
+ import { queueLinkCalendar } from './links';
13
+
14
+ /** Google Calendar credentials (lib/sealed.ts): their own key and data, never readable as live video's. */
15
+ export const gcalSeal = sealer('gcal', 'Google Calendar credentials');
16
+
17
+ /** The path Google sends the browser back to. */
18
+ export const CALLBACK_PATH = '/api/calendar/google/oauth/callback';
19
+ /** Where the admin finishes signing in (Calendar settings → Synced with Google). */
20
+ export const SETTINGS_PATH = '/admin/calendar/settings';
21
+
22
+ const LOOPBACK = ['localhost', '127.0.0.1', '[::1]'];
23
+ /** The site's own origin from Settings → Site URL (HTTPS, or HTTP on localhost for development), or null when it isn't usable. */
24
+ export function siteOriginOf(siteUrl: string): string | null {
25
+ const raw = siteUrl.trim();
26
+ if (!raw) return null;
27
+ try {
28
+ const url = new URL(raw.includes('://') ? raw : `https://${raw}`);
29
+ if (url.username || url.password || (url.protocol !== 'https:' && !(url.protocol === 'http:' && LOOPBACK.includes(url.hostname)))) return null;
30
+ return url.origin;
31
+ } catch { return null; }
32
+ }
33
+ function requireOrigin(siteUrl: string, requestUrl: URL) {
34
+ const origin = siteOriginOf(siteUrl);
35
+ if (!origin) throw new ApiError(400, 'Set the Site URL in Settings → General first (HTTPS, or localhost while developing).');
36
+ if (origin !== requestUrl.origin) throw new ApiError(400, `Open the admin at ${origin} (the Site URL) to connect Google.`);
37
+ return origin;
38
+ }
39
+
40
+ /** Saves the OAuth client. The same client ID without a secret keeps the saved secret; a new client signs the account out. */
41
+ export async function saveGoogleClient(repo: ContentRepository, input: { clientId: string; clientSecret: string }, locals?: unknown) {
42
+ const clientId = input.clientId.trim(), secret = input.clientSecret.trim();
43
+ if (!/^[\w.-]{8,200}\.apps\.googleusercontent\.com$/.test(clientId)) throw new ApiError(400, 'Paste the client ID from Google Cloud. It ends in .apps.googleusercontent.com.');
44
+ const current = await repo.gcal.account();
45
+ if (current?.clientId === clientId && !secret) return;
46
+ if (!secret) throw new ApiError(400, 'Paste the client secret too.');
47
+ if (current) await disconnectGoogle(repo, locals);
48
+ await repo.gcal.saveClient(clientId, gcalSeal.encrypt({ clientSecret: secret } satisfies GcalCredentials, 'account', locals));
49
+ }
50
+
51
+ /** The Google sign-in URL for this admin session (10 minutes, one use). */
52
+ export async function beginGoogleConnect(repo: ContentRepository, session: string, url: URL, locals?: unknown) {
53
+ const account = await repo.gcal.account();
54
+ if (!account) throw new ApiError(400, 'Save the client ID and secret first.');
55
+ const creds = gcalSeal.decrypt<GcalCredentials>(account.encrypted, 'account', locals);
56
+ if (!creds.clientSecret) throw new ApiError(400, 'Save the client secret first.');
57
+ const origin = requireOrigin((await repo.getSettings()).siteUrl, url);
58
+ const callbackUrl = `${origin}${CALLBACK_PATH}`, state = randomToken(), verifier = randomToken();
59
+ await repo.gcal.state(hash(state), hash(session), account.revision, gcalSeal.encrypt({ verifier, callbackUrl }, 'oauth', locals), Date.now() + 600_000);
60
+ const auth = new URL(GOOGLE_AUTH);
61
+ const params = { client_id: account.clientId, redirect_uri: callbackUrl, response_type: 'code', scope: GOOGLE_SCOPES.join(' '), access_type: 'offline',
62
+ prompt: 'consent select_account', state, code_challenge: Buffer.from(hash(verifier), 'hex').toString('base64url'), code_challenge_method: 'S256' };
63
+ for (const [k, v] of Object.entries(params)) auth.searchParams.set(k, v);
64
+ return auth.href;
65
+ }
66
+
67
+ type Pending = { verifier: string; callbackUrl: string; code?: string };
68
+ /** Google's redirect back: parks the code on its pending state (no sign-in needed; only the admin who began it can finish). */
69
+ export async function receiveGoogleCallback(repo: ContentRepository, url: URL, locals?: unknown) {
70
+ const state = url.searchParams.get('state') ?? '';
71
+ if (!/^[\w-]{43}$/.test(state)) throw new ApiError(400, 'Invalid sign-in state. Start again.');
72
+ if (url.searchParams.has('error')) throw new ApiError(400, 'Google sign-in wasn’t completed.');
73
+ const code = url.searchParams.get('code');
74
+ if (!code || code.length > 8192) throw new ApiError(400, 'Missing authorization code.');
75
+ const now = Date.now(), stored = await repo.gcal.pendingState(hash(state), now);
76
+ if (!stored) throw new ApiError(400, 'The sign-in expired. Start again.');
77
+ const pending = gcalSeal.decrypt<Pending>(stored.encrypted, 'oauth', locals);
78
+ if (pending.code) throw new ApiError(409, 'This sign-in already came back. Start again.');
79
+ if (new URL(pending.callbackUrl).origin !== url.origin) throw new ApiError(400, 'The callback address doesn’t match the site.');
80
+ if (!await repo.gcal.attachCode(hash(state), stored.encrypted, gcalSeal.encrypt({ ...pending, code }, 'oauth', locals), Math.min(stored.expiresAt, now + 120_000), now)) {
81
+ throw new ApiError(409, 'This sign-in already came back. Start again.');
82
+ }
83
+ }
84
+
85
+ /** The signed-in admin finishes: tokens for the parked code, the account's address and the calendars it can edit. */
86
+ export async function finishGoogleConnect(repo: ContentRepository, session: string, url: URL, userId: string, locals?: unknown): Promise<GcalAccount> {
87
+ const rows = session ? await repo.gcal.consumeSessionStates(hash(session), Date.now()) : [];
88
+ const stored = rows.map((r) => ({ ...r, ...gcalSeal.decrypt<Pending>(r.encrypted, 'oauth', locals) })).find((r) => r.code);
89
+ const account = await repo.gcal.account();
90
+ if (!stored || !account || stored.revision !== account.revision) throw new ApiError(400, 'The sign-in expired or the Google client changed. Start again.');
91
+ if (new URL(stored.callbackUrl).origin !== url.origin) throw new ApiError(400, 'The callback address doesn’t match the site.');
92
+ const old = gcalSeal.decrypt<GcalCredentials>(account.encrypted, 'account', locals);
93
+ try {
94
+ const creds = await exchangeCode(account.clientId, old.clientSecret, stored.code!, stored.verifier, stored.callbackUrl, AbortSignal.timeout(30_000));
95
+ const calendars = await new GoogleClient(account.clientId, creds, AbortSignal.timeout(30_000)).calendars();
96
+ const primary = calendars.find((c) => c.primary)?.id ?? '';
97
+ const email = primary.includes('@') ? primary : '';
98
+ const switched = !!account.email && !!email && account.email !== email;
99
+ const ok = await repo.gcal.patchAccount({ encrypted: gcalSeal.encrypt(creds, 'account', locals), email, status: 'connected', error: '', siteUrl: url.origin,
100
+ calendars, calendarsAt: Date.now(), connectedBy: userId, connectedAt: Date.now() }, account.revision, true);
101
+ if (!ok) throw new ApiError(409, 'The Google connection changed meanwhile. Connect again.');
102
+ // reconnecting the same account picks up where Disconnect stopped (reading Google again, catching up on the
103
+ // site's changes); another account leaves the links paused for a person to check. Links paused by hand or
104
+ // after a restore stay paused.
105
+ if (!switched) {
106
+ for (const l of await repo.gcal.listLinks()) {
107
+ if (l.state !== 'paused' || l.pausedReason !== 'disconnected') continue;
108
+ await repo.gcal.patchLink(l.id, l.matchedAt ? { state: 'active', pausedReason: '', error: '', nextAt: 0, fullSync: true } : { state: 'matching', pausedReason: '', match: null }, l.revision);
109
+ if (l.matchedAt) await queueLinkCalendar(repo, (await repo.gcal.getLink(l.id))!);
110
+ }
111
+ }
112
+ return (await repo.gcal.account())!;
113
+ } catch (e) {
114
+ if (e instanceof ApiError) throw e;
115
+ throw new ApiError(400, e instanceof GoogleError ? e.message : 'Google didn’t finish the sign-in. Try again.');
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Disconnect (or a new client replacing the old one): Google's grant is
121
+ * revoked (best effort) and the credentials go. Links stay, paused, and both
122
+ * copies of every event stay as they are.
123
+ */
124
+ export async function disconnectGoogle(repo: ContentRepository, locals?: unknown) {
125
+ const account = await repo.gcal.account();
126
+ await repo.gcal.removeAccount();
127
+ if (account) {
128
+ try { await revokeToken(gcalSeal.decrypt<GcalCredentials>(account.encrypted, 'account', locals)); } catch { /* local removal still happened */ }
129
+ }
130
+ }
131
+
132
+ /** An API client for the connected account; refreshed tokens are saved sealed. Throws GoogleError('auth') when there's no usable sign-in. */
133
+ export async function accountClient(repo: ContentRepository, signal: AbortSignal, locals?: unknown) {
134
+ const account = await repo.gcal.account();
135
+ if (!account || account.status === 'setup') throw new GoogleError('auth', 'Connect Google in Calendar settings first.');
136
+ if (account.status === 'reconnect') throw new GoogleError('auth', account.error || 'Google refused the saved sign-in. Reconnect Google in Calendar settings.');
137
+ let creds: GcalCredentials;
138
+ try { creds = gcalSeal.decrypt<GcalCredentials>(account.encrypted, 'account', locals); }
139
+ catch { throw new GoogleError('auth', 'The saved Google sign-in can’t be read here (the site’s secret changed). Reconnect Google.'); }
140
+ const client = new GoogleClient(account.clientId, creds, signal, (c) => repo.gcal.saveTokens(gcalSeal.encrypt(c, 'account', locals), account.revision));
141
+ return { account, client };
142
+ }
143
+
144
+ /** Google refused the sign-in: the account shows "Reconnect Google"; links wait (their changes stay queued). */
145
+ export async function markReconnect(repo: ContentRepository, message: string) {
146
+ const account = await repo.gcal.account();
147
+ if (account && account.status === 'connected') await repo.gcal.patchAccount({ status: 'reconnect', error: message }, account.revision);
148
+ }
149
+
150
+ /** Lists the calendars the account can edit again (the link picker). */
151
+ export async function refreshGoogleCalendars(repo: ContentRepository, locals?: unknown) {
152
+ try {
153
+ const { account, client } = await accountClient(repo, AbortSignal.timeout(30_000), locals);
154
+ const calendars = await client.calendars();
155
+ await repo.gcal.patchAccount({ calendars, calendarsAt: Date.now() }, account.revision);
156
+ return calendars;
157
+ } catch (e) {
158
+ if (e instanceof GoogleError && e.code === 'auth') await markReconnect(repo, e.message);
159
+ throw new ApiError(400, e instanceof GoogleError || e instanceof ApiError ? e.message : 'Couldn’t list the Google calendars. Try again.');
160
+ }
161
+ }
@@ -0,0 +1,50 @@
1
+ import type { ContentRepository } from '../../repo/types';
2
+ import type { CalEvent, Settings } from '../../schema';
3
+ import type { SaveHooks } from '../../content-service';
4
+ import { I18N } from '../../i18n';
5
+ import { siteLang } from '../../dates';
6
+ import { eventUrl } from '../config';
7
+ import { eventClashes } from '../../booking/holds';
8
+ import type { GoogleClient } from './client';
9
+ import { syncWords, type MapContext } from './map';
10
+ import type { GcalItem, GcalLink, NewLogEntry } from './types';
11
+
12
+ /** What one sync run shares: the account's client, the site's settings and origin, and the links. */
13
+ export interface SyncContext {
14
+ repo: ContentRepository;
15
+ client: GoogleClient;
16
+ settings: Settings;
17
+ origin: string;
18
+ map: MapContext;
19
+ /** the site's own writes (side effects use the site origin) */
20
+ hooks: SaveHooks;
21
+ now: number;
22
+ links: GcalLink[];
23
+ }
24
+
25
+ /** The site half of a context (no Google calls): enough for "Use Google's version". */
26
+ export function siteContext(repo: ContentRepository, settings: Settings, origin: string, locals?: unknown) {
27
+ const lang = (I18N.enabled && I18N.defaultLocale) || siteLang(settings);
28
+ return { repo, settings, origin, map: { settings, origin, pagePath: eventUrl, words: syncWords(lang) } satisfies MapContext, hooks: { url: new URL(origin), locals } satisfies SaveHooks };
29
+ }
30
+
31
+ export function syncContext(repo: ContentRepository, client: GoogleClient, settings: Settings, origin: string, links: GcalLink[], locals?: unknown, now = Date.now()): SyncContext {
32
+ return { ...siteContext(repo, settings, origin, locals), client, links, now };
33
+ }
34
+
35
+ /** A Recent changes entry about one pair. */
36
+ export const entry = (link: GcalLink, item: Pick<GcalItem, 'googleId' | 'eventId'> | null, title: string, e: Omit<NewLogEntry, 'linkId' | 'googleId' | 'eventId' | 'title'>): NewLogEntry =>
37
+ ({ ...e, linkId: link.id, googleId: item?.googleId ?? '', eventId: item?.eventId ?? '', title });
38
+
39
+ /** Room clashes after a change from Google: it applies anyway (it already happened there), and staff see the clash. */
40
+ export async function clashNote(ctx: SyncContext, link: GcalLink, item: Pick<GcalItem, 'googleId' | 'eventId'>, e: CalEvent): Promise<NewLogEntry | null> {
41
+ if (e.status !== 'published' || e.cancelled || !e.roomHold.resourceIds.length) return null;
42
+ const { clashes } = await eventClashes(ctx.repo, ctx.settings, e, { now: ctx.now, horizonDays: 120 }).catch(() => ({ clashes: [] as Awaited<ReturnType<typeof eventClashes>>['clashes'] }));
43
+ if (!clashes.length) return null;
44
+ const rooms = new Map((await ctx.repo.listResources()).map((r) => [r.id, r.name]));
45
+ const first = clashes[0];
46
+ const room = rooms.get(first.hold.resourceId) ?? 'A room';
47
+ return entry(link, item, e.title, { direction: 'clash', action: 'room',
48
+ summary: `${room} is also held by “${first.other.title}”${clashes.length > 1 ? ` and ${clashes.length - 1} more` : ''}. The change from Google stays: check Bookings.`,
49
+ data: { clashes: clashes.slice(0, 10).map((c) => ({ room: rooms.get(c.hold.resourceId) ?? '', start: c.hold.eventStart, other: c.other.title, otherId: c.other.eventId })) } });
50
+ }