slotlock 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +237 -0
  2. package/agent-server.d.ts +25 -5
  3. package/agent-server.js +152 -7
  4. package/agent-store-backend.d.ts +6 -1
  5. package/agent-store-backend.js +861 -55
  6. package/application-api.d.ts +19 -1
  7. package/application-api.js +578 -21
  8. package/approvals.d.ts +2 -0
  9. package/approvals.js +12 -5
  10. package/assistant.d.ts +34 -8
  11. package/assistant.js +81 -26
  12. package/availability-store.d.ts +193 -0
  13. package/availability-store.js +558 -0
  14. package/booking-rules.d.ts +92 -5
  15. package/booking-rules.js +243 -15
  16. package/dashboard-assistant-ui.d.ts +1 -1
  17. package/dashboard-assistant-ui.js +109 -8
  18. package/dashboard.d.ts +55 -6
  19. package/dashboard.js +790 -105
  20. package/ddl.d.ts +5 -1
  21. package/ddl.js +760 -9
  22. package/event-feed.d.ts +1 -1
  23. package/event-feed.js +18 -0
  24. package/holiday-import.d.ts +38 -0
  25. package/holiday-import.js +57 -0
  26. package/holidays.d.ts +38 -0
  27. package/holidays.js +150 -0
  28. package/hours.d.ts +125 -0
  29. package/hours.js +224 -0
  30. package/index.d.ts +14 -3
  31. package/index.js +10 -2
  32. package/interop.d.ts +25 -7
  33. package/interop.js +145 -17
  34. package/lifecycle.d.ts +31 -0
  35. package/lifecycle.js +115 -0
  36. package/maintenance.d.ts +19 -0
  37. package/maintenance.js +55 -1
  38. package/metrics.js +3 -0
  39. package/openapi.json +16237 -7795
  40. package/package.json +1 -1
  41. package/ranges.d.ts +28 -0
  42. package/ranges.js +135 -0
  43. package/resource-search.d.ts +19 -0
  44. package/resource-search.js +153 -0
  45. package/rest-api.js +7 -0
  46. package/rules.d.ts +2 -0
  47. package/rules.js +2 -0
  48. package/self-host.d.ts +1 -1
  49. package/self-host.js +221 -21
  50. package/slotlock.manifest.json +1 -1
  51. package/store.d.ts +367 -24
  52. package/store.js +1567 -97
  53. package/types.d.ts +198 -0
@@ -0,0 +1,558 @@
1
+ import { SLOTLOCK_MAX_AVAILABILITY_EXCEPTIONS, invalidException, parseExceptionDays, storedExceptionWindows, } from './hours.js';
2
+ import { parseAvailabilityRules } from './weekly-hours.js';
3
+ const LOCATION_REF = /^[a-z0-9][a-z0-9._-]{0,99}$/;
4
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5
+ const MAX_LABEL = 200;
6
+ const MAX_SOURCE_UID_BYTES = 600;
7
+ const MAX_LIST = 1_000;
8
+ export const SLOTLOCK_OVERRIDE_RESOURCES = Object.freeze(['none', 'matching', 'all']);
9
+ /** Inherited hours as slotlock.inherited_availability gives them; rules that do not parse read as closed. */
10
+ export function storedInheritedAvailability(value) {
11
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
12
+ return undefined;
13
+ const { source, location, rules } = value;
14
+ if (source !== 'location' && source !== 'tenant')
15
+ return undefined;
16
+ let parsed;
17
+ try {
18
+ parsed = parseAvailabilityRules(rules);
19
+ }
20
+ catch {
21
+ parsed = [];
22
+ }
23
+ return source === 'location' && typeof location === 'string'
24
+ ? { source, location, rules: parsed }
25
+ : { source: 'tenant', rules: parsed };
26
+ }
27
+ function storedRules(value) {
28
+ try {
29
+ return parseAvailabilityRules(value);
30
+ }
31
+ catch {
32
+ return [];
33
+ }
34
+ }
35
+ function rulesParameter(sql, rules) {
36
+ return sql.json(parseAvailabilityRules([...rules]).map((rule) => ({ ...rule })));
37
+ }
38
+ function notFound(code, message) {
39
+ return Object.assign(new Error(message), { code });
40
+ }
41
+ /**
42
+ * Store the tenant's weekly hours (or clear them, `null`): every resource without its own and with
43
+ * no location hours above it uses them. `overrideResources` says what becomes of the hours
44
+ * resources keep of their own (SlotlockOverrideResources; `none` by default).
45
+ */
46
+ export async function storeTenantAvailability(sql, params) {
47
+ const override = params.overrideResources ?? 'none';
48
+ if (!SLOTLOCK_OVERRIDE_RESOURCES.includes(override)) {
49
+ throw Object.assign(new Error("overrideResources must be 'none', 'matching' or 'all'"), {
50
+ code: 'invalid_availability',
51
+ });
52
+ }
53
+ const rules = params.rules === null ? null : parseAvailabilityRules([...params.rules]);
54
+ // The hours stored before, for `matching`: read before they are replaced.
55
+ const [before] = override === 'matching'
56
+ ? await sql `
57
+ SELECT rules FROM slotlock.availability_defaults
58
+ WHERE tenant_ref = ${params.tenantRef} AND location_ref IS NULL`
59
+ : [];
60
+ if (params.rules === null) {
61
+ await sql `DELETE FROM slotlock.availability_defaults WHERE tenant_ref = ${params.tenantRef} AND location_ref IS NULL`;
62
+ }
63
+ else {
64
+ await sql `
65
+ INSERT INTO slotlock.availability_defaults (tenant_ref, location_ref, rules)
66
+ VALUES (${params.tenantRef}, NULL, ${rulesParameter(sql, params.rules)}::jsonb)
67
+ ON CONFLICT (tenant_ref, (COALESCE(location_ref, '')))
68
+ DO UPDATE SET rules = EXCLUDED.rules, updated_at = statement_timestamp()`;
69
+ }
70
+ if (override === 'all') {
71
+ await sql `
72
+ UPDATE slotlock.resources SET availability_rules = NULL
73
+ WHERE tenant_ref = ${params.tenantRef} AND availability_rules IS NOT NULL`;
74
+ }
75
+ else if (override === 'matching') {
76
+ const same = [rules, before ? storedRules(before.rules) : null].filter((candidate) => candidate !== null);
77
+ if (same.length > 0) {
78
+ // The same hours in any order: each list compared with its rules sorted.
79
+ await sql `
80
+ WITH wanted AS (
81
+ SELECT COALESCE((SELECT jsonb_agg(rule ORDER BY rule::text) FROM jsonb_array_elements(hours) rule), '[]'::jsonb) AS hours
82
+ FROM jsonb_array_elements(${sql.json(same.map((list) => list.map((rule) => ({ ...rule }))))}::jsonb) hours
83
+ )
84
+ UPDATE slotlock.resources AS resource SET availability_rules = NULL
85
+ WHERE resource.tenant_ref = ${params.tenantRef}
86
+ AND jsonb_typeof(resource.availability_rules) = 'array'
87
+ AND COALESCE(
88
+ (SELECT jsonb_agg(rule ORDER BY rule::text) FROM jsonb_array_elements(resource.availability_rules) rule),
89
+ '[]'::jsonb
90
+ ) IN (SELECT hours FROM wanted)`;
91
+ }
92
+ }
93
+ const [counted] = await sql `
94
+ SELECT count(*) FILTER (
95
+ WHERE availability_rules IS NULL
96
+ AND COALESCE(slotlock.inherited_availability(tenant_ref, location_ref) ->> 'source', 'server') <> 'location'
97
+ ) AS inheriting,
98
+ count(*) FILTER (WHERE availability_rules IS NOT NULL) AS own_hours
99
+ FROM slotlock.resources
100
+ WHERE tenant_ref = ${params.tenantRef} AND archived_at IS NULL`;
101
+ return { inheriting: Number(counted?.inheriting ?? 0), ownHours: Number(counted?.own_hours ?? 0) };
102
+ }
103
+ /**
104
+ * Store a location's weekly hours (or clear them, `null`): resources at it or inside it without
105
+ * their own use them, unless a location nearer them has its own. Null for a location the tenant
106
+ * does not have.
107
+ */
108
+ export async function setLocationAvailability(sql, params) {
109
+ if (!LOCATION_REF.test(params.location))
110
+ return null;
111
+ const [place] = await sql `
112
+ SELECT ref FROM slotlock.locations
113
+ WHERE tenant_ref = ${params.tenantRef} AND ref = ${params.location} FOR KEY SHARE`;
114
+ if (!place)
115
+ return null;
116
+ if (params.rules === null) {
117
+ await sql `
118
+ DELETE FROM slotlock.availability_defaults
119
+ WHERE tenant_ref = ${params.tenantRef} AND location_ref = ${params.location}`;
120
+ }
121
+ else {
122
+ await sql `
123
+ INSERT INTO slotlock.availability_defaults (tenant_ref, location_ref, rules)
124
+ VALUES (${params.tenantRef}, ${params.location}, ${rulesParameter(sql, params.rules)}::jsonb)
125
+ ON CONFLICT (tenant_ref, (COALESCE(location_ref, '')))
126
+ DO UPDATE SET rules = EXCLUDED.rules, updated_at = statement_timestamp()`;
127
+ }
128
+ const [counted] = await sql `
129
+ SELECT count(*) FILTER (
130
+ WHERE availability_rules IS NULL
131
+ AND slotlock.inherited_availability(tenant_ref, location_ref) ->> 'location' = ${params.location}
132
+ ) AS inheriting,
133
+ count(*) FILTER (WHERE availability_rules IS NOT NULL) AS own_hours
134
+ FROM slotlock.resources
135
+ WHERE tenant_ref = ${params.tenantRef} AND archived_at IS NULL AND location_ref IS NOT NULL
136
+ AND ${params.location} = ANY (slotlock.location_path(tenant_ref, location_ref))`;
137
+ return { inheriting: Number(counted?.inheriting ?? 0), ownHours: Number(counted?.own_hours ?? 0) };
138
+ }
139
+ export async function getAvailabilityDefaults(sql, params) {
140
+ const rows = await sql `
141
+ SELECT location_ref, rules FROM slotlock.availability_defaults
142
+ WHERE tenant_ref = ${params.tenantRef}
143
+ ORDER BY location_ref NULLS FIRST`;
144
+ const tenant = rows.find((row) => row.location_ref === null);
145
+ return {
146
+ tenant: tenant ? storedRules(tenant.rules) : null,
147
+ locations: rows
148
+ .filter((row) => row.location_ref !== null)
149
+ .map((row) => ({ location: row.location_ref, rules: storedRules(row.rules) })),
150
+ };
151
+ }
152
+ const EXCEPTION_COLUMNS = `id, tenant_ref, location_ref, resource_id,
153
+ to_char(starts_on, 'YYYY-MM-DD') AS starts_on, to_char(ends_on, 'YYYY-MM-DD') AS ends_on,
154
+ outcome, hours, label, source, source_uid, calendar_id, created_at`;
155
+ function toException(row) {
156
+ return {
157
+ id: row.id,
158
+ tenantRef: row.tenant_ref,
159
+ ...(row.location_ref !== null ? { location: row.location_ref } : {}),
160
+ ...(row.resource_id !== null ? { resourceId: row.resource_id } : {}),
161
+ startsOn: row.starts_on,
162
+ endsOn: row.ends_on,
163
+ outcome: row.outcome,
164
+ ...(row.outcome === 'open' ? { hours: storedExceptionWindows(row.hours) } : {}),
165
+ label: row.label,
166
+ source: row.source,
167
+ ...(row.source_uid !== null ? { sourceUid: row.source_uid } : {}),
168
+ ...(row.calendar_id !== null ? { calendarId: row.calendar_id } : {}),
169
+ createdAt: row.created_at,
170
+ };
171
+ }
172
+ /**
173
+ * Add an exception, or, for an import's `sourceUid` already stored at that scope, update it.
174
+ * Refuses `invalid_availability_exception`, `location_not_found`, `resource_not_found` and
175
+ * `availability_exceptions_limit_reached` (SLOTLOCK_MAX_AVAILABILITY_EXCEPTIONS a tenant).
176
+ */
177
+ export async function createAvailabilityException(sql, input) {
178
+ const days = parseExceptionDays({
179
+ startsOn: input.startsOn,
180
+ endsOn: input.endsOn,
181
+ outcome: input.outcome,
182
+ hours: input.hours,
183
+ });
184
+ const label = typeof input.label === 'string' ? input.label.trim() : '';
185
+ if (label.length < 1 || [...label].length > MAX_LABEL) {
186
+ throw invalidException(`The label must be 1-${MAX_LABEL} characters`);
187
+ }
188
+ if (input.location !== undefined && input.resourceId !== undefined) {
189
+ throw invalidException('An exception is for a location or a resource, not both');
190
+ }
191
+ const source = input.source ?? 'manual';
192
+ if (!['manual', 'ics', 'vavailability'].includes(source))
193
+ throw invalidException('Unknown source');
194
+ if (input.sourceUid !== undefined &&
195
+ (typeof input.sourceUid !== 'string' ||
196
+ input.sourceUid.length === 0 ||
197
+ Buffer.byteLength(input.sourceUid) > MAX_SOURCE_UID_BYTES)) {
198
+ throw invalidException(`A source id is 1-${MAX_SOURCE_UID_BYTES} bytes`);
199
+ }
200
+ if (input.location !== undefined) {
201
+ const [place] = LOCATION_REF.test(input.location)
202
+ ? await sql `
203
+ SELECT ref FROM slotlock.locations
204
+ WHERE tenant_ref = ${input.tenantRef} AND ref = ${input.location} FOR KEY SHARE`
205
+ : [];
206
+ if (!place)
207
+ throw notFound('location_not_found', 'Slotlock has no such location');
208
+ }
209
+ if (input.resourceId !== undefined) {
210
+ const [resource] = UUID.test(input.resourceId)
211
+ ? await sql `
212
+ SELECT id FROM slotlock.resources
213
+ WHERE tenant_ref = ${input.tenantRef} AND id = ${input.resourceId}::uuid FOR KEY SHARE`
214
+ : [];
215
+ if (!resource)
216
+ throw notFound('resource_not_found', 'Slotlock has no such resource');
217
+ }
218
+ const hours = days.hours ? sql.json(days.hours.map((window) => ({ ...window }))) : null;
219
+ const location = input.location ?? null;
220
+ const resourceId = input.resourceId ?? null;
221
+ const sourceUid = input.sourceUid ?? null;
222
+ const calendarId = input.calendarId ?? null;
223
+ const rows = await sql `
224
+ INSERT INTO slotlock.availability_exceptions (
225
+ tenant_ref, location_ref, resource_id, starts_on, ends_on, outcome, hours, label, source, source_uid,
226
+ calendar_id
227
+ )
228
+ SELECT ${input.tenantRef}, ${location}, ${resourceId}::uuid, ${days.startsOn}::date, ${days.endsOn}::date,
229
+ ${days.outcome}, ${hours}::jsonb, ${label}, ${source}, ${sourceUid}, ${calendarId}::uuid
230
+ WHERE (SELECT count(*) FROM slotlock.availability_exceptions WHERE tenant_ref = ${input.tenantRef})
231
+ < ${SLOTLOCK_MAX_AVAILABILITY_EXCEPTIONS}
232
+ OR (${sourceUid}::text IS NOT NULL AND EXISTS (
233
+ SELECT 1 FROM slotlock.availability_exceptions
234
+ WHERE tenant_ref = ${input.tenantRef}
235
+ AND COALESCE(location_ref, '') = COALESCE(${location}::text, '')
236
+ AND COALESCE(resource_id::text, '') = COALESCE(${resourceId}::text, '')
237
+ AND source_uid = ${sourceUid}))
238
+ ON CONFLICT (tenant_ref, (COALESCE(location_ref, '')), (COALESCE(resource_id::text, '')), source_uid)
239
+ WHERE source_uid IS NOT NULL
240
+ DO UPDATE SET starts_on = EXCLUDED.starts_on, ends_on = EXCLUDED.ends_on, outcome = EXCLUDED.outcome,
241
+ hours = EXCLUDED.hours, label = EXCLUDED.label, source = EXCLUDED.source,
242
+ calendar_id = EXCLUDED.calendar_id
243
+ RETURNING ${sql.unsafe(EXCEPTION_COLUMNS)}`;
244
+ const row = rows[0];
245
+ if (!row) {
246
+ throw Object.assign(new Error(`Slotlock keeps at most ${SLOTLOCK_MAX_AVAILABILITY_EXCEPTIONS} availability exceptions a tenant`), { code: 'availability_exceptions_limit_reached' });
247
+ }
248
+ return toException(row);
249
+ }
250
+ /**
251
+ * The tenant's exceptions by first day: those that apply to `resourceId` (the tenant's, its
252
+ * locations' and its own), or one `location`'s own, or all of them; only those reaching `from` to
253
+ * `to` when given. Keyset-paginated by `after` (the last one's id).
254
+ */
255
+ export async function listAvailabilityExceptions(sql, params) {
256
+ const limit = params.limit ?? 100;
257
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIST) {
258
+ throw invalidException(`The limit must be 1-${MAX_LIST}`);
259
+ }
260
+ for (const day of [params.from, params.to]) {
261
+ if (day !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(day))
262
+ throw invalidException('Dates must be YYYY-MM-DD');
263
+ }
264
+ if (params.resourceId !== undefined && !UUID.test(params.resourceId))
265
+ return [];
266
+ if (params.location !== undefined && !LOCATION_REF.test(params.location))
267
+ return [];
268
+ if (params.after !== undefined && !UUID.test(params.after))
269
+ throw invalidException('The cursor is not an id');
270
+ const resourceId = params.resourceId ?? null;
271
+ const location = params.location ?? null;
272
+ const rows = await sql `
273
+ WITH cursor AS (
274
+ SELECT starts_on, id FROM slotlock.availability_exceptions
275
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.after ?? null}::uuid
276
+ ), target AS (
277
+ SELECT resource.id, CASE WHEN resource.location_ref IS NULL THEN '{}'::text[]
278
+ ELSE slotlock.location_path(resource.tenant_ref, resource.location_ref) END AS refs
279
+ FROM slotlock.resources resource
280
+ WHERE resource.tenant_ref = ${params.tenantRef} AND resource.id = ${resourceId}::uuid
281
+ )
282
+ SELECT ${sql.unsafe(EXCEPTION_COLUMNS)} FROM slotlock.availability_exceptions exception
283
+ WHERE exception.tenant_ref = ${params.tenantRef}
284
+ AND (${resourceId}::uuid IS NULL OR EXISTS (
285
+ SELECT 1 FROM target
286
+ WHERE exception.resource_id = target.id
287
+ OR (exception.resource_id IS NULL AND exception.location_ref IS NULL)
288
+ OR exception.location_ref = ANY (target.refs)))
289
+ AND (${location}::text IS NULL OR exception.location_ref = ${location})
290
+ AND (${params.from ?? null}::date IS NULL OR exception.ends_on >= ${params.from ?? null}::date)
291
+ AND (${params.to ?? null}::date IS NULL OR exception.starts_on <= ${params.to ?? null}::date)
292
+ AND (${params.after ?? null}::uuid IS NULL
293
+ OR EXISTS (SELECT 1 FROM cursor
294
+ WHERE (exception.starts_on, exception.id) > (cursor.starts_on, cursor.id)))
295
+ ORDER BY exception.starts_on, exception.id
296
+ LIMIT ${limit}`;
297
+ return rows.map(toException);
298
+ }
299
+ export async function deleteAvailabilityException(sql, params) {
300
+ if (!UUID.test(params.id))
301
+ return false;
302
+ const rows = await sql `
303
+ DELETE FROM slotlock.availability_exceptions
304
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}::uuid
305
+ RETURNING id`;
306
+ return rows.length > 0;
307
+ }
308
+ /**
309
+ * Each resource's exceptions that reach local days from `from` to `to` (`YYYY-MM-DD`), ranked as the
310
+ * evaluator applies them: the tenant's (0), each location's from the outermost in, the resource's
311
+ * own (1000).
312
+ */
313
+ export async function rankedExceptions(sql, resourceIds, days) {
314
+ const found = new Map();
315
+ const ids = resourceIds.filter((id) => UUID.test(id));
316
+ if (ids.length === 0)
317
+ return found;
318
+ const rows = await sql `
319
+ SELECT resource.id::text AS resource_id,
320
+ to_char(exception.starts_on, 'YYYY-MM-DD') AS starts_on,
321
+ to_char(exception.ends_on, 'YYYY-MM-DD') AS ends_on,
322
+ exception.outcome, exception.hours,
323
+ CASE WHEN exception.resource_id IS NOT NULL THEN 1000
324
+ WHEN exception.location_ref IS NOT NULL
325
+ THEN 1000 - array_position(path.refs, exception.location_ref)
326
+ ELSE 0 END AS rank
327
+ FROM slotlock.resources resource
328
+ CROSS JOIN LATERAL (
329
+ SELECT CASE WHEN resource.location_ref IS NULL THEN '{}'::text[]
330
+ ELSE slotlock.location_path(resource.tenant_ref, resource.location_ref) END AS refs
331
+ ) path
332
+ JOIN slotlock.availability_exceptions exception
333
+ ON exception.tenant_ref = resource.tenant_ref
334
+ AND (exception.resource_id = resource.id
335
+ OR (exception.resource_id IS NULL AND exception.location_ref IS NULL)
336
+ OR exception.location_ref = ANY (path.refs))
337
+ WHERE resource.id = ANY (${ids}::uuid[])
338
+ AND exception.ends_on >= ${days.from}::date
339
+ AND (${days.to ?? null}::date IS NULL OR exception.starts_on <= ${days.to ?? null}::date)
340
+ ORDER BY resource.id, rank, exception.created_at, exception.id`;
341
+ for (const row of rows) {
342
+ const ranked = {
343
+ startsOn: row.starts_on,
344
+ endsOn: row.ends_on,
345
+ outcome: row.outcome,
346
+ rank: Number(row.rank),
347
+ ...(row.outcome === 'open' ? { hours: storedExceptionWindows(row.hours) } : {}),
348
+ };
349
+ found.set(row.resource_id, [...(found.get(row.resource_id) ?? []), ranked]);
350
+ }
351
+ return found;
352
+ }
353
+ /**
354
+ * Replace the exceptions an import of one resource's VAVAILABILITY made with `ranges`. Refuses as
355
+ * createAvailabilityException does.
356
+ */
357
+ export async function replaceImportedExceptions(sql, params) {
358
+ if (!UUID.test(params.resourceId))
359
+ throw notFound('resource_not_found', 'Slotlock has no such resource');
360
+ await sql `
361
+ DELETE FROM slotlock.availability_exceptions
362
+ WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}::uuid
363
+ AND source = 'vavailability'`;
364
+ for (const range of params.ranges) {
365
+ await createAvailabilityException(sql, {
366
+ tenantRef: params.tenantRef,
367
+ resourceId: params.resourceId,
368
+ startsOn: range.startsOn,
369
+ endsOn: range.endsOn,
370
+ outcome: range.outcome,
371
+ ...(range.hours ? { hours: range.hours } : {}),
372
+ label: range.outcome === 'closed' ? 'Closed (imported hours)' : 'Special hours (imported hours)',
373
+ source: 'vavailability',
374
+ sourceUid: `vavailability:${range.startsOn}`,
375
+ });
376
+ }
377
+ return params.ranges.length;
378
+ }
379
+ /** How long a subscription waits between imports, and after a failed one. */
380
+ export const SLOTLOCK_HOLIDAY_REIMPORT_DAYS = 30;
381
+ export const SLOTLOCK_HOLIDAY_RETRY_HOURS = 24;
382
+ /** Holiday calendars a tenant may keep. */
383
+ export const SLOTLOCK_MAX_HOLIDAY_CALENDARS = 50;
384
+ const HOLIDAY_CALENDAR_COLUMNS = `calendar.id, calendar.name, calendar.location_ref, calendar.resource_id,
385
+ calendar.url, calendar.imported_at, calendar.next_import_at, calendar.last_error, calendar.created_at,
386
+ (SELECT count(*)::int FROM slotlock.availability_exceptions closure
387
+ WHERE closure.calendar_id = calendar.id AND closure.tenant_ref = calendar.tenant_ref) AS closures`;
388
+ function toHolidayCalendar(row) {
389
+ return {
390
+ id: row.id,
391
+ name: row.name,
392
+ ...(row.location_ref !== null ? { location: row.location_ref } : {}),
393
+ ...(row.resource_id !== null ? { resourceId: row.resource_id } : {}),
394
+ ...(row.url !== null ? { url: row.url } : {}),
395
+ ...(row.imported_at !== null ? { importedAt: row.imported_at } : {}),
396
+ ...(row.next_import_at !== null ? { nextImportAt: row.next_import_at } : {}),
397
+ ...(row.last_error !== null ? { lastError: row.last_error } : {}),
398
+ closures: Number(row.closures),
399
+ createdAt: row.created_at,
400
+ };
401
+ }
402
+ /** A calendar's name: 1-200 characters, spaces folded. */
403
+ function calendarName(value) {
404
+ const name = value.replace(/\s+/g, ' ').trim();
405
+ if (name.length < 1 || [...name].length > MAX_LABEL) {
406
+ throw Object.assign(new Error(`A holiday calendar's name is 1-${MAX_LABEL} characters`), {
407
+ code: 'invalid_holiday_calendar',
408
+ });
409
+ }
410
+ return name;
411
+ }
412
+ /** A subscription's URL: https, no credentials, at most 2,048 bytes. */
413
+ function calendarUrl(value) {
414
+ let parsed;
415
+ try {
416
+ parsed = new URL(value);
417
+ }
418
+ catch {
419
+ throw Object.assign(new Error('The calendar URL is not a URL'), { code: 'invalid_holiday_calendar' });
420
+ }
421
+ if (parsed.protocol !== 'https:' || parsed.username || parsed.password || Buffer.byteLength(parsed.href) > 2_048) {
422
+ throw Object.assign(new Error('The calendar URL must be https, without credentials, at most 2,048 bytes'), {
423
+ code: 'invalid_holiday_calendar',
424
+ });
425
+ }
426
+ return parsed.href;
427
+ }
428
+ /**
429
+ * Import a holiday calendar's days off as closures at its scope (the tenant, a location or a
430
+ * resource), as the calendar named `name` there: each holiday added or updated by its UID, then
431
+ * the closures this calendar made earlier that it no longer names between `span.from` and
432
+ * `span.to` deleted, so a holiday it dropped goes too. A closure imported before calendars were
433
+ * kept, with the UID of one it names, becomes this calendar's. With `url`, the calendar is a
434
+ * subscription maintenance imports again every SLOTLOCK_HOLIDAY_REIMPORT_DAYS; without, it stops
435
+ * being one. Refuses `invalid_holiday_calendar`, `location_not_found`, `resource_not_found`,
436
+ * `holiday_calendars_limit_reached` and `availability_exceptions_limit_reached`.
437
+ */
438
+ export async function importHolidayCalendar(sql, params) {
439
+ if (params.location !== undefined && params.resourceId !== undefined) {
440
+ throw invalidException('A holiday calendar is for a location or a resource, not both');
441
+ }
442
+ const name = calendarName(params.name);
443
+ const url = params.url === undefined ? null : calendarUrl(params.url);
444
+ if (params.location !== undefined) {
445
+ const [place] = LOCATION_REF.test(params.location)
446
+ ? await sql `
447
+ SELECT ref FROM slotlock.locations
448
+ WHERE tenant_ref = ${params.tenantRef} AND ref = ${params.location} FOR KEY SHARE`
449
+ : [];
450
+ if (!place)
451
+ throw notFound('location_not_found', 'Slotlock has no such location');
452
+ }
453
+ if (params.resourceId !== undefined) {
454
+ const [resource] = UUID.test(params.resourceId)
455
+ ? await sql `
456
+ SELECT id FROM slotlock.resources
457
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.resourceId}::uuid FOR KEY SHARE`
458
+ : [];
459
+ if (!resource)
460
+ throw notFound('resource_not_found', 'Slotlock has no such resource');
461
+ }
462
+ const location = params.location ?? null;
463
+ const resourceId = params.resourceId ?? null;
464
+ const [calendar] = await sql `
465
+ INSERT INTO slotlock.holiday_calendars (tenant_ref, location_ref, resource_id, name, url, imported_at, next_import_at)
466
+ SELECT ${params.tenantRef}, ${location}, ${resourceId}::uuid, ${name}, ${url}, statement_timestamp(),
467
+ CASE WHEN ${url}::text IS NULL THEN NULL
468
+ ELSE statement_timestamp() + make_interval(days => ${SLOTLOCK_HOLIDAY_REIMPORT_DAYS}) END
469
+ WHERE (SELECT count(*) FROM slotlock.holiday_calendars WHERE tenant_ref = ${params.tenantRef})
470
+ < ${SLOTLOCK_MAX_HOLIDAY_CALENDARS}
471
+ OR EXISTS (
472
+ SELECT 1 FROM slotlock.holiday_calendars
473
+ WHERE tenant_ref = ${params.tenantRef}
474
+ AND COALESCE(location_ref, '') = COALESCE(${location}::text, '')
475
+ AND COALESCE(resource_id::text, '') = COALESCE(${resourceId}::text, '')
476
+ AND name = ${name})
477
+ ON CONFLICT (tenant_ref, (COALESCE(location_ref, '')), (COALESCE(resource_id::text, '')), name)
478
+ DO UPDATE SET url = EXCLUDED.url, imported_at = EXCLUDED.imported_at,
479
+ next_import_at = EXCLUDED.next_import_at, last_error = NULL
480
+ RETURNING id`;
481
+ if (!calendar) {
482
+ throw Object.assign(new Error(`Slotlock keeps at most ${SLOTLOCK_MAX_HOLIDAY_CALENDARS} holiday calendars a tenant`), {
483
+ code: 'holiday_calendars_limit_reached',
484
+ });
485
+ }
486
+ const uids = [];
487
+ for (const holiday of params.holidays) {
488
+ const sourceUid = `ics:${holiday.uid}`;
489
+ uids.push(sourceUid);
490
+ await createAvailabilityException(sql, {
491
+ tenantRef: params.tenantRef,
492
+ ...(params.location !== undefined ? { location: params.location } : {}),
493
+ ...(params.resourceId !== undefined ? { resourceId: params.resourceId } : {}),
494
+ startsOn: holiday.startsOn,
495
+ endsOn: holiday.endsOn,
496
+ outcome: 'closed',
497
+ label: holiday.label,
498
+ source: 'ics',
499
+ sourceUid,
500
+ calendarId: calendar.id,
501
+ });
502
+ }
503
+ // What this calendar said before and no longer says, in the span it was read for.
504
+ const removed = await sql `
505
+ DELETE FROM slotlock.availability_exceptions
506
+ WHERE tenant_ref = ${params.tenantRef} AND calendar_id = ${calendar.id}::uuid
507
+ AND ends_on >= ${params.span.from}::date AND starts_on <= ${params.span.to}::date
508
+ AND NOT (source_uid = ANY(${uids}::text[]))`;
509
+ const [row] = await sql `
510
+ SELECT ${sql.unsafe(HOLIDAY_CALENDAR_COLUMNS)} FROM slotlock.holiday_calendars calendar
511
+ WHERE calendar.tenant_ref = ${params.tenantRef} AND calendar.id = ${calendar.id}::uuid`;
512
+ return { calendar: toHolidayCalendar(row), imported: params.holidays.length, removed: removed.count };
513
+ }
514
+ /** The tenant's holiday calendars, by scope and name. */
515
+ export async function listHolidayCalendars(sql, params) {
516
+ const rows = await sql `
517
+ SELECT ${sql.unsafe(HOLIDAY_CALENDAR_COLUMNS)} FROM slotlock.holiday_calendars calendar
518
+ WHERE calendar.tenant_ref = ${params.tenantRef}
519
+ ORDER BY calendar.location_ref NULLS FIRST, calendar.resource_id NULLS FIRST, calendar.name
520
+ LIMIT ${SLOTLOCK_MAX_HOLIDAY_CALENDARS}`;
521
+ return rows.map(toHolidayCalendar);
522
+ }
523
+ /** Delete a holiday calendar and its closures. Null for one the tenant does not have. */
524
+ export async function deleteHolidayCalendar(sql, params) {
525
+ if (!UUID.test(params.id))
526
+ return null;
527
+ const [found] = await sql `
528
+ SELECT (SELECT count(*)::int FROM slotlock.availability_exceptions
529
+ WHERE tenant_ref = ${params.tenantRef} AND calendar_id = ${params.id}::uuid) AS closures
530
+ FROM slotlock.holiday_calendars
531
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}::uuid
532
+ FOR UPDATE`;
533
+ if (!found)
534
+ return null;
535
+ // Its closures go with it (ON DELETE CASCADE).
536
+ await sql `DELETE FROM slotlock.holiday_calendars WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}::uuid`;
537
+ return { closures: Number(found.closures) };
538
+ }
539
+ /** Subscriptions due to be imported again, the longest waiting first. */
540
+ export async function dueHolidayCalendars(sql, params) {
541
+ const rows = await sql `
542
+ SELECT ${sql.unsafe(HOLIDAY_CALENDAR_COLUMNS)} FROM slotlock.holiday_calendars calendar
543
+ WHERE calendar.tenant_ref = ${params.tenantRef} AND calendar.next_import_at <= statement_timestamp()
544
+ ORDER BY calendar.next_import_at, calendar.id
545
+ LIMIT ${params.limit}`;
546
+ return rows.map(toHolidayCalendar);
547
+ }
548
+ /** Note why a subscription's import failed, and try again in SLOTLOCK_HOLIDAY_RETRY_HOURS. */
549
+ export async function noteHolidayCalendarFailure(sql, params) {
550
+ if (!UUID.test(params.id))
551
+ return;
552
+ const error = [...params.error.replace(/\s+/g, ' ').trim()].slice(0, 500).join('') || 'The import failed';
553
+ await sql `
554
+ UPDATE slotlock.holiday_calendars
555
+ SET last_error = ${error},
556
+ next_import_at = statement_timestamp() + make_interval(hours => ${SLOTLOCK_HOLIDAY_RETRY_HOURS})
557
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}::uuid AND url IS NOT NULL`;
558
+ }