@fayz-ai/plugin-agenda 0.8.0 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/dist/{chunk-LFUELUIT.cjs → chunk-3KWNHV5A.cjs} +60 -2
  2. package/dist/chunk-3KWNHV5A.cjs.map +1 -0
  3. package/dist/{chunk-DRZF6CV2.js → chunk-YQO6B4G3.js} +60 -3
  4. package/dist/chunk-YQO6B4G3.js.map +1 -0
  5. package/dist/components/AgendaGeneralSettings.d.ts.map +1 -1
  6. package/dist/components/AppointmentModal.d.ts.map +1 -1
  7. package/dist/components/CalendarToolbar.d.ts.map +1 -1
  8. package/dist/data/supabase.d.ts +5 -1
  9. package/dist/data/supabase.d.ts.map +1 -1
  10. package/dist/index.cjs +1071 -766
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +914 -609
  14. package/dist/index.js.map +1 -1
  15. package/dist/integrations/google-calendar/components/CalendarColorDot.d.ts +6 -0
  16. package/dist/integrations/google-calendar/components/CalendarColorDot.d.ts.map +1 -0
  17. package/dist/integrations/google-calendar/components/ChannelMappingList.d.ts +9 -0
  18. package/dist/integrations/google-calendar/components/ChannelMappingList.d.ts.map +1 -0
  19. package/dist/integrations/google-calendar/components/ImportEventsWizard.d.ts +12 -0
  20. package/dist/integrations/google-calendar/components/ImportEventsWizard.d.ts.map +1 -0
  21. package/dist/integrations/google-calendar/components/useAgendaLookups.d.ts +26 -0
  22. package/dist/integrations/google-calendar/components/useAgendaLookups.d.ts.map +1 -0
  23. package/dist/integrations/google-calendar/connectorDef.d.ts.map +1 -1
  24. package/dist/integrations/google-calendar/data/supabase.d.ts +33 -1
  25. package/dist/integrations/google-calendar/data/supabase.d.ts.map +1 -1
  26. package/dist/integrations/google-calendar/data/tables.d.ts +6 -0
  27. package/dist/integrations/google-calendar/data/tables.d.ts.map +1 -0
  28. package/dist/integrations/google-calendar/index.d.ts +1 -1
  29. package/dist/integrations/google-calendar/index.d.ts.map +1 -1
  30. package/dist/integrations/google-calendar/mapping.d.ts +56 -4
  31. package/dist/integrations/google-calendar/mapping.d.ts.map +1 -1
  32. package/dist/integrations/google-calendar/types.d.ts +101 -0
  33. package/dist/integrations/google-calendar/types.d.ts.map +1 -1
  34. package/dist/locales/en.d.ts.map +1 -1
  35. package/dist/locales/pt-BR.d.ts.map +1 -1
  36. package/dist/migrations/index.d.ts +11 -0
  37. package/dist/migrations/index.d.ts.map +1 -1
  38. package/dist/public/BookingWidget.d.ts.map +1 -1
  39. package/dist/public/context.d.ts +3 -1
  40. package/dist/public/context.d.ts.map +1 -1
  41. package/dist/public/createPublicBookingPlugin.d.ts +7 -1
  42. package/dist/public/createPublicBookingPlugin.d.ts.map +1 -1
  43. package/dist/public/data.d.ts +6 -0
  44. package/dist/public/data.d.ts.map +1 -1
  45. package/dist/public/data.supabase.d.ts.map +1 -1
  46. package/dist/public/format.d.ts +22 -1
  47. package/dist/public/format.d.ts.map +1 -1
  48. package/dist/public/hooks.d.ts.map +1 -1
  49. package/dist/public/index.cjs +31 -63
  50. package/dist/public/index.cjs.map +1 -1
  51. package/dist/public/index.d.ts +1 -1
  52. package/dist/public/index.d.ts.map +1 -1
  53. package/dist/public/index.js +27 -61
  54. package/dist/public/index.js.map +1 -1
  55. package/dist/public/steps/ContactStep.d.ts +4 -2
  56. package/dist/public/steps/ContactStep.d.ts.map +1 -1
  57. package/dist/public/types.d.ts +16 -0
  58. package/dist/public/types.d.ts.map +1 -1
  59. package/dist/views/CalendarView.d.ts.map +1 -1
  60. package/package.json +6 -5
  61. package/src/components/AgendaGeneralSettings.tsx +38 -16
  62. package/src/components/AppointmentModal.tsx +50 -108
  63. package/src/components/CalendarToolbar.tsx +10 -7
  64. package/src/data/supabase.ts +67 -13
  65. package/src/index.ts +56 -8
  66. package/src/integrations/google-calendar/components/CalendarColorDot.tsx +23 -0
  67. package/src/integrations/google-calendar/components/ChannelMappingList.tsx +329 -0
  68. package/src/integrations/google-calendar/components/ImportEventsWizard.tsx +215 -0
  69. package/src/integrations/google-calendar/components/useAgendaLookups.ts +100 -0
  70. package/src/integrations/google-calendar/connectorDef.tsx +123 -40
  71. package/src/integrations/google-calendar/data/supabase.ts +109 -13
  72. package/src/integrations/google-calendar/data/tables.ts +12 -0
  73. package/src/integrations/google-calendar/index.ts +10 -1
  74. package/src/integrations/google-calendar/integration.test.ts +126 -1
  75. package/src/integrations/google-calendar/mapping.ts +149 -9
  76. package/src/integrations/google-calendar/migrations/001_google_calendar.sql +9 -6
  77. package/src/integrations/google-calendar/migrations/002_smart_sync.sql +517 -0
  78. package/src/integrations/google-calendar/migrations/003_plg_rename.sql +273 -0
  79. package/src/integrations/google-calendar/types.ts +111 -0
  80. package/src/locales/en.ts +1 -0
  81. package/src/locales/pt-BR.ts +1 -0
  82. package/src/migrations/003_public_client_kind.sql +175 -0
  83. package/src/migrations/004_public_booking_assignee.sql +176 -0
  84. package/src/migrations/005_agent_rpcs.sql +168 -0
  85. package/src/migrations/006_agent_rpc_localtime.sql +181 -0
  86. package/src/migrations/007_schedule_exception_precedence.sql +290 -0
  87. package/src/migrations/008_agent_rpc_exception_precedence.sql +172 -0
  88. package/src/migrations/009_public_booking_location.sql +162 -0
  89. package/src/migrations/010_public_service_curation.sql +31 -0
  90. package/src/migrations/011_default_assignee.sql +167 -0
  91. package/src/migrations/012_drop_legacy_booking_overload.sql +29 -0
  92. package/src/migrations/013_normalize_v_appointments.sql +161 -0
  93. package/src/migrations/index.ts +1746 -1
  94. package/src/public/BookingWidget.tsx +10 -1
  95. package/src/public/context.tsx +3 -0
  96. package/src/public/createPublicBookingPlugin.ts +14 -1
  97. package/src/public/data.supabase.ts +3 -0
  98. package/src/public/data.ts +6 -0
  99. package/src/public/format.ts +36 -1
  100. package/src/public/hooks.ts +7 -2
  101. package/src/public/index.ts +1 -0
  102. package/src/public/steps/ContactStep.tsx +10 -5
  103. package/src/public/types.ts +21 -0
  104. package/src/views/CalendarView.tsx +15 -1
  105. package/dist/chunk-3LJKZQK4.js +0 -12
  106. package/dist/chunk-3LJKZQK4.js.map +0 -1
  107. package/dist/chunk-DRZF6CV2.js.map +0 -1
  108. package/dist/chunk-LFUELUIT.cjs.map +0 -1
  109. package/dist/chunk-SYDBDI3Q.cjs +0 -15
  110. package/dist/chunk-SYDBDI3Q.cjs.map +0 -1
  111. package/dist/components/SearchCombobox.d.ts +0 -38
  112. package/dist/components/SearchCombobox.d.ts.map +0 -1
  113. package/dist/tenant-222EHYUC.cjs +0 -16
  114. package/dist/tenant-222EHYUC.cjs.map +0 -1
  115. package/dist/tenant-LAUWVUYB.js +0 -3
  116. package/dist/tenant-LAUWVUYB.js.map +0 -1
  117. package/src/components/SearchCombobox.tsx +0 -216
@@ -0,0 +1,100 @@
1
+ // Host-plugin lookups for the smart-sync de-para target picker.
2
+ //
3
+ // A channel can target one professional (assignee), one service, or one location
4
+ // instead of the whole agenda. To offer real pickers (not raw uuid inputs) the
5
+ // connector reuses plugin-agenda's own data providers — it lives inside the same
6
+ // package, so these are in-process reads under the same RLS:
7
+ // • professionals + locations → createSupabaseAgendaProvider()
8
+ // • services → createSupabasePublicBookingProvider() (v_public_services)
9
+ // Everything is loaded once, lazily, and degrades to an empty list (the row then
10
+ // falls back to a plain uuid input) if a lookup is unavailable for the tenant.
11
+ import { useEffect, useState } from 'react'
12
+ import { getActiveTenantId } from '@fayz-ai/core'
13
+ import { createSupabaseAgendaProvider } from '../../../data'
14
+ import { createSupabasePublicBookingProvider } from '../../../public/data.supabase'
15
+ import type { ChannelTargetKind } from '../types'
16
+
17
+ /** A pickable target option (assignee/service/location) — id + display name. */
18
+ export interface LookupOption {
19
+ id: string
20
+ name: string
21
+ }
22
+
23
+ export interface AgendaLookups {
24
+ assignee: LookupOption[]
25
+ service: LookupOption[]
26
+ location: LookupOption[]
27
+ }
28
+
29
+ const EMPTY: AgendaLookups = { assignee: [], service: [], location: [] }
30
+
31
+ export interface UseAgendaLookupsResult {
32
+ lookups: AgendaLookups
33
+ loading: boolean
34
+ /** True when at least one of the three lookups returned rows. */
35
+ hasAny: boolean
36
+ }
37
+
38
+ /**
39
+ * Load professionals / services / locations for the target picker. Failures are
40
+ * swallowed per-lookup (each falls back to an empty list) so a missing table or
41
+ * a vertical without services never breaks the whole de-para screen.
42
+ */
43
+ export function useAgendaLookups(enabled: boolean): UseAgendaLookupsResult {
44
+ const [lookups, setLookups] = useState<AgendaLookups>(EMPTY)
45
+ const [loading, setLoading] = useState(false)
46
+
47
+ useEffect(() => {
48
+ if (!enabled) return
49
+ let cancelled = false
50
+ setLoading(true)
51
+ void (async () => {
52
+ const agenda = createSupabaseAgendaProvider()
53
+ const tenantId = getActiveTenantId()
54
+ const publicProvider = tenantId
55
+ ? createSupabasePublicBookingProvider({ tenantId })
56
+ : null
57
+
58
+ const [assignee, location, service] = await Promise.all([
59
+ agenda.getProfessionals().then(
60
+ (rows) => rows.map((p) => ({ id: p.id, name: p.name })),
61
+ () => [] as LookupOption[],
62
+ ),
63
+ (agenda.getLocations?.() ?? Promise.resolve([])).then(
64
+ (rows) => rows.map((l) => ({ id: l.id, name: l.name })),
65
+ () => [] as LookupOption[],
66
+ ),
67
+ (publicProvider?.getServices() ?? Promise.resolve([])).then(
68
+ (rows) => rows.map((s) => ({ id: s.id, name: s.name })),
69
+ () => [] as LookupOption[],
70
+ ),
71
+ ])
72
+
73
+ if (cancelled) return
74
+ setLookups({ assignee, service, location })
75
+ setLoading(false)
76
+ })()
77
+
78
+ return () => {
79
+ cancelled = true
80
+ }
81
+ }, [enabled])
82
+
83
+ const hasAny =
84
+ lookups.assignee.length > 0 ||
85
+ lookups.service.length > 0 ||
86
+ lookups.location.length > 0
87
+
88
+ return { lookups, loading, hasAny }
89
+ }
90
+
91
+ /** Options available for a given target kind (empty for whole-agenda / null). */
92
+ export function optionsForKind(
93
+ lookups: AgendaLookups,
94
+ kind: ChannelTargetKind,
95
+ ): LookupOption[] {
96
+ if (kind === 'assignee') return lookups.assignee
97
+ if (kind === 'service') return lookups.service
98
+ if (kind === 'location') return lookups.location
99
+ return []
100
+ }
@@ -5,74 +5,157 @@
5
5
  // disconnect; the ExtraPanel below adds the calendar picker, manual sync and
6
6
  // run history (only meaningful once connected).
7
7
  import React, { useEffect, useState } from 'react'
8
- import { RefreshCw, History, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react'
8
+ import { RefreshCw, History, CheckCircle2, AlertCircle, Loader2, CalendarCog, CalendarPlus } from 'lucide-react'
9
9
  import { Button, toast } from '@fayz-ai/ui'
10
10
  import type { ConnectorDefinition } from '@fayz-ai/core'
11
11
  import { createGoogleCalendarProvider } from './data/supabase'
12
- import type { CalendarSyncLogEntry } from './types'
12
+ import type { CalendarChannel, CalendarSyncLogEntry } from './types'
13
+ import { ChannelMappingList } from './components/ChannelMappingList'
14
+ import { ImportEventsWizard } from './components/ImportEventsWizard'
13
15
 
14
16
  const provider = createGoogleCalendarProvider()
15
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
+
16
25
  function GoogleCalendarExtraPanel() {
17
26
  const [connected, setConnected] = useState(false)
18
- const [calendarId, setCalendarId] = useState('primary')
27
+ const [lastSyncAt, setLastSyncAt] = useState<string | undefined>()
19
28
  const [syncing, setSyncing] = useState(false)
20
29
  const [log, setLog] = useState<CalendarSyncLogEntry[]>([])
21
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)
22
36
 
23
37
  async function load() {
24
38
  const integ = await provider.getIntegration()
25
39
  setConnected(!!integ?.connected)
26
- if (integ) { setCalendarId(integ.calendarId); void provider.getSyncLog().then(setLog) }
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
+ }
27
48
  setLoaded(true)
28
49
  }
29
50
  useEffect(() => { void load() }, [])
30
51
 
31
- // Before connecting there's nothing to configure here.
32
- if (!loaded || !connected) return null
33
-
34
- async function handleSaveCalendar() {
35
- await provider.setCalendar(calendarId)
36
- toast.success('Saved')
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
+ )
37
60
  }
61
+
38
62
  async function handleSyncNow() {
39
63
  setSyncing(true)
40
- try { const r = await provider.syncNow(); toast.success(`Synced — ${r.written} updated`); void provider.getSyncLog().then(setLog) }
41
- catch (e: any) { toast.error(e?.message ?? 'Sync failed') }
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') }
42
70
  finally { setSyncing(false) }
43
71
  }
44
72
 
45
- return (
46
- <div className="space-y-4 border-t pt-4">
47
- <div className="flex flex-wrap items-end gap-3">
48
- <label className="block flex-1 min-w-[200px]">
49
- <span className="text-xs font-medium text-muted-foreground">Calendar ID</span>
50
- <input value={calendarId} onChange={(e) => setCalendarId(e.target.value)} placeholder="primary"
51
- className="w-full mt-1 rounded-input border border-input bg-card px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
52
- </label>
53
- <Button variant="outline" size="sm" onClick={handleSaveCalendar}>Save</Button>
54
- <Button variant="outline" size="sm" onClick={handleSyncNow} disabled={syncing}>
55
- <RefreshCw className={`h-3.5 w-3.5 ${syncing ? 'animate-spin' : ''}`} /> Sync now
56
- </Button>
57
- </div>
73
+ const canImport = inboundChannels(channels).length > 0
58
74
 
59
- {log.length > 0 && (
60
- <div className="space-y-2">
61
- <div className="flex items-center gap-2"><History className="h-4 w-4 text-muted-foreground" /><h4 className="text-sm font-semibold">History</h4></div>
62
- <div className="rounded-md border divide-y text-sm">
63
- {log.map((r) => (
64
- <div key={r.id} className="flex items-center gap-3 px-3 py-2">
65
- <span className="text-muted-foreground text-xs">{new Date(r.createdAt).toLocaleString()}</span>
66
- <span className="text-[10px] uppercase tracking-wide px-1 py-px rounded bg-muted text-muted-foreground">{r.direction}</span>
67
- <span className="ml-auto text-xs">{r.written} written / {r.fetched} fetched</span>
68
- {r.status === 'success'
69
- ? <CheckCircle2 className="h-3.5 w-3.5 text-success" />
70
- : <AlertCircle className="h-3.5 w-3.5 text-warning" />}
71
- </div>
72
- ))}
73
- </div>
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>
74
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>
75
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>
76
159
  </div>
77
160
  )
78
161
  }
@@ -1,10 +1,21 @@
1
1
  // Google Calendar integration (control plane) — Supabase client.
2
2
  //
3
- // Thin client: reads/writes the calendar_integrations row and invokes the
4
- // google-calendar-sync edge function for OAuth connect + manual sync. The
5
- // function holds the OAuth client secret and does the Calendar API work.
6
- import { getSupabaseClientOptional } from '@fayz-ai/core'
7
- import type { CalendarIntegration, CalendarSyncLogEntry } from '../types'
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'
8
19
 
9
20
  const FUNCTION = 'google-calendar-sync'
10
21
 
@@ -14,6 +25,20 @@ function sb() {
14
25
  return supabase
15
26
  }
16
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
+
17
42
  export interface GoogleCalendarProvider {
18
43
  getIntegration(): Promise<CalendarIntegration | null>
19
44
  /** Start the OAuth connect flow — returns the Google consent URL to redirect to. */
@@ -21,14 +46,27 @@ export interface GoogleCalendarProvider {
21
46
  setCalendar(calendarId: string): Promise<void>
22
47
  disconnect(): Promise<void>
23
48
  /** Manually pull external changes now (inbound). */
24
- syncNow(): Promise<{ fetched: number; written: number }>
49
+ syncNow(): Promise<{ fetched: number; written: number; discovered?: number }>
25
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>
26
64
  }
27
65
 
28
66
  export function createGoogleCalendarProvider(): GoogleCalendarProvider {
29
67
  return {
30
68
  async getIntegration() {
31
- const { data } = await sb().from('calendar_integrations').select('*').eq('provider', 'google').limit(1).maybeSingle()
69
+ const { data } = await sb().from(GCAL_TABLES.integrations).select('*').eq('provider', 'google').limit(1).maybeSingle()
32
70
  if (!data) return null
33
71
  return {
34
72
  id: data.id,
@@ -42,33 +80,91 @@ export function createGoogleCalendarProvider(): GoogleCalendarProvider {
42
80
 
43
81
  async getConnectUrl(redirectTo) {
44
82
  const { data, error } = await sb().functions.invoke(FUNCTION, {
45
- body: { action: 'oauth_start', redirectTo: redirectTo ?? window.location.href },
83
+ body: { action: 'oauth_start', tenantId: getActiveTenantId(), redirectTo: redirectTo ?? window.location.href },
46
84
  })
47
85
  if (error) throw new Error(error.message)
48
86
  return (data as { url: string }).url
49
87
  },
50
88
 
51
89
  async setCalendar(calendarId) {
52
- await sb().from('calendar_integrations').update({ calendar_id: calendarId, updated_at: new Date().toISOString() }).eq('provider', 'google')
90
+ await sb().from(GCAL_TABLES.integrations).update({ calendar_id: calendarId, updated_at: new Date().toISOString() }).eq('provider', 'google')
53
91
  },
54
92
 
55
93
  async disconnect() {
56
- await sb().from('calendar_integrations').update({ active: false, oauth_refresh_token: null, oauth_access_token: null }).eq('provider', 'google')
94
+ await sb().from(GCAL_TABLES.integrations).update({ active: false, oauth_refresh_token: null, oauth_access_token: null }).eq('provider', 'google')
57
95
  },
58
96
 
59
97
  async syncNow() {
60
- const { data, error } = await sb().functions.invoke(FUNCTION, { body: { action: 'pull_events', trigger: 'manual' } })
98
+ const { data, error } = await sb().functions.invoke(FUNCTION, { body: { action: 'pull_events', tenantId: getActiveTenantId(), trigger: 'manual' } })
61
99
  if (error) throw new Error(error.message)
62
- return data as { fetched: number; written: number }
100
+ return data as { fetched: number; written: number; discovered?: number }
63
101
  },
64
102
 
65
103
  async getSyncLog() {
66
- const { data } = await sb().from('calendar_sync_log').select('*').order('created_at', { ascending: false }).limit(20)
104
+ const { data } = await sb().from(GCAL_TABLES.syncLog).select('*').order('created_at', { ascending: false }).limit(20)
67
105
  return (data ?? []).map((r: any) => ({
68
106
  id: r.id, direction: r.direction, trigger: r.trigger ?? undefined,
69
107
  status: r.status, fetched: r.fetched ?? 0, written: r.written ?? 0,
70
108
  error: r.error ?? undefined, createdAt: r.created_at,
71
109
  }))
72
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
+ },
73
169
  }
74
170
  }
@@ -0,0 +1,12 @@
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
@@ -14,7 +14,16 @@ import { googleCalendarConnectorDef } from './connectorDef'
14
14
  export { createGoogleCalendarProvider } from './data/supabase'
15
15
  export { googleCalendarConnector } from './connector'
16
16
  export { googleCalendarConnectorDef } from './connectorDef'
17
- export { bookingToEvent, eventToBookingPatch, bookingSummary } from './mapping'
17
+ export {
18
+ bookingToEvent,
19
+ eventToBookingPatch,
20
+ bookingSummary,
21
+ appointmentRowToEvent,
22
+ eventToAppointmentPatch,
23
+ eventTimes,
24
+ resolveTargetChannel,
25
+ channelRowToChannel,
26
+ } from './mapping'
18
27
  export * from './types'
19
28
 
20
29
  // Addon plugin: contributes the Google Calendar connector into its host
@@ -8,8 +8,18 @@
8
8
  import { describe, it, expect } from 'vitest'
9
9
  import { assertConnectorContract } from '@fayz-ai/core/testing'
10
10
  import { googleCalendarConnector } from './connector'
11
- import { bookingToEvent, eventToBookingPatch, bookingSummary } from './mapping'
11
+ import {
12
+ bookingToEvent,
13
+ eventToBookingPatch,
14
+ bookingSummary,
15
+ appointmentRowToEvent,
16
+ eventToAppointmentPatch,
17
+ eventTimes,
18
+ resolveTargetChannel,
19
+ channelRowToChannel,
20
+ } from './mapping'
12
21
  import type { CalendarBooking } from '../../types'
22
+ import type { AppointmentRow, CalendarChannel } from './types'
13
23
 
14
24
  function booking(overrides: Partial<CalendarBooking> = {}): CalendarBooking {
15
25
  return {
@@ -61,6 +71,121 @@ describe('google-calendar · booking↔event mapping (pure, TDD)', () => {
61
71
  const patch = eventToBookingPatch({ id: 'e1', status: 'cancelled', start: {}, end: {} })
62
72
  expect(patch.status).toBe('cancelled')
63
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
+ })
64
189
  })
65
190
 
66
191
  // Data-plane behaviors — require a live Google account + the deployed