slotlock 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +237 -0
  2. package/agent-server.d.ts +25 -5
  3. package/agent-server.js +152 -7
  4. package/agent-store-backend.d.ts +6 -1
  5. package/agent-store-backend.js +861 -55
  6. package/application-api.d.ts +19 -1
  7. package/application-api.js +578 -21
  8. package/approvals.d.ts +2 -0
  9. package/approvals.js +12 -5
  10. package/assistant.d.ts +34 -8
  11. package/assistant.js +81 -26
  12. package/availability-store.d.ts +193 -0
  13. package/availability-store.js +558 -0
  14. package/booking-rules.d.ts +92 -5
  15. package/booking-rules.js +243 -15
  16. package/dashboard-assistant-ui.d.ts +1 -1
  17. package/dashboard-assistant-ui.js +109 -8
  18. package/dashboard.d.ts +55 -6
  19. package/dashboard.js +790 -105
  20. package/ddl.d.ts +5 -1
  21. package/ddl.js +760 -9
  22. package/event-feed.d.ts +1 -1
  23. package/event-feed.js +18 -0
  24. package/holiday-import.d.ts +38 -0
  25. package/holiday-import.js +57 -0
  26. package/holidays.d.ts +38 -0
  27. package/holidays.js +150 -0
  28. package/hours.d.ts +125 -0
  29. package/hours.js +224 -0
  30. package/index.d.ts +14 -3
  31. package/index.js +10 -2
  32. package/interop.d.ts +25 -7
  33. package/interop.js +145 -17
  34. package/lifecycle.d.ts +31 -0
  35. package/lifecycle.js +115 -0
  36. package/maintenance.d.ts +19 -0
  37. package/maintenance.js +55 -1
  38. package/metrics.js +3 -0
  39. package/openapi.json +16237 -7795
  40. package/package.json +1 -1
  41. package/ranges.d.ts +28 -0
  42. package/ranges.js +135 -0
  43. package/resource-search.d.ts +19 -0
  44. package/resource-search.js +153 -0
  45. package/rest-api.js +7 -0
  46. package/rules.d.ts +2 -0
  47. package/rules.js +2 -0
  48. package/self-host.d.ts +1 -1
  49. package/self-host.js +221 -21
  50. package/slotlock.manifest.json +1 -1
  51. package/store.d.ts +367 -24
  52. package/store.js +1567 -97
  53. package/types.d.ts +198 -0
package/store.js CHANGED
@@ -3,14 +3,17 @@ import { SLOTLOCK_MAX_LOCATION_DEPTH, SLOTLOCK_MAX_LOCATIONS, SLOTLOCK_MAX_RESOU
3
3
  import { readSlotlockTenantUsage } from './usage.js';
4
4
  import { slotlockPlanReadRefusal } from './plans.js';
5
5
  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';
6
+ import { cancellationFeeDue, cutoffRuleBroken, bookingBufferBeforeMs, bookingSearchBlocked, bookingSearchReach, parseBookingRuleDefaults, parseEffectiveBookingRules, bookingBufferAfterMs, bookingRuleBroken, bookingSearch, parseBookingRules, } from './booking-rules.js';
7
7
  import { createHash, randomUUID } from 'node:crypto';
8
8
  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';
9
+ 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
10
  import { findNextAvailable, mergeIntervals } from './engine.js';
11
- import { SLOTLOCK_CALENDAR_HORIZON_DAYS, assertBoundedSearchWindow, expandRules, invalidWindowError, } from './rules.js';
11
+ import { SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_SOURCE_BUSY_MAX, assertBoundedSearchWindow, expandRules, invalidWindowError, } from './rules.js';
12
12
  import { CalendarContractError, CalendarExpansionLimitError, expandCalendarEventSeries, normalizeCalendarEventContent, } from './sync.js';
13
13
  import { parseAvailabilityRules } from './weekly-hours.js';
14
+ import { applicableHours, exceptionQueryDays } from './hours.js';
15
+ import { zonedDateTimeToUtc } from './timezone.js';
16
+ import { createAvailabilityException, deleteHolidayCalendar, dueHolidayCalendars, importHolidayCalendar, listHolidayCalendars, noteHolidayCalendarFailure, deleteAvailabilityException, getAvailabilityDefaults, listAvailabilityExceptions, rankedExceptions, replaceImportedExceptions, setLocationAvailability, storeTenantAvailability, storedInheritedAvailability, } from './availability-store.js';
14
17
  import { APPROVAL_COLUMNS, approvalByRequestKey, approvalPoliciesFor, beginApprovalGrant, declineApproval, deleteApprovalPolicy, expireApprovals, finishApprovalGrant, getApproval, listApprovalPolicies, listApprovals, putApprovalPolicy, recordApprovalChange, toApproval, } from './approvals.js';
15
18
  const EXCLUSION_VIOLATION = '23P01';
16
19
  /** Holds one approval may own: a bundle's resources, or a move's new time in pieces. */
@@ -31,7 +34,7 @@ const MAX_HOLD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
31
34
  const MAX_BUFFER_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
32
35
  /** A day, as bufferBeforeMinutes allows: a read by start can then still use the index. */
33
36
  const MAX_BUFFER_BEFORE_MS = 24 * 60 * 60 * 1000;
34
- export { SLOTLOCK_CALENDAR_HORIZON_DAYS };
37
+ export { SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_SOURCE_BUSY_MAX };
35
38
  /**
36
39
  * Longest one-off event (ten years and some leap days); a recurring series is bounded by its
37
40
  * materialization window instead.
@@ -690,6 +693,9 @@ function validateExternalCommand(params) {
690
693
  if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.externalRef)) {
691
694
  return { ok: false, code: 'invalid_identity' };
692
695
  }
696
+ if (params.overrideReason !== undefined && !isValidOverrideReason(params.overrideReason)) {
697
+ return { ok: false, code: 'invalid_identity' };
698
+ }
693
699
  if (!Number.isSafeInteger(params.bufferAfterMs) ||
694
700
  params.bufferAfterMs < 0 ||
695
701
  params.bufferAfterMs > MAX_BUFFER_AFTER_MS) {
@@ -698,8 +704,91 @@ function validateExternalCommand(params) {
698
704
  if (!Number.isSafeInteger(params.revision) || params.revision <= 0) {
699
705
  return { ok: false, code: 'invalid_revision' };
700
706
  }
707
+ if (params.contactRef !== undefined && !isValidContactRef(params.contactRef)) {
708
+ return { ok: false, code: 'invalid_contact_ref' };
709
+ }
710
+ if (params.notify !== undefined && typeof params.notify !== 'boolean') {
711
+ return { ok: false, code: 'invalid_notify' };
712
+ }
713
+ if (params.reminderMinutes !== undefined &&
714
+ (!Array.isArray(params.reminderMinutes) ||
715
+ params.reminderMinutes.length > 5 ||
716
+ new Set(params.reminderMinutes).size !== params.reminderMinutes.length ||
717
+ params.reminderMinutes.some((minutes) => !Number.isSafeInteger(minutes) || minutes < 0 || minutes > 527_040))) {
718
+ return { ok: false, code: 'invalid_reminders' };
719
+ }
701
720
  return null;
702
721
  }
722
+ /** A booking's reminders as stored: sorted, latest first; null for none. */
723
+ function reminderColumn(minutes) {
724
+ return minutes && minutes.length > 0 ? [...minutes].sort((a, b) => b - a) : null;
725
+ }
726
+ /** A recipient's reference (RFC 0004 P3): what the contacts table and a booking name a person by. */
727
+ const CONTACT_REF = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/;
728
+ function isValidContactRef(value) {
729
+ return typeof value === 'string' && CONTACT_REF.test(value);
730
+ }
731
+ /** The most contacts a tenant keeps (RFC 0004 P3). */
732
+ export const SLOTLOCK_MAX_CONTACTS_PER_TENANT = 100_000;
733
+ const E164 = /^\+[1-9][0-9]{6,14}$/;
734
+ const LOCALE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
735
+ const CONTACT_COLUMNS = 'ref, name, email, phone, whatsapp, consent, consent_at, locale, created_at, updated_at';
736
+ function toContact(row) {
737
+ const consent = {};
738
+ for (const channel of ['email', 'sms', 'whatsapp']) {
739
+ const answer = row.consent?.[channel];
740
+ if (typeof answer === 'boolean')
741
+ consent[channel] = answer;
742
+ }
743
+ const consentAt = {};
744
+ for (const channel of ['email', 'sms', 'whatsapp']) {
745
+ const at = row.consent_at?.[channel];
746
+ if (typeof at === 'string' && channel in consent)
747
+ consentAt[channel] = new Date(at);
748
+ }
749
+ return {
750
+ ref: row.ref,
751
+ name: row.name,
752
+ email: row.email,
753
+ phone: row.phone,
754
+ whatsapp: row.whatsapp,
755
+ consent,
756
+ consentAt,
757
+ locale: row.locale,
758
+ createdAt: row.created_at,
759
+ updatedAt: row.updated_at,
760
+ };
761
+ }
762
+ /** A contact's fields as the table's CHECK takes them, or null when one is not. */
763
+ function contactFields(params) {
764
+ const text = (value, valid) => {
765
+ if (value === undefined || value === null)
766
+ return null;
767
+ if (typeof value !== 'string' || !valid(value))
768
+ return undefined;
769
+ return value;
770
+ };
771
+ const name = text(params.name, (value) => value.length >= 1 && value.length <= 200 && !/[\u0000-\u001f\u007f]/.test(value));
772
+ const email = text(params.email, (value) => value.length >= 3 && value.length <= 320 && /^[^\s@]+@[^\s@]+$/.test(value));
773
+ const phone = text(params.phone, (value) => E164.test(value));
774
+ const whatsapp = text(params.whatsapp, (value) => E164.test(value));
775
+ const locale = text(params.locale, (value) => value.length <= 35 && LOCALE.test(value));
776
+ if (name === undefined || email === undefined || phone === undefined || whatsapp === undefined || locale === undefined) {
777
+ return null;
778
+ }
779
+ const consent = {};
780
+ if (params.consent !== undefined) {
781
+ if (typeof params.consent !== 'object' || params.consent === null || Array.isArray(params.consent))
782
+ return null;
783
+ for (const [channel, answer] of Object.entries(params.consent)) {
784
+ if ((channel !== 'email' && channel !== 'sms' && channel !== 'whatsapp') || typeof answer !== 'boolean') {
785
+ return null;
786
+ }
787
+ consent[channel] = answer;
788
+ }
789
+ }
790
+ return { name, email, phone, whatsapp, consent, locale };
791
+ }
703
792
  function toReservation(row) {
704
793
  return {
705
794
  id: row.id,
@@ -711,8 +800,19 @@ function toReservation(row) {
711
800
  bufferAfterMs: Number(row.buffer_after_ms),
712
801
  revision: Number(row.revision),
713
802
  source: row.source,
803
+ ...(row.contact_ref ? { contactRef: row.contact_ref } : {}),
804
+ ...(row.notify === false ? { notify: false } : {}),
805
+ ...(row.reminder_minutes?.length ? { reminderMinutes: row.reminder_minutes.map(Number) } : {}),
714
806
  };
715
807
  }
808
+ /** A hold's payment columns, as every read of one names them. */
809
+ const HOLD_PAYMENT_COLUMNS = 'payment_required, payment_ref, paid_at, payment_amount_minor, payment_currency, paid_amount_minor, paid_currency';
810
+ const HOLD_PAYMENT_COLUMNS_OF_HOLD = HOLD_PAYMENT_COLUMNS.split(', ')
811
+ .map((column) => `hold.${column}`)
812
+ .join(', ');
813
+ function amountOf(minor, currency) {
814
+ return minor === null || minor === undefined || !currency ? null : { amountMinor: Number(minor), currency };
815
+ }
716
816
  function toHold(row) {
717
817
  return {
718
818
  id: row.id,
@@ -723,8 +823,35 @@ function toHold(row) {
723
823
  expiresAt: row.expires_at,
724
824
  source: row.source,
725
825
  ...(row.hold_group_id ? { groupId: row.hold_group_id } : {}),
826
+ ...(row.payment_required
827
+ ? {
828
+ paymentRequired: true,
829
+ paymentRef: row.payment_ref ?? null,
830
+ paidAt: row.paid_at ?? null,
831
+ paymentAmount: amountOf(row.payment_amount_minor, row.payment_currency),
832
+ paidAmount: amountOf(row.paid_amount_minor, row.paid_currency),
833
+ }
834
+ : {}),
726
835
  };
727
836
  }
837
+ /** The longest a hold that waits for payment may live (RFC 0004 C3): 30 minutes. */
838
+ export const SLOTLOCK_PAYMENT_HOLD_MAX_TTL_MS = 30 * 60 * 1000;
839
+ /** An amount in a currency's minor unit (cents), and its ISO 4217 code: what a hold's payment is. */
840
+ function isValidAmount(value) {
841
+ const amount = value;
842
+ return (typeof amount === 'object' &&
843
+ amount !== null &&
844
+ Number.isSafeInteger(amount.amountMinor) &&
845
+ amount.amountMinor >= 0 &&
846
+ amount.amountMinor <= 1_000_000_000_000 &&
847
+ typeof amount.currency === 'string' &&
848
+ /^[A-Z]{3}$/.test(amount.currency));
849
+ }
850
+ /** A payment's reference as the integrator's processor names it: 1-255 printable bytes. */
851
+ function isValidPaymentRef(value) {
852
+ return (typeof value === 'string' &&
853
+ /^[\x21-\x7e]{1,255}$/.test(value));
854
+ }
728
855
  function pageCursor(after) {
729
856
  if (after !== undefined &&
730
857
  (!(after.start instanceof Date) ||
@@ -1072,6 +1199,7 @@ function putCommandValue(params) {
1072
1199
  source: params.command.source ?? null,
1073
1200
  // Only when present, so a create without a hold keeps its hash.
1074
1201
  ...(params.command.fromHold ? { fromHold: params.command.fromHold.holdId } : {}),
1202
+ ...(params.command.notify === false ? { notify: false } : {}),
1075
1203
  event: {
1076
1204
  ...params.event,
1077
1205
  start: params.event.start.toISOString(),
@@ -1193,6 +1321,199 @@ async function bookingRuling(tx, resourceId, interval, defaultHours) {
1193
1321
  bufferBeforeMs: judge.bufferBeforeMs,
1194
1322
  };
1195
1323
  }
1324
+ /**
1325
+ * The wall time a reservation says it starts at (RFC 0004 B9), when it names `start` in the
1326
+ * resource's zone; `undefined` when none was given; null when it is not one or names another
1327
+ * instant.
1328
+ */
1329
+ async function localStartFor(tx, resourceId, localStart, start) {
1330
+ if (localStart === undefined)
1331
+ return undefined;
1332
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(localStart);
1333
+ if (!match)
1334
+ return null;
1335
+ const [row] = await tx `SELECT timezone FROM slotlock.resources WHERE id = ${resourceId}`;
1336
+ if (!row)
1337
+ return null;
1338
+ try {
1339
+ const [, year, month, day, hour, minute, second] = match.map((part) => (part === undefined ? 0 : Number(part)));
1340
+ const named = zonedDateTimeToUtc({ year: year, month: month, day: day, hour: hour, minute: minute, second: second }, row.timezone);
1341
+ return named.getTime() === start.getTime() ? `${localStart.length === 16 ? `${localStart}:00` : localStart}` : null;
1342
+ }
1343
+ catch {
1344
+ return null;
1345
+ }
1346
+ }
1347
+ /**
1348
+ * Whether a source's reported busy time (RFC 0004 C1) meets [start, end) on the resource: read
1349
+ * under the resource's write lock, which a source's replacement takes too.
1350
+ */
1351
+ async function sourceBusyLoss(tx, resourceId, start, end) {
1352
+ const [hit] = await tx `
1353
+ SELECT 1 FROM slotlock.source_busy
1354
+ WHERE resource_id = ${resourceId} AND starts_at < ${end} AND ends_at > ${start}
1355
+ LIMIT 1`;
1356
+ return hit !== undefined;
1357
+ }
1358
+ /**
1359
+ * A source's busy time as kept: each interval valid, clipped to the window, sorted and merged
1360
+ * (touching ones too). Null when any is not an interval, or there are too many.
1361
+ */
1362
+ function mergeSourceBusy(busy, window) {
1363
+ if (!Array.isArray(busy) || busy.length > SLOTLOCK_SOURCE_BUSY_MAX)
1364
+ return null;
1365
+ const clipped = [];
1366
+ for (const interval of busy) {
1367
+ const start = interval?.start;
1368
+ const end = interval?.end;
1369
+ if (!(start instanceof Date) || !(end instanceof Date))
1370
+ return null;
1371
+ if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime()) || end <= start)
1372
+ return null;
1373
+ const from = Math.max(start.getTime(), window.start.getTime());
1374
+ const to = Math.min(end.getTime(), window.end.getTime());
1375
+ if (to > from)
1376
+ clipped.push({ start: new Date(from), end: new Date(to) });
1377
+ }
1378
+ return mergeIntervals(clipped);
1379
+ }
1380
+ /** An entry's offer: the hold made for it, said on the entry and the feed. */
1381
+ async function recordWaitlistOffer(tx, tenantRef, entry, offer) {
1382
+ await tx `
1383
+ UPDATE slotlock.waitlist_entries
1384
+ SET status = 'offered', hold_id = ${offer.holdId}, offered_resource_id = ${offer.resourceId},
1385
+ offered_at = statement_timestamp()
1386
+ WHERE id = ${entry.id}`;
1387
+ await tx `
1388
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
1389
+ VALUES (${tenantRef}, ${offer.resourceId}, 'waitlist.offered', ${entry.id},
1390
+ jsonb_strip_nulls(jsonb_build_object(
1391
+ 'id', ${entry.id}::uuid, 'hold_id', ${offer.holdId}::uuid,
1392
+ 'resource_id', ${offer.resourceId}::uuid, 'pool', ${entry.pool_ref}::text,
1393
+ 'start', ${offer.start}::timestamptz, 'end', ${offer.end}::timestamptz,
1394
+ 'expires_at', ${offer.expiresAt}::timestamptz,
1395
+ 'holder_ref', ${entry.holder_ref}::text)),
1396
+ 'slotlock:maintenance')`;
1397
+ }
1398
+ /**
1399
+ * The earliest slot of the entry's length within its window, from now, on its resource or any
1400
+ * member of its pool (by id), held for its holder: what is busy is read with each resource's
1401
+ * buffers, and the hold itself still meets the booking rules and the constraint. Null when none.
1402
+ */
1403
+ async function offerSlotOfLength(tx, inner, tenantRef, entry) {
1404
+ const durationMs = Number(entry.duration_ms);
1405
+ const candidates = await tx `
1406
+ SELECT resources.id,
1407
+ COALESCE(least(greatest((effective.effective_booking_rules->>'bufferBeforeMinutes')::numeric, 0), 1440)::int, 0) * 60000
1408
+ AS before_ms,
1409
+ COALESCE(least(greatest((effective.effective_booking_rules->>'turnaroundMinutes')::numeric, 0), 10080)::int, 0) * 60000
1410
+ AS after_ms,
1411
+ statement_timestamp() AS now
1412
+ FROM slotlock.resources, LATERAL (SELECT ${tx.unsafe(EFFECTIVE_BOOKING_RULES)}) effective
1413
+ WHERE resources.tenant_ref = ${tenantRef} AND resources.archived_at IS NULL
1414
+ AND (resources.id = ${entry.resource_id}::uuid
1415
+ OR (${entry.resource_id}::uuid IS NULL AND resources.pool_ref = ${entry.pool_ref}))
1416
+ ORDER BY resources.id
1417
+ LIMIT ${OFFER_CANDIDATES}`;
1418
+ for (const candidate of candidates) {
1419
+ const from = new Date(Math.ceil(Math.max(entry.starts_at.getTime(), candidate.now.getTime()) / 60_000) * 60_000);
1420
+ if (from.getTime() + durationMs > entry.ends_at.getTime())
1421
+ continue;
1422
+ const reach = {
1423
+ start: new Date(from.getTime() - Number(candidate.before_ms) - 86_400_000),
1424
+ end: new Date(entry.ends_at.getTime() + Number(candidate.after_ms) + 86_400_000),
1425
+ };
1426
+ // Bookings and holds, a composite's parent's or parts' claims, and calendars' busy time.
1427
+ const busy = await tx `
1428
+ SELECT occupied_starts_at AS start, occupied_ends_at AS end FROM (
1429
+ SELECT COALESCE(occupied_starts_at, starts_at) AS occupied_starts_at,
1430
+ COALESCE(occupied_ends_at, ends_at) AS occupied_ends_at
1431
+ FROM slotlock.reservations
1432
+ WHERE resource_id = ${candidate.id}
1433
+ AND tstzrange(COALESCE(occupied_starts_at, starts_at), COALESCE(occupied_ends_at, ends_at), '[)')
1434
+ && tstzrange(${reach.start}::timestamptz, ${reach.end}::timestamptz, '[)')
1435
+ AND (status <> 'held' OR expires_at > statement_timestamp())
1436
+ UNION ALL
1437
+ ${compositeBusy(tx, candidate.id, reach)}
1438
+ UNION ALL
1439
+ ${sourceBusy(tx, candidate.id, reach)}
1440
+ ) busy`;
1441
+ // A slot [s, s + d) with its buffers occupies [s - before, s + d + after).
1442
+ const blocked = busy.map((taken) => ({
1443
+ start: new Date(taken.start.getTime() - Number(candidate.after_ms)),
1444
+ end: new Date(taken.end.getTime() + Number(candidate.before_ms)),
1445
+ }));
1446
+ const slot = findNextAvailable({
1447
+ busy: blocked,
1448
+ windows: [{ start: from, end: entry.ends_at }],
1449
+ durationMs,
1450
+ align: (start) => new Date(Math.ceil(start.getTime() / 60_000) * 60_000),
1451
+ });
1452
+ if (!slot)
1453
+ continue;
1454
+ const hold = await inner.acquireHold({
1455
+ resourceId: candidate.id,
1456
+ start: slot.start,
1457
+ end: slot.end,
1458
+ ttlMs: Number(entry.offer_ttl_ms),
1459
+ tenantRef,
1460
+ holderRef: entry.holder_ref,
1461
+ source: 'slotlock:waitlist',
1462
+ });
1463
+ if (!hold.ok)
1464
+ continue;
1465
+ return { resourceId: candidate.id, holdId: hold.hold.id, start: slot.start, end: slot.end, expiresAt: hold.hold.expiresAt };
1466
+ }
1467
+ return null;
1468
+ }
1469
+ /** An admin's reason for going past a cutoff (RFC 0004 B5): 1-500 characters, no control characters. */
1470
+ function isValidOverrideReason(value) {
1471
+ return typeof value === 'string' && /^[^\u0000-\u001f\u007f]{1,500}$/u.test(value) && value.trim() !== '';
1472
+ }
1473
+ /**
1474
+ * A resource's effective booking rules and the database clock: what a cancellation or a move is
1475
+ * judged by (RFC 0004 B5). Rules that do not read refuse nothing here.
1476
+ */
1477
+ async function rulesNow(tx, resourceId) {
1478
+ const [row] = await tx `
1479
+ SELECT ${tx.unsafe(EFFECTIVE_BOOKING_RULES)}, statement_timestamp() AS now
1480
+ FROM slotlock.resources WHERE id = ${resourceId}`;
1481
+ if (!row)
1482
+ return { rules: undefined, now: new Date() };
1483
+ if (row.effective_booking_rules === null || row.effective_booking_rules === undefined)
1484
+ return { rules: undefined, now: row.now };
1485
+ return { rules: parseEffectiveBookingRules(row.effective_booking_rules).rules, now: row.now };
1486
+ }
1487
+ /**
1488
+ * Mark what the next change of a booking records on the feed (RFC 0004 B5): a cancellation's fee
1489
+ * and an admin's reason for going past a cutoff. Cleared with nulls.
1490
+ */
1491
+ async function noteEnding(tx, fee, overrideReason) {
1492
+ await tx `
1493
+ SELECT set_config('slotlock.cancellation_fee', ${fee ? JSON.stringify(fee) : ''}, true),
1494
+ set_config('slotlock.override_reason', ${overrideReason ?? ''}, true)`;
1495
+ }
1496
+ /**
1497
+ * Whether a one-off event may be cancelled now under its resource's cancel cutoff (RFC 0004 B5),
1498
+ * and the fee a late cancellation records, marked for the feed. A series is not held to the cutoff:
1499
+ * cancelling it cancels what is still to come.
1500
+ */
1501
+ async function eventEnding(tx, event, enforce, overrideReason) {
1502
+ if (event.recurring)
1503
+ return { ok: true, fee: null };
1504
+ const judged = await rulesNow(tx, event.resource_id);
1505
+ let note = null;
1506
+ if (enforce && cutoffRuleBroken(judged.rules, 'cancel', event.starts_at, judged.now)) {
1507
+ if (overrideReason === undefined) {
1508
+ return { ok: false, refusal: { ok: false, code: 'booking_rule_violated', rule: 'cancel_cutoff' } };
1509
+ }
1510
+ note = overrideReason;
1511
+ }
1512
+ const due = cancellationFeeDue(judged.rules, event.starts_at, judged.now);
1513
+ const fee = due ? { amountMinor: due.amountMinor, currency: due.currency } : null;
1514
+ await noteEnding(tx, fee ? { amount_minor: fee.amountMinor, currency: fee.currency } : null, note);
1515
+ return { ok: true, fee };
1516
+ }
1196
1517
  /** How recently a sync must have read a resource whose booking rules require certainty. */
1197
1518
  const CERTAIN_COVERAGE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
1198
1519
  /**
@@ -1201,25 +1522,35 @@ const CERTAIN_COVERAGE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
1201
1522
  */
1202
1523
  async function bookingJudge(tx, resourceId, defaultHours) {
1203
1524
  const [row] = await tx `
1204
- SELECT ${tx.unsafe(EFFECTIVE_BOOKING_RULES)}, availability_rules, timezone,
1205
- statement_timestamp() AS now
1525
+ SELECT ${tx.unsafe(EFFECTIVE_BOOKING_RULES)}, availability_rules,
1526
+ CASE WHEN resources.availability_rules IS NULL
1527
+ THEN slotlock.inherited_availability(resources.tenant_ref, resources.location_ref) END
1528
+ AS inherited_availability,
1529
+ timezone, statement_timestamp() AS now,
1530
+ to_char((statement_timestamp() AT TIME ZONE 'UTC')::date - 2, 'YYYY-MM-DD') AS today
1206
1531
  FROM slotlock.resources WHERE id = ${resourceId}`;
1207
1532
  if (!row || row.effective_booking_rules === null || row.effective_booking_rules === undefined) {
1208
1533
  return { broken: () => null, turnaroundMs: 0, bufferBeforeMs: 0 };
1209
1534
  }
1210
- const { effectiveBookingRules: rules, availabilityRules } = toResource({
1535
+ const resource = toResource({
1211
1536
  id: resourceId,
1212
1537
  external_ref: null,
1213
1538
  tenant_ref: null,
1214
1539
  timezone: row.timezone,
1215
1540
  availability_rules: row.availability_rules,
1541
+ inherited_availability: row.inherited_availability,
1216
1542
  booking_rules: null,
1217
1543
  pool_ref: null,
1218
1544
  visibility: 'shared',
1219
1545
  archived_at: null,
1220
1546
  effective_booking_rules: row.effective_booking_rules,
1221
1547
  });
1222
- const hours = availabilityRules ?? defaultHours;
1548
+ const rules = resource.effectiveBookingRules;
1549
+ const hours = applicableHours(resource, defaultHours).rules;
1550
+ // Enforced hours read the exceptions from two days back (a zone is at most fourteen hours from UTC).
1551
+ const exceptions = rules?.enforceHours === true
1552
+ ? ((await rankedExceptions(tx, [resourceId], { from: row.today })).get(resourceId) ?? [])
1553
+ : [];
1223
1554
  const coverage = rules?.requireCertain === true
1224
1555
  ? await tx `
1225
1556
  SELECT starts_at, ends_at,
@@ -1246,6 +1577,7 @@ async function bookingJudge(tx, resourceId, defaultHours) {
1246
1577
  rules: checks.first ? rules : later,
1247
1578
  timezone: row.timezone,
1248
1579
  hours,
1580
+ exceptions,
1249
1581
  interval,
1250
1582
  now: row.now,
1251
1583
  }) ?? (uncertain(interval) ? 'uncertain' : null),
@@ -1324,7 +1656,7 @@ function pgCode(err) {
1324
1656
  export const SLOTLOCK_MAX_POOL_MEMBERS = 100;
1325
1657
  /** The most pools of one tenant that may have default booking rules (`setBookingRuleDefaults`). */
1326
1658
  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';
1659
+ 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
1660
  function toWaitlistEntry(row) {
1329
1661
  return {
1330
1662
  id: row.id,
@@ -1339,6 +1671,8 @@ function toWaitlistEntry(row) {
1339
1671
  offeredResourceId: row.offered_resource_id,
1340
1672
  offeredAt: row.offered_at,
1341
1673
  createdAt: row.created_at,
1674
+ durationMs: row.duration_ms === null ? null : Number(row.duration_ms),
1675
+ decidedAt: row.decided_at,
1342
1676
  };
1343
1677
  }
1344
1678
  export function isPoolName(value) {
@@ -1353,6 +1687,9 @@ function invalidPoolError() {
1353
1687
  const RESOURCE_COLUMNS = `id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
1354
1688
  visibility, archived_at, name, description, kind, capacity, labels, attributes, location_ref,
1355
1689
  parent_id, part_index,
1690
+ CASE WHEN resources.availability_rules IS NULL
1691
+ THEN slotlock.inherited_availability(resources.tenant_ref, resources.location_ref) END
1692
+ AS inherited_availability,
1356
1693
  (SELECT place.name FROM slotlock.locations place
1357
1694
  WHERE place.tenant_ref = resources.tenant_ref AND place.ref = resources.location_ref) AS location_name,
1358
1695
  ARRAY(SELECT member.group_ref FROM slotlock.resource_group_members member
@@ -1432,6 +1769,11 @@ function toResource(row) {
1432
1769
  resource.availabilityRules = [];
1433
1770
  }
1434
1771
  }
1772
+ if (resource.availabilityRules === undefined) {
1773
+ const inherited = storedInheritedAvailability(row.inherited_availability);
1774
+ if (inherited)
1775
+ resource.inheritedAvailability = inherited;
1776
+ }
1435
1777
  if (row.booking_rules !== null && row.booking_rules !== undefined) {
1436
1778
  // Written validated; anything else refuses every booking, never as no rule.
1437
1779
  resource.bookingRules = storedBookingRules(row.booking_rules);
@@ -2097,6 +2439,18 @@ function compositeBusy(executor, resourceId, window) {
2097
2439
  AND claim.during && tstzrange(${window.start}, ${window.end}, '[)')
2098
2440
  AND (claim.status <> 'held' OR claim.expires_at > statement_timestamp())`;
2099
2441
  }
2442
+ /**
2443
+ * Busy time calendars reported without their events (RFC 0004 C1) that meets `window`, as
2444
+ * (occupied_starts_at, occupied_ends_at): every read of a resource's busy time unions it in, as
2445
+ * every write refuses it (sourceBusyLoss), so a time offered is a time the write accepts.
2446
+ */
2447
+ function sourceBusy(executor, resourceId, window) {
2448
+ return executor `
2449
+ SELECT busy.starts_at AS occupied_starts_at, busy.ends_at AS occupied_ends_at
2450
+ FROM slotlock.source_busy busy
2451
+ WHERE busy.resource_id = ${resourceId}
2452
+ AND busy.starts_at < ${window.end} AND busy.ends_at > ${window.start}`;
2453
+ }
2100
2454
  /**
2101
2455
  * Structured answer after losing to the EXCLUDE arbiter: name the live winner.
2102
2456
  * conflictingReservationId is null in one narrow race (the blocker expired between the constraint
@@ -2241,18 +2595,31 @@ export const SLOTLOCK_TENANT_WRITE_METHODS = Object.freeze([
2241
2595
  'declineApproval',
2242
2596
  'resetTestTenant',
2243
2597
  'fastForwardTestTenant',
2598
+ 'storeTenantAvailability',
2244
2599
  'setTenantAvailability',
2600
+ 'setLocationAvailability',
2601
+ 'createAvailabilityException',
2602
+ 'deleteAvailabilityException',
2603
+ 'replaceImportedExceptions',
2604
+ 'importHolidayCalendar',
2605
+ 'deleteHolidayCalendar',
2606
+ 'noteHolidayCalendarFailure',
2245
2607
  'createReservation',
2246
2608
  'createExternalReservation',
2247
2609
  'rescheduleExternalReservation',
2248
2610
  'cancelExternalReservation',
2249
2611
  'acquireHold',
2612
+ 'markHoldPaid',
2613
+ 'putContact',
2614
+ 'deleteContact',
2250
2615
  'confirmHold',
2251
2616
  'releaseHold',
2252
2617
  'putCalendarEvent',
2253
2618
  'cancelCalendarEvent',
2254
2619
  'cancelCalendarEventById',
2255
2620
  'recordCalendarCoverage',
2621
+ 'replaceSourceBusy',
2622
+ 'reportCoverageConflict',
2256
2623
  'createWebhookEndpoint',
2257
2624
  'deleteWebhookEndpoint',
2258
2625
  'enableWebhookEndpoint',
@@ -2261,12 +2628,14 @@ export const SLOTLOCK_TENANT_WRITE_METHODS = Object.freeze([
2261
2628
  'checkInReservation',
2262
2629
  'joinWaitlist',
2263
2630
  'leaveWaitlist',
2631
+ 'declineWaitlistOffer',
2264
2632
  'putAssistantSettings',
2265
2633
  'deleteAssistantSettings',
2266
2634
  'resealAssistantKey',
2267
- 'putAssistantConversation',
2635
+ 'putAssistantChat',
2636
+ 'renameAssistantChat',
2268
2637
  'settleAssistantAction',
2269
- 'deleteAssistantConversation',
2638
+ 'deleteAssistantChat',
2270
2639
  ]);
2271
2640
  function tenantSuspendedError() {
2272
2641
  return Object.assign(new Error('Slotlock tenant is suspended: it may read, and write nothing'), {
@@ -2348,6 +2717,9 @@ function buildSlotlockStore(sql, options, refusal) {
2348
2717
  retentionDays,
2349
2718
  maxHoldsPerHolder,
2350
2719
  ...(maxHeldHoursPerHolder !== undefined ? { maxHeldHoursPerHolder } : {}),
2720
+ // The server's hours judge a tenant's writes too: without them, enforced hours would refuse
2721
+ // every booking on a resource that inherits nothing.
2722
+ ...(options.defaultAvailabilityRules !== undefined ? { defaultAvailabilityRules: options.defaultAvailabilityRules } : {}),
2351
2723
  };
2352
2724
  /**
2353
2725
  * Under the tenant's lock for `kind`: the limit when the tenant is at it, else null. Writes that
@@ -3185,18 +3557,75 @@ function buildSlotlockStore(sql, options, refusal) {
3185
3557
  WHERE tenant_ref = ${params.tenantRef} AND pool_ref = ${params.pool}
3186
3558
  AND member_order = 'round_robin'`;
3187
3559
  },
3560
+ async storeTenantAvailability(params) {
3561
+ if (!isValidIdentity(params.tenantRef)) {
3562
+ throw Object.assign(new Error('Slotlock tenant reference must be 1-500 UTF-8 bytes'), {
3563
+ code: 'invalid_identity',
3564
+ });
3565
+ }
3566
+ // What `matching` compares with is read in the same transaction as what replaces it.
3567
+ return inTransaction(sql, (tx) => storeTenantAvailability(tx, params), 'read committed');
3568
+ },
3188
3569
  async setTenantAvailability(params) {
3189
3570
  if (!isValidIdentity(params.tenantRef)) {
3190
3571
  throw Object.assign(new Error('Slotlock tenant reference must be 1-500 UTF-8 bytes'), {
3191
3572
  code: 'invalid_identity',
3192
3573
  });
3193
3574
  }
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;
3575
+ const stored = await inTransaction(sql, (tx) => storeTenantAvailability(tx, { tenantRef: params.tenantRef, rules: params.rules, overrideResources: 'all' }), 'read committed');
3576
+ return stored.inheriting;
3577
+ },
3578
+ async setLocationAvailability(params) {
3579
+ if (!isValidIdentity(params.tenantRef)) {
3580
+ throw Object.assign(new Error('Slotlock tenant reference must be 1-500 UTF-8 bytes'), {
3581
+ code: 'invalid_identity',
3582
+ });
3583
+ }
3584
+ return setLocationAvailability(sql, params);
3585
+ },
3586
+ async getAvailabilityDefaults(params) {
3587
+ return getAvailabilityDefaults(sql, params);
3588
+ },
3589
+ async createAvailabilityException(params) {
3590
+ if (!isValidIdentity(params.tenantRef)) {
3591
+ throw Object.assign(new Error('Slotlock tenant reference must be 1-500 UTF-8 bytes'), {
3592
+ code: 'invalid_identity',
3593
+ });
3594
+ }
3595
+ return createAvailabilityException(sql, params);
3596
+ },
3597
+ async listAvailabilityExceptions(params) {
3598
+ return listAvailabilityExceptions(sql, params);
3599
+ },
3600
+ async deleteAvailabilityException(params) {
3601
+ return deleteAvailabilityException(sql, params);
3602
+ },
3603
+ async replaceImportedExceptions(params) {
3604
+ return replaceImportedExceptions(sql, params);
3605
+ },
3606
+ async importHolidayCalendar(params) {
3607
+ if (!isValidIdentity(params.tenantRef)) {
3608
+ throw Object.assign(new Error('Slotlock tenant reference must be 1-500 UTF-8 bytes'), {
3609
+ code: 'invalid_identity',
3610
+ });
3611
+ }
3612
+ // The calendar, its holidays and what it dropped, all or none.
3613
+ return inTransaction(sql, (tx) => importHolidayCalendar(tx, params), 'read committed');
3614
+ },
3615
+ async listHolidayCalendars(params) {
3616
+ return listHolidayCalendars(sql, params);
3617
+ },
3618
+ async deleteHolidayCalendar(params) {
3619
+ return inTransaction(sql, (tx) => deleteHolidayCalendar(tx, params), 'read committed');
3620
+ },
3621
+ async dueHolidayCalendars(params) {
3622
+ return dueHolidayCalendars(sql, params);
3623
+ },
3624
+ async noteHolidayCalendarFailure(params) {
3625
+ return noteHolidayCalendarFailure(sql, params);
3626
+ },
3627
+ async availabilityExceptionsFor(params) {
3628
+ return rankedExceptions(sql, params.resourceIds, exceptionQueryDays(params.window));
3200
3629
  },
3201
3630
  async listResources(params) {
3202
3631
  if (!isValidIdentity(params.tenantRef)) {
@@ -3388,6 +3817,9 @@ function buildSlotlockStore(sql, options, refusal) {
3388
3817
  return { ok: false, code: 'resource_not_found' };
3389
3818
  if (locked.archived)
3390
3819
  return { ok: false, code: 'resource_archived' };
3820
+ // Busy time a calendar reported without its events (RFC 0004 C1) is refused as a booking's is.
3821
+ if (await sourceBusyLoss(tx, params.resourceId, occupied.start, occupied.end))
3822
+ return { ok: false, code: 'overlap', conflictingReservationId: null };
3391
3823
  await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
3392
3824
  const [row] = await tx `
3393
3825
  INSERT INTO slotlock.reservations (
@@ -3400,7 +3832,7 @@ function buildSlotlockStore(sql, options, refusal) {
3400
3832
  ${params.source ?? null}
3401
3833
  )
3402
3834
  RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
3403
- buffer_after_ms, revision, source`;
3835
+ buffer_after_ms, revision, source, contact_ref, notify, reminder_minutes`;
3404
3836
  if (!row)
3405
3837
  throw new Error('slotlock: reservation insert returned no row');
3406
3838
  return { ok: true, reservation: toReservation(row) };
@@ -3446,7 +3878,7 @@ function buildSlotlockStore(sql, options, refusal) {
3446
3878
  // holds.
3447
3879
  const existing = await tx `
3448
3880
  SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
3449
- buffer_after_ms, revision, source
3881
+ buffer_after_ms, revision, source, contact_ref, notify, reminder_minutes
3450
3882
  FROM slotlock.reservations
3451
3883
  WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
3452
3884
  AND calendar_event_id IS NULL
@@ -3485,24 +3917,31 @@ function buildSlotlockStore(sql, options, refusal) {
3485
3917
  }))) {
3486
3918
  return { ok: false, code: 'recurrence_not_materialized' };
3487
3919
  }
3920
+ const wall = await localStartFor(tx, params.resourceId, params.localStart, params.start);
3921
+ if (wall === null)
3922
+ return { ok: false, code: 'invalid_local_start' };
3488
3923
  // Writers to this resource take turns from here to the insert (lockResourceForWrite).
3489
3924
  const locked = await lockResourceForWrite(tx, params.resourceId, params.tenantRef);
3490
3925
  if (!locked)
3491
3926
  return { ok: false, code: 'resource_tenant_mismatch' };
3492
3927
  if (locked.archived)
3493
3928
  return { ok: false, code: 'resource_archived' };
3929
+ // Busy time a calendar reported without its events (RFC 0004 C1) is refused as a booking's is.
3930
+ if (await sourceBusyLoss(tx, params.resourceId, occupied.start, occupied.end))
3931
+ return { ok: false, code: 'overlap', conflictingReservationId: null };
3494
3932
  await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
3495
3933
  const rows = await tx `
3496
3934
  INSERT INTO slotlock.reservations (
3497
3935
  resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
3498
- buffer_before_ms, revision, source
3936
+ buffer_before_ms, revision, source, wall_starts_at, contact_ref, notify, reminder_minutes
3499
3937
  ) VALUES (
3500
3938
  ${params.resourceId}, ${params.tenantRef}, ${params.externalRef}, ${params.start},
3501
3939
  ${params.end}, ${bufferAfterMs}, ${writtenBefore}, ${params.revision},
3502
- ${params.source ?? null}
3940
+ ${params.source ?? null}, ${wall ?? null}::timestamp, ${params.contactRef ?? null},
3941
+ ${params.notify ?? true}, ${reminderColumn(params.reminderMinutes)}::integer[]
3503
3942
  )
3504
3943
  RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
3505
- buffer_after_ms, revision, source`;
3944
+ buffer_after_ms, revision, source, contact_ref, notify, reminder_minutes`;
3506
3945
  const row = rows[0];
3507
3946
  if (!row)
3508
3947
  throw new Error('slotlock: external reservation insert returned no row');
@@ -3538,7 +3977,7 @@ function buildSlotlockStore(sql, options, refusal) {
3538
3977
  await lockExternalIdentity(tx, params.tenantRef, params.externalRef);
3539
3978
  const existing = await tx `
3540
3979
  SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
3541
- buffer_after_ms, revision, source
3980
+ buffer_after_ms, revision, source, contact_ref, notify, reminder_minutes
3542
3981
  FROM slotlock.reservations
3543
3982
  WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
3544
3983
  AND calendar_event_id IS NULL
@@ -3566,6 +4005,21 @@ function buildSlotlockStore(sql, options, refusal) {
3566
4005
  if (params.revision !== currentRevision + 1) {
3567
4006
  return { ok: false, code: 'revision_conflict' };
3568
4007
  }
4008
+ // A move in time or to another resource, past the booking's reschedule cutoff (RFC 0004
4009
+ // B5), only with an admin's reason.
4010
+ const moved = found.starts_at.getTime() !== params.start.getTime() ||
4011
+ found.ends_at.getTime() !== params.end.getTime() ||
4012
+ found.resource_id !== params.resourceId;
4013
+ let overrideNote = null;
4014
+ if (moved) {
4015
+ const judged = await rulesNow(tx, found.resource_id);
4016
+ if (cutoffRuleBroken(judged.rules, 'reschedule', found.starts_at, judged.now)) {
4017
+ if (params.overrideReason === undefined) {
4018
+ return { ok: false, code: 'booking_rule_violated', rule: 'reschedule_cutoff' };
4019
+ }
4020
+ overrideNote = params.overrideReason;
4021
+ }
4022
+ }
3569
4023
  const resourceState = await tenantResourceState(tx, params.resourceId, params.tenantRef);
3570
4024
  if (resourceState === 'missing')
3571
4025
  return { ok: false, code: 'resource_tenant_mismatch' };
@@ -3573,6 +4027,11 @@ function buildSlotlockStore(sql, options, refusal) {
3573
4027
  return { ok: false, code: 'resource_archived' };
3574
4028
  if (ruling.rule)
3575
4029
  return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
4030
+ if (overrideNote !== null)
4031
+ await noteEnding(tx, null, overrideNote);
4032
+ const wall = await localStartFor(tx, params.resourceId, params.localStart, params.start);
4033
+ if (wall === null)
4034
+ return { ok: false, code: 'invalid_local_start' };
3576
4035
  bufferAfterMs = Math.max(params.bufferAfterMs, ruling.turnaroundMs);
3577
4036
  bufferBeforeMs = ruling.bufferBeforeMs;
3578
4037
  const occupied = occupiedTime({ ...params, bufferBeforeMs, bufferAfterMs });
@@ -3589,6 +4048,9 @@ function buildSlotlockStore(sql, options, refusal) {
3589
4048
  return { ok: false, code: 'resource_tenant_mismatch' };
3590
4049
  if (locked.archived)
3591
4050
  return { ok: false, code: 'resource_archived' };
4051
+ // Busy time a calendar reported without its events (RFC 0004 C1) is refused as a booking's is.
4052
+ if (await sourceBusyLoss(tx, params.resourceId, occupied.start, occupied.end))
4053
+ return { ok: false, code: 'overlap', conflictingReservationId: null };
3592
4054
  await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
3593
4055
  const rows = await tx `
3594
4056
  UPDATE slotlock.reservations
@@ -3599,6 +4061,13 @@ function buildSlotlockStore(sql, options, refusal) {
3599
4061
  buffer_before_ms = ${bufferBeforeMs},
3600
4062
  revision = ${params.revision},
3601
4063
  source = ${params.source ?? null},
4064
+ -- Whom to tell, and whether (RFC 0004 P3): as sent, like the rest.
4065
+ contact_ref = ${params.contactRef ?? null},
4066
+ notify = ${params.notify ?? true},
4067
+ reminder_minutes = ${reminderColumn(params.reminderMinutes)}::integer[],
4068
+ -- The wall time it now says it starts at, if any (RFC 0004 B9).
4069
+ wall_starts_at = ${wall ?? null}::timestamp,
4070
+ zone_shift_reported_at = NULL,
3602
4071
  -- A check-in is for the time it was made for: a move to another start clears it.
3603
4072
  checked_in_at = CASE
3604
4073
  WHEN starts_at = ${params.start} AND resource_id = ${params.resourceId}
@@ -3606,7 +4075,7 @@ function buildSlotlockStore(sql, options, refusal) {
3606
4075
  END
3607
4076
  WHERE id = ${found.id}
3608
4077
  RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
3609
- buffer_after_ms, revision, source`;
4078
+ buffer_after_ms, revision, source, contact_ref, notify, reminder_minutes`;
3610
4079
  const row = rows[0];
3611
4080
  if (!row)
3612
4081
  throw new Error('slotlock: external reservation update returned no row');
@@ -3633,6 +4102,9 @@ function buildSlotlockStore(sql, options, refusal) {
3633
4102
  if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.externalRef)) {
3634
4103
  return { ok: false, code: 'invalid_identity' };
3635
4104
  }
4105
+ if (params.overrideReason !== undefined && !isValidOverrideReason(params.overrideReason)) {
4106
+ return { ok: false, code: 'invalid_identity' };
4107
+ }
3636
4108
  if (!Number.isSafeInteger(params.revision) || params.revision <= 0) {
3637
4109
  return { ok: false, code: 'invalid_revision' };
3638
4110
  }
@@ -3653,11 +4125,15 @@ function buildSlotlockStore(sql, options, refusal) {
3653
4125
  : { ok: false, code: 'reservation_cancelled' };
3654
4126
  }
3655
4127
  const existing = await tx `
3656
- SELECT id, revision FROM slotlock.reservations
4128
+ SELECT id, revision, resource_id, starts_at FROM slotlock.reservations
3657
4129
  WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
3658
4130
  AND calendar_event_id IS NULL
3659
4131
  FOR UPDATE`;
3660
4132
  const found = existing[0];
4133
+ // Past its cancel cutoff a booking is cancelled only with an admin's reason, and a late
4134
+ // cancellation records its fee (RFC 0004 B5).
4135
+ let fee = null;
4136
+ let overrideNote = null;
3661
4137
  if (found) {
3662
4138
  const currentRevision = Number(found.revision);
3663
4139
  if (params.revision <= currentRevision)
@@ -3665,6 +4141,14 @@ function buildSlotlockStore(sql, options, refusal) {
3665
4141
  if (params.revision !== currentRevision + 1) {
3666
4142
  return { ok: false, code: 'revision_conflict' };
3667
4143
  }
4144
+ const judged = await rulesNow(tx, found.resource_id);
4145
+ if (cutoffRuleBroken(judged.rules, 'cancel', found.starts_at, judged.now)) {
4146
+ if (params.overrideReason === undefined) {
4147
+ return { ok: false, code: 'booking_rule_violated', rule: 'cancel_cutoff' };
4148
+ }
4149
+ overrideNote = params.overrideReason;
4150
+ }
4151
+ fee = cancellationFeeDue(judged.rules, found.starts_at, judged.now);
3668
4152
  }
3669
4153
  if (!found) {
3670
4154
  // This reference is a calendar event's occupation, not a booking: nothing to cancel or
@@ -3683,14 +4167,25 @@ function buildSlotlockStore(sql, options, refusal) {
3683
4167
  }
3684
4168
  // A reference no booking holds yet is retired too, so a create delivered after its cancel
3685
4169
  // cannot bring it back.
4170
+ const recorded = fee ? { amount_minor: fee.amountMinor, currency: fee.currency } : null;
3686
4171
  await tx `
3687
- INSERT INTO slotlock.reservation_tombstones (tenant_ref, external_ref, revision, stray)
3688
- VALUES (${params.tenantRef}, ${params.externalRef}, ${params.revision}, ${!found})`;
4172
+ INSERT INTO slotlock.reservation_tombstones (
4173
+ tenant_ref, external_ref, revision, stray, end_reason, cancellation_fee, override_reason
4174
+ )
4175
+ VALUES (${params.tenantRef}, ${params.externalRef}, ${params.revision}, ${!found}, 'cancelled',
4176
+ ${recorded ? tx.json(recorded) : null}::jsonb, ${overrideNote})`;
3689
4177
  if (!found)
3690
4178
  return { ok: true, cancelled: false, revision: params.revision };
4179
+ await noteEnding(tx, recorded, overrideNote);
3691
4180
  const deleted = await tx `
3692
4181
  DELETE FROM slotlock.reservations WHERE id = ${found.id} RETURNING id`;
3693
- return { ok: true, cancelled: deleted.length === 1, revision: params.revision };
4182
+ await noteEnding(tx, null, null);
4183
+ return {
4184
+ ok: true,
4185
+ cancelled: deleted.length === 1,
4186
+ revision: params.revision,
4187
+ ...(fee ? { cancellationFee: { amountMinor: fee.amountMinor, currency: fee.currency } } : {}),
4188
+ };
3694
4189
  });
3695
4190
  },
3696
4191
  async acquireHold(params) {
@@ -3702,6 +4197,16 @@ function buildSlotlockStore(sql, options, refusal) {
3702
4197
  if (!Number.isFinite(params.ttlMs) || params.ttlMs <= 0 || params.ttlMs > MAX_HOLD_TTL_MS) {
3703
4198
  return { ok: false, code: 'invalid_ttl' };
3704
4199
  }
4200
+ const paymentRequired = params.paymentRequired === true;
4201
+ if (params.paymentRequired !== undefined && typeof params.paymentRequired !== 'boolean') {
4202
+ return { ok: false, code: 'invalid_identity' };
4203
+ }
4204
+ if (paymentRequired && params.ttlMs > SLOTLOCK_PAYMENT_HOLD_MAX_TTL_MS) {
4205
+ return { ok: false, code: 'invalid_ttl' };
4206
+ }
4207
+ if (params.payment !== undefined && (!paymentRequired || !isValidAmount(params.payment))) {
4208
+ return { ok: false, code: 'invalid_payment' };
4209
+ }
3705
4210
  const bufferAfterMs = params.bufferAfterMs ?? 0;
3706
4211
  if (!Number.isSafeInteger(bufferAfterMs) ||
3707
4212
  bufferAfterMs < 0 ||
@@ -3736,6 +4241,8 @@ function buildSlotlockStore(sql, options, refusal) {
3736
4241
  source: params.source ?? null,
3737
4242
  ...(params.groupId !== undefined ? { groupId: params.groupId } : {}),
3738
4243
  ...(holderRef !== undefined ? { holderRef } : {}),
4244
+ ...(paymentRequired ? { paymentRequired } : {}),
4245
+ ...(params.payment ? { payment: params.payment } : {}),
3739
4246
  });
3740
4247
  const ttlSeconds = params.ttlMs / 1000;
3741
4248
  // The holder's caps: the store's, lowered by its key's own (RFC 0004 A5), never raised.
@@ -3764,7 +4271,14 @@ function buildSlotlockStore(sql, options, refusal) {
3764
4271
  const [prior] = await tx `
3765
4272
  SELECT hold_id AS id, resource_id, starts_at, ends_at, buffer_after_ms, expires_at,
3766
4273
  source, payload_hash,
3767
- (SELECT hold_group_id FROM slotlock.reservations WHERE id = hold_id) AS hold_group_id
4274
+ (SELECT hold_group_id FROM slotlock.reservations WHERE id = hold_id) AS hold_group_id,
4275
+ (SELECT payment_required FROM slotlock.reservations WHERE id = hold_id) AS payment_required,
4276
+ (SELECT payment_ref FROM slotlock.reservations WHERE id = hold_id) AS payment_ref,
4277
+ (SELECT paid_at FROM slotlock.reservations WHERE id = hold_id) AS paid_at,
4278
+ (SELECT payment_amount_minor FROM slotlock.reservations WHERE id = hold_id) AS payment_amount_minor,
4279
+ (SELECT payment_currency FROM slotlock.reservations WHERE id = hold_id) AS payment_currency,
4280
+ (SELECT paid_amount_minor FROM slotlock.reservations WHERE id = hold_id) AS paid_amount_minor,
4281
+ (SELECT paid_currency FROM slotlock.reservations WHERE id = hold_id) AS paid_currency
3768
4282
  FROM slotlock.hold_commands
3769
4283
  WHERE tenant_ref = ${tenantRef ?? null} AND principal_ref = ${principalRef ?? null}
3770
4284
  AND idempotency_key = ${idempotencyKey ?? null}`;
@@ -3833,18 +4347,23 @@ function buildSlotlockStore(sql, options, refusal) {
3833
4347
  return { ok: false, code: 'resource_not_found' };
3834
4348
  if (locked.archived)
3835
4349
  return { ok: false, code: 'resource_archived' };
4350
+ // Busy time a calendar reported without its events (RFC 0004 C1) is refused as a booking's is.
4351
+ if (await sourceBusyLoss(tx, params.resourceId, reach.start, reach.end))
4352
+ return { ok: false, code: 'overlap', conflictingReservationId: null };
3836
4353
  await reapExpiredHolds(tx, params.resourceId, reach.start, reach.end);
3837
4354
  const [row] = await tx `
3838
4355
  INSERT INTO slotlock.reservations (
3839
4356
  resource_id, starts_at, ends_at, buffer_after_ms, buffer_before_ms, status,
3840
- expires_at, source, holder_ref, hold_group_id
4357
+ expires_at, source, holder_ref, hold_group_id, payment_required,
4358
+ payment_amount_minor, payment_currency
3841
4359
  ) VALUES (
3842
4360
  ${params.resourceId}, ${params.start}, ${params.end}, ${written}, ${writtenBefore}, 'held',
3843
4361
  statement_timestamp() + make_interval(secs => ${ttlSeconds}), ${params.source ?? null},
3844
- ${holderRef ?? null}, ${params.groupId ?? null}
4362
+ ${holderRef ?? null}, ${params.groupId ?? null}, ${paymentRequired},
4363
+ ${params.payment?.amountMinor ?? null}, ${params.payment?.currency ?? null}
3845
4364
  )
3846
4365
  RETURNING id, resource_id, starts_at, ends_at, buffer_after_ms, expires_at, source,
3847
- hold_group_id`;
4366
+ hold_group_id, ${tx.unsafe(HOLD_PAYMENT_COLUMNS)}`;
3848
4367
  if (!row)
3849
4368
  throw new Error('slotlock: hold insert returned no row');
3850
4369
  if (keyed) {
@@ -3879,6 +4398,193 @@ function buildSlotlockStore(sql, options, refusal) {
3879
4398
  }
3880
4399
  }
3881
4400
  },
4401
+ async putContact(params) {
4402
+ if (!isValidIdentity(params.tenantRef)) {
4403
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4404
+ }
4405
+ if (!isValidContactRef(params.ref))
4406
+ return { ok: false, code: 'invalid_contact_ref' };
4407
+ const fields = contactFields(params);
4408
+ if (!fields)
4409
+ return { ok: false, code: 'invalid_contact' };
4410
+ return inTransaction(sql, async (tx) => {
4411
+ await lockExternalIdentity(tx, params.tenantRef, `contact:${params.ref}`);
4412
+ const [existing] = await tx `
4413
+ SELECT ref FROM slotlock.contacts WHERE tenant_ref = ${params.tenantRef} AND ref = ${params.ref}`;
4414
+ if (!existing) {
4415
+ await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify(['slotlock-contacts', params.tenantRef])}, 0))`;
4416
+ const [kept] = await tx `
4417
+ SELECT count(*)::int AS count FROM (
4418
+ SELECT 1 FROM slotlock.contacts WHERE tenant_ref = ${params.tenantRef}
4419
+ LIMIT ${SLOTLOCK_MAX_CONTACTS_PER_TENANT}) kept`;
4420
+ if ((kept?.count ?? 0) >= SLOTLOCK_MAX_CONTACTS_PER_TENANT) {
4421
+ return { ok: false, code: 'contact_limit_reached', limit: SLOTLOCK_MAX_CONTACTS_PER_TENANT };
4422
+ }
4423
+ }
4424
+ // When each channel's answer was given, kept while it stands: the proof of consent.
4425
+ const [before] = await tx `
4426
+ SELECT consent, consent_at FROM slotlock.contacts
4427
+ WHERE tenant_ref = ${params.tenantRef} AND ref = ${params.ref}`;
4428
+ const stamp = new Date().toISOString();
4429
+ const consentAt = {};
4430
+ for (const channel of ['email', 'sms', 'whatsapp']) {
4431
+ const answer = fields.consent[channel];
4432
+ if (answer === undefined)
4433
+ continue;
4434
+ const kept = before?.consent?.[channel] === answer ? before.consent_at?.[channel] : undefined;
4435
+ consentAt[channel] = kept ?? stamp;
4436
+ }
4437
+ const [row] = await tx `
4438
+ INSERT INTO slotlock.contacts (tenant_ref, ref, name, email, phone, whatsapp, consent, consent_at, locale)
4439
+ VALUES (${params.tenantRef}, ${params.ref}, ${fields.name}, ${fields.email}, ${fields.phone},
4440
+ ${fields.whatsapp}, ${tx.json(fields.consent)}, ${tx.json(consentAt)}, ${fields.locale})
4441
+ ON CONFLICT (tenant_ref, ref) DO UPDATE
4442
+ SET name = EXCLUDED.name, email = EXCLUDED.email, phone = EXCLUDED.phone,
4443
+ whatsapp = EXCLUDED.whatsapp, consent = EXCLUDED.consent, consent_at = EXCLUDED.consent_at,
4444
+ locale = EXCLUDED.locale, updated_at = statement_timestamp()
4445
+ RETURNING ${tx.unsafe(CONTACT_COLUMNS)}`;
4446
+ if (!row)
4447
+ throw new Error('slotlock: contact upsert returned no row');
4448
+ return { ok: true, contact: toContact(row), created: !existing };
4449
+ }, 'read committed');
4450
+ },
4451
+ async getContact(params) {
4452
+ if (!isValidIdentity(params.tenantRef)) {
4453
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4454
+ }
4455
+ if (!isValidContactRef(params.ref))
4456
+ return null;
4457
+ const [row] = await sql `
4458
+ SELECT ${sql.unsafe(CONTACT_COLUMNS)}
4459
+ FROM slotlock.contacts WHERE tenant_ref = ${params.tenantRef} AND ref = ${params.ref}`;
4460
+ return row ? toContact(row) : null;
4461
+ },
4462
+ async listContacts(params) {
4463
+ if (!isValidIdentity(params.tenantRef)) {
4464
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4465
+ }
4466
+ const limit = pageLimit(params.limit);
4467
+ if (params.after !== undefined && !isValidContactRef(params.after))
4468
+ return [];
4469
+ const rows = await sql `
4470
+ SELECT ${sql.unsafe(CONTACT_COLUMNS)} FROM slotlock.contacts
4471
+ WHERE tenant_ref = ${params.tenantRef}
4472
+ AND (${params.after ?? null}::text IS NULL OR ref > ${params.after ?? null})
4473
+ ORDER BY ref
4474
+ LIMIT ${limit}`;
4475
+ return rows.map(toContact);
4476
+ },
4477
+ async deleteContact(params) {
4478
+ if (!isValidIdentity(params.tenantRef)) {
4479
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4480
+ }
4481
+ if (!isValidContactRef(params.ref))
4482
+ return { deleted: false, bookings: 0 };
4483
+ return inTransaction(sql, async (tx) => {
4484
+ await lockExternalIdentity(tx, params.tenantRef, `contact:${params.ref}`);
4485
+ const deleted = await tx `
4486
+ DELETE FROM slotlock.contacts WHERE tenant_ref = ${params.tenantRef} AND ref = ${params.ref}
4487
+ RETURNING ref`;
4488
+ // Its reference leaves the bookings too; the feed keeps no row of the person (the change
4489
+ // trigger ignores a write that moves no time).
4490
+ const bookings = await tx `
4491
+ UPDATE slotlock.reservations SET contact_ref = NULL
4492
+ WHERE tenant_ref = ${params.tenantRef} AND contact_ref = ${params.ref}
4493
+ RETURNING id`;
4494
+ return { deleted: deleted.length > 0, bookings: bookings.length };
4495
+ }, 'read committed');
4496
+ },
4497
+ async markHoldPaid(params) {
4498
+ if (!isValidIdentity(params.tenantRef))
4499
+ return { ok: false, code: 'invalid_identity' };
4500
+ if (!isValidPaymentRef(params.paymentRef))
4501
+ return { ok: false, code: 'invalid_payment_ref' };
4502
+ if (params.received !== undefined && !isValidAmount(params.received))
4503
+ return { ok: false, code: 'invalid_payment' };
4504
+ if (!isValidResourceId(params.holdId))
4505
+ return { ok: false, code: 'hold_not_found' };
4506
+ return inTransaction(sql, async (tx) => {
4507
+ // One payment record per hold, kept however the hold ends: the same payment told again
4508
+ // (a reloaded return page, a redelivered webhook) after the hold was booked is answered as
4509
+ // recorded, never as a refusal a payment adapter would refund.
4510
+ await lockExternalIdentity(tx, params.tenantRef, `hold-payment:${params.holdId}`);
4511
+ const [recorded] = await tx `
4512
+ SELECT hold_id AS id, resource_id, starts_at, ends_at, buffer_after_ms, expires_at,
4513
+ NULL::text AS source, true AS payment_required, payment_ref, paid_at,
4514
+ payment_amount_minor, payment_currency, paid_amount_minor, paid_currency
4515
+ FROM slotlock.hold_payments
4516
+ WHERE hold_id = ${params.holdId} AND tenant_ref = ${params.tenantRef}`;
4517
+ if (recorded) {
4518
+ return recorded.payment_ref === params.paymentRef
4519
+ ? { ok: true, hold: toHold(recorded), idempotent: true }
4520
+ : { ok: false, code: 'payment_conflict' };
4521
+ }
4522
+ const [row] = await tx `
4523
+ SELECT hold.id, hold.resource_id, hold.starts_at, hold.ends_at, hold.buffer_after_ms,
4524
+ hold.expires_at, hold.source, hold.hold_group_id, hold.payment_required,
4525
+ hold.payment_ref, hold.paid_at, hold.payment_amount_minor, hold.payment_currency,
4526
+ hold.paid_amount_minor, hold.paid_currency, hold.status,
4527
+ hold.expires_at > statement_timestamp() AS live
4528
+ FROM slotlock.reservations hold
4529
+ JOIN slotlock.resources resource ON resource.id = hold.resource_id
4530
+ WHERE hold.id = ${params.holdId} AND resource.tenant_ref = ${params.tenantRef}
4531
+ AND hold.calendar_event_id IS NULL
4532
+ FOR UPDATE OF hold`;
4533
+ if (!row || row.status !== 'held')
4534
+ return { ok: false, code: 'hold_not_found' };
4535
+ if (!row.payment_required)
4536
+ return { ok: false, code: 'payment_not_required' };
4537
+ if (row.payment_ref !== null && row.payment_ref !== undefined) {
4538
+ return row.payment_ref === params.paymentRef
4539
+ ? { ok: true, hold: toHold(row), idempotent: true }
4540
+ : { ok: false, code: 'payment_conflict' };
4541
+ }
4542
+ // Paid after it lapsed: the time may be someone else's by now. The integrator refunds.
4543
+ if (!row.live)
4544
+ return { ok: false, code: 'hold_expired' };
4545
+ // Less than the hold asks, or in another currency: not its payment (RFC 0004 C3).
4546
+ const expected = amountOf(row.payment_amount_minor, row.payment_currency);
4547
+ if (expected &&
4548
+ (!params.received ||
4549
+ params.received.currency !== expected.currency ||
4550
+ params.received.amountMinor < expected.amountMinor)) {
4551
+ return { ok: false, code: 'payment_amount_mismatch' };
4552
+ }
4553
+ const [paid] = await tx `
4554
+ UPDATE slotlock.reservations
4555
+ SET payment_ref = ${params.paymentRef}, paid_at = statement_timestamp(),
4556
+ paid_amount_minor = ${params.received?.amountMinor ?? null},
4557
+ paid_currency = ${params.received?.currency ?? null}
4558
+ WHERE id = ${params.holdId}
4559
+ RETURNING id, resource_id, starts_at, ends_at, buffer_after_ms, expires_at, source,
4560
+ hold_group_id, ${tx.unsafe(HOLD_PAYMENT_COLUMNS)}`;
4561
+ if (!paid)
4562
+ throw new Error('slotlock: hold payment update returned no row');
4563
+ await tx `
4564
+ INSERT INTO slotlock.hold_payments (
4565
+ tenant_ref, hold_id, resource_id, starts_at, ends_at, buffer_after_ms, expires_at,
4566
+ payment_ref, paid_at, payment_amount_minor, payment_currency, paid_amount_minor, paid_currency
4567
+ ) VALUES (
4568
+ ${params.tenantRef}, ${paid.id}, ${paid.resource_id}, ${paid.starts_at}, ${paid.ends_at},
4569
+ ${paid.buffer_after_ms}, ${paid.expires_at}, ${params.paymentRef}, ${paid.paid_at ?? null},
4570
+ ${paid.payment_amount_minor ?? null}, ${paid.payment_currency ?? null},
4571
+ ${params.received?.amountMinor ?? null}, ${params.received?.currency ?? null}
4572
+ )`;
4573
+ await tx `
4574
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
4575
+ VALUES (${params.tenantRef}, ${paid.resource_id}, 'hold.paid', ${paid.id},
4576
+ jsonb_build_object(
4577
+ 'id', ${paid.id}::uuid, 'resource_id', ${paid.resource_id}::uuid,
4578
+ 'start', ${paid.starts_at}::timestamptz, 'end', ${paid.ends_at}::timestamptz,
4579
+ 'expires_at', ${paid.expires_at}::timestamptz,
4580
+ 'payment_ref', ${params.paymentRef}::text,
4581
+ 'paid_at', ${paid.paid_at ?? null}::timestamptz,
4582
+ 'amount_minor', ${params.received?.amountMinor ?? null}::bigint,
4583
+ 'currency', ${params.received?.currency ?? null}::text),
4584
+ NULLIF(current_setting('slotlock.actor', true), ''))`;
4585
+ return { ok: true, hold: toHold(paid), idempotent: false };
4586
+ }, 'read committed');
4587
+ },
3882
4588
  async getHold(params) {
3883
4589
  if (!isValidIdentity(params.tenantRef)) {
3884
4590
  throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
@@ -3887,7 +4593,7 @@ function buildSlotlockStore(sql, options, refusal) {
3887
4593
  return null;
3888
4594
  const [row] = await sql `
3889
4595
  SELECT hold.id, hold.resource_id, hold.starts_at, hold.ends_at, hold.buffer_after_ms,
3890
- hold.expires_at, hold.source
4596
+ hold.expires_at, hold.source, ${sql.unsafe(HOLD_PAYMENT_COLUMNS_OF_HOLD)}
3891
4597
  FROM slotlock.reservations hold
3892
4598
  JOIN slotlock.resources resource ON resource.id = hold.resource_id
3893
4599
  WHERE hold.id = ${params.holdId} AND resource.tenant_ref = ${params.tenantRef}
@@ -3902,7 +4608,8 @@ function buildSlotlockStore(sql, options, refusal) {
3902
4608
  return null;
3903
4609
  const [row] = await sql `
3904
4610
  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
4611
+ hold.expires_at, hold.source, ${sql.unsafe(HOLD_PAYMENT_COLUMNS_OF_HOLD)},
4612
+ hold.expires_at > statement_timestamp() AS live
3906
4613
  FROM slotlock.reservations hold
3907
4614
  JOIN slotlock.resources resource ON resource.id = hold.resource_id
3908
4615
  WHERE hold.id = ${params.holdId} AND resource.tenant_ref = ${params.tenantRef}
@@ -3920,7 +4627,7 @@ function buildSlotlockStore(sql, options, refusal) {
3920
4627
  const after = pageCursor(params.after);
3921
4628
  const rows = await sql `
3922
4629
  SELECT hold.id, hold.resource_id, hold.starts_at, hold.ends_at, hold.buffer_after_ms,
3923
- hold.expires_at, hold.source
4630
+ hold.expires_at, hold.source, ${sql.unsafe(HOLD_PAYMENT_COLUMNS_OF_HOLD)}
3924
4631
  FROM slotlock.reservations hold
3925
4632
  JOIN slotlock.resources resource ON resource.id = hold.resource_id
3926
4633
  WHERE hold.resource_id = ${params.resourceId} AND resource.tenant_ref = ${params.tenantRef}
@@ -4239,7 +4946,7 @@ function buildSlotlockStore(sql, options, refusal) {
4239
4946
  }
4240
4947
  const [row] = await sql `
4241
4948
  SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
4242
- revision, source
4949
+ revision, source, contact_ref, notify, reminder_minutes
4243
4950
  FROM slotlock.reservations
4244
4951
  WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
4245
4952
  AND calendar_event_id IS NULL`;
@@ -4257,7 +4964,7 @@ function buildSlotlockStore(sql, options, refusal) {
4257
4964
  const after = pageCursor(params.after);
4258
4965
  const rows = await sql `
4259
4966
  SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
4260
- revision, source
4967
+ revision, source, contact_ref, notify, reminder_minutes
4261
4968
  FROM slotlock.reservations
4262
4969
  WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
4263
4970
  AND calendar_event_id IS NULL
@@ -4294,8 +5001,9 @@ function buildSlotlockStore(sql, options, refusal) {
4294
5001
  const resource = await lockResourceForWrite(tx, owner.resource_id, null);
4295
5002
  const [row] = await tx `
4296
5003
  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
5004
+ revision, source, contact_ref, notify, reminder_minutes, status,
5005
+ (status = 'held' AND expires_at > statement_timestamp()) AS live,
5006
+ hold_group_id, holder_ref, (payment_required AND paid_at IS NULL) AS unpaid
4299
5007
  FROM slotlock.reservations
4300
5008
  WHERE id = ${holdId}
4301
5009
  FOR UPDATE`;
@@ -4314,6 +5022,9 @@ function buildSlotlockStore(sql, options, refusal) {
4314
5022
  }
4315
5023
  if (!row.live)
4316
5024
  return { ok: false, code: 'hold_expired' };
5025
+ // A hold that waits for payment is booked only once it is paid (RFC 0004 C3).
5026
+ if (row.unpaid)
5027
+ return { ok: false, code: 'payment_required' };
4317
5028
  if (resource?.archived !== false)
4318
5029
  return { ok: false, code: 'resource_archived' };
4319
5030
  if (bookingTenant !== null) {
@@ -4340,7 +5051,7 @@ function buildSlotlockStore(sql, options, refusal) {
4340
5051
  tenant_ref = ${bookingTenant}, external_ref = ${bookingTenant === null ? null : holdId}
4341
5052
  WHERE id = ${holdId}
4342
5053
  RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
4343
- buffer_after_ms, revision, source`;
5054
+ buffer_after_ms, revision, source, contact_ref, notify, reminder_minutes`;
4344
5055
  if (!confirmed)
4345
5056
  throw new Error('slotlock: hold confirmation returned no row');
4346
5057
  // Its linked alternatives (one group, one holder) are released with it, as booking one
@@ -4423,6 +5134,8 @@ function buildSlotlockStore(sql, options, refusal) {
4423
5134
  AND (status <> 'held' OR expires_at > statement_timestamp())
4424
5135
  UNION ALL
4425
5136
  ${compositeBusy(sql, resourceId, window)}
5137
+ UNION ALL
5138
+ ${sourceBusy(sql, resourceId, window)}
4426
5139
  ) busy
4427
5140
  ORDER BY occupied_starts_at
4428
5141
  LIMIT ${options.limit ?? null}`;
@@ -4560,6 +5273,19 @@ function buildSlotlockStore(sql, options, refusal) {
4560
5273
  if (params.expectedRevision !== currentRevision) {
4561
5274
  return { ok: false, code: 'revision_conflict', currentRevision };
4562
5275
  }
5276
+ // A one-off event moved in time or to another resource, past its reschedule cutoff
5277
+ // (RFC 0004 B5), is refused to a writer held to the booking rules.
5278
+ if (found &&
5279
+ params.enforceBookingRules === true &&
5280
+ found.recurrence_rule === null &&
5281
+ (found.starts_at.getTime() !== event.start.getTime() ||
5282
+ found.ends_at.getTime() !== event.end.getTime() ||
5283
+ found.resource_id !== params.resourceId)) {
5284
+ const judged = await rulesNow(tx, found.resource_id);
5285
+ if (cutoffRuleBroken(judged.rules, 'reschedule', found.starts_at, judged.now)) {
5286
+ return { ok: false, code: 'booking_rule_violated', rule: 'reschedule_cutoff' };
5287
+ }
5288
+ }
4563
5289
  const series = expanded ?? resumeStoredSeries(event, materializationWindow, found);
4564
5290
  if (!series)
4565
5291
  return { ok: false, code: 'invalid_event' };
@@ -4600,7 +5326,8 @@ function buildSlotlockStore(sql, options, refusal) {
4600
5326
  }
4601
5327
  const [hold] = await tx `
4602
5328
  SELECT resource_id, starts_at, ends_at, hold_group_id,
4603
- expires_at > statement_timestamp() AS live
5329
+ expires_at > statement_timestamp() AS live,
5330
+ (payment_required AND paid_at IS NULL) AS unpaid
4604
5331
  FROM slotlock.reservations
4605
5332
  WHERE id = ${holdId} AND status = 'held' AND holder_ref = ${holderRef}
4606
5333
  FOR UPDATE`;
@@ -4610,6 +5337,9 @@ function buildSlotlockStore(sql, options, refusal) {
4610
5337
  // API's confirm names it. Another holder's stays not found.
4611
5338
  if (!hold.live)
4612
5339
  return { ok: false, code: 'hold_expired' };
5340
+ // A hold that waits for payment is booked only once paid (RFC 0004 C3).
5341
+ if (hold.unpaid)
5342
+ return { ok: false, code: 'payment_required' };
4613
5343
  const [only] = occurrences;
4614
5344
  if (hold.resource_id !== params.resourceId ||
4615
5345
  occurrences.length !== 1 ||
@@ -4618,12 +5348,19 @@ function buildSlotlockStore(sql, options, refusal) {
4618
5348
  only.end > hold.ends_at) {
4619
5349
  return { ok: false, code: 'hold_mismatch' };
4620
5350
  }
5351
+ // The hold booked is accepted, not let go (slotlock.waitlist_offer_settle); the rest of
5352
+ // its group is released.
5353
+ await tx `SELECT set_config('slotlock.ending', 'confirmed', true)`;
5354
+ await tx `DELETE FROM slotlock.reservations WHERE id = ${holdId} AND status = 'held' AND holder_ref = ${holderRef}`;
5355
+ await tx `SELECT set_config('slotlock.ending', '', true)`;
5356
+ // A set held together (RFC 0004 B7) is booked member by member: its other holds stay.
4621
5357
  await tx `
4622
5358
  DELETE FROM slotlock.reservations
4623
5359
  WHERE status = 'held' AND holder_ref = ${holderRef}
4624
5360
  AND (id = ${holdId}
4625
5361
  OR (${hold.hold_group_id}::uuid IS NOT NULL
4626
- AND hold_group_id = ${hold.hold_group_id}::uuid))`;
5362
+ AND hold_group_id = ${hold.hold_group_id}::uuid
5363
+ AND source IS DISTINCT FROM 'slotlock:together'))`;
4627
5364
  }
4628
5365
  let turnaroundMs = 0;
4629
5366
  let bufferBeforeMs = 0;
@@ -4679,6 +5416,8 @@ function buildSlotlockStore(sql, options, refusal) {
4679
5416
  source = ${params.source ?? null},
4680
5417
  buffer_after_ms = ${turnaroundMs},
4681
5418
  buffer_before_ms = ${bufferBeforeMs},
5419
+ -- Whether anyone should be told (RFC 0004 P3): as sent, like the rest.
5420
+ notify = ${params.notify !== false},
4682
5421
  updated_at = now()
4683
5422
  WHERE id = ${eventId}
4684
5423
  AND tenant_ref = ${params.tenantRef}
@@ -4691,7 +5430,7 @@ function buildSlotlockStore(sql, options, refusal) {
4691
5430
  summary, description, location, status, transparency, organizer, attendees,
4692
5431
  reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
4693
5432
  materialized_ends_at, recurrence_cursor, recurrence_ends_by, revision, source,
4694
- buffer_after_ms, buffer_before_ms
5433
+ buffer_after_ms, buffer_before_ms, notify
4695
5434
  ) VALUES (
4696
5435
  ${eventId}, ${params.tenantRef}, ${ownerRef}, ${params.externalRef},
4697
5436
  ${params.resourceId},
@@ -4705,7 +5444,8 @@ function buildSlotlockStore(sql, options, refusal) {
4705
5444
  ${tx.json(eventExceptionsForJson(event.recurrence?.exceptions))},
4706
5445
  ${materializationWindow.start}, ${materializationWindow.end},
4707
5446
  ${seriesCursorJson(tx, series.cursor)}, ${recurrenceEndsBy}::text::timestamptz,
4708
- ${revision}, ${params.source ?? null}, ${turnaroundMs}, ${bufferBeforeMs}
5447
+ ${revision}, ${params.source ?? null}, ${turnaroundMs}, ${bufferBeforeMs},
5448
+ ${params.notify !== false}
4709
5449
  )`;
4710
5450
  }
4711
5451
  // The normalized children are canonical; the JSON columns above are a compatibility
@@ -4717,6 +5457,15 @@ function buildSlotlockStore(sql, options, refusal) {
4717
5457
  return { ok: false, code: 'resource_tenant_mismatch' };
4718
5458
  if (locked.archived)
4719
5459
  return { ok: false, code: 'resource_archived' };
5460
+ // A booking (held to the rules, as agents' are) never takes busy time a calendar reported
5461
+ // without its events (RFC 0004 C1); events imported from calendars are that time.
5462
+ if (params.enforceBookingRules === true && (event.transparency ?? 'opaque') === 'opaque') {
5463
+ for (const occurrence of occurrences) {
5464
+ if (await sourceBusyLoss(tx, params.resourceId, new Date(occurrence.start.getTime() - bufferBeforeMs), new Date(occurrence.end.getTime() + turnaroundMs))) {
5465
+ return { ok: false, code: 'overlap', conflictingReservationId: null };
5466
+ }
5467
+ }
5468
+ }
4720
5469
  await replaceCalendarEventOccurrences(tx, {
4721
5470
  tenantRef: params.tenantRef,
4722
5471
  eventId,
@@ -5042,7 +5791,8 @@ function buildSlotlockStore(sql, options, refusal) {
5042
5791
  };
5043
5792
  }
5044
5793
  const events = await tx `
5045
- SELECT id, resource_id, revision FROM slotlock.calendar_events
5794
+ SELECT id, resource_id, revision, starts_at, recurrence_rule IS NOT NULL AS recurring
5795
+ FROM slotlock.calendar_events
5046
5796
  WHERE tenant_ref = ${params.tenantRef}
5047
5797
  AND owner_ref = ${ownerRef}
5048
5798
  AND external_ref = ${params.externalRef}
@@ -5059,7 +5809,10 @@ function buildSlotlockStore(sql, options, refusal) {
5059
5809
  if (params.expectedRevision !== currentRevision) {
5060
5810
  return { ok: false, code: 'revision_conflict', currentRevision };
5061
5811
  }
5062
- return finishCalendarCancel(tx, {
5812
+ const ending = found ? await eventEnding(tx, found, params.enforceBookingRules === true, undefined) : null;
5813
+ if (ending && !ending.ok)
5814
+ return ending.refusal;
5815
+ const cancelled = await finishCalendarCancel(tx, {
5063
5816
  tenantRef: params.tenantRef,
5064
5817
  ownerRef,
5065
5818
  externalRef: params.externalRef,
@@ -5069,9 +5822,15 @@ function buildSlotlockStore(sql, options, refusal) {
5069
5822
  idempotencyKey: params.idempotencyKey,
5070
5823
  payloadHash,
5071
5824
  });
5825
+ if (ending)
5826
+ await noteEnding(tx, null, null);
5827
+ return cancelled.ok && ending?.fee ? { ...cancelled, cancellationFee: ending.fee } : cancelled;
5072
5828
  });
5073
5829
  },
5074
5830
  async cancelCalendarEventById(params) {
5831
+ if (params.overrideReason !== undefined && !isValidOverrideReason(params.overrideReason)) {
5832
+ return { ok: false, code: 'invalid_identity' };
5833
+ }
5075
5834
  if (!isValidIdentity(params.tenantRef) ||
5076
5835
  !isValidResourceId(params.eventId) ||
5077
5836
  !isValidIdentity(params.idempotencyKey) ||
@@ -5122,7 +5881,8 @@ function buildSlotlockStore(sql, options, refusal) {
5122
5881
  return { ok: false, code: 'event_not_found' };
5123
5882
  await lockCalendarEventIdentity(tx, params.tenantRef, identity.owner_ref, identity.external_ref);
5124
5883
  const [found] = await tx `
5125
- SELECT id, resource_id, revision FROM slotlock.calendar_events
5884
+ SELECT id, resource_id, revision, starts_at, recurrence_rule IS NOT NULL AS recurring
5885
+ FROM slotlock.calendar_events
5126
5886
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.eventId}
5127
5887
  FOR UPDATE`;
5128
5888
  if (!found)
@@ -5135,7 +5895,11 @@ function buildSlotlockStore(sql, options, refusal) {
5135
5895
  if (params.expectedRevision !== currentRevision) {
5136
5896
  return { ok: false, code: 'revision_conflict', currentRevision };
5137
5897
  }
5138
- return finishCalendarCancel(tx, {
5898
+ // The tenant's own cancellation is held to the cutoff too, unless an admin gives a reason.
5899
+ const ending = await eventEnding(tx, found, true, params.overrideReason);
5900
+ if (!ending.ok)
5901
+ return ending.refusal;
5902
+ const cancelled = await finishCalendarCancel(tx, {
5139
5903
  tenantRef: params.tenantRef,
5140
5904
  ownerRef: identity.owner_ref,
5141
5905
  externalRef: identity.external_ref,
@@ -5145,6 +5909,8 @@ function buildSlotlockStore(sql, options, refusal) {
5145
5909
  idempotencyKey: params.idempotencyKey,
5146
5910
  payloadHash,
5147
5911
  });
5912
+ await noteEnding(tx, null, null);
5913
+ return cancelled.ok && ending.fee ? { ...cancelled, cancellationFee: ending.fee } : cancelled;
5148
5914
  });
5149
5915
  },
5150
5916
  async followZoneRules(params) {
@@ -5228,7 +5994,197 @@ function buildSlotlockStore(sql, options, refusal) {
5228
5994
  conflicts++;
5229
5995
  }
5230
5996
  }
5231
- return { shifted: shifted.length, moved, conflicts };
5997
+ // A series' occurrences to come (RFC 0004 B9): said on the feed, once each; with `move`, each
5998
+ // series moves whole, in one transaction, where Node's zone rules agree with the database's.
5999
+ const occurrences = await sql `
6000
+ SELECT occurrence.event_id, occurrence.recurrence_id, event.resource_id, occurrence.reservation_id,
6001
+ event.timezone, to_char(occurrence.wall_starts_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS wall,
6002
+ occurrence.starts_at, occurrence.ends_at,
6003
+ slotlock.zoned_instant(occurrence.wall_starts_at, event.timezone) AS moved_start
6004
+ FROM slotlock.calendar_event_occurrences occurrence
6005
+ JOIN slotlock.calendar_events event ON event.id = occurrence.event_id
6006
+ WHERE event.tenant_ref = ${params.tenantRef} AND event.recurrence_rule IS NOT NULL
6007
+ AND occurrence.starts_at > now() AND occurrence.zone_shift_reported_at IS NULL
6008
+ AND occurrence.wall_starts_at IS NOT NULL
6009
+ AND slotlock.zoned_instant(occurrence.wall_starts_at, event.timezone) IS NOT NULL
6010
+ AND slotlock.zoned_instant(occurrence.wall_starts_at, event.timezone) <> occurrence.starts_at
6011
+ ORDER BY occurrence.starts_at, occurrence.event_id
6012
+ LIMIT ${limit}`;
6013
+ const reportOccurrence = async (occurrence, type, reason) => {
6014
+ await sql `
6015
+ UPDATE slotlock.calendar_event_occurrences SET zone_shift_reported_at = now()
6016
+ WHERE event_id = ${occurrence.event_id} AND recurrence_id = ${occurrence.recurrence_id}`;
6017
+ const shift = occurrence.moved_start.getTime() - occurrence.starts_at.getTime();
6018
+ await sql `
6019
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
6020
+ VALUES (${params.tenantRef}, ${occurrence.resource_id}, ${type}, ${occurrence.event_id},
6021
+ ${sql.json({
6022
+ id: occurrence.event_id,
6023
+ recurrence_id: occurrence.recurrence_id,
6024
+ resource_id: occurrence.resource_id,
6025
+ start: occurrence.starts_at.toISOString(),
6026
+ end: occurrence.ends_at.toISOString(),
6027
+ zone_start: occurrence.moved_start.toISOString(),
6028
+ zone_end: new Date(occurrence.ends_at.getTime() + shift).toISOString(),
6029
+ ...(reason ? { reason } : {}),
6030
+ })}, 'slotlock:tzdata')`;
6031
+ };
6032
+ const series = new Map();
6033
+ for (const occurrence of occurrences) {
6034
+ const list = series.get(occurrence.event_id) ?? [];
6035
+ list.push(occurrence);
6036
+ series.set(occurrence.event_id, list);
6037
+ }
6038
+ for (const batch of series.values()) {
6039
+ if (!params.move) {
6040
+ for (const occurrence of batch)
6041
+ await reportOccurrence(occurrence, 'event.timezone_shifted');
6042
+ continue;
6043
+ }
6044
+ // The whole series moves at once: every occurrence to come that shifted, past the batch.
6045
+ const shiftedOccurrences = await sql `
6046
+ SELECT occurrence.event_id, occurrence.recurrence_id, event.resource_id, occurrence.reservation_id,
6047
+ event.timezone, to_char(occurrence.wall_starts_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS wall,
6048
+ occurrence.starts_at, occurrence.ends_at,
6049
+ slotlock.zoned_instant(occurrence.wall_starts_at, event.timezone) AS moved_start
6050
+ FROM slotlock.calendar_event_occurrences occurrence
6051
+ JOIN slotlock.calendar_events event ON event.id = occurrence.event_id
6052
+ WHERE event.tenant_ref = ${params.tenantRef} AND occurrence.event_id = ${batch[0].event_id}
6053
+ AND occurrence.starts_at > now() AND occurrence.zone_shift_reported_at IS NULL
6054
+ AND occurrence.wall_starts_at IS NOT NULL
6055
+ AND slotlock.zoned_instant(occurrence.wall_starts_at, event.timezone) IS NOT NULL
6056
+ AND slotlock.zoned_instant(occurrence.wall_starts_at, event.timezone) <> occurrence.starts_at
6057
+ ORDER BY occurrence.starts_at`;
6058
+ if (shiftedOccurrences.length === 0)
6059
+ continue;
6060
+ // Moved only where this server's zone rules put each wall time where the database's do: a
6061
+ // server with older rules would compute the old times again on the series' next write.
6062
+ const agree = shiftedOccurrences.every((occurrence) => {
6063
+ const wall = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/.exec(occurrence.wall);
6064
+ if (!wall)
6065
+ return false;
6066
+ try {
6067
+ const node = zonedDateTimeToUtc({
6068
+ year: Number(wall[1]),
6069
+ month: Number(wall[2]),
6070
+ day: Number(wall[3]),
6071
+ hour: Number(wall[4]),
6072
+ minute: Number(wall[5]),
6073
+ second: Number(wall[6]),
6074
+ }, occurrence.timezone);
6075
+ return node.getTime() === occurrence.moved_start.getTime();
6076
+ }
6077
+ catch {
6078
+ return false;
6079
+ }
6080
+ });
6081
+ if (!agree) {
6082
+ for (const occurrence of shiftedOccurrences)
6083
+ await reportOccurrence(occurrence, 'event.timezone_shifted', 'rules_disagree');
6084
+ continue;
6085
+ }
6086
+ const eventId = shiftedOccurrences[0].event_id;
6087
+ try {
6088
+ await inTransaction(sql, async (tx) => {
6089
+ await tx `SELECT set_config('slotlock.actor', 'slotlock:tzdata', true)`;
6090
+ for (const occurrence of shiftedOccurrences) {
6091
+ const movedEnd = new Date(occurrence.ends_at.getTime() + occurrence.moved_start.getTime() - occurrence.starts_at.getTime());
6092
+ await tx `
6093
+ UPDATE slotlock.calendar_event_occurrences
6094
+ SET starts_at = ${occurrence.moved_start}, ends_at = ${movedEnd}
6095
+ WHERE event_id = ${eventId} AND recurrence_id = ${occurrence.recurrence_id}`;
6096
+ if (occurrence.reservation_id !== null) {
6097
+ await tx `
6098
+ UPDATE slotlock.reservations SET starts_at = ${occurrence.moved_start}, ends_at = ${movedEnd}
6099
+ WHERE id = ${occurrence.reservation_id}::uuid`;
6100
+ }
6101
+ }
6102
+ // The series at its next revision; its anchor too, while it is still to come.
6103
+ const [bumped] = await tx `
6104
+ UPDATE slotlock.calendar_events
6105
+ SET starts_at = CASE WHEN starts_at > now()
6106
+ THEN COALESCE(slotlock.zoned_instant(wall_starts_at, timezone), starts_at)
6107
+ ELSE starts_at END,
6108
+ ends_at = CASE WHEN starts_at > now()
6109
+ THEN COALESCE(slotlock.zoned_instant(wall_ends_at, timezone), ends_at)
6110
+ ELSE ends_at END,
6111
+ revision = revision + 1, updated_at = now()
6112
+ WHERE id = ${eventId}
6113
+ RETURNING revision`;
6114
+ await tx `UPDATE slotlock.reservations SET revision = ${Number(bumped?.revision)} WHERE calendar_event_id = ${eventId}`;
6115
+ });
6116
+ moved++;
6117
+ }
6118
+ catch (error) {
6119
+ if (pgCode(error) !== EXCLUSION_VIOLATION)
6120
+ throw error;
6121
+ // Something is in the way: the series keeps its times, said once, at its first shifted one.
6122
+ await reportOccurrence(shiftedOccurrences[0], 'event.timezone_conflict');
6123
+ for (const occurrence of shiftedOccurrences.slice(1)) {
6124
+ await sql `
6125
+ UPDATE slotlock.calendar_event_occurrences SET zone_shift_reported_at = now()
6126
+ WHERE event_id = ${occurrence.event_id} AND recurrence_id = ${occurrence.recurrence_id}`;
6127
+ }
6128
+ conflicts++;
6129
+ }
6130
+ }
6131
+ // Reservations that carry the wall time they were booked for (RFC 0004 B9).
6132
+ const reservations = await sql `
6133
+ SELECT booking.id, booking.external_ref, booking.resource_id, booking.starts_at, booking.ends_at,
6134
+ slotlock.zoned_instant(booking.wall_starts_at, resource.timezone) AS moved_start
6135
+ FROM slotlock.reservations booking
6136
+ JOIN slotlock.resources resource ON resource.id = booking.resource_id
6137
+ WHERE booking.tenant_ref = ${params.tenantRef} AND resource.tenant_ref = ${params.tenantRef}
6138
+ AND booking.status = 'confirmed' AND booking.calendar_event_id IS NULL
6139
+ AND booking.starts_at > now() AND booking.wall_starts_at IS NOT NULL
6140
+ AND booking.zone_shift_reported_at IS NULL
6141
+ AND slotlock.zoned_instant(booking.wall_starts_at, resource.timezone) IS NOT NULL
6142
+ AND slotlock.zoned_instant(booking.wall_starts_at, resource.timezone) <> booking.starts_at
6143
+ ORDER BY booking.starts_at, booking.id
6144
+ LIMIT ${limit}`;
6145
+ const reportReservation = async (booking, type) => {
6146
+ await sql `UPDATE slotlock.reservations SET zone_shift_reported_at = now() WHERE id = ${booking.id}`;
6147
+ const shift = booking.moved_start.getTime() - booking.starts_at.getTime();
6148
+ await sql `
6149
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
6150
+ VALUES (${params.tenantRef}, ${booking.resource_id}, ${type}, ${booking.external_ref},
6151
+ ${sql.json({
6152
+ id: booking.id,
6153
+ reference: booking.external_ref,
6154
+ resource_id: booking.resource_id,
6155
+ start: booking.starts_at.toISOString(),
6156
+ end: booking.ends_at.toISOString(),
6157
+ zone_start: booking.moved_start.toISOString(),
6158
+ zone_end: new Date(booking.ends_at.getTime() + shift).toISOString(),
6159
+ })}, 'slotlock:tzdata')`;
6160
+ };
6161
+ for (const booking of reservations) {
6162
+ if (!params.move) {
6163
+ await reportReservation(booking, 'reservation.timezone_shifted');
6164
+ continue;
6165
+ }
6166
+ try {
6167
+ await inTransaction(sql, async (tx) => {
6168
+ await tx `SELECT set_config('slotlock.actor', 'slotlock:tzdata', true)`;
6169
+ const shift = booking.moved_start.getTime() - booking.starts_at.getTime();
6170
+ // The wall time it was booked for, at the instant the rules now give it: a move at the
6171
+ // next revision, on the feed as any move.
6172
+ await tx `
6173
+ UPDATE slotlock.reservations
6174
+ SET starts_at = ${booking.moved_start}, ends_at = ${new Date(booking.ends_at.getTime() + shift)},
6175
+ revision = revision + 1
6176
+ WHERE id = ${booking.id}`;
6177
+ });
6178
+ moved++;
6179
+ }
6180
+ catch (error) {
6181
+ if (pgCode(error) !== EXCLUSION_VIOLATION)
6182
+ throw error;
6183
+ await reportReservation(booking, 'reservation.timezone_conflict');
6184
+ conflicts++;
6185
+ }
6186
+ }
6187
+ return { shifted: shifted.length + occurrences.length + reservations.length, moved, conflicts };
5232
6188
  },
5233
6189
  async pruneBookingRetention(params) {
5234
6190
  if (!isValidIdentity(params.tenantRef)) {
@@ -5488,6 +6444,165 @@ function buildSlotlockStore(sql, options, refusal) {
5488
6444
  return { ok: true, revision: params.revision, idempotent: false };
5489
6445
  });
5490
6446
  },
6447
+ async replaceSourceBusy(params) {
6448
+ if (!isValidIdentity(params.tenantRef) ||
6449
+ !isValidIdentity(params.source) ||
6450
+ !isValidResourceId(params.resourceId)) {
6451
+ return { ok: false, code: 'invalid_identity' };
6452
+ }
6453
+ let window;
6454
+ try {
6455
+ window = calendarWindow(params.window);
6456
+ }
6457
+ catch {
6458
+ return { ok: false, code: 'invalid_window' };
6459
+ }
6460
+ const busy = mergeSourceBusy(params.busy, window);
6461
+ if (busy === null)
6462
+ return { ok: false, code: 'invalid_busy' };
6463
+ return inTransaction(sql, async (tx) => {
6464
+ // In turn with every booking on the resource, each of which reads this time (sourceBusyLoss):
6465
+ // a booking either sees the new busy time or is seen by it here.
6466
+ if (!(await lockResourceForWrite(tx, params.resourceId, params.tenantRef))) {
6467
+ return { ok: false, code: 'resource_tenant_mismatch' };
6468
+ }
6469
+ const recorded = await buildSlotlockStore(tx, options, null).recordCalendarCoverage({
6470
+ tenantRef: params.tenantRef,
6471
+ resourceId: params.resourceId,
6472
+ source: params.source,
6473
+ window,
6474
+ revision: params.revision,
6475
+ });
6476
+ if (!recorded.ok)
6477
+ return recorded;
6478
+ if (recorded.idempotent)
6479
+ return { ...recorded, busy: busy.length, conflicts: 0 };
6480
+ const starts = busy.map((interval) => interval.start.toISOString());
6481
+ const ends = busy.map((interval) => interval.end.toISOString());
6482
+ // Confirmed bookings in the way of what the source now says, but not of what it said
6483
+ // before: a conflict already reported is not reported again on every read.
6484
+ const conflicts = await tx `
6485
+ WITH reported AS (
6486
+ SELECT starts_at, ends_at
6487
+ FROM ROWS FROM (pg_catalog.unnest(${starts}::text[]::timestamptz[]),
6488
+ pg_catalog.unnest(${ends}::text[]::timestamptz[])) AS busy(starts_at, ends_at)
6489
+ )
6490
+ SELECT DISTINCT ON (booking.id)
6491
+ booking.id, booking.external_ref, booking.calendar_event_id,
6492
+ booking.starts_at, booking.ends_at,
6493
+ reported.starts_at AS busy_start, reported.ends_at AS busy_end
6494
+ FROM slotlock.reservations booking
6495
+ JOIN reported ON booking.starts_at < reported.ends_at AND booking.ends_at > reported.starts_at
6496
+ WHERE booking.resource_id = ${params.resourceId}
6497
+ AND booking.status = 'confirmed'
6498
+ AND booking.starts_at < ${window.end} AND booking.ends_at > ${window.start}
6499
+ AND NOT EXISTS (
6500
+ SELECT 1 FROM slotlock.source_busy previous
6501
+ WHERE previous.resource_id = ${params.resourceId} AND previous.source = ${params.source}
6502
+ AND previous.starts_at < booking.ends_at AND previous.ends_at > booking.starts_at)
6503
+ ORDER BY booking.id, reported.starts_at`;
6504
+ // What the source said of the window goes; what it said outside it stays.
6505
+ await tx `
6506
+ WITH removed AS (
6507
+ DELETE FROM slotlock.source_busy
6508
+ WHERE resource_id = ${params.resourceId} AND source = ${params.source}
6509
+ AND starts_at < ${window.end} AND ends_at > ${window.start}
6510
+ RETURNING starts_at, ends_at
6511
+ )
6512
+ INSERT INTO slotlock.source_busy (tenant_ref, resource_id, source, starts_at, ends_at)
6513
+ SELECT ${params.tenantRef}::text, ${params.resourceId}::uuid, ${params.source}::text, starts_at,
6514
+ ${window.start}::timestamptz
6515
+ FROM removed WHERE starts_at < ${window.start}
6516
+ UNION ALL
6517
+ SELECT ${params.tenantRef}::text, ${params.resourceId}::uuid, ${params.source}::text,
6518
+ ${window.end}::timestamptz, ends_at
6519
+ FROM removed WHERE ends_at > ${window.end}`;
6520
+ if (busy.length > 0) {
6521
+ await tx `
6522
+ INSERT INTO slotlock.source_busy (tenant_ref, resource_id, source, starts_at, ends_at)
6523
+ SELECT ${params.tenantRef}::text, ${params.resourceId}::uuid, ${params.source}::text, starts_at, ends_at
6524
+ FROM ROWS FROM (pg_catalog.unnest(${starts}::text[]::timestamptz[]),
6525
+ pg_catalog.unnest(${ends}::text[]::timestamptz[])) AS busy(starts_at, ends_at)`;
6526
+ }
6527
+ if (conflicts.length > 0) {
6528
+ // Named as every coverage change is, by the source's source_id (never the source); the
6529
+ // booking it is in the way of is in the data (an event's by its id).
6530
+ await tx `
6531
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
6532
+ SELECT ${params.tenantRef}, ${params.resourceId}, 'coverage.conflict', coverage.source_id,
6533
+ jsonb_strip_nulls(jsonb_build_object(
6534
+ 'resource_id', ${params.resourceId}::uuid,
6535
+ 'source_id', coverage.source_id,
6536
+ 'reason', 'busy_in_calendar',
6537
+ 'id', CASE WHEN conflict.event_id IS NULL THEN conflict.id END,
6538
+ 'reference', conflict.reference,
6539
+ 'event_id', conflict.event_id,
6540
+ 'start', conflict.starts_at, 'end', conflict.ends_at,
6541
+ 'busy_start', conflict.busy_start, 'busy_end', conflict.busy_end)),
6542
+ NULLIF(current_setting('slotlock.actor', true), '')
6543
+ FROM ROWS FROM (
6544
+ pg_catalog.unnest(${conflicts.map((row) => row.id)}::text[]::uuid[]),
6545
+ pg_catalog.unnest(${conflicts.map((row) => row.external_ref)}::text[]),
6546
+ pg_catalog.unnest(${conflicts.map((row) => row.calendar_event_id)}::text[]::uuid[]),
6547
+ pg_catalog.unnest(${conflicts.map((row) => row.starts_at.toISOString())}::text[]::timestamptz[]),
6548
+ pg_catalog.unnest(${conflicts.map((row) => row.ends_at.toISOString())}::text[]::timestamptz[]),
6549
+ pg_catalog.unnest(${conflicts.map((row) => row.busy_start.toISOString())}::text[]::timestamptz[]),
6550
+ pg_catalog.unnest(${conflicts.map((row) => row.busy_end.toISOString())}::text[]::timestamptz[]))
6551
+ AS conflict(id, reference, event_id, starts_at, ends_at, busy_start, busy_end)
6552
+ CROSS JOIN (SELECT source_id FROM slotlock.calendar_coverage
6553
+ WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
6554
+ AND source = ${params.source}) coverage`;
6555
+ }
6556
+ return { ...recorded, busy: busy.length, conflicts: conflicts.length };
6557
+ }, 'read committed');
6558
+ },
6559
+ async reportCoverageConflict(params) {
6560
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.source) || !isValidResourceId(params.resourceId)) {
6561
+ return { ok: false, code: 'invalid_identity' };
6562
+ }
6563
+ const byEvent = 'eventId' in params.booking;
6564
+ if (byEvent ? !isValidResourceId(params.booking.eventId) : !isValidIdentity(params.booking.reservationRef)) {
6565
+ return { ok: false, code: 'invalid_identity' };
6566
+ }
6567
+ if (params.reason !== 'changed_in_calendar' && params.reason !== 'removed_from_calendar') {
6568
+ return { ok: false, code: 'invalid_identity' };
6569
+ }
6570
+ if (params.calendar !== undefined && !isFiniteWindow(params.calendar))
6571
+ return { ok: false, code: 'invalid_window' };
6572
+ return inTransaction(sql, async (tx) => {
6573
+ const [coverage] = await tx `
6574
+ SELECT source_id FROM slotlock.calendar_coverage
6575
+ WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId} AND source = ${params.source}`;
6576
+ if (!coverage)
6577
+ return { ok: false, code: 'coverage_not_found' };
6578
+ const [booking] = byEvent
6579
+ ? await tx `
6580
+ SELECT id, NULL::text AS reference, starts_at, ends_at FROM slotlock.calendar_events
6581
+ WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
6582
+ AND id = ${params.booking.eventId}::uuid`
6583
+ : await tx `
6584
+ SELECT id, external_ref AS reference, starts_at, ends_at FROM slotlock.reservations
6585
+ WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
6586
+ AND external_ref = ${params.booking.reservationRef}
6587
+ AND calendar_event_id IS NULL AND status = 'confirmed'`;
6588
+ if (!booking) {
6589
+ return { ok: false, code: byEvent ? 'event_not_found' : 'reservation_not_found' };
6590
+ }
6591
+ await tx `
6592
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
6593
+ VALUES (${params.tenantRef}, ${params.resourceId}, 'coverage.conflict', ${coverage.source_id},
6594
+ jsonb_strip_nulls(jsonb_build_object(
6595
+ 'resource_id', ${params.resourceId}::uuid, 'source_id', ${coverage.source_id}::text,
6596
+ 'reason', ${params.reason}::text,
6597
+ 'id', ${byEvent ? null : booking.id}::uuid, 'reference', ${booking.reference}::text,
6598
+ 'event_id', ${byEvent ? booking.id : null}::uuid,
6599
+ 'start', ${booking.starts_at}::timestamptz, 'end', ${booking.ends_at}::timestamptz,
6600
+ 'calendar_start', ${params.calendar?.start ?? null}::timestamptz,
6601
+ 'calendar_end', ${params.calendar?.end ?? null}::timestamptz)),
6602
+ NULLIF(current_setting('slotlock.actor', true), ''))`;
6603
+ return { ok: true };
6604
+ }, 'read committed');
6605
+ },
5491
6606
  async recordCoverageFailure(params) {
5492
6607
  if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.source) || !isValidResourceId(params.resourceId)) {
5493
6608
  return { ok: false, code: 'invalid_identity' };
@@ -5506,6 +6621,11 @@ function buildSlotlockStore(sql, options, refusal) {
5506
6621
  return false;
5507
6622
  }
5508
6623
  const rows = await sql `
6624
+ WITH busy AS (
6625
+ DELETE FROM slotlock.source_busy
6626
+ WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
6627
+ AND source = ${params.source}
6628
+ )
5509
6629
  DELETE FROM slotlock.calendar_coverage
5510
6630
  WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
5511
6631
  AND source = ${params.source}
@@ -5535,6 +6655,106 @@ function buildSlotlockStore(sql, options, refusal) {
5535
6655
  stale: row.stale,
5536
6656
  }));
5537
6657
  },
6658
+ async dueReminders(params) {
6659
+ if (!isValidIdentity(params.tenantRef)) {
6660
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
6661
+ }
6662
+ const limit = params.limit ?? 500;
6663
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 5_000) {
6664
+ throw new RangeError('limit must be a whole number from 1 to 5000');
6665
+ }
6666
+ const rows = await sql `
6667
+ WITH due AS (
6668
+ SELECT occurrence.event_id, occurrence.recurrence_id, reminder.ordinal, reminder.action,
6669
+ reminder.minutes_before_start, occurrence.starts_at, occurrence.ends_at,
6670
+ event.resource_id,
6671
+ occurrence.starts_at - make_interval(mins => reminder.minutes_before_start) AS due_at
6672
+ FROM slotlock.calendar_event_occurrences occurrence
6673
+ JOIN slotlock.calendar_events event
6674
+ ON event.id = occurrence.event_id AND event.tenant_ref = occurrence.tenant_ref
6675
+ JOIN slotlock.calendar_event_reminders reminder
6676
+ ON reminder.event_id = occurrence.event_id AND reminder.tenant_ref = occurrence.tenant_ref
6677
+ WHERE occurrence.tenant_ref = ${params.tenantRef}
6678
+ AND event.notify AND event.status <> 'cancelled'
6679
+ AND occurrence.starts_at > statement_timestamp()
6680
+ AND occurrence.starts_at - make_interval(mins => reminder.minutes_before_start)
6681
+ <= statement_timestamp()
6682
+ AND occurrence.starts_at - make_interval(mins => reminder.minutes_before_start)
6683
+ > statement_timestamp() - interval '1 day'
6684
+ -- Said once per time it is due: an occurrence moved since is reminded again.
6685
+ AND NOT EXISTS (
6686
+ SELECT 1 FROM slotlock.reminder_dispatches sent
6687
+ WHERE sent.event_id = occurrence.event_id AND sent.recurrence_id = occurrence.recurrence_id
6688
+ AND sent.ordinal = reminder.ordinal
6689
+ AND sent.due_at = occurrence.starts_at - make_interval(mins => reminder.minutes_before_start))
6690
+ ORDER BY due_at, occurrence.event_id, occurrence.recurrence_id, reminder.ordinal
6691
+ LIMIT ${limit}
6692
+ ), dispatched AS (
6693
+ INSERT INTO slotlock.reminder_dispatches (tenant_ref, event_id, recurrence_id, ordinal, due_at)
6694
+ SELECT ${params.tenantRef}, event_id, recurrence_id, ordinal, due_at FROM due
6695
+ ON CONFLICT (event_id, recurrence_id, ordinal) DO UPDATE
6696
+ SET due_at = EXCLUDED.due_at, dispatched_at = statement_timestamp()
6697
+ WHERE slotlock.reminder_dispatches.due_at <> EXCLUDED.due_at
6698
+ RETURNING event_id, recurrence_id, ordinal
6699
+ ), reported AS (
6700
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
6701
+ SELECT ${params.tenantRef}, due.resource_id, 'reminder.due', due.event_id::text,
6702
+ jsonb_build_object(
6703
+ 'event_id', due.event_id, 'recurrence_id', due.recurrence_id,
6704
+ 'resource_id', due.resource_id, 'ordinal', due.ordinal, 'action', due.action,
6705
+ 'minutes_before_start', due.minutes_before_start, 'due_at', due.due_at,
6706
+ 'start', due.starts_at, 'end', due.ends_at),
6707
+ 'slotlock:maintenance'
6708
+ FROM due JOIN dispatched USING (event_id, recurrence_id, ordinal)
6709
+ RETURNING 1
6710
+ )
6711
+ SELECT count(*)::int AS count FROM reported`;
6712
+ const events = rows[0]?.count ?? 0;
6713
+ if (events >= limit)
6714
+ return events;
6715
+ // A booking's reminders too, named by its reference, with whom to tell.
6716
+ const [bookings] = await sql `
6717
+ WITH due AS (
6718
+ SELECT booking.id, booking.external_ref, booking.resource_id, booking.contact_ref,
6719
+ booking.starts_at, booking.ends_at, (reminder.ordinal - 1)::int AS ordinal,
6720
+ reminder.minutes,
6721
+ booking.starts_at - make_interval(mins => reminder.minutes) AS due_at
6722
+ FROM slotlock.reservations booking,
6723
+ LATERAL unnest(booking.reminder_minutes) WITH ORDINALITY AS reminder(minutes, ordinal)
6724
+ WHERE booking.tenant_ref = ${params.tenantRef} AND booking.status = 'confirmed'
6725
+ AND booking.calendar_event_id IS NULL AND booking.notify
6726
+ AND booking.reminder_minutes IS NOT NULL
6727
+ AND booking.starts_at > statement_timestamp()
6728
+ AND booking.starts_at - make_interval(mins => reminder.minutes) <= statement_timestamp()
6729
+ AND booking.starts_at - make_interval(mins => reminder.minutes) > statement_timestamp() - interval '1 day'
6730
+ AND NOT EXISTS (
6731
+ SELECT 1 FROM slotlock.reservation_reminder_dispatches sent
6732
+ WHERE sent.reservation_id = booking.id AND sent.ordinal = reminder.ordinal - 1
6733
+ AND sent.due_at = booking.starts_at - make_interval(mins => reminder.minutes))
6734
+ ORDER BY due_at, booking.id, reminder.ordinal
6735
+ LIMIT ${limit - events}
6736
+ ), dispatched AS (
6737
+ INSERT INTO slotlock.reservation_reminder_dispatches (tenant_ref, reservation_id, ordinal, due_at)
6738
+ SELECT ${params.tenantRef}, id, ordinal, due_at FROM due
6739
+ ON CONFLICT (reservation_id, ordinal) DO UPDATE
6740
+ SET due_at = EXCLUDED.due_at, dispatched_at = statement_timestamp()
6741
+ WHERE slotlock.reservation_reminder_dispatches.due_at <> EXCLUDED.due_at
6742
+ RETURNING reservation_id AS id, ordinal
6743
+ ), reported AS (
6744
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
6745
+ SELECT ${params.tenantRef}, due.resource_id, 'reminder.due', due.external_ref,
6746
+ jsonb_strip_nulls(jsonb_build_object(
6747
+ 'reservation_id', due.id, 'reference', due.external_ref,
6748
+ 'resource_id', due.resource_id, 'contact_ref', due.contact_ref,
6749
+ 'ordinal', due.ordinal, 'minutes_before_start', due.minutes, 'due_at', due.due_at,
6750
+ 'start', due.starts_at, 'end', due.ends_at)),
6751
+ 'slotlock:maintenance'
6752
+ FROM due JOIN dispatched USING (id, ordinal)
6753
+ RETURNING 1
6754
+ )
6755
+ SELECT count(*)::int AS count FROM reported`;
6756
+ return events + (bookings?.count ?? 0);
6757
+ },
5538
6758
  async reportStaleCoverage(params) {
5539
6759
  if (!isValidIdentity(params.tenantRef)) {
5540
6760
  throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
@@ -5604,6 +6824,45 @@ function buildSlotlockStore(sql, options, refusal) {
5604
6824
  return { ok: true, reference: params.externalRef, checkedInAt: row.checked_in_at };
5605
6825
  });
5606
6826
  },
6827
+ async completeEndedBookings(params) {
6828
+ if (!isValidIdentity(params.tenantRef)) {
6829
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
6830
+ }
6831
+ const limit = params.limit ?? 500;
6832
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 5_000) {
6833
+ throw invalidIdentityError('Slotlock completion limit must be 1-5,000');
6834
+ }
6835
+ // A suspended tenant's bookings stay as they are.
6836
+ if (refusal)
6837
+ return 0;
6838
+ const rows = await sql `
6839
+ WITH done AS (
6840
+ UPDATE slotlock.reservations booking
6841
+ SET completed_at = statement_timestamp()
6842
+ WHERE booking.id IN (
6843
+ SELECT id FROM slotlock.reservations
6844
+ WHERE tenant_ref = ${params.tenantRef} AND status = 'confirmed'
6845
+ AND calendar_event_id IS NULL AND completed_at IS NULL
6846
+ AND ends_at <= statement_timestamp()
6847
+ AND ends_at > statement_timestamp() - interval '7 days'
6848
+ ORDER BY ends_at, id
6849
+ LIMIT ${limit}
6850
+ FOR UPDATE SKIP LOCKED)
6851
+ RETURNING booking.id, booking.resource_id, booking.external_ref, booking.starts_at,
6852
+ booking.ends_at, booking.revision, booking.checked_in_at
6853
+ )
6854
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
6855
+ SELECT ${params.tenantRef}, done.resource_id, 'reservation.completed',
6856
+ COALESCE(done.external_ref, done.id::text),
6857
+ jsonb_strip_nulls(jsonb_build_object(
6858
+ 'id', done.id, 'reference', done.external_ref, 'resource_id', done.resource_id,
6859
+ 'start', done.starts_at, 'end', done.ends_at, 'revision', done.revision,
6860
+ 'checked_in_at', done.checked_in_at)),
6861
+ 'slotlock:maintenance'
6862
+ FROM done
6863
+ RETURNING id`;
6864
+ return rows.length;
6865
+ },
5607
6866
  async releaseNoShows(params) {
5608
6867
  if (!isValidIdentity(params.tenantRef)) {
5609
6868
  throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
@@ -5645,8 +6904,8 @@ function buildSlotlockStore(sql, options, refusal) {
5645
6904
  return false;
5646
6905
  const revision = Number(row.revision) + 1;
5647
6906
  await tx `
5648
- INSERT INTO slotlock.reservation_tombstones (tenant_ref, external_ref, revision, stray)
5649
- VALUES (${tenantRef}, ${row.external_ref}, ${revision}, false)`;
6907
+ INSERT INTO slotlock.reservation_tombstones (tenant_ref, external_ref, revision, stray, end_reason)
6908
+ VALUES (${tenantRef}, ${row.external_ref}, ${revision}, false, 'no_show')`;
5650
6909
  // The deletion is the change: the feed's trigger names it reservation.no_show (one change,
5651
6910
  // not a cancellation too), by maintenance, with the check-in window it missed.
5652
6911
  await tx `
@@ -5684,6 +6943,14 @@ function buildSlotlockStore(sql, options, refusal) {
5684
6943
  if (!Number.isSafeInteger(offerTtlMs) || offerTtlMs < 60_000 || offerTtlMs > 86_400_000) {
5685
6944
  return { ok: false, code: 'invalid_ttl' };
5686
6945
  }
6946
+ const durationMs = params.durationMs ?? null;
6947
+ if (durationMs !== null &&
6948
+ (!Number.isSafeInteger(durationMs) ||
6949
+ durationMs < 60_000 ||
6950
+ durationMs % 60_000 !== 0 ||
6951
+ durationMs > window.end.getTime() - window.start.getTime())) {
6952
+ return { ok: false, code: 'invalid_window' };
6953
+ }
5687
6954
  const key = params.idempotencyKey;
5688
6955
  if (key !== undefined && !isValidIdentity(key))
5689
6956
  return { ok: false, code: 'invalid_identity' };
@@ -5697,6 +6964,7 @@ function buildSlotlockStore(sql, options, refusal) {
5697
6964
  window.end.toISOString(),
5698
6965
  holderRef,
5699
6966
  offerTtlMs,
6967
+ ...(durationMs === null ? [] : [durationMs]),
5700
6968
  ]))
5701
6969
  .digest('hex');
5702
6970
  return inTransaction(sql, async (tx) => {
@@ -5742,11 +7010,20 @@ function buildSlotlockStore(sql, options, refusal) {
5742
7010
  const [row] = await tx `
5743
7011
  INSERT INTO slotlock.waitlist_entries
5744
7012
  (tenant_ref, resource_id, pool_ref, starts_at, ends_at, holder_ref, offer_ttl_ms,
5745
- idempotency_key, payload_hash)
7013
+ idempotency_key, payload_hash, duration_ms)
5746
7014
  VALUES (${tenantRef}, ${params.resourceId ?? null}, ${params.pool ?? null}, ${window.start},
5747
- ${window.end}, ${holderRef}, ${offerTtlMs}, ${key ?? null}, ${payloadHash})
7015
+ ${window.end}, ${holderRef}, ${offerTtlMs}, ${key ?? null}, ${payloadHash}, ${durationMs})
5748
7016
  RETURNING ${tx.unsafe(WAITLIST_COLUMNS)}`;
5749
- return { ok: true, entry: toWaitlistEntry(row), replayed: false };
7017
+ const entry = row;
7018
+ await tx `
7019
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
7020
+ VALUES (${tenantRef}, ${entry.resource_id}::uuid, 'waitlist.joined', ${entry.id},
7021
+ jsonb_strip_nulls(jsonb_build_object(
7022
+ 'id', ${entry.id}::uuid, 'resource_id', ${entry.resource_id}::uuid, 'pool', ${entry.pool_ref}::text,
7023
+ 'start', ${entry.starts_at}::timestamptz, 'end', ${entry.ends_at}::timestamptz,
7024
+ 'duration_ms', ${durationMs}::bigint, 'holder_ref', ${entry.holder_ref}::text)),
7025
+ NULLIF(current_setting('slotlock.actor', true), ''))`;
7026
+ return { ok: true, entry: toWaitlistEntry(entry), replayed: false };
5750
7027
  });
5751
7028
  },
5752
7029
  async listWaitlist(params) {
@@ -5776,9 +7053,19 @@ function buildSlotlockStore(sql, options, refusal) {
5776
7053
  DELETE FROM slotlock.waitlist_entries
5777
7054
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
5778
7055
  AND (${params.holderRef ?? null}::text IS NULL OR holder_ref = ${params.holderRef ?? null})
5779
- RETURNING hold_id`;
7056
+ RETURNING hold_id, status, resource_id, pool_ref, holder_ref`;
5780
7057
  if (!left)
5781
7058
  return false;
7059
+ await tx `
7060
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
7061
+ VALUES (${params.tenantRef}, ${left.resource_id}::uuid, 'waitlist.left', ${params.id},
7062
+ jsonb_strip_nulls(jsonb_build_object(
7063
+ 'id', ${params.id}::uuid, 'resource_id', ${left.resource_id}::uuid, 'pool', ${left.pool_ref}::text,
7064
+ 'status', ${left.status}::text, 'holder_ref', ${left.holder_ref}::text)),
7065
+ NULLIF(current_setting('slotlock.actor', true), ''))`;
7066
+ // An offer still open goes with it; the entry is gone, so its hold's end changes no entry.
7067
+ if (left.status !== 'offered')
7068
+ return true;
5782
7069
  // An untaken offer's hold goes too; a confirmed one is a booking now and stays.
5783
7070
  if (left.hold_id) {
5784
7071
  await buildSlotlockStore(tx, options, null).releaseHold(left.hold_id, { tenantRef: params.tenantRef });
@@ -5786,6 +7073,57 @@ function buildSlotlockStore(sql, options, refusal) {
5786
7073
  return true;
5787
7074
  });
5788
7075
  },
7076
+ async holdGroup(params) {
7077
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.holderRef) || !isValidResourceId(params.groupId)) {
7078
+ return [];
7079
+ }
7080
+ if (params.alignExpiry === true) {
7081
+ await sql `
7082
+ UPDATE slotlock.reservations held
7083
+ SET expires_at = earliest.at
7084
+ FROM (SELECT min(expires_at) AS at FROM slotlock.reservations
7085
+ WHERE hold_group_id = ${params.groupId}::uuid AND holder_ref = ${params.holderRef}
7086
+ AND status = 'held') earliest
7087
+ WHERE held.hold_group_id = ${params.groupId}::uuid AND held.holder_ref = ${params.holderRef}
7088
+ AND held.status = 'held' AND held.expires_at <> earliest.at`;
7089
+ }
7090
+ const rows = await sql `
7091
+ SELECT held.id, held.resource_id, held.starts_at, held.ends_at, held.expires_at, held.source
7092
+ FROM slotlock.reservations held
7093
+ JOIN slotlock.resources resource ON resource.id = held.resource_id
7094
+ WHERE held.hold_group_id = ${params.groupId}::uuid AND held.holder_ref = ${params.holderRef}
7095
+ AND resource.tenant_ref = ${params.tenantRef} AND held.status = 'held'
7096
+ AND held.expires_at > statement_timestamp()
7097
+ ORDER BY held.resource_id`;
7098
+ return rows.map((row) => ({
7099
+ holdId: row.id,
7100
+ resourceId: row.resource_id,
7101
+ start: row.starts_at,
7102
+ end: row.ends_at,
7103
+ expiresAt: row.expires_at,
7104
+ source: row.source,
7105
+ }));
7106
+ },
7107
+ async declineWaitlistOffer(params) {
7108
+ if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id))
7109
+ return null;
7110
+ if (params.holderRef !== undefined && !isValidIdentity(params.holderRef))
7111
+ return null;
7112
+ return inTransaction(sql, async (tx) => {
7113
+ const [offer] = await tx `
7114
+ SELECT hold_id FROM slotlock.waitlist_entries
7115
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id} AND status = 'offered'
7116
+ AND (${params.holderRef ?? null}::text IS NULL OR holder_ref = ${params.holderRef ?? null})
7117
+ FOR UPDATE`;
7118
+ if (!offer)
7119
+ return null;
7120
+ // Letting the hold go declines the entry (slotlock.waitlist_offer_settle) and frees the time.
7121
+ await buildSlotlockStore(tx, options, null).releaseHold(offer.hold_id, { tenantRef: params.tenantRef });
7122
+ const [row] = await tx `
7123
+ SELECT ${tx.unsafe(WAITLIST_COLUMNS)} FROM slotlock.waitlist_entries WHERE id = ${params.id}`;
7124
+ return row ? toWaitlistEntry(row) : null;
7125
+ });
7126
+ },
5789
7127
  async getAssistantSettings(params) {
5790
7128
  if (!isValidIdentity(params.tenantRef))
5791
7129
  throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
@@ -5847,42 +7185,106 @@ function buildSlotlockStore(sql, options, refusal) {
5847
7185
  RETURNING 1`;
5848
7186
  return rows.length > 0;
5849
7187
  },
5850
- async getAssistantConversation(params) {
7188
+ async listAssistantChats(params) {
5851
7189
  if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.memberRef))
7190
+ return [];
7191
+ const limit = params.limit ?? SLOTLOCK_ASSISTANT_MAX_CHATS;
7192
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > SLOTLOCK_ASSISTANT_MAX_CHATS) {
7193
+ throw invalidIdentityError(`Slotlock assistant chat list limit must be 1-${String(SLOTLOCK_ASSISTANT_MAX_CHATS)}`);
7194
+ }
7195
+ const rows = await sql `
7196
+ SELECT chat_id, title, updated_at FROM slotlock.assistant_chats
7197
+ WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef}
7198
+ AND (expires_at IS NULL OR expires_at > statement_timestamp())
7199
+ ORDER BY updated_at DESC, chat_id
7200
+ LIMIT ${limit}`;
7201
+ return rows.map((row) => ({ id: row.chat_id, title: row.title, updatedAt: row.updated_at }));
7202
+ },
7203
+ async getAssistantChat(params) {
7204
+ if (!isValidIdentity(params.tenantRef) ||
7205
+ !isValidIdentity(params.memberRef) ||
7206
+ !SLOTLOCK_ASSISTANT_CHAT_ID.test(params.chatId)) {
5852
7207
  return null;
7208
+ }
5853
7209
  const [row] = await sql `
5854
- SELECT messages, actions, updated_at, expires_at FROM slotlock.assistant_conversations
7210
+ SELECT title, messages, actions, created_at, updated_at, expires_at FROM slotlock.assistant_chats
5855
7211
  WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef}
5856
- AND expires_at > statement_timestamp()`;
7212
+ AND chat_id = ${params.chatId}
7213
+ AND (expires_at IS NULL OR expires_at > statement_timestamp())`;
5857
7214
  if (!row)
5858
7215
  return null;
5859
7216
  return {
7217
+ id: params.chatId,
7218
+ title: row.title,
5860
7219
  messages: row.messages,
5861
7220
  actions: row.actions,
7221
+ createdAt: row.created_at,
5862
7222
  updatedAt: row.updated_at,
5863
7223
  expiresAt: row.expires_at,
5864
7224
  };
5865
7225
  },
5866
- async putAssistantConversation(params) {
7226
+ async putAssistantChat(params) {
5867
7227
  if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.memberRef)) {
5868
7228
  throw invalidIdentityError('Slotlock tenant and member references must be 1-500 UTF-8 bytes');
5869
7229
  }
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');
7230
+ if (!SLOTLOCK_ASSISTANT_CHAT_ID.test(params.chatId)) {
7231
+ throw invalidIdentityError('Slotlock assistant chat id must be 16 URL-safe characters');
5872
7232
  }
7233
+ const title = params.title.trim();
7234
+ if ([...title].length < 1 || [...title].length > 120) {
7235
+ throw invalidIdentityError('Slotlock assistant chat title must be 1-120 characters');
7236
+ }
7237
+ if (params.ttlMs !== null &&
7238
+ (!Number.isSafeInteger(params.ttlMs) || params.ttlMs < 1_000 || params.ttlMs > 3_650 * 86_400_000)) {
7239
+ throw invalidIdentityError('Slotlock assistant conversation ttlMs must be 1 second to 3,650 days');
7240
+ }
7241
+ const ttlSeconds = params.ttlMs === null ? null : params.ttlMs / 1_000;
5873
7242
  const messages = sql.json(params.messages);
5874
7243
  const added = sql.json(params.added);
5875
7244
  const keep = [...params.keep];
7245
+ // One member's chats are written one at a time, so the trim below sees every insert before it.
5876
7246
  await sql `
5877
- DELETE FROM slotlock.assistant_conversations
5878
- WHERE tenant_ref = ${params.tenantRef} AND expires_at <= statement_timestamp()`;
7247
+ SELECT pg_advisory_xact_lock(hashtextextended(
7248
+ ${`slotlock:assistant-chats:${params.tenantRef}\n${params.memberRef}`}::text, 0))`;
7249
+ if (params.create === false) {
7250
+ // Only one still there: a chat deleted while its turn ran stays deleted.
7251
+ const changed = await sql `
7252
+ UPDATE slotlock.assistant_chats AS stored
7253
+ SET messages = ${messages},
7254
+ actions = (
7255
+ SELECT coalesce(jsonb_object_agg(existing.key, existing.value), '{}'::jsonb)
7256
+ FROM jsonb_each(stored.actions) AS existing
7257
+ WHERE existing.key = ANY(${keep}::text[])
7258
+ ) || (
7259
+ SELECT coalesce(jsonb_object_agg(fresh.key, fresh.value), '{}'::jsonb)
7260
+ FROM jsonb_each(${added}::jsonb) AS fresh
7261
+ WHERE NOT stored.actions ? fresh.key
7262
+ ),
7263
+ updated_at = statement_timestamp(),
7264
+ expires_at = CASE WHEN ${ttlSeconds}::double precision IS NULL THEN NULL
7265
+ ELSE statement_timestamp() + make_interval(secs => ${ttlSeconds}::double precision) END
7266
+ WHERE stored.tenant_ref = ${params.tenantRef} AND stored.member_ref = ${params.memberRef}
7267
+ AND stored.chat_id = ${params.chatId}
7268
+ AND (stored.expires_at IS NULL OR stored.expires_at > statement_timestamp())
7269
+ RETURNING 1`;
7270
+ if (changed.length === 0)
7271
+ return false;
7272
+ if (ttlSeconds !== null) {
7273
+ await sql `
7274
+ SELECT slotlock.register_assistant_tenant(
7275
+ ${params.tenantRef}::text,
7276
+ statement_timestamp() + make_interval(secs => ${ttlSeconds}::double precision))`;
7277
+ }
7278
+ return true;
7279
+ }
5879
7280
  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
7281
+ INSERT INTO slotlock.assistant_chats AS stored
7282
+ (tenant_ref, member_ref, chat_id, title, messages, actions, created_at, updated_at, expires_at)
7283
+ VALUES (${params.tenantRef}, ${params.memberRef}, ${params.chatId}, ${title}, ${messages}, ${added},
7284
+ statement_timestamp(), statement_timestamp(),
7285
+ CASE WHEN ${ttlSeconds}::double precision IS NULL THEN NULL
7286
+ ELSE statement_timestamp() + make_interval(secs => ${ttlSeconds}::double precision) END)
7287
+ ON CONFLICT (tenant_ref, member_ref, chat_id) DO UPDATE SET
5886
7288
  messages = EXCLUDED.messages,
5887
7289
  -- The stored ones kept, as they stand, then the new ones: never a settled one again.
5888
7290
  actions = (
@@ -5896,21 +7298,55 @@ function buildSlotlockStore(sql, options, refusal) {
5896
7298
  ),
5897
7299
  updated_at = EXCLUDED.updated_at,
5898
7300
  expires_at = EXCLUDED.expires_at`;
7301
+ // The member keeps the most recent; past the limit, the least recently used go.
5899
7302
  await sql `
5900
- SELECT slotlock.register_assistant_tenant(
5901
- ${params.tenantRef}::text,
5902
- statement_timestamp() + make_interval(secs => ${params.ttlMs / 1_000}))`;
7303
+ DELETE FROM slotlock.assistant_chats gone
7304
+ WHERE gone.tenant_ref = ${params.tenantRef} AND gone.member_ref = ${params.memberRef}
7305
+ AND gone.chat_id IN (
7306
+ SELECT kept.chat_id FROM slotlock.assistant_chats kept
7307
+ WHERE kept.tenant_ref = ${params.tenantRef} AND kept.member_ref = ${params.memberRef}
7308
+ ORDER BY kept.updated_at DESC, kept.chat_id
7309
+ OFFSET ${SLOTLOCK_ASSISTANT_MAX_CHATS})`;
7310
+ if (ttlSeconds !== null) {
7311
+ await sql `
7312
+ SELECT slotlock.register_assistant_tenant(
7313
+ ${params.tenantRef}::text,
7314
+ statement_timestamp() + make_interval(secs => ${ttlSeconds}::double precision))`;
7315
+ }
7316
+ return true;
7317
+ },
7318
+ async renameAssistantChat(params) {
7319
+ if (!isValidIdentity(params.tenantRef) ||
7320
+ !isValidIdentity(params.memberRef) ||
7321
+ !SLOTLOCK_ASSISTANT_CHAT_ID.test(params.chatId)) {
7322
+ return false;
7323
+ }
7324
+ const title = params.title.trim();
7325
+ if ([...title].length < 1 || [...title].length > 120) {
7326
+ throw invalidIdentityError('Slotlock assistant chat title must be 1-120 characters');
7327
+ }
7328
+ const rows = await sql `
7329
+ UPDATE slotlock.assistant_chats SET title = ${title}
7330
+ WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef}
7331
+ AND chat_id = ${params.chatId}
7332
+ AND (expires_at IS NULL OR expires_at > statement_timestamp())
7333
+ RETURNING 1`;
7334
+ return rows.length > 0;
5903
7335
  },
5904
7336
  async settleAssistantAction(params) {
5905
- if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.memberRef))
7337
+ if (!isValidIdentity(params.tenantRef) ||
7338
+ !isValidIdentity(params.memberRef) ||
7339
+ !SLOTLOCK_ASSISTANT_CHAT_ID.test(params.chatId)) {
5906
7340
  return null;
7341
+ }
5907
7342
  const patch = sql.json(params.patch);
5908
7343
  const [row] = await sql `
5909
- UPDATE slotlock.assistant_conversations
7344
+ UPDATE slotlock.assistant_chats
5910
7345
  SET actions = jsonb_set(actions, ARRAY[${params.actionId}::text],
5911
7346
  (actions -> ${params.actionId}::text) || ${patch}::jsonb)
5912
7347
  WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef}
5913
- AND expires_at > statement_timestamp()
7348
+ AND chat_id = ${params.chatId}
7349
+ AND (expires_at IS NULL OR expires_at > statement_timestamp())
5914
7350
  AND actions -> ${params.actionId}::text ->> 'status' = ${params.from}
5915
7351
  RETURNING actions -> ${params.actionId}::text AS action`;
5916
7352
  return row?.action ?? null;
@@ -5922,7 +7358,17 @@ function buildSlotlockStore(sql, options, refusal) {
5922
7358
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10_000) {
5923
7359
  throw invalidIdentityError('Slotlock assistant prune limit must be 1-10,000');
5924
7360
  }
5925
- const rows = await sql `
7361
+ // The conversations a deployment gave a lifetime, past it; and those 0.8 kept, past theirs.
7362
+ const chats = await sql `
7363
+ DELETE FROM slotlock.assistant_chats
7364
+ WHERE tenant_ref = ${params.tenantRef}
7365
+ AND (member_ref, chat_id) IN (
7366
+ SELECT member_ref, chat_id FROM slotlock.assistant_chats
7367
+ WHERE tenant_ref = ${params.tenantRef} AND expires_at <= statement_timestamp()
7368
+ LIMIT ${limit})
7369
+ AND expires_at <= statement_timestamp()
7370
+ RETURNING 1`;
7371
+ const old = await sql `
5926
7372
  DELETE FROM slotlock.assistant_conversations
5927
7373
  WHERE tenant_ref = ${params.tenantRef}
5928
7374
  AND member_ref IN (
@@ -5931,7 +7377,7 @@ function buildSlotlockStore(sql, options, refusal) {
5931
7377
  LIMIT ${limit})
5932
7378
  AND expires_at <= statement_timestamp()
5933
7379
  RETURNING 1`;
5934
- return rows.length;
7380
+ return chats.length + old.length;
5935
7381
  },
5936
7382
  async sweepAssistantConversations(params) {
5937
7383
  const limit = params?.limit ?? 10_000;
@@ -5942,12 +7388,16 @@ function buildSlotlockStore(sql, options, refusal) {
5942
7388
  SELECT slotlock.sweep_assistant_conversations(${limit}::integer) AS deleted`;
5943
7389
  return Number(row?.deleted ?? 0);
5944
7390
  },
5945
- async deleteAssistantConversation(params) {
5946
- if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.memberRef))
7391
+ async deleteAssistantChat(params) {
7392
+ if (!isValidIdentity(params.tenantRef) ||
7393
+ !isValidIdentity(params.memberRef) ||
7394
+ !SLOTLOCK_ASSISTANT_CHAT_ID.test(params.chatId)) {
5947
7395
  return false;
7396
+ }
5948
7397
  const rows = await sql `
5949
- DELETE FROM slotlock.assistant_conversations
5950
- WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef} RETURNING 1`;
7398
+ DELETE FROM slotlock.assistant_chats
7399
+ WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef}
7400
+ AND chat_id = ${params.chatId} RETURNING 1`;
5951
7401
  return rows.length > 0;
5952
7402
  },
5953
7403
  async offerWaitlist(params) {
@@ -5967,10 +7417,21 @@ function buildSlotlockStore(sql, options, refusal) {
5967
7417
  await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify(['slotlock-waitlist', tenantRef])}, 0))`;
5968
7418
  let expired = 0;
5969
7419
  if (!after) {
7420
+ // Offers whose hold ran out lapse (slotlock.waitlist_offer_settle), freeing their time.
7421
+ await tx `
7422
+ DELETE FROM slotlock.reservations offer
7423
+ USING slotlock.resources resource
7424
+ WHERE resource.id = offer.resource_id AND resource.tenant_ref = ${tenantRef}
7425
+ AND offer.status = 'held' AND offer.source = 'slotlock:waitlist'
7426
+ AND offer.expires_at <= statement_timestamp()`;
5970
7427
  const [gone] = await tx `
5971
7428
  WITH gone AS (
5972
7429
  UPDATE slotlock.waitlist_entries SET status = 'expired'
5973
- WHERE tenant_ref = ${tenantRef} AND status = 'waiting' AND starts_at <= statement_timestamp()
7430
+ WHERE tenant_ref = ${tenantRef} AND status = 'waiting'
7431
+ -- The whole window once it starts; a length once no slot of it fits before the end.
7432
+ AND (CASE WHEN duration_ms IS NULL THEN starts_at
7433
+ ELSE ends_at - make_interval(secs => duration_ms / 1000.0) END)
7434
+ <= statement_timestamp()
5974
7435
  RETURNING id, resource_id, pool_ref, starts_at, ends_at, holder_ref
5975
7436
  ), reported AS (
5976
7437
  INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
@@ -6004,6 +7465,15 @@ function buildSlotlockStore(sql, options, refusal) {
6004
7465
  WHERE id = ${entry.id} AND status = 'waiting' FOR UPDATE`;
6005
7466
  if (!still)
6006
7467
  continue;
7468
+ if (entry.duration_ms !== null) {
7469
+ // Any slot of its length within its window (RFC 0004 B6), on its resource or any member.
7470
+ const slot = await offerSlotOfLength(tx, inner, tenantRef, entry);
7471
+ if (slot) {
7472
+ await recordWaitlistOffer(tx, tenantRef, entry, slot);
7473
+ offered += 1;
7474
+ }
7475
+ continue;
7476
+ }
6007
7477
  // Only resources that look free (buffers included) are tried: a refused hold keeps its
6008
7478
  // locks until the transaction ends.
6009
7479
  const candidates = await tx `
@@ -6026,6 +7496,12 @@ function buildSlotlockStore(sql, options, refusal) {
6026
7496
  && tstzrange(${entry.starts_at}::timestamptz - make_interval(mins => buffers.before_minutes),
6027
7497
  ${entry.ends_at}::timestamptz + make_interval(mins => buffers.after_minutes), '[)')
6028
7498
  AND (taken.status <> 'held' OR taken.expires_at > statement_timestamp()))
7499
+ -- Nor busy time a calendar reported (RFC 0004 C1), which the hold would be refused.
7500
+ AND NOT EXISTS (
7501
+ SELECT 1 FROM slotlock.source_busy busy
7502
+ WHERE busy.resource_id = resources.id
7503
+ AND busy.starts_at < ${entry.ends_at}::timestamptz + make_interval(mins => buffers.after_minutes)
7504
+ AND busy.ends_at > ${entry.starts_at}::timestamptz - make_interval(mins => buffers.before_minutes))
6029
7505
  ORDER BY resources.id
6030
7506
  LIMIT ${OFFER_CANDIDATES}`;
6031
7507
  for (const { id: resourceId } of candidates) {
@@ -6040,21 +7516,13 @@ function buildSlotlockStore(sql, options, refusal) {
6040
7516
  });
6041
7517
  if (!hold.ok)
6042
7518
  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')`;
7519
+ await recordWaitlistOffer(tx, tenantRef, entry, {
7520
+ resourceId,
7521
+ holdId: hold.hold.id,
7522
+ start: entry.starts_at,
7523
+ end: entry.ends_at,
7524
+ expiresAt: hold.hold.expiresAt,
7525
+ });
6058
7526
  offered += 1;
6059
7527
  break;
6060
7528
  }
@@ -6111,6 +7579,8 @@ function buildSlotlockStore(sql, options, refusal) {
6111
7579
  AND (reservation.status <> 'held' OR reservation.expires_at > statement_timestamp())
6112
7580
  UNION ALL
6113
7581
  ${compositeBusy(sql, params.resourceId, window)}
7582
+ UNION ALL
7583
+ ${sourceBusy(sql, params.resourceId, window)}
6114
7584
  ORDER BY 1
6115
7585
  LIMIT ${MAX_FREE_BUSY_INTERVALS + 1}`;
6116
7586
  if (busyRows.length > MAX_FREE_BUSY_INTERVALS) {