@fayz-ai/plugin-agenda 0.27.10 → 0.27.12

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 (45) hide show
  1. package/dist/AgendaGeneralSettings-YBI43RB6.js +96 -0
  2. package/dist/AgendaGeneralSettings-YBI43RB6.js.map +1 -0
  3. package/dist/AgendaMessageTemplates-46ZLTFLM.js +31 -0
  4. package/dist/AgendaMessageTemplates-46ZLTFLM.js.map +1 -0
  5. package/dist/AgendaPage-43OXQDZE.js +3174 -0
  6. package/dist/AgendaPage-43OXQDZE.js.map +1 -0
  7. package/dist/CheckinPage-SGWK2TQZ.js +904 -0
  8. package/dist/CheckinPage-SGWK2TQZ.js.map +1 -0
  9. package/dist/ConfirmationsView-DZJSYBXA.js +361 -0
  10. package/dist/ConfirmationsView-DZJSYBXA.js.map +1 -0
  11. package/dist/MyClientsTodayWidget-ZGTXJJKN.js +127 -0
  12. package/dist/MyClientsTodayWidget-ZGTXJJKN.js.map +1 -0
  13. package/dist/PersonAttendanceBlock-PHZVHFQ4.js +105 -0
  14. package/dist/PersonAttendanceBlock-PHZVHFQ4.js.map +1 -0
  15. package/dist/PersonAttendancesWidget-3NTWBGCU.js +1278 -0
  16. package/dist/PersonAttendancesWidget-3NTWBGCU.js.map +1 -0
  17. package/dist/PersonServicesWidget-4RJGKX3K.js +80 -0
  18. package/dist/PersonServicesWidget-4RJGKX3K.js.map +1 -0
  19. package/dist/chunk-37INOUNW.js +20 -0
  20. package/dist/chunk-37INOUNW.js.map +1 -0
  21. package/dist/chunk-A2VD5DVN.js +12 -0
  22. package/dist/chunk-A2VD5DVN.js.map +1 -0
  23. package/dist/chunk-HGJI2JR6.js +169 -0
  24. package/dist/chunk-HGJI2JR6.js.map +1 -0
  25. package/dist/chunk-VFQPXKXP.js +11 -0
  26. package/dist/chunk-VFQPXKXP.js.map +1 -0
  27. package/dist/chunk-WNANVT4F.js +2386 -0
  28. package/dist/chunk-WNANVT4F.js.map +1 -0
  29. package/dist/chunk-X7PKHXT6.js +18 -0
  30. package/dist/chunk-X7PKHXT6.js.map +1 -0
  31. package/dist/chunk-XTNSK4RJ.js +12 -0
  32. package/dist/chunk-XTNSK4RJ.js.map +1 -0
  33. package/dist/components/PersonAttendanceBlock.d.ts.map +1 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +2530 -19553
  36. package/dist/index.js.map +1 -1
  37. package/dist/migrations/integrations-google-calendar.browser.js +15 -0
  38. package/dist/migrations/integrations-google-calendar.browser.js.map +1 -0
  39. package/dist/migrations/integrations-google-calendar.js +1992 -0
  40. package/dist/migrations/integrations-google-calendar.js.map +1 -0
  41. package/dist/migrations/main.browser.js +29 -0
  42. package/dist/migrations/main.browser.js.map +1 -0
  43. package/dist/migrations/main.js +6440 -0
  44. package/dist/migrations/main.js.map +1 -0
  45. package/package.json +13 -7
@@ -0,0 +1,1992 @@
1
+ // src/integrations/google-calendar/migrations/index.ts
2
+ var MIGRATION_001_GOOGLE_CALENDAR = `-- ---------------------------------------------------------------------------
3
+ -- Google Calendar integration (bidirectional) \u2014 connection + audit + triggers
4
+ -- ---------------------------------------------------------------------------
5
+ -- Outbound (Fayz booking \u2192 Google event) fires from a trigger on public.appointments
6
+ -- via pg_net to the google-calendar-sync edge function (push_event). Inbound
7
+ -- (Google \u2192 booking) runs on a pg_cron schedule (pull_events) and/or a Google
8
+ -- watch-channel webhook. The booking\u2194event link is stored on
9
+ -- public.appointments.metadata.googleCalendarEventId (no schema change there).
10
+
11
+ -- Plugin-owned tables carry the platform \`plg_<plugin>_\` prefix (DATA-MODEL.md
12
+ -- Ring 1). Legacy pools provisioned under the old \`calendar_*\` names are migrated
13
+ -- by 003_plg_rename.sql; fresh installs are born with the prefixed names here.
14
+ CREATE TABLE IF NOT EXISTS public.plg_calendar_integrations (
15
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
16
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
17
+ provider text NOT NULL DEFAULT 'google',
18
+ oauth_refresh_token text,
19
+ oauth_access_token text,
20
+ token_expires_at timestamptz,
21
+ calendar_id text NOT NULL DEFAULT 'primary',
22
+ -- Incremental pull cursor + Google watch channel bookkeeping.
23
+ sync_token text,
24
+ channel_id text,
25
+ channel_resource_id text,
26
+ channel_expires_at timestamptz,
27
+ active boolean NOT NULL DEFAULT true,
28
+ last_sync_at timestamptz,
29
+ created_at timestamptz NOT NULL DEFAULT now(),
30
+ updated_at timestamptz NOT NULL DEFAULT now(),
31
+ UNIQUE (tenant_id, provider)
32
+ );
33
+ ALTER TABLE public.plg_calendar_integrations ENABLE ROW LEVEL SECURITY;
34
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_integrations_tenant ON public.plg_calendar_integrations(tenant_id);
35
+
36
+ CREATE TABLE IF NOT EXISTS public.plg_calendar_sync_log (
37
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
38
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
39
+ direction text NOT NULL, -- inbound | outbound
40
+ trigger text, -- on-write | scheduled | webhook | manual
41
+ status text NOT NULL DEFAULT 'success', -- success | partial | error
42
+ fetched integer NOT NULL DEFAULT 0,
43
+ written integer NOT NULL DEFAULT 0,
44
+ error text,
45
+ created_at timestamptz NOT NULL DEFAULT now()
46
+ );
47
+ ALTER TABLE public.plg_calendar_sync_log ENABLE ROW LEVEL SECURITY;
48
+
49
+ -- RLS: tenant isolation.
50
+ DO $$
51
+ DECLARE t text;
52
+ BEGIN
53
+ FOR t IN SELECT unnest(ARRAY['plg_calendar_integrations','plg_calendar_sync_log'])
54
+ LOOP
55
+ EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO authenticated', t);
56
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_rls') THEN
57
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR ALL TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids())) WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_rls', t);
58
+ END IF;
59
+ END LOOP;
60
+ END $$;
61
+
62
+ -- ---------------------------------------------------------------------------
63
+ -- Outbound trigger: booking change \u2192 edge function (push_event).
64
+ -- Requires pg_net + a configured function URL. Set once per project:
65
+ -- ALTER DATABASE postgres SET app.gcal_sync_url = 'https://<ref>.functions.supabase.co/google-calendar-sync';
66
+ -- ALTER DATABASE postgres SET app.gcal_service_key = '<service-role-key>';
67
+ -- Safe to skip in environments without pg_net \u2014 outbound also works via the
68
+ -- client-side withGoogleCalendarSync() decorator.
69
+ -- ---------------------------------------------------------------------------
70
+ CREATE OR REPLACE FUNCTION public.fn_gcal_push_booking() RETURNS trigger
71
+ LANGUAGE plpgsql SECURITY DEFINER AS $$
72
+ DECLARE
73
+ url text := current_setting('app.gcal_sync_url', true);
74
+ key text := current_setting('app.gcal_service_key', true);
75
+ rec record := COALESCE(NEW, OLD);
76
+ op text := lower(TG_OP);
77
+ BEGIN
78
+ IF url IS NULL OR url = '' THEN RETURN COALESCE(NEW, OLD); END IF;
79
+ PERFORM net.http_post(
80
+ url := url,
81
+ headers := jsonb_build_object('Content-Type', 'application/json', 'Authorization', 'Bearer ' || COALESCE(key, '')),
82
+ body := jsonb_build_object('action', 'push_event', 'op', op, 'bookingId', rec.id, 'tenantId', rec.tenant_id)
83
+ );
84
+ RETURN COALESCE(NEW, OLD);
85
+ END $$;
86
+
87
+ DROP TRIGGER IF EXISTS trg_gcal_push ON public.appointments;
88
+ CREATE TRIGGER trg_gcal_push
89
+ AFTER INSERT OR UPDATE OR DELETE ON public.appointments
90
+ FOR EACH ROW EXECUTE FUNCTION public.fn_gcal_push_booking();
91
+
92
+ -- Inbound schedule (optional): pull external changes every 10 minutes.
93
+ -- SELECT cron.schedule('gcal-pull', '*/10 * * * *', $$
94
+ -- SELECT net.http_post(
95
+ -- url := current_setting('app.gcal_sync_url'),
96
+ -- headers := jsonb_build_object('Content-Type','application/json','Authorization','Bearer '||current_setting('app.gcal_service_key')),
97
+ -- body := jsonb_build_object('action','pull_events'));
98
+ -- $$);
99
+ `;
100
+ var MIGRATION_002_SMART_SYNC = `-- ---------------------------------------------------------------------------
101
+ -- Google Calendar SMART SYNC \u2014 multi-calendar de-para + loop-safe two-way sync
102
+ -- ---------------------------------------------------------------------------
103
+ -- Builds on 001_google_calendar.sql. Where 001 modelled a single calendar per
104
+ -- tenant (plg_calendar_integrations, one outbound trigger, a pull template), 002
105
+ -- makes the sync vertical-agnostic and bidirectional-safe:
106
+ --
107
+ -- \xA71 public.plg_calendar_channels \u2014 de-para: one Google calendar \u2192 one sync
108
+ -- target (whole tenant / assignee / service
109
+ -- / location), per direction + import mode
110
+ -- \xA72 fn_gcal_push_booking (REPLACE)\u2014 loop-prevention guard + correlation id
111
+ -- \xA73 plg_calendar_sync_log \u2014 correlation_id + channel_id_ref columns
112
+ -- \xA74 inbound write functions \u2014 the ONLY path Google\u2192booking writes take
113
+ -- (gcal_import_event / gcal_apply_event_patch
114
+ -- / gcal_unlink_channel), SECURITY DEFINER,
115
+ -- service_role-only
116
+ -- \xA75 get_available_slots (REPLACE) \u2014 tenant-wide imported blocks now also
117
+ -- remove slots for every professional
118
+ -- \xA76 pg_cron template \u2014 per-channel pull
119
+ --
120
+ -- Idempotent / re-runnable: IF NOT EXISTS, CREATE OR REPLACE, DROP \u2026 IF EXISTS.
121
+ -- Written against the canonical public names (appointments uses starts_at/ends_at,
122
+ -- metadata jsonb, party_id/assignee_id \u2014 see packages/db 004_archetypes.sql).
123
+ -- ---------------------------------------------------------------------------
124
+
125
+ -- ===========================================================================
126
+ -- \xA71 \u2014 plg_calendar_channels: multi-calendar de-para (vertical-agnostic)
127
+ -- target_kind NULL \u2192 whole tenant agenda (agency-os): every professional
128
+ -- target_kind 'assignee' \u2192 calendar maps to one professional (school: teacher)
129
+ -- target_kind 'service' | 'location' \u2192 clinic-style mapping
130
+ -- import_mode 'block' \u2192 inbound events become opaque busy blocks
131
+ -- import_mode 'appointment' \u2192 inbound events become real appointments
132
+ -- ===========================================================================
133
+ CREATE TABLE IF NOT EXISTS public.plg_calendar_channels (
134
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
135
+ integration_id uuid NOT NULL REFERENCES public.plg_calendar_integrations(id) ON DELETE CASCADE,
136
+ tenant_id uuid NOT NULL,
137
+ google_calendar_id text NOT NULL,
138
+ summary text,
139
+ color text,
140
+ direction text NOT NULL DEFAULT 'bidirectional'
141
+ CHECK (direction IN ('inbound','outbound','bidirectional','off')),
142
+ target_kind text CHECK (target_kind IN ('assignee','service','location')),
143
+ target_id uuid,
144
+ import_mode text NOT NULL DEFAULT 'block'
145
+ CHECK (import_mode IN ('block','appointment')),
146
+ sync_token text,
147
+ channel_id text,
148
+ resource_id text,
149
+ channel_expires_at timestamptz,
150
+ is_active boolean NOT NULL DEFAULT true,
151
+ created_at timestamptz NOT NULL DEFAULT now(),
152
+ updated_at timestamptz NOT NULL DEFAULT now(),
153
+ UNIQUE (integration_id, google_calendar_id)
154
+ );
155
+
156
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_channels_tenant ON public.plg_calendar_channels(tenant_id);
157
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_channels_integration ON public.plg_calendar_channels(integration_id);
158
+
159
+ ALTER TABLE public.plg_calendar_channels ENABLE ROW LEVEL SECURITY;
160
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_calendar_channels TO authenticated;
161
+ -- service_role bypasses RLS, but this table is born after 004's blanket grant,
162
+ -- so grant it explicitly (the inbound functions run as definer/service_role).
163
+ GRANT ALL ON public.plg_calendar_channels TO service_role;
164
+
165
+ DROP TRIGGER IF EXISTS plg_calendar_channels_updated_at ON public.plg_calendar_channels;
166
+ CREATE TRIGGER plg_calendar_channels_updated_at BEFORE UPDATE ON public.plg_calendar_channels
167
+ FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
168
+
169
+ -- RLS: tenant isolation (padr\xE3o 001) + explicit service_role FOR ALL policy
170
+ -- (matches the vigente service_role policy pattern from 001_core payment_events).
171
+ DROP POLICY IF EXISTS plg_calendar_channels_rls ON public.plg_calendar_channels;
172
+ CREATE POLICY plg_calendar_channels_rls ON public.plg_calendar_channels
173
+ FOR ALL TO authenticated
174
+ USING (tenant_id IN (SELECT public.user_tenant_ids()))
175
+ WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
176
+
177
+ DROP POLICY IF EXISTS plg_calendar_channels_service ON public.plg_calendar_channels;
178
+ CREATE POLICY plg_calendar_channels_service ON public.plg_calendar_channels
179
+ FOR ALL TO service_role USING (true) WITH CHECK (true);
180
+
181
+ -- ===========================================================================
182
+ -- \xA72 \u2014 Outbound trigger with loop-prevention + correlation id.
183
+ -- CREATE OR REPLACE of 001's fn_gcal_push_booking. Same pg_net \u2192 edge function
184
+ -- payload (app.gcal_sync_url / app.gcal_service_key), but now:
185
+ -- * SKIP the push when the write itself came from the Google sync, detected by
186
+ -- the TRANSACTION-LOCAL GUC app.booking_origin = 'google' \u2014 set by every \xA74
187
+ -- writer via set_config(..., true) and gone when the transaction ends.
188
+ -- This is what stops the Google\u2192booking\u2192Google\u2192\u2026 echo loop.
189
+ -- Deliberately NOT a persistent metadata check: a sticky marker
190
+ -- (metadata.syncOrigin) would permanently mute pushes for imported events,
191
+ -- so a later admin edit would never reach Google. metadata.syncOrigin is
192
+ -- kept by \xA74 as provenance only \u2014 this trigger does not read it.
193
+ -- * carry a correlation_id so both directions can be stitched in the sync log
194
+ -- (from GUC app.booking_correlation_id, else a fresh uuid \u2014 design ported
195
+ -- from PR #7).
196
+ -- ===========================================================================
197
+ CREATE OR REPLACE FUNCTION public.fn_gcal_push_booking() RETURNS trigger
198
+ LANGUAGE plpgsql SECURITY DEFINER AS $$
199
+ DECLARE
200
+ url text := current_setting('app.gcal_sync_url', true);
201
+ key text := current_setting('app.gcal_service_key', true);
202
+ rec record := COALESCE(NEW, OLD);
203
+ op text := lower(TG_OP);
204
+ corr text := NULLIF(current_setting('app.booking_correlation_id', true), '');
205
+ BEGIN
206
+ -- Loop guard: never bounce a Google-originated write back to Google.
207
+ -- Transaction-local GUC only (see \xA72 header for why not persistent metadata).
208
+ IF current_setting('app.booking_origin', true) = 'google' THEN
209
+ RETURN COALESCE(NEW, OLD);
210
+ END IF;
211
+
212
+ IF url IS NULL OR url = '' THEN RETURN COALESCE(NEW, OLD); END IF;
213
+
214
+ PERFORM net.http_post(
215
+ url := url,
216
+ headers := jsonb_build_object('Content-Type', 'application/json',
217
+ 'Authorization', 'Bearer ' || COALESCE(key, '')),
218
+ body := jsonb_build_object(
219
+ 'action', 'push_event',
220
+ 'op', op,
221
+ 'bookingId', rec.id,
222
+ 'tenantId', rec.tenant_id,
223
+ 'correlationId', COALESCE(corr, gen_random_uuid()::text)
224
+ )
225
+ );
226
+ RETURN COALESCE(NEW, OLD);
227
+ END $$;
228
+
229
+ -- Re-assert the trigger (idempotent; unchanged from 001).
230
+ DROP TRIGGER IF EXISTS trg_gcal_push ON public.appointments;
231
+ CREATE TRIGGER trg_gcal_push
232
+ AFTER INSERT OR UPDATE OR DELETE ON public.appointments
233
+ FOR EACH ROW EXECUTE FUNCTION public.fn_gcal_push_booking();
234
+
235
+ -- ===========================================================================
236
+ -- \xA73 \u2014 sync log: correlate the two directions + reference the source channel.
237
+ -- ===========================================================================
238
+ ALTER TABLE public.plg_calendar_sync_log
239
+ ADD COLUMN IF NOT EXISTS correlation_id uuid;
240
+ ALTER TABLE public.plg_calendar_sync_log
241
+ ADD COLUMN IF NOT EXISTS channel_id_ref uuid
242
+ REFERENCES public.plg_calendar_channels(id) ON DELETE SET NULL;
243
+ -- Unlinked future events seen during a pull (import-wizard candidates), so the
244
+ -- log can show "N eventos aguardando import" without re-hitting Google.
245
+ ALTER TABLE public.plg_calendar_sync_log
246
+ ADD COLUMN IF NOT EXISTS discovered integer;
247
+
248
+ -- ===========================================================================
249
+ -- \xA74 \u2014 Inbound write path (Google \u2192 booking). THE single entry point used by
250
+ -- the edge function (scheduled pull, webhook, and the import wizard). All three
251
+ -- functions are SECURITY DEFINER with SET search_path = public and are granted
252
+ -- to service_role ONLY (anon/authenticated explicitly revoked) \u2014 they bypass the
253
+ -- tenant RLS on purpose and must never be reachable from the anon/authenticated
254
+ -- PostgREST surface. Every writer sets the TRANSACTION-LOCAL GUC
255
+ -- app.booking_origin='google' (set_config ..., true) so \xA72's trigger suppresses
256
+ -- the echo for THIS write only; metadata.syncOrigin is provenance, not a guard.
257
+ -- ===========================================================================
258
+
259
+ -- gcal_import_event: create (or idempotently update) a booking from a Google
260
+ -- event, honouring the channel's import_mode + target mapping.
261
+ -- kind = 'block' | 'appointment' (from channel.import_mode)
262
+ -- assignee_id = channel.target_id when target_kind='assignee', else NULL
263
+ -- (NULL = tenant-wide \u2192 blocks every professional, see \xA75)
264
+ -- location_id = channel.target_id when target_kind='location', else NULL
265
+ -- status = 'scheduled'
266
+ -- No order is created \u2014 an external import is not a sale. Idempotent on
267
+ -- (tenant_id, metadata.googleCalendarEventId): update in place if seen before.
268
+ CREATE OR REPLACE FUNCTION public.gcal_import_event(
269
+ p_tenant_id uuid,
270
+ p_channel_id uuid,
271
+ p_event_id text,
272
+ p_etag text,
273
+ p_summary text,
274
+ p_starts_at timestamptz,
275
+ p_ends_at timestamptz,
276
+ p_all_day boolean,
277
+ p_description text DEFAULT NULL
278
+ ) RETURNS uuid
279
+ LANGUAGE plpgsql SECURITY DEFINER
280
+ SET search_path = public
281
+ AS $$
282
+ DECLARE
283
+ v_ch record;
284
+ v_kind text;
285
+ v_assignee uuid;
286
+ v_location uuid;
287
+ v_ends timestamptz;
288
+ v_meta jsonb;
289
+ v_existing uuid;
290
+ v_appt uuid;
291
+ BEGIN
292
+ IF p_tenant_id IS NULL OR p_event_id IS NULL OR p_starts_at IS NULL THEN
293
+ RAISE EXCEPTION 'gcal_import_event: tenant, event id and start are required';
294
+ END IF;
295
+
296
+ -- Transaction-local: \xA72's trigger skips the echo for this write only.
297
+ PERFORM set_config('app.booking_origin', 'google', true);
298
+
299
+ -- Resolve the channel mapping (must belong to the same tenant).
300
+ SELECT c.import_mode, c.target_kind, c.target_id
301
+ INTO v_ch
302
+ FROM plg_calendar_channels c
303
+ WHERE c.id = p_channel_id AND c.tenant_id = p_tenant_id;
304
+ IF NOT FOUND THEN RAISE EXCEPTION 'gcal_import_event: unknown channel for tenant'; END IF;
305
+
306
+ v_kind := CASE WHEN v_ch.import_mode = 'appointment' THEN 'appointment' ELSE 'block' END;
307
+ v_assignee := CASE WHEN v_ch.target_kind = 'assignee' THEN v_ch.target_id ELSE NULL END;
308
+ v_location := CASE WHEN v_ch.target_kind = 'location' THEN v_ch.target_id ELSE NULL END;
309
+
310
+ -- All-day events without an explicit end block the whole day so \xA75 excludes it.
311
+ v_ends := COALESCE(
312
+ p_ends_at,
313
+ CASE WHEN COALESCE(p_all_day, false) THEN p_starts_at + interval '1 day' ELSE p_starts_at END
314
+ );
315
+
316
+ v_meta := jsonb_build_object(
317
+ 'syncOrigin', 'google',
318
+ 'googleCalendarEventId', p_event_id,
319
+ 'gcalEtag', p_etag,
320
+ 'gcalChannelId', p_channel_id::text,
321
+ 'allDay', COALESCE(p_all_day, false),
322
+ 'title', p_summary,
323
+ 'source', 'google_import'
324
+ );
325
+
326
+ -- Idempotency: has this event already been imported for the tenant?
327
+ SELECT b.id INTO v_existing
328
+ FROM appointments b
329
+ WHERE b.tenant_id = p_tenant_id
330
+ AND b.metadata->>'googleCalendarEventId' = p_event_id
331
+ LIMIT 1;
332
+
333
+ IF v_existing IS NOT NULL THEN
334
+ UPDATE appointments b
335
+ SET kind = v_kind,
336
+ assignee_id = v_assignee,
337
+ location_id = v_location,
338
+ starts_at = p_starts_at,
339
+ ends_at = v_ends,
340
+ status = 'scheduled',
341
+ notes = COALESCE(p_description, p_summary),
342
+ metadata = COALESCE(b.metadata, '{}'::jsonb) || v_meta,
343
+ updated_at = now()
344
+ WHERE b.id = v_existing
345
+ RETURNING b.id INTO v_appt;
346
+ ELSE
347
+ INSERT INTO appointments (tenant_id, kind, assignee_id, location_id,
348
+ starts_at, ends_at, status, notes, metadata)
349
+ VALUES (p_tenant_id, v_kind, v_assignee, v_location,
350
+ p_starts_at, v_ends, 'scheduled', COALESCE(p_description, p_summary), v_meta)
351
+ RETURNING id INTO v_appt;
352
+ END IF;
353
+
354
+ RETURN v_appt;
355
+ END $$;
356
+
357
+ -- gcal_apply_event_patch: apply an inbound reschedule/cancel to an already-linked
358
+ -- booking. Located by metadata.googleCalendarEventId. Echo suppression: if the
359
+ -- stored gcalEtag already equals the incoming etag, this is our own write coming
360
+ -- back \u2014 skip and return NULL. Every real write re-stamps syncOrigin='google'.
361
+ CREATE OR REPLACE FUNCTION public.gcal_apply_event_patch(
362
+ p_tenant_id uuid,
363
+ p_event_id text,
364
+ p_etag text,
365
+ p_starts_at timestamptz,
366
+ p_ends_at timestamptz,
367
+ p_summary text,
368
+ p_cancelled boolean
369
+ ) RETURNS uuid
370
+ LANGUAGE plpgsql SECURITY DEFINER
371
+ SET search_path = public
372
+ AS $$
373
+ DECLARE
374
+ v_appt uuid;
375
+ v_curr_etag text;
376
+ BEGIN
377
+ IF p_tenant_id IS NULL OR p_event_id IS NULL THEN
378
+ RAISE EXCEPTION 'gcal_apply_event_patch: tenant and event id are required';
379
+ END IF;
380
+
381
+ -- Transaction-local: \xA72's trigger skips the echo for this write only.
382
+ PERFORM set_config('app.booking_origin', 'google', true);
383
+
384
+ SELECT b.id, b.metadata->>'gcalEtag'
385
+ INTO v_appt, v_curr_etag
386
+ FROM appointments b
387
+ WHERE b.tenant_id = p_tenant_id
388
+ AND b.metadata->>'googleCalendarEventId' = p_event_id
389
+ LIMIT 1;
390
+
391
+ IF v_appt IS NULL THEN RETURN NULL; END IF; -- not linked here
392
+ IF p_etag IS NOT NULL AND p_etag = v_curr_etag THEN
393
+ RETURN NULL; -- echo: nothing changed
394
+ END IF;
395
+
396
+ IF COALESCE(p_cancelled, false) THEN
397
+ UPDATE appointments b
398
+ SET status = 'cancelled',
399
+ metadata = COALESCE(b.metadata, '{}'::jsonb)
400
+ || jsonb_build_object('syncOrigin', 'google', 'gcalEtag', p_etag),
401
+ updated_at = now()
402
+ WHERE b.id = v_appt;
403
+ ELSE
404
+ UPDATE appointments b
405
+ SET starts_at = COALESCE(p_starts_at, b.starts_at),
406
+ ends_at = COALESCE(p_ends_at, b.ends_at),
407
+ notes = COALESCE(p_summary, b.notes),
408
+ metadata = COALESCE(b.metadata, '{}'::jsonb)
409
+ || jsonb_build_object('syncOrigin', 'google',
410
+ 'gcalEtag', p_etag,
411
+ 'title', COALESCE(p_summary, b.metadata->>'title')),
412
+ updated_at = now()
413
+ WHERE b.id = v_appt;
414
+ END IF;
415
+
416
+ RETURN v_appt;
417
+ END $$;
418
+
419
+ -- gcal_stamp_outbound: after the edge function creates the Google event for an
420
+ -- outbound push, it stamps the fresh event id/etag back onto the appointment.
421
+ -- Bookkeeping only \u2014 must not re-fire \xA72's trigger, hence the GUC. Does NOT set
422
+ -- metadata.syncOrigin (the booking's provenance is still the app/site).
423
+ CREATE OR REPLACE FUNCTION public.gcal_stamp_outbound(
424
+ p_tenant_id uuid,
425
+ p_booking_id uuid,
426
+ p_event_id text,
427
+ p_etag text DEFAULT NULL,
428
+ p_calendar_id text DEFAULT NULL
429
+ ) RETURNS uuid
430
+ LANGUAGE plpgsql SECURITY DEFINER
431
+ SET search_path = public
432
+ AS $$
433
+ DECLARE
434
+ v_id uuid;
435
+ BEGIN
436
+ IF p_tenant_id IS NULL OR p_booking_id IS NULL OR p_event_id IS NULL THEN
437
+ RAISE EXCEPTION 'gcal_stamp_outbound: tenant, booking and event id are required';
438
+ END IF;
439
+
440
+ -- Transaction-local: \xA72's trigger skips the echo for this write only.
441
+ PERFORM set_config('app.booking_origin', 'google', true);
442
+
443
+ UPDATE appointments b
444
+ SET metadata = COALESCE(b.metadata, '{}'::jsonb)
445
+ || jsonb_build_object('googleCalendarEventId', p_event_id,
446
+ 'gcalEtag', p_etag,
447
+ 'gcalSyncedAt', now())
448
+ || CASE WHEN p_calendar_id IS NULL THEN '{}'::jsonb
449
+ ELSE jsonb_build_object('gcalCalendarId', p_calendar_id) END,
450
+ updated_at = now()
451
+ WHERE b.id = p_booking_id AND b.tenant_id = p_tenant_id
452
+ RETURNING b.id INTO v_id;
453
+ RETURN v_id;
454
+ END $$;
455
+
456
+ -- gcal_unlink_channel: forget the incremental cursor + Google watch bookkeeping
457
+ -- for a channel (used on disconnect / re-watch). Leaves the mapping row in place.
458
+ CREATE OR REPLACE FUNCTION public.gcal_unlink_channel(
459
+ p_channel_id uuid
460
+ ) RETURNS uuid
461
+ LANGUAGE plpgsql SECURITY DEFINER
462
+ SET search_path = public
463
+ AS $$
464
+ DECLARE
465
+ v_id uuid;
466
+ BEGIN
467
+ UPDATE plg_calendar_channels c
468
+ SET sync_token = NULL,
469
+ channel_id = NULL,
470
+ resource_id = NULL,
471
+ channel_expires_at = NULL,
472
+ updated_at = now()
473
+ WHERE c.id = p_channel_id
474
+ RETURNING c.id INTO v_id;
475
+ RETURN v_id;
476
+ END $$;
477
+
478
+ -- Lock the inbound writers to service_role only.
479
+ DO $$
480
+ DECLARE fn text;
481
+ BEGIN
482
+ FOR fn IN SELECT unnest(ARRAY[
483
+ 'public.gcal_import_event(uuid, uuid, text, text, text, timestamptz, timestamptz, boolean, text)',
484
+ 'public.gcal_apply_event_patch(uuid, text, text, timestamptz, timestamptz, text, boolean)',
485
+ 'public.gcal_stamp_outbound(uuid, uuid, text, text, text)',
486
+ 'public.gcal_unlink_channel(uuid)'
487
+ ])
488
+ LOOP
489
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM public, anon, authenticated', fn);
490
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO service_role', fn);
491
+ END LOOP;
492
+ END $$;
493
+
494
+ -- ===========================================================================
495
+ -- \xA75 \u2014 get_available_slots: imported Google blocks must remove availability.
496
+ -- CREATE OR REPLACE keeping the EXACT signature (anon + admin both call it, and
497
+ -- it is graded on the same shape as plugin-agenda/src/migrations/001_public_booking.sql).
498
+ --
499
+ -- Conceptual diff vs 001 \u2014 ONE condition in the overlap NOT EXISTS:
500
+ -- before: AND b.assignee_id = c.assignee_id
501
+ -- after: AND (b.assignee_id = c.assignee_id
502
+ -- OR (b.assignee_id IS NULL AND b.kind = 'block'))
503
+ -- Rationale: a tenant-wide BLOCK imported from Google (target_kind NULL \u2192
504
+ -- assignee_id IS NULL, kind 'block', see \xA74) must busy-out the slot for EVERY
505
+ -- professional. Restricted to kind='block' so an unassigned admin appointment
506
+ -- keeps the 001 behaviour (blocks nobody). Per-assignee rows keep matching on
507
+ -- assignee_id.
508
+ -- Everything else is byte-identical to 001: the status filter already covers
509
+ -- "any non-cancelled kind" (no_show also excluded), and the p_assignee_id-NULL
510
+ -- fan-out across staff via day_schedules is preserved untouched.
511
+ -- ===========================================================================
512
+ CREATE OR REPLACE FUNCTION public.get_available_slots(
513
+ p_tenant_id uuid,
514
+ p_date date,
515
+ p_duration_minutes integer,
516
+ p_assignee_id uuid DEFAULT NULL,
517
+ p_slot_interval integer DEFAULT 30
518
+ ) RETURNS TABLE (slot_start timestamptz, slot_end timestamptz)
519
+ LANGUAGE plpgsql STABLE SECURITY DEFINER
520
+ SET search_path = public
521
+ AS $$
522
+ DECLARE
523
+ v_tz text;
524
+ BEGIN
525
+ -- tight validation: this function is anon-callable
526
+ IF p_duration_minutes IS NULL OR p_duration_minutes NOT BETWEEN 5 AND 480 THEN
527
+ RAISE EXCEPTION 'invalid duration';
528
+ END IF;
529
+ IF p_slot_interval IS NULL OR p_slot_interval NOT BETWEEN 5 AND 240 THEN
530
+ RAISE EXCEPTION 'invalid interval';
531
+ END IF;
532
+ IF p_date IS NULL OR p_date < current_date OR p_date > current_date + 90 THEN
533
+ RETURN;
534
+ END IF;
535
+
536
+ SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
537
+ FROM tenants t WHERE t.id = p_tenant_id;
538
+ IF v_tz IS NULL THEN RETURN; END IF; -- unknown tenant \u2192 empty, not an error
539
+
540
+ RETURN QUERY
541
+ WITH day_schedules AS (
542
+ SELECT sc.assignee_id, sc.starts_at AS win_start, sc.ends_at AS win_end
543
+ FROM schedules sc
544
+ WHERE sc.tenant_id = p_tenant_id
545
+ AND sc.kind = 'working_hours'
546
+ AND sc.is_active
547
+ AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)
548
+ AND (
549
+ sc.specific_date = p_date
550
+ OR (sc.specific_date IS NULL AND sc.day_of_week = EXTRACT(dow FROM p_date)::smallint)
551
+ )
552
+ ),
553
+ candidate AS (
554
+ SELECT
555
+ ds.assignee_id,
556
+ gs AS s_start,
557
+ gs + make_interval(mins => p_duration_minutes) AS s_end
558
+ FROM day_schedules ds
559
+ CROSS JOIN LATERAL generate_series(
560
+ (p_date + ds.win_start) AT TIME ZONE v_tz,
561
+ ((p_date + ds.win_end) AT TIME ZONE v_tz) - make_interval(mins => p_duration_minutes),
562
+ make_interval(mins => p_slot_interval)
563
+ ) AS gs
564
+ )
565
+ SELECT DISTINCT c.s_start, c.s_end
566
+ FROM candidate c
567
+ WHERE NOT EXISTS (
568
+ SELECT 1 FROM appointments b
569
+ WHERE b.tenant_id = p_tenant_id
570
+ -- SMART SYNC: match this professional's own bookings OR any tenant-wide
571
+ -- BLOCK (assignee_id IS NULL AND kind='block', e.g. imported from Google
572
+ -- with a whole-tenant channel) \u2014 the latter busies the slot for every
573
+ -- professional. Assignee-less rows of other kinds (an unassigned admin
574
+ -- appointment) keep the 001 behaviour and do not block anyone.
575
+ AND (b.assignee_id = c.assignee_id
576
+ OR (b.assignee_id IS NULL AND b.kind = 'block'))
577
+ AND b.status NOT IN ('cancelled', 'no_show')
578
+ AND b.starts_at < c.s_end
579
+ AND COALESCE(b.ends_at, b.starts_at) > c.s_start
580
+ )
581
+ ORDER BY 1;
582
+ END;
583
+ $$;
584
+
585
+ REVOKE ALL ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer) FROM public;
586
+ GRANT EXECUTE ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer)
587
+ TO anon, authenticated, service_role;
588
+
589
+ -- ===========================================================================
590
+ -- \xA76 \u2014 Inbound schedule (optional) \u2014 per-channel incremental pull.
591
+ -- Supersedes the single-calendar template in 001. Enable once per project after
592
+ -- setting app.gcal_sync_url / app.gcal_service_key (see 001 header). The edge
593
+ -- function fans out over active channels, using each channel's sync_token cursor:
594
+ --
595
+ -- SELECT cron.schedule('gcal-pull', '*/10 * * * *', $CRON$
596
+ -- SELECT net.http_post(
597
+ -- url := current_setting('app.gcal_sync_url'),
598
+ -- headers := jsonb_build_object(
599
+ -- 'Content-Type','application/json',
600
+ -- 'Authorization','Bearer '||current_setting('app.gcal_service_key')),
601
+ -- body := jsonb_build_object('action','pull_events'))
602
+ -- $CRON$);
603
+ --
604
+ -- Or drive one row per channel from SQL (tighter scoping / per-channel cadence):
605
+ --
606
+ -- SELECT cron.schedule('gcal-pull-'||ch.id::text, '*/10 * * * *', format($CRON$
607
+ -- SELECT net.http_post(
608
+ -- url := current_setting('app.gcal_sync_url'),
609
+ -- headers := jsonb_build_object(
610
+ -- 'Content-Type','application/json',
611
+ -- 'Authorization','Bearer '||current_setting('app.gcal_service_key')),
612
+ -- body := jsonb_build_object('action','pull_events','channelId',%L))
613
+ -- $CRON$, ch.id))
614
+ -- FROM public.plg_calendar_channels ch
615
+ -- WHERE ch.is_active AND ch.direction IN ('inbound','bidirectional');
616
+ -- ===========================================================================
617
+ `;
618
+ var MIGRATION_003_PLG_RENAME = `-- ---------------------------------------------------------------------------
619
+ -- 003_plg_rename.sql \u2014 standardize the Google Calendar connector objects onto
620
+ -- the platform \`plg_<plugin>_\` prefix (DATA-MODEL.md Ring 1).
621
+ --
622
+ -- calendar_integrations \u2192 plg_calendar_integrations
623
+ -- calendar_channels \u2192 plg_calendar_channels
624
+ -- calendar_sync_log \u2192 plg_calendar_sync_log
625
+ --
626
+ -- Legacy-pool remediation ONLY: 001/002 now create the prefixed names directly,
627
+ -- so fresh installs never reach a rename branch. Fully idempotent and guarded \u2014
628
+ -- every RENAME fires only when the old object exists and the new one does not,
629
+ -- so re-running (or running on an already-prefixed pool) is a no-op.
630
+ --
631
+ -- A table RENAME preserves its data, RLS state, policies, grants, indexes,
632
+ -- constraints and triggers (they follow the table), so \xA71 only renames the
633
+ -- table + the explicitly-named indexes. \xA72 then re-points the SECURITY DEFINER
634
+ -- gcal_* functions whose bodies hard-code the old table names.
635
+ -- ---------------------------------------------------------------------------
636
+
637
+ -- ===========================================================================
638
+ -- \xA71 \u2014 Guarded table + index renames.
639
+ -- ===========================================================================
640
+ DO $$
641
+ BEGIN
642
+ IF to_regclass('public.calendar_integrations') IS NOT NULL
643
+ AND to_regclass('public.plg_calendar_integrations') IS NULL THEN
644
+ ALTER TABLE public.calendar_integrations RENAME TO plg_calendar_integrations;
645
+ END IF;
646
+
647
+ IF to_regclass('public.calendar_channels') IS NOT NULL
648
+ AND to_regclass('public.plg_calendar_channels') IS NULL THEN
649
+ ALTER TABLE public.calendar_channels RENAME TO plg_calendar_channels;
650
+ END IF;
651
+
652
+ IF to_regclass('public.calendar_sync_log') IS NOT NULL
653
+ AND to_regclass('public.plg_calendar_sync_log') IS NULL THEN
654
+ ALTER TABLE public.calendar_sync_log RENAME TO plg_calendar_sync_log;
655
+ END IF;
656
+
657
+ -- Named indexes do not follow a table rename automatically.
658
+ IF to_regclass('public.idx_calendar_integrations_tenant') IS NOT NULL
659
+ AND to_regclass('public.idx_plg_calendar_integrations_tenant') IS NULL THEN
660
+ ALTER INDEX public.idx_calendar_integrations_tenant RENAME TO idx_plg_calendar_integrations_tenant;
661
+ END IF;
662
+
663
+ IF to_regclass('public.idx_calendar_channels_tenant') IS NOT NULL
664
+ AND to_regclass('public.idx_plg_calendar_channels_tenant') IS NULL THEN
665
+ ALTER INDEX public.idx_calendar_channels_tenant RENAME TO idx_plg_calendar_channels_tenant;
666
+ END IF;
667
+
668
+ IF to_regclass('public.idx_calendar_channels_integration') IS NOT NULL
669
+ AND to_regclass('public.idx_plg_calendar_channels_integration') IS NULL THEN
670
+ ALTER INDEX public.idx_calendar_channels_integration RENAME TO idx_plg_calendar_channels_integration;
671
+ END IF;
672
+ END $$;
673
+
674
+ -- ===========================================================================
675
+ -- \xA72 \u2014 Re-point the inbound SECURITY DEFINER writers at the prefixed names.
676
+ -- These bodies are byte-identical to 002_smart_sync.sql except the calendar_*
677
+ -- table references, which now read plg_calendar_*. gcal_apply_event_patch and
678
+ -- gcal_stamp_outbound only touch public.appointments (unaffected by the rename)
679
+ -- and are re-asserted here for completeness so all four writers stay in step.
680
+ -- ===========================================================================
681
+
682
+ CREATE OR REPLACE FUNCTION public.gcal_import_event(
683
+ p_tenant_id uuid,
684
+ p_channel_id uuid,
685
+ p_event_id text,
686
+ p_etag text,
687
+ p_summary text,
688
+ p_starts_at timestamptz,
689
+ p_ends_at timestamptz,
690
+ p_all_day boolean,
691
+ p_description text DEFAULT NULL
692
+ ) RETURNS uuid
693
+ LANGUAGE plpgsql SECURITY DEFINER
694
+ SET search_path = public
695
+ AS $$
696
+ DECLARE
697
+ v_ch record;
698
+ v_kind text;
699
+ v_assignee uuid;
700
+ v_location uuid;
701
+ v_ends timestamptz;
702
+ v_meta jsonb;
703
+ v_existing uuid;
704
+ v_appt uuid;
705
+ BEGIN
706
+ IF p_tenant_id IS NULL OR p_event_id IS NULL OR p_starts_at IS NULL THEN
707
+ RAISE EXCEPTION 'gcal_import_event: tenant, event id and start are required';
708
+ END IF;
709
+
710
+ PERFORM set_config('app.booking_origin', 'google', true);
711
+
712
+ SELECT c.import_mode, c.target_kind, c.target_id
713
+ INTO v_ch
714
+ FROM plg_calendar_channels c
715
+ WHERE c.id = p_channel_id AND c.tenant_id = p_tenant_id;
716
+ IF NOT FOUND THEN RAISE EXCEPTION 'gcal_import_event: unknown channel for tenant'; END IF;
717
+
718
+ v_kind := CASE WHEN v_ch.import_mode = 'appointment' THEN 'appointment' ELSE 'block' END;
719
+ v_assignee := CASE WHEN v_ch.target_kind = 'assignee' THEN v_ch.target_id ELSE NULL END;
720
+ v_location := CASE WHEN v_ch.target_kind = 'location' THEN v_ch.target_id ELSE NULL END;
721
+
722
+ v_ends := COALESCE(
723
+ p_ends_at,
724
+ CASE WHEN COALESCE(p_all_day, false) THEN p_starts_at + interval '1 day' ELSE p_starts_at END
725
+ );
726
+
727
+ v_meta := jsonb_build_object(
728
+ 'syncOrigin', 'google',
729
+ 'googleCalendarEventId', p_event_id,
730
+ 'gcalEtag', p_etag,
731
+ 'gcalChannelId', p_channel_id::text,
732
+ 'allDay', COALESCE(p_all_day, false),
733
+ 'title', p_summary,
734
+ 'source', 'google_import'
735
+ );
736
+
737
+ SELECT b.id INTO v_existing
738
+ FROM appointments b
739
+ WHERE b.tenant_id = p_tenant_id
740
+ AND b.metadata->>'googleCalendarEventId' = p_event_id
741
+ LIMIT 1;
742
+
743
+ IF v_existing IS NOT NULL THEN
744
+ UPDATE appointments b
745
+ SET kind = v_kind,
746
+ assignee_id = v_assignee,
747
+ location_id = v_location,
748
+ starts_at = p_starts_at,
749
+ ends_at = v_ends,
750
+ status = 'scheduled',
751
+ notes = COALESCE(p_description, p_summary),
752
+ metadata = COALESCE(b.metadata, '{}'::jsonb) || v_meta,
753
+ updated_at = now()
754
+ WHERE b.id = v_existing
755
+ RETURNING b.id INTO v_appt;
756
+ ELSE
757
+ INSERT INTO appointments (tenant_id, kind, assignee_id, location_id,
758
+ starts_at, ends_at, status, notes, metadata)
759
+ VALUES (p_tenant_id, v_kind, v_assignee, v_location,
760
+ p_starts_at, v_ends, 'scheduled', COALESCE(p_description, p_summary), v_meta)
761
+ RETURNING id INTO v_appt;
762
+ END IF;
763
+
764
+ RETURN v_appt;
765
+ END $$;
766
+
767
+ CREATE OR REPLACE FUNCTION public.gcal_apply_event_patch(
768
+ p_tenant_id uuid,
769
+ p_event_id text,
770
+ p_etag text,
771
+ p_starts_at timestamptz,
772
+ p_ends_at timestamptz,
773
+ p_summary text,
774
+ p_cancelled boolean
775
+ ) RETURNS uuid
776
+ LANGUAGE plpgsql SECURITY DEFINER
777
+ SET search_path = public
778
+ AS $$
779
+ DECLARE
780
+ v_appt uuid;
781
+ v_curr_etag text;
782
+ BEGIN
783
+ IF p_tenant_id IS NULL OR p_event_id IS NULL THEN
784
+ RAISE EXCEPTION 'gcal_apply_event_patch: tenant and event id are required';
785
+ END IF;
786
+
787
+ PERFORM set_config('app.booking_origin', 'google', true);
788
+
789
+ SELECT b.id, b.metadata->>'gcalEtag'
790
+ INTO v_appt, v_curr_etag
791
+ FROM appointments b
792
+ WHERE b.tenant_id = p_tenant_id
793
+ AND b.metadata->>'googleCalendarEventId' = p_event_id
794
+ LIMIT 1;
795
+
796
+ IF v_appt IS NULL THEN RETURN NULL; END IF;
797
+ IF p_etag IS NOT NULL AND p_etag = v_curr_etag THEN
798
+ RETURN NULL;
799
+ END IF;
800
+
801
+ IF COALESCE(p_cancelled, false) THEN
802
+ UPDATE appointments b
803
+ SET status = 'cancelled',
804
+ metadata = COALESCE(b.metadata, '{}'::jsonb)
805
+ || jsonb_build_object('syncOrigin', 'google', 'gcalEtag', p_etag),
806
+ updated_at = now()
807
+ WHERE b.id = v_appt;
808
+ ELSE
809
+ UPDATE appointments b
810
+ SET starts_at = COALESCE(p_starts_at, b.starts_at),
811
+ ends_at = COALESCE(p_ends_at, b.ends_at),
812
+ notes = COALESCE(p_summary, b.notes),
813
+ metadata = COALESCE(b.metadata, '{}'::jsonb)
814
+ || jsonb_build_object('syncOrigin', 'google',
815
+ 'gcalEtag', p_etag,
816
+ 'title', COALESCE(p_summary, b.metadata->>'title')),
817
+ updated_at = now()
818
+ WHERE b.id = v_appt;
819
+ END IF;
820
+
821
+ RETURN v_appt;
822
+ END $$;
823
+
824
+ CREATE OR REPLACE FUNCTION public.gcal_stamp_outbound(
825
+ p_tenant_id uuid,
826
+ p_booking_id uuid,
827
+ p_event_id text,
828
+ p_etag text DEFAULT NULL,
829
+ p_calendar_id text DEFAULT NULL
830
+ ) RETURNS uuid
831
+ LANGUAGE plpgsql SECURITY DEFINER
832
+ SET search_path = public
833
+ AS $$
834
+ DECLARE
835
+ v_id uuid;
836
+ BEGIN
837
+ IF p_tenant_id IS NULL OR p_booking_id IS NULL OR p_event_id IS NULL THEN
838
+ RAISE EXCEPTION 'gcal_stamp_outbound: tenant, booking and event id are required';
839
+ END IF;
840
+
841
+ PERFORM set_config('app.booking_origin', 'google', true);
842
+
843
+ UPDATE appointments b
844
+ SET metadata = COALESCE(b.metadata, '{}'::jsonb)
845
+ || jsonb_build_object('googleCalendarEventId', p_event_id,
846
+ 'gcalEtag', p_etag,
847
+ 'gcalSyncedAt', now())
848
+ || CASE WHEN p_calendar_id IS NULL THEN '{}'::jsonb
849
+ ELSE jsonb_build_object('gcalCalendarId', p_calendar_id) END,
850
+ updated_at = now()
851
+ WHERE b.id = p_booking_id AND b.tenant_id = p_tenant_id
852
+ RETURNING b.id INTO v_id;
853
+ RETURN v_id;
854
+ END $$;
855
+
856
+ CREATE OR REPLACE FUNCTION public.gcal_unlink_channel(
857
+ p_channel_id uuid
858
+ ) RETURNS uuid
859
+ LANGUAGE plpgsql SECURITY DEFINER
860
+ SET search_path = public
861
+ AS $$
862
+ DECLARE
863
+ v_id uuid;
864
+ BEGIN
865
+ UPDATE plg_calendar_channels c
866
+ SET sync_token = NULL,
867
+ channel_id = NULL,
868
+ resource_id = NULL,
869
+ channel_expires_at = NULL,
870
+ updated_at = now()
871
+ WHERE c.id = p_channel_id
872
+ RETURNING c.id INTO v_id;
873
+ RETURN v_id;
874
+ END $$;
875
+
876
+ -- Re-assert the service_role-only lock (idempotent; matches 002 \xA74).
877
+ DO $$
878
+ DECLARE fn text;
879
+ BEGIN
880
+ FOR fn IN SELECT unnest(ARRAY[
881
+ 'public.gcal_import_event(uuid, uuid, text, text, text, timestamptz, timestamptz, boolean, text)',
882
+ 'public.gcal_apply_event_patch(uuid, text, text, timestamptz, timestamptz, text, boolean)',
883
+ 'public.gcal_stamp_outbound(uuid, uuid, text, text, text)',
884
+ 'public.gcal_unlink_channel(uuid)'
885
+ ])
886
+ LOOP
887
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM public, anon, authenticated', fn);
888
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO service_role', fn);
889
+ END LOOP;
890
+ END $$;
891
+ `;
892
+ var MIGRATION_004_DROP_OAUTH_TOKENS = `-- ============================================================================
893
+ -- Google Calendar 004: the credential leaves the tenant database, and the
894
+ -- outbound trigger stops being able to break a booking write.
895
+ --
896
+ -- \xA71 credential drop oauth_refresh_token + oauth_access_token
897
+ -- \xA72 outbound anti-loop, safe no-op, delete context, correlation id
898
+ -- \xA73 realtime publish public.appointments so the calendar repaints
899
+ --
900
+ -- Additive and idempotent: no table is created, no policy is rewritten, and the
901
+ -- tenant RLS from 001 already covers everything touched here.
902
+ -- ============================================================================
903
+
904
+ -- \xA71 \u2500\u2500 credential \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
905
+ -- THE EXPOSURE THIS CLOSES: 001 stored \`oauth_refresh_token text\` and
906
+ -- \`oauth_access_token text\` on public.plg_calendar_integrations. That table is
907
+ -- read from the browser with the publishable key under tenant RLS \u2014 and the
908
+ -- connector read it with \`select('*')\` \u2014 so a Google refresh token was readable
909
+ -- by ANY authenticated member of the tenant, reception included. A refresh token
910
+ -- is not a session: it is durable access to the owner's entire calendar, mail-
911
+ -- adjacent metadata and every calendar shared with them. And a pool is one
912
+ -- Supabase project serving N tenants, so anything running there with
913
+ -- service_role read every clinic's Google grant at once.
914
+ --
915
+ -- This is the third connector to make the same move, and the worst of the three.
916
+ -- RankLayer had nothing to replace its key with (it pushes IN and authenticates
917
+ -- against the platform); PlugBank's credential is meant to move to the platform
918
+ -- (FAY-1360 \u2014 as of 2026-08-12 its api_token is still a plaintext pool column).
919
+ -- Google Calendar is PlugBank's case: the grant still has to exist, it just must
920
+ -- not exist HERE. The destination is the platform's ConnectorConnection store
921
+ -- (encrypted; exists, unused), with the sync function redeeming per call \u2014 that
922
+ -- is FAY-1359, and it has NOT happened.
923
+ --
924
+ -- \u26A0 REPO AHEAD OF POOL (verified 2026-08-12, FAY-1379): this file has NOT been
925
+ -- applied to the live salon pool. plg_calendar_integrations there still carries
926
+ -- both token columns with a real clinic's grant, and the DEPLOYED
927
+ -- google-calendar-sync (v16) still reads and writes them. Applying this drop
928
+ -- before FAY-1359's ordered rollout (function reads both homes \u2192 data migrated
929
+ -- \u2192 function reads only the new home \u2192 drop) severs the clinic's sync. Do not
930
+ -- \`db apply\` this to a pool outside that sequence.
931
+ --
932
+ -- \`token_expires_at\` deliberately stays. It is a timestamp, not a credential,
933
+ -- and it is the only non-secret signal that a grant exists \u2014 which is what the
934
+ -- browser needs in order to draw a connected/disconnected badge without ever
935
+ -- being handed the thing it is reporting on. See ../credentials.ts.
936
+ --
937
+ -- ROTATE any token a pool already stored. Nothing is live on this connector yet,
938
+ -- but DROP COLUMN is a catalog change: the old values stay in the heap until the
939
+ -- next table rewrite, so a grant that ever reached a pool must be revoked at
940
+ -- Google rather than assumed gone.
941
+
942
+ DO $$
943
+ BEGIN
944
+ IF to_regclass('public.plg_calendar_integrations') IS NOT NULL THEN
945
+ ALTER TABLE public.plg_calendar_integrations DROP COLUMN IF EXISTS oauth_refresh_token;
946
+ ALTER TABLE public.plg_calendar_integrations DROP COLUMN IF EXISTS oauth_access_token;
947
+ END IF;
948
+ -- Pools converted before 003 renamed the tables carry the legacy name.
949
+ IF to_regclass('public.calendar_integrations') IS NOT NULL THEN
950
+ ALTER TABLE public.calendar_integrations DROP COLUMN IF EXISTS oauth_refresh_token;
951
+ ALTER TABLE public.calendar_integrations DROP COLUMN IF EXISTS oauth_access_token;
952
+ END IF;
953
+ END $$;
954
+
955
+ -- \xA72 \u2500\u2500 outbound trigger \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
956
+ -- Three defects in 001's fn_gcal_push_booking, in order of how badly they hurt:
957
+ --
958
+ -- 1. NO ANTI-LOOP. An inbound Google event is written to public.appointments by
959
+ -- the sync function, which fires this trigger, which pushes the same change
960
+ -- back to Google, which changes the event, which the next pull reads as new.
961
+ -- The symptom a customer reports is an appointment that will not stop moving.
962
+ -- The sync function sets \`app.booking_origin = 'google'\` for the duration of
963
+ -- its transaction; a write carrying that marker is never echoed back.
964
+ --
965
+ -- 2. A MISSING PREREQUISITE COULD BREAK THE BOOKING. 001 returned early on an
966
+ -- empty URL but not on an empty key, so a half-configured pool sent an
967
+ -- unauthenticated pg_net request; and any error raised in here aborts the
968
+ -- INSERT that fired it. An integration that is not set up must degrade to
969
+ -- "Google is not updated", never to "the appointment could not be saved".
970
+ -- Every exit path below returns the row.
971
+ --
972
+ -- 3. A DELETE ARRIVED WITH NOTHING TO DELETE. AFTER DELETE can still read OLD,
973
+ -- but pg_net delivers asynchronously \u2014 by the time the function runs, the row
974
+ -- is gone and it cannot look up which Google event to remove. The linked ids
975
+ -- now travel in the payload.
976
+ --
977
+ -- The early returns are not enough on their own: on a pool where the settings
978
+ -- are present but pg_net is not installed, the PERFORM itself raises \`schema
979
+ -- "net" does not exist\` and takes the INSERT down with it. So the delivery is
980
+ -- wrapped too. Swallowing a delivery error is safe here because pg_net only
981
+ -- ENQUEUES \u2014 anything it could tell us is already visible as "nothing has
982
+ -- synced" in plg_calendar_sync_log and in the connector's derived health.
983
+ --
984
+ -- The Supabase service-role key still reaches this trigger through the
985
+ -- \`app.gcal_service_key\` database setting that 001 introduced. That is a lesser
986
+ -- exposure than the one closed in \xA71 (a GUC is not reachable through PostgREST,
987
+ -- so no browser can read it) but it is still a pool-wide credential in the
988
+ -- database, and it is NOT fixed here. FAY-1372 shipped the RLS half (a
989
+ -- tenant-scoped claim narrows user_tenant_ids); nothing mints such a token for
990
+ -- this trigger yet, and the function still runs on service_role (FAY-1378).
991
+ -- Left as a named debt rather than moved to a third hiding place.
992
+
993
+ CREATE OR REPLACE FUNCTION public.fn_gcal_push_booking() RETURNS trigger
994
+ LANGUAGE plpgsql
995
+ SECURITY DEFINER
996
+ SET search_path = public, extensions, net
997
+ AS $$
998
+ DECLARE
999
+ url text := current_setting('app.gcal_sync_url', true);
1000
+ key text := current_setting('app.gcal_service_key', true);
1001
+ rec record := COALESCE(NEW, OLD);
1002
+ op text := lower(TG_OP);
1003
+ corr text := NULLIF(current_setting('app.booking_correlation_id', true), '');
1004
+ BEGIN
1005
+ -- (1) Inbound Google writes mark themselves. Never echo them back.
1006
+ IF current_setting('app.booking_origin', true) = 'google' THEN
1007
+ RETURN COALESCE(NEW, OLD);
1008
+ END IF;
1009
+
1010
+ -- (2) Not configured is not an error. Diagnostics report the connector as
1011
+ -- incomplete; the appointment is saved either way.
1012
+ IF NULLIF(url, '') IS NULL OR NULLIF(key, '') IS NULL THEN
1013
+ RETURN COALESCE(NEW, OLD);
1014
+ END IF;
1015
+
1016
+ BEGIN
1017
+ PERFORM net.http_post(
1018
+ url := url,
1019
+ headers := jsonb_build_object(
1020
+ 'Content-Type', 'application/json',
1021
+ 'Authorization', 'Bearer ' || key
1022
+ ),
1023
+ body := jsonb_build_object(
1024
+ 'action', 'push_event',
1025
+ 'op', op,
1026
+ 'bookingId', rec.id,
1027
+ 'tenantId', rec.tenant_id,
1028
+ -- (3) Carried so a delete can still be honoured after the row is gone.
1029
+ 'googleEventId', rec.metadata->>'googleCalendarEventId',
1030
+ 'calendarId', rec.metadata->>'gcalCalendarId',
1031
+ 'correlationId', COALESCE(corr, gen_random_uuid()::text)
1032
+ )
1033
+ );
1034
+ EXCEPTION WHEN OTHERS THEN
1035
+ -- Missing pg_net, a bad URL, a full queue: the appointment is still saved.
1036
+ RAISE WARNING 'gcal push skipped for booking %: %', rec.id, SQLERRM;
1037
+ END;
1038
+
1039
+ RETURN COALESCE(NEW, OLD);
1040
+ END $$;
1041
+
1042
+ -- \xA73 \u2500\u2500 realtime \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1043
+ -- The calendar screen subscribes to public.appointments so a booking written by
1044
+ -- the sync function repaints the grid the manager is looking at, instead of
1045
+ -- waiting for them to reload the tab. Additive and guarded: a pool without the
1046
+ -- supabase_realtime publication is left alone, and re-running adds nothing.
1047
+ DO $$
1048
+ BEGIN
1049
+ IF EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'supabase_realtime')
1050
+ AND NOT EXISTS (
1051
+ SELECT 1 FROM pg_publication_tables
1052
+ WHERE pubname = 'supabase_realtime' AND schemaname = 'public' AND tablename = 'appointments'
1053
+ )
1054
+ THEN
1055
+ ALTER PUBLICATION supabase_realtime ADD TABLE public.appointments;
1056
+ END IF;
1057
+ END $$;
1058
+ `;
1059
+ var MIGRATION_005_OUTBOUND_VAULT_DELIVERY = `-- ============================================================================
1060
+ -- Google Calendar 005: the outbound credential moves to Vault, and stops being
1061
+ -- the service-role key. The Authorization header stays.
1062
+ --
1063
+ -- \xA71 trigger fn_gcal_push_booking reads Vault, falls back to the old GUCs
1064
+ -- \xA72 cleanup clear app.gcal_service_key ONLY where Vault already covers it
1065
+ --
1066
+ -- Additive and idempotent: a CREATE OR REPLACE plus a guarded, conditional
1067
+ -- catalog cleanup.
1068
+ --
1069
+ -- WRITTEN AGAINST THE DEPLOYED POOL, NOT AGAINST THIS FOLDER. The repo and
1070
+ -- cluster-salon-br-01 (gphxclpkbtbucoqclbco) had drifted: \`004\` here still reads
1071
+ -- \`app.gcal_service_key\`, while the pool has been on Vault since 2026-07-28 and
1072
+ -- the deployed function is four features ahead of \`functions/\`. Everything below
1073
+ -- was checked against the live database and the live function source, because
1074
+ -- the previous attempt at this file was derived from the repo alone and would
1075
+ -- have broken a real clinic. See FAY-1379 for the drift itself.
1076
+ -- ============================================================================
1077
+
1078
+ -- \xA71 \u2500\u2500 the credential \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1079
+ -- THE EXPOSURE THIS CLOSES, precisely: \`001\` told operators to run
1080
+ -- ALTER DATABASE postgres SET app.gcal_service_key = '<service-role-key>';
1081
+ -- and every version of this trigger in the repo since (\`001\`, \`002 \xA72\`,
1082
+ -- \`004 \xA72\`) reads it back with current_setting(). A pool is ONE Supabase project
1083
+ -- serving N tenants and service_role bypasses RLS by definition, so that setting
1084
+ -- is unrestricted read/write over every tenant in the cluster, in plain text in
1085
+ -- pg_db_role_setting and in every pg_dump taken since.
1086
+ --
1087
+ -- It is closed by making the credential BOTH narrow and encrypted, not by
1088
+ -- removing it: the header is the only thing authorising the push. The deployed
1089
+ -- function checks it (\`isServiceRequest\` compares the bearer against
1090
+ -- GCAL_OUTBOUND_SECRET, then against the service-role key) and \`push_event\` is a
1091
+ -- service action with no user path, so a request with no Authorization header is
1092
+ -- rejected with \`400 Unknown action: push_event\`. Because pg_net is
1093
+ -- fire-and-forget that rejection is INVISIBLE \u2014 the clinic would simply stop
1094
+ -- appearing in Google Calendar. Removing the header is therefore not a security
1095
+ -- improvement, it is a silent outage. It was tried and reverted.
1096
+ --
1097
+ -- So: \`gcal_outbound_secret\` in Vault. Encrypted at rest, outside pg_dump's
1098
+ -- plaintext, readable only by a definer function like this one, and \u2014 the part
1099
+ -- that matters \u2014 it authorises exactly one thing, calling this connector's edge
1100
+ -- function. Losing it does not hand anyone another tenant's data. This is the
1101
+ -- shape the pool has been running since 2026-07-28; \`005\` is the repo catching
1102
+ -- up, so a pool provisioned fresh from this folder is born with it instead of
1103
+ -- being told to paste a service-role key into a database setting.
1104
+ --
1105
+ -- THE FALLBACK IS DELIBERATE. A pool that followed \`001\` and has no Vault secret
1106
+ -- keeps working off the old GUCs, loudly. The alternative \u2014 read Vault only \u2014
1107
+ -- silently disarms the push on exactly the pools that did what the docs told
1108
+ -- them to, which is the failure this file exists to avoid.
1109
+ --
1110
+ -- WHAT THIS DOES NOT FIX: the edge function still builds its Supabase client
1111
+ -- with SUPABASE_SERVICE_ROLE_KEY, so the FUNCTION still reads every tenant in
1112
+ -- the pool. That is not reachable from SQL and does not close here: it closes
1113
+ -- when the platform mints a short-lived tenant-scoped token (FAY-1353) that
1114
+ -- packages/db/migrations/028_tenant_scoped_token.sql already teaches every
1115
+ -- policy to honour through user_tenant_ids(). See docs/platform/DECISIONS.md,
1116
+ -- 2026-08-11 \u2014 "a connector's privilege comes from its token".
1117
+ --
1118
+ -- ROTATE the service-role key of any pool that ever ran \`001\`'s setup line: it
1119
+ -- was plain text at rest and in every backup taken since, so \xA72 clearing it is
1120
+ -- not the same as it never having been there.
1121
+
1122
+ CREATE OR REPLACE FUNCTION public.fn_gcal_push_booking() RETURNS trigger
1123
+ LANGUAGE plpgsql
1124
+ SECURITY DEFINER
1125
+ SET search_path = public, extensions, net, vault
1126
+ AS $$
1127
+ DECLARE
1128
+ url text;
1129
+ key text;
1130
+ legacy boolean := false;
1131
+ rec record := COALESCE(NEW, OLD);
1132
+ op text := lower(TG_OP);
1133
+ corr text := NULLIF(current_setting('app.booking_correlation_id', true), '');
1134
+ BEGIN
1135
+ -- Unchanged from 004: inbound Google writes mark themselves, and a write
1136
+ -- carrying that marker is never echoed back.
1137
+ IF current_setting('app.booking_origin', true) = 'google' THEN
1138
+ RETURN COALESCE(NEW, OLD);
1139
+ END IF;
1140
+
1141
+ -- Vault first. Wrapped because a pool without the vault extension must
1142
+ -- degrade to the fallback, not abort the booking write.
1143
+ BEGIN
1144
+ SELECT decrypted_secret INTO url
1145
+ FROM vault.decrypted_secrets WHERE name = 'gcal_sync_url'
1146
+ ORDER BY created_at DESC LIMIT 1;
1147
+ SELECT decrypted_secret INTO key
1148
+ FROM vault.decrypted_secrets WHERE name = 'gcal_outbound_secret'
1149
+ ORDER BY created_at DESC LIMIT 1;
1150
+ EXCEPTION WHEN OTHERS THEN
1151
+ url := NULL;
1152
+ key := NULL;
1153
+ END;
1154
+
1155
+ -- Legacy pools provisioned per 001's header keep working, and say so.
1156
+ IF NULLIF(url, '') IS NULL OR NULLIF(key, '') IS NULL THEN
1157
+ url := COALESCE(NULLIF(url, ''), current_setting('app.gcal_sync_url', true));
1158
+ key := COALESCE(NULLIF(key, ''), current_setting('app.gcal_service_key', true));
1159
+ legacy := NULLIF(key, '') IS NOT NULL;
1160
+ END IF;
1161
+
1162
+ -- Not configured is not an error: an unconfigured pool degrades to "Google is
1163
+ -- not updated", never to "the appointment could not be saved".
1164
+ IF NULLIF(url, '') IS NULL OR NULLIF(key, '') IS NULL THEN
1165
+ RETURN COALESCE(NEW, OLD);
1166
+ END IF;
1167
+
1168
+ IF legacy THEN
1169
+ RAISE WARNING 'gcal outbound is still using app.gcal_service_key; move it to the vault secret gcal_outbound_secret and rotate the service-role key';
1170
+ END IF;
1171
+
1172
+ BEGIN
1173
+ PERFORM net.http_post(
1174
+ url := url,
1175
+ headers := jsonb_build_object(
1176
+ 'Content-Type', 'application/json',
1177
+ -- REQUIRED. The deployed function authorises push_event on this header
1178
+ -- alone; without it every push 400s, silently, forever.
1179
+ 'Authorization', 'Bearer ' || key
1180
+ ),
1181
+ body := jsonb_build_object(
1182
+ 'action', 'push_event',
1183
+ 'op', op,
1184
+ 'bookingId', rec.id,
1185
+ 'tenantId', rec.tenant_id,
1186
+ -- Carried so a delete can still be honoured after the row is gone.
1187
+ 'googleEventId', rec.metadata->>'googleCalendarEventId',
1188
+ 'calendarId', rec.metadata->>'gcalCalendarId',
1189
+ 'correlationId', COALESCE(corr, gen_random_uuid()::text)
1190
+ )
1191
+ );
1192
+ EXCEPTION WHEN OTHERS THEN
1193
+ -- Missing pg_net, a bad URL, a full queue: the appointment is still saved.
1194
+ -- The deployed trigger lacks this wrapper; 004 added it here after a pool
1195
+ -- with the settings present but the extension missing took an INSERT down.
1196
+ RAISE WARNING 'gcal push skipped for booking %: %', rec.id, SQLERRM;
1197
+ END;
1198
+
1199
+ RETURN COALESCE(NEW, OLD);
1200
+ END $$;
1201
+
1202
+ -- \xA72 \u2500\u2500 cleanup \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1203
+ -- Redefining the function stops the key being PREFERRED; it does not stop it
1204
+ -- being STORED. Settings live in pg_db_role_setting per (database, role), so the
1205
+ -- sweep is driven off the catalog rather than off the one ALTER DATABASE form
1206
+ -- \`001\` happened to show.
1207
+ --
1208
+ -- CONDITIONAL ON PURPOSE: the setting is cleared only where Vault already
1209
+ -- supplies \`gcal_outbound_secret\`, i.e. only where \xA71 provably never needs the
1210
+ -- fallback. Anywhere else it is left in place and reported, because clearing it
1211
+ -- there would disarm the push with no error anyone can see. On the live pool
1212
+ -- this loop finds nothing \u2014 the setting is not present on any role or database \u2014
1213
+ -- and that is the expected result, not a failure.
1214
+ DO $$
1215
+ DECLARE
1216
+ r record;
1217
+ has_vault boolean := false;
1218
+ BEGIN
1219
+ BEGIN
1220
+ SELECT EXISTS (
1221
+ SELECT 1 FROM vault.decrypted_secrets
1222
+ WHERE name = 'gcal_outbound_secret' AND NULLIF(decrypted_secret, '') IS NOT NULL
1223
+ ) INTO has_vault;
1224
+ EXCEPTION WHEN OTHERS THEN
1225
+ has_vault := false;
1226
+ END;
1227
+
1228
+ FOR r IN
1229
+ SELECT d.datname, a.rolname
1230
+ FROM pg_db_role_setting s
1231
+ LEFT JOIN pg_database d ON d.oid = s.setdatabase
1232
+ LEFT JOIN pg_roles a ON a.oid = s.setrole
1233
+ WHERE EXISTS (
1234
+ SELECT 1 FROM unnest(s.setconfig) AS c WHERE c LIKE 'app.gcal_service_key=%'
1235
+ )
1236
+ LOOP
1237
+ IF NOT has_vault THEN
1238
+ RAISE WARNING 'app.gcal_service_key is still set (database %, role %) and is the ONLY outbound credential \u2014 create the vault secret gcal_outbound_secret first, then re-run this migration',
1239
+ r.datname, r.rolname;
1240
+ CONTINUE;
1241
+ END IF;
1242
+ BEGIN
1243
+ IF r.rolname IS NULL THEN
1244
+ EXECUTE format('ALTER DATABASE %I RESET app.gcal_service_key', r.datname);
1245
+ ELSIF r.datname IS NULL THEN
1246
+ EXECUTE format('ALTER ROLE %I RESET app.gcal_service_key', r.rolname);
1247
+ ELSE
1248
+ EXECUTE format('ALTER ROLE %I IN DATABASE %I RESET app.gcal_service_key', r.rolname, r.datname);
1249
+ END IF;
1250
+ EXCEPTION WHEN OTHERS THEN
1251
+ RAISE WARNING 'could not clear app.gcal_service_key (database %, role %): % \u2014 clear it by hand, and rotate the key either way',
1252
+ r.datname, r.rolname, SQLERRM;
1253
+ END;
1254
+ END LOOP;
1255
+ END $$;
1256
+ `;
1257
+ var MIGRATION_006_WEBHOOK_DELIVERIES_OUTBOX = `-- ---------------------------------------------------------------------------
1258
+ -- Google Calendar WEBHOOK INTAKE \u2014 delivery ledger + automation outbox
1259
+ -- ---------------------------------------------------------------------------
1260
+ -- RECONSTRUCTED FROM THE LIVE SCHEMA on 2026-08-12 (FAY-1379). These objects
1261
+ -- existed in pool gphxclpkbtbucoqclbco and in NO migration in any repo: they
1262
+ -- were applied by hand alongside the google-calendar-webhook deployment. A pool
1263
+ -- provisioned from this repo could not run that function \u2014 the SELECT of
1264
+ -- plg_calendar_channels.webhook_token_hash failed before any gate ran, and both
1265
+ -- INSERT targets were missing. This file closes that hole.
1266
+ --
1267
+ -- Written to be applied to a pool that ALREADY has them: every statement is
1268
+ -- IF NOT EXISTS / DROP-then-CREATE, and re-running it must be a no-op.
1269
+ --
1270
+ -- \xA71 plg_calendar_channels \u2014 the three webhook columns
1271
+ -- \xA72 plg_calendar_webhook_deliveries \u2014 one row per Google notification
1272
+ -- \xA73 plg_calendar_automation_outbox \u2014 domain events for the automations lane
1273
+ --
1274
+ -- Read by: functions/google-calendar-webhook/index.ts (\xA71 gate, \xA72 idempotency,
1275
+ -- \xA73 emit) and functions/google-calendar-sync/index.ts (\xA71 watch bookkeeping,
1276
+ -- \xA73 emit on inbound import/patch).
1277
+ -- ---------------------------------------------------------------------------
1278
+
1279
+ -- ===========================================================================
1280
+ -- \xA71 \u2014 Watch-channel authentication state on plg_calendar_channels.
1281
+ -- Added by ALTER in the pool (they sit after created_at/updated_at), so they
1282
+ -- are added the same way here rather than folded into 002's CREATE TABLE \u2014
1283
+ -- 002 has already run everywhere and its checksum must not move.
1284
+ --
1285
+ -- webhook_token_hash: SHA-256 of the token handed to Google at watch_start. The
1286
+ -- token itself is never stored, so a dump of this table does not let anyone
1287
+ -- forge a notification. The webhook re-hashes what arrives and compares in
1288
+ -- constant time.
1289
+ -- last_webhook_message_number: high-water mark of Google's monotonic per-channel
1290
+ -- counter \u2014 the replay guard. bigint because it outgrows int4.
1291
+ -- ===========================================================================
1292
+ ALTER TABLE public.plg_calendar_channels
1293
+ ADD COLUMN IF NOT EXISTS webhook_token_hash text;
1294
+ ALTER TABLE public.plg_calendar_channels
1295
+ ADD COLUMN IF NOT EXISTS last_webhook_message_number bigint;
1296
+ ALTER TABLE public.plg_calendar_channels
1297
+ ADD COLUMN IF NOT EXISTS last_webhook_at timestamptz;
1298
+
1299
+ -- ===========================================================================
1300
+ -- \xA72 \u2014 plg_calendar_webhook_deliveries: one row per accepted notification.
1301
+ --
1302
+ -- The row IS the idempotency mechanism, not a log of one: the webhook inserts
1303
+ -- before doing any work and reads 23505 as "already handled", returning 204.
1304
+ -- Hence two unique keys, both present in the pool and both reproduced \u2014
1305
+ -- (channel_ref, message_number) is the constraint the FK cascade protects, and
1306
+ -- (google_channel_id, message_number) catches a replay arriving after the
1307
+ -- channel row was recreated with the same Google channel id.
1308
+ -- ===========================================================================
1309
+ CREATE TABLE IF NOT EXISTS public.plg_calendar_webhook_deliveries (
1310
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
1311
+ tenant_id uuid NOT NULL,
1312
+ channel_ref uuid NOT NULL REFERENCES public.plg_calendar_channels(id) ON DELETE CASCADE,
1313
+ google_channel_id text NOT NULL,
1314
+ resource_id text,
1315
+ message_number bigint NOT NULL,
1316
+ resource_state text,
1317
+ status text NOT NULL DEFAULT 'pending'
1318
+ CHECK (status IN ('pending','processing','processed','failed','ignored')),
1319
+ error text,
1320
+ received_at timestamptz NOT NULL DEFAULT now(),
1321
+ processed_at timestamptz,
1322
+ created_at timestamptz DEFAULT now(),
1323
+ UNIQUE (channel_ref, message_number)
1324
+ );
1325
+
1326
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_calendar_webhook_delivery_message
1327
+ ON public.plg_calendar_webhook_deliveries (google_channel_id, message_number);
1328
+
1329
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_webhook_deliveries_tenant_created
1330
+ ON public.plg_calendar_webhook_deliveries (tenant_id, created_at DESC);
1331
+
1332
+ -- Two partial indexes over the same predicate on different sort columns. Both
1333
+ -- are in the pool (evidence of two overlapping hand-applied scripts) and both
1334
+ -- are reproduced so this file matches the live schema exactly; consolidating
1335
+ -- them is a separate change with its own reasoning.
1336
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_webhook_pending
1337
+ ON public.plg_calendar_webhook_deliveries (status, received_at)
1338
+ WHERE status IN ('pending','failed');
1339
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_webhook_deliveries_pending
1340
+ ON public.plg_calendar_webhook_deliveries (status, created_at)
1341
+ WHERE status IN ('pending','failed');
1342
+
1343
+ ALTER TABLE public.plg_calendar_webhook_deliveries ENABLE ROW LEVEL SECURITY;
1344
+
1345
+ -- Read-only for the app. Every write comes from the webhook function running as
1346
+ -- service_role, which bypasses RLS \u2014 there is no authenticated INSERT/UPDATE
1347
+ -- grant, so a browser cannot forge or retire a delivery.
1348
+ GRANT SELECT ON public.plg_calendar_webhook_deliveries TO authenticated;
1349
+ GRANT ALL ON public.plg_calendar_webhook_deliveries TO service_role;
1350
+
1351
+ DROP POLICY IF EXISTS plg_calendar_webhook_deliveries_read ON public.plg_calendar_webhook_deliveries;
1352
+ CREATE POLICY plg_calendar_webhook_deliveries_read ON public.plg_calendar_webhook_deliveries
1353
+ FOR SELECT TO authenticated
1354
+ USING (tenant_id IN (SELECT public.user_tenant_ids()));
1355
+
1356
+ -- ===========================================================================
1357
+ -- \xA73 \u2014 plg_calendar_automation_outbox: calendar facts, for the automations lane.
1358
+ --
1359
+ -- Transactional outbox: the sync/webhook functions record WHAT happened and
1360
+ -- return; a consumer drains it. idempotency_key is unique per tenant so a
1361
+ -- replayed Google delivery or a re-run pull upserts onto the same row instead
1362
+ -- of firing an automation twice \u2014 that key is what the functions pass as
1363
+ -- onConflict, so it is load-bearing and not merely a hygiene constraint.
1364
+ -- ===========================================================================
1365
+ CREATE TABLE IF NOT EXISTS public.plg_calendar_automation_outbox (
1366
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
1367
+ tenant_id uuid NOT NULL,
1368
+ source text NOT NULL DEFAULT 'google_calendar',
1369
+ event_type text NOT NULL,
1370
+ aggregate_type text NOT NULL,
1371
+ aggregate_id text,
1372
+ idempotency_key text NOT NULL,
1373
+ payload jsonb NOT NULL DEFAULT '{}'::jsonb,
1374
+ status text NOT NULL DEFAULT 'pending'
1375
+ CHECK (status IN ('pending','processing','processed','failed','ignored')),
1376
+ attempts integer NOT NULL DEFAULT 0,
1377
+ last_error text,
1378
+ occurred_at timestamptz NOT NULL DEFAULT now(),
1379
+ -- available_at is the retry clock: a failed row is re-eligible once it passes.
1380
+ available_at timestamptz NOT NULL DEFAULT now(),
1381
+ processed_at timestamptz,
1382
+ error text,
1383
+ created_at timestamptz DEFAULT now(),
1384
+ UNIQUE (tenant_id, idempotency_key)
1385
+ );
1386
+
1387
+ -- Redundant with the UNIQUE constraint's own index, and present in the pool for
1388
+ -- the same overlapping-scripts reason as \xA72. Reproduced, not tidied.
1389
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_calendar_automation_outbox_idempotency
1390
+ ON public.plg_calendar_automation_outbox (tenant_id, idempotency_key);
1391
+
1392
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_automation_pending
1393
+ ON public.plg_calendar_automation_outbox (status, available_at)
1394
+ WHERE status IN ('pending','failed');
1395
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_automation_outbox_pending
1396
+ ON public.plg_calendar_automation_outbox (status, available_at, created_at)
1397
+ WHERE status IN ('pending','failed');
1398
+
1399
+ ALTER TABLE public.plg_calendar_automation_outbox ENABLE ROW LEVEL SECURITY;
1400
+
1401
+ GRANT SELECT ON public.plg_calendar_automation_outbox TO authenticated;
1402
+ GRANT ALL ON public.plg_calendar_automation_outbox TO service_role;
1403
+
1404
+ DROP POLICY IF EXISTS plg_calendar_automation_outbox_read ON public.plg_calendar_automation_outbox;
1405
+ CREATE POLICY plg_calendar_automation_outbox_read ON public.plg_calendar_automation_outbox
1406
+ FOR SELECT TO authenticated
1407
+ USING (tenant_id IN (SELECT public.user_tenant_ids()));
1408
+ `;
1409
+ var MIGRATION_007_CREDENTIAL_FREE_INSTALL = `-- ---------------------------------------------------------------------------
1410
+ -- Google Calendar \u2014 a credential-free install, from zero
1411
+ -- ---------------------------------------------------------------------------
1412
+ -- FAY-1359/1360. On 2026-08-12 the five plg_calendar_* tables were DROPPED from
1413
+ -- the beauty pool (gphxclpkbtbucoqclbco) together with both edge functions,
1414
+ -- because plg_calendar_integrations held the tenant's Google refresh token in an
1415
+ -- open column. A pool is one database for N clinics, so that column was one
1416
+ -- backup, one dump or one service_role key away from being every clinic's
1417
+ -- calendar.
1418
+ --
1419
+ -- This file is what a pool runs to have the connector back. It is deliberately
1420
+ -- SELF-CONTAINED rather than "001 through 006 again":
1421
+ --
1422
+ -- * 001 CREATEs the credential columns and 004 drops them. A pool restored by
1423
+ -- replaying the chain therefore holds a refresh-token column for the length
1424
+ -- of that replay, and holds it forever if the replay stops early \u2014 which is
1425
+ -- exactly what a half-applied ledger does. There is no ordering of those
1426
+ -- files in which the column never exists; there is this file, in which it
1427
+ -- never exists.
1428
+ -- * The beauty pool's ledger and its schema had already parted company (it
1429
+ -- recorded shop/0006b while tables up to 0043 were applied by hand). A
1430
+ -- recovery that depends on the ledger being right is not a recovery.
1431
+ --
1432
+ -- Re-runnable, and a near no-op on a pool that ran 001\u2013006: every statement is
1433
+ -- IF NOT EXISTS / DROP-then-CREATE, and \xA75 drops the credential columns if it
1434
+ -- finds them rather than assuming 004 ran.
1435
+ --
1436
+ -- \xA71 plg_calendar_integrations \u2014 per-tenant sync state. NO credential.
1437
+ -- \xA72 plg_calendar_channels \u2014 the de-para, incl. the webhook columns
1438
+ -- \xA73 plg_calendar_sync_log \u2014 run history
1439
+ -- \xA74 plg_calendar_webhook_deliveries + plg_calendar_automation_outbox
1440
+ -- \xA75 the gate: this schema must not be able to hold a Google grant
1441
+ --
1442
+ -- WHAT OWNS WHAT, after this file (FAY-1388):
1443
+ -- the PLATFORM owns the credential \u2014 which Google account, authorized by
1444
+ -- whom, and the refresh token, in the vault, reachable only by redemption;
1445
+ -- plg_connections owns "is this connector on for this tenant", written by the
1446
+ -- app when consent comes back;
1447
+ -- these tables own the SYNC STATE \u2014 which calendars map to what, how far the
1448
+ -- cursor got, what Google last told us. Nothing here is a secret, and
1449
+ -- nothing here can be traded for access to anyone's calendar.
1450
+ -- ---------------------------------------------------------------------------
1451
+
1452
+ -- ===========================================================================
1453
+ -- \xA71 \u2014 plg_calendar_integrations: one row per tenant, sync state only.
1454
+ --
1455
+ -- Same shape as 001 MINUS oauth_refresh_token, oauth_access_token and
1456
+ -- token_expires_at. There is no column here to put a token in, which is the
1457
+ -- property this migration exists to establish \u2014 see \xA75, which enforces it.
1458
+ --
1459
+ -- No FK to plg_connections on purpose. That table is the connector framework's
1460
+ -- and this one is the calendar's; a FK would make the connector's row a
1461
+ -- prerequisite for reading a cursor, and the two planes are meant to be able to
1462
+ -- fail independently.
1463
+ -- ===========================================================================
1464
+ CREATE TABLE IF NOT EXISTS public.plg_calendar_integrations (
1465
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
1466
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
1467
+ provider text NOT NULL DEFAULT 'google',
1468
+ calendar_id text NOT NULL DEFAULT 'primary',
1469
+ -- Incremental pull cursor + legacy single-calendar watch bookkeeping.
1470
+ sync_token text,
1471
+ channel_id text,
1472
+ channel_resource_id text,
1473
+ channel_expires_at timestamptz,
1474
+ active boolean NOT NULL DEFAULT true,
1475
+ last_sync_at timestamptz,
1476
+ created_at timestamptz NOT NULL DEFAULT now(),
1477
+ updated_at timestamptz NOT NULL DEFAULT now(),
1478
+ UNIQUE (tenant_id, provider)
1479
+ );
1480
+ ALTER TABLE public.plg_calendar_integrations ENABLE ROW LEVEL SECURITY;
1481
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_integrations_tenant
1482
+ ON public.plg_calendar_integrations(tenant_id);
1483
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_calendar_integrations TO authenticated;
1484
+ GRANT ALL ON public.plg_calendar_integrations TO service_role;
1485
+
1486
+ -- ===========================================================================
1487
+ -- \xA72 \u2014 plg_calendar_channels: the de-para, with 006's webhook columns folded in.
1488
+ -- ===========================================================================
1489
+ CREATE TABLE IF NOT EXISTS public.plg_calendar_channels (
1490
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
1491
+ integration_id uuid NOT NULL REFERENCES public.plg_calendar_integrations(id) ON DELETE CASCADE,
1492
+ tenant_id uuid NOT NULL,
1493
+ google_calendar_id text NOT NULL,
1494
+ summary text,
1495
+ color text,
1496
+ direction text NOT NULL DEFAULT 'bidirectional'
1497
+ CHECK (direction IN ('inbound','outbound','bidirectional','off')),
1498
+ target_kind text CHECK (target_kind IN ('assignee','service','location')),
1499
+ target_id uuid,
1500
+ import_mode text NOT NULL DEFAULT 'block'
1501
+ CHECK (import_mode IN ('block','appointment')),
1502
+ sync_token text,
1503
+ channel_id text,
1504
+ resource_id text,
1505
+ channel_expires_at timestamptz,
1506
+ is_active boolean NOT NULL DEFAULT true,
1507
+ created_at timestamptz NOT NULL DEFAULT now(),
1508
+ updated_at timestamptz NOT NULL DEFAULT now(),
1509
+ UNIQUE (integration_id, google_calendar_id)
1510
+ );
1511
+
1512
+ -- The webhook's authentication state. \`webhook_token_hash\` is a SHA-256 of the
1513
+ -- token handed to Google \u2014 the token itself is never stored, so reading this
1514
+ -- table does not let anyone forge a notification. Added by ALTER as well as in
1515
+ -- the CREATE above, for the pool that already has the table from 002.
1516
+ ALTER TABLE public.plg_calendar_channels
1517
+ ADD COLUMN IF NOT EXISTS webhook_token_hash text;
1518
+ ALTER TABLE public.plg_calendar_channels
1519
+ ADD COLUMN IF NOT EXISTS last_webhook_message_number bigint;
1520
+ ALTER TABLE public.plg_calendar_channels
1521
+ ADD COLUMN IF NOT EXISTS last_webhook_at timestamptz;
1522
+
1523
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_channels_tenant
1524
+ ON public.plg_calendar_channels(tenant_id);
1525
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_channels_integration
1526
+ ON public.plg_calendar_channels(integration_id);
1527
+
1528
+ ALTER TABLE public.plg_calendar_channels ENABLE ROW LEVEL SECURITY;
1529
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_calendar_channels TO authenticated;
1530
+ GRANT ALL ON public.plg_calendar_channels TO service_role;
1531
+
1532
+ DROP TRIGGER IF EXISTS plg_calendar_channels_updated_at ON public.plg_calendar_channels;
1533
+ CREATE TRIGGER plg_calendar_channels_updated_at BEFORE UPDATE ON public.plg_calendar_channels
1534
+ FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
1535
+
1536
+ -- ===========================================================================
1537
+ -- \xA73 \u2014 plg_calendar_sync_log: what already happened, per run.
1538
+ -- ===========================================================================
1539
+ CREATE TABLE IF NOT EXISTS public.plg_calendar_sync_log (
1540
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
1541
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
1542
+ direction text NOT NULL, -- inbound | outbound
1543
+ trigger text, -- on-write | scheduled | webhook | manual
1544
+ status text NOT NULL DEFAULT 'success', -- success | partial | error
1545
+ fetched integer NOT NULL DEFAULT 0,
1546
+ written integer NOT NULL DEFAULT 0,
1547
+ error text,
1548
+ correlation_id uuid,
1549
+ channel_id_ref uuid,
1550
+ created_at timestamptz NOT NULL DEFAULT now()
1551
+ );
1552
+ ALTER TABLE public.plg_calendar_sync_log ENABLE ROW LEVEL SECURITY;
1553
+ ALTER TABLE public.plg_calendar_sync_log ADD COLUMN IF NOT EXISTS correlation_id uuid;
1554
+ ALTER TABLE public.plg_calendar_sync_log ADD COLUMN IF NOT EXISTS channel_id_ref uuid;
1555
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_calendar_sync_log TO authenticated;
1556
+ GRANT ALL ON public.plg_calendar_sync_log TO service_role;
1557
+
1558
+ -- RLS: tenant isolation for the three tables the app reads and writes.
1559
+ DO $$
1560
+ DECLARE t text;
1561
+ BEGIN
1562
+ FOR t IN SELECT unnest(ARRAY[
1563
+ 'plg_calendar_integrations',
1564
+ 'plg_calendar_channels',
1565
+ 'plg_calendar_sync_log'
1566
+ ])
1567
+ LOOP
1568
+ IF NOT EXISTS (
1569
+ SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_rls'
1570
+ ) THEN
1571
+ EXECUTE format(
1572
+ 'CREATE POLICY %I ON public.%I FOR ALL TO authenticated '
1573
+ 'USING (tenant_id IN (SELECT public.user_tenant_ids())) '
1574
+ 'WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()))',
1575
+ t||'_rls', t);
1576
+ END IF;
1577
+ IF NOT EXISTS (
1578
+ SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_service'
1579
+ ) THEN
1580
+ EXECUTE format(
1581
+ 'CREATE POLICY %I ON public.%I FOR ALL TO service_role USING (true) WITH CHECK (true)',
1582
+ t||'_service', t);
1583
+ END IF;
1584
+ END LOOP;
1585
+ END $$;
1586
+
1587
+ -- ===========================================================================
1588
+ -- \xA74 \u2014 Webhook intake: the delivery ledger and the automation outbox.
1589
+ --
1590
+ -- The delivery row IS the idempotency mechanism: the webhook inserts before
1591
+ -- doing any work and reads a 23505 as "already handled". Google retries hard,
1592
+ -- so this is what stops one notification becoming two appointments.
1593
+ -- ===========================================================================
1594
+ CREATE TABLE IF NOT EXISTS public.plg_calendar_webhook_deliveries (
1595
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
1596
+ tenant_id uuid NOT NULL,
1597
+ channel_ref uuid NOT NULL REFERENCES public.plg_calendar_channels(id) ON DELETE CASCADE,
1598
+ google_channel_id text NOT NULL,
1599
+ resource_id text,
1600
+ message_number bigint NOT NULL,
1601
+ resource_state text,
1602
+ status text NOT NULL DEFAULT 'pending'
1603
+ CHECK (status IN ('pending','processing','processed','failed','ignored')),
1604
+ error text,
1605
+ received_at timestamptz NOT NULL DEFAULT now(),
1606
+ processed_at timestamptz,
1607
+ created_at timestamptz DEFAULT now(),
1608
+ UNIQUE (channel_ref, message_number)
1609
+ );
1610
+
1611
+ -- Catches a replay that arrives after the channel row was recreated with the
1612
+ -- same Google channel id \u2014 the FK-scoped constraint above cannot see that.
1613
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_calendar_webhook_delivery_message
1614
+ ON public.plg_calendar_webhook_deliveries (google_channel_id, message_number);
1615
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_webhook_deliveries_tenant_created
1616
+ ON public.plg_calendar_webhook_deliveries (tenant_id, created_at DESC);
1617
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_webhook_deliveries_pending
1618
+ ON public.plg_calendar_webhook_deliveries (status, created_at)
1619
+ WHERE status IN ('pending','failed');
1620
+
1621
+ ALTER TABLE public.plg_calendar_webhook_deliveries ENABLE ROW LEVEL SECURITY;
1622
+ -- Read-only for the app: every write comes from the webhook function as
1623
+ -- service_role, so a browser cannot forge or retire a delivery.
1624
+ GRANT SELECT ON public.plg_calendar_webhook_deliveries TO authenticated;
1625
+ GRANT ALL ON public.plg_calendar_webhook_deliveries TO service_role;
1626
+
1627
+ DROP POLICY IF EXISTS plg_calendar_webhook_deliveries_read ON public.plg_calendar_webhook_deliveries;
1628
+ CREATE POLICY plg_calendar_webhook_deliveries_read ON public.plg_calendar_webhook_deliveries
1629
+ FOR SELECT TO authenticated
1630
+ USING (tenant_id IN (SELECT public.user_tenant_ids()));
1631
+
1632
+ CREATE TABLE IF NOT EXISTS public.plg_calendar_automation_outbox (
1633
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
1634
+ tenant_id uuid NOT NULL,
1635
+ source text NOT NULL DEFAULT 'google_calendar',
1636
+ event_type text NOT NULL,
1637
+ aggregate_type text NOT NULL,
1638
+ aggregate_id text,
1639
+ idempotency_key text NOT NULL,
1640
+ payload jsonb NOT NULL DEFAULT '{}'::jsonb,
1641
+ status text NOT NULL DEFAULT 'pending'
1642
+ CHECK (status IN ('pending','processing','processed','failed','ignored')),
1643
+ attempts integer NOT NULL DEFAULT 0,
1644
+ last_error text,
1645
+ occurred_at timestamptz NOT NULL DEFAULT now(),
1646
+ -- available_at is the retry clock: a failed row is re-eligible once it passes.
1647
+ available_at timestamptz NOT NULL DEFAULT now(),
1648
+ processed_at timestamptz,
1649
+ error text,
1650
+ created_at timestamptz DEFAULT now(),
1651
+ UNIQUE (tenant_id, idempotency_key)
1652
+ );
1653
+
1654
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_automation_outbox_pending
1655
+ ON public.plg_calendar_automation_outbox (status, available_at, created_at)
1656
+ WHERE status IN ('pending','failed');
1657
+
1658
+ ALTER TABLE public.plg_calendar_automation_outbox ENABLE ROW LEVEL SECURITY;
1659
+ GRANT SELECT ON public.plg_calendar_automation_outbox TO authenticated;
1660
+ GRANT ALL ON public.plg_calendar_automation_outbox TO service_role;
1661
+
1662
+ DROP POLICY IF EXISTS plg_calendar_automation_outbox_read ON public.plg_calendar_automation_outbox;
1663
+ CREATE POLICY plg_calendar_automation_outbox_read ON public.plg_calendar_automation_outbox
1664
+ FOR SELECT TO authenticated
1665
+ USING (tenant_id IN (SELECT public.user_tenant_ids()));
1666
+
1667
+ -- ===========================================================================
1668
+ -- \xA75 \u2014 The gate.
1669
+ --
1670
+ -- Everything above is a CREATE, and a CREATE says nothing about a pool that
1671
+ -- already has the old shape. This block is the part that actually removes the
1672
+ -- hole: it drops the three credential columns wherever it finds them \u2014 the
1673
+ -- pool 004 never reached, the pool restored from an old backup, the pool
1674
+ -- someone re-provisioned from 001 \u2014 and then refuses to finish if any survived.
1675
+ --
1676
+ -- The RAISE matters more than the DROP. A migration that silently tolerated a
1677
+ -- token column is how this schema kept one for months.
1678
+ -- ===========================================================================
1679
+ DO $$
1680
+ DECLARE
1681
+ leftover text;
1682
+ BEGIN
1683
+ ALTER TABLE IF EXISTS public.plg_calendar_integrations DROP COLUMN IF EXISTS oauth_refresh_token;
1684
+ ALTER TABLE IF EXISTS public.plg_calendar_integrations DROP COLUMN IF EXISTS oauth_access_token;
1685
+ ALTER TABLE IF EXISTS public.plg_calendar_integrations DROP COLUMN IF EXISTS token_expires_at;
1686
+
1687
+ SELECT string_agg(table_name || '.' || column_name, ', ')
1688
+ INTO leftover
1689
+ FROM information_schema.columns
1690
+ WHERE table_schema = 'public'
1691
+ AND table_name LIKE 'plg_calendar%'
1692
+ AND (
1693
+ column_name LIKE '%refresh_token%'
1694
+ OR column_name LIKE '%access_token%'
1695
+ OR column_name IN ('oauth_token', 'client_secret', 'api_key')
1696
+ );
1697
+
1698
+ IF leftover IS NOT NULL THEN
1699
+ RAISE EXCEPTION
1700
+ 'Google Calendar schema still holds credential columns: %. The grant belongs in the platform vault (FAY-1359); redeem it at run time instead.',
1701
+ leftover;
1702
+ END IF;
1703
+ END $$;
1704
+
1705
+ -- Dropping the columns does not erase what was in them: Postgres leaves the old
1706
+ -- values in the heap until the next table rewrite. Any grant that ever reached a
1707
+ -- pool must be REVOKED AT GOOGLE, not assumed gone \u2014 which is also why every
1708
+ -- tenant re-consents after this file runs.
1709
+ `;
1710
+ var MIGRATION_008_THE_OUTBOUND_HALF = `-- ---------------------------------------------------------------------------
1711
+ -- Google Calendar \u2014 the outbound half that 007 does not install
1712
+ -- ---------------------------------------------------------------------------
1713
+ -- 007 is the credential-free install: it creates the five plg_calendar_* tables
1714
+ -- from zero, with no column a Google grant could ever sit in. What it does NOT
1715
+ -- create is the trigger on public.appointments that pushes a booking OUT to
1716
+ -- Google, because that trigger never lived in those tables.
1717
+ --
1718
+ -- Only 001 and 002 create \`trg_gcal_push\`, and 001 is the file whose credential
1719
+ -- columns 007 exists to prevent. So there is no ordering of the existing files
1720
+ -- that gives a fresh pool the outbound direction without also giving it, for a
1721
+ -- while, a plaintext refresh-token column.
1722
+ --
1723
+ -- The result on a pool installed by 007 alone: Google \u2192 Fayz works, Fayz \u2192
1724
+ -- Google is silent. Not an error, not a warning \u2014 appointments simply never
1725
+ -- reach the calendar. Discovered on cluster-squash-test after 007 came up green
1726
+ -- and the tables were correct.
1727
+ --
1728
+ -- This file is the missing half, and nothing else:
1729
+ --
1730
+ -- \xA71 public.fn_gcal_push_booking \u2014 verbatim the definition 005 settled on:
1731
+ -- vault first (\`gcal_sync_url\` + \`gcal_outbound_secret\`), the legacy GUC
1732
+ -- only as a fallback that WARNs, and a no-op when neither is configured.
1733
+ -- Reproduced rather than referenced because 005 cannot run here: it
1734
+ -- replaces a function 001 created, and 001 is the file we are avoiding.
1735
+ -- \xA72 trg_gcal_push on public.appointments \u2014 the shape 002 re-asserts.
1736
+ --
1737
+ -- A pool that already ran 001\u2192006 gets an identical function and an identical
1738
+ -- trigger, which is why this is safe to apply anywhere: every statement is
1739
+ -- CREATE OR REPLACE / DROP-then-CREATE.
1740
+ --
1741
+ -- WITHOUT THE VAULT SECRETS THIS IS INERT ON PURPOSE. The function reads
1742
+ -- \`gcal_sync_url\` and \`gcal_outbound_secret\` from the vault and returns without
1743
+ -- pushing when they are absent, so installing the trigger before the secrets
1744
+ -- exist cannot break an INSERT into appointments \u2014 the most load-bearing table
1745
+ -- the agenda has.
1746
+ --
1747
+ -- Idempotent and replay-safe.
1748
+ -- ---------------------------------------------------------------------------
1749
+
1750
+ -- ===========================================================================
1751
+ -- \xA71 \u2014 the push, as 005 defined it
1752
+ -- ===========================================================================
1753
+ CREATE OR REPLACE FUNCTION public.fn_gcal_push_booking() RETURNS trigger
1754
+ LANGUAGE plpgsql
1755
+ SECURITY DEFINER
1756
+ SET search_path = public, extensions, net, vault
1757
+ AS $$
1758
+ DECLARE
1759
+ url text;
1760
+ key text;
1761
+ legacy boolean := false;
1762
+ rec record := COALESCE(NEW, OLD);
1763
+ op text := lower(TG_OP);
1764
+ corr text := NULLIF(current_setting('app.booking_correlation_id', true), '');
1765
+ BEGIN
1766
+ -- Unchanged from 004: inbound Google writes mark themselves, and a write
1767
+ -- carrying that marker is never echoed back.
1768
+ IF current_setting('app.booking_origin', true) = 'google' THEN
1769
+ RETURN COALESCE(NEW, OLD);
1770
+ END IF;
1771
+
1772
+ -- Vault first. Wrapped because a pool without the vault extension must
1773
+ -- degrade to the fallback, not abort the booking write.
1774
+ BEGIN
1775
+ SELECT decrypted_secret INTO url
1776
+ FROM vault.decrypted_secrets WHERE name = 'gcal_sync_url'
1777
+ ORDER BY created_at DESC LIMIT 1;
1778
+ SELECT decrypted_secret INTO key
1779
+ FROM vault.decrypted_secrets WHERE name = 'gcal_outbound_secret'
1780
+ ORDER BY created_at DESC LIMIT 1;
1781
+ EXCEPTION WHEN OTHERS THEN
1782
+ url := NULL;
1783
+ key := NULL;
1784
+ END;
1785
+
1786
+ -- Legacy pools provisioned per 001's header keep working, and say so.
1787
+ IF NULLIF(url, '') IS NULL OR NULLIF(key, '') IS NULL THEN
1788
+ url := COALESCE(NULLIF(url, ''), current_setting('app.gcal_sync_url', true));
1789
+ key := COALESCE(NULLIF(key, ''), current_setting('app.gcal_service_key', true));
1790
+ legacy := NULLIF(key, '') IS NOT NULL;
1791
+ END IF;
1792
+
1793
+ -- Not configured is not an error: an unconfigured pool degrades to "Google is
1794
+ -- not updated", never to "the appointment could not be saved".
1795
+ IF NULLIF(url, '') IS NULL OR NULLIF(key, '') IS NULL THEN
1796
+ RETURN COALESCE(NEW, OLD);
1797
+ END IF;
1798
+
1799
+ IF legacy THEN
1800
+ RAISE WARNING 'gcal outbound is still using app.gcal_service_key; move it to the vault secret gcal_outbound_secret and rotate the service-role key';
1801
+ END IF;
1802
+
1803
+ -- Does THIS tenant have a calendar at all?
1804
+ --
1805
+ -- Added here, not inherited from 005. The vault secrets are per POOL and the
1806
+ -- integration is per TENANT, so without this every appointment write in the
1807
+ -- pool posted to the sync function \u2014 including for tenants that never
1808
+ -- connected Google. On the cluster this was installed against that is 354.989
1809
+ -- appointments across several tenants, all of them paying for an HTTP round
1810
+ -- trip whose only possible answer is "no integration".
1811
+ --
1812
+ -- The function would have answered correctly; the cost is the asking. Indexed
1813
+ -- by tenant (idx_plg_calendar_integrations_tenant), so the guard is a lookup,
1814
+ -- not a scan.
1815
+ --
1816
+ -- A tenant who DISCONNECTS stops pushing from that moment, which means a
1817
+ -- booking deleted afterwards leaves its Google event behind. That is the right
1818
+ -- trade: the alternative is pushing on behalf of someone who revoked us.
1819
+ IF NOT EXISTS (
1820
+ SELECT 1 FROM public.plg_calendar_integrations i
1821
+ WHERE i.tenant_id = rec.tenant_id AND i.active
1822
+ ) THEN
1823
+ RETURN COALESCE(NEW, OLD);
1824
+ END IF;
1825
+
1826
+ BEGIN
1827
+ PERFORM net.http_post(
1828
+ url := url,
1829
+ headers := jsonb_build_object(
1830
+ 'Content-Type', 'application/json',
1831
+ -- REQUIRED. The deployed function authorises push_event on this header
1832
+ -- alone; without it every push 400s, silently, forever.
1833
+ 'Authorization', 'Bearer ' || key
1834
+ ),
1835
+ body := jsonb_build_object(
1836
+ 'action', 'push_event',
1837
+ 'op', op,
1838
+ 'bookingId', rec.id,
1839
+ 'tenantId', rec.tenant_id,
1840
+ -- Carried so a delete can still be honoured after the row is gone.
1841
+ 'googleEventId', rec.metadata->>'googleCalendarEventId',
1842
+ 'calendarId', rec.metadata->>'gcalCalendarId',
1843
+ 'correlationId', COALESCE(corr, gen_random_uuid()::text)
1844
+ )
1845
+ );
1846
+ EXCEPTION WHEN OTHERS THEN
1847
+ -- Missing pg_net, a bad URL, a full queue: the appointment is still saved.
1848
+ -- The deployed trigger lacks this wrapper; 004 added it here after a pool
1849
+ -- with the settings present but the extension missing took an INSERT down.
1850
+ RAISE WARNING 'gcal push skipped for booking %: %', rec.id, SQLERRM;
1851
+ END;
1852
+
1853
+ RETURN COALESCE(NEW, OLD);
1854
+ END $$;
1855
+
1856
+ -- \xA72 \u2500\u2500 cleanup \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1857
+ -- Redefining the function stops the key being PREFERRED; it does not stop it
1858
+ -- being STORED. Settings live in pg_db_role_setting per (database, role), so the
1859
+ -- sweep is driven off the catalog rather than off the one ALTER DATABASE form
1860
+ -- \`001\` happened to show.
1861
+ --
1862
+ -- CONDITIONAL ON PURPOSE: the setting is cleared only where Vault already
1863
+ -- supplies \`gcal_outbound_secret\`, i.e. only where \xA71 provably never needs the
1864
+ -- fallback. Anywhere else it is left in place and reported, because clearing it
1865
+ -- there would disarm the push with no error anyone can see. On the live pool
1866
+ -- this loop finds nothing \u2014 the setting is not present on any role or database \u2014
1867
+ -- and that is the expected result, not a failure.
1868
+ DO $$
1869
+ DECLARE
1870
+ r record;
1871
+ has_vault boolean := false;
1872
+ BEGIN
1873
+ BEGIN
1874
+ SELECT EXISTS (
1875
+ SELECT 1 FROM vault.decrypted_secrets
1876
+ WHERE name = 'gcal_outbound_secret' AND NULLIF(decrypted_secret, '') IS NOT NULL
1877
+ ) INTO has_vault;
1878
+ EXCEPTION WHEN OTHERS THEN
1879
+ has_vault := false;
1880
+ END;
1881
+
1882
+ FOR r IN
1883
+ SELECT d.datname, a.rolname
1884
+ FROM pg_db_role_setting s
1885
+ LEFT JOIN pg_database d ON d.oid = s.setdatabase
1886
+ LEFT JOIN pg_roles a ON a.oid = s.setrole
1887
+ WHERE EXISTS (
1888
+ SELECT 1 FROM unnest(s.setconfig) AS c WHERE c LIKE 'app.gcal_service_key=%'
1889
+ )
1890
+ LOOP
1891
+ IF NOT has_vault THEN
1892
+ RAISE WARNING 'app.gcal_service_key is still set (database %, role %) and is the ONLY outbound credential \u2014 create the vault secret gcal_outbound_secret first, then re-run this migration',
1893
+ r.datname, r.rolname;
1894
+ CONTINUE;
1895
+ END IF;
1896
+ BEGIN
1897
+ IF r.rolname IS NULL THEN
1898
+ EXECUTE format('ALTER DATABASE %I RESET app.gcal_service_key', r.datname);
1899
+ ELSIF r.datname IS NULL THEN
1900
+ EXECUTE format('ALTER ROLE %I RESET app.gcal_service_key', r.rolname);
1901
+ ELSE
1902
+ EXECUTE format('ALTER ROLE %I IN DATABASE %I RESET app.gcal_service_key', r.rolname, r.datname);
1903
+ END IF;
1904
+ EXCEPTION WHEN OTHERS THEN
1905
+ RAISE WARNING 'could not clear app.gcal_service_key (database %, role %): % \u2014 clear it by hand, and rotate the key either way',
1906
+ r.datname, r.rolname, SQLERRM;
1907
+ END;
1908
+ END LOOP;
1909
+ END $$;
1910
+
1911
+ -- ===========================================================================
1912
+ -- \xA72 \u2014 the trigger 007 left out
1913
+ --
1914
+ -- AFTER INSERT OR UPDATE OR DELETE: a booking that is moved or cancelled has to
1915
+ -- reach Google too, and a trigger that only fired on INSERT would leave the
1916
+ -- calendar showing an appointment the CRM no longer has.
1917
+ -- ===========================================================================
1918
+ DROP TRIGGER IF EXISTS trg_gcal_push ON public.appointments;
1919
+ CREATE TRIGGER trg_gcal_push
1920
+ AFTER INSERT OR UPDATE OR DELETE ON public.appointments
1921
+ FOR EACH ROW EXECUTE FUNCTION public.fn_gcal_push_booking();
1922
+
1923
+ COMMENT ON FUNCTION public.fn_gcal_push_booking() IS
1924
+ 'Pushes a booking to Google Calendar over pg_net, authorised by the vault secret gcal_outbound_secret. Inert while the vault secrets are absent (008).';
1925
+ `;
1926
+ var MIGRATION_009_THE_SERVICE_RUNS_THE_SYNC = `-- ============================================================================
1927
+ -- google-calendar 009_the_service_runs_the_sync \u2014 the Google Calendar data
1928
+ -- plane moves to the integration service (ADR 0003 phase 2, wave 3).
1929
+ --
1930
+ -- User actions run AS the verified caller and machine actions (a booking push,
1931
+ -- a webhook pull, watch renewal) AS the store's owner, both under RLS: the
1932
+ -- integrations, channels and sync log already admit a member. What only a
1933
+ -- machine writes gets the service role, table by table, with an explicit
1934
+ -- policy: the webhook delivery log and the automation outbox. And the four
1935
+ -- server-only RPCs the sync calls (002/003: service_role only) get EXECUTE for
1936
+ -- the service role, where they exist on the cluster.
1937
+ --
1938
+ -- Guarded: the role is an operator step, and a cluster may not have the RPCs.
1939
+ -- Replay-safe.
1940
+ -- ============================================================================
1941
+
1942
+ DO $$
1943
+ DECLARE
1944
+ t text;
1945
+ f text;
1946
+ BEGIN
1947
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fayz_integration') THEN
1948
+ RETURN;
1949
+ END IF;
1950
+
1951
+ FOREACH t IN ARRAY ARRAY['plg_calendar_webhook_deliveries', 'plg_calendar_automation_outbox'] LOOP
1952
+ IF to_regclass('public.' || t) IS NULL THEN CONTINUE; END IF;
1953
+ EXECUTE format('GRANT SELECT, INSERT, UPDATE ON public.%I TO fayz_integration', t);
1954
+ EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_service', t);
1955
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR ALL TO fayz_integration USING (true) WITH CHECK (true)', t || '_service', t);
1956
+ END LOOP;
1957
+
1958
+ -- The webhook resolves a channel by its Google channel id before it knows the
1959
+ -- tenant: a read of the channels table, and nothing else on it.
1960
+ IF to_regclass('public.plg_calendar_channels') IS NOT NULL THEN
1961
+ GRANT SELECT ON public.plg_calendar_channels TO fayz_integration;
1962
+ DROP POLICY IF EXISTS plg_calendar_channels_service_read ON public.plg_calendar_channels;
1963
+ CREATE POLICY plg_calendar_channels_service_read ON public.plg_calendar_channels FOR SELECT TO fayz_integration USING (true);
1964
+ END IF;
1965
+
1966
+ FOREACH f IN ARRAY ARRAY[
1967
+ 'public.gcal_import_event(uuid, uuid, text, text, text, timestamptz, timestamptz, boolean, text)',
1968
+ 'public.gcal_apply_event_patch(uuid, text, text, timestamptz, timestamptz, text, boolean)',
1969
+ 'public.gcal_stamp_outbound(uuid, uuid, text, text, text)',
1970
+ 'public.gcal_unlink_channel(uuid)'
1971
+ ] LOOP
1972
+ IF to_regprocedure(f) IS NOT NULL THEN
1973
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO fayz_integration', f);
1974
+ END IF;
1975
+ END LOOP;
1976
+ END $$;
1977
+ `;
1978
+ var MIGRATIONS = [
1979
+ { id: "001_google_calendar", sql: MIGRATION_001_GOOGLE_CALENDAR },
1980
+ { id: "002_smart_sync", sql: MIGRATION_002_SMART_SYNC },
1981
+ { id: "003_plg_rename", sql: MIGRATION_003_PLG_RENAME },
1982
+ { id: "004_drop_oauth_tokens", sql: MIGRATION_004_DROP_OAUTH_TOKENS },
1983
+ { id: "005_outbound_vault_delivery", sql: MIGRATION_005_OUTBOUND_VAULT_DELIVERY },
1984
+ { id: "006_webhook_deliveries_outbox", sql: MIGRATION_006_WEBHOOK_DELIVERIES_OUTBOX },
1985
+ { id: "007_credential_free_install", sql: MIGRATION_007_CREDENTIAL_FREE_INSTALL },
1986
+ { id: "008_the_outbound_half", sql: MIGRATION_008_THE_OUTBOUND_HALF },
1987
+ { id: "009_the_service_runs_the_sync", sql: MIGRATION_009_THE_SERVICE_RUNS_THE_SYNC }
1988
+ ];
1989
+
1990
+ export { MIGRATIONS, MIGRATION_001_GOOGLE_CALENDAR, MIGRATION_002_SMART_SYNC, MIGRATION_003_PLG_RENAME, MIGRATION_004_DROP_OAUTH_TOKENS, MIGRATION_005_OUTBOUND_VAULT_DELIVERY, MIGRATION_006_WEBHOOK_DELIVERIES_OUTBOX, MIGRATION_007_CREDENTIAL_FREE_INSTALL, MIGRATION_008_THE_OUTBOUND_HALF, MIGRATION_009_THE_SERVICE_RUNS_THE_SYNC };
1991
+ //# sourceMappingURL=integrations-google-calendar.js.map
1992
+ //# sourceMappingURL=integrations-google-calendar.js.map