1925 lines
56 KiB
TypeScript
1925 lines
56 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,
|
|
TickerAutomationSource
|
|
} 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 { shouldQueueTickerAutomation } from '@/lib/server/issuer-overlays';
|
|
import { ensureIssuerOverlayRow } from '@/lib/server/repos/issuer-overlays';
|
|
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',
|
|
'disclosure',
|
|
'equity',
|
|
'comprehensive_income'
|
|
];
|
|
const FINANCIAL_CADENCES: FinancialCadence[] = ['annual', 'quarterly', 'ltm'];
|
|
const FINANCIAL_SURFACES: FinancialSurfaceKind[] = [
|
|
'income_statement',
|
|
'balance_sheet',
|
|
'cash_flow_statement',
|
|
'equity_statement',
|
|
'disclosures',
|
|
'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 TICKER_AUTOMATION_SOURCES: TickerAutomationSource[] = ['analysis', 'financials', 'search', 'graphing', 'research'];
|
|
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';
|
|
case 'disclosure':
|
|
return 'disclosures';
|
|
case 'equity':
|
|
return 'equity_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 asTickerAutomationSource(value: unknown) {
|
|
return TICKER_AUTOMATION_SOURCES.includes(value as TickerAutomationSource)
|
|
? value as TickerAutomationSource
|
|
: 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;
|
|
}
|
|
}
|
|
|
|
async function ensureTickerAutomationTask(input: {
|
|
userId: string;
|
|
ticker: string;
|
|
source: TickerAutomationSource;
|
|
}) {
|
|
void input.source;
|
|
const ticker = input.ticker.trim().toUpperCase();
|
|
await ensureIssuerOverlayRow(ticker);
|
|
|
|
if (!(await shouldQueueTickerAutomation(ticker))) {
|
|
return {
|
|
queued: false,
|
|
task: null
|
|
};
|
|
}
|
|
|
|
const watchlistItem = await getWatchlistItemByTicker(input.userId, ticker);
|
|
const task = await findOrEnqueueTask({
|
|
userId: input.userId,
|
|
taskType: 'sync_filings',
|
|
payload: buildSyncFilingsPayload({
|
|
ticker,
|
|
limit: defaultFinancialSyncLimit(),
|
|
category: watchlistItem?.category,
|
|
tags: watchlistItem?.tags
|
|
}),
|
|
priority: 89,
|
|
resourceKey: `sync_filings:${ticker}`
|
|
});
|
|
|
|
return {
|
|
queued: true,
|
|
task
|
|
};
|
|
}
|
|
|
|
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 });
|
|
})
|
|
.post('/tickers/ensure', 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 source = asTickerAutomationSource(payload.source);
|
|
|
|
if (!ticker) {
|
|
return jsonError('ticker is required');
|
|
}
|
|
|
|
if (!source) {
|
|
return jsonError('source is required');
|
|
}
|
|
|
|
try {
|
|
return Response.json(await ensureTickerAutomationTask({
|
|
userId: session.user.id,
|
|
ticker,
|
|
source
|
|
}));
|
|
} catch (error) {
|
|
return jsonError(asErrorMessage(error, `Failed to ensure ticker automation for ${ticker}`));
|
|
}
|
|
}, {
|
|
body: t.Object({
|
|
ticker: t.String({ minLength: 1 }),
|
|
source: t.Union([
|
|
t.Literal('analysis'),
|
|
t.Literal('financials'),
|
|
t.Literal('search'),
|
|
t.Literal('graphing'),
|
|
t.Literal('research')
|
|
])
|
|
})
|
|
})
|
|
.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('equity_statement'),
|
|
t.Literal('disclosures'),
|
|
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('disclosure'),
|
|
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;
|