import { Elysia, t } from 'elysia'; import type { TaskStatus } from '@/lib/types'; import { auth } from '@/lib/auth'; import { requireAuthenticatedSession } from '@/lib/server/auth-session'; import { asErrorMessage, jsonError } from '@/lib/server/http'; import { buildPortfolioSummary } from '@/lib/server/portfolio'; import { listFilingsRecords } from '@/lib/server/repos/filings'; import { deleteHoldingByIdRecord, listUserHoldings, updateHoldingByIdRecord, upsertHoldingRecord } from '@/lib/server/repos/holdings'; import { getLatestPortfolioInsight } from '@/lib/server/repos/insights'; import { deleteWatchlistItemRecord, listWatchlistItems, upsertWatchlistItemRecord } from '@/lib/server/repos/watchlist'; import { enqueueTask, getTaskById, getTaskQueueSnapshot, listRecentTasks } from '@/lib/server/tasks'; const ALLOWED_STATUSES: TaskStatus[] = ['queued', 'running', 'completed', 'failed']; function asRecord(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { return {}; } return value as Record; } function asPositiveNumber(value: unknown) { const parsed = typeof value === 'number' ? value : Number(value); return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } const authHandler = ({ request }: { request: Request }) => auth.handler(request); export const app = new Elysia({ prefix: '/api' }) .all('/auth', authHandler) .all('/auth/*', authHandler) .get('/health', async () => { const queue = await getTaskQueueSnapshot(); return Response.json({ status: 'ok', version: '4.0.0', timestamp: new Date().toISOString(), queue }); }) .get('/me', async () => { const { session, response } = await requireAuthenticatedSession(); if (response) { return response; } return Response.json({ user: { id: session.user.id, email: session.user.email, name: session.user.name, image: session.user.image } }); }) .get('/watchlist', async () => { const { session, response } = await requireAuthenticatedSession(); if (response) { return response; } const items = await listWatchlistItems(session.user.id); return Response.json({ items }); }) .post('/watchlist', async ({ body }) => { const { session, response } = await requireAuthenticatedSession(); if (response) { return response; } const payload = asRecord(body); const ticker = typeof payload.ticker === 'string' ? payload.ticker.trim().toUpperCase() : ''; const companyName = typeof payload.companyName === 'string' ? payload.companyName.trim() : ''; const sector = typeof payload.sector === 'string' ? payload.sector.trim() : ''; if (!ticker) { return jsonError('ticker is required'); } if (!companyName) { return jsonError('companyName is required'); } try { const item = await upsertWatchlistItemRecord({ userId: session.user.id, ticker, companyName, sector }); return Response.json({ item }); } catch (error) { return jsonError(asErrorMessage(error, 'Failed to create watchlist item')); } }, { body: t.Object({ ticker: t.String({ minLength: 1 }), companyName: t.String({ minLength: 1 }), sector: t.Optional(t.String()) }) }) .delete('/watchlist/:id', async ({ params }) => { const { session, response } = await requireAuthenticatedSession(); if (response) { return response; } const numericId = Number(params.id); if (!Number.isInteger(numericId) || numericId <= 0) { return jsonError('Invalid watchlist id', 400); } const removed = await deleteWatchlistItemRecord(session.user.id, numericId); if (!removed) { return jsonError('Watchlist item not found', 404); } return Response.json({ success: true }); }, { params: t.Object({ id: t.String({ minLength: 1 }) }) }) .get('/portfolio/holdings', async () => { const { session, response } = await requireAuthenticatedSession(); if (response) { return response; } const holdings = await listUserHoldings(session.user.id); return Response.json({ holdings }); }) .post('/portfolio/holdings', async ({ body }) => { const { session, response } = await requireAuthenticatedSession(); if (response) { return response; } const payload = asRecord(body); const ticker = typeof payload.ticker === 'string' ? payload.ticker.trim().toUpperCase() : ''; const shares = asPositiveNumber(payload.shares); const avgCost = asPositiveNumber(payload.avgCost); if (!ticker) { return jsonError('ticker is required'); } if (shares === null) { return jsonError('shares must be a positive number'); } if (avgCost === null) { return jsonError('avgCost must be a positive number'); } try { const currentPrice = asPositiveNumber(payload.currentPrice) ?? avgCost; const holding = await upsertHoldingRecord({ userId: session.user.id, ticker, shares, avgCost, currentPrice }); return Response.json({ holding }); } catch (error) { return jsonError(asErrorMessage(error, 'Failed to save holding')); } }, { body: t.Object({ ticker: t.String({ minLength: 1 }), shares: t.Number({ exclusiveMinimum: 0 }), avgCost: t.Number({ exclusiveMinimum: 0 }), currentPrice: t.Optional(t.Number({ exclusiveMinimum: 0 })) }) }) .patch('/portfolio/holdings/:id', async ({ params, body }) => { const { session, response } = await requireAuthenticatedSession(); if (response) { return response; } const numericId = Number(params.id); if (!Number.isInteger(numericId) || numericId <= 0) { return jsonError('Invalid holding id'); } const payload = asRecord(body); const updated = await updateHoldingByIdRecord({ userId: session.user.id, id: numericId, shares: asPositiveNumber(payload.shares) ?? undefined, avgCost: asPositiveNumber(payload.avgCost) ?? undefined, currentPrice: asPositiveNumber(payload.currentPrice) ?? undefined }); if (!updated) { return jsonError('Holding not found', 404); } return Response.json({ holding: updated }); }, { params: t.Object({ id: t.String({ minLength: 1 }) }), body: t.Object({ shares: t.Optional(t.Number({ exclusiveMinimum: 0 })), avgCost: t.Optional(t.Number({ exclusiveMinimum: 0 })), currentPrice: t.Optional(t.Number({ exclusiveMinimum: 0 })) }) }) .delete('/portfolio/holdings/:id', async ({ params }) => { const { session, response } = await requireAuthenticatedSession(); if (response) { return response; } const numericId = Number(params.id); if (!Number.isInteger(numericId) || numericId <= 0) { return jsonError('Invalid holding id'); } const removed = await deleteHoldingByIdRecord(session.user.id, numericId); if (!removed) { return jsonError('Holding not found', 404); } return Response.json({ success: true }); }, { params: t.Object({ id: t.String({ minLength: 1 }) }) }) .get('/portfolio/summary', async () => { const { session, response } = await requireAuthenticatedSession(); if (response) { return response; } const holdings = await listUserHoldings(session.user.id); const summary = buildPortfolioSummary(holdings); return Response.json({ summary }); }) .post('/portfolio/refresh-prices', async () => { const { session, response } = await requireAuthenticatedSession(); if (response) { return response; } try { const task = await enqueueTask({ userId: session.user.id, taskType: 'refresh_prices', payload: {}, priority: 80 }); return Response.json({ task }); } catch (error) { return jsonError(asErrorMessage(error, 'Failed to queue refresh task')); } }) .post('/portfolio/insights/generate', async () => { const { session, response } = await requireAuthenticatedSession(); if (response) { return response; } try { const task = await enqueueTask({ userId: session.user.id, taskType: 'portfolio_insights', payload: {}, priority: 70 }); return Response.json({ task }); } catch (error) { return jsonError(asErrorMessage(error, 'Failed to queue insights task')); } }) .get('/portfolio/insights/latest', async () => { const { session, response } = await requireAuthenticatedSession(); if (response) { return response; } const insight = await getLatestPortfolioInsight(session.user.id); return Response.json({ insight }); }) .get('/filings', async ({ query }) => { const { response } = await requireAuthenticatedSession(); if (response) { return response; } const tickerFilter = typeof query.ticker === 'string' ? query.ticker.trim().toUpperCase() : undefined; const limit = typeof query.limit === 'number' ? query.limit : Number(query.limit); const filings = await listFilingsRecords({ ticker: tickerFilter, limit: Number.isFinite(limit) ? limit : 50 }); return Response.json({ filings }); }, { query: t.Object({ ticker: t.Optional(t.String()), limit: t.Optional(t.Numeric()) }) }) .post('/filings/sync', async ({ body }) => { const { session, response } = await requireAuthenticatedSession(); if (response) { return response; } const payload = asRecord(body); const ticker = typeof payload.ticker === 'string' ? payload.ticker.trim().toUpperCase() : ''; if (!ticker) { return jsonError('ticker is required'); } try { const limit = typeof payload.limit === 'number' ? payload.limit : Number(payload.limit); const task = await enqueueTask({ userId: session.user.id, taskType: 'sync_filings', payload: { ticker, limit: Number.isFinite(limit) ? limit : 20 }, priority: 90 }); return Response.json({ task }); } catch (error) { return jsonError(asErrorMessage(error, 'Failed to queue filings sync task')); } }, { body: t.Object({ ticker: t.String({ minLength: 1 }), limit: t.Optional(t.Numeric()) }) }) .post('/filings/:accessionNumber/analyze', async ({ params }) => { const { session, response } = await requireAuthenticatedSession(); if (response) { return response; } const accessionNumber = params.accessionNumber?.trim() ?? ''; if (accessionNumber.length < 4) { return jsonError('Invalid accession number'); } try { const task = await enqueueTask({ userId: session.user.id, taskType: 'analyze_filing', payload: { accessionNumber }, priority: 65 }); return Response.json({ task }); } catch (error) { return jsonError(asErrorMessage(error, 'Failed to queue filing analysis task')); } }, { params: t.Object({ accessionNumber: t.String({ minLength: 4 }) }) }) .get('/tasks', async ({ query }) => { const { session, response } = await requireAuthenticatedSession(); if (response) { return response; } const limit = typeof query.limit === 'number' ? query.limit : Number(query.limit ?? 20); const statusInput = query.status; const rawStatuses = Array.isArray(statusInput) ? statusInput : statusInput ? [statusInput] : []; const statuses = rawStatuses.filter((status): status is TaskStatus => { return ALLOWED_STATUSES.includes(status as TaskStatus); }); const tasks = await listRecentTasks( session.user.id, Number.isFinite(limit) ? limit : 20, statuses.length > 0 ? statuses : undefined ); return Response.json({ tasks }); }, { query: t.Object({ limit: t.Optional(t.Numeric()), status: t.Optional(t.Union([t.String(), t.Array(t.String())])) }) }) .get('/tasks/:taskId', async ({ params }) => { const { session, response } = await requireAuthenticatedSession(); if (response) { return response; } const task = await getTaskById(params.taskId, session.user.id); if (!task) { return jsonError('Task not found', 404); } return Response.json({ task }); }, { params: t.Object({ taskId: t.String({ minLength: 1 }) }) }); export type App = typeof app;