@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.
- package/dist/{chunk-LFUELUIT.cjs → chunk-3KWNHV5A.cjs} +60 -2
- package/dist/chunk-3KWNHV5A.cjs.map +1 -0
- package/dist/{chunk-DRZF6CV2.js → chunk-YQO6B4G3.js} +60 -3
- package/dist/chunk-YQO6B4G3.js.map +1 -0
- package/dist/components/AgendaGeneralSettings.d.ts.map +1 -1
- package/dist/components/AppointmentModal.d.ts.map +1 -1
- package/dist/components/CalendarToolbar.d.ts.map +1 -1
- package/dist/data/supabase.d.ts +5 -1
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/index.cjs +1071 -766
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +914 -609
- package/dist/index.js.map +1 -1
- package/dist/integrations/google-calendar/components/CalendarColorDot.d.ts +6 -0
- package/dist/integrations/google-calendar/components/CalendarColorDot.d.ts.map +1 -0
- package/dist/integrations/google-calendar/components/ChannelMappingList.d.ts +9 -0
- package/dist/integrations/google-calendar/components/ChannelMappingList.d.ts.map +1 -0
- package/dist/integrations/google-calendar/components/ImportEventsWizard.d.ts +12 -0
- package/dist/integrations/google-calendar/components/ImportEventsWizard.d.ts.map +1 -0
- package/dist/integrations/google-calendar/components/useAgendaLookups.d.ts +26 -0
- package/dist/integrations/google-calendar/components/useAgendaLookups.d.ts.map +1 -0
- package/dist/integrations/google-calendar/connectorDef.d.ts.map +1 -1
- package/dist/integrations/google-calendar/data/supabase.d.ts +33 -1
- package/dist/integrations/google-calendar/data/supabase.d.ts.map +1 -1
- package/dist/integrations/google-calendar/data/tables.d.ts +6 -0
- package/dist/integrations/google-calendar/data/tables.d.ts.map +1 -0
- package/dist/integrations/google-calendar/index.d.ts +1 -1
- package/dist/integrations/google-calendar/index.d.ts.map +1 -1
- package/dist/integrations/google-calendar/mapping.d.ts +56 -4
- package/dist/integrations/google-calendar/mapping.d.ts.map +1 -1
- package/dist/integrations/google-calendar/types.d.ts +101 -0
- package/dist/integrations/google-calendar/types.d.ts.map +1 -1
- package/dist/locales/en.d.ts.map +1 -1
- package/dist/locales/pt-BR.d.ts.map +1 -1
- package/dist/migrations/index.d.ts +11 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/public/BookingWidget.d.ts.map +1 -1
- package/dist/public/context.d.ts +3 -1
- package/dist/public/context.d.ts.map +1 -1
- package/dist/public/createPublicBookingPlugin.d.ts +7 -1
- package/dist/public/createPublicBookingPlugin.d.ts.map +1 -1
- package/dist/public/data.d.ts +6 -0
- package/dist/public/data.d.ts.map +1 -1
- package/dist/public/data.supabase.d.ts.map +1 -1
- package/dist/public/format.d.ts +22 -1
- package/dist/public/format.d.ts.map +1 -1
- package/dist/public/hooks.d.ts.map +1 -1
- package/dist/public/index.cjs +31 -63
- package/dist/public/index.cjs.map +1 -1
- package/dist/public/index.d.ts +1 -1
- package/dist/public/index.d.ts.map +1 -1
- package/dist/public/index.js +27 -61
- package/dist/public/index.js.map +1 -1
- package/dist/public/steps/ContactStep.d.ts +4 -2
- package/dist/public/steps/ContactStep.d.ts.map +1 -1
- package/dist/public/types.d.ts +16 -0
- package/dist/public/types.d.ts.map +1 -1
- package/dist/views/CalendarView.d.ts.map +1 -1
- package/package.json +6 -5
- package/src/components/AgendaGeneralSettings.tsx +38 -16
- package/src/components/AppointmentModal.tsx +50 -108
- package/src/components/CalendarToolbar.tsx +10 -7
- package/src/data/supabase.ts +67 -13
- package/src/index.ts +56 -8
- package/src/integrations/google-calendar/components/CalendarColorDot.tsx +23 -0
- package/src/integrations/google-calendar/components/ChannelMappingList.tsx +329 -0
- package/src/integrations/google-calendar/components/ImportEventsWizard.tsx +215 -0
- package/src/integrations/google-calendar/components/useAgendaLookups.ts +100 -0
- package/src/integrations/google-calendar/connectorDef.tsx +123 -40
- package/src/integrations/google-calendar/data/supabase.ts +109 -13
- package/src/integrations/google-calendar/data/tables.ts +12 -0
- package/src/integrations/google-calendar/index.ts +10 -1
- package/src/integrations/google-calendar/integration.test.ts +126 -1
- package/src/integrations/google-calendar/mapping.ts +149 -9
- package/src/integrations/google-calendar/migrations/001_google_calendar.sql +9 -6
- package/src/integrations/google-calendar/migrations/002_smart_sync.sql +517 -0
- package/src/integrations/google-calendar/migrations/003_plg_rename.sql +273 -0
- package/src/integrations/google-calendar/types.ts +111 -0
- package/src/locales/en.ts +1 -0
- package/src/locales/pt-BR.ts +1 -0
- package/src/migrations/003_public_client_kind.sql +175 -0
- package/src/migrations/004_public_booking_assignee.sql +176 -0
- package/src/migrations/005_agent_rpcs.sql +168 -0
- package/src/migrations/006_agent_rpc_localtime.sql +181 -0
- package/src/migrations/007_schedule_exception_precedence.sql +290 -0
- package/src/migrations/008_agent_rpc_exception_precedence.sql +172 -0
- package/src/migrations/009_public_booking_location.sql +162 -0
- package/src/migrations/010_public_service_curation.sql +31 -0
- package/src/migrations/011_default_assignee.sql +167 -0
- package/src/migrations/012_drop_legacy_booking_overload.sql +29 -0
- package/src/migrations/013_normalize_v_appointments.sql +161 -0
- package/src/migrations/index.ts +1746 -1
- package/src/public/BookingWidget.tsx +10 -1
- package/src/public/context.tsx +3 -0
- package/src/public/createPublicBookingPlugin.ts +14 -1
- package/src/public/data.supabase.ts +3 -0
- package/src/public/data.ts +6 -0
- package/src/public/format.ts +36 -1
- package/src/public/hooks.ts +7 -2
- package/src/public/index.ts +1 -0
- package/src/public/steps/ContactStep.tsx +10 -5
- package/src/public/types.ts +21 -0
- package/src/views/CalendarView.tsx +15 -1
- package/dist/chunk-3LJKZQK4.js +0 -12
- package/dist/chunk-3LJKZQK4.js.map +0 -1
- package/dist/chunk-DRZF6CV2.js.map +0 -1
- package/dist/chunk-LFUELUIT.cjs.map +0 -1
- package/dist/chunk-SYDBDI3Q.cjs +0 -15
- package/dist/chunk-SYDBDI3Q.cjs.map +0 -1
- package/dist/components/SearchCombobox.d.ts +0 -38
- package/dist/components/SearchCombobox.d.ts.map +0 -1
- package/dist/tenant-222EHYUC.cjs +0 -16
- package/dist/tenant-222EHYUC.cjs.map +0 -1
- package/dist/tenant-LAUWVUYB.js +0 -3
- package/dist/tenant-LAUWVUYB.js.map +0 -1
- package/src/components/SearchCombobox.tsx +0 -216
|
@@ -0,0 +1,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 [
|
|
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) {
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 {
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 {
|
|
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 {
|
|
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
|