@fayz-ai/plugin-agenda 0.9.0 → 0.10.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 (196) 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-ISLJXP7O.js} +7 -2
  6. package/dist/chunk-ISLJXP7O.js.map +1 -0
  7. package/dist/components/AppointmentModal.d.ts.map +1 -1
  8. package/dist/components/PaymentStatusBadge.d.ts +1 -1
  9. package/dist/components/PaymentStatusBadge.d.ts.map +1 -1
  10. package/dist/config.d.ts +2 -2
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/{AgendaContext.d.ts → context.d.ts} +1 -1
  13. package/dist/context.d.ts.map +1 -0
  14. package/dist/data/mock.d.ts.map +1 -1
  15. package/dist/data/realtime.test.d.ts +2 -0
  16. package/dist/data/realtime.test.d.ts.map +1 -0
  17. package/dist/data/registries.d.ts.map +1 -0
  18. package/dist/data/supabase.d.ts.map +1 -1
  19. package/dist/data/types.d.ts +7 -0
  20. package/dist/data/types.d.ts.map +1 -1
  21. package/dist/index.d.ts +2 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +2515 -187
  24. package/dist/index.js.map +1 -1
  25. package/dist/integrations/google-calendar/conflict.d.ts +25 -0
  26. package/dist/integrations/google-calendar/conflict.d.ts.map +1 -0
  27. package/dist/integrations/google-calendar/connectorDef.d.ts +12 -0
  28. package/dist/integrations/google-calendar/connectorDef.d.ts.map +1 -1
  29. package/dist/integrations/google-calendar/credentials.d.ts +27 -0
  30. package/dist/integrations/google-calendar/credentials.d.ts.map +1 -0
  31. package/dist/integrations/google-calendar/data/supabase.d.ts +10 -2
  32. package/dist/integrations/google-calendar/data/supabase.d.ts.map +1 -1
  33. package/dist/integrations/google-calendar/data/tables.d.ts +2 -0
  34. package/dist/integrations/google-calendar/data/tables.d.ts.map +1 -1
  35. package/dist/integrations/google-calendar/health.d.ts +23 -0
  36. package/dist/integrations/google-calendar/health.d.ts.map +1 -0
  37. package/dist/integrations/google-calendar/health.test.d.ts +2 -0
  38. package/dist/integrations/google-calendar/health.test.d.ts.map +1 -0
  39. package/dist/integrations/google-calendar/index.d.ts +6 -0
  40. package/dist/integrations/google-calendar/index.d.ts.map +1 -1
  41. package/dist/integrations/google-calendar/migrations/index.d.ts +12 -0
  42. package/dist/integrations/google-calendar/migrations/index.d.ts.map +1 -0
  43. package/dist/integrations/google-calendar/panel-data.d.ts +26 -0
  44. package/dist/integrations/google-calendar/panel-data.d.ts.map +1 -0
  45. package/dist/integrations/google-calendar/panel-data.test.d.ts +2 -0
  46. package/dist/integrations/google-calendar/panel-data.test.d.ts.map +1 -0
  47. package/dist/integrations/google-calendar/types.d.ts +17 -0
  48. package/dist/integrations/google-calendar/types.d.ts.map +1 -1
  49. package/dist/lib/booking-events.d.ts +5 -0
  50. package/dist/lib/booking-events.d.ts.map +1 -0
  51. package/dist/lib/calendar-events.d.ts +8 -0
  52. package/dist/lib/calendar-events.d.ts.map +1 -0
  53. package/dist/lib/calendar-events.test.d.ts +2 -0
  54. package/dist/lib/calendar-events.test.d.ts.map +1 -0
  55. package/dist/lib/message-events.d.ts +12 -0
  56. package/dist/lib/message-events.d.ts.map +1 -0
  57. package/dist/locales/en.d.ts.map +1 -1
  58. package/dist/locales/pt-BR.d.ts.map +1 -1
  59. package/dist/public/{data.d.ts → data/index.d.ts} +4 -4
  60. package/dist/public/data/index.d.ts.map +1 -0
  61. package/dist/public/{data.supabase.d.ts → data/supabase.d.ts} +2 -2
  62. package/dist/public/data/supabase.d.ts.map +1 -0
  63. package/dist/public/{hooks.d.ts → hooks/index.d.ts} +4 -4
  64. package/dist/public/hooks/index.d.ts.map +1 -0
  65. package/dist/public/index.d.ts +4 -4
  66. package/dist/public/index.d.ts.map +1 -1
  67. package/dist/public/index.js +3 -3
  68. package/dist/public/index.js.map +1 -1
  69. package/dist/public/{format.d.ts → lib/format.d.ts} +1 -1
  70. package/dist/public/lib/format.d.ts.map +1 -0
  71. package/dist/public/views/BookingWidget.d.ts.map +1 -0
  72. package/dist/public/{steps → views/steps}/ContactStep.d.ts +1 -1
  73. package/dist/public/views/steps/ContactStep.d.ts.map +1 -0
  74. package/dist/public/{steps → views/steps}/DateTimeStep.d.ts +3 -3
  75. package/dist/public/views/steps/DateTimeStep.d.ts.map +1 -0
  76. package/dist/public/{steps → views/steps}/OverviewStep.d.ts +1 -1
  77. package/dist/public/views/steps/OverviewStep.d.ts.map +1 -0
  78. package/dist/public/{steps → views/steps}/PaymentStep.d.ts +1 -1
  79. package/dist/public/views/steps/PaymentStep.d.ts.map +1 -0
  80. package/dist/store.d.ts +1 -1
  81. package/dist/store.d.ts.map +1 -1
  82. package/dist/styles/calendar-styles.d.ts.map +1 -0
  83. package/dist/types.d.ts +1 -1
  84. package/dist/types.d.ts.map +1 -1
  85. package/dist/{AgendaPage.d.ts → views/AgendaPage.d.ts} +3 -3
  86. package/dist/views/AgendaPage.d.ts.map +1 -0
  87. package/dist/views/CalendarView.d.ts.map +1 -1
  88. package/package.json +9 -11
  89. package/dist/AgendaContext.d.ts.map +0 -1
  90. package/dist/AgendaPage.d.ts.map +0 -1
  91. package/dist/calendar-styles.d.ts.map +0 -1
  92. package/dist/chunk-Q2PAWBPR.js.map +0 -1
  93. package/dist/financial-bridge.d.ts.map +0 -1
  94. package/dist/integrations/google-calendar/GoogleCalendarSettings.d.ts +0 -3
  95. package/dist/integrations/google-calendar/GoogleCalendarSettings.d.ts.map +0 -1
  96. package/dist/public/BookingWidget.d.ts.map +0 -1
  97. package/dist/public/data.d.ts.map +0 -1
  98. package/dist/public/data.supabase.d.ts.map +0 -1
  99. package/dist/public/format.d.ts.map +0 -1
  100. package/dist/public/hooks.d.ts.map +0 -1
  101. package/dist/public/steps/ContactStep.d.ts.map +0 -1
  102. package/dist/public/steps/DateTimeStep.d.ts.map +0 -1
  103. package/dist/public/steps/OverviewStep.d.ts.map +0 -1
  104. package/dist/public/steps/PaymentStep.d.ts.map +0 -1
  105. package/dist/registries.d.ts.map +0 -1
  106. package/src/AgendaContext.tsx +0 -11
  107. package/src/AgendaPage.tsx +0 -33
  108. package/src/bridges/create-financial-bridge.ts +0 -296
  109. package/src/calendar-styles.css +0 -245
  110. package/src/calendar-styles.ts +0 -285
  111. package/src/components/AgendaGeneralSettings.tsx +0 -86
  112. package/src/components/AgendaMobileList.tsx +0 -103
  113. package/src/components/AppointmentModal.tsx +0 -1050
  114. package/src/components/AppointmentPopover.tsx +0 -231
  115. package/src/components/BookingPaymentPanel.tsx +0 -394
  116. package/src/components/CalendarSidebar.tsx +0 -119
  117. package/src/components/CalendarToolbar.tsx +0 -86
  118. package/src/components/EventContextMenu.tsx +0 -125
  119. package/src/components/FloatingPanel.tsx +0 -123
  120. package/src/components/MiniCalendar.tsx +0 -120
  121. package/src/components/PaymentStatusBadge.tsx +0 -62
  122. package/src/components/StatusBadge.tsx +0 -23
  123. package/src/config.ts +0 -368
  124. package/src/data/index.ts +0 -4
  125. package/src/data/mock.ts +0 -344
  126. package/src/data/supabase.ts +0 -636
  127. package/src/data/types.ts +0 -37
  128. package/src/financial-bridge.ts +0 -96
  129. package/src/hooks/useAgendaSettings.ts +0 -17
  130. package/src/index.ts +0 -297
  131. package/src/integrations/google-calendar/GoogleCalendarSettings.tsx +0 -136
  132. package/src/integrations/google-calendar/components/CalendarColorDot.tsx +0 -23
  133. package/src/integrations/google-calendar/components/ChannelMappingList.tsx +0 -329
  134. package/src/integrations/google-calendar/components/ImportEventsWizard.tsx +0 -215
  135. package/src/integrations/google-calendar/components/useAgendaLookups.ts +0 -100
  136. package/src/integrations/google-calendar/connector.ts +0 -20
  137. package/src/integrations/google-calendar/connectorDef.tsx +0 -185
  138. package/src/integrations/google-calendar/data/supabase.ts +0 -170
  139. package/src/integrations/google-calendar/data/tables.ts +0 -12
  140. package/src/integrations/google-calendar/index.ts +0 -45
  141. package/src/integrations/google-calendar/integration.test.ts +0 -199
  142. package/src/integrations/google-calendar/mapping.ts +0 -183
  143. package/src/integrations/google-calendar/migrations/001_google_calendar.sql +0 -97
  144. package/src/integrations/google-calendar/migrations/002_smart_sync.sql +0 -517
  145. package/src/integrations/google-calendar/migrations/003_plg_rename.sql +0 -273
  146. package/src/integrations/google-calendar/types.ts +0 -142
  147. package/src/lib/tenant.ts +0 -3
  148. package/src/locales/en.ts +0 -220
  149. package/src/locales/index.ts +0 -7
  150. package/src/locales/pt-BR.ts +0 -220
  151. package/src/migrations/000b_v_bookings_rename.sql +0 -21
  152. package/src/migrations/000c_dedupe_customer_phones.sql +0 -43
  153. package/src/migrations/001_public_booking.sql +0 -296
  154. package/src/migrations/002_v_bookings_compat.sql +0 -14
  155. package/src/migrations/003_public_client_kind.sql +0 -175
  156. package/src/migrations/004_public_booking_assignee.sql +0 -176
  157. package/src/migrations/005_agent_rpcs.sql +0 -168
  158. package/src/migrations/006_agent_rpc_localtime.sql +0 -181
  159. package/src/migrations/007_schedule_exception_precedence.sql +0 -290
  160. package/src/migrations/008_agent_rpc_exception_precedence.sql +0 -172
  161. package/src/migrations/009_public_booking_location.sql +0 -162
  162. package/src/migrations/010_public_service_curation.sql +0 -31
  163. package/src/migrations/011_default_assignee.sql +0 -167
  164. package/src/migrations/012_drop_legacy_booking_overload.sql +0 -29
  165. package/src/migrations/013_normalize_v_appointments.sql +0 -199
  166. package/src/migrations/014_agent_rpc_update_appointment.sql +0 -199
  167. package/src/migrations/index.ts +0 -2377
  168. package/src/public/BookingWidget.tsx +0 -324
  169. package/src/public/components/BrandHeader.tsx +0 -27
  170. package/src/public/components/ServiceCard.tsx +0 -39
  171. package/src/public/components/SidebarExtras.tsx +0 -30
  172. package/src/public/components/StepRailItem.tsx +0 -27
  173. package/src/public/components/SuccessPanel.tsx +0 -27
  174. package/src/public/components/SummaryRail.tsx +0 -90
  175. package/src/public/components/defaults.ts +0 -14
  176. package/src/public/context.tsx +0 -103
  177. package/src/public/createPublicBookingPlugin.ts +0 -281
  178. package/src/public/data.supabase.ts +0 -97
  179. package/src/public/data.ts +0 -105
  180. package/src/public/format.ts +0 -96
  181. package/src/public/hooks.ts +0 -110
  182. package/src/public/index.ts +0 -69
  183. package/src/public/steps/ContactStep.tsx +0 -184
  184. package/src/public/steps/DateTimeStep.tsx +0 -138
  185. package/src/public/steps/OverviewStep.tsx +0 -81
  186. package/src/public/steps/PaymentStep.tsx +0 -180
  187. package/src/public/types.ts +0 -250
  188. package/src/registries.ts +0 -35
  189. package/src/store.ts +0 -311
  190. package/src/types.ts +0 -273
  191. package/src/views/CalendarView.tsx +0 -1112
  192. package/src/views/ConfirmationsView.tsx +0 -155
  193. /package/dist/{financial-bridge.d.ts → bridges/financial-bridge.d.ts} +0 -0
  194. /package/dist/{registries.d.ts → data/registries.d.ts} +0 -0
  195. /package/dist/public/{BookingWidget.d.ts → views/BookingWidget.d.ts} +0 -0
  196. /package/dist/{calendar-styles.d.ts → styles/calendar-styles.d.ts} +0 -0
@@ -1,175 +0,0 @@
1
- -- ---------------------------------------------------------------------------
2
- -- Public booking — tenant-configurable client kind
3
- -- ---------------------------------------------------------------------------
4
- -- 001 hardcoded kind='customer' for people created by create_public_booking.
5
- -- Verticals whose client archetype uses another kind (school → 'student',
6
- -- clinic → whatever the app registers) ended up with people invisible to the
7
- -- app's own pages/lookups and with entity links resolving to unregistered
8
- -- routes (#/customers/:id → not found).
9
- --
10
- -- The client kind now comes from TENANT SETTINGS (admin-controlled — never
11
- -- from the anon caller): tenants.settings.booking.client_kind
12
- -- Default remains 'customer', so existing pools/tenants are unaffected until
13
- -- they opt in:
14
- -- UPDATE tenants SET settings = jsonb_set(COALESCE(settings,'{}'::jsonb),
15
- -- '{booking,client_kind}', '"student"') WHERE id = '<tenant>';
16
- --
17
- -- The upsert no longer relies on the kind-specific partial unique index
18
- -- (people_tenant_customer_phone) — a per-(tenant, phone) advisory lock
19
- -- serializes concurrent first-bookings for any kind. The 001 index stays in
20
- -- place (harmless, still guards direct customer writes).
21
- --
22
- -- Idempotent: CREATE OR REPLACE, same signature as 001.
23
- -- ---------------------------------------------------------------------------
24
- CREATE OR REPLACE FUNCTION public.create_public_booking(
25
- p_tenant_id uuid,
26
- p_service_id uuid,
27
- p_starts_at timestamptz,
28
- p_name text,
29
- p_phone text,
30
- p_email text DEFAULT NULL,
31
- p_notes text DEFAULT NULL
32
- ) RETURNS TABLE (booking_id uuid, starts_at timestamptz, ends_at timestamptz)
33
- LANGUAGE plpgsql SECURITY DEFINER
34
- SET search_path = public
35
- AS $$
36
- DECLARE
37
- v_service record;
38
- v_person_id uuid;
39
- v_assignee uuid;
40
- v_ends timestamptz;
41
- v_order uuid;
42
- v_booking uuid;
43
- v_phone text;
44
- v_tz text;
45
- v_notes text;
46
- v_client_kind text;
47
- BEGIN
48
- -- validation: anon-callable, everything tight
49
- IF p_name IS NULL OR length(trim(p_name)) NOT BETWEEN 2 AND 120 THEN
50
- RAISE EXCEPTION 'invalid name';
51
- END IF;
52
- v_phone := regexp_replace(COALESCE(p_phone, ''), '\D', '', 'g');
53
- IF length(v_phone) NOT BETWEEN 8 AND 15 THEN
54
- RAISE EXCEPTION 'invalid phone';
55
- END IF;
56
- IF p_email IS NOT NULL AND (length(p_email) > 254 OR p_email !~ '^[^@\s]+@[^@\s]+\.[^@\s]+$') THEN
57
- RAISE EXCEPTION 'invalid email';
58
- END IF;
59
- v_notes := left(p_notes, 500);
60
- IF p_starts_at IS NULL OR p_starts_at <= now() OR p_starts_at > now() + interval '90 days' THEN
61
- RAISE EXCEPTION 'invalid start';
62
- END IF;
63
-
64
- SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
65
- INTO v_service
66
- FROM services s
67
- WHERE s.id = p_service_id AND s.tenant_id = p_tenant_id
68
- AND s.is_active AND s.status = 'active';
69
- IF NOT FOUND THEN RAISE EXCEPTION 'unknown service'; END IF;
70
-
71
- v_ends := p_starts_at + make_interval(mins => v_service.duration_minutes);
72
-
73
- -- tenant config: timezone + client kind (admin-set; sanitized, never caller input)
74
- SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo'),
75
- COALESCE(NULLIF(trim(t.settings->'booking'->>'client_kind'), ''), 'customer')
76
- INTO v_tz, v_client_kind
77
- FROM tenants t WHERE t.id = p_tenant_id;
78
- IF v_tz IS NULL THEN RAISE EXCEPTION 'unknown tenant'; END IF;
79
- IF v_client_kind !~ '^[a-z_]{2,32}$' THEN v_client_kind := 'customer'; END IF;
80
-
81
- -- pick the assignee whose working hours cover the slot
82
- SELECT sc.assignee_id INTO v_assignee
83
- FROM schedules sc
84
- WHERE sc.tenant_id = p_tenant_id
85
- AND sc.kind = 'working_hours'
86
- AND sc.is_active
87
- AND (
88
- sc.specific_date = (p_starts_at AT TIME ZONE v_tz)::date
89
- OR (sc.specific_date IS NULL
90
- AND sc.day_of_week = EXTRACT(dow FROM p_starts_at AT TIME ZONE v_tz)::smallint)
91
- )
92
- AND (p_starts_at AT TIME ZONE v_tz)::time >= sc.starts_at
93
- AND (v_ends AT TIME ZONE v_tz)::time <= sc.ends_at
94
- ORDER BY sc.assignee_id
95
- LIMIT 1;
96
- IF v_assignee IS NULL THEN RAISE EXCEPTION 'slot outside working hours'; END IF;
97
-
98
- -- serialize per (tenant, assignee) to kill the double-book race
99
- PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_assignee::text, 0));
100
-
101
- IF EXISTS (
102
- SELECT 1 FROM appointments b
103
- WHERE b.tenant_id = p_tenant_id
104
- AND b.assignee_id = v_assignee
105
- AND b.status NOT IN ('cancelled', 'no_show')
106
- AND b.starts_at < v_ends
107
- AND COALESCE(b.ends_at, b.starts_at) > p_starts_at
108
- ) THEN
109
- RAISE EXCEPTION 'slot no longer available';
110
- END IF;
111
-
112
- -- anti-spam: cap open future public bookings per phone per tenant
113
- IF (
114
- SELECT count(*) FROM appointments b
115
- JOIN people p ON p.id = b.party_id
116
- WHERE b.tenant_id = p_tenant_id
117
- AND p.phone = v_phone
118
- AND b.starts_at > now()
119
- AND b.status = 'scheduled'
120
- ) >= 3 THEN
121
- RAISE EXCEPTION 'too many open bookings for this phone';
122
- END IF;
123
-
124
- -- upsert the client by (tenant, kind, phone). Kind is tenant-configured, so
125
- -- the 001 customer-only partial index can't arbitrate — a per-(tenant,
126
- -- phone) advisory lock serializes concurrent first-bookings instead.
127
- PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || ':' || v_phone, 1));
128
-
129
- SELECT p.id INTO v_person_id
130
- FROM people p
131
- WHERE p.tenant_id = p_tenant_id AND p.kind = v_client_kind AND p.phone = v_phone
132
- ORDER BY p.created_at
133
- LIMIT 1;
134
-
135
- IF v_person_id IS NOT NULL THEN
136
- UPDATE people SET
137
- name = trim(p_name),
138
- email = COALESCE(p_email, people.email),
139
- updated_at = now()
140
- WHERE id = v_person_id;
141
- ELSE
142
- INSERT INTO people (tenant_id, kind, name, phone, email, metadata)
143
- VALUES (p_tenant_id, v_client_kind, trim(p_name), v_phone, p_email,
144
- jsonb_build_object('source', 'public_booking'))
145
- RETURNING id INTO v_person_id;
146
- END IF;
147
-
148
- -- order + booking + order_item (statuses hardcoded — caller cannot set)
149
- INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
150
- VALUES (p_tenant_id, 'appointment', 'scheduled', v_person_id, v_assignee,
151
- v_service.price, v_service.price, v_notes,
152
- jsonb_build_object('source', 'public_booking',
153
- 'serviceNames', v_service.name,
154
- 'contactName', trim(p_name)))
155
- RETURNING id INTO v_order;
156
-
157
- INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
158
- starts_at, ends_at, status, notes, metadata)
159
- VALUES (p_tenant_id, 'appointment', v_person_id, v_assignee, v_order,
160
- p_starts_at, v_ends, 'scheduled', v_notes,
161
- jsonb_build_object('source', 'public_booking', 'serviceNames', v_service.name))
162
- RETURNING id INTO v_booking;
163
-
164
- INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
165
- sort_order, duration_minutes, assignee_id)
166
- VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
167
- 0, v_service.duration_minutes, v_assignee);
168
-
169
- RETURN QUERY SELECT v_booking, p_starts_at, v_ends;
170
- END;
171
- $$;
172
-
173
- REVOKE ALL ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text) FROM public;
174
- GRANT EXECUTE ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text)
175
- TO anon, authenticated, service_role;
@@ -1,176 +0,0 @@
1
- -- ---------------------------------------------------------------------------
2
- -- Public booking — respect the professional the visitor saw
3
- -- ---------------------------------------------------------------------------
4
- -- Until now create_public_booking IGNORED which professional the site showed
5
- -- availability for and re-picked by working hours (ORDER BY assignee_id
6
- -- LIMIT 1). With several professionals covering the same slot the booking
7
- -- landed on someone else than displayed ("Equipe Great DJs" on the site,
8
- -- "Rafa Groove" in the agenda). New optional p_assignee_id:
9
- -- * provided → validate THAT professional's working hours cover the slot and
10
- -- book them — never silently reassign (mismatch becomes an explicit error);
11
- -- * NULL → auto-pick as before (sites that don't pin a professional).
12
- -- Conflict check + advisory lock already run against the resolved assignee, so
13
- -- the double-book guard follows whichever path resolves.
14
- --
15
- -- Signature change (8th arg): the old 7-arg function is DROPPED first —
16
- -- CREATE OR REPLACE with a different signature would create an ambiguous
17
- -- PostgREST overload instead of replacing.
18
- -- Includes 003's tenant-configurable client kind. Idempotent.
19
- -- ---------------------------------------------------------------------------
20
- DROP FUNCTION IF EXISTS public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text);
21
-
22
- CREATE OR REPLACE FUNCTION public.create_public_booking(
23
- p_tenant_id uuid,
24
- p_service_id uuid,
25
- p_starts_at timestamptz,
26
- p_name text,
27
- p_phone text,
28
- p_email text DEFAULT NULL,
29
- p_notes text DEFAULT NULL,
30
- p_assignee_id uuid DEFAULT NULL
31
- ) RETURNS TABLE (booking_id uuid, starts_at timestamptz, ends_at timestamptz)
32
- LANGUAGE plpgsql SECURITY DEFINER
33
- SET search_path = public
34
- AS $$
35
- DECLARE
36
- v_service record;
37
- v_person_id uuid;
38
- v_assignee uuid;
39
- v_ends timestamptz;
40
- v_order uuid;
41
- v_booking uuid;
42
- v_phone text;
43
- v_tz text;
44
- v_notes text;
45
- v_client_kind text;
46
- BEGIN
47
- -- validation: anon-callable, everything tight
48
- IF p_name IS NULL OR length(trim(p_name)) NOT BETWEEN 2 AND 120 THEN
49
- RAISE EXCEPTION 'invalid name';
50
- END IF;
51
- v_phone := regexp_replace(COALESCE(p_phone, ''), '\D', '', 'g');
52
- IF length(v_phone) NOT BETWEEN 8 AND 15 THEN
53
- RAISE EXCEPTION 'invalid phone';
54
- END IF;
55
- IF p_email IS NOT NULL AND (length(p_email) > 254 OR p_email !~ '^[^@\s]+@[^@\s]+\.[^@\s]+$') THEN
56
- RAISE EXCEPTION 'invalid email';
57
- END IF;
58
- v_notes := left(p_notes, 500);
59
- IF p_starts_at IS NULL OR p_starts_at <= now() OR p_starts_at > now() + interval '90 days' THEN
60
- RAISE EXCEPTION 'invalid start';
61
- END IF;
62
-
63
- SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
64
- INTO v_service
65
- FROM services s
66
- WHERE s.id = p_service_id AND s.tenant_id = p_tenant_id
67
- AND s.is_active AND s.status = 'active';
68
- IF NOT FOUND THEN RAISE EXCEPTION 'unknown service'; END IF;
69
-
70
- v_ends := p_starts_at + make_interval(mins => v_service.duration_minutes);
71
-
72
- -- tenant config: timezone + client kind (admin-set; sanitized, never caller input)
73
- SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo'),
74
- COALESCE(NULLIF(trim(t.settings->'booking'->>'client_kind'), ''), 'customer')
75
- INTO v_tz, v_client_kind
76
- FROM tenants t WHERE t.id = p_tenant_id;
77
- IF v_tz IS NULL THEN RAISE EXCEPTION 'unknown tenant'; END IF;
78
- IF v_client_kind !~ '^[a-z_]{2,32}$' THEN v_client_kind := 'customer'; END IF;
79
-
80
- -- resolve the professional: the one the visitor saw (validated), else the
81
- -- first whose working hours cover the slot
82
- SELECT sc.assignee_id INTO v_assignee
83
- FROM schedules sc
84
- WHERE sc.tenant_id = p_tenant_id
85
- AND sc.kind = 'working_hours'
86
- AND sc.is_active
87
- AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)
88
- AND (
89
- sc.specific_date = (p_starts_at AT TIME ZONE v_tz)::date
90
- OR (sc.specific_date IS NULL
91
- AND sc.day_of_week = EXTRACT(dow FROM p_starts_at AT TIME ZONE v_tz)::smallint)
92
- )
93
- AND (p_starts_at AT TIME ZONE v_tz)::time >= sc.starts_at
94
- AND (v_ends AT TIME ZONE v_tz)::time <= sc.ends_at
95
- ORDER BY sc.assignee_id
96
- LIMIT 1;
97
- IF v_assignee IS NULL THEN RAISE EXCEPTION 'slot outside working hours'; 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 client by (tenant, kind, phone), serialized by advisory lock
126
- -- (kind is tenant-configured, so the customer-only partial index can't
127
- -- arbitrate)
128
- PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || ':' || v_phone, 1));
129
-
130
- SELECT p.id INTO v_person_id
131
- FROM people p
132
- WHERE p.tenant_id = p_tenant_id AND p.kind = v_client_kind AND p.phone = v_phone
133
- ORDER BY p.created_at
134
- LIMIT 1;
135
-
136
- IF v_person_id IS NOT NULL THEN
137
- UPDATE people SET
138
- name = trim(p_name),
139
- email = COALESCE(p_email, people.email),
140
- updated_at = now()
141
- WHERE id = v_person_id;
142
- ELSE
143
- INSERT INTO people (tenant_id, kind, name, phone, email, metadata)
144
- VALUES (p_tenant_id, v_client_kind, trim(p_name), v_phone, p_email,
145
- jsonb_build_object('source', 'public_booking'))
146
- RETURNING id INTO v_person_id;
147
- END IF;
148
-
149
- -- order + booking + order_item (statuses hardcoded — caller cannot set)
150
- INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
151
- VALUES (p_tenant_id, 'appointment', 'scheduled', v_person_id, v_assignee,
152
- v_service.price, v_service.price, v_notes,
153
- jsonb_build_object('source', 'public_booking',
154
- 'serviceNames', v_service.name,
155
- 'contactName', trim(p_name)))
156
- RETURNING id INTO v_order;
157
-
158
- INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
159
- starts_at, ends_at, status, notes, metadata)
160
- VALUES (p_tenant_id, 'appointment', v_person_id, v_assignee, v_order,
161
- p_starts_at, v_ends, 'scheduled', v_notes,
162
- jsonb_build_object('source', 'public_booking', 'serviceNames', v_service.name))
163
- RETURNING id INTO v_booking;
164
-
165
- INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
166
- sort_order, duration_minutes, assignee_id)
167
- VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
168
- 0, v_service.duration_minutes, v_assignee);
169
-
170
- RETURN QUERY SELECT v_booking, p_starts_at, v_ends;
171
- END;
172
- $$;
173
-
174
- REVOKE ALL ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text, uuid) FROM public;
175
- GRANT EXECUTE ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text, uuid)
176
- TO anon, authenticated, service_role;
@@ -1,168 +0,0 @@
1
- -- ============================================================================
2
- -- plugin-agenda 005: server-plane agent write RPC.
3
- --
4
- -- public.agent_agenda_create_appointment — the INTERNAL (operator-actor)
5
- -- counterpart of create_public_booking (001 §5): same invariants (working
6
- -- hours, advisory-lock race safety, hardcoded statuses), but:
7
- -- * actor-authorized: public.agent_guard (spine 015) runs role→plan→limit
8
- -- BEFORE anything — denial comes back as a structured jsonb the agent can
9
- -- explain conversationally, never a raised exception;
10
- -- * the client is an EXISTING person id (the agent resolves names→ids via
11
- -- reads first — ambiguity is a conversation problem, not a SQL one);
12
- -- * a requested professional is honored (working hours permitting);
13
- -- * audited: audit_logs row with the acting user.
14
- --
15
- -- Contract (all agent_* RPCs): (p_tenant_id, p_actor_user_id, p_payload jsonb)
16
- -- → jsonb {ok:true, id, record:{...}} | {ok:false, denial:{...}} | {ok:false,
17
- -- error text}. GRANT authenticated+service_role — NEVER anon (the Fayz broker
18
- -- calls with the pool service key and injects tenant/actor server-side).
19
- -- ============================================================================
20
-
21
- CREATE OR REPLACE FUNCTION public.agent_agenda_create_appointment(
22
- p_tenant_id uuid,
23
- p_actor_user_id uuid,
24
- p_payload jsonb
25
- ) RETURNS jsonb
26
- LANGUAGE plpgsql SECURITY DEFINER
27
- SET search_path = public
28
- AS $$
29
- DECLARE
30
- v_denial jsonb;
31
- v_used int;
32
- v_client_id uuid;
33
- v_service_id uuid;
34
- v_professional uuid;
35
- v_starts timestamptz;
36
- v_notes text;
37
- v_service record;
38
- v_client record;
39
- v_ends timestamptz;
40
- v_tz text;
41
- v_order uuid;
42
- v_booking uuid;
43
- BEGIN
44
- -- ── payload ──────────────────────────────────────────────────────────────
45
- BEGIN
46
- v_client_id := (p_payload->>'client_id')::uuid;
47
- v_service_id := (p_payload->>'service_id')::uuid;
48
- v_professional := (p_payload->>'professional_id')::uuid;
49
- v_starts := (p_payload->>'starts_at')::timestamptz;
50
- EXCEPTION WHEN OTHERS THEN
51
- RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);
52
- END;
53
- v_notes := left(p_payload->>'notes', 500);
54
- IF v_client_id IS NULL OR v_service_id IS NULL OR v_starts IS NULL THEN
55
- RETURN jsonb_build_object('ok', false, 'error', 'client_id, service_id and starts_at are required');
56
- END IF;
57
- IF v_starts <= now() OR v_starts > now() + interval '365 days' THEN
58
- RETURN jsonb_build_object('ok', false, 'error', 'starts_at must be in the future (max 1 year)');
59
- END IF;
60
-
61
- -- ── authorization: role → plan → bookings_month cap ──────────────────────
62
- SELECT count(*) INTO v_used FROM appointments b
63
- WHERE b.tenant_id = p_tenant_id
64
- AND b.created_at >= date_trunc('month', now());
65
- v_denial := agent_guard(p_tenant_id, p_actor_user_id,
66
- 'appointments', 'create', 'bookings_month', v_used, 1);
67
- IF v_denial IS NOT NULL THEN
68
- RETURN jsonb_build_object('ok', false, 'denial', v_denial);
69
- END IF;
70
-
71
- -- ── invariants ───────────────────────────────────────────────────────────
72
- SELECT p.id, p.name INTO v_client
73
- FROM people p
74
- WHERE p.id = v_client_id AND p.tenant_id = p_tenant_id;
75
- IF NOT FOUND THEN
76
- RETURN jsonb_build_object('ok', false, 'error', 'unknown client for this tenant');
77
- END IF;
78
-
79
- SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
80
- INTO v_service
81
- FROM services s
82
- WHERE s.id = v_service_id AND s.tenant_id = p_tenant_id
83
- AND s.is_active AND s.status = 'active';
84
- IF NOT FOUND THEN
85
- RETURN jsonb_build_object('ok', false, 'error', 'unknown or inactive service');
86
- END IF;
87
-
88
- v_ends := v_starts + make_interval(mins => v_service.duration_minutes);
89
- SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
90
- FROM tenants t WHERE t.id = p_tenant_id;
91
-
92
- -- requested professional honored; otherwise pick one whose hours cover the slot
93
- SELECT sc.assignee_id INTO v_professional
94
- FROM schedules sc
95
- WHERE sc.tenant_id = p_tenant_id
96
- AND sc.kind = 'working_hours'
97
- AND sc.is_active
98
- AND (v_professional IS NULL OR sc.assignee_id = v_professional)
99
- AND (
100
- sc.specific_date = (v_starts AT TIME ZONE v_tz)::date
101
- OR (sc.specific_date IS NULL
102
- AND sc.day_of_week = EXTRACT(dow FROM v_starts AT TIME ZONE v_tz)::smallint)
103
- )
104
- AND (v_starts AT TIME ZONE v_tz)::time >= sc.starts_at
105
- AND (v_ends AT TIME ZONE v_tz)::time <= sc.ends_at
106
- ORDER BY sc.assignee_id
107
- LIMIT 1;
108
- IF v_professional IS NULL THEN
109
- RETURN jsonb_build_object('ok', false, 'error', 'slot outside working hours for the requested professional');
110
- END IF;
111
-
112
- -- race safety: serialize per (tenant, professional), then conflict-check
113
- PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_professional::text, 0));
114
- IF EXISTS (
115
- SELECT 1 FROM appointments b
116
- WHERE b.tenant_id = p_tenant_id
117
- AND b.assignee_id = v_professional
118
- AND b.status NOT IN ('cancelled', 'no_show')
119
- AND b.starts_at < v_ends
120
- AND COALESCE(b.ends_at, b.starts_at) > v_starts
121
- ) THEN
122
- RETURN jsonb_build_object('ok', false, 'error', 'slot no longer available');
123
- END IF;
124
-
125
- -- ── write (statuses hardcoded) + audit ───────────────────────────────────
126
- INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
127
- VALUES (p_tenant_id, 'appointment', 'scheduled', v_client_id, v_professional,
128
- v_service.price, v_service.price, v_notes,
129
- jsonb_build_object('source', 'agent', 'serviceNames', v_service.name))
130
- RETURNING id INTO v_order;
131
-
132
- INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
133
- starts_at, ends_at, status, notes, metadata)
134
- VALUES (p_tenant_id, 'appointment', v_client_id, v_professional, v_order,
135
- v_starts, v_ends, 'scheduled', v_notes,
136
- jsonb_build_object('source', 'agent', 'serviceNames', v_service.name))
137
- RETURNING id INTO v_booking;
138
-
139
- INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
140
- sort_order, duration_minutes, assignee_id)
141
- VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
142
- 0, v_service.duration_minutes, v_professional);
143
-
144
- INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
145
- VALUES (p_tenant_id, p_actor_user_id, 'agent.createAppointment', 'appointment', v_booking::text,
146
- jsonb_build_object('payload', p_payload, 'order_id', v_order));
147
-
148
- RETURN jsonb_build_object(
149
- 'ok', true,
150
- 'id', v_booking,
151
- 'record', jsonb_build_object(
152
- 'ref', jsonb_build_object('id', v_booking, 'resource', 'appointments',
153
- 'archetype', 'schedule:appointment'),
154
- 'starts_at', v_starts,
155
- 'ends_at', v_ends,
156
- 'client_name', v_client.name,
157
- 'service_name', v_service.name,
158
- 'price', v_service.price,
159
- 'status', 'scheduled'
160
- )
161
- );
162
- END;
163
- $$;
164
-
165
- REVOKE ALL ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb) FROM public;
166
- REVOKE EXECUTE ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb) FROM anon;
167
- GRANT EXECUTE ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb)
168
- TO authenticated, service_role;