diff --git a/src/lib/notion/services/metadataSync.ts b/src/lib/notion/services/metadataSync.ts new file mode 100644 index 000000000..f81955489 --- /dev/null +++ b/src/lib/notion/services/metadataSync.ts @@ -0,0 +1,45 @@ +/** + * Send Notion user metadata to the backend via the + * `integration:metadata-sync` socket event so the server can merge it + * into the user's Notion OAuth integration metadata. + */ +import { emitViaRustSocket } from '../../../utils/tauriSocket'; + +const INTEGRATION_METADATA_SYNC_EVENT = 'integration:metadata-sync'; +const PROVIDER_NOTION = 'notion'; + +export interface NotionUserProfileLike { + id: string; + name?: string | null; + email?: string | null; + type?: string | null; + avatar_url?: string | null; +} + +/** + * Emit `integration:metadata-sync` with Notion user profile so the + * backend can merge it into the user's Notion OAuth integration. + * No-op when profile is missing or invalid. + */ +export function syncNotionMetadataToBackend( + profile: NotionUserProfileLike | null | undefined +): void { + if (!profile || !profile.id) return; + + const metadata: Record = { + id: profile.id, + name: profile.name ?? null, + email: profile.email ?? null, + type: profile.type ?? null, + avatar_url: profile.avatar_url ?? null, + }; + + const payload = { + requestId: crypto.randomUUID(), + provider: PROVIDER_NOTION, + metadata, + }; + + void emitViaRustSocket(INTEGRATION_METADATA_SYNC_EVENT, payload); +} + diff --git a/src/providers/SkillProvider.tsx b/src/providers/SkillProvider.tsx index e9969fca5..0de405348 100644 --- a/src/providers/SkillProvider.tsx +++ b/src/providers/SkillProvider.tsx @@ -12,10 +12,12 @@ import { type GmailProfileLike, syncGmailMetadataToBackend, } from '../lib/gmail/services/metadataSync'; +import { syncNotionMetadataToBackend } from '../lib/notion/services/metadataSync'; import { skillManager } from '../lib/skills/manager'; import type { SkillManifest } from '../lib/skills/types'; import { buildManualSentryEvent, enqueueError } from '../services/errorReportQueue'; import { type GmailProfile, setGmailProfile } from '../store/gmailSlice'; +import { setNotionProfile, type NotionUserProfile } from '../store/notionSlice'; import { useAppDispatch, useAppSelector } from '../store/hooks'; import { setSkillError, setSkillState, setSkillStatus } from '../store/skillsSlice'; import { DEV_AUTO_LOAD_SKILL, IS_DEV } from '../utils/config'; @@ -86,12 +88,40 @@ function syncGmailStateToSlice( syncGmailMetadataToBackend(gmailState.profile as GmailProfileLike); } +async function syncNotionUserOnConnect( + dispatch: ReturnType +): Promise { + try { + const toolResult = await skillManager.callTool('notion', 'get-user', { user_id: 'me' }); + if (!toolResult || toolResult.isError || toolResult.content.length === 0) { + console.warn('[SkillProvider] Notion get-user tool returned no content or an error'); + return; + } + const first = toolResult.content[0]; + const raw = first?.text; + if (!raw) return; + + const parsed = JSON.parse(raw) as NotionUserProfile | { error?: string }; + if ('error' in parsed && parsed.error) { + console.warn('[SkillProvider] Notion get-user error:', parsed.error); + return; + } + + const profile = parsed as NotionUserProfile; + dispatch(setNotionProfile(profile)); + syncNotionMetadataToBackend(profile); + } catch (e) { + console.error('[SkillProvider] Failed to call Notion get-user tool after connect:', e); + } +} + export default function SkillProvider({ children }: { children: ReactNode }) { const { token } = useAppSelector(state => state.auth); const skillsState = useAppSelector(state => state.skills.skills); const skillStates = useAppSelector(state => state.skills.skillStates); const dispatch = useAppDispatch(); const initRef = useRef(false); + const lastNotionConnectionStatusRef = useRef(undefined); // Keep gmailSlice in sync with skills.skillStates.gmail (event handler + rehydration) const gmailSkillState = skillStates?.gmail as Record | undefined; @@ -100,6 +130,20 @@ export default function SkillProvider({ children }: { children: ReactNode }) { syncGmailStateToSlice(gmailSkillState, dispatch); }, [gmailSkillState, dispatch]); + // When Notion connection_status transitions to "connected", fetch the current user + // via the notion get-user tool, store it in notionSlice, and sync metadata to backend. + const notionSkillState = skillStates?.notion as Record | undefined; + useEffect(() => { + if (!notionSkillState || typeof notionSkillState !== 'object') return; + const connectionStatus = notionSkillState.connection_status as string | undefined; + const prev = lastNotionConnectionStatusRef.current; + lastNotionConnectionStatusRef.current = connectionStatus; + + if (connectionStatus === 'connected' && prev !== 'connected') { + void syncNotionUserOnConnect(dispatch); + } + }, [notionSkillState, dispatch]); + // Listen for skill state changes emitted from the Rust runtime event loop useEffect(() => { let unlisten: (() => void) | undefined; diff --git a/src/store/index.ts b/src/store/index.ts index b0bc1f0e1..93ee95eeb 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -20,6 +20,7 @@ import authReducer, { setOnboardedForUser, setToken } from './authSlice'; import daemonReducer from './daemonSlice'; import gmailReducer from './gmailSlice'; import inviteReducer from './inviteSlice'; +import notionReducer from './notionSlice'; import skillsReducer from './skillsSlice'; import socketReducer from './socketSlice'; import teamReducer from './teamSlice'; @@ -96,6 +97,7 @@ export const store = configureStore({ team: teamReducer, thread: persistedThreadReducer, invite: inviteReducer, + notion: notionReducer, }, middleware: getDefaultMiddleware => { const middleware = getDefaultMiddleware({ diff --git a/src/store/notionSlice.ts b/src/store/notionSlice.ts new file mode 100644 index 000000000..d824c6d7c --- /dev/null +++ b/src/store/notionSlice.ts @@ -0,0 +1,33 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; + +export interface NotionUserProfile { + id: string; + name?: string | null; + email?: string | null; + type?: string | null; + avatar_url?: string | null; +} + +interface NotionState { + /** Profile of the connected Notion user (from Notion skill) */ + profile: NotionUserProfile | null; +} + +const initialState: NotionState = { profile: null }; + +const notionSlice = createSlice({ + name: 'notion', + initialState, + reducers: { + setNotionProfile(state, action: PayloadAction) { + state.profile = action.payload; + }, + clearNotionProfile(state) { + state.profile = null; + }, + }, +}); + +export const { setNotionProfile, clearNotionProfile } = notionSlice.actions; +export default notionSlice.reducer; + diff --git a/src/utils/desktopDeepLinkListener.ts b/src/utils/desktopDeepLinkListener.ts index ecce1b63d..4a0c55406 100644 --- a/src/utils/desktopDeepLinkListener.ts +++ b/src/utils/desktopDeepLinkListener.ts @@ -1,12 +1,12 @@ import { isTauri as coreIsTauri, invoke } from '@tauri-apps/api/core'; import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link'; -import { skillManager } from '../lib/skills/manager'; import { consumeLoginToken, fetchIntegrationTokens } from '../services/api/authApi'; import { buildManualSentryEvent, enqueueError } from '../services/errorReportQueue'; import { store } from '../store'; import { setToken } from '../store/authSlice'; import { setSkillState } from '../store/skillsSlice'; +import { skillManager } from '../lib/skills/manager'; import { decryptIntegrationTokens, hexToBase64, @@ -219,7 +219,8 @@ const handleOAuthDeepLink = async (parsed: URL) => { }) ); - // For Gmail, pass decrypted access token so the skill uses it instead of the proxy + // For OAuth-capable skills (e.g. Gmail, Notion), pass decrypted access token so the + // skill can use it directly when supported instead of always going through the proxy. let extraCredential: { accessToken?: string } | undefined; try { @@ -229,7 +230,7 @@ const handleOAuthDeepLink = async (parsed: URL) => { extraCredential = { accessToken: payload.accessToken }; } } catch (e) { - console.warn('[DeepLink] Could not decrypt Gmail token for skill:', e); + console.warn('[DeepLink] Could not decrypt integration token for skill:', e); } await skillManager.notifyOAuthComplete(skillId, integrationId, undefined, extraCredential);