@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.
- package/dist/{chunk-DRZF6CV2.js → chunk-Q2PAWBPR.js} +62 -3
- package/dist/chunk-Q2PAWBPR.js.map +1 -0
- package/dist/{chunk-LFUELUIT.cjs → chunk-RX2HMWXS.cjs} +62 -2
- package/dist/chunk-RX2HMWXS.cjs.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 +1078 -768
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +921 -611
- 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 +12 -1
- 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 +5 -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/001_public_booking.sql +7 -2
- 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 +199 -0
- package/src/migrations/index.ts +1791 -3
- 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 +7 -4
- 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,273 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 003_plg_rename.sql — standardize the Google Calendar connector objects onto
|
|
3
|
+
-- the platform `plg_<plugin>_` prefix (DATA-MODEL.md Ring 1).
|
|
4
|
+
--
|
|
5
|
+
-- calendar_integrations → plg_calendar_integrations
|
|
6
|
+
-- calendar_channels → plg_calendar_channels
|
|
7
|
+
-- calendar_sync_log → plg_calendar_sync_log
|
|
8
|
+
--
|
|
9
|
+
-- Legacy-pool remediation ONLY: 001/002 now create the prefixed names directly,
|
|
10
|
+
-- so fresh installs never reach a rename branch. Fully idempotent and guarded —
|
|
11
|
+
-- every RENAME fires only when the old object exists and the new one does not,
|
|
12
|
+
-- so re-running (or running on an already-prefixed pool) is a no-op.
|
|
13
|
+
--
|
|
14
|
+
-- A table RENAME preserves its data, RLS state, policies, grants, indexes,
|
|
15
|
+
-- constraints and triggers (they follow the table), so §1 only renames the
|
|
16
|
+
-- table + the explicitly-named indexes. §2 then re-points the SECURITY DEFINER
|
|
17
|
+
-- gcal_* functions whose bodies hard-code the old table names.
|
|
18
|
+
-- ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
-- ===========================================================================
|
|
21
|
+
-- §1 — Guarded table + index renames.
|
|
22
|
+
-- ===========================================================================
|
|
23
|
+
DO $$
|
|
24
|
+
BEGIN
|
|
25
|
+
IF to_regclass('public.calendar_integrations') IS NOT NULL
|
|
26
|
+
AND to_regclass('public.plg_calendar_integrations') IS NULL THEN
|
|
27
|
+
ALTER TABLE public.calendar_integrations RENAME TO plg_calendar_integrations;
|
|
28
|
+
END IF;
|
|
29
|
+
|
|
30
|
+
IF to_regclass('public.calendar_channels') IS NOT NULL
|
|
31
|
+
AND to_regclass('public.plg_calendar_channels') IS NULL THEN
|
|
32
|
+
ALTER TABLE public.calendar_channels RENAME TO plg_calendar_channels;
|
|
33
|
+
END IF;
|
|
34
|
+
|
|
35
|
+
IF to_regclass('public.calendar_sync_log') IS NOT NULL
|
|
36
|
+
AND to_regclass('public.plg_calendar_sync_log') IS NULL THEN
|
|
37
|
+
ALTER TABLE public.calendar_sync_log RENAME TO plg_calendar_sync_log;
|
|
38
|
+
END IF;
|
|
39
|
+
|
|
40
|
+
-- Named indexes do not follow a table rename automatically.
|
|
41
|
+
IF to_regclass('public.idx_calendar_integrations_tenant') IS NOT NULL
|
|
42
|
+
AND to_regclass('public.idx_plg_calendar_integrations_tenant') IS NULL THEN
|
|
43
|
+
ALTER INDEX public.idx_calendar_integrations_tenant RENAME TO idx_plg_calendar_integrations_tenant;
|
|
44
|
+
END IF;
|
|
45
|
+
|
|
46
|
+
IF to_regclass('public.idx_calendar_channels_tenant') IS NOT NULL
|
|
47
|
+
AND to_regclass('public.idx_plg_calendar_channels_tenant') IS NULL THEN
|
|
48
|
+
ALTER INDEX public.idx_calendar_channels_tenant RENAME TO idx_plg_calendar_channels_tenant;
|
|
49
|
+
END IF;
|
|
50
|
+
|
|
51
|
+
IF to_regclass('public.idx_calendar_channels_integration') IS NOT NULL
|
|
52
|
+
AND to_regclass('public.idx_plg_calendar_channels_integration') IS NULL THEN
|
|
53
|
+
ALTER INDEX public.idx_calendar_channels_integration RENAME TO idx_plg_calendar_channels_integration;
|
|
54
|
+
END IF;
|
|
55
|
+
END $$;
|
|
56
|
+
|
|
57
|
+
-- ===========================================================================
|
|
58
|
+
-- §2 — Re-point the inbound SECURITY DEFINER writers at the prefixed names.
|
|
59
|
+
-- These bodies are byte-identical to 002_smart_sync.sql except the calendar_*
|
|
60
|
+
-- table references, which now read plg_calendar_*. gcal_apply_event_patch and
|
|
61
|
+
-- gcal_stamp_outbound only touch public.appointments (unaffected by the rename)
|
|
62
|
+
-- and are re-asserted here for completeness so all four writers stay in step.
|
|
63
|
+
-- ===========================================================================
|
|
64
|
+
|
|
65
|
+
CREATE OR REPLACE FUNCTION public.gcal_import_event(
|
|
66
|
+
p_tenant_id uuid,
|
|
67
|
+
p_channel_id uuid,
|
|
68
|
+
p_event_id text,
|
|
69
|
+
p_etag text,
|
|
70
|
+
p_summary text,
|
|
71
|
+
p_starts_at timestamptz,
|
|
72
|
+
p_ends_at timestamptz,
|
|
73
|
+
p_all_day boolean,
|
|
74
|
+
p_description text DEFAULT NULL
|
|
75
|
+
) RETURNS uuid
|
|
76
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
77
|
+
SET search_path = public
|
|
78
|
+
AS $$
|
|
79
|
+
DECLARE
|
|
80
|
+
v_ch record;
|
|
81
|
+
v_kind text;
|
|
82
|
+
v_assignee uuid;
|
|
83
|
+
v_location uuid;
|
|
84
|
+
v_ends timestamptz;
|
|
85
|
+
v_meta jsonb;
|
|
86
|
+
v_existing uuid;
|
|
87
|
+
v_appt uuid;
|
|
88
|
+
BEGIN
|
|
89
|
+
IF p_tenant_id IS NULL OR p_event_id IS NULL OR p_starts_at IS NULL THEN
|
|
90
|
+
RAISE EXCEPTION 'gcal_import_event: tenant, event id and start are required';
|
|
91
|
+
END IF;
|
|
92
|
+
|
|
93
|
+
PERFORM set_config('app.booking_origin', 'google', true);
|
|
94
|
+
|
|
95
|
+
SELECT c.import_mode, c.target_kind, c.target_id
|
|
96
|
+
INTO v_ch
|
|
97
|
+
FROM plg_calendar_channels c
|
|
98
|
+
WHERE c.id = p_channel_id AND c.tenant_id = p_tenant_id;
|
|
99
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'gcal_import_event: unknown channel for tenant'; END IF;
|
|
100
|
+
|
|
101
|
+
v_kind := CASE WHEN v_ch.import_mode = 'appointment' THEN 'appointment' ELSE 'block' END;
|
|
102
|
+
v_assignee := CASE WHEN v_ch.target_kind = 'assignee' THEN v_ch.target_id ELSE NULL END;
|
|
103
|
+
v_location := CASE WHEN v_ch.target_kind = 'location' THEN v_ch.target_id ELSE NULL END;
|
|
104
|
+
|
|
105
|
+
v_ends := COALESCE(
|
|
106
|
+
p_ends_at,
|
|
107
|
+
CASE WHEN COALESCE(p_all_day, false) THEN p_starts_at + interval '1 day' ELSE p_starts_at END
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
v_meta := jsonb_build_object(
|
|
111
|
+
'syncOrigin', 'google',
|
|
112
|
+
'googleCalendarEventId', p_event_id,
|
|
113
|
+
'gcalEtag', p_etag,
|
|
114
|
+
'gcalChannelId', p_channel_id::text,
|
|
115
|
+
'allDay', COALESCE(p_all_day, false),
|
|
116
|
+
'title', p_summary,
|
|
117
|
+
'source', 'google_import'
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
SELECT b.id INTO v_existing
|
|
121
|
+
FROM appointments b
|
|
122
|
+
WHERE b.tenant_id = p_tenant_id
|
|
123
|
+
AND b.metadata->>'googleCalendarEventId' = p_event_id
|
|
124
|
+
LIMIT 1;
|
|
125
|
+
|
|
126
|
+
IF v_existing IS NOT NULL THEN
|
|
127
|
+
UPDATE appointments b
|
|
128
|
+
SET kind = v_kind,
|
|
129
|
+
assignee_id = v_assignee,
|
|
130
|
+
location_id = v_location,
|
|
131
|
+
starts_at = p_starts_at,
|
|
132
|
+
ends_at = v_ends,
|
|
133
|
+
status = 'scheduled',
|
|
134
|
+
notes = COALESCE(p_description, p_summary),
|
|
135
|
+
metadata = COALESCE(b.metadata, '{}'::jsonb) || v_meta,
|
|
136
|
+
updated_at = now()
|
|
137
|
+
WHERE b.id = v_existing
|
|
138
|
+
RETURNING b.id INTO v_appt;
|
|
139
|
+
ELSE
|
|
140
|
+
INSERT INTO appointments (tenant_id, kind, assignee_id, location_id,
|
|
141
|
+
starts_at, ends_at, status, notes, metadata)
|
|
142
|
+
VALUES (p_tenant_id, v_kind, v_assignee, v_location,
|
|
143
|
+
p_starts_at, v_ends, 'scheduled', COALESCE(p_description, p_summary), v_meta)
|
|
144
|
+
RETURNING id INTO v_appt;
|
|
145
|
+
END IF;
|
|
146
|
+
|
|
147
|
+
RETURN v_appt;
|
|
148
|
+
END $$;
|
|
149
|
+
|
|
150
|
+
CREATE OR REPLACE FUNCTION public.gcal_apply_event_patch(
|
|
151
|
+
p_tenant_id uuid,
|
|
152
|
+
p_event_id text,
|
|
153
|
+
p_etag text,
|
|
154
|
+
p_starts_at timestamptz,
|
|
155
|
+
p_ends_at timestamptz,
|
|
156
|
+
p_summary text,
|
|
157
|
+
p_cancelled boolean
|
|
158
|
+
) RETURNS uuid
|
|
159
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
160
|
+
SET search_path = public
|
|
161
|
+
AS $$
|
|
162
|
+
DECLARE
|
|
163
|
+
v_appt uuid;
|
|
164
|
+
v_curr_etag text;
|
|
165
|
+
BEGIN
|
|
166
|
+
IF p_tenant_id IS NULL OR p_event_id IS NULL THEN
|
|
167
|
+
RAISE EXCEPTION 'gcal_apply_event_patch: tenant and event id are required';
|
|
168
|
+
END IF;
|
|
169
|
+
|
|
170
|
+
PERFORM set_config('app.booking_origin', 'google', true);
|
|
171
|
+
|
|
172
|
+
SELECT b.id, b.metadata->>'gcalEtag'
|
|
173
|
+
INTO v_appt, v_curr_etag
|
|
174
|
+
FROM appointments b
|
|
175
|
+
WHERE b.tenant_id = p_tenant_id
|
|
176
|
+
AND b.metadata->>'googleCalendarEventId' = p_event_id
|
|
177
|
+
LIMIT 1;
|
|
178
|
+
|
|
179
|
+
IF v_appt IS NULL THEN RETURN NULL; END IF;
|
|
180
|
+
IF p_etag IS NOT NULL AND p_etag = v_curr_etag THEN
|
|
181
|
+
RETURN NULL;
|
|
182
|
+
END IF;
|
|
183
|
+
|
|
184
|
+
IF COALESCE(p_cancelled, false) THEN
|
|
185
|
+
UPDATE appointments b
|
|
186
|
+
SET status = 'cancelled',
|
|
187
|
+
metadata = COALESCE(b.metadata, '{}'::jsonb)
|
|
188
|
+
|| jsonb_build_object('syncOrigin', 'google', 'gcalEtag', p_etag),
|
|
189
|
+
updated_at = now()
|
|
190
|
+
WHERE b.id = v_appt;
|
|
191
|
+
ELSE
|
|
192
|
+
UPDATE appointments b
|
|
193
|
+
SET starts_at = COALESCE(p_starts_at, b.starts_at),
|
|
194
|
+
ends_at = COALESCE(p_ends_at, b.ends_at),
|
|
195
|
+
notes = COALESCE(p_summary, b.notes),
|
|
196
|
+
metadata = COALESCE(b.metadata, '{}'::jsonb)
|
|
197
|
+
|| jsonb_build_object('syncOrigin', 'google',
|
|
198
|
+
'gcalEtag', p_etag,
|
|
199
|
+
'title', COALESCE(p_summary, b.metadata->>'title')),
|
|
200
|
+
updated_at = now()
|
|
201
|
+
WHERE b.id = v_appt;
|
|
202
|
+
END IF;
|
|
203
|
+
|
|
204
|
+
RETURN v_appt;
|
|
205
|
+
END $$;
|
|
206
|
+
|
|
207
|
+
CREATE OR REPLACE FUNCTION public.gcal_stamp_outbound(
|
|
208
|
+
p_tenant_id uuid,
|
|
209
|
+
p_booking_id uuid,
|
|
210
|
+
p_event_id text,
|
|
211
|
+
p_etag text DEFAULT NULL,
|
|
212
|
+
p_calendar_id text DEFAULT NULL
|
|
213
|
+
) RETURNS uuid
|
|
214
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
215
|
+
SET search_path = public
|
|
216
|
+
AS $$
|
|
217
|
+
DECLARE
|
|
218
|
+
v_id uuid;
|
|
219
|
+
BEGIN
|
|
220
|
+
IF p_tenant_id IS NULL OR p_booking_id IS NULL OR p_event_id IS NULL THEN
|
|
221
|
+
RAISE EXCEPTION 'gcal_stamp_outbound: tenant, booking and event id are required';
|
|
222
|
+
END IF;
|
|
223
|
+
|
|
224
|
+
PERFORM set_config('app.booking_origin', 'google', true);
|
|
225
|
+
|
|
226
|
+
UPDATE appointments b
|
|
227
|
+
SET metadata = COALESCE(b.metadata, '{}'::jsonb)
|
|
228
|
+
|| jsonb_build_object('googleCalendarEventId', p_event_id,
|
|
229
|
+
'gcalEtag', p_etag,
|
|
230
|
+
'gcalSyncedAt', now())
|
|
231
|
+
|| CASE WHEN p_calendar_id IS NULL THEN '{}'::jsonb
|
|
232
|
+
ELSE jsonb_build_object('gcalCalendarId', p_calendar_id) END,
|
|
233
|
+
updated_at = now()
|
|
234
|
+
WHERE b.id = p_booking_id AND b.tenant_id = p_tenant_id
|
|
235
|
+
RETURNING b.id INTO v_id;
|
|
236
|
+
RETURN v_id;
|
|
237
|
+
END $$;
|
|
238
|
+
|
|
239
|
+
CREATE OR REPLACE FUNCTION public.gcal_unlink_channel(
|
|
240
|
+
p_channel_id uuid
|
|
241
|
+
) RETURNS uuid
|
|
242
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
243
|
+
SET search_path = public
|
|
244
|
+
AS $$
|
|
245
|
+
DECLARE
|
|
246
|
+
v_id uuid;
|
|
247
|
+
BEGIN
|
|
248
|
+
UPDATE plg_calendar_channels c
|
|
249
|
+
SET sync_token = NULL,
|
|
250
|
+
channel_id = NULL,
|
|
251
|
+
resource_id = NULL,
|
|
252
|
+
channel_expires_at = NULL,
|
|
253
|
+
updated_at = now()
|
|
254
|
+
WHERE c.id = p_channel_id
|
|
255
|
+
RETURNING c.id INTO v_id;
|
|
256
|
+
RETURN v_id;
|
|
257
|
+
END $$;
|
|
258
|
+
|
|
259
|
+
-- Re-assert the service_role-only lock (idempotent; matches 002 §4).
|
|
260
|
+
DO $$
|
|
261
|
+
DECLARE fn text;
|
|
262
|
+
BEGIN
|
|
263
|
+
FOR fn IN SELECT unnest(ARRAY[
|
|
264
|
+
'public.gcal_import_event(uuid, uuid, text, text, text, timestamptz, timestamptz, boolean, text)',
|
|
265
|
+
'public.gcal_apply_event_patch(uuid, text, text, timestamptz, timestamptz, text, boolean)',
|
|
266
|
+
'public.gcal_stamp_outbound(uuid, uuid, text, text, text)',
|
|
267
|
+
'public.gcal_unlink_channel(uuid)'
|
|
268
|
+
])
|
|
269
|
+
LOOP
|
|
270
|
+
EXECUTE format('REVOKE ALL ON FUNCTION %s FROM public, anon, authenticated', fn);
|
|
271
|
+
EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO service_role', fn);
|
|
272
|
+
END LOOP;
|
|
273
|
+
END $$;
|
|
@@ -23,9 +23,120 @@ export interface CalendarSyncLogEntry {
|
|
|
23
23
|
/** Minimal Google Calendar event shape we read/write (subset of the API). */
|
|
24
24
|
export interface GCalEvent {
|
|
25
25
|
id: string
|
|
26
|
+
etag?: string
|
|
26
27
|
summary?: string
|
|
27
28
|
description?: string
|
|
29
|
+
/** Timed events carry dateTime; all-day events carry only `date` (YYYY-MM-DD). */
|
|
28
30
|
start: { dateTime?: string; date?: string; timeZone?: string }
|
|
29
31
|
end: { dateTime?: string; date?: string; timeZone?: string }
|
|
30
32
|
status?: string
|
|
33
|
+
/** Set on instances expanded from a recurring series (singleEvents=true). */
|
|
34
|
+
recurringEventId?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Smart-sync: multi-calendar channels + wizard preview shapes
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
/** How a channel maps a Google calendar onto the agenda (de-para direction). */
|
|
42
|
+
export type ChannelDirection = 'inbound' | 'outbound' | 'bidirectional' | 'off'
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What the channel targets on the Fayz side. `null` (target_kind) means the
|
|
46
|
+
* whole agenda; otherwise imports/exports scope to one assignee/service/location.
|
|
47
|
+
*/
|
|
48
|
+
export type ChannelTargetKind = 'assignee' | 'service' | 'location' | null
|
|
49
|
+
|
|
50
|
+
/** How inbound events materialize as appointments. */
|
|
51
|
+
export type ChannelImportMode = 'block' | 'appointment'
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A per-calendar sync channel (row of public.plg_calendar_channels). One Google
|
|
55
|
+
* calendar ↔ one agenda target, with its own direction + incremental cursor.
|
|
56
|
+
*/
|
|
57
|
+
export interface CalendarChannel {
|
|
58
|
+
id: string
|
|
59
|
+
integrationId: string
|
|
60
|
+
tenantId: string
|
|
61
|
+
googleCalendarId: string
|
|
62
|
+
summary: string | null
|
|
63
|
+
color: string | null
|
|
64
|
+
direction: ChannelDirection
|
|
65
|
+
targetKind: ChannelTargetKind
|
|
66
|
+
targetId: string | null
|
|
67
|
+
importMode: ChannelImportMode
|
|
68
|
+
syncToken: string | null
|
|
69
|
+
channelId: string | null
|
|
70
|
+
resourceId: string | null
|
|
71
|
+
channelExpiresAt: string | null
|
|
72
|
+
isActive: boolean
|
|
73
|
+
createdAt: string
|
|
74
|
+
updatedAt: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A future Google event not yet linked to an appointment — shown in the import wizard. */
|
|
78
|
+
export interface ExternalEventPreview {
|
|
79
|
+
eventId: string
|
|
80
|
+
etag: string
|
|
81
|
+
summary: string
|
|
82
|
+
startsAt: string
|
|
83
|
+
endsAt: string
|
|
84
|
+
allDay: boolean
|
|
85
|
+
calendarId: string
|
|
86
|
+
channelId: string
|
|
87
|
+
recurring?: boolean
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** One entry from the Google calendarList (for the de-para picker). */
|
|
91
|
+
export interface GoogleCalendarListEntry {
|
|
92
|
+
id: string
|
|
93
|
+
summary: string
|
|
94
|
+
backgroundColor?: string
|
|
95
|
+
primary?: boolean
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Result of an import_events run (per-item outcome tally). */
|
|
99
|
+
export interface ImportEventsResult {
|
|
100
|
+
imported: number
|
|
101
|
+
skipped: number
|
|
102
|
+
errors: { eventId: string; error: string }[]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Raw shape of a public.appointments row as the edge function / RPCs see it
|
|
107
|
+
* (snake_case DB columns). Kept here so the pure mapping helpers can be shared
|
|
108
|
+
* between the SDK client and the Deno edge function without a DB roundtrip type.
|
|
109
|
+
*/
|
|
110
|
+
export interface AppointmentRow {
|
|
111
|
+
id: string
|
|
112
|
+
tenant_id: string
|
|
113
|
+
kind?: string | null
|
|
114
|
+
assignee_id?: string | null
|
|
115
|
+
location_id?: string | null
|
|
116
|
+
starts_at: string
|
|
117
|
+
ends_at?: string | null
|
|
118
|
+
status?: string | null
|
|
119
|
+
notes?: string | null
|
|
120
|
+
metadata?: Record<string, any> | null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Raw shape of a public.plg_calendar_channels row (snake_case DB columns). */
|
|
124
|
+
export interface CalendarChannelRow {
|
|
125
|
+
id: string
|
|
126
|
+
integration_id: string
|
|
127
|
+
tenant_id: string
|
|
128
|
+
google_calendar_id: string
|
|
129
|
+
summary?: string | null
|
|
130
|
+
color?: string | null
|
|
131
|
+
direction: ChannelDirection
|
|
132
|
+
target_kind?: ChannelTargetKind
|
|
133
|
+
target_id?: string | null
|
|
134
|
+
import_mode?: ChannelImportMode
|
|
135
|
+
sync_token?: string | null
|
|
136
|
+
channel_id?: string | null
|
|
137
|
+
resource_id?: string | null
|
|
138
|
+
channel_expires_at?: string | null
|
|
139
|
+
is_active?: boolean | null
|
|
140
|
+
created_at?: string
|
|
141
|
+
updated_at?: string
|
|
31
142
|
}
|
package/src/locales/en.ts
CHANGED
|
@@ -29,6 +29,7 @@ export const en: Record<string, string> = {
|
|
|
29
29
|
"agenda.appointment.searchService": "Search service to add...",
|
|
30
30
|
"agenda.appointment.selectLocation": "Select location...",
|
|
31
31
|
"agenda.appointment.selectProfessional": "Select professional...",
|
|
32
|
+
"agenda.appointment.selectClientHint": "Select the client from the list or use New client to enable saving.",
|
|
32
33
|
"agenda.appointment.services": "Services",
|
|
33
34
|
"agenda.appointment.status": "Status",
|
|
34
35
|
"agenda.appointment.to": "to",
|
package/src/locales/pt-BR.ts
CHANGED
|
@@ -29,6 +29,7 @@ export const ptBR: Record<string, string> = {
|
|
|
29
29
|
"agenda.appointment.searchService": "Buscar serviço para adicionar...",
|
|
30
30
|
"agenda.appointment.selectLocation": "Selecionar local...",
|
|
31
31
|
"agenda.appointment.selectProfessional": "Selecionar profissional...",
|
|
32
|
+
"agenda.appointment.selectClientHint": "Selecione o cliente na lista ou use Novo cliente para habilitar o salvamento.",
|
|
32
33
|
"agenda.appointment.services": "Serviços",
|
|
33
34
|
"agenda.appointment.status": "Status",
|
|
34
35
|
"agenda.appointment.to": "até",
|
|
@@ -44,7 +44,10 @@ CREATE INDEX IF NOT EXISTS appointments_assignee_time
|
|
|
44
44
|
WHERE status NOT IN ('cancelled', 'no_show');
|
|
45
45
|
|
|
46
46
|
-- §2 — admin read model (security_invoker → underlying RLS applies; anon sees 0 rows)
|
|
47
|
-
CREATE OR REPLACE
|
|
47
|
+
-- DROP first: 013 widens this view and CREATE OR REPLACE cannot narrow a live
|
|
48
|
+
-- view on replay ("cannot drop columns from view"). 013 replays right after.
|
|
49
|
+
DROP VIEW IF EXISTS public.v_appointments;
|
|
50
|
+
CREATE VIEW public.v_appointments
|
|
48
51
|
WITH (security_invoker = true) AS
|
|
49
52
|
SELECT
|
|
50
53
|
b.id, b.tenant_id, b.kind, b.starts_at, b.ends_at, b.status, b.notes,
|
|
@@ -72,7 +75,9 @@ GRANT SELECT ON public.v_appointments TO authenticated, service_role;
|
|
|
72
75
|
REVOKE SELECT ON public.v_appointments FROM anon;
|
|
73
76
|
|
|
74
77
|
-- §3 — anon-readable public catalog (owner-rights view = deliberate, whitelisted)
|
|
75
|
-
|
|
78
|
+
-- DROP first: 010 widens this view (curation columns) — same replay rule as §2.
|
|
79
|
+
DROP VIEW IF EXISTS public.v_public_services;
|
|
80
|
+
CREATE VIEW public.v_public_services AS
|
|
76
81
|
SELECT
|
|
77
82
|
s.id, s.tenant_id, s.name, s.description, s.price, s.currency,
|
|
78
83
|
COALESCE(s.duration_minutes, 30) AS duration_minutes,
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- Public booking — tenant-configurable client kind
|
|
3
|
+
-- ---------------------------------------------------------------------------
|
|
4
|
+
-- 001 hardcoded kind='customer' for people created by create_public_booking.
|
|
5
|
+
-- Verticals whose client archetype uses another kind (school → 'student',
|
|
6
|
+
-- clinic → whatever the app registers) ended up with people invisible to the
|
|
7
|
+
-- app's own pages/lookups and with entity links resolving to unregistered
|
|
8
|
+
-- routes (#/customers/:id → not found).
|
|
9
|
+
--
|
|
10
|
+
-- The client kind now comes from TENANT SETTINGS (admin-controlled — never
|
|
11
|
+
-- from the anon caller): tenants.settings.booking.client_kind
|
|
12
|
+
-- Default remains 'customer', so existing pools/tenants are unaffected until
|
|
13
|
+
-- they opt in:
|
|
14
|
+
-- UPDATE tenants SET settings = jsonb_set(COALESCE(settings,'{}'::jsonb),
|
|
15
|
+
-- '{booking,client_kind}', '"student"') WHERE id = '<tenant>';
|
|
16
|
+
--
|
|
17
|
+
-- The upsert no longer relies on the kind-specific partial unique index
|
|
18
|
+
-- (people_tenant_customer_phone) — a per-(tenant, phone) advisory lock
|
|
19
|
+
-- serializes concurrent first-bookings for any kind. The 001 index stays in
|
|
20
|
+
-- place (harmless, still guards direct customer writes).
|
|
21
|
+
--
|
|
22
|
+
-- Idempotent: CREATE OR REPLACE, same signature as 001.
|
|
23
|
+
-- ---------------------------------------------------------------------------
|
|
24
|
+
CREATE OR REPLACE FUNCTION public.create_public_booking(
|
|
25
|
+
p_tenant_id uuid,
|
|
26
|
+
p_service_id uuid,
|
|
27
|
+
p_starts_at timestamptz,
|
|
28
|
+
p_name text,
|
|
29
|
+
p_phone text,
|
|
30
|
+
p_email text DEFAULT NULL,
|
|
31
|
+
p_notes text DEFAULT NULL
|
|
32
|
+
) RETURNS TABLE (booking_id uuid, starts_at timestamptz, ends_at timestamptz)
|
|
33
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
34
|
+
SET search_path = public
|
|
35
|
+
AS $$
|
|
36
|
+
DECLARE
|
|
37
|
+
v_service record;
|
|
38
|
+
v_person_id uuid;
|
|
39
|
+
v_assignee uuid;
|
|
40
|
+
v_ends timestamptz;
|
|
41
|
+
v_order uuid;
|
|
42
|
+
v_booking uuid;
|
|
43
|
+
v_phone text;
|
|
44
|
+
v_tz text;
|
|
45
|
+
v_notes text;
|
|
46
|
+
v_client_kind text;
|
|
47
|
+
BEGIN
|
|
48
|
+
-- validation: anon-callable, everything tight
|
|
49
|
+
IF p_name IS NULL OR length(trim(p_name)) NOT BETWEEN 2 AND 120 THEN
|
|
50
|
+
RAISE EXCEPTION 'invalid name';
|
|
51
|
+
END IF;
|
|
52
|
+
v_phone := regexp_replace(COALESCE(p_phone, ''), '\D', '', 'g');
|
|
53
|
+
IF length(v_phone) NOT BETWEEN 8 AND 15 THEN
|
|
54
|
+
RAISE EXCEPTION 'invalid phone';
|
|
55
|
+
END IF;
|
|
56
|
+
IF p_email IS NOT NULL AND (length(p_email) > 254 OR p_email !~ '^[^@\s]+@[^@\s]+\.[^@\s]+$') THEN
|
|
57
|
+
RAISE EXCEPTION 'invalid email';
|
|
58
|
+
END IF;
|
|
59
|
+
v_notes := left(p_notes, 500);
|
|
60
|
+
IF p_starts_at IS NULL OR p_starts_at <= now() OR p_starts_at > now() + interval '90 days' THEN
|
|
61
|
+
RAISE EXCEPTION 'invalid start';
|
|
62
|
+
END IF;
|
|
63
|
+
|
|
64
|
+
SELECT s.id, s.name, s.price, COALESCE(s.duration_minutes, 30) AS duration_minutes
|
|
65
|
+
INTO v_service
|
|
66
|
+
FROM services s
|
|
67
|
+
WHERE s.id = p_service_id AND s.tenant_id = p_tenant_id
|
|
68
|
+
AND s.is_active AND s.status = 'active';
|
|
69
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'unknown service'; END IF;
|
|
70
|
+
|
|
71
|
+
v_ends := p_starts_at + make_interval(mins => v_service.duration_minutes);
|
|
72
|
+
|
|
73
|
+
-- tenant config: timezone + client kind (admin-set; sanitized, never caller input)
|
|
74
|
+
SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo'),
|
|
75
|
+
COALESCE(NULLIF(trim(t.settings->'booking'->>'client_kind'), ''), 'customer')
|
|
76
|
+
INTO v_tz, v_client_kind
|
|
77
|
+
FROM tenants t WHERE t.id = p_tenant_id;
|
|
78
|
+
IF v_tz IS NULL THEN RAISE EXCEPTION 'unknown tenant'; END IF;
|
|
79
|
+
IF v_client_kind !~ '^[a-z_]{2,32}$' THEN v_client_kind := 'customer'; END IF;
|
|
80
|
+
|
|
81
|
+
-- pick the assignee whose working hours cover the slot
|
|
82
|
+
SELECT sc.assignee_id INTO v_assignee
|
|
83
|
+
FROM schedules sc
|
|
84
|
+
WHERE sc.tenant_id = p_tenant_id
|
|
85
|
+
AND sc.kind = 'working_hours'
|
|
86
|
+
AND sc.is_active
|
|
87
|
+
AND (
|
|
88
|
+
sc.specific_date = (p_starts_at AT TIME ZONE v_tz)::date
|
|
89
|
+
OR (sc.specific_date IS NULL
|
|
90
|
+
AND sc.day_of_week = EXTRACT(dow FROM p_starts_at AT TIME ZONE v_tz)::smallint)
|
|
91
|
+
)
|
|
92
|
+
AND (p_starts_at AT TIME ZONE v_tz)::time >= sc.starts_at
|
|
93
|
+
AND (v_ends AT TIME ZONE v_tz)::time <= sc.ends_at
|
|
94
|
+
ORDER BY sc.assignee_id
|
|
95
|
+
LIMIT 1;
|
|
96
|
+
IF v_assignee IS NULL THEN RAISE EXCEPTION 'slot outside working hours'; END IF;
|
|
97
|
+
|
|
98
|
+
-- serialize per (tenant, assignee) to kill the double-book race
|
|
99
|
+
PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || v_assignee::text, 0));
|
|
100
|
+
|
|
101
|
+
IF EXISTS (
|
|
102
|
+
SELECT 1 FROM appointments b
|
|
103
|
+
WHERE b.tenant_id = p_tenant_id
|
|
104
|
+
AND b.assignee_id = v_assignee
|
|
105
|
+
AND b.status NOT IN ('cancelled', 'no_show')
|
|
106
|
+
AND b.starts_at < v_ends
|
|
107
|
+
AND COALESCE(b.ends_at, b.starts_at) > p_starts_at
|
|
108
|
+
) THEN
|
|
109
|
+
RAISE EXCEPTION 'slot no longer available';
|
|
110
|
+
END IF;
|
|
111
|
+
|
|
112
|
+
-- anti-spam: cap open future public bookings per phone per tenant
|
|
113
|
+
IF (
|
|
114
|
+
SELECT count(*) FROM appointments b
|
|
115
|
+
JOIN people p ON p.id = b.party_id
|
|
116
|
+
WHERE b.tenant_id = p_tenant_id
|
|
117
|
+
AND p.phone = v_phone
|
|
118
|
+
AND b.starts_at > now()
|
|
119
|
+
AND b.status = 'scheduled'
|
|
120
|
+
) >= 3 THEN
|
|
121
|
+
RAISE EXCEPTION 'too many open bookings for this phone';
|
|
122
|
+
END IF;
|
|
123
|
+
|
|
124
|
+
-- upsert the client by (tenant, kind, phone). Kind is tenant-configured, so
|
|
125
|
+
-- the 001 customer-only partial index can't arbitrate — a per-(tenant,
|
|
126
|
+
-- phone) advisory lock serializes concurrent first-bookings instead.
|
|
127
|
+
PERFORM pg_advisory_xact_lock(hashtextextended(p_tenant_id::text || ':' || v_phone, 1));
|
|
128
|
+
|
|
129
|
+
SELECT p.id INTO v_person_id
|
|
130
|
+
FROM people p
|
|
131
|
+
WHERE p.tenant_id = p_tenant_id AND p.kind = v_client_kind AND p.phone = v_phone
|
|
132
|
+
ORDER BY p.created_at
|
|
133
|
+
LIMIT 1;
|
|
134
|
+
|
|
135
|
+
IF v_person_id IS NOT NULL THEN
|
|
136
|
+
UPDATE people SET
|
|
137
|
+
name = trim(p_name),
|
|
138
|
+
email = COALESCE(p_email, people.email),
|
|
139
|
+
updated_at = now()
|
|
140
|
+
WHERE id = v_person_id;
|
|
141
|
+
ELSE
|
|
142
|
+
INSERT INTO people (tenant_id, kind, name, phone, email, metadata)
|
|
143
|
+
VALUES (p_tenant_id, v_client_kind, trim(p_name), v_phone, p_email,
|
|
144
|
+
jsonb_build_object('source', 'public_booking'))
|
|
145
|
+
RETURNING id INTO v_person_id;
|
|
146
|
+
END IF;
|
|
147
|
+
|
|
148
|
+
-- order + booking + order_item (statuses hardcoded — caller cannot set)
|
|
149
|
+
INSERT INTO orders (tenant_id, kind, status, party_id, assignee_id, subtotal, total, notes, metadata)
|
|
150
|
+
VALUES (p_tenant_id, 'appointment', 'scheduled', v_person_id, v_assignee,
|
|
151
|
+
v_service.price, v_service.price, v_notes,
|
|
152
|
+
jsonb_build_object('source', 'public_booking',
|
|
153
|
+
'serviceNames', v_service.name,
|
|
154
|
+
'contactName', trim(p_name)))
|
|
155
|
+
RETURNING id INTO v_order;
|
|
156
|
+
|
|
157
|
+
INSERT INTO appointments (tenant_id, kind, party_id, assignee_id, order_id,
|
|
158
|
+
starts_at, ends_at, status, notes, metadata)
|
|
159
|
+
VALUES (p_tenant_id, 'appointment', v_person_id, v_assignee, v_order,
|
|
160
|
+
p_starts_at, v_ends, 'scheduled', v_notes,
|
|
161
|
+
jsonb_build_object('source', 'public_booking', 'serviceNames', v_service.name))
|
|
162
|
+
RETURNING id INTO v_booking;
|
|
163
|
+
|
|
164
|
+
INSERT INTO order_items (order_id, service_id, name, quantity, unit_price, total,
|
|
165
|
+
sort_order, duration_minutes, assignee_id)
|
|
166
|
+
VALUES (v_order, v_service.id, v_service.name, 1, v_service.price, v_service.price,
|
|
167
|
+
0, v_service.duration_minutes, v_assignee);
|
|
168
|
+
|
|
169
|
+
RETURN QUERY SELECT v_booking, p_starts_at, v_ends;
|
|
170
|
+
END;
|
|
171
|
+
$$;
|
|
172
|
+
|
|
173
|
+
REVOKE ALL ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text) FROM public;
|
|
174
|
+
GRANT EXECUTE ON FUNCTION public.create_public_booking(uuid, uuid, timestamptz, text, text, text, text)
|
|
175
|
+
TO anon, authenticated, service_role;
|