@acms-subir/core 0.3.63 → 0.3.64
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 +13 -0
- package/cli/migrations.generated.mjs +23 -1
- package/docs/calendar.md +4 -0
- package/docs/google-calendar.md +81 -4
- package/mobile-template/recipes/booking/files/app/book/details.tsx +3 -2
- package/mobile-template/recipes/booking/files/app/book/manage.tsx +15 -4
- package/package.json +1 -1
- package/src/lib/calendar/google/bookings.ts +296 -0
- package/src/lib/calendar/google/inbound.ts +7 -1
- package/src/lib/calendar/google/links.ts +3 -0
- package/src/lib/calendar/google/map.ts +9 -5
- package/src/lib/calendar/google/match.ts +6 -2
- package/src/lib/calendar/google/migration.ts +34 -0
- package/src/lib/calendar/google/outbound.ts +9 -2
- package/src/lib/calendar/google/store.ts +38 -5
- package/src/lib/calendar/google/types.ts +17 -0
- package/src/lib/repo/migrations.ts +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.64
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 243474e: The booking recipe no longer puts a booking's manage token in a route. On the web a route is the page address, so the token ended up in history and URL logs, against the app template's own rule. Its confirmation now keeps the token in memory and passes a random key to `app/book/manage.tsx`; after a restart, that screen points to the confirmation email's link instead. The native package has the helpers: `rememberBookingToken(token)` returns the key, and `bookingToken(key)` returns the token (or null). Apps that copied the recipe in 0.3.63 can copy the two changed files (`app/book/details.tsx`, `app/book/manage.tsx`).
|
|
8
|
+
- 445ef91: The Google Calendar sync copies the site's confirmed room bookings to Google, out only. Each linked Google calendar whose link holds the booking's room (direction Both ways or Site → Google only, while the Site URL matches) gets one copy, titled with the booking link's name in the main language, with its time (busy) and a line saying it was booked on the website. Copies never carry the booker's name, email, phone, answers or notes, and never have guests; writes use `sendUpdates=none`. A copy is made when the booking is confirmed (pending bookings never go out), changed when it moves or its link is renamed, moved when the link's rooms change, and deleted when the booking is cancelled, declined or deleted.
|
|
9
|
+
|
|
10
|
+
Every copy has a hidden link (`extendedProperties.private` `{ acmsBookingId, acmsSite }`): incoming reads, full resyncs and the first match never take it as a site event or list it under "Only in Google", and `isBooking()` never reads it as a booking page appointment, whatever its title. An edit or delete of a copy in Google never changes the booking: Recent changes lists it once, and the site's version goes back with the booking's next change. Unlinking keeps the copies; linking again pairs them by their hidden links, and deletes stale ones (a booking cancelled, declined or moved to another room meanwhile, or a second copy), but never a copy of a booking the site doesn't have.
|
|
11
|
+
|
|
12
|
+
A Google booking page only treats the copies as busy when it checks the room's calendar: tick it under the booking page's **Booked appointment settings → Check calendar availability** (the guide's "Booking pages" section has the steps for replacing booking pages with the site's booking links).
|
|
13
|
+
|
|
14
|
+
Migration 73 adds `gcal_booking_items` and triggers on `bookings` and `booking_links` (name and mode) that queue a booking's changes in `gcal_queue` (as `booking:<id>` rows). The guide is `acms docs google-calendar` → Site bookings.
|
|
15
|
+
|
|
3
16
|
## 0.3.63
|
|
4
17
|
|
|
5
18
|
### Patch Changes
|
|
@@ -519,6 +519,24 @@ var GCAL_MIGRATION = { version: 69, statements: [
|
|
|
519
519
|
`CREATE TRIGGER gcal_queue_update AFTER UPDATE ON events WHEN ${guard} BEGIN ${enqueue3("NEW.id")} END`,
|
|
520
520
|
`CREATE TRIGGER gcal_queue_delete AFTER DELETE ON events WHEN ${guard} BEGIN ${enqueue3("OLD.id")} END`
|
|
521
521
|
] };
|
|
522
|
+
var enqueueBooking = (id) => enqueue3(`'booking:'||${id}`);
|
|
523
|
+
var GCAL_BOOKINGS_MIGRATION = { version: 73, statements: [
|
|
524
|
+
`CREATE TABLE gcal_booking_items (
|
|
525
|
+
link_id TEXT NOT NULL, google_id TEXT NOT NULL, booking_id TEXT NOT NULL, site TEXT NOT NULL DEFAULT '', google TEXT NOT NULL DEFAULT '',
|
|
526
|
+
etag TEXT NOT NULL DEFAULT '', html_link TEXT NOT NULL DEFAULT '', gone INTEGER NOT NULL DEFAULT 0, synced_at INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL,
|
|
527
|
+
PRIMARY KEY (link_id, google_id), UNIQUE (link_id, booking_id)
|
|
528
|
+
)`,
|
|
529
|
+
`CREATE INDEX gcal_booking_items_booking ON gcal_booking_items (booking_id)`,
|
|
530
|
+
`CREATE TRIGGER gcal_booking_insert AFTER INSERT ON bookings WHEN ${guard} AND NEW.kind='booking' AND NEW.status='confirmed' BEGIN ${enqueueBooking("NEW.id")} END`,
|
|
531
|
+
`CREATE TRIGGER gcal_booking_update AFTER UPDATE OF status, start_at, end_at, resource_id, link_id, kind ON bookings WHEN ${guard} AND (NEW.kind='booking' OR OLD.kind='booking')
|
|
532
|
+
AND (OLD.status IS NOT NEW.status OR OLD.start_at IS NOT NEW.start_at OR OLD.end_at IS NOT NEW.end_at OR OLD.resource_id IS NOT NEW.resource_id OR OLD.link_id IS NOT NEW.link_id OR OLD.kind IS NOT NEW.kind)
|
|
533
|
+
BEGIN ${enqueueBooking("NEW.id")} END`,
|
|
534
|
+
`CREATE TRIGGER gcal_booking_delete AFTER DELETE ON bookings WHEN ${guard} AND OLD.kind='booking' BEGIN ${enqueueBooking("OLD.id")} END`,
|
|
535
|
+
`CREATE TRIGGER gcal_booking_link_change AFTER UPDATE OF name, mode ON booking_links WHEN ${guard} AND (OLD.name IS NOT NEW.name OR OLD.mode IS NOT NEW.mode) BEGIN
|
|
536
|
+
INSERT INTO gcal_queue (event_id,version,due_at,attempts,claimed_until,error)
|
|
537
|
+
SELECT 'booking:'||id,1,${now5},0,0,'' FROM bookings WHERE link_id=NEW.id AND kind='booking' AND status='confirmed' AND end_at>${now5}
|
|
538
|
+
ON CONFLICT(event_id) DO UPDATE SET version=version+1,due_at=excluded.due_at,attempts=0,error=''; END`
|
|
539
|
+
] };
|
|
522
540
|
|
|
523
541
|
// src/lib/repo/migrations.ts
|
|
524
542
|
var CONTENT_VERSION_COLUMNS = {
|
|
@@ -1377,7 +1395,11 @@ var MIGRATIONS = [
|
|
|
1377
1395
|
`ALTER TABLE booking_links ADD COLUMN translations TEXT NOT NULL DEFAULT '{}'`,
|
|
1378
1396
|
`DROP TRIGGER content_version_booking_links_update`,
|
|
1379
1397
|
contentVersionTriggers("booking_links", [...CONTENT_VERSION_COLUMNS.booking_links, "translations"])[2]
|
|
1380
|
-
] }
|
|
1398
|
+
] },
|
|
1399
|
+
// copies of confirmed room bookings in Google (lib/calendar/google/bookings.ts):
|
|
1400
|
+
// their pairs, and triggers on bookings and booking links feeding gcal_queue.
|
|
1401
|
+
// Not public, so no cache trigger.
|
|
1402
|
+
GCAL_BOOKINGS_MIGRATION
|
|
1381
1403
|
];
|
|
1382
1404
|
var CURRENT_CONTENT_VERSION_COLUMNS = {
|
|
1383
1405
|
...CONTENT_VERSION_COLUMNS,
|
package/docs/calendar.md
CHANGED
|
@@ -496,6 +496,10 @@ calendars in step, both ways, within a few minutes. The full guide is
|
|
|
496
496
|
- **Booked appointments** from Google booking pages come in as "Booked" with the
|
|
497
497
|
internal tag `#google-booking` (`GOOGLE_BOOKING_TAG`), holding the link's room. Treat
|
|
498
498
|
them as bookings: the tag never shows publicly.
|
|
499
|
+
- **Site bookings go out.** A confirmed booking of a room a link holds gets a copy in
|
|
500
|
+
that Google calendar, titled with its booking link's name, never who booked. It's out
|
|
501
|
+
only: an edit in Google never changes the booking, and a copy never comes back as an
|
|
502
|
+
event or as "Booked" (`acms docs google-calendar` → Site bookings).
|
|
499
503
|
- **"This and following"** maps to "from this date on", both ways:
|
|
500
504
|
- A Google split changes the event from that date on (`changeFromDate` as `GOOGLE_SYNC`).
|
|
501
505
|
- A change from a date on gives the later part its own Google series.
|
package/docs/google-calendar.md
CHANGED
|
@@ -65,7 +65,8 @@ the account can edit. The settings:
|
|
|
65
65
|
- Site → Google only: Google mirrors the site. Start here, then switch.
|
|
66
66
|
- Google → site only: site edits stay on the site.
|
|
67
67
|
- **Rooms**: the room(s) this Google calendar stands for. Events that come in
|
|
68
|
-
from it hold those rooms. Site events holding one of them go to it
|
|
68
|
+
from it hold those rooms. Site events holding one of them go to it, and so
|
|
69
|
+
do confirmed bookings of them (see [Site bookings](#site-bookings)).
|
|
69
70
|
- **Also gets this calendar's events that hold no room**: one link per site
|
|
70
71
|
calendar may have this.
|
|
71
72
|
- A link with no rooms carries its whole site calendar.
|
|
@@ -140,6 +141,7 @@ suggestion decided.
|
|
|
140
141
|
| Unpublished or deleted | deleted in Google | site → Google |
|
|
141
142
|
| Deleted in Google | **cancelled** on the site (struck through, room freed, restorable) | Google → site |
|
|
142
143
|
| Hidden link | `extendedProperties.private` `{ acmsEventId, acmsSite }` | site → Google |
|
|
144
|
+
| Confirmed room booking | a copy titled with its booking link's name ([Site bookings](#site-bookings)) | site → Google only |
|
|
143
145
|
|
|
144
146
|
Never sent:
|
|
145
147
|
|
|
@@ -220,6 +222,66 @@ Changes are compared field by field against what the pair last agreed:
|
|
|
220
222
|
applies, because it already happened in Google. The clash shows in Recent
|
|
221
223
|
changes and the event editor.
|
|
222
224
|
|
|
225
|
+
## Site bookings
|
|
226
|
+
|
|
227
|
+
Confirmed bookings of the site's rooms (`/book`, the app, staff) get a copy
|
|
228
|
+
in each linked Google calendar that holds their room, so the calendar stays a
|
|
229
|
+
mirror of the room, and the room shows as busy there. A Google booking page
|
|
230
|
+
only sees that when it checks the room's calendar (see
|
|
231
|
+
[Booking pages](#booking-pages-appointment-schedules)). They only go out:
|
|
232
|
+
|
|
233
|
+
- **Which calendars:** links set to Both ways or Site → Google only, whose
|
|
234
|
+
**Rooms** include the booking's room. A link without rooms gets no bookings.
|
|
235
|
+
Each Google calendar gets one copy. Links only run while the Site URL
|
|
236
|
+
matches (see [Safety](#safety)).
|
|
237
|
+
- **Which bookings:** confirmed ones, ending on or after the link's start date.
|
|
238
|
+
Pending bookings never go out (a first booking waiting for staff goes out
|
|
239
|
+
once it's approved). Staff blocks and RSVPs never do.
|
|
240
|
+
- **What a copy has:**
|
|
241
|
+
- the booking link's name in the site's main language ("Rehearsal";
|
|
242
|
+
"Reserved" when the link was deleted);
|
|
243
|
+
- its time (a whole-day link's days as all day), marked busy;
|
|
244
|
+
- one line saying it was booked on the website and is changed there.
|
|
245
|
+
|
|
246
|
+
It never has the booker's name, email, phone, answers or notes, and no
|
|
247
|
+
guests. Every write uses `sendUpdates=none`.
|
|
248
|
+
- **Hidden link:** `extendedProperties.private` `{ acmsBookingId, acmsSite }`.
|
|
249
|
+
- **When it changes:**
|
|
250
|
+
- **Confirmed:** the copy is made.
|
|
251
|
+
- **Moved, or its booking link renamed:** the copy is changed.
|
|
252
|
+
- **The link's rooms change:** the copy moves to the Google calendar that
|
|
253
|
+
now holds the room.
|
|
254
|
+
- **Cancelled, declined or deleted:** the copy is deleted.
|
|
255
|
+
|
|
256
|
+
**Stale copies.** Linking a calendar again finds the copies left from before.
|
|
257
|
+
A copy of a booking that was cancelled, declined or moved to another room in
|
|
258
|
+
the meantime, or a second copy of one booking, is deleted from Google (listed
|
|
259
|
+
in Recent changes), so it doesn't keep the room busy. A copy of a booking the
|
|
260
|
+
site doesn't have (after restoring an older backup) is left alone.
|
|
261
|
+
|
|
262
|
+
**Edits in Google never change the booking.** Changing or deleting a copy in
|
|
263
|
+
Google is listed once in Recent changes ("A booking's copy was changed in …,
|
|
264
|
+
now …"). The copy stays as Google has it until the booking changes on the
|
|
265
|
+
site. Then the site's version goes back, and a deleted copy is made again.
|
|
266
|
+
|
|
267
|
+
**Never read back.** Every copy carries its hidden link, so:
|
|
268
|
+
|
|
269
|
+
- the incoming side, a full resync and the first match never take it as a
|
|
270
|
+
site event, and never list it under "Only in Google";
|
|
271
|
+
- it's never read as a booked appointment from a booking page, even when its
|
|
272
|
+
title is one of the link's booking page titles, or "Rehearsal";
|
|
273
|
+
- unlinking keeps the copies, and linking the calendar again pairs them
|
|
274
|
+
again by their hidden links (Start syncing), with no second copy.
|
|
275
|
+
|
|
276
|
+
A link turned to Google → site only keeps the copies it has, and they're no
|
|
277
|
+
longer updated. Copies made by another site (staging) are left alone.
|
|
278
|
+
|
|
279
|
+
Their pairs are `gcal_booking_items` (migration 73). Their outgoing changes
|
|
280
|
+
share `gcal_queue`, as `booking:<id>` rows filled by triggers on `bookings`
|
|
281
|
+
(a confirmed insert, and any change of status, time, room or booking link,
|
|
282
|
+
or a delete) and on a booking link's name or mode (its upcoming confirmed
|
|
283
|
+
bookings).
|
|
284
|
+
|
|
223
285
|
## Booking pages (appointment schedules)
|
|
224
286
|
|
|
225
287
|
Google's API has no marker for appointments booked through a booking page.
|
|
@@ -244,6 +306,19 @@ unpairs it.
|
|
|
244
306
|
Check this against a real booking before relying on it: book one and look
|
|
245
307
|
at it in Recent changes and on the site.
|
|
246
308
|
|
|
309
|
+
**Replacing them with the site's booking links:**
|
|
310
|
+
1. Add a booking link to the room (for example "Rehearsal", with **Name in
|
|
311
|
+
Español**). Its bookings go to Google as [Site bookings](#site-bookings).
|
|
312
|
+
2. Until the old booking pages are retired, tick the room's Google calendar
|
|
313
|
+
under each booking page's **Booked appointment settings → Check calendar
|
|
314
|
+
availability**, so site bookings block those slots. By default a booking
|
|
315
|
+
page checks only the account's primary calendar.
|
|
316
|
+
3. Keep the old booking pages' titles under **Booking pages** until their
|
|
317
|
+
last appointments have passed, so those still come in as "Booked".
|
|
318
|
+
|
|
319
|
+
Copies of site bookings are never read as booked appointments, even with the
|
|
320
|
+
same title.
|
|
321
|
+
|
|
247
322
|
## Safety
|
|
248
323
|
|
|
249
324
|
- **Nothing before review.** A new link only reads, until Start syncing.
|
|
@@ -261,8 +336,8 @@ at it in Recent changes and on the site.
|
|
|
261
336
|
backup never deletes anything in Google.
|
|
262
337
|
- **Environment sync and backups** never copy the Google account, links or
|
|
263
338
|
pairs. Nothing is stored on the event row.
|
|
264
|
-
- **Unlink** and **Disconnect** keep both copies of every event
|
|
265
|
-
pairs them by their hidden links.
|
|
339
|
+
- **Unlink** and **Disconnect** keep both copies of every event, and the
|
|
340
|
+
copies of bookings. Linking again pairs them by their hidden links.
|
|
266
341
|
- **Loops**: each pair stores a hash of every synced field, per side, as last
|
|
267
342
|
agreed. A change that already matches is never sent back.
|
|
268
343
|
- **Failures** back off like YouTube's, and honour Google's rate limits:
|
|
@@ -289,12 +364,14 @@ Inside the framework, the sync lives in `lib/calendar/google/`:
|
|
|
289
364
|
|
|
290
365
|
- `map.ts`: the field mapping, as pure functions;
|
|
291
366
|
- `outbound.ts`, `inbound.ts`, `match.ts`;
|
|
367
|
+
- `bookings.ts`: copies of the site's room bookings (out only);
|
|
292
368
|
- `client.ts`: Google's API over `fetch`;
|
|
293
369
|
- `service.ts`: the scheduler job and Sync now.
|
|
294
370
|
|
|
295
371
|
Its tables (migration 69): `gcal_account`, `gcal_oauth_states`, `gcal_links`,
|
|
296
372
|
`gcal_items` (one row per pair), `gcal_queue` (filled by triggers on every
|
|
297
|
-
insert, update and delete of `events`) and `gcal_log`.
|
|
373
|
+
insert, update and delete of `events`) and `gcal_log`. Migration 73 adds
|
|
374
|
+
`gcal_booking_items` and the booking triggers ([Site bookings](#site-bookings)).
|
|
298
375
|
|
|
299
376
|
## Not yet
|
|
300
377
|
|
|
@@ -3,7 +3,7 @@ import { KeyboardAvoidingView, Platform, Pressable, ScrollView, Switch, Text, Te
|
|
|
3
3
|
import { Stack, router, useLocalSearchParams } from 'expo-router';
|
|
4
4
|
import * as Crypto from 'expo-crypto';
|
|
5
5
|
import { isAcmsError } from '@acms-subir/client';
|
|
6
|
-
import { Button, fill, formatBookingDate, useBooking, useLayout, useLocale, useResources, useSite, useStrings, useTheme } from '@acms-subir/native';
|
|
6
|
+
import { Button, fill, formatBookingDate, rememberBookingToken, useBooking, useLayout, useLocale, useResources, useSite, useStrings, useTheme } from '@acms-subir/native';
|
|
7
7
|
|
|
8
8
|
// Details form and confirmation. useBooking keeps one idempotency key per
|
|
9
9
|
// booking intent, so pressing "Book" again after a timeout can't double-book.
|
|
@@ -48,7 +48,8 @@ export default function DetailsScreen() {
|
|
|
48
48
|
<Text style={t.styles.text.body}>{when}</Text>
|
|
49
49
|
<Text style={t.styles.text.muted}>{fill(confirmed ? w.confirmationOnItsWay : w.emailWhenApproved, { email: b.email })}</Text>
|
|
50
50
|
{site.data?.features.bookingChanges
|
|
51
|
-
|
|
51
|
+
// the token stays in memory: only a random key goes in the route (on the web, the page address)
|
|
52
|
+
? <Button label={w.bookManage} variant="outline" onPress={() => router.push({ pathname: '/book/manage', params: { booking: rememberBookingToken(manageToken) } })} />
|
|
52
53
|
: <Text style={t.styles.text.small}>{w.changeByEmail}</Text>}
|
|
53
54
|
<Button label={w.requestDone} onPress={() => { booking.reset(); router.dismissAll(); }} />
|
|
54
55
|
</ScrollView>
|
|
@@ -2,10 +2,13 @@ import { useState } from 'react';
|
|
|
2
2
|
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
|
|
3
3
|
import { Stack, useLocalSearchParams } from 'expo-router';
|
|
4
4
|
import { isAcmsError } from '@acms-subir/client';
|
|
5
|
-
import { Button, ErrorView, fill, formatBookingDate, lengthText, useCancelBooking, useLayout, useLocale, useManagedBooking, useRescheduleAvailability, useRescheduleBooking, useStrings, useTheme } from '@acms-subir/native';
|
|
5
|
+
import { Button, ErrorView, bookingToken, fill, formatBookingDate, lengthText, useCancelBooking, useLayout, useLocale, useManagedBooking, useRescheduleAvailability, useRescheduleBooking, useStrings, useTheme } from '@acms-subir/native';
|
|
6
6
|
|
|
7
|
-
// The booker's own booking, from its private token
|
|
8
|
-
//
|
|
7
|
+
// The booker's own booking, from its private token: its status and time, a
|
|
8
|
+
// new time, or cancel. The confirmation screen keeps the token in memory and
|
|
9
|
+
// passes a key for it (rememberBookingToken), never the token: on the web a
|
|
10
|
+
// route is the page address. After a restart the key means nothing, and the
|
|
11
|
+
// screen points to the email's link. Needs the site's
|
|
9
12
|
// App access switch for booking changes (site.features.bookingChanges); the
|
|
10
13
|
// link in every booking email does the same on the website either way. The
|
|
11
14
|
// booking link's change cutoff applies (can.reschedule / can.cancel).
|
|
@@ -14,7 +17,8 @@ export default function ManageBookingScreen() {
|
|
|
14
17
|
const w = useStrings();
|
|
15
18
|
const { locale } = useLocale();
|
|
16
19
|
const { gutter } = useLayout();
|
|
17
|
-
const {
|
|
20
|
+
const { booking } = useLocalSearchParams<{ booking: string }>();
|
|
21
|
+
const token = bookingToken(booking);
|
|
18
22
|
const managed = useManagedBooking(token);
|
|
19
23
|
const cancel = useCancelBooking(token);
|
|
20
24
|
const move = useRescheduleBooking(token);
|
|
@@ -24,6 +28,13 @@ export default function ManageBookingScreen() {
|
|
|
24
28
|
// a link with several lengths offers new times of the booking's own length
|
|
25
29
|
const length = managed.data?.link?.mode === 'range' ? Math.round((Date.parse(managed.data.booking.end) - Date.parse(managed.data.booking.start)) / 60_000) : undefined;
|
|
26
30
|
const slots = useRescheduleAvailability(token, picking ? { days: 14, minutes: length } : null);
|
|
31
|
+
// opened again after a restart: the key no longer stands for the token
|
|
32
|
+
if (!token) return (
|
|
33
|
+
<View style={{ flex: 1, backgroundColor: t.colors.bg, padding: gutter }}>
|
|
34
|
+
<Stack.Screen options={{ title: w.yourBooking }} />
|
|
35
|
+
<Text style={t.styles.text.muted}>{w.changeByEmail}</Text>
|
|
36
|
+
</View>
|
|
37
|
+
);
|
|
27
38
|
if (managed.error && !managed.data) return <ErrorView error={managed.error} retry={() => managed.refetch()} />;
|
|
28
39
|
if (!managed.data) return <ActivityIndicator color={t.colors.accent} style={{ marginTop: 32 }} />;
|
|
29
40
|
const { booking: b, title, can, link } = managed.data;
|
package/package.json
CHANGED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// Site → Google for room bookings: a confirmed booking of a room that a link
|
|
2
|
+
// holds (GcalLink.roomIds) gets a copy in that link's Google calendar, so the
|
|
3
|
+
// room shows as busy there (a Google booking page can't double-book it) and
|
|
4
|
+
// the calendar stays a mirror of the room. Out only:
|
|
5
|
+
// - only links that send (both, out) and run on this site (its Site URL), and
|
|
6
|
+
// only the Google calendars whose link holds the booking's room: one copy
|
|
7
|
+
// in each. A link holding no room gets no bookings. Pending, declined and
|
|
8
|
+
// cancelled bookings, staff blocks and RSVPs never go out.
|
|
9
|
+
// - a copy has the booking link's name in the site's main language
|
|
10
|
+
// ("Reserved" once the link is gone), the time and a line saying it's a
|
|
11
|
+
// website booking: never the booker's name, email, phone, notes or answers,
|
|
12
|
+
// and no guests. Google never emails anyone (sendUpdates=none).
|
|
13
|
+
// - made when the booking is confirmed, patched when it moves or its link is
|
|
14
|
+
// renamed, moved to another Google calendar when the rooms change, deleted
|
|
15
|
+
// when it's cancelled, declined or deleted
|
|
16
|
+
// - every copy carries a hidden link (extendedProperties.private
|
|
17
|
+
// acmsBookingId / acmsSite): the incoming side, a full resync and the first
|
|
18
|
+
// match never take it as an event, never list it under "Only in Google",
|
|
19
|
+
// and isBooking() never reads it as an appointment from a booking page
|
|
20
|
+
// - an edit or delete in Google never changes the booking: it's listed in
|
|
21
|
+
// Recent changes once, and the site's version goes back to Google with the
|
|
22
|
+
// booking's next change
|
|
23
|
+
//
|
|
24
|
+
// The queue is gcal_queue's: a booking's row is 'booking:<id>' (migration 73's triggers).
|
|
25
|
+
import type { ContentRepository } from '../../repo/types';
|
|
26
|
+
import type { Booking, BookingLink, Resource, Settings } from '../../schema';
|
|
27
|
+
import { tzParts } from '../ics';
|
|
28
|
+
import { addWallDays, formatWall, parseWall } from '../events';
|
|
29
|
+
import type { SyncContext } from './context';
|
|
30
|
+
import { GoogleError } from './client';
|
|
31
|
+
import { describe, fieldHash, googleTimes, plainText, wallFromGoogle, windowStart, type Fields, type When } from './map';
|
|
32
|
+
import type { GcalBookingItem, GcalLink, GoogleEvent, GoogleTime, NewLogEntry } from './types';
|
|
33
|
+
|
|
34
|
+
const QUEUE_PREFIX = 'booking:';
|
|
35
|
+
/** A booking's row in gcal_queue. */
|
|
36
|
+
export const bookingQueueKey = (bookingId: string) => `${QUEUE_PREFIX}${bookingId}`;
|
|
37
|
+
/** The booking a gcal_queue row stands for, or null for an event's row. */
|
|
38
|
+
export const queuedBookingId = (key: string) => (key.startsWith(QUEUE_PREFIX) ? key.slice(QUEUE_PREFIX.length) : null);
|
|
39
|
+
|
|
40
|
+
/** A copy of a site booking (any site's): never an event, never a booked appointment. */
|
|
41
|
+
export const isBookingCopy = (g: Pick<GoogleEvent, 'extendedProperties'>) => !!g.extendedProperties?.private?.acmsBookingId;
|
|
42
|
+
|
|
43
|
+
/** The hidden link on every copy. */
|
|
44
|
+
export const bookingHiddenLink = (bookingId: string, origin: string) => ({ private: { acmsBookingId: bookingId, acmsSite: origin } });
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The Google id for a booking's copy, chosen by the site so a run that
|
|
48
|
+
* crashed after Google saved it finds that copy again. "acmsk" can never
|
|
49
|
+
* start an event's copy id (those go on with hex digits); a copy made again
|
|
50
|
+
* after Google deleted the first gets a new id (deleted ids stay taken).
|
|
51
|
+
*/
|
|
52
|
+
export function bookingCopyId(bookingId: string, again = 0): string | undefined {
|
|
53
|
+
const hex = bookingId.toLowerCase().replace(/-/g, '');
|
|
54
|
+
if (!/^[0-9a-f]{32}$/.test(hex)) return undefined;
|
|
55
|
+
return `acmsk${hex}${again ? `r${again.toString(32)}` : ''}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Whether this link gets a copy of the booking: confirmed, a room the link holds, from the link's start date on. */
|
|
59
|
+
export function linkCarriesBooking(link: Pick<GcalLink, 'roomIds' | 'startDate'>, b: Booking | null, settings: Pick<Settings, 'timezone'>): b is Booking {
|
|
60
|
+
return !!b && b.kind === 'booking' && b.status === 'confirmed' && link.roomIds.includes(b.resourceId) && b.end > windowStart(link, settings);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
64
|
+
const wallOf = (ms: number, tz: string) => { const p = tzParts(ms, tz); return `${p.y}-${pad(p.m)}-${pad(p.d)}T${pad(p.hh)}:${pad(p.mm)}`; };
|
|
65
|
+
|
|
66
|
+
/** The booking's time as the sync's When: wall-clock in its room's zone; a whole-day link's days (end inclusive). */
|
|
67
|
+
export function bookingWhen(b: Pick<Booking, 'start' | 'end' | 'timezone'>, link: Pick<BookingLink, 'mode'> | null, settings: Pick<Settings, 'timezone'>): When {
|
|
68
|
+
const tz = b.timezone || settings.timezone || 'UTC';
|
|
69
|
+
if (link?.mode === 'day') {
|
|
70
|
+
const first = wallOf(b.start, tz).slice(0, 10);
|
|
71
|
+
const last = formatWall({ ...addWallDays(parseWall(wallOf(b.end, tz).slice(0, 10))!, -1), dateOnly: true });
|
|
72
|
+
return { allDay: true, start: first, end: last < first ? first : last, tz: '' };
|
|
73
|
+
}
|
|
74
|
+
return { allDay: false, start: wallOf(b.start, tz), end: wallOf(b.end, tz), tz };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The copy's title: its booking link's name in the main language, else "Reserved". */
|
|
78
|
+
export const bookingCopyTitle = (link: Pick<BookingLink, 'name'> | null, words: SyncContext['map']['words']) => link?.name.trim() || words.reserved;
|
|
79
|
+
|
|
80
|
+
/** The whole copy: title, a line saying where to change it, its time, busy, and the hidden link. Nothing about who booked. */
|
|
81
|
+
export function bookingBody(b: Booking, link: BookingLink | null, ctx: Pick<SyncContext, 'settings' | 'origin' | 'map'>): Record<string, unknown> {
|
|
82
|
+
return {
|
|
83
|
+
summary: bookingCopyTitle(link, ctx.map.words),
|
|
84
|
+
description: ctx.map.words.bookingNote,
|
|
85
|
+
...googleTimes(bookingWhen(b, link, ctx.settings)),
|
|
86
|
+
transparency: 'opaque',
|
|
87
|
+
extendedProperties: bookingHiddenLink(b.id, ctx.origin),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// a start or end as the same text whichever way Google writes it (an offset or a zone)
|
|
92
|
+
const instantKey = (t: GoogleTime | undefined) => (t?.date ? t.date : wallFromGoogle(t, 'UTC') ?? '');
|
|
93
|
+
/** A copy's title, time and description as one hash: what the site sent, or what Google has. */
|
|
94
|
+
export const bookingCopyHash = (g: Pick<GoogleEvent, 'summary' | 'description' | 'start' | 'end'>) =>
|
|
95
|
+
fieldHash({ title: (g.summary ?? '').trim(), start: instantKey(g.start), end: instantKey(g.end), desc: plainText(g.description ?? '') });
|
|
96
|
+
|
|
97
|
+
// "Rehearsal (Sanctuary), Sat, Jan 4, 10:00 AM – 12:00 PM": for Recent changes (staff, the main language)
|
|
98
|
+
function described(b: Booking, link: BookingLink | null, room: Resource | null, ctx: SyncContext) {
|
|
99
|
+
const when = describe('when', { when: bookingWhen(b, link, ctx.settings) } as Fields);
|
|
100
|
+
return `${room ? `${room.name}, ` : ''}${when}`;
|
|
101
|
+
}
|
|
102
|
+
const logEntry = (link: GcalLink, googleId: string, bookingId: string, title: string, e: Omit<NewLogEntry, 'linkId' | 'googleId' | 'eventId' | 'title'>): NewLogEntry =>
|
|
103
|
+
({ ...e, linkId: link.id, googleId, eventId: '', title, data: { bookingId, ...(e.data ?? {}) } });
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* One booking against every link that may carry it. Each link is tried on
|
|
107
|
+
* its own (one link's error doesn't hold up the others; the booking is
|
|
108
|
+
* retried). Google refusing a copy (403) is listed once and not retried.
|
|
109
|
+
*/
|
|
110
|
+
export async function pushBooking(ctx: SyncContext, bookingId: string) {
|
|
111
|
+
const b = await ctx.repo.getBooking(bookingId);
|
|
112
|
+
const items = await ctx.repo.gcal.bookingItemsFor(bookingId);
|
|
113
|
+
if (!b && !items.length) return;
|
|
114
|
+
const [link, room] = b ? await Promise.all([b.linkId ? ctx.repo.getBookingLink(b.linkId) : null, b.resourceId ? ctx.repo.getResource(b.resourceId) : null]) : [null, null];
|
|
115
|
+
let failure: unknown = null;
|
|
116
|
+
for (const l of ctx.links) {
|
|
117
|
+
if (l.state !== 'active' || l.direction === 'in') continue;
|
|
118
|
+
const item = items.find((i) => i.linkId === l.id);
|
|
119
|
+
try { await pushToLink(ctx, l, b, link, room, item); }
|
|
120
|
+
catch (err) {
|
|
121
|
+
if (err instanceof GoogleError && (err.code === 'auth' || err.code === 'quota')) throw err;
|
|
122
|
+
if (err instanceof GoogleError && err.code === 'forbidden') {
|
|
123
|
+
await ctx.repo.gcal.log([logEntry(l, item?.googleId ?? '', bookingId, bookingCopyTitle(link, ctx.map.words), { direction: 'out', action: 'failed',
|
|
124
|
+
summary: `${err.message} The booking stays on the site; ${l.googleName} doesn’t get its copy.` })], ctx.now);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
failure ??= err;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (failure) throw failure;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const removedWhy = (b: Booking | null, l: GcalLink) =>
|
|
134
|
+
!b ? 'Deleted on the site' : b.status === 'cancelled' ? 'Cancelled on the site' : b.status === 'declined' ? 'Declined on the site'
|
|
135
|
+
: b.status !== 'confirmed' ? 'No longer confirmed on the site' : !l.roomIds.includes(b.resourceId) ? `Its room isn’t on ${l.googleName} any more` : 'Before the link’s start date';
|
|
136
|
+
|
|
137
|
+
async function pushToLink(ctx: SyncContext, l: GcalLink, b: Booking | null, link: BookingLink | null, room: Resource | null, item: GcalBookingItem | undefined) {
|
|
138
|
+
const title = bookingCopyTitle(link, ctx.map.words);
|
|
139
|
+
if (!linkCarriesBooking(l, b, ctx.settings)) {
|
|
140
|
+
if (!item) return;
|
|
141
|
+
if (!item.gone) await ctx.client.deleteEvent(l.googleId, item.googleId);
|
|
142
|
+
await ctx.repo.gcal.deleteBookingItem(l.id, item.googleId);
|
|
143
|
+
if (!item.gone) await ctx.repo.gcal.log([logEntry(l, item.googleId, item.bookingId, title, { direction: 'out', action: 'removed', summary: `${removedWhy(b, l)}: the booking’s copy was removed from ${l.googleName}.` })], ctx.now);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const body = bookingBody(b, link, ctx);
|
|
147
|
+
const want = bookingCopyHash(body as GoogleEvent);
|
|
148
|
+
const what = described(b, link, room, ctx);
|
|
149
|
+
if (!item) return void await createCopy(ctx, l, b, body, want, title, `${what}: copied to ${l.googleName}.`);
|
|
150
|
+
// nothing changed on the site since the copy was sent (an edit in Google stays there until it does)
|
|
151
|
+
if (item.site === want) return;
|
|
152
|
+
// deleted in Google: the site's change makes it again
|
|
153
|
+
if (item.gone) return void await createCopy(ctx, l, b, body, want, title, `${what}: changed on the site, so its copy is back in ${l.googleName}.`, item);
|
|
154
|
+
let g: GoogleEvent | null;
|
|
155
|
+
try { g = await ctx.client.patchEvent(l.googleId, item.googleId, body); }
|
|
156
|
+
catch (err) {
|
|
157
|
+
if (!(err instanceof GoogleError && (err.code === 'notfound' || err.code === 'gone'))) throw err;
|
|
158
|
+
g = null;
|
|
159
|
+
}
|
|
160
|
+
// deleted in Google without the site seeing it (a Site → Google only link reads nothing): a patch doesn't bring it back
|
|
161
|
+
if (!g || g.status === 'cancelled') return void await createCopy(ctx, l, b, body, want, title, `${what}: its copy was missing from ${l.googleName}, so it was made again.`, item);
|
|
162
|
+
await ctx.repo.gcal.putBookingItem({ ...item, site: want, google: bookingCopyHash(g), etag: g.etag ?? item.etag, htmlLink: g.htmlLink ?? item.htmlLink, gone: false, syncedAt: ctx.now });
|
|
163
|
+
await ctx.repo.gcal.log([logEntry(l, item.googleId, b.id, title, { direction: 'out', action: 'updated', summary: `${what} → ${l.googleName}` })], ctx.now);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function createCopy(ctx: SyncContext, l: GcalLink, b: Booking, body: Record<string, unknown>, want: string, title: string, summary: string, previous?: GcalBookingItem) {
|
|
167
|
+
const id = bookingCopyId(b.id, previous ? Math.floor(ctx.now / 1000) : 0);
|
|
168
|
+
let g: GoogleEvent;
|
|
169
|
+
try { g = await ctx.client.insertEvent(l.googleId, id ? { id, ...body } : body); }
|
|
170
|
+
catch (err) {
|
|
171
|
+
if (!(err instanceof GoogleError && err.code === 'conflict' && id)) throw err;
|
|
172
|
+
// that id is taken: our own copy from a run that stopped before saving the pair, or one Google deleted (ids stay taken)
|
|
173
|
+
const found = await ctx.client.getEvent(l.googleId, id).catch(() => null);
|
|
174
|
+
g = found && found.status !== 'cancelled' && found.extendedProperties?.private?.acmsBookingId === b.id
|
|
175
|
+
? await ctx.client.patchEvent(l.googleId, id, body)
|
|
176
|
+
: await ctx.client.insertEvent(l.googleId, body);
|
|
177
|
+
}
|
|
178
|
+
await ctx.repo.gcal.putBookingItem({ linkId: l.id, googleId: g.id, bookingId: b.id, site: want, google: bookingCopyHash(g), etag: g.etag ?? '', htmlLink: g.htmlLink ?? '',
|
|
179
|
+
gone: false, syncedAt: ctx.now, createdAt: previous?.createdAt ?? ctx.now });
|
|
180
|
+
await ctx.repo.gcal.log([logEntry(l, g.id, b.id, title, { direction: 'out', action: 'created', summary })], ctx.now);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---- what Google does to a copy ----
|
|
184
|
+
|
|
185
|
+
// what the copy says in Google now, for Recent changes
|
|
186
|
+
function googleWords(g: GoogleEvent, l: GcalLink, ctx: SyncContext) {
|
|
187
|
+
const tz = g.start?.timeZone || l.googleTz || ctx.settings.timezone || 'UTC';
|
|
188
|
+
const start = g.start?.date ?? wallFromGoogle(g.start, tz), end = g.end?.date ?? wallFromGoogle(g.end, tz);
|
|
189
|
+
const allDay = !!g.start?.date;
|
|
190
|
+
const last = allDay && end ? formatWall({ ...addWallDays(parseWall(end)!, -1), dateOnly: true }) : end;
|
|
191
|
+
const when = start ? describe('when', { when: { allDay, start, end: last ?? start, tz } } as Fields) : '';
|
|
192
|
+
return [`“${(g.summary ?? '').trim()}”`, when].filter(Boolean).join(', ');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* A copy of a site booking came in from Google (a change list, a full read,
|
|
197
|
+
* the first match). Never an event. A copy this link pairs: an edit or delete
|
|
198
|
+
* there is listed once (the booking stays as it is; its next change on the
|
|
199
|
+
* site goes back). A copy of this site's that the link doesn't pair:
|
|
200
|
+
* - of a booking it carries and has no live copy of (a crashed run, a link
|
|
201
|
+
* made again): paired by its hidden link;
|
|
202
|
+
* - of a booking that's no longer confirmed or no longer in the link's rooms
|
|
203
|
+
* (changed while unlinked), or a second copy of one: deleted, so it doesn't
|
|
204
|
+
* keep the room busy in Google;
|
|
205
|
+
* - of a booking the site doesn't have (an older backup was restored), or
|
|
206
|
+
* another site's: left alone, as an older backup never deletes anything in Google.
|
|
207
|
+
*/
|
|
208
|
+
export async function bookingCopySeen(ctx: SyncContext, l: GcalLink, g: GoogleEvent) {
|
|
209
|
+
const item = await ctx.repo.gcal.getBookingItem(l.id, g.id);
|
|
210
|
+
const hidden = g.extendedProperties?.private;
|
|
211
|
+
if (!item) {
|
|
212
|
+
if (g.status === 'cancelled' || hidden?.acmsSite !== ctx.origin || !hidden.acmsBookingId || l.direction === 'in') return;
|
|
213
|
+
const b = await ctx.repo.getBooking(hidden.acmsBookingId);
|
|
214
|
+
if (!b) return;
|
|
215
|
+
const link = b.linkId ? await ctx.repo.getBookingLink(b.linkId) : null;
|
|
216
|
+
const title = bookingCopyTitle(link, ctx.map.words);
|
|
217
|
+
// a booking before the link's start date isn't the link's business
|
|
218
|
+
const stale = b.kind !== 'booking' || b.status !== 'confirmed' || !l.roomIds.includes(b.resourceId);
|
|
219
|
+
if (!stale && !linkCarriesBooking(l, b, ctx.settings)) return;
|
|
220
|
+
const paired = (await ctx.repo.gcal.bookingItemsFor(b.id)).find((i) => i.linkId === l.id);
|
|
221
|
+
// its pair's copy is still there: this one is a second copy (checked in Google, as a run making the
|
|
222
|
+
// pair's copy again may not have saved it yet)
|
|
223
|
+
const live = !stale && paired && !paired.gone && await ctx.client.getEvent(l.googleId, paired.googleId)
|
|
224
|
+
.then((p) => p.status !== 'cancelled', (err) => { if (err instanceof GoogleError && (err.code === 'notfound' || err.code === 'gone')) return false; throw err; });
|
|
225
|
+
if (stale || live) {
|
|
226
|
+
await ctx.client.deleteEvent(l.googleId, g.id);
|
|
227
|
+
await ctx.repo.gcal.log([logEntry(l, g.id, b.id, title, { direction: 'out', action: 'removed',
|
|
228
|
+
summary: `${stale ? removedWhy(b, l) : 'A second copy of this booking'}: a stale copy of it was removed from ${l.googleName}.` })], ctx.now);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const h = bookingCopyHash(g);
|
|
232
|
+
// replaces a pair whose copy Google deleted
|
|
233
|
+
await ctx.repo.gcal.putBookingItem({ linkId: l.id, googleId: g.id, bookingId: b.id, site: h, google: h, etag: g.etag ?? '', htmlLink: g.htmlLink ?? '', gone: false, syncedAt: ctx.now, createdAt: paired?.createdAt ?? ctx.now });
|
|
234
|
+
await ctx.repo.gcal.log([logEntry(l, g.id, b.id, title, { direction: 'info', action: 'paired', summary: `Found a booking’s copy in ${l.googleName} again by its hidden link.` })], ctx.now);
|
|
235
|
+
// the copy as the booking is now, if Google's differs
|
|
236
|
+
await ctx.repo.gcal.enqueue([bookingQueueKey(b.id)], ctx.now);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const title = (g.summary ?? '').trim() || 'A booking';
|
|
240
|
+
if (g.status === 'cancelled') {
|
|
241
|
+
if (item.gone) return;
|
|
242
|
+
await ctx.repo.gcal.patchBookingItem(l.id, g.id, { gone: true, etag: g.etag ?? item.etag, syncedAt: ctx.now });
|
|
243
|
+
await ctx.repo.gcal.log([logEntry(l, g.id, item.bookingId, title, { direction: 'in', action: 'removed',
|
|
244
|
+
summary: `A booking’s copy was deleted in ${l.googleName}. The booking stays on the site; its copy comes back with its next change there.` })], ctx.now);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const h = bookingCopyHash(g);
|
|
248
|
+
if (item.gone) {
|
|
249
|
+
await ctx.repo.gcal.patchBookingItem(l.id, g.id, { gone: false, google: h, etag: g.etag ?? item.etag, syncedAt: ctx.now });
|
|
250
|
+
await ctx.repo.gcal.log([logEntry(l, g.id, item.bookingId, title, { direction: 'in', action: 'restored', summary: `A booking’s copy is back in ${l.googleName}.` })], ctx.now);
|
|
251
|
+
if (h !== item.site) await ctx.repo.gcal.enqueue([bookingQueueKey(item.bookingId)], ctx.now);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (h === item.google) {
|
|
255
|
+
if (g.etag && g.etag !== item.etag) await ctx.repo.gcal.patchBookingItem(l.id, g.id, { etag: g.etag });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
await ctx.repo.gcal.patchBookingItem(l.id, g.id, { google: h, etag: g.etag ?? item.etag, syncedAt: ctx.now });
|
|
259
|
+
await ctx.repo.gcal.log([logEntry(l, g.id, item.bookingId, title, { direction: 'in', action: 'edited',
|
|
260
|
+
summary: `A booking’s copy was changed in ${l.googleName} (now ${googleWords(g, l, ctx)}). The booking on the site stays as it is; its next change there goes back to Google.` })], ctx.now);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** After a full read: copies Google didn't list (deleted long ago) are checked one by one. */
|
|
264
|
+
export async function sweepBookingCopies(ctx: SyncContext, l: GcalLink, listed: GoogleEvent[]) {
|
|
265
|
+
const seen = new Set(listed.map((g) => g.id));
|
|
266
|
+
for (const item of (await ctx.repo.gcal.bookingItemsForLink(l.id)).filter((i) => !i.gone && !seen.has(i.googleId)).slice(0, 200)) {
|
|
267
|
+
const g = await ctx.client.getEvent(l.googleId, item.googleId).catch((err) => { if (err instanceof GoogleError && (err.code === 'notfound' || err.code === 'gone')) return null; throw err; });
|
|
268
|
+
await bookingCopySeen(ctx, l, g ?? { id: item.googleId, status: 'cancelled' });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* A link starting, resuming or now holding other rooms: the bookings of its
|
|
274
|
+
* rooms from its start date on, and the bookings it has copies of (a copy
|
|
275
|
+
* that no longer belongs is removed). After a restore, a copy whose booking
|
|
276
|
+
* the backup doesn't have is forgotten instead: an older backup never deletes
|
|
277
|
+
* anything in Google.
|
|
278
|
+
*/
|
|
279
|
+
export async function queueLinkBookings(repo: ContentRepository, l: GcalLink, settings: Pick<Settings, 'timezone'>, restored = false) {
|
|
280
|
+
const items = await repo.gcal.bookingItemsForLink(l.id);
|
|
281
|
+
const keep: GcalBookingItem[] = [];
|
|
282
|
+
for (const i of items) {
|
|
283
|
+
if (restored && !await repo.getBooking(i.bookingId)) await repo.gcal.deleteBookingItem(l.id, i.googleId);
|
|
284
|
+
else keep.push(i);
|
|
285
|
+
}
|
|
286
|
+
const bookings = l.roomIds.length ? await repo.listBookings({ from: windowStart(l, settings), statuses: ['confirmed'], kinds: ['booking'] }) : [];
|
|
287
|
+
const ids = [...bookings.filter((b) => l.roomIds.includes(b.resourceId)).map((b) => b.id), ...keep.map((i) => i.bookingId)];
|
|
288
|
+
if (ids.length) await repo.gcal.enqueue([...new Set(ids)].map(bookingQueueKey));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** What a queued booking is called in Recent changes when it couldn't be sent. */
|
|
292
|
+
export async function queuedBookingTitle(ctx: SyncContext, bookingId: string): Promise<string> {
|
|
293
|
+
const b = await ctx.repo.getBooking(bookingId).catch(() => null);
|
|
294
|
+
const link = b?.linkId ? await ctx.repo.getBookingLink(b.linkId).catch(() => null) : null;
|
|
295
|
+
return bookingCopyTitle(link, ctx.map.words);
|
|
296
|
+
}
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
type DateState, type Fields,
|
|
40
40
|
} from './map';
|
|
41
41
|
import type { GcalItem, GcalLink, GoogleEvent, Hashes, NewLogEntry } from './types';
|
|
42
|
+
import { bookingCopySeen, isBookingCopy, sweepBookingCopies } from './bookings';
|
|
42
43
|
|
|
43
44
|
export interface InboundOptions {
|
|
44
45
|
/** a full read: no member pushes (Q10) */
|
|
@@ -104,7 +105,10 @@ export async function syncIncoming(ctx: SyncContext, link: GcalLink) {
|
|
|
104
105
|
listing = await listChanges(ctx, link, true);
|
|
105
106
|
}
|
|
106
107
|
const handled = await processEvents(ctx, link, listing.items, { quiet: full });
|
|
107
|
-
if (full)
|
|
108
|
+
if (full) {
|
|
109
|
+
await sweepMissing(ctx, link, listing.items, { quiet: true });
|
|
110
|
+
await sweepBookingCopies(ctx, link, listing.items);
|
|
111
|
+
}
|
|
108
112
|
return { handled, syncToken: listing.syncToken, full };
|
|
109
113
|
}
|
|
110
114
|
|
|
@@ -141,6 +145,8 @@ async function failed(ctx: SyncContext, link: GcalLink, g: GoogleEvent, e: unkno
|
|
|
141
145
|
|
|
142
146
|
async function handleEvent(ctx: SyncContext, link: GcalLink, g: GoogleEvent, opts: InboundOptions) {
|
|
143
147
|
if (opts.skip?.has(g.id)) return;
|
|
148
|
+
// a copy of one of the site's bookings: never a site event (./bookings.ts)
|
|
149
|
+
if (isBookingCopy(g)) return void await bookingCopySeen(ctx, link, g);
|
|
144
150
|
const item = await ctx.repo.gcal.getItem(link.id, g.id);
|
|
145
151
|
if (g.status === 'cancelled') { if (item && !item.gone) await googleDeleted(ctx, link, item, opts); return; }
|
|
146
152
|
if (item) {
|
|
@@ -13,6 +13,7 @@ import type { Settings } from '../../schema';
|
|
|
13
13
|
import { parseWall } from '../events';
|
|
14
14
|
import { siteOriginOf } from './connect';
|
|
15
15
|
import { linkCarries } from './map';
|
|
16
|
+
import { queueLinkBookings } from './bookings';
|
|
16
17
|
import { Direction, PrefixRule, type GcalLink } from './types';
|
|
17
18
|
|
|
18
19
|
export const LinkInput = z.object({
|
|
@@ -91,6 +92,8 @@ async function catchUp(repo: ContentRepository, link: GcalLink, restored = false
|
|
|
91
92
|
if (restored) for (const i of items) if (!known.has(i.eventId)) await repo.gcal.deleteItem(link.id, i.googleId);
|
|
92
93
|
const ids = events.filter((e) => e.calendarIds.includes(link.calendarId)).map((e) => e.id);
|
|
93
94
|
await repo.gcal.enqueue([...ids, ...items.filter((i) => !restored || known.has(i.eventId)).map((i) => i.eventId)]);
|
|
95
|
+
// and the bookings of its rooms (their copies: ./bookings.ts)
|
|
96
|
+
await queueLinkBookings(repo, link, await repo.getSettings(), restored);
|
|
94
97
|
}
|
|
95
98
|
|
|
96
99
|
/** 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. */
|
|
@@ -29,11 +29,13 @@ const pad = (n: number) => String(n).padStart(2, '0');
|
|
|
29
29
|
/** The internal tag on booked appointments from Google booking pages (Q16): never shown publicly. */
|
|
30
30
|
export { BOOKING_TAG } from '../events';
|
|
31
31
|
|
|
32
|
-
/** The few words the sync writes, in the site's main language (Q9). */
|
|
33
|
-
export interface SyncWords { cancelled: string; details: string; link: string; booked: string; untitled: string }
|
|
32
|
+
/** The few words the sync writes, in the site's main language (Q9). reserved and bookingNote: copies of the site's bookings (./bookings.ts). */
|
|
33
|
+
export interface SyncWords { cancelled: string; details: string; link: string; booked: string; untitled: string; reserved: string; bookingNote: string }
|
|
34
34
|
const WORDS: Record<string, SyncWords> = {
|
|
35
|
-
en: { cancelled: 'Cancelled: ', details: 'Details: ', link: 'Link: ', booked: 'Booked', untitled: 'Untitled event'
|
|
36
|
-
|
|
35
|
+
en: { cancelled: 'Cancelled: ', details: 'Details: ', link: 'Link: ', booked: 'Booked', untitled: 'Untitled event', reserved: 'Reserved',
|
|
36
|
+
bookingNote: 'Booked on the website. Change or cancel it there: changes made here don’t reach the booking.' },
|
|
37
|
+
es: { cancelled: 'Cancelado: ', details: 'Detalles: ', link: 'Enlace: ', booked: 'Reservado', untitled: 'Evento sin título', reserved: 'Reservado',
|
|
38
|
+
bookingNote: 'Reservado en el sitio web. Cámbialo o cancélalo allí: los cambios hechos aquí no llegan a la reserva.' },
|
|
37
39
|
};
|
|
38
40
|
export const syncWords = (lang: string): SyncWords => WORDS[(lang || 'en').toLowerCase().split('-')[0]] ?? WORDS.en;
|
|
39
41
|
// every language's "Cancelled: ", so a Google title reads the same whichever one wrote it
|
|
@@ -428,9 +430,11 @@ export const notAnEvent = (g: Pick<GoogleEvent, 'eventType'>) => !!g.eventType &
|
|
|
428
430
|
* page's title into each booked event, so a link names its booking pages: an
|
|
429
431
|
* event with one of those titles (or that title and the booker's name after
|
|
430
432
|
* it) and a guest is a booking. So is one whose description links to a
|
|
431
|
-
* Google booking page.
|
|
433
|
+
* Google booking page. A copy of one of the site's own bookings (its hidden
|
|
434
|
+
* acmsBookingId, ./bookings.ts) never is, whatever its title.
|
|
432
435
|
*/
|
|
433
436
|
export function isBooking(g: GoogleEvent, link: Pick<GcalLink, 'bookingTitles'>): boolean {
|
|
437
|
+
if (g.extendedProperties?.private?.acmsBookingId) return false;
|
|
434
438
|
const title = titleKey(g.summary ?? '').toLowerCase();
|
|
435
439
|
const guest = (g.attendees ?? []).some((a) => !a.self && !a.organizer && !a.resource);
|
|
436
440
|
const named = link.bookingTitles.some((t) => {
|
|
@@ -10,13 +10,16 @@
|
|
|
10
10
|
// (Q18), and so does every other field and date, as the agreed starting point.
|
|
11
11
|
// Events only in Google come to the site, events only on the site go to
|
|
12
12
|
// Google, pairs marked "Not the same" count as two events, and no member
|
|
13
|
-
// notifications are sent for any of it (Q10).
|
|
13
|
+
// notifications are sent for any of it (Q10). Copies of the site's own
|
|
14
|
+
// bookings aren't events on either side: they're paired again by their
|
|
15
|
+
// hidden link when syncing starts.
|
|
14
16
|
import { ApiError } from '../../api';
|
|
15
17
|
import { hasTag } from '../../schema';
|
|
16
18
|
import type { SyncContext } from './context';
|
|
17
19
|
import { processEvents, listChanges, pairExisting } from './inbound';
|
|
18
20
|
import { queueLinkCalendar } from './links';
|
|
19
21
|
import { BOOKING_TAG, asSiteEvent, comparableTitle, googleFields, inWindow, isBooking, linkCarries, notAnEvent, sameButEnd, windowStart } from './map';
|
|
22
|
+
import { isBookingCopy } from './bookings';
|
|
20
23
|
import type { GcalLink, MatchPair, MatchState } from './types';
|
|
21
24
|
|
|
22
25
|
const REASON_WORDS: Record<MatchPair['reason'], string> = {
|
|
@@ -28,7 +31,8 @@ export const reasonWords = (r: MatchPair['reason']) => REASON_WORDS[r];
|
|
|
28
31
|
export async function buildMatch(ctx: SyncContext, link: GcalLink): Promise<MatchState> {
|
|
29
32
|
const startMs = windowStart(link, ctx.settings);
|
|
30
33
|
const { items } = await listChanges(ctx, link, true);
|
|
31
|
-
|
|
34
|
+
// copies of the site's own bookings are neither pairs nor "Only in Google" (./bookings.ts)
|
|
35
|
+
const google = items.filter((g) => !g.recurringEventId && g.status !== 'cancelled' && !notAnEvent(g) && !isBookingCopy(g))
|
|
32
36
|
.map((g) => { const f = googleFields(g, link, ctx.settings); return f ? { g, f, shape: asSiteEvent(f, ctx.settings) } : null; })
|
|
33
37
|
.filter((x): x is NonNullable<typeof x> => !!x && inWindow(x.shape, startMs, ctx.settings));
|
|
34
38
|
const site = (await ctx.repo.listEvents()).filter((e) => linkCarries(e, link) && !hasTag(e.tags, BOOKING_TAG) && inWindow(e, startMs, ctx.settings));
|
|
@@ -69,3 +69,37 @@ export const GCAL_MIGRATION = { version: 69, statements: [
|
|
|
69
69
|
`CREATE TRIGGER gcal_queue_update AFTER UPDATE ON events WHEN ${guard} BEGIN ${enqueue('NEW.id')} END`,
|
|
70
70
|
`CREATE TRIGGER gcal_queue_delete AFTER DELETE ON events WHEN ${guard} BEGIN ${enqueue('OLD.id')} END`,
|
|
71
71
|
] };
|
|
72
|
+
|
|
73
|
+
// v73: copies of the site's room bookings in Google (lib/calendar/google/bookings.ts).
|
|
74
|
+
// Out only: a confirmed booking of a room a link holds gets a copy in that
|
|
75
|
+
// link's Google calendar, and Google-side edits never change the booking.
|
|
76
|
+
//
|
|
77
|
+
// - gcal_booking_items: one row per copy (a booking ⇄ its copy in one link),
|
|
78
|
+
// with the hash of what the site last sent and of the copy as Google last
|
|
79
|
+
// had it (an edit or delete in Google is listed once, not undone)
|
|
80
|
+
// - the outgoing queue is gcal_queue's: a booking's row is keyed
|
|
81
|
+
// 'booking:<id>' (event ids never start so), so it shares the run, its
|
|
82
|
+
// lease, retries and the waiting count
|
|
83
|
+
//
|
|
84
|
+
// The triggers queue a room booking on every insert as confirmed, every
|
|
85
|
+
// change to its status, times, room or link, and its deletion, and every
|
|
86
|
+
// upcoming confirmed booking of a booking link whose name (the copy's title)
|
|
87
|
+
// or mode (whole days or times) changed, with the same guard as the events triggers.
|
|
88
|
+
const enqueueBooking = (id: string) => enqueue(`'booking:'||${id}`);
|
|
89
|
+
export const GCAL_BOOKINGS_MIGRATION = { version: 73, statements: [
|
|
90
|
+
`CREATE TABLE gcal_booking_items (
|
|
91
|
+
link_id TEXT NOT NULL, google_id TEXT NOT NULL, booking_id TEXT NOT NULL, site TEXT NOT NULL DEFAULT '', google TEXT NOT NULL DEFAULT '',
|
|
92
|
+
etag TEXT NOT NULL DEFAULT '', html_link TEXT NOT NULL DEFAULT '', gone INTEGER NOT NULL DEFAULT 0, synced_at INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL,
|
|
93
|
+
PRIMARY KEY (link_id, google_id), UNIQUE (link_id, booking_id)
|
|
94
|
+
)`,
|
|
95
|
+
`CREATE INDEX gcal_booking_items_booking ON gcal_booking_items (booking_id)`,
|
|
96
|
+
`CREATE TRIGGER gcal_booking_insert AFTER INSERT ON bookings WHEN ${guard} AND NEW.kind='booking' AND NEW.status='confirmed' BEGIN ${enqueueBooking('NEW.id')} END`,
|
|
97
|
+
`CREATE TRIGGER gcal_booking_update AFTER UPDATE OF status, start_at, end_at, resource_id, link_id, kind ON bookings WHEN ${guard} AND (NEW.kind='booking' OR OLD.kind='booking')
|
|
98
|
+
AND (OLD.status IS NOT NEW.status OR OLD.start_at IS NOT NEW.start_at OR OLD.end_at IS NOT NEW.end_at OR OLD.resource_id IS NOT NEW.resource_id OR OLD.link_id IS NOT NEW.link_id OR OLD.kind IS NOT NEW.kind)
|
|
99
|
+
BEGIN ${enqueueBooking('NEW.id')} END`,
|
|
100
|
+
`CREATE TRIGGER gcal_booking_delete AFTER DELETE ON bookings WHEN ${guard} AND OLD.kind='booking' BEGIN ${enqueueBooking('OLD.id')} END`,
|
|
101
|
+
`CREATE TRIGGER gcal_booking_link_change AFTER UPDATE OF name, mode ON booking_links WHEN ${guard} AND (OLD.name IS NOT NEW.name OR OLD.mode IS NOT NEW.mode) BEGIN
|
|
102
|
+
INSERT INTO gcal_queue (event_id,version,due_at,attempts,claimed_until,error)
|
|
103
|
+
SELECT 'booking:'||id,1,${now},0,0,'' FROM bookings WHERE link_id=NEW.id AND kind='booking' AND status='confirmed' AND end_at>${now}
|
|
104
|
+
ON CONFLICT(event_id) DO UPDATE SET version=version+1,due_at=excluded.due_at,attempts=0,error=''; END`,
|
|
105
|
+
] };
|
|
@@ -24,6 +24,7 @@ import type { SyncContext } from './context';
|
|
|
24
24
|
import { entry } from './context';
|
|
25
25
|
import { GoogleError } from './client';
|
|
26
26
|
import { applyGoogleChange, applySegmentChange } from './inbound';
|
|
27
|
+
import { pushBooking, queuedBookingId, queuedBookingTitle } from './bookings';
|
|
27
28
|
import {
|
|
28
29
|
BOOKING_TAG, NONE, changed, dateHash, fieldHashes, fieldWord, googleBody, googleFields, googleIdFor, googlePatch, inWindow, instanceBody,
|
|
29
30
|
instanceState, keyStart, linkCarries, originalStart, siteFields, windowStart, type DateState, type Fields,
|
|
@@ -34,14 +35,15 @@ const LEASE = 5 * 60_000;
|
|
|
34
35
|
/** a change Google kept refusing is dropped (and listed) after this many runs */
|
|
35
36
|
const MAX_ATTEMPTS = 8;
|
|
36
37
|
|
|
37
|
-
/** Works the outgoing queue. Returns how many
|
|
38
|
+
/** Works the outgoing queue (events, and the site's bookings: ./bookings.ts). Returns how many it finished. Throws GoogleError('auth') when Google refused the sign-in (everything waits). */
|
|
38
39
|
export async function runOutbound(ctx: SyncContext, limit = 25): Promise<number> {
|
|
39
40
|
const rows = await ctx.repo.gcal.claimQueue(ctx.now, limit, LEASE);
|
|
40
41
|
let done = 0;
|
|
41
42
|
for (let i = 0; i < rows.length; i++) {
|
|
42
43
|
const row = rows[i];
|
|
44
|
+
const bookingId = queuedBookingId(row.eventId);
|
|
43
45
|
try {
|
|
44
|
-
await pushEvent(ctx, row.eventId);
|
|
46
|
+
await (bookingId ? pushBooking(ctx, bookingId) : pushEvent(ctx, row.eventId));
|
|
45
47
|
await ctx.repo.gcal.finishQueue(row.eventId, row.version);
|
|
46
48
|
done++;
|
|
47
49
|
} catch (e) {
|
|
@@ -56,6 +58,11 @@ export async function runOutbound(ctx: SyncContext, limit = 25): Promise<number>
|
|
|
56
58
|
break;
|
|
57
59
|
}
|
|
58
60
|
if (row.attempts >= MAX_ATTEMPTS) {
|
|
61
|
+
if (bookingId) {
|
|
62
|
+
await ctx.repo.gcal.log([{ direction: 'out', action: 'failed', title: await queuedBookingTitle(ctx, bookingId), data: { bookingId }, summary: `A booking’s copy wasn’t sent to Google after ${MAX_ATTEMPTS} tries: ${message} It goes again with the booking’s next change.` }], ctx.now);
|
|
63
|
+
await ctx.repo.gcal.finishQueue(row.eventId, row.version);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
59
66
|
const event = await ctx.repo.getEvent(row.eventId).catch(() => null);
|
|
60
67
|
await ctx.repo.gcal.log([{ direction: 'out', action: 'failed', eventId: row.eventId, title: event?.title ?? '', summary: `Not sent to Google after ${MAX_ATTEMPTS} tries: ${message} Edit the event to try again.` }], ctx.now);
|
|
61
68
|
await ctx.repo.gcal.finishQueue(row.eventId, row.version);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomInt } from 'node:crypto';
|
|
2
2
|
import type { SqlDriver, SqlStatement } from '../../repo/sql';
|
|
3
|
-
import type { Conflict, GcalAccount, GcalItem, GcalLink, GoogleCalendarEntry, Hashes, LogEntry, MatchState, NewLogEntry, PrefixRule } from './types';
|
|
3
|
+
import type { Conflict, GcalAccount, GcalBookingItem, GcalItem, GcalLink, GoogleCalendarEntry, Hashes, LogEntry, MatchState, NewLogEntry, PrefixRule } from './types';
|
|
4
4
|
|
|
5
5
|
// Storage for the Google Calendar sync (tables: ./migration.ts). Links use
|
|
6
6
|
// the live module's job fields: a claim takes a lease (90 s, so a crashed run
|
|
@@ -35,6 +35,10 @@ function item(r: Row): GcalItem {
|
|
|
35
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
36
|
};
|
|
37
37
|
}
|
|
38
|
+
function bookingItem(r: Row): GcalBookingItem {
|
|
39
|
+
return { linkId: String(r.link_id), googleId: String(r.google_id), bookingId: String(r.booking_id), site: String(r.site ?? ''), google: String(r.google ?? ''),
|
|
40
|
+
etag: String(r.etag ?? ''), htmlLink: String(r.html_link ?? ''), gone: !!r.gone, syncedAt: Number(r.synced_at ?? 0), createdAt: Number(r.created_at) };
|
|
41
|
+
}
|
|
38
42
|
function logEntry(r: Row): LogEntry {
|
|
39
43
|
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
44
|
direction: r.direction as LogEntry['direction'], action: String(r.action), title: String(r.title ?? ''), summary: String(r.summary ?? ''),
|
|
@@ -48,12 +52,13 @@ const linkColumns: Record<string, string> = { googleName: 'google_name', googleT
|
|
|
48
52
|
fullSync: 'full_sync', nextAt: 'next_at', failures: 'failures', failingSince: 'failing_since', alertedAt: 'alerted_at', lastOkAt: 'last_ok_at', error: 'error' };
|
|
49
53
|
const itemColumns: Record<string, string> = { eventId: 'event_id', icalUid: 'ical_uid', kind: 'kind', segmentFrom: 'segment_from', etag: 'etag', htmlLink: 'html_link',
|
|
50
54
|
site: 'site', google: 'google', descGoogle: 'desc_google', booking: 'booking', gone: 'gone', conflict: 'conflict', syncedAt: 'synced_at' };
|
|
55
|
+
const bookingItemColumns: Record<string, string> = { site: 'site', google: 'google', etag: 'etag', htmlLink: 'html_link', gone: 'gone', syncedAt: 'synced_at' };
|
|
51
56
|
const jsonish = new Set(['calendars', 'roomIds', 'rules', 'bookingTitles', 'match', 'site', 'google', 'conflict']);
|
|
52
57
|
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
|
|
58
|
+
function sets(data: Record<string, unknown>, columns: Record<string, string>, json = jsonish) {
|
|
54
59
|
if ('leftOut' in data && data.leftOut !== undefined) data = { ...data, gone: goneValue({ gone: data.gone !== undefined ? !!data.gone : true, leftOut: !!data.leftOut }) };
|
|
55
60
|
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]) =>
|
|
61
|
+
return { sql: entries.map(([k]) => `${columns[k]}=?`), args: entries.map(([k, v]) => json.has(k) ? (v === null ? null : JSON.stringify(v)) : typeof v === 'boolean' ? Number(v) : v) };
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
export interface QueueRow { eventId: string; version: number; attempts: number }
|
|
@@ -140,9 +145,10 @@ export class GcalStore {
|
|
|
140
145
|
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
146
|
return rows[0] ? link(rows[0]) : null;
|
|
142
147
|
}
|
|
143
|
-
/** Unlink: the pairs go, both copies of every event stay. Recent changes are kept. */
|
|
148
|
+
/** Unlink: the pairs go, both copies of every event (and the bookings' copies) stay. Recent changes are kept. */
|
|
144
149
|
async deleteLink(id: string) {
|
|
145
|
-
await (await this.db()).batch([{ sql: 'DELETE FROM gcal_items WHERE link_id=?', args: [id] }, { sql: 'DELETE FROM
|
|
150
|
+
await (await this.db()).batch([{ sql: 'DELETE FROM gcal_items WHERE link_id=?', args: [id] }, { sql: 'DELETE FROM gcal_booking_items WHERE link_id=?', args: [id] },
|
|
151
|
+
{ sql: 'DELETE FROM gcal_links WHERE id=?', args: [id] }]);
|
|
146
152
|
}
|
|
147
153
|
/** Restore: every link waits for someone to check it, and changes queued before it describe content it replaced. */
|
|
148
154
|
static restoreStatements(): SqlStatement[] {
|
|
@@ -184,6 +190,33 @@ export class GcalStore {
|
|
|
184
190
|
}
|
|
185
191
|
async deleteItem(linkId: string, googleId: string) { await (await this.db()).run('DELETE FROM gcal_items WHERE link_id=? AND google_id=?', [linkId, googleId]); }
|
|
186
192
|
|
|
193
|
+
// ---- copies of the site's bookings (out only) ----
|
|
194
|
+
async bookingItemsFor(bookingId: string) { return (await (await this.db()).all('SELECT * FROM gcal_booking_items WHERE booking_id=?', [bookingId])).map(bookingItem); }
|
|
195
|
+
async bookingItemsForLink(linkId: string) { return (await (await this.db()).all('SELECT * FROM gcal_booking_items WHERE link_id=?', [linkId])).map(bookingItem); }
|
|
196
|
+
async getBookingItem(linkId: string, googleId: string) {
|
|
197
|
+
const r = (await (await this.db()).all('SELECT * FROM gcal_booking_items WHERE link_id=? AND google_id=?', [linkId, googleId]))[0];
|
|
198
|
+
return r ? bookingItem(r) : null;
|
|
199
|
+
}
|
|
200
|
+
/** Saves a copy's pair; a copy made again (a new Google id) replaces the booking's old pair in that link. */
|
|
201
|
+
async putBookingItem(i: GcalBookingItem) {
|
|
202
|
+
await (await this.db()).batch([
|
|
203
|
+
{ sql: 'DELETE FROM gcal_booking_items WHERE link_id=? AND booking_id=? AND google_id<>?', args: [i.linkId, i.bookingId, i.googleId] },
|
|
204
|
+
{ sql: `INSERT INTO gcal_booking_items (link_id,google_id,booking_id,site,google,etag,html_link,gone,synced_at,created_at) VALUES (?,?,?,?,?,?,?,?,?,?)
|
|
205
|
+
ON CONFLICT(link_id,google_id) DO UPDATE SET booking_id=excluded.booking_id,site=excluded.site,google=excluded.google,etag=excluded.etag,html_link=excluded.html_link,
|
|
206
|
+
gone=excluded.gone,synced_at=excluded.synced_at`, args: [i.linkId, i.googleId, i.bookingId, i.site, i.google, i.etag, i.htmlLink, i.gone ? 1 : 0, i.syncedAt, i.createdAt] },
|
|
207
|
+
]);
|
|
208
|
+
}
|
|
209
|
+
async patchBookingItem(linkId: string, googleId: string, data: Partial<GcalBookingItem>) {
|
|
210
|
+
// a booking copy's hashes are plain text
|
|
211
|
+
const s = sets(data, bookingItemColumns, new Set());
|
|
212
|
+
if (s.sql.length) await (await this.db()).run(`UPDATE gcal_booking_items SET ${s.sql.join(',')} WHERE link_id=? AND google_id=?`, [...s.args, linkId, googleId]);
|
|
213
|
+
}
|
|
214
|
+
async deleteBookingItem(linkId: string, googleId: string) { await (await this.db()).run('DELETE FROM gcal_booking_items WHERE link_id=? AND google_id=?', [linkId, googleId]); }
|
|
215
|
+
async countBookingItems() {
|
|
216
|
+
const rows = await (await this.db()).all('SELECT link_id, COUNT(*) AS n FROM gcal_booking_items WHERE gone=0 GROUP BY link_id');
|
|
217
|
+
return new Map(rows.map((r) => [String(r.link_id), Number(r.n)]));
|
|
218
|
+
}
|
|
219
|
+
|
|
187
220
|
// ---- the outgoing queue ----
|
|
188
221
|
/** Due events, oldest first, each held for `leaseMs` (another run skips them meanwhile). */
|
|
189
222
|
async claimQueue(now: number, limit: number, leaseMs: number): Promise<QueueRow[]> {
|
|
@@ -125,6 +125,23 @@ export interface GcalItem {
|
|
|
125
125
|
createdAt: number;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/** A copy of a site room booking in one link's Google calendar (lib/calendar/google/bookings.ts). Out only. */
|
|
129
|
+
export interface GcalBookingItem {
|
|
130
|
+
linkId: string;
|
|
131
|
+
googleId: string;
|
|
132
|
+
bookingId: string;
|
|
133
|
+
/** the hash of the copy the site last sent (bookingCopyHash) */
|
|
134
|
+
site: string;
|
|
135
|
+
/** the hash of the copy as Google last had it: an edit there is listed once */
|
|
136
|
+
google: string;
|
|
137
|
+
etag: string;
|
|
138
|
+
htmlLink: string;
|
|
139
|
+
/** deleted in Google: made again only on the site's next change to the booking */
|
|
140
|
+
gone: boolean;
|
|
141
|
+
syncedAt: number;
|
|
142
|
+
createdAt: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
128
145
|
export type LogDirection = 'in' | 'out' | 'clash' | 'info';
|
|
129
146
|
export interface LogEntry {
|
|
130
147
|
id: number;
|
|
@@ -12,7 +12,7 @@ import { SYNC_MIGRATION } from '../sync/migration';
|
|
|
12
12
|
import { ANNOUNCEMENT_TABLE, ANNOUNCEMENT_VISIBLE_COLUMNS } from '../announcements/migration';
|
|
13
13
|
import { SCREENS_MIGRATION } from '../display/store';
|
|
14
14
|
import { REQUEST_TABLE } from '../calendar/request-migration';
|
|
15
|
-
import { GCAL_MIGRATION } from '../calendar/google/migration';
|
|
15
|
+
import { GCAL_BOOKINGS_MIGRATION, GCAL_MIGRATION } from '../calendar/google/migration';
|
|
16
16
|
// Versioned schema migrations shared by the two SQLite-dialect adapters
|
|
17
17
|
// (D1 and libSQL). Applied versions are recorded in the `migrations` table;
|
|
18
18
|
// each adapter's runner applies whatever is newer, in order, at startup.
|
|
@@ -755,6 +755,10 @@ export const MIGRATIONS: Migration[] = [
|
|
|
755
755
|
`DROP TRIGGER content_version_booking_links_update`,
|
|
756
756
|
contentVersionTriggers('booking_links', [...CONTENT_VERSION_COLUMNS.booking_links, 'translations'])[2],
|
|
757
757
|
] },
|
|
758
|
+
// copies of confirmed room bookings in Google (lib/calendar/google/bookings.ts):
|
|
759
|
+
// their pairs, and triggers on bookings and booking links feeding gcal_queue.
|
|
760
|
+
// Not public, so no cache trigger.
|
|
761
|
+
GCAL_BOOKINGS_MIGRATION,
|
|
758
762
|
];
|
|
759
763
|
|
|
760
764
|
// Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
|