@fayz-ai/plugin-agenda 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/README.md +3 -3
  2. package/dist/bridges/create-financial-bridge.d.ts +1 -1
  3. package/dist/bridges/create-financial-bridge.d.ts.map +1 -1
  4. package/dist/bridges/financial-bridge.d.ts.map +1 -0
  5. package/dist/{chunk-Q2PAWBPR.js → chunk-76ZOHRBM.js} +2 -2
  6. package/dist/{chunk-Q2PAWBPR.js.map → chunk-76ZOHRBM.js.map} +1 -1
  7. package/dist/components/PaymentStatusBadge.d.ts +1 -1
  8. package/dist/components/PaymentStatusBadge.d.ts.map +1 -1
  9. package/dist/config.d.ts +2 -2
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/{AgendaContext.d.ts → context.d.ts} +1 -1
  12. package/dist/context.d.ts.map +1 -0
  13. package/dist/data/registries.d.ts.map +1 -0
  14. package/dist/index.d.ts +2 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +170 -7
  17. package/dist/index.js.map +1 -1
  18. package/dist/lib/message-events.d.ts +12 -0
  19. package/dist/lib/message-events.d.ts.map +1 -0
  20. package/dist/public/{data.d.ts → data/index.d.ts} +4 -4
  21. package/dist/public/data/index.d.ts.map +1 -0
  22. package/dist/public/{data.supabase.d.ts → data/supabase.d.ts} +2 -2
  23. package/dist/public/data/supabase.d.ts.map +1 -0
  24. package/dist/public/{hooks.d.ts → hooks/index.d.ts} +4 -4
  25. package/dist/public/hooks/index.d.ts.map +1 -0
  26. package/dist/public/index.d.ts +4 -4
  27. package/dist/public/index.d.ts.map +1 -1
  28. package/dist/public/index.js +3 -3
  29. package/dist/public/index.js.map +1 -1
  30. package/dist/public/{format.d.ts → lib/format.d.ts} +1 -1
  31. package/dist/public/lib/format.d.ts.map +1 -0
  32. package/dist/public/views/BookingWidget.d.ts.map +1 -0
  33. package/dist/public/{steps → views/steps}/ContactStep.d.ts +1 -1
  34. package/dist/public/views/steps/ContactStep.d.ts.map +1 -0
  35. package/dist/public/{steps → views/steps}/DateTimeStep.d.ts +3 -3
  36. package/dist/public/views/steps/DateTimeStep.d.ts.map +1 -0
  37. package/dist/public/{steps → views/steps}/OverviewStep.d.ts +1 -1
  38. package/dist/public/views/steps/OverviewStep.d.ts.map +1 -0
  39. package/dist/public/{steps → views/steps}/PaymentStep.d.ts +1 -1
  40. package/dist/public/views/steps/PaymentStep.d.ts.map +1 -0
  41. package/dist/store.d.ts +1 -1
  42. package/dist/store.d.ts.map +1 -1
  43. package/dist/styles/calendar-styles.d.ts.map +1 -0
  44. package/dist/types.d.ts +1 -1
  45. package/dist/types.d.ts.map +1 -1
  46. package/dist/{AgendaPage.d.ts → views/AgendaPage.d.ts} +3 -3
  47. package/dist/views/AgendaPage.d.ts.map +1 -0
  48. package/package.json +6 -9
  49. package/dist/AgendaContext.d.ts.map +0 -1
  50. package/dist/AgendaPage.d.ts.map +0 -1
  51. package/dist/calendar-styles.d.ts.map +0 -1
  52. package/dist/financial-bridge.d.ts.map +0 -1
  53. package/dist/public/BookingWidget.d.ts.map +0 -1
  54. package/dist/public/data.d.ts.map +0 -1
  55. package/dist/public/data.supabase.d.ts.map +0 -1
  56. package/dist/public/format.d.ts.map +0 -1
  57. package/dist/public/hooks.d.ts.map +0 -1
  58. package/dist/public/steps/ContactStep.d.ts.map +0 -1
  59. package/dist/public/steps/DateTimeStep.d.ts.map +0 -1
  60. package/dist/public/steps/OverviewStep.d.ts.map +0 -1
  61. package/dist/public/steps/PaymentStep.d.ts.map +0 -1
  62. package/dist/registries.d.ts.map +0 -1
  63. package/src/AgendaContext.tsx +0 -11
  64. package/src/AgendaPage.tsx +0 -33
  65. package/src/bridges/create-financial-bridge.ts +0 -296
  66. package/src/calendar-styles.css +0 -245
  67. package/src/calendar-styles.ts +0 -285
  68. package/src/components/AgendaGeneralSettings.tsx +0 -86
  69. package/src/components/AgendaMobileList.tsx +0 -103
  70. package/src/components/AppointmentModal.tsx +0 -1050
  71. package/src/components/AppointmentPopover.tsx +0 -231
  72. package/src/components/BookingPaymentPanel.tsx +0 -394
  73. package/src/components/CalendarSidebar.tsx +0 -119
  74. package/src/components/CalendarToolbar.tsx +0 -86
  75. package/src/components/EventContextMenu.tsx +0 -125
  76. package/src/components/FloatingPanel.tsx +0 -123
  77. package/src/components/MiniCalendar.tsx +0 -120
  78. package/src/components/PaymentStatusBadge.tsx +0 -62
  79. package/src/components/StatusBadge.tsx +0 -23
  80. package/src/config.ts +0 -368
  81. package/src/data/index.ts +0 -4
  82. package/src/data/mock.ts +0 -344
  83. package/src/data/supabase.ts +0 -636
  84. package/src/data/types.ts +0 -37
  85. package/src/financial-bridge.ts +0 -96
  86. package/src/hooks/useAgendaSettings.ts +0 -17
  87. package/src/index.ts +0 -297
  88. package/src/integrations/google-calendar/GoogleCalendarSettings.tsx +0 -136
  89. package/src/integrations/google-calendar/components/CalendarColorDot.tsx +0 -23
  90. package/src/integrations/google-calendar/components/ChannelMappingList.tsx +0 -329
  91. package/src/integrations/google-calendar/components/ImportEventsWizard.tsx +0 -215
  92. package/src/integrations/google-calendar/components/useAgendaLookups.ts +0 -100
  93. package/src/integrations/google-calendar/connector.ts +0 -20
  94. package/src/integrations/google-calendar/connectorDef.tsx +0 -185
  95. package/src/integrations/google-calendar/data/supabase.ts +0 -170
  96. package/src/integrations/google-calendar/data/tables.ts +0 -12
  97. package/src/integrations/google-calendar/index.ts +0 -45
  98. package/src/integrations/google-calendar/integration.test.ts +0 -199
  99. package/src/integrations/google-calendar/mapping.ts +0 -183
  100. package/src/integrations/google-calendar/migrations/001_google_calendar.sql +0 -97
  101. package/src/integrations/google-calendar/migrations/002_smart_sync.sql +0 -517
  102. package/src/integrations/google-calendar/migrations/003_plg_rename.sql +0 -273
  103. package/src/integrations/google-calendar/types.ts +0 -142
  104. package/src/lib/tenant.ts +0 -3
  105. package/src/locales/en.ts +0 -220
  106. package/src/locales/index.ts +0 -7
  107. package/src/locales/pt-BR.ts +0 -220
  108. package/src/migrations/000b_v_bookings_rename.sql +0 -21
  109. package/src/migrations/000c_dedupe_customer_phones.sql +0 -43
  110. package/src/migrations/001_public_booking.sql +0 -296
  111. package/src/migrations/002_v_bookings_compat.sql +0 -14
  112. package/src/migrations/003_public_client_kind.sql +0 -175
  113. package/src/migrations/004_public_booking_assignee.sql +0 -176
  114. package/src/migrations/005_agent_rpcs.sql +0 -168
  115. package/src/migrations/006_agent_rpc_localtime.sql +0 -181
  116. package/src/migrations/007_schedule_exception_precedence.sql +0 -290
  117. package/src/migrations/008_agent_rpc_exception_precedence.sql +0 -172
  118. package/src/migrations/009_public_booking_location.sql +0 -162
  119. package/src/migrations/010_public_service_curation.sql +0 -31
  120. package/src/migrations/011_default_assignee.sql +0 -167
  121. package/src/migrations/012_drop_legacy_booking_overload.sql +0 -29
  122. package/src/migrations/013_normalize_v_appointments.sql +0 -199
  123. package/src/migrations/014_agent_rpc_update_appointment.sql +0 -199
  124. package/src/migrations/index.ts +0 -2377
  125. package/src/public/BookingWidget.tsx +0 -324
  126. package/src/public/components/BrandHeader.tsx +0 -27
  127. package/src/public/components/ServiceCard.tsx +0 -39
  128. package/src/public/components/SidebarExtras.tsx +0 -30
  129. package/src/public/components/StepRailItem.tsx +0 -27
  130. package/src/public/components/SuccessPanel.tsx +0 -27
  131. package/src/public/components/SummaryRail.tsx +0 -90
  132. package/src/public/components/defaults.ts +0 -14
  133. package/src/public/context.tsx +0 -103
  134. package/src/public/createPublicBookingPlugin.ts +0 -281
  135. package/src/public/data.supabase.ts +0 -97
  136. package/src/public/data.ts +0 -105
  137. package/src/public/format.ts +0 -96
  138. package/src/public/hooks.ts +0 -110
  139. package/src/public/index.ts +0 -69
  140. package/src/public/steps/ContactStep.tsx +0 -184
  141. package/src/public/steps/DateTimeStep.tsx +0 -138
  142. package/src/public/steps/OverviewStep.tsx +0 -81
  143. package/src/public/steps/PaymentStep.tsx +0 -180
  144. package/src/public/types.ts +0 -250
  145. package/src/registries.ts +0 -35
  146. package/src/store.ts +0 -311
  147. package/src/types.ts +0 -273
  148. package/src/views/CalendarView.tsx +0 -1112
  149. package/src/views/ConfirmationsView.tsx +0 -155
  150. /package/dist/{financial-bridge.d.ts → bridges/financial-bridge.d.ts} +0 -0
  151. /package/dist/{registries.d.ts → data/registries.d.ts} +0 -0
  152. /package/dist/public/{BookingWidget.d.ts → views/BookingWidget.d.ts} +0 -0
  153. /package/dist/{calendar-styles.d.ts → styles/calendar-styles.d.ts} +0 -0
@@ -1,181 +0,0 @@
1
- -- ============================================================================
2
- -- plugin-agenda 006: agent_agenda_create_appointment speaks LOCAL time.
3
- --
4
- -- Models reliably mangle UTC↔local offsets ("10h" became 13:00 BRT via a
5
- -- wrong sign). The RPC knows the tenant's timezone — so stop asking the model
6
- -- to convert: `starts_at` WITHOUT an offset (2026-07-27T10:00) is interpreted
7
- -- in the tenant tz; an explicit offset still works. The result now carries
8
- -- starts_at_local/ends_at_local strings so the reply echoes the truth in the
9
- -- user's own clock. Supersedes 005's function body (new file — applied
10
- -- migrations are never edited).
11
- -- ============================================================================
12
-
13
- CREATE OR REPLACE FUNCTION public.agent_agenda_create_appointment(
14
- p_tenant_id uuid,
15
- p_actor_user_id uuid,
16
- p_payload jsonb
17
- ) RETURNS jsonb
18
- LANGUAGE plpgsql SECURITY DEFINER
19
- SET search_path = public
20
- AS $$
21
- DECLARE
22
- v_denial jsonb;
23
- v_used int;
24
- v_client_id uuid;
25
- v_service_id uuid;
26
- v_professional uuid;
27
- v_starts timestamptz;
28
- v_starts_raw text;
29
- v_notes text;
30
- v_service record;
31
- v_client record;
32
- v_ends timestamptz;
33
- v_tz text;
34
- v_order uuid;
35
- v_booking uuid;
36
- BEGIN
37
- SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
38
- FROM tenants t WHERE t.id = p_tenant_id;
39
- IF v_tz IS NULL THEN
40
- RETURN jsonb_build_object('ok', false, 'error', 'unknown tenant');
41
- END IF;
42
-
43
- -- ── payload ──────────────────────────────────────────────────────────────
44
- BEGIN
45
- v_client_id := (p_payload->>'client_id')::uuid;
46
- v_service_id := (p_payload->>'service_id')::uuid;
47
- v_professional := (p_payload->>'professional_id')::uuid;
48
- v_starts_raw := p_payload->>'starts_at';
49
- IF v_starts_raw ~ '(Z|[+-][0-9]{2}:?[0-9]{2})$' THEN
50
- v_starts := v_starts_raw::timestamptz; -- explicit offset
51
- ELSE
52
- v_starts := (v_starts_raw::timestamp) AT TIME ZONE v_tz; -- tenant-local
53
- END IF;
54
- EXCEPTION WHEN OTHERS THEN
55
- RETURN jsonb_build_object('ok', false, 'error', 'invalid payload: ' || SQLERRM);
56
- END;
57
- v_notes := left(p_payload->>'notes', 500);
58
- IF v_client_id IS NULL OR v_service_id IS NULL OR v_starts IS NULL THEN
59
- RETURN jsonb_build_object('ok', false, 'error', 'client_id, service_id and starts_at are required');
60
- END IF;
61
- IF v_starts <= now() OR v_starts > now() + interval '365 days' THEN
62
- RETURN jsonb_build_object('ok', false, 'error', 'starts_at must be in the future (max 1 year)');
63
- END IF;
64
-
65
- -- ── authorization: role → plan → bookings_month cap ──────────────────────
66
- SELECT count(*) INTO v_used FROM appointments b
67
- WHERE b.tenant_id = p_tenant_id
68
- AND b.created_at >= date_trunc('month', now());
69
- v_denial := agent_guard(p_tenant_id, p_actor_user_id,
70
- 'appointments', 'create', 'bookings_month', v_used, 1);
71
- IF v_denial IS NOT NULL THEN
72
- RETURN jsonb_build_object('ok', false, 'denial', v_denial);
73
- END IF;
74
-
75
- -- ── invariants ───────────────────────────────────────────────────────────
76
- SELECT p.id, p.name INTO v_client
77
- FROM people p
78
- WHERE p.id = v_client_id AND p.tenant_id = p_tenant_id;
79
- IF NOT FOUND THEN
80
- RETURN jsonb_build_object('ok', false, 'error', 'unknown client for this tenant');
81
- END IF;
82
-
83
- SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
84
- INTO v_service
85
- FROM services s
86
- WHERE s.id = v_service_id AND s.tenant_id = p_tenant_id
87
- AND s.is_active AND s.status = 'active';
88
- IF NOT FOUND THEN
89
- RETURN jsonb_build_object('ok', false, 'error', 'unknown or inactive service');
90
- END IF;
91
-
92
- v_ends := v_starts + make_interval(mins => v_service.duration_minutes);
93
-
94
- SELECT sc.assignee_id INTO v_professional
95
- FROM schedules sc
96
- WHERE sc.tenant_id = p_tenant_id
97
- AND sc.kind = 'working_hours'
98
- AND sc.is_active
99
- AND (v_professional IS NULL OR sc.assignee_id = v_professional)
100
- AND (
101
- sc.specific_date = (v_starts AT TIME ZONE v_tz)::date
102
- OR (sc.specific_date IS NULL
103
- AND sc.day_of_week = EXTRACT(dow FROM v_starts AT TIME ZONE v_tz)::smallint)
104
- )
105
- AND (v_starts AT TIME ZONE v_tz)::time >= sc.starts_at
106
- AND (v_ends AT TIME ZONE v_tz)::time <= sc.ends_at
107
- -- prefer a professional who is actually FREE at the slot (when the user
108
- -- did not name one, the first busy professional must not block the booking)
109
- AND NOT EXISTS (
110
- SELECT 1 FROM appointments b
111
- WHERE b.tenant_id = p_tenant_id
112
- AND b.assignee_id = sc.assignee_id
113
- AND b.status NOT IN ('cancelled', 'no_show')
114
- AND b.starts_at < v_ends
115
- AND COALESCE(b.ends_at, b.starts_at) > v_starts
116
- )
117
- ORDER BY sc.assignee_id
118
- LIMIT 1;
119
- IF v_professional IS NULL THEN
120
- RETURN jsonb_build_object('ok', false, 'error',
121
- 'no professional is available at that slot (outside working hours or already booked). Suggest a different time. Times are tenant-local, tz ' || v_tz);
122
- END IF;
123
-
124
- PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_professional::text, 0));
125
- IF EXISTS (
126
- SELECT 1 FROM appointments b
127
- WHERE b.tenant_id = p_tenant_id
128
- AND b.assignee_id = v_professional
129
- AND b.status NOT IN ('cancelled', 'no_show')
130
- AND b.starts_at < v_ends
131
- AND COALESCE(b.ends_at, b.starts_at) > v_starts
132
- ) THEN
133
- RETURN jsonb_build_object('ok', false, 'error', 'slot no longer available');
134
- END IF;
135
-
136
- -- ── write + audit ────────────────────────────────────────────────────────
137
- INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
138
- VALUES (p_tenant_id, 'appointment', 'scheduled', v_client_id, v_professional,
139
- v_service.price, v_service.price, v_notes,
140
- jsonb_build_object('source', 'agent', 'serviceNames', v_service.name))
141
- RETURNING id INTO v_order;
142
-
143
- INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
144
- starts_at, ends_at, status, notes, metadata)
145
- VALUES (p_tenant_id, 'appointment', v_client_id, v_professional, v_order,
146
- v_starts, v_ends, 'scheduled', v_notes,
147
- jsonb_build_object('source', 'agent', 'serviceNames', v_service.name))
148
- RETURNING id INTO v_booking;
149
-
150
- INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
151
- sort_order, duration_minutes, assignee_id)
152
- VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
153
- 0, v_service.duration_minutes, v_professional);
154
-
155
- INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
156
- VALUES (p_tenant_id, p_actor_user_id, 'agent.createAppointment', 'appointment', v_booking::text,
157
- jsonb_build_object('payload', p_payload, 'order_id', v_order));
158
-
159
- RETURN jsonb_build_object(
160
- 'ok', true,
161
- 'id', v_booking,
162
- 'record', jsonb_build_object(
163
- 'ref', jsonb_build_object('id', v_booking, 'resource', 'appointments',
164
- 'archetype', 'schedule:appointment'),
165
- 'starts_at', v_starts,
166
- 'ends_at', v_ends,
167
- 'starts_at_local', to_char(v_starts AT TIME ZONE v_tz, 'YYYY-MM-DD (Dy) HH24:MI') || ' ' || v_tz,
168
- 'ends_at_local', to_char(v_ends AT TIME ZONE v_tz, 'HH24:MI'),
169
- 'client_name', v_client.name,
170
- 'service_name', v_service.name,
171
- 'price', v_service.price,
172
- 'status', 'scheduled'
173
- )
174
- );
175
- END;
176
- $$;
177
-
178
- REVOKE ALL ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb) FROM public;
179
- REVOKE EXECUTE ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb) FROM anon;
180
- GRANT EXECUTE ON FUNCTION public.agent_agenda_create_appointment(uuid, uuid, jsonb)
181
- TO authenticated, service_role;
@@ -1,290 +0,0 @@
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;
@@ -1,172 +0,0 @@
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;