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.
- package/CHANGELOG.md +237 -0
- package/agent-server.d.ts +25 -5
- package/agent-server.js +152 -7
- package/agent-store-backend.d.ts +6 -1
- package/agent-store-backend.js +861 -55
- package/application-api.d.ts +19 -1
- package/application-api.js +578 -21
- package/approvals.d.ts +2 -0
- package/approvals.js +12 -5
- package/assistant.d.ts +34 -8
- package/assistant.js +81 -26
- package/availability-store.d.ts +193 -0
- package/availability-store.js +558 -0
- package/booking-rules.d.ts +92 -5
- package/booking-rules.js +243 -15
- package/dashboard-assistant-ui.d.ts +1 -1
- package/dashboard-assistant-ui.js +109 -8
- package/dashboard.d.ts +55 -6
- package/dashboard.js +790 -105
- package/ddl.d.ts +5 -1
- package/ddl.js +760 -9
- package/event-feed.d.ts +1 -1
- package/event-feed.js +18 -0
- package/holiday-import.d.ts +38 -0
- package/holiday-import.js +57 -0
- package/holidays.d.ts +38 -0
- package/holidays.js +150 -0
- package/hours.d.ts +125 -0
- package/hours.js +224 -0
- package/index.d.ts +14 -3
- package/index.js +10 -2
- package/interop.d.ts +25 -7
- package/interop.js +145 -17
- package/lifecycle.d.ts +31 -0
- package/lifecycle.js +115 -0
- package/maintenance.d.ts +19 -0
- package/maintenance.js +55 -1
- package/metrics.js +3 -0
- package/openapi.json +16237 -7795
- package/package.json +1 -1
- package/ranges.d.ts +28 -0
- package/ranges.js +135 -0
- package/resource-search.d.ts +19 -0
- package/resource-search.js +153 -0
- package/rest-api.js +7 -0
- package/rules.d.ts +2 -0
- package/rules.js +2 -0
- package/self-host.d.ts +1 -1
- package/self-host.js +221 -21
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +367 -24
- package/store.js +1567 -97
- 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
|
+
}
|