Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 74 additions & 27 deletions backend/src/__tests__/feedback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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);
});
});
});
27 changes: 20 additions & 7 deletions backend/src/check_emails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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)
);
}
}

Expand Down
5 changes: 4 additions & 1 deletion backend/src/config/swagger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
5 changes: 4 additions & 1 deletion backend/src/jobs/calendar-events.job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
8 changes: 5 additions & 3 deletions backend/src/jobs/digest-scheduler.job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion backend/src/jobs/index-emails.job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
5 changes: 4 additions & 1 deletion backend/src/jobs/reminder.job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 3 additions & 3 deletions backend/src/middleware/rate-limiter.middleware.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Request, Response } from 'express';
import { rateLimit } from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import Redis from 'ioredis';

Check warning on line 4 in backend/src/middleware/rate-limiter.middleware.ts

View workflow job for this annotation

GitHub Actions / build-and-test

'Redis' is defined but never used
import { AuthService } from '../services/auth.service';
import { createRedisClient, RedisHealth } from '../utils/redis-health';

Expand Down Expand Up @@ -57,7 +57,7 @@
resetKey: async (key: string) => {
memoryStore.hits.delete(key);
memoryStore.resetTimes.delete(key);
}
},
};

class HybridStore {
Expand All @@ -72,7 +72,7 @@
throw new Error('Redis is disabled');
}
return redisClient.call(args[0], ...args.slice(1));
}
},
});
}
}
Expand Down Expand Up @@ -119,7 +119,7 @@
* 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) => {
Expand Down
4 changes: 3 additions & 1 deletion backend/src/reminder-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading
Loading