@fayz-ai/plugin-agenda 0.8.1 → 0.8.3

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 (117) hide show
  1. package/dist/{chunk-LFUELUIT.cjs → chunk-3KWNHV5A.cjs} +60 -2
  2. package/dist/chunk-3KWNHV5A.cjs.map +1 -0
  3. package/dist/{chunk-DRZF6CV2.js → chunk-YQO6B4G3.js} +60 -3
  4. package/dist/chunk-YQO6B4G3.js.map +1 -0
  5. package/dist/components/AgendaGeneralSettings.d.ts.map +1 -1
  6. package/dist/components/AppointmentModal.d.ts.map +1 -1
  7. package/dist/components/CalendarToolbar.d.ts.map +1 -1
  8. package/dist/data/supabase.d.ts +5 -1
  9. package/dist/data/supabase.d.ts.map +1 -1
  10. package/dist/index.cjs +1071 -766
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +914 -609
  14. package/dist/index.js.map +1 -1
  15. package/dist/integrations/google-calendar/components/CalendarColorDot.d.ts +6 -0
  16. package/dist/integrations/google-calendar/components/CalendarColorDot.d.ts.map +1 -0
  17. package/dist/integrations/google-calendar/components/ChannelMappingList.d.ts +9 -0
  18. package/dist/integrations/google-calendar/components/ChannelMappingList.d.ts.map +1 -0
  19. package/dist/integrations/google-calendar/components/ImportEventsWizard.d.ts +12 -0
  20. package/dist/integrations/google-calendar/components/ImportEventsWizard.d.ts.map +1 -0
  21. package/dist/integrations/google-calendar/components/useAgendaLookups.d.ts +26 -0
  22. package/dist/integrations/google-calendar/components/useAgendaLookups.d.ts.map +1 -0
  23. package/dist/integrations/google-calendar/connectorDef.d.ts.map +1 -1
  24. package/dist/integrations/google-calendar/data/supabase.d.ts +33 -1
  25. package/dist/integrations/google-calendar/data/supabase.d.ts.map +1 -1
  26. package/dist/integrations/google-calendar/data/tables.d.ts +6 -0
  27. package/dist/integrations/google-calendar/data/tables.d.ts.map +1 -0
  28. package/dist/integrations/google-calendar/index.d.ts +1 -1
  29. package/dist/integrations/google-calendar/index.d.ts.map +1 -1
  30. package/dist/integrations/google-calendar/mapping.d.ts +56 -4
  31. package/dist/integrations/google-calendar/mapping.d.ts.map +1 -1
  32. package/dist/integrations/google-calendar/types.d.ts +101 -0
  33. package/dist/integrations/google-calendar/types.d.ts.map +1 -1
  34. package/dist/locales/en.d.ts.map +1 -1
  35. package/dist/locales/pt-BR.d.ts.map +1 -1
  36. package/dist/migrations/index.d.ts +11 -0
  37. package/dist/migrations/index.d.ts.map +1 -1
  38. package/dist/public/BookingWidget.d.ts.map +1 -1
  39. package/dist/public/context.d.ts +3 -1
  40. package/dist/public/context.d.ts.map +1 -1
  41. package/dist/public/createPublicBookingPlugin.d.ts +7 -1
  42. package/dist/public/createPublicBookingPlugin.d.ts.map +1 -1
  43. package/dist/public/data.d.ts +6 -0
  44. package/dist/public/data.d.ts.map +1 -1
  45. package/dist/public/data.supabase.d.ts.map +1 -1
  46. package/dist/public/format.d.ts +22 -1
  47. package/dist/public/format.d.ts.map +1 -1
  48. package/dist/public/hooks.d.ts.map +1 -1
  49. package/dist/public/index.cjs +31 -63
  50. package/dist/public/index.cjs.map +1 -1
  51. package/dist/public/index.d.ts +1 -1
  52. package/dist/public/index.d.ts.map +1 -1
  53. package/dist/public/index.js +27 -61
  54. package/dist/public/index.js.map +1 -1
  55. package/dist/public/steps/ContactStep.d.ts +4 -2
  56. package/dist/public/steps/ContactStep.d.ts.map +1 -1
  57. package/dist/public/types.d.ts +16 -0
  58. package/dist/public/types.d.ts.map +1 -1
  59. package/dist/views/CalendarView.d.ts.map +1 -1
  60. package/package.json +5 -5
  61. package/src/components/AgendaGeneralSettings.tsx +38 -16
  62. package/src/components/AppointmentModal.tsx +50 -108
  63. package/src/components/CalendarToolbar.tsx +10 -7
  64. package/src/data/supabase.ts +67 -13
  65. package/src/index.ts +56 -8
  66. package/src/integrations/google-calendar/components/CalendarColorDot.tsx +23 -0
  67. package/src/integrations/google-calendar/components/ChannelMappingList.tsx +329 -0
  68. package/src/integrations/google-calendar/components/ImportEventsWizard.tsx +215 -0
  69. package/src/integrations/google-calendar/components/useAgendaLookups.ts +100 -0
  70. package/src/integrations/google-calendar/connectorDef.tsx +123 -40
  71. package/src/integrations/google-calendar/data/supabase.ts +109 -13
  72. package/src/integrations/google-calendar/data/tables.ts +12 -0
  73. package/src/integrations/google-calendar/index.ts +10 -1
  74. package/src/integrations/google-calendar/integration.test.ts +126 -1
  75. package/src/integrations/google-calendar/mapping.ts +149 -9
  76. package/src/integrations/google-calendar/migrations/001_google_calendar.sql +9 -6
  77. package/src/integrations/google-calendar/migrations/002_smart_sync.sql +517 -0
  78. package/src/integrations/google-calendar/migrations/003_plg_rename.sql +273 -0
  79. package/src/integrations/google-calendar/types.ts +111 -0
  80. package/src/locales/en.ts +1 -0
  81. package/src/locales/pt-BR.ts +1 -0
  82. package/src/migrations/003_public_client_kind.sql +175 -0
  83. package/src/migrations/004_public_booking_assignee.sql +176 -0
  84. package/src/migrations/005_agent_rpcs.sql +168 -0
  85. package/src/migrations/006_agent_rpc_localtime.sql +181 -0
  86. package/src/migrations/007_schedule_exception_precedence.sql +290 -0
  87. package/src/migrations/008_agent_rpc_exception_precedence.sql +172 -0
  88. package/src/migrations/009_public_booking_location.sql +162 -0
  89. package/src/migrations/010_public_service_curation.sql +31 -0
  90. package/src/migrations/011_default_assignee.sql +167 -0
  91. package/src/migrations/012_drop_legacy_booking_overload.sql +29 -0
  92. package/src/migrations/013_normalize_v_appointments.sql +161 -0
  93. package/src/migrations/index.ts +1746 -1
  94. package/src/public/BookingWidget.tsx +10 -1
  95. package/src/public/context.tsx +3 -0
  96. package/src/public/createPublicBookingPlugin.ts +14 -1
  97. package/src/public/data.supabase.ts +3 -0
  98. package/src/public/data.ts +6 -0
  99. package/src/public/format.ts +36 -1
  100. package/src/public/hooks.ts +7 -2
  101. package/src/public/index.ts +1 -0
  102. package/src/public/steps/ContactStep.tsx +10 -5
  103. package/src/public/types.ts +21 -0
  104. package/src/views/CalendarView.tsx +15 -1
  105. package/dist/chunk-3LJKZQK4.js +0 -12
  106. package/dist/chunk-3LJKZQK4.js.map +0 -1
  107. package/dist/chunk-DRZF6CV2.js.map +0 -1
  108. package/dist/chunk-LFUELUIT.cjs.map +0 -1
  109. package/dist/chunk-SYDBDI3Q.cjs +0 -15
  110. package/dist/chunk-SYDBDI3Q.cjs.map +0 -1
  111. package/dist/components/SearchCombobox.d.ts +0 -38
  112. package/dist/components/SearchCombobox.d.ts.map +0 -1
  113. package/dist/tenant-222EHYUC.cjs +0 -16
  114. package/dist/tenant-222EHYUC.cjs.map +0 -1
  115. package/dist/tenant-LAUWVUYB.js +0 -3
  116. package/dist/tenant-LAUWVUYB.js.map +0 -1
  117. package/src/components/SearchCombobox.tsx +0 -216
@@ -0,0 +1,517 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- Google Calendar SMART SYNC — multi-calendar de-para + loop-safe two-way sync
3
+ -- ---------------------------------------------------------------------------
4
+ -- Builds on 001_google_calendar.sql. Where 001 modelled a single calendar per
5
+ -- tenant (plg_calendar_integrations, one outbound trigger, a pull template), 002
6
+ -- makes the sync vertical-agnostic and bidirectional-safe:
7
+ --
8
+ -- §1 public.plg_calendar_channels — de-para: one Google calendar → one sync
9
+ -- target (whole tenant / assignee / service
10
+ -- / location), per direction + import mode
11
+ -- §2 fn_gcal_push_booking (REPLACE)— loop-prevention guard + correlation id
12
+ -- §3 plg_calendar_sync_log — correlation_id + channel_id_ref columns
13
+ -- §4 inbound write functions — the ONLY path Google→booking writes take
14
+ -- (gcal_import_event / gcal_apply_event_patch
15
+ -- / gcal_unlink_channel), SECURITY DEFINER,
16
+ -- service_role-only
17
+ -- §5 get_available_slots (REPLACE) — tenant-wide imported blocks now also
18
+ -- remove slots for every professional
19
+ -- §6 pg_cron template — per-channel pull
20
+ --
21
+ -- Idempotent / re-runnable: IF NOT EXISTS, CREATE OR REPLACE, DROP … IF EXISTS.
22
+ -- Written against the canonical public names (appointments uses starts_at/ends_at,
23
+ -- metadata jsonb, party_id/assignee_id — see packages/db 004_archetypes.sql).
24
+ -- ---------------------------------------------------------------------------
25
+
26
+ -- ===========================================================================
27
+ -- §1 — plg_calendar_channels: multi-calendar de-para (vertical-agnostic)
28
+ -- target_kind NULL → whole tenant agenda (agency-os): every professional
29
+ -- target_kind 'assignee' → calendar maps to one professional (school: teacher)
30
+ -- target_kind 'service' | 'location' → clinic-style mapping
31
+ -- import_mode 'block' → inbound events become opaque busy blocks
32
+ -- import_mode 'appointment' → inbound events become real appointments
33
+ -- ===========================================================================
34
+ CREATE TABLE IF NOT EXISTS public.plg_calendar_channels (
35
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
36
+ integration_id uuid NOT NULL REFERENCES public.plg_calendar_integrations(id) ON DELETE CASCADE,
37
+ tenant_id uuid NOT NULL,
38
+ google_calendar_id text NOT NULL,
39
+ summary text,
40
+ color text,
41
+ direction text NOT NULL DEFAULT 'bidirectional'
42
+ CHECK (direction IN ('inbound','outbound','bidirectional','off')),
43
+ target_kind text CHECK (target_kind IN ('assignee','service','location')),
44
+ target_id uuid,
45
+ import_mode text NOT NULL DEFAULT 'block'
46
+ CHECK (import_mode IN ('block','appointment')),
47
+ sync_token text,
48
+ channel_id text,
49
+ resource_id text,
50
+ channel_expires_at timestamptz,
51
+ is_active boolean NOT NULL DEFAULT true,
52
+ created_at timestamptz NOT NULL DEFAULT now(),
53
+ updated_at timestamptz NOT NULL DEFAULT now(),
54
+ UNIQUE (integration_id, google_calendar_id)
55
+ );
56
+
57
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_channels_tenant ON public.plg_calendar_channels(tenant_id);
58
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_channels_integration ON public.plg_calendar_channels(integration_id);
59
+
60
+ ALTER TABLE public.plg_calendar_channels ENABLE ROW LEVEL SECURITY;
61
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_calendar_channels TO authenticated;
62
+ -- service_role bypasses RLS, but this table is born after 004's blanket grant,
63
+ -- so grant it explicitly (the inbound functions run as definer/service_role).
64
+ GRANT ALL ON public.plg_calendar_channels TO service_role;
65
+
66
+ DROP TRIGGER IF EXISTS plg_calendar_channels_updated_at ON public.plg_calendar_channels;
67
+ CREATE TRIGGER plg_calendar_channels_updated_at BEFORE UPDATE ON public.plg_calendar_channels
68
+ FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
69
+
70
+ -- RLS: tenant isolation (padrão 001) + explicit service_role FOR ALL policy
71
+ -- (matches the vigente service_role policy pattern from 001_core payment_events).
72
+ DROP POLICY IF EXISTS plg_calendar_channels_rls ON public.plg_calendar_channels;
73
+ CREATE POLICY plg_calendar_channels_rls ON public.plg_calendar_channels
74
+ FOR ALL TO authenticated
75
+ USING (tenant_id IN (SELECT public.user_tenant_ids()))
76
+ WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
77
+
78
+ DROP POLICY IF EXISTS plg_calendar_channels_service ON public.plg_calendar_channels;
79
+ CREATE POLICY plg_calendar_channels_service ON public.plg_calendar_channels
80
+ FOR ALL TO service_role USING (true) WITH CHECK (true);
81
+
82
+ -- ===========================================================================
83
+ -- §2 — Outbound trigger with loop-prevention + correlation id.
84
+ -- CREATE OR REPLACE of 001's fn_gcal_push_booking. Same pg_net → edge function
85
+ -- payload (app.gcal_sync_url / app.gcal_service_key), but now:
86
+ -- * SKIP the push when the write itself came from the Google sync, detected by
87
+ -- the TRANSACTION-LOCAL GUC app.booking_origin = 'google' — set by every §4
88
+ -- writer via set_config(..., true) and gone when the transaction ends.
89
+ -- This is what stops the Google→booking→Google→… echo loop.
90
+ -- Deliberately NOT a persistent metadata check: a sticky marker
91
+ -- (metadata.syncOrigin) would permanently mute pushes for imported events,
92
+ -- so a later admin edit would never reach Google. metadata.syncOrigin is
93
+ -- kept by §4 as provenance only — this trigger does not read it.
94
+ -- * carry a correlation_id so both directions can be stitched in the sync log
95
+ -- (from GUC app.booking_correlation_id, else a fresh uuid — design ported
96
+ -- from PR #7).
97
+ -- ===========================================================================
98
+ CREATE OR REPLACE FUNCTION public.fn_gcal_push_booking() RETURNS trigger
99
+ LANGUAGE plpgsql SECURITY DEFINER AS $$
100
+ DECLARE
101
+ url text := current_setting('app.gcal_sync_url', true);
102
+ key text := current_setting('app.gcal_service_key', true);
103
+ rec record := COALESCE(NEW, OLD);
104
+ op text := lower(TG_OP);
105
+ corr text := NULLIF(current_setting('app.booking_correlation_id', true), '');
106
+ BEGIN
107
+ -- Loop guard: never bounce a Google-originated write back to Google.
108
+ -- Transaction-local GUC only (see §2 header for why not persistent metadata).
109
+ IF current_setting('app.booking_origin', true) = 'google' THEN
110
+ RETURN COALESCE(NEW, OLD);
111
+ END IF;
112
+
113
+ IF url IS NULL OR url = '' THEN RETURN COALESCE(NEW, OLD); END IF;
114
+
115
+ PERFORM net.http_post(
116
+ url := url,
117
+ headers := jsonb_build_object('Content-Type', 'application/json',
118
+ 'Authorization', 'Bearer ' || COALESCE(key, '')),
119
+ body := jsonb_build_object(
120
+ 'action', 'push_event',
121
+ 'op', op,
122
+ 'bookingId', rec.id,
123
+ 'tenantId', rec.tenant_id,
124
+ 'correlationId', COALESCE(corr, gen_random_uuid()::text)
125
+ )
126
+ );
127
+ RETURN COALESCE(NEW, OLD);
128
+ END $$;
129
+
130
+ -- Re-assert the trigger (idempotent; unchanged from 001).
131
+ DROP TRIGGER IF EXISTS trg_gcal_push ON public.appointments;
132
+ CREATE TRIGGER trg_gcal_push
133
+ AFTER INSERT OR UPDATE OR DELETE ON public.appointments
134
+ FOR EACH ROW EXECUTE FUNCTION public.fn_gcal_push_booking();
135
+
136
+ -- ===========================================================================
137
+ -- §3 — sync log: correlate the two directions + reference the source channel.
138
+ -- ===========================================================================
139
+ ALTER TABLE public.plg_calendar_sync_log
140
+ ADD COLUMN IF NOT EXISTS correlation_id uuid;
141
+ ALTER TABLE public.plg_calendar_sync_log
142
+ ADD COLUMN IF NOT EXISTS channel_id_ref uuid
143
+ REFERENCES public.plg_calendar_channels(id) ON DELETE SET NULL;
144
+ -- Unlinked future events seen during a pull (import-wizard candidates), so the
145
+ -- log can show "N eventos aguardando import" without re-hitting Google.
146
+ ALTER TABLE public.plg_calendar_sync_log
147
+ ADD COLUMN IF NOT EXISTS discovered integer;
148
+
149
+ -- ===========================================================================
150
+ -- §4 — Inbound write path (Google → booking). THE single entry point used by
151
+ -- the edge function (scheduled pull, webhook, and the import wizard). All three
152
+ -- functions are SECURITY DEFINER with SET search_path = public and are granted
153
+ -- to service_role ONLY (anon/authenticated explicitly revoked) — they bypass the
154
+ -- tenant RLS on purpose and must never be reachable from the anon/authenticated
155
+ -- PostgREST surface. Every writer sets the TRANSACTION-LOCAL GUC
156
+ -- app.booking_origin='google' (set_config ..., true) so §2's trigger suppresses
157
+ -- the echo for THIS write only; metadata.syncOrigin is provenance, not a guard.
158
+ -- ===========================================================================
159
+
160
+ -- gcal_import_event: create (or idempotently update) a booking from a Google
161
+ -- event, honouring the channel's import_mode + target mapping.
162
+ -- kind = 'block' | 'appointment' (from channel.import_mode)
163
+ -- assignee_id = channel.target_id when target_kind='assignee', else NULL
164
+ -- (NULL = tenant-wide → blocks every professional, see §5)
165
+ -- location_id = channel.target_id when target_kind='location', else NULL
166
+ -- status = 'scheduled'
167
+ -- No order is created — an external import is not a sale. Idempotent on
168
+ -- (tenant_id, metadata.googleCalendarEventId): update in place if seen before.
169
+ CREATE OR REPLACE FUNCTION public.gcal_import_event(
170
+ p_tenant_id uuid,
171
+ p_channel_id uuid,
172
+ p_event_id text,
173
+ p_etag text,
174
+ p_summary text,
175
+ p_starts_at timestamptz,
176
+ p_ends_at timestamptz,
177
+ p_all_day boolean,
178
+ p_description text DEFAULT NULL
179
+ ) RETURNS uuid
180
+ LANGUAGE plpgsql SECURITY DEFINER
181
+ SET search_path = public
182
+ AS $$
183
+ DECLARE
184
+ v_ch record;
185
+ v_kind text;
186
+ v_assignee uuid;
187
+ v_location uuid;
188
+ v_ends timestamptz;
189
+ v_meta jsonb;
190
+ v_existing uuid;
191
+ v_appt uuid;
192
+ BEGIN
193
+ IF p_tenant_id IS NULL OR p_event_id IS NULL OR p_starts_at IS NULL THEN
194
+ RAISE EXCEPTION 'gcal_import_event: tenant, event id and start are required';
195
+ END IF;
196
+
197
+ -- Transaction-local: §2's trigger skips the echo for this write only.
198
+ PERFORM set_config('app.booking_origin', 'google', true);
199
+
200
+ -- Resolve the channel mapping (must belong to the same tenant).
201
+ SELECT c.import_mode, c.target_kind, c.target_id
202
+ INTO v_ch
203
+ FROM plg_calendar_channels c
204
+ WHERE c.id = p_channel_id AND c.tenant_id = p_tenant_id;
205
+ IF NOT FOUND THEN RAISE EXCEPTION 'gcal_import_event: unknown channel for tenant'; END IF;
206
+
207
+ v_kind := CASE WHEN v_ch.import_mode = 'appointment' THEN 'appointment' ELSE 'block' END;
208
+ v_assignee := CASE WHEN v_ch.target_kind = 'assignee' THEN v_ch.target_id ELSE NULL END;
209
+ v_location := CASE WHEN v_ch.target_kind = 'location' THEN v_ch.target_id ELSE NULL END;
210
+
211
+ -- All-day events without an explicit end block the whole day so §5 excludes it.
212
+ v_ends := COALESCE(
213
+ p_ends_at,
214
+ CASE WHEN COALESCE(p_all_day, false) THEN p_starts_at + interval '1 day' ELSE p_starts_at END
215
+ );
216
+
217
+ v_meta := jsonb_build_object(
218
+ 'syncOrigin', 'google',
219
+ 'googleCalendarEventId', p_event_id,
220
+ 'gcalEtag', p_etag,
221
+ 'gcalChannelId', p_channel_id::text,
222
+ 'allDay', COALESCE(p_all_day, false),
223
+ 'title', p_summary,
224
+ 'source', 'google_import'
225
+ );
226
+
227
+ -- Idempotency: has this event already been imported for the tenant?
228
+ SELECT b.id INTO v_existing
229
+ FROM appointments b
230
+ WHERE b.tenant_id = p_tenant_id
231
+ AND b.metadata->>'googleCalendarEventId' = p_event_id
232
+ LIMIT 1;
233
+
234
+ IF v_existing IS NOT NULL THEN
235
+ UPDATE appointments b
236
+ SET kind = v_kind,
237
+ assignee_id = v_assignee,
238
+ location_id = v_location,
239
+ starts_at = p_starts_at,
240
+ ends_at = v_ends,
241
+ status = 'scheduled',
242
+ notes = COALESCE(p_description, p_summary),
243
+ metadata = COALESCE(b.metadata, '{}'::jsonb) || v_meta,
244
+ updated_at = now()
245
+ WHERE b.id = v_existing
246
+ RETURNING b.id INTO v_appt;
247
+ ELSE
248
+ INSERT INTO appointments (tenant_id, kind, assignee_id, location_id,
249
+ starts_at, ends_at, status, notes, metadata)
250
+ VALUES (p_tenant_id, v_kind, v_assignee, v_location,
251
+ p_starts_at, v_ends, 'scheduled', COALESCE(p_description, p_summary), v_meta)
252
+ RETURNING id INTO v_appt;
253
+ END IF;
254
+
255
+ RETURN v_appt;
256
+ END $$;
257
+
258
+ -- gcal_apply_event_patch: apply an inbound reschedule/cancel to an already-linked
259
+ -- booking. Located by metadata.googleCalendarEventId. Echo suppression: if the
260
+ -- stored gcalEtag already equals the incoming etag, this is our own write coming
261
+ -- back — skip and return NULL. Every real write re-stamps syncOrigin='google'.
262
+ CREATE OR REPLACE FUNCTION public.gcal_apply_event_patch(
263
+ p_tenant_id uuid,
264
+ p_event_id text,
265
+ p_etag text,
266
+ p_starts_at timestamptz,
267
+ p_ends_at timestamptz,
268
+ p_summary text,
269
+ p_cancelled boolean
270
+ ) RETURNS uuid
271
+ LANGUAGE plpgsql SECURITY DEFINER
272
+ SET search_path = public
273
+ AS $$
274
+ DECLARE
275
+ v_appt uuid;
276
+ v_curr_etag text;
277
+ BEGIN
278
+ IF p_tenant_id IS NULL OR p_event_id IS NULL THEN
279
+ RAISE EXCEPTION 'gcal_apply_event_patch: tenant and event id are required';
280
+ END IF;
281
+
282
+ -- Transaction-local: §2's trigger skips the echo for this write only.
283
+ PERFORM set_config('app.booking_origin', 'google', true);
284
+
285
+ SELECT b.id, b.metadata->>'gcalEtag'
286
+ INTO v_appt, v_curr_etag
287
+ FROM appointments b
288
+ WHERE b.tenant_id = p_tenant_id
289
+ AND b.metadata->>'googleCalendarEventId' = p_event_id
290
+ LIMIT 1;
291
+
292
+ IF v_appt IS NULL THEN RETURN NULL; END IF; -- not linked here
293
+ IF p_etag IS NOT NULL AND p_etag = v_curr_etag THEN
294
+ RETURN NULL; -- echo: nothing changed
295
+ END IF;
296
+
297
+ IF COALESCE(p_cancelled, false) THEN
298
+ UPDATE appointments b
299
+ SET status = 'cancelled',
300
+ metadata = COALESCE(b.metadata, '{}'::jsonb)
301
+ || jsonb_build_object('syncOrigin', 'google', 'gcalEtag', p_etag),
302
+ updated_at = now()
303
+ WHERE b.id = v_appt;
304
+ ELSE
305
+ UPDATE appointments b
306
+ SET starts_at = COALESCE(p_starts_at, b.starts_at),
307
+ ends_at = COALESCE(p_ends_at, b.ends_at),
308
+ notes = COALESCE(p_summary, b.notes),
309
+ metadata = COALESCE(b.metadata, '{}'::jsonb)
310
+ || jsonb_build_object('syncOrigin', 'google',
311
+ 'gcalEtag', p_etag,
312
+ 'title', COALESCE(p_summary, b.metadata->>'title')),
313
+ updated_at = now()
314
+ WHERE b.id = v_appt;
315
+ END IF;
316
+
317
+ RETURN v_appt;
318
+ END $$;
319
+
320
+ -- gcal_stamp_outbound: after the edge function creates the Google event for an
321
+ -- outbound push, it stamps the fresh event id/etag back onto the appointment.
322
+ -- Bookkeeping only — must not re-fire §2's trigger, hence the GUC. Does NOT set
323
+ -- metadata.syncOrigin (the booking's provenance is still the app/site).
324
+ CREATE OR REPLACE FUNCTION public.gcal_stamp_outbound(
325
+ p_tenant_id uuid,
326
+ p_booking_id uuid,
327
+ p_event_id text,
328
+ p_etag text DEFAULT NULL,
329
+ p_calendar_id text DEFAULT NULL
330
+ ) RETURNS uuid
331
+ LANGUAGE plpgsql SECURITY DEFINER
332
+ SET search_path = public
333
+ AS $$
334
+ DECLARE
335
+ v_id uuid;
336
+ BEGIN
337
+ IF p_tenant_id IS NULL OR p_booking_id IS NULL OR p_event_id IS NULL THEN
338
+ RAISE EXCEPTION 'gcal_stamp_outbound: tenant, booking and event id are required';
339
+ END IF;
340
+
341
+ -- Transaction-local: §2's trigger skips the echo for this write only.
342
+ PERFORM set_config('app.booking_origin', 'google', true);
343
+
344
+ UPDATE appointments b
345
+ SET metadata = COALESCE(b.metadata, '{}'::jsonb)
346
+ || jsonb_build_object('googleCalendarEventId', p_event_id,
347
+ 'gcalEtag', p_etag,
348
+ 'gcalSyncedAt', now())
349
+ || CASE WHEN p_calendar_id IS NULL THEN '{}'::jsonb
350
+ ELSE jsonb_build_object('gcalCalendarId', p_calendar_id) END,
351
+ updated_at = now()
352
+ WHERE b.id = p_booking_id AND b.tenant_id = p_tenant_id
353
+ RETURNING b.id INTO v_id;
354
+ RETURN v_id;
355
+ END $$;
356
+
357
+ -- gcal_unlink_channel: forget the incremental cursor + Google watch bookkeeping
358
+ -- for a channel (used on disconnect / re-watch). Leaves the mapping row in place.
359
+ CREATE OR REPLACE FUNCTION public.gcal_unlink_channel(
360
+ p_channel_id uuid
361
+ ) RETURNS uuid
362
+ LANGUAGE plpgsql SECURITY DEFINER
363
+ SET search_path = public
364
+ AS $$
365
+ DECLARE
366
+ v_id uuid;
367
+ BEGIN
368
+ UPDATE plg_calendar_channels c
369
+ SET sync_token = NULL,
370
+ channel_id = NULL,
371
+ resource_id = NULL,
372
+ channel_expires_at = NULL,
373
+ updated_at = now()
374
+ WHERE c.id = p_channel_id
375
+ RETURNING c.id INTO v_id;
376
+ RETURN v_id;
377
+ END $$;
378
+
379
+ -- Lock the inbound writers to service_role only.
380
+ DO $$
381
+ DECLARE fn text;
382
+ BEGIN
383
+ FOR fn IN SELECT unnest(ARRAY[
384
+ 'public.gcal_import_event(uuid, uuid, text, text, text, timestamptz, timestamptz, boolean, text)',
385
+ 'public.gcal_apply_event_patch(uuid, text, text, timestamptz, timestamptz, text, boolean)',
386
+ 'public.gcal_stamp_outbound(uuid, uuid, text, text, text)',
387
+ 'public.gcal_unlink_channel(uuid)'
388
+ ])
389
+ LOOP
390
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM public, anon, authenticated', fn);
391
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO service_role', fn);
392
+ END LOOP;
393
+ END $$;
394
+
395
+ -- ===========================================================================
396
+ -- §5 — get_available_slots: imported Google blocks must remove availability.
397
+ -- CREATE OR REPLACE keeping the EXACT signature (anon + admin both call it, and
398
+ -- it is graded on the same shape as plugin-agenda/src/migrations/001_public_booking.sql).
399
+ --
400
+ -- Conceptual diff vs 001 — ONE condition in the overlap NOT EXISTS:
401
+ -- before: AND b.assignee_id = c.assignee_id
402
+ -- after: AND (b.assignee_id = c.assignee_id
403
+ -- OR (b.assignee_id IS NULL AND b.kind = 'block'))
404
+ -- Rationale: a tenant-wide BLOCK imported from Google (target_kind NULL →
405
+ -- assignee_id IS NULL, kind 'block', see §4) must busy-out the slot for EVERY
406
+ -- professional. Restricted to kind='block' so an unassigned admin appointment
407
+ -- keeps the 001 behaviour (blocks nobody). Per-assignee rows keep matching on
408
+ -- assignee_id.
409
+ -- Everything else is byte-identical to 001: the status filter already covers
410
+ -- "any non-cancelled kind" (no_show also excluded), and the p_assignee_id-NULL
411
+ -- fan-out across staff via day_schedules is preserved untouched.
412
+ -- ===========================================================================
413
+ CREATE OR REPLACE FUNCTION public.get_available_slots(
414
+ p_tenant_id uuid,
415
+ p_date date,
416
+ p_duration_minutes integer,
417
+ p_assignee_id uuid DEFAULT NULL,
418
+ p_slot_interval integer DEFAULT 30
419
+ ) RETURNS TABLE (slot_start timestamptz, slot_end timestamptz)
420
+ LANGUAGE plpgsql STABLE SECURITY DEFINER
421
+ SET search_path = public
422
+ AS $$
423
+ DECLARE
424
+ v_tz text;
425
+ BEGIN
426
+ -- tight validation: this function is anon-callable
427
+ IF p_duration_minutes IS NULL OR p_duration_minutes NOT BETWEEN 5 AND 480 THEN
428
+ RAISE EXCEPTION 'invalid duration';
429
+ END IF;
430
+ IF p_slot_interval IS NULL OR p_slot_interval NOT BETWEEN 5 AND 240 THEN
431
+ RAISE EXCEPTION 'invalid interval';
432
+ END IF;
433
+ IF p_date IS NULL OR p_date < current_date OR p_date > current_date + 90 THEN
434
+ RETURN;
435
+ END IF;
436
+
437
+ SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
438
+ FROM tenants t WHERE t.id = p_tenant_id;
439
+ IF v_tz IS NULL THEN RETURN; END IF; -- unknown tenant → empty, not an error
440
+
441
+ RETURN QUERY
442
+ WITH day_schedules AS (
443
+ SELECT sc.assignee_id, sc.starts_at AS win_start, sc.ends_at AS win_end
444
+ FROM schedules sc
445
+ WHERE sc.tenant_id = p_tenant_id
446
+ AND sc.kind = 'working_hours'
447
+ AND sc.is_active
448
+ AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)
449
+ AND (
450
+ sc.specific_date = p_date
451
+ OR (sc.specific_date IS NULL AND sc.day_of_week = EXTRACT(dow FROM p_date)::smallint)
452
+ )
453
+ ),
454
+ candidate AS (
455
+ SELECT
456
+ ds.assignee_id,
457
+ gs AS s_start,
458
+ gs + make_interval(mins => p_duration_minutes) AS s_end
459
+ FROM day_schedules ds
460
+ CROSS JOIN LATERAL generate_series(
461
+ (p_date + ds.win_start) AT TIME ZONE v_tz,
462
+ ((p_date + ds.win_end) AT TIME ZONE v_tz) - make_interval(mins => p_duration_minutes),
463
+ make_interval(mins => p_slot_interval)
464
+ ) AS gs
465
+ )
466
+ SELECT DISTINCT c.s_start, c.s_end
467
+ FROM candidate c
468
+ WHERE NOT EXISTS (
469
+ SELECT 1 FROM appointments b
470
+ WHERE b.tenant_id = p_tenant_id
471
+ -- SMART SYNC: match this professional's own bookings OR any tenant-wide
472
+ -- BLOCK (assignee_id IS NULL AND kind='block', e.g. imported from Google
473
+ -- with a whole-tenant channel) — the latter busies the slot for every
474
+ -- professional. Assignee-less rows of other kinds (an unassigned admin
475
+ -- appointment) keep the 001 behaviour and do not block anyone.
476
+ AND (b.assignee_id = c.assignee_id
477
+ OR (b.assignee_id IS NULL AND b.kind = 'block'))
478
+ AND b.status NOT IN ('cancelled', 'no_show')
479
+ AND b.starts_at < c.s_end
480
+ AND COALESCE(b.ends_at, b.starts_at) > c.s_start
481
+ )
482
+ ORDER BY 1;
483
+ END;
484
+ $$;
485
+
486
+ REVOKE ALL ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer) FROM public;
487
+ GRANT EXECUTE ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer)
488
+ TO anon, authenticated, service_role;
489
+
490
+ -- ===========================================================================
491
+ -- §6 — Inbound schedule (optional) — per-channel incremental pull.
492
+ -- Supersedes the single-calendar template in 001. Enable once per project after
493
+ -- setting app.gcal_sync_url / app.gcal_service_key (see 001 header). The edge
494
+ -- function fans out over active channels, using each channel's sync_token cursor:
495
+ --
496
+ -- SELECT cron.schedule('gcal-pull', '*/10 * * * *', $CRON$
497
+ -- SELECT net.http_post(
498
+ -- url := current_setting('app.gcal_sync_url'),
499
+ -- headers := jsonb_build_object(
500
+ -- 'Content-Type','application/json',
501
+ -- 'Authorization','Bearer '||current_setting('app.gcal_service_key')),
502
+ -- body := jsonb_build_object('action','pull_events'))
503
+ -- $CRON$);
504
+ --
505
+ -- Or drive one row per channel from SQL (tighter scoping / per-channel cadence):
506
+ --
507
+ -- SELECT cron.schedule('gcal-pull-'||ch.id::text, '*/10 * * * *', format($CRON$
508
+ -- SELECT net.http_post(
509
+ -- url := current_setting('app.gcal_sync_url'),
510
+ -- headers := jsonb_build_object(
511
+ -- 'Content-Type','application/json',
512
+ -- 'Authorization','Bearer '||current_setting('app.gcal_service_key')),
513
+ -- body := jsonb_build_object('action','pull_events','channelId',%L))
514
+ -- $CRON$, ch.id))
515
+ -- FROM public.plg_calendar_channels ch
516
+ -- WHERE ch.is_active AND ch.direction IN ('inbound','bidirectional');
517
+ -- ===========================================================================