@fayz-ai/plugin-agenda 0.8.0 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-LFUELUIT.cjs → chunk-3KWNHV5A.cjs} +60 -2
- package/dist/chunk-3KWNHV5A.cjs.map +1 -0
- package/dist/{chunk-DRZF6CV2.js → chunk-YQO6B4G3.js} +60 -3
- package/dist/chunk-YQO6B4G3.js.map +1 -0
- package/dist/components/AgendaGeneralSettings.d.ts.map +1 -1
- package/dist/components/AppointmentModal.d.ts.map +1 -1
- package/dist/components/CalendarToolbar.d.ts.map +1 -1
- package/dist/data/supabase.d.ts +5 -1
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/index.cjs +1071 -766
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +914 -609
- package/dist/index.js.map +1 -1
- package/dist/integrations/google-calendar/components/CalendarColorDot.d.ts +6 -0
- package/dist/integrations/google-calendar/components/CalendarColorDot.d.ts.map +1 -0
- package/dist/integrations/google-calendar/components/ChannelMappingList.d.ts +9 -0
- package/dist/integrations/google-calendar/components/ChannelMappingList.d.ts.map +1 -0
- package/dist/integrations/google-calendar/components/ImportEventsWizard.d.ts +12 -0
- package/dist/integrations/google-calendar/components/ImportEventsWizard.d.ts.map +1 -0
- package/dist/integrations/google-calendar/components/useAgendaLookups.d.ts +26 -0
- package/dist/integrations/google-calendar/components/useAgendaLookups.d.ts.map +1 -0
- package/dist/integrations/google-calendar/connectorDef.d.ts.map +1 -1
- package/dist/integrations/google-calendar/data/supabase.d.ts +33 -1
- package/dist/integrations/google-calendar/data/supabase.d.ts.map +1 -1
- package/dist/integrations/google-calendar/data/tables.d.ts +6 -0
- package/dist/integrations/google-calendar/data/tables.d.ts.map +1 -0
- package/dist/integrations/google-calendar/index.d.ts +1 -1
- package/dist/integrations/google-calendar/index.d.ts.map +1 -1
- package/dist/integrations/google-calendar/mapping.d.ts +56 -4
- package/dist/integrations/google-calendar/mapping.d.ts.map +1 -1
- package/dist/integrations/google-calendar/types.d.ts +101 -0
- package/dist/integrations/google-calendar/types.d.ts.map +1 -1
- package/dist/locales/en.d.ts.map +1 -1
- package/dist/locales/pt-BR.d.ts.map +1 -1
- package/dist/migrations/index.d.ts +11 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/public/BookingWidget.d.ts.map +1 -1
- package/dist/public/context.d.ts +3 -1
- package/dist/public/context.d.ts.map +1 -1
- package/dist/public/createPublicBookingPlugin.d.ts +7 -1
- package/dist/public/createPublicBookingPlugin.d.ts.map +1 -1
- package/dist/public/data.d.ts +6 -0
- package/dist/public/data.d.ts.map +1 -1
- package/dist/public/data.supabase.d.ts.map +1 -1
- package/dist/public/format.d.ts +22 -1
- package/dist/public/format.d.ts.map +1 -1
- package/dist/public/hooks.d.ts.map +1 -1
- package/dist/public/index.cjs +31 -63
- package/dist/public/index.cjs.map +1 -1
- package/dist/public/index.d.ts +1 -1
- package/dist/public/index.d.ts.map +1 -1
- package/dist/public/index.js +27 -61
- package/dist/public/index.js.map +1 -1
- package/dist/public/steps/ContactStep.d.ts +4 -2
- package/dist/public/steps/ContactStep.d.ts.map +1 -1
- package/dist/public/types.d.ts +16 -0
- package/dist/public/types.d.ts.map +1 -1
- package/dist/views/CalendarView.d.ts.map +1 -1
- package/package.json +6 -5
- package/src/components/AgendaGeneralSettings.tsx +38 -16
- package/src/components/AppointmentModal.tsx +50 -108
- package/src/components/CalendarToolbar.tsx +10 -7
- package/src/data/supabase.ts +67 -13
- package/src/index.ts +56 -8
- package/src/integrations/google-calendar/components/CalendarColorDot.tsx +23 -0
- package/src/integrations/google-calendar/components/ChannelMappingList.tsx +329 -0
- package/src/integrations/google-calendar/components/ImportEventsWizard.tsx +215 -0
- package/src/integrations/google-calendar/components/useAgendaLookups.ts +100 -0
- package/src/integrations/google-calendar/connectorDef.tsx +123 -40
- package/src/integrations/google-calendar/data/supabase.ts +109 -13
- package/src/integrations/google-calendar/data/tables.ts +12 -0
- package/src/integrations/google-calendar/index.ts +10 -1
- package/src/integrations/google-calendar/integration.test.ts +126 -1
- package/src/integrations/google-calendar/mapping.ts +149 -9
- package/src/integrations/google-calendar/migrations/001_google_calendar.sql +9 -6
- package/src/integrations/google-calendar/migrations/002_smart_sync.sql +517 -0
- package/src/integrations/google-calendar/migrations/003_plg_rename.sql +273 -0
- package/src/integrations/google-calendar/types.ts +111 -0
- package/src/locales/en.ts +1 -0
- package/src/locales/pt-BR.ts +1 -0
- package/src/migrations/003_public_client_kind.sql +175 -0
- package/src/migrations/004_public_booking_assignee.sql +176 -0
- package/src/migrations/005_agent_rpcs.sql +168 -0
- package/src/migrations/006_agent_rpc_localtime.sql +181 -0
- package/src/migrations/007_schedule_exception_precedence.sql +290 -0
- package/src/migrations/008_agent_rpc_exception_precedence.sql +172 -0
- package/src/migrations/009_public_booking_location.sql +162 -0
- package/src/migrations/010_public_service_curation.sql +31 -0
- package/src/migrations/011_default_assignee.sql +167 -0
- package/src/migrations/012_drop_legacy_booking_overload.sql +29 -0
- package/src/migrations/013_normalize_v_appointments.sql +161 -0
- package/src/migrations/index.ts +1746 -1
- package/src/public/BookingWidget.tsx +10 -1
- package/src/public/context.tsx +3 -0
- package/src/public/createPublicBookingPlugin.ts +14 -1
- package/src/public/data.supabase.ts +3 -0
- package/src/public/data.ts +6 -0
- package/src/public/format.ts +36 -1
- package/src/public/hooks.ts +7 -2
- package/src/public/index.ts +1 -0
- package/src/public/steps/ContactStep.tsx +10 -5
- package/src/public/types.ts +21 -0
- package/src/views/CalendarView.tsx +15 -1
- package/dist/chunk-3LJKZQK4.js +0 -12
- package/dist/chunk-3LJKZQK4.js.map +0 -1
- package/dist/chunk-DRZF6CV2.js.map +0 -1
- package/dist/chunk-LFUELUIT.cjs.map +0 -1
- package/dist/chunk-SYDBDI3Q.cjs +0 -15
- package/dist/chunk-SYDBDI3Q.cjs.map +0 -1
- package/dist/components/SearchCombobox.d.ts +0 -38
- package/dist/components/SearchCombobox.d.ts.map +0 -1
- package/dist/tenant-222EHYUC.cjs +0 -16
- package/dist/tenant-222EHYUC.cjs.map +0 -1
- package/dist/tenant-LAUWVUYB.js +0 -3
- package/dist/tenant-LAUWVUYB.js.map +0 -1
- 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);
|