@fayz-ai/plugin-agenda 0.9.0 → 0.9.1

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 (153) hide show
  1. package/README.md +3 -3
  2. package/dist/bridges/create-financial-bridge.d.ts +1 -1
  3. package/dist/bridges/create-financial-bridge.d.ts.map +1 -1
  4. package/dist/bridges/financial-bridge.d.ts.map +1 -0
  5. package/dist/{chunk-Q2PAWBPR.js → chunk-76ZOHRBM.js} +2 -2
  6. package/dist/{chunk-Q2PAWBPR.js.map → chunk-76ZOHRBM.js.map} +1 -1
  7. package/dist/components/PaymentStatusBadge.d.ts +1 -1
  8. package/dist/components/PaymentStatusBadge.d.ts.map +1 -1
  9. package/dist/config.d.ts +2 -2
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/{AgendaContext.d.ts → context.d.ts} +1 -1
  12. package/dist/context.d.ts.map +1 -0
  13. package/dist/data/registries.d.ts.map +1 -0
  14. package/dist/index.d.ts +2 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +170 -7
  17. package/dist/index.js.map +1 -1
  18. package/dist/lib/message-events.d.ts +12 -0
  19. package/dist/lib/message-events.d.ts.map +1 -0
  20. package/dist/public/{data.d.ts → data/index.d.ts} +4 -4
  21. package/dist/public/data/index.d.ts.map +1 -0
  22. package/dist/public/{data.supabase.d.ts → data/supabase.d.ts} +2 -2
  23. package/dist/public/data/supabase.d.ts.map +1 -0
  24. package/dist/public/{hooks.d.ts → hooks/index.d.ts} +4 -4
  25. package/dist/public/hooks/index.d.ts.map +1 -0
  26. package/dist/public/index.d.ts +4 -4
  27. package/dist/public/index.d.ts.map +1 -1
  28. package/dist/public/index.js +3 -3
  29. package/dist/public/index.js.map +1 -1
  30. package/dist/public/{format.d.ts → lib/format.d.ts} +1 -1
  31. package/dist/public/lib/format.d.ts.map +1 -0
  32. package/dist/public/views/BookingWidget.d.ts.map +1 -0
  33. package/dist/public/{steps → views/steps}/ContactStep.d.ts +1 -1
  34. package/dist/public/views/steps/ContactStep.d.ts.map +1 -0
  35. package/dist/public/{steps → views/steps}/DateTimeStep.d.ts +3 -3
  36. package/dist/public/views/steps/DateTimeStep.d.ts.map +1 -0
  37. package/dist/public/{steps → views/steps}/OverviewStep.d.ts +1 -1
  38. package/dist/public/views/steps/OverviewStep.d.ts.map +1 -0
  39. package/dist/public/{steps → views/steps}/PaymentStep.d.ts +1 -1
  40. package/dist/public/views/steps/PaymentStep.d.ts.map +1 -0
  41. package/dist/store.d.ts +1 -1
  42. package/dist/store.d.ts.map +1 -1
  43. package/dist/styles/calendar-styles.d.ts.map +1 -0
  44. package/dist/types.d.ts +1 -1
  45. package/dist/types.d.ts.map +1 -1
  46. package/dist/{AgendaPage.d.ts → views/AgendaPage.d.ts} +3 -3
  47. package/dist/views/AgendaPage.d.ts.map +1 -0
  48. package/package.json +6 -9
  49. package/dist/AgendaContext.d.ts.map +0 -1
  50. package/dist/AgendaPage.d.ts.map +0 -1
  51. package/dist/calendar-styles.d.ts.map +0 -1
  52. package/dist/financial-bridge.d.ts.map +0 -1
  53. package/dist/public/BookingWidget.d.ts.map +0 -1
  54. package/dist/public/data.d.ts.map +0 -1
  55. package/dist/public/data.supabase.d.ts.map +0 -1
  56. package/dist/public/format.d.ts.map +0 -1
  57. package/dist/public/hooks.d.ts.map +0 -1
  58. package/dist/public/steps/ContactStep.d.ts.map +0 -1
  59. package/dist/public/steps/DateTimeStep.d.ts.map +0 -1
  60. package/dist/public/steps/OverviewStep.d.ts.map +0 -1
  61. package/dist/public/steps/PaymentStep.d.ts.map +0 -1
  62. package/dist/registries.d.ts.map +0 -1
  63. package/src/AgendaContext.tsx +0 -11
  64. package/src/AgendaPage.tsx +0 -33
  65. package/src/bridges/create-financial-bridge.ts +0 -296
  66. package/src/calendar-styles.css +0 -245
  67. package/src/calendar-styles.ts +0 -285
  68. package/src/components/AgendaGeneralSettings.tsx +0 -86
  69. package/src/components/AgendaMobileList.tsx +0 -103
  70. package/src/components/AppointmentModal.tsx +0 -1050
  71. package/src/components/AppointmentPopover.tsx +0 -231
  72. package/src/components/BookingPaymentPanel.tsx +0 -394
  73. package/src/components/CalendarSidebar.tsx +0 -119
  74. package/src/components/CalendarToolbar.tsx +0 -86
  75. package/src/components/EventContextMenu.tsx +0 -125
  76. package/src/components/FloatingPanel.tsx +0 -123
  77. package/src/components/MiniCalendar.tsx +0 -120
  78. package/src/components/PaymentStatusBadge.tsx +0 -62
  79. package/src/components/StatusBadge.tsx +0 -23
  80. package/src/config.ts +0 -368
  81. package/src/data/index.ts +0 -4
  82. package/src/data/mock.ts +0 -344
  83. package/src/data/supabase.ts +0 -636
  84. package/src/data/types.ts +0 -37
  85. package/src/financial-bridge.ts +0 -96
  86. package/src/hooks/useAgendaSettings.ts +0 -17
  87. package/src/index.ts +0 -297
  88. package/src/integrations/google-calendar/GoogleCalendarSettings.tsx +0 -136
  89. package/src/integrations/google-calendar/components/CalendarColorDot.tsx +0 -23
  90. package/src/integrations/google-calendar/components/ChannelMappingList.tsx +0 -329
  91. package/src/integrations/google-calendar/components/ImportEventsWizard.tsx +0 -215
  92. package/src/integrations/google-calendar/components/useAgendaLookups.ts +0 -100
  93. package/src/integrations/google-calendar/connector.ts +0 -20
  94. package/src/integrations/google-calendar/connectorDef.tsx +0 -185
  95. package/src/integrations/google-calendar/data/supabase.ts +0 -170
  96. package/src/integrations/google-calendar/data/tables.ts +0 -12
  97. package/src/integrations/google-calendar/index.ts +0 -45
  98. package/src/integrations/google-calendar/integration.test.ts +0 -199
  99. package/src/integrations/google-calendar/mapping.ts +0 -183
  100. package/src/integrations/google-calendar/migrations/001_google_calendar.sql +0 -97
  101. package/src/integrations/google-calendar/migrations/002_smart_sync.sql +0 -517
  102. package/src/integrations/google-calendar/migrations/003_plg_rename.sql +0 -273
  103. package/src/integrations/google-calendar/types.ts +0 -142
  104. package/src/lib/tenant.ts +0 -3
  105. package/src/locales/en.ts +0 -220
  106. package/src/locales/index.ts +0 -7
  107. package/src/locales/pt-BR.ts +0 -220
  108. package/src/migrations/000b_v_bookings_rename.sql +0 -21
  109. package/src/migrations/000c_dedupe_customer_phones.sql +0 -43
  110. package/src/migrations/001_public_booking.sql +0 -296
  111. package/src/migrations/002_v_bookings_compat.sql +0 -14
  112. package/src/migrations/003_public_client_kind.sql +0 -175
  113. package/src/migrations/004_public_booking_assignee.sql +0 -176
  114. package/src/migrations/005_agent_rpcs.sql +0 -168
  115. package/src/migrations/006_agent_rpc_localtime.sql +0 -181
  116. package/src/migrations/007_schedule_exception_precedence.sql +0 -290
  117. package/src/migrations/008_agent_rpc_exception_precedence.sql +0 -172
  118. package/src/migrations/009_public_booking_location.sql +0 -162
  119. package/src/migrations/010_public_service_curation.sql +0 -31
  120. package/src/migrations/011_default_assignee.sql +0 -167
  121. package/src/migrations/012_drop_legacy_booking_overload.sql +0 -29
  122. package/src/migrations/013_normalize_v_appointments.sql +0 -199
  123. package/src/migrations/014_agent_rpc_update_appointment.sql +0 -199
  124. package/src/migrations/index.ts +0 -2377
  125. package/src/public/BookingWidget.tsx +0 -324
  126. package/src/public/components/BrandHeader.tsx +0 -27
  127. package/src/public/components/ServiceCard.tsx +0 -39
  128. package/src/public/components/SidebarExtras.tsx +0 -30
  129. package/src/public/components/StepRailItem.tsx +0 -27
  130. package/src/public/components/SuccessPanel.tsx +0 -27
  131. package/src/public/components/SummaryRail.tsx +0 -90
  132. package/src/public/components/defaults.ts +0 -14
  133. package/src/public/context.tsx +0 -103
  134. package/src/public/createPublicBookingPlugin.ts +0 -281
  135. package/src/public/data.supabase.ts +0 -97
  136. package/src/public/data.ts +0 -105
  137. package/src/public/format.ts +0 -96
  138. package/src/public/hooks.ts +0 -110
  139. package/src/public/index.ts +0 -69
  140. package/src/public/steps/ContactStep.tsx +0 -184
  141. package/src/public/steps/DateTimeStep.tsx +0 -138
  142. package/src/public/steps/OverviewStep.tsx +0 -81
  143. package/src/public/steps/PaymentStep.tsx +0 -180
  144. package/src/public/types.ts +0 -250
  145. package/src/registries.ts +0 -35
  146. package/src/store.ts +0 -311
  147. package/src/types.ts +0 -273
  148. package/src/views/CalendarView.tsx +0 -1112
  149. package/src/views/ConfirmationsView.tsx +0 -155
  150. /package/dist/{financial-bridge.d.ts → bridges/financial-bridge.d.ts} +0 -0
  151. /package/dist/{registries.d.ts → data/registries.d.ts} +0 -0
  152. /package/dist/public/{BookingWidget.d.ts → views/BookingWidget.d.ts} +0 -0
  153. /package/dist/{calendar-styles.d.ts → styles/calendar-styles.d.ts} +0 -0
@@ -1,162 +0,0 @@
1
- -- Public bookings were written with location_id = NULL. The agenda's location
2
- -- filter is optional (default "all"), so they still rendered — but the moment
3
- -- an operator filtered by their unit, every booking taken from the website
4
- -- disappeared from the calendar. Single-location tenants are the common case
5
- -- and have an unambiguous answer, so default to it; multi-location tenants
6
- -- stay NULL (there is no correct guess) until the public flow asks.
7
- --
8
- -- Only the assignment is new; the rest of the body is unchanged from 004.
9
-
10
- CREATE OR REPLACE FUNCTION public.create_public_booking(
11
- p_tenant_id uuid,
12
- p_service_id uuid,
13
- p_starts_at timestamptz,
14
- p_name text,
15
- p_phone text,
16
- p_email text DEFAULT NULL,
17
- p_notes text DEFAULT NULL
18
- )
19
- RETURNS TABLE(booking_id uuid, starts_at timestamptz, ends_at timestamptz)
20
- LANGUAGE plpgsql
21
- SECURITY DEFINER
22
- SET search_path TO 'public'
23
- AS $function$
24
- DECLARE
25
- v_service record;
26
- v_person_id uuid;
27
- v_assignee uuid;
28
- v_ends timestamptz;
29
- v_order uuid;
30
- v_booking uuid;
31
- v_phone text;
32
- v_tz text;
33
- v_notes text;
34
- v_location uuid;
35
- BEGIN
36
- -- validation: anon-callable, everything tight
37
- IF p_name IS NULL OR length(trim(p_name)) NOT BETWEEN 2 AND 120 THEN
38
- RAISE EXCEPTION 'invalid name';
39
- END IF;
40
- v_phone := regexp_replace(COALESCE(p_phone, ''), '\D', '', 'g');
41
- IF length(v_phone) NOT BETWEEN 8 AND 15 THEN
42
- RAISE EXCEPTION 'invalid phone';
43
- END IF;
44
- IF p_email IS NOT NULL AND (length(p_email) > 254 OR p_email !~ '^[^@\s]+@[^@\s]+\.[^@\s]+$') THEN
45
- RAISE EXCEPTION 'invalid email';
46
- END IF;
47
- v_notes := left(p_notes, 500);
48
- IF p_starts_at IS NULL OR p_starts_at <= now() OR p_starts_at > now() + interval '90 days' THEN
49
- RAISE EXCEPTION 'invalid start';
50
- END IF;
51
-
52
- SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
53
- INTO v_service
54
- FROM services s
55
- WHERE s.id = p_service_id AND s.tenant_id = p_tenant_id
56
- AND s.is_active AND s.status = 'active';
57
- IF NOT FOUND THEN RAISE EXCEPTION 'unknown service'; END IF;
58
-
59
- v_ends := p_starts_at + make_interval(mins => v_service.duration_minutes);
60
- SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
61
- FROM tenants t WHERE t.id = p_tenant_id;
62
- IF v_tz IS NULL THEN RAISE EXCEPTION 'unknown tenant'; END IF;
63
-
64
- -- pick the assignee whose working hours cover the slot
65
- SELECT sc.assignee_id INTO v_assignee
66
- FROM schedules sc
67
- WHERE sc.tenant_id = p_tenant_id
68
- AND sc.kind = 'working_hours'
69
- AND sc.is_active
70
- AND (
71
- sc.specific_date = (p_starts_at AT TIME ZONE v_tz)::date
72
- OR (sc.specific_date IS NULL
73
- AND sc.day_of_week = EXTRACT(dow FROM p_starts_at AT TIME ZONE v_tz)::smallint)
74
- )
75
- AND (p_starts_at AT TIME ZONE v_tz)::time >= sc.starts_at
76
- AND (v_ends AT TIME ZONE v_tz)::time <= sc.ends_at
77
- ORDER BY sc.assignee_id
78
- LIMIT 1;
79
- IF v_assignee IS NULL THEN RAISE EXCEPTION 'slot outside working hours'; END IF;
80
-
81
- -- NEW: stamp the unit so the calendar's location filter keeps these visible.
82
- -- Prefer the schedule's own location; else the tenant's only location.
83
- SELECT sc.location_id INTO v_location
84
- FROM schedules sc
85
- WHERE sc.tenant_id = p_tenant_id
86
- AND sc.kind = 'working_hours'
87
- AND sc.is_active
88
- AND sc.assignee_id = v_assignee
89
- AND sc.location_id IS NOT NULL
90
- LIMIT 1;
91
-
92
- IF v_location IS NULL THEN
93
- SELECT l.id INTO v_location
94
- FROM locations l
95
- WHERE l.tenant_id = p_tenant_id
96
- HAVING count(*) = 1; -- unambiguous only
97
- END IF;
98
-
99
- -- serialize per (tenant, assignee) to kill the double-book race
100
- PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_assignee::text, 0));
101
-
102
- IF EXISTS (
103
- SELECT 1 FROM appointments b
104
- WHERE b.tenant_id = p_tenant_id
105
- AND b.assignee_id = v_assignee
106
- AND b.status NOT IN ('cancelled', 'no_show')
107
- AND b.starts_at < v_ends
108
- AND COALESCE(b.ends_at, b.starts_at) > p_starts_at
109
- ) THEN
110
- RAISE EXCEPTION 'slot no longer available';
111
- END IF;
112
-
113
- -- anti-spam: cap open future public bookings per phone per tenant
114
- IF (
115
- SELECT count(*) FROM appointments b
116
- JOIN people p ON p.id = b.party_id
117
- WHERE b.tenant_id = p_tenant_id
118
- AND p.phone = v_phone
119
- AND b.starts_at > now()
120
- AND b.status = 'scheduled'
121
- ) >= 3 THEN
122
- RAISE EXCEPTION 'too many open bookings for this phone';
123
- END IF;
124
-
125
- -- upsert the customer by (tenant, phone) — matches people_tenant_customer_phone
126
- INSERT INTO people (tenant_id, kind, name, phone, email, metadata)
127
- VALUES (p_tenant_id, 'customer', trim(p_name), v_phone, p_email,
128
- jsonb_build_object('source', 'public_booking'))
129
- ON CONFLICT (tenant_id, phone) WHERE kind = 'customer' AND phone IS NOT NULL
130
- DO UPDATE SET
131
- name = EXCLUDED.name,
132
- email = COALESCE(EXCLUDED.email, people.email),
133
- updated_at = now()
134
- RETURNING id INTO v_person_id;
135
-
136
- -- order + booking + order_item (statuses hardcoded — caller cannot set)
137
- INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, location_id,
138
- subtotal, total, notes, metadata)
139
- VALUES (p_tenant_id, 'appointment', 'scheduled', v_person_id, v_assignee, v_location,
140
- v_service.price, v_service.price, v_notes,
141
- jsonb_build_object('source', 'public_booking',
142
- 'serviceNames', v_service.name,
143
- 'contactName', trim(p_name)))
144
- RETURNING id INTO v_order;
145
-
146
- INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id, location_id,
147
- starts_at, ends_at, status, notes, metadata)
148
- VALUES (p_tenant_id, 'appointment', v_person_id, v_assignee, v_order, v_location,
149
- p_starts_at, v_ends, 'scheduled', v_notes,
150
- jsonb_build_object('source', 'public_booking', 'serviceNames', v_service.name))
151
- RETURNING id INTO v_booking;
152
-
153
- INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
154
- sort_order, duration_minutes, assignee_id)
155
- VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
156
- 0, v_service.duration_minutes, v_assignee);
157
-
158
- RETURN QUERY SELECT v_booking, p_starts_at, v_ends;
159
- END;
160
- $function$;
161
-
162
- GRANT EXECUTE ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text) TO anon, authenticated;
@@ -1,31 +0,0 @@
1
- -- plugin-agenda 010: curate what the PUBLIC booking page sells.
2
- --
3
- -- v_public_services published every active service, so the website catalog was
4
- -- "whatever exists in the system". That conflates two different questions:
5
- --
6
- -- • is this service usable at all? → services.is_active / status
7
- -- • should a stranger be able to book it? → this flag
8
- --
9
- -- Without the split, hiding something from the site means deactivating it —
10
- -- which also removes it from the internal agenda and orphans its history. The
11
- -- case that forced this: a school whose public page listed 7 services when it
12
- -- sells 3, and one of the 4 extras already had real bookings attached.
13
- --
14
- -- OPT-OUT on purpose: a service is public unless explicitly marked otherwise.
15
- -- Opt-in would silently empty every live catalog the moment this ships.
16
-
17
- CREATE OR REPLACE VIEW public.v_public_services AS
18
- SELECT id,
19
- tenant_id,
20
- name,
21
- description,
22
- price,
23
- currency,
24
- COALESCE(duration_minutes, 30) AS duration_minutes,
25
- image_url,
26
- COALESCE(((metadata ->> 'sort_order'::text))::integer, 0) AS sort_order
27
- FROM services s
28
- WHERE is_active
29
- AND status = 'active'::text
30
- -- absent → public (back-compat). Only an explicit false hides it.
31
- AND COALESCE((metadata ->> 'bookableOnline'::text)::boolean, true);
@@ -1,167 +0,0 @@
1
- -- plugin-agenda 011: honour a tenant's default professional.
2
- --
3
- -- create_public_booking picks the assignee from whoever's working hours cover
4
- -- the slot, ordered by assignee_id — i.e. the lowest UUID wins. That is fine
5
- -- when the caller names the professional, and wrong when it does not: the
6
- -- public page confirms "Equipe Great DJs" while the booking silently lands on
7
- -- an unrelated instructor whose UUID happened to sort first.
8
- --
9
- -- Clients shipped before p_assignee_id existed still call this with 7 named
10
- -- args, so the parameter defaults to NULL and every live booking on those sites
11
- -- is mis-assigned today. Republishing the SDK fixes the caller, but the sites
12
- -- already deployed cannot be fixed that way — this can, from the database.
13
- --
14
- -- Precedence: explicit arg > tenant default > first covering schedule.
15
- -- Tenants without the setting keep exactly the previous behaviour.
16
-
17
- CREATE OR REPLACE FUNCTION public.create_public_booking(p_tenant_id uuid, p_service_id uuid, p_starts_at timestamp with time zone, p_name text, p_phone text, p_email text DEFAULT NULL::text, p_notes text DEFAULT NULL::text, p_assignee_id uuid DEFAULT NULL::uuid)
18
- RETURNS TABLE(booking_id uuid, starts_at timestamp with time zone, ends_at timestamp with time zone)
19
- LANGUAGE plpgsql
20
- SECURITY DEFINER
21
- SET search_path TO 'public'
22
- AS $function$
23
- DECLARE
24
- v_service record;
25
- v_person_id uuid;
26
- v_assignee uuid;
27
- v_requested uuid;
28
- v_ends timestamptz;
29
- v_order uuid;
30
- v_booking uuid;
31
- v_phone text;
32
- v_tz text;
33
- v_notes text;
34
- v_client_kind text;
35
- BEGIN
36
- -- validation: anon-callable, everything tight
37
- IF p_name IS NULL OR length(trim(p_name)) NOT BETWEEN 2 AND 120 THEN
38
- RAISE EXCEPTION 'invalid name';
39
- END IF;
40
- v_phone := regexp_replace(COALESCE(p_phone, ''), '\D', '', 'g');
41
- IF length(v_phone) NOT BETWEEN 8 AND 15 THEN
42
- RAISE EXCEPTION 'invalid phone';
43
- END IF;
44
- IF p_email IS NOT NULL AND (length(p_email) > 254 OR p_email !~ '^[^@\s]+@[^@\s]+\.[^@\s]+$') THEN
45
- RAISE EXCEPTION 'invalid email';
46
- END IF;
47
- v_notes := left(p_notes, 500);
48
- IF p_starts_at IS NULL OR p_starts_at <= now() OR p_starts_at > now() + interval '90 days' THEN
49
- RAISE EXCEPTION 'invalid start';
50
- END IF;
51
-
52
- SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
53
- INTO v_service
54
- FROM services s
55
- WHERE s.id = p_service_id AND s.tenant_id = p_tenant_id
56
- AND s.is_active AND s.status = 'active';
57
- IF NOT FOUND THEN RAISE EXCEPTION 'unknown service'; END IF;
58
-
59
- v_ends := p_starts_at + make_interval(mins => v_service.duration_minutes);
60
-
61
- -- tenant config: timezone + client kind (admin-set; sanitized, never caller input)
62
- SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo'),
63
- COALESCE(NULLIF(trim(t.settings->'booking'->>'client_kind'), ''), 'customer')
64
- INTO v_tz, v_client_kind
65
- FROM tenants t WHERE t.id = p_tenant_id;
66
- IF v_tz IS NULL THEN RAISE EXCEPTION 'unknown tenant'; END IF;
67
- IF v_client_kind !~ '^[a-z_]{2,32}$' THEN v_client_kind := 'customer'; END IF;
68
-
69
- -- resolve the professional, in order of trust:
70
- -- 1. the id the caller sent (the one the visitor actually saw)
71
- -- 2. the tenant's configured default (settings->>'defaultAssigneeId')
72
- -- 3. whoever's working hours cover the slot
73
- --
74
- -- Step 2 is why this migration exists. Older published clients call this with
75
- -- 7 named args, leaving p_assignee_id NULL, and step 3 then resolved to
76
- -- "lowest assignee UUID" — an arbitrary instructor who had nothing to do with
77
- -- the name the site displayed on the confirmation screen. The default gives
78
- -- those clients a correct answer without waiting for a redeploy.
79
- v_requested := COALESCE(
80
- p_assignee_id,
81
- (SELECT NULLIF(t.settings ->> 'defaultAssigneeId', '')::uuid
82
- FROM tenants t WHERE t.id = p_tenant_id)
83
- );
84
-
85
- SELECT w.assignee_id INTO v_assignee
86
- FROM agenda_working_windows(
87
- p_tenant_id, (p_starts_at AT TIME ZONE v_tz)::date, v_requested) w
88
- WHERE (p_starts_at AT TIME ZONE v_tz)::time >= w.win_start
89
- AND (v_ends AT TIME ZONE v_tz)::time <= w.win_end
90
- ORDER BY w.assignee_id
91
- LIMIT 1;
92
- IF v_assignee IS NULL THEN RAISE EXCEPTION 'slot outside working hours'; END IF;
93
-
94
- -- serialize per (tenant, assignee) to kill the double-book race
95
- PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_assignee::text, 0));
96
-
97
- IF EXISTS (
98
- SELECT 1 FROM appointments b
99
- WHERE b.tenant_id = p_tenant_id
100
- AND b.assignee_id = v_assignee
101
- AND b.status NOT IN ('cancelled', 'no_show')
102
- AND b.starts_at < v_ends
103
- AND COALESCE(b.ends_at, b.starts_at) > p_starts_at
104
- ) THEN
105
- RAISE EXCEPTION 'slot no longer available';
106
- END IF;
107
-
108
- -- anti-spam: cap open future public bookings per phone per tenant
109
- IF (
110
- SELECT count(*) FROM appointments b
111
- JOIN people p ON p.id = b.party_id
112
- WHERE b.tenant_id = p_tenant_id
113
- AND p.phone = v_phone
114
- AND b.starts_at > now()
115
- AND b.status = 'scheduled'
116
- ) >= 3 THEN
117
- RAISE EXCEPTION 'too many open bookings for this phone';
118
- END IF;
119
-
120
- -- upsert the client by (tenant, kind, phone), serialized by advisory lock
121
- -- (kind is tenant-configured, so the customer-only partial index can't
122
- -- arbitrate)
123
- PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || ':' || v_phone, 1));
124
-
125
- SELECT p.id INTO v_person_id
126
- FROM people p
127
- WHERE p.tenant_id = p_tenant_id AND p.kind = v_client_kind AND p.phone = v_phone
128
- ORDER BY p.created_at
129
- LIMIT 1;
130
-
131
- IF v_person_id IS NOT NULL THEN
132
- UPDATE people SET
133
- name = trim(p_name),
134
- email = COALESCE(p_email, people.email),
135
- updated_at = now()
136
- WHERE id = v_person_id;
137
- ELSE
138
- INSERT INTO people (tenant_id, kind, name, phone, email, metadata)
139
- VALUES (p_tenant_id, v_client_kind, trim(p_name), v_phone, p_email,
140
- jsonb_build_object('source', 'public_booking'))
141
- RETURNING id INTO v_person_id;
142
- END IF;
143
-
144
- -- order + booking + order_item (statuses hardcoded — caller cannot set)
145
- INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
146
- VALUES (p_tenant_id, 'appointment', 'scheduled', v_person_id, v_assignee,
147
- v_service.price, v_service.price, v_notes,
148
- jsonb_build_object('source', 'public_booking',
149
- 'serviceNames', v_service.name,
150
- 'contactName', trim(p_name)))
151
- RETURNING id INTO v_order;
152
-
153
- INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
154
- starts_at, ends_at, status, notes, metadata)
155
- VALUES (p_tenant_id, 'appointment', v_person_id, v_assignee, v_order,
156
- p_starts_at, v_ends, 'scheduled', v_notes,
157
- jsonb_build_object('source', 'public_booking', 'serviceNames', v_service.name))
158
- RETURNING id INTO v_booking;
159
-
160
- INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
161
- sort_order, duration_minutes, assignee_id)
162
- VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
163
- 0, v_service.duration_minutes, v_assignee);
164
-
165
- RETURN QUERY SELECT v_booking, p_starts_at, v_ends;
166
- END;
167
- $function$;
@@ -1,29 +0,0 @@
1
- -- plugin-agenda 012: retire the pre-assignee create_public_booking overload.
2
- --
3
- -- ⚠️ DO NOT APPLY UNTIL @fayz-ai/plugin-agenda IS REPUBLISHED *AND* EVERY LIVE
4
- -- SITE HAS BEEN REDEPLOYED. Applying early breaks public booking on any
5
- -- site still serving a bundle that calls the 7-argument signature — the
6
- -- call stops resolving and every submission fails.
7
- --
8
- -- Some pools carry two overloads side by side:
9
- -- create_public_booking(uuid,uuid,timestamptz,text,text,text,text)
10
- -- create_public_booking(uuid,uuid,timestamptz,text,text,text,text,uuid)
11
- --
12
- -- Nothing errors when both exist, which is exactly the problem: a stale client
13
- -- silently resolves to the 7-arg one, which cannot know which professional the
14
- -- visitor was shown and falls back to "lowest assignee UUID". The site confirms
15
- -- one name, the agenda records another, and no one finds out until someone
16
- -- compares the two screens.
17
- --
18
- -- 011 makes that fallback survivable (tenant default). This makes it
19
- -- impossible — but only once no caller needs the old signature.
20
- --
21
- -- Verify before running:
22
- -- 1. npm view @fayz-ai/plugin-agenda version → the republished version
23
- -- 2. every deployed bundle greps for p_assignee_id:
24
- -- curl -s https://<site>/assets/index-*.js | grep -c p_assignee_id
25
- -- Each must be ≥ 2 (slots + booking). Zero means that site still needs it.
26
-
27
- DROP FUNCTION IF EXISTS public.create_public_booking(
28
- uuid, uuid, timestamptz, text, text, text, text
29
- );
@@ -1,199 +0,0 @@
1
- -- plugin-agenda 013: one booking read model — v_appointments.
2
- --
3
- -- Three views described the same 34 rows:
4
- --
5
- -- v_bookings → thin alias over the legacy view (002 compat shim)
6
- -- v_bookings_legacy_beauty → the pre-pools shape, still feeding 5 rep_* views
7
- -- v_appointments → the canonical name, and the only one the SDK reads
8
- --
9
- -- The school pool already had only v_appointments; the salon pool carried all
10
- -- three. Same data, three names, and reports quietly wired to the oldest one.
11
- --
12
- -- v_appointments was a strict SUBSET, so it could not simply absorb them. It
13
- -- gains the 7 columns it lacked — most importantly `services`, the order_items
14
- -- aggregate that 4 of the 5 reports depend on. That also retires a documented
15
- -- N+1: getBookingById fetches order_items in a second round-trip precisely
16
- -- because this view had no line items (see plugin-agenda/src/data/supabase.ts).
17
- --
18
- -- Deliberately NOT copied from the legacy view:
19
- -- • total_duration_minutes — legacy sums order_items durations, the canonical
20
- -- measures the slot. They disagree, no report reads it, and the SDK relies
21
- -- on the canonical meaning. Kept as-is.
22
- -- • client_name — the canonical COALESCEs with metadata->>'title' so blocks
23
- -- and personal events show a label instead of NULL. Kept as-is (an
24
- -- improvement the legacy shape never had).
25
-
26
- -- 1. v_appointments becomes the superset. Columns are APPENDED: CREATE OR
27
- -- REPLACE VIEW cannot reorder or drop, and the SDK selects '*'.
28
- CREATE OR REPLACE VIEW public.v_appointments
29
- WITH (security_invoker = true) AS
30
- SELECT b.id,
31
- b.tenant_id,
32
- b.kind,
33
- b.starts_at,
34
- b.ends_at,
35
- b.status,
36
- b.notes,
37
- b.order_id,
38
- b.location_id,
39
- b.metadata,
40
- b.created_at,
41
- b.updated_at,
42
- b.party_id AS client_id,
43
- COALESCE(pc.name, (b.metadata ->> 'title'::text)) AS client_name,
44
- pc.phone AS client_phone,
45
- pc.email AS client_email,
46
- pc.avatar_url AS client_avatar_url,
47
- b.assignee_id AS professional_id,
48
- ps.name AS professional_name,
49
- ps.avatar_url AS professional_avatar_url,
50
- l.name AS location_name,
51
- o.total AS order_total,
52
- o.status AS order_status,
53
- ((EXTRACT(epoch FROM (COALESCE(b.ends_at, b.starts_at) - b.starts_at)))::integer / 60) AS total_duration_minutes,
54
- -- appended:
55
- o.reference_number,
56
- o.status AS stage,
57
- (o.metadata ->> 'direction'::text) AS direction,
58
- ( SELECT json_agg(json_build_object('id', oi.id, 'serviceId', oi.service_id,
59
- 'name', oi.name, 'durationMinutes', oi.duration_minutes,
60
- 'price', oi.unit_price, 'assigneeId', oi.assignee_id)
61
- ORDER BY oi.sort_order)
62
- FROM order_items oi
63
- WHERE (oi.order_id = b.order_id)) AS services,
64
- inv.status AS payment_status,
65
- inv.paid AS order_paid,
66
- inv.balance AS order_balance
67
- FROM (((((appointments b
68
- LEFT JOIN people pc ON ((pc.id = b.party_id)))
69
- LEFT JOIN people ps ON ((ps.id = b.assignee_id)))
70
- LEFT JOIN locations l ON ((l.id = b.location_id)))
71
- LEFT JOIN orders o ON ((o.id = b.order_id)))
72
- LEFT JOIN v_invoice_balances inv ON ((inv.invoice_id = b.order_id)));
73
-
74
- -- 2. Repoint the reports off the legacy view. Bodies are unchanged
75
- -- except for the source relation.
76
-
77
- CREATE OR REPLACE VIEW public.rep_appointments_by_period AS
78
- SELECT tenant_id,
79
- id AS booking_id,
80
- (starts_at)::date AS date,
81
- starts_at,
82
- client_id,
83
- client_name,
84
- professional_id,
85
- professional_name,
86
- COALESCE(((services -> 0) ->> 'name'::text), ((services -> 0) ->> 'serviceName'::text)) AS service_name,
87
- status,
88
- order_total AS revenue,
89
- created_at,
90
- updated_at
91
- FROM v_appointments b;
92
-
93
- -- legacy_pre_pools only exists on pools migrated from the pre-pool era; fresh
94
- -- pools get the same shape with confirmation columns defaulting to pending/NULL.
95
- DO $$
96
- BEGIN
97
- IF to_regclass('legacy_pre_pools.appointments') IS NOT NULL THEN
98
- EXECUTE $v$
99
- CREATE OR REPLACE VIEW public.rep_confirmation_queue AS
100
- SELECT b.tenant_id,
101
- b.id AS booking_id,
102
- (b.starts_at)::date AS date,
103
- b.starts_at,
104
- b.client_id,
105
- b.client_name,
106
- b.professional_id,
107
- b.professional_name,
108
- COALESCE(((b.services -> 0) ->> 'name'::text), ((b.services -> 0) ->> 'serviceName'::text)) AS service_name,
109
- COALESCE(a.confirmation_status, 'pending'::text) AS confirmation_status,
110
- a.confirmation_channel,
111
- a.confirmation_sent_at,
112
- a.confirmed_at,
113
- b.created_at,
114
- b.updated_at
115
- FROM (v_appointments b
116
- LEFT JOIN legacy_pre_pools.appointments a ON ((a.booking_id = b.id)))
117
- WHERE ((b.status <> ALL (ARRAY['cancelled'::text, 'no_show'::text])) AND (b.starts_at >= (now() - '1 day'::interval)))
118
- $v$;
119
- ELSE
120
- EXECUTE $v$
121
- CREATE OR REPLACE VIEW public.rep_confirmation_queue AS
122
- SELECT b.tenant_id,
123
- b.id AS booking_id,
124
- (b.starts_at)::date AS date,
125
- b.starts_at,
126
- b.client_id,
127
- b.client_name,
128
- b.professional_id,
129
- b.professional_name,
130
- COALESCE(((b.services -> 0) ->> 'name'::text), ((b.services -> 0) ->> 'serviceName'::text)) AS service_name,
131
- 'pending'::text AS confirmation_status,
132
- NULL::text AS confirmation_channel,
133
- NULL::timestamptz AS confirmation_sent_at,
134
- NULL::timestamptz AS confirmed_at,
135
- b.created_at,
136
- b.updated_at
137
- FROM v_appointments b
138
- WHERE ((b.status <> ALL (ARRAY['cancelled'::text, 'no_show'::text])) AND (b.starts_at >= (now() - '1 day'::interval)))
139
- $v$;
140
- END IF;
141
- END $$;
142
-
143
- -- v_clients (beauty Ring-2 extension) may not exist on non-salon pools — the
144
- -- report only makes sense where it does.
145
- DO $$
146
- BEGIN
147
- IF to_regclass('public.v_clients') IS NULL THEN RETURN; END IF;
148
- EXECUTE $v$
149
- CREATE OR REPLACE VIEW public.rep_new_clients AS
150
- SELECT c.tenant_id,
151
- c.id AS client_id,
152
- (c.created_at)::date AS date,
153
- c.name AS client_name,
154
- c.origin,
155
- first_booking.service_name AS first_service,
156
- c.created_at,
157
- c.updated_at
158
- FROM (v_clients c
159
- LEFT JOIN LATERAL ( SELECT COALESCE(((b.services -> 0) ->> 'name'::text), ((b.services -> 0) ->> 'serviceName'::text)) AS service_name
160
- FROM v_appointments b
161
- WHERE (b.client_id = c.id)
162
- ORDER BY b.starts_at
163
- LIMIT 1) first_booking ON (true))
164
- $v$;
165
- END $$;
166
-
167
- CREATE OR REPLACE VIEW public.rep_revenue_by_professional AS
168
- SELECT tenant_id,
169
- (starts_at)::date AS date,
170
- professional_id,
171
- COALESCE(professional_name, 'Sem profissional'::text) AS professional_name,
172
- (count(*))::integer AS appointment_count,
173
- (COALESCE(sum(NULLIF(order_total, (0)::numeric)), (0)::numeric))::numeric(14,2) AS total_revenue,
174
- (COALESCE(avg(NULLIF(order_total, (0)::numeric)), (0)::numeric))::numeric(14,2) AS avg_ticket,
175
- (0)::numeric(14,2) AS commission,
176
- max(updated_at) AS updated_at
177
- FROM v_appointments b
178
- WHERE (status <> ALL (ARRAY['cancelled'::text, 'no_show'::text]))
179
- GROUP BY tenant_id, ((starts_at)::date), professional_id, COALESCE(professional_name, 'Sem profissional'::text);
180
-
181
- CREATE OR REPLACE VIEW public.rep_revenue_by_service AS
182
- SELECT b.tenant_id,
183
- (b.starts_at)::date AS date,
184
- NULLIF((service_item.value ->> 'serviceId'::text), ''::text) AS service_id,
185
- COALESCE((service_item.value ->> 'name'::text), (service_item.value ->> 'serviceName'::text), 'Sem servico'::text) AS service_name,
186
- (count(DISTINCT b.id))::integer AS quantity,
187
- (COALESCE(sum((NULLIF((service_item.value ->> 'price'::text), ''::text))::numeric), (0)::numeric))::numeric(14,2) AS total_revenue,
188
- (COALESCE(avg((NULLIF((service_item.value ->> 'price'::text), ''::text))::numeric), (0)::numeric))::numeric(14,2) AS avg_ticket,
189
- max(b.updated_at) AS updated_at
190
- FROM (v_appointments b
191
- CROSS JOIN LATERAL jsonb_array_elements(COALESCE((b.services)::jsonb, '[]'::jsonb)) service_item(value))
192
- WHERE (b.status <> ALL (ARRAY['cancelled'::text, 'no_show'::text]))
193
- GROUP BY b.tenant_id, ((b.starts_at)::date), NULLIF((service_item.value ->> 'serviceId'::text), ''::text), COALESCE((service_item.value ->> 'name'::text), (service_item.value ->> 'serviceName'::text), 'Sem servico'::text);
194
-
195
- -- 3. Retire the duplicates. v_bookings goes first: it reads the legacy view.
196
- -- Nothing in the SDK references either name (checked: only migrations and a
197
- -- README mention v_bookings).
198
- DROP VIEW IF EXISTS public.v_bookings;
199
- DROP VIEW IF EXISTS public.v_bookings_legacy_beauty;