@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.
- package/README.md +2 -0
- package/dist/bridges/create-financial-bridge.d.ts.map +1 -1
- package/dist/config.d.ts +2 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/index.cjs +77 -65
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +77 -65
- package/dist/index.js.map +1 -1
- package/dist/migrations/index.d.ts +8 -1
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/public/createPublicBookingPlugin.d.ts.map +1 -1
- package/dist/public/index.cjs +16 -16
- package/dist/public/index.cjs.map +1 -1
- package/dist/public/index.js +17 -17
- package/dist/public/index.js.map +1 -1
- package/dist/registries.d.ts.map +1 -1
- package/dist/types.d.ts +3 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +21 -14
- package/src/bridges/create-financial-bridge.ts +6 -8
- package/src/components/AppointmentModal.tsx +4 -4
- package/src/config.ts +2 -2
- package/src/data/supabase.ts +26 -25
- package/src/index.ts +1 -1
- package/src/integrations/google-calendar/index.ts +1 -1
- package/src/integrations/google-calendar/migrations/001_google_calendar.sql +6 -6
- package/src/migrations/000b_v_bookings_rename.sql +21 -0
- package/src/migrations/000c_dedupe_customer_phones.sql +43 -0
- package/src/migrations/001_public_booking.sql +40 -27
- package/src/migrations/002_v_bookings_compat.sql +14 -0
- package/src/migrations/index.ts +134 -29
- package/src/public/createPublicBookingPlugin.ts +23 -20
- package/src/public/data.supabase.ts +4 -4
- package/src/registries.ts +1 -2
- 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–
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
25
|
-
ON
|
|
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
|
|
30
|
-
ON
|
|
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.
|
|
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
|
|
53
|
-
LEFT JOIN
|
|
54
|
-
LEFT JOIN
|
|
55
|
-
LEFT JOIN
|
|
56
|
-
LEFT JOIN
|
|
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.
|
|
59
|
-
REVOKE SELECT ON public.
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
231
|
-
JOIN
|
|
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
|
|
241
|
-
INSERT INTO
|
|
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,
|
|
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
|
|
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$;
|
package/src/migrations/index.ts
CHANGED
|
@@ -1,40 +1,122 @@
|
|
|
1
|
-
// AUTO-GENERATED from 001_public_booking.sql — regenerate with scripts/embed-migrations.mjs
|
|
2
|
-
//
|
|
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–
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
28
|
-
ON
|
|
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
|
|
33
|
-
ON
|
|
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.
|
|
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
|
|
56
|
-
LEFT JOIN
|
|
57
|
-
LEFT JOIN
|
|
58
|
-
LEFT JOIN
|
|
59
|
-
LEFT JOIN
|
|
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.
|
|
62
|
-
REVOKE SELECT ON public.
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
234
|
-
JOIN
|
|
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
|
|
244
|
-
INSERT INTO
|
|
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,
|
|
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
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
(
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
116
|
+
/** Working hours schedule (from public.schedules) */
|
|
117
117
|
export interface Schedule {
|
|
118
118
|
id: string
|
|
119
119
|
tenantId: string
|