@fayz-ai/plugin-agenda 0.10.2 → 0.11.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.
@@ -1 +1 @@
1
- {"version":3,"file":"en.d.ts","sourceRoot":"","sources":["../../src/locales/en.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAyNrC,CAAA"}
1
+ {"version":3,"file":"en.d.ts","sourceRoot":"","sources":["../../src/locales/en.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CA2NrC,CAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"pt-BR.d.ts","sourceRoot":"","sources":["../../src/locales/pt-BR.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAyNvC,CAAA"}
1
+ {"version":3,"file":"pt-BR.d.ts","sourceRoot":"","sources":["../../src/locales/pt-BR.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CA2NvC,CAAA"}
@@ -14,6 +14,7 @@ export declare const MIGRATION_011_DEFAULT_ASSIGNEE = "-- plugin-agenda 011: hon
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
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";
17
+ export declare const MIGRATION_015_ANALYTICS_READ_MODELS = "-- ============================================================================\n-- @fayz-ai/plugin-agenda \u2014 read model for the analytics engine, and the\n-- security_invoker the report views never got.\n--\n-- TWO THINGS, and the first one is a hole, not a feature.\n--\n-- 1. rep_appointments_by_period, rep_revenue_by_service and\n-- rep_revenue_by_professional were created without\n-- `WITH (security_invoker = true)`. A view runs as its OWNER, migrations are\n-- applied as `postgres`, and a table owner is exempt from that table's RLS \u2014\n-- so on a shared industry pool any signed-in user of ANY tenant could read\n-- every tenant's agenda revenue. The spine's 043_view_invoker.sql closes it\n-- everywhere at once; this carries the fix home to the plugin that owns the\n-- views, as that file asks. ALTER VIEW ... SET is additive and idempotent,\n-- so the two agree wherever both land.\n--\n-- Registering a leaky view in the analytics allowlist would have WIDENED it:\n-- analytics_run takes the tenant id as a PARAMETER, so without invoker\n-- semantics a client could simply pass somebody else's. The tenant predicate\n-- is defence in depth; RLS is the lock.\n--\n-- 2. plg_agenda_rep_bookings \u2014 rep_appointments_by_period plus the 0/1 flags.\n-- The engine has no conditional aggregate (see 024), so \"no-show rate\" and\n-- \"revenue actually earned\" cannot be expressed by a card over the raw view:\n-- its `revenue` is order_total on EVERY row, which bills the school for the\n-- lesson the student skipped. The flags put the condition in the read model,\n-- where it can be read once and audited.\n--\n-- Idempotent. Guarded per object, so a pool without the agenda skips it.\n-- ============================================================================\n\n-- ----------------------------------------------------------------------------\n-- 1. Close the leak on the existing report views.\n-- ----------------------------------------------------------------------------\nDO $$\nDECLARE\n v text;\n leaky text[] := ARRAY[\n 'rep_appointments_by_period',\n 'rep_revenue_by_service',\n 'rep_revenue_by_professional',\n 'rep_confirmation_queue'\n ];\nBEGIN\n FOREACH v IN ARRAY leaky LOOP\n IF to_regclass('public.' || v) IS NOT NULL THEN\n EXECUTE format('ALTER VIEW public.%I SET (security_invoker = true)', v);\n END IF;\n END LOOP;\nEND $$;\n\n-- ----------------------------------------------------------------------------\n-- 2. The analytics read model.\n--\n-- `date` is the spine, not created_at: a booking is measured by when it\n-- HAPPENS, so a range that reads \"this month\" must return this month's\n-- lessons and not the ones typed into the system this month.\n-- ----------------------------------------------------------------------------\nDO $$\nBEGIN\n IF to_regclass('public.rep_appointments_by_period') IS NULL THEN\n RAISE NOTICE 'agenda: rep_appointments_by_period missing \u2014 read model skipped';\n RETURN;\n END IF;\n\n EXECUTE $v$\n CREATE OR REPLACE VIEW public.plg_agenda_rep_bookings\n WITH (security_invoker = true) AS\n SELECT\n b.tenant_id,\n b.booking_id,\n b.date,\n b.starts_at,\n b.client_id,\n b.client_name,\n b.professional_id,\n b.professional_name,\n b.service_name,\n b.status,\n b.revenue AS booked_revenue,\n -- What the chair actually earned, and what it did not.\n CASE WHEN b.status IN ('cancelled', 'no_show') THEN 0 ELSE COALESCE(b.revenue, 0) END AS effective_revenue,\n CASE WHEN b.status IN ('cancelled', 'no_show') THEN COALESCE(b.revenue, 0) ELSE 0 END AS lost_revenue,\n CASE WHEN b.status = 'completed' THEN 1 ELSE 0 END AS is_completed,\n CASE WHEN b.status = 'cancelled' THEN 1 ELSE 0 END AS is_cancelled,\n CASE WHEN b.status = 'no_show' THEN 1 ELSE 0 END AS is_no_show,\n b.created_at,\n b.updated_at\n FROM public.rep_appointments_by_period b\n $v$;\n\n GRANT SELECT ON public.plg_agenda_rep_bookings TO authenticated;\nEND $$;\n\n-- ----------------------------------------------------------------------------\n-- 3. Register.\n-- ----------------------------------------------------------------------------\nDO $$\nBEGIN\n IF to_regclass('public.plg_analytics_read_models') IS NULL THEN\n RAISE NOTICE 'agenda: analytics engine not installed \u2014 read models not registered';\n RETURN;\n END IF;\n\n IF to_regclass('public.plg_agenda_rep_bookings') IS NOT NULL THEN\n INSERT INTO public.plg_analytics_read_models (name, date_column, tenant_column, description) VALUES\n ('plg_agenda_rep_bookings', 'date', 'tenant_id',\n 'One row per booking, dated by when it happens, with status flags and earned/lost revenue')\n ON CONFLICT (name) DO UPDATE\n SET date_column = EXCLUDED.date_column,\n tenant_column = EXCLUDED.tenant_column,\n description = EXCLUDED.description;\n END IF;\n\n IF to_regclass('public.rep_revenue_by_service') IS NOT NULL THEN\n INSERT INTO public.plg_analytics_read_models (name, date_column, tenant_column, description) VALUES\n ('rep_revenue_by_service', 'date', 'tenant_id',\n 'Revenue per service per day; cancellations and no-shows already excluded')\n ON CONFLICT (name) DO UPDATE\n SET date_column = EXCLUDED.date_column,\n tenant_column = EXCLUDED.tenant_column,\n description = EXCLUDED.description;\n END IF;\n\n IF to_regclass('public.rep_revenue_by_professional') IS NOT NULL THEN\n INSERT INTO public.plg_analytics_read_models (name, date_column, tenant_column, description) VALUES\n ('rep_revenue_by_professional', 'date', 'tenant_id',\n 'Revenue per professional per day; cancellations and no-shows already excluded')\n ON CONFLICT (name) DO UPDATE\n SET date_column = EXCLUDED.date_column,\n tenant_column = EXCLUDED.tenant_column,\n description = EXCLUDED.description;\n END IF;\nEND $$;\n";
17
18
  export declare const MIGRATIONS: Array<{
18
19
  id: string;
19
20
  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,0CAA0C,o7SAuMtD,CAAA;AAED,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAiBzD,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,mCAAmC,08LAsI/C,CAAA;AAED,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAkBzD,CAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"CalendarView.d.ts","sourceRoot":"","sources":["../../src/views/CalendarView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA4D,MAAM,OAAO,CAAA;AA2QhF,wBAAgB,YAAY,sBA22B3B"}
1
+ {"version":3,"file":"CalendarView.d.ts","sourceRoot":"","sources":["../../src/views/CalendarView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA4D,MAAM,OAAO,CAAA;AA2QhF,wBAAgB,YAAY,sBAk3B3B"}
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "fayz": {
4
4
  "status": "beta"
5
5
  },
6
- "version": "0.10.2",
6
+ "version": "0.11.0",
7
7
  "description": "Fayz SDK — plugin-agenda plugin",
8
8
  "type": "module",
9
9
  "sideEffects": [
@@ -41,10 +41,10 @@
41
41
  "@tanstack/react-table": "^8.20.0",
42
42
  "@fullcalendar/resource-timegrid": "^6.1.0",
43
43
  "@fullcalendar/list": "^6.1.0",
44
- "@fayz-ai/core": "^0.14.0",
45
- "@fayz-ai/ui": "^0.14.0",
46
- "@fayz-ai/admin": "^0.14.0",
47
- "@fayz-ai/plugin-financial": "^0.10.0"
44
+ "@fayz-ai/core": "^0.15.0",
45
+ "@fayz-ai/admin": "^0.15.0",
46
+ "@fayz-ai/plugin-financial": "^0.11.0",
47
+ "@fayz-ai/ui": "^0.15.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@types/react": "^18.3.0",