@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
@@ -28,7 +28,8 @@ function snakeToCamel(obj: Record<string, unknown>): Record<string, unknown> {
28
28
  function getClients() {
29
29
  const supabase = getSupabaseClientOptional() as any
30
30
  if (!supabase) throw new Error('Supabase not initialized')
31
- return { core: supabase.schema('saas_core'), pub: supabase }
31
+ // Core v1 lives in PUBLIC — a single client serves both core and view reads.
32
+ return { core: supabase, pub: supabase }
32
33
  }
33
34
 
34
35
  function mapBooking(row: Record<string, unknown>): CalendarBooking {
@@ -97,7 +98,7 @@ async function hydrateCanonicalBookings(core: any, bookings: any[]): Promise<Cal
97
98
  { data: items, error: itemsError },
98
99
  ] = await Promise.all([
99
100
  personIds.length
100
- ? core.from('persons').select('id, name, phone, email, avatar_url').in('id', personIds)
101
+ ? core.from('people').select('id, name, phone, email, avatar_url').in('id', personIds)
101
102
  : Promise.resolve({ data: [], error: null }),
102
103
  locationIds.length
103
104
  ? core.from('locations').select('id, name').in('id', locationIds)
@@ -170,7 +171,7 @@ async function hydrateCanonicalBookings(core: any, bookings: any[]): Promise<Cal
170
171
  }
171
172
 
172
173
  async function getCanonicalBookings(core: any, query: BookingQuery): Promise<CalendarBooking[]> {
173
- let q = core.from('bookings')
174
+ let q = core.from('appointments')
174
175
  .select('id, tenant_id, kind, starts_at, ends_at, status, notes, order_id, location_id, metadata, party_id, assignee_id, created_at, updated_at')
175
176
  .gte('starts_at', query.dateRange.start)
176
177
  .lte('starts_at', query.dateRange.end)
@@ -187,11 +188,11 @@ async function getCanonicalBookings(core: any, query: BookingQuery): Promise<Cal
187
188
 
188
189
  async function getCanonicalBookingById(core: any, id: string): Promise<CalendarBooking | null> {
189
190
  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'
190
- let { data, error } = await core.from('bookings').select(columns).eq('id', id).maybeSingle()
191
+ let { data, error } = await core.from('appointments').select(columns).eq('id', id).maybeSingle()
191
192
  if (error) throw error
192
193
 
193
194
  if (!data) {
194
- const fallback = await core.from('bookings').select(columns).eq('order_id', id).maybeSingle()
195
+ const fallback = await core.from('appointments').select(columns).eq('order_id', id).maybeSingle()
195
196
  if (fallback.error) throw fallback.error
196
197
  data = fallback.data
197
198
  }
@@ -207,10 +208,10 @@ async function getCanonicalBookingById(core: any, id: string): Promise<CalendarB
207
208
 
208
209
  export function createSupabaseAgendaProvider(): AgendaDataProvider {
209
210
  return {
210
- // v_bookings is in public schema (PostgREST default)
211
+ // v_appointments is in public schema (PostgREST default)
211
212
  async getBookings(query: BookingQuery): Promise<CalendarBooking[]> {
212
213
  const { core, pub } = getClients()
213
- let q = pub.from('v_bookings').select('*')
214
+ let q = pub.from('v_appointments').select('*')
214
215
  .gte('starts_at', query.dateRange.start)
215
216
  .lte('starts_at', query.dateRange.end)
216
217
 
@@ -230,7 +231,7 @@ export function createSupabaseAgendaProvider(): AgendaDataProvider {
230
231
 
231
232
  async getBookingById(id: string): Promise<CalendarBooking | null> {
232
233
  const { core, pub } = getClients()
233
- const { data, error } = await pub.from('v_bookings').select('*').eq('id', id).maybeSingle()
234
+ const { data, error } = await pub.from('v_appointments').select('*').eq('id', id).maybeSingle()
234
235
  if (error) throw error
235
236
  if (data) return mapBooking(data as Record<string, unknown>)
236
237
  return getCanonicalBookingById(core, id)
@@ -250,7 +251,7 @@ export function createSupabaseAgendaProvider(): AgendaDataProvider {
250
251
  // Resolve client name for metadata (used by financial detail view)
251
252
  let contactName: string | undefined
252
253
  if (input.clientId) {
253
- const { data: person } = await core.from('persons').select('name').eq('id', input.clientId).single()
254
+ const { data: person } = await core.from('people').select('name').eq('id', input.clientId).single()
254
255
  contactName = person?.name as string | undefined
255
256
  }
256
257
 
@@ -277,7 +278,7 @@ export function createSupabaseAgendaProvider(): AgendaDataProvider {
277
278
  }).select('id').single()
278
279
  if (orderErr) throw orderErr
279
280
 
280
- const { data: booking, error: bookingErr } = await core.from('bookings').insert({
281
+ const { data: booking, error: bookingErr } = await core.from('appointments').insert({
281
282
  tenant_id: tenantId,
282
283
  kind: input.kind ?? 'appointment',
283
284
  party_id: input.clientId || null,
@@ -321,7 +322,7 @@ export function createSupabaseAgendaProvider(): AgendaDataProvider {
321
322
 
322
323
  async updateBooking(id: string, data: UpdateBookingInput): Promise<CalendarBooking> {
323
324
  const { core } = getClients()
324
- const { data: existing, error: existingErr } = await core.from('bookings')
325
+ const { data: existing, error: existingErr } = await core.from('appointments')
325
326
  .select('id, order_id, starts_at, assignee_id, metadata')
326
327
  .eq('id', id)
327
328
  .single()
@@ -357,7 +358,7 @@ export function createSupabaseAgendaProvider(): AgendaDataProvider {
357
358
 
358
359
  const effectiveAssigneeId = data.professionalId ?? (existing?.assignee_id as string | null | undefined) ?? null
359
360
 
360
- const { error } = await core.from('bookings').update(updates).eq('id', id)
361
+ const { error } = await core.from('appointments').update(updates).eq('id', id)
361
362
  if (error) throw error
362
363
 
363
364
  // S4: services are updated only in order_items below (booking_items deprecated).
@@ -405,7 +406,7 @@ export function createSupabaseAgendaProvider(): AgendaDataProvider {
405
406
 
406
407
  async deleteBooking(id: string): Promise<void> {
407
408
  const { core, pub } = getClients()
408
- const { data: booking, error: bookingErr } = await core.from('bookings')
409
+ const { data: booking, error: bookingErr } = await core.from('appointments')
409
410
  .select('order_id')
410
411
  .eq('id', id)
411
412
  .single()
@@ -413,22 +414,22 @@ export function createSupabaseAgendaProvider(): AgendaDataProvider {
413
414
 
414
415
  const orderId = booking?.order_id as string | null | undefined
415
416
  const { count, error: movementErr } = orderId
416
- ? await pub.from('financial_movements').select('id', { count: 'exact', head: true }).eq('invoice_id', orderId)
417
+ ? await pub.from('plg_financial_movements').select('id', { count: 'exact', head: true }).eq('invoice_id', orderId)
417
418
  : { count: 0, error: null }
418
419
  if (movementErr) throw movementErr
419
420
 
420
421
  if (orderId && (count ?? 0) > 0) {
421
422
  // Cancel movements + order (don't delete financial records)
422
- await pub.from('financial_movements').update({ status: 'cancelled' }).eq('invoice_id', orderId)
423
+ await pub.from('plg_financial_movements').update({ status: 'cancelled' }).eq('invoice_id', orderId)
423
424
  await core.from('orders').update({ status: 'cancelled' }).eq('id', orderId)
424
- const { error } = await core.from('bookings').update({ status: 'cancelled' }).eq('id', id)
425
+ const { error } = await core.from('appointments').update({ status: 'cancelled' }).eq('id', id)
425
426
  if (error) throw error
426
427
  } else {
427
428
  // Safe to delete — remove any movements first (FK constraint)
428
429
  if (orderId) {
429
- await pub.from('financial_movements').delete().eq('invoice_id', orderId)
430
+ await pub.from('plg_financial_movements').delete().eq('invoice_id', orderId)
430
431
  }
431
- const { error } = await core.from('bookings').delete().eq('id', id)
432
+ const { error } = await core.from('appointments').delete().eq('id', id)
432
433
  if (error) throw error
433
434
  if (orderId) {
434
435
  const { error: orderErr } = await core.from('orders').delete().eq('id', orderId)
@@ -443,7 +444,7 @@ export function createSupabaseAgendaProvider(): AgendaDataProvider {
443
444
 
444
445
  async checkConflict(params: ConflictCheckParams): Promise<boolean> {
445
446
  const { core } = getClients()
446
- let q = core.from('bookings').select('id', { count: 'exact', head: true })
447
+ let q = core.from('appointments').select('id', { count: 'exact', head: true })
447
448
  .eq('assignee_id', params.assigneeId)
448
449
  .eq('kind', 'appointment')
449
450
  .not('status', 'in', '("cancelled","no_show")')
@@ -513,7 +514,7 @@ export function createSupabaseAgendaProvider(): AgendaDataProvider {
513
514
 
514
515
  async getProfessionals(): Promise<Professional[]> {
515
516
  const { core } = getClients()
516
- const { data, error } = await core.from('persons')
517
+ const { data, error } = await core.from('people')
517
518
  .select('id, name, avatar_url, is_active')
518
519
  .eq('kind', 'staff')
519
520
  .eq('is_active', true)
@@ -541,7 +542,7 @@ export function createSupabaseAgendaProvider(): AgendaDataProvider {
541
542
  const cutoff = new Date()
542
543
  cutoff.setDate(cutoff.getDate() + daysAhead)
543
544
 
544
- const { data, error } = await pub.from('v_bookings').select('*')
545
+ const { data, error } = await pub.from('v_appointments').select('*')
545
546
  .eq('status', 'scheduled')
546
547
  .gte('starts_at', now)
547
548
  .lte('starts_at', cutoff.toISOString())
@@ -552,22 +553,22 @@ export function createSupabaseAgendaProvider(): AgendaDataProvider {
552
553
 
553
554
  async sendConfirmation(bookingId: string, channel: string): Promise<void> {
554
555
  const { core } = getClients()
555
- const { data: booking } = await core.from('bookings').select('metadata').eq('id', bookingId).single()
556
+ const { data: booking } = await core.from('appointments').select('metadata').eq('id', bookingId).single()
556
557
  const metadata = (booking?.metadata ?? {}) as Record<string, unknown>
557
558
  const confirmations = (metadata.confirmations as any[] ?? [])
558
559
  confirmations.push({ channel, sentAt: new Date().toISOString(), status: 'sent' })
559
- await core.from('bookings').update({ metadata: { ...metadata, confirmations } }).eq('id', bookingId)
560
+ await core.from('appointments').update({ metadata: { ...metadata, confirmations } }).eq('id', bookingId)
560
561
  },
561
562
 
562
563
  async completeBooking(bookingId: string): Promise<{ booking: CalendarBooking; orderId: string }> {
563
564
  const { core } = getClients()
564
- const { data: existing, error: existingErr } = await core.from('bookings')
565
+ const { data: existing, error: existingErr } = await core.from('appointments')
565
566
  .select('order_id')
566
567
  .eq('id', bookingId)
567
568
  .single()
568
569
  if (existingErr) throw existingErr
569
570
 
570
- const { error } = await core.from('bookings')
571
+ const { error } = await core.from('appointments')
571
572
  .update({ status: 'completed' })
572
573
  .eq('id', bookingId)
573
574
  if (error) throw error
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ import { createAgendaStore } from './store'
11
11
  import { agendaRegistries } from './registries'
12
12
  import { AgendaGeneralSettings } from './components/AgendaGeneralSettings'
13
13
  import { agendaLocales } from './locales'
14
+ import { MIGRATION_001_PUBLIC_BOOKING } from './migrations'
14
15
  import { setScheduleBlockConfig, getScheduleBlockConfig, PluginSettingsPanel } from '@fayz-ai/saas'
15
16
 
16
17
  // ---------------------------------------------------------------------------
@@ -179,6 +180,16 @@ export function createAgendaPlugin(options?: AgendaPluginOptions): PluginManifes
179
180
  permission: { feature: 'appointments', action: 'read' as const },
180
181
  },
181
182
  ],
183
+ migrations: [
184
+ {
185
+ id: 'agenda-001-public-booking',
186
+ version: '0.3.0',
187
+ sql: MIGRATION_001_PUBLIC_BOOKING,
188
+ description:
189
+ 'Booking read model (v_appointments) + anon public booking surface (v_public_services, get_available_slots, create_public_booking) + order_items duration/assignee columns',
190
+ },
191
+ ],
192
+
182
193
  locales: agendaLocales,
183
194
  }
184
195
  }
@@ -5,7 +5,7 @@
5
5
  // Google) is automatic via a DB trigger; inbound (Google → booking) via pg_cron
6
6
  // or a Google watch webhook. The data plane is the google-calendar-sync edge
7
7
  // function (see ./functions/google-calendar-sync). The booking↔event link lives
8
- // on saas_core.bookings.metadata.googleCalendarEventId.
8
+ // on public.appointments.metadata.googleCalendarEventId.
9
9
  //
10
10
  // Graduation path: extract to its own `@fayz-ai/plugin-google-calendar` package.
11
11
  import type { PluginManifest } from '@fayz-ai/core'
@@ -1,15 +1,15 @@
1
1
  -- ---------------------------------------------------------------------------
2
2
  -- Google Calendar integration (bidirectional) — connection + audit + triggers
3
3
  -- ---------------------------------------------------------------------------
4
- -- Outbound (Fayz booking → Google event) fires from a trigger on saas_core.bookings
4
+ -- Outbound (Fayz booking → Google event) fires from a trigger on public.appointments
5
5
  -- via pg_net to the google-calendar-sync edge function (push_event). Inbound
6
6
  -- (Google → booking) runs on a pg_cron schedule (pull_events) and/or a Google
7
7
  -- watch-channel webhook. The booking↔event link is stored on
8
- -- saas_core.bookings.metadata.googleCalendarEventId (no schema change there).
8
+ -- public.appointments.metadata.googleCalendarEventId (no schema change there).
9
9
 
10
10
  CREATE TABLE IF NOT EXISTS public.calendar_integrations (
11
11
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
12
- tenant_id uuid NOT NULL REFERENCES saas_core.tenants(id) ON DELETE CASCADE,
12
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
13
13
  provider text NOT NULL DEFAULT 'google',
14
14
  oauth_refresh_token text,
15
15
  oauth_access_token text,
@@ -31,7 +31,7 @@ CREATE INDEX IF NOT EXISTS idx_calendar_integrations_tenant ON public.calendar_i
31
31
 
32
32
  CREATE TABLE IF NOT EXISTS public.calendar_sync_log (
33
33
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
34
- tenant_id uuid NOT NULL REFERENCES saas_core.tenants(id) ON DELETE CASCADE,
34
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
35
35
  direction text NOT NULL, -- inbound | outbound
36
36
  trigger text, -- on-write | scheduled | webhook | manual
37
37
  status text NOT NULL DEFAULT 'success', -- success | partial | error
@@ -80,9 +80,9 @@ BEGIN
80
80
  RETURN COALESCE(NEW, OLD);
81
81
  END $$;
82
82
 
83
- DROP TRIGGER IF EXISTS trg_gcal_push ON saas_core.bookings;
83
+ DROP TRIGGER IF EXISTS trg_gcal_push ON public.appointments;
84
84
  CREATE TRIGGER trg_gcal_push
85
- AFTER INSERT OR UPDATE OR DELETE ON saas_core.bookings
85
+ AFTER INSERT OR UPDATE OR DELETE ON public.appointments
86
86
  FOR EACH ROW EXECUTE FUNCTION public.fn_gcal_push_booking();
87
87
 
88
88
  -- Inbound schedule (optional): pull external changes every 10 minutes.
@@ -0,0 +1,21 @@
1
+ -- Salon-only unblock: the pre-pools beauty deployment built rep_* report
2
+ -- views on top of its (wider) v_bookings view, so 001's cleanup DROP cannot
3
+ -- run there. Renaming keeps the dependents attached (they track the OID) and
4
+ -- frees the name for 001; 002_v_bookings_compat re-creates v_bookings as a
5
+ -- thin alias for live pre-M5 clients. No-op on fresh pools and on pools whose
6
+ -- v_bookings has no dependents (001's DROP handles those).
7
+
8
+ DO $rename$
9
+ BEGIN
10
+ IF to_regclass('public.v_bookings') IS NOT NULL
11
+ AND to_regclass('public.v_bookings_legacy_beauty') IS NULL
12
+ AND EXISTS (
13
+ SELECT 1 FROM pg_depend d
14
+ JOIN pg_rewrite rw ON rw.oid = d.objid
15
+ JOIN pg_class dep ON dep.oid = rw.ev_class
16
+ JOIN pg_class ref ON ref.oid = d.refobjid
17
+ WHERE ref.relname = 'v_bookings' AND dep.relname <> 'v_bookings'
18
+ ) THEN
19
+ EXECUTE 'ALTER VIEW public.v_bookings RENAME TO v_bookings_legacy_beauty';
20
+ END IF;
21
+ END $rename$;
@@ -0,0 +1,43 @@
1
+ -- 001 needs the customer-phone upsert key UNIQUE(tenant_id, phone). Converted
2
+ -- pools can carry duplicates (found live on salon: 9 dogfood test records all
3
+ -- sharing the founder's phone). Non-destructive dedupe: per (tenant_id, phone)
4
+ -- customer group keep the row with most appointments (tie: oldest), NULL the
5
+ -- phone on the rest and stash the original in metadata->'dedupedPhone'.
6
+ -- Idempotent: reruns find no groups > 1.
7
+
8
+ DO $dedupe$
9
+ DECLARE
10
+ r record;
11
+ BEGIN
12
+ IF to_regclass('public.people') IS NULL THEN
13
+ RETURN;
14
+ END IF;
15
+
16
+ FOR r IN
17
+ SELECT p.id, p.phone
18
+ FROM public.people p
19
+ JOIN LATERAL (
20
+ SELECT count(*) AS appts
21
+ FROM public.appointments a WHERE a.party_id = p.id
22
+ ) ac ON true
23
+ WHERE p.kind = 'customer' AND p.phone IS NOT NULL
24
+ AND p.id NOT IN (
25
+ SELECT DISTINCT ON (p2.tenant_id, p2.phone) p2.id
26
+ FROM public.people p2
27
+ WHERE p2.kind = 'customer' AND p2.phone IS NOT NULL
28
+ ORDER BY p2.tenant_id, p2.phone,
29
+ (SELECT count(*) FROM public.appointments a2 WHERE a2.party_id = p2.id) DESC,
30
+ p2.created_at ASC
31
+ )
32
+ AND (p.tenant_id, p.phone) IN (
33
+ SELECT tenant_id, phone FROM public.people
34
+ WHERE kind = 'customer' AND phone IS NOT NULL
35
+ GROUP BY tenant_id, phone HAVING count(*) > 1
36
+ )
37
+ LOOP
38
+ UPDATE public.people
39
+ SET metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object('dedupedPhone', r.phone),
40
+ phone = NULL
41
+ WHERE id = r.id;
42
+ END LOOP;
43
+ END $dedupe$;
@@ -0,0 +1,291 @@
1
+ -- ============================================================================
2
+ -- plugin-agenda 001: booking read model + public (anon) booking surface
3
+ --
4
+ -- Ships the objects the agenda providers reference but the spine (packages/db
5
+ -- migrations 001–010) does not define:
6
+ -- §0 converted-pool cleanup — drop the pre-rename v_bookings view / indexes
7
+ -- §1 order_items columns the admin provider writes (duration/assignee)
8
+ -- §2 public.v_appointments — admin read model (authenticated, RLS applies)
9
+ -- §3 public.v_public_services — anon-readable service catalog
10
+ -- §4 public.get_available_slots — anon RPC (mirrors the mock algorithm)
11
+ -- §5 public.create_public_booking — anon RPC (validated, race-safe write)
12
+ --
13
+ -- Core v1 lives directly in PUBLIC (no separate core schema): people (was
14
+ -- persons), appointments (was bookings). This migration is written against public
15
+ -- names and is idempotent + safe on both fresh and converted pools.
16
+ --
17
+ -- Anon-access design: public booking pages run with the publishable key and no
18
+ -- session, so the canonical tenant RLS yields nothing. Anon access is granted
19
+ -- ONLY via the column-whitelisted view (§3) and SECURITY DEFINER RPCs (§4, §5)
20
+ -- with tight input validation and hardcoded statuses.
21
+ -- ============================================================================
22
+
23
+ -- §0 — converted-pool cleanup. The pre-industry-pool shape shipped a v_bookings
24
+ -- view and persons_/bookings_-named indexes; drop them so this file re-emits the
25
+ -- canonical v_appointments view + people_/appointments_ indexes without leaving
26
+ -- duplicates. All guarded with IF EXISTS → no-ops on a fresh pool.
27
+ DROP VIEW IF EXISTS public.v_bookings;
28
+ DROP INDEX IF EXISTS public.persons_tenant_customer_phone;
29
+ DROP INDEX IF EXISTS public.bookings_assignee_time;
30
+
31
+ -- §1 — columns the agenda provider writes but 004_archetypes lacks
32
+ ALTER TABLE public.order_items
33
+ ADD COLUMN IF NOT EXISTS duration_minutes integer,
34
+ ADD COLUMN IF NOT EXISTS assignee_id uuid REFERENCES public.people(id) ON DELETE SET NULL;
35
+
36
+ -- Upsert key for public customers (per tenant, by normalized phone)
37
+ CREATE UNIQUE INDEX IF NOT EXISTS people_tenant_customer_phone
38
+ ON public.people(tenant_id, phone)
39
+ WHERE kind = 'customer' AND phone IS NOT NULL;
40
+
41
+ -- Slot-conflict scan
42
+ CREATE INDEX IF NOT EXISTS appointments_assignee_time
43
+ ON public.appointments(assignee_id, starts_at, ends_at)
44
+ WHERE status NOT IN ('cancelled', 'no_show');
45
+
46
+ -- §2 — admin read model (security_invoker → underlying RLS applies; anon sees 0 rows)
47
+ CREATE OR REPLACE VIEW public.v_appointments
48
+ WITH (security_invoker = true) AS
49
+ SELECT
50
+ b.id, b.tenant_id, b.kind, b.starts_at, b.ends_at, b.status, b.notes,
51
+ b.order_id, b.location_id, b.metadata, b.created_at, b.updated_at,
52
+ b.party_id AS client_id,
53
+ COALESCE(pc.name, b.metadata->>'title') AS client_name,
54
+ pc.phone AS client_phone,
55
+ pc.email AS client_email,
56
+ pc.avatar_url AS client_avatar_url,
57
+ b.assignee_id AS professional_id,
58
+ ps.name AS professional_name,
59
+ ps.avatar_url AS professional_avatar_url,
60
+ l.name AS location_name,
61
+ o.total AS order_total,
62
+ o.status AS order_status,
63
+ (EXTRACT(EPOCH FROM (COALESCE(b.ends_at, b.starts_at) - b.starts_at))::int / 60)
64
+ AS total_duration_minutes
65
+ FROM public.appointments b
66
+ LEFT JOIN public.people pc ON pc.id = b.party_id
67
+ LEFT JOIN public.people ps ON ps.id = b.assignee_id
68
+ LEFT JOIN public.locations l ON l.id = b.location_id
69
+ LEFT JOIN public.orders o ON o.id = b.order_id;
70
+
71
+ GRANT SELECT ON public.v_appointments TO authenticated, service_role;
72
+ REVOKE SELECT ON public.v_appointments FROM anon;
73
+
74
+ -- §3 — anon-readable public catalog (owner-rights view = deliberate, whitelisted)
75
+ CREATE OR REPLACE VIEW public.v_public_services AS
76
+ SELECT
77
+ s.id, s.tenant_id, s.name, s.description, s.price, s.currency,
78
+ COALESCE(s.duration_minutes, 30) AS duration_minutes,
79
+ s.image_url,
80
+ COALESCE((s.metadata->>'sort_order')::int, 0) AS sort_order
81
+ FROM public.services s
82
+ WHERE s.is_active AND s.status = 'active';
83
+
84
+ GRANT SELECT ON public.v_public_services TO anon, authenticated, service_role;
85
+
86
+ -- §4 — availability RPC (mirrors plugin-agenda/src/data/mock.ts getAvailableSlots)
87
+ CREATE OR REPLACE FUNCTION public.get_available_slots(
88
+ p_tenant_id uuid,
89
+ p_date date,
90
+ p_duration_minutes integer,
91
+ p_assignee_id uuid DEFAULT NULL,
92
+ p_slot_interval integer DEFAULT 30
93
+ ) RETURNS TABLE (slot_start timestamptz, slot_end timestamptz)
94
+ LANGUAGE plpgsql STABLE SECURITY DEFINER
95
+ SET search_path = public
96
+ AS $$
97
+ DECLARE
98
+ v_tz text;
99
+ BEGIN
100
+ -- tight validation: this function is anon-callable
101
+ IF p_duration_minutes IS NULL OR p_duration_minutes NOT BETWEEN 5 AND 480 THEN
102
+ RAISE EXCEPTION 'invalid duration';
103
+ END IF;
104
+ IF p_slot_interval IS NULL OR p_slot_interval NOT BETWEEN 5 AND 240 THEN
105
+ RAISE EXCEPTION 'invalid interval';
106
+ END IF;
107
+ IF p_date IS NULL OR p_date < current_date OR p_date > current_date + 90 THEN
108
+ RETURN;
109
+ END IF;
110
+
111
+ SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
112
+ FROM tenants t WHERE t.id = p_tenant_id;
113
+ IF v_tz IS NULL THEN RETURN; END IF; -- unknown tenant → empty, not an error
114
+
115
+ RETURN QUERY
116
+ WITH day_schedules AS (
117
+ SELECT sc.assignee_id, sc.starts_at AS win_start, sc.ends_at AS win_end
118
+ FROM schedules sc
119
+ WHERE sc.tenant_id = p_tenant_id
120
+ AND sc.kind = 'working_hours'
121
+ AND sc.is_active
122
+ AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)
123
+ AND (
124
+ sc.specific_date = p_date
125
+ OR (sc.specific_date IS NULL AND sc.day_of_week = EXTRACT(dow FROM p_date)::smallint)
126
+ )
127
+ ),
128
+ candidate AS (
129
+ SELECT
130
+ ds.assignee_id,
131
+ gs AS s_start,
132
+ gs + make_interval(mins => p_duration_minutes) AS s_end
133
+ FROM day_schedules ds
134
+ CROSS JOIN LATERAL generate_series(
135
+ (p_date + ds.win_start) AT TIME ZONE v_tz,
136
+ ((p_date + ds.win_end) AT TIME ZONE v_tz) - make_interval(mins => p_duration_minutes),
137
+ make_interval(mins => p_slot_interval)
138
+ ) AS gs
139
+ )
140
+ SELECT DISTINCT c.s_start, c.s_end
141
+ FROM candidate c
142
+ WHERE NOT EXISTS (
143
+ SELECT 1 FROM appointments b
144
+ WHERE b.tenant_id = p_tenant_id
145
+ AND b.assignee_id = c.assignee_id
146
+ AND b.status NOT IN ('cancelled', 'no_show')
147
+ AND b.starts_at < c.s_end
148
+ AND COALESCE(b.ends_at, b.starts_at) > c.s_start
149
+ )
150
+ ORDER BY 1;
151
+ END;
152
+ $$;
153
+
154
+ REVOKE ALL ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer) FROM public;
155
+ GRANT EXECUTE ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer)
156
+ TO anon, authenticated, service_role;
157
+
158
+ -- §5 — public booking write RPC (validated, race-safe, hardcoded statuses)
159
+ CREATE OR REPLACE FUNCTION public.create_public_booking(
160
+ p_tenant_id uuid,
161
+ p_service_id uuid,
162
+ p_starts_at timestamptz,
163
+ p_name text,
164
+ p_phone text,
165
+ p_email text DEFAULT NULL,
166
+ p_notes text DEFAULT NULL
167
+ ) RETURNS TABLE (booking_id uuid, starts_at timestamptz, ends_at timestamptz)
168
+ LANGUAGE plpgsql SECURITY DEFINER
169
+ SET search_path = public
170
+ AS $$
171
+ DECLARE
172
+ v_service record;
173
+ v_person_id uuid;
174
+ v_assignee uuid;
175
+ v_ends timestamptz;
176
+ v_order uuid;
177
+ v_booking uuid;
178
+ v_phone text;
179
+ v_tz text;
180
+ v_notes text;
181
+ BEGIN
182
+ -- validation: anon-callable, everything tight
183
+ IF p_name IS NULL OR length(trim(p_name)) NOT BETWEEN 2 AND 120 THEN
184
+ RAISE EXCEPTION 'invalid name';
185
+ END IF;
186
+ v_phone := regexp_replace(COALESCE(p_phone, ''), '\D', '', 'g');
187
+ IF length(v_phone) NOT BETWEEN 8 AND 15 THEN
188
+ RAISE EXCEPTION 'invalid phone';
189
+ END IF;
190
+ IF p_email IS NOT NULL AND (length(p_email) > 254 OR p_email !~ '^[^@\s]+@[^@\s]+\.[^@\s]+$') THEN
191
+ RAISE EXCEPTION 'invalid email';
192
+ END IF;
193
+ v_notes := left(p_notes, 500);
194
+ IF p_starts_at IS NULL OR p_starts_at <= now() OR p_starts_at > now() + interval '90 days' THEN
195
+ RAISE EXCEPTION 'invalid start';
196
+ END IF;
197
+
198
+ SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
199
+ INTO v_service
200
+ FROM services s
201
+ WHERE s.id = p_service_id AND s.tenant_id = p_tenant_id
202
+ AND s.is_active AND s.status = 'active';
203
+ IF NOT FOUND THEN RAISE EXCEPTION 'unknown service'; END IF;
204
+
205
+ v_ends := p_starts_at + make_interval(mins => v_service.duration_minutes);
206
+ SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
207
+ FROM tenants t WHERE t.id = p_tenant_id;
208
+ IF v_tz IS NULL THEN RAISE EXCEPTION 'unknown tenant'; END IF;
209
+
210
+ -- pick the assignee whose working hours cover the slot
211
+ SELECT sc.assignee_id INTO v_assignee
212
+ FROM schedules sc
213
+ WHERE sc.tenant_id = p_tenant_id
214
+ AND sc.kind = 'working_hours'
215
+ AND sc.is_active
216
+ AND (
217
+ sc.specific_date = (p_starts_at AT TIME ZONE v_tz)::date
218
+ OR (sc.specific_date IS NULL
219
+ AND sc.day_of_week = EXTRACT(dow FROM p_starts_at AT TIME ZONE v_tz)::smallint)
220
+ )
221
+ AND (p_starts_at AT TIME ZONE v_tz)::time >= sc.starts_at
222
+ AND (v_ends AT TIME ZONE v_tz)::time <= sc.ends_at
223
+ ORDER BY sc.assignee_id
224
+ LIMIT 1;
225
+ IF v_assignee IS NULL THEN RAISE EXCEPTION 'slot outside working hours'; END IF;
226
+
227
+ -- serialize per (tenant, assignee) to kill the double-book race
228
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_assignee::text, 0));
229
+
230
+ IF EXISTS (
231
+ SELECT 1 FROM appointments b
232
+ WHERE b.tenant_id = p_tenant_id
233
+ AND b.assignee_id = v_assignee
234
+ AND b.status NOT IN ('cancelled', 'no_show')
235
+ AND b.starts_at < v_ends
236
+ AND COALESCE(b.ends_at, b.starts_at) > p_starts_at
237
+ ) THEN
238
+ RAISE EXCEPTION 'slot no longer available';
239
+ END IF;
240
+
241
+ -- anti-spam: cap open future public bookings per phone per tenant
242
+ IF (
243
+ SELECT count(*) FROM appointments b
244
+ JOIN people p ON p.id = b.party_id
245
+ WHERE b.tenant_id = p_tenant_id
246
+ AND p.phone = v_phone
247
+ AND b.starts_at > now()
248
+ AND b.status = 'scheduled'
249
+ ) >= 3 THEN
250
+ RAISE EXCEPTION 'too many open bookings for this phone';
251
+ END IF;
252
+
253
+ -- upsert the customer by (tenant, phone) — matches people_tenant_customer_phone
254
+ INSERT INTO people (tenant_id, kind, name, phone, email, metadata)
255
+ VALUES (p_tenant_id, 'customer', trim(p_name), v_phone, p_email,
256
+ jsonb_build_object('source', 'public_booking'))
257
+ ON CONFLICT (tenant_id, phone) WHERE kind = 'customer' AND phone IS NOT NULL
258
+ DO UPDATE SET
259
+ name = EXCLUDED.name,
260
+ email = COALESCE(EXCLUDED.email, people.email),
261
+ updated_at = now()
262
+ RETURNING id INTO v_person_id;
263
+
264
+ -- order + booking + order_item (statuses hardcoded — caller cannot set)
265
+ INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
266
+ VALUES (p_tenant_id, 'appointment', 'scheduled', v_person_id, v_assignee,
267
+ v_service.price, v_service.price, v_notes,
268
+ jsonb_build_object('source', 'public_booking',
269
+ 'serviceNames', v_service.name,
270
+ 'contactName', trim(p_name)))
271
+ RETURNING id INTO v_order;
272
+
273
+ INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
274
+ starts_at, ends_at, status, notes, metadata)
275
+ VALUES (p_tenant_id, 'appointment', v_person_id, v_assignee, v_order,
276
+ p_starts_at, v_ends, 'scheduled', v_notes,
277
+ jsonb_build_object('source', 'public_booking', 'serviceNames', v_service.name))
278
+ RETURNING id INTO v_booking;
279
+
280
+ INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
281
+ sort_order, duration_minutes, assignee_id)
282
+ VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
283
+ 0, v_service.duration_minutes, v_assignee);
284
+
285
+ RETURN QUERY SELECT v_booking, p_starts_at, v_ends;
286
+ END;
287
+ $$;
288
+
289
+ REVOKE ALL ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text) FROM public;
290
+ GRANT EXECUTE ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text)
291
+ TO anon, authenticated, service_role;
@@ -0,0 +1,14 @@
1
+ -- Where 000b preserved the legacy beauty view, re-create v_bookings as a
2
+ -- thin alias over it so live pre-M5 clients that SELECT v_bookings by name
3
+ -- keep their full legacy column set. Dropped for good once the beauty app
4
+ -- ships on v_appointments (M5 follow-up).
5
+
6
+ DO $compat$
7
+ BEGIN
8
+ IF to_regclass('public.v_bookings_legacy_beauty') IS NOT NULL
9
+ AND to_regclass('public.v_bookings') IS NULL THEN
10
+ EXECUTE 'CREATE VIEW public.v_bookings WITH (security_invoker = true) AS SELECT * FROM public.v_bookings_legacy_beauty';
11
+ EXECUTE 'GRANT SELECT ON public.v_bookings TO authenticated, service_role';
12
+ EXECUTE 'REVOKE SELECT ON public.v_bookings FROM anon';
13
+ END IF;
14
+ END $compat$;