@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.
Files changed (117) hide show
  1. package/dist/{chunk-LFUELUIT.cjs → chunk-3KWNHV5A.cjs} +60 -2
  2. package/dist/chunk-3KWNHV5A.cjs.map +1 -0
  3. package/dist/{chunk-DRZF6CV2.js → chunk-YQO6B4G3.js} +60 -3
  4. package/dist/chunk-YQO6B4G3.js.map +1 -0
  5. package/dist/components/AgendaGeneralSettings.d.ts.map +1 -1
  6. package/dist/components/AppointmentModal.d.ts.map +1 -1
  7. package/dist/components/CalendarToolbar.d.ts.map +1 -1
  8. package/dist/data/supabase.d.ts +5 -1
  9. package/dist/data/supabase.d.ts.map +1 -1
  10. package/dist/index.cjs +1071 -766
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +914 -609
  14. package/dist/index.js.map +1 -1
  15. package/dist/integrations/google-calendar/components/CalendarColorDot.d.ts +6 -0
  16. package/dist/integrations/google-calendar/components/CalendarColorDot.d.ts.map +1 -0
  17. package/dist/integrations/google-calendar/components/ChannelMappingList.d.ts +9 -0
  18. package/dist/integrations/google-calendar/components/ChannelMappingList.d.ts.map +1 -0
  19. package/dist/integrations/google-calendar/components/ImportEventsWizard.d.ts +12 -0
  20. package/dist/integrations/google-calendar/components/ImportEventsWizard.d.ts.map +1 -0
  21. package/dist/integrations/google-calendar/components/useAgendaLookups.d.ts +26 -0
  22. package/dist/integrations/google-calendar/components/useAgendaLookups.d.ts.map +1 -0
  23. package/dist/integrations/google-calendar/connectorDef.d.ts.map +1 -1
  24. package/dist/integrations/google-calendar/data/supabase.d.ts +33 -1
  25. package/dist/integrations/google-calendar/data/supabase.d.ts.map +1 -1
  26. package/dist/integrations/google-calendar/data/tables.d.ts +6 -0
  27. package/dist/integrations/google-calendar/data/tables.d.ts.map +1 -0
  28. package/dist/integrations/google-calendar/index.d.ts +1 -1
  29. package/dist/integrations/google-calendar/index.d.ts.map +1 -1
  30. package/dist/integrations/google-calendar/mapping.d.ts +56 -4
  31. package/dist/integrations/google-calendar/mapping.d.ts.map +1 -1
  32. package/dist/integrations/google-calendar/types.d.ts +101 -0
  33. package/dist/integrations/google-calendar/types.d.ts.map +1 -1
  34. package/dist/locales/en.d.ts.map +1 -1
  35. package/dist/locales/pt-BR.d.ts.map +1 -1
  36. package/dist/migrations/index.d.ts +11 -0
  37. package/dist/migrations/index.d.ts.map +1 -1
  38. package/dist/public/BookingWidget.d.ts.map +1 -1
  39. package/dist/public/context.d.ts +3 -1
  40. package/dist/public/context.d.ts.map +1 -1
  41. package/dist/public/createPublicBookingPlugin.d.ts +7 -1
  42. package/dist/public/createPublicBookingPlugin.d.ts.map +1 -1
  43. package/dist/public/data.d.ts +6 -0
  44. package/dist/public/data.d.ts.map +1 -1
  45. package/dist/public/data.supabase.d.ts.map +1 -1
  46. package/dist/public/format.d.ts +22 -1
  47. package/dist/public/format.d.ts.map +1 -1
  48. package/dist/public/hooks.d.ts.map +1 -1
  49. package/dist/public/index.cjs +31 -63
  50. package/dist/public/index.cjs.map +1 -1
  51. package/dist/public/index.d.ts +1 -1
  52. package/dist/public/index.d.ts.map +1 -1
  53. package/dist/public/index.js +27 -61
  54. package/dist/public/index.js.map +1 -1
  55. package/dist/public/steps/ContactStep.d.ts +4 -2
  56. package/dist/public/steps/ContactStep.d.ts.map +1 -1
  57. package/dist/public/types.d.ts +16 -0
  58. package/dist/public/types.d.ts.map +1 -1
  59. package/dist/views/CalendarView.d.ts.map +1 -1
  60. package/package.json +6 -5
  61. package/src/components/AgendaGeneralSettings.tsx +38 -16
  62. package/src/components/AppointmentModal.tsx +50 -108
  63. package/src/components/CalendarToolbar.tsx +10 -7
  64. package/src/data/supabase.ts +67 -13
  65. package/src/index.ts +56 -8
  66. package/src/integrations/google-calendar/components/CalendarColorDot.tsx +23 -0
  67. package/src/integrations/google-calendar/components/ChannelMappingList.tsx +329 -0
  68. package/src/integrations/google-calendar/components/ImportEventsWizard.tsx +215 -0
  69. package/src/integrations/google-calendar/components/useAgendaLookups.ts +100 -0
  70. package/src/integrations/google-calendar/connectorDef.tsx +123 -40
  71. package/src/integrations/google-calendar/data/supabase.ts +109 -13
  72. package/src/integrations/google-calendar/data/tables.ts +12 -0
  73. package/src/integrations/google-calendar/index.ts +10 -1
  74. package/src/integrations/google-calendar/integration.test.ts +126 -1
  75. package/src/integrations/google-calendar/mapping.ts +149 -9
  76. package/src/integrations/google-calendar/migrations/001_google_calendar.sql +9 -6
  77. package/src/integrations/google-calendar/migrations/002_smart_sync.sql +517 -0
  78. package/src/integrations/google-calendar/migrations/003_plg_rename.sql +273 -0
  79. package/src/integrations/google-calendar/types.ts +111 -0
  80. package/src/locales/en.ts +1 -0
  81. package/src/locales/pt-BR.ts +1 -0
  82. package/src/migrations/003_public_client_kind.sql +175 -0
  83. package/src/migrations/004_public_booking_assignee.sql +176 -0
  84. package/src/migrations/005_agent_rpcs.sql +168 -0
  85. package/src/migrations/006_agent_rpc_localtime.sql +181 -0
  86. package/src/migrations/007_schedule_exception_precedence.sql +290 -0
  87. package/src/migrations/008_agent_rpc_exception_precedence.sql +172 -0
  88. package/src/migrations/009_public_booking_location.sql +162 -0
  89. package/src/migrations/010_public_service_curation.sql +31 -0
  90. package/src/migrations/011_default_assignee.sql +167 -0
  91. package/src/migrations/012_drop_legacy_booking_overload.sql +29 -0
  92. package/src/migrations/013_normalize_v_appointments.sql +161 -0
  93. package/src/migrations/index.ts +1746 -1
  94. package/src/public/BookingWidget.tsx +10 -1
  95. package/src/public/context.tsx +3 -0
  96. package/src/public/createPublicBookingPlugin.ts +14 -1
  97. package/src/public/data.supabase.ts +3 -0
  98. package/src/public/data.ts +6 -0
  99. package/src/public/format.ts +36 -1
  100. package/src/public/hooks.ts +7 -2
  101. package/src/public/index.ts +1 -0
  102. package/src/public/steps/ContactStep.tsx +10 -5
  103. package/src/public/types.ts +21 -0
  104. package/src/views/CalendarView.tsx +15 -1
  105. package/dist/chunk-3LJKZQK4.js +0 -12
  106. package/dist/chunk-3LJKZQK4.js.map +0 -1
  107. package/dist/chunk-DRZF6CV2.js.map +0 -1
  108. package/dist/chunk-LFUELUIT.cjs.map +0 -1
  109. package/dist/chunk-SYDBDI3Q.cjs +0 -15
  110. package/dist/chunk-SYDBDI3Q.cjs.map +0 -1
  111. package/dist/components/SearchCombobox.d.ts +0 -38
  112. package/dist/components/SearchCombobox.d.ts.map +0 -1
  113. package/dist/tenant-222EHYUC.cjs +0 -16
  114. package/dist/tenant-222EHYUC.cjs.map +0 -1
  115. package/dist/tenant-LAUWVUYB.js +0 -3
  116. package/dist/tenant-LAUWVUYB.js.map +0 -1
  117. 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",
@@ -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é",
@@ -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;