@fayz-ai/plugin-agenda 0.8.4 → 0.9.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
 
@@ -2156,6 +2156,207 @@ DROP VIEW IF EXISTS public.v_bookings;
2156
2156
  DROP VIEW IF EXISTS public.v_bookings_legacy_beauty;
2157
2157
  `
2158
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
+
2159
2360
  export const MIGRATIONS: Array<{ id: string; sql: string }> = [
2160
2361
  { id: "000b_v_bookings_rename", sql: MIGRATION_000B_V_BOOKINGS_RENAME },
2161
2362
  { id: "000c_dedupe_customer_phones", sql: MIGRATION_000C_DEDUPE_CUSTOMER_PHONES },
@@ -2172,4 +2373,5 @@ export const MIGRATIONS: Array<{ id: string; sql: string }> = [
2172
2373
  { id: "011_default_assignee", sql: MIGRATION_011_DEFAULT_ASSIGNEE },
2173
2374
  { id: "012_drop_legacy_booking_overload", sql: MIGRATION_012_DROP_LEGACY_BOOKING_OVERLOAD },
2174
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 },
2175
2377
  ]
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
@@ -633,6 +633,34 @@ export function CalendarView() {
633
633
  // Alternating resource backgrounds — apply via DOM after render
634
634
  const wrapperRef = useRef<HTMLDivElement>(null)
635
635
 
636
+ // FullCalendar measures its columns once and only re-measures on a WINDOW
637
+ // resize. Opening/closing the assistant rail (or the sidebar) resizes the
638
+ // container without one, so the grid stayed locked at the narrow width until
639
+ // a real window resize came along. Watch the container and hand FC the new box.
640
+ useEffect(() => {
641
+ const container = wrapperRef.current
642
+ if (!container) return
643
+ let frame = 0
644
+ let lastWidth = container.clientWidth
645
+ let lastHeight = container.clientHeight
646
+ const ro = new ResizeObserver(() => {
647
+ const width = container.clientWidth
648
+ const height = container.clientHeight
649
+ if (width === lastWidth && height === lastHeight) return
650
+ lastWidth = width
651
+ lastHeight = height
652
+ // Next frame, not inline: measuring inside the observer callback reads a
653
+ // mid-layout box (and trips the RO loop warning). The rail animates, so
654
+ // this fires on each frame and the last one settles on the final width.
655
+ cancelAnimationFrame(frame)
656
+ frame = requestAnimationFrame(() => calendarRef.current?.getApi()?.updateSize())
657
+ })
658
+ ro.observe(container)
659
+ return () => { cancelAnimationFrame(frame); ro.disconnect() }
660
+ // Re-attach whenever the calendar branch remounts (skeleton → grid, mobile
661
+ // list → grid), since the observed node is gone on the other branches.
662
+ }, [isMobile, mobileGridActive, professionalsLoading, professionals.length])
663
+
636
664
  // Responsive day headers — collapse weekday names to a single initial (S M T
637
665
  // W T F S, Google-Calendar style) when day columns get too narrow. Measured
638
666
  // from the actual rendered column width so it adapts to resource count + view.