@fayz-ai/plugin-agenda 0.8.0 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-LFUELUIT.cjs → chunk-3KWNHV5A.cjs} +60 -2
- package/dist/chunk-3KWNHV5A.cjs.map +1 -0
- package/dist/{chunk-DRZF6CV2.js → chunk-YQO6B4G3.js} +60 -3
- package/dist/chunk-YQO6B4G3.js.map +1 -0
- package/dist/components/AgendaGeneralSettings.d.ts.map +1 -1
- package/dist/components/AppointmentModal.d.ts.map +1 -1
- package/dist/components/CalendarToolbar.d.ts.map +1 -1
- package/dist/data/supabase.d.ts +5 -1
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/index.cjs +1071 -766
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +914 -609
- package/dist/index.js.map +1 -1
- package/dist/integrations/google-calendar/components/CalendarColorDot.d.ts +6 -0
- package/dist/integrations/google-calendar/components/CalendarColorDot.d.ts.map +1 -0
- package/dist/integrations/google-calendar/components/ChannelMappingList.d.ts +9 -0
- package/dist/integrations/google-calendar/components/ChannelMappingList.d.ts.map +1 -0
- package/dist/integrations/google-calendar/components/ImportEventsWizard.d.ts +12 -0
- package/dist/integrations/google-calendar/components/ImportEventsWizard.d.ts.map +1 -0
- package/dist/integrations/google-calendar/components/useAgendaLookups.d.ts +26 -0
- package/dist/integrations/google-calendar/components/useAgendaLookups.d.ts.map +1 -0
- package/dist/integrations/google-calendar/connectorDef.d.ts.map +1 -1
- package/dist/integrations/google-calendar/data/supabase.d.ts +33 -1
- package/dist/integrations/google-calendar/data/supabase.d.ts.map +1 -1
- package/dist/integrations/google-calendar/data/tables.d.ts +6 -0
- package/dist/integrations/google-calendar/data/tables.d.ts.map +1 -0
- package/dist/integrations/google-calendar/index.d.ts +1 -1
- package/dist/integrations/google-calendar/index.d.ts.map +1 -1
- package/dist/integrations/google-calendar/mapping.d.ts +56 -4
- package/dist/integrations/google-calendar/mapping.d.ts.map +1 -1
- package/dist/integrations/google-calendar/types.d.ts +101 -0
- package/dist/integrations/google-calendar/types.d.ts.map +1 -1
- package/dist/locales/en.d.ts.map +1 -1
- package/dist/locales/pt-BR.d.ts.map +1 -1
- package/dist/migrations/index.d.ts +11 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/public/BookingWidget.d.ts.map +1 -1
- package/dist/public/context.d.ts +3 -1
- package/dist/public/context.d.ts.map +1 -1
- package/dist/public/createPublicBookingPlugin.d.ts +7 -1
- package/dist/public/createPublicBookingPlugin.d.ts.map +1 -1
- package/dist/public/data.d.ts +6 -0
- package/dist/public/data.d.ts.map +1 -1
- package/dist/public/data.supabase.d.ts.map +1 -1
- package/dist/public/format.d.ts +22 -1
- package/dist/public/format.d.ts.map +1 -1
- package/dist/public/hooks.d.ts.map +1 -1
- package/dist/public/index.cjs +31 -63
- package/dist/public/index.cjs.map +1 -1
- package/dist/public/index.d.ts +1 -1
- package/dist/public/index.d.ts.map +1 -1
- package/dist/public/index.js +27 -61
- package/dist/public/index.js.map +1 -1
- package/dist/public/steps/ContactStep.d.ts +4 -2
- package/dist/public/steps/ContactStep.d.ts.map +1 -1
- package/dist/public/types.d.ts +16 -0
- package/dist/public/types.d.ts.map +1 -1
- package/dist/views/CalendarView.d.ts.map +1 -1
- package/package.json +6 -5
- package/src/components/AgendaGeneralSettings.tsx +38 -16
- package/src/components/AppointmentModal.tsx +50 -108
- package/src/components/CalendarToolbar.tsx +10 -7
- package/src/data/supabase.ts +67 -13
- package/src/index.ts +56 -8
- package/src/integrations/google-calendar/components/CalendarColorDot.tsx +23 -0
- package/src/integrations/google-calendar/components/ChannelMappingList.tsx +329 -0
- package/src/integrations/google-calendar/components/ImportEventsWizard.tsx +215 -0
- package/src/integrations/google-calendar/components/useAgendaLookups.ts +100 -0
- package/src/integrations/google-calendar/connectorDef.tsx +123 -40
- package/src/integrations/google-calendar/data/supabase.ts +109 -13
- package/src/integrations/google-calendar/data/tables.ts +12 -0
- package/src/integrations/google-calendar/index.ts +10 -1
- package/src/integrations/google-calendar/integration.test.ts +126 -1
- package/src/integrations/google-calendar/mapping.ts +149 -9
- package/src/integrations/google-calendar/migrations/001_google_calendar.sql +9 -6
- package/src/integrations/google-calendar/migrations/002_smart_sync.sql +517 -0
- package/src/integrations/google-calendar/migrations/003_plg_rename.sql +273 -0
- package/src/integrations/google-calendar/types.ts +111 -0
- package/src/locales/en.ts +1 -0
- package/src/locales/pt-BR.ts +1 -0
- package/src/migrations/003_public_client_kind.sql +175 -0
- package/src/migrations/004_public_booking_assignee.sql +176 -0
- package/src/migrations/005_agent_rpcs.sql +168 -0
- package/src/migrations/006_agent_rpc_localtime.sql +181 -0
- package/src/migrations/007_schedule_exception_precedence.sql +290 -0
- package/src/migrations/008_agent_rpc_exception_precedence.sql +172 -0
- package/src/migrations/009_public_booking_location.sql +162 -0
- package/src/migrations/010_public_service_curation.sql +31 -0
- package/src/migrations/011_default_assignee.sql +167 -0
- package/src/migrations/012_drop_legacy_booking_overload.sql +29 -0
- package/src/migrations/013_normalize_v_appointments.sql +161 -0
- package/src/migrations/index.ts +1746 -1
- package/src/public/BookingWidget.tsx +10 -1
- package/src/public/context.tsx +3 -0
- package/src/public/createPublicBookingPlugin.ts +14 -1
- package/src/public/data.supabase.ts +3 -0
- package/src/public/data.ts +6 -0
- package/src/public/format.ts +36 -1
- package/src/public/hooks.ts +7 -2
- package/src/public/index.ts +1 -0
- package/src/public/steps/ContactStep.tsx +10 -5
- package/src/public/types.ts +21 -0
- package/src/views/CalendarView.tsx +15 -1
- package/dist/chunk-3LJKZQK4.js +0 -12
- package/dist/chunk-3LJKZQK4.js.map +0 -1
- package/dist/chunk-DRZF6CV2.js.map +0 -1
- package/dist/chunk-LFUELUIT.cjs.map +0 -1
- package/dist/chunk-SYDBDI3Q.cjs +0 -15
- package/dist/chunk-SYDBDI3Q.cjs.map +0 -1
- package/dist/components/SearchCombobox.d.ts +0 -38
- package/dist/components/SearchCombobox.d.ts.map +0 -1
- package/dist/tenant-222EHYUC.cjs +0 -16
- package/dist/tenant-222EHYUC.cjs.map +0 -1
- package/dist/tenant-LAUWVUYB.js +0 -3
- package/dist/tenant-LAUWVUYB.js.map +0 -1
- package/src/components/SearchCombobox.tsx +0 -216
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- Public booking — respect the professional the visitor saw
|
|
3
|
+
-- ---------------------------------------------------------------------------
|
|
4
|
+
-- Until now create_public_booking IGNORED which professional the site showed
|
|
5
|
+
-- availability for and re-picked by working hours (ORDER BY assignee_id
|
|
6
|
+
-- LIMIT 1). With several professionals covering the same slot the booking
|
|
7
|
+
-- landed on someone else than displayed ("Equipe Great DJs" on the site,
|
|
8
|
+
-- "Rafa Groove" in the agenda). New optional p_assignee_id:
|
|
9
|
+
-- * provided → validate THAT professional's working hours cover the slot and
|
|
10
|
+
-- book them — never silently reassign (mismatch becomes an explicit error);
|
|
11
|
+
-- * NULL → auto-pick as before (sites that don't pin a professional).
|
|
12
|
+
-- Conflict check + advisory lock already run against the resolved assignee, so
|
|
13
|
+
-- the double-book guard follows whichever path resolves.
|
|
14
|
+
--
|
|
15
|
+
-- Signature change (8th arg): the old 7-arg function is DROPPED first —
|
|
16
|
+
-- CREATE OR REPLACE with a different signature would create an ambiguous
|
|
17
|
+
-- PostgREST overload instead of replacing.
|
|
18
|
+
-- Includes 003's tenant-configurable client kind. Idempotent.
|
|
19
|
+
-- ---------------------------------------------------------------------------
|
|
20
|
+
DROP FUNCTION IF EXISTS public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text);
|
|
21
|
+
|
|
22
|
+
CREATE OR REPLACE FUNCTION public.create_public_booking(
|
|
23
|
+
p_tenant_id uuid,
|
|
24
|
+
p_service_id uuid,
|
|
25
|
+
p_starts_at timestamptz,
|
|
26
|
+
p_name text,
|
|
27
|
+
p_phone text,
|
|
28
|
+
p_email text DEFAULT NULL,
|
|
29
|
+
p_notes text DEFAULT NULL,
|
|
30
|
+
p_assignee_id uuid DEFAULT NULL
|
|
31
|
+
) RETURNS TABLE (booking_id uuid, starts_at timestamptz, ends_at timestamptz)
|
|
32
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
33
|
+
SET search_path = public
|
|
34
|
+
AS $$
|
|
35
|
+
DECLARE
|
|
36
|
+
v_service record;
|
|
37
|
+
v_person_id uuid;
|
|
38
|
+
v_assignee uuid;
|
|
39
|
+
v_ends timestamptz;
|
|
40
|
+
v_order uuid;
|
|
41
|
+
v_booking uuid;
|
|
42
|
+
v_phone text;
|
|
43
|
+
v_tz text;
|
|
44
|
+
v_notes text;
|
|
45
|
+
v_client_kind text;
|
|
46
|
+
BEGIN
|
|
47
|
+
-- validation: anon-callable, everything tight
|
|
48
|
+
IF p_name IS NULL OR length(trim(p_name)) NOT BETWEEN 2 AND 120 THEN
|
|
49
|
+
RAISE EXCEPTION 'invalid name';
|
|
50
|
+
END IF;
|
|
51
|
+
v_phone := regexp_replace(COALESCE(p_phone, ''), '\D', '', 'g');
|
|
52
|
+
IF length(v_phone) NOT BETWEEN 8 AND 15 THEN
|
|
53
|
+
RAISE EXCEPTION 'invalid phone';
|
|
54
|
+
END IF;
|
|
55
|
+
IF p_email IS NOT NULL AND (length(p_email) > 254 OR p_email !~ '^[^@\s]+@[^@\s]+\.[^@\s]+$') THEN
|
|
56
|
+
RAISE EXCEPTION 'invalid email';
|
|
57
|
+
END IF;
|
|
58
|
+
v_notes := left(p_notes, 500);
|
|
59
|
+
IF p_starts_at IS NULL OR p_starts_at <= now() OR p_starts_at > now() + interval '90 days' THEN
|
|
60
|
+
RAISE EXCEPTION 'invalid start';
|
|
61
|
+
END IF;
|
|
62
|
+
|
|
63
|
+
SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
|
|
64
|
+
INTO v_service
|
|
65
|
+
FROM services s
|
|
66
|
+
WHERE s.id = p_service_id AND s.tenant_id = p_tenant_id
|
|
67
|
+
AND s.is_active AND s.status = 'active';
|
|
68
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'unknown service'; END IF;
|
|
69
|
+
|
|
70
|
+
v_ends := p_starts_at + make_interval(mins => v_service.duration_minutes);
|
|
71
|
+
|
|
72
|
+
-- tenant config: timezone + client kind (admin-set; sanitized, never caller input)
|
|
73
|
+
SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo'),
|
|
74
|
+
COALESCE(NULLIF(trim(t.settings->'booking'->>'client_kind'), ''), 'customer')
|
|
75
|
+
INTO v_tz, v_client_kind
|
|
76
|
+
FROM tenants t WHERE t.id = p_tenant_id;
|
|
77
|
+
IF v_tz IS NULL THEN RAISE EXCEPTION 'unknown tenant'; END IF;
|
|
78
|
+
IF v_client_kind !~ '^[a-z_]{2,32}$' THEN v_client_kind := 'customer'; END IF;
|
|
79
|
+
|
|
80
|
+
-- resolve the professional: the one the visitor saw (validated), else the
|
|
81
|
+
-- first whose working hours cover the slot
|
|
82
|
+
SELECT sc.assignee_id INTO v_assignee
|
|
83
|
+
FROM schedules sc
|
|
84
|
+
WHERE sc.tenant_id = p_tenant_id
|
|
85
|
+
AND sc.kind = 'working_hours'
|
|
86
|
+
AND sc.is_active
|
|
87
|
+
AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)
|
|
88
|
+
AND (
|
|
89
|
+
sc.specific_date = (p_starts_at AT TIME ZONE v_tz)::date
|
|
90
|
+
OR (sc.specific_date IS NULL
|
|
91
|
+
AND sc.day_of_week = EXTRACT(dow FROM p_starts_at AT TIME ZONE v_tz)::smallint)
|
|
92
|
+
)
|
|
93
|
+
AND (p_starts_at AT TIME ZONE v_tz)::time >= sc.starts_at
|
|
94
|
+
AND (v_ends AT TIME ZONE v_tz)::time <= sc.ends_at
|
|
95
|
+
ORDER BY sc.assignee_id
|
|
96
|
+
LIMIT 1;
|
|
97
|
+
IF v_assignee IS NULL THEN RAISE EXCEPTION 'slot outside working hours'; END IF;
|
|
98
|
+
|
|
99
|
+
-- serialize per (tenant, assignee) to kill the double-book race
|
|
100
|
+
PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_assignee::text, 0));
|
|
101
|
+
|
|
102
|
+
IF EXISTS (
|
|
103
|
+
SELECT 1 FROM appointments b
|
|
104
|
+
WHERE b.tenant_id = p_tenant_id
|
|
105
|
+
AND b.assignee_id = v_assignee
|
|
106
|
+
AND b.status NOT IN ('cancelled', 'no_show')
|
|
107
|
+
AND b.starts_at < v_ends
|
|
108
|
+
AND COALESCE(b.ends_at, b.starts_at) > p_starts_at
|
|
109
|
+
) THEN
|
|
110
|
+
RAISE EXCEPTION 'slot no longer available';
|
|
111
|
+
END IF;
|
|
112
|
+
|
|
113
|
+
-- anti-spam: cap open future public bookings per phone per tenant
|
|
114
|
+
IF (
|
|
115
|
+
SELECT count(*) FROM appointments b
|
|
116
|
+
JOIN people p ON p.id = b.party_id
|
|
117
|
+
WHERE b.tenant_id = p_tenant_id
|
|
118
|
+
AND p.phone = v_phone
|
|
119
|
+
AND b.starts_at > now()
|
|
120
|
+
AND b.status = 'scheduled'
|
|
121
|
+
) >= 3 THEN
|
|
122
|
+
RAISE EXCEPTION 'too many open bookings for this phone';
|
|
123
|
+
END IF;
|
|
124
|
+
|
|
125
|
+
-- upsert the client by (tenant, kind, phone), serialized by advisory lock
|
|
126
|
+
-- (kind is tenant-configured, so the customer-only partial index can't
|
|
127
|
+
-- arbitrate)
|
|
128
|
+
PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || ':' || v_phone, 1));
|
|
129
|
+
|
|
130
|
+
SELECT p.id INTO v_person_id
|
|
131
|
+
FROM people p
|
|
132
|
+
WHERE p.tenant_id = p_tenant_id AND p.kind = v_client_kind AND p.phone = v_phone
|
|
133
|
+
ORDER BY p.created_at
|
|
134
|
+
LIMIT 1;
|
|
135
|
+
|
|
136
|
+
IF v_person_id IS NOT NULL THEN
|
|
137
|
+
UPDATE people SET
|
|
138
|
+
name = trim(p_name),
|
|
139
|
+
email = COALESCE(p_email, people.email),
|
|
140
|
+
updated_at = now()
|
|
141
|
+
WHERE id = v_person_id;
|
|
142
|
+
ELSE
|
|
143
|
+
INSERT INTO people (tenant_id, kind, name, phone, email, metadata)
|
|
144
|
+
VALUES (p_tenant_id, v_client_kind, trim(p_name), v_phone, p_email,
|
|
145
|
+
jsonb_build_object('source', 'public_booking'))
|
|
146
|
+
RETURNING id INTO v_person_id;
|
|
147
|
+
END IF;
|
|
148
|
+
|
|
149
|
+
-- order + booking + order_item (statuses hardcoded — caller cannot set)
|
|
150
|
+
INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
|
|
151
|
+
VALUES (p_tenant_id, 'appointment', 'scheduled', v_person_id, v_assignee,
|
|
152
|
+
v_service.price, v_service.price, v_notes,
|
|
153
|
+
jsonb_build_object('source', 'public_booking',
|
|
154
|
+
'serviceNames', v_service.name,
|
|
155
|
+
'contactName', trim(p_name)))
|
|
156
|
+
RETURNING id INTO v_order;
|
|
157
|
+
|
|
158
|
+
INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
|
|
159
|
+
starts_at, ends_at, status, notes, metadata)
|
|
160
|
+
VALUES (p_tenant_id, 'appointment', v_person_id, v_assignee, v_order,
|
|
161
|
+
p_starts_at, v_ends, 'scheduled', v_notes,
|
|
162
|
+
jsonb_build_object('source', 'public_booking', 'serviceNames', v_service.name))
|
|
163
|
+
RETURNING id INTO v_booking;
|
|
164
|
+
|
|
165
|
+
INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
|
|
166
|
+
sort_order, duration_minutes, assignee_id)
|
|
167
|
+
VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
|
|
168
|
+
0, v_service.duration_minutes, v_assignee);
|
|
169
|
+
|
|
170
|
+
RETURN QUERY SELECT v_booking, p_starts_at, v_ends;
|
|
171
|
+
END;
|
|
172
|
+
$$;
|
|
173
|
+
|
|
174
|
+
REVOKE ALL ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text, uuid) FROM public;
|
|
175
|
+
GRANT EXECUTE ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text, uuid)
|
|
176
|
+
TO anon, authenticated, service_role;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- plugin-agenda 005: server-plane agent write RPC.
|
|
3
|
+
--
|
|
4
|
+
-- public.agent_agenda_create_appointment — the INTERNAL (operator-actor)
|
|
5
|
+
-- counterpart of create_public_booking (001 §5): same invariants (working
|
|
6
|
+
-- hours, advisory-lock race safety, hardcoded statuses), but:
|
|
7
|
+
-- * actor-authorized: public.agent_guard (spine 015) runs role→plan→limit
|
|
8
|
+
-- BEFORE anything — denial comes back as a structured jsonb the agent can
|
|
9
|
+
-- explain conversationally, never a raised exception;
|
|
10
|
+
-- * the client is an EXISTING person id (the agent resolves names→ids via
|
|
11
|
+
-- reads first — ambiguity is a conversation problem, not a SQL one);
|
|
12
|
+
-- * a requested professional is honored (working hours permitting);
|
|
13
|
+
-- * audited: audit_logs row with the acting user.
|
|
14
|
+
--
|
|
15
|
+
-- Contract (all agent_* RPCs): (p_tenant_id, p_actor_user_id, p_payload jsonb)
|
|
16
|
+
-- → jsonb {ok:true, id, record:{...}} | {ok:false, denial:{...}} | {ok:false,
|
|
17
|
+
-- error text}. GRANT authenticated+service_role — NEVER anon (the Fayz broker
|
|
18
|
+
-- calls with the pool service key and injects tenant/actor server-side).
|
|
19
|
+
-- ============================================================================
|
|
20
|
+
|
|
21
|
+
CREATE OR REPLACE FUNCTION public.agent_agenda_create_appointment(
|
|
22
|
+
p_tenant_id uuid,
|
|
23
|
+
p_actor_user_id uuid,
|
|
24
|
+
p_payload jsonb
|
|
25
|
+
) RETURNS jsonb
|
|
26
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
27
|
+
SET search_path = public
|
|
28
|
+
AS $$
|
|
29
|
+
DECLARE
|
|
30
|
+
v_denial jsonb;
|
|
31
|
+
v_used int;
|
|
32
|
+
v_client_id uuid;
|
|
33
|
+
v_service_id uuid;
|
|
34
|
+
v_professional uuid;
|
|
35
|
+
v_starts timestamptz;
|
|
36
|
+
v_notes text;
|
|
37
|
+
v_service record;
|
|
38
|
+
v_client record;
|
|
39
|
+
v_ends timestamptz;
|
|
40
|
+
v_tz text;
|
|
41
|
+
v_order uuid;
|
|
42
|
+
v_booking uuid;
|
|
43
|
+
BEGIN
|
|
44
|
+
-- ── payload ──────────────────────────────────────────────────────────────
|
|
45
|
+
BEGIN
|
|
46
|
+
v_client_id := (p_payload->>'client_id')::uuid;
|
|
47
|
+
v_service_id := (p_payload->>'service_id')::uuid;
|
|
48
|
+
v_professional := (p_payload->>'professional_id')::uuid;
|
|
49
|
+
v_starts := (p_payload->>'starts_at')::timestamptz;
|
|
50
|
+
EXCEPTION WHEN OTHERS THEN
|
|
51
|
+
RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);
|
|
52
|
+
END;
|
|
53
|
+
v_notes := left(p_payload->>'notes', 500);
|
|
54
|
+
IF v_client_id IS NULL OR v_service_id IS NULL OR v_starts IS NULL THEN
|
|
55
|
+
RETURN jsonb_build_object('ok', false, 'error', 'client_id, service_id and starts_at are required');
|
|
56
|
+
END IF;
|
|
57
|
+
IF v_starts <= now() OR v_starts > now() + interval '365 days' THEN
|
|
58
|
+
RETURN jsonb_build_object('ok', false, 'error', 'starts_at must be in the future (max 1 year)');
|
|
59
|
+
END IF;
|
|
60
|
+
|
|
61
|
+
-- ── authorization: role → plan → bookings_month cap ──────────────────────
|
|
62
|
+
SELECT count(*) INTO v_used FROM appointments b
|
|
63
|
+
WHERE b.tenant_id = p_tenant_id
|
|
64
|
+
AND b.created_at >= date_trunc('month', now());
|
|
65
|
+
v_denial := agent_guard(p_tenant_id, p_actor_user_id,
|
|
66
|
+
'appointments', 'create', 'bookings_month', v_used, 1);
|
|
67
|
+
IF v_denial IS NOT NULL THEN
|
|
68
|
+
RETURN jsonb_build_object('ok', false, 'denial', v_denial);
|
|
69
|
+
END IF;
|
|
70
|
+
|
|
71
|
+
-- ── invariants ───────────────────────────────────────────────────────────
|
|
72
|
+
SELECT p.id, p.name INTO v_client
|
|
73
|
+
FROM people p
|
|
74
|
+
WHERE p.id = v_client_id AND p.tenant_id = p_tenant_id;
|
|
75
|
+
IF NOT FOUND THEN
|
|
76
|
+
RETURN jsonb_build_object('ok', false, 'error', 'unknown client for this tenant');
|
|
77
|
+
END IF;
|
|
78
|
+
|
|
79
|
+
SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
|
|
80
|
+
INTO v_service
|
|
81
|
+
FROM services s
|
|
82
|
+
WHERE s.id = v_service_id AND s.tenant_id = p_tenant_id
|
|
83
|
+
AND s.is_active AND s.status = 'active';
|
|
84
|
+
IF NOT FOUND THEN
|
|
85
|
+
RETURN jsonb_build_object('ok', false, 'error', 'unknown or inactive service');
|
|
86
|
+
END IF;
|
|
87
|
+
|
|
88
|
+
v_ends := v_starts + make_interval(mins => v_service.duration_minutes);
|
|
89
|
+
SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
|
|
90
|
+
FROM tenants t WHERE t.id = p_tenant_id;
|
|
91
|
+
|
|
92
|
+
-- requested professional honored; otherwise pick one whose hours cover the slot
|
|
93
|
+
SELECT sc.assignee_id INTO v_professional
|
|
94
|
+
FROM schedules sc
|
|
95
|
+
WHERE sc.tenant_id = p_tenant_id
|
|
96
|
+
AND sc.kind = 'working_hours'
|
|
97
|
+
AND sc.is_active
|
|
98
|
+
AND (v_professional IS NULL OR sc.assignee_id = v_professional)
|
|
99
|
+
AND (
|
|
100
|
+
sc.specific_date = (v_starts AT TIME ZONE v_tz)::date
|
|
101
|
+
OR (sc.specific_date IS NULL
|
|
102
|
+
AND sc.day_of_week = EXTRACT(dow FROM v_starts AT TIME ZONE v_tz)::smallint)
|
|
103
|
+
)
|
|
104
|
+
AND (v_starts AT TIME ZONE v_tz)::time >= sc.starts_at
|
|
105
|
+
AND (v_ends AT TIME ZONE v_tz)::time <= sc.ends_at
|
|
106
|
+
ORDER BY sc.assignee_id
|
|
107
|
+
LIMIT 1;
|
|
108
|
+
IF v_professional IS NULL THEN
|
|
109
|
+
RETURN jsonb_build_object('ok', false, 'error', 'slot outside working hours for the requested professional');
|
|
110
|
+
END IF;
|
|
111
|
+
|
|
112
|
+
-- race safety: serialize per (tenant, professional), then conflict-check
|
|
113
|
+
PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_professional::text, 0));
|
|
114
|
+
IF EXISTS (
|
|
115
|
+
SELECT 1 FROM appointments b
|
|
116
|
+
WHERE b.tenant_id = p_tenant_id
|
|
117
|
+
AND b.assignee_id = v_professional
|
|
118
|
+
AND b.status NOT IN ('cancelled', 'no_show')
|
|
119
|
+
AND b.starts_at < v_ends
|
|
120
|
+
AND COALESCE(b.ends_at, b.starts_at) > v_starts
|
|
121
|
+
) THEN
|
|
122
|
+
RETURN jsonb_build_object('ok', false, 'error', 'slot no longer available');
|
|
123
|
+
END IF;
|
|
124
|
+
|
|
125
|
+
-- ── write (statuses hardcoded) + audit ───────────────────────────────────
|
|
126
|
+
INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
|
|
127
|
+
VALUES (p_tenant_id, 'appointment', 'scheduled', v_client_id, v_professional,
|
|
128
|
+
v_service.price, v_service.price, v_notes,
|
|
129
|
+
jsonb_build_object('source', 'agent', 'serviceNames', v_service.name))
|
|
130
|
+
RETURNING id INTO v_order;
|
|
131
|
+
|
|
132
|
+
INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
|
|
133
|
+
starts_at, ends_at, status, notes, metadata)
|
|
134
|
+
VALUES (p_tenant_id, 'appointment', v_client_id, v_professional, v_order,
|
|
135
|
+
v_starts, v_ends, 'scheduled', v_notes,
|
|
136
|
+
jsonb_build_object('source', 'agent', 'serviceNames', v_service.name))
|
|
137
|
+
RETURNING id INTO v_booking;
|
|
138
|
+
|
|
139
|
+
INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
|
|
140
|
+
sort_order, duration_minutes, assignee_id)
|
|
141
|
+
VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
|
|
142
|
+
0, v_service.duration_minutes, v_professional);
|
|
143
|
+
|
|
144
|
+
INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
|
|
145
|
+
VALUES (p_tenant_id, p_actor_user_id, 'agent.createAppointment', 'appointment', v_booking::text,
|
|
146
|
+
jsonb_build_object('payload', p_payload, 'order_id', v_order));
|
|
147
|
+
|
|
148
|
+
RETURN jsonb_build_object(
|
|
149
|
+
'ok', true,
|
|
150
|
+
'id', v_booking,
|
|
151
|
+
'record', jsonb_build_object(
|
|
152
|
+
'ref', jsonb_build_object('id', v_booking, 'resource', 'appointments',
|
|
153
|
+
'archetype', 'schedule:appointment'),
|
|
154
|
+
'starts_at', v_starts,
|
|
155
|
+
'ends_at', v_ends,
|
|
156
|
+
'client_name', v_client.name,
|
|
157
|
+
'service_name', v_service.name,
|
|
158
|
+
'price', v_service.price,
|
|
159
|
+
'status', 'scheduled'
|
|
160
|
+
)
|
|
161
|
+
);
|
|
162
|
+
END;
|
|
163
|
+
$$;
|
|
164
|
+
|
|
165
|
+
REVOKE ALL ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb) FROM public;
|
|
166
|
+
REVOKE EXECUTE ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb) FROM anon;
|
|
167
|
+
GRANT EXECUTE ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb)
|
|
168
|
+
TO authenticated, service_role;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- plugin-agenda 006: agent_agenda_create_appointment speaks LOCAL time.
|
|
3
|
+
--
|
|
4
|
+
-- Models reliably mangle UTC↔local offsets ("10h" became 13:00 BRT via a
|
|
5
|
+
-- wrong sign). The RPC knows the tenant's timezone — so stop asking the model
|
|
6
|
+
-- to convert: `starts_at` WITHOUT an offset (2026-07-27T10:00) is interpreted
|
|
7
|
+
-- in the tenant tz; an explicit offset still works. The result now carries
|
|
8
|
+
-- starts_at_local/ends_at_local strings so the reply echoes the truth in the
|
|
9
|
+
-- user's own clock. Supersedes 005's function body (new file — applied
|
|
10
|
+
-- migrations are never edited).
|
|
11
|
+
-- ============================================================================
|
|
12
|
+
|
|
13
|
+
CREATE OR REPLACE FUNCTION public.agent_agenda_create_appointment(
|
|
14
|
+
p_tenant_id uuid,
|
|
15
|
+
p_actor_user_id uuid,
|
|
16
|
+
p_payload jsonb
|
|
17
|
+
) RETURNS jsonb
|
|
18
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
19
|
+
SET search_path = public
|
|
20
|
+
AS $$
|
|
21
|
+
DECLARE
|
|
22
|
+
v_denial jsonb;
|
|
23
|
+
v_used int;
|
|
24
|
+
v_client_id uuid;
|
|
25
|
+
v_service_id uuid;
|
|
26
|
+
v_professional uuid;
|
|
27
|
+
v_starts timestamptz;
|
|
28
|
+
v_starts_raw text;
|
|
29
|
+
v_notes text;
|
|
30
|
+
v_service record;
|
|
31
|
+
v_client record;
|
|
32
|
+
v_ends timestamptz;
|
|
33
|
+
v_tz text;
|
|
34
|
+
v_order uuid;
|
|
35
|
+
v_booking uuid;
|
|
36
|
+
BEGIN
|
|
37
|
+
SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
|
|
38
|
+
FROM tenants t WHERE t.id = p_tenant_id;
|
|
39
|
+
IF v_tz IS NULL THEN
|
|
40
|
+
RETURN jsonb_build_object('ok', false, 'error', 'unknown tenant');
|
|
41
|
+
END IF;
|
|
42
|
+
|
|
43
|
+
-- ── payload ──────────────────────────────────────────────────────────────
|
|
44
|
+
BEGIN
|
|
45
|
+
v_client_id := (p_payload->>'client_id')::uuid;
|
|
46
|
+
v_service_id := (p_payload->>'service_id')::uuid;
|
|
47
|
+
v_professional := (p_payload->>'professional_id')::uuid;
|
|
48
|
+
v_starts_raw := p_payload->>'starts_at';
|
|
49
|
+
IF v_starts_raw ~ '(Z|[+-][0-9]{2}:?[0-9]{2})$' THEN
|
|
50
|
+
v_starts := v_starts_raw::timestamptz; -- explicit offset
|
|
51
|
+
ELSE
|
|
52
|
+
v_starts := (v_starts_raw::timestamp) AT TIME ZONE v_tz; -- tenant-local
|
|
53
|
+
END IF;
|
|
54
|
+
EXCEPTION WHEN OTHERS THEN
|
|
55
|
+
RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);
|
|
56
|
+
END;
|
|
57
|
+
v_notes := left(p_payload->>'notes', 500);
|
|
58
|
+
IF v_client_id IS NULL OR v_service_id IS NULL OR v_starts IS NULL THEN
|
|
59
|
+
RETURN jsonb_build_object('ok', false, 'error', 'client_id, service_id and starts_at are required');
|
|
60
|
+
END IF;
|
|
61
|
+
IF v_starts <= now() OR v_starts > now() + interval '365 days' THEN
|
|
62
|
+
RETURN jsonb_build_object('ok', false, 'error', 'starts_at must be in the future (max 1 year)');
|
|
63
|
+
END IF;
|
|
64
|
+
|
|
65
|
+
-- ── authorization: role → plan → bookings_month cap ──────────────────────
|
|
66
|
+
SELECT count(*) INTO v_used FROM appointments b
|
|
67
|
+
WHERE b.tenant_id = p_tenant_id
|
|
68
|
+
AND b.created_at >= date_trunc('month', now());
|
|
69
|
+
v_denial := agent_guard(p_tenant_id, p_actor_user_id,
|
|
70
|
+
'appointments', 'create', 'bookings_month', v_used, 1);
|
|
71
|
+
IF v_denial IS NOT NULL THEN
|
|
72
|
+
RETURN jsonb_build_object('ok', false, 'denial', v_denial);
|
|
73
|
+
END IF;
|
|
74
|
+
|
|
75
|
+
-- ── invariants ───────────────────────────────────────────────────────────
|
|
76
|
+
SELECT p.id, p.name INTO v_client
|
|
77
|
+
FROM people p
|
|
78
|
+
WHERE p.id = v_client_id AND p.tenant_id = p_tenant_id;
|
|
79
|
+
IF NOT FOUND THEN
|
|
80
|
+
RETURN jsonb_build_object('ok', false, 'error', 'unknown client for this tenant');
|
|
81
|
+
END IF;
|
|
82
|
+
|
|
83
|
+
SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
|
|
84
|
+
INTO v_service
|
|
85
|
+
FROM services s
|
|
86
|
+
WHERE s.id = v_service_id AND s.tenant_id = p_tenant_id
|
|
87
|
+
AND s.is_active AND s.status = 'active';
|
|
88
|
+
IF NOT FOUND THEN
|
|
89
|
+
RETURN jsonb_build_object('ok', false, 'error', 'unknown or inactive service');
|
|
90
|
+
END IF;
|
|
91
|
+
|
|
92
|
+
v_ends := v_starts + make_interval(mins => v_service.duration_minutes);
|
|
93
|
+
|
|
94
|
+
SELECT sc.assignee_id INTO v_professional
|
|
95
|
+
FROM schedules sc
|
|
96
|
+
WHERE sc.tenant_id = p_tenant_id
|
|
97
|
+
AND sc.kind = 'working_hours'
|
|
98
|
+
AND sc.is_active
|
|
99
|
+
AND (v_professional IS NULL OR sc.assignee_id = v_professional)
|
|
100
|
+
AND (
|
|
101
|
+
sc.specific_date = (v_starts AT TIME ZONE v_tz)::date
|
|
102
|
+
OR (sc.specific_date IS NULL
|
|
103
|
+
AND sc.day_of_week = EXTRACT(dow FROM v_starts AT TIME ZONE v_tz)::smallint)
|
|
104
|
+
)
|
|
105
|
+
AND (v_starts AT TIME ZONE v_tz)::time >= sc.starts_at
|
|
106
|
+
AND (v_ends AT TIME ZONE v_tz)::time <= sc.ends_at
|
|
107
|
+
-- prefer a professional who is actually FREE at the slot (when the user
|
|
108
|
+
-- did not name one, the first busy professional must not block the booking)
|
|
109
|
+
AND NOT EXISTS (
|
|
110
|
+
SELECT 1 FROM appointments b
|
|
111
|
+
WHERE b.tenant_id = p_tenant_id
|
|
112
|
+
AND b.assignee_id = sc.assignee_id
|
|
113
|
+
AND b.status NOT IN ('cancelled', 'no_show')
|
|
114
|
+
AND b.starts_at < v_ends
|
|
115
|
+
AND COALESCE(b.ends_at, b.starts_at) > v_starts
|
|
116
|
+
)
|
|
117
|
+
ORDER BY sc.assignee_id
|
|
118
|
+
LIMIT 1;
|
|
119
|
+
IF v_professional IS NULL THEN
|
|
120
|
+
RETURN jsonb_build_object('ok', false, 'error',
|
|
121
|
+
'no professional is available at that slot (outside working hours or already booked). Suggest a different time. Times are tenant-local, tz ' || v_tz);
|
|
122
|
+
END IF;
|
|
123
|
+
|
|
124
|
+
PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_professional::text, 0));
|
|
125
|
+
IF EXISTS (
|
|
126
|
+
SELECT 1 FROM appointments b
|
|
127
|
+
WHERE b.tenant_id = p_tenant_id
|
|
128
|
+
AND b.assignee_id = v_professional
|
|
129
|
+
AND b.status NOT IN ('cancelled', 'no_show')
|
|
130
|
+
AND b.starts_at < v_ends
|
|
131
|
+
AND COALESCE(b.ends_at, b.starts_at) > v_starts
|
|
132
|
+
) THEN
|
|
133
|
+
RETURN jsonb_build_object('ok', false, 'error', 'slot no longer available');
|
|
134
|
+
END IF;
|
|
135
|
+
|
|
136
|
+
-- ── write + audit ────────────────────────────────────────────────────────
|
|
137
|
+
INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
|
|
138
|
+
VALUES (p_tenant_id, 'appointment', 'scheduled', v_client_id, v_professional,
|
|
139
|
+
v_service.price, v_service.price, v_notes,
|
|
140
|
+
jsonb_build_object('source', 'agent', 'serviceNames', v_service.name))
|
|
141
|
+
RETURNING id INTO v_order;
|
|
142
|
+
|
|
143
|
+
INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
|
|
144
|
+
starts_at, ends_at, status, notes, metadata)
|
|
145
|
+
VALUES (p_tenant_id, 'appointment', v_client_id, v_professional, v_order,
|
|
146
|
+
v_starts, v_ends, 'scheduled', v_notes,
|
|
147
|
+
jsonb_build_object('source', 'agent', 'serviceNames', v_service.name))
|
|
148
|
+
RETURNING id INTO v_booking;
|
|
149
|
+
|
|
150
|
+
INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
|
|
151
|
+
sort_order, duration_minutes, assignee_id)
|
|
152
|
+
VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
|
|
153
|
+
0, v_service.duration_minutes, v_professional);
|
|
154
|
+
|
|
155
|
+
INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
|
|
156
|
+
VALUES (p_tenant_id, p_actor_user_id, 'agent.createAppointment', 'appointment', v_booking::text,
|
|
157
|
+
jsonb_build_object('payload', p_payload, 'order_id', v_order));
|
|
158
|
+
|
|
159
|
+
RETURN jsonb_build_object(
|
|
160
|
+
'ok', true,
|
|
161
|
+
'id', v_booking,
|
|
162
|
+
'record', jsonb_build_object(
|
|
163
|
+
'ref', jsonb_build_object('id', v_booking, 'resource', 'appointments',
|
|
164
|
+
'archetype', 'schedule:appointment'),
|
|
165
|
+
'starts_at', v_starts,
|
|
166
|
+
'ends_at', v_ends,
|
|
167
|
+
'starts_at_local', to_char(v_starts AT TIME ZONE v_tz, 'YYYY-MM-DD (Dy) HH24:MI') || ' ' || v_tz,
|
|
168
|
+
'ends_at_local', to_char(v_ends AT TIME ZONE v_tz, 'HH24:MI'),
|
|
169
|
+
'client_name', v_client.name,
|
|
170
|
+
'service_name', v_service.name,
|
|
171
|
+
'price', v_service.price,
|
|
172
|
+
'status', 'scheduled'
|
|
173
|
+
)
|
|
174
|
+
);
|
|
175
|
+
END;
|
|
176
|
+
$$;
|
|
177
|
+
|
|
178
|
+
REVOKE ALL ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb) FROM public;
|
|
179
|
+
REVOKE EXECUTE ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb) FROM anon;
|
|
180
|
+
GRANT EXECUTE ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb)
|
|
181
|
+
TO authenticated, service_role;
|