@fayz-ai/plugin-agenda 0.9.0 → 0.10.0

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 (196) 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-ISLJXP7O.js} +7 -2
  6. package/dist/chunk-ISLJXP7O.js.map +1 -0
  7. package/dist/components/AppointmentModal.d.ts.map +1 -1
  8. package/dist/components/PaymentStatusBadge.d.ts +1 -1
  9. package/dist/components/PaymentStatusBadge.d.ts.map +1 -1
  10. package/dist/config.d.ts +2 -2
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/{AgendaContext.d.ts → context.d.ts} +1 -1
  13. package/dist/context.d.ts.map +1 -0
  14. package/dist/data/mock.d.ts.map +1 -1
  15. package/dist/data/realtime.test.d.ts +2 -0
  16. package/dist/data/realtime.test.d.ts.map +1 -0
  17. package/dist/data/registries.d.ts.map +1 -0
  18. package/dist/data/supabase.d.ts.map +1 -1
  19. package/dist/data/types.d.ts +7 -0
  20. package/dist/data/types.d.ts.map +1 -1
  21. package/dist/index.d.ts +2 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +2515 -187
  24. package/dist/index.js.map +1 -1
  25. package/dist/integrations/google-calendar/conflict.d.ts +25 -0
  26. package/dist/integrations/google-calendar/conflict.d.ts.map +1 -0
  27. package/dist/integrations/google-calendar/connectorDef.d.ts +12 -0
  28. package/dist/integrations/google-calendar/connectorDef.d.ts.map +1 -1
  29. package/dist/integrations/google-calendar/credentials.d.ts +27 -0
  30. package/dist/integrations/google-calendar/credentials.d.ts.map +1 -0
  31. package/dist/integrations/google-calendar/data/supabase.d.ts +10 -2
  32. package/dist/integrations/google-calendar/data/supabase.d.ts.map +1 -1
  33. package/dist/integrations/google-calendar/data/tables.d.ts +2 -0
  34. package/dist/integrations/google-calendar/data/tables.d.ts.map +1 -1
  35. package/dist/integrations/google-calendar/health.d.ts +23 -0
  36. package/dist/integrations/google-calendar/health.d.ts.map +1 -0
  37. package/dist/integrations/google-calendar/health.test.d.ts +2 -0
  38. package/dist/integrations/google-calendar/health.test.d.ts.map +1 -0
  39. package/dist/integrations/google-calendar/index.d.ts +6 -0
  40. package/dist/integrations/google-calendar/index.d.ts.map +1 -1
  41. package/dist/integrations/google-calendar/migrations/index.d.ts +12 -0
  42. package/dist/integrations/google-calendar/migrations/index.d.ts.map +1 -0
  43. package/dist/integrations/google-calendar/panel-data.d.ts +26 -0
  44. package/dist/integrations/google-calendar/panel-data.d.ts.map +1 -0
  45. package/dist/integrations/google-calendar/panel-data.test.d.ts +2 -0
  46. package/dist/integrations/google-calendar/panel-data.test.d.ts.map +1 -0
  47. package/dist/integrations/google-calendar/types.d.ts +17 -0
  48. package/dist/integrations/google-calendar/types.d.ts.map +1 -1
  49. package/dist/lib/booking-events.d.ts +5 -0
  50. package/dist/lib/booking-events.d.ts.map +1 -0
  51. package/dist/lib/calendar-events.d.ts +8 -0
  52. package/dist/lib/calendar-events.d.ts.map +1 -0
  53. package/dist/lib/calendar-events.test.d.ts +2 -0
  54. package/dist/lib/calendar-events.test.d.ts.map +1 -0
  55. package/dist/lib/message-events.d.ts +12 -0
  56. package/dist/lib/message-events.d.ts.map +1 -0
  57. package/dist/locales/en.d.ts.map +1 -1
  58. package/dist/locales/pt-BR.d.ts.map +1 -1
  59. package/dist/public/{data.d.ts → data/index.d.ts} +4 -4
  60. package/dist/public/data/index.d.ts.map +1 -0
  61. package/dist/public/{data.supabase.d.ts → data/supabase.d.ts} +2 -2
  62. package/dist/public/data/supabase.d.ts.map +1 -0
  63. package/dist/public/{hooks.d.ts → hooks/index.d.ts} +4 -4
  64. package/dist/public/hooks/index.d.ts.map +1 -0
  65. package/dist/public/index.d.ts +4 -4
  66. package/dist/public/index.d.ts.map +1 -1
  67. package/dist/public/index.js +3 -3
  68. package/dist/public/index.js.map +1 -1
  69. package/dist/public/{format.d.ts → lib/format.d.ts} +1 -1
  70. package/dist/public/lib/format.d.ts.map +1 -0
  71. package/dist/public/views/BookingWidget.d.ts.map +1 -0
  72. package/dist/public/{steps → views/steps}/ContactStep.d.ts +1 -1
  73. package/dist/public/views/steps/ContactStep.d.ts.map +1 -0
  74. package/dist/public/{steps → views/steps}/DateTimeStep.d.ts +3 -3
  75. package/dist/public/views/steps/DateTimeStep.d.ts.map +1 -0
  76. package/dist/public/{steps → views/steps}/OverviewStep.d.ts +1 -1
  77. package/dist/public/views/steps/OverviewStep.d.ts.map +1 -0
  78. package/dist/public/{steps → views/steps}/PaymentStep.d.ts +1 -1
  79. package/dist/public/views/steps/PaymentStep.d.ts.map +1 -0
  80. package/dist/store.d.ts +1 -1
  81. package/dist/store.d.ts.map +1 -1
  82. package/dist/styles/calendar-styles.d.ts.map +1 -0
  83. package/dist/types.d.ts +1 -1
  84. package/dist/types.d.ts.map +1 -1
  85. package/dist/{AgendaPage.d.ts → views/AgendaPage.d.ts} +3 -3
  86. package/dist/views/AgendaPage.d.ts.map +1 -0
  87. package/dist/views/CalendarView.d.ts.map +1 -1
  88. package/package.json +9 -11
  89. package/dist/AgendaContext.d.ts.map +0 -1
  90. package/dist/AgendaPage.d.ts.map +0 -1
  91. package/dist/calendar-styles.d.ts.map +0 -1
  92. package/dist/chunk-Q2PAWBPR.js.map +0 -1
  93. package/dist/financial-bridge.d.ts.map +0 -1
  94. package/dist/integrations/google-calendar/GoogleCalendarSettings.d.ts +0 -3
  95. package/dist/integrations/google-calendar/GoogleCalendarSettings.d.ts.map +0 -1
  96. package/dist/public/BookingWidget.d.ts.map +0 -1
  97. package/dist/public/data.d.ts.map +0 -1
  98. package/dist/public/data.supabase.d.ts.map +0 -1
  99. package/dist/public/format.d.ts.map +0 -1
  100. package/dist/public/hooks.d.ts.map +0 -1
  101. package/dist/public/steps/ContactStep.d.ts.map +0 -1
  102. package/dist/public/steps/DateTimeStep.d.ts.map +0 -1
  103. package/dist/public/steps/OverviewStep.d.ts.map +0 -1
  104. package/dist/public/steps/PaymentStep.d.ts.map +0 -1
  105. package/dist/registries.d.ts.map +0 -1
  106. package/src/AgendaContext.tsx +0 -11
  107. package/src/AgendaPage.tsx +0 -33
  108. package/src/bridges/create-financial-bridge.ts +0 -296
  109. package/src/calendar-styles.css +0 -245
  110. package/src/calendar-styles.ts +0 -285
  111. package/src/components/AgendaGeneralSettings.tsx +0 -86
  112. package/src/components/AgendaMobileList.tsx +0 -103
  113. package/src/components/AppointmentModal.tsx +0 -1050
  114. package/src/components/AppointmentPopover.tsx +0 -231
  115. package/src/components/BookingPaymentPanel.tsx +0 -394
  116. package/src/components/CalendarSidebar.tsx +0 -119
  117. package/src/components/CalendarToolbar.tsx +0 -86
  118. package/src/components/EventContextMenu.tsx +0 -125
  119. package/src/components/FloatingPanel.tsx +0 -123
  120. package/src/components/MiniCalendar.tsx +0 -120
  121. package/src/components/PaymentStatusBadge.tsx +0 -62
  122. package/src/components/StatusBadge.tsx +0 -23
  123. package/src/config.ts +0 -368
  124. package/src/data/index.ts +0 -4
  125. package/src/data/mock.ts +0 -344
  126. package/src/data/supabase.ts +0 -636
  127. package/src/data/types.ts +0 -37
  128. package/src/financial-bridge.ts +0 -96
  129. package/src/hooks/useAgendaSettings.ts +0 -17
  130. package/src/index.ts +0 -297
  131. package/src/integrations/google-calendar/GoogleCalendarSettings.tsx +0 -136
  132. package/src/integrations/google-calendar/components/CalendarColorDot.tsx +0 -23
  133. package/src/integrations/google-calendar/components/ChannelMappingList.tsx +0 -329
  134. package/src/integrations/google-calendar/components/ImportEventsWizard.tsx +0 -215
  135. package/src/integrations/google-calendar/components/useAgendaLookups.ts +0 -100
  136. package/src/integrations/google-calendar/connector.ts +0 -20
  137. package/src/integrations/google-calendar/connectorDef.tsx +0 -185
  138. package/src/integrations/google-calendar/data/supabase.ts +0 -170
  139. package/src/integrations/google-calendar/data/tables.ts +0 -12
  140. package/src/integrations/google-calendar/index.ts +0 -45
  141. package/src/integrations/google-calendar/integration.test.ts +0 -199
  142. package/src/integrations/google-calendar/mapping.ts +0 -183
  143. package/src/integrations/google-calendar/migrations/001_google_calendar.sql +0 -97
  144. package/src/integrations/google-calendar/migrations/002_smart_sync.sql +0 -517
  145. package/src/integrations/google-calendar/migrations/003_plg_rename.sql +0 -273
  146. package/src/integrations/google-calendar/types.ts +0 -142
  147. package/src/lib/tenant.ts +0 -3
  148. package/src/locales/en.ts +0 -220
  149. package/src/locales/index.ts +0 -7
  150. package/src/locales/pt-BR.ts +0 -220
  151. package/src/migrations/000b_v_bookings_rename.sql +0 -21
  152. package/src/migrations/000c_dedupe_customer_phones.sql +0 -43
  153. package/src/migrations/001_public_booking.sql +0 -296
  154. package/src/migrations/002_v_bookings_compat.sql +0 -14
  155. package/src/migrations/003_public_client_kind.sql +0 -175
  156. package/src/migrations/004_public_booking_assignee.sql +0 -176
  157. package/src/migrations/005_agent_rpcs.sql +0 -168
  158. package/src/migrations/006_agent_rpc_localtime.sql +0 -181
  159. package/src/migrations/007_schedule_exception_precedence.sql +0 -290
  160. package/src/migrations/008_agent_rpc_exception_precedence.sql +0 -172
  161. package/src/migrations/009_public_booking_location.sql +0 -162
  162. package/src/migrations/010_public_service_curation.sql +0 -31
  163. package/src/migrations/011_default_assignee.sql +0 -167
  164. package/src/migrations/012_drop_legacy_booking_overload.sql +0 -29
  165. package/src/migrations/013_normalize_v_appointments.sql +0 -199
  166. package/src/migrations/014_agent_rpc_update_appointment.sql +0 -199
  167. package/src/migrations/index.ts +0 -2377
  168. package/src/public/BookingWidget.tsx +0 -324
  169. package/src/public/components/BrandHeader.tsx +0 -27
  170. package/src/public/components/ServiceCard.tsx +0 -39
  171. package/src/public/components/SidebarExtras.tsx +0 -30
  172. package/src/public/components/StepRailItem.tsx +0 -27
  173. package/src/public/components/SuccessPanel.tsx +0 -27
  174. package/src/public/components/SummaryRail.tsx +0 -90
  175. package/src/public/components/defaults.ts +0 -14
  176. package/src/public/context.tsx +0 -103
  177. package/src/public/createPublicBookingPlugin.ts +0 -281
  178. package/src/public/data.supabase.ts +0 -97
  179. package/src/public/data.ts +0 -105
  180. package/src/public/format.ts +0 -96
  181. package/src/public/hooks.ts +0 -110
  182. package/src/public/index.ts +0 -69
  183. package/src/public/steps/ContactStep.tsx +0 -184
  184. package/src/public/steps/DateTimeStep.tsx +0 -138
  185. package/src/public/steps/OverviewStep.tsx +0 -81
  186. package/src/public/steps/PaymentStep.tsx +0 -180
  187. package/src/public/types.ts +0 -250
  188. package/src/registries.ts +0 -35
  189. package/src/store.ts +0 -311
  190. package/src/types.ts +0 -273
  191. package/src/views/CalendarView.tsx +0 -1112
  192. package/src/views/ConfirmationsView.tsx +0 -155
  193. /package/dist/{financial-bridge.d.ts → bridges/financial-bridge.d.ts} +0 -0
  194. /package/dist/{registries.d.ts → data/registries.d.ts} +0 -0
  195. /package/dist/public/{BookingWidget.d.ts → views/BookingWidget.d.ts} +0 -0
  196. /package/dist/{calendar-styles.d.ts → styles/calendar-styles.d.ts} +0 -0
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
- import { createMockAgendaProvider, createSupabasePublicBookingProvider } from './chunk-Q2PAWBPR.js';
2
- export { createMockAgendaProvider } from './chunk-Q2PAWBPR.js';
1
+ import { createMockAgendaProvider, createSupabasePublicBookingProvider } from './chunk-ISLJXP7O.js';
2
+ export { createMockAgendaProvider } from './chunk-ISLJXP7O.js';
3
3
  import React13, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
4
- import { getSupabaseClientOptional, getActiveTenantId, registerTranslations, createSafeDataProvider, emitDataChanged, usePluginRuntimeOptional, useTranslation, getCurrentLocale, useDataChanged, formatCurrency } from '@fayz-ai/core';
5
- import { createPluginContext, setScheduleBlockConfig, getScheduleBlockConfig, PluginSettingsPanel, dedup, useTenantPluginSettings, SettingsGroup, ToggleRow, SelectRow, usePluginPrefs, PersonLink, useLimitGuard, CrudFormPage, CrudDetailPage, ContactPicker, invalidateLimit } from '@fayz-ai/saas';
4
+ import { connectionStatus, createConnectionStore, getSupabaseClientOptional, getActiveTenantId, registerTranslations, registerMessageDefaults, createSafeDataProvider, connectorRuntimeToken, CONNECTOR_RUNTIME_TOKEN_HEADER, emitDataChanged, usePluginRuntimeOptional, useTranslation, emitDomainEvent, getCurrentLocale, useDataChanged, formatCurrency } from '@fayz-ai/core';
5
+ import { createPluginContext, fayzConnectorOAuth, setScheduleBlockConfig, getScheduleBlockConfig, PluginSettingsPanel, dedup, useTenantPluginSettings, SettingsGroup, ToggleRow, SelectRow, usePluginPrefs, PersonLink, useLimitGuard, CrudFormPage, CrudDetailPage, ContactPicker, invalidateLimit } from '@fayz-ai/admin';
6
6
  import FullCalendar from '@fullcalendar/react';
7
7
  import dayGridPlugin from '@fullcalendar/daygrid';
8
8
  import timeGridPlugin from '@fullcalendar/timegrid';
@@ -10,12 +10,172 @@ import resourceTimeGridPlugin from '@fullcalendar/resource-timegrid';
10
10
  import interactionPlugin from '@fullcalendar/interaction';
11
11
  import listPlugin from '@fullcalendar/list';
12
12
  import ptBrLocale from '@fullcalendar/core/locales/pt-br';
13
- import { CalendarCog, CalendarPlus, RefreshCw, History, CheckCircle2, AlertCircle, Loader2, X, Plus, ChevronDown, ChevronLeft, ChevronRight, Settings, Pencil, Trash2, Clock, User, MapPin, FileText, AlertTriangle, Check, DollarSign, HandCoins, Ban, CheckSquare, Calendar, Briefcase, CircleDot, FileCheck, ArrowRightLeft, CreditCard, QrCode, Banknote, ExternalLink } from 'lucide-react';
14
- import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
13
+ import { Loader2, AlertCircle, RefreshCw, CalendarPlus, X, CheckCircle2, Plus, ChevronDown, ChevronLeft, ChevronRight, Settings, Pencil, Trash2, Clock, User, MapPin, FileText, AlertTriangle, Check, DollarSign, HandCoins, Ban, CheckSquare, Calendar, Briefcase, CircleDot, FileCheck, ArrowRightLeft, CreditCard, QrCode, Banknote, ExternalLink } from 'lucide-react';
14
+ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
15
15
  import { createPortal } from 'react-dom';
16
- import { Button, toast, Checkbox, Badge, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, ConfirmDialog, Modal, ModalContent, ModalTitle, SearchCombobox, cn, TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@fayz-ai/ui';
16
+ import { toast, Button, Checkbox, Badge, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, ConfirmDialog, Modal, ModalContent, ModalTitle, SearchCombobox, cn, TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@fayz-ai/ui';
17
17
  import { createStore } from 'zustand/vanilla';
18
18
 
19
+ // src/lib/message-events.ts
20
+ var BOOKING_FIELDS = [
21
+ { name: "customer_name", label: "Cliente", type: "string", example: "Maria Silva" },
22
+ { name: "service_name", label: "Servi\xE7o", type: "string", example: "Corte + Escova" },
23
+ { name: "professional_name", label: "Profissional", type: "string", example: "Camila Duarte" },
24
+ { name: "booking_date", label: "Data", type: "date", example: "12/08/2026" },
25
+ { name: "booking_time", label: "Hor\xE1rio", type: "string", example: "14:30" },
26
+ { name: "location_name", label: "Local", type: "string", example: "Unidade Centro" },
27
+ { name: "business_name", label: "Seu neg\xF3cio", type: "string", example: "Seu Neg\xF3cio" }
28
+ ];
29
+ function buildAgendaDomainEvents() {
30
+ return [
31
+ {
32
+ name: "agenda.booking_created",
33
+ title: "Agendamento criado",
34
+ icon: "CalendarPlus",
35
+ audit: true,
36
+ description: "Um hor\xE1rio foi marcado",
37
+ trigger: "Quando o agendamento \xE9 criado (recep\xE7\xE3o, site ou app)",
38
+ fields: BOOKING_FIELDS
39
+ },
40
+ {
41
+ name: "agenda.booking_reminder",
42
+ title: "Lembrete de hor\xE1rio",
43
+ icon: "BellRing",
44
+ audit: false,
45
+ description: "V\xE9spera do atendimento",
46
+ trigger: "No dia anterior ao hor\xE1rio marcado",
47
+ fields: BOOKING_FIELDS
48
+ },
49
+ {
50
+ name: "agenda.booking_rescheduled",
51
+ title: "Hor\xE1rio remarcado",
52
+ icon: "CalendarClock",
53
+ audit: true,
54
+ description: "O atendimento mudou de dia ou de hora",
55
+ trigger: "Quando a data ou o hor\xE1rio do agendamento muda",
56
+ fields: BOOKING_FIELDS
57
+ },
58
+ {
59
+ name: "agenda.booking_cancelled",
60
+ title: "Agendamento cancelado",
61
+ icon: "CalendarX",
62
+ audit: true,
63
+ description: "O hor\xE1rio foi desmarcado",
64
+ trigger: "Quando o agendamento \xE9 cancelado",
65
+ fields: BOOKING_FIELDS
66
+ },
67
+ {
68
+ name: "agenda.booking_completed",
69
+ title: "Atendimento conclu\xEDdo",
70
+ icon: "CalendarCheck",
71
+ audit: true,
72
+ description: "O atendimento aconteceu",
73
+ trigger: "Quando o agendamento \xE9 marcado como conclu\xEDdo",
74
+ fields: BOOKING_FIELDS
75
+ },
76
+ {
77
+ name: "agenda.booking_no_show",
78
+ title: "Cliente n\xE3o compareceu",
79
+ icon: "UserX",
80
+ audit: true,
81
+ description: "O hor\xE1rio foi perdido",
82
+ trigger: "Quando o agendamento \xE9 marcado como falta",
83
+ fields: BOOKING_FIELDS
84
+ }
85
+ ];
86
+ }
87
+ function buildAgendaMessageDefaults() {
88
+ return [
89
+ {
90
+ eventKey: "agenda.booking_created",
91
+ kind: "transactional",
92
+ // WhatsApp na frente em toda a agenda: confirmação de horário é lida no
93
+ // celular, e no Brasil é lá que a cliente responde.
94
+ channels: ["whatsapp", "email"],
95
+ defaults: {
96
+ whatsapp: {
97
+ body: "Oi {{customer_name}}! Seu hor\xE1rio est\xE1 marcado \u2705\n\n*{{service_name}}*\n{{booking_date}} \xE0s {{booking_time}}\ncom {{professional_name}}\n\nQualquer coisa \xE9 s\xF3 chamar aqui. \u2014 {{business_name}}"
98
+ },
99
+ email: {
100
+ subject: "Hor\xE1rio confirmado \u2014 {{booking_date}} \xE0s {{booking_time}}",
101
+ body: "Ol\xE1 {{customer_name}},\n\nSeu hor\xE1rio est\xE1 confirmado:\n\n{{service_name}}\n{{booking_date}} \xE0s {{booking_time}}\ncom {{professional_name}}\n{{location_name}}\n\nAt\xE9 l\xE1!\n{{business_name}}"
102
+ }
103
+ },
104
+ // Confirmar por e-mail é o mínimo que se espera de quem acabou de marcar
105
+ // um horário — nasce ligado. O WhatsApp custa por mensagem e exige número
106
+ // aprovado; ligá-lo sozinho gastaria o dinheiro de alguém sem ser pedido.
107
+ defaultEnabled: { email: true }
108
+ },
109
+ {
110
+ eventKey: "agenda.booking_reminder",
111
+ kind: "transactional",
112
+ channels: ["whatsapp", "email"],
113
+ defaults: {
114
+ whatsapp: {
115
+ body: "Oi {{customer_name}}! Passando para lembrar do seu hor\xE1rio amanh\xE3 \u{1F487}\n\n*{{service_name}}*\n{{booking_date}} \xE0s {{booking_time}}\ncom {{professional_name}}\n\nConsegue vir? Se precisar remarcar, me avisa por aqui. \u2014 {{business_name}}"
116
+ },
117
+ email: {
118
+ subject: "Lembrete: seu hor\xE1rio \xE9 amanh\xE3, {{booking_time}}",
119
+ body: "Ol\xE1 {{customer_name}},\n\nSeu hor\xE1rio \xE9 amanh\xE3:\n\n{{service_name}}\n{{booking_date}} \xE0s {{booking_time}}\ncom {{professional_name}}\n{{location_name}}\n\nSe precisar remarcar, \xE9 s\xF3 responder este e-mail.\n\n{{business_name}}"
120
+ }
121
+ },
122
+ defaultEnabled: { email: true }
123
+ },
124
+ {
125
+ eventKey: "agenda.booking_rescheduled",
126
+ kind: "transactional",
127
+ channels: ["whatsapp", "email"],
128
+ defaults: {
129
+ whatsapp: {
130
+ body: "Oi {{customer_name}}! Seu hor\xE1rio foi remarcado \u{1F5D3}\n\n*{{service_name}}*\nAgora: {{booking_date}} \xE0s {{booking_time}}\ncom {{professional_name}}\n\n\u2014 {{business_name}}"
131
+ },
132
+ email: {
133
+ subject: "Seu hor\xE1rio mudou para {{booking_date}}, {{booking_time}}",
134
+ body: "Ol\xE1 {{customer_name}},\n\nSeu hor\xE1rio foi remarcado:\n\n{{service_name}}\n{{booking_date}} \xE0s {{booking_time}}\ncom {{professional_name}}\n\n{{business_name}}"
135
+ }
136
+ },
137
+ defaultEnabled: { email: true }
138
+ },
139
+ {
140
+ eventKey: "agenda.booking_cancelled",
141
+ kind: "transactional",
142
+ channels: ["whatsapp", "email"],
143
+ defaults: {
144
+ whatsapp: {
145
+ body: "Oi {{customer_name}}, seu hor\xE1rio de {{booking_date}} \xE0s {{booking_time}} foi cancelado.\n\nQuer que eu veja outra data para voc\xEA? \u2014 {{business_name}}"
146
+ },
147
+ email: {
148
+ subject: "Hor\xE1rio de {{booking_date}} cancelado",
149
+ body: "Ol\xE1 {{customer_name}},\n\nSeu hor\xE1rio de {{booking_date}} \xE0s {{booking_time}} foi cancelado.\n\nSe quiser remarcar, \xE9 s\xF3 responder este e-mail.\n\n{{business_name}}"
150
+ }
151
+ },
152
+ defaultEnabled: { email: true }
153
+ },
154
+ {
155
+ eventKey: "agenda.booking_completed",
156
+ kind: "marketing",
157
+ // Pedir opinião é iniciativa do negócio, não resposta a um ato da
158
+ // pessoa — e é por isso que nasce desligado.
159
+ channels: ["whatsapp"],
160
+ defaults: {
161
+ whatsapp: {
162
+ body: "Oi {{customer_name}}! Foi \xF3timo te atender hoje \u{1F49B}\n\nComo foi a experi\xEAncia com {{professional_name}}? Sua opini\xE3o ajuda demais.\n\n\u2014 {{business_name}}"
163
+ }
164
+ }
165
+ },
166
+ {
167
+ eventKey: "agenda.booking_no_show",
168
+ kind: "transactional",
169
+ channels: ["whatsapp"],
170
+ defaults: {
171
+ whatsapp: {
172
+ body: "Oi {{customer_name}}, senti sua falta no hor\xE1rio de {{booking_date}} \u{1F614}\n\nAconteceu alguma coisa? Se quiser, j\xE1 deixo outro hor\xE1rio separado para voc\xEA. \u2014 {{business_name}}"
173
+ }
174
+ }
175
+ }
176
+ ];
177
+ }
178
+
19
179
  // src/config.ts
20
180
  var DEFAULT_LABELS = {
21
181
  pageTitle: "Agenda",
@@ -140,7 +300,7 @@ var useAgendaConfig = ctx.useConfig;
140
300
  var useAgendaProvider = ctx.useProvider;
141
301
  var useAgendaStore = ctx.useStore;
142
302
 
143
- // src/calendar-styles.ts
303
+ // src/styles/calendar-styles.ts
144
304
  var STYLE_ID = "saas-agenda-calendar-styles";
145
305
  var css = (
146
306
  /* css */
@@ -1557,8 +1717,7 @@ function AppointmentModal({ open, mode, bookingId, prefill, initialTab, external
1557
1717
  clearable: !isPaidBooking,
1558
1718
  variant: "inline",
1559
1719
  placeholder: t2("agenda.appointment.searchClient"),
1560
- createLabel: t2("agenda.appointment.newClient"),
1561
- hint: /* @__PURE__ */ jsx("p", { className: "text-[11px] text-warning", children: t2("agenda.appointment.selectClientHint") })
1720
+ createLabel: t2("agenda.appointment.newClient")
1562
1721
  }
1563
1722
  ) })
1564
1723
  ] }),
@@ -2261,12 +2420,17 @@ function AgendaMobileList({ bookings, statusColors, currencyCode, onSelect }) {
2261
2420
  }) })
2262
2421
  ] }, g.key)) });
2263
2422
  }
2264
- function bookingToEvent(booking, statusColors) {
2423
+
2424
+ // src/lib/calendar-events.ts
2425
+ function bookingToEvent(booking, statusColors, visibleResourceIds = []) {
2265
2426
  const color = statusColors[booking.status] ?? "#6b7280";
2266
- const serviceNames = booking.services?.map((s) => s.name).join(", ") ?? "";
2427
+ const serviceNames = booking.services?.map((service) => service.name).join(", ") ?? "";
2267
2428
  return {
2268
2429
  id: booking.id,
2269
- resourceId: booking.professionalId ?? void 0,
2430
+ // A tenant-wide block (an imported whole-agenda Google event, for example)
2431
+ // has no assignee. Availability treats it as busy for every professional, so
2432
+ // resource views must render it in every visible lane instead of dropping it.
2433
+ ...booking.professionalId ? { resourceId: booking.professionalId } : visibleResourceIds.length > 0 ? { resourceIds: visibleResourceIds } : {},
2270
2434
  title: booking.clientName ?? "Unknown",
2271
2435
  start: booking.startsAt,
2272
2436
  end: booking.endsAt ?? void 0,
@@ -2395,6 +2559,8 @@ var PLAIN_VIEW_OPTIONS = [
2395
2559
  { key: "listMonth", labelKey: "agenda.calendar.viewList" }
2396
2560
  ];
2397
2561
  var MOBILE_LIST_VIEW = "__mobileList__";
2562
+ var AGENDA_POLL_INTERVAL_MS = 3e4;
2563
+ var AGENDA_REALTIME_DEBOUNCE_MS = 300;
2398
2564
  function viewForNoResources(view) {
2399
2565
  return view.startsWith("resource") ? "dayGridMonth" : view;
2400
2566
  }
@@ -2417,6 +2583,7 @@ function useIsMobile() {
2417
2583
  function CalendarView() {
2418
2584
  injectCalendarStyles();
2419
2585
  const config = useAgendaConfig();
2586
+ const provider2 = useAgendaProvider();
2420
2587
  const t2 = useTranslation();
2421
2588
  const currentLocale = getCurrentLocale();
2422
2589
  const fcLocale = currentLocale === "pt-BR" ? ptBrLocale : void 0;
@@ -2515,9 +2682,9 @@ function CalendarView() {
2515
2682
  end.setDate(end.getDate() + 91);
2516
2683
  fetchBookings({ start: start.toISOString(), end: end.toISOString() });
2517
2684
  }, [isMobile, fetchBookings]);
2518
- useDataChanged({}, () => {
2685
+ const refreshVisibleBookings = useCallback(() => {
2519
2686
  if (visibleRange) {
2520
- fetchBookings(visibleRange);
2687
+ void fetchBookings(visibleRange);
2521
2688
  return;
2522
2689
  }
2523
2690
  const start = /* @__PURE__ */ new Date();
@@ -2525,8 +2692,38 @@ function CalendarView() {
2525
2692
  start.setDate(start.getDate() - 1);
2526
2693
  const end = new Date(start);
2527
2694
  end.setDate(end.getDate() + 91);
2528
- fetchBookings({ start: start.toISOString(), end: end.toISOString() });
2695
+ void fetchBookings({ start: start.toISOString(), end: end.toISOString() });
2529
2696
  }, [visibleRange, fetchBookings]);
2697
+ useDataChanged({}, refreshVisibleBookings, [refreshVisibleBookings]);
2698
+ useEffect(() => {
2699
+ if (typeof window === "undefined" || typeof document === "undefined") return;
2700
+ let refreshTimer;
2701
+ let unsubscribe;
2702
+ const scheduleRefresh = (delay = 0) => {
2703
+ if (document.visibilityState === "hidden") return;
2704
+ if (refreshTimer) clearTimeout(refreshTimer);
2705
+ refreshTimer = setTimeout(refreshVisibleBookings, delay);
2706
+ };
2707
+ try {
2708
+ unsubscribe = provider2.subscribeToBookingChanges?.(() => scheduleRefresh(AGENDA_REALTIME_DEBOUNCE_MS));
2709
+ } catch (error) {
2710
+ console.warn("[agenda] Realtime unavailable; using the polling fallback.", error);
2711
+ }
2712
+ const pollTimer = window.setInterval(() => scheduleRefresh(), AGENDA_POLL_INTERVAL_MS);
2713
+ const onVisibilityChange = () => {
2714
+ if (document.visibilityState === "visible") scheduleRefresh();
2715
+ };
2716
+ const onFocus = () => scheduleRefresh();
2717
+ document.addEventListener("visibilitychange", onVisibilityChange);
2718
+ window.addEventListener("focus", onFocus);
2719
+ return () => {
2720
+ if (refreshTimer) clearTimeout(refreshTimer);
2721
+ window.clearInterval(pollTimer);
2722
+ document.removeEventListener("visibilitychange", onVisibilityChange);
2723
+ window.removeEventListener("focus", onFocus);
2724
+ unsubscribe?.();
2725
+ };
2726
+ }, [provider2, refreshVisibleBookings]);
2530
2727
  const resources = useMemo(() => {
2531
2728
  const filtered = selectedProfIds.length > 0 ? professionals.filter((p) => selectedProfIds.includes(p.id)) : professionals;
2532
2729
  return filtered.map((p) => ({
@@ -2535,7 +2732,11 @@ function CalendarView() {
2535
2732
  extendedProps: { avatarUrl: p.avatarUrl, locationName: p.locationName }
2536
2733
  }));
2537
2734
  }, [professionals, selectedProfIds]);
2538
- const bookingEvents = useMemo(() => bookings.map((b) => bookingToEvent(b, statusColors)), [bookings, statusColors]);
2735
+ const visibleResourceIds = useMemo(() => resources.map((resource) => resource.id), [resources]);
2736
+ const bookingEvents = useMemo(
2737
+ () => bookings.map((b) => bookingToEvent(b, statusColors, visibleResourceIds)),
2738
+ [bookings, statusColors, visibleResourceIds]
2739
+ );
2539
2740
  const allLocations = useMemo(() => {
2540
2741
  const map = /* @__PURE__ */ new Map();
2541
2742
  for (const loc of config.locations) map.set(loc.id, loc.name);
@@ -3305,9 +3506,19 @@ async function getCanonicalBookingById(core, id) {
3305
3506
  const [booking] = await hydrateCanonicalBookings(core, [data]);
3306
3507
  return booking ?? null;
3307
3508
  }
3509
+ var bookingChangesChannelSequence = 0;
3308
3510
  function createSupabaseAgendaProvider(providerOptions) {
3309
3511
  const professionalKind = providerOptions?.professionalKind ?? "staff";
3310
3512
  return {
3513
+ subscribeToBookingChanges(onChange) {
3514
+ const { core } = getClients();
3515
+ const tenantId = getTenantId() ?? "active";
3516
+ const channelName = `agenda:appointments:${tenantId}:${++bookingChangesChannelSequence}`;
3517
+ const channel = core.channel(channelName).on("postgres_changes", { event: "*", schema: "public", table: "appointments" }, onChange).subscribe();
3518
+ return () => {
3519
+ void core.removeChannel(channel);
3520
+ };
3521
+ },
3311
3522
  // v_appointments is in public schema (PostgREST default)
3312
3523
  async getBookings(query) {
3313
3524
  const { core, pub } = getClients();
@@ -3620,6 +3831,33 @@ function createSupabaseAgendaProvider(providerOptions) {
3620
3831
  }
3621
3832
  };
3622
3833
  }
3834
+ var STATUS_EVENT = {
3835
+ cancelled: "agenda.booking_cancelled",
3836
+ completed: "agenda.booking_completed",
3837
+ no_show: "agenda.booking_no_show"
3838
+ };
3839
+ function bookingStatusEventKey(status) {
3840
+ return STATUS_EVENT[status];
3841
+ }
3842
+ function bookingPayload(booking) {
3843
+ const starts = new Date(booking.startsAt);
3844
+ return {
3845
+ customer_name: booking.clientName ?? "",
3846
+ service_name: (booking.services ?? []).map((s) => s.name).join(" + "),
3847
+ professional_name: booking.professionalName ?? "",
3848
+ booking_date: starts.toLocaleDateString("pt-BR"),
3849
+ booking_time: starts.toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" }),
3850
+ location_name: booking.locationName ?? ""
3851
+ };
3852
+ }
3853
+ function emitBookingEvent(key, booking) {
3854
+ emitDomainEvent(key, bookingPayload(booking), {
3855
+ tenantId: booking.tenantId || getAgendaTenantId() || getActiveTenantId() || null,
3856
+ subject: { type: "booking", id: booking.id }
3857
+ });
3858
+ }
3859
+
3860
+ // src/store.ts
3623
3861
  function getCurrentBookingQuery(state, dateRange = state.dateRange) {
3624
3862
  return {
3625
3863
  dateRange,
@@ -3705,6 +3943,7 @@ function createAgendaStore(provider2, financialBridge) {
3705
3943
  async createBooking(input) {
3706
3944
  try {
3707
3945
  const booking = await provider2.createBooking(input);
3946
+ emitBookingEvent("agenda.booking_created", booking);
3708
3947
  const state = get();
3709
3948
  if (state.dateRange.start && state.dateRange.end) {
3710
3949
  const bookings = await provider2.getBookings(getCurrentBookingQuery(state));
@@ -3720,7 +3959,14 @@ function createAgendaStore(provider2, financialBridge) {
3720
3959
  },
3721
3960
  async updateBooking(id, data) {
3722
3961
  try {
3962
+ const prev = get().bookings.find((b) => b.id === id);
3723
3963
  const booking = await provider2.updateBooking(id, data);
3964
+ const statusKey = prev && booking.status !== prev.status ? bookingStatusEventKey(booking.status) : void 0;
3965
+ if (statusKey) {
3966
+ emitBookingEvent(statusKey, booking);
3967
+ } else if (prev && new Date(booking.startsAt).getTime() !== new Date(prev.startsAt).getTime()) {
3968
+ emitBookingEvent("agenda.booking_rescheduled", booking);
3969
+ }
3724
3970
  const state = get();
3725
3971
  if (state.dateRange.start && state.dateRange.end) {
3726
3972
  const bookings = await provider2.getBookings(getCurrentBookingQuery(state));
@@ -3751,9 +3997,12 @@ function createAgendaStore(provider2, financialBridge) {
3751
3997
  },
3752
3998
  async updateBookingStatus(id, status) {
3753
3999
  const prev = get().bookings;
4000
+ const prevStatus = prev.find((b) => b.id === id)?.status;
3754
4001
  set({ bookings: prev.map((b) => b.id === id ? { ...b, status } : b) });
3755
4002
  try {
3756
- await provider2.updateBookingStatus(id, status);
4003
+ const updated = await provider2.updateBookingStatus(id, status);
4004
+ const key = prevStatus !== status ? bookingStatusEventKey(status) : void 0;
4005
+ if (key) emitBookingEvent(key, updated);
3757
4006
  const state = get();
3758
4007
  if (state.dateRange.start && state.dateRange.end) {
3759
4008
  const bookings = await provider2.getBookings(getCurrentBookingQuery(state));
@@ -3783,11 +4032,12 @@ function createAgendaStore(provider2, financialBridge) {
3783
4032
  throw new Error("Time conflict");
3784
4033
  }
3785
4034
  }
3786
- await provider2.updateBooking(id, {
4035
+ const updated = await provider2.updateBooking(id, {
3787
4036
  startsAt: newStart,
3788
4037
  endsAt: newEnd,
3789
4038
  ...newProfessionalId && { professionalId: newProfessionalId }
3790
4039
  });
4040
+ emitBookingEvent("agenda.booking_rescheduled", updated);
3791
4041
  const state = get();
3792
4042
  if (state.dateRange.start && state.dateRange.end) {
3793
4043
  const bookings = await provider2.getBookings(getCurrentBookingQuery(state));
@@ -3827,7 +4077,7 @@ function createAgendaStore(provider2, financialBridge) {
3827
4077
  }));
3828
4078
  }
3829
4079
 
3830
- // src/registries.ts
4080
+ // src/data/registries.ts
3831
4081
  var holidayEntity = {
3832
4082
  name: "Holiday",
3833
4083
  namePlural: "Holidays",
@@ -3960,7 +4210,6 @@ var en = {
3960
4210
  "agenda.appointment.searchService": "Search service to add...",
3961
4211
  "agenda.appointment.selectLocation": "Select location...",
3962
4212
  "agenda.appointment.selectProfessional": "Select professional...",
3963
- "agenda.appointment.selectClientHint": "Select the client from the list or use New client to enable saving.",
3964
4213
  "agenda.appointment.services": "Services",
3965
4214
  "agenda.appointment.status": "Status",
3966
4215
  "agenda.appointment.to": "to",
@@ -4181,7 +4430,6 @@ var ptBR = {
4181
4430
  "agenda.appointment.searchService": "Buscar servi\xE7o para adicionar...",
4182
4431
  "agenda.appointment.selectLocation": "Selecionar local...",
4183
4432
  "agenda.appointment.selectProfessional": "Selecionar profissional...",
4184
- "agenda.appointment.selectClientHint": "Selecione o cliente na lista ou use Novo cliente para habilitar o salvamento.",
4185
4433
  "agenda.appointment.services": "Servi\xE7os",
4186
4434
  "agenda.appointment.status": "Status",
4187
4435
  "agenda.appointment.to": "at\xE9",
@@ -4935,12 +5183,20 @@ function channelRowToChannel(r) {
4935
5183
  };
4936
5184
  }
4937
5185
 
5186
+ // src/integrations/google-calendar/credentials.ts
5187
+ var CONNECTION_COLUMNS = "id, provider, calendar_id, active, last_sync_at";
5188
+ function isIntegrationActive(row) {
5189
+ return !!row && row.active !== false;
5190
+ }
5191
+ function disconnectPatch() {
5192
+ return { active: false, updated_at: (/* @__PURE__ */ new Date()).toISOString() };
5193
+ }
5194
+
4938
5195
  // src/integrations/google-calendar/data/tables.ts
4939
5196
  var GCAL_TABLES = {
4940
5197
  integrations: "plg_calendar_integrations",
4941
5198
  channels: "plg_calendar_channels",
4942
- syncLog: "plg_calendar_sync_log"
4943
- };
5199
+ syncLog: "plg_calendar_sync_log"};
4944
5200
 
4945
5201
  // src/integrations/google-calendar/data/supabase.ts
4946
5202
  var FUNCTION = "google-calendar-sync";
@@ -4949,37 +5205,57 @@ function sb() {
4949
5205
  if (!supabase) throw new Error("Supabase not initialized");
4950
5206
  return supabase;
4951
5207
  }
5208
+ async function invoke(body) {
5209
+ const tenantId = getActiveTenantId();
5210
+ const runtimeToken = await connectorRuntimeToken({ tenantId });
5211
+ const { data, error } = await sb().functions.invoke(FUNCTION, {
5212
+ body: { ...body, tenantId },
5213
+ headers: { [CONNECTOR_RUNTIME_TOKEN_HEADER]: runtimeToken }
5214
+ });
5215
+ if (error) throw new Error(error.message);
5216
+ return data;
5217
+ }
4952
5218
  function createGoogleCalendarProvider() {
4953
5219
  return {
4954
5220
  async getIntegration() {
4955
- const { data } = await sb().from(GCAL_TABLES.integrations).select("*").eq("provider", "google").limit(1).maybeSingle();
5221
+ const { data } = await sb().from(GCAL_TABLES.integrations).select(CONNECTION_COLUMNS).eq("provider", "google").limit(1).maybeSingle();
4956
5222
  if (!data) return null;
4957
5223
  return {
4958
5224
  id: data.id,
4959
5225
  provider: data.provider,
4960
5226
  calendarId: data.calendar_id ?? "primary",
4961
- active: data.active ?? true,
4962
- connected: !!data.oauth_refresh_token,
5227
+ active: isIntegrationActive(data),
5228
+ // The sync state row existing is not the same as the clinic having
5229
+ // authorized Google — `plg_connections` owns that answer now, and the
5230
+ // connector asks it through the connection store.
5231
+ connected: isIntegrationActive(data),
4963
5232
  lastSyncAt: data.last_sync_at ?? void 0
4964
5233
  };
4965
5234
  },
4966
- async getConnectUrl(redirectTo) {
4967
- const { data, error } = await sb().functions.invoke(FUNCTION, {
4968
- body: { action: "oauth_start", tenantId: getActiveTenantId(), redirectTo: redirectTo ?? window.location.href }
4969
- });
5235
+ async ensureIntegration() {
5236
+ const { error } = await sb().from(GCAL_TABLES.integrations).upsert(
5237
+ {
5238
+ tenant_id: getActiveTenantId(),
5239
+ provider: "google",
5240
+ active: true,
5241
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
5242
+ },
5243
+ { onConflict: "tenant_id,provider" }
5244
+ );
4970
5245
  if (error) throw new Error(error.message);
4971
- return data.url;
4972
5246
  },
4973
5247
  async setCalendar(calendarId) {
4974
5248
  await sb().from(GCAL_TABLES.integrations).update({ calendar_id: calendarId, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("provider", "google");
4975
5249
  },
4976
5250
  async disconnect() {
4977
- await sb().from(GCAL_TABLES.integrations).update({ active: false, oauth_refresh_token: null, oauth_access_token: null }).eq("provider", "google");
5251
+ await invoke({ action: "disconnect" });
5252
+ await sb().from(GCAL_TABLES.integrations).update(disconnectPatch()).eq("provider", "google");
4978
5253
  },
4979
5254
  async syncNow() {
4980
- const { data, error } = await sb().functions.invoke(FUNCTION, { body: { action: "pull_events", tenantId: getActiveTenantId(), trigger: "manual" } });
4981
- if (error) throw new Error(error.message);
4982
- return data;
5255
+ return invoke({
5256
+ action: "pull_events",
5257
+ trigger: "manual"
5258
+ });
4983
5259
  },
4984
5260
  async getSyncLog() {
4985
5261
  const { data } = await sb().from(GCAL_TABLES.syncLog).select("*").order("created_at", { ascending: false }).limit(20);
@@ -4995,9 +5271,8 @@ function createGoogleCalendarProvider() {
4995
5271
  }));
4996
5272
  },
4997
5273
  async listGoogleCalendars() {
4998
- const { data, error } = await sb().functions.invoke(FUNCTION, { body: { action: "list_calendars", tenantId: getActiveTenantId() } });
4999
- if (error) throw new Error(error.message);
5000
- return data.calendars ?? [];
5274
+ const result = await invoke({ action: "list_calendars" });
5275
+ return result.calendars ?? [];
5001
5276
  },
5002
5277
  async listChannels() {
5003
5278
  const { data } = await sb().from(GCAL_TABLES.channels).select("*").order("created_at", { ascending: true });
@@ -5032,21 +5307,86 @@ function createGoogleCalendarProvider() {
5032
5307
  if (error) throw new Error(error.message);
5033
5308
  },
5034
5309
  async listExternalEvents(params) {
5035
- const { data, error } = await sb().functions.invoke(FUNCTION, {
5036
- body: { action: "list_external_events", tenantId: getActiveTenantId(), channelId: params?.channelId, timeMin: params?.timeMin }
5310
+ const result = await invoke({
5311
+ action: "list_external_events",
5312
+ channelId: params?.channelId,
5313
+ timeMin: params?.timeMin
5037
5314
  });
5038
- if (error) throw new Error(error.message);
5039
- return data.events ?? [];
5315
+ return result.events ?? [];
5040
5316
  },
5041
5317
  async importEvents(items) {
5042
- const { data, error } = await sb().functions.invoke(FUNCTION, {
5043
- body: { action: "import_events", tenantId: getActiveTenantId(), items }
5044
- });
5045
- if (error) throw new Error(error.message);
5046
- return data;
5318
+ return invoke({ action: "import_events", items });
5319
+ }
5320
+ };
5321
+ }
5322
+
5323
+ // src/integrations/google-calendar/health.ts
5324
+ function deriveGoogleCalendarHealth(input) {
5325
+ const now = input.now ?? Date.now();
5326
+ const staleAfterMs = input.staleAfterMs ?? 45 * 6e4;
5327
+ const renewBeforeMs = input.renewBeforeMs ?? 24 * 60 * 6e4;
5328
+ const issues = [];
5329
+ const latest = input.logs[0];
5330
+ if (latest && latest.status !== "success" && latest.status !== "skipped") {
5331
+ issues.push({ code: "sync_error", detail: latest.error });
5332
+ }
5333
+ if (!input.lastSyncAt) {
5334
+ issues.push({ code: "sync_missing" });
5335
+ } else {
5336
+ const lastSyncMs = new Date(input.lastSyncAt).getTime();
5337
+ if (!Number.isFinite(lastSyncMs) || lastSyncMs < now - staleAfterMs) {
5338
+ issues.push({ code: "sync_stale", detail: input.lastSyncAt });
5339
+ }
5340
+ }
5341
+ const inbound = input.channels.filter(
5342
+ (channel) => channel.isActive && (channel.direction === "inbound" || channel.direction === "bidirectional")
5343
+ );
5344
+ if (inbound.length === 0) issues.push({ code: "inbound_channel_missing" });
5345
+ for (const channel of inbound) {
5346
+ if (!channel.channelId || !channel.resourceId || !channel.channelExpiresAt) {
5347
+ issues.push({ code: "watch_missing", detail: channel.summary ?? channel.googleCalendarId });
5348
+ continue;
5349
+ }
5350
+ const expiresAt = new Date(channel.channelExpiresAt).getTime();
5351
+ if (!Number.isFinite(expiresAt) || expiresAt <= now + renewBeforeMs) {
5352
+ issues.push({ code: "watch_expiring", detail: channel.summary ?? channel.googleCalendarId });
5047
5353
  }
5354
+ }
5355
+ const localTimeZone2 = input.localTimeZone;
5356
+ const mismatchedTimeZones = [...new Set(input.calendarTimeZones ?? [])].filter(Boolean).filter((timeZone) => localTimeZone2 && timeZone !== localTimeZone2);
5357
+ if (mismatchedTimeZones.length > 0) {
5358
+ issues.push({ code: "timezone_mismatch", detail: mismatchedTimeZones.join(", ") });
5359
+ }
5360
+ return {
5361
+ status: issues.some((issue) => issue.code === "sync_error") ? "error" : issues.length > 0 ? "attention" : "healthy",
5362
+ issues
5048
5363
  };
5049
5364
  }
5365
+
5366
+ // src/integrations/google-calendar/panel-data.ts
5367
+ function localTimeZone() {
5368
+ return typeof Intl !== "undefined" ? Intl.DateTimeFormat().resolvedOptions().timeZone : void 0;
5369
+ }
5370
+ function googleCalendarHealthOf(input) {
5371
+ if (!input.integration?.connected) return { status: "disconnected", issues: [] };
5372
+ return deriveGoogleCalendarHealth({
5373
+ lastSyncAt: input.integration.lastSyncAt,
5374
+ logs: input.logs,
5375
+ channels: input.channels,
5376
+ localTimeZone: localTimeZone()
5377
+ });
5378
+ }
5379
+ async function readGoogleCalendarPanel(provider2) {
5380
+ const integration = await provider2.getIntegration().catch(() => null);
5381
+ if (!integration?.connected) {
5382
+ return { integration, logs: [], channels: [], health: { status: "disconnected", issues: [] } };
5383
+ }
5384
+ const [logs, channels] = await Promise.all([
5385
+ provider2.getSyncLog().catch(() => []),
5386
+ provider2.listChannels().catch(() => [])
5387
+ ]);
5388
+ return { integration, logs, channels, health: googleCalendarHealthOf({ integration, logs, channels }) };
5389
+ }
5050
5390
  function CalendarColorDot({
5051
5391
  color,
5052
5392
  className = ""
@@ -5477,123 +5817,93 @@ function ImportEventsWizard({
5477
5817
  ] });
5478
5818
  }
5479
5819
  var provider = createGoogleCalendarProvider();
5820
+ var GOOGLE_CALENDAR_CONNECTOR_ID = "google-calendar";
5821
+ var googleCalendarConnectionStore = () => createConnectionStore(GOOGLE_CALENDAR_CONNECTOR_ID, {
5822
+ settingsKeys: ["calendarId"]
5823
+ });
5824
+ var HEALTH_LABELS = {
5825
+ sync_error: "a \xFAltima sincroniza\xE7\xE3o falhou",
5826
+ sync_missing: "nenhuma sincroniza\xE7\xE3o foi feita ainda",
5827
+ sync_stale: "sem sincronizar h\xE1 mais de uma hora",
5828
+ inbound_channel_missing: "nenhuma agenda do Google est\xE1 marcada para importar",
5829
+ watch_missing: "o aviso autom\xE1tico do Google n\xE3o est\xE1 ativo",
5830
+ watch_expiring: "o aviso autom\xE1tico do Google est\xE1 prestes a expirar",
5831
+ timezone_mismatch: "o fuso hor\xE1rio do Google \xE9 diferente do seu"
5832
+ };
5833
+ function issueLine(issue) {
5834
+ const label = HEALTH_LABELS[issue.code];
5835
+ return issue.detail ? `${label} (${issue.detail})` : label;
5836
+ }
5837
+ function healthDetail(health) {
5838
+ if (health.status === "disconnected") return void 0;
5839
+ if (health.status === "healthy") return "Sincronizando normalmente";
5840
+ return issueLine(health.issues[0]);
5841
+ }
5842
+ async function readHealth() {
5843
+ return (await readGoogleCalendarPanel(provider)).health;
5844
+ }
5480
5845
  function inboundChannels(channels) {
5481
5846
  return channels.filter(
5482
5847
  (c) => c.isActive && (c.direction === "inbound" || c.direction === "bidirectional")
5483
5848
  );
5484
5849
  }
5485
- function GoogleCalendarExtraPanel() {
5850
+ var TRIGGERS = ["on-write", "scheduled", "manual", "webhook"];
5851
+ function toSyncRun(entry) {
5852
+ return {
5853
+ connectionId: "",
5854
+ connectorId: "google-calendar",
5855
+ direction: entry.direction === "inbound" ? "inbound" : "outbound",
5856
+ triggerKind: TRIGGERS.includes(entry.trigger) ? entry.trigger : "manual",
5857
+ status: entry.status === "success" || entry.status === "partial" || entry.status === "running" ? entry.status : "error",
5858
+ id: entry.id,
5859
+ fetched: entry.fetched,
5860
+ written: entry.written,
5861
+ error: entry.error ?? null,
5862
+ startedAt: entry.createdAt
5863
+ };
5864
+ }
5865
+ function GoogleCalendarMapping() {
5486
5866
  const [connected, setConnected] = useState(false);
5487
- const [lastSyncAt, setLastSyncAt] = useState();
5488
- const [syncing, setSyncing] = useState(false);
5489
- const [log, setLog] = useState([]);
5490
5867
  const [loaded, setLoaded] = useState(false);
5868
+ useEffect(() => {
5869
+ let cancelled = false;
5870
+ void provider.getIntegration().then((i) => {
5871
+ if (!cancelled) setConnected(!!i?.connected);
5872
+ }).catch(() => {
5873
+ }).finally(() => {
5874
+ if (!cancelled) setLoaded(true);
5875
+ });
5876
+ return () => {
5877
+ cancelled = true;
5878
+ };
5879
+ }, []);
5880
+ if (!loaded || !connected) return null;
5881
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
5882
+ /* @__PURE__ */ jsx("p", { className: "text-xs leading-relaxed text-muted-foreground", children: "Diga o que cada agenda do Google sincroniza \u2014 um profissional, um servi\xE7o, um local, ou a sua agenda inteira. Cada mudan\xE7a \xE9 salva na hora." }),
5883
+ /* @__PURE__ */ jsx(ChannelMappingList, { provider })
5884
+ ] });
5885
+ }
5886
+ function GoogleCalendarImport({ onClose, onRan }) {
5491
5887
  const [channels, setChannels] = useState([]);
5492
- const [showMapping, setShowMapping] = useState(false);
5493
- const [showWizard, setShowWizard] = useState(false);
5494
- async function load() {
5495
- const integ = await provider.getIntegration();
5496
- setConnected(!!integ?.connected);
5497
- if (integ) {
5498
- setLastSyncAt(integ.lastSyncAt);
5499
- void provider.getSyncLog().then(setLog);
5500
- void provider.listChannels().then((ch) => {
5501
- setChannels(ch);
5502
- if (ch.length > 0) setShowMapping(true);
5503
- });
5504
- }
5505
- setLoaded(true);
5506
- }
5507
5888
  useEffect(() => {
5508
- void load();
5889
+ let cancelled = false;
5890
+ void provider.listChannels().then((ch) => {
5891
+ if (!cancelled) setChannels(ch);
5892
+ }).catch(() => {
5893
+ });
5894
+ return () => {
5895
+ cancelled = true;
5896
+ };
5509
5897
  }, []);
5510
- if (!loaded) return null;
5511
- if (!connected) {
5512
- return /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground border-t pt-4", children: "Connect a Google account above to map your calendars and import events." });
5513
- }
5514
- async function handleSyncNow() {
5515
- setSyncing(true);
5516
- try {
5517
- const r = await provider.syncNow();
5518
- toast.success(`Synced \u2014 ${r.written} updated`);
5519
- void provider.getSyncLog().then(setLog);
5520
- void provider.getIntegration().then((i) => setLastSyncAt(i?.lastSyncAt));
5521
- } catch (e) {
5522
- toast.error(e?.message ?? "Sync failed");
5523
- } finally {
5524
- setSyncing(false);
5898
+ return /* @__PURE__ */ jsx(
5899
+ ImportEventsWizard,
5900
+ {
5901
+ provider,
5902
+ channels: inboundChannels(channels),
5903
+ onClose,
5904
+ onImported: () => onRan?.()
5525
5905
  }
5526
- }
5527
- const canImport = inboundChannels(channels).length > 0;
5528
- return /* @__PURE__ */ jsxs("div", { className: "space-y-5 border-t pt-4", children: [
5529
- /* @__PURE__ */ jsxs("section", { className: "space-y-2", children: [
5530
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
5531
- /* @__PURE__ */ jsx(CalendarCog, { className: "h-4 w-4 text-muted-foreground" }),
5532
- /* @__PURE__ */ jsx("h4", { className: "text-sm font-semibold", children: "Calendars" })
5533
- ] }),
5534
- showMapping ? /* @__PURE__ */ jsx(ChannelMappingList, { provider, onChannelsChange: setChannels }) : /* @__PURE__ */ jsxs("div", { className: "rounded-md border border-dashed px-4 py-5 text-center space-y-2", children: [
5535
- /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: "Map each Google calendar to what it syncs \u2014 a professional, service, location, or your whole agenda." }),
5536
- /* @__PURE__ */ jsxs(Button, { variant: "outline", size: "sm", onClick: () => setShowMapping(true), children: [
5537
- /* @__PURE__ */ jsx(CalendarCog, { className: "h-3.5 w-3.5" }),
5538
- " Configure calendars"
5539
- ] })
5540
- ] })
5541
- ] }),
5542
- showMapping && /* @__PURE__ */ jsx("section", { className: "space-y-2", children: showWizard ? /* @__PURE__ */ jsx(
5543
- ImportEventsWizard,
5544
- {
5545
- provider,
5546
- channels: inboundChannels(channels),
5547
- onClose: () => setShowWizard(false),
5548
- onImported: () => {
5549
- void provider.getSyncLog().then(setLog);
5550
- void provider.getIntegration().then((i) => setLastSyncAt(i?.lastSyncAt));
5551
- }
5552
- }
5553
- ) : /* @__PURE__ */ jsxs(
5554
- Button,
5555
- {
5556
- variant: "outline",
5557
- size: "sm",
5558
- disabled: !canImport,
5559
- title: canImport ? void 0 : "Enable an importing calendar first",
5560
- onClick: () => setShowWizard(true),
5561
- children: [
5562
- /* @__PURE__ */ jsx(CalendarPlus, { className: "h-3.5 w-3.5" }),
5563
- " Import future events"
5564
- ]
5565
- }
5566
- ) }),
5567
- /* @__PURE__ */ jsxs("section", { className: "space-y-2", children: [
5568
- /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [
5569
- /* @__PURE__ */ jsxs(Button, { variant: "outline", size: "sm", onClick: handleSyncNow, disabled: syncing, children: [
5570
- /* @__PURE__ */ jsx(RefreshCw, { className: `h-3.5 w-3.5 ${syncing ? "animate-spin" : ""}` }),
5571
- " Sync now"
5572
- ] }),
5573
- lastSyncAt && /* @__PURE__ */ jsxs("span", { className: "text-[11px] text-muted-foreground", children: [
5574
- "Last sync: ",
5575
- new Date(lastSyncAt).toLocaleString()
5576
- ] })
5577
- ] }),
5578
- log.length > 0 && /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
5579
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
5580
- /* @__PURE__ */ jsx(History, { className: "h-4 w-4 text-muted-foreground" }),
5581
- /* @__PURE__ */ jsx("h4", { className: "text-sm font-semibold", children: "History" })
5582
- ] }),
5583
- /* @__PURE__ */ jsx("div", { className: "rounded-md border divide-y text-sm", children: log.map((r) => /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 px-3 py-2", children: [
5584
- /* @__PURE__ */ jsx("span", { className: "text-muted-foreground text-xs", children: new Date(r.createdAt).toLocaleString() }),
5585
- /* @__PURE__ */ jsx("span", { className: "text-[10px] uppercase tracking-wide px-1 py-px rounded bg-muted text-muted-foreground", children: r.direction }),
5586
- /* @__PURE__ */ jsxs("span", { className: "ml-auto text-xs", children: [
5587
- r.written,
5588
- " written / ",
5589
- r.fetched,
5590
- " fetched"
5591
- ] }),
5592
- r.status === "success" ? /* @__PURE__ */ jsx(CheckCircle2, { className: "h-3.5 w-3.5 text-success" }) : /* @__PURE__ */ jsx(AlertCircle, { className: "h-3.5 w-3.5 text-warning" })
5593
- ] }, r.id)) })
5594
- ] })
5595
- ] })
5596
- ] });
5906
+ );
5597
5907
  }
5598
5908
  var googleCalendarConnectorDef = {
5599
5909
  id: "google-calendar",
@@ -5603,50 +5913,2061 @@ var googleCalendarConnectorDef = {
5603
5913
  icon: "Calendar",
5604
5914
  authKind: "oauth",
5605
5915
  category: "scheduling",
5916
+ // `direction` is who reaches for whom: this app reaches Google (consent, push,
5917
+ // pull). The data being BIDIRECTIONAL is a property of the entity, and is
5918
+ // declared where the type allows it — see ./connector.ts, capability
5919
+ // `booking: 'bidirectional'`.
5606
5920
  direction: "outbound",
5607
5921
  // Sync only, and deliberately: the agenda has to exist inside the app — to be
5608
5922
  // listed, filtered, reported on, and read when Google is unreachable. An
5609
5923
  // agentic plane would answer questions but leave the calendar screen empty.
5610
5924
  planes: ["sync"],
5611
- entities: ["booking"],
5612
5925
  docsUrl: "https://support.google.com/calendar",
5926
+ // What it needs, grounded in what the data plane actually does. `booking` is
5927
+ // read AND written: the pull path lands external events through the
5928
+ // `gcal_import_event` / `gcal_apply_event_patch` RPCs (migrations/002 §
5929
+ // "import"), which INSERT and UPDATE public.appointments, and the push path
5930
+ // reads the appointment plus its items to compose the Google event.
5931
+ //
5932
+ // Marked sensitive because of what leaves the tenant, not because of the row
5933
+ // count: the event title is "<serviços> — <nome do cliente>" and the
5934
+ // description carries the appointment notes (./mapping.ts). Someone
5935
+ // authorizing this is sending client names and visit notes to Google, and that
5936
+ // is the fact the consent view exists to put in front of them.
5937
+ permissions: [
5938
+ {
5939
+ kind: "data",
5940
+ resource: "booking",
5941
+ actions: ["read", "write"],
5942
+ reason: "Ler os agendamentos para criar os eventos no Google, e gravar aqui os eventos que voc\xEA criar por l\xE1. Nome do cliente, servi\xE7os e observa\xE7\xF5es v\xE3o no evento.",
5943
+ sensitive: true
5944
+ },
5945
+ // The three facts that change what Google should show, and only those.
5946
+ // `booking_completed`/`booking_no_show` are left out because Google has no
5947
+ // notion of either and the event does not move; `booking_reminder` likewise.
5948
+ {
5949
+ kind: "event",
5950
+ resource: "agenda.booking_created",
5951
+ actions: ["listen"],
5952
+ reason: "Saber que um hor\xE1rio foi marcado, para criar o evento no Google Calendar."
5953
+ },
5954
+ {
5955
+ kind: "event",
5956
+ resource: "agenda.booking_rescheduled",
5957
+ actions: ["listen"],
5958
+ reason: "Saber que um hor\xE1rio mudou, para mover o evento no Google Calendar."
5959
+ },
5960
+ {
5961
+ kind: "event",
5962
+ resource: "agenda.booking_cancelled",
5963
+ actions: ["listen"],
5964
+ reason: "Saber que um agendamento foi cancelado, para apagar o evento do Google Calendar."
5965
+ }
5966
+ ],
5967
+ // Keys copied from ../../lib/message-events.ts, which is where the agenda
5968
+ // declares them.
5969
+ //
5970
+ // `agenda.booking_completed` and `agenda.booking_no_show` are left out on
5971
+ // purpose: Google has no notion of either, and the event does not move, so
5972
+ // waking the connector for them is a round-trip that changes nothing.
5973
+ // `agenda.booking_reminder` is left out for the same reason — a reminder is
5974
+ // not a change to the calendar.
5975
+ //
5976
+ // Deletion is missing because the agenda declares no `booking_deleted` fact;
5977
+ // a hard delete is delivered by the trigger in migrations/004, not by an
5978
+ // event. That asymmetry is real and is written down in ./GOOGLE-CALENDAR.md.
5979
+ subscribesTo: [
5980
+ "agenda.booking_created",
5981
+ "agenda.booking_rescheduled",
5982
+ "agenda.booking_cancelled"
5983
+ ],
5984
+ // These are not preferences someone chose — they are what the integration IS,
5985
+ // so they appear only while the connection is live and disconnecting is the
5986
+ // way out. Today the trigger in migrations/004 delivers them; when the Fayz
5987
+ // ingress lands it takes over the same three, which is what makes that swap a
5988
+ // configuration change instead of a rewrite.
5989
+ managedAutomations: [
5990
+ { eventKey: "agenda.booking_created", label: "Criar o evento no Google Calendar quando um hor\xE1rio \xE9 agendado aqui" },
5991
+ { eventKey: "agenda.booking_rescheduled", label: "Mover o evento no Google Calendar quando o hor\xE1rio \xE9 remarcado" },
5992
+ { eventKey: "agenda.booking_cancelled", label: "Apagar o evento do Google Calendar quando o agendamento \xE9 cancelado" }
5993
+ ],
5994
+ // The two moments that DO exist in code, as opposed to the three above, which
5995
+ // are delivered by the trigger in migrations/004 rather than by an ingress.
5996
+ // Both are sync moments and both reach `booking`, which is what `touches`
5997
+ // says — the connector cannot name a resource it did not ask for.
5998
+ invokedAt: [
5999
+ {
6000
+ key: "agenda.booking.push",
6001
+ label: "Um hor\xE1rio \xE9 criado, remarcado ou cancelado aqui, e o evento \xE9 atualizado no Google",
6002
+ plane: "sync",
6003
+ trigger: "on-write",
6004
+ touches: ["booking"]
6005
+ },
6006
+ {
6007
+ key: "agenda.calendar.pull",
6008
+ label: "O Google avisa que algo mudou por l\xE1, e os eventos s\xE3o trazidos para c\xE1",
6009
+ plane: "sync",
6010
+ trigger: "webhook",
6011
+ touches: ["booking"]
6012
+ }
6013
+ ],
6014
+ /**
6015
+ * The LIST row, from the connection the hub already batched — one request for
6016
+ * N connectors, and no calendar read at all.
6017
+ */
6018
+ ...connectionStatus(
6019
+ GOOGLE_CALENDAR_CONNECTOR_ID,
6020
+ (connection) => connection?.active ? { connected: true, plane: "sync" } : { connected: false },
6021
+ { store: googleCalendarConnectionStore }
6022
+ ),
6023
+ /**
6024
+ * The DETAIL screen, where health is worth a round-trip.
6025
+ *
6026
+ * Two questions, in order, and they are not the same one: `plg_connections`
6027
+ * says whether the clinic authorized Google, and ./health.ts says whether the
6028
+ * sync is actually working. Asking only the second is what let a connection
6029
+ * read as "conectado" off a token timestamp; asking only the first would
6030
+ * report a healthy connector that has not synced since Tuesday.
6031
+ */
5613
6032
  async getStatus() {
5614
- const integ = await provider.getIntegration();
5615
- return { connected: !!integ?.connected };
6033
+ try {
6034
+ const connection = await googleCalendarConnectionStore().get();
6035
+ if (!connection?.active) return { connected: false };
6036
+ const health = await readHealth();
6037
+ return {
6038
+ connected: true,
6039
+ plane: "sync",
6040
+ detail: healthDetail(health),
6041
+ issues: health.issues.map(issueLine)
6042
+ };
6043
+ } catch {
6044
+ return { connected: false };
6045
+ }
5616
6046
  },
5617
- startOAuth: (redirectTo) => provider.getConnectUrl(redirectTo),
5618
- disconnect: () => provider.disconnect(),
5619
- ExtraPanel: GoogleCalendarExtraPanel
5620
- };
5621
-
5622
- // src/integrations/google-calendar/connector.ts
5623
- var googleCalendarConnector = {
5624
- id: "google-calendar",
5625
- provider: "google",
5626
- pluginId: "agenda",
5627
- authKind: "oauth",
5628
- capabilities: [
6047
+ // Consent runs on the platform: it holds the Google client pair, takes the
6048
+ // callback and puts the grant in the vault. This pool used to do all three.
6049
+ startOAuth: fayzConnectorOAuth(GOOGLE_CALENDAR_CONNECTOR_ID),
6050
+ /**
6051
+ * The other half of the consent. The platform stores the grant; the pool
6052
+ * stores "is it on?", and with nobody writing the second the connector would
6053
+ * answer "não conectado" to a clinic that had just authorized Google.
6054
+ *
6055
+ * Called with no values — the credential is not ours to see.
6056
+ */
6057
+ saveConnection: async () => {
6058
+ await provider.ensureIntegration();
6059
+ await googleCalendarConnectionStore().save({ active: true });
6060
+ },
6061
+ /**
6062
+ * Three things end a connection, and only the first is this pool's:
6063
+ * the row flips inactive (here)
6064
+ * the watches are stopped at Google and the cursors cleared (the function)
6065
+ * the grant is revoked (the platform — it holds the token)
6066
+ *
6067
+ * The function call is best-effort on purpose: a clinic that already revoked
6068
+ * us at Google gets a 409 from it, and refusing to disconnect over that would
6069
+ * leave the connector on with no way to turn it off.
6070
+ */
6071
+ disconnect: async () => {
6072
+ try {
6073
+ await provider.disconnect();
6074
+ } catch {
6075
+ }
6076
+ await googleCalendarConnectionStore().disconnect();
6077
+ },
6078
+ // Disconnecting stops the traffic; it does not undo it. Said here because the
6079
+ // question is only ever asked next to the button.
6080
+ disconnectNote: "Desconectar para a sincroniza\xE7\xE3o nos dois sentidos. Os agendamentos j\xE1 importados continuam aqui, e os eventos j\xE1 criados continuam no Google.",
6081
+ async listRuns(options) {
6082
+ try {
6083
+ const log = await provider.getSyncLog();
6084
+ const runs = log.map(toSyncRun);
6085
+ return options?.limit ? runs.slice(0, options.limit) : runs;
6086
+ } catch {
6087
+ return [];
6088
+ }
6089
+ },
6090
+ // Declared, not drawn — the same seam Bling uses. Behaviour unchanged.
6091
+ syncActions: [
5629
6092
  {
5630
- entity: "booking",
5631
- direction: "bidirectional",
5632
- triggers: ["on-write", "scheduled", "webhook"]
6093
+ key: "sync_now",
6094
+ label: "Sincronizar agora",
6095
+ direction: "inbound",
6096
+ run: async () => {
6097
+ const result = await provider.syncNow();
6098
+ return `Sincronizado \u2014 ${result.written} atualizados`;
6099
+ }
6100
+ },
6101
+ {
6102
+ key: "import_events",
6103
+ label: "Importar eventos futuros",
6104
+ direction: "inbound",
6105
+ // Hidden rather than disabled: nothing to bring in, and a greyed control
6106
+ // reads as something the reader failed to do.
6107
+ available: async () => {
6108
+ try {
6109
+ return inboundChannels(await provider.listChannels()).length > 0;
6110
+ } catch {
6111
+ return false;
6112
+ }
6113
+ },
6114
+ Component: GoogleCalendarImport,
6115
+ run: async () => {
6116
+ }
5633
6117
  }
5634
- ]
6118
+ ],
6119
+ ExtraPanel: GoogleCalendarMapping
5635
6120
  };
5636
6121
 
5637
- // src/integrations/google-calendar/index.ts
5638
- function createGoogleCalendarPlugin() {
5639
- return {
5640
- id: "google-calendar",
5641
- name: "Google Calendar",
5642
- icon: "Calendar",
5643
- version: "0.1.0",
5644
- scope: "addon",
5645
- defaultEnabled: true,
5646
- dependencies: ["agenda"],
5647
- navigation: [],
5648
- routes: [],
5649
- connectors: [googleCalendarConnectorDef]
6122
+ // src/integrations/google-calendar/migrations/index.ts
6123
+ var MIGRATION_001_GOOGLE_CALENDAR = `-- ---------------------------------------------------------------------------
6124
+ -- Google Calendar integration (bidirectional) \u2014 connection + audit + triggers
6125
+ -- ---------------------------------------------------------------------------
6126
+ -- Outbound (Fayz booking \u2192 Google event) fires from a trigger on public.appointments
6127
+ -- via pg_net to the google-calendar-sync edge function (push_event). Inbound
6128
+ -- (Google \u2192 booking) runs on a pg_cron schedule (pull_events) and/or a Google
6129
+ -- watch-channel webhook. The booking\u2194event link is stored on
6130
+ -- public.appointments.metadata.googleCalendarEventId (no schema change there).
6131
+
6132
+ -- Plugin-owned tables carry the platform \`plg_<plugin>_\` prefix (DATA-MODEL.md
6133
+ -- Ring 1). Legacy pools provisioned under the old \`calendar_*\` names are migrated
6134
+ -- by 003_plg_rename.sql; fresh installs are born with the prefixed names here.
6135
+ CREATE TABLE IF NOT EXISTS public.plg_calendar_integrations (
6136
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
6137
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
6138
+ provider text NOT NULL DEFAULT 'google',
6139
+ oauth_refresh_token text,
6140
+ oauth_access_token text,
6141
+ token_expires_at timestamptz,
6142
+ calendar_id text NOT NULL DEFAULT 'primary',
6143
+ -- Incremental pull cursor + Google watch channel bookkeeping.
6144
+ sync_token text,
6145
+ channel_id text,
6146
+ channel_resource_id text,
6147
+ channel_expires_at timestamptz,
6148
+ active boolean NOT NULL DEFAULT true,
6149
+ last_sync_at timestamptz,
6150
+ created_at timestamptz NOT NULL DEFAULT now(),
6151
+ updated_at timestamptz NOT NULL DEFAULT now(),
6152
+ UNIQUE (tenant_id, provider)
6153
+ );
6154
+ ALTER TABLE public.plg_calendar_integrations ENABLE ROW LEVEL SECURITY;
6155
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_integrations_tenant ON public.plg_calendar_integrations(tenant_id);
6156
+
6157
+ CREATE TABLE IF NOT EXISTS public.plg_calendar_sync_log (
6158
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
6159
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
6160
+ direction text NOT NULL, -- inbound | outbound
6161
+ trigger text, -- on-write | scheduled | webhook | manual
6162
+ status text NOT NULL DEFAULT 'success', -- success | partial | error
6163
+ fetched integer NOT NULL DEFAULT 0,
6164
+ written integer NOT NULL DEFAULT 0,
6165
+ error text,
6166
+ created_at timestamptz NOT NULL DEFAULT now()
6167
+ );
6168
+ ALTER TABLE public.plg_calendar_sync_log ENABLE ROW LEVEL SECURITY;
6169
+
6170
+ -- RLS: tenant isolation.
6171
+ DO $$
6172
+ DECLARE t text;
6173
+ BEGIN
6174
+ FOR t IN SELECT unnest(ARRAY['plg_calendar_integrations','plg_calendar_sync_log'])
6175
+ LOOP
6176
+ EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO authenticated', t);
6177
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_rls') THEN
6178
+ EXECUTE format('CREATE POLICY %I ON public.%I FOR ALL TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids())) WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_rls', t);
6179
+ END IF;
6180
+ END LOOP;
6181
+ END $$;
6182
+
6183
+ -- ---------------------------------------------------------------------------
6184
+ -- Outbound trigger: booking change \u2192 edge function (push_event).
6185
+ -- Requires pg_net + a configured function URL. Set once per project:
6186
+ -- ALTER DATABASE postgres SET app.gcal_sync_url = 'https://<ref>.functions.supabase.co/google-calendar-sync';
6187
+ -- ALTER DATABASE postgres SET app.gcal_service_key = '<service-role-key>';
6188
+ -- Safe to skip in environments without pg_net \u2014 outbound also works via the
6189
+ -- client-side withGoogleCalendarSync() decorator.
6190
+ -- ---------------------------------------------------------------------------
6191
+ CREATE OR REPLACE FUNCTION public.fn_gcal_push_booking() RETURNS trigger
6192
+ LANGUAGE plpgsql SECURITY DEFINER AS $$
6193
+ DECLARE
6194
+ url text := current_setting('app.gcal_sync_url', true);
6195
+ key text := current_setting('app.gcal_service_key', true);
6196
+ rec record := COALESCE(NEW, OLD);
6197
+ op text := lower(TG_OP);
6198
+ BEGIN
6199
+ IF url IS NULL OR url = '' THEN RETURN COALESCE(NEW, OLD); END IF;
6200
+ PERFORM net.http_post(
6201
+ url := url,
6202
+ headers := jsonb_build_object('Content-Type', 'application/json', 'Authorization', 'Bearer ' || COALESCE(key, '')),
6203
+ body := jsonb_build_object('action', 'push_event', 'op', op, 'bookingId', rec.id, 'tenantId', rec.tenant_id)
6204
+ );
6205
+ RETURN COALESCE(NEW, OLD);
6206
+ END $$;
6207
+
6208
+ DROP TRIGGER IF EXISTS trg_gcal_push ON public.appointments;
6209
+ CREATE TRIGGER trg_gcal_push
6210
+ AFTER INSERT OR UPDATE OR DELETE ON public.appointments
6211
+ FOR EACH ROW EXECUTE FUNCTION public.fn_gcal_push_booking();
6212
+
6213
+ -- Inbound schedule (optional): pull external changes every 10 minutes.
6214
+ -- SELECT cron.schedule('gcal-pull', '*/10 * * * *', $$
6215
+ -- SELECT net.http_post(
6216
+ -- url := current_setting('app.gcal_sync_url'),
6217
+ -- headers := jsonb_build_object('Content-Type','application/json','Authorization','Bearer '||current_setting('app.gcal_service_key')),
6218
+ -- body := jsonb_build_object('action','pull_events'));
6219
+ -- $$);
6220
+ `;
6221
+ var MIGRATION_002_SMART_SYNC = `-- ---------------------------------------------------------------------------
6222
+ -- Google Calendar SMART SYNC \u2014 multi-calendar de-para + loop-safe two-way sync
6223
+ -- ---------------------------------------------------------------------------
6224
+ -- Builds on 001_google_calendar.sql. Where 001 modelled a single calendar per
6225
+ -- tenant (plg_calendar_integrations, one outbound trigger, a pull template), 002
6226
+ -- makes the sync vertical-agnostic and bidirectional-safe:
6227
+ --
6228
+ -- \xA71 public.plg_calendar_channels \u2014 de-para: one Google calendar \u2192 one sync
6229
+ -- target (whole tenant / assignee / service
6230
+ -- / location), per direction + import mode
6231
+ -- \xA72 fn_gcal_push_booking (REPLACE)\u2014 loop-prevention guard + correlation id
6232
+ -- \xA73 plg_calendar_sync_log \u2014 correlation_id + channel_id_ref columns
6233
+ -- \xA74 inbound write functions \u2014 the ONLY path Google\u2192booking writes take
6234
+ -- (gcal_import_event / gcal_apply_event_patch
6235
+ -- / gcal_unlink_channel), SECURITY DEFINER,
6236
+ -- service_role-only
6237
+ -- \xA75 get_available_slots (REPLACE) \u2014 tenant-wide imported blocks now also
6238
+ -- remove slots for every professional
6239
+ -- \xA76 pg_cron template \u2014 per-channel pull
6240
+ --
6241
+ -- Idempotent / re-runnable: IF NOT EXISTS, CREATE OR REPLACE, DROP \u2026 IF EXISTS.
6242
+ -- Written against the canonical public names (appointments uses starts_at/ends_at,
6243
+ -- metadata jsonb, party_id/assignee_id \u2014 see packages/db 004_archetypes.sql).
6244
+ -- ---------------------------------------------------------------------------
6245
+
6246
+ -- ===========================================================================
6247
+ -- \xA71 \u2014 plg_calendar_channels: multi-calendar de-para (vertical-agnostic)
6248
+ -- target_kind NULL \u2192 whole tenant agenda (agency-os): every professional
6249
+ -- target_kind 'assignee' \u2192 calendar maps to one professional (school: teacher)
6250
+ -- target_kind 'service' | 'location' \u2192 clinic-style mapping
6251
+ -- import_mode 'block' \u2192 inbound events become opaque busy blocks
6252
+ -- import_mode 'appointment' \u2192 inbound events become real appointments
6253
+ -- ===========================================================================
6254
+ CREATE TABLE IF NOT EXISTS public.plg_calendar_channels (
6255
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
6256
+ integration_id uuid NOT NULL REFERENCES public.plg_calendar_integrations(id) ON DELETE CASCADE,
6257
+ tenant_id uuid NOT NULL,
6258
+ google_calendar_id text NOT NULL,
6259
+ summary text,
6260
+ color text,
6261
+ direction text NOT NULL DEFAULT 'bidirectional'
6262
+ CHECK (direction IN ('inbound','outbound','bidirectional','off')),
6263
+ target_kind text CHECK (target_kind IN ('assignee','service','location')),
6264
+ target_id uuid,
6265
+ import_mode text NOT NULL DEFAULT 'block'
6266
+ CHECK (import_mode IN ('block','appointment')),
6267
+ sync_token text,
6268
+ channel_id text,
6269
+ resource_id text,
6270
+ channel_expires_at timestamptz,
6271
+ is_active boolean NOT NULL DEFAULT true,
6272
+ created_at timestamptz NOT NULL DEFAULT now(),
6273
+ updated_at timestamptz NOT NULL DEFAULT now(),
6274
+ UNIQUE (integration_id, google_calendar_id)
6275
+ );
6276
+
6277
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_channels_tenant ON public.plg_calendar_channels(tenant_id);
6278
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_channels_integration ON public.plg_calendar_channels(integration_id);
6279
+
6280
+ ALTER TABLE public.plg_calendar_channels ENABLE ROW LEVEL SECURITY;
6281
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_calendar_channels TO authenticated;
6282
+ -- service_role bypasses RLS, but this table is born after 004's blanket grant,
6283
+ -- so grant it explicitly (the inbound functions run as definer/service_role).
6284
+ GRANT ALL ON public.plg_calendar_channels TO service_role;
6285
+
6286
+ DROP TRIGGER IF EXISTS plg_calendar_channels_updated_at ON public.plg_calendar_channels;
6287
+ CREATE TRIGGER plg_calendar_channels_updated_at BEFORE UPDATE ON public.plg_calendar_channels
6288
+ FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
6289
+
6290
+ -- RLS: tenant isolation (padr\xE3o 001) + explicit service_role FOR ALL policy
6291
+ -- (matches the vigente service_role policy pattern from 001_core payment_events).
6292
+ DROP POLICY IF EXISTS plg_calendar_channels_rls ON public.plg_calendar_channels;
6293
+ CREATE POLICY plg_calendar_channels_rls ON public.plg_calendar_channels
6294
+ FOR ALL TO authenticated
6295
+ USING (tenant_id IN (SELECT public.user_tenant_ids()))
6296
+ WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
6297
+
6298
+ DROP POLICY IF EXISTS plg_calendar_channels_service ON public.plg_calendar_channels;
6299
+ CREATE POLICY plg_calendar_channels_service ON public.plg_calendar_channels
6300
+ FOR ALL TO service_role USING (true) WITH CHECK (true);
6301
+
6302
+ -- ===========================================================================
6303
+ -- \xA72 \u2014 Outbound trigger with loop-prevention + correlation id.
6304
+ -- CREATE OR REPLACE of 001's fn_gcal_push_booking. Same pg_net \u2192 edge function
6305
+ -- payload (app.gcal_sync_url / app.gcal_service_key), but now:
6306
+ -- * SKIP the push when the write itself came from the Google sync, detected by
6307
+ -- the TRANSACTION-LOCAL GUC app.booking_origin = 'google' \u2014 set by every \xA74
6308
+ -- writer via set_config(..., true) and gone when the transaction ends.
6309
+ -- This is what stops the Google\u2192booking\u2192Google\u2192\u2026 echo loop.
6310
+ -- Deliberately NOT a persistent metadata check: a sticky marker
6311
+ -- (metadata.syncOrigin) would permanently mute pushes for imported events,
6312
+ -- so a later admin edit would never reach Google. metadata.syncOrigin is
6313
+ -- kept by \xA74 as provenance only \u2014 this trigger does not read it.
6314
+ -- * carry a correlation_id so both directions can be stitched in the sync log
6315
+ -- (from GUC app.booking_correlation_id, else a fresh uuid \u2014 design ported
6316
+ -- from PR #7).
6317
+ -- ===========================================================================
6318
+ CREATE OR REPLACE FUNCTION public.fn_gcal_push_booking() RETURNS trigger
6319
+ LANGUAGE plpgsql SECURITY DEFINER AS $$
6320
+ DECLARE
6321
+ url text := current_setting('app.gcal_sync_url', true);
6322
+ key text := current_setting('app.gcal_service_key', true);
6323
+ rec record := COALESCE(NEW, OLD);
6324
+ op text := lower(TG_OP);
6325
+ corr text := NULLIF(current_setting('app.booking_correlation_id', true), '');
6326
+ BEGIN
6327
+ -- Loop guard: never bounce a Google-originated write back to Google.
6328
+ -- Transaction-local GUC only (see \xA72 header for why not persistent metadata).
6329
+ IF current_setting('app.booking_origin', true) = 'google' THEN
6330
+ RETURN COALESCE(NEW, OLD);
6331
+ END IF;
6332
+
6333
+ IF url IS NULL OR url = '' THEN RETURN COALESCE(NEW, OLD); END IF;
6334
+
6335
+ PERFORM net.http_post(
6336
+ url := url,
6337
+ headers := jsonb_build_object('Content-Type', 'application/json',
6338
+ 'Authorization', 'Bearer ' || COALESCE(key, '')),
6339
+ body := jsonb_build_object(
6340
+ 'action', 'push_event',
6341
+ 'op', op,
6342
+ 'bookingId', rec.id,
6343
+ 'tenantId', rec.tenant_id,
6344
+ 'correlationId', COALESCE(corr, gen_random_uuid()::text)
6345
+ )
6346
+ );
6347
+ RETURN COALESCE(NEW, OLD);
6348
+ END $$;
6349
+
6350
+ -- Re-assert the trigger (idempotent; unchanged from 001).
6351
+ DROP TRIGGER IF EXISTS trg_gcal_push ON public.appointments;
6352
+ CREATE TRIGGER trg_gcal_push
6353
+ AFTER INSERT OR UPDATE OR DELETE ON public.appointments
6354
+ FOR EACH ROW EXECUTE FUNCTION public.fn_gcal_push_booking();
6355
+
6356
+ -- ===========================================================================
6357
+ -- \xA73 \u2014 sync log: correlate the two directions + reference the source channel.
6358
+ -- ===========================================================================
6359
+ ALTER TABLE public.plg_calendar_sync_log
6360
+ ADD COLUMN IF NOT EXISTS correlation_id uuid;
6361
+ ALTER TABLE public.plg_calendar_sync_log
6362
+ ADD COLUMN IF NOT EXISTS channel_id_ref uuid
6363
+ REFERENCES public.plg_calendar_channels(id) ON DELETE SET NULL;
6364
+ -- Unlinked future events seen during a pull (import-wizard candidates), so the
6365
+ -- log can show "N eventos aguardando import" without re-hitting Google.
6366
+ ALTER TABLE public.plg_calendar_sync_log
6367
+ ADD COLUMN IF NOT EXISTS discovered integer;
6368
+
6369
+ -- ===========================================================================
6370
+ -- \xA74 \u2014 Inbound write path (Google \u2192 booking). THE single entry point used by
6371
+ -- the edge function (scheduled pull, webhook, and the import wizard). All three
6372
+ -- functions are SECURITY DEFINER with SET search_path = public and are granted
6373
+ -- to service_role ONLY (anon/authenticated explicitly revoked) \u2014 they bypass the
6374
+ -- tenant RLS on purpose and must never be reachable from the anon/authenticated
6375
+ -- PostgREST surface. Every writer sets the TRANSACTION-LOCAL GUC
6376
+ -- app.booking_origin='google' (set_config ..., true) so \xA72's trigger suppresses
6377
+ -- the echo for THIS write only; metadata.syncOrigin is provenance, not a guard.
6378
+ -- ===========================================================================
6379
+
6380
+ -- gcal_import_event: create (or idempotently update) a booking from a Google
6381
+ -- event, honouring the channel's import_mode + target mapping.
6382
+ -- kind = 'block' | 'appointment' (from channel.import_mode)
6383
+ -- assignee_id = channel.target_id when target_kind='assignee', else NULL
6384
+ -- (NULL = tenant-wide \u2192 blocks every professional, see \xA75)
6385
+ -- location_id = channel.target_id when target_kind='location', else NULL
6386
+ -- status = 'scheduled'
6387
+ -- No order is created \u2014 an external import is not a sale. Idempotent on
6388
+ -- (tenant_id, metadata.googleCalendarEventId): update in place if seen before.
6389
+ CREATE OR REPLACE FUNCTION public.gcal_import_event(
6390
+ p_tenant_id uuid,
6391
+ p_channel_id uuid,
6392
+ p_event_id text,
6393
+ p_etag text,
6394
+ p_summary text,
6395
+ p_starts_at timestamptz,
6396
+ p_ends_at timestamptz,
6397
+ p_all_day boolean,
6398
+ p_description text DEFAULT NULL
6399
+ ) RETURNS uuid
6400
+ LANGUAGE plpgsql SECURITY DEFINER
6401
+ SET search_path = public
6402
+ AS $$
6403
+ DECLARE
6404
+ v_ch record;
6405
+ v_kind text;
6406
+ v_assignee uuid;
6407
+ v_location uuid;
6408
+ v_ends timestamptz;
6409
+ v_meta jsonb;
6410
+ v_existing uuid;
6411
+ v_appt uuid;
6412
+ BEGIN
6413
+ IF p_tenant_id IS NULL OR p_event_id IS NULL OR p_starts_at IS NULL THEN
6414
+ RAISE EXCEPTION 'gcal_import_event: tenant, event id and start are required';
6415
+ END IF;
6416
+
6417
+ -- Transaction-local: \xA72's trigger skips the echo for this write only.
6418
+ PERFORM set_config('app.booking_origin', 'google', true);
6419
+
6420
+ -- Resolve the channel mapping (must belong to the same tenant).
6421
+ SELECT c.import_mode, c.target_kind, c.target_id
6422
+ INTO v_ch
6423
+ FROM plg_calendar_channels c
6424
+ WHERE c.id = p_channel_id AND c.tenant_id = p_tenant_id;
6425
+ IF NOT FOUND THEN RAISE EXCEPTION 'gcal_import_event: unknown channel for tenant'; END IF;
6426
+
6427
+ v_kind := CASE WHEN v_ch.import_mode = 'appointment' THEN 'appointment' ELSE 'block' END;
6428
+ v_assignee := CASE WHEN v_ch.target_kind = 'assignee' THEN v_ch.target_id ELSE NULL END;
6429
+ v_location := CASE WHEN v_ch.target_kind = 'location' THEN v_ch.target_id ELSE NULL END;
6430
+
6431
+ -- All-day events without an explicit end block the whole day so \xA75 excludes it.
6432
+ v_ends := COALESCE(
6433
+ p_ends_at,
6434
+ CASE WHEN COALESCE(p_all_day, false) THEN p_starts_at + interval '1 day' ELSE p_starts_at END
6435
+ );
6436
+
6437
+ v_meta := jsonb_build_object(
6438
+ 'syncOrigin', 'google',
6439
+ 'googleCalendarEventId', p_event_id,
6440
+ 'gcalEtag', p_etag,
6441
+ 'gcalChannelId', p_channel_id::text,
6442
+ 'allDay', COALESCE(p_all_day, false),
6443
+ 'title', p_summary,
6444
+ 'source', 'google_import'
6445
+ );
6446
+
6447
+ -- Idempotency: has this event already been imported for the tenant?
6448
+ SELECT b.id INTO v_existing
6449
+ FROM appointments b
6450
+ WHERE b.tenant_id = p_tenant_id
6451
+ AND b.metadata->>'googleCalendarEventId' = p_event_id
6452
+ LIMIT 1;
6453
+
6454
+ IF v_existing IS NOT NULL THEN
6455
+ UPDATE appointments b
6456
+ SET kind = v_kind,
6457
+ assignee_id = v_assignee,
6458
+ location_id = v_location,
6459
+ starts_at = p_starts_at,
6460
+ ends_at = v_ends,
6461
+ status = 'scheduled',
6462
+ notes = COALESCE(p_description, p_summary),
6463
+ metadata = COALESCE(b.metadata, '{}'::jsonb) || v_meta,
6464
+ updated_at = now()
6465
+ WHERE b.id = v_existing
6466
+ RETURNING b.id INTO v_appt;
6467
+ ELSE
6468
+ INSERT INTO appointments (tenant_id, kind, assignee_id, location_id,
6469
+ starts_at, ends_at, status, notes, metadata)
6470
+ VALUES (p_tenant_id, v_kind, v_assignee, v_location,
6471
+ p_starts_at, v_ends, 'scheduled', COALESCE(p_description, p_summary), v_meta)
6472
+ RETURNING id INTO v_appt;
6473
+ END IF;
6474
+
6475
+ RETURN v_appt;
6476
+ END $$;
6477
+
6478
+ -- gcal_apply_event_patch: apply an inbound reschedule/cancel to an already-linked
6479
+ -- booking. Located by metadata.googleCalendarEventId. Echo suppression: if the
6480
+ -- stored gcalEtag already equals the incoming etag, this is our own write coming
6481
+ -- back \u2014 skip and return NULL. Every real write re-stamps syncOrigin='google'.
6482
+ CREATE OR REPLACE FUNCTION public.gcal_apply_event_patch(
6483
+ p_tenant_id uuid,
6484
+ p_event_id text,
6485
+ p_etag text,
6486
+ p_starts_at timestamptz,
6487
+ p_ends_at timestamptz,
6488
+ p_summary text,
6489
+ p_cancelled boolean
6490
+ ) RETURNS uuid
6491
+ LANGUAGE plpgsql SECURITY DEFINER
6492
+ SET search_path = public
6493
+ AS $$
6494
+ DECLARE
6495
+ v_appt uuid;
6496
+ v_curr_etag text;
6497
+ BEGIN
6498
+ IF p_tenant_id IS NULL OR p_event_id IS NULL THEN
6499
+ RAISE EXCEPTION 'gcal_apply_event_patch: tenant and event id are required';
6500
+ END IF;
6501
+
6502
+ -- Transaction-local: \xA72's trigger skips the echo for this write only.
6503
+ PERFORM set_config('app.booking_origin', 'google', true);
6504
+
6505
+ SELECT b.id, b.metadata->>'gcalEtag'
6506
+ INTO v_appt, v_curr_etag
6507
+ FROM appointments b
6508
+ WHERE b.tenant_id = p_tenant_id
6509
+ AND b.metadata->>'googleCalendarEventId' = p_event_id
6510
+ LIMIT 1;
6511
+
6512
+ IF v_appt IS NULL THEN RETURN NULL; END IF; -- not linked here
6513
+ IF p_etag IS NOT NULL AND p_etag = v_curr_etag THEN
6514
+ RETURN NULL; -- echo: nothing changed
6515
+ END IF;
6516
+
6517
+ IF COALESCE(p_cancelled, false) THEN
6518
+ UPDATE appointments b
6519
+ SET status = 'cancelled',
6520
+ metadata = COALESCE(b.metadata, '{}'::jsonb)
6521
+ || jsonb_build_object('syncOrigin', 'google', 'gcalEtag', p_etag),
6522
+ updated_at = now()
6523
+ WHERE b.id = v_appt;
6524
+ ELSE
6525
+ UPDATE appointments b
6526
+ SET starts_at = COALESCE(p_starts_at, b.starts_at),
6527
+ ends_at = COALESCE(p_ends_at, b.ends_at),
6528
+ notes = COALESCE(p_summary, b.notes),
6529
+ metadata = COALESCE(b.metadata, '{}'::jsonb)
6530
+ || jsonb_build_object('syncOrigin', 'google',
6531
+ 'gcalEtag', p_etag,
6532
+ 'title', COALESCE(p_summary, b.metadata->>'title')),
6533
+ updated_at = now()
6534
+ WHERE b.id = v_appt;
6535
+ END IF;
6536
+
6537
+ RETURN v_appt;
6538
+ END $$;
6539
+
6540
+ -- gcal_stamp_outbound: after the edge function creates the Google event for an
6541
+ -- outbound push, it stamps the fresh event id/etag back onto the appointment.
6542
+ -- Bookkeeping only \u2014 must not re-fire \xA72's trigger, hence the GUC. Does NOT set
6543
+ -- metadata.syncOrigin (the booking's provenance is still the app/site).
6544
+ CREATE OR REPLACE FUNCTION public.gcal_stamp_outbound(
6545
+ p_tenant_id uuid,
6546
+ p_booking_id uuid,
6547
+ p_event_id text,
6548
+ p_etag text DEFAULT NULL,
6549
+ p_calendar_id text DEFAULT NULL
6550
+ ) RETURNS uuid
6551
+ LANGUAGE plpgsql SECURITY DEFINER
6552
+ SET search_path = public
6553
+ AS $$
6554
+ DECLARE
6555
+ v_id uuid;
6556
+ BEGIN
6557
+ IF p_tenant_id IS NULL OR p_booking_id IS NULL OR p_event_id IS NULL THEN
6558
+ RAISE EXCEPTION 'gcal_stamp_outbound: tenant, booking and event id are required';
6559
+ END IF;
6560
+
6561
+ -- Transaction-local: \xA72's trigger skips the echo for this write only.
6562
+ PERFORM set_config('app.booking_origin', 'google', true);
6563
+
6564
+ UPDATE appointments b
6565
+ SET metadata = COALESCE(b.metadata, '{}'::jsonb)
6566
+ || jsonb_build_object('googleCalendarEventId', p_event_id,
6567
+ 'gcalEtag', p_etag,
6568
+ 'gcalSyncedAt', now())
6569
+ || CASE WHEN p_calendar_id IS NULL THEN '{}'::jsonb
6570
+ ELSE jsonb_build_object('gcalCalendarId', p_calendar_id) END,
6571
+ updated_at = now()
6572
+ WHERE b.id = p_booking_id AND b.tenant_id = p_tenant_id
6573
+ RETURNING b.id INTO v_id;
6574
+ RETURN v_id;
6575
+ END $$;
6576
+
6577
+ -- gcal_unlink_channel: forget the incremental cursor + Google watch bookkeeping
6578
+ -- for a channel (used on disconnect / re-watch). Leaves the mapping row in place.
6579
+ CREATE OR REPLACE FUNCTION public.gcal_unlink_channel(
6580
+ p_channel_id uuid
6581
+ ) RETURNS uuid
6582
+ LANGUAGE plpgsql SECURITY DEFINER
6583
+ SET search_path = public
6584
+ AS $$
6585
+ DECLARE
6586
+ v_id uuid;
6587
+ BEGIN
6588
+ UPDATE plg_calendar_channels c
6589
+ SET sync_token = NULL,
6590
+ channel_id = NULL,
6591
+ resource_id = NULL,
6592
+ channel_expires_at = NULL,
6593
+ updated_at = now()
6594
+ WHERE c.id = p_channel_id
6595
+ RETURNING c.id INTO v_id;
6596
+ RETURN v_id;
6597
+ END $$;
6598
+
6599
+ -- Lock the inbound writers to service_role only.
6600
+ DO $$
6601
+ DECLARE fn text;
6602
+ BEGIN
6603
+ FOR fn IN SELECT unnest(ARRAY[
6604
+ 'public.gcal_import_event(uuid, uuid, text, text, text, timestamptz, timestamptz, boolean, text)',
6605
+ 'public.gcal_apply_event_patch(uuid, text, text, timestamptz, timestamptz, text, boolean)',
6606
+ 'public.gcal_stamp_outbound(uuid, uuid, text, text, text)',
6607
+ 'public.gcal_unlink_channel(uuid)'
6608
+ ])
6609
+ LOOP
6610
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM public, anon, authenticated', fn);
6611
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO service_role', fn);
6612
+ END LOOP;
6613
+ END $$;
6614
+
6615
+ -- ===========================================================================
6616
+ -- \xA75 \u2014 get_available_slots: imported Google blocks must remove availability.
6617
+ -- CREATE OR REPLACE keeping the EXACT signature (anon + admin both call it, and
6618
+ -- it is graded on the same shape as plugin-agenda/src/migrations/001_public_booking.sql).
6619
+ --
6620
+ -- Conceptual diff vs 001 \u2014 ONE condition in the overlap NOT EXISTS:
6621
+ -- before: AND b.assignee_id = c.assignee_id
6622
+ -- after: AND (b.assignee_id = c.assignee_id
6623
+ -- OR (b.assignee_id IS NULL AND b.kind = 'block'))
6624
+ -- Rationale: a tenant-wide BLOCK imported from Google (target_kind NULL \u2192
6625
+ -- assignee_id IS NULL, kind 'block', see \xA74) must busy-out the slot for EVERY
6626
+ -- professional. Restricted to kind='block' so an unassigned admin appointment
6627
+ -- keeps the 001 behaviour (blocks nobody). Per-assignee rows keep matching on
6628
+ -- assignee_id.
6629
+ -- Everything else is byte-identical to 001: the status filter already covers
6630
+ -- "any non-cancelled kind" (no_show also excluded), and the p_assignee_id-NULL
6631
+ -- fan-out across staff via day_schedules is preserved untouched.
6632
+ -- ===========================================================================
6633
+ CREATE OR REPLACE FUNCTION public.get_available_slots(
6634
+ p_tenant_id uuid,
6635
+ p_date date,
6636
+ p_duration_minutes integer,
6637
+ p_assignee_id uuid DEFAULT NULL,
6638
+ p_slot_interval integer DEFAULT 30
6639
+ ) RETURNS TABLE (slot_start timestamptz, slot_end timestamptz)
6640
+ LANGUAGE plpgsql STABLE SECURITY DEFINER
6641
+ SET search_path = public
6642
+ AS $$
6643
+ DECLARE
6644
+ v_tz text;
6645
+ BEGIN
6646
+ -- tight validation: this function is anon-callable
6647
+ IF p_duration_minutes IS NULL OR p_duration_minutes NOT BETWEEN 5 AND 480 THEN
6648
+ RAISE EXCEPTION 'invalid duration';
6649
+ END IF;
6650
+ IF p_slot_interval IS NULL OR p_slot_interval NOT BETWEEN 5 AND 240 THEN
6651
+ RAISE EXCEPTION 'invalid interval';
6652
+ END IF;
6653
+ IF p_date IS NULL OR p_date < current_date OR p_date > current_date + 90 THEN
6654
+ RETURN;
6655
+ END IF;
6656
+
6657
+ SELECT COALESCE(t.settings->>'timezone', 'America/Sao_Paulo') INTO v_tz
6658
+ FROM tenants t WHERE t.id = p_tenant_id;
6659
+ IF v_tz IS NULL THEN RETURN; END IF; -- unknown tenant \u2192 empty, not an error
6660
+
6661
+ RETURN QUERY
6662
+ WITH day_schedules AS (
6663
+ SELECT sc.assignee_id, sc.starts_at AS win_start, sc.ends_at AS win_end
6664
+ FROM schedules sc
6665
+ WHERE sc.tenant_id = p_tenant_id
6666
+ AND sc.kind = 'working_hours'
6667
+ AND sc.is_active
6668
+ AND (p_assignee_id IS NULL OR sc.assignee_id = p_assignee_id)
6669
+ AND (
6670
+ sc.specific_date = p_date
6671
+ OR (sc.specific_date IS NULL AND sc.day_of_week = EXTRACT(dow FROM p_date)::smallint)
6672
+ )
6673
+ ),
6674
+ candidate AS (
6675
+ SELECT
6676
+ ds.assignee_id,
6677
+ gs AS s_start,
6678
+ gs + make_interval(mins => p_duration_minutes) AS s_end
6679
+ FROM day_schedules ds
6680
+ CROSS JOIN LATERAL generate_series(
6681
+ (p_date + ds.win_start) AT TIME ZONE v_tz,
6682
+ ((p_date + ds.win_end) AT TIME ZONE v_tz) - make_interval(mins => p_duration_minutes),
6683
+ make_interval(mins => p_slot_interval)
6684
+ ) AS gs
6685
+ )
6686
+ SELECT DISTINCT c.s_start, c.s_end
6687
+ FROM candidate c
6688
+ WHERE NOT EXISTS (
6689
+ SELECT 1 FROM appointments b
6690
+ WHERE b.tenant_id = p_tenant_id
6691
+ -- SMART SYNC: match this professional's own bookings OR any tenant-wide
6692
+ -- BLOCK (assignee_id IS NULL AND kind='block', e.g. imported from Google
6693
+ -- with a whole-tenant channel) \u2014 the latter busies the slot for every
6694
+ -- professional. Assignee-less rows of other kinds (an unassigned admin
6695
+ -- appointment) keep the 001 behaviour and do not block anyone.
6696
+ AND (b.assignee_id = c.assignee_id
6697
+ OR (b.assignee_id IS NULL AND b.kind = 'block'))
6698
+ AND b.status NOT IN ('cancelled', 'no_show')
6699
+ AND b.starts_at < c.s_end
6700
+ AND COALESCE(b.ends_at, b.starts_at) > c.s_start
6701
+ )
6702
+ ORDER BY 1;
6703
+ END;
6704
+ $$;
6705
+
6706
+ REVOKE ALL ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer) FROM public;
6707
+ GRANT EXECUTE ON FUNCTION public.get_available_slots(uuid, date, integer, uuid, integer)
6708
+ TO anon, authenticated, service_role;
6709
+
6710
+ -- ===========================================================================
6711
+ -- \xA76 \u2014 Inbound schedule (optional) \u2014 per-channel incremental pull.
6712
+ -- Supersedes the single-calendar template in 001. Enable once per project after
6713
+ -- setting app.gcal_sync_url / app.gcal_service_key (see 001 header). The edge
6714
+ -- function fans out over active channels, using each channel's sync_token cursor:
6715
+ --
6716
+ -- SELECT cron.schedule('gcal-pull', '*/10 * * * *', $CRON$
6717
+ -- SELECT net.http_post(
6718
+ -- url := current_setting('app.gcal_sync_url'),
6719
+ -- headers := jsonb_build_object(
6720
+ -- 'Content-Type','application/json',
6721
+ -- 'Authorization','Bearer '||current_setting('app.gcal_service_key')),
6722
+ -- body := jsonb_build_object('action','pull_events'))
6723
+ -- $CRON$);
6724
+ --
6725
+ -- Or drive one row per channel from SQL (tighter scoping / per-channel cadence):
6726
+ --
6727
+ -- SELECT cron.schedule('gcal-pull-'||ch.id::text, '*/10 * * * *', format($CRON$
6728
+ -- SELECT net.http_post(
6729
+ -- url := current_setting('app.gcal_sync_url'),
6730
+ -- headers := jsonb_build_object(
6731
+ -- 'Content-Type','application/json',
6732
+ -- 'Authorization','Bearer '||current_setting('app.gcal_service_key')),
6733
+ -- body := jsonb_build_object('action','pull_events','channelId',%L))
6734
+ -- $CRON$, ch.id))
6735
+ -- FROM public.plg_calendar_channels ch
6736
+ -- WHERE ch.is_active AND ch.direction IN ('inbound','bidirectional');
6737
+ -- ===========================================================================
6738
+ `;
6739
+ var MIGRATION_003_PLG_RENAME = `-- ---------------------------------------------------------------------------
6740
+ -- 003_plg_rename.sql \u2014 standardize the Google Calendar connector objects onto
6741
+ -- the platform \`plg_<plugin>_\` prefix (DATA-MODEL.md Ring 1).
6742
+ --
6743
+ -- calendar_integrations \u2192 plg_calendar_integrations
6744
+ -- calendar_channels \u2192 plg_calendar_channels
6745
+ -- calendar_sync_log \u2192 plg_calendar_sync_log
6746
+ --
6747
+ -- Legacy-pool remediation ONLY: 001/002 now create the prefixed names directly,
6748
+ -- so fresh installs never reach a rename branch. Fully idempotent and guarded \u2014
6749
+ -- every RENAME fires only when the old object exists and the new one does not,
6750
+ -- so re-running (or running on an already-prefixed pool) is a no-op.
6751
+ --
6752
+ -- A table RENAME preserves its data, RLS state, policies, grants, indexes,
6753
+ -- constraints and triggers (they follow the table), so \xA71 only renames the
6754
+ -- table + the explicitly-named indexes. \xA72 then re-points the SECURITY DEFINER
6755
+ -- gcal_* functions whose bodies hard-code the old table names.
6756
+ -- ---------------------------------------------------------------------------
6757
+
6758
+ -- ===========================================================================
6759
+ -- \xA71 \u2014 Guarded table + index renames.
6760
+ -- ===========================================================================
6761
+ DO $$
6762
+ BEGIN
6763
+ IF to_regclass('public.calendar_integrations') IS NOT NULL
6764
+ AND to_regclass('public.plg_calendar_integrations') IS NULL THEN
6765
+ ALTER TABLE public.calendar_integrations RENAME TO plg_calendar_integrations;
6766
+ END IF;
6767
+
6768
+ IF to_regclass('public.calendar_channels') IS NOT NULL
6769
+ AND to_regclass('public.plg_calendar_channels') IS NULL THEN
6770
+ ALTER TABLE public.calendar_channels RENAME TO plg_calendar_channels;
6771
+ END IF;
6772
+
6773
+ IF to_regclass('public.calendar_sync_log') IS NOT NULL
6774
+ AND to_regclass('public.plg_calendar_sync_log') IS NULL THEN
6775
+ ALTER TABLE public.calendar_sync_log RENAME TO plg_calendar_sync_log;
6776
+ END IF;
6777
+
6778
+ -- Named indexes do not follow a table rename automatically.
6779
+ IF to_regclass('public.idx_calendar_integrations_tenant') IS NOT NULL
6780
+ AND to_regclass('public.idx_plg_calendar_integrations_tenant') IS NULL THEN
6781
+ ALTER INDEX public.idx_calendar_integrations_tenant RENAME TO idx_plg_calendar_integrations_tenant;
6782
+ END IF;
6783
+
6784
+ IF to_regclass('public.idx_calendar_channels_tenant') IS NOT NULL
6785
+ AND to_regclass('public.idx_plg_calendar_channels_tenant') IS NULL THEN
6786
+ ALTER INDEX public.idx_calendar_channels_tenant RENAME TO idx_plg_calendar_channels_tenant;
6787
+ END IF;
6788
+
6789
+ IF to_regclass('public.idx_calendar_channels_integration') IS NOT NULL
6790
+ AND to_regclass('public.idx_plg_calendar_channels_integration') IS NULL THEN
6791
+ ALTER INDEX public.idx_calendar_channels_integration RENAME TO idx_plg_calendar_channels_integration;
6792
+ END IF;
6793
+ END $$;
6794
+
6795
+ -- ===========================================================================
6796
+ -- \xA72 \u2014 Re-point the inbound SECURITY DEFINER writers at the prefixed names.
6797
+ -- These bodies are byte-identical to 002_smart_sync.sql except the calendar_*
6798
+ -- table references, which now read plg_calendar_*. gcal_apply_event_patch and
6799
+ -- gcal_stamp_outbound only touch public.appointments (unaffected by the rename)
6800
+ -- and are re-asserted here for completeness so all four writers stay in step.
6801
+ -- ===========================================================================
6802
+
6803
+ CREATE OR REPLACE FUNCTION public.gcal_import_event(
6804
+ p_tenant_id uuid,
6805
+ p_channel_id uuid,
6806
+ p_event_id text,
6807
+ p_etag text,
6808
+ p_summary text,
6809
+ p_starts_at timestamptz,
6810
+ p_ends_at timestamptz,
6811
+ p_all_day boolean,
6812
+ p_description text DEFAULT NULL
6813
+ ) RETURNS uuid
6814
+ LANGUAGE plpgsql SECURITY DEFINER
6815
+ SET search_path = public
6816
+ AS $$
6817
+ DECLARE
6818
+ v_ch record;
6819
+ v_kind text;
6820
+ v_assignee uuid;
6821
+ v_location uuid;
6822
+ v_ends timestamptz;
6823
+ v_meta jsonb;
6824
+ v_existing uuid;
6825
+ v_appt uuid;
6826
+ BEGIN
6827
+ IF p_tenant_id IS NULL OR p_event_id IS NULL OR p_starts_at IS NULL THEN
6828
+ RAISE EXCEPTION 'gcal_import_event: tenant, event id and start are required';
6829
+ END IF;
6830
+
6831
+ PERFORM set_config('app.booking_origin', 'google', true);
6832
+
6833
+ SELECT c.import_mode, c.target_kind, c.target_id
6834
+ INTO v_ch
6835
+ FROM plg_calendar_channels c
6836
+ WHERE c.id = p_channel_id AND c.tenant_id = p_tenant_id;
6837
+ IF NOT FOUND THEN RAISE EXCEPTION 'gcal_import_event: unknown channel for tenant'; END IF;
6838
+
6839
+ v_kind := CASE WHEN v_ch.import_mode = 'appointment' THEN 'appointment' ELSE 'block' END;
6840
+ v_assignee := CASE WHEN v_ch.target_kind = 'assignee' THEN v_ch.target_id ELSE NULL END;
6841
+ v_location := CASE WHEN v_ch.target_kind = 'location' THEN v_ch.target_id ELSE NULL END;
6842
+
6843
+ v_ends := COALESCE(
6844
+ p_ends_at,
6845
+ CASE WHEN COALESCE(p_all_day, false) THEN p_starts_at + interval '1 day' ELSE p_starts_at END
6846
+ );
6847
+
6848
+ v_meta := jsonb_build_object(
6849
+ 'syncOrigin', 'google',
6850
+ 'googleCalendarEventId', p_event_id,
6851
+ 'gcalEtag', p_etag,
6852
+ 'gcalChannelId', p_channel_id::text,
6853
+ 'allDay', COALESCE(p_all_day, false),
6854
+ 'title', p_summary,
6855
+ 'source', 'google_import'
6856
+ );
6857
+
6858
+ SELECT b.id INTO v_existing
6859
+ FROM appointments b
6860
+ WHERE b.tenant_id = p_tenant_id
6861
+ AND b.metadata->>'googleCalendarEventId' = p_event_id
6862
+ LIMIT 1;
6863
+
6864
+ IF v_existing IS NOT NULL THEN
6865
+ UPDATE appointments b
6866
+ SET kind = v_kind,
6867
+ assignee_id = v_assignee,
6868
+ location_id = v_location,
6869
+ starts_at = p_starts_at,
6870
+ ends_at = v_ends,
6871
+ status = 'scheduled',
6872
+ notes = COALESCE(p_description, p_summary),
6873
+ metadata = COALESCE(b.metadata, '{}'::jsonb) || v_meta,
6874
+ updated_at = now()
6875
+ WHERE b.id = v_existing
6876
+ RETURNING b.id INTO v_appt;
6877
+ ELSE
6878
+ INSERT INTO appointments (tenant_id, kind, assignee_id, location_id,
6879
+ starts_at, ends_at, status, notes, metadata)
6880
+ VALUES (p_tenant_id, v_kind, v_assignee, v_location,
6881
+ p_starts_at, v_ends, 'scheduled', COALESCE(p_description, p_summary), v_meta)
6882
+ RETURNING id INTO v_appt;
6883
+ END IF;
6884
+
6885
+ RETURN v_appt;
6886
+ END $$;
6887
+
6888
+ CREATE OR REPLACE FUNCTION public.gcal_apply_event_patch(
6889
+ p_tenant_id uuid,
6890
+ p_event_id text,
6891
+ p_etag text,
6892
+ p_starts_at timestamptz,
6893
+ p_ends_at timestamptz,
6894
+ p_summary text,
6895
+ p_cancelled boolean
6896
+ ) RETURNS uuid
6897
+ LANGUAGE plpgsql SECURITY DEFINER
6898
+ SET search_path = public
6899
+ AS $$
6900
+ DECLARE
6901
+ v_appt uuid;
6902
+ v_curr_etag text;
6903
+ BEGIN
6904
+ IF p_tenant_id IS NULL OR p_event_id IS NULL THEN
6905
+ RAISE EXCEPTION 'gcal_apply_event_patch: tenant and event id are required';
6906
+ END IF;
6907
+
6908
+ PERFORM set_config('app.booking_origin', 'google', true);
6909
+
6910
+ SELECT b.id, b.metadata->>'gcalEtag'
6911
+ INTO v_appt, v_curr_etag
6912
+ FROM appointments b
6913
+ WHERE b.tenant_id = p_tenant_id
6914
+ AND b.metadata->>'googleCalendarEventId' = p_event_id
6915
+ LIMIT 1;
6916
+
6917
+ IF v_appt IS NULL THEN RETURN NULL; END IF;
6918
+ IF p_etag IS NOT NULL AND p_etag = v_curr_etag THEN
6919
+ RETURN NULL;
6920
+ END IF;
6921
+
6922
+ IF COALESCE(p_cancelled, false) THEN
6923
+ UPDATE appointments b
6924
+ SET status = 'cancelled',
6925
+ metadata = COALESCE(b.metadata, '{}'::jsonb)
6926
+ || jsonb_build_object('syncOrigin', 'google', 'gcalEtag', p_etag),
6927
+ updated_at = now()
6928
+ WHERE b.id = v_appt;
6929
+ ELSE
6930
+ UPDATE appointments b
6931
+ SET starts_at = COALESCE(p_starts_at, b.starts_at),
6932
+ ends_at = COALESCE(p_ends_at, b.ends_at),
6933
+ notes = COALESCE(p_summary, b.notes),
6934
+ metadata = COALESCE(b.metadata, '{}'::jsonb)
6935
+ || jsonb_build_object('syncOrigin', 'google',
6936
+ 'gcalEtag', p_etag,
6937
+ 'title', COALESCE(p_summary, b.metadata->>'title')),
6938
+ updated_at = now()
6939
+ WHERE b.id = v_appt;
6940
+ END IF;
6941
+
6942
+ RETURN v_appt;
6943
+ END $$;
6944
+
6945
+ CREATE OR REPLACE FUNCTION public.gcal_stamp_outbound(
6946
+ p_tenant_id uuid,
6947
+ p_booking_id uuid,
6948
+ p_event_id text,
6949
+ p_etag text DEFAULT NULL,
6950
+ p_calendar_id text DEFAULT NULL
6951
+ ) RETURNS uuid
6952
+ LANGUAGE plpgsql SECURITY DEFINER
6953
+ SET search_path = public
6954
+ AS $$
6955
+ DECLARE
6956
+ v_id uuid;
6957
+ BEGIN
6958
+ IF p_tenant_id IS NULL OR p_booking_id IS NULL OR p_event_id IS NULL THEN
6959
+ RAISE EXCEPTION 'gcal_stamp_outbound: tenant, booking and event id are required';
6960
+ END IF;
6961
+
6962
+ PERFORM set_config('app.booking_origin', 'google', true);
6963
+
6964
+ UPDATE appointments b
6965
+ SET metadata = COALESCE(b.metadata, '{}'::jsonb)
6966
+ || jsonb_build_object('googleCalendarEventId', p_event_id,
6967
+ 'gcalEtag', p_etag,
6968
+ 'gcalSyncedAt', now())
6969
+ || CASE WHEN p_calendar_id IS NULL THEN '{}'::jsonb
6970
+ ELSE jsonb_build_object('gcalCalendarId', p_calendar_id) END,
6971
+ updated_at = now()
6972
+ WHERE b.id = p_booking_id AND b.tenant_id = p_tenant_id
6973
+ RETURNING b.id INTO v_id;
6974
+ RETURN v_id;
6975
+ END $$;
6976
+
6977
+ CREATE OR REPLACE FUNCTION public.gcal_unlink_channel(
6978
+ p_channel_id uuid
6979
+ ) RETURNS uuid
6980
+ LANGUAGE plpgsql SECURITY DEFINER
6981
+ SET search_path = public
6982
+ AS $$
6983
+ DECLARE
6984
+ v_id uuid;
6985
+ BEGIN
6986
+ UPDATE plg_calendar_channels c
6987
+ SET sync_token = NULL,
6988
+ channel_id = NULL,
6989
+ resource_id = NULL,
6990
+ channel_expires_at = NULL,
6991
+ updated_at = now()
6992
+ WHERE c.id = p_channel_id
6993
+ RETURNING c.id INTO v_id;
6994
+ RETURN v_id;
6995
+ END $$;
6996
+
6997
+ -- Re-assert the service_role-only lock (idempotent; matches 002 \xA74).
6998
+ DO $$
6999
+ DECLARE fn text;
7000
+ BEGIN
7001
+ FOR fn IN SELECT unnest(ARRAY[
7002
+ 'public.gcal_import_event(uuid, uuid, text, text, text, timestamptz, timestamptz, boolean, text)',
7003
+ 'public.gcal_apply_event_patch(uuid, text, text, timestamptz, timestamptz, text, boolean)',
7004
+ 'public.gcal_stamp_outbound(uuid, uuid, text, text, text)',
7005
+ 'public.gcal_unlink_channel(uuid)'
7006
+ ])
7007
+ LOOP
7008
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM public, anon, authenticated', fn);
7009
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO service_role', fn);
7010
+ END LOOP;
7011
+ END $$;
7012
+ `;
7013
+ var MIGRATION_004_DROP_OAUTH_TOKENS = `-- ============================================================================
7014
+ -- Google Calendar 004: the credential leaves the tenant database, and the
7015
+ -- outbound trigger stops being able to break a booking write.
7016
+ --
7017
+ -- \xA71 credential drop oauth_refresh_token + oauth_access_token
7018
+ -- \xA72 outbound anti-loop, safe no-op, delete context, correlation id
7019
+ -- \xA73 realtime publish public.appointments so the calendar repaints
7020
+ --
7021
+ -- Additive and idempotent: no table is created, no policy is rewritten, and the
7022
+ -- tenant RLS from 001 already covers everything touched here.
7023
+ -- ============================================================================
7024
+
7025
+ -- \xA71 \u2500\u2500 credential \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7026
+ -- THE EXPOSURE THIS CLOSES: 001 stored \`oauth_refresh_token text\` and
7027
+ -- \`oauth_access_token text\` on public.plg_calendar_integrations. That table is
7028
+ -- read from the browser with the publishable key under tenant RLS \u2014 and the
7029
+ -- connector read it with \`select('*')\` \u2014 so a Google refresh token was readable
7030
+ -- by ANY authenticated member of the tenant, reception included. A refresh token
7031
+ -- is not a session: it is durable access to the owner's entire calendar, mail-
7032
+ -- adjacent metadata and every calendar shared with them. And a pool is one
7033
+ -- Supabase project serving N tenants, so anything running there with
7034
+ -- service_role read every clinic's Google grant at once.
7035
+ --
7036
+ -- This is the third connector to make the same move, and the worst of the three.
7037
+ -- RankLayer had nothing to replace its key with (it pushes IN and authenticates
7038
+ -- against the platform); PlugBank's credential is meant to move to the platform
7039
+ -- (FAY-1360 \u2014 as of 2026-08-12 its api_token is still a plaintext pool column).
7040
+ -- Google Calendar is PlugBank's case: the grant still has to exist, it just must
7041
+ -- not exist HERE. The destination is the platform's ConnectorConnection store
7042
+ -- (encrypted; exists, unused), with the sync function redeeming per call \u2014 that
7043
+ -- is FAY-1359, and it has NOT happened.
7044
+ --
7045
+ -- \u26A0 REPO AHEAD OF POOL (verified 2026-08-12, FAY-1379): this file has NOT been
7046
+ -- applied to the live salon pool. plg_calendar_integrations there still carries
7047
+ -- both token columns with a real clinic's grant, and the DEPLOYED
7048
+ -- google-calendar-sync (v16) still reads and writes them. Applying this drop
7049
+ -- before FAY-1359's ordered rollout (function reads both homes \u2192 data migrated
7050
+ -- \u2192 function reads only the new home \u2192 drop) severs the clinic's sync. Do not
7051
+ -- \`db apply\` this to a pool outside that sequence.
7052
+ --
7053
+ -- \`token_expires_at\` deliberately stays. It is a timestamp, not a credential,
7054
+ -- and it is the only non-secret signal that a grant exists \u2014 which is what the
7055
+ -- browser needs in order to draw a connected/disconnected badge without ever
7056
+ -- being handed the thing it is reporting on. See ../credentials.ts.
7057
+ --
7058
+ -- ROTATE any token a pool already stored. Nothing is live on this connector yet,
7059
+ -- but DROP COLUMN is a catalog change: the old values stay in the heap until the
7060
+ -- next table rewrite, so a grant that ever reached a pool must be revoked at
7061
+ -- Google rather than assumed gone.
7062
+
7063
+ DO $$
7064
+ BEGIN
7065
+ IF to_regclass('public.plg_calendar_integrations') IS NOT NULL THEN
7066
+ ALTER TABLE public.plg_calendar_integrations DROP COLUMN IF EXISTS oauth_refresh_token;
7067
+ ALTER TABLE public.plg_calendar_integrations DROP COLUMN IF EXISTS oauth_access_token;
7068
+ END IF;
7069
+ -- Pools converted before 003 renamed the tables carry the legacy name.
7070
+ IF to_regclass('public.calendar_integrations') IS NOT NULL THEN
7071
+ ALTER TABLE public.calendar_integrations DROP COLUMN IF EXISTS oauth_refresh_token;
7072
+ ALTER TABLE public.calendar_integrations DROP COLUMN IF EXISTS oauth_access_token;
7073
+ END IF;
7074
+ END $$;
7075
+
7076
+ -- \xA72 \u2500\u2500 outbound trigger \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7077
+ -- Three defects in 001's fn_gcal_push_booking, in order of how badly they hurt:
7078
+ --
7079
+ -- 1. NO ANTI-LOOP. An inbound Google event is written to public.appointments by
7080
+ -- the sync function, which fires this trigger, which pushes the same change
7081
+ -- back to Google, which changes the event, which the next pull reads as new.
7082
+ -- The symptom a customer reports is an appointment that will not stop moving.
7083
+ -- The sync function sets \`app.booking_origin = 'google'\` for the duration of
7084
+ -- its transaction; a write carrying that marker is never echoed back.
7085
+ --
7086
+ -- 2. A MISSING PREREQUISITE COULD BREAK THE BOOKING. 001 returned early on an
7087
+ -- empty URL but not on an empty key, so a half-configured pool sent an
7088
+ -- unauthenticated pg_net request; and any error raised in here aborts the
7089
+ -- INSERT that fired it. An integration that is not set up must degrade to
7090
+ -- "Google is not updated", never to "the appointment could not be saved".
7091
+ -- Every exit path below returns the row.
7092
+ --
7093
+ -- 3. A DELETE ARRIVED WITH NOTHING TO DELETE. AFTER DELETE can still read OLD,
7094
+ -- but pg_net delivers asynchronously \u2014 by the time the function runs, the row
7095
+ -- is gone and it cannot look up which Google event to remove. The linked ids
7096
+ -- now travel in the payload.
7097
+ --
7098
+ -- The early returns are not enough on their own: on a pool where the settings
7099
+ -- are present but pg_net is not installed, the PERFORM itself raises \`schema
7100
+ -- "net" does not exist\` and takes the INSERT down with it. So the delivery is
7101
+ -- wrapped too. Swallowing a delivery error is safe here because pg_net only
7102
+ -- ENQUEUES \u2014 anything it could tell us is already visible as "nothing has
7103
+ -- synced" in plg_calendar_sync_log and in the connector's derived health.
7104
+ --
7105
+ -- The Supabase service-role key still reaches this trigger through the
7106
+ -- \`app.gcal_service_key\` database setting that 001 introduced. That is a lesser
7107
+ -- exposure than the one closed in \xA71 (a GUC is not reachable through PostgREST,
7108
+ -- so no browser can read it) but it is still a pool-wide credential in the
7109
+ -- database, and it is NOT fixed here. FAY-1372 shipped the RLS half (a
7110
+ -- tenant-scoped claim narrows user_tenant_ids); nothing mints such a token for
7111
+ -- this trigger yet, and the function still runs on service_role (FAY-1378).
7112
+ -- Left as a named debt rather than moved to a third hiding place.
7113
+
7114
+ CREATE OR REPLACE FUNCTION public.fn_gcal_push_booking() RETURNS trigger
7115
+ LANGUAGE plpgsql
7116
+ SECURITY DEFINER
7117
+ SET search_path = public, extensions, net
7118
+ AS $$
7119
+ DECLARE
7120
+ url text := current_setting('app.gcal_sync_url', true);
7121
+ key text := current_setting('app.gcal_service_key', true);
7122
+ rec record := COALESCE(NEW, OLD);
7123
+ op text := lower(TG_OP);
7124
+ corr text := NULLIF(current_setting('app.booking_correlation_id', true), '');
7125
+ BEGIN
7126
+ -- (1) Inbound Google writes mark themselves. Never echo them back.
7127
+ IF current_setting('app.booking_origin', true) = 'google' THEN
7128
+ RETURN COALESCE(NEW, OLD);
7129
+ END IF;
7130
+
7131
+ -- (2) Not configured is not an error. Diagnostics report the connector as
7132
+ -- incomplete; the appointment is saved either way.
7133
+ IF NULLIF(url, '') IS NULL OR NULLIF(key, '') IS NULL THEN
7134
+ RETURN COALESCE(NEW, OLD);
7135
+ END IF;
7136
+
7137
+ BEGIN
7138
+ PERFORM net.http_post(
7139
+ url := url,
7140
+ headers := jsonb_build_object(
7141
+ 'Content-Type', 'application/json',
7142
+ 'Authorization', 'Bearer ' || key
7143
+ ),
7144
+ body := jsonb_build_object(
7145
+ 'action', 'push_event',
7146
+ 'op', op,
7147
+ 'bookingId', rec.id,
7148
+ 'tenantId', rec.tenant_id,
7149
+ -- (3) Carried so a delete can still be honoured after the row is gone.
7150
+ 'googleEventId', rec.metadata->>'googleCalendarEventId',
7151
+ 'calendarId', rec.metadata->>'gcalCalendarId',
7152
+ 'correlationId', COALESCE(corr, gen_random_uuid()::text)
7153
+ )
7154
+ );
7155
+ EXCEPTION WHEN OTHERS THEN
7156
+ -- Missing pg_net, a bad URL, a full queue: the appointment is still saved.
7157
+ RAISE WARNING 'gcal push skipped for booking %: %', rec.id, SQLERRM;
7158
+ END;
7159
+
7160
+ RETURN COALESCE(NEW, OLD);
7161
+ END $$;
7162
+
7163
+ -- \xA73 \u2500\u2500 realtime \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7164
+ -- The calendar screen subscribes to public.appointments so a booking written by
7165
+ -- the sync function repaints the grid the manager is looking at, instead of
7166
+ -- waiting for them to reload the tab. Additive and guarded: a pool without the
7167
+ -- supabase_realtime publication is left alone, and re-running adds nothing.
7168
+ DO $$
7169
+ BEGIN
7170
+ IF EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'supabase_realtime')
7171
+ AND NOT EXISTS (
7172
+ SELECT 1 FROM pg_publication_tables
7173
+ WHERE pubname = 'supabase_realtime' AND schemaname = 'public' AND tablename = 'appointments'
7174
+ )
7175
+ THEN
7176
+ ALTER PUBLICATION supabase_realtime ADD TABLE public.appointments;
7177
+ END IF;
7178
+ END $$;
7179
+ `;
7180
+ var MIGRATION_005_OUTBOUND_VAULT_DELIVERY = `-- ============================================================================
7181
+ -- Google Calendar 005: the outbound credential moves to Vault, and stops being
7182
+ -- the service-role key. The Authorization header stays.
7183
+ --
7184
+ -- \xA71 trigger fn_gcal_push_booking reads Vault, falls back to the old GUCs
7185
+ -- \xA72 cleanup clear app.gcal_service_key ONLY where Vault already covers it
7186
+ --
7187
+ -- Additive and idempotent: a CREATE OR REPLACE plus a guarded, conditional
7188
+ -- catalog cleanup.
7189
+ --
7190
+ -- WRITTEN AGAINST THE DEPLOYED POOL, NOT AGAINST THIS FOLDER. The repo and
7191
+ -- cluster-salon-br-01 (gphxclpkbtbucoqclbco) had drifted: \`004\` here still reads
7192
+ -- \`app.gcal_service_key\`, while the pool has been on Vault since 2026-07-28 and
7193
+ -- the deployed function is four features ahead of \`functions/\`. Everything below
7194
+ -- was checked against the live database and the live function source, because
7195
+ -- the previous attempt at this file was derived from the repo alone and would
7196
+ -- have broken a real clinic. See FAY-1379 for the drift itself.
7197
+ -- ============================================================================
7198
+
7199
+ -- \xA71 \u2500\u2500 the credential \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7200
+ -- THE EXPOSURE THIS CLOSES, precisely: \`001\` told operators to run
7201
+ -- ALTER DATABASE postgres SET app.gcal_service_key = '<service-role-key>';
7202
+ -- and every version of this trigger in the repo since (\`001\`, \`002 \xA72\`,
7203
+ -- \`004 \xA72\`) reads it back with current_setting(). A pool is ONE Supabase project
7204
+ -- serving N tenants and service_role bypasses RLS by definition, so that setting
7205
+ -- is unrestricted read/write over every tenant in the cluster, in plain text in
7206
+ -- pg_db_role_setting and in every pg_dump taken since.
7207
+ --
7208
+ -- It is closed by making the credential BOTH narrow and encrypted, not by
7209
+ -- removing it: the header is the only thing authorising the push. The deployed
7210
+ -- function checks it (\`isServiceRequest\` compares the bearer against
7211
+ -- GCAL_OUTBOUND_SECRET, then against the service-role key) and \`push_event\` is a
7212
+ -- service action with no user path, so a request with no Authorization header is
7213
+ -- rejected with \`400 Unknown action: push_event\`. Because pg_net is
7214
+ -- fire-and-forget that rejection is INVISIBLE \u2014 the clinic would simply stop
7215
+ -- appearing in Google Calendar. Removing the header is therefore not a security
7216
+ -- improvement, it is a silent outage. It was tried and reverted.
7217
+ --
7218
+ -- So: \`gcal_outbound_secret\` in Vault. Encrypted at rest, outside pg_dump's
7219
+ -- plaintext, readable only by a definer function like this one, and \u2014 the part
7220
+ -- that matters \u2014 it authorises exactly one thing, calling this connector's edge
7221
+ -- function. Losing it does not hand anyone another tenant's data. This is the
7222
+ -- shape the pool has been running since 2026-07-28; \`005\` is the repo catching
7223
+ -- up, so a pool provisioned fresh from this folder is born with it instead of
7224
+ -- being told to paste a service-role key into a database setting.
7225
+ --
7226
+ -- THE FALLBACK IS DELIBERATE. A pool that followed \`001\` and has no Vault secret
7227
+ -- keeps working off the old GUCs, loudly. The alternative \u2014 read Vault only \u2014
7228
+ -- silently disarms the push on exactly the pools that did what the docs told
7229
+ -- them to, which is the failure this file exists to avoid.
7230
+ --
7231
+ -- WHAT THIS DOES NOT FIX: the edge function still builds its Supabase client
7232
+ -- with SUPABASE_SERVICE_ROLE_KEY, so the FUNCTION still reads every tenant in
7233
+ -- the pool. That is not reachable from SQL and does not close here: it closes
7234
+ -- when the platform mints a short-lived tenant-scoped token (FAY-1353) that
7235
+ -- packages/db/migrations/028_tenant_scoped_token.sql already teaches every
7236
+ -- policy to honour through user_tenant_ids(). See docs/platform/DECISIONS.md,
7237
+ -- 2026-08-11 \u2014 "a connector's privilege comes from its token".
7238
+ --
7239
+ -- ROTATE the service-role key of any pool that ever ran \`001\`'s setup line: it
7240
+ -- was plain text at rest and in every backup taken since, so \xA72 clearing it is
7241
+ -- not the same as it never having been there.
7242
+
7243
+ CREATE OR REPLACE FUNCTION public.fn_gcal_push_booking() RETURNS trigger
7244
+ LANGUAGE plpgsql
7245
+ SECURITY DEFINER
7246
+ SET search_path = public, extensions, net, vault
7247
+ AS $$
7248
+ DECLARE
7249
+ url text;
7250
+ key text;
7251
+ legacy boolean := false;
7252
+ rec record := COALESCE(NEW, OLD);
7253
+ op text := lower(TG_OP);
7254
+ corr text := NULLIF(current_setting('app.booking_correlation_id', true), '');
7255
+ BEGIN
7256
+ -- Unchanged from 004: inbound Google writes mark themselves, and a write
7257
+ -- carrying that marker is never echoed back.
7258
+ IF current_setting('app.booking_origin', true) = 'google' THEN
7259
+ RETURN COALESCE(NEW, OLD);
7260
+ END IF;
7261
+
7262
+ -- Vault first. Wrapped because a pool without the vault extension must
7263
+ -- degrade to the fallback, not abort the booking write.
7264
+ BEGIN
7265
+ SELECT decrypted_secret INTO url
7266
+ FROM vault.decrypted_secrets WHERE name = 'gcal_sync_url'
7267
+ ORDER BY created_at DESC LIMIT 1;
7268
+ SELECT decrypted_secret INTO key
7269
+ FROM vault.decrypted_secrets WHERE name = 'gcal_outbound_secret'
7270
+ ORDER BY created_at DESC LIMIT 1;
7271
+ EXCEPTION WHEN OTHERS THEN
7272
+ url := NULL;
7273
+ key := NULL;
7274
+ END;
7275
+
7276
+ -- Legacy pools provisioned per 001's header keep working, and say so.
7277
+ IF NULLIF(url, '') IS NULL OR NULLIF(key, '') IS NULL THEN
7278
+ url := COALESCE(NULLIF(url, ''), current_setting('app.gcal_sync_url', true));
7279
+ key := COALESCE(NULLIF(key, ''), current_setting('app.gcal_service_key', true));
7280
+ legacy := NULLIF(key, '') IS NOT NULL;
7281
+ END IF;
7282
+
7283
+ -- Not configured is not an error: an unconfigured pool degrades to "Google is
7284
+ -- not updated", never to "the appointment could not be saved".
7285
+ IF NULLIF(url, '') IS NULL OR NULLIF(key, '') IS NULL THEN
7286
+ RETURN COALESCE(NEW, OLD);
7287
+ END IF;
7288
+
7289
+ IF legacy THEN
7290
+ RAISE WARNING 'gcal outbound is still using app.gcal_service_key; move it to the vault secret gcal_outbound_secret and rotate the service-role key';
7291
+ END IF;
7292
+
7293
+ BEGIN
7294
+ PERFORM net.http_post(
7295
+ url := url,
7296
+ headers := jsonb_build_object(
7297
+ 'Content-Type', 'application/json',
7298
+ -- REQUIRED. The deployed function authorises push_event on this header
7299
+ -- alone; without it every push 400s, silently, forever.
7300
+ 'Authorization', 'Bearer ' || key
7301
+ ),
7302
+ body := jsonb_build_object(
7303
+ 'action', 'push_event',
7304
+ 'op', op,
7305
+ 'bookingId', rec.id,
7306
+ 'tenantId', rec.tenant_id,
7307
+ -- Carried so a delete can still be honoured after the row is gone.
7308
+ 'googleEventId', rec.metadata->>'googleCalendarEventId',
7309
+ 'calendarId', rec.metadata->>'gcalCalendarId',
7310
+ 'correlationId', COALESCE(corr, gen_random_uuid()::text)
7311
+ )
7312
+ );
7313
+ EXCEPTION WHEN OTHERS THEN
7314
+ -- Missing pg_net, a bad URL, a full queue: the appointment is still saved.
7315
+ -- The deployed trigger lacks this wrapper; 004 added it here after a pool
7316
+ -- with the settings present but the extension missing took an INSERT down.
7317
+ RAISE WARNING 'gcal push skipped for booking %: %', rec.id, SQLERRM;
7318
+ END;
7319
+
7320
+ RETURN COALESCE(NEW, OLD);
7321
+ END $$;
7322
+
7323
+ -- \xA72 \u2500\u2500 cleanup \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7324
+ -- Redefining the function stops the key being PREFERRED; it does not stop it
7325
+ -- being STORED. Settings live in pg_db_role_setting per (database, role), so the
7326
+ -- sweep is driven off the catalog rather than off the one ALTER DATABASE form
7327
+ -- \`001\` happened to show.
7328
+ --
7329
+ -- CONDITIONAL ON PURPOSE: the setting is cleared only where Vault already
7330
+ -- supplies \`gcal_outbound_secret\`, i.e. only where \xA71 provably never needs the
7331
+ -- fallback. Anywhere else it is left in place and reported, because clearing it
7332
+ -- there would disarm the push with no error anyone can see. On the live pool
7333
+ -- this loop finds nothing \u2014 the setting is not present on any role or database \u2014
7334
+ -- and that is the expected result, not a failure.
7335
+ DO $$
7336
+ DECLARE
7337
+ r record;
7338
+ has_vault boolean := false;
7339
+ BEGIN
7340
+ BEGIN
7341
+ SELECT EXISTS (
7342
+ SELECT 1 FROM vault.decrypted_secrets
7343
+ WHERE name = 'gcal_outbound_secret' AND NULLIF(decrypted_secret, '') IS NOT NULL
7344
+ ) INTO has_vault;
7345
+ EXCEPTION WHEN OTHERS THEN
7346
+ has_vault := false;
7347
+ END;
7348
+
7349
+ FOR r IN
7350
+ SELECT d.datname, a.rolname
7351
+ FROM pg_db_role_setting s
7352
+ LEFT JOIN pg_database d ON d.oid = s.setdatabase
7353
+ LEFT JOIN pg_roles a ON a.oid = s.setrole
7354
+ WHERE EXISTS (
7355
+ SELECT 1 FROM unnest(s.setconfig) AS c WHERE c LIKE 'app.gcal_service_key=%'
7356
+ )
7357
+ LOOP
7358
+ IF NOT has_vault THEN
7359
+ RAISE WARNING 'app.gcal_service_key is still set (database %, role %) and is the ONLY outbound credential \u2014 create the vault secret gcal_outbound_secret first, then re-run this migration',
7360
+ r.datname, r.rolname;
7361
+ CONTINUE;
7362
+ END IF;
7363
+ BEGIN
7364
+ IF r.rolname IS NULL THEN
7365
+ EXECUTE format('ALTER DATABASE %I RESET app.gcal_service_key', r.datname);
7366
+ ELSIF r.datname IS NULL THEN
7367
+ EXECUTE format('ALTER ROLE %I RESET app.gcal_service_key', r.rolname);
7368
+ ELSE
7369
+ EXECUTE format('ALTER ROLE %I IN DATABASE %I RESET app.gcal_service_key', r.rolname, r.datname);
7370
+ END IF;
7371
+ EXCEPTION WHEN OTHERS THEN
7372
+ RAISE WARNING 'could not clear app.gcal_service_key (database %, role %): % \u2014 clear it by hand, and rotate the key either way',
7373
+ r.datname, r.rolname, SQLERRM;
7374
+ END;
7375
+ END LOOP;
7376
+ END $$;
7377
+ `;
7378
+ var MIGRATION_006_WEBHOOK_DELIVERIES_OUTBOX = `-- ---------------------------------------------------------------------------
7379
+ -- Google Calendar WEBHOOK INTAKE \u2014 delivery ledger + automation outbox
7380
+ -- ---------------------------------------------------------------------------
7381
+ -- RECONSTRUCTED FROM THE LIVE SCHEMA on 2026-08-12 (FAY-1379). These objects
7382
+ -- existed in pool gphxclpkbtbucoqclbco and in NO migration in any repo: they
7383
+ -- were applied by hand alongside the google-calendar-webhook deployment. A pool
7384
+ -- provisioned from this repo could not run that function \u2014 the SELECT of
7385
+ -- plg_calendar_channels.webhook_token_hash failed before any gate ran, and both
7386
+ -- INSERT targets were missing. This file closes that hole.
7387
+ --
7388
+ -- Written to be applied to a pool that ALREADY has them: every statement is
7389
+ -- IF NOT EXISTS / DROP-then-CREATE, and re-running it must be a no-op.
7390
+ --
7391
+ -- \xA71 plg_calendar_channels \u2014 the three webhook columns
7392
+ -- \xA72 plg_calendar_webhook_deliveries \u2014 one row per Google notification
7393
+ -- \xA73 plg_calendar_automation_outbox \u2014 domain events for the automations lane
7394
+ --
7395
+ -- Read by: functions/google-calendar-webhook/index.ts (\xA71 gate, \xA72 idempotency,
7396
+ -- \xA73 emit) and functions/google-calendar-sync/index.ts (\xA71 watch bookkeeping,
7397
+ -- \xA73 emit on inbound import/patch).
7398
+ -- ---------------------------------------------------------------------------
7399
+
7400
+ -- ===========================================================================
7401
+ -- \xA71 \u2014 Watch-channel authentication state on plg_calendar_channels.
7402
+ -- Added by ALTER in the pool (they sit after created_at/updated_at), so they
7403
+ -- are added the same way here rather than folded into 002's CREATE TABLE \u2014
7404
+ -- 002 has already run everywhere and its checksum must not move.
7405
+ --
7406
+ -- webhook_token_hash: SHA-256 of the token handed to Google at watch_start. The
7407
+ -- token itself is never stored, so a dump of this table does not let anyone
7408
+ -- forge a notification. The webhook re-hashes what arrives and compares in
7409
+ -- constant time.
7410
+ -- last_webhook_message_number: high-water mark of Google's monotonic per-channel
7411
+ -- counter \u2014 the replay guard. bigint because it outgrows int4.
7412
+ -- ===========================================================================
7413
+ ALTER TABLE public.plg_calendar_channels
7414
+ ADD COLUMN IF NOT EXISTS webhook_token_hash text;
7415
+ ALTER TABLE public.plg_calendar_channels
7416
+ ADD COLUMN IF NOT EXISTS last_webhook_message_number bigint;
7417
+ ALTER TABLE public.plg_calendar_channels
7418
+ ADD COLUMN IF NOT EXISTS last_webhook_at timestamptz;
7419
+
7420
+ -- ===========================================================================
7421
+ -- \xA72 \u2014 plg_calendar_webhook_deliveries: one row per accepted notification.
7422
+ --
7423
+ -- The row IS the idempotency mechanism, not a log of one: the webhook inserts
7424
+ -- before doing any work and reads 23505 as "already handled", returning 204.
7425
+ -- Hence two unique keys, both present in the pool and both reproduced \u2014
7426
+ -- (channel_ref, message_number) is the constraint the FK cascade protects, and
7427
+ -- (google_channel_id, message_number) catches a replay arriving after the
7428
+ -- channel row was recreated with the same Google channel id.
7429
+ -- ===========================================================================
7430
+ CREATE TABLE IF NOT EXISTS public.plg_calendar_webhook_deliveries (
7431
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
7432
+ tenant_id uuid NOT NULL,
7433
+ channel_ref uuid NOT NULL REFERENCES public.plg_calendar_channels(id) ON DELETE CASCADE,
7434
+ google_channel_id text NOT NULL,
7435
+ resource_id text,
7436
+ message_number bigint NOT NULL,
7437
+ resource_state text,
7438
+ status text NOT NULL DEFAULT 'pending'
7439
+ CHECK (status IN ('pending','processing','processed','failed','ignored')),
7440
+ error text,
7441
+ received_at timestamptz NOT NULL DEFAULT now(),
7442
+ processed_at timestamptz,
7443
+ created_at timestamptz DEFAULT now(),
7444
+ UNIQUE (channel_ref, message_number)
7445
+ );
7446
+
7447
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_calendar_webhook_delivery_message
7448
+ ON public.plg_calendar_webhook_deliveries (google_channel_id, message_number);
7449
+
7450
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_webhook_deliveries_tenant_created
7451
+ ON public.plg_calendar_webhook_deliveries (tenant_id, created_at DESC);
7452
+
7453
+ -- Two partial indexes over the same predicate on different sort columns. Both
7454
+ -- are in the pool (evidence of two overlapping hand-applied scripts) and both
7455
+ -- are reproduced so this file matches the live schema exactly; consolidating
7456
+ -- them is a separate change with its own reasoning.
7457
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_webhook_pending
7458
+ ON public.plg_calendar_webhook_deliveries (status, received_at)
7459
+ WHERE status IN ('pending','failed');
7460
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_webhook_deliveries_pending
7461
+ ON public.plg_calendar_webhook_deliveries (status, created_at)
7462
+ WHERE status IN ('pending','failed');
7463
+
7464
+ ALTER TABLE public.plg_calendar_webhook_deliveries ENABLE ROW LEVEL SECURITY;
7465
+
7466
+ -- Read-only for the app. Every write comes from the webhook function running as
7467
+ -- service_role, which bypasses RLS \u2014 there is no authenticated INSERT/UPDATE
7468
+ -- grant, so a browser cannot forge or retire a delivery.
7469
+ GRANT SELECT ON public.plg_calendar_webhook_deliveries TO authenticated;
7470
+ GRANT ALL ON public.plg_calendar_webhook_deliveries TO service_role;
7471
+
7472
+ DROP POLICY IF EXISTS plg_calendar_webhook_deliveries_read ON public.plg_calendar_webhook_deliveries;
7473
+ CREATE POLICY plg_calendar_webhook_deliveries_read ON public.plg_calendar_webhook_deliveries
7474
+ FOR SELECT TO authenticated
7475
+ USING (tenant_id IN (SELECT public.user_tenant_ids()));
7476
+
7477
+ -- ===========================================================================
7478
+ -- \xA73 \u2014 plg_calendar_automation_outbox: calendar facts, for the automations lane.
7479
+ --
7480
+ -- Transactional outbox: the sync/webhook functions record WHAT happened and
7481
+ -- return; a consumer drains it. idempotency_key is unique per tenant so a
7482
+ -- replayed Google delivery or a re-run pull upserts onto the same row instead
7483
+ -- of firing an automation twice \u2014 that key is what the functions pass as
7484
+ -- onConflict, so it is load-bearing and not merely a hygiene constraint.
7485
+ -- ===========================================================================
7486
+ CREATE TABLE IF NOT EXISTS public.plg_calendar_automation_outbox (
7487
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
7488
+ tenant_id uuid NOT NULL,
7489
+ source text NOT NULL DEFAULT 'google_calendar',
7490
+ event_type text NOT NULL,
7491
+ aggregate_type text NOT NULL,
7492
+ aggregate_id text,
7493
+ idempotency_key text NOT NULL,
7494
+ payload jsonb NOT NULL DEFAULT '{}'::jsonb,
7495
+ status text NOT NULL DEFAULT 'pending'
7496
+ CHECK (status IN ('pending','processing','processed','failed','ignored')),
7497
+ attempts integer NOT NULL DEFAULT 0,
7498
+ last_error text,
7499
+ occurred_at timestamptz NOT NULL DEFAULT now(),
7500
+ -- available_at is the retry clock: a failed row is re-eligible once it passes.
7501
+ available_at timestamptz NOT NULL DEFAULT now(),
7502
+ processed_at timestamptz,
7503
+ error text,
7504
+ created_at timestamptz DEFAULT now(),
7505
+ UNIQUE (tenant_id, idempotency_key)
7506
+ );
7507
+
7508
+ -- Redundant with the UNIQUE constraint's own index, and present in the pool for
7509
+ -- the same overlapping-scripts reason as \xA72. Reproduced, not tidied.
7510
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_calendar_automation_outbox_idempotency
7511
+ ON public.plg_calendar_automation_outbox (tenant_id, idempotency_key);
7512
+
7513
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_automation_pending
7514
+ ON public.plg_calendar_automation_outbox (status, available_at)
7515
+ WHERE status IN ('pending','failed');
7516
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_automation_outbox_pending
7517
+ ON public.plg_calendar_automation_outbox (status, available_at, created_at)
7518
+ WHERE status IN ('pending','failed');
7519
+
7520
+ ALTER TABLE public.plg_calendar_automation_outbox ENABLE ROW LEVEL SECURITY;
7521
+
7522
+ GRANT SELECT ON public.plg_calendar_automation_outbox TO authenticated;
7523
+ GRANT ALL ON public.plg_calendar_automation_outbox TO service_role;
7524
+
7525
+ DROP POLICY IF EXISTS plg_calendar_automation_outbox_read ON public.plg_calendar_automation_outbox;
7526
+ CREATE POLICY plg_calendar_automation_outbox_read ON public.plg_calendar_automation_outbox
7527
+ FOR SELECT TO authenticated
7528
+ USING (tenant_id IN (SELECT public.user_tenant_ids()));
7529
+ `;
7530
+ var MIGRATION_007_CREDENTIAL_FREE_INSTALL = `-- ---------------------------------------------------------------------------
7531
+ -- Google Calendar \u2014 a credential-free install, from zero
7532
+ -- ---------------------------------------------------------------------------
7533
+ -- FAY-1359/1360. On 2026-08-12 the five plg_calendar_* tables were DROPPED from
7534
+ -- the beauty pool (gphxclpkbtbucoqclbco) together with both edge functions,
7535
+ -- because plg_calendar_integrations held the tenant's Google refresh token in an
7536
+ -- open column. A pool is one database for N clinics, so that column was one
7537
+ -- backup, one dump or one service_role key away from being every clinic's
7538
+ -- calendar.
7539
+ --
7540
+ -- This file is what a pool runs to have the connector back. It is deliberately
7541
+ -- SELF-CONTAINED rather than "001 through 006 again":
7542
+ --
7543
+ -- * 001 CREATEs the credential columns and 004 drops them. A pool restored by
7544
+ -- replaying the chain therefore holds a refresh-token column for the length
7545
+ -- of that replay, and holds it forever if the replay stops early \u2014 which is
7546
+ -- exactly what a half-applied ledger does. There is no ordering of those
7547
+ -- files in which the column never exists; there is this file, in which it
7548
+ -- never exists.
7549
+ -- * The beauty pool's ledger and its schema had already parted company (it
7550
+ -- recorded shop/0006b while tables up to 0043 were applied by hand). A
7551
+ -- recovery that depends on the ledger being right is not a recovery.
7552
+ --
7553
+ -- Re-runnable, and a near no-op on a pool that ran 001\u2013006: every statement is
7554
+ -- IF NOT EXISTS / DROP-then-CREATE, and \xA75 drops the credential columns if it
7555
+ -- finds them rather than assuming 004 ran.
7556
+ --
7557
+ -- \xA71 plg_calendar_integrations \u2014 per-tenant sync state. NO credential.
7558
+ -- \xA72 plg_calendar_channels \u2014 the de-para, incl. the webhook columns
7559
+ -- \xA73 plg_calendar_sync_log \u2014 run history
7560
+ -- \xA74 plg_calendar_webhook_deliveries + plg_calendar_automation_outbox
7561
+ -- \xA75 the gate: this schema must not be able to hold a Google grant
7562
+ --
7563
+ -- WHAT OWNS WHAT, after this file (FAY-1388):
7564
+ -- the PLATFORM owns the credential \u2014 which Google account, authorized by
7565
+ -- whom, and the refresh token, in the vault, reachable only by redemption;
7566
+ -- plg_connections owns "is this connector on for this tenant", written by the
7567
+ -- app when consent comes back;
7568
+ -- these tables own the SYNC STATE \u2014 which calendars map to what, how far the
7569
+ -- cursor got, what Google last told us. Nothing here is a secret, and
7570
+ -- nothing here can be traded for access to anyone's calendar.
7571
+ -- ---------------------------------------------------------------------------
7572
+
7573
+ -- ===========================================================================
7574
+ -- \xA71 \u2014 plg_calendar_integrations: one row per tenant, sync state only.
7575
+ --
7576
+ -- Same shape as 001 MINUS oauth_refresh_token, oauth_access_token and
7577
+ -- token_expires_at. There is no column here to put a token in, which is the
7578
+ -- property this migration exists to establish \u2014 see \xA75, which enforces it.
7579
+ --
7580
+ -- No FK to plg_connections on purpose. That table is the connector framework's
7581
+ -- and this one is the calendar's; a FK would make the connector's row a
7582
+ -- prerequisite for reading a cursor, and the two planes are meant to be able to
7583
+ -- fail independently.
7584
+ -- ===========================================================================
7585
+ CREATE TABLE IF NOT EXISTS public.plg_calendar_integrations (
7586
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
7587
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
7588
+ provider text NOT NULL DEFAULT 'google',
7589
+ calendar_id text NOT NULL DEFAULT 'primary',
7590
+ -- Incremental pull cursor + legacy single-calendar watch bookkeeping.
7591
+ sync_token text,
7592
+ channel_id text,
7593
+ channel_resource_id text,
7594
+ channel_expires_at timestamptz,
7595
+ active boolean NOT NULL DEFAULT true,
7596
+ last_sync_at timestamptz,
7597
+ created_at timestamptz NOT NULL DEFAULT now(),
7598
+ updated_at timestamptz NOT NULL DEFAULT now(),
7599
+ UNIQUE (tenant_id, provider)
7600
+ );
7601
+ ALTER TABLE public.plg_calendar_integrations ENABLE ROW LEVEL SECURITY;
7602
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_integrations_tenant
7603
+ ON public.plg_calendar_integrations(tenant_id);
7604
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_calendar_integrations TO authenticated;
7605
+ GRANT ALL ON public.plg_calendar_integrations TO service_role;
7606
+
7607
+ -- ===========================================================================
7608
+ -- \xA72 \u2014 plg_calendar_channels: the de-para, with 006's webhook columns folded in.
7609
+ -- ===========================================================================
7610
+ CREATE TABLE IF NOT EXISTS public.plg_calendar_channels (
7611
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
7612
+ integration_id uuid NOT NULL REFERENCES public.plg_calendar_integrations(id) ON DELETE CASCADE,
7613
+ tenant_id uuid NOT NULL,
7614
+ google_calendar_id text NOT NULL,
7615
+ summary text,
7616
+ color text,
7617
+ direction text NOT NULL DEFAULT 'bidirectional'
7618
+ CHECK (direction IN ('inbound','outbound','bidirectional','off')),
7619
+ target_kind text CHECK (target_kind IN ('assignee','service','location')),
7620
+ target_id uuid,
7621
+ import_mode text NOT NULL DEFAULT 'block'
7622
+ CHECK (import_mode IN ('block','appointment')),
7623
+ sync_token text,
7624
+ channel_id text,
7625
+ resource_id text,
7626
+ channel_expires_at timestamptz,
7627
+ is_active boolean NOT NULL DEFAULT true,
7628
+ created_at timestamptz NOT NULL DEFAULT now(),
7629
+ updated_at timestamptz NOT NULL DEFAULT now(),
7630
+ UNIQUE (integration_id, google_calendar_id)
7631
+ );
7632
+
7633
+ -- The webhook's authentication state. \`webhook_token_hash\` is a SHA-256 of the
7634
+ -- token handed to Google \u2014 the token itself is never stored, so reading this
7635
+ -- table does not let anyone forge a notification. Added by ALTER as well as in
7636
+ -- the CREATE above, for the pool that already has the table from 002.
7637
+ ALTER TABLE public.plg_calendar_channels
7638
+ ADD COLUMN IF NOT EXISTS webhook_token_hash text;
7639
+ ALTER TABLE public.plg_calendar_channels
7640
+ ADD COLUMN IF NOT EXISTS last_webhook_message_number bigint;
7641
+ ALTER TABLE public.plg_calendar_channels
7642
+ ADD COLUMN IF NOT EXISTS last_webhook_at timestamptz;
7643
+
7644
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_channels_tenant
7645
+ ON public.plg_calendar_channels(tenant_id);
7646
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_channels_integration
7647
+ ON public.plg_calendar_channels(integration_id);
7648
+
7649
+ ALTER TABLE public.plg_calendar_channels ENABLE ROW LEVEL SECURITY;
7650
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_calendar_channels TO authenticated;
7651
+ GRANT ALL ON public.plg_calendar_channels TO service_role;
7652
+
7653
+ DROP TRIGGER IF EXISTS plg_calendar_channels_updated_at ON public.plg_calendar_channels;
7654
+ CREATE TRIGGER plg_calendar_channels_updated_at BEFORE UPDATE ON public.plg_calendar_channels
7655
+ FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
7656
+
7657
+ -- ===========================================================================
7658
+ -- \xA73 \u2014 plg_calendar_sync_log: what already happened, per run.
7659
+ -- ===========================================================================
7660
+ CREATE TABLE IF NOT EXISTS public.plg_calendar_sync_log (
7661
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
7662
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
7663
+ direction text NOT NULL, -- inbound | outbound
7664
+ trigger text, -- on-write | scheduled | webhook | manual
7665
+ status text NOT NULL DEFAULT 'success', -- success | partial | error
7666
+ fetched integer NOT NULL DEFAULT 0,
7667
+ written integer NOT NULL DEFAULT 0,
7668
+ error text,
7669
+ correlation_id uuid,
7670
+ channel_id_ref uuid,
7671
+ created_at timestamptz NOT NULL DEFAULT now()
7672
+ );
7673
+ ALTER TABLE public.plg_calendar_sync_log ENABLE ROW LEVEL SECURITY;
7674
+ ALTER TABLE public.plg_calendar_sync_log ADD COLUMN IF NOT EXISTS correlation_id uuid;
7675
+ ALTER TABLE public.plg_calendar_sync_log ADD COLUMN IF NOT EXISTS channel_id_ref uuid;
7676
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_calendar_sync_log TO authenticated;
7677
+ GRANT ALL ON public.plg_calendar_sync_log TO service_role;
7678
+
7679
+ -- RLS: tenant isolation for the three tables the app reads and writes.
7680
+ DO $$
7681
+ DECLARE t text;
7682
+ BEGIN
7683
+ FOR t IN SELECT unnest(ARRAY[
7684
+ 'plg_calendar_integrations',
7685
+ 'plg_calendar_channels',
7686
+ 'plg_calendar_sync_log'
7687
+ ])
7688
+ LOOP
7689
+ IF NOT EXISTS (
7690
+ SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_rls'
7691
+ ) THEN
7692
+ EXECUTE format(
7693
+ 'CREATE POLICY %I ON public.%I FOR ALL TO authenticated '
7694
+ 'USING (tenant_id IN (SELECT public.user_tenant_ids())) '
7695
+ 'WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()))',
7696
+ t||'_rls', t);
7697
+ END IF;
7698
+ IF NOT EXISTS (
7699
+ SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_service'
7700
+ ) THEN
7701
+ EXECUTE format(
7702
+ 'CREATE POLICY %I ON public.%I FOR ALL TO service_role USING (true) WITH CHECK (true)',
7703
+ t||'_service', t);
7704
+ END IF;
7705
+ END LOOP;
7706
+ END $$;
7707
+
7708
+ -- ===========================================================================
7709
+ -- \xA74 \u2014 Webhook intake: the delivery ledger and the automation outbox.
7710
+ --
7711
+ -- The delivery row IS the idempotency mechanism: the webhook inserts before
7712
+ -- doing any work and reads a 23505 as "already handled". Google retries hard,
7713
+ -- so this is what stops one notification becoming two appointments.
7714
+ -- ===========================================================================
7715
+ CREATE TABLE IF NOT EXISTS public.plg_calendar_webhook_deliveries (
7716
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
7717
+ tenant_id uuid NOT NULL,
7718
+ channel_ref uuid NOT NULL REFERENCES public.plg_calendar_channels(id) ON DELETE CASCADE,
7719
+ google_channel_id text NOT NULL,
7720
+ resource_id text,
7721
+ message_number bigint NOT NULL,
7722
+ resource_state text,
7723
+ status text NOT NULL DEFAULT 'pending'
7724
+ CHECK (status IN ('pending','processing','processed','failed','ignored')),
7725
+ error text,
7726
+ received_at timestamptz NOT NULL DEFAULT now(),
7727
+ processed_at timestamptz,
7728
+ created_at timestamptz DEFAULT now(),
7729
+ UNIQUE (channel_ref, message_number)
7730
+ );
7731
+
7732
+ -- Catches a replay that arrives after the channel row was recreated with the
7733
+ -- same Google channel id \u2014 the FK-scoped constraint above cannot see that.
7734
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_calendar_webhook_delivery_message
7735
+ ON public.plg_calendar_webhook_deliveries (google_channel_id, message_number);
7736
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_webhook_deliveries_tenant_created
7737
+ ON public.plg_calendar_webhook_deliveries (tenant_id, created_at DESC);
7738
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_webhook_deliveries_pending
7739
+ ON public.plg_calendar_webhook_deliveries (status, created_at)
7740
+ WHERE status IN ('pending','failed');
7741
+
7742
+ ALTER TABLE public.plg_calendar_webhook_deliveries ENABLE ROW LEVEL SECURITY;
7743
+ -- Read-only for the app: every write comes from the webhook function as
7744
+ -- service_role, so a browser cannot forge or retire a delivery.
7745
+ GRANT SELECT ON public.plg_calendar_webhook_deliveries TO authenticated;
7746
+ GRANT ALL ON public.plg_calendar_webhook_deliveries TO service_role;
7747
+
7748
+ DROP POLICY IF EXISTS plg_calendar_webhook_deliveries_read ON public.plg_calendar_webhook_deliveries;
7749
+ CREATE POLICY plg_calendar_webhook_deliveries_read ON public.plg_calendar_webhook_deliveries
7750
+ FOR SELECT TO authenticated
7751
+ USING (tenant_id IN (SELECT public.user_tenant_ids()));
7752
+
7753
+ CREATE TABLE IF NOT EXISTS public.plg_calendar_automation_outbox (
7754
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
7755
+ tenant_id uuid NOT NULL,
7756
+ source text NOT NULL DEFAULT 'google_calendar',
7757
+ event_type text NOT NULL,
7758
+ aggregate_type text NOT NULL,
7759
+ aggregate_id text,
7760
+ idempotency_key text NOT NULL,
7761
+ payload jsonb NOT NULL DEFAULT '{}'::jsonb,
7762
+ status text NOT NULL DEFAULT 'pending'
7763
+ CHECK (status IN ('pending','processing','processed','failed','ignored')),
7764
+ attempts integer NOT NULL DEFAULT 0,
7765
+ last_error text,
7766
+ occurred_at timestamptz NOT NULL DEFAULT now(),
7767
+ -- available_at is the retry clock: a failed row is re-eligible once it passes.
7768
+ available_at timestamptz NOT NULL DEFAULT now(),
7769
+ processed_at timestamptz,
7770
+ error text,
7771
+ created_at timestamptz DEFAULT now(),
7772
+ UNIQUE (tenant_id, idempotency_key)
7773
+ );
7774
+
7775
+ CREATE INDEX IF NOT EXISTS idx_plg_calendar_automation_outbox_pending
7776
+ ON public.plg_calendar_automation_outbox (status, available_at, created_at)
7777
+ WHERE status IN ('pending','failed');
7778
+
7779
+ ALTER TABLE public.plg_calendar_automation_outbox ENABLE ROW LEVEL SECURITY;
7780
+ GRANT SELECT ON public.plg_calendar_automation_outbox TO authenticated;
7781
+ GRANT ALL ON public.plg_calendar_automation_outbox TO service_role;
7782
+
7783
+ DROP POLICY IF EXISTS plg_calendar_automation_outbox_read ON public.plg_calendar_automation_outbox;
7784
+ CREATE POLICY plg_calendar_automation_outbox_read ON public.plg_calendar_automation_outbox
7785
+ FOR SELECT TO authenticated
7786
+ USING (tenant_id IN (SELECT public.user_tenant_ids()));
7787
+
7788
+ -- ===========================================================================
7789
+ -- \xA75 \u2014 The gate.
7790
+ --
7791
+ -- Everything above is a CREATE, and a CREATE says nothing about a pool that
7792
+ -- already has the old shape. This block is the part that actually removes the
7793
+ -- hole: it drops the three credential columns wherever it finds them \u2014 the
7794
+ -- pool 004 never reached, the pool restored from an old backup, the pool
7795
+ -- someone re-provisioned from 001 \u2014 and then refuses to finish if any survived.
7796
+ --
7797
+ -- The RAISE matters more than the DROP. A migration that silently tolerated a
7798
+ -- token column is how this schema kept one for months.
7799
+ -- ===========================================================================
7800
+ DO $$
7801
+ DECLARE
7802
+ leftover text;
7803
+ BEGIN
7804
+ ALTER TABLE IF EXISTS public.plg_calendar_integrations DROP COLUMN IF EXISTS oauth_refresh_token;
7805
+ ALTER TABLE IF EXISTS public.plg_calendar_integrations DROP COLUMN IF EXISTS oauth_access_token;
7806
+ ALTER TABLE IF EXISTS public.plg_calendar_integrations DROP COLUMN IF EXISTS token_expires_at;
7807
+
7808
+ SELECT string_agg(table_name || '.' || column_name, ', ')
7809
+ INTO leftover
7810
+ FROM information_schema.columns
7811
+ WHERE table_schema = 'public'
7812
+ AND table_name LIKE 'plg_calendar%'
7813
+ AND (
7814
+ column_name LIKE '%refresh_token%'
7815
+ OR column_name LIKE '%access_token%'
7816
+ OR column_name IN ('oauth_token', 'client_secret', 'api_key')
7817
+ );
7818
+
7819
+ IF leftover IS NOT NULL THEN
7820
+ RAISE EXCEPTION
7821
+ 'Google Calendar schema still holds credential columns: %. The grant belongs in the platform vault (FAY-1359); redeem it at run time instead.',
7822
+ leftover;
7823
+ END IF;
7824
+ END $$;
7825
+
7826
+ -- Dropping the columns does not erase what was in them: Postgres leaves the old
7827
+ -- values in the heap until the next table rewrite. Any grant that ever reached a
7828
+ -- pool must be REVOKED AT GOOGLE, not assumed gone \u2014 which is also why every
7829
+ -- tenant re-consents after this file runs.
7830
+ `;
7831
+
7832
+ // src/integrations/google-calendar/connector.ts
7833
+ var googleCalendarConnector = {
7834
+ id: "google-calendar",
7835
+ provider: "google",
7836
+ pluginId: "agenda",
7837
+ authKind: "oauth",
7838
+ capabilities: [
7839
+ {
7840
+ entity: "booking",
7841
+ direction: "bidirectional",
7842
+ triggers: ["on-write", "scheduled", "webhook"]
7843
+ }
7844
+ ]
7845
+ };
7846
+
7847
+ // src/integrations/google-calendar/index.ts
7848
+ function createGoogleCalendarPlugin() {
7849
+ return {
7850
+ id: "google-calendar",
7851
+ name: "Google Calendar",
7852
+ icon: "Calendar",
7853
+ version: "0.1.0",
7854
+ scope: "addon",
7855
+ defaultEnabled: true,
7856
+ dependencies: ["agenda"],
7857
+ navigation: [],
7858
+ routes: [],
7859
+ connectors: [googleCalendarConnectorDef],
7860
+ functions: [
7861
+ {
7862
+ slug: "google-calendar-sync",
7863
+ // Not reached through the Fayz ingress: it is called from the browser
7864
+ // (calendar list, sync now, import), from the appointments trigger and
7865
+ // from the webhook function. Consent is NOT in that list any more — it
7866
+ // moved to the platform (FAY-1359/1360), which is also why this function
7867
+ // no longer answers GET.
7868
+ ingress: "none",
7869
+ // FALSE, and here it is a decision rather than a gap: two of its callers
7870
+ // cannot present a JWT — the DB trigger sends a Vault secret as bearer
7871
+ // and pg_cron/the webhook send the service-role key. The gateway would
7872
+ // reject both before a line of the function runs. Authorization lives one
7873
+ // level in: `isServiceRequest` for the service actions, a Supabase user
7874
+ // plus tenant membership for the rest, and `tenantId` is required on both.
7875
+ verifyJwt: false,
7876
+ // NAMES only — and no Google client pair among them any more. The client
7877
+ // secret and the tenant's grant are both the platform's; this function
7878
+ // redeems a short-lived access token per call with the three names below.
7879
+ // SUPABASE_SERVICE_ROLE_KEY is listed because the function reads it
7880
+ // TODAY — it is the pool-wide privilege the 2026-08-11 ADR is written
7881
+ // to remove, and hiding it from the manifest would hide the debt.
7882
+ secrets: [
7883
+ "FAYZ_API_URL",
7884
+ "FAYZ_PROJECT_ID",
7885
+ "CONNECTOR_REDEMPTION_SECRET",
7886
+ "GCAL_OUTBOUND_SECRET",
7887
+ "SUPABASE_URL",
7888
+ "SUPABASE_SERVICE_ROLE_KEY"
7889
+ ],
7890
+ // The RPCs the function's credential should be allowed to call once it
7891
+ // stops holding service_role. Both already exist (002/003) and are
7892
+ // already how the inbound writes are applied; what is missing is the
7893
+ // credential that can call these and nothing else.
7894
+ grants: ["gcal_import_event", "gcal_apply_event_patch"]
7895
+ },
7896
+ {
7897
+ // Declared for the first time (FAY-1379). It had been ACTIVE in the live
7898
+ // pool since 2026-05 with no source in any repo — the manifest could not
7899
+ // name what nobody could read.
7900
+ slug: "google-calendar-webhook",
7901
+ // Google POSTs here directly; there is no ingress to put in front of it.
7902
+ ingress: "none",
7903
+ // FALSE, and here it is a correct decision rather than a gap: Google
7904
+ // sends no Authorization header at all. The credential is the per-channel
7905
+ // token, checked against a stored SHA-256 in constant time, with the
7906
+ // resource id and a monotonic message number as the replay guard.
7907
+ verifyJwt: false,
7908
+ // NAMES only. GCAL_OUTBOUND_SECRET is the shared secret this function
7909
+ // presents when it asks google-calendar-sync to pull — the same secret
7910
+ // the DB trigger uses. SUPABASE_SERVICE_ROLE_KEY is listed because the
7911
+ // function reads it TODAY, same declared debt as the sync function.
7912
+ secrets: [
7913
+ "SUPABASE_URL",
7914
+ "SUPABASE_SERVICE_ROLE_KEY",
7915
+ "GCAL_OUTBOUND_SECRET",
7916
+ "GCAL_SYNC_FUNCTION_URL",
7917
+ "GCAL_WEBHOOK_AUTO_PULL"
7918
+ ],
7919
+ // It calls no RPC: it writes the delivery row and the outbox, and
7920
+ // delegates every appointment write to google-calendar-sync.
7921
+ grants: []
7922
+ }
7923
+ ],
7924
+ // Declared for the first time. Without this the connector's SQL was
7925
+ // reachable only by whoever remembered the folder — which is how a table
7926
+ // holding plaintext OAuth tokens shipped with nothing to gate it.
7927
+ migrations: [
7928
+ {
7929
+ id: "google-calendar-001_google_calendar",
7930
+ version: "0.1.0",
7931
+ sql: MIGRATION_001_GOOGLE_CALENDAR,
7932
+ description: "plg_calendar_integrations + plg_calendar_sync_log with tenant RLS, and the outbound appointments trigger"
7933
+ },
7934
+ {
7935
+ id: "google-calendar-002_smart_sync",
7936
+ version: "0.1.0",
7937
+ sql: MIGRATION_002_SMART_SYNC,
7938
+ description: "plg_calendar_channels (per-calendar de-para) + gcal_import_event / gcal_apply_event_patch"
7939
+ },
7940
+ {
7941
+ id: "google-calendar-003_plg_rename",
7942
+ version: "0.1.0",
7943
+ sql: MIGRATION_003_PLG_RENAME,
7944
+ description: "Rename legacy calendar_* tables to the plg_calendar_* prefix and repoint the RPCs"
7945
+ },
7946
+ {
7947
+ id: "google-calendar-004_drop_oauth_tokens",
7948
+ version: "0.9.1",
7949
+ sql: MIGRATION_004_DROP_OAUTH_TOKENS,
7950
+ description: "Drop plg_calendar_integrations.oauth_refresh_token/oauth_access_token (destination: the Fayz platform credential store \u2014 only inside FAY-1359 ordered rollout), give the outbound trigger an anti-loop guard and a safe no-op, and publish appointments for realtime"
7951
+ },
7952
+ {
7953
+ id: "google-calendar-005_outbound_vault_delivery",
7954
+ version: "0.9.2",
7955
+ sql: MIGRATION_005_OUTBOUND_VAULT_DELIVERY,
7956
+ description: "Take the outbound credential from the vault secret gcal_outbound_secret instead of the service-role key in app.gcal_service_key, keeping the Authorization header the sync function authorises on, and falling back to the old setting so a legacy pool is never silently disarmed"
7957
+ },
7958
+ {
7959
+ id: "google-calendar-006_webhook_deliveries_outbox",
7960
+ version: "0.9.2",
7961
+ sql: MIGRATION_006_WEBHOOK_DELIVERIES_OUTBOX,
7962
+ description: "Reconstructed from the live pool (FAY-1379): the webhook columns on plg_calendar_channels plus plg_calendar_webhook_deliveries and plg_calendar_automation_outbox with RLS. Both deployed functions write these and no migration created them, so a fresh pool could not run the webhook at all"
7963
+ },
7964
+ {
7965
+ id: "google-calendar-007_credential_free_install",
7966
+ version: "0.9.3",
7967
+ sql: MIGRATION_007_CREDENTIAL_FREE_INSTALL,
7968
+ description: "Install the whole connector schema in its post-FAY-1359 shape, from zero, with no credential column at any point \u2014 the five tables the beauty pool had dropped, plus a gate that drops a token column wherever it is still found and refuses to finish if one survives"
7969
+ }
7970
+ ]
5650
7971
  };
5651
7972
  }
5652
7973
 
@@ -5654,6 +7975,7 @@ function createGoogleCalendarPlugin() {
5654
7975
  function createAgendaPlugin(options) {
5655
7976
  const config = resolveConfig(options);
5656
7977
  registerTranslations(agendaLocales);
7978
+ registerMessageDefaults(buildAgendaMessageDefaults());
5657
7979
  const provider2 = options?.dataProvider ?? createSafeDataProvider(
5658
7980
  () => createSupabaseAgendaProvider({ professionalKind: config.professionalKind }),
5659
7981
  () => createMockAgendaProvider()
@@ -5688,6 +8010,8 @@ function createAgendaPlugin(options) {
5688
8010
  scaffolds: ["saas"],
5689
8011
  defaultEnabled: true,
5690
8012
  dependencies: [],
8013
+ // Os FATOS da agenda — só expostos enquanto o módulo está ATIVO.
8014
+ events: buildAgendaDomainEvents(),
5691
8015
  declaredFeatures: [
5692
8016
  { id: "appointments", label: config.labels.pageTitle, group: config.labels.pageTitle },
5693
8017
  { id: "agenda.schedules", label: "Schedule Management", group: config.labels.pageTitle }
@@ -5889,10 +8213,14 @@ function createAgendaPlugin(options) {
5889
8213
  description: "Booking read model (v_appointments) + anon public booking surface (v_public_services, get_available_slots, create_public_booking) + order_items duration/assignee columns"
5890
8214
  }
5891
8215
  ],
8216
+ // Google Calendar is the connector this module's addon already ships;
8217
+ // Calendly and WhatsApp are the two the Fayz plugin page has been
8218
+ // promising since the catalogue was written.
8219
+ integrations: ["google-calendar", "calendly", "whatsapp"],
5892
8220
  locales: agendaLocales
5893
8221
  };
5894
8222
  }
5895
8223
 
5896
- export { bookingToEvent2 as bookingToEvent, computeCommissionAmount, createAgendaPlugin, createFinancialBridge, createGoogleCalendarPlugin, createGoogleCalendarProvider, eventToBookingPatch, googleCalendarConnector };
8224
+ export { bookingToEvent2 as bookingToEvent, buildAgendaDomainEvents, buildAgendaMessageDefaults, computeCommissionAmount, createAgendaPlugin, createFinancialBridge, createGoogleCalendarPlugin, createGoogleCalendarProvider, eventToBookingPatch, googleCalendarConnector };
5897
8225
  //# sourceMappingURL=index.js.map
5898
8226
  //# sourceMappingURL=index.js.map