@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.
Files changed (75) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/cli/main.mjs +2 -1
  3. package/cli/migrations.generated.mjs +59 -6
  4. package/docs/calendar.md +104 -2
  5. package/docs/google-calendar.md +302 -0
  6. package/docs/mobile.md +3 -1
  7. package/docs/server.md +58 -0
  8. package/integration.mjs +38 -0
  9. package/mobile-template/client/AGENTS.md +1 -0
  10. package/mobile-template/client/README.md +1 -1
  11. package/mobile-template/recipes/event-request/files/app/request.tsx +305 -0
  12. package/mobile-template/recipes/event-request/files/components/DayPicker.tsx +66 -0
  13. package/mobile-template/recipes/event-request/files/components/RequestEventLink.tsx +19 -0
  14. package/mobile-template/recipes/event-request/files/components/RequestEventRow.tsx +20 -0
  15. package/mobile-template/recipes/event-request/recipe.json +21 -0
  16. package/package.json +1 -1
  17. package/src/components/LegalSettings.tsx +1 -1
  18. package/src/components/MobileApp.tsx +2 -2
  19. package/src/components/editor/event/SingleDates.tsx +12 -2
  20. package/src/layouts/Admin.astro +7 -0
  21. package/src/lib/calendar/google/client.ts +167 -0
  22. package/src/lib/calendar/google/connect.ts +161 -0
  23. package/src/lib/calendar/google/context.ts +50 -0
  24. package/src/lib/calendar/google/inbound.ts +493 -0
  25. package/src/lib/calendar/google/links.ts +139 -0
  26. package/src/lib/calendar/google/map.ts +543 -0
  27. package/src/lib/calendar/google/match.ts +103 -0
  28. package/src/lib/calendar/google/migration.ts +71 -0
  29. package/src/lib/calendar/google/outbound.ts +272 -0
  30. package/src/lib/calendar/google/service.ts +193 -0
  31. package/src/lib/calendar/google/store.ts +241 -0
  32. package/src/lib/calendar/google/types.ts +188 -0
  33. package/src/lib/calendar/request-admin.ts +211 -0
  34. package/src/lib/calendar/request-config.ts +8 -0
  35. package/src/lib/calendar/request-mail.ts +158 -0
  36. package/src/lib/calendar/request-page.ts +52 -0
  37. package/src/lib/calendar/request-service.ts +535 -0
  38. package/src/lib/calendar/request-store.ts +2 -2
  39. package/src/lib/calendar/request-words.ts +263 -0
  40. package/src/lib/calendar/schedule.ts +8 -8
  41. package/src/lib/display/board.ts +3 -1
  42. package/src/lib/email/service.ts +17 -2
  43. package/src/lib/email/templates.ts +8 -0
  44. package/src/lib/legal/admin.ts +1 -1
  45. package/src/lib/legal/app-privacy.ts +2 -2
  46. package/src/lib/legal/facts.ts +7 -0
  47. package/src/lib/legal/text-en.ts +7 -0
  48. package/src/lib/legal/text-es.ts +7 -0
  49. package/src/lib/live/crypto.ts +7 -28
  50. package/src/lib/mobile/http.ts +2 -1
  51. package/src/lib/mobile/openapi.ts +8 -0
  52. package/src/lib/mobile/requests.ts +27 -0
  53. package/src/lib/mobile/schemas.ts +14 -3
  54. package/src/lib/mobile/site.ts +3 -0
  55. package/src/lib/push/types.ts +1 -1
  56. package/src/lib/repo/migrations.ts +4 -0
  57. package/src/lib/repo/sql.ts +18 -9
  58. package/src/lib/repo/types.ts +6 -2
  59. package/src/lib/scheduler.ts +6 -0
  60. package/src/lib/schema.ts +43 -2
  61. package/src/lib/sealed.ts +46 -0
  62. package/src/pages/admin/calendar/events/[id].astro +42 -1
  63. package/src/pages/admin/calendar/events/index.astro +2 -0
  64. package/src/pages/admin/calendar/google/match.astro +160 -0
  65. package/src/pages/admin/calendar/google.astro +107 -0
  66. package/src/pages/admin/calendar/requests.astro +298 -0
  67. package/src/pages/admin/calendar/settings.astro +273 -4
  68. package/src/pages/admin/integrations.astro +6 -0
  69. package/src/pages/api/calendar/google/oauth/callback.ts +16 -0
  70. package/src/pages/api/v1/requests/index.ts +6 -0
  71. package/src/pages/api/v1/requests/options.ts +7 -0
  72. package/src/pages/request/index.astro +182 -0
  73. package/src/server/index.ts +105 -1
  74. package/src/styles/site.css +29 -0
  75. package/src/virtual.d.ts +1 -0
@@ -0,0 +1,493 @@
1
+ // Google → site. Every 2 minutes each link asks Google what changed since
2
+ // its sync token (events.list with syncToken; deleted events included). A
3
+ // 410 (or a first run, a resume) reads everything from the link's start date
4
+ // again: a full resync pairs by the pairs it knows, then by the hidden link
5
+ // on each Google copy (extendedProperties.private.acmsEventId), so it makes
6
+ // no doubles, and it sends no member pushes (Q10).
7
+ //
8
+ // What each change becomes on the site:
9
+ // - a new Google event: a published site event in the link's calendar,
10
+ // holding the link's rooms (Q5); a booked appointment comes in as "Booked"
11
+ // with the internal #google-booking tag and nothing else (Q16)
12
+ // - a changed field: copied, unless the site changed the same field since
13
+ // they last agreed; then the site wins and Google's value is kept for
14
+ // "Use Google's version" (Q4). A description only while it's Google's (Q8).
15
+ // - one date of a series cancelled or moved: C1's cancelOccurrence /
16
+ // moveOccurrence as GOOGLE_SYNC, so RSVPs, room holds and pushes follow;
17
+ // a per-date title or description isn't kept (listed instead)
18
+ // - deleted: cancelled on the site (struck through, room freed, restorable,
19
+ // Q6), or, when the event has another linked copy, just this link's room
20
+ // and calendar taken off it
21
+ // - "this and following": the new Google series is linked to the same site
22
+ // event as a later part (kind segment) and listed for staff; C4 ("from this
23
+ // date on") will apply those later
24
+ import { EventLocation, RoomHold, type CalEvent } from '../../schema';
25
+ import { eventSideEffects } from '../../content-service';
26
+ import { expandEvents } from '../events';
27
+ import { rruleMap } from '../rrule';
28
+ import { cancelOccurrence, restoreOccurrence, uniqueSlug } from '../schedule';
29
+ import { GOOGLE_SYNC, moveOccurrence, moveOccurrenceBack } from '../moves';
30
+ import { clashNote, entry, type SyncContext } from './context';
31
+ import { GoogleError } from './client';
32
+ import {
33
+ BOOKING_TAG, NONE, asSiteEvent, changed, dateHash, describe, fieldHash, fieldHashes, fieldWord, googleFields, hiddenLink, inWindow, instanceKey,
34
+ instanceState, isBooking, keyStart, linkCarries, notAnEvent, paragraphsHtml, sameValue, siteFields, siteUpdate, splitFrom, windowStart, type DateState, type Fields,
35
+ } from './map';
36
+ import type { GcalItem, GcalLink, GoogleEvent, Hashes, NewLogEntry } from './types';
37
+
38
+ export interface InboundOptions {
39
+ /** a full read: no member pushes (Q10) */
40
+ quiet: boolean;
41
+ /** Google ids to take as new events even when they carry a hidden link (the first match's "Not the same") */
42
+ apart?: Set<string>;
43
+ /** series paired during this run: their dates as Google has them are recorded, not applied (the agreed starting point, Q18) */
44
+ adopt?: Set<string>;
45
+ /** the site's events by sourceUid, read once per batch */
46
+ uids?: Promise<Map<string, CalEvent[]>>;
47
+ /** Google ids left alone this run (a reviewed pair whose site event changed since the review) */
48
+ skip?: Set<string>;
49
+ }
50
+
51
+ const masterHashes = (h: Hashes) => Object.fromEntries(Object.entries(h).filter(([k]) => !k.startsWith('date:')));
52
+ const setHash = (h: Hashes, name: string, value: string) => { if (value === NONE && name.startsWith('date:')) delete h[name]; else h[name] = value; };
53
+ const rethrowGoogle = (e: unknown) => { if (e instanceof GoogleError) throw e; };
54
+ const errorText = (e: unknown) => (e instanceof Error ? e.message : 'Something went wrong.');
55
+ const same = (a: unknown, b: unknown) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
56
+
57
+ /**
58
+ * Writes a change from Google to the live event, leaving staged edits staged.
59
+ * A staged draft holds the editor's whole form, so each field the draft still
60
+ * has at its old live value takes the new value too: pressing Update on an
61
+ * unrelated staged edit must not undo Google's change. A field the draft did
62
+ * change stays as staged (applying it is a site edit, and goes to Google).
63
+ */
64
+ async function writeSite(ctx: SyncContext, e: CalEvent, update: Partial<CalEvent>, quiet: boolean) {
65
+ await ctx.repo.updateEvent(e.id, update, { keepDraft: true, quiet });
66
+ if (e.draft) {
67
+ const draft = e.draft as Record<string, unknown>;
68
+ const follow = Object.fromEntries(Object.entries(update).filter(([k]) => Object.hasOwn(draft, k) && same(draft[k], (e as Record<string, unknown>)[k])));
69
+ if (Object.keys(follow).length) await ctx.repo.saveEventDraft(e.id, follow);
70
+ }
71
+ return (await ctx.repo.getEvent(e.id))!;
72
+ }
73
+
74
+ /** Every page of a link's changes (or of everything, `full`), with the sync token for next time. */
75
+ export async function listChanges(ctx: SyncContext, link: GcalLink, full: boolean) {
76
+ const items: GoogleEvent[] = [];
77
+ let pageToken: string | undefined;
78
+ for (let page = 0; page < 40; page++) {
79
+ const data = await ctx.client.listEvents(link.googleId, full
80
+ ? { timeMin: new Date(windowStart(link, ctx.settings)).toISOString(), showDeleted: true, pageToken }
81
+ : { syncToken: link.syncToken, pageToken });
82
+ items.push(...data.items);
83
+ if (data.nextPageToken) { pageToken = data.nextPageToken; continue; }
84
+ if (!data.nextSyncToken) throw new GoogleError('unavailable', 'Google didn’t finish the list. The sync tries again shortly.');
85
+ return { items, syncToken: data.nextSyncToken };
86
+ }
87
+ throw new GoogleError('unavailable', 'This Google calendar has too many changes to read in one run. The sync continues next time.');
88
+ }
89
+
90
+ /** Reads one link's changes and applies them. A 410 starts a full resync at once. */
91
+ export async function syncIncoming(ctx: SyncContext, link: GcalLink) {
92
+ let full = link.fullSync || !link.syncToken;
93
+ let listing;
94
+ try { listing = await listChanges(ctx, link, full); }
95
+ catch (e) {
96
+ if (!(e instanceof GoogleError && e.code === 'gone') || full) throw e;
97
+ full = true;
98
+ await ctx.repo.gcal.log([{ direction: 'info', action: 'resync', linkId: link.id, title: link.googleName, summary: 'Google asked for a full read. Pairs are matched again by their hidden links: nothing is doubled and no notifications go out.' }], ctx.now);
99
+ listing = await listChanges(ctx, link, true);
100
+ }
101
+ const handled = await processEvents(ctx, link, listing.items, { quiet: full });
102
+ if (full) await sweepMissing(ctx, link, listing.items, { quiet: true });
103
+ return { handled, syncToken: listing.syncToken, full };
104
+ }
105
+
106
+ /** Applies a batch of Google events: series and one-offs first (splits before the series they came from), then the single dates. */
107
+ export async function processEvents(ctx: SyncContext, link: GcalLink, events: GoogleEvent[], opts: InboundOptions): Promise<number> {
108
+ let uids: InboundOptions['uids'];
109
+ opts = { ...opts, get uids() {
110
+ return uids ??= ctx.repo.listEvents().then((all) => {
111
+ const m = new Map<string, CalEvent[]>();
112
+ for (const e of all) if (e.sourceUid) m.set(e.sourceUid, [...(m.get(e.sourceUid) ?? []), e]);
113
+ return m;
114
+ });
115
+ } };
116
+ const masters = events.filter((g) => !g.recurringEventId && !notAnEvent(g)).sort((a, b) => Number(!!splitFrom(b.id)) - Number(!!splitFrom(a.id)));
117
+ const byId = new Map(masters.map((g) => [g.id, g]));
118
+ const dates = new Map<string, GoogleEvent[]>();
119
+ for (const g of events) if (g.recurringEventId) dates.set(g.recurringEventId, [...(dates.get(g.recurringEventId) ?? []), g]);
120
+ let handled = 0;
121
+ for (const g of masters) {
122
+ try { await handleEvent(ctx, link, g, opts); handled++; }
123
+ catch (e) { rethrowGoogle(e); await failed(ctx, link, g, e); }
124
+ }
125
+ for (const [masterId, list] of dates) {
126
+ try { await handleDates(ctx, link, masterId, list, byId.get(masterId), opts); handled += list.length; }
127
+ catch (e) { rethrowGoogle(e); await failed(ctx, link, byId.get(masterId) ?? list[0], e); }
128
+ }
129
+ return handled;
130
+ }
131
+
132
+ async function failed(ctx: SyncContext, link: GcalLink, g: GoogleEvent, e: unknown) {
133
+ console.error('acms google sync: incoming change failed', e);
134
+ await ctx.repo.gcal.log([{ direction: 'info', action: 'failed', linkId: link.id, googleId: g.id, title: g.summary ?? '', summary: `A change from Google couldn’t be applied: ${errorText(e)}` }], ctx.now);
135
+ }
136
+
137
+ async function handleEvent(ctx: SyncContext, link: GcalLink, g: GoogleEvent, opts: InboundOptions) {
138
+ if (opts.skip?.has(g.id)) return;
139
+ const item = await ctx.repo.gcal.getItem(link.id, g.id);
140
+ if (g.status === 'cancelled') { if (item && !item.gone) await googleDeleted(ctx, link, item, opts); return; }
141
+ if (item) {
142
+ if (item.kind === 'segment') return;
143
+ if (item.gone) await backInGoogle(ctx, link, item, opts);
144
+ await applyGoogleChange(ctx, link, { ...item, gone: false }, g, opts);
145
+ return;
146
+ }
147
+ if (opts.apart?.has(g.id)) return void await createSiteEvent(ctx, link, g, opts);
148
+ const base = splitFrom(g.id);
149
+ if (base) {
150
+ const from = await ctx.repo.gcal.getItem(link.id, base);
151
+ if (from && from.kind !== 'segment') return void await recordSplit(ctx, link, from, g);
152
+ }
153
+ const hidden = g.extendedProperties?.private;
154
+ if (hidden?.acmsEventId && hidden.acmsSite === ctx.origin) {
155
+ const e = await ctx.repo.getEvent(hidden.acmsEventId);
156
+ const paired = e && (await ctx.repo.gcal.itemsForEvent(e.id)).some((i) => i.linkId === link.id && i.kind !== 'segment');
157
+ // a copy Google duplicated (with its hidden link), one moved here from another calendar, or an event this site
158
+ // no longer has (an older backup was restored): a new site event
159
+ if (e && !paired && linkCarries(e, link)) return void await pairExisting(ctx, link, e, g, opts, 'its hidden link');
160
+ }
161
+ if (g.iCalUID) {
162
+ // made on the site from this calendar's feed before the link ("Make an editable copy", an .ics import)
163
+ const e = ((await opts.uids)?.get(g.iCalUID) ?? []).find((x) => x.calendarIds.includes(link.calendarId));
164
+ if (e && linkCarries(e, link) && !(await ctx.repo.gcal.itemsForEvent(e.id)).some((i) => i.linkId === link.id)) return void await pairExisting(ctx, link, e, g, opts, 'the same calendar ID');
165
+ }
166
+ await createSiteEvent(ctx, link, g, opts);
167
+ }
168
+
169
+ /** Pairs an existing site event with a Google event as they are: both sides' fields become the agreed starting point and nothing is rewritten (Q18). */
170
+ export async function pairExisting(ctx: SyncContext, link: GcalLink, e: CalEvent, g: GoogleEvent, opts: InboundOptions, why: string) {
171
+ const gf = googleFields(g, link, ctx.settings);
172
+ let g2 = g;
173
+ if (g.extendedProperties?.private?.acmsEventId !== e.id || g.extendedProperties?.private?.acmsSite !== ctx.origin) {
174
+ g2 = await ctx.client.patchEvent(link.googleId, g.id, { extendedProperties: hiddenLink(e.id, ctx.origin) }).catch((err) => { if (err instanceof GoogleError && (err.code === 'auth' || err.code === 'quota')) throw err; return g; });
175
+ }
176
+ const sf = siteFields(e, link, ctx.map);
177
+ const item: GcalItem = {
178
+ linkId: link.id, googleId: g.id, eventId: e.id, icalUid: g.iCalUID ?? '', kind: g.recurrence?.length ? 'series' : 'single', segmentFrom: '', etag: g2.etag ?? '', htmlLink: g2.htmlLink ?? '',
179
+ // a pair whose site event has no description of its own keeps Google's (Q8)
180
+ site: fieldHashes(sf), google: gf ? fieldHashes(gf) : {}, descGoogle: !sf.desc && !!gf?.desc, booking: false, gone: false, leftOut: false, conflict: null, syncedAt: ctx.now, createdAt: ctx.now,
181
+ };
182
+ await ctx.repo.gcal.putItem(item);
183
+ opts.adopt?.add(g.id);
184
+ await ctx.repo.gcal.log([entry(link, item, e.title, { direction: 'info', action: 'paired', summary: `Paired with “${g.summary ?? ''}” in ${link.googleName} by ${why}. Both keep their titles until one is edited.` })], ctx.now);
185
+ }
186
+
187
+ /** A new Google event: published on the site at once (Q5), in the link's calendar, holding its rooms. */
188
+ async function createSiteEvent(ctx: SyncContext, link: GcalLink, g: GoogleEvent, opts: InboundOptions) {
189
+ const gf = googleFields(g, link, ctx.settings);
190
+ if (!gf) return;
191
+ const shape = asSiteEvent(gf, ctx.settings);
192
+ if (!inWindow(shape, windowStart(link, ctx.settings), ctx.settings)) return;
193
+ const { words } = ctx.map;
194
+ const booking = isBooking(g, link);
195
+ const title = booking ? words.booked : gf.title || words.untitled;
196
+ const tags = booking ? [BOOKING_TAG] : link.rules.filter((r) => gf.rules[r.prefix]).map((r) => r.tag);
197
+ const created = await ctx.repo.createEvent({
198
+ title: title.slice(0, 300), slug: await uniqueSlug(ctx.repo, title), status: 'draft',
199
+ start: gf.when.start, end: gf.when.end, allDay: gf.when.allDay, timezone: shape.timezone, rrule: gf.repeat.rrule, exdates: gf.repeat.exdates,
200
+ calendarIds: [link.calendarId], tags, sourceUid: g.iCalUID ?? '',
201
+ location: booking ? EventLocation.parse({}) : EventLocation.parse({ name: gf.place.slice(0, 300) }),
202
+ ...(booking || !gf.desc ? {} : { bodyHtml: paragraphsHtml(gf.desc), excerpt: gf.desc.replace(/\s+/g, ' ').slice(0, 300) }),
203
+ roomHold: RoomHold.parse({ resourceIds: link.roomIds }),
204
+ });
205
+ if (tags.length) await ctx.repo.ensureTags(tags);
206
+ await ctx.repo.updateEvent(created.id, { status: 'published' }, { quiet: opts.quiet });
207
+ // the hidden link lets a resync or a later link find this pair again
208
+ const g2 = await ctx.client.patchEvent(link.googleId, g.id, { extendedProperties: hiddenLink(created.id, ctx.origin) }).catch((err) => {
209
+ if (err instanceof GoogleError && (err.code === 'auth' || err.code === 'quota')) throw err;
210
+ return g;
211
+ });
212
+ const e = (await ctx.repo.getEvent(created.id))!;
213
+ const item: GcalItem = {
214
+ linkId: link.id, googleId: g.id, eventId: e.id, icalUid: g.iCalUID ?? '', kind: gf.repeat.rrule ? 'series' : 'single', segmentFrom: '', etag: g2.etag ?? '', htmlLink: g2.htmlLink ?? g.htmlLink ?? '',
215
+ site: fieldHashes(siteFields(e, link, ctx.map)), google: fieldHashes(gf), descGoogle: !booking, booking, gone: false, leftOut: false, conflict: null, syncedAt: ctx.now, createdAt: ctx.now,
216
+ };
217
+ await ctx.repo.gcal.putItem(item);
218
+ await eventSideEffects(ctx.repo, ctx.hooks, e);
219
+ const rooms = new Map((await ctx.repo.listResources()).map((r) => [r.id, r.name]));
220
+ const held = link.roomIds.map((r) => rooms.get(r)).filter(Boolean);
221
+ const notes: NewLogEntry[] = [entry(link, item, e.title, { direction: 'in', action: 'created', summary: [`New in ${link.googleName}`, booking ? 'a booked appointment' : 'published',
222
+ held.length ? `holds ${held.join(' and ')}` : '', tags.filter((t) => t !== BOOKING_TAG).length ? `tagged ${tags.join(', ')}` : ''].filter(Boolean).join(' · ') })];
223
+ const clash = await clashNote(ctx, link, item, e);
224
+ if (clash) notes.push(clash);
225
+ await ctx.repo.gcal.log(notes, ctx.now);
226
+ }
227
+
228
+ /** Google brought back a copy it had deleted (its trash): the event is no longer cancelled. */
229
+ async function backInGoogle(ctx: SyncContext, link: GcalLink, item: GcalItem, opts: InboundOptions) {
230
+ const e = await ctx.repo.getEvent(item.eventId);
231
+ if (e?.cancelled && !item.leftOut) await eventSideEffects(ctx.repo, ctx.hooks, await writeSite(ctx, e, { cancelled: false }, opts.quiet));
232
+ await ctx.repo.gcal.patchItem(link.id, item.googleId, { gone: false, leftOut: false, site: item.leftOut ? item.site : { ...item.site, status: fieldHash(false) } });
233
+ await ctx.repo.gcal.log([entry(link, item, e?.title ?? '', { direction: 'in', action: 'restored', summary: `Back in ${link.googleName}: no longer cancelled.` })], ctx.now);
234
+ }
235
+
236
+ /** True when Google's rule is the site's with an earlier end (an end added or moved sooner): Google dropped dates the site still has. */
237
+ function endsSooner(siteRule: string, googleRule: string): boolean {
238
+ if (!siteRule || !googleRule) return false;
239
+ const a = rruleMap(siteRule), b = rruleMap(googleRule);
240
+ if (!b.UNTIL && !b.COUNT) return false;
241
+ const rest = (m: Record<string, string>) => JSON.stringify(Object.entries(m).filter(([k]) => k !== 'UNTIL' && k !== 'COUNT').sort());
242
+ if (rest(a) !== rest(b)) return false;
243
+ if (!a.UNTIL && !a.COUNT) return true;
244
+ if (a.COUNT && b.COUNT) return Number(b.COUNT) < Number(a.COUNT);
245
+ if (a.UNTIL && b.UNTIL) return b.UNTIL.slice(0, 8) < a.UNTIL.slice(0, 8);
246
+ return true;
247
+ }
248
+
249
+ /** The value of one field from Google, for "Use Google's version" later. */
250
+ const pick = (f: Fields, name: string): Partial<Fields> =>
251
+ name === 'title' ? { title: f.title } : name === 'when' ? { when: f.when } : name === 'repeat' ? { repeat: f.repeat } : name === 'place' ? { place: f.place }
252
+ : name === 'desc' ? { desc: f.desc } : name.startsWith('rule:') ? { rules: { [name.slice(5)]: !!f.rules[name.slice(5)] } } : {};
253
+
254
+ /** Copies what Google changed since the pair last agreed onto the site event, field by field (the site wins where both changed, Q4). */
255
+ export async function applyGoogleChange(ctx: SyncContext, link: GcalLink, item: GcalItem, g: GoogleEvent, opts: InboundOptions = { quiet: false }) {
256
+ const e = await ctx.repo.getEvent(item.eventId);
257
+ const gf = googleFields(g, link, ctx.settings);
258
+ // the site event is gone (its deletion is queued to go out), or Google's copy has no usable time
259
+ if (!e || !gf) return;
260
+ const gh = fieldHashes(gf);
261
+ // rules no longer on the link are forgotten; one just added starts from what each side has (no write)
262
+ const rules = new Set(link.rules.map((r) => `rule:${r.prefix}`));
263
+ item = { ...item, site: { ...item.site }, google: { ...item.google } };
264
+ for (const n of Object.keys({ ...item.google, ...gh })) {
265
+ if (!n.startsWith('rule:')) continue;
266
+ if (!rules.has(n)) { delete item.google[n]; delete item.site[n]; }
267
+ else if (item.google[n] === undefined) { item.google[n] = gh[n]; item.site[n] ??= fieldHashes(siteFields(e, link, ctx.map))[n]; }
268
+ }
269
+ const chg = changed(masterHashes(gh), masterHashes(item.google), (n) => !n.startsWith('rule:') || rules.has(n));
270
+ if (!chg.length) {
271
+ await ctx.repo.gcal.patchItem(link.id, item.googleId, { etag: g.etag ?? item.etag, htmlLink: g.htmlLink ?? item.htmlLink, site: item.site, google: item.google });
272
+ return;
273
+ }
274
+ const sf = siteFields(e, link, ctx.map), sh = fieldHashes(sf);
275
+ const split = (await ctx.repo.gcal.itemsForEvent(e.id)).some((i) => i.linkId === link.id && i.kind === 'segment');
276
+ const google: Hashes = { ...item.google }, site: Hashes = { ...item.site };
277
+ const notes: NewLogEntry[] = [];
278
+ const apply: string[] = [];
279
+ let conflict = item.conflict, sendBack = false;
280
+ for (const n of chg) {
281
+ setHash(google, n, gh[n] ?? '');
282
+ // a booking keeps "Booked", no place and no description
283
+ if (item.booking && n !== 'when') continue;
284
+ if (n === 'desc' && !item.descGoogle) {
285
+ // once the site has its own description, it wins (Q8): Google gets the site's text back
286
+ const [id] = await ctx.repo.gcal.log([entry(link, item, e.title, { direction: 'clash', action: 'description', summary: 'Description changed in Google, but the site’s description wins once it’s been edited there (Q8). Google gets the site’s text back.',
287
+ data: { field: n, site: describe(n, sf), google: describe(n, gf), value: pick(gf, n) } })], ctx.now);
288
+ conflict = { field: n, at: ctx.now, site: describe(n, sf), google: describe(n, gf), logId: id };
289
+ site[n] = '';
290
+ sendBack = true;
291
+ continue;
292
+ }
293
+ if (split && (n === 'when' || n === 'repeat')) {
294
+ notes.push(entry(link, item, e.title, { direction: 'info', action: 'split', summary: `${fieldWord(n)} changed when Google split this series (“this and following”). The site keeps one event as it was for now: change the later dates on the site by hand if needed.` }));
295
+ continue;
296
+ }
297
+ if (n === 'repeat' && endsSooner(sf.repeat.rrule, gf.repeat.rrule)) {
298
+ notes.push(entry(link, item, e.title, { direction: 'info', action: 'ended', summary: `Google ended this series sooner (${describe(n, gf)}). The site keeps its later dates: end the series or cancel those dates on the site if that’s right.` }));
299
+ continue;
300
+ }
301
+ // a Google → site link mirrors Google; otherwise a field both sides changed keeps the site's value (Q4)
302
+ const siteChanged = (sh[n] ?? '') !== (item.site[n] ?? '');
303
+ if (siteChanged && link.direction !== 'in' && !sameValue(n, sf, gf, link.rules)) {
304
+ const [id] = await ctx.repo.gcal.log([entry(link, item, e.title, { direction: 'clash', action: 'field', summary: `${fieldWord(n)} changed on both sides: kept the site’s “${describe(n, sf)}” (Google had “${describe(n, gf)}”).`,
305
+ data: { field: n, site: describe(n, sf), google: describe(n, gf), value: pick(gf, n) } })], ctx.now);
306
+ conflict = { field: n, at: ctx.now, site: describe(n, sf), google: describe(n, gf), logId: id };
307
+ sendBack = true;
308
+ continue;
309
+ }
310
+ if (siteChanged && link.direction !== 'in') { setHash(site, n, sh[n] ?? ''); continue; }
311
+ apply.push(n);
312
+ }
313
+ if (apply.length) {
314
+ const update = siteUpdate(e, gf, apply, { rules: link.rules, descGoogle: item.descGoogle, settings: ctx.settings });
315
+ if (update) {
316
+ const after = await writeSite(ctx, e, update, opts.quiet);
317
+ if (update.tags) await ctx.repo.ensureTags(update.tags);
318
+ const sf2 = siteFields(after, link, ctx.map), sh2 = fieldHashes(sf2);
319
+ for (const n of apply) setHash(site, n, sh2[n] ?? '');
320
+ // a series that moved counts its single dates again
321
+ if (apply.includes('when') || apply.includes('repeat')) {
322
+ for (const k of Object.keys(site)) if (k.startsWith('date:')) delete site[k];
323
+ for (const [k, v] of Object.entries(sh2)) if (k.startsWith('date:')) site[k] = v;
324
+ }
325
+ await eventSideEffects(ctx.repo, ctx.hooks, after);
326
+ notes.unshift(entry(link, item, after.title, { direction: 'in', action: 'updated',
327
+ summary: apply.map((n) => n === 'when' || n === 'place' || n === 'title' ? `${fieldWord(n)} ${describe(n, sf)} → ${describe(n, gf)}` : fieldWord(n)).join('; ') }));
328
+ if (apply.includes('when') || apply.includes('repeat')) { const clash = await clashNote(ctx, link, item, after); if (clash) notes.push(clash); }
329
+ if (conflict && apply.includes(conflict.field)) conflict = null;
330
+ } else for (const n of apply) setHash(site, n, sh[n] ?? '');
331
+ }
332
+ await ctx.repo.gcal.patchItem(link.id, item.googleId, { site, google, conflict, etag: g.etag ?? item.etag, htmlLink: g.htmlLink ?? item.htmlLink, kind: gf.repeat.rrule ? 'series' : 'single', syncedAt: ctx.now });
333
+ await ctx.repo.gcal.log(notes, ctx.now);
334
+ if (sendBack) await ctx.repo.gcal.enqueue([e.id], ctx.now);
335
+ }
336
+
337
+ /** Single dates of a series that changed in Google: cancelled, moved, or back to normal. */
338
+ async function handleDates(ctx: SyncContext, link: GcalLink, masterId: string, list: GoogleEvent[], master: GoogleEvent | undefined, opts: InboundOptions) {
339
+ const item = await ctx.repo.gcal.getItem(link.id, masterId);
340
+ // dates of a later part (Google moved them to the split series), of a booking, or of a series no longer paired
341
+ if (!item || item.kind === 'segment' || item.booking || item.gone) return;
342
+ let e = await ctx.repo.getEvent(item.eventId);
343
+ if (!e) return;
344
+ const m = master ?? await ctx.client.getEvent(link.googleId, masterId).catch((err) => { if (err instanceof GoogleError && (err.code === 'notfound' || err.code === 'gone')) return null; throw err; });
345
+ let sf = siteFields(e, link, ctx.map);
346
+ // Google's dates read in the site event's zone, where its date keys are
347
+ const gf = m && m.status !== 'cancelled' ? googleFields(m, link, ctx.settings, sf.when.tz) : null;
348
+ if (!gf || !gf.repeat.rrule) return;
349
+ const adopt = opts.adopt?.has(item.googleId);
350
+ const google: Hashes = { ...item.google }, site: Hashes = { ...item.site };
351
+ const notes: NewLogEntry[] = [];
352
+ let sendBack = false;
353
+ for (const i of list) {
354
+ const key = instanceKey(i, gf.when);
355
+ if (!key) continue;
356
+ const n = `date:${key}`;
357
+ const state = instanceState(i, gf, key);
358
+ const gh = dateHash(state);
359
+ if (adopt) { setHash(google, n, gh); continue; }
360
+ if (gh === (item.google[n] ?? NONE)) {
361
+ if (state === 'none' && i.status !== 'cancelled' && ((i.summary !== undefined && i.summary !== m!.summary) || (i.description ?? '') !== (m!.description ?? ''))) {
362
+ notes.push(entry(link, item, e.title, { direction: 'info', action: 'date', summary: `The ${key.replace('T', ' ')} date has its own title or description in Google. The site keeps one title for every date, so that edit stays in Google only.` }));
363
+ }
364
+ continue;
365
+ }
366
+ setHash(google, n, gh);
367
+ if (link.direction === 'out' || keyStart(key, gf.when, ctx.settings) <= ctx.now) continue;
368
+ const mine: DateState | 'none' = sf.dates[key] ?? 'none';
369
+ if (dateHash(mine) === gh) { setHash(site, n, gh); continue; }
370
+ if (dateHash(mine) !== (item.site[n] ?? NONE) && link.direction !== 'in') {
371
+ await ctx.repo.gcal.log([entry(link, item, e.title, { direction: 'clash', action: 'date', summary: `The ${key.replace('T', ' ')} date changed on both sides: kept the site’s version.` })], ctx.now);
372
+ sendBack = true;
373
+ continue;
374
+ }
375
+ try {
376
+ const words = await applyDate(ctx, e, key, state, mine, sf, opts);
377
+ e = (await ctx.repo.getEvent(e.id))!;
378
+ sf = siteFields(e, link, ctx.map);
379
+ setHash(site, n, dateHash(sf.dates[key] ?? 'none'));
380
+ notes.push(entry(link, item, e.title, { direction: 'in', action: 'date', summary: `${key.replace('T', ' ')}: ${words}` }));
381
+ } catch (err) {
382
+ rethrowGoogle(err);
383
+ notes.push(entry(link, item, e.title, { direction: 'info', action: 'date', summary: `The ${key.replace('T', ' ')} change from Google wasn’t applied: ${errorText(err)}` }));
384
+ }
385
+ }
386
+ await ctx.repo.gcal.patchItem(link.id, item.googleId, { site, google, syncedAt: ctx.now });
387
+ await ctx.repo.gcal.log(notes, ctx.now);
388
+ if (sendBack) await ctx.repo.gcal.enqueue([e.id], ctx.now);
389
+ }
390
+
391
+ /** One date as Google has it, through C1 (as GOOGLE_SYNC: staff rights, clashes only warn). Returns what happened, in words. */
392
+ async function applyDate(ctx: SyncContext, e: CalEvent, key: string, state: DateState | 'none', mine: DateState | 'none', sf: Fields, opts: InboundOptions): Promise<string> {
393
+ const quiet = { quiet: opts.quiet };
394
+ if (state === 'cancelled') { await cancelOccurrence(ctx.repo, e.id, key, ctx.hooks, ctx.now, quiet); return 'cancelled'; }
395
+ if (mine === 'cancelled') await restoreOccurrence(ctx.repo, e.id, key, ctx.hooks, ctx.now, quiet);
396
+ if (state === 'none') {
397
+ if (mine !== 'cancelled') await moveOccurrenceBack(ctx.repo, GOOGLE_SYNC, e.id, key, ctx.hooks, { ...quiet, now: ctx.now });
398
+ return mine === 'cancelled' ? 'no longer cancelled' : 'back at its usual time';
399
+ }
400
+ const location = state.place === sf.place ? null : { name: state.place.slice(0, 300), address: '', placeId: '', lat: null, lng: null, mapImage: '', mapImageDark: '' };
401
+ const r = await moveOccurrence(ctx.repo, GOOGLE_SYNC, e.id, key, { start: state.start, end: state.end, location }, ctx.hooks, { ...quiet, now: ctx.now });
402
+ return `moved to ${state.start.replace('T', ' ')}${location ? ` at ${state.place}` : ''}${r.clashes.length ? ' (a room it holds is taken then: check Bookings)' : ''}`;
403
+ }
404
+
405
+ /** Deleted in Google: just this link's room and calendar come off the event while another linked copy remains; otherwise it's cancelled on the site (Q6). */
406
+ async function googleDeleted(ctx: SyncContext, link: GcalLink, item: GcalItem, opts: InboundOptions) {
407
+ const e = await ctx.repo.getEvent(item.eventId);
408
+ if (item.kind === 'segment') {
409
+ await ctx.repo.gcal.deleteItem(link.id, item.googleId);
410
+ await ctx.repo.gcal.log([entry(link, item, e?.title ?? '', { direction: 'info', action: 'split', summary: `Google deleted the later part of this series${item.segmentFrom ? ` (from ${item.segmentFrom.replace('T', ' ')})` : ''}. The site keeps those dates: cancel them on the site if that’s right.` })], ctx.now);
411
+ return;
412
+ }
413
+ if (!e) { await ctx.repo.gcal.deleteItem(link.id, item.googleId); return; }
414
+ const others = (await ctx.repo.gcal.itemsForEvent(e.id)).filter((i) => !(i.linkId === link.id && i.googleId === item.googleId) && !i.gone && i.kind !== 'segment');
415
+ const otherLinks = ctx.links.filter((l) => others.some((i) => i.linkId === l.id));
416
+ if (otherLinks.length) {
417
+ const rooms = e.roomHold.resourceIds.filter((r) => !link.roomIds.includes(r));
418
+ const calendarIds = otherLinks.some((l) => l.calendarId === link.calendarId) ? e.calendarIds : e.calendarIds.filter((c) => c !== link.calendarId);
419
+ const update: Partial<CalEvent> = {};
420
+ if (rooms.length !== e.roomHold.resourceIds.length) update.roomHold = { ...e.roomHold, resourceIds: rooms };
421
+ if (calendarIds.length !== e.calendarIds.length) update.calendarIds = calendarIds;
422
+ const others = otherLinks.map((l) => l.googleName).join(' and ');
423
+ // nothing can come off (the link carries the whole calendar, which another link also has): this Google calendar keeps it out
424
+ if (linkCarries({ ...e, ...update }, link)) {
425
+ await ctx.repo.gcal.patchItem(link.id, item.googleId, { gone: true, leftOut: true, syncedAt: ctx.now });
426
+ await ctx.repo.gcal.log([entry(link, item, e.title, { direction: 'in', action: 'removed', summary: `Deleted from ${link.googleName}: it stays out of that calendar, and stays on the site and in ${others}.` })], ctx.now);
427
+ return;
428
+ }
429
+ await eventSideEffects(ctx.repo, ctx.hooks, await writeSite(ctx, e, update, opts.quiet));
430
+ await ctx.repo.gcal.deleteItem(link.id, item.googleId);
431
+ await ctx.repo.gcal.log([entry(link, item, e.title, { direction: 'in', action: 'removed',
432
+ summary: `Deleted from ${link.googleName}: ${update.roomHold ? 'its room' : 'that calendar'} came off the event, which stays in ${others}.` })], ctx.now);
433
+ return;
434
+ }
435
+ if (e.status === 'published' && !e.cancelled) await eventSideEffects(ctx.repo, ctx.hooks, await writeSite(ctx, e, { cancelled: true }, opts.quiet));
436
+ await ctx.repo.gcal.patchItem(link.id, item.googleId, { gone: true, syncedAt: ctx.now });
437
+ await ctx.repo.gcal.log([entry(link, item, e.title, { direction: 'in', action: 'cancelled', summary: `Deleted in ${link.googleName}: cancelled on the site. Restore it there to bring it back in Google.` })], ctx.now);
438
+ }
439
+
440
+ /** Google split a series ("this and following"): the new series is linked to the same site event as a later part, listed for staff (C4 will apply these). */
441
+ async function recordSplit(ctx: SyncContext, link: GcalLink, base: GcalItem, g: GoogleEvent) {
442
+ const e = await ctx.repo.getEvent(base.eventId);
443
+ const gf = googleFields(g, link, ctx.settings);
444
+ // the first date of the site's series from the new part's first day on
445
+ let from = '';
446
+ if (e && gf) {
447
+ const day = asSiteEvent({ ...gf, when: { ...gf.when, start: gf.when.start.slice(0, 10), end: gf.when.start.slice(0, 10), allDay: true } }, ctx.settings).startAt;
448
+ from = expandEvents([{ ...e, movedDates: {} }], ctx.settings, { displayTz: ctx.settings.timezone || 'UTC', now: day, windowStartMs: day, windowEndMs: day + 400 * 86_400_000 })
449
+ .find((o) => o.start >= day)?.key ?? '';
450
+ }
451
+ await ctx.repo.gcal.putItem({
452
+ linkId: link.id, googleId: g.id, eventId: base.eventId, icalUid: g.iCalUID ?? '', kind: 'segment', segmentFrom: from, etag: g.etag ?? '', htmlLink: g.htmlLink ?? '',
453
+ site: {}, google: gf ? fieldHashes(gf) : {}, descGoogle: false, booking: false, gone: false, leftOut: false, conflict: null, syncedAt: ctx.now, createdAt: ctx.now,
454
+ });
455
+ await ctx.repo.gcal.log([entry(link, base, e?.title ?? g.summary ?? '', { direction: 'info', action: 'split',
456
+ summary: `Google changed this series from ${from ? from.replace('T', ' ') : 'a later date'} on (“this and following”)${gf ? `: ${describe('when', gf)}, ${describe('repeat', gf)}` : ''}. The site keeps one event as it was for now; change the later dates on the site by hand if needed.` })], ctx.now);
457
+ }
458
+
459
+ /** After a full read: pairs Google didn't list (deleted long ago, or a list that skipped them) are checked one by one. */
460
+ async function sweepMissing(ctx: SyncContext, link: GcalLink, listed: GoogleEvent[], opts: InboundOptions) {
461
+ const seen = new Set(listed.map((g) => g.id));
462
+ const startMs = windowStart(link, ctx.settings);
463
+ const missing = (await ctx.repo.gcal.itemsForLink(link.id)).filter((i) => !i.gone && !seen.has(i.googleId));
464
+ for (const item of missing.slice(0, 200)) {
465
+ const e = await ctx.repo.getEvent(item.eventId);
466
+ if (e && !inWindow(e, startMs, ctx.settings)) continue;
467
+ const g = await ctx.client.getEvent(link.googleId, item.googleId).catch((err) => { if (err instanceof GoogleError && (err.code === 'notfound' || err.code === 'gone')) return null; throw err; });
468
+ if (!g || g.status === 'cancelled') await googleDeleted(ctx, link, item, opts);
469
+ else if (item.kind !== 'segment') await applyGoogleChange(ctx, link, item, g, opts);
470
+ }
471
+ }
472
+
473
+ /** "Use Google's version" from a clash: the site takes Google's value for that field (and Google keeps it, since they now agree). */
474
+ export async function useGoogleValue(ctx: Pick<SyncContext, 'repo' | 'settings' | 'map' | 'hooks'>, logId: number) {
475
+ const log = await ctx.repo.gcal.getLog(logId);
476
+ const data = log?.data as { field?: string; value?: Partial<Fields> } | null;
477
+ if (!log || !data?.field || !data.value) throw Object.assign(new Error('That change can’t be used any more.'), { status: 404 });
478
+ const [link, item, e] = await Promise.all([ctx.repo.gcal.getLink(log.linkId), ctx.repo.gcal.getItem(log.linkId, log.googleId), ctx.repo.getEvent(log.eventId)]);
479
+ if (!link || !item || !e) throw Object.assign(new Error('That event is no longer synced.'), { status: 404 });
480
+ const current = siteFields(e, link, ctx.map);
481
+ const g: Fields = { ...current, ...data.value, rules: { ...current.rules, ...(data.value.rules ?? {}) } };
482
+ const update = siteUpdate(e, g, [data.field], { rules: link.rules, descGoogle: true, settings: ctx.settings });
483
+ if (update) {
484
+ await writeSite(ctx as SyncContext, e, update, false);
485
+ if (update.tags) await ctx.repo.ensureTags(update.tags);
486
+ }
487
+ const after = (await ctx.repo.getEvent(e.id))!;
488
+ // an ordinary site change from here: every copy gets Google's value (the losing copy had the site's by now)
489
+ await ctx.repo.gcal.patchItem(link.id, item.googleId, { conflict: item.conflict?.field === data.field ? null : item.conflict });
490
+ await ctx.repo.gcal.resolveLog(logId);
491
+ await eventSideEffects(ctx.repo, ctx.hooks, after);
492
+ return after;
493
+ }
@@ -0,0 +1,139 @@
1
+ // Links: one site calendar ⇄ one Google calendar. A site calendar may have
2
+ // several links, one per room (cces: the room calendar ⇄ "Santuario |
3
+ // Sanctuary" holding the Sanctuary, and ⇄ "Multimedia Room #1" holding its
4
+ // room). A new link waits in "matching" until someone reviews the first
5
+ // match (./match.ts) and presses Start syncing; until then nothing is
6
+ // written to either side. A link only runs on the site that made it: a copy
7
+ // of the site (a restored backup elsewhere, staging, a local copy with a
8
+ // different Site URL) pauses its links instead of writing into the real calendar.
9
+ import { z } from 'zod';
10
+ import { ApiError } from '../../api';
11
+ import type { ContentRepository } from '../../repo/types';
12
+ import type { Settings } from '../../schema';
13
+ import { parseWall } from '../events';
14
+ import { siteOriginOf } from './connect';
15
+ import { linkCarries } from './map';
16
+ import { Direction, PrefixRule, type GcalLink } from './types';
17
+
18
+ export const LinkInput = z.object({
19
+ calendarId: z.string().min(1).max(100),
20
+ googleId: z.string().min(1).max(1024),
21
+ direction: Direction.default('both'),
22
+ roomIds: z.array(z.string().max(100)).max(50).default([]),
23
+ noRoom: z.boolean().default(false),
24
+ rules: z.array(PrefixRule).max(10).default([]),
25
+ bookingTitles: z.array(z.string().trim().min(1).max(200)).max(20).default([]),
26
+ /** YYYY-MM-DD; default 30 days ago (Q11) */
27
+ startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
28
+ }).strict();
29
+ export type LinkInput = z.input<typeof LinkInput>;
30
+ export const LinkChanges = LinkInput.omit({ calendarId: true, googleId: true, startDate: true }).partial().strict();
31
+
32
+ /** Why a link can't run here (shown on it), or '' when it can. */
33
+ export const PAUSE_WORDS: Record<string, string> = {
34
+ paused: 'Paused',
35
+ restored: 'Paused after a restore: check the events, then resume',
36
+ 'other-site': 'This copy isn’t the site that linked Google',
37
+ disconnected: 'Google is disconnected',
38
+ reconnect: 'Reconnect Google',
39
+ };
40
+
41
+ /** '' when the link may run on this site, else the pause reason. */
42
+ export function siteGuard(link: Pick<GcalLink, 'siteUrl'>, settings: Pick<Settings, 'siteUrl'>): '' | 'other-site' {
43
+ return siteOriginOf(settings.siteUrl) === link.siteUrl ? '' : 'other-site';
44
+ }
45
+
46
+ const daysAgo = (n: number, now = Date.now()) => new Date(now - n * 86_400_000).toISOString().slice(0, 10);
47
+
48
+ async function cleanLink(repo: ContentRepository, input: z.infer<typeof LinkChanges>, calendarId: string, selfId?: string) {
49
+ const rooms = new Set((await repo.listResources()).map((r) => r.id));
50
+ const roomIds = input.roomIds === undefined ? undefined : [...new Set(input.roomIds)].filter((r) => rooms.has(r));
51
+ if (input.noRoom) {
52
+ const other = (await repo.gcal.listLinks()).find((l) => l.calendarId === calendarId && l.noRoom && l.id !== selfId);
53
+ if (other) throw new ApiError(400, `“${other.googleName}” already gets this calendar’s events that hold no room. Only one link can.`);
54
+ }
55
+ const rules = input.rules === undefined ? undefined : input.rules.filter((r, i, all) => all.findIndex((x) => x.prefix.toLowerCase() === r.prefix.toLowerCase()) === i);
56
+ const bookingTitles = input.bookingTitles === undefined ? undefined : [...new Set(input.bookingTitles.map((t) => t.trim()))];
57
+ return { ...input, ...(roomIds ? { roomIds } : {}), ...(rules ? { rules } : {}), ...(bookingTitles ? { bookingTitles } : {}) };
58
+ }
59
+
60
+ /** A new link, waiting for its first-match review. */
61
+ export async function createLink(repo: ContentRepository, raw: unknown): Promise<GcalLink> {
62
+ const input = LinkInput.parse(raw);
63
+ const [account, settings, calendar] = await Promise.all([repo.gcal.account(), repo.getSettings(), repo.getCalendar(input.calendarId)]);
64
+ if (!account || account.status !== 'connected') throw new ApiError(400, 'Connect Google first.');
65
+ if (!calendar || calendar.kind !== 'native') throw new ApiError(400, 'Pick one of your calendars.');
66
+ const google = account.calendars.find((c) => c.id === input.googleId);
67
+ if (!google) throw new ApiError(400, 'Pick a Google calendar this account can edit. If it’s missing, refresh the list.');
68
+ const taken = (await repo.gcal.listLinks()).find((l) => l.googleId === input.googleId);
69
+ if (taken) throw new ApiError(409, `“${google.name}” is already linked.`);
70
+ const origin = siteOriginOf(settings.siteUrl);
71
+ if (!origin) throw new ApiError(400, 'Set the Site URL in Settings → General first.');
72
+ const startDate = input.startDate ?? daysAgo(30);
73
+ if (!parseWall(startDate)) throw new ApiError(400, 'Pick a valid start date.');
74
+ const clean = await cleanLink(repo, input, calendar.id);
75
+ return repo.gcal.createLink({ calendarId: calendar.id, googleId: google.id, googleName: google.name, googleTz: google.timeZone, direction: clean.direction ?? 'both',
76
+ roomIds: clean.roomIds ?? [], noRoom: clean.noRoom ?? false, rules: clean.rules ?? [], bookingTitles: clean.bookingTitles ?? [], startDate, siteUrl: origin });
77
+ }
78
+
79
+ /**
80
+ * The site calendar's events, queued to go out (a link started, resumed or
81
+ * now carries other rooms), with the pairs' events: one deleted on the site
82
+ * while the link waited is removed from Google. After a restore, though, a
83
+ * pair whose event the backup doesn't have is forgotten instead, and the full
84
+ * read brings that event back from Google: an older backup never deletes
85
+ * anything in Google.
86
+ */
87
+ async function catchUp(repo: ContentRepository, link: GcalLink, restored = false) {
88
+ const events = await repo.listEvents();
89
+ const known = new Set(events.map((e) => e.id));
90
+ const items = await repo.gcal.itemsForLink(link.id);
91
+ if (restored) for (const i of items) if (!known.has(i.eventId)) await repo.gcal.deleteItem(link.id, i.googleId);
92
+ const ids = events.filter((e) => e.calendarIds.includes(link.calendarId)).map((e) => e.id);
93
+ await repo.gcal.enqueue([...ids, ...items.filter((i) => !restored || known.has(i.eventId)).map((i) => i.eventId)]);
94
+ }
95
+
96
+ /** Direction, rooms, the "no room" flag, rules and booking titles. Copies follow at the next run: events that now belong here go out, ones that don't leave. */
97
+ export async function updateLink(repo: ContentRepository, id: string, raw: unknown): Promise<GcalLink> {
98
+ const link = await repo.gcal.getLink(id);
99
+ if (!link) throw new ApiError(404, 'That link no longer exists.');
100
+ const clean = await cleanLink(repo, LinkChanges.parse(raw), link.calendarId, link.id);
101
+ // reading Google again from scratch after "Site → Google only" catches what changed there meanwhile
102
+ const reread = clean.direction && clean.direction !== link.direction && link.direction === 'out';
103
+ if (!await repo.gcal.patchLink(id, { ...clean, ...(reread ? { fullSync: true } : {}) }, link.revision)) throw new ApiError(409, 'The link changed meanwhile. Reload and try again.');
104
+ const next = (await repo.gcal.getLink(id))!;
105
+ if (next.state === 'active') await catchUp(repo, next);
106
+ return next;
107
+ }
108
+
109
+ export async function pauseLink(repo: ContentRepository, id: string) {
110
+ const link = await repo.gcal.getLink(id);
111
+ if (!link) throw new ApiError(404, 'That link no longer exists.');
112
+ if (link.state !== 'paused') await repo.gcal.patchLink(id, { state: 'paused', pausedReason: 'paused' }, link.revision);
113
+ }
114
+
115
+ /** Resume: a link that never finished its first match goes back to it; otherwise it reads Google again from scratch (quietly, pairing by the hidden links) and catches up. */
116
+ export async function resumeLink(repo: ContentRepository, id: string) {
117
+ const [link, settings, account] = await Promise.all([repo.gcal.getLink(id), repo.getSettings(), repo.gcal.account()]);
118
+ if (!link) throw new ApiError(404, 'That link no longer exists.');
119
+ if (siteGuard(link, settings)) throw new ApiError(400, `${PAUSE_WORDS['other-site']} (it was made on ${link.siteUrl}). Unlink it here, or set the Site URL back.`);
120
+ if (!account || account.status !== 'connected') throw new ApiError(400, 'Connect Google first.');
121
+ const matched = link.matchedAt > 0;
122
+ if (!await repo.gcal.patchLink(id, { state: matched ? 'active' : 'matching', pausedReason: '', error: '', failures: 0, failingSince: 0, nextAt: 0, ...(matched ? { fullSync: true } : { match: null }) }, link.revision)) {
123
+ throw new ApiError(409, 'The link changed meanwhile. Reload and try again.');
124
+ }
125
+ const next = (await repo.gcal.getLink(id))!;
126
+ if (matched) await catchUp(repo, next, link.pausedReason === 'restored');
127
+ return next;
128
+ }
129
+
130
+ /** Unlink: the pairs are forgotten, and both copies of every event stay where they are (a later link finds them again by their hidden link). */
131
+ export async function unlink(repo: ContentRepository, id: string) {
132
+ await repo.gcal.deleteLink(id);
133
+ }
134
+
135
+ /** The links an event goes to now (for the editor's badge and docs). */
136
+ export function linksFor(e: Parameters<typeof linkCarries>[0], links: GcalLink[]) {
137
+ return links.filter((l) => linkCarries(e, l));
138
+ }
139
+ export { catchUp as queueLinkCalendar };