Files
Neon-Desk/lib/server/api/app.ts

1830 lines
54 KiB
TypeScript

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<Filing['filing_type']> = 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<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return value as Record<string, unknown>;
}
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<string, unknown>;
}
function asTags(value: unknown) {
const source = Array.isArray(value)
? value
: typeof value === 'string'
? value.split(',')
: [];
const unique = new Set<string>();
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;