@fayz-ai/plugin-agenda 0.9.0 → 0.9.1

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 (153) hide show
  1. package/README.md +3 -3
  2. package/dist/bridges/create-financial-bridge.d.ts +1 -1
  3. package/dist/bridges/create-financial-bridge.d.ts.map +1 -1
  4. package/dist/bridges/financial-bridge.d.ts.map +1 -0
  5. package/dist/{chunk-Q2PAWBPR.js → chunk-76ZOHRBM.js} +2 -2
  6. package/dist/{chunk-Q2PAWBPR.js.map → chunk-76ZOHRBM.js.map} +1 -1
  7. package/dist/components/PaymentStatusBadge.d.ts +1 -1
  8. package/dist/components/PaymentStatusBadge.d.ts.map +1 -1
  9. package/dist/config.d.ts +2 -2
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/{AgendaContext.d.ts → context.d.ts} +1 -1
  12. package/dist/context.d.ts.map +1 -0
  13. package/dist/data/registries.d.ts.map +1 -0
  14. package/dist/index.d.ts +2 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +170 -7
  17. package/dist/index.js.map +1 -1
  18. package/dist/lib/message-events.d.ts +12 -0
  19. package/dist/lib/message-events.d.ts.map +1 -0
  20. package/dist/public/{data.d.ts → data/index.d.ts} +4 -4
  21. package/dist/public/data/index.d.ts.map +1 -0
  22. package/dist/public/{data.supabase.d.ts → data/supabase.d.ts} +2 -2
  23. package/dist/public/data/supabase.d.ts.map +1 -0
  24. package/dist/public/{hooks.d.ts → hooks/index.d.ts} +4 -4
  25. package/dist/public/hooks/index.d.ts.map +1 -0
  26. package/dist/public/index.d.ts +4 -4
  27. package/dist/public/index.d.ts.map +1 -1
  28. package/dist/public/index.js +3 -3
  29. package/dist/public/index.js.map +1 -1
  30. package/dist/public/{format.d.ts → lib/format.d.ts} +1 -1
  31. package/dist/public/lib/format.d.ts.map +1 -0
  32. package/dist/public/views/BookingWidget.d.ts.map +1 -0
  33. package/dist/public/{steps → views/steps}/ContactStep.d.ts +1 -1
  34. package/dist/public/views/steps/ContactStep.d.ts.map +1 -0
  35. package/dist/public/{steps → views/steps}/DateTimeStep.d.ts +3 -3
  36. package/dist/public/views/steps/DateTimeStep.d.ts.map +1 -0
  37. package/dist/public/{steps → views/steps}/OverviewStep.d.ts +1 -1
  38. package/dist/public/views/steps/OverviewStep.d.ts.map +1 -0
  39. package/dist/public/{steps → views/steps}/PaymentStep.d.ts +1 -1
  40. package/dist/public/views/steps/PaymentStep.d.ts.map +1 -0
  41. package/dist/store.d.ts +1 -1
  42. package/dist/store.d.ts.map +1 -1
  43. package/dist/styles/calendar-styles.d.ts.map +1 -0
  44. package/dist/types.d.ts +1 -1
  45. package/dist/types.d.ts.map +1 -1
  46. package/dist/{AgendaPage.d.ts → views/AgendaPage.d.ts} +3 -3
  47. package/dist/views/AgendaPage.d.ts.map +1 -0
  48. package/package.json +6 -9
  49. package/dist/AgendaContext.d.ts.map +0 -1
  50. package/dist/AgendaPage.d.ts.map +0 -1
  51. package/dist/calendar-styles.d.ts.map +0 -1
  52. package/dist/financial-bridge.d.ts.map +0 -1
  53. package/dist/public/BookingWidget.d.ts.map +0 -1
  54. package/dist/public/data.d.ts.map +0 -1
  55. package/dist/public/data.supabase.d.ts.map +0 -1
  56. package/dist/public/format.d.ts.map +0 -1
  57. package/dist/public/hooks.d.ts.map +0 -1
  58. package/dist/public/steps/ContactStep.d.ts.map +0 -1
  59. package/dist/public/steps/DateTimeStep.d.ts.map +0 -1
  60. package/dist/public/steps/OverviewStep.d.ts.map +0 -1
  61. package/dist/public/steps/PaymentStep.d.ts.map +0 -1
  62. package/dist/registries.d.ts.map +0 -1
  63. package/src/AgendaContext.tsx +0 -11
  64. package/src/AgendaPage.tsx +0 -33
  65. package/src/bridges/create-financial-bridge.ts +0 -296
  66. package/src/calendar-styles.css +0 -245
  67. package/src/calendar-styles.ts +0 -285
  68. package/src/components/AgendaGeneralSettings.tsx +0 -86
  69. package/src/components/AgendaMobileList.tsx +0 -103
  70. package/src/components/AppointmentModal.tsx +0 -1050
  71. package/src/components/AppointmentPopover.tsx +0 -231
  72. package/src/components/BookingPaymentPanel.tsx +0 -394
  73. package/src/components/CalendarSidebar.tsx +0 -119
  74. package/src/components/CalendarToolbar.tsx +0 -86
  75. package/src/components/EventContextMenu.tsx +0 -125
  76. package/src/components/FloatingPanel.tsx +0 -123
  77. package/src/components/MiniCalendar.tsx +0 -120
  78. package/src/components/PaymentStatusBadge.tsx +0 -62
  79. package/src/components/StatusBadge.tsx +0 -23
  80. package/src/config.ts +0 -368
  81. package/src/data/index.ts +0 -4
  82. package/src/data/mock.ts +0 -344
  83. package/src/data/supabase.ts +0 -636
  84. package/src/data/types.ts +0 -37
  85. package/src/financial-bridge.ts +0 -96
  86. package/src/hooks/useAgendaSettings.ts +0 -17
  87. package/src/index.ts +0 -297
  88. package/src/integrations/google-calendar/GoogleCalendarSettings.tsx +0 -136
  89. package/src/integrations/google-calendar/components/CalendarColorDot.tsx +0 -23
  90. package/src/integrations/google-calendar/components/ChannelMappingList.tsx +0 -329
  91. package/src/integrations/google-calendar/components/ImportEventsWizard.tsx +0 -215
  92. package/src/integrations/google-calendar/components/useAgendaLookups.ts +0 -100
  93. package/src/integrations/google-calendar/connector.ts +0 -20
  94. package/src/integrations/google-calendar/connectorDef.tsx +0 -185
  95. package/src/integrations/google-calendar/data/supabase.ts +0 -170
  96. package/src/integrations/google-calendar/data/tables.ts +0 -12
  97. package/src/integrations/google-calendar/index.ts +0 -45
  98. package/src/integrations/google-calendar/integration.test.ts +0 -199
  99. package/src/integrations/google-calendar/mapping.ts +0 -183
  100. package/src/integrations/google-calendar/migrations/001_google_calendar.sql +0 -97
  101. package/src/integrations/google-calendar/migrations/002_smart_sync.sql +0 -517
  102. package/src/integrations/google-calendar/migrations/003_plg_rename.sql +0 -273
  103. package/src/integrations/google-calendar/types.ts +0 -142
  104. package/src/lib/tenant.ts +0 -3
  105. package/src/locales/en.ts +0 -220
  106. package/src/locales/index.ts +0 -7
  107. package/src/locales/pt-BR.ts +0 -220
  108. package/src/migrations/000b_v_bookings_rename.sql +0 -21
  109. package/src/migrations/000c_dedupe_customer_phones.sql +0 -43
  110. package/src/migrations/001_public_booking.sql +0 -296
  111. package/src/migrations/002_v_bookings_compat.sql +0 -14
  112. package/src/migrations/003_public_client_kind.sql +0 -175
  113. package/src/migrations/004_public_booking_assignee.sql +0 -176
  114. package/src/migrations/005_agent_rpcs.sql +0 -168
  115. package/src/migrations/006_agent_rpc_localtime.sql +0 -181
  116. package/src/migrations/007_schedule_exception_precedence.sql +0 -290
  117. package/src/migrations/008_agent_rpc_exception_precedence.sql +0 -172
  118. package/src/migrations/009_public_booking_location.sql +0 -162
  119. package/src/migrations/010_public_service_curation.sql +0 -31
  120. package/src/migrations/011_default_assignee.sql +0 -167
  121. package/src/migrations/012_drop_legacy_booking_overload.sql +0 -29
  122. package/src/migrations/013_normalize_v_appointments.sql +0 -199
  123. package/src/migrations/014_agent_rpc_update_appointment.sql +0 -199
  124. package/src/migrations/index.ts +0 -2377
  125. package/src/public/BookingWidget.tsx +0 -324
  126. package/src/public/components/BrandHeader.tsx +0 -27
  127. package/src/public/components/ServiceCard.tsx +0 -39
  128. package/src/public/components/SidebarExtras.tsx +0 -30
  129. package/src/public/components/StepRailItem.tsx +0 -27
  130. package/src/public/components/SuccessPanel.tsx +0 -27
  131. package/src/public/components/SummaryRail.tsx +0 -90
  132. package/src/public/components/defaults.ts +0 -14
  133. package/src/public/context.tsx +0 -103
  134. package/src/public/createPublicBookingPlugin.ts +0 -281
  135. package/src/public/data.supabase.ts +0 -97
  136. package/src/public/data.ts +0 -105
  137. package/src/public/format.ts +0 -96
  138. package/src/public/hooks.ts +0 -110
  139. package/src/public/index.ts +0 -69
  140. package/src/public/steps/ContactStep.tsx +0 -184
  141. package/src/public/steps/DateTimeStep.tsx +0 -138
  142. package/src/public/steps/OverviewStep.tsx +0 -81
  143. package/src/public/steps/PaymentStep.tsx +0 -180
  144. package/src/public/types.ts +0 -250
  145. package/src/registries.ts +0 -35
  146. package/src/store.ts +0 -311
  147. package/src/types.ts +0 -273
  148. package/src/views/CalendarView.tsx +0 -1112
  149. package/src/views/ConfirmationsView.tsx +0 -155
  150. /package/dist/{financial-bridge.d.ts → bridges/financial-bridge.d.ts} +0 -0
  151. /package/dist/{registries.d.ts → data/registries.d.ts} +0 -0
  152. /package/dist/public/{BookingWidget.d.ts → views/BookingWidget.d.ts} +0 -0
  153. /package/dist/{calendar-styles.d.ts → styles/calendar-styles.d.ts} +0 -0
@@ -1,2377 +0,0 @@
1
- // AUTO-GENERATED from 000b_v_bookings_rename.sql, 000c_dedupe_customer_phones.sql, 001_public_booking.sql, 002_v_bookings_compat.sql, 003_public_client_kind.sql, 004_public_booking_assignee.sql, 005_agent_rpcs.sql, 006_agent_rpc_localtime.sql, 007_schedule_exception_precedence.sql, 008_agent_rpc_exception_precedence.sql, 009_public_booking_location.sql, 010_public_service_curation.sql, 011_default_assignee.sql, 012_drop_legacy_booking_overload.sql, 013_normalize_v_appointments.sql, 014_agent_rpc_update_appointment.sql — regenerate with scripts/embed-migrations.mjs
2
- // SQL files are the source of truth; this inline copy lets the manifest declare
3
- // migrations as data. Do not edit by hand — run the embed script instead.
4
-
5
- export const MIGRATION_000B_V_BOOKINGS_RENAME = `-- Salon-only unblock: the pre-pools beauty deployment built rep_* report
6
- -- views on top of its (wider) v_bookings view, so 001's cleanup DROP cannot
7
- -- run there. Renaming keeps the dependents attached (they track the OID) and
8
- -- frees the name for 001; 002_v_bookings_compat re-creates v_bookings as a
9
- -- thin alias for live pre-M5 clients. No-op on fresh pools and on pools whose
10
- -- v_bookings has no dependents (001's DROP handles those).
11
-
12
- DO $rename$
13
- BEGIN
14
- IF to_regclass('public.v_bookings') IS NOT NULL
15
- AND to_regclass('public.v_bookings_legacy_beauty') IS NULL
16
- AND EXISTS (
17
- SELECT 1 FROM pg_depend d
18
- JOIN pg_rewrite rw ON rw.oid = d.objid
19
- JOIN pg_class dep ON dep.oid = rw.ev_class
20
- JOIN pg_class ref ON ref.oid = d.refobjid
21
- WHERE ref.relname = 'v_bookings' AND dep.relname <> 'v_bookings'
22
- ) THEN
23
- EXECUTE 'ALTER VIEW public.v_bookings RENAME TO v_bookings_legacy_beauty';
24
- END IF;
25
- END $rename$;
26
- `
27
-
28
- export const MIGRATION_000C_DEDUPE_CUSTOMER_PHONES = `-- 001 needs the customer-phone upsert key UNIQUE(tenant_id, phone). Converted
29
- -- pools can carry duplicates (found live on salon: 9 dogfood test records all
30
- -- sharing the founder's phone). Non-destructive dedupe: per (tenant_id, phone)
31
- -- customer group keep the row with most appointments (tie: oldest), NULL the
32
- -- phone on the rest and stash the original in metadata->'dedupedPhone'.
33
- -- Idempotent: reruns find no groups > 1.
34
-
35
- DO $dedupe$
36
- DECLARE
37
- r record;
38
- BEGIN
39
- IF to_regclass('public.people') IS NULL THEN
40
- RETURN;
41
- END IF;
42
-
43
- FOR r IN
44
- SELECT p.id, p.phone
45
- FROM public.people p
46
- JOIN LATERAL (
47
- SELECT count(*) AS appts
48
- FROM public.appointments a WHERE a.party_id = p.id
49
- ) ac ON true
50
- WHERE p.kind = 'customer' AND p.phone IS NOT NULL
51
- AND p.id NOT IN (
52
- SELECT DISTINCT ON (p2.tenant_id, p2.phone) p2.id
53
- FROM public.people p2
54
- WHERE p2.kind = 'customer' AND p2.phone IS NOT NULL
55
- ORDER BY p2.tenant_id, p2.phone,
56
- (SELECT count(*) FROM public.appointments a2 WHERE a2.party_id = p2.id) DESC,
57
- p2.created_at ASC
58
- )
59
- AND (p.tenant_id, p.phone) IN (
60
- SELECT tenant_id, phone FROM public.people
61
- WHERE kind = 'customer' AND phone IS NOT NULL
62
- GROUP BY tenant_id, phone HAVING count(*) > 1
63
- )
64
- LOOP
65
- UPDATE public.people
66
- SET metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object('dedupedPhone', r.phone),
67
- phone = NULL
68
- WHERE id = r.id;
69
- END LOOP;
70
- END $dedupe$;
71
- `
72
-
73
- export const MIGRATION_001_PUBLIC_BOOKING = `-- ============================================================================
74
- -- plugin-agenda 001: booking read model + public (anon) booking surface
75
- --
76
- -- Ships the objects the agenda providers reference but the spine (packages/db
77
- -- migrations 001–010) does not define:
78
- -- §0 converted-pool cleanup — drop the pre-rename v_bookings view / indexes
79
- -- §1 order_items columns the admin provider writes (duration/assignee)
80
- -- §2 public.v_appointments — admin read model (authenticated, RLS applies)
81
- -- §3 public.v_public_services — anon-readable service catalog
82
- -- §4 public.get_available_slots — anon RPC (mirrors the mock algorithm)
83
- -- §5 public.create_public_booking — anon RPC (validated, race-safe write)
84
- --
85
- -- Core v1 lives directly in PUBLIC (no separate core schema): people (was
86
- -- persons), appointments (was bookings). This migration is written against public
87
- -- names and is idempotent + safe on both fresh and converted pools.
88
- --
89
- -- Anon-access design: public booking pages run with the publishable key and no
90
- -- session, so the canonical tenant RLS yields nothing. Anon access is granted
91
- -- ONLY via the column-whitelisted view (§3) and SECURITY DEFINER RPCs (§4, §5)
92
- -- with tight input validation and hardcoded statuses.
93
- -- ============================================================================
94
-
95
- -- §0 — converted-pool cleanup. The pre-industry-pool shape shipped a v_bookings
96
- -- view and persons_/bookings_-named indexes; drop them so this file re-emits the
97
- -- canonical v_appointments view + people_/appointments_ indexes without leaving
98
- -- duplicates. All guarded with IF EXISTS → no-ops on a fresh pool.
99
- DROP VIEW IF EXISTS public.v_bookings;
100
- DROP INDEX IF EXISTS public.persons_tenant_customer_phone;
101
- DROP INDEX IF EXISTS public.bookings_assignee_time;
102
-
103
- -- §1 — columns the agenda provider writes but 004_archetypes lacks
104
- ALTER TABLE public.order_items
105
- ADD COLUMN IF NOT EXISTS duration_minutes integer,
106
- ADD COLUMN IF NOT EXISTS assignee_id uuid REFERENCES public.people(id) ON DELETE SET NULL;
107
-
108
- -- Upsert key for public customers (per tenant, by normalized phone)
109
- CREATE UNIQUE INDEX IF NOT EXISTS people_tenant_customer_phone
110
- ON public.people(tenant_id, phone)
111
- WHERE kind = 'customer' AND phone IS NOT NULL;
112
-
113
- -- Slot-conflict scan
114
- CREATE INDEX IF NOT EXISTS appointments_assignee_time
115
- ON public.appointments(assignee_id, starts_at, ends_at)
116
- WHERE status NOT IN ('cancelled', 'no_show');
117
-
118
- -- §2 — admin read model (security_invoker → underlying RLS applies; anon sees 0 rows)
119
- -- DROP first: 013 widens this view and CREATE OR REPLACE cannot narrow a live
120
- -- view on replay ("cannot drop columns from view"). 013 replays right after.
121
- DROP VIEW IF EXISTS public.v_appointments;
122
- CREATE VIEW public.v_appointments
123
- WITH (security_invoker = true) AS
124
- SELECT
125
- b.id, b.tenant_id, b.kind, b.starts_at, b.ends_at, b.status, b.notes,
126
- b.order_id, b.location_id, b.metadata, b.created_at, b.updated_at,
127
- b.party_id AS client_id,
128
- COALESCE(pc.name, b.metadata->>'title') AS client_name,
129
- pc.phone AS client_phone,
130
- pc.email AS client_email,
131
- pc.avatar_url AS client_avatar_url,
132
- b.assignee_id AS professional_id,
133
- ps.name AS professional_name,
134
- ps.avatar_url AS professional_avatar_url,
135
- l.name AS location_name,
136
- o.total AS order_total,
137
- o.status AS order_status,
138
- (EXTRACT(EPOCH FROM (COALESCE(b.ends_at, b.starts_at) - b.starts_at))::int / 60)
139
- AS total_duration_minutes
140
- FROM public.appointments b
141
- LEFT JOIN public.people pc ON pc.id = b.party_id
142
- LEFT JOIN public.people ps ON ps.id = b.assignee_id
143
- LEFT JOIN public.locations l ON l.id = b.location_id
144
- LEFT JOIN public.orders o ON o.id = b.order_id;
145
-
146
- GRANT SELECT ON public.v_appointments TO authenticated, service_role;
147
- REVOKE SELECT ON public.v_appointments FROM anon;
148
-
149
- -- §3 — anon-readable public catalog (owner-rights view = deliberate, whitelisted)
150
- -- DROP first: 010 widens this view (curation columns) — same replay rule as §2.
151
- DROP VIEW IF EXISTS public.v_public_services;
152
- CREATE VIEW public.v_public_services AS
153
- SELECT
154
- s.id, s.tenant_id, s.name, s.description, s.price, s.currency,
155
- COALESCE(s.duration_minutes, 30) AS duration_minutes,
156
- s.image_url,
157
- COALESCE((s.metadata->>'sort_order')::int, 0) AS sort_order
158
- FROM public.services s
159
- WHERE s.is_active AND s.status = 'active';
160
-
161
- GRANT SELECT ON public.v_public_services TO anon, authenticated, service_role;
162
-
163
- -- §4 — availability RPC (mirrors plugin-agenda/src/data/mock.ts getAvailableSlots)
164
- CREATE OR REPLACE FUNCTION public.get_available_slots(
165
- p_tenant_id uuid,
166
- p_date date,
167
- p_duration_minutes integer,
168
- p_assignee_id uuid DEFAULT NULL,
169
- p_slot_interval integer DEFAULT 30
170
- ) RETURNS TABLE (slot_start timestamptz, slot_end timestamptz)
171
- LANGUAGE plpgsql STABLE SECURITY DEFINER
172
- SET search_path = public
173
- AS $$
174
- DECLARE
175
- v_tz text;
176
- BEGIN
177
- -- tight validation: this function is anon-callable
178
- IF p_duration_minutes IS NULL OR p_duration_minutes NOT BETWEEN 5 AND 480 THEN
179
- RAISE EXCEPTION 'invalid duration';
180
- END IF;
181
- IF p_slot_interval IS NULL OR p_slot_interval NOT BETWEEN 5 AND 240 THEN
182
- RAISE EXCEPTION 'invalid interval';
183
- END IF;
184
- IF p_date IS NULL OR p_date < current_date OR p_date > current_date + 90 THEN
185
- RETURN;
186
- END IF;
187
-
188
- SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
189
- FROM tenants t WHERE t.id = p_tenant_id;
190
- IF v_tz IS NULL THEN RETURN; END IF; -- unknown tenant → empty, not an error
191
-
192
- RETURN QUERY
193
- WITH day_schedules AS (
194
- SELECT sc.assignee_id, sc.starts_at AS win_start, sc.ends_at AS win_end
195
- FROM schedules sc
196
- WHERE sc.tenant_id = p_tenant_id
197
- AND sc.kind = 'working_hours'
198
- AND sc.is_active
199
- AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)
200
- AND (
201
- sc.specific_date = p_date
202
- OR (sc.specific_date IS NULL AND sc.day_of_week = EXTRACT(dow FROM p_date)::smallint)
203
- )
204
- ),
205
- candidate AS (
206
- SELECT
207
- ds.assignee_id,
208
- gs AS s_start,
209
- gs + make_interval(mins => p_duration_minutes) AS s_end
210
- FROM day_schedules ds
211
- CROSS JOIN LATERAL generate_series(
212
- (p_date + ds.win_start) AT TIME ZONE v_tz,
213
- ((p_date + ds.win_end) AT TIME ZONE v_tz) - make_interval(mins => p_duration_minutes),
214
- make_interval(mins => p_slot_interval)
215
- ) AS gs
216
- )
217
- SELECT DISTINCT c.s_start, c.s_end
218
- FROM candidate c
219
- WHERE NOT EXISTS (
220
- SELECT 1 FROM appointments b
221
- WHERE b.tenant_id = p_tenant_id
222
- AND b.assignee_id = c.assignee_id
223
- AND b.status NOT IN ('cancelled', 'no_show')
224
- AND b.starts_at < c.s_end
225
- AND COALESCE(b.ends_at, b.starts_at) > c.s_start
226
- )
227
- ORDER BY 1;
228
- END;
229
- $$;
230
-
231
- REVOKE ALL ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer) FROM public;
232
- GRANT EXECUTE ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer)
233
- TO anon, authenticated, service_role;
234
-
235
- -- §5 — public booking write RPC (validated, race-safe, hardcoded statuses)
236
- CREATE OR REPLACE FUNCTION public.create_public_booking(
237
- p_tenant_id uuid,
238
- p_service_id uuid,
239
- p_starts_at timestamptz,
240
- p_name text,
241
- p_phone text,
242
- p_email text DEFAULT NULL,
243
- p_notes text DEFAULT NULL
244
- ) RETURNS TABLE (booking_id uuid, starts_at timestamptz, ends_at timestamptz)
245
- LANGUAGE plpgsql SECURITY DEFINER
246
- SET search_path = public
247
- AS $$
248
- DECLARE
249
- v_service record;
250
- v_person_id uuid;
251
- v_assignee uuid;
252
- v_ends timestamptz;
253
- v_order uuid;
254
- v_booking uuid;
255
- v_phone text;
256
- v_tz text;
257
- v_notes text;
258
- BEGIN
259
- -- validation: anon-callable, everything tight
260
- IF p_name IS NULL OR length(trim(p_name)) NOT BETWEEN 2 AND 120 THEN
261
- RAISE EXCEPTION 'invalid name';
262
- END IF;
263
- v_phone := regexp_replace(COALESCE(p_phone, ''), '\\D', '', 'g');
264
- IF length(v_phone) NOT BETWEEN 8 AND 15 THEN
265
- RAISE EXCEPTION 'invalid phone';
266
- END IF;
267
- IF p_email IS NOT NULL AND (length(p_email) > 254 OR p_email !~ '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$') THEN
268
- RAISE EXCEPTION 'invalid email';
269
- END IF;
270
- v_notes := left(p_notes, 500);
271
- IF p_starts_at IS NULL OR p_starts_at <= now() OR p_starts_at > now() + interval '90 days' THEN
272
- RAISE EXCEPTION 'invalid start';
273
- END IF;
274
-
275
- SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
276
- INTO v_service
277
- FROM services s
278
- WHERE s.id = p_service_id AND s.tenant_id = p_tenant_id
279
- AND s.is_active AND s.status = 'active';
280
- IF NOT FOUND THEN RAISE EXCEPTION 'unknown service'; END IF;
281
-
282
- v_ends := p_starts_at + make_interval(mins => v_service.duration_minutes);
283
- SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
284
- FROM tenants t WHERE t.id = p_tenant_id;
285
- IF v_tz IS NULL THEN RAISE EXCEPTION 'unknown tenant'; END IF;
286
-
287
- -- pick the assignee whose working hours cover the slot
288
- SELECT sc.assignee_id INTO v_assignee
289
- FROM schedules sc
290
- WHERE sc.tenant_id = p_tenant_id
291
- AND sc.kind = 'working_hours'
292
- AND sc.is_active
293
- AND (
294
- sc.specific_date = (p_starts_at AT TIME ZONE v_tz)::date
295
- OR (sc.specific_date IS NULL
296
- AND sc.day_of_week = EXTRACT(dow FROM p_starts_at AT TIME ZONE v_tz)::smallint)
297
- )
298
- AND (p_starts_at AT TIME ZONE v_tz)::time >= sc.starts_at
299
- AND (v_ends AT TIME ZONE v_tz)::time <= sc.ends_at
300
- ORDER BY sc.assignee_id
301
- LIMIT 1;
302
- IF v_assignee IS NULL THEN RAISE EXCEPTION 'slot outside working hours'; END IF;
303
-
304
- -- serialize per (tenant, assignee) to kill the double-book race
305
- PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_assignee::text, 0));
306
-
307
- IF EXISTS (
308
- SELECT 1 FROM appointments b
309
- WHERE b.tenant_id = p_tenant_id
310
- AND b.assignee_id = v_assignee
311
- AND b.status NOT IN ('cancelled', 'no_show')
312
- AND b.starts_at < v_ends
313
- AND COALESCE(b.ends_at, b.starts_at) > p_starts_at
314
- ) THEN
315
- RAISE EXCEPTION 'slot no longer available';
316
- END IF;
317
-
318
- -- anti-spam: cap open future public bookings per phone per tenant
319
- IF (
320
- SELECT count(*) FROM appointments b
321
- JOIN people p ON p.id = b.party_id
322
- WHERE b.tenant_id = p_tenant_id
323
- AND p.phone = v_phone
324
- AND b.starts_at > now()
325
- AND b.status = 'scheduled'
326
- ) >= 3 THEN
327
- RAISE EXCEPTION 'too many open bookings for this phone';
328
- END IF;
329
-
330
- -- upsert the customer by (tenant, phone) — matches people_tenant_customer_phone
331
- INSERT INTO people (tenant_id, kind, name, phone, email, metadata)
332
- VALUES (p_tenant_id, 'customer', trim(p_name), v_phone, p_email,
333
- jsonb_build_object('source', 'public_booking'))
334
- ON CONFLICT (tenant_id, phone) WHERE kind = 'customer' AND phone IS NOT NULL
335
- DO UPDATE SET
336
- name = EXCLUDED.name,
337
- email = COALESCE(EXCLUDED.email, people.email),
338
- updated_at = now()
339
- RETURNING id INTO v_person_id;
340
-
341
- -- order + booking + order_item (statuses hardcoded — caller cannot set)
342
- INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
343
- VALUES (p_tenant_id, 'appointment', 'scheduled', v_person_id, v_assignee,
344
- v_service.price, v_service.price, v_notes,
345
- jsonb_build_object('source', 'public_booking',
346
- 'serviceNames', v_service.name,
347
- 'contactName', trim(p_name)))
348
- RETURNING id INTO v_order;
349
-
350
- INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
351
- starts_at, ends_at, status, notes, metadata)
352
- VALUES (p_tenant_id, 'appointment', v_person_id, v_assignee, v_order,
353
- p_starts_at, v_ends, 'scheduled', v_notes,
354
- jsonb_build_object('source', 'public_booking', 'serviceNames', v_service.name))
355
- RETURNING id INTO v_booking;
356
-
357
- INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
358
- sort_order, duration_minutes, assignee_id)
359
- VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
360
- 0, v_service.duration_minutes, v_assignee);
361
-
362
- RETURN QUERY SELECT v_booking, p_starts_at, v_ends;
363
- END;
364
- $$;
365
-
366
- REVOKE ALL ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text) FROM public;
367
- GRANT EXECUTE ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text)
368
- TO anon, authenticated, service_role;
369
- `
370
-
371
- export const MIGRATION_002_V_BOOKINGS_COMPAT = `-- Where 000b preserved the legacy beauty view, re-create v_bookings as a
372
- -- thin alias over it so live pre-M5 clients that SELECT v_bookings by name
373
- -- keep their full legacy column set. Dropped for good once the beauty app
374
- -- ships on v_appointments (M5 follow-up).
375
-
376
- DO $compat$
377
- BEGIN
378
- IF to_regclass('public.v_bookings_legacy_beauty') IS NOT NULL
379
- AND to_regclass('public.v_bookings') IS NULL THEN
380
- EXECUTE 'CREATE VIEW public.v_bookings WITH (security_invoker = true) AS SELECT * FROM public.v_bookings_legacy_beauty';
381
- EXECUTE 'GRANT SELECT ON public.v_bookings TO authenticated, service_role';
382
- EXECUTE 'REVOKE SELECT ON public.v_bookings FROM anon';
383
- END IF;
384
- END $compat$;
385
- `
386
-
387
- export const MIGRATION_003_PUBLIC_CLIENT_KIND = `-- ---------------------------------------------------------------------------
388
- -- Public booking — tenant-configurable client kind
389
- -- ---------------------------------------------------------------------------
390
- -- 001 hardcoded kind='customer' for people created by create_public_booking.
391
- -- Verticals whose client archetype uses another kind (school → 'student',
392
- -- clinic → whatever the app registers) ended up with people invisible to the
393
- -- app's own pages/lookups and with entity links resolving to unregistered
394
- -- routes (#/customers/:id → not found).
395
- --
396
- -- The client kind now comes from TENANT SETTINGS (admin-controlled — never
397
- -- from the anon caller): tenants.settings.booking.client_kind
398
- -- Default remains 'customer', so existing pools/tenants are unaffected until
399
- -- they opt in:
400
- -- UPDATE tenants SET settings = jsonb_set(COALESCE(settings,'{}'::jsonb),
401
- -- '{booking,client_kind}', '"student"') WHERE id = '<tenant>';
402
- --
403
- -- The upsert no longer relies on the kind-specific partial unique index
404
- -- (people_tenant_customer_phone) — a per-(tenant, phone) advisory lock
405
- -- serializes concurrent first-bookings for any kind. The 001 index stays in
406
- -- place (harmless, still guards direct customer writes).
407
- --
408
- -- Idempotent: CREATE OR REPLACE, same signature as 001.
409
- -- ---------------------------------------------------------------------------
410
- CREATE OR REPLACE FUNCTION public.create_public_booking(
411
- p_tenant_id uuid,
412
- p_service_id uuid,
413
- p_starts_at timestamptz,
414
- p_name text,
415
- p_phone text,
416
- p_email text DEFAULT NULL,
417
- p_notes text DEFAULT NULL
418
- ) RETURNS TABLE (booking_id uuid, starts_at timestamptz, ends_at timestamptz)
419
- LANGUAGE plpgsql SECURITY DEFINER
420
- SET search_path = public
421
- AS $$
422
- DECLARE
423
- v_service record;
424
- v_person_id uuid;
425
- v_assignee uuid;
426
- v_ends timestamptz;
427
- v_order uuid;
428
- v_booking uuid;
429
- v_phone text;
430
- v_tz text;
431
- v_notes text;
432
- v_client_kind text;
433
- BEGIN
434
- -- validation: anon-callable, everything tight
435
- IF p_name IS NULL OR length(trim(p_name)) NOT BETWEEN 2 AND 120 THEN
436
- RAISE EXCEPTION 'invalid name';
437
- END IF;
438
- v_phone := regexp_replace(COALESCE(p_phone, ''), '\\D', '', 'g');
439
- IF length(v_phone) NOT BETWEEN 8 AND 15 THEN
440
- RAISE EXCEPTION 'invalid phone';
441
- END IF;
442
- IF p_email IS NOT NULL AND (length(p_email) > 254 OR p_email !~ '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$') THEN
443
- RAISE EXCEPTION 'invalid email';
444
- END IF;
445
- v_notes := left(p_notes, 500);
446
- IF p_starts_at IS NULL OR p_starts_at <= now() OR p_starts_at > now() + interval '90 days' THEN
447
- RAISE EXCEPTION 'invalid start';
448
- END IF;
449
-
450
- SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
451
- INTO v_service
452
- FROM services s
453
- WHERE s.id = p_service_id AND s.tenant_id = p_tenant_id
454
- AND s.is_active AND s.status = 'active';
455
- IF NOT FOUND THEN RAISE EXCEPTION 'unknown service'; END IF;
456
-
457
- v_ends := p_starts_at + make_interval(mins => v_service.duration_minutes);
458
-
459
- -- tenant config: timezone + client kind (admin-set; sanitized, never caller input)
460
- SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo'),
461
- COALESCE(NULLIF(trim(t.settings->'booking'->>'client_kind'), ''), 'customer')
462
- INTO v_tz, v_client_kind
463
- FROM tenants t WHERE t.id = p_tenant_id;
464
- IF v_tz IS NULL THEN RAISE EXCEPTION 'unknown tenant'; END IF;
465
- IF v_client_kind !~ '^[a-z_]{2,32}$' THEN v_client_kind := 'customer'; END IF;
466
-
467
- -- pick the assignee whose working hours cover the slot
468
- SELECT sc.assignee_id INTO v_assignee
469
- FROM schedules sc
470
- WHERE sc.tenant_id = p_tenant_id
471
- AND sc.kind = 'working_hours'
472
- AND sc.is_active
473
- AND (
474
- sc.specific_date = (p_starts_at AT TIME ZONE v_tz)::date
475
- OR (sc.specific_date IS NULL
476
- AND sc.day_of_week = EXTRACT(dow FROM p_starts_at AT TIME ZONE v_tz)::smallint)
477
- )
478
- AND (p_starts_at AT TIME ZONE v_tz)::time >= sc.starts_at
479
- AND (v_ends AT TIME ZONE v_tz)::time <= sc.ends_at
480
- ORDER BY sc.assignee_id
481
- LIMIT 1;
482
- IF v_assignee IS NULL THEN RAISE EXCEPTION 'slot outside working hours'; END IF;
483
-
484
- -- serialize per (tenant, assignee) to kill the double-book race
485
- PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_assignee::text, 0));
486
-
487
- IF EXISTS (
488
- SELECT 1 FROM appointments b
489
- WHERE b.tenant_id = p_tenant_id
490
- AND b.assignee_id = v_assignee
491
- AND b.status NOT IN ('cancelled', 'no_show')
492
- AND b.starts_at < v_ends
493
- AND COALESCE(b.ends_at, b.starts_at) > p_starts_at
494
- ) THEN
495
- RAISE EXCEPTION 'slot no longer available';
496
- END IF;
497
-
498
- -- anti-spam: cap open future public bookings per phone per tenant
499
- IF (
500
- SELECT count(*) FROM appointments b
501
- JOIN people p ON p.id = b.party_id
502
- WHERE b.tenant_id = p_tenant_id
503
- AND p.phone = v_phone
504
- AND b.starts_at > now()
505
- AND b.status = 'scheduled'
506
- ) >= 3 THEN
507
- RAISE EXCEPTION 'too many open bookings for this phone';
508
- END IF;
509
-
510
- -- upsert the client by (tenant, kind, phone). Kind is tenant-configured, so
511
- -- the 001 customer-only partial index can't arbitrate — a per-(tenant,
512
- -- phone) advisory lock serializes concurrent first-bookings instead.
513
- PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || ':' || v_phone, 1));
514
-
515
- SELECT p.id INTO v_person_id
516
- FROM people p
517
- WHERE p.tenant_id = p_tenant_id AND p.kind = v_client_kind AND p.phone = v_phone
518
- ORDER BY p.created_at
519
- LIMIT 1;
520
-
521
- IF v_person_id IS NOT NULL THEN
522
- UPDATE people SET
523
- name = trim(p_name),
524
- email = COALESCE(p_email, people.email),
525
- updated_at = now()
526
- WHERE id = v_person_id;
527
- ELSE
528
- INSERT INTO people (tenant_id, kind, name, phone, email, metadata)
529
- VALUES (p_tenant_id, v_client_kind, trim(p_name), v_phone, p_email,
530
- jsonb_build_object('source', 'public_booking'))
531
- RETURNING id INTO v_person_id;
532
- END IF;
533
-
534
- -- order + booking + order_item (statuses hardcoded — caller cannot set)
535
- INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
536
- VALUES (p_tenant_id, 'appointment', 'scheduled', v_person_id, v_assignee,
537
- v_service.price, v_service.price, v_notes,
538
- jsonb_build_object('source', 'public_booking',
539
- 'serviceNames', v_service.name,
540
- 'contactName', trim(p_name)))
541
- RETURNING id INTO v_order;
542
-
543
- INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
544
- starts_at, ends_at, status, notes, metadata)
545
- VALUES (p_tenant_id, 'appointment', v_person_id, v_assignee, v_order,
546
- p_starts_at, v_ends, 'scheduled', v_notes,
547
- jsonb_build_object('source', 'public_booking', 'serviceNames', v_service.name))
548
- RETURNING id INTO v_booking;
549
-
550
- INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
551
- sort_order, duration_minutes, assignee_id)
552
- VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
553
- 0, v_service.duration_minutes, v_assignee);
554
-
555
- RETURN QUERY SELECT v_booking, p_starts_at, v_ends;
556
- END;
557
- $$;
558
-
559
- REVOKE ALL ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text) FROM public;
560
- GRANT EXECUTE ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text)
561
- TO anon, authenticated, service_role;
562
- `
563
-
564
- export const MIGRATION_004_PUBLIC_BOOKING_ASSIGNEE = `-- ---------------------------------------------------------------------------
565
- -- Public booking — respect the professional the visitor saw
566
- -- ---------------------------------------------------------------------------
567
- -- Until now create_public_booking IGNORED which professional the site showed
568
- -- availability for and re-picked by working hours (ORDER BY assignee_id
569
- -- LIMIT 1). With several professionals covering the same slot the booking
570
- -- landed on someone else than displayed ("Equipe Great DJs" on the site,
571
- -- "Rafa Groove" in the agenda). New optional p_assignee_id:
572
- -- * provided → validate THAT professional's working hours cover the slot and
573
- -- book them — never silently reassign (mismatch becomes an explicit error);
574
- -- * NULL → auto-pick as before (sites that don't pin a professional).
575
- -- Conflict check + advisory lock already run against the resolved assignee, so
576
- -- the double-book guard follows whichever path resolves.
577
- --
578
- -- Signature change (8th arg): the old 7-arg function is DROPPED first —
579
- -- CREATE OR REPLACE with a different signature would create an ambiguous
580
- -- PostgREST overload instead of replacing.
581
- -- Includes 003's tenant-configurable client kind. Idempotent.
582
- -- ---------------------------------------------------------------------------
583
- DROP FUNCTION IF EXISTS public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text);
584
-
585
- CREATE OR REPLACE FUNCTION public.create_public_booking(
586
- p_tenant_id uuid,
587
- p_service_id uuid,
588
- p_starts_at timestamptz,
589
- p_name text,
590
- p_phone text,
591
- p_email text DEFAULT NULL,
592
- p_notes text DEFAULT NULL,
593
- p_assignee_id uuid DEFAULT NULL
594
- ) RETURNS TABLE (booking_id uuid, starts_at timestamptz, ends_at timestamptz)
595
- LANGUAGE plpgsql SECURITY DEFINER
596
- SET search_path = public
597
- AS $$
598
- DECLARE
599
- v_service record;
600
- v_person_id uuid;
601
- v_assignee uuid;
602
- v_ends timestamptz;
603
- v_order uuid;
604
- v_booking uuid;
605
- v_phone text;
606
- v_tz text;
607
- v_notes text;
608
- v_client_kind text;
609
- BEGIN
610
- -- validation: anon-callable, everything tight
611
- IF p_name IS NULL OR length(trim(p_name)) NOT BETWEEN 2 AND 120 THEN
612
- RAISE EXCEPTION 'invalid name';
613
- END IF;
614
- v_phone := regexp_replace(COALESCE(p_phone, ''), '\\D', '', 'g');
615
- IF length(v_phone) NOT BETWEEN 8 AND 15 THEN
616
- RAISE EXCEPTION 'invalid phone';
617
- END IF;
618
- IF p_email IS NOT NULL AND (length(p_email) > 254 OR p_email !~ '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$') THEN
619
- RAISE EXCEPTION 'invalid email';
620
- END IF;
621
- v_notes := left(p_notes, 500);
622
- IF p_starts_at IS NULL OR p_starts_at <= now() OR p_starts_at > now() + interval '90 days' THEN
623
- RAISE EXCEPTION 'invalid start';
624
- END IF;
625
-
626
- SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
627
- INTO v_service
628
- FROM services s
629
- WHERE s.id = p_service_id AND s.tenant_id = p_tenant_id
630
- AND s.is_active AND s.status = 'active';
631
- IF NOT FOUND THEN RAISE EXCEPTION 'unknown service'; END IF;
632
-
633
- v_ends := p_starts_at + make_interval(mins => v_service.duration_minutes);
634
-
635
- -- tenant config: timezone + client kind (admin-set; sanitized, never caller input)
636
- SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo'),
637
- COALESCE(NULLIF(trim(t.settings->'booking'->>'client_kind'), ''), 'customer')
638
- INTO v_tz, v_client_kind
639
- FROM tenants t WHERE t.id = p_tenant_id;
640
- IF v_tz IS NULL THEN RAISE EXCEPTION 'unknown tenant'; END IF;
641
- IF v_client_kind !~ '^[a-z_]{2,32}$' THEN v_client_kind := 'customer'; END IF;
642
-
643
- -- resolve the professional: the one the visitor saw (validated), else the
644
- -- first whose working hours cover the slot
645
- SELECT sc.assignee_id INTO v_assignee
646
- FROM schedules sc
647
- WHERE sc.tenant_id = p_tenant_id
648
- AND sc.kind = 'working_hours'
649
- AND sc.is_active
650
- AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)
651
- AND (
652
- sc.specific_date = (p_starts_at AT TIME ZONE v_tz)::date
653
- OR (sc.specific_date IS NULL
654
- AND sc.day_of_week = EXTRACT(dow FROM p_starts_at AT TIME ZONE v_tz)::smallint)
655
- )
656
- AND (p_starts_at AT TIME ZONE v_tz)::time >= sc.starts_at
657
- AND (v_ends AT TIME ZONE v_tz)::time <= sc.ends_at
658
- ORDER BY sc.assignee_id
659
- LIMIT 1;
660
- IF v_assignee IS NULL THEN RAISE EXCEPTION 'slot outside working hours'; END IF;
661
-
662
- -- serialize per (tenant, assignee) to kill the double-book race
663
- PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_assignee::text, 0));
664
-
665
- IF EXISTS (
666
- SELECT 1 FROM appointments b
667
- WHERE b.tenant_id = p_tenant_id
668
- AND b.assignee_id = v_assignee
669
- AND b.status NOT IN ('cancelled', 'no_show')
670
- AND b.starts_at < v_ends
671
- AND COALESCE(b.ends_at, b.starts_at) > p_starts_at
672
- ) THEN
673
- RAISE EXCEPTION 'slot no longer available';
674
- END IF;
675
-
676
- -- anti-spam: cap open future public bookings per phone per tenant
677
- IF (
678
- SELECT count(*) FROM appointments b
679
- JOIN people p ON p.id = b.party_id
680
- WHERE b.tenant_id = p_tenant_id
681
- AND p.phone = v_phone
682
- AND b.starts_at > now()
683
- AND b.status = 'scheduled'
684
- ) >= 3 THEN
685
- RAISE EXCEPTION 'too many open bookings for this phone';
686
- END IF;
687
-
688
- -- upsert the client by (tenant, kind, phone), serialized by advisory lock
689
- -- (kind is tenant-configured, so the customer-only partial index can't
690
- -- arbitrate)
691
- PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || ':' || v_phone, 1));
692
-
693
- SELECT p.id INTO v_person_id
694
- FROM people p
695
- WHERE p.tenant_id = p_tenant_id AND p.kind = v_client_kind AND p.phone = v_phone
696
- ORDER BY p.created_at
697
- LIMIT 1;
698
-
699
- IF v_person_id IS NOT NULL THEN
700
- UPDATE people SET
701
- name = trim(p_name),
702
- email = COALESCE(p_email, people.email),
703
- updated_at = now()
704
- WHERE id = v_person_id;
705
- ELSE
706
- INSERT INTO people (tenant_id, kind, name, phone, email, metadata)
707
- VALUES (p_tenant_id, v_client_kind, trim(p_name), v_phone, p_email,
708
- jsonb_build_object('source', 'public_booking'))
709
- RETURNING id INTO v_person_id;
710
- END IF;
711
-
712
- -- order + booking + order_item (statuses hardcoded — caller cannot set)
713
- INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
714
- VALUES (p_tenant_id, 'appointment', 'scheduled', v_person_id, v_assignee,
715
- v_service.price, v_service.price, v_notes,
716
- jsonb_build_object('source', 'public_booking',
717
- 'serviceNames', v_service.name,
718
- 'contactName', trim(p_name)))
719
- RETURNING id INTO v_order;
720
-
721
- INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
722
- starts_at, ends_at, status, notes, metadata)
723
- VALUES (p_tenant_id, 'appointment', v_person_id, v_assignee, v_order,
724
- p_starts_at, v_ends, 'scheduled', v_notes,
725
- jsonb_build_object('source', 'public_booking', 'serviceNames', v_service.name))
726
- RETURNING id INTO v_booking;
727
-
728
- INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
729
- sort_order, duration_minutes, assignee_id)
730
- VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
731
- 0, v_service.duration_minutes, v_assignee);
732
-
733
- RETURN QUERY SELECT v_booking, p_starts_at, v_ends;
734
- END;
735
- $$;
736
-
737
- REVOKE ALL ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text, uuid) FROM public;
738
- GRANT EXECUTE ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text, uuid)
739
- TO anon, authenticated, service_role;
740
- `
741
-
742
- export const MIGRATION_005_AGENT_RPCS = `-- ============================================================================
743
- -- plugin-agenda 005: server-plane agent write RPC.
744
- --
745
- -- public.agent_agenda_create_appointment — the INTERNAL (operator-actor)
746
- -- counterpart of create_public_booking (001 §5): same invariants (working
747
- -- hours, advisory-lock race safety, hardcoded statuses), but:
748
- -- * actor-authorized: public.agent_guard (spine 015) runs role→plan→limit
749
- -- BEFORE anything — denial comes back as a structured jsonb the agent can
750
- -- explain conversationally, never a raised exception;
751
- -- * the client is an EXISTING person id (the agent resolves names→ids via
752
- -- reads first — ambiguity is a conversation problem, not a SQL one);
753
- -- * a requested professional is honored (working hours permitting);
754
- -- * audited: audit_logs row with the acting user.
755
- --
756
- -- Contract (all agent_* RPCs): (p_tenant_id, p_actor_user_id, p_payload jsonb)
757
- -- → jsonb {ok:true, id, record:{...}} | {ok:false, denial:{...}} | {ok:false,
758
- -- error text}. GRANT authenticated+service_role — NEVER anon (the Fayz broker
759
- -- calls with the pool service key and injects tenant/actor server-side).
760
- -- ============================================================================
761
-
762
- CREATE OR REPLACE FUNCTION public.agent_agenda_create_appointment(
763
- p_tenant_id uuid,
764
- p_actor_user_id uuid,
765
- p_payload jsonb
766
- ) RETURNS jsonb
767
- LANGUAGE plpgsql SECURITY DEFINER
768
- SET search_path = public
769
- AS $$
770
- DECLARE
771
- v_denial jsonb;
772
- v_used int;
773
- v_client_id uuid;
774
- v_service_id uuid;
775
- v_professional uuid;
776
- v_starts timestamptz;
777
- v_notes text;
778
- v_service record;
779
- v_client record;
780
- v_ends timestamptz;
781
- v_tz text;
782
- v_order uuid;
783
- v_booking uuid;
784
- BEGIN
785
- -- ── payload ──────────────────────────────────────────────────────────────
786
- BEGIN
787
- v_client_id := (p_payload->>'client_id')::uuid;
788
- v_service_id := (p_payload->>'service_id')::uuid;
789
- v_professional := (p_payload->>'professional_id')::uuid;
790
- v_starts := (p_payload->>'starts_at')::timestamptz;
791
- EXCEPTION WHEN OTHERS THEN
792
- RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);
793
- END;
794
- v_notes := left(p_payload->>'notes', 500);
795
- IF v_client_id IS NULL OR v_service_id IS NULL OR v_starts IS NULL THEN
796
- RETURN jsonb_build_object('ok', false, 'error', 'client_id, service_id and starts_at are required');
797
- END IF;
798
- IF v_starts <= now() OR v_starts > now() + interval '365 days' THEN
799
- RETURN jsonb_build_object('ok', false, 'error', 'starts_at must be in the future (max 1 year)');
800
- END IF;
801
-
802
- -- ── authorization: role → plan → bookings_month cap ──────────────────────
803
- SELECT count(*) INTO v_used FROM appointments b
804
- WHERE b.tenant_id = p_tenant_id
805
- AND b.created_at >= date_trunc('month', now());
806
- v_denial := agent_guard(p_tenant_id, p_actor_user_id,
807
- 'appointments', 'create', 'bookings_month', v_used, 1);
808
- IF v_denial IS NOT NULL THEN
809
- RETURN jsonb_build_object('ok', false, 'denial', v_denial);
810
- END IF;
811
-
812
- -- ── invariants ───────────────────────────────────────────────────────────
813
- SELECT p.id, p.name INTO v_client
814
- FROM people p
815
- WHERE p.id = v_client_id AND p.tenant_id = p_tenant_id;
816
- IF NOT FOUND THEN
817
- RETURN jsonb_build_object('ok', false, 'error', 'unknown client for this tenant');
818
- END IF;
819
-
820
- SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
821
- INTO v_service
822
- FROM services s
823
- WHERE s.id = v_service_id AND s.tenant_id = p_tenant_id
824
- AND s.is_active AND s.status = 'active';
825
- IF NOT FOUND THEN
826
- RETURN jsonb_build_object('ok', false, 'error', 'unknown or inactive service');
827
- END IF;
828
-
829
- v_ends := v_starts + make_interval(mins => v_service.duration_minutes);
830
- SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
831
- FROM tenants t WHERE t.id = p_tenant_id;
832
-
833
- -- requested professional honored; otherwise pick one whose hours cover the slot
834
- SELECT sc.assignee_id INTO v_professional
835
- FROM schedules sc
836
- WHERE sc.tenant_id = p_tenant_id
837
- AND sc.kind = 'working_hours'
838
- AND sc.is_active
839
- AND (v_professional IS NULL OR sc.assignee_id = v_professional)
840
- AND (
841
- sc.specific_date = (v_starts AT TIME ZONE v_tz)::date
842
- OR (sc.specific_date IS NULL
843
- AND sc.day_of_week = EXTRACT(dow FROM v_starts AT TIME ZONE v_tz)::smallint)
844
- )
845
- AND (v_starts AT TIME ZONE v_tz)::time >= sc.starts_at
846
- AND (v_ends AT TIME ZONE v_tz)::time <= sc.ends_at
847
- ORDER BY sc.assignee_id
848
- LIMIT 1;
849
- IF v_professional IS NULL THEN
850
- RETURN jsonb_build_object('ok', false, 'error', 'slot outside working hours for the requested professional');
851
- END IF;
852
-
853
- -- race safety: serialize per (tenant, professional), then conflict-check
854
- PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_professional::text, 0));
855
- IF EXISTS (
856
- SELECT 1 FROM appointments b
857
- WHERE b.tenant_id = p_tenant_id
858
- AND b.assignee_id = v_professional
859
- AND b.status NOT IN ('cancelled', 'no_show')
860
- AND b.starts_at < v_ends
861
- AND COALESCE(b.ends_at, b.starts_at) > v_starts
862
- ) THEN
863
- RETURN jsonb_build_object('ok', false, 'error', 'slot no longer available');
864
- END IF;
865
-
866
- -- ── write (statuses hardcoded) + audit ───────────────────────────────────
867
- INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
868
- VALUES (p_tenant_id, 'appointment', 'scheduled', v_client_id, v_professional,
869
- v_service.price, v_service.price, v_notes,
870
- jsonb_build_object('source', 'agent', 'serviceNames', v_service.name))
871
- RETURNING id INTO v_order;
872
-
873
- INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
874
- starts_at, ends_at, status, notes, metadata)
875
- VALUES (p_tenant_id, 'appointment', v_client_id, v_professional, v_order,
876
- v_starts, v_ends, 'scheduled', v_notes,
877
- jsonb_build_object('source', 'agent', 'serviceNames', v_service.name))
878
- RETURNING id INTO v_booking;
879
-
880
- INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
881
- sort_order, duration_minutes, assignee_id)
882
- VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
883
- 0, v_service.duration_minutes, v_professional);
884
-
885
- INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
886
- VALUES (p_tenant_id, p_actor_user_id, 'agent.createAppointment', 'appointment', v_booking::text,
887
- jsonb_build_object('payload', p_payload, 'order_id', v_order));
888
-
889
- RETURN jsonb_build_object(
890
- 'ok', true,
891
- 'id', v_booking,
892
- 'record', jsonb_build_object(
893
- 'ref', jsonb_build_object('id', v_booking, 'resource', 'appointments',
894
- 'archetype', 'schedule:appointment'),
895
- 'starts_at', v_starts,
896
- 'ends_at', v_ends,
897
- 'client_name', v_client.name,
898
- 'service_name', v_service.name,
899
- 'price', v_service.price,
900
- 'status', 'scheduled'
901
- )
902
- );
903
- END;
904
- $$;
905
-
906
- REVOKE ALL ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb) FROM public;
907
- REVOKE EXECUTE ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb) FROM anon;
908
- GRANT EXECUTE ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb)
909
- TO authenticated, service_role;
910
- `
911
-
912
- export const MIGRATION_006_AGENT_RPC_LOCALTIME = `-- ============================================================================
913
- -- plugin-agenda 006: agent_agenda_create_appointment speaks LOCAL time.
914
- --
915
- -- Models reliably mangle UTC↔local offsets ("10h" became 13:00 BRT via a
916
- -- wrong sign). The RPC knows the tenant's timezone — so stop asking the model
917
- -- to convert: \`starts_at\` WITHOUT an offset (2026-07-27T10:00) is interpreted
918
- -- in the tenant tz; an explicit offset still works. The result now carries
919
- -- starts_at_local/ends_at_local strings so the reply echoes the truth in the
920
- -- user's own clock. Supersedes 005's function body (new file — applied
921
- -- migrations are never edited).
922
- -- ============================================================================
923
-
924
- CREATE OR REPLACE FUNCTION public.agent_agenda_create_appointment(
925
- p_tenant_id uuid,
926
- p_actor_user_id uuid,
927
- p_payload jsonb
928
- ) RETURNS jsonb
929
- LANGUAGE plpgsql SECURITY DEFINER
930
- SET search_path = public
931
- AS $$
932
- DECLARE
933
- v_denial jsonb;
934
- v_used int;
935
- v_client_id uuid;
936
- v_service_id uuid;
937
- v_professional uuid;
938
- v_starts timestamptz;
939
- v_starts_raw text;
940
- v_notes text;
941
- v_service record;
942
- v_client record;
943
- v_ends timestamptz;
944
- v_tz text;
945
- v_order uuid;
946
- v_booking uuid;
947
- BEGIN
948
- SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
949
- FROM tenants t WHERE t.id = p_tenant_id;
950
- IF v_tz IS NULL THEN
951
- RETURN jsonb_build_object('ok', false, 'error', 'unknown tenant');
952
- END IF;
953
-
954
- -- ── payload ──────────────────────────────────────────────────────────────
955
- BEGIN
956
- v_client_id := (p_payload->>'client_id')::uuid;
957
- v_service_id := (p_payload->>'service_id')::uuid;
958
- v_professional := (p_payload->>'professional_id')::uuid;
959
- v_starts_raw := p_payload->>'starts_at';
960
- IF v_starts_raw ~ '(Z|[+-][0-9]{2}:?[0-9]{2})$' THEN
961
- v_starts := v_starts_raw::timestamptz; -- explicit offset
962
- ELSE
963
- v_starts := (v_starts_raw::timestamp) AT TIME ZONE v_tz; -- tenant-local
964
- END IF;
965
- EXCEPTION WHEN OTHERS THEN
966
- RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);
967
- END;
968
- v_notes := left(p_payload->>'notes', 500);
969
- IF v_client_id IS NULL OR v_service_id IS NULL OR v_starts IS NULL THEN
970
- RETURN jsonb_build_object('ok', false, 'error', 'client_id, service_id and starts_at are required');
971
- END IF;
972
- IF v_starts <= now() OR v_starts > now() + interval '365 days' THEN
973
- RETURN jsonb_build_object('ok', false, 'error', 'starts_at must be in the future (max 1 year)');
974
- END IF;
975
-
976
- -- ── authorization: role → plan → bookings_month cap ──────────────────────
977
- SELECT count(*) INTO v_used FROM appointments b
978
- WHERE b.tenant_id = p_tenant_id
979
- AND b.created_at >= date_trunc('month', now());
980
- v_denial := agent_guard(p_tenant_id, p_actor_user_id,
981
- 'appointments', 'create', 'bookings_month', v_used, 1);
982
- IF v_denial IS NOT NULL THEN
983
- RETURN jsonb_build_object('ok', false, 'denial', v_denial);
984
- END IF;
985
-
986
- -- ── invariants ───────────────────────────────────────────────────────────
987
- SELECT p.id, p.name INTO v_client
988
- FROM people p
989
- WHERE p.id = v_client_id AND p.tenant_id = p_tenant_id;
990
- IF NOT FOUND THEN
991
- RETURN jsonb_build_object('ok', false, 'error', 'unknown client for this tenant');
992
- END IF;
993
-
994
- SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
995
- INTO v_service
996
- FROM services s
997
- WHERE s.id = v_service_id AND s.tenant_id = p_tenant_id
998
- AND s.is_active AND s.status = 'active';
999
- IF NOT FOUND THEN
1000
- RETURN jsonb_build_object('ok', false, 'error', 'unknown or inactive service');
1001
- END IF;
1002
-
1003
- v_ends := v_starts + make_interval(mins => v_service.duration_minutes);
1004
-
1005
- SELECT sc.assignee_id INTO v_professional
1006
- FROM schedules sc
1007
- WHERE sc.tenant_id = p_tenant_id
1008
- AND sc.kind = 'working_hours'
1009
- AND sc.is_active
1010
- AND (v_professional IS NULL OR sc.assignee_id = v_professional)
1011
- AND (
1012
- sc.specific_date = (v_starts AT TIME ZONE v_tz)::date
1013
- OR (sc.specific_date IS NULL
1014
- AND sc.day_of_week = EXTRACT(dow FROM v_starts AT TIME ZONE v_tz)::smallint)
1015
- )
1016
- AND (v_starts AT TIME ZONE v_tz)::time >= sc.starts_at
1017
- AND (v_ends AT TIME ZONE v_tz)::time <= sc.ends_at
1018
- -- prefer a professional who is actually FREE at the slot (when the user
1019
- -- did not name one, the first busy professional must not block the booking)
1020
- AND NOT EXISTS (
1021
- SELECT 1 FROM appointments b
1022
- WHERE b.tenant_id = p_tenant_id
1023
- AND b.assignee_id = sc.assignee_id
1024
- AND b.status NOT IN ('cancelled', 'no_show')
1025
- AND b.starts_at < v_ends
1026
- AND COALESCE(b.ends_at, b.starts_at) > v_starts
1027
- )
1028
- ORDER BY sc.assignee_id
1029
- LIMIT 1;
1030
- IF v_professional IS NULL THEN
1031
- RETURN jsonb_build_object('ok', false, 'error',
1032
- 'no professional is available at that slot (outside working hours or already booked). Suggest a different time. Times are tenant-local, tz ' || v_tz);
1033
- END IF;
1034
-
1035
- PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_professional::text, 0));
1036
- IF EXISTS (
1037
- SELECT 1 FROM appointments b
1038
- WHERE b.tenant_id = p_tenant_id
1039
- AND b.assignee_id = v_professional
1040
- AND b.status NOT IN ('cancelled', 'no_show')
1041
- AND b.starts_at < v_ends
1042
- AND COALESCE(b.ends_at, b.starts_at) > v_starts
1043
- ) THEN
1044
- RETURN jsonb_build_object('ok', false, 'error', 'slot no longer available');
1045
- END IF;
1046
-
1047
- -- ── write + audit ────────────────────────────────────────────────────────
1048
- INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
1049
- VALUES (p_tenant_id, 'appointment', 'scheduled', v_client_id, v_professional,
1050
- v_service.price, v_service.price, v_notes,
1051
- jsonb_build_object('source', 'agent', 'serviceNames', v_service.name))
1052
- RETURNING id INTO v_order;
1053
-
1054
- INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
1055
- starts_at, ends_at, status, notes, metadata)
1056
- VALUES (p_tenant_id, 'appointment', v_client_id, v_professional, v_order,
1057
- v_starts, v_ends, 'scheduled', v_notes,
1058
- jsonb_build_object('source', 'agent', 'serviceNames', v_service.name))
1059
- RETURNING id INTO v_booking;
1060
-
1061
- INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
1062
- sort_order, duration_minutes, assignee_id)
1063
- VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
1064
- 0, v_service.duration_minutes, v_professional);
1065
-
1066
- INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
1067
- VALUES (p_tenant_id, p_actor_user_id, 'agent.createAppointment', 'appointment', v_booking::text,
1068
- jsonb_build_object('payload', p_payload, 'order_id', v_order));
1069
-
1070
- RETURN jsonb_build_object(
1071
- 'ok', true,
1072
- 'id', v_booking,
1073
- 'record', jsonb_build_object(
1074
- 'ref', jsonb_build_object('id', v_booking, 'resource', 'appointments',
1075
- 'archetype', 'schedule:appointment'),
1076
- 'starts_at', v_starts,
1077
- 'ends_at', v_ends,
1078
- 'starts_at_local', to_char(v_starts AT TIME ZONE v_tz, 'YYYY-MM-DD (Dy) HH24:MI') || ' ' || v_tz,
1079
- 'ends_at_local', to_char(v_ends AT TIME ZONE v_tz, 'HH24:MI'),
1080
- 'client_name', v_client.name,
1081
- 'service_name', v_service.name,
1082
- 'price', v_service.price,
1083
- 'status', 'scheduled'
1084
- )
1085
- );
1086
- END;
1087
- $$;
1088
-
1089
- REVOKE ALL ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb) FROM public;
1090
- REVOKE EXECUTE ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb) FROM anon;
1091
- GRANT EXECUTE ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb)
1092
- TO authenticated, service_role;
1093
- `
1094
-
1095
- export const MIGRATION_007_SCHEDULE_EXCEPTION_PRECEDENCE = `-- ============================================================================
1096
- -- plugin-agenda 007: specific-date exceptions take PRECEDENCE over weekly hours.
1097
- --
1098
- -- The admin ScheduleEditor (saveException) writes two kinds of specific-date
1099
- -- rows: 'available' = active rows with custom windows for the date; day off
1100
- -- ('unavailable') = ONE inactive 00:00–00:00 sentinel. Until now every reader
1101
- -- treated schedules as a flat union filtered by is_active, so:
1102
- -- • a day off was INVISIBLE (inactive sentinel filtered out, weekly rows
1103
- -- still matched) — the public site kept selling a day the admin closed;
1104
- -- • an 'available' exception ADDED to the weekly hours instead of replacing
1105
- -- them ("horário especial" should define the date's hours).
1106
- --
1107
- -- Fix: one helper — agenda_working_windows(tenant, date, assignee) — is now the
1108
- -- single source of a day's windows: if ANY specific-date row exists for an
1109
- -- assignee (active or sentinel), only the ACTIVE specific-date rows count for
1110
- -- that assignee; otherwise the weekly (day_of_week) rows apply. Both readers
1111
- -- are redefined on top of it:
1112
- -- • get_available_slots — body otherwise identical to the latest version
1113
- -- (google-calendar/002_smart_sync §5, tenant-wide 'block' clause KEPT);
1114
- -- • create_public_booking — body otherwise identical to 004.
1115
- --
1116
- -- ORDERING: on pools with the Google Calendar connector this file must run
1117
- -- AFTER integrations/google-calendar/migrations/002_smart_sync.sql (both
1118
- -- redefine get_available_slots; 007 is the superset and must win). Re-running
1119
- -- 007 last is always safe — it preserves 002's block semantics.
1120
- -- ============================================================================
1121
-
1122
- -- ----------------------------------------------------------------------------
1123
- -- §1 agenda_working_windows — the day's working windows, exception-aware.
1124
- -- ----------------------------------------------------------------------------
1125
- CREATE OR REPLACE FUNCTION public.agenda_working_windows(
1126
- p_tenant_id uuid,
1127
- p_date date,
1128
- p_assignee_id uuid DEFAULT NULL
1129
- ) RETURNS TABLE (assignee_id uuid, win_start time, win_end time)
1130
- LANGUAGE sql STABLE
1131
- SET search_path = public
1132
- AS $$
1133
- WITH exc AS (
1134
- -- Every specific-date row for the date, ACTIVE OR NOT: the inactive day-off
1135
- -- sentinel must still suppress the weekly rows below.
1136
- SELECT sc.assignee_id, sc.starts_at, sc.ends_at, sc.is_active
1137
- FROM schedules sc
1138
- WHERE sc.tenant_id = p_tenant_id
1139
- AND sc.kind = 'working_hours'
1140
- AND sc.specific_date = p_date
1141
- AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)
1142
- )
1143
- SELECT e.assignee_id, e.starts_at, e.ends_at
1144
- FROM exc e
1145
- WHERE e.is_active
1146
- UNION ALL
1147
- SELECT sc.assignee_id, sc.starts_at, sc.ends_at
1148
- FROM schedules sc
1149
- WHERE sc.tenant_id = p_tenant_id
1150
- AND sc.kind = 'working_hours'
1151
- AND sc.is_active
1152
- AND sc.specific_date IS NULL
1153
- AND sc.day_of_week = EXTRACT(dow FROM p_date)::smallint
1154
- AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)
1155
- AND NOT EXISTS (SELECT 1 FROM exc e WHERE e.assignee_id = sc.assignee_id)
1156
- $$;
1157
-
1158
- REVOKE ALL ON FUNCTION public.agenda_working_windows(uuid, date, uuid) FROM public;
1159
- REVOKE EXECUTE ON FUNCTION public.agenda_working_windows(uuid, date, uuid) FROM anon;
1160
- GRANT EXECUTE ON FUNCTION public.agenda_working_windows(uuid, date, uuid)
1161
- TO authenticated, service_role;
1162
-
1163
- -- ----------------------------------------------------------------------------
1164
- -- §2 get_available_slots — day_schedules now comes from the helper.
1165
- -- Everything else is byte-identical to google-calendar/002_smart_sync §5.
1166
- -- ----------------------------------------------------------------------------
1167
- CREATE OR REPLACE FUNCTION public.get_available_slots(
1168
- p_tenant_id uuid,
1169
- p_date date,
1170
- p_duration_minutes integer,
1171
- p_assignee_id uuid DEFAULT NULL,
1172
- p_slot_interval integer DEFAULT 30
1173
- ) RETURNS TABLE (slot_start timestamptz, slot_end timestamptz)
1174
- LANGUAGE plpgsql STABLE SECURITY DEFINER
1175
- SET search_path = public
1176
- AS $$
1177
- DECLARE
1178
- v_tz text;
1179
- BEGIN
1180
- -- tight validation: this function is anon-callable
1181
- IF p_duration_minutes IS NULL OR p_duration_minutes NOT BETWEEN 5 AND 480 THEN
1182
- RAISE EXCEPTION 'invalid duration';
1183
- END IF;
1184
- IF p_slot_interval IS NULL OR p_slot_interval NOT BETWEEN 5 AND 240 THEN
1185
- RAISE EXCEPTION 'invalid interval';
1186
- END IF;
1187
- IF p_date IS NULL OR p_date < current_date OR p_date > current_date + 90 THEN
1188
- RETURN;
1189
- END IF;
1190
-
1191
- SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
1192
- FROM tenants t WHERE t.id = p_tenant_id;
1193
- IF v_tz IS NULL THEN RETURN; END IF; -- unknown tenant → empty, not an error
1194
-
1195
- RETURN QUERY
1196
- WITH day_schedules AS (
1197
- SELECT w.assignee_id, w.win_start, w.win_end
1198
- FROM agenda_working_windows(p_tenant_id, p_date, p_assignee_id) w
1199
- ),
1200
- candidate AS (
1201
- SELECT
1202
- ds.assignee_id,
1203
- gs AS s_start,
1204
- gs + make_interval(mins => p_duration_minutes) AS s_end
1205
- FROM day_schedules ds
1206
- CROSS JOIN LATERAL generate_series(
1207
- (p_date + ds.win_start) AT TIME ZONE v_tz,
1208
- ((p_date + ds.win_end) AT TIME ZONE v_tz) - make_interval(mins => p_duration_minutes),
1209
- make_interval(mins => p_slot_interval)
1210
- ) AS gs
1211
- )
1212
- SELECT DISTINCT c.s_start, c.s_end
1213
- FROM candidate c
1214
- WHERE NOT EXISTS (
1215
- SELECT 1 FROM appointments b
1216
- WHERE b.tenant_id = p_tenant_id
1217
- -- SMART SYNC (gcal 002): this professional's own bookings OR any
1218
- -- tenant-wide BLOCK (assignee_id IS NULL AND kind='block') busy the slot.
1219
- AND (b.assignee_id = c.assignee_id
1220
- OR (b.assignee_id IS NULL AND b.kind = 'block'))
1221
- AND b.status NOT IN ('cancelled', 'no_show')
1222
- AND b.starts_at < c.s_end
1223
- AND COALESCE(b.ends_at, b.starts_at) > c.s_start
1224
- )
1225
- ORDER BY 1;
1226
- END;
1227
- $$;
1228
-
1229
- REVOKE ALL ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer) FROM public;
1230
- GRANT EXECUTE ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer)
1231
- TO anon, authenticated, service_role;
1232
-
1233
- -- ----------------------------------------------------------------------------
1234
- -- §3 create_public_booking — professional resolution goes through the helper
1235
- -- (a booking on a closed date now fails 'slot outside working hours').
1236
- -- Everything else is byte-identical to 004.
1237
- -- ----------------------------------------------------------------------------
1238
- CREATE OR REPLACE FUNCTION public.create_public_booking(
1239
- p_tenant_id uuid,
1240
- p_service_id uuid,
1241
- p_starts_at timestamptz,
1242
- p_name text,
1243
- p_phone text,
1244
- p_email text DEFAULT NULL,
1245
- p_notes text DEFAULT NULL,
1246
- p_assignee_id uuid DEFAULT NULL
1247
- ) RETURNS TABLE (booking_id uuid, starts_at timestamptz, ends_at timestamptz)
1248
- LANGUAGE plpgsql SECURITY DEFINER
1249
- SET search_path = public
1250
- AS $$
1251
- DECLARE
1252
- v_service record;
1253
- v_person_id uuid;
1254
- v_assignee uuid;
1255
- v_ends timestamptz;
1256
- v_order uuid;
1257
- v_booking uuid;
1258
- v_phone text;
1259
- v_tz text;
1260
- v_notes text;
1261
- v_client_kind text;
1262
- BEGIN
1263
- -- validation: anon-callable, everything tight
1264
- IF p_name IS NULL OR length(trim(p_name)) NOT BETWEEN 2 AND 120 THEN
1265
- RAISE EXCEPTION 'invalid name';
1266
- END IF;
1267
- v_phone := regexp_replace(COALESCE(p_phone, ''), '\\D', '', 'g');
1268
- IF length(v_phone) NOT BETWEEN 8 AND 15 THEN
1269
- RAISE EXCEPTION 'invalid phone';
1270
- END IF;
1271
- IF p_email IS NOT NULL AND (length(p_email) > 254 OR p_email !~ '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$') THEN
1272
- RAISE EXCEPTION 'invalid email';
1273
- END IF;
1274
- v_notes := left(p_notes, 500);
1275
- IF p_starts_at IS NULL OR p_starts_at <= now() OR p_starts_at > now() + interval '90 days' THEN
1276
- RAISE EXCEPTION 'invalid start';
1277
- END IF;
1278
-
1279
- SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
1280
- INTO v_service
1281
- FROM services s
1282
- WHERE s.id = p_service_id AND s.tenant_id = p_tenant_id
1283
- AND s.is_active AND s.status = 'active';
1284
- IF NOT FOUND THEN RAISE EXCEPTION 'unknown service'; END IF;
1285
-
1286
- v_ends := p_starts_at + make_interval(mins => v_service.duration_minutes);
1287
-
1288
- -- tenant config: timezone + client kind (admin-set; sanitized, never caller input)
1289
- SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo'),
1290
- COALESCE(NULLIF(trim(t.settings->'booking'->>'client_kind'), ''), 'customer')
1291
- INTO v_tz, v_client_kind
1292
- FROM tenants t WHERE t.id = p_tenant_id;
1293
- IF v_tz IS NULL THEN RAISE EXCEPTION 'unknown tenant'; END IF;
1294
- IF v_client_kind !~ '^[a-z_]{2,32}$' THEN v_client_kind := 'customer'; END IF;
1295
-
1296
- -- resolve the professional: the one the visitor saw (validated), else the
1297
- -- first whose EXCEPTION-AWARE working hours cover the slot (007)
1298
- SELECT w.assignee_id INTO v_assignee
1299
- FROM agenda_working_windows(
1300
- p_tenant_id, (p_starts_at AT TIME ZONE v_tz)::date, p_assignee_id) w
1301
- WHERE (p_starts_at AT TIME ZONE v_tz)::time >= w.win_start
1302
- AND (v_ends AT TIME ZONE v_tz)::time <= w.win_end
1303
- ORDER BY w.assignee_id
1304
- LIMIT 1;
1305
- IF v_assignee IS NULL THEN RAISE EXCEPTION 'slot outside working hours'; END IF;
1306
-
1307
- -- serialize per (tenant, assignee) to kill the double-book race
1308
- PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_assignee::text, 0));
1309
-
1310
- IF EXISTS (
1311
- SELECT 1 FROM appointments b
1312
- WHERE b.tenant_id = p_tenant_id
1313
- AND b.assignee_id = v_assignee
1314
- AND b.status NOT IN ('cancelled', 'no_show')
1315
- AND b.starts_at < v_ends
1316
- AND COALESCE(b.ends_at, b.starts_at) > p_starts_at
1317
- ) THEN
1318
- RAISE EXCEPTION 'slot no longer available';
1319
- END IF;
1320
-
1321
- -- anti-spam: cap open future public bookings per phone per tenant
1322
- IF (
1323
- SELECT count(*) FROM appointments b
1324
- JOIN people p ON p.id = b.party_id
1325
- WHERE b.tenant_id = p_tenant_id
1326
- AND p.phone = v_phone
1327
- AND b.starts_at > now()
1328
- AND b.status = 'scheduled'
1329
- ) >= 3 THEN
1330
- RAISE EXCEPTION 'too many open bookings for this phone';
1331
- END IF;
1332
-
1333
- -- upsert the client by (tenant, kind, phone), serialized by advisory lock
1334
- -- (kind is tenant-configured, so the customer-only partial index can't
1335
- -- arbitrate)
1336
- PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || ':' || v_phone, 1));
1337
-
1338
- SELECT p.id INTO v_person_id
1339
- FROM people p
1340
- WHERE p.tenant_id = p_tenant_id AND p.kind = v_client_kind AND p.phone = v_phone
1341
- ORDER BY p.created_at
1342
- LIMIT 1;
1343
-
1344
- IF v_person_id IS NOT NULL THEN
1345
- UPDATE people SET
1346
- name = trim(p_name),
1347
- email = COALESCE(p_email, people.email),
1348
- updated_at = now()
1349
- WHERE id = v_person_id;
1350
- ELSE
1351
- INSERT INTO people (tenant_id, kind, name, phone, email, metadata)
1352
- VALUES (p_tenant_id, v_client_kind, trim(p_name), v_phone, p_email,
1353
- jsonb_build_object('source', 'public_booking'))
1354
- RETURNING id INTO v_person_id;
1355
- END IF;
1356
-
1357
- -- order + booking + order_item (statuses hardcoded — caller cannot set)
1358
- INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
1359
- VALUES (p_tenant_id, 'appointment', 'scheduled', v_person_id, v_assignee,
1360
- v_service.price, v_service.price, v_notes,
1361
- jsonb_build_object('source', 'public_booking',
1362
- 'serviceNames', v_service.name,
1363
- 'contactName', trim(p_name)))
1364
- RETURNING id INTO v_order;
1365
-
1366
- INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
1367
- starts_at, ends_at, status, notes, metadata)
1368
- VALUES (p_tenant_id, 'appointment', v_person_id, v_assignee, v_order,
1369
- p_starts_at, v_ends, 'scheduled', v_notes,
1370
- jsonb_build_object('source', 'public_booking', 'serviceNames', v_service.name))
1371
- RETURNING id INTO v_booking;
1372
-
1373
- INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
1374
- sort_order, duration_minutes, assignee_id)
1375
- VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
1376
- 0, v_service.duration_minutes, v_assignee);
1377
-
1378
- RETURN QUERY SELECT v_booking, p_starts_at, v_ends;
1379
- END;
1380
- $$;
1381
-
1382
- REVOKE ALL ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text, uuid) FROM public;
1383
- GRANT EXECUTE ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text, uuid)
1384
- TO anon, authenticated, service_role;
1385
- `
1386
-
1387
- export const MIGRATION_008_AGENT_RPC_EXCEPTION_PRECEDENCE = `-- ============================================================================
1388
- -- plugin-agenda 008: agent_agenda_create_appointment honors schedule exceptions.
1389
- --
1390
- -- Same precedence fix as 007, applied to the agent write path: professional
1391
- -- resolution now goes through agenda_working_windows (007 §1), so a date the
1392
- -- admin closed (day-off sentinel) or re-opened with custom hours ("horário
1393
- -- especial") binds the agent exactly like it binds the public site. Everything
1394
- -- else is byte-identical to 006 (LOCAL-time input, free-professional pick,
1395
- -- advisory lock, audit). Requires 007. Supersedes 006's function body.
1396
- -- ============================================================================
1397
-
1398
- CREATE OR REPLACE FUNCTION public.agent_agenda_create_appointment(
1399
- p_tenant_id uuid,
1400
- p_actor_user_id uuid,
1401
- p_payload jsonb
1402
- ) RETURNS jsonb
1403
- LANGUAGE plpgsql SECURITY DEFINER
1404
- SET search_path = public
1405
- AS $$
1406
- DECLARE
1407
- v_denial jsonb;
1408
- v_used int;
1409
- v_client_id uuid;
1410
- v_service_id uuid;
1411
- v_professional uuid;
1412
- v_starts timestamptz;
1413
- v_starts_raw text;
1414
- v_notes text;
1415
- v_service record;
1416
- v_client record;
1417
- v_ends timestamptz;
1418
- v_tz text;
1419
- v_order uuid;
1420
- v_booking uuid;
1421
- BEGIN
1422
- SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
1423
- FROM tenants t WHERE t.id = p_tenant_id;
1424
- IF v_tz IS NULL THEN
1425
- RETURN jsonb_build_object('ok', false, 'error', 'unknown tenant');
1426
- END IF;
1427
-
1428
- -- ── payload ──────────────────────────────────────────────────────────────
1429
- BEGIN
1430
- v_client_id := (p_payload->>'client_id')::uuid;
1431
- v_service_id := (p_payload->>'service_id')::uuid;
1432
- v_professional := (p_payload->>'professional_id')::uuid;
1433
- v_starts_raw := p_payload->>'starts_at';
1434
- IF v_starts_raw ~ '(Z|[+-][0-9]{2}:?[0-9]{2})$' THEN
1435
- v_starts := v_starts_raw::timestamptz; -- explicit offset
1436
- ELSE
1437
- v_starts := (v_starts_raw::timestamp) AT TIME ZONE v_tz; -- tenant-local
1438
- END IF;
1439
- EXCEPTION WHEN OTHERS THEN
1440
- RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);
1441
- END;
1442
- v_notes := left(p_payload->>'notes', 500);
1443
- IF v_client_id IS NULL OR v_service_id IS NULL OR v_starts IS NULL THEN
1444
- RETURN jsonb_build_object('ok', false, 'error', 'client_id, service_id and starts_at are required');
1445
- END IF;
1446
- IF v_starts <= now() OR v_starts > now() + interval '365 days' THEN
1447
- RETURN jsonb_build_object('ok', false, 'error', 'starts_at must be in the future (max 1 year)');
1448
- END IF;
1449
-
1450
- -- ── authorization: role → plan → bookings_month cap ──────────────────────
1451
- SELECT count(*) INTO v_used FROM appointments b
1452
- WHERE b.tenant_id = p_tenant_id
1453
- AND b.created_at >= date_trunc('month', now());
1454
- v_denial := agent_guard(p_tenant_id, p_actor_user_id,
1455
- 'appointments', 'create', 'bookings_month', v_used, 1);
1456
- IF v_denial IS NOT NULL THEN
1457
- RETURN jsonb_build_object('ok', false, 'denial', v_denial);
1458
- END IF;
1459
-
1460
- -- ── invariants ───────────────────────────────────────────────────────────
1461
- SELECT p.id, p.name INTO v_client
1462
- FROM people p
1463
- WHERE p.id = v_client_id AND p.tenant_id = p_tenant_id;
1464
- IF NOT FOUND THEN
1465
- RETURN jsonb_build_object('ok', false, 'error', 'unknown client for this tenant');
1466
- END IF;
1467
-
1468
- SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
1469
- INTO v_service
1470
- FROM services s
1471
- WHERE s.id = v_service_id AND s.tenant_id = p_tenant_id
1472
- AND s.is_active AND s.status = 'active';
1473
- IF NOT FOUND THEN
1474
- RETURN jsonb_build_object('ok', false, 'error', 'unknown or inactive service');
1475
- END IF;
1476
-
1477
- v_ends := v_starts + make_interval(mins => v_service.duration_minutes);
1478
-
1479
- -- exception-aware windows (007); still prefer a professional who is FREE at
1480
- -- the slot when the user did not name one
1481
- SELECT w.assignee_id INTO v_professional
1482
- FROM agenda_working_windows(
1483
- p_tenant_id, (v_starts AT TIME ZONE v_tz)::date, v_professional) w
1484
- WHERE (v_starts AT TIME ZONE v_tz)::time >= w.win_start
1485
- AND (v_ends AT TIME ZONE v_tz)::time <= w.win_end
1486
- AND NOT EXISTS (
1487
- SELECT 1 FROM appointments b
1488
- WHERE b.tenant_id = p_tenant_id
1489
- AND b.assignee_id = w.assignee_id
1490
- AND b.status NOT IN ('cancelled', 'no_show')
1491
- AND b.starts_at < v_ends
1492
- AND COALESCE(b.ends_at, b.starts_at) > v_starts
1493
- )
1494
- ORDER BY w.assignee_id
1495
- LIMIT 1;
1496
- IF v_professional IS NULL THEN
1497
- RETURN jsonb_build_object('ok', false, 'error',
1498
- 'no professional is available at that slot (outside working hours or already booked). Suggest a different time. Times are tenant-local, tz ' || v_tz);
1499
- END IF;
1500
-
1501
- PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_professional::text, 0));
1502
- IF EXISTS (
1503
- SELECT 1 FROM appointments b
1504
- WHERE b.tenant_id = p_tenant_id
1505
- AND b.assignee_id = v_professional
1506
- AND b.status NOT IN ('cancelled', 'no_show')
1507
- AND b.starts_at < v_ends
1508
- AND COALESCE(b.ends_at, b.starts_at) > v_starts
1509
- ) THEN
1510
- RETURN jsonb_build_object('ok', false, 'error', 'slot no longer available');
1511
- END IF;
1512
-
1513
- -- ── write + audit ────────────────────────────────────────────────────────
1514
- INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
1515
- VALUES (p_tenant_id, 'appointment', 'scheduled', v_client_id, v_professional,
1516
- v_service.price, v_service.price, v_notes,
1517
- jsonb_build_object('source', 'agent', 'serviceNames', v_service.name))
1518
- RETURNING id INTO v_order;
1519
-
1520
- INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
1521
- starts_at, ends_at, status, notes, metadata)
1522
- VALUES (p_tenant_id, 'appointment', v_client_id, v_professional, v_order,
1523
- v_starts, v_ends, 'scheduled', v_notes,
1524
- jsonb_build_object('source', 'agent', 'serviceNames', v_service.name))
1525
- RETURNING id INTO v_booking;
1526
-
1527
- INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
1528
- sort_order, duration_minutes, assignee_id)
1529
- VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
1530
- 0, v_service.duration_minutes, v_professional);
1531
-
1532
- INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
1533
- VALUES (p_tenant_id, p_actor_user_id, 'agent.createAppointment', 'appointment', v_booking::text,
1534
- jsonb_build_object('payload', p_payload, 'order_id', v_order));
1535
-
1536
- RETURN jsonb_build_object(
1537
- 'ok', true,
1538
- 'id', v_booking,
1539
- 'record', jsonb_build_object(
1540
- 'ref', jsonb_build_object('id', v_booking, 'resource', 'appointments',
1541
- 'archetype', 'schedule:appointment'),
1542
- 'starts_at', v_starts,
1543
- 'ends_at', v_ends,
1544
- 'starts_at_local', to_char(v_starts AT TIME ZONE v_tz, 'YYYY-MM-DD (Dy) HH24:MI') || ' ' || v_tz,
1545
- 'ends_at_local', to_char(v_ends AT TIME ZONE v_tz, 'HH24:MI'),
1546
- 'client_name', v_client.name,
1547
- 'service_name', v_service.name,
1548
- 'price', v_service.price,
1549
- 'status', 'scheduled'
1550
- )
1551
- );
1552
- END;
1553
- $$;
1554
-
1555
- REVOKE ALL ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb) FROM public;
1556
- REVOKE EXECUTE ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb) FROM anon;
1557
- GRANT EXECUTE ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb)
1558
- TO authenticated, service_role;
1559
- `
1560
-
1561
- export const MIGRATION_009_PUBLIC_BOOKING_LOCATION = `-- Public bookings were written with location_id = NULL. The agenda's location
1562
- -- filter is optional (default "all"), so they still rendered — but the moment
1563
- -- an operator filtered by their unit, every booking taken from the website
1564
- -- disappeared from the calendar. Single-location tenants are the common case
1565
- -- and have an unambiguous answer, so default to it; multi-location tenants
1566
- -- stay NULL (there is no correct guess) until the public flow asks.
1567
- --
1568
- -- Only the assignment is new; the rest of the body is unchanged from 004.
1569
-
1570
- CREATE OR REPLACE FUNCTION public.create_public_booking(
1571
- p_tenant_id uuid,
1572
- p_service_id uuid,
1573
- p_starts_at timestamptz,
1574
- p_name text,
1575
- p_phone text,
1576
- p_email text DEFAULT NULL,
1577
- p_notes text DEFAULT NULL
1578
- )
1579
- RETURNS TABLE(booking_id uuid, starts_at timestamptz, ends_at timestamptz)
1580
- LANGUAGE plpgsql
1581
- SECURITY DEFINER
1582
- SET search_path TO 'public'
1583
- AS $function$
1584
- DECLARE
1585
- v_service record;
1586
- v_person_id uuid;
1587
- v_assignee uuid;
1588
- v_ends timestamptz;
1589
- v_order uuid;
1590
- v_booking uuid;
1591
- v_phone text;
1592
- v_tz text;
1593
- v_notes text;
1594
- v_location uuid;
1595
- BEGIN
1596
- -- validation: anon-callable, everything tight
1597
- IF p_name IS NULL OR length(trim(p_name)) NOT BETWEEN 2 AND 120 THEN
1598
- RAISE EXCEPTION 'invalid name';
1599
- END IF;
1600
- v_phone := regexp_replace(COALESCE(p_phone, ''), '\\D', '', 'g');
1601
- IF length(v_phone) NOT BETWEEN 8 AND 15 THEN
1602
- RAISE EXCEPTION 'invalid phone';
1603
- END IF;
1604
- IF p_email IS NOT NULL AND (length(p_email) > 254 OR p_email !~ '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$') THEN
1605
- RAISE EXCEPTION 'invalid email';
1606
- END IF;
1607
- v_notes := left(p_notes, 500);
1608
- IF p_starts_at IS NULL OR p_starts_at <= now() OR p_starts_at > now() + interval '90 days' THEN
1609
- RAISE EXCEPTION 'invalid start';
1610
- END IF;
1611
-
1612
- SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
1613
- INTO v_service
1614
- FROM services s
1615
- WHERE s.id = p_service_id AND s.tenant_id = p_tenant_id
1616
- AND s.is_active AND s.status = 'active';
1617
- IF NOT FOUND THEN RAISE EXCEPTION 'unknown service'; END IF;
1618
-
1619
- v_ends := p_starts_at + make_interval(mins => v_service.duration_minutes);
1620
- SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
1621
- FROM tenants t WHERE t.id = p_tenant_id;
1622
- IF v_tz IS NULL THEN RAISE EXCEPTION 'unknown tenant'; END IF;
1623
-
1624
- -- pick the assignee whose working hours cover the slot
1625
- SELECT sc.assignee_id INTO v_assignee
1626
- FROM schedules sc
1627
- WHERE sc.tenant_id = p_tenant_id
1628
- AND sc.kind = 'working_hours'
1629
- AND sc.is_active
1630
- AND (
1631
- sc.specific_date = (p_starts_at AT TIME ZONE v_tz)::date
1632
- OR (sc.specific_date IS NULL
1633
- AND sc.day_of_week = EXTRACT(dow FROM p_starts_at AT TIME ZONE v_tz)::smallint)
1634
- )
1635
- AND (p_starts_at AT TIME ZONE v_tz)::time >= sc.starts_at
1636
- AND (v_ends AT TIME ZONE v_tz)::time <= sc.ends_at
1637
- ORDER BY sc.assignee_id
1638
- LIMIT 1;
1639
- IF v_assignee IS NULL THEN RAISE EXCEPTION 'slot outside working hours'; END IF;
1640
-
1641
- -- NEW: stamp the unit so the calendar's location filter keeps these visible.
1642
- -- Prefer the schedule's own location; else the tenant's only location.
1643
- SELECT sc.location_id INTO v_location
1644
- FROM schedules sc
1645
- WHERE sc.tenant_id = p_tenant_id
1646
- AND sc.kind = 'working_hours'
1647
- AND sc.is_active
1648
- AND sc.assignee_id = v_assignee
1649
- AND sc.location_id IS NOT NULL
1650
- LIMIT 1;
1651
-
1652
- IF v_location IS NULL THEN
1653
- SELECT l.id INTO v_location
1654
- FROM locations l
1655
- WHERE l.tenant_id = p_tenant_id
1656
- HAVING count(*) = 1; -- unambiguous only
1657
- END IF;
1658
-
1659
- -- serialize per (tenant, assignee) to kill the double-book race
1660
- PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_assignee::text, 0));
1661
-
1662
- IF EXISTS (
1663
- SELECT 1 FROM appointments b
1664
- WHERE b.tenant_id = p_tenant_id
1665
- AND b.assignee_id = v_assignee
1666
- AND b.status NOT IN ('cancelled', 'no_show')
1667
- AND b.starts_at < v_ends
1668
- AND COALESCE(b.ends_at, b.starts_at) > p_starts_at
1669
- ) THEN
1670
- RAISE EXCEPTION 'slot no longer available';
1671
- END IF;
1672
-
1673
- -- anti-spam: cap open future public bookings per phone per tenant
1674
- IF (
1675
- SELECT count(*) FROM appointments b
1676
- JOIN people p ON p.id = b.party_id
1677
- WHERE b.tenant_id = p_tenant_id
1678
- AND p.phone = v_phone
1679
- AND b.starts_at > now()
1680
- AND b.status = 'scheduled'
1681
- ) >= 3 THEN
1682
- RAISE EXCEPTION 'too many open bookings for this phone';
1683
- END IF;
1684
-
1685
- -- upsert the customer by (tenant, phone) — matches people_tenant_customer_phone
1686
- INSERT INTO people (tenant_id, kind, name, phone, email, metadata)
1687
- VALUES (p_tenant_id, 'customer', trim(p_name), v_phone, p_email,
1688
- jsonb_build_object('source', 'public_booking'))
1689
- ON CONFLICT (tenant_id, phone) WHERE kind = 'customer' AND phone IS NOT NULL
1690
- DO UPDATE SET
1691
- name = EXCLUDED.name,
1692
- email = COALESCE(EXCLUDED.email, people.email),
1693
- updated_at = now()
1694
- RETURNING id INTO v_person_id;
1695
-
1696
- -- order + booking + order_item (statuses hardcoded — caller cannot set)
1697
- INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, location_id,
1698
- subtotal, total, notes, metadata)
1699
- VALUES (p_tenant_id, 'appointment', 'scheduled', v_person_id, v_assignee, v_location,
1700
- v_service.price, v_service.price, v_notes,
1701
- jsonb_build_object('source', 'public_booking',
1702
- 'serviceNames', v_service.name,
1703
- 'contactName', trim(p_name)))
1704
- RETURNING id INTO v_order;
1705
-
1706
- INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id, location_id,
1707
- starts_at, ends_at, status, notes, metadata)
1708
- VALUES (p_tenant_id, 'appointment', v_person_id, v_assignee, v_order, v_location,
1709
- p_starts_at, v_ends, 'scheduled', v_notes,
1710
- jsonb_build_object('source', 'public_booking', 'serviceNames', v_service.name))
1711
- RETURNING id INTO v_booking;
1712
-
1713
- INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
1714
- sort_order, duration_minutes, assignee_id)
1715
- VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
1716
- 0, v_service.duration_minutes, v_assignee);
1717
-
1718
- RETURN QUERY SELECT v_booking, p_starts_at, v_ends;
1719
- END;
1720
- $function$;
1721
-
1722
- GRANT EXECUTE ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text) TO anon, authenticated;
1723
- `
1724
-
1725
- export const MIGRATION_010_PUBLIC_SERVICE_CURATION = `-- plugin-agenda 010: curate what the PUBLIC booking page sells.
1726
- --
1727
- -- v_public_services published every active service, so the website catalog was
1728
- -- "whatever exists in the system". That conflates two different questions:
1729
- --
1730
- -- • is this service usable at all? → services.is_active / status
1731
- -- • should a stranger be able to book it? → this flag
1732
- --
1733
- -- Without the split, hiding something from the site means deactivating it —
1734
- -- which also removes it from the internal agenda and orphans its history. The
1735
- -- case that forced this: a school whose public page listed 7 services when it
1736
- -- sells 3, and one of the 4 extras already had real bookings attached.
1737
- --
1738
- -- OPT-OUT on purpose: a service is public unless explicitly marked otherwise.
1739
- -- Opt-in would silently empty every live catalog the moment this ships.
1740
-
1741
- CREATE OR REPLACE VIEW public.v_public_services AS
1742
- SELECT id,
1743
- tenant_id,
1744
- name,
1745
- description,
1746
- price,
1747
- currency,
1748
- COALESCE(duration_minutes, 30) AS duration_minutes,
1749
- image_url,
1750
- COALESCE(((metadata ->> 'sort_order'::text))::integer, 0) AS sort_order
1751
- FROM services s
1752
- WHERE is_active
1753
- AND status = 'active'::text
1754
- -- absent → public (back-compat). Only an explicit false hides it.
1755
- AND COALESCE((metadata ->> 'bookableOnline'::text)::boolean, true);
1756
- `
1757
-
1758
- export const MIGRATION_011_DEFAULT_ASSIGNEE = `-- plugin-agenda 011: honour a tenant's default professional.
1759
- --
1760
- -- create_public_booking picks the assignee from whoever's working hours cover
1761
- -- the slot, ordered by assignee_id — i.e. the lowest UUID wins. That is fine
1762
- -- when the caller names the professional, and wrong when it does not: the
1763
- -- public page confirms "Equipe Great DJs" while the booking silently lands on
1764
- -- an unrelated instructor whose UUID happened to sort first.
1765
- --
1766
- -- Clients shipped before p_assignee_id existed still call this with 7 named
1767
- -- args, so the parameter defaults to NULL and every live booking on those sites
1768
- -- is mis-assigned today. Republishing the SDK fixes the caller, but the sites
1769
- -- already deployed cannot be fixed that way — this can, from the database.
1770
- --
1771
- -- Precedence: explicit arg > tenant default > first covering schedule.
1772
- -- Tenants without the setting keep exactly the previous behaviour.
1773
-
1774
- CREATE OR REPLACE FUNCTION public.create_public_booking(p_tenant_id uuid, p_service_id uuid, p_starts_at timestamp with time zone, p_name text, p_phone text, p_email text DEFAULT NULL::text, p_notes text DEFAULT NULL::text, p_assignee_id uuid DEFAULT NULL::uuid)
1775
- RETURNS TABLE(booking_id uuid, starts_at timestamp with time zone, ends_at timestamp with time zone)
1776
- LANGUAGE plpgsql
1777
- SECURITY DEFINER
1778
- SET search_path TO 'public'
1779
- AS $function$
1780
- DECLARE
1781
- v_service record;
1782
- v_person_id uuid;
1783
- v_assignee uuid;
1784
- v_requested uuid;
1785
- v_ends timestamptz;
1786
- v_order uuid;
1787
- v_booking uuid;
1788
- v_phone text;
1789
- v_tz text;
1790
- v_notes text;
1791
- v_client_kind text;
1792
- BEGIN
1793
- -- validation: anon-callable, everything tight
1794
- IF p_name IS NULL OR length(trim(p_name)) NOT BETWEEN 2 AND 120 THEN
1795
- RAISE EXCEPTION 'invalid name';
1796
- END IF;
1797
- v_phone := regexp_replace(COALESCE(p_phone, ''), '\\D', '', 'g');
1798
- IF length(v_phone) NOT BETWEEN 8 AND 15 THEN
1799
- RAISE EXCEPTION 'invalid phone';
1800
- END IF;
1801
- IF p_email IS NOT NULL AND (length(p_email) > 254 OR p_email !~ '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$') THEN
1802
- RAISE EXCEPTION 'invalid email';
1803
- END IF;
1804
- v_notes := left(p_notes, 500);
1805
- IF p_starts_at IS NULL OR p_starts_at <= now() OR p_starts_at > now() + interval '90 days' THEN
1806
- RAISE EXCEPTION 'invalid start';
1807
- END IF;
1808
-
1809
- SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
1810
- INTO v_service
1811
- FROM services s
1812
- WHERE s.id = p_service_id AND s.tenant_id = p_tenant_id
1813
- AND s.is_active AND s.status = 'active';
1814
- IF NOT FOUND THEN RAISE EXCEPTION 'unknown service'; END IF;
1815
-
1816
- v_ends := p_starts_at + make_interval(mins => v_service.duration_minutes);
1817
-
1818
- -- tenant config: timezone + client kind (admin-set; sanitized, never caller input)
1819
- SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo'),
1820
- COALESCE(NULLIF(trim(t.settings->'booking'->>'client_kind'), ''), 'customer')
1821
- INTO v_tz, v_client_kind
1822
- FROM tenants t WHERE t.id = p_tenant_id;
1823
- IF v_tz IS NULL THEN RAISE EXCEPTION 'unknown tenant'; END IF;
1824
- IF v_client_kind !~ '^[a-z_]{2,32}$' THEN v_client_kind := 'customer'; END IF;
1825
-
1826
- -- resolve the professional, in order of trust:
1827
- -- 1. the id the caller sent (the one the visitor actually saw)
1828
- -- 2. the tenant's configured default (settings->>'defaultAssigneeId')
1829
- -- 3. whoever's working hours cover the slot
1830
- --
1831
- -- Step 2 is why this migration exists. Older published clients call this with
1832
- -- 7 named args, leaving p_assignee_id NULL, and step 3 then resolved to
1833
- -- "lowest assignee UUID" — an arbitrary instructor who had nothing to do with
1834
- -- the name the site displayed on the confirmation screen. The default gives
1835
- -- those clients a correct answer without waiting for a redeploy.
1836
- v_requested := COALESCE(
1837
- p_assignee_id,
1838
- (SELECT NULLIF(t.settings ->> 'defaultAssigneeId', '')::uuid
1839
- FROM tenants t WHERE t.id = p_tenant_id)
1840
- );
1841
-
1842
- SELECT w.assignee_id INTO v_assignee
1843
- FROM agenda_working_windows(
1844
- p_tenant_id, (p_starts_at AT TIME ZONE v_tz)::date, v_requested) w
1845
- WHERE (p_starts_at AT TIME ZONE v_tz)::time >= w.win_start
1846
- AND (v_ends AT TIME ZONE v_tz)::time <= w.win_end
1847
- ORDER BY w.assignee_id
1848
- LIMIT 1;
1849
- IF v_assignee IS NULL THEN RAISE EXCEPTION 'slot outside working hours'; END IF;
1850
-
1851
- -- serialize per (tenant, assignee) to kill the double-book race
1852
- PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_assignee::text, 0));
1853
-
1854
- IF EXISTS (
1855
- SELECT 1 FROM appointments b
1856
- WHERE b.tenant_id = p_tenant_id
1857
- AND b.assignee_id = v_assignee
1858
- AND b.status NOT IN ('cancelled', 'no_show')
1859
- AND b.starts_at < v_ends
1860
- AND COALESCE(b.ends_at, b.starts_at) > p_starts_at
1861
- ) THEN
1862
- RAISE EXCEPTION 'slot no longer available';
1863
- END IF;
1864
-
1865
- -- anti-spam: cap open future public bookings per phone per tenant
1866
- IF (
1867
- SELECT count(*) FROM appointments b
1868
- JOIN people p ON p.id = b.party_id
1869
- WHERE b.tenant_id = p_tenant_id
1870
- AND p.phone = v_phone
1871
- AND b.starts_at > now()
1872
- AND b.status = 'scheduled'
1873
- ) >= 3 THEN
1874
- RAISE EXCEPTION 'too many open bookings for this phone';
1875
- END IF;
1876
-
1877
- -- upsert the client by (tenant, kind, phone), serialized by advisory lock
1878
- -- (kind is tenant-configured, so the customer-only partial index can't
1879
- -- arbitrate)
1880
- PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || ':' || v_phone, 1));
1881
-
1882
- SELECT p.id INTO v_person_id
1883
- FROM people p
1884
- WHERE p.tenant_id = p_tenant_id AND p.kind = v_client_kind AND p.phone = v_phone
1885
- ORDER BY p.created_at
1886
- LIMIT 1;
1887
-
1888
- IF v_person_id IS NOT NULL THEN
1889
- UPDATE people SET
1890
- name = trim(p_name),
1891
- email = COALESCE(p_email, people.email),
1892
- updated_at = now()
1893
- WHERE id = v_person_id;
1894
- ELSE
1895
- INSERT INTO people (tenant_id, kind, name, phone, email, metadata)
1896
- VALUES (p_tenant_id, v_client_kind, trim(p_name), v_phone, p_email,
1897
- jsonb_build_object('source', 'public_booking'))
1898
- RETURNING id INTO v_person_id;
1899
- END IF;
1900
-
1901
- -- order + booking + order_item (statuses hardcoded — caller cannot set)
1902
- INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
1903
- VALUES (p_tenant_id, 'appointment', 'scheduled', v_person_id, v_assignee,
1904
- v_service.price, v_service.price, v_notes,
1905
- jsonb_build_object('source', 'public_booking',
1906
- 'serviceNames', v_service.name,
1907
- 'contactName', trim(p_name)))
1908
- RETURNING id INTO v_order;
1909
-
1910
- INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
1911
- starts_at, ends_at, status, notes, metadata)
1912
- VALUES (p_tenant_id, 'appointment', v_person_id, v_assignee, v_order,
1913
- p_starts_at, v_ends, 'scheduled', v_notes,
1914
- jsonb_build_object('source', 'public_booking', 'serviceNames', v_service.name))
1915
- RETURNING id INTO v_booking;
1916
-
1917
- INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
1918
- sort_order, duration_minutes, assignee_id)
1919
- VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
1920
- 0, v_service.duration_minutes, v_assignee);
1921
-
1922
- RETURN QUERY SELECT v_booking, p_starts_at, v_ends;
1923
- END;
1924
- $function$;
1925
- `
1926
-
1927
- export const MIGRATION_012_DROP_LEGACY_BOOKING_OVERLOAD = `-- plugin-agenda 012: retire the pre-assignee create_public_booking overload.
1928
- --
1929
- -- ⚠️ DO NOT APPLY UNTIL @fayz-ai/plugin-agenda IS REPUBLISHED *AND* EVERY LIVE
1930
- -- SITE HAS BEEN REDEPLOYED. Applying early breaks public booking on any
1931
- -- site still serving a bundle that calls the 7-argument signature — the
1932
- -- call stops resolving and every submission fails.
1933
- --
1934
- -- Some pools carry two overloads side by side:
1935
- -- create_public_booking(uuid,uuid,timestamptz,text,text,text,text)
1936
- -- create_public_booking(uuid,uuid,timestamptz,text,text,text,text,uuid)
1937
- --
1938
- -- Nothing errors when both exist, which is exactly the problem: a stale client
1939
- -- silently resolves to the 7-arg one, which cannot know which professional the
1940
- -- visitor was shown and falls back to "lowest assignee UUID". The site confirms
1941
- -- one name, the agenda records another, and no one finds out until someone
1942
- -- compares the two screens.
1943
- --
1944
- -- 011 makes that fallback survivable (tenant default). This makes it
1945
- -- impossible — but only once no caller needs the old signature.
1946
- --
1947
- -- Verify before running:
1948
- -- 1. npm view @fayz-ai/plugin-agenda version → the republished version
1949
- -- 2. every deployed bundle greps for p_assignee_id:
1950
- -- curl -s https://<site>/assets/index-*.js | grep -c p_assignee_id
1951
- -- Each must be ≥ 2 (slots + booking). Zero means that site still needs it.
1952
-
1953
- DROP FUNCTION IF EXISTS public.create_public_booking(
1954
- uuid, uuid, timestamptz, text, text, text, text
1955
- );
1956
- `
1957
-
1958
- export const MIGRATION_013_NORMALIZE_V_APPOINTMENTS = `-- plugin-agenda 013: one booking read model — v_appointments.
1959
- --
1960
- -- Three views described the same 34 rows:
1961
- --
1962
- -- v_bookings → thin alias over the legacy view (002 compat shim)
1963
- -- v_bookings_legacy_beauty → the pre-pools shape, still feeding 5 rep_* views
1964
- -- v_appointments → the canonical name, and the only one the SDK reads
1965
- --
1966
- -- The school pool already had only v_appointments; the salon pool carried all
1967
- -- three. Same data, three names, and reports quietly wired to the oldest one.
1968
- --
1969
- -- v_appointments was a strict SUBSET, so it could not simply absorb them. It
1970
- -- gains the 7 columns it lacked — most importantly \`services\`, the order_items
1971
- -- aggregate that 4 of the 5 reports depend on. That also retires a documented
1972
- -- N+1: getBookingById fetches order_items in a second round-trip precisely
1973
- -- because this view had no line items (see plugin-agenda/src/data/supabase.ts).
1974
- --
1975
- -- Deliberately NOT copied from the legacy view:
1976
- -- • total_duration_minutes — legacy sums order_items durations, the canonical
1977
- -- measures the slot. They disagree, no report reads it, and the SDK relies
1978
- -- on the canonical meaning. Kept as-is.
1979
- -- • client_name — the canonical COALESCEs with metadata->>'title' so blocks
1980
- -- and personal events show a label instead of NULL. Kept as-is (an
1981
- -- improvement the legacy shape never had).
1982
-
1983
- -- 1. v_appointments becomes the superset. Columns are APPENDED: CREATE OR
1984
- -- REPLACE VIEW cannot reorder or drop, and the SDK selects '*'.
1985
- CREATE OR REPLACE VIEW public.v_appointments
1986
- WITH (security_invoker = true) AS
1987
- SELECT b.id,
1988
- b.tenant_id,
1989
- b.kind,
1990
- b.starts_at,
1991
- b.ends_at,
1992
- b.status,
1993
- b.notes,
1994
- b.order_id,
1995
- b.location_id,
1996
- b.metadata,
1997
- b.created_at,
1998
- b.updated_at,
1999
- b.party_id AS client_id,
2000
- COALESCE(pc.name, (b.metadata ->> 'title'::text)) AS client_name,
2001
- pc.phone AS client_phone,
2002
- pc.email AS client_email,
2003
- pc.avatar_url AS client_avatar_url,
2004
- b.assignee_id AS professional_id,
2005
- ps.name AS professional_name,
2006
- ps.avatar_url AS professional_avatar_url,
2007
- l.name AS location_name,
2008
- o.total AS order_total,
2009
- o.status AS order_status,
2010
- ((EXTRACT(epoch FROM (COALESCE(b.ends_at, b.starts_at) - b.starts_at)))::integer / 60) AS total_duration_minutes,
2011
- -- appended:
2012
- o.reference_number,
2013
- o.status AS stage,
2014
- (o.metadata ->> 'direction'::text) AS direction,
2015
- ( SELECT json_agg(json_build_object('id', oi.id, 'serviceId', oi.service_id,
2016
- 'name', oi.name, 'durationMinutes', oi.duration_minutes,
2017
- 'price', oi.unit_price, 'assigneeId', oi.assignee_id)
2018
- ORDER BY oi.sort_order)
2019
- FROM order_items oi
2020
- WHERE (oi.order_id = b.order_id)) AS services,
2021
- inv.status AS payment_status,
2022
- inv.paid AS order_paid,
2023
- inv.balance AS order_balance
2024
- FROM (((((appointments b
2025
- LEFT JOIN people pc ON ((pc.id = b.party_id)))
2026
- LEFT JOIN people ps ON ((ps.id = b.assignee_id)))
2027
- LEFT JOIN locations l ON ((l.id = b.location_id)))
2028
- LEFT JOIN orders o ON ((o.id = b.order_id)))
2029
- LEFT JOIN v_invoice_balances inv ON ((inv.invoice_id = b.order_id)));
2030
-
2031
- -- 2. Repoint the reports off the legacy view. Bodies are unchanged
2032
- -- except for the source relation.
2033
-
2034
- CREATE OR REPLACE VIEW public.rep_appointments_by_period AS
2035
- SELECT tenant_id,
2036
- id AS booking_id,
2037
- (starts_at)::date AS date,
2038
- starts_at,
2039
- client_id,
2040
- client_name,
2041
- professional_id,
2042
- professional_name,
2043
- COALESCE(((services -> 0) ->> 'name'::text), ((services -> 0) ->> 'serviceName'::text)) AS service_name,
2044
- status,
2045
- order_total AS revenue,
2046
- created_at,
2047
- updated_at
2048
- FROM v_appointments b;
2049
-
2050
- -- legacy_pre_pools only exists on pools migrated from the pre-pool era; fresh
2051
- -- pools get the same shape with confirmation columns defaulting to pending/NULL.
2052
- DO $$
2053
- BEGIN
2054
- IF to_regclass('legacy_pre_pools.appointments') IS NOT NULL THEN
2055
- EXECUTE $v$
2056
- CREATE OR REPLACE VIEW public.rep_confirmation_queue AS
2057
- SELECT b.tenant_id,
2058
- b.id AS booking_id,
2059
- (b.starts_at)::date AS date,
2060
- b.starts_at,
2061
- b.client_id,
2062
- b.client_name,
2063
- b.professional_id,
2064
- b.professional_name,
2065
- COALESCE(((b.services -> 0) ->> 'name'::text), ((b.services -> 0) ->> 'serviceName'::text)) AS service_name,
2066
- COALESCE(a.confirmation_status, 'pending'::text) AS confirmation_status,
2067
- a.confirmation_channel,
2068
- a.confirmation_sent_at,
2069
- a.confirmed_at,
2070
- b.created_at,
2071
- b.updated_at
2072
- FROM (v_appointments b
2073
- LEFT JOIN legacy_pre_pools.appointments a ON ((a.booking_id = b.id)))
2074
- WHERE ((b.status <> ALL (ARRAY['cancelled'::text, 'no_show'::text])) AND (b.starts_at >= (now() - '1 day'::interval)))
2075
- $v$;
2076
- ELSE
2077
- EXECUTE $v$
2078
- CREATE OR REPLACE VIEW public.rep_confirmation_queue AS
2079
- SELECT b.tenant_id,
2080
- b.id AS booking_id,
2081
- (b.starts_at)::date AS date,
2082
- b.starts_at,
2083
- b.client_id,
2084
- b.client_name,
2085
- b.professional_id,
2086
- b.professional_name,
2087
- COALESCE(((b.services -> 0) ->> 'name'::text), ((b.services -> 0) ->> 'serviceName'::text)) AS service_name,
2088
- 'pending'::text AS confirmation_status,
2089
- NULL::text AS confirmation_channel,
2090
- NULL::timestamptz AS confirmation_sent_at,
2091
- NULL::timestamptz AS confirmed_at,
2092
- b.created_at,
2093
- b.updated_at
2094
- FROM v_appointments b
2095
- WHERE ((b.status <> ALL (ARRAY['cancelled'::text, 'no_show'::text])) AND (b.starts_at >= (now() - '1 day'::interval)))
2096
- $v$;
2097
- END IF;
2098
- END $$;
2099
-
2100
- -- v_clients (beauty Ring-2 extension) may not exist on non-salon pools — the
2101
- -- report only makes sense where it does.
2102
- DO $$
2103
- BEGIN
2104
- IF to_regclass('public.v_clients') IS NULL THEN RETURN; END IF;
2105
- EXECUTE $v$
2106
- CREATE OR REPLACE VIEW public.rep_new_clients AS
2107
- SELECT c.tenant_id,
2108
- c.id AS client_id,
2109
- (c.created_at)::date AS date,
2110
- c.name AS client_name,
2111
- c.origin,
2112
- first_booking.service_name AS first_service,
2113
- c.created_at,
2114
- c.updated_at
2115
- FROM (v_clients c
2116
- LEFT JOIN LATERAL ( SELECT COALESCE(((b.services -> 0) ->> 'name'::text), ((b.services -> 0) ->> 'serviceName'::text)) AS service_name
2117
- FROM v_appointments b
2118
- WHERE (b.client_id = c.id)
2119
- ORDER BY b.starts_at
2120
- LIMIT 1) first_booking ON (true))
2121
- $v$;
2122
- END $$;
2123
-
2124
- CREATE OR REPLACE VIEW public.rep_revenue_by_professional AS
2125
- SELECT tenant_id,
2126
- (starts_at)::date AS date,
2127
- professional_id,
2128
- COALESCE(professional_name, 'Sem profissional'::text) AS professional_name,
2129
- (count(*))::integer AS appointment_count,
2130
- (COALESCE(sum(NULLIF(order_total, (0)::numeric)), (0)::numeric))::numeric(14,2) AS total_revenue,
2131
- (COALESCE(avg(NULLIF(order_total, (0)::numeric)), (0)::numeric))::numeric(14,2) AS avg_ticket,
2132
- (0)::numeric(14,2) AS commission,
2133
- max(updated_at) AS updated_at
2134
- FROM v_appointments b
2135
- WHERE (status <> ALL (ARRAY['cancelled'::text, 'no_show'::text]))
2136
- GROUP BY tenant_id, ((starts_at)::date), professional_id, COALESCE(professional_name, 'Sem profissional'::text);
2137
-
2138
- CREATE OR REPLACE VIEW public.rep_revenue_by_service AS
2139
- SELECT b.tenant_id,
2140
- (b.starts_at)::date AS date,
2141
- NULLIF((service_item.value ->> 'serviceId'::text), ''::text) AS service_id,
2142
- COALESCE((service_item.value ->> 'name'::text), (service_item.value ->> 'serviceName'::text), 'Sem servico'::text) AS service_name,
2143
- (count(DISTINCT b.id))::integer AS quantity,
2144
- (COALESCE(sum((NULLIF((service_item.value ->> 'price'::text), ''::text))::numeric), (0)::numeric))::numeric(14,2) AS total_revenue,
2145
- (COALESCE(avg((NULLIF((service_item.value ->> 'price'::text), ''::text))::numeric), (0)::numeric))::numeric(14,2) AS avg_ticket,
2146
- max(b.updated_at) AS updated_at
2147
- FROM (v_appointments b
2148
- CROSS JOIN LATERAL jsonb_array_elements(COALESCE((b.services)::jsonb, '[]'::jsonb)) service_item(value))
2149
- WHERE (b.status <> ALL (ARRAY['cancelled'::text, 'no_show'::text]))
2150
- GROUP BY b.tenant_id, ((b.starts_at)::date), NULLIF((service_item.value ->> 'serviceId'::text), ''::text), COALESCE((service_item.value ->> 'name'::text), (service_item.value ->> 'serviceName'::text), 'Sem servico'::text);
2151
-
2152
- -- 3. Retire the duplicates. v_bookings goes first: it reads the legacy view.
2153
- -- Nothing in the SDK references either name (checked: only migrations and a
2154
- -- README mention v_bookings).
2155
- DROP VIEW IF EXISTS public.v_bookings;
2156
- DROP VIEW IF EXISTS public.v_bookings_legacy_beauty;
2157
- `
2158
-
2159
- export const MIGRATION_014_AGENT_RPC_UPDATE_APPOINTMENT = `-- ============================================================================
2160
- -- plugin-agenda 014: agent_agenda_update_appointment — remarcar / reatribuir /
2161
- -- cancelar um agendamento existente.
2162
- --
2163
- -- Lacuna que isso fecha: o agente sabia CRIAR e não sabia MEXER. Pedido real
2164
- -- ("remarca esse paciente pra 16:30") caía no updateRecord genérico, que bate
2165
- -- em v_appointments — uma view — e devolve read_only; o modelo então "resolvia"
2166
- -- abrindo um agendamento NOVO, deixando o antigo de pé. Duplicar a agenda de um
2167
- -- salão é pior do que não fazer nada.
2168
- --
2169
- -- Mesma cadeia de guardas do 008 (agent_guard → janela de trabalho com exceções
2170
- -- → lock advisory → checagem de conflito → auditoria), com três diferenças:
2171
- -- • ação 'edit', sem cota (remarcar não consome bookings_month);
2172
- -- • o próprio agendamento é excluído da checagem de conflito consigo mesmo;
2173
- -- • cancelar não precisa de horário livre — só o guard.
2174
- -- Requer 007 (agenda_working_windows). Idempotente.
2175
- -- ============================================================================
2176
-
2177
- CREATE OR REPLACE FUNCTION public.agent_agenda_update_appointment(
2178
- p_tenant_id uuid,
2179
- p_actor_user_id uuid,
2180
- p_payload jsonb
2181
- ) RETURNS jsonb
2182
- LANGUAGE plpgsql SECURITY DEFINER
2183
- SET search_path = public
2184
- AS $$
2185
- DECLARE
2186
- v_denial jsonb;
2187
- v_id uuid;
2188
- v_tz text;
2189
- v_row record;
2190
- v_starts_raw text;
2191
- v_starts timestamptz;
2192
- v_ends timestamptz;
2193
- v_duration int;
2194
- v_professional uuid;
2195
- v_status text;
2196
- v_notes text;
2197
- v_wants_move boolean;
2198
- v_wants_status boolean;
2199
- BEGIN
2200
- SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
2201
- FROM tenants t WHERE t.id = p_tenant_id;
2202
- IF v_tz IS NULL THEN
2203
- RETURN jsonb_build_object('ok', false, 'error', 'unknown tenant');
2204
- END IF;
2205
-
2206
- -- ── payload ──────────────────────────────────────────────────────────────
2207
- BEGIN
2208
- v_id := (p_payload->>'appointment_id')::uuid;
2209
- v_professional := (p_payload->>'professional_id')::uuid;
2210
- v_starts_raw := p_payload->>'starts_at';
2211
- EXCEPTION WHEN OTHERS THEN
2212
- RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);
2213
- END;
2214
- v_status := nullif(trim(lower(p_payload->>'status')), '');
2215
- v_notes := left(p_payload->>'notes', 500);
2216
- v_wants_move := v_starts_raw IS NOT NULL OR v_professional IS NOT NULL;
2217
- v_wants_status := v_status IS NOT NULL;
2218
-
2219
- IF v_id IS NULL THEN
2220
- RETURN jsonb_build_object('ok', false, 'error',
2221
- 'appointment_id is required — search the agenda first and reuse the id of the appointment to change');
2222
- END IF;
2223
- IF NOT v_wants_move AND NOT v_wants_status AND v_notes IS NULL THEN
2224
- RETURN jsonb_build_object('ok', false, 'error',
2225
- 'nothing to change: send starts_at, professional_id, status or notes');
2226
- END IF;
2227
- IF v_status IS NOT NULL
2228
- AND v_status NOT IN ('scheduled', 'confirmed', 'in_progress', 'completed', 'cancelled', 'no_show') THEN
2229
- RETURN jsonb_build_object('ok', false, 'error',
2230
- 'status must be one of scheduled, confirmed, in_progress, completed, cancelled, no_show');
2231
- END IF;
2232
-
2233
- -- ── the appointment must exist, in THIS tenant ───────────────────────────
2234
- SELECT b.id, b.starts_at, b.ends_at, b.assignee_id, b.status, b.party_id, b.order_id
2235
- INTO v_row
2236
- FROM appointments b
2237
- WHERE b.id = v_id AND b.tenant_id = p_tenant_id;
2238
- IF NOT FOUND THEN
2239
- RETURN jsonb_build_object('ok', false, 'error', 'unknown appointment for this tenant');
2240
- END IF;
2241
-
2242
- -- ── authorization: role → plan. No cap: editing frees nothing and buys
2243
- -- nothing, so charging bookings_month for a reschedule would be wrong.
2244
- v_denial := agent_guard(p_tenant_id, p_actor_user_id, 'appointments', 'edit');
2245
- IF v_denial IS NOT NULL THEN
2246
- RETURN jsonb_build_object('ok', false, 'denial', v_denial);
2247
- END IF;
2248
-
2249
- -- Duration is preserved across a move — a 60-minute appointment stays 60.
2250
- v_duration := GREATEST(
2251
- COALESCE(EXTRACT(EPOCH FROM (v_row.ends_at - v_row.starts_at)) / 60, 30)::int, 5);
2252
- v_professional := COALESCE(v_professional, v_row.assignee_id);
2253
-
2254
- IF v_wants_move THEN
2255
- IF v_starts_raw IS NOT NULL THEN
2256
- BEGIN
2257
- IF v_starts_raw ~ '(Z|[+-][0-9]{2}:?[0-9]{2})$' THEN
2258
- v_starts := v_starts_raw::timestamptz; -- explicit offset
2259
- ELSE
2260
- v_starts := (v_starts_raw::timestamp) AT TIME ZONE v_tz; -- tenant-local
2261
- END IF;
2262
- EXCEPTION WHEN OTHERS THEN
2263
- RETURN jsonb_build_object('ok', false, 'error', 'invalid starts_at: ' || SQLERRM);
2264
- END;
2265
- ELSE
2266
- v_starts := v_row.starts_at;
2267
- END IF;
2268
- v_ends := v_starts + make_interval(mins => v_duration);
2269
-
2270
- IF v_starts > now() + interval '365 days' THEN
2271
- RETURN jsonb_build_object('ok', false, 'error', 'starts_at must be within the next year');
2272
- END IF;
2273
-
2274
- -- Working window (exception-aware, 007) for the target professional.
2275
- IF NOT EXISTS (
2276
- SELECT 1
2277
- FROM agenda_working_windows(
2278
- p_tenant_id, (v_starts AT TIME ZONE v_tz)::date, v_professional) w
2279
- WHERE w.assignee_id = v_professional
2280
- AND (v_starts AT TIME ZONE v_tz)::time >= w.win_start
2281
- AND (v_ends AT TIME ZONE v_tz)::time <= w.win_end
2282
- ) THEN
2283
- RETURN jsonb_build_object('ok', false, 'error',
2284
- 'that slot is outside the professional''s working hours. Suggest a different time. Times are tenant-local, tz ' || v_tz);
2285
- END IF;
2286
-
2287
- PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_professional::text, 0));
2288
- IF EXISTS (
2289
- SELECT 1 FROM appointments b
2290
- WHERE b.tenant_id = p_tenant_id
2291
- AND b.assignee_id = v_professional
2292
- AND b.id <> v_id -- never conflicts with itself
2293
- AND b.status NOT IN ('cancelled', 'no_show')
2294
- AND b.starts_at < v_ends
2295
- AND COALESCE(b.ends_at, b.starts_at) > v_starts
2296
- ) THEN
2297
- RETURN jsonb_build_object('ok', false, 'error',
2298
- 'that slot is already taken. Suggest a different time.');
2299
- END IF;
2300
- END IF;
2301
-
2302
- -- ── write + audit ────────────────────────────────────────────────────────
2303
- UPDATE appointments b SET
2304
- starts_at = COALESCE(v_starts, b.starts_at),
2305
- ends_at = COALESCE(v_ends, b.ends_at),
2306
- assignee_id = v_professional,
2307
- status = COALESCE(v_status, b.status),
2308
- notes = COALESCE(v_notes, b.notes),
2309
- metadata = COALESCE(b.metadata, '{}'::jsonb) || jsonb_build_object('lastAgentEdit', now()),
2310
- updated_at = now()
2311
- WHERE b.id = v_id AND b.tenant_id = p_tenant_id;
2312
-
2313
- -- The order mirrors the appointment's assignee/status so the financial side
2314
- -- does not drift from the calendar.
2315
- IF v_row.order_id IS NOT NULL THEN
2316
- UPDATE orders o SET
2317
- assignee_id = v_professional,
2318
- status = CASE WHEN v_status = 'cancelled' THEN 'cancelled' ELSE o.status END,
2319
- updated_at = now()
2320
- WHERE o.id = v_row.order_id AND o.tenant_id = p_tenant_id;
2321
- END IF;
2322
-
2323
- INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
2324
- VALUES (p_tenant_id, p_actor_user_id, 'agent.updateAppointment', 'appointment', v_id::text,
2325
- jsonb_build_object('payload', p_payload,
2326
- 'previous', jsonb_build_object(
2327
- 'starts_at', v_row.starts_at,
2328
- 'ends_at', v_row.ends_at,
2329
- 'assignee_id', v_row.assignee_id,
2330
- 'status', v_row.status)));
2331
-
2332
- SELECT a.id, a.starts_at, a.ends_at, a.status, a.client_name, a.professional_name
2333
- INTO v_row
2334
- FROM v_appointments a WHERE a.id = v_id;
2335
-
2336
- RETURN jsonb_build_object(
2337
- 'ok', true,
2338
- 'id', v_id,
2339
- 'record', jsonb_build_object(
2340
- 'ref', jsonb_build_object('id', v_id, 'resource', 'appointments',
2341
- 'archetype', 'schedule:appointment'),
2342
- 'starts_at', v_row.starts_at,
2343
- 'ends_at', v_row.ends_at,
2344
- 'starts_at_local', to_char(v_row.starts_at AT TIME ZONE v_tz, 'YYYY-MM-DD (Dy) HH24:MI') || ' ' || v_tz,
2345
- 'ends_at_local', to_char(v_row.ends_at AT TIME ZONE v_tz, 'HH24:MI'),
2346
- 'client_name', v_row.client_name,
2347
- 'professional_name', v_row.professional_name,
2348
- 'status', v_row.status
2349
- )
2350
- );
2351
- END;
2352
- $$;
2353
-
2354
- REVOKE ALL ON FUNCTION public.agent_agenda_update_appointment(uuid, uuid, jsonb) FROM public;
2355
- REVOKE EXECUTE ON FUNCTION public.agent_agenda_update_appointment(uuid, uuid, jsonb) FROM anon;
2356
- GRANT EXECUTE ON FUNCTION public.agent_agenda_update_appointment(uuid, uuid, jsonb)
2357
- TO authenticated, service_role;
2358
- `
2359
-
2360
- export const MIGRATIONS: Array<{ id: string; sql: string }> = [
2361
- { id: "000b_v_bookings_rename", sql: MIGRATION_000B_V_BOOKINGS_RENAME },
2362
- { id: "000c_dedupe_customer_phones", sql: MIGRATION_000C_DEDUPE_CUSTOMER_PHONES },
2363
- { id: "001_public_booking", sql: MIGRATION_001_PUBLIC_BOOKING },
2364
- { id: "002_v_bookings_compat", sql: MIGRATION_002_V_BOOKINGS_COMPAT },
2365
- { id: "003_public_client_kind", sql: MIGRATION_003_PUBLIC_CLIENT_KIND },
2366
- { id: "004_public_booking_assignee", sql: MIGRATION_004_PUBLIC_BOOKING_ASSIGNEE },
2367
- { id: "005_agent_rpcs", sql: MIGRATION_005_AGENT_RPCS },
2368
- { id: "006_agent_rpc_localtime", sql: MIGRATION_006_AGENT_RPC_LOCALTIME },
2369
- { id: "007_schedule_exception_precedence", sql: MIGRATION_007_SCHEDULE_EXCEPTION_PRECEDENCE },
2370
- { id: "008_agent_rpc_exception_precedence", sql: MIGRATION_008_AGENT_RPC_EXCEPTION_PRECEDENCE },
2371
- { id: "009_public_booking_location", sql: MIGRATION_009_PUBLIC_BOOKING_LOCATION },
2372
- { id: "010_public_service_curation", sql: MIGRATION_010_PUBLIC_SERVICE_CURATION },
2373
- { id: "011_default_assignee", sql: MIGRATION_011_DEFAULT_ASSIGNEE },
2374
- { id: "012_drop_legacy_booking_overload", sql: MIGRATION_012_DROP_LEGACY_BOOKING_OVERLOAD },
2375
- { id: "013_normalize_v_appointments", sql: MIGRATION_013_NORMALIZE_V_APPOINTMENTS },
2376
- { id: "014_agent_rpc_update_appointment", sql: MIGRATION_014_AGENT_RPC_UPDATE_APPOINTMENT },
2377
- ]