@fayz-ai/plugin-agenda 0.8.1 → 0.8.3

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.
Files changed (117) hide show
  1. package/dist/{chunk-LFUELUIT.cjs → chunk-3KWNHV5A.cjs} +60 -2
  2. package/dist/chunk-3KWNHV5A.cjs.map +1 -0
  3. package/dist/{chunk-DRZF6CV2.js → chunk-YQO6B4G3.js} +60 -3
  4. package/dist/chunk-YQO6B4G3.js.map +1 -0
  5. package/dist/components/AgendaGeneralSettings.d.ts.map +1 -1
  6. package/dist/components/AppointmentModal.d.ts.map +1 -1
  7. package/dist/components/CalendarToolbar.d.ts.map +1 -1
  8. package/dist/data/supabase.d.ts +5 -1
  9. package/dist/data/supabase.d.ts.map +1 -1
  10. package/dist/index.cjs +1071 -766
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +914 -609
  14. package/dist/index.js.map +1 -1
  15. package/dist/integrations/google-calendar/components/CalendarColorDot.d.ts +6 -0
  16. package/dist/integrations/google-calendar/components/CalendarColorDot.d.ts.map +1 -0
  17. package/dist/integrations/google-calendar/components/ChannelMappingList.d.ts +9 -0
  18. package/dist/integrations/google-calendar/components/ChannelMappingList.d.ts.map +1 -0
  19. package/dist/integrations/google-calendar/components/ImportEventsWizard.d.ts +12 -0
  20. package/dist/integrations/google-calendar/components/ImportEventsWizard.d.ts.map +1 -0
  21. package/dist/integrations/google-calendar/components/useAgendaLookups.d.ts +26 -0
  22. package/dist/integrations/google-calendar/components/useAgendaLookups.d.ts.map +1 -0
  23. package/dist/integrations/google-calendar/connectorDef.d.ts.map +1 -1
  24. package/dist/integrations/google-calendar/data/supabase.d.ts +33 -1
  25. package/dist/integrations/google-calendar/data/supabase.d.ts.map +1 -1
  26. package/dist/integrations/google-calendar/data/tables.d.ts +6 -0
  27. package/dist/integrations/google-calendar/data/tables.d.ts.map +1 -0
  28. package/dist/integrations/google-calendar/index.d.ts +1 -1
  29. package/dist/integrations/google-calendar/index.d.ts.map +1 -1
  30. package/dist/integrations/google-calendar/mapping.d.ts +56 -4
  31. package/dist/integrations/google-calendar/mapping.d.ts.map +1 -1
  32. package/dist/integrations/google-calendar/types.d.ts +101 -0
  33. package/dist/integrations/google-calendar/types.d.ts.map +1 -1
  34. package/dist/locales/en.d.ts.map +1 -1
  35. package/dist/locales/pt-BR.d.ts.map +1 -1
  36. package/dist/migrations/index.d.ts +11 -0
  37. package/dist/migrations/index.d.ts.map +1 -1
  38. package/dist/public/BookingWidget.d.ts.map +1 -1
  39. package/dist/public/context.d.ts +3 -1
  40. package/dist/public/context.d.ts.map +1 -1
  41. package/dist/public/createPublicBookingPlugin.d.ts +7 -1
  42. package/dist/public/createPublicBookingPlugin.d.ts.map +1 -1
  43. package/dist/public/data.d.ts +6 -0
  44. package/dist/public/data.d.ts.map +1 -1
  45. package/dist/public/data.supabase.d.ts.map +1 -1
  46. package/dist/public/format.d.ts +22 -1
  47. package/dist/public/format.d.ts.map +1 -1
  48. package/dist/public/hooks.d.ts.map +1 -1
  49. package/dist/public/index.cjs +31 -63
  50. package/dist/public/index.cjs.map +1 -1
  51. package/dist/public/index.d.ts +1 -1
  52. package/dist/public/index.d.ts.map +1 -1
  53. package/dist/public/index.js +27 -61
  54. package/dist/public/index.js.map +1 -1
  55. package/dist/public/steps/ContactStep.d.ts +4 -2
  56. package/dist/public/steps/ContactStep.d.ts.map +1 -1
  57. package/dist/public/types.d.ts +16 -0
  58. package/dist/public/types.d.ts.map +1 -1
  59. package/dist/views/CalendarView.d.ts.map +1 -1
  60. package/package.json +5 -5
  61. package/src/components/AgendaGeneralSettings.tsx +38 -16
  62. package/src/components/AppointmentModal.tsx +50 -108
  63. package/src/components/CalendarToolbar.tsx +10 -7
  64. package/src/data/supabase.ts +67 -13
  65. package/src/index.ts +56 -8
  66. package/src/integrations/google-calendar/components/CalendarColorDot.tsx +23 -0
  67. package/src/integrations/google-calendar/components/ChannelMappingList.tsx +329 -0
  68. package/src/integrations/google-calendar/components/ImportEventsWizard.tsx +215 -0
  69. package/src/integrations/google-calendar/components/useAgendaLookups.ts +100 -0
  70. package/src/integrations/google-calendar/connectorDef.tsx +123 -40
  71. package/src/integrations/google-calendar/data/supabase.ts +109 -13
  72. package/src/integrations/google-calendar/data/tables.ts +12 -0
  73. package/src/integrations/google-calendar/index.ts +10 -1
  74. package/src/integrations/google-calendar/integration.test.ts +126 -1
  75. package/src/integrations/google-calendar/mapping.ts +149 -9
  76. package/src/integrations/google-calendar/migrations/001_google_calendar.sql +9 -6
  77. package/src/integrations/google-calendar/migrations/002_smart_sync.sql +517 -0
  78. package/src/integrations/google-calendar/migrations/003_plg_rename.sql +273 -0
  79. package/src/integrations/google-calendar/types.ts +111 -0
  80. package/src/locales/en.ts +1 -0
  81. package/src/locales/pt-BR.ts +1 -0
  82. package/src/migrations/003_public_client_kind.sql +175 -0
  83. package/src/migrations/004_public_booking_assignee.sql +176 -0
  84. package/src/migrations/005_agent_rpcs.sql +168 -0
  85. package/src/migrations/006_agent_rpc_localtime.sql +181 -0
  86. package/src/migrations/007_schedule_exception_precedence.sql +290 -0
  87. package/src/migrations/008_agent_rpc_exception_precedence.sql +172 -0
  88. package/src/migrations/009_public_booking_location.sql +162 -0
  89. package/src/migrations/010_public_service_curation.sql +31 -0
  90. package/src/migrations/011_default_assignee.sql +167 -0
  91. package/src/migrations/012_drop_legacy_booking_overload.sql +29 -0
  92. package/src/migrations/013_normalize_v_appointments.sql +161 -0
  93. package/src/migrations/index.ts +1746 -1
  94. package/src/public/BookingWidget.tsx +10 -1
  95. package/src/public/context.tsx +3 -0
  96. package/src/public/createPublicBookingPlugin.ts +14 -1
  97. package/src/public/data.supabase.ts +3 -0
  98. package/src/public/data.ts +6 -0
  99. package/src/public/format.ts +36 -1
  100. package/src/public/hooks.ts +7 -2
  101. package/src/public/index.ts +1 -0
  102. package/src/public/steps/ContactStep.tsx +10 -5
  103. package/src/public/types.ts +21 -0
  104. package/src/views/CalendarView.tsx +15 -1
  105. package/dist/chunk-3LJKZQK4.js +0 -12
  106. package/dist/chunk-3LJKZQK4.js.map +0 -1
  107. package/dist/chunk-DRZF6CV2.js.map +0 -1
  108. package/dist/chunk-LFUELUIT.cjs.map +0 -1
  109. package/dist/chunk-SYDBDI3Q.cjs +0 -15
  110. package/dist/chunk-SYDBDI3Q.cjs.map +0 -1
  111. package/dist/components/SearchCombobox.d.ts +0 -38
  112. package/dist/components/SearchCombobox.d.ts.map +0 -1
  113. package/dist/tenant-222EHYUC.cjs +0 -16
  114. package/dist/tenant-222EHYUC.cjs.map +0 -1
  115. package/dist/tenant-LAUWVUYB.js +0 -3
  116. package/dist/tenant-LAUWVUYB.js.map +0 -1
  117. package/src/components/SearchCombobox.tsx +0 -216
@@ -2,6 +2,17 @@ export declare const MIGRATION_000B_V_BOOKINGS_RENAME = "-- Salon-only unblock:
2
2
  export declare const MIGRATION_000C_DEDUPE_CUSTOMER_PHONES = "-- 001 needs the customer-phone upsert key UNIQUE(tenant_id, phone). Converted\n-- pools can carry duplicates (found live on salon: 9 dogfood test records all\n-- sharing the founder's phone). Non-destructive dedupe: per (tenant_id, phone)\n-- customer group keep the row with most appointments (tie: oldest), NULL the\n-- phone on the rest and stash the original in metadata->'dedupedPhone'.\n-- Idempotent: reruns find no groups > 1.\n\nDO $dedupe$\nDECLARE\n r record;\nBEGIN\n IF to_regclass('public.people') IS NULL THEN\n RETURN;\n END IF;\n\n FOR r IN\n SELECT p.id, p.phone\n FROM public.people p\n JOIN LATERAL (\n SELECT count(*) AS appts\n FROM public.appointments a WHERE a.party_id = p.id\n ) ac ON true\n WHERE p.kind = 'customer' AND p.phone IS NOT NULL\n AND p.id NOT IN (\n SELECT DISTINCT ON (p2.tenant_id, p2.phone) p2.id\n FROM public.people p2\n WHERE p2.kind = 'customer' AND p2.phone IS NOT NULL\n ORDER BY p2.tenant_id, p2.phone,\n (SELECT count(*) FROM public.appointments a2 WHERE a2.party_id = p2.id) DESC,\n p2.created_at ASC\n )\n AND (p.tenant_id, p.phone) IN (\n SELECT tenant_id, phone FROM public.people\n WHERE kind = 'customer' AND phone IS NOT NULL\n GROUP BY tenant_id, phone HAVING count(*) > 1\n )\n LOOP\n UPDATE public.people\n SET metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object('dedupedPhone', r.phone),\n phone = NULL\n WHERE id = r.id;\n END LOOP;\nEND $dedupe$;\n";
3
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-- Upsert key for public customers (per tenant, by normalized phone)\nCREATE UNIQUE 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)\nCREATE OR REPLACE 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)\nCREATE OR REPLACE 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) \u2014 matches people_tenant_customer_phone\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 ON CONFLICT (tenant_id, phone) WHERE kind = 'customer' AND phone IS NOT NULL\n DO UPDATE SET\n name = EXCLUDED.name,\n email = COALESCE(EXCLUDED.email, people.email),\n updated_at = now()\n RETURNING id INTO v_person_id;\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";
4
4
  export declare const MIGRATION_002_V_BOOKINGS_COMPAT = "-- Where 000b preserved the legacy beauty view, re-create v_bookings as a\n-- thin alias over it so live pre-M5 clients that SELECT v_bookings by name\n-- keep their full legacy column set. Dropped for good once the beauty app\n-- ships on v_appointments (M5 follow-up).\n\nDO $compat$\nBEGIN\n IF to_regclass('public.v_bookings_legacy_beauty') IS NOT NULL\n AND to_regclass('public.v_bookings') IS NULL THEN\n EXECUTE 'CREATE VIEW public.v_bookings WITH (security_invoker = true) AS SELECT * FROM public.v_bookings_legacy_beauty';\n EXECUTE 'GRANT SELECT ON public.v_bookings TO authenticated, service_role';\n EXECUTE 'REVOKE SELECT ON public.v_bookings FROM anon';\n END IF;\nEND $compat$;\n";
5
+ export declare const MIGRATION_003_PUBLIC_CLIENT_KIND = "-- ---------------------------------------------------------------------------\n-- Public booking \u2014 tenant-configurable client kind\n-- ---------------------------------------------------------------------------\n-- 001 hardcoded kind='customer' for people created by create_public_booking.\n-- Verticals whose client archetype uses another kind (school \u2192 'student',\n-- clinic \u2192 whatever the app registers) ended up with people invisible to the\n-- app's own pages/lookups and with entity links resolving to unregistered\n-- routes (#/customers/:id \u2192 not found).\n--\n-- The client kind now comes from TENANT SETTINGS (admin-controlled \u2014 never\n-- from the anon caller): tenants.settings.booking.client_kind\n-- Default remains 'customer', so existing pools/tenants are unaffected until\n-- they opt in:\n-- UPDATE tenants SET settings = jsonb_set(COALESCE(settings,'{}'::jsonb),\n-- '{booking,client_kind}', '\"student\"') WHERE id = '<tenant>';\n--\n-- The upsert no longer relies on the kind-specific partial unique index\n-- (people_tenant_customer_phone) \u2014 a per-(tenant, phone) advisory lock\n-- serializes concurrent first-bookings for any kind. The 001 index stays in\n-- place (harmless, still guards direct customer writes).\n--\n-- Idempotent: CREATE OR REPLACE, same signature as 001.\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) 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 -- 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 client by (tenant, kind, phone). Kind is tenant-configured, so\n -- the 001 customer-only partial index can't arbitrate \u2014 a per-(tenant,\n -- phone) advisory lock serializes concurrent first-bookings instead.\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) FROM public;\nGRANT EXECUTE ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text)\n TO anon, authenticated, service_role;\n";
6
+ export declare const MIGRATION_004_PUBLIC_BOOKING_ASSIGNEE = "-- ---------------------------------------------------------------------------\n-- Public booking \u2014 respect the professional the visitor saw\n-- ---------------------------------------------------------------------------\n-- Until now create_public_booking IGNORED which professional the site showed\n-- availability for and re-picked by working hours (ORDER BY assignee_id\n-- LIMIT 1). With several professionals covering the same slot the booking\n-- landed on someone else than displayed (\"Equipe Great DJs\" on the site,\n-- \"Rafa Groove\" in the agenda). New optional p_assignee_id:\n-- * provided \u2192 validate THAT professional's working hours cover the slot and\n-- book them \u2014 never silently reassign (mismatch becomes an explicit error);\n-- * NULL \u2192 auto-pick as before (sites that don't pin a professional).\n-- Conflict check + advisory lock already run against the resolved assignee, so\n-- the double-book guard follows whichever path resolves.\n--\n-- Signature change (8th arg): the old 7-arg function is DROPPED first \u2014\n-- CREATE OR REPLACE with a different signature would create an ambiguous\n-- PostgREST overload instead of replacing.\n-- Includes 003's tenant-configurable client kind. Idempotent.\n-- ---------------------------------------------------------------------------\nDROP FUNCTION IF EXISTS public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text);\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 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 (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)\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 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";
7
+ export declare const MIGRATION_005_AGENT_RPCS = "-- ============================================================================\n-- plugin-agenda 005: server-plane agent write RPC.\n--\n-- public.agent_agenda_create_appointment \u2014 the INTERNAL (operator-actor)\n-- counterpart of create_public_booking (001 \u00A75): same invariants (working\n-- hours, advisory-lock race safety, hardcoded statuses), but:\n-- * actor-authorized: public.agent_guard (spine 015) runs role\u2192plan\u2192limit\n-- BEFORE anything \u2014 denial comes back as a structured jsonb the agent can\n-- explain conversationally, never a raised exception;\n-- * the client is an EXISTING person id (the agent resolves names\u2192ids via\n-- reads first \u2014 ambiguity is a conversation problem, not a SQL one);\n-- * a requested professional is honored (working hours permitting);\n-- * audited: audit_logs row with the acting user.\n--\n-- Contract (all agent_* RPCs): (p_tenant_id, p_actor_user_id, p_payload jsonb)\n-- \u2192 jsonb {ok:true, id, record:{...}} | {ok:false, denial:{...}} | {ok:false,\n-- error text}. GRANT authenticated+service_role \u2014 NEVER anon (the Fayz broker\n-- calls with the pool service key and injects tenant/actor server-side).\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_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 -- \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 := (p_payload->>'starts_at')::timestamptz;\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 SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz\n FROM tenants t WHERE t.id = p_tenant_id;\n\n -- requested professional honored; otherwise pick one whose hours cover the slot\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 ORDER BY sc.assignee_id\n LIMIT 1;\n IF v_professional IS NULL THEN\n RETURN jsonb_build_object('ok', false, 'error', 'slot outside working hours for the requested professional');\n END IF;\n\n -- race safety: serialize per (tenant, professional), then conflict-check\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 (statuses hardcoded) + 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\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 '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";
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) \u2014 matches people_tenant_customer_phone\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 ON CONFLICT (tenant_id, phone) WHERE kind = 'customer' AND phone IS NOT NULL\n DO UPDATE SET\n name = EXCLUDED.name,\n email = COALESCE(EXCLUDED.email, people.email),\n updated_at = now()\n RETURNING id INTO v_person_id;\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\nCREATE OR REPLACE VIEW public.rep_confirmation_queue AS\nSELECT 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\nCREATE OR REPLACE VIEW public.rep_new_clients AS\nSELECT 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\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";
5
16
  export declare const MIGRATIONS: Array<{
6
17
  id: string;
7
18
  sql: string;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,gCAAgC,09BAqB5C,CAAA;AAED,eAAO,MAAM,qCAAqC,giDA2CjD,CAAA;AAED,eAAO,MAAM,4BAA4B,mgZAmSxC,CAAA;AAED,eAAO,MAAM,+BAA+B,0sBAc3C,CAAA;AAED,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAKzD,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,gCAAgC,09BAqB5C,CAAA;AAED,eAAO,MAAM,qCAAqC,giDA2CjD,CAAA;AAED,eAAO,MAAM,4BAA4B,mgZAmSxC,CAAA;AAED,eAAO,MAAM,+BAA+B,0sBAc3C,CAAA;AAED,eAAO,MAAM,gCAAgC,q5OA+K5C,CAAA;AAED,eAAO,MAAM,qCAAqC,oiPAgLjD,CAAA;AAED,eAAO,MAAM,wBAAwB,uzQAwKpC,CAAA;AAED,eAAO,MAAM,iCAAiC,0xRAqL7C,CAAA;AAED,eAAO,MAAM,2CAA2C,i5YAkSvD,CAAA;AAED,eAAO,MAAM,4CAA4C,i8QA4KxD,CAAA;AAED,eAAO,MAAM,qCAAqC,04MAkKjD,CAAA;AAED,eAAO,MAAM,qCAAqC,y2CA+BjD,CAAA;AAED,eAAO,MAAM,8BAA8B,ivOAuK1C,CAAA;AAED,eAAO,MAAM,0CAA0C,6gDA6BtD,CAAA;AAED,eAAO,MAAM,sCAAsC,gxOAiKlD,CAAA;AAED,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAgBzD,CAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"BookingWidget.d.ts","sourceRoot":"","sources":["../../src/public/BookingWidget.tsx"],"names":[],"mappings":"AAgBA;;;;;;;;GAQG;AACH,wBAAgB,aAAa,gCAiS5B"}
1
+ {"version":3,"file":"BookingWidget.d.ts","sourceRoot":"","sources":["../../src/public/BookingWidget.tsx"],"names":[],"mappings":"AAgBA;;;;;;;;GAQG;AACH,wBAAgB,aAAa,gCA0S5B"}
@@ -1,7 +1,7 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import type { PaymentProvider } from '@fayz-ai/core';
3
3
  import type { PublicBookingDataProvider } from './data';
4
- import type { BookingComponents, BookingWindow, PublicBookingLabels, PublicService, ResolvedBookingBrand, ResolvedPayment, WorkingHours } from './types';
4
+ import type { BookingComponents, BookingWindow, PhoneVerificationMode, PublicBookingLabels, PublicService, ResolvedBookingBrand, ResolvedPayment, WorkingHours } from './types';
5
5
  /** Emitted when the customer's phone is verified / identity captured, for the
6
6
  * host to bridge to auth (booking stays decoupled — data-only, optional). */
7
7
  export type OnIdentityVerified = (identity: {
@@ -19,6 +19,8 @@ export interface PublicBookingContextValue {
19
19
  servicesLoading: boolean;
20
20
  /** Business opening hours, shown on the overview "Sobre" section. */
21
21
  businessHours: WorkingHours;
22
+ /** 'otp' shows the code modal; 'none' trusts the typed number (default). */
23
+ phoneVerification: PhoneVerificationMode;
22
24
  /** Payment step config, or null when the flow ends at confirmation. */
23
25
  payment: ResolvedPayment | null;
24
26
  /** Injected charge provider (Pix). When absent, the payment step self-mocks. */
@@ -1 +1 @@
1
- {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../../src/public/context.tsx"],"names":[],"mappings":"AAAA,OAAO,EAA2D,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAC/F,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AACpD,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,QAAQ,CAAA;AACvD,OAAO,KAAK,EACV,iBAAiB,EACjB,aAAa,EACb,mBAAmB,EACnB,aAAa,EACb,oBAAoB,EACpB,eAAe,EACf,YAAY,EACb,MAAM,SAAS,CAAA;AAEhB;8EAC8E;AAC9E,MAAM,MAAM,kBAAkB,GAAG,CAAC,QAAQ,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,KAAK,IAAI,CAAA;AAEpG,MAAM,WAAW,yBAAyB;IACxC,QAAQ,EAAE,yBAAyB,CAAA;IACnC,0EAA0E;IAC1E,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,gBAAgB,EAAE,MAAM,CAAA;IACxB,gFAAgF;IAChF,QAAQ,EAAE,aAAa,EAAE,CAAA;IACzB,eAAe,EAAE,OAAO,CAAA;IACxB,qEAAqE;IACrE,aAAa,EAAE,YAAY,CAAA;IAC3B,uEAAuE;IACvE,OAAO,EAAE,eAAe,GAAG,IAAI,CAAA;IAC/B,gFAAgF;IAChF,eAAe,EAAE,eAAe,GAAG,IAAI,CAAA;IACvC,+EAA+E;IAC/E,kBAAkB,EAAE,kBAAkB,GAAG,IAAI,CAAA;IAC7C,MAAM,EAAE,aAAa,CAAA;IACrB,MAAM,EAAE,mBAAmB,CAAA;IAC3B,sDAAsD;IACtD,KAAK,EAAE,oBAAoB,CAAA;IAC3B,+DAA+D;IAC/D,UAAU,EAAE,iBAAiB,CAAA;IAC7B,uDAAuD;IACvD,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,CAAA;CACf;AAID,MAAM,WAAW,oBAAoB;IACnC,2DAA2D;IAC3D,IAAI,EAAE,IAAI,CAAC,yBAAyB,EAAE,UAAU,GAAG,iBAAiB,CAAC,CAAA;IACrE,+EAA+E;IAC/E,cAAc,EAAE,aAAa,EAAE,GAAG,IAAI,CAAA;IACtC,QAAQ,EAAE,SAAS,CAAA;CACpB;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,EAAE,IAAI,EAAE,cAAc,EAAE,QAAQ,EAAE,EAAE,oBAAoB,+BAiCvF;AAED,wBAAgB,UAAU,IAAI,yBAAyB,CAMtD"}
1
+ {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../../src/public/context.tsx"],"names":[],"mappings":"AAAA,OAAO,EAA2D,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAC/F,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AACpD,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,QAAQ,CAAA;AACvD,OAAO,KAAK,EACV,iBAAiB,EACjB,aAAa,EACb,qBAAqB,EACrB,mBAAmB,EACnB,aAAa,EACb,oBAAoB,EACpB,eAAe,EACf,YAAY,EACb,MAAM,SAAS,CAAA;AAEhB;8EAC8E;AAC9E,MAAM,MAAM,kBAAkB,GAAG,CAAC,QAAQ,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,KAAK,IAAI,CAAA;AAEpG,MAAM,WAAW,yBAAyB;IACxC,QAAQ,EAAE,yBAAyB,CAAA;IACnC,0EAA0E;IAC1E,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,gBAAgB,EAAE,MAAM,CAAA;IACxB,gFAAgF;IAChF,QAAQ,EAAE,aAAa,EAAE,CAAA;IACzB,eAAe,EAAE,OAAO,CAAA;IACxB,qEAAqE;IACrE,aAAa,EAAE,YAAY,CAAA;IAC3B,4EAA4E;IAC5E,iBAAiB,EAAE,qBAAqB,CAAA;IACxC,uEAAuE;IACvE,OAAO,EAAE,eAAe,GAAG,IAAI,CAAA;IAC/B,gFAAgF;IAChF,eAAe,EAAE,eAAe,GAAG,IAAI,CAAA;IACvC,+EAA+E;IAC/E,kBAAkB,EAAE,kBAAkB,GAAG,IAAI,CAAA;IAC7C,MAAM,EAAE,aAAa,CAAA;IACrB,MAAM,EAAE,mBAAmB,CAAA;IAC3B,sDAAsD;IACtD,KAAK,EAAE,oBAAoB,CAAA;IAC3B,+DAA+D;IAC/D,UAAU,EAAE,iBAAiB,CAAA;IAC7B,uDAAuD;IACvD,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,CAAA;CACf;AAID,MAAM,WAAW,oBAAoB;IACnC,2DAA2D;IAC3D,IAAI,EAAE,IAAI,CAAC,yBAAyB,EAAE,UAAU,GAAG,iBAAiB,CAAC,CAAA;IACrE,+EAA+E;IAC/E,cAAc,EAAE,aAAa,EAAE,GAAG,IAAI,CAAA;IACtC,QAAQ,EAAE,SAAS,CAAA;CACpB;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,EAAE,IAAI,EAAE,cAAc,EAAE,QAAQ,EAAE,EAAE,oBAAoB,+BAiCvF;AAED,wBAAgB,UAAU,IAAI,yBAAyB,CAMtD"}
@@ -3,7 +3,7 @@ import type { PluginManifest, PluginScope, VerticalId, PaymentProvider } from '@
3
3
  import type { MockAgendaSeed } from '../data/mock';
4
4
  import { type PublicBookingDataProvider } from './data';
5
5
  import { type OnIdentityVerified } from './context';
6
- import type { BookingBrand, BookingComponents, BookingWindow, PaymentConfig, PublicBookingLabels, PublicService, WorkingHours } from './types';
6
+ import type { BookingBrand, BookingComponents, BookingWindow, PaymentConfig, PhoneVerificationMode, PublicBookingLabels, PublicService, WorkingHours } from './types';
7
7
  export interface PublicBookingOptions {
8
8
  /** Mount path for the public booking route. Default '/agendar'. */
9
9
  basePath?: string;
@@ -36,6 +36,12 @@ export interface PublicBookingOptions {
36
36
  brand?: BookingBrand;
37
37
  /** Swap whole subcomponents when structure must change (defaults exported). */
38
38
  components?: Partial<BookingComponents>;
39
+ /**
40
+ * Phone verification before the details sub-step. Default 'none' — the typed
41
+ * number is trusted. Set 'otp' only where a real code sender is wired: the
42
+ * built-in modal is a POC that accepts the literal '0000' and sends nothing.
43
+ */
44
+ phoneVerification?: PhoneVerificationMode;
39
45
  /** Adds a final Pagamento (Pix) step. Omit/disable to end the flow at confirmation. */
40
46
  payment?: PaymentConfig;
41
47
  /** Charge provider (from a payments plugin) used by the Pagamento step. Optional. */
@@ -1 +1 @@
1
- {"version":3,"file":"createPublicBookingPlugin.d.ts","sourceRoot":"","sources":["../../src/public/createPublicBookingPlugin.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,KAAK,EAAE,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAE9D,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AAE7F,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAClD,OAAO,EAAmC,KAAK,yBAAyB,EAAE,MAAM,QAAQ,CAAA;AAExF,OAAO,EAAmD,KAAK,kBAAkB,EAAE,MAAM,WAAW,CAAA;AAGpG,OAAO,KAAK,EACV,YAAY,EACZ,iBAAiB,EACjB,aAAa,EACb,aAAa,EACb,mBAAmB,EACnB,aAAa,EAGb,YAAY,EACb,MAAM,SAAS,CAAA;AAEhB,MAAM,WAAW,oBAAoB;IACnC,mEAAmE;IACnE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,YAAY,CAAC,EAAE;QAAE,EAAE,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IAC7C;;;OAGG;IACH,QAAQ,CAAC,EAAE,aAAa,EAAE,CAAA;IAC1B,gFAAgF;IAChF,YAAY,CAAC,EAAE,YAAY,CAAA;IAC3B,sDAAsD;IACtD,MAAM,CAAC,EAAE,OAAO,CAAC,aAAa,CAAC,CAAA;IAC/B,wDAAwD;IACxD,MAAM,CAAC,EAAE,OAAO,CAAC,mBAAmB,CAAC,CAAA;IACrC,wEAAwE;IACxE,KAAK,CAAC,EAAE,YAAY,CAAA;IACpB,+EAA+E;IAC/E,UAAU,CAAC,EAAE,OAAO,CAAC,iBAAiB,CAAC,CAAA;IACvC,uFAAuF;IACvF,OAAO,CAAC,EAAE,aAAa,CAAA;IACvB,qFAAqF;IACrF,eAAe,CAAC,EAAE,eAAe,CAAA;IACjC,oFAAoF;IACpF,kBAAkB,CAAC,EAAE,kBAAkB,CAAA;IACvC,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,4EAA4E;IAC5E,YAAY,CAAC,EAAE,yBAAyB,CAAA;IACxC,8CAA8C;IAC9C,IAAI,CAAC,EAAE,cAAc,CAAA;IACrB,KAAK,CAAC,EAAE,WAAW,CAAA;IACnB,UAAU,CAAC,EAAE,UAAU,CAAA;CACxB;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,cAAc,CAAA;IACxB,QAAQ,EAAE,EAAE,CAAC;QAAE,QAAQ,EAAE,SAAS,CAAA;KAAE,CAAC,CAAA;IACrC,YAAY,EAAE,yBAAyB,CAAA;CACxC;AAiGD,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,oBAAoB,GAAG,mBAAmB,CAmG5F"}
1
+ {"version":3,"file":"createPublicBookingPlugin.d.ts","sourceRoot":"","sources":["../../src/public/createPublicBookingPlugin.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,KAAK,EAAE,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAE9D,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AAE7F,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAClD,OAAO,EAAmC,KAAK,yBAAyB,EAAE,MAAM,QAAQ,CAAA;AAExF,OAAO,EAAmD,KAAK,kBAAkB,EAAE,MAAM,WAAW,CAAA;AAGpG,OAAO,KAAK,EACV,YAAY,EACZ,iBAAiB,EACjB,aAAa,EACb,aAAa,EACb,qBAAqB,EACrB,mBAAmB,EACnB,aAAa,EAGb,YAAY,EACb,MAAM,SAAS,CAAA;AAEhB,MAAM,WAAW,oBAAoB;IACnC,mEAAmE;IACnE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,YAAY,CAAC,EAAE;QAAE,EAAE,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IAC7C;;;OAGG;IACH,QAAQ,CAAC,EAAE,aAAa,EAAE,CAAA;IAC1B,gFAAgF;IAChF,YAAY,CAAC,EAAE,YAAY,CAAA;IAC3B,sDAAsD;IACtD,MAAM,CAAC,EAAE,OAAO,CAAC,aAAa,CAAC,CAAA;IAC/B,wDAAwD;IACxD,MAAM,CAAC,EAAE,OAAO,CAAC,mBAAmB,CAAC,CAAA;IACrC,wEAAwE;IACxE,KAAK,CAAC,EAAE,YAAY,CAAA;IACpB,+EAA+E;IAC/E,UAAU,CAAC,EAAE,OAAO,CAAC,iBAAiB,CAAC,CAAA;IACvC;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,qBAAqB,CAAA;IACzC,uFAAuF;IACvF,OAAO,CAAC,EAAE,aAAa,CAAA;IACvB,qFAAqF;IACrF,eAAe,CAAC,EAAE,eAAe,CAAA;IACjC,oFAAoF;IACpF,kBAAkB,CAAC,EAAE,kBAAkB,CAAA;IACvC,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,4EAA4E;IAC5E,YAAY,CAAC,EAAE,yBAAyB,CAAA;IACxC,8CAA8C;IAC9C,IAAI,CAAC,EAAE,cAAc,CAAA;IACrB,KAAK,CAAC,EAAE,WAAW,CAAA;IACnB,UAAU,CAAC,EAAE,UAAU,CAAA;CACxB;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,cAAc,CAAA;IACxB,QAAQ,EAAE,EAAE,CAAC;QAAE,QAAQ,EAAE,SAAS,CAAA;KAAE,CAAC,CAAA;IACrC,YAAY,EAAE,yBAAyB,CAAA;CACxC;AAqGD,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,oBAAoB,GAAG,mBAAmB,CAqG5F"}
@@ -15,6 +15,12 @@ export interface PublicCreateBookingInput {
15
15
  startsAt: string;
16
16
  /** name required; phone should include the country dial for real backends. */
17
17
  contact: ContactInfo;
18
+ /**
19
+ * Staff person id the visitor saw availability for. When set, the backend
20
+ * books THAT professional (or fails if their hours don't cover the slot) —
21
+ * never silently reassigns. null/undefined → backend auto-picks.
22
+ */
23
+ assigneeId?: string | null;
18
24
  }
19
25
  export interface PublicBookingResult {
20
26
  bookingId: string;
@@ -1 +1 @@
1
- {"version":3,"file":"data.d.ts","sourceRoot":"","sources":["../../src/public/data.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAA;AACxC,OAAO,EAA4B,KAAK,cAAc,EAAE,MAAM,cAAc,CAAA;AAC5E,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,SAAS,CAAA;AAEzD,MAAM,WAAW,eAAe;IAC9B,sEAAsE;IACtE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,uCAAuC;IACvC,IAAI,EAAE,MAAM,CAAA;IACZ,eAAe,EAAE,MAAM,CAAA;IACvB,YAAY,EAAE,MAAM,CAAA;CACrB;AAED,MAAM,WAAW,wBAAwB;IACvC,SAAS,EAAE,MAAM,CAAA;IACjB,6CAA6C;IAC7C,QAAQ,EAAE,MAAM,CAAA;IAChB,8EAA8E;IAC9E,OAAO,EAAE,WAAW,CAAA;CACrB;AAED,MAAM,WAAW,mBAAmB;IAClC,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,yBAAyB;IACxC,2EAA2E;IAC3E,WAAW,IAAI,OAAO,CAAC,aAAa,EAAE,CAAC,CAAA;IACvC,iBAAiB,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC,CAAA;IAC9D,aAAa,CAAC,KAAK,EAAE,wBAAwB,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAAA;CAC7E;AAOD,MAAM,WAAW,wBAAwB;IACvC,IAAI,EAAE,cAAc,CAAA;IACpB,QAAQ,EAAE,aAAa,EAAE,CAAA;IACzB,0DAA0D;IAC1D,cAAc,EAAE,MAAM,CAAA;CACvB;AAED,wBAAgB,+BAA+B,CAAC,OAAO,EAAE,wBAAwB,GAAG,yBAAyB,CA4C5G"}
1
+ {"version":3,"file":"data.d.ts","sourceRoot":"","sources":["../../src/public/data.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAA;AACxC,OAAO,EAA4B,KAAK,cAAc,EAAE,MAAM,cAAc,CAAA;AAC5E,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,SAAS,CAAA;AAEzD,MAAM,WAAW,eAAe;IAC9B,sEAAsE;IACtE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,uCAAuC;IACvC,IAAI,EAAE,MAAM,CAAA;IACZ,eAAe,EAAE,MAAM,CAAA;IACvB,YAAY,EAAE,MAAM,CAAA;CACrB;AAED,MAAM,WAAW,wBAAwB;IACvC,SAAS,EAAE,MAAM,CAAA;IACjB,6CAA6C;IAC7C,QAAQ,EAAE,MAAM,CAAA;IAChB,8EAA8E;IAC9E,OAAO,EAAE,WAAW,CAAA;IACpB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAC3B;AAED,MAAM,WAAW,mBAAmB;IAClC,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,yBAAyB;IACxC,2EAA2E;IAC3E,WAAW,IAAI,OAAO,CAAC,aAAa,EAAE,CAAC,CAAA;IACvC,iBAAiB,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC,CAAA;IAC9D,aAAa,CAAC,KAAK,EAAE,wBAAwB,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAAA;CAC7E;AAOD,MAAM,WAAW,wBAAwB;IACvC,IAAI,EAAE,cAAc,CAAA;IACpB,QAAQ,EAAE,aAAa,EAAE,CAAA;IACzB,0DAA0D;IAC1D,cAAc,EAAE,MAAM,CAAA;CACvB;AAED,wBAAgB,+BAA+B,CAAC,OAAO,EAAE,wBAAwB,GAAG,yBAAyB,CA4C5G"}
@@ -1 +1 @@
1
- {"version":3,"file":"data.supabase.d.ts","sourceRoot":"","sources":["../../src/public/data.supabase.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,QAAQ,CAAA;AAEvD,MAAM,WAAW,4BAA4B;IAC3C,sEAAsE;IACtE,QAAQ,EAAE,MAAM,CAAA;CACjB;AAkBD,wBAAgB,mCAAmC,CAAC,OAAO,EAAE,4BAA4B,GAAG,yBAAyB,CAsDpH"}
1
+ {"version":3,"file":"data.supabase.d.ts","sourceRoot":"","sources":["../../src/public/data.supabase.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,QAAQ,CAAA;AAEvD,MAAM,WAAW,4BAA4B;IAC3C,sEAAsE;IACtE,QAAQ,EAAE,MAAM,CAAA;CACjB;AAkBD,wBAAgB,mCAAmC,CAAC,OAAO,EAAE,4BAA4B,GAAG,yBAAyB,CAyDpH"}
@@ -4,8 +4,29 @@ export declare const WEEK_ORDER: number[];
4
4
  export declare function useFormatters(locale: string, currency: string): BookingFormatters;
5
5
  /** Local 'YYYY-MM-DD' for a Date. */
6
6
  export declare function toIso(d: Date): string;
7
- /** Read a preselected service id from the URL (?service=…), router-agnostic. */
7
+ /** Read a preselected service from the URL (?service=…), router-agnostic. */
8
8
  export declare function readServiceParam(): string | null;
9
+ /**
10
+ * Comparable form of a service name: lowercase, accent-free, non-alphanumerics
11
+ * collapsed to single dashes.
12
+ *
13
+ * Exists because the marketing site and the catalog spell the same service
14
+ * differently — the site writes "Peeling Químico" and "Drenagem Linfática", the
15
+ * catalog stores "Peeling Quimico" and "Drenagem Linfatica". Matching on the
16
+ * raw name would miss most of them, and making the site link raw UUIDs instead
17
+ * would scatter database ids through page markup and break on every reseed.
18
+ */
19
+ export declare function serviceSlug(name: string): string;
20
+ /**
21
+ * Resolve `?service=` against the catalog. Accepts the service id or its
22
+ * slugified name, so a site can link `/agendar?service=limpeza-de-pele`
23
+ * without knowing any ids. Unknown value ⇒ null, and the widget opens on the
24
+ * overview as if no service had been asked for.
25
+ */
26
+ export declare function resolvePreselectedService<T extends {
27
+ id: string;
28
+ name: string;
29
+ }>(param: string | null, services: T[]): string | null;
9
30
  export interface DayCell {
10
31
  iso: string;
11
32
  date: Date;
@@ -1 +1 @@
1
- {"version":3,"file":"format.d.ts","sourceRoot":"","sources":["../../src/public/format.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAA;AAEhD,eAAO,MAAM,SAAS,UAAuG,CAAA;AAC7H,eAAO,MAAM,UAAU,UAAwB,CAAA;AAE/C,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,iBAAiB,CAajF;AAED,qCAAqC;AACrC,wBAAgB,KAAK,CAAC,CAAC,EAAE,IAAI,GAAG,MAAM,CAErC;AAED,gFAAgF;AAChF,wBAAgB,gBAAgB,IAAI,MAAM,GAAG,IAAI,CAGhD;AAED,MAAM,WAAW,OAAO;IACtB,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,IAAI,CAAA;IACV,OAAO,EAAE,OAAO,CAAA;CACjB;AAED,kEAAkE;AAClE,wBAAgB,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,EAAE,CASvD;AAED,6CAA6C;AAC7C,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,GAAG,EAAE,iBAAiB,GAAG,MAAM,CAIrG"}
1
+ {"version":3,"file":"format.d.ts","sourceRoot":"","sources":["../../src/public/format.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAA;AAEhD,eAAO,MAAM,SAAS,UAAuG,CAAA;AAC7H,eAAO,MAAM,UAAU,UAAwB,CAAA;AAE/C,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,iBAAiB,CAajF;AAED,qCAAqC;AACrC,wBAAgB,KAAK,CAAC,CAAC,EAAE,IAAI,GAAG,MAAM,CAErC;AAED,6EAA6E;AAC7E,wBAAgB,gBAAgB,IAAI,MAAM,GAAG,IAAI,CAGhD;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAOhD;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,CAAC,SAAS;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAC9E,KAAK,EAAE,MAAM,GAAG,IAAI,EACpB,QAAQ,EAAE,CAAC,EAAE,GACZ,MAAM,GAAG,IAAI,CAKf;AAED,MAAM,WAAW,OAAO;IACtB,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,IAAI,CAAA;IACV,OAAO,EAAE,OAAO,CAAA;CACjB;AAED,kEAAkE;AAClE,wBAAgB,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,EAAE,CASvD;AAED,6CAA6C;AAC7C,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,GAAG,EAAE,iBAAiB,GAAG,MAAM,CAIrG"}
@@ -1 +1 @@
1
- {"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../../src/public/hooks.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAC5C,OAAO,KAAK,EAAE,wBAAwB,EAAE,mBAAmB,EAAE,MAAM,QAAQ,CAAA;AAC3E,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAA;AAExC,mFAAmF;AACnF,wBAAgB,WAAW,IAAI,aAAa,EAAE,CAE7C;AAED,MAAM,WAAW,uBAAuB;IACtC,QAAQ,EAAE,aAAa,EAAE,CAAA;IACzB,OAAO,EAAE,OAAO,CAAA;CACjB;AAED,mEAAmE;AACnE,wBAAgB,iBAAiB,IAAI,uBAAuB,CAG3D;AAED,MAAM,WAAW,uBAAuB;IACtC,KAAK,EAAE,QAAQ,EAAE,CAAA;IACjB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAA;CACpB;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE;IAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GAAG,uBAAuB,CAqCpH;AAED,MAAM,MAAM,wBAAwB,GAAG,wBAAwB,CAAA;AAE/D,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,CAAC,KAAK,EAAE,wBAAwB,KAAK,OAAO,CAAC,mBAAmB,CAAC,CAAA;IACzE,UAAU,EAAE,OAAO,CAAA;IACnB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAA;IACnB,IAAI,EAAE,OAAO,CAAA;CACd;AAED,sEAAsE;AACtE,wBAAgB,gBAAgB,IAAI,sBAAsB,CAuBzD"}
1
+ {"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../../src/public/hooks.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAC5C,OAAO,KAAK,EAAE,wBAAwB,EAAE,mBAAmB,EAAE,MAAM,QAAQ,CAAA;AAC3E,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAA;AAExC,mFAAmF;AACnF,wBAAgB,WAAW,IAAI,aAAa,EAAE,CAE7C;AAED,MAAM,WAAW,uBAAuB;IACtC,QAAQ,EAAE,aAAa,EAAE,CAAA;IACzB,OAAO,EAAE,OAAO,CAAA;CACjB;AAED,mEAAmE;AACnE,wBAAgB,iBAAiB,IAAI,uBAAuB,CAG3D;AAED,MAAM,WAAW,uBAAuB;IACtC,KAAK,EAAE,QAAQ,EAAE,CAAA;IACjB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAA;CACpB;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE;IAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GAAG,uBAAuB,CAqCpH;AAED,MAAM,MAAM,wBAAwB,GAAG,wBAAwB,CAAA;AAE/D,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,CAAC,KAAK,EAAE,wBAAwB,KAAK,OAAO,CAAC,mBAAmB,CAAC,CAAA;IACzE,UAAU,EAAE,OAAO,CAAA;IACnB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAA;IACnB,IAAI,EAAE,OAAO,CAAA;CACd;AAED,sEAAsE;AACtE,wBAAgB,gBAAgB,IAAI,sBAAsB,CA4BzD"}
@@ -1,14 +1,14 @@
1
1
  'use strict';
2
2
 
3
- var chunkLFUELUIT_cjs = require('../chunk-LFUELUIT.cjs');
3
+ var chunk3KWNHV5A_cjs = require('../chunk-3KWNHV5A.cjs');
4
4
  var react = require('react');
5
5
  var lucideReact = require('lucide-react');
6
- var core = require('@fayz-ai/core');
7
6
  var jsxRuntime = require('react/jsx-runtime');
7
+ var core = require('@fayz-ai/core');
8
8
 
9
9
  // src/public/data.ts
10
10
  function createMockPublicBookingProvider(options) {
11
- const inner = chunkLFUELUIT_cjs.createMockAgendaProvider({ seed: options.seed });
11
+ const inner = chunk3KWNHV5A_cjs.createMockAgendaProvider({ seed: options.seed });
12
12
  const { services, professionalId } = options;
13
13
  return {
14
14
  async getServices() {
@@ -49,58 +49,6 @@ function createMockPublicBookingProvider(options) {
49
49
  }
50
50
  };
51
51
  }
52
- function getClient() {
53
- return core.getSupabaseClientOptional() ?? core.getFayzCloudClient();
54
- }
55
- function toPhoneDigits(phone) {
56
- return (phone ?? "").replace(/\D/g, "");
57
- }
58
- function createSupabasePublicBookingProvider(options) {
59
- const { tenantId } = options;
60
- return {
61
- async getServices() {
62
- const { data, error } = await getClient().from("v_public_services").select("id, name, description, price, currency, duration_minutes, sort_order").eq("tenant_id", tenantId).order("sort_order", { ascending: true });
63
- if (error) throw new Error(`[plugin-agenda/public] getServices: ${error.message}`);
64
- return (data ?? []).map((row) => ({
65
- id: String(row.id),
66
- name: String(row.name),
67
- description: row.description ?? void 0,
68
- price: Number(row.price ?? 0),
69
- durationMinutes: Number(row.duration_minutes ?? 30)
70
- }));
71
- },
72
- async getAvailableSlots(query) {
73
- const { data, error } = await getClient().rpc("get_available_slots", {
74
- p_tenant_id: tenantId,
75
- p_assignee_id: query.assigneeId,
76
- p_date: query.date,
77
- p_duration_minutes: query.durationMinutes,
78
- p_slot_interval: query.slotInterval
79
- });
80
- if (error) throw new Error(`[plugin-agenda/public] getAvailableSlots: ${error.message}`);
81
- return (data ?? []).map((row) => ({
82
- start: row.slot_start,
83
- end: row.slot_end
84
- }));
85
- },
86
- async createBooking(input) {
87
- const phone = toPhoneDigits(input.contact.phone);
88
- const { data, error } = await getClient().rpc("create_public_booking", {
89
- p_tenant_id: tenantId,
90
- p_service_id: input.serviceId,
91
- p_starts_at: input.startsAt,
92
- p_name: input.contact.name.trim(),
93
- p_phone: phone,
94
- p_email: input.contact.email?.trim() || null,
95
- p_notes: input.contact.notes?.trim() || null
96
- });
97
- if (error) throw new Error(`[plugin-agenda/public] createBooking: ${error.message}`);
98
- const row = Array.isArray(data) ? data[0] : data;
99
- if (!row) throw new Error("[plugin-agenda/public] createBooking: empty RPC response");
100
- return { bookingId: row.booking_id, startsAt: row.starts_at, endsAt: row.ends_at };
101
- }
102
- };
103
- }
104
52
  var PublicBookingContext = react.createContext(null);
105
53
  function BookingProvider({ base, staticServices, children }) {
106
54
  const [fetched, setFetched] = react.useState(null);
@@ -173,7 +121,7 @@ function useAvailableSlots(params) {
173
121
  return { slots, loading, error };
174
122
  }
175
123
  function useCreateBooking() {
176
- const { provider } = useBooking();
124
+ const { provider, professionalId } = useBooking();
177
125
  const [submitting, setSubmitting] = react.useState(false);
178
126
  const [error, setError] = react.useState(null);
179
127
  const [done, setDone] = react.useState(false);
@@ -181,7 +129,10 @@ function useCreateBooking() {
181
129
  setSubmitting(true);
182
130
  setError(null);
183
131
  try {
184
- const result = await provider.createBooking(input);
132
+ const result = await provider.createBooking({
133
+ ...input,
134
+ assigneeId: input.assigneeId ?? professionalId ?? null
135
+ });
185
136
  setDone(true);
186
137
  return result;
187
138
  } catch (err) {
@@ -397,6 +348,7 @@ function ContactStep({
397
348
  contact,
398
349
  onContactChange,
399
350
  contactStep,
351
+ phoneVerification,
400
352
  country,
401
353
  onCountryChange,
402
354
  code,
@@ -451,7 +403,7 @@ function ContactStep({
451
403
  }
452
404
  )
453
405
  ] }),
454
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-2 text-xs text-muted-foreground", children: labels.phoneHint })
406
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-2 text-xs text-muted-foreground", children: phoneVerification === "none" ? labels.phoneHintNoVerification : labels.phoneHint })
455
407
  ] }),
456
408
  /* @__PURE__ */ jsxRuntime.jsx(
457
409
  "button",
@@ -459,7 +411,7 @@ function ContactStep({
459
411
  type: "submit",
460
412
  disabled: core.unmaskPhone(contact.phone).length < 8,
461
413
  className: "mt-5 w-full rounded-xl bg-primary py-3 font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
462
- children: labels.sendCodeCta
414
+ children: phoneVerification === "none" ? labels.phoneContinueCta : labels.sendCodeCta
463
415
  }
464
416
  )
465
417
  ] }) : null,
@@ -472,7 +424,7 @@ function ContactStep({
472
424
  /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "flex items-center gap-2 text-sm text-foreground", children: [
473
425
  /* @__PURE__ */ jsxRuntime.jsx(lucideReact.ShieldCheck, { className: "h-4 w-4 text-primary" }),
474
426
  " ",
475
- labels.phoneVerified,
427
+ phoneVerification === "none" ? labels.phoneLabel : labels.phoneVerified,
476
428
  " \xB7 ",
477
429
  country.flag,
478
430
  " ",
@@ -809,6 +761,7 @@ function BookingWidget() {
809
761
  businessHours,
810
762
  payment,
811
763
  paymentProvider,
764
+ phoneVerification,
812
765
  onIdentityVerified,
813
766
  labels,
814
767
  brand,
@@ -905,6 +858,11 @@ function BookingWidget() {
905
858
  }
906
859
  function sendCode() {
907
860
  if (core.unmaskPhone(contact.phone).length < 8) return;
861
+ if (phoneVerification === "none") {
862
+ emitIdentity();
863
+ setContactStep("details");
864
+ return;
865
+ }
908
866
  setCode("");
909
867
  setCodeError(false);
910
868
  setContactStep("code");
@@ -1042,6 +1000,7 @@ function BookingWidget() {
1042
1000
  setCode(v);
1043
1001
  setCodeError(false);
1044
1002
  },
1003
+ phoneVerification,
1045
1004
  codeError,
1046
1005
  onSendCode: sendCode,
1047
1006
  onConfirmCode: confirmCode,
@@ -1248,6 +1207,8 @@ var DEFAULT_LABELS = {
1248
1207
  phoneLabel: "Celular",
1249
1208
  phoneHint: "Enviaremos um c\xF3digo para confirmar que o n\xFAmero de telefone \xE9 seu. Usaremos este n\xFAmero para associar as suas informa\xE7\xF5es \xE0 sua conta.",
1250
1209
  sendCodeCta: "Enviar c\xF3digo via WhatsApp",
1210
+ phoneHintNoVerification: "Usaremos este n\xFAmero para confirmar seu agendamento pelo WhatsApp.",
1211
+ phoneContinueCta: "Continuar",
1251
1212
  codeTitle: "Insira o c\xF3digo",
1252
1213
  codeBody: "Enviamos um c\xF3digo via WhatsApp para voc\xEA confirmar seu n\xFAmero.",
1253
1214
  codeInvalid: "C\xF3digo inv\xE1lido. Para testar, use 0000.",
@@ -1264,7 +1225,9 @@ var DEFAULT_LABELS = {
1264
1225
  notesPlaceholder: "Use esse espa\xE7o para deixar uma observa\xE7\xE3o sobre este agendamento",
1265
1226
  saveAndContinueCta: "Salvar e continuar",
1266
1227
  submitError: "N\xE3o foi poss\xEDvel concluir o agendamento. Tente novamente.",
1267
- noChargeNote: "Pagamento via Pix \xB7 Sem cobran\xE7a agora",
1228
+ // Rendered ONLY when the payment step is off — so it must not promise a
1229
+ // payment method the flow never shows.
1230
+ noChargeNote: "Sem cobran\xE7a agora \xB7 o pagamento \xE9 feito no atendimento",
1268
1231
  // right rail
1269
1232
  railTitle: "Passos para agendar",
1270
1233
  railWaitingPayment: "Aguardando pagamento",
@@ -1321,6 +1284,7 @@ function createPublicBookingPlugin(options) {
1321
1284
  const locale = options.locale ?? "pt-BR";
1322
1285
  const hours = options.workingHours ?? DEFAULT_HOURS;
1323
1286
  const staticServices = options.services ?? null;
1287
+ const phoneVerification = options.phoneVerification ?? "none";
1324
1288
  const payment = options.payment?.enabled ? {
1325
1289
  reservationFeePercent: options.payment.reservationFeePercent ?? 100,
1326
1290
  method: options.payment.method ?? "pix"
@@ -1342,7 +1306,7 @@ function createPublicBookingPlugin(options) {
1342
1306
  if (options.dataProvider) {
1343
1307
  provider = options.dataProvider;
1344
1308
  } else if (options.tenantId) {
1345
- provider = createSupabasePublicBookingProvider({ tenantId: options.tenantId });
1309
+ provider = chunk3KWNHV5A_cjs.createSupabasePublicBookingProvider({ tenantId: options.tenantId });
1346
1310
  } else {
1347
1311
  if (typeof console !== "undefined") {
1348
1312
  console.warn(
@@ -1360,6 +1324,7 @@ function createPublicBookingPlugin(options) {
1360
1324
  professionalId,
1361
1325
  professionalName,
1362
1326
  businessHours: hours,
1327
+ phoneVerification,
1363
1328
  payment,
1364
1329
  paymentProvider: options.paymentProvider ?? null,
1365
1330
  onIdentityVerified: options.onIdentityVerified ?? null,
@@ -1399,6 +1364,10 @@ function createPublicBookingPlugin(options) {
1399
1364
  return { manifest, Provider, dataProvider: provider };
1400
1365
  }
1401
1366
 
1367
+ Object.defineProperty(exports, "createSupabasePublicBookingProvider", {
1368
+ enumerable: true,
1369
+ get: function () { return chunk3KWNHV5A_cjs.createSupabasePublicBookingProvider; }
1370
+ });
1402
1371
  exports.BookingProvider = BookingProvider;
1403
1372
  exports.BookingWidget = BookingWidget;
1404
1373
  exports.DAY_NAMES = DAY_NAMES;
@@ -1411,7 +1380,6 @@ exports.DefaultSuccessPanel = DefaultSuccessPanel;
1411
1380
  exports.WEEK_ORDER = WEEK_ORDER;
1412
1381
  exports.createMockPublicBookingProvider = createMockPublicBookingProvider;
1413
1382
  exports.createPublicBookingPlugin = createPublicBookingPlugin;
1414
- exports.createSupabasePublicBookingProvider = createSupabasePublicBookingProvider;
1415
1383
  exports.formatSlotRange = formatSlotRange;
1416
1384
  exports.useAvailableSlots = useAvailableSlots;
1417
1385
  exports.useBooking = useBooking;