diff --git a/src/__tests__/audit.test.ts b/src/__tests__/audit.test.ts index 59d9b74..5b6bfb6 100644 --- a/src/__tests__/audit.test.ts +++ b/src/__tests__/audit.test.ts @@ -46,7 +46,7 @@ describe('Audit Logging', () => { describe('Request Metadata', () => { test('IP address extracted from x-forwarded-for', () => { const header = '192.168.1.1, 10.0.0.1'; - const ip = header.split(',')[0].trim(); + const ip = header.split(',')[0]!.trim(); expect(ip).toBe('192.168.1.1'); }); @@ -71,9 +71,9 @@ describe('Audit Logging', () => { } expect(Object.keys(diff)).toHaveLength(2); - expect(diff.firstName.from).toBe('John'); - expect(diff.firstName.to).toBe('Jonathan'); - expect(diff.stage.from).toBe('lead'); + expect(diff.firstName!.from).toBe('John'); + expect(diff.firstName!.to).toBe('Jonathan'); + expect(diff.stage!.from).toBe('lead'); expect(diff.lastName).toBeUndefined(); }); @@ -87,8 +87,10 @@ describe('Audit Logging', () => { describe('Audit Log Filters', () => { test('page and limit defaults', () => { - const page = parseInt(undefined || '1'); - const limit = Math.min(parseInt(undefined || '50'), 100); + const noPage: string | undefined = undefined; + const noLimit: string | undefined = undefined; + const page = parseInt(noPage || '1'); + const limit = Math.min(parseInt(noLimit || '50'), 100); expect(page).toBe(1); expect(limit).toBe(50); }); diff --git a/src/__tests__/clients.test.ts b/src/__tests__/clients.test.ts index 3b09267..05fccf2 100644 --- a/src/__tests__/clients.test.ts +++ b/src/__tests__/clients.test.ts @@ -3,7 +3,7 @@ import { describe, test, expect } from 'bun:test'; describe('Client Routes', () => { describe('Validation', () => { test('clientSchema requires firstName', () => { - const invalidClient = { + const invalidClient: Record = { lastName: 'Doe', }; // Schema validation test - firstName is required @@ -11,7 +11,7 @@ describe('Client Routes', () => { }); test('clientSchema requires lastName', () => { - const invalidClient = { + const invalidClient: Record = { firstName: 'John', }; // Schema validation test - lastName is required @@ -107,8 +107,8 @@ describe('Search Functionality', () => { const filtered = clients.filter(c => c.tags?.includes('vip')); expect(filtered).toHaveLength(2); - expect(filtered[0].firstName).toBe('John'); - expect(filtered[1].firstName).toBe('Bob'); + expect(filtered[0]!.firstName).toBe('John'); + expect(filtered[1]!.firstName).toBe('Bob'); }); }); diff --git a/src/index.ts b/src/index.ts index c673a07..d09ab5f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,7 +24,6 @@ import { meetingPrepRoutes } from './routes/meeting-prep'; import { db } from './db'; import { users } from './db/schema'; import { eq } from 'drizzle-orm'; -import type { User } from './lib/auth'; import { tagRoutes } from './routes/tags'; import { initJobQueue } from './services/jobs'; @@ -57,21 +56,7 @@ const app = new Elysia() .use(inviteRoutes) .use(passwordResetRoutes) - // Protected routes - require auth - .derive(async ({ request, set }): Promise<{ user: User }> => { - const session = await auth.api.getSession({ - headers: request.headers, - }); - - if (!session?.user) { - set.status = 401; - throw new Error('Unauthorized'); - } - - return { user: session.user as User }; - }) - - // API routes (all require auth due to derive above) + // API routes (auth middleware is in each route plugin) .group('/api', app => app .use(clientRoutes) .use(importRoutes) @@ -96,34 +81,37 @@ const app = new Elysia() // Error handler .onError(({ code, error, set, path }) => { // Always log errors with full details + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + console.error(`[${new Date().toISOString()}] ERROR on ${path}:`, { code, - message: error.message, - stack: error.stack, + message, + stack, }); if (code === 'VALIDATION') { set.status = 400; - return { error: 'Validation error', details: error.message }; + return { error: 'Validation error', details: message }; } - if (error.message === 'Unauthorized') { + if (message === 'Unauthorized') { set.status = 401; return { error: 'Unauthorized' }; } - if (error.message.includes('Forbidden')) { + if (message.includes('Forbidden')) { set.status = 403; - return { error: error.message }; + return { error: message }; } - if (error.message.includes('not found')) { + if (message.includes('not found')) { set.status = 404; - return { error: error.message }; + return { error: message }; } set.status = 500; - return { error: 'Internal server error', details: error.message }; + return { error: 'Internal server error', details: message }; }) .listen(process.env.PORT || 3000); diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..760cdc3 --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,20 @@ +import { Elysia } from 'elysia'; +import { auth, type User } from '../lib/auth'; + +/** + * Auth middleware plugin - adds `user` to the Elysia context. + * Import and `.use(authMiddleware)` in route files that need authentication. + */ +export const authMiddleware = new Elysia({ name: 'auth-middleware' }) + .derive({ as: 'scoped' }, async ({ request, set }): Promise<{ user: User }> => { + const session = await auth.api.getSession({ + headers: request.headers, + }); + + if (!session?.user) { + set.status = 401; + throw new Error('Unauthorized'); + } + + return { user: session.user as User }; + }); diff --git a/src/middleware/rate-limit.ts b/src/middleware/rate-limit.ts index 5d8548c..f7da18d 100644 --- a/src/middleware/rate-limit.ts +++ b/src/middleware/rate-limit.ts @@ -44,7 +44,7 @@ function checkRateLimit(key: string, config: RateLimitConfig): { allowed: boolea function getClientIP(request: Request): string { // Check common proxy headers const forwarded = request.headers.get('x-forwarded-for'); - if (forwarded) return forwarded.split(',')[0].trim(); + if (forwarded) return forwarded.split(',')[0]?.trim() ?? '127.0.0.1'; const realIp = request.headers.get('x-real-ip'); if (realIp) return realIp; return '127.0.0.1'; diff --git a/src/routes/activity.ts b/src/routes/activity.ts index aa19ad5..2a86972 100644 --- a/src/routes/activity.ts +++ b/src/routes/activity.ts @@ -1,3 +1,4 @@ +import { authMiddleware } from '../middleware/auth'; import { Elysia, t } from 'elysia'; import { db } from '../db'; import { clients, events, communications, interactions } from '../db/schema'; @@ -14,6 +15,7 @@ export interface ActivityItem { } export const activityRoutes = new Elysia({ prefix: '/clients' }) + .use(authMiddleware) // Get activity timeline for a client .get('/:id/activity', async ({ params, user }: { params: { id: string }; user: User }) => { // Verify client belongs to user diff --git a/src/routes/admin.ts b/src/routes/admin.ts index bbc5e89..b86d61e 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -1,3 +1,4 @@ +import { authMiddleware } from '../middleware/auth'; import { Elysia, t } from 'elysia'; import { db } from '../db'; import { users, invites, passwordResetTokens } from '../db/schema'; @@ -6,6 +7,7 @@ import { auth } from '../lib/auth'; import type { User } from '../lib/auth'; export const adminRoutes = new Elysia({ prefix: '/admin' }) + .use(authMiddleware) // Admin guard — all routes in this group require admin role .onBeforeHandle(({ user, set }: { user: User; set: any }) => { if ((user as any).role !== 'admin') { diff --git a/src/routes/audit-logs.ts b/src/routes/audit-logs.ts index a928f15..b89664e 100644 --- a/src/routes/audit-logs.ts +++ b/src/routes/audit-logs.ts @@ -1,3 +1,4 @@ +import { authMiddleware } from '../middleware/auth'; import { Elysia, t } from 'elysia'; import { db } from '../db'; import { auditLogs, users } from '../db/schema'; @@ -5,6 +6,7 @@ import { eq, desc, and, gte, lte, ilike, or, sql } from 'drizzle-orm'; import type { User } from '../lib/auth'; export const auditLogRoutes = new Elysia({ prefix: '/audit-logs' }) + .use(authMiddleware) // Admin guard .onBeforeHandle(({ user, set }: { user: User; set: any }) => { if ((user as any).role !== 'admin') { diff --git a/src/routes/clients.ts b/src/routes/clients.ts index 06f4f53..2513e9d 100644 --- a/src/routes/clients.ts +++ b/src/routes/clients.ts @@ -1,3 +1,4 @@ +import { authMiddleware } from '../middleware/auth'; import { Elysia, t } from 'elysia'; import { db } from '../db'; import { clients, events } from '../db/schema'; @@ -84,6 +85,7 @@ const clientSchema = t.Object({ const updateClientSchema = t.Partial(clientSchema); export const clientRoutes = new Elysia({ prefix: '/clients' }) + .use(authMiddleware) // List clients with optional search and pagination .get('/', async ({ query, user }: { query: { search?: string; tag?: string; page?: string; limit?: string }; user: User }) => { let baseQuery = db.select().from(clients).where(eq(clients.userId, user.id)); @@ -179,7 +181,9 @@ export const clientRoutes = new Elysia({ prefix: '/clients' }) .returning(); // Auto-sync birthday/anniversary events - await syncClientEvents(user.id, client); + if (client) { + await syncClientEvents(user.id, client); + } return client; }, { diff --git a/src/routes/emails.ts b/src/routes/emails.ts index 71696ce..3e169ae 100644 --- a/src/routes/emails.ts +++ b/src/routes/emails.ts @@ -1,3 +1,4 @@ +import { authMiddleware } from '../middleware/auth'; import { Elysia, t } from 'elysia'; import { db } from '../db'; import { clients, communications, userProfiles } from '../db/schema'; @@ -8,6 +9,7 @@ import type { User } from '../lib/auth'; import { randomUUID } from 'crypto'; export const emailRoutes = new Elysia({ prefix: '/emails' }) + .use(authMiddleware) // Generate email for a client .post('/generate', async ({ body, user }: { body: { diff --git a/src/routes/events.ts b/src/routes/events.ts index ffa51f5..073ec03 100644 --- a/src/routes/events.ts +++ b/src/routes/events.ts @@ -1,3 +1,4 @@ +import { authMiddleware } from '../middleware/auth'; import { Elysia, t } from 'elysia'; import { db } from '../db'; import { events, clients } from '../db/schema'; @@ -5,6 +6,7 @@ import { eq, and, gte, lte, sql } from 'drizzle-orm'; import type { User } from '../lib/auth'; export const eventRoutes = new Elysia({ prefix: '/events' }) + .use(authMiddleware) // List events with optional filters .get('/', async ({ query, user }: { query: { diff --git a/src/routes/import.ts b/src/routes/import.ts index ad0a984..5dfdee7 100644 --- a/src/routes/import.ts +++ b/src/routes/import.ts @@ -1,3 +1,4 @@ +import { authMiddleware } from '../middleware/auth'; import { Elysia, t } from 'elysia'; import { db } from '../db'; import { clients, events } from '../db/schema'; @@ -160,6 +161,7 @@ async function syncClientEvents(userId: string, client: { id: string; firstName: } export const importRoutes = new Elysia({ prefix: '/clients' }) + .use(authMiddleware) // Preview CSV - returns headers and auto-mapped columns + sample rows .post('/import/preview', async ({ body, user }: { body: { file: File }; user: User }) => { const text = await body.file.text(); @@ -211,6 +213,7 @@ export const importRoutes = new Elysia({ prefix: '/clients' }) for (let i = 0; i < dataRows.length; i++) { const row = dataRows[i]; + if (!row) continue; try { const record: Record = {}; @@ -260,12 +263,14 @@ export const importRoutes = new Elysia({ prefix: '/clients' }) .returning(); // Sync events - await syncClientEvents(user.id, { - id: client.id, - firstName: client.firstName, - birthday: client.birthday, - anniversary: client.anniversary, - }); + if (client) { + await syncClientEvents(user.id, { + id: client.id, + firstName: client.firstName, + birthday: client.birthday, + anniversary: client.anniversary, + }); + } results.imported++; } catch (err: any) { diff --git a/src/routes/insights.ts b/src/routes/insights.ts index 84439ef..fb8098c 100644 --- a/src/routes/insights.ts +++ b/src/routes/insights.ts @@ -1,3 +1,4 @@ +import { authMiddleware } from '../middleware/auth'; import { Elysia } from 'elysia'; import { db } from '../db'; import { clients, events } from '../db/schema'; @@ -5,6 +6,7 @@ import { eq, and, sql, lte, gte, isNull, or } from 'drizzle-orm'; import type { User } from '../lib/auth'; export const insightsRoutes = new Elysia({ prefix: '/insights' }) + .use(authMiddleware) .get('/', async ({ user }: { user: User }) => { const now = new Date(); const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); diff --git a/src/routes/interactions.ts b/src/routes/interactions.ts index 1ff9f13..fafef50 100644 --- a/src/routes/interactions.ts +++ b/src/routes/interactions.ts @@ -1,3 +1,4 @@ +import { authMiddleware } from '../middleware/auth'; import { Elysia, t } from 'elysia'; import { db } from '../db'; import { interactions, clients } from '../db/schema'; @@ -5,6 +6,7 @@ import { eq, and, desc } from 'drizzle-orm'; import type { User } from '../lib/auth'; export const interactionRoutes = new Elysia() + .use(authMiddleware) // List interactions for a client .get('/clients/:clientId/interactions', async ({ params, user }: { params: { clientId: string }; user: User }) => { // Verify client belongs to user diff --git a/src/routes/meeting-prep.ts b/src/routes/meeting-prep.ts index 648f438..2f5db2d 100644 --- a/src/routes/meeting-prep.ts +++ b/src/routes/meeting-prep.ts @@ -1,3 +1,4 @@ +import { authMiddleware } from '../middleware/auth'; import { Elysia, t } from 'elysia'; import { db } from '../db'; import { clients, interactions, communications, events, clientNotes } from '../db/schema'; @@ -6,6 +7,7 @@ import type { User } from '../lib/auth'; import { generateMeetingPrep } from '../services/ai'; export const meetingPrepRoutes = new Elysia() + .use(authMiddleware) // Get meeting prep for a client .get('/clients/:id/meeting-prep', async ({ params, user, query }: { params: { id: string }; diff --git a/src/routes/network.ts b/src/routes/network.ts index 41e5a7a..1cb834f 100644 --- a/src/routes/network.ts +++ b/src/routes/network.ts @@ -1,3 +1,4 @@ +import { authMiddleware } from '../middleware/auth'; import { Elysia, t } from 'elysia'; import { db } from '../db'; import { clients } from '../db/schema'; @@ -23,6 +24,7 @@ function toClientProfile(c: typeof clients.$inferSelect): ClientProfile { } export const networkRoutes = new Elysia({ prefix: '/network' }) + .use(authMiddleware) // Get all network matches for the user's clients .get('/matches', async (ctx) => { const user = (ctx as any).user; diff --git a/src/routes/notes.ts b/src/routes/notes.ts index 2e63d58..0b11e9b 100644 --- a/src/routes/notes.ts +++ b/src/routes/notes.ts @@ -1,3 +1,4 @@ +import { authMiddleware } from '../middleware/auth'; import { Elysia, t } from 'elysia'; import { db } from '../db'; import { clientNotes, clients } from '../db/schema'; @@ -5,6 +6,7 @@ import { eq, and, desc } from 'drizzle-orm'; import type { User } from '../lib/auth'; export const notesRoutes = new Elysia({ prefix: '/clients/:clientId/notes' }) + .use(authMiddleware) // List notes for a client .get('/', async ({ params, user }: { params: { clientId: string }; user: User }) => { // Verify client belongs to user diff --git a/src/routes/notifications.ts b/src/routes/notifications.ts index a2e0584..3306447 100644 --- a/src/routes/notifications.ts +++ b/src/routes/notifications.ts @@ -1,3 +1,4 @@ +import { authMiddleware } from '../middleware/auth'; import { Elysia, t } from 'elysia'; import { db } from '../db'; import { notifications, clients } from '../db/schema'; @@ -5,6 +6,7 @@ import { eq, and, desc, sql } from 'drizzle-orm'; import type { User } from '../lib/auth'; export const notificationRoutes = new Elysia({ prefix: '/notifications' }) + .use(authMiddleware) // List notifications .get('/', async ({ query, user }: { query: { limit?: string; unreadOnly?: string }; user: User }) => { const limit = query.limit ? parseInt(query.limit) : 50; diff --git a/src/routes/profile.ts b/src/routes/profile.ts index ac80104..89c83e8 100644 --- a/src/routes/profile.ts +++ b/src/routes/profile.ts @@ -1,3 +1,4 @@ +import { authMiddleware } from '../middleware/auth'; import { Elysia, t } from 'elysia'; import { db } from '../db'; import { users, userProfiles, accounts } from '../db/schema'; @@ -6,6 +7,7 @@ import type { User } from '../lib/auth'; import { logAudit, getRequestMeta } from '../services/audit'; export const profileRoutes = new Elysia({ prefix: '/profile' }) + .use(authMiddleware) // Get current user's profile .get('/', async ({ user }: { user: User }) => { // Get user and profile @@ -159,8 +161,9 @@ export const profileRoutes = new Elysia({ prefix: '/profile' }) .where(eq(userProfiles.userId, user.id)) .limit(1); + const tone = (body.tone || 'friendly') as 'formal' | 'friendly' | 'casual'; const style = { - tone: body.tone || 'friendly', + tone, greeting: body.greeting || '', signoff: body.signoff || '', writingSamples: (body.writingSamples || []).slice(0, 3), diff --git a/src/routes/reports.ts b/src/routes/reports.ts index 185b315..92af967 100644 --- a/src/routes/reports.ts +++ b/src/routes/reports.ts @@ -1,3 +1,4 @@ +import { authMiddleware } from '../middleware/auth'; import { Elysia } from 'elysia'; import { db } from '../db'; import { clients, events, communications } from '../db/schema'; @@ -5,6 +6,7 @@ import { eq, and, sql, gte, lte, count, desc } from 'drizzle-orm'; import type { User } from '../lib/auth'; export const reportsRoutes = new Elysia() + .use(authMiddleware) // Analytics overview .get('/reports/overview', async ({ user }: { user: User }) => { const userId = user.id; @@ -84,21 +86,21 @@ export const reportsRoutes = new Elysia() return { clients: { - total: totalClients.count, - newThisMonth: newClientsMonth.count, - newThisWeek: newClientsWeek.count, - contactedRecently: contactedRecently.count, - neverContacted: neverContacted.count, + total: totalClients?.count ?? 0, + newThisMonth: newClientsMonth?.count ?? 0, + newThisWeek: newClientsWeek?.count ?? 0, + contactedRecently: contactedRecently?.count ?? 0, + neverContacted: neverContacted?.count ?? 0, }, emails: { - total: totalEmails.count, - sent: emailsSent.count, - draft: emailsDraft.count, - sentLast30Days: emailsRecent.count, + total: totalEmails?.count ?? 0, + sent: emailsSent?.count ?? 0, + draft: emailsDraft?.count ?? 0, + sentLast30Days: emailsRecent?.count ?? 0, }, events: { - total: totalEvents.count, - upcoming30Days: upcomingEvents.count, + total: totalEvents?.count ?? 0, + upcoming30Days: upcomingEvents?.count ?? 0, }, }; }) @@ -406,11 +408,11 @@ export const reportsRoutes = new Elysia() }); } - if (draftCount.count > 0) { + if ((draftCount?.count ?? 0) > 0) { notifications.push({ id: 'drafts', type: 'drafts' as const, - title: `${draftCount.count} draft email${draftCount.count > 1 ? 's' : ''} pending`, + title: `${draftCount?.count ?? 0} draft email${(draftCount?.count ?? 0) > 1 ? 's' : ''} pending`, description: 'Review and send your drafted emails', date: new Date().toISOString(), link: '/emails', @@ -434,7 +436,7 @@ export const reportsRoutes = new Elysia() overdue: overdueEvents.length, upcoming: upcomingEvents.length, stale: staleClients.length, - drafts: draftCount.count, + drafts: draftCount?.count ?? 0, }, }; }); diff --git a/src/routes/segments.ts b/src/routes/segments.ts index 74968e3..35f5ef6 100644 --- a/src/routes/segments.ts +++ b/src/routes/segments.ts @@ -1,3 +1,4 @@ +import { authMiddleware } from '../middleware/auth'; import { Elysia, t } from 'elysia'; import { db } from '../db'; import { clientSegments, clients } from '../db/schema'; @@ -77,6 +78,7 @@ function buildClientConditions(filters: SegmentFilters, userId: string) { } export const segmentRoutes = new Elysia({ prefix: '/segments' }) + .use(authMiddleware) // List saved segments .get('/', async ({ user }: { user: User }) => { return db.select() diff --git a/src/routes/tags.ts b/src/routes/tags.ts index 95d0ef9..ad814cf 100644 --- a/src/routes/tags.ts +++ b/src/routes/tags.ts @@ -1,3 +1,4 @@ +import { authMiddleware } from '../middleware/auth'; import { Elysia, t } from 'elysia'; import { db } from '../db'; import { clients } from '../db/schema'; @@ -5,6 +6,7 @@ import { eq, sql } from 'drizzle-orm'; import type { User } from '../lib/auth'; export const tagRoutes = new Elysia({ prefix: '/tags' }) + .use(authMiddleware) // GET /api/tags - all unique tags with client counts .get('/', async ({ user }: { user: User }) => { const allClients = await db.select({ tags: clients.tags }) diff --git a/src/routes/templates.ts b/src/routes/templates.ts index 3b7318e..1da6ee5 100644 --- a/src/routes/templates.ts +++ b/src/routes/templates.ts @@ -1,3 +1,4 @@ +import { authMiddleware } from '../middleware/auth'; import { Elysia, t } from 'elysia'; import { db } from '../db'; import { emailTemplates } from '../db/schema'; @@ -5,6 +6,7 @@ import { eq, and, desc, sql } from 'drizzle-orm'; import type { User } from '../lib/auth'; export const templateRoutes = new Elysia({ prefix: '/templates' }) + .use(authMiddleware) // List templates .get('/', async ({ query, user }: { query: { category?: string }; user: User }) => { let conditions = [eq(emailTemplates.userId, user.id)]; diff --git a/src/services/jobs.ts b/src/services/jobs.ts index 639d271..26bfba8 100644 --- a/src/services/jobs.ts +++ b/src/services/jobs.ts @@ -1,4 +1,5 @@ import { PgBoss } from 'pg-boss'; +import type { Job } from 'pg-boss'; import { db } from '../db'; import { events, notifications, clients, users } from '../db/schema'; import { eq, and, gte, lte, sql } from 'drizzle-orm'; @@ -22,8 +23,8 @@ export async function initJobQueue(): Promise { console.log('✅ pg-boss job queue started'); // Register job handlers - await boss.work('check-upcoming-events', { teamConcurrency: 1 }, checkUpcomingEvents); - await boss.work('send-event-reminder', { teamConcurrency: 5 }, sendEventReminder); + await boss.work('check-upcoming-events', { localConcurrency: 1 }, checkUpcomingEvents); + await boss.work('send-event-reminder', { localConcurrency: 5 }, sendEventReminder); // Schedule daily check at 8am UTC await boss.schedule('check-upcoming-events', '0 8 * * *', {}, { @@ -39,7 +40,7 @@ export function getJobQueue(): PgBoss | null { } // Job: Check upcoming events and create notifications -async function checkUpcomingEvents(job: PgBoss.Job) { +async function checkUpcomingEvents(jobs: Job[]) { console.log(`[jobs] Running checkUpcomingEvents at ${new Date().toISOString()}`); try { @@ -118,14 +119,18 @@ async function checkUpcomingEvents(job: PgBoss.Job) { } // Job: Send email reminder to advisor -async function sendEventReminder(job: PgBoss.Job<{ +interface EventReminderData { userId: string; eventId: string; clientId: string; eventTitle: string; clientName: string; daysUntil: number; -}>) { +} + +async function sendEventReminder(jobs: Job[]) { + const job = jobs[0]; + if (!job) return; const { userId, eventTitle, clientName, daysUntil } = job.data; try {