slotlock 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +281 -0
- package/agent-server.d.ts +25 -5
- package/agent-server.js +152 -7
- package/agent-store-backend.d.ts +6 -1
- package/agent-store-backend.js +861 -55
- package/application-api.d.ts +19 -1
- package/application-api.js +578 -21
- package/approvals.d.ts +2 -0
- package/approvals.js +12 -5
- package/assistant.d.ts +64 -9
- package/assistant.js +450 -82
- package/availability-store.d.ts +193 -0
- package/availability-store.js +558 -0
- package/booking-rules.d.ts +92 -5
- package/booking-rules.js +243 -15
- package/dashboard-assistant-ui.d.ts +21 -0
- package/dashboard-assistant-ui.js +539 -0
- package/dashboard-markdown.d.ts +2 -0
- package/dashboard-markdown.js +157 -0
- package/dashboard-settings-ui.d.ts +1 -0
- package/dashboard-settings-ui.js +78 -0
- package/dashboard.d.ts +84 -11
- package/dashboard.js +1654 -384
- package/ddl.d.ts +5 -1
- package/ddl.js +760 -9
- package/event-feed.d.ts +1 -1
- package/event-feed.js +18 -0
- package/holiday-import.d.ts +38 -0
- package/holiday-import.js +57 -0
- package/holidays.d.ts +38 -0
- package/holidays.js +150 -0
- package/hours.d.ts +125 -0
- package/hours.js +224 -0
- package/index.d.ts +14 -3
- package/index.js +10 -2
- package/interop.d.ts +25 -7
- package/interop.js +145 -17
- package/lifecycle.d.ts +31 -0
- package/lifecycle.js +115 -0
- package/maintenance.d.ts +19 -0
- package/maintenance.js +55 -1
- package/metrics.js +3 -0
- package/openapi.json +16237 -7795
- package/package.json +1 -1
- package/ranges.d.ts +28 -0
- package/ranges.js +135 -0
- package/resource-search.d.ts +19 -0
- package/resource-search.js +153 -0
- package/rest-api.js +7 -0
- package/rules.d.ts +2 -0
- package/rules.js +2 -0
- package/self-host.d.ts +1 -1
- package/self-host.js +221 -21
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +367 -24
- package/store.js +1567 -97
- package/types.d.ts +198 -0
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,
|
|
1205
|
-
|
|
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
|
|
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
|
|
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
|
-
'
|
|
2635
|
+
'putAssistantChat',
|
|
2636
|
+
'renameAssistantChat',
|
|
2268
2637
|
'settleAssistantAction',
|
|
2269
|
-
'
|
|
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
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
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 (
|
|
3688
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
4298
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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 (!
|
|
5871
|
-
throw invalidIdentityError('Slotlock assistant
|
|
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
|
-
|
|
5878
|
-
|
|
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.
|
|
5881
|
-
(tenant_ref, member_ref, messages, actions, updated_at, expires_at)
|
|
5882
|
-
VALUES (${params.tenantRef}, ${params.memberRef}, ${messages}, ${added},
|
|
5883
|
-
statement_timestamp(),
|
|
5884
|
-
|
|
5885
|
-
|
|
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
|
-
|
|
5901
|
-
|
|
5902
|
-
|
|
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) ||
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
5946
|
-
if (!isValidIdentity(params.tenantRef) ||
|
|
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.
|
|
5950
|
-
WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef}
|
|
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'
|
|
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
|
-
|
|
6045
|
-
|
|
6046
|
-
|
|
6047
|
-
|
|
6048
|
-
|
|
6049
|
-
|
|
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) {
|