feat: implement Notion metadata synchronization and user profile management

- Added a new service `metadataSync.ts` to handle synchronization of Notion user metadata to the backend via the `integration:metadata-sync` socket event.
- Introduced a new Redux slice `notionSlice.ts` for managing the Notion user profile state.
- Updated `SkillProvider` to fetch and sync Notion user profile upon connection, ensuring seamless integration with the backend.
- Enhanced the store configuration to include the new Notion reducer.
This commit is contained in:
M3gA-Mind
2026-03-02 21:17:51 +05:30
parent fef36f963d
commit 3627dc1ef2
5 changed files with 128 additions and 3 deletions
+45
View File
@@ -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<string, unknown> = {
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);
}
+44
View File
@@ -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<typeof useAppDispatch>
): Promise<void> {
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<string | undefined>(undefined);
// Keep gmailSlice in sync with skills.skillStates.gmail (event handler + rehydration)
const gmailSkillState = skillStates?.gmail as Record<string, unknown> | 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<string, unknown> | 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;
+2
View File
@@ -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({
+33
View File
@@ -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<NotionUserProfile | null>) {
state.profile = action.payload;
},
clearNotionProfile(state) {
state.profile = null;
},
},
});
export const { setNotionProfile, clearNotionProfile } = notionSlice.actions;
export default notionSlice.reducer;
+4 -3
View File
@@ -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);