@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.
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +69 -1
- package/dist/index.js.map +1 -1
- package/dist/integrations/google-calendar/connectorDef.d.ts.map +1 -1
- package/dist/migrations/index.d.ts +1 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/views/CalendarView.d.ts.map +1 -1
- package/package.json +18 -15
- package/src/index.ts +40 -0
- package/src/integrations/google-calendar/connectorDef.tsx +8 -0
- package/src/migrations/014_agent_rpc_update_appointment.sql +199 -0
- package/src/migrations/index.ts +203 -1
- package/src/store.ts +5 -0
- package/src/views/CalendarView.tsx +28 -0
- package/dist/chunk-RX2HMWXS.cjs +0 -483
- package/dist/chunk-RX2HMWXS.cjs.map +0 -1
- package/dist/index.cjs +0 -5853
- package/dist/index.cjs.map +0 -1
- package/dist/public/index.cjs +0 -1391
- package/dist/public/index.cjs.map +0 -1
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
|
|
|
@@ -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.
|