@fayz-ai/plugin-agenda 0.8.1 → 0.8.4

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 (118) hide show
  1. package/dist/{chunk-DRZF6CV2.js → chunk-Q2PAWBPR.js} +62 -3
  2. package/dist/chunk-Q2PAWBPR.js.map +1 -0
  3. package/dist/{chunk-LFUELUIT.cjs → chunk-RX2HMWXS.cjs} +62 -2
  4. package/dist/chunk-RX2HMWXS.cjs.map +1 -0
  5. package/dist/components/AgendaGeneralSettings.d.ts.map +1 -1
  6. package/dist/components/AppointmentModal.d.ts.map +1 -1
  7. package/dist/components/CalendarToolbar.d.ts.map +1 -1
  8. package/dist/data/supabase.d.ts +5 -1
  9. package/dist/data/supabase.d.ts.map +1 -1
  10. package/dist/index.cjs +1078 -768
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +921 -611
  14. package/dist/index.js.map +1 -1
  15. package/dist/integrations/google-calendar/components/CalendarColorDot.d.ts +6 -0
  16. package/dist/integrations/google-calendar/components/CalendarColorDot.d.ts.map +1 -0
  17. package/dist/integrations/google-calendar/components/ChannelMappingList.d.ts +9 -0
  18. package/dist/integrations/google-calendar/components/ChannelMappingList.d.ts.map +1 -0
  19. package/dist/integrations/google-calendar/components/ImportEventsWizard.d.ts +12 -0
  20. package/dist/integrations/google-calendar/components/ImportEventsWizard.d.ts.map +1 -0
  21. package/dist/integrations/google-calendar/components/useAgendaLookups.d.ts +26 -0
  22. package/dist/integrations/google-calendar/components/useAgendaLookups.d.ts.map +1 -0
  23. package/dist/integrations/google-calendar/connectorDef.d.ts.map +1 -1
  24. package/dist/integrations/google-calendar/data/supabase.d.ts +33 -1
  25. package/dist/integrations/google-calendar/data/supabase.d.ts.map +1 -1
  26. package/dist/integrations/google-calendar/data/tables.d.ts +6 -0
  27. package/dist/integrations/google-calendar/data/tables.d.ts.map +1 -0
  28. package/dist/integrations/google-calendar/index.d.ts +1 -1
  29. package/dist/integrations/google-calendar/index.d.ts.map +1 -1
  30. package/dist/integrations/google-calendar/mapping.d.ts +56 -4
  31. package/dist/integrations/google-calendar/mapping.d.ts.map +1 -1
  32. package/dist/integrations/google-calendar/types.d.ts +101 -0
  33. package/dist/integrations/google-calendar/types.d.ts.map +1 -1
  34. package/dist/locales/en.d.ts.map +1 -1
  35. package/dist/locales/pt-BR.d.ts.map +1 -1
  36. package/dist/migrations/index.d.ts +12 -1
  37. package/dist/migrations/index.d.ts.map +1 -1
  38. package/dist/public/BookingWidget.d.ts.map +1 -1
  39. package/dist/public/context.d.ts +3 -1
  40. package/dist/public/context.d.ts.map +1 -1
  41. package/dist/public/createPublicBookingPlugin.d.ts +7 -1
  42. package/dist/public/createPublicBookingPlugin.d.ts.map +1 -1
  43. package/dist/public/data.d.ts +6 -0
  44. package/dist/public/data.d.ts.map +1 -1
  45. package/dist/public/data.supabase.d.ts.map +1 -1
  46. package/dist/public/format.d.ts +22 -1
  47. package/dist/public/format.d.ts.map +1 -1
  48. package/dist/public/hooks.d.ts.map +1 -1
  49. package/dist/public/index.cjs +31 -63
  50. package/dist/public/index.cjs.map +1 -1
  51. package/dist/public/index.d.ts +1 -1
  52. package/dist/public/index.d.ts.map +1 -1
  53. package/dist/public/index.js +27 -61
  54. package/dist/public/index.js.map +1 -1
  55. package/dist/public/steps/ContactStep.d.ts +4 -2
  56. package/dist/public/steps/ContactStep.d.ts.map +1 -1
  57. package/dist/public/types.d.ts +16 -0
  58. package/dist/public/types.d.ts.map +1 -1
  59. package/dist/views/CalendarView.d.ts.map +1 -1
  60. package/package.json +5 -5
  61. package/src/components/AgendaGeneralSettings.tsx +38 -16
  62. package/src/components/AppointmentModal.tsx +50 -108
  63. package/src/components/CalendarToolbar.tsx +10 -7
  64. package/src/data/supabase.ts +67 -13
  65. package/src/index.ts +56 -8
  66. package/src/integrations/google-calendar/components/CalendarColorDot.tsx +23 -0
  67. package/src/integrations/google-calendar/components/ChannelMappingList.tsx +329 -0
  68. package/src/integrations/google-calendar/components/ImportEventsWizard.tsx +215 -0
  69. package/src/integrations/google-calendar/components/useAgendaLookups.ts +100 -0
  70. package/src/integrations/google-calendar/connectorDef.tsx +123 -40
  71. package/src/integrations/google-calendar/data/supabase.ts +109 -13
  72. package/src/integrations/google-calendar/data/tables.ts +12 -0
  73. package/src/integrations/google-calendar/index.ts +10 -1
  74. package/src/integrations/google-calendar/integration.test.ts +126 -1
  75. package/src/integrations/google-calendar/mapping.ts +149 -9
  76. package/src/integrations/google-calendar/migrations/001_google_calendar.sql +9 -6
  77. package/src/integrations/google-calendar/migrations/002_smart_sync.sql +517 -0
  78. package/src/integrations/google-calendar/migrations/003_plg_rename.sql +273 -0
  79. package/src/integrations/google-calendar/types.ts +111 -0
  80. package/src/locales/en.ts +1 -0
  81. package/src/locales/pt-BR.ts +1 -0
  82. package/src/migrations/001_public_booking.sql +7 -2
  83. package/src/migrations/003_public_client_kind.sql +175 -0
  84. package/src/migrations/004_public_booking_assignee.sql +176 -0
  85. package/src/migrations/005_agent_rpcs.sql +168 -0
  86. package/src/migrations/006_agent_rpc_localtime.sql +181 -0
  87. package/src/migrations/007_schedule_exception_precedence.sql +290 -0
  88. package/src/migrations/008_agent_rpc_exception_precedence.sql +172 -0
  89. package/src/migrations/009_public_booking_location.sql +162 -0
  90. package/src/migrations/010_public_service_curation.sql +31 -0
  91. package/src/migrations/011_default_assignee.sql +167 -0
  92. package/src/migrations/012_drop_legacy_booking_overload.sql +29 -0
  93. package/src/migrations/013_normalize_v_appointments.sql +199 -0
  94. package/src/migrations/index.ts +1791 -3
  95. package/src/public/BookingWidget.tsx +10 -1
  96. package/src/public/context.tsx +3 -0
  97. package/src/public/createPublicBookingPlugin.ts +14 -1
  98. package/src/public/data.supabase.ts +7 -4
  99. package/src/public/data.ts +6 -0
  100. package/src/public/format.ts +36 -1
  101. package/src/public/hooks.ts +7 -2
  102. package/src/public/index.ts +1 -0
  103. package/src/public/steps/ContactStep.tsx +10 -5
  104. package/src/public/types.ts +21 -0
  105. package/src/views/CalendarView.tsx +15 -1
  106. package/dist/chunk-3LJKZQK4.js +0 -12
  107. package/dist/chunk-3LJKZQK4.js.map +0 -1
  108. package/dist/chunk-DRZF6CV2.js.map +0 -1
  109. package/dist/chunk-LFUELUIT.cjs.map +0 -1
  110. package/dist/chunk-SYDBDI3Q.cjs +0 -15
  111. package/dist/chunk-SYDBDI3Q.cjs.map +0 -1
  112. package/dist/components/SearchCombobox.d.ts +0 -38
  113. package/dist/components/SearchCombobox.d.ts.map +0 -1
  114. package/dist/tenant-222EHYUC.cjs +0 -16
  115. package/dist/tenant-222EHYUC.cjs.map +0 -1
  116. package/dist/tenant-LAUWVUYB.js +0 -3
  117. package/dist/tenant-LAUWVUYB.js.map +0 -1
  118. package/src/components/SearchCombobox.tsx +0 -216
@@ -0,0 +1,290 @@
1
+ -- ============================================================================
2
+ -- plugin-agenda 007: specific-date exceptions take PRECEDENCE over weekly hours.
3
+ --
4
+ -- The admin ScheduleEditor (saveException) writes two kinds of specific-date
5
+ -- rows: 'available' = active rows with custom windows for the date; day off
6
+ -- ('unavailable') = ONE inactive 00:00–00:00 sentinel. Until now every reader
7
+ -- treated schedules as a flat union filtered by is_active, so:
8
+ -- • a day off was INVISIBLE (inactive sentinel filtered out, weekly rows
9
+ -- still matched) — the public site kept selling a day the admin closed;
10
+ -- • an 'available' exception ADDED to the weekly hours instead of replacing
11
+ -- them ("horário especial" should define the date's hours).
12
+ --
13
+ -- Fix: one helper — agenda_working_windows(tenant, date, assignee) — is now the
14
+ -- single source of a day's windows: if ANY specific-date row exists for an
15
+ -- assignee (active or sentinel), only the ACTIVE specific-date rows count for
16
+ -- that assignee; otherwise the weekly (day_of_week) rows apply. Both readers
17
+ -- are redefined on top of it:
18
+ -- • get_available_slots — body otherwise identical to the latest version
19
+ -- (google-calendar/002_smart_sync §5, tenant-wide 'block' clause KEPT);
20
+ -- • create_public_booking — body otherwise identical to 004.
21
+ --
22
+ -- ORDERING: on pools with the Google Calendar connector this file must run
23
+ -- AFTER integrations/google-calendar/migrations/002_smart_sync.sql (both
24
+ -- redefine get_available_slots; 007 is the superset and must win). Re-running
25
+ -- 007 last is always safe — it preserves 002's block semantics.
26
+ -- ============================================================================
27
+
28
+ -- ----------------------------------------------------------------------------
29
+ -- §1 agenda_working_windows — the day's working windows, exception-aware.
30
+ -- ----------------------------------------------------------------------------
31
+ CREATE OR REPLACE FUNCTION public.agenda_working_windows(
32
+ p_tenant_id uuid,
33
+ p_date date,
34
+ p_assignee_id uuid DEFAULT NULL
35
+ ) RETURNS TABLE (assignee_id uuid, win_start time, win_end time)
36
+ LANGUAGE sql STABLE
37
+ SET search_path = public
38
+ AS $$
39
+ WITH exc AS (
40
+ -- Every specific-date row for the date, ACTIVE OR NOT: the inactive day-off
41
+ -- sentinel must still suppress the weekly rows below.
42
+ SELECT sc.assignee_id, sc.starts_at, sc.ends_at, sc.is_active
43
+ FROM schedules sc
44
+ WHERE sc.tenant_id = p_tenant_id
45
+ AND sc.kind = 'working_hours'
46
+ AND sc.specific_date = p_date
47
+ AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)
48
+ )
49
+ SELECT e.assignee_id, e.starts_at, e.ends_at
50
+ FROM exc e
51
+ WHERE e.is_active
52
+ UNION ALL
53
+ SELECT sc.assignee_id, sc.starts_at, sc.ends_at
54
+ FROM schedules sc
55
+ WHERE sc.tenant_id = p_tenant_id
56
+ AND sc.kind = 'working_hours'
57
+ AND sc.is_active
58
+ AND sc.specific_date IS NULL
59
+ AND sc.day_of_week = EXTRACT(dow FROM p_date)::smallint
60
+ AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)
61
+ AND NOT EXISTS (SELECT 1 FROM exc e WHERE e.assignee_id = sc.assignee_id)
62
+ $$;
63
+
64
+ REVOKE ALL ON FUNCTION public.agenda_working_windows(uuid, date, uuid) FROM public;
65
+ REVOKE EXECUTE ON FUNCTION public.agenda_working_windows(uuid, date, uuid) FROM anon;
66
+ GRANT EXECUTE ON FUNCTION public.agenda_working_windows(uuid, date, uuid)
67
+ TO authenticated, service_role;
68
+
69
+ -- ----------------------------------------------------------------------------
70
+ -- §2 get_available_slots — day_schedules now comes from the helper.
71
+ -- Everything else is byte-identical to google-calendar/002_smart_sync §5.
72
+ -- ----------------------------------------------------------------------------
73
+ CREATE OR REPLACE FUNCTION public.get_available_slots(
74
+ p_tenant_id uuid,
75
+ p_date date,
76
+ p_duration_minutes integer,
77
+ p_assignee_id uuid DEFAULT NULL,
78
+ p_slot_interval integer DEFAULT 30
79
+ ) RETURNS TABLE (slot_start timestamptz, slot_end timestamptz)
80
+ LANGUAGE plpgsql STABLE SECURITY DEFINER
81
+ SET search_path = public
82
+ AS $$
83
+ DECLARE
84
+ v_tz text;
85
+ BEGIN
86
+ -- tight validation: this function is anon-callable
87
+ IF p_duration_minutes IS NULL OR p_duration_minutes NOT BETWEEN 5 AND 480 THEN
88
+ RAISE EXCEPTION 'invalid duration';
89
+ END IF;
90
+ IF p_slot_interval IS NULL OR p_slot_interval NOT BETWEEN 5 AND 240 THEN
91
+ RAISE EXCEPTION 'invalid interval';
92
+ END IF;
93
+ IF p_date IS NULL OR p_date < current_date OR p_date > current_date + 90 THEN
94
+ RETURN;
95
+ END IF;
96
+
97
+ SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
98
+ FROM tenants t WHERE t.id = p_tenant_id;
99
+ IF v_tz IS NULL THEN RETURN; END IF; -- unknown tenant → empty, not an error
100
+
101
+ RETURN QUERY
102
+ WITH day_schedules AS (
103
+ SELECT w.assignee_id, w.win_start, w.win_end
104
+ FROM agenda_working_windows(p_tenant_id, p_date, p_assignee_id) w
105
+ ),
106
+ candidate AS (
107
+ SELECT
108
+ ds.assignee_id,
109
+ gs AS s_start,
110
+ gs + make_interval(mins => p_duration_minutes) AS s_end
111
+ FROM day_schedules ds
112
+ CROSS JOIN LATERAL generate_series(
113
+ (p_date + ds.win_start) AT TIME ZONE v_tz,
114
+ ((p_date + ds.win_end) AT TIME ZONE v_tz) - make_interval(mins => p_duration_minutes),
115
+ make_interval(mins => p_slot_interval)
116
+ ) AS gs
117
+ )
118
+ SELECT DISTINCT c.s_start, c.s_end
119
+ FROM candidate c
120
+ WHERE NOT EXISTS (
121
+ SELECT 1 FROM appointments b
122
+ WHERE b.tenant_id = p_tenant_id
123
+ -- SMART SYNC (gcal 002): this professional's own bookings OR any
124
+ -- tenant-wide BLOCK (assignee_id IS NULL AND kind='block') busy the slot.
125
+ AND (b.assignee_id = c.assignee_id
126
+ OR (b.assignee_id IS NULL AND b.kind = 'block'))
127
+ AND b.status NOT IN ('cancelled', 'no_show')
128
+ AND b.starts_at < c.s_end
129
+ AND COALESCE(b.ends_at, b.starts_at) > c.s_start
130
+ )
131
+ ORDER BY 1;
132
+ END;
133
+ $$;
134
+
135
+ REVOKE ALL ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer) FROM public;
136
+ GRANT EXECUTE ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer)
137
+ TO anon, authenticated, service_role;
138
+
139
+ -- ----------------------------------------------------------------------------
140
+ -- §3 create_public_booking — professional resolution goes through the helper
141
+ -- (a booking on a closed date now fails 'slot outside working hours').
142
+ -- Everything else is byte-identical to 004.
143
+ -- ----------------------------------------------------------------------------
144
+ CREATE OR REPLACE FUNCTION public.create_public_booking(
145
+ p_tenant_id uuid,
146
+ p_service_id uuid,
147
+ p_starts_at timestamptz,
148
+ p_name text,
149
+ p_phone text,
150
+ p_email text DEFAULT NULL,
151
+ p_notes text DEFAULT NULL,
152
+ p_assignee_id uuid DEFAULT NULL
153
+ ) RETURNS TABLE (booking_id uuid, starts_at timestamptz, ends_at timestamptz)
154
+ LANGUAGE plpgsql SECURITY DEFINER
155
+ SET search_path = public
156
+ AS $$
157
+ DECLARE
158
+ v_service record;
159
+ v_person_id uuid;
160
+ v_assignee uuid;
161
+ v_ends timestamptz;
162
+ v_order uuid;
163
+ v_booking uuid;
164
+ v_phone text;
165
+ v_tz text;
166
+ v_notes text;
167
+ v_client_kind text;
168
+ BEGIN
169
+ -- validation: anon-callable, everything tight
170
+ IF p_name IS NULL OR length(trim(p_name)) NOT BETWEEN 2 AND 120 THEN
171
+ RAISE EXCEPTION 'invalid name';
172
+ END IF;
173
+ v_phone := regexp_replace(COALESCE(p_phone, ''), '\D', '', 'g');
174
+ IF length(v_phone) NOT BETWEEN 8 AND 15 THEN
175
+ RAISE EXCEPTION 'invalid phone';
176
+ END IF;
177
+ IF p_email IS NOT NULL AND (length(p_email) > 254 OR p_email !~ '^[^@\s]+@[^@\s]+\.[^@\s]+$') THEN
178
+ RAISE EXCEPTION 'invalid email';
179
+ END IF;
180
+ v_notes := left(p_notes, 500);
181
+ IF p_starts_at IS NULL OR p_starts_at <= now() OR p_starts_at > now() + interval '90 days' THEN
182
+ RAISE EXCEPTION 'invalid start';
183
+ END IF;
184
+
185
+ SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
186
+ INTO v_service
187
+ FROM services s
188
+ WHERE s.id = p_service_id AND s.tenant_id = p_tenant_id
189
+ AND s.is_active AND s.status = 'active';
190
+ IF NOT FOUND THEN RAISE EXCEPTION 'unknown service'; END IF;
191
+
192
+ v_ends := p_starts_at + make_interval(mins => v_service.duration_minutes);
193
+
194
+ -- tenant config: timezone + client kind (admin-set; sanitized, never caller input)
195
+ SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo'),
196
+ COALESCE(NULLIF(trim(t.settings->'booking'->>'client_kind'), ''), 'customer')
197
+ INTO v_tz, v_client_kind
198
+ FROM tenants t WHERE t.id = p_tenant_id;
199
+ IF v_tz IS NULL THEN RAISE EXCEPTION 'unknown tenant'; END IF;
200
+ IF v_client_kind !~ '^[a-z_]{2,32}$' THEN v_client_kind := 'customer'; END IF;
201
+
202
+ -- resolve the professional: the one the visitor saw (validated), else the
203
+ -- first whose EXCEPTION-AWARE working hours cover the slot (007)
204
+ SELECT w.assignee_id INTO v_assignee
205
+ FROM agenda_working_windows(
206
+ p_tenant_id, (p_starts_at AT TIME ZONE v_tz)::date, p_assignee_id) w
207
+ WHERE (p_starts_at AT TIME ZONE v_tz)::time >= w.win_start
208
+ AND (v_ends AT TIME ZONE v_tz)::time <= w.win_end
209
+ ORDER BY w.assignee_id
210
+ LIMIT 1;
211
+ IF v_assignee IS NULL THEN RAISE EXCEPTION 'slot outside working hours'; END IF;
212
+
213
+ -- serialize per (tenant, assignee) to kill the double-book race
214
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_assignee::text, 0));
215
+
216
+ IF EXISTS (
217
+ SELECT 1 FROM appointments b
218
+ WHERE b.tenant_id = p_tenant_id
219
+ AND b.assignee_id = v_assignee
220
+ AND b.status NOT IN ('cancelled', 'no_show')
221
+ AND b.starts_at < v_ends
222
+ AND COALESCE(b.ends_at, b.starts_at) > p_starts_at
223
+ ) THEN
224
+ RAISE EXCEPTION 'slot no longer available';
225
+ END IF;
226
+
227
+ -- anti-spam: cap open future public bookings per phone per tenant
228
+ IF (
229
+ SELECT count(*) FROM appointments b
230
+ JOIN people p ON p.id = b.party_id
231
+ WHERE b.tenant_id = p_tenant_id
232
+ AND p.phone = v_phone
233
+ AND b.starts_at > now()
234
+ AND b.status = 'scheduled'
235
+ ) >= 3 THEN
236
+ RAISE EXCEPTION 'too many open bookings for this phone';
237
+ END IF;
238
+
239
+ -- upsert the client by (tenant, kind, phone), serialized by advisory lock
240
+ -- (kind is tenant-configured, so the customer-only partial index can't
241
+ -- arbitrate)
242
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || ':' || v_phone, 1));
243
+
244
+ SELECT p.id INTO v_person_id
245
+ FROM people p
246
+ WHERE p.tenant_id = p_tenant_id AND p.kind = v_client_kind AND p.phone = v_phone
247
+ ORDER BY p.created_at
248
+ LIMIT 1;
249
+
250
+ IF v_person_id IS NOT NULL THEN
251
+ UPDATE people SET
252
+ name = trim(p_name),
253
+ email = COALESCE(p_email, people.email),
254
+ updated_at = now()
255
+ WHERE id = v_person_id;
256
+ ELSE
257
+ INSERT INTO people (tenant_id, kind, name, phone, email, metadata)
258
+ VALUES (p_tenant_id, v_client_kind, trim(p_name), v_phone, p_email,
259
+ jsonb_build_object('source', 'public_booking'))
260
+ RETURNING id INTO v_person_id;
261
+ END IF;
262
+
263
+ -- order + booking + order_item (statuses hardcoded — caller cannot set)
264
+ INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
265
+ VALUES (p_tenant_id, 'appointment', 'scheduled', v_person_id, v_assignee,
266
+ v_service.price, v_service.price, v_notes,
267
+ jsonb_build_object('source', 'public_booking',
268
+ 'serviceNames', v_service.name,
269
+ 'contactName', trim(p_name)))
270
+ RETURNING id INTO v_order;
271
+
272
+ INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
273
+ starts_at, ends_at, status, notes, metadata)
274
+ VALUES (p_tenant_id, 'appointment', v_person_id, v_assignee, v_order,
275
+ p_starts_at, v_ends, 'scheduled', v_notes,
276
+ jsonb_build_object('source', 'public_booking', 'serviceNames', v_service.name))
277
+ RETURNING id INTO v_booking;
278
+
279
+ INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
280
+ sort_order, duration_minutes, assignee_id)
281
+ VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
282
+ 0, v_service.duration_minutes, v_assignee);
283
+
284
+ RETURN QUERY SELECT v_booking, p_starts_at, v_ends;
285
+ END;
286
+ $$;
287
+
288
+ REVOKE ALL ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text, uuid) FROM public;
289
+ GRANT EXECUTE ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text, uuid)
290
+ TO anon, authenticated, service_role;
@@ -0,0 +1,172 @@
1
+ -- ============================================================================
2
+ -- plugin-agenda 008: agent_agenda_create_appointment honors schedule exceptions.
3
+ --
4
+ -- Same precedence fix as 007, applied to the agent write path: professional
5
+ -- resolution now goes through agenda_working_windows (007 §1), so a date the
6
+ -- admin closed (day-off sentinel) or re-opened with custom hours ("horário
7
+ -- especial") binds the agent exactly like it binds the public site. Everything
8
+ -- else is byte-identical to 006 (LOCAL-time input, free-professional pick,
9
+ -- advisory lock, audit). Requires 007. Supersedes 006's function body.
10
+ -- ============================================================================
11
+
12
+ CREATE OR REPLACE FUNCTION public.agent_agenda_create_appointment(
13
+ p_tenant_id uuid,
14
+ p_actor_user_id uuid,
15
+ p_payload jsonb
16
+ ) RETURNS jsonb
17
+ LANGUAGE plpgsql SECURITY DEFINER
18
+ SET search_path = public
19
+ AS $$
20
+ DECLARE
21
+ v_denial jsonb;
22
+ v_used int;
23
+ v_client_id uuid;
24
+ v_service_id uuid;
25
+ v_professional uuid;
26
+ v_starts timestamptz;
27
+ v_starts_raw text;
28
+ v_notes text;
29
+ v_service record;
30
+ v_client record;
31
+ v_ends timestamptz;
32
+ v_tz text;
33
+ v_order uuid;
34
+ v_booking uuid;
35
+ BEGIN
36
+ SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
37
+ FROM tenants t WHERE t.id = p_tenant_id;
38
+ IF v_tz IS NULL THEN
39
+ RETURN jsonb_build_object('ok', false, 'error', 'unknown tenant');
40
+ END IF;
41
+
42
+ -- ── payload ──────────────────────────────────────────────────────────────
43
+ BEGIN
44
+ v_client_id := (p_payload->>'client_id')::uuid;
45
+ v_service_id := (p_payload->>'service_id')::uuid;
46
+ v_professional := (p_payload->>'professional_id')::uuid;
47
+ v_starts_raw := p_payload->>'starts_at';
48
+ IF v_starts_raw ~ '(Z|[+-][0-9]{2}:?[0-9]{2})$' THEN
49
+ v_starts := v_starts_raw::timestamptz; -- explicit offset
50
+ ELSE
51
+ v_starts := (v_starts_raw::timestamp) AT TIME ZONE v_tz; -- tenant-local
52
+ END IF;
53
+ EXCEPTION WHEN OTHERS THEN
54
+ RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);
55
+ END;
56
+ v_notes := left(p_payload->>'notes', 500);
57
+ IF v_client_id IS NULL OR v_service_id IS NULL OR v_starts IS NULL THEN
58
+ RETURN jsonb_build_object('ok', false, 'error', 'client_id, service_id and starts_at are required');
59
+ END IF;
60
+ IF v_starts <= now() OR v_starts > now() + interval '365 days' THEN
61
+ RETURN jsonb_build_object('ok', false, 'error', 'starts_at must be in the future (max 1 year)');
62
+ END IF;
63
+
64
+ -- ── authorization: role → plan → bookings_month cap ──────────────────────
65
+ SELECT count(*) INTO v_used FROM appointments b
66
+ WHERE b.tenant_id = p_tenant_id
67
+ AND b.created_at >= date_trunc('month', now());
68
+ v_denial := agent_guard(p_tenant_id, p_actor_user_id,
69
+ 'appointments', 'create', 'bookings_month', v_used, 1);
70
+ IF v_denial IS NOT NULL THEN
71
+ RETURN jsonb_build_object('ok', false, 'denial', v_denial);
72
+ END IF;
73
+
74
+ -- ── invariants ───────────────────────────────────────────────────────────
75
+ SELECT p.id, p.name INTO v_client
76
+ FROM people p
77
+ WHERE p.id = v_client_id AND p.tenant_id = p_tenant_id;
78
+ IF NOT FOUND THEN
79
+ RETURN jsonb_build_object('ok', false, 'error', 'unknown client for this tenant');
80
+ END IF;
81
+
82
+ SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
83
+ INTO v_service
84
+ FROM services s
85
+ WHERE s.id = v_service_id AND s.tenant_id = p_tenant_id
86
+ AND s.is_active AND s.status = 'active';
87
+ IF NOT FOUND THEN
88
+ RETURN jsonb_build_object('ok', false, 'error', 'unknown or inactive service');
89
+ END IF;
90
+
91
+ v_ends := v_starts + make_interval(mins => v_service.duration_minutes);
92
+
93
+ -- exception-aware windows (007); still prefer a professional who is FREE at
94
+ -- the slot when the user did not name one
95
+ SELECT w.assignee_id INTO v_professional
96
+ FROM agenda_working_windows(
97
+ p_tenant_id, (v_starts AT TIME ZONE v_tz)::date, v_professional) w
98
+ WHERE (v_starts AT TIME ZONE v_tz)::time >= w.win_start
99
+ AND (v_ends AT TIME ZONE v_tz)::time <= w.win_end
100
+ AND NOT EXISTS (
101
+ SELECT 1 FROM appointments b
102
+ WHERE b.tenant_id = p_tenant_id
103
+ AND b.assignee_id = w.assignee_id
104
+ AND b.status NOT IN ('cancelled', 'no_show')
105
+ AND b.starts_at < v_ends
106
+ AND COALESCE(b.ends_at, b.starts_at) > v_starts
107
+ )
108
+ ORDER BY w.assignee_id
109
+ LIMIT 1;
110
+ IF v_professional IS NULL THEN
111
+ RETURN jsonb_build_object('ok', false, 'error',
112
+ 'no professional is available at that slot (outside working hours or already booked). Suggest a different time. Times are tenant-local, tz ' || v_tz);
113
+ END IF;
114
+
115
+ PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_professional::text, 0));
116
+ IF EXISTS (
117
+ SELECT 1 FROM appointments b
118
+ WHERE b.tenant_id = p_tenant_id
119
+ AND b.assignee_id = v_professional
120
+ AND b.status NOT IN ('cancelled', 'no_show')
121
+ AND b.starts_at < v_ends
122
+ AND COALESCE(b.ends_at, b.starts_at) > v_starts
123
+ ) THEN
124
+ RETURN jsonb_build_object('ok', false, 'error', 'slot no longer available');
125
+ END IF;
126
+
127
+ -- ── write + audit ────────────────────────────────────────────────────────
128
+ INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
129
+ VALUES (p_tenant_id, 'appointment', 'scheduled', v_client_id, v_professional,
130
+ v_service.price, v_service.price, v_notes,
131
+ jsonb_build_object('source', 'agent', 'serviceNames', v_service.name))
132
+ RETURNING id INTO v_order;
133
+
134
+ INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
135
+ starts_at, ends_at, status, notes, metadata)
136
+ VALUES (p_tenant_id, 'appointment', v_client_id, v_professional, v_order,
137
+ v_starts, v_ends, 'scheduled', v_notes,
138
+ jsonb_build_object('source', 'agent', 'serviceNames', v_service.name))
139
+ RETURNING id INTO v_booking;
140
+
141
+ INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
142
+ sort_order, duration_minutes, assignee_id)
143
+ VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
144
+ 0, v_service.duration_minutes, v_professional);
145
+
146
+ INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
147
+ VALUES (p_tenant_id, p_actor_user_id, 'agent.createAppointment', 'appointment', v_booking::text,
148
+ jsonb_build_object('payload', p_payload, 'order_id', v_order));
149
+
150
+ RETURN jsonb_build_object(
151
+ 'ok', true,
152
+ 'id', v_booking,
153
+ 'record', jsonb_build_object(
154
+ 'ref', jsonb_build_object('id', v_booking, 'resource', 'appointments',
155
+ 'archetype', 'schedule:appointment'),
156
+ 'starts_at', v_starts,
157
+ 'ends_at', v_ends,
158
+ 'starts_at_local', to_char(v_starts AT TIME ZONE v_tz, 'YYYY-MM-DD (Dy) HH24:MI') || ' ' || v_tz,
159
+ 'ends_at_local', to_char(v_ends AT TIME ZONE v_tz, 'HH24:MI'),
160
+ 'client_name', v_client.name,
161
+ 'service_name', v_service.name,
162
+ 'price', v_service.price,
163
+ 'status', 'scheduled'
164
+ )
165
+ );
166
+ END;
167
+ $$;
168
+
169
+ REVOKE ALL ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb) FROM public;
170
+ REVOKE EXECUTE ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb) FROM anon;
171
+ GRANT EXECUTE ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb)
172
+ TO authenticated, service_role;
@@ -0,0 +1,162 @@
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;
@@ -0,0 +1,31 @@
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);