@fayz-ai/plugin-agenda 0.2.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +2 -0
  2. package/dist/bridges/create-financial-bridge.d.ts.map +1 -1
  3. package/dist/chunk-DRZF6CV2.js +421 -0
  4. package/dist/chunk-DRZF6CV2.js.map +1 -0
  5. package/dist/chunk-LFUELUIT.cjs +423 -0
  6. package/dist/chunk-LFUELUIT.cjs.map +1 -0
  7. package/dist/config.d.ts +2 -2
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/data/supabase.d.ts.map +1 -1
  10. package/dist/index.cjs +765 -22
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +765 -22
  14. package/dist/index.js.map +1 -1
  15. package/dist/migrations/index.d.ts +9 -0
  16. package/dist/migrations/index.d.ts.map +1 -0
  17. package/dist/public/BookingWidget.d.ts +4 -2
  18. package/dist/public/BookingWidget.d.ts.map +1 -1
  19. package/dist/public/components/BrandHeader.d.ts +6 -0
  20. package/dist/public/components/BrandHeader.d.ts.map +1 -0
  21. package/dist/public/components/ServiceCard.d.ts +5 -0
  22. package/dist/public/components/ServiceCard.d.ts.map +1 -0
  23. package/dist/public/components/SidebarExtras.d.ts +4 -0
  24. package/dist/public/components/SidebarExtras.d.ts.map +1 -0
  25. package/dist/public/components/StepRailItem.d.ts +4 -0
  26. package/dist/public/components/StepRailItem.d.ts.map +1 -0
  27. package/dist/public/components/SuccessPanel.d.ts +4 -0
  28. package/dist/public/components/SuccessPanel.d.ts.map +1 -0
  29. package/dist/public/components/SummaryRail.d.ts +27 -0
  30. package/dist/public/components/SummaryRail.d.ts.map +1 -0
  31. package/dist/public/components/defaults.d.ts +3 -0
  32. package/dist/public/components/defaults.d.ts.map +1 -0
  33. package/dist/public/context.d.ts +22 -8
  34. package/dist/public/context.d.ts.map +1 -1
  35. package/dist/public/createPublicBookingPlugin.d.ts +27 -10
  36. package/dist/public/createPublicBookingPlugin.d.ts.map +1 -1
  37. package/dist/public/data.d.ts +37 -0
  38. package/dist/public/data.d.ts.map +1 -0
  39. package/dist/public/data.supabase.d.ts +7 -0
  40. package/dist/public/data.supabase.d.ts.map +1 -0
  41. package/dist/public/format.d.ts +18 -0
  42. package/dist/public/format.d.ts.map +1 -0
  43. package/dist/public/hooks.d.ts +13 -11
  44. package/dist/public/hooks.d.ts.map +1 -1
  45. package/dist/public/index.cjs +1018 -615
  46. package/dist/public/index.cjs.map +1 -1
  47. package/dist/public/index.d.ts +15 -4
  48. package/dist/public/index.d.ts.map +1 -1
  49. package/dist/public/index.js +1008 -618
  50. package/dist/public/index.js.map +1 -1
  51. package/dist/public/steps/ContactStep.d.ts +30 -0
  52. package/dist/public/steps/ContactStep.d.ts.map +1 -0
  53. package/dist/public/steps/DateTimeStep.d.ts +23 -0
  54. package/dist/public/steps/DateTimeStep.d.ts.map +1 -0
  55. package/dist/public/steps/OverviewStep.d.ts +16 -0
  56. package/dist/public/steps/OverviewStep.d.ts.map +1 -0
  57. package/dist/public/steps/PaymentStep.d.ts +20 -0
  58. package/dist/public/steps/PaymentStep.d.ts.map +1 -0
  59. package/dist/public/types.d.ts +134 -0
  60. package/dist/public/types.d.ts.map +1 -1
  61. package/dist/registries.d.ts.map +1 -1
  62. package/dist/types.d.ts +3 -3
  63. package/dist/types.d.ts.map +1 -1
  64. package/package.json +11 -5
  65. package/src/bridges/create-financial-bridge.ts +6 -8
  66. package/src/components/AppointmentModal.tsx +4 -4
  67. package/src/config.ts +2 -2
  68. package/src/data/supabase.ts +26 -25
  69. package/src/index.ts +11 -0
  70. package/src/integrations/google-calendar/index.ts +1 -1
  71. package/src/integrations/google-calendar/migrations/001_google_calendar.sql +6 -6
  72. package/src/migrations/000b_v_bookings_rename.sql +21 -0
  73. package/src/migrations/000c_dedupe_customer_phones.sql +43 -0
  74. package/src/migrations/001_public_booking.sql +291 -0
  75. package/src/migrations/002_v_bookings_compat.sql +14 -0
  76. package/src/migrations/index.ts +387 -0
  77. package/src/public/BookingWidget.tsx +136 -713
  78. package/src/public/components/BrandHeader.tsx +27 -0
  79. package/src/public/components/ServiceCard.tsx +39 -0
  80. package/src/public/components/SidebarExtras.tsx +30 -0
  81. package/src/public/components/StepRailItem.tsx +27 -0
  82. package/src/public/components/SuccessPanel.tsx +27 -0
  83. package/src/public/components/SummaryRail.tsx +90 -0
  84. package/src/public/components/defaults.ts +14 -0
  85. package/src/public/context.tsx +64 -7
  86. package/src/public/createPublicBookingPlugin.ts +138 -25
  87. package/src/public/data.supabase.ts +94 -0
  88. package/src/public/data.ts +99 -0
  89. package/src/public/format.ts +61 -0
  90. package/src/public/hooks.ts +22 -34
  91. package/src/public/index.ts +57 -4
  92. package/src/public/steps/ContactStep.tsx +179 -0
  93. package/src/public/steps/DateTimeStep.tsx +138 -0
  94. package/src/public/steps/OverviewStep.tsx +81 -0
  95. package/src/public/steps/PaymentStep.tsx +180 -0
  96. package/src/public/types.ts +169 -3
  97. package/src/registries.ts +1 -2
  98. package/src/types.ts +4 -4
  99. package/dist/chunk-GAJVIEXM.cjs +0 -870
  100. package/dist/chunk-GAJVIEXM.cjs.map +0 -1
  101. package/dist/chunk-U6VTLOHN.js +0 -867
  102. package/dist/chunk-U6VTLOHN.js.map +0 -1
@@ -0,0 +1,387 @@
1
+ // AUTO-GENERATED from 000b_v_bookings_rename.sql, 000c_dedupe_customer_phones.sql, 001_public_booking.sql, 002_v_bookings_compat.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
+ CREATE OR REPLACE VIEW public.v_appointments
120
+ WITH (security_invoker = true) AS
121
+ SELECT
122
+ b.id, b.tenant_id, b.kind, b.starts_at, b.ends_at, b.status, b.notes,
123
+ b.order_id, b.location_id, b.metadata, b.created_at, b.updated_at,
124
+ b.party_id AS client_id,
125
+ COALESCE(pc.name, b.metadata->>'title') AS client_name,
126
+ pc.phone AS client_phone,
127
+ pc.email AS client_email,
128
+ pc.avatar_url AS client_avatar_url,
129
+ b.assignee_id AS professional_id,
130
+ ps.name AS professional_name,
131
+ ps.avatar_url AS professional_avatar_url,
132
+ l.name AS location_name,
133
+ o.total AS order_total,
134
+ o.status AS order_status,
135
+ (EXTRACT(EPOCH FROM (COALESCE(b.ends_at, b.starts_at) - b.starts_at))::int / 60)
136
+ AS total_duration_minutes
137
+ FROM public.appointments b
138
+ LEFT JOIN public.people pc ON pc.id = b.party_id
139
+ LEFT JOIN public.people ps ON ps.id = b.assignee_id
140
+ LEFT JOIN public.locations l ON l.id = b.location_id
141
+ LEFT JOIN public.orders o ON o.id = b.order_id;
142
+
143
+ GRANT SELECT ON public.v_appointments TO authenticated, service_role;
144
+ REVOKE SELECT ON public.v_appointments FROM anon;
145
+
146
+ -- §3 — anon-readable public catalog (owner-rights view = deliberate, whitelisted)
147
+ CREATE OR REPLACE VIEW public.v_public_services AS
148
+ SELECT
149
+ s.id, s.tenant_id, s.name, s.description, s.price, s.currency,
150
+ COALESCE(s.duration_minutes, 30) AS duration_minutes,
151
+ s.image_url,
152
+ COALESCE((s.metadata->>'sort_order')::int, 0) AS sort_order
153
+ FROM public.services s
154
+ WHERE s.is_active AND s.status = 'active';
155
+
156
+ GRANT SELECT ON public.v_public_services TO anon, authenticated, service_role;
157
+
158
+ -- §4 — availability RPC (mirrors plugin-agenda/src/data/mock.ts getAvailableSlots)
159
+ CREATE OR REPLACE FUNCTION public.get_available_slots(
160
+ p_tenant_id uuid,
161
+ p_date date,
162
+ p_duration_minutes integer,
163
+ p_assignee_id uuid DEFAULT NULL,
164
+ p_slot_interval integer DEFAULT 30
165
+ ) RETURNS TABLE (slot_start timestamptz, slot_end timestamptz)
166
+ LANGUAGE plpgsql STABLE SECURITY DEFINER
167
+ SET search_path = public
168
+ AS $$
169
+ DECLARE
170
+ v_tz text;
171
+ BEGIN
172
+ -- tight validation: this function is anon-callable
173
+ IF p_duration_minutes IS NULL OR p_duration_minutes NOT BETWEEN 5 AND 480 THEN
174
+ RAISE EXCEPTION 'invalid duration';
175
+ END IF;
176
+ IF p_slot_interval IS NULL OR p_slot_interval NOT BETWEEN 5 AND 240 THEN
177
+ RAISE EXCEPTION 'invalid interval';
178
+ END IF;
179
+ IF p_date IS NULL OR p_date < current_date OR p_date > current_date + 90 THEN
180
+ RETURN;
181
+ END IF;
182
+
183
+ SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
184
+ FROM tenants t WHERE t.id = p_tenant_id;
185
+ IF v_tz IS NULL THEN RETURN; END IF; -- unknown tenant → empty, not an error
186
+
187
+ RETURN QUERY
188
+ WITH day_schedules AS (
189
+ SELECT sc.assignee_id, sc.starts_at AS win_start, sc.ends_at AS win_end
190
+ FROM schedules sc
191
+ WHERE sc.tenant_id = p_tenant_id
192
+ AND sc.kind = 'working_hours'
193
+ AND sc.is_active
194
+ AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)
195
+ AND (
196
+ sc.specific_date = p_date
197
+ OR (sc.specific_date IS NULL AND sc.day_of_week = EXTRACT(dow FROM p_date)::smallint)
198
+ )
199
+ ),
200
+ candidate AS (
201
+ SELECT
202
+ ds.assignee_id,
203
+ gs AS s_start,
204
+ gs + make_interval(mins => p_duration_minutes) AS s_end
205
+ FROM day_schedules ds
206
+ CROSS JOIN LATERAL generate_series(
207
+ (p_date + ds.win_start) AT TIME ZONE v_tz,
208
+ ((p_date + ds.win_end) AT TIME ZONE v_tz) - make_interval(mins => p_duration_minutes),
209
+ make_interval(mins => p_slot_interval)
210
+ ) AS gs
211
+ )
212
+ SELECT DISTINCT c.s_start, c.s_end
213
+ FROM candidate c
214
+ WHERE NOT EXISTS (
215
+ SELECT 1 FROM appointments b
216
+ WHERE b.tenant_id = p_tenant_id
217
+ AND b.assignee_id = c.assignee_id
218
+ AND b.status NOT IN ('cancelled', 'no_show')
219
+ AND b.starts_at < c.s_end
220
+ AND COALESCE(b.ends_at, b.starts_at) > c.s_start
221
+ )
222
+ ORDER BY 1;
223
+ END;
224
+ $$;
225
+
226
+ REVOKE ALL ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer) FROM public;
227
+ GRANT EXECUTE ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer)
228
+ TO anon, authenticated, service_role;
229
+
230
+ -- §5 — public booking write RPC (validated, race-safe, hardcoded statuses)
231
+ CREATE OR REPLACE FUNCTION public.create_public_booking(
232
+ p_tenant_id uuid,
233
+ p_service_id uuid,
234
+ p_starts_at timestamptz,
235
+ p_name text,
236
+ p_phone text,
237
+ p_email text DEFAULT NULL,
238
+ p_notes text DEFAULT NULL
239
+ ) RETURNS TABLE (booking_id uuid, starts_at timestamptz, ends_at timestamptz)
240
+ LANGUAGE plpgsql SECURITY DEFINER
241
+ SET search_path = public
242
+ AS $$
243
+ DECLARE
244
+ v_service record;
245
+ v_person_id uuid;
246
+ v_assignee uuid;
247
+ v_ends timestamptz;
248
+ v_order uuid;
249
+ v_booking uuid;
250
+ v_phone text;
251
+ v_tz text;
252
+ v_notes text;
253
+ BEGIN
254
+ -- validation: anon-callable, everything tight
255
+ IF p_name IS NULL OR length(trim(p_name)) NOT BETWEEN 2 AND 120 THEN
256
+ RAISE EXCEPTION 'invalid name';
257
+ END IF;
258
+ v_phone := regexp_replace(COALESCE(p_phone, ''), '\\D', '', 'g');
259
+ IF length(v_phone) NOT BETWEEN 8 AND 15 THEN
260
+ RAISE EXCEPTION 'invalid phone';
261
+ END IF;
262
+ IF p_email IS NOT NULL AND (length(p_email) > 254 OR p_email !~ '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$') THEN
263
+ RAISE EXCEPTION 'invalid email';
264
+ END IF;
265
+ v_notes := left(p_notes, 500);
266
+ IF p_starts_at IS NULL OR p_starts_at <= now() OR p_starts_at > now() + interval '90 days' THEN
267
+ RAISE EXCEPTION 'invalid start';
268
+ END IF;
269
+
270
+ SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
271
+ INTO v_service
272
+ FROM services s
273
+ WHERE s.id = p_service_id AND s.tenant_id = p_tenant_id
274
+ AND s.is_active AND s.status = 'active';
275
+ IF NOT FOUND THEN RAISE EXCEPTION 'unknown service'; END IF;
276
+
277
+ v_ends := p_starts_at + make_interval(mins => v_service.duration_minutes);
278
+ SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
279
+ FROM tenants t WHERE t.id = p_tenant_id;
280
+ IF v_tz IS NULL THEN RAISE EXCEPTION 'unknown tenant'; END IF;
281
+
282
+ -- pick the assignee whose working hours cover the slot
283
+ SELECT sc.assignee_id INTO v_assignee
284
+ FROM schedules sc
285
+ WHERE sc.tenant_id = p_tenant_id
286
+ AND sc.kind = 'working_hours'
287
+ AND sc.is_active
288
+ AND (
289
+ sc.specific_date = (p_starts_at AT TIME ZONE v_tz)::date
290
+ OR (sc.specific_date IS NULL
291
+ AND sc.day_of_week = EXTRACT(dow FROM p_starts_at AT TIME ZONE v_tz)::smallint)
292
+ )
293
+ AND (p_starts_at AT TIME ZONE v_tz)::time >= sc.starts_at
294
+ AND (v_ends AT TIME ZONE v_tz)::time <= sc.ends_at
295
+ ORDER BY sc.assignee_id
296
+ LIMIT 1;
297
+ IF v_assignee IS NULL THEN RAISE EXCEPTION 'slot outside working hours'; END IF;
298
+
299
+ -- serialize per (tenant, assignee) to kill the double-book race
300
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_assignee::text, 0));
301
+
302
+ IF EXISTS (
303
+ SELECT 1 FROM appointments b
304
+ WHERE b.tenant_id = p_tenant_id
305
+ AND b.assignee_id = v_assignee
306
+ AND b.status NOT IN ('cancelled', 'no_show')
307
+ AND b.starts_at < v_ends
308
+ AND COALESCE(b.ends_at, b.starts_at) > p_starts_at
309
+ ) THEN
310
+ RAISE EXCEPTION 'slot no longer available';
311
+ END IF;
312
+
313
+ -- anti-spam: cap open future public bookings per phone per tenant
314
+ IF (
315
+ SELECT count(*) FROM appointments b
316
+ JOIN people p ON p.id = b.party_id
317
+ WHERE b.tenant_id = p_tenant_id
318
+ AND p.phone = v_phone
319
+ AND b.starts_at > now()
320
+ AND b.status = 'scheduled'
321
+ ) >= 3 THEN
322
+ RAISE EXCEPTION 'too many open bookings for this phone';
323
+ END IF;
324
+
325
+ -- upsert the customer by (tenant, phone) — matches people_tenant_customer_phone
326
+ INSERT INTO people (tenant_id, kind, name, phone, email, metadata)
327
+ VALUES (p_tenant_id, 'customer', trim(p_name), v_phone, p_email,
328
+ jsonb_build_object('source', 'public_booking'))
329
+ ON CONFLICT (tenant_id, phone) WHERE kind = 'customer' AND phone IS NOT NULL
330
+ DO UPDATE SET
331
+ name = EXCLUDED.name,
332
+ email = COALESCE(EXCLUDED.email, people.email),
333
+ updated_at = now()
334
+ RETURNING id INTO v_person_id;
335
+
336
+ -- order + booking + order_item (statuses hardcoded — caller cannot set)
337
+ INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
338
+ VALUES (p_tenant_id, 'appointment', 'scheduled', v_person_id, v_assignee,
339
+ v_service.price, v_service.price, v_notes,
340
+ jsonb_build_object('source', 'public_booking',
341
+ 'serviceNames', v_service.name,
342
+ 'contactName', trim(p_name)))
343
+ RETURNING id INTO v_order;
344
+
345
+ INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
346
+ starts_at, ends_at, status, notes, metadata)
347
+ VALUES (p_tenant_id, 'appointment', v_person_id, v_assignee, v_order,
348
+ p_starts_at, v_ends, 'scheduled', v_notes,
349
+ jsonb_build_object('source', 'public_booking', 'serviceNames', v_service.name))
350
+ RETURNING id INTO v_booking;
351
+
352
+ INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
353
+ sort_order, duration_minutes, assignee_id)
354
+ VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
355
+ 0, v_service.duration_minutes, v_assignee);
356
+
357
+ RETURN QUERY SELECT v_booking, p_starts_at, v_ends;
358
+ END;
359
+ $$;
360
+
361
+ REVOKE ALL ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text) FROM public;
362
+ GRANT EXECUTE ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text)
363
+ TO anon, authenticated, service_role;
364
+ `
365
+
366
+ export const MIGRATION_002_V_BOOKINGS_COMPAT = `-- Where 000b preserved the legacy beauty view, re-create v_bookings as a
367
+ -- thin alias over it so live pre-M5 clients that SELECT v_bookings by name
368
+ -- keep their full legacy column set. Dropped for good once the beauty app
369
+ -- ships on v_appointments (M5 follow-up).
370
+
371
+ DO $compat$
372
+ BEGIN
373
+ IF to_regclass('public.v_bookings_legacy_beauty') IS NOT NULL
374
+ AND to_regclass('public.v_bookings') IS NULL THEN
375
+ EXECUTE 'CREATE VIEW public.v_bookings WITH (security_invoker = true) AS SELECT * FROM public.v_bookings_legacy_beauty';
376
+ EXECUTE 'GRANT SELECT ON public.v_bookings TO authenticated, service_role';
377
+ EXECUTE 'REVOKE SELECT ON public.v_bookings FROM anon';
378
+ END IF;
379
+ END $compat$;
380
+ `
381
+
382
+ export const MIGRATIONS: Array<{ id: string; sql: string }> = [
383
+ { id: "000b_v_bookings_rename", sql: MIGRATION_000B_V_BOOKINGS_RENAME },
384
+ { id: "000c_dedupe_customer_phones", sql: MIGRATION_000C_DEDUPE_CUSTOMER_PHONES },
385
+ { id: "001_public_booking", sql: MIGRATION_001_PUBLIC_BOOKING },
386
+ { id: "002_v_bookings_compat", sql: MIGRATION_002_V_BOOKINGS_COMPAT },
387
+ ]