fix: resolve TypeScript errors for CI pipeline
Some checks failed
CI/CD / check (push) Successful in 19s
CI/CD / deploy (push) Failing after 1s

- Fix pg-boss Job type imports (PgBoss.Job -> Job from pg-boss)
- Replace deprecated teamConcurrency with localConcurrency
- Add null checks for possibly undefined values (clients, import rows)
- Fix tone type narrowing in profile.ts
- Fix test type assertions (non-null assertions, explicit Record types)
- Extract auth middleware into shared module
- Fix rate limiter Map generic type
This commit is contained in:
2026-01-30 03:27:58 +00:00
parent 4dc641db02
commit 7634306832
24 changed files with 120 additions and 63 deletions

View File

@@ -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);
});

View File

@@ -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<string, unknown> = {
lastName: 'Doe',
};
// Schema validation test - firstName is required
@@ -11,7 +11,7 @@ describe('Client Routes', () => {
});
test('clientSchema requires lastName', () => {
const invalidClient = {
const invalidClient: Record<string, unknown> = {
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');
});
});

View File

@@ -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);

20
src/middleware/auth.ts Normal file
View File

@@ -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 };
});

View File

@@ -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';

View File

@@ -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

View File

@@ -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') {

View File

@@ -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') {

View File

@@ -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;
}, {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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<string, any> = {};
@@ -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) {

View File

@@ -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);

View File

@@ -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

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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

View File

@@ -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;

View File

@@ -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),

View File

@@ -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,
},
};
});

View File

@@ -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()

View File

@@ -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 })

View File

@@ -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)];

View File

@@ -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<PgBoss> {
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<EventReminderData>[]) {
const job = jobs[0];
if (!job) return;
const { userId, eventTitle, clientName, daysUntil } = job.data;
try {