@fayz-ai/plugin-agenda 0.12.1 → 0.13.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.
- package/dist/bridges/create-financial-bridge.d.ts.map +1 -1
- package/dist/{chunk-ISLJXP7O.js → chunk-PDYBAVXU.js} +6 -4
- package/dist/chunk-PDYBAVXU.js.map +1 -0
- package/dist/components/AppointmentModal.d.ts.map +1 -1
- package/dist/components/CalendarSidebar.d.ts.map +1 -1
- package/dist/components/CheckinDialog.d.ts +24 -0
- package/dist/components/CheckinDialog.d.ts.map +1 -0
- package/dist/components/FilterRail.d.ts +42 -0
- package/dist/components/FilterRail.d.ts.map +1 -0
- package/dist/components/MiniCalendar.d.ts +1 -8
- package/dist/components/MiniCalendar.d.ts.map +1 -1
- package/dist/components/PaymentStatusBadge.d.ts +4 -9
- package/dist/components/PaymentStatusBadge.d.ts.map +1 -1
- package/dist/components/StatusBadge.d.ts +6 -0
- package/dist/components/StatusBadge.d.ts.map +1 -1
- package/dist/config.d.ts +14 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/data/mock.d.ts.map +1 -1
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/data/types.d.ts +2 -2
- package/dist/data/types.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2343 -472
- package/dist/index.js.map +1 -1
- package/dist/integrations/google-calendar/connectorDef.d.ts +1 -1
- package/dist/integrations/google-calendar/credentials.d.ts +1 -1
- package/dist/locales/en.d.ts.map +1 -1
- package/dist/locales/pt-BR.d.ts.map +1 -1
- package/dist/migrations/index.d.ts +7 -21
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/public/index.js +2 -2
- package/dist/store.d.ts +8 -3
- package/dist/store.d.ts.map +1 -1
- package/dist/types.d.ts +17 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/views/CalendarView.d.ts +16 -0
- package/dist/views/CalendarView.d.ts.map +1 -1
- package/dist/views/CheckinPage.d.ts +12 -0
- package/dist/views/CheckinPage.d.ts.map +1 -0
- package/dist/views/CheckinView.d.ts +13 -0
- package/dist/views/CheckinView.d.ts.map +1 -0
- package/package.json +5 -5
- package/dist/chunk-ISLJXP7O.js.map +0 -1
|
@@ -1,24 +1,10 @@
|
|
|
1
|
-
export declare const MIGRATION_000B_V_BOOKINGS_RENAME = "-- Salon-only unblock: the pre-pools beauty deployment built rep_* report\n-- views on top of its (wider) v_bookings view, so 001's cleanup DROP cannot\n-- run there. Renaming keeps the dependents attached (they track the OID) and\n-- frees the name for 001; 002_v_bookings_compat re-creates v_bookings as a\n-- thin alias for live pre-M5 clients. No-op on fresh pools and on pools whose\n-- v_bookings has no dependents (001's DROP handles those).\n\nDO $rename$\nBEGIN\n IF to_regclass('public.v_bookings') IS NOT NULL\n AND to_regclass('public.v_bookings_legacy_beauty') IS NULL\n AND EXISTS (\n SELECT 1 FROM pg_depend d\n JOIN pg_rewrite rw ON rw.oid = d.objid\n JOIN pg_class dep ON dep.oid = rw.ev_class\n JOIN pg_class ref ON ref.oid = d.refobjid\n WHERE ref.relname = 'v_bookings' AND dep.relname <> 'v_bookings'\n ) THEN\n EXECUTE 'ALTER VIEW public.v_bookings RENAME TO v_bookings_legacy_beauty';\n END IF;\nEND $rename$;\n";
|
|
2
|
-
export declare const
|
|
3
|
-
export declare const MIGRATION_001_PUBLIC_BOOKING = "-- ============================================================================\n-- plugin-agenda 001: booking read model + public (anon) booking surface\n--\n-- Ships the objects the agenda providers reference but the spine (packages/db\n-- migrations 001\u2013010) does not define:\n-- \u00A70 converted-pool cleanup \u2014 drop the pre-rename v_bookings view / indexes\n-- \u00A71 order_items columns the admin provider writes (duration/assignee)\n-- \u00A72 public.v_appointments \u2014 admin read model (authenticated, RLS applies)\n-- \u00A73 public.v_public_services \u2014 anon-readable service catalog\n-- \u00A74 public.get_available_slots \u2014 anon RPC (mirrors the mock algorithm)\n-- \u00A75 public.create_public_booking \u2014 anon RPC (validated, race-safe write)\n--\n-- Core v1 lives directly in PUBLIC (no separate core schema): people (was\n-- persons), appointments (was bookings). This migration is written against public\n-- names and is idempotent + safe on both fresh and converted pools.\n--\n-- Anon-access design: public booking pages run with the publishable key and no\n-- session, so the canonical tenant RLS yields nothing. Anon access is granted\n-- ONLY via the column-whitelisted view (\u00A73) and SECURITY DEFINER RPCs (\u00A74, \u00A75)\n-- with tight input validation and hardcoded statuses.\n-- ============================================================================\n\n-- \u00A70 \u2014 converted-pool cleanup. The pre-industry-pool shape shipped a v_bookings\n-- view and persons_/bookings_-named indexes; drop them so this file re-emits the\n-- canonical v_appointments view + people_/appointments_ indexes without leaving\n-- duplicates. All guarded with IF EXISTS \u2192 no-ops on a fresh pool.\nDROP VIEW IF EXISTS public.v_bookings;\nDROP INDEX IF EXISTS public.persons_tenant_customer_phone;\nDROP INDEX IF EXISTS public.bookings_assignee_time;\n\n-- \u00A71 \u2014 columns the agenda provider writes but 004_archetypes lacks\nALTER TABLE public.order_items\n ADD COLUMN IF NOT EXISTS duration_minutes integer,\n ADD COLUMN IF NOT EXISTS assignee_id uuid REFERENCES public.people(id) ON DELETE SET NULL;\n\n-- Lookup key for public customers (per tenant, by normalized phone).\n-- NOT UNIQUE. It was, and spine/110 says why it cannot be: `kind` became a\n-- derived column, so an imported 'client' turns into a 'customer' and a\n-- placeholder phone shared by eight real people lands under one key. The upsert\n-- that used to lean on it takes a per-(tenant, phone) advisory lock instead\n-- (003, 009) \u2014 the index is here for the lookup that lock guards.\nDROP INDEX IF EXISTS public.people_tenant_customer_phone;\nCREATE INDEX IF NOT EXISTS people_tenant_customer_phone\n ON public.people(tenant_id, phone)\n WHERE kind = 'customer' AND phone IS NOT NULL;\n\n-- Slot-conflict scan\nCREATE INDEX IF NOT EXISTS appointments_assignee_time\n ON public.appointments(assignee_id, starts_at, ends_at)\n WHERE status NOT IN ('cancelled', 'no_show');\n\n-- \u00A72 \u2014 admin read model (security_invoker \u2192 underlying RLS applies; anon sees 0 rows)\n-- DROP first: 013 widens this view and CREATE OR REPLACE cannot narrow a live\n-- view on replay (\"cannot drop columns from view\"). 013 replays right after.\n-- 013 builds on this one, so the drop needs its dependents out and back (143).\nSELECT app.detach_view_dependents('public.v_appointments');\nDROP VIEW IF EXISTS public.v_appointments;\nCREATE VIEW public.v_appointments\nWITH (security_invoker = true) AS\nSELECT\n b.id, b.tenant_id, b.kind, b.starts_at, b.ends_at, b.status, b.notes,\n b.order_id, b.location_id, b.metadata, b.created_at, b.updated_at,\n b.party_id AS client_id,\n COALESCE(pc.name, b.metadata->>'title') 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))::int / 60)\n AS total_duration_minutes\nFROM public.appointments b\nLEFT JOIN public.people pc ON pc.id = b.party_id\nLEFT JOIN public.people ps ON ps.id = b.assignee_id\nLEFT JOIN public.locations l ON l.id = b.location_id\nLEFT JOIN public.orders o ON o.id = b.order_id;\n\nGRANT SELECT ON public.v_appointments TO authenticated, service_role;\nREVOKE SELECT ON public.v_appointments FROM anon;\n\n-- \u00A73 \u2014 anon-readable public catalog (owner-rights view = deliberate, whitelisted)\n-- DROP first: 010 widens this view (curation columns) \u2014 same replay rule as \u00A72.\nDROP VIEW IF EXISTS public.v_public_services;\nCREATE VIEW public.v_public_services AS\nSELECT\n s.id, s.tenant_id, s.name, s.description, s.price, s.currency,\n COALESCE(s.duration_minutes, 30) AS duration_minutes,\n s.image_url,\n COALESCE((s.metadata->>'sort_order')::int, 0) AS sort_order\nFROM public.services s\nWHERE s.is_active AND s.status = 'active';\n\nGRANT SELECT ON public.v_public_services TO anon, authenticated, service_role;\n\n-- \u00A74 \u2014 availability RPC (mirrors plugin-agenda/src/data/mock.ts getAvailableSlots)\nCREATE OR REPLACE FUNCTION public.get_available_slots(\n p_tenant_id uuid,\n p_date date,\n p_duration_minutes integer,\n p_assignee_id uuid DEFAULT NULL,\n p_slot_interval integer DEFAULT 30\n) RETURNS TABLE (slot_start timestamptz, slot_end timestamptz)\nLANGUAGE plpgsql STABLE SECURITY DEFINER\nSET search_path = public\nAS $$\nDECLARE\n v_tz text;\nBEGIN\n -- tight validation: this function is anon-callable\n IF p_duration_minutes IS NULL OR p_duration_minutes NOT BETWEEN 5 AND 480 THEN\n RAISE EXCEPTION 'invalid duration';\n END IF;\n IF p_slot_interval IS NULL OR p_slot_interval NOT BETWEEN 5 AND 240 THEN\n RAISE EXCEPTION 'invalid interval';\n END IF;\n IF p_date IS NULL OR p_date < current_date OR p_date > current_date + 90 THEN\n RETURN;\n END IF;\n\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 RETURN; END IF; -- unknown tenant \u2192 empty, not an error\n\n RETURN QUERY\n WITH day_schedules AS (\n SELECT sc.assignee_id, sc.starts_at AS win_start, sc.ends_at AS win_end\n FROM schedules sc\n WHERE sc.tenant_id = p_tenant_id\n AND sc.kind = 'working_hours'\n AND sc.is_active\n AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)\n AND (\n sc.specific_date = p_date\n OR (sc.specific_date IS NULL AND sc.day_of_week = EXTRACT(dow FROM p_date)::smallint)\n )\n ),\n candidate AS (\n SELECT\n ds.assignee_id,\n gs AS s_start,\n gs + make_interval(mins => p_duration_minutes) AS s_end\n FROM day_schedules ds\n CROSS JOIN LATERAL generate_series(\n (p_date + ds.win_start) AT TIME ZONE v_tz,\n ((p_date + ds.win_end) AT TIME ZONE v_tz) - make_interval(mins => p_duration_minutes),\n make_interval(mins => p_slot_interval)\n ) AS gs\n )\n SELECT DISTINCT c.s_start, c.s_end\n FROM candidate c\n WHERE NOT EXISTS (\n SELECT 1 FROM appointments b\n WHERE b.tenant_id = p_tenant_id\n AND b.assignee_id = c.assignee_id\n AND b.status NOT IN ('cancelled', 'no_show')\n AND b.starts_at < c.s_end\n AND COALESCE(b.ends_at, b.starts_at) > c.s_start\n )\n ORDER BY 1;\nEND;\n$$;\n\nREVOKE ALL ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer) FROM public;\nGRANT EXECUTE ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer)\n TO anon, authenticated, service_role;\n\n-- \u00A75 \u2014 public booking write RPC (validated, race-safe, hardcoded statuses)\nCREATE OR REPLACE FUNCTION public.create_public_booking(\n p_tenant_id uuid,\n p_service_id uuid,\n p_starts_at timestamptz,\n p_name text,\n p_phone text,\n p_email text DEFAULT NULL,\n p_notes text DEFAULT NULL\n) RETURNS TABLE (booking_id uuid, starts_at timestamptz, ends_at timestamptz)\nLANGUAGE plpgsql SECURITY DEFINER\nSET search_path = public\nAS $$\nDECLARE\n v_service record;\n v_person_id uuid;\n v_assignee 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;\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 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 RAISE EXCEPTION 'unknown tenant'; END IF;\n\n -- pick the assignee whose working hours cover the slot\n SELECT sc.assignee_id INTO v_assignee\n FROM schedules sc\n WHERE sc.tenant_id = p_tenant_id\n AND sc.kind = 'working_hours'\n AND sc.is_active\n AND (\n sc.specific_date = (p_starts_at AT TIME ZONE v_tz)::date\n OR (sc.specific_date IS NULL\n AND sc.day_of_week = EXTRACT(dow FROM p_starts_at AT TIME ZONE v_tz)::smallint)\n )\n AND (p_starts_at AT TIME ZONE v_tz)::time >= sc.starts_at\n AND (v_ends AT TIME ZONE v_tz)::time <= sc.ends_at\n ORDER BY sc.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 customer by (tenant, phone). NOT ON CONFLICT: the partial unique\n -- index this used to infer is gone (spine/110 \u2014 a placeholder phone is shared\n -- by real, distinct people). 003 already settled the replacement: take a\n -- per-(tenant, phone) advisory lock so two first-bookings serialise, then look\n -- before inserting. Oldest match wins, so a returning caller keeps the record\n -- that carries their history.\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 = 'customer' 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, 'customer', 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$$;\n\nREVOKE ALL ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text) FROM public;\nGRANT EXECUTE ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text)\n TO anon, authenticated, service_role;\n\nSELECT app.reattach_view_dependents();\n";
|
|
4
|
-
export declare const
|
|
5
|
-
export declare const
|
|
6
|
-
export declare const
|
|
7
|
-
export declare const
|
|
8
|
-
export declare const MIGRATION_006_AGENT_RPC_LOCALTIME = "-- ============================================================================\n-- plugin-agenda 006: agent_agenda_create_appointment speaks LOCAL time.\n--\n-- Models reliably mangle UTC\u2194local offsets (\"10h\" became 13:00 BRT via a\n-- wrong sign). The RPC knows the tenant's timezone \u2014 so stop asking the model\n-- to convert: `starts_at` WITHOUT an offset (2026-07-27T10:00) is interpreted\n-- in the tenant tz; an explicit offset still works. The result now carries\n-- starts_at_local/ends_at_local strings so the reply echoes the truth in the\n-- user's own clock. Supersedes 005's function body (new file \u2014 applied\n-- migrations are never edited).\n-- ============================================================================\n\nCREATE OR REPLACE FUNCTION public.agent_agenda_create_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_used int;\n v_client_id uuid;\n v_service_id uuid;\n v_professional uuid;\n v_starts timestamptz;\n v_starts_raw text;\n v_notes text;\n v_service record;\n v_client record;\n v_ends timestamptz;\n v_tz text;\n v_order uuid;\n v_booking uuid;\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_client_id := (p_payload->>'client_id')::uuid;\n v_service_id := (p_payload->>'service_id')::uuid;\n v_professional := (p_payload->>'professional_id')::uuid;\n v_starts_raw := p_payload->>'starts_at';\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 payload: ' || SQLERRM);\n END;\n v_notes := left(p_payload->>'notes', 500);\n IF v_client_id IS NULL OR v_service_id IS NULL OR v_starts IS NULL THEN\n RETURN jsonb_build_object('ok', false, 'error', 'client_id, service_id and starts_at are required');\n END IF;\n IF v_starts <= now() OR v_starts > now() + interval '365 days' THEN\n RETURN jsonb_build_object('ok', false, 'error', 'starts_at must be in the future (max 1 year)');\n END IF;\n\n -- \u2500\u2500 authorization: role \u2192 plan \u2192 bookings_month cap \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n SELECT count(*) INTO v_used FROM appointments b\n WHERE b.tenant_id = p_tenant_id\n AND b.created_at >= date_trunc('month', now());\n v_denial := agent_guard(p_tenant_id, p_actor_user_id,\n 'appointments', 'create', 'bookings_month', v_used, 1);\n IF v_denial IS NOT NULL THEN\n RETURN jsonb_build_object('ok', false, 'denial', v_denial);\n END IF;\n\n -- \u2500\u2500 invariants \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 SELECT p.id, p.name INTO v_client\n FROM people p\n WHERE p.id = v_client_id AND p.tenant_id = p_tenant_id;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('ok', false, 'error', 'unknown client for this tenant');\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 = v_service_id AND s.tenant_id = p_tenant_id\n AND s.is_active AND s.status = 'active';\n IF NOT FOUND THEN\n RETURN jsonb_build_object('ok', false, 'error', 'unknown or inactive service');\n END IF;\n\n v_ends := v_starts + make_interval(mins => v_service.duration_minutes);\n\n SELECT sc.assignee_id INTO v_professional\n FROM schedules sc\n WHERE sc.tenant_id = p_tenant_id\n AND sc.kind = 'working_hours'\n AND sc.is_active\n AND (v_professional IS NULL OR sc.assignee_id = v_professional)\n AND (\n sc.specific_date = (v_starts AT TIME ZONE v_tz)::date\n OR (sc.specific_date IS NULL\n AND sc.day_of_week = EXTRACT(dow FROM v_starts AT TIME ZONE v_tz)::smallint)\n )\n AND (v_starts AT TIME ZONE v_tz)::time >= sc.starts_at\n AND (v_ends AT TIME ZONE v_tz)::time <= sc.ends_at\n -- prefer a professional who is actually FREE at the slot (when the user\n -- did not name one, the first busy professional must not block the booking)\n AND NOT EXISTS (\n SELECT 1 FROM appointments b\n WHERE b.tenant_id = p_tenant_id\n AND b.assignee_id = sc.assignee_id\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 )\n ORDER BY sc.assignee_id\n LIMIT 1;\n IF v_professional IS NULL THEN\n RETURN jsonb_build_object('ok', false, 'error',\n 'no professional is available at that slot (outside working hours or already booked). 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.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', 'slot no longer available');\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 INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)\n VALUES (p_tenant_id, 'appointment', 'scheduled', v_client_id, v_professional,\n v_service.price, v_service.price, v_notes,\n jsonb_build_object('source', 'agent', 'serviceNames', v_service.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_client_id, v_professional, v_order,\n v_starts, v_ends, 'scheduled', v_notes,\n jsonb_build_object('source', 'agent', '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_professional);\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.createAppointment', 'appointment', v_booking::text,\n jsonb_build_object('payload', p_payload, 'order_id', v_order));\n\n RETURN jsonb_build_object(\n 'ok', true,\n 'id', v_booking,\n 'record', jsonb_build_object(\n 'ref', jsonb_build_object('id', v_booking, 'resource', 'appointments',\n 'archetype', 'schedule:appointment'),\n 'starts_at', v_starts,\n 'ends_at', v_ends,\n 'starts_at_local', to_char(v_starts AT TIME ZONE v_tz, 'YYYY-MM-DD (Dy) HH24:MI') || ' ' || v_tz,\n 'ends_at_local', to_char(v_ends AT TIME ZONE v_tz, 'HH24:MI'),\n 'client_name', v_client.name,\n 'service_name', v_service.name,\n 'price', v_service.price,\n 'status', 'scheduled'\n )\n );\nEND;\n$$;\n\nREVOKE ALL ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb) FROM public;\nREVOKE EXECUTE ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb) FROM anon;\nGRANT EXECUTE ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb)\n TO authenticated, service_role;\n";
|
|
9
|
-
export declare const MIGRATION_007_SCHEDULE_EXCEPTION_PRECEDENCE = "-- ============================================================================\n-- plugin-agenda 007: specific-date exceptions take PRECEDENCE over weekly hours.\n--\n-- The admin ScheduleEditor (saveException) writes two kinds of specific-date\n-- rows: 'available' = active rows with custom windows for the date; day off\n-- ('unavailable') = ONE inactive 00:00\u201300:00 sentinel. Until now every reader\n-- treated schedules as a flat union filtered by is_active, so:\n-- \u2022 a day off was INVISIBLE (inactive sentinel filtered out, weekly rows\n-- still matched) \u2014 the public site kept selling a day the admin closed;\n-- \u2022 an 'available' exception ADDED to the weekly hours instead of replacing\n-- them (\"hor\u00E1rio especial\" should define the date's hours).\n--\n-- Fix: one helper \u2014 agenda_working_windows(tenant, date, assignee) \u2014 is now the\n-- single source of a day's windows: if ANY specific-date row exists for an\n-- assignee (active or sentinel), only the ACTIVE specific-date rows count for\n-- that assignee; otherwise the weekly (day_of_week) rows apply. Both readers\n-- are redefined on top of it:\n-- \u2022 get_available_slots \u2014 body otherwise identical to the latest version\n-- (google-calendar/002_smart_sync \u00A75, tenant-wide 'block' clause KEPT);\n-- \u2022 create_public_booking \u2014 body otherwise identical to 004.\n--\n-- ORDERING: on pools with the Google Calendar connector this file must run\n-- AFTER integrations/google-calendar/migrations/002_smart_sync.sql (both\n-- redefine get_available_slots; 007 is the superset and must win). Re-running\n-- 007 last is always safe \u2014 it preserves 002's block semantics.\n-- ============================================================================\n\n-- ----------------------------------------------------------------------------\n-- \u00A71 agenda_working_windows \u2014 the day's working windows, exception-aware.\n-- ----------------------------------------------------------------------------\nCREATE OR REPLACE FUNCTION public.agenda_working_windows(\n p_tenant_id uuid,\n p_date date,\n p_assignee_id uuid DEFAULT NULL\n) RETURNS TABLE (assignee_id uuid, win_start time, win_end time)\nLANGUAGE sql STABLE\nSET search_path = public\nAS $$\n WITH exc AS (\n -- Every specific-date row for the date, ACTIVE OR NOT: the inactive day-off\n -- sentinel must still suppress the weekly rows below.\n SELECT sc.assignee_id, sc.starts_at, sc.ends_at, sc.is_active\n FROM schedules sc\n WHERE sc.tenant_id = p_tenant_id\n AND sc.kind = 'working_hours'\n AND sc.specific_date = p_date\n AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)\n )\n SELECT e.assignee_id, e.starts_at, e.ends_at\n FROM exc e\n WHERE e.is_active\n UNION ALL\n SELECT sc.assignee_id, sc.starts_at, sc.ends_at\n FROM schedules sc\n WHERE sc.tenant_id = p_tenant_id\n AND sc.kind = 'working_hours'\n AND sc.is_active\n AND sc.specific_date IS NULL\n AND sc.day_of_week = EXTRACT(dow FROM p_date)::smallint\n AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)\n AND NOT EXISTS (SELECT 1 FROM exc e WHERE e.assignee_id = sc.assignee_id)\n$$;\n\nREVOKE ALL ON FUNCTION public.agenda_working_windows(uuid, date, uuid) FROM public;\nREVOKE EXECUTE ON FUNCTION public.agenda_working_windows(uuid, date, uuid) FROM anon;\nGRANT EXECUTE ON FUNCTION public.agenda_working_windows(uuid, date, uuid)\n TO authenticated, service_role;\n\n-- ----------------------------------------------------------------------------\n-- \u00A72 get_available_slots \u2014 day_schedules now comes from the helper.\n-- Everything else is byte-identical to google-calendar/002_smart_sync \u00A75.\n-- ----------------------------------------------------------------------------\nCREATE OR REPLACE FUNCTION public.get_available_slots(\n p_tenant_id uuid,\n p_date date,\n p_duration_minutes integer,\n p_assignee_id uuid DEFAULT NULL,\n p_slot_interval integer DEFAULT 30\n) RETURNS TABLE (slot_start timestamptz, slot_end timestamptz)\nLANGUAGE plpgsql STABLE SECURITY DEFINER\nSET search_path = public\nAS $$\nDECLARE\n v_tz text;\nBEGIN\n -- tight validation: this function is anon-callable\n IF p_duration_minutes IS NULL OR p_duration_minutes NOT BETWEEN 5 AND 480 THEN\n RAISE EXCEPTION 'invalid duration';\n END IF;\n IF p_slot_interval IS NULL OR p_slot_interval NOT BETWEEN 5 AND 240 THEN\n RAISE EXCEPTION 'invalid interval';\n END IF;\n IF p_date IS NULL OR p_date < current_date OR p_date > current_date + 90 THEN\n RETURN;\n END IF;\n\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 RETURN; END IF; -- unknown tenant \u2192 empty, not an error\n\n RETURN QUERY\n WITH day_schedules AS (\n SELECT w.assignee_id, w.win_start, w.win_end\n FROM agenda_working_windows(p_tenant_id, p_date, p_assignee_id) w\n ),\n candidate AS (\n SELECT\n ds.assignee_id,\n gs AS s_start,\n gs + make_interval(mins => p_duration_minutes) AS s_end\n FROM day_schedules ds\n CROSS JOIN LATERAL generate_series(\n (p_date + ds.win_start) AT TIME ZONE v_tz,\n ((p_date + ds.win_end) AT TIME ZONE v_tz) - make_interval(mins => p_duration_minutes),\n make_interval(mins => p_slot_interval)\n ) AS gs\n )\n SELECT DISTINCT c.s_start, c.s_end\n FROM candidate c\n WHERE NOT EXISTS (\n SELECT 1 FROM appointments b\n WHERE b.tenant_id = p_tenant_id\n -- SMART SYNC (gcal 002): this professional's own bookings OR any\n -- tenant-wide BLOCK (assignee_id IS NULL AND kind='block') busy the slot.\n AND (b.assignee_id = c.assignee_id\n OR (b.assignee_id IS NULL AND b.kind = 'block'))\n AND b.status NOT IN ('cancelled', 'no_show')\n AND b.starts_at < c.s_end\n AND COALESCE(b.ends_at, b.starts_at) > c.s_start\n )\n ORDER BY 1;\nEND;\n$$;\n\nREVOKE ALL ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer) FROM public;\nGRANT EXECUTE ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer)\n TO anon, authenticated, service_role;\n\n-- ----------------------------------------------------------------------------\n-- \u00A73 create_public_booking \u2014 professional resolution goes through the helper\n-- (a booking on a closed date now fails 'slot outside working hours').\n-- Everything else is byte-identical to 004.\n-- ----------------------------------------------------------------------------\nCREATE OR REPLACE FUNCTION public.create_public_booking(\n p_tenant_id uuid,\n p_service_id uuid,\n p_starts_at timestamptz,\n p_name text,\n p_phone text,\n p_email text DEFAULT NULL,\n p_notes text DEFAULT NULL,\n p_assignee_id uuid DEFAULT NULL\n) RETURNS TABLE (booking_id uuid, starts_at timestamptz, ends_at timestamptz)\nLANGUAGE plpgsql SECURITY DEFINER\nSET search_path = public\nAS $$\nDECLARE\n v_service record;\n v_person_id uuid;\n v_assignee 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: the one the visitor saw (validated), else the\n -- first whose EXCEPTION-AWARE working hours cover the slot (007)\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, p_assignee_id) 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$$;\n\nREVOKE ALL ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text, uuid) FROM public;\nGRANT EXECUTE ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text, uuid)\n TO anon, authenticated, service_role;\n";
|
|
10
|
-
export declare const MIGRATION_008_AGENT_RPC_EXCEPTION_PRECEDENCE = "-- ============================================================================\n-- plugin-agenda 008: agent_agenda_create_appointment honors schedule exceptions.\n--\n-- Same precedence fix as 007, applied to the agent write path: professional\n-- resolution now goes through agenda_working_windows (007 \u00A71), so a date the\n-- admin closed (day-off sentinel) or re-opened with custom hours (\"hor\u00E1rio\n-- especial\") binds the agent exactly like it binds the public site. Everything\n-- else is byte-identical to 006 (LOCAL-time input, free-professional pick,\n-- advisory lock, audit). Requires 007. Supersedes 006's function body.\n-- ============================================================================\n\nCREATE OR REPLACE FUNCTION public.agent_agenda_create_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_used int;\n v_client_id uuid;\n v_service_id uuid;\n v_professional uuid;\n v_starts timestamptz;\n v_starts_raw text;\n v_notes text;\n v_service record;\n v_client record;\n v_ends timestamptz;\n v_tz text;\n v_order uuid;\n v_booking uuid;\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_client_id := (p_payload->>'client_id')::uuid;\n v_service_id := (p_payload->>'service_id')::uuid;\n v_professional := (p_payload->>'professional_id')::uuid;\n v_starts_raw := p_payload->>'starts_at';\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 payload: ' || SQLERRM);\n END;\n v_notes := left(p_payload->>'notes', 500);\n IF v_client_id IS NULL OR v_service_id IS NULL OR v_starts IS NULL THEN\n RETURN jsonb_build_object('ok', false, 'error', 'client_id, service_id and starts_at are required');\n END IF;\n IF v_starts <= now() OR v_starts > now() + interval '365 days' THEN\n RETURN jsonb_build_object('ok', false, 'error', 'starts_at must be in the future (max 1 year)');\n END IF;\n\n -- \u2500\u2500 authorization: role \u2192 plan \u2192 bookings_month cap \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n SELECT count(*) INTO v_used FROM appointments b\n WHERE b.tenant_id = p_tenant_id\n AND b.created_at >= date_trunc('month', now());\n v_denial := agent_guard(p_tenant_id, p_actor_user_id,\n 'appointments', 'create', 'bookings_month', v_used, 1);\n IF v_denial IS NOT NULL THEN\n RETURN jsonb_build_object('ok', false, 'denial', v_denial);\n END IF;\n\n -- \u2500\u2500 invariants \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 SELECT p.id, p.name INTO v_client\n FROM people p\n WHERE p.id = v_client_id AND p.tenant_id = p_tenant_id;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('ok', false, 'error', 'unknown client for this tenant');\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 = v_service_id AND s.tenant_id = p_tenant_id\n AND s.is_active AND s.status = 'active';\n IF NOT FOUND THEN\n RETURN jsonb_build_object('ok', false, 'error', 'unknown or inactive service');\n END IF;\n\n v_ends := v_starts + make_interval(mins => v_service.duration_minutes);\n\n -- exception-aware windows (007); still prefer a professional who is FREE at\n -- the slot when the user did not name one\n SELECT w.assignee_id INTO v_professional\n FROM agenda_working_windows(\n p_tenant_id, (v_starts AT TIME ZONE v_tz)::date, v_professional) w\n WHERE (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 AND NOT EXISTS (\n SELECT 1 FROM appointments b\n WHERE b.tenant_id = p_tenant_id\n AND b.assignee_id = w.assignee_id\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 )\n ORDER BY w.assignee_id\n LIMIT 1;\n IF v_professional IS NULL THEN\n RETURN jsonb_build_object('ok', false, 'error',\n 'no professional is available at that slot (outside working hours or already booked). 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.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', 'slot no longer available');\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 INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)\n VALUES (p_tenant_id, 'appointment', 'scheduled', v_client_id, v_professional,\n v_service.price, v_service.price, v_notes,\n jsonb_build_object('source', 'agent', 'serviceNames', v_service.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_client_id, v_professional, v_order,\n v_starts, v_ends, 'scheduled', v_notes,\n jsonb_build_object('source', 'agent', '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_professional);\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.createAppointment', 'appointment', v_booking::text,\n jsonb_build_object('payload', p_payload, 'order_id', v_order));\n\n RETURN jsonb_build_object(\n 'ok', true,\n 'id', v_booking,\n 'record', jsonb_build_object(\n 'ref', jsonb_build_object('id', v_booking, 'resource', 'appointments',\n 'archetype', 'schedule:appointment'),\n 'starts_at', v_starts,\n 'ends_at', v_ends,\n 'starts_at_local', to_char(v_starts AT TIME ZONE v_tz, 'YYYY-MM-DD (Dy) HH24:MI') || ' ' || v_tz,\n 'ends_at_local', to_char(v_ends AT TIME ZONE v_tz, 'HH24:MI'),\n 'client_name', v_client.name,\n 'service_name', v_service.name,\n 'price', v_service.price,\n 'status', 'scheduled'\n )\n );\nEND;\n$$;\n\nREVOKE ALL ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb) FROM public;\nREVOKE EXECUTE ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb) FROM anon;\nGRANT EXECUTE ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb)\n TO authenticated, service_role;\n";
|
|
11
|
-
export declare const MIGRATION_009_PUBLIC_BOOKING_LOCATION = "-- Public bookings were written with location_id = NULL. The agenda's location\n-- filter is optional (default \"all\"), so they still rendered \u2014 but the moment\n-- an operator filtered by their unit, every booking taken from the website\n-- disappeared from the calendar. Single-location tenants are the common case\n-- and have an unambiguous answer, so default to it; multi-location tenants\n-- stay NULL (there is no correct guess) until the public flow asks.\n--\n-- Only the assignment is new; the rest of the body is unchanged from 004.\n\nCREATE OR REPLACE FUNCTION public.create_public_booking(\n p_tenant_id uuid,\n p_service_id uuid,\n p_starts_at timestamptz,\n p_name text,\n p_phone text,\n p_email text DEFAULT NULL,\n p_notes text DEFAULT NULL\n)\nRETURNS TABLE(booking_id uuid, starts_at timestamptz, ends_at timestamptz)\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path TO 'public'\nAS $function$\nDECLARE\n v_service record;\n v_person_id uuid;\n v_assignee 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_location uuid;\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 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 RAISE EXCEPTION 'unknown tenant'; END IF;\n\n -- pick the assignee whose working hours cover the slot\n SELECT sc.assignee_id INTO v_assignee\n FROM schedules sc\n WHERE sc.tenant_id = p_tenant_id\n AND sc.kind = 'working_hours'\n AND sc.is_active\n AND (\n sc.specific_date = (p_starts_at AT TIME ZONE v_tz)::date\n OR (sc.specific_date IS NULL\n AND sc.day_of_week = EXTRACT(dow FROM p_starts_at AT TIME ZONE v_tz)::smallint)\n )\n AND (p_starts_at AT TIME ZONE v_tz)::time >= sc.starts_at\n AND (v_ends AT TIME ZONE v_tz)::time <= sc.ends_at\n ORDER BY sc.assignee_id\n LIMIT 1;\n IF v_assignee IS NULL THEN RAISE EXCEPTION 'slot outside working hours'; END IF;\n\n -- NEW: stamp the unit so the calendar's location filter keeps these visible.\n -- Prefer the schedule's own location; else the tenant's only location.\n SELECT sc.location_id INTO v_location\n FROM schedules sc\n WHERE sc.tenant_id = p_tenant_id\n AND sc.kind = 'working_hours'\n AND sc.is_active\n AND sc.assignee_id = v_assignee\n AND sc.location_id IS NOT NULL\n LIMIT 1;\n\n IF v_location IS NULL THEN\n SELECT l.id INTO v_location\n FROM locations l\n WHERE l.tenant_id = p_tenant_id\n HAVING count(*) = 1; -- unambiguous only\n 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 customer by (tenant, phone). NOT ON CONFLICT: the partial unique\n -- index this used to infer is gone (spine/110 \u2014 a placeholder phone is shared\n -- by real, distinct people). 003 already settled the replacement: take a\n -- per-(tenant, phone) advisory lock so two first-bookings serialise, then look\n -- before inserting. Oldest match wins, so a returning caller keeps the record\n -- that carries their history.\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 = 'customer' 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, 'customer', 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, location_id,\n subtotal, total, notes, metadata)\n VALUES (p_tenant_id, 'appointment', 'scheduled', v_person_id, v_assignee, v_location,\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, location_id,\n starts_at, ends_at, status, notes, metadata)\n VALUES (p_tenant_id, 'appointment', v_person_id, v_assignee, v_order, v_location,\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\nGRANT EXECUTE ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text) TO anon, authenticated;\n";
|
|
12
|
-
export declare const MIGRATION_010_PUBLIC_SERVICE_CURATION = "-- plugin-agenda 010: curate what the PUBLIC booking page sells.\n--\n-- v_public_services published every active service, so the website catalog was\n-- \"whatever exists in the system\". That conflates two different questions:\n--\n-- \u2022 is this service usable at all? \u2192 services.is_active / status\n-- \u2022 should a stranger be able to book it? \u2192 this flag\n--\n-- Without the split, hiding something from the site means deactivating it \u2014\n-- which also removes it from the internal agenda and orphans its history. The\n-- case that forced this: a school whose public page listed 7 services when it\n-- sells 3, and one of the 4 extras already had real bookings attached.\n--\n-- OPT-OUT on purpose: a service is public unless explicitly marked otherwise.\n-- Opt-in would silently empty every live catalog the moment this ships.\n\nCREATE OR REPLACE VIEW public.v_public_services AS\nSELECT id,\n tenant_id,\n name,\n description,\n price,\n currency,\n COALESCE(duration_minutes, 30) AS duration_minutes,\n image_url,\n COALESCE(((metadata ->> 'sort_order'::text))::integer, 0) AS sort_order\n FROM services s\n WHERE is_active\n AND status = 'active'::text\n -- absent \u2192 public (back-compat). Only an explicit false hides it.\n AND COALESCE((metadata ->> 'bookableOnline'::text)::boolean, true);\n";
|
|
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
|
-
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
|
-
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";
|
|
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 044_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";
|
|
18
|
-
export declare const MIGRATION_016_PUBLIC_SERVICES_RELEASE = "-- 016_public_services_release.sql\n--\n-- A service released only to one unit was bookable by anyone. Fix for #201.\n--\n-- `v_public_services` is the anonymous booking catalog. It runs with owner\n-- rights on purpose \u2014 anon holds no membership and no tenant claim, so under\n-- `security_invoker` the RLS on products/services would return nothing and the\n-- public page would go blank. That is the right call, but it means the release\n-- rule #139 put on the internal path never applied here:\n--\n-- products_available_in (075:77) is RESTRICTIVE \u2026 TO authenticated\n--\n-- so `available_in` governed every logged-in reader and nobody else. A service\n-- released to branch B1 alone was listed, and bookable, to the whole internet.\n--\n-- The public catalog is TENANT-wide: create_public_booking takes p_tenant_id and\n-- no unit, so there is no unit context in which \"released to B1\" could be\n-- honoured. A unit-restricted service is therefore not a public offering, and\n-- the view says so \u2014 the same rule the internal path uses (\"no available_in\n-- tuple \u21D2 everywhere\"), read from the anonymous side where \"everywhere\" is the\n-- only scope that exists.\n--\n-- Consequence worth stating plainly: a service released per unit disappears from\n-- the public page. That is the fix, not a side effect \u2014 until the public path\n-- carries a unit, listing it publicly is the bug. The booking RPC refuses it too,\n-- so a stale service id cannot walk in through the back door.\n--\n-- Replay-safe: CREATE OR REPLACE on both objects, no DDL, no data touched.\n\nCREATE OR REPLACE VIEW public.v_public_services AS\nSELECT s.id,\n s.tenant_id,\n s.name,\n s.description,\n s.price,\n s.currency,\n COALESCE(s.duration_minutes, 30) AS duration_minutes,\n s.image_url,\n COALESCE(((s.metadata ->> 'sort_order'::text))::integer, 0) AS sort_order\n FROM services s\n WHERE s.is_active\n AND s.status = 'active'::text\n -- absent \u2192 public (back-compat). Only an explicit false hides it.\n AND COALESCE((s.metadata ->> 'bookableOnline'::text)::boolean, true)\n -- \u2026and released to everywhere, not to particular units (#201). A tuple here\n -- means \"these units only\", which the tenant-wide public page cannot honour.\n AND NOT EXISTS (\n SELECT 1 FROM app.resource_grants g\n WHERE g.tenant_id = s.tenant_id\n AND g.resource_type = 'catalog.product'\n AND g.resource_id = s.id\n AND g.relation = 'available_in');\n\nCOMMENT ON VIEW public.v_public_services IS\n 'Anonymous booking catalog. Owner-rights on purpose (anon has no tenant claim), so the release rule is written into the body: only services with no available_in tuple are public \u2014 see #201.';\n\n-- The RPC must agree with the view, or the id from a cached page still books.\nCREATE OR REPLACE FUNCTION public.agenda_assert_service_is_public(p_tenant_id uuid, p_service_id uuid)\nRETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nBEGIN\n IF EXISTS (SELECT 1 FROM app.resource_grants g\n WHERE g.tenant_id = p_tenant_id\n AND g.resource_type = 'catalog.product'\n AND g.resource_id = p_service_id\n AND g.relation = 'available_in') THEN\n RAISE EXCEPTION 'este servi\u00E7o n\u00E3o est\u00E1 dispon\u00EDvel para agendamento online'\n USING ERRCODE = '42501',\n HINT = 'The service is released to specific units; the public booking path is tenant-wide and cannot honour a unit release (#201).';\n END IF;\nEND $$;\nREVOKE ALL ON FUNCTION public.agenda_assert_service_is_public(uuid, uuid) FROM public, anon, authenticated;\n\n-- create_public_booking re-emitted from 011 with exactly one line added: the\n-- assert above, right after the service is resolved. Everything else is 011's\n-- body verbatim, so this is a guard, not a rewrite.\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 -- \u2026and it must be a PUBLIC service, not merely an existing one. A service\n -- released to particular units is not a tenant-wide offering, and this path has\n -- no unit to honour the release in; without this an id from a cached page still\n -- booked what the catalog no longer lists (#201).\n PERFORM public.agenda_assert_service_is_public(p_tenant_id, p_service_id);\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";
|
|
19
|
-
export declare const MIGRATION_017_BOOKING_CHANNEL_REPLY = "-- ============================================================================\n-- plugin-agenda 016: plg_booking_apply_channel_reply \u2014 the customer tapped\n-- Confirmar, and the hor\u00E1rio actually changes.\n--\n-- This is the whole point of the WhatsApp wave. Legacy beautyplace matched the\n-- WORD the customer typed (\"sim\", \"confirmo\"), which had no way to know WHICH\n-- appointment was meant and so confirmed every future one they had; the\n-- workaround was a 48-hour re-enrichment subsystem that existed only to guess.\n-- The button carries the appointment id in its payload, so there is nothing to\n-- guess \u2014 and this function is the other half: the id arrives, the state\n-- machine is checked, one row changes.\n--\n-- SERVICE-ROLE ONLY. The caller is the tyxter-webhook edge function, acting on\n-- a signed delivery from the provider \u2014 there is no session and no auth.uid().\n-- That is also why the tenant is not a parameter: it is READ from the\n-- appointment. A tenant id in the signature would be a tenant id the caller\n-- could get wrong, and the caller is code reacting to the outside world.\n--\n-- Idempotent by design, not by luck: re-applying an action that already\n-- happened returns the current state with `changed: false` and emits NOTHING.\n-- Tyxter delivers at least once, so the same tap arrives twice as a matter of\n-- routine, and a second `agenda.booking_cancelled` would send the customer a\n-- second cancellation message for one cancellation.\n-- Idempotent + safe to re-run.\n-- ============================================================================\n\n-- `p_expect_tenant_id` is the caller saying which tenant it BELIEVES this\n-- booking belongs to, and it is the difference between a safe function and a\n-- way to confirm any appointment in the pool by guessing a uuid. The button\n-- payload arrives from a customer's phone; WhatsApp echoes back the id we put\n-- on the button, and nothing stops a crafted client from echoing a different\n-- one. The webhook knows which tenant the CONVERSATION belongs to, and that is\n-- the claim checked here \u2014 before the update, not after it.\n--\n-- A mismatch answers `not_found` and not `not_allowed`, deliberately: the two\n-- would let a prober tell \"this uuid is somebody's appointment\" from \"this uuid\n-- is nothing\", which is a booking-id oracle.\nCREATE OR REPLACE FUNCTION public.plg_booking_apply_channel_reply(\n p_booking_id uuid,\n p_action text,\n p_expect_tenant_id uuid DEFAULT NULL\n) RETURNS jsonb\nLANGUAGE plpgsql\nSECURITY DEFINER\nSET search_path = public\nAS $fn$\nDECLARE\n v_action text := nullif(trim(lower(p_action)), '');\n v_target text;\n v_row record;\n v_tz text;\n v_starts timestamptz;\n v_event text;\n v_payload jsonb;\nBEGIN\n IF v_action IS NULL OR v_action NOT IN ('confirm', 'cancel') THEN\n RETURN jsonb_build_object('result', 'not_allowed', 'reason', 'unknown_action');\n END IF;\n IF p_booking_id IS NULL THEN\n RETURN jsonb_build_object('result', 'not_found', 'reason', 'no_booking_id');\n END IF;\n\n v_target := CASE WHEN v_action = 'confirm' THEN 'confirmed' ELSE 'cancelled' END;\n\n -- FOR UPDATE, because two deliveries of the same tap race each other and the\n -- loser must read the state the winner wrote, not the state both started from.\n SELECT b.id, b.tenant_id, b.status, b.starts_at\n INTO v_row\n FROM public.appointments b\n WHERE b.id = p_booking_id\n FOR UPDATE;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('result', 'not_found', 'booking_id', p_booking_id);\n END IF;\n\n IF p_expect_tenant_id IS NOT NULL AND v_row.tenant_id <> p_expect_tenant_id THEN\n RETURN jsonb_build_object('result', 'not_found', 'booking_id', p_booking_id);\n END IF;\n\n -- Already there. The answer is the outcome, not an error: the customer sees\n -- \"confirmado\" either way, and a webhook that 500s on a duplicate is a\n -- webhook Tyxter retries eight times over a day and a half.\n IF v_row.status = v_target THEN\n RETURN jsonb_build_object(\n 'result', v_target, 'changed', false,\n 'booking_id', v_row.id, 'tenant_id', v_row.tenant_id, 'status', v_row.status);\n END IF;\n\n -- Confirming is only meaningful from the two states that mean \"not settled\n -- yet\". 'pending' is the table's own DEFAULT and 'scheduled' is what every\n -- write path sets, so both are live appointments awaiting the customer.\n -- Confirming a completed or cancelled one would rewrite history.\n IF v_action = 'confirm' AND v_row.status NOT IN ('pending', 'scheduled') THEN\n RETURN jsonb_build_object(\n 'result', 'not_allowed', 'changed', false,\n 'booking_id', v_row.id, 'tenant_id', v_row.tenant_id, 'status', v_row.status);\n END IF;\n\n -- Cancelling is allowed from anything that has not already ENDED. A confirmed\n -- appointment can still be cancelled \u2014 that is the ordinary case \u2014 but one\n -- already cancelled or already completed cannot.\n IF v_action = 'cancel' AND v_row.status IN ('cancelled', 'completed') THEN\n RETURN jsonb_build_object(\n 'result', 'not_allowed', 'changed', false,\n 'booking_id', v_row.id, 'tenant_id', v_row.tenant_id, 'status', v_row.status);\n END IF;\n\n UPDATE public.appointments\n SET status = v_target, updated_at = now()\n WHERE id = v_row.id;\n\n -- \u2500\u2500 the fact \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 -- Emitted HERE and not by the caller. The write happened in the database, so\n -- the browser emitter never sees it (packages/db/migrations/027 is the whole\n -- argument), and a webhook that updated the row and then failed to post the\n -- event would leave a confirmed appointment nobody was told about.\n --\n -- The payload is BOOKING_FIELDS from plugin-agenda's message-events.ts, field\n -- for field, so an automation reacting to a WhatsApp confirmation renders the\n -- same message as one reacting to a confirmation made in the app.\n SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz\n FROM public.tenants t WHERE t.id = v_row.tenant_id;\n v_tz := COALESCE(v_tz, 'America/Sao_Paulo');\n v_starts := v_row.starts_at;\n\n SELECT jsonb_build_object(\n 'customer_name', COALESCE(a.client_name, ''),\n 'service_name', COALESCE((\n SELECT string_agg(s->>'name', ' + ')\n FROM jsonb_array_elements(COALESCE(a.services::jsonb, '[]'::jsonb)) AS s), ''),\n 'professional_name', COALESCE(a.professional_name, ''),\n 'booking_date', to_char(v_starts AT TIME ZONE v_tz, 'DD/MM/YYYY'),\n 'booking_time', to_char(v_starts AT TIME ZONE v_tz, 'HH24:MI'),\n 'location_name', COALESCE(a.location_name, ''),\n 'customer_phone', COALESCE(a.client_phone, ''),\n 'source', 'channel_reply')\n INTO v_payload\n FROM public.v_appointments a\n WHERE a.id = v_row.id;\n\n v_event := CASE WHEN v_action = 'confirm'\n THEN 'agenda.booking_confirmed'\n ELSE 'agenda.booking_cancelled' END;\n\n PERFORM public.plg_emit_event(\n v_event,\n COALESCE(v_payload, '{}'::jsonb),\n 'booking',\n v_row.id::text,\n v_row.tenant_id,\n NULL);\n\n RETURN jsonb_build_object(\n 'result', v_target, 'changed', true, 'event_key', v_event,\n 'booking_id', v_row.id, 'tenant_id', v_row.tenant_id,\n 'status', v_target, 'previous_status', v_row.status);\nEND;\n$fn$;\n\n-- The webhook has the service-role key and no session; nothing else may move an\n-- appointment without one. Granting `authenticated` here would be a way to\n-- confirm any appointment in the pool by id, with no tenant check anywhere in\n-- the body \u2014 the check this function deliberately does not make, because its\n-- only caller cannot present a tenant to check against.\nREVOKE ALL ON FUNCTION public.plg_booking_apply_channel_reply(uuid, text, uuid) FROM public;\nREVOKE ALL ON FUNCTION public.plg_booking_apply_channel_reply(uuid, text, uuid) FROM anon;\nREVOKE ALL ON FUNCTION public.plg_booking_apply_channel_reply(uuid, text, uuid) FROM authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_booking_apply_channel_reply(uuid, text, uuid) TO service_role;\n";
|
|
20
|
-
export declare const MIGRATION_018_THE_AGENDA_DECLARES_ITS_SPECIES = "-- ---------------------------------------------------------------------------\n-- 020_the_agenda_declares_its_species.sql \u2014 the agenda owns the species of the\n-- Compromisso and of the Agenda, and says so in a row (#274, ADR 0017).\n--\n-- The base owns the FORM: `public.appointments` and `public.schedules` are\n-- archetypes, with their columns, their RLS and their screens. What KIND of\n-- appointment exists is the plugin's, and since 147 that is a row in a registry\n-- with a real foreign key behind it \u2014 not a literal a plugin hopes nobody else\n-- also chose.\n--\n-- \u2500\u2500 This closes a break, not a formality \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-- 147 created `appointment_kinds` and `schedule_kinds` empty, and adopted only\n-- what a live pool already held. Three of the four pools have appointments, so\n-- adoption covered them and nothing looked wrong. A FRESH cluster has neither \u2014\n-- so the first booking on a brand-new pool would have been refused by the FK,\n-- with a message about a constraint rather than about a missing declaration.\n-- Found by the anchoring rule the same week it was written (#283), which is the\n-- argument for having it: the alternative was finding it in a customer's first\n-- five minutes.\n--\n-- \u2500\u2500 Why the plugin and not the base \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-- Registering these in 147 would have been quicker and wrong. `working_hours`\n-- is not a fact about scheduling in general; it is how THIS plugin models a\n-- professional's week, and a vertical that models availability differently must\n-- be able to register its own without editing the base. The rule the registry\n-- exists to make enforceable is exactly this one, so the agenda goes first.\n-- ---------------------------------------------------------------------------\n\nDO $$\nBEGIN\n IF to_regprocedure('public.register_kind(text, text, text, text[], jsonb, text)') IS NULL THEN\n RAISE NOTICE 'plugin-agenda 020: register_kind missing (spine < 147) \u2014 skipped';\n RETURN;\n END IF;\n\n -- \u2500\u2500 the Compromisso \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 -- Statuses are the agenda's own lifecycle, the same vocabulary 090 gave the\n -- commercial document that accompanies an appointment. Transitions are left\n -- NULL: the plugin's RPCs own the machine, and a second one in the registry\n -- would be a second opinion about the same fact.\n PERFORM public.register_kind(\n 'appointment', 'appointment', 'agenda',\n ARRAY['scheduled', 'confirmed', 'in_progress', 'completed', 'cancelled', 'no_show'],\n NULL,\n 'A booked appointment \u2014 the Compromisso the agenda schedules, confirms and closes.');\n\n PERFORM public.register_kind(\n 'appointment', 'block', 'agenda',\n ARRAY['scheduled', 'cancelled'],\n NULL,\n 'Time deliberately taken off the calendar: a break, a meeting, an absence. Occupies a slot and is never billed.');\n\n -- \u2500\u2500 the Agenda itself \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 PERFORM public.register_kind(\n 'schedule', 'working_hours', 'agenda', NULL, NULL,\n 'A professional''s recurring availability \u2014 the week that says when a slot can exist at all.');\n\n PERFORM public.register_kind(\n 'schedule', 'exception', 'agenda', NULL, NULL,\n 'A dated override of the working week: a holiday, a half-day, a one-off extension. Beats the recurring rule for its date (007).');\n\n PERFORM public.register_kind(\n 'schedule', 'block', 'agenda', NULL, NULL,\n 'A closed period on the calendar, independent of any one professional.');\nEND $$;\n";
|
|
21
|
-
export declare const MIGRATION_019_THE_DEFINER_FUNCTIONS_STOP_TRUSTING_THE_PATH = "-- ---------------------------------------------------------------------------\n-- 019_the_definer_functions_stop_trusting_the_path \u2014 every SECURITY DEFINER function this plugin\n-- owns stops resolving names the caller's way (#283).\n--\n-- The shape being closed: a definer function runs as its OWNER but resolves\n-- unqualified names using the CALLER's `search_path`. Anyone who can put an\n-- object earlier in that path gets it executed with the owner's rights. 157\n-- removed the ability to plant one in `public`; this removes the other half,\n-- which is the function trusting the path at all.\n--\n-- \u2500\u2500 Why ALTER and not a re-emitted CREATE \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-- These functions are live in pools. Re-emitting each body to add one clause\n-- would copy hundreds of lines for a one-word change, and every copy is a\n-- chance to alter behaviour by accident. `ALTER FUNCTION \u2026 SET search_path`\n-- changes exactly the property in question and nothing else.\n--\n-- \u2500\u2500 Why `public, pg_catalog` and not '' \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-- The empty path is stricter and demands every name be schema-qualified \u2014 the\n-- right shape for something written today, and a rewrite of bodies written\n-- before the rule. What closes the hole is that the path is FIXED rather than\n-- inherited; these bodies already read `public` objects by bare name, so\n-- pinning to what they were written against keeps them correct and stops the\n-- caller having any say. The booking path is called by an anonymous shopper, which is exactly the caller whose path should count for nothing.\n-- ---------------------------------------------------------------------------\n\nDO $pin$\nDECLARE\n v_fn record;\n v_pinned integer := 0;\nBEGIN\n FOR v_fn IN\n SELECT p.oid, p.proname, pg_get_function_identity_arguments(p.oid) AS args\n FROM pg_proc p\n JOIN pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname = 'public'\n AND p.prosecdef\n AND p.proname = ANY (ARRAY['create_public_booking', 'fn_gcal_push_booking'])\n AND NOT EXISTS (\n SELECT 1 FROM unnest(coalesce(p.proconfig, ARRAY[]::text[])) AS cfg\n WHERE cfg LIKE 'search\\_path=%'\n )\n LOOP\n EXECUTE format('ALTER FUNCTION public.%I(%s) SET search_path = public, pg_catalog',\n v_fn.proname, v_fn.args);\n v_pinned := v_pinned + 1;\n END LOOP;\n IF v_pinned > 0 THEN\n RAISE NOTICE 'plugin-agenda: pinned search_path on % definer function(s)', v_pinned;\n END IF;\nEND $pin$;\n";
|
|
1
|
+
export declare const MIGRATION_000_BASELINE = "-- ============================================================================\n-- plugins/plugin-agenda/src/migrations/000_baseline.sql \u2014 what installing this provisions, as one file.\n--\n-- GENERATED by scripts/emit-unit-baselines.mjs (#338). Do not edit by hand:\n-- change the schema, regenerate, and let scripts/check-chain-equivalence.mjs\n-- prove the result still matches.\n--\n-- This replaced 23 migration files. They are kept, unapplied, in\n-- migrations.archive/ \u2014 a year of decisions is worth reading even when it is no\n-- longer worth replaying.\n--\n-- Object order is fixed rather than dependency-sorted: schemas, types,\n-- sequences, tables, defaults, functions, constraints, views, indexes, foreign\n-- keys, triggers, row security, policies, grants. Dependencies BETWEEN units\n-- are carried by the order packages/db/chain.json declares.\n-- ============================================================================\n\n-- A LANGUAGE sql function binds its body at CREATE time, and 570 functions\n-- sorted by name are not sorted by who calls whom. This is what lets them load\n-- in any order; every body is still compiled on first call, so a genuinely\n-- broken reference surfaces then rather than never. Session-scoped.\nSET check_function_bodies = false;\n\n\n\n-- \u2500\u2500 default privileges: reset \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-- Cleared BEFORE anything is created, so every object below gets the ACL the\n-- dump describes rather than the image's defaults on top of it. Restored at\n-- the end of this file.\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\n\n-- \u2500\u2500 function \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\nCREATE FUNCTION public.agenda_assert_service_is_public(p_tenant_id uuid, p_service_id uuid) RETURNS void\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nBEGIN\n IF EXISTS (SELECT 1 FROM app.resource_grants g\n WHERE g.tenant_id = p_tenant_id\n AND g.resource_type = 'catalog.product'\n AND g.resource_id = p_service_id\n AND g.relation = 'available_in') THEN\n RAISE EXCEPTION 'este servi\u00E7o n\u00E3o est\u00E1 dispon\u00EDvel para agendamento online'\n USING ERRCODE = '42501',\n HINT = 'The service is released to specific units; the public booking path is tenant-wide and cannot honour a unit release (#201).';\n END IF;\nEND $$;\n\nCREATE FUNCTION public.agenda_booking_cancel(p_appointment_id uuid, p_hard boolean DEFAULT false) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $_$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_appt public.appointments%ROWTYPE;\n v_money integer := 0;\nBEGIN\n SELECT * INTO v_appt FROM public.appointments a WHERE a.id = p_appointment_id AND a.tenant_id = v_tenant;\n IF v_appt.id IS NULL THEN\n RAISE EXCEPTION 'agenda_booking_cancel: appointment % not found', p_appointment_id USING ERRCODE = 'P0002';\n END IF;\n IF NOT (select app.has_permission('agenda.delete', v_appt.unit_id)) THEN\n RAISE EXCEPTION 'agenda_booking_cancel: agenda.delete is required' USING ERRCODE = '42501';\n END IF;\n\n -- Money that exists is never deleted. A booking that raised a T\u00EDtulo is\n -- CANCELLED, both sides, in one transaction.\n IF v_appt.order_id IS NOT NULL AND to_regclass('public.plg_financial_invoices') IS NOT NULL THEN\n EXECUTE 'SELECT count(*)::int FROM public.plg_financial_invoices WHERE order_id = $1 AND cancelled_at IS NULL'\n INTO v_money USING v_appt.order_id;\n END IF;\n\n IF v_money > 0 OR NOT p_hard THEN\n UPDATE public.appointments SET status = 'cancelled' WHERE id = p_appointment_id;\n IF v_appt.order_id IS NOT NULL THEN\n UPDATE public.orders SET status = 'cancelled' WHERE id = v_appt.order_id AND status <> 'completed';\n END IF;\n RETURN jsonb_build_object('cancelled', true, 'deleted', false, 'order_id', v_appt.order_id);\n END IF;\n\n DELETE FROM public.appointments WHERE id = p_appointment_id;\n IF v_appt.order_id IS NOT NULL THEN\n DELETE FROM public.orders WHERE id = v_appt.order_id;\n END IF;\n RETURN jsonb_build_object('cancelled', true, 'deleted', true, 'order_id', v_appt.order_id);\nEND $_$;\n\nCREATE FUNCTION public.agenda_booking_create(p_starts_at timestamp with time zone, p_ends_at timestamp with time zone DEFAULT NULL::timestamp with time zone, p_lines jsonb DEFAULT '[]'::jsonb, p_party_id uuid DEFAULT NULL::uuid, p_assignee_id uuid DEFAULT NULL::uuid, p_unit_id uuid DEFAULT NULL::uuid, p_location_id uuid DEFAULT NULL::uuid, p_kind text DEFAULT 'appointment'::text, p_notes text DEFAULT NULL::text, p_metadata jsonb DEFAULT '{}'::jsonb, p_idempotency_key text DEFAULT NULL::text) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_order uuid;\n v_booking uuid;\n v_ends timestamptz;\n v_minutes integer;\n v_line jsonb;\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'agenda_booking_create: no active tenant' USING ERRCODE = '42501';\n END IF;\n IF NOT (select app.has_permission('agenda.create', p_unit_id)) THEN\n RAISE EXCEPTION 'agenda_booking_create: agenda.create is required' USING ERRCODE = '42501';\n END IF;\n\n IF p_idempotency_key IS NOT NULL THEN\n SELECT a.id, a.order_id INTO v_booking, v_order FROM public.appointments a\n WHERE a.tenant_id = v_tenant AND a.metadata ->> 'idempotencyKey' = p_idempotency_key\n LIMIT 1;\n IF v_booking IS NOT NULL THEN\n RETURN jsonb_build_object('appointment_id', v_booking, 'order_id', v_order, 'replayed', true);\n END IF;\n END IF;\n\n SELECT coalesce(sum(coalesce((l ->> 'duration_minutes')::integer, 0)), 0)\n INTO v_minutes FROM jsonb_array_elements(coalesce(p_lines, '[]'::jsonb)) l;\n v_ends := coalesce(p_ends_at, p_starts_at + (greatest(v_minutes, 30) || ' minutes')::interval);\n\n -- The core's door, not a direct write. One statement, and the lines come with\n -- it: there is no window in which the Venda exists without them.\n v_order := public.order_create(\n p_kind => p_kind,\n p_lines => coalesce(p_lines, '[]'::jsonb),\n p_party_id => p_party_id,\n p_unit_id => p_unit_id,\n p_status => 'scheduled',\n p_assignee_id => p_assignee_id,\n p_notes => p_notes,\n p_metadata => coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('source', 'agenda'),\n p_idempotency_key => p_idempotency_key);\n\n INSERT INTO public.appointments (tenant_id, unit_id, kind, party_id, assignee_id, location_id,\n order_id, starts_at, ends_at, status, notes, metadata)\n VALUES (v_tenant, p_unit_id, p_kind, p_party_id, p_assignee_id, p_location_id,\n v_order, p_starts_at, v_ends, 'scheduled', p_notes,\n coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('source', 'agenda')\n || CASE WHEN p_idempotency_key IS NULL THEN '{}'::jsonb\n ELSE jsonb_build_object('idempotencyKey', p_idempotency_key) END)\n RETURNING id INTO v_booking;\n\n -- The agenda's own per-line columns (001 added them to the line table): who\n -- performs it and how long it takes.\n FOR v_line IN SELECT * FROM jsonb_array_elements(coalesce(p_lines, '[]'::jsonb)) LOOP\n UPDATE public.items oi\n SET duration_minutes = nullif(v_line ->> 'duration_minutes', '')::integer,\n assignee_id = coalesce(nullif(v_line ->> 'assignee_id', '')::uuid, p_assignee_id)\n WHERE oi.order_id = v_order\n AND oi.product_id IS NOT DISTINCT FROM nullif(v_line ->> 'service_id', '')::uuid\n AND oi.name = coalesce(nullif(btrim(coalesce(v_line ->> 'name', '')), ''), 'Item');\n END LOOP;\n\n RETURN jsonb_build_object('appointment_id', v_booking, 'order_id', v_order, 'replayed', false);\nEND $$;\n\nCREATE FUNCTION public.agenda_booking_invoice(p_appointment_id uuid, p_installments integer DEFAULT 1, p_due_date date DEFAULT NULL::date) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_order uuid;\nBEGIN\n SELECT a.order_id INTO v_order FROM public.appointments a\n WHERE a.id = p_appointment_id AND a.tenant_id = v_tenant;\n IF v_order IS NULL THEN\n RAISE EXCEPTION 'agenda_booking_invoice: appointment % has no Venda to bill', p_appointment_id\n USING ERRCODE = 'P0002',\n HINT = 'A booking created through agenda_booking_create always carries one; v_appointments_sem_order lists the rest.';\n END IF;\n\n IF to_regprocedure('public.fn_invoice_from_order(uuid, date, text, integer, text)') IS NULL THEN\n RAISE EXCEPTION 'agenda_booking_invoice: the financial plugin is not installed' USING ERRCODE = '0A000';\n END IF;\n\n PERFORM public.fn_invoice_from_order(v_order, p_due_date, 'invoiced', greatest(coalesce(p_installments, 1), 1), 'credit');\n RETURN jsonb_build_object('order_id', v_order, 'appointment_id', p_appointment_id);\nEND $$;\n\nCREATE FUNCTION public.agenda_booking_update(p_appointment_id uuid, p_starts_at timestamp with time zone DEFAULT NULL::timestamp with time zone, p_ends_at timestamp with time zone DEFAULT NULL::timestamp with time zone, p_status text DEFAULT NULL::text, p_party_id uuid DEFAULT NULL::uuid, p_assignee_id uuid DEFAULT NULL::uuid, p_location_id uuid DEFAULT NULL::uuid, p_notes text DEFAULT NULL::text, p_metadata jsonb DEFAULT NULL::jsonb, p_lines jsonb DEFAULT NULL::jsonb) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_appt public.appointments%ROWTYPE;\n v_line jsonb;\n v_n integer := 0;\n v_sub numeric := 0;\n v_qty numeric;\n v_price numeric;\nBEGIN\n SELECT * INTO v_appt FROM public.appointments a WHERE a.id = p_appointment_id AND a.tenant_id = v_tenant;\n IF v_appt.id IS NULL THEN\n RAISE EXCEPTION 'agenda_booking_update: appointment % not found', p_appointment_id USING ERRCODE = 'P0002';\n END IF;\n IF NOT (select app.has_permission('agenda.edit', v_appt.unit_id)) THEN\n RAISE EXCEPTION 'agenda_booking_update: agenda.edit is required' USING ERRCODE = '42501';\n END IF;\n\n UPDATE public.appointments SET\n starts_at = coalesce(p_starts_at, starts_at),\n ends_at = coalesce(p_ends_at, ends_at),\n status = coalesce(p_status, status),\n party_id = coalesce(p_party_id, party_id),\n assignee_id = coalesce(p_assignee_id, assignee_id),\n location_id = coalesce(p_location_id, location_id),\n notes = coalesce(p_notes, notes),\n metadata = coalesce(metadata, '{}'::jsonb) || coalesce(p_metadata, '{}'::jsonb)\n WHERE id = p_appointment_id;\n\n IF v_appt.order_id IS NOT NULL THEN\n UPDATE public.orders SET\n party_id = coalesce(p_party_id, party_id),\n assignee_id = coalesce(p_assignee_id, assignee_id),\n location_id = coalesce(p_location_id, location_id),\n notes = coalesce(p_notes, notes)\n WHERE id = v_appt.order_id;\n\n IF p_lines IS NOT NULL THEN\n -- Replacing the lines and the header total in the SAME statement pair is\n -- the point: a total that disagrees with its lines is what two browser\n -- calls left behind whenever the second one did not land.\n DELETE FROM public.items WHERE order_id = v_appt.order_id;\n FOR v_line IN SELECT * FROM jsonb_array_elements(p_lines) LOOP\n v_n := v_n + 1;\n v_qty := coalesce((v_line ->> 'quantity')::numeric, 1);\n v_price := round(coalesce((v_line ->> 'unit_price')::numeric, 0), 2);\n INSERT INTO public.items (order_id, product_id, name, quantity, unit_price, total,\n sort_order, duration_minutes, assignee_id)\n VALUES (v_appt.order_id, nullif(v_line ->> 'service_id', '')::uuid,\n coalesce(nullif(btrim(coalesce(v_line ->> 'name', '')), ''), 'Item'),\n v_qty, v_price, round(v_qty * v_price, 2),\n coalesce((v_line ->> 'sort_order')::integer, v_n - 1),\n nullif(v_line ->> 'duration_minutes', '')::integer,\n coalesce(nullif(v_line ->> 'assignee_id', '')::uuid, p_assignee_id, v_appt.assignee_id));\n v_sub := v_sub + round(v_qty * v_price, 2);\n END LOOP;\n UPDATE public.orders SET subtotal = v_sub, total = v_sub WHERE id = v_appt.order_id;\n END IF;\n END IF;\n\n RETURN jsonb_build_object('appointment_id', p_appointment_id, 'order_id', v_appt.order_id);\nEND $$;\n\nCREATE FUNCTION public.agenda_working_windows(p_tenant_id uuid, p_date date, p_assignee_id uuid DEFAULT NULL::uuid) RETURNS TABLE(assignee_id uuid, win_start time without time zone, win_end time without time zone)\n LANGUAGE sql STABLE\n SET search_path TO 'public'\n AS $$\n WITH exc AS (\n -- Every specific-date row for the date, ACTIVE OR NOT: the inactive day-off\n -- sentinel must still suppress the weekly rows below.\n SELECT sc.assignee_id, sc.starts_at, sc.ends_at, sc.is_active\n FROM schedules sc\n WHERE sc.tenant_id = p_tenant_id\n AND sc.kind = 'working_hours'\n AND sc.specific_date = p_date\n AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)\n )\n SELECT e.assignee_id, e.starts_at, e.ends_at\n FROM exc e\n WHERE e.is_active\n UNION ALL\n SELECT sc.assignee_id, sc.starts_at, sc.ends_at\n FROM schedules sc\n WHERE sc.tenant_id = p_tenant_id\n AND sc.kind = 'working_hours'\n AND sc.is_active\n AND sc.specific_date IS NULL\n AND sc.day_of_week = EXTRACT(dow FROM p_date)::smallint\n AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)\n AND NOT EXISTS (SELECT 1 FROM exc e WHERE e.assignee_id = sc.assignee_id)\n$$;\n\nCREATE FUNCTION public.agent_agenda_create_appointment(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO 'public'\n AS $_$\nDECLARE\n v_denial jsonb;\n v_used int;\n v_client_id uuid;\n v_service_id uuid;\n v_professional uuid;\n v_starts timestamptz;\n v_starts_raw text;\n v_notes text;\n v_service record;\n v_client record;\n v_ends timestamptz;\n v_tz text;\n v_order uuid;\n v_booking uuid;\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_client_id := (p_payload->>'client_id')::uuid;\n v_service_id := (p_payload->>'service_id')::uuid;\n v_professional := (p_payload->>'professional_id')::uuid;\n v_starts_raw := p_payload->>'starts_at';\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 payload: ' || SQLERRM);\n END;\n v_notes := left(p_payload->>'notes', 500);\n IF v_client_id IS NULL OR v_service_id IS NULL OR v_starts IS NULL THEN\n RETURN jsonb_build_object('ok', false, 'error', 'client_id, service_id and starts_at are required');\n END IF;\n IF v_starts <= now() OR v_starts > now() + interval '365 days' THEN\n RETURN jsonb_build_object('ok', false, 'error', 'starts_at must be in the future (max 1 year)');\n END IF;\n\n -- \u2500\u2500 authorization: role \u2192 plan \u2192 bookings_month cap \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n SELECT count(*) INTO v_used FROM appointments b\n WHERE b.tenant_id = p_tenant_id\n AND b.created_at >= date_trunc('month', now());\n v_denial := agent_guard(p_tenant_id, p_actor_user_id,\n 'appointments', 'create', 'bookings_month', v_used, 1);\n IF v_denial IS NOT NULL THEN\n RETURN jsonb_build_object('ok', false, 'denial', v_denial);\n END IF;\n\n -- \u2500\u2500 invariants \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 SELECT p.id, p.name INTO v_client\n FROM people p\n WHERE p.id = v_client_id AND p.tenant_id = p_tenant_id;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('ok', false, 'error', 'unknown client for this tenant');\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 = v_service_id AND s.tenant_id = p_tenant_id\n AND s.is_active AND s.status = 'active';\n IF NOT FOUND THEN\n RETURN jsonb_build_object('ok', false, 'error', 'unknown or inactive service');\n END IF;\n\n v_ends := v_starts + make_interval(mins => v_service.duration_minutes);\n\n -- exception-aware windows (007); still prefer a professional who is FREE at\n -- the slot when the user did not name one\n SELECT w.assignee_id INTO v_professional\n FROM agenda_working_windows(\n p_tenant_id, (v_starts AT TIME ZONE v_tz)::date, v_professional) w\n WHERE (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 AND NOT EXISTS (\n SELECT 1 FROM appointments b\n WHERE b.tenant_id = p_tenant_id\n AND b.assignee_id = w.assignee_id\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 )\n ORDER BY w.assignee_id\n LIMIT 1;\n IF v_professional IS NULL THEN\n RETURN jsonb_build_object('ok', false, 'error',\n 'no professional is available at that slot (outside working hours or already booked). 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.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', 'slot no longer available');\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 INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)\n VALUES (p_tenant_id, 'appointment', 'scheduled', v_client_id, v_professional,\n v_service.price, v_service.price, v_notes,\n jsonb_build_object('source', 'agent', 'serviceNames', v_service.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_client_id, v_professional, v_order,\n v_starts, v_ends, 'scheduled', v_notes,\n jsonb_build_object('source', 'agent', 'serviceNames', v_service.name))\n RETURNING id INTO v_booking;\n\n INSERT INTO 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_professional);\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.createAppointment', 'appointment', v_booking::text,\n jsonb_build_object('payload', p_payload, 'order_id', v_order));\n\n RETURN jsonb_build_object(\n 'ok', true,\n 'id', v_booking,\n 'record', jsonb_build_object(\n 'ref', jsonb_build_object('id', v_booking, 'resource', 'appointments',\n 'archetype', 'schedule:appointment'),\n 'starts_at', v_starts,\n 'ends_at', v_ends,\n 'starts_at_local', to_char(v_starts AT TIME ZONE v_tz, 'YYYY-MM-DD (Dy) HH24:MI') || ' ' || v_tz,\n 'ends_at_local', to_char(v_ends AT TIME ZONE v_tz, 'HH24:MI'),\n 'client_name', v_client.name,\n 'service_name', v_service.name,\n 'price', v_service.price,\n 'status', 'scheduled'\n )\n );\nEND;\n$_$;\n\nCREATE FUNCTION public.agent_agenda_update_appointment(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO 'public'\n AS $_$\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\nCREATE 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) RETURNS TABLE(booking_id uuid, starts_at timestamp with time zone, ends_at timestamp with time zone)\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO 'public'\n AS $_$\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 -- \u2026and it must be a PUBLIC service, not merely an existing one. A service\n -- released to particular units is not a tenant-wide offering, and this path has\n -- no unit to honour the release in; without this an id from a cached page still\n -- booked what the catalog no longer lists (#201).\n PERFORM public.agenda_assert_service_is_public(p_tenant_id, p_service_id);\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 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$_$;\n\nCREATE FUNCTION public.get_available_slots(p_tenant_id uuid, p_date date, p_duration_minutes integer, p_assignee_id uuid DEFAULT NULL::uuid, p_slot_interval integer DEFAULT 30) RETURNS TABLE(slot_start timestamp with time zone, slot_end timestamp with time zone)\n LANGUAGE plpgsql STABLE SECURITY DEFINER\n SET search_path TO 'public'\n AS $$\nDECLARE\n v_tz text;\nBEGIN\n -- tight validation: this function is anon-callable\n IF p_duration_minutes IS NULL OR p_duration_minutes NOT BETWEEN 5 AND 480 THEN\n RAISE EXCEPTION 'invalid duration';\n END IF;\n IF p_slot_interval IS NULL OR p_slot_interval NOT BETWEEN 5 AND 240 THEN\n RAISE EXCEPTION 'invalid interval';\n END IF;\n IF p_date IS NULL OR p_date < current_date OR p_date > current_date + 90 THEN\n RETURN;\n END IF;\n\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 RETURN; END IF; -- unknown tenant \u2192 empty, not an error\n\n RETURN QUERY\n WITH day_schedules AS (\n SELECT w.assignee_id, w.win_start, w.win_end\n FROM agenda_working_windows(p_tenant_id, p_date, p_assignee_id) w\n ),\n candidate AS (\n SELECT\n ds.assignee_id,\n gs AS s_start,\n gs + make_interval(mins => p_duration_minutes) AS s_end\n FROM day_schedules ds\n CROSS JOIN LATERAL generate_series(\n (p_date + ds.win_start) AT TIME ZONE v_tz,\n ((p_date + ds.win_end) AT TIME ZONE v_tz) - make_interval(mins => p_duration_minutes),\n make_interval(mins => p_slot_interval)\n ) AS gs\n )\n SELECT DISTINCT c.s_start, c.s_end\n FROM candidate c\n WHERE NOT EXISTS (\n SELECT 1 FROM appointments b\n WHERE b.tenant_id = p_tenant_id\n -- SMART SYNC (gcal 002): this professional's own bookings OR any\n -- tenant-wide BLOCK (assignee_id IS NULL AND kind='block') busy the slot.\n AND (b.assignee_id = c.assignee_id\n OR (b.assignee_id IS NULL AND b.kind = 'block'))\n AND b.status NOT IN ('cancelled', 'no_show')\n AND b.starts_at < c.s_end\n AND COALESCE(b.ends_at, b.starts_at) > c.s_start\n )\n ORDER BY 1;\nEND;\n$$;\n\nCREATE FUNCTION public.plg_booking_apply_channel_reply(p_booking_id uuid, p_action text, p_expect_tenant_id uuid DEFAULT NULL::uuid) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO 'public'\n AS $$\nDECLARE\n v_action text := nullif(trim(lower(p_action)), '');\n v_target text;\n v_row record;\n v_tz text;\n v_starts timestamptz;\n v_event text;\n v_payload jsonb;\nBEGIN\n IF v_action IS NULL OR v_action NOT IN ('confirm', 'cancel') THEN\n RETURN jsonb_build_object('result', 'not_allowed', 'reason', 'unknown_action');\n END IF;\n IF p_booking_id IS NULL THEN\n RETURN jsonb_build_object('result', 'not_found', 'reason', 'no_booking_id');\n END IF;\n\n v_target := CASE WHEN v_action = 'confirm' THEN 'confirmed' ELSE 'cancelled' END;\n\n -- FOR UPDATE, because two deliveries of the same tap race each other and the\n -- loser must read the state the winner wrote, not the state both started from.\n SELECT b.id, b.tenant_id, b.status, b.starts_at\n INTO v_row\n FROM public.appointments b\n WHERE b.id = p_booking_id\n FOR UPDATE;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('result', 'not_found', 'booking_id', p_booking_id);\n END IF;\n\n IF p_expect_tenant_id IS NOT NULL AND v_row.tenant_id <> p_expect_tenant_id THEN\n RETURN jsonb_build_object('result', 'not_found', 'booking_id', p_booking_id);\n END IF;\n\n -- Already there. The answer is the outcome, not an error: the customer sees\n -- \"confirmado\" either way, and a webhook that 500s on a duplicate is a\n -- webhook Tyxter retries eight times over a day and a half.\n IF v_row.status = v_target THEN\n RETURN jsonb_build_object(\n 'result', v_target, 'changed', false,\n 'booking_id', v_row.id, 'tenant_id', v_row.tenant_id, 'status', v_row.status);\n END IF;\n\n -- Confirming is only meaningful from the two states that mean \"not settled\n -- yet\". 'pending' is the table's own DEFAULT and 'scheduled' is what every\n -- write path sets, so both are live appointments awaiting the customer.\n -- Confirming a completed or cancelled one would rewrite history.\n IF v_action = 'confirm' AND v_row.status NOT IN ('pending', 'scheduled') THEN\n RETURN jsonb_build_object(\n 'result', 'not_allowed', 'changed', false,\n 'booking_id', v_row.id, 'tenant_id', v_row.tenant_id, 'status', v_row.status);\n END IF;\n\n -- Cancelling is allowed from anything that has not already ENDED. A confirmed\n -- appointment can still be cancelled \u2014 that is the ordinary case \u2014 but one\n -- already cancelled or already completed cannot.\n IF v_action = 'cancel' AND v_row.status IN ('cancelled', 'completed') THEN\n RETURN jsonb_build_object(\n 'result', 'not_allowed', 'changed', false,\n 'booking_id', v_row.id, 'tenant_id', v_row.tenant_id, 'status', v_row.status);\n END IF;\n\n UPDATE public.appointments\n SET status = v_target, updated_at = now()\n WHERE id = v_row.id;\n\n -- \u2500\u2500 the fact \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 -- Emitted HERE and not by the caller. The write happened in the database, so\n -- the browser emitter never sees it (packages/db/migrations/027 is the whole\n -- argument), and a webhook that updated the row and then failed to post the\n -- event would leave a confirmed appointment nobody was told about.\n --\n -- The payload is BOOKING_FIELDS from plugin-agenda's message-events.ts, field\n -- for field, so an automation reacting to a WhatsApp confirmation renders the\n -- same message as one reacting to a confirmation made in the app.\n SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz\n FROM public.tenants t WHERE t.id = v_row.tenant_id;\n v_tz := COALESCE(v_tz, 'America/Sao_Paulo');\n v_starts := v_row.starts_at;\n\n SELECT jsonb_build_object(\n 'customer_name', COALESCE(a.client_name, ''),\n 'service_name', COALESCE((\n SELECT string_agg(s->>'name', ' + ')\n FROM jsonb_array_elements(COALESCE(a.services::jsonb, '[]'::jsonb)) AS s), ''),\n 'professional_name', COALESCE(a.professional_name, ''),\n 'booking_date', to_char(v_starts AT TIME ZONE v_tz, 'DD/MM/YYYY'),\n 'booking_time', to_char(v_starts AT TIME ZONE v_tz, 'HH24:MI'),\n 'location_name', COALESCE(a.location_name, ''),\n 'customer_phone', COALESCE(a.client_phone, ''),\n 'source', 'channel_reply')\n INTO v_payload\n FROM public.v_appointments a\n WHERE a.id = v_row.id;\n\n v_event := CASE WHEN v_action = 'confirm'\n THEN 'agenda.booking_confirmed'\n ELSE 'agenda.booking_cancelled' END;\n\n PERFORM public.plg_emit_event(\n v_event,\n COALESCE(v_payload, '{}'::jsonb),\n 'booking',\n v_row.id::text,\n v_row.tenant_id,\n NULL);\n\n RETURN jsonb_build_object(\n 'result', v_target, 'changed', true, 'event_key', v_event,\n 'booking_id', v_row.id, 'tenant_id', v_row.tenant_id,\n 'status', v_target, 'previous_status', v_row.status);\nEND;\n$$;\n\n\n-- \u2500\u2500 view \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\u2500\u2500\n\nCREATE VIEW public.v_appointments WITH (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 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.product_id, 'name', oi.name, 'durationMinutes', oi.duration_minutes, 'price', oi.unit_price, 'assigneeId', oi.assignee_id) ORDER BY oi.sort_order) AS json_agg\n FROM public.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 (((((public.appointments b\n LEFT JOIN public.people pc ON ((pc.id = b.party_id)))\n LEFT JOIN public.people ps ON ((ps.id = b.assignee_id)))\n LEFT JOIN public.locations l ON ((l.id = b.location_id)))\n LEFT JOIN public.orders o ON ((o.id = b.order_id)))\n LEFT JOIN public.v_invoice_balances inv ON ((inv.invoice_id = b.order_id)));\n\nCREATE VIEW public.rep_appointments_by_period WITH (security_invoker='true') AS\n SELECT 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 public.v_appointments b;\n\nCREATE VIEW public.plg_agenda_rep_bookings WITH (security_invoker='true') AS\n SELECT tenant_id,\n booking_id,\n date,\n starts_at,\n client_id,\n client_name,\n professional_id,\n professional_name,\n service_name,\n status,\n revenue AS booked_revenue,\n CASE\n WHEN (status = ANY (ARRAY['cancelled'::text, 'no_show'::text])) THEN (0)::numeric\n ELSE COALESCE(revenue, (0)::numeric)\n END AS effective_revenue,\n CASE\n WHEN (status = ANY (ARRAY['cancelled'::text, 'no_show'::text])) THEN COALESCE(revenue, (0)::numeric)\n ELSE (0)::numeric\n END AS lost_revenue,\n CASE\n WHEN (status = 'completed'::text) THEN 1\n ELSE 0\n END AS is_completed,\n CASE\n WHEN (status = 'cancelled'::text) THEN 1\n ELSE 0\n END AS is_cancelled,\n CASE\n WHEN (status = 'no_show'::text) THEN 1\n ELSE 0\n END AS is_no_show,\n created_at,\n updated_at\n FROM public.rep_appointments_by_period b;\n\nCREATE VIEW public.rep_confirmation_queue WITH (security_invoker='true') AS\n SELECT 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 'pending'::text AS confirmation_status,\n NULL::text AS confirmation_channel,\n NULL::timestamp with time zone AS confirmation_sent_at,\n NULL::timestamp with time zone AS confirmed_at,\n created_at,\n updated_at\n FROM public.v_appointments b\n WHERE ((status <> ALL (ARRAY['cancelled'::text, 'no_show'::text])) AND (starts_at >= (now() - '1 day'::interval)));\n\nCREATE VIEW public.rep_revenue_by_professional WITH (security_invoker='true') AS\n SELECT 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 public.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 VIEW public.rep_revenue_by_service WITH (security_invoker='true') AS\n SELECT 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 (public.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\nCREATE VIEW public.v_appointments_sem_order WITH (security_invoker='true') AS\n SELECT id AS appointment_id,\n tenant_id,\n unit_id,\n kind,\n status,\n party_id,\n assignee_id,\n starts_at\n FROM public.appointments a\n WHERE ((order_id IS NULL) AND (status = ANY (ARRAY['completed'::text, 'invoiced'::text, 'paid'::text])));\n\nCREATE VIEW public.v_public_services AS\n SELECT id,\n tenant_id,\n name,\n description,\n price,\n currency,\n COALESCE(duration_minutes, 30) AS duration_minutes,\n image_url,\n COALESCE(((metadata ->> 'sort_order'::text))::integer, 0) AS sort_order\n FROM public.products s\n WHERE ((kind = 'service'::text) AND is_active AND (status = 'active'::text) AND COALESCE(((metadata ->> 'bookableOnline'::text))::boolean, true) AND (NOT (EXISTS ( SELECT 1\n FROM app.resource_grants g\n WHERE ((g.tenant_id = s.tenant_id) AND (g.resource_type = 'catalog.product'::text) AND (g.resource_id = s.id) AND (g.relation = 'available_in'::text))))));\n\n\n-- \u2500\u2500 acl \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\u2500\u2500\u2500\n\nREVOKE ALL ON FUNCTION public.agenda_assert_service_is_public(p_tenant_id uuid, p_service_id uuid) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.agenda_assert_service_is_public(p_tenant_id uuid, p_service_id uuid) TO service_role;\n\nREVOKE ALL ON FUNCTION public.agenda_booking_cancel(p_appointment_id uuid, p_hard boolean) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.agenda_booking_cancel(p_appointment_id uuid, p_hard boolean) TO authenticated;\nGRANT ALL ON FUNCTION public.agenda_booking_cancel(p_appointment_id uuid, p_hard boolean) TO service_role;\n\nREVOKE ALL ON FUNCTION public.agenda_booking_create(p_starts_at timestamp with time zone, p_ends_at timestamp with time zone, p_lines jsonb, p_party_id uuid, p_assignee_id uuid, p_unit_id uuid, p_location_id uuid, p_kind text, p_notes text, p_metadata jsonb, p_idempotency_key text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.agenda_booking_create(p_starts_at timestamp with time zone, p_ends_at timestamp with time zone, p_lines jsonb, p_party_id uuid, p_assignee_id uuid, p_unit_id uuid, p_location_id uuid, p_kind text, p_notes text, p_metadata jsonb, p_idempotency_key text) TO authenticated;\nGRANT ALL ON FUNCTION public.agenda_booking_create(p_starts_at timestamp with time zone, p_ends_at timestamp with time zone, p_lines jsonb, p_party_id uuid, p_assignee_id uuid, p_unit_id uuid, p_location_id uuid, p_kind text, p_notes text, p_metadata jsonb, p_idempotency_key text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.agenda_booking_invoice(p_appointment_id uuid, p_installments integer, p_due_date date) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.agenda_booking_invoice(p_appointment_id uuid, p_installments integer, p_due_date date) TO authenticated;\nGRANT ALL ON FUNCTION public.agenda_booking_invoice(p_appointment_id uuid, p_installments integer, p_due_date date) TO service_role;\n\nREVOKE ALL ON FUNCTION public.agenda_booking_update(p_appointment_id uuid, p_starts_at timestamp with time zone, p_ends_at timestamp with time zone, p_status text, p_party_id uuid, p_assignee_id uuid, p_location_id uuid, p_notes text, p_metadata jsonb, p_lines jsonb) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.agenda_booking_update(p_appointment_id uuid, p_starts_at timestamp with time zone, p_ends_at timestamp with time zone, p_status text, p_party_id uuid, p_assignee_id uuid, p_location_id uuid, p_notes text, p_metadata jsonb, p_lines jsonb) TO authenticated;\nGRANT ALL ON FUNCTION public.agenda_booking_update(p_appointment_id uuid, p_starts_at timestamp with time zone, p_ends_at timestamp with time zone, p_status text, p_party_id uuid, p_assignee_id uuid, p_location_id uuid, p_notes text, p_metadata jsonb, p_lines jsonb) TO service_role;\n\nREVOKE ALL ON FUNCTION public.agenda_working_windows(p_tenant_id uuid, p_date date, p_assignee_id uuid) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.agenda_working_windows(p_tenant_id uuid, p_date date, p_assignee_id uuid) TO authenticated;\nGRANT ALL ON FUNCTION public.agenda_working_windows(p_tenant_id uuid, p_date date, p_assignee_id uuid) TO service_role;\n\nREVOKE ALL ON FUNCTION public.agent_agenda_create_appointment(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.agent_agenda_create_appointment(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) TO authenticated;\nGRANT ALL ON FUNCTION public.agent_agenda_create_appointment(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) TO service_role;\n\nREVOKE ALL ON FUNCTION public.agent_agenda_update_appointment(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.agent_agenda_update_appointment(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) TO authenticated;\nGRANT ALL ON FUNCTION public.agent_agenda_update_appointment(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) TO service_role;\n\nREVOKE ALL ON 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, p_notes text, p_assignee_id uuid) FROM PUBLIC;\nGRANT ALL ON 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, p_notes text, p_assignee_id uuid) TO authenticated;\nGRANT ALL ON 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, p_notes text, p_assignee_id uuid) TO service_role;\nGRANT ALL ON 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, p_notes text, p_assignee_id uuid) TO anon;\n\nREVOKE ALL ON FUNCTION public.get_available_slots(p_tenant_id uuid, p_date date, p_duration_minutes integer, p_assignee_id uuid, p_slot_interval integer) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.get_available_slots(p_tenant_id uuid, p_date date, p_duration_minutes integer, p_assignee_id uuid, p_slot_interval integer) TO authenticated;\nGRANT ALL ON FUNCTION public.get_available_slots(p_tenant_id uuid, p_date date, p_duration_minutes integer, p_assignee_id uuid, p_slot_interval integer) TO service_role;\nGRANT ALL ON FUNCTION public.get_available_slots(p_tenant_id uuid, p_date date, p_duration_minutes integer, p_assignee_id uuid, p_slot_interval integer) TO anon;\n\nREVOKE ALL ON FUNCTION public.plg_booking_apply_channel_reply(p_booking_id uuid, p_action text, p_expect_tenant_id uuid) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.plg_booking_apply_channel_reply(p_booking_id uuid, p_action text, p_expect_tenant_id uuid) TO service_role;\n\nGRANT MAINTAIN ON TABLE public.v_appointments TO anon;\nGRANT ALL ON TABLE public.v_appointments TO authenticated;\nGRANT ALL ON TABLE public.v_appointments TO service_role;\n\nGRANT MAINTAIN ON TABLE public.rep_appointments_by_period TO anon;\nGRANT ALL ON TABLE public.rep_appointments_by_period TO authenticated;\nGRANT ALL ON TABLE public.rep_appointments_by_period TO service_role;\n\nGRANT MAINTAIN ON TABLE public.plg_agenda_rep_bookings TO anon;\nGRANT ALL ON TABLE public.plg_agenda_rep_bookings TO authenticated;\nGRANT ALL ON TABLE public.plg_agenda_rep_bookings TO service_role;\n\nGRANT MAINTAIN ON TABLE public.rep_confirmation_queue TO anon;\nGRANT ALL ON TABLE public.rep_confirmation_queue TO authenticated;\nGRANT ALL ON TABLE public.rep_confirmation_queue TO service_role;\n\nGRANT MAINTAIN ON TABLE public.rep_revenue_by_professional TO anon;\nGRANT ALL ON TABLE public.rep_revenue_by_professional TO authenticated;\nGRANT ALL ON TABLE public.rep_revenue_by_professional TO service_role;\n\nGRANT MAINTAIN ON TABLE public.rep_revenue_by_service TO anon;\nGRANT ALL ON TABLE public.rep_revenue_by_service TO authenticated;\nGRANT ALL ON TABLE public.rep_revenue_by_service TO service_role;\n\nGRANT MAINTAIN ON TABLE public.v_appointments_sem_order TO anon;\nGRANT ALL ON TABLE public.v_appointments_sem_order TO authenticated;\nGRANT ALL ON TABLE public.v_appointments_sem_order TO service_role;\n\nGRANT SELECT,MAINTAIN ON TABLE public.v_public_services TO anon;\nGRANT ALL ON TABLE public.v_public_services TO authenticated;\nGRANT ALL ON TABLE public.v_public_services TO service_role;\n\n\n-- \u2500\u2500 comment \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\nCOMMENT ON FUNCTION public.agenda_booking_create(p_starts_at timestamp with time zone, p_ends_at timestamp with time zone, p_lines jsonb, p_party_id uuid, p_assignee_id uuid, p_unit_id uuid, p_location_id uuid, p_kind text, p_notes text, p_metadata jsonb, p_idempotency_key text) IS 'A booking, its Venda and its lines in one transaction (PRD 06). Replaces the three browser calls createBooking used to make \u2014 closing the tab between them is what left an order nobody could find.';\n\nCOMMENT ON FUNCTION public.agenda_booking_invoice(p_appointment_id uuid, p_installments integer, p_due_date date) IS 'Bill a booking in one call (PRD 06). The bridge used to read the order in the browser and then call the invoice RPC; here the lookup and the promotion are one transaction.';\n\nCOMMENT ON VIEW public.v_appointments_sem_order IS 'Appointments that reached a billable state with no Venda behind them (PRD 06). An unbilled appointment is legitimate; a COMPLETED one with nothing commercial attached is the 72% hole the V1 has, arriving early enough to stop.';\n\nCOMMENT ON VIEW public.v_public_services IS 'Anonymous booking catalog. Owner-rights on purpose (anon has no tenant claim), so the release rule is written into the body: only services with no available_in tuple are public \u2014 see #201.';\n\n\n-- \u2500\u2500 default privileges: restore \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-- Put back, so the NEXT thing installed inherits what the chain had.\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT MAINTAIN ON TABLES TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT MAINTAIN ON TABLES TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$--\n-- PostgreSQL database dump complete\n--$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$--\n-- PostgreSQL database dump complete\n--$stmt$;\nEND $dp$;\n";
|
|
2
|
+
export declare const MIGRATION_001_THE_CONNECTOR_DEFINERS_KEEP_A_PINNED_PATH = "-- ---------------------------------------------------------------------------\n-- 001_the_connector_definers_keep_a_pinned_path \u2014 the half of 019 the squash\n-- could not photograph (#283, #338).\n--\n-- 019 pinned `search_path` on every SECURITY DEFINER function this plugin owns,\n-- including `fn_gcal_push_booking`, which the GOOGLE CALENDAR CONNECTOR creates.\n-- Connector SQL is its own ledger source: it is not in chain.json, so it is not\n-- in the schema `emit-baseline` photographs, so 000_baseline.sql cannot carry\n-- the pin the way it carries the other 40.\n--\n-- Without this file a fresh install applies the connector's own 001-005, which\n-- create the function unpinned, and nothing ever pins it again \u2014 a definer\n-- function resolving names the caller's way, which is the exact shape #283\n-- exists to close. So 019's loop stays a migration.\n--\n-- Idempotent by construction: it pins only what is unpinned, and skips the\n-- function entirely on an install without the connector.\n-- ---------------------------------------------------------------------------\n\nDO $pin$\nDECLARE\n v_fn record;\n v_pinned integer := 0;\nBEGIN\n FOR v_fn IN\n SELECT p.oid, p.proname, pg_get_function_identity_arguments(p.oid) AS args\n FROM pg_proc p\n JOIN pg_namespace n ON n.oid = p.pronamespace\n WHERE n.nspname = 'public'\n AND p.prosecdef\n AND p.proname = ANY (ARRAY['create_public_booking', 'fn_gcal_push_booking'])\n AND NOT EXISTS (\n SELECT 1 FROM unnest(coalesce(p.proconfig, ARRAY[]::text[])) AS cfg\n WHERE cfg LIKE 'search\\_path=%'\n )\n LOOP\n -- `public, pg_catalog`, not '': these bodies read public objects by bare\n -- name and were written that way. What closes the hole is that the path is\n -- FIXED rather than inherited \u2014 the booking path is called by an anonymous\n -- shopper, and that caller's path should count for nothing.\n EXECUTE format('ALTER FUNCTION public.%I(%s) SET search_path = public, pg_catalog',\n v_fn.proname, v_fn.args);\n v_pinned := v_pinned + 1;\n END LOOP;\n IF v_pinned > 0 THEN\n RAISE NOTICE 'plugin-agenda: pinned search_path on % connector definer function(s)', v_pinned;\n END IF;\nEND $pin$;\n";
|
|
3
|
+
export declare const MIGRATION_002_THE_APPOINTMENT_NAMES_THE_UNIT = "-- ============================================================================\n-- 002_the_appointment_names_the_unit \u2014 v_appointments leaves the locations\n-- projection (#214 / #352).\n--\n-- The view LEFT JOINed public.locations, a compat projection of app.units. A\n-- view is a HARD dependency, so this join is what made the projection\n-- undroppable by the spine \u2014 which runs first and cannot remove a name a\n-- plugin applied later still selects.\n--\n-- The view is already security_invoker and app.units already grants SELECT to\n-- authenticated under its own RLS, so nothing about who sees which appointment\n-- changes: the same policy decides, one relation earlier.\n-- ============================================================================\n\nCREATE OR REPLACE VIEW public.v_appointments WITH (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 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.product_id, 'name', oi.name, 'durationMinutes', oi.duration_minutes, 'price', oi.unit_price, 'assigneeId', oi.assignee_id) ORDER BY oi.sort_order) AS json_agg\n FROM public.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 (((((public.appointments b\n LEFT JOIN public.people pc ON ((pc.id = b.party_id)))\n LEFT JOIN public.people ps ON ((ps.id = b.assignee_id)))\n LEFT JOIN app.units l ON ((l.id = b.location_id)))\n LEFT JOIN public.orders o ON ((o.id = b.order_id)))\n LEFT JOIN public.v_invoice_balances inv ON ((inv.invoice_id = b.order_id)));";
|
|
4
|
+
export declare const MIGRATION_003_THE_CLIENT_ARRIVES_BEFORE_BEING_SERVED = "-- 003_the_client_arrives_before_being_served.sql\n--\n-- Falta um estado entre \"confirmado\" e \"em atendimento\": AGUARDANDO.\n--\n-- \u00C9 o estado que o check-in produz \u2014 a cliente chegou, est\u00E1 na recep\u00E7\u00E3o, ainda\n-- n\u00E3o sentou na cadeira. Hoje o ciclo do `appointment` vai de `confirmed`\n-- direto para `in_progress`, e quem recebe \u00E9 obrigado a escolher entre mentir\n-- (dizer que j\u00E1 est\u00E1 em atendimento) ou n\u00E3o registrar nada. As duas respostas\n-- apagam a informa\u00E7\u00E3o que a recep\u00E7\u00E3o mais usa: quem j\u00E1 est\u00E1 aqui.\n--\n-- No V1 esse estado existe e \u00E9 o padr\u00E3o da tela de check-in, ao lado de\n-- `scheduled`, `confirmed` e `in_progress`. Port\u00E1-lo \u00E9 o que permite a tela\n-- fazer sentido \u2014 sem ele, \"fazer check-in\" n\u00E3o teria o que gravar.\n--\n-- Entra em `appointment` e em `service_order`: s\u00E3o a mesma coisa com nomes\n-- diferentes, e uma ordem de servi\u00E7o tamb\u00E9m tem algu\u00E9m esperando.\n\nUPDATE public.order_kinds\n SET statuses = (\n SELECT array_agg(DISTINCT s ORDER BY s)\n FROM unnest(coalesce(statuses, ARRAY[]::text[]) || ARRAY['waiting']) AS s\n ),\n updated_at = now()\n WHERE kind IN ('appointment', 'service_order')\n AND NOT ('waiting' = ANY (coalesce(statuses, ARRAY[]::text[])));\n";
|
|
5
|
+
export declare const MIGRATION_004_THE_APPOINTMENT_CARRIES_ITS_UNIT = "-- 004_the_appointment_carries_its_unit.sql\n--\n-- Um agendamento criado PELA TELA nasce com `unit_id` NULO: o formul\u00E1rio coleta\n-- o local e o provider nunca envia a unidade. E `location_id` j\u00E1 \u00C9 uma unidade \u2014\n-- aponta para o mesmo `app.units`.\n--\n-- O efeito n\u00E3o \u00E9 cosm\u00E9tico. O escopo por unidade \u00E9 o que faz a recep\u00E7\u00E3o de uma\n-- filial ver a fila DELA; uma linha sem unidade n\u00E3o \u00E9 filtrada por unidade\n-- nenhuma, ent\u00E3o ela aparece para TODAS. Num tenant de franquia \u2014 e a rede\n-- sint\u00E9tica tem seis unidades \u2014 isso \u00E9 atendimento de uma filial vis\u00EDvel na\n-- recep\u00E7\u00E3o da outra.\n--\n-- Apareceu numa jornada real: duas clientes no mesmo dia, uma marcada pela tela\n-- (sem unidade) e outra com unidade; a recep\u00E7\u00E3o de Norte \u2014 Centro via a primeira\n-- e n\u00E3o a segunda. O filtro estava certo. O dado \u00E9 que chegava incompleto.\n--\n-- POR QUE GATILHO, E N\u00C3O S\u00D3 O CAMPO NO PROVIDER. O provider \u00E9 um escritor entre\n-- v\u00E1rios: o agente cria agendamento, a importa\u00E7\u00E3o cria, o totem cria. Corrigir\n-- s\u00F3 a tela deixaria os outros escrevendo linha sem unidade \u2014 e o furo \u00E9 de\n-- VISIBILIDADE, que n\u00E3o se conserta pela metade.\nCREATE OR REPLACE FUNCTION public.trg_agenda_stamp_unit()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $$\nBEGIN\n IF NEW.unit_id IS NULL AND NEW.location_id IS NOT NULL THEN\n NEW.unit_id := NEW.location_id;\n END IF;\n RETURN NEW;\nEND $$;\n\nCOMMENT ON FUNCTION public.trg_agenda_stamp_unit() IS\n 'Carimba a unidade do agendamento a partir do local, que \u00E9 a mesma app.units. Sem ela a linha escapa do escopo por unidade e aparece para todas as filiais (004).';\n\nDROP TRIGGER IF EXISTS appointments_stamp_unit ON public.appointments;\nCREATE TRIGGER appointments_stamp_unit\n BEFORE INSERT OR UPDATE OF location_id, unit_id ON public.appointments\n FOR EACH ROW\n EXECUTE FUNCTION public.trg_agenda_stamp_unit();\n\n-- \u2500\u2500 e os que j\u00E1 nasceram sem \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\nUPDATE public.appointments\n SET unit_id = location_id\n WHERE unit_id IS NULL AND location_id IS NOT NULL;\n";
|
|
6
|
+
export declare const MIGRATION_005_THE_QUEUE_KNOWS_HOW_LONG_SHE_WAITED = "-- 005_the_queue_knows_how_long_she_waited.sql\n--\n-- A fila mostra QUEM chegou e n\u00E3o H\u00C1 QUANTO TEMPO. Quem est\u00E1 esperando h\u00E1\n-- quarenta minutos e quem acabou de entrar aparecem iguais, e \u00E9 justamente essa\n-- diferen\u00E7a que decide quem a recep\u00E7\u00E3o chama primeiro.\n--\n-- O V1 mede por `checked_in_at`, e o coment\u00E1rio dele explica por que n\u00E3o serve\n-- `updated_at`: qualquer edi\u00E7\u00E3o no agendamento o reescreve, ent\u00E3o trocar uma\n-- observa\u00E7\u00E3o \u00E0s 14h faria a cliente que chegou \u00E0s 13h parecer rec\u00E9m-chegada. Um\n-- rel\u00F3gio que qualquer coisa reinicia n\u00E3o \u00E9 rel\u00F3gio.\n--\n-- O carimbo \u00E9 do BANCO, e n\u00E3o da tela: quem faz check-in pode ser a recep\u00E7\u00E3o\n-- pelo navegador, o totem, ou o pr\u00F3prio agente. Tr\u00EAs escritores, um s\u00F3 instante\n-- verdadeiro \u2014 e o rel\u00F3gio do servidor \u00E9 o \u00FAnico que os tr\u00EAs compartilham.\nALTER TABLE public.appointments\n ADD COLUMN IF NOT EXISTS checked_in_at timestamptz;\n\nCOMMENT ON COLUMN public.appointments.checked_in_at IS\n 'Quando a pessoa CHEGOU. N\u00E3o use updated_at para isso: qualquer edi\u00E7\u00E3o o reescreve e o tempo de espera mente (005).';\n\n-- \u2500\u2500 e o carimbo acontece na transi\u00E7\u00E3o, n\u00E3o no cliente \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nCREATE OR REPLACE FUNCTION public.trg_agenda_stamp_checkin()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $$\nBEGIN\n -- S\u00F3 na ENTRADA em waiting: reentrar no estado (uma corre\u00E7\u00E3o de status, um\n -- salvamento qualquer) n\u00E3o pode reiniciar a espera de quem j\u00E1 est\u00E1 sentada.\n IF NEW.status = 'waiting' AND (TG_OP = 'INSERT' OR OLD.status IS DISTINCT FROM 'waiting')\n AND NEW.checked_in_at IS NULL THEN\n NEW.checked_in_at := now();\n END IF;\n RETURN NEW;\nEND $$;\n\nCOMMENT ON FUNCTION public.trg_agenda_stamp_checkin() IS\n 'Carimba a chegada ao ENTRAR em waiting, uma vez. Reentrar no estado n\u00E3o reinicia a espera (005).';\n\nDROP TRIGGER IF EXISTS appointments_stamp_checkin ON public.appointments;\nCREATE TRIGGER appointments_stamp_checkin\n BEFORE INSERT OR UPDATE OF status ON public.appointments\n FOR EACH ROW\n EXECUTE FUNCTION public.trg_agenda_stamp_checkin();\n\n-- Quem j\u00E1 est\u00E1 esperando desde antes deste arquivo herda o \u00FAltimo instante\n-- conhecido \u2014 impreciso, e ainda assim melhor que um travess\u00E3o.\nUPDATE public.appointments\n SET checked_in_at = updated_at\n WHERE status = 'waiting' AND checked_in_at IS NULL;\n";
|
|
7
|
+
export declare const MIGRATION_006_THE_AGENDA_STOPS_PRICING_EVERY_INVOICE = "-- ---------------------------------------------------------------------------\n-- 006_the_agenda_stops_pricing_every_invoice.sql \u2014 ler DOIS agendamentos deixa\n-- de custar a apura\u00E7\u00E3o de 8.134 faturas.\n--\n-- O SINTOMA: `GET /rest/v1/v_appointments?...` devolvendo 500 com\n-- `57014: canceling statement due to statement timeout`, de forma reprodut\u00EDvel,\n-- ao abrir a agenda depois que existe agendamento na semana. Enquanto a semana\n-- estava vazia a mesma consulta respondia em 12ms \u2014 o que escondeu o problema e\n-- fez ele parecer intermitente.\n--\n-- \u2500\u2500 A medi\u00E7\u00E3o \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-- EXPLAIN ANALYZE da consulta exata, com 2 agendamentos na janela:\n--\n-- Nested Loop Left Join actual time=17137..21252 rows=2\n-- Join Filter: (o_1.id = b.order_id)\n-- Rows Removed by Join Filter: 16268 \u2190 dezesseis mil\n-- -> Nested Loop ... actual time=5619..10590 rows=8134 loops=2\n-- Execution Time: 21272 ms\n--\n-- `v_invoice_balances` foi calculada POR INTEIRO \u2014 as 8.134 faturas do tenant \u2014\n-- e DUAS VEZES, uma por agendamento. S\u00F3 depois o `Join Filter` jogou fora\n-- 16.268 das 16.270 linhas para ficar com as 2 que interessavam.\n--\n-- \u2500\u2500 Por que o filtro n\u00E3o desceu \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-- A view fazia:\n--\n-- LEFT JOIN v_invoice_balances inv ON inv.invoice_id = b.order_id\n--\n-- Isso \u00E9 um JOIN entre duas rela\u00E7\u00F5es, n\u00E3o um filtro constante. O planejador n\u00E3o\n-- tem por onde empurrar `b.order_id` para dentro de um agregado do outro lado:\n-- ele precisa produzir o lado direito inteiro para ent\u00E3o casar as chaves. E o\n-- lado direito \u00E9 um `GROUP BY order_id` sobre a \u00E1rvore financeira inteira\n-- (faturas \u2192 parcelas \u2192 aloca\u00E7\u00F5es \u2192 movimentos), cada n\u00EDvel pagando a RLS por\n-- linha. 8.134 linhas \u00D7 ~0,45ms de pol\u00EDtica \u00D7 tr\u00EAs n\u00EDveis \u00E9 o or\u00E7amento todo.\n--\n-- \u2500\u2500 A corre\u00E7\u00E3o \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-- `LEFT JOIN LATERAL`. A correla\u00E7\u00E3o deixa de ser uma condi\u00E7\u00E3o de join e vira um\n-- PAR\u00C2METRO da subconsulta: `WHERE vb.invoice_id = b.order_id` \u00E9 avaliado com\n-- `b.order_id` j\u00E1 conhecido, e `invoice_id` \u00E9 exatamente a chave de agrupamento\n-- de `v_invoice_balances` (`GROUP BY t.order_id`, depois `o.id = tb.order_id`).\n-- Um qual sobre a chave de agrupamento desce por baixo do agregado, e o que\n-- era uma varredura de 8.134 faturas vira uma sondagem de \u00EDndice de UMA.\n--\n-- Nada muda no que a view devolve: mesmas colunas, mesma ordem, mesmos tipos \u2014\n-- `LEFT JOIN LATERAL \u2026 ON true` tem exatamente a sem\u00E2ntica do LEFT JOIN que\n-- substitui, inclusive o NULL quando n\u00E3o h\u00E1 fatura.\n--\n-- \u2500\u2500 O que esta migration N\u00C3O conserta \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-- O multiplicador continua sendo a RLS por linha (~0,45ms por linha por\n-- tabela). Aqui ele deixa de doer porque o n\u00FAmero de linhas cai de 8.134 para\n-- 1, n\u00E3o porque ficou barato. Qualquer outra leitura que varra as tabelas\n-- financeiras inteiras vai reencontr\u00E1-lo \u2014 ver 016 e 019 para a forma de tirar\n-- pergunta constante de dentro do la\u00E7o.\n--\n-- MORA NA CORRENTE DA AGENDA, e n\u00E3o na do n\u00FAcleo, porque redefine\n-- `v_appointments` \u2014 view da agenda \u2014 lendo `v_invoice_balances`, view do\n-- financeiro. A corrente \u00E9 n\u00FAcleo \u2192 financeiro \u2192 \u2026 \u2192 agenda, ent\u00E3o este \u00E9 o\n-- \u00FAnico degrau em que as duas j\u00E1 existem. Nascida como 021 no n\u00FAcleo, ela\n-- falhava no `apply` com \"relation v_invoice_balances does not exist\", e o\n-- db-from-zero foi quem disse isso em voz alta.\n-- ---------------------------------------------------------------------------\n\n-- `WITH (security_invoker = true)` expl\u00EDcito: `CREATE OR REPLACE VIEW` N\u00C3O\n-- herda as op\u00E7\u00F5es da view que substitui, e sem isto ela volta a rodar com os\n-- direitos do dono \u2014 as pol\u00EDticas de quem l\u00EA deixam de valer. \u00C9 o que a\n-- asser\u00E7\u00E3o N16 da postura grada, e foi ela que pegou.\nCREATE OR REPLACE VIEW public.v_appointments WITH (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 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.product_id, 'name', oi.name, 'durationMinutes', oi.duration_minutes, 'price', oi.unit_price, 'assigneeId', oi.assignee_id) ORDER BY oi.sort_order) AS json_agg\n FROM 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 app.units l ON l.id = b.location_id\n LEFT JOIN orders o ON o.id = b.order_id\n -- LATERAL e n\u00E3o JOIN: assim `b.order_id` \u00E9 PAR\u00C2METRO da subconsulta e o\n -- filtro desce por baixo do `GROUP BY order_id` de v_invoice_balances.\n -- Como JOIN, o agregado inteiro (8.134 faturas) era produzido por\n -- agendamento e descartado depois \u2014 21s para devolver 2 linhas.\n LEFT JOIN LATERAL (\n SELECT vb.status, vb.paid, vb.balance\n FROM v_invoice_balances vb\n WHERE vb.invoice_id = b.order_id\n LIMIT 1\n ) inv ON true;\n";
|
|
22
8
|
export declare const MIGRATIONS: Array<{
|
|
23
9
|
id: string;
|
|
24
10
|
sql: string;
|