slotlock 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +542 -0
- package/NOTICE +2 -0
- package/README.md +4 -2
- 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 +1068 -68
- package/application-api.d.ts +28 -1
- package/application-api.js +789 -21
- package/approvals.d.ts +2 -0
- package/approvals.js +12 -5
- package/assistant.d.ts +34 -8
- package/assistant.js +81 -26
- package/availability-store.d.ts +193 -0
- package/availability-store.js +566 -0
- package/booking-rules.d.ts +103 -5
- package/booking-rules.js +263 -15
- package/config.d.ts +50 -0
- package/config.js +107 -0
- package/dashboard-assistant-ui.d.ts +1 -1
- package/dashboard-assistant-ui.js +109 -8
- package/dashboard-scope.d.ts +7 -0
- package/dashboard-scope.js +21 -0
- package/dashboard.d.ts +152 -8
- package/dashboard.js +3868 -399
- package/ddl-identity.d.ts +25 -0
- package/ddl-identity.js +881 -0
- package/ddl-scim.d.ts +5 -0
- package/ddl-scim.js +740 -0
- package/ddl-sso.d.ts +15 -0
- package/ddl-sso.js +1033 -0
- package/ddl.d.ts +44 -9
- package/ddl.js +2872 -47
- package/domains.d.ts +117 -0
- package/domains.js +340 -0
- package/event-feed.d.ts +2 -2
- package/event-feed.js +24 -34
- package/holiday-import.d.ts +38 -0
- package/holiday-import.js +56 -0
- package/holidays.d.ts +38 -0
- package/holidays.js +209 -0
- package/hours.d.ts +125 -0
- package/hours.js +224 -0
- package/index.d.ts +24 -5
- package/index.js +17 -3
- package/interop.d.ts +25 -7
- package/interop.js +145 -17
- package/invitation-email.d.ts +26 -0
- package/invitation-email.js +73 -0
- package/lifecycle.d.ts +31 -0
- package/lifecycle.js +115 -0
- package/maintenance.d.ts +26 -2
- package/maintenance.js +70 -2
- package/metrics.js +3 -0
- package/oidc.d.ts +135 -0
- package/oidc.js +452 -0
- package/openapi.json +20172 -9850
- package/package.json +2 -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/scim-server.d.ts +19 -0
- package/scim-server.js +243 -0
- package/scim.d.ts +180 -0
- package/scim.js +416 -0
- package/self-host.d.ts +19 -6
- package/self-host.js +483 -28
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +637 -23
- package/store.js +2529 -147
- package/types.d.ts +230 -0
- package/types.js +68 -1
- package/workspace-domains.d.ts +146 -0
- package/workspace-domains.js +249 -0
- package/workspace-roles.d.ts +47 -0
- package/workspace-roles.js +304 -0
- package/workspace-scim.d.ts +89 -0
- package/workspace-scim.js +157 -0
- package/workspace-sso.d.ts +131 -0
- package/workspace-sso.js +180 -0
- package/workspaces.d.ts +240 -0
- package/workspaces.js +433 -0
package/store.js
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
// DB mechanics for the Slotlock core: explicit `postgres` client in, structured results out.
|
|
2
2
|
import { SLOTLOCK_MAX_LOCATION_DEPTH, SLOTLOCK_MAX_LOCATIONS, SLOTLOCK_MAX_RESOURCE_GROUPS, SLOTLOCK_POOL_ORDERS, SLOTLOCK_RESOURCE_KINDS, isSlotlockResourceScope, parseSlotlockLocation, parseSlotlockResourceGroup, parseSlotlockResourceProfile, slotlockGroupingRef, } from './resource-profile.js';
|
|
3
3
|
import { readSlotlockTenantUsage } from './usage.js';
|
|
4
|
+
import { isSlotlockWorkspaceRole } from './workspace-roles.js';
|
|
4
5
|
import { slotlockPlanReadRefusal } from './plans.js';
|
|
5
6
|
import { SLOTLOCK_CHANGE_ORIGINS, SLOTLOCK_CHANGE_RETENTION_DAYS, isSlotlockChangeOrigin, slotlockEventFeedMethods, } from './event-feed.js';
|
|
6
|
-
import { bookingBufferBeforeMs, bookingSearchBlocked, bookingSearchReach, parseBookingRuleDefaults, parseEffectiveBookingRules, bookingBufferAfterMs, bookingRuleBroken, bookingSearch, parseBookingRules, } from './booking-rules.js';
|
|
7
|
-
import { createHash, randomUUID } from 'node:crypto';
|
|
7
|
+
import { cancellationFeeDue, cutoffRuleBroken, bookingBufferBeforeMs, bookingSearchBlocked, bookingSearchReach, parseBookingRuleDefaults, parseEffectiveBookingRules, bookingBufferAfterMs, bookingRuleBroken, bookingSearch, parseBookingRules, } from './booking-rules.js';
|
|
8
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
8
9
|
import ICAL from 'ical.js';
|
|
9
|
-
import { SLOTLOCK_CORE_DDL, SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH, SLOTLOCK_DEPLOYMENT_CHECKS, SLOTLOCK_SCHEMA_CONTROL_REFUSAL, SLOTLOCK_SCHEMA_NEWER_REFUSAL, SLOTLOCK_SCHEMA_VERSION, SLOTLOCK_STRUCTURE_DDL, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_RESOURCE_SCOPED_TABLES, SLOTLOCK_RESOURCE_SCOPE_SETTING, SLOTLOCK_TENANT_TABLES, SLOTLOCK_VALIDATION_DDL, createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, slotlockCoverageChangeRenameSql, slotlockTenantLockKey, } from './ddl.js';
|
|
10
|
+
import { SLOTLOCK_ASSISTANT_CHAT_ID, SLOTLOCK_ASSISTANT_MAX_CHATS, SLOTLOCK_CORE_DDL, SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH, SLOTLOCK_DEPLOYMENT_CHECKS, SLOTLOCK_SCHEMA_CONTROL_REFUSAL, SLOTLOCK_SCHEMA_NEWER_REFUSAL, SLOTLOCK_SCHEMA_VERSION, SLOTLOCK_STRUCTURE_DDL, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_RESOURCE_SCOPED_TABLES, SLOTLOCK_RESOURCE_SCOPE_SETTING, SLOTLOCK_TENANT_TABLES, SLOTLOCK_VALIDATION_DDL, createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, slotlockCoverageChangeRenameSql, slotlockTenantLockKey, } from './ddl.js';
|
|
10
11
|
import { findNextAvailable, mergeIntervals } from './engine.js';
|
|
11
|
-
import { SLOTLOCK_CALENDAR_HORIZON_DAYS, assertBoundedSearchWindow, expandRules, invalidWindowError, } from './rules.js';
|
|
12
|
+
import { SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_SOURCE_BUSY_MAX, assertBoundedSearchWindow, expandRules, invalidWindowError, } from './rules.js';
|
|
12
13
|
import { CalendarContractError, CalendarExpansionLimitError, expandCalendarEventSeries, normalizeCalendarEventContent, } from './sync.js';
|
|
14
|
+
import { SLOTLOCK_SUBJECT_TABLES, SLOTLOCK_TENANT_EXPORT_TABLES, } from './types.js';
|
|
13
15
|
import { parseAvailabilityRules } from './weekly-hours.js';
|
|
16
|
+
import { applicableHours, exceptionQueryDays } from './hours.js';
|
|
17
|
+
import { zonedDateTimeToUtc } from './timezone.js';
|
|
18
|
+
import { createAvailabilityException, deleteHolidayCalendar, dueHolidayCalendars, importHolidayCalendar, listHolidayCalendars, noteHolidayCalendarFailure, deleteAvailabilityException, getAvailabilityDefaults, listAvailabilityExceptions, rankedExceptions, replaceImportedExceptions, setLocationAvailability, storeTenantAvailability, storedInheritedAvailability, } from './availability-store.js';
|
|
14
19
|
import { APPROVAL_COLUMNS, approvalByRequestKey, approvalPoliciesFor, beginApprovalGrant, declineApproval, deleteApprovalPolicy, expireApprovals, finishApprovalGrant, getApproval, listApprovalPolicies, listApprovals, putApprovalPolicy, recordApprovalChange, toApproval, } from './approvals.js';
|
|
15
20
|
const EXCLUSION_VIOLATION = '23P01';
|
|
16
21
|
/** Holds one approval may own: a bundle's resources, or a move's new time in pieces. */
|
|
@@ -31,7 +36,30 @@ const MAX_HOLD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
31
36
|
const MAX_BUFFER_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
|
|
32
37
|
/** A day, as bufferBeforeMinutes allows: a read by start can then still use the index. */
|
|
33
38
|
const MAX_BUFFER_BEFORE_MS = 24 * 60 * 60 * 1000;
|
|
34
|
-
|
|
39
|
+
/** An audit action: `area.verb` (or `area.thing.verb`), lowercase. */
|
|
40
|
+
const AUDIT_ACTION = /^[a-z][a-z_]{0,31}(\.[a-z][a-z_]{0,31}){1,2}$/;
|
|
41
|
+
const AUDIT_TARGET_TYPE = /^[a-z][a-z_]{0,31}$/;
|
|
42
|
+
const AUDIT_ACTOR_TYPES = new Set(['person', 'api_key', 'assistant', 'system']);
|
|
43
|
+
/** Text cut to at most `max` UTF-8 bytes on a character boundary; null when absent or empty. */
|
|
44
|
+
function cutBytes(value, max) {
|
|
45
|
+
if (value === undefined || value === '')
|
|
46
|
+
return null;
|
|
47
|
+
if (Buffer.byteLength(value, 'utf8') <= max)
|
|
48
|
+
return value;
|
|
49
|
+
let out = '';
|
|
50
|
+
for (const character of value) {
|
|
51
|
+
if (Buffer.byteLength(out + character, 'utf8') > max)
|
|
52
|
+
break;
|
|
53
|
+
out += character;
|
|
54
|
+
}
|
|
55
|
+
return out === '' ? null : out;
|
|
56
|
+
}
|
|
57
|
+
/** Audit metadata kept under the column's 4 KB: past it, the event says it was cut. */
|
|
58
|
+
function boundAuditMetadata(metadata) {
|
|
59
|
+
const text = JSON.stringify(metadata);
|
|
60
|
+
return text !== undefined && Buffer.byteLength(text, 'utf8') <= 3_000 ? metadata : { truncated: true };
|
|
61
|
+
}
|
|
62
|
+
export { SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_SOURCE_BUSY_MAX };
|
|
35
63
|
/**
|
|
36
64
|
* Longest one-off event (ten years and some leap days); a recurring series is bounded by its
|
|
37
65
|
* materialization window instead.
|
|
@@ -690,6 +718,9 @@ function validateExternalCommand(params) {
|
|
|
690
718
|
if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.externalRef)) {
|
|
691
719
|
return { ok: false, code: 'invalid_identity' };
|
|
692
720
|
}
|
|
721
|
+
if (params.overrideReason !== undefined && !isValidOverrideReason(params.overrideReason)) {
|
|
722
|
+
return { ok: false, code: 'invalid_identity' };
|
|
723
|
+
}
|
|
693
724
|
if (!Number.isSafeInteger(params.bufferAfterMs) ||
|
|
694
725
|
params.bufferAfterMs < 0 ||
|
|
695
726
|
params.bufferAfterMs > MAX_BUFFER_AFTER_MS) {
|
|
@@ -698,8 +729,91 @@ function validateExternalCommand(params) {
|
|
|
698
729
|
if (!Number.isSafeInteger(params.revision) || params.revision <= 0) {
|
|
699
730
|
return { ok: false, code: 'invalid_revision' };
|
|
700
731
|
}
|
|
732
|
+
if (params.contactRef !== undefined && !isValidContactRef(params.contactRef)) {
|
|
733
|
+
return { ok: false, code: 'invalid_contact_ref' };
|
|
734
|
+
}
|
|
735
|
+
if (params.notify !== undefined && typeof params.notify !== 'boolean') {
|
|
736
|
+
return { ok: false, code: 'invalid_notify' };
|
|
737
|
+
}
|
|
738
|
+
if (params.reminderMinutes !== undefined &&
|
|
739
|
+
(!Array.isArray(params.reminderMinutes) ||
|
|
740
|
+
params.reminderMinutes.length > 5 ||
|
|
741
|
+
new Set(params.reminderMinutes).size !== params.reminderMinutes.length ||
|
|
742
|
+
params.reminderMinutes.some((minutes) => !Number.isSafeInteger(minutes) || minutes < 0 || minutes > 527_040))) {
|
|
743
|
+
return { ok: false, code: 'invalid_reminders' };
|
|
744
|
+
}
|
|
701
745
|
return null;
|
|
702
746
|
}
|
|
747
|
+
/** A booking's reminders as stored: sorted, latest first; null for none. */
|
|
748
|
+
function reminderColumn(minutes) {
|
|
749
|
+
return minutes && minutes.length > 0 ? [...minutes].sort((a, b) => b - a) : null;
|
|
750
|
+
}
|
|
751
|
+
/** A recipient's reference (RFC 0004 P3): what the contacts table and a booking name a person by. */
|
|
752
|
+
const CONTACT_REF = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/;
|
|
753
|
+
function isValidContactRef(value) {
|
|
754
|
+
return typeof value === 'string' && CONTACT_REF.test(value);
|
|
755
|
+
}
|
|
756
|
+
/** The most contacts a tenant keeps (RFC 0004 P3). */
|
|
757
|
+
export const SLOTLOCK_MAX_CONTACTS_PER_TENANT = 100_000;
|
|
758
|
+
const E164 = /^\+[1-9][0-9]{6,14}$/;
|
|
759
|
+
const LOCALE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
|
|
760
|
+
const CONTACT_COLUMNS = 'ref, name, email, phone, whatsapp, consent, consent_at, locale, created_at, updated_at';
|
|
761
|
+
function toContact(row) {
|
|
762
|
+
const consent = {};
|
|
763
|
+
for (const channel of ['email', 'sms', 'whatsapp']) {
|
|
764
|
+
const answer = row.consent?.[channel];
|
|
765
|
+
if (typeof answer === 'boolean')
|
|
766
|
+
consent[channel] = answer;
|
|
767
|
+
}
|
|
768
|
+
const consentAt = {};
|
|
769
|
+
for (const channel of ['email', 'sms', 'whatsapp']) {
|
|
770
|
+
const at = row.consent_at?.[channel];
|
|
771
|
+
if (typeof at === 'string' && channel in consent)
|
|
772
|
+
consentAt[channel] = new Date(at);
|
|
773
|
+
}
|
|
774
|
+
return {
|
|
775
|
+
ref: row.ref,
|
|
776
|
+
name: row.name,
|
|
777
|
+
email: row.email,
|
|
778
|
+
phone: row.phone,
|
|
779
|
+
whatsapp: row.whatsapp,
|
|
780
|
+
consent,
|
|
781
|
+
consentAt,
|
|
782
|
+
locale: row.locale,
|
|
783
|
+
createdAt: row.created_at,
|
|
784
|
+
updatedAt: row.updated_at,
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
/** A contact's fields as the table's CHECK takes them, or null when one is not. */
|
|
788
|
+
function contactFields(params) {
|
|
789
|
+
const text = (value, valid) => {
|
|
790
|
+
if (value === undefined || value === null)
|
|
791
|
+
return null;
|
|
792
|
+
if (typeof value !== 'string' || !valid(value))
|
|
793
|
+
return undefined;
|
|
794
|
+
return value;
|
|
795
|
+
};
|
|
796
|
+
const name = text(params.name, (value) => value.length >= 1 && value.length <= 200 && !/[\u0000-\u001f\u007f]/.test(value));
|
|
797
|
+
const email = text(params.email, (value) => value.length >= 3 && value.length <= 320 && /^[^\s@]+@[^\s@]+$/.test(value));
|
|
798
|
+
const phone = text(params.phone, (value) => E164.test(value));
|
|
799
|
+
const whatsapp = text(params.whatsapp, (value) => E164.test(value));
|
|
800
|
+
const locale = text(params.locale, (value) => value.length <= 35 && LOCALE.test(value));
|
|
801
|
+
if (name === undefined || email === undefined || phone === undefined || whatsapp === undefined || locale === undefined) {
|
|
802
|
+
return null;
|
|
803
|
+
}
|
|
804
|
+
const consent = {};
|
|
805
|
+
if (params.consent !== undefined) {
|
|
806
|
+
if (typeof params.consent !== 'object' || params.consent === null || Array.isArray(params.consent))
|
|
807
|
+
return null;
|
|
808
|
+
for (const [channel, answer] of Object.entries(params.consent)) {
|
|
809
|
+
if ((channel !== 'email' && channel !== 'sms' && channel !== 'whatsapp') || typeof answer !== 'boolean') {
|
|
810
|
+
return null;
|
|
811
|
+
}
|
|
812
|
+
consent[channel] = answer;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
return { name, email, phone, whatsapp, consent, locale };
|
|
816
|
+
}
|
|
703
817
|
function toReservation(row) {
|
|
704
818
|
return {
|
|
705
819
|
id: row.id,
|
|
@@ -711,8 +825,19 @@ function toReservation(row) {
|
|
|
711
825
|
bufferAfterMs: Number(row.buffer_after_ms),
|
|
712
826
|
revision: Number(row.revision),
|
|
713
827
|
source: row.source,
|
|
828
|
+
...(row.contact_ref ? { contactRef: row.contact_ref } : {}),
|
|
829
|
+
...(row.notify === false ? { notify: false } : {}),
|
|
830
|
+
...(row.reminder_minutes?.length ? { reminderMinutes: row.reminder_minutes.map(Number) } : {}),
|
|
714
831
|
};
|
|
715
832
|
}
|
|
833
|
+
/** A hold's payment columns, as every read of one names them. */
|
|
834
|
+
const HOLD_PAYMENT_COLUMNS = 'payment_required, payment_ref, paid_at, payment_amount_minor, payment_currency, paid_amount_minor, paid_currency';
|
|
835
|
+
const HOLD_PAYMENT_COLUMNS_OF_HOLD = HOLD_PAYMENT_COLUMNS.split(', ')
|
|
836
|
+
.map((column) => `hold.${column}`)
|
|
837
|
+
.join(', ');
|
|
838
|
+
function amountOf(minor, currency) {
|
|
839
|
+
return minor === null || minor === undefined || !currency ? null : { amountMinor: Number(minor), currency };
|
|
840
|
+
}
|
|
716
841
|
function toHold(row) {
|
|
717
842
|
return {
|
|
718
843
|
id: row.id,
|
|
@@ -723,8 +848,35 @@ function toHold(row) {
|
|
|
723
848
|
expiresAt: row.expires_at,
|
|
724
849
|
source: row.source,
|
|
725
850
|
...(row.hold_group_id ? { groupId: row.hold_group_id } : {}),
|
|
851
|
+
...(row.payment_required
|
|
852
|
+
? {
|
|
853
|
+
paymentRequired: true,
|
|
854
|
+
paymentRef: row.payment_ref ?? null,
|
|
855
|
+
paidAt: row.paid_at ?? null,
|
|
856
|
+
paymentAmount: amountOf(row.payment_amount_minor, row.payment_currency),
|
|
857
|
+
paidAmount: amountOf(row.paid_amount_minor, row.paid_currency),
|
|
858
|
+
}
|
|
859
|
+
: {}),
|
|
726
860
|
};
|
|
727
861
|
}
|
|
862
|
+
/** The longest a hold that waits for payment may live (RFC 0004 C3): 30 minutes. */
|
|
863
|
+
export const SLOTLOCK_PAYMENT_HOLD_MAX_TTL_MS = 30 * 60 * 1000;
|
|
864
|
+
/** An amount in a currency's minor unit (cents), and its ISO 4217 code: what a hold's payment is. */
|
|
865
|
+
function isValidAmount(value) {
|
|
866
|
+
const amount = value;
|
|
867
|
+
return (typeof amount === 'object' &&
|
|
868
|
+
amount !== null &&
|
|
869
|
+
Number.isSafeInteger(amount.amountMinor) &&
|
|
870
|
+
amount.amountMinor >= 0 &&
|
|
871
|
+
amount.amountMinor <= 1_000_000_000_000 &&
|
|
872
|
+
typeof amount.currency === 'string' &&
|
|
873
|
+
/^[A-Z]{3}$/.test(amount.currency));
|
|
874
|
+
}
|
|
875
|
+
/** A payment's reference as the integrator's processor names it: 1-255 printable bytes. */
|
|
876
|
+
function isValidPaymentRef(value) {
|
|
877
|
+
return (typeof value === 'string' &&
|
|
878
|
+
/^[\x21-\x7e]{1,255}$/.test(value));
|
|
879
|
+
}
|
|
728
880
|
function pageCursor(after) {
|
|
729
881
|
if (after !== undefined &&
|
|
730
882
|
(!(after.start instanceof Date) ||
|
|
@@ -748,6 +900,169 @@ function pageLimit(limit) {
|
|
|
748
900
|
function sandboxTestOnlyError() {
|
|
749
901
|
return Object.assign(new Error('Slotlock does this only in a test environment (a tenant ending ~test)'), { code: 'sandbox_test_only' });
|
|
750
902
|
}
|
|
903
|
+
/** Columns an export leaves out of a table it holds: secrets, and leases only a server reads. */
|
|
904
|
+
const TENANT_EXPORT_LEFT_OUT_COLUMNS = {
|
|
905
|
+
webhook_endpoints: ['secret', 'lease_token', 'lease_until'],
|
|
906
|
+
assistant_settings: ['sealed_key'],
|
|
907
|
+
};
|
|
908
|
+
/**
|
|
909
|
+
* Every table an export holds, as one statement (so one snapshot): each row as its table's name
|
|
910
|
+
* and a JSON line, without the tenant. `$1` is the tenant. A hold keeps no tenant of its own: its
|
|
911
|
+
* resource's is its. API keys come from slotlock.list_api_keys, which holds no digest.
|
|
912
|
+
*/
|
|
913
|
+
const TENANT_EXPORT_SQL = SLOTLOCK_TENANT_EXPORT_TABLES.map((table) => {
|
|
914
|
+
if (table === 'api_keys') {
|
|
915
|
+
return `SELECT 'api_keys'::text AS table_name, (pg_catalog.to_jsonb(kept) - 'key_tenant_ref')::text AS line
|
|
916
|
+
FROM slotlock.list_api_keys($1) kept`;
|
|
917
|
+
}
|
|
918
|
+
const dropped = ['tenant_ref', ...(TENANT_EXPORT_LEFT_OUT_COLUMNS[table] ?? [])].map((column) => ` - '${column}'`).join('');
|
|
919
|
+
const where = table === 'reservations'
|
|
920
|
+
? `kept.tenant_ref = $1 OR (kept.tenant_ref IS NULL AND kept.resource_id IN (
|
|
921
|
+
SELECT owned.id FROM slotlock.resources owned WHERE owned.tenant_ref = $1))`
|
|
922
|
+
: 'kept.tenant_ref = $1';
|
|
923
|
+
return `SELECT '${table}'::text AS table_name, (pg_catalog.to_jsonb(kept)${dropped})::text AS line
|
|
924
|
+
FROM slotlock.${table} kept WHERE ${where}`;
|
|
925
|
+
}).join('\nUNION ALL\n');
|
|
926
|
+
/**
|
|
927
|
+
* Read everything the tenant keeps through one cursor (one snapshot), handing each part (one
|
|
928
|
+
* table's rows, up to SLOTLOCK_TENANT_EXPORT_PART_ROWS or about a megabyte) to `onPart`.
|
|
929
|
+
*/
|
|
930
|
+
async function readTenantExport(tx, tenantRef, onPart) {
|
|
931
|
+
const totals = { rows: 0, bytes: 0, tables: {} };
|
|
932
|
+
let current = null;
|
|
933
|
+
const flush = async () => {
|
|
934
|
+
if (current && current.lines.length > 0)
|
|
935
|
+
await onPart(current);
|
|
936
|
+
current = null;
|
|
937
|
+
};
|
|
938
|
+
await tx.unsafe(`DECLARE slotlock_tenant_export NO SCROLL CURSOR FOR\n${TENANT_EXPORT_SQL}`, [tenantRef]);
|
|
939
|
+
for (;;) {
|
|
940
|
+
const rows = await tx.unsafe('FETCH 1000 FROM slotlock_tenant_export');
|
|
941
|
+
if (rows.length === 0)
|
|
942
|
+
break;
|
|
943
|
+
for (const row of rows) {
|
|
944
|
+
const open = current;
|
|
945
|
+
if (open &&
|
|
946
|
+
(open.table !== row.table_name ||
|
|
947
|
+
open.lines.length >= SLOTLOCK_TENANT_EXPORT_PART_ROWS ||
|
|
948
|
+
open.bytes >= TENANT_EXPORT_PART_BYTES)) {
|
|
949
|
+
await flush();
|
|
950
|
+
}
|
|
951
|
+
current ??= { table: row.table_name, lines: [], bytes: 0 };
|
|
952
|
+
const bytes = Buffer.byteLength(row.line) + 1;
|
|
953
|
+
current.lines.push(row.line);
|
|
954
|
+
current.bytes += bytes;
|
|
955
|
+
totals.rows += 1;
|
|
956
|
+
totals.bytes += bytes;
|
|
957
|
+
totals.tables[row.table_name] = (totals.tables[row.table_name] ?? 0) + 1;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
await flush();
|
|
961
|
+
await tx.unsafe('CLOSE slotlock_tenant_export');
|
|
962
|
+
return totals;
|
|
963
|
+
}
|
|
964
|
+
function toTenantExport(row) {
|
|
965
|
+
return {
|
|
966
|
+
id: row.id,
|
|
967
|
+
status: row.status,
|
|
968
|
+
requestedBy: row.requested_by,
|
|
969
|
+
createdAt: row.created_at,
|
|
970
|
+
finishedAt: row.finished_at,
|
|
971
|
+
expiresAt: row.expires_at,
|
|
972
|
+
parts: row.parts,
|
|
973
|
+
rows: Number(row.row_count),
|
|
974
|
+
bytes: Number(row.bytes),
|
|
975
|
+
tables: row.tables,
|
|
976
|
+
error: row.error,
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
function tenantExportRefusal(code) {
|
|
980
|
+
const message = {
|
|
981
|
+
export_in_progress: 'Slotlock builds one export of a tenant at a time: wait for the one asked for',
|
|
982
|
+
export_limit_reached: `Slotlock keeps at most ${SLOTLOCK_MAX_TENANT_EXPORTS} exports of a tenant: delete one first`,
|
|
983
|
+
export_too_large: `Slotlock exports at most ${SLOTLOCK_TENANT_EXPORT_MAX_BYTES / 1024 / 1024} MB through the API: its operator runs slotlock tenant export`,
|
|
984
|
+
}[code];
|
|
985
|
+
return Object.assign(new Error(message), { code });
|
|
986
|
+
}
|
|
987
|
+
/** A subject request's email: one address, compared without case. */
|
|
988
|
+
const SUBJECT_EMAIL = /^[^\s@]+@[^\s@]+$/;
|
|
989
|
+
/** Whom a subject request names, checked; null when it names none, more than one, or a bad one. */
|
|
990
|
+
function subjectKey(subject) {
|
|
991
|
+
if (typeof subject !== 'object' || subject === null)
|
|
992
|
+
return null;
|
|
993
|
+
const named = Object.entries(subject).filter(([, value]) => value !== undefined);
|
|
994
|
+
if (named.length !== 1)
|
|
995
|
+
return null;
|
|
996
|
+
const [kind, raw] = named[0];
|
|
997
|
+
if (typeof raw !== 'string')
|
|
998
|
+
return null;
|
|
999
|
+
if (kind === 'email') {
|
|
1000
|
+
const value = raw.trim().toLowerCase();
|
|
1001
|
+
const bytes = Buffer.byteLength(value);
|
|
1002
|
+
return bytes >= 3 && bytes <= 320 && SUBJECT_EMAIL.test(value) ? { kind, value } : null;
|
|
1003
|
+
}
|
|
1004
|
+
if (kind === 'contact')
|
|
1005
|
+
return isValidContactRef(raw) ? { kind, value: raw } : null;
|
|
1006
|
+
if (kind === 'holder' || kind === 'reference')
|
|
1007
|
+
return isValidIdentity(raw) ? { kind, value: raw } : null;
|
|
1008
|
+
return null;
|
|
1009
|
+
}
|
|
1010
|
+
function invalidSubjectError() {
|
|
1011
|
+
return Object.assign(new Error('Slotlock names a person by exactly one of email, holder, contact or reference'), { code: 'invalid_subject' });
|
|
1012
|
+
}
|
|
1013
|
+
function emptySubjectCounts() {
|
|
1014
|
+
return Object.fromEntries(SLOTLOCK_SUBJECT_TABLES.map((table) => [table, 0]));
|
|
1015
|
+
}
|
|
1016
|
+
/** The request fields an approval may keep a booking's or an event's reference in. */
|
|
1017
|
+
const SUBJECT_REFERENCE_KEYS = ['reference', 'reservation_ref', 'external_ref', 'event_ref'];
|
|
1018
|
+
/** The approvals whose request names the subject (SQL, on `approval`). */
|
|
1019
|
+
function subjectApprovalSql(tx, key, contactRefs) {
|
|
1020
|
+
const value = key.value;
|
|
1021
|
+
switch (key.kind) {
|
|
1022
|
+
case 'email':
|
|
1023
|
+
return tx `(lower(approval.request -> 'organizer' ->> 'email') = ${value}
|
|
1024
|
+
OR (jsonb_typeof(approval.request -> 'attendees') = 'array'
|
|
1025
|
+
AND EXISTS (SELECT 1 FROM jsonb_array_elements(approval.request -> 'attendees') AS attendee(value)
|
|
1026
|
+
WHERE lower(attendee.value ->> 'email') = ${value}))
|
|
1027
|
+
OR approval.request ->> 'contact_ref' = ANY(${contactRefs}::text[]))`;
|
|
1028
|
+
case 'holder':
|
|
1029
|
+
return tx `approval.request ->> 'holder_ref' = ${value}`;
|
|
1030
|
+
case 'contact':
|
|
1031
|
+
return tx `approval.request ->> 'contact_ref' = ${value}`;
|
|
1032
|
+
case 'reference':
|
|
1033
|
+
return tx `EXISTS (SELECT 1 FROM unnest(${[...SUBJECT_REFERENCE_KEYS]}::text[]) AS field(name)
|
|
1034
|
+
WHERE approval.request ->> field.name = ${value})`;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
/** An approval's request with the subject taken out (SQL, on `approval`). */
|
|
1038
|
+
function subjectScrubbedRequestSql(tx, key, contactRefs, replacement) {
|
|
1039
|
+
const value = key.value;
|
|
1040
|
+
switch (key.kind) {
|
|
1041
|
+
case 'email':
|
|
1042
|
+
return tx `(
|
|
1043
|
+
SELECT CASE WHEN lower(kept.request -> 'organizer' ->> 'email') = ${value} THEN kept.request - 'organizer'
|
|
1044
|
+
ELSE kept.request END
|
|
1045
|
+
FROM (SELECT CASE
|
|
1046
|
+
WHEN jsonb_typeof(scrubbed.request -> 'attendees') = 'array'
|
|
1047
|
+
THEN jsonb_set(scrubbed.request, '{attendees}', coalesce((
|
|
1048
|
+
SELECT jsonb_agg(attendee.value ORDER BY attendee.ordinality)
|
|
1049
|
+
FROM jsonb_array_elements(scrubbed.request -> 'attendees')
|
|
1050
|
+
WITH ORDINALITY AS attendee(value, ordinality)
|
|
1051
|
+
WHERE lower(attendee.value ->> 'email') IS DISTINCT FROM ${value}), '[]'::jsonb))
|
|
1052
|
+
ELSE scrubbed.request END AS request
|
|
1053
|
+
FROM (SELECT CASE WHEN approval.request ->> 'contact_ref' = ANY(${contactRefs}::text[])
|
|
1054
|
+
THEN approval.request - 'contact_ref' ELSE approval.request END AS request) scrubbed) kept)`;
|
|
1055
|
+
case 'holder':
|
|
1056
|
+
return tx `jsonb_set(approval.request, '{holder_ref}', to_jsonb(${replacement}::text))`;
|
|
1057
|
+
case 'contact':
|
|
1058
|
+
return tx `approval.request - 'contact_ref'`;
|
|
1059
|
+
case 'reference':
|
|
1060
|
+
return tx `(
|
|
1061
|
+
SELECT approval.request || coalesce(jsonb_object_agg(field.name, to_jsonb(${replacement}::text)), '{}'::jsonb)
|
|
1062
|
+
FROM unnest(${[...SUBJECT_REFERENCE_KEYS]}::text[]) AS field(name)
|
|
1063
|
+
WHERE approval.request ->> field.name = ${value})`;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
751
1066
|
function invalidIdentityError(message) {
|
|
752
1067
|
return Object.assign(new Error(message), { code: 'invalid_identity' });
|
|
753
1068
|
}
|
|
@@ -1072,6 +1387,7 @@ function putCommandValue(params) {
|
|
|
1072
1387
|
source: params.command.source ?? null,
|
|
1073
1388
|
// Only when present, so a create without a hold keeps its hash.
|
|
1074
1389
|
...(params.command.fromHold ? { fromHold: params.command.fromHold.holdId } : {}),
|
|
1390
|
+
...(params.command.notify === false ? { notify: false } : {}),
|
|
1075
1391
|
event: {
|
|
1076
1392
|
...params.event,
|
|
1077
1393
|
start: params.event.start.toISOString(),
|
|
@@ -1191,8 +1507,255 @@ async function bookingRuling(tx, resourceId, interval, defaultHours) {
|
|
|
1191
1507
|
rule: judge.broken(interval),
|
|
1192
1508
|
turnaroundMs: judge.turnaroundMs,
|
|
1193
1509
|
bufferBeforeMs: judge.bufferBeforeMs,
|
|
1510
|
+
holdPrice: judge.holdPrice,
|
|
1194
1511
|
};
|
|
1195
1512
|
}
|
|
1513
|
+
/**
|
|
1514
|
+
* The wall time a reservation says it starts at (RFC 0004 B9), when it names `start` in the
|
|
1515
|
+
* resource's zone; `undefined` when none was given; null when it is not one or names another
|
|
1516
|
+
* instant.
|
|
1517
|
+
*/
|
|
1518
|
+
async function localStartFor(tx, resourceId, localStart, start) {
|
|
1519
|
+
if (localStart === undefined)
|
|
1520
|
+
return undefined;
|
|
1521
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(localStart);
|
|
1522
|
+
if (!match)
|
|
1523
|
+
return null;
|
|
1524
|
+
const [row] = await tx `SELECT timezone FROM slotlock.resources WHERE id = ${resourceId}`;
|
|
1525
|
+
if (!row)
|
|
1526
|
+
return null;
|
|
1527
|
+
try {
|
|
1528
|
+
const [, year, month, day, hour, minute, second] = match.map((part) => (part === undefined ? 0 : Number(part)));
|
|
1529
|
+
const named = zonedDateTimeToUtc({ year: year, month: month, day: day, hour: hour, minute: minute, second: second }, row.timezone);
|
|
1530
|
+
return named.getTime() === start.getTime() ? `${localStart.length === 16 ? `${localStart}:00` : localStart}` : null;
|
|
1531
|
+
}
|
|
1532
|
+
catch {
|
|
1533
|
+
return null;
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* Whether a source's reported busy time (RFC 0004 C1) meets [start, end) on the resource: read
|
|
1538
|
+
* under the resource's write lock, which a source's replacement takes too.
|
|
1539
|
+
*/
|
|
1540
|
+
async function sourceBusyLoss(tx, resourceId, start, end) {
|
|
1541
|
+
const [hit] = await tx `
|
|
1542
|
+
SELECT 1 FROM slotlock.source_busy
|
|
1543
|
+
WHERE resource_id IN (${compositeKin(tx, resourceId)}) AND starts_at < ${end} AND ends_at > ${start}
|
|
1544
|
+
LIMIT 1`;
|
|
1545
|
+
return hit !== undefined;
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* The resources whose busy time keeps this one busy (RFC 0003 G8, RFC 0004 C1), as a subquery: it,
|
|
1549
|
+
* its composite if it is a part (the whole is in use), and its parts if it is a composite (a part
|
|
1550
|
+
* in use takes the whole). Never its sibling parts, which are booked apart.
|
|
1551
|
+
*/
|
|
1552
|
+
function compositeKin(executor, resourceId) {
|
|
1553
|
+
return executor `
|
|
1554
|
+
SELECT ${resourceId}::uuid
|
|
1555
|
+
UNION ALL
|
|
1556
|
+
SELECT parent_id FROM slotlock.resources WHERE id = ${resourceId}::uuid AND parent_id IS NOT NULL
|
|
1557
|
+
UNION ALL
|
|
1558
|
+
SELECT id FROM slotlock.resources WHERE parent_id = ${resourceId}::uuid`;
|
|
1559
|
+
}
|
|
1560
|
+
/**
|
|
1561
|
+
* A source's busy time as kept: each interval valid, clipped to the window, sorted and merged
|
|
1562
|
+
* (touching ones too). Null when any is not an interval, or there are too many.
|
|
1563
|
+
*/
|
|
1564
|
+
function mergeSourceBusy(busy, window) {
|
|
1565
|
+
if (!Array.isArray(busy) || busy.length > SLOTLOCK_SOURCE_BUSY_MAX)
|
|
1566
|
+
return null;
|
|
1567
|
+
const clipped = [];
|
|
1568
|
+
for (const interval of busy) {
|
|
1569
|
+
const start = interval?.start;
|
|
1570
|
+
const end = interval?.end;
|
|
1571
|
+
if (!(start instanceof Date) || !(end instanceof Date))
|
|
1572
|
+
return null;
|
|
1573
|
+
if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime()) || end <= start)
|
|
1574
|
+
return null;
|
|
1575
|
+
const from = Math.max(start.getTime(), window.start.getTime());
|
|
1576
|
+
const to = Math.min(end.getTime(), window.end.getTime());
|
|
1577
|
+
if (to > from)
|
|
1578
|
+
clipped.push({ start: new Date(from), end: new Date(to) });
|
|
1579
|
+
}
|
|
1580
|
+
return mergeIntervals(clipped);
|
|
1581
|
+
}
|
|
1582
|
+
/** An entry's offer: the hold made for it, said on the entry and the feed. */
|
|
1583
|
+
async function recordWaitlistOffer(tx, tenantRef, entry, offer) {
|
|
1584
|
+
await tx `
|
|
1585
|
+
UPDATE slotlock.waitlist_entries
|
|
1586
|
+
SET status = 'offered', hold_id = ${offer.holdId}, offered_resource_id = ${offer.resourceId},
|
|
1587
|
+
offered_at = statement_timestamp()
|
|
1588
|
+
WHERE id = ${entry.id}`;
|
|
1589
|
+
await tx `
|
|
1590
|
+
INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
|
|
1591
|
+
VALUES (${tenantRef}, ${offer.resourceId}, 'waitlist.offered', ${entry.id},
|
|
1592
|
+
jsonb_strip_nulls(jsonb_build_object(
|
|
1593
|
+
'id', ${entry.id}::uuid, 'hold_id', ${offer.holdId}::uuid,
|
|
1594
|
+
'resource_id', ${offer.resourceId}::uuid, 'pool', ${entry.pool_ref}::text,
|
|
1595
|
+
'start', ${offer.start}::timestamptz, 'end', ${offer.end}::timestamptz,
|
|
1596
|
+
'expires_at', ${offer.expiresAt}::timestamptz,
|
|
1597
|
+
'holder_ref', ${entry.holder_ref}::text)),
|
|
1598
|
+
'slotlock:maintenance')`;
|
|
1599
|
+
}
|
|
1600
|
+
/**
|
|
1601
|
+
* The earliest slot of the entry's length within its window, from now, on its resource or any
|
|
1602
|
+
* member of its pool (by id), held for its holder: what is busy is read with each resource's
|
|
1603
|
+
* buffers, and the hold itself still meets the booking rules and the constraint. Null when none.
|
|
1604
|
+
*/
|
|
1605
|
+
async function offerSlotOfLength(tx, inner, tenantRef, entry) {
|
|
1606
|
+
const durationMs = Number(entry.duration_ms);
|
|
1607
|
+
const candidates = await tx `
|
|
1608
|
+
SELECT resources.id,
|
|
1609
|
+
COALESCE(least(greatest((effective.effective_booking_rules->>'bufferBeforeMinutes')::numeric, 0), 1440)::int, 0) * 60000
|
|
1610
|
+
AS before_ms,
|
|
1611
|
+
COALESCE(least(greatest((effective.effective_booking_rules->>'turnaroundMinutes')::numeric, 0), 10080)::int, 0) * 60000
|
|
1612
|
+
AS after_ms,
|
|
1613
|
+
statement_timestamp() AS now
|
|
1614
|
+
FROM slotlock.resources, LATERAL (SELECT ${tx.unsafe(EFFECTIVE_BOOKING_RULES)}) effective
|
|
1615
|
+
WHERE resources.tenant_ref = ${tenantRef} AND resources.archived_at IS NULL
|
|
1616
|
+
AND (resources.id = ${entry.resource_id}::uuid
|
|
1617
|
+
OR (${entry.resource_id}::uuid IS NULL AND resources.pool_ref = ${entry.pool_ref}))
|
|
1618
|
+
ORDER BY resources.id
|
|
1619
|
+
LIMIT ${OFFER_CANDIDATES}`;
|
|
1620
|
+
for (const candidate of candidates) {
|
|
1621
|
+
const from = new Date(Math.ceil(Math.max(entry.starts_at.getTime(), candidate.now.getTime()) / 60_000) * 60_000);
|
|
1622
|
+
if (from.getTime() + durationMs > entry.ends_at.getTime())
|
|
1623
|
+
continue;
|
|
1624
|
+
const reach = {
|
|
1625
|
+
start: new Date(from.getTime() - Number(candidate.before_ms) - 86_400_000),
|
|
1626
|
+
end: new Date(entry.ends_at.getTime() + Number(candidate.after_ms) + 86_400_000),
|
|
1627
|
+
};
|
|
1628
|
+
// Bookings and holds, a composite's parent's or parts' claims, and calendars' busy time.
|
|
1629
|
+
const busy = await tx `
|
|
1630
|
+
SELECT occupied_starts_at AS start, occupied_ends_at AS end FROM (
|
|
1631
|
+
SELECT COALESCE(occupied_starts_at, starts_at) AS occupied_starts_at,
|
|
1632
|
+
COALESCE(occupied_ends_at, ends_at) AS occupied_ends_at
|
|
1633
|
+
FROM slotlock.reservations
|
|
1634
|
+
WHERE resource_id = ${candidate.id}
|
|
1635
|
+
AND tstzrange(COALESCE(occupied_starts_at, starts_at), COALESCE(occupied_ends_at, ends_at), '[)')
|
|
1636
|
+
&& tstzrange(${reach.start}::timestamptz, ${reach.end}::timestamptz, '[)')
|
|
1637
|
+
AND (status <> 'held' OR expires_at > statement_timestamp())
|
|
1638
|
+
UNION ALL
|
|
1639
|
+
${compositeBusy(tx, candidate.id, reach)}
|
|
1640
|
+
UNION ALL
|
|
1641
|
+
${sourceBusy(tx, candidate.id, reach)}
|
|
1642
|
+
) busy`;
|
|
1643
|
+
// A slot [s, s + d) with its buffers occupies [s - before, s + d + after).
|
|
1644
|
+
const blocked = busy.map((taken) => ({
|
|
1645
|
+
start: new Date(taken.start.getTime() - Number(candidate.after_ms)),
|
|
1646
|
+
end: new Date(taken.end.getTime() + Number(candidate.before_ms)),
|
|
1647
|
+
}));
|
|
1648
|
+
const slot = findNextAvailable({
|
|
1649
|
+
busy: blocked,
|
|
1650
|
+
windows: [{ start: from, end: entry.ends_at }],
|
|
1651
|
+
durationMs,
|
|
1652
|
+
align: (start) => new Date(Math.ceil(start.getTime() / 60_000) * 60_000),
|
|
1653
|
+
});
|
|
1654
|
+
if (!slot)
|
|
1655
|
+
continue;
|
|
1656
|
+
const hold = await inner.acquireHold({
|
|
1657
|
+
resourceId: candidate.id,
|
|
1658
|
+
start: slot.start,
|
|
1659
|
+
end: slot.end,
|
|
1660
|
+
ttlMs: Number(entry.offer_ttl_ms),
|
|
1661
|
+
tenantRef,
|
|
1662
|
+
holderRef: entry.holder_ref,
|
|
1663
|
+
source: 'slotlock:waitlist',
|
|
1664
|
+
});
|
|
1665
|
+
if (!hold.ok)
|
|
1666
|
+
continue;
|
|
1667
|
+
return { resourceId: candidate.id, holdId: hold.hold.id, start: slot.start, end: slot.end, expiresAt: hold.hold.expiresAt };
|
|
1668
|
+
}
|
|
1669
|
+
return null;
|
|
1670
|
+
}
|
|
1671
|
+
/** An admin's reason for going past a cutoff (RFC 0004 B5): 1-500 characters, no control characters. */
|
|
1672
|
+
function isValidOverrideReason(value) {
|
|
1673
|
+
return typeof value === 'string' && /^[^\u0000-\u001f\u007f]{1,500}$/u.test(value) && value.trim() !== '';
|
|
1674
|
+
}
|
|
1675
|
+
/**
|
|
1676
|
+
* A resource's effective booking rules and the database clock: what a cancellation or a move is
|
|
1677
|
+
* judged by (RFC 0004 B5). Rules that do not read refuse nothing here.
|
|
1678
|
+
*/
|
|
1679
|
+
async function rulesNow(tx, resourceId) {
|
|
1680
|
+
const [row] = await tx `
|
|
1681
|
+
SELECT ${tx.unsafe(EFFECTIVE_BOOKING_RULES)}, statement_timestamp() AS now
|
|
1682
|
+
FROM slotlock.resources WHERE id = ${resourceId}`;
|
|
1683
|
+
if (!row)
|
|
1684
|
+
return { rules: undefined, now: new Date() };
|
|
1685
|
+
if (row.effective_booking_rules === null || row.effective_booking_rules === undefined)
|
|
1686
|
+
return { rules: undefined, now: row.now };
|
|
1687
|
+
return { rules: parseEffectiveBookingRules(row.effective_booking_rules).rules, now: row.now };
|
|
1688
|
+
}
|
|
1689
|
+
/**
|
|
1690
|
+
* Mark what the next change of a booking records on the feed (RFC 0004 B5): a cancellation's fee
|
|
1691
|
+
* and an admin's reason for going past a cutoff. Cleared with nulls.
|
|
1692
|
+
*/
|
|
1693
|
+
/**
|
|
1694
|
+
* Whether a paid hold ended without a booking (RFC 0004 C3), so its payment was never applied:
|
|
1695
|
+
* not booked (confirmed, or booked from), not still held, and not taken over by an approval that
|
|
1696
|
+
* was granted or may still be.
|
|
1697
|
+
*/
|
|
1698
|
+
async function paidHoldEnded(tx, tenantRef, payment) {
|
|
1699
|
+
if (payment.booked_at)
|
|
1700
|
+
return false;
|
|
1701
|
+
const [hold] = await tx `
|
|
1702
|
+
SELECT status, expires_at > statement_timestamp() AS live FROM slotlock.reservations WHERE id = ${payment.id}`;
|
|
1703
|
+
if (hold)
|
|
1704
|
+
return hold.status === 'held' && !hold.live;
|
|
1705
|
+
if (payment.approval_id) {
|
|
1706
|
+
const [approval] = await tx `
|
|
1707
|
+
SELECT status, expires_at > statement_timestamp() AS live FROM slotlock.approvals
|
|
1708
|
+
WHERE id = ${payment.approval_id}::uuid AND tenant_ref = ${tenantRef}`;
|
|
1709
|
+
if (approval?.status === 'granted')
|
|
1710
|
+
return false;
|
|
1711
|
+
if (approval?.status === 'pending' && approval.live)
|
|
1712
|
+
return false;
|
|
1713
|
+
}
|
|
1714
|
+
return true;
|
|
1715
|
+
}
|
|
1716
|
+
/**
|
|
1717
|
+
* Whether cancelling a booking that starts at `start` would be late now (RFC 0004 B5): past its
|
|
1718
|
+
* cancel cutoff, or inside the window its cancellation fee is due in. A move made then keeps that
|
|
1719
|
+
* start as the booking's cutoff anchor, so a move cannot take it out of what a cancellation owes.
|
|
1720
|
+
*/
|
|
1721
|
+
function cancellationLate(rules, start, now) {
|
|
1722
|
+
return (cutoffRuleBroken(rules, 'cancel', start, now) !== null ||
|
|
1723
|
+
(rules?.cancellationFee?.withinMinutes !== undefined && cancellationFeeDue(rules, start, now) !== null));
|
|
1724
|
+
}
|
|
1725
|
+
/** The start a cancellation is judged from: the cutoff anchor a late move kept, if earlier. */
|
|
1726
|
+
function cancellationStart(start, anchor) {
|
|
1727
|
+
return anchor && anchor.getTime() < start.getTime() ? anchor : start;
|
|
1728
|
+
}
|
|
1729
|
+
async function noteEnding(tx, fee, overrideReason) {
|
|
1730
|
+
await tx `
|
|
1731
|
+
SELECT set_config('slotlock.cancellation_fee', ${fee ? JSON.stringify(fee) : ''}, true),
|
|
1732
|
+
set_config('slotlock.override_reason', ${overrideReason ?? ''}, true)`;
|
|
1733
|
+
}
|
|
1734
|
+
/**
|
|
1735
|
+
* Whether a one-off event may be cancelled now under its resource's cancel cutoff (RFC 0004 B5),
|
|
1736
|
+
* and the fee a late cancellation records, marked for the feed. A series is not held to the cutoff:
|
|
1737
|
+
* cancelling it cancels what is still to come.
|
|
1738
|
+
*/
|
|
1739
|
+
async function eventEnding(tx, event, enforce, overrideReason) {
|
|
1740
|
+
if (event.recurring)
|
|
1741
|
+
return { ok: true, fee: null };
|
|
1742
|
+
const judged = await rulesNow(tx, event.resource_id);
|
|
1743
|
+
// From the start it had when a late move kept it, if one did.
|
|
1744
|
+
const start = cancellationStart(event.starts_at, event.cutoff_anchor);
|
|
1745
|
+
let note = null;
|
|
1746
|
+
if (enforce && cutoffRuleBroken(judged.rules, 'cancel', start, judged.now)) {
|
|
1747
|
+
if (overrideReason === undefined) {
|
|
1748
|
+
return { ok: false, refusal: { ok: false, code: 'booking_rule_violated', rule: 'cancel_cutoff' } };
|
|
1749
|
+
}
|
|
1750
|
+
note = overrideReason;
|
|
1751
|
+
}
|
|
1752
|
+
const due = cancellationFeeDue(judged.rules, start, judged.now);
|
|
1753
|
+
const fee = due ? { amountMinor: due.amountMinor, currency: due.currency } : null;
|
|
1754
|
+
await noteEnding(tx, fee ? { amount_minor: fee.amountMinor, currency: fee.currency } : null, note);
|
|
1755
|
+
return { ok: true, fee };
|
|
1756
|
+
}
|
|
1757
|
+
/** How long busy time a calendar reported is kept after it ended: the feed's retention. */
|
|
1758
|
+
const SOURCE_BUSY_RETENTION_DAYS = 30;
|
|
1196
1759
|
/** How recently a sync must have read a resource whose booking rules require certainty. */
|
|
1197
1760
|
const CERTAIN_COVERAGE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
1198
1761
|
/**
|
|
@@ -1201,25 +1764,35 @@ const CERTAIN_COVERAGE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
|
1201
1764
|
*/
|
|
1202
1765
|
async function bookingJudge(tx, resourceId, defaultHours) {
|
|
1203
1766
|
const [row] = await tx `
|
|
1204
|
-
SELECT ${tx.unsafe(EFFECTIVE_BOOKING_RULES)}, availability_rules,
|
|
1205
|
-
|
|
1767
|
+
SELECT ${tx.unsafe(EFFECTIVE_BOOKING_RULES)}, availability_rules,
|
|
1768
|
+
CASE WHEN resources.availability_rules IS NULL
|
|
1769
|
+
THEN slotlock.inherited_availability(resources.tenant_ref, resources.location_ref) END
|
|
1770
|
+
AS inherited_availability,
|
|
1771
|
+
timezone, statement_timestamp() AS now,
|
|
1772
|
+
to_char((statement_timestamp() AT TIME ZONE 'UTC')::date - 2, 'YYYY-MM-DD') AS today
|
|
1206
1773
|
FROM slotlock.resources WHERE id = ${resourceId}`;
|
|
1207
1774
|
if (!row || row.effective_booking_rules === null || row.effective_booking_rules === undefined) {
|
|
1208
|
-
return { broken: () => null, turnaroundMs: 0, bufferBeforeMs: 0 };
|
|
1775
|
+
return { broken: () => null, turnaroundMs: 0, bufferBeforeMs: 0, holdPrice: undefined };
|
|
1209
1776
|
}
|
|
1210
|
-
const
|
|
1777
|
+
const resource = toResource({
|
|
1211
1778
|
id: resourceId,
|
|
1212
1779
|
external_ref: null,
|
|
1213
1780
|
tenant_ref: null,
|
|
1214
1781
|
timezone: row.timezone,
|
|
1215
1782
|
availability_rules: row.availability_rules,
|
|
1783
|
+
inherited_availability: row.inherited_availability,
|
|
1216
1784
|
booking_rules: null,
|
|
1217
1785
|
pool_ref: null,
|
|
1218
1786
|
visibility: 'shared',
|
|
1219
1787
|
archived_at: null,
|
|
1220
1788
|
effective_booking_rules: row.effective_booking_rules,
|
|
1221
1789
|
});
|
|
1222
|
-
const
|
|
1790
|
+
const rules = resource.effectiveBookingRules;
|
|
1791
|
+
const hours = applicableHours(resource, defaultHours).rules;
|
|
1792
|
+
// Enforced hours read the exceptions from two days back (a zone is at most fourteen hours from UTC).
|
|
1793
|
+
const exceptions = rules?.enforceHours === true
|
|
1794
|
+
? ((await rankedExceptions(tx, [resourceId], { from: row.today })).get(resourceId) ?? [])
|
|
1795
|
+
: [];
|
|
1223
1796
|
const coverage = rules?.requireCertain === true
|
|
1224
1797
|
? await tx `
|
|
1225
1798
|
SELECT starts_at, ends_at,
|
|
@@ -1246,11 +1819,13 @@ async function bookingJudge(tx, resourceId, defaultHours) {
|
|
|
1246
1819
|
rules: checks.first ? rules : later,
|
|
1247
1820
|
timezone: row.timezone,
|
|
1248
1821
|
hours,
|
|
1822
|
+
exceptions,
|
|
1249
1823
|
interval,
|
|
1250
1824
|
now: row.now,
|
|
1251
1825
|
}) ?? (uncertain(interval) ? 'uncertain' : null),
|
|
1252
1826
|
turnaroundMs,
|
|
1253
1827
|
bufferBeforeMs,
|
|
1828
|
+
holdPrice: rules?.holdPrice,
|
|
1254
1829
|
};
|
|
1255
1830
|
}
|
|
1256
1831
|
function sameExternalPayload(row, params, turnaroundMs = 0) {
|
|
@@ -1262,6 +1837,29 @@ function sameExternalPayload(row, params, turnaroundMs = 0) {
|
|
|
1262
1837
|
Number(row.revision) === params.revision &&
|
|
1263
1838
|
row.source === (params.source ?? null));
|
|
1264
1839
|
}
|
|
1840
|
+
/**
|
|
1841
|
+
* What a holder holds in the tenant, live: its own holds, and those of approvals it asked for that
|
|
1842
|
+
* a person has yet to decide (RFC 0004 A4), which hold time for it just the same. Counted for its
|
|
1843
|
+
* caps under the holder's lock. `except` leaves out a hold about to give way to an approval.
|
|
1844
|
+
*/
|
|
1845
|
+
async function holderHeld(tx, tenantRef, holderRef, except) {
|
|
1846
|
+
const [held] = await tx `
|
|
1847
|
+
SELECT count(*)::int AS count,
|
|
1848
|
+
COALESCE(sum(extract(epoch FROM
|
|
1849
|
+
ends_at + (buffer_after_ms + buffer_before_ms) * interval '1 millisecond'
|
|
1850
|
+
- starts_at)), 0)::float8
|
|
1851
|
+
AS seconds
|
|
1852
|
+
FROM slotlock.reservations
|
|
1853
|
+
WHERE tenant_ref IS NULL AND status = 'held'
|
|
1854
|
+
AND expires_at > statement_timestamp()
|
|
1855
|
+
AND resource_id IN (SELECT id FROM slotlock.resources WHERE tenant_ref = ${tenantRef})
|
|
1856
|
+
AND (${except ?? null}::uuid IS NULL OR id <> ${except ?? null}::uuid)
|
|
1857
|
+
AND (holder_ref = ${holderRef}
|
|
1858
|
+
OR holder_ref IN (SELECT 'approval:' || approval.id FROM slotlock.approvals approval
|
|
1859
|
+
WHERE approval.tenant_ref = ${tenantRef} AND approval.status = 'pending'
|
|
1860
|
+
AND approval.requester ->> 'holder_ref' = ${holderRef}))`;
|
|
1861
|
+
return { count: held?.count ?? 0, seconds: held?.seconds ?? 0 };
|
|
1862
|
+
}
|
|
1265
1863
|
async function lockExternalIdentity(tx, tenantRef, externalRef) {
|
|
1266
1864
|
// JSON is unambiguous for this pair and has no NUL byte (Postgres text rejects U+0000).
|
|
1267
1865
|
await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([tenantRef, externalRef])}, 0))`;
|
|
@@ -1324,7 +1922,7 @@ function pgCode(err) {
|
|
|
1324
1922
|
export const SLOTLOCK_MAX_POOL_MEMBERS = 100;
|
|
1325
1923
|
/** The most pools of one tenant that may have default booking rules (`setBookingRuleDefaults`). */
|
|
1326
1924
|
export const SLOTLOCK_MAX_POOL_RULE_DEFAULTS = 1_000;
|
|
1327
|
-
const WAITLIST_COLUMNS = 'id, resource_id, pool_ref, starts_at, ends_at, holder_ref, offer_ttl_ms, status, hold_id, offered_resource_id, offered_at, created_at';
|
|
1925
|
+
const WAITLIST_COLUMNS = 'id, resource_id, pool_ref, starts_at, ends_at, holder_ref, offer_ttl_ms, status, hold_id, offered_resource_id, offered_at, created_at, duration_ms, decided_at';
|
|
1328
1926
|
function toWaitlistEntry(row) {
|
|
1329
1927
|
return {
|
|
1330
1928
|
id: row.id,
|
|
@@ -1339,6 +1937,8 @@ function toWaitlistEntry(row) {
|
|
|
1339
1937
|
offeredResourceId: row.offered_resource_id,
|
|
1340
1938
|
offeredAt: row.offered_at,
|
|
1341
1939
|
createdAt: row.created_at,
|
|
1940
|
+
durationMs: row.duration_ms === null ? null : Number(row.duration_ms),
|
|
1941
|
+
decidedAt: row.decided_at,
|
|
1342
1942
|
};
|
|
1343
1943
|
}
|
|
1344
1944
|
export function isPoolName(value) {
|
|
@@ -1353,6 +1953,9 @@ function invalidPoolError() {
|
|
|
1353
1953
|
const RESOURCE_COLUMNS = `id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
|
|
1354
1954
|
visibility, archived_at, name, description, kind, capacity, labels, attributes, location_ref,
|
|
1355
1955
|
parent_id, part_index,
|
|
1956
|
+
CASE WHEN resources.availability_rules IS NULL
|
|
1957
|
+
THEN slotlock.inherited_availability(resources.tenant_ref, resources.location_ref) END
|
|
1958
|
+
AS inherited_availability,
|
|
1356
1959
|
(SELECT place.name FROM slotlock.locations place
|
|
1357
1960
|
WHERE place.tenant_ref = resources.tenant_ref AND place.ref = resources.location_ref) AS location_name,
|
|
1358
1961
|
ARRAY(SELECT member.group_ref FROM slotlock.resource_group_members member
|
|
@@ -1432,6 +2035,11 @@ function toResource(row) {
|
|
|
1432
2035
|
resource.availabilityRules = [];
|
|
1433
2036
|
}
|
|
1434
2037
|
}
|
|
2038
|
+
if (resource.availabilityRules === undefined) {
|
|
2039
|
+
const inherited = storedInheritedAvailability(row.inherited_availability);
|
|
2040
|
+
if (inherited)
|
|
2041
|
+
resource.inheritedAvailability = inherited;
|
|
2042
|
+
}
|
|
1435
2043
|
if (row.booking_rules !== null && row.booking_rules !== undefined) {
|
|
1436
2044
|
// Written validated; anything else refuses every booking, never as no rule.
|
|
1437
2045
|
resource.bookingRules = storedBookingRules(row.booking_rules);
|
|
@@ -1640,6 +2248,24 @@ async function rollStoredSeries(tx, tenantRef, eventId, horizon) {
|
|
|
1640
2248
|
cursor: row.recurrence_cursor ?? null,
|
|
1641
2249
|
stored: true,
|
|
1642
2250
|
});
|
|
2251
|
+
// Busy time a calendar reported (RFC 0004 C1) is refused to a rolled occurrence as an overlap is:
|
|
2252
|
+
// nothing is written, the series is put off, and the roll counts it a conflict.
|
|
2253
|
+
if ((event.transparency ?? 'opaque') === 'opaque' && occurrences.length > 0) {
|
|
2254
|
+
const beforeMs = Number(row.buffer_before_ms ?? 0);
|
|
2255
|
+
const afterMs = Number(row.buffer_after_ms ?? 0);
|
|
2256
|
+
const [met] = await tx `
|
|
2257
|
+
SELECT 1
|
|
2258
|
+
FROM slotlock.source_busy busy
|
|
2259
|
+
JOIN ROWS FROM (
|
|
2260
|
+
pg_catalog.unnest(${occurrences.map((each) => new Date(each.start.getTime() - beforeMs).toISOString())}::text[]::timestamptz[]),
|
|
2261
|
+
pg_catalog.unnest(${occurrences.map((each) => new Date(each.end.getTime() + afterMs).toISOString())}::text[]::timestamptz[]))
|
|
2262
|
+
AS occurrence(starts_at, ends_at)
|
|
2263
|
+
ON busy.starts_at < occurrence.ends_at AND busy.ends_at > occurrence.starts_at
|
|
2264
|
+
WHERE busy.resource_id IN (${compositeKin(tx, row.resource_id)})
|
|
2265
|
+
LIMIT 1`;
|
|
2266
|
+
if (met)
|
|
2267
|
+
throw Object.assign(new Error('slotlock: a rolled occurrence meets reported busy time'), { code: EXCLUSION_VIOLATION });
|
|
2268
|
+
}
|
|
1643
2269
|
await replaceCalendarEventOccurrences(tx, {
|
|
1644
2270
|
tenantRef,
|
|
1645
2271
|
eventId: row.id,
|
|
@@ -1815,6 +2441,35 @@ async function rollSeriesBehind(executor, resourceId, start, end, exceptEventId)
|
|
|
1815
2441
|
// Rolled back whole (its own transaction, or a savepoint of the caller's).
|
|
1816
2442
|
}
|
|
1817
2443
|
}
|
|
2444
|
+
/** An audit event as the API and `slotlock audit export` give it: snake_case, nulls kept. */
|
|
2445
|
+
export function slotlockAuditEventRecord(event) {
|
|
2446
|
+
return {
|
|
2447
|
+
id: event.id,
|
|
2448
|
+
occurred_at: event.occurredAt.toISOString(),
|
|
2449
|
+
action: event.action,
|
|
2450
|
+
actor_type: event.actorType,
|
|
2451
|
+
actor_ref: event.actorRef,
|
|
2452
|
+
actor_role: event.actorRole ?? null,
|
|
2453
|
+
target_type: event.targetType ?? null,
|
|
2454
|
+
target_ref: event.targetRef ?? null,
|
|
2455
|
+
outcome: event.outcome,
|
|
2456
|
+
reason: event.reason ?? null,
|
|
2457
|
+
ip_address: event.ipAddress ?? null,
|
|
2458
|
+
user_agent: event.userAgent ?? null,
|
|
2459
|
+
metadata: event.metadata,
|
|
2460
|
+
chain_index: event.chainIndex,
|
|
2461
|
+
hash: event.hash,
|
|
2462
|
+
};
|
|
2463
|
+
}
|
|
2464
|
+
/** Hours a tenant export is kept once ready, or since it was asked for if it never is. */
|
|
2465
|
+
export const SLOTLOCK_TENANT_EXPORT_HOURS = 24;
|
|
2466
|
+
/** The most a tenant export holds: past it, the export fails (`export_too_large`). */
|
|
2467
|
+
export const SLOTLOCK_TENANT_EXPORT_MAX_BYTES = 512 * 1024 * 1024;
|
|
2468
|
+
/** The most exports a tenant keeps at once. */
|
|
2469
|
+
export const SLOTLOCK_MAX_TENANT_EXPORTS = 5;
|
|
2470
|
+
/** A part of an export: one table's rows, at most this many, or about this many bytes. */
|
|
2471
|
+
export const SLOTLOCK_TENANT_EXPORT_PART_ROWS = 5_000;
|
|
2472
|
+
const TENANT_EXPORT_PART_BYTES = 1024 * 1024;
|
|
1818
2473
|
const MAX_TENANT_LIMIT = 10_000_000;
|
|
1819
2474
|
function tenantLimitsOption(limits) {
|
|
1820
2475
|
const valid = {};
|
|
@@ -2097,6 +2752,18 @@ function compositeBusy(executor, resourceId, window) {
|
|
|
2097
2752
|
AND claim.during && tstzrange(${window.start}, ${window.end}, '[)')
|
|
2098
2753
|
AND (claim.status <> 'held' OR claim.expires_at > statement_timestamp())`;
|
|
2099
2754
|
}
|
|
2755
|
+
/**
|
|
2756
|
+
* Busy time calendars reported without their events (RFC 0004 C1) that meets `window`, as
|
|
2757
|
+
* (occupied_starts_at, occupied_ends_at): every read of a resource's busy time unions it in, as
|
|
2758
|
+
* every write refuses it (sourceBusyLoss), so a time offered is a time the write accepts.
|
|
2759
|
+
*/
|
|
2760
|
+
function sourceBusy(executor, resourceId, window) {
|
|
2761
|
+
return executor `
|
|
2762
|
+
SELECT busy.starts_at AS occupied_starts_at, busy.ends_at AS occupied_ends_at
|
|
2763
|
+
FROM slotlock.source_busy busy
|
|
2764
|
+
WHERE busy.resource_id IN (${compositeKin(executor, resourceId)})
|
|
2765
|
+
AND busy.starts_at < ${window.end} AND busy.ends_at > ${window.start}`;
|
|
2766
|
+
}
|
|
2100
2767
|
/**
|
|
2101
2768
|
* Structured answer after losing to the EXCLUDE arbiter: name the live winner.
|
|
2102
2769
|
* conflictingReservationId is null in one narrow race (the blocker expired between the constraint
|
|
@@ -2206,6 +2873,24 @@ export function createTenantSlots(max) {
|
|
|
2206
2873
|
function lockTimeoutError(cause) {
|
|
2207
2874
|
return Object.assign(new Error('Slotlock tenant transaction waited too long for a lock: retry later', { cause }), { code: 'lock_timeout' });
|
|
2208
2875
|
}
|
|
2876
|
+
/**
|
|
2877
|
+
* Build a pending tenant export (RFC 0004 D3) in its own tenant transaction, and mark it failed if
|
|
2878
|
+
* that fails (`export_too_large`, else `export_failed`). Null when it was not pending, or another
|
|
2879
|
+
* server is building it. Never throws: a server builds it after answering, and maintenance builds
|
|
2880
|
+
* one left pending.
|
|
2881
|
+
*/
|
|
2882
|
+
export async function buildSlotlockTenantExport(store, tenantRef, exportId) {
|
|
2883
|
+
try {
|
|
2884
|
+
return await store.withTenant(tenantRef, (tenant) => tenant.buildTenantExport({ tenantRef, exportId }));
|
|
2885
|
+
}
|
|
2886
|
+
catch (error) {
|
|
2887
|
+
const reason = error?.code === 'export_too_large' ? 'export_too_large' : 'export_failed';
|
|
2888
|
+
await store
|
|
2889
|
+
.withTenant(tenantRef, (tenant) => tenant.failTenantExport({ tenantRef, exportId, error: reason }))
|
|
2890
|
+
.catch(() => undefined);
|
|
2891
|
+
return null;
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2209
2894
|
export function createSlotlockStore(sql, options = {}) {
|
|
2210
2895
|
return buildSlotlockStore(sql, options, null);
|
|
2211
2896
|
}
|
|
@@ -2241,18 +2926,31 @@ export const SLOTLOCK_TENANT_WRITE_METHODS = Object.freeze([
|
|
|
2241
2926
|
'declineApproval',
|
|
2242
2927
|
'resetTestTenant',
|
|
2243
2928
|
'fastForwardTestTenant',
|
|
2929
|
+
'storeTenantAvailability',
|
|
2244
2930
|
'setTenantAvailability',
|
|
2931
|
+
'setLocationAvailability',
|
|
2932
|
+
'createAvailabilityException',
|
|
2933
|
+
'deleteAvailabilityException',
|
|
2934
|
+
'replaceImportedExceptions',
|
|
2935
|
+
'importHolidayCalendar',
|
|
2936
|
+
'deleteHolidayCalendar',
|
|
2937
|
+
'noteHolidayCalendarFailure',
|
|
2245
2938
|
'createReservation',
|
|
2246
2939
|
'createExternalReservation',
|
|
2247
2940
|
'rescheduleExternalReservation',
|
|
2248
2941
|
'cancelExternalReservation',
|
|
2249
2942
|
'acquireHold',
|
|
2943
|
+
'markHoldPaid',
|
|
2944
|
+
'putContact',
|
|
2945
|
+
'deleteContact',
|
|
2250
2946
|
'confirmHold',
|
|
2251
2947
|
'releaseHold',
|
|
2252
2948
|
'putCalendarEvent',
|
|
2253
2949
|
'cancelCalendarEvent',
|
|
2254
2950
|
'cancelCalendarEventById',
|
|
2255
2951
|
'recordCalendarCoverage',
|
|
2952
|
+
'replaceSourceBusy',
|
|
2953
|
+
'reportCoverageConflict',
|
|
2256
2954
|
'createWebhookEndpoint',
|
|
2257
2955
|
'deleteWebhookEndpoint',
|
|
2258
2956
|
'enableWebhookEndpoint',
|
|
@@ -2261,12 +2959,14 @@ export const SLOTLOCK_TENANT_WRITE_METHODS = Object.freeze([
|
|
|
2261
2959
|
'checkInReservation',
|
|
2262
2960
|
'joinWaitlist',
|
|
2263
2961
|
'leaveWaitlist',
|
|
2962
|
+
'declineWaitlistOffer',
|
|
2264
2963
|
'putAssistantSettings',
|
|
2265
2964
|
'deleteAssistantSettings',
|
|
2266
2965
|
'resealAssistantKey',
|
|
2267
|
-
'
|
|
2966
|
+
'putAssistantChat',
|
|
2967
|
+
'renameAssistantChat',
|
|
2268
2968
|
'settleAssistantAction',
|
|
2269
|
-
'
|
|
2969
|
+
'deleteAssistantChat',
|
|
2270
2970
|
]);
|
|
2271
2971
|
function tenantSuspendedError() {
|
|
2272
2972
|
return Object.assign(new Error('Slotlock tenant is suspended: it may read, and write nothing'), {
|
|
@@ -2348,6 +3048,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2348
3048
|
retentionDays,
|
|
2349
3049
|
maxHoldsPerHolder,
|
|
2350
3050
|
...(maxHeldHoursPerHolder !== undefined ? { maxHeldHoursPerHolder } : {}),
|
|
3051
|
+
// The server's hours judge a tenant's writes too: without them, enforced hours would refuse
|
|
3052
|
+
// every booking on a resource that inherits nothing.
|
|
3053
|
+
...(options.defaultAvailabilityRules !== undefined ? { defaultAvailabilityRules: options.defaultAvailabilityRules } : {}),
|
|
2351
3054
|
};
|
|
2352
3055
|
/**
|
|
2353
3056
|
* Under the tenant's lock for `kind`: the limit when the tenant is at it, else null. Writes that
|
|
@@ -3185,18 +3888,75 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3185
3888
|
WHERE tenant_ref = ${params.tenantRef} AND pool_ref = ${params.pool}
|
|
3186
3889
|
AND member_order = 'round_robin'`;
|
|
3187
3890
|
},
|
|
3891
|
+
async storeTenantAvailability(params) {
|
|
3892
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
3893
|
+
throw Object.assign(new Error('Slotlock tenant reference must be 1-500 UTF-8 bytes'), {
|
|
3894
|
+
code: 'invalid_identity',
|
|
3895
|
+
});
|
|
3896
|
+
}
|
|
3897
|
+
// What `matching` compares with is read in the same transaction as what replaces it.
|
|
3898
|
+
return inTransaction(sql, (tx) => storeTenantAvailability(tx, params), 'read committed');
|
|
3899
|
+
},
|
|
3188
3900
|
async setTenantAvailability(params) {
|
|
3189
3901
|
if (!isValidIdentity(params.tenantRef)) {
|
|
3190
3902
|
throw Object.assign(new Error('Slotlock tenant reference must be 1-500 UTF-8 bytes'), {
|
|
3191
3903
|
code: 'invalid_identity',
|
|
3192
3904
|
});
|
|
3193
3905
|
}
|
|
3194
|
-
const
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3906
|
+
const stored = await inTransaction(sql, (tx) => storeTenantAvailability(tx, { tenantRef: params.tenantRef, rules: params.rules, overrideResources: 'all' }), 'read committed');
|
|
3907
|
+
return stored.inheriting;
|
|
3908
|
+
},
|
|
3909
|
+
async setLocationAvailability(params) {
|
|
3910
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
3911
|
+
throw Object.assign(new Error('Slotlock tenant reference must be 1-500 UTF-8 bytes'), {
|
|
3912
|
+
code: 'invalid_identity',
|
|
3913
|
+
});
|
|
3914
|
+
}
|
|
3915
|
+
return setLocationAvailability(sql, params);
|
|
3916
|
+
},
|
|
3917
|
+
async getAvailabilityDefaults(params) {
|
|
3918
|
+
return getAvailabilityDefaults(sql, params);
|
|
3919
|
+
},
|
|
3920
|
+
async createAvailabilityException(params) {
|
|
3921
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
3922
|
+
throw Object.assign(new Error('Slotlock tenant reference must be 1-500 UTF-8 bytes'), {
|
|
3923
|
+
code: 'invalid_identity',
|
|
3924
|
+
});
|
|
3925
|
+
}
|
|
3926
|
+
return createAvailabilityException(sql, params);
|
|
3927
|
+
},
|
|
3928
|
+
async listAvailabilityExceptions(params) {
|
|
3929
|
+
return listAvailabilityExceptions(sql, params);
|
|
3930
|
+
},
|
|
3931
|
+
async deleteAvailabilityException(params) {
|
|
3932
|
+
return deleteAvailabilityException(sql, params);
|
|
3933
|
+
},
|
|
3934
|
+
async replaceImportedExceptions(params) {
|
|
3935
|
+
return replaceImportedExceptions(sql, params);
|
|
3936
|
+
},
|
|
3937
|
+
async importHolidayCalendar(params) {
|
|
3938
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
3939
|
+
throw Object.assign(new Error('Slotlock tenant reference must be 1-500 UTF-8 bytes'), {
|
|
3940
|
+
code: 'invalid_identity',
|
|
3941
|
+
});
|
|
3942
|
+
}
|
|
3943
|
+
// The calendar, its holidays and what it dropped, all or none.
|
|
3944
|
+
return inTransaction(sql, (tx) => importHolidayCalendar(tx, params), 'read committed');
|
|
3945
|
+
},
|
|
3946
|
+
async listHolidayCalendars(params) {
|
|
3947
|
+
return listHolidayCalendars(sql, params);
|
|
3948
|
+
},
|
|
3949
|
+
async deleteHolidayCalendar(params) {
|
|
3950
|
+
return inTransaction(sql, (tx) => deleteHolidayCalendar(tx, params), 'read committed');
|
|
3951
|
+
},
|
|
3952
|
+
async dueHolidayCalendars(params) {
|
|
3953
|
+
return dueHolidayCalendars(sql, params);
|
|
3954
|
+
},
|
|
3955
|
+
async noteHolidayCalendarFailure(params) {
|
|
3956
|
+
return noteHolidayCalendarFailure(sql, params);
|
|
3957
|
+
},
|
|
3958
|
+
async availabilityExceptionsFor(params) {
|
|
3959
|
+
return rankedExceptions(sql, params.resourceIds, exceptionQueryDays(params.window));
|
|
3200
3960
|
},
|
|
3201
3961
|
async listResources(params) {
|
|
3202
3962
|
if (!isValidIdentity(params.tenantRef)) {
|
|
@@ -3388,6 +4148,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3388
4148
|
return { ok: false, code: 'resource_not_found' };
|
|
3389
4149
|
if (locked.archived)
|
|
3390
4150
|
return { ok: false, code: 'resource_archived' };
|
|
4151
|
+
// Busy time a calendar reported without its events (RFC 0004 C1) is refused as a booking's is.
|
|
4152
|
+
if (await sourceBusyLoss(tx, params.resourceId, occupied.start, occupied.end))
|
|
4153
|
+
return { ok: false, code: 'overlap', conflictingReservationId: null };
|
|
3391
4154
|
await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
|
|
3392
4155
|
const [row] = await tx `
|
|
3393
4156
|
INSERT INTO slotlock.reservations (
|
|
@@ -3400,7 +4163,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3400
4163
|
${params.source ?? null}
|
|
3401
4164
|
)
|
|
3402
4165
|
RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
|
|
3403
|
-
buffer_after_ms, revision, source`;
|
|
4166
|
+
buffer_after_ms, revision, source, contact_ref, notify, reminder_minutes`;
|
|
3404
4167
|
if (!row)
|
|
3405
4168
|
throw new Error('slotlock: reservation insert returned no row');
|
|
3406
4169
|
return { ok: true, reservation: toReservation(row) };
|
|
@@ -3446,7 +4209,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3446
4209
|
// holds.
|
|
3447
4210
|
const existing = await tx `
|
|
3448
4211
|
SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
|
|
3449
|
-
buffer_after_ms, revision, source
|
|
4212
|
+
buffer_after_ms, revision, source, contact_ref, notify, reminder_minutes
|
|
3450
4213
|
FROM slotlock.reservations
|
|
3451
4214
|
WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
|
|
3452
4215
|
AND calendar_event_id IS NULL
|
|
@@ -3485,24 +4248,31 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3485
4248
|
}))) {
|
|
3486
4249
|
return { ok: false, code: 'recurrence_not_materialized' };
|
|
3487
4250
|
}
|
|
4251
|
+
const wall = await localStartFor(tx, params.resourceId, params.localStart, params.start);
|
|
4252
|
+
if (wall === null)
|
|
4253
|
+
return { ok: false, code: 'invalid_local_start' };
|
|
3488
4254
|
// Writers to this resource take turns from here to the insert (lockResourceForWrite).
|
|
3489
4255
|
const locked = await lockResourceForWrite(tx, params.resourceId, params.tenantRef);
|
|
3490
4256
|
if (!locked)
|
|
3491
4257
|
return { ok: false, code: 'resource_tenant_mismatch' };
|
|
3492
4258
|
if (locked.archived)
|
|
3493
4259
|
return { ok: false, code: 'resource_archived' };
|
|
4260
|
+
// Busy time a calendar reported without its events (RFC 0004 C1) is refused as a booking's is.
|
|
4261
|
+
if (await sourceBusyLoss(tx, params.resourceId, occupied.start, occupied.end))
|
|
4262
|
+
return { ok: false, code: 'overlap', conflictingReservationId: null };
|
|
3494
4263
|
await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
|
|
3495
4264
|
const rows = await tx `
|
|
3496
4265
|
INSERT INTO slotlock.reservations (
|
|
3497
4266
|
resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
|
|
3498
|
-
buffer_before_ms, revision, source
|
|
4267
|
+
buffer_before_ms, revision, source, wall_starts_at, contact_ref, notify, reminder_minutes
|
|
3499
4268
|
) VALUES (
|
|
3500
4269
|
${params.resourceId}, ${params.tenantRef}, ${params.externalRef}, ${params.start},
|
|
3501
4270
|
${params.end}, ${bufferAfterMs}, ${writtenBefore}, ${params.revision},
|
|
3502
|
-
${params.source ?? null}
|
|
4271
|
+
${params.source ?? null}, ${wall ?? null}::timestamp, ${params.contactRef ?? null},
|
|
4272
|
+
${params.notify ?? true}, ${reminderColumn(params.reminderMinutes)}::integer[]
|
|
3503
4273
|
)
|
|
3504
4274
|
RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
|
|
3505
|
-
buffer_after_ms, revision, source`;
|
|
4275
|
+
buffer_after_ms, revision, source, contact_ref, notify, reminder_minutes`;
|
|
3506
4276
|
const row = rows[0];
|
|
3507
4277
|
if (!row)
|
|
3508
4278
|
throw new Error('slotlock: external reservation insert returned no row');
|
|
@@ -3538,7 +4308,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3538
4308
|
await lockExternalIdentity(tx, params.tenantRef, params.externalRef);
|
|
3539
4309
|
const existing = await tx `
|
|
3540
4310
|
SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
|
|
3541
|
-
buffer_after_ms, revision, source
|
|
4311
|
+
buffer_after_ms, revision, source, contact_ref, notify, reminder_minutes, cutoff_anchor
|
|
3542
4312
|
FROM slotlock.reservations
|
|
3543
4313
|
WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
|
|
3544
4314
|
AND calendar_event_id IS NULL
|
|
@@ -3566,6 +4336,26 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3566
4336
|
if (params.revision !== currentRevision + 1) {
|
|
3567
4337
|
return { ok: false, code: 'revision_conflict' };
|
|
3568
4338
|
}
|
|
4339
|
+
// A move in time or to another resource, past the booking's reschedule cutoff (RFC 0004
|
|
4340
|
+
// B5), only with an admin's reason.
|
|
4341
|
+
const moved = found.starts_at.getTime() !== params.start.getTime() ||
|
|
4342
|
+
found.ends_at.getTime() !== params.end.getTime() ||
|
|
4343
|
+
found.resource_id !== params.resourceId;
|
|
4344
|
+
let overrideNote = null;
|
|
4345
|
+
let cutoffAnchor = found.cutoff_anchor;
|
|
4346
|
+
if (moved) {
|
|
4347
|
+
const judged = await rulesNow(tx, found.resource_id);
|
|
4348
|
+
if (cutoffRuleBroken(judged.rules, 'reschedule', found.starts_at, judged.now)) {
|
|
4349
|
+
if (params.overrideReason === undefined) {
|
|
4350
|
+
return { ok: false, code: 'booking_rule_violated', rule: 'reschedule_cutoff' };
|
|
4351
|
+
}
|
|
4352
|
+
overrideNote = params.overrideReason;
|
|
4353
|
+
}
|
|
4354
|
+
// Moved when cancelling would already be late: it stays as late as it was.
|
|
4355
|
+
const from = cancellationStart(found.starts_at, cutoffAnchor);
|
|
4356
|
+
if (cancellationLate(judged.rules, from, judged.now))
|
|
4357
|
+
cutoffAnchor = from;
|
|
4358
|
+
}
|
|
3569
4359
|
const resourceState = await tenantResourceState(tx, params.resourceId, params.tenantRef);
|
|
3570
4360
|
if (resourceState === 'missing')
|
|
3571
4361
|
return { ok: false, code: 'resource_tenant_mismatch' };
|
|
@@ -3573,6 +4363,11 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3573
4363
|
return { ok: false, code: 'resource_archived' };
|
|
3574
4364
|
if (ruling.rule)
|
|
3575
4365
|
return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
|
|
4366
|
+
if (overrideNote !== null)
|
|
4367
|
+
await noteEnding(tx, null, overrideNote);
|
|
4368
|
+
const wall = await localStartFor(tx, params.resourceId, params.localStart, params.start);
|
|
4369
|
+
if (wall === null)
|
|
4370
|
+
return { ok: false, code: 'invalid_local_start' };
|
|
3576
4371
|
bufferAfterMs = Math.max(params.bufferAfterMs, ruling.turnaroundMs);
|
|
3577
4372
|
bufferBeforeMs = ruling.bufferBeforeMs;
|
|
3578
4373
|
const occupied = occupiedTime({ ...params, bufferBeforeMs, bufferAfterMs });
|
|
@@ -3589,6 +4384,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3589
4384
|
return { ok: false, code: 'resource_tenant_mismatch' };
|
|
3590
4385
|
if (locked.archived)
|
|
3591
4386
|
return { ok: false, code: 'resource_archived' };
|
|
4387
|
+
// Busy time a calendar reported without its events (RFC 0004 C1) is refused as a booking's is.
|
|
4388
|
+
if (await sourceBusyLoss(tx, params.resourceId, occupied.start, occupied.end))
|
|
4389
|
+
return { ok: false, code: 'overlap', conflictingReservationId: null };
|
|
3592
4390
|
await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
|
|
3593
4391
|
const rows = await tx `
|
|
3594
4392
|
UPDATE slotlock.reservations
|
|
@@ -3599,14 +4397,22 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3599
4397
|
buffer_before_ms = ${bufferBeforeMs},
|
|
3600
4398
|
revision = ${params.revision},
|
|
3601
4399
|
source = ${params.source ?? null},
|
|
4400
|
+
-- Whom to tell, and whether (RFC 0004 P3): as sent, like the rest.
|
|
4401
|
+
contact_ref = ${params.contactRef ?? null},
|
|
4402
|
+
notify = ${params.notify ?? true},
|
|
4403
|
+
reminder_minutes = ${reminderColumn(params.reminderMinutes)}::integer[],
|
|
4404
|
+
-- The wall time it now says it starts at, if any (RFC 0004 B9).
|
|
4405
|
+
wall_starts_at = ${wall ?? null}::timestamp,
|
|
4406
|
+
zone_shift_reported_at = NULL,
|
|
3602
4407
|
-- A check-in is for the time it was made for: a move to another start clears it.
|
|
3603
4408
|
checked_in_at = CASE
|
|
3604
4409
|
WHEN starts_at = ${params.start} AND resource_id = ${params.resourceId}
|
|
3605
4410
|
THEN checked_in_at
|
|
3606
|
-
END
|
|
4411
|
+
END,
|
|
4412
|
+
cutoff_anchor = ${cutoffAnchor ?? null}::timestamptz
|
|
3607
4413
|
WHERE id = ${found.id}
|
|
3608
4414
|
RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
|
|
3609
|
-
buffer_after_ms, revision, source`;
|
|
4415
|
+
buffer_after_ms, revision, source, contact_ref, notify, reminder_minutes`;
|
|
3610
4416
|
const row = rows[0];
|
|
3611
4417
|
if (!row)
|
|
3612
4418
|
throw new Error('slotlock: external reservation update returned no row');
|
|
@@ -3633,6 +4439,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3633
4439
|
if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.externalRef)) {
|
|
3634
4440
|
return { ok: false, code: 'invalid_identity' };
|
|
3635
4441
|
}
|
|
4442
|
+
if (params.overrideReason !== undefined && !isValidOverrideReason(params.overrideReason)) {
|
|
4443
|
+
return { ok: false, code: 'invalid_identity' };
|
|
4444
|
+
}
|
|
3636
4445
|
if (!Number.isSafeInteger(params.revision) || params.revision <= 0) {
|
|
3637
4446
|
return { ok: false, code: 'invalid_revision' };
|
|
3638
4447
|
}
|
|
@@ -3653,11 +4462,15 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3653
4462
|
: { ok: false, code: 'reservation_cancelled' };
|
|
3654
4463
|
}
|
|
3655
4464
|
const existing = await tx `
|
|
3656
|
-
SELECT id, revision FROM slotlock.reservations
|
|
4465
|
+
SELECT id, revision, resource_id, starts_at, cutoff_anchor FROM slotlock.reservations
|
|
3657
4466
|
WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
|
|
3658
4467
|
AND calendar_event_id IS NULL
|
|
3659
4468
|
FOR UPDATE`;
|
|
3660
4469
|
const found = existing[0];
|
|
4470
|
+
// Past its cancel cutoff a booking is cancelled only with an admin's reason, and a late
|
|
4471
|
+
// cancellation records its fee (RFC 0004 B5).
|
|
4472
|
+
let fee = null;
|
|
4473
|
+
let overrideNote = null;
|
|
3661
4474
|
if (found) {
|
|
3662
4475
|
const currentRevision = Number(found.revision);
|
|
3663
4476
|
if (params.revision <= currentRevision)
|
|
@@ -3665,6 +4478,16 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3665
4478
|
if (params.revision !== currentRevision + 1) {
|
|
3666
4479
|
return { ok: false, code: 'revision_conflict' };
|
|
3667
4480
|
}
|
|
4481
|
+
const judged = await rulesNow(tx, found.resource_id);
|
|
4482
|
+
// From the start it had when a late move kept it, if one did.
|
|
4483
|
+
const start = cancellationStart(found.starts_at, found.cutoff_anchor);
|
|
4484
|
+
if (cutoffRuleBroken(judged.rules, 'cancel', start, judged.now)) {
|
|
4485
|
+
if (params.overrideReason === undefined) {
|
|
4486
|
+
return { ok: false, code: 'booking_rule_violated', rule: 'cancel_cutoff' };
|
|
4487
|
+
}
|
|
4488
|
+
overrideNote = params.overrideReason;
|
|
4489
|
+
}
|
|
4490
|
+
fee = cancellationFeeDue(judged.rules, start, judged.now);
|
|
3668
4491
|
}
|
|
3669
4492
|
if (!found) {
|
|
3670
4493
|
// This reference is a calendar event's occupation, not a booking: nothing to cancel or
|
|
@@ -3683,14 +4506,25 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3683
4506
|
}
|
|
3684
4507
|
// A reference no booking holds yet is retired too, so a create delivered after its cancel
|
|
3685
4508
|
// cannot bring it back.
|
|
4509
|
+
const recorded = fee ? { amount_minor: fee.amountMinor, currency: fee.currency } : null;
|
|
3686
4510
|
await tx `
|
|
3687
|
-
INSERT INTO slotlock.reservation_tombstones (
|
|
3688
|
-
|
|
4511
|
+
INSERT INTO slotlock.reservation_tombstones (
|
|
4512
|
+
tenant_ref, external_ref, revision, stray, end_reason, cancellation_fee, override_reason
|
|
4513
|
+
)
|
|
4514
|
+
VALUES (${params.tenantRef}, ${params.externalRef}, ${params.revision}, ${!found}, 'cancelled',
|
|
4515
|
+
${recorded ? tx.json(recorded) : null}::jsonb, ${overrideNote})`;
|
|
3689
4516
|
if (!found)
|
|
3690
4517
|
return { ok: true, cancelled: false, revision: params.revision };
|
|
4518
|
+
await noteEnding(tx, recorded, overrideNote);
|
|
3691
4519
|
const deleted = await tx `
|
|
3692
4520
|
DELETE FROM slotlock.reservations WHERE id = ${found.id} RETURNING id`;
|
|
3693
|
-
|
|
4521
|
+
await noteEnding(tx, null, null);
|
|
4522
|
+
return {
|
|
4523
|
+
ok: true,
|
|
4524
|
+
cancelled: deleted.length === 1,
|
|
4525
|
+
revision: params.revision,
|
|
4526
|
+
...(fee ? { cancellationFee: { amountMinor: fee.amountMinor, currency: fee.currency } } : {}),
|
|
4527
|
+
};
|
|
3694
4528
|
});
|
|
3695
4529
|
},
|
|
3696
4530
|
async acquireHold(params) {
|
|
@@ -3702,6 +4536,16 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3702
4536
|
if (!Number.isFinite(params.ttlMs) || params.ttlMs <= 0 || params.ttlMs > MAX_HOLD_TTL_MS) {
|
|
3703
4537
|
return { ok: false, code: 'invalid_ttl' };
|
|
3704
4538
|
}
|
|
4539
|
+
const paymentRequired = params.paymentRequired === true;
|
|
4540
|
+
if (params.paymentRequired !== undefined && typeof params.paymentRequired !== 'boolean') {
|
|
4541
|
+
return { ok: false, code: 'invalid_identity' };
|
|
4542
|
+
}
|
|
4543
|
+
if (paymentRequired && params.ttlMs > SLOTLOCK_PAYMENT_HOLD_MAX_TTL_MS) {
|
|
4544
|
+
return { ok: false, code: 'invalid_ttl' };
|
|
4545
|
+
}
|
|
4546
|
+
if (params.payment !== undefined && (!paymentRequired || !isValidAmount(params.payment))) {
|
|
4547
|
+
return { ok: false, code: 'invalid_payment' };
|
|
4548
|
+
}
|
|
3705
4549
|
const bufferAfterMs = params.bufferAfterMs ?? 0;
|
|
3706
4550
|
if (!Number.isSafeInteger(bufferAfterMs) ||
|
|
3707
4551
|
bufferAfterMs < 0 ||
|
|
@@ -3736,6 +4580,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3736
4580
|
source: params.source ?? null,
|
|
3737
4581
|
...(params.groupId !== undefined ? { groupId: params.groupId } : {}),
|
|
3738
4582
|
...(holderRef !== undefined ? { holderRef } : {}),
|
|
4583
|
+
...(paymentRequired ? { paymentRequired } : {}),
|
|
4584
|
+
...(params.payment ? { payment: params.payment } : {}),
|
|
3739
4585
|
});
|
|
3740
4586
|
const ttlSeconds = params.ttlMs / 1000;
|
|
3741
4587
|
// The holder's caps: the store's, lowered by its key's own (RFC 0004 A5), never raised.
|
|
@@ -3764,7 +4610,14 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3764
4610
|
const [prior] = await tx `
|
|
3765
4611
|
SELECT hold_id AS id, resource_id, starts_at, ends_at, buffer_after_ms, expires_at,
|
|
3766
4612
|
source, payload_hash,
|
|
3767
|
-
(SELECT hold_group_id FROM slotlock.reservations WHERE id = hold_id) AS hold_group_id
|
|
4613
|
+
(SELECT hold_group_id FROM slotlock.reservations WHERE id = hold_id) AS hold_group_id,
|
|
4614
|
+
(SELECT payment_required FROM slotlock.reservations WHERE id = hold_id) AS payment_required,
|
|
4615
|
+
(SELECT payment_ref FROM slotlock.reservations WHERE id = hold_id) AS payment_ref,
|
|
4616
|
+
(SELECT paid_at FROM slotlock.reservations WHERE id = hold_id) AS paid_at,
|
|
4617
|
+
(SELECT payment_amount_minor FROM slotlock.reservations WHERE id = hold_id) AS payment_amount_minor,
|
|
4618
|
+
(SELECT payment_currency FROM slotlock.reservations WHERE id = hold_id) AS payment_currency,
|
|
4619
|
+
(SELECT paid_amount_minor FROM slotlock.reservations WHERE id = hold_id) AS paid_amount_minor,
|
|
4620
|
+
(SELECT paid_currency FROM slotlock.reservations WHERE id = hold_id) AS paid_currency
|
|
3768
4621
|
FROM slotlock.hold_commands
|
|
3769
4622
|
WHERE tenant_ref = ${tenantRef ?? null} AND principal_ref = ${principalRef ?? null}
|
|
3770
4623
|
AND idempotency_key = ${idempotencyKey ?? null}`;
|
|
@@ -3782,6 +4635,24 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3782
4635
|
return { ok: false, code: 'resource_archived' };
|
|
3783
4636
|
if (holdLimit !== null)
|
|
3784
4637
|
return { ok: false, code: 'hold_limit_reached', limit: holdLimit };
|
|
4638
|
+
// An agent's hold takes the tenant's price, never its own (RFC 0004 C3).
|
|
4639
|
+
let holdPaymentRequired = paymentRequired;
|
|
4640
|
+
let holdPayment = params.payment;
|
|
4641
|
+
let holdTtlSeconds = ttlSeconds;
|
|
4642
|
+
// The resource's rules, read once: its price here, the rest judged below.
|
|
4643
|
+
const ruling = await bookingRuling(tx, params.resourceId, params, defaultHours);
|
|
4644
|
+
if (params.priceFromRules === true) {
|
|
4645
|
+
const price = ruling.holdPrice;
|
|
4646
|
+
if (params.payment !== undefined &&
|
|
4647
|
+
(!price || price.amountMinor !== params.payment.amountMinor || price.currency !== params.payment.currency)) {
|
|
4648
|
+
return { ok: false, code: 'payment_price_mismatch' };
|
|
4649
|
+
}
|
|
4650
|
+
if (price) {
|
|
4651
|
+
holdPaymentRequired = true;
|
|
4652
|
+
holdPayment = { amountMinor: price.amountMinor, currency: price.currency };
|
|
4653
|
+
holdTtlSeconds = Math.min(ttlSeconds, SLOTLOCK_PAYMENT_HOLD_MAX_TTL_MS / 1000);
|
|
4654
|
+
}
|
|
4655
|
+
}
|
|
3785
4656
|
let heldSeconds = null;
|
|
3786
4657
|
if (holderRef !== undefined) {
|
|
3787
4658
|
// Counted under a lock on the holder, so racing holds stop at the cap: an agent
|
|
@@ -3791,23 +4662,13 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3791
4662
|
tenantRef,
|
|
3792
4663
|
holderRef,
|
|
3793
4664
|
])}, 0))`;
|
|
3794
|
-
const
|
|
3795
|
-
|
|
3796
|
-
COALESCE(sum(extract(epoch FROM
|
|
3797
|
-
ends_at + (buffer_after_ms + buffer_before_ms) * interval '1 millisecond'
|
|
3798
|
-
- starts_at)), 0)::float8
|
|
3799
|
-
AS seconds
|
|
3800
|
-
FROM slotlock.reservations
|
|
3801
|
-
WHERE holder_ref = ${holderRef} AND tenant_ref IS NULL AND status = 'held'
|
|
3802
|
-
AND expires_at > statement_timestamp()
|
|
3803
|
-
AND resource_id IN (SELECT id FROM slotlock.resources WHERE tenant_ref = ${tenantRef ?? null})`;
|
|
3804
|
-
if ((held?.count ?? 0) >= holdCap) {
|
|
4665
|
+
const held = await holderHeld(tx, tenantRef ?? null, holderRef);
|
|
4666
|
+
if (held.count >= holdCap) {
|
|
3805
4667
|
return { ok: false, code: 'hold_limit_reached', limit: holdCap };
|
|
3806
4668
|
}
|
|
3807
4669
|
if (heldHoursCap !== undefined)
|
|
3808
|
-
heldSeconds = held
|
|
4670
|
+
heldSeconds = held.seconds;
|
|
3809
4671
|
}
|
|
3810
|
-
const ruling = await bookingRuling(tx, params.resourceId, params, defaultHours);
|
|
3811
4672
|
if (ruling.rule)
|
|
3812
4673
|
return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
|
|
3813
4674
|
written = Math.max(bufferAfterMs, ruling.turnaroundMs);
|
|
@@ -3833,18 +4694,23 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3833
4694
|
return { ok: false, code: 'resource_not_found' };
|
|
3834
4695
|
if (locked.archived)
|
|
3835
4696
|
return { ok: false, code: 'resource_archived' };
|
|
4697
|
+
// Busy time a calendar reported without its events (RFC 0004 C1) is refused as a booking's is.
|
|
4698
|
+
if (await sourceBusyLoss(tx, params.resourceId, reach.start, reach.end))
|
|
4699
|
+
return { ok: false, code: 'overlap', conflictingReservationId: null };
|
|
3836
4700
|
await reapExpiredHolds(tx, params.resourceId, reach.start, reach.end);
|
|
3837
4701
|
const [row] = await tx `
|
|
3838
4702
|
INSERT INTO slotlock.reservations (
|
|
3839
4703
|
resource_id, starts_at, ends_at, buffer_after_ms, buffer_before_ms, status,
|
|
3840
|
-
expires_at, source, holder_ref, hold_group_id
|
|
4704
|
+
expires_at, source, holder_ref, hold_group_id, payment_required,
|
|
4705
|
+
payment_amount_minor, payment_currency
|
|
3841
4706
|
) VALUES (
|
|
3842
4707
|
${params.resourceId}, ${params.start}, ${params.end}, ${written}, ${writtenBefore}, 'held',
|
|
3843
|
-
statement_timestamp() + make_interval(secs => ${
|
|
3844
|
-
${holderRef ?? null}, ${params.groupId ?? null}
|
|
4708
|
+
statement_timestamp() + make_interval(secs => ${holdTtlSeconds}), ${params.source ?? null},
|
|
4709
|
+
${holderRef ?? null}, ${params.groupId ?? null}, ${holdPaymentRequired},
|
|
4710
|
+
${holdPayment?.amountMinor ?? null}, ${holdPayment?.currency ?? null}
|
|
3845
4711
|
)
|
|
3846
4712
|
RETURNING id, resource_id, starts_at, ends_at, buffer_after_ms, expires_at, source,
|
|
3847
|
-
hold_group_id`;
|
|
4713
|
+
hold_group_id, ${tx.unsafe(HOLD_PAYMENT_COLUMNS)}`;
|
|
3848
4714
|
if (!row)
|
|
3849
4715
|
throw new Error('slotlock: hold insert returned no row');
|
|
3850
4716
|
if (keyed) {
|
|
@@ -3879,50 +4745,620 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3879
4745
|
}
|
|
3880
4746
|
}
|
|
3881
4747
|
},
|
|
3882
|
-
async
|
|
4748
|
+
async putContact(params) {
|
|
3883
4749
|
if (!isValidIdentity(params.tenantRef)) {
|
|
3884
4750
|
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
3885
4751
|
}
|
|
3886
|
-
if (!
|
|
3887
|
-
return
|
|
3888
|
-
const
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
4752
|
+
if (!isValidContactRef(params.ref))
|
|
4753
|
+
return { ok: false, code: 'invalid_contact_ref' };
|
|
4754
|
+
const fields = contactFields(params);
|
|
4755
|
+
if (!fields)
|
|
4756
|
+
return { ok: false, code: 'invalid_contact' };
|
|
4757
|
+
return inTransaction(sql, async (tx) => {
|
|
4758
|
+
await lockExternalIdentity(tx, params.tenantRef, `contact:${params.ref}`);
|
|
4759
|
+
const [existing] = await tx `
|
|
4760
|
+
SELECT ref FROM slotlock.contacts WHERE tenant_ref = ${params.tenantRef} AND ref = ${params.ref}`;
|
|
4761
|
+
if (!existing) {
|
|
4762
|
+
await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify(['slotlock-contacts', params.tenantRef])}, 0))`;
|
|
4763
|
+
const [kept] = await tx `
|
|
4764
|
+
SELECT count(*)::int AS count FROM (
|
|
4765
|
+
SELECT 1 FROM slotlock.contacts WHERE tenant_ref = ${params.tenantRef}
|
|
4766
|
+
LIMIT ${SLOTLOCK_MAX_CONTACTS_PER_TENANT}) kept`;
|
|
4767
|
+
if ((kept?.count ?? 0) >= SLOTLOCK_MAX_CONTACTS_PER_TENANT) {
|
|
4768
|
+
return { ok: false, code: 'contact_limit_reached', limit: SLOTLOCK_MAX_CONTACTS_PER_TENANT };
|
|
4769
|
+
}
|
|
4770
|
+
}
|
|
4771
|
+
// When each channel's answer was given, kept while it stands: the proof of consent.
|
|
4772
|
+
const [before] = await tx `
|
|
4773
|
+
SELECT consent, consent_at FROM slotlock.contacts
|
|
4774
|
+
WHERE tenant_ref = ${params.tenantRef} AND ref = ${params.ref}`;
|
|
4775
|
+
const stamp = new Date().toISOString();
|
|
4776
|
+
const consentAt = {};
|
|
4777
|
+
for (const channel of ['email', 'sms', 'whatsapp']) {
|
|
4778
|
+
const answer = fields.consent[channel];
|
|
4779
|
+
if (answer === undefined)
|
|
4780
|
+
continue;
|
|
4781
|
+
const kept = before?.consent?.[channel] === answer ? before.consent_at?.[channel] : undefined;
|
|
4782
|
+
consentAt[channel] = kept ?? stamp;
|
|
4783
|
+
}
|
|
4784
|
+
const [row] = await tx `
|
|
4785
|
+
INSERT INTO slotlock.contacts (tenant_ref, ref, name, email, phone, whatsapp, consent, consent_at, locale)
|
|
4786
|
+
VALUES (${params.tenantRef}, ${params.ref}, ${fields.name}, ${fields.email}, ${fields.phone},
|
|
4787
|
+
${fields.whatsapp}, ${tx.json(fields.consent)}, ${tx.json(consentAt)}, ${fields.locale})
|
|
4788
|
+
ON CONFLICT (tenant_ref, ref) DO UPDATE
|
|
4789
|
+
SET name = EXCLUDED.name, email = EXCLUDED.email, phone = EXCLUDED.phone,
|
|
4790
|
+
whatsapp = EXCLUDED.whatsapp, consent = EXCLUDED.consent, consent_at = EXCLUDED.consent_at,
|
|
4791
|
+
locale = EXCLUDED.locale, updated_at = statement_timestamp()
|
|
4792
|
+
RETURNING ${tx.unsafe(CONTACT_COLUMNS)}`;
|
|
4793
|
+
if (!row)
|
|
4794
|
+
throw new Error('slotlock: contact upsert returned no row');
|
|
4795
|
+
return { ok: true, contact: toContact(row), created: !existing };
|
|
4796
|
+
}, 'read committed');
|
|
3896
4797
|
},
|
|
3897
|
-
async
|
|
3898
|
-
if (!isValidIdentity(params.tenantRef)
|
|
3899
|
-
throw invalidIdentityError('Slotlock tenant
|
|
4798
|
+
async getContact(params) {
|
|
4799
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
4800
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
3900
4801
|
}
|
|
3901
|
-
if (!
|
|
4802
|
+
if (!isValidContactRef(params.ref))
|
|
3902
4803
|
return null;
|
|
3903
4804
|
const [row] = await sql `
|
|
3904
|
-
SELECT
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
JOIN slotlock.resources resource ON resource.id = hold.resource_id
|
|
3908
|
-
WHERE hold.id = ${params.holdId} AND resource.tenant_ref = ${params.tenantRef}
|
|
3909
|
-
AND hold.status = 'held' AND hold.holder_ref = ${params.holderRef}`;
|
|
3910
|
-
return row ? { hold: toHold(row), live: row.live } : null;
|
|
4805
|
+
SELECT ${sql.unsafe(CONTACT_COLUMNS)}
|
|
4806
|
+
FROM slotlock.contacts WHERE tenant_ref = ${params.tenantRef} AND ref = ${params.ref}`;
|
|
4807
|
+
return row ? toContact(row) : null;
|
|
3911
4808
|
},
|
|
3912
|
-
async
|
|
4809
|
+
async listContacts(params) {
|
|
3913
4810
|
if (!isValidIdentity(params.tenantRef)) {
|
|
3914
4811
|
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
3915
4812
|
}
|
|
3916
|
-
if (!isValidResourceId(params.resourceId)) {
|
|
3917
|
-
throw invalidIdentityError('Slotlock resource id must be a UUID');
|
|
3918
|
-
}
|
|
3919
4813
|
const limit = pageLimit(params.limit);
|
|
3920
|
-
|
|
4814
|
+
if (params.after !== undefined && !isValidContactRef(params.after))
|
|
4815
|
+
return [];
|
|
3921
4816
|
const rows = await sql `
|
|
3922
|
-
SELECT
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
4817
|
+
SELECT ${sql.unsafe(CONTACT_COLUMNS)} FROM slotlock.contacts
|
|
4818
|
+
WHERE tenant_ref = ${params.tenantRef}
|
|
4819
|
+
AND (${params.after ?? null}::text IS NULL OR ref > ${params.after ?? null})
|
|
4820
|
+
ORDER BY ref
|
|
4821
|
+
LIMIT ${limit}`;
|
|
4822
|
+
return rows.map(toContact);
|
|
4823
|
+
},
|
|
4824
|
+
async deleteContact(params) {
|
|
4825
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
4826
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
4827
|
+
}
|
|
4828
|
+
if (!isValidContactRef(params.ref))
|
|
4829
|
+
return { deleted: false, bookings: 0 };
|
|
4830
|
+
return inTransaction(sql, async (tx) => {
|
|
4831
|
+
await lockExternalIdentity(tx, params.tenantRef, `contact:${params.ref}`);
|
|
4832
|
+
const deleted = await tx `
|
|
4833
|
+
DELETE FROM slotlock.contacts WHERE tenant_ref = ${params.tenantRef} AND ref = ${params.ref}
|
|
4834
|
+
RETURNING ref`;
|
|
4835
|
+
// Its reference leaves the bookings too; the feed keeps no row of the person (the change
|
|
4836
|
+
// trigger ignores a write that moves no time).
|
|
4837
|
+
const bookings = await tx `
|
|
4838
|
+
UPDATE slotlock.reservations SET contact_ref = NULL
|
|
4839
|
+
WHERE tenant_ref = ${params.tenantRef} AND contact_ref = ${params.ref}
|
|
4840
|
+
RETURNING id`;
|
|
4841
|
+
return { deleted: deleted.length > 0, bookings: bookings.length };
|
|
4842
|
+
}, 'read committed');
|
|
4843
|
+
},
|
|
4844
|
+
async exportSubject(params) {
|
|
4845
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
4846
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
4847
|
+
}
|
|
4848
|
+
const key = subjectKey(params.subject);
|
|
4849
|
+
if (!key)
|
|
4850
|
+
throw invalidSubjectError();
|
|
4851
|
+
const limit = params.limit ?? 1_000;
|
|
4852
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10_000) {
|
|
4853
|
+
throw Object.assign(new RangeError('Slotlock subject export limit must be 1-10000'), { code: 'invalid_limit' });
|
|
4854
|
+
}
|
|
4855
|
+
const tenantRef = params.tenantRef;
|
|
4856
|
+
const value = key.value;
|
|
4857
|
+
const take = limit + 1;
|
|
4858
|
+
return inTransaction(sql, async (tx) => {
|
|
4859
|
+
// The contacts an email or a contact reference names; the bookings, changes and approvals
|
|
4860
|
+
// that name those contacts are theirs too.
|
|
4861
|
+
const contactRefs = key.kind === 'email'
|
|
4862
|
+
? (await tx `
|
|
4863
|
+
SELECT ref FROM slotlock.contacts WHERE tenant_ref = ${tenantRef} AND lower(email) = ${value}`).map((row) => row.ref)
|
|
4864
|
+
: key.kind === 'contact'
|
|
4865
|
+
? [value]
|
|
4866
|
+
: [];
|
|
4867
|
+
const records = Object.fromEntries(SLOTLOCK_SUBJECT_TABLES.map((table) => [table, []]));
|
|
4868
|
+
const read = async (table, rows) => {
|
|
4869
|
+
records[table] = (await rows).map((entry) => entry.row);
|
|
4870
|
+
};
|
|
4871
|
+
if (contactRefs.length > 0) {
|
|
4872
|
+
await read('contacts', tx `
|
|
4873
|
+
SELECT to_jsonb(contact) - 'tenant_ref' AS row FROM slotlock.contacts contact
|
|
4874
|
+
WHERE contact.tenant_ref = ${tenantRef} AND contact.ref = ANY(${contactRefs}::text[])
|
|
4875
|
+
ORDER BY contact.ref LIMIT ${take}`);
|
|
4876
|
+
await read('reservations', tx `
|
|
4877
|
+
SELECT to_jsonb(booking) - 'tenant_ref' AS row FROM slotlock.reservations booking
|
|
4878
|
+
WHERE booking.tenant_ref = ${tenantRef} AND booking.contact_ref = ANY(${contactRefs}::text[])
|
|
4879
|
+
ORDER BY booking.created_at, booking.id LIMIT ${take}`);
|
|
4880
|
+
await read('outbox', tx `
|
|
4881
|
+
SELECT jsonb_build_object('id', change.id::text, 'type', change.type, 'subject', change.subject,
|
|
4882
|
+
'data', change.data, 'occurred_at', change.occurred_at) AS row
|
|
4883
|
+
FROM slotlock.outbox change
|
|
4884
|
+
WHERE change.tenant_ref = ${tenantRef} AND change.data ->> 'contact_ref' = ANY(${contactRefs}::text[])
|
|
4885
|
+
ORDER BY change.id LIMIT ${take}`);
|
|
4886
|
+
}
|
|
4887
|
+
if (key.kind === 'email') {
|
|
4888
|
+
await read('calendar_event_attendees', tx `
|
|
4889
|
+
SELECT to_jsonb(attendee) - 'tenant_ref' AS row FROM slotlock.calendar_event_attendees attendee
|
|
4890
|
+
WHERE attendee.tenant_ref = ${tenantRef} AND lower(attendee.email) = ${value}
|
|
4891
|
+
ORDER BY attendee.event_id, attendee.ordinal LIMIT ${take}`);
|
|
4892
|
+
}
|
|
4893
|
+
if (key.kind === 'email' || key.kind === 'reference') {
|
|
4894
|
+
// An event's time and title, and its organizer only when that is them: never the others
|
|
4895
|
+
// it names.
|
|
4896
|
+
await read('calendar_events', tx `
|
|
4897
|
+
SELECT jsonb_strip_nulls(jsonb_build_object(
|
|
4898
|
+
'id', event.id, 'external_ref', event.external_ref, 'resource_id', event.resource_id,
|
|
4899
|
+
'starts_at', event.starts_at, 'ends_at', event.ends_at, 'timezone', event.timezone,
|
|
4900
|
+
'summary', event.summary, 'status', event.status,
|
|
4901
|
+
'recurring', event.recurrence_rule IS NOT NULL, 'created_at', event.created_at,
|
|
4902
|
+
'organizer', CASE WHEN lower(event.organizer ->> 'email') = ${value} THEN event.organizer END)) AS row
|
|
4903
|
+
FROM slotlock.calendar_events event
|
|
4904
|
+
WHERE event.tenant_ref = ${tenantRef}
|
|
4905
|
+
AND ${key.kind === 'email'
|
|
4906
|
+
? tx `(lower(event.organizer ->> 'email') = ${value}
|
|
4907
|
+
OR EXISTS (SELECT 1 FROM jsonb_array_elements(event.attendees) AS attendee(value)
|
|
4908
|
+
WHERE lower(attendee.value ->> 'email') = ${value}))`
|
|
4909
|
+
: tx `event.external_ref = ${value}`}
|
|
4910
|
+
ORDER BY event.created_at, event.id LIMIT ${take}`);
|
|
4911
|
+
}
|
|
4912
|
+
if (key.kind === 'holder') {
|
|
4913
|
+
// A hold may keep no tenant of its own: its resource's is its.
|
|
4914
|
+
await read('reservations', tx `
|
|
4915
|
+
SELECT to_jsonb(booking) - 'tenant_ref' AS row FROM slotlock.reservations booking
|
|
4916
|
+
WHERE booking.holder_ref = ${value}
|
|
4917
|
+
AND (booking.tenant_ref = ${tenantRef}
|
|
4918
|
+
OR (booking.tenant_ref IS NULL AND booking.resource_id IN (
|
|
4919
|
+
SELECT resource.id FROM slotlock.resources resource WHERE resource.tenant_ref = ${tenantRef})))
|
|
4920
|
+
ORDER BY booking.created_at, booking.id LIMIT ${take}`);
|
|
4921
|
+
await read('waitlist_entries', tx `
|
|
4922
|
+
SELECT to_jsonb(entry) - 'tenant_ref' AS row FROM slotlock.waitlist_entries entry
|
|
4923
|
+
WHERE entry.tenant_ref = ${tenantRef} AND entry.holder_ref = ${value}
|
|
4924
|
+
ORDER BY entry.created_at, entry.id LIMIT ${take}`);
|
|
4925
|
+
await read('outbox', tx `
|
|
4926
|
+
SELECT jsonb_build_object('id', change.id::text, 'type', change.type, 'subject', change.subject,
|
|
4927
|
+
'data', change.data, 'occurred_at', change.occurred_at) AS row
|
|
4928
|
+
FROM slotlock.outbox change
|
|
4929
|
+
WHERE change.tenant_ref = ${tenantRef} AND change.data ->> 'holder_ref' = ${value}
|
|
4930
|
+
ORDER BY change.id LIMIT ${take}`);
|
|
4931
|
+
}
|
|
4932
|
+
if (key.kind === 'reference') {
|
|
4933
|
+
await read('reservations', tx `
|
|
4934
|
+
SELECT to_jsonb(booking) - 'tenant_ref' AS row FROM slotlock.reservations booking
|
|
4935
|
+
WHERE booking.tenant_ref = ${tenantRef} AND booking.external_ref = ${value}
|
|
4936
|
+
AND booking.calendar_event_id IS NULL
|
|
4937
|
+
ORDER BY booking.created_at, booking.id LIMIT ${take}`);
|
|
4938
|
+
for (const table of [
|
|
4939
|
+
'reservation_tombstones',
|
|
4940
|
+
'reservation_conflict_archive',
|
|
4941
|
+
'calendar_event_tombstones',
|
|
4942
|
+
'calendar_event_commands',
|
|
4943
|
+
]) {
|
|
4944
|
+
await read(table, tx `
|
|
4945
|
+
SELECT to_jsonb(kept) - 'tenant_ref' AS row FROM ${tx(`slotlock.${table}`)} kept
|
|
4946
|
+
WHERE kept.tenant_ref = ${tenantRef} AND kept.external_ref = ${value}
|
|
4947
|
+
LIMIT ${take}`);
|
|
4948
|
+
}
|
|
4949
|
+
await read('outbox', tx `
|
|
4950
|
+
SELECT jsonb_build_object('id', change.id::text, 'type', change.type, 'subject', change.subject,
|
|
4951
|
+
'data', change.data, 'occurred_at', change.occurred_at) AS row
|
|
4952
|
+
FROM slotlock.outbox change
|
|
4953
|
+
WHERE change.tenant_ref = ${tenantRef} AND change.data ->> 'reference' = ${value}
|
|
4954
|
+
ORDER BY change.id LIMIT ${take}`);
|
|
4955
|
+
}
|
|
4956
|
+
// An approval's decision, never its request: that may name others.
|
|
4957
|
+
await read('approvals', tx `
|
|
4958
|
+
SELECT jsonb_strip_nulls(jsonb_build_object(
|
|
4959
|
+
'id', approval.id, 'policy_ref', approval.policy_ref, 'status', approval.status,
|
|
4960
|
+
'operation', approval.operation, 'starts_at', approval.starts_at,
|
|
4961
|
+
'ends_at', approval.ends_at, 'expires_at', approval.expires_at,
|
|
4962
|
+
'decided_at', approval.decided_at, 'created_at', approval.created_at)) AS row
|
|
4963
|
+
FROM slotlock.approvals approval
|
|
4964
|
+
WHERE approval.tenant_ref = ${tenantRef} AND ${subjectApprovalSql(tx, key, contactRefs)}
|
|
4965
|
+
ORDER BY approval.created_at, approval.id LIMIT ${take}`);
|
|
4966
|
+
let truncated = false;
|
|
4967
|
+
for (const table of SLOTLOCK_SUBJECT_TABLES) {
|
|
4968
|
+
if (records[table].length > limit) {
|
|
4969
|
+
records[table] = records[table].slice(0, limit);
|
|
4970
|
+
truncated = true;
|
|
4971
|
+
}
|
|
4972
|
+
}
|
|
4973
|
+
return { records, truncated };
|
|
4974
|
+
}, 'read committed');
|
|
4975
|
+
},
|
|
4976
|
+
async eraseSubject(params) {
|
|
4977
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
4978
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
4979
|
+
}
|
|
4980
|
+
const key = subjectKey(params.subject);
|
|
4981
|
+
if (!key)
|
|
4982
|
+
throw invalidSubjectError();
|
|
4983
|
+
const tenantRef = params.tenantRef;
|
|
4984
|
+
const value = key.value;
|
|
4985
|
+
// One name for what the erasure reached, so what was one holder or booking still reads as one.
|
|
4986
|
+
const replacement = `erased-${randomBytes(16).toString('hex')}`;
|
|
4987
|
+
return inTransaction(sql, async (tx) => {
|
|
4988
|
+
const erased = emptySubjectCounts();
|
|
4989
|
+
const count = async (table, rows) => {
|
|
4990
|
+
erased[table] += (await rows).length;
|
|
4991
|
+
};
|
|
4992
|
+
const changes = async (kind, name) => {
|
|
4993
|
+
const [row] = await tx `
|
|
4994
|
+
SELECT slotlock.erase_subject_changes(${tenantRef}::text, ${kind}::text, ${name}::text,
|
|
4995
|
+
${kind === 'contact' ? null : replacement}::text) AS changed`;
|
|
4996
|
+
erased.outbox += Number(row?.changed ?? 0);
|
|
4997
|
+
};
|
|
4998
|
+
const contactRefs = key.kind === 'email'
|
|
4999
|
+
? (await tx `
|
|
5000
|
+
SELECT ref FROM slotlock.contacts WHERE tenant_ref = ${tenantRef} AND lower(email) = ${value}
|
|
5001
|
+
ORDER BY ref`).map((row) => row.ref)
|
|
5002
|
+
: key.kind === 'contact'
|
|
5003
|
+
? [value]
|
|
5004
|
+
: [];
|
|
5005
|
+
// As deleteContact: a contact written meanwhile waits, then finds itself gone or kept.
|
|
5006
|
+
for (const ref of contactRefs)
|
|
5007
|
+
await lockExternalIdentity(tx, tenantRef, `contact:${ref}`);
|
|
5008
|
+
if (key.kind === 'reference')
|
|
5009
|
+
await lockExternalIdentity(tx, tenantRef, value);
|
|
5010
|
+
// Approvals first, while their requests still match what is being erased.
|
|
5011
|
+
await count('approvals', tx `
|
|
5012
|
+
UPDATE slotlock.approvals approval
|
|
5013
|
+
SET request = ${subjectScrubbedRequestSql(tx, key, contactRefs, replacement)}
|
|
5014
|
+
WHERE approval.tenant_ref = ${tenantRef} AND ${subjectApprovalSql(tx, key, contactRefs)}
|
|
5015
|
+
RETURNING 1`);
|
|
5016
|
+
if (contactRefs.length > 0) {
|
|
5017
|
+
await count('contacts', tx `
|
|
5018
|
+
DELETE FROM slotlock.contacts
|
|
5019
|
+
WHERE tenant_ref = ${tenantRef} AND ref = ANY(${contactRefs}::text[])
|
|
5020
|
+
RETURNING 1`);
|
|
5021
|
+
// The bookings stay; they no longer name whom to tell.
|
|
5022
|
+
await count('reservations', tx `
|
|
5023
|
+
UPDATE slotlock.reservations SET contact_ref = NULL
|
|
5024
|
+
WHERE tenant_ref = ${tenantRef} AND contact_ref = ANY(${contactRefs}::text[])
|
|
5025
|
+
RETURNING 1`);
|
|
5026
|
+
for (const ref of contactRefs)
|
|
5027
|
+
await changes('contact', ref);
|
|
5028
|
+
}
|
|
5029
|
+
if (key.kind === 'email') {
|
|
5030
|
+
await count('calendar_event_attendees', tx `
|
|
5031
|
+
DELETE FROM slotlock.calendar_event_attendees
|
|
5032
|
+
WHERE tenant_ref = ${tenantRef} AND lower(email) = ${value}
|
|
5033
|
+
RETURNING 1`);
|
|
5034
|
+
// The event keeps its revision: nothing it books changed, and no one is told of it again.
|
|
5035
|
+
await count('calendar_events', tx `
|
|
5036
|
+
UPDATE slotlock.calendar_events event
|
|
5037
|
+
SET attendees = coalesce((
|
|
5038
|
+
SELECT jsonb_agg(attendee.value ORDER BY attendee.ordinality)
|
|
5039
|
+
FROM jsonb_array_elements(event.attendees) WITH ORDINALITY AS attendee(value, ordinality)
|
|
5040
|
+
WHERE lower(attendee.value ->> 'email') IS DISTINCT FROM ${value}), '[]'::jsonb),
|
|
5041
|
+
organizer = CASE WHEN lower(event.organizer ->> 'email') = ${value} THEN NULL ELSE event.organizer END
|
|
5042
|
+
WHERE event.tenant_ref = ${tenantRef}
|
|
5043
|
+
AND (lower(event.organizer ->> 'email') = ${value}
|
|
5044
|
+
OR EXISTS (SELECT 1 FROM jsonb_array_elements(event.attendees) AS attendee(value)
|
|
5045
|
+
WHERE lower(attendee.value ->> 'email') = ${value}))
|
|
5046
|
+
RETURNING 1`);
|
|
5047
|
+
}
|
|
5048
|
+
if (key.kind === 'holder') {
|
|
5049
|
+
await count('waitlist_entries', tx `
|
|
5050
|
+
DELETE FROM slotlock.waitlist_entries
|
|
5051
|
+
WHERE tenant_ref = ${tenantRef} AND holder_ref = ${value}
|
|
5052
|
+
RETURNING 1`);
|
|
5053
|
+
// Holds and bookings stay as time taken, held by a name that is no one's.
|
|
5054
|
+
await count('reservations', tx `
|
|
5055
|
+
UPDATE slotlock.reservations booking SET holder_ref = ${replacement}
|
|
5056
|
+
WHERE booking.holder_ref = ${value}
|
|
5057
|
+
AND (booking.tenant_ref = ${tenantRef}
|
|
5058
|
+
OR (booking.tenant_ref IS NULL AND booking.resource_id IN (
|
|
5059
|
+
SELECT resource.id FROM slotlock.resources resource WHERE resource.tenant_ref = ${tenantRef})))
|
|
5060
|
+
RETURNING 1`);
|
|
5061
|
+
await changes('holder', value);
|
|
5062
|
+
}
|
|
5063
|
+
if (key.kind === 'reference') {
|
|
5064
|
+
// A booking keeps its time under a name that is no one's; so do its ended bookings and
|
|
5065
|
+
// events. The reference is free to book again.
|
|
5066
|
+
await count('reservations', tx `
|
|
5067
|
+
UPDATE slotlock.reservations SET external_ref = ${replacement}
|
|
5068
|
+
WHERE tenant_ref = ${tenantRef} AND external_ref = ${value} AND calendar_event_id IS NULL
|
|
5069
|
+
RETURNING 1`);
|
|
5070
|
+
for (const table of [
|
|
5071
|
+
'reservation_tombstones',
|
|
5072
|
+
'reservation_conflict_archive',
|
|
5073
|
+
'calendar_events',
|
|
5074
|
+
'calendar_event_tombstones',
|
|
5075
|
+
'calendar_event_commands',
|
|
5076
|
+
]) {
|
|
5077
|
+
await count(table, tx `
|
|
5078
|
+
UPDATE ${tx(`slotlock.${table}`)} SET external_ref = ${replacement}
|
|
5079
|
+
WHERE tenant_ref = ${tenantRef} AND external_ref = ${value}
|
|
5080
|
+
RETURNING 1`);
|
|
5081
|
+
}
|
|
5082
|
+
await changes('reference', value);
|
|
5083
|
+
}
|
|
5084
|
+
return { erased };
|
|
5085
|
+
}, 'read committed');
|
|
5086
|
+
},
|
|
5087
|
+
async createTenantExport(params) {
|
|
5088
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
5089
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
5090
|
+
}
|
|
5091
|
+
const tenantRef = params.tenantRef;
|
|
5092
|
+
return inTransaction(sql, async (tx) => {
|
|
5093
|
+
await lockExternalIdentity(tx, tenantRef, 'tenant-export');
|
|
5094
|
+
const [kept] = await tx `
|
|
5095
|
+
SELECT count(*) FILTER (WHERE status = 'pending')::int AS pending, count(*)::int AS kept
|
|
5096
|
+
FROM slotlock.tenant_exports
|
|
5097
|
+
WHERE tenant_ref = ${tenantRef} AND expires_at > statement_timestamp()`;
|
|
5098
|
+
if ((kept?.pending ?? 0) > 0)
|
|
5099
|
+
throw tenantExportRefusal('export_in_progress');
|
|
5100
|
+
if ((kept?.kept ?? 0) >= SLOTLOCK_MAX_TENANT_EXPORTS)
|
|
5101
|
+
throw tenantExportRefusal('export_limit_reached');
|
|
5102
|
+
const requestedBy = params.requestedBy === undefined ? null : cutBytes(params.requestedBy, 200) ?? null;
|
|
5103
|
+
const [row] = await tx `
|
|
5104
|
+
INSERT INTO slotlock.tenant_exports (tenant_ref, requested_by, expires_at)
|
|
5105
|
+
VALUES (${tenantRef}, ${requestedBy},
|
|
5106
|
+
statement_timestamp() + make_interval(hours => ${SLOTLOCK_TENANT_EXPORT_HOURS}))
|
|
5107
|
+
RETURNING *, row_count::text AS row_count, bytes::text AS bytes`;
|
|
5108
|
+
if (!row)
|
|
5109
|
+
throw new Error('slotlock: tenant export insert returned no row');
|
|
5110
|
+
return toTenantExport(row);
|
|
5111
|
+
}, 'read committed');
|
|
5112
|
+
},
|
|
5113
|
+
async buildTenantExport(params) {
|
|
5114
|
+
if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.exportId))
|
|
5115
|
+
return null;
|
|
5116
|
+
const tenantRef = params.tenantRef;
|
|
5117
|
+
const exportId = params.exportId;
|
|
5118
|
+
return inTransaction(sql, async (tx) => {
|
|
5119
|
+
// Held to the end: another server building it skips it rather than waiting.
|
|
5120
|
+
const [claimed] = await tx `
|
|
5121
|
+
SELECT id FROM slotlock.tenant_exports
|
|
5122
|
+
WHERE id = ${exportId} AND tenant_ref = ${tenantRef} AND status = 'pending'
|
|
5123
|
+
FOR UPDATE SKIP LOCKED`;
|
|
5124
|
+
if (!claimed)
|
|
5125
|
+
return null;
|
|
5126
|
+
let part = 0;
|
|
5127
|
+
let written = 0;
|
|
5128
|
+
const totals = await readTenantExport(tx, tenantRef, async (read) => {
|
|
5129
|
+
// Refused as soon as it is too large, so nothing past the limit is read or kept.
|
|
5130
|
+
written += read.bytes;
|
|
5131
|
+
if (written > SLOTLOCK_TENANT_EXPORT_MAX_BYTES)
|
|
5132
|
+
throw tenantExportRefusal('export_too_large');
|
|
5133
|
+
part += 1;
|
|
5134
|
+
await tx `
|
|
5135
|
+
INSERT INTO slotlock.tenant_export_parts (export_id, tenant_ref, part, table_name, row_count, content)
|
|
5136
|
+
VALUES (${exportId}, ${tenantRef}, ${part}, ${read.table}, ${read.lines.length}, ${read.lines.join('\n')})`;
|
|
5137
|
+
});
|
|
5138
|
+
const [row] = await tx `
|
|
5139
|
+
UPDATE slotlock.tenant_exports
|
|
5140
|
+
SET status = 'ready', finished_at = statement_timestamp(),
|
|
5141
|
+
expires_at = statement_timestamp() + make_interval(hours => ${SLOTLOCK_TENANT_EXPORT_HOURS}),
|
|
5142
|
+
parts = ${part}, row_count = ${totals.rows}, bytes = ${totals.bytes},
|
|
5143
|
+
tables = ${tx.json(totals.tables)}
|
|
5144
|
+
WHERE id = ${exportId} AND tenant_ref = ${tenantRef}
|
|
5145
|
+
RETURNING *, row_count::text AS row_count, bytes::text AS bytes`;
|
|
5146
|
+
return row ? toTenantExport(row) : null;
|
|
5147
|
+
}, 'read committed');
|
|
5148
|
+
},
|
|
5149
|
+
async failTenantExport(params) {
|
|
5150
|
+
if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.exportId))
|
|
5151
|
+
return;
|
|
5152
|
+
await sql `
|
|
5153
|
+
UPDATE slotlock.tenant_exports
|
|
5154
|
+
SET status = 'failed', finished_at = statement_timestamp(), error = ${cutBytes(params.error, 200) ?? 'export_failed'}
|
|
5155
|
+
WHERE id = ${params.exportId} AND tenant_ref = ${params.tenantRef} AND status = 'pending'`;
|
|
5156
|
+
},
|
|
5157
|
+
async getTenantExport(params) {
|
|
5158
|
+
if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.exportId))
|
|
5159
|
+
return null;
|
|
5160
|
+
const [row] = await sql `
|
|
5161
|
+
SELECT *, row_count::text AS row_count, bytes::text AS bytes FROM slotlock.tenant_exports
|
|
5162
|
+
WHERE id = ${params.exportId} AND tenant_ref = ${params.tenantRef}`;
|
|
5163
|
+
return row ? toTenantExport(row) : null;
|
|
5164
|
+
},
|
|
5165
|
+
async listTenantExports(params) {
|
|
5166
|
+
if (!isValidIdentity(params.tenantRef))
|
|
5167
|
+
return [];
|
|
5168
|
+
const rows = await sql `
|
|
5169
|
+
SELECT *, row_count::text AS row_count, bytes::text AS bytes FROM slotlock.tenant_exports
|
|
5170
|
+
WHERE tenant_ref = ${params.tenantRef}
|
|
5171
|
+
ORDER BY created_at DESC, id DESC`;
|
|
5172
|
+
return rows.map(toTenantExport);
|
|
5173
|
+
},
|
|
5174
|
+
async getTenantExportPart(params) {
|
|
5175
|
+
if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.exportId)) {
|
|
5176
|
+
return { ok: false, code: 'export_not_found' };
|
|
5177
|
+
}
|
|
5178
|
+
const [found] = await sql `
|
|
5179
|
+
SELECT made.status, part.table_name, part.content
|
|
5180
|
+
FROM slotlock.tenant_exports made
|
|
5181
|
+
LEFT JOIN slotlock.tenant_export_parts part
|
|
5182
|
+
ON part.export_id = made.id AND part.tenant_ref = made.tenant_ref AND part.part = ${params.part}
|
|
5183
|
+
WHERE made.id = ${params.exportId} AND made.tenant_ref = ${params.tenantRef}`;
|
|
5184
|
+
if (!found)
|
|
5185
|
+
return { ok: false, code: 'export_not_found' };
|
|
5186
|
+
if (found.status !== 'ready')
|
|
5187
|
+
return { ok: false, code: 'export_not_ready' };
|
|
5188
|
+
if (found.table_name === null || found.content === null)
|
|
5189
|
+
return { ok: false, code: 'export_part_not_found' };
|
|
5190
|
+
return { ok: true, table: found.table_name, lines: found.content.split('\n') };
|
|
5191
|
+
},
|
|
5192
|
+
async deleteTenantExport(params) {
|
|
5193
|
+
if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.exportId))
|
|
5194
|
+
return false;
|
|
5195
|
+
const deleted = await sql `
|
|
5196
|
+
DELETE FROM slotlock.tenant_exports WHERE id = ${params.exportId} AND tenant_ref = ${params.tenantRef}
|
|
5197
|
+
RETURNING 1`;
|
|
5198
|
+
return deleted.length > 0;
|
|
5199
|
+
},
|
|
5200
|
+
async pruneTenantExports(params) {
|
|
5201
|
+
if (!isValidIdentity(params.tenantRef))
|
|
5202
|
+
return { deleted: 0, pending: [] };
|
|
5203
|
+
const deleted = await sql `
|
|
5204
|
+
DELETE FROM slotlock.tenant_exports
|
|
5205
|
+
WHERE tenant_ref = ${params.tenantRef} AND expires_at <= statement_timestamp()
|
|
5206
|
+
RETURNING 1`;
|
|
5207
|
+
const pending = await sql `
|
|
5208
|
+
SELECT id FROM slotlock.tenant_exports
|
|
5209
|
+
WHERE tenant_ref = ${params.tenantRef} AND status = 'pending'
|
|
5210
|
+
AND created_at < statement_timestamp() - interval '2 minutes'
|
|
5211
|
+
ORDER BY created_at`;
|
|
5212
|
+
return { deleted: deleted.length, pending: pending.map((row) => row.id) };
|
|
5213
|
+
},
|
|
5214
|
+
async streamTenantExport(params) {
|
|
5215
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
5216
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
5217
|
+
}
|
|
5218
|
+
return inTransaction(sql, (tx) => readTenantExport(tx, params.tenantRef, ({ table, lines }) => params.onPart({ table, lines })), 'read committed');
|
|
5219
|
+
},
|
|
5220
|
+
async markHoldPaid(params) {
|
|
5221
|
+
if (!isValidIdentity(params.tenantRef))
|
|
5222
|
+
return { ok: false, code: 'invalid_identity' };
|
|
5223
|
+
if (!isValidPaymentRef(params.paymentRef))
|
|
5224
|
+
return { ok: false, code: 'invalid_payment_ref' };
|
|
5225
|
+
if (params.received !== undefined && !isValidAmount(params.received))
|
|
5226
|
+
return { ok: false, code: 'invalid_payment' };
|
|
5227
|
+
if (!isValidResourceId(params.holdId))
|
|
5228
|
+
return { ok: false, code: 'hold_not_found' };
|
|
5229
|
+
return inTransaction(sql, async (tx) => {
|
|
5230
|
+
// One payment record per hold, kept however the hold ends: the same payment told again
|
|
5231
|
+
// (a reloaded return page, a redelivered webhook) after the hold was booked is answered as
|
|
5232
|
+
// recorded, never as a refusal a payment adapter would refund.
|
|
5233
|
+
await lockExternalIdentity(tx, params.tenantRef, `hold-payment:${params.holdId}`);
|
|
5234
|
+
const [recorded] = await tx `
|
|
5235
|
+
SELECT hold_id AS id, resource_id, starts_at, ends_at, buffer_after_ms, expires_at,
|
|
5236
|
+
NULL::text AS source, true AS payment_required, payment_ref, paid_at,
|
|
5237
|
+
payment_amount_minor, payment_currency, paid_amount_minor, paid_currency,
|
|
5238
|
+
booked_at, approval_id
|
|
5239
|
+
FROM slotlock.hold_payments
|
|
5240
|
+
WHERE hold_id = ${params.holdId} AND tenant_ref = ${params.tenantRef}`;
|
|
5241
|
+
if (recorded) {
|
|
5242
|
+
if (recorded.payment_ref !== params.paymentRef) {
|
|
5243
|
+
// Named, so an adapter refunds only a payment it can tell is another.
|
|
5244
|
+
return { ok: false, code: 'payment_conflict', paymentRef: recorded.payment_ref };
|
|
5245
|
+
}
|
|
5246
|
+
// Its hold ended without a booking: the payment was never applied, and is refunded.
|
|
5247
|
+
if (await paidHoldEnded(tx, params.tenantRef, recorded))
|
|
5248
|
+
return { ok: false, code: 'hold_ended' };
|
|
5249
|
+
return { ok: true, hold: toHold(recorded), idempotent: true };
|
|
5250
|
+
}
|
|
5251
|
+
const [row] = await tx `
|
|
5252
|
+
SELECT hold.id, hold.resource_id, hold.starts_at, hold.ends_at, hold.buffer_after_ms,
|
|
5253
|
+
hold.expires_at, hold.source, hold.hold_group_id, hold.payment_required,
|
|
5254
|
+
hold.payment_ref, hold.paid_at, hold.payment_amount_minor, hold.payment_currency,
|
|
5255
|
+
hold.paid_amount_minor, hold.paid_currency, hold.status,
|
|
5256
|
+
hold.expires_at > statement_timestamp() AS live
|
|
5257
|
+
FROM slotlock.reservations hold
|
|
5258
|
+
JOIN slotlock.resources resource ON resource.id = hold.resource_id
|
|
5259
|
+
WHERE hold.id = ${params.holdId} AND resource.tenant_ref = ${params.tenantRef}
|
|
5260
|
+
AND hold.calendar_event_id IS NULL
|
|
5261
|
+
FOR UPDATE OF hold`;
|
|
5262
|
+
if (!row || row.status !== 'held')
|
|
5263
|
+
return { ok: false, code: 'hold_not_found' };
|
|
5264
|
+
if (!row.payment_required)
|
|
5265
|
+
return { ok: false, code: 'payment_not_required' };
|
|
5266
|
+
if (row.payment_ref !== null && row.payment_ref !== undefined) {
|
|
5267
|
+
return row.payment_ref === params.paymentRef
|
|
5268
|
+
? { ok: true, hold: toHold(row), idempotent: true }
|
|
5269
|
+
: { ok: false, code: 'payment_conflict', paymentRef: row.payment_ref };
|
|
5270
|
+
}
|
|
5271
|
+
// Paid after it lapsed: the time may be someone else's by now. The integrator refunds.
|
|
5272
|
+
if (!row.live)
|
|
5273
|
+
return { ok: false, code: 'hold_expired' };
|
|
5274
|
+
// Less than the hold asks, or in another currency: not its payment (RFC 0004 C3).
|
|
5275
|
+
const expected = amountOf(row.payment_amount_minor, row.payment_currency);
|
|
5276
|
+
if (expected &&
|
|
5277
|
+
(!params.received ||
|
|
5278
|
+
params.received.currency !== expected.currency ||
|
|
5279
|
+
params.received.amountMinor < expected.amountMinor)) {
|
|
5280
|
+
return { ok: false, code: 'payment_amount_mismatch' };
|
|
5281
|
+
}
|
|
5282
|
+
const [paid] = await tx `
|
|
5283
|
+
UPDATE slotlock.reservations
|
|
5284
|
+
SET payment_ref = ${params.paymentRef}, paid_at = statement_timestamp(),
|
|
5285
|
+
paid_amount_minor = ${params.received?.amountMinor ?? null},
|
|
5286
|
+
paid_currency = ${params.received?.currency ?? null}
|
|
5287
|
+
WHERE id = ${params.holdId}
|
|
5288
|
+
RETURNING id, resource_id, starts_at, ends_at, buffer_after_ms, expires_at, source,
|
|
5289
|
+
hold_group_id, ${tx.unsafe(HOLD_PAYMENT_COLUMNS)}`;
|
|
5290
|
+
if (!paid)
|
|
5291
|
+
throw new Error('slotlock: hold payment update returned no row');
|
|
5292
|
+
await tx `
|
|
5293
|
+
INSERT INTO slotlock.hold_payments (
|
|
5294
|
+
tenant_ref, hold_id, resource_id, starts_at, ends_at, buffer_after_ms, expires_at,
|
|
5295
|
+
payment_ref, paid_at, payment_amount_minor, payment_currency, paid_amount_minor, paid_currency
|
|
5296
|
+
) VALUES (
|
|
5297
|
+
${params.tenantRef}, ${paid.id}, ${paid.resource_id}, ${paid.starts_at}, ${paid.ends_at},
|
|
5298
|
+
${paid.buffer_after_ms}, ${paid.expires_at}, ${params.paymentRef}, ${paid.paid_at ?? null},
|
|
5299
|
+
${paid.payment_amount_minor ?? null}, ${paid.payment_currency ?? null},
|
|
5300
|
+
${params.received?.amountMinor ?? null}, ${params.received?.currency ?? null}
|
|
5301
|
+
)`;
|
|
5302
|
+
await tx `
|
|
5303
|
+
INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
|
|
5304
|
+
VALUES (${params.tenantRef}, ${paid.resource_id}, 'hold.paid', ${paid.id},
|
|
5305
|
+
jsonb_build_object(
|
|
5306
|
+
'id', ${paid.id}::uuid, 'resource_id', ${paid.resource_id}::uuid,
|
|
5307
|
+
'start', ${paid.starts_at}::timestamptz, 'end', ${paid.ends_at}::timestamptz,
|
|
5308
|
+
'expires_at', ${paid.expires_at}::timestamptz,
|
|
5309
|
+
'payment_ref', ${params.paymentRef}::text,
|
|
5310
|
+
'paid_at', ${paid.paid_at ?? null}::timestamptz,
|
|
5311
|
+
'amount_minor', ${params.received?.amountMinor ?? null}::bigint,
|
|
5312
|
+
'currency', ${params.received?.currency ?? null}::text),
|
|
5313
|
+
NULLIF(current_setting('slotlock.actor', true), ''))`;
|
|
5314
|
+
return { ok: true, hold: toHold(paid), idempotent: false };
|
|
5315
|
+
}, 'read committed');
|
|
5316
|
+
},
|
|
5317
|
+
async getHold(params) {
|
|
5318
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
5319
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
5320
|
+
}
|
|
5321
|
+
if (!isValidResourceId(params.holdId))
|
|
5322
|
+
return null;
|
|
5323
|
+
const [row] = await sql `
|
|
5324
|
+
SELECT hold.id, hold.resource_id, hold.starts_at, hold.ends_at, hold.buffer_after_ms,
|
|
5325
|
+
hold.expires_at, hold.source, ${sql.unsafe(HOLD_PAYMENT_COLUMNS_OF_HOLD)}
|
|
5326
|
+
FROM slotlock.reservations hold
|
|
5327
|
+
JOIN slotlock.resources resource ON resource.id = hold.resource_id
|
|
5328
|
+
WHERE hold.id = ${params.holdId} AND resource.tenant_ref = ${params.tenantRef}
|
|
5329
|
+
AND hold.status = 'held' AND hold.expires_at > statement_timestamp()`;
|
|
5330
|
+
return row ? toHold(row) : null;
|
|
5331
|
+
},
|
|
5332
|
+
async getHolderHold(params) {
|
|
5333
|
+
if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.holderRef)) {
|
|
5334
|
+
throw invalidIdentityError('Slotlock tenant and holder must be 1-500 UTF-8 bytes');
|
|
5335
|
+
}
|
|
5336
|
+
if (!isValidResourceId(params.holdId))
|
|
5337
|
+
return null;
|
|
5338
|
+
const [row] = await sql `
|
|
5339
|
+
SELECT hold.id, hold.resource_id, hold.starts_at, hold.ends_at, hold.buffer_after_ms,
|
|
5340
|
+
hold.expires_at, hold.source, ${sql.unsafe(HOLD_PAYMENT_COLUMNS_OF_HOLD)},
|
|
5341
|
+
hold.expires_at > statement_timestamp() AS live
|
|
5342
|
+
FROM slotlock.reservations hold
|
|
5343
|
+
JOIN slotlock.resources resource ON resource.id = hold.resource_id
|
|
5344
|
+
WHERE hold.id = ${params.holdId} AND resource.tenant_ref = ${params.tenantRef}
|
|
5345
|
+
AND hold.status = 'held' AND hold.holder_ref = ${params.holderRef}`;
|
|
5346
|
+
return row ? { hold: toHold(row), live: row.live } : null;
|
|
5347
|
+
},
|
|
5348
|
+
async listHolds(params) {
|
|
5349
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
5350
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
5351
|
+
}
|
|
5352
|
+
if (!isValidResourceId(params.resourceId)) {
|
|
5353
|
+
throw invalidIdentityError('Slotlock resource id must be a UUID');
|
|
5354
|
+
}
|
|
5355
|
+
const limit = pageLimit(params.limit);
|
|
5356
|
+
const after = pageCursor(params.after);
|
|
5357
|
+
const rows = await sql `
|
|
5358
|
+
SELECT hold.id, hold.resource_id, hold.starts_at, hold.ends_at, hold.buffer_after_ms,
|
|
5359
|
+
hold.expires_at, hold.source, ${sql.unsafe(HOLD_PAYMENT_COLUMNS_OF_HOLD)}
|
|
5360
|
+
FROM slotlock.reservations hold
|
|
5361
|
+
JOIN slotlock.resources resource ON resource.id = hold.resource_id
|
|
3926
5362
|
WHERE hold.resource_id = ${params.resourceId} AND resource.tenant_ref = ${params.tenantRef}
|
|
3927
5363
|
AND hold.status = 'held' AND hold.expires_at > statement_timestamp()
|
|
3928
5364
|
AND (${after.start}::timestamptz IS NULL
|
|
@@ -4079,13 +5515,43 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4079
5515
|
if ((pending?.tenant ?? 0) >= SLOTLOCK_MAX_PENDING_APPROVALS) {
|
|
4080
5516
|
return { ok: false, code: 'approval_limit_reached', limit: SLOTLOCK_MAX_PENDING_APPROVALS };
|
|
4081
5517
|
}
|
|
5518
|
+
// The agent's caps, with its other pending approvals and without the hold that gives way
|
|
5519
|
+
// to this one; under its lock, taken before the resources' as a hold takes it.
|
|
5520
|
+
const holder = params.holder;
|
|
5521
|
+
if (holder && isValidIdentity(holder.ref)) {
|
|
5522
|
+
await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([
|
|
5523
|
+
'slotlock-holder',
|
|
5524
|
+
tenantRef,
|
|
5525
|
+
holder.ref,
|
|
5526
|
+
])}, 0))`;
|
|
5527
|
+
const consumed = params.consumeHold && isValidResourceId(params.consumeHold.holdId) && params.consumeHold.holderRef === holder.ref
|
|
5528
|
+
? params.consumeHold.holdId
|
|
5529
|
+
: undefined;
|
|
5530
|
+
const held = await holderHeld(tx, tenantRef, holder.ref, consumed);
|
|
5531
|
+
const holdCap = Math.min(maxHoldsPerHolder, holder.caps?.maxHolds ?? Number.POSITIVE_INFINITY);
|
|
5532
|
+
if (held.count + params.holds.length > holdCap) {
|
|
5533
|
+
return { ok: false, code: 'hold_limit_reached', limit: holdCap };
|
|
5534
|
+
}
|
|
5535
|
+
const heldHoursCap = holder.caps?.maxHeldHours !== undefined
|
|
5536
|
+
? Math.min(maxHeldHoursPerHolder ?? Number.POSITIVE_INFINITY, holder.caps.maxHeldHours)
|
|
5537
|
+
: maxHeldHoursPerHolder;
|
|
5538
|
+
const asking = params.holds.reduce((seconds, hold) => seconds + (hold.end.getTime() - hold.start.getTime()) / 1000, 0);
|
|
5539
|
+
if (heldHoursCap !== undefined && held.seconds + asking > heldHoursCap * 3_600) {
|
|
5540
|
+
return { ok: false, code: 'held_time_limit_reached', limit: heldHoursCap };
|
|
5541
|
+
}
|
|
5542
|
+
}
|
|
4082
5543
|
const id = randomUUID();
|
|
4083
5544
|
// The requester's own hold of the time gives way to the approval's.
|
|
4084
5545
|
if (params.consumeHold && isValidResourceId(params.consumeHold.holdId)) {
|
|
5546
|
+
// Taken over, not ended: its payment, if any, goes with the approval (booked if granted).
|
|
5547
|
+
await tx `SELECT set_config('slotlock.ending', 'approval', true)`;
|
|
4085
5548
|
await tx `
|
|
4086
5549
|
DELETE FROM slotlock.reservations
|
|
4087
5550
|
WHERE id = ${params.consumeHold.holdId} AND status = 'held'
|
|
4088
5551
|
AND holder_ref = ${params.consumeHold.holderRef}`;
|
|
5552
|
+
await tx `SELECT set_config('slotlock.ending', '', true)`;
|
|
5553
|
+
await tx `UPDATE slotlock.hold_payments SET approval_id = ${id}::uuid
|
|
5554
|
+
WHERE hold_id = ${params.consumeHold.holdId} AND tenant_ref = ${tenantRef}`;
|
|
4089
5555
|
}
|
|
4090
5556
|
const holds = [...params.holds].sort((left, right) => left.resourceId.localeCompare(right.resourceId) || left.start.getTime() - right.start.getTime());
|
|
4091
5557
|
// Locked as any booking write locks them, in id order, so a racing booking takes turns.
|
|
@@ -4112,7 +5578,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4112
5578
|
starts_at, ends_at, request_key, expires_at
|
|
4113
5579
|
) VALUES (
|
|
4114
5580
|
${id}, ${tenantRef}, ${params.policyRef}, ${params.operation}, ${tx.json(params.request)},
|
|
4115
|
-
${tx.json(params.requester
|
|
5581
|
+
${tx.json((holder ? { ...params.requester, holder_ref: holder.ref } : params.requester))},
|
|
5582
|
+
${held.map((hold) => hold.id)}::uuid[],
|
|
4116
5583
|
${[...new Set(holds.map((hold) => hold.resourceId))]}::uuid[], ${params.start}, ${params.end},
|
|
4117
5584
|
${params.requestKey}, ${params.expiresAt}
|
|
4118
5585
|
)
|
|
@@ -4239,7 +5706,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4239
5706
|
}
|
|
4240
5707
|
const [row] = await sql `
|
|
4241
5708
|
SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
|
|
4242
|
-
revision, source
|
|
5709
|
+
revision, source, contact_ref, notify, reminder_minutes
|
|
4243
5710
|
FROM slotlock.reservations
|
|
4244
5711
|
WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
|
|
4245
5712
|
AND calendar_event_id IS NULL`;
|
|
@@ -4257,7 +5724,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4257
5724
|
const after = pageCursor(params.after);
|
|
4258
5725
|
const rows = await sql `
|
|
4259
5726
|
SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
|
|
4260
|
-
revision, source
|
|
5727
|
+
revision, source, contact_ref, notify, reminder_minutes
|
|
4261
5728
|
FROM slotlock.reservations
|
|
4262
5729
|
WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
|
|
4263
5730
|
AND calendar_event_id IS NULL
|
|
@@ -4294,8 +5761,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4294
5761
|
const resource = await lockResourceForWrite(tx, owner.resource_id, null);
|
|
4295
5762
|
const [row] = await tx `
|
|
4296
5763
|
SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
|
|
4297
|
-
revision, source,
|
|
4298
|
-
|
|
5764
|
+
revision, source, contact_ref, notify, reminder_minutes, status,
|
|
5765
|
+
(status = 'held' AND expires_at > statement_timestamp()) AS live,
|
|
5766
|
+
hold_group_id, holder_ref, (payment_required AND paid_at IS NULL) AS unpaid
|
|
4299
5767
|
FROM slotlock.reservations
|
|
4300
5768
|
WHERE id = ${holdId}
|
|
4301
5769
|
FOR UPDATE`;
|
|
@@ -4314,8 +5782,16 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4314
5782
|
}
|
|
4315
5783
|
if (!row.live)
|
|
4316
5784
|
return { ok: false, code: 'hold_expired' };
|
|
5785
|
+
// A hold that waits for payment is booked only once it is paid (RFC 0004 C3).
|
|
5786
|
+
if (row.unpaid)
|
|
5787
|
+
return { ok: false, code: 'payment_required' };
|
|
4317
5788
|
if (resource?.archived !== false)
|
|
4318
5789
|
return { ok: false, code: 'resource_archived' };
|
|
5790
|
+
// Busy time a calendar reported since the hold was made (RFC 0004 C1) is refused, as an
|
|
5791
|
+
// agent's confirmation of its hold refuses it: the source's time is not to be booked.
|
|
5792
|
+
if (await sourceBusyLoss(tx, row.resource_id, row.starts_at, new Date(row.ends_at.getTime() + Number(row.buffer_after_ms)))) {
|
|
5793
|
+
return { ok: false, code: 'overlap' };
|
|
5794
|
+
}
|
|
4319
5795
|
if (bookingTenant !== null) {
|
|
4320
5796
|
const [retired] = await tx `
|
|
4321
5797
|
SELECT revision FROM slotlock.reservation_tombstones
|
|
@@ -4340,9 +5816,12 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4340
5816
|
tenant_ref = ${bookingTenant}, external_ref = ${bookingTenant === null ? null : holdId}
|
|
4341
5817
|
WHERE id = ${holdId}
|
|
4342
5818
|
RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
|
|
4343
|
-
buffer_after_ms, revision, source`;
|
|
5819
|
+
buffer_after_ms, revision, source, contact_ref, notify, reminder_minutes`;
|
|
4344
5820
|
if (!confirmed)
|
|
4345
5821
|
throw new Error('slotlock: hold confirmation returned no row');
|
|
5822
|
+
// Its payment, if any, is applied: never refunded as a hold that ended unbooked.
|
|
5823
|
+
await tx `UPDATE slotlock.hold_payments SET booked_at = statement_timestamp()
|
|
5824
|
+
WHERE hold_id = ${holdId} AND booked_at IS NULL`;
|
|
4346
5825
|
// Its linked alternatives (one group, one holder) are released with it, as booking one
|
|
4347
5826
|
// from an agent releases the rest.
|
|
4348
5827
|
if (row.hold_group_id !== null && row.holder_ref !== null) {
|
|
@@ -4423,6 +5902,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4423
5902
|
AND (status <> 'held' OR expires_at > statement_timestamp())
|
|
4424
5903
|
UNION ALL
|
|
4425
5904
|
${compositeBusy(sql, resourceId, window)}
|
|
5905
|
+
UNION ALL
|
|
5906
|
+
${sourceBusy(sql, resourceId, window)}
|
|
4426
5907
|
) busy
|
|
4427
5908
|
ORDER BY occupied_starts_at
|
|
4428
5909
|
LIMIT ${options.limit ?? null}`;
|
|
@@ -4544,7 +6025,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4544
6025
|
SELECT id, tenant_ref, owner_ref, external_ref, resource_id, starts_at, ends_at, timezone,
|
|
4545
6026
|
summary, description, location, status, transparency, organizer, attendees,
|
|
4546
6027
|
reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
|
|
4547
|
-
materialized_ends_at, recurrence_cursor, revision, source, created_at, updated_at
|
|
6028
|
+
materialized_ends_at, recurrence_cursor, revision, source, created_at, updated_at,
|
|
6029
|
+
cutoff_anchor
|
|
4548
6030
|
FROM slotlock.calendar_events
|
|
4549
6031
|
WHERE tenant_ref = ${params.tenantRef}
|
|
4550
6032
|
AND owner_ref = ${ownerRef}
|
|
@@ -4560,6 +6042,34 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4560
6042
|
if (params.expectedRevision !== currentRevision) {
|
|
4561
6043
|
return { ok: false, code: 'revision_conflict', currentRevision };
|
|
4562
6044
|
}
|
|
6045
|
+
// A one-off event changed past its cutoffs (RFC 0004 B5) is refused to a writer held to
|
|
6046
|
+
// the booking rules: moved in time or to another resource, or made a series (whose
|
|
6047
|
+
// cancellation no cutoff holds), past its reschedule cutoff; made transparent, letting its
|
|
6048
|
+
// time go, past its cancel cutoff or once a cancellation fee is due, as a cancellation
|
|
6049
|
+
// would be (cancelling records the fee).
|
|
6050
|
+
let movedAnchor = null;
|
|
6051
|
+
if (found && params.enforceBookingRules === true && found.recurrence_rule === null) {
|
|
6052
|
+
const moved = found.starts_at.getTime() !== event.start.getTime() ||
|
|
6053
|
+
found.ends_at.getTime() !== event.end.getTime() ||
|
|
6054
|
+
found.resource_id !== params.resourceId ||
|
|
6055
|
+
(event.recurrence?.rrule ?? null) !== null;
|
|
6056
|
+
const letGo = found.transparency !== 'transparent' && (event.transparency ?? 'opaque') === 'transparent';
|
|
6057
|
+
if (moved || letGo) {
|
|
6058
|
+
const judged = await rulesNow(tx, found.resource_id);
|
|
6059
|
+
if (moved && cutoffRuleBroken(judged.rules, 'reschedule', found.starts_at, judged.now)) {
|
|
6060
|
+
return { ok: false, code: 'booking_rule_violated', rule: 'reschedule_cutoff' };
|
|
6061
|
+
}
|
|
6062
|
+
const from = cancellationStart(found.starts_at, found.cutoff_anchor);
|
|
6063
|
+
if (letGo &&
|
|
6064
|
+
(cutoffRuleBroken(judged.rules, 'cancel', from, judged.now) ||
|
|
6065
|
+
cancellationFeeDue(judged.rules, from, judged.now) !== null)) {
|
|
6066
|
+
return { ok: false, code: 'booking_rule_violated', rule: 'cancel_cutoff' };
|
|
6067
|
+
}
|
|
6068
|
+
// Moved when cancelling would already be late: it stays as late as it was.
|
|
6069
|
+
if (moved && cancellationLate(judged.rules, from, judged.now))
|
|
6070
|
+
movedAnchor = from;
|
|
6071
|
+
}
|
|
6072
|
+
}
|
|
4563
6073
|
const series = expanded ?? resumeStoredSeries(event, materializationWindow, found);
|
|
4564
6074
|
if (!series)
|
|
4565
6075
|
return { ok: false, code: 'invalid_event' };
|
|
@@ -4593,6 +6103,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4593
6103
|
}
|
|
4594
6104
|
const eventId = found?.id ?? randomUUID();
|
|
4595
6105
|
const revision = currentRevision + 1;
|
|
6106
|
+
let heldPaid = false;
|
|
4596
6107
|
if (params.fromHold) {
|
|
4597
6108
|
const { holdId, holderRef } = params.fromHold;
|
|
4598
6109
|
if (!isValidResourceId(holdId) || !isValidIdentity(holderRef)) {
|
|
@@ -4600,16 +6111,22 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4600
6111
|
}
|
|
4601
6112
|
const [hold] = await tx `
|
|
4602
6113
|
SELECT resource_id, starts_at, ends_at, hold_group_id,
|
|
4603
|
-
expires_at > statement_timestamp() AS live
|
|
6114
|
+
expires_at > statement_timestamp() AS live,
|
|
6115
|
+
(payment_required AND paid_at IS NULL) AS unpaid,
|
|
6116
|
+
(payment_required AND paid_at IS NOT NULL) AS paid
|
|
4604
6117
|
FROM slotlock.reservations
|
|
4605
6118
|
WHERE id = ${holdId} AND status = 'held' AND holder_ref = ${holderRef}
|
|
4606
6119
|
FOR UPDATE`;
|
|
4607
6120
|
if (!hold)
|
|
4608
6121
|
return { ok: false, code: 'hold_not_found' };
|
|
6122
|
+
heldPaid = hold.paid;
|
|
4609
6123
|
// The holder's own hold that lapsed and is not yet reaped: named as the application
|
|
4610
6124
|
// API's confirm names it. Another holder's stays not found.
|
|
4611
6125
|
if (!hold.live)
|
|
4612
6126
|
return { ok: false, code: 'hold_expired' };
|
|
6127
|
+
// A hold that waits for payment is booked only once paid (RFC 0004 C3).
|
|
6128
|
+
if (hold.unpaid)
|
|
6129
|
+
return { ok: false, code: 'payment_required' };
|
|
4613
6130
|
const [only] = occurrences;
|
|
4614
6131
|
if (hold.resource_id !== params.resourceId ||
|
|
4615
6132
|
occurrences.length !== 1 ||
|
|
@@ -4618,17 +6135,35 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4618
6135
|
only.end > hold.ends_at) {
|
|
4619
6136
|
return { ok: false, code: 'hold_mismatch' };
|
|
4620
6137
|
}
|
|
6138
|
+
// The hold booked is accepted, not let go (slotlock.waitlist_offer_settle); the rest of
|
|
6139
|
+
// its group is released.
|
|
6140
|
+
await tx `SELECT set_config('slotlock.ending', 'confirmed', true)`;
|
|
6141
|
+
await tx `DELETE FROM slotlock.reservations WHERE id = ${holdId} AND status = 'held' AND holder_ref = ${holderRef}`;
|
|
6142
|
+
await tx `SELECT set_config('slotlock.ending', '', true)`;
|
|
6143
|
+
// Its payment, if any, is applied to this booking.
|
|
6144
|
+
await tx `UPDATE slotlock.hold_payments SET booked_at = statement_timestamp()
|
|
6145
|
+
WHERE hold_id = ${holdId} AND booked_at IS NULL`;
|
|
6146
|
+
// A set held together (RFC 0004 B7) is booked member by member: its other holds stay.
|
|
4621
6147
|
await tx `
|
|
4622
6148
|
DELETE FROM slotlock.reservations
|
|
4623
6149
|
WHERE status = 'held' AND holder_ref = ${holderRef}
|
|
4624
6150
|
AND (id = ${holdId}
|
|
4625
6151
|
OR (${hold.hold_group_id}::uuid IS NOT NULL
|
|
4626
|
-
AND hold_group_id = ${hold.hold_group_id}::uuid
|
|
6152
|
+
AND hold_group_id = ${hold.hold_group_id}::uuid
|
|
6153
|
+
AND source IS DISTINCT FROM 'slotlock:together'))`;
|
|
4627
6154
|
}
|
|
4628
6155
|
let turnaroundMs = 0;
|
|
4629
6156
|
let bufferBeforeMs = 0;
|
|
4630
|
-
|
|
4631
|
-
|
|
6157
|
+
// The resource's rules, read once, for its price and the rest alike.
|
|
6158
|
+
const judge = params.enforceBookingRules === true && (event.transparency ?? 'opaque') === 'opaque'
|
|
6159
|
+
? await bookingJudge(tx, params.resourceId, defaultHours)
|
|
6160
|
+
: null;
|
|
6161
|
+
// A resource with a hold price (RFC 0004 C3) is booked by an agent only from its own
|
|
6162
|
+
// hold, once paid: new time on it, or time moved onto it from another resource.
|
|
6163
|
+
if (judge && (!found || found.resource_id !== params.resourceId) && !heldPaid && judge.holdPrice !== undefined) {
|
|
6164
|
+
return { ok: false, code: 'payment_required' };
|
|
6165
|
+
}
|
|
6166
|
+
if (judge) {
|
|
4632
6167
|
for (const [index, occurrence] of occurrences.entries()) {
|
|
4633
6168
|
// A held time's notice and advance were judged when it was held.
|
|
4634
6169
|
const rule = judge.broken(occurrence, { first: index === 0 && !params.fromHold });
|
|
@@ -4679,6 +6214,10 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4679
6214
|
source = ${params.source ?? null},
|
|
4680
6215
|
buffer_after_ms = ${turnaroundMs},
|
|
4681
6216
|
buffer_before_ms = ${bufferBeforeMs},
|
|
6217
|
+
-- Whether anyone should be told (RFC 0004 P3): as sent, like the rest.
|
|
6218
|
+
notify = ${params.notify !== false},
|
|
6219
|
+
-- Kept, or set by a late move (RFC 0004 B5).
|
|
6220
|
+
cutoff_anchor = COALESCE(${movedAnchor}::timestamptz, cutoff_anchor),
|
|
4682
6221
|
updated_at = now()
|
|
4683
6222
|
WHERE id = ${eventId}
|
|
4684
6223
|
AND tenant_ref = ${params.tenantRef}
|
|
@@ -4691,7 +6230,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4691
6230
|
summary, description, location, status, transparency, organizer, attendees,
|
|
4692
6231
|
reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
|
|
4693
6232
|
materialized_ends_at, recurrence_cursor, recurrence_ends_by, revision, source,
|
|
4694
|
-
buffer_after_ms, buffer_before_ms
|
|
6233
|
+
buffer_after_ms, buffer_before_ms, notify
|
|
4695
6234
|
) VALUES (
|
|
4696
6235
|
${eventId}, ${params.tenantRef}, ${ownerRef}, ${params.externalRef},
|
|
4697
6236
|
${params.resourceId},
|
|
@@ -4705,7 +6244,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4705
6244
|
${tx.json(eventExceptionsForJson(event.recurrence?.exceptions))},
|
|
4706
6245
|
${materializationWindow.start}, ${materializationWindow.end},
|
|
4707
6246
|
${seriesCursorJson(tx, series.cursor)}, ${recurrenceEndsBy}::text::timestamptz,
|
|
4708
|
-
${revision}, ${params.source ?? null}, ${turnaroundMs}, ${bufferBeforeMs}
|
|
6247
|
+
${revision}, ${params.source ?? null}, ${turnaroundMs}, ${bufferBeforeMs},
|
|
6248
|
+
${params.notify !== false}
|
|
4709
6249
|
)`;
|
|
4710
6250
|
}
|
|
4711
6251
|
// The normalized children are canonical; the JSON columns above are a compatibility
|
|
@@ -4717,6 +6257,15 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4717
6257
|
return { ok: false, code: 'resource_tenant_mismatch' };
|
|
4718
6258
|
if (locked.archived)
|
|
4719
6259
|
return { ok: false, code: 'resource_archived' };
|
|
6260
|
+
// A booking (held to the rules, as agents' are) never takes busy time a calendar reported
|
|
6261
|
+
// without its events (RFC 0004 C1); events imported from calendars are that time.
|
|
6262
|
+
if (params.enforceBookingRules === true && (event.transparency ?? 'opaque') === 'opaque') {
|
|
6263
|
+
for (const occurrence of occurrences) {
|
|
6264
|
+
if (await sourceBusyLoss(tx, params.resourceId, new Date(occurrence.start.getTime() - bufferBeforeMs), new Date(occurrence.end.getTime() + turnaroundMs))) {
|
|
6265
|
+
return { ok: false, code: 'overlap', conflictingReservationId: null };
|
|
6266
|
+
}
|
|
6267
|
+
}
|
|
6268
|
+
}
|
|
4720
6269
|
await replaceCalendarEventOccurrences(tx, {
|
|
4721
6270
|
tenantRef: params.tenantRef,
|
|
4722
6271
|
eventId,
|
|
@@ -5042,7 +6591,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5042
6591
|
};
|
|
5043
6592
|
}
|
|
5044
6593
|
const events = await tx `
|
|
5045
|
-
SELECT id, resource_id, revision
|
|
6594
|
+
SELECT id, resource_id, revision, starts_at, recurrence_rule IS NOT NULL AS recurring, cutoff_anchor
|
|
6595
|
+
FROM slotlock.calendar_events
|
|
5046
6596
|
WHERE tenant_ref = ${params.tenantRef}
|
|
5047
6597
|
AND owner_ref = ${ownerRef}
|
|
5048
6598
|
AND external_ref = ${params.externalRef}
|
|
@@ -5059,7 +6609,10 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5059
6609
|
if (params.expectedRevision !== currentRevision) {
|
|
5060
6610
|
return { ok: false, code: 'revision_conflict', currentRevision };
|
|
5061
6611
|
}
|
|
5062
|
-
|
|
6612
|
+
const ending = found ? await eventEnding(tx, found, params.enforceBookingRules === true, undefined) : null;
|
|
6613
|
+
if (ending && !ending.ok)
|
|
6614
|
+
return ending.refusal;
|
|
6615
|
+
const cancelled = await finishCalendarCancel(tx, {
|
|
5063
6616
|
tenantRef: params.tenantRef,
|
|
5064
6617
|
ownerRef,
|
|
5065
6618
|
externalRef: params.externalRef,
|
|
@@ -5069,9 +6622,15 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5069
6622
|
idempotencyKey: params.idempotencyKey,
|
|
5070
6623
|
payloadHash,
|
|
5071
6624
|
});
|
|
6625
|
+
if (ending)
|
|
6626
|
+
await noteEnding(tx, null, null);
|
|
6627
|
+
return cancelled.ok && ending?.fee ? { ...cancelled, cancellationFee: ending.fee } : cancelled;
|
|
5072
6628
|
});
|
|
5073
6629
|
},
|
|
5074
6630
|
async cancelCalendarEventById(params) {
|
|
6631
|
+
if (params.overrideReason !== undefined && !isValidOverrideReason(params.overrideReason)) {
|
|
6632
|
+
return { ok: false, code: 'invalid_identity' };
|
|
6633
|
+
}
|
|
5075
6634
|
if (!isValidIdentity(params.tenantRef) ||
|
|
5076
6635
|
!isValidResourceId(params.eventId) ||
|
|
5077
6636
|
!isValidIdentity(params.idempotencyKey) ||
|
|
@@ -5122,7 +6681,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5122
6681
|
return { ok: false, code: 'event_not_found' };
|
|
5123
6682
|
await lockCalendarEventIdentity(tx, params.tenantRef, identity.owner_ref, identity.external_ref);
|
|
5124
6683
|
const [found] = await tx `
|
|
5125
|
-
SELECT id, resource_id, revision
|
|
6684
|
+
SELECT id, resource_id, revision, starts_at, recurrence_rule IS NOT NULL AS recurring, cutoff_anchor
|
|
6685
|
+
FROM slotlock.calendar_events
|
|
5126
6686
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.eventId}
|
|
5127
6687
|
FOR UPDATE`;
|
|
5128
6688
|
if (!found)
|
|
@@ -5135,7 +6695,11 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5135
6695
|
if (params.expectedRevision !== currentRevision) {
|
|
5136
6696
|
return { ok: false, code: 'revision_conflict', currentRevision };
|
|
5137
6697
|
}
|
|
5138
|
-
|
|
6698
|
+
// The tenant's own cancellation is held to the cutoff too, unless an admin gives a reason.
|
|
6699
|
+
const ending = await eventEnding(tx, found, true, params.overrideReason);
|
|
6700
|
+
if (!ending.ok)
|
|
6701
|
+
return ending.refusal;
|
|
6702
|
+
const cancelled = await finishCalendarCancel(tx, {
|
|
5139
6703
|
tenantRef: params.tenantRef,
|
|
5140
6704
|
ownerRef: identity.owner_ref,
|
|
5141
6705
|
externalRef: identity.external_ref,
|
|
@@ -5145,6 +6709,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5145
6709
|
idempotencyKey: params.idempotencyKey,
|
|
5146
6710
|
payloadHash,
|
|
5147
6711
|
});
|
|
6712
|
+
await noteEnding(tx, null, null);
|
|
6713
|
+
return cancelled.ok && ending.fee ? { ...cancelled, cancellationFee: ending.fee } : cancelled;
|
|
5148
6714
|
});
|
|
5149
6715
|
},
|
|
5150
6716
|
async followZoneRules(params) {
|
|
@@ -5228,7 +6794,197 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5228
6794
|
conflicts++;
|
|
5229
6795
|
}
|
|
5230
6796
|
}
|
|
5231
|
-
|
|
6797
|
+
// A series' occurrences to come (RFC 0004 B9): said on the feed, once each; with `move`, each
|
|
6798
|
+
// series moves whole, in one transaction, where Node's zone rules agree with the database's.
|
|
6799
|
+
const occurrences = await sql `
|
|
6800
|
+
SELECT occurrence.event_id, occurrence.recurrence_id, event.resource_id, occurrence.reservation_id,
|
|
6801
|
+
event.timezone, to_char(occurrence.wall_starts_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS wall,
|
|
6802
|
+
occurrence.starts_at, occurrence.ends_at,
|
|
6803
|
+
slotlock.zoned_instant(occurrence.wall_starts_at, event.timezone) AS moved_start
|
|
6804
|
+
FROM slotlock.calendar_event_occurrences occurrence
|
|
6805
|
+
JOIN slotlock.calendar_events event ON event.id = occurrence.event_id
|
|
6806
|
+
WHERE event.tenant_ref = ${params.tenantRef} AND event.recurrence_rule IS NOT NULL
|
|
6807
|
+
AND occurrence.starts_at > now() AND occurrence.zone_shift_reported_at IS NULL
|
|
6808
|
+
AND occurrence.wall_starts_at IS NOT NULL
|
|
6809
|
+
AND slotlock.zoned_instant(occurrence.wall_starts_at, event.timezone) IS NOT NULL
|
|
6810
|
+
AND slotlock.zoned_instant(occurrence.wall_starts_at, event.timezone) <> occurrence.starts_at
|
|
6811
|
+
ORDER BY occurrence.starts_at, occurrence.event_id
|
|
6812
|
+
LIMIT ${limit}`;
|
|
6813
|
+
const reportOccurrence = async (occurrence, type, reason) => {
|
|
6814
|
+
await sql `
|
|
6815
|
+
UPDATE slotlock.calendar_event_occurrences SET zone_shift_reported_at = now()
|
|
6816
|
+
WHERE event_id = ${occurrence.event_id} AND recurrence_id = ${occurrence.recurrence_id}`;
|
|
6817
|
+
const shift = occurrence.moved_start.getTime() - occurrence.starts_at.getTime();
|
|
6818
|
+
await sql `
|
|
6819
|
+
INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
|
|
6820
|
+
VALUES (${params.tenantRef}, ${occurrence.resource_id}, ${type}, ${occurrence.event_id},
|
|
6821
|
+
${sql.json({
|
|
6822
|
+
id: occurrence.event_id,
|
|
6823
|
+
recurrence_id: occurrence.recurrence_id,
|
|
6824
|
+
resource_id: occurrence.resource_id,
|
|
6825
|
+
start: occurrence.starts_at.toISOString(),
|
|
6826
|
+
end: occurrence.ends_at.toISOString(),
|
|
6827
|
+
zone_start: occurrence.moved_start.toISOString(),
|
|
6828
|
+
zone_end: new Date(occurrence.ends_at.getTime() + shift).toISOString(),
|
|
6829
|
+
...(reason ? { reason } : {}),
|
|
6830
|
+
})}, 'slotlock:tzdata')`;
|
|
6831
|
+
};
|
|
6832
|
+
const series = new Map();
|
|
6833
|
+
for (const occurrence of occurrences) {
|
|
6834
|
+
const list = series.get(occurrence.event_id) ?? [];
|
|
6835
|
+
list.push(occurrence);
|
|
6836
|
+
series.set(occurrence.event_id, list);
|
|
6837
|
+
}
|
|
6838
|
+
for (const batch of series.values()) {
|
|
6839
|
+
if (!params.move) {
|
|
6840
|
+
for (const occurrence of batch)
|
|
6841
|
+
await reportOccurrence(occurrence, 'event.timezone_shifted');
|
|
6842
|
+
continue;
|
|
6843
|
+
}
|
|
6844
|
+
// The whole series moves at once: every occurrence to come that shifted, past the batch.
|
|
6845
|
+
const shiftedOccurrences = await sql `
|
|
6846
|
+
SELECT occurrence.event_id, occurrence.recurrence_id, event.resource_id, occurrence.reservation_id,
|
|
6847
|
+
event.timezone, to_char(occurrence.wall_starts_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS wall,
|
|
6848
|
+
occurrence.starts_at, occurrence.ends_at,
|
|
6849
|
+
slotlock.zoned_instant(occurrence.wall_starts_at, event.timezone) AS moved_start
|
|
6850
|
+
FROM slotlock.calendar_event_occurrences occurrence
|
|
6851
|
+
JOIN slotlock.calendar_events event ON event.id = occurrence.event_id
|
|
6852
|
+
WHERE event.tenant_ref = ${params.tenantRef} AND occurrence.event_id = ${batch[0].event_id}
|
|
6853
|
+
AND occurrence.starts_at > now() AND occurrence.zone_shift_reported_at IS NULL
|
|
6854
|
+
AND occurrence.wall_starts_at IS NOT NULL
|
|
6855
|
+
AND slotlock.zoned_instant(occurrence.wall_starts_at, event.timezone) IS NOT NULL
|
|
6856
|
+
AND slotlock.zoned_instant(occurrence.wall_starts_at, event.timezone) <> occurrence.starts_at
|
|
6857
|
+
ORDER BY occurrence.starts_at`;
|
|
6858
|
+
if (shiftedOccurrences.length === 0)
|
|
6859
|
+
continue;
|
|
6860
|
+
// Moved only where this server's zone rules put each wall time where the database's do: a
|
|
6861
|
+
// server with older rules would compute the old times again on the series' next write.
|
|
6862
|
+
const agree = shiftedOccurrences.every((occurrence) => {
|
|
6863
|
+
const wall = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/.exec(occurrence.wall);
|
|
6864
|
+
if (!wall)
|
|
6865
|
+
return false;
|
|
6866
|
+
try {
|
|
6867
|
+
const node = zonedDateTimeToUtc({
|
|
6868
|
+
year: Number(wall[1]),
|
|
6869
|
+
month: Number(wall[2]),
|
|
6870
|
+
day: Number(wall[3]),
|
|
6871
|
+
hour: Number(wall[4]),
|
|
6872
|
+
minute: Number(wall[5]),
|
|
6873
|
+
second: Number(wall[6]),
|
|
6874
|
+
}, occurrence.timezone);
|
|
6875
|
+
return node.getTime() === occurrence.moved_start.getTime();
|
|
6876
|
+
}
|
|
6877
|
+
catch {
|
|
6878
|
+
return false;
|
|
6879
|
+
}
|
|
6880
|
+
});
|
|
6881
|
+
if (!agree) {
|
|
6882
|
+
for (const occurrence of shiftedOccurrences)
|
|
6883
|
+
await reportOccurrence(occurrence, 'event.timezone_shifted', 'rules_disagree');
|
|
6884
|
+
continue;
|
|
6885
|
+
}
|
|
6886
|
+
const eventId = shiftedOccurrences[0].event_id;
|
|
6887
|
+
try {
|
|
6888
|
+
await inTransaction(sql, async (tx) => {
|
|
6889
|
+
await tx `SELECT set_config('slotlock.actor', 'slotlock:tzdata', true)`;
|
|
6890
|
+
for (const occurrence of shiftedOccurrences) {
|
|
6891
|
+
const movedEnd = new Date(occurrence.ends_at.getTime() + occurrence.moved_start.getTime() - occurrence.starts_at.getTime());
|
|
6892
|
+
await tx `
|
|
6893
|
+
UPDATE slotlock.calendar_event_occurrences
|
|
6894
|
+
SET starts_at = ${occurrence.moved_start}, ends_at = ${movedEnd}
|
|
6895
|
+
WHERE event_id = ${eventId} AND recurrence_id = ${occurrence.recurrence_id}`;
|
|
6896
|
+
if (occurrence.reservation_id !== null) {
|
|
6897
|
+
await tx `
|
|
6898
|
+
UPDATE slotlock.reservations SET starts_at = ${occurrence.moved_start}, ends_at = ${movedEnd}
|
|
6899
|
+
WHERE id = ${occurrence.reservation_id}::uuid`;
|
|
6900
|
+
}
|
|
6901
|
+
}
|
|
6902
|
+
// The series at its next revision; its anchor too, while it is still to come.
|
|
6903
|
+
const [bumped] = await tx `
|
|
6904
|
+
UPDATE slotlock.calendar_events
|
|
6905
|
+
SET starts_at = CASE WHEN starts_at > now()
|
|
6906
|
+
THEN COALESCE(slotlock.zoned_instant(wall_starts_at, timezone), starts_at)
|
|
6907
|
+
ELSE starts_at END,
|
|
6908
|
+
ends_at = CASE WHEN starts_at > now()
|
|
6909
|
+
THEN COALESCE(slotlock.zoned_instant(wall_ends_at, timezone), ends_at)
|
|
6910
|
+
ELSE ends_at END,
|
|
6911
|
+
revision = revision + 1, updated_at = now()
|
|
6912
|
+
WHERE id = ${eventId}
|
|
6913
|
+
RETURNING revision`;
|
|
6914
|
+
await tx `UPDATE slotlock.reservations SET revision = ${Number(bumped?.revision)} WHERE calendar_event_id = ${eventId}`;
|
|
6915
|
+
});
|
|
6916
|
+
moved++;
|
|
6917
|
+
}
|
|
6918
|
+
catch (error) {
|
|
6919
|
+
if (pgCode(error) !== EXCLUSION_VIOLATION)
|
|
6920
|
+
throw error;
|
|
6921
|
+
// Something is in the way: the series keeps its times, said once, at its first shifted one.
|
|
6922
|
+
await reportOccurrence(shiftedOccurrences[0], 'event.timezone_conflict');
|
|
6923
|
+
for (const occurrence of shiftedOccurrences.slice(1)) {
|
|
6924
|
+
await sql `
|
|
6925
|
+
UPDATE slotlock.calendar_event_occurrences SET zone_shift_reported_at = now()
|
|
6926
|
+
WHERE event_id = ${occurrence.event_id} AND recurrence_id = ${occurrence.recurrence_id}`;
|
|
6927
|
+
}
|
|
6928
|
+
conflicts++;
|
|
6929
|
+
}
|
|
6930
|
+
}
|
|
6931
|
+
// Reservations that carry the wall time they were booked for (RFC 0004 B9).
|
|
6932
|
+
const reservations = await sql `
|
|
6933
|
+
SELECT booking.id, booking.external_ref, booking.resource_id, booking.starts_at, booking.ends_at,
|
|
6934
|
+
slotlock.zoned_instant(booking.wall_starts_at, resource.timezone) AS moved_start
|
|
6935
|
+
FROM slotlock.reservations booking
|
|
6936
|
+
JOIN slotlock.resources resource ON resource.id = booking.resource_id
|
|
6937
|
+
WHERE booking.tenant_ref = ${params.tenantRef} AND resource.tenant_ref = ${params.tenantRef}
|
|
6938
|
+
AND booking.status = 'confirmed' AND booking.calendar_event_id IS NULL
|
|
6939
|
+
AND booking.starts_at > now() AND booking.wall_starts_at IS NOT NULL
|
|
6940
|
+
AND booking.zone_shift_reported_at IS NULL
|
|
6941
|
+
AND slotlock.zoned_instant(booking.wall_starts_at, resource.timezone) IS NOT NULL
|
|
6942
|
+
AND slotlock.zoned_instant(booking.wall_starts_at, resource.timezone) <> booking.starts_at
|
|
6943
|
+
ORDER BY booking.starts_at, booking.id
|
|
6944
|
+
LIMIT ${limit}`;
|
|
6945
|
+
const reportReservation = async (booking, type) => {
|
|
6946
|
+
await sql `UPDATE slotlock.reservations SET zone_shift_reported_at = now() WHERE id = ${booking.id}`;
|
|
6947
|
+
const shift = booking.moved_start.getTime() - booking.starts_at.getTime();
|
|
6948
|
+
await sql `
|
|
6949
|
+
INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
|
|
6950
|
+
VALUES (${params.tenantRef}, ${booking.resource_id}, ${type}, ${booking.external_ref},
|
|
6951
|
+
${sql.json({
|
|
6952
|
+
id: booking.id,
|
|
6953
|
+
reference: booking.external_ref,
|
|
6954
|
+
resource_id: booking.resource_id,
|
|
6955
|
+
start: booking.starts_at.toISOString(),
|
|
6956
|
+
end: booking.ends_at.toISOString(),
|
|
6957
|
+
zone_start: booking.moved_start.toISOString(),
|
|
6958
|
+
zone_end: new Date(booking.ends_at.getTime() + shift).toISOString(),
|
|
6959
|
+
})}, 'slotlock:tzdata')`;
|
|
6960
|
+
};
|
|
6961
|
+
for (const booking of reservations) {
|
|
6962
|
+
if (!params.move) {
|
|
6963
|
+
await reportReservation(booking, 'reservation.timezone_shifted');
|
|
6964
|
+
continue;
|
|
6965
|
+
}
|
|
6966
|
+
try {
|
|
6967
|
+
await inTransaction(sql, async (tx) => {
|
|
6968
|
+
await tx `SELECT set_config('slotlock.actor', 'slotlock:tzdata', true)`;
|
|
6969
|
+
const shift = booking.moved_start.getTime() - booking.starts_at.getTime();
|
|
6970
|
+
// The wall time it was booked for, at the instant the rules now give it: a move at the
|
|
6971
|
+
// next revision, on the feed as any move.
|
|
6972
|
+
await tx `
|
|
6973
|
+
UPDATE slotlock.reservations
|
|
6974
|
+
SET starts_at = ${booking.moved_start}, ends_at = ${new Date(booking.ends_at.getTime() + shift)},
|
|
6975
|
+
revision = revision + 1
|
|
6976
|
+
WHERE id = ${booking.id}`;
|
|
6977
|
+
});
|
|
6978
|
+
moved++;
|
|
6979
|
+
}
|
|
6980
|
+
catch (error) {
|
|
6981
|
+
if (pgCode(error) !== EXCLUSION_VIOLATION)
|
|
6982
|
+
throw error;
|
|
6983
|
+
await reportReservation(booking, 'reservation.timezone_conflict');
|
|
6984
|
+
conflicts++;
|
|
6985
|
+
}
|
|
6986
|
+
}
|
|
6987
|
+
return { shifted: shifted.length + occurrences.length + reservations.length, moved, conflicts };
|
|
5232
6988
|
},
|
|
5233
6989
|
async pruneBookingRetention(params) {
|
|
5234
6990
|
if (!isValidIdentity(params.tenantRef)) {
|
|
@@ -5311,6 +7067,18 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5311
7067
|
)
|
|
5312
7068
|
SELECT count(*)::int AS deleted FROM deleted`;
|
|
5313
7069
|
const tombstonesDeleted = Number(tombstones?.deleted ?? 0);
|
|
7070
|
+
// Busy time a calendar reported (RFC 0004 C1) that ended a month ago keeps nothing out any
|
|
7071
|
+
// more: it goes, as the feed's changes do, so a person's schedule is not kept for good.
|
|
7072
|
+
const [busy] = await tx `
|
|
7073
|
+
WITH ended AS (
|
|
7074
|
+
SELECT ctid FROM slotlock.source_busy
|
|
7075
|
+
WHERE tenant_ref = ${params.tenantRef}
|
|
7076
|
+
AND ends_at < statement_timestamp() - make_interval(days => ${SOURCE_BUSY_RETENTION_DAYS})
|
|
7077
|
+
LIMIT ${limit}
|
|
7078
|
+
), deleted AS (
|
|
7079
|
+
DELETE FROM slotlock.source_busy busy USING ended WHERE busy.ctid = ended.ctid RETURNING 1
|
|
7080
|
+
)
|
|
7081
|
+
SELECT count(*)::int AS deleted FROM deleted`;
|
|
5314
7082
|
return {
|
|
5315
7083
|
expiredHoldsDeleted,
|
|
5316
7084
|
holdCommandsDeleted,
|
|
@@ -5318,7 +7086,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5318
7086
|
hasMore: legacy.length === limit ||
|
|
5319
7087
|
expiredHoldsDeleted === limit ||
|
|
5320
7088
|
holdCommandsDeleted === limit ||
|
|
5321
|
-
tombstonesDeleted === limit
|
|
7089
|
+
tombstonesDeleted === limit ||
|
|
7090
|
+
Number(busy?.deleted ?? 0) === limit,
|
|
5322
7091
|
};
|
|
5323
7092
|
});
|
|
5324
7093
|
},
|
|
@@ -5488,6 +7257,165 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5488
7257
|
return { ok: true, revision: params.revision, idempotent: false };
|
|
5489
7258
|
});
|
|
5490
7259
|
},
|
|
7260
|
+
async replaceSourceBusy(params) {
|
|
7261
|
+
if (!isValidIdentity(params.tenantRef) ||
|
|
7262
|
+
!isValidIdentity(params.source) ||
|
|
7263
|
+
!isValidResourceId(params.resourceId)) {
|
|
7264
|
+
return { ok: false, code: 'invalid_identity' };
|
|
7265
|
+
}
|
|
7266
|
+
let window;
|
|
7267
|
+
try {
|
|
7268
|
+
window = calendarWindow(params.window);
|
|
7269
|
+
}
|
|
7270
|
+
catch {
|
|
7271
|
+
return { ok: false, code: 'invalid_window' };
|
|
7272
|
+
}
|
|
7273
|
+
const busy = mergeSourceBusy(params.busy, window);
|
|
7274
|
+
if (busy === null)
|
|
7275
|
+
return { ok: false, code: 'invalid_busy' };
|
|
7276
|
+
return inTransaction(sql, async (tx) => {
|
|
7277
|
+
// In turn with every booking on the resource, each of which reads this time (sourceBusyLoss):
|
|
7278
|
+
// a booking either sees the new busy time or is seen by it here.
|
|
7279
|
+
if (!(await lockResourceForWrite(tx, params.resourceId, params.tenantRef))) {
|
|
7280
|
+
return { ok: false, code: 'resource_tenant_mismatch' };
|
|
7281
|
+
}
|
|
7282
|
+
const recorded = await buildSlotlockStore(tx, options, null).recordCalendarCoverage({
|
|
7283
|
+
tenantRef: params.tenantRef,
|
|
7284
|
+
resourceId: params.resourceId,
|
|
7285
|
+
source: params.source,
|
|
7286
|
+
window,
|
|
7287
|
+
revision: params.revision,
|
|
7288
|
+
});
|
|
7289
|
+
if (!recorded.ok)
|
|
7290
|
+
return recorded;
|
|
7291
|
+
if (recorded.idempotent)
|
|
7292
|
+
return { ...recorded, busy: busy.length, conflicts: 0 };
|
|
7293
|
+
const starts = busy.map((interval) => interval.start.toISOString());
|
|
7294
|
+
const ends = busy.map((interval) => interval.end.toISOString());
|
|
7295
|
+
// Confirmed bookings in the way of what the source now says, but not of what it said
|
|
7296
|
+
// before: a conflict already reported is not reported again on every read.
|
|
7297
|
+
const conflicts = await tx `
|
|
7298
|
+
WITH reported AS (
|
|
7299
|
+
SELECT starts_at, ends_at
|
|
7300
|
+
FROM ROWS FROM (pg_catalog.unnest(${starts}::text[]::timestamptz[]),
|
|
7301
|
+
pg_catalog.unnest(${ends}::text[]::timestamptz[])) AS busy(starts_at, ends_at)
|
|
7302
|
+
)
|
|
7303
|
+
SELECT DISTINCT ON (booking.id)
|
|
7304
|
+
booking.id, booking.external_ref, booking.calendar_event_id,
|
|
7305
|
+
booking.starts_at, booking.ends_at,
|
|
7306
|
+
reported.starts_at AS busy_start, reported.ends_at AS busy_end
|
|
7307
|
+
FROM slotlock.reservations booking
|
|
7308
|
+
JOIN reported ON booking.starts_at < reported.ends_at AND booking.ends_at > reported.starts_at
|
|
7309
|
+
WHERE booking.resource_id IN (${compositeKin(tx, params.resourceId)})
|
|
7310
|
+
AND booking.status = 'confirmed'
|
|
7311
|
+
AND booking.starts_at < ${window.end} AND booking.ends_at > ${window.start}
|
|
7312
|
+
AND NOT EXISTS (
|
|
7313
|
+
SELECT 1 FROM slotlock.source_busy previous
|
|
7314
|
+
WHERE previous.resource_id = ${params.resourceId} AND previous.source = ${params.source}
|
|
7315
|
+
AND previous.starts_at < booking.ends_at AND previous.ends_at > booking.starts_at)
|
|
7316
|
+
ORDER BY booking.id, reported.starts_at`;
|
|
7317
|
+
// What the source said of the window goes; what it said outside it stays.
|
|
7318
|
+
await tx `
|
|
7319
|
+
WITH removed AS (
|
|
7320
|
+
DELETE FROM slotlock.source_busy
|
|
7321
|
+
WHERE resource_id = ${params.resourceId} AND source = ${params.source}
|
|
7322
|
+
AND starts_at < ${window.end} AND ends_at > ${window.start}
|
|
7323
|
+
RETURNING starts_at, ends_at
|
|
7324
|
+
)
|
|
7325
|
+
INSERT INTO slotlock.source_busy (tenant_ref, resource_id, source, starts_at, ends_at)
|
|
7326
|
+
SELECT ${params.tenantRef}::text, ${params.resourceId}::uuid, ${params.source}::text, starts_at,
|
|
7327
|
+
${window.start}::timestamptz
|
|
7328
|
+
FROM removed WHERE starts_at < ${window.start}
|
|
7329
|
+
UNION ALL
|
|
7330
|
+
SELECT ${params.tenantRef}::text, ${params.resourceId}::uuid, ${params.source}::text,
|
|
7331
|
+
${window.end}::timestamptz, ends_at
|
|
7332
|
+
FROM removed WHERE ends_at > ${window.end}`;
|
|
7333
|
+
if (busy.length > 0) {
|
|
7334
|
+
await tx `
|
|
7335
|
+
INSERT INTO slotlock.source_busy (tenant_ref, resource_id, source, starts_at, ends_at)
|
|
7336
|
+
SELECT ${params.tenantRef}::text, ${params.resourceId}::uuid, ${params.source}::text, starts_at, ends_at
|
|
7337
|
+
FROM ROWS FROM (pg_catalog.unnest(${starts}::text[]::timestamptz[]),
|
|
7338
|
+
pg_catalog.unnest(${ends}::text[]::timestamptz[])) AS busy(starts_at, ends_at)`;
|
|
7339
|
+
}
|
|
7340
|
+
if (conflicts.length > 0) {
|
|
7341
|
+
// Named as every coverage change is, by the source's source_id (never the source); the
|
|
7342
|
+
// booking it is in the way of is in the data (an event's by its id).
|
|
7343
|
+
await tx `
|
|
7344
|
+
INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
|
|
7345
|
+
SELECT ${params.tenantRef}, ${params.resourceId}, 'coverage.conflict', coverage.source_id,
|
|
7346
|
+
jsonb_strip_nulls(jsonb_build_object(
|
|
7347
|
+
'resource_id', ${params.resourceId}::uuid,
|
|
7348
|
+
'source_id', coverage.source_id,
|
|
7349
|
+
'reason', 'busy_in_calendar',
|
|
7350
|
+
'id', CASE WHEN conflict.event_id IS NULL THEN conflict.id END,
|
|
7351
|
+
'reference', conflict.reference,
|
|
7352
|
+
'event_id', conflict.event_id,
|
|
7353
|
+
'start', conflict.starts_at, 'end', conflict.ends_at,
|
|
7354
|
+
'busy_start', conflict.busy_start, 'busy_end', conflict.busy_end)),
|
|
7355
|
+
NULLIF(current_setting('slotlock.actor', true), '')
|
|
7356
|
+
FROM ROWS FROM (
|
|
7357
|
+
pg_catalog.unnest(${conflicts.map((row) => row.id)}::text[]::uuid[]),
|
|
7358
|
+
pg_catalog.unnest(${conflicts.map((row) => row.external_ref)}::text[]),
|
|
7359
|
+
pg_catalog.unnest(${conflicts.map((row) => row.calendar_event_id)}::text[]::uuid[]),
|
|
7360
|
+
pg_catalog.unnest(${conflicts.map((row) => row.starts_at.toISOString())}::text[]::timestamptz[]),
|
|
7361
|
+
pg_catalog.unnest(${conflicts.map((row) => row.ends_at.toISOString())}::text[]::timestamptz[]),
|
|
7362
|
+
pg_catalog.unnest(${conflicts.map((row) => row.busy_start.toISOString())}::text[]::timestamptz[]),
|
|
7363
|
+
pg_catalog.unnest(${conflicts.map((row) => row.busy_end.toISOString())}::text[]::timestamptz[]))
|
|
7364
|
+
AS conflict(id, reference, event_id, starts_at, ends_at, busy_start, busy_end)
|
|
7365
|
+
CROSS JOIN (SELECT source_id FROM slotlock.calendar_coverage
|
|
7366
|
+
WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
|
|
7367
|
+
AND source = ${params.source}) coverage`;
|
|
7368
|
+
}
|
|
7369
|
+
return { ...recorded, busy: busy.length, conflicts: conflicts.length };
|
|
7370
|
+
}, 'read committed');
|
|
7371
|
+
},
|
|
7372
|
+
async reportCoverageConflict(params) {
|
|
7373
|
+
if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.source) || !isValidResourceId(params.resourceId)) {
|
|
7374
|
+
return { ok: false, code: 'invalid_identity' };
|
|
7375
|
+
}
|
|
7376
|
+
const byEvent = 'eventId' in params.booking;
|
|
7377
|
+
if (byEvent ? !isValidResourceId(params.booking.eventId) : !isValidIdentity(params.booking.reservationRef)) {
|
|
7378
|
+
return { ok: false, code: 'invalid_identity' };
|
|
7379
|
+
}
|
|
7380
|
+
if (params.reason !== 'changed_in_calendar' && params.reason !== 'removed_from_calendar') {
|
|
7381
|
+
return { ok: false, code: 'invalid_identity' };
|
|
7382
|
+
}
|
|
7383
|
+
if (params.calendar !== undefined && !isFiniteWindow(params.calendar))
|
|
7384
|
+
return { ok: false, code: 'invalid_window' };
|
|
7385
|
+
return inTransaction(sql, async (tx) => {
|
|
7386
|
+
const [coverage] = await tx `
|
|
7387
|
+
SELECT source_id FROM slotlock.calendar_coverage
|
|
7388
|
+
WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId} AND source = ${params.source}`;
|
|
7389
|
+
if (!coverage)
|
|
7390
|
+
return { ok: false, code: 'coverage_not_found' };
|
|
7391
|
+
const [booking] = byEvent
|
|
7392
|
+
? await tx `
|
|
7393
|
+
SELECT id, NULL::text AS reference, starts_at, ends_at FROM slotlock.calendar_events
|
|
7394
|
+
WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
|
|
7395
|
+
AND id = ${params.booking.eventId}::uuid`
|
|
7396
|
+
: await tx `
|
|
7397
|
+
SELECT id, external_ref AS reference, starts_at, ends_at FROM slotlock.reservations
|
|
7398
|
+
WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
|
|
7399
|
+
AND external_ref = ${params.booking.reservationRef}
|
|
7400
|
+
AND calendar_event_id IS NULL AND status = 'confirmed'`;
|
|
7401
|
+
if (!booking) {
|
|
7402
|
+
return { ok: false, code: byEvent ? 'event_not_found' : 'reservation_not_found' };
|
|
7403
|
+
}
|
|
7404
|
+
await tx `
|
|
7405
|
+
INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
|
|
7406
|
+
VALUES (${params.tenantRef}, ${params.resourceId}, 'coverage.conflict', ${coverage.source_id},
|
|
7407
|
+
jsonb_strip_nulls(jsonb_build_object(
|
|
7408
|
+
'resource_id', ${params.resourceId}::uuid, 'source_id', ${coverage.source_id}::text,
|
|
7409
|
+
'reason', ${params.reason}::text,
|
|
7410
|
+
'id', ${byEvent ? null : booking.id}::uuid, 'reference', ${booking.reference}::text,
|
|
7411
|
+
'event_id', ${byEvent ? booking.id : null}::uuid,
|
|
7412
|
+
'start', ${booking.starts_at}::timestamptz, 'end', ${booking.ends_at}::timestamptz,
|
|
7413
|
+
'calendar_start', ${params.calendar?.start ?? null}::timestamptz,
|
|
7414
|
+
'calendar_end', ${params.calendar?.end ?? null}::timestamptz)),
|
|
7415
|
+
NULLIF(current_setting('slotlock.actor', true), ''))`;
|
|
7416
|
+
return { ok: true };
|
|
7417
|
+
}, 'read committed');
|
|
7418
|
+
},
|
|
5491
7419
|
async recordCoverageFailure(params) {
|
|
5492
7420
|
if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.source) || !isValidResourceId(params.resourceId)) {
|
|
5493
7421
|
return { ok: false, code: 'invalid_identity' };
|
|
@@ -5506,6 +7434,11 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5506
7434
|
return false;
|
|
5507
7435
|
}
|
|
5508
7436
|
const rows = await sql `
|
|
7437
|
+
WITH busy AS (
|
|
7438
|
+
DELETE FROM slotlock.source_busy
|
|
7439
|
+
WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
|
|
7440
|
+
AND source = ${params.source}
|
|
7441
|
+
)
|
|
5509
7442
|
DELETE FROM slotlock.calendar_coverage
|
|
5510
7443
|
WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
|
|
5511
7444
|
AND source = ${params.source}
|
|
@@ -5535,6 +7468,110 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5535
7468
|
stale: row.stale,
|
|
5536
7469
|
}));
|
|
5537
7470
|
},
|
|
7471
|
+
async dueReminders(params) {
|
|
7472
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
7473
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
7474
|
+
}
|
|
7475
|
+
const limit = params.limit ?? 500;
|
|
7476
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 5_000) {
|
|
7477
|
+
throw new RangeError('limit must be a whole number from 1 to 5000');
|
|
7478
|
+
}
|
|
7479
|
+
const rows = await sql `
|
|
7480
|
+
WITH candidates AS (
|
|
7481
|
+
-- Reminders due at the same time are said once (the first in the list).
|
|
7482
|
+
SELECT DISTINCT ON (occurrence.event_id, occurrence.recurrence_id,
|
|
7483
|
+
occurrence.starts_at - make_interval(mins => reminder.minutes_before_start))
|
|
7484
|
+
occurrence.event_id, occurrence.recurrence_id, reminder.ordinal, reminder.action,
|
|
7485
|
+
reminder.minutes_before_start, occurrence.starts_at, occurrence.ends_at,
|
|
7486
|
+
event.resource_id,
|
|
7487
|
+
occurrence.starts_at - make_interval(mins => reminder.minutes_before_start) AS due_at
|
|
7488
|
+
FROM slotlock.calendar_event_occurrences occurrence
|
|
7489
|
+
JOIN slotlock.calendar_events event
|
|
7490
|
+
ON event.id = occurrence.event_id AND event.tenant_ref = occurrence.tenant_ref
|
|
7491
|
+
JOIN slotlock.calendar_event_reminders reminder
|
|
7492
|
+
ON reminder.event_id = occurrence.event_id AND reminder.tenant_ref = occurrence.tenant_ref
|
|
7493
|
+
WHERE occurrence.tenant_ref = ${params.tenantRef}
|
|
7494
|
+
AND event.notify AND event.status <> 'cancelled'
|
|
7495
|
+
AND occurrence.starts_at > statement_timestamp()
|
|
7496
|
+
AND occurrence.starts_at - make_interval(mins => reminder.minutes_before_start)
|
|
7497
|
+
<= statement_timestamp()
|
|
7498
|
+
AND occurrence.starts_at - make_interval(mins => reminder.minutes_before_start)
|
|
7499
|
+
> statement_timestamp() - interval '1 day'
|
|
7500
|
+
-- Said once per time it is due, whatever its place in the list: one added or
|
|
7501
|
+
-- reordered says nothing again; an occurrence moved since is reminded again.
|
|
7502
|
+
AND NOT EXISTS (
|
|
7503
|
+
SELECT 1 FROM slotlock.reminder_dispatches sent
|
|
7504
|
+
WHERE sent.event_id = occurrence.event_id AND sent.recurrence_id = occurrence.recurrence_id
|
|
7505
|
+
AND sent.due_at = occurrence.starts_at - make_interval(mins => reminder.minutes_before_start))
|
|
7506
|
+
ORDER BY occurrence.event_id, occurrence.recurrence_id,
|
|
7507
|
+
occurrence.starts_at - make_interval(mins => reminder.minutes_before_start), reminder.ordinal
|
|
7508
|
+
), due AS (
|
|
7509
|
+
SELECT * FROM candidates ORDER BY due_at, event_id, recurrence_id LIMIT ${limit}
|
|
7510
|
+
), dispatched AS (
|
|
7511
|
+
INSERT INTO slotlock.reminder_dispatches (tenant_ref, event_id, recurrence_id, ordinal, due_at)
|
|
7512
|
+
SELECT ${params.tenantRef}, event_id, recurrence_id, ordinal, due_at FROM due
|
|
7513
|
+
ON CONFLICT (event_id, recurrence_id, due_at) DO NOTHING
|
|
7514
|
+
RETURNING event_id, recurrence_id, due_at
|
|
7515
|
+
), reported AS (
|
|
7516
|
+
INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
|
|
7517
|
+
SELECT ${params.tenantRef}, due.resource_id, 'reminder.due', due.event_id::text,
|
|
7518
|
+
jsonb_build_object(
|
|
7519
|
+
'event_id', due.event_id, 'recurrence_id', due.recurrence_id,
|
|
7520
|
+
'resource_id', due.resource_id, 'ordinal', due.ordinal, 'action', due.action,
|
|
7521
|
+
'minutes_before_start', due.minutes_before_start, 'due_at', due.due_at,
|
|
7522
|
+
'start', due.starts_at, 'end', due.ends_at),
|
|
7523
|
+
'slotlock:maintenance'
|
|
7524
|
+
FROM due JOIN dispatched USING (event_id, recurrence_id, due_at)
|
|
7525
|
+
RETURNING 1
|
|
7526
|
+
)
|
|
7527
|
+
SELECT count(*)::int AS count FROM reported`;
|
|
7528
|
+
const events = rows[0]?.count ?? 0;
|
|
7529
|
+
if (events >= limit)
|
|
7530
|
+
return events;
|
|
7531
|
+
// A booking's reminders too, named by its reference, with whom to tell.
|
|
7532
|
+
const [bookings] = await sql `
|
|
7533
|
+
WITH candidates AS (
|
|
7534
|
+
SELECT DISTINCT ON (booking.id, booking.starts_at - make_interval(mins => reminder.minutes))
|
|
7535
|
+
booking.id, booking.external_ref, booking.resource_id, booking.contact_ref,
|
|
7536
|
+
booking.starts_at, booking.ends_at, (reminder.ordinal - 1)::int AS ordinal,
|
|
7537
|
+
reminder.minutes,
|
|
7538
|
+
booking.starts_at - make_interval(mins => reminder.minutes) AS due_at
|
|
7539
|
+
FROM slotlock.reservations booking,
|
|
7540
|
+
LATERAL unnest(booking.reminder_minutes) WITH ORDINALITY AS reminder(minutes, ordinal)
|
|
7541
|
+
WHERE booking.tenant_ref = ${params.tenantRef} AND booking.status = 'confirmed'
|
|
7542
|
+
AND booking.calendar_event_id IS NULL AND booking.notify
|
|
7543
|
+
AND booking.reminder_minutes IS NOT NULL
|
|
7544
|
+
AND booking.starts_at > statement_timestamp()
|
|
7545
|
+
AND booking.starts_at - make_interval(mins => reminder.minutes) <= statement_timestamp()
|
|
7546
|
+
AND booking.starts_at - make_interval(mins => reminder.minutes) > statement_timestamp() - interval '1 day'
|
|
7547
|
+
-- Said once per time it is due, as an event's reminders are.
|
|
7548
|
+
AND NOT EXISTS (
|
|
7549
|
+
SELECT 1 FROM slotlock.reservation_reminder_dispatches sent
|
|
7550
|
+
WHERE sent.reservation_id = booking.id
|
|
7551
|
+
AND sent.due_at = booking.starts_at - make_interval(mins => reminder.minutes))
|
|
7552
|
+
ORDER BY booking.id, booking.starts_at - make_interval(mins => reminder.minutes), reminder.ordinal
|
|
7553
|
+
), due AS (
|
|
7554
|
+
SELECT * FROM candidates ORDER BY due_at, id LIMIT ${limit - events}
|
|
7555
|
+
), dispatched AS (
|
|
7556
|
+
INSERT INTO slotlock.reservation_reminder_dispatches (tenant_ref, reservation_id, ordinal, due_at)
|
|
7557
|
+
SELECT ${params.tenantRef}, id, ordinal, due_at FROM due
|
|
7558
|
+
ON CONFLICT (reservation_id, due_at) DO NOTHING
|
|
7559
|
+
RETURNING reservation_id AS id, due_at
|
|
7560
|
+
), reported AS (
|
|
7561
|
+
INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
|
|
7562
|
+
SELECT ${params.tenantRef}, due.resource_id, 'reminder.due', due.external_ref,
|
|
7563
|
+
jsonb_strip_nulls(jsonb_build_object(
|
|
7564
|
+
'reservation_id', due.id, 'reference', due.external_ref,
|
|
7565
|
+
'resource_id', due.resource_id, 'contact_ref', due.contact_ref,
|
|
7566
|
+
'ordinal', due.ordinal, 'minutes_before_start', due.minutes, 'due_at', due.due_at,
|
|
7567
|
+
'start', due.starts_at, 'end', due.ends_at)),
|
|
7568
|
+
'slotlock:maintenance'
|
|
7569
|
+
FROM due JOIN dispatched USING (id, due_at)
|
|
7570
|
+
RETURNING 1
|
|
7571
|
+
)
|
|
7572
|
+
SELECT count(*)::int AS count FROM reported`;
|
|
7573
|
+
return events + (bookings?.count ?? 0);
|
|
7574
|
+
},
|
|
5538
7575
|
async reportStaleCoverage(params) {
|
|
5539
7576
|
if (!isValidIdentity(params.tenantRef)) {
|
|
5540
7577
|
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
@@ -5604,6 +7641,45 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5604
7641
|
return { ok: true, reference: params.externalRef, checkedInAt: row.checked_in_at };
|
|
5605
7642
|
});
|
|
5606
7643
|
},
|
|
7644
|
+
async completeEndedBookings(params) {
|
|
7645
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
7646
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
7647
|
+
}
|
|
7648
|
+
const limit = params.limit ?? 500;
|
|
7649
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 5_000) {
|
|
7650
|
+
throw invalidIdentityError('Slotlock completion limit must be 1-5,000');
|
|
7651
|
+
}
|
|
7652
|
+
// A suspended tenant's bookings stay as they are.
|
|
7653
|
+
if (refusal)
|
|
7654
|
+
return 0;
|
|
7655
|
+
const rows = await sql `
|
|
7656
|
+
WITH done AS (
|
|
7657
|
+
UPDATE slotlock.reservations booking
|
|
7658
|
+
SET completed_at = statement_timestamp()
|
|
7659
|
+
WHERE booking.id IN (
|
|
7660
|
+
SELECT id FROM slotlock.reservations
|
|
7661
|
+
WHERE tenant_ref = ${params.tenantRef} AND status = 'confirmed'
|
|
7662
|
+
AND calendar_event_id IS NULL AND completed_at IS NULL
|
|
7663
|
+
AND ends_at <= statement_timestamp()
|
|
7664
|
+
AND ends_at > statement_timestamp() - interval '7 days'
|
|
7665
|
+
ORDER BY ends_at, id
|
|
7666
|
+
LIMIT ${limit}
|
|
7667
|
+
FOR UPDATE SKIP LOCKED)
|
|
7668
|
+
RETURNING booking.id, booking.resource_id, booking.external_ref, booking.starts_at,
|
|
7669
|
+
booking.ends_at, booking.revision, booking.checked_in_at
|
|
7670
|
+
)
|
|
7671
|
+
INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
|
|
7672
|
+
SELECT ${params.tenantRef}, done.resource_id, 'reservation.completed',
|
|
7673
|
+
COALESCE(done.external_ref, done.id::text),
|
|
7674
|
+
jsonb_strip_nulls(jsonb_build_object(
|
|
7675
|
+
'id', done.id, 'reference', done.external_ref, 'resource_id', done.resource_id,
|
|
7676
|
+
'start', done.starts_at, 'end', done.ends_at, 'revision', done.revision,
|
|
7677
|
+
'checked_in_at', done.checked_in_at)),
|
|
7678
|
+
'slotlock:maintenance'
|
|
7679
|
+
FROM done
|
|
7680
|
+
RETURNING id`;
|
|
7681
|
+
return rows.length;
|
|
7682
|
+
},
|
|
5607
7683
|
async releaseNoShows(params) {
|
|
5608
7684
|
if (!isValidIdentity(params.tenantRef)) {
|
|
5609
7685
|
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
@@ -5645,8 +7721,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5645
7721
|
return false;
|
|
5646
7722
|
const revision = Number(row.revision) + 1;
|
|
5647
7723
|
await tx `
|
|
5648
|
-
INSERT INTO slotlock.reservation_tombstones (tenant_ref, external_ref, revision, stray)
|
|
5649
|
-
VALUES (${tenantRef}, ${row.external_ref}, ${revision}, false)`;
|
|
7724
|
+
INSERT INTO slotlock.reservation_tombstones (tenant_ref, external_ref, revision, stray, end_reason)
|
|
7725
|
+
VALUES (${tenantRef}, ${row.external_ref}, ${revision}, false, 'no_show')`;
|
|
5650
7726
|
// The deletion is the change: the feed's trigger names it reservation.no_show (one change,
|
|
5651
7727
|
// not a cancellation too), by maintenance, with the check-in window it missed.
|
|
5652
7728
|
await tx `
|
|
@@ -5684,6 +7760,14 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5684
7760
|
if (!Number.isSafeInteger(offerTtlMs) || offerTtlMs < 60_000 || offerTtlMs > 86_400_000) {
|
|
5685
7761
|
return { ok: false, code: 'invalid_ttl' };
|
|
5686
7762
|
}
|
|
7763
|
+
const durationMs = params.durationMs ?? null;
|
|
7764
|
+
if (durationMs !== null &&
|
|
7765
|
+
(!Number.isSafeInteger(durationMs) ||
|
|
7766
|
+
durationMs < 60_000 ||
|
|
7767
|
+
durationMs % 60_000 !== 0 ||
|
|
7768
|
+
durationMs > window.end.getTime() - window.start.getTime())) {
|
|
7769
|
+
return { ok: false, code: 'invalid_window' };
|
|
7770
|
+
}
|
|
5687
7771
|
const key = params.idempotencyKey;
|
|
5688
7772
|
if (key !== undefined && !isValidIdentity(key))
|
|
5689
7773
|
return { ok: false, code: 'invalid_identity' };
|
|
@@ -5697,6 +7781,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5697
7781
|
window.end.toISOString(),
|
|
5698
7782
|
holderRef,
|
|
5699
7783
|
offerTtlMs,
|
|
7784
|
+
...(durationMs === null ? [] : [durationMs]),
|
|
5700
7785
|
]))
|
|
5701
7786
|
.digest('hex');
|
|
5702
7787
|
return inTransaction(sql, async (tx) => {
|
|
@@ -5742,11 +7827,20 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5742
7827
|
const [row] = await tx `
|
|
5743
7828
|
INSERT INTO slotlock.waitlist_entries
|
|
5744
7829
|
(tenant_ref, resource_id, pool_ref, starts_at, ends_at, holder_ref, offer_ttl_ms,
|
|
5745
|
-
idempotency_key, payload_hash)
|
|
7830
|
+
idempotency_key, payload_hash, duration_ms)
|
|
5746
7831
|
VALUES (${tenantRef}, ${params.resourceId ?? null}, ${params.pool ?? null}, ${window.start},
|
|
5747
|
-
${window.end}, ${holderRef}, ${offerTtlMs}, ${key ?? null}, ${payloadHash})
|
|
7832
|
+
${window.end}, ${holderRef}, ${offerTtlMs}, ${key ?? null}, ${payloadHash}, ${durationMs})
|
|
5748
7833
|
RETURNING ${tx.unsafe(WAITLIST_COLUMNS)}`;
|
|
5749
|
-
|
|
7834
|
+
const entry = row;
|
|
7835
|
+
await tx `
|
|
7836
|
+
INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
|
|
7837
|
+
VALUES (${tenantRef}, ${entry.resource_id}::uuid, 'waitlist.joined', ${entry.id},
|
|
7838
|
+
jsonb_strip_nulls(jsonb_build_object(
|
|
7839
|
+
'id', ${entry.id}::uuid, 'resource_id', ${entry.resource_id}::uuid, 'pool', ${entry.pool_ref}::text,
|
|
7840
|
+
'start', ${entry.starts_at}::timestamptz, 'end', ${entry.ends_at}::timestamptz,
|
|
7841
|
+
'duration_ms', ${durationMs}::bigint, 'holder_ref', ${entry.holder_ref}::text)),
|
|
7842
|
+
NULLIF(current_setting('slotlock.actor', true), ''))`;
|
|
7843
|
+
return { ok: true, entry: toWaitlistEntry(entry), replayed: false };
|
|
5750
7844
|
});
|
|
5751
7845
|
},
|
|
5752
7846
|
async listWaitlist(params) {
|
|
@@ -5776,9 +7870,19 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5776
7870
|
DELETE FROM slotlock.waitlist_entries
|
|
5777
7871
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
5778
7872
|
AND (${params.holderRef ?? null}::text IS NULL OR holder_ref = ${params.holderRef ?? null})
|
|
5779
|
-
RETURNING hold_id`;
|
|
7873
|
+
RETURNING hold_id, status, resource_id, pool_ref, holder_ref`;
|
|
5780
7874
|
if (!left)
|
|
5781
7875
|
return false;
|
|
7876
|
+
await tx `
|
|
7877
|
+
INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
|
|
7878
|
+
VALUES (${params.tenantRef}, ${left.resource_id}::uuid, 'waitlist.left', ${params.id},
|
|
7879
|
+
jsonb_strip_nulls(jsonb_build_object(
|
|
7880
|
+
'id', ${params.id}::uuid, 'resource_id', ${left.resource_id}::uuid, 'pool', ${left.pool_ref}::text,
|
|
7881
|
+
'status', ${left.status}::text, 'holder_ref', ${left.holder_ref}::text)),
|
|
7882
|
+
NULLIF(current_setting('slotlock.actor', true), ''))`;
|
|
7883
|
+
// An offer still open goes with it; the entry is gone, so its hold's end changes no entry.
|
|
7884
|
+
if (left.status !== 'offered')
|
|
7885
|
+
return true;
|
|
5782
7886
|
// An untaken offer's hold goes too; a confirmed one is a booking now and stays.
|
|
5783
7887
|
if (left.hold_id) {
|
|
5784
7888
|
await buildSlotlockStore(tx, options, null).releaseHold(left.hold_id, { tenantRef: params.tenantRef });
|
|
@@ -5786,6 +7890,57 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5786
7890
|
return true;
|
|
5787
7891
|
});
|
|
5788
7892
|
},
|
|
7893
|
+
async holdGroup(params) {
|
|
7894
|
+
if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.holderRef) || !isValidResourceId(params.groupId)) {
|
|
7895
|
+
return [];
|
|
7896
|
+
}
|
|
7897
|
+
if (params.alignExpiry === true) {
|
|
7898
|
+
await sql `
|
|
7899
|
+
UPDATE slotlock.reservations held
|
|
7900
|
+
SET expires_at = earliest.at
|
|
7901
|
+
FROM (SELECT min(expires_at) AS at FROM slotlock.reservations
|
|
7902
|
+
WHERE hold_group_id = ${params.groupId}::uuid AND holder_ref = ${params.holderRef}
|
|
7903
|
+
AND status = 'held') earliest
|
|
7904
|
+
WHERE held.hold_group_id = ${params.groupId}::uuid AND held.holder_ref = ${params.holderRef}
|
|
7905
|
+
AND held.status = 'held' AND held.expires_at <> earliest.at`;
|
|
7906
|
+
}
|
|
7907
|
+
const rows = await sql `
|
|
7908
|
+
SELECT held.id, held.resource_id, held.starts_at, held.ends_at, held.expires_at, held.source
|
|
7909
|
+
FROM slotlock.reservations held
|
|
7910
|
+
JOIN slotlock.resources resource ON resource.id = held.resource_id
|
|
7911
|
+
WHERE held.hold_group_id = ${params.groupId}::uuid AND held.holder_ref = ${params.holderRef}
|
|
7912
|
+
AND resource.tenant_ref = ${params.tenantRef} AND held.status = 'held'
|
|
7913
|
+
AND held.expires_at > statement_timestamp()
|
|
7914
|
+
ORDER BY held.resource_id`;
|
|
7915
|
+
return rows.map((row) => ({
|
|
7916
|
+
holdId: row.id,
|
|
7917
|
+
resourceId: row.resource_id,
|
|
7918
|
+
start: row.starts_at,
|
|
7919
|
+
end: row.ends_at,
|
|
7920
|
+
expiresAt: row.expires_at,
|
|
7921
|
+
source: row.source,
|
|
7922
|
+
}));
|
|
7923
|
+
},
|
|
7924
|
+
async declineWaitlistOffer(params) {
|
|
7925
|
+
if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id))
|
|
7926
|
+
return null;
|
|
7927
|
+
if (params.holderRef !== undefined && !isValidIdentity(params.holderRef))
|
|
7928
|
+
return null;
|
|
7929
|
+
return inTransaction(sql, async (tx) => {
|
|
7930
|
+
const [offer] = await tx `
|
|
7931
|
+
SELECT hold_id FROM slotlock.waitlist_entries
|
|
7932
|
+
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id} AND status = 'offered'
|
|
7933
|
+
AND (${params.holderRef ?? null}::text IS NULL OR holder_ref = ${params.holderRef ?? null})
|
|
7934
|
+
FOR UPDATE`;
|
|
7935
|
+
if (!offer)
|
|
7936
|
+
return null;
|
|
7937
|
+
// Letting the hold go declines the entry (slotlock.waitlist_offer_settle) and frees the time.
|
|
7938
|
+
await buildSlotlockStore(tx, options, null).releaseHold(offer.hold_id, { tenantRef: params.tenantRef });
|
|
7939
|
+
const [row] = await tx `
|
|
7940
|
+
SELECT ${tx.unsafe(WAITLIST_COLUMNS)} FROM slotlock.waitlist_entries WHERE id = ${params.id}`;
|
|
7941
|
+
return row ? toWaitlistEntry(row) : null;
|
|
7942
|
+
});
|
|
7943
|
+
},
|
|
5789
7944
|
async getAssistantSettings(params) {
|
|
5790
7945
|
if (!isValidIdentity(params.tenantRef))
|
|
5791
7946
|
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
@@ -5847,42 +8002,106 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5847
8002
|
RETURNING 1`;
|
|
5848
8003
|
return rows.length > 0;
|
|
5849
8004
|
},
|
|
5850
|
-
async
|
|
8005
|
+
async listAssistantChats(params) {
|
|
5851
8006
|
if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.memberRef))
|
|
8007
|
+
return [];
|
|
8008
|
+
const limit = params.limit ?? SLOTLOCK_ASSISTANT_MAX_CHATS;
|
|
8009
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > SLOTLOCK_ASSISTANT_MAX_CHATS) {
|
|
8010
|
+
throw invalidIdentityError(`Slotlock assistant chat list limit must be 1-${String(SLOTLOCK_ASSISTANT_MAX_CHATS)}`);
|
|
8011
|
+
}
|
|
8012
|
+
const rows = await sql `
|
|
8013
|
+
SELECT chat_id, title, updated_at FROM slotlock.assistant_chats
|
|
8014
|
+
WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef}
|
|
8015
|
+
AND (expires_at IS NULL OR expires_at > statement_timestamp())
|
|
8016
|
+
ORDER BY updated_at DESC, chat_id
|
|
8017
|
+
LIMIT ${limit}`;
|
|
8018
|
+
return rows.map((row) => ({ id: row.chat_id, title: row.title, updatedAt: row.updated_at }));
|
|
8019
|
+
},
|
|
8020
|
+
async getAssistantChat(params) {
|
|
8021
|
+
if (!isValidIdentity(params.tenantRef) ||
|
|
8022
|
+
!isValidIdentity(params.memberRef) ||
|
|
8023
|
+
!SLOTLOCK_ASSISTANT_CHAT_ID.test(params.chatId)) {
|
|
5852
8024
|
return null;
|
|
8025
|
+
}
|
|
5853
8026
|
const [row] = await sql `
|
|
5854
|
-
SELECT messages, actions, updated_at, expires_at FROM slotlock.
|
|
8027
|
+
SELECT title, messages, actions, created_at, updated_at, expires_at FROM slotlock.assistant_chats
|
|
5855
8028
|
WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef}
|
|
5856
|
-
AND
|
|
8029
|
+
AND chat_id = ${params.chatId}
|
|
8030
|
+
AND (expires_at IS NULL OR expires_at > statement_timestamp())`;
|
|
5857
8031
|
if (!row)
|
|
5858
8032
|
return null;
|
|
5859
8033
|
return {
|
|
8034
|
+
id: params.chatId,
|
|
8035
|
+
title: row.title,
|
|
5860
8036
|
messages: row.messages,
|
|
5861
8037
|
actions: row.actions,
|
|
8038
|
+
createdAt: row.created_at,
|
|
5862
8039
|
updatedAt: row.updated_at,
|
|
5863
8040
|
expiresAt: row.expires_at,
|
|
5864
8041
|
};
|
|
5865
8042
|
},
|
|
5866
|
-
async
|
|
8043
|
+
async putAssistantChat(params) {
|
|
5867
8044
|
if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.memberRef)) {
|
|
5868
8045
|
throw invalidIdentityError('Slotlock tenant and member references must be 1-500 UTF-8 bytes');
|
|
5869
8046
|
}
|
|
5870
|
-
if (!
|
|
5871
|
-
throw invalidIdentityError('Slotlock assistant
|
|
8047
|
+
if (!SLOTLOCK_ASSISTANT_CHAT_ID.test(params.chatId)) {
|
|
8048
|
+
throw invalidIdentityError('Slotlock assistant chat id must be 16 URL-safe characters');
|
|
8049
|
+
}
|
|
8050
|
+
const title = params.title.trim();
|
|
8051
|
+
if ([...title].length < 1 || [...title].length > 120) {
|
|
8052
|
+
throw invalidIdentityError('Slotlock assistant chat title must be 1-120 characters');
|
|
5872
8053
|
}
|
|
8054
|
+
if (params.ttlMs !== null &&
|
|
8055
|
+
(!Number.isSafeInteger(params.ttlMs) || params.ttlMs < 1_000 || params.ttlMs > 3_650 * 86_400_000)) {
|
|
8056
|
+
throw invalidIdentityError('Slotlock assistant conversation ttlMs must be 1 second to 3,650 days');
|
|
8057
|
+
}
|
|
8058
|
+
const ttlSeconds = params.ttlMs === null ? null : params.ttlMs / 1_000;
|
|
5873
8059
|
const messages = sql.json(params.messages);
|
|
5874
8060
|
const added = sql.json(params.added);
|
|
5875
8061
|
const keep = [...params.keep];
|
|
8062
|
+
// One member's chats are written one at a time, so the trim below sees every insert before it.
|
|
5876
8063
|
await sql `
|
|
5877
|
-
|
|
5878
|
-
|
|
8064
|
+
SELECT pg_advisory_xact_lock(hashtextextended(
|
|
8065
|
+
${`slotlock:assistant-chats:${params.tenantRef}\n${params.memberRef}`}::text, 0))`;
|
|
8066
|
+
if (params.create === false) {
|
|
8067
|
+
// Only one still there: a chat deleted while its turn ran stays deleted.
|
|
8068
|
+
const changed = await sql `
|
|
8069
|
+
UPDATE slotlock.assistant_chats AS stored
|
|
8070
|
+
SET messages = ${messages},
|
|
8071
|
+
actions = (
|
|
8072
|
+
SELECT coalesce(jsonb_object_agg(existing.key, existing.value), '{}'::jsonb)
|
|
8073
|
+
FROM jsonb_each(stored.actions) AS existing
|
|
8074
|
+
WHERE existing.key = ANY(${keep}::text[])
|
|
8075
|
+
) || (
|
|
8076
|
+
SELECT coalesce(jsonb_object_agg(fresh.key, fresh.value), '{}'::jsonb)
|
|
8077
|
+
FROM jsonb_each(${added}::jsonb) AS fresh
|
|
8078
|
+
WHERE NOT stored.actions ? fresh.key
|
|
8079
|
+
),
|
|
8080
|
+
updated_at = statement_timestamp(),
|
|
8081
|
+
expires_at = CASE WHEN ${ttlSeconds}::double precision IS NULL THEN NULL
|
|
8082
|
+
ELSE statement_timestamp() + make_interval(secs => ${ttlSeconds}::double precision) END
|
|
8083
|
+
WHERE stored.tenant_ref = ${params.tenantRef} AND stored.member_ref = ${params.memberRef}
|
|
8084
|
+
AND stored.chat_id = ${params.chatId}
|
|
8085
|
+
AND (stored.expires_at IS NULL OR stored.expires_at > statement_timestamp())
|
|
8086
|
+
RETURNING 1`;
|
|
8087
|
+
if (changed.length === 0)
|
|
8088
|
+
return false;
|
|
8089
|
+
if (ttlSeconds !== null) {
|
|
8090
|
+
await sql `
|
|
8091
|
+
SELECT slotlock.register_assistant_tenant(
|
|
8092
|
+
${params.tenantRef}::text,
|
|
8093
|
+
statement_timestamp() + make_interval(secs => ${ttlSeconds}::double precision))`;
|
|
8094
|
+
}
|
|
8095
|
+
return true;
|
|
8096
|
+
}
|
|
5879
8097
|
await sql `
|
|
5880
|
-
INSERT INTO slotlock.
|
|
5881
|
-
(tenant_ref, member_ref, messages, actions, updated_at, expires_at)
|
|
5882
|
-
VALUES (${params.tenantRef}, ${params.memberRef}, ${messages}, ${added},
|
|
5883
|
-
statement_timestamp(),
|
|
5884
|
-
|
|
5885
|
-
|
|
8098
|
+
INSERT INTO slotlock.assistant_chats AS stored
|
|
8099
|
+
(tenant_ref, member_ref, chat_id, title, messages, actions, created_at, updated_at, expires_at)
|
|
8100
|
+
VALUES (${params.tenantRef}, ${params.memberRef}, ${params.chatId}, ${title}, ${messages}, ${added},
|
|
8101
|
+
statement_timestamp(), statement_timestamp(),
|
|
8102
|
+
CASE WHEN ${ttlSeconds}::double precision IS NULL THEN NULL
|
|
8103
|
+
ELSE statement_timestamp() + make_interval(secs => ${ttlSeconds}::double precision) END)
|
|
8104
|
+
ON CONFLICT (tenant_ref, member_ref, chat_id) DO UPDATE SET
|
|
5886
8105
|
messages = EXCLUDED.messages,
|
|
5887
8106
|
-- The stored ones kept, as they stand, then the new ones: never a settled one again.
|
|
5888
8107
|
actions = (
|
|
@@ -5896,21 +8115,55 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5896
8115
|
),
|
|
5897
8116
|
updated_at = EXCLUDED.updated_at,
|
|
5898
8117
|
expires_at = EXCLUDED.expires_at`;
|
|
8118
|
+
// The member keeps the most recent; past the limit, the least recently used go.
|
|
5899
8119
|
await sql `
|
|
5900
|
-
|
|
5901
|
-
|
|
5902
|
-
|
|
8120
|
+
DELETE FROM slotlock.assistant_chats gone
|
|
8121
|
+
WHERE gone.tenant_ref = ${params.tenantRef} AND gone.member_ref = ${params.memberRef}
|
|
8122
|
+
AND gone.chat_id IN (
|
|
8123
|
+
SELECT kept.chat_id FROM slotlock.assistant_chats kept
|
|
8124
|
+
WHERE kept.tenant_ref = ${params.tenantRef} AND kept.member_ref = ${params.memberRef}
|
|
8125
|
+
ORDER BY kept.updated_at DESC, kept.chat_id
|
|
8126
|
+
OFFSET ${SLOTLOCK_ASSISTANT_MAX_CHATS})`;
|
|
8127
|
+
if (ttlSeconds !== null) {
|
|
8128
|
+
await sql `
|
|
8129
|
+
SELECT slotlock.register_assistant_tenant(
|
|
8130
|
+
${params.tenantRef}::text,
|
|
8131
|
+
statement_timestamp() + make_interval(secs => ${ttlSeconds}::double precision))`;
|
|
8132
|
+
}
|
|
8133
|
+
return true;
|
|
8134
|
+
},
|
|
8135
|
+
async renameAssistantChat(params) {
|
|
8136
|
+
if (!isValidIdentity(params.tenantRef) ||
|
|
8137
|
+
!isValidIdentity(params.memberRef) ||
|
|
8138
|
+
!SLOTLOCK_ASSISTANT_CHAT_ID.test(params.chatId)) {
|
|
8139
|
+
return false;
|
|
8140
|
+
}
|
|
8141
|
+
const title = params.title.trim();
|
|
8142
|
+
if ([...title].length < 1 || [...title].length > 120) {
|
|
8143
|
+
throw invalidIdentityError('Slotlock assistant chat title must be 1-120 characters');
|
|
8144
|
+
}
|
|
8145
|
+
const rows = await sql `
|
|
8146
|
+
UPDATE slotlock.assistant_chats SET title = ${title}
|
|
8147
|
+
WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef}
|
|
8148
|
+
AND chat_id = ${params.chatId}
|
|
8149
|
+
AND (expires_at IS NULL OR expires_at > statement_timestamp())
|
|
8150
|
+
RETURNING 1`;
|
|
8151
|
+
return rows.length > 0;
|
|
5903
8152
|
},
|
|
5904
8153
|
async settleAssistantAction(params) {
|
|
5905
|
-
if (!isValidIdentity(params.tenantRef) ||
|
|
8154
|
+
if (!isValidIdentity(params.tenantRef) ||
|
|
8155
|
+
!isValidIdentity(params.memberRef) ||
|
|
8156
|
+
!SLOTLOCK_ASSISTANT_CHAT_ID.test(params.chatId)) {
|
|
5906
8157
|
return null;
|
|
8158
|
+
}
|
|
5907
8159
|
const patch = sql.json(params.patch);
|
|
5908
8160
|
const [row] = await sql `
|
|
5909
|
-
UPDATE slotlock.
|
|
8161
|
+
UPDATE slotlock.assistant_chats
|
|
5910
8162
|
SET actions = jsonb_set(actions, ARRAY[${params.actionId}::text],
|
|
5911
8163
|
(actions -> ${params.actionId}::text) || ${patch}::jsonb)
|
|
5912
8164
|
WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef}
|
|
5913
|
-
AND
|
|
8165
|
+
AND chat_id = ${params.chatId}
|
|
8166
|
+
AND (expires_at IS NULL OR expires_at > statement_timestamp())
|
|
5914
8167
|
AND actions -> ${params.actionId}::text ->> 'status' = ${params.from}
|
|
5915
8168
|
RETURNING actions -> ${params.actionId}::text AS action`;
|
|
5916
8169
|
return row?.action ?? null;
|
|
@@ -5922,7 +8175,17 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5922
8175
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10_000) {
|
|
5923
8176
|
throw invalidIdentityError('Slotlock assistant prune limit must be 1-10,000');
|
|
5924
8177
|
}
|
|
5925
|
-
|
|
8178
|
+
// The conversations a deployment gave a lifetime, past it; and those 0.8 kept, past theirs.
|
|
8179
|
+
const chats = await sql `
|
|
8180
|
+
DELETE FROM slotlock.assistant_chats
|
|
8181
|
+
WHERE tenant_ref = ${params.tenantRef}
|
|
8182
|
+
AND (member_ref, chat_id) IN (
|
|
8183
|
+
SELECT member_ref, chat_id FROM slotlock.assistant_chats
|
|
8184
|
+
WHERE tenant_ref = ${params.tenantRef} AND expires_at <= statement_timestamp()
|
|
8185
|
+
LIMIT ${limit})
|
|
8186
|
+
AND expires_at <= statement_timestamp()
|
|
8187
|
+
RETURNING 1`;
|
|
8188
|
+
const old = await sql `
|
|
5926
8189
|
DELETE FROM slotlock.assistant_conversations
|
|
5927
8190
|
WHERE tenant_ref = ${params.tenantRef}
|
|
5928
8191
|
AND member_ref IN (
|
|
@@ -5931,7 +8194,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5931
8194
|
LIMIT ${limit})
|
|
5932
8195
|
AND expires_at <= statement_timestamp()
|
|
5933
8196
|
RETURNING 1`;
|
|
5934
|
-
return
|
|
8197
|
+
return chats.length + old.length;
|
|
5935
8198
|
},
|
|
5936
8199
|
async sweepAssistantConversations(params) {
|
|
5937
8200
|
const limit = params?.limit ?? 10_000;
|
|
@@ -5942,14 +8205,113 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5942
8205
|
SELECT slotlock.sweep_assistant_conversations(${limit}::integer) AS deleted`;
|
|
5943
8206
|
return Number(row?.deleted ?? 0);
|
|
5944
8207
|
},
|
|
5945
|
-
async
|
|
5946
|
-
|
|
8208
|
+
async sweepDashboardRecords() {
|
|
8209
|
+
// Each its own statement: an erasure that runs long never takes the prunes with it.
|
|
8210
|
+
const [pruned] = await sql `
|
|
8211
|
+
SELECT slotlock.prune_workspace_invitations() + slotlock.prune_dashboard_sessions()
|
|
8212
|
+
+ slotlock.prune_workspace_domains() + slotlock.prune_sso() AS deleted`;
|
|
8213
|
+
const [erased] = await sql `SELECT slotlock.erase_deleted_teams() AS erased`;
|
|
8214
|
+
return Number(pruned?.deleted ?? 0) + Number(erased?.erased ?? 0);
|
|
8215
|
+
},
|
|
8216
|
+
async deleteAssistantChat(params) {
|
|
8217
|
+
if (!isValidIdentity(params.tenantRef) ||
|
|
8218
|
+
!isValidIdentity(params.memberRef) ||
|
|
8219
|
+
!SLOTLOCK_ASSISTANT_CHAT_ID.test(params.chatId)) {
|
|
5947
8220
|
return false;
|
|
8221
|
+
}
|
|
5948
8222
|
const rows = await sql `
|
|
5949
|
-
DELETE FROM slotlock.
|
|
5950
|
-
WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef}
|
|
8223
|
+
DELETE FROM slotlock.assistant_chats
|
|
8224
|
+
WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef}
|
|
8225
|
+
AND chat_id = ${params.chatId} RETURNING 1`;
|
|
5951
8226
|
return rows.length > 0;
|
|
5952
8227
|
},
|
|
8228
|
+
async recordAuditEvent(params) {
|
|
8229
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
8230
|
+
throw invalidIdentityError('Slotlock tenant references must be 1-500 UTF-8 bytes');
|
|
8231
|
+
}
|
|
8232
|
+
const event = params.event;
|
|
8233
|
+
if (!AUDIT_ACTION.test(event.action) || !AUDIT_ACTOR_TYPES.has(event.actorType) || !isValidIdentity(event.actorRef)) {
|
|
8234
|
+
throw invalidIdentityError('Slotlock audit events need an action (area.verb), an actor type and an actor reference');
|
|
8235
|
+
}
|
|
8236
|
+
if (event.targetType !== undefined && !AUDIT_TARGET_TYPE.test(event.targetType)) {
|
|
8237
|
+
throw invalidIdentityError('Slotlock audit event target types are lowercase words');
|
|
8238
|
+
}
|
|
8239
|
+
if (event.actorRole !== undefined && event.actorRole !== null && !isSlotlockWorkspaceRole(event.actorRole)) {
|
|
8240
|
+
throw invalidIdentityError('Slotlock audit event roles are workspace roles');
|
|
8241
|
+
}
|
|
8242
|
+
await sql `
|
|
8243
|
+
INSERT INTO slotlock.audit_events
|
|
8244
|
+
(tenant_ref, action, actor_type, actor_ref, actor_role, target_type, target_ref, outcome,
|
|
8245
|
+
reason, ip_address, user_agent, metadata)
|
|
8246
|
+
VALUES (${params.tenantRef}, ${event.action}, ${event.actorType}, ${event.actorRef},
|
|
8247
|
+
${event.actorRole ?? null}, ${event.targetType ?? null}, ${cutBytes(event.targetRef, 500)},
|
|
8248
|
+
${event.outcome === 'denied' ? 'denied' : 'success'}, ${cutBytes(event.reason, 200)},
|
|
8249
|
+
${cutBytes(event.ipAddress, 64)}, ${cutBytes(event.userAgent, 256)},
|
|
8250
|
+
${sql.json(boundAuditMetadata(event.metadata ?? {}))})`;
|
|
8251
|
+
},
|
|
8252
|
+
async listAuditEvents(params) {
|
|
8253
|
+
if (!isValidIdentity(params.tenantRef))
|
|
8254
|
+
return [];
|
|
8255
|
+
const limit = Math.min(Math.max(Math.trunc(params.limit ?? 50), 1), 200);
|
|
8256
|
+
const before = params.before;
|
|
8257
|
+
const action = params.action !== undefined && AUDIT_ACTION.test(params.action) ? params.action : null;
|
|
8258
|
+
const actor = params.actorRef !== undefined && isValidIdentity(params.actorRef) ? params.actorRef : null;
|
|
8259
|
+
const beforeId = params.beforeId !== undefined && isValidResourceId(params.beforeId) ? params.beforeId : null;
|
|
8260
|
+
if (params.beforeId !== undefined && beforeId === null)
|
|
8261
|
+
return [];
|
|
8262
|
+
const rows = await sql `
|
|
8263
|
+
SELECT id, occurred_at, action, actor_type, actor_ref, actor_role, target_type, target_ref,
|
|
8264
|
+
outcome, reason, ip_address, user_agent, metadata, chain_index, hash
|
|
8265
|
+
FROM slotlock.audit_events
|
|
8266
|
+
WHERE tenant_ref = ${params.tenantRef}
|
|
8267
|
+
AND (${action}::text IS NULL OR action = ${action}::text)
|
|
8268
|
+
AND (${actor}::text IS NULL OR actor_ref = ${actor}::text)
|
|
8269
|
+
AND (${before?.occurredAt ?? null}::timestamptz IS NULL
|
|
8270
|
+
OR (occurred_at, id) < (${before?.occurredAt ?? null}::timestamptz, ${before?.id ?? null}::uuid))
|
|
8271
|
+
AND (${beforeId}::uuid IS NULL
|
|
8272
|
+
OR (occurred_at, id) < (SELECT kept.occurred_at, kept.id FROM slotlock.audit_events kept
|
|
8273
|
+
WHERE kept.tenant_ref = ${params.tenantRef} AND kept.id = ${beforeId}::uuid))
|
|
8274
|
+
ORDER BY occurred_at DESC, id DESC
|
|
8275
|
+
LIMIT ${limit}`;
|
|
8276
|
+
return rows.map((row) => ({
|
|
8277
|
+
id: row.id,
|
|
8278
|
+
occurredAt: row.occurred_at,
|
|
8279
|
+
action: row.action,
|
|
8280
|
+
actorType: row.actor_type,
|
|
8281
|
+
actorRef: row.actor_ref,
|
|
8282
|
+
actorRole: row.actor_role,
|
|
8283
|
+
...(row.target_type !== null ? { targetType: row.target_type } : {}),
|
|
8284
|
+
...(row.target_ref !== null ? { targetRef: row.target_ref } : {}),
|
|
8285
|
+
outcome: row.outcome,
|
|
8286
|
+
...(row.reason !== null ? { reason: row.reason } : {}),
|
|
8287
|
+
...(row.ip_address !== null ? { ipAddress: row.ip_address } : {}),
|
|
8288
|
+
...(row.user_agent !== null ? { userAgent: row.user_agent } : {}),
|
|
8289
|
+
chainIndex: row.chain_index === null ? null : Number(row.chain_index),
|
|
8290
|
+
hash: row.hash === null ? null : row.hash.toString('hex'),
|
|
8291
|
+
metadata: row.metadata,
|
|
8292
|
+
}));
|
|
8293
|
+
},
|
|
8294
|
+
async tenantPlan(params) {
|
|
8295
|
+
if (!isValidIdentity(params.tenantRef))
|
|
8296
|
+
return null;
|
|
8297
|
+
const [row] = await sql `
|
|
8298
|
+
SELECT plan, status FROM slotlock.tenant_plan(${params.tenantRef}::text) LIMIT 1`;
|
|
8299
|
+
return row ? { plan: row.plan, status: row.status } : null;
|
|
8300
|
+
},
|
|
8301
|
+
async pruneAuditEvents(params) {
|
|
8302
|
+
if (!isValidIdentity(params.tenantRef))
|
|
8303
|
+
return 0;
|
|
8304
|
+
const [row] = await sql `
|
|
8305
|
+
SELECT slotlock.prune_audit_events(${params.tenantRef}::text) AS pruned`;
|
|
8306
|
+
return Number(row?.pruned ?? 0);
|
|
8307
|
+
},
|
|
8308
|
+
async verifyAuditChain(params) {
|
|
8309
|
+
if (!isValidIdentity(params.tenantRef))
|
|
8310
|
+
return { checked: 0, brokenAt: null };
|
|
8311
|
+
const [row] = await sql `
|
|
8312
|
+
SELECT checked, broken_at FROM slotlock.verify_audit_chain(${params.tenantRef}::text)`;
|
|
8313
|
+
return { checked: Number(row?.checked ?? 0), brokenAt: row?.broken_at === null || row === undefined ? null : Number(row.broken_at) };
|
|
8314
|
+
},
|
|
5953
8315
|
async offerWaitlist(params) {
|
|
5954
8316
|
if (!isValidIdentity(params.tenantRef)) {
|
|
5955
8317
|
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
@@ -5967,10 +8329,21 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5967
8329
|
await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify(['slotlock-waitlist', tenantRef])}, 0))`;
|
|
5968
8330
|
let expired = 0;
|
|
5969
8331
|
if (!after) {
|
|
8332
|
+
// Offers whose hold ran out lapse (slotlock.waitlist_offer_settle), freeing their time.
|
|
8333
|
+
await tx `
|
|
8334
|
+
DELETE FROM slotlock.reservations offer
|
|
8335
|
+
USING slotlock.resources resource
|
|
8336
|
+
WHERE resource.id = offer.resource_id AND resource.tenant_ref = ${tenantRef}
|
|
8337
|
+
AND offer.status = 'held' AND offer.source = 'slotlock:waitlist'
|
|
8338
|
+
AND offer.expires_at <= statement_timestamp()`;
|
|
5970
8339
|
const [gone] = await tx `
|
|
5971
8340
|
WITH gone AS (
|
|
5972
8341
|
UPDATE slotlock.waitlist_entries SET status = 'expired'
|
|
5973
|
-
WHERE tenant_ref = ${tenantRef} AND status = 'waiting'
|
|
8342
|
+
WHERE tenant_ref = ${tenantRef} AND status = 'waiting'
|
|
8343
|
+
-- The whole window once it starts; a length once no slot of it fits before the end.
|
|
8344
|
+
AND (CASE WHEN duration_ms IS NULL THEN starts_at
|
|
8345
|
+
ELSE ends_at - make_interval(secs => duration_ms / 1000.0) END)
|
|
8346
|
+
<= statement_timestamp()
|
|
5974
8347
|
RETURNING id, resource_id, pool_ref, starts_at, ends_at, holder_ref
|
|
5975
8348
|
), reported AS (
|
|
5976
8349
|
INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
|
|
@@ -6004,6 +8377,15 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
6004
8377
|
WHERE id = ${entry.id} AND status = 'waiting' FOR UPDATE`;
|
|
6005
8378
|
if (!still)
|
|
6006
8379
|
continue;
|
|
8380
|
+
if (entry.duration_ms !== null) {
|
|
8381
|
+
// Any slot of its length within its window (RFC 0004 B6), on its resource or any member.
|
|
8382
|
+
const slot = await offerSlotOfLength(tx, inner, tenantRef, entry);
|
|
8383
|
+
if (slot) {
|
|
8384
|
+
await recordWaitlistOffer(tx, tenantRef, entry, slot);
|
|
8385
|
+
offered += 1;
|
|
8386
|
+
}
|
|
8387
|
+
continue;
|
|
8388
|
+
}
|
|
6007
8389
|
// Only resources that look free (buffers included) are tried: a refused hold keeps its
|
|
6008
8390
|
// locks until the transaction ends.
|
|
6009
8391
|
const candidates = await tx `
|
|
@@ -6026,6 +8408,12 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
6026
8408
|
&& tstzrange(${entry.starts_at}::timestamptz - make_interval(mins => buffers.before_minutes),
|
|
6027
8409
|
${entry.ends_at}::timestamptz + make_interval(mins => buffers.after_minutes), '[)')
|
|
6028
8410
|
AND (taken.status <> 'held' OR taken.expires_at > statement_timestamp()))
|
|
8411
|
+
-- Nor busy time a calendar reported (RFC 0004 C1), which the hold would be refused.
|
|
8412
|
+
AND NOT EXISTS (
|
|
8413
|
+
SELECT 1 FROM slotlock.source_busy busy
|
|
8414
|
+
WHERE busy.resource_id = resources.id
|
|
8415
|
+
AND busy.starts_at < ${entry.ends_at}::timestamptz + make_interval(mins => buffers.after_minutes)
|
|
8416
|
+
AND busy.ends_at > ${entry.starts_at}::timestamptz - make_interval(mins => buffers.before_minutes))
|
|
6029
8417
|
ORDER BY resources.id
|
|
6030
8418
|
LIMIT ${OFFER_CANDIDATES}`;
|
|
6031
8419
|
for (const { id: resourceId } of candidates) {
|
|
@@ -6040,21 +8428,13 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
6040
8428
|
});
|
|
6041
8429
|
if (!hold.ok)
|
|
6042
8430
|
continue;
|
|
6043
|
-
await tx
|
|
6044
|
-
|
|
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')`;
|
|
8431
|
+
await recordWaitlistOffer(tx, tenantRef, entry, {
|
|
8432
|
+
resourceId,
|
|
8433
|
+
holdId: hold.hold.id,
|
|
8434
|
+
start: entry.starts_at,
|
|
8435
|
+
end: entry.ends_at,
|
|
8436
|
+
expiresAt: hold.hold.expiresAt,
|
|
8437
|
+
});
|
|
6058
8438
|
offered += 1;
|
|
6059
8439
|
break;
|
|
6060
8440
|
}
|
|
@@ -6111,6 +8491,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
6111
8491
|
AND (reservation.status <> 'held' OR reservation.expires_at > statement_timestamp())
|
|
6112
8492
|
UNION ALL
|
|
6113
8493
|
${compositeBusy(sql, params.resourceId, window)}
|
|
8494
|
+
UNION ALL
|
|
8495
|
+
${sourceBusy(sql, params.resourceId, window)}
|
|
6114
8496
|
ORDER BY 1
|
|
6115
8497
|
LIMIT ${MAX_FREE_BUSY_INTERVALS + 1}`;
|
|
6116
8498
|
if (busyRows.length > MAX_FREE_BUSY_INTERVALS) {
|