slotlock 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +237 -0
  2. package/agent-server.d.ts +25 -5
  3. package/agent-server.js +152 -7
  4. package/agent-store-backend.d.ts +6 -1
  5. package/agent-store-backend.js +861 -55
  6. package/application-api.d.ts +19 -1
  7. package/application-api.js +578 -21
  8. package/approvals.d.ts +2 -0
  9. package/approvals.js +12 -5
  10. package/assistant.d.ts +34 -8
  11. package/assistant.js +81 -26
  12. package/availability-store.d.ts +193 -0
  13. package/availability-store.js +558 -0
  14. package/booking-rules.d.ts +92 -5
  15. package/booking-rules.js +243 -15
  16. package/dashboard-assistant-ui.d.ts +1 -1
  17. package/dashboard-assistant-ui.js +109 -8
  18. package/dashboard.d.ts +55 -6
  19. package/dashboard.js +790 -105
  20. package/ddl.d.ts +5 -1
  21. package/ddl.js +760 -9
  22. package/event-feed.d.ts +1 -1
  23. package/event-feed.js +18 -0
  24. package/holiday-import.d.ts +38 -0
  25. package/holiday-import.js +57 -0
  26. package/holidays.d.ts +38 -0
  27. package/holidays.js +150 -0
  28. package/hours.d.ts +125 -0
  29. package/hours.js +224 -0
  30. package/index.d.ts +14 -3
  31. package/index.js +10 -2
  32. package/interop.d.ts +25 -7
  33. package/interop.js +145 -17
  34. package/lifecycle.d.ts +31 -0
  35. package/lifecycle.js +115 -0
  36. package/maintenance.d.ts +19 -0
  37. package/maintenance.js +55 -1
  38. package/metrics.js +3 -0
  39. package/openapi.json +16237 -7795
  40. package/package.json +1 -1
  41. package/ranges.d.ts +28 -0
  42. package/ranges.js +135 -0
  43. package/resource-search.d.ts +19 -0
  44. package/resource-search.js +153 -0
  45. package/rest-api.js +7 -0
  46. package/rules.d.ts +2 -0
  47. package/rules.js +2 -0
  48. package/self-host.d.ts +1 -1
  49. package/self-host.js +221 -21
  50. package/slotlock.manifest.json +1 -1
  51. package/store.d.ts +367 -24
  52. package/store.js +1567 -97
  53. package/types.d.ts +198 -0
package/ddl.js CHANGED
@@ -292,6 +292,10 @@ export const SLOTLOCK_QUOTA_FUNCTIONS = Object.freeze([
292
292
  ]);
293
293
  /** What a plan's monthly quotas count (quotas.ts): operations run and bookings made. */
294
294
  export const SLOTLOCK_QUOTA_METRICS = Object.freeze(['operations', 'bookings']);
295
+ /** The most conversations a member keeps with the dashboard assistant: past it, the least recent goes. */
296
+ export const SLOTLOCK_ASSISTANT_MAX_CHATS = 100;
297
+ /** How a conversation with the assistant is named: 16 URL-safe characters. */
298
+ export const SLOTLOCK_ASSISTANT_CHAT_ID = /^[A-Za-z0-9_-]{16}$/;
295
299
  /** The shares of a quota, in percent, at which a tenant is told once a month (usage.threshold_reached). */
296
300
  export const SLOTLOCK_QUOTA_NOTICE_PERCENTS = Object.freeze([80, 100]);
297
301
  /** The most bookings past the roll-up cursor one quota read counts: more than any plan allows. */
@@ -1948,6 +1952,38 @@ BEGIN
1948
1952
  DELETE FROM slotlock.approval_policies WHERE tenant_ref = requested_tenant_ref;
1949
1953
  GET DIAGNOSTICS erased_rows = ROW_COUNT;
1950
1954
  RETURN NEXT;
1955
+ erased_table := 'availability_exceptions';
1956
+ DELETE FROM slotlock.availability_exceptions WHERE tenant_ref = requested_tenant_ref;
1957
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
1958
+ RETURN NEXT;
1959
+ erased_table := 'holiday_calendars';
1960
+ DELETE FROM slotlock.holiday_calendars WHERE tenant_ref = requested_tenant_ref;
1961
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
1962
+ RETURN NEXT;
1963
+ erased_table := 'availability_defaults';
1964
+ DELETE FROM slotlock.availability_defaults WHERE tenant_ref = requested_tenant_ref;
1965
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
1966
+ RETURN NEXT;
1967
+ erased_table := 'contacts';
1968
+ DELETE FROM slotlock.contacts WHERE tenant_ref = requested_tenant_ref;
1969
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
1970
+ RETURN NEXT;
1971
+ erased_table := 'reminder_dispatches';
1972
+ DELETE FROM slotlock.reminder_dispatches WHERE tenant_ref = requested_tenant_ref;
1973
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
1974
+ RETURN NEXT;
1975
+ erased_table := 'reservation_reminder_dispatches';
1976
+ DELETE FROM slotlock.reservation_reminder_dispatches WHERE tenant_ref = requested_tenant_ref;
1977
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
1978
+ RETURN NEXT;
1979
+ erased_table := 'hold_payments';
1980
+ DELETE FROM slotlock.hold_payments WHERE tenant_ref = requested_tenant_ref;
1981
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
1982
+ RETURN NEXT;
1983
+ erased_table := 'source_busy';
1984
+ DELETE FROM slotlock.source_busy WHERE tenant_ref = requested_tenant_ref;
1985
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
1986
+ RETURN NEXT;
1951
1987
  erased_table := 'reservations';
1952
1988
  DELETE FROM slotlock.reservations
1953
1989
  WHERE tenant_ref = requested_tenant_ref
@@ -2031,6 +2067,10 @@ BEGIN
2031
2067
  DELETE FROM slotlock.assistant_conversations WHERE tenant_ref = requested_tenant_ref;
2032
2068
  GET DIAGNOSTICS erased_rows = ROW_COUNT;
2033
2069
  RETURN NEXT;
2070
+ erased_table := 'assistant_chats';
2071
+ DELETE FROM slotlock.assistant_chats WHERE tenant_ref = requested_tenant_ref;
2072
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
2073
+ RETURN NEXT;
2034
2074
  erased_table := 'assistant_settings';
2035
2075
  DELETE FROM slotlock.assistant_settings WHERE tenant_ref = requested_tenant_ref;
2036
2076
  GET DIAGNOSTICS erased_rows = ROW_COUNT;
@@ -2199,7 +2239,9 @@ BEGIN
2199
2239
  FOREACH emptied IN ARRAY ARRAY[
2200
2240
  'calendar_event_attendees', 'calendar_event_reminders', 'calendar_event_exceptions',
2201
2241
  'calendar_event_occurrences', 'composite_claims', 'approvals', 'approval_policies',
2202
- 'calendar_events', 'calendar_event_tombstones', 'calendar_event_commands',
2242
+ 'availability_exceptions', 'holiday_calendars', 'availability_defaults', 'contacts', 'reminder_dispatches',
2243
+ 'reservation_reminder_dispatches',
2244
+ 'hold_payments', 'source_busy', 'calendar_events', 'calendar_event_tombstones', 'calendar_event_commands',
2203
2245
  'reservation_tombstones', 'reservation_conflict_archive', 'calendar_coverage', 'hold_commands',
2204
2246
  'waitlist_entries', 'booking_rule_defaults', 'pool_settings', 'resource_group_members',
2205
2247
  'resources', 'resource_groups', 'outbox'
@@ -2407,8 +2449,21 @@ BEGIN
2407
2449
  AND gone.expires_at <= now();
2408
2450
  GET DIAGNOSTICS step = ROW_COUNT;
2409
2451
  deleted := deleted + step;
2410
- SELECT min(live.expires_at) INTO upcoming FROM slotlock.assistant_conversations live
2411
- WHERE live.tenant_ref = due.tenant_ref;
2452
+ DELETE FROM slotlock.assistant_chats gone
2453
+ WHERE gone.tenant_ref = due.tenant_ref
2454
+ AND (gone.member_ref, gone.chat_id) IN (
2455
+ SELECT expired.member_ref, expired.chat_id FROM slotlock.assistant_chats expired
2456
+ WHERE expired.tenant_ref = due.tenant_ref AND expired.expires_at <= now()
2457
+ LIMIT greatest(max_rows - deleted, 0))
2458
+ AND gone.expires_at <= now();
2459
+ GET DIAGNOSTICS step = ROW_COUNT;
2460
+ deleted := deleted + step;
2461
+ SELECT least(
2462
+ (SELECT min(live.expires_at) FROM slotlock.assistant_conversations live
2463
+ WHERE live.tenant_ref = due.tenant_ref),
2464
+ (SELECT min(chat.expires_at) FROM slotlock.assistant_chats chat
2465
+ WHERE chat.tenant_ref = due.tenant_ref AND chat.expires_at IS NOT NULL))
2466
+ INTO upcoming;
2412
2467
  IF upcoming IS NULL THEN
2413
2468
  DELETE FROM slotlock.assistant_tenants WHERE tenant_ref = due.tenant_ref;
2414
2469
  ELSE
@@ -2750,7 +2805,7 @@ AS $slotlock_location_path$
2750
2805
  FROM slotlock.locations place JOIN up ON place.tenant_ref = requested_tenant_ref AND place.ref = up.parent_ref
2751
2806
  WHERE up.steps <= ${SLOTLOCK_MAX_LOCATION_DEPTH}
2752
2807
  )
2753
- SELECT COALESCE(array_agg(up.ref), '{}'::text[]) FROM up;
2808
+ SELECT COALESCE(array_agg(up.ref ORDER BY up.steps), '{}'::text[]) FROM up;
2754
2809
  $slotlock_location_path$;
2755
2810
  -- 0.5 allowed a filter of at most 20 types, fewer than there are now. The wider CHECK admits every
2756
2811
  -- row the old one did, so it is added NOT VALID (no scan under the lock), and the old one dropped.
@@ -2821,6 +2876,78 @@ END
2821
2876
  $slotlock_waitlist_key$;
2822
2877
  CREATE UNIQUE INDEX IF NOT EXISTS slotlock_waitlist_entries_key_idx
2823
2878
  ON slotlock.waitlist_entries (tenant_ref, idempotency_key) WHERE idempotency_key IS NOT NULL;
2879
+ -- Waitlists that match (RFC 0004 B6): an entry may ask for any slot of a length within its window
2880
+ -- (duration_ms; NULL is the whole window), and an offer ends accepted (its hold confirmed), declined
2881
+ -- (let go) or lapsed (expired), when it was decided. The wider checks admit every row the old ones
2882
+ -- did, so they are added NOT VALID (no scan under the lock), and the old ones dropped.
2883
+ ALTER TABLE slotlock.waitlist_entries ADD COLUMN IF NOT EXISTS duration_ms bigint;
2884
+ ALTER TABLE slotlock.waitlist_entries ADD COLUMN IF NOT EXISTS decided_at timestamptz;
2885
+ DO $slotlock_waitlist_outcomes$
2886
+ BEGIN
2887
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'slotlock_waitlist_entries_outcome_valid'
2888
+ AND conrelid = 'slotlock.waitlist_entries'::regclass) THEN
2889
+ ALTER TABLE slotlock.waitlist_entries ADD CONSTRAINT slotlock_waitlist_entries_outcome_valid CHECK (
2890
+ status IN ('waiting', 'offered', 'accepted', 'declined', 'lapsed', 'expired')
2891
+ AND (status <> 'offered' OR (hold_id IS NOT NULL AND offered_at IS NOT NULL AND offered_resource_id IS NOT NULL))
2892
+ AND (status <> 'waiting' OR (hold_id IS NULL AND offered_at IS NULL))
2893
+ AND (duration_ms IS NULL OR (duration_ms >= 60000
2894
+ AND duration_ms <= extract(epoch FROM ends_at - starts_at) * 1000))
2895
+ ) NOT VALID;
2896
+ END IF;
2897
+ ALTER TABLE slotlock.waitlist_entries DROP CONSTRAINT IF EXISTS slotlock_waitlist_entries_status_valid;
2898
+ ALTER TABLE slotlock.waitlist_entries DROP CONSTRAINT IF EXISTS slotlock_waitlist_entries_offer_valid;
2899
+ END
2900
+ $slotlock_waitlist_outcomes$;
2901
+
2902
+ -- How an offer ends (RFC 0004 B6), from what becomes of its hold, whatever path took it: confirmed
2903
+ -- (or consumed by a booking that names it, under slotlock.ending = 'confirmed') is accepted;
2904
+ -- expired is lapsed; let go before then is declined. The entry says so, and the feed.
2905
+ CREATE OR REPLACE FUNCTION slotlock.waitlist_offer_settle()
2906
+ RETURNS trigger
2907
+ LANGUAGE plpgsql
2908
+ SET search_path = pg_catalog, slotlock, pg_temp
2909
+ AS $slotlock_waitlist_offer_settle$
2910
+ DECLARE
2911
+ outcome text;
2912
+ settled slotlock.waitlist_entries%ROWTYPE;
2913
+ BEGIN
2914
+ IF OLD.status <> 'held' OR OLD.source IS DISTINCT FROM 'slotlock:waitlist' THEN RETURN NULL; END IF;
2915
+ IF TG_OP = 'UPDATE' THEN
2916
+ IF NEW.status = 'held' THEN RETURN NULL; END IF;
2917
+ outcome := 'accepted';
2918
+ ELSIF current_setting('slotlock.ending', true) = 'confirmed' THEN
2919
+ outcome := 'accepted';
2920
+ ELSIF OLD.expires_at <= statement_timestamp() THEN
2921
+ outcome := 'lapsed';
2922
+ ELSE
2923
+ outcome := 'declined';
2924
+ END IF;
2925
+ UPDATE slotlock.waitlist_entries SET status = outcome, decided_at = statement_timestamp()
2926
+ WHERE hold_id = OLD.id AND status = 'offered'
2927
+ RETURNING * INTO settled;
2928
+ IF settled.id IS NULL THEN RETURN NULL; END IF;
2929
+ -- Erasure (erase_tenant, running as the schema's owner) is not reported.
2930
+ IF settled.tenant_ref = current_setting('slotlock.erasing', true)
2931
+ AND current_user = (SELECT pg_catalog.pg_get_userbyid(nspowner) FROM pg_catalog.pg_namespace
2932
+ WHERE nspname = 'slotlock') THEN
2933
+ RETURN NULL;
2934
+ END IF;
2935
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
2936
+ VALUES (
2937
+ settled.tenant_ref, settled.offered_resource_id, 'waitlist.' || outcome, settled.id::text,
2938
+ jsonb_strip_nulls(jsonb_build_object(
2939
+ 'id', settled.id, 'hold_id', settled.hold_id, 'resource_id', settled.offered_resource_id,
2940
+ 'pool', settled.pool_ref, 'holder_ref', settled.holder_ref)),
2941
+ COALESCE(NULLIF(current_setting('slotlock.actor', true), ''),
2942
+ CASE WHEN outcome = 'lapsed' THEN 'slotlock:maintenance' END)
2943
+ );
2944
+ RETURN NULL;
2945
+ END
2946
+ $slotlock_waitlist_offer_settle$;
2947
+ DROP TRIGGER IF EXISTS slotlock_waitlist_offer_settle ON slotlock.reservations;
2948
+ CREATE TRIGGER slotlock_waitlist_offer_settle
2949
+ AFTER UPDATE OF status OR DELETE ON slotlock.reservations
2950
+ FOR EACH ROW EXECUTE FUNCTION slotlock.waitlist_offer_settle();
2824
2951
  CREATE INDEX IF NOT EXISTS slotlock_waitlist_entries_tenant_idx
2825
2952
  ON slotlock.waitlist_entries (tenant_ref, starts_at);
2826
2953
 
@@ -2929,6 +3056,63 @@ CREATE TABLE IF NOT EXISTS slotlock.assistant_conversations (
2929
3056
  CREATE INDEX IF NOT EXISTS slotlock_assistant_conversations_expiry_idx
2930
3057
  ON slotlock.assistant_conversations (tenant_ref, expires_at);
2931
3058
 
3059
+ -- A member's conversations with the dashboard assistant (its history), each with the writes it
3060
+ -- proposed. Kept until the member deletes one, or until expires_at where the deployment gives
3061
+ -- conversations a lifetime; at most ${SLOTLOCK_ASSISTANT_MAX_CHATS} a member, the least recent going first.
3062
+ CREATE TABLE IF NOT EXISTS slotlock.assistant_chats (
3063
+ tenant_ref text NOT NULL,
3064
+ member_ref text NOT NULL,
3065
+ chat_id text NOT NULL,
3066
+ title text NOT NULL,
3067
+ messages jsonb NOT NULL DEFAULT '[]'::jsonb,
3068
+ actions jsonb NOT NULL DEFAULT '{}'::jsonb,
3069
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
3070
+ updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
3071
+ expires_at timestamptz,
3072
+ CONSTRAINT slotlock_assistant_chats_pkey PRIMARY KEY (tenant_ref, member_ref, chat_id),
3073
+ CONSTRAINT slotlock_assistant_chats_identity_valid CHECK (
3074
+ octet_length(tenant_ref) BETWEEN 1 AND 500 AND octet_length(member_ref) BETWEEN 1 AND 500
3075
+ AND chat_id ~ '^[A-Za-z0-9_-]{16}$'
3076
+ ),
3077
+ CONSTRAINT slotlock_assistant_chats_title_valid CHECK (char_length(title) BETWEEN 1 AND 120),
3078
+ CONSTRAINT slotlock_assistant_chats_messages_valid CHECK (
3079
+ jsonb_typeof(messages) = 'array' AND octet_length(messages::text) <= 262144
3080
+ ),
3081
+ CONSTRAINT slotlock_assistant_chats_actions_valid CHECK (
3082
+ jsonb_typeof(actions) = 'object' AND octet_length(actions::text) <= 131072
3083
+ )
3084
+ );
3085
+ CREATE INDEX IF NOT EXISTS slotlock_assistant_chats_recent_idx
3086
+ ON slotlock.assistant_chats (tenant_ref, member_ref, updated_at DESC);
3087
+ CREATE INDEX IF NOT EXISTS slotlock_assistant_chats_expiry_idx
3088
+ ON slotlock.assistant_chats (tenant_ref, expires_at) WHERE expires_at IS NOT NULL;
3089
+
3090
+ -- The one conversation a member had before chats were kept (0.8 and earlier) becomes their first
3091
+ -- chat, titled by its first message, and leaves the old table: a later migration does not bring
3092
+ -- back a chat the member deleted. A server of 0.8 still running meanwhile keeps using the old table;
3093
+ -- what it writes there is moved by the next migration as a chat of its own (its id is the
3094
+ -- conversation's, with when it was last written), never into one already moved.
3095
+ WITH moved AS (
3096
+ DELETE FROM slotlock.assistant_conversations old
3097
+ WHERE old.expires_at > statement_timestamp()
3098
+ RETURNING old.tenant_ref, old.member_ref, old.messages, old.actions, old.updated_at
3099
+ )
3100
+ INSERT INTO slotlock.assistant_chats AS chat
3101
+ (tenant_ref, member_ref, chat_id, title, messages, actions, created_at, updated_at, expires_at)
3102
+ SELECT moved.tenant_ref, moved.member_ref,
3103
+ pg_catalog.translate(pg_catalog.left(pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to(
3104
+ moved.tenant_ref || pg_catalog.chr(10) || moved.member_ref || pg_catalog.chr(10)
3105
+ || (extract(epoch FROM moved.updated_at) * 1000000)::bigint::text, 'UTF8')), 'base64'), 16), '+/', '-_'),
3106
+ coalesce(nullif(pg_catalog.left(pg_catalog.regexp_replace(pg_catalog.btrim(first_user.text), '\\s+', ' ', 'g'), 80), ''),
3107
+ 'Conversation'),
3108
+ moved.messages, moved.actions, moved.updated_at, moved.updated_at, NULL
3109
+ FROM moved
3110
+ LEFT JOIN LATERAL (
3111
+ SELECT entry ->> 'text' AS text FROM pg_catalog.jsonb_array_elements(moved.messages) AS entry
3112
+ WHERE entry ->> 'role' = 'user' LIMIT 1
3113
+ ) first_user ON true
3114
+ ON CONFLICT DO NOTHING;
3115
+
2932
3116
  -- The tenant a reservation row belongs to: its own, or (a hold) its resource's.
2933
3117
  CREATE OR REPLACE FUNCTION slotlock.outbox_reservation_change()
2934
3118
  RETURNS trigger
@@ -2980,8 +3164,8 @@ BEGIN
2980
3164
  END;
2981
3165
  END IF;
2982
3166
  INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
2983
- VALUES (
2984
- owner, changed.resource_id, kind,
3167
+ SELECT
3168
+ owner, changed.resource_id, written.change_type,
2985
3169
  COALESCE(changed.external_ref, changed.id::text),
2986
3170
  jsonb_strip_nulls(jsonb_build_object(
2987
3171
  'id', changed.id,
@@ -2990,6 +3174,14 @@ BEGIN
2990
3174
  'start', changed.starts_at,
2991
3175
  'end', changed.ends_at,
2992
3176
  'buffer_after_ms', changed.buffer_after_ms,
3177
+ -- Whom to tell and whether (RFC 0004 P3): a reference, never the person; notify only when off.
3178
+ 'contact_ref', changed.contact_ref,
3179
+ 'notify', CASE WHEN NOT changed.notify THEN false END,
3180
+ 'payment_required', CASE WHEN changed.payment_required THEN true END,
3181
+ -- A cancellation's fee and an admin's reason for going past a cutoff (RFC 0004 B5).
3182
+ 'cancellation_fee', CASE WHEN kind = 'reservation.cancelled'
3183
+ THEN NULLIF(current_setting('slotlock.cancellation_fee', true), '')::jsonb END,
3184
+ 'override_reason', NULLIF(current_setting('slotlock.override_reason', true), ''),
2993
3185
  -- A no-show carries the revision its reference is retired at, and the check-in window.
2994
3186
  'revision', CASE WHEN changed.status = 'held' THEN NULL
2995
3187
  WHEN kind = 'reservation.no_show' THEN changed.revision + 1
@@ -3003,7 +3195,13 @@ BEGIN
3003
3195
  'revision', OLD.revision) END
3004
3196
  )),
3005
3197
  NULLIF(current_setting('slotlock.actor', true), '')
3006
- );
3198
+ -- A booking moved in time or to another resource is also reservation.rescheduled (RFC 0004
3199
+ -- B4); reservation.updated stays for those who read it.
3200
+ FROM (VALUES (kind), (CASE WHEN kind = 'reservation.updated'
3201
+ AND (OLD.starts_at, OLD.ends_at, OLD.resource_id)
3202
+ IS DISTINCT FROM (NEW.starts_at, NEW.ends_at, NEW.resource_id)
3203
+ THEN 'reservation.rescheduled' END)) AS written(change_type)
3204
+ WHERE written.change_type IS NOT NULL;
3007
3205
  RETURN NULL;
3008
3206
  END
3009
3207
  $slotlock_outbox_reservation$;
@@ -3047,6 +3245,11 @@ BEGIN
3047
3245
  'transparency', changed.transparency,
3048
3246
  'recurring', changed.recurrence_rule IS NOT NULL,
3049
3247
  'revision', changed.revision,
3248
+ 'notify', CASE WHEN NOT changed.notify THEN false END,
3249
+ -- A cancellation's fee and an admin's reason for going past a cutoff (RFC 0004 B5).
3250
+ 'cancellation_fee', CASE WHEN kind = 'event.cancelled'
3251
+ THEN NULLIF(current_setting('slotlock.cancellation_fee', true), '')::jsonb END,
3252
+ 'override_reason', NULLIF(current_setting('slotlock.override_reason', true), ''),
3050
3253
  'before', CASE WHEN TG_OP = 'UPDATE' THEN jsonb_build_object(
3051
3254
  'resource_id', OLD.resource_id, 'start', OLD.starts_at, 'end', OLD.ends_at,
3052
3255
  'status', OLD.status, 'transparency', OLD.transparency, 'revision', OLD.revision) END
@@ -3983,6 +4186,12 @@ CREATE INDEX IF NOT EXISTS slotlock_resources_labels_idx ON slotlock.resources U
3983
4186
  ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS tenant_ref text;
3984
4187
  ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS external_ref text;
3985
4188
  ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS buffer_after_ms bigint NOT NULL DEFAULT 0;
4189
+ -- When maintenance said the booking ended (reservation.completed, RFC 0004 B4): it changes nothing
4190
+ -- anyone wrote, so the feed's trigger ignores it.
4191
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS completed_at timestamptz;
4192
+ CREATE INDEX IF NOT EXISTS slotlock_reservations_uncompleted_idx
4193
+ ON slotlock.reservations (tenant_ref, ends_at)
4194
+ WHERE status = 'confirmed' AND completed_at IS NULL;
3986
4195
  ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS occupied_ends_at timestamptz;
3987
4196
  -- The leading buffer a booking keeps before its start (its resource's bufferBeforeMinutes when it
3988
4197
  -- was made), and where its occupied time starts: its start less that buffer. A row written before
@@ -4078,6 +4287,31 @@ ALTER TABLE slotlock.reservation_tombstones ALTER COLUMN cancelled_at SET DEFAUL
4078
4287
  -- are kept too.
4079
4288
  ALTER TABLE slotlock.reservation_tombstones
4080
4289
  ADD COLUMN IF NOT EXISTS stray boolean NOT NULL DEFAULT false;
4290
+ -- Why the booking ended (RFC 0004 B4): cancelled or no_show (a hold leaves no tombstone; released
4291
+ -- and expired are on the feed). Rows from before keep 'cancelled', as every one of them was. A late
4292
+ -- cancellation's fee (B5), recorded for the integrator to charge, and an admin's reason for going
4293
+ -- past a cutoff.
4294
+ ALTER TABLE slotlock.reservation_tombstones
4295
+ ADD COLUMN IF NOT EXISTS end_reason text NOT NULL DEFAULT 'cancelled';
4296
+ ALTER TABLE slotlock.reservation_tombstones ADD COLUMN IF NOT EXISTS cancellation_fee jsonb;
4297
+ ALTER TABLE slotlock.reservation_tombstones ADD COLUMN IF NOT EXISTS override_reason text;
4298
+ DO $slotlock_tombstone_end_reason$
4299
+ BEGIN
4300
+ IF NOT EXISTS (
4301
+ SELECT 1 FROM pg_catalog.pg_constraint
4302
+ WHERE conrelid = 'slotlock.reservation_tombstones'::regclass
4303
+ AND conname = 'slotlock_reservation_tombstones_end_valid'
4304
+ ) THEN
4305
+ ALTER TABLE slotlock.reservation_tombstones
4306
+ ADD CONSTRAINT slotlock_reservation_tombstones_end_valid CHECK (
4307
+ end_reason IN ('cancelled', 'no_show', 'released', 'expired', 'erased')
4308
+ AND (cancellation_fee IS NULL OR (jsonb_typeof(cancellation_fee) = 'object'
4309
+ AND octet_length(cancellation_fee::text) <= 200))
4310
+ AND (override_reason IS NULL OR char_length(override_reason) BETWEEN 1 AND 500)
4311
+ );
4312
+ END IF;
4313
+ END
4314
+ $slotlock_tombstone_end_reason$;
4081
4315
  -- Resource identity: one resource per tenant/external_ref namespace (plus one isolated NULL-tenant
4082
4316
  -- legacy namespace). Dedupe first (repoint reservations to the keeper, drop the rest) so the
4083
4317
  -- unique indexes also bind on DBs poisoned by the pre-index
@@ -4755,6 +4989,9 @@ CREATE TABLE IF NOT EXISTS slotlock.approval_policies (
4755
4989
  AND lead_minutes BETWEEN 0 AND 43200
4756
4990
  )
4757
4991
  );
4992
+ -- A policy holds agents' bookings, and the tenant's own through the application API (bundles and
4993
+ -- pool bookings) only when it includes them (RFC 0004 B7).
4994
+ ALTER TABLE slotlock.approval_policies ADD COLUMN IF NOT EXISTS include_application boolean NOT NULL DEFAULT false;
4758
4995
 
4759
4996
  -- A write waiting for a person: its time held by holds whose holder is the approval (never the
4760
4997
  -- agent, so it cannot release them and they do not count against its caps), and the request to run
@@ -4799,6 +5036,147 @@ CREATE INDEX IF NOT EXISTS slotlock_approvals_pending_idx
4799
5036
  CREATE INDEX IF NOT EXISTS slotlock_approvals_expiry_idx
4800
5037
  ON slotlock.approvals (expires_at) WHERE status = 'pending';
4801
5038
 
5039
+ -- Stored hours (RFC 0004 B2): the tenant's (no location) and each location's. A resource without
5040
+ -- its own inherits its location's, the nearest up the chain, then its tenant's, then the server's.
5041
+ -- Rules are written validated (weekly rules, at most 50).
5042
+ CREATE TABLE IF NOT EXISTS slotlock.availability_defaults (
5043
+ tenant_ref text NOT NULL,
5044
+ location_ref text,
5045
+ rules jsonb NOT NULL,
5046
+ updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
5047
+ CONSTRAINT slotlock_availability_defaults_location_fkey FOREIGN KEY (tenant_ref, location_ref)
5048
+ REFERENCES slotlock.locations (tenant_ref, ref) ON DELETE CASCADE,
5049
+ CONSTRAINT slotlock_availability_defaults_valid CHECK (
5050
+ octet_length(tenant_ref) BETWEEN 1 AND 500
5051
+ AND jsonb_typeof(rules) = 'array' AND jsonb_array_length(rules) <= 50
5052
+ AND octet_length(rules::text) <= 65536
5053
+ )
5054
+ );
5055
+ CREATE UNIQUE INDEX IF NOT EXISTS slotlock_availability_defaults_scope_idx
5056
+ ON slotlock.availability_defaults (tenant_ref, (COALESCE(location_ref, '')));
5057
+
5058
+ -- Exceptions to the weekly hours (RFC 0004 B1): whole local days, closed or open with hours of
5059
+ -- their own, for the tenant, a location (and every resource inside it) or one resource. An import
5060
+ -- names its source's id, so importing again updates the same row.
5061
+ CREATE TABLE IF NOT EXISTS slotlock.availability_exceptions (
5062
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
5063
+ tenant_ref text NOT NULL,
5064
+ location_ref text,
5065
+ resource_id uuid,
5066
+ starts_on date NOT NULL,
5067
+ ends_on date NOT NULL,
5068
+ outcome text NOT NULL,
5069
+ hours jsonb,
5070
+ label text NOT NULL,
5071
+ source text NOT NULL DEFAULT 'manual',
5072
+ source_uid text,
5073
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
5074
+ CONSTRAINT slotlock_availability_exceptions_location_fkey FOREIGN KEY (tenant_ref, location_ref)
5075
+ REFERENCES slotlock.locations (tenant_ref, ref) ON DELETE CASCADE,
5076
+ CONSTRAINT slotlock_availability_exceptions_resource_fkey FOREIGN KEY (resource_id, tenant_ref)
5077
+ REFERENCES slotlock.resources (id, tenant_ref) ON DELETE CASCADE,
5078
+ CONSTRAINT slotlock_availability_exceptions_valid CHECK (
5079
+ octet_length(tenant_ref) BETWEEN 1 AND 500
5080
+ AND (location_ref IS NULL OR resource_id IS NULL)
5081
+ AND ends_on >= starts_on AND ends_on - starts_on < 367
5082
+ AND outcome IN ('closed', 'open')
5083
+ AND ((outcome = 'open') = (hours IS NOT NULL))
5084
+ AND (hours IS NULL OR (jsonb_typeof(hours) = 'array' AND jsonb_array_length(hours) BETWEEN 1 AND 4))
5085
+ AND char_length(label) BETWEEN 1 AND 200 AND btrim(label) <> ''
5086
+ AND source IN ('manual', 'ics', 'vavailability')
5087
+ AND (source_uid IS NULL OR octet_length(source_uid) BETWEEN 1 AND 600)
5088
+ )
5089
+ );
5090
+ CREATE UNIQUE INDEX IF NOT EXISTS slotlock_availability_exceptions_source_idx
5091
+ ON slotlock.availability_exceptions
5092
+ (tenant_ref, (COALESCE(location_ref, '')), (COALESCE(resource_id::text, '')), source_uid)
5093
+ WHERE source_uid IS NOT NULL;
5094
+ CREATE INDEX IF NOT EXISTS slotlock_availability_exceptions_days_idx
5095
+ ON slotlock.availability_exceptions (tenant_ref, ends_on, starts_on);
5096
+ CREATE INDEX IF NOT EXISTS slotlock_availability_exceptions_resource_idx
5097
+ ON slotlock.availability_exceptions (resource_id) WHERE resource_id IS NOT NULL;
5098
+
5099
+ -- A holiday calendar imported into closures (RFC 0004 B1), for the tenant, a location or a
5100
+ -- resource, by name: its closures name it, so importing it again removes the holidays it has
5101
+ -- dropped. With a URL it is a subscription, which maintenance imports again by next_import_at.
5102
+ -- Deleting it deletes its closures.
5103
+ CREATE TABLE IF NOT EXISTS slotlock.holiday_calendars (
5104
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
5105
+ tenant_ref text NOT NULL,
5106
+ location_ref text,
5107
+ resource_id uuid,
5108
+ name text NOT NULL,
5109
+ url text,
5110
+ imported_at timestamptz,
5111
+ next_import_at timestamptz,
5112
+ last_error text,
5113
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
5114
+ CONSTRAINT slotlock_holiday_calendars_tenant_key UNIQUE (id, tenant_ref),
5115
+ CONSTRAINT slotlock_holiday_calendars_location_fkey FOREIGN KEY (tenant_ref, location_ref)
5116
+ REFERENCES slotlock.locations (tenant_ref, ref) ON DELETE CASCADE,
5117
+ CONSTRAINT slotlock_holiday_calendars_resource_fkey FOREIGN KEY (resource_id, tenant_ref)
5118
+ REFERENCES slotlock.resources (id, tenant_ref) ON DELETE CASCADE,
5119
+ CONSTRAINT slotlock_holiday_calendars_valid CHECK (
5120
+ octet_length(tenant_ref) BETWEEN 1 AND 500
5121
+ AND (location_ref IS NULL OR resource_id IS NULL)
5122
+ AND char_length(name) BETWEEN 1 AND 200 AND btrim(name) <> ''
5123
+ AND (url IS NULL OR (url ~ '^https://' AND octet_length(url) <= 2048))
5124
+ AND (next_import_at IS NULL OR url IS NOT NULL)
5125
+ AND (last_error IS NULL OR char_length(last_error) <= 500)
5126
+ )
5127
+ );
5128
+ CREATE UNIQUE INDEX IF NOT EXISTS slotlock_holiday_calendars_name_idx
5129
+ ON slotlock.holiday_calendars (tenant_ref, (COALESCE(location_ref, '')), (COALESCE(resource_id::text, '')), name);
5130
+ CREATE INDEX IF NOT EXISTS slotlock_holiday_calendars_due_idx
5131
+ ON slotlock.holiday_calendars (tenant_ref, next_import_at) WHERE next_import_at IS NOT NULL;
5132
+ -- The calendar an imported closure came from: NULL for one added by hand, or imported before
5133
+ -- calendars were kept (claimed by the next import of a calendar that names it).
5134
+ ALTER TABLE slotlock.availability_exceptions ADD COLUMN IF NOT EXISTS calendar_id uuid;
5135
+ DO $slotlock_exception_calendar$
5136
+ BEGIN
5137
+ IF NOT EXISTS (
5138
+ SELECT 1 FROM pg_constraint
5139
+ WHERE conname = 'slotlock_availability_exceptions_calendar_fkey'
5140
+ AND conrelid = 'slotlock.availability_exceptions'::regclass
5141
+ ) THEN
5142
+ ALTER TABLE slotlock.availability_exceptions
5143
+ ADD CONSTRAINT slotlock_availability_exceptions_calendar_fkey FOREIGN KEY (calendar_id, tenant_ref)
5144
+ REFERENCES slotlock.holiday_calendars (id, tenant_ref) ON DELETE CASCADE;
5145
+ END IF;
5146
+ END
5147
+ $slotlock_exception_calendar$;
5148
+ CREATE INDEX IF NOT EXISTS slotlock_availability_exceptions_calendar_idx
5149
+ ON slotlock.availability_exceptions (calendar_id) WHERE calendar_id IS NOT NULL;
5150
+
5151
+ -- The hours a resource without its own inherits: its location's, the nearest up the chain, else its
5152
+ -- tenant's, as {source, location?, rules}; NULL leaves the server's default. Runs as the caller, so
5153
+ -- row-level security keeps it inside the tenant.
5154
+ CREATE OR REPLACE FUNCTION slotlock.inherited_availability(requested_tenant_ref text, requested_location_ref text)
5155
+ RETURNS jsonb
5156
+ LANGUAGE sql
5157
+ STABLE
5158
+ SET search_path = pg_catalog, slotlock, pg_temp
5159
+ AS $slotlock_inherited_availability$
5160
+ WITH RECURSIVE up(ref, parent_ref, steps) AS (
5161
+ SELECT place.ref, place.parent_ref, 1 FROM slotlock.locations place
5162
+ WHERE requested_location_ref IS NOT NULL
5163
+ AND place.tenant_ref = requested_tenant_ref AND place.ref = requested_location_ref
5164
+ UNION ALL
5165
+ SELECT place.ref, place.parent_ref, up.steps + 1
5166
+ FROM slotlock.locations place JOIN up ON place.tenant_ref = requested_tenant_ref AND place.ref = up.parent_ref
5167
+ WHERE up.steps <= ${SLOTLOCK_MAX_LOCATION_DEPTH}
5168
+ )
5169
+ SELECT COALESCE(
5170
+ (SELECT jsonb_build_object('source', 'location', 'location', up.ref, 'rules', defaults.rules)
5171
+ FROM up JOIN slotlock.availability_defaults defaults
5172
+ ON defaults.tenant_ref = requested_tenant_ref AND defaults.location_ref = up.ref
5173
+ ORDER BY up.steps LIMIT 1),
5174
+ (SELECT jsonb_build_object('source', 'tenant', 'rules', defaults.rules)
5175
+ FROM slotlock.availability_defaults defaults
5176
+ WHERE defaults.tenant_ref = requested_tenant_ref AND defaults.location_ref IS NULL)
5177
+ );
5178
+ $slotlock_inherited_availability$;
5179
+
4802
5180
  -- Every row occupies from its start to its occupied end: its end plus its turnaround buffer. Up to
4803
5181
  -- 0.1 the buffer bound only tenant-owned bookings to each other (slotlock_reservations_buffered_no_overlap),
4804
5182
  -- so a hold could take a booking's buffer and a buffered booking could cover a hold. The upgrade
@@ -5075,6 +5453,22 @@ ALTER TABLE slotlock.calendar_events ADD COLUMN IF NOT EXISTS recurrence_ends_by
5075
5453
  ALTER TABLE slotlock.calendar_events
5076
5454
  ADD COLUMN IF NOT EXISTS buffer_after_ms integer NOT NULL DEFAULT 0
5077
5455
  CONSTRAINT slotlock_calendar_events_buffer_valid CHECK (buffer_after_ms BETWEEN 0 AND 86400000);
5456
+ -- A turnaround may run to a week (RFC 0004 B3): the wider check admits every row the old one did,
5457
+ -- so it is added NOT VALID (no scan under the lock), and the old one dropped.
5458
+ DO $slotlock_calendar_event_week_turnaround$
5459
+ BEGIN
5460
+ IF NOT EXISTS (
5461
+ SELECT 1 FROM pg_catalog.pg_constraint
5462
+ WHERE conrelid = 'slotlock.calendar_events'::regclass
5463
+ AND conname = 'slotlock_calendar_events_buffer_week_valid'
5464
+ ) THEN
5465
+ ALTER TABLE slotlock.calendar_events
5466
+ ADD CONSTRAINT slotlock_calendar_events_buffer_week_valid
5467
+ CHECK (buffer_after_ms BETWEEN 0 AND 604800000) NOT VALID;
5468
+ END IF;
5469
+ ALTER TABLE slotlock.calendar_events DROP CONSTRAINT IF EXISTS slotlock_calendar_events_buffer_valid;
5470
+ END
5471
+ $slotlock_calendar_event_week_turnaround$;
5078
5472
  -- The leading buffer a booked event keeps before each occurrence, kept for a later roll alike.
5079
5473
  ALTER TABLE slotlock.calendar_events
5080
5474
  ADD COLUMN IF NOT EXISTS buffer_before_ms integer NOT NULL DEFAULT 0
@@ -5398,6 +5792,205 @@ CREATE TABLE IF NOT EXISTS slotlock.calendar_event_occurrences (
5398
5792
  )
5399
5793
  );
5400
5794
 
5795
+ -- Zone rule changes reach everything (RFC 0004 B9): a series' occurrences, and reservations that
5796
+ -- carry the wall time they were booked for, keep it, so maintenance finds those whose wall time now
5797
+ -- falls at another instant. An occurrence's is its series' zone's wall time when written; a
5798
+ -- reservation's is what the writer gave (local_start), in its resource's zone.
5799
+ ALTER TABLE slotlock.calendar_event_occurrences ADD COLUMN IF NOT EXISTS wall_starts_at timestamp;
5800
+ ALTER TABLE slotlock.calendar_event_occurrences ADD COLUMN IF NOT EXISTS zone_shift_reported_at timestamptz;
5801
+ CREATE OR REPLACE FUNCTION slotlock.calendar_event_occurrence_wall_time()
5802
+ RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, slotlock, pg_temp
5803
+ AS $slotlock_occurrence_wall_time$
5804
+ BEGIN
5805
+ IF TG_OP = 'INSERT' OR NEW.starts_at IS DISTINCT FROM OLD.starts_at THEN
5806
+ NEW.wall_starts_at := slotlock.zoned_wall(
5807
+ NEW.starts_at, (SELECT event.timezone FROM slotlock.calendar_events event WHERE event.id = NEW.event_id));
5808
+ NEW.zone_shift_reported_at := NULL;
5809
+ END IF;
5810
+ RETURN NEW;
5811
+ END
5812
+ $slotlock_occurrence_wall_time$;
5813
+ DROP TRIGGER IF EXISTS slotlock_calendar_event_occurrence_wall_time ON slotlock.calendar_event_occurrences;
5814
+ CREATE TRIGGER slotlock_calendar_event_occurrence_wall_time
5815
+ BEFORE INSERT OR UPDATE ON slotlock.calendar_event_occurrences
5816
+ FOR EACH ROW EXECUTE FUNCTION slotlock.calendar_event_occurrence_wall_time();
5817
+ -- Occurrences to come written before this release: their wall time as the rules read now. A new
5818
+ -- database has none, and may not have slotlock.zoned_wall yet at this point.
5819
+ DO $slotlock_occurrence_wall_backfill$
5820
+ BEGIN
5821
+ IF to_regprocedure('slotlock.zoned_wall(timestamptz, text)') IS NOT NULL THEN
5822
+ EXECUTE $backfill$
5823
+ UPDATE slotlock.calendar_event_occurrences occurrence
5824
+ SET wall_starts_at = slotlock.zoned_wall(occurrence.starts_at, event.timezone)
5825
+ FROM slotlock.calendar_events event
5826
+ WHERE event.id = occurrence.event_id AND occurrence.wall_starts_at IS NULL
5827
+ AND occurrence.starts_at > now()
5828
+ $backfill$;
5829
+ END IF;
5830
+ END
5831
+ $slotlock_occurrence_wall_backfill$;
5832
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS wall_starts_at timestamp;
5833
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS zone_shift_reported_at timestamptz;
5834
+
5835
+ -- Integrations (RFC 0004 P3). Core never sends a message and never holds a provider token; it keeps
5836
+ -- what the packages that do need. A booking may name the person to tell (contact_ref) and say not
5837
+ -- to tell anyone (notify); a hold may wait for the integrator's payment before it is confirmed.
5838
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS contact_ref text;
5839
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS notify boolean NOT NULL DEFAULT true;
5840
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS payment_required boolean NOT NULL DEFAULT false;
5841
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS payment_ref text;
5842
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS paid_at timestamptz;
5843
+ -- What a hold's payment must be, and what was received (RFC 0004 C3), in the currency's minor unit.
5844
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS payment_amount_minor bigint;
5845
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS payment_currency text;
5846
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS paid_amount_minor bigint;
5847
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS paid_currency text;
5848
+ ALTER TABLE slotlock.calendar_events ADD COLUMN IF NOT EXISTS notify boolean NOT NULL DEFAULT true;
5849
+ DO $slotlock_integration_columns$
5850
+ BEGIN
5851
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'slotlock_reservations_integration_valid'
5852
+ AND conrelid = 'slotlock.reservations'::regclass) THEN
5853
+ ALTER TABLE slotlock.reservations ADD CONSTRAINT slotlock_reservations_integration_valid CHECK (
5854
+ (contact_ref IS NULL OR contact_ref ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$')
5855
+ AND (payment_ref IS NULL OR octet_length(payment_ref) BETWEEN 1 AND 255)
5856
+ AND (paid_at IS NULL OR payment_ref IS NOT NULL)
5857
+ ) NOT VALID;
5858
+ END IF;
5859
+ END
5860
+ $slotlock_integration_columns$;
5861
+ DO $slotlock_payment_amounts$
5862
+ BEGIN
5863
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'slotlock_reservations_payment_amounts_valid'
5864
+ AND conrelid = 'slotlock.reservations'::regclass) THEN
5865
+ ALTER TABLE slotlock.reservations ADD CONSTRAINT slotlock_reservations_payment_amounts_valid CHECK (
5866
+ (payment_amount_minor IS NULL) = (payment_currency IS NULL)
5867
+ AND (paid_amount_minor IS NULL) = (paid_currency IS NULL)
5868
+ AND (payment_amount_minor IS NULL OR payment_amount_minor BETWEEN 0 AND 1000000000000)
5869
+ AND (paid_amount_minor IS NULL OR paid_amount_minor BETWEEN 0 AND 1000000000000)
5870
+ AND (payment_currency IS NULL OR payment_currency ~ '^[A-Z]{3}$')
5871
+ AND (paid_currency IS NULL OR paid_currency ~ '^[A-Z]{3}$')
5872
+ ) NOT VALID;
5873
+ END IF;
5874
+ END
5875
+ $slotlock_payment_amounts$;
5876
+ -- Forgetting a person finds the bookings that name them.
5877
+ CREATE INDEX IF NOT EXISTS slotlock_reservations_contact_idx
5878
+ ON slotlock.reservations (tenant_ref, contact_ref) WHERE contact_ref IS NOT NULL;
5879
+
5880
+ -- The people a tenant's bookings may tell (RFC 0004 P3): how to reach each, and what each agreed
5881
+ -- to. A person is erased by deleting their row; bookings keep no copy of what it held.
5882
+ CREATE TABLE IF NOT EXISTS slotlock.contacts (
5883
+ tenant_ref text NOT NULL,
5884
+ ref text NOT NULL,
5885
+ name text,
5886
+ email text,
5887
+ phone text,
5888
+ whatsapp text,
5889
+ consent jsonb NOT NULL DEFAULT '{}'::jsonb,
5890
+ locale text,
5891
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
5892
+ updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
5893
+ PRIMARY KEY (tenant_ref, ref),
5894
+ CONSTRAINT slotlock_contacts_valid CHECK (
5895
+ octet_length(tenant_ref) BETWEEN 1 AND 500
5896
+ AND ref ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$'
5897
+ AND (name IS NULL OR char_length(name) BETWEEN 1 AND 200)
5898
+ AND (email IS NULL OR (char_length(email) BETWEEN 3 AND 320 AND email LIKE '%_@_%'))
5899
+ AND (phone IS NULL OR phone ~ '^[+][1-9][0-9]{6,14}$')
5900
+ AND (whatsapp IS NULL OR whatsapp ~ '^[+][1-9][0-9]{6,14}$')
5901
+ AND jsonb_typeof(consent) = 'object' AND octet_length(consent::text) <= 512
5902
+ AND (locale IS NULL OR locale ~ '^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$')
5903
+ )
5904
+ );
5905
+
5906
+ -- Each reminder once (RFC 0004 P3): when maintenance put reminder.due on the feed for an
5907
+ -- occurrence, so a later pass never says it again.
5908
+ CREATE TABLE IF NOT EXISTS slotlock.reminder_dispatches (
5909
+ tenant_ref text NOT NULL,
5910
+ event_id uuid NOT NULL,
5911
+ recurrence_id text NOT NULL,
5912
+ ordinal integer NOT NULL,
5913
+ due_at timestamptz NOT NULL,
5914
+ dispatched_at timestamptz NOT NULL DEFAULT statement_timestamp(),
5915
+ PRIMARY KEY (event_id, recurrence_id, ordinal),
5916
+ CONSTRAINT slotlock_reminder_dispatches_event_fkey FOREIGN KEY (event_id, tenant_ref)
5917
+ REFERENCES slotlock.calendar_events (id, tenant_ref) ON DELETE CASCADE
5918
+ );
5919
+ CREATE INDEX IF NOT EXISTS slotlock_reminder_dispatches_tenant_idx
5920
+ ON slotlock.reminder_dispatches (tenant_ref, due_at);
5921
+
5922
+ -- A booking's reminders (RFC 0004 P3): minutes before its start, at most five; each said once on
5923
+ -- the feed as reminder.due, and again if the booking moves.
5924
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS reminder_minutes integer[];
5925
+ DO $slotlock_reservation_reminders$
5926
+ BEGIN
5927
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'slotlock_reservations_reminders_valid'
5928
+ AND conrelid = 'slotlock.reservations'::regclass) THEN
5929
+ ALTER TABLE slotlock.reservations ADD CONSTRAINT slotlock_reservations_reminders_valid CHECK (
5930
+ reminder_minutes IS NULL
5931
+ OR (cardinality(reminder_minutes) BETWEEN 1 AND 5
5932
+ AND 0 <= ALL (reminder_minutes) AND 527040 >= ALL (reminder_minutes))
5933
+ ) NOT VALID;
5934
+ END IF;
5935
+ END
5936
+ $slotlock_reservation_reminders$;
5937
+ CREATE TABLE IF NOT EXISTS slotlock.reservation_reminder_dispatches (
5938
+ tenant_ref text NOT NULL,
5939
+ reservation_id uuid NOT NULL,
5940
+ ordinal integer NOT NULL CHECK (ordinal BETWEEN 0 AND 4),
5941
+ due_at timestamptz NOT NULL,
5942
+ dispatched_at timestamptz NOT NULL DEFAULT statement_timestamp(),
5943
+ PRIMARY KEY (reservation_id, ordinal),
5944
+ CONSTRAINT slotlock_reservation_reminder_dispatches_fkey FOREIGN KEY (reservation_id, tenant_ref)
5945
+ REFERENCES slotlock.reservations (id, tenant_ref) ON DELETE CASCADE
5946
+ );
5947
+ CREATE INDEX IF NOT EXISTS slotlock_reservation_reminder_dispatches_tenant_idx
5948
+ ON slotlock.reservation_reminder_dispatches (tenant_ref, due_at);
5949
+
5950
+ -- Each hold's payment, kept once recorded (RFC 0004 C3), however the hold ends: confirmed in place,
5951
+ -- booked as an event (its row deleted), released or expired. So the same payment, told again later,
5952
+ -- is answered as the payment already recorded, never as one to refund.
5953
+ CREATE TABLE IF NOT EXISTS slotlock.hold_payments (
5954
+ tenant_ref text NOT NULL,
5955
+ hold_id uuid PRIMARY KEY,
5956
+ resource_id uuid NOT NULL,
5957
+ starts_at timestamptz NOT NULL,
5958
+ ends_at timestamptz NOT NULL,
5959
+ buffer_after_ms bigint NOT NULL DEFAULT 0,
5960
+ expires_at timestamptz NOT NULL,
5961
+ payment_ref text NOT NULL CHECK (octet_length(payment_ref) BETWEEN 1 AND 255),
5962
+ paid_at timestamptz NOT NULL DEFAULT statement_timestamp(),
5963
+ payment_amount_minor bigint,
5964
+ payment_currency text,
5965
+ paid_amount_minor bigint,
5966
+ paid_currency text,
5967
+ CONSTRAINT slotlock_hold_payments_resource_fkey FOREIGN KEY (resource_id, tenant_ref)
5968
+ REFERENCES slotlock.resources (id, tenant_ref) ON DELETE CASCADE
5969
+ );
5970
+ -- When each channel's consent was last given or withdrawn, as proof of it.
5971
+ ALTER TABLE slotlock.contacts ADD COLUMN IF NOT EXISTS consent_at jsonb NOT NULL DEFAULT '{}'::jsonb;
5972
+
5973
+ -- Busy time a calendar reports without its events (Google freeBusy, Microsoft getSchedule), for one
5974
+ -- resource and source (RFC 0004 C1): replaced window by window with the source's coverage, counted
5975
+ -- in free/busy and refused to every booking Slotlock makes, as an event's time would be.
5976
+ CREATE TABLE IF NOT EXISTS slotlock.source_busy (
5977
+ tenant_ref text NOT NULL,
5978
+ resource_id uuid NOT NULL,
5979
+ source text NOT NULL,
5980
+ starts_at timestamptz NOT NULL,
5981
+ ends_at timestamptz NOT NULL,
5982
+ CONSTRAINT slotlock_source_busy_resource_fkey FOREIGN KEY (resource_id, tenant_ref)
5983
+ REFERENCES slotlock.resources (id, tenant_ref) ON DELETE CASCADE,
5984
+ CONSTRAINT slotlock_source_busy_valid CHECK (
5985
+ octet_length(tenant_ref) BETWEEN 1 AND 500 AND octet_length(source) BETWEEN 1 AND 500
5986
+ AND ends_at > starts_at
5987
+ )
5988
+ );
5989
+ CREATE INDEX IF NOT EXISTS slotlock_source_busy_resource_idx
5990
+ ON slotlock.source_busy (resource_id, starts_at, ends_at);
5991
+ CREATE INDEX IF NOT EXISTS slotlock_source_busy_source_idx
5992
+ ON slotlock.source_busy (resource_id, source, starts_at);
5993
+
5401
5994
  -- A cancelled UID remains terminal. This blocks delayed create delivery after the event row and
5402
5995
  -- its derived reservations have been removed.
5403
5996
  CREATE TABLE IF NOT EXISTS slotlock.calendar_event_tombstones (
@@ -5742,6 +6335,7 @@ export const SLOTLOCK_TENANT_TABLES = Object.freeze([
5742
6335
  'booking_rule_defaults',
5743
6336
  'assistant_settings',
5744
6337
  'assistant_conversations',
6338
+ 'assistant_chats',
5745
6339
  'locations',
5746
6340
  'resource_groups',
5747
6341
  'resource_group_members',
@@ -5749,6 +6343,14 @@ export const SLOTLOCK_TENANT_TABLES = Object.freeze([
5749
6343
  'composite_claims',
5750
6344
  'approval_policies',
5751
6345
  'approvals',
6346
+ 'availability_defaults',
6347
+ 'availability_exceptions',
6348
+ 'holiday_calendars',
6349
+ 'contacts',
6350
+ 'reminder_dispatches',
6351
+ 'reservation_reminder_dispatches',
6352
+ 'hold_payments',
6353
+ 'source_busy',
5752
6354
  ]);
5753
6355
  const POSTGRES_SETTING_NAME = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/;
5754
6356
  const POSTGRES_ROLE_NAME = /^[a-z_][a-z0-9_]{0,62}$/;
@@ -5797,6 +6399,11 @@ export const SLOTLOCK_RESOURCE_SCOPED_TABLES = Object.freeze({
5797
6399
  // A change or a waitlist entry about no one resource (a key, a pool) is not the key's.
5798
6400
  outbox: resourceScopeAllows('resource_id'),
5799
6401
  waitlist_entries: resourceScopeAllows('resource_id'),
6402
+ // The tenant's and locations' exceptions shape every resource's hours, so every key reads them.
6403
+ availability_exceptions: `(resource_id IS NULL OR ${resourceScopeAllows('resource_id')})`,
6404
+ holiday_calendars: `(resource_id IS NULL OR ${resourceScopeAllows('resource_id')})`,
6405
+ source_busy: resourceScopeAllows('resource_id'),
6406
+ hold_payments: resourceScopeAllows('resource_id'),
5800
6407
  ...Object.fromEntries([
5801
6408
  'calendar_event_attendees',
5802
6409
  'calendar_event_reminders',
@@ -5862,6 +6469,8 @@ ALTER TABLE slotlock.assistant_settings ENABLE ROW LEVEL SECURITY;
5862
6469
  ALTER TABLE slotlock.assistant_settings FORCE ROW LEVEL SECURITY;
5863
6470
  ALTER TABLE slotlock.assistant_conversations ENABLE ROW LEVEL SECURITY;
5864
6471
  ALTER TABLE slotlock.assistant_conversations FORCE ROW LEVEL SECURITY;
6472
+ ALTER TABLE slotlock.assistant_chats ENABLE ROW LEVEL SECURITY;
6473
+ ALTER TABLE slotlock.assistant_chats FORCE ROW LEVEL SECURITY;
5865
6474
  ALTER TABLE slotlock.locations ENABLE ROW LEVEL SECURITY;
5866
6475
  ALTER TABLE slotlock.locations FORCE ROW LEVEL SECURITY;
5867
6476
  ALTER TABLE slotlock.resource_groups ENABLE ROW LEVEL SECURITY;
@@ -5876,6 +6485,22 @@ ALTER TABLE slotlock.approval_policies ENABLE ROW LEVEL SECURITY;
5876
6485
  ALTER TABLE slotlock.approval_policies FORCE ROW LEVEL SECURITY;
5877
6486
  ALTER TABLE slotlock.approvals ENABLE ROW LEVEL SECURITY;
5878
6487
  ALTER TABLE slotlock.approvals FORCE ROW LEVEL SECURITY;
6488
+ ALTER TABLE slotlock.availability_defaults ENABLE ROW LEVEL SECURITY;
6489
+ ALTER TABLE slotlock.availability_defaults FORCE ROW LEVEL SECURITY;
6490
+ ALTER TABLE slotlock.availability_exceptions ENABLE ROW LEVEL SECURITY;
6491
+ ALTER TABLE slotlock.availability_exceptions FORCE ROW LEVEL SECURITY;
6492
+ ALTER TABLE slotlock.contacts ENABLE ROW LEVEL SECURITY;
6493
+ ALTER TABLE slotlock.contacts FORCE ROW LEVEL SECURITY;
6494
+ ALTER TABLE slotlock.reminder_dispatches ENABLE ROW LEVEL SECURITY;
6495
+ ALTER TABLE slotlock.reminder_dispatches FORCE ROW LEVEL SECURITY;
6496
+ ALTER TABLE slotlock.reservation_reminder_dispatches ENABLE ROW LEVEL SECURITY;
6497
+ ALTER TABLE slotlock.reservation_reminder_dispatches FORCE ROW LEVEL SECURITY;
6498
+ ALTER TABLE slotlock.hold_payments ENABLE ROW LEVEL SECURITY;
6499
+ ALTER TABLE slotlock.hold_payments FORCE ROW LEVEL SECURITY;
6500
+ ALTER TABLE slotlock.holiday_calendars ENABLE ROW LEVEL SECURITY;
6501
+ ALTER TABLE slotlock.holiday_calendars FORCE ROW LEVEL SECURITY;
6502
+ ALTER TABLE slotlock.source_busy ENABLE ROW LEVEL SECURITY;
6503
+ ALTER TABLE slotlock.source_busy FORCE ROW LEVEL SECURITY;
5879
6504
 
5880
6505
  -- One database must have one tenant-context contract. Re-applying with an adapter-specific
5881
6506
  -- setting deliberately rebinds the stable policies instead of silently keeping a prior setting.
@@ -5908,13 +6533,22 @@ BEGIN
5908
6533
  'booking_rule_defaults',
5909
6534
  'assistant_settings',
5910
6535
  'assistant_conversations',
6536
+ 'assistant_chats',
5911
6537
  'locations',
5912
6538
  'resource_groups',
5913
6539
  'resource_group_members',
5914
6540
  'pool_settings',
5915
6541
  'composite_claims',
5916
6542
  'approval_policies',
5917
- 'approvals'
6543
+ 'approvals',
6544
+ 'availability_defaults',
6545
+ 'availability_exceptions',
6546
+ 'holiday_calendars',
6547
+ 'contacts',
6548
+ 'reminder_dispatches',
6549
+ 'reservation_reminder_dispatches',
6550
+ 'hold_payments',
6551
+ 'source_busy'
5918
6552
  )
5919
6553
  LOOP
5920
6554
  EXECUTE format(
@@ -6156,6 +6790,16 @@ BEGIN
6156
6790
  WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
6157
6791
  END IF;
6158
6792
 
6793
+ IF NOT EXISTS (
6794
+ SELECT 1 FROM pg_policies
6795
+ WHERE schemaname = 'slotlock' AND tablename = 'assistant_chats'
6796
+ AND policyname = 'tenant_isolation_v1'
6797
+ ) THEN
6798
+ CREATE POLICY tenant_isolation_v1 ON slotlock.assistant_chats
6799
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
6800
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
6801
+ END IF;
6802
+
6159
6803
  IF NOT EXISTS (
6160
6804
  SELECT 1 FROM pg_policies
6161
6805
  WHERE schemaname = 'slotlock' AND tablename = 'locations'
@@ -6225,6 +6869,86 @@ BEGIN
6225
6869
  USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
6226
6870
  WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
6227
6871
  END IF;
6872
+
6873
+ IF NOT EXISTS (
6874
+ SELECT 1 FROM pg_policies
6875
+ WHERE schemaname = 'slotlock' AND tablename = 'availability_defaults'
6876
+ AND policyname = 'tenant_isolation_v1'
6877
+ ) THEN
6878
+ CREATE POLICY tenant_isolation_v1 ON slotlock.availability_defaults
6879
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
6880
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
6881
+ END IF;
6882
+
6883
+ IF NOT EXISTS (
6884
+ SELECT 1 FROM pg_policies
6885
+ WHERE schemaname = 'slotlock' AND tablename = 'availability_exceptions'
6886
+ AND policyname = 'tenant_isolation_v1'
6887
+ ) THEN
6888
+ CREATE POLICY tenant_isolation_v1 ON slotlock.availability_exceptions
6889
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
6890
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
6891
+ END IF;
6892
+
6893
+ IF NOT EXISTS (
6894
+ SELECT 1 FROM pg_policies
6895
+ WHERE schemaname = 'slotlock' AND tablename = 'contacts'
6896
+ AND policyname = 'tenant_isolation_v1'
6897
+ ) THEN
6898
+ CREATE POLICY tenant_isolation_v1 ON slotlock.contacts
6899
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
6900
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
6901
+ END IF;
6902
+
6903
+ IF NOT EXISTS (
6904
+ SELECT 1 FROM pg_policies
6905
+ WHERE schemaname = 'slotlock' AND tablename = 'reminder_dispatches'
6906
+ AND policyname = 'tenant_isolation_v1'
6907
+ ) THEN
6908
+ CREATE POLICY tenant_isolation_v1 ON slotlock.reminder_dispatches
6909
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
6910
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
6911
+ END IF;
6912
+
6913
+ IF NOT EXISTS (
6914
+ SELECT 1 FROM pg_policies
6915
+ WHERE schemaname = 'slotlock' AND tablename = 'reservation_reminder_dispatches'
6916
+ AND policyname = 'tenant_isolation_v1'
6917
+ ) THEN
6918
+ CREATE POLICY tenant_isolation_v1 ON slotlock.reservation_reminder_dispatches
6919
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
6920
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
6921
+ END IF;
6922
+
6923
+ IF NOT EXISTS (
6924
+ SELECT 1 FROM pg_policies
6925
+ WHERE schemaname = 'slotlock' AND tablename = 'hold_payments'
6926
+ AND policyname = 'tenant_isolation_v1'
6927
+ ) THEN
6928
+ CREATE POLICY tenant_isolation_v1 ON slotlock.hold_payments
6929
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
6930
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
6931
+ END IF;
6932
+
6933
+ IF NOT EXISTS (
6934
+ SELECT 1 FROM pg_policies
6935
+ WHERE schemaname = 'slotlock' AND tablename = 'holiday_calendars'
6936
+ AND policyname = 'tenant_isolation_v1'
6937
+ ) THEN
6938
+ CREATE POLICY tenant_isolation_v1 ON slotlock.holiday_calendars
6939
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
6940
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
6941
+ END IF;
6942
+
6943
+ IF NOT EXISTS (
6944
+ SELECT 1 FROM pg_policies
6945
+ WHERE schemaname = 'slotlock' AND tablename = 'source_busy'
6946
+ AND policyname = 'tenant_isolation_v1'
6947
+ ) THEN
6948
+ CREATE POLICY tenant_isolation_v1 ON slotlock.source_busy
6949
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
6950
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
6951
+ END IF;
6228
6952
  END
6229
6953
  $slotlock_rls$;
6230
6954
 
@@ -6268,6 +6992,8 @@ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.assistant_settings
6268
6992
  IS 'slotlock:tenant-context:${tenantContextSetting}';
6269
6993
  COMMENT ON POLICY tenant_isolation_v1 ON slotlock.assistant_conversations
6270
6994
  IS 'slotlock:tenant-context:${tenantContextSetting}';
6995
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.assistant_chats
6996
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
6271
6997
  COMMENT ON POLICY tenant_isolation_v1 ON slotlock.locations
6272
6998
  IS 'slotlock:tenant-context:${tenantContextSetting}';
6273
6999
  COMMENT ON POLICY tenant_isolation_v1 ON slotlock.resource_groups
@@ -6282,6 +7008,22 @@ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.approval_policies
6282
7008
  IS 'slotlock:tenant-context:${tenantContextSetting}';
6283
7009
  COMMENT ON POLICY tenant_isolation_v1 ON slotlock.approvals
6284
7010
  IS 'slotlock:tenant-context:${tenantContextSetting}';
7011
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.availability_defaults
7012
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
7013
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.availability_exceptions
7014
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
7015
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.contacts
7016
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
7017
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.reminder_dispatches
7018
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
7019
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.reservation_reminder_dispatches
7020
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
7021
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.hold_payments
7022
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
7023
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.holiday_calendars
7024
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
7025
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.source_busy
7026
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
6285
7027
  ${SLOTLOCK_RESOURCE_SCOPE_POLICIES}
6286
7028
 
6287
7029
  INSERT INTO slotlock.rls_policy_contracts (
@@ -6326,13 +7068,22 @@ WHERE schemaname = 'slotlock'
6326
7068
  'booking_rule_defaults',
6327
7069
  'assistant_settings',
6328
7070
  'assistant_conversations',
7071
+ 'assistant_chats',
6329
7072
  'locations',
6330
7073
  'resource_groups',
6331
7074
  'resource_group_members',
6332
7075
  'pool_settings',
6333
7076
  'composite_claims',
6334
7077
  'approval_policies',
6335
- 'approvals'
7078
+ 'approvals',
7079
+ 'availability_defaults',
7080
+ 'availability_exceptions',
7081
+ 'holiday_calendars',
7082
+ 'contacts',
7083
+ 'reminder_dispatches',
7084
+ 'reservation_reminder_dispatches',
7085
+ 'hold_payments',
7086
+ 'source_busy'
6336
7087
  )
6337
7088
  AND policyname = 'tenant_isolation_v1'
6338
7089
  ON CONFLICT (table_name) DO UPDATE