Implement fiscal-style research MVP flows
Some checks failed
PR Checks / typecheck-and-build (push) Has been cancelled
Some checks failed
PR Checks / typecheck-and-build (push) Has been cancelled
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
import { Elysia, t } from 'elysia';
|
||||
import { getWorld } from 'workflow/runtime';
|
||||
import type {
|
||||
CoveragePriority,
|
||||
CoverageStatus,
|
||||
Filing,
|
||||
FinancialHistoryWindow,
|
||||
FinancialStatementKind,
|
||||
ResearchJournalEntryType,
|
||||
TaskStatus
|
||||
} from '@/lib/types';
|
||||
import { auth } from '@/lib/auth';
|
||||
@@ -15,18 +18,32 @@ import {
|
||||
getCompanyFinancialTaxonomy
|
||||
} from '@/lib/server/financial-taxonomy';
|
||||
import { redactInternalFilingAnalysisFields } from '@/lib/server/api/filing-redaction';
|
||||
import { getFilingByAccession, listFilingsRecords } from '@/lib/server/repos/filings';
|
||||
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 {
|
||||
createResearchJournalEntryRecord,
|
||||
deleteResearchJournalEntryRecord,
|
||||
listResearchJournalEntries,
|
||||
updateResearchJournalEntryRecord
|
||||
} from '@/lib/server/repos/research-journal';
|
||||
import {
|
||||
deleteWatchlistItemRecord,
|
||||
getWatchlistItemById,
|
||||
getWatchlistItemByTicker,
|
||||
listWatchlistItems,
|
||||
updateWatchlistItemRecord,
|
||||
updateWatchlistReviewByTicker,
|
||||
upsertWatchlistItemRecord
|
||||
} from '@/lib/server/repos/watchlist';
|
||||
import { getPriceHistory, getQuote } from '@/lib/server/prices';
|
||||
@@ -52,6 +69,9 @@ const FINANCIAL_STATEMENT_KINDS: FinancialStatementKind[] = [
|
||||
'comprehensive_income'
|
||||
];
|
||||
const FINANCIAL_HISTORY_WINDOWS: FinancialHistoryWindow[] = ['10y', 'all'];
|
||||
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'];
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
@@ -94,6 +114,14 @@ function asOptionalString(value: unknown) {
|
||||
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
|
||||
@@ -130,6 +158,31 @@ function asHistoryWindow(value: unknown): FinancialHistoryWindow {
|
||||
: '10y';
|
||||
}
|
||||
|
||||
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 formatLabel(value: string) {
|
||||
return value
|
||||
.split('_')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function withFinancialMetricsPolicy(filing: Filing): Filing {
|
||||
if (FINANCIAL_FORMS.has(filing.filing_type)) {
|
||||
return filing;
|
||||
@@ -266,7 +319,14 @@ export const app = new Elysia({ prefix: '/api' })
|
||||
}
|
||||
|
||||
const items = await listWatchlistItems(session.user.id);
|
||||
return Response.json({ items });
|
||||
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();
|
||||
@@ -280,6 +340,9 @@ export const app = new Elysia({ prefix: '/api' })
|
||||
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');
|
||||
@@ -296,7 +359,10 @@ export const app = new Elysia({ prefix: '/api' })
|
||||
companyName,
|
||||
sector,
|
||||
category,
|
||||
tags
|
||||
tags,
|
||||
status,
|
||||
priority,
|
||||
lastReviewedAt
|
||||
});
|
||||
|
||||
const autoFilingSyncQueued = created
|
||||
@@ -316,9 +382,94 @@ export const app = new Elysia({ prefix: '/api' })
|
||||
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()]))
|
||||
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) {
|
||||
@@ -377,13 +528,15 @@ export const app = new Elysia({ prefix: '/api' })
|
||||
|
||||
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
|
||||
currentPrice,
|
||||
companyName
|
||||
});
|
||||
|
||||
const autoFilingSyncQueued = created
|
||||
@@ -399,7 +552,8 @@ export const app = new Elysia({ prefix: '/api' })
|
||||
ticker: t.String({ minLength: 1 }),
|
||||
shares: t.Number({ exclusiveMinimum: 0 }),
|
||||
avgCost: t.Number({ exclusiveMinimum: 0 }),
|
||||
currentPrice: t.Optional(t.Number({ exclusiveMinimum: 0 }))
|
||||
currentPrice: t.Optional(t.Number({ exclusiveMinimum: 0 })),
|
||||
companyName: t.Optional(t.String())
|
||||
})
|
||||
})
|
||||
.patch('/portfolio/holdings/:id', async ({ params, body }) => {
|
||||
@@ -420,7 +574,8 @@ export const app = new Elysia({ prefix: '/api' })
|
||||
id: numericId,
|
||||
shares: asPositiveNumber(payload.shares) ?? undefined,
|
||||
avgCost: asPositiveNumber(payload.avgCost) ?? undefined,
|
||||
currentPrice: asPositiveNumber(payload.currentPrice) ?? undefined
|
||||
currentPrice: asPositiveNumber(payload.currentPrice) ?? undefined,
|
||||
companyName: payload.companyName === undefined ? undefined : (asOptionalString(payload.companyName) ?? '')
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
@@ -435,7 +590,8 @@ export const app = new Elysia({ prefix: '/api' })
|
||||
body: t.Object({
|
||||
shares: t.Optional(t.Number({ exclusiveMinimum: 0 })),
|
||||
avgCost: t.Optional(t.Number({ exclusiveMinimum: 0 })),
|
||||
currentPrice: t.Optional(t.Number({ exclusiveMinimum: 0 }))
|
||||
currentPrice: t.Optional(t.Number({ exclusiveMinimum: 0 })),
|
||||
companyName: t.Optional(t.String())
|
||||
})
|
||||
})
|
||||
.delete('/portfolio/holdings/:id', async ({ params }) => {
|
||||
@@ -522,6 +678,127 @@ export const app = new Elysia({ prefix: '/api' })
|
||||
|
||||
return Response.json({ insight });
|
||||
})
|
||||
.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
|
||||
});
|
||||
|
||||
await updateWatchlistReviewByTicker(session.user.id, ticker, entry.updated_at);
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -533,22 +810,21 @@ export const app = new Elysia({ prefix: '/api' })
|
||||
return jsonError('ticker is required');
|
||||
}
|
||||
|
||||
const [filings, holdings, watchlist, liveQuote, priceHistory] = await Promise.all([
|
||||
const [filings, holding, watchlistItem, liveQuote, priceHistory, journalPreview] = await Promise.all([
|
||||
listFilingsRecords({ ticker, limit: 40 }),
|
||||
listUserHoldings(session.user.id),
|
||||
listWatchlistItems(session.user.id),
|
||||
getHoldingByTicker(session.user.id, ticker),
|
||||
getWatchlistItemByTicker(session.user.id, ticker),
|
||||
getQuote(ticker),
|
||||
getPriceHistory(ticker)
|
||||
getPriceHistory(ticker),
|
||||
listResearchJournalEntries(session.user.id, ticker, 6)
|
||||
]);
|
||||
const redactedFilings = filings
|
||||
.map(redactInternalFilingAnalysisFields)
|
||||
.map(withFinancialMetricsPolicy);
|
||||
|
||||
const latestFiling = redactedFilings[0] ?? null;
|
||||
const holding = holdings.find((entry) => entry.ticker === ticker) ?? null;
|
||||
const watchlistItem = watchlist.find((entry) => entry.ticker === ticker) ?? null;
|
||||
|
||||
const companyName = latestFiling?.company_name
|
||||
?? holding?.company_name
|
||||
?? watchlistItem?.company_name
|
||||
?? ticker;
|
||||
|
||||
@@ -575,6 +851,30 @@ export const app = new Elysia({ prefix: '/api' })
|
||||
model: entry.analysis?.model ?? 'unknown',
|
||||
summary: entry.analysis?.text ?? entry.analysis?.legacyInsights ?? ''
|
||||
}));
|
||||
const latestMetricsFiling = redactedFilings.find((entry) => entry.metrics) ?? null;
|
||||
const referenceMetrics = latestMetricsFiling?.metrics ?? null;
|
||||
const keyMetrics = {
|
||||
referenceDate: latestMetricsFiling?.filing_date ?? latestFiling?.filing_date ?? null,
|
||||
revenue: referenceMetrics?.revenue ?? null,
|
||||
netIncome: referenceMetrics?.netIncome ?? null,
|
||||
totalAssets: referenceMetrics?.totalAssets ?? null,
|
||||
cash: referenceMetrics?.cash ?? null,
|
||||
debt: referenceMetrics?.debt ?? null,
|
||||
netMargin: referenceMetrics?.revenue && referenceMetrics.netIncome !== null
|
||||
? (referenceMetrics.netIncome / referenceMetrics.revenue) * 100
|
||||
: null
|
||||
};
|
||||
const latestFilingSummary = latestFiling
|
||||
? {
|
||||
accessionNumber: latestFiling.accession_number,
|
||||
filingDate: latestFiling.filing_date,
|
||||
filingType: latestFiling.filing_type,
|
||||
filingUrl: latestFiling.filing_url,
|
||||
submissionUrl: latestFiling.submission_url ?? null,
|
||||
summary: latestFiling.analysis?.text ?? latestFiling.analysis?.legacyInsights ?? null,
|
||||
hasAnalysis: Boolean(latestFiling.analysis?.text || latestFiling.analysis?.legacyInsights)
|
||||
}
|
||||
: null;
|
||||
|
||||
return Response.json({
|
||||
analysis: {
|
||||
@@ -591,7 +891,17 @@ export const app = new Elysia({ prefix: '/api' })
|
||||
priceHistory,
|
||||
financials,
|
||||
filings: redactedFilings.slice(0, 20),
|
||||
aiReports
|
||||
aiReports,
|
||||
coverage: watchlistItem
|
||||
? {
|
||||
...watchlistItem,
|
||||
latest_filing_date: latestFiling?.filing_date ?? watchlistItem.latest_filing_date ?? null
|
||||
}
|
||||
: null,
|
||||
journalPreview,
|
||||
recentAiReports: aiReports.slice(0, 5),
|
||||
latestFilingSummary,
|
||||
keyMetrics
|
||||
}
|
||||
});
|
||||
}, {
|
||||
|
||||
Reference in New Issue
Block a user