diff --git a/backend/src/__tests__/feedback.test.ts b/backend/src/__tests__/feedback.test.ts index bc7f6fe..eac473c 100644 --- a/backend/src/__tests__/feedback.test.ts +++ b/backend/src/__tests__/feedback.test.ts @@ -57,12 +57,14 @@ describe('Feedback Collection API and Service', () => { }) .expect(429); - expect(res.body).toEqual({ error: 'Rate limit exceeded: 100 feedbacks per day' }); + expect(res.body).toEqual({ + error: 'Rate limit exceeded: 100 feedbacks per day', + }); }); it('should successfully record feedback and return 201', async () => { jest.spyOn(prisma.userFeedback, 'count').mockResolvedValue(45); - + const recordFeedbackSpy = jest .spyOn(FeedbackCollectorService, 'recordFeedback') .mockResolvedValue(undefined); @@ -76,15 +78,18 @@ describe('Feedback Collection API and Service', () => { }) .expect(201); - expect(recordFeedbackSpy).toHaveBeenCalledWith(mockUserId, mockEmailId, 'thumbs_up', undefined); + expect(recordFeedbackSpy).toHaveBeenCalledWith( + mockUserId, + mockEmailId, + 'thumbs_up', + undefined + ); }); }); describe('GET /api/users/me/ai-profile', () => { it('should return 401 if unauthorized', async () => { - await request(app) - .get('/api/users/me/ai-profile') - .expect(401); + await request(app).get('/api/users/me/ai-profile').expect(401); }); it('should return empty weekly profile if settings do not exist', async () => { @@ -130,11 +135,20 @@ describe('Feedback Collection API and Service', () => { describe('FeedbackCollectorService logic', () => { it('should handle deleted emails gracefully without crashing', async () => { - jest.spyOn(FeedbackCollectorService.prisma.email, 'findUnique').mockResolvedValue(null); - const userFeedbackCreateSpy = jest.spyOn(FeedbackCollectorService.prisma.userFeedback, 'create'); + jest + .spyOn(FeedbackCollectorService.prisma.email, 'findUnique') + .mockResolvedValue(null); + const userFeedbackCreateSpy = jest.spyOn( + FeedbackCollectorService.prisma.userFeedback, + 'create' + ); await expect( - FeedbackCollectorService.recordFeedback(mockUserId, 'non-existent-email', 'thumbs_up') + FeedbackCollectorService.recordFeedback( + mockUserId, + 'non-existent-email', + 'thumbs_up' + ) ).resolves.not.toThrow(); expect(userFeedbackCreateSpy).not.toHaveBeenCalled(); @@ -162,18 +176,35 @@ describe('Feedback Collection API and Service', () => { }), }; - jest.spyOn(FeedbackCollectorService.prisma.email, 'findUnique').mockResolvedValue(mockEmail as any); - jest.spyOn(FeedbackCollectorService.prisma.userFeedback, 'create').mockResolvedValue({} as any); - jest.spyOn(FeedbackCollectorService.prisma.userSettings, 'findUnique').mockResolvedValue(mockSettings as any); - const settingsUpdateSpy = jest.spyOn(FeedbackCollectorService.prisma.userSettings, 'update').mockResolvedValue({} as any); - - await FeedbackCollectorService.recordFeedback(mockUserId, mockEmailId, 'category_correction', 'urgent'); + jest + .spyOn(FeedbackCollectorService.prisma.email, 'findUnique') + .mockResolvedValue(mockEmail as any); + jest + .spyOn(FeedbackCollectorService.prisma.userFeedback, 'create') + .mockResolvedValue({} as any); + jest + .spyOn(FeedbackCollectorService.prisma.userSettings, 'findUnique') + .mockResolvedValue(mockSettings as any); + const settingsUpdateSpy = jest + .spyOn(FeedbackCollectorService.prisma.userSettings, 'update') + .mockResolvedValue({} as any); + + await FeedbackCollectorService.recordFeedback( + mockUserId, + mockEmailId, + 'category_correction', + 'urgent' + ); expect(settingsUpdateSpy).toHaveBeenCalled(); - const updatedData = JSON.parse(settingsUpdateSpy.mock.calls[0][0].data.aiPreferenceProfile as string); - + const updatedData = JSON.parse( + settingsUpdateSpy.mock.calls[0][0].data.aiPreferenceProfile as string + ); + const weekKey = FeedbackCollectorService.getStartOfWeek(); - expect(updatedData.weekly[weekKey].categoryCorrections['newsletter->urgent']).toBe(2); + expect( + updatedData.weekly[weekKey].categoryCorrections['newsletter->urgent'] + ).toBe(2); }); it('should incrementally update preferred senders on thumbs_up', async () => { @@ -192,18 +223,34 @@ describe('Feedback Collection API and Service', () => { aiPreferenceProfile: null, }; - jest.spyOn(FeedbackCollectorService.prisma.email, 'findUnique').mockResolvedValue(mockEmail as any); - jest.spyOn(FeedbackCollectorService.prisma.userFeedback, 'create').mockResolvedValue({} as any); - jest.spyOn(FeedbackCollectorService.prisma.userSettings, 'findUnique').mockResolvedValue(mockSettings as any); - const settingsUpdateSpy = jest.spyOn(FeedbackCollectorService.prisma.userSettings, 'update').mockResolvedValue({} as any); - - await FeedbackCollectorService.recordFeedback(mockUserId, mockEmailId, 'thumbs_up'); + jest + .spyOn(FeedbackCollectorService.prisma.email, 'findUnique') + .mockResolvedValue(mockEmail as any); + jest + .spyOn(FeedbackCollectorService.prisma.userFeedback, 'create') + .mockResolvedValue({} as any); + jest + .spyOn(FeedbackCollectorService.prisma.userSettings, 'findUnique') + .mockResolvedValue(mockSettings as any); + const settingsUpdateSpy = jest + .spyOn(FeedbackCollectorService.prisma.userSettings, 'update') + .mockResolvedValue({} as any); + + await FeedbackCollectorService.recordFeedback( + mockUserId, + mockEmailId, + 'thumbs_up' + ); expect(settingsUpdateSpy).toHaveBeenCalled(); - const updatedData = JSON.parse(settingsUpdateSpy.mock.calls[0][0].data.aiPreferenceProfile as string); - + const updatedData = JSON.parse( + settingsUpdateSpy.mock.calls[0][0].data.aiPreferenceProfile as string + ); + const weekKey = FeedbackCollectorService.getStartOfWeek(); - expect(updatedData.weekly[weekKey].preferredSenders['sender@example.com']).toBe(1); + expect( + updatedData.weekly[weekKey].preferredSenders['sender@example.com'] + ).toBe(1); }); }); }); diff --git a/backend/src/check_emails.ts b/backend/src/check_emails.ts index c9b49d6..f33ba49 100644 --- a/backend/src/check_emails.ts +++ b/backend/src/check_emails.ts @@ -8,16 +8,19 @@ const prisma = new PrismaClient(); async function main() { const userId = 'cba1c39e-dbfb-4f1a-9a58-bc1d93240140'; - + const user = await prisma.user.findUnique({ where: { id: userId }, include: { settings: true, emailAccounts: true, - } + }, }); console.log('User settings:', JSON.stringify(user?.settings, null, 2)); - console.log('Email accounts linked:', JSON.stringify(user?.emailAccounts, null, 2)); + console.log( + 'Email accounts linked:', + JSON.stringify(user?.emailAccounts, null, 2) + ); const emails = await prisma.email.findMany({ where: { userId }, @@ -26,14 +29,24 @@ async function main() { include: { analysis: true, actionItems: true, - } + }, }); console.log(`Found ${emails.length} emails. Details:`); for (const e of emails) { - console.log(`- Subject: "${e.subject}" from "${e.sender}" status: "${e.status}"`); - console.log(` Analysis:`, e.analysis ? `Category: ${e.analysis.category}, Priority: ${e.analysis.priorityScore}` : 'None'); - console.log(` Action Items (${e.actionItems.length}):`, e.actionItems.map(ai => ai.taskDescription)); + console.log( + `- Subject: "${e.subject}" from "${e.sender}" status: "${e.status}"` + ); + console.log( + ` Analysis:`, + e.analysis + ? `Category: ${e.analysis.category}, Priority: ${e.analysis.priorityScore}` + : 'None' + ); + console.log( + ` Action Items (${e.actionItems.length}):`, + e.actionItems.map((ai) => ai.taskDescription) + ); } } diff --git a/backend/src/config/swagger.ts b/backend/src/config/swagger.ts index 08ffca2..d98c121 100644 --- a/backend/src/config/swagger.ts +++ b/backend/src/config/swagger.ts @@ -11,7 +11,10 @@ const swaggerDefinition = { }, servers: [ { url: 'http://localhost:8000', description: 'Local development' }, - { url: process.env.BASE_URL ?? 'https://api.inboxos.com', description: 'Production' }, + { + url: process.env.BASE_URL ?? 'https://api.inboxos.com', + description: 'Production', + }, ], components: { securitySchemes: { diff --git a/backend/src/jobs/calendar-events.job.ts b/backend/src/jobs/calendar-events.job.ts index c58910a..31b32e5 100644 --- a/backend/src/jobs/calendar-events.job.ts +++ b/backend/src/jobs/calendar-events.job.ts @@ -16,7 +16,10 @@ const getRedisConnectionOptions = (): ConnectionOptions => { ? parseInt(parsed.pathname.substring(1) || '0', 10) : 0, maxRetriesPerRequest: null, - tls: parsed.protocol === 'rediss:' ? { rejectUnauthorized: false } : undefined, + tls: + parsed.protocol === 'rediss:' + ? { rejectUnauthorized: false } + : undefined, }; } catch (error) { console.error( diff --git a/backend/src/jobs/digest-scheduler.job.ts b/backend/src/jobs/digest-scheduler.job.ts index 2ac130e..c1e7a92 100644 --- a/backend/src/jobs/digest-scheduler.job.ts +++ b/backend/src/jobs/digest-scheduler.job.ts @@ -19,7 +19,10 @@ const getRedisConnectionOptions = (): ConnectionOptions => { ? parseInt(parsed.pathname.substring(1) || '0', 10) : 0, maxRetriesPerRequest: null, - tls: parsed.protocol === 'rediss:' ? { rejectUnauthorized: false } : undefined, + tls: + parsed.protocol === 'rediss:' + ? { rejectUnauthorized: false } + : undefined, }; } catch (error) { console.error( @@ -60,7 +63,7 @@ export const digestWorker = new Worker( if (gmailAccount?.syncState === 'needs_reauth') { logger.warn( `[DigestWorker] Skipping digest job ${job.id} for user ${userId} — Gmail account requires re-authentication. ` + - `User has been notified via in-app notification.` + `User has been notified via in-app notification.` ); // Resolve without throwing: BullMQ marks job as completed (not failed), // which prevents infinite retries and Redis quota drain. @@ -87,7 +90,6 @@ export const digestWorker = new Worker( { connection } ); - /** * Synchronizes the BullMQ repeatable digest jobs for a specific user. * Reads user settings and schedules or clears repeatable jobs accordingly. diff --git a/backend/src/jobs/index-emails.job.ts b/backend/src/jobs/index-emails.job.ts index d0489ab..12f65a9 100644 --- a/backend/src/jobs/index-emails.job.ts +++ b/backend/src/jobs/index-emails.job.ts @@ -17,7 +17,10 @@ const getRedisConnectionOptions = (): ConnectionOptions => { ? parseInt(parsed.pathname.substring(1) || '0', 10) : 0, maxRetriesPerRequest: null, - tls: parsed.protocol === 'rediss:' ? { rejectUnauthorized: false } : undefined, + tls: + parsed.protocol === 'rediss:' + ? { rejectUnauthorized: false } + : undefined, }; } catch (error) { console.error( diff --git a/backend/src/jobs/reminder.job.ts b/backend/src/jobs/reminder.job.ts index 3c0e336..90246ce 100644 --- a/backend/src/jobs/reminder.job.ts +++ b/backend/src/jobs/reminder.job.ts @@ -18,7 +18,10 @@ const getRedisConnectionOptions = (): ConnectionOptions => { ? parseInt(parsed.pathname.substring(1) || '0', 10) : 0, maxRetriesPerRequest: null, - tls: parsed.protocol === 'rediss:' ? { rejectUnauthorized: false } : undefined, + tls: + parsed.protocol === 'rediss:' + ? { rejectUnauthorized: false } + : undefined, }; } catch (error) { logger.error( diff --git a/backend/src/middleware/rate-limiter.middleware.ts b/backend/src/middleware/rate-limiter.middleware.ts index 1bb0c3b..2a4ca22 100644 --- a/backend/src/middleware/rate-limiter.middleware.ts +++ b/backend/src/middleware/rate-limiter.middleware.ts @@ -57,7 +57,7 @@ const memoryStore = { resetKey: async (key: string) => { memoryStore.hits.delete(key); memoryStore.resetTimes.delete(key); - } + }, }; class HybridStore { @@ -72,7 +72,7 @@ class HybridStore { throw new Error('Redis is disabled'); } return redisClient.call(args[0], ...args.slice(1)); - } + }, }); } } @@ -119,7 +119,7 @@ class HybridStore { * Integrates with Redis to store request count across service restarts and scale-outs. */ export const rateLimiter = rateLimit({ - store: !isTest ? new HybridStore() as any : undefined, // Use default memory store in test env + store: !isTest ? (new HybridStore() as any) : undefined, // Use default memory store in test env validate: false, // Suppress validations for custom configuration windowMs: 15 * 60 * 1000, // 15-minute window limit: async (req: Request) => { diff --git a/backend/src/reminder-worker.ts b/backend/src/reminder-worker.ts index 14e5083..070af45 100644 --- a/backend/src/reminder-worker.ts +++ b/backend/src/reminder-worker.ts @@ -19,7 +19,9 @@ logger.info('[ReminderWorker] Starting dedicated reminder worker process...'); ReminderSchedulerService.initWorker(); -logger.info('[ReminderWorker] Worker registered. Listening for reminder.fire jobs on queue: inboxos-reminders'); +logger.info( + '[ReminderWorker] Worker registered. Listening for reminder.fire jobs on queue: inboxos-reminders' +); // Graceful shutdown const gracefulShutdown = async () => { diff --git a/backend/src/routes/integrations.routes.ts b/backend/src/routes/integrations.routes.ts index 33dd9e1..9d9e5bd 100644 --- a/backend/src/routes/integrations.routes.ts +++ b/backend/src/routes/integrations.routes.ts @@ -362,7 +362,10 @@ integrationsRouter.get( return res.json({ connected: !!integration }); } catch (err: any) { - logger.error('[Integrations] GET /google_calendar/status error:', err.message); + logger.error( + '[Integrations] GET /google_calendar/status error:', + err.message + ); return res.status(500).json({ error: 'Failed to fetch calendar status' }); } } @@ -383,7 +386,8 @@ integrationsRouter.get( const oauth2Client = new google.auth.OAuth2( process.env.GMAIL_CLIENT_ID, process.env.GMAIL_CLIENT_SECRET, - process.env.GOOGLE_CALENDAR_REDIRECT_URI || 'http://localhost:8000/api/integrations/google_calendar/callback' + process.env.GOOGLE_CALENDAR_REDIRECT_URI || + 'http://localhost:8000/api/integrations/google_calendar/callback' ); const url = oauth2Client.generateAuthUrl({ @@ -395,8 +399,13 @@ integrationsRouter.get( return res.json({ url }); } catch (err: any) { - logger.error('[Integrations] GET /google_calendar/auth error:', err.message); - return res.status(500).json({ error: 'Failed to generate calendar auth URL' }); + logger.error( + '[Integrations] GET /google_calendar/auth error:', + err.message + ); + return res + .status(500) + .json({ error: 'Failed to generate calendar auth URL' }); } } ); @@ -419,7 +428,8 @@ integrationsRouter.get( const oauth2Client = new google.auth.OAuth2( process.env.GMAIL_CLIENT_ID, process.env.GMAIL_CLIENT_SECRET, - process.env.GOOGLE_CALENDAR_REDIRECT_URI || 'http://localhost:8000/api/integrations/google_calendar/callback' + process.env.GOOGLE_CALENDAR_REDIRECT_URI || + 'http://localhost:8000/api/integrations/google_calendar/callback' ); const { tokens } = await oauth2Client.getToken(code); @@ -443,14 +453,21 @@ integrationsRouter.get( }, }); - logger.info('[Integrations] Google Calendar connected successfully', { userId }); - + logger.info('[Integrations] Google Calendar connected successfully', { + userId, + }); + // Redirect back to frontend settings integrations subtab const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173'; return res.redirect(`${frontendUrl}/`); } catch (err: any) { - logger.error('[Integrations] Google Calendar callback error:', err.message); - return res.status(500).send('Google Calendar integration failed. Please check backend logs.'); + logger.error( + '[Integrations] Google Calendar callback error:', + err.message + ); + return res + .status(500) + .send('Google Calendar integration failed. Please check backend logs.'); } } ); @@ -479,9 +496,13 @@ integrationsRouter.delete( logger.info('[Integrations] Google Calendar disconnected', { userId }); return res.json({ message: 'Google Calendar disconnected successfully' }); } catch (err: any) { - logger.error('[Integrations] DELETE /google_calendar error:', err.message); - return res.status(500).json({ error: 'Failed to disconnect Google Calendar' }); + logger.error( + '[Integrations] DELETE /google_calendar error:', + err.message + ); + return res + .status(500) + .json({ error: 'Failed to disconnect Google Calendar' }); } } ); - diff --git a/backend/src/server.ts b/backend/src/server.ts index 4048b70..39ae673 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -10,7 +10,10 @@ import cookieParser from 'cookie-parser'; import { PrismaClient } from '@prisma/client'; import { z } from 'zod'; import { AuthService } from './services/auth.service'; -import { requireAuth, AuthenticatedRequest } from './middleware/auth.middleware'; +import { + requireAuth, + AuthenticatedRequest, +} from './middleware/auth.middleware'; import { rateLimiter } from './middleware/rate-limiter.middleware'; import client from './utils/metrics'; import { logger } from './utils/logger'; @@ -24,7 +27,11 @@ import { registerWorkerHandlers } from './worker'; import { Server as SocketIoServer } from 'socket.io'; import { WebSocketService } from './services/websocket.service'; // Firebase Admin — modular v14 imports -import { initializeApp as firebaseInitializeApp, getApps, cert } from 'firebase-admin/app'; +import { + initializeApp as firebaseInitializeApp, + getApps, + cert, +} from 'firebase-admin/app'; import { getAuth as firebaseGetAuth } from 'firebase-admin/auth'; import { setupSwagger } from './config/swagger'; @@ -53,17 +60,21 @@ if (!getApps().length) { projectId: process.env.FIREBASE_PROJECT_ID, clientEmail: process.env.FIREBASE_CLIENT_EMAIL, // .env stores literal \n — Node needs real newlines - privateKey: (process.env.FIREBASE_PRIVATE_KEY || '').replace(/\\n/g, '\n'), + privateKey: (process.env.FIREBASE_PRIVATE_KEY || '').replace( + /\\n/g, + '\n' + ), }), }); logger.info('Firebase Admin SDK initialized'); } catch (err: any) { - logger.warn('Firebase Admin SDK init failed (Google Sign-In will be unavailable):', err.message); + logger.warn( + 'Firebase Admin SDK init failed (Google Sign-In will be unavailable):', + err.message + ); } } - - const app = express(); const prisma = new PrismaClient(); const PORT = process.env.PORT || 8000; @@ -71,33 +82,48 @@ const PORT = process.env.PORT || 8000; const allowedOrigins = [ 'http://localhost', 'http://127.0.0.1', - 'https://inbox-os-frontend.vercel.app' + 'https://inbox-os-frontend.vercel.app', ]; if (process.env.FRONTEND_URL) { allowedOrigins.push(process.env.FRONTEND_URL.replace(/\/$/, '')); } if (process.env.ALLOWED_ORIGINS) { - process.env.ALLOWED_ORIGINS.split(',').forEach(o => allowedOrigins.push(o.trim().replace(/\/$/, ''))); + process.env.ALLOWED_ORIGINS.split(',').forEach((o) => + allowedOrigins.push(o.trim().replace(/\/$/, '')) + ); } app.use((req, res, next) => { const origin = req.headers.origin; if (origin) { const originClean = origin.replace(/\/$/, ''); - const isVercelPreview = originClean.startsWith('https://inbox-os-frontend') && originClean.endsWith('.vercel.app'); - const isAllowed = isVercelPreview || allowedOrigins.some(o => - originClean === o || - (o.startsWith('http://localhost') && originClean.startsWith('http://localhost:')) || - (o.startsWith('http://127.0.0.1') && originClean.startsWith('http://127.0.0.1:')) - ); + const isVercelPreview = + originClean.startsWith('https://inbox-os-frontend') && + originClean.endsWith('.vercel.app'); + const isAllowed = + isVercelPreview || + allowedOrigins.some( + (o) => + originClean === o || + (o.startsWith('http://localhost') && + originClean.startsWith('http://localhost:')) || + (o.startsWith('http://127.0.0.1') && + originClean.startsWith('http://127.0.0.1:')) + ); if (isAllowed) { res.setHeader('Access-Control-Allow-Origin', origin); } } res.setHeader('Access-Control-Allow-Credentials', 'true'); - res.setHeader('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS,PATCH'); - res.setHeader('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization'); + res.setHeader( + 'Access-Control-Allow-Methods', + 'GET,PUT,POST,DELETE,OPTIONS,PATCH' + ); + res.setHeader( + 'Access-Control-Allow-Headers', + 'Origin, X-Requested-With, Content-Type, Accept, Authorization' + ); if (req.method === 'OPTIONS') { res.sendStatus(200); return; @@ -105,7 +131,6 @@ app.use((req, res, next) => { next(); }); - /** * @swagger * /metrics: @@ -120,7 +145,8 @@ app.use((req, res, next) => { app.get('/metrics', async (req: Request, res: Response) => { const ip = req.ip || req.socket.remoteAddress || ''; const cleanIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip; - const isLocalhost = cleanIp === '127.0.0.1' || cleanIp === '::1' || cleanIp === 'localhost'; + const isLocalhost = + cleanIp === '127.0.0.1' || cleanIp === '::1' || cleanIp === 'localhost'; let isPrivate = false; const ipParts = cleanIp.split('.'); @@ -145,7 +171,9 @@ app.get('/metrics', async (req: Request, res: Response) => { res.set('Content-Type', client.register.contentType); res.end(await client.register.metrics()); } catch (err: any) { - logger.error('Failed to generate Prometheus metrics', { error: err.message }); + logger.error('Failed to generate Prometheus metrics', { + error: err.message, + }); res.status(500).end(err); } }); @@ -197,7 +225,6 @@ app.get('/api/health', (req: Request, res: Response) => { }); }); - /** * @swagger * /api/auth/register: @@ -263,7 +290,9 @@ app.post('/api/auth/register', async (req: Request, res: Response) => { }); if (existingUser) { - return res.status(400).json({ error: 'User with this email already exists' }); + return res + .status(400) + .json({ error: 'User with this email already exists' }); } // Hash the password with 10 salt rounds @@ -370,7 +399,10 @@ app.post('/api/auth/login', async (req: Request, res: Response) => { } // Verify password - const isPasswordValid = await AuthService.comparePassword(password, user.passwordHash); + const isPasswordValid = await AuthService.comparePassword( + password, + user.passwordHash + ); if (!isPasswordValid) { return res.status(401).json({ error: 'Invalid email or password' }); } @@ -452,11 +484,15 @@ app.post('/api/auth/logout', (_req: Request, res: Response) => { * 401: * description: Unauthorized */ -app.get('/api/auth/me', requireAuth, (req: AuthenticatedRequest, res: Response) => { - return res.status(200).json({ - user: req.user, - }); -}); +app.get( + '/api/auth/me', + requireAuth, + (req: AuthenticatedRequest, res: Response) => { + return res.status(200).json({ + user: req.user, + }); + } +); /** * POST /api/auth/firebase @@ -470,7 +506,9 @@ app.post('/api/auth/firebase', async (req: Request, res: Response) => { } if (!getApps().length) { - return res.status(503).json({ error: 'Firebase Admin not configured on server' }); + return res + .status(503) + .json({ error: 'Firebase Admin not configured on server' }); } try { @@ -522,7 +560,9 @@ app.post('/api/auth/google/check', async (req: Request, res: Response) => { } if (!getApps().length) { - return res.status(503).json({ error: 'Firebase Admin not configured on server' }); + return res + .status(503) + .json({ error: 'Firebase Admin not configured on server' }); } try { @@ -561,19 +601,27 @@ app.post('/api/auth/google/check', async (req: Request, res: Response) => { app.post('/api/auth/google/register', async (req: Request, res: Response) => { const { idToken, username, password } = req.body; if (!idToken || !username || !password) { - return res.status(400).json({ error: 'idToken, username, and password are required' }); + return res + .status(400) + .json({ error: 'idToken, username, and password are required' }); } if (username.length < 3) { - return res.status(400).json({ error: 'Username must be at least 3 characters long' }); + return res + .status(400) + .json({ error: 'Username must be at least 3 characters long' }); } if (password.length < 6) { - return res.status(400).json({ error: 'Password must be at least 6 characters long' }); + return res + .status(400) + .json({ error: 'Password must be at least 6 characters long' }); } if (!getApps().length) { - return res.status(503).json({ error: 'Firebase Admin not configured on server' }); + return res + .status(503) + .json({ error: 'Firebase Admin not configured on server' }); } try { @@ -588,7 +636,9 @@ app.post('/api/auth/google/register', async (req: Request, res: Response) => { where: { email }, }); if (existingEmail) { - return res.status(400).json({ error: 'This Google account is already registered. Please log in.' }); + return res.status(400).json({ + error: 'This Google account is already registered. Please log in.', + }); } // Check if username already taken @@ -596,7 +646,9 @@ app.post('/api/auth/google/register', async (req: Request, res: Response) => { where: { username }, }); if (existingUsername) { - return res.status(400).json({ error: 'This username is already taken. Please choose another one.' }); + return res.status(400).json({ + error: 'This username is already taken. Please choose another one.', + }); } const passwordHash = await AuthService.hashPassword(password); @@ -624,7 +676,9 @@ app.post('/api/auth/google/register', async (req: Request, res: Response) => { }); } catch (err: any) { logger.error('Google registration error:', { error: err.message }); - return res.status(500).json({ error: 'Registration failed: ' + err.message }); + return res + .status(500) + .json({ error: 'Registration failed: ' + err.message }); } }); @@ -635,11 +689,15 @@ app.post('/api/auth/google/register', async (req: Request, res: Response) => { app.post('/api/auth/google/login', async (req: Request, res: Response) => { const { idToken, username, password } = req.body; if (!idToken || !username || !password) { - return res.status(400).json({ error: 'idToken, username, and password are required' }); + return res + .status(400) + .json({ error: 'idToken, username, and password are required' }); } if (!getApps().length) { - return res.status(503).json({ error: 'Firebase Admin not configured on server' }); + return res + .status(503) + .json({ error: 'Firebase Admin not configured on server' }); } try { @@ -654,14 +712,21 @@ app.post('/api/auth/google/login', async (req: Request, res: Response) => { }); if (!user) { - return res.status(401).json({ error: 'You are not registered under this Google account.' }); + return res + .status(401) + .json({ error: 'You are not registered under this Google account.' }); } if (user.username !== username) { - return res.status(401).json({ error: 'Incorrect username for this Google account.' }); + return res + .status(401) + .json({ error: 'Incorrect username for this Google account.' }); } - const isPasswordValid = await AuthService.comparePassword(password, user.passwordHash); + const isPasswordValid = await AuthService.comparePassword( + password, + user.passwordHash + ); if (!isPasswordValid) { return res.status(401).json({ error: 'Incorrect password.' }); } @@ -681,7 +746,9 @@ app.post('/api/auth/google/login', async (req: Request, res: Response) => { }); } catch (err: any) { logger.error('Google login error:', { error: err.message }); - return res.status(401).json({ error: 'Authentication failed: ' + err.message }); + return res + .status(401) + .json({ error: 'Authentication failed: ' + err.message }); } }); @@ -724,57 +791,61 @@ app.post('/api/auth/google/login', async (req: Request, res: Response) => { * 404: * description: User not found */ -app.get('/api/users/profile', requireAuth, async (req: AuthenticatedRequest, res: Response) => { - try { - const userId = req.user?.userId; - if (!userId) { - return res.status(401).json({ error: 'Unauthorized' }); - } - let newToken: string | undefined; +app.get( + '/api/users/profile', + requireAuth, + async (req: AuthenticatedRequest, res: Response) => { + try { + const userId = req.user?.userId; + if (!userId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + let newToken: string | undefined; + + const cacheKey = `user:profile:${userId}`; + + // Try fetching from Redis cache first + const cachedProfile = await RedisService.get(cacheKey); + if (cachedProfile) { + try { + const parsedProfile = JSON.parse(cachedProfile); + return res.status(200).json(parsedProfile); + } catch (parseError) { + console.warn('Failed to parse cached user profile JSON:', parseError); + } + } - const cacheKey = `user:profile:${userId}`; + // Fetch from Prisma if not cached + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + email: true, + createdAt: true, + settings: { + select: { + theme: true, + signature: true, + autoReply: true, + }, + }, + }, + }); - // Try fetching from Redis cache first - const cachedProfile = await RedisService.get(cacheKey); - if (cachedProfile) { - try { - const parsedProfile = JSON.parse(cachedProfile); - return res.status(200).json(parsedProfile); - } catch (parseError) { - console.warn('Failed to parse cached user profile JSON:', parseError); + if (!user) { + return res.status(404).json({ error: 'User not found' }); } - } - // Fetch from Prisma if not cached - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { - id: true, - email: true, - createdAt: true, - settings: { - select: { - theme: true, - signature: true, - autoReply: true, - } - } - }, - }); + // Store in cache for 300 seconds + await RedisService.setex(cacheKey, 300, JSON.stringify(user)); - if (!user) { - return res.status(404).json({ error: 'User not found' }); + return res.status(200).json(user); + } catch (error) { + console.error('Fetch profile error:', error); + return res.status(500).json({ error: 'Internal server error' }); } - - // Store in cache for 300 seconds - await RedisService.setex(cacheKey, 300, JSON.stringify(user)); - - return res.status(200).json(user); - } catch (error) { - console.error('Fetch profile error:', error); - return res.status(500).json({ error: 'Internal server error' }); } -}); +); /** * @swagger @@ -817,7 +888,8 @@ app.post('/api/webhooks/incoming', async (req: Request, res: Response) => { }); } - const { sender, recipient, subject, body, messageId, inReplyTo } = validation.data; + const { sender, recipient, subject, body, messageId, inReplyTo } = + validation.data; // 2. Fetch or dynamically create the recipient User let user = await prisma.user.findUnique({ @@ -828,7 +900,9 @@ app.post('/api/webhooks/incoming', async (req: Request, res: Response) => { user = await prisma.user.create({ data: { email: recipient, - passwordHash: await AuthService.hashPassword('webhook-generated-password-hash'), + passwordHash: await AuthService.hashPassword( + 'webhook-generated-password-hash' + ), }, }); } @@ -893,8 +967,13 @@ app.post('/api/telegram/webhook', async (req: Request, res: Response) => { try { // Verify Telegram secret token if configured const secretHeader = req.headers['x-telegram-bot-api-secret-token']; - if (TelegramConfig.webhookSecret && secretHeader !== TelegramConfig.webhookSecret) { - logger.warn('[TelegramBot] Rejected webhook request with invalid secret token.'); + if ( + TelegramConfig.webhookSecret && + secretHeader !== TelegramConfig.webhookSecret + ) { + logger.warn( + '[TelegramBot] Rejected webhook request with invalid secret token.' + ); return res.status(403).json({ error: 'Forbidden' }); } @@ -910,7 +989,6 @@ app.post('/api/telegram/webhook', async (req: Request, res: Response) => { } }); - /** * @swagger * /api/users/me/settings: @@ -939,66 +1017,74 @@ app.post('/api/telegram/webhook', async (req: Request, res: Response) => { * 200: * description: User settings object */ -app.get('/api/users/me/settings', requireAuth, async (req: AuthenticatedRequest, res: Response) => { - try { - const userId = req.user?.userId; - if (!userId) { - return res.status(401).json({ error: 'Unauthorized' }); - } - let newToken: string | undefined; +app.get( + '/api/users/me/settings', + requireAuth, + async (req: AuthenticatedRequest, res: Response) => { + try { + const userId = req.user?.userId; + if (!userId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + let newToken: string | undefined; - const settings = await prisma.userSettings.findUnique({ - where: { userId }, - }); - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { username: true, email: true }, - }); + const settings = await prisma.userSettings.findUnique({ + where: { userId }, + }); + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { username: true, email: true }, + }); - return res.status(200).json({ - theme: settings?.theme ?? 'dark', - signature: settings?.signature ?? null, - autoReply: settings?.autoReply ?? false, - username: user?.username ?? null, - email: user?.email ?? '', - userId, - }); - } catch (error) { - console.error('Fetch settings error:', error); - return res.status(500).json({ error: 'Internal server error' }); + return res.status(200).json({ + theme: settings?.theme ?? 'dark', + signature: settings?.signature ?? null, + autoReply: settings?.autoReply ?? false, + username: user?.username ?? null, + email: user?.email ?? '', + userId, + }); + } catch (error) { + console.error('Fetch settings error:', error); + return res.status(500).json({ error: 'Internal server error' }); + } } -}); +); /** * GET /api/users/me/ai-profile * Retrieve user's AI profile from settings. */ -app.get('/api/users/me/ai-profile', requireAuth, async (req: AuthenticatedRequest, res: Response) => { - try { - const userId = req.user?.userId; - if (!userId) { - return res.status(401).json({ error: 'Unauthorized' }); - } +app.get( + '/api/users/me/ai-profile', + requireAuth, + async (req: AuthenticatedRequest, res: Response) => { + try { + const userId = req.user?.userId; + if (!userId) { + return res.status(401).json({ error: 'Unauthorized' }); + } - const settings = await prisma.userSettings.findUnique({ - where: { userId }, - }); + const settings = await prisma.userSettings.findUnique({ + where: { userId }, + }); - if (!settings || !settings.aiPreferenceProfile) { - return res.status(200).json({ weekly: {} }); - } + if (!settings || !settings.aiPreferenceProfile) { + return res.status(200).json({ weekly: {} }); + } - try { - const parsed = JSON.parse(settings.aiPreferenceProfile); - return res.status(200).json(parsed); - } catch { - return res.status(200).json({ weekly: {} }); + try { + const parsed = JSON.parse(settings.aiPreferenceProfile); + return res.status(200).json(parsed); + } catch { + return res.status(200).json({ weekly: {} }); + } + } catch (error) { + console.error('Fetch ai-profile error:', error); + return res.status(500).json({ error: 'Internal server error' }); } - } catch (error) { - console.error('Fetch ai-profile error:', error); - return res.status(500).json({ error: 'Internal server error' }); } -}); +); /** * PUT /api/users/me/settings @@ -1053,90 +1139,95 @@ const updateSettingsSchema = z.object({ * 200: * description: Settings updated */ -app.put('/api/users/me/settings', requireAuth, async (req: AuthenticatedRequest, res: Response) => { - try { - const userId = req.user?.userId; - if (!userId) { - return res.status(401).json({ error: 'Unauthorized' }); - } - let newToken: string | undefined; - - const validation = updateSettingsSchema.safeParse(req.body); - if (!validation.success) { - return res.status(400).json({ - error: 'Invalid payload schema', - details: validation.error.flatten(), - }); - } - - const { theme, signature, autoReply, username } = validation.data; +app.put( + '/api/users/me/settings', + requireAuth, + async (req: AuthenticatedRequest, res: Response) => { + try { + const userId = req.user?.userId; + if (!userId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + let newToken: string | undefined; - if (username) { - const existing = await prisma.user.findFirst({ - where: { username, NOT: { id: userId } }, - }); - if (existing) { - return res.status(400).json({ error: 'Username is already taken' }); + const validation = updateSettingsSchema.safeParse(req.body); + if (!validation.success) { + return res.status(400).json({ + error: 'Invalid payload schema', + details: validation.error.flatten(), + }); } - await prisma.user.update({ - where: { id: userId }, - data: { username }, - }); + const { theme, signature, autoReply, username } = validation.data; - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (user) { - newToken = AuthService.generateToken(user.id, user.email, username); - res.cookie('token', newToken, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', - maxAge: 24 * 60 * 60 * 1000, + if (username) { + const existing = await prisma.user.findFirst({ + where: { username, NOT: { id: userId } }, }); + if (existing) { + return res.status(400).json({ error: 'Username is already taken' }); + } + + await prisma.user.update({ + where: { id: userId }, + data: { username }, + }); + + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (user) { + newToken = AuthService.generateToken(user.id, user.email, username); + res.cookie('token', newToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', + maxAge: 24 * 60 * 60 * 1000, + }); + } } - } - const updatedSettings = await prisma.userSettings.upsert({ - where: { userId }, - update: { - ...(theme !== undefined && { theme }), - ...(signature !== undefined && { signature }), - ...(autoReply !== undefined && { autoReply }), - }, - create: { - userId, - theme: theme ?? 'dark', - signature: signature ?? null, - autoReply: autoReply ?? false, - }, - }); + const updatedSettings = await prisma.userSettings.upsert({ + where: { userId }, + update: { + ...(theme !== undefined && { theme }), + ...(signature !== undefined && { signature }), + ...(autoReply !== undefined && { autoReply }), + }, + create: { + userId, + theme: theme ?? 'dark', + signature: signature ?? null, + autoReply: autoReply ?? false, + }, + }); - return res.status(200).json({ - message: 'Settings updated successfully', - settings: { - theme: updatedSettings.theme, - signature: updatedSettings.signature, - autoReply: updatedSettings.autoReply, - username: username || null, - }, - }); - } catch (error) { - console.error('Update settings error:', error); - return res.status(500).json({ error: 'Internal server error' }); + return res.status(200).json({ + message: 'Settings updated successfully', + settings: { + theme: updatedSettings.theme, + signature: updatedSettings.signature, + autoReply: updatedSettings.autoReply, + username: username || null, + }, + }); + } catch (error) { + console.error('Update settings error:', error); + return res.status(500).json({ error: 'Internal server error' }); + } } -}); +); // OAuth2 & Encryption config const getOAuth2Client = (req?: Request) => { let redirectUri = process.env.GMAIL_REDIRECT_URI; if (!redirectUri && req) { - const protocol = process.env.NODE_ENV === 'production' ? 'https' : req.protocol; + const protocol = + process.env.NODE_ENV === 'production' ? 'https' : req.protocol; const host = req.get('host'); redirectUri = `${protocol}://${host}/api/integrations/gmail/callback`; } if (!redirectUri) { - redirectUri = process.env.RENDER_EXTERNAL_URL - ? `${process.env.RENDER_EXTERNAL_URL.replace(/\/$/, '')}/api/integrations/gmail/callback` + redirectUri = process.env.RENDER_EXTERNAL_URL + ? `${process.env.RENDER_EXTERNAL_URL.replace(/\/$/, '')}/api/integrations/gmail/callback` : 'http://localhost:8000/api/integrations/gmail/callback'; } return new google.auth.OAuth2( @@ -1178,16 +1269,20 @@ const oauth2Client = getOAuth2Client(); * 302: * description: Redirect to Google OAuth consent screen */ -app.get('/api/integrations/gmail/auth', requireAuth, (req: AuthenticatedRequest, res: Response) => { - const client = getOAuth2Client(req); - const url = client.generateAuthUrl({ - access_type: 'offline', - scope: ['https://mail.google.com/'], - prompt: 'consent', - state: req.user?.userId - }); - return res.json({ url }); -}); +app.get( + '/api/integrations/gmail/auth', + requireAuth, + (req: AuthenticatedRequest, res: Response) => { + const client = getOAuth2Client(req); + const url = client.generateAuthUrl({ + access_type: 'offline', + scope: ['https://mail.google.com/'], + prompt: 'consent', + state: req.user?.userId, + }); + return res.json({ url }); + } +); /** * GET /api/integrations/gmail/callback @@ -1230,292 +1325,337 @@ app.get('/api/integrations/gmail/auth', requireAuth, (req: AuthenticatedRequest, * 200: * description: OAuth callback processed */ -app.get('/api/integrations/gmail/callback', async (req: Request, res: Response) => { - const code = req.query.code as string; - const userId = req.query.state as string; - - if (!code || !userId) { - return res.status(400).json({ error: 'Missing code or state parameters' }); - } +app.get( + '/api/integrations/gmail/callback', + async (req: Request, res: Response) => { + const code = req.query.code as string; + const userId = req.query.state as string; - try { - const client = getOAuth2Client(req); - const { tokens } = await client.getToken(code); - client.setCredentials(tokens); + if (!code || !userId) { + return res + .status(400) + .json({ error: 'Missing code or state parameters' }); + } - const gmail = google.gmail({ version: 'v1', auth: client }); - const profile = await gmail.users.getProfile({ userId: 'me' }); - const emailAddress = profile.data.emailAddress; + try { + const client = getOAuth2Client(req); + const { tokens } = await client.getToken(code); + client.setCredentials(tokens); + + const gmail = google.gmail({ version: 'v1', auth: client }); + const profile = await gmail.users.getProfile({ userId: 'me' }); + const emailAddress = profile.data.emailAddress; + + if (!emailAddress) { + return res + .status(400) + .json({ error: 'Could not fetch email address from Google' }); + } - if (!emailAddress) { - return res.status(400).json({ error: 'Could not fetch email address from Google' }); - } + // ── Google Sign-In flow ─────────────────────────────────────────────────── + // If state is 'google-signin', auto-create or find the user by Gmail address + // then set a JWT cookie and redirect to the dashboard. + if (userId === 'google-signin') { + let user = await prisma.user.findUnique({ + where: { email: emailAddress }, + }); + if (!user) { + user = await prisma.user.create({ + data: { + email: emailAddress, + passwordHash: crypto.randomBytes(32).toString('hex'), // unusable password — Google is the auth + }, + }); + } - // ── Google Sign-In flow ─────────────────────────────────────────────────── - // If state is 'google-signin', auto-create or find the user by Gmail address - // then set a JWT cookie and redirect to the dashboard. - if (userId === 'google-signin') { - let user = await prisma.user.findUnique({ where: { email: emailAddress } }); - if (!user) { - user = await prisma.user.create({ - data: { - email: emailAddress, - passwordHash: crypto.randomBytes(32).toString('hex'), // unusable password — Google is the auth - } + const jwtToken = AuthService.generateToken(user.id, user.email); + res.cookie('token', jwtToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', + maxAge: 24 * 60 * 60 * 1000, }); - } - const jwtToken = AuthService.generateToken(user.id, user.email); - res.cookie('token', jwtToken, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', - maxAge: 24 * 60 * 60 * 1000, - }); + // Also connect their Gmail account + const encryptedTokens = encrypt(JSON.stringify(tokens)); + await prisma.emailAccount.upsert({ + where: { + userId_provider_emailAddress: { + userId: user.id, + provider: 'gmail', + emailAddress, + }, + }, + update: { + encryptedTokens, + syncState: 'connected', + lastSyncAt: new Date(), + }, + create: { + userId: user.id, + provider: 'gmail', + emailAddress, + encryptedTokens, + syncState: 'connected', + }, + }); + + // Trigger sync in background immediately + GmailSyncService.syncLatestEmails(user.id).catch((err) => { + logger.error( + '[GmailCallback] Initial Google Sign-in Gmail sync failed:', + err + ); + }); + + const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173'; + return res.redirect(`${frontendUrl}/dashboard?token=${jwtToken}`); + } - // Also connect their Gmail account + // ── Connect Gmail to existing account flow ──────────────────────────────── const encryptedTokens = encrypt(JSON.stringify(tokens)); + + // Save to Database await prisma.emailAccount.upsert({ - where: { userId_provider_emailAddress: { userId: user.id, provider: 'gmail', emailAddress } }, - update: { encryptedTokens, syncState: 'connected', lastSyncAt: new Date() }, - create: { userId: user.id, provider: 'gmail', emailAddress, encryptedTokens, syncState: 'connected' } + where: { + userId_provider_emailAddress: { + userId, + provider: 'gmail', + emailAddress, + }, + }, + update: { + encryptedTokens, + syncState: 'connected', + lastSyncAt: new Date(), + }, + create: { + userId, + provider: 'gmail', + emailAddress, + encryptedTokens, + syncState: 'connected', + }, }); // Trigger sync in background immediately - GmailSyncService.syncLatestEmails(user.id).catch(err => { - logger.error('[GmailCallback] Initial Google Sign-in Gmail sync failed:', err); + GmailSyncService.syncLatestEmails(userId).catch((err) => { + logger.error('[GmailCallback] Initial Gmail connect sync failed:', err); }); const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173'; - return res.redirect(`${frontendUrl}/dashboard?token=${jwtToken}`); + return res.redirect(`${frontendUrl}/dashboard/settings?tab=integrations`); + } catch (error) { + console.error('OAuth callback error:', error); + return res.status(500).json({ error: 'OAuth integration failed' }); } - - // ── Connect Gmail to existing account flow ──────────────────────────────── - const encryptedTokens = encrypt(JSON.stringify(tokens)); - - // Save to Database - await prisma.emailAccount.upsert({ - where: { - userId_provider_emailAddress: { - userId, - provider: 'gmail', - emailAddress - } - }, - update: { - encryptedTokens, - syncState: 'connected', - lastSyncAt: new Date() - }, - create: { - userId, - provider: 'gmail', - emailAddress, - encryptedTokens, - syncState: 'connected' - } - }); - - // Trigger sync in background immediately - GmailSyncService.syncLatestEmails(userId).catch(err => { - logger.error('[GmailCallback] Initial Gmail connect sync failed:', err); - }); - - const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173'; - return res.redirect(`${frontendUrl}/dashboard/settings?tab=integrations`); - } catch (error) { - console.error('OAuth callback error:', error); - return res.status(500).json({ error: 'OAuth integration failed' }); } -}); +); /** * GET /api/integrations/gmail/status * Returns whether the authenticated user has a connected Gmail account. */ -app.get('/api/integrations/gmail/status', requireAuth, async (req: AuthenticatedRequest, res: Response) => { - try { - const userId = req.user?.userId; - if (!userId) return res.status(401).json({ error: 'Unauthorized' }); +app.get( + '/api/integrations/gmail/status', + requireAuth, + async (req: AuthenticatedRequest, res: Response) => { + try { + const userId = req.user?.userId; + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); - const account = await prisma.emailAccount.findFirst({ - where: { userId, provider: 'gmail' }, - select: { emailAddress: true, syncState: true, lastSyncAt: true }, - }); + const account = await prisma.emailAccount.findFirst({ + where: { userId, provider: 'gmail' }, + select: { emailAddress: true, syncState: true, lastSyncAt: true }, + }); - if (!account) { - return res.json({ connected: false }); - } + if (!account) { + return res.json({ connected: false }); + } - return res.json({ - connected: true, - emailAddress: account.emailAddress, - syncState: account.syncState, - lastSyncAt: account.lastSyncAt, - }); - } catch (error) { - console.error('Gmail status error:', error); - return res.status(500).json({ error: 'Internal server error' }); + return res.json({ + connected: true, + emailAddress: account.emailAddress, + syncState: account.syncState, + lastSyncAt: account.lastSyncAt, + }); + } catch (error) { + console.error('Gmail status error:', error); + return res.status(500).json({ error: 'Internal server error' }); + } } -}); +); /** * POST /api/integrations/gmail/sync * Fetches the latest 50 unread Gmail messages for the authenticated user * and stores them in the Email table. */ -app.post('/api/integrations/gmail/sync', requireAuth, async (req: AuthenticatedRequest, res: Response) => { - try { - const userId = req.user?.userId; - if (!userId) return res.status(401).json({ error: 'Unauthorized' }); - - const account = await prisma.emailAccount.findFirst({ - where: { userId, provider: 'gmail' }, - }); +app.post( + '/api/integrations/gmail/sync', + requireAuth, + async (req: AuthenticatedRequest, res: Response) => { + try { + const userId = req.user?.userId; + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); - if (!account) { - return res.status(400).json({ error: 'No Gmail account connected. Please connect Gmail first.' }); - } + const account = await prisma.emailAccount.findFirst({ + where: { userId, provider: 'gmail' }, + }); - // Decrypt stored tokens - let tokens: any; - try { - tokens = JSON.parse(decrypt(account.encryptedTokens)); - } catch (e) { - return res.status(400).json({ error: 'Failed to decrypt Gmail tokens. Please reconnect Gmail.' }); - } + if (!account) { + return res.status(400).json({ + error: 'No Gmail account connected. Please connect Gmail first.', + }); + } - // Build authenticated Gmail client with stored tokens - const userOAuth = new google.auth.OAuth2( - process.env.GMAIL_CLIENT_ID, - process.env.GMAIL_CLIENT_SECRET, - process.env.GMAIL_REDIRECT_URI - ); - userOAuth.setCredentials(tokens); + // Decrypt stored tokens + let tokens: any; + try { + tokens = JSON.parse(decrypt(account.encryptedTokens)); + } catch (e) { + return res.status(400).json({ + error: 'Failed to decrypt Gmail tokens. Please reconnect Gmail.', + }); + } - // Auto-refresh token if expired - userOAuth.on('tokens', async (newTokens) => { - const merged = { ...tokens, ...newTokens }; - const encrypted = encrypt(JSON.stringify(merged)); - await prisma.emailAccount.update({ - where: { id: account.id }, - data: { encryptedTokens: encrypted }, + // Build authenticated Gmail client with stored tokens + const userOAuth = new google.auth.OAuth2( + process.env.GMAIL_CLIENT_ID, + process.env.GMAIL_CLIENT_SECRET, + process.env.GMAIL_REDIRECT_URI + ); + userOAuth.setCredentials(tokens); + + // Auto-refresh token if expired + userOAuth.on('tokens', async (newTokens) => { + const merged = { ...tokens, ...newTokens }; + const encrypted = encrypt(JSON.stringify(merged)); + await prisma.emailAccount.update({ + where: { id: account.id }, + data: { encryptedTokens: encrypted }, + }); }); - }); - - const gmail = google.gmail({ version: 'v1', auth: userOAuth }); - // Fetch list of latest 50 messages - const listRes = await gmail.users.messages.list({ - userId: 'me', - maxResults: 50, - q: 'in:inbox', - }); + const gmail = google.gmail({ version: 'v1', auth: userOAuth }); - const messages = listRes.data.messages || []; - if (messages.length === 0) { - await prisma.emailAccount.update({ - where: { id: account.id }, - data: { lastSyncAt: new Date() }, + // Fetch list of latest 50 messages + const listRes = await gmail.users.messages.list({ + userId: 'me', + maxResults: 50, + q: 'in:inbox', }); - return res.json({ synced: 0, message: 'No messages found in inbox.' }); - } - let syncedCount = 0; + const messages = listRes.data.messages || []; + if (messages.length === 0) { + await prisma.emailAccount.update({ + where: { id: account.id }, + data: { lastSyncAt: new Date() }, + }); + return res.json({ synced: 0, message: 'No messages found in inbox.' }); + } - for (const msg of messages) { - if (!msg.id) continue; + let syncedCount = 0; - // Skip if already stored - const existing = await prisma.email.findUnique({ where: { messageId: msg.id } }); - if (existing) continue; + for (const msg of messages) { + if (!msg.id) continue; - try { - const fullMsg = await gmail.users.messages.get({ - userId: 'me', - id: msg.id, - format: 'full', + // Skip if already stored + const existing = await prisma.email.findUnique({ + where: { messageId: msg.id }, }); + if (existing) continue; + try { + const fullMsg = await gmail.users.messages.get({ + userId: 'me', + id: msg.id, + format: 'full', + }); - const headers = fullMsg.data.payload?.headers || []; - const getHeader = (name: string) => - headers.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value || ''; - - const subject = getHeader('Subject') || '(no subject)'; - const from = getHeader('From') || 'unknown@unknown.com'; - const to = getHeader('To') || account.emailAddress; - const messageId = getHeader('Message-ID') || msg.id; - const inReplyTo = getHeader('In-Reply-To') || null; + const headers = fullMsg.data.payload?.headers || []; + const getHeader = (name: string) => + headers.find((h) => h.name?.toLowerCase() === name.toLowerCase()) + ?.value || ''; + + const subject = getHeader('Subject') || '(no subject)'; + const from = getHeader('From') || 'unknown@unknown.com'; + const to = getHeader('To') || account.emailAddress; + const messageId = getHeader('Message-ID') || msg.id; + const inReplyTo = getHeader('In-Reply-To') || null; + + // Extract plain text body + let body = ''; + const extractBody = (part: any): string => { + if (part.mimeType === 'text/plain' && part.body?.data) { + return Buffer.from(part.body.data, 'base64url').toString('utf-8'); + } + if (part.parts) { + for (const p of part.parts) { + const text = extractBody(p); + if (text) return text; + } + } + return ''; + }; - // Extract plain text body - let body = ''; - const extractBody = (part: any): string => { - if (part.mimeType === 'text/plain' && part.body?.data) { - return Buffer.from(part.body.data, 'base64url').toString('utf-8'); + if (fullMsg.data.payload) { + body = extractBody(fullMsg.data.payload); } - if (part.parts) { - for (const p of part.parts) { - const text = extractBody(p); - if (text) return text; - } + if (!body && fullMsg.data.snippet) { + body = fullMsg.data.snippet; } - return ''; - }; - if (fullMsg.data.payload) { - body = extractBody(fullMsg.data.payload); - } - if (!body && fullMsg.data.snippet) { - body = fullMsg.data.snippet; - } + // Find or create thread + let threadId: string | null = null; + if (inReplyTo) { + const prev = await prisma.email.findUnique({ + where: { messageId: inReplyTo }, + }); + if (prev) threadId = prev.threadId; + } + if (!threadId) { + const newThread = await prisma.thread.create({ + data: { summary: `Thread: ${subject}` }, + }); + threadId = newThread.id; + } - // Find or create thread - let threadId: string | null = null; - if (inReplyTo) { - const prev = await prisma.email.findUnique({ where: { messageId: inReplyTo } }); - if (prev) threadId = prev.threadId; - } - if (!threadId) { - const newThread = await prisma.thread.create({ - data: { summary: `Thread: ${subject}` }, + const emailRecord = await prisma.email.create({ + data: { + messageId, + inReplyTo, + sender: from, + recipient: to, + subject, + body, + status: 'UNREAD', + userId, + threadId, + }, }); - threadId = newThread.id; + syncedCount++; + await EventBus.publish('email.received', { emailId: emailRecord.id }); + } catch (msgErr: any) { + console.warn(`Failed to sync message ${msg.id}:`, msgErr.message); } - - const emailRecord = await prisma.email.create({ - data: { - messageId, - inReplyTo, - sender: from, - recipient: to, - subject, - body, - status: 'UNREAD', - userId, - threadId, - }, - }); - syncedCount++; - await EventBus.publish('email.received', { emailId: emailRecord.id }); - } catch (msgErr: any) { - console.warn(`Failed to sync message ${msg.id}:`, msgErr.message); } - } - // Update last sync time - await prisma.emailAccount.update({ - where: { id: account.id }, - data: { lastSyncAt: new Date() }, - }); + // Update last sync time + await prisma.emailAccount.update({ + where: { id: account.id }, + data: { lastSyncAt: new Date() }, + }); - return res.json({ synced: syncedCount, total: messages.length }); - } catch (error: any) { - console.error('Gmail sync error:', error.message); - return res.status(500).json({ error: 'Gmail sync failed. ' + error.message }); + return res.json({ synced: syncedCount, total: messages.length }); + } catch (error: any) { + console.error('Gmail sync error:', error.message); + return res + .status(500) + .json({ error: 'Gmail sync failed. ' + error.message }); + } } -}); - +); /** * POST /api/emails/send @@ -1563,23 +1703,36 @@ app.post('/api/integrations/gmail/sync', requireAuth, async (req: AuthenticatedR * 202: * description: Email queued for sending */ -app.post('/api/emails/send', requireAuth, async (req: AuthenticatedRequest, res: Response) => { - try { - const { to, subject, text, html, inReplyTo } = req.body; - if (!to || !subject || !text) { - return res.status(400).json({ error: 'Missing to, subject, or text' }); +app.post( + '/api/emails/send', + requireAuth, + async (req: AuthenticatedRequest, res: Response) => { + try { + const { to, subject, text, html, inReplyTo } = req.body; + if (!to || !subject || !text) { + return res.status(400).json({ error: 'Missing to, subject, or text' }); + } + + const userId = req.user?.userId; + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); + + const result = await EmailSenderService.send(userId, { + to, + subject, + text, + html, + inReplyTo, + }); + return res.status(200).json({ + message: 'Email sent successfully', + messageId: result.messageId, + }); + } catch (error: any) { + console.error('Send email error:', error.message); + return res.status(500).json({ error: 'Failed to send email' }); } - - const userId = req.user?.userId; - if (!userId) return res.status(401).json({ error: 'Unauthorized' }); - - const result = await EmailSenderService.send(userId, { to, subject, text, html, inReplyTo }); - return res.status(200).json({ message: 'Email sent successfully', messageId: result.messageId }); - } catch (error: any) { - console.error('Send email error:', error.message); - return res.status(500).json({ error: 'Failed to send email' }); } -}); +); /** * Webhook Config Routes @@ -1626,23 +1779,33 @@ app.post('/api/emails/send', requireAuth, async (req: AuthenticatedRequest, res: * 201: * description: Webhook configuration created */ -app.post('/api/webhooks/config', requireAuth, async (req: AuthenticatedRequest, res: Response) => { - try { - const { targetUrl, events } = req.body; - const userId = req.user?.userId; - if (!userId) return res.status(401).json({ error: 'Unauthorized' }); - if (!targetUrl || !Array.isArray(events)) return res.status(400).json({ error: 'Invalid payload' }); - - const secret = crypto.randomBytes(32).toString('hex'); - const hook = await prisma.webhookEndpoint.create({ - data: { targetUrl, events: JSON.stringify(events), secret, userId } - }); - - return res.json({ id: hook.id, targetUrl: hook.targetUrl, events: JSON.parse(hook.events), secret: hook.secret }); - } catch (err) { - return res.status(500).json({ error: 'Failed to create webhook' }); +app.post( + '/api/webhooks/config', + requireAuth, + async (req: AuthenticatedRequest, res: Response) => { + try { + const { targetUrl, events } = req.body; + const userId = req.user?.userId; + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); + if (!targetUrl || !Array.isArray(events)) + return res.status(400).json({ error: 'Invalid payload' }); + + const secret = crypto.randomBytes(32).toString('hex'); + const hook = await prisma.webhookEndpoint.create({ + data: { targetUrl, events: JSON.stringify(events), secret, userId }, + }); + + return res.json({ + id: hook.id, + targetUrl: hook.targetUrl, + events: JSON.parse(hook.events), + secret: hook.secret, + }); + } catch (err) { + return res.status(500).json({ error: 'Failed to create webhook' }); + } } -}); +); /** * @swagger @@ -1672,18 +1835,30 @@ app.post('/api/webhooks/config', requireAuth, async (req: AuthenticatedRequest, * 200: * description: List of webhook configs */ -app.get('/api/webhooks/config', requireAuth, async (req: AuthenticatedRequest, res: Response) => { - try { - const userId = req.user?.userId; - if (!userId) return res.status(401).json({ error: 'Unauthorized' }); - - const hooks = await prisma.webhookEndpoint.findMany({ where: { userId } }); - const formatted = hooks.map((h: { id: string; targetUrl: string; events: string }) => ({ id: h.id, targetUrl: h.targetUrl, events: JSON.parse(h.events) })); - return res.json(formatted); - } catch (err) { - return res.status(500).json({ error: 'Failed to fetch webhooks' }); +app.get( + '/api/webhooks/config', + requireAuth, + async (req: AuthenticatedRequest, res: Response) => { + try { + const userId = req.user?.userId; + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); + + const hooks = await prisma.webhookEndpoint.findMany({ + where: { userId }, + }); + const formatted = hooks.map( + (h: { id: string; targetUrl: string; events: string }) => ({ + id: h.id, + targetUrl: h.targetUrl, + events: JSON.parse(h.events), + }) + ); + return res.json(formatted); + } catch (err) { + return res.status(500).json({ error: 'Failed to fetch webhooks' }); + } } -}); +); /** * @swagger @@ -1714,28 +1889,33 @@ app.get('/api/webhooks/config', requireAuth, async (req: AuthenticatedRequest, r * 401: * description: Unauthorized */ -app.patch('/api/webhooks/config/:id', requireAuth, async (req: AuthenticatedRequest, res: Response) => { - try { - const userId = req.user?.userId; - if (!userId) return res.status(401).json({ error: 'Unauthorized' }); - const { targetUrl, events } = req.body; - const id = req.params.id as string; +app.patch( + '/api/webhooks/config/:id', + requireAuth, + async (req: AuthenticatedRequest, res: Response) => { + try { + const userId = req.user?.userId; + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); + const { targetUrl, events } = req.body; + const id = req.params.id as string; - const hook = await prisma.webhookEndpoint.findUnique({ where: { id } }); - if (!hook || hook.userId !== userId) return res.status(404).json({ error: 'Not found' }); + const hook = await prisma.webhookEndpoint.findUnique({ where: { id } }); + if (!hook || hook.userId !== userId) + return res.status(404).json({ error: 'Not found' }); - await prisma.webhookEndpoint.update({ - where: { id }, - data: { - ...(targetUrl && { targetUrl }), - ...(events && { events: JSON.stringify(events) }) - } - }); - return res.json({ message: 'Webhook updated' }); - } catch (err) { - return res.status(500).json({ error: 'Failed to update webhook' }); + await prisma.webhookEndpoint.update({ + where: { id }, + data: { + ...(targetUrl && { targetUrl }), + ...(events && { events: JSON.stringify(events) }), + }, + }); + return res.json({ message: 'Webhook updated' }); + } catch (err) { + return res.status(500).json({ error: 'Failed to update webhook' }); + } } -}); +); /** * @swagger @@ -1777,22 +1957,26 @@ app.patch('/api/webhooks/config/:id', requireAuth, async (req: AuthenticatedRequ * 204: * description: Webhook deleted */ -app.delete('/api/webhooks/config/:id', requireAuth, async (req: AuthenticatedRequest, res: Response) => { - try { - const userId = req.user?.userId; - if (!userId) return res.status(401).json({ error: 'Unauthorized' }); - const id = req.params.id as string; - - const hook = await prisma.webhookEndpoint.findUnique({ where: { id } }); - if (!hook || hook.userId !== userId) return res.status(404).json({ error: 'Not found' }); - - await prisma.webhookEndpoint.delete({ where: { id } }); - return res.json({ message: 'Webhook deleted' }); - } catch (err) { - return res.status(500).json({ error: 'Failed to delete webhook' }); +app.delete( + '/api/webhooks/config/:id', + requireAuth, + async (req: AuthenticatedRequest, res: Response) => { + try { + const userId = req.user?.userId; + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); + const id = req.params.id as string; + + const hook = await prisma.webhookEndpoint.findUnique({ where: { id } }); + if (!hook || hook.userId !== userId) + return res.status(404).json({ error: 'Not found' }); + + await prisma.webhookEndpoint.delete({ where: { id } }); + return res.json({ message: 'Webhook deleted' }); + } catch (err) { + return res.status(500).json({ error: 'Failed to delete webhook' }); + } } -}); - +); /** * GET /api/emails @@ -1840,39 +2024,50 @@ app.delete('/api/webhooks/config/:id', requireAuth, async (req: AuthenticatedReq * 200: * description: Array of email objects */ -app.get('/api/emails', requireAuth, async (req: AuthenticatedRequest, res: Response) => { - try { - const userId = req.user?.userId; - if (!userId) return res.status(401).json({ error: 'Unauthorized' }); - - const limit = parseInt(req.query.limit as string) || 10; - const offset = parseInt(req.query.offset as string) || 0; - const category = req.query.category as string | undefined; - - const where: any = { userId }; - if (category && category !== 'all') where.category = category; - - const [emails, total] = await Promise.all([ - prisma.email.findMany({ - where, - orderBy: { createdAt: 'desc' }, - take: limit, - skip: offset, - select: { - id: true, messageId: true, sender: true, recipient: true, - subject: true, body: true, status: true, category: true, - createdAt: true, threadId: true - } - }), - prisma.email.count({ where }) - ]); - - return res.json({ emails, total, limit, offset }); - } catch (err) { - console.error('GET /api/emails error:', err); - return res.status(500).json({ error: 'Failed to fetch emails' }); +app.get( + '/api/emails', + requireAuth, + async (req: AuthenticatedRequest, res: Response) => { + try { + const userId = req.user?.userId; + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); + + const limit = parseInt(req.query.limit as string) || 10; + const offset = parseInt(req.query.offset as string) || 0; + const category = req.query.category as string | undefined; + + const where: any = { userId }; + if (category && category !== 'all') where.category = category; + + const [emails, total] = await Promise.all([ + prisma.email.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: limit, + skip: offset, + select: { + id: true, + messageId: true, + sender: true, + recipient: true, + subject: true, + body: true, + status: true, + category: true, + createdAt: true, + threadId: true, + }, + }), + prisma.email.count({ where }), + ]); + + return res.json({ emails, total, limit, offset }); + } catch (err) { + console.error('GET /api/emails error:', err); + return res.status(500).json({ error: 'Failed to fetch emails' }); + } } -}); +); /** * GET /api/emails/:id @@ -1924,105 +2119,141 @@ app.get('/api/emails', requireAuth, async (req: AuthenticatedRequest, res: Respo * GET /api/emails/search * Search emails by subject or body. */ -app.get('/api/emails/search', requireAuth, async (req: AuthenticatedRequest, res: Response) => { - try { - const userId = req.user?.userId; - if (!userId) return res.status(401).json({ error: 'Unauthorized' }); - - const q = req.query.q as string; - if (!q || !q.trim()) { - return res.status(400).json({ error: 'Query parameter "q" is required and cannot be empty' }); - } +app.get( + '/api/emails/search', + requireAuth, + async (req: AuthenticatedRequest, res: Response) => { + try { + const userId = req.user?.userId; + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); - const rawLimit = parseInt(req.query.limit as string); - const limit = isNaN(rawLimit) || rawLimit <= 0 ? 20 : Math.min(rawLimit, 20); - - const rawOffset = parseInt(req.query.offset as string); - const offset = isNaN(rawOffset) || rawOffset < 0 ? 0 : rawOffset; - - const where = { - userId, - OR: [ - { subject: { contains: q } }, - { body: { contains: q } } - ] - }; - - const [emails, total] = await Promise.all([ - prisma.email.findMany({ - where, - take: limit, - skip: offset, - orderBy: { createdAt: 'desc' } - }), - prisma.email.count({ where }) - ]); - - return res.json({ - emails, - pagination: { - total, - limit, - offset + const q = req.query.q as string; + if (!q || !q.trim()) { + return res.status(400).json({ + error: 'Query parameter "q" is required and cannot be empty', + }); } - }); - } catch (err: any) { - console.error('GET /api/emails/search error:', err); - return res.status(500).json({ error: 'Failed to search emails' }); - } -}); -app.get('/api/emails/:id', requireAuth, async (req: AuthenticatedRequest, res: Response) => { - try { - const userId = req.user?.userId; - if (!userId) return res.status(401).json({ error: 'Unauthorized' }); + const rawLimit = parseInt(req.query.limit as string); + const limit = + isNaN(rawLimit) || rawLimit <= 0 ? 20 : Math.min(rawLimit, 20); - const id = req.params.id as string; - const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - if (!uuidRegex.test(id)) { - return res.status(400).json({ error: 'Invalid email ID format' }); + const rawOffset = parseInt(req.query.offset as string); + const offset = isNaN(rawOffset) || rawOffset < 0 ? 0 : rawOffset; + + const where = { + userId, + OR: [{ subject: { contains: q } }, { body: { contains: q } }], + }; + + const [emails, total] = await Promise.all([ + prisma.email.findMany({ + where, + take: limit, + skip: offset, + orderBy: { createdAt: 'desc' }, + }), + prisma.email.count({ where }), + ]); + + return res.json({ + emails, + pagination: { + total, + limit, + offset, + }, + }); + } catch (err: any) { + console.error('GET /api/emails/search error:', err); + return res.status(500).json({ error: 'Failed to search emails' }); } + } +); - const email = await prisma.email.findUnique({ - where: { id }, - include: { - actionItems: true, - analysis: true, - thread: { - include: { - emails: { - orderBy: { - createdAt: 'asc' - } - } - } - } +app.get( + '/api/emails/:id', + requireAuth, + async (req: AuthenticatedRequest, res: Response) => { + try { + const userId = req.user?.userId; + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); + + const id = req.params.id as string; + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!uuidRegex.test(id)) { + return res.status(400).json({ error: 'Invalid email ID format' }); } - }); - if (!email || email.userId !== userId) { - return res.status(404).json({ error: 'Email not found' }); - } + const email = await prisma.email.findUnique({ + where: { id }, + include: { + actionItems: true, + analysis: true, + thread: { + include: { + emails: { + orderBy: { + createdAt: 'asc', + }, + }, + }, + }, + }, + }); - return res.json(email); - } catch (error) { - console.error('GET /api/emails/:id error:', error); - return res.status(500).json({ error: 'Failed to fetch email details' }); + if (!email || email.userId !== userId) { + return res.status(404).json({ error: 'Email not found' }); + } + + return res.json(email); + } catch (error) { + console.error('GET /api/emails/:id error:', error); + return res.status(500).json({ error: 'Failed to fetch email details' }); + } } -}); +); /** * Rules Engine Validation Schemas */ const ruleConditionSchema = z.object({ - field: z.enum(['from', 'to', 'subject', 'body', 'category', 'priority', 'hasAttachments', 'senderDomain']), - operator: z.enum(['equals', 'contains', 'startsWith', 'endsWith', 'regex', 'gt', 'lt', 'in']), - value: z.string() + field: z.enum([ + 'from', + 'to', + 'subject', + 'body', + 'category', + 'priority', + 'hasAttachments', + 'senderDomain', + ]), + operator: z.enum([ + 'equals', + 'contains', + 'startsWith', + 'endsWith', + 'regex', + 'gt', + 'lt', + 'in', + ]), + value: z.string(), }); const ruleActionSchema = z.object({ - type: z.enum(['moveToFolder', 'applyLabel', 'markAsRead', 'markAsUrgent', 'forwardTo', 'webhook', 'sendTelegram', 'sendWhatsApp']), - config: z.record(z.string(), z.any()) + type: z.enum([ + 'moveToFolder', + 'applyLabel', + 'markAsRead', + 'markAsUrgent', + 'forwardTo', + 'webhook', + 'sendTelegram', + 'sendWhatsApp', + ]), + config: z.record(z.string(), z.any()), }); const createRuleSchema = z.object({ @@ -2030,7 +2261,7 @@ const createRuleSchema = z.object({ description: z.string().optional(), priority: z.number().int().default(0), conditions: z.array(ruleConditionSchema).min(1), - actions: z.array(ruleActionSchema).min(1) + actions: z.array(ruleActionSchema).min(1), }); /** @@ -2065,26 +2296,30 @@ const createRuleSchema = z.object({ * 200: * description: Array of rule objects */ -app.get('/api/rules', requireAuth, async (req: AuthenticatedRequest, res: Response) => { - try { - const userId = req.user?.userId; - if (!userId) return res.status(401).json({ error: 'Unauthorized' }); - - const rules = await prisma.rule.findMany({ - where: { userId }, - orderBy: { priority: 'desc' }, - include: { - conditions: true, - actions: true - } - }); +app.get( + '/api/rules', + requireAuth, + async (req: AuthenticatedRequest, res: Response) => { + try { + const userId = req.user?.userId; + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); - return res.json(rules); - } catch (error) { - console.error('GET /api/rules error:', error); - return res.status(500).json({ error: 'Failed to fetch rules' }); + const rules = await prisma.rule.findMany({ + where: { userId }, + orderBy: { priority: 'desc' }, + include: { + conditions: true, + actions: true, + }, + }); + + return res.json(rules); + } catch (error) { + console.error('GET /api/rules error:', error); + return res.status(500).json({ error: 'Failed to fetch rules' }); + } } -}); +); /** * POST /api/rules @@ -2132,46 +2367,51 @@ app.get('/api/rules', requireAuth, async (req: AuthenticatedRequest, res: Respon * 201: * description: Rule created */ -app.post('/api/rules', requireAuth, async (req: AuthenticatedRequest, res: Response) => { - try { - const userId = req.user?.userId; - if (!userId) return res.status(401).json({ error: 'Unauthorized' }); - - const validation = createRuleSchema.safeParse(req.body); - if (!validation.success) { - return res.status(400).json({ - error: 'Invalid request payload', - details: validation.error.flatten() - }); - } +app.post( + '/api/rules', + requireAuth, + async (req: AuthenticatedRequest, res: Response) => { + try { + const userId = req.user?.userId; + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); + + const validation = createRuleSchema.safeParse(req.body); + if (!validation.success) { + return res.status(400).json({ + error: 'Invalid request payload', + details: validation.error.flatten(), + }); + } - const { name, description, priority, conditions, actions } = validation.data; + const { name, description, priority, conditions, actions } = + validation.data; - const newRule = await prisma.rule.create({ - data: { - userId, - name, - description, - priority, - conditions: { - create: conditions + const newRule = await prisma.rule.create({ + data: { + userId, + name, + description, + priority, + conditions: { + create: conditions, + }, + actions: { + create: actions as any, + }, }, - actions: { - create: actions as any - } - }, - include: { - conditions: true, - actions: true - } - }); + include: { + conditions: true, + actions: true, + }, + }); - return res.status(201).json(newRule); - } catch (error) { - console.error('POST /api/rules error:', error); - return res.status(500).json({ error: 'Failed to create rule' }); + return res.status(201).json(newRule); + } catch (error) { + console.error('POST /api/rules error:', error); + return res.status(500).json({ error: 'Failed to create rule' }); + } } -}); +); /** * GET /api/rules/:id @@ -2219,30 +2459,34 @@ app.post('/api/rules', requireAuth, async (req: AuthenticatedRequest, res: Respo * 200: * description: Rule object */ -app.get('/api/rules/:id', requireAuth, async (req: AuthenticatedRequest, res: Response) => { - try { - const userId = req.user?.userId; - if (!userId) return res.status(401).json({ error: 'Unauthorized' }); - - const id = req.params.id as string; - const rule = await prisma.rule.findUnique({ - where: { id }, - include: { - conditions: true, - actions: true +app.get( + '/api/rules/:id', + requireAuth, + async (req: AuthenticatedRequest, res: Response) => { + try { + const userId = req.user?.userId; + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); + + const id = req.params.id as string; + const rule = await prisma.rule.findUnique({ + where: { id }, + include: { + conditions: true, + actions: true, + }, + }); + + if (!rule || rule.userId !== userId) { + return res.status(404).json({ error: 'Rule not found' }); } - }); - if (!rule || rule.userId !== userId) { - return res.status(404).json({ error: 'Rule not found' }); + return res.json(rule); + } catch (error) { + console.error('GET /api/rules/:id error:', error); + return res.status(500).json({ error: 'Failed to fetch rule' }); } - - return res.json(rule); - } catch (error) { - console.error('GET /api/rules/:id error:', error); - return res.status(500).json({ error: 'Failed to fetch rule' }); } -}); +); /** * PUT /api/rules/:id @@ -2304,58 +2548,63 @@ app.get('/api/rules/:id', requireAuth, async (req: AuthenticatedRequest, res: Re * 200: * description: Rule updated */ -app.put('/api/rules/:id', requireAuth, async (req: AuthenticatedRequest, res: Response) => { - try { - const userId = req.user?.userId; - if (!userId) return res.status(401).json({ error: 'Unauthorized' }); +app.put( + '/api/rules/:id', + requireAuth, + async (req: AuthenticatedRequest, res: Response) => { + try { + const userId = req.user?.userId; + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); - const id = req.params.id as string; - const existingRule = await prisma.rule.findUnique({ where: { id } }); - if (!existingRule || existingRule.userId !== userId) { - return res.status(404).json({ error: 'Rule not found' }); - } + const id = req.params.id as string; + const existingRule = await prisma.rule.findUnique({ where: { id } }); + if (!existingRule || existingRule.userId !== userId) { + return res.status(404).json({ error: 'Rule not found' }); + } - const validation = createRuleSchema.safeParse(req.body); - if (!validation.success) { - return res.status(400).json({ - error: 'Invalid request payload', - details: validation.error.flatten() - }); - } + const validation = createRuleSchema.safeParse(req.body); + if (!validation.success) { + return res.status(400).json({ + error: 'Invalid request payload', + details: validation.error.flatten(), + }); + } - const { name, description, priority, conditions, actions } = validation.data; + const { name, description, priority, conditions, actions } = + validation.data; - // Run delete-then-create inside a transaction - const updatedRule = await prisma.$transaction(async (tx: any) => { - await tx.ruleCondition.deleteMany({ where: { ruleId: id } }); - await tx.ruleAction.deleteMany({ where: { ruleId: id } }); + // Run delete-then-create inside a transaction + const updatedRule = await prisma.$transaction(async (tx: any) => { + await tx.ruleCondition.deleteMany({ where: { ruleId: id } }); + await tx.ruleAction.deleteMany({ where: { ruleId: id } }); - return tx.rule.update({ - where: { id }, - data: { - name, - description, - priority, - conditions: { - create: conditions + return tx.rule.update({ + where: { id }, + data: { + name, + description, + priority, + conditions: { + create: conditions, + }, + actions: { + create: actions as any, + }, }, - actions: { - create: actions as any - } - }, - include: { - conditions: true, - actions: true - } + include: { + conditions: true, + actions: true, + }, + }); }); - }); - return res.json(updatedRule); - } catch (error) { - console.error('PUT /api/rules/:id error:', error); - return res.status(500).json({ error: 'Failed to update rule' }); + return res.json(updatedRule); + } catch (error) { + console.error('PUT /api/rules/:id error:', error); + return res.status(500).json({ error: 'Failed to update rule' }); + } } -}); +); /** * DELETE /api/rules/:id @@ -2403,25 +2652,29 @@ app.put('/api/rules/:id', requireAuth, async (req: AuthenticatedRequest, res: Re * 204: * description: Rule deleted */ -app.delete('/api/rules/:id', requireAuth, async (req: AuthenticatedRequest, res: Response) => { - try { - const userId = req.user?.userId; - if (!userId) return res.status(401).json({ error: 'Unauthorized' }); +app.delete( + '/api/rules/:id', + requireAuth, + async (req: AuthenticatedRequest, res: Response) => { + try { + const userId = req.user?.userId; + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); - const id = req.params.id as string; - const rule = await prisma.rule.findUnique({ where: { id } }); - if (!rule || rule.userId !== userId) { - return res.status(404).json({ error: 'Rule not found' }); - } + const id = req.params.id as string; + const rule = await prisma.rule.findUnique({ where: { id } }); + if (!rule || rule.userId !== userId) { + return res.status(404).json({ error: 'Rule not found' }); + } - await prisma.rule.delete({ where: { id } }); + await prisma.rule.delete({ where: { id } }); - return res.json({ message: 'Rule deleted successfully' }); - } catch (error) { - console.error('DELETE /api/rules/:id error:', error); - return res.status(500).json({ error: 'Failed to delete rule' }); + return res.json({ message: 'Rule deleted successfully' }); + } catch (error) { + console.error('DELETE /api/rules/:id error:', error); + return res.status(500).json({ error: 'Failed to delete rule' }); + } } -}); +); /** * POST /api/rules/:id/toggle @@ -2469,28 +2722,35 @@ app.delete('/api/rules/:id', requireAuth, async (req: AuthenticatedRequest, res: * 200: * description: Rule toggled */ -app.post('/api/rules/:id/toggle', requireAuth, async (req: AuthenticatedRequest, res: Response) => { - try { - const userId = req.user?.userId; - if (!userId) return res.status(401).json({ error: 'Unauthorized' }); +app.post( + '/api/rules/:id/toggle', + requireAuth, + async (req: AuthenticatedRequest, res: Response) => { + try { + const userId = req.user?.userId; + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); - const id = req.params.id as string; - const rule = await prisma.rule.findUnique({ where: { id } }); - if (!rule || rule.userId !== userId) { - return res.status(404).json({ error: 'Rule not found' }); - } + const id = req.params.id as string; + const rule = await prisma.rule.findUnique({ where: { id } }); + if (!rule || rule.userId !== userId) { + return res.status(404).json({ error: 'Rule not found' }); + } - const updated = await prisma.rule.update({ - where: { id }, - data: { isActive: !rule.isActive } - }); + const updated = await prisma.rule.update({ + where: { id }, + data: { isActive: !rule.isActive }, + }); - return res.json({ message: 'Rule toggled successfully', isActive: updated.isActive }); - } catch (error) { - console.error('POST /api/rules/:id/toggle error:', error); - return res.status(500).json({ error: 'Failed to toggle rule' }); + return res.json({ + message: 'Rule toggled successfully', + isActive: updated.isActive, + }); + } catch (error) { + console.error('POST /api/rules/:id/toggle error:', error); + return res.status(500).json({ error: 'Failed to toggle rule' }); + } } -}); +); /** * GET /api/auth/google @@ -2523,9 +2783,13 @@ app.get('/api/auth/google', (req: Request, res: Response) => { const client = getOAuth2Client(req); const url = client.generateAuthUrl({ access_type: 'offline', - scope: ['https://mail.google.com/', 'https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.profile'], + scope: [ + 'https://mail.google.com/', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + ], prompt: 'consent', - state: 'google-signin' // special flag — callback will auto-create user + state: 'google-signin', // special flag — callback will auto-create user }); return res.json({ url }); }); @@ -2556,13 +2820,16 @@ const server = app.listen(PORT, () => { registerWorkerHandlers().catch((err) => { logger.error('Failed to register inline worker handlers:', err); }); - + // Register EventBus fallback handler AFTER server is listening // to avoid blocking startup if Redis is slow or unavailable. // This allows graceful degradation while the server remains responsive. EventBus.onFallback(() => { registerWorkerHandlers().catch((err) => { - console.error('Failed to register inline worker handlers on EventBus fallback:', err); + console.error( + 'Failed to register inline worker handlers on EventBus fallback:', + err + ); }); }); }); @@ -2576,20 +2843,27 @@ const io = new SocketIoServer(server, { return; } const originClean = origin.replace(/\/$/, ''); - const isVercelPreview = originClean.startsWith('https://inbox-os-frontend') && originClean.endsWith('.vercel.app'); - const isAllowed = isVercelPreview || allowedOrigins.some(o => - originClean === o || - (o.startsWith('http://localhost') && originClean.startsWith('http://localhost:')) || - (o.startsWith('http://127.0.0.1') && originClean.startsWith('http://127.0.0.1:')) - ); + const isVercelPreview = + originClean.startsWith('https://inbox-os-frontend') && + originClean.endsWith('.vercel.app'); + const isAllowed = + isVercelPreview || + allowedOrigins.some( + (o) => + originClean === o || + (o.startsWith('http://localhost') && + originClean.startsWith('http://localhost:')) || + (o.startsWith('http://127.0.0.1') && + originClean.startsWith('http://127.0.0.1:')) + ); if (isAllowed) { callback(null, true); } else { callback(new Error('Not allowed by CORS')); } }, - credentials: true - } + credentials: true, + }, }); WebSocketService.initialize(io); diff --git a/backend/src/services/actions/calendar-creator.service.ts b/backend/src/services/actions/calendar-creator.service.ts index 83f9567..1eb89d8 100644 --- a/backend/src/services/actions/calendar-creator.service.ts +++ b/backend/src/services/actions/calendar-creator.service.ts @@ -51,8 +51,11 @@ export class CalendarCreatorService { } // 3. Configure Google OAuth2 Client - const googleCalendarRedirectUri = process.env.GOOGLE_CALENDAR_REDIRECT_URI || - (process.env.RENDER_EXTERNAL_URL ? `${process.env.RENDER_EXTERNAL_URL.replace(/\/$/, '')}/api/integrations/google_calendar/callback` : 'http://localhost:8000/api/integrations/google_calendar/callback'); + const googleCalendarRedirectUri = + process.env.GOOGLE_CALENDAR_REDIRECT_URI || + (process.env.RENDER_EXTERNAL_URL + ? `${process.env.RENDER_EXTERNAL_URL.replace(/\/$/, '')}/api/integrations/google_calendar/callback` + : 'http://localhost:8000/api/integrations/google_calendar/callback'); const oauth2Client = new google.auth.OAuth2( process.env.GMAIL_CLIENT_ID, diff --git a/backend/src/services/actions/reminder-scheduler.service.ts b/backend/src/services/actions/reminder-scheduler.service.ts index 5fed380..08fb676 100644 --- a/backend/src/services/actions/reminder-scheduler.service.ts +++ b/backend/src/services/actions/reminder-scheduler.service.ts @@ -236,7 +236,9 @@ export class ReminderSchedulerService { } public static initWorker(): void { - logger.info('[ReminderScheduler] initWorker stub called (delegated to separate worker process)'); + logger.info( + '[ReminderScheduler] initWorker stub called (delegated to separate worker process)' + ); } public static async shutdown(): Promise { diff --git a/backend/src/services/ai-providers/ollama.provider.ts b/backend/src/services/ai-providers/ollama.provider.ts index 7aa6813..d4dcbd3 100644 --- a/backend/src/services/ai-providers/ollama.provider.ts +++ b/backend/src/services/ai-providers/ollama.provider.ts @@ -245,11 +245,16 @@ Summary:`; const embedding = response.data.embedding; if (!embedding || !Array.isArray(embedding)) { - throw new Error('[Ollama] Response did not contain a valid embedding array.'); + throw new Error( + '[Ollama] Response did not contain a valid embedding array.' + ); } return embedding; } catch (error: any) { - console.error('[Ollama] Embedding generation failed:', error.message || error); + console.error( + '[Ollama] Embedding generation failed:', + error.message || error + ); throw error; } } diff --git a/backend/src/services/ai.service.ts b/backend/src/services/ai.service.ts index 1461641..fb8356a 100644 --- a/backend/src/services/ai.service.ts +++ b/backend/src/services/ai.service.ts @@ -604,7 +604,9 @@ Provide a confidence score between 0.0 and 1.0. Also, extract all deadlines ment if (!item.deadline || item.deadline.trim() === '') { // Try parsing the task description first - let fallbackDate = this.parseDateWithChrono(item.taskDescription || item.task || ''); + let fallbackDate = this.parseDateWithChrono( + item.taskDescription || item.task || '' + ); if (!fallbackDate) { // If not found in task description, parse the email body fallbackDate = this.parseDateWithChrono(body); @@ -1397,7 +1399,9 @@ Do NOT infer or fabricate deadlines. Only return dates explicitly stated.`; const rawContent = response.choices[0]?.message?.content; if (!rawContent) { - throw new Error('OpenAI returned an empty deadline extraction response.'); + throw new Error( + 'OpenAI returned an empty deadline extraction response.' + ); } const result = JSON.parse(rawContent) as { deadlines: string[] }; return result.deadlines || []; @@ -1413,7 +1417,10 @@ Do NOT infer or fabricate deadlines. Only return dates explicitly stated.`; await new Promise((resolve) => setTimeout(resolve, delay)); delay *= 2; } else { - console.error('[AIService] Deadline extraction (OpenAI) failed:', error); + console.error( + '[AIService] Deadline extraction (OpenAI) failed:', + error + ); if (attempt >= maxAttempts) return []; throw error; } @@ -1464,7 +1471,9 @@ Do NOT infer or fabricate deadlines. Only return dates explicitly stated.`; const rawContent = response.text; if (!rawContent) { - throw new Error('Gemini returned an empty deadline extraction response.'); + throw new Error( + 'Gemini returned an empty deadline extraction response.' + ); } const result = JSON.parse(rawContent) as { deadlines: string[] }; return result.deadlines || []; @@ -1483,7 +1492,10 @@ Do NOT infer or fabricate deadlines. Only return dates explicitly stated.`; await new Promise((resolve) => setTimeout(resolve, delay)); delay *= 2; } else { - console.error('[AIService] Deadline extraction (Gemini) failed:', error); + console.error( + '[AIService] Deadline extraction (Gemini) failed:', + error + ); if (attempt >= maxAttempts) return []; throw error; } diff --git a/backend/src/services/ai/feedback-collector.service.ts b/backend/src/services/ai/feedback-collector.service.ts index e6c5be4..f377847 100644 --- a/backend/src/services/ai/feedback-collector.service.ts +++ b/backend/src/services/ai/feedback-collector.service.ts @@ -26,7 +26,11 @@ export class FeedbackCollectorService { public static async recordFeedback( userId: string, emailId: string, - feedbackType: 'thumbs_up' | 'thumbs_down' | 'category_correction' | 'priority_adjustment', + feedbackType: + | 'thumbs_up' + | 'thumbs_down' + | 'category_correction' + | 'priority_adjustment', correctedValue?: string ): Promise { // 1. Fetch email by emailId @@ -36,12 +40,14 @@ export class FeedbackCollectorService { // 2. If email is not found, handle gracefully (ignore, don't crash) if (!email) { - console.warn(`[FeedbackCollector] Email not found for feedback (emailId: ${emailId}). Ignoring feedback.`); + console.warn( + `[FeedbackCollector] Email not found for feedback (emailId: ${emailId}). Ignoring feedback.` + ); return; } // 3. Determine original value - let originalValue = email.category || 'unclassified'; + const originalValue = email.category || 'unclassified'; // 4. Save feedback in the database await this.prisma.userFeedback.create({ @@ -76,7 +82,10 @@ export class FeedbackCollectorService { try { profile = JSON.parse(settings.aiPreferenceProfile); } catch (e) { - console.error('[FeedbackCollector] Failed to parse aiPreferenceProfile JSON, reinitializing profile.', e); + console.error( + '[FeedbackCollector] Failed to parse aiPreferenceProfile JSON, reinitializing profile.', + e + ); } } @@ -103,18 +112,22 @@ export class FeedbackCollectorService { // Incremental update based on feedback type if (feedbackType === 'category_correction' && correctedValue) { const correctionKey = `${originalValue}->${correctedValue}`; - weekProfile.categoryCorrections[correctionKey] = (weekProfile.categoryCorrections[correctionKey] || 0) + 1; + weekProfile.categoryCorrections[correctionKey] = + (weekProfile.categoryCorrections[correctionKey] || 0) + 1; } else if (feedbackType === 'thumbs_up') { if (email.sender) { - weekProfile.preferredSenders[email.sender] = (weekProfile.preferredSenders[email.sender] || 0) + 1; + weekProfile.preferredSenders[email.sender] = + (weekProfile.preferredSenders[email.sender] || 0) + 1; } } else if (feedbackType === 'thumbs_down') { if (originalValue) { - weekProfile.ignoredCategories[originalValue] = (weekProfile.ignoredCategories[originalValue] || 0) + 1; + weekProfile.ignoredCategories[originalValue] = + (weekProfile.ignoredCategories[originalValue] || 0) + 1; } } else if (feedbackType === 'priority_adjustment' && correctedValue) { const adjustmentKey = `${originalValue}->${correctedValue}`; - weekProfile.priorityAdjustments[adjustmentKey] = (weekProfile.priorityAdjustments[adjustmentKey] || 0) + 1; + weekProfile.priorityAdjustments[adjustmentKey] = + (weekProfile.priorityAdjustments[adjustmentKey] || 0) + 1; } // 7. Save updated profile as JSON string diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts index 0230c31..fd6d6c9 100644 --- a/backend/src/services/auth.service.ts +++ b/backend/src/services/auth.service.ts @@ -27,7 +27,11 @@ export class AuthService { /** * Generates a JWT token containing the user's ID, email, and username. */ - public static generateToken(userId: string, email: string, username?: string | null): string { + public static generateToken( + userId: string, + email: string, + username?: string | null + ): string { return jwt.sign({ userId, email, username: username || null }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, }); diff --git a/backend/src/services/email-sender.service.ts b/backend/src/services/email-sender.service.ts index f4c6bad..ce8cc7c 100644 --- a/backend/src/services/email-sender.service.ts +++ b/backend/src/services/email-sender.service.ts @@ -15,7 +15,8 @@ const prisma = new PrismaClient(); // 'access_revoked' → User explicitly revoked app access. // 'unknown' → Any other OAuth-family 401/403 error. export class GmailAuthError extends Error { - public readonly gmailCause: 'token_expired' | 'client_mismatch' | 'access_revoked' | 'unknown'; + public readonly gmailCause: + 'token_expired' | 'client_mismatch' | 'access_revoked' | 'unknown'; public readonly userId: string; public readonly accountId: string; public readonly originalCode: string | number | undefined; @@ -51,7 +52,9 @@ function classifyGoogleAuthError( // invalid_grant → refresh token rejected; most often redirect_uri or client rotation if (errorStr === 'invalid_grant' || errMessage.includes('invalid_grant')) { - const gmailCause = errMessage.includes('revoked') ? 'access_revoked' : 'token_expired'; + const gmailCause = errMessage.includes('revoked') + ? 'access_revoked' + : 'token_expired'; logger.error('[GmailAuth] Token invalid/expired (invalid_grant)', { gmailCause, userId, @@ -63,15 +66,27 @@ function classifyGoogleAuthError( } // unauthorized_client → OAuth client ID/secret mismatch or redirect_uri mismatch at token exchange - if (errorStr === 'unauthorized_client' || errMessage.includes('unauthorized_client')) { - logger.error('[GmailAuth] Unauthorized client — likely redirect_uri or client ID change', { - gmailCause: 'client_mismatch', + if ( + errorStr === 'unauthorized_client' || + errMessage.includes('unauthorized_client') + ) { + logger.error( + '[GmailAuth] Unauthorized client — likely redirect_uri or client ID change', + { + gmailCause: 'client_mismatch', + userId, + accountId, + errorCode: code, + details: err.message, + } + ); + return new GmailAuthError( + err.message, + 'client_mismatch', userId, accountId, - errorCode: code, - details: err.message, - }); - return new GmailAuthError(err.message, 'client_mismatch', userId, accountId, code); + code + ); } // access_denied → explicit user revocation @@ -82,7 +97,13 @@ function classifyGoogleAuthError( accountId, errorCode: code, }); - return new GmailAuthError(err.message, 'access_revoked', userId, accountId, code); + return new GmailAuthError( + err.message, + 'access_revoked', + userId, + accountId, + code + ); } // Generic 401/403 or token-shaped error @@ -124,8 +145,11 @@ export class EmailSenderService { // ── GMAIL OUTBOUND (GMAIL API) ─────────────────────────────────────────── if (account.provider === 'gmail') { const tokens = JSON.parse(decrypt(account.encryptedTokens)); - const redirectUri = process.env.GMAIL_REDIRECT_URI || - (process.env.RENDER_EXTERNAL_URL ? `${process.env.RENDER_EXTERNAL_URL.replace(/\/$/, '')}/api/integrations/gmail/callback` : 'http://localhost:8000/api/integrations/gmail/callback'); + const redirectUri = + process.env.GMAIL_REDIRECT_URI || + (process.env.RENDER_EXTERNAL_URL + ? `${process.env.RENDER_EXTERNAL_URL.replace(/\/$/, '')}/api/integrations/gmail/callback` + : 'http://localhost:8000/api/integrations/gmail/callback'); const oauth2Client = new google.auth.OAuth2( process.env.GMAIL_CLIENT_ID, diff --git a/backend/src/services/gmail-sync.service.ts b/backend/src/services/gmail-sync.service.ts index 07a415f..60a94fd 100644 --- a/backend/src/services/gmail-sync.service.ts +++ b/backend/src/services/gmail-sync.service.ts @@ -24,8 +24,11 @@ export class GmailSyncService { // 2. Decrypt tokens and set up OAuth client const tokens = JSON.parse(decrypt(account.encryptedTokens)); - const redirectUri = process.env.GMAIL_REDIRECT_URI || - (process.env.RENDER_EXTERNAL_URL ? `${process.env.RENDER_EXTERNAL_URL.replace(/\/$/, '')}/api/integrations/gmail/callback` : 'http://localhost:8000/api/integrations/gmail/callback'); + const redirectUri = + process.env.GMAIL_REDIRECT_URI || + (process.env.RENDER_EXTERNAL_URL + ? `${process.env.RENDER_EXTERNAL_URL.replace(/\/$/, '')}/api/integrations/gmail/callback` + : 'http://localhost:8000/api/integrations/gmail/callback'); const oauth2Client = new google.auth.OAuth2( process.env.GMAIL_CLIENT_ID, diff --git a/backend/src/services/outputs/email-digest.adapter.ts b/backend/src/services/outputs/email-digest.adapter.ts index 0a7e21c..04de0b3 100644 --- a/backend/src/services/outputs/email-digest.adapter.ts +++ b/backend/src/services/outputs/email-digest.adapter.ts @@ -93,8 +93,8 @@ export class EmailDigestAdapter { err.gmailCause === 'client_mismatch' ? 'Your Gmail connection was invalidated by a server configuration change. Please reconnect your Gmail account in Settings → Integrations.' : err.gmailCause === 'access_revoked' - ? 'It looks like you revoked InboxOS access to your Gmail account. Please reconnect in Settings → Integrations to resume email digests.' - : 'Your Gmail connection has expired. Please reconnect your Gmail account in Settings → Integrations to resume email digests.'; + ? 'It looks like you revoked InboxOS access to your Gmail account. Please reconnect in Settings → Integrations to resume email digests.' + : 'Your Gmail connection has expired. Please reconnect your Gmail account in Settings → Integrations to resume email digests.'; await prisma.notification.create({ data: { @@ -123,7 +123,9 @@ export class EmailDigestAdapter { where: { id: digest.id }, data: { status: 'failed' }, }); - } catch (_) { /* best effort */ } + } catch (_) { + /* best effort */ + } // DO NOT re-throw — suppresses BullMQ retry for dead tokens return; diff --git a/backend/src/services/telegram-bot.service.ts b/backend/src/services/telegram-bot.service.ts index 7905369..76cea50 100644 --- a/backend/src/services/telegram-bot.service.ts +++ b/backend/src/services/telegram-bot.service.ts @@ -236,7 +236,12 @@ export class TelegramBotService { if (response.status === 429) { const retryAfterHeader = response.headers['retry-after']; const retryAfterSeconds = retryAfterHeader - ? parseInt(Array.isArray(retryAfterHeader) ? retryAfterHeader[0] : retryAfterHeader, 10) + ? parseInt( + Array.isArray(retryAfterHeader) + ? retryAfterHeader[0] + : retryAfterHeader, + 10 + ) : Math.pow(2, attempt); logger.warn( `[TelegramBot] Rate limited (429) on API call "${method}". Retrying in ${retryAfterSeconds}s...` diff --git a/backend/src/services/telegram-notification.service.ts b/backend/src/services/telegram-notification.service.ts index cd339c5..2b1e0b1 100644 --- a/backend/src/services/telegram-notification.service.ts +++ b/backend/src/services/telegram-notification.service.ts @@ -149,9 +149,12 @@ export class TelegramNotificationService { ? `*Email:* ${emailSubject}\n*Deadline:* ${deadlineFormatted}\n\n_This deadline has passed. Please follow up._` : `*Email:* ${emailSubject}\n*Deadline:* ${deadlineFormatted}\n*Alert:* ${offsetLabel} reminder`; - const priority = isOverdue ? 'high' : offsetLabel === 'at deadline' ? 'high' : 'normal'; + const priority = isOverdue + ? 'high' + : offsetLabel === 'at deadline' + ? 'high' + : 'normal'; return this.sendNotification(chatId, title, content, priority); } } - diff --git a/backend/src/test-classifier.ts b/backend/src/test-classifier.ts index 61651d2..f6631dc 100644 --- a/backend/src/test-classifier.ts +++ b/backend/src/test-classifier.ts @@ -122,9 +122,13 @@ async function runClassifierTests() { console.log('- Extracted Deadlines:', resultD1.deadlines); const expectedDeadline = '2026-07-15T23:59:00Z'; if (resultD1.deadlines.includes(expectedDeadline)) { - console.log('✅ PASSED: Correctly extracted July 15, 2026 deadline as 2026-07-15T23:59:00Z!\n'); + console.log( + '✅ PASSED: Correctly extracted July 15, 2026 deadline as 2026-07-15T23:59:00Z!\n' + ); } else { - console.error(`❌ FAILED: Expected deadline ${expectedDeadline} not found in result.\n`); + console.error( + `❌ FAILED: Expected deadline ${expectedDeadline} not found in result.\n` + ); process.exit(1); } @@ -138,7 +142,9 @@ async function runClassifierTests() { if (resultD2.deadlines.length > 0) { console.log('✅ PASSED: Relative date ("Tomorrow") parsed successfully!\n'); } else { - console.error('❌ FAILED: No deadline extracted for relative date "Tomorrow".\n'); + console.error( + '❌ FAILED: No deadline extracted for relative date "Tomorrow".\n' + ); process.exit(1); } @@ -150,9 +156,13 @@ async function runClassifierTests() { ); console.log('- Extracted Deadlines:', resultD3.deadlines); if (resultD3.deadlines.length > 0) { - console.log('✅ PASSED: Relative date ("Next Monday") parsed successfully!\n'); + console.log( + '✅ PASSED: Relative date ("Next Monday") parsed successfully!\n' + ); } else { - console.error('❌ FAILED: No deadline extracted for relative date "Next Monday".\n'); + console.error( + '❌ FAILED: No deadline extracted for relative date "Next Monday".\n' + ); process.exit(1); } @@ -163,11 +173,15 @@ async function runClassifierTests() { 'Reminder: Submit by July 15, 2026.' ); console.log('- Extracted Deadlines:', resultD4.deadlines); - const occurrences = resultD4.deadlines.filter(d => d === expectedDeadline).length; + const occurrences = resultD4.deadlines.filter( + (d) => d === expectedDeadline + ).length; if (occurrences === 1) { console.log('✅ PASSED: Deadlines correctly deduplicated!\n'); } else { - console.error(`❌ FAILED: Expected 1 occurrence of ${expectedDeadline}, got ${occurrences}.\n`); + console.error( + `❌ FAILED: Expected 1 occurrence of ${expectedDeadline}, got ${occurrences}.\n` + ); process.exit(1); } @@ -177,11 +191,22 @@ async function runClassifierTests() { 'DBMS Project Code', 'Submit DBMS report by July 15, 2026' ); - console.log('- Extracted Action Items:', JSON.stringify(actionItems, null, 2)); - if (actionItems.length > 0 && actionItems[0].task && actionItems[0].taskDescription) { - console.log('✅ PASSED: Action items extraction returned both task and taskDescription fields!\n'); + console.log( + '- Extracted Action Items:', + JSON.stringify(actionItems, null, 2) + ); + if ( + actionItems.length > 0 && + actionItems[0].task && + actionItems[0].taskDescription + ) { + console.log( + '✅ PASSED: Action items extraction returned both task and taskDescription fields!\n' + ); } else { - console.error('❌ FAILED: Action items extraction output is missing required fields.\n'); + console.error( + '❌ FAILED: Action items extraction output is missing required fields.\n' + ); process.exit(1); } diff --git a/backend/src/utils/bullmq-wrapper.ts b/backend/src/utils/bullmq-wrapper.ts index 967dac0..e9dbfec 100644 --- a/backend/src/utils/bullmq-wrapper.ts +++ b/backend/src/utils/bullmq-wrapper.ts @@ -1,4 +1,9 @@ -import { Queue as RealQueue, Worker as RealWorker, Job, ConnectionOptions } from 'bullmq'; +import { + Queue as RealQueue, + Worker as RealWorker, + Job, + ConnectionOptions, +} from 'bullmq'; import { RedisHealth } from './redis-health'; import { logger } from './logger'; @@ -12,7 +17,10 @@ export class Queue { private realQueue: RealQueue | null = null; private name: string; private connectionOpts: any; - private localRepeatJobs = new Map(); + private localRepeatJobs = new Map< + string, + { pattern: string; tz?: string; data: any; intervalId?: NodeJS.Timeout } + >(); constructor(name: string, options: any) { this.name = name; @@ -26,7 +34,10 @@ export class Queue { RedisHealth.handleError(err); }); } catch (err) { - logger.error(`[BullMQ Wrapper] Failed to instantiate real Queue "${name}":`, err); + logger.error( + `[BullMQ Wrapper] Failed to instantiate real Queue "${name}":`, + err + ); RedisHealth.handleError(err); } } @@ -34,7 +45,9 @@ export class Queue { // If Redis becomes disabled, clean up the real queue if it exists RedisHealth.onDisable(() => { if (this.realQueue) { - logger.info(`[BullMQ Wrapper] Closing real Queue "${this.name}" due to Redis disable.`); + logger.info( + `[BullMQ Wrapper] Closing real Queue "${this.name}" due to Redis disable.` + ); this.realQueue.close().catch(() => {}); this.realQueue = null; } @@ -43,7 +56,9 @@ export class Queue { async add(jobName: string, data: any, options?: any): Promise { if (RedisHealth.isDisabled() || !this.realQueue) { - logger.warn(`[BullMQ Wrapper] Redis disabled. Executing job "${jobName}" in-memory fallback.`); + logger.warn( + `[BullMQ Wrapper] Redis disabled. Executing job "${jobName}" in-memory fallback.` + ); const jobId = `mock-${this.name}-${Math.random().toString(36).substring(7)}`; @@ -88,7 +103,10 @@ export class Queue { log: async () => {}, }; processor(repeatJob).catch((err) => { - logger.error(`[BullMQ Wrapper] Mock repeat job "${jobName}" failed:`, err); + logger.error( + `[BullMQ Wrapper] Mock repeat job "${jobName}" failed:`, + err + ); }); } }; @@ -111,21 +129,31 @@ export class Queue { if (processor) { const delay = options?.delay || 0; if (delay > 0) { - logger.info(`[BullMQ Wrapper] Scheduling job "${jobName}" with delay ${delay}ms`); + logger.info( + `[BullMQ Wrapper] Scheduling job "${jobName}" with delay ${delay}ms` + ); setTimeout(() => { processor(mockJob).catch((err) => { - logger.error(`[BullMQ Wrapper] Mock job "${jobName}" failed:`, err); + logger.error( + `[BullMQ Wrapper] Mock job "${jobName}" failed:`, + err + ); }); }, delay); } else { setImmediate(() => { processor(mockJob).catch((err) => { - logger.error(`[BullMQ Wrapper] Mock job "${jobName}" failed:`, err); + logger.error( + `[BullMQ Wrapper] Mock job "${jobName}" failed:`, + err + ); }); }); } } else { - logger.warn(`[BullMQ Wrapper] No worker/processor registered for queue "${this.name}".`); + logger.warn( + `[BullMQ Wrapper] No worker/processor registered for queue "${this.name}".` + ); } return mockJob; @@ -134,7 +162,10 @@ export class Queue { try { return await this.realQueue.add(jobName, data, options); } catch (err) { - logger.error(`[BullMQ Wrapper] Queue.add failed for "${this.name}":`, err); + logger.error( + `[BullMQ Wrapper] Queue.add failed for "${this.name}":`, + err + ); RedisHealth.handleError(err); // Fallback inline execution return this.add(jobName, data, options); @@ -153,7 +184,10 @@ export class Queue { try { return await this.realQueue.getRepeatableJobs(); } catch (err) { - logger.error(`[BullMQ Wrapper] getRepeatableJobs failed for "${this.name}":`, err); + logger.error( + `[BullMQ Wrapper] getRepeatableJobs failed for "${this.name}":`, + err + ); RedisHealth.handleError(err); return []; } @@ -222,14 +256,19 @@ export class Worker { }); this.setupRealEvents(); } catch (err) { - logger.error(`[BullMQ Wrapper] Failed to instantiate real Worker "${name}":`, err); + logger.error( + `[BullMQ Wrapper] Failed to instantiate real Worker "${name}":`, + err + ); RedisHealth.handleError(err); } } RedisHealth.onDisable(() => { if (this.realWorker) { - logger.info(`[BullMQ Wrapper] Redis disabled. Closing real Worker "${this.name}" to prevent error loop.`); + logger.info( + `[BullMQ Wrapper] Redis disabled. Closing real Worker "${this.name}" to prevent error loop.` + ); this.realWorker.close().catch(() => {}); this.realWorker = null; } @@ -240,11 +279,11 @@ export class Worker { if (!this.realWorker) return; const events = ['completed', 'failed', 'error', 'active', 'progress']; - events.forEach(event => { + events.forEach((event) => { this.realWorker!.on(event as any, (...args: any[]) => { const handlers = this.eventHandlers.get(event); if (handlers) { - handlers.forEach(h => h(...args)); + handlers.forEach((h) => h(...args)); } }); }); diff --git a/backend/src/utils/redis-health.ts b/backend/src/utils/redis-health.ts index d91486a..4b950b1 100644 --- a/backend/src/utils/redis-health.ts +++ b/backend/src/utils/redis-health.ts @@ -15,8 +15,10 @@ export const RedisHealth = { if (val !== redisDisabled) { redisDisabled = val; if (val) { - logger.warn('⚠️ [RedisHealth] Redis has been disabled (unreachable or rate limited). Falling back to in-memory mode.'); - disableListeners.forEach(listener => { + logger.warn( + '⚠️ [RedisHealth] Redis has been disabled (unreachable or rate limited). Falling back to in-memory mode.' + ); + disableListeners.forEach((listener) => { try { listener(true); } catch (err) { @@ -52,7 +54,7 @@ export const RedisHealth = { ) { this.setDisabled(true); } - } + }, }; export class MockRedisClient { @@ -102,7 +104,9 @@ export class MockRedisClient { export function createRedisClient(url: string, options?: any): Redis { if (RedisHealth.isDisabled()) { - logger.info('[RedisHealth] Redis disabled at startup. Creating Mock Redis Client.'); + logger.info( + '[RedisHealth] Redis disabled at startup. Creating Mock Redis Client.' + ); return new MockRedisClient() as any; } @@ -119,7 +123,11 @@ export function createRedisClient(url: string, options?: any): Redis { } if (prop === 'on' || prop === 'addListener') { - return function(this: any, event: string, handler: (...args: any[]) => void) { + return function ( + this: any, + event: string, + handler: (...args: any[]) => void + ) { const wrappedHandler = (...args: any[]) => { if (event === 'error') { RedisHealth.handleError(args[0]); @@ -133,8 +141,12 @@ export function createRedisClient(url: string, options?: any): Redis { const orig = Reflect.get(target, prop, receiver); if (typeof orig === 'function') { const bound = orig.bind(target); - return function(this: any, ...args: any[]) { - if (RedisHealth.isDisabled() && prop !== 'quit' && prop !== 'disconnect') { + return function (this: any, ...args: any[]) { + if ( + RedisHealth.isDisabled() && + prop !== 'quit' && + prop !== 'disconnect' + ) { const mock = new MockRedisClient() as any; if (typeof mock[prop] === 'function') { return mock[prop](...args); @@ -148,7 +160,9 @@ export function createRedisClient(url: string, options?: any): Redis { return result.catch((err: any) => { RedisHealth.handleError(err); if (RedisHealth.isDisabled()) { - logger.warn(`[RedisHealth] Command "${String(prop)}" failed on Redis. Falling back.`); + logger.warn( + `[RedisHealth] Command "${String(prop)}" failed on Redis. Falling back.` + ); return null; } throw err; @@ -165,7 +179,7 @@ export function createRedisClient(url: string, options?: any): Redis { }; } return orig; - } + }, }); return proxy as any; diff --git a/backend/src/utils/redis-patch.ts b/backend/src/utils/redis-patch.ts index c68c42e..dbc4dc4 100644 --- a/backend/src/utils/redis-patch.ts +++ b/backend/src/utils/redis-patch.ts @@ -1,12 +1,16 @@ import { EventEmitter } from 'events'; import { RedisHealth, MockRedisClient } from './redis-health'; -const ioredisModule = require('ioredis'); -const OriginalRedis = ioredisModule.default || ioredisModule; +import Redis from 'ioredis'; +const OriginalRedis = Redis as any; // 1. Globally patch EventEmitter.prototype.emit to catch and swallow Redis-related unhandled errors const origEmit = EventEmitter.prototype.emit; -EventEmitter.prototype.emit = function(this: any, event: string, ...args: any[]) { +EventEmitter.prototype.emit = function ( + this: any, + event: string, + ...args: any[] +) { if (event === 'error') { const err = args[0]; const className = this.constructor ? this.constructor.name : ''; @@ -24,7 +28,9 @@ EventEmitter.prototype.emit = function(this: any, event: string, ...args: any[]) if (RedisHealth.isDisabled()) { // Swallowing the error event to prevent uncaught exception crashes - console.warn(`⚠️ [RedisPatch] Swallowed uncaught error on ${className}: ${err?.message || err}`); + console.warn( + `⚠️ [RedisPatch] Swallowed uncaught error on ${className}: ${err?.message || err}` + ); return false; } } @@ -34,7 +40,7 @@ EventEmitter.prototype.emit = function(this: any, event: string, ...args: any[]) // 2. Patch connect method to bypass connection once disabled const origConnect = OriginalRedis.prototype.connect; -OriginalRedis.prototype.connect = function(this: any, ...args: any[]) { +OriginalRedis.prototype.connect = function (this: any, ...args: any[]) { if (RedisHealth.isDisabled()) { return Promise.resolve(); } @@ -43,7 +49,11 @@ OriginalRedis.prototype.connect = function(this: any, ...args: any[]) { // 3. Patch sendCommand to bypass command sending once disabled and return mock responses const origSendCommand = OriginalRedis.prototype.sendCommand; -OriginalRedis.prototype.sendCommand = function(this: any, command: any, ...args: any[]) { +OriginalRedis.prototype.sendCommand = function ( + this: any, + command: any, + ...args: any[] +) { const name = command?.name || ''; if (RedisHealth.isDisabled() && name !== 'quit' && name !== 'disconnect') { @@ -77,8 +87,12 @@ OriginalRedis.prototype.sendCommand = function(this: any, command: any, ...args: }; // 4. Patch status getter/setter safely -const statusDesc = Object.getOwnPropertyDescriptor(OriginalRedis.prototype, 'status') - || Object.getOwnPropertyDescriptor(Object.getPrototypeOf(OriginalRedis.prototype), 'status'); +const statusDesc = + Object.getOwnPropertyDescriptor(OriginalRedis.prototype, 'status') || + Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(OriginalRedis.prototype), + 'status' + ); if (statusDesc) { Object.defineProperty(OriginalRedis.prototype, 'status', { @@ -95,8 +109,10 @@ if (statusDesc) { this._status = val; } }, - configurable: true + configurable: true, }); } -console.log('✅ [RedisPatch] Globally patched EventEmitter and ioredis safely.'); +console.log( + '✅ [RedisPatch] Globally patched EventEmitter and ioredis safely.' +); diff --git a/backend/src/worker.ts b/backend/src/worker.ts index 0cac864..14cfec3 100644 --- a/backend/src/worker.ts +++ b/backend/src/worker.ts @@ -215,16 +215,21 @@ export async function registerWorkerHandlers() { .filter((d) => !isNaN(d.getTime())); if (deadlineDates.length > 0) { - logger.info('[Worker] Scheduling reminders for extracted deadlines', { - emailId, - count: deadlineDates.length, - deadlines: deadlineDates.map((d) => d.toISOString()), - }); + logger.info( + '[Worker] Scheduling reminders for extracted deadlines', + { + emailId, + count: deadlineDates.length, + deadlines: deadlineDates.map((d) => d.toISOString()), + } + ); await ReminderSchedulerService.scheduleReminders( email.id, deadlineDates ); - logger.info('[Worker] Reminders scheduled successfully', { emailId }); + logger.info('[Worker] Reminders scheduled successfully', { + emailId, + }); } } else { logger.info('[Worker] No deadlines found in email', { emailId });