@fayz-ai/plugin-agenda 0.8.3 → 0.9.0-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-YQO6B4G3.js → chunk-Q2PAWBPR.js} +6 -4
- package/dist/chunk-Q2PAWBPR.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +49 -5
- package/dist/index.js.map +1 -1
- package/dist/migrations/index.d.ts +3 -2
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/public/index.js +2 -2
- package/dist/store.d.ts.map +1 -1
- package/package.json +17 -14
- package/src/index.ts +40 -0
- package/src/migrations/001_public_booking.sql +7 -2
- package/src/migrations/013_normalize_v_appointments.sql +72 -34
- package/src/migrations/014_agent_rpc_update_appointment.sql +199 -0
- package/src/migrations/index.ts +283 -38
- package/src/public/data.supabase.ts +4 -4
- package/src/store.ts +5 -0
- package/dist/chunk-3KWNHV5A.cjs +0 -481
- package/dist/chunk-3KWNHV5A.cjs.map +0 -1
- package/dist/chunk-YQO6B4G3.js.map +0 -1
- package/dist/index.cjs +0 -5848
- package/dist/index.cjs.map +0 -1
- package/dist/public/index.cjs +0 -1391
- package/dist/public/index.cjs.map +0 -1
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- plugin-agenda 014: agent_agenda_update_appointment — remarcar / reatribuir /
|
|
3
|
+
-- cancelar um agendamento existente.
|
|
4
|
+
--
|
|
5
|
+
-- Lacuna que isso fecha: o agente sabia CRIAR e não sabia MEXER. Pedido real
|
|
6
|
+
-- ("remarca esse paciente pra 16:30") caía no updateRecord genérico, que bate
|
|
7
|
+
-- em v_appointments — uma view — e devolve read_only; o modelo então "resolvia"
|
|
8
|
+
-- abrindo um agendamento NOVO, deixando o antigo de pé. Duplicar a agenda de um
|
|
9
|
+
-- salão é pior do que não fazer nada.
|
|
10
|
+
--
|
|
11
|
+
-- Mesma cadeia de guardas do 008 (agent_guard → janela de trabalho com exceções
|
|
12
|
+
-- → lock advisory → checagem de conflito → auditoria), com três diferenças:
|
|
13
|
+
-- • ação 'edit', sem cota (remarcar não consome bookings_month);
|
|
14
|
+
-- • o próprio agendamento é excluído da checagem de conflito consigo mesmo;
|
|
15
|
+
-- • cancelar não precisa de horário livre — só o guard.
|
|
16
|
+
-- Requer 007 (agenda_working_windows). Idempotente.
|
|
17
|
+
-- ============================================================================
|
|
18
|
+
|
|
19
|
+
CREATE OR REPLACE FUNCTION public.agent_agenda_update_appointment(
|
|
20
|
+
p_tenant_id uuid,
|
|
21
|
+
p_actor_user_id uuid,
|
|
22
|
+
p_payload jsonb
|
|
23
|
+
) RETURNS jsonb
|
|
24
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
25
|
+
SET search_path = public
|
|
26
|
+
AS $$
|
|
27
|
+
DECLARE
|
|
28
|
+
v_denial jsonb;
|
|
29
|
+
v_id uuid;
|
|
30
|
+
v_tz text;
|
|
31
|
+
v_row record;
|
|
32
|
+
v_starts_raw text;
|
|
33
|
+
v_starts timestamptz;
|
|
34
|
+
v_ends timestamptz;
|
|
35
|
+
v_duration int;
|
|
36
|
+
v_professional uuid;
|
|
37
|
+
v_status text;
|
|
38
|
+
v_notes text;
|
|
39
|
+
v_wants_move boolean;
|
|
40
|
+
v_wants_status boolean;
|
|
41
|
+
BEGIN
|
|
42
|
+
SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
|
|
43
|
+
FROM tenants t WHERE t.id = p_tenant_id;
|
|
44
|
+
IF v_tz IS NULL THEN
|
|
45
|
+
RETURN jsonb_build_object('ok', false, 'error', 'unknown tenant');
|
|
46
|
+
END IF;
|
|
47
|
+
|
|
48
|
+
-- ── payload ──────────────────────────────────────────────────────────────
|
|
49
|
+
BEGIN
|
|
50
|
+
v_id := (p_payload->>'appointment_id')::uuid;
|
|
51
|
+
v_professional := (p_payload->>'professional_id')::uuid;
|
|
52
|
+
v_starts_raw := p_payload->>'starts_at';
|
|
53
|
+
EXCEPTION WHEN OTHERS THEN
|
|
54
|
+
RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);
|
|
55
|
+
END;
|
|
56
|
+
v_status := nullif(trim(lower(p_payload->>'status')), '');
|
|
57
|
+
v_notes := left(p_payload->>'notes', 500);
|
|
58
|
+
v_wants_move := v_starts_raw IS NOT NULL OR v_professional IS NOT NULL;
|
|
59
|
+
v_wants_status := v_status IS NOT NULL;
|
|
60
|
+
|
|
61
|
+
IF v_id IS NULL THEN
|
|
62
|
+
RETURN jsonb_build_object('ok', false, 'error',
|
|
63
|
+
'appointment_id is required — search the agenda first and reuse the id of the appointment to change');
|
|
64
|
+
END IF;
|
|
65
|
+
IF NOT v_wants_move AND NOT v_wants_status AND v_notes IS NULL THEN
|
|
66
|
+
RETURN jsonb_build_object('ok', false, 'error',
|
|
67
|
+
'nothing to change: send starts_at, professional_id, status or notes');
|
|
68
|
+
END IF;
|
|
69
|
+
IF v_status IS NOT NULL
|
|
70
|
+
AND v_status NOT IN ('scheduled', 'confirmed', 'in_progress', 'completed', 'cancelled', 'no_show') THEN
|
|
71
|
+
RETURN jsonb_build_object('ok', false, 'error',
|
|
72
|
+
'status must be one of scheduled, confirmed, in_progress, completed, cancelled, no_show');
|
|
73
|
+
END IF;
|
|
74
|
+
|
|
75
|
+
-- ── the appointment must exist, in THIS tenant ───────────────────────────
|
|
76
|
+
SELECT b.id, b.starts_at, b.ends_at, b.assignee_id, b.status, b.party_id, b.order_id
|
|
77
|
+
INTO v_row
|
|
78
|
+
FROM appointments b
|
|
79
|
+
WHERE b.id = v_id AND b.tenant_id = p_tenant_id;
|
|
80
|
+
IF NOT FOUND THEN
|
|
81
|
+
RETURN jsonb_build_object('ok', false, 'error', 'unknown appointment for this tenant');
|
|
82
|
+
END IF;
|
|
83
|
+
|
|
84
|
+
-- ── authorization: role → plan. No cap: editing frees nothing and buys
|
|
85
|
+
-- nothing, so charging bookings_month for a reschedule would be wrong.
|
|
86
|
+
v_denial := agent_guard(p_tenant_id, p_actor_user_id, 'appointments', 'edit');
|
|
87
|
+
IF v_denial IS NOT NULL THEN
|
|
88
|
+
RETURN jsonb_build_object('ok', false, 'denial', v_denial);
|
|
89
|
+
END IF;
|
|
90
|
+
|
|
91
|
+
-- Duration is preserved across a move — a 60-minute appointment stays 60.
|
|
92
|
+
v_duration := GREATEST(
|
|
93
|
+
COALESCE(EXTRACT(EPOCH FROM (v_row.ends_at - v_row.starts_at)) / 60, 30)::int, 5);
|
|
94
|
+
v_professional := COALESCE(v_professional, v_row.assignee_id);
|
|
95
|
+
|
|
96
|
+
IF v_wants_move THEN
|
|
97
|
+
IF v_starts_raw IS NOT NULL THEN
|
|
98
|
+
BEGIN
|
|
99
|
+
IF v_starts_raw ~ '(Z|[+-][0-9]{2}:?[0-9]{2})$' THEN
|
|
100
|
+
v_starts := v_starts_raw::timestamptz; -- explicit offset
|
|
101
|
+
ELSE
|
|
102
|
+
v_starts := (v_starts_raw::timestamp) AT TIME ZONE v_tz; -- tenant-local
|
|
103
|
+
END IF;
|
|
104
|
+
EXCEPTION WHEN OTHERS THEN
|
|
105
|
+
RETURN jsonb_build_object('ok', false, 'error', 'invalid starts_at: ' || SQLERRM);
|
|
106
|
+
END;
|
|
107
|
+
ELSE
|
|
108
|
+
v_starts := v_row.starts_at;
|
|
109
|
+
END IF;
|
|
110
|
+
v_ends := v_starts + make_interval(mins => v_duration);
|
|
111
|
+
|
|
112
|
+
IF v_starts > now() + interval '365 days' THEN
|
|
113
|
+
RETURN jsonb_build_object('ok', false, 'error', 'starts_at must be within the next year');
|
|
114
|
+
END IF;
|
|
115
|
+
|
|
116
|
+
-- Working window (exception-aware, 007) for the target professional.
|
|
117
|
+
IF NOT EXISTS (
|
|
118
|
+
SELECT 1
|
|
119
|
+
FROM agenda_working_windows(
|
|
120
|
+
p_tenant_id, (v_starts AT TIME ZONE v_tz)::date, v_professional) w
|
|
121
|
+
WHERE w.assignee_id = v_professional
|
|
122
|
+
AND (v_starts AT TIME ZONE v_tz)::time >= w.win_start
|
|
123
|
+
AND (v_ends AT TIME ZONE v_tz)::time <= w.win_end
|
|
124
|
+
) THEN
|
|
125
|
+
RETURN jsonb_build_object('ok', false, 'error',
|
|
126
|
+
'that slot is outside the professional''s working hours. Suggest a different time. Times are tenant-local, tz ' || v_tz);
|
|
127
|
+
END IF;
|
|
128
|
+
|
|
129
|
+
PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_professional::text, 0));
|
|
130
|
+
IF EXISTS (
|
|
131
|
+
SELECT 1 FROM appointments b
|
|
132
|
+
WHERE b.tenant_id = p_tenant_id
|
|
133
|
+
AND b.assignee_id = v_professional
|
|
134
|
+
AND b.id <> v_id -- never conflicts with itself
|
|
135
|
+
AND b.status NOT IN ('cancelled', 'no_show')
|
|
136
|
+
AND b.starts_at < v_ends
|
|
137
|
+
AND COALESCE(b.ends_at, b.starts_at) > v_starts
|
|
138
|
+
) THEN
|
|
139
|
+
RETURN jsonb_build_object('ok', false, 'error',
|
|
140
|
+
'that slot is already taken. Suggest a different time.');
|
|
141
|
+
END IF;
|
|
142
|
+
END IF;
|
|
143
|
+
|
|
144
|
+
-- ── write + audit ────────────────────────────────────────────────────────
|
|
145
|
+
UPDATE appointments b SET
|
|
146
|
+
starts_at = COALESCE(v_starts, b.starts_at),
|
|
147
|
+
ends_at = COALESCE(v_ends, b.ends_at),
|
|
148
|
+
assignee_id = v_professional,
|
|
149
|
+
status = COALESCE(v_status, b.status),
|
|
150
|
+
notes = COALESCE(v_notes, b.notes),
|
|
151
|
+
metadata = COALESCE(b.metadata, '{}'::jsonb) || jsonb_build_object('lastAgentEdit', now()),
|
|
152
|
+
updated_at = now()
|
|
153
|
+
WHERE b.id = v_id AND b.tenant_id = p_tenant_id;
|
|
154
|
+
|
|
155
|
+
-- The order mirrors the appointment's assignee/status so the financial side
|
|
156
|
+
-- does not drift from the calendar.
|
|
157
|
+
IF v_row.order_id IS NOT NULL THEN
|
|
158
|
+
UPDATE orders o SET
|
|
159
|
+
assignee_id = v_professional,
|
|
160
|
+
status = CASE WHEN v_status = 'cancelled' THEN 'cancelled' ELSE o.status END,
|
|
161
|
+
updated_at = now()
|
|
162
|
+
WHERE o.id = v_row.order_id AND o.tenant_id = p_tenant_id;
|
|
163
|
+
END IF;
|
|
164
|
+
|
|
165
|
+
INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
|
|
166
|
+
VALUES (p_tenant_id, p_actor_user_id, 'agent.updateAppointment', 'appointment', v_id::text,
|
|
167
|
+
jsonb_build_object('payload', p_payload,
|
|
168
|
+
'previous', jsonb_build_object(
|
|
169
|
+
'starts_at', v_row.starts_at,
|
|
170
|
+
'ends_at', v_row.ends_at,
|
|
171
|
+
'assignee_id', v_row.assignee_id,
|
|
172
|
+
'status', v_row.status)));
|
|
173
|
+
|
|
174
|
+
SELECT a.id, a.starts_at, a.ends_at, a.status, a.client_name, a.professional_name
|
|
175
|
+
INTO v_row
|
|
176
|
+
FROM v_appointments a WHERE a.id = v_id;
|
|
177
|
+
|
|
178
|
+
RETURN jsonb_build_object(
|
|
179
|
+
'ok', true,
|
|
180
|
+
'id', v_id,
|
|
181
|
+
'record', jsonb_build_object(
|
|
182
|
+
'ref', jsonb_build_object('id', v_id, 'resource', 'appointments',
|
|
183
|
+
'archetype', 'schedule:appointment'),
|
|
184
|
+
'starts_at', v_row.starts_at,
|
|
185
|
+
'ends_at', v_row.ends_at,
|
|
186
|
+
'starts_at_local', to_char(v_row.starts_at AT TIME ZONE v_tz, 'YYYY-MM-DD (Dy) HH24:MI') || ' ' || v_tz,
|
|
187
|
+
'ends_at_local', to_char(v_row.ends_at AT TIME ZONE v_tz, 'HH24:MI'),
|
|
188
|
+
'client_name', v_row.client_name,
|
|
189
|
+
'professional_name', v_row.professional_name,
|
|
190
|
+
'status', v_row.status
|
|
191
|
+
)
|
|
192
|
+
);
|
|
193
|
+
END;
|
|
194
|
+
$$;
|
|
195
|
+
|
|
196
|
+
REVOKE ALL ON FUNCTION public.agent_agenda_update_appointment(uuid, uuid, jsonb) FROM public;
|
|
197
|
+
REVOKE EXECUTE ON FUNCTION public.agent_agenda_update_appointment(uuid, uuid, jsonb) FROM anon;
|
|
198
|
+
GRANT EXECUTE ON FUNCTION public.agent_agenda_update_appointment(uuid, uuid, jsonb)
|
|
199
|
+
TO authenticated, service_role;
|
package/src/migrations/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// AUTO-GENERATED from 000b_v_bookings_rename.sql, 000c_dedupe_customer_phones.sql, 001_public_booking.sql, 002_v_bookings_compat.sql, 003_public_client_kind.sql, 004_public_booking_assignee.sql, 005_agent_rpcs.sql, 006_agent_rpc_localtime.sql, 007_schedule_exception_precedence.sql, 008_agent_rpc_exception_precedence.sql, 009_public_booking_location.sql, 010_public_service_curation.sql, 011_default_assignee.sql, 012_drop_legacy_booking_overload.sql, 013_normalize_v_appointments.sql — regenerate with scripts/embed-migrations.mjs
|
|
1
|
+
// AUTO-GENERATED from 000b_v_bookings_rename.sql, 000c_dedupe_customer_phones.sql, 001_public_booking.sql, 002_v_bookings_compat.sql, 003_public_client_kind.sql, 004_public_booking_assignee.sql, 005_agent_rpcs.sql, 006_agent_rpc_localtime.sql, 007_schedule_exception_precedence.sql, 008_agent_rpc_exception_precedence.sql, 009_public_booking_location.sql, 010_public_service_curation.sql, 011_default_assignee.sql, 012_drop_legacy_booking_overload.sql, 013_normalize_v_appointments.sql, 014_agent_rpc_update_appointment.sql — regenerate with scripts/embed-migrations.mjs
|
|
2
2
|
// SQL files are the source of truth; this inline copy lets the manifest declare
|
|
3
3
|
// migrations as data. Do not edit by hand — run the embed script instead.
|
|
4
4
|
|
|
@@ -116,7 +116,10 @@ CREATE INDEX IF NOT EXISTS appointments_assignee_time
|
|
|
116
116
|
WHERE status NOT IN ('cancelled', 'no_show');
|
|
117
117
|
|
|
118
118
|
-- §2 — admin read model (security_invoker → underlying RLS applies; anon sees 0 rows)
|
|
119
|
-
CREATE OR REPLACE
|
|
119
|
+
-- DROP first: 013 widens this view and CREATE OR REPLACE cannot narrow a live
|
|
120
|
+
-- view on replay ("cannot drop columns from view"). 013 replays right after.
|
|
121
|
+
DROP VIEW IF EXISTS public.v_appointments;
|
|
122
|
+
CREATE VIEW public.v_appointments
|
|
120
123
|
WITH (security_invoker = true) AS
|
|
121
124
|
SELECT
|
|
122
125
|
b.id, b.tenant_id, b.kind, b.starts_at, b.ends_at, b.status, b.notes,
|
|
@@ -144,7 +147,9 @@ GRANT SELECT ON public.v_appointments TO authenticated, service_role;
|
|
|
144
147
|
REVOKE SELECT ON public.v_appointments FROM anon;
|
|
145
148
|
|
|
146
149
|
-- §3 — anon-readable public catalog (owner-rights view = deliberate, whitelisted)
|
|
147
|
-
|
|
150
|
+
-- DROP first: 010 widens this view (curation columns) — same replay rule as §2.
|
|
151
|
+
DROP VIEW IF EXISTS public.v_public_services;
|
|
152
|
+
CREATE VIEW public.v_public_services AS
|
|
148
153
|
SELECT
|
|
149
154
|
s.id, s.tenant_id, s.name, s.description, s.price, s.currency,
|
|
150
155
|
COALESCE(s.duration_minutes, 30) AS duration_minutes,
|
|
@@ -2042,41 +2047,79 @@ SELECT tenant_id,
|
|
|
2042
2047
|
updated_at
|
|
2043
2048
|
FROM v_appointments b;
|
|
2044
2049
|
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2050
|
+
-- legacy_pre_pools only exists on pools migrated from the pre-pool era; fresh
|
|
2051
|
+
-- pools get the same shape with confirmation columns defaulting to pending/NULL.
|
|
2052
|
+
DO $$
|
|
2053
|
+
BEGIN
|
|
2054
|
+
IF to_regclass('legacy_pre_pools.appointments') IS NOT NULL THEN
|
|
2055
|
+
EXECUTE $v$
|
|
2056
|
+
CREATE OR REPLACE VIEW public.rep_confirmation_queue AS
|
|
2057
|
+
SELECT b.tenant_id,
|
|
2058
|
+
b.id AS booking_id,
|
|
2059
|
+
(b.starts_at)::date AS date,
|
|
2060
|
+
b.starts_at,
|
|
2061
|
+
b.client_id,
|
|
2062
|
+
b.client_name,
|
|
2063
|
+
b.professional_id,
|
|
2064
|
+
b.professional_name,
|
|
2065
|
+
COALESCE(((b.services -> 0) ->> 'name'::text), ((b.services -> 0) ->> 'serviceName'::text)) AS service_name,
|
|
2066
|
+
COALESCE(a.confirmation_status, 'pending'::text) AS confirmation_status,
|
|
2067
|
+
a.confirmation_channel,
|
|
2068
|
+
a.confirmation_sent_at,
|
|
2069
|
+
a.confirmed_at,
|
|
2070
|
+
b.created_at,
|
|
2071
|
+
b.updated_at
|
|
2072
|
+
FROM (v_appointments b
|
|
2073
|
+
LEFT JOIN legacy_pre_pools.appointments a ON ((a.booking_id = b.id)))
|
|
2074
|
+
WHERE ((b.status <> ALL (ARRAY['cancelled'::text, 'no_show'::text])) AND (b.starts_at >= (now() - '1 day'::interval)))
|
|
2075
|
+
$v$;
|
|
2076
|
+
ELSE
|
|
2077
|
+
EXECUTE $v$
|
|
2078
|
+
CREATE OR REPLACE VIEW public.rep_confirmation_queue AS
|
|
2079
|
+
SELECT b.tenant_id,
|
|
2080
|
+
b.id AS booking_id,
|
|
2081
|
+
(b.starts_at)::date AS date,
|
|
2082
|
+
b.starts_at,
|
|
2083
|
+
b.client_id,
|
|
2084
|
+
b.client_name,
|
|
2085
|
+
b.professional_id,
|
|
2086
|
+
b.professional_name,
|
|
2087
|
+
COALESCE(((b.services -> 0) ->> 'name'::text), ((b.services -> 0) ->> 'serviceName'::text)) AS service_name,
|
|
2088
|
+
'pending'::text AS confirmation_status,
|
|
2089
|
+
NULL::text AS confirmation_channel,
|
|
2090
|
+
NULL::timestamptz AS confirmation_sent_at,
|
|
2091
|
+
NULL::timestamptz AS confirmed_at,
|
|
2092
|
+
b.created_at,
|
|
2093
|
+
b.updated_at
|
|
2094
|
+
FROM v_appointments b
|
|
2095
|
+
WHERE ((b.status <> ALL (ARRAY['cancelled'::text, 'no_show'::text])) AND (b.starts_at >= (now() - '1 day'::interval)))
|
|
2096
|
+
$v$;
|
|
2097
|
+
END IF;
|
|
2098
|
+
END $$;
|
|
2099
|
+
|
|
2100
|
+
-- v_clients (beauty Ring-2 extension) may not exist on non-salon pools — the
|
|
2101
|
+
-- report only makes sense where it does.
|
|
2102
|
+
DO $$
|
|
2103
|
+
BEGIN
|
|
2104
|
+
IF to_regclass('public.v_clients') IS NULL THEN RETURN; END IF;
|
|
2105
|
+
EXECUTE $v$
|
|
2106
|
+
CREATE OR REPLACE VIEW public.rep_new_clients AS
|
|
2107
|
+
SELECT c.tenant_id,
|
|
2108
|
+
c.id AS client_id,
|
|
2109
|
+
(c.created_at)::date AS date,
|
|
2110
|
+
c.name AS client_name,
|
|
2111
|
+
c.origin,
|
|
2112
|
+
first_booking.service_name AS first_service,
|
|
2113
|
+
c.created_at,
|
|
2114
|
+
c.updated_at
|
|
2115
|
+
FROM (v_clients c
|
|
2116
|
+
LEFT JOIN LATERAL ( SELECT COALESCE(((b.services -> 0) ->> 'name'::text), ((b.services -> 0) ->> 'serviceName'::text)) AS service_name
|
|
2117
|
+
FROM v_appointments b
|
|
2118
|
+
WHERE (b.client_id = c.id)
|
|
2119
|
+
ORDER BY b.starts_at
|
|
2120
|
+
LIMIT 1) first_booking ON (true))
|
|
2121
|
+
$v$;
|
|
2122
|
+
END $$;
|
|
2080
2123
|
|
|
2081
2124
|
CREATE OR REPLACE VIEW public.rep_revenue_by_professional AS
|
|
2082
2125
|
SELECT tenant_id,
|
|
@@ -2113,6 +2156,207 @@ DROP VIEW IF EXISTS public.v_bookings;
|
|
|
2113
2156
|
DROP VIEW IF EXISTS public.v_bookings_legacy_beauty;
|
|
2114
2157
|
`
|
|
2115
2158
|
|
|
2159
|
+
export const MIGRATION_014_AGENT_RPC_UPDATE_APPOINTMENT = `-- ============================================================================
|
|
2160
|
+
-- plugin-agenda 014: agent_agenda_update_appointment — remarcar / reatribuir /
|
|
2161
|
+
-- cancelar um agendamento existente.
|
|
2162
|
+
--
|
|
2163
|
+
-- Lacuna que isso fecha: o agente sabia CRIAR e não sabia MEXER. Pedido real
|
|
2164
|
+
-- ("remarca esse paciente pra 16:30") caía no updateRecord genérico, que bate
|
|
2165
|
+
-- em v_appointments — uma view — e devolve read_only; o modelo então "resolvia"
|
|
2166
|
+
-- abrindo um agendamento NOVO, deixando o antigo de pé. Duplicar a agenda de um
|
|
2167
|
+
-- salão é pior do que não fazer nada.
|
|
2168
|
+
--
|
|
2169
|
+
-- Mesma cadeia de guardas do 008 (agent_guard → janela de trabalho com exceções
|
|
2170
|
+
-- → lock advisory → checagem de conflito → auditoria), com três diferenças:
|
|
2171
|
+
-- • ação 'edit', sem cota (remarcar não consome bookings_month);
|
|
2172
|
+
-- • o próprio agendamento é excluído da checagem de conflito consigo mesmo;
|
|
2173
|
+
-- • cancelar não precisa de horário livre — só o guard.
|
|
2174
|
+
-- Requer 007 (agenda_working_windows). Idempotente.
|
|
2175
|
+
-- ============================================================================
|
|
2176
|
+
|
|
2177
|
+
CREATE OR REPLACE FUNCTION public.agent_agenda_update_appointment(
|
|
2178
|
+
p_tenant_id uuid,
|
|
2179
|
+
p_actor_user_id uuid,
|
|
2180
|
+
p_payload jsonb
|
|
2181
|
+
) RETURNS jsonb
|
|
2182
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
2183
|
+
SET search_path = public
|
|
2184
|
+
AS $$
|
|
2185
|
+
DECLARE
|
|
2186
|
+
v_denial jsonb;
|
|
2187
|
+
v_id uuid;
|
|
2188
|
+
v_tz text;
|
|
2189
|
+
v_row record;
|
|
2190
|
+
v_starts_raw text;
|
|
2191
|
+
v_starts timestamptz;
|
|
2192
|
+
v_ends timestamptz;
|
|
2193
|
+
v_duration int;
|
|
2194
|
+
v_professional uuid;
|
|
2195
|
+
v_status text;
|
|
2196
|
+
v_notes text;
|
|
2197
|
+
v_wants_move boolean;
|
|
2198
|
+
v_wants_status boolean;
|
|
2199
|
+
BEGIN
|
|
2200
|
+
SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
|
|
2201
|
+
FROM tenants t WHERE t.id = p_tenant_id;
|
|
2202
|
+
IF v_tz IS NULL THEN
|
|
2203
|
+
RETURN jsonb_build_object('ok', false, 'error', 'unknown tenant');
|
|
2204
|
+
END IF;
|
|
2205
|
+
|
|
2206
|
+
-- ── payload ──────────────────────────────────────────────────────────────
|
|
2207
|
+
BEGIN
|
|
2208
|
+
v_id := (p_payload->>'appointment_id')::uuid;
|
|
2209
|
+
v_professional := (p_payload->>'professional_id')::uuid;
|
|
2210
|
+
v_starts_raw := p_payload->>'starts_at';
|
|
2211
|
+
EXCEPTION WHEN OTHERS THEN
|
|
2212
|
+
RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);
|
|
2213
|
+
END;
|
|
2214
|
+
v_status := nullif(trim(lower(p_payload->>'status')), '');
|
|
2215
|
+
v_notes := left(p_payload->>'notes', 500);
|
|
2216
|
+
v_wants_move := v_starts_raw IS NOT NULL OR v_professional IS NOT NULL;
|
|
2217
|
+
v_wants_status := v_status IS NOT NULL;
|
|
2218
|
+
|
|
2219
|
+
IF v_id IS NULL THEN
|
|
2220
|
+
RETURN jsonb_build_object('ok', false, 'error',
|
|
2221
|
+
'appointment_id is required — search the agenda first and reuse the id of the appointment to change');
|
|
2222
|
+
END IF;
|
|
2223
|
+
IF NOT v_wants_move AND NOT v_wants_status AND v_notes IS NULL THEN
|
|
2224
|
+
RETURN jsonb_build_object('ok', false, 'error',
|
|
2225
|
+
'nothing to change: send starts_at, professional_id, status or notes');
|
|
2226
|
+
END IF;
|
|
2227
|
+
IF v_status IS NOT NULL
|
|
2228
|
+
AND v_status NOT IN ('scheduled', 'confirmed', 'in_progress', 'completed', 'cancelled', 'no_show') THEN
|
|
2229
|
+
RETURN jsonb_build_object('ok', false, 'error',
|
|
2230
|
+
'status must be one of scheduled, confirmed, in_progress, completed, cancelled, no_show');
|
|
2231
|
+
END IF;
|
|
2232
|
+
|
|
2233
|
+
-- ── the appointment must exist, in THIS tenant ───────────────────────────
|
|
2234
|
+
SELECT b.id, b.starts_at, b.ends_at, b.assignee_id, b.status, b.party_id, b.order_id
|
|
2235
|
+
INTO v_row
|
|
2236
|
+
FROM appointments b
|
|
2237
|
+
WHERE b.id = v_id AND b.tenant_id = p_tenant_id;
|
|
2238
|
+
IF NOT FOUND THEN
|
|
2239
|
+
RETURN jsonb_build_object('ok', false, 'error', 'unknown appointment for this tenant');
|
|
2240
|
+
END IF;
|
|
2241
|
+
|
|
2242
|
+
-- ── authorization: role → plan. No cap: editing frees nothing and buys
|
|
2243
|
+
-- nothing, so charging bookings_month for a reschedule would be wrong.
|
|
2244
|
+
v_denial := agent_guard(p_tenant_id, p_actor_user_id, 'appointments', 'edit');
|
|
2245
|
+
IF v_denial IS NOT NULL THEN
|
|
2246
|
+
RETURN jsonb_build_object('ok', false, 'denial', v_denial);
|
|
2247
|
+
END IF;
|
|
2248
|
+
|
|
2249
|
+
-- Duration is preserved across a move — a 60-minute appointment stays 60.
|
|
2250
|
+
v_duration := GREATEST(
|
|
2251
|
+
COALESCE(EXTRACT(EPOCH FROM (v_row.ends_at - v_row.starts_at)) / 60, 30)::int, 5);
|
|
2252
|
+
v_professional := COALESCE(v_professional, v_row.assignee_id);
|
|
2253
|
+
|
|
2254
|
+
IF v_wants_move THEN
|
|
2255
|
+
IF v_starts_raw IS NOT NULL THEN
|
|
2256
|
+
BEGIN
|
|
2257
|
+
IF v_starts_raw ~ '(Z|[+-][0-9]{2}:?[0-9]{2})$' THEN
|
|
2258
|
+
v_starts := v_starts_raw::timestamptz; -- explicit offset
|
|
2259
|
+
ELSE
|
|
2260
|
+
v_starts := (v_starts_raw::timestamp) AT TIME ZONE v_tz; -- tenant-local
|
|
2261
|
+
END IF;
|
|
2262
|
+
EXCEPTION WHEN OTHERS THEN
|
|
2263
|
+
RETURN jsonb_build_object('ok', false, 'error', 'invalid starts_at: ' || SQLERRM);
|
|
2264
|
+
END;
|
|
2265
|
+
ELSE
|
|
2266
|
+
v_starts := v_row.starts_at;
|
|
2267
|
+
END IF;
|
|
2268
|
+
v_ends := v_starts + make_interval(mins => v_duration);
|
|
2269
|
+
|
|
2270
|
+
IF v_starts > now() + interval '365 days' THEN
|
|
2271
|
+
RETURN jsonb_build_object('ok', false, 'error', 'starts_at must be within the next year');
|
|
2272
|
+
END IF;
|
|
2273
|
+
|
|
2274
|
+
-- Working window (exception-aware, 007) for the target professional.
|
|
2275
|
+
IF NOT EXISTS (
|
|
2276
|
+
SELECT 1
|
|
2277
|
+
FROM agenda_working_windows(
|
|
2278
|
+
p_tenant_id, (v_starts AT TIME ZONE v_tz)::date, v_professional) w
|
|
2279
|
+
WHERE w.assignee_id = v_professional
|
|
2280
|
+
AND (v_starts AT TIME ZONE v_tz)::time >= w.win_start
|
|
2281
|
+
AND (v_ends AT TIME ZONE v_tz)::time <= w.win_end
|
|
2282
|
+
) THEN
|
|
2283
|
+
RETURN jsonb_build_object('ok', false, 'error',
|
|
2284
|
+
'that slot is outside the professional''s working hours. Suggest a different time. Times are tenant-local, tz ' || v_tz);
|
|
2285
|
+
END IF;
|
|
2286
|
+
|
|
2287
|
+
PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_professional::text, 0));
|
|
2288
|
+
IF EXISTS (
|
|
2289
|
+
SELECT 1 FROM appointments b
|
|
2290
|
+
WHERE b.tenant_id = p_tenant_id
|
|
2291
|
+
AND b.assignee_id = v_professional
|
|
2292
|
+
AND b.id <> v_id -- never conflicts with itself
|
|
2293
|
+
AND b.status NOT IN ('cancelled', 'no_show')
|
|
2294
|
+
AND b.starts_at < v_ends
|
|
2295
|
+
AND COALESCE(b.ends_at, b.starts_at) > v_starts
|
|
2296
|
+
) THEN
|
|
2297
|
+
RETURN jsonb_build_object('ok', false, 'error',
|
|
2298
|
+
'that slot is already taken. Suggest a different time.');
|
|
2299
|
+
END IF;
|
|
2300
|
+
END IF;
|
|
2301
|
+
|
|
2302
|
+
-- ── write + audit ────────────────────────────────────────────────────────
|
|
2303
|
+
UPDATE appointments b SET
|
|
2304
|
+
starts_at = COALESCE(v_starts, b.starts_at),
|
|
2305
|
+
ends_at = COALESCE(v_ends, b.ends_at),
|
|
2306
|
+
assignee_id = v_professional,
|
|
2307
|
+
status = COALESCE(v_status, b.status),
|
|
2308
|
+
notes = COALESCE(v_notes, b.notes),
|
|
2309
|
+
metadata = COALESCE(b.metadata, '{}'::jsonb) || jsonb_build_object('lastAgentEdit', now()),
|
|
2310
|
+
updated_at = now()
|
|
2311
|
+
WHERE b.id = v_id AND b.tenant_id = p_tenant_id;
|
|
2312
|
+
|
|
2313
|
+
-- The order mirrors the appointment's assignee/status so the financial side
|
|
2314
|
+
-- does not drift from the calendar.
|
|
2315
|
+
IF v_row.order_id IS NOT NULL THEN
|
|
2316
|
+
UPDATE orders o SET
|
|
2317
|
+
assignee_id = v_professional,
|
|
2318
|
+
status = CASE WHEN v_status = 'cancelled' THEN 'cancelled' ELSE o.status END,
|
|
2319
|
+
updated_at = now()
|
|
2320
|
+
WHERE o.id = v_row.order_id AND o.tenant_id = p_tenant_id;
|
|
2321
|
+
END IF;
|
|
2322
|
+
|
|
2323
|
+
INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
|
|
2324
|
+
VALUES (p_tenant_id, p_actor_user_id, 'agent.updateAppointment', 'appointment', v_id::text,
|
|
2325
|
+
jsonb_build_object('payload', p_payload,
|
|
2326
|
+
'previous', jsonb_build_object(
|
|
2327
|
+
'starts_at', v_row.starts_at,
|
|
2328
|
+
'ends_at', v_row.ends_at,
|
|
2329
|
+
'assignee_id', v_row.assignee_id,
|
|
2330
|
+
'status', v_row.status)));
|
|
2331
|
+
|
|
2332
|
+
SELECT a.id, a.starts_at, a.ends_at, a.status, a.client_name, a.professional_name
|
|
2333
|
+
INTO v_row
|
|
2334
|
+
FROM v_appointments a WHERE a.id = v_id;
|
|
2335
|
+
|
|
2336
|
+
RETURN jsonb_build_object(
|
|
2337
|
+
'ok', true,
|
|
2338
|
+
'id', v_id,
|
|
2339
|
+
'record', jsonb_build_object(
|
|
2340
|
+
'ref', jsonb_build_object('id', v_id, 'resource', 'appointments',
|
|
2341
|
+
'archetype', 'schedule:appointment'),
|
|
2342
|
+
'starts_at', v_row.starts_at,
|
|
2343
|
+
'ends_at', v_row.ends_at,
|
|
2344
|
+
'starts_at_local', to_char(v_row.starts_at AT TIME ZONE v_tz, 'YYYY-MM-DD (Dy) HH24:MI') || ' ' || v_tz,
|
|
2345
|
+
'ends_at_local', to_char(v_row.ends_at AT TIME ZONE v_tz, 'HH24:MI'),
|
|
2346
|
+
'client_name', v_row.client_name,
|
|
2347
|
+
'professional_name', v_row.professional_name,
|
|
2348
|
+
'status', v_row.status
|
|
2349
|
+
)
|
|
2350
|
+
);
|
|
2351
|
+
END;
|
|
2352
|
+
$$;
|
|
2353
|
+
|
|
2354
|
+
REVOKE ALL ON FUNCTION public.agent_agenda_update_appointment(uuid, uuid, jsonb) FROM public;
|
|
2355
|
+
REVOKE EXECUTE ON FUNCTION public.agent_agenda_update_appointment(uuid, uuid, jsonb) FROM anon;
|
|
2356
|
+
GRANT EXECUTE ON FUNCTION public.agent_agenda_update_appointment(uuid, uuid, jsonb)
|
|
2357
|
+
TO authenticated, service_role;
|
|
2358
|
+
`
|
|
2359
|
+
|
|
2116
2360
|
export const MIGRATIONS: Array<{ id: string; sql: string }> = [
|
|
2117
2361
|
{ id: "000b_v_bookings_rename", sql: MIGRATION_000B_V_BOOKINGS_RENAME },
|
|
2118
2362
|
{ id: "000c_dedupe_customer_phones", sql: MIGRATION_000C_DEDUPE_CUSTOMER_PHONES },
|
|
@@ -2129,4 +2373,5 @@ export const MIGRATIONS: Array<{ id: string; sql: string }> = [
|
|
|
2129
2373
|
{ id: "011_default_assignee", sql: MIGRATION_011_DEFAULT_ASSIGNEE },
|
|
2130
2374
|
{ id: "012_drop_legacy_booking_overload", sql: MIGRATION_012_DROP_LEGACY_BOOKING_OVERLOAD },
|
|
2131
2375
|
{ id: "013_normalize_v_appointments", sql: MIGRATION_013_NORMALIZE_V_APPOINTMENTS },
|
|
2376
|
+
{ id: "014_agent_rpc_update_appointment", sql: MIGRATION_014_AGENT_RPC_UPDATE_APPOINTMENT },
|
|
2132
2377
|
]
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// Shipped by plugins/plugin-agenda/src/migrations/001_public_booking.sql.
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import { 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.
|
|
31
29
|
function getClient(): SupabaseLikeClient {
|
|
32
|
-
|
|
30
|
+
const client = getSupabaseClientOptional()
|
|
31
|
+
if (!client) throw new Error('No Supabase client registered — call setGlobalSupabaseClient() at app startup.')
|
|
32
|
+
return client as SupabaseLikeClient
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/** Digits-only phone (RPC normalizes again server-side; keep the dial code). */
|
package/src/store.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createStore, type StoreApi } from 'zustand/vanilla'
|
|
2
|
+
import { emitDataChanged } from '@fayz-ai/core'
|
|
2
3
|
import { dedup } from '@fayz-ai/saas'
|
|
3
4
|
import { toast } from '@fayz-ai/ui'
|
|
4
5
|
import { getScheduleBlockConfig, setScheduleBlockConfig } from '@fayz-ai/saas'
|
|
@@ -181,6 +182,7 @@ export function createAgendaStore(provider: AgendaDataProvider, financialBridge?
|
|
|
181
182
|
const bookings = await provider.getBookings(getCurrentBookingQuery(state))
|
|
182
183
|
set({ bookings })
|
|
183
184
|
}
|
|
185
|
+
emitDataChanged({ table: 'appointments', op: 'create', id: booking.id, source: 'ui' })
|
|
184
186
|
toast.success('Appointment created')
|
|
185
187
|
return booking
|
|
186
188
|
} catch (err: any) {
|
|
@@ -197,6 +199,7 @@ export function createAgendaStore(provider: AgendaDataProvider, financialBridge?
|
|
|
197
199
|
const bookings = await provider.getBookings(getCurrentBookingQuery(state))
|
|
198
200
|
set({ bookings })
|
|
199
201
|
}
|
|
202
|
+
emitDataChanged({ table: 'appointments', op: 'update', id, source: 'ui' })
|
|
200
203
|
toast.success('Appointment updated')
|
|
201
204
|
return booking
|
|
202
205
|
} catch (err: any) {
|
|
@@ -213,6 +216,7 @@ export function createAgendaStore(provider: AgendaDataProvider, financialBridge?
|
|
|
213
216
|
const bookings = await provider.getBookings(getCurrentBookingQuery(state))
|
|
214
217
|
set({ bookings })
|
|
215
218
|
}
|
|
219
|
+
emitDataChanged({ table: 'appointments', op: 'delete', id, source: 'ui' })
|
|
216
220
|
toast.success('Appointment deleted')
|
|
217
221
|
} catch (err: any) {
|
|
218
222
|
toast.error('Failed to delete appointment', { description: err?.message })
|
|
@@ -232,6 +236,7 @@ export function createAgendaStore(provider: AgendaDataProvider, financialBridge?
|
|
|
232
236
|
const bookings = await provider.getBookings(getCurrentBookingQuery(state))
|
|
233
237
|
set({ bookings })
|
|
234
238
|
}
|
|
239
|
+
emitDataChanged({ table: 'appointments', op: 'update', id, source: 'ui' })
|
|
235
240
|
toast.success('Status updated')
|
|
236
241
|
} catch (err: any) {
|
|
237
242
|
// Rollback on failure
|