@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.
@@ -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;
@@ -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 VIEW public.v_appointments
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
- CREATE OR REPLACE VIEW public.v_public_services AS
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
- CREATE OR REPLACE VIEW public.rep_confirmation_queue AS
2046
- SELECT b.tenant_id,
2047
- b.id AS booking_id,
2048
- (b.starts_at)::date AS date,
2049
- b.starts_at,
2050
- b.client_id,
2051
- b.client_name,
2052
- b.professional_id,
2053
- b.professional_name,
2054
- COALESCE(((b.services -> 0) ->> 'name'::text), ((b.services -> 0) ->> 'serviceName'::text)) AS service_name,
2055
- COALESCE(a.confirmation_status, 'pending'::text) AS confirmation_status,
2056
- a.confirmation_channel,
2057
- a.confirmation_sent_at,
2058
- a.confirmed_at,
2059
- b.created_at,
2060
- b.updated_at
2061
- FROM (v_appointments b
2062
- LEFT JOIN legacy_pre_pools.appointments a ON ((a.booking_id = b.id)))
2063
- WHERE ((b.status <> ALL (ARRAY['cancelled'::text, 'no_show'::text])) AND (b.starts_at >= (now() - '1 day'::interval)));
2064
-
2065
- CREATE OR REPLACE VIEW public.rep_new_clients AS
2066
- SELECT c.tenant_id,
2067
- c.id AS client_id,
2068
- (c.created_at)::date AS date,
2069
- c.name AS client_name,
2070
- c.origin,
2071
- first_booking.service_name AS first_service,
2072
- c.created_at,
2073
- c.updated_at
2074
- FROM (v_clients c
2075
- LEFT JOIN LATERAL ( SELECT COALESCE(((b.services -> 0) ->> 'name'::text), ((b.services -> 0) ->> 'serviceName'::text)) AS service_name
2076
- FROM v_appointments b
2077
- WHERE (b.client_id = c.id)
2078
- ORDER BY b.starts_at
2079
- LIMIT 1) first_booking ON (true));
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 { getFayzCloudClient, getSupabaseClientOptional } from '@fayz-ai/core'
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
- return (getSupabaseClientOptional() ?? getFayzCloudClient()) as SupabaseLikeClient
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