@acms-subir/core 0.3.54 → 0.3.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/cli/main.mjs +2 -1
- package/cli/migrations.generated.mjs +59 -6
- package/docs/calendar.md +29 -0
- package/docs/google-calendar.md +302 -0
- package/docs/server.md +7 -0
- package/integration.mjs +4 -0
- package/package.json +1 -1
- package/src/components/LegalSettings.tsx +1 -1
- package/src/lib/calendar/google/client.ts +167 -0
- package/src/lib/calendar/google/connect.ts +161 -0
- package/src/lib/calendar/google/context.ts +50 -0
- package/src/lib/calendar/google/inbound.ts +493 -0
- package/src/lib/calendar/google/links.ts +139 -0
- package/src/lib/calendar/google/map.ts +543 -0
- package/src/lib/calendar/google/match.ts +103 -0
- package/src/lib/calendar/google/migration.ts +71 -0
- package/src/lib/calendar/google/outbound.ts +272 -0
- package/src/lib/calendar/google/service.ts +193 -0
- package/src/lib/calendar/google/store.ts +241 -0
- package/src/lib/calendar/google/types.ts +188 -0
- package/src/lib/calendar/schedule.ts +8 -8
- package/src/lib/display/board.ts +3 -1
- package/src/lib/email/service.ts +11 -1
- package/src/lib/email/templates.ts +8 -0
- package/src/lib/legal/admin.ts +1 -1
- package/src/lib/legal/facts.ts +3 -0
- package/src/lib/legal/text-en.ts +4 -0
- package/src/lib/legal/text-es.ts +4 -0
- package/src/lib/live/crypto.ts +7 -28
- package/src/lib/repo/migrations.ts +4 -0
- package/src/lib/repo/sql.ts +18 -9
- package/src/lib/repo/types.ts +6 -2
- package/src/lib/scheduler.ts +3 -0
- package/src/lib/sealed.ts +46 -0
- package/src/pages/admin/calendar/events/[id].astro +42 -1
- package/src/pages/admin/calendar/google/match.astro +160 -0
- package/src/pages/admin/calendar/google.astro +107 -0
- package/src/pages/admin/calendar/settings.astro +273 -4
- package/src/pages/admin/integrations.astro +6 -0
- package/src/pages/api/calendar/google/oauth/callback.ts +16 -0
- package/src/server/index.ts +49 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { randomInt } from 'node:crypto';
|
|
2
|
+
import type { SqlDriver, SqlStatement } from '../../repo/sql';
|
|
3
|
+
import type { Conflict, GcalAccount, GcalItem, GcalLink, GoogleCalendarEntry, Hashes, LogEntry, MatchState, NewLogEntry, PrefixRule } from './types';
|
|
4
|
+
|
|
5
|
+
// Storage for the Google Calendar sync (tables: ./migration.ts). Links use
|
|
6
|
+
// the live module's job fields: a claim takes a lease (90 s, so a crashed run
|
|
7
|
+
// frees it), a write that holds the lease releases it, and the revision
|
|
8
|
+
// rejects late results after someone edits, pauses or removes the link.
|
|
9
|
+
|
|
10
|
+
type Row = Record<string, unknown>;
|
|
11
|
+
const parse = <T>(raw: unknown, fallback: T): T => { try { return raw ? JSON.parse(String(raw)) as T : fallback; } catch { return fallback; } };
|
|
12
|
+
const LOG_DAYS = 90;
|
|
13
|
+
|
|
14
|
+
function account(r: Row): GcalAccount {
|
|
15
|
+
return { clientId: String(r.client_id), encrypted: String(r.encrypted), email: String(r.email ?? ''), status: (r.status as GcalAccount['status']) ?? 'setup',
|
|
16
|
+
error: String(r.error ?? ''), siteUrl: String(r.site_url ?? ''), calendars: parse<GoogleCalendarEntry[]>(r.calendars, []), calendarsAt: Number(r.calendars_at ?? 0),
|
|
17
|
+
connectedBy: String(r.connected_by ?? ''), connectedAt: Number(r.connected_at ?? 0), revision: Number(r.revision), updatedAt: Number(r.updated_at ?? 0) };
|
|
18
|
+
}
|
|
19
|
+
function link(r: Row): GcalLink {
|
|
20
|
+
return {
|
|
21
|
+
id: String(r.id), calendarId: String(r.calendar_id), googleId: String(r.google_id), googleName: String(r.google_name ?? ''), googleTz: String(r.google_tz ?? ''),
|
|
22
|
+
direction: r.direction as GcalLink['direction'], roomIds: parse<string[]>(r.room_ids, []), noRoom: !!r.no_room, rules: parse<PrefixRule[]>(r.rules, []), bookingTitles: parse<string[]>(r.booking_titles, []),
|
|
23
|
+
startDate: String(r.start_date), state: r.state as GcalLink['state'], pausedReason: String(r.paused_reason ?? ''), siteUrl: String(r.site_url),
|
|
24
|
+
matchedAt: Number(r.matched_at ?? 0), match: parse<MatchState | null>(r.match, null), syncToken: String(r.sync_token ?? ''), fullSync: !!r.full_sync,
|
|
25
|
+
lease: String(r.lease ?? ''), leaseUntil: Number(r.lease_until ?? 0), nextAt: Number(r.next_at ?? 0), failures: Number(r.failures ?? 0),
|
|
26
|
+
failingSince: Number(r.failing_since ?? 0), alertedAt: Number(r.alerted_at ?? 0), lastOkAt: Number(r.last_ok_at ?? 0), error: String(r.error ?? ''),
|
|
27
|
+
revision: Number(r.revision), createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function item(r: Row): GcalItem {
|
|
31
|
+
return {
|
|
32
|
+
linkId: String(r.link_id), googleId: String(r.google_id), eventId: String(r.event_id), icalUid: String(r.ical_uid ?? ''), kind: r.kind as GcalItem['kind'],
|
|
33
|
+
segmentFrom: String(r.segment_from ?? ''), etag: String(r.etag ?? ''), htmlLink: String(r.html_link ?? ''), site: parse<Hashes>(r.site, {}), google: parse<Hashes>(r.google, {}),
|
|
34
|
+
// gone: 0 live, 1 deleted in Google, 2 deleted from this Google calendar only (left out)
|
|
35
|
+
descGoogle: !!r.desc_google, booking: !!r.booking, gone: !!r.gone, leftOut: Number(r.gone) === 2, conflict: parse<Conflict | null>(r.conflict, null), syncedAt: Number(r.synced_at ?? 0), createdAt: Number(r.created_at),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function logEntry(r: Row): LogEntry {
|
|
39
|
+
return { id: Number(r.id), at: Number(r.at), linkId: String(r.link_id ?? ''), eventId: String(r.event_id ?? ''), googleId: String(r.google_id ?? ''),
|
|
40
|
+
direction: r.direction as LogEntry['direction'], action: String(r.action), title: String(r.title ?? ''), summary: String(r.summary ?? ''),
|
|
41
|
+
data: parse<Record<string, unknown> | null>(r.data, null), resolved: !!r.resolved };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const accountColumns: Record<string, string> = { clientId: 'client_id', encrypted: 'encrypted', email: 'email', status: 'status', error: 'error', siteUrl: 'site_url',
|
|
45
|
+
calendars: 'calendars', calendarsAt: 'calendars_at', connectedBy: 'connected_by', connectedAt: 'connected_at' };
|
|
46
|
+
const linkColumns: Record<string, string> = { googleName: 'google_name', googleTz: 'google_tz', direction: 'direction', roomIds: 'room_ids', noRoom: 'no_room', rules: 'rules', bookingTitles: 'booking_titles',
|
|
47
|
+
startDate: 'start_date', state: 'state', pausedReason: 'paused_reason', siteUrl: 'site_url', matchedAt: 'matched_at', match: 'match', syncToken: 'sync_token',
|
|
48
|
+
fullSync: 'full_sync', nextAt: 'next_at', failures: 'failures', failingSince: 'failing_since', alertedAt: 'alerted_at', lastOkAt: 'last_ok_at', error: 'error' };
|
|
49
|
+
const itemColumns: Record<string, string> = { eventId: 'event_id', icalUid: 'ical_uid', kind: 'kind', segmentFrom: 'segment_from', etag: 'etag', htmlLink: 'html_link',
|
|
50
|
+
site: 'site', google: 'google', descGoogle: 'desc_google', booking: 'booking', gone: 'gone', conflict: 'conflict', syncedAt: 'synced_at' };
|
|
51
|
+
const jsonish = new Set(['calendars', 'roomIds', 'rules', 'bookingTitles', 'match', 'site', 'google', 'conflict']);
|
|
52
|
+
const goneValue = (i: Pick<GcalItem, 'gone' | 'leftOut'>) => (i.gone ? (i.leftOut ? 2 : 1) : 0);
|
|
53
|
+
function sets(data: Record<string, unknown>, columns: Record<string, string>) {
|
|
54
|
+
if ('leftOut' in data && data.leftOut !== undefined) data = { ...data, gone: goneValue({ gone: data.gone !== undefined ? !!data.gone : true, leftOut: !!data.leftOut }) };
|
|
55
|
+
const entries = Object.entries(data).filter(([k, v]) => columns[k] && v !== undefined);
|
|
56
|
+
return { sql: entries.map(([k]) => `${columns[k]}=?`), args: entries.map(([k, v]) => jsonish.has(k) ? (v === null ? null : JSON.stringify(v)) : typeof v === 'boolean' ? Number(v) : v) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface QueueRow { eventId: string; version: number; attempts: number }
|
|
60
|
+
|
|
61
|
+
export class GcalStore {
|
|
62
|
+
constructor(private driver: () => Promise<SqlDriver>) {}
|
|
63
|
+
private async db() { return this.driver(); }
|
|
64
|
+
|
|
65
|
+
// ---- the connected account ----
|
|
66
|
+
async account() { const r = (await (await this.db()).all('SELECT * FROM gcal_account WHERE id=1'))[0]; return r ? account(r) : null; }
|
|
67
|
+
/** Saves the OAuth client (a new client signs out: status setup). A fresh random revision invalidates any sign-in in progress. */
|
|
68
|
+
async saveClient(clientId: string, encrypted: string, now = Date.now()) {
|
|
69
|
+
await (await this.db()).run(`INSERT INTO gcal_account (id,client_id,encrypted,status,revision,updated_at) VALUES (1,?,?,'setup',?,?)
|
|
70
|
+
ON CONFLICT(id) DO UPDATE SET client_id=excluded.client_id,encrypted=excluded.encrypted,status='setup',error='',email='',calendars='[]',calendars_at=0,
|
|
71
|
+
connected_by='',connected_at=0,revision=revision+1,updated_at=excluded.updated_at`, [clientId, encrypted, randomInt(1, 2 ** 48 - 1), now]);
|
|
72
|
+
}
|
|
73
|
+
/** Changes the account if its revision still matches; `bump` invalidates sign-ins in progress. */
|
|
74
|
+
async patchAccount(data: Partial<GcalAccount>, revision: number, bump = false) {
|
|
75
|
+
const s = sets(data, accountColumns);
|
|
76
|
+
if (!s.sql.length) return false;
|
|
77
|
+
const rows = await (await this.db()).all(`UPDATE gcal_account SET ${s.sql.join(',')},updated_at=?${bump ? ',revision=revision+1' : ''} WHERE id=1 AND revision=? RETURNING id`, [...s.args, Date.now(), revision]);
|
|
78
|
+
return rows.length > 0;
|
|
79
|
+
}
|
|
80
|
+
/** Saves refreshed tokens without touching anything else (several links may refresh in one run). */
|
|
81
|
+
async saveTokens(encrypted: string, revision: number) {
|
|
82
|
+
await (await this.db()).run('UPDATE gcal_account SET encrypted=? WHERE id=1 AND revision=?', [encrypted, revision]);
|
|
83
|
+
}
|
|
84
|
+
/** Disconnect: the account and pending sign-ins go; links stay, paused, and so do both copies of every event. */
|
|
85
|
+
async removeAccount() {
|
|
86
|
+
await (await this.db()).batch([
|
|
87
|
+
{ sql: 'DELETE FROM gcal_account WHERE id=1' },
|
|
88
|
+
{ sql: 'DELETE FROM gcal_oauth_states' },
|
|
89
|
+
// links already paused (by hand, after a restore) keep their reason
|
|
90
|
+
{ sql: "UPDATE gcal_links SET state='paused',paused_reason='disconnected',lease='',lease_until=0,revision=revision+1,updated_at=? WHERE state<>'paused'", args: [Date.now()] },
|
|
91
|
+
]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---- one-use pending sign-ins (same contract as LiveStore's) ----
|
|
95
|
+
async state(hash: string, sessionHash: string, revision: number, encrypted: string, expiresAt: number) {
|
|
96
|
+
await (await this.db()).batch([{ sql: 'DELETE FROM gcal_oauth_states WHERE expires_at<? OR session_hash=?', args: [Date.now(), sessionHash] },
|
|
97
|
+
{ sql: 'INSERT INTO gcal_oauth_states (hash,session_hash,revision,encrypted,expires_at) VALUES (?,?,?,?,?)', args: [hash, sessionHash, revision, encrypted, expiresAt] }]);
|
|
98
|
+
}
|
|
99
|
+
async pendingState(hash: string, now: number) {
|
|
100
|
+
const r = (await (await this.db()).all('SELECT encrypted,expires_at FROM gcal_oauth_states WHERE hash=? AND expires_at>?', [hash, now]))[0];
|
|
101
|
+
return r ? { encrypted: String(r.encrypted), expiresAt: Number(r.expires_at) } : null;
|
|
102
|
+
}
|
|
103
|
+
/** Compare-and-swap on the old ciphertext: only the first callback counts. */
|
|
104
|
+
async attachCode(hash: string, before: string, after: string, expiresAt: number, now: number) {
|
|
105
|
+
return (await (await this.db()).all('UPDATE gcal_oauth_states SET encrypted=?,expires_at=? WHERE hash=? AND encrypted=? AND expires_at>? RETURNING hash', [after, expiresAt, hash, before, now])).length > 0;
|
|
106
|
+
}
|
|
107
|
+
async consumeSessionStates(sessionHash: string, now: number) {
|
|
108
|
+
const rows = await (await this.db()).all('DELETE FROM gcal_oauth_states WHERE session_hash=? AND expires_at>? RETURNING revision,encrypted', [sessionHash, now]);
|
|
109
|
+
return rows.map((r) => ({ revision: Number(r.revision), encrypted: String(r.encrypted) }));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---- links ----
|
|
113
|
+
async listLinks() { return (await (await this.db()).all('SELECT * FROM gcal_links ORDER BY created_at, id')).map(link); }
|
|
114
|
+
async getLink(id: string) { const r = (await (await this.db()).all('SELECT * FROM gcal_links WHERE id=?', [id]))[0]; return r ? link(r) : null; }
|
|
115
|
+
async createLink(data: Pick<GcalLink, 'calendarId' | 'googleId' | 'googleName' | 'googleTz' | 'direction' | 'roomIds' | 'noRoom' | 'rules' | 'bookingTitles' | 'startDate' | 'siteUrl'>, now = Date.now()) {
|
|
116
|
+
const id = crypto.randomUUID();
|
|
117
|
+
await (await this.db()).run(`INSERT INTO gcal_links (id,calendar_id,google_id,google_name,google_tz,direction,room_ids,no_room,rules,booking_titles,start_date,site_url,revision,created_at,updated_at)
|
|
118
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, [id, data.calendarId, data.googleId, data.googleName, data.googleTz, data.direction, JSON.stringify(data.roomIds),
|
|
119
|
+
data.noRoom ? 1 : 0, JSON.stringify(data.rules), JSON.stringify(data.bookingTitles), data.startDate, data.siteUrl, randomInt(1, 2 ** 48 - 1), now, now]);
|
|
120
|
+
return (await this.getLink(id))!;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Writes link fields if the revision still matches. With the lease (a sync run finishing) the
|
|
124
|
+
* revision stays and the lease is released; without it (a person's change) the revision moves on,
|
|
125
|
+
* so a run still in flight can't write its late result over the change.
|
|
126
|
+
*/
|
|
127
|
+
async patchLink(id: string, data: Partial<GcalLink>, revision: number, lease?: string) {
|
|
128
|
+
const s = sets(data, linkColumns);
|
|
129
|
+
const rows = await (await this.db()).all(`UPDATE gcal_links SET ${[...s.sql, 'updated_at=?'].join(',')},${lease ? "lease='',lease_until=0" : 'revision=revision+1'}
|
|
130
|
+
WHERE id=? AND revision=?${lease ? ' AND lease=?' : ''} RETURNING id`, [...s.args, Date.now(), id, revision, ...(lease ? [lease] : [])]);
|
|
131
|
+
return rows.length > 0;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Takes the link for one run: an active link that is due, or (forced: "Sync now", "Start syncing") any link that isn't paused.
|
|
135
|
+
* The lease outlasts a run's Google calls (110 s), so a second run can't take the link while the first is still working.
|
|
136
|
+
*/
|
|
137
|
+
async claimLink(id: string, now: number, force = false) {
|
|
138
|
+
const lease = crypto.randomUUID();
|
|
139
|
+
const rows = await (await this.db()).all(`UPDATE gcal_links SET lease=?,lease_until=? WHERE id=? AND lease_until<=?
|
|
140
|
+
AND ${force ? "state<>'paused'" : "state='active' AND next_at<=?"} RETURNING *`, force ? [lease, now + 150_000, id, now] : [lease, now + 150_000, id, now, now]);
|
|
141
|
+
return rows[0] ? link(rows[0]) : null;
|
|
142
|
+
}
|
|
143
|
+
/** Unlink: the pairs go, both copies of every event stay. Recent changes are kept. */
|
|
144
|
+
async deleteLink(id: string) {
|
|
145
|
+
await (await this.db()).batch([{ sql: 'DELETE FROM gcal_items WHERE link_id=?', args: [id] }, { sql: 'DELETE FROM gcal_links WHERE id=?', args: [id] }]);
|
|
146
|
+
}
|
|
147
|
+
/** Restore: every link waits for someone to check it, and changes queued before it describe content it replaced. */
|
|
148
|
+
static restoreStatements(): SqlStatement[] {
|
|
149
|
+
return [
|
|
150
|
+
{ sql: "UPDATE gcal_links SET state='paused',paused_reason='restored',lease='',lease_until=0,revision=revision+1" },
|
|
151
|
+
{ sql: 'DELETE FROM gcal_queue' },
|
|
152
|
+
{ sql: 'DELETE FROM gcal_oauth_states' },
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---- pairs ----
|
|
157
|
+
async itemsForLink(linkId: string) { return (await (await this.db()).all('SELECT * FROM gcal_items WHERE link_id=?', [linkId])).map(item); }
|
|
158
|
+
async itemsForEvent(eventId: string) { return (await (await this.db()).all('SELECT * FROM gcal_items WHERE event_id=?', [eventId])).map(item); }
|
|
159
|
+
async getItem(linkId: string, googleId: string) { const r = (await (await this.db()).all('SELECT * FROM gcal_items WHERE link_id=? AND google_id=?', [linkId, googleId]))[0]; return r ? item(r) : null; }
|
|
160
|
+
async countItems() {
|
|
161
|
+
const rows = await (await this.db()).all('SELECT link_id, COUNT(*) AS n FROM gcal_items WHERE gone=0 GROUP BY link_id');
|
|
162
|
+
return new Map(rows.map((r) => [String(r.link_id), Number(r.n)]));
|
|
163
|
+
}
|
|
164
|
+
async putItem(i: GcalItem) {
|
|
165
|
+
await (await this.db()).run(`INSERT INTO gcal_items (link_id,google_id,event_id,ical_uid,kind,segment_from,etag,html_link,site,google,desc_google,booking,gone,conflict,synced_at,created_at)
|
|
166
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(link_id,google_id) DO UPDATE SET event_id=excluded.event_id,ical_uid=excluded.ical_uid,kind=excluded.kind,
|
|
167
|
+
segment_from=excluded.segment_from,etag=excluded.etag,html_link=excluded.html_link,site=excluded.site,google=excluded.google,desc_google=excluded.desc_google,
|
|
168
|
+
booking=excluded.booking,gone=excluded.gone,conflict=excluded.conflict,synced_at=excluded.synced_at`,
|
|
169
|
+
[i.linkId, i.googleId, i.eventId, i.icalUid, i.kind, i.segmentFrom, i.etag, i.htmlLink, JSON.stringify(i.site), JSON.stringify(i.google), i.descGoogle ? 1 : 0,
|
|
170
|
+
i.booking ? 1 : 0, goneValue(i), i.conflict ? JSON.stringify(i.conflict) : null, i.syncedAt, i.createdAt]);
|
|
171
|
+
}
|
|
172
|
+
async patchItem(linkId: string, googleId: string, data: Partial<GcalItem>) {
|
|
173
|
+
const s = sets(data, itemColumns);
|
|
174
|
+
if (s.sql.length) await (await this.db()).run(`UPDATE gcal_items SET ${s.sql.join(',')} WHERE link_id=? AND google_id=?`, [...s.args, linkId, googleId]);
|
|
175
|
+
}
|
|
176
|
+
/** The Google copy was made again (a new Google id): one write, so the pair is never lost in between. */
|
|
177
|
+
async replaceItem(oldGoogleId: string, next: GcalItem) {
|
|
178
|
+
await (await this.db()).batch([
|
|
179
|
+
{ sql: 'DELETE FROM gcal_items WHERE link_id=? AND google_id=?', args: [next.linkId, oldGoogleId] },
|
|
180
|
+
{ sql: `INSERT INTO gcal_items (link_id,google_id,event_id,ical_uid,kind,segment_from,etag,html_link,site,google,desc_google,booking,gone,conflict,synced_at,created_at)
|
|
181
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, args: [next.linkId, next.googleId, next.eventId, next.icalUid, next.kind, next.segmentFrom, next.etag, next.htmlLink,
|
|
182
|
+
JSON.stringify(next.site), JSON.stringify(next.google), next.descGoogle ? 1 : 0, next.booking ? 1 : 0, goneValue(next), next.conflict ? JSON.stringify(next.conflict) : null, next.syncedAt, next.createdAt] },
|
|
183
|
+
]);
|
|
184
|
+
}
|
|
185
|
+
async deleteItem(linkId: string, googleId: string) { await (await this.db()).run('DELETE FROM gcal_items WHERE link_id=? AND google_id=?', [linkId, googleId]); }
|
|
186
|
+
|
|
187
|
+
// ---- the outgoing queue ----
|
|
188
|
+
/** Due events, oldest first, each held for `leaseMs` (another run skips them meanwhile). */
|
|
189
|
+
async claimQueue(now: number, limit: number, leaseMs: number): Promise<QueueRow[]> {
|
|
190
|
+
const rows = await (await this.db()).all(`UPDATE gcal_queue SET claimed_until=?,attempts=attempts+1 WHERE event_id IN (
|
|
191
|
+
SELECT event_id FROM gcal_queue WHERE due_at<=? AND claimed_until<=? ORDER BY due_at LIMIT ?) RETURNING event_id,version,attempts`, [now + leaseMs, now, now, limit]);
|
|
192
|
+
return rows.map((r) => ({ eventId: String(r.event_id), version: Number(r.version), attempts: Number(r.attempts) }));
|
|
193
|
+
}
|
|
194
|
+
/** Done with the change it claimed; a newer change to the same event (version moved on) stays queued, free for the next run. */
|
|
195
|
+
async finishQueue(eventId: string, version: number) {
|
|
196
|
+
await (await this.db()).batch([
|
|
197
|
+
{ sql: 'DELETE FROM gcal_queue WHERE event_id=? AND version=?', args: [eventId, version] },
|
|
198
|
+
{ sql: 'UPDATE gcal_queue SET claimed_until=0 WHERE event_id=?', args: [eventId] },
|
|
199
|
+
]);
|
|
200
|
+
}
|
|
201
|
+
async retryQueue(eventId: string, version: number, at: number, error: string) {
|
|
202
|
+
await (await this.db()).batch([
|
|
203
|
+
{ sql: 'UPDATE gcal_queue SET due_at=?,claimed_until=0,error=? WHERE event_id=? AND version=?', args: [at, error.slice(0, 500), eventId, version] },
|
|
204
|
+
{ sql: 'UPDATE gcal_queue SET claimed_until=0 WHERE event_id=? AND version<>?', args: [eventId, version] },
|
|
205
|
+
]);
|
|
206
|
+
}
|
|
207
|
+
/** Queues events by hand (a link starting or resuming catches up on its calendar). */
|
|
208
|
+
async enqueue(eventIds: string[], now = Date.now()) {
|
|
209
|
+
const stmts = [...new Set(eventIds)].map((id) => ({ sql: `INSERT INTO gcal_queue (event_id,version,due_at,attempts,claimed_until,error) VALUES (?,1,?,0,0,'')
|
|
210
|
+
ON CONFLICT(event_id) DO UPDATE SET version=version+1,due_at=excluded.due_at,attempts=0,error=''`, args: [id, now] as unknown[] }));
|
|
211
|
+
const db = await this.db();
|
|
212
|
+
for (let i = 0; i < stmts.length; i += 100) await db.batch(stmts.slice(i, i + 100));
|
|
213
|
+
}
|
|
214
|
+
async queueStats(now = Date.now()) {
|
|
215
|
+
const r = (await (await this.db()).all("SELECT COUNT(*) AS n, SUM(CASE WHEN error<>'' THEN 1 ELSE 0 END) AS failing, MIN(due_at) AS oldest FROM gcal_queue"))[0];
|
|
216
|
+
return { waiting: Number(r?.n ?? 0), failing: Number(r?.failing ?? 0), oldestMs: r?.oldest ? Math.max(0, now - Number(r.oldest)) : 0 };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---- Recent changes ----
|
|
220
|
+
async log(entries: NewLogEntry[], now = Date.now()) {
|
|
221
|
+
const db = await this.db();
|
|
222
|
+
const ids: number[] = [];
|
|
223
|
+
for (const e of entries) {
|
|
224
|
+
const r = (await db.all(`INSERT INTO gcal_log (at,link_id,event_id,google_id,direction,action,title,summary,data) VALUES (?,?,?,?,?,?,?,?,?) RETURNING id`,
|
|
225
|
+
[e.at ?? now, e.linkId ?? '', e.eventId ?? '', e.googleId ?? '', e.direction, e.action, (e.title ?? '').slice(0, 300), (e.summary ?? '').slice(0, 1000), e.data ? JSON.stringify(e.data) : null]))[0];
|
|
226
|
+
ids.push(Number(r?.id ?? 0));
|
|
227
|
+
}
|
|
228
|
+
return ids;
|
|
229
|
+
}
|
|
230
|
+
async listLog(q: { linkId?: string; eventId?: string; limit?: number; before?: number } = {}) {
|
|
231
|
+
const where: string[] = [], args: unknown[] = [];
|
|
232
|
+
if (q.linkId) { where.push('link_id=?'); args.push(q.linkId); }
|
|
233
|
+
if (q.eventId) { where.push('event_id=?'); args.push(q.eventId); }
|
|
234
|
+
if (q.before) { where.push('id<?'); args.push(q.before); }
|
|
235
|
+
const rows = await (await this.db()).all(`SELECT * FROM gcal_log${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY at DESC, id DESC LIMIT ?`, [...args, Math.min(500, q.limit ?? 50)]);
|
|
236
|
+
return rows.map(logEntry);
|
|
237
|
+
}
|
|
238
|
+
async getLog(id: number) { const r = (await (await this.db()).all('SELECT * FROM gcal_log WHERE id=?', [id]))[0]; return r ? logEntry(r) : null; }
|
|
239
|
+
async resolveLog(id: number) { await (await this.db()).run('UPDATE gcal_log SET resolved=1 WHERE id=?', [id]); }
|
|
240
|
+
async purgeLog(now = Date.now()) { await (await this.db()).run('DELETE FROM gcal_log WHERE at<?', [now - LOG_DAYS * 86_400_000]); }
|
|
241
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
// Two-way Google Calendar sync: the stored records (lib/calendar/google/store.ts)
|
|
4
|
+
// and the parts of Google's API responses the sync reads.
|
|
5
|
+
|
|
6
|
+
/** both ways; site → Google only; Google → site only */
|
|
7
|
+
export const Direction = z.enum(['both', 'out', 'in']);
|
|
8
|
+
export type Direction = z.infer<typeof Direction>;
|
|
9
|
+
|
|
10
|
+
/** A title prefix ⇄ a site tag ("CLASS:" ⇄ Class), both ways, the prefix kept in the title on both sides. */
|
|
11
|
+
export const PrefixRule = z.object({
|
|
12
|
+
prefix: z.string().trim().min(1).max(40),
|
|
13
|
+
tag: z.string().trim().min(1).max(80),
|
|
14
|
+
}).strict();
|
|
15
|
+
export type PrefixRule = z.infer<typeof PrefixRule>;
|
|
16
|
+
|
|
17
|
+
/** One Google calendar the connected account can edit (from calendarList). */
|
|
18
|
+
export interface GoogleCalendarEntry { id: string; name: string; timeZone: string; accessRole: string; primary: boolean; color: string }
|
|
19
|
+
|
|
20
|
+
export interface GcalAccount {
|
|
21
|
+
clientId: string;
|
|
22
|
+
/** sealed GcalCredentials */
|
|
23
|
+
encrypted: string;
|
|
24
|
+
/** the account's address (its primary calendar id), once connected */
|
|
25
|
+
email: string;
|
|
26
|
+
/** setup: client saved, not signed in; connected; reconnect: Google refused the saved sign-in */
|
|
27
|
+
status: 'setup' | 'connected' | 'reconnect';
|
|
28
|
+
error: string;
|
|
29
|
+
/** the site origin it was connected on */
|
|
30
|
+
siteUrl: string;
|
|
31
|
+
calendars: GoogleCalendarEntry[];
|
|
32
|
+
calendarsAt: number;
|
|
33
|
+
connectedBy: string;
|
|
34
|
+
connectedAt: number;
|
|
35
|
+
revision: number;
|
|
36
|
+
updatedAt: number;
|
|
37
|
+
}
|
|
38
|
+
export interface GcalCredentials { clientSecret: string; accessToken?: string; refreshToken?: string; tokenExpiresAt?: number; scope?: string; callbackUrl?: string }
|
|
39
|
+
|
|
40
|
+
/** The first-match review, saved on the link until "Start syncing" (lib/calendar/google/match.ts). */
|
|
41
|
+
export interface MatchPair {
|
|
42
|
+
eventId: string;
|
|
43
|
+
googleId: string;
|
|
44
|
+
/** why they were paired: the hidden id, the imported UID, or title and time */
|
|
45
|
+
reason: 'hidden-id' | 'uid' | 'title-time' | 'time-repeat';
|
|
46
|
+
/** linked (sure, or confirmed), suggested (waits for a person), apart ("Not the same") */
|
|
47
|
+
state: 'linked' | 'suggested' | 'apart';
|
|
48
|
+
}
|
|
49
|
+
/** What the review page shows about a Google event (it isn't fetched again for every page view). */
|
|
50
|
+
export interface MatchGoogle { title: string; start: number; allDay: boolean; repeats: boolean; booking: boolean }
|
|
51
|
+
export interface MatchState { builtAt: number; pairs: MatchPair[]; siteOnly: string[]; googleOnly: string[]; google: Record<string, MatchGoogle> }
|
|
52
|
+
|
|
53
|
+
export interface GcalLink {
|
|
54
|
+
id: string;
|
|
55
|
+
/** the site calendar */
|
|
56
|
+
calendarId: string;
|
|
57
|
+
/** the Google calendar */
|
|
58
|
+
googleId: string;
|
|
59
|
+
googleName: string;
|
|
60
|
+
googleTz: string;
|
|
61
|
+
direction: Direction;
|
|
62
|
+
/** the rooms this Google calendar stands for: its events hold them, and site events holding them go here */
|
|
63
|
+
roomIds: string[];
|
|
64
|
+
/** also gets the site calendar's events that hold no room */
|
|
65
|
+
noRoom: boolean;
|
|
66
|
+
rules: PrefixRule[];
|
|
67
|
+
/** titles of the Google calendar's booking pages (appointment schedules): booked appointments come in as "Booked" (Q16) */
|
|
68
|
+
bookingTitles: string[];
|
|
69
|
+
/** YYYY-MM-DD: events from this date on, plus repeating series still running */
|
|
70
|
+
startDate: string;
|
|
71
|
+
/** matching: waiting for the first-match review; active; paused (pausedReason says why) */
|
|
72
|
+
state: 'matching' | 'active' | 'paused';
|
|
73
|
+
pausedReason: string;
|
|
74
|
+
/** the site origin that made the link: it only runs there */
|
|
75
|
+
siteUrl: string;
|
|
76
|
+
/** when "Start syncing" ran (0 = never: resuming goes back to matching) */
|
|
77
|
+
matchedAt: number;
|
|
78
|
+
match: MatchState | null;
|
|
79
|
+
syncToken: string;
|
|
80
|
+
/** the next read lists everything (first run, Google's 410, a resume): quiet, pairs by hidden id first */
|
|
81
|
+
fullSync: boolean;
|
|
82
|
+
lease: string;
|
|
83
|
+
leaseUntil: number;
|
|
84
|
+
nextAt: number;
|
|
85
|
+
failures: number;
|
|
86
|
+
failingSince: number;
|
|
87
|
+
alertedAt: number;
|
|
88
|
+
lastOkAt: number;
|
|
89
|
+
error: string;
|
|
90
|
+
revision: number;
|
|
91
|
+
createdAt: number;
|
|
92
|
+
updatedAt: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** hashes of each synced field (lib/calendar/google/map.ts fieldHashes) */
|
|
96
|
+
export type Hashes = Record<string, string>;
|
|
97
|
+
|
|
98
|
+
/** The latest field both sides changed, until someone picks a version or edits it again (sent: the site's value has gone to Google since). */
|
|
99
|
+
export interface Conflict { field: string; at: number; site: string; google: string; logId: number; sent?: boolean }
|
|
100
|
+
|
|
101
|
+
export interface GcalItem {
|
|
102
|
+
linkId: string;
|
|
103
|
+
googleId: string;
|
|
104
|
+
eventId: string;
|
|
105
|
+
icalUid: string;
|
|
106
|
+
/** series (repeating), single, or segment: a later part from Google's "this and following" (C4 takes these over) */
|
|
107
|
+
kind: 'series' | 'single' | 'segment';
|
|
108
|
+
/** segment: the occurrence key it takes over from */
|
|
109
|
+
segmentFrom: string;
|
|
110
|
+
etag: string;
|
|
111
|
+
htmlLink: string;
|
|
112
|
+
/** each side's fields as last agreed */
|
|
113
|
+
site: Hashes;
|
|
114
|
+
google: Hashes;
|
|
115
|
+
/** created in Google and its description never edited on the site: Google's description is used (Q8) */
|
|
116
|
+
descGoogle: boolean;
|
|
117
|
+
/** a booked appointment from a Google booking page: Google's, never written back */
|
|
118
|
+
booking: boolean;
|
|
119
|
+
/** the Google copy was deleted in Google: not recreated unless the event comes back on the site */
|
|
120
|
+
gone: boolean;
|
|
121
|
+
/** (with gone) deleted from this Google calendar only, while the event stays in its other linked copies: it stays out of this one */
|
|
122
|
+
leftOut: boolean;
|
|
123
|
+
conflict: Conflict | null;
|
|
124
|
+
syncedAt: number;
|
|
125
|
+
createdAt: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type LogDirection = 'in' | 'out' | 'clash' | 'info';
|
|
129
|
+
export interface LogEntry {
|
|
130
|
+
id: number;
|
|
131
|
+
at: number;
|
|
132
|
+
linkId: string;
|
|
133
|
+
eventId: string;
|
|
134
|
+
googleId: string;
|
|
135
|
+
direction: LogDirection;
|
|
136
|
+
action: string;
|
|
137
|
+
title: string;
|
|
138
|
+
/** what changed, in words */
|
|
139
|
+
summary: string;
|
|
140
|
+
/** before/after values; a clash keeps Google's value for "Use Google's version" */
|
|
141
|
+
data: Record<string, unknown> | null;
|
|
142
|
+
resolved: boolean;
|
|
143
|
+
}
|
|
144
|
+
export type NewLogEntry = Pick<LogEntry, 'direction' | 'action'> & Partial<Pick<LogEntry, 'at' | 'linkId' | 'eventId' | 'googleId' | 'data' | 'title' | 'summary'>>;
|
|
145
|
+
|
|
146
|
+
// ---- Google's API (only what the sync reads; unknown fields are dropped) ----
|
|
147
|
+
|
|
148
|
+
const text = (max: number) => z.string().max(max);
|
|
149
|
+
export const GoogleTime = z.object({ date: text(20).optional(), dateTime: text(40).optional(), timeZone: text(100).optional() });
|
|
150
|
+
export type GoogleTime = z.infer<typeof GoogleTime>;
|
|
151
|
+
const Person = z.object({ email: text(320).optional(), displayName: text(500).optional(), self: z.boolean().optional() });
|
|
152
|
+
export const GoogleEvent = z.object({
|
|
153
|
+
id: text(1024),
|
|
154
|
+
status: text(20).optional(),
|
|
155
|
+
etag: text(200).optional(),
|
|
156
|
+
htmlLink: text(2048).optional(),
|
|
157
|
+
summary: text(8192).optional(),
|
|
158
|
+
description: text(65536).optional(),
|
|
159
|
+
location: text(8192).optional(),
|
|
160
|
+
start: GoogleTime.optional(),
|
|
161
|
+
end: GoogleTime.optional(),
|
|
162
|
+
originalStartTime: GoogleTime.optional(),
|
|
163
|
+
recurringEventId: text(1024).optional(),
|
|
164
|
+
recurrence: z.array(text(8192)).max(100).optional(),
|
|
165
|
+
iCalUID: text(1024).optional(),
|
|
166
|
+
eventType: text(40).optional(),
|
|
167
|
+
updated: text(40).optional(),
|
|
168
|
+
created: text(40).optional(),
|
|
169
|
+
organizer: Person.optional(),
|
|
170
|
+
creator: Person.optional(),
|
|
171
|
+
attendees: z.array(Person.extend({ organizer: z.boolean().optional(), resource: z.boolean().optional() })).max(1000).optional(),
|
|
172
|
+
extendedProperties: z.object({ private: z.record(text(1024)).optional(), shared: z.record(text(1024)).optional() }).optional(),
|
|
173
|
+
source: z.object({ url: text(2048).optional(), title: text(1000).optional() }).optional(),
|
|
174
|
+
});
|
|
175
|
+
export type GoogleEvent = z.infer<typeof GoogleEvent>;
|
|
176
|
+
export const GoogleEventPage = z.object({
|
|
177
|
+
items: z.array(GoogleEvent).max(2500).default([]),
|
|
178
|
+
nextPageToken: text(4096).optional(),
|
|
179
|
+
nextSyncToken: text(4096).optional(),
|
|
180
|
+
timeZone: text(100).optional(),
|
|
181
|
+
});
|
|
182
|
+
export const GoogleCalendarListPage = z.object({
|
|
183
|
+
items: z.array(z.object({
|
|
184
|
+
id: text(1024), summary: text(1000).optional(), summaryOverride: text(1000).optional(), timeZone: text(100).optional(),
|
|
185
|
+
accessRole: text(40).optional(), primary: z.boolean().optional(), backgroundColor: text(20).optional(), deleted: z.boolean().optional(),
|
|
186
|
+
})).max(250).default([]),
|
|
187
|
+
nextPageToken: text(4096).optional(),
|
|
188
|
+
});
|
|
@@ -194,7 +194,7 @@ export async function undoMove(repo: ContentRepository, actor: MoveActor, undo:
|
|
|
194
194
|
// checks: callers (admin actions, site code through @acms-subir/core/server)
|
|
195
195
|
// decide who may do this.
|
|
196
196
|
|
|
197
|
-
async function setOccurrenceCancelled(repo: ContentRepository, id: string, key: string, cancel: boolean, hooks: SaveHooks, now = Date.now()): Promise<CalEvent> {
|
|
197
|
+
async function setOccurrenceCancelled(repo: ContentRepository, id: string, key: string, cancel: boolean, hooks: SaveHooks, now = Date.now(), opts: { quiet?: boolean } = {}): Promise<CalEvent> {
|
|
198
198
|
const e = await repo.getEvent(id);
|
|
199
199
|
if (!e) throw new ApiError(404, 'Event not found.');
|
|
200
200
|
// error.code as for moves (lib/calendar/moves.ts MoveRefusal)
|
|
@@ -210,22 +210,22 @@ async function setOccurrenceCancelled(repo: ContentRepository, id: string, key:
|
|
|
210
210
|
// a date is moved or cancelled, never both: cancelling a moved date drops its move (its RSVPs, at the new time, are cancelled below)
|
|
211
211
|
if (cancel && Object.hasOwn(e.movedDates, key)) {
|
|
212
212
|
const { [key]: _moved, ...moved } = e.movedDates;
|
|
213
|
-
await repo.setEventMovedDates(id, moved, cancelledDates);
|
|
214
|
-
} else await repo.setEventCancelledDates(id, cancelledDates);
|
|
213
|
+
await repo.setEventMovedDates(id, moved, cancelledDates, opts);
|
|
214
|
+
} else await repo.setEventCancelledDates(id, cancelledDates, opts);
|
|
215
215
|
if (cancel && await repo.cancelOccurrenceRsvps(id, o.start)) await kickEmail(repo, hooks.locals, waitUntilFrom(hooks.locals));
|
|
216
216
|
const after = (await repo.getEvent(id))!;
|
|
217
217
|
await eventSideEffects(repo, hooks, after);
|
|
218
218
|
return after;
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
-
/** Cancel one date of a repeating event, at once (also on a published event). Idempotent. */
|
|
222
|
-
export function cancelOccurrence(repo: ContentRepository, id: string, key: string, hooks: SaveHooks, now?: number): Promise<CalEvent> {
|
|
223
|
-
return setOccurrenceCancelled(repo, id, key, true, hooks, now);
|
|
221
|
+
/** Cancel one date of a repeating event, at once (also on a published event). Idempotent. quiet: no app notice (RSVP emails still go). */
|
|
222
|
+
export function cancelOccurrence(repo: ContentRepository, id: string, key: string, hooks: SaveHooks, now?: number, opts?: { quiet?: boolean }): Promise<CalEvent> {
|
|
223
|
+
return setOccurrenceCancelled(repo, id, key, true, hooks, now, opts);
|
|
224
224
|
}
|
|
225
225
|
|
|
226
226
|
/** Undo cancelOccurrence for one date. Idempotent. */
|
|
227
|
-
export function restoreOccurrence(repo: ContentRepository, id: string, key: string, hooks: SaveHooks, now?: number): Promise<CalEvent> {
|
|
228
|
-
return setOccurrenceCancelled(repo, id, key, false, hooks, now);
|
|
227
|
+
export function restoreOccurrence(repo: ContentRepository, id: string, key: string, hooks: SaveHooks, now?: number, opts?: { quiet?: boolean }): Promise<CalEvent> {
|
|
228
|
+
return setOccurrenceCancelled(repo, id, key, false, hooks, now, opts);
|
|
229
229
|
}
|
|
230
230
|
|
|
231
231
|
export async function duplicateEvent(repo: ContentRepository, id: string): Promise<CalEvent> {
|
package/src/lib/display/board.ts
CHANGED
|
@@ -18,7 +18,9 @@ import { renderBoard, type DisplayItem, type DisplayModel, type DisplayRoom } fr
|
|
|
18
18
|
import { displayStrings, isBookedWord } from './words';
|
|
19
19
|
import { qrSvg } from './http';
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
// booked appointments from Google booking pages (lib/calendar/google/map.ts)
|
|
22
|
+
export { BOOKING_TAG as GOOGLE_BOOKING_TAG } from '../calendar/google/map';
|
|
23
|
+
import { BOOKING_TAG as GOOGLE_BOOKING_TAG } from '../calendar/google/map';
|
|
22
24
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
23
25
|
const isoOf = (p: { y: number; m: number; d: number }) => `${p.y}-${pad(p.m)}-${pad(p.d)}`;
|
|
24
26
|
|
package/src/lib/email/service.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ContentRepository } from '../repo/types';
|
|
2
2
|
import { resolveConfig, decrypt, encrypt, runtimeValue } from './config';
|
|
3
3
|
import { sendWithProvider } from './providers';
|
|
4
|
-
import { accountMail, canonicalOrigin, eventRequestMail, guestCodeMail, phoneLinkedMail, EmailLinkError, type EventRequestMailKind } from './templates';
|
|
4
|
+
import { accountMail, canonicalOrigin, eventRequestMail, googleSyncAlertMail, guestCodeMail, phoneLinkedMail, EmailLinkError, type EventRequestMailKind } from './templates';
|
|
5
5
|
import { formatDate, siteLang } from '../dates';
|
|
6
6
|
import { I18N } from '../i18n';
|
|
7
7
|
import { bookingMail } from '../booking/service';
|
|
@@ -73,6 +73,16 @@ async function render(repo: ContentRepository, job: EmailJob, locals?: unknown,
|
|
|
73
73
|
const url = staff ? `${origin}/admin/calendar/events/${e.id}` : `${origin}${MEMBERS.enabled ? memberEventsPath() : '/'}`;
|
|
74
74
|
return eventRequestMail(p.kind, job.recipient, settings.siteTitle, { title: e.title, when: formatDate(e.startAt, settings), by: by?.name ?? '', note: e.request?.note ?? '' }, p.reply, url);
|
|
75
75
|
}
|
|
76
|
+
if (job.kind === 'gcal-alert') {
|
|
77
|
+
// only while that link is still failing since the same time
|
|
78
|
+
const p = JSON.parse(job.payload) as { linkId: string; since: number };
|
|
79
|
+
const link = await repo.gcal.getLink(p.linkId);
|
|
80
|
+
if (!link || link.state !== 'active' || link.failingSince !== p.since) return null;
|
|
81
|
+
let since: string;
|
|
82
|
+
try { since = new Intl.DateTimeFormat(siteLang(settings), { dateStyle: 'medium', timeStyle: 'short', timeZone: settings.timezone || 'UTC' }).format(p.since); }
|
|
83
|
+
catch { since = new Date(p.since).toISOString(); }
|
|
84
|
+
return googleSyncAlertMail(job.recipient, settings.siteTitle, { google: link.googleName, error: link.error, since }, `${origin}/admin/calendar/settings#google`);
|
|
85
|
+
}
|
|
76
86
|
if (job.kind === 'welcome' || job.kind === 'password-changed') return accountMail(job.kind, job.recipient, settings.siteTitle, `${origin}/admin/login`);
|
|
77
87
|
if (job.kind === 'email-changed') return accountMail(job.kind, job.recipient, settings.siteTitle);
|
|
78
88
|
return null;
|
|
@@ -38,6 +38,14 @@ export function eventRequestMail(kind: EventRequestMailKind, to: string, site: s
|
|
|
38
38
|
return { to, subject: `${copy[0]} · ${site}`, text: [copy[0], '', copy[1], ...(extra ? ['', extra] : []), '', `${copy[2]}: ${url}`].join('\n'),
|
|
39
39
|
html: `<!doctype html><html><body style="margin:0;background:#f6f5f2;color:#24211c;font:16px/1.6 system-ui,sans-serif"><div style="max-width:520px;margin:auto;padding:32px 24px"><p style="color:#7d776e">${escape(site)}</p><h1 style="font-size:22px">${escape(copy[0])}</h1><p>${escape(copy[1])}</p>${extra ? `<p style="background:#efebe4;border-radius:8px;padding:10px 12px">${escape(extra)}</p>` : ''}<p><a style="display:inline-block;background:#ee5d20;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none" href="${escape(url)}">${escape(copy[2])}</a></p></div></body></html>` };
|
|
40
40
|
}
|
|
41
|
+
/** staff: a Google Calendar link has been failing for an hour (lib/calendar/google/service.ts) */
|
|
42
|
+
export function googleSyncAlertMail(to: string, site: string, link: { google: string; error: string; since: string }, url: string): Mail {
|
|
43
|
+
const subject = `Google Calendar sync stopped: ${link.google}`;
|
|
44
|
+
const intro = `The site hasn’t been able to sync with “${link.google}” since ${link.since}. Changes on either side wait until it works again; nothing is lost.`;
|
|
45
|
+
const reason = link.error ? `Google said: ${link.error}` : '';
|
|
46
|
+
return { to, subject: `${subject} · ${site}`, text: [subject, '', intro, ...(reason ? ['', reason] : []), '', `Check it in Calendar settings: ${url}`].join('\n'),
|
|
47
|
+
html: `<!doctype html><html><body style="margin:0;background:#f6f5f2;color:#24211c;font:16px/1.6 system-ui,sans-serif"><div style="max-width:520px;margin:auto;padding:32px 24px"><p style="color:#7d776e">${escape(site)}</p><h1 style="font-size:22px">${escape(subject)}</h1><p>${escape(intro)}</p>${reason ? `<p style="background:#efebe4;border-radius:8px;padding:10px 12px">${escape(reason)}</p>` : ''}<p><a style="display:inline-block;background:#ee5d20;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none" href="${escape(url)}">Open Calendar settings</a></p></div></body></html>` };
|
|
48
|
+
}
|
|
41
49
|
/** a guest's app sign-in code; no link, so it works on any device */
|
|
42
50
|
export function guestCodeMail(to: string, site: string, code: string): Mail {
|
|
43
51
|
const intro = `Enter this code in the ${site} app to see your bookings. It expires in 10 minutes. If you did not ask for it, ignore this message.`;
|
package/src/lib/legal/admin.ts
CHANGED
|
@@ -40,7 +40,7 @@ export async function legalAdminState(repo: ContentRepository, url: URL, locals?
|
|
|
40
40
|
updated: LEGAL.enabled ? { privacy: await policyUpdated(repo, 'privacy', facts, settings), terms: await policyUpdated(repo, 'terms', facts, settings) } : null,
|
|
41
41
|
covers: {
|
|
42
42
|
calendar: facts.calendar, bookings: facts.bookings, guestSignIn: facts.guestSignIn, push: facts.push.enabled, auth: facts.auth,
|
|
43
|
-
email: facts.email, maps: facts.maps, live: facts.live, analytics: facts.analytics, ai: facts.ai,
|
|
43
|
+
email: facts.email, maps: facts.maps, live: facts.live, googleCalendar: facts.googleCalendar, analytics: facts.analytics, ai: facts.ai,
|
|
44
44
|
},
|
|
45
45
|
warnings,
|
|
46
46
|
answers: appPrivacyAnswers(facts, link('privacy') ?? `${origin}/privacy`),
|
package/src/lib/legal/facts.ts
CHANGED
|
@@ -40,6 +40,8 @@ export interface LegalFacts {
|
|
|
40
40
|
/** maps provider name, or '' */
|
|
41
41
|
maps: string;
|
|
42
42
|
live: boolean;
|
|
43
|
+
/** a Google account is connected for the two-way Google Calendar sync (lib/calendar/google/) */
|
|
44
|
+
googleCalendar: boolean;
|
|
43
45
|
/** acms({ announcements }): urgent ones reach every device; read and closed state stays in the app */
|
|
44
46
|
announcements: boolean;
|
|
45
47
|
analytics: { umami: boolean; replay: boolean; meta: boolean; tiktok: boolean };
|
|
@@ -82,6 +84,7 @@ export async function legalFacts(repo: ContentRepository, settings: Settings, lo
|
|
|
82
84
|
email: email && !email.error && email.provider !== 'none' && email.config.enabled ? EMAIL_NAMES[email.provider] ?? email.provider : '',
|
|
83
85
|
maps: (() => { const m = mapsStatus(locals); return m.enabled ? m.label.replace(/ \(self-hosted\)$/, '') : ''; })(),
|
|
84
86
|
live: settings.live.mode !== 'off',
|
|
87
|
+
googleCalendar: CALENDAR.enabled && await repo.gcal.account().then((a) => !!a && a.status !== 'setup').catch(() => false),
|
|
85
88
|
announcements: ANNOUNCEMENTS.enabled,
|
|
86
89
|
analytics: {
|
|
87
90
|
umami: a.umami.enabled && !!a.umami.websiteId,
|
package/src/lib/legal/text-en.ts
CHANGED
|
@@ -14,6 +14,7 @@ function processors(f: LegalFacts): string[] {
|
|
|
14
14
|
...(f.push.enabled ? ['Expo, Apple and Google (app notifications)'] : []),
|
|
15
15
|
...(f.auth === 'clerk' ? ['Clerk (sign-in)'] : []),
|
|
16
16
|
...(f.maps ? [`${f.maps} (maps)`] : []),
|
|
17
|
+
...(f.googleCalendar ? ['Google (Google Calendar)'] : []),
|
|
17
18
|
...(f.analytics.umami ? ['Umami (visit statistics)'] : []),
|
|
18
19
|
...(f.ai ? ['OpenAI or another AI service we choose (writing tools used by staff)'] : []),
|
|
19
20
|
...(f.offsiteBackups ? ['our backup storage provider'] : []),
|
|
@@ -61,6 +62,9 @@ export function privacyEn(f: LegalFacts, country: string): PolicySection[] {
|
|
|
61
62
|
if (f.maps) s.push({ id: 'maps', title: 'Maps', text: [
|
|
62
63
|
`When staff enter an event’s address, we look it up with ${f.maps}. Your browser doesn’t contact the map service: map images are stored on this site. Directions links open Google Maps only if you choose them.`,
|
|
63
64
|
] });
|
|
65
|
+
if (f.googleCalendar) s.push({ id: 'google-calendar', title: 'Google Calendar', text: [
|
|
66
|
+
'We keep a copy of our calendar in our own Google Calendar account, and changes made there come back to this site. That copy holds what our calendar shows: event titles, times, places and descriptions. It never includes your RSVPs, your name or what you tell us when you sign up for an event, and Google sends no invitations. Appointments booked through our Google booking pages show on this site only as “Booked”, without names. Google applies its own privacy policy to its copy.',
|
|
67
|
+
] });
|
|
64
68
|
if (f.live) s.push({ id: 'live', title: 'Live video', text: [
|
|
65
69
|
'When we stream, the site shows our broadcast from YouTube or Twitch. Until you press play, your browser only contacts this site: preview images come through it. Pressing play, or opening the broadcast on YouTube or Twitch, loads that platform, which applies its own privacy policy.',
|
|
66
70
|
] });
|
package/src/lib/legal/text-es.ts
CHANGED
|
@@ -14,6 +14,7 @@ function processors(f: LegalFacts): string[] {
|
|
|
14
14
|
...(f.push.enabled ? ['Expo, Apple y Google (notificaciones de la app)'] : []),
|
|
15
15
|
...(f.auth === 'clerk' ? ['Clerk (inicio de sesión)'] : []),
|
|
16
16
|
...(f.maps ? [`${f.maps} (mapas)`] : []),
|
|
17
|
+
...(f.googleCalendar ? ['Google (Google Calendar)'] : []),
|
|
17
18
|
...(f.analytics.umami ? ['Umami (estadísticas de visitas)'] : []),
|
|
18
19
|
...(f.ai ? ['OpenAI u otro servicio de inteligencia artificial que elijamos (herramientas de redacción que usa el personal)'] : []),
|
|
19
20
|
...(f.offsiteBackups ? ['nuestro proveedor de almacenamiento de respaldos'] : []),
|
|
@@ -62,6 +63,9 @@ export function privacyEs(f: LegalFacts, country: string): PolicySection[] {
|
|
|
62
63
|
if (f.maps) s.push({ id: 'maps', title: 'Mapas', text: [
|
|
63
64
|
`Cuando el personal escribe la dirección de un evento, la buscamos con ${f.maps}. Su navegador no se conecta con el servicio de mapas: las imágenes de los mapas se guardan en este sitio. Los enlaces para llegar abren Google Maps solo si usted los elige.`,
|
|
64
65
|
] });
|
|
66
|
+
if (f.googleCalendar) s.push({ id: 'google-calendar', title: 'Google Calendar', text: [
|
|
67
|
+
'Guardamos una copia de nuestro calendario en nuestra propia cuenta de Google Calendar, y los cambios que se hacen allí regresan a este sitio. Esa copia contiene lo que muestra nuestro calendario: títulos, horarios, lugares y descripciones de los eventos. Nunca incluye sus confirmaciones de asistencia, su nombre ni lo que nos dice al inscribirse en un evento, y Google no envía invitaciones. Las citas reservadas en nuestras páginas de reserva de Google aparecen en este sitio solo como “Reservado”, sin nombres. Google aplica su propia política de privacidad a su copia.',
|
|
68
|
+
] });
|
|
65
69
|
if (f.live) s.push({ id: 'live', title: 'Video en vivo', text: [
|
|
66
70
|
'Cuando transmitimos, el sitio muestra nuestra transmisión de YouTube o Twitch. Hasta que usted presiona reproducir, su navegador solo se conecta con este sitio: las imágenes de vista previa pasan por él. Al presionar reproducir, o al abrir la transmisión en YouTube o Twitch, se carga esa plataforma, que aplica su propia política de privacidad.',
|
|
67
71
|
] });
|