@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.
- package/README.md +2 -0
- package/dist/bridges/create-financial-bridge.d.ts.map +1 -1
- package/dist/chunk-DRZF6CV2.js +421 -0
- package/dist/chunk-DRZF6CV2.js.map +1 -0
- package/dist/chunk-LFUELUIT.cjs +423 -0
- package/dist/chunk-LFUELUIT.cjs.map +1 -0
- package/dist/config.d.ts +2 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/index.cjs +765 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +765 -22
- package/dist/index.js.map +1 -1
- package/dist/migrations/index.d.ts +9 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/public/BookingWidget.d.ts +4 -2
- package/dist/public/BookingWidget.d.ts.map +1 -1
- package/dist/public/components/BrandHeader.d.ts +6 -0
- package/dist/public/components/BrandHeader.d.ts.map +1 -0
- package/dist/public/components/ServiceCard.d.ts +5 -0
- package/dist/public/components/ServiceCard.d.ts.map +1 -0
- package/dist/public/components/SidebarExtras.d.ts +4 -0
- package/dist/public/components/SidebarExtras.d.ts.map +1 -0
- package/dist/public/components/StepRailItem.d.ts +4 -0
- package/dist/public/components/StepRailItem.d.ts.map +1 -0
- package/dist/public/components/SuccessPanel.d.ts +4 -0
- package/dist/public/components/SuccessPanel.d.ts.map +1 -0
- package/dist/public/components/SummaryRail.d.ts +27 -0
- package/dist/public/components/SummaryRail.d.ts.map +1 -0
- package/dist/public/components/defaults.d.ts +3 -0
- package/dist/public/components/defaults.d.ts.map +1 -0
- package/dist/public/context.d.ts +22 -8
- package/dist/public/context.d.ts.map +1 -1
- package/dist/public/createPublicBookingPlugin.d.ts +27 -10
- package/dist/public/createPublicBookingPlugin.d.ts.map +1 -1
- package/dist/public/data.d.ts +37 -0
- package/dist/public/data.d.ts.map +1 -0
- package/dist/public/data.supabase.d.ts +7 -0
- package/dist/public/data.supabase.d.ts.map +1 -0
- package/dist/public/format.d.ts +18 -0
- package/dist/public/format.d.ts.map +1 -0
- package/dist/public/hooks.d.ts +13 -11
- package/dist/public/hooks.d.ts.map +1 -1
- package/dist/public/index.cjs +1018 -615
- package/dist/public/index.cjs.map +1 -1
- package/dist/public/index.d.ts +15 -4
- package/dist/public/index.d.ts.map +1 -1
- package/dist/public/index.js +1008 -618
- package/dist/public/index.js.map +1 -1
- package/dist/public/steps/ContactStep.d.ts +30 -0
- package/dist/public/steps/ContactStep.d.ts.map +1 -0
- package/dist/public/steps/DateTimeStep.d.ts +23 -0
- package/dist/public/steps/DateTimeStep.d.ts.map +1 -0
- package/dist/public/steps/OverviewStep.d.ts +16 -0
- package/dist/public/steps/OverviewStep.d.ts.map +1 -0
- package/dist/public/steps/PaymentStep.d.ts +20 -0
- package/dist/public/steps/PaymentStep.d.ts.map +1 -0
- package/dist/public/types.d.ts +134 -0
- package/dist/public/types.d.ts.map +1 -1
- package/dist/registries.d.ts.map +1 -1
- package/dist/types.d.ts +3 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +11 -5
- package/src/bridges/create-financial-bridge.ts +6 -8
- package/src/components/AppointmentModal.tsx +4 -4
- package/src/config.ts +2 -2
- package/src/data/supabase.ts +26 -25
- package/src/index.ts +11 -0
- package/src/integrations/google-calendar/index.ts +1 -1
- package/src/integrations/google-calendar/migrations/001_google_calendar.sql +6 -6
- package/src/migrations/000b_v_bookings_rename.sql +21 -0
- package/src/migrations/000c_dedupe_customer_phones.sql +43 -0
- package/src/migrations/001_public_booking.sql +291 -0
- package/src/migrations/002_v_bookings_compat.sql +14 -0
- package/src/migrations/index.ts +387 -0
- package/src/public/BookingWidget.tsx +136 -713
- package/src/public/components/BrandHeader.tsx +27 -0
- package/src/public/components/ServiceCard.tsx +39 -0
- package/src/public/components/SidebarExtras.tsx +30 -0
- package/src/public/components/StepRailItem.tsx +27 -0
- package/src/public/components/SuccessPanel.tsx +27 -0
- package/src/public/components/SummaryRail.tsx +90 -0
- package/src/public/components/defaults.ts +14 -0
- package/src/public/context.tsx +64 -7
- package/src/public/createPublicBookingPlugin.ts +138 -25
- package/src/public/data.supabase.ts +94 -0
- package/src/public/data.ts +99 -0
- package/src/public/format.ts +61 -0
- package/src/public/hooks.ts +22 -34
- package/src/public/index.ts +57 -4
- package/src/public/steps/ContactStep.tsx +179 -0
- package/src/public/steps/DateTimeStep.tsx +138 -0
- package/src/public/steps/OverviewStep.tsx +81 -0
- package/src/public/steps/PaymentStep.tsx +180 -0
- package/src/public/types.ts +169 -3
- package/src/registries.ts +1 -2
- package/src/types.ts +4 -4
- package/dist/chunk-GAJVIEXM.cjs +0 -870
- package/dist/chunk-GAJVIEXM.cjs.map +0 -1
- package/dist/chunk-U6VTLOHN.js +0 -867
- package/dist/chunk-U6VTLOHN.js.map +0 -1
package/dist/index.cjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
var
|
|
3
|
+
var chunkLFUELUIT_cjs = require('./chunk-LFUELUIT.cjs');
|
|
4
4
|
var chunkSYDBDI3Q_cjs = require('./chunk-SYDBDI3Q.cjs');
|
|
5
5
|
var React12 = require('react');
|
|
6
6
|
var core = require('@fayz-ai/core');
|
|
@@ -1726,14 +1726,14 @@ function AppointmentModal({ open, mode, bookingId, prefill, initialTab, external
|
|
|
1726
1726
|
onCancel: () => setClientEditing(false),
|
|
1727
1727
|
onSubmit: async (data) => {
|
|
1728
1728
|
try {
|
|
1729
|
-
const { getSupabaseClientOptional:
|
|
1730
|
-
const supabase =
|
|
1729
|
+
const { getSupabaseClientOptional: getSupabaseClientOptional4 } = await import('@fayz-ai/core');
|
|
1730
|
+
const supabase = getSupabaseClientOptional4();
|
|
1731
1731
|
if (!supabase) return;
|
|
1732
1732
|
const dbData = {};
|
|
1733
1733
|
for (const [k, v] of Object.entries(data)) {
|
|
1734
1734
|
dbData[k.replace(/[A-Z]/g, (m) => "_" + m.toLowerCase())] = v;
|
|
1735
1735
|
}
|
|
1736
|
-
await supabase.
|
|
1736
|
+
await supabase.from("people").update(dbData).eq("id", clientId);
|
|
1737
1737
|
setClientData({ ...clientData, ...data });
|
|
1738
1738
|
setClientEditing(false);
|
|
1739
1739
|
} catch {
|
|
@@ -1888,12 +1888,12 @@ function AppointmentModal({ open, mode, bookingId, prefill, initialTab, external
|
|
|
1888
1888
|
onClick: async () => {
|
|
1889
1889
|
setCreatingClient(true);
|
|
1890
1890
|
try {
|
|
1891
|
-
const { getSupabaseClientOptional:
|
|
1892
|
-
const { getAgendaTenantId } = await import('./tenant-222EHYUC.cjs');
|
|
1893
|
-
const supabase =
|
|
1894
|
-
const tenantId =
|
|
1891
|
+
const { getSupabaseClientOptional: getSupabaseClientOptional4 } = await import('@fayz-ai/core');
|
|
1892
|
+
const { getAgendaTenantId: getAgendaTenantId2 } = await import('./tenant-222EHYUC.cjs');
|
|
1893
|
+
const supabase = getSupabaseClientOptional4();
|
|
1894
|
+
const tenantId = getAgendaTenantId2();
|
|
1895
1895
|
if (!supabase || !tenantId) throw new Error("Not initialized");
|
|
1896
|
-
const { data: personRow, error: personError } = await supabase.
|
|
1896
|
+
const { data: personRow, error: personError } = await supabase.from("people").insert({
|
|
1897
1897
|
tenant_id: tenantId,
|
|
1898
1898
|
kind: config.clientKind,
|
|
1899
1899
|
name: newClientName.trim(),
|
|
@@ -1906,7 +1906,7 @@ function AppointmentModal({ open, mode, bookingId, prefill, initialTab, external
|
|
|
1906
1906
|
tenant_id: tenantId
|
|
1907
1907
|
});
|
|
1908
1908
|
if (clientError) {
|
|
1909
|
-
await supabase.
|
|
1909
|
+
await supabase.from("people").delete().eq("id", personRow.id);
|
|
1910
1910
|
throw clientError;
|
|
1911
1911
|
}
|
|
1912
1912
|
setClientId(personRow.id);
|
|
@@ -3473,6 +3473,448 @@ function AgendaPage({ config, provider: provider2, store }) {
|
|
|
3473
3473
|
}, [tenantId]);
|
|
3474
3474
|
return /* @__PURE__ */ jsxRuntime.jsx(AgendaContextProvider, { config, provider: provider2, store, children: /* @__PURE__ */ jsxRuntime.jsx(CalendarView, {}) });
|
|
3475
3475
|
}
|
|
3476
|
+
function getTenantId() {
|
|
3477
|
+
return chunkSYDBDI3Q_cjs.getAgendaTenantId() ?? core.getActiveTenantId();
|
|
3478
|
+
}
|
|
3479
|
+
function snakeToCamel(obj) {
|
|
3480
|
+
const result = {};
|
|
3481
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
3482
|
+
result[key.replace(/_([a-z])/g, (_, c) => c.toUpperCase())] = value;
|
|
3483
|
+
}
|
|
3484
|
+
return result;
|
|
3485
|
+
}
|
|
3486
|
+
function getClients() {
|
|
3487
|
+
const supabase = core.getSupabaseClientOptional();
|
|
3488
|
+
if (!supabase) throw new Error("Supabase not initialized");
|
|
3489
|
+
return { core: supabase, pub: supabase };
|
|
3490
|
+
}
|
|
3491
|
+
function mapBooking(row) {
|
|
3492
|
+
return snakeToCamel(row);
|
|
3493
|
+
}
|
|
3494
|
+
function mapSchedule(row) {
|
|
3495
|
+
return snakeToCamel(row);
|
|
3496
|
+
}
|
|
3497
|
+
function addMinutes(iso, minutes) {
|
|
3498
|
+
const d = new Date(iso);
|
|
3499
|
+
d.setMinutes(d.getMinutes() + minutes);
|
|
3500
|
+
return d.toISOString();
|
|
3501
|
+
}
|
|
3502
|
+
function unique(values) {
|
|
3503
|
+
return Array.from(new Set(values.filter((value) => !!value)));
|
|
3504
|
+
}
|
|
3505
|
+
function indexById(rows) {
|
|
3506
|
+
return new Map((rows ?? []).filter((row) => row?.id).map((row) => [row.id, row]));
|
|
3507
|
+
}
|
|
3508
|
+
function groupBy(rows, getKey) {
|
|
3509
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
3510
|
+
for (const row of rows) {
|
|
3511
|
+
const key = getKey(row);
|
|
3512
|
+
if (!key) continue;
|
|
3513
|
+
grouped.set(key, [...grouped.get(key) ?? [], row]);
|
|
3514
|
+
}
|
|
3515
|
+
return grouped;
|
|
3516
|
+
}
|
|
3517
|
+
function getDurationMinutes(startsAt, endsAt) {
|
|
3518
|
+
if (!endsAt) return null;
|
|
3519
|
+
return Math.round((new Date(endsAt).getTime() - new Date(startsAt).getTime()) / 6e4);
|
|
3520
|
+
}
|
|
3521
|
+
function mergeBookings(viewBookings, canonicalBookings) {
|
|
3522
|
+
const canonicalIds = new Set(canonicalBookings.map((booking) => booking.id));
|
|
3523
|
+
const canonicalOrderIds = new Set(canonicalBookings.map((booking) => booking.orderId).filter(Boolean));
|
|
3524
|
+
const merged = [
|
|
3525
|
+
...canonicalBookings,
|
|
3526
|
+
...viewBookings.filter((booking) => {
|
|
3527
|
+
if (canonicalIds.has(booking.id)) return false;
|
|
3528
|
+
if (booking.orderId && canonicalOrderIds.has(booking.orderId)) return false;
|
|
3529
|
+
if (canonicalOrderIds.has(booking.id)) return false;
|
|
3530
|
+
return true;
|
|
3531
|
+
})
|
|
3532
|
+
];
|
|
3533
|
+
return merged.sort((a, b) => new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime());
|
|
3534
|
+
}
|
|
3535
|
+
async function hydrateCanonicalBookings(core, bookings) {
|
|
3536
|
+
const personIds = unique(bookings.flatMap((booking) => [booking.party_id, booking.assignee_id]));
|
|
3537
|
+
const locationIds = unique(bookings.map((booking) => booking.location_id));
|
|
3538
|
+
const orderIds = unique(bookings.map((booking) => booking.order_id));
|
|
3539
|
+
unique(bookings.map((booking) => booking.id));
|
|
3540
|
+
const [
|
|
3541
|
+
{ data: persons, error: personsError },
|
|
3542
|
+
{ data: locations, error: locationsError },
|
|
3543
|
+
{ data: orders, error: ordersError },
|
|
3544
|
+
{ data: items, error: itemsError }
|
|
3545
|
+
] = await Promise.all([
|
|
3546
|
+
personIds.length ? core.from("people").select("id, name, phone, email, avatar_url").in("id", personIds) : Promise.resolve({ data: [], error: null }),
|
|
3547
|
+
locationIds.length ? core.from("locations").select("id, name").in("id", locationIds) : Promise.resolve({ data: [], error: null }),
|
|
3548
|
+
orderIds.length ? core.from("orders").select("id, total, status").in("id", orderIds) : Promise.resolve({ data: [], error: null }),
|
|
3549
|
+
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 })
|
|
3550
|
+
]);
|
|
3551
|
+
if (personsError) throw personsError;
|
|
3552
|
+
if (locationsError) throw locationsError;
|
|
3553
|
+
if (ordersError) throw ordersError;
|
|
3554
|
+
if (itemsError) throw itemsError;
|
|
3555
|
+
const personsById = indexById(persons);
|
|
3556
|
+
const locationsById = indexById(locations);
|
|
3557
|
+
const ordersById = indexById(orders);
|
|
3558
|
+
const itemsByOrderId = groupBy(items ?? [], (item) => item.order_id);
|
|
3559
|
+
return bookings.map((booking) => {
|
|
3560
|
+
const client = booking.party_id ? personsById.get(booking.party_id) : null;
|
|
3561
|
+
const professional = booking.assignee_id ? personsById.get(booking.assignee_id) : null;
|
|
3562
|
+
const location = booking.location_id ? locationsById.get(booking.location_id) : null;
|
|
3563
|
+
const order = booking.order_id ? ordersById.get(booking.order_id) : null;
|
|
3564
|
+
const bookingItems = ((booking.order_id ? itemsByOrderId.get(booking.order_id) : null) ?? []).sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)).map((item) => ({
|
|
3565
|
+
id: item.id,
|
|
3566
|
+
serviceId: item.service_id,
|
|
3567
|
+
name: item.name,
|
|
3568
|
+
durationMinutes: item.duration_minutes,
|
|
3569
|
+
price: item.unit_price,
|
|
3570
|
+
assigneeId: item.assignee_id
|
|
3571
|
+
}));
|
|
3572
|
+
const itemDuration = bookingItems.reduce((sum, item) => sum + (item.durationMinutes ?? 0), 0);
|
|
3573
|
+
return {
|
|
3574
|
+
id: booking.id,
|
|
3575
|
+
tenantId: booking.tenant_id,
|
|
3576
|
+
kind: booking.kind,
|
|
3577
|
+
startsAt: booking.starts_at,
|
|
3578
|
+
endsAt: booking.ends_at,
|
|
3579
|
+
status: booking.status,
|
|
3580
|
+
notes: booking.notes,
|
|
3581
|
+
orderId: booking.order_id,
|
|
3582
|
+
locationId: booking.location_id,
|
|
3583
|
+
metadata: booking.metadata ?? {},
|
|
3584
|
+
clientId: booking.party_id,
|
|
3585
|
+
// Simple events (no party) fall back to the title stored in metadata.
|
|
3586
|
+
clientName: client?.name ?? booking.metadata?.title ?? null,
|
|
3587
|
+
clientPhone: client?.phone ?? null,
|
|
3588
|
+
clientEmail: client?.email ?? null,
|
|
3589
|
+
clientAvatarUrl: client?.avatar_url ?? null,
|
|
3590
|
+
professionalId: booking.assignee_id,
|
|
3591
|
+
professionalName: professional?.name ?? null,
|
|
3592
|
+
professionalAvatarUrl: professional?.avatar_url ?? null,
|
|
3593
|
+
locationName: location?.name ?? null,
|
|
3594
|
+
orderTotal: order?.total ?? null,
|
|
3595
|
+
orderStatus: order?.status ?? null,
|
|
3596
|
+
services: bookingItems.length ? bookingItems : null,
|
|
3597
|
+
totalDurationMinutes: itemDuration || getDurationMinutes(booking.starts_at, booking.ends_at),
|
|
3598
|
+
createdAt: booking.created_at,
|
|
3599
|
+
updatedAt: booking.updated_at
|
|
3600
|
+
};
|
|
3601
|
+
});
|
|
3602
|
+
}
|
|
3603
|
+
async function getCanonicalBookings(core, query) {
|
|
3604
|
+
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);
|
|
3605
|
+
if (query.kind) q = q.eq("kind", query.kind);
|
|
3606
|
+
if (query.statuses?.length) q = q.in("status", query.statuses);
|
|
3607
|
+
if (query.professionalIds?.length) q = q.in("assignee_id", query.professionalIds);
|
|
3608
|
+
if (query.locationId) q = q.eq("location_id", query.locationId);
|
|
3609
|
+
const { data, error } = await q.order("starts_at", { ascending: true });
|
|
3610
|
+
if (error) throw error;
|
|
3611
|
+
return hydrateCanonicalBookings(core, data ?? []);
|
|
3612
|
+
}
|
|
3613
|
+
async function getCanonicalBookingById(core, id) {
|
|
3614
|
+
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";
|
|
3615
|
+
let { data, error } = await core.from("appointments").select(columns).eq("id", id).maybeSingle();
|
|
3616
|
+
if (error) throw error;
|
|
3617
|
+
if (!data) {
|
|
3618
|
+
const fallback = await core.from("appointments").select(columns).eq("order_id", id).maybeSingle();
|
|
3619
|
+
if (fallback.error) throw fallback.error;
|
|
3620
|
+
data = fallback.data;
|
|
3621
|
+
}
|
|
3622
|
+
if (!data) return null;
|
|
3623
|
+
const [booking] = await hydrateCanonicalBookings(core, [data]);
|
|
3624
|
+
return booking ?? null;
|
|
3625
|
+
}
|
|
3626
|
+
function createSupabaseAgendaProvider() {
|
|
3627
|
+
return {
|
|
3628
|
+
// v_appointments is in public schema (PostgREST default)
|
|
3629
|
+
async getBookings(query) {
|
|
3630
|
+
const { core, pub } = getClients();
|
|
3631
|
+
let q = pub.from("v_appointments").select("*").gte("starts_at", query.dateRange.start).lte("starts_at", query.dateRange.end);
|
|
3632
|
+
if (query.kind) q = q.eq("kind", query.kind);
|
|
3633
|
+
if (query.statuses?.length) q = q.in("status", query.statuses);
|
|
3634
|
+
if (query.professionalIds?.length) q = q.in("professional_id", query.professionalIds);
|
|
3635
|
+
if (query.locationId) q = q.eq("location_id", query.locationId);
|
|
3636
|
+
q = q.order("starts_at", { ascending: true });
|
|
3637
|
+
const { data, error } = await q;
|
|
3638
|
+
if (error) throw error;
|
|
3639
|
+
const viewBookings = (data ?? []).map(mapBooking);
|
|
3640
|
+
const canonicalBookings = await getCanonicalBookings(core, query);
|
|
3641
|
+
return mergeBookings(viewBookings, canonicalBookings);
|
|
3642
|
+
},
|
|
3643
|
+
async getBookingById(id) {
|
|
3644
|
+
const { core, pub } = getClients();
|
|
3645
|
+
const { data, error } = await pub.from("v_appointments").select("*").eq("id", id).maybeSingle();
|
|
3646
|
+
if (error) throw error;
|
|
3647
|
+
if (data) return mapBooking(data);
|
|
3648
|
+
return getCanonicalBookingById(core, id);
|
|
3649
|
+
},
|
|
3650
|
+
async createBooking(input) {
|
|
3651
|
+
const { core } = getClients();
|
|
3652
|
+
const tenantId = getTenantId();
|
|
3653
|
+
if (!tenantId) throw new Error("No active tenant");
|
|
3654
|
+
const hasServices = input.services.length > 0;
|
|
3655
|
+
const totalDuration = input.services.reduce((sum, s) => sum + s.durationMinutes, 0);
|
|
3656
|
+
const totalPrice = input.services.reduce((sum, s) => sum + s.price, 0);
|
|
3657
|
+
const endsAt = addMinutes(input.startsAt, totalDuration || 30);
|
|
3658
|
+
const serviceNames = input.services.map((s) => s.name).join(", ");
|
|
3659
|
+
let contactName;
|
|
3660
|
+
if (input.clientId) {
|
|
3661
|
+
const { data: person } = await core.from("people").select("name").eq("id", input.clientId).single();
|
|
3662
|
+
contactName = person?.name;
|
|
3663
|
+
}
|
|
3664
|
+
const eventTitle = input.title?.trim() || void 0;
|
|
3665
|
+
const { data: order, error: orderErr } = await core.from("orders").insert({
|
|
3666
|
+
tenant_id: tenantId,
|
|
3667
|
+
kind: input.kind ?? "appointment",
|
|
3668
|
+
status: "scheduled",
|
|
3669
|
+
party_id: input.clientId || null,
|
|
3670
|
+
assignee_id: input.professionalId,
|
|
3671
|
+
location_id: input.locationId ?? null,
|
|
3672
|
+
subtotal: totalPrice,
|
|
3673
|
+
total: totalPrice,
|
|
3674
|
+
notes: input.notes ?? null,
|
|
3675
|
+
metadata: {
|
|
3676
|
+
source: "agenda",
|
|
3677
|
+
...serviceNames ? { serviceNames, itemsSummary: serviceNames } : {},
|
|
3678
|
+
...contactName ? { contactName } : {},
|
|
3679
|
+
...eventTitle ? { title: eventTitle } : {}
|
|
3680
|
+
}
|
|
3681
|
+
}).select("id").single();
|
|
3682
|
+
if (orderErr) throw orderErr;
|
|
3683
|
+
const { data: booking, error: bookingErr } = await core.from("appointments").insert({
|
|
3684
|
+
tenant_id: tenantId,
|
|
3685
|
+
kind: input.kind ?? "appointment",
|
|
3686
|
+
party_id: input.clientId || null,
|
|
3687
|
+
assignee_id: input.professionalId,
|
|
3688
|
+
location_id: input.locationId ?? null,
|
|
3689
|
+
order_id: order.id,
|
|
3690
|
+
starts_at: input.startsAt,
|
|
3691
|
+
ends_at: endsAt,
|
|
3692
|
+
status: "scheduled",
|
|
3693
|
+
notes: input.notes ?? null,
|
|
3694
|
+
metadata: {
|
|
3695
|
+
source: "agenda",
|
|
3696
|
+
...serviceNames ? { serviceNames, itemsSummary: serviceNames } : {},
|
|
3697
|
+
...contactName ? { contactName } : {},
|
|
3698
|
+
...eventTitle ? { title: eventTitle } : {}
|
|
3699
|
+
}
|
|
3700
|
+
}).select("id").single();
|
|
3701
|
+
if (bookingErr) throw bookingErr;
|
|
3702
|
+
if (hasServices) {
|
|
3703
|
+
const orderItems = input.services.map((s, i) => ({
|
|
3704
|
+
order_id: order.id,
|
|
3705
|
+
service_id: s.serviceId,
|
|
3706
|
+
name: s.name,
|
|
3707
|
+
quantity: 1,
|
|
3708
|
+
unit_price: s.price,
|
|
3709
|
+
total: s.price,
|
|
3710
|
+
sort_order: i,
|
|
3711
|
+
duration_minutes: s.durationMinutes,
|
|
3712
|
+
assignee_id: s.assigneeId ?? input.professionalId
|
|
3713
|
+
}));
|
|
3714
|
+
const { error: oiErr } = await core.from("order_items").insert(orderItems);
|
|
3715
|
+
if (oiErr) throw oiErr;
|
|
3716
|
+
}
|
|
3717
|
+
const createdBooking = await this.getBookingById(booking.id);
|
|
3718
|
+
if (!createdBooking) throw new Error("Created booking did not resolve from agenda read model");
|
|
3719
|
+
return createdBooking;
|
|
3720
|
+
},
|
|
3721
|
+
async updateBooking(id, data) {
|
|
3722
|
+
const { core } = getClients();
|
|
3723
|
+
const { data: existing, error: existingErr } = await core.from("appointments").select("id, order_id, starts_at, assignee_id, metadata").eq("id", id).single();
|
|
3724
|
+
if (existingErr) throw existingErr;
|
|
3725
|
+
const updates = {};
|
|
3726
|
+
if (data.startsAt) updates.starts_at = data.startsAt;
|
|
3727
|
+
if (data.endsAt) updates.ends_at = data.endsAt;
|
|
3728
|
+
if (data.title !== void 0) {
|
|
3729
|
+
updates.metadata = { ...existing?.metadata ?? {}, title: data.title };
|
|
3730
|
+
}
|
|
3731
|
+
if (data.status) {
|
|
3732
|
+
updates.status = data.status;
|
|
3733
|
+
}
|
|
3734
|
+
if (data.notes !== void 0) updates.notes = data.notes;
|
|
3735
|
+
if (data.professionalId) updates.assignee_id = data.professionalId;
|
|
3736
|
+
if (data.clientId) updates.party_id = data.clientId;
|
|
3737
|
+
if (data.locationId) updates.location_id = data.locationId;
|
|
3738
|
+
if (data.services) {
|
|
3739
|
+
const totalDuration = data.services.reduce((s, svc) => s + svc.durationMinutes, 0);
|
|
3740
|
+
data.services.reduce((s, svc) => s + svc.price, 0);
|
|
3741
|
+
const startsAt = data.startsAt ?? existing?.starts_at;
|
|
3742
|
+
if (startsAt) {
|
|
3743
|
+
const ends = new Date(new Date(startsAt).getTime() + totalDuration * 6e4);
|
|
3744
|
+
updates.ends_at = ends.toISOString();
|
|
3745
|
+
}
|
|
3746
|
+
}
|
|
3747
|
+
const effectiveAssigneeId = data.professionalId ?? existing?.assignee_id ?? null;
|
|
3748
|
+
const { error } = await core.from("appointments").update(updates).eq("id", id);
|
|
3749
|
+
if (error) throw error;
|
|
3750
|
+
const orderId = existing?.order_id;
|
|
3751
|
+
if (orderId) {
|
|
3752
|
+
const orderUpdates = {};
|
|
3753
|
+
if (data.notes !== void 0) orderUpdates.notes = data.notes;
|
|
3754
|
+
if (data.professionalId) orderUpdates.assignee_id = data.professionalId;
|
|
3755
|
+
if (data.clientId) orderUpdates.party_id = data.clientId;
|
|
3756
|
+
if (data.locationId) orderUpdates.location_id = data.locationId;
|
|
3757
|
+
if (data.services) {
|
|
3758
|
+
const totalPrice = data.services.reduce((s, svc) => s + svc.price, 0);
|
|
3759
|
+
orderUpdates.subtotal = totalPrice;
|
|
3760
|
+
orderUpdates.total = totalPrice;
|
|
3761
|
+
}
|
|
3762
|
+
if (Object.keys(orderUpdates).length > 0) {
|
|
3763
|
+
const { error: orderErr } = await core.from("orders").update(orderUpdates).eq("id", orderId);
|
|
3764
|
+
if (orderErr) throw orderErr;
|
|
3765
|
+
}
|
|
3766
|
+
if (data.services) {
|
|
3767
|
+
await core.from("order_items").delete().eq("order_id", orderId);
|
|
3768
|
+
if (data.services.length > 0) {
|
|
3769
|
+
const items = data.services.map((svc, idx) => ({
|
|
3770
|
+
order_id: orderId,
|
|
3771
|
+
service_id: svc.serviceId || null,
|
|
3772
|
+
name: svc.name,
|
|
3773
|
+
quantity: 1,
|
|
3774
|
+
unit_price: svc.price,
|
|
3775
|
+
total: svc.price,
|
|
3776
|
+
sort_order: idx,
|
|
3777
|
+
duration_minutes: svc.durationMinutes,
|
|
3778
|
+
assignee_id: svc.assigneeId ?? effectiveAssigneeId
|
|
3779
|
+
}));
|
|
3780
|
+
const { error: itemErr } = await core.from("order_items").insert(items);
|
|
3781
|
+
if (itemErr) throw itemErr;
|
|
3782
|
+
}
|
|
3783
|
+
}
|
|
3784
|
+
}
|
|
3785
|
+
return await this.getBookingById(id);
|
|
3786
|
+
},
|
|
3787
|
+
async deleteBooking(id) {
|
|
3788
|
+
const { core, pub } = getClients();
|
|
3789
|
+
const { data: booking, error: bookingErr } = await core.from("appointments").select("order_id").eq("id", id).single();
|
|
3790
|
+
if (bookingErr) throw bookingErr;
|
|
3791
|
+
const orderId = booking?.order_id;
|
|
3792
|
+
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 };
|
|
3793
|
+
if (movementErr) throw movementErr;
|
|
3794
|
+
if (orderId && (count ?? 0) > 0) {
|
|
3795
|
+
await pub.from("plg_financial_movements").update({ status: "cancelled" }).eq("invoice_id", orderId);
|
|
3796
|
+
await core.from("orders").update({ status: "cancelled" }).eq("id", orderId);
|
|
3797
|
+
const { error } = await core.from("appointments").update({ status: "cancelled" }).eq("id", id);
|
|
3798
|
+
if (error) throw error;
|
|
3799
|
+
} else {
|
|
3800
|
+
if (orderId) {
|
|
3801
|
+
await pub.from("plg_financial_movements").delete().eq("invoice_id", orderId);
|
|
3802
|
+
}
|
|
3803
|
+
const { error } = await core.from("appointments").delete().eq("id", id);
|
|
3804
|
+
if (error) throw error;
|
|
3805
|
+
if (orderId) {
|
|
3806
|
+
const { error: orderErr } = await core.from("orders").delete().eq("id", orderId);
|
|
3807
|
+
if (orderErr) throw orderErr;
|
|
3808
|
+
}
|
|
3809
|
+
}
|
|
3810
|
+
},
|
|
3811
|
+
async updateBookingStatus(id, status) {
|
|
3812
|
+
return this.updateBooking(id, { status });
|
|
3813
|
+
},
|
|
3814
|
+
async checkConflict(params) {
|
|
3815
|
+
const { core } = getClients();
|
|
3816
|
+
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);
|
|
3817
|
+
if (params.excludeBookingId) {
|
|
3818
|
+
q = q.neq("id", params.excludeBookingId);
|
|
3819
|
+
}
|
|
3820
|
+
const { count, error } = await q;
|
|
3821
|
+
if (error) throw error;
|
|
3822
|
+
return (count ?? 0) > 0;
|
|
3823
|
+
},
|
|
3824
|
+
async getAvailableSlots(params) {
|
|
3825
|
+
const { pub } = getClients();
|
|
3826
|
+
const { data, error } = await pub.rpc("get_available_slots", {
|
|
3827
|
+
p_tenant_id: getTenantId(),
|
|
3828
|
+
p_assignee_id: params.assigneeId,
|
|
3829
|
+
p_date: params.date,
|
|
3830
|
+
p_duration_minutes: params.durationMinutes,
|
|
3831
|
+
p_slot_interval: params.slotInterval ?? 30
|
|
3832
|
+
});
|
|
3833
|
+
if (error) throw error;
|
|
3834
|
+
return (data ?? []).map((row) => ({ start: row.slot_start, end: row.slot_end }));
|
|
3835
|
+
},
|
|
3836
|
+
async getSchedules(professionalId) {
|
|
3837
|
+
const { core } = getClients();
|
|
3838
|
+
let q = core.from("schedules").select("*").eq("kind", "working_hours");
|
|
3839
|
+
if (professionalId) q = q.eq("assignee_id", professionalId);
|
|
3840
|
+
q = q.order("day_of_week").order("starts_at");
|
|
3841
|
+
const { data, error } = await q;
|
|
3842
|
+
if (error) throw error;
|
|
3843
|
+
return (data ?? []).map(mapSchedule);
|
|
3844
|
+
},
|
|
3845
|
+
async saveSchedule(input) {
|
|
3846
|
+
const { core } = getClients();
|
|
3847
|
+
const tenantId = getTenantId();
|
|
3848
|
+
if (!tenantId) throw new Error("No active tenant");
|
|
3849
|
+
const { data, error } = await core.from("schedules").insert({
|
|
3850
|
+
tenant_id: tenantId,
|
|
3851
|
+
kind: "working_hours",
|
|
3852
|
+
assignee_id: input.assigneeId,
|
|
3853
|
+
location_id: input.locationId ?? null,
|
|
3854
|
+
day_of_week: input.dayOfWeek ?? null,
|
|
3855
|
+
specific_date: input.specificDate ?? null,
|
|
3856
|
+
starts_at: input.startsAt,
|
|
3857
|
+
ends_at: input.endsAt,
|
|
3858
|
+
is_active: input.isActive !== false,
|
|
3859
|
+
metadata: input.metadata ?? {}
|
|
3860
|
+
}).select("*").single();
|
|
3861
|
+
if (error) throw error;
|
|
3862
|
+
return mapSchedule(data);
|
|
3863
|
+
},
|
|
3864
|
+
async deleteSchedule(id) {
|
|
3865
|
+
const { core } = getClients();
|
|
3866
|
+
const { error } = await core.from("schedules").delete().eq("id", id);
|
|
3867
|
+
if (error) throw error;
|
|
3868
|
+
},
|
|
3869
|
+
async getProfessionals() {
|
|
3870
|
+
const { core } = getClients();
|
|
3871
|
+
const { data, error } = await core.from("people").select("id, name, avatar_url, is_active").eq("kind", "staff").eq("is_active", true).order("name");
|
|
3872
|
+
if (error) throw error;
|
|
3873
|
+
return (data ?? []).map((p) => ({
|
|
3874
|
+
id: p.id,
|
|
3875
|
+
name: p.name,
|
|
3876
|
+
avatarUrl: p.avatar_url,
|
|
3877
|
+
locationId: null,
|
|
3878
|
+
locationName: null,
|
|
3879
|
+
isActive: p.is_active
|
|
3880
|
+
}));
|
|
3881
|
+
},
|
|
3882
|
+
async getLocations() {
|
|
3883
|
+
const { core } = getClients();
|
|
3884
|
+
const { data, error } = await core.from("locations").select("id, name").eq("is_active", true).order("name");
|
|
3885
|
+
if (error) throw error;
|
|
3886
|
+
return (data ?? []).map((l) => ({ id: l.id, name: l.name }));
|
|
3887
|
+
},
|
|
3888
|
+
async getConfirmationsPending(daysAhead = 2) {
|
|
3889
|
+
const { pub } = getClients();
|
|
3890
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3891
|
+
const cutoff = /* @__PURE__ */ new Date();
|
|
3892
|
+
cutoff.setDate(cutoff.getDate() + daysAhead);
|
|
3893
|
+
const { data, error } = await pub.from("v_appointments").select("*").eq("status", "scheduled").gte("starts_at", now).lte("starts_at", cutoff.toISOString()).order("starts_at");
|
|
3894
|
+
if (error) throw error;
|
|
3895
|
+
return (data ?? []).map(mapBooking);
|
|
3896
|
+
},
|
|
3897
|
+
async sendConfirmation(bookingId, channel) {
|
|
3898
|
+
const { core } = getClients();
|
|
3899
|
+
const { data: booking } = await core.from("appointments").select("metadata").eq("id", bookingId).single();
|
|
3900
|
+
const metadata = booking?.metadata ?? {};
|
|
3901
|
+
const confirmations = metadata.confirmations ?? [];
|
|
3902
|
+
confirmations.push({ channel, sentAt: (/* @__PURE__ */ new Date()).toISOString(), status: "sent" });
|
|
3903
|
+
await core.from("appointments").update({ metadata: { ...metadata, confirmations } }).eq("id", bookingId);
|
|
3904
|
+
},
|
|
3905
|
+
async completeBooking(bookingId) {
|
|
3906
|
+
const { core } = getClients();
|
|
3907
|
+
const { data: existing, error: existingErr } = await core.from("appointments").select("order_id").eq("id", bookingId).single();
|
|
3908
|
+
if (existingErr) throw existingErr;
|
|
3909
|
+
const { error } = await core.from("appointments").update({ status: "completed" }).eq("id", bookingId);
|
|
3910
|
+
if (error) throw error;
|
|
3911
|
+
const orderId = existing?.order_id;
|
|
3912
|
+
const booking = await this.getBookingById(bookingId);
|
|
3913
|
+
if (!booking) throw new Error("Booking not found");
|
|
3914
|
+
return { booking, orderId: orderId ?? bookingId };
|
|
3915
|
+
}
|
|
3916
|
+
};
|
|
3917
|
+
}
|
|
3476
3918
|
function getCurrentBookingQuery(state, dateRange = state.dateRange) {
|
|
3477
3919
|
return {
|
|
3478
3920
|
dateRange,
|
|
@@ -3693,7 +4135,6 @@ var holidayEntity = {
|
|
|
3693
4135
|
],
|
|
3694
4136
|
data: {
|
|
3695
4137
|
table: "holidays",
|
|
3696
|
-
schema: "saas_core",
|
|
3697
4138
|
tenantScoped: true
|
|
3698
4139
|
}
|
|
3699
4140
|
};
|
|
@@ -4221,6 +4662,300 @@ var agendaLocales = {
|
|
|
4221
4662
|
en,
|
|
4222
4663
|
"pt-BR": ptBR
|
|
4223
4664
|
};
|
|
4665
|
+
|
|
4666
|
+
// src/migrations/index.ts
|
|
4667
|
+
var MIGRATION_001_PUBLIC_BOOKING = `-- ============================================================================
|
|
4668
|
+
-- plugin-agenda 001: booking read model + public (anon) booking surface
|
|
4669
|
+
--
|
|
4670
|
+
-- Ships the objects the agenda providers reference but the spine (packages/db
|
|
4671
|
+
-- migrations 001\u2013010) does not define:
|
|
4672
|
+
-- \xA70 converted-pool cleanup \u2014 drop the pre-rename v_bookings view / indexes
|
|
4673
|
+
-- \xA71 order_items columns the admin provider writes (duration/assignee)
|
|
4674
|
+
-- \xA72 public.v_appointments \u2014 admin read model (authenticated, RLS applies)
|
|
4675
|
+
-- \xA73 public.v_public_services \u2014 anon-readable service catalog
|
|
4676
|
+
-- \xA74 public.get_available_slots \u2014 anon RPC (mirrors the mock algorithm)
|
|
4677
|
+
-- \xA75 public.create_public_booking \u2014 anon RPC (validated, race-safe write)
|
|
4678
|
+
--
|
|
4679
|
+
-- Core v1 lives directly in PUBLIC (no separate core schema): people (was
|
|
4680
|
+
-- persons), appointments (was bookings). This migration is written against public
|
|
4681
|
+
-- names and is idempotent + safe on both fresh and converted pools.
|
|
4682
|
+
--
|
|
4683
|
+
-- Anon-access design: public booking pages run with the publishable key and no
|
|
4684
|
+
-- session, so the canonical tenant RLS yields nothing. Anon access is granted
|
|
4685
|
+
-- ONLY via the column-whitelisted view (\xA73) and SECURITY DEFINER RPCs (\xA74, \xA75)
|
|
4686
|
+
-- with tight input validation and hardcoded statuses.
|
|
4687
|
+
-- ============================================================================
|
|
4688
|
+
|
|
4689
|
+
-- \xA70 \u2014 converted-pool cleanup. The pre-industry-pool shape shipped a v_bookings
|
|
4690
|
+
-- view and persons_/bookings_-named indexes; drop them so this file re-emits the
|
|
4691
|
+
-- canonical v_appointments view + people_/appointments_ indexes without leaving
|
|
4692
|
+
-- duplicates. All guarded with IF EXISTS \u2192 no-ops on a fresh pool.
|
|
4693
|
+
DROP VIEW IF EXISTS public.v_bookings;
|
|
4694
|
+
DROP INDEX IF EXISTS public.persons_tenant_customer_phone;
|
|
4695
|
+
DROP INDEX IF EXISTS public.bookings_assignee_time;
|
|
4696
|
+
|
|
4697
|
+
-- \xA71 \u2014 columns the agenda provider writes but 004_archetypes lacks
|
|
4698
|
+
ALTER TABLE public.order_items
|
|
4699
|
+
ADD COLUMN IF NOT EXISTS duration_minutes integer,
|
|
4700
|
+
ADD COLUMN IF NOT EXISTS assignee_id uuid REFERENCES public.people(id) ON DELETE SET NULL;
|
|
4701
|
+
|
|
4702
|
+
-- Upsert key for public customers (per tenant, by normalized phone)
|
|
4703
|
+
CREATE UNIQUE INDEX IF NOT EXISTS people_tenant_customer_phone
|
|
4704
|
+
ON public.people(tenant_id, phone)
|
|
4705
|
+
WHERE kind = 'customer' AND phone IS NOT NULL;
|
|
4706
|
+
|
|
4707
|
+
-- Slot-conflict scan
|
|
4708
|
+
CREATE INDEX IF NOT EXISTS appointments_assignee_time
|
|
4709
|
+
ON public.appointments(assignee_id, starts_at, ends_at)
|
|
4710
|
+
WHERE status NOT IN ('cancelled', 'no_show');
|
|
4711
|
+
|
|
4712
|
+
-- \xA72 \u2014 admin read model (security_invoker \u2192 underlying RLS applies; anon sees 0 rows)
|
|
4713
|
+
CREATE OR REPLACE VIEW public.v_appointments
|
|
4714
|
+
WITH (security_invoker = true) AS
|
|
4715
|
+
SELECT
|
|
4716
|
+
b.id, b.tenant_id, b.kind, b.starts_at, b.ends_at, b.status, b.notes,
|
|
4717
|
+
b.order_id, b.location_id, b.metadata, b.created_at, b.updated_at,
|
|
4718
|
+
b.party_id AS client_id,
|
|
4719
|
+
COALESCE(pc.name, b.metadata->>'title') AS client_name,
|
|
4720
|
+
pc.phone AS client_phone,
|
|
4721
|
+
pc.email AS client_email,
|
|
4722
|
+
pc.avatar_url AS client_avatar_url,
|
|
4723
|
+
b.assignee_id AS professional_id,
|
|
4724
|
+
ps.name AS professional_name,
|
|
4725
|
+
ps.avatar_url AS professional_avatar_url,
|
|
4726
|
+
l.name AS location_name,
|
|
4727
|
+
o.total AS order_total,
|
|
4728
|
+
o.status AS order_status,
|
|
4729
|
+
(EXTRACT(EPOCH FROM (COALESCE(b.ends_at, b.starts_at) - b.starts_at))::int / 60)
|
|
4730
|
+
AS total_duration_minutes
|
|
4731
|
+
FROM public.appointments b
|
|
4732
|
+
LEFT JOIN public.people pc ON pc.id = b.party_id
|
|
4733
|
+
LEFT JOIN public.people ps ON ps.id = b.assignee_id
|
|
4734
|
+
LEFT JOIN public.locations l ON l.id = b.location_id
|
|
4735
|
+
LEFT JOIN public.orders o ON o.id = b.order_id;
|
|
4736
|
+
|
|
4737
|
+
GRANT SELECT ON public.v_appointments TO authenticated, service_role;
|
|
4738
|
+
REVOKE SELECT ON public.v_appointments FROM anon;
|
|
4739
|
+
|
|
4740
|
+
-- \xA73 \u2014 anon-readable public catalog (owner-rights view = deliberate, whitelisted)
|
|
4741
|
+
CREATE OR REPLACE VIEW public.v_public_services AS
|
|
4742
|
+
SELECT
|
|
4743
|
+
s.id, s.tenant_id, s.name, s.description, s.price, s.currency,
|
|
4744
|
+
COALESCE(s.duration_minutes, 30) AS duration_minutes,
|
|
4745
|
+
s.image_url,
|
|
4746
|
+
COALESCE((s.metadata->>'sort_order')::int, 0) AS sort_order
|
|
4747
|
+
FROM public.services s
|
|
4748
|
+
WHERE s.is_active AND s.status = 'active';
|
|
4749
|
+
|
|
4750
|
+
GRANT SELECT ON public.v_public_services TO anon, authenticated, service_role;
|
|
4751
|
+
|
|
4752
|
+
-- \xA74 \u2014 availability RPC (mirrors plugin-agenda/src/data/mock.ts getAvailableSlots)
|
|
4753
|
+
CREATE OR REPLACE FUNCTION public.get_available_slots(
|
|
4754
|
+
p_tenant_id uuid,
|
|
4755
|
+
p_date date,
|
|
4756
|
+
p_duration_minutes integer,
|
|
4757
|
+
p_assignee_id uuid DEFAULT NULL,
|
|
4758
|
+
p_slot_interval integer DEFAULT 30
|
|
4759
|
+
) RETURNS TABLE (slot_start timestamptz, slot_end timestamptz)
|
|
4760
|
+
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
|
4761
|
+
SET search_path = public
|
|
4762
|
+
AS $$
|
|
4763
|
+
DECLARE
|
|
4764
|
+
v_tz text;
|
|
4765
|
+
BEGIN
|
|
4766
|
+
-- tight validation: this function is anon-callable
|
|
4767
|
+
IF p_duration_minutes IS NULL OR p_duration_minutes NOT BETWEEN 5 AND 480 THEN
|
|
4768
|
+
RAISE EXCEPTION 'invalid duration';
|
|
4769
|
+
END IF;
|
|
4770
|
+
IF p_slot_interval IS NULL OR p_slot_interval NOT BETWEEN 5 AND 240 THEN
|
|
4771
|
+
RAISE EXCEPTION 'invalid interval';
|
|
4772
|
+
END IF;
|
|
4773
|
+
IF p_date IS NULL OR p_date < current_date OR p_date > current_date + 90 THEN
|
|
4774
|
+
RETURN;
|
|
4775
|
+
END IF;
|
|
4776
|
+
|
|
4777
|
+
SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
|
|
4778
|
+
FROM tenants t WHERE t.id = p_tenant_id;
|
|
4779
|
+
IF v_tz IS NULL THEN RETURN; END IF; -- unknown tenant \u2192 empty, not an error
|
|
4780
|
+
|
|
4781
|
+
RETURN QUERY
|
|
4782
|
+
WITH day_schedules AS (
|
|
4783
|
+
SELECT sc.assignee_id, sc.starts_at AS win_start, sc.ends_at AS win_end
|
|
4784
|
+
FROM schedules sc
|
|
4785
|
+
WHERE sc.tenant_id = p_tenant_id
|
|
4786
|
+
AND sc.kind = 'working_hours'
|
|
4787
|
+
AND sc.is_active
|
|
4788
|
+
AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)
|
|
4789
|
+
AND (
|
|
4790
|
+
sc.specific_date = p_date
|
|
4791
|
+
OR (sc.specific_date IS NULL AND sc.day_of_week = EXTRACT(dow FROM p_date)::smallint)
|
|
4792
|
+
)
|
|
4793
|
+
),
|
|
4794
|
+
candidate AS (
|
|
4795
|
+
SELECT
|
|
4796
|
+
ds.assignee_id,
|
|
4797
|
+
gs AS s_start,
|
|
4798
|
+
gs + make_interval(mins => p_duration_minutes) AS s_end
|
|
4799
|
+
FROM day_schedules ds
|
|
4800
|
+
CROSS JOIN LATERAL generate_series(
|
|
4801
|
+
(p_date + ds.win_start) AT TIME ZONE v_tz,
|
|
4802
|
+
((p_date + ds.win_end) AT TIME ZONE v_tz) - make_interval(mins => p_duration_minutes),
|
|
4803
|
+
make_interval(mins => p_slot_interval)
|
|
4804
|
+
) AS gs
|
|
4805
|
+
)
|
|
4806
|
+
SELECT DISTINCT c.s_start, c.s_end
|
|
4807
|
+
FROM candidate c
|
|
4808
|
+
WHERE NOT EXISTS (
|
|
4809
|
+
SELECT 1 FROM appointments b
|
|
4810
|
+
WHERE b.tenant_id = p_tenant_id
|
|
4811
|
+
AND b.assignee_id = c.assignee_id
|
|
4812
|
+
AND b.status NOT IN ('cancelled', 'no_show')
|
|
4813
|
+
AND b.starts_at < c.s_end
|
|
4814
|
+
AND COALESCE(b.ends_at, b.starts_at) > c.s_start
|
|
4815
|
+
)
|
|
4816
|
+
ORDER BY 1;
|
|
4817
|
+
END;
|
|
4818
|
+
$$;
|
|
4819
|
+
|
|
4820
|
+
REVOKE ALL ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer) FROM public;
|
|
4821
|
+
GRANT EXECUTE ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer)
|
|
4822
|
+
TO anon, authenticated, service_role;
|
|
4823
|
+
|
|
4824
|
+
-- \xA75 \u2014 public booking write RPC (validated, race-safe, hardcoded statuses)
|
|
4825
|
+
CREATE OR REPLACE FUNCTION public.create_public_booking(
|
|
4826
|
+
p_tenant_id uuid,
|
|
4827
|
+
p_service_id uuid,
|
|
4828
|
+
p_starts_at timestamptz,
|
|
4829
|
+
p_name text,
|
|
4830
|
+
p_phone text,
|
|
4831
|
+
p_email text DEFAULT NULL,
|
|
4832
|
+
p_notes text DEFAULT NULL
|
|
4833
|
+
) RETURNS TABLE (booking_id uuid, starts_at timestamptz, ends_at timestamptz)
|
|
4834
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
4835
|
+
SET search_path = public
|
|
4836
|
+
AS $$
|
|
4837
|
+
DECLARE
|
|
4838
|
+
v_service record;
|
|
4839
|
+
v_person_id uuid;
|
|
4840
|
+
v_assignee uuid;
|
|
4841
|
+
v_ends timestamptz;
|
|
4842
|
+
v_order uuid;
|
|
4843
|
+
v_booking uuid;
|
|
4844
|
+
v_phone text;
|
|
4845
|
+
v_tz text;
|
|
4846
|
+
v_notes text;
|
|
4847
|
+
BEGIN
|
|
4848
|
+
-- validation: anon-callable, everything tight
|
|
4849
|
+
IF p_name IS NULL OR length(trim(p_name)) NOT BETWEEN 2 AND 120 THEN
|
|
4850
|
+
RAISE EXCEPTION 'invalid name';
|
|
4851
|
+
END IF;
|
|
4852
|
+
v_phone := regexp_replace(COALESCE(p_phone, ''), '\\D', '', 'g');
|
|
4853
|
+
IF length(v_phone) NOT BETWEEN 8 AND 15 THEN
|
|
4854
|
+
RAISE EXCEPTION 'invalid phone';
|
|
4855
|
+
END IF;
|
|
4856
|
+
IF p_email IS NOT NULL AND (length(p_email) > 254 OR p_email !~ '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$') THEN
|
|
4857
|
+
RAISE EXCEPTION 'invalid email';
|
|
4858
|
+
END IF;
|
|
4859
|
+
v_notes := left(p_notes, 500);
|
|
4860
|
+
IF p_starts_at IS NULL OR p_starts_at <= now() OR p_starts_at > now() + interval '90 days' THEN
|
|
4861
|
+
RAISE EXCEPTION 'invalid start';
|
|
4862
|
+
END IF;
|
|
4863
|
+
|
|
4864
|
+
SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
|
|
4865
|
+
INTO v_service
|
|
4866
|
+
FROM services s
|
|
4867
|
+
WHERE s.id = p_service_id AND s.tenant_id = p_tenant_id
|
|
4868
|
+
AND s.is_active AND s.status = 'active';
|
|
4869
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'unknown service'; END IF;
|
|
4870
|
+
|
|
4871
|
+
v_ends := p_starts_at + make_interval(mins => v_service.duration_minutes);
|
|
4872
|
+
SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
|
|
4873
|
+
FROM tenants t WHERE t.id = p_tenant_id;
|
|
4874
|
+
IF v_tz IS NULL THEN RAISE EXCEPTION 'unknown tenant'; END IF;
|
|
4875
|
+
|
|
4876
|
+
-- pick the assignee whose working hours cover the slot
|
|
4877
|
+
SELECT sc.assignee_id INTO v_assignee
|
|
4878
|
+
FROM schedules sc
|
|
4879
|
+
WHERE sc.tenant_id = p_tenant_id
|
|
4880
|
+
AND sc.kind = 'working_hours'
|
|
4881
|
+
AND sc.is_active
|
|
4882
|
+
AND (
|
|
4883
|
+
sc.specific_date = (p_starts_at AT TIME ZONE v_tz)::date
|
|
4884
|
+
OR (sc.specific_date IS NULL
|
|
4885
|
+
AND sc.day_of_week = EXTRACT(dow FROM p_starts_at AT TIME ZONE v_tz)::smallint)
|
|
4886
|
+
)
|
|
4887
|
+
AND (p_starts_at AT TIME ZONE v_tz)::time >= sc.starts_at
|
|
4888
|
+
AND (v_ends AT TIME ZONE v_tz)::time <= sc.ends_at
|
|
4889
|
+
ORDER BY sc.assignee_id
|
|
4890
|
+
LIMIT 1;
|
|
4891
|
+
IF v_assignee IS NULL THEN RAISE EXCEPTION 'slot outside working hours'; END IF;
|
|
4892
|
+
|
|
4893
|
+
-- serialize per (tenant, assignee) to kill the double-book race
|
|
4894
|
+
PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_assignee::text, 0));
|
|
4895
|
+
|
|
4896
|
+
IF EXISTS (
|
|
4897
|
+
SELECT 1 FROM appointments b
|
|
4898
|
+
WHERE b.tenant_id = p_tenant_id
|
|
4899
|
+
AND b.assignee_id = v_assignee
|
|
4900
|
+
AND b.status NOT IN ('cancelled', 'no_show')
|
|
4901
|
+
AND b.starts_at < v_ends
|
|
4902
|
+
AND COALESCE(b.ends_at, b.starts_at) > p_starts_at
|
|
4903
|
+
) THEN
|
|
4904
|
+
RAISE EXCEPTION 'slot no longer available';
|
|
4905
|
+
END IF;
|
|
4906
|
+
|
|
4907
|
+
-- anti-spam: cap open future public bookings per phone per tenant
|
|
4908
|
+
IF (
|
|
4909
|
+
SELECT count(*) FROM appointments b
|
|
4910
|
+
JOIN people p ON p.id = b.party_id
|
|
4911
|
+
WHERE b.tenant_id = p_tenant_id
|
|
4912
|
+
AND p.phone = v_phone
|
|
4913
|
+
AND b.starts_at > now()
|
|
4914
|
+
AND b.status = 'scheduled'
|
|
4915
|
+
) >= 3 THEN
|
|
4916
|
+
RAISE EXCEPTION 'too many open bookings for this phone';
|
|
4917
|
+
END IF;
|
|
4918
|
+
|
|
4919
|
+
-- upsert the customer by (tenant, phone) \u2014 matches people_tenant_customer_phone
|
|
4920
|
+
INSERT INTO people (tenant_id, kind, name, phone, email, metadata)
|
|
4921
|
+
VALUES (p_tenant_id, 'customer', trim(p_name), v_phone, p_email,
|
|
4922
|
+
jsonb_build_object('source', 'public_booking'))
|
|
4923
|
+
ON CONFLICT (tenant_id, phone) WHERE kind = 'customer' AND phone IS NOT NULL
|
|
4924
|
+
DO UPDATE SET
|
|
4925
|
+
name = EXCLUDED.name,
|
|
4926
|
+
email = COALESCE(EXCLUDED.email, people.email),
|
|
4927
|
+
updated_at = now()
|
|
4928
|
+
RETURNING id INTO v_person_id;
|
|
4929
|
+
|
|
4930
|
+
-- order + booking + order_item (statuses hardcoded \u2014 caller cannot set)
|
|
4931
|
+
INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
|
|
4932
|
+
VALUES (p_tenant_id, 'appointment', 'scheduled', v_person_id, v_assignee,
|
|
4933
|
+
v_service.price, v_service.price, v_notes,
|
|
4934
|
+
jsonb_build_object('source', 'public_booking',
|
|
4935
|
+
'serviceNames', v_service.name,
|
|
4936
|
+
'contactName', trim(p_name)))
|
|
4937
|
+
RETURNING id INTO v_order;
|
|
4938
|
+
|
|
4939
|
+
INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
|
|
4940
|
+
starts_at, ends_at, status, notes, metadata)
|
|
4941
|
+
VALUES (p_tenant_id, 'appointment', v_person_id, v_assignee, v_order,
|
|
4942
|
+
p_starts_at, v_ends, 'scheduled', v_notes,
|
|
4943
|
+
jsonb_build_object('source', 'public_booking', 'serviceNames', v_service.name))
|
|
4944
|
+
RETURNING id INTO v_booking;
|
|
4945
|
+
|
|
4946
|
+
INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
|
|
4947
|
+
sort_order, duration_minutes, assignee_id)
|
|
4948
|
+
VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
|
|
4949
|
+
0, v_service.duration_minutes, v_assignee);
|
|
4950
|
+
|
|
4951
|
+
RETURN QUERY SELECT v_booking, p_starts_at, v_ends;
|
|
4952
|
+
END;
|
|
4953
|
+
$$;
|
|
4954
|
+
|
|
4955
|
+
REVOKE ALL ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text) FROM public;
|
|
4956
|
+
GRANT EXECUTE ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text)
|
|
4957
|
+
TO anon, authenticated, service_role;
|
|
4958
|
+
`;
|
|
4224
4959
|
function computeCommissionAmount(total, ratePercent) {
|
|
4225
4960
|
const base = Number(total) || 0;
|
|
4226
4961
|
const rate = Number(ratePercent) || 0;
|
|
@@ -4248,8 +4983,8 @@ function createFinancialBridge(provider2) {
|
|
|
4248
4983
|
if (!supabase) return result;
|
|
4249
4984
|
try {
|
|
4250
4985
|
const [{ data: orders }, { data: movements }] = await Promise.all([
|
|
4251
|
-
supabase.
|
|
4252
|
-
supabase.from("
|
|
4986
|
+
supabase.from("orders").select("id, kind, status, total, reference_number, metadata").in("id", orderIds),
|
|
4987
|
+
supabase.from("plg_financial_movements").select("invoice_id, status, paid_amount").in("invoice_id", orderIds)
|
|
4253
4988
|
]);
|
|
4254
4989
|
const movementsByOrderId = /* @__PURE__ */ new Map();
|
|
4255
4990
|
for (const movement of movements ?? []) {
|
|
@@ -4277,8 +5012,8 @@ function createFinancialBridge(provider2) {
|
|
|
4277
5012
|
const supabase = core.getSupabaseClientOptional();
|
|
4278
5013
|
if (!supabase) return null;
|
|
4279
5014
|
const [orderRes, movRes] = await Promise.all([
|
|
4280
|
-
supabase.
|
|
4281
|
-
supabase.from("
|
|
5015
|
+
supabase.from("orders").select("id, status, total, reference_number, metadata").eq("id", orderId).single(),
|
|
5016
|
+
supabase.from("plg_financial_movements").select("*").eq("invoice_id", orderId).order("due_date")
|
|
4282
5017
|
]);
|
|
4283
5018
|
const order = orderRes.data;
|
|
4284
5019
|
if (!order) return null;
|
|
@@ -4286,7 +5021,7 @@ function createFinancialBridge(provider2) {
|
|
|
4286
5021
|
const typeIds = [...new Set((movRes.data ?? []).map((m) => m.payment_method_type_id).filter(Boolean))];
|
|
4287
5022
|
let typeNames = {};
|
|
4288
5023
|
if (typeIds.length > 0) {
|
|
4289
|
-
const { data: types } = await supabase.from("
|
|
5024
|
+
const { data: types } = await supabase.from("plg_financial_payment_method_types").select("id, name").in("id", typeIds);
|
|
4290
5025
|
typeNames = Object.fromEntries((types ?? []).map((t) => [t.id, t.name]));
|
|
4291
5026
|
}
|
|
4292
5027
|
const movements = (movRes.data ?? []).map((m) => ({
|
|
@@ -4338,17 +5073,17 @@ function createFinancialBridge(provider2) {
|
|
|
4338
5073
|
async createCommissionMovement(orderId, options) {
|
|
4339
5074
|
const supabase = core.getSupabaseClientOptional();
|
|
4340
5075
|
if (!supabase) return null;
|
|
4341
|
-
const { data: order } = await supabase.
|
|
5076
|
+
const { data: order } = await supabase.from("orders").select("id, tenant_id, assignee_id, total").eq("id", orderId).single();
|
|
4342
5077
|
if (!order || !order.assignee_id) return null;
|
|
4343
5078
|
const { data: staff } = await supabase.from("v_staff").select("commission_rate").eq("id", order.assignee_id).single();
|
|
4344
5079
|
const rate = Number(staff?.commission_rate) || 0;
|
|
4345
5080
|
const base = Number(order.total) || 0;
|
|
4346
5081
|
const amount = computeCommissionAmount(base, rate);
|
|
4347
5082
|
if (amount <= 0) return null;
|
|
4348
|
-
const { data: existing } = await supabase.from("
|
|
5083
|
+
const { data: existing } = await supabase.from("plg_financial_movements").select("id").eq("invoice_id", orderId).eq("movement_kind", "commission").limit(1);
|
|
4349
5084
|
if (existing && existing.length > 0) return existing[0].id;
|
|
4350
5085
|
const dueDate = options?.dueDate ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
4351
|
-
const { data: inserted } = await supabase.from("
|
|
5086
|
+
const { data: inserted } = await supabase.from("plg_financial_movements").insert({
|
|
4352
5087
|
tenant_id: order.tenant_id,
|
|
4353
5088
|
invoice_id: orderId,
|
|
4354
5089
|
direction: "debit",
|
|
@@ -4635,8 +5370,8 @@ function createAgendaPlugin(options) {
|
|
|
4635
5370
|
const config = resolveConfig(options);
|
|
4636
5371
|
core.registerTranslations(agendaLocales);
|
|
4637
5372
|
const provider2 = options?.dataProvider ?? core.createSafeDataProvider(
|
|
4638
|
-
() =>
|
|
4639
|
-
() =>
|
|
5373
|
+
() => createSupabaseAgendaProvider(),
|
|
5374
|
+
() => chunkLFUELUIT_cjs.createMockAgendaProvider()
|
|
4640
5375
|
);
|
|
4641
5376
|
const store = createAgendaStore(provider2, options?.financialBridge);
|
|
4642
5377
|
const registries = [...agendaRegistries, ...options?.settingsRegistries ?? []];
|
|
@@ -4780,13 +5515,21 @@ function createAgendaPlugin(options) {
|
|
|
4780
5515
|
permission: { feature: "appointments", action: "read" }
|
|
4781
5516
|
}
|
|
4782
5517
|
],
|
|
5518
|
+
migrations: [
|
|
5519
|
+
{
|
|
5520
|
+
id: "agenda-001-public-booking",
|
|
5521
|
+
version: "0.3.0",
|
|
5522
|
+
sql: MIGRATION_001_PUBLIC_BOOKING,
|
|
5523
|
+
description: "Booking read model (v_appointments) + anon public booking surface (v_public_services, get_available_slots, create_public_booking) + order_items duration/assignee columns"
|
|
5524
|
+
}
|
|
5525
|
+
],
|
|
4783
5526
|
locales: agendaLocales
|
|
4784
5527
|
};
|
|
4785
5528
|
}
|
|
4786
5529
|
|
|
4787
5530
|
Object.defineProperty(exports, "createMockAgendaProvider", {
|
|
4788
5531
|
enumerable: true,
|
|
4789
|
-
get: function () { return
|
|
5532
|
+
get: function () { return chunkLFUELUIT_cjs.createMockAgendaProvider; }
|
|
4790
5533
|
});
|
|
4791
5534
|
exports.bookingToEvent = bookingToEvent2;
|
|
4792
5535
|
exports.computeCommissionAmount = computeCommissionAmount;
|