@fayz-ai/plugin-agenda 0.2.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +2 -0
  2. package/dist/bridges/create-financial-bridge.d.ts.map +1 -1
  3. package/dist/chunk-DRZF6CV2.js +421 -0
  4. package/dist/chunk-DRZF6CV2.js.map +1 -0
  5. package/dist/chunk-LFUELUIT.cjs +423 -0
  6. package/dist/chunk-LFUELUIT.cjs.map +1 -0
  7. package/dist/config.d.ts +2 -2
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/data/supabase.d.ts.map +1 -1
  10. package/dist/index.cjs +765 -22
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +765 -22
  14. package/dist/index.js.map +1 -1
  15. package/dist/migrations/index.d.ts +9 -0
  16. package/dist/migrations/index.d.ts.map +1 -0
  17. package/dist/public/BookingWidget.d.ts +4 -2
  18. package/dist/public/BookingWidget.d.ts.map +1 -1
  19. package/dist/public/components/BrandHeader.d.ts +6 -0
  20. package/dist/public/components/BrandHeader.d.ts.map +1 -0
  21. package/dist/public/components/ServiceCard.d.ts +5 -0
  22. package/dist/public/components/ServiceCard.d.ts.map +1 -0
  23. package/dist/public/components/SidebarExtras.d.ts +4 -0
  24. package/dist/public/components/SidebarExtras.d.ts.map +1 -0
  25. package/dist/public/components/StepRailItem.d.ts +4 -0
  26. package/dist/public/components/StepRailItem.d.ts.map +1 -0
  27. package/dist/public/components/SuccessPanel.d.ts +4 -0
  28. package/dist/public/components/SuccessPanel.d.ts.map +1 -0
  29. package/dist/public/components/SummaryRail.d.ts +27 -0
  30. package/dist/public/components/SummaryRail.d.ts.map +1 -0
  31. package/dist/public/components/defaults.d.ts +3 -0
  32. package/dist/public/components/defaults.d.ts.map +1 -0
  33. package/dist/public/context.d.ts +22 -8
  34. package/dist/public/context.d.ts.map +1 -1
  35. package/dist/public/createPublicBookingPlugin.d.ts +27 -10
  36. package/dist/public/createPublicBookingPlugin.d.ts.map +1 -1
  37. package/dist/public/data.d.ts +37 -0
  38. package/dist/public/data.d.ts.map +1 -0
  39. package/dist/public/data.supabase.d.ts +7 -0
  40. package/dist/public/data.supabase.d.ts.map +1 -0
  41. package/dist/public/format.d.ts +18 -0
  42. package/dist/public/format.d.ts.map +1 -0
  43. package/dist/public/hooks.d.ts +13 -11
  44. package/dist/public/hooks.d.ts.map +1 -1
  45. package/dist/public/index.cjs +1018 -615
  46. package/dist/public/index.cjs.map +1 -1
  47. package/dist/public/index.d.ts +15 -4
  48. package/dist/public/index.d.ts.map +1 -1
  49. package/dist/public/index.js +1008 -618
  50. package/dist/public/index.js.map +1 -1
  51. package/dist/public/steps/ContactStep.d.ts +30 -0
  52. package/dist/public/steps/ContactStep.d.ts.map +1 -0
  53. package/dist/public/steps/DateTimeStep.d.ts +23 -0
  54. package/dist/public/steps/DateTimeStep.d.ts.map +1 -0
  55. package/dist/public/steps/OverviewStep.d.ts +16 -0
  56. package/dist/public/steps/OverviewStep.d.ts.map +1 -0
  57. package/dist/public/steps/PaymentStep.d.ts +20 -0
  58. package/dist/public/steps/PaymentStep.d.ts.map +1 -0
  59. package/dist/public/types.d.ts +134 -0
  60. package/dist/public/types.d.ts.map +1 -1
  61. package/dist/registries.d.ts.map +1 -1
  62. package/dist/types.d.ts +3 -3
  63. package/dist/types.d.ts.map +1 -1
  64. package/package.json +11 -5
  65. package/src/bridges/create-financial-bridge.ts +6 -8
  66. package/src/components/AppointmentModal.tsx +4 -4
  67. package/src/config.ts +2 -2
  68. package/src/data/supabase.ts +26 -25
  69. package/src/index.ts +11 -0
  70. package/src/integrations/google-calendar/index.ts +1 -1
  71. package/src/integrations/google-calendar/migrations/001_google_calendar.sql +6 -6
  72. package/src/migrations/000b_v_bookings_rename.sql +21 -0
  73. package/src/migrations/000c_dedupe_customer_phones.sql +43 -0
  74. package/src/migrations/001_public_booking.sql +291 -0
  75. package/src/migrations/002_v_bookings_compat.sql +14 -0
  76. package/src/migrations/index.ts +387 -0
  77. package/src/public/BookingWidget.tsx +136 -713
  78. package/src/public/components/BrandHeader.tsx +27 -0
  79. package/src/public/components/ServiceCard.tsx +39 -0
  80. package/src/public/components/SidebarExtras.tsx +30 -0
  81. package/src/public/components/StepRailItem.tsx +27 -0
  82. package/src/public/components/SuccessPanel.tsx +27 -0
  83. package/src/public/components/SummaryRail.tsx +90 -0
  84. package/src/public/components/defaults.ts +14 -0
  85. package/src/public/context.tsx +64 -7
  86. package/src/public/createPublicBookingPlugin.ts +138 -25
  87. package/src/public/data.supabase.ts +94 -0
  88. package/src/public/data.ts +99 -0
  89. package/src/public/format.ts +61 -0
  90. package/src/public/hooks.ts +22 -34
  91. package/src/public/index.ts +57 -4
  92. package/src/public/steps/ContactStep.tsx +179 -0
  93. package/src/public/steps/DateTimeStep.tsx +138 -0
  94. package/src/public/steps/OverviewStep.tsx +81 -0
  95. package/src/public/steps/PaymentStep.tsx +180 -0
  96. package/src/public/types.ts +169 -3
  97. package/src/registries.ts +1 -2
  98. package/src/types.ts +4 -4
  99. package/dist/chunk-GAJVIEXM.cjs +0 -870
  100. package/dist/chunk-GAJVIEXM.cjs.map +0 -1
  101. package/dist/chunk-U6VTLOHN.js +0 -867
  102. package/dist/chunk-U6VTLOHN.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
- import { createSupabaseAgendaProvider, createMockAgendaProvider } from './chunk-U6VTLOHN.js';
2
- export { createMockAgendaProvider } from './chunk-U6VTLOHN.js';
3
- import { setAgendaTenantId } from './chunk-3LJKZQK4.js';
1
+ import { createMockAgendaProvider } from './chunk-DRZF6CV2.js';
2
+ export { createMockAgendaProvider } from './chunk-DRZF6CV2.js';
3
+ import { setAgendaTenantId, getAgendaTenantId } from './chunk-3LJKZQK4.js';
4
4
  import React12, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
5
- import { getSupabaseClientOptional, registerTranslations, createSafeDataProvider, usePluginRuntimeOptional, useTranslation, getCurrentLocale, formatCurrency } from '@fayz-ai/core';
5
+ import { getSupabaseClientOptional, registerTranslations, createSafeDataProvider, usePluginRuntimeOptional, useTranslation, getActiveTenantId, getCurrentLocale, formatCurrency } from '@fayz-ai/core';
6
6
  import { createPluginContext, setScheduleBlockConfig, getScheduleBlockConfig, PluginSettingsPanel, dedup, SettingsGroup, ToggleRow, SelectRow, usePluginPrefs, PersonLink, CrudFormPage, CrudDetailPage } from '@fayz-ai/saas';
7
7
  import FullCalendar from '@fullcalendar/react';
8
8
  import dayGridPlugin from '@fullcalendar/daygrid';
@@ -1714,14 +1714,14 @@ function AppointmentModal({ open, mode, bookingId, prefill, initialTab, external
1714
1714
  onCancel: () => setClientEditing(false),
1715
1715
  onSubmit: async (data) => {
1716
1716
  try {
1717
- const { getSupabaseClientOptional: getSupabaseClientOptional3 } = await import('@fayz-ai/core');
1718
- const supabase = getSupabaseClientOptional3();
1717
+ const { getSupabaseClientOptional: getSupabaseClientOptional4 } = await import('@fayz-ai/core');
1718
+ const supabase = getSupabaseClientOptional4();
1719
1719
  if (!supabase) return;
1720
1720
  const dbData = {};
1721
1721
  for (const [k, v] of Object.entries(data)) {
1722
1722
  dbData[k.replace(/[A-Z]/g, (m) => "_" + m.toLowerCase())] = v;
1723
1723
  }
1724
- await supabase.schema("saas_core").from("persons").update(dbData).eq("id", clientId);
1724
+ await supabase.from("people").update(dbData).eq("id", clientId);
1725
1725
  setClientData({ ...clientData, ...data });
1726
1726
  setClientEditing(false);
1727
1727
  } catch {
@@ -1876,12 +1876,12 @@ function AppointmentModal({ open, mode, bookingId, prefill, initialTab, external
1876
1876
  onClick: async () => {
1877
1877
  setCreatingClient(true);
1878
1878
  try {
1879
- const { getSupabaseClientOptional: getSupabaseClientOptional3 } = await import('@fayz-ai/core');
1880
- const { getAgendaTenantId } = await import('./tenant-LAUWVUYB.js');
1881
- const supabase = getSupabaseClientOptional3();
1882
- const tenantId = getAgendaTenantId();
1879
+ const { getSupabaseClientOptional: getSupabaseClientOptional4 } = await import('@fayz-ai/core');
1880
+ const { getAgendaTenantId: getAgendaTenantId2 } = await import('./tenant-LAUWVUYB.js');
1881
+ const supabase = getSupabaseClientOptional4();
1882
+ const tenantId = getAgendaTenantId2();
1883
1883
  if (!supabase || !tenantId) throw new Error("Not initialized");
1884
- const { data: personRow, error: personError } = await supabase.schema("saas_core").from("persons").insert({
1884
+ const { data: personRow, error: personError } = await supabase.from("people").insert({
1885
1885
  tenant_id: tenantId,
1886
1886
  kind: config.clientKind,
1887
1887
  name: newClientName.trim(),
@@ -1894,7 +1894,7 @@ function AppointmentModal({ open, mode, bookingId, prefill, initialTab, external
1894
1894
  tenant_id: tenantId
1895
1895
  });
1896
1896
  if (clientError) {
1897
- await supabase.schema("saas_core").from("persons").delete().eq("id", personRow.id);
1897
+ await supabase.from("people").delete().eq("id", personRow.id);
1898
1898
  throw clientError;
1899
1899
  }
1900
1900
  setClientId(personRow.id);
@@ -3461,6 +3461,448 @@ function AgendaPage({ config, provider: provider2, store }) {
3461
3461
  }, [tenantId]);
3462
3462
  return /* @__PURE__ */ jsx(AgendaContextProvider, { config, provider: provider2, store, children: /* @__PURE__ */ jsx(CalendarView, {}) });
3463
3463
  }
3464
+ function getTenantId() {
3465
+ return getAgendaTenantId() ?? getActiveTenantId();
3466
+ }
3467
+ function snakeToCamel(obj) {
3468
+ const result = {};
3469
+ for (const [key, value] of Object.entries(obj)) {
3470
+ result[key.replace(/_([a-z])/g, (_, c) => c.toUpperCase())] = value;
3471
+ }
3472
+ return result;
3473
+ }
3474
+ function getClients() {
3475
+ const supabase = getSupabaseClientOptional();
3476
+ if (!supabase) throw new Error("Supabase not initialized");
3477
+ return { core: supabase, pub: supabase };
3478
+ }
3479
+ function mapBooking(row) {
3480
+ return snakeToCamel(row);
3481
+ }
3482
+ function mapSchedule(row) {
3483
+ return snakeToCamel(row);
3484
+ }
3485
+ function addMinutes(iso, minutes) {
3486
+ const d = new Date(iso);
3487
+ d.setMinutes(d.getMinutes() + minutes);
3488
+ return d.toISOString();
3489
+ }
3490
+ function unique(values) {
3491
+ return Array.from(new Set(values.filter((value) => !!value)));
3492
+ }
3493
+ function indexById(rows) {
3494
+ return new Map((rows ?? []).filter((row) => row?.id).map((row) => [row.id, row]));
3495
+ }
3496
+ function groupBy(rows, getKey) {
3497
+ const grouped = /* @__PURE__ */ new Map();
3498
+ for (const row of rows) {
3499
+ const key = getKey(row);
3500
+ if (!key) continue;
3501
+ grouped.set(key, [...grouped.get(key) ?? [], row]);
3502
+ }
3503
+ return grouped;
3504
+ }
3505
+ function getDurationMinutes(startsAt, endsAt) {
3506
+ if (!endsAt) return null;
3507
+ return Math.round((new Date(endsAt).getTime() - new Date(startsAt).getTime()) / 6e4);
3508
+ }
3509
+ function mergeBookings(viewBookings, canonicalBookings) {
3510
+ const canonicalIds = new Set(canonicalBookings.map((booking) => booking.id));
3511
+ const canonicalOrderIds = new Set(canonicalBookings.map((booking) => booking.orderId).filter(Boolean));
3512
+ const merged = [
3513
+ ...canonicalBookings,
3514
+ ...viewBookings.filter((booking) => {
3515
+ if (canonicalIds.has(booking.id)) return false;
3516
+ if (booking.orderId && canonicalOrderIds.has(booking.orderId)) return false;
3517
+ if (canonicalOrderIds.has(booking.id)) return false;
3518
+ return true;
3519
+ })
3520
+ ];
3521
+ return merged.sort((a, b) => new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime());
3522
+ }
3523
+ async function hydrateCanonicalBookings(core, bookings) {
3524
+ const personIds = unique(bookings.flatMap((booking) => [booking.party_id, booking.assignee_id]));
3525
+ const locationIds = unique(bookings.map((booking) => booking.location_id));
3526
+ const orderIds = unique(bookings.map((booking) => booking.order_id));
3527
+ unique(bookings.map((booking) => booking.id));
3528
+ const [
3529
+ { data: persons, error: personsError },
3530
+ { data: locations, error: locationsError },
3531
+ { data: orders, error: ordersError },
3532
+ { data: items, error: itemsError }
3533
+ ] = await Promise.all([
3534
+ personIds.length ? core.from("people").select("id, name, phone, email, avatar_url").in("id", personIds) : Promise.resolve({ data: [], error: null }),
3535
+ locationIds.length ? core.from("locations").select("id, name").in("id", locationIds) : Promise.resolve({ data: [], error: null }),
3536
+ orderIds.length ? core.from("orders").select("id, total, status").in("id", orderIds) : Promise.resolve({ data: [], error: null }),
3537
+ orderIds.length ? core.from("order_items").select("id, order_id, service_id, name, duration_minutes, unit_price, assignee_id, sort_order").in("order_id", orderIds) : Promise.resolve({ data: [], error: null })
3538
+ ]);
3539
+ if (personsError) throw personsError;
3540
+ if (locationsError) throw locationsError;
3541
+ if (ordersError) throw ordersError;
3542
+ if (itemsError) throw itemsError;
3543
+ const personsById = indexById(persons);
3544
+ const locationsById = indexById(locations);
3545
+ const ordersById = indexById(orders);
3546
+ const itemsByOrderId = groupBy(items ?? [], (item) => item.order_id);
3547
+ return bookings.map((booking) => {
3548
+ const client = booking.party_id ? personsById.get(booking.party_id) : null;
3549
+ const professional = booking.assignee_id ? personsById.get(booking.assignee_id) : null;
3550
+ const location = booking.location_id ? locationsById.get(booking.location_id) : null;
3551
+ const order = booking.order_id ? ordersById.get(booking.order_id) : null;
3552
+ const bookingItems = ((booking.order_id ? itemsByOrderId.get(booking.order_id) : null) ?? []).sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)).map((item) => ({
3553
+ id: item.id,
3554
+ serviceId: item.service_id,
3555
+ name: item.name,
3556
+ durationMinutes: item.duration_minutes,
3557
+ price: item.unit_price,
3558
+ assigneeId: item.assignee_id
3559
+ }));
3560
+ const itemDuration = bookingItems.reduce((sum, item) => sum + (item.durationMinutes ?? 0), 0);
3561
+ return {
3562
+ id: booking.id,
3563
+ tenantId: booking.tenant_id,
3564
+ kind: booking.kind,
3565
+ startsAt: booking.starts_at,
3566
+ endsAt: booking.ends_at,
3567
+ status: booking.status,
3568
+ notes: booking.notes,
3569
+ orderId: booking.order_id,
3570
+ locationId: booking.location_id,
3571
+ metadata: booking.metadata ?? {},
3572
+ clientId: booking.party_id,
3573
+ // Simple events (no party) fall back to the title stored in metadata.
3574
+ clientName: client?.name ?? booking.metadata?.title ?? null,
3575
+ clientPhone: client?.phone ?? null,
3576
+ clientEmail: client?.email ?? null,
3577
+ clientAvatarUrl: client?.avatar_url ?? null,
3578
+ professionalId: booking.assignee_id,
3579
+ professionalName: professional?.name ?? null,
3580
+ professionalAvatarUrl: professional?.avatar_url ?? null,
3581
+ locationName: location?.name ?? null,
3582
+ orderTotal: order?.total ?? null,
3583
+ orderStatus: order?.status ?? null,
3584
+ services: bookingItems.length ? bookingItems : null,
3585
+ totalDurationMinutes: itemDuration || getDurationMinutes(booking.starts_at, booking.ends_at),
3586
+ createdAt: booking.created_at,
3587
+ updatedAt: booking.updated_at
3588
+ };
3589
+ });
3590
+ }
3591
+ async function getCanonicalBookings(core, query) {
3592
+ let q = core.from("appointments").select("id, tenant_id, kind, starts_at, ends_at, status, notes, order_id, location_id, metadata, party_id, assignee_id, created_at, updated_at").gte("starts_at", query.dateRange.start).lte("starts_at", query.dateRange.end);
3593
+ if (query.kind) q = q.eq("kind", query.kind);
3594
+ if (query.statuses?.length) q = q.in("status", query.statuses);
3595
+ if (query.professionalIds?.length) q = q.in("assignee_id", query.professionalIds);
3596
+ if (query.locationId) q = q.eq("location_id", query.locationId);
3597
+ const { data, error } = await q.order("starts_at", { ascending: true });
3598
+ if (error) throw error;
3599
+ return hydrateCanonicalBookings(core, data ?? []);
3600
+ }
3601
+ async function getCanonicalBookingById(core, id) {
3602
+ const columns = "id, tenant_id, kind, starts_at, ends_at, status, notes, order_id, location_id, metadata, party_id, assignee_id, created_at, updated_at";
3603
+ let { data, error } = await core.from("appointments").select(columns).eq("id", id).maybeSingle();
3604
+ if (error) throw error;
3605
+ if (!data) {
3606
+ const fallback = await core.from("appointments").select(columns).eq("order_id", id).maybeSingle();
3607
+ if (fallback.error) throw fallback.error;
3608
+ data = fallback.data;
3609
+ }
3610
+ if (!data) return null;
3611
+ const [booking] = await hydrateCanonicalBookings(core, [data]);
3612
+ return booking ?? null;
3613
+ }
3614
+ function createSupabaseAgendaProvider() {
3615
+ return {
3616
+ // v_appointments is in public schema (PostgREST default)
3617
+ async getBookings(query) {
3618
+ const { core, pub } = getClients();
3619
+ let q = pub.from("v_appointments").select("*").gte("starts_at", query.dateRange.start).lte("starts_at", query.dateRange.end);
3620
+ if (query.kind) q = q.eq("kind", query.kind);
3621
+ if (query.statuses?.length) q = q.in("status", query.statuses);
3622
+ if (query.professionalIds?.length) q = q.in("professional_id", query.professionalIds);
3623
+ if (query.locationId) q = q.eq("location_id", query.locationId);
3624
+ q = q.order("starts_at", { ascending: true });
3625
+ const { data, error } = await q;
3626
+ if (error) throw error;
3627
+ const viewBookings = (data ?? []).map(mapBooking);
3628
+ const canonicalBookings = await getCanonicalBookings(core, query);
3629
+ return mergeBookings(viewBookings, canonicalBookings);
3630
+ },
3631
+ async getBookingById(id) {
3632
+ const { core, pub } = getClients();
3633
+ const { data, error } = await pub.from("v_appointments").select("*").eq("id", id).maybeSingle();
3634
+ if (error) throw error;
3635
+ if (data) return mapBooking(data);
3636
+ return getCanonicalBookingById(core, id);
3637
+ },
3638
+ async createBooking(input) {
3639
+ const { core } = getClients();
3640
+ const tenantId = getTenantId();
3641
+ if (!tenantId) throw new Error("No active tenant");
3642
+ const hasServices = input.services.length > 0;
3643
+ const totalDuration = input.services.reduce((sum, s) => sum + s.durationMinutes, 0);
3644
+ const totalPrice = input.services.reduce((sum, s) => sum + s.price, 0);
3645
+ const endsAt = addMinutes(input.startsAt, totalDuration || 30);
3646
+ const serviceNames = input.services.map((s) => s.name).join(", ");
3647
+ let contactName;
3648
+ if (input.clientId) {
3649
+ const { data: person } = await core.from("people").select("name").eq("id", input.clientId).single();
3650
+ contactName = person?.name;
3651
+ }
3652
+ const eventTitle = input.title?.trim() || void 0;
3653
+ const { data: order, error: orderErr } = await core.from("orders").insert({
3654
+ tenant_id: tenantId,
3655
+ kind: input.kind ?? "appointment",
3656
+ status: "scheduled",
3657
+ party_id: input.clientId || null,
3658
+ assignee_id: input.professionalId,
3659
+ location_id: input.locationId ?? null,
3660
+ subtotal: totalPrice,
3661
+ total: totalPrice,
3662
+ notes: input.notes ?? null,
3663
+ metadata: {
3664
+ source: "agenda",
3665
+ ...serviceNames ? { serviceNames, itemsSummary: serviceNames } : {},
3666
+ ...contactName ? { contactName } : {},
3667
+ ...eventTitle ? { title: eventTitle } : {}
3668
+ }
3669
+ }).select("id").single();
3670
+ if (orderErr) throw orderErr;
3671
+ const { data: booking, error: bookingErr } = await core.from("appointments").insert({
3672
+ tenant_id: tenantId,
3673
+ kind: input.kind ?? "appointment",
3674
+ party_id: input.clientId || null,
3675
+ assignee_id: input.professionalId,
3676
+ location_id: input.locationId ?? null,
3677
+ order_id: order.id,
3678
+ starts_at: input.startsAt,
3679
+ ends_at: endsAt,
3680
+ status: "scheduled",
3681
+ notes: input.notes ?? null,
3682
+ metadata: {
3683
+ source: "agenda",
3684
+ ...serviceNames ? { serviceNames, itemsSummary: serviceNames } : {},
3685
+ ...contactName ? { contactName } : {},
3686
+ ...eventTitle ? { title: eventTitle } : {}
3687
+ }
3688
+ }).select("id").single();
3689
+ if (bookingErr) throw bookingErr;
3690
+ if (hasServices) {
3691
+ const orderItems = input.services.map((s, i) => ({
3692
+ order_id: order.id,
3693
+ service_id: s.serviceId,
3694
+ name: s.name,
3695
+ quantity: 1,
3696
+ unit_price: s.price,
3697
+ total: s.price,
3698
+ sort_order: i,
3699
+ duration_minutes: s.durationMinutes,
3700
+ assignee_id: s.assigneeId ?? input.professionalId
3701
+ }));
3702
+ const { error: oiErr } = await core.from("order_items").insert(orderItems);
3703
+ if (oiErr) throw oiErr;
3704
+ }
3705
+ const createdBooking = await this.getBookingById(booking.id);
3706
+ if (!createdBooking) throw new Error("Created booking did not resolve from agenda read model");
3707
+ return createdBooking;
3708
+ },
3709
+ async updateBooking(id, data) {
3710
+ const { core } = getClients();
3711
+ const { data: existing, error: existingErr } = await core.from("appointments").select("id, order_id, starts_at, assignee_id, metadata").eq("id", id).single();
3712
+ if (existingErr) throw existingErr;
3713
+ const updates = {};
3714
+ if (data.startsAt) updates.starts_at = data.startsAt;
3715
+ if (data.endsAt) updates.ends_at = data.endsAt;
3716
+ if (data.title !== void 0) {
3717
+ updates.metadata = { ...existing?.metadata ?? {}, title: data.title };
3718
+ }
3719
+ if (data.status) {
3720
+ updates.status = data.status;
3721
+ }
3722
+ if (data.notes !== void 0) updates.notes = data.notes;
3723
+ if (data.professionalId) updates.assignee_id = data.professionalId;
3724
+ if (data.clientId) updates.party_id = data.clientId;
3725
+ if (data.locationId) updates.location_id = data.locationId;
3726
+ if (data.services) {
3727
+ const totalDuration = data.services.reduce((s, svc) => s + svc.durationMinutes, 0);
3728
+ data.services.reduce((s, svc) => s + svc.price, 0);
3729
+ const startsAt = data.startsAt ?? existing?.starts_at;
3730
+ if (startsAt) {
3731
+ const ends = new Date(new Date(startsAt).getTime() + totalDuration * 6e4);
3732
+ updates.ends_at = ends.toISOString();
3733
+ }
3734
+ }
3735
+ const effectiveAssigneeId = data.professionalId ?? existing?.assignee_id ?? null;
3736
+ const { error } = await core.from("appointments").update(updates).eq("id", id);
3737
+ if (error) throw error;
3738
+ const orderId = existing?.order_id;
3739
+ if (orderId) {
3740
+ const orderUpdates = {};
3741
+ if (data.notes !== void 0) orderUpdates.notes = data.notes;
3742
+ if (data.professionalId) orderUpdates.assignee_id = data.professionalId;
3743
+ if (data.clientId) orderUpdates.party_id = data.clientId;
3744
+ if (data.locationId) orderUpdates.location_id = data.locationId;
3745
+ if (data.services) {
3746
+ const totalPrice = data.services.reduce((s, svc) => s + svc.price, 0);
3747
+ orderUpdates.subtotal = totalPrice;
3748
+ orderUpdates.total = totalPrice;
3749
+ }
3750
+ if (Object.keys(orderUpdates).length > 0) {
3751
+ const { error: orderErr } = await core.from("orders").update(orderUpdates).eq("id", orderId);
3752
+ if (orderErr) throw orderErr;
3753
+ }
3754
+ if (data.services) {
3755
+ await core.from("order_items").delete().eq("order_id", orderId);
3756
+ if (data.services.length > 0) {
3757
+ const items = data.services.map((svc, idx) => ({
3758
+ order_id: orderId,
3759
+ service_id: svc.serviceId || null,
3760
+ name: svc.name,
3761
+ quantity: 1,
3762
+ unit_price: svc.price,
3763
+ total: svc.price,
3764
+ sort_order: idx,
3765
+ duration_minutes: svc.durationMinutes,
3766
+ assignee_id: svc.assigneeId ?? effectiveAssigneeId
3767
+ }));
3768
+ const { error: itemErr } = await core.from("order_items").insert(items);
3769
+ if (itemErr) throw itemErr;
3770
+ }
3771
+ }
3772
+ }
3773
+ return await this.getBookingById(id);
3774
+ },
3775
+ async deleteBooking(id) {
3776
+ const { core, pub } = getClients();
3777
+ const { data: booking, error: bookingErr } = await core.from("appointments").select("order_id").eq("id", id).single();
3778
+ if (bookingErr) throw bookingErr;
3779
+ const orderId = booking?.order_id;
3780
+ const { count, error: movementErr } = orderId ? await pub.from("plg_financial_movements").select("id", { count: "exact", head: true }).eq("invoice_id", orderId) : { count: 0, error: null };
3781
+ if (movementErr) throw movementErr;
3782
+ if (orderId && (count ?? 0) > 0) {
3783
+ await pub.from("plg_financial_movements").update({ status: "cancelled" }).eq("invoice_id", orderId);
3784
+ await core.from("orders").update({ status: "cancelled" }).eq("id", orderId);
3785
+ const { error } = await core.from("appointments").update({ status: "cancelled" }).eq("id", id);
3786
+ if (error) throw error;
3787
+ } else {
3788
+ if (orderId) {
3789
+ await pub.from("plg_financial_movements").delete().eq("invoice_id", orderId);
3790
+ }
3791
+ const { error } = await core.from("appointments").delete().eq("id", id);
3792
+ if (error) throw error;
3793
+ if (orderId) {
3794
+ const { error: orderErr } = await core.from("orders").delete().eq("id", orderId);
3795
+ if (orderErr) throw orderErr;
3796
+ }
3797
+ }
3798
+ },
3799
+ async updateBookingStatus(id, status) {
3800
+ return this.updateBooking(id, { status });
3801
+ },
3802
+ async checkConflict(params) {
3803
+ const { core } = getClients();
3804
+ let q = core.from("appointments").select("id", { count: "exact", head: true }).eq("assignee_id", params.assigneeId).eq("kind", "appointment").not("status", "in", '("cancelled","no_show")').not("starts_at", "is", null).lt("starts_at", params.endsAt).gt("ends_at", params.startsAt);
3805
+ if (params.excludeBookingId) {
3806
+ q = q.neq("id", params.excludeBookingId);
3807
+ }
3808
+ const { count, error } = await q;
3809
+ if (error) throw error;
3810
+ return (count ?? 0) > 0;
3811
+ },
3812
+ async getAvailableSlots(params) {
3813
+ const { pub } = getClients();
3814
+ const { data, error } = await pub.rpc("get_available_slots", {
3815
+ p_tenant_id: getTenantId(),
3816
+ p_assignee_id: params.assigneeId,
3817
+ p_date: params.date,
3818
+ p_duration_minutes: params.durationMinutes,
3819
+ p_slot_interval: params.slotInterval ?? 30
3820
+ });
3821
+ if (error) throw error;
3822
+ return (data ?? []).map((row) => ({ start: row.slot_start, end: row.slot_end }));
3823
+ },
3824
+ async getSchedules(professionalId) {
3825
+ const { core } = getClients();
3826
+ let q = core.from("schedules").select("*").eq("kind", "working_hours");
3827
+ if (professionalId) q = q.eq("assignee_id", professionalId);
3828
+ q = q.order("day_of_week").order("starts_at");
3829
+ const { data, error } = await q;
3830
+ if (error) throw error;
3831
+ return (data ?? []).map(mapSchedule);
3832
+ },
3833
+ async saveSchedule(input) {
3834
+ const { core } = getClients();
3835
+ const tenantId = getTenantId();
3836
+ if (!tenantId) throw new Error("No active tenant");
3837
+ const { data, error } = await core.from("schedules").insert({
3838
+ tenant_id: tenantId,
3839
+ kind: "working_hours",
3840
+ assignee_id: input.assigneeId,
3841
+ location_id: input.locationId ?? null,
3842
+ day_of_week: input.dayOfWeek ?? null,
3843
+ specific_date: input.specificDate ?? null,
3844
+ starts_at: input.startsAt,
3845
+ ends_at: input.endsAt,
3846
+ is_active: input.isActive !== false,
3847
+ metadata: input.metadata ?? {}
3848
+ }).select("*").single();
3849
+ if (error) throw error;
3850
+ return mapSchedule(data);
3851
+ },
3852
+ async deleteSchedule(id) {
3853
+ const { core } = getClients();
3854
+ const { error } = await core.from("schedules").delete().eq("id", id);
3855
+ if (error) throw error;
3856
+ },
3857
+ async getProfessionals() {
3858
+ const { core } = getClients();
3859
+ const { data, error } = await core.from("people").select("id, name, avatar_url, is_active").eq("kind", "staff").eq("is_active", true).order("name");
3860
+ if (error) throw error;
3861
+ return (data ?? []).map((p) => ({
3862
+ id: p.id,
3863
+ name: p.name,
3864
+ avatarUrl: p.avatar_url,
3865
+ locationId: null,
3866
+ locationName: null,
3867
+ isActive: p.is_active
3868
+ }));
3869
+ },
3870
+ async getLocations() {
3871
+ const { core } = getClients();
3872
+ const { data, error } = await core.from("locations").select("id, name").eq("is_active", true).order("name");
3873
+ if (error) throw error;
3874
+ return (data ?? []).map((l) => ({ id: l.id, name: l.name }));
3875
+ },
3876
+ async getConfirmationsPending(daysAhead = 2) {
3877
+ const { pub } = getClients();
3878
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3879
+ const cutoff = /* @__PURE__ */ new Date();
3880
+ cutoff.setDate(cutoff.getDate() + daysAhead);
3881
+ const { data, error } = await pub.from("v_appointments").select("*").eq("status", "scheduled").gte("starts_at", now).lte("starts_at", cutoff.toISOString()).order("starts_at");
3882
+ if (error) throw error;
3883
+ return (data ?? []).map(mapBooking);
3884
+ },
3885
+ async sendConfirmation(bookingId, channel) {
3886
+ const { core } = getClients();
3887
+ const { data: booking } = await core.from("appointments").select("metadata").eq("id", bookingId).single();
3888
+ const metadata = booking?.metadata ?? {};
3889
+ const confirmations = metadata.confirmations ?? [];
3890
+ confirmations.push({ channel, sentAt: (/* @__PURE__ */ new Date()).toISOString(), status: "sent" });
3891
+ await core.from("appointments").update({ metadata: { ...metadata, confirmations } }).eq("id", bookingId);
3892
+ },
3893
+ async completeBooking(bookingId) {
3894
+ const { core } = getClients();
3895
+ const { data: existing, error: existingErr } = await core.from("appointments").select("order_id").eq("id", bookingId).single();
3896
+ if (existingErr) throw existingErr;
3897
+ const { error } = await core.from("appointments").update({ status: "completed" }).eq("id", bookingId);
3898
+ if (error) throw error;
3899
+ const orderId = existing?.order_id;
3900
+ const booking = await this.getBookingById(bookingId);
3901
+ if (!booking) throw new Error("Booking not found");
3902
+ return { booking, orderId: orderId ?? bookingId };
3903
+ }
3904
+ };
3905
+ }
3464
3906
  function getCurrentBookingQuery(state, dateRange = state.dateRange) {
3465
3907
  return {
3466
3908
  dateRange,
@@ -3681,7 +4123,6 @@ var holidayEntity = {
3681
4123
  ],
3682
4124
  data: {
3683
4125
  table: "holidays",
3684
- schema: "saas_core",
3685
4126
  tenantScoped: true
3686
4127
  }
3687
4128
  };
@@ -4209,6 +4650,300 @@ var agendaLocales = {
4209
4650
  en,
4210
4651
  "pt-BR": ptBR
4211
4652
  };
4653
+
4654
+ // src/migrations/index.ts
4655
+ var MIGRATION_001_PUBLIC_BOOKING = `-- ============================================================================
4656
+ -- plugin-agenda 001: booking read model + public (anon) booking surface
4657
+ --
4658
+ -- Ships the objects the agenda providers reference but the spine (packages/db
4659
+ -- migrations 001\u2013010) does not define:
4660
+ -- \xA70 converted-pool cleanup \u2014 drop the pre-rename v_bookings view / indexes
4661
+ -- \xA71 order_items columns the admin provider writes (duration/assignee)
4662
+ -- \xA72 public.v_appointments \u2014 admin read model (authenticated, RLS applies)
4663
+ -- \xA73 public.v_public_services \u2014 anon-readable service catalog
4664
+ -- \xA74 public.get_available_slots \u2014 anon RPC (mirrors the mock algorithm)
4665
+ -- \xA75 public.create_public_booking \u2014 anon RPC (validated, race-safe write)
4666
+ --
4667
+ -- Core v1 lives directly in PUBLIC (no separate core schema): people (was
4668
+ -- persons), appointments (was bookings). This migration is written against public
4669
+ -- names and is idempotent + safe on both fresh and converted pools.
4670
+ --
4671
+ -- Anon-access design: public booking pages run with the publishable key and no
4672
+ -- session, so the canonical tenant RLS yields nothing. Anon access is granted
4673
+ -- ONLY via the column-whitelisted view (\xA73) and SECURITY DEFINER RPCs (\xA74, \xA75)
4674
+ -- with tight input validation and hardcoded statuses.
4675
+ -- ============================================================================
4676
+
4677
+ -- \xA70 \u2014 converted-pool cleanup. The pre-industry-pool shape shipped a v_bookings
4678
+ -- view and persons_/bookings_-named indexes; drop them so this file re-emits the
4679
+ -- canonical v_appointments view + people_/appointments_ indexes without leaving
4680
+ -- duplicates. All guarded with IF EXISTS \u2192 no-ops on a fresh pool.
4681
+ DROP VIEW IF EXISTS public.v_bookings;
4682
+ DROP INDEX IF EXISTS public.persons_tenant_customer_phone;
4683
+ DROP INDEX IF EXISTS public.bookings_assignee_time;
4684
+
4685
+ -- \xA71 \u2014 columns the agenda provider writes but 004_archetypes lacks
4686
+ ALTER TABLE public.order_items
4687
+ ADD COLUMN IF NOT EXISTS duration_minutes integer,
4688
+ ADD COLUMN IF NOT EXISTS assignee_id uuid REFERENCES public.people(id) ON DELETE SET NULL;
4689
+
4690
+ -- Upsert key for public customers (per tenant, by normalized phone)
4691
+ CREATE UNIQUE INDEX IF NOT EXISTS people_tenant_customer_phone
4692
+ ON public.people(tenant_id, phone)
4693
+ WHERE kind = 'customer' AND phone IS NOT NULL;
4694
+
4695
+ -- Slot-conflict scan
4696
+ CREATE INDEX IF NOT EXISTS appointments_assignee_time
4697
+ ON public.appointments(assignee_id, starts_at, ends_at)
4698
+ WHERE status NOT IN ('cancelled', 'no_show');
4699
+
4700
+ -- \xA72 \u2014 admin read model (security_invoker \u2192 underlying RLS applies; anon sees 0 rows)
4701
+ CREATE OR REPLACE VIEW public.v_appointments
4702
+ WITH (security_invoker = true) AS
4703
+ SELECT
4704
+ b.id, b.tenant_id, b.kind, b.starts_at, b.ends_at, b.status, b.notes,
4705
+ b.order_id, b.location_id, b.metadata, b.created_at, b.updated_at,
4706
+ b.party_id AS client_id,
4707
+ COALESCE(pc.name, b.metadata->>'title') AS client_name,
4708
+ pc.phone AS client_phone,
4709
+ pc.email AS client_email,
4710
+ pc.avatar_url AS client_avatar_url,
4711
+ b.assignee_id AS professional_id,
4712
+ ps.name AS professional_name,
4713
+ ps.avatar_url AS professional_avatar_url,
4714
+ l.name AS location_name,
4715
+ o.total AS order_total,
4716
+ o.status AS order_status,
4717
+ (EXTRACT(EPOCH FROM (COALESCE(b.ends_at, b.starts_at) - b.starts_at))::int / 60)
4718
+ AS total_duration_minutes
4719
+ FROM public.appointments b
4720
+ LEFT JOIN public.people pc ON pc.id = b.party_id
4721
+ LEFT JOIN public.people ps ON ps.id = b.assignee_id
4722
+ LEFT JOIN public.locations l ON l.id = b.location_id
4723
+ LEFT JOIN public.orders o ON o.id = b.order_id;
4724
+
4725
+ GRANT SELECT ON public.v_appointments TO authenticated, service_role;
4726
+ REVOKE SELECT ON public.v_appointments FROM anon;
4727
+
4728
+ -- \xA73 \u2014 anon-readable public catalog (owner-rights view = deliberate, whitelisted)
4729
+ CREATE OR REPLACE VIEW public.v_public_services AS
4730
+ SELECT
4731
+ s.id, s.tenant_id, s.name, s.description, s.price, s.currency,
4732
+ COALESCE(s.duration_minutes, 30) AS duration_minutes,
4733
+ s.image_url,
4734
+ COALESCE((s.metadata->>'sort_order')::int, 0) AS sort_order
4735
+ FROM public.services s
4736
+ WHERE s.is_active AND s.status = 'active';
4737
+
4738
+ GRANT SELECT ON public.v_public_services TO anon, authenticated, service_role;
4739
+
4740
+ -- \xA74 \u2014 availability RPC (mirrors plugin-agenda/src/data/mock.ts getAvailableSlots)
4741
+ CREATE OR REPLACE FUNCTION public.get_available_slots(
4742
+ p_tenant_id uuid,
4743
+ p_date date,
4744
+ p_duration_minutes integer,
4745
+ p_assignee_id uuid DEFAULT NULL,
4746
+ p_slot_interval integer DEFAULT 30
4747
+ ) RETURNS TABLE (slot_start timestamptz, slot_end timestamptz)
4748
+ LANGUAGE plpgsql STABLE SECURITY DEFINER
4749
+ SET search_path = public
4750
+ AS $$
4751
+ DECLARE
4752
+ v_tz text;
4753
+ BEGIN
4754
+ -- tight validation: this function is anon-callable
4755
+ IF p_duration_minutes IS NULL OR p_duration_minutes NOT BETWEEN 5 AND 480 THEN
4756
+ RAISE EXCEPTION 'invalid duration';
4757
+ END IF;
4758
+ IF p_slot_interval IS NULL OR p_slot_interval NOT BETWEEN 5 AND 240 THEN
4759
+ RAISE EXCEPTION 'invalid interval';
4760
+ END IF;
4761
+ IF p_date IS NULL OR p_date < current_date OR p_date > current_date + 90 THEN
4762
+ RETURN;
4763
+ END IF;
4764
+
4765
+ SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
4766
+ FROM tenants t WHERE t.id = p_tenant_id;
4767
+ IF v_tz IS NULL THEN RETURN; END IF; -- unknown tenant \u2192 empty, not an error
4768
+
4769
+ RETURN QUERY
4770
+ WITH day_schedules AS (
4771
+ SELECT sc.assignee_id, sc.starts_at AS win_start, sc.ends_at AS win_end
4772
+ FROM schedules sc
4773
+ WHERE sc.tenant_id = p_tenant_id
4774
+ AND sc.kind = 'working_hours'
4775
+ AND sc.is_active
4776
+ AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)
4777
+ AND (
4778
+ sc.specific_date = p_date
4779
+ OR (sc.specific_date IS NULL AND sc.day_of_week = EXTRACT(dow FROM p_date)::smallint)
4780
+ )
4781
+ ),
4782
+ candidate AS (
4783
+ SELECT
4784
+ ds.assignee_id,
4785
+ gs AS s_start,
4786
+ gs + make_interval(mins => p_duration_minutes) AS s_end
4787
+ FROM day_schedules ds
4788
+ CROSS JOIN LATERAL generate_series(
4789
+ (p_date + ds.win_start) AT TIME ZONE v_tz,
4790
+ ((p_date + ds.win_end) AT TIME ZONE v_tz) - make_interval(mins => p_duration_minutes),
4791
+ make_interval(mins => p_slot_interval)
4792
+ ) AS gs
4793
+ )
4794
+ SELECT DISTINCT c.s_start, c.s_end
4795
+ FROM candidate c
4796
+ WHERE NOT EXISTS (
4797
+ SELECT 1 FROM appointments b
4798
+ WHERE b.tenant_id = p_tenant_id
4799
+ AND b.assignee_id = c.assignee_id
4800
+ AND b.status NOT IN ('cancelled', 'no_show')
4801
+ AND b.starts_at < c.s_end
4802
+ AND COALESCE(b.ends_at, b.starts_at) > c.s_start
4803
+ )
4804
+ ORDER BY 1;
4805
+ END;
4806
+ $$;
4807
+
4808
+ REVOKE ALL ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer) FROM public;
4809
+ GRANT EXECUTE ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer)
4810
+ TO anon, authenticated, service_role;
4811
+
4812
+ -- \xA75 \u2014 public booking write RPC (validated, race-safe, hardcoded statuses)
4813
+ CREATE OR REPLACE FUNCTION public.create_public_booking(
4814
+ p_tenant_id uuid,
4815
+ p_service_id uuid,
4816
+ p_starts_at timestamptz,
4817
+ p_name text,
4818
+ p_phone text,
4819
+ p_email text DEFAULT NULL,
4820
+ p_notes text DEFAULT NULL
4821
+ ) RETURNS TABLE (booking_id uuid, starts_at timestamptz, ends_at timestamptz)
4822
+ LANGUAGE plpgsql SECURITY DEFINER
4823
+ SET search_path = public
4824
+ AS $$
4825
+ DECLARE
4826
+ v_service record;
4827
+ v_person_id uuid;
4828
+ v_assignee uuid;
4829
+ v_ends timestamptz;
4830
+ v_order uuid;
4831
+ v_booking uuid;
4832
+ v_phone text;
4833
+ v_tz text;
4834
+ v_notes text;
4835
+ BEGIN
4836
+ -- validation: anon-callable, everything tight
4837
+ IF p_name IS NULL OR length(trim(p_name)) NOT BETWEEN 2 AND 120 THEN
4838
+ RAISE EXCEPTION 'invalid name';
4839
+ END IF;
4840
+ v_phone := regexp_replace(COALESCE(p_phone, ''), '\\D', '', 'g');
4841
+ IF length(v_phone) NOT BETWEEN 8 AND 15 THEN
4842
+ RAISE EXCEPTION 'invalid phone';
4843
+ END IF;
4844
+ IF p_email IS NOT NULL AND (length(p_email) > 254 OR p_email !~ '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$') THEN
4845
+ RAISE EXCEPTION 'invalid email';
4846
+ END IF;
4847
+ v_notes := left(p_notes, 500);
4848
+ IF p_starts_at IS NULL OR p_starts_at <= now() OR p_starts_at > now() + interval '90 days' THEN
4849
+ RAISE EXCEPTION 'invalid start';
4850
+ END IF;
4851
+
4852
+ SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
4853
+ INTO v_service
4854
+ FROM services s
4855
+ WHERE s.id = p_service_id AND s.tenant_id = p_tenant_id
4856
+ AND s.is_active AND s.status = 'active';
4857
+ IF NOT FOUND THEN RAISE EXCEPTION 'unknown service'; END IF;
4858
+
4859
+ v_ends := p_starts_at + make_interval(mins => v_service.duration_minutes);
4860
+ SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
4861
+ FROM tenants t WHERE t.id = p_tenant_id;
4862
+ IF v_tz IS NULL THEN RAISE EXCEPTION 'unknown tenant'; END IF;
4863
+
4864
+ -- pick the assignee whose working hours cover the slot
4865
+ SELECT sc.assignee_id INTO v_assignee
4866
+ FROM schedules sc
4867
+ WHERE sc.tenant_id = p_tenant_id
4868
+ AND sc.kind = 'working_hours'
4869
+ AND sc.is_active
4870
+ AND (
4871
+ sc.specific_date = (p_starts_at AT TIME ZONE v_tz)::date
4872
+ OR (sc.specific_date IS NULL
4873
+ AND sc.day_of_week = EXTRACT(dow FROM p_starts_at AT TIME ZONE v_tz)::smallint)
4874
+ )
4875
+ AND (p_starts_at AT TIME ZONE v_tz)::time >= sc.starts_at
4876
+ AND (v_ends AT TIME ZONE v_tz)::time <= sc.ends_at
4877
+ ORDER BY sc.assignee_id
4878
+ LIMIT 1;
4879
+ IF v_assignee IS NULL THEN RAISE EXCEPTION 'slot outside working hours'; END IF;
4880
+
4881
+ -- serialize per (tenant, assignee) to kill the double-book race
4882
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_assignee::text, 0));
4883
+
4884
+ IF EXISTS (
4885
+ SELECT 1 FROM appointments b
4886
+ WHERE b.tenant_id = p_tenant_id
4887
+ AND b.assignee_id = v_assignee
4888
+ AND b.status NOT IN ('cancelled', 'no_show')
4889
+ AND b.starts_at < v_ends
4890
+ AND COALESCE(b.ends_at, b.starts_at) > p_starts_at
4891
+ ) THEN
4892
+ RAISE EXCEPTION 'slot no longer available';
4893
+ END IF;
4894
+
4895
+ -- anti-spam: cap open future public bookings per phone per tenant
4896
+ IF (
4897
+ SELECT count(*) FROM appointments b
4898
+ JOIN people p ON p.id = b.party_id
4899
+ WHERE b.tenant_id = p_tenant_id
4900
+ AND p.phone = v_phone
4901
+ AND b.starts_at > now()
4902
+ AND b.status = 'scheduled'
4903
+ ) >= 3 THEN
4904
+ RAISE EXCEPTION 'too many open bookings for this phone';
4905
+ END IF;
4906
+
4907
+ -- upsert the customer by (tenant, phone) \u2014 matches people_tenant_customer_phone
4908
+ INSERT INTO people (tenant_id, kind, name, phone, email, metadata)
4909
+ VALUES (p_tenant_id, 'customer', trim(p_name), v_phone, p_email,
4910
+ jsonb_build_object('source', 'public_booking'))
4911
+ ON CONFLICT (tenant_id, phone) WHERE kind = 'customer' AND phone IS NOT NULL
4912
+ DO UPDATE SET
4913
+ name = EXCLUDED.name,
4914
+ email = COALESCE(EXCLUDED.email, people.email),
4915
+ updated_at = now()
4916
+ RETURNING id INTO v_person_id;
4917
+
4918
+ -- order + booking + order_item (statuses hardcoded \u2014 caller cannot set)
4919
+ INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
4920
+ VALUES (p_tenant_id, 'appointment', 'scheduled', v_person_id, v_assignee,
4921
+ v_service.price, v_service.price, v_notes,
4922
+ jsonb_build_object('source', 'public_booking',
4923
+ 'serviceNames', v_service.name,
4924
+ 'contactName', trim(p_name)))
4925
+ RETURNING id INTO v_order;
4926
+
4927
+ INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
4928
+ starts_at, ends_at, status, notes, metadata)
4929
+ VALUES (p_tenant_id, 'appointment', v_person_id, v_assignee, v_order,
4930
+ p_starts_at, v_ends, 'scheduled', v_notes,
4931
+ jsonb_build_object('source', 'public_booking', 'serviceNames', v_service.name))
4932
+ RETURNING id INTO v_booking;
4933
+
4934
+ INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
4935
+ sort_order, duration_minutes, assignee_id)
4936
+ VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
4937
+ 0, v_service.duration_minutes, v_assignee);
4938
+
4939
+ RETURN QUERY SELECT v_booking, p_starts_at, v_ends;
4940
+ END;
4941
+ $$;
4942
+
4943
+ REVOKE ALL ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text) FROM public;
4944
+ GRANT EXECUTE ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text)
4945
+ TO anon, authenticated, service_role;
4946
+ `;
4212
4947
  function computeCommissionAmount(total, ratePercent) {
4213
4948
  const base = Number(total) || 0;
4214
4949
  const rate = Number(ratePercent) || 0;
@@ -4236,8 +4971,8 @@ function createFinancialBridge(provider2) {
4236
4971
  if (!supabase) return result;
4237
4972
  try {
4238
4973
  const [{ data: orders }, { data: movements }] = await Promise.all([
4239
- supabase.schema("saas_core").from("orders").select("id, kind, status, total, reference_number, metadata").in("id", orderIds),
4240
- supabase.from("financial_movements").select("invoice_id, status, paid_amount").in("invoice_id", orderIds)
4974
+ supabase.from("orders").select("id, kind, status, total, reference_number, metadata").in("id", orderIds),
4975
+ supabase.from("plg_financial_movements").select("invoice_id, status, paid_amount").in("invoice_id", orderIds)
4241
4976
  ]);
4242
4977
  const movementsByOrderId = /* @__PURE__ */ new Map();
4243
4978
  for (const movement of movements ?? []) {
@@ -4265,8 +5000,8 @@ function createFinancialBridge(provider2) {
4265
5000
  const supabase = getSupabaseClientOptional();
4266
5001
  if (!supabase) return null;
4267
5002
  const [orderRes, movRes] = await Promise.all([
4268
- supabase.schema("saas_core").from("orders").select("id, status, total, reference_number, metadata").eq("id", orderId).single(),
4269
- supabase.from("financial_movements").select("*").eq("invoice_id", orderId).order("due_date")
5003
+ supabase.from("orders").select("id, status, total, reference_number, metadata").eq("id", orderId).single(),
5004
+ supabase.from("plg_financial_movements").select("*").eq("invoice_id", orderId).order("due_date")
4270
5005
  ]);
4271
5006
  const order = orderRes.data;
4272
5007
  if (!order) return null;
@@ -4274,7 +5009,7 @@ function createFinancialBridge(provider2) {
4274
5009
  const typeIds = [...new Set((movRes.data ?? []).map((m) => m.payment_method_type_id).filter(Boolean))];
4275
5010
  let typeNames = {};
4276
5011
  if (typeIds.length > 0) {
4277
- const { data: types } = await supabase.from("payment_method_types").select("id, name").in("id", typeIds);
5012
+ const { data: types } = await supabase.from("plg_financial_payment_method_types").select("id, name").in("id", typeIds);
4278
5013
  typeNames = Object.fromEntries((types ?? []).map((t) => [t.id, t.name]));
4279
5014
  }
4280
5015
  const movements = (movRes.data ?? []).map((m) => ({
@@ -4326,17 +5061,17 @@ function createFinancialBridge(provider2) {
4326
5061
  async createCommissionMovement(orderId, options) {
4327
5062
  const supabase = getSupabaseClientOptional();
4328
5063
  if (!supabase) return null;
4329
- const { data: order } = await supabase.schema("saas_core").from("orders").select("id, tenant_id, assignee_id, total").eq("id", orderId).single();
5064
+ const { data: order } = await supabase.from("orders").select("id, tenant_id, assignee_id, total").eq("id", orderId).single();
4330
5065
  if (!order || !order.assignee_id) return null;
4331
5066
  const { data: staff } = await supabase.from("v_staff").select("commission_rate").eq("id", order.assignee_id).single();
4332
5067
  const rate = Number(staff?.commission_rate) || 0;
4333
5068
  const base = Number(order.total) || 0;
4334
5069
  const amount = computeCommissionAmount(base, rate);
4335
5070
  if (amount <= 0) return null;
4336
- const { data: existing } = await supabase.from("financial_movements").select("id").eq("invoice_id", orderId).eq("movement_kind", "commission").limit(1);
5071
+ const { data: existing } = await supabase.from("plg_financial_movements").select("id").eq("invoice_id", orderId).eq("movement_kind", "commission").limit(1);
4337
5072
  if (existing && existing.length > 0) return existing[0].id;
4338
5073
  const dueDate = options?.dueDate ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
4339
- const { data: inserted } = await supabase.from("financial_movements").insert({
5074
+ const { data: inserted } = await supabase.from("plg_financial_movements").insert({
4340
5075
  tenant_id: order.tenant_id,
4341
5076
  invoice_id: orderId,
4342
5077
  direction: "debit",
@@ -4768,6 +5503,14 @@ function createAgendaPlugin(options) {
4768
5503
  permission: { feature: "appointments", action: "read" }
4769
5504
  }
4770
5505
  ],
5506
+ migrations: [
5507
+ {
5508
+ id: "agenda-001-public-booking",
5509
+ version: "0.3.0",
5510
+ sql: MIGRATION_001_PUBLIC_BOOKING,
5511
+ description: "Booking read model (v_appointments) + anon public booking surface (v_public_services, get_available_slots, create_public_booking) + order_items duration/assignee columns"
5512
+ }
5513
+ ],
4771
5514
  locales: agendaLocales
4772
5515
  };
4773
5516
  }