@fayz-ai/plugin-agenda 0.3.0 → 0.8.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 (36) hide show
  1. package/README.md +2 -0
  2. package/dist/bridges/create-financial-bridge.d.ts.map +1 -1
  3. package/dist/config.d.ts +2 -2
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/data/supabase.d.ts.map +1 -1
  6. package/dist/index.cjs +77 -65
  7. package/dist/index.cjs.map +1 -1
  8. package/dist/index.js +77 -65
  9. package/dist/index.js.map +1 -1
  10. package/dist/migrations/index.d.ts +8 -1
  11. package/dist/migrations/index.d.ts.map +1 -1
  12. package/dist/public/createPublicBookingPlugin.d.ts.map +1 -1
  13. package/dist/public/index.cjs +16 -16
  14. package/dist/public/index.cjs.map +1 -1
  15. package/dist/public/index.js +17 -17
  16. package/dist/public/index.js.map +1 -1
  17. package/dist/registries.d.ts.map +1 -1
  18. package/dist/types.d.ts +3 -3
  19. package/dist/types.d.ts.map +1 -1
  20. package/package.json +21 -14
  21. package/src/bridges/create-financial-bridge.ts +6 -8
  22. package/src/components/AppointmentModal.tsx +4 -4
  23. package/src/config.ts +2 -2
  24. package/src/data/supabase.ts +26 -25
  25. package/src/index.ts +1 -1
  26. package/src/integrations/google-calendar/index.ts +1 -1
  27. package/src/integrations/google-calendar/migrations/001_google_calendar.sql +6 -6
  28. package/src/migrations/000b_v_bookings_rename.sql +21 -0
  29. package/src/migrations/000c_dedupe_customer_phones.sql +43 -0
  30. package/src/migrations/001_public_booking.sql +40 -27
  31. package/src/migrations/002_v_bookings_compat.sql +14 -0
  32. package/src/migrations/index.ts +134 -29
  33. package/src/public/createPublicBookingPlugin.ts +23 -20
  34. package/src/public/data.supabase.ts +4 -4
  35. package/src/registries.ts +1 -2
  36. package/src/types.ts +4 -4
@@ -2,36 +2,49 @@
2
2
  -- plugin-agenda 001: booking read model + public (anon) booking surface
3
3
  --
4
4
  -- Ships the objects the agenda providers reference but the spine (packages/db
5
- -- migrations 001–008) does not define:
5
+ -- migrations 001–010) does not define:
6
+ -- §0 converted-pool cleanup — drop the pre-rename v_bookings view / indexes
6
7
  -- §1 order_items columns the admin provider writes (duration/assignee)
7
- -- §2 public.v_bookings — admin read model (authenticated, RLS applies)
8
+ -- §2 public.v_appointments — admin read model (authenticated, RLS applies)
8
9
  -- §3 public.v_public_services — anon-readable service catalog
9
10
  -- §4 public.get_available_slots — anon RPC (mirrors the mock algorithm)
10
11
  -- §5 public.create_public_booking — anon RPC (validated, race-safe write)
11
12
  --
13
+ -- Core v1 lives directly in PUBLIC (no separate core schema): people (was
14
+ -- persons), appointments (was bookings). This migration is written against public
15
+ -- names and is idempotent + safe on both fresh and converted pools.
16
+ --
12
17
  -- Anon-access design: public booking pages run with the publishable key and no
13
18
  -- session, so the canonical tenant RLS yields nothing. Anon access is granted
14
19
  -- ONLY via the column-whitelisted view (§3) and SECURITY DEFINER RPCs (§4, §5)
15
20
  -- with tight input validation and hardcoded statuses.
16
21
  -- ============================================================================
17
22
 
23
+ -- §0 — converted-pool cleanup. The pre-industry-pool shape shipped a v_bookings
24
+ -- view and persons_/bookings_-named indexes; drop them so this file re-emits the
25
+ -- canonical v_appointments view + people_/appointments_ indexes without leaving
26
+ -- duplicates. All guarded with IF EXISTS → no-ops on a fresh pool.
27
+ DROP VIEW IF EXISTS public.v_bookings;
28
+ DROP INDEX IF EXISTS public.persons_tenant_customer_phone;
29
+ DROP INDEX IF EXISTS public.bookings_assignee_time;
30
+
18
31
  -- §1 — columns the agenda provider writes but 004_archetypes lacks
19
- ALTER TABLE saas_core.order_items
32
+ ALTER TABLE public.order_items
20
33
  ADD COLUMN IF NOT EXISTS duration_minutes integer,
21
- ADD COLUMN IF NOT EXISTS assignee_id uuid REFERENCES saas_core.persons(id) ON DELETE SET NULL;
34
+ ADD COLUMN IF NOT EXISTS assignee_id uuid REFERENCES public.people(id) ON DELETE SET NULL;
22
35
 
23
36
  -- Upsert key for public customers (per tenant, by normalized phone)
24
- CREATE UNIQUE INDEX IF NOT EXISTS persons_tenant_customer_phone
25
- ON saas_core.persons(tenant_id, phone)
37
+ CREATE UNIQUE INDEX IF NOT EXISTS people_tenant_customer_phone
38
+ ON public.people(tenant_id, phone)
26
39
  WHERE kind = 'customer' AND phone IS NOT NULL;
27
40
 
28
41
  -- Slot-conflict scan
29
- CREATE INDEX IF NOT EXISTS bookings_assignee_time
30
- ON saas_core.bookings(assignee_id, starts_at, ends_at)
42
+ CREATE INDEX IF NOT EXISTS appointments_assignee_time
43
+ ON public.appointments(assignee_id, starts_at, ends_at)
31
44
  WHERE status NOT IN ('cancelled', 'no_show');
32
45
 
33
46
  -- §2 — admin read model (security_invoker → underlying RLS applies; anon sees 0 rows)
34
- CREATE OR REPLACE VIEW public.v_bookings
47
+ CREATE OR REPLACE VIEW public.v_appointments
35
48
  WITH (security_invoker = true) AS
36
49
  SELECT
37
50
  b.id, b.tenant_id, b.kind, b.starts_at, b.ends_at, b.status, b.notes,
@@ -49,14 +62,14 @@ SELECT
49
62
  o.status AS order_status,
50
63
  (EXTRACT(EPOCH FROM (COALESCE(b.ends_at, b.starts_at) - b.starts_at))::int / 60)
51
64
  AS total_duration_minutes
52
- FROM saas_core.bookings b
53
- LEFT JOIN saas_core.persons pc ON pc.id = b.party_id
54
- LEFT JOIN saas_core.persons ps ON ps.id = b.assignee_id
55
- LEFT JOIN saas_core.locations l ON l.id = b.location_id
56
- LEFT JOIN saas_core.orders o ON o.id = b.order_id;
65
+ FROM public.appointments b
66
+ LEFT JOIN public.people pc ON pc.id = b.party_id
67
+ LEFT JOIN public.people ps ON ps.id = b.assignee_id
68
+ LEFT JOIN public.locations l ON l.id = b.location_id
69
+ LEFT JOIN public.orders o ON o.id = b.order_id;
57
70
 
58
- GRANT SELECT ON public.v_bookings TO authenticated, service_role;
59
- REVOKE SELECT ON public.v_bookings FROM anon;
71
+ GRANT SELECT ON public.v_appointments TO authenticated, service_role;
72
+ REVOKE SELECT ON public.v_appointments FROM anon;
60
73
 
61
74
  -- §3 — anon-readable public catalog (owner-rights view = deliberate, whitelisted)
62
75
  CREATE OR REPLACE VIEW public.v_public_services AS
@@ -65,7 +78,7 @@ SELECT
65
78
  COALESCE(s.duration_minutes, 30) AS duration_minutes,
66
79
  s.image_url,
67
80
  COALESCE((s.metadata->>'sort_order')::int, 0) AS sort_order
68
- FROM saas_core.services s
81
+ FROM public.services s
69
82
  WHERE s.is_active AND s.status = 'active';
70
83
 
71
84
  GRANT SELECT ON public.v_public_services TO anon, authenticated, service_role;
@@ -79,7 +92,7 @@ CREATE OR REPLACE FUNCTION public.get_available_slots(
79
92
  p_slot_interval integer DEFAULT 30
80
93
  ) RETURNS TABLE (slot_start timestamptz, slot_end timestamptz)
81
94
  LANGUAGE plpgsql STABLE SECURITY DEFINER
82
- SET search_path = saas_core, public
95
+ SET search_path = public
83
96
  AS $$
84
97
  DECLARE
85
98
  v_tz text;
@@ -127,7 +140,7 @@ BEGIN
127
140
  SELECT DISTINCT c.s_start, c.s_end
128
141
  FROM candidate c
129
142
  WHERE NOT EXISTS (
130
- SELECT 1 FROM bookings b
143
+ SELECT 1 FROM appointments b
131
144
  WHERE b.tenant_id = p_tenant_id
132
145
  AND b.assignee_id = c.assignee_id
133
146
  AND b.status NOT IN ('cancelled', 'no_show')
@@ -153,7 +166,7 @@ CREATE OR REPLACE FUNCTION public.create_public_booking(
153
166
  p_notes text DEFAULT NULL
154
167
  ) RETURNS TABLE (booking_id uuid, starts_at timestamptz, ends_at timestamptz)
155
168
  LANGUAGE plpgsql SECURITY DEFINER
156
- SET search_path = saas_core, public
169
+ SET search_path = public
157
170
  AS $$
158
171
  DECLARE
159
172
  v_service record;
@@ -215,7 +228,7 @@ BEGIN
215
228
  PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_assignee::text, 0));
216
229
 
217
230
  IF EXISTS (
218
- SELECT 1 FROM bookings b
231
+ SELECT 1 FROM appointments b
219
232
  WHERE b.tenant_id = p_tenant_id
220
233
  AND b.assignee_id = v_assignee
221
234
  AND b.status NOT IN ('cancelled', 'no_show')
@@ -227,8 +240,8 @@ BEGIN
227
240
 
228
241
  -- anti-spam: cap open future public bookings per phone per tenant
229
242
  IF (
230
- SELECT count(*) FROM bookings b
231
- JOIN persons p ON p.id = b.party_id
243
+ SELECT count(*) FROM appointments b
244
+ JOIN people p ON p.id = b.party_id
232
245
  WHERE b.tenant_id = p_tenant_id
233
246
  AND p.phone = v_phone
234
247
  AND b.starts_at > now()
@@ -237,14 +250,14 @@ BEGIN
237
250
  RAISE EXCEPTION 'too many open bookings for this phone';
238
251
  END IF;
239
252
 
240
- -- upsert the customer by (tenant, phone) — matches persons_tenant_customer_phone
241
- INSERT INTO persons (tenant_id, kind, name, phone, email, metadata)
253
+ -- upsert the customer by (tenant, phone) — matches people_tenant_customer_phone
254
+ INSERT INTO people (tenant_id, kind, name, phone, email, metadata)
242
255
  VALUES (p_tenant_id, 'customer', trim(p_name), v_phone, p_email,
243
256
  jsonb_build_object('source', 'public_booking'))
244
257
  ON CONFLICT (tenant_id, phone) WHERE kind = 'customer' AND phone IS NOT NULL
245
258
  DO UPDATE SET
246
259
  name = EXCLUDED.name,
247
- email = COALESCE(EXCLUDED.email, persons.email),
260
+ email = COALESCE(EXCLUDED.email, people.email),
248
261
  updated_at = now()
249
262
  RETURNING id INTO v_person_id;
250
263
 
@@ -257,7 +270,7 @@ BEGIN
257
270
  'contactName', trim(p_name)))
258
271
  RETURNING id INTO v_order;
259
272
 
260
- INSERT INTO bookings (tenant_id, kind, party_id, assignee_id, order_id,
273
+ INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
261
274
  starts_at, ends_at, status, notes, metadata)
262
275
  VALUES (p_tenant_id, 'appointment', v_person_id, v_assignee, v_order,
263
276
  p_starts_at, v_ends, 'scheduled', v_notes,
@@ -0,0 +1,14 @@
1
+ -- Where 000b preserved the legacy beauty view, re-create v_bookings as a
2
+ -- thin alias over it so live pre-M5 clients that SELECT v_bookings by name
3
+ -- keep their full legacy column set. Dropped for good once the beauty app
4
+ -- ships on v_appointments (M5 follow-up).
5
+
6
+ DO $compat$
7
+ BEGIN
8
+ IF to_regclass('public.v_bookings_legacy_beauty') IS NOT NULL
9
+ AND to_regclass('public.v_bookings') IS NULL THEN
10
+ EXECUTE 'CREATE VIEW public.v_bookings WITH (security_invoker = true) AS SELECT * FROM public.v_bookings_legacy_beauty';
11
+ EXECUTE 'GRANT SELECT ON public.v_bookings TO authenticated, service_role';
12
+ EXECUTE 'REVOKE SELECT ON public.v_bookings FROM anon';
13
+ END IF;
14
+ END $compat$;
@@ -1,40 +1,122 @@
1
- // AUTO-GENERATED from 001_public_booking.sql — regenerate with scripts/embed-migrations.mjs
2
- // (kept in sync so the manifest can declare migrations as data, plugin-tasks pattern)
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
+ `
3
72
 
4
73
  export const MIGRATION_001_PUBLIC_BOOKING = `-- ============================================================================
5
74
  -- plugin-agenda 001: booking read model + public (anon) booking surface
6
75
  --
7
76
  -- Ships the objects the agenda providers reference but the spine (packages/db
8
- -- migrations 001–008) does not define:
77
+ -- migrations 001–010) does not define:
78
+ -- §0 converted-pool cleanup — drop the pre-rename v_bookings view / indexes
9
79
  -- §1 order_items columns the admin provider writes (duration/assignee)
10
- -- §2 public.v_bookings — admin read model (authenticated, RLS applies)
80
+ -- §2 public.v_appointments — admin read model (authenticated, RLS applies)
11
81
  -- §3 public.v_public_services — anon-readable service catalog
12
82
  -- §4 public.get_available_slots — anon RPC (mirrors the mock algorithm)
13
83
  -- §5 public.create_public_booking — anon RPC (validated, race-safe write)
14
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
+ --
15
89
  -- Anon-access design: public booking pages run with the publishable key and no
16
90
  -- session, so the canonical tenant RLS yields nothing. Anon access is granted
17
91
  -- ONLY via the column-whitelisted view (§3) and SECURITY DEFINER RPCs (§4, §5)
18
92
  -- with tight input validation and hardcoded statuses.
19
93
  -- ============================================================================
20
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
+
21
103
  -- §1 — columns the agenda provider writes but 004_archetypes lacks
22
- ALTER TABLE saas_core.order_items
104
+ ALTER TABLE public.order_items
23
105
  ADD COLUMN IF NOT EXISTS duration_minutes integer,
24
- ADD COLUMN IF NOT EXISTS assignee_id uuid REFERENCES saas_core.persons(id) ON DELETE SET NULL;
106
+ ADD COLUMN IF NOT EXISTS assignee_id uuid REFERENCES public.people(id) ON DELETE SET NULL;
25
107
 
26
108
  -- Upsert key for public customers (per tenant, by normalized phone)
27
- CREATE UNIQUE INDEX IF NOT EXISTS persons_tenant_customer_phone
28
- ON saas_core.persons(tenant_id, phone)
109
+ CREATE UNIQUE INDEX IF NOT EXISTS people_tenant_customer_phone
110
+ ON public.people(tenant_id, phone)
29
111
  WHERE kind = 'customer' AND phone IS NOT NULL;
30
112
 
31
113
  -- Slot-conflict scan
32
- CREATE INDEX IF NOT EXISTS bookings_assignee_time
33
- ON saas_core.bookings(assignee_id, starts_at, ends_at)
114
+ CREATE INDEX IF NOT EXISTS appointments_assignee_time
115
+ ON public.appointments(assignee_id, starts_at, ends_at)
34
116
  WHERE status NOT IN ('cancelled', 'no_show');
35
117
 
36
118
  -- §2 — admin read model (security_invoker → underlying RLS applies; anon sees 0 rows)
37
- CREATE OR REPLACE VIEW public.v_bookings
119
+ CREATE OR REPLACE VIEW public.v_appointments
38
120
  WITH (security_invoker = true) AS
39
121
  SELECT
40
122
  b.id, b.tenant_id, b.kind, b.starts_at, b.ends_at, b.status, b.notes,
@@ -52,14 +134,14 @@ SELECT
52
134
  o.status AS order_status,
53
135
  (EXTRACT(EPOCH FROM (COALESCE(b.ends_at, b.starts_at) - b.starts_at))::int / 60)
54
136
  AS total_duration_minutes
55
- FROM saas_core.bookings b
56
- LEFT JOIN saas_core.persons pc ON pc.id = b.party_id
57
- LEFT JOIN saas_core.persons ps ON ps.id = b.assignee_id
58
- LEFT JOIN saas_core.locations l ON l.id = b.location_id
59
- LEFT JOIN saas_core.orders o ON o.id = b.order_id;
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;
60
142
 
61
- GRANT SELECT ON public.v_bookings TO authenticated, service_role;
62
- REVOKE SELECT ON public.v_bookings FROM anon;
143
+ GRANT SELECT ON public.v_appointments TO authenticated, service_role;
144
+ REVOKE SELECT ON public.v_appointments FROM anon;
63
145
 
64
146
  -- §3 — anon-readable public catalog (owner-rights view = deliberate, whitelisted)
65
147
  CREATE OR REPLACE VIEW public.v_public_services AS
@@ -68,7 +150,7 @@ SELECT
68
150
  COALESCE(s.duration_minutes, 30) AS duration_minutes,
69
151
  s.image_url,
70
152
  COALESCE((s.metadata->>'sort_order')::int, 0) AS sort_order
71
- FROM saas_core.services s
153
+ FROM public.services s
72
154
  WHERE s.is_active AND s.status = 'active';
73
155
 
74
156
  GRANT SELECT ON public.v_public_services TO anon, authenticated, service_role;
@@ -82,7 +164,7 @@ CREATE OR REPLACE FUNCTION public.get_available_slots(
82
164
  p_slot_interval integer DEFAULT 30
83
165
  ) RETURNS TABLE (slot_start timestamptz, slot_end timestamptz)
84
166
  LANGUAGE plpgsql STABLE SECURITY DEFINER
85
- SET search_path = saas_core, public
167
+ SET search_path = public
86
168
  AS $$
87
169
  DECLARE
88
170
  v_tz text;
@@ -130,7 +212,7 @@ BEGIN
130
212
  SELECT DISTINCT c.s_start, c.s_end
131
213
  FROM candidate c
132
214
  WHERE NOT EXISTS (
133
- SELECT 1 FROM bookings b
215
+ SELECT 1 FROM appointments b
134
216
  WHERE b.tenant_id = p_tenant_id
135
217
  AND b.assignee_id = c.assignee_id
136
218
  AND b.status NOT IN ('cancelled', 'no_show')
@@ -156,7 +238,7 @@ CREATE OR REPLACE FUNCTION public.create_public_booking(
156
238
  p_notes text DEFAULT NULL
157
239
  ) RETURNS TABLE (booking_id uuid, starts_at timestamptz, ends_at timestamptz)
158
240
  LANGUAGE plpgsql SECURITY DEFINER
159
- SET search_path = saas_core, public
241
+ SET search_path = public
160
242
  AS $$
161
243
  DECLARE
162
244
  v_service record;
@@ -218,7 +300,7 @@ BEGIN
218
300
  PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_assignee::text, 0));
219
301
 
220
302
  IF EXISTS (
221
- SELECT 1 FROM bookings b
303
+ SELECT 1 FROM appointments b
222
304
  WHERE b.tenant_id = p_tenant_id
223
305
  AND b.assignee_id = v_assignee
224
306
  AND b.status NOT IN ('cancelled', 'no_show')
@@ -230,8 +312,8 @@ BEGIN
230
312
 
231
313
  -- anti-spam: cap open future public bookings per phone per tenant
232
314
  IF (
233
- SELECT count(*) FROM bookings b
234
- JOIN persons p ON p.id = b.party_id
315
+ SELECT count(*) FROM appointments b
316
+ JOIN people p ON p.id = b.party_id
235
317
  WHERE b.tenant_id = p_tenant_id
236
318
  AND p.phone = v_phone
237
319
  AND b.starts_at > now()
@@ -240,14 +322,14 @@ BEGIN
240
322
  RAISE EXCEPTION 'too many open bookings for this phone';
241
323
  END IF;
242
324
 
243
- -- upsert the customer by (tenant, phone) — matches persons_tenant_customer_phone
244
- INSERT INTO persons (tenant_id, kind, name, phone, email, metadata)
325
+ -- upsert the customer by (tenant, phone) — matches people_tenant_customer_phone
326
+ INSERT INTO people (tenant_id, kind, name, phone, email, metadata)
245
327
  VALUES (p_tenant_id, 'customer', trim(p_name), v_phone, p_email,
246
328
  jsonb_build_object('source', 'public_booking'))
247
329
  ON CONFLICT (tenant_id, phone) WHERE kind = 'customer' AND phone IS NOT NULL
248
330
  DO UPDATE SET
249
331
  name = EXCLUDED.name,
250
- email = COALESCE(EXCLUDED.email, persons.email),
332
+ email = COALESCE(EXCLUDED.email, people.email),
251
333
  updated_at = now()
252
334
  RETURNING id INTO v_person_id;
253
335
 
@@ -260,7 +342,7 @@ BEGIN
260
342
  'contactName', trim(p_name)))
261
343
  RETURNING id INTO v_order;
262
344
 
263
- INSERT INTO bookings (tenant_id, kind, party_id, assignee_id, order_id,
345
+ INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
264
346
  starts_at, ends_at, status, notes, metadata)
265
347
  VALUES (p_tenant_id, 'appointment', v_person_id, v_assignee, v_order,
266
348
  p_starts_at, v_ends, 'scheduled', v_notes,
@@ -280,3 +362,26 @@ REVOKE ALL ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, tex
280
362
  GRANT EXECUTE ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text)
281
363
  TO anon, authenticated, service_role;
282
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
+ ]
@@ -1,7 +1,6 @@
1
1
  import { createElement, type FC, type ReactNode } from 'react'
2
2
  import { CalendarCheck, ShieldCheck, Video } from 'lucide-react'
3
3
  import type { PluginManifest, PluginScope, VerticalId, PaymentProvider } from '@fayz-ai/core'
4
- import { createSafeDataProvider } from '@fayz-ai/core'
5
4
  import type { Professional, Schedule } from '../types'
6
5
  import type { MockAgendaSeed } from '../data/mock'
7
6
  import { createMockPublicBookingProvider, type PublicBookingDataProvider } from './data'
@@ -202,24 +201,28 @@ export function createPublicBookingPlugin(options: PublicBookingOptions): Public
202
201
  bookings: [],
203
202
  }
204
203
 
205
- const provider =
206
- options.dataProvider ??
207
- createSafeDataProvider<PublicBookingDataProvider>(
208
- () => {
209
- if (!options.tenantId) {
210
- throw new Error(
211
- '[plugin-agenda/public] `tenantId` is required when a Supabase client is configured (setGlobalSupabaseClient).',
212
- )
213
- }
214
- return createSupabasePublicBookingProvider({ tenantId: options.tenantId })
215
- },
216
- () =>
217
- createMockPublicBookingProvider({
218
- seed: mockSeed,
219
- services: staticServices ?? [],
220
- professionalId: mockProfessionalId,
221
- }),
222
- )
204
+ // Resolution: explicit dataProvider → Supabase whenever a tenantId is
205
+ // declared (the app's global client if registered, else Fayz Cloud — the
206
+ // shared hosted backend) → seeded mock. The mock fallback is LOUD on
207
+ // purpose: the old silent fallback hid missing-env bugs as empty catalogs.
208
+ let provider: PublicBookingDataProvider
209
+ if (options.dataProvider) {
210
+ provider = options.dataProvider
211
+ } else if (options.tenantId) {
212
+ provider = createSupabasePublicBookingProvider({ tenantId: options.tenantId })
213
+ } else {
214
+ if (typeof console !== 'undefined') {
215
+ console.warn(
216
+ '[plugin-agenda/public] no `tenantId` and no `dataProvider` — falling back to the seeded MOCK provider. ' +
217
+ 'Pass `tenantId` to book against Fayz Cloud, or inject a dataProvider.',
218
+ )
219
+ }
220
+ provider = createMockPublicBookingProvider({
221
+ seed: mockSeed,
222
+ services: staticServices ?? [],
223
+ professionalId: mockProfessionalId,
224
+ })
225
+ }
223
226
 
224
227
  const base: Omit<PublicBookingContextValue, 'services' | 'servicesLoading'> = {
225
228
  provider,
@@ -250,7 +253,7 @@ export function createPublicBookingPlugin(options: PublicBookingOptions): Public
250
253
  id: 'booking',
251
254
  name: 'Agendamento',
252
255
  icon: 'CalendarCheck',
253
- version: '0.3.0',
256
+ version: '0.4.0',
254
257
  scope: options.scope ?? 'universal',
255
258
  verticalId: options.verticalId,
256
259
  scaffolds: ['website', 'landing_page'],
@@ -11,7 +11,7 @@
11
11
  // Shipped by plugins/plugin-agenda/src/migrations/001_public_booking.sql.
12
12
  // ---------------------------------------------------------------------------
13
13
 
14
- import { getSupabaseClientOptional } from '@fayz-ai/core'
14
+ import { getFayzCloudClient, getSupabaseClientOptional } from '@fayz-ai/core'
15
15
  import type { TimeSlot } from '../types'
16
16
  import type { PublicService } from './types'
17
17
  import type { PublicBookingDataProvider } from './data'
@@ -26,10 +26,10 @@ interface SupabaseLikeClient {
26
26
  rpc(fn: string, args: Record<string, unknown>): PromiseLike<{ data: unknown; error: { message: string } | null }>
27
27
  }
28
28
 
29
+ // App-registered client (BYO backend) wins; otherwise Fayz Cloud — the shared
30
+ // hosted project every tenantId-only app books against by default.
29
31
  function getClient(): SupabaseLikeClient {
30
- const client = getSupabaseClientOptional() as SupabaseLikeClient | null
31
- if (!client) throw new Error('[plugin-agenda/public] Supabase client not initialized')
32
- return client
32
+ return (getSupabaseClientOptional() ?? getFayzCloudClient()) as SupabaseLikeClient
33
33
  }
34
34
 
35
35
  /** Digits-only phone (RPC normalizes again server-side; keep the dial code). */
package/src/registries.ts CHANGED
@@ -2,7 +2,7 @@ import type { PluginRegistryDef } from '@fayz-ai/core'
2
2
  import type { EntityDef } from '@fayz-ai/core'
3
3
 
4
4
  // ---------------------------------------------------------------------------
5
- // Holidays (saas_core.holidays — tenant-scoped)
5
+ // Holidays (public.holidays — tenant-scoped)
6
6
  // ---------------------------------------------------------------------------
7
7
 
8
8
  const holidayEntity: EntityDef = {
@@ -21,7 +21,6 @@ const holidayEntity: EntityDef = {
21
21
  ],
22
22
  data: {
23
23
  table: 'holidays',
24
- schema: 'saas_core',
25
24
  tenantScoped: true,
26
25
  },
27
26
  }
package/src/types.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // Agenda Plugin — Pure TypeScript types
3
- // Zero dependencies. Maps to saas_core archetype tables.
3
+ // Zero dependencies. Maps to public archetype tables.
4
4
  // ---------------------------------------------------------------------------
5
5
 
6
6
  // ============================================================
@@ -46,7 +46,7 @@ export interface BookingTypeConfig {
46
46
  // CORE ENTITIES
47
47
  // ============================================================
48
48
 
49
- /** A booking service line item (from saas_core.booking_items) */
49
+ /** A booking service line item (from public.appointment_items) */
50
50
  export interface BookingService {
51
51
  id: string
52
52
  serviceId: string | null
@@ -56,7 +56,7 @@ export interface BookingService {
56
56
  assigneeId: string | null
57
57
  }
58
58
 
59
- /** Calendar booking — the main entity rendered on the calendar (from v_bookings view on orders) */
59
+ /** Calendar booking — the main entity rendered on the calendar (from v_appointments view on orders) */
60
60
  export interface CalendarBooking {
61
61
  id: string
62
62
  tenantId: string
@@ -113,7 +113,7 @@ export interface Professional {
113
113
  isActive: boolean
114
114
  }
115
115
 
116
- /** Working hours schedule (from saas_core.schedules) */
116
+ /** Working hours schedule (from public.schedules) */
117
117
  export interface Schedule {
118
118
  id: string
119
119
  tenantId: string