@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
@@ -1,4 +1,4 @@
1
- // AUTO-GENERATED from 000b_v_bookings_rename.sql, 000c_dedupe_customer_phones.sql, 001_public_booking.sql, 002_v_bookings_compat.sql — regenerate with scripts/embed-migrations.mjs
1
+ // AUTO-GENERATED from 000b_v_bookings_rename.sql, 000c_dedupe_customer_phones.sql, 001_public_booking.sql, 002_v_bookings_compat.sql, 003_public_client_kind.sql, 004_public_booking_assignee.sql, 005_agent_rpcs.sql, 006_agent_rpc_localtime.sql, 007_schedule_exception_precedence.sql, 008_agent_rpc_exception_precedence.sql, 009_public_booking_location.sql, 010_public_service_curation.sql, 011_default_assignee.sql, 012_drop_legacy_booking_overload.sql, 013_normalize_v_appointments.sql — regenerate with scripts/embed-migrations.mjs
2
2
  // SQL files are the source of truth; this inline copy lets the manifest declare
3
3
  // migrations as data. Do not edit by hand — run the embed script instead.
4
4
 
@@ -379,9 +379,1754 @@ BEGIN
379
379
  END $compat$;
380
380
  `
381
381
 
382
+ export const MIGRATION_003_PUBLIC_CLIENT_KIND = `-- ---------------------------------------------------------------------------
383
+ -- Public booking — tenant-configurable client kind
384
+ -- ---------------------------------------------------------------------------
385
+ -- 001 hardcoded kind='customer' for people created by create_public_booking.
386
+ -- Verticals whose client archetype uses another kind (school → 'student',
387
+ -- clinic → whatever the app registers) ended up with people invisible to the
388
+ -- app's own pages/lookups and with entity links resolving to unregistered
389
+ -- routes (#/customers/:id → not found).
390
+ --
391
+ -- The client kind now comes from TENANT SETTINGS (admin-controlled — never
392
+ -- from the anon caller): tenants.settings.booking.client_kind
393
+ -- Default remains 'customer', so existing pools/tenants are unaffected until
394
+ -- they opt in:
395
+ -- UPDATE tenants SET settings = jsonb_set(COALESCE(settings,'{}'::jsonb),
396
+ -- '{booking,client_kind}', '"student"') WHERE id = '<tenant>';
397
+ --
398
+ -- The upsert no longer relies on the kind-specific partial unique index
399
+ -- (people_tenant_customer_phone) — a per-(tenant, phone) advisory lock
400
+ -- serializes concurrent first-bookings for any kind. The 001 index stays in
401
+ -- place (harmless, still guards direct customer writes).
402
+ --
403
+ -- Idempotent: CREATE OR REPLACE, same signature as 001.
404
+ -- ---------------------------------------------------------------------------
405
+ CREATE OR REPLACE FUNCTION public.create_public_booking(
406
+ p_tenant_id uuid,
407
+ p_service_id uuid,
408
+ p_starts_at timestamptz,
409
+ p_name text,
410
+ p_phone text,
411
+ p_email text DEFAULT NULL,
412
+ p_notes text DEFAULT NULL
413
+ ) RETURNS TABLE (booking_id uuid, starts_at timestamptz, ends_at timestamptz)
414
+ LANGUAGE plpgsql SECURITY DEFINER
415
+ SET search_path = public
416
+ AS $$
417
+ DECLARE
418
+ v_service record;
419
+ v_person_id uuid;
420
+ v_assignee uuid;
421
+ v_ends timestamptz;
422
+ v_order uuid;
423
+ v_booking uuid;
424
+ v_phone text;
425
+ v_tz text;
426
+ v_notes text;
427
+ v_client_kind text;
428
+ BEGIN
429
+ -- validation: anon-callable, everything tight
430
+ IF p_name IS NULL OR length(trim(p_name)) NOT BETWEEN 2 AND 120 THEN
431
+ RAISE EXCEPTION 'invalid name';
432
+ END IF;
433
+ v_phone := regexp_replace(COALESCE(p_phone, ''), '\\D', '', 'g');
434
+ IF length(v_phone) NOT BETWEEN 8 AND 15 THEN
435
+ RAISE EXCEPTION 'invalid phone';
436
+ END IF;
437
+ IF p_email IS NOT NULL AND (length(p_email) > 254 OR p_email !~ '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$') THEN
438
+ RAISE EXCEPTION 'invalid email';
439
+ END IF;
440
+ v_notes := left(p_notes, 500);
441
+ IF p_starts_at IS NULL OR p_starts_at <= now() OR p_starts_at > now() + interval '90 days' THEN
442
+ RAISE EXCEPTION 'invalid start';
443
+ END IF;
444
+
445
+ SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
446
+ INTO v_service
447
+ FROM services s
448
+ WHERE s.id = p_service_id AND s.tenant_id = p_tenant_id
449
+ AND s.is_active AND s.status = 'active';
450
+ IF NOT FOUND THEN RAISE EXCEPTION 'unknown service'; END IF;
451
+
452
+ v_ends := p_starts_at + make_interval(mins => v_service.duration_minutes);
453
+
454
+ -- tenant config: timezone + client kind (admin-set; sanitized, never caller input)
455
+ SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo'),
456
+ COALESCE(NULLIF(trim(t.settings->'booking'->>'client_kind'), ''), 'customer')
457
+ INTO v_tz, v_client_kind
458
+ FROM tenants t WHERE t.id = p_tenant_id;
459
+ IF v_tz IS NULL THEN RAISE EXCEPTION 'unknown tenant'; END IF;
460
+ IF v_client_kind !~ '^[a-z_]{2,32}$' THEN v_client_kind := 'customer'; END IF;
461
+
462
+ -- pick the assignee whose working hours cover the slot
463
+ SELECT sc.assignee_id INTO v_assignee
464
+ FROM schedules sc
465
+ WHERE sc.tenant_id = p_tenant_id
466
+ AND sc.kind = 'working_hours'
467
+ AND sc.is_active
468
+ AND (
469
+ sc.specific_date = (p_starts_at AT TIME ZONE v_tz)::date
470
+ OR (sc.specific_date IS NULL
471
+ AND sc.day_of_week = EXTRACT(dow FROM p_starts_at AT TIME ZONE v_tz)::smallint)
472
+ )
473
+ AND (p_starts_at AT TIME ZONE v_tz)::time >= sc.starts_at
474
+ AND (v_ends AT TIME ZONE v_tz)::time <= sc.ends_at
475
+ ORDER BY sc.assignee_id
476
+ LIMIT 1;
477
+ IF v_assignee IS NULL THEN RAISE EXCEPTION 'slot outside working hours'; END IF;
478
+
479
+ -- serialize per (tenant, assignee) to kill the double-book race
480
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_assignee::text, 0));
481
+
482
+ IF EXISTS (
483
+ SELECT 1 FROM appointments b
484
+ WHERE b.tenant_id = p_tenant_id
485
+ AND b.assignee_id = v_assignee
486
+ AND b.status NOT IN ('cancelled', 'no_show')
487
+ AND b.starts_at < v_ends
488
+ AND COALESCE(b.ends_at, b.starts_at) > p_starts_at
489
+ ) THEN
490
+ RAISE EXCEPTION 'slot no longer available';
491
+ END IF;
492
+
493
+ -- anti-spam: cap open future public bookings per phone per tenant
494
+ IF (
495
+ SELECT count(*) FROM appointments b
496
+ JOIN people p ON p.id = b.party_id
497
+ WHERE b.tenant_id = p_tenant_id
498
+ AND p.phone = v_phone
499
+ AND b.starts_at > now()
500
+ AND b.status = 'scheduled'
501
+ ) >= 3 THEN
502
+ RAISE EXCEPTION 'too many open bookings for this phone';
503
+ END IF;
504
+
505
+ -- upsert the client by (tenant, kind, phone). Kind is tenant-configured, so
506
+ -- the 001 customer-only partial index can't arbitrate — a per-(tenant,
507
+ -- phone) advisory lock serializes concurrent first-bookings instead.
508
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || ':' || v_phone, 1));
509
+
510
+ SELECT p.id INTO v_person_id
511
+ FROM people p
512
+ WHERE p.tenant_id = p_tenant_id AND p.kind = v_client_kind AND p.phone = v_phone
513
+ ORDER BY p.created_at
514
+ LIMIT 1;
515
+
516
+ IF v_person_id IS NOT NULL THEN
517
+ UPDATE people SET
518
+ name = trim(p_name),
519
+ email = COALESCE(p_email, people.email),
520
+ updated_at = now()
521
+ WHERE id = v_person_id;
522
+ ELSE
523
+ INSERT INTO people (tenant_id, kind, name, phone, email, metadata)
524
+ VALUES (p_tenant_id, v_client_kind, trim(p_name), v_phone, p_email,
525
+ jsonb_build_object('source', 'public_booking'))
526
+ RETURNING id INTO v_person_id;
527
+ END IF;
528
+
529
+ -- order + booking + order_item (statuses hardcoded — caller cannot set)
530
+ INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
531
+ VALUES (p_tenant_id, 'appointment', 'scheduled', v_person_id, v_assignee,
532
+ v_service.price, v_service.price, v_notes,
533
+ jsonb_build_object('source', 'public_booking',
534
+ 'serviceNames', v_service.name,
535
+ 'contactName', trim(p_name)))
536
+ RETURNING id INTO v_order;
537
+
538
+ INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
539
+ starts_at, ends_at, status, notes, metadata)
540
+ VALUES (p_tenant_id, 'appointment', v_person_id, v_assignee, v_order,
541
+ p_starts_at, v_ends, 'scheduled', v_notes,
542
+ jsonb_build_object('source', 'public_booking', 'serviceNames', v_service.name))
543
+ RETURNING id INTO v_booking;
544
+
545
+ INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
546
+ sort_order, duration_minutes, assignee_id)
547
+ VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
548
+ 0, v_service.duration_minutes, v_assignee);
549
+
550
+ RETURN QUERY SELECT v_booking, p_starts_at, v_ends;
551
+ END;
552
+ $$;
553
+
554
+ REVOKE ALL ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text) FROM public;
555
+ GRANT EXECUTE ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text)
556
+ TO anon, authenticated, service_role;
557
+ `
558
+
559
+ export const MIGRATION_004_PUBLIC_BOOKING_ASSIGNEE = `-- ---------------------------------------------------------------------------
560
+ -- Public booking — respect the professional the visitor saw
561
+ -- ---------------------------------------------------------------------------
562
+ -- Until now create_public_booking IGNORED which professional the site showed
563
+ -- availability for and re-picked by working hours (ORDER BY assignee_id
564
+ -- LIMIT 1). With several professionals covering the same slot the booking
565
+ -- landed on someone else than displayed ("Equipe Great DJs" on the site,
566
+ -- "Rafa Groove" in the agenda). New optional p_assignee_id:
567
+ -- * provided → validate THAT professional's working hours cover the slot and
568
+ -- book them — never silently reassign (mismatch becomes an explicit error);
569
+ -- * NULL → auto-pick as before (sites that don't pin a professional).
570
+ -- Conflict check + advisory lock already run against the resolved assignee, so
571
+ -- the double-book guard follows whichever path resolves.
572
+ --
573
+ -- Signature change (8th arg): the old 7-arg function is DROPPED first —
574
+ -- CREATE OR REPLACE with a different signature would create an ambiguous
575
+ -- PostgREST overload instead of replacing.
576
+ -- Includes 003's tenant-configurable client kind. Idempotent.
577
+ -- ---------------------------------------------------------------------------
578
+ DROP FUNCTION IF EXISTS public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text);
579
+
580
+ CREATE OR REPLACE FUNCTION public.create_public_booking(
581
+ p_tenant_id uuid,
582
+ p_service_id uuid,
583
+ p_starts_at timestamptz,
584
+ p_name text,
585
+ p_phone text,
586
+ p_email text DEFAULT NULL,
587
+ p_notes text DEFAULT NULL,
588
+ p_assignee_id uuid DEFAULT NULL
589
+ ) RETURNS TABLE (booking_id uuid, starts_at timestamptz, ends_at timestamptz)
590
+ LANGUAGE plpgsql SECURITY DEFINER
591
+ SET search_path = public
592
+ AS $$
593
+ DECLARE
594
+ v_service record;
595
+ v_person_id uuid;
596
+ v_assignee uuid;
597
+ v_ends timestamptz;
598
+ v_order uuid;
599
+ v_booking uuid;
600
+ v_phone text;
601
+ v_tz text;
602
+ v_notes text;
603
+ v_client_kind text;
604
+ BEGIN
605
+ -- validation: anon-callable, everything tight
606
+ IF p_name IS NULL OR length(trim(p_name)) NOT BETWEEN 2 AND 120 THEN
607
+ RAISE EXCEPTION 'invalid name';
608
+ END IF;
609
+ v_phone := regexp_replace(COALESCE(p_phone, ''), '\\D', '', 'g');
610
+ IF length(v_phone) NOT BETWEEN 8 AND 15 THEN
611
+ RAISE EXCEPTION 'invalid phone';
612
+ END IF;
613
+ IF p_email IS NOT NULL AND (length(p_email) > 254 OR p_email !~ '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$') THEN
614
+ RAISE EXCEPTION 'invalid email';
615
+ END IF;
616
+ v_notes := left(p_notes, 500);
617
+ IF p_starts_at IS NULL OR p_starts_at <= now() OR p_starts_at > now() + interval '90 days' THEN
618
+ RAISE EXCEPTION 'invalid start';
619
+ END IF;
620
+
621
+ SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
622
+ INTO v_service
623
+ FROM services s
624
+ WHERE s.id = p_service_id AND s.tenant_id = p_tenant_id
625
+ AND s.is_active AND s.status = 'active';
626
+ IF NOT FOUND THEN RAISE EXCEPTION 'unknown service'; END IF;
627
+
628
+ v_ends := p_starts_at + make_interval(mins => v_service.duration_minutes);
629
+
630
+ -- tenant config: timezone + client kind (admin-set; sanitized, never caller input)
631
+ SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo'),
632
+ COALESCE(NULLIF(trim(t.settings->'booking'->>'client_kind'), ''), 'customer')
633
+ INTO v_tz, v_client_kind
634
+ FROM tenants t WHERE t.id = p_tenant_id;
635
+ IF v_tz IS NULL THEN RAISE EXCEPTION 'unknown tenant'; END IF;
636
+ IF v_client_kind !~ '^[a-z_]{2,32}$' THEN v_client_kind := 'customer'; END IF;
637
+
638
+ -- resolve the professional: the one the visitor saw (validated), else the
639
+ -- first whose working hours cover the slot
640
+ SELECT sc.assignee_id INTO v_assignee
641
+ FROM schedules sc
642
+ WHERE sc.tenant_id = p_tenant_id
643
+ AND sc.kind = 'working_hours'
644
+ AND sc.is_active
645
+ AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)
646
+ AND (
647
+ sc.specific_date = (p_starts_at AT TIME ZONE v_tz)::date
648
+ OR (sc.specific_date IS NULL
649
+ AND sc.day_of_week = EXTRACT(dow FROM p_starts_at AT TIME ZONE v_tz)::smallint)
650
+ )
651
+ AND (p_starts_at AT TIME ZONE v_tz)::time >= sc.starts_at
652
+ AND (v_ends AT TIME ZONE v_tz)::time <= sc.ends_at
653
+ ORDER BY sc.assignee_id
654
+ LIMIT 1;
655
+ IF v_assignee IS NULL THEN RAISE EXCEPTION 'slot outside working hours'; END IF;
656
+
657
+ -- serialize per (tenant, assignee) to kill the double-book race
658
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_assignee::text, 0));
659
+
660
+ IF EXISTS (
661
+ SELECT 1 FROM appointments b
662
+ WHERE b.tenant_id = p_tenant_id
663
+ AND b.assignee_id = v_assignee
664
+ AND b.status NOT IN ('cancelled', 'no_show')
665
+ AND b.starts_at < v_ends
666
+ AND COALESCE(b.ends_at, b.starts_at) > p_starts_at
667
+ ) THEN
668
+ RAISE EXCEPTION 'slot no longer available';
669
+ END IF;
670
+
671
+ -- anti-spam: cap open future public bookings per phone per tenant
672
+ IF (
673
+ SELECT count(*) FROM appointments b
674
+ JOIN people p ON p.id = b.party_id
675
+ WHERE b.tenant_id = p_tenant_id
676
+ AND p.phone = v_phone
677
+ AND b.starts_at > now()
678
+ AND b.status = 'scheduled'
679
+ ) >= 3 THEN
680
+ RAISE EXCEPTION 'too many open bookings for this phone';
681
+ END IF;
682
+
683
+ -- upsert the client by (tenant, kind, phone), serialized by advisory lock
684
+ -- (kind is tenant-configured, so the customer-only partial index can't
685
+ -- arbitrate)
686
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || ':' || v_phone, 1));
687
+
688
+ SELECT p.id INTO v_person_id
689
+ FROM people p
690
+ WHERE p.tenant_id = p_tenant_id AND p.kind = v_client_kind AND p.phone = v_phone
691
+ ORDER BY p.created_at
692
+ LIMIT 1;
693
+
694
+ IF v_person_id IS NOT NULL THEN
695
+ UPDATE people SET
696
+ name = trim(p_name),
697
+ email = COALESCE(p_email, people.email),
698
+ updated_at = now()
699
+ WHERE id = v_person_id;
700
+ ELSE
701
+ INSERT INTO people (tenant_id, kind, name, phone, email, metadata)
702
+ VALUES (p_tenant_id, v_client_kind, trim(p_name), v_phone, p_email,
703
+ jsonb_build_object('source', 'public_booking'))
704
+ RETURNING id INTO v_person_id;
705
+ END IF;
706
+
707
+ -- order + booking + order_item (statuses hardcoded — caller cannot set)
708
+ INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
709
+ VALUES (p_tenant_id, 'appointment', 'scheduled', v_person_id, v_assignee,
710
+ v_service.price, v_service.price, v_notes,
711
+ jsonb_build_object('source', 'public_booking',
712
+ 'serviceNames', v_service.name,
713
+ 'contactName', trim(p_name)))
714
+ RETURNING id INTO v_order;
715
+
716
+ INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
717
+ starts_at, ends_at, status, notes, metadata)
718
+ VALUES (p_tenant_id, 'appointment', v_person_id, v_assignee, v_order,
719
+ p_starts_at, v_ends, 'scheduled', v_notes,
720
+ jsonb_build_object('source', 'public_booking', 'serviceNames', v_service.name))
721
+ RETURNING id INTO v_booking;
722
+
723
+ INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
724
+ sort_order, duration_minutes, assignee_id)
725
+ VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
726
+ 0, v_service.duration_minutes, v_assignee);
727
+
728
+ RETURN QUERY SELECT v_booking, p_starts_at, v_ends;
729
+ END;
730
+ $$;
731
+
732
+ REVOKE ALL ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text, uuid) FROM public;
733
+ GRANT EXECUTE ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text, uuid)
734
+ TO anon, authenticated, service_role;
735
+ `
736
+
737
+ export const MIGRATION_005_AGENT_RPCS = `-- ============================================================================
738
+ -- plugin-agenda 005: server-plane agent write RPC.
739
+ --
740
+ -- public.agent_agenda_create_appointment — the INTERNAL (operator-actor)
741
+ -- counterpart of create_public_booking (001 §5): same invariants (working
742
+ -- hours, advisory-lock race safety, hardcoded statuses), but:
743
+ -- * actor-authorized: public.agent_guard (spine 015) runs role→plan→limit
744
+ -- BEFORE anything — denial comes back as a structured jsonb the agent can
745
+ -- explain conversationally, never a raised exception;
746
+ -- * the client is an EXISTING person id (the agent resolves names→ids via
747
+ -- reads first — ambiguity is a conversation problem, not a SQL one);
748
+ -- * a requested professional is honored (working hours permitting);
749
+ -- * audited: audit_logs row with the acting user.
750
+ --
751
+ -- Contract (all agent_* RPCs): (p_tenant_id, p_actor_user_id, p_payload jsonb)
752
+ -- → jsonb {ok:true, id, record:{...}} | {ok:false, denial:{...}} | {ok:false,
753
+ -- error text}. GRANT authenticated+service_role — NEVER anon (the Fayz broker
754
+ -- calls with the pool service key and injects tenant/actor server-side).
755
+ -- ============================================================================
756
+
757
+ CREATE OR REPLACE FUNCTION public.agent_agenda_create_appointment(
758
+ p_tenant_id uuid,
759
+ p_actor_user_id uuid,
760
+ p_payload jsonb
761
+ ) RETURNS jsonb
762
+ LANGUAGE plpgsql SECURITY DEFINER
763
+ SET search_path = public
764
+ AS $$
765
+ DECLARE
766
+ v_denial jsonb;
767
+ v_used int;
768
+ v_client_id uuid;
769
+ v_service_id uuid;
770
+ v_professional uuid;
771
+ v_starts timestamptz;
772
+ v_notes text;
773
+ v_service record;
774
+ v_client record;
775
+ v_ends timestamptz;
776
+ v_tz text;
777
+ v_order uuid;
778
+ v_booking uuid;
779
+ BEGIN
780
+ -- ── payload ──────────────────────────────────────────────────────────────
781
+ BEGIN
782
+ v_client_id := (p_payload->>'client_id')::uuid;
783
+ v_service_id := (p_payload->>'service_id')::uuid;
784
+ v_professional := (p_payload->>'professional_id')::uuid;
785
+ v_starts := (p_payload->>'starts_at')::timestamptz;
786
+ EXCEPTION WHEN OTHERS THEN
787
+ RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);
788
+ END;
789
+ v_notes := left(p_payload->>'notes', 500);
790
+ IF v_client_id IS NULL OR v_service_id IS NULL OR v_starts IS NULL THEN
791
+ RETURN jsonb_build_object('ok', false, 'error', 'client_id, service_id and starts_at are required');
792
+ END IF;
793
+ IF v_starts <= now() OR v_starts > now() + interval '365 days' THEN
794
+ RETURN jsonb_build_object('ok', false, 'error', 'starts_at must be in the future (max 1 year)');
795
+ END IF;
796
+
797
+ -- ── authorization: role → plan → bookings_month cap ──────────────────────
798
+ SELECT count(*) INTO v_used FROM appointments b
799
+ WHERE b.tenant_id = p_tenant_id
800
+ AND b.created_at >= date_trunc('month', now());
801
+ v_denial := agent_guard(p_tenant_id, p_actor_user_id,
802
+ 'appointments', 'create', 'bookings_month', v_used, 1);
803
+ IF v_denial IS NOT NULL THEN
804
+ RETURN jsonb_build_object('ok', false, 'denial', v_denial);
805
+ END IF;
806
+
807
+ -- ── invariants ───────────────────────────────────────────────────────────
808
+ SELECT p.id, p.name INTO v_client
809
+ FROM people p
810
+ WHERE p.id = v_client_id AND p.tenant_id = p_tenant_id;
811
+ IF NOT FOUND THEN
812
+ RETURN jsonb_build_object('ok', false, 'error', 'unknown client for this tenant');
813
+ END IF;
814
+
815
+ SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
816
+ INTO v_service
817
+ FROM services s
818
+ WHERE s.id = v_service_id AND s.tenant_id = p_tenant_id
819
+ AND s.is_active AND s.status = 'active';
820
+ IF NOT FOUND THEN
821
+ RETURN jsonb_build_object('ok', false, 'error', 'unknown or inactive service');
822
+ END IF;
823
+
824
+ v_ends := v_starts + make_interval(mins => v_service.duration_minutes);
825
+ SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
826
+ FROM tenants t WHERE t.id = p_tenant_id;
827
+
828
+ -- requested professional honored; otherwise pick one whose hours cover the slot
829
+ SELECT sc.assignee_id INTO v_professional
830
+ FROM schedules sc
831
+ WHERE sc.tenant_id = p_tenant_id
832
+ AND sc.kind = 'working_hours'
833
+ AND sc.is_active
834
+ AND (v_professional IS NULL OR sc.assignee_id = v_professional)
835
+ AND (
836
+ sc.specific_date = (v_starts AT TIME ZONE v_tz)::date
837
+ OR (sc.specific_date IS NULL
838
+ AND sc.day_of_week = EXTRACT(dow FROM v_starts AT TIME ZONE v_tz)::smallint)
839
+ )
840
+ AND (v_starts AT TIME ZONE v_tz)::time >= sc.starts_at
841
+ AND (v_ends AT TIME ZONE v_tz)::time <= sc.ends_at
842
+ ORDER BY sc.assignee_id
843
+ LIMIT 1;
844
+ IF v_professional IS NULL THEN
845
+ RETURN jsonb_build_object('ok', false, 'error', 'slot outside working hours for the requested professional');
846
+ END IF;
847
+
848
+ -- race safety: serialize per (tenant, professional), then conflict-check
849
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_professional::text, 0));
850
+ IF EXISTS (
851
+ SELECT 1 FROM appointments b
852
+ WHERE b.tenant_id = p_tenant_id
853
+ AND b.assignee_id = v_professional
854
+ AND b.status NOT IN ('cancelled', 'no_show')
855
+ AND b.starts_at < v_ends
856
+ AND COALESCE(b.ends_at, b.starts_at) > v_starts
857
+ ) THEN
858
+ RETURN jsonb_build_object('ok', false, 'error', 'slot no longer available');
859
+ END IF;
860
+
861
+ -- ── write (statuses hardcoded) + audit ───────────────────────────────────
862
+ INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
863
+ VALUES (p_tenant_id, 'appointment', 'scheduled', v_client_id, v_professional,
864
+ v_service.price, v_service.price, v_notes,
865
+ jsonb_build_object('source', 'agent', 'serviceNames', v_service.name))
866
+ RETURNING id INTO v_order;
867
+
868
+ INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
869
+ starts_at, ends_at, status, notes, metadata)
870
+ VALUES (p_tenant_id, 'appointment', v_client_id, v_professional, v_order,
871
+ v_starts, v_ends, 'scheduled', v_notes,
872
+ jsonb_build_object('source', 'agent', 'serviceNames', v_service.name))
873
+ RETURNING id INTO v_booking;
874
+
875
+ INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
876
+ sort_order, duration_minutes, assignee_id)
877
+ VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
878
+ 0, v_service.duration_minutes, v_professional);
879
+
880
+ INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
881
+ VALUES (p_tenant_id, p_actor_user_id, 'agent.createAppointment', 'appointment', v_booking::text,
882
+ jsonb_build_object('payload', p_payload, 'order_id', v_order));
883
+
884
+ RETURN jsonb_build_object(
885
+ 'ok', true,
886
+ 'id', v_booking,
887
+ 'record', jsonb_build_object(
888
+ 'ref', jsonb_build_object('id', v_booking, 'resource', 'appointments',
889
+ 'archetype', 'schedule:appointment'),
890
+ 'starts_at', v_starts,
891
+ 'ends_at', v_ends,
892
+ 'client_name', v_client.name,
893
+ 'service_name', v_service.name,
894
+ 'price', v_service.price,
895
+ 'status', 'scheduled'
896
+ )
897
+ );
898
+ END;
899
+ $$;
900
+
901
+ REVOKE ALL ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb) FROM public;
902
+ REVOKE EXECUTE ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb) FROM anon;
903
+ GRANT EXECUTE ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb)
904
+ TO authenticated, service_role;
905
+ `
906
+
907
+ export const MIGRATION_006_AGENT_RPC_LOCALTIME = `-- ============================================================================
908
+ -- plugin-agenda 006: agent_agenda_create_appointment speaks LOCAL time.
909
+ --
910
+ -- Models reliably mangle UTC↔local offsets ("10h" became 13:00 BRT via a
911
+ -- wrong sign). The RPC knows the tenant's timezone — so stop asking the model
912
+ -- to convert: \`starts_at\` WITHOUT an offset (2026-07-27T10:00) is interpreted
913
+ -- in the tenant tz; an explicit offset still works. The result now carries
914
+ -- starts_at_local/ends_at_local strings so the reply echoes the truth in the
915
+ -- user's own clock. Supersedes 005's function body (new file — applied
916
+ -- migrations are never edited).
917
+ -- ============================================================================
918
+
919
+ CREATE OR REPLACE FUNCTION public.agent_agenda_create_appointment(
920
+ p_tenant_id uuid,
921
+ p_actor_user_id uuid,
922
+ p_payload jsonb
923
+ ) RETURNS jsonb
924
+ LANGUAGE plpgsql SECURITY DEFINER
925
+ SET search_path = public
926
+ AS $$
927
+ DECLARE
928
+ v_denial jsonb;
929
+ v_used int;
930
+ v_client_id uuid;
931
+ v_service_id uuid;
932
+ v_professional uuid;
933
+ v_starts timestamptz;
934
+ v_starts_raw text;
935
+ v_notes text;
936
+ v_service record;
937
+ v_client record;
938
+ v_ends timestamptz;
939
+ v_tz text;
940
+ v_order uuid;
941
+ v_booking uuid;
942
+ BEGIN
943
+ SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
944
+ FROM tenants t WHERE t.id = p_tenant_id;
945
+ IF v_tz IS NULL THEN
946
+ RETURN jsonb_build_object('ok', false, 'error', 'unknown tenant');
947
+ END IF;
948
+
949
+ -- ── payload ──────────────────────────────────────────────────────────────
950
+ BEGIN
951
+ v_client_id := (p_payload->>'client_id')::uuid;
952
+ v_service_id := (p_payload->>'service_id')::uuid;
953
+ v_professional := (p_payload->>'professional_id')::uuid;
954
+ v_starts_raw := p_payload->>'starts_at';
955
+ IF v_starts_raw ~ '(Z|[+-][0-9]{2}:?[0-9]{2})$' THEN
956
+ v_starts := v_starts_raw::timestamptz; -- explicit offset
957
+ ELSE
958
+ v_starts := (v_starts_raw::timestamp) AT TIME ZONE v_tz; -- tenant-local
959
+ END IF;
960
+ EXCEPTION WHEN OTHERS THEN
961
+ RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);
962
+ END;
963
+ v_notes := left(p_payload->>'notes', 500);
964
+ IF v_client_id IS NULL OR v_service_id IS NULL OR v_starts IS NULL THEN
965
+ RETURN jsonb_build_object('ok', false, 'error', 'client_id, service_id and starts_at are required');
966
+ END IF;
967
+ IF v_starts <= now() OR v_starts > now() + interval '365 days' THEN
968
+ RETURN jsonb_build_object('ok', false, 'error', 'starts_at must be in the future (max 1 year)');
969
+ END IF;
970
+
971
+ -- ── authorization: role → plan → bookings_month cap ──────────────────────
972
+ SELECT count(*) INTO v_used FROM appointments b
973
+ WHERE b.tenant_id = p_tenant_id
974
+ AND b.created_at >= date_trunc('month', now());
975
+ v_denial := agent_guard(p_tenant_id, p_actor_user_id,
976
+ 'appointments', 'create', 'bookings_month', v_used, 1);
977
+ IF v_denial IS NOT NULL THEN
978
+ RETURN jsonb_build_object('ok', false, 'denial', v_denial);
979
+ END IF;
980
+
981
+ -- ── invariants ───────────────────────────────────────────────────────────
982
+ SELECT p.id, p.name INTO v_client
983
+ FROM people p
984
+ WHERE p.id = v_client_id AND p.tenant_id = p_tenant_id;
985
+ IF NOT FOUND THEN
986
+ RETURN jsonb_build_object('ok', false, 'error', 'unknown client for this tenant');
987
+ END IF;
988
+
989
+ SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
990
+ INTO v_service
991
+ FROM services s
992
+ WHERE s.id = v_service_id AND s.tenant_id = p_tenant_id
993
+ AND s.is_active AND s.status = 'active';
994
+ IF NOT FOUND THEN
995
+ RETURN jsonb_build_object('ok', false, 'error', 'unknown or inactive service');
996
+ END IF;
997
+
998
+ v_ends := v_starts + make_interval(mins => v_service.duration_minutes);
999
+
1000
+ SELECT sc.assignee_id INTO v_professional
1001
+ FROM schedules sc
1002
+ WHERE sc.tenant_id = p_tenant_id
1003
+ AND sc.kind = 'working_hours'
1004
+ AND sc.is_active
1005
+ AND (v_professional IS NULL OR sc.assignee_id = v_professional)
1006
+ AND (
1007
+ sc.specific_date = (v_starts AT TIME ZONE v_tz)::date
1008
+ OR (sc.specific_date IS NULL
1009
+ AND sc.day_of_week = EXTRACT(dow FROM v_starts AT TIME ZONE v_tz)::smallint)
1010
+ )
1011
+ AND (v_starts AT TIME ZONE v_tz)::time >= sc.starts_at
1012
+ AND (v_ends AT TIME ZONE v_tz)::time <= sc.ends_at
1013
+ -- prefer a professional who is actually FREE at the slot (when the user
1014
+ -- did not name one, the first busy professional must not block the booking)
1015
+ AND NOT EXISTS (
1016
+ SELECT 1 FROM appointments b
1017
+ WHERE b.tenant_id = p_tenant_id
1018
+ AND b.assignee_id = sc.assignee_id
1019
+ AND b.status NOT IN ('cancelled', 'no_show')
1020
+ AND b.starts_at < v_ends
1021
+ AND COALESCE(b.ends_at, b.starts_at) > v_starts
1022
+ )
1023
+ ORDER BY sc.assignee_id
1024
+ LIMIT 1;
1025
+ IF v_professional IS NULL THEN
1026
+ RETURN jsonb_build_object('ok', false, 'error',
1027
+ 'no professional is available at that slot (outside working hours or already booked). Suggest a different time. Times are tenant-local, tz ' || v_tz);
1028
+ END IF;
1029
+
1030
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_professional::text, 0));
1031
+ IF EXISTS (
1032
+ SELECT 1 FROM appointments b
1033
+ WHERE b.tenant_id = p_tenant_id
1034
+ AND b.assignee_id = v_professional
1035
+ AND b.status NOT IN ('cancelled', 'no_show')
1036
+ AND b.starts_at < v_ends
1037
+ AND COALESCE(b.ends_at, b.starts_at) > v_starts
1038
+ ) THEN
1039
+ RETURN jsonb_build_object('ok', false, 'error', 'slot no longer available');
1040
+ END IF;
1041
+
1042
+ -- ── write + audit ────────────────────────────────────────────────────────
1043
+ INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
1044
+ VALUES (p_tenant_id, 'appointment', 'scheduled', v_client_id, v_professional,
1045
+ v_service.price, v_service.price, v_notes,
1046
+ jsonb_build_object('source', 'agent', 'serviceNames', v_service.name))
1047
+ RETURNING id INTO v_order;
1048
+
1049
+ INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
1050
+ starts_at, ends_at, status, notes, metadata)
1051
+ VALUES (p_tenant_id, 'appointment', v_client_id, v_professional, v_order,
1052
+ v_starts, v_ends, 'scheduled', v_notes,
1053
+ jsonb_build_object('source', 'agent', 'serviceNames', v_service.name))
1054
+ RETURNING id INTO v_booking;
1055
+
1056
+ INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
1057
+ sort_order, duration_minutes, assignee_id)
1058
+ VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
1059
+ 0, v_service.duration_minutes, v_professional);
1060
+
1061
+ INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
1062
+ VALUES (p_tenant_id, p_actor_user_id, 'agent.createAppointment', 'appointment', v_booking::text,
1063
+ jsonb_build_object('payload', p_payload, 'order_id', v_order));
1064
+
1065
+ RETURN jsonb_build_object(
1066
+ 'ok', true,
1067
+ 'id', v_booking,
1068
+ 'record', jsonb_build_object(
1069
+ 'ref', jsonb_build_object('id', v_booking, 'resource', 'appointments',
1070
+ 'archetype', 'schedule:appointment'),
1071
+ 'starts_at', v_starts,
1072
+ 'ends_at', v_ends,
1073
+ 'starts_at_local', to_char(v_starts AT TIME ZONE v_tz, 'YYYY-MM-DD (Dy) HH24:MI') || ' ' || v_tz,
1074
+ 'ends_at_local', to_char(v_ends AT TIME ZONE v_tz, 'HH24:MI'),
1075
+ 'client_name', v_client.name,
1076
+ 'service_name', v_service.name,
1077
+ 'price', v_service.price,
1078
+ 'status', 'scheduled'
1079
+ )
1080
+ );
1081
+ END;
1082
+ $$;
1083
+
1084
+ REVOKE ALL ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb) FROM public;
1085
+ REVOKE EXECUTE ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb) FROM anon;
1086
+ GRANT EXECUTE ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb)
1087
+ TO authenticated, service_role;
1088
+ `
1089
+
1090
+ export const MIGRATION_007_SCHEDULE_EXCEPTION_PRECEDENCE = `-- ============================================================================
1091
+ -- plugin-agenda 007: specific-date exceptions take PRECEDENCE over weekly hours.
1092
+ --
1093
+ -- The admin ScheduleEditor (saveException) writes two kinds of specific-date
1094
+ -- rows: 'available' = active rows with custom windows for the date; day off
1095
+ -- ('unavailable') = ONE inactive 00:00–00:00 sentinel. Until now every reader
1096
+ -- treated schedules as a flat union filtered by is_active, so:
1097
+ -- • a day off was INVISIBLE (inactive sentinel filtered out, weekly rows
1098
+ -- still matched) — the public site kept selling a day the admin closed;
1099
+ -- • an 'available' exception ADDED to the weekly hours instead of replacing
1100
+ -- them ("horário especial" should define the date's hours).
1101
+ --
1102
+ -- Fix: one helper — agenda_working_windows(tenant, date, assignee) — is now the
1103
+ -- single source of a day's windows: if ANY specific-date row exists for an
1104
+ -- assignee (active or sentinel), only the ACTIVE specific-date rows count for
1105
+ -- that assignee; otherwise the weekly (day_of_week) rows apply. Both readers
1106
+ -- are redefined on top of it:
1107
+ -- • get_available_slots — body otherwise identical to the latest version
1108
+ -- (google-calendar/002_smart_sync §5, tenant-wide 'block' clause KEPT);
1109
+ -- • create_public_booking — body otherwise identical to 004.
1110
+ --
1111
+ -- ORDERING: on pools with the Google Calendar connector this file must run
1112
+ -- AFTER integrations/google-calendar/migrations/002_smart_sync.sql (both
1113
+ -- redefine get_available_slots; 007 is the superset and must win). Re-running
1114
+ -- 007 last is always safe — it preserves 002's block semantics.
1115
+ -- ============================================================================
1116
+
1117
+ -- ----------------------------------------------------------------------------
1118
+ -- §1 agenda_working_windows — the day's working windows, exception-aware.
1119
+ -- ----------------------------------------------------------------------------
1120
+ CREATE OR REPLACE FUNCTION public.agenda_working_windows(
1121
+ p_tenant_id uuid,
1122
+ p_date date,
1123
+ p_assignee_id uuid DEFAULT NULL
1124
+ ) RETURNS TABLE (assignee_id uuid, win_start time, win_end time)
1125
+ LANGUAGE sql STABLE
1126
+ SET search_path = public
1127
+ AS $$
1128
+ WITH exc AS (
1129
+ -- Every specific-date row for the date, ACTIVE OR NOT: the inactive day-off
1130
+ -- sentinel must still suppress the weekly rows below.
1131
+ SELECT sc.assignee_id, sc.starts_at, sc.ends_at, sc.is_active
1132
+ FROM schedules sc
1133
+ WHERE sc.tenant_id = p_tenant_id
1134
+ AND sc.kind = 'working_hours'
1135
+ AND sc.specific_date = p_date
1136
+ AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)
1137
+ )
1138
+ SELECT e.assignee_id, e.starts_at, e.ends_at
1139
+ FROM exc e
1140
+ WHERE e.is_active
1141
+ UNION ALL
1142
+ SELECT sc.assignee_id, sc.starts_at, sc.ends_at
1143
+ FROM schedules sc
1144
+ WHERE sc.tenant_id = p_tenant_id
1145
+ AND sc.kind = 'working_hours'
1146
+ AND sc.is_active
1147
+ AND sc.specific_date IS NULL
1148
+ AND sc.day_of_week = EXTRACT(dow FROM p_date)::smallint
1149
+ AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)
1150
+ AND NOT EXISTS (SELECT 1 FROM exc e WHERE e.assignee_id = sc.assignee_id)
1151
+ $$;
1152
+
1153
+ REVOKE ALL ON FUNCTION public.agenda_working_windows(uuid, date, uuid) FROM public;
1154
+ REVOKE EXECUTE ON FUNCTION public.agenda_working_windows(uuid, date, uuid) FROM anon;
1155
+ GRANT EXECUTE ON FUNCTION public.agenda_working_windows(uuid, date, uuid)
1156
+ TO authenticated, service_role;
1157
+
1158
+ -- ----------------------------------------------------------------------------
1159
+ -- §2 get_available_slots — day_schedules now comes from the helper.
1160
+ -- Everything else is byte-identical to google-calendar/002_smart_sync §5.
1161
+ -- ----------------------------------------------------------------------------
1162
+ CREATE OR REPLACE FUNCTION public.get_available_slots(
1163
+ p_tenant_id uuid,
1164
+ p_date date,
1165
+ p_duration_minutes integer,
1166
+ p_assignee_id uuid DEFAULT NULL,
1167
+ p_slot_interval integer DEFAULT 30
1168
+ ) RETURNS TABLE (slot_start timestamptz, slot_end timestamptz)
1169
+ LANGUAGE plpgsql STABLE SECURITY DEFINER
1170
+ SET search_path = public
1171
+ AS $$
1172
+ DECLARE
1173
+ v_tz text;
1174
+ BEGIN
1175
+ -- tight validation: this function is anon-callable
1176
+ IF p_duration_minutes IS NULL OR p_duration_minutes NOT BETWEEN 5 AND 480 THEN
1177
+ RAISE EXCEPTION 'invalid duration';
1178
+ END IF;
1179
+ IF p_slot_interval IS NULL OR p_slot_interval NOT BETWEEN 5 AND 240 THEN
1180
+ RAISE EXCEPTION 'invalid interval';
1181
+ END IF;
1182
+ IF p_date IS NULL OR p_date < current_date OR p_date > current_date + 90 THEN
1183
+ RETURN;
1184
+ END IF;
1185
+
1186
+ SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
1187
+ FROM tenants t WHERE t.id = p_tenant_id;
1188
+ IF v_tz IS NULL THEN RETURN; END IF; -- unknown tenant → empty, not an error
1189
+
1190
+ RETURN QUERY
1191
+ WITH day_schedules AS (
1192
+ SELECT w.assignee_id, w.win_start, w.win_end
1193
+ FROM agenda_working_windows(p_tenant_id, p_date, p_assignee_id) w
1194
+ ),
1195
+ candidate AS (
1196
+ SELECT
1197
+ ds.assignee_id,
1198
+ gs AS s_start,
1199
+ gs + make_interval(mins => p_duration_minutes) AS s_end
1200
+ FROM day_schedules ds
1201
+ CROSS JOIN LATERAL generate_series(
1202
+ (p_date + ds.win_start) AT TIME ZONE v_tz,
1203
+ ((p_date + ds.win_end) AT TIME ZONE v_tz) - make_interval(mins => p_duration_minutes),
1204
+ make_interval(mins => p_slot_interval)
1205
+ ) AS gs
1206
+ )
1207
+ SELECT DISTINCT c.s_start, c.s_end
1208
+ FROM candidate c
1209
+ WHERE NOT EXISTS (
1210
+ SELECT 1 FROM appointments b
1211
+ WHERE b.tenant_id = p_tenant_id
1212
+ -- SMART SYNC (gcal 002): this professional's own bookings OR any
1213
+ -- tenant-wide BLOCK (assignee_id IS NULL AND kind='block') busy the slot.
1214
+ AND (b.assignee_id = c.assignee_id
1215
+ OR (b.assignee_id IS NULL AND b.kind = 'block'))
1216
+ AND b.status NOT IN ('cancelled', 'no_show')
1217
+ AND b.starts_at < c.s_end
1218
+ AND COALESCE(b.ends_at, b.starts_at) > c.s_start
1219
+ )
1220
+ ORDER BY 1;
1221
+ END;
1222
+ $$;
1223
+
1224
+ REVOKE ALL ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer) FROM public;
1225
+ GRANT EXECUTE ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer)
1226
+ TO anon, authenticated, service_role;
1227
+
1228
+ -- ----------------------------------------------------------------------------
1229
+ -- §3 create_public_booking — professional resolution goes through the helper
1230
+ -- (a booking on a closed date now fails 'slot outside working hours').
1231
+ -- Everything else is byte-identical to 004.
1232
+ -- ----------------------------------------------------------------------------
1233
+ CREATE OR REPLACE FUNCTION public.create_public_booking(
1234
+ p_tenant_id uuid,
1235
+ p_service_id uuid,
1236
+ p_starts_at timestamptz,
1237
+ p_name text,
1238
+ p_phone text,
1239
+ p_email text DEFAULT NULL,
1240
+ p_notes text DEFAULT NULL,
1241
+ p_assignee_id uuid DEFAULT NULL
1242
+ ) RETURNS TABLE (booking_id uuid, starts_at timestamptz, ends_at timestamptz)
1243
+ LANGUAGE plpgsql SECURITY DEFINER
1244
+ SET search_path = public
1245
+ AS $$
1246
+ DECLARE
1247
+ v_service record;
1248
+ v_person_id uuid;
1249
+ v_assignee uuid;
1250
+ v_ends timestamptz;
1251
+ v_order uuid;
1252
+ v_booking uuid;
1253
+ v_phone text;
1254
+ v_tz text;
1255
+ v_notes text;
1256
+ v_client_kind text;
1257
+ BEGIN
1258
+ -- validation: anon-callable, everything tight
1259
+ IF p_name IS NULL OR length(trim(p_name)) NOT BETWEEN 2 AND 120 THEN
1260
+ RAISE EXCEPTION 'invalid name';
1261
+ END IF;
1262
+ v_phone := regexp_replace(COALESCE(p_phone, ''), '\\D', '', 'g');
1263
+ IF length(v_phone) NOT BETWEEN 8 AND 15 THEN
1264
+ RAISE EXCEPTION 'invalid phone';
1265
+ END IF;
1266
+ IF p_email IS NOT NULL AND (length(p_email) > 254 OR p_email !~ '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$') THEN
1267
+ RAISE EXCEPTION 'invalid email';
1268
+ END IF;
1269
+ v_notes := left(p_notes, 500);
1270
+ IF p_starts_at IS NULL OR p_starts_at <= now() OR p_starts_at > now() + interval '90 days' THEN
1271
+ RAISE EXCEPTION 'invalid start';
1272
+ END IF;
1273
+
1274
+ SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
1275
+ INTO v_service
1276
+ FROM services s
1277
+ WHERE s.id = p_service_id AND s.tenant_id = p_tenant_id
1278
+ AND s.is_active AND s.status = 'active';
1279
+ IF NOT FOUND THEN RAISE EXCEPTION 'unknown service'; END IF;
1280
+
1281
+ v_ends := p_starts_at + make_interval(mins => v_service.duration_minutes);
1282
+
1283
+ -- tenant config: timezone + client kind (admin-set; sanitized, never caller input)
1284
+ SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo'),
1285
+ COALESCE(NULLIF(trim(t.settings->'booking'->>'client_kind'), ''), 'customer')
1286
+ INTO v_tz, v_client_kind
1287
+ FROM tenants t WHERE t.id = p_tenant_id;
1288
+ IF v_tz IS NULL THEN RAISE EXCEPTION 'unknown tenant'; END IF;
1289
+ IF v_client_kind !~ '^[a-z_]{2,32}$' THEN v_client_kind := 'customer'; END IF;
1290
+
1291
+ -- resolve the professional: the one the visitor saw (validated), else the
1292
+ -- first whose EXCEPTION-AWARE working hours cover the slot (007)
1293
+ SELECT w.assignee_id INTO v_assignee
1294
+ FROM agenda_working_windows(
1295
+ p_tenant_id, (p_starts_at AT TIME ZONE v_tz)::date, p_assignee_id) w
1296
+ WHERE (p_starts_at AT TIME ZONE v_tz)::time >= w.win_start
1297
+ AND (v_ends AT TIME ZONE v_tz)::time <= w.win_end
1298
+ ORDER BY w.assignee_id
1299
+ LIMIT 1;
1300
+ IF v_assignee IS NULL THEN RAISE EXCEPTION 'slot outside working hours'; END IF;
1301
+
1302
+ -- serialize per (tenant, assignee) to kill the double-book race
1303
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_assignee::text, 0));
1304
+
1305
+ IF EXISTS (
1306
+ SELECT 1 FROM appointments b
1307
+ WHERE b.tenant_id = p_tenant_id
1308
+ AND b.assignee_id = v_assignee
1309
+ AND b.status NOT IN ('cancelled', 'no_show')
1310
+ AND b.starts_at < v_ends
1311
+ AND COALESCE(b.ends_at, b.starts_at) > p_starts_at
1312
+ ) THEN
1313
+ RAISE EXCEPTION 'slot no longer available';
1314
+ END IF;
1315
+
1316
+ -- anti-spam: cap open future public bookings per phone per tenant
1317
+ IF (
1318
+ SELECT count(*) FROM appointments b
1319
+ JOIN people p ON p.id = b.party_id
1320
+ WHERE b.tenant_id = p_tenant_id
1321
+ AND p.phone = v_phone
1322
+ AND b.starts_at > now()
1323
+ AND b.status = 'scheduled'
1324
+ ) >= 3 THEN
1325
+ RAISE EXCEPTION 'too many open bookings for this phone';
1326
+ END IF;
1327
+
1328
+ -- upsert the client by (tenant, kind, phone), serialized by advisory lock
1329
+ -- (kind is tenant-configured, so the customer-only partial index can't
1330
+ -- arbitrate)
1331
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || ':' || v_phone, 1));
1332
+
1333
+ SELECT p.id INTO v_person_id
1334
+ FROM people p
1335
+ WHERE p.tenant_id = p_tenant_id AND p.kind = v_client_kind AND p.phone = v_phone
1336
+ ORDER BY p.created_at
1337
+ LIMIT 1;
1338
+
1339
+ IF v_person_id IS NOT NULL THEN
1340
+ UPDATE people SET
1341
+ name = trim(p_name),
1342
+ email = COALESCE(p_email, people.email),
1343
+ updated_at = now()
1344
+ WHERE id = v_person_id;
1345
+ ELSE
1346
+ INSERT INTO people (tenant_id, kind, name, phone, email, metadata)
1347
+ VALUES (p_tenant_id, v_client_kind, trim(p_name), v_phone, p_email,
1348
+ jsonb_build_object('source', 'public_booking'))
1349
+ RETURNING id INTO v_person_id;
1350
+ END IF;
1351
+
1352
+ -- order + booking + order_item (statuses hardcoded — caller cannot set)
1353
+ INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
1354
+ VALUES (p_tenant_id, 'appointment', 'scheduled', v_person_id, v_assignee,
1355
+ v_service.price, v_service.price, v_notes,
1356
+ jsonb_build_object('source', 'public_booking',
1357
+ 'serviceNames', v_service.name,
1358
+ 'contactName', trim(p_name)))
1359
+ RETURNING id INTO v_order;
1360
+
1361
+ INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
1362
+ starts_at, ends_at, status, notes, metadata)
1363
+ VALUES (p_tenant_id, 'appointment', v_person_id, v_assignee, v_order,
1364
+ p_starts_at, v_ends, 'scheduled', v_notes,
1365
+ jsonb_build_object('source', 'public_booking', 'serviceNames', v_service.name))
1366
+ RETURNING id INTO v_booking;
1367
+
1368
+ INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
1369
+ sort_order, duration_minutes, assignee_id)
1370
+ VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
1371
+ 0, v_service.duration_minutes, v_assignee);
1372
+
1373
+ RETURN QUERY SELECT v_booking, p_starts_at, v_ends;
1374
+ END;
1375
+ $$;
1376
+
1377
+ REVOKE ALL ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text, uuid) FROM public;
1378
+ GRANT EXECUTE ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text, uuid)
1379
+ TO anon, authenticated, service_role;
1380
+ `
1381
+
1382
+ export const MIGRATION_008_AGENT_RPC_EXCEPTION_PRECEDENCE = `-- ============================================================================
1383
+ -- plugin-agenda 008: agent_agenda_create_appointment honors schedule exceptions.
1384
+ --
1385
+ -- Same precedence fix as 007, applied to the agent write path: professional
1386
+ -- resolution now goes through agenda_working_windows (007 §1), so a date the
1387
+ -- admin closed (day-off sentinel) or re-opened with custom hours ("horário
1388
+ -- especial") binds the agent exactly like it binds the public site. Everything
1389
+ -- else is byte-identical to 006 (LOCAL-time input, free-professional pick,
1390
+ -- advisory lock, audit). Requires 007. Supersedes 006's function body.
1391
+ -- ============================================================================
1392
+
1393
+ CREATE OR REPLACE FUNCTION public.agent_agenda_create_appointment(
1394
+ p_tenant_id uuid,
1395
+ p_actor_user_id uuid,
1396
+ p_payload jsonb
1397
+ ) RETURNS jsonb
1398
+ LANGUAGE plpgsql SECURITY DEFINER
1399
+ SET search_path = public
1400
+ AS $$
1401
+ DECLARE
1402
+ v_denial jsonb;
1403
+ v_used int;
1404
+ v_client_id uuid;
1405
+ v_service_id uuid;
1406
+ v_professional uuid;
1407
+ v_starts timestamptz;
1408
+ v_starts_raw text;
1409
+ v_notes text;
1410
+ v_service record;
1411
+ v_client record;
1412
+ v_ends timestamptz;
1413
+ v_tz text;
1414
+ v_order uuid;
1415
+ v_booking uuid;
1416
+ BEGIN
1417
+ SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
1418
+ FROM tenants t WHERE t.id = p_tenant_id;
1419
+ IF v_tz IS NULL THEN
1420
+ RETURN jsonb_build_object('ok', false, 'error', 'unknown tenant');
1421
+ END IF;
1422
+
1423
+ -- ── payload ──────────────────────────────────────────────────────────────
1424
+ BEGIN
1425
+ v_client_id := (p_payload->>'client_id')::uuid;
1426
+ v_service_id := (p_payload->>'service_id')::uuid;
1427
+ v_professional := (p_payload->>'professional_id')::uuid;
1428
+ v_starts_raw := p_payload->>'starts_at';
1429
+ IF v_starts_raw ~ '(Z|[+-][0-9]{2}:?[0-9]{2})$' THEN
1430
+ v_starts := v_starts_raw::timestamptz; -- explicit offset
1431
+ ELSE
1432
+ v_starts := (v_starts_raw::timestamp) AT TIME ZONE v_tz; -- tenant-local
1433
+ END IF;
1434
+ EXCEPTION WHEN OTHERS THEN
1435
+ RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);
1436
+ END;
1437
+ v_notes := left(p_payload->>'notes', 500);
1438
+ IF v_client_id IS NULL OR v_service_id IS NULL OR v_starts IS NULL THEN
1439
+ RETURN jsonb_build_object('ok', false, 'error', 'client_id, service_id and starts_at are required');
1440
+ END IF;
1441
+ IF v_starts <= now() OR v_starts > now() + interval '365 days' THEN
1442
+ RETURN jsonb_build_object('ok', false, 'error', 'starts_at must be in the future (max 1 year)');
1443
+ END IF;
1444
+
1445
+ -- ── authorization: role → plan → bookings_month cap ──────────────────────
1446
+ SELECT count(*) INTO v_used FROM appointments b
1447
+ WHERE b.tenant_id = p_tenant_id
1448
+ AND b.created_at >= date_trunc('month', now());
1449
+ v_denial := agent_guard(p_tenant_id, p_actor_user_id,
1450
+ 'appointments', 'create', 'bookings_month', v_used, 1);
1451
+ IF v_denial IS NOT NULL THEN
1452
+ RETURN jsonb_build_object('ok', false, 'denial', v_denial);
1453
+ END IF;
1454
+
1455
+ -- ── invariants ───────────────────────────────────────────────────────────
1456
+ SELECT p.id, p.name INTO v_client
1457
+ FROM people p
1458
+ WHERE p.id = v_client_id AND p.tenant_id = p_tenant_id;
1459
+ IF NOT FOUND THEN
1460
+ RETURN jsonb_build_object('ok', false, 'error', 'unknown client for this tenant');
1461
+ END IF;
1462
+
1463
+ SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
1464
+ INTO v_service
1465
+ FROM services s
1466
+ WHERE s.id = v_service_id AND s.tenant_id = p_tenant_id
1467
+ AND s.is_active AND s.status = 'active';
1468
+ IF NOT FOUND THEN
1469
+ RETURN jsonb_build_object('ok', false, 'error', 'unknown or inactive service');
1470
+ END IF;
1471
+
1472
+ v_ends := v_starts + make_interval(mins => v_service.duration_minutes);
1473
+
1474
+ -- exception-aware windows (007); still prefer a professional who is FREE at
1475
+ -- the slot when the user did not name one
1476
+ SELECT w.assignee_id INTO v_professional
1477
+ FROM agenda_working_windows(
1478
+ p_tenant_id, (v_starts AT TIME ZONE v_tz)::date, v_professional) w
1479
+ WHERE (v_starts AT TIME ZONE v_tz)::time >= w.win_start
1480
+ AND (v_ends AT TIME ZONE v_tz)::time <= w.win_end
1481
+ AND NOT EXISTS (
1482
+ SELECT 1 FROM appointments b
1483
+ WHERE b.tenant_id = p_tenant_id
1484
+ AND b.assignee_id = w.assignee_id
1485
+ AND b.status NOT IN ('cancelled', 'no_show')
1486
+ AND b.starts_at < v_ends
1487
+ AND COALESCE(b.ends_at, b.starts_at) > v_starts
1488
+ )
1489
+ ORDER BY w.assignee_id
1490
+ LIMIT 1;
1491
+ IF v_professional IS NULL THEN
1492
+ RETURN jsonb_build_object('ok', false, 'error',
1493
+ 'no professional is available at that slot (outside working hours or already booked). Suggest a different time. Times are tenant-local, tz ' || v_tz);
1494
+ END IF;
1495
+
1496
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_professional::text, 0));
1497
+ IF EXISTS (
1498
+ SELECT 1 FROM appointments b
1499
+ WHERE b.tenant_id = p_tenant_id
1500
+ AND b.assignee_id = v_professional
1501
+ AND b.status NOT IN ('cancelled', 'no_show')
1502
+ AND b.starts_at < v_ends
1503
+ AND COALESCE(b.ends_at, b.starts_at) > v_starts
1504
+ ) THEN
1505
+ RETURN jsonb_build_object('ok', false, 'error', 'slot no longer available');
1506
+ END IF;
1507
+
1508
+ -- ── write + audit ────────────────────────────────────────────────────────
1509
+ INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
1510
+ VALUES (p_tenant_id, 'appointment', 'scheduled', v_client_id, v_professional,
1511
+ v_service.price, v_service.price, v_notes,
1512
+ jsonb_build_object('source', 'agent', 'serviceNames', v_service.name))
1513
+ RETURNING id INTO v_order;
1514
+
1515
+ INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
1516
+ starts_at, ends_at, status, notes, metadata)
1517
+ VALUES (p_tenant_id, 'appointment', v_client_id, v_professional, v_order,
1518
+ v_starts, v_ends, 'scheduled', v_notes,
1519
+ jsonb_build_object('source', 'agent', 'serviceNames', v_service.name))
1520
+ RETURNING id INTO v_booking;
1521
+
1522
+ INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
1523
+ sort_order, duration_minutes, assignee_id)
1524
+ VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
1525
+ 0, v_service.duration_minutes, v_professional);
1526
+
1527
+ INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
1528
+ VALUES (p_tenant_id, p_actor_user_id, 'agent.createAppointment', 'appointment', v_booking::text,
1529
+ jsonb_build_object('payload', p_payload, 'order_id', v_order));
1530
+
1531
+ RETURN jsonb_build_object(
1532
+ 'ok', true,
1533
+ 'id', v_booking,
1534
+ 'record', jsonb_build_object(
1535
+ 'ref', jsonb_build_object('id', v_booking, 'resource', 'appointments',
1536
+ 'archetype', 'schedule:appointment'),
1537
+ 'starts_at', v_starts,
1538
+ 'ends_at', v_ends,
1539
+ 'starts_at_local', to_char(v_starts AT TIME ZONE v_tz, 'YYYY-MM-DD (Dy) HH24:MI') || ' ' || v_tz,
1540
+ 'ends_at_local', to_char(v_ends AT TIME ZONE v_tz, 'HH24:MI'),
1541
+ 'client_name', v_client.name,
1542
+ 'service_name', v_service.name,
1543
+ 'price', v_service.price,
1544
+ 'status', 'scheduled'
1545
+ )
1546
+ );
1547
+ END;
1548
+ $$;
1549
+
1550
+ REVOKE ALL ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb) FROM public;
1551
+ REVOKE EXECUTE ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb) FROM anon;
1552
+ GRANT EXECUTE ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb)
1553
+ TO authenticated, service_role;
1554
+ `
1555
+
1556
+ export const MIGRATION_009_PUBLIC_BOOKING_LOCATION = `-- Public bookings were written with location_id = NULL. The agenda's location
1557
+ -- filter is optional (default "all"), so they still rendered — but the moment
1558
+ -- an operator filtered by their unit, every booking taken from the website
1559
+ -- disappeared from the calendar. Single-location tenants are the common case
1560
+ -- and have an unambiguous answer, so default to it; multi-location tenants
1561
+ -- stay NULL (there is no correct guess) until the public flow asks.
1562
+ --
1563
+ -- Only the assignment is new; the rest of the body is unchanged from 004.
1564
+
1565
+ CREATE OR REPLACE FUNCTION public.create_public_booking(
1566
+ p_tenant_id uuid,
1567
+ p_service_id uuid,
1568
+ p_starts_at timestamptz,
1569
+ p_name text,
1570
+ p_phone text,
1571
+ p_email text DEFAULT NULL,
1572
+ p_notes text DEFAULT NULL
1573
+ )
1574
+ RETURNS TABLE(booking_id uuid, starts_at timestamptz, ends_at timestamptz)
1575
+ LANGUAGE plpgsql
1576
+ SECURITY DEFINER
1577
+ SET search_path TO 'public'
1578
+ AS $function$
1579
+ DECLARE
1580
+ v_service record;
1581
+ v_person_id uuid;
1582
+ v_assignee uuid;
1583
+ v_ends timestamptz;
1584
+ v_order uuid;
1585
+ v_booking uuid;
1586
+ v_phone text;
1587
+ v_tz text;
1588
+ v_notes text;
1589
+ v_location uuid;
1590
+ BEGIN
1591
+ -- validation: anon-callable, everything tight
1592
+ IF p_name IS NULL OR length(trim(p_name)) NOT BETWEEN 2 AND 120 THEN
1593
+ RAISE EXCEPTION 'invalid name';
1594
+ END IF;
1595
+ v_phone := regexp_replace(COALESCE(p_phone, ''), '\\D', '', 'g');
1596
+ IF length(v_phone) NOT BETWEEN 8 AND 15 THEN
1597
+ RAISE EXCEPTION 'invalid phone';
1598
+ END IF;
1599
+ IF p_email IS NOT NULL AND (length(p_email) > 254 OR p_email !~ '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$') THEN
1600
+ RAISE EXCEPTION 'invalid email';
1601
+ END IF;
1602
+ v_notes := left(p_notes, 500);
1603
+ IF p_starts_at IS NULL OR p_starts_at <= now() OR p_starts_at > now() + interval '90 days' THEN
1604
+ RAISE EXCEPTION 'invalid start';
1605
+ END IF;
1606
+
1607
+ SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
1608
+ INTO v_service
1609
+ FROM services s
1610
+ WHERE s.id = p_service_id AND s.tenant_id = p_tenant_id
1611
+ AND s.is_active AND s.status = 'active';
1612
+ IF NOT FOUND THEN RAISE EXCEPTION 'unknown service'; END IF;
1613
+
1614
+ v_ends := p_starts_at + make_interval(mins => v_service.duration_minutes);
1615
+ SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
1616
+ FROM tenants t WHERE t.id = p_tenant_id;
1617
+ IF v_tz IS NULL THEN RAISE EXCEPTION 'unknown tenant'; END IF;
1618
+
1619
+ -- pick the assignee whose working hours cover the slot
1620
+ SELECT sc.assignee_id INTO v_assignee
1621
+ FROM schedules sc
1622
+ WHERE sc.tenant_id = p_tenant_id
1623
+ AND sc.kind = 'working_hours'
1624
+ AND sc.is_active
1625
+ AND (
1626
+ sc.specific_date = (p_starts_at AT TIME ZONE v_tz)::date
1627
+ OR (sc.specific_date IS NULL
1628
+ AND sc.day_of_week = EXTRACT(dow FROM p_starts_at AT TIME ZONE v_tz)::smallint)
1629
+ )
1630
+ AND (p_starts_at AT TIME ZONE v_tz)::time >= sc.starts_at
1631
+ AND (v_ends AT TIME ZONE v_tz)::time <= sc.ends_at
1632
+ ORDER BY sc.assignee_id
1633
+ LIMIT 1;
1634
+ IF v_assignee IS NULL THEN RAISE EXCEPTION 'slot outside working hours'; END IF;
1635
+
1636
+ -- NEW: stamp the unit so the calendar's location filter keeps these visible.
1637
+ -- Prefer the schedule's own location; else the tenant's only location.
1638
+ SELECT sc.location_id INTO v_location
1639
+ FROM schedules sc
1640
+ WHERE sc.tenant_id = p_tenant_id
1641
+ AND sc.kind = 'working_hours'
1642
+ AND sc.is_active
1643
+ AND sc.assignee_id = v_assignee
1644
+ AND sc.location_id IS NOT NULL
1645
+ LIMIT 1;
1646
+
1647
+ IF v_location IS NULL THEN
1648
+ SELECT l.id INTO v_location
1649
+ FROM locations l
1650
+ WHERE l.tenant_id = p_tenant_id
1651
+ HAVING count(*) = 1; -- unambiguous only
1652
+ END IF;
1653
+
1654
+ -- serialize per (tenant, assignee) to kill the double-book race
1655
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_assignee::text, 0));
1656
+
1657
+ IF EXISTS (
1658
+ SELECT 1 FROM appointments b
1659
+ WHERE b.tenant_id = p_tenant_id
1660
+ AND b.assignee_id = v_assignee
1661
+ AND b.status NOT IN ('cancelled', 'no_show')
1662
+ AND b.starts_at < v_ends
1663
+ AND COALESCE(b.ends_at, b.starts_at) > p_starts_at
1664
+ ) THEN
1665
+ RAISE EXCEPTION 'slot no longer available';
1666
+ END IF;
1667
+
1668
+ -- anti-spam: cap open future public bookings per phone per tenant
1669
+ IF (
1670
+ SELECT count(*) FROM appointments b
1671
+ JOIN people p ON p.id = b.party_id
1672
+ WHERE b.tenant_id = p_tenant_id
1673
+ AND p.phone = v_phone
1674
+ AND b.starts_at > now()
1675
+ AND b.status = 'scheduled'
1676
+ ) >= 3 THEN
1677
+ RAISE EXCEPTION 'too many open bookings for this phone';
1678
+ END IF;
1679
+
1680
+ -- upsert the customer by (tenant, phone) — matches people_tenant_customer_phone
1681
+ INSERT INTO people (tenant_id, kind, name, phone, email, metadata)
1682
+ VALUES (p_tenant_id, 'customer', trim(p_name), v_phone, p_email,
1683
+ jsonb_build_object('source', 'public_booking'))
1684
+ ON CONFLICT (tenant_id, phone) WHERE kind = 'customer' AND phone IS NOT NULL
1685
+ DO UPDATE SET
1686
+ name = EXCLUDED.name,
1687
+ email = COALESCE(EXCLUDED.email, people.email),
1688
+ updated_at = now()
1689
+ RETURNING id INTO v_person_id;
1690
+
1691
+ -- order + booking + order_item (statuses hardcoded — caller cannot set)
1692
+ INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, location_id,
1693
+ subtotal, total, notes, metadata)
1694
+ VALUES (p_tenant_id, 'appointment', 'scheduled', v_person_id, v_assignee, v_location,
1695
+ v_service.price, v_service.price, v_notes,
1696
+ jsonb_build_object('source', 'public_booking',
1697
+ 'serviceNames', v_service.name,
1698
+ 'contactName', trim(p_name)))
1699
+ RETURNING id INTO v_order;
1700
+
1701
+ INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id, location_id,
1702
+ starts_at, ends_at, status, notes, metadata)
1703
+ VALUES (p_tenant_id, 'appointment', v_person_id, v_assignee, v_order, v_location,
1704
+ p_starts_at, v_ends, 'scheduled', v_notes,
1705
+ jsonb_build_object('source', 'public_booking', 'serviceNames', v_service.name))
1706
+ RETURNING id INTO v_booking;
1707
+
1708
+ INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
1709
+ sort_order, duration_minutes, assignee_id)
1710
+ VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
1711
+ 0, v_service.duration_minutes, v_assignee);
1712
+
1713
+ RETURN QUERY SELECT v_booking, p_starts_at, v_ends;
1714
+ END;
1715
+ $function$;
1716
+
1717
+ GRANT EXECUTE ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text) TO anon, authenticated;
1718
+ `
1719
+
1720
+ export const MIGRATION_010_PUBLIC_SERVICE_CURATION = `-- plugin-agenda 010: curate what the PUBLIC booking page sells.
1721
+ --
1722
+ -- v_public_services published every active service, so the website catalog was
1723
+ -- "whatever exists in the system". That conflates two different questions:
1724
+ --
1725
+ -- • is this service usable at all? → services.is_active / status
1726
+ -- • should a stranger be able to book it? → this flag
1727
+ --
1728
+ -- Without the split, hiding something from the site means deactivating it —
1729
+ -- which also removes it from the internal agenda and orphans its history. The
1730
+ -- case that forced this: a school whose public page listed 7 services when it
1731
+ -- sells 3, and one of the 4 extras already had real bookings attached.
1732
+ --
1733
+ -- OPT-OUT on purpose: a service is public unless explicitly marked otherwise.
1734
+ -- Opt-in would silently empty every live catalog the moment this ships.
1735
+
1736
+ CREATE OR REPLACE VIEW public.v_public_services AS
1737
+ SELECT id,
1738
+ tenant_id,
1739
+ name,
1740
+ description,
1741
+ price,
1742
+ currency,
1743
+ COALESCE(duration_minutes, 30) AS duration_minutes,
1744
+ image_url,
1745
+ COALESCE(((metadata ->> 'sort_order'::text))::integer, 0) AS sort_order
1746
+ FROM services s
1747
+ WHERE is_active
1748
+ AND status = 'active'::text
1749
+ -- absent → public (back-compat). Only an explicit false hides it.
1750
+ AND COALESCE((metadata ->> 'bookableOnline'::text)::boolean, true);
1751
+ `
1752
+
1753
+ export const MIGRATION_011_DEFAULT_ASSIGNEE = `-- plugin-agenda 011: honour a tenant's default professional.
1754
+ --
1755
+ -- create_public_booking picks the assignee from whoever's working hours cover
1756
+ -- the slot, ordered by assignee_id — i.e. the lowest UUID wins. That is fine
1757
+ -- when the caller names the professional, and wrong when it does not: the
1758
+ -- public page confirms "Equipe Great DJs" while the booking silently lands on
1759
+ -- an unrelated instructor whose UUID happened to sort first.
1760
+ --
1761
+ -- Clients shipped before p_assignee_id existed still call this with 7 named
1762
+ -- args, so the parameter defaults to NULL and every live booking on those sites
1763
+ -- is mis-assigned today. Republishing the SDK fixes the caller, but the sites
1764
+ -- already deployed cannot be fixed that way — this can, from the database.
1765
+ --
1766
+ -- Precedence: explicit arg > tenant default > first covering schedule.
1767
+ -- Tenants without the setting keep exactly the previous behaviour.
1768
+
1769
+ CREATE 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)
1770
+ RETURNS TABLE(booking_id uuid, starts_at timestamp with time zone, ends_at timestamp with time zone)
1771
+ LANGUAGE plpgsql
1772
+ SECURITY DEFINER
1773
+ SET search_path TO 'public'
1774
+ AS $function$
1775
+ DECLARE
1776
+ v_service record;
1777
+ v_person_id uuid;
1778
+ v_assignee uuid;
1779
+ v_requested uuid;
1780
+ v_ends timestamptz;
1781
+ v_order uuid;
1782
+ v_booking uuid;
1783
+ v_phone text;
1784
+ v_tz text;
1785
+ v_notes text;
1786
+ v_client_kind text;
1787
+ BEGIN
1788
+ -- validation: anon-callable, everything tight
1789
+ IF p_name IS NULL OR length(trim(p_name)) NOT BETWEEN 2 AND 120 THEN
1790
+ RAISE EXCEPTION 'invalid name';
1791
+ END IF;
1792
+ v_phone := regexp_replace(COALESCE(p_phone, ''), '\\D', '', 'g');
1793
+ IF length(v_phone) NOT BETWEEN 8 AND 15 THEN
1794
+ RAISE EXCEPTION 'invalid phone';
1795
+ END IF;
1796
+ IF p_email IS NOT NULL AND (length(p_email) > 254 OR p_email !~ '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$') THEN
1797
+ RAISE EXCEPTION 'invalid email';
1798
+ END IF;
1799
+ v_notes := left(p_notes, 500);
1800
+ IF p_starts_at IS NULL OR p_starts_at <= now() OR p_starts_at > now() + interval '90 days' THEN
1801
+ RAISE EXCEPTION 'invalid start';
1802
+ END IF;
1803
+
1804
+ SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
1805
+ INTO v_service
1806
+ FROM services s
1807
+ WHERE s.id = p_service_id AND s.tenant_id = p_tenant_id
1808
+ AND s.is_active AND s.status = 'active';
1809
+ IF NOT FOUND THEN RAISE EXCEPTION 'unknown service'; END IF;
1810
+
1811
+ v_ends := p_starts_at + make_interval(mins => v_service.duration_minutes);
1812
+
1813
+ -- tenant config: timezone + client kind (admin-set; sanitized, never caller input)
1814
+ SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo'),
1815
+ COALESCE(NULLIF(trim(t.settings->'booking'->>'client_kind'), ''), 'customer')
1816
+ INTO v_tz, v_client_kind
1817
+ FROM tenants t WHERE t.id = p_tenant_id;
1818
+ IF v_tz IS NULL THEN RAISE EXCEPTION 'unknown tenant'; END IF;
1819
+ IF v_client_kind !~ '^[a-z_]{2,32}$' THEN v_client_kind := 'customer'; END IF;
1820
+
1821
+ -- resolve the professional, in order of trust:
1822
+ -- 1. the id the caller sent (the one the visitor actually saw)
1823
+ -- 2. the tenant's configured default (settings->>'defaultAssigneeId')
1824
+ -- 3. whoever's working hours cover the slot
1825
+ --
1826
+ -- Step 2 is why this migration exists. Older published clients call this with
1827
+ -- 7 named args, leaving p_assignee_id NULL, and step 3 then resolved to
1828
+ -- "lowest assignee UUID" — an arbitrary instructor who had nothing to do with
1829
+ -- the name the site displayed on the confirmation screen. The default gives
1830
+ -- those clients a correct answer without waiting for a redeploy.
1831
+ v_requested := COALESCE(
1832
+ p_assignee_id,
1833
+ (SELECT NULLIF(t.settings ->> 'defaultAssigneeId', '')::uuid
1834
+ FROM tenants t WHERE t.id = p_tenant_id)
1835
+ );
1836
+
1837
+ SELECT w.assignee_id INTO v_assignee
1838
+ FROM agenda_working_windows(
1839
+ p_tenant_id, (p_starts_at AT TIME ZONE v_tz)::date, v_requested) w
1840
+ WHERE (p_starts_at AT TIME ZONE v_tz)::time >= w.win_start
1841
+ AND (v_ends AT TIME ZONE v_tz)::time <= w.win_end
1842
+ ORDER BY w.assignee_id
1843
+ LIMIT 1;
1844
+ IF v_assignee IS NULL THEN RAISE EXCEPTION 'slot outside working hours'; END IF;
1845
+
1846
+ -- serialize per (tenant, assignee) to kill the double-book race
1847
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_assignee::text, 0));
1848
+
1849
+ IF EXISTS (
1850
+ SELECT 1 FROM appointments b
1851
+ WHERE b.tenant_id = p_tenant_id
1852
+ AND b.assignee_id = v_assignee
1853
+ AND b.status NOT IN ('cancelled', 'no_show')
1854
+ AND b.starts_at < v_ends
1855
+ AND COALESCE(b.ends_at, b.starts_at) > p_starts_at
1856
+ ) THEN
1857
+ RAISE EXCEPTION 'slot no longer available';
1858
+ END IF;
1859
+
1860
+ -- anti-spam: cap open future public bookings per phone per tenant
1861
+ IF (
1862
+ SELECT count(*) FROM appointments b
1863
+ JOIN people p ON p.id = b.party_id
1864
+ WHERE b.tenant_id = p_tenant_id
1865
+ AND p.phone = v_phone
1866
+ AND b.starts_at > now()
1867
+ AND b.status = 'scheduled'
1868
+ ) >= 3 THEN
1869
+ RAISE EXCEPTION 'too many open bookings for this phone';
1870
+ END IF;
1871
+
1872
+ -- upsert the client by (tenant, kind, phone), serialized by advisory lock
1873
+ -- (kind is tenant-configured, so the customer-only partial index can't
1874
+ -- arbitrate)
1875
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || ':' || v_phone, 1));
1876
+
1877
+ SELECT p.id INTO v_person_id
1878
+ FROM people p
1879
+ WHERE p.tenant_id = p_tenant_id AND p.kind = v_client_kind AND p.phone = v_phone
1880
+ ORDER BY p.created_at
1881
+ LIMIT 1;
1882
+
1883
+ IF v_person_id IS NOT NULL THEN
1884
+ UPDATE people SET
1885
+ name = trim(p_name),
1886
+ email = COALESCE(p_email, people.email),
1887
+ updated_at = now()
1888
+ WHERE id = v_person_id;
1889
+ ELSE
1890
+ INSERT INTO people (tenant_id, kind, name, phone, email, metadata)
1891
+ VALUES (p_tenant_id, v_client_kind, trim(p_name), v_phone, p_email,
1892
+ jsonb_build_object('source', 'public_booking'))
1893
+ RETURNING id INTO v_person_id;
1894
+ END IF;
1895
+
1896
+ -- order + booking + order_item (statuses hardcoded — caller cannot set)
1897
+ INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
1898
+ VALUES (p_tenant_id, 'appointment', 'scheduled', v_person_id, v_assignee,
1899
+ v_service.price, v_service.price, v_notes,
1900
+ jsonb_build_object('source', 'public_booking',
1901
+ 'serviceNames', v_service.name,
1902
+ 'contactName', trim(p_name)))
1903
+ RETURNING id INTO v_order;
1904
+
1905
+ INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
1906
+ starts_at, ends_at, status, notes, metadata)
1907
+ VALUES (p_tenant_id, 'appointment', v_person_id, v_assignee, v_order,
1908
+ p_starts_at, v_ends, 'scheduled', v_notes,
1909
+ jsonb_build_object('source', 'public_booking', 'serviceNames', v_service.name))
1910
+ RETURNING id INTO v_booking;
1911
+
1912
+ INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
1913
+ sort_order, duration_minutes, assignee_id)
1914
+ VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
1915
+ 0, v_service.duration_minutes, v_assignee);
1916
+
1917
+ RETURN QUERY SELECT v_booking, p_starts_at, v_ends;
1918
+ END;
1919
+ $function$;
1920
+ `
1921
+
1922
+ export const MIGRATION_012_DROP_LEGACY_BOOKING_OVERLOAD = `-- plugin-agenda 012: retire the pre-assignee create_public_booking overload.
1923
+ --
1924
+ -- ⚠️ DO NOT APPLY UNTIL @fayz-ai/plugin-agenda IS REPUBLISHED *AND* EVERY LIVE
1925
+ -- SITE HAS BEEN REDEPLOYED. Applying early breaks public booking on any
1926
+ -- site still serving a bundle that calls the 7-argument signature — the
1927
+ -- call stops resolving and every submission fails.
1928
+ --
1929
+ -- Some pools carry two overloads side by side:
1930
+ -- create_public_booking(uuid,uuid,timestamptz,text,text,text,text)
1931
+ -- create_public_booking(uuid,uuid,timestamptz,text,text,text,text,uuid)
1932
+ --
1933
+ -- Nothing errors when both exist, which is exactly the problem: a stale client
1934
+ -- silently resolves to the 7-arg one, which cannot know which professional the
1935
+ -- visitor was shown and falls back to "lowest assignee UUID". The site confirms
1936
+ -- one name, the agenda records another, and no one finds out until someone
1937
+ -- compares the two screens.
1938
+ --
1939
+ -- 011 makes that fallback survivable (tenant default). This makes it
1940
+ -- impossible — but only once no caller needs the old signature.
1941
+ --
1942
+ -- Verify before running:
1943
+ -- 1. npm view @fayz-ai/plugin-agenda version → the republished version
1944
+ -- 2. every deployed bundle greps for p_assignee_id:
1945
+ -- curl -s https://<site>/assets/index-*.js | grep -c p_assignee_id
1946
+ -- Each must be ≥ 2 (slots + booking). Zero means that site still needs it.
1947
+
1948
+ DROP FUNCTION IF EXISTS public.create_public_booking(
1949
+ uuid, uuid, timestamptz, text, text, text, text
1950
+ );
1951
+ `
1952
+
1953
+ export const MIGRATION_013_NORMALIZE_V_APPOINTMENTS = `-- plugin-agenda 013: one booking read model — v_appointments.
1954
+ --
1955
+ -- Three views described the same 34 rows:
1956
+ --
1957
+ -- v_bookings → thin alias over the legacy view (002 compat shim)
1958
+ -- v_bookings_legacy_beauty → the pre-pools shape, still feeding 5 rep_* views
1959
+ -- v_appointments → the canonical name, and the only one the SDK reads
1960
+ --
1961
+ -- The school pool already had only v_appointments; the salon pool carried all
1962
+ -- three. Same data, three names, and reports quietly wired to the oldest one.
1963
+ --
1964
+ -- v_appointments was a strict SUBSET, so it could not simply absorb them. It
1965
+ -- gains the 7 columns it lacked — most importantly \`services\`, the order_items
1966
+ -- aggregate that 4 of the 5 reports depend on. That also retires a documented
1967
+ -- N+1: getBookingById fetches order_items in a second round-trip precisely
1968
+ -- because this view had no line items (see plugin-agenda/src/data/supabase.ts).
1969
+ --
1970
+ -- Deliberately NOT copied from the legacy view:
1971
+ -- • total_duration_minutes — legacy sums order_items durations, the canonical
1972
+ -- measures the slot. They disagree, no report reads it, and the SDK relies
1973
+ -- on the canonical meaning. Kept as-is.
1974
+ -- • client_name — the canonical COALESCEs with metadata->>'title' so blocks
1975
+ -- and personal events show a label instead of NULL. Kept as-is (an
1976
+ -- improvement the legacy shape never had).
1977
+
1978
+ -- 1. v_appointments becomes the superset. Columns are APPENDED: CREATE OR
1979
+ -- REPLACE VIEW cannot reorder or drop, and the SDK selects '*'.
1980
+ CREATE OR REPLACE VIEW public.v_appointments
1981
+ WITH (security_invoker = true) AS
1982
+ SELECT b.id,
1983
+ b.tenant_id,
1984
+ b.kind,
1985
+ b.starts_at,
1986
+ b.ends_at,
1987
+ b.status,
1988
+ b.notes,
1989
+ b.order_id,
1990
+ b.location_id,
1991
+ b.metadata,
1992
+ b.created_at,
1993
+ b.updated_at,
1994
+ b.party_id AS client_id,
1995
+ COALESCE(pc.name, (b.metadata ->> 'title'::text)) AS client_name,
1996
+ pc.phone AS client_phone,
1997
+ pc.email AS client_email,
1998
+ pc.avatar_url AS client_avatar_url,
1999
+ b.assignee_id AS professional_id,
2000
+ ps.name AS professional_name,
2001
+ ps.avatar_url AS professional_avatar_url,
2002
+ l.name AS location_name,
2003
+ o.total AS order_total,
2004
+ o.status AS order_status,
2005
+ ((EXTRACT(epoch FROM (COALESCE(b.ends_at, b.starts_at) - b.starts_at)))::integer / 60) AS total_duration_minutes,
2006
+ -- appended:
2007
+ o.reference_number,
2008
+ o.status AS stage,
2009
+ (o.metadata ->> 'direction'::text) AS direction,
2010
+ ( SELECT json_agg(json_build_object('id', oi.id, 'serviceId', oi.service_id,
2011
+ 'name', oi.name, 'durationMinutes', oi.duration_minutes,
2012
+ 'price', oi.unit_price, 'assigneeId', oi.assignee_id)
2013
+ ORDER BY oi.sort_order)
2014
+ FROM order_items oi
2015
+ WHERE (oi.order_id = b.order_id)) AS services,
2016
+ inv.status AS payment_status,
2017
+ inv.paid AS order_paid,
2018
+ inv.balance AS order_balance
2019
+ FROM (((((appointments b
2020
+ LEFT JOIN people pc ON ((pc.id = b.party_id)))
2021
+ LEFT JOIN people ps ON ((ps.id = b.assignee_id)))
2022
+ LEFT JOIN locations l ON ((l.id = b.location_id)))
2023
+ LEFT JOIN orders o ON ((o.id = b.order_id)))
2024
+ LEFT JOIN v_invoice_balances inv ON ((inv.invoice_id = b.order_id)));
2025
+
2026
+ -- 2. Repoint the reports off the legacy view. Bodies are unchanged
2027
+ -- except for the source relation.
2028
+
2029
+ CREATE OR REPLACE VIEW public.rep_appointments_by_period AS
2030
+ SELECT tenant_id,
2031
+ id AS booking_id,
2032
+ (starts_at)::date AS date,
2033
+ starts_at,
2034
+ client_id,
2035
+ client_name,
2036
+ professional_id,
2037
+ professional_name,
2038
+ COALESCE(((services -> 0) ->> 'name'::text), ((services -> 0) ->> 'serviceName'::text)) AS service_name,
2039
+ status,
2040
+ order_total AS revenue,
2041
+ created_at,
2042
+ updated_at
2043
+ FROM v_appointments b;
2044
+
2045
+ CREATE OR REPLACE VIEW public.rep_confirmation_queue AS
2046
+ SELECT b.tenant_id,
2047
+ b.id AS booking_id,
2048
+ (b.starts_at)::date AS date,
2049
+ b.starts_at,
2050
+ b.client_id,
2051
+ b.client_name,
2052
+ b.professional_id,
2053
+ b.professional_name,
2054
+ COALESCE(((b.services -> 0) ->> 'name'::text), ((b.services -> 0) ->> 'serviceName'::text)) AS service_name,
2055
+ COALESCE(a.confirmation_status, 'pending'::text) AS confirmation_status,
2056
+ a.confirmation_channel,
2057
+ a.confirmation_sent_at,
2058
+ a.confirmed_at,
2059
+ b.created_at,
2060
+ b.updated_at
2061
+ FROM (v_appointments b
2062
+ LEFT JOIN legacy_pre_pools.appointments a ON ((a.booking_id = b.id)))
2063
+ WHERE ((b.status <> ALL (ARRAY['cancelled'::text, 'no_show'::text])) AND (b.starts_at >= (now() - '1 day'::interval)));
2064
+
2065
+ CREATE OR REPLACE VIEW public.rep_new_clients AS
2066
+ SELECT c.tenant_id,
2067
+ c.id AS client_id,
2068
+ (c.created_at)::date AS date,
2069
+ c.name AS client_name,
2070
+ c.origin,
2071
+ first_booking.service_name AS first_service,
2072
+ c.created_at,
2073
+ c.updated_at
2074
+ FROM (v_clients c
2075
+ LEFT JOIN LATERAL ( SELECT COALESCE(((b.services -> 0) ->> 'name'::text), ((b.services -> 0) ->> 'serviceName'::text)) AS service_name
2076
+ FROM v_appointments b
2077
+ WHERE (b.client_id = c.id)
2078
+ ORDER BY b.starts_at
2079
+ LIMIT 1) first_booking ON (true));
2080
+
2081
+ CREATE OR REPLACE VIEW public.rep_revenue_by_professional AS
2082
+ SELECT tenant_id,
2083
+ (starts_at)::date AS date,
2084
+ professional_id,
2085
+ COALESCE(professional_name, 'Sem profissional'::text) AS professional_name,
2086
+ (count(*))::integer AS appointment_count,
2087
+ (COALESCE(sum(NULLIF(order_total, (0)::numeric)), (0)::numeric))::numeric(14,2) AS total_revenue,
2088
+ (COALESCE(avg(NULLIF(order_total, (0)::numeric)), (0)::numeric))::numeric(14,2) AS avg_ticket,
2089
+ (0)::numeric(14,2) AS commission,
2090
+ max(updated_at) AS updated_at
2091
+ FROM v_appointments b
2092
+ WHERE (status <> ALL (ARRAY['cancelled'::text, 'no_show'::text]))
2093
+ GROUP BY tenant_id, ((starts_at)::date), professional_id, COALESCE(professional_name, 'Sem profissional'::text);
2094
+
2095
+ CREATE OR REPLACE VIEW public.rep_revenue_by_service AS
2096
+ SELECT b.tenant_id,
2097
+ (b.starts_at)::date AS date,
2098
+ NULLIF((service_item.value ->> 'serviceId'::text), ''::text) AS service_id,
2099
+ COALESCE((service_item.value ->> 'name'::text), (service_item.value ->> 'serviceName'::text), 'Sem servico'::text) AS service_name,
2100
+ (count(DISTINCT b.id))::integer AS quantity,
2101
+ (COALESCE(sum((NULLIF((service_item.value ->> 'price'::text), ''::text))::numeric), (0)::numeric))::numeric(14,2) AS total_revenue,
2102
+ (COALESCE(avg((NULLIF((service_item.value ->> 'price'::text), ''::text))::numeric), (0)::numeric))::numeric(14,2) AS avg_ticket,
2103
+ max(b.updated_at) AS updated_at
2104
+ FROM (v_appointments b
2105
+ CROSS JOIN LATERAL jsonb_array_elements(COALESCE((b.services)::jsonb, '[]'::jsonb)) service_item(value))
2106
+ WHERE (b.status <> ALL (ARRAY['cancelled'::text, 'no_show'::text]))
2107
+ 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);
2108
+
2109
+ -- 3. Retire the duplicates. v_bookings goes first: it reads the legacy view.
2110
+ -- Nothing in the SDK references either name (checked: only migrations and a
2111
+ -- README mention v_bookings).
2112
+ DROP VIEW IF EXISTS public.v_bookings;
2113
+ DROP VIEW IF EXISTS public.v_bookings_legacy_beauty;
2114
+ `
2115
+
382
2116
  export const MIGRATIONS: Array<{ id: string; sql: string }> = [
383
2117
  { id: "000b_v_bookings_rename", sql: MIGRATION_000B_V_BOOKINGS_RENAME },
384
2118
  { id: "000c_dedupe_customer_phones", sql: MIGRATION_000C_DEDUPE_CUSTOMER_PHONES },
385
2119
  { id: "001_public_booking", sql: MIGRATION_001_PUBLIC_BOOKING },
386
2120
  { id: "002_v_bookings_compat", sql: MIGRATION_002_V_BOOKINGS_COMPAT },
2121
+ { id: "003_public_client_kind", sql: MIGRATION_003_PUBLIC_CLIENT_KIND },
2122
+ { id: "004_public_booking_assignee", sql: MIGRATION_004_PUBLIC_BOOKING_ASSIGNEE },
2123
+ { id: "005_agent_rpcs", sql: MIGRATION_005_AGENT_RPCS },
2124
+ { id: "006_agent_rpc_localtime", sql: MIGRATION_006_AGENT_RPC_LOCALTIME },
2125
+ { id: "007_schedule_exception_precedence", sql: MIGRATION_007_SCHEDULE_EXCEPTION_PRECEDENCE },
2126
+ { id: "008_agent_rpc_exception_precedence", sql: MIGRATION_008_AGENT_RPC_EXCEPTION_PRECEDENCE },
2127
+ { id: "009_public_booking_location", sql: MIGRATION_009_PUBLIC_BOOKING_LOCATION },
2128
+ { id: "010_public_service_curation", sql: MIGRATION_010_PUBLIC_SERVICE_CURATION },
2129
+ { id: "011_default_assignee", sql: MIGRATION_011_DEFAULT_ASSIGNEE },
2130
+ { id: "012_drop_legacy_booking_overload", sql: MIGRATION_012_DROP_LEGACY_BOOKING_OVERLOAD },
2131
+ { id: "013_normalize_v_appointments", sql: MIGRATION_013_NORMALIZE_V_APPOINTMENTS },
387
2132
  ]