@acms-subir/core 0.3.62 → 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 +27 -0
- package/cli/migrations.generated.mjs +31 -1
- package/docs/calendar.md +61 -1
- package/docs/google-calendar.md +81 -4
- package/mobile-template/recipes/booking/files/app/book/[resource].tsx +32 -17
- package/mobile-template/recipes/booking/files/app/book/details.tsx +35 -25
- package/mobile-template/recipes/booking/files/app/book/index.tsx +11 -5
- package/mobile-template/recipes/booking/files/app/book/manage.tsx +93 -0
- package/mobile-template/recipes/booking/recipe.json +6 -3
- package/package.json +1 -1
- package/src/components/booking/BookingFlow.astro +16 -8
- package/src/lib/booking/public.ts +40 -0
- package/src/lib/booking/service.ts +7 -3
- package/src/lib/booking/view.ts +6 -0
- package/src/lib/calendar/data.ts +25 -31
- 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/display/board.ts +5 -3
- package/src/lib/editor/schedule.ts +2 -0
- package/src/lib/mobile/calendar.ts +30 -6
- package/src/lib/mobile/guest.ts +4 -2
- package/src/lib/mobile/manage.ts +8 -4
- package/src/lib/mobile/openapi.ts +4 -4
- package/src/lib/mobile/schemas.ts +6 -1
- package/src/lib/repo/migrations.ts +13 -1
- package/src/lib/repo/sql.ts +10 -5
- package/src/lib/schema.ts +19 -5
- package/src/lib/sections.ts +4 -2
- package/src/pages/admin/calendar/resources/link.astro +14 -1
- package/src/pages/book/[resource]/[link].astro +10 -5
- package/src/pages/book/[resource]/index.astro +14 -8
- package/src/pages/book/index.astro +8 -3
- package/src/pages/book/manage/[token].astro +5 -3
- package/src/pages/embed/book/[resource]/[link].astro +11 -3
|
@@ -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;
|
package/src/lib/display/board.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// One TV board, ready to render: which days it covers, which events it may
|
|
2
2
|
// show, and when it goes stale. Quick links (/display/calendar?…) and saved
|
|
3
3
|
// screens without private details show what calendar.ics shows: published
|
|
4
|
-
// events in public calendars (or an unlisted one named in cal=)
|
|
4
|
+
// events in public calendars (or an unlisted one named in cal=), plus, as
|
|
5
|
+
// /calendar shows them, the bookings of rooms set to show on the calendar,
|
|
6
|
+
// named by their booking link (never who booked). A saved
|
|
5
7
|
// screen with private details on also shows private calendars it names, the
|
|
6
8
|
// bookings of its rooms with the booker's name, the rooms and hosts of every
|
|
7
9
|
// event. Never emails, phone numbers, notes or RSVP lists.
|
|
@@ -108,9 +110,9 @@ export async function loadDisplayBoard(repo: ContentRepository, site: Settings,
|
|
|
108
110
|
const { events, calendars: shownCalendars } = await collectViewEvents(repo, site, win, {
|
|
109
111
|
audience: req.privateDetails ? 'screen' : 'public',
|
|
110
112
|
filter,
|
|
111
|
-
// quick links show what calendar.ics shows
|
|
113
|
+
// quick links show what calendar.ics shows (no subscribed feeds), and
|
|
114
|
+
// bookings as /calendar shows them: named by their link, never who booked
|
|
112
115
|
includeFeeds: req.privateDetails,
|
|
113
|
-
includeBookings: req.privateDetails,
|
|
114
116
|
// translated titles in the display's language
|
|
115
117
|
locale: lang,
|
|
116
118
|
});
|
|
@@ -263,6 +263,8 @@ export const LinkInput = z.object({
|
|
|
263
263
|
confirmMessage: z.string().max(1000).optional(), redirectUrl: z.string().max(1000).refine((v) => !v || /^https?:\/\//.test(v), 'Use an http(s) URL.').optional(),
|
|
264
264
|
active: z.boolean().optional(),
|
|
265
265
|
changeCutoff: z.number().int().min(0).max(8760).optional(),
|
|
266
|
+
// its name and description in the site's other languages: { es: { name: 'Ensayo' } } ('' = the main one)
|
|
267
|
+
translations: z.record(z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})?$/), z.object({ name: z.string().trim().max(120).default(''), description: z.string().max(2000).default('') })).optional(),
|
|
266
268
|
}).strict();
|
|
267
269
|
export const LinkPatch = LinkInput.partial();
|
|
268
270
|
|