@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 +1 @@
1
- {"version":3,"file":"connectorDef.d.ts","sourceRoot":"","sources":["../../../src/integrations/google-calendar/connectorDef.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAA;AAyJxD,eAAO,MAAM,0BAA0B,EAAE,mBAcxC,CAAA"}
1
+ {"version":3,"file":"connectorDef.d.ts","sourceRoot":"","sources":["../../../src/integrations/google-calendar/connectorDef.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAA;AAyJxD,eAAO,MAAM,0BAA0B,EAAE,mBAsBxC,CAAA"}
@@ -13,6 +13,7 @@ export declare const MIGRATION_010_PUBLIC_SERVICE_CURATION = "-- plugin-agenda 0
13
13
  export declare const MIGRATION_011_DEFAULT_ASSIGNEE = "-- plugin-agenda 011: honour a tenant's default professional.\n--\n-- create_public_booking picks the assignee from whoever's working hours cover\n-- the slot, ordered by assignee_id \u2014 i.e. the lowest UUID wins. That is fine\n-- when the caller names the professional, and wrong when it does not: the\n-- public page confirms \"Equipe Great DJs\" while the booking silently lands on\n-- an unrelated instructor whose UUID happened to sort first.\n--\n-- Clients shipped before p_assignee_id existed still call this with 7 named\n-- args, so the parameter defaults to NULL and every live booking on those sites\n-- is mis-assigned today. Republishing the SDK fixes the caller, but the sites\n-- already deployed cannot be fixed that way \u2014 this can, from the database.\n--\n-- Precedence: explicit arg > tenant default > first covering schedule.\n-- Tenants without the setting keep exactly the previous behaviour.\n\nCREATE OR REPLACE FUNCTION public.create_public_booking(p_tenant_id uuid, p_service_id uuid, p_starts_at timestamp with time zone, p_name text, p_phone text, p_email text DEFAULT NULL::text, p_notes text DEFAULT NULL::text, p_assignee_id uuid DEFAULT NULL::uuid)\n RETURNS TABLE(booking_id uuid, starts_at timestamp with time zone, ends_at timestamp with time zone)\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO 'public'\nAS $function$\nDECLARE\n v_service record;\n v_person_id uuid;\n v_assignee uuid;\n v_requested uuid;\n v_ends timestamptz;\n v_order uuid;\n v_booking uuid;\n v_phone text;\n v_tz text;\n v_notes text;\n v_client_kind text;\nBEGIN\n -- validation: anon-callable, everything tight\n IF p_name IS NULL OR length(trim(p_name)) NOT BETWEEN 2 AND 120 THEN\n RAISE EXCEPTION 'invalid name';\n END IF;\n v_phone := regexp_replace(COALESCE(p_phone, ''), '\\D', '', 'g');\n IF length(v_phone) NOT BETWEEN 8 AND 15 THEN\n RAISE EXCEPTION 'invalid phone';\n END IF;\n IF p_email IS NOT NULL AND (length(p_email) > 254 OR p_email !~ '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$') THEN\n RAISE EXCEPTION 'invalid email';\n END IF;\n v_notes := left(p_notes, 500);\n IF p_starts_at IS NULL OR p_starts_at <= now() OR p_starts_at > now() + interval '90 days' THEN\n RAISE EXCEPTION 'invalid start';\n END IF;\n\n SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes\n INTO v_service\n FROM services s\n WHERE s.id = p_service_id AND s.tenant_id = p_tenant_id\n AND s.is_active AND s.status = 'active';\n IF NOT FOUND THEN RAISE EXCEPTION 'unknown service'; END IF;\n\n v_ends := p_starts_at + make_interval(mins => v_service.duration_minutes);\n\n -- tenant config: timezone + client kind (admin-set; sanitized, never caller input)\n SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo'),\n COALESCE(NULLIF(trim(t.settings->'booking'->>'client_kind'), ''), 'customer')\n INTO v_tz, v_client_kind\n FROM tenants t WHERE t.id = p_tenant_id;\n IF v_tz IS NULL THEN RAISE EXCEPTION 'unknown tenant'; END IF;\n IF v_client_kind !~ '^[a-z_]{2,32}$' THEN v_client_kind := 'customer'; END IF;\n\n -- resolve the professional, in order of trust:\n -- 1. the id the caller sent (the one the visitor actually saw)\n -- 2. the tenant's configured default (settings->>'defaultAssigneeId')\n -- 3. whoever's working hours cover the slot\n --\n -- Step 2 is why this migration exists. Older published clients call this with\n -- 7 named args, leaving p_assignee_id NULL, and step 3 then resolved to\n -- \"lowest assignee UUID\" \u2014 an arbitrary instructor who had nothing to do with\n -- the name the site displayed on the confirmation screen. The default gives\n -- those clients a correct answer without waiting for a redeploy.\n v_requested := COALESCE(\n p_assignee_id,\n (SELECT NULLIF(t.settings ->> 'defaultAssigneeId', '')::uuid\n FROM tenants t WHERE t.id = p_tenant_id)\n );\n\n SELECT w.assignee_id INTO v_assignee\n FROM agenda_working_windows(\n p_tenant_id, (p_starts_at AT TIME ZONE v_tz)::date, v_requested) w\n WHERE (p_starts_at AT TIME ZONE v_tz)::time >= w.win_start\n AND (v_ends AT TIME ZONE v_tz)::time <= w.win_end\n ORDER BY w.assignee_id\n LIMIT 1;\n IF v_assignee IS NULL THEN RAISE EXCEPTION 'slot outside working hours'; END IF;\n\n -- serialize per (tenant, assignee) to kill the double-book race\n PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_assignee::text, 0));\n\n IF EXISTS (\n SELECT 1 FROM appointments b\n WHERE b.tenant_id = p_tenant_id\n AND b.assignee_id = v_assignee\n AND b.status NOT IN ('cancelled', 'no_show')\n AND b.starts_at < v_ends\n AND COALESCE(b.ends_at, b.starts_at) > p_starts_at\n ) THEN\n RAISE EXCEPTION 'slot no longer available';\n END IF;\n\n -- anti-spam: cap open future public bookings per phone per tenant\n IF (\n SELECT count(*) FROM appointments b\n JOIN people p ON p.id = b.party_id\n WHERE b.tenant_id = p_tenant_id\n AND p.phone = v_phone\n AND b.starts_at > now()\n AND b.status = 'scheduled'\n ) >= 3 THEN\n RAISE EXCEPTION 'too many open bookings for this phone';\n END IF;\n\n -- upsert the client by (tenant, kind, phone), serialized by advisory lock\n -- (kind is tenant-configured, so the customer-only partial index can't\n -- arbitrate)\n PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || ':' || v_phone, 1));\n\n SELECT p.id INTO v_person_id\n FROM people p\n WHERE p.tenant_id = p_tenant_id AND p.kind = v_client_kind AND p.phone = v_phone\n ORDER BY p.created_at\n LIMIT 1;\n\n IF v_person_id IS NOT NULL THEN\n UPDATE people SET\n name = trim(p_name),\n email = COALESCE(p_email, people.email),\n updated_at = now()\n WHERE id = v_person_id;\n ELSE\n INSERT INTO people (tenant_id, kind, name, phone, email, metadata)\n VALUES (p_tenant_id, v_client_kind, trim(p_name), v_phone, p_email,\n jsonb_build_object('source', 'public_booking'))\n RETURNING id INTO v_person_id;\n END IF;\n\n -- order + booking + order_item (statuses hardcoded \u2014 caller cannot set)\n INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)\n VALUES (p_tenant_id, 'appointment', 'scheduled', v_person_id, v_assignee,\n v_service.price, v_service.price, v_notes,\n jsonb_build_object('source', 'public_booking',\n 'serviceNames', v_service.name,\n 'contactName', trim(p_name)))\n RETURNING id INTO v_order;\n\n INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,\n starts_at, ends_at, status, notes, metadata)\n VALUES (p_tenant_id, 'appointment', v_person_id, v_assignee, v_order,\n p_starts_at, v_ends, 'scheduled', v_notes,\n jsonb_build_object('source', 'public_booking', 'serviceNames', v_service.name))\n RETURNING id INTO v_booking;\n\n INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,\n sort_order, duration_minutes, assignee_id)\n VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,\n 0, v_service.duration_minutes, v_assignee);\n\n RETURN QUERY SELECT v_booking, p_starts_at, v_ends;\nEND;\n$function$;\n";
14
14
  export declare const MIGRATION_012_DROP_LEGACY_BOOKING_OVERLOAD = "-- plugin-agenda 012: retire the pre-assignee create_public_booking overload.\n--\n-- \u26A0\uFE0F DO NOT APPLY UNTIL @fayz-ai/plugin-agenda IS REPUBLISHED *AND* EVERY LIVE\n-- SITE HAS BEEN REDEPLOYED. Applying early breaks public booking on any\n-- site still serving a bundle that calls the 7-argument signature \u2014 the\n-- call stops resolving and every submission fails.\n--\n-- Some pools carry two overloads side by side:\n-- create_public_booking(uuid,uuid,timestamptz,text,text,text,text)\n-- create_public_booking(uuid,uuid,timestamptz,text,text,text,text,uuid)\n--\n-- Nothing errors when both exist, which is exactly the problem: a stale client\n-- silently resolves to the 7-arg one, which cannot know which professional the\n-- visitor was shown and falls back to \"lowest assignee UUID\". The site confirms\n-- one name, the agenda records another, and no one finds out until someone\n-- compares the two screens.\n--\n-- 011 makes that fallback survivable (tenant default). This makes it\n-- impossible \u2014 but only once no caller needs the old signature.\n--\n-- Verify before running:\n-- 1. npm view @fayz-ai/plugin-agenda version \u2192 the republished version\n-- 2. every deployed bundle greps for p_assignee_id:\n-- curl -s https://<site>/assets/index-*.js | grep -c p_assignee_id\n-- Each must be \u2265 2 (slots + booking). Zero means that site still needs it.\n\nDROP FUNCTION IF EXISTS public.create_public_booking(\n uuid, uuid, timestamptz, text, text, text, text\n);\n";
15
15
  export declare const MIGRATION_013_NORMALIZE_V_APPOINTMENTS = "-- plugin-agenda 013: one booking read model \u2014 v_appointments.\n--\n-- Three views described the same 34 rows:\n--\n-- v_bookings \u2192 thin alias over the legacy view (002 compat shim)\n-- v_bookings_legacy_beauty \u2192 the pre-pools shape, still feeding 5 rep_* views\n-- v_appointments \u2192 the canonical name, and the only one the SDK reads\n--\n-- The school pool already had only v_appointments; the salon pool carried all\n-- three. Same data, three names, and reports quietly wired to the oldest one.\n--\n-- v_appointments was a strict SUBSET, so it could not simply absorb them. It\n-- gains the 7 columns it lacked \u2014 most importantly `services`, the order_items\n-- aggregate that 4 of the 5 reports depend on. That also retires a documented\n-- N+1: getBookingById fetches order_items in a second round-trip precisely\n-- because this view had no line items (see plugin-agenda/src/data/supabase.ts).\n--\n-- Deliberately NOT copied from the legacy view:\n-- \u2022 total_duration_minutes \u2014 legacy sums order_items durations, the canonical\n-- measures the slot. They disagree, no report reads it, and the SDK relies\n-- on the canonical meaning. Kept as-is.\n-- \u2022 client_name \u2014 the canonical COALESCEs with metadata->>'title' so blocks\n-- and personal events show a label instead of NULL. Kept as-is (an\n-- improvement the legacy shape never had).\n\n-- 1. v_appointments becomes the superset. Columns are APPENDED: CREATE OR\n-- REPLACE VIEW cannot reorder or drop, and the SDK selects '*'.\nCREATE OR REPLACE VIEW public.v_appointments\nWITH (security_invoker = true) AS\n SELECT b.id,\n b.tenant_id,\n b.kind,\n b.starts_at,\n b.ends_at,\n b.status,\n b.notes,\n b.order_id,\n b.location_id,\n b.metadata,\n b.created_at,\n b.updated_at,\n b.party_id AS client_id,\n COALESCE(pc.name, (b.metadata ->> 'title'::text)) AS client_name,\n pc.phone AS client_phone,\n pc.email AS client_email,\n pc.avatar_url AS client_avatar_url,\n b.assignee_id AS professional_id,\n ps.name AS professional_name,\n ps.avatar_url AS professional_avatar_url,\n l.name AS location_name,\n o.total AS order_total,\n o.status AS order_status,\n ((EXTRACT(epoch FROM (COALESCE(b.ends_at, b.starts_at) - b.starts_at)))::integer / 60) AS total_duration_minutes,\n -- appended:\n o.reference_number,\n o.status AS stage,\n (o.metadata ->> 'direction'::text) AS direction,\n ( SELECT json_agg(json_build_object('id', oi.id, 'serviceId', oi.service_id,\n 'name', oi.name, 'durationMinutes', oi.duration_minutes,\n 'price', oi.unit_price, 'assigneeId', oi.assignee_id)\n ORDER BY oi.sort_order)\n FROM order_items oi\n WHERE (oi.order_id = b.order_id)) AS services,\n inv.status AS payment_status,\n inv.paid AS order_paid,\n inv.balance AS order_balance\n FROM (((((appointments b\n LEFT JOIN people pc ON ((pc.id = b.party_id)))\n LEFT JOIN people ps ON ((ps.id = b.assignee_id)))\n LEFT JOIN locations l ON ((l.id = b.location_id)))\n LEFT JOIN orders o ON ((o.id = b.order_id)))\n LEFT JOIN v_invoice_balances inv ON ((inv.invoice_id = b.order_id)));\n\n-- 2. Repoint the reports off the legacy view. Bodies are unchanged\n-- except for the source relation.\n\nCREATE OR REPLACE VIEW public.rep_appointments_by_period AS\nSELECT tenant_id,\n id AS booking_id,\n (starts_at)::date AS date,\n starts_at,\n client_id,\n client_name,\n professional_id,\n professional_name,\n COALESCE(((services -> 0) ->> 'name'::text), ((services -> 0) ->> 'serviceName'::text)) AS service_name,\n status,\n order_total AS revenue,\n created_at,\n updated_at\n FROM v_appointments b;\n\n-- legacy_pre_pools only exists on pools migrated from the pre-pool era; fresh\n-- pools get the same shape with confirmation columns defaulting to pending/NULL.\nDO $$\nBEGIN\n IF to_regclass('legacy_pre_pools.appointments') IS NOT NULL THEN\n EXECUTE $v$\n CREATE OR REPLACE VIEW public.rep_confirmation_queue AS\n SELECT b.tenant_id,\n b.id AS booking_id,\n (b.starts_at)::date AS date,\n b.starts_at,\n b.client_id,\n b.client_name,\n b.professional_id,\n b.professional_name,\n COALESCE(((b.services -> 0) ->> 'name'::text), ((b.services -> 0) ->> 'serviceName'::text)) AS service_name,\n COALESCE(a.confirmation_status, 'pending'::text) AS confirmation_status,\n a.confirmation_channel,\n a.confirmation_sent_at,\n a.confirmed_at,\n b.created_at,\n b.updated_at\n FROM (v_appointments b\n LEFT JOIN legacy_pre_pools.appointments a ON ((a.booking_id = b.id)))\n WHERE ((b.status <> ALL (ARRAY['cancelled'::text, 'no_show'::text])) AND (b.starts_at >= (now() - '1 day'::interval)))\n $v$;\n ELSE\n EXECUTE $v$\n CREATE OR REPLACE VIEW public.rep_confirmation_queue AS\n SELECT b.tenant_id,\n b.id AS booking_id,\n (b.starts_at)::date AS date,\n b.starts_at,\n b.client_id,\n b.client_name,\n b.professional_id,\n b.professional_name,\n COALESCE(((b.services -> 0) ->> 'name'::text), ((b.services -> 0) ->> 'serviceName'::text)) AS service_name,\n 'pending'::text AS confirmation_status,\n NULL::text AS confirmation_channel,\n NULL::timestamptz AS confirmation_sent_at,\n NULL::timestamptz AS confirmed_at,\n b.created_at,\n b.updated_at\n FROM v_appointments b\n WHERE ((b.status <> ALL (ARRAY['cancelled'::text, 'no_show'::text])) AND (b.starts_at >= (now() - '1 day'::interval)))\n $v$;\n END IF;\nEND $$;\n\n-- v_clients (beauty Ring-2 extension) may not exist on non-salon pools \u2014 the\n-- report only makes sense where it does.\nDO $$\nBEGIN\n IF to_regclass('public.v_clients') IS NULL THEN RETURN; END IF;\n EXECUTE $v$\n CREATE OR REPLACE VIEW public.rep_new_clients AS\n SELECT c.tenant_id,\n c.id AS client_id,\n (c.created_at)::date AS date,\n c.name AS client_name,\n c.origin,\n first_booking.service_name AS first_service,\n c.created_at,\n c.updated_at\n FROM (v_clients c\n LEFT JOIN LATERAL ( SELECT COALESCE(((b.services -> 0) ->> 'name'::text), ((b.services -> 0) ->> 'serviceName'::text)) AS service_name\n FROM v_appointments b\n WHERE (b.client_id = c.id)\n ORDER BY b.starts_at\n LIMIT 1) first_booking ON (true))\n $v$;\nEND $$;\n\nCREATE OR REPLACE VIEW public.rep_revenue_by_professional AS\nSELECT tenant_id,\n (starts_at)::date AS date,\n professional_id,\n COALESCE(professional_name, 'Sem profissional'::text) AS professional_name,\n (count(*))::integer AS appointment_count,\n (COALESCE(sum(NULLIF(order_total, (0)::numeric)), (0)::numeric))::numeric(14,2) AS total_revenue,\n (COALESCE(avg(NULLIF(order_total, (0)::numeric)), (0)::numeric))::numeric(14,2) AS avg_ticket,\n (0)::numeric(14,2) AS commission,\n max(updated_at) AS updated_at\n FROM v_appointments b\n WHERE (status <> ALL (ARRAY['cancelled'::text, 'no_show'::text]))\n GROUP BY tenant_id, ((starts_at)::date), professional_id, COALESCE(professional_name, 'Sem profissional'::text);\n\nCREATE OR REPLACE VIEW public.rep_revenue_by_service AS\nSELECT b.tenant_id,\n (b.starts_at)::date AS date,\n NULLIF((service_item.value ->> 'serviceId'::text), ''::text) AS service_id,\n COALESCE((service_item.value ->> 'name'::text), (service_item.value ->> 'serviceName'::text), 'Sem servico'::text) AS service_name,\n (count(DISTINCT b.id))::integer AS quantity,\n (COALESCE(sum((NULLIF((service_item.value ->> 'price'::text), ''::text))::numeric), (0)::numeric))::numeric(14,2) AS total_revenue,\n (COALESCE(avg((NULLIF((service_item.value ->> 'price'::text), ''::text))::numeric), (0)::numeric))::numeric(14,2) AS avg_ticket,\n max(b.updated_at) AS updated_at\n FROM (v_appointments b\n CROSS JOIN LATERAL jsonb_array_elements(COALESCE((b.services)::jsonb, '[]'::jsonb)) service_item(value))\n WHERE (b.status <> ALL (ARRAY['cancelled'::text, 'no_show'::text]))\n GROUP BY b.tenant_id, ((b.starts_at)::date), NULLIF((service_item.value ->> 'serviceId'::text), ''::text), COALESCE((service_item.value ->> 'name'::text), (service_item.value ->> 'serviceName'::text), 'Sem servico'::text);\n\n-- 3. Retire the duplicates. v_bookings goes first: it reads the legacy view.\n-- Nothing in the SDK references either name (checked: only migrations and a\n-- README mention v_bookings).\nDROP VIEW IF EXISTS public.v_bookings;\nDROP VIEW IF EXISTS public.v_bookings_legacy_beauty;\n";
16
+ export declare const MIGRATION_014_AGENT_RPC_UPDATE_APPOINTMENT = "-- ============================================================================\n-- plugin-agenda 014: agent_agenda_update_appointment \u2014 remarcar / reatribuir /\n-- cancelar um agendamento existente.\n--\n-- Lacuna que isso fecha: o agente sabia CRIAR e n\u00E3o sabia MEXER. Pedido real\n-- (\"remarca esse paciente pra 16:30\") ca\u00EDa no updateRecord gen\u00E9rico, que bate\n-- em v_appointments \u2014 uma view \u2014 e devolve read_only; o modelo ent\u00E3o \"resolvia\"\n-- abrindo um agendamento NOVO, deixando o antigo de p\u00E9. Duplicar a agenda de um\n-- sal\u00E3o \u00E9 pior do que n\u00E3o fazer nada.\n--\n-- Mesma cadeia de guardas do 008 (agent_guard \u2192 janela de trabalho com exce\u00E7\u00F5es\n-- \u2192 lock advisory \u2192 checagem de conflito \u2192 auditoria), com tr\u00EAs diferen\u00E7as:\n-- \u2022 a\u00E7\u00E3o 'edit', sem cota (remarcar n\u00E3o consome bookings_month);\n-- \u2022 o pr\u00F3prio agendamento \u00E9 exclu\u00EDdo da checagem de conflito consigo mesmo;\n-- \u2022 cancelar n\u00E3o precisa de hor\u00E1rio livre \u2014 s\u00F3 o guard.\n-- Requer 007 (agenda_working_windows). Idempotente.\n-- ============================================================================\n\nCREATE OR REPLACE FUNCTION public.agent_agenda_update_appointment(\n p_tenant_id uuid,\n p_actor_user_id uuid,\n p_payload jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER\nSET search_path = public\nAS $$\nDECLARE\n v_denial jsonb;\n v_id uuid;\n v_tz text;\n v_row record;\n v_starts_raw text;\n v_starts timestamptz;\n v_ends timestamptz;\n v_duration int;\n v_professional uuid;\n v_status text;\n v_notes text;\n v_wants_move boolean;\n v_wants_status boolean;\nBEGIN\n SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz\n FROM tenants t WHERE t.id = p_tenant_id;\n IF v_tz IS NULL THEN\n RETURN jsonb_build_object('ok', false, 'error', 'unknown tenant');\n END IF;\n\n -- \u2500\u2500 payload \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n BEGIN\n v_id := (p_payload->>'appointment_id')::uuid;\n v_professional := (p_payload->>'professional_id')::uuid;\n v_starts_raw := p_payload->>'starts_at';\n EXCEPTION WHEN OTHERS THEN\n RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);\n END;\n v_status := nullif(trim(lower(p_payload->>'status')), '');\n v_notes := left(p_payload->>'notes', 500);\n v_wants_move := v_starts_raw IS NOT NULL OR v_professional IS NOT NULL;\n v_wants_status := v_status IS NOT NULL;\n\n IF v_id IS NULL THEN\n RETURN jsonb_build_object('ok', false, 'error',\n 'appointment_id is required \u2014 search the agenda first and reuse the id of the appointment to change');\n END IF;\n IF NOT v_wants_move AND NOT v_wants_status AND v_notes IS NULL THEN\n RETURN jsonb_build_object('ok', false, 'error',\n 'nothing to change: send starts_at, professional_id, status or notes');\n END IF;\n IF v_status IS NOT NULL\n AND v_status NOT IN ('scheduled', 'confirmed', 'in_progress', 'completed', 'cancelled', 'no_show') THEN\n RETURN jsonb_build_object('ok', false, 'error',\n 'status must be one of scheduled, confirmed, in_progress, completed, cancelled, no_show');\n END IF;\n\n -- \u2500\u2500 the appointment must exist, in THIS tenant \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n SELECT b.id, b.starts_at, b.ends_at, b.assignee_id, b.status, b.party_id, b.order_id\n INTO v_row\n FROM appointments b\n WHERE b.id = v_id AND b.tenant_id = p_tenant_id;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('ok', false, 'error', 'unknown appointment for this tenant');\n END IF;\n\n -- \u2500\u2500 authorization: role \u2192 plan. No cap: editing frees nothing and buys\n -- nothing, so charging bookings_month for a reschedule would be wrong.\n v_denial := agent_guard(p_tenant_id, p_actor_user_id, 'appointments', 'edit');\n IF v_denial IS NOT NULL THEN\n RETURN jsonb_build_object('ok', false, 'denial', v_denial);\n END IF;\n\n -- Duration is preserved across a move \u2014 a 60-minute appointment stays 60.\n v_duration := GREATEST(\n COALESCE(EXTRACT(EPOCH FROM (v_row.ends_at - v_row.starts_at)) / 60, 30)::int, 5);\n v_professional := COALESCE(v_professional, v_row.assignee_id);\n\n IF v_wants_move THEN\n IF v_starts_raw IS NOT NULL THEN\n BEGIN\n IF v_starts_raw ~ '(Z|[+-][0-9]{2}:?[0-9]{2})$' THEN\n v_starts := v_starts_raw::timestamptz; -- explicit offset\n ELSE\n v_starts := (v_starts_raw::timestamp) AT TIME ZONE v_tz; -- tenant-local\n END IF;\n EXCEPTION WHEN OTHERS THEN\n RETURN jsonb_build_object('ok', false, 'error', 'invalid starts_at: ' || SQLERRM);\n END;\n ELSE\n v_starts := v_row.starts_at;\n END IF;\n v_ends := v_starts + make_interval(mins => v_duration);\n\n IF v_starts > now() + interval '365 days' THEN\n RETURN jsonb_build_object('ok', false, 'error', 'starts_at must be within the next year');\n END IF;\n\n -- Working window (exception-aware, 007) for the target professional.\n IF NOT EXISTS (\n SELECT 1\n FROM agenda_working_windows(\n p_tenant_id, (v_starts AT TIME ZONE v_tz)::date, v_professional) w\n WHERE w.assignee_id = v_professional\n AND (v_starts AT TIME ZONE v_tz)::time >= w.win_start\n AND (v_ends AT TIME ZONE v_tz)::time <= w.win_end\n ) THEN\n RETURN jsonb_build_object('ok', false, 'error',\n 'that slot is outside the professional''s working hours. Suggest a different time. Times are tenant-local, tz ' || v_tz);\n END IF;\n\n PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_professional::text, 0));\n IF EXISTS (\n SELECT 1 FROM appointments b\n WHERE b.tenant_id = p_tenant_id\n AND b.assignee_id = v_professional\n AND b.id <> v_id -- never conflicts with itself\n AND b.status NOT IN ('cancelled', 'no_show')\n AND b.starts_at < v_ends\n AND COALESCE(b.ends_at, b.starts_at) > v_starts\n ) THEN\n RETURN jsonb_build_object('ok', false, 'error',\n 'that slot is already taken. Suggest a different time.');\n END IF;\n END IF;\n\n -- \u2500\u2500 write + audit \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n UPDATE appointments b SET\n starts_at = COALESCE(v_starts, b.starts_at),\n ends_at = COALESCE(v_ends, b.ends_at),\n assignee_id = v_professional,\n status = COALESCE(v_status, b.status),\n notes = COALESCE(v_notes, b.notes),\n metadata = COALESCE(b.metadata, '{}'::jsonb) || jsonb_build_object('lastAgentEdit', now()),\n updated_at = now()\n WHERE b.id = v_id AND b.tenant_id = p_tenant_id;\n\n -- The order mirrors the appointment's assignee/status so the financial side\n -- does not drift from the calendar.\n IF v_row.order_id IS NOT NULL THEN\n UPDATE orders o SET\n assignee_id = v_professional,\n status = CASE WHEN v_status = 'cancelled' THEN 'cancelled' ELSE o.status END,\n updated_at = now()\n WHERE o.id = v_row.order_id AND o.tenant_id = p_tenant_id;\n END IF;\n\n INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)\n VALUES (p_tenant_id, p_actor_user_id, 'agent.updateAppointment', 'appointment', v_id::text,\n jsonb_build_object('payload', p_payload,\n 'previous', jsonb_build_object(\n 'starts_at', v_row.starts_at,\n 'ends_at', v_row.ends_at,\n 'assignee_id', v_row.assignee_id,\n 'status', v_row.status)));\n\n SELECT a.id, a.starts_at, a.ends_at, a.status, a.client_name, a.professional_name\n INTO v_row\n FROM v_appointments a WHERE a.id = v_id;\n\n RETURN jsonb_build_object(\n 'ok', true,\n 'id', v_id,\n 'record', jsonb_build_object(\n 'ref', jsonb_build_object('id', v_id, 'resource', 'appointments',\n 'archetype', 'schedule:appointment'),\n 'starts_at', v_row.starts_at,\n 'ends_at', v_row.ends_at,\n 'starts_at_local', to_char(v_row.starts_at AT TIME ZONE v_tz, 'YYYY-MM-DD (Dy) HH24:MI') || ' ' || v_tz,\n 'ends_at_local', to_char(v_row.ends_at AT TIME ZONE v_tz, 'HH24:MI'),\n 'client_name', v_row.client_name,\n 'professional_name', v_row.professional_name,\n 'status', v_row.status\n )\n );\nEND;\n$$;\n\nREVOKE ALL ON FUNCTION public.agent_agenda_update_appointment(uuid, uuid, jsonb) FROM public;\nREVOKE EXECUTE ON FUNCTION public.agent_agenda_update_appointment(uuid, uuid, jsonb) FROM anon;\nGRANT EXECUTE ON FUNCTION public.agent_agenda_update_appointment(uuid, uuid, jsonb)\n TO authenticated, service_role;\n";
16
17
  export declare const MIGRATIONS: Array<{
17
18
  id: string;
18
19
  sql: string;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,gCAAgC,09BAqB5C,CAAA;AAED,eAAO,MAAM,qCAAqC,giDA2CjD,CAAA;AAED,eAAO,MAAM,4BAA4B,q0ZAwSxC,CAAA;AAED,eAAO,MAAM,+BAA+B,0sBAc3C,CAAA;AAED,eAAO,MAAM,gCAAgC,q5OA+K5C,CAAA;AAED,eAAO,MAAM,qCAAqC,oiPAgLjD,CAAA;AAED,eAAO,MAAM,wBAAwB,uzQAwKpC,CAAA;AAED,eAAO,MAAM,iCAAiC,0xRAqL7C,CAAA;AAED,eAAO,MAAM,2CAA2C,i5YAkSvD,CAAA;AAED,eAAO,MAAM,4CAA4C,i8QA4KxD,CAAA;AAED,eAAO,MAAM,qCAAqC,04MAkKjD,CAAA;AAED,eAAO,MAAM,qCAAqC,y2CA+BjD,CAAA;AAED,eAAO,MAAM,8BAA8B,ivOAuK1C,CAAA;AAED,eAAO,MAAM,0CAA0C,6gDA6BtD,CAAA;AAED,eAAO,MAAM,sCAAsC,2yRAuMlD,CAAA;AAED,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAgBzD,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,gCAAgC,09BAqB5C,CAAA;AAED,eAAO,MAAM,qCAAqC,giDA2CjD,CAAA;AAED,eAAO,MAAM,4BAA4B,q0ZAwSxC,CAAA;AAED,eAAO,MAAM,+BAA+B,0sBAc3C,CAAA;AAED,eAAO,MAAM,gCAAgC,q5OA+K5C,CAAA;AAED,eAAO,MAAM,qCAAqC,oiPAgLjD,CAAA;AAED,eAAO,MAAM,wBAAwB,uzQAwKpC,CAAA;AAED,eAAO,MAAM,iCAAiC,0xRAqL7C,CAAA;AAED,eAAO,MAAM,2CAA2C,i5YAkSvD,CAAA;AAED,eAAO,MAAM,4CAA4C,i8QA4KxD,CAAA;AAED,eAAO,MAAM,qCAAqC,04MAkKjD,CAAA;AAED,eAAO,MAAM,qCAAqC,y2CA+BjD,CAAA;AAED,eAAO,MAAM,8BAA8B,ivOAuK1C,CAAA;AAED,eAAO,MAAM,0CAA0C,6gDA6BtD,CAAA;AAED,eAAO,MAAM,sCAAsC,2yRAuMlD,CAAA;AAED,eAAO,MAAM,0CAA0C,o7SAuMtD,CAAA;AAED,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAiBzD,CAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"CalendarView.d.ts","sourceRoot":"","sources":["../../src/views/CalendarView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA4D,MAAM,OAAO,CAAA;AAwRhF,wBAAgB,YAAY,sBAmyB3B"}
1
+ {"version":3,"file":"CalendarView.d.ts","sourceRoot":"","sources":["../../src/views/CalendarView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA4D,MAAM,OAAO,CAAA;AAwRhF,wBAAgB,YAAY,sBA+zB3B"}
package/package.json CHANGED
@@ -3,24 +3,25 @@
3
3
  "fayz": {
4
4
  "status": "beta"
5
5
  },
6
- "version": "0.8.4",
6
+ "version": "0.9.0-next.1",
7
7
  "description": "Fayz SDK — plugin-agenda plugin",
8
8
  "type": "module",
9
- "main": "./dist/index.cjs",
9
+ "sideEffects": [
10
+ "**/*.css"
11
+ ],
12
+ "main": "./dist/index.js",
10
13
  "module": "./dist/index.js",
11
14
  "types": "./dist/index.d.ts",
12
15
  "exports": {
13
16
  ".": {
14
17
  "source": "./src/index.ts",
15
18
  "types": "./dist/index.d.ts",
16
- "import": "./dist/index.js",
17
- "require": "./dist/index.cjs"
19
+ "import": "./dist/index.js"
18
20
  },
19
21
  "./public": {
20
22
  "source": "./src/public/index.ts",
21
23
  "types": "./dist/public/index.d.ts",
22
- "import": "./dist/public/index.js",
23
- "require": "./dist/public/index.cjs"
24
+ "import": "./dist/public/index.js"
24
25
  }
25
26
  },
26
27
  "files": [
@@ -29,11 +30,11 @@
29
30
  ],
30
31
  "peerDependencies": {
31
32
  "react": "^18.0.0 || ^19.0.0",
32
- "react-dom": "^18.0.0 || ^19.0.0"
33
+ "react-dom": "^18.0.0 || ^19.0.0",
34
+ "zustand": "^4.5.0 || ^5.0.0",
35
+ "lucide-react": ">=0.400.0 <1.0.0"
33
36
  },
34
37
  "dependencies": {
35
- "lucide-react": "^0.400.0",
36
- "zustand": "^4.5.0",
37
38
  "@fullcalendar/core": "^6.1.20",
38
39
  "@fullcalendar/daygrid": "^6.1.20",
39
40
  "@fullcalendar/timegrid": "^6.1.20",
@@ -43,10 +44,10 @@
43
44
  "@tanstack/react-table": "^8.20.0",
44
45
  "@fullcalendar/resource-timegrid": "^6.1.0",
45
46
  "@fullcalendar/list": "^6.1.0",
46
- "@fayz-ai/core": "^0.8.4",
47
- "@fayz-ai/ui": "^0.8.4",
48
- "@fayz-ai/saas": "^0.8.4",
49
- "@fayz-ai/plugin-financial": "^0.8.4"
47
+ "@fayz-ai/ui": "^0.9.0-next.1",
48
+ "@fayz-ai/saas": "^0.9.0-next.1",
49
+ "@fayz-ai/core": "^0.9.0-next.1",
50
+ "@fayz-ai/plugin-financial": "^0.9.0-next.1"
50
51
  },
51
52
  "devDependencies": {
52
53
  "@types/react": "^18.3.0",
@@ -54,8 +55,10 @@
54
55
  "tsup": "^8.2.0",
55
56
  "typescript": "^5.5.0",
56
57
  "@types/react-dom": "^18.3.0",
57
- "vitest": "^2.1.9",
58
- "react-dom": "^18.3.0"
58
+ "vitest": "^4.1.5",
59
+ "react-dom": "^18.3.0",
60
+ "zustand": "^4.5.0",
61
+ "lucide-react": "^0.400.0"
59
62
  },
60
63
  "license": "MIT",
61
64
  "keywords": [
package/src/index.ts CHANGED
@@ -108,6 +108,13 @@ export function createAgendaPlugin(options?: AgendaPluginOptions): PluginManifes
108
108
  'Guarded appointment create: agent_guard (role→plan→bookings_month cap), working hours, race-safe conflict check, audited.',
109
109
  audits: true,
110
110
  },
111
+ {
112
+ name: 'agent_agenda_update_appointment',
113
+ kind: 'write' as const,
114
+ description:
115
+ 'Guarded appointment reschedule/reassign/cancel: agent_guard (role→plan, no cap), working hours, race-safe conflict check that skips the appointment itself, audited.',
116
+ audits: true,
117
+ },
111
118
  ],
112
119
 
113
120
  navigation: [
@@ -181,6 +188,39 @@ export function createAgendaPlugin(options?: AgendaPluginOptions): PluginManifes
181
188
  ],
182
189
  permission: { feature: 'appointments', action: 'create' as const },
183
190
  },
191
+ {
192
+ id: 'agenda.update-appointment',
193
+ name: 'updateAppointment',
194
+ description:
195
+ 'Changes an appointment that ALREADY EXISTS: reschedule (starts_at), reassign (professional_id), cancel or confirm (status), or annotate (notes). ' +
196
+ 'This is the ONLY way to change an appointment — the appointments entity is a read-only view, so updateRecord cannot touch it. ' +
197
+ 'NEVER answer a reschedule or a cancellation by creating a new appointment: that leaves the original standing and double-books the professional. ' +
198
+ 'Takes the appointment id from a previous search. The duration is preserved automatically. The user confirms before anything changes.',
199
+ icon: 'CalendarClock',
200
+ mode: 'persist' as const,
201
+ execution: { plane: 'server' as const, kind: 'rpc' as const, rpc: 'agent_agenda_update_appointment' },
202
+ category: 'Scheduling',
203
+ parameters: {
204
+ type: 'object' as const,
205
+ properties: {
206
+ appointment_id: { type: 'string' as const, description: 'Id of the appointment to change (from a search)' },
207
+ starts_at: { type: 'string' as const, description: "New LOCAL date-time of the business, NO timezone suffix — e.g. '2026-07-27T16:30'. Omit to keep the current time." },
208
+ professional_id: { type: 'string' as const, description: 'Move to another professional (optional)' },
209
+ status: {
210
+ type: 'string' as const,
211
+ description: 'New status — use "cancelled" to cancel',
212
+ enum: ['scheduled', 'confirmed', 'in_progress', 'completed', 'cancelled', 'no_show'],
213
+ },
214
+ notes: { type: 'string' as const, description: 'Optional notes' },
215
+ },
216
+ required: ['appointment_id'],
217
+ },
218
+ suggestions: [
219
+ { label: 'Remarca o Gustavo para 16:30' },
220
+ { label: 'Cancela o atendimento das 10h' },
221
+ ],
222
+ permission: { feature: 'appointments', action: 'edit' as const },
223
+ },
184
224
  {
185
225
  id: 'agenda.check-availability',
186
226
  name: 'checkAvailability',
@@ -167,6 +167,14 @@ export const googleCalendarConnectorDef: ConnectorDefinition = {
167
167
  description: 'Two-way sync between your bookings and Google Calendar.',
168
168
  icon: 'Calendar',
169
169
  authKind: 'oauth',
170
+ category: 'scheduling',
171
+ direction: 'outbound',
172
+ // Sync only, and deliberately: the agenda has to exist inside the app — to be
173
+ // listed, filtered, reported on, and read when Google is unreachable. An
174
+ // agentic plane would answer questions but leave the calendar screen empty.
175
+ planes: ['sync'],
176
+ entities: ['booking'],
177
+ docsUrl: 'https://support.google.com/calendar',
170
178
  async getStatus() {
171
179
  const integ = await provider.getIntegration()
172
180
  return { connected: !!integ?.connected }
@@ -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;