import { Elysia, t } from 'elysia'; import { getWorld } from 'workflow/runtime'; import type { CoveragePriority, CoverageStatus, Filing, FinancialCadence, FinancialStatementKind, FinancialSurfaceKind, ResearchArtifactKind, ResearchArtifactSource, ResearchJournalEntryType, SearchSource, ResearchMemoConviction, ResearchMemoRating, ResearchMemoSection, TaskStatus } from '@/lib/types'; import { auth } from '@/lib/auth'; import { requireAuthenticatedSession } from '@/lib/server/auth-session'; import { getLatestFinancialIngestionSchemaStatus } from '@/lib/server/db/financial-ingestion-schema'; import { asErrorMessage, jsonError } from '@/lib/server/http'; import { buildPortfolioSummary } from '@/lib/server/portfolio'; import { defaultFinancialSyncLimit, getCompanyFinancials } from '@/lib/server/financial-taxonomy'; import { redactInternalFilingAnalysisFields } from '@/lib/server/api/filing-redaction'; import { getFilingByAccession, listFilingsRecords, listLatestFilingDatesByTickers } from '@/lib/server/repos/filings'; import { deleteHoldingByIdRecord, getHoldingByTicker, listUserHoldings, updateHoldingByIdRecord, upsertHoldingRecord } from '@/lib/server/repos/holdings'; import { getLatestPortfolioInsight } from '@/lib/server/repos/insights'; import { addResearchMemoEvidenceLink, createAiReportArtifactFromAccession, createFilingArtifactFromAccession, createResearchArtifactRecord, deleteResearchArtifactRecord, deleteResearchMemoEvidenceLink, getResearchArtifactFileResponse, getResearchMemoByTicker, getResearchPacket, getResearchWorkspace, listResearchArtifacts, storeResearchUpload, updateResearchArtifactRecord, upsertResearchMemoRecord } from '@/lib/server/repos/research-library'; import { createResearchJournalEntryRecord, deleteResearchJournalEntryRecord, listResearchJournalEntries, updateResearchJournalEntryRecord } from '@/lib/server/repos/research-journal'; import { deleteWatchlistItemRecord, getWatchlistItemById, getWatchlistItemByTicker, listWatchlistItems, updateWatchlistItemRecord, updateWatchlistReviewByTicker, upsertWatchlistItemRecord } from '@/lib/server/repos/watchlist'; import { answerSearchQuery, searchKnowledgeBase } from '@/lib/server/search'; import { enqueueTask, findOrEnqueueTask, findInFlightTask, getTaskById, getTaskTimeline, getTaskQueueSnapshot, listRecentTasks, updateTaskNotification } from '@/lib/server/tasks'; import { getCompanyAnalysisPayload } from '@/lib/server/company-analysis'; const ALLOWED_STATUSES: TaskStatus[] = ['queued', 'running', 'completed', 'failed']; const FINANCIAL_FORMS: ReadonlySet = new Set(['10-K', '10-Q']); const AUTO_FILING_SYNC_LIMIT = 20; const FINANCIALS_V3_ENABLED = process.env.FINANCIALS_V3?.trim().toLowerCase() !== 'false'; const FINANCIAL_STATEMENT_KINDS: FinancialStatementKind[] = [ 'income', 'balance', 'cash_flow', 'equity', 'comprehensive_income' ]; const FINANCIAL_CADENCES: FinancialCadence[] = ['annual', 'quarterly', 'ltm']; const FINANCIAL_SURFACES: FinancialSurfaceKind[] = [ 'income_statement', 'balance_sheet', 'cash_flow_statement', 'ratios', 'segments_kpis', 'adjusted', 'custom_metrics' ]; const COVERAGE_STATUSES: CoverageStatus[] = ['backlog', 'active', 'watch', 'archive']; const COVERAGE_PRIORITIES: CoveragePriority[] = ['low', 'medium', 'high']; const JOURNAL_ENTRY_TYPES: ResearchJournalEntryType[] = ['note', 'filing_note', 'status_change']; const SEARCH_SOURCES: SearchSource[] = ['documents', 'filings', 'research']; const RESEARCH_ARTIFACT_KINDS: ResearchArtifactKind[] = ['filing', 'ai_report', 'note', 'upload', 'memo_snapshot', 'status_change']; const RESEARCH_ARTIFACT_SOURCES: ResearchArtifactSource[] = ['system', 'user']; const RESEARCH_MEMO_RATINGS: ResearchMemoRating[] = ['strong_buy', 'buy', 'hold', 'sell']; const RESEARCH_MEMO_CONVICTIONS: ResearchMemoConviction[] = ['low', 'medium', 'high']; const RESEARCH_MEMO_SECTIONS: ResearchMemoSection[] = [ 'thesis', 'variant_view', 'catalysts', 'risks', 'disconfirming_evidence', 'next_actions' ]; 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; } function asBoolean(value: unknown, fallback = false) { if (typeof value === 'boolean') { return value; } if (typeof value === 'string') { const normalized = value.trim().toLowerCase(); if (normalized === 'true' || normalized === '1' || normalized === 'yes') { return true; } if (normalized === 'false' || normalized === '0' || normalized === 'no') { return false; } } return fallback; } function asOptionalString(value: unknown) { if (typeof value !== 'string') { return null; } const normalized = value.trim(); return normalized.length > 0 ? normalized : null; } function asOptionalRecord(value: unknown) { if (!value || typeof value !== 'object' || Array.isArray(value)) { return null; } return value as Record; } function asTags(value: unknown) { const source = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : []; const unique = new Set(); for (const entry of source) { if (typeof entry !== 'string') { continue; } const tag = entry.trim(); if (!tag) { continue; } unique.add(tag); } return [...unique]; } function asStatementKind(value: unknown): FinancialStatementKind { return FINANCIAL_STATEMENT_KINDS.includes(value as FinancialStatementKind) ? value as FinancialStatementKind : 'income'; } function asCadence(value: unknown): FinancialCadence { return FINANCIAL_CADENCES.includes(value as FinancialCadence) ? value as FinancialCadence : 'annual'; } function surfaceFromLegacyStatement(statement: FinancialStatementKind): FinancialSurfaceKind { switch (statement) { case 'balance': return 'balance_sheet'; case 'cash_flow': return 'cash_flow_statement'; default: return 'income_statement'; } } function asSurfaceKind(surface: unknown, statement: unknown): FinancialSurfaceKind { if (FINANCIAL_SURFACES.includes(surface as FinancialSurfaceKind)) { return surface as FinancialSurfaceKind; } return surfaceFromLegacyStatement(asStatementKind(statement)); } function asCoverageStatus(value: unknown) { return COVERAGE_STATUSES.includes(value as CoverageStatus) ? value as CoverageStatus : undefined; } function asCoveragePriority(value: unknown) { return COVERAGE_PRIORITIES.includes(value as CoveragePriority) ? value as CoveragePriority : undefined; } function asJournalEntryType(value: unknown) { return JOURNAL_ENTRY_TYPES.includes(value as ResearchJournalEntryType) ? value as ResearchJournalEntryType : undefined; } function asSearchSources(value: unknown) { const raw = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : []; const normalized = raw .filter((entry): entry is string => typeof entry === 'string') .map((entry) => entry.trim().toLowerCase()) .filter((entry): entry is SearchSource => SEARCH_SOURCES.includes(entry as SearchSource)); return normalized.length > 0 ? [...new Set(normalized)] : undefined; } function asResearchArtifactKind(value: unknown) { return RESEARCH_ARTIFACT_KINDS.includes(value as ResearchArtifactKind) ? value as ResearchArtifactKind : undefined; } function asResearchArtifactSource(value: unknown) { return RESEARCH_ARTIFACT_SOURCES.includes(value as ResearchArtifactSource) ? value as ResearchArtifactSource : undefined; } function asResearchMemoRating(value: unknown) { if (value === null) { return null; } return RESEARCH_MEMO_RATINGS.includes(value as ResearchMemoRating) ? value as ResearchMemoRating : undefined; } function asResearchMemoConviction(value: unknown) { if (value === null) { return null; } return RESEARCH_MEMO_CONVICTIONS.includes(value as ResearchMemoConviction) ? value as ResearchMemoConviction : undefined; } function asResearchMemoSection(value: unknown) { return RESEARCH_MEMO_SECTIONS.includes(value as ResearchMemoSection) ? value as ResearchMemoSection : undefined; } function formatLabel(value: string) { return value .split('_') .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(' '); } function normalizeTicker(value: unknown) { return typeof value === 'string' ? value.trim().toUpperCase() : ''; } function withFinancialMetricsPolicy(filing: Filing): Filing { if (FINANCIAL_FORMS.has(filing.filing_type)) { return filing; } return { ...filing, metrics: null }; } function buildSyncFilingsPayload(input: { ticker: string; limit: number; category?: unknown; tags?: unknown; }) { const category = asOptionalString(input.category); const tags = asTags(input.tags); return { ticker: input.ticker, limit: input.limit, ...(category ? { category } : {}), ...(tags.length > 0 ? { tags } : {}) }; } async function queueAutoFilingSync( userId: string, ticker: string, metadata?: { category?: unknown; tags?: unknown } ) { try { await findOrEnqueueTask({ userId, taskType: 'sync_filings', payload: buildSyncFilingsPayload({ ticker, limit: AUTO_FILING_SYNC_LIMIT, category: metadata?.category, tags: metadata?.tags }), priority: 90, resourceKey: `sync_filings:${ticker}` }); return true; } catch (error) { console.error(`[auto-filing-sync] failed for ${ticker}:`, error); return false; } } const authHandler = ({ request }: { request: Request }) => auth.handler(request); async function checkWorkflowBackend() { try { const world = getWorld(); await world.runs.list({ pagination: { limit: 1 }, resolveData: 'none' }); return { ok: true } as const; } catch (error) { return { ok: false, reason: asErrorMessage(error, 'Workflow backend unavailable') } as const; } } export const app = new Elysia({ prefix: '/api' }) .all('/auth', authHandler) .all('/auth/*', authHandler) .get('/health', async () => { try { const [queue, workflowBackend] = await Promise.all([ getTaskQueueSnapshot(), checkWorkflowBackend() ]); const ingestionSchema = getLatestFinancialIngestionSchemaStatus(); const ingestionSchemaPayload = ingestionSchema ? { ok: ingestionSchema.ok, mode: ingestionSchema.mode, missingIndexes: ingestionSchema.missingIndexes, duplicateGroups: ingestionSchema.duplicateGroups, lastCheckedAt: ingestionSchema.lastCheckedAt } : { ok: false, mode: 'failed' as const, missingIndexes: [], duplicateGroups: 0, lastCheckedAt: new Date().toISOString() }; const schemaHealthy = ingestionSchema?.ok ?? false; if (!workflowBackend.ok || !schemaHealthy) { return Response.json({ status: 'degraded', version: '4.0.0', timestamp: new Date().toISOString(), queue, database: { ingestionSchema: ingestionSchemaPayload }, workflow: { ok: workflowBackend.ok, ...(workflowBackend.ok ? {} : { reason: workflowBackend.reason }) } }, { status: 503 }); } return Response.json({ status: 'ok', version: '4.0.0', timestamp: new Date().toISOString(), queue, database: { ingestionSchema: ingestionSchemaPayload }, workflow: { ok: true } }); } catch (error) { return Response.json({ status: 'degraded', version: '4.0.0', timestamp: new Date().toISOString(), error: asErrorMessage(error, 'Health check failed') }, { status: 503 }); } }) .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); const latestFilingDates = await listLatestFilingDatesByTickers(items.map((item) => item.ticker)); return Response.json({ items: items.map((item) => ({ ...item, latest_filing_date: latestFilingDates.get(item.ticker) ?? null })) }); }) .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 = asOptionalString(payload.sector) ?? ''; const category = asOptionalString(payload.category) ?? ''; const tags = asTags(payload.tags); const status = asCoverageStatus(payload.status); const priority = asCoveragePriority(payload.priority); const lastReviewedAt = asOptionalString(payload.lastReviewedAt); 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, category, tags, status, priority, lastReviewedAt }); return Response.json({ item, autoFilingSyncQueued: false }); } 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()), category: t.Optional(t.String()), tags: t.Optional(t.Union([t.Array(t.String()), t.String()])), status: t.Optional(t.Union([ t.Literal('backlog'), t.Literal('active'), t.Literal('watch'), t.Literal('archive') ])), priority: t.Optional(t.Union([ t.Literal('low'), t.Literal('medium'), t.Literal('high') ])), lastReviewedAt: t.Optional(t.String()) }) }) .patch('/watchlist/: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 watchlist id', 400); } const existing = await getWatchlistItemById(session.user.id, numericId); if (!existing) { return jsonError('Watchlist item not found', 404); } const payload = asRecord(body); const nextStatus = payload.status === undefined ? existing.status : asCoverageStatus(payload.status); const nextPriority = payload.priority === undefined ? existing.priority : asCoveragePriority(payload.priority); if (payload.status !== undefined && !nextStatus) { return jsonError('Invalid coverage status', 400); } if (payload.priority !== undefined && !nextPriority) { return jsonError('Invalid coverage priority', 400); } try { const item = await updateWatchlistItemRecord({ userId: session.user.id, id: numericId, companyName: payload.companyName === undefined ? undefined : (typeof payload.companyName === 'string' ? payload.companyName : ''), sector: payload.sector === undefined ? undefined : (typeof payload.sector === 'string' ? payload.sector : ''), category: payload.category === undefined ? undefined : (typeof payload.category === 'string' ? payload.category : ''), tags: payload.tags === undefined ? undefined : asTags(payload.tags), status: nextStatus, priority: nextPriority, lastReviewedAt: payload.lastReviewedAt === undefined ? undefined : asOptionalString(payload.lastReviewedAt) }); if (!item) { return jsonError('Watchlist item not found', 404); } const statusChanged = existing.status !== item.status; if (statusChanged) { await createResearchJournalEntryRecord({ userId: session.user.id, ticker: item.ticker, entryType: 'status_change', title: `Coverage status changed to ${formatLabel(item.status)}`, bodyMarkdown: `Coverage status changed from ${formatLabel(existing.status)} to ${formatLabel(item.status)}.`, metadata: { previousStatus: existing.status, nextStatus: item.status, priority: item.priority } }); } return Response.json({ item, statusChangeJournalCreated: statusChanged }); } catch (error) { return jsonError(asErrorMessage(error, 'Failed to update coverage item')); } }) .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 companyName = asOptionalString(payload.companyName) ?? undefined; const { holding, created } = await upsertHoldingRecord({ userId: session.user.id, ticker, shares, avgCost, currentPrice, companyName }); const autoFilingSyncQueued = created ? await queueAutoFilingSync(session.user.id, ticker) : false; return Response.json({ holding, autoFilingSyncQueued }); } 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 })), companyName: t.Optional(t.String()) }) }) .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, companyName: payload.companyName === undefined ? undefined : (asOptionalString(payload.companyName) ?? '') }); 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 })), companyName: t.Optional(t.String()) }) }) .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, resourceKey: 'refresh_prices:portfolio' }); 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, resourceKey: 'portfolio_insights:portfolio' }); 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('/research/workspace', async ({ query }) => { const { session, response } = await requireAuthenticatedSession(); if (response) { return response; } const ticker = typeof query.ticker === 'string' ? query.ticker.trim().toUpperCase() : ''; if (!ticker) { return jsonError('ticker is required'); } const workspace = await getResearchWorkspace(session.user.id, ticker); return Response.json({ workspace }); }, { query: t.Object({ ticker: t.String({ minLength: 1 }) }) }) .get('/research/library', async ({ query }) => { const { session, response } = await requireAuthenticatedSession(); if (response) { return response; } const ticker = typeof query.ticker === 'string' ? query.ticker.trim().toUpperCase() : ''; if (!ticker) { return jsonError('ticker is required'); } const linkedToMemo = query.linkedToMemo === undefined ? null : asBoolean(query.linkedToMemo, false); const library = await listResearchArtifacts(session.user.id, { ticker, q: asOptionalString(query.q), kind: asResearchArtifactKind(query.kind) ?? null, tag: asOptionalString(query.tag), source: asResearchArtifactSource(query.source) ?? null, linkedToMemo, limit: typeof query.limit === 'number' ? query.limit : Number(query.limit ?? 100) }); return Response.json(library); }, { query: t.Object({ ticker: t.String({ minLength: 1 }), q: t.Optional(t.String()), kind: t.Optional(t.String()), tag: t.Optional(t.String()), source: t.Optional(t.String()), linkedToMemo: t.Optional(t.String()), limit: t.Optional(t.Numeric()) }) }) .post('/research/library', 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 kind = asResearchArtifactKind(payload.kind); const source = asResearchArtifactSource(payload.source); const title = asOptionalString(payload.title); const summary = asOptionalString(payload.summary); const bodyMarkdown = asOptionalString(payload.bodyMarkdown); if (!ticker) { return jsonError('ticker is required'); } if (!kind) { return jsonError('kind is required'); } if (kind === 'upload') { return jsonError('Use /api/research/library/upload for file uploads'); } if (!title && !summary && !bodyMarkdown) { return jsonError('title, summary, or bodyMarkdown is required'); } try { const artifact = await createResearchArtifactRecord({ userId: session.user.id, ticker, accessionNumber: asOptionalString(payload.accessionNumber), kind, source: source ?? 'user', subtype: asOptionalString(payload.subtype), title, summary, bodyMarkdown, tags: asTags(payload.tags), metadata: asOptionalRecord(payload.metadata) }); await updateWatchlistReviewByTicker(session.user.id, ticker, artifact.updated_at); return Response.json({ artifact }); } catch (error) { return jsonError(asErrorMessage(error, 'Failed to create research artifact')); } }) .post('/research/library/upload', async ({ request }) => { const { session, response } = await requireAuthenticatedSession(); if (response) { return response; } try { const form = await request.formData(); const ticker = normalizeTicker(String(form.get('ticker') ?? '')); const title = asOptionalString(String(form.get('title') ?? '')); const summary = asOptionalString(String(form.get('summary') ?? '')); const tags = asTags(String(form.get('tags') ?? '')); const file = form.get('file'); if (!ticker) { return jsonError('ticker is required'); } if (!(file instanceof File)) { return jsonError('file is required'); } const artifact = await storeResearchUpload({ userId: session.user.id, ticker, file, title, summary, tags }); return Response.json({ artifact }); } catch (error) { return jsonError(asErrorMessage(error, 'Failed to upload research file')); } }) .patch('/research/library/: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 artifact id', 400); } const payload = asRecord(body); try { const artifact = await updateResearchArtifactRecord({ userId: session.user.id, id: numericId, title: payload.title === undefined ? undefined : asOptionalString(payload.title), summary: payload.summary === undefined ? undefined : asOptionalString(payload.summary), bodyMarkdown: payload.bodyMarkdown === undefined ? undefined : (typeof payload.bodyMarkdown === 'string' ? payload.bodyMarkdown : ''), tags: payload.tags === undefined ? undefined : asTags(payload.tags), metadata: payload.metadata === undefined ? undefined : asOptionalRecord(payload.metadata) }); if (!artifact) { return jsonError('Research artifact not found', 404); } return Response.json({ artifact }); } catch (error) { return jsonError(asErrorMessage(error, 'Failed to update research artifact')); } }, { params: t.Object({ id: t.String({ minLength: 1 }) }) }) .delete('/research/library/: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 artifact id', 400); } const removed = await deleteResearchArtifactRecord(session.user.id, numericId); if (!removed) { return jsonError('Research artifact not found', 404); } return Response.json({ success: true }); }, { params: t.Object({ id: t.String({ minLength: 1 }) }) }) .get('/research/library/:id/file', 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 artifact id', 400); } const fileResponse = await getResearchArtifactFileResponse(session.user.id, numericId); if (!fileResponse) { return jsonError('Research upload not found', 404); } return fileResponse; }, { params: t.Object({ id: t.String({ minLength: 1 }) }) }) .get('/research/memo', async ({ query }) => { const { session, response } = await requireAuthenticatedSession(); if (response) { return response; } const ticker = typeof query.ticker === 'string' ? query.ticker.trim().toUpperCase() : ''; if (!ticker) { return jsonError('ticker is required'); } const memo = await getResearchMemoByTicker(session.user.id, ticker); return Response.json({ memo }); }, { query: t.Object({ ticker: t.String({ minLength: 1 }) }) }) .put('/research/memo', 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'); } const rating = asResearchMemoRating(payload.rating); const conviction = asResearchMemoConviction(payload.conviction); if (payload.rating !== undefined && rating === undefined) { return jsonError('Invalid memo rating', 400); } if (payload.conviction !== undefined && conviction === undefined) { return jsonError('Invalid memo conviction', 400); } try { const memo = await upsertResearchMemoRecord({ userId: session.user.id, ticker, rating, conviction, timeHorizonMonths: payload.timeHorizonMonths === undefined ? undefined : (typeof payload.timeHorizonMonths === 'number' ? payload.timeHorizonMonths : Number(payload.timeHorizonMonths)), packetTitle: payload.packetTitle === undefined ? undefined : asOptionalString(payload.packetTitle), packetSubtitle: payload.packetSubtitle === undefined ? undefined : asOptionalString(payload.packetSubtitle), thesisMarkdown: payload.thesisMarkdown === undefined ? undefined : String(payload.thesisMarkdown), variantViewMarkdown: payload.variantViewMarkdown === undefined ? undefined : String(payload.variantViewMarkdown), catalystsMarkdown: payload.catalystsMarkdown === undefined ? undefined : String(payload.catalystsMarkdown), risksMarkdown: payload.risksMarkdown === undefined ? undefined : String(payload.risksMarkdown), disconfirmingEvidenceMarkdown: payload.disconfirmingEvidenceMarkdown === undefined ? undefined : String(payload.disconfirmingEvidenceMarkdown), nextActionsMarkdown: payload.nextActionsMarkdown === undefined ? undefined : String(payload.nextActionsMarkdown) }); return Response.json({ memo }); } catch (error) { return jsonError(asErrorMessage(error, 'Failed to save research memo')); } }) .post('/research/memo/:id/evidence', 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 memo id', 400); } const payload = asRecord(body); const section = asResearchMemoSection(payload.section); const artifactId = typeof payload.artifactId === 'number' ? payload.artifactId : Number(payload.artifactId); if (!section) { return jsonError('section is required', 400); } if (!Number.isInteger(artifactId) || artifactId <= 0) { return jsonError('artifactId is required', 400); } try { const evidence = await addResearchMemoEvidenceLink({ userId: session.user.id, memoId: numericId, artifactId, section, annotation: asOptionalString(payload.annotation), sortOrder: payload.sortOrder === undefined ? undefined : (typeof payload.sortOrder === 'number' ? payload.sortOrder : Number(payload.sortOrder)) }); return Response.json({ evidence }); } catch (error) { return jsonError(asErrorMessage(error, 'Failed to attach memo evidence')); } }, { params: t.Object({ id: t.String({ minLength: 1 }) }) }) .delete('/research/memo/:id/evidence/:linkId', async ({ params }) => { const { session, response } = await requireAuthenticatedSession(); if (response) { return response; } const memoId = Number(params.id); const linkId = Number(params.linkId); if (!Number.isInteger(memoId) || memoId <= 0 || !Number.isInteger(linkId) || linkId <= 0) { return jsonError('Invalid memo evidence id', 400); } const removed = await deleteResearchMemoEvidenceLink(session.user.id, memoId, linkId); if (!removed) { return jsonError('Memo evidence not found', 404); } return Response.json({ success: true }); }, { params: t.Object({ id: t.String({ minLength: 1 }), linkId: t.String({ minLength: 1 }) }) }) .get('/research/packet', async ({ query }) => { const { session, response } = await requireAuthenticatedSession(); if (response) { return response; } const ticker = typeof query.ticker === 'string' ? query.ticker.trim().toUpperCase() : ''; if (!ticker) { return jsonError('ticker is required'); } const packet = await getResearchPacket(session.user.id, ticker); return Response.json({ packet }); }, { query: t.Object({ ticker: t.String({ minLength: 1 }) }) }) .get('/research/journal', async ({ query }) => { const { session, response } = await requireAuthenticatedSession(); if (response) { return response; } const ticker = typeof query.ticker === 'string' ? query.ticker.trim().toUpperCase() : ''; if (!ticker) { return jsonError('ticker is required'); } const entries = await listResearchJournalEntries(session.user.id, ticker); return Response.json({ entries }); }, { query: t.Object({ ticker: t.String({ minLength: 1 }) }) }) .post('/research/journal', 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 entryType = asJournalEntryType(payload.entryType); const title = asOptionalString(payload.title); const bodyMarkdown = typeof payload.bodyMarkdown === 'string' ? payload.bodyMarkdown.trim() : ''; const accessionNumber = asOptionalString(payload.accessionNumber); const metadata = asOptionalRecord(payload.metadata); if (!ticker) { return jsonError('ticker is required'); } if (!entryType) { return jsonError('entryType is required'); } if (!bodyMarkdown) { return jsonError('bodyMarkdown is required'); } try { const entry = await createResearchJournalEntryRecord({ userId: session.user.id, ticker, entryType, title, bodyMarkdown, accessionNumber, metadata }); if (!entry) { return jsonError('Failed to create journal entry', 500); } await updateWatchlistReviewByTicker(session.user.id, ticker, entry.updated_at); try { await enqueueTask({ userId: session.user.id, taskType: 'index_search', payload: { ticker: entry.ticker, journalEntryId: entry.id, sourceKinds: ['research_note'] }, priority: 52, resourceKey: `index_search:research_note:${session.user.id}:${entry.id}` }); } catch (error) { console.error('[search-index-journal-create] failed:', error); } return Response.json({ entry }); } catch (error) { return jsonError(asErrorMessage(error, 'Failed to create journal entry')); } }) .patch('/research/journal/: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 journal id', 400); } const payload = asRecord(body); const title = payload.title === undefined ? undefined : asOptionalString(payload.title); const bodyMarkdown = payload.bodyMarkdown === undefined ? undefined : (typeof payload.bodyMarkdown === 'string' ? payload.bodyMarkdown : ''); try { const entry = await updateResearchJournalEntryRecord({ userId: session.user.id, id: numericId, title, bodyMarkdown, metadata: payload.metadata === undefined ? undefined : asOptionalRecord(payload.metadata) }); if (!entry) { return jsonError('Journal entry not found', 404); } await updateWatchlistReviewByTicker(session.user.id, entry.ticker, entry.updated_at); try { await enqueueTask({ userId: session.user.id, taskType: 'index_search', payload: { ticker: entry.ticker, journalEntryId: entry.id, sourceKinds: ['research_note'] }, priority: 52, resourceKey: `index_search:research_note:${session.user.id}:${entry.id}` }); } catch (error) { console.error('[search-index-journal-update] failed:', error); } return Response.json({ entry }); } catch (error) { return jsonError(asErrorMessage(error, 'Failed to update journal entry')); } }) .delete('/research/journal/: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 journal id', 400); } const removed = await deleteResearchJournalEntryRecord(session.user.id, numericId); if (!removed) { return jsonError('Journal entry not found', 404); } try { await enqueueTask({ userId: session.user.id, taskType: 'index_search', payload: { deleteSourceRefs: [{ sourceKind: 'research_note', sourceRef: String(numericId), scope: 'user', userId: session.user.id }] }, priority: 52, resourceKey: `index_search:research_note:${session.user.id}:${numericId}:delete` }); } catch (error) { console.error('[search-index-journal-delete] failed:', error); } return Response.json({ success: true }); }, { params: t.Object({ id: t.String({ minLength: 1 }) }) }) .get('/analysis/company', async ({ query }) => { const { session, response } = await requireAuthenticatedSession(); if (response) { return response; } const ticker = typeof query.ticker === 'string' ? query.ticker.trim().toUpperCase() : ''; if (!ticker) { return jsonError('ticker is required'); } const refresh = asBoolean(query.refresh, false); const analysis = await getCompanyAnalysisPayload({ userId: session.user.id, ticker, refresh }); return Response.json({ analysis }); }, { query: t.Object({ ticker: t.String({ minLength: 1 }), refresh: t.Optional(t.String()) }) }) .get('/financials/company', async ({ query }) => { const { session, response } = await requireAuthenticatedSession(); if (response) { return response; } if (!FINANCIALS_V3_ENABLED) { return jsonError('Financial statements v3 is disabled', 404); } const ticker = typeof query.ticker === 'string' ? query.ticker.trim().toUpperCase() : ''; if (!ticker) { return jsonError('ticker is required'); } const surfaceKind = asSurfaceKind(query.surface, query.statement); const cadence = asCadence(query.cadence); const includeDimensions = asBoolean(query.includeDimensions, false); const includeFacts = asBoolean(query.includeFacts, false); const cursor = typeof query.cursor === 'string' && query.cursor.trim().length > 0 ? query.cursor.trim() : null; const limit = Number.isFinite(Number(query.limit)) ? Number(query.limit) : undefined; const factsCursor = typeof query.factsCursor === 'string' && query.factsCursor.trim().length > 0 ? query.factsCursor.trim() : null; const factsLimit = Number.isFinite(Number(query.factsLimit)) ? Number(query.factsLimit) : undefined; let payload = await getCompanyFinancials({ ticker, surfaceKind, cadence, includeDimensions, includeFacts, factsCursor, factsLimit, cursor, limit, v3Enabled: FINANCIALS_V3_ENABLED, queuedSync: false }); let queuedSync = false; const shouldQueueSync = cursor === null && ( payload.dataSourceStatus.pendingFilings > 0 || payload.coverage.filings === 0 || payload.nextCursor !== null ); if (shouldQueueSync) { try { const watchlistItem = await getWatchlistItemByTicker(session.user.id, ticker); await findOrEnqueueTask({ userId: session.user.id, taskType: 'sync_filings', payload: buildSyncFilingsPayload({ ticker, limit: defaultFinancialSyncLimit(), category: watchlistItem?.category, tags: watchlistItem?.tags }), priority: 88, resourceKey: `sync_filings:${ticker}` }); queuedSync = true; } catch (error) { console.error(`[financials-v3-sync] failed for ${ticker}:`, error); } } if (queuedSync) { payload = { ...payload, dataSourceStatus: { ...payload.dataSourceStatus, queuedSync: true } }; } return Response.json({ financials: payload }); }, { query: t.Object({ ticker: t.String({ minLength: 1 }), surface: t.Optional(t.Union([ t.Literal('income_statement'), t.Literal('balance_sheet'), t.Literal('cash_flow_statement'), t.Literal('ratios'), t.Literal('segments_kpis'), t.Literal('adjusted'), t.Literal('custom_metrics') ])), cadence: t.Optional(t.Union([ t.Literal('annual'), t.Literal('quarterly'), t.Literal('ltm') ])), statement: t.Optional(t.Union([ t.Literal('income'), t.Literal('balance'), t.Literal('cash_flow'), t.Literal('equity'), t.Literal('comprehensive_income') ])), includeDimensions: t.Optional(t.Union([t.String(), t.Boolean()])), includeFacts: t.Optional(t.Union([t.String(), t.Boolean()])), cursor: t.Optional(t.String()), limit: t.Optional(t.Numeric()), factsCursor: t.Optional(t.String()), factsLimit: t.Optional(t.Numeric()) }) }) .get('/analysis/reports/:accessionNumber', async ({ params }) => { const { response } = await requireAuthenticatedSession(); if (response) { return response; } const accessionNumber = params.accessionNumber?.trim() ?? ''; if (accessionNumber.length < 4) { return jsonError('Invalid accession number'); } const filing = await getFilingByAccession(accessionNumber); if (!filing) { return jsonError('AI summary not found', 404); } const summary = filing.analysis?.text ?? filing.analysis?.legacyInsights ?? ''; if (!summary) { return jsonError('AI summary not found', 404); } return Response.json({ report: { accessionNumber: filing.accession_number, ticker: filing.ticker, companyName: filing.company_name, filingDate: filing.filing_date, filingType: filing.filing_type, provider: filing.analysis?.provider ?? 'unknown', model: filing.analysis?.model ?? 'unknown', summary, filingUrl: filing.filing_url, submissionUrl: filing.submission_url ?? null, primaryDocument: filing.primary_document ?? null } }); }, { params: t.Object({ accessionNumber: t.String({ minLength: 4 }) }) }) .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: filings.map(redactInternalFilingAnalysisFields).map(withFinancialMetricsPolicy) }); }, { query: t.Object({ ticker: t.Optional(t.String()), limit: t.Optional(t.Numeric()) }) }) .get('/search', async ({ query }) => { const { session, response } = await requireAuthenticatedSession(); if (response) { return response; } const q = typeof query.q === 'string' ? query.q.trim() : ''; if (q.length < 2) { return jsonError('q is required', 400); } const results = await searchKnowledgeBase({ userId: session.user.id, query: q, ticker: asOptionalString(query.ticker), sources: asSearchSources(query.sources), limit: typeof query.limit === 'number' ? query.limit : Number(query.limit) }); return Response.json({ results }); }, { query: t.Object({ q: t.String({ minLength: 2 }), ticker: t.Optional(t.String()), sources: t.Optional(t.Union([t.String(), t.Array(t.String())])), limit: t.Optional(t.Numeric()) }) }) .post('/search/answer', async ({ body }) => { const { session, response } = await requireAuthenticatedSession(); if (response) { return response; } const payload = asRecord(body); const query = typeof payload.query === 'string' ? payload.query.trim() : ''; if (query.length < 2) { return jsonError('query is required', 400); } const answer = await answerSearchQuery({ userId: session.user.id, query, ticker: asOptionalString(payload.ticker), sources: asSearchSources(payload.sources), limit: asPositiveNumber(payload.limit) ?? undefined }); return Response.json(answer); }, { body: t.Object({ query: t.String({ minLength: 2 }), ticker: t.Optional(t.String()), sources: t.Optional(t.Union([t.String(), t.Array(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() : ''; const category = asOptionalString(payload.category); const tags = asTags(payload.tags); if (!ticker) { return jsonError('ticker is required'); } try { const limit = typeof payload.limit === 'number' ? payload.limit : Number(payload.limit); const task = await findOrEnqueueTask({ userId: session.user.id, taskType: 'sync_filings', payload: buildSyncFilingsPayload({ ticker, limit: Number.isFinite(limit) ? limit : 20, category, tags }), priority: 90, resourceKey: `sync_filings:${ticker}` }); 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()), category: t.Optional(t.String()), tags: t.Optional(t.Union([t.Array(t.String()), t.String()])) }) }) .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 resourceKey = `analyze_filing:${accessionNumber}`; const existing = await findInFlightTask( session.user.id, 'analyze_filing', resourceKey ); if (existing) { return Response.json({ task: existing }); } const task = await enqueueTask({ userId: session.user.id, taskType: 'analyze_filing', payload: { accessionNumber }, priority: 65, resourceKey }); 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 }) }) }) .get('/tasks/:taskId/timeline', async ({ params }) => { const { session, response } = await requireAuthenticatedSession(); if (response) { return response; } const timeline = await getTaskTimeline(params.taskId, session.user.id); if (!timeline) { return jsonError('Task not found', 404); } return Response.json(timeline); }, { params: t.Object({ taskId: t.String({ minLength: 1 }) }) }) .patch('/tasks/:taskId/notification', async ({ params, body }) => { const { session, response } = await requireAuthenticatedSession(); if (response) { return response; } const payload = asRecord(body); const read = typeof payload.read === 'boolean' ? payload.read : undefined; const silenced = typeof payload.silenced === 'boolean' ? payload.silenced : undefined; if (read === undefined && silenced === undefined) { return jsonError('read or silenced must be provided'); } const task = await updateTaskNotification(session.user.id, params.taskId, { read, silenced }); if (!task) { return jsonError('Task not found', 404); } return Response.json({ task }); }, { params: t.Object({ taskId: t.String({ minLength: 1 }) }), body: t.Object({ read: t.Optional(t.Boolean()), silenced: t.Optional(t.Boolean()) }) }); export type App = typeof app;