slotlock 0.9.0 → 0.10.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 +305 -0
- package/NOTICE +2 -0
- package/README.md +4 -2
- package/agent-store-backend.js +209 -15
- package/application-api.d.ts +10 -1
- package/application-api.js +213 -2
- package/availability-store.js +13 -5
- package/booking-rules.d.ts +11 -0
- package/booking-rules.js +21 -1
- package/config.d.ts +50 -0
- package/config.js +107 -0
- package/dashboard-scope.d.ts +7 -0
- package/dashboard-scope.js +21 -0
- package/dashboard.d.ts +97 -2
- package/dashboard.js +3068 -284
- package/ddl-identity.d.ts +25 -0
- package/ddl-identity.js +881 -0
- package/ddl-scim.d.ts +5 -0
- package/ddl-scim.js +740 -0
- package/ddl-sso.d.ts +15 -0
- package/ddl-sso.js +1033 -0
- package/ddl.d.ts +40 -9
- package/ddl.js +2123 -49
- package/domains.d.ts +117 -0
- package/domains.js +340 -0
- package/event-feed.d.ts +1 -1
- package/event-feed.js +6 -34
- package/holiday-import.d.ts +1 -1
- package/holiday-import.js +2 -3
- package/holidays.js +70 -11
- package/index.d.ts +12 -4
- package/index.js +8 -2
- package/invitation-email.d.ts +26 -0
- package/invitation-email.js +73 -0
- package/maintenance.d.ts +7 -2
- package/maintenance.js +15 -1
- package/oidc.d.ts +135 -0
- package/oidc.js +452 -0
- package/openapi.json +1886 -6
- package/package.json +2 -1
- package/scim-server.d.ts +19 -0
- package/scim-server.js +243 -0
- package/scim.d.ts +180 -0
- package/scim.js +416 -0
- package/self-host.d.ts +19 -6
- package/self-host.js +262 -7
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +273 -2
- package/store.js +984 -72
- package/types.d.ts +33 -1
- package/types.js +68 -1
- package/workspace-domains.d.ts +146 -0
- package/workspace-domains.js +249 -0
- package/workspace-roles.d.ts +47 -0
- package/workspace-roles.js +304 -0
- package/workspace-scim.d.ts +89 -0
- package/workspace-scim.js +157 -0
- package/workspace-sso.d.ts +131 -0
- package/workspace-sso.js +180 -0
- package/workspaces.d.ts +240 -0
- package/workspaces.js +433 -0
package/store.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
// DB mechanics for the Slotlock core: explicit `postgres` client in, structured results out.
|
|
2
2
|
import { SLOTLOCK_MAX_LOCATION_DEPTH, SLOTLOCK_MAX_LOCATIONS, SLOTLOCK_MAX_RESOURCE_GROUPS, SLOTLOCK_POOL_ORDERS, SLOTLOCK_RESOURCE_KINDS, isSlotlockResourceScope, parseSlotlockLocation, parseSlotlockResourceGroup, parseSlotlockResourceProfile, slotlockGroupingRef, } from './resource-profile.js';
|
|
3
3
|
import { readSlotlockTenantUsage } from './usage.js';
|
|
4
|
+
import { isSlotlockWorkspaceRole } from './workspace-roles.js';
|
|
4
5
|
import { slotlockPlanReadRefusal } from './plans.js';
|
|
5
6
|
import { SLOTLOCK_CHANGE_ORIGINS, SLOTLOCK_CHANGE_RETENTION_DAYS, isSlotlockChangeOrigin, slotlockEventFeedMethods, } from './event-feed.js';
|
|
6
7
|
import { cancellationFeeDue, cutoffRuleBroken, bookingBufferBeforeMs, bookingSearchBlocked, bookingSearchReach, parseBookingRuleDefaults, parseEffectiveBookingRules, bookingBufferAfterMs, bookingRuleBroken, bookingSearch, parseBookingRules, } from './booking-rules.js';
|
|
7
|
-
import { createHash, randomUUID } from 'node:crypto';
|
|
8
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
8
9
|
import ICAL from 'ical.js';
|
|
9
10
|
import { SLOTLOCK_ASSISTANT_CHAT_ID, SLOTLOCK_ASSISTANT_MAX_CHATS, SLOTLOCK_CORE_DDL, SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH, SLOTLOCK_DEPLOYMENT_CHECKS, SLOTLOCK_SCHEMA_CONTROL_REFUSAL, SLOTLOCK_SCHEMA_NEWER_REFUSAL, SLOTLOCK_SCHEMA_VERSION, SLOTLOCK_STRUCTURE_DDL, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_RESOURCE_SCOPED_TABLES, SLOTLOCK_RESOURCE_SCOPE_SETTING, SLOTLOCK_TENANT_TABLES, SLOTLOCK_VALIDATION_DDL, createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, slotlockCoverageChangeRenameSql, slotlockTenantLockKey, } from './ddl.js';
|
|
10
11
|
import { findNextAvailable, mergeIntervals } from './engine.js';
|
|
11
12
|
import { SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_SOURCE_BUSY_MAX, assertBoundedSearchWindow, expandRules, invalidWindowError, } from './rules.js';
|
|
12
13
|
import { CalendarContractError, CalendarExpansionLimitError, expandCalendarEventSeries, normalizeCalendarEventContent, } from './sync.js';
|
|
14
|
+
import { SLOTLOCK_SUBJECT_TABLES, SLOTLOCK_TENANT_EXPORT_TABLES, } from './types.js';
|
|
13
15
|
import { parseAvailabilityRules } from './weekly-hours.js';
|
|
14
16
|
import { applicableHours, exceptionQueryDays } from './hours.js';
|
|
15
17
|
import { zonedDateTimeToUtc } from './timezone.js';
|
|
@@ -34,6 +36,29 @@ const MAX_HOLD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
34
36
|
const MAX_BUFFER_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
|
|
35
37
|
/** A day, as bufferBeforeMinutes allows: a read by start can then still use the index. */
|
|
36
38
|
const MAX_BUFFER_BEFORE_MS = 24 * 60 * 60 * 1000;
|
|
39
|
+
/** An audit action: `area.verb` (or `area.thing.verb`), lowercase. */
|
|
40
|
+
const AUDIT_ACTION = /^[a-z][a-z_]{0,31}(\.[a-z][a-z_]{0,31}){1,2}$/;
|
|
41
|
+
const AUDIT_TARGET_TYPE = /^[a-z][a-z_]{0,31}$/;
|
|
42
|
+
const AUDIT_ACTOR_TYPES = new Set(['person', 'api_key', 'assistant', 'system']);
|
|
43
|
+
/** Text cut to at most `max` UTF-8 bytes on a character boundary; null when absent or empty. */
|
|
44
|
+
function cutBytes(value, max) {
|
|
45
|
+
if (value === undefined || value === '')
|
|
46
|
+
return null;
|
|
47
|
+
if (Buffer.byteLength(value, 'utf8') <= max)
|
|
48
|
+
return value;
|
|
49
|
+
let out = '';
|
|
50
|
+
for (const character of value) {
|
|
51
|
+
if (Buffer.byteLength(out + character, 'utf8') > max)
|
|
52
|
+
break;
|
|
53
|
+
out += character;
|
|
54
|
+
}
|
|
55
|
+
return out === '' ? null : out;
|
|
56
|
+
}
|
|
57
|
+
/** Audit metadata kept under the column's 4 KB: past it, the event says it was cut. */
|
|
58
|
+
function boundAuditMetadata(metadata) {
|
|
59
|
+
const text = JSON.stringify(metadata);
|
|
60
|
+
return text !== undefined && Buffer.byteLength(text, 'utf8') <= 3_000 ? metadata : { truncated: true };
|
|
61
|
+
}
|
|
37
62
|
export { SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_SOURCE_BUSY_MAX };
|
|
38
63
|
/**
|
|
39
64
|
* Longest one-off event (ten years and some leap days); a recurring series is bounded by its
|
|
@@ -875,6 +900,169 @@ function pageLimit(limit) {
|
|
|
875
900
|
function sandboxTestOnlyError() {
|
|
876
901
|
return Object.assign(new Error('Slotlock does this only in a test environment (a tenant ending ~test)'), { code: 'sandbox_test_only' });
|
|
877
902
|
}
|
|
903
|
+
/** Columns an export leaves out of a table it holds: secrets, and leases only a server reads. */
|
|
904
|
+
const TENANT_EXPORT_LEFT_OUT_COLUMNS = {
|
|
905
|
+
webhook_endpoints: ['secret', 'lease_token', 'lease_until'],
|
|
906
|
+
assistant_settings: ['sealed_key'],
|
|
907
|
+
};
|
|
908
|
+
/**
|
|
909
|
+
* Every table an export holds, as one statement (so one snapshot): each row as its table's name
|
|
910
|
+
* and a JSON line, without the tenant. `$1` is the tenant. A hold keeps no tenant of its own: its
|
|
911
|
+
* resource's is its. API keys come from slotlock.list_api_keys, which holds no digest.
|
|
912
|
+
*/
|
|
913
|
+
const TENANT_EXPORT_SQL = SLOTLOCK_TENANT_EXPORT_TABLES.map((table) => {
|
|
914
|
+
if (table === 'api_keys') {
|
|
915
|
+
return `SELECT 'api_keys'::text AS table_name, (pg_catalog.to_jsonb(kept) - 'key_tenant_ref')::text AS line
|
|
916
|
+
FROM slotlock.list_api_keys($1) kept`;
|
|
917
|
+
}
|
|
918
|
+
const dropped = ['tenant_ref', ...(TENANT_EXPORT_LEFT_OUT_COLUMNS[table] ?? [])].map((column) => ` - '${column}'`).join('');
|
|
919
|
+
const where = table === 'reservations'
|
|
920
|
+
? `kept.tenant_ref = $1 OR (kept.tenant_ref IS NULL AND kept.resource_id IN (
|
|
921
|
+
SELECT owned.id FROM slotlock.resources owned WHERE owned.tenant_ref = $1))`
|
|
922
|
+
: 'kept.tenant_ref = $1';
|
|
923
|
+
return `SELECT '${table}'::text AS table_name, (pg_catalog.to_jsonb(kept)${dropped})::text AS line
|
|
924
|
+
FROM slotlock.${table} kept WHERE ${where}`;
|
|
925
|
+
}).join('\nUNION ALL\n');
|
|
926
|
+
/**
|
|
927
|
+
* Read everything the tenant keeps through one cursor (one snapshot), handing each part (one
|
|
928
|
+
* table's rows, up to SLOTLOCK_TENANT_EXPORT_PART_ROWS or about a megabyte) to `onPart`.
|
|
929
|
+
*/
|
|
930
|
+
async function readTenantExport(tx, tenantRef, onPart) {
|
|
931
|
+
const totals = { rows: 0, bytes: 0, tables: {} };
|
|
932
|
+
let current = null;
|
|
933
|
+
const flush = async () => {
|
|
934
|
+
if (current && current.lines.length > 0)
|
|
935
|
+
await onPart(current);
|
|
936
|
+
current = null;
|
|
937
|
+
};
|
|
938
|
+
await tx.unsafe(`DECLARE slotlock_tenant_export NO SCROLL CURSOR FOR\n${TENANT_EXPORT_SQL}`, [tenantRef]);
|
|
939
|
+
for (;;) {
|
|
940
|
+
const rows = await tx.unsafe('FETCH 1000 FROM slotlock_tenant_export');
|
|
941
|
+
if (rows.length === 0)
|
|
942
|
+
break;
|
|
943
|
+
for (const row of rows) {
|
|
944
|
+
const open = current;
|
|
945
|
+
if (open &&
|
|
946
|
+
(open.table !== row.table_name ||
|
|
947
|
+
open.lines.length >= SLOTLOCK_TENANT_EXPORT_PART_ROWS ||
|
|
948
|
+
open.bytes >= TENANT_EXPORT_PART_BYTES)) {
|
|
949
|
+
await flush();
|
|
950
|
+
}
|
|
951
|
+
current ??= { table: row.table_name, lines: [], bytes: 0 };
|
|
952
|
+
const bytes = Buffer.byteLength(row.line) + 1;
|
|
953
|
+
current.lines.push(row.line);
|
|
954
|
+
current.bytes += bytes;
|
|
955
|
+
totals.rows += 1;
|
|
956
|
+
totals.bytes += bytes;
|
|
957
|
+
totals.tables[row.table_name] = (totals.tables[row.table_name] ?? 0) + 1;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
await flush();
|
|
961
|
+
await tx.unsafe('CLOSE slotlock_tenant_export');
|
|
962
|
+
return totals;
|
|
963
|
+
}
|
|
964
|
+
function toTenantExport(row) {
|
|
965
|
+
return {
|
|
966
|
+
id: row.id,
|
|
967
|
+
status: row.status,
|
|
968
|
+
requestedBy: row.requested_by,
|
|
969
|
+
createdAt: row.created_at,
|
|
970
|
+
finishedAt: row.finished_at,
|
|
971
|
+
expiresAt: row.expires_at,
|
|
972
|
+
parts: row.parts,
|
|
973
|
+
rows: Number(row.row_count),
|
|
974
|
+
bytes: Number(row.bytes),
|
|
975
|
+
tables: row.tables,
|
|
976
|
+
error: row.error,
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
function tenantExportRefusal(code) {
|
|
980
|
+
const message = {
|
|
981
|
+
export_in_progress: 'Slotlock builds one export of a tenant at a time: wait for the one asked for',
|
|
982
|
+
export_limit_reached: `Slotlock keeps at most ${SLOTLOCK_MAX_TENANT_EXPORTS} exports of a tenant: delete one first`,
|
|
983
|
+
export_too_large: `Slotlock exports at most ${SLOTLOCK_TENANT_EXPORT_MAX_BYTES / 1024 / 1024} MB through the API: its operator runs slotlock tenant export`,
|
|
984
|
+
}[code];
|
|
985
|
+
return Object.assign(new Error(message), { code });
|
|
986
|
+
}
|
|
987
|
+
/** A subject request's email: one address, compared without case. */
|
|
988
|
+
const SUBJECT_EMAIL = /^[^\s@]+@[^\s@]+$/;
|
|
989
|
+
/** Whom a subject request names, checked; null when it names none, more than one, or a bad one. */
|
|
990
|
+
function subjectKey(subject) {
|
|
991
|
+
if (typeof subject !== 'object' || subject === null)
|
|
992
|
+
return null;
|
|
993
|
+
const named = Object.entries(subject).filter(([, value]) => value !== undefined);
|
|
994
|
+
if (named.length !== 1)
|
|
995
|
+
return null;
|
|
996
|
+
const [kind, raw] = named[0];
|
|
997
|
+
if (typeof raw !== 'string')
|
|
998
|
+
return null;
|
|
999
|
+
if (kind === 'email') {
|
|
1000
|
+
const value = raw.trim().toLowerCase();
|
|
1001
|
+
const bytes = Buffer.byteLength(value);
|
|
1002
|
+
return bytes >= 3 && bytes <= 320 && SUBJECT_EMAIL.test(value) ? { kind, value } : null;
|
|
1003
|
+
}
|
|
1004
|
+
if (kind === 'contact')
|
|
1005
|
+
return isValidContactRef(raw) ? { kind, value: raw } : null;
|
|
1006
|
+
if (kind === 'holder' || kind === 'reference')
|
|
1007
|
+
return isValidIdentity(raw) ? { kind, value: raw } : null;
|
|
1008
|
+
return null;
|
|
1009
|
+
}
|
|
1010
|
+
function invalidSubjectError() {
|
|
1011
|
+
return Object.assign(new Error('Slotlock names a person by exactly one of email, holder, contact or reference'), { code: 'invalid_subject' });
|
|
1012
|
+
}
|
|
1013
|
+
function emptySubjectCounts() {
|
|
1014
|
+
return Object.fromEntries(SLOTLOCK_SUBJECT_TABLES.map((table) => [table, 0]));
|
|
1015
|
+
}
|
|
1016
|
+
/** The request fields an approval may keep a booking's or an event's reference in. */
|
|
1017
|
+
const SUBJECT_REFERENCE_KEYS = ['reference', 'reservation_ref', 'external_ref', 'event_ref'];
|
|
1018
|
+
/** The approvals whose request names the subject (SQL, on `approval`). */
|
|
1019
|
+
function subjectApprovalSql(tx, key, contactRefs) {
|
|
1020
|
+
const value = key.value;
|
|
1021
|
+
switch (key.kind) {
|
|
1022
|
+
case 'email':
|
|
1023
|
+
return tx `(lower(approval.request -> 'organizer' ->> 'email') = ${value}
|
|
1024
|
+
OR (jsonb_typeof(approval.request -> 'attendees') = 'array'
|
|
1025
|
+
AND EXISTS (SELECT 1 FROM jsonb_array_elements(approval.request -> 'attendees') AS attendee(value)
|
|
1026
|
+
WHERE lower(attendee.value ->> 'email') = ${value}))
|
|
1027
|
+
OR approval.request ->> 'contact_ref' = ANY(${contactRefs}::text[]))`;
|
|
1028
|
+
case 'holder':
|
|
1029
|
+
return tx `approval.request ->> 'holder_ref' = ${value}`;
|
|
1030
|
+
case 'contact':
|
|
1031
|
+
return tx `approval.request ->> 'contact_ref' = ${value}`;
|
|
1032
|
+
case 'reference':
|
|
1033
|
+
return tx `EXISTS (SELECT 1 FROM unnest(${[...SUBJECT_REFERENCE_KEYS]}::text[]) AS field(name)
|
|
1034
|
+
WHERE approval.request ->> field.name = ${value})`;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
/** An approval's request with the subject taken out (SQL, on `approval`). */
|
|
1038
|
+
function subjectScrubbedRequestSql(tx, key, contactRefs, replacement) {
|
|
1039
|
+
const value = key.value;
|
|
1040
|
+
switch (key.kind) {
|
|
1041
|
+
case 'email':
|
|
1042
|
+
return tx `(
|
|
1043
|
+
SELECT CASE WHEN lower(kept.request -> 'organizer' ->> 'email') = ${value} THEN kept.request - 'organizer'
|
|
1044
|
+
ELSE kept.request END
|
|
1045
|
+
FROM (SELECT CASE
|
|
1046
|
+
WHEN jsonb_typeof(scrubbed.request -> 'attendees') = 'array'
|
|
1047
|
+
THEN jsonb_set(scrubbed.request, '{attendees}', coalesce((
|
|
1048
|
+
SELECT jsonb_agg(attendee.value ORDER BY attendee.ordinality)
|
|
1049
|
+
FROM jsonb_array_elements(scrubbed.request -> 'attendees')
|
|
1050
|
+
WITH ORDINALITY AS attendee(value, ordinality)
|
|
1051
|
+
WHERE lower(attendee.value ->> 'email') IS DISTINCT FROM ${value}), '[]'::jsonb))
|
|
1052
|
+
ELSE scrubbed.request END AS request
|
|
1053
|
+
FROM (SELECT CASE WHEN approval.request ->> 'contact_ref' = ANY(${contactRefs}::text[])
|
|
1054
|
+
THEN approval.request - 'contact_ref' ELSE approval.request END AS request) scrubbed) kept)`;
|
|
1055
|
+
case 'holder':
|
|
1056
|
+
return tx `jsonb_set(approval.request, '{holder_ref}', to_jsonb(${replacement}::text))`;
|
|
1057
|
+
case 'contact':
|
|
1058
|
+
return tx `approval.request - 'contact_ref'`;
|
|
1059
|
+
case 'reference':
|
|
1060
|
+
return tx `(
|
|
1061
|
+
SELECT approval.request || coalesce(jsonb_object_agg(field.name, to_jsonb(${replacement}::text)), '{}'::jsonb)
|
|
1062
|
+
FROM unnest(${[...SUBJECT_REFERENCE_KEYS]}::text[]) AS field(name)
|
|
1063
|
+
WHERE approval.request ->> field.name = ${value})`;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
878
1066
|
function invalidIdentityError(message) {
|
|
879
1067
|
return Object.assign(new Error(message), { code: 'invalid_identity' });
|
|
880
1068
|
}
|
|
@@ -1319,6 +1507,7 @@ async function bookingRuling(tx, resourceId, interval, defaultHours) {
|
|
|
1319
1507
|
rule: judge.broken(interval),
|
|
1320
1508
|
turnaroundMs: judge.turnaroundMs,
|
|
1321
1509
|
bufferBeforeMs: judge.bufferBeforeMs,
|
|
1510
|
+
holdPrice: judge.holdPrice,
|
|
1322
1511
|
};
|
|
1323
1512
|
}
|
|
1324
1513
|
/**
|
|
@@ -1351,10 +1540,23 @@ async function localStartFor(tx, resourceId, localStart, start) {
|
|
|
1351
1540
|
async function sourceBusyLoss(tx, resourceId, start, end) {
|
|
1352
1541
|
const [hit] = await tx `
|
|
1353
1542
|
SELECT 1 FROM slotlock.source_busy
|
|
1354
|
-
WHERE resource_id
|
|
1543
|
+
WHERE resource_id IN (${compositeKin(tx, resourceId)}) AND starts_at < ${end} AND ends_at > ${start}
|
|
1355
1544
|
LIMIT 1`;
|
|
1356
1545
|
return hit !== undefined;
|
|
1357
1546
|
}
|
|
1547
|
+
/**
|
|
1548
|
+
* The resources whose busy time keeps this one busy (RFC 0003 G8, RFC 0004 C1), as a subquery: it,
|
|
1549
|
+
* its composite if it is a part (the whole is in use), and its parts if it is a composite (a part
|
|
1550
|
+
* in use takes the whole). Never its sibling parts, which are booked apart.
|
|
1551
|
+
*/
|
|
1552
|
+
function compositeKin(executor, resourceId) {
|
|
1553
|
+
return executor `
|
|
1554
|
+
SELECT ${resourceId}::uuid
|
|
1555
|
+
UNION ALL
|
|
1556
|
+
SELECT parent_id FROM slotlock.resources WHERE id = ${resourceId}::uuid AND parent_id IS NOT NULL
|
|
1557
|
+
UNION ALL
|
|
1558
|
+
SELECT id FROM slotlock.resources WHERE parent_id = ${resourceId}::uuid`;
|
|
1559
|
+
}
|
|
1358
1560
|
/**
|
|
1359
1561
|
* A source's busy time as kept: each interval valid, clipped to the window, sorted and merged
|
|
1360
1562
|
* (touching ones too). Null when any is not an interval, or there are too many.
|
|
@@ -1488,6 +1690,42 @@ async function rulesNow(tx, resourceId) {
|
|
|
1488
1690
|
* Mark what the next change of a booking records on the feed (RFC 0004 B5): a cancellation's fee
|
|
1489
1691
|
* and an admin's reason for going past a cutoff. Cleared with nulls.
|
|
1490
1692
|
*/
|
|
1693
|
+
/**
|
|
1694
|
+
* Whether a paid hold ended without a booking (RFC 0004 C3), so its payment was never applied:
|
|
1695
|
+
* not booked (confirmed, or booked from), not still held, and not taken over by an approval that
|
|
1696
|
+
* was granted or may still be.
|
|
1697
|
+
*/
|
|
1698
|
+
async function paidHoldEnded(tx, tenantRef, payment) {
|
|
1699
|
+
if (payment.booked_at)
|
|
1700
|
+
return false;
|
|
1701
|
+
const [hold] = await tx `
|
|
1702
|
+
SELECT status, expires_at > statement_timestamp() AS live FROM slotlock.reservations WHERE id = ${payment.id}`;
|
|
1703
|
+
if (hold)
|
|
1704
|
+
return hold.status === 'held' && !hold.live;
|
|
1705
|
+
if (payment.approval_id) {
|
|
1706
|
+
const [approval] = await tx `
|
|
1707
|
+
SELECT status, expires_at > statement_timestamp() AS live FROM slotlock.approvals
|
|
1708
|
+
WHERE id = ${payment.approval_id}::uuid AND tenant_ref = ${tenantRef}`;
|
|
1709
|
+
if (approval?.status === 'granted')
|
|
1710
|
+
return false;
|
|
1711
|
+
if (approval?.status === 'pending' && approval.live)
|
|
1712
|
+
return false;
|
|
1713
|
+
}
|
|
1714
|
+
return true;
|
|
1715
|
+
}
|
|
1716
|
+
/**
|
|
1717
|
+
* Whether cancelling a booking that starts at `start` would be late now (RFC 0004 B5): past its
|
|
1718
|
+
* cancel cutoff, or inside the window its cancellation fee is due in. A move made then keeps that
|
|
1719
|
+
* start as the booking's cutoff anchor, so a move cannot take it out of what a cancellation owes.
|
|
1720
|
+
*/
|
|
1721
|
+
function cancellationLate(rules, start, now) {
|
|
1722
|
+
return (cutoffRuleBroken(rules, 'cancel', start, now) !== null ||
|
|
1723
|
+
(rules?.cancellationFee?.withinMinutes !== undefined && cancellationFeeDue(rules, start, now) !== null));
|
|
1724
|
+
}
|
|
1725
|
+
/** The start a cancellation is judged from: the cutoff anchor a late move kept, if earlier. */
|
|
1726
|
+
function cancellationStart(start, anchor) {
|
|
1727
|
+
return anchor && anchor.getTime() < start.getTime() ? anchor : start;
|
|
1728
|
+
}
|
|
1491
1729
|
async function noteEnding(tx, fee, overrideReason) {
|
|
1492
1730
|
await tx `
|
|
1493
1731
|
SELECT set_config('slotlock.cancellation_fee', ${fee ? JSON.stringify(fee) : ''}, true),
|
|
@@ -1502,18 +1740,22 @@ async function eventEnding(tx, event, enforce, overrideReason) {
|
|
|
1502
1740
|
if (event.recurring)
|
|
1503
1741
|
return { ok: true, fee: null };
|
|
1504
1742
|
const judged = await rulesNow(tx, event.resource_id);
|
|
1743
|
+
// From the start it had when a late move kept it, if one did.
|
|
1744
|
+
const start = cancellationStart(event.starts_at, event.cutoff_anchor);
|
|
1505
1745
|
let note = null;
|
|
1506
|
-
if (enforce && cutoffRuleBroken(judged.rules, 'cancel',
|
|
1746
|
+
if (enforce && cutoffRuleBroken(judged.rules, 'cancel', start, judged.now)) {
|
|
1507
1747
|
if (overrideReason === undefined) {
|
|
1508
1748
|
return { ok: false, refusal: { ok: false, code: 'booking_rule_violated', rule: 'cancel_cutoff' } };
|
|
1509
1749
|
}
|
|
1510
1750
|
note = overrideReason;
|
|
1511
1751
|
}
|
|
1512
|
-
const due = cancellationFeeDue(judged.rules,
|
|
1752
|
+
const due = cancellationFeeDue(judged.rules, start, judged.now);
|
|
1513
1753
|
const fee = due ? { amountMinor: due.amountMinor, currency: due.currency } : null;
|
|
1514
1754
|
await noteEnding(tx, fee ? { amount_minor: fee.amountMinor, currency: fee.currency } : null, note);
|
|
1515
1755
|
return { ok: true, fee };
|
|
1516
1756
|
}
|
|
1757
|
+
/** How long busy time a calendar reported is kept after it ended: the feed's retention. */
|
|
1758
|
+
const SOURCE_BUSY_RETENTION_DAYS = 30;
|
|
1517
1759
|
/** How recently a sync must have read a resource whose booking rules require certainty. */
|
|
1518
1760
|
const CERTAIN_COVERAGE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
1519
1761
|
/**
|
|
@@ -1530,7 +1772,7 @@ async function bookingJudge(tx, resourceId, defaultHours) {
|
|
|
1530
1772
|
to_char((statement_timestamp() AT TIME ZONE 'UTC')::date - 2, 'YYYY-MM-DD') AS today
|
|
1531
1773
|
FROM slotlock.resources WHERE id = ${resourceId}`;
|
|
1532
1774
|
if (!row || row.effective_booking_rules === null || row.effective_booking_rules === undefined) {
|
|
1533
|
-
return { broken: () => null, turnaroundMs: 0, bufferBeforeMs: 0 };
|
|
1775
|
+
return { broken: () => null, turnaroundMs: 0, bufferBeforeMs: 0, holdPrice: undefined };
|
|
1534
1776
|
}
|
|
1535
1777
|
const resource = toResource({
|
|
1536
1778
|
id: resourceId,
|
|
@@ -1583,6 +1825,7 @@ async function bookingJudge(tx, resourceId, defaultHours) {
|
|
|
1583
1825
|
}) ?? (uncertain(interval) ? 'uncertain' : null),
|
|
1584
1826
|
turnaroundMs,
|
|
1585
1827
|
bufferBeforeMs,
|
|
1828
|
+
holdPrice: rules?.holdPrice,
|
|
1586
1829
|
};
|
|
1587
1830
|
}
|
|
1588
1831
|
function sameExternalPayload(row, params, turnaroundMs = 0) {
|
|
@@ -1594,6 +1837,29 @@ function sameExternalPayload(row, params, turnaroundMs = 0) {
|
|
|
1594
1837
|
Number(row.revision) === params.revision &&
|
|
1595
1838
|
row.source === (params.source ?? null));
|
|
1596
1839
|
}
|
|
1840
|
+
/**
|
|
1841
|
+
* What a holder holds in the tenant, live: its own holds, and those of approvals it asked for that
|
|
1842
|
+
* a person has yet to decide (RFC 0004 A4), which hold time for it just the same. Counted for its
|
|
1843
|
+
* caps under the holder's lock. `except` leaves out a hold about to give way to an approval.
|
|
1844
|
+
*/
|
|
1845
|
+
async function holderHeld(tx, tenantRef, holderRef, except) {
|
|
1846
|
+
const [held] = await tx `
|
|
1847
|
+
SELECT count(*)::int AS count,
|
|
1848
|
+
COALESCE(sum(extract(epoch FROM
|
|
1849
|
+
ends_at + (buffer_after_ms + buffer_before_ms) * interval '1 millisecond'
|
|
1850
|
+
- starts_at)), 0)::float8
|
|
1851
|
+
AS seconds
|
|
1852
|
+
FROM slotlock.reservations
|
|
1853
|
+
WHERE tenant_ref IS NULL AND status = 'held'
|
|
1854
|
+
AND expires_at > statement_timestamp()
|
|
1855
|
+
AND resource_id IN (SELECT id FROM slotlock.resources WHERE tenant_ref = ${tenantRef})
|
|
1856
|
+
AND (${except ?? null}::uuid IS NULL OR id <> ${except ?? null}::uuid)
|
|
1857
|
+
AND (holder_ref = ${holderRef}
|
|
1858
|
+
OR holder_ref IN (SELECT 'approval:' || approval.id FROM slotlock.approvals approval
|
|
1859
|
+
WHERE approval.tenant_ref = ${tenantRef} AND approval.status = 'pending'
|
|
1860
|
+
AND approval.requester ->> 'holder_ref' = ${holderRef}))`;
|
|
1861
|
+
return { count: held?.count ?? 0, seconds: held?.seconds ?? 0 };
|
|
1862
|
+
}
|
|
1597
1863
|
async function lockExternalIdentity(tx, tenantRef, externalRef) {
|
|
1598
1864
|
// JSON is unambiguous for this pair and has no NUL byte (Postgres text rejects U+0000).
|
|
1599
1865
|
await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([tenantRef, externalRef])}, 0))`;
|
|
@@ -1982,6 +2248,24 @@ async function rollStoredSeries(tx, tenantRef, eventId, horizon) {
|
|
|
1982
2248
|
cursor: row.recurrence_cursor ?? null,
|
|
1983
2249
|
stored: true,
|
|
1984
2250
|
});
|
|
2251
|
+
// Busy time a calendar reported (RFC 0004 C1) is refused to a rolled occurrence as an overlap is:
|
|
2252
|
+
// nothing is written, the series is put off, and the roll counts it a conflict.
|
|
2253
|
+
if ((event.transparency ?? 'opaque') === 'opaque' && occurrences.length > 0) {
|
|
2254
|
+
const beforeMs = Number(row.buffer_before_ms ?? 0);
|
|
2255
|
+
const afterMs = Number(row.buffer_after_ms ?? 0);
|
|
2256
|
+
const [met] = await tx `
|
|
2257
|
+
SELECT 1
|
|
2258
|
+
FROM slotlock.source_busy busy
|
|
2259
|
+
JOIN ROWS FROM (
|
|
2260
|
+
pg_catalog.unnest(${occurrences.map((each) => new Date(each.start.getTime() - beforeMs).toISOString())}::text[]::timestamptz[]),
|
|
2261
|
+
pg_catalog.unnest(${occurrences.map((each) => new Date(each.end.getTime() + afterMs).toISOString())}::text[]::timestamptz[]))
|
|
2262
|
+
AS occurrence(starts_at, ends_at)
|
|
2263
|
+
ON busy.starts_at < occurrence.ends_at AND busy.ends_at > occurrence.starts_at
|
|
2264
|
+
WHERE busy.resource_id IN (${compositeKin(tx, row.resource_id)})
|
|
2265
|
+
LIMIT 1`;
|
|
2266
|
+
if (met)
|
|
2267
|
+
throw Object.assign(new Error('slotlock: a rolled occurrence meets reported busy time'), { code: EXCLUSION_VIOLATION });
|
|
2268
|
+
}
|
|
1985
2269
|
await replaceCalendarEventOccurrences(tx, {
|
|
1986
2270
|
tenantRef,
|
|
1987
2271
|
eventId: row.id,
|
|
@@ -2157,6 +2441,35 @@ async function rollSeriesBehind(executor, resourceId, start, end, exceptEventId)
|
|
|
2157
2441
|
// Rolled back whole (its own transaction, or a savepoint of the caller's).
|
|
2158
2442
|
}
|
|
2159
2443
|
}
|
|
2444
|
+
/** An audit event as the API and `slotlock audit export` give it: snake_case, nulls kept. */
|
|
2445
|
+
export function slotlockAuditEventRecord(event) {
|
|
2446
|
+
return {
|
|
2447
|
+
id: event.id,
|
|
2448
|
+
occurred_at: event.occurredAt.toISOString(),
|
|
2449
|
+
action: event.action,
|
|
2450
|
+
actor_type: event.actorType,
|
|
2451
|
+
actor_ref: event.actorRef,
|
|
2452
|
+
actor_role: event.actorRole ?? null,
|
|
2453
|
+
target_type: event.targetType ?? null,
|
|
2454
|
+
target_ref: event.targetRef ?? null,
|
|
2455
|
+
outcome: event.outcome,
|
|
2456
|
+
reason: event.reason ?? null,
|
|
2457
|
+
ip_address: event.ipAddress ?? null,
|
|
2458
|
+
user_agent: event.userAgent ?? null,
|
|
2459
|
+
metadata: event.metadata,
|
|
2460
|
+
chain_index: event.chainIndex,
|
|
2461
|
+
hash: event.hash,
|
|
2462
|
+
};
|
|
2463
|
+
}
|
|
2464
|
+
/** Hours a tenant export is kept once ready, or since it was asked for if it never is. */
|
|
2465
|
+
export const SLOTLOCK_TENANT_EXPORT_HOURS = 24;
|
|
2466
|
+
/** The most a tenant export holds: past it, the export fails (`export_too_large`). */
|
|
2467
|
+
export const SLOTLOCK_TENANT_EXPORT_MAX_BYTES = 512 * 1024 * 1024;
|
|
2468
|
+
/** The most exports a tenant keeps at once. */
|
|
2469
|
+
export const SLOTLOCK_MAX_TENANT_EXPORTS = 5;
|
|
2470
|
+
/** A part of an export: one table's rows, at most this many, or about this many bytes. */
|
|
2471
|
+
export const SLOTLOCK_TENANT_EXPORT_PART_ROWS = 5_000;
|
|
2472
|
+
const TENANT_EXPORT_PART_BYTES = 1024 * 1024;
|
|
2160
2473
|
const MAX_TENANT_LIMIT = 10_000_000;
|
|
2161
2474
|
function tenantLimitsOption(limits) {
|
|
2162
2475
|
const valid = {};
|
|
@@ -2448,7 +2761,7 @@ function sourceBusy(executor, resourceId, window) {
|
|
|
2448
2761
|
return executor `
|
|
2449
2762
|
SELECT busy.starts_at AS occupied_starts_at, busy.ends_at AS occupied_ends_at
|
|
2450
2763
|
FROM slotlock.source_busy busy
|
|
2451
|
-
WHERE busy.resource_id
|
|
2764
|
+
WHERE busy.resource_id IN (${compositeKin(executor, resourceId)})
|
|
2452
2765
|
AND busy.starts_at < ${window.end} AND busy.ends_at > ${window.start}`;
|
|
2453
2766
|
}
|
|
2454
2767
|
/**
|
|
@@ -2560,6 +2873,24 @@ export function createTenantSlots(max) {
|
|
|
2560
2873
|
function lockTimeoutError(cause) {
|
|
2561
2874
|
return Object.assign(new Error('Slotlock tenant transaction waited too long for a lock: retry later', { cause }), { code: 'lock_timeout' });
|
|
2562
2875
|
}
|
|
2876
|
+
/**
|
|
2877
|
+
* Build a pending tenant export (RFC 0004 D3) in its own tenant transaction, and mark it failed if
|
|
2878
|
+
* that fails (`export_too_large`, else `export_failed`). Null when it was not pending, or another
|
|
2879
|
+
* server is building it. Never throws: a server builds it after answering, and maintenance builds
|
|
2880
|
+
* one left pending.
|
|
2881
|
+
*/
|
|
2882
|
+
export async function buildSlotlockTenantExport(store, tenantRef, exportId) {
|
|
2883
|
+
try {
|
|
2884
|
+
return await store.withTenant(tenantRef, (tenant) => tenant.buildTenantExport({ tenantRef, exportId }));
|
|
2885
|
+
}
|
|
2886
|
+
catch (error) {
|
|
2887
|
+
const reason = error?.code === 'export_too_large' ? 'export_too_large' : 'export_failed';
|
|
2888
|
+
await store
|
|
2889
|
+
.withTenant(tenantRef, (tenant) => tenant.failTenantExport({ tenantRef, exportId, error: reason }))
|
|
2890
|
+
.catch(() => undefined);
|
|
2891
|
+
return null;
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2563
2894
|
export function createSlotlockStore(sql, options = {}) {
|
|
2564
2895
|
return buildSlotlockStore(sql, options, null);
|
|
2565
2896
|
}
|
|
@@ -3977,7 +4308,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3977
4308
|
await lockExternalIdentity(tx, params.tenantRef, params.externalRef);
|
|
3978
4309
|
const existing = await tx `
|
|
3979
4310
|
SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
|
|
3980
|
-
buffer_after_ms, revision, source, contact_ref, notify, reminder_minutes
|
|
4311
|
+
buffer_after_ms, revision, source, contact_ref, notify, reminder_minutes, cutoff_anchor
|
|
3981
4312
|
FROM slotlock.reservations
|
|
3982
4313
|
WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
|
|
3983
4314
|
AND calendar_event_id IS NULL
|
|
@@ -4011,6 +4342,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4011
4342
|
found.ends_at.getTime() !== params.end.getTime() ||
|
|
4012
4343
|
found.resource_id !== params.resourceId;
|
|
4013
4344
|
let overrideNote = null;
|
|
4345
|
+
let cutoffAnchor = found.cutoff_anchor;
|
|
4014
4346
|
if (moved) {
|
|
4015
4347
|
const judged = await rulesNow(tx, found.resource_id);
|
|
4016
4348
|
if (cutoffRuleBroken(judged.rules, 'reschedule', found.starts_at, judged.now)) {
|
|
@@ -4019,6 +4351,10 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4019
4351
|
}
|
|
4020
4352
|
overrideNote = params.overrideReason;
|
|
4021
4353
|
}
|
|
4354
|
+
// Moved when cancelling would already be late: it stays as late as it was.
|
|
4355
|
+
const from = cancellationStart(found.starts_at, cutoffAnchor);
|
|
4356
|
+
if (cancellationLate(judged.rules, from, judged.now))
|
|
4357
|
+
cutoffAnchor = from;
|
|
4022
4358
|
}
|
|
4023
4359
|
const resourceState = await tenantResourceState(tx, params.resourceId, params.tenantRef);
|
|
4024
4360
|
if (resourceState === 'missing')
|
|
@@ -4072,7 +4408,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4072
4408
|
checked_in_at = CASE
|
|
4073
4409
|
WHEN starts_at = ${params.start} AND resource_id = ${params.resourceId}
|
|
4074
4410
|
THEN checked_in_at
|
|
4075
|
-
END
|
|
4411
|
+
END,
|
|
4412
|
+
cutoff_anchor = ${cutoffAnchor ?? null}::timestamptz
|
|
4076
4413
|
WHERE id = ${found.id}
|
|
4077
4414
|
RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
|
|
4078
4415
|
buffer_after_ms, revision, source, contact_ref, notify, reminder_minutes`;
|
|
@@ -4125,7 +4462,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4125
4462
|
: { ok: false, code: 'reservation_cancelled' };
|
|
4126
4463
|
}
|
|
4127
4464
|
const existing = await tx `
|
|
4128
|
-
SELECT id, revision, resource_id, starts_at FROM slotlock.reservations
|
|
4465
|
+
SELECT id, revision, resource_id, starts_at, cutoff_anchor FROM slotlock.reservations
|
|
4129
4466
|
WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
|
|
4130
4467
|
AND calendar_event_id IS NULL
|
|
4131
4468
|
FOR UPDATE`;
|
|
@@ -4142,13 +4479,15 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4142
4479
|
return { ok: false, code: 'revision_conflict' };
|
|
4143
4480
|
}
|
|
4144
4481
|
const judged = await rulesNow(tx, found.resource_id);
|
|
4145
|
-
|
|
4482
|
+
// From the start it had when a late move kept it, if one did.
|
|
4483
|
+
const start = cancellationStart(found.starts_at, found.cutoff_anchor);
|
|
4484
|
+
if (cutoffRuleBroken(judged.rules, 'cancel', start, judged.now)) {
|
|
4146
4485
|
if (params.overrideReason === undefined) {
|
|
4147
4486
|
return { ok: false, code: 'booking_rule_violated', rule: 'cancel_cutoff' };
|
|
4148
4487
|
}
|
|
4149
4488
|
overrideNote = params.overrideReason;
|
|
4150
4489
|
}
|
|
4151
|
-
fee = cancellationFeeDue(judged.rules,
|
|
4490
|
+
fee = cancellationFeeDue(judged.rules, start, judged.now);
|
|
4152
4491
|
}
|
|
4153
4492
|
if (!found) {
|
|
4154
4493
|
// This reference is a calendar event's occupation, not a booking: nothing to cancel or
|
|
@@ -4296,6 +4635,24 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4296
4635
|
return { ok: false, code: 'resource_archived' };
|
|
4297
4636
|
if (holdLimit !== null)
|
|
4298
4637
|
return { ok: false, code: 'hold_limit_reached', limit: holdLimit };
|
|
4638
|
+
// An agent's hold takes the tenant's price, never its own (RFC 0004 C3).
|
|
4639
|
+
let holdPaymentRequired = paymentRequired;
|
|
4640
|
+
let holdPayment = params.payment;
|
|
4641
|
+
let holdTtlSeconds = ttlSeconds;
|
|
4642
|
+
// The resource's rules, read once: its price here, the rest judged below.
|
|
4643
|
+
const ruling = await bookingRuling(tx, params.resourceId, params, defaultHours);
|
|
4644
|
+
if (params.priceFromRules === true) {
|
|
4645
|
+
const price = ruling.holdPrice;
|
|
4646
|
+
if (params.payment !== undefined &&
|
|
4647
|
+
(!price || price.amountMinor !== params.payment.amountMinor || price.currency !== params.payment.currency)) {
|
|
4648
|
+
return { ok: false, code: 'payment_price_mismatch' };
|
|
4649
|
+
}
|
|
4650
|
+
if (price) {
|
|
4651
|
+
holdPaymentRequired = true;
|
|
4652
|
+
holdPayment = { amountMinor: price.amountMinor, currency: price.currency };
|
|
4653
|
+
holdTtlSeconds = Math.min(ttlSeconds, SLOTLOCK_PAYMENT_HOLD_MAX_TTL_MS / 1000);
|
|
4654
|
+
}
|
|
4655
|
+
}
|
|
4299
4656
|
let heldSeconds = null;
|
|
4300
4657
|
if (holderRef !== undefined) {
|
|
4301
4658
|
// Counted under a lock on the holder, so racing holds stop at the cap: an agent
|
|
@@ -4305,23 +4662,13 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4305
4662
|
tenantRef,
|
|
4306
4663
|
holderRef,
|
|
4307
4664
|
])}, 0))`;
|
|
4308
|
-
const
|
|
4309
|
-
|
|
4310
|
-
COALESCE(sum(extract(epoch FROM
|
|
4311
|
-
ends_at + (buffer_after_ms + buffer_before_ms) * interval '1 millisecond'
|
|
4312
|
-
- starts_at)), 0)::float8
|
|
4313
|
-
AS seconds
|
|
4314
|
-
FROM slotlock.reservations
|
|
4315
|
-
WHERE holder_ref = ${holderRef} AND tenant_ref IS NULL AND status = 'held'
|
|
4316
|
-
AND expires_at > statement_timestamp()
|
|
4317
|
-
AND resource_id IN (SELECT id FROM slotlock.resources WHERE tenant_ref = ${tenantRef ?? null})`;
|
|
4318
|
-
if ((held?.count ?? 0) >= holdCap) {
|
|
4665
|
+
const held = await holderHeld(tx, tenantRef ?? null, holderRef);
|
|
4666
|
+
if (held.count >= holdCap) {
|
|
4319
4667
|
return { ok: false, code: 'hold_limit_reached', limit: holdCap };
|
|
4320
4668
|
}
|
|
4321
4669
|
if (heldHoursCap !== undefined)
|
|
4322
|
-
heldSeconds = held
|
|
4670
|
+
heldSeconds = held.seconds;
|
|
4323
4671
|
}
|
|
4324
|
-
const ruling = await bookingRuling(tx, params.resourceId, params, defaultHours);
|
|
4325
4672
|
if (ruling.rule)
|
|
4326
4673
|
return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
|
|
4327
4674
|
written = Math.max(bufferAfterMs, ruling.turnaroundMs);
|
|
@@ -4358,9 +4705,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4358
4705
|
payment_amount_minor, payment_currency
|
|
4359
4706
|
) VALUES (
|
|
4360
4707
|
${params.resourceId}, ${params.start}, ${params.end}, ${written}, ${writtenBefore}, 'held',
|
|
4361
|
-
statement_timestamp() + make_interval(secs => ${
|
|
4362
|
-
${holderRef ?? null}, ${params.groupId ?? null}, ${
|
|
4363
|
-
${
|
|
4708
|
+
statement_timestamp() + make_interval(secs => ${holdTtlSeconds}), ${params.source ?? null},
|
|
4709
|
+
${holderRef ?? null}, ${params.groupId ?? null}, ${holdPaymentRequired},
|
|
4710
|
+
${holdPayment?.amountMinor ?? null}, ${holdPayment?.currency ?? null}
|
|
4364
4711
|
)
|
|
4365
4712
|
RETURNING id, resource_id, starts_at, ends_at, buffer_after_ms, expires_at, source,
|
|
4366
4713
|
hold_group_id, ${tx.unsafe(HOLD_PAYMENT_COLUMNS)}`;
|
|
@@ -4494,6 +4841,382 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4494
4841
|
return { deleted: deleted.length > 0, bookings: bookings.length };
|
|
4495
4842
|
}, 'read committed');
|
|
4496
4843
|
},
|
|
4844
|
+
async exportSubject(params) {
|
|
4845
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
4846
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
4847
|
+
}
|
|
4848
|
+
const key = subjectKey(params.subject);
|
|
4849
|
+
if (!key)
|
|
4850
|
+
throw invalidSubjectError();
|
|
4851
|
+
const limit = params.limit ?? 1_000;
|
|
4852
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10_000) {
|
|
4853
|
+
throw Object.assign(new RangeError('Slotlock subject export limit must be 1-10000'), { code: 'invalid_limit' });
|
|
4854
|
+
}
|
|
4855
|
+
const tenantRef = params.tenantRef;
|
|
4856
|
+
const value = key.value;
|
|
4857
|
+
const take = limit + 1;
|
|
4858
|
+
return inTransaction(sql, async (tx) => {
|
|
4859
|
+
// The contacts an email or a contact reference names; the bookings, changes and approvals
|
|
4860
|
+
// that name those contacts are theirs too.
|
|
4861
|
+
const contactRefs = key.kind === 'email'
|
|
4862
|
+
? (await tx `
|
|
4863
|
+
SELECT ref FROM slotlock.contacts WHERE tenant_ref = ${tenantRef} AND lower(email) = ${value}`).map((row) => row.ref)
|
|
4864
|
+
: key.kind === 'contact'
|
|
4865
|
+
? [value]
|
|
4866
|
+
: [];
|
|
4867
|
+
const records = Object.fromEntries(SLOTLOCK_SUBJECT_TABLES.map((table) => [table, []]));
|
|
4868
|
+
const read = async (table, rows) => {
|
|
4869
|
+
records[table] = (await rows).map((entry) => entry.row);
|
|
4870
|
+
};
|
|
4871
|
+
if (contactRefs.length > 0) {
|
|
4872
|
+
await read('contacts', tx `
|
|
4873
|
+
SELECT to_jsonb(contact) - 'tenant_ref' AS row FROM slotlock.contacts contact
|
|
4874
|
+
WHERE contact.tenant_ref = ${tenantRef} AND contact.ref = ANY(${contactRefs}::text[])
|
|
4875
|
+
ORDER BY contact.ref LIMIT ${take}`);
|
|
4876
|
+
await read('reservations', tx `
|
|
4877
|
+
SELECT to_jsonb(booking) - 'tenant_ref' AS row FROM slotlock.reservations booking
|
|
4878
|
+
WHERE booking.tenant_ref = ${tenantRef} AND booking.contact_ref = ANY(${contactRefs}::text[])
|
|
4879
|
+
ORDER BY booking.created_at, booking.id LIMIT ${take}`);
|
|
4880
|
+
await read('outbox', tx `
|
|
4881
|
+
SELECT jsonb_build_object('id', change.id::text, 'type', change.type, 'subject', change.subject,
|
|
4882
|
+
'data', change.data, 'occurred_at', change.occurred_at) AS row
|
|
4883
|
+
FROM slotlock.outbox change
|
|
4884
|
+
WHERE change.tenant_ref = ${tenantRef} AND change.data ->> 'contact_ref' = ANY(${contactRefs}::text[])
|
|
4885
|
+
ORDER BY change.id LIMIT ${take}`);
|
|
4886
|
+
}
|
|
4887
|
+
if (key.kind === 'email') {
|
|
4888
|
+
await read('calendar_event_attendees', tx `
|
|
4889
|
+
SELECT to_jsonb(attendee) - 'tenant_ref' AS row FROM slotlock.calendar_event_attendees attendee
|
|
4890
|
+
WHERE attendee.tenant_ref = ${tenantRef} AND lower(attendee.email) = ${value}
|
|
4891
|
+
ORDER BY attendee.event_id, attendee.ordinal LIMIT ${take}`);
|
|
4892
|
+
}
|
|
4893
|
+
if (key.kind === 'email' || key.kind === 'reference') {
|
|
4894
|
+
// An event's time and title, and its organizer only when that is them: never the others
|
|
4895
|
+
// it names.
|
|
4896
|
+
await read('calendar_events', tx `
|
|
4897
|
+
SELECT jsonb_strip_nulls(jsonb_build_object(
|
|
4898
|
+
'id', event.id, 'external_ref', event.external_ref, 'resource_id', event.resource_id,
|
|
4899
|
+
'starts_at', event.starts_at, 'ends_at', event.ends_at, 'timezone', event.timezone,
|
|
4900
|
+
'summary', event.summary, 'status', event.status,
|
|
4901
|
+
'recurring', event.recurrence_rule IS NOT NULL, 'created_at', event.created_at,
|
|
4902
|
+
'organizer', CASE WHEN lower(event.organizer ->> 'email') = ${value} THEN event.organizer END)) AS row
|
|
4903
|
+
FROM slotlock.calendar_events event
|
|
4904
|
+
WHERE event.tenant_ref = ${tenantRef}
|
|
4905
|
+
AND ${key.kind === 'email'
|
|
4906
|
+
? tx `(lower(event.organizer ->> 'email') = ${value}
|
|
4907
|
+
OR EXISTS (SELECT 1 FROM jsonb_array_elements(event.attendees) AS attendee(value)
|
|
4908
|
+
WHERE lower(attendee.value ->> 'email') = ${value}))`
|
|
4909
|
+
: tx `event.external_ref = ${value}`}
|
|
4910
|
+
ORDER BY event.created_at, event.id LIMIT ${take}`);
|
|
4911
|
+
}
|
|
4912
|
+
if (key.kind === 'holder') {
|
|
4913
|
+
// A hold may keep no tenant of its own: its resource's is its.
|
|
4914
|
+
await read('reservations', tx `
|
|
4915
|
+
SELECT to_jsonb(booking) - 'tenant_ref' AS row FROM slotlock.reservations booking
|
|
4916
|
+
WHERE booking.holder_ref = ${value}
|
|
4917
|
+
AND (booking.tenant_ref = ${tenantRef}
|
|
4918
|
+
OR (booking.tenant_ref IS NULL AND booking.resource_id IN (
|
|
4919
|
+
SELECT resource.id FROM slotlock.resources resource WHERE resource.tenant_ref = ${tenantRef})))
|
|
4920
|
+
ORDER BY booking.created_at, booking.id LIMIT ${take}`);
|
|
4921
|
+
await read('waitlist_entries', tx `
|
|
4922
|
+
SELECT to_jsonb(entry) - 'tenant_ref' AS row FROM slotlock.waitlist_entries entry
|
|
4923
|
+
WHERE entry.tenant_ref = ${tenantRef} AND entry.holder_ref = ${value}
|
|
4924
|
+
ORDER BY entry.created_at, entry.id LIMIT ${take}`);
|
|
4925
|
+
await read('outbox', tx `
|
|
4926
|
+
SELECT jsonb_build_object('id', change.id::text, 'type', change.type, 'subject', change.subject,
|
|
4927
|
+
'data', change.data, 'occurred_at', change.occurred_at) AS row
|
|
4928
|
+
FROM slotlock.outbox change
|
|
4929
|
+
WHERE change.tenant_ref = ${tenantRef} AND change.data ->> 'holder_ref' = ${value}
|
|
4930
|
+
ORDER BY change.id LIMIT ${take}`);
|
|
4931
|
+
}
|
|
4932
|
+
if (key.kind === 'reference') {
|
|
4933
|
+
await read('reservations', tx `
|
|
4934
|
+
SELECT to_jsonb(booking) - 'tenant_ref' AS row FROM slotlock.reservations booking
|
|
4935
|
+
WHERE booking.tenant_ref = ${tenantRef} AND booking.external_ref = ${value}
|
|
4936
|
+
AND booking.calendar_event_id IS NULL
|
|
4937
|
+
ORDER BY booking.created_at, booking.id LIMIT ${take}`);
|
|
4938
|
+
for (const table of [
|
|
4939
|
+
'reservation_tombstones',
|
|
4940
|
+
'reservation_conflict_archive',
|
|
4941
|
+
'calendar_event_tombstones',
|
|
4942
|
+
'calendar_event_commands',
|
|
4943
|
+
]) {
|
|
4944
|
+
await read(table, tx `
|
|
4945
|
+
SELECT to_jsonb(kept) - 'tenant_ref' AS row FROM ${tx(`slotlock.${table}`)} kept
|
|
4946
|
+
WHERE kept.tenant_ref = ${tenantRef} AND kept.external_ref = ${value}
|
|
4947
|
+
LIMIT ${take}`);
|
|
4948
|
+
}
|
|
4949
|
+
await read('outbox', tx `
|
|
4950
|
+
SELECT jsonb_build_object('id', change.id::text, 'type', change.type, 'subject', change.subject,
|
|
4951
|
+
'data', change.data, 'occurred_at', change.occurred_at) AS row
|
|
4952
|
+
FROM slotlock.outbox change
|
|
4953
|
+
WHERE change.tenant_ref = ${tenantRef} AND change.data ->> 'reference' = ${value}
|
|
4954
|
+
ORDER BY change.id LIMIT ${take}`);
|
|
4955
|
+
}
|
|
4956
|
+
// An approval's decision, never its request: that may name others.
|
|
4957
|
+
await read('approvals', tx `
|
|
4958
|
+
SELECT jsonb_strip_nulls(jsonb_build_object(
|
|
4959
|
+
'id', approval.id, 'policy_ref', approval.policy_ref, 'status', approval.status,
|
|
4960
|
+
'operation', approval.operation, 'starts_at', approval.starts_at,
|
|
4961
|
+
'ends_at', approval.ends_at, 'expires_at', approval.expires_at,
|
|
4962
|
+
'decided_at', approval.decided_at, 'created_at', approval.created_at)) AS row
|
|
4963
|
+
FROM slotlock.approvals approval
|
|
4964
|
+
WHERE approval.tenant_ref = ${tenantRef} AND ${subjectApprovalSql(tx, key, contactRefs)}
|
|
4965
|
+
ORDER BY approval.created_at, approval.id LIMIT ${take}`);
|
|
4966
|
+
let truncated = false;
|
|
4967
|
+
for (const table of SLOTLOCK_SUBJECT_TABLES) {
|
|
4968
|
+
if (records[table].length > limit) {
|
|
4969
|
+
records[table] = records[table].slice(0, limit);
|
|
4970
|
+
truncated = true;
|
|
4971
|
+
}
|
|
4972
|
+
}
|
|
4973
|
+
return { records, truncated };
|
|
4974
|
+
}, 'read committed');
|
|
4975
|
+
},
|
|
4976
|
+
async eraseSubject(params) {
|
|
4977
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
4978
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
4979
|
+
}
|
|
4980
|
+
const key = subjectKey(params.subject);
|
|
4981
|
+
if (!key)
|
|
4982
|
+
throw invalidSubjectError();
|
|
4983
|
+
const tenantRef = params.tenantRef;
|
|
4984
|
+
const value = key.value;
|
|
4985
|
+
// One name for what the erasure reached, so what was one holder or booking still reads as one.
|
|
4986
|
+
const replacement = `erased-${randomBytes(16).toString('hex')}`;
|
|
4987
|
+
return inTransaction(sql, async (tx) => {
|
|
4988
|
+
const erased = emptySubjectCounts();
|
|
4989
|
+
const count = async (table, rows) => {
|
|
4990
|
+
erased[table] += (await rows).length;
|
|
4991
|
+
};
|
|
4992
|
+
const changes = async (kind, name) => {
|
|
4993
|
+
const [row] = await tx `
|
|
4994
|
+
SELECT slotlock.erase_subject_changes(${tenantRef}::text, ${kind}::text, ${name}::text,
|
|
4995
|
+
${kind === 'contact' ? null : replacement}::text) AS changed`;
|
|
4996
|
+
erased.outbox += Number(row?.changed ?? 0);
|
|
4997
|
+
};
|
|
4998
|
+
const contactRefs = key.kind === 'email'
|
|
4999
|
+
? (await tx `
|
|
5000
|
+
SELECT ref FROM slotlock.contacts WHERE tenant_ref = ${tenantRef} AND lower(email) = ${value}
|
|
5001
|
+
ORDER BY ref`).map((row) => row.ref)
|
|
5002
|
+
: key.kind === 'contact'
|
|
5003
|
+
? [value]
|
|
5004
|
+
: [];
|
|
5005
|
+
// As deleteContact: a contact written meanwhile waits, then finds itself gone or kept.
|
|
5006
|
+
for (const ref of contactRefs)
|
|
5007
|
+
await lockExternalIdentity(tx, tenantRef, `contact:${ref}`);
|
|
5008
|
+
if (key.kind === 'reference')
|
|
5009
|
+
await lockExternalIdentity(tx, tenantRef, value);
|
|
5010
|
+
// Approvals first, while their requests still match what is being erased.
|
|
5011
|
+
await count('approvals', tx `
|
|
5012
|
+
UPDATE slotlock.approvals approval
|
|
5013
|
+
SET request = ${subjectScrubbedRequestSql(tx, key, contactRefs, replacement)}
|
|
5014
|
+
WHERE approval.tenant_ref = ${tenantRef} AND ${subjectApprovalSql(tx, key, contactRefs)}
|
|
5015
|
+
RETURNING 1`);
|
|
5016
|
+
if (contactRefs.length > 0) {
|
|
5017
|
+
await count('contacts', tx `
|
|
5018
|
+
DELETE FROM slotlock.contacts
|
|
5019
|
+
WHERE tenant_ref = ${tenantRef} AND ref = ANY(${contactRefs}::text[])
|
|
5020
|
+
RETURNING 1`);
|
|
5021
|
+
// The bookings stay; they no longer name whom to tell.
|
|
5022
|
+
await count('reservations', tx `
|
|
5023
|
+
UPDATE slotlock.reservations SET contact_ref = NULL
|
|
5024
|
+
WHERE tenant_ref = ${tenantRef} AND contact_ref = ANY(${contactRefs}::text[])
|
|
5025
|
+
RETURNING 1`);
|
|
5026
|
+
for (const ref of contactRefs)
|
|
5027
|
+
await changes('contact', ref);
|
|
5028
|
+
}
|
|
5029
|
+
if (key.kind === 'email') {
|
|
5030
|
+
await count('calendar_event_attendees', tx `
|
|
5031
|
+
DELETE FROM slotlock.calendar_event_attendees
|
|
5032
|
+
WHERE tenant_ref = ${tenantRef} AND lower(email) = ${value}
|
|
5033
|
+
RETURNING 1`);
|
|
5034
|
+
// The event keeps its revision: nothing it books changed, and no one is told of it again.
|
|
5035
|
+
await count('calendar_events', tx `
|
|
5036
|
+
UPDATE slotlock.calendar_events event
|
|
5037
|
+
SET attendees = coalesce((
|
|
5038
|
+
SELECT jsonb_agg(attendee.value ORDER BY attendee.ordinality)
|
|
5039
|
+
FROM jsonb_array_elements(event.attendees) WITH ORDINALITY AS attendee(value, ordinality)
|
|
5040
|
+
WHERE lower(attendee.value ->> 'email') IS DISTINCT FROM ${value}), '[]'::jsonb),
|
|
5041
|
+
organizer = CASE WHEN lower(event.organizer ->> 'email') = ${value} THEN NULL ELSE event.organizer END
|
|
5042
|
+
WHERE event.tenant_ref = ${tenantRef}
|
|
5043
|
+
AND (lower(event.organizer ->> 'email') = ${value}
|
|
5044
|
+
OR EXISTS (SELECT 1 FROM jsonb_array_elements(event.attendees) AS attendee(value)
|
|
5045
|
+
WHERE lower(attendee.value ->> 'email') = ${value}))
|
|
5046
|
+
RETURNING 1`);
|
|
5047
|
+
}
|
|
5048
|
+
if (key.kind === 'holder') {
|
|
5049
|
+
await count('waitlist_entries', tx `
|
|
5050
|
+
DELETE FROM slotlock.waitlist_entries
|
|
5051
|
+
WHERE tenant_ref = ${tenantRef} AND holder_ref = ${value}
|
|
5052
|
+
RETURNING 1`);
|
|
5053
|
+
// Holds and bookings stay as time taken, held by a name that is no one's.
|
|
5054
|
+
await count('reservations', tx `
|
|
5055
|
+
UPDATE slotlock.reservations booking SET holder_ref = ${replacement}
|
|
5056
|
+
WHERE booking.holder_ref = ${value}
|
|
5057
|
+
AND (booking.tenant_ref = ${tenantRef}
|
|
5058
|
+
OR (booking.tenant_ref IS NULL AND booking.resource_id IN (
|
|
5059
|
+
SELECT resource.id FROM slotlock.resources resource WHERE resource.tenant_ref = ${tenantRef})))
|
|
5060
|
+
RETURNING 1`);
|
|
5061
|
+
await changes('holder', value);
|
|
5062
|
+
}
|
|
5063
|
+
if (key.kind === 'reference') {
|
|
5064
|
+
// A booking keeps its time under a name that is no one's; so do its ended bookings and
|
|
5065
|
+
// events. The reference is free to book again.
|
|
5066
|
+
await count('reservations', tx `
|
|
5067
|
+
UPDATE slotlock.reservations SET external_ref = ${replacement}
|
|
5068
|
+
WHERE tenant_ref = ${tenantRef} AND external_ref = ${value} AND calendar_event_id IS NULL
|
|
5069
|
+
RETURNING 1`);
|
|
5070
|
+
for (const table of [
|
|
5071
|
+
'reservation_tombstones',
|
|
5072
|
+
'reservation_conflict_archive',
|
|
5073
|
+
'calendar_events',
|
|
5074
|
+
'calendar_event_tombstones',
|
|
5075
|
+
'calendar_event_commands',
|
|
5076
|
+
]) {
|
|
5077
|
+
await count(table, tx `
|
|
5078
|
+
UPDATE ${tx(`slotlock.${table}`)} SET external_ref = ${replacement}
|
|
5079
|
+
WHERE tenant_ref = ${tenantRef} AND external_ref = ${value}
|
|
5080
|
+
RETURNING 1`);
|
|
5081
|
+
}
|
|
5082
|
+
await changes('reference', value);
|
|
5083
|
+
}
|
|
5084
|
+
return { erased };
|
|
5085
|
+
}, 'read committed');
|
|
5086
|
+
},
|
|
5087
|
+
async createTenantExport(params) {
|
|
5088
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
5089
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
5090
|
+
}
|
|
5091
|
+
const tenantRef = params.tenantRef;
|
|
5092
|
+
return inTransaction(sql, async (tx) => {
|
|
5093
|
+
await lockExternalIdentity(tx, tenantRef, 'tenant-export');
|
|
5094
|
+
const [kept] = await tx `
|
|
5095
|
+
SELECT count(*) FILTER (WHERE status = 'pending')::int AS pending, count(*)::int AS kept
|
|
5096
|
+
FROM slotlock.tenant_exports
|
|
5097
|
+
WHERE tenant_ref = ${tenantRef} AND expires_at > statement_timestamp()`;
|
|
5098
|
+
if ((kept?.pending ?? 0) > 0)
|
|
5099
|
+
throw tenantExportRefusal('export_in_progress');
|
|
5100
|
+
if ((kept?.kept ?? 0) >= SLOTLOCK_MAX_TENANT_EXPORTS)
|
|
5101
|
+
throw tenantExportRefusal('export_limit_reached');
|
|
5102
|
+
const requestedBy = params.requestedBy === undefined ? null : cutBytes(params.requestedBy, 200) ?? null;
|
|
5103
|
+
const [row] = await tx `
|
|
5104
|
+
INSERT INTO slotlock.tenant_exports (tenant_ref, requested_by, expires_at)
|
|
5105
|
+
VALUES (${tenantRef}, ${requestedBy},
|
|
5106
|
+
statement_timestamp() + make_interval(hours => ${SLOTLOCK_TENANT_EXPORT_HOURS}))
|
|
5107
|
+
RETURNING *, row_count::text AS row_count, bytes::text AS bytes`;
|
|
5108
|
+
if (!row)
|
|
5109
|
+
throw new Error('slotlock: tenant export insert returned no row');
|
|
5110
|
+
return toTenantExport(row);
|
|
5111
|
+
}, 'read committed');
|
|
5112
|
+
},
|
|
5113
|
+
async buildTenantExport(params) {
|
|
5114
|
+
if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.exportId))
|
|
5115
|
+
return null;
|
|
5116
|
+
const tenantRef = params.tenantRef;
|
|
5117
|
+
const exportId = params.exportId;
|
|
5118
|
+
return inTransaction(sql, async (tx) => {
|
|
5119
|
+
// Held to the end: another server building it skips it rather than waiting.
|
|
5120
|
+
const [claimed] = await tx `
|
|
5121
|
+
SELECT id FROM slotlock.tenant_exports
|
|
5122
|
+
WHERE id = ${exportId} AND tenant_ref = ${tenantRef} AND status = 'pending'
|
|
5123
|
+
FOR UPDATE SKIP LOCKED`;
|
|
5124
|
+
if (!claimed)
|
|
5125
|
+
return null;
|
|
5126
|
+
let part = 0;
|
|
5127
|
+
let written = 0;
|
|
5128
|
+
const totals = await readTenantExport(tx, tenantRef, async (read) => {
|
|
5129
|
+
// Refused as soon as it is too large, so nothing past the limit is read or kept.
|
|
5130
|
+
written += read.bytes;
|
|
5131
|
+
if (written > SLOTLOCK_TENANT_EXPORT_MAX_BYTES)
|
|
5132
|
+
throw tenantExportRefusal('export_too_large');
|
|
5133
|
+
part += 1;
|
|
5134
|
+
await tx `
|
|
5135
|
+
INSERT INTO slotlock.tenant_export_parts (export_id, tenant_ref, part, table_name, row_count, content)
|
|
5136
|
+
VALUES (${exportId}, ${tenantRef}, ${part}, ${read.table}, ${read.lines.length}, ${read.lines.join('\n')})`;
|
|
5137
|
+
});
|
|
5138
|
+
const [row] = await tx `
|
|
5139
|
+
UPDATE slotlock.tenant_exports
|
|
5140
|
+
SET status = 'ready', finished_at = statement_timestamp(),
|
|
5141
|
+
expires_at = statement_timestamp() + make_interval(hours => ${SLOTLOCK_TENANT_EXPORT_HOURS}),
|
|
5142
|
+
parts = ${part}, row_count = ${totals.rows}, bytes = ${totals.bytes},
|
|
5143
|
+
tables = ${tx.json(totals.tables)}
|
|
5144
|
+
WHERE id = ${exportId} AND tenant_ref = ${tenantRef}
|
|
5145
|
+
RETURNING *, row_count::text AS row_count, bytes::text AS bytes`;
|
|
5146
|
+
return row ? toTenantExport(row) : null;
|
|
5147
|
+
}, 'read committed');
|
|
5148
|
+
},
|
|
5149
|
+
async failTenantExport(params) {
|
|
5150
|
+
if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.exportId))
|
|
5151
|
+
return;
|
|
5152
|
+
await sql `
|
|
5153
|
+
UPDATE slotlock.tenant_exports
|
|
5154
|
+
SET status = 'failed', finished_at = statement_timestamp(), error = ${cutBytes(params.error, 200) ?? 'export_failed'}
|
|
5155
|
+
WHERE id = ${params.exportId} AND tenant_ref = ${params.tenantRef} AND status = 'pending'`;
|
|
5156
|
+
},
|
|
5157
|
+
async getTenantExport(params) {
|
|
5158
|
+
if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.exportId))
|
|
5159
|
+
return null;
|
|
5160
|
+
const [row] = await sql `
|
|
5161
|
+
SELECT *, row_count::text AS row_count, bytes::text AS bytes FROM slotlock.tenant_exports
|
|
5162
|
+
WHERE id = ${params.exportId} AND tenant_ref = ${params.tenantRef}`;
|
|
5163
|
+
return row ? toTenantExport(row) : null;
|
|
5164
|
+
},
|
|
5165
|
+
async listTenantExports(params) {
|
|
5166
|
+
if (!isValidIdentity(params.tenantRef))
|
|
5167
|
+
return [];
|
|
5168
|
+
const rows = await sql `
|
|
5169
|
+
SELECT *, row_count::text AS row_count, bytes::text AS bytes FROM slotlock.tenant_exports
|
|
5170
|
+
WHERE tenant_ref = ${params.tenantRef}
|
|
5171
|
+
ORDER BY created_at DESC, id DESC`;
|
|
5172
|
+
return rows.map(toTenantExport);
|
|
5173
|
+
},
|
|
5174
|
+
async getTenantExportPart(params) {
|
|
5175
|
+
if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.exportId)) {
|
|
5176
|
+
return { ok: false, code: 'export_not_found' };
|
|
5177
|
+
}
|
|
5178
|
+
const [found] = await sql `
|
|
5179
|
+
SELECT made.status, part.table_name, part.content
|
|
5180
|
+
FROM slotlock.tenant_exports made
|
|
5181
|
+
LEFT JOIN slotlock.tenant_export_parts part
|
|
5182
|
+
ON part.export_id = made.id AND part.tenant_ref = made.tenant_ref AND part.part = ${params.part}
|
|
5183
|
+
WHERE made.id = ${params.exportId} AND made.tenant_ref = ${params.tenantRef}`;
|
|
5184
|
+
if (!found)
|
|
5185
|
+
return { ok: false, code: 'export_not_found' };
|
|
5186
|
+
if (found.status !== 'ready')
|
|
5187
|
+
return { ok: false, code: 'export_not_ready' };
|
|
5188
|
+
if (found.table_name === null || found.content === null)
|
|
5189
|
+
return { ok: false, code: 'export_part_not_found' };
|
|
5190
|
+
return { ok: true, table: found.table_name, lines: found.content.split('\n') };
|
|
5191
|
+
},
|
|
5192
|
+
async deleteTenantExport(params) {
|
|
5193
|
+
if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.exportId))
|
|
5194
|
+
return false;
|
|
5195
|
+
const deleted = await sql `
|
|
5196
|
+
DELETE FROM slotlock.tenant_exports WHERE id = ${params.exportId} AND tenant_ref = ${params.tenantRef}
|
|
5197
|
+
RETURNING 1`;
|
|
5198
|
+
return deleted.length > 0;
|
|
5199
|
+
},
|
|
5200
|
+
async pruneTenantExports(params) {
|
|
5201
|
+
if (!isValidIdentity(params.tenantRef))
|
|
5202
|
+
return { deleted: 0, pending: [] };
|
|
5203
|
+
const deleted = await sql `
|
|
5204
|
+
DELETE FROM slotlock.tenant_exports
|
|
5205
|
+
WHERE tenant_ref = ${params.tenantRef} AND expires_at <= statement_timestamp()
|
|
5206
|
+
RETURNING 1`;
|
|
5207
|
+
const pending = await sql `
|
|
5208
|
+
SELECT id FROM slotlock.tenant_exports
|
|
5209
|
+
WHERE tenant_ref = ${params.tenantRef} AND status = 'pending'
|
|
5210
|
+
AND created_at < statement_timestamp() - interval '2 minutes'
|
|
5211
|
+
ORDER BY created_at`;
|
|
5212
|
+
return { deleted: deleted.length, pending: pending.map((row) => row.id) };
|
|
5213
|
+
},
|
|
5214
|
+
async streamTenantExport(params) {
|
|
5215
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
5216
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
5217
|
+
}
|
|
5218
|
+
return inTransaction(sql, (tx) => readTenantExport(tx, params.tenantRef, ({ table, lines }) => params.onPart({ table, lines })), 'read committed');
|
|
5219
|
+
},
|
|
4497
5220
|
async markHoldPaid(params) {
|
|
4498
5221
|
if (!isValidIdentity(params.tenantRef))
|
|
4499
5222
|
return { ok: false, code: 'invalid_identity' };
|
|
@@ -4511,13 +5234,19 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4511
5234
|
const [recorded] = await tx `
|
|
4512
5235
|
SELECT hold_id AS id, resource_id, starts_at, ends_at, buffer_after_ms, expires_at,
|
|
4513
5236
|
NULL::text AS source, true AS payment_required, payment_ref, paid_at,
|
|
4514
|
-
payment_amount_minor, payment_currency, paid_amount_minor, paid_currency
|
|
5237
|
+
payment_amount_minor, payment_currency, paid_amount_minor, paid_currency,
|
|
5238
|
+
booked_at, approval_id
|
|
4515
5239
|
FROM slotlock.hold_payments
|
|
4516
5240
|
WHERE hold_id = ${params.holdId} AND tenant_ref = ${params.tenantRef}`;
|
|
4517
5241
|
if (recorded) {
|
|
4518
|
-
|
|
4519
|
-
|
|
4520
|
-
|
|
5242
|
+
if (recorded.payment_ref !== params.paymentRef) {
|
|
5243
|
+
// Named, so an adapter refunds only a payment it can tell is another.
|
|
5244
|
+
return { ok: false, code: 'payment_conflict', paymentRef: recorded.payment_ref };
|
|
5245
|
+
}
|
|
5246
|
+
// Its hold ended without a booking: the payment was never applied, and is refunded.
|
|
5247
|
+
if (await paidHoldEnded(tx, params.tenantRef, recorded))
|
|
5248
|
+
return { ok: false, code: 'hold_ended' };
|
|
5249
|
+
return { ok: true, hold: toHold(recorded), idempotent: true };
|
|
4521
5250
|
}
|
|
4522
5251
|
const [row] = await tx `
|
|
4523
5252
|
SELECT hold.id, hold.resource_id, hold.starts_at, hold.ends_at, hold.buffer_after_ms,
|
|
@@ -4537,7 +5266,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4537
5266
|
if (row.payment_ref !== null && row.payment_ref !== undefined) {
|
|
4538
5267
|
return row.payment_ref === params.paymentRef
|
|
4539
5268
|
? { ok: true, hold: toHold(row), idempotent: true }
|
|
4540
|
-
: { ok: false, code: 'payment_conflict' };
|
|
5269
|
+
: { ok: false, code: 'payment_conflict', paymentRef: row.payment_ref };
|
|
4541
5270
|
}
|
|
4542
5271
|
// Paid after it lapsed: the time may be someone else's by now. The integrator refunds.
|
|
4543
5272
|
if (!row.live)
|
|
@@ -4786,13 +5515,43 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4786
5515
|
if ((pending?.tenant ?? 0) >= SLOTLOCK_MAX_PENDING_APPROVALS) {
|
|
4787
5516
|
return { ok: false, code: 'approval_limit_reached', limit: SLOTLOCK_MAX_PENDING_APPROVALS };
|
|
4788
5517
|
}
|
|
5518
|
+
// The agent's caps, with its other pending approvals and without the hold that gives way
|
|
5519
|
+
// to this one; under its lock, taken before the resources' as a hold takes it.
|
|
5520
|
+
const holder = params.holder;
|
|
5521
|
+
if (holder && isValidIdentity(holder.ref)) {
|
|
5522
|
+
await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([
|
|
5523
|
+
'slotlock-holder',
|
|
5524
|
+
tenantRef,
|
|
5525
|
+
holder.ref,
|
|
5526
|
+
])}, 0))`;
|
|
5527
|
+
const consumed = params.consumeHold && isValidResourceId(params.consumeHold.holdId) && params.consumeHold.holderRef === holder.ref
|
|
5528
|
+
? params.consumeHold.holdId
|
|
5529
|
+
: undefined;
|
|
5530
|
+
const held = await holderHeld(tx, tenantRef, holder.ref, consumed);
|
|
5531
|
+
const holdCap = Math.min(maxHoldsPerHolder, holder.caps?.maxHolds ?? Number.POSITIVE_INFINITY);
|
|
5532
|
+
if (held.count + params.holds.length > holdCap) {
|
|
5533
|
+
return { ok: false, code: 'hold_limit_reached', limit: holdCap };
|
|
5534
|
+
}
|
|
5535
|
+
const heldHoursCap = holder.caps?.maxHeldHours !== undefined
|
|
5536
|
+
? Math.min(maxHeldHoursPerHolder ?? Number.POSITIVE_INFINITY, holder.caps.maxHeldHours)
|
|
5537
|
+
: maxHeldHoursPerHolder;
|
|
5538
|
+
const asking = params.holds.reduce((seconds, hold) => seconds + (hold.end.getTime() - hold.start.getTime()) / 1000, 0);
|
|
5539
|
+
if (heldHoursCap !== undefined && held.seconds + asking > heldHoursCap * 3_600) {
|
|
5540
|
+
return { ok: false, code: 'held_time_limit_reached', limit: heldHoursCap };
|
|
5541
|
+
}
|
|
5542
|
+
}
|
|
4789
5543
|
const id = randomUUID();
|
|
4790
5544
|
// The requester's own hold of the time gives way to the approval's.
|
|
4791
5545
|
if (params.consumeHold && isValidResourceId(params.consumeHold.holdId)) {
|
|
5546
|
+
// Taken over, not ended: its payment, if any, goes with the approval (booked if granted).
|
|
5547
|
+
await tx `SELECT set_config('slotlock.ending', 'approval', true)`;
|
|
4792
5548
|
await tx `
|
|
4793
5549
|
DELETE FROM slotlock.reservations
|
|
4794
5550
|
WHERE id = ${params.consumeHold.holdId} AND status = 'held'
|
|
4795
5551
|
AND holder_ref = ${params.consumeHold.holderRef}`;
|
|
5552
|
+
await tx `SELECT set_config('slotlock.ending', '', true)`;
|
|
5553
|
+
await tx `UPDATE slotlock.hold_payments SET approval_id = ${id}::uuid
|
|
5554
|
+
WHERE hold_id = ${params.consumeHold.holdId} AND tenant_ref = ${tenantRef}`;
|
|
4796
5555
|
}
|
|
4797
5556
|
const holds = [...params.holds].sort((left, right) => left.resourceId.localeCompare(right.resourceId) || left.start.getTime() - right.start.getTime());
|
|
4798
5557
|
// Locked as any booking write locks them, in id order, so a racing booking takes turns.
|
|
@@ -4819,7 +5578,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4819
5578
|
starts_at, ends_at, request_key, expires_at
|
|
4820
5579
|
) VALUES (
|
|
4821
5580
|
${id}, ${tenantRef}, ${params.policyRef}, ${params.operation}, ${tx.json(params.request)},
|
|
4822
|
-
${tx.json(params.requester
|
|
5581
|
+
${tx.json((holder ? { ...params.requester, holder_ref: holder.ref } : params.requester))},
|
|
5582
|
+
${held.map((hold) => hold.id)}::uuid[],
|
|
4823
5583
|
${[...new Set(holds.map((hold) => hold.resourceId))]}::uuid[], ${params.start}, ${params.end},
|
|
4824
5584
|
${params.requestKey}, ${params.expiresAt}
|
|
4825
5585
|
)
|
|
@@ -5027,6 +5787,11 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5027
5787
|
return { ok: false, code: 'payment_required' };
|
|
5028
5788
|
if (resource?.archived !== false)
|
|
5029
5789
|
return { ok: false, code: 'resource_archived' };
|
|
5790
|
+
// Busy time a calendar reported since the hold was made (RFC 0004 C1) is refused, as an
|
|
5791
|
+
// agent's confirmation of its hold refuses it: the source's time is not to be booked.
|
|
5792
|
+
if (await sourceBusyLoss(tx, row.resource_id, row.starts_at, new Date(row.ends_at.getTime() + Number(row.buffer_after_ms)))) {
|
|
5793
|
+
return { ok: false, code: 'overlap' };
|
|
5794
|
+
}
|
|
5030
5795
|
if (bookingTenant !== null) {
|
|
5031
5796
|
const [retired] = await tx `
|
|
5032
5797
|
SELECT revision FROM slotlock.reservation_tombstones
|
|
@@ -5054,6 +5819,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5054
5819
|
buffer_after_ms, revision, source, contact_ref, notify, reminder_minutes`;
|
|
5055
5820
|
if (!confirmed)
|
|
5056
5821
|
throw new Error('slotlock: hold confirmation returned no row');
|
|
5822
|
+
// Its payment, if any, is applied: never refunded as a hold that ended unbooked.
|
|
5823
|
+
await tx `UPDATE slotlock.hold_payments SET booked_at = statement_timestamp()
|
|
5824
|
+
WHERE hold_id = ${holdId} AND booked_at IS NULL`;
|
|
5057
5825
|
// Its linked alternatives (one group, one holder) are released with it, as booking one
|
|
5058
5826
|
// from an agent releases the rest.
|
|
5059
5827
|
if (row.hold_group_id !== null && row.holder_ref !== null) {
|
|
@@ -5257,7 +6025,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5257
6025
|
SELECT id, tenant_ref, owner_ref, external_ref, resource_id, starts_at, ends_at, timezone,
|
|
5258
6026
|
summary, description, location, status, transparency, organizer, attendees,
|
|
5259
6027
|
reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
|
|
5260
|
-
materialized_ends_at, recurrence_cursor, revision, source, created_at, updated_at
|
|
6028
|
+
materialized_ends_at, recurrence_cursor, revision, source, created_at, updated_at,
|
|
6029
|
+
cutoff_anchor
|
|
5261
6030
|
FROM slotlock.calendar_events
|
|
5262
6031
|
WHERE tenant_ref = ${params.tenantRef}
|
|
5263
6032
|
AND owner_ref = ${ownerRef}
|
|
@@ -5273,17 +6042,32 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5273
6042
|
if (params.expectedRevision !== currentRevision) {
|
|
5274
6043
|
return { ok: false, code: 'revision_conflict', currentRevision };
|
|
5275
6044
|
}
|
|
5276
|
-
// A one-off event
|
|
5277
|
-
//
|
|
5278
|
-
|
|
5279
|
-
|
|
5280
|
-
|
|
5281
|
-
|
|
6045
|
+
// A one-off event changed past its cutoffs (RFC 0004 B5) is refused to a writer held to
|
|
6046
|
+
// the booking rules: moved in time or to another resource, or made a series (whose
|
|
6047
|
+
// cancellation no cutoff holds), past its reschedule cutoff; made transparent, letting its
|
|
6048
|
+
// time go, past its cancel cutoff or once a cancellation fee is due, as a cancellation
|
|
6049
|
+
// would be (cancelling records the fee).
|
|
6050
|
+
let movedAnchor = null;
|
|
6051
|
+
if (found && params.enforceBookingRules === true && found.recurrence_rule === null) {
|
|
6052
|
+
const moved = found.starts_at.getTime() !== event.start.getTime() ||
|
|
5282
6053
|
found.ends_at.getTime() !== event.end.getTime() ||
|
|
5283
|
-
found.resource_id !== params.resourceId
|
|
5284
|
-
|
|
5285
|
-
|
|
5286
|
-
|
|
6054
|
+
found.resource_id !== params.resourceId ||
|
|
6055
|
+
(event.recurrence?.rrule ?? null) !== null;
|
|
6056
|
+
const letGo = found.transparency !== 'transparent' && (event.transparency ?? 'opaque') === 'transparent';
|
|
6057
|
+
if (moved || letGo) {
|
|
6058
|
+
const judged = await rulesNow(tx, found.resource_id);
|
|
6059
|
+
if (moved && cutoffRuleBroken(judged.rules, 'reschedule', found.starts_at, judged.now)) {
|
|
6060
|
+
return { ok: false, code: 'booking_rule_violated', rule: 'reschedule_cutoff' };
|
|
6061
|
+
}
|
|
6062
|
+
const from = cancellationStart(found.starts_at, found.cutoff_anchor);
|
|
6063
|
+
if (letGo &&
|
|
6064
|
+
(cutoffRuleBroken(judged.rules, 'cancel', from, judged.now) ||
|
|
6065
|
+
cancellationFeeDue(judged.rules, from, judged.now) !== null)) {
|
|
6066
|
+
return { ok: false, code: 'booking_rule_violated', rule: 'cancel_cutoff' };
|
|
6067
|
+
}
|
|
6068
|
+
// Moved when cancelling would already be late: it stays as late as it was.
|
|
6069
|
+
if (moved && cancellationLate(judged.rules, from, judged.now))
|
|
6070
|
+
movedAnchor = from;
|
|
5287
6071
|
}
|
|
5288
6072
|
}
|
|
5289
6073
|
const series = expanded ?? resumeStoredSeries(event, materializationWindow, found);
|
|
@@ -5319,6 +6103,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5319
6103
|
}
|
|
5320
6104
|
const eventId = found?.id ?? randomUUID();
|
|
5321
6105
|
const revision = currentRevision + 1;
|
|
6106
|
+
let heldPaid = false;
|
|
5322
6107
|
if (params.fromHold) {
|
|
5323
6108
|
const { holdId, holderRef } = params.fromHold;
|
|
5324
6109
|
if (!isValidResourceId(holdId) || !isValidIdentity(holderRef)) {
|
|
@@ -5327,12 +6112,14 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5327
6112
|
const [hold] = await tx `
|
|
5328
6113
|
SELECT resource_id, starts_at, ends_at, hold_group_id,
|
|
5329
6114
|
expires_at > statement_timestamp() AS live,
|
|
5330
|
-
(payment_required AND paid_at IS NULL) AS unpaid
|
|
6115
|
+
(payment_required AND paid_at IS NULL) AS unpaid,
|
|
6116
|
+
(payment_required AND paid_at IS NOT NULL) AS paid
|
|
5331
6117
|
FROM slotlock.reservations
|
|
5332
6118
|
WHERE id = ${holdId} AND status = 'held' AND holder_ref = ${holderRef}
|
|
5333
6119
|
FOR UPDATE`;
|
|
5334
6120
|
if (!hold)
|
|
5335
6121
|
return { ok: false, code: 'hold_not_found' };
|
|
6122
|
+
heldPaid = hold.paid;
|
|
5336
6123
|
// The holder's own hold that lapsed and is not yet reaped: named as the application
|
|
5337
6124
|
// API's confirm names it. Another holder's stays not found.
|
|
5338
6125
|
if (!hold.live)
|
|
@@ -5353,6 +6140,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5353
6140
|
await tx `SELECT set_config('slotlock.ending', 'confirmed', true)`;
|
|
5354
6141
|
await tx `DELETE FROM slotlock.reservations WHERE id = ${holdId} AND status = 'held' AND holder_ref = ${holderRef}`;
|
|
5355
6142
|
await tx `SELECT set_config('slotlock.ending', '', true)`;
|
|
6143
|
+
// Its payment, if any, is applied to this booking.
|
|
6144
|
+
await tx `UPDATE slotlock.hold_payments SET booked_at = statement_timestamp()
|
|
6145
|
+
WHERE hold_id = ${holdId} AND booked_at IS NULL`;
|
|
5356
6146
|
// A set held together (RFC 0004 B7) is booked member by member: its other holds stay.
|
|
5357
6147
|
await tx `
|
|
5358
6148
|
DELETE FROM slotlock.reservations
|
|
@@ -5364,8 +6154,16 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5364
6154
|
}
|
|
5365
6155
|
let turnaroundMs = 0;
|
|
5366
6156
|
let bufferBeforeMs = 0;
|
|
5367
|
-
|
|
5368
|
-
|
|
6157
|
+
// The resource's rules, read once, for its price and the rest alike.
|
|
6158
|
+
const judge = params.enforceBookingRules === true && (event.transparency ?? 'opaque') === 'opaque'
|
|
6159
|
+
? await bookingJudge(tx, params.resourceId, defaultHours)
|
|
6160
|
+
: null;
|
|
6161
|
+
// A resource with a hold price (RFC 0004 C3) is booked by an agent only from its own
|
|
6162
|
+
// hold, once paid: new time on it, or time moved onto it from another resource.
|
|
6163
|
+
if (judge && (!found || found.resource_id !== params.resourceId) && !heldPaid && judge.holdPrice !== undefined) {
|
|
6164
|
+
return { ok: false, code: 'payment_required' };
|
|
6165
|
+
}
|
|
6166
|
+
if (judge) {
|
|
5369
6167
|
for (const [index, occurrence] of occurrences.entries()) {
|
|
5370
6168
|
// A held time's notice and advance were judged when it was held.
|
|
5371
6169
|
const rule = judge.broken(occurrence, { first: index === 0 && !params.fromHold });
|
|
@@ -5418,6 +6216,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5418
6216
|
buffer_before_ms = ${bufferBeforeMs},
|
|
5419
6217
|
-- Whether anyone should be told (RFC 0004 P3): as sent, like the rest.
|
|
5420
6218
|
notify = ${params.notify !== false},
|
|
6219
|
+
-- Kept, or set by a late move (RFC 0004 B5).
|
|
6220
|
+
cutoff_anchor = COALESCE(${movedAnchor}::timestamptz, cutoff_anchor),
|
|
5421
6221
|
updated_at = now()
|
|
5422
6222
|
WHERE id = ${eventId}
|
|
5423
6223
|
AND tenant_ref = ${params.tenantRef}
|
|
@@ -5791,7 +6591,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5791
6591
|
};
|
|
5792
6592
|
}
|
|
5793
6593
|
const events = await tx `
|
|
5794
|
-
SELECT id, resource_id, revision, starts_at, recurrence_rule IS NOT NULL AS recurring
|
|
6594
|
+
SELECT id, resource_id, revision, starts_at, recurrence_rule IS NOT NULL AS recurring, cutoff_anchor
|
|
5795
6595
|
FROM slotlock.calendar_events
|
|
5796
6596
|
WHERE tenant_ref = ${params.tenantRef}
|
|
5797
6597
|
AND owner_ref = ${ownerRef}
|
|
@@ -5881,7 +6681,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5881
6681
|
return { ok: false, code: 'event_not_found' };
|
|
5882
6682
|
await lockCalendarEventIdentity(tx, params.tenantRef, identity.owner_ref, identity.external_ref);
|
|
5883
6683
|
const [found] = await tx `
|
|
5884
|
-
SELECT id, resource_id, revision, starts_at, recurrence_rule IS NOT NULL AS recurring
|
|
6684
|
+
SELECT id, resource_id, revision, starts_at, recurrence_rule IS NOT NULL AS recurring, cutoff_anchor
|
|
5885
6685
|
FROM slotlock.calendar_events
|
|
5886
6686
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.eventId}
|
|
5887
6687
|
FOR UPDATE`;
|
|
@@ -6267,6 +7067,18 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
6267
7067
|
)
|
|
6268
7068
|
SELECT count(*)::int AS deleted FROM deleted`;
|
|
6269
7069
|
const tombstonesDeleted = Number(tombstones?.deleted ?? 0);
|
|
7070
|
+
// Busy time a calendar reported (RFC 0004 C1) that ended a month ago keeps nothing out any
|
|
7071
|
+
// more: it goes, as the feed's changes do, so a person's schedule is not kept for good.
|
|
7072
|
+
const [busy] = await tx `
|
|
7073
|
+
WITH ended AS (
|
|
7074
|
+
SELECT ctid FROM slotlock.source_busy
|
|
7075
|
+
WHERE tenant_ref = ${params.tenantRef}
|
|
7076
|
+
AND ends_at < statement_timestamp() - make_interval(days => ${SOURCE_BUSY_RETENTION_DAYS})
|
|
7077
|
+
LIMIT ${limit}
|
|
7078
|
+
), deleted AS (
|
|
7079
|
+
DELETE FROM slotlock.source_busy busy USING ended WHERE busy.ctid = ended.ctid RETURNING 1
|
|
7080
|
+
)
|
|
7081
|
+
SELECT count(*)::int AS deleted FROM deleted`;
|
|
6270
7082
|
return {
|
|
6271
7083
|
expiredHoldsDeleted,
|
|
6272
7084
|
holdCommandsDeleted,
|
|
@@ -6274,7 +7086,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
6274
7086
|
hasMore: legacy.length === limit ||
|
|
6275
7087
|
expiredHoldsDeleted === limit ||
|
|
6276
7088
|
holdCommandsDeleted === limit ||
|
|
6277
|
-
tombstonesDeleted === limit
|
|
7089
|
+
tombstonesDeleted === limit ||
|
|
7090
|
+
Number(busy?.deleted ?? 0) === limit,
|
|
6278
7091
|
};
|
|
6279
7092
|
});
|
|
6280
7093
|
},
|
|
@@ -6493,7 +7306,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
6493
7306
|
reported.starts_at AS busy_start, reported.ends_at AS busy_end
|
|
6494
7307
|
FROM slotlock.reservations booking
|
|
6495
7308
|
JOIN reported ON booking.starts_at < reported.ends_at AND booking.ends_at > reported.starts_at
|
|
6496
|
-
WHERE booking.resource_id
|
|
7309
|
+
WHERE booking.resource_id IN (${compositeKin(tx, params.resourceId)})
|
|
6497
7310
|
AND booking.status = 'confirmed'
|
|
6498
7311
|
AND booking.starts_at < ${window.end} AND booking.ends_at > ${window.start}
|
|
6499
7312
|
AND NOT EXISTS (
|
|
@@ -6664,8 +7477,11 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
6664
7477
|
throw new RangeError('limit must be a whole number from 1 to 5000');
|
|
6665
7478
|
}
|
|
6666
7479
|
const rows = await sql `
|
|
6667
|
-
WITH
|
|
6668
|
-
|
|
7480
|
+
WITH candidates AS (
|
|
7481
|
+
-- Reminders due at the same time are said once (the first in the list).
|
|
7482
|
+
SELECT DISTINCT ON (occurrence.event_id, occurrence.recurrence_id,
|
|
7483
|
+
occurrence.starts_at - make_interval(mins => reminder.minutes_before_start))
|
|
7484
|
+
occurrence.event_id, occurrence.recurrence_id, reminder.ordinal, reminder.action,
|
|
6669
7485
|
reminder.minutes_before_start, occurrence.starts_at, occurrence.ends_at,
|
|
6670
7486
|
event.resource_id,
|
|
6671
7487
|
occurrence.starts_at - make_interval(mins => reminder.minutes_before_start) AS due_at
|
|
@@ -6681,21 +7497,21 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
6681
7497
|
<= statement_timestamp()
|
|
6682
7498
|
AND occurrence.starts_at - make_interval(mins => reminder.minutes_before_start)
|
|
6683
7499
|
> statement_timestamp() - interval '1 day'
|
|
6684
|
-
-- Said once per time it is due
|
|
7500
|
+
-- Said once per time it is due, whatever its place in the list: one added or
|
|
7501
|
+
-- reordered says nothing again; an occurrence moved since is reminded again.
|
|
6685
7502
|
AND NOT EXISTS (
|
|
6686
7503
|
SELECT 1 FROM slotlock.reminder_dispatches sent
|
|
6687
7504
|
WHERE sent.event_id = occurrence.event_id AND sent.recurrence_id = occurrence.recurrence_id
|
|
6688
|
-
AND sent.ordinal = reminder.ordinal
|
|
6689
7505
|
AND sent.due_at = occurrence.starts_at - make_interval(mins => reminder.minutes_before_start))
|
|
6690
|
-
ORDER BY
|
|
6691
|
-
|
|
7506
|
+
ORDER BY occurrence.event_id, occurrence.recurrence_id,
|
|
7507
|
+
occurrence.starts_at - make_interval(mins => reminder.minutes_before_start), reminder.ordinal
|
|
7508
|
+
), due AS (
|
|
7509
|
+
SELECT * FROM candidates ORDER BY due_at, event_id, recurrence_id LIMIT ${limit}
|
|
6692
7510
|
), dispatched AS (
|
|
6693
7511
|
INSERT INTO slotlock.reminder_dispatches (tenant_ref, event_id, recurrence_id, ordinal, due_at)
|
|
6694
7512
|
SELECT ${params.tenantRef}, event_id, recurrence_id, ordinal, due_at FROM due
|
|
6695
|
-
ON CONFLICT (event_id, recurrence_id,
|
|
6696
|
-
|
|
6697
|
-
WHERE slotlock.reminder_dispatches.due_at <> EXCLUDED.due_at
|
|
6698
|
-
RETURNING event_id, recurrence_id, ordinal
|
|
7513
|
+
ON CONFLICT (event_id, recurrence_id, due_at) DO NOTHING
|
|
7514
|
+
RETURNING event_id, recurrence_id, due_at
|
|
6699
7515
|
), reported AS (
|
|
6700
7516
|
INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
|
|
6701
7517
|
SELECT ${params.tenantRef}, due.resource_id, 'reminder.due', due.event_id::text,
|
|
@@ -6705,7 +7521,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
6705
7521
|
'minutes_before_start', due.minutes_before_start, 'due_at', due.due_at,
|
|
6706
7522
|
'start', due.starts_at, 'end', due.ends_at),
|
|
6707
7523
|
'slotlock:maintenance'
|
|
6708
|
-
FROM due JOIN dispatched USING (event_id, recurrence_id,
|
|
7524
|
+
FROM due JOIN dispatched USING (event_id, recurrence_id, due_at)
|
|
6709
7525
|
RETURNING 1
|
|
6710
7526
|
)
|
|
6711
7527
|
SELECT count(*)::int AS count FROM reported`;
|
|
@@ -6714,8 +7530,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
6714
7530
|
return events;
|
|
6715
7531
|
// A booking's reminders too, named by its reference, with whom to tell.
|
|
6716
7532
|
const [bookings] = await sql `
|
|
6717
|
-
WITH
|
|
6718
|
-
SELECT booking.id, booking.
|
|
7533
|
+
WITH candidates AS (
|
|
7534
|
+
SELECT DISTINCT ON (booking.id, booking.starts_at - make_interval(mins => reminder.minutes))
|
|
7535
|
+
booking.id, booking.external_ref, booking.resource_id, booking.contact_ref,
|
|
6719
7536
|
booking.starts_at, booking.ends_at, (reminder.ordinal - 1)::int AS ordinal,
|
|
6720
7537
|
reminder.minutes,
|
|
6721
7538
|
booking.starts_at - make_interval(mins => reminder.minutes) AS due_at
|
|
@@ -6727,19 +7544,19 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
6727
7544
|
AND booking.starts_at > statement_timestamp()
|
|
6728
7545
|
AND booking.starts_at - make_interval(mins => reminder.minutes) <= statement_timestamp()
|
|
6729
7546
|
AND booking.starts_at - make_interval(mins => reminder.minutes) > statement_timestamp() - interval '1 day'
|
|
7547
|
+
-- Said once per time it is due, as an event's reminders are.
|
|
6730
7548
|
AND NOT EXISTS (
|
|
6731
7549
|
SELECT 1 FROM slotlock.reservation_reminder_dispatches sent
|
|
6732
|
-
WHERE sent.reservation_id = booking.id
|
|
7550
|
+
WHERE sent.reservation_id = booking.id
|
|
6733
7551
|
AND sent.due_at = booking.starts_at - make_interval(mins => reminder.minutes))
|
|
6734
|
-
ORDER BY
|
|
6735
|
-
|
|
7552
|
+
ORDER BY booking.id, booking.starts_at - make_interval(mins => reminder.minutes), reminder.ordinal
|
|
7553
|
+
), due AS (
|
|
7554
|
+
SELECT * FROM candidates ORDER BY due_at, id LIMIT ${limit - events}
|
|
6736
7555
|
), dispatched AS (
|
|
6737
7556
|
INSERT INTO slotlock.reservation_reminder_dispatches (tenant_ref, reservation_id, ordinal, due_at)
|
|
6738
7557
|
SELECT ${params.tenantRef}, id, ordinal, due_at FROM due
|
|
6739
|
-
ON CONFLICT (reservation_id,
|
|
6740
|
-
|
|
6741
|
-
WHERE slotlock.reservation_reminder_dispatches.due_at <> EXCLUDED.due_at
|
|
6742
|
-
RETURNING reservation_id AS id, ordinal
|
|
7558
|
+
ON CONFLICT (reservation_id, due_at) DO NOTHING
|
|
7559
|
+
RETURNING reservation_id AS id, due_at
|
|
6743
7560
|
), reported AS (
|
|
6744
7561
|
INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
|
|
6745
7562
|
SELECT ${params.tenantRef}, due.resource_id, 'reminder.due', due.external_ref,
|
|
@@ -6749,7 +7566,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
6749
7566
|
'ordinal', due.ordinal, 'minutes_before_start', due.minutes, 'due_at', due.due_at,
|
|
6750
7567
|
'start', due.starts_at, 'end', due.ends_at)),
|
|
6751
7568
|
'slotlock:maintenance'
|
|
6752
|
-
FROM due JOIN dispatched USING (id,
|
|
7569
|
+
FROM due JOIN dispatched USING (id, due_at)
|
|
6753
7570
|
RETURNING 1
|
|
6754
7571
|
)
|
|
6755
7572
|
SELECT count(*)::int AS count FROM reported`;
|
|
@@ -7388,6 +8205,14 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
7388
8205
|
SELECT slotlock.sweep_assistant_conversations(${limit}::integer) AS deleted`;
|
|
7389
8206
|
return Number(row?.deleted ?? 0);
|
|
7390
8207
|
},
|
|
8208
|
+
async sweepDashboardRecords() {
|
|
8209
|
+
// Each its own statement: an erasure that runs long never takes the prunes with it.
|
|
8210
|
+
const [pruned] = await sql `
|
|
8211
|
+
SELECT slotlock.prune_workspace_invitations() + slotlock.prune_dashboard_sessions()
|
|
8212
|
+
+ slotlock.prune_workspace_domains() + slotlock.prune_sso() AS deleted`;
|
|
8213
|
+
const [erased] = await sql `SELECT slotlock.erase_deleted_teams() AS erased`;
|
|
8214
|
+
return Number(pruned?.deleted ?? 0) + Number(erased?.erased ?? 0);
|
|
8215
|
+
},
|
|
7391
8216
|
async deleteAssistantChat(params) {
|
|
7392
8217
|
if (!isValidIdentity(params.tenantRef) ||
|
|
7393
8218
|
!isValidIdentity(params.memberRef) ||
|
|
@@ -7400,6 +8225,93 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
7400
8225
|
AND chat_id = ${params.chatId} RETURNING 1`;
|
|
7401
8226
|
return rows.length > 0;
|
|
7402
8227
|
},
|
|
8228
|
+
async recordAuditEvent(params) {
|
|
8229
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
8230
|
+
throw invalidIdentityError('Slotlock tenant references must be 1-500 UTF-8 bytes');
|
|
8231
|
+
}
|
|
8232
|
+
const event = params.event;
|
|
8233
|
+
if (!AUDIT_ACTION.test(event.action) || !AUDIT_ACTOR_TYPES.has(event.actorType) || !isValidIdentity(event.actorRef)) {
|
|
8234
|
+
throw invalidIdentityError('Slotlock audit events need an action (area.verb), an actor type and an actor reference');
|
|
8235
|
+
}
|
|
8236
|
+
if (event.targetType !== undefined && !AUDIT_TARGET_TYPE.test(event.targetType)) {
|
|
8237
|
+
throw invalidIdentityError('Slotlock audit event target types are lowercase words');
|
|
8238
|
+
}
|
|
8239
|
+
if (event.actorRole !== undefined && event.actorRole !== null && !isSlotlockWorkspaceRole(event.actorRole)) {
|
|
8240
|
+
throw invalidIdentityError('Slotlock audit event roles are workspace roles');
|
|
8241
|
+
}
|
|
8242
|
+
await sql `
|
|
8243
|
+
INSERT INTO slotlock.audit_events
|
|
8244
|
+
(tenant_ref, action, actor_type, actor_ref, actor_role, target_type, target_ref, outcome,
|
|
8245
|
+
reason, ip_address, user_agent, metadata)
|
|
8246
|
+
VALUES (${params.tenantRef}, ${event.action}, ${event.actorType}, ${event.actorRef},
|
|
8247
|
+
${event.actorRole ?? null}, ${event.targetType ?? null}, ${cutBytes(event.targetRef, 500)},
|
|
8248
|
+
${event.outcome === 'denied' ? 'denied' : 'success'}, ${cutBytes(event.reason, 200)},
|
|
8249
|
+
${cutBytes(event.ipAddress, 64)}, ${cutBytes(event.userAgent, 256)},
|
|
8250
|
+
${sql.json(boundAuditMetadata(event.metadata ?? {}))})`;
|
|
8251
|
+
},
|
|
8252
|
+
async listAuditEvents(params) {
|
|
8253
|
+
if (!isValidIdentity(params.tenantRef))
|
|
8254
|
+
return [];
|
|
8255
|
+
const limit = Math.min(Math.max(Math.trunc(params.limit ?? 50), 1), 200);
|
|
8256
|
+
const before = params.before;
|
|
8257
|
+
const action = params.action !== undefined && AUDIT_ACTION.test(params.action) ? params.action : null;
|
|
8258
|
+
const actor = params.actorRef !== undefined && isValidIdentity(params.actorRef) ? params.actorRef : null;
|
|
8259
|
+
const beforeId = params.beforeId !== undefined && isValidResourceId(params.beforeId) ? params.beforeId : null;
|
|
8260
|
+
if (params.beforeId !== undefined && beforeId === null)
|
|
8261
|
+
return [];
|
|
8262
|
+
const rows = await sql `
|
|
8263
|
+
SELECT id, occurred_at, action, actor_type, actor_ref, actor_role, target_type, target_ref,
|
|
8264
|
+
outcome, reason, ip_address, user_agent, metadata, chain_index, hash
|
|
8265
|
+
FROM slotlock.audit_events
|
|
8266
|
+
WHERE tenant_ref = ${params.tenantRef}
|
|
8267
|
+
AND (${action}::text IS NULL OR action = ${action}::text)
|
|
8268
|
+
AND (${actor}::text IS NULL OR actor_ref = ${actor}::text)
|
|
8269
|
+
AND (${before?.occurredAt ?? null}::timestamptz IS NULL
|
|
8270
|
+
OR (occurred_at, id) < (${before?.occurredAt ?? null}::timestamptz, ${before?.id ?? null}::uuid))
|
|
8271
|
+
AND (${beforeId}::uuid IS NULL
|
|
8272
|
+
OR (occurred_at, id) < (SELECT kept.occurred_at, kept.id FROM slotlock.audit_events kept
|
|
8273
|
+
WHERE kept.tenant_ref = ${params.tenantRef} AND kept.id = ${beforeId}::uuid))
|
|
8274
|
+
ORDER BY occurred_at DESC, id DESC
|
|
8275
|
+
LIMIT ${limit}`;
|
|
8276
|
+
return rows.map((row) => ({
|
|
8277
|
+
id: row.id,
|
|
8278
|
+
occurredAt: row.occurred_at,
|
|
8279
|
+
action: row.action,
|
|
8280
|
+
actorType: row.actor_type,
|
|
8281
|
+
actorRef: row.actor_ref,
|
|
8282
|
+
actorRole: row.actor_role,
|
|
8283
|
+
...(row.target_type !== null ? { targetType: row.target_type } : {}),
|
|
8284
|
+
...(row.target_ref !== null ? { targetRef: row.target_ref } : {}),
|
|
8285
|
+
outcome: row.outcome,
|
|
8286
|
+
...(row.reason !== null ? { reason: row.reason } : {}),
|
|
8287
|
+
...(row.ip_address !== null ? { ipAddress: row.ip_address } : {}),
|
|
8288
|
+
...(row.user_agent !== null ? { userAgent: row.user_agent } : {}),
|
|
8289
|
+
chainIndex: row.chain_index === null ? null : Number(row.chain_index),
|
|
8290
|
+
hash: row.hash === null ? null : row.hash.toString('hex'),
|
|
8291
|
+
metadata: row.metadata,
|
|
8292
|
+
}));
|
|
8293
|
+
},
|
|
8294
|
+
async tenantPlan(params) {
|
|
8295
|
+
if (!isValidIdentity(params.tenantRef))
|
|
8296
|
+
return null;
|
|
8297
|
+
const [row] = await sql `
|
|
8298
|
+
SELECT plan, status FROM slotlock.tenant_plan(${params.tenantRef}::text) LIMIT 1`;
|
|
8299
|
+
return row ? { plan: row.plan, status: row.status } : null;
|
|
8300
|
+
},
|
|
8301
|
+
async pruneAuditEvents(params) {
|
|
8302
|
+
if (!isValidIdentity(params.tenantRef))
|
|
8303
|
+
return 0;
|
|
8304
|
+
const [row] = await sql `
|
|
8305
|
+
SELECT slotlock.prune_audit_events(${params.tenantRef}::text) AS pruned`;
|
|
8306
|
+
return Number(row?.pruned ?? 0);
|
|
8307
|
+
},
|
|
8308
|
+
async verifyAuditChain(params) {
|
|
8309
|
+
if (!isValidIdentity(params.tenantRef))
|
|
8310
|
+
return { checked: 0, brokenAt: null };
|
|
8311
|
+
const [row] = await sql `
|
|
8312
|
+
SELECT checked, broken_at FROM slotlock.verify_audit_chain(${params.tenantRef}::text)`;
|
|
8313
|
+
return { checked: Number(row?.checked ?? 0), brokenAt: row?.broken_at === null || row === undefined ? null : Number(row.broken_at) };
|
|
8314
|
+
},
|
|
7403
8315
|
async offerWaitlist(params) {
|
|
7404
8316
|
if (!isValidIdentity(params.tenantRef)) {
|
|
7405
8317
|
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|