From 7f04ecd644f2b382dd9a06ff35d127ff5b5fa229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 26 Feb 2026 17:42:27 +0000 Subject: [PATCH] dock config to postgres, auto-add /email on google oauth, improved email empty state Co-Authored-By: Claude Opus 4.6 --- scripts/add-email-dock-user2.ts | 30 +++++ .../Screens/Dashboard/Email/EmailList.tsx | 35 ++++- src/databases/officer_db/src/index.ts | 25 +++- .../officer_db/src/queries/user-data.ts | 57 ++++++++ src/servers/api/dock/dock.ts | 35 ++--- src/servers/api/integrations/integrations.ts | 124 +++++++++--------- src/servers/data-path.ts | 1 - 7 files changed, 214 insertions(+), 93 deletions(-) create mode 100644 scripts/add-email-dock-user2.ts create mode 100644 src/databases/officer_db/src/queries/user-data.ts diff --git a/scripts/add-email-dock-user2.ts b/scripts/add-email-dock-user2.ts new file mode 100644 index 00000000..30942467 --- /dev/null +++ b/scripts/add-email-dock-user2.ts @@ -0,0 +1,30 @@ +/** + * One-time script: add /email to user 2's dock + * + * Usage: bun run scripts/add-email-dock-user2.ts + */ + +import { getDockPaths, setDockPaths } from 'officerdb'; + +const USER_ID = 2; +const DEFAULT_PATHS = ['/', '/files', '/automation', '/projects', '/workspaces', '/chat']; + +async function main() { + const existing = await getDockPaths(USER_ID); + const paths = existing ?? DEFAULT_PATHS; + + if (paths.includes('/email')) { + console.log(`[dock] User ${USER_ID} already has /email in dock`); + } else { + paths.push('/email'); + await setDockPaths(USER_ID, paths); + console.log(`[dock] Added /email to user ${USER_ID}'s dock: ${JSON.stringify(paths)}`); + } + + process.exit(0); +} + +main().catch((err) => { + console.error('[dock] Failed:', err); + process.exit(1); +}); diff --git a/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx b/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx index dea6f822..d75770f9 100644 --- a/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx @@ -1,12 +1,21 @@ import { useEffect, useRef, useState } from 'react'; +import { Link } from 'react-router'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { ChevronLeft, ChevronRight, Loader2, Mail, Paperclip, RefreshCw } from 'lucide-react'; import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; import { useClient } from 'hooks/useClient'; import { useGlobal } from 'hooks/useGlobal'; import { useJobs } from 'hooks/useJobs'; import type { EmailSummary } from 'types'; +type GoogleStatus = { + configured: boolean; + connected: boolean; + email: string | null; + picture: string | null; +}; + const LIMIT = 50; const formatDate = (iso: string) => { @@ -27,6 +36,11 @@ export const EmailList = () => { const { jobs, createJob } = useJobs({ type: 'gmail-sync' }); const isSyncing = jobs.some((j) => j.status === 'queued' || j.status === 'running'); + const { data: googleStatus } = useQuery({ + queryKey: ['google-status'], + queryFn: () => client.get('/integrations/google/status'), + }); + const { data, isLoading } = useQuery({ queryKey: ['email-messages', page], queryFn: () => @@ -65,9 +79,26 @@ export const EmailList = () => { if (messages.length === 0 && page === 1) { return ( -
+
- No emails found + {googleStatus && !googleStatus.configured ? ( + Google integration not configured. Contact your administrator. + ) : googleStatus && !googleStatus.connected ? ( +
+ Connect your Google account to sync emails + +
+ ) : ( +
+ No emails synced yet + +
+ )}
); } diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index 3515d22b..86408953 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -1,5 +1,4 @@ export { - initAuthStore, getUsers, getUserById, getUserByEmail, @@ -7,8 +6,8 @@ export { createUser, updateUser, deleteUser, - getPasskeysByEmail, - getPasskeysByEmailAndOrigin, + getPasskeysByUserId, + getPasskeysByUserIdAndOrigin, getPasskeyByCredentialId, createPasskey, updatePasskey, @@ -17,4 +16,22 @@ export { blacklistToken, isTokenBlacklisted, cleanupExpiredTokens, -} from './store'; +} from './queries/auth'; + +export { readServerSettings, writeServerSettings } from './queries/server-config'; + +export { getUserSettings, setUserSettings, getUserState, patchUserState, getDockPaths, setDockPaths } from './queries/user-data'; + +export { + getServerIntegrations, + getServerIntegration, + upsertServerIntegration, + deleteServerIntegration, + getUserIntegrations, + getUserIntegration, + upsertUserIntegration, + deleteUserIntegration, +} from './queries/integrations'; + +export { db } from './db'; +export * as schema from './schema'; diff --git a/src/databases/officer_db/src/queries/user-data.ts b/src/databases/officer_db/src/queries/user-data.ts new file mode 100644 index 00000000..3d72276a --- /dev/null +++ b/src/databases/officer_db/src/queries/user-data.ts @@ -0,0 +1,57 @@ +import { eq } from 'drizzle-orm'; +import { db } from '../db'; +import { dockConfigs, userSettings, userState } from '../schema'; + +// ── User Settings ── + +export async function getUserSettings(userId: number): Promise> { + const [row] = await db.select().from(userSettings).where(eq(userSettings.userId, userId)); + return (row?.settings as Record) ?? {}; +} + +export async function setUserSettings(userId: number, settings: Record): Promise { + await db + .insert(userSettings) + .values({ userId, settings, updatedAt: new Date() }) + .onConflictDoUpdate({ + target: userSettings.userId, + set: { settings, updatedAt: new Date() }, + }); +} + +// ── User State ── + +export async function getUserState(userId: number): Promise> { + const [row] = await db.select().from(userState).where(eq(userState.userId, userId)); + return (row?.state as Record) ?? {}; +} + +export async function patchUserState(userId: number, patch: Record): Promise> { + const current = await getUserState(userId); + const merged = { ...current, ...patch }; + await db + .insert(userState) + .values({ userId, state: merged, updatedAt: new Date() }) + .onConflictDoUpdate({ + target: userState.userId, + set: { state: merged, updatedAt: new Date() }, + }); + return merged; +} + +// ── Dock Config ── + +export async function getDockPaths(userId: number): Promise { + const [row] = await db.select().from(dockConfigs).where(eq(dockConfigs.userId, userId)); + return (row?.paths as string[]) ?? null; +} + +export async function setDockPaths(userId: number, paths: string[]): Promise { + await db + .insert(dockConfigs) + .values({ userId, paths, updatedAt: new Date() }) + .onConflictDoUpdate({ + target: dockConfigs.userId, + set: { paths, updatedAt: new Date() }, + }); +} diff --git a/src/servers/api/dock/dock.ts b/src/servers/api/dock/dock.ts index 61e72bb0..d5929f34 100644 --- a/src/servers/api/dock/dock.ts +++ b/src/servers/api/dock/dock.ts @@ -1,36 +1,19 @@ -import { mkdir } from 'node:fs/promises'; -import { dirname } from 'node:path'; import { createRouter } from '../../create-router'; -import { getUserDockFile } from '@@/data-path'; - -const ensureDir = (filePath: string) => mkdir(dirname(filePath), { recursive: true }); +import { getDockPaths, setDockPaths } from 'officerdb'; export const dockRouter = createRouter(); -// GET / — return dock.json, or null if it doesn't exist (frontend uses defaults) +// GET / — return dock paths, or null if none saved (frontend uses defaults) dockRouter.get('/', async (ctx) => { - const email = ctx.get('user').email; - const filePath = getUserDockFile(email); - const file = Bun.file(filePath); - - if (await file.exists()) { - try { - return ctx.json(await file.json()); - } catch { - // corrupted file — treat as missing - } - } - - return ctx.json(null); + const userId = ctx.get('user').id; + const paths = await getDockPaths(userId); + return ctx.json(paths); }); // PUT / — full replacement of dock paths array dockRouter.put('/', async (ctx) => { - const email = ctx.get('user').email; - const body = ctx.get('body'); - const filePath = getUserDockFile(email); - - await ensureDir(filePath); - await Bun.write(filePath, JSON.stringify(body, null, 2)); - return ctx.json(body); + const userId = ctx.get('user').id; + const paths = ctx.get('body') as string[]; + await setDockPaths(userId, paths); + return ctx.json(paths); }); diff --git a/src/servers/api/integrations/integrations.ts b/src/servers/api/integrations/integrations.ts index 209f86da..275a0cf7 100644 --- a/src/servers/api/integrations/integrations.ts +++ b/src/servers/api/integrations/integrations.ts @@ -1,10 +1,15 @@ -import { mkdir } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; import { createRouter } from '../../create-router'; -import { DATA_PATH, SERVER_CONFIG_DIR } from '@@/data-path'; -import { CustomError } from '../../custom-errors'; - -const googleConfigPath = join(SERVER_CONFIG_DIR, 'google-oauth.json'); +import { FORBIDDEN, BAD_REQUEST } from '../../custom-errors'; +import { + getServerIntegration, + upsertServerIntegration, + getUserByEmail, + getUserIntegration, + upsertUserIntegration, + deleteUserIntegration, + getDockPaths, + setDockPaths, +} from 'officerdb'; const GOOGLE_SCOPES = [ 'https://www.googleapis.com/auth/gmail.readonly', @@ -12,30 +17,14 @@ const GOOGLE_SCOPES = [ 'https://www.googleapis.com/auth/userinfo.email', ]; -const ensureDir = (filePath: string) => mkdir(dirname(filePath), { recursive: true }); +type GoogleConfig = { clientId: string; clientSecret: string }; -export const readGoogleConfig = async () => { - try { - return await Bun.file(googleConfigPath).json(); - } catch { - return null; - } -}; - -const getUserGoogleFile = (email: string) => join(DATA_PATH, email, 'integrations', 'google.json'); - -const readUserGoogle = async (email: string) => { - try { - return await Bun.file(getUserGoogleFile(email)).json(); - } catch { - return null; - } -}; - -const writeUserGoogle = async (email: string, data: Record) => { - const filePath = getUserGoogleFile(email); - await ensureDir(filePath); - await Bun.write(filePath, JSON.stringify(data, null, 2)); +export const readGoogleConfig = async (): Promise => { + const integration = await getServerIntegration('google'); + if (!integration) return null; + const config = integration.config as Record; + if (!config.clientId || !config.clientSecret) return null; + return config as unknown as GoogleConfig; }; export const integrationsRouter = createRouter(); @@ -48,33 +37,30 @@ integrationsRouter.get('/', async (ctx) => { integrationsRouter.get('/google/config', async (ctx) => { const user = ctx.get('user'); - if (user.role !== 'Super Admin') throw new CustomError('Forbidden', 403); + if (user.role !== 'Super Admin') throw FORBIDDEN(); return ctx.json(await readGoogleConfig()); }); integrationsRouter.put('/google/config', async (ctx) => { const user = ctx.get('user'); - if (user.role !== 'Super Admin') throw new CustomError('Forbidden', 403); + if (user.role !== 'Super Admin') throw FORBIDDEN(); const body = ctx.get('body') as { clientId?: string; clientSecret?: string }; const config = { clientId: body.clientId ?? '', clientSecret: body.clientSecret ?? '' }; - await ensureDir(googleConfigPath); - await Bun.write(googleConfigPath, JSON.stringify(config, null, 2)); + await upsertServerIntegration('google', config); return ctx.json(config); }); integrationsRouter.get('/google/verify', async (ctx) => { const user = ctx.get('user'); - if (user.role !== 'Super Admin') throw new CustomError('Forbidden', 403); + if (user.role !== 'Super Admin') throw FORBIDDEN(); const config = await readGoogleConfig(); if (!config?.clientId || !config?.clientSecret) { return ctx.json({ valid: false, error: 'Missing credentials' }); } - // Send a dummy token exchange — valid credentials return "invalid_grant", - // invalid credentials return "invalid_client" const res = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, @@ -96,27 +82,22 @@ integrationsRouter.get('/google/verify', async (ctx) => { // --- Personal: Google account connection status --- integrationsRouter.get('/google/status', async (ctx) => { - const email = ctx.get('user').email; + const user = ctx.get('user'); const config = await readGoogleConfig(); - const connection = await readUserGoogle(email); + const connection = await getUserIntegration(user.id, 'google'); + const connConfig = connection?.config as Record | undefined; return ctx.json({ configured: !!(config?.clientId && config?.clientSecret), - connected: !!connection?.accessToken, - email: connection?.email ?? null, - picture: connection?.picture ?? null, + connected: !!connConfig?.accessToken, + email: connConfig?.email ?? null, + picture: connConfig?.picture ?? null, }); }); integrationsRouter.delete('/google/connection', async (ctx) => { - const email = ctx.get('user').email; - const filePath = getUserGoogleFile(email); - - const file = Bun.file(filePath); - if (await file.exists()) { - await Bun.write(filePath, '{}'); - } - + const user = ctx.get('user'); + await deleteUserIntegration(user.id, 'google'); return ctx.json({ ok: true }); }); @@ -125,12 +106,12 @@ integrationsRouter.delete('/google/connection', async (ctx) => { integrationsRouter.get('/google/authorize', async (ctx) => { const config = await readGoogleConfig(); if (!config?.clientId || !config?.clientSecret) { - throw new CustomError('Google OAuth not configured', 400); + throw BAD_REQUEST('Google OAuth not configured'); } const email = ctx.get('user').email; const origin = ctx.req.query('origin'); - if (!origin) throw new CustomError('Missing origin parameter', 400); + if (!origin) throw BAD_REQUEST('Missing origin parameter'); const redirectUri = `${origin}/api/integrations/google/callback`; const state = Buffer.from(JSON.stringify({ email, redirectUri })).toString('base64url'); @@ -174,7 +155,6 @@ export const googleCallbackHandler = async (ctx: any) => { return ctx.redirect('/settings/integrations?google=error'); } - // Exchange code for tokens const tokenResponse = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, @@ -194,7 +174,6 @@ export const googleCallbackHandler = async (ctx: any) => { const tokens = await tokenResponse.json(); - // Fetch the user's Google email const userinfoResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { headers: { Authorization: `Bearer ${tokens.access_token}` }, }); @@ -207,14 +186,39 @@ export const googleCallbackHandler = async (ctx: any) => { picture = userinfo.picture ?? null; } - await writeUserGoogle(email, { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresAt: Date.now() + tokens.expires_in * 1000, - email: googleEmail, - picture, - scope: tokens.scope, + const dbUser = await getUserByEmail(email); + if (!dbUser) { + return ctx.redirect('/settings/integrations?google=error'); + } + + const serverIntegration = await getServerIntegration('google'); + + await upsertUserIntegration({ + userId: dbUser.id, + provider: 'google', + serverIntegrationId: serverIntegration?.id, + config: { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt: Date.now() + tokens.expires_in * 1000, + email: googleEmail, + picture, + scope: tokens.scope, + }, }); + // Auto-add /email to dock + try { + const existing = await getDockPaths(dbUser.id); + const paths = existing ?? ['/', '/files', '/automation', '/projects', '/workspaces', '/chat']; + + if (!paths.includes('/email')) { + paths.push('/email'); + await setDockPaths(dbUser.id, paths); + } + } catch { + // Non-critical — don't block the OAuth flow + } + return ctx.redirect('/settings/integrations?google=success'); }; diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts index 11fe558b..2e48c679 100644 --- a/src/servers/data-path.ts +++ b/src/servers/data-path.ts @@ -89,6 +89,5 @@ export const getTmpAttachmentsDir = (email: string) => join(DATA_PATH, email, 'c export const getAttachmentsDir = (email: string, provider: 'claude' | 'opencode' | 'pi-mono', sessionId: string) => join(DATA_PATH, email, 'chat_sessions', provider, sessionId, 'attachments'); -export const getUserDockFile = (email: string) => join(DATA_PATH, email, 'dock', 'dock.json'); export const getUserEmailDir = (email: string) => join(DATA_PATH, email, 'Gmail', 'emails');