slotlock 0.8.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.
Files changed (86) hide show
  1. package/CHANGELOG.md +542 -0
  2. package/NOTICE +2 -0
  3. package/README.md +4 -2
  4. package/agent-server.d.ts +25 -5
  5. package/agent-server.js +152 -7
  6. package/agent-store-backend.d.ts +6 -1
  7. package/agent-store-backend.js +1068 -68
  8. package/application-api.d.ts +28 -1
  9. package/application-api.js +789 -21
  10. package/approvals.d.ts +2 -0
  11. package/approvals.js +12 -5
  12. package/assistant.d.ts +34 -8
  13. package/assistant.js +81 -26
  14. package/availability-store.d.ts +193 -0
  15. package/availability-store.js +566 -0
  16. package/booking-rules.d.ts +103 -5
  17. package/booking-rules.js +263 -15
  18. package/config.d.ts +50 -0
  19. package/config.js +107 -0
  20. package/dashboard-assistant-ui.d.ts +1 -1
  21. package/dashboard-assistant-ui.js +109 -8
  22. package/dashboard-scope.d.ts +7 -0
  23. package/dashboard-scope.js +21 -0
  24. package/dashboard.d.ts +152 -8
  25. package/dashboard.js +3868 -399
  26. package/ddl-identity.d.ts +25 -0
  27. package/ddl-identity.js +881 -0
  28. package/ddl-scim.d.ts +5 -0
  29. package/ddl-scim.js +740 -0
  30. package/ddl-sso.d.ts +15 -0
  31. package/ddl-sso.js +1033 -0
  32. package/ddl.d.ts +44 -9
  33. package/ddl.js +2872 -47
  34. package/domains.d.ts +117 -0
  35. package/domains.js +340 -0
  36. package/event-feed.d.ts +2 -2
  37. package/event-feed.js +24 -34
  38. package/holiday-import.d.ts +38 -0
  39. package/holiday-import.js +56 -0
  40. package/holidays.d.ts +38 -0
  41. package/holidays.js +209 -0
  42. package/hours.d.ts +125 -0
  43. package/hours.js +224 -0
  44. package/index.d.ts +24 -5
  45. package/index.js +17 -3
  46. package/interop.d.ts +25 -7
  47. package/interop.js +145 -17
  48. package/invitation-email.d.ts +26 -0
  49. package/invitation-email.js +73 -0
  50. package/lifecycle.d.ts +31 -0
  51. package/lifecycle.js +115 -0
  52. package/maintenance.d.ts +26 -2
  53. package/maintenance.js +70 -2
  54. package/metrics.js +3 -0
  55. package/oidc.d.ts +135 -0
  56. package/oidc.js +452 -0
  57. package/openapi.json +20172 -9850
  58. package/package.json +2 -1
  59. package/ranges.d.ts +28 -0
  60. package/ranges.js +135 -0
  61. package/resource-search.d.ts +19 -0
  62. package/resource-search.js +153 -0
  63. package/rest-api.js +7 -0
  64. package/rules.d.ts +2 -0
  65. package/rules.js +2 -0
  66. package/scim-server.d.ts +19 -0
  67. package/scim-server.js +243 -0
  68. package/scim.d.ts +180 -0
  69. package/scim.js +416 -0
  70. package/self-host.d.ts +19 -6
  71. package/self-host.js +483 -28
  72. package/slotlock.manifest.json +1 -1
  73. package/store.d.ts +637 -23
  74. package/store.js +2529 -147
  75. package/types.d.ts +230 -0
  76. package/types.js +68 -1
  77. package/workspace-domains.d.ts +146 -0
  78. package/workspace-domains.js +249 -0
  79. package/workspace-roles.d.ts +47 -0
  80. package/workspace-roles.js +304 -0
  81. package/workspace-scim.d.ts +89 -0
  82. package/workspace-scim.js +157 -0
  83. package/workspace-sso.d.ts +131 -0
  84. package/workspace-sso.js +180 -0
  85. package/workspaces.d.ts +240 -0
  86. package/workspaces.js +433 -0
package/store.js CHANGED
@@ -1,16 +1,21 @@
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
- import { bookingBufferBeforeMs, bookingSearchBlocked, bookingSearchReach, parseBookingRuleDefaults, parseEffectiveBookingRules, bookingBufferAfterMs, bookingRuleBroken, bookingSearch, parseBookingRules, } from './booking-rules.js';
7
- import { createHash, randomUUID } from 'node:crypto';
7
+ import { cancellationFeeDue, cutoffRuleBroken, bookingBufferBeforeMs, bookingSearchBlocked, bookingSearchReach, parseBookingRuleDefaults, parseEffectiveBookingRules, bookingBufferAfterMs, bookingRuleBroken, bookingSearch, parseBookingRules, } from './booking-rules.js';
8
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
8
9
  import ICAL from 'ical.js';
9
- import { 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
+ 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
- import { SLOTLOCK_CALENDAR_HORIZON_DAYS, assertBoundedSearchWindow, expandRules, invalidWindowError, } from './rules.js';
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';
16
+ import { applicableHours, exceptionQueryDays } from './hours.js';
17
+ import { zonedDateTimeToUtc } from './timezone.js';
18
+ import { createAvailabilityException, deleteHolidayCalendar, dueHolidayCalendars, importHolidayCalendar, listHolidayCalendars, noteHolidayCalendarFailure, deleteAvailabilityException, getAvailabilityDefaults, listAvailabilityExceptions, rankedExceptions, replaceImportedExceptions, setLocationAvailability, storeTenantAvailability, storedInheritedAvailability, } from './availability-store.js';
14
19
  import { APPROVAL_COLUMNS, approvalByRequestKey, approvalPoliciesFor, beginApprovalGrant, declineApproval, deleteApprovalPolicy, expireApprovals, finishApprovalGrant, getApproval, listApprovalPolicies, listApprovals, putApprovalPolicy, recordApprovalChange, toApproval, } from './approvals.js';
15
20
  const EXCLUSION_VIOLATION = '23P01';
16
21
  /** Holds one approval may own: a bundle's resources, or a move's new time in pieces. */
@@ -31,7 +36,30 @@ const MAX_HOLD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
31
36
  const MAX_BUFFER_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
32
37
  /** A day, as bufferBeforeMinutes allows: a read by start can then still use the index. */
33
38
  const MAX_BUFFER_BEFORE_MS = 24 * 60 * 60 * 1000;
34
- export { SLOTLOCK_CALENDAR_HORIZON_DAYS };
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
+ }
62
+ export { SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_SOURCE_BUSY_MAX };
35
63
  /**
36
64
  * Longest one-off event (ten years and some leap days); a recurring series is bounded by its
37
65
  * materialization window instead.
@@ -690,6 +718,9 @@ function validateExternalCommand(params) {
690
718
  if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.externalRef)) {
691
719
  return { ok: false, code: 'invalid_identity' };
692
720
  }
721
+ if (params.overrideReason !== undefined && !isValidOverrideReason(params.overrideReason)) {
722
+ return { ok: false, code: 'invalid_identity' };
723
+ }
693
724
  if (!Number.isSafeInteger(params.bufferAfterMs) ||
694
725
  params.bufferAfterMs < 0 ||
695
726
  params.bufferAfterMs > MAX_BUFFER_AFTER_MS) {
@@ -698,8 +729,91 @@ function validateExternalCommand(params) {
698
729
  if (!Number.isSafeInteger(params.revision) || params.revision <= 0) {
699
730
  return { ok: false, code: 'invalid_revision' };
700
731
  }
732
+ if (params.contactRef !== undefined && !isValidContactRef(params.contactRef)) {
733
+ return { ok: false, code: 'invalid_contact_ref' };
734
+ }
735
+ if (params.notify !== undefined && typeof params.notify !== 'boolean') {
736
+ return { ok: false, code: 'invalid_notify' };
737
+ }
738
+ if (params.reminderMinutes !== undefined &&
739
+ (!Array.isArray(params.reminderMinutes) ||
740
+ params.reminderMinutes.length > 5 ||
741
+ new Set(params.reminderMinutes).size !== params.reminderMinutes.length ||
742
+ params.reminderMinutes.some((minutes) => !Number.isSafeInteger(minutes) || minutes < 0 || minutes > 527_040))) {
743
+ return { ok: false, code: 'invalid_reminders' };
744
+ }
701
745
  return null;
702
746
  }
747
+ /** A booking's reminders as stored: sorted, latest first; null for none. */
748
+ function reminderColumn(minutes) {
749
+ return minutes && minutes.length > 0 ? [...minutes].sort((a, b) => b - a) : null;
750
+ }
751
+ /** A recipient's reference (RFC 0004 P3): what the contacts table and a booking name a person by. */
752
+ const CONTACT_REF = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/;
753
+ function isValidContactRef(value) {
754
+ return typeof value === 'string' && CONTACT_REF.test(value);
755
+ }
756
+ /** The most contacts a tenant keeps (RFC 0004 P3). */
757
+ export const SLOTLOCK_MAX_CONTACTS_PER_TENANT = 100_000;
758
+ const E164 = /^\+[1-9][0-9]{6,14}$/;
759
+ const LOCALE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
760
+ const CONTACT_COLUMNS = 'ref, name, email, phone, whatsapp, consent, consent_at, locale, created_at, updated_at';
761
+ function toContact(row) {
762
+ const consent = {};
763
+ for (const channel of ['email', 'sms', 'whatsapp']) {
764
+ const answer = row.consent?.[channel];
765
+ if (typeof answer === 'boolean')
766
+ consent[channel] = answer;
767
+ }
768
+ const consentAt = {};
769
+ for (const channel of ['email', 'sms', 'whatsapp']) {
770
+ const at = row.consent_at?.[channel];
771
+ if (typeof at === 'string' && channel in consent)
772
+ consentAt[channel] = new Date(at);
773
+ }
774
+ return {
775
+ ref: row.ref,
776
+ name: row.name,
777
+ email: row.email,
778
+ phone: row.phone,
779
+ whatsapp: row.whatsapp,
780
+ consent,
781
+ consentAt,
782
+ locale: row.locale,
783
+ createdAt: row.created_at,
784
+ updatedAt: row.updated_at,
785
+ };
786
+ }
787
+ /** A contact's fields as the table's CHECK takes them, or null when one is not. */
788
+ function contactFields(params) {
789
+ const text = (value, valid) => {
790
+ if (value === undefined || value === null)
791
+ return null;
792
+ if (typeof value !== 'string' || !valid(value))
793
+ return undefined;
794
+ return value;
795
+ };
796
+ const name = text(params.name, (value) => value.length >= 1 && value.length <= 200 && !/[\u0000-\u001f\u007f]/.test(value));
797
+ const email = text(params.email, (value) => value.length >= 3 && value.length <= 320 && /^[^\s@]+@[^\s@]+$/.test(value));
798
+ const phone = text(params.phone, (value) => E164.test(value));
799
+ const whatsapp = text(params.whatsapp, (value) => E164.test(value));
800
+ const locale = text(params.locale, (value) => value.length <= 35 && LOCALE.test(value));
801
+ if (name === undefined || email === undefined || phone === undefined || whatsapp === undefined || locale === undefined) {
802
+ return null;
803
+ }
804
+ const consent = {};
805
+ if (params.consent !== undefined) {
806
+ if (typeof params.consent !== 'object' || params.consent === null || Array.isArray(params.consent))
807
+ return null;
808
+ for (const [channel, answer] of Object.entries(params.consent)) {
809
+ if ((channel !== 'email' && channel !== 'sms' && channel !== 'whatsapp') || typeof answer !== 'boolean') {
810
+ return null;
811
+ }
812
+ consent[channel] = answer;
813
+ }
814
+ }
815
+ return { name, email, phone, whatsapp, consent, locale };
816
+ }
703
817
  function toReservation(row) {
704
818
  return {
705
819
  id: row.id,
@@ -711,8 +825,19 @@ function toReservation(row) {
711
825
  bufferAfterMs: Number(row.buffer_after_ms),
712
826
  revision: Number(row.revision),
713
827
  source: row.source,
828
+ ...(row.contact_ref ? { contactRef: row.contact_ref } : {}),
829
+ ...(row.notify === false ? { notify: false } : {}),
830
+ ...(row.reminder_minutes?.length ? { reminderMinutes: row.reminder_minutes.map(Number) } : {}),
714
831
  };
715
832
  }
833
+ /** A hold's payment columns, as every read of one names them. */
834
+ const HOLD_PAYMENT_COLUMNS = 'payment_required, payment_ref, paid_at, payment_amount_minor, payment_currency, paid_amount_minor, paid_currency';
835
+ const HOLD_PAYMENT_COLUMNS_OF_HOLD = HOLD_PAYMENT_COLUMNS.split(', ')
836
+ .map((column) => `hold.${column}`)
837
+ .join(', ');
838
+ function amountOf(minor, currency) {
839
+ return minor === null || minor === undefined || !currency ? null : { amountMinor: Number(minor), currency };
840
+ }
716
841
  function toHold(row) {
717
842
  return {
718
843
  id: row.id,
@@ -723,8 +848,35 @@ function toHold(row) {
723
848
  expiresAt: row.expires_at,
724
849
  source: row.source,
725
850
  ...(row.hold_group_id ? { groupId: row.hold_group_id } : {}),
851
+ ...(row.payment_required
852
+ ? {
853
+ paymentRequired: true,
854
+ paymentRef: row.payment_ref ?? null,
855
+ paidAt: row.paid_at ?? null,
856
+ paymentAmount: amountOf(row.payment_amount_minor, row.payment_currency),
857
+ paidAmount: amountOf(row.paid_amount_minor, row.paid_currency),
858
+ }
859
+ : {}),
726
860
  };
727
861
  }
862
+ /** The longest a hold that waits for payment may live (RFC 0004 C3): 30 minutes. */
863
+ export const SLOTLOCK_PAYMENT_HOLD_MAX_TTL_MS = 30 * 60 * 1000;
864
+ /** An amount in a currency's minor unit (cents), and its ISO 4217 code: what a hold's payment is. */
865
+ function isValidAmount(value) {
866
+ const amount = value;
867
+ return (typeof amount === 'object' &&
868
+ amount !== null &&
869
+ Number.isSafeInteger(amount.amountMinor) &&
870
+ amount.amountMinor >= 0 &&
871
+ amount.amountMinor <= 1_000_000_000_000 &&
872
+ typeof amount.currency === 'string' &&
873
+ /^[A-Z]{3}$/.test(amount.currency));
874
+ }
875
+ /** A payment's reference as the integrator's processor names it: 1-255 printable bytes. */
876
+ function isValidPaymentRef(value) {
877
+ return (typeof value === 'string' &&
878
+ /^[\x21-\x7e]{1,255}$/.test(value));
879
+ }
728
880
  function pageCursor(after) {
729
881
  if (after !== undefined &&
730
882
  (!(after.start instanceof Date) ||
@@ -748,6 +900,169 @@ function pageLimit(limit) {
748
900
  function sandboxTestOnlyError() {
749
901
  return Object.assign(new Error('Slotlock does this only in a test environment (a tenant ending ~test)'), { code: 'sandbox_test_only' });
750
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
+ }
751
1066
  function invalidIdentityError(message) {
752
1067
  return Object.assign(new Error(message), { code: 'invalid_identity' });
753
1068
  }
@@ -1072,6 +1387,7 @@ function putCommandValue(params) {
1072
1387
  source: params.command.source ?? null,
1073
1388
  // Only when present, so a create without a hold keeps its hash.
1074
1389
  ...(params.command.fromHold ? { fromHold: params.command.fromHold.holdId } : {}),
1390
+ ...(params.command.notify === false ? { notify: false } : {}),
1075
1391
  event: {
1076
1392
  ...params.event,
1077
1393
  start: params.event.start.toISOString(),
@@ -1191,8 +1507,255 @@ async function bookingRuling(tx, resourceId, interval, defaultHours) {
1191
1507
  rule: judge.broken(interval),
1192
1508
  turnaroundMs: judge.turnaroundMs,
1193
1509
  bufferBeforeMs: judge.bufferBeforeMs,
1510
+ holdPrice: judge.holdPrice,
1194
1511
  };
1195
1512
  }
1513
+ /**
1514
+ * The wall time a reservation says it starts at (RFC 0004 B9), when it names `start` in the
1515
+ * resource's zone; `undefined` when none was given; null when it is not one or names another
1516
+ * instant.
1517
+ */
1518
+ async function localStartFor(tx, resourceId, localStart, start) {
1519
+ if (localStart === undefined)
1520
+ return undefined;
1521
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(localStart);
1522
+ if (!match)
1523
+ return null;
1524
+ const [row] = await tx `SELECT timezone FROM slotlock.resources WHERE id = ${resourceId}`;
1525
+ if (!row)
1526
+ return null;
1527
+ try {
1528
+ const [, year, month, day, hour, minute, second] = match.map((part) => (part === undefined ? 0 : Number(part)));
1529
+ const named = zonedDateTimeToUtc({ year: year, month: month, day: day, hour: hour, minute: minute, second: second }, row.timezone);
1530
+ return named.getTime() === start.getTime() ? `${localStart.length === 16 ? `${localStart}:00` : localStart}` : null;
1531
+ }
1532
+ catch {
1533
+ return null;
1534
+ }
1535
+ }
1536
+ /**
1537
+ * Whether a source's reported busy time (RFC 0004 C1) meets [start, end) on the resource: read
1538
+ * under the resource's write lock, which a source's replacement takes too.
1539
+ */
1540
+ async function sourceBusyLoss(tx, resourceId, start, end) {
1541
+ const [hit] = await tx `
1542
+ SELECT 1 FROM slotlock.source_busy
1543
+ WHERE resource_id IN (${compositeKin(tx, resourceId)}) AND starts_at < ${end} AND ends_at > ${start}
1544
+ LIMIT 1`;
1545
+ return hit !== undefined;
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
+ }
1560
+ /**
1561
+ * A source's busy time as kept: each interval valid, clipped to the window, sorted and merged
1562
+ * (touching ones too). Null when any is not an interval, or there are too many.
1563
+ */
1564
+ function mergeSourceBusy(busy, window) {
1565
+ if (!Array.isArray(busy) || busy.length > SLOTLOCK_SOURCE_BUSY_MAX)
1566
+ return null;
1567
+ const clipped = [];
1568
+ for (const interval of busy) {
1569
+ const start = interval?.start;
1570
+ const end = interval?.end;
1571
+ if (!(start instanceof Date) || !(end instanceof Date))
1572
+ return null;
1573
+ if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime()) || end <= start)
1574
+ return null;
1575
+ const from = Math.max(start.getTime(), window.start.getTime());
1576
+ const to = Math.min(end.getTime(), window.end.getTime());
1577
+ if (to > from)
1578
+ clipped.push({ start: new Date(from), end: new Date(to) });
1579
+ }
1580
+ return mergeIntervals(clipped);
1581
+ }
1582
+ /** An entry's offer: the hold made for it, said on the entry and the feed. */
1583
+ async function recordWaitlistOffer(tx, tenantRef, entry, offer) {
1584
+ await tx `
1585
+ UPDATE slotlock.waitlist_entries
1586
+ SET status = 'offered', hold_id = ${offer.holdId}, offered_resource_id = ${offer.resourceId},
1587
+ offered_at = statement_timestamp()
1588
+ WHERE id = ${entry.id}`;
1589
+ await tx `
1590
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
1591
+ VALUES (${tenantRef}, ${offer.resourceId}, 'waitlist.offered', ${entry.id},
1592
+ jsonb_strip_nulls(jsonb_build_object(
1593
+ 'id', ${entry.id}::uuid, 'hold_id', ${offer.holdId}::uuid,
1594
+ 'resource_id', ${offer.resourceId}::uuid, 'pool', ${entry.pool_ref}::text,
1595
+ 'start', ${offer.start}::timestamptz, 'end', ${offer.end}::timestamptz,
1596
+ 'expires_at', ${offer.expiresAt}::timestamptz,
1597
+ 'holder_ref', ${entry.holder_ref}::text)),
1598
+ 'slotlock:maintenance')`;
1599
+ }
1600
+ /**
1601
+ * The earliest slot of the entry's length within its window, from now, on its resource or any
1602
+ * member of its pool (by id), held for its holder: what is busy is read with each resource's
1603
+ * buffers, and the hold itself still meets the booking rules and the constraint. Null when none.
1604
+ */
1605
+ async function offerSlotOfLength(tx, inner, tenantRef, entry) {
1606
+ const durationMs = Number(entry.duration_ms);
1607
+ const candidates = await tx `
1608
+ SELECT resources.id,
1609
+ COALESCE(least(greatest((effective.effective_booking_rules->>'bufferBeforeMinutes')::numeric, 0), 1440)::int, 0) * 60000
1610
+ AS before_ms,
1611
+ COALESCE(least(greatest((effective.effective_booking_rules->>'turnaroundMinutes')::numeric, 0), 10080)::int, 0) * 60000
1612
+ AS after_ms,
1613
+ statement_timestamp() AS now
1614
+ FROM slotlock.resources, LATERAL (SELECT ${tx.unsafe(EFFECTIVE_BOOKING_RULES)}) effective
1615
+ WHERE resources.tenant_ref = ${tenantRef} AND resources.archived_at IS NULL
1616
+ AND (resources.id = ${entry.resource_id}::uuid
1617
+ OR (${entry.resource_id}::uuid IS NULL AND resources.pool_ref = ${entry.pool_ref}))
1618
+ ORDER BY resources.id
1619
+ LIMIT ${OFFER_CANDIDATES}`;
1620
+ for (const candidate of candidates) {
1621
+ const from = new Date(Math.ceil(Math.max(entry.starts_at.getTime(), candidate.now.getTime()) / 60_000) * 60_000);
1622
+ if (from.getTime() + durationMs > entry.ends_at.getTime())
1623
+ continue;
1624
+ const reach = {
1625
+ start: new Date(from.getTime() - Number(candidate.before_ms) - 86_400_000),
1626
+ end: new Date(entry.ends_at.getTime() + Number(candidate.after_ms) + 86_400_000),
1627
+ };
1628
+ // Bookings and holds, a composite's parent's or parts' claims, and calendars' busy time.
1629
+ const busy = await tx `
1630
+ SELECT occupied_starts_at AS start, occupied_ends_at AS end FROM (
1631
+ SELECT COALESCE(occupied_starts_at, starts_at) AS occupied_starts_at,
1632
+ COALESCE(occupied_ends_at, ends_at) AS occupied_ends_at
1633
+ FROM slotlock.reservations
1634
+ WHERE resource_id = ${candidate.id}
1635
+ AND tstzrange(COALESCE(occupied_starts_at, starts_at), COALESCE(occupied_ends_at, ends_at), '[)')
1636
+ && tstzrange(${reach.start}::timestamptz, ${reach.end}::timestamptz, '[)')
1637
+ AND (status <> 'held' OR expires_at > statement_timestamp())
1638
+ UNION ALL
1639
+ ${compositeBusy(tx, candidate.id, reach)}
1640
+ UNION ALL
1641
+ ${sourceBusy(tx, candidate.id, reach)}
1642
+ ) busy`;
1643
+ // A slot [s, s + d) with its buffers occupies [s - before, s + d + after).
1644
+ const blocked = busy.map((taken) => ({
1645
+ start: new Date(taken.start.getTime() - Number(candidate.after_ms)),
1646
+ end: new Date(taken.end.getTime() + Number(candidate.before_ms)),
1647
+ }));
1648
+ const slot = findNextAvailable({
1649
+ busy: blocked,
1650
+ windows: [{ start: from, end: entry.ends_at }],
1651
+ durationMs,
1652
+ align: (start) => new Date(Math.ceil(start.getTime() / 60_000) * 60_000),
1653
+ });
1654
+ if (!slot)
1655
+ continue;
1656
+ const hold = await inner.acquireHold({
1657
+ resourceId: candidate.id,
1658
+ start: slot.start,
1659
+ end: slot.end,
1660
+ ttlMs: Number(entry.offer_ttl_ms),
1661
+ tenantRef,
1662
+ holderRef: entry.holder_ref,
1663
+ source: 'slotlock:waitlist',
1664
+ });
1665
+ if (!hold.ok)
1666
+ continue;
1667
+ return { resourceId: candidate.id, holdId: hold.hold.id, start: slot.start, end: slot.end, expiresAt: hold.hold.expiresAt };
1668
+ }
1669
+ return null;
1670
+ }
1671
+ /** An admin's reason for going past a cutoff (RFC 0004 B5): 1-500 characters, no control characters. */
1672
+ function isValidOverrideReason(value) {
1673
+ return typeof value === 'string' && /^[^\u0000-\u001f\u007f]{1,500}$/u.test(value) && value.trim() !== '';
1674
+ }
1675
+ /**
1676
+ * A resource's effective booking rules and the database clock: what a cancellation or a move is
1677
+ * judged by (RFC 0004 B5). Rules that do not read refuse nothing here.
1678
+ */
1679
+ async function rulesNow(tx, resourceId) {
1680
+ const [row] = await tx `
1681
+ SELECT ${tx.unsafe(EFFECTIVE_BOOKING_RULES)}, statement_timestamp() AS now
1682
+ FROM slotlock.resources WHERE id = ${resourceId}`;
1683
+ if (!row)
1684
+ return { rules: undefined, now: new Date() };
1685
+ if (row.effective_booking_rules === null || row.effective_booking_rules === undefined)
1686
+ return { rules: undefined, now: row.now };
1687
+ return { rules: parseEffectiveBookingRules(row.effective_booking_rules).rules, now: row.now };
1688
+ }
1689
+ /**
1690
+ * Mark what the next change of a booking records on the feed (RFC 0004 B5): a cancellation's fee
1691
+ * and an admin's reason for going past a cutoff. Cleared with nulls.
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
+ }
1729
+ async function noteEnding(tx, fee, overrideReason) {
1730
+ await tx `
1731
+ SELECT set_config('slotlock.cancellation_fee', ${fee ? JSON.stringify(fee) : ''}, true),
1732
+ set_config('slotlock.override_reason', ${overrideReason ?? ''}, true)`;
1733
+ }
1734
+ /**
1735
+ * Whether a one-off event may be cancelled now under its resource's cancel cutoff (RFC 0004 B5),
1736
+ * and the fee a late cancellation records, marked for the feed. A series is not held to the cutoff:
1737
+ * cancelling it cancels what is still to come.
1738
+ */
1739
+ async function eventEnding(tx, event, enforce, overrideReason) {
1740
+ if (event.recurring)
1741
+ return { ok: true, fee: null };
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);
1745
+ let note = null;
1746
+ if (enforce && cutoffRuleBroken(judged.rules, 'cancel', start, judged.now)) {
1747
+ if (overrideReason === undefined) {
1748
+ return { ok: false, refusal: { ok: false, code: 'booking_rule_violated', rule: 'cancel_cutoff' } };
1749
+ }
1750
+ note = overrideReason;
1751
+ }
1752
+ const due = cancellationFeeDue(judged.rules, start, judged.now);
1753
+ const fee = due ? { amountMinor: due.amountMinor, currency: due.currency } : null;
1754
+ await noteEnding(tx, fee ? { amount_minor: fee.amountMinor, currency: fee.currency } : null, note);
1755
+ return { ok: true, fee };
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;
1196
1759
  /** How recently a sync must have read a resource whose booking rules require certainty. */
1197
1760
  const CERTAIN_COVERAGE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
1198
1761
  /**
@@ -1201,25 +1764,35 @@ const CERTAIN_COVERAGE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
1201
1764
  */
1202
1765
  async function bookingJudge(tx, resourceId, defaultHours) {
1203
1766
  const [row] = await tx `
1204
- SELECT ${tx.unsafe(EFFECTIVE_BOOKING_RULES)}, availability_rules, timezone,
1205
- statement_timestamp() AS now
1767
+ SELECT ${tx.unsafe(EFFECTIVE_BOOKING_RULES)}, availability_rules,
1768
+ CASE WHEN resources.availability_rules IS NULL
1769
+ THEN slotlock.inherited_availability(resources.tenant_ref, resources.location_ref) END
1770
+ AS inherited_availability,
1771
+ timezone, statement_timestamp() AS now,
1772
+ to_char((statement_timestamp() AT TIME ZONE 'UTC')::date - 2, 'YYYY-MM-DD') AS today
1206
1773
  FROM slotlock.resources WHERE id = ${resourceId}`;
1207
1774
  if (!row || row.effective_booking_rules === null || row.effective_booking_rules === undefined) {
1208
- return { broken: () => null, turnaroundMs: 0, bufferBeforeMs: 0 };
1775
+ return { broken: () => null, turnaroundMs: 0, bufferBeforeMs: 0, holdPrice: undefined };
1209
1776
  }
1210
- const { effectiveBookingRules: rules, availabilityRules } = toResource({
1777
+ const resource = toResource({
1211
1778
  id: resourceId,
1212
1779
  external_ref: null,
1213
1780
  tenant_ref: null,
1214
1781
  timezone: row.timezone,
1215
1782
  availability_rules: row.availability_rules,
1783
+ inherited_availability: row.inherited_availability,
1216
1784
  booking_rules: null,
1217
1785
  pool_ref: null,
1218
1786
  visibility: 'shared',
1219
1787
  archived_at: null,
1220
1788
  effective_booking_rules: row.effective_booking_rules,
1221
1789
  });
1222
- const hours = availabilityRules ?? defaultHours;
1790
+ const rules = resource.effectiveBookingRules;
1791
+ const hours = applicableHours(resource, defaultHours).rules;
1792
+ // Enforced hours read the exceptions from two days back (a zone is at most fourteen hours from UTC).
1793
+ const exceptions = rules?.enforceHours === true
1794
+ ? ((await rankedExceptions(tx, [resourceId], { from: row.today })).get(resourceId) ?? [])
1795
+ : [];
1223
1796
  const coverage = rules?.requireCertain === true
1224
1797
  ? await tx `
1225
1798
  SELECT starts_at, ends_at,
@@ -1246,11 +1819,13 @@ async function bookingJudge(tx, resourceId, defaultHours) {
1246
1819
  rules: checks.first ? rules : later,
1247
1820
  timezone: row.timezone,
1248
1821
  hours,
1822
+ exceptions,
1249
1823
  interval,
1250
1824
  now: row.now,
1251
1825
  }) ?? (uncertain(interval) ? 'uncertain' : null),
1252
1826
  turnaroundMs,
1253
1827
  bufferBeforeMs,
1828
+ holdPrice: rules?.holdPrice,
1254
1829
  };
1255
1830
  }
1256
1831
  function sameExternalPayload(row, params, turnaroundMs = 0) {
@@ -1262,6 +1837,29 @@ function sameExternalPayload(row, params, turnaroundMs = 0) {
1262
1837
  Number(row.revision) === params.revision &&
1263
1838
  row.source === (params.source ?? null));
1264
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
+ }
1265
1863
  async function lockExternalIdentity(tx, tenantRef, externalRef) {
1266
1864
  // JSON is unambiguous for this pair and has no NUL byte (Postgres text rejects U+0000).
1267
1865
  await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([tenantRef, externalRef])}, 0))`;
@@ -1324,7 +1922,7 @@ function pgCode(err) {
1324
1922
  export const SLOTLOCK_MAX_POOL_MEMBERS = 100;
1325
1923
  /** The most pools of one tenant that may have default booking rules (`setBookingRuleDefaults`). */
1326
1924
  export const SLOTLOCK_MAX_POOL_RULE_DEFAULTS = 1_000;
1327
- const WAITLIST_COLUMNS = 'id, resource_id, pool_ref, starts_at, ends_at, holder_ref, offer_ttl_ms, status, hold_id, offered_resource_id, offered_at, created_at';
1925
+ const WAITLIST_COLUMNS = 'id, resource_id, pool_ref, starts_at, ends_at, holder_ref, offer_ttl_ms, status, hold_id, offered_resource_id, offered_at, created_at, duration_ms, decided_at';
1328
1926
  function toWaitlistEntry(row) {
1329
1927
  return {
1330
1928
  id: row.id,
@@ -1339,6 +1937,8 @@ function toWaitlistEntry(row) {
1339
1937
  offeredResourceId: row.offered_resource_id,
1340
1938
  offeredAt: row.offered_at,
1341
1939
  createdAt: row.created_at,
1940
+ durationMs: row.duration_ms === null ? null : Number(row.duration_ms),
1941
+ decidedAt: row.decided_at,
1342
1942
  };
1343
1943
  }
1344
1944
  export function isPoolName(value) {
@@ -1353,6 +1953,9 @@ function invalidPoolError() {
1353
1953
  const RESOURCE_COLUMNS = `id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
1354
1954
  visibility, archived_at, name, description, kind, capacity, labels, attributes, location_ref,
1355
1955
  parent_id, part_index,
1956
+ CASE WHEN resources.availability_rules IS NULL
1957
+ THEN slotlock.inherited_availability(resources.tenant_ref, resources.location_ref) END
1958
+ AS inherited_availability,
1356
1959
  (SELECT place.name FROM slotlock.locations place
1357
1960
  WHERE place.tenant_ref = resources.tenant_ref AND place.ref = resources.location_ref) AS location_name,
1358
1961
  ARRAY(SELECT member.group_ref FROM slotlock.resource_group_members member
@@ -1432,6 +2035,11 @@ function toResource(row) {
1432
2035
  resource.availabilityRules = [];
1433
2036
  }
1434
2037
  }
2038
+ if (resource.availabilityRules === undefined) {
2039
+ const inherited = storedInheritedAvailability(row.inherited_availability);
2040
+ if (inherited)
2041
+ resource.inheritedAvailability = inherited;
2042
+ }
1435
2043
  if (row.booking_rules !== null && row.booking_rules !== undefined) {
1436
2044
  // Written validated; anything else refuses every booking, never as no rule.
1437
2045
  resource.bookingRules = storedBookingRules(row.booking_rules);
@@ -1640,6 +2248,24 @@ async function rollStoredSeries(tx, tenantRef, eventId, horizon) {
1640
2248
  cursor: row.recurrence_cursor ?? null,
1641
2249
  stored: true,
1642
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
+ }
1643
2269
  await replaceCalendarEventOccurrences(tx, {
1644
2270
  tenantRef,
1645
2271
  eventId: row.id,
@@ -1815,6 +2441,35 @@ async function rollSeriesBehind(executor, resourceId, start, end, exceptEventId)
1815
2441
  // Rolled back whole (its own transaction, or a savepoint of the caller's).
1816
2442
  }
1817
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;
1818
2473
  const MAX_TENANT_LIMIT = 10_000_000;
1819
2474
  function tenantLimitsOption(limits) {
1820
2475
  const valid = {};
@@ -2097,6 +2752,18 @@ function compositeBusy(executor, resourceId, window) {
2097
2752
  AND claim.during && tstzrange(${window.start}, ${window.end}, '[)')
2098
2753
  AND (claim.status <> 'held' OR claim.expires_at > statement_timestamp())`;
2099
2754
  }
2755
+ /**
2756
+ * Busy time calendars reported without their events (RFC 0004 C1) that meets `window`, as
2757
+ * (occupied_starts_at, occupied_ends_at): every read of a resource's busy time unions it in, as
2758
+ * every write refuses it (sourceBusyLoss), so a time offered is a time the write accepts.
2759
+ */
2760
+ function sourceBusy(executor, resourceId, window) {
2761
+ return executor `
2762
+ SELECT busy.starts_at AS occupied_starts_at, busy.ends_at AS occupied_ends_at
2763
+ FROM slotlock.source_busy busy
2764
+ WHERE busy.resource_id IN (${compositeKin(executor, resourceId)})
2765
+ AND busy.starts_at < ${window.end} AND busy.ends_at > ${window.start}`;
2766
+ }
2100
2767
  /**
2101
2768
  * Structured answer after losing to the EXCLUDE arbiter: name the live winner.
2102
2769
  * conflictingReservationId is null in one narrow race (the blocker expired between the constraint
@@ -2206,6 +2873,24 @@ export function createTenantSlots(max) {
2206
2873
  function lockTimeoutError(cause) {
2207
2874
  return Object.assign(new Error('Slotlock tenant transaction waited too long for a lock: retry later', { cause }), { code: 'lock_timeout' });
2208
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
+ }
2209
2894
  export function createSlotlockStore(sql, options = {}) {
2210
2895
  return buildSlotlockStore(sql, options, null);
2211
2896
  }
@@ -2241,18 +2926,31 @@ export const SLOTLOCK_TENANT_WRITE_METHODS = Object.freeze([
2241
2926
  'declineApproval',
2242
2927
  'resetTestTenant',
2243
2928
  'fastForwardTestTenant',
2929
+ 'storeTenantAvailability',
2244
2930
  'setTenantAvailability',
2931
+ 'setLocationAvailability',
2932
+ 'createAvailabilityException',
2933
+ 'deleteAvailabilityException',
2934
+ 'replaceImportedExceptions',
2935
+ 'importHolidayCalendar',
2936
+ 'deleteHolidayCalendar',
2937
+ 'noteHolidayCalendarFailure',
2245
2938
  'createReservation',
2246
2939
  'createExternalReservation',
2247
2940
  'rescheduleExternalReservation',
2248
2941
  'cancelExternalReservation',
2249
2942
  'acquireHold',
2943
+ 'markHoldPaid',
2944
+ 'putContact',
2945
+ 'deleteContact',
2250
2946
  'confirmHold',
2251
2947
  'releaseHold',
2252
2948
  'putCalendarEvent',
2253
2949
  'cancelCalendarEvent',
2254
2950
  'cancelCalendarEventById',
2255
2951
  'recordCalendarCoverage',
2952
+ 'replaceSourceBusy',
2953
+ 'reportCoverageConflict',
2256
2954
  'createWebhookEndpoint',
2257
2955
  'deleteWebhookEndpoint',
2258
2956
  'enableWebhookEndpoint',
@@ -2261,12 +2959,14 @@ export const SLOTLOCK_TENANT_WRITE_METHODS = Object.freeze([
2261
2959
  'checkInReservation',
2262
2960
  'joinWaitlist',
2263
2961
  'leaveWaitlist',
2962
+ 'declineWaitlistOffer',
2264
2963
  'putAssistantSettings',
2265
2964
  'deleteAssistantSettings',
2266
2965
  'resealAssistantKey',
2267
- 'putAssistantConversation',
2966
+ 'putAssistantChat',
2967
+ 'renameAssistantChat',
2268
2968
  'settleAssistantAction',
2269
- 'deleteAssistantConversation',
2969
+ 'deleteAssistantChat',
2270
2970
  ]);
2271
2971
  function tenantSuspendedError() {
2272
2972
  return Object.assign(new Error('Slotlock tenant is suspended: it may read, and write nothing'), {
@@ -2348,6 +3048,9 @@ function buildSlotlockStore(sql, options, refusal) {
2348
3048
  retentionDays,
2349
3049
  maxHoldsPerHolder,
2350
3050
  ...(maxHeldHoursPerHolder !== undefined ? { maxHeldHoursPerHolder } : {}),
3051
+ // The server's hours judge a tenant's writes too: without them, enforced hours would refuse
3052
+ // every booking on a resource that inherits nothing.
3053
+ ...(options.defaultAvailabilityRules !== undefined ? { defaultAvailabilityRules: options.defaultAvailabilityRules } : {}),
2351
3054
  };
2352
3055
  /**
2353
3056
  * Under the tenant's lock for `kind`: the limit when the tenant is at it, else null. Writes that
@@ -3185,18 +3888,75 @@ function buildSlotlockStore(sql, options, refusal) {
3185
3888
  WHERE tenant_ref = ${params.tenantRef} AND pool_ref = ${params.pool}
3186
3889
  AND member_order = 'round_robin'`;
3187
3890
  },
3891
+ async storeTenantAvailability(params) {
3892
+ if (!isValidIdentity(params.tenantRef)) {
3893
+ throw Object.assign(new Error('Slotlock tenant reference must be 1-500 UTF-8 bytes'), {
3894
+ code: 'invalid_identity',
3895
+ });
3896
+ }
3897
+ // What `matching` compares with is read in the same transaction as what replaces it.
3898
+ return inTransaction(sql, (tx) => storeTenantAvailability(tx, params), 'read committed');
3899
+ },
3188
3900
  async setTenantAvailability(params) {
3189
3901
  if (!isValidIdentity(params.tenantRef)) {
3190
3902
  throw Object.assign(new Error('Slotlock tenant reference must be 1-500 UTF-8 bytes'), {
3191
3903
  code: 'invalid_identity',
3192
3904
  });
3193
3905
  }
3194
- const rules = availabilityParameter(sql, params.rules);
3195
- const updated = await sql `
3196
- UPDATE slotlock.resources
3197
- SET availability_rules = ${rules}::jsonb
3198
- WHERE tenant_ref = ${params.tenantRef}`;
3199
- return updated.count;
3906
+ const stored = await inTransaction(sql, (tx) => storeTenantAvailability(tx, { tenantRef: params.tenantRef, rules: params.rules, overrideResources: 'all' }), 'read committed');
3907
+ return stored.inheriting;
3908
+ },
3909
+ async setLocationAvailability(params) {
3910
+ if (!isValidIdentity(params.tenantRef)) {
3911
+ throw Object.assign(new Error('Slotlock tenant reference must be 1-500 UTF-8 bytes'), {
3912
+ code: 'invalid_identity',
3913
+ });
3914
+ }
3915
+ return setLocationAvailability(sql, params);
3916
+ },
3917
+ async getAvailabilityDefaults(params) {
3918
+ return getAvailabilityDefaults(sql, params);
3919
+ },
3920
+ async createAvailabilityException(params) {
3921
+ if (!isValidIdentity(params.tenantRef)) {
3922
+ throw Object.assign(new Error('Slotlock tenant reference must be 1-500 UTF-8 bytes'), {
3923
+ code: 'invalid_identity',
3924
+ });
3925
+ }
3926
+ return createAvailabilityException(sql, params);
3927
+ },
3928
+ async listAvailabilityExceptions(params) {
3929
+ return listAvailabilityExceptions(sql, params);
3930
+ },
3931
+ async deleteAvailabilityException(params) {
3932
+ return deleteAvailabilityException(sql, params);
3933
+ },
3934
+ async replaceImportedExceptions(params) {
3935
+ return replaceImportedExceptions(sql, params);
3936
+ },
3937
+ async importHolidayCalendar(params) {
3938
+ if (!isValidIdentity(params.tenantRef)) {
3939
+ throw Object.assign(new Error('Slotlock tenant reference must be 1-500 UTF-8 bytes'), {
3940
+ code: 'invalid_identity',
3941
+ });
3942
+ }
3943
+ // The calendar, its holidays and what it dropped, all or none.
3944
+ return inTransaction(sql, (tx) => importHolidayCalendar(tx, params), 'read committed');
3945
+ },
3946
+ async listHolidayCalendars(params) {
3947
+ return listHolidayCalendars(sql, params);
3948
+ },
3949
+ async deleteHolidayCalendar(params) {
3950
+ return inTransaction(sql, (tx) => deleteHolidayCalendar(tx, params), 'read committed');
3951
+ },
3952
+ async dueHolidayCalendars(params) {
3953
+ return dueHolidayCalendars(sql, params);
3954
+ },
3955
+ async noteHolidayCalendarFailure(params) {
3956
+ return noteHolidayCalendarFailure(sql, params);
3957
+ },
3958
+ async availabilityExceptionsFor(params) {
3959
+ return rankedExceptions(sql, params.resourceIds, exceptionQueryDays(params.window));
3200
3960
  },
3201
3961
  async listResources(params) {
3202
3962
  if (!isValidIdentity(params.tenantRef)) {
@@ -3388,6 +4148,9 @@ function buildSlotlockStore(sql, options, refusal) {
3388
4148
  return { ok: false, code: 'resource_not_found' };
3389
4149
  if (locked.archived)
3390
4150
  return { ok: false, code: 'resource_archived' };
4151
+ // Busy time a calendar reported without its events (RFC 0004 C1) is refused as a booking's is.
4152
+ if (await sourceBusyLoss(tx, params.resourceId, occupied.start, occupied.end))
4153
+ return { ok: false, code: 'overlap', conflictingReservationId: null };
3391
4154
  await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
3392
4155
  const [row] = await tx `
3393
4156
  INSERT INTO slotlock.reservations (
@@ -3400,7 +4163,7 @@ function buildSlotlockStore(sql, options, refusal) {
3400
4163
  ${params.source ?? null}
3401
4164
  )
3402
4165
  RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
3403
- buffer_after_ms, revision, source`;
4166
+ buffer_after_ms, revision, source, contact_ref, notify, reminder_minutes`;
3404
4167
  if (!row)
3405
4168
  throw new Error('slotlock: reservation insert returned no row');
3406
4169
  return { ok: true, reservation: toReservation(row) };
@@ -3446,7 +4209,7 @@ function buildSlotlockStore(sql, options, refusal) {
3446
4209
  // holds.
3447
4210
  const existing = await tx `
3448
4211
  SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
3449
- buffer_after_ms, revision, source
4212
+ buffer_after_ms, revision, source, contact_ref, notify, reminder_minutes
3450
4213
  FROM slotlock.reservations
3451
4214
  WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
3452
4215
  AND calendar_event_id IS NULL
@@ -3485,24 +4248,31 @@ function buildSlotlockStore(sql, options, refusal) {
3485
4248
  }))) {
3486
4249
  return { ok: false, code: 'recurrence_not_materialized' };
3487
4250
  }
4251
+ const wall = await localStartFor(tx, params.resourceId, params.localStart, params.start);
4252
+ if (wall === null)
4253
+ return { ok: false, code: 'invalid_local_start' };
3488
4254
  // Writers to this resource take turns from here to the insert (lockResourceForWrite).
3489
4255
  const locked = await lockResourceForWrite(tx, params.resourceId, params.tenantRef);
3490
4256
  if (!locked)
3491
4257
  return { ok: false, code: 'resource_tenant_mismatch' };
3492
4258
  if (locked.archived)
3493
4259
  return { ok: false, code: 'resource_archived' };
4260
+ // Busy time a calendar reported without its events (RFC 0004 C1) is refused as a booking's is.
4261
+ if (await sourceBusyLoss(tx, params.resourceId, occupied.start, occupied.end))
4262
+ return { ok: false, code: 'overlap', conflictingReservationId: null };
3494
4263
  await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
3495
4264
  const rows = await tx `
3496
4265
  INSERT INTO slotlock.reservations (
3497
4266
  resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
3498
- buffer_before_ms, revision, source
4267
+ buffer_before_ms, revision, source, wall_starts_at, contact_ref, notify, reminder_minutes
3499
4268
  ) VALUES (
3500
4269
  ${params.resourceId}, ${params.tenantRef}, ${params.externalRef}, ${params.start},
3501
4270
  ${params.end}, ${bufferAfterMs}, ${writtenBefore}, ${params.revision},
3502
- ${params.source ?? null}
4271
+ ${params.source ?? null}, ${wall ?? null}::timestamp, ${params.contactRef ?? null},
4272
+ ${params.notify ?? true}, ${reminderColumn(params.reminderMinutes)}::integer[]
3503
4273
  )
3504
4274
  RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
3505
- buffer_after_ms, revision, source`;
4275
+ buffer_after_ms, revision, source, contact_ref, notify, reminder_minutes`;
3506
4276
  const row = rows[0];
3507
4277
  if (!row)
3508
4278
  throw new Error('slotlock: external reservation insert returned no row');
@@ -3538,7 +4308,7 @@ function buildSlotlockStore(sql, options, refusal) {
3538
4308
  await lockExternalIdentity(tx, params.tenantRef, params.externalRef);
3539
4309
  const existing = await tx `
3540
4310
  SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
3541
- buffer_after_ms, revision, source
4311
+ buffer_after_ms, revision, source, contact_ref, notify, reminder_minutes, cutoff_anchor
3542
4312
  FROM slotlock.reservations
3543
4313
  WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
3544
4314
  AND calendar_event_id IS NULL
@@ -3566,6 +4336,26 @@ function buildSlotlockStore(sql, options, refusal) {
3566
4336
  if (params.revision !== currentRevision + 1) {
3567
4337
  return { ok: false, code: 'revision_conflict' };
3568
4338
  }
4339
+ // A move in time or to another resource, past the booking's reschedule cutoff (RFC 0004
4340
+ // B5), only with an admin's reason.
4341
+ const moved = found.starts_at.getTime() !== params.start.getTime() ||
4342
+ found.ends_at.getTime() !== params.end.getTime() ||
4343
+ found.resource_id !== params.resourceId;
4344
+ let overrideNote = null;
4345
+ let cutoffAnchor = found.cutoff_anchor;
4346
+ if (moved) {
4347
+ const judged = await rulesNow(tx, found.resource_id);
4348
+ if (cutoffRuleBroken(judged.rules, 'reschedule', found.starts_at, judged.now)) {
4349
+ if (params.overrideReason === undefined) {
4350
+ return { ok: false, code: 'booking_rule_violated', rule: 'reschedule_cutoff' };
4351
+ }
4352
+ overrideNote = params.overrideReason;
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;
4358
+ }
3569
4359
  const resourceState = await tenantResourceState(tx, params.resourceId, params.tenantRef);
3570
4360
  if (resourceState === 'missing')
3571
4361
  return { ok: false, code: 'resource_tenant_mismatch' };
@@ -3573,6 +4363,11 @@ function buildSlotlockStore(sql, options, refusal) {
3573
4363
  return { ok: false, code: 'resource_archived' };
3574
4364
  if (ruling.rule)
3575
4365
  return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
4366
+ if (overrideNote !== null)
4367
+ await noteEnding(tx, null, overrideNote);
4368
+ const wall = await localStartFor(tx, params.resourceId, params.localStart, params.start);
4369
+ if (wall === null)
4370
+ return { ok: false, code: 'invalid_local_start' };
3576
4371
  bufferAfterMs = Math.max(params.bufferAfterMs, ruling.turnaroundMs);
3577
4372
  bufferBeforeMs = ruling.bufferBeforeMs;
3578
4373
  const occupied = occupiedTime({ ...params, bufferBeforeMs, bufferAfterMs });
@@ -3589,6 +4384,9 @@ function buildSlotlockStore(sql, options, refusal) {
3589
4384
  return { ok: false, code: 'resource_tenant_mismatch' };
3590
4385
  if (locked.archived)
3591
4386
  return { ok: false, code: 'resource_archived' };
4387
+ // Busy time a calendar reported without its events (RFC 0004 C1) is refused as a booking's is.
4388
+ if (await sourceBusyLoss(tx, params.resourceId, occupied.start, occupied.end))
4389
+ return { ok: false, code: 'overlap', conflictingReservationId: null };
3592
4390
  await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
3593
4391
  const rows = await tx `
3594
4392
  UPDATE slotlock.reservations
@@ -3599,14 +4397,22 @@ function buildSlotlockStore(sql, options, refusal) {
3599
4397
  buffer_before_ms = ${bufferBeforeMs},
3600
4398
  revision = ${params.revision},
3601
4399
  source = ${params.source ?? null},
4400
+ -- Whom to tell, and whether (RFC 0004 P3): as sent, like the rest.
4401
+ contact_ref = ${params.contactRef ?? null},
4402
+ notify = ${params.notify ?? true},
4403
+ reminder_minutes = ${reminderColumn(params.reminderMinutes)}::integer[],
4404
+ -- The wall time it now says it starts at, if any (RFC 0004 B9).
4405
+ wall_starts_at = ${wall ?? null}::timestamp,
4406
+ zone_shift_reported_at = NULL,
3602
4407
  -- A check-in is for the time it was made for: a move to another start clears it.
3603
4408
  checked_in_at = CASE
3604
4409
  WHEN starts_at = ${params.start} AND resource_id = ${params.resourceId}
3605
4410
  THEN checked_in_at
3606
- END
4411
+ END,
4412
+ cutoff_anchor = ${cutoffAnchor ?? null}::timestamptz
3607
4413
  WHERE id = ${found.id}
3608
4414
  RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
3609
- buffer_after_ms, revision, source`;
4415
+ buffer_after_ms, revision, source, contact_ref, notify, reminder_minutes`;
3610
4416
  const row = rows[0];
3611
4417
  if (!row)
3612
4418
  throw new Error('slotlock: external reservation update returned no row');
@@ -3633,6 +4439,9 @@ function buildSlotlockStore(sql, options, refusal) {
3633
4439
  if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.externalRef)) {
3634
4440
  return { ok: false, code: 'invalid_identity' };
3635
4441
  }
4442
+ if (params.overrideReason !== undefined && !isValidOverrideReason(params.overrideReason)) {
4443
+ return { ok: false, code: 'invalid_identity' };
4444
+ }
3636
4445
  if (!Number.isSafeInteger(params.revision) || params.revision <= 0) {
3637
4446
  return { ok: false, code: 'invalid_revision' };
3638
4447
  }
@@ -3653,11 +4462,15 @@ function buildSlotlockStore(sql, options, refusal) {
3653
4462
  : { ok: false, code: 'reservation_cancelled' };
3654
4463
  }
3655
4464
  const existing = await tx `
3656
- SELECT id, revision FROM slotlock.reservations
4465
+ SELECT id, revision, resource_id, starts_at, cutoff_anchor FROM slotlock.reservations
3657
4466
  WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
3658
4467
  AND calendar_event_id IS NULL
3659
4468
  FOR UPDATE`;
3660
4469
  const found = existing[0];
4470
+ // Past its cancel cutoff a booking is cancelled only with an admin's reason, and a late
4471
+ // cancellation records its fee (RFC 0004 B5).
4472
+ let fee = null;
4473
+ let overrideNote = null;
3661
4474
  if (found) {
3662
4475
  const currentRevision = Number(found.revision);
3663
4476
  if (params.revision <= currentRevision)
@@ -3665,6 +4478,16 @@ function buildSlotlockStore(sql, options, refusal) {
3665
4478
  if (params.revision !== currentRevision + 1) {
3666
4479
  return { ok: false, code: 'revision_conflict' };
3667
4480
  }
4481
+ const judged = await rulesNow(tx, found.resource_id);
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)) {
4485
+ if (params.overrideReason === undefined) {
4486
+ return { ok: false, code: 'booking_rule_violated', rule: 'cancel_cutoff' };
4487
+ }
4488
+ overrideNote = params.overrideReason;
4489
+ }
4490
+ fee = cancellationFeeDue(judged.rules, start, judged.now);
3668
4491
  }
3669
4492
  if (!found) {
3670
4493
  // This reference is a calendar event's occupation, not a booking: nothing to cancel or
@@ -3683,14 +4506,25 @@ function buildSlotlockStore(sql, options, refusal) {
3683
4506
  }
3684
4507
  // A reference no booking holds yet is retired too, so a create delivered after its cancel
3685
4508
  // cannot bring it back.
4509
+ const recorded = fee ? { amount_minor: fee.amountMinor, currency: fee.currency } : null;
3686
4510
  await tx `
3687
- INSERT INTO slotlock.reservation_tombstones (tenant_ref, external_ref, revision, stray)
3688
- VALUES (${params.tenantRef}, ${params.externalRef}, ${params.revision}, ${!found})`;
4511
+ INSERT INTO slotlock.reservation_tombstones (
4512
+ tenant_ref, external_ref, revision, stray, end_reason, cancellation_fee, override_reason
4513
+ )
4514
+ VALUES (${params.tenantRef}, ${params.externalRef}, ${params.revision}, ${!found}, 'cancelled',
4515
+ ${recorded ? tx.json(recorded) : null}::jsonb, ${overrideNote})`;
3689
4516
  if (!found)
3690
4517
  return { ok: true, cancelled: false, revision: params.revision };
4518
+ await noteEnding(tx, recorded, overrideNote);
3691
4519
  const deleted = await tx `
3692
4520
  DELETE FROM slotlock.reservations WHERE id = ${found.id} RETURNING id`;
3693
- return { ok: true, cancelled: deleted.length === 1, revision: params.revision };
4521
+ await noteEnding(tx, null, null);
4522
+ return {
4523
+ ok: true,
4524
+ cancelled: deleted.length === 1,
4525
+ revision: params.revision,
4526
+ ...(fee ? { cancellationFee: { amountMinor: fee.amountMinor, currency: fee.currency } } : {}),
4527
+ };
3694
4528
  });
3695
4529
  },
3696
4530
  async acquireHold(params) {
@@ -3702,6 +4536,16 @@ function buildSlotlockStore(sql, options, refusal) {
3702
4536
  if (!Number.isFinite(params.ttlMs) || params.ttlMs <= 0 || params.ttlMs > MAX_HOLD_TTL_MS) {
3703
4537
  return { ok: false, code: 'invalid_ttl' };
3704
4538
  }
4539
+ const paymentRequired = params.paymentRequired === true;
4540
+ if (params.paymentRequired !== undefined && typeof params.paymentRequired !== 'boolean') {
4541
+ return { ok: false, code: 'invalid_identity' };
4542
+ }
4543
+ if (paymentRequired && params.ttlMs > SLOTLOCK_PAYMENT_HOLD_MAX_TTL_MS) {
4544
+ return { ok: false, code: 'invalid_ttl' };
4545
+ }
4546
+ if (params.payment !== undefined && (!paymentRequired || !isValidAmount(params.payment))) {
4547
+ return { ok: false, code: 'invalid_payment' };
4548
+ }
3705
4549
  const bufferAfterMs = params.bufferAfterMs ?? 0;
3706
4550
  if (!Number.isSafeInteger(bufferAfterMs) ||
3707
4551
  bufferAfterMs < 0 ||
@@ -3736,6 +4580,8 @@ function buildSlotlockStore(sql, options, refusal) {
3736
4580
  source: params.source ?? null,
3737
4581
  ...(params.groupId !== undefined ? { groupId: params.groupId } : {}),
3738
4582
  ...(holderRef !== undefined ? { holderRef } : {}),
4583
+ ...(paymentRequired ? { paymentRequired } : {}),
4584
+ ...(params.payment ? { payment: params.payment } : {}),
3739
4585
  });
3740
4586
  const ttlSeconds = params.ttlMs / 1000;
3741
4587
  // The holder's caps: the store's, lowered by its key's own (RFC 0004 A5), never raised.
@@ -3764,7 +4610,14 @@ function buildSlotlockStore(sql, options, refusal) {
3764
4610
  const [prior] = await tx `
3765
4611
  SELECT hold_id AS id, resource_id, starts_at, ends_at, buffer_after_ms, expires_at,
3766
4612
  source, payload_hash,
3767
- (SELECT hold_group_id FROM slotlock.reservations WHERE id = hold_id) AS hold_group_id
4613
+ (SELECT hold_group_id FROM slotlock.reservations WHERE id = hold_id) AS hold_group_id,
4614
+ (SELECT payment_required FROM slotlock.reservations WHERE id = hold_id) AS payment_required,
4615
+ (SELECT payment_ref FROM slotlock.reservations WHERE id = hold_id) AS payment_ref,
4616
+ (SELECT paid_at FROM slotlock.reservations WHERE id = hold_id) AS paid_at,
4617
+ (SELECT payment_amount_minor FROM slotlock.reservations WHERE id = hold_id) AS payment_amount_minor,
4618
+ (SELECT payment_currency FROM slotlock.reservations WHERE id = hold_id) AS payment_currency,
4619
+ (SELECT paid_amount_minor FROM slotlock.reservations WHERE id = hold_id) AS paid_amount_minor,
4620
+ (SELECT paid_currency FROM slotlock.reservations WHERE id = hold_id) AS paid_currency
3768
4621
  FROM slotlock.hold_commands
3769
4622
  WHERE tenant_ref = ${tenantRef ?? null} AND principal_ref = ${principalRef ?? null}
3770
4623
  AND idempotency_key = ${idempotencyKey ?? null}`;
@@ -3782,6 +4635,24 @@ function buildSlotlockStore(sql, options, refusal) {
3782
4635
  return { ok: false, code: 'resource_archived' };
3783
4636
  if (holdLimit !== null)
3784
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
+ }
3785
4656
  let heldSeconds = null;
3786
4657
  if (holderRef !== undefined) {
3787
4658
  // Counted under a lock on the holder, so racing holds stop at the cap: an agent
@@ -3791,23 +4662,13 @@ function buildSlotlockStore(sql, options, refusal) {
3791
4662
  tenantRef,
3792
4663
  holderRef,
3793
4664
  ])}, 0))`;
3794
- const [held] = await tx `
3795
- SELECT count(*)::int AS count,
3796
- COALESCE(sum(extract(epoch FROM
3797
- ends_at + (buffer_after_ms + buffer_before_ms) * interval '1 millisecond'
3798
- - starts_at)), 0)::float8
3799
- AS seconds
3800
- FROM slotlock.reservations
3801
- WHERE holder_ref = ${holderRef} AND tenant_ref IS NULL AND status = 'held'
3802
- AND expires_at > statement_timestamp()
3803
- AND resource_id IN (SELECT id FROM slotlock.resources WHERE tenant_ref = ${tenantRef ?? null})`;
3804
- if ((held?.count ?? 0) >= holdCap) {
4665
+ const held = await holderHeld(tx, tenantRef ?? null, holderRef);
4666
+ if (held.count >= holdCap) {
3805
4667
  return { ok: false, code: 'hold_limit_reached', limit: holdCap };
3806
4668
  }
3807
4669
  if (heldHoursCap !== undefined)
3808
- heldSeconds = held?.seconds ?? 0;
4670
+ heldSeconds = held.seconds;
3809
4671
  }
3810
- const ruling = await bookingRuling(tx, params.resourceId, params, defaultHours);
3811
4672
  if (ruling.rule)
3812
4673
  return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
3813
4674
  written = Math.max(bufferAfterMs, ruling.turnaroundMs);
@@ -3833,18 +4694,23 @@ function buildSlotlockStore(sql, options, refusal) {
3833
4694
  return { ok: false, code: 'resource_not_found' };
3834
4695
  if (locked.archived)
3835
4696
  return { ok: false, code: 'resource_archived' };
4697
+ // Busy time a calendar reported without its events (RFC 0004 C1) is refused as a booking's is.
4698
+ if (await sourceBusyLoss(tx, params.resourceId, reach.start, reach.end))
4699
+ return { ok: false, code: 'overlap', conflictingReservationId: null };
3836
4700
  await reapExpiredHolds(tx, params.resourceId, reach.start, reach.end);
3837
4701
  const [row] = await tx `
3838
4702
  INSERT INTO slotlock.reservations (
3839
4703
  resource_id, starts_at, ends_at, buffer_after_ms, buffer_before_ms, status,
3840
- expires_at, source, holder_ref, hold_group_id
4704
+ expires_at, source, holder_ref, hold_group_id, payment_required,
4705
+ payment_amount_minor, payment_currency
3841
4706
  ) VALUES (
3842
4707
  ${params.resourceId}, ${params.start}, ${params.end}, ${written}, ${writtenBefore}, 'held',
3843
- statement_timestamp() + make_interval(secs => ${ttlSeconds}), ${params.source ?? null},
3844
- ${holderRef ?? null}, ${params.groupId ?? null}
4708
+ statement_timestamp() + make_interval(secs => ${holdTtlSeconds}), ${params.source ?? null},
4709
+ ${holderRef ?? null}, ${params.groupId ?? null}, ${holdPaymentRequired},
4710
+ ${holdPayment?.amountMinor ?? null}, ${holdPayment?.currency ?? null}
3845
4711
  )
3846
4712
  RETURNING id, resource_id, starts_at, ends_at, buffer_after_ms, expires_at, source,
3847
- hold_group_id`;
4713
+ hold_group_id, ${tx.unsafe(HOLD_PAYMENT_COLUMNS)}`;
3848
4714
  if (!row)
3849
4715
  throw new Error('slotlock: hold insert returned no row');
3850
4716
  if (keyed) {
@@ -3879,50 +4745,620 @@ function buildSlotlockStore(sql, options, refusal) {
3879
4745
  }
3880
4746
  }
3881
4747
  },
3882
- async getHold(params) {
4748
+ async putContact(params) {
3883
4749
  if (!isValidIdentity(params.tenantRef)) {
3884
4750
  throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
3885
4751
  }
3886
- if (!isValidResourceId(params.holdId))
3887
- return null;
3888
- const [row] = await sql `
3889
- SELECT hold.id, hold.resource_id, hold.starts_at, hold.ends_at, hold.buffer_after_ms,
3890
- hold.expires_at, hold.source
3891
- FROM slotlock.reservations hold
3892
- JOIN slotlock.resources resource ON resource.id = hold.resource_id
3893
- WHERE hold.id = ${params.holdId} AND resource.tenant_ref = ${params.tenantRef}
3894
- AND hold.status = 'held' AND hold.expires_at > statement_timestamp()`;
3895
- return row ? toHold(row) : null;
4752
+ if (!isValidContactRef(params.ref))
4753
+ return { ok: false, code: 'invalid_contact_ref' };
4754
+ const fields = contactFields(params);
4755
+ if (!fields)
4756
+ return { ok: false, code: 'invalid_contact' };
4757
+ return inTransaction(sql, async (tx) => {
4758
+ await lockExternalIdentity(tx, params.tenantRef, `contact:${params.ref}`);
4759
+ const [existing] = await tx `
4760
+ SELECT ref FROM slotlock.contacts WHERE tenant_ref = ${params.tenantRef} AND ref = ${params.ref}`;
4761
+ if (!existing) {
4762
+ await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify(['slotlock-contacts', params.tenantRef])}, 0))`;
4763
+ const [kept] = await tx `
4764
+ SELECT count(*)::int AS count FROM (
4765
+ SELECT 1 FROM slotlock.contacts WHERE tenant_ref = ${params.tenantRef}
4766
+ LIMIT ${SLOTLOCK_MAX_CONTACTS_PER_TENANT}) kept`;
4767
+ if ((kept?.count ?? 0) >= SLOTLOCK_MAX_CONTACTS_PER_TENANT) {
4768
+ return { ok: false, code: 'contact_limit_reached', limit: SLOTLOCK_MAX_CONTACTS_PER_TENANT };
4769
+ }
4770
+ }
4771
+ // When each channel's answer was given, kept while it stands: the proof of consent.
4772
+ const [before] = await tx `
4773
+ SELECT consent, consent_at FROM slotlock.contacts
4774
+ WHERE tenant_ref = ${params.tenantRef} AND ref = ${params.ref}`;
4775
+ const stamp = new Date().toISOString();
4776
+ const consentAt = {};
4777
+ for (const channel of ['email', 'sms', 'whatsapp']) {
4778
+ const answer = fields.consent[channel];
4779
+ if (answer === undefined)
4780
+ continue;
4781
+ const kept = before?.consent?.[channel] === answer ? before.consent_at?.[channel] : undefined;
4782
+ consentAt[channel] = kept ?? stamp;
4783
+ }
4784
+ const [row] = await tx `
4785
+ INSERT INTO slotlock.contacts (tenant_ref, ref, name, email, phone, whatsapp, consent, consent_at, locale)
4786
+ VALUES (${params.tenantRef}, ${params.ref}, ${fields.name}, ${fields.email}, ${fields.phone},
4787
+ ${fields.whatsapp}, ${tx.json(fields.consent)}, ${tx.json(consentAt)}, ${fields.locale})
4788
+ ON CONFLICT (tenant_ref, ref) DO UPDATE
4789
+ SET name = EXCLUDED.name, email = EXCLUDED.email, phone = EXCLUDED.phone,
4790
+ whatsapp = EXCLUDED.whatsapp, consent = EXCLUDED.consent, consent_at = EXCLUDED.consent_at,
4791
+ locale = EXCLUDED.locale, updated_at = statement_timestamp()
4792
+ RETURNING ${tx.unsafe(CONTACT_COLUMNS)}`;
4793
+ if (!row)
4794
+ throw new Error('slotlock: contact upsert returned no row');
4795
+ return { ok: true, contact: toContact(row), created: !existing };
4796
+ }, 'read committed');
3896
4797
  },
3897
- async getHolderHold(params) {
3898
- if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.holderRef)) {
3899
- throw invalidIdentityError('Slotlock tenant and holder must be 1-500 UTF-8 bytes');
4798
+ async getContact(params) {
4799
+ if (!isValidIdentity(params.tenantRef)) {
4800
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
3900
4801
  }
3901
- if (!isValidResourceId(params.holdId))
4802
+ if (!isValidContactRef(params.ref))
3902
4803
  return null;
3903
4804
  const [row] = await sql `
3904
- SELECT hold.id, hold.resource_id, hold.starts_at, hold.ends_at, hold.buffer_after_ms,
3905
- hold.expires_at, hold.source, hold.expires_at > statement_timestamp() AS live
3906
- FROM slotlock.reservations hold
3907
- JOIN slotlock.resources resource ON resource.id = hold.resource_id
3908
- WHERE hold.id = ${params.holdId} AND resource.tenant_ref = ${params.tenantRef}
3909
- AND hold.status = 'held' AND hold.holder_ref = ${params.holderRef}`;
3910
- return row ? { hold: toHold(row), live: row.live } : null;
4805
+ SELECT ${sql.unsafe(CONTACT_COLUMNS)}
4806
+ FROM slotlock.contacts WHERE tenant_ref = ${params.tenantRef} AND ref = ${params.ref}`;
4807
+ return row ? toContact(row) : null;
3911
4808
  },
3912
- async listHolds(params) {
4809
+ async listContacts(params) {
3913
4810
  if (!isValidIdentity(params.tenantRef)) {
3914
4811
  throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
3915
4812
  }
3916
- if (!isValidResourceId(params.resourceId)) {
3917
- throw invalidIdentityError('Slotlock resource id must be a UUID');
3918
- }
3919
4813
  const limit = pageLimit(params.limit);
3920
- const after = pageCursor(params.after);
4814
+ if (params.after !== undefined && !isValidContactRef(params.after))
4815
+ return [];
3921
4816
  const rows = await sql `
3922
- SELECT hold.id, hold.resource_id, hold.starts_at, hold.ends_at, hold.buffer_after_ms,
3923
- hold.expires_at, hold.source
3924
- FROM slotlock.reservations hold
3925
- JOIN slotlock.resources resource ON resource.id = hold.resource_id
4817
+ SELECT ${sql.unsafe(CONTACT_COLUMNS)} FROM slotlock.contacts
4818
+ WHERE tenant_ref = ${params.tenantRef}
4819
+ AND (${params.after ?? null}::text IS NULL OR ref > ${params.after ?? null})
4820
+ ORDER BY ref
4821
+ LIMIT ${limit}`;
4822
+ return rows.map(toContact);
4823
+ },
4824
+ async deleteContact(params) {
4825
+ if (!isValidIdentity(params.tenantRef)) {
4826
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4827
+ }
4828
+ if (!isValidContactRef(params.ref))
4829
+ return { deleted: false, bookings: 0 };
4830
+ return inTransaction(sql, async (tx) => {
4831
+ await lockExternalIdentity(tx, params.tenantRef, `contact:${params.ref}`);
4832
+ const deleted = await tx `
4833
+ DELETE FROM slotlock.contacts WHERE tenant_ref = ${params.tenantRef} AND ref = ${params.ref}
4834
+ RETURNING ref`;
4835
+ // Its reference leaves the bookings too; the feed keeps no row of the person (the change
4836
+ // trigger ignores a write that moves no time).
4837
+ const bookings = await tx `
4838
+ UPDATE slotlock.reservations SET contact_ref = NULL
4839
+ WHERE tenant_ref = ${params.tenantRef} AND contact_ref = ${params.ref}
4840
+ RETURNING id`;
4841
+ return { deleted: deleted.length > 0, bookings: bookings.length };
4842
+ }, 'read committed');
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
+ },
5220
+ async markHoldPaid(params) {
5221
+ if (!isValidIdentity(params.tenantRef))
5222
+ return { ok: false, code: 'invalid_identity' };
5223
+ if (!isValidPaymentRef(params.paymentRef))
5224
+ return { ok: false, code: 'invalid_payment_ref' };
5225
+ if (params.received !== undefined && !isValidAmount(params.received))
5226
+ return { ok: false, code: 'invalid_payment' };
5227
+ if (!isValidResourceId(params.holdId))
5228
+ return { ok: false, code: 'hold_not_found' };
5229
+ return inTransaction(sql, async (tx) => {
5230
+ // One payment record per hold, kept however the hold ends: the same payment told again
5231
+ // (a reloaded return page, a redelivered webhook) after the hold was booked is answered as
5232
+ // recorded, never as a refusal a payment adapter would refund.
5233
+ await lockExternalIdentity(tx, params.tenantRef, `hold-payment:${params.holdId}`);
5234
+ const [recorded] = await tx `
5235
+ SELECT hold_id AS id, resource_id, starts_at, ends_at, buffer_after_ms, expires_at,
5236
+ NULL::text AS source, true AS payment_required, payment_ref, paid_at,
5237
+ payment_amount_minor, payment_currency, paid_amount_minor, paid_currency,
5238
+ booked_at, approval_id
5239
+ FROM slotlock.hold_payments
5240
+ WHERE hold_id = ${params.holdId} AND tenant_ref = ${params.tenantRef}`;
5241
+ if (recorded) {
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 };
5250
+ }
5251
+ const [row] = await tx `
5252
+ SELECT hold.id, hold.resource_id, hold.starts_at, hold.ends_at, hold.buffer_after_ms,
5253
+ hold.expires_at, hold.source, hold.hold_group_id, hold.payment_required,
5254
+ hold.payment_ref, hold.paid_at, hold.payment_amount_minor, hold.payment_currency,
5255
+ hold.paid_amount_minor, hold.paid_currency, hold.status,
5256
+ hold.expires_at > statement_timestamp() AS live
5257
+ FROM slotlock.reservations hold
5258
+ JOIN slotlock.resources resource ON resource.id = hold.resource_id
5259
+ WHERE hold.id = ${params.holdId} AND resource.tenant_ref = ${params.tenantRef}
5260
+ AND hold.calendar_event_id IS NULL
5261
+ FOR UPDATE OF hold`;
5262
+ if (!row || row.status !== 'held')
5263
+ return { ok: false, code: 'hold_not_found' };
5264
+ if (!row.payment_required)
5265
+ return { ok: false, code: 'payment_not_required' };
5266
+ if (row.payment_ref !== null && row.payment_ref !== undefined) {
5267
+ return row.payment_ref === params.paymentRef
5268
+ ? { ok: true, hold: toHold(row), idempotent: true }
5269
+ : { ok: false, code: 'payment_conflict', paymentRef: row.payment_ref };
5270
+ }
5271
+ // Paid after it lapsed: the time may be someone else's by now. The integrator refunds.
5272
+ if (!row.live)
5273
+ return { ok: false, code: 'hold_expired' };
5274
+ // Less than the hold asks, or in another currency: not its payment (RFC 0004 C3).
5275
+ const expected = amountOf(row.payment_amount_minor, row.payment_currency);
5276
+ if (expected &&
5277
+ (!params.received ||
5278
+ params.received.currency !== expected.currency ||
5279
+ params.received.amountMinor < expected.amountMinor)) {
5280
+ return { ok: false, code: 'payment_amount_mismatch' };
5281
+ }
5282
+ const [paid] = await tx `
5283
+ UPDATE slotlock.reservations
5284
+ SET payment_ref = ${params.paymentRef}, paid_at = statement_timestamp(),
5285
+ paid_amount_minor = ${params.received?.amountMinor ?? null},
5286
+ paid_currency = ${params.received?.currency ?? null}
5287
+ WHERE id = ${params.holdId}
5288
+ RETURNING id, resource_id, starts_at, ends_at, buffer_after_ms, expires_at, source,
5289
+ hold_group_id, ${tx.unsafe(HOLD_PAYMENT_COLUMNS)}`;
5290
+ if (!paid)
5291
+ throw new Error('slotlock: hold payment update returned no row');
5292
+ await tx `
5293
+ INSERT INTO slotlock.hold_payments (
5294
+ tenant_ref, hold_id, resource_id, starts_at, ends_at, buffer_after_ms, expires_at,
5295
+ payment_ref, paid_at, payment_amount_minor, payment_currency, paid_amount_minor, paid_currency
5296
+ ) VALUES (
5297
+ ${params.tenantRef}, ${paid.id}, ${paid.resource_id}, ${paid.starts_at}, ${paid.ends_at},
5298
+ ${paid.buffer_after_ms}, ${paid.expires_at}, ${params.paymentRef}, ${paid.paid_at ?? null},
5299
+ ${paid.payment_amount_minor ?? null}, ${paid.payment_currency ?? null},
5300
+ ${params.received?.amountMinor ?? null}, ${params.received?.currency ?? null}
5301
+ )`;
5302
+ await tx `
5303
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
5304
+ VALUES (${params.tenantRef}, ${paid.resource_id}, 'hold.paid', ${paid.id},
5305
+ jsonb_build_object(
5306
+ 'id', ${paid.id}::uuid, 'resource_id', ${paid.resource_id}::uuid,
5307
+ 'start', ${paid.starts_at}::timestamptz, 'end', ${paid.ends_at}::timestamptz,
5308
+ 'expires_at', ${paid.expires_at}::timestamptz,
5309
+ 'payment_ref', ${params.paymentRef}::text,
5310
+ 'paid_at', ${paid.paid_at ?? null}::timestamptz,
5311
+ 'amount_minor', ${params.received?.amountMinor ?? null}::bigint,
5312
+ 'currency', ${params.received?.currency ?? null}::text),
5313
+ NULLIF(current_setting('slotlock.actor', true), ''))`;
5314
+ return { ok: true, hold: toHold(paid), idempotent: false };
5315
+ }, 'read committed');
5316
+ },
5317
+ async getHold(params) {
5318
+ if (!isValidIdentity(params.tenantRef)) {
5319
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
5320
+ }
5321
+ if (!isValidResourceId(params.holdId))
5322
+ return null;
5323
+ const [row] = await sql `
5324
+ SELECT hold.id, hold.resource_id, hold.starts_at, hold.ends_at, hold.buffer_after_ms,
5325
+ hold.expires_at, hold.source, ${sql.unsafe(HOLD_PAYMENT_COLUMNS_OF_HOLD)}
5326
+ FROM slotlock.reservations hold
5327
+ JOIN slotlock.resources resource ON resource.id = hold.resource_id
5328
+ WHERE hold.id = ${params.holdId} AND resource.tenant_ref = ${params.tenantRef}
5329
+ AND hold.status = 'held' AND hold.expires_at > statement_timestamp()`;
5330
+ return row ? toHold(row) : null;
5331
+ },
5332
+ async getHolderHold(params) {
5333
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.holderRef)) {
5334
+ throw invalidIdentityError('Slotlock tenant and holder must be 1-500 UTF-8 bytes');
5335
+ }
5336
+ if (!isValidResourceId(params.holdId))
5337
+ return null;
5338
+ const [row] = await sql `
5339
+ SELECT hold.id, hold.resource_id, hold.starts_at, hold.ends_at, hold.buffer_after_ms,
5340
+ hold.expires_at, hold.source, ${sql.unsafe(HOLD_PAYMENT_COLUMNS_OF_HOLD)},
5341
+ hold.expires_at > statement_timestamp() AS live
5342
+ FROM slotlock.reservations hold
5343
+ JOIN slotlock.resources resource ON resource.id = hold.resource_id
5344
+ WHERE hold.id = ${params.holdId} AND resource.tenant_ref = ${params.tenantRef}
5345
+ AND hold.status = 'held' AND hold.holder_ref = ${params.holderRef}`;
5346
+ return row ? { hold: toHold(row), live: row.live } : null;
5347
+ },
5348
+ async listHolds(params) {
5349
+ if (!isValidIdentity(params.tenantRef)) {
5350
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
5351
+ }
5352
+ if (!isValidResourceId(params.resourceId)) {
5353
+ throw invalidIdentityError('Slotlock resource id must be a UUID');
5354
+ }
5355
+ const limit = pageLimit(params.limit);
5356
+ const after = pageCursor(params.after);
5357
+ const rows = await sql `
5358
+ SELECT hold.id, hold.resource_id, hold.starts_at, hold.ends_at, hold.buffer_after_ms,
5359
+ hold.expires_at, hold.source, ${sql.unsafe(HOLD_PAYMENT_COLUMNS_OF_HOLD)}
5360
+ FROM slotlock.reservations hold
5361
+ JOIN slotlock.resources resource ON resource.id = hold.resource_id
3926
5362
  WHERE hold.resource_id = ${params.resourceId} AND resource.tenant_ref = ${params.tenantRef}
3927
5363
  AND hold.status = 'held' AND hold.expires_at > statement_timestamp()
3928
5364
  AND (${after.start}::timestamptz IS NULL
@@ -4079,13 +5515,43 @@ function buildSlotlockStore(sql, options, refusal) {
4079
5515
  if ((pending?.tenant ?? 0) >= SLOTLOCK_MAX_PENDING_APPROVALS) {
4080
5516
  return { ok: false, code: 'approval_limit_reached', limit: SLOTLOCK_MAX_PENDING_APPROVALS };
4081
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
+ }
4082
5543
  const id = randomUUID();
4083
5544
  // The requester's own hold of the time gives way to the approval's.
4084
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)`;
4085
5548
  await tx `
4086
5549
  DELETE FROM slotlock.reservations
4087
5550
  WHERE id = ${params.consumeHold.holdId} AND status = 'held'
4088
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}`;
4089
5555
  }
4090
5556
  const holds = [...params.holds].sort((left, right) => left.resourceId.localeCompare(right.resourceId) || left.start.getTime() - right.start.getTime());
4091
5557
  // Locked as any booking write locks them, in id order, so a racing booking takes turns.
@@ -4112,7 +5578,8 @@ function buildSlotlockStore(sql, options, refusal) {
4112
5578
  starts_at, ends_at, request_key, expires_at
4113
5579
  ) VALUES (
4114
5580
  ${id}, ${tenantRef}, ${params.policyRef}, ${params.operation}, ${tx.json(params.request)},
4115
- ${tx.json(params.requester)}, ${held.map((hold) => hold.id)}::uuid[],
5581
+ ${tx.json((holder ? { ...params.requester, holder_ref: holder.ref } : params.requester))},
5582
+ ${held.map((hold) => hold.id)}::uuid[],
4116
5583
  ${[...new Set(holds.map((hold) => hold.resourceId))]}::uuid[], ${params.start}, ${params.end},
4117
5584
  ${params.requestKey}, ${params.expiresAt}
4118
5585
  )
@@ -4239,7 +5706,7 @@ function buildSlotlockStore(sql, options, refusal) {
4239
5706
  }
4240
5707
  const [row] = await sql `
4241
5708
  SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
4242
- revision, source
5709
+ revision, source, contact_ref, notify, reminder_minutes
4243
5710
  FROM slotlock.reservations
4244
5711
  WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
4245
5712
  AND calendar_event_id IS NULL`;
@@ -4257,7 +5724,7 @@ function buildSlotlockStore(sql, options, refusal) {
4257
5724
  const after = pageCursor(params.after);
4258
5725
  const rows = await sql `
4259
5726
  SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
4260
- revision, source
5727
+ revision, source, contact_ref, notify, reminder_minutes
4261
5728
  FROM slotlock.reservations
4262
5729
  WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
4263
5730
  AND calendar_event_id IS NULL
@@ -4294,8 +5761,9 @@ function buildSlotlockStore(sql, options, refusal) {
4294
5761
  const resource = await lockResourceForWrite(tx, owner.resource_id, null);
4295
5762
  const [row] = await tx `
4296
5763
  SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
4297
- revision, source, status, (status = 'held' AND expires_at > statement_timestamp()) AS live,
4298
- hold_group_id, holder_ref
5764
+ revision, source, contact_ref, notify, reminder_minutes, status,
5765
+ (status = 'held' AND expires_at > statement_timestamp()) AS live,
5766
+ hold_group_id, holder_ref, (payment_required AND paid_at IS NULL) AS unpaid
4299
5767
  FROM slotlock.reservations
4300
5768
  WHERE id = ${holdId}
4301
5769
  FOR UPDATE`;
@@ -4314,8 +5782,16 @@ function buildSlotlockStore(sql, options, refusal) {
4314
5782
  }
4315
5783
  if (!row.live)
4316
5784
  return { ok: false, code: 'hold_expired' };
5785
+ // A hold that waits for payment is booked only once it is paid (RFC 0004 C3).
5786
+ if (row.unpaid)
5787
+ return { ok: false, code: 'payment_required' };
4317
5788
  if (resource?.archived !== false)
4318
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
+ }
4319
5795
  if (bookingTenant !== null) {
4320
5796
  const [retired] = await tx `
4321
5797
  SELECT revision FROM slotlock.reservation_tombstones
@@ -4340,9 +5816,12 @@ function buildSlotlockStore(sql, options, refusal) {
4340
5816
  tenant_ref = ${bookingTenant}, external_ref = ${bookingTenant === null ? null : holdId}
4341
5817
  WHERE id = ${holdId}
4342
5818
  RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
4343
- buffer_after_ms, revision, source`;
5819
+ buffer_after_ms, revision, source, contact_ref, notify, reminder_minutes`;
4344
5820
  if (!confirmed)
4345
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`;
4346
5825
  // Its linked alternatives (one group, one holder) are released with it, as booking one
4347
5826
  // from an agent releases the rest.
4348
5827
  if (row.hold_group_id !== null && row.holder_ref !== null) {
@@ -4423,6 +5902,8 @@ function buildSlotlockStore(sql, options, refusal) {
4423
5902
  AND (status <> 'held' OR expires_at > statement_timestamp())
4424
5903
  UNION ALL
4425
5904
  ${compositeBusy(sql, resourceId, window)}
5905
+ UNION ALL
5906
+ ${sourceBusy(sql, resourceId, window)}
4426
5907
  ) busy
4427
5908
  ORDER BY occupied_starts_at
4428
5909
  LIMIT ${options.limit ?? null}`;
@@ -4544,7 +6025,8 @@ function buildSlotlockStore(sql, options, refusal) {
4544
6025
  SELECT id, tenant_ref, owner_ref, external_ref, resource_id, starts_at, ends_at, timezone,
4545
6026
  summary, description, location, status, transparency, organizer, attendees,
4546
6027
  reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
4547
- 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
4548
6030
  FROM slotlock.calendar_events
4549
6031
  WHERE tenant_ref = ${params.tenantRef}
4550
6032
  AND owner_ref = ${ownerRef}
@@ -4560,6 +6042,34 @@ function buildSlotlockStore(sql, options, refusal) {
4560
6042
  if (params.expectedRevision !== currentRevision) {
4561
6043
  return { ok: false, code: 'revision_conflict', currentRevision };
4562
6044
  }
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() ||
6053
+ found.ends_at.getTime() !== event.end.getTime() ||
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;
6071
+ }
6072
+ }
4563
6073
  const series = expanded ?? resumeStoredSeries(event, materializationWindow, found);
4564
6074
  if (!series)
4565
6075
  return { ok: false, code: 'invalid_event' };
@@ -4593,6 +6103,7 @@ function buildSlotlockStore(sql, options, refusal) {
4593
6103
  }
4594
6104
  const eventId = found?.id ?? randomUUID();
4595
6105
  const revision = currentRevision + 1;
6106
+ let heldPaid = false;
4596
6107
  if (params.fromHold) {
4597
6108
  const { holdId, holderRef } = params.fromHold;
4598
6109
  if (!isValidResourceId(holdId) || !isValidIdentity(holderRef)) {
@@ -4600,16 +6111,22 @@ function buildSlotlockStore(sql, options, refusal) {
4600
6111
  }
4601
6112
  const [hold] = await tx `
4602
6113
  SELECT resource_id, starts_at, ends_at, hold_group_id,
4603
- expires_at > statement_timestamp() AS live
6114
+ expires_at > statement_timestamp() AS live,
6115
+ (payment_required AND paid_at IS NULL) AS unpaid,
6116
+ (payment_required AND paid_at IS NOT NULL) AS paid
4604
6117
  FROM slotlock.reservations
4605
6118
  WHERE id = ${holdId} AND status = 'held' AND holder_ref = ${holderRef}
4606
6119
  FOR UPDATE`;
4607
6120
  if (!hold)
4608
6121
  return { ok: false, code: 'hold_not_found' };
6122
+ heldPaid = hold.paid;
4609
6123
  // The holder's own hold that lapsed and is not yet reaped: named as the application
4610
6124
  // API's confirm names it. Another holder's stays not found.
4611
6125
  if (!hold.live)
4612
6126
  return { ok: false, code: 'hold_expired' };
6127
+ // A hold that waits for payment is booked only once paid (RFC 0004 C3).
6128
+ if (hold.unpaid)
6129
+ return { ok: false, code: 'payment_required' };
4613
6130
  const [only] = occurrences;
4614
6131
  if (hold.resource_id !== params.resourceId ||
4615
6132
  occurrences.length !== 1 ||
@@ -4618,17 +6135,35 @@ function buildSlotlockStore(sql, options, refusal) {
4618
6135
  only.end > hold.ends_at) {
4619
6136
  return { ok: false, code: 'hold_mismatch' };
4620
6137
  }
6138
+ // The hold booked is accepted, not let go (slotlock.waitlist_offer_settle); the rest of
6139
+ // its group is released.
6140
+ await tx `SELECT set_config('slotlock.ending', 'confirmed', true)`;
6141
+ await tx `DELETE FROM slotlock.reservations WHERE id = ${holdId} AND status = 'held' AND holder_ref = ${holderRef}`;
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`;
6146
+ // A set held together (RFC 0004 B7) is booked member by member: its other holds stay.
4621
6147
  await tx `
4622
6148
  DELETE FROM slotlock.reservations
4623
6149
  WHERE status = 'held' AND holder_ref = ${holderRef}
4624
6150
  AND (id = ${holdId}
4625
6151
  OR (${hold.hold_group_id}::uuid IS NOT NULL
4626
- AND hold_group_id = ${hold.hold_group_id}::uuid))`;
6152
+ AND hold_group_id = ${hold.hold_group_id}::uuid
6153
+ AND source IS DISTINCT FROM 'slotlock:together'))`;
4627
6154
  }
4628
6155
  let turnaroundMs = 0;
4629
6156
  let bufferBeforeMs = 0;
4630
- if (params.enforceBookingRules === true && (event.transparency ?? 'opaque') === 'opaque') {
4631
- const judge = await bookingJudge(tx, params.resourceId, defaultHours);
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) {
4632
6167
  for (const [index, occurrence] of occurrences.entries()) {
4633
6168
  // A held time's notice and advance were judged when it was held.
4634
6169
  const rule = judge.broken(occurrence, { first: index === 0 && !params.fromHold });
@@ -4679,6 +6214,10 @@ function buildSlotlockStore(sql, options, refusal) {
4679
6214
  source = ${params.source ?? null},
4680
6215
  buffer_after_ms = ${turnaroundMs},
4681
6216
  buffer_before_ms = ${bufferBeforeMs},
6217
+ -- Whether anyone should be told (RFC 0004 P3): as sent, like the rest.
6218
+ notify = ${params.notify !== false},
6219
+ -- Kept, or set by a late move (RFC 0004 B5).
6220
+ cutoff_anchor = COALESCE(${movedAnchor}::timestamptz, cutoff_anchor),
4682
6221
  updated_at = now()
4683
6222
  WHERE id = ${eventId}
4684
6223
  AND tenant_ref = ${params.tenantRef}
@@ -4691,7 +6230,7 @@ function buildSlotlockStore(sql, options, refusal) {
4691
6230
  summary, description, location, status, transparency, organizer, attendees,
4692
6231
  reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
4693
6232
  materialized_ends_at, recurrence_cursor, recurrence_ends_by, revision, source,
4694
- buffer_after_ms, buffer_before_ms
6233
+ buffer_after_ms, buffer_before_ms, notify
4695
6234
  ) VALUES (
4696
6235
  ${eventId}, ${params.tenantRef}, ${ownerRef}, ${params.externalRef},
4697
6236
  ${params.resourceId},
@@ -4705,7 +6244,8 @@ function buildSlotlockStore(sql, options, refusal) {
4705
6244
  ${tx.json(eventExceptionsForJson(event.recurrence?.exceptions))},
4706
6245
  ${materializationWindow.start}, ${materializationWindow.end},
4707
6246
  ${seriesCursorJson(tx, series.cursor)}, ${recurrenceEndsBy}::text::timestamptz,
4708
- ${revision}, ${params.source ?? null}, ${turnaroundMs}, ${bufferBeforeMs}
6247
+ ${revision}, ${params.source ?? null}, ${turnaroundMs}, ${bufferBeforeMs},
6248
+ ${params.notify !== false}
4709
6249
  )`;
4710
6250
  }
4711
6251
  // The normalized children are canonical; the JSON columns above are a compatibility
@@ -4717,6 +6257,15 @@ function buildSlotlockStore(sql, options, refusal) {
4717
6257
  return { ok: false, code: 'resource_tenant_mismatch' };
4718
6258
  if (locked.archived)
4719
6259
  return { ok: false, code: 'resource_archived' };
6260
+ // A booking (held to the rules, as agents' are) never takes busy time a calendar reported
6261
+ // without its events (RFC 0004 C1); events imported from calendars are that time.
6262
+ if (params.enforceBookingRules === true && (event.transparency ?? 'opaque') === 'opaque') {
6263
+ for (const occurrence of occurrences) {
6264
+ if (await sourceBusyLoss(tx, params.resourceId, new Date(occurrence.start.getTime() - bufferBeforeMs), new Date(occurrence.end.getTime() + turnaroundMs))) {
6265
+ return { ok: false, code: 'overlap', conflictingReservationId: null };
6266
+ }
6267
+ }
6268
+ }
4720
6269
  await replaceCalendarEventOccurrences(tx, {
4721
6270
  tenantRef: params.tenantRef,
4722
6271
  eventId,
@@ -5042,7 +6591,8 @@ function buildSlotlockStore(sql, options, refusal) {
5042
6591
  };
5043
6592
  }
5044
6593
  const events = await tx `
5045
- SELECT id, resource_id, revision FROM slotlock.calendar_events
6594
+ SELECT id, resource_id, revision, starts_at, recurrence_rule IS NOT NULL AS recurring, cutoff_anchor
6595
+ FROM slotlock.calendar_events
5046
6596
  WHERE tenant_ref = ${params.tenantRef}
5047
6597
  AND owner_ref = ${ownerRef}
5048
6598
  AND external_ref = ${params.externalRef}
@@ -5059,7 +6609,10 @@ function buildSlotlockStore(sql, options, refusal) {
5059
6609
  if (params.expectedRevision !== currentRevision) {
5060
6610
  return { ok: false, code: 'revision_conflict', currentRevision };
5061
6611
  }
5062
- return finishCalendarCancel(tx, {
6612
+ const ending = found ? await eventEnding(tx, found, params.enforceBookingRules === true, undefined) : null;
6613
+ if (ending && !ending.ok)
6614
+ return ending.refusal;
6615
+ const cancelled = await finishCalendarCancel(tx, {
5063
6616
  tenantRef: params.tenantRef,
5064
6617
  ownerRef,
5065
6618
  externalRef: params.externalRef,
@@ -5069,9 +6622,15 @@ function buildSlotlockStore(sql, options, refusal) {
5069
6622
  idempotencyKey: params.idempotencyKey,
5070
6623
  payloadHash,
5071
6624
  });
6625
+ if (ending)
6626
+ await noteEnding(tx, null, null);
6627
+ return cancelled.ok && ending?.fee ? { ...cancelled, cancellationFee: ending.fee } : cancelled;
5072
6628
  });
5073
6629
  },
5074
6630
  async cancelCalendarEventById(params) {
6631
+ if (params.overrideReason !== undefined && !isValidOverrideReason(params.overrideReason)) {
6632
+ return { ok: false, code: 'invalid_identity' };
6633
+ }
5075
6634
  if (!isValidIdentity(params.tenantRef) ||
5076
6635
  !isValidResourceId(params.eventId) ||
5077
6636
  !isValidIdentity(params.idempotencyKey) ||
@@ -5122,7 +6681,8 @@ function buildSlotlockStore(sql, options, refusal) {
5122
6681
  return { ok: false, code: 'event_not_found' };
5123
6682
  await lockCalendarEventIdentity(tx, params.tenantRef, identity.owner_ref, identity.external_ref);
5124
6683
  const [found] = await tx `
5125
- SELECT id, resource_id, revision FROM slotlock.calendar_events
6684
+ SELECT id, resource_id, revision, starts_at, recurrence_rule IS NOT NULL AS recurring, cutoff_anchor
6685
+ FROM slotlock.calendar_events
5126
6686
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.eventId}
5127
6687
  FOR UPDATE`;
5128
6688
  if (!found)
@@ -5135,7 +6695,11 @@ function buildSlotlockStore(sql, options, refusal) {
5135
6695
  if (params.expectedRevision !== currentRevision) {
5136
6696
  return { ok: false, code: 'revision_conflict', currentRevision };
5137
6697
  }
5138
- return finishCalendarCancel(tx, {
6698
+ // The tenant's own cancellation is held to the cutoff too, unless an admin gives a reason.
6699
+ const ending = await eventEnding(tx, found, true, params.overrideReason);
6700
+ if (!ending.ok)
6701
+ return ending.refusal;
6702
+ const cancelled = await finishCalendarCancel(tx, {
5139
6703
  tenantRef: params.tenantRef,
5140
6704
  ownerRef: identity.owner_ref,
5141
6705
  externalRef: identity.external_ref,
@@ -5145,6 +6709,8 @@ function buildSlotlockStore(sql, options, refusal) {
5145
6709
  idempotencyKey: params.idempotencyKey,
5146
6710
  payloadHash,
5147
6711
  });
6712
+ await noteEnding(tx, null, null);
6713
+ return cancelled.ok && ending.fee ? { ...cancelled, cancellationFee: ending.fee } : cancelled;
5148
6714
  });
5149
6715
  },
5150
6716
  async followZoneRules(params) {
@@ -5228,7 +6794,197 @@ function buildSlotlockStore(sql, options, refusal) {
5228
6794
  conflicts++;
5229
6795
  }
5230
6796
  }
5231
- return { shifted: shifted.length, moved, conflicts };
6797
+ // A series' occurrences to come (RFC 0004 B9): said on the feed, once each; with `move`, each
6798
+ // series moves whole, in one transaction, where Node's zone rules agree with the database's.
6799
+ const occurrences = await sql `
6800
+ SELECT occurrence.event_id, occurrence.recurrence_id, event.resource_id, occurrence.reservation_id,
6801
+ event.timezone, to_char(occurrence.wall_starts_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS wall,
6802
+ occurrence.starts_at, occurrence.ends_at,
6803
+ slotlock.zoned_instant(occurrence.wall_starts_at, event.timezone) AS moved_start
6804
+ FROM slotlock.calendar_event_occurrences occurrence
6805
+ JOIN slotlock.calendar_events event ON event.id = occurrence.event_id
6806
+ WHERE event.tenant_ref = ${params.tenantRef} AND event.recurrence_rule IS NOT NULL
6807
+ AND occurrence.starts_at > now() AND occurrence.zone_shift_reported_at IS NULL
6808
+ AND occurrence.wall_starts_at IS NOT NULL
6809
+ AND slotlock.zoned_instant(occurrence.wall_starts_at, event.timezone) IS NOT NULL
6810
+ AND slotlock.zoned_instant(occurrence.wall_starts_at, event.timezone) <> occurrence.starts_at
6811
+ ORDER BY occurrence.starts_at, occurrence.event_id
6812
+ LIMIT ${limit}`;
6813
+ const reportOccurrence = async (occurrence, type, reason) => {
6814
+ await sql `
6815
+ UPDATE slotlock.calendar_event_occurrences SET zone_shift_reported_at = now()
6816
+ WHERE event_id = ${occurrence.event_id} AND recurrence_id = ${occurrence.recurrence_id}`;
6817
+ const shift = occurrence.moved_start.getTime() - occurrence.starts_at.getTime();
6818
+ await sql `
6819
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
6820
+ VALUES (${params.tenantRef}, ${occurrence.resource_id}, ${type}, ${occurrence.event_id},
6821
+ ${sql.json({
6822
+ id: occurrence.event_id,
6823
+ recurrence_id: occurrence.recurrence_id,
6824
+ resource_id: occurrence.resource_id,
6825
+ start: occurrence.starts_at.toISOString(),
6826
+ end: occurrence.ends_at.toISOString(),
6827
+ zone_start: occurrence.moved_start.toISOString(),
6828
+ zone_end: new Date(occurrence.ends_at.getTime() + shift).toISOString(),
6829
+ ...(reason ? { reason } : {}),
6830
+ })}, 'slotlock:tzdata')`;
6831
+ };
6832
+ const series = new Map();
6833
+ for (const occurrence of occurrences) {
6834
+ const list = series.get(occurrence.event_id) ?? [];
6835
+ list.push(occurrence);
6836
+ series.set(occurrence.event_id, list);
6837
+ }
6838
+ for (const batch of series.values()) {
6839
+ if (!params.move) {
6840
+ for (const occurrence of batch)
6841
+ await reportOccurrence(occurrence, 'event.timezone_shifted');
6842
+ continue;
6843
+ }
6844
+ // The whole series moves at once: every occurrence to come that shifted, past the batch.
6845
+ const shiftedOccurrences = await sql `
6846
+ SELECT occurrence.event_id, occurrence.recurrence_id, event.resource_id, occurrence.reservation_id,
6847
+ event.timezone, to_char(occurrence.wall_starts_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS wall,
6848
+ occurrence.starts_at, occurrence.ends_at,
6849
+ slotlock.zoned_instant(occurrence.wall_starts_at, event.timezone) AS moved_start
6850
+ FROM slotlock.calendar_event_occurrences occurrence
6851
+ JOIN slotlock.calendar_events event ON event.id = occurrence.event_id
6852
+ WHERE event.tenant_ref = ${params.tenantRef} AND occurrence.event_id = ${batch[0].event_id}
6853
+ AND occurrence.starts_at > now() AND occurrence.zone_shift_reported_at IS NULL
6854
+ AND occurrence.wall_starts_at IS NOT NULL
6855
+ AND slotlock.zoned_instant(occurrence.wall_starts_at, event.timezone) IS NOT NULL
6856
+ AND slotlock.zoned_instant(occurrence.wall_starts_at, event.timezone) <> occurrence.starts_at
6857
+ ORDER BY occurrence.starts_at`;
6858
+ if (shiftedOccurrences.length === 0)
6859
+ continue;
6860
+ // Moved only where this server's zone rules put each wall time where the database's do: a
6861
+ // server with older rules would compute the old times again on the series' next write.
6862
+ const agree = shiftedOccurrences.every((occurrence) => {
6863
+ const wall = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/.exec(occurrence.wall);
6864
+ if (!wall)
6865
+ return false;
6866
+ try {
6867
+ const node = zonedDateTimeToUtc({
6868
+ year: Number(wall[1]),
6869
+ month: Number(wall[2]),
6870
+ day: Number(wall[3]),
6871
+ hour: Number(wall[4]),
6872
+ minute: Number(wall[5]),
6873
+ second: Number(wall[6]),
6874
+ }, occurrence.timezone);
6875
+ return node.getTime() === occurrence.moved_start.getTime();
6876
+ }
6877
+ catch {
6878
+ return false;
6879
+ }
6880
+ });
6881
+ if (!agree) {
6882
+ for (const occurrence of shiftedOccurrences)
6883
+ await reportOccurrence(occurrence, 'event.timezone_shifted', 'rules_disagree');
6884
+ continue;
6885
+ }
6886
+ const eventId = shiftedOccurrences[0].event_id;
6887
+ try {
6888
+ await inTransaction(sql, async (tx) => {
6889
+ await tx `SELECT set_config('slotlock.actor', 'slotlock:tzdata', true)`;
6890
+ for (const occurrence of shiftedOccurrences) {
6891
+ const movedEnd = new Date(occurrence.ends_at.getTime() + occurrence.moved_start.getTime() - occurrence.starts_at.getTime());
6892
+ await tx `
6893
+ UPDATE slotlock.calendar_event_occurrences
6894
+ SET starts_at = ${occurrence.moved_start}, ends_at = ${movedEnd}
6895
+ WHERE event_id = ${eventId} AND recurrence_id = ${occurrence.recurrence_id}`;
6896
+ if (occurrence.reservation_id !== null) {
6897
+ await tx `
6898
+ UPDATE slotlock.reservations SET starts_at = ${occurrence.moved_start}, ends_at = ${movedEnd}
6899
+ WHERE id = ${occurrence.reservation_id}::uuid`;
6900
+ }
6901
+ }
6902
+ // The series at its next revision; its anchor too, while it is still to come.
6903
+ const [bumped] = await tx `
6904
+ UPDATE slotlock.calendar_events
6905
+ SET starts_at = CASE WHEN starts_at > now()
6906
+ THEN COALESCE(slotlock.zoned_instant(wall_starts_at, timezone), starts_at)
6907
+ ELSE starts_at END,
6908
+ ends_at = CASE WHEN starts_at > now()
6909
+ THEN COALESCE(slotlock.zoned_instant(wall_ends_at, timezone), ends_at)
6910
+ ELSE ends_at END,
6911
+ revision = revision + 1, updated_at = now()
6912
+ WHERE id = ${eventId}
6913
+ RETURNING revision`;
6914
+ await tx `UPDATE slotlock.reservations SET revision = ${Number(bumped?.revision)} WHERE calendar_event_id = ${eventId}`;
6915
+ });
6916
+ moved++;
6917
+ }
6918
+ catch (error) {
6919
+ if (pgCode(error) !== EXCLUSION_VIOLATION)
6920
+ throw error;
6921
+ // Something is in the way: the series keeps its times, said once, at its first shifted one.
6922
+ await reportOccurrence(shiftedOccurrences[0], 'event.timezone_conflict');
6923
+ for (const occurrence of shiftedOccurrences.slice(1)) {
6924
+ await sql `
6925
+ UPDATE slotlock.calendar_event_occurrences SET zone_shift_reported_at = now()
6926
+ WHERE event_id = ${occurrence.event_id} AND recurrence_id = ${occurrence.recurrence_id}`;
6927
+ }
6928
+ conflicts++;
6929
+ }
6930
+ }
6931
+ // Reservations that carry the wall time they were booked for (RFC 0004 B9).
6932
+ const reservations = await sql `
6933
+ SELECT booking.id, booking.external_ref, booking.resource_id, booking.starts_at, booking.ends_at,
6934
+ slotlock.zoned_instant(booking.wall_starts_at, resource.timezone) AS moved_start
6935
+ FROM slotlock.reservations booking
6936
+ JOIN slotlock.resources resource ON resource.id = booking.resource_id
6937
+ WHERE booking.tenant_ref = ${params.tenantRef} AND resource.tenant_ref = ${params.tenantRef}
6938
+ AND booking.status = 'confirmed' AND booking.calendar_event_id IS NULL
6939
+ AND booking.starts_at > now() AND booking.wall_starts_at IS NOT NULL
6940
+ AND booking.zone_shift_reported_at IS NULL
6941
+ AND slotlock.zoned_instant(booking.wall_starts_at, resource.timezone) IS NOT NULL
6942
+ AND slotlock.zoned_instant(booking.wall_starts_at, resource.timezone) <> booking.starts_at
6943
+ ORDER BY booking.starts_at, booking.id
6944
+ LIMIT ${limit}`;
6945
+ const reportReservation = async (booking, type) => {
6946
+ await sql `UPDATE slotlock.reservations SET zone_shift_reported_at = now() WHERE id = ${booking.id}`;
6947
+ const shift = booking.moved_start.getTime() - booking.starts_at.getTime();
6948
+ await sql `
6949
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
6950
+ VALUES (${params.tenantRef}, ${booking.resource_id}, ${type}, ${booking.external_ref},
6951
+ ${sql.json({
6952
+ id: booking.id,
6953
+ reference: booking.external_ref,
6954
+ resource_id: booking.resource_id,
6955
+ start: booking.starts_at.toISOString(),
6956
+ end: booking.ends_at.toISOString(),
6957
+ zone_start: booking.moved_start.toISOString(),
6958
+ zone_end: new Date(booking.ends_at.getTime() + shift).toISOString(),
6959
+ })}, 'slotlock:tzdata')`;
6960
+ };
6961
+ for (const booking of reservations) {
6962
+ if (!params.move) {
6963
+ await reportReservation(booking, 'reservation.timezone_shifted');
6964
+ continue;
6965
+ }
6966
+ try {
6967
+ await inTransaction(sql, async (tx) => {
6968
+ await tx `SELECT set_config('slotlock.actor', 'slotlock:tzdata', true)`;
6969
+ const shift = booking.moved_start.getTime() - booking.starts_at.getTime();
6970
+ // The wall time it was booked for, at the instant the rules now give it: a move at the
6971
+ // next revision, on the feed as any move.
6972
+ await tx `
6973
+ UPDATE slotlock.reservations
6974
+ SET starts_at = ${booking.moved_start}, ends_at = ${new Date(booking.ends_at.getTime() + shift)},
6975
+ revision = revision + 1
6976
+ WHERE id = ${booking.id}`;
6977
+ });
6978
+ moved++;
6979
+ }
6980
+ catch (error) {
6981
+ if (pgCode(error) !== EXCLUSION_VIOLATION)
6982
+ throw error;
6983
+ await reportReservation(booking, 'reservation.timezone_conflict');
6984
+ conflicts++;
6985
+ }
6986
+ }
6987
+ return { shifted: shifted.length + occurrences.length + reservations.length, moved, conflicts };
5232
6988
  },
5233
6989
  async pruneBookingRetention(params) {
5234
6990
  if (!isValidIdentity(params.tenantRef)) {
@@ -5311,6 +7067,18 @@ function buildSlotlockStore(sql, options, refusal) {
5311
7067
  )
5312
7068
  SELECT count(*)::int AS deleted FROM deleted`;
5313
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`;
5314
7082
  return {
5315
7083
  expiredHoldsDeleted,
5316
7084
  holdCommandsDeleted,
@@ -5318,7 +7086,8 @@ function buildSlotlockStore(sql, options, refusal) {
5318
7086
  hasMore: legacy.length === limit ||
5319
7087
  expiredHoldsDeleted === limit ||
5320
7088
  holdCommandsDeleted === limit ||
5321
- tombstonesDeleted === limit,
7089
+ tombstonesDeleted === limit ||
7090
+ Number(busy?.deleted ?? 0) === limit,
5322
7091
  };
5323
7092
  });
5324
7093
  },
@@ -5488,6 +7257,165 @@ function buildSlotlockStore(sql, options, refusal) {
5488
7257
  return { ok: true, revision: params.revision, idempotent: false };
5489
7258
  });
5490
7259
  },
7260
+ async replaceSourceBusy(params) {
7261
+ if (!isValidIdentity(params.tenantRef) ||
7262
+ !isValidIdentity(params.source) ||
7263
+ !isValidResourceId(params.resourceId)) {
7264
+ return { ok: false, code: 'invalid_identity' };
7265
+ }
7266
+ let window;
7267
+ try {
7268
+ window = calendarWindow(params.window);
7269
+ }
7270
+ catch {
7271
+ return { ok: false, code: 'invalid_window' };
7272
+ }
7273
+ const busy = mergeSourceBusy(params.busy, window);
7274
+ if (busy === null)
7275
+ return { ok: false, code: 'invalid_busy' };
7276
+ return inTransaction(sql, async (tx) => {
7277
+ // In turn with every booking on the resource, each of which reads this time (sourceBusyLoss):
7278
+ // a booking either sees the new busy time or is seen by it here.
7279
+ if (!(await lockResourceForWrite(tx, params.resourceId, params.tenantRef))) {
7280
+ return { ok: false, code: 'resource_tenant_mismatch' };
7281
+ }
7282
+ const recorded = await buildSlotlockStore(tx, options, null).recordCalendarCoverage({
7283
+ tenantRef: params.tenantRef,
7284
+ resourceId: params.resourceId,
7285
+ source: params.source,
7286
+ window,
7287
+ revision: params.revision,
7288
+ });
7289
+ if (!recorded.ok)
7290
+ return recorded;
7291
+ if (recorded.idempotent)
7292
+ return { ...recorded, busy: busy.length, conflicts: 0 };
7293
+ const starts = busy.map((interval) => interval.start.toISOString());
7294
+ const ends = busy.map((interval) => interval.end.toISOString());
7295
+ // Confirmed bookings in the way of what the source now says, but not of what it said
7296
+ // before: a conflict already reported is not reported again on every read.
7297
+ const conflicts = await tx `
7298
+ WITH reported AS (
7299
+ SELECT starts_at, ends_at
7300
+ FROM ROWS FROM (pg_catalog.unnest(${starts}::text[]::timestamptz[]),
7301
+ pg_catalog.unnest(${ends}::text[]::timestamptz[])) AS busy(starts_at, ends_at)
7302
+ )
7303
+ SELECT DISTINCT ON (booking.id)
7304
+ booking.id, booking.external_ref, booking.calendar_event_id,
7305
+ booking.starts_at, booking.ends_at,
7306
+ reported.starts_at AS busy_start, reported.ends_at AS busy_end
7307
+ FROM slotlock.reservations booking
7308
+ JOIN reported ON booking.starts_at < reported.ends_at AND booking.ends_at > reported.starts_at
7309
+ WHERE booking.resource_id IN (${compositeKin(tx, params.resourceId)})
7310
+ AND booking.status = 'confirmed'
7311
+ AND booking.starts_at < ${window.end} AND booking.ends_at > ${window.start}
7312
+ AND NOT EXISTS (
7313
+ SELECT 1 FROM slotlock.source_busy previous
7314
+ WHERE previous.resource_id = ${params.resourceId} AND previous.source = ${params.source}
7315
+ AND previous.starts_at < booking.ends_at AND previous.ends_at > booking.starts_at)
7316
+ ORDER BY booking.id, reported.starts_at`;
7317
+ // What the source said of the window goes; what it said outside it stays.
7318
+ await tx `
7319
+ WITH removed AS (
7320
+ DELETE FROM slotlock.source_busy
7321
+ WHERE resource_id = ${params.resourceId} AND source = ${params.source}
7322
+ AND starts_at < ${window.end} AND ends_at > ${window.start}
7323
+ RETURNING starts_at, ends_at
7324
+ )
7325
+ INSERT INTO slotlock.source_busy (tenant_ref, resource_id, source, starts_at, ends_at)
7326
+ SELECT ${params.tenantRef}::text, ${params.resourceId}::uuid, ${params.source}::text, starts_at,
7327
+ ${window.start}::timestamptz
7328
+ FROM removed WHERE starts_at < ${window.start}
7329
+ UNION ALL
7330
+ SELECT ${params.tenantRef}::text, ${params.resourceId}::uuid, ${params.source}::text,
7331
+ ${window.end}::timestamptz, ends_at
7332
+ FROM removed WHERE ends_at > ${window.end}`;
7333
+ if (busy.length > 0) {
7334
+ await tx `
7335
+ INSERT INTO slotlock.source_busy (tenant_ref, resource_id, source, starts_at, ends_at)
7336
+ SELECT ${params.tenantRef}::text, ${params.resourceId}::uuid, ${params.source}::text, starts_at, ends_at
7337
+ FROM ROWS FROM (pg_catalog.unnest(${starts}::text[]::timestamptz[]),
7338
+ pg_catalog.unnest(${ends}::text[]::timestamptz[])) AS busy(starts_at, ends_at)`;
7339
+ }
7340
+ if (conflicts.length > 0) {
7341
+ // Named as every coverage change is, by the source's source_id (never the source); the
7342
+ // booking it is in the way of is in the data (an event's by its id).
7343
+ await tx `
7344
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
7345
+ SELECT ${params.tenantRef}, ${params.resourceId}, 'coverage.conflict', coverage.source_id,
7346
+ jsonb_strip_nulls(jsonb_build_object(
7347
+ 'resource_id', ${params.resourceId}::uuid,
7348
+ 'source_id', coverage.source_id,
7349
+ 'reason', 'busy_in_calendar',
7350
+ 'id', CASE WHEN conflict.event_id IS NULL THEN conflict.id END,
7351
+ 'reference', conflict.reference,
7352
+ 'event_id', conflict.event_id,
7353
+ 'start', conflict.starts_at, 'end', conflict.ends_at,
7354
+ 'busy_start', conflict.busy_start, 'busy_end', conflict.busy_end)),
7355
+ NULLIF(current_setting('slotlock.actor', true), '')
7356
+ FROM ROWS FROM (
7357
+ pg_catalog.unnest(${conflicts.map((row) => row.id)}::text[]::uuid[]),
7358
+ pg_catalog.unnest(${conflicts.map((row) => row.external_ref)}::text[]),
7359
+ pg_catalog.unnest(${conflicts.map((row) => row.calendar_event_id)}::text[]::uuid[]),
7360
+ pg_catalog.unnest(${conflicts.map((row) => row.starts_at.toISOString())}::text[]::timestamptz[]),
7361
+ pg_catalog.unnest(${conflicts.map((row) => row.ends_at.toISOString())}::text[]::timestamptz[]),
7362
+ pg_catalog.unnest(${conflicts.map((row) => row.busy_start.toISOString())}::text[]::timestamptz[]),
7363
+ pg_catalog.unnest(${conflicts.map((row) => row.busy_end.toISOString())}::text[]::timestamptz[]))
7364
+ AS conflict(id, reference, event_id, starts_at, ends_at, busy_start, busy_end)
7365
+ CROSS JOIN (SELECT source_id FROM slotlock.calendar_coverage
7366
+ WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
7367
+ AND source = ${params.source}) coverage`;
7368
+ }
7369
+ return { ...recorded, busy: busy.length, conflicts: conflicts.length };
7370
+ }, 'read committed');
7371
+ },
7372
+ async reportCoverageConflict(params) {
7373
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.source) || !isValidResourceId(params.resourceId)) {
7374
+ return { ok: false, code: 'invalid_identity' };
7375
+ }
7376
+ const byEvent = 'eventId' in params.booking;
7377
+ if (byEvent ? !isValidResourceId(params.booking.eventId) : !isValidIdentity(params.booking.reservationRef)) {
7378
+ return { ok: false, code: 'invalid_identity' };
7379
+ }
7380
+ if (params.reason !== 'changed_in_calendar' && params.reason !== 'removed_from_calendar') {
7381
+ return { ok: false, code: 'invalid_identity' };
7382
+ }
7383
+ if (params.calendar !== undefined && !isFiniteWindow(params.calendar))
7384
+ return { ok: false, code: 'invalid_window' };
7385
+ return inTransaction(sql, async (tx) => {
7386
+ const [coverage] = await tx `
7387
+ SELECT source_id FROM slotlock.calendar_coverage
7388
+ WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId} AND source = ${params.source}`;
7389
+ if (!coverage)
7390
+ return { ok: false, code: 'coverage_not_found' };
7391
+ const [booking] = byEvent
7392
+ ? await tx `
7393
+ SELECT id, NULL::text AS reference, starts_at, ends_at FROM slotlock.calendar_events
7394
+ WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
7395
+ AND id = ${params.booking.eventId}::uuid`
7396
+ : await tx `
7397
+ SELECT id, external_ref AS reference, starts_at, ends_at FROM slotlock.reservations
7398
+ WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
7399
+ AND external_ref = ${params.booking.reservationRef}
7400
+ AND calendar_event_id IS NULL AND status = 'confirmed'`;
7401
+ if (!booking) {
7402
+ return { ok: false, code: byEvent ? 'event_not_found' : 'reservation_not_found' };
7403
+ }
7404
+ await tx `
7405
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
7406
+ VALUES (${params.tenantRef}, ${params.resourceId}, 'coverage.conflict', ${coverage.source_id},
7407
+ jsonb_strip_nulls(jsonb_build_object(
7408
+ 'resource_id', ${params.resourceId}::uuid, 'source_id', ${coverage.source_id}::text,
7409
+ 'reason', ${params.reason}::text,
7410
+ 'id', ${byEvent ? null : booking.id}::uuid, 'reference', ${booking.reference}::text,
7411
+ 'event_id', ${byEvent ? booking.id : null}::uuid,
7412
+ 'start', ${booking.starts_at}::timestamptz, 'end', ${booking.ends_at}::timestamptz,
7413
+ 'calendar_start', ${params.calendar?.start ?? null}::timestamptz,
7414
+ 'calendar_end', ${params.calendar?.end ?? null}::timestamptz)),
7415
+ NULLIF(current_setting('slotlock.actor', true), ''))`;
7416
+ return { ok: true };
7417
+ }, 'read committed');
7418
+ },
5491
7419
  async recordCoverageFailure(params) {
5492
7420
  if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.source) || !isValidResourceId(params.resourceId)) {
5493
7421
  return { ok: false, code: 'invalid_identity' };
@@ -5506,6 +7434,11 @@ function buildSlotlockStore(sql, options, refusal) {
5506
7434
  return false;
5507
7435
  }
5508
7436
  const rows = await sql `
7437
+ WITH busy AS (
7438
+ DELETE FROM slotlock.source_busy
7439
+ WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
7440
+ AND source = ${params.source}
7441
+ )
5509
7442
  DELETE FROM slotlock.calendar_coverage
5510
7443
  WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
5511
7444
  AND source = ${params.source}
@@ -5535,6 +7468,110 @@ function buildSlotlockStore(sql, options, refusal) {
5535
7468
  stale: row.stale,
5536
7469
  }));
5537
7470
  },
7471
+ async dueReminders(params) {
7472
+ if (!isValidIdentity(params.tenantRef)) {
7473
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
7474
+ }
7475
+ const limit = params.limit ?? 500;
7476
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 5_000) {
7477
+ throw new RangeError('limit must be a whole number from 1 to 5000');
7478
+ }
7479
+ const rows = await sql `
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,
7485
+ reminder.minutes_before_start, occurrence.starts_at, occurrence.ends_at,
7486
+ event.resource_id,
7487
+ occurrence.starts_at - make_interval(mins => reminder.minutes_before_start) AS due_at
7488
+ FROM slotlock.calendar_event_occurrences occurrence
7489
+ JOIN slotlock.calendar_events event
7490
+ ON event.id = occurrence.event_id AND event.tenant_ref = occurrence.tenant_ref
7491
+ JOIN slotlock.calendar_event_reminders reminder
7492
+ ON reminder.event_id = occurrence.event_id AND reminder.tenant_ref = occurrence.tenant_ref
7493
+ WHERE occurrence.tenant_ref = ${params.tenantRef}
7494
+ AND event.notify AND event.status <> 'cancelled'
7495
+ AND occurrence.starts_at > statement_timestamp()
7496
+ AND occurrence.starts_at - make_interval(mins => reminder.minutes_before_start)
7497
+ <= statement_timestamp()
7498
+ AND occurrence.starts_at - make_interval(mins => reminder.minutes_before_start)
7499
+ > statement_timestamp() - interval '1 day'
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.
7502
+ AND NOT EXISTS (
7503
+ SELECT 1 FROM slotlock.reminder_dispatches sent
7504
+ WHERE sent.event_id = occurrence.event_id AND sent.recurrence_id = occurrence.recurrence_id
7505
+ AND sent.due_at = occurrence.starts_at - make_interval(mins => reminder.minutes_before_start))
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}
7510
+ ), dispatched AS (
7511
+ INSERT INTO slotlock.reminder_dispatches (tenant_ref, event_id, recurrence_id, ordinal, due_at)
7512
+ SELECT ${params.tenantRef}, event_id, recurrence_id, ordinal, due_at FROM due
7513
+ ON CONFLICT (event_id, recurrence_id, due_at) DO NOTHING
7514
+ RETURNING event_id, recurrence_id, due_at
7515
+ ), reported AS (
7516
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
7517
+ SELECT ${params.tenantRef}, due.resource_id, 'reminder.due', due.event_id::text,
7518
+ jsonb_build_object(
7519
+ 'event_id', due.event_id, 'recurrence_id', due.recurrence_id,
7520
+ 'resource_id', due.resource_id, 'ordinal', due.ordinal, 'action', due.action,
7521
+ 'minutes_before_start', due.minutes_before_start, 'due_at', due.due_at,
7522
+ 'start', due.starts_at, 'end', due.ends_at),
7523
+ 'slotlock:maintenance'
7524
+ FROM due JOIN dispatched USING (event_id, recurrence_id, due_at)
7525
+ RETURNING 1
7526
+ )
7527
+ SELECT count(*)::int AS count FROM reported`;
7528
+ const events = rows[0]?.count ?? 0;
7529
+ if (events >= limit)
7530
+ return events;
7531
+ // A booking's reminders too, named by its reference, with whom to tell.
7532
+ const [bookings] = await sql `
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,
7536
+ booking.starts_at, booking.ends_at, (reminder.ordinal - 1)::int AS ordinal,
7537
+ reminder.minutes,
7538
+ booking.starts_at - make_interval(mins => reminder.minutes) AS due_at
7539
+ FROM slotlock.reservations booking,
7540
+ LATERAL unnest(booking.reminder_minutes) WITH ORDINALITY AS reminder(minutes, ordinal)
7541
+ WHERE booking.tenant_ref = ${params.tenantRef} AND booking.status = 'confirmed'
7542
+ AND booking.calendar_event_id IS NULL AND booking.notify
7543
+ AND booking.reminder_minutes IS NOT NULL
7544
+ AND booking.starts_at > statement_timestamp()
7545
+ AND booking.starts_at - make_interval(mins => reminder.minutes) <= statement_timestamp()
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.
7548
+ AND NOT EXISTS (
7549
+ SELECT 1 FROM slotlock.reservation_reminder_dispatches sent
7550
+ WHERE sent.reservation_id = booking.id
7551
+ AND sent.due_at = booking.starts_at - make_interval(mins => reminder.minutes))
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}
7555
+ ), dispatched AS (
7556
+ INSERT INTO slotlock.reservation_reminder_dispatches (tenant_ref, reservation_id, ordinal, due_at)
7557
+ SELECT ${params.tenantRef}, id, ordinal, due_at FROM due
7558
+ ON CONFLICT (reservation_id, due_at) DO NOTHING
7559
+ RETURNING reservation_id AS id, due_at
7560
+ ), reported AS (
7561
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
7562
+ SELECT ${params.tenantRef}, due.resource_id, 'reminder.due', due.external_ref,
7563
+ jsonb_strip_nulls(jsonb_build_object(
7564
+ 'reservation_id', due.id, 'reference', due.external_ref,
7565
+ 'resource_id', due.resource_id, 'contact_ref', due.contact_ref,
7566
+ 'ordinal', due.ordinal, 'minutes_before_start', due.minutes, 'due_at', due.due_at,
7567
+ 'start', due.starts_at, 'end', due.ends_at)),
7568
+ 'slotlock:maintenance'
7569
+ FROM due JOIN dispatched USING (id, due_at)
7570
+ RETURNING 1
7571
+ )
7572
+ SELECT count(*)::int AS count FROM reported`;
7573
+ return events + (bookings?.count ?? 0);
7574
+ },
5538
7575
  async reportStaleCoverage(params) {
5539
7576
  if (!isValidIdentity(params.tenantRef)) {
5540
7577
  throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
@@ -5604,6 +7641,45 @@ function buildSlotlockStore(sql, options, refusal) {
5604
7641
  return { ok: true, reference: params.externalRef, checkedInAt: row.checked_in_at };
5605
7642
  });
5606
7643
  },
7644
+ async completeEndedBookings(params) {
7645
+ if (!isValidIdentity(params.tenantRef)) {
7646
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
7647
+ }
7648
+ const limit = params.limit ?? 500;
7649
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 5_000) {
7650
+ throw invalidIdentityError('Slotlock completion limit must be 1-5,000');
7651
+ }
7652
+ // A suspended tenant's bookings stay as they are.
7653
+ if (refusal)
7654
+ return 0;
7655
+ const rows = await sql `
7656
+ WITH done AS (
7657
+ UPDATE slotlock.reservations booking
7658
+ SET completed_at = statement_timestamp()
7659
+ WHERE booking.id IN (
7660
+ SELECT id FROM slotlock.reservations
7661
+ WHERE tenant_ref = ${params.tenantRef} AND status = 'confirmed'
7662
+ AND calendar_event_id IS NULL AND completed_at IS NULL
7663
+ AND ends_at <= statement_timestamp()
7664
+ AND ends_at > statement_timestamp() - interval '7 days'
7665
+ ORDER BY ends_at, id
7666
+ LIMIT ${limit}
7667
+ FOR UPDATE SKIP LOCKED)
7668
+ RETURNING booking.id, booking.resource_id, booking.external_ref, booking.starts_at,
7669
+ booking.ends_at, booking.revision, booking.checked_in_at
7670
+ )
7671
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
7672
+ SELECT ${params.tenantRef}, done.resource_id, 'reservation.completed',
7673
+ COALESCE(done.external_ref, done.id::text),
7674
+ jsonb_strip_nulls(jsonb_build_object(
7675
+ 'id', done.id, 'reference', done.external_ref, 'resource_id', done.resource_id,
7676
+ 'start', done.starts_at, 'end', done.ends_at, 'revision', done.revision,
7677
+ 'checked_in_at', done.checked_in_at)),
7678
+ 'slotlock:maintenance'
7679
+ FROM done
7680
+ RETURNING id`;
7681
+ return rows.length;
7682
+ },
5607
7683
  async releaseNoShows(params) {
5608
7684
  if (!isValidIdentity(params.tenantRef)) {
5609
7685
  throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
@@ -5645,8 +7721,8 @@ function buildSlotlockStore(sql, options, refusal) {
5645
7721
  return false;
5646
7722
  const revision = Number(row.revision) + 1;
5647
7723
  await tx `
5648
- INSERT INTO slotlock.reservation_tombstones (tenant_ref, external_ref, revision, stray)
5649
- VALUES (${tenantRef}, ${row.external_ref}, ${revision}, false)`;
7724
+ INSERT INTO slotlock.reservation_tombstones (tenant_ref, external_ref, revision, stray, end_reason)
7725
+ VALUES (${tenantRef}, ${row.external_ref}, ${revision}, false, 'no_show')`;
5650
7726
  // The deletion is the change: the feed's trigger names it reservation.no_show (one change,
5651
7727
  // not a cancellation too), by maintenance, with the check-in window it missed.
5652
7728
  await tx `
@@ -5684,6 +7760,14 @@ function buildSlotlockStore(sql, options, refusal) {
5684
7760
  if (!Number.isSafeInteger(offerTtlMs) || offerTtlMs < 60_000 || offerTtlMs > 86_400_000) {
5685
7761
  return { ok: false, code: 'invalid_ttl' };
5686
7762
  }
7763
+ const durationMs = params.durationMs ?? null;
7764
+ if (durationMs !== null &&
7765
+ (!Number.isSafeInteger(durationMs) ||
7766
+ durationMs < 60_000 ||
7767
+ durationMs % 60_000 !== 0 ||
7768
+ durationMs > window.end.getTime() - window.start.getTime())) {
7769
+ return { ok: false, code: 'invalid_window' };
7770
+ }
5687
7771
  const key = params.idempotencyKey;
5688
7772
  if (key !== undefined && !isValidIdentity(key))
5689
7773
  return { ok: false, code: 'invalid_identity' };
@@ -5697,6 +7781,7 @@ function buildSlotlockStore(sql, options, refusal) {
5697
7781
  window.end.toISOString(),
5698
7782
  holderRef,
5699
7783
  offerTtlMs,
7784
+ ...(durationMs === null ? [] : [durationMs]),
5700
7785
  ]))
5701
7786
  .digest('hex');
5702
7787
  return inTransaction(sql, async (tx) => {
@@ -5742,11 +7827,20 @@ function buildSlotlockStore(sql, options, refusal) {
5742
7827
  const [row] = await tx `
5743
7828
  INSERT INTO slotlock.waitlist_entries
5744
7829
  (tenant_ref, resource_id, pool_ref, starts_at, ends_at, holder_ref, offer_ttl_ms,
5745
- idempotency_key, payload_hash)
7830
+ idempotency_key, payload_hash, duration_ms)
5746
7831
  VALUES (${tenantRef}, ${params.resourceId ?? null}, ${params.pool ?? null}, ${window.start},
5747
- ${window.end}, ${holderRef}, ${offerTtlMs}, ${key ?? null}, ${payloadHash})
7832
+ ${window.end}, ${holderRef}, ${offerTtlMs}, ${key ?? null}, ${payloadHash}, ${durationMs})
5748
7833
  RETURNING ${tx.unsafe(WAITLIST_COLUMNS)}`;
5749
- return { ok: true, entry: toWaitlistEntry(row), replayed: false };
7834
+ const entry = row;
7835
+ await tx `
7836
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
7837
+ VALUES (${tenantRef}, ${entry.resource_id}::uuid, 'waitlist.joined', ${entry.id},
7838
+ jsonb_strip_nulls(jsonb_build_object(
7839
+ 'id', ${entry.id}::uuid, 'resource_id', ${entry.resource_id}::uuid, 'pool', ${entry.pool_ref}::text,
7840
+ 'start', ${entry.starts_at}::timestamptz, 'end', ${entry.ends_at}::timestamptz,
7841
+ 'duration_ms', ${durationMs}::bigint, 'holder_ref', ${entry.holder_ref}::text)),
7842
+ NULLIF(current_setting('slotlock.actor', true), ''))`;
7843
+ return { ok: true, entry: toWaitlistEntry(entry), replayed: false };
5750
7844
  });
5751
7845
  },
5752
7846
  async listWaitlist(params) {
@@ -5776,9 +7870,19 @@ function buildSlotlockStore(sql, options, refusal) {
5776
7870
  DELETE FROM slotlock.waitlist_entries
5777
7871
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
5778
7872
  AND (${params.holderRef ?? null}::text IS NULL OR holder_ref = ${params.holderRef ?? null})
5779
- RETURNING hold_id`;
7873
+ RETURNING hold_id, status, resource_id, pool_ref, holder_ref`;
5780
7874
  if (!left)
5781
7875
  return false;
7876
+ await tx `
7877
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
7878
+ VALUES (${params.tenantRef}, ${left.resource_id}::uuid, 'waitlist.left', ${params.id},
7879
+ jsonb_strip_nulls(jsonb_build_object(
7880
+ 'id', ${params.id}::uuid, 'resource_id', ${left.resource_id}::uuid, 'pool', ${left.pool_ref}::text,
7881
+ 'status', ${left.status}::text, 'holder_ref', ${left.holder_ref}::text)),
7882
+ NULLIF(current_setting('slotlock.actor', true), ''))`;
7883
+ // An offer still open goes with it; the entry is gone, so its hold's end changes no entry.
7884
+ if (left.status !== 'offered')
7885
+ return true;
5782
7886
  // An untaken offer's hold goes too; a confirmed one is a booking now and stays.
5783
7887
  if (left.hold_id) {
5784
7888
  await buildSlotlockStore(tx, options, null).releaseHold(left.hold_id, { tenantRef: params.tenantRef });
@@ -5786,6 +7890,57 @@ function buildSlotlockStore(sql, options, refusal) {
5786
7890
  return true;
5787
7891
  });
5788
7892
  },
7893
+ async holdGroup(params) {
7894
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.holderRef) || !isValidResourceId(params.groupId)) {
7895
+ return [];
7896
+ }
7897
+ if (params.alignExpiry === true) {
7898
+ await sql `
7899
+ UPDATE slotlock.reservations held
7900
+ SET expires_at = earliest.at
7901
+ FROM (SELECT min(expires_at) AS at FROM slotlock.reservations
7902
+ WHERE hold_group_id = ${params.groupId}::uuid AND holder_ref = ${params.holderRef}
7903
+ AND status = 'held') earliest
7904
+ WHERE held.hold_group_id = ${params.groupId}::uuid AND held.holder_ref = ${params.holderRef}
7905
+ AND held.status = 'held' AND held.expires_at <> earliest.at`;
7906
+ }
7907
+ const rows = await sql `
7908
+ SELECT held.id, held.resource_id, held.starts_at, held.ends_at, held.expires_at, held.source
7909
+ FROM slotlock.reservations held
7910
+ JOIN slotlock.resources resource ON resource.id = held.resource_id
7911
+ WHERE held.hold_group_id = ${params.groupId}::uuid AND held.holder_ref = ${params.holderRef}
7912
+ AND resource.tenant_ref = ${params.tenantRef} AND held.status = 'held'
7913
+ AND held.expires_at > statement_timestamp()
7914
+ ORDER BY held.resource_id`;
7915
+ return rows.map((row) => ({
7916
+ holdId: row.id,
7917
+ resourceId: row.resource_id,
7918
+ start: row.starts_at,
7919
+ end: row.ends_at,
7920
+ expiresAt: row.expires_at,
7921
+ source: row.source,
7922
+ }));
7923
+ },
7924
+ async declineWaitlistOffer(params) {
7925
+ if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id))
7926
+ return null;
7927
+ if (params.holderRef !== undefined && !isValidIdentity(params.holderRef))
7928
+ return null;
7929
+ return inTransaction(sql, async (tx) => {
7930
+ const [offer] = await tx `
7931
+ SELECT hold_id FROM slotlock.waitlist_entries
7932
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id} AND status = 'offered'
7933
+ AND (${params.holderRef ?? null}::text IS NULL OR holder_ref = ${params.holderRef ?? null})
7934
+ FOR UPDATE`;
7935
+ if (!offer)
7936
+ return null;
7937
+ // Letting the hold go declines the entry (slotlock.waitlist_offer_settle) and frees the time.
7938
+ await buildSlotlockStore(tx, options, null).releaseHold(offer.hold_id, { tenantRef: params.tenantRef });
7939
+ const [row] = await tx `
7940
+ SELECT ${tx.unsafe(WAITLIST_COLUMNS)} FROM slotlock.waitlist_entries WHERE id = ${params.id}`;
7941
+ return row ? toWaitlistEntry(row) : null;
7942
+ });
7943
+ },
5789
7944
  async getAssistantSettings(params) {
5790
7945
  if (!isValidIdentity(params.tenantRef))
5791
7946
  throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
@@ -5847,42 +8002,106 @@ function buildSlotlockStore(sql, options, refusal) {
5847
8002
  RETURNING 1`;
5848
8003
  return rows.length > 0;
5849
8004
  },
5850
- async getAssistantConversation(params) {
8005
+ async listAssistantChats(params) {
5851
8006
  if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.memberRef))
8007
+ return [];
8008
+ const limit = params.limit ?? SLOTLOCK_ASSISTANT_MAX_CHATS;
8009
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > SLOTLOCK_ASSISTANT_MAX_CHATS) {
8010
+ throw invalidIdentityError(`Slotlock assistant chat list limit must be 1-${String(SLOTLOCK_ASSISTANT_MAX_CHATS)}`);
8011
+ }
8012
+ const rows = await sql `
8013
+ SELECT chat_id, title, updated_at FROM slotlock.assistant_chats
8014
+ WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef}
8015
+ AND (expires_at IS NULL OR expires_at > statement_timestamp())
8016
+ ORDER BY updated_at DESC, chat_id
8017
+ LIMIT ${limit}`;
8018
+ return rows.map((row) => ({ id: row.chat_id, title: row.title, updatedAt: row.updated_at }));
8019
+ },
8020
+ async getAssistantChat(params) {
8021
+ if (!isValidIdentity(params.tenantRef) ||
8022
+ !isValidIdentity(params.memberRef) ||
8023
+ !SLOTLOCK_ASSISTANT_CHAT_ID.test(params.chatId)) {
5852
8024
  return null;
8025
+ }
5853
8026
  const [row] = await sql `
5854
- SELECT messages, actions, updated_at, expires_at FROM slotlock.assistant_conversations
8027
+ SELECT title, messages, actions, created_at, updated_at, expires_at FROM slotlock.assistant_chats
5855
8028
  WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef}
5856
- AND expires_at > statement_timestamp()`;
8029
+ AND chat_id = ${params.chatId}
8030
+ AND (expires_at IS NULL OR expires_at > statement_timestamp())`;
5857
8031
  if (!row)
5858
8032
  return null;
5859
8033
  return {
8034
+ id: params.chatId,
8035
+ title: row.title,
5860
8036
  messages: row.messages,
5861
8037
  actions: row.actions,
8038
+ createdAt: row.created_at,
5862
8039
  updatedAt: row.updated_at,
5863
8040
  expiresAt: row.expires_at,
5864
8041
  };
5865
8042
  },
5866
- async putAssistantConversation(params) {
8043
+ async putAssistantChat(params) {
5867
8044
  if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.memberRef)) {
5868
8045
  throw invalidIdentityError('Slotlock tenant and member references must be 1-500 UTF-8 bytes');
5869
8046
  }
5870
- if (!Number.isSafeInteger(params.ttlMs) || params.ttlMs < 1_000 || params.ttlMs > 86_400_000) {
5871
- throw invalidIdentityError('Slotlock assistant conversation ttlMs must be 1 second to a day');
8047
+ if (!SLOTLOCK_ASSISTANT_CHAT_ID.test(params.chatId)) {
8048
+ throw invalidIdentityError('Slotlock assistant chat id must be 16 URL-safe characters');
8049
+ }
8050
+ const title = params.title.trim();
8051
+ if ([...title].length < 1 || [...title].length > 120) {
8052
+ throw invalidIdentityError('Slotlock assistant chat title must be 1-120 characters');
5872
8053
  }
8054
+ if (params.ttlMs !== null &&
8055
+ (!Number.isSafeInteger(params.ttlMs) || params.ttlMs < 1_000 || params.ttlMs > 3_650 * 86_400_000)) {
8056
+ throw invalidIdentityError('Slotlock assistant conversation ttlMs must be 1 second to 3,650 days');
8057
+ }
8058
+ const ttlSeconds = params.ttlMs === null ? null : params.ttlMs / 1_000;
5873
8059
  const messages = sql.json(params.messages);
5874
8060
  const added = sql.json(params.added);
5875
8061
  const keep = [...params.keep];
8062
+ // One member's chats are written one at a time, so the trim below sees every insert before it.
5876
8063
  await sql `
5877
- DELETE FROM slotlock.assistant_conversations
5878
- WHERE tenant_ref = ${params.tenantRef} AND expires_at <= statement_timestamp()`;
8064
+ SELECT pg_advisory_xact_lock(hashtextextended(
8065
+ ${`slotlock:assistant-chats:${params.tenantRef}\n${params.memberRef}`}::text, 0))`;
8066
+ if (params.create === false) {
8067
+ // Only one still there: a chat deleted while its turn ran stays deleted.
8068
+ const changed = await sql `
8069
+ UPDATE slotlock.assistant_chats AS stored
8070
+ SET messages = ${messages},
8071
+ actions = (
8072
+ SELECT coalesce(jsonb_object_agg(existing.key, existing.value), '{}'::jsonb)
8073
+ FROM jsonb_each(stored.actions) AS existing
8074
+ WHERE existing.key = ANY(${keep}::text[])
8075
+ ) || (
8076
+ SELECT coalesce(jsonb_object_agg(fresh.key, fresh.value), '{}'::jsonb)
8077
+ FROM jsonb_each(${added}::jsonb) AS fresh
8078
+ WHERE NOT stored.actions ? fresh.key
8079
+ ),
8080
+ updated_at = statement_timestamp(),
8081
+ expires_at = CASE WHEN ${ttlSeconds}::double precision IS NULL THEN NULL
8082
+ ELSE statement_timestamp() + make_interval(secs => ${ttlSeconds}::double precision) END
8083
+ WHERE stored.tenant_ref = ${params.tenantRef} AND stored.member_ref = ${params.memberRef}
8084
+ AND stored.chat_id = ${params.chatId}
8085
+ AND (stored.expires_at IS NULL OR stored.expires_at > statement_timestamp())
8086
+ RETURNING 1`;
8087
+ if (changed.length === 0)
8088
+ return false;
8089
+ if (ttlSeconds !== null) {
8090
+ await sql `
8091
+ SELECT slotlock.register_assistant_tenant(
8092
+ ${params.tenantRef}::text,
8093
+ statement_timestamp() + make_interval(secs => ${ttlSeconds}::double precision))`;
8094
+ }
8095
+ return true;
8096
+ }
5879
8097
  await sql `
5880
- INSERT INTO slotlock.assistant_conversations AS stored
5881
- (tenant_ref, member_ref, messages, actions, updated_at, expires_at)
5882
- VALUES (${params.tenantRef}, ${params.memberRef}, ${messages}, ${added},
5883
- statement_timestamp(),
5884
- statement_timestamp() + make_interval(secs => ${params.ttlMs / 1_000}))
5885
- ON CONFLICT (tenant_ref, member_ref) DO UPDATE SET
8098
+ INSERT INTO slotlock.assistant_chats AS stored
8099
+ (tenant_ref, member_ref, chat_id, title, messages, actions, created_at, updated_at, expires_at)
8100
+ VALUES (${params.tenantRef}, ${params.memberRef}, ${params.chatId}, ${title}, ${messages}, ${added},
8101
+ statement_timestamp(), statement_timestamp(),
8102
+ CASE WHEN ${ttlSeconds}::double precision IS NULL THEN NULL
8103
+ ELSE statement_timestamp() + make_interval(secs => ${ttlSeconds}::double precision) END)
8104
+ ON CONFLICT (tenant_ref, member_ref, chat_id) DO UPDATE SET
5886
8105
  messages = EXCLUDED.messages,
5887
8106
  -- The stored ones kept, as they stand, then the new ones: never a settled one again.
5888
8107
  actions = (
@@ -5896,21 +8115,55 @@ function buildSlotlockStore(sql, options, refusal) {
5896
8115
  ),
5897
8116
  updated_at = EXCLUDED.updated_at,
5898
8117
  expires_at = EXCLUDED.expires_at`;
8118
+ // The member keeps the most recent; past the limit, the least recently used go.
5899
8119
  await sql `
5900
- SELECT slotlock.register_assistant_tenant(
5901
- ${params.tenantRef}::text,
5902
- statement_timestamp() + make_interval(secs => ${params.ttlMs / 1_000}))`;
8120
+ DELETE FROM slotlock.assistant_chats gone
8121
+ WHERE gone.tenant_ref = ${params.tenantRef} AND gone.member_ref = ${params.memberRef}
8122
+ AND gone.chat_id IN (
8123
+ SELECT kept.chat_id FROM slotlock.assistant_chats kept
8124
+ WHERE kept.tenant_ref = ${params.tenantRef} AND kept.member_ref = ${params.memberRef}
8125
+ ORDER BY kept.updated_at DESC, kept.chat_id
8126
+ OFFSET ${SLOTLOCK_ASSISTANT_MAX_CHATS})`;
8127
+ if (ttlSeconds !== null) {
8128
+ await sql `
8129
+ SELECT slotlock.register_assistant_tenant(
8130
+ ${params.tenantRef}::text,
8131
+ statement_timestamp() + make_interval(secs => ${ttlSeconds}::double precision))`;
8132
+ }
8133
+ return true;
8134
+ },
8135
+ async renameAssistantChat(params) {
8136
+ if (!isValidIdentity(params.tenantRef) ||
8137
+ !isValidIdentity(params.memberRef) ||
8138
+ !SLOTLOCK_ASSISTANT_CHAT_ID.test(params.chatId)) {
8139
+ return false;
8140
+ }
8141
+ const title = params.title.trim();
8142
+ if ([...title].length < 1 || [...title].length > 120) {
8143
+ throw invalidIdentityError('Slotlock assistant chat title must be 1-120 characters');
8144
+ }
8145
+ const rows = await sql `
8146
+ UPDATE slotlock.assistant_chats SET title = ${title}
8147
+ WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef}
8148
+ AND chat_id = ${params.chatId}
8149
+ AND (expires_at IS NULL OR expires_at > statement_timestamp())
8150
+ RETURNING 1`;
8151
+ return rows.length > 0;
5903
8152
  },
5904
8153
  async settleAssistantAction(params) {
5905
- if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.memberRef))
8154
+ if (!isValidIdentity(params.tenantRef) ||
8155
+ !isValidIdentity(params.memberRef) ||
8156
+ !SLOTLOCK_ASSISTANT_CHAT_ID.test(params.chatId)) {
5906
8157
  return null;
8158
+ }
5907
8159
  const patch = sql.json(params.patch);
5908
8160
  const [row] = await sql `
5909
- UPDATE slotlock.assistant_conversations
8161
+ UPDATE slotlock.assistant_chats
5910
8162
  SET actions = jsonb_set(actions, ARRAY[${params.actionId}::text],
5911
8163
  (actions -> ${params.actionId}::text) || ${patch}::jsonb)
5912
8164
  WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef}
5913
- AND expires_at > statement_timestamp()
8165
+ AND chat_id = ${params.chatId}
8166
+ AND (expires_at IS NULL OR expires_at > statement_timestamp())
5914
8167
  AND actions -> ${params.actionId}::text ->> 'status' = ${params.from}
5915
8168
  RETURNING actions -> ${params.actionId}::text AS action`;
5916
8169
  return row?.action ?? null;
@@ -5922,7 +8175,17 @@ function buildSlotlockStore(sql, options, refusal) {
5922
8175
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10_000) {
5923
8176
  throw invalidIdentityError('Slotlock assistant prune limit must be 1-10,000');
5924
8177
  }
5925
- const rows = await sql `
8178
+ // The conversations a deployment gave a lifetime, past it; and those 0.8 kept, past theirs.
8179
+ const chats = await sql `
8180
+ DELETE FROM slotlock.assistant_chats
8181
+ WHERE tenant_ref = ${params.tenantRef}
8182
+ AND (member_ref, chat_id) IN (
8183
+ SELECT member_ref, chat_id FROM slotlock.assistant_chats
8184
+ WHERE tenant_ref = ${params.tenantRef} AND expires_at <= statement_timestamp()
8185
+ LIMIT ${limit})
8186
+ AND expires_at <= statement_timestamp()
8187
+ RETURNING 1`;
8188
+ const old = await sql `
5926
8189
  DELETE FROM slotlock.assistant_conversations
5927
8190
  WHERE tenant_ref = ${params.tenantRef}
5928
8191
  AND member_ref IN (
@@ -5931,7 +8194,7 @@ function buildSlotlockStore(sql, options, refusal) {
5931
8194
  LIMIT ${limit})
5932
8195
  AND expires_at <= statement_timestamp()
5933
8196
  RETURNING 1`;
5934
- return rows.length;
8197
+ return chats.length + old.length;
5935
8198
  },
5936
8199
  async sweepAssistantConversations(params) {
5937
8200
  const limit = params?.limit ?? 10_000;
@@ -5942,14 +8205,113 @@ function buildSlotlockStore(sql, options, refusal) {
5942
8205
  SELECT slotlock.sweep_assistant_conversations(${limit}::integer) AS deleted`;
5943
8206
  return Number(row?.deleted ?? 0);
5944
8207
  },
5945
- async deleteAssistantConversation(params) {
5946
- if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.memberRef))
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
+ },
8216
+ async deleteAssistantChat(params) {
8217
+ if (!isValidIdentity(params.tenantRef) ||
8218
+ !isValidIdentity(params.memberRef) ||
8219
+ !SLOTLOCK_ASSISTANT_CHAT_ID.test(params.chatId)) {
5947
8220
  return false;
8221
+ }
5948
8222
  const rows = await sql `
5949
- DELETE FROM slotlock.assistant_conversations
5950
- WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef} RETURNING 1`;
8223
+ DELETE FROM slotlock.assistant_chats
8224
+ WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef}
8225
+ AND chat_id = ${params.chatId} RETURNING 1`;
5951
8226
  return rows.length > 0;
5952
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
+ },
5953
8315
  async offerWaitlist(params) {
5954
8316
  if (!isValidIdentity(params.tenantRef)) {
5955
8317
  throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
@@ -5967,10 +8329,21 @@ function buildSlotlockStore(sql, options, refusal) {
5967
8329
  await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify(['slotlock-waitlist', tenantRef])}, 0))`;
5968
8330
  let expired = 0;
5969
8331
  if (!after) {
8332
+ // Offers whose hold ran out lapse (slotlock.waitlist_offer_settle), freeing their time.
8333
+ await tx `
8334
+ DELETE FROM slotlock.reservations offer
8335
+ USING slotlock.resources resource
8336
+ WHERE resource.id = offer.resource_id AND resource.tenant_ref = ${tenantRef}
8337
+ AND offer.status = 'held' AND offer.source = 'slotlock:waitlist'
8338
+ AND offer.expires_at <= statement_timestamp()`;
5970
8339
  const [gone] = await tx `
5971
8340
  WITH gone AS (
5972
8341
  UPDATE slotlock.waitlist_entries SET status = 'expired'
5973
- WHERE tenant_ref = ${tenantRef} AND status = 'waiting' AND starts_at <= statement_timestamp()
8342
+ WHERE tenant_ref = ${tenantRef} AND status = 'waiting'
8343
+ -- The whole window once it starts; a length once no slot of it fits before the end.
8344
+ AND (CASE WHEN duration_ms IS NULL THEN starts_at
8345
+ ELSE ends_at - make_interval(secs => duration_ms / 1000.0) END)
8346
+ <= statement_timestamp()
5974
8347
  RETURNING id, resource_id, pool_ref, starts_at, ends_at, holder_ref
5975
8348
  ), reported AS (
5976
8349
  INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
@@ -6004,6 +8377,15 @@ function buildSlotlockStore(sql, options, refusal) {
6004
8377
  WHERE id = ${entry.id} AND status = 'waiting' FOR UPDATE`;
6005
8378
  if (!still)
6006
8379
  continue;
8380
+ if (entry.duration_ms !== null) {
8381
+ // Any slot of its length within its window (RFC 0004 B6), on its resource or any member.
8382
+ const slot = await offerSlotOfLength(tx, inner, tenantRef, entry);
8383
+ if (slot) {
8384
+ await recordWaitlistOffer(tx, tenantRef, entry, slot);
8385
+ offered += 1;
8386
+ }
8387
+ continue;
8388
+ }
6007
8389
  // Only resources that look free (buffers included) are tried: a refused hold keeps its
6008
8390
  // locks until the transaction ends.
6009
8391
  const candidates = await tx `
@@ -6026,6 +8408,12 @@ function buildSlotlockStore(sql, options, refusal) {
6026
8408
  && tstzrange(${entry.starts_at}::timestamptz - make_interval(mins => buffers.before_minutes),
6027
8409
  ${entry.ends_at}::timestamptz + make_interval(mins => buffers.after_minutes), '[)')
6028
8410
  AND (taken.status <> 'held' OR taken.expires_at > statement_timestamp()))
8411
+ -- Nor busy time a calendar reported (RFC 0004 C1), which the hold would be refused.
8412
+ AND NOT EXISTS (
8413
+ SELECT 1 FROM slotlock.source_busy busy
8414
+ WHERE busy.resource_id = resources.id
8415
+ AND busy.starts_at < ${entry.ends_at}::timestamptz + make_interval(mins => buffers.after_minutes)
8416
+ AND busy.ends_at > ${entry.starts_at}::timestamptz - make_interval(mins => buffers.before_minutes))
6029
8417
  ORDER BY resources.id
6030
8418
  LIMIT ${OFFER_CANDIDATES}`;
6031
8419
  for (const { id: resourceId } of candidates) {
@@ -6040,21 +8428,13 @@ function buildSlotlockStore(sql, options, refusal) {
6040
8428
  });
6041
8429
  if (!hold.ok)
6042
8430
  continue;
6043
- await tx `
6044
- UPDATE slotlock.waitlist_entries
6045
- SET status = 'offered', hold_id = ${hold.hold.id}, offered_resource_id = ${resourceId},
6046
- offered_at = statement_timestamp()
6047
- WHERE id = ${entry.id}`;
6048
- await tx `
6049
- INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
6050
- VALUES (${tenantRef}, ${resourceId}, 'waitlist.offered', ${entry.id},
6051
- jsonb_strip_nulls(jsonb_build_object(
6052
- 'id', ${entry.id}::uuid, 'hold_id', ${hold.hold.id}::uuid,
6053
- 'resource_id', ${resourceId}::uuid, 'pool', ${entry.pool_ref}::text,
6054
- 'start', ${entry.starts_at}::timestamptz, 'end', ${entry.ends_at}::timestamptz,
6055
- 'expires_at', ${hold.hold.expiresAt}::timestamptz,
6056
- 'holder_ref', ${entry.holder_ref}::text)),
6057
- 'slotlock:maintenance')`;
8431
+ await recordWaitlistOffer(tx, tenantRef, entry, {
8432
+ resourceId,
8433
+ holdId: hold.hold.id,
8434
+ start: entry.starts_at,
8435
+ end: entry.ends_at,
8436
+ expiresAt: hold.hold.expiresAt,
8437
+ });
6058
8438
  offered += 1;
6059
8439
  break;
6060
8440
  }
@@ -6111,6 +8491,8 @@ function buildSlotlockStore(sql, options, refusal) {
6111
8491
  AND (reservation.status <> 'held' OR reservation.expires_at > statement_timestamp())
6112
8492
  UNION ALL
6113
8493
  ${compositeBusy(sql, params.resourceId, window)}
8494
+ UNION ALL
8495
+ ${sourceBusy(sql, params.resourceId, window)}
6114
8496
  ORDER BY 1
6115
8497
  LIMIT ${MAX_FREE_BUSY_INTERVALS + 1}`;
6116
8498
  if (busyRows.length > MAX_FREE_BUSY_INTERVALS) {