@fayz-ai/plugin-agenda 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/README.md +3 -3
  2. package/dist/bridges/create-financial-bridge.d.ts +1 -1
  3. package/dist/bridges/create-financial-bridge.d.ts.map +1 -1
  4. package/dist/bridges/financial-bridge.d.ts.map +1 -0
  5. package/dist/{chunk-Q2PAWBPR.js → chunk-76ZOHRBM.js} +2 -2
  6. package/dist/{chunk-Q2PAWBPR.js.map → chunk-76ZOHRBM.js.map} +1 -1
  7. package/dist/components/PaymentStatusBadge.d.ts +1 -1
  8. package/dist/components/PaymentStatusBadge.d.ts.map +1 -1
  9. package/dist/config.d.ts +2 -2
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/{AgendaContext.d.ts → context.d.ts} +1 -1
  12. package/dist/context.d.ts.map +1 -0
  13. package/dist/data/registries.d.ts.map +1 -0
  14. package/dist/index.d.ts +2 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +170 -7
  17. package/dist/index.js.map +1 -1
  18. package/dist/lib/message-events.d.ts +12 -0
  19. package/dist/lib/message-events.d.ts.map +1 -0
  20. package/dist/public/{data.d.ts → data/index.d.ts} +4 -4
  21. package/dist/public/data/index.d.ts.map +1 -0
  22. package/dist/public/{data.supabase.d.ts → data/supabase.d.ts} +2 -2
  23. package/dist/public/data/supabase.d.ts.map +1 -0
  24. package/dist/public/{hooks.d.ts → hooks/index.d.ts} +4 -4
  25. package/dist/public/hooks/index.d.ts.map +1 -0
  26. package/dist/public/index.d.ts +4 -4
  27. package/dist/public/index.d.ts.map +1 -1
  28. package/dist/public/index.js +3 -3
  29. package/dist/public/index.js.map +1 -1
  30. package/dist/public/{format.d.ts → lib/format.d.ts} +1 -1
  31. package/dist/public/lib/format.d.ts.map +1 -0
  32. package/dist/public/views/BookingWidget.d.ts.map +1 -0
  33. package/dist/public/{steps → views/steps}/ContactStep.d.ts +1 -1
  34. package/dist/public/views/steps/ContactStep.d.ts.map +1 -0
  35. package/dist/public/{steps → views/steps}/DateTimeStep.d.ts +3 -3
  36. package/dist/public/views/steps/DateTimeStep.d.ts.map +1 -0
  37. package/dist/public/{steps → views/steps}/OverviewStep.d.ts +1 -1
  38. package/dist/public/views/steps/OverviewStep.d.ts.map +1 -0
  39. package/dist/public/{steps → views/steps}/PaymentStep.d.ts +1 -1
  40. package/dist/public/views/steps/PaymentStep.d.ts.map +1 -0
  41. package/dist/store.d.ts +1 -1
  42. package/dist/store.d.ts.map +1 -1
  43. package/dist/styles/calendar-styles.d.ts.map +1 -0
  44. package/dist/types.d.ts +1 -1
  45. package/dist/types.d.ts.map +1 -1
  46. package/dist/{AgendaPage.d.ts → views/AgendaPage.d.ts} +3 -3
  47. package/dist/views/AgendaPage.d.ts.map +1 -0
  48. package/package.json +6 -9
  49. package/dist/AgendaContext.d.ts.map +0 -1
  50. package/dist/AgendaPage.d.ts.map +0 -1
  51. package/dist/calendar-styles.d.ts.map +0 -1
  52. package/dist/financial-bridge.d.ts.map +0 -1
  53. package/dist/public/BookingWidget.d.ts.map +0 -1
  54. package/dist/public/data.d.ts.map +0 -1
  55. package/dist/public/data.supabase.d.ts.map +0 -1
  56. package/dist/public/format.d.ts.map +0 -1
  57. package/dist/public/hooks.d.ts.map +0 -1
  58. package/dist/public/steps/ContactStep.d.ts.map +0 -1
  59. package/dist/public/steps/DateTimeStep.d.ts.map +0 -1
  60. package/dist/public/steps/OverviewStep.d.ts.map +0 -1
  61. package/dist/public/steps/PaymentStep.d.ts.map +0 -1
  62. package/dist/registries.d.ts.map +0 -1
  63. package/src/AgendaContext.tsx +0 -11
  64. package/src/AgendaPage.tsx +0 -33
  65. package/src/bridges/create-financial-bridge.ts +0 -296
  66. package/src/calendar-styles.css +0 -245
  67. package/src/calendar-styles.ts +0 -285
  68. package/src/components/AgendaGeneralSettings.tsx +0 -86
  69. package/src/components/AgendaMobileList.tsx +0 -103
  70. package/src/components/AppointmentModal.tsx +0 -1050
  71. package/src/components/AppointmentPopover.tsx +0 -231
  72. package/src/components/BookingPaymentPanel.tsx +0 -394
  73. package/src/components/CalendarSidebar.tsx +0 -119
  74. package/src/components/CalendarToolbar.tsx +0 -86
  75. package/src/components/EventContextMenu.tsx +0 -125
  76. package/src/components/FloatingPanel.tsx +0 -123
  77. package/src/components/MiniCalendar.tsx +0 -120
  78. package/src/components/PaymentStatusBadge.tsx +0 -62
  79. package/src/components/StatusBadge.tsx +0 -23
  80. package/src/config.ts +0 -368
  81. package/src/data/index.ts +0 -4
  82. package/src/data/mock.ts +0 -344
  83. package/src/data/supabase.ts +0 -636
  84. package/src/data/types.ts +0 -37
  85. package/src/financial-bridge.ts +0 -96
  86. package/src/hooks/useAgendaSettings.ts +0 -17
  87. package/src/index.ts +0 -297
  88. package/src/integrations/google-calendar/GoogleCalendarSettings.tsx +0 -136
  89. package/src/integrations/google-calendar/components/CalendarColorDot.tsx +0 -23
  90. package/src/integrations/google-calendar/components/ChannelMappingList.tsx +0 -329
  91. package/src/integrations/google-calendar/components/ImportEventsWizard.tsx +0 -215
  92. package/src/integrations/google-calendar/components/useAgendaLookups.ts +0 -100
  93. package/src/integrations/google-calendar/connector.ts +0 -20
  94. package/src/integrations/google-calendar/connectorDef.tsx +0 -185
  95. package/src/integrations/google-calendar/data/supabase.ts +0 -170
  96. package/src/integrations/google-calendar/data/tables.ts +0 -12
  97. package/src/integrations/google-calendar/index.ts +0 -45
  98. package/src/integrations/google-calendar/integration.test.ts +0 -199
  99. package/src/integrations/google-calendar/mapping.ts +0 -183
  100. package/src/integrations/google-calendar/migrations/001_google_calendar.sql +0 -97
  101. package/src/integrations/google-calendar/migrations/002_smart_sync.sql +0 -517
  102. package/src/integrations/google-calendar/migrations/003_plg_rename.sql +0 -273
  103. package/src/integrations/google-calendar/types.ts +0 -142
  104. package/src/lib/tenant.ts +0 -3
  105. package/src/locales/en.ts +0 -220
  106. package/src/locales/index.ts +0 -7
  107. package/src/locales/pt-BR.ts +0 -220
  108. package/src/migrations/000b_v_bookings_rename.sql +0 -21
  109. package/src/migrations/000c_dedupe_customer_phones.sql +0 -43
  110. package/src/migrations/001_public_booking.sql +0 -296
  111. package/src/migrations/002_v_bookings_compat.sql +0 -14
  112. package/src/migrations/003_public_client_kind.sql +0 -175
  113. package/src/migrations/004_public_booking_assignee.sql +0 -176
  114. package/src/migrations/005_agent_rpcs.sql +0 -168
  115. package/src/migrations/006_agent_rpc_localtime.sql +0 -181
  116. package/src/migrations/007_schedule_exception_precedence.sql +0 -290
  117. package/src/migrations/008_agent_rpc_exception_precedence.sql +0 -172
  118. package/src/migrations/009_public_booking_location.sql +0 -162
  119. package/src/migrations/010_public_service_curation.sql +0 -31
  120. package/src/migrations/011_default_assignee.sql +0 -167
  121. package/src/migrations/012_drop_legacy_booking_overload.sql +0 -29
  122. package/src/migrations/013_normalize_v_appointments.sql +0 -199
  123. package/src/migrations/014_agent_rpc_update_appointment.sql +0 -199
  124. package/src/migrations/index.ts +0 -2377
  125. package/src/public/BookingWidget.tsx +0 -324
  126. package/src/public/components/BrandHeader.tsx +0 -27
  127. package/src/public/components/ServiceCard.tsx +0 -39
  128. package/src/public/components/SidebarExtras.tsx +0 -30
  129. package/src/public/components/StepRailItem.tsx +0 -27
  130. package/src/public/components/SuccessPanel.tsx +0 -27
  131. package/src/public/components/SummaryRail.tsx +0 -90
  132. package/src/public/components/defaults.ts +0 -14
  133. package/src/public/context.tsx +0 -103
  134. package/src/public/createPublicBookingPlugin.ts +0 -281
  135. package/src/public/data.supabase.ts +0 -97
  136. package/src/public/data.ts +0 -105
  137. package/src/public/format.ts +0 -96
  138. package/src/public/hooks.ts +0 -110
  139. package/src/public/index.ts +0 -69
  140. package/src/public/steps/ContactStep.tsx +0 -184
  141. package/src/public/steps/DateTimeStep.tsx +0 -138
  142. package/src/public/steps/OverviewStep.tsx +0 -81
  143. package/src/public/steps/PaymentStep.tsx +0 -180
  144. package/src/public/types.ts +0 -250
  145. package/src/registries.ts +0 -35
  146. package/src/store.ts +0 -311
  147. package/src/types.ts +0 -273
  148. package/src/views/CalendarView.tsx +0 -1112
  149. package/src/views/ConfirmationsView.tsx +0 -155
  150. /package/dist/{financial-bridge.d.ts → bridges/financial-bridge.d.ts} +0 -0
  151. /package/dist/{registries.d.ts → data/registries.d.ts} +0 -0
  152. /package/dist/public/{BookingWidget.d.ts → views/BookingWidget.d.ts} +0 -0
  153. /package/dist/{calendar-styles.d.ts → styles/calendar-styles.d.ts} +0 -0
@@ -1,185 +0,0 @@
1
- // Google Calendar as a unified ConnectorDefinition.
2
- //
3
- // Surfaces inside plugin-agenda's settings → Integrations tab (the host),
4
- // rendered by the shared ConnectorsHub. The hub handles the OAuth connect/
5
- // disconnect; the ExtraPanel below adds the calendar picker, manual sync and
6
- // run history (only meaningful once connected).
7
- import React, { useEffect, useState } from 'react'
8
- import { RefreshCw, History, CheckCircle2, AlertCircle, Loader2, CalendarCog, CalendarPlus } from 'lucide-react'
9
- import { Button, toast } from '@fayz-ai/ui'
10
- import type { ConnectorDefinition } from '@fayz-ai/core'
11
- import { createGoogleCalendarProvider } from './data/supabase'
12
- import type { CalendarChannel, CalendarSyncLogEntry } from './types'
13
- import { ChannelMappingList } from './components/ChannelMappingList'
14
- import { ImportEventsWizard } from './components/ImportEventsWizard'
15
-
16
- const provider = createGoogleCalendarProvider()
17
-
18
- /** Channels Google is allowed to write INTO the agenda from (gate the import wizard). */
19
- function inboundChannels(channels: CalendarChannel[]): CalendarChannel[] {
20
- return channels.filter(
21
- (c) => c.isActive && (c.direction === 'inbound' || c.direction === 'bidirectional'),
22
- )
23
- }
24
-
25
- function GoogleCalendarExtraPanel() {
26
- const [connected, setConnected] = useState(false)
27
- const [lastSyncAt, setLastSyncAt] = useState<string | undefined>()
28
- const [syncing, setSyncing] = useState(false)
29
- const [log, setLog] = useState<CalendarSyncLogEntry[]>([])
30
- const [loaded, setLoaded] = useState(false)
31
- const [channels, setChannels] = useState<CalendarChannel[]>([])
32
- // Progressive disclosure: once connected the de-para is opened either on the
33
- // "Configure calendars" CTA or automatically when channels already exist.
34
- const [showMapping, setShowMapping] = useState(false)
35
- const [showWizard, setShowWizard] = useState(false)
36
-
37
- async function load() {
38
- const integ = await provider.getIntegration()
39
- setConnected(!!integ?.connected)
40
- if (integ) {
41
- setLastSyncAt(integ.lastSyncAt)
42
- void provider.getSyncLog().then(setLog)
43
- void provider.listChannels().then((ch) => {
44
- setChannels(ch)
45
- if (ch.length > 0) setShowMapping(true)
46
- })
47
- }
48
- setLoaded(true)
49
- }
50
- useEffect(() => { void load() }, [])
51
-
52
- // Before connecting there's nothing to configure here — a short hint only.
53
- if (!loaded) return null
54
- if (!connected) {
55
- return (
56
- <p className="text-xs text-muted-foreground border-t pt-4">
57
- Connect a Google account above to map your calendars and import events.
58
- </p>
59
- )
60
- }
61
-
62
- async function handleSyncNow() {
63
- setSyncing(true)
64
- try {
65
- const r = await provider.syncNow()
66
- toast.success(`Synced — ${r.written} updated`)
67
- void provider.getSyncLog().then(setLog)
68
- void provider.getIntegration().then((i) => setLastSyncAt(i?.lastSyncAt))
69
- } catch (e: any) { toast.error(e?.message ?? 'Sync failed') }
70
- finally { setSyncing(false) }
71
- }
72
-
73
- const canImport = inboundChannels(channels).length > 0
74
-
75
- return (
76
- <div className="space-y-5 border-t pt-4">
77
- {/* 1 — De-para (mapping). CTA first when nothing is configured yet. */}
78
- <section className="space-y-2">
79
- <div className="flex items-center gap-2">
80
- <CalendarCog className="h-4 w-4 text-muted-foreground" />
81
- <h4 className="text-sm font-semibold">Calendars</h4>
82
- </div>
83
- {showMapping ? (
84
- <ChannelMappingList provider={provider} onChannelsChange={setChannels} />
85
- ) : (
86
- <div className="rounded-md border border-dashed px-4 py-5 text-center space-y-2">
87
- <p className="text-sm text-muted-foreground">
88
- Map each Google calendar to what it syncs — a professional, service, location, or your
89
- whole agenda.
90
- </p>
91
- <Button variant="outline" size="sm" onClick={() => setShowMapping(true)}>
92
- <CalendarCog className="h-3.5 w-3.5" /> Configure calendars
93
- </Button>
94
- </div>
95
- )}
96
- </section>
97
-
98
- {/* 2 — Import future events (only meaningful once an inbound channel exists). */}
99
- {showMapping && (
100
- <section className="space-y-2">
101
- {showWizard ? (
102
- <ImportEventsWizard
103
- provider={provider}
104
- channels={inboundChannels(channels)}
105
- onClose={() => setShowWizard(false)}
106
- onImported={() => {
107
- void provider.getSyncLog().then(setLog)
108
- void provider.getIntegration().then((i) => setLastSyncAt(i?.lastSyncAt))
109
- }}
110
- />
111
- ) : (
112
- <Button
113
- variant="outline"
114
- size="sm"
115
- disabled={!canImport}
116
- title={canImport ? undefined : 'Enable an importing calendar first'}
117
- onClick={() => setShowWizard(true)}
118
- >
119
- <CalendarPlus className="h-3.5 w-3.5" /> Import future events
120
- </Button>
121
- )}
122
- </section>
123
- )}
124
-
125
- {/* 3 — Status: last sync + manual pull + run history. */}
126
- <section className="space-y-2">
127
- <div className="flex flex-wrap items-center gap-3">
128
- <Button variant="outline" size="sm" onClick={handleSyncNow} disabled={syncing}>
129
- <RefreshCw className={`h-3.5 w-3.5 ${syncing ? 'animate-spin' : ''}`} /> Sync now
130
- </Button>
131
- {lastSyncAt && (
132
- <span className="text-[11px] text-muted-foreground">
133
- Last sync: {new Date(lastSyncAt).toLocaleString()}
134
- </span>
135
- )}
136
- </div>
137
-
138
- {log.length > 0 && (
139
- <div className="space-y-2">
140
- <div className="flex items-center gap-2">
141
- <History className="h-4 w-4 text-muted-foreground" />
142
- <h4 className="text-sm font-semibold">History</h4>
143
- </div>
144
- <div className="rounded-md border divide-y text-sm">
145
- {log.map((r) => (
146
- <div key={r.id} className="flex items-center gap-3 px-3 py-2">
147
- <span className="text-muted-foreground text-xs">{new Date(r.createdAt).toLocaleString()}</span>
148
- <span className="text-[10px] uppercase tracking-wide px-1 py-px rounded bg-muted text-muted-foreground">{r.direction}</span>
149
- <span className="ml-auto text-xs">{r.written} written / {r.fetched} fetched</span>
150
- {r.status === 'success'
151
- ? <CheckCircle2 className="h-3.5 w-3.5 text-success" />
152
- : <AlertCircle className="h-3.5 w-3.5 text-warning" />}
153
- </div>
154
- ))}
155
- </div>
156
- </div>
157
- )}
158
- </section>
159
- </div>
160
- )
161
- }
162
-
163
- export const googleCalendarConnectorDef: ConnectorDefinition = {
164
- id: 'google-calendar',
165
- hostPluginId: 'agenda',
166
- name: 'Google Calendar',
167
- description: 'Two-way sync between your bookings and Google Calendar.',
168
- icon: 'Calendar',
169
- authKind: 'oauth',
170
- category: 'scheduling',
171
- direction: 'outbound',
172
- // Sync only, and deliberately: the agenda has to exist inside the app — to be
173
- // listed, filtered, reported on, and read when Google is unreachable. An
174
- // agentic plane would answer questions but leave the calendar screen empty.
175
- planes: ['sync'],
176
- entities: ['booking'],
177
- docsUrl: 'https://support.google.com/calendar',
178
- async getStatus() {
179
- const integ = await provider.getIntegration()
180
- return { connected: !!integ?.connected }
181
- },
182
- startOAuth: (redirectTo) => provider.getConnectUrl(redirectTo),
183
- disconnect: () => provider.disconnect(),
184
- ExtraPanel: GoogleCalendarExtraPanel,
185
- }
@@ -1,170 +0,0 @@
1
- // Google Calendar integration (control plane) — Supabase client.
2
- //
3
- // Thin client. Split of responsibilities:
4
- // • anything that needs the OAuth client secret or a Google API call goes
5
- // through the google-calendar-sync edge function (invoke);
6
- // • plain tenant-scoped reads/writes (integration row, channels, log) hit the
7
- // tables directly under RLS.
8
- import { getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core'
9
- import { channelRowToChannel } from '../mapping'
10
- import { GCAL_TABLES } from './tables'
11
- import type {
12
- CalendarIntegration,
13
- CalendarSyncLogEntry,
14
- CalendarChannel,
15
- ExternalEventPreview,
16
- GoogleCalendarListEntry,
17
- ImportEventsResult,
18
- } from '../types'
19
-
20
- const FUNCTION = 'google-calendar-sync'
21
-
22
- function sb() {
23
- const supabase = getSupabaseClientOptional() as any
24
- if (!supabase) throw new Error('Supabase not initialized')
25
- return supabase
26
- }
27
-
28
- /** Snake_case patch for plg_calendar_channels writes (RLS-scoped). */
29
- export interface CalendarChannelInput {
30
- id?: string
31
- integrationId?: string
32
- googleCalendarId: string
33
- summary?: string | null
34
- color?: string | null
35
- direction?: CalendarChannel['direction']
36
- targetKind?: CalendarChannel['targetKind']
37
- targetId?: string | null
38
- importMode?: CalendarChannel['importMode']
39
- isActive?: boolean
40
- }
41
-
42
- export interface GoogleCalendarProvider {
43
- getIntegration(): Promise<CalendarIntegration | null>
44
- /** Start the OAuth connect flow — returns the Google consent URL to redirect to. */
45
- getConnectUrl(redirectTo?: string): Promise<string>
46
- setCalendar(calendarId: string): Promise<void>
47
- disconnect(): Promise<void>
48
- /** Manually pull external changes now (inbound). */
49
- syncNow(): Promise<{ fetched: number; written: number; discovered?: number }>
50
- getSyncLog(): Promise<CalendarSyncLogEntry[]>
51
- // --- smart sync: multi-calendar channels + import wizard ---
52
- /** List the user's Google calendars for the de-para picker (needs the API → function). */
53
- listGoogleCalendars(): Promise<GoogleCalendarListEntry[]>
54
- /** Read this tenant's channels (RLS). */
55
- listChannels(): Promise<CalendarChannel[]>
56
- /** Create/update a channel (RLS upsert on integration_id + google_calendar_id). */
57
- saveChannel(input: CalendarChannelInput): Promise<void>
58
- /** Delete a channel by id (RLS). */
59
- deleteChannel(id: string): Promise<void>
60
- /** Preview future events not yet linked to an appointment (import wizard). */
61
- listExternalEvents(params?: { channelId?: string; timeMin?: string }): Promise<ExternalEventPreview[]>
62
- /** Import selected external events as appointments. */
63
- importEvents(items: { channelId: string; eventId: string }[]): Promise<ImportEventsResult>
64
- }
65
-
66
- export function createGoogleCalendarProvider(): GoogleCalendarProvider {
67
- return {
68
- async getIntegration() {
69
- const { data } = await sb().from(GCAL_TABLES.integrations).select('*').eq('provider', 'google').limit(1).maybeSingle()
70
- if (!data) return null
71
- return {
72
- id: data.id,
73
- provider: data.provider,
74
- calendarId: data.calendar_id ?? 'primary',
75
- active: data.active ?? true,
76
- connected: !!data.oauth_refresh_token,
77
- lastSyncAt: data.last_sync_at ?? undefined,
78
- }
79
- },
80
-
81
- async getConnectUrl(redirectTo) {
82
- const { data, error } = await sb().functions.invoke(FUNCTION, {
83
- body: { action: 'oauth_start', tenantId: getActiveTenantId(), redirectTo: redirectTo ?? window.location.href },
84
- })
85
- if (error) throw new Error(error.message)
86
- return (data as { url: string }).url
87
- },
88
-
89
- async setCalendar(calendarId) {
90
- await sb().from(GCAL_TABLES.integrations).update({ calendar_id: calendarId, updated_at: new Date().toISOString() }).eq('provider', 'google')
91
- },
92
-
93
- async disconnect() {
94
- await sb().from(GCAL_TABLES.integrations).update({ active: false, oauth_refresh_token: null, oauth_access_token: null }).eq('provider', 'google')
95
- },
96
-
97
- async syncNow() {
98
- const { data, error } = await sb().functions.invoke(FUNCTION, { body: { action: 'pull_events', tenantId: getActiveTenantId(), trigger: 'manual' } })
99
- if (error) throw new Error(error.message)
100
- return data as { fetched: number; written: number; discovered?: number }
101
- },
102
-
103
- async getSyncLog() {
104
- const { data } = await sb().from(GCAL_TABLES.syncLog).select('*').order('created_at', { ascending: false }).limit(20)
105
- return (data ?? []).map((r: any) => ({
106
- id: r.id, direction: r.direction, trigger: r.trigger ?? undefined,
107
- status: r.status, fetched: r.fetched ?? 0, written: r.written ?? 0,
108
- error: r.error ?? undefined, createdAt: r.created_at,
109
- }))
110
- },
111
-
112
- async listGoogleCalendars() {
113
- const { data, error } = await sb().functions.invoke(FUNCTION, { body: { action: 'list_calendars', tenantId: getActiveTenantId() } })
114
- if (error) throw new Error(error.message)
115
- return (data as { calendars: GoogleCalendarListEntry[] }).calendars ?? []
116
- },
117
-
118
- async listChannels() {
119
- const { data } = await sb().from(GCAL_TABLES.channels).select('*').order('created_at', { ascending: true })
120
- return (data ?? []).map(channelRowToChannel)
121
- },
122
-
123
- async saveChannel(input) {
124
- const tenantId = getActiveTenantId()
125
- // Resolve the integration id when the caller didn't pass one.
126
- let integrationId = input.integrationId
127
- if (!integrationId) {
128
- const { data } = await sb().from(GCAL_TABLES.integrations).select('id').eq('provider', 'google').limit(1).maybeSingle()
129
- integrationId = data?.id
130
- }
131
- const row: Record<string, unknown> = {
132
- integration_id: integrationId,
133
- tenant_id: tenantId,
134
- google_calendar_id: input.googleCalendarId,
135
- updated_at: new Date().toISOString(),
136
- }
137
- if (input.id !== undefined) row.id = input.id
138
- if (input.summary !== undefined) row.summary = input.summary
139
- if (input.color !== undefined) row.color = input.color
140
- if (input.direction !== undefined) row.direction = input.direction
141
- if (input.targetKind !== undefined) row.target_kind = input.targetKind
142
- if (input.targetId !== undefined) row.target_id = input.targetId
143
- if (input.importMode !== undefined) row.import_mode = input.importMode
144
- if (input.isActive !== undefined) row.is_active = input.isActive
145
- const { error } = await sb().from(GCAL_TABLES.channels).upsert(row, { onConflict: 'integration_id,google_calendar_id' })
146
- if (error) throw new Error(error.message)
147
- },
148
-
149
- async deleteChannel(id) {
150
- const { error } = await sb().from(GCAL_TABLES.channels).delete().eq('id', id)
151
- if (error) throw new Error(error.message)
152
- },
153
-
154
- async listExternalEvents(params) {
155
- const { data, error } = await sb().functions.invoke(FUNCTION, {
156
- body: { action: 'list_external_events', tenantId: getActiveTenantId(), channelId: params?.channelId, timeMin: params?.timeMin },
157
- })
158
- if (error) throw new Error(error.message)
159
- return (data as { events: ExternalEventPreview[] }).events ?? []
160
- },
161
-
162
- async importEvents(items) {
163
- const { data, error } = await sb().functions.invoke(FUNCTION, {
164
- body: { action: 'import_events', tenantId: getActiveTenantId(), items },
165
- })
166
- if (error) throw new Error(error.message)
167
- return data as ImportEventsResult
168
- },
169
- }
170
- }
@@ -1,12 +0,0 @@
1
- // Canonical DB object names for the Google Calendar connector.
2
- //
3
- // Plugin-owned tables carry the platform `plg_<plugin>_` prefix (DATA-MODEL.md
4
- // Ring 1). Single source of truth so the connector data layer (supabase.ts) and
5
- // the google-calendar-sync edge function never drift on a table name. The SQL
6
- // side is owned by ./migrations/001_google_calendar.sql + 002_smart_sync.sql
7
- // (fresh installs) and 003_plg_rename.sql (legacy-pool rename).
8
- export const GCAL_TABLES = {
9
- integrations: 'plg_calendar_integrations',
10
- channels: 'plg_calendar_channels',
11
- syncLog: 'plg_calendar_sync_log',
12
- } as const
@@ -1,45 +0,0 @@
1
- // Google Calendar integration — official (in-SDK) addon for plugin-agenda.
2
- //
3
- // Settings-only PluginManifest: contributes a "Google Calendar" tab to /settings
4
- // for connecting an account and running a manual sync. Outbound (booking →
5
- // Google) is automatic via a DB trigger; inbound (Google → booking) via pg_cron
6
- // or a Google watch webhook. The data plane is the google-calendar-sync edge
7
- // function (see ./functions/google-calendar-sync). The booking↔event link lives
8
- // on public.appointments.metadata.googleCalendarEventId.
9
- //
10
- // Graduation path: extract to its own `@fayz-ai/plugin-google-calendar` package.
11
- import type { PluginManifest } from '@fayz-ai/core'
12
- import { googleCalendarConnectorDef } from './connectorDef'
13
-
14
- export { createGoogleCalendarProvider } from './data/supabase'
15
- export { googleCalendarConnector } from './connector'
16
- export { googleCalendarConnectorDef } from './connectorDef'
17
- export {
18
- bookingToEvent,
19
- eventToBookingPatch,
20
- bookingSummary,
21
- appointmentRowToEvent,
22
- eventToAppointmentPatch,
23
- eventTimes,
24
- resolveTargetChannel,
25
- channelRowToChannel,
26
- } from './mapping'
27
- export * from './types'
28
-
29
- // Addon plugin: contributes the Google Calendar connector into its host
30
- // (plugin-agenda). It appears in Agenda settings → Integrations — no top-level
31
- // settings tab of its own. See packages/saas ConnectorsHub.
32
- export function createGoogleCalendarPlugin(): PluginManifest {
33
- return {
34
- id: 'google-calendar',
35
- name: 'Google Calendar',
36
- icon: 'Calendar',
37
- version: '0.1.0',
38
- scope: 'addon',
39
- defaultEnabled: true,
40
- dependencies: ['agenda'],
41
- navigation: [],
42
- routes: [],
43
- connectors: [googleCalendarConnectorDef],
44
- }
45
- }
@@ -1,199 +0,0 @@
1
- // Integration contract + capability test for the Google Calendar connector.
2
- //
3
- // TDD anchor: the connector contract, manifest integrity, and the pure
4
- // booking↔event mapping are all proven HERE — before/without the deployed edge
5
- // function. The data-plane behaviors that need a live Google account are written
6
- // as it.todo specs so the intended contract is documented and pending, not
7
- // silently missing. See docs/PLUGIN-PATTERNS.md → integration capability.
8
- import { describe, it, expect } from 'vitest'
9
- import { assertConnectorContract } from '@fayz-ai/core/testing'
10
- import { googleCalendarConnector } from './connector'
11
- import {
12
- bookingToEvent,
13
- eventToBookingPatch,
14
- bookingSummary,
15
- appointmentRowToEvent,
16
- eventToAppointmentPatch,
17
- eventTimes,
18
- resolveTargetChannel,
19
- channelRowToChannel,
20
- } from './mapping'
21
- import type { CalendarBooking } from '../../types'
22
- import type { AppointmentRow, CalendarChannel } from './types'
23
-
24
- function booking(overrides: Partial<CalendarBooking> = {}): CalendarBooking {
25
- return {
26
- id: 'b1', tenantId: 't1', kind: 'appointment',
27
- startsAt: '2026-06-18T13:00:00-03:00', endsAt: '2026-06-18T14:00:00-03:00',
28
- status: 'scheduled', notes: null, orderId: 'b1', locationId: null, metadata: {},
29
- clientId: null, clientName: 'Sarah', clientPhone: null, clientEmail: 'sarah@x.com', clientAvatarUrl: null,
30
- professionalId: null, professionalName: 'Ana', professionalAvatarUrl: null,
31
- locationName: 'HQ', orderTotal: null, orderStatus: null,
32
- services: [{ id: 's1', serviceId: 'svc1', name: 'Haircut', durationMinutes: 60, price: 100, assigneeId: null }] as any,
33
- totalDurationMinutes: 60, createdAt: '', updatedAt: '',
34
- ...overrides,
35
- }
36
- }
37
-
38
- describe('google-calendar · connector contract (integrity)', () => {
39
- it('declares a valid connector descriptor', () => {
40
- expect(() => assertConnectorContract(googleCalendarConnector)).not.toThrow()
41
- expect(googleCalendarConnector.capabilities[0].direction).toBe('bidirectional')
42
- })
43
- })
44
-
45
- describe('google-calendar · booking↔event mapping (pure, TDD)', () => {
46
- it('builds an event title from services + client', () => {
47
- expect(bookingSummary(booking())).toBe('Haircut — Sarah')
48
- })
49
-
50
- it('maps a booking to a Google event input with start/end', () => {
51
- const ev = bookingToEvent(booking())
52
- expect(ev).not.toBeNull()
53
- expect(ev!.summary).toBe('Haircut — Sarah')
54
- expect(ev!.start.dateTime).toBe('2026-06-18T13:00:00-03:00')
55
- expect(ev!.end.dateTime).toBe('2026-06-18T14:00:00-03:00')
56
- })
57
-
58
- it('refuses to map a booking with no end time', () => {
59
- expect(bookingToEvent(booking({ endsAt: null }))).toBeNull()
60
- })
61
-
62
- it('maps an inbound reschedule to a booking patch', () => {
63
- const patch = eventToBookingPatch({
64
- id: 'e1', start: { dateTime: '2026-06-18T15:00:00-03:00' }, end: { dateTime: '2026-06-18T16:00:00-03:00' },
65
- })
66
- expect(patch.startsAt).toBe('2026-06-18T15:00:00-03:00')
67
- expect(patch.endsAt).toBe('2026-06-18T16:00:00-03:00')
68
- })
69
-
70
- it('maps an inbound cancellation to a cancelled status', () => {
71
- const patch = eventToBookingPatch({ id: 'e1', status: 'cancelled', start: {}, end: {} })
72
- expect(patch.status).toBe('cancelled')
73
- })
74
-
75
- it('reads an all-day event window (date, no dateTime)', () => {
76
- const t = eventTimes({ id: 'e1', start: { date: '2026-06-18' }, end: { date: '2026-06-19' } })
77
- expect(t.allDay).toBe(true)
78
- expect(t.startsAt).toBe('2026-06-18')
79
- expect(t.endsAt).toBe('2026-06-19')
80
- })
81
-
82
- it('reads a timed event window as not all-day', () => {
83
- const t = eventTimes({ id: 'e1', start: { dateTime: '2026-06-18T13:00:00-03:00' }, end: { dateTime: '2026-06-18T14:00:00-03:00' } })
84
- expect(t.allDay).toBe(false)
85
- expect(t.startsAt).toBe('2026-06-18T13:00:00-03:00')
86
- })
87
- })
88
-
89
- describe('google-calendar · appointment-row mapping (edge-function shape)', () => {
90
- function row(overrides: Partial<AppointmentRow> = {}): AppointmentRow {
91
- return { id: 'a1', tenant_id: 't1', starts_at: '2026-06-18T13:00:00-03:00', ends_at: '2026-06-18T14:00:00-03:00', ...overrides }
92
- }
93
-
94
- it('maps a minimal row to a Google event input', () => {
95
- const ev = appointmentRowToEvent(row())
96
- expect(ev.summary).toBe('Appointment')
97
- expect(ev.start.dateTime).toBe('2026-06-18T13:00:00-03:00')
98
- expect(ev.end.dateTime).toBe('2026-06-18T14:00:00-03:00')
99
- })
100
-
101
- it('builds the title from metadata service + contact names', () => {
102
- const ev = appointmentRowToEvent(row({ metadata: { serviceNames: 'Cleaning', contactName: 'Sarah' }, notes: 'bring x-rays' }))
103
- expect(ev.summary).toBe('Cleaning — Sarah')
104
- expect(ev.description).toBe('bring x-rays')
105
- })
106
-
107
- it('falls back end→start when the row has no end', () => {
108
- const ev = appointmentRowToEvent(row({ ends_at: null }))
109
- expect(ev.end.dateTime).toBe('2026-06-18T13:00:00-03:00')
110
- })
111
-
112
- it('maps an inbound reschedule to a snake_case appointment patch', () => {
113
- const patch = eventToAppointmentPatch({
114
- id: 'e1', summary: 'Moved',
115
- start: { dateTime: '2026-06-18T15:00:00-03:00' }, end: { dateTime: '2026-06-18T16:00:00-03:00' },
116
- })
117
- expect(patch.cancelled).toBe(false)
118
- expect(patch.starts_at).toBe('2026-06-18T15:00:00-03:00')
119
- expect(patch.ends_at).toBe('2026-06-18T16:00:00-03:00')
120
- expect(patch.summary).toBe('Moved')
121
- expect(patch.allDay).toBe(false)
122
- })
123
-
124
- it('maps an inbound all-day event to an all-day patch', () => {
125
- const patch = eventToAppointmentPatch({ id: 'e1', start: { date: '2026-06-18' }, end: { date: '2026-06-19' } })
126
- expect(patch.allDay).toBe(true)
127
- expect(patch.starts_at).toBe('2026-06-18')
128
- })
129
-
130
- it('maps an inbound cancellation to a cancelled patch', () => {
131
- const patch = eventToAppointmentPatch({ id: 'e1', status: 'cancelled', start: {}, end: {} })
132
- expect(patch.cancelled).toBe(true)
133
- expect(patch.starts_at).toBeUndefined()
134
- })
135
- })
136
-
137
- describe('google-calendar · channel resolution (outbound priority, pure)', () => {
138
- function ch(overrides: Partial<CalendarChannel> = {}): CalendarChannel {
139
- return {
140
- id: 'c1', integrationId: 'i1', tenantId: 't1', googleCalendarId: 'cal@group',
141
- summary: null, color: null, direction: 'bidirectional', targetKind: null, targetId: null,
142
- importMode: 'appointment', syncToken: null, channelId: null, resourceId: null,
143
- channelExpiresAt: null, isActive: true, createdAt: '', updatedAt: '',
144
- ...overrides,
145
- }
146
- }
147
-
148
- it('prefers an assignee-targeted channel over service/location/whole-agenda', () => {
149
- const channels = [
150
- ch({ id: 'whole', googleCalendarId: 'whole@g', targetKind: null }),
151
- ch({ id: 'loc', googleCalendarId: 'loc@g', targetKind: 'location', targetId: 'L1' }),
152
- ch({ id: 'svc', googleCalendarId: 'svc@g', targetKind: 'service', targetId: 'S1' }),
153
- ch({ id: 'ana', googleCalendarId: 'ana@g', targetKind: 'assignee', targetId: 'A1' }),
154
- ]
155
- const match = resolveTargetChannel(channels, { assigneeId: 'A1', serviceIds: ['S1'], locationId: 'L1' })
156
- expect(match?.id).toBe('ana')
157
- })
158
-
159
- it('falls through assignee → service → location → whole agenda', () => {
160
- const svc = ch({ id: 'svc', targetKind: 'service', targetId: 'S9' })
161
- const loc = ch({ id: 'loc', targetKind: 'location', targetId: 'L9' })
162
- const whole = ch({ id: 'whole', targetKind: null })
163
- expect(resolveTargetChannel([svc, loc, whole], { assigneeId: 'nope', serviceIds: ['S9'] })?.id).toBe('svc')
164
- expect(resolveTargetChannel([loc, whole], { locationId: 'L9' })?.id).toBe('loc')
165
- expect(resolveTargetChannel([whole], { assigneeId: 'x' })?.id).toBe('whole')
166
- })
167
-
168
- it('ignores inactive and non-outbound channels', () => {
169
- const inbound = ch({ id: 'in', targetKind: 'assignee', targetId: 'A1', direction: 'inbound' })
170
- const off = ch({ id: 'off', targetKind: 'assignee', targetId: 'A1', direction: 'off' })
171
- const inactive = ch({ id: 'x', targetKind: null, isActive: false })
172
- expect(resolveTargetChannel([inbound, off, inactive], { assigneeId: 'A1' })).toBeNull()
173
- })
174
-
175
- it('returns null when nothing matches', () => {
176
- const svc = ch({ id: 'svc', targetKind: 'service', targetId: 'S1' })
177
- expect(resolveTargetChannel([svc], { assigneeId: 'A1', serviceIds: ['other'] })).toBeNull()
178
- })
179
-
180
- it('channelRowToChannel maps snake_case rows with defaults', () => {
181
- const mapped = channelRowToChannel({
182
- id: 'c1', integration_id: 'i1', tenant_id: 't1', google_calendar_id: 'cal@g', direction: 'inbound',
183
- })
184
- expect(mapped.googleCalendarId).toBe('cal@g')
185
- expect(mapped.importMode).toBe('appointment')
186
- expect(mapped.isActive).toBe(true)
187
- expect(mapped.targetKind).toBeNull()
188
- })
189
- })
190
-
191
- // Data-plane behaviors — require a live Google account + the deployed
192
- // google-calendar-sync edge function. Documented as the pending contract (TDD).
193
- describe('google-calendar · sync data plane (pending — needs edge function)', () => {
194
- it.todo('push_event: creating a booking creates a Google event and stores its id on bookings.metadata')
195
- it.todo('push_event: updating a booking patches the linked Google event')
196
- it.todo('push_event: cancelling a booking deletes the Google event')
197
- it.todo('pull_events: a Google reschedule updates the linked booking; a cancel cancels it')
198
- it.todo('pull_events: advances and persists the incremental syncToken')
199
- })