Add research workspace and graphing flows

This commit is contained in:
2026-03-07 16:52:35 -05:00
parent db01f207a5
commit 62bacdf104
37 changed files with 5494 additions and 434 deletions

View File

@@ -7,7 +7,12 @@ import type {
FinancialCadence,
FinancialStatementKind,
FinancialSurfaceKind,
ResearchArtifactKind,
ResearchArtifactSource,
ResearchJournalEntryType,
ResearchMemoConviction,
ResearchMemoRating,
ResearchMemoSection,
TaskStatus
} from '@/lib/types';
import { auth } from '@/lib/auth';
@@ -32,6 +37,22 @@ import {
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,
@@ -82,6 +103,18 @@ const FINANCIAL_SURFACES: FinancialSurfaceKind[] = [
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 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)) {
@@ -205,6 +238,44 @@ function asJournalEntryType(value: unknown) {
: 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('_')
@@ -212,6 +283,10 @@ function formatLabel(value: string) {
.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;
@@ -707,6 +782,383 @@ export const app = new Elysia({ prefix: '/api' })
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) {
@@ -762,6 +1214,10 @@ export const app = new Elysia({ prefix: '/api' })
metadata
});
if (!entry) {
return jsonError('Failed to create journal entry', 500);
}
await updateWatchlistReviewByTicker(session.user.id, ticker, entry.updated_at);
return Response.json({ entry });

View File

@@ -24,6 +24,8 @@ describe('sqlite schema compatibility bootstrap', () => {
expect(__dbInternals.hasColumn(client, 'holding', 'company_name')).toBe(false);
expect(__dbInternals.hasTable(client, 'filing_taxonomy_snapshot')).toBe(false);
expect(__dbInternals.hasTable(client, 'research_journal_entry')).toBe(false);
expect(__dbInternals.hasTable(client, 'research_artifact')).toBe(false);
expect(__dbInternals.hasTable(client, 'research_memo')).toBe(false);
__dbInternals.ensureLocalSqliteSchema(client);
@@ -37,6 +39,9 @@ describe('sqlite schema compatibility bootstrap', () => {
expect(__dbInternals.hasTable(client, 'filing_taxonomy_snapshot')).toBe(true);
expect(__dbInternals.hasTable(client, 'filing_taxonomy_fact')).toBe(true);
expect(__dbInternals.hasTable(client, 'research_journal_entry')).toBe(true);
expect(__dbInternals.hasTable(client, 'research_artifact')).toBe(true);
expect(__dbInternals.hasTable(client, 'research_memo')).toBe(true);
expect(__dbInternals.hasTable(client, 'research_memo_evidence')).toBe(true);
client.close();
});

View File

@@ -50,7 +50,304 @@ function applySqlFile(client: Database, fileName: string) {
client.exec(sql);
}
function applyBaseSchemaCompat(client: Database) {
const sql = readFileSync(join(process.cwd(), 'drizzle', '0000_cold_silver_centurion.sql'), 'utf8')
.replaceAll('CREATE TABLE `', 'CREATE TABLE IF NOT EXISTS `')
.replaceAll('CREATE UNIQUE INDEX `', 'CREATE UNIQUE INDEX IF NOT EXISTS `')
.replaceAll('CREATE INDEX `', 'CREATE INDEX IF NOT EXISTS `');
client.exec(sql);
}
function ensureResearchWorkspaceSchema(client: Database) {
if (!hasTable(client, 'research_artifact')) {
client.exec(`
CREATE TABLE IF NOT EXISTS \`research_artifact\` (
\`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
\`user_id\` text NOT NULL,
\`organization_id\` text,
\`ticker\` text NOT NULL,
\`accession_number\` text,
\`kind\` text NOT NULL,
\`source\` text NOT NULL DEFAULT 'user',
\`subtype\` text,
\`title\` text,
\`summary\` text,
\`body_markdown\` text,
\`search_text\` text,
\`visibility_scope\` text NOT NULL DEFAULT 'private',
\`tags\` text,
\`metadata\` text,
\`file_name\` text,
\`mime_type\` text,
\`file_size_bytes\` integer,
\`storage_path\` text,
\`created_at\` text NOT NULL,
\`updated_at\` text NOT NULL,
FOREIGN KEY (\`user_id\`) REFERENCES \`user\`(\`id\`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (\`organization_id\`) REFERENCES \`organization\`(\`id\`) ON UPDATE no action ON DELETE set null
);
`);
}
if (!hasTable(client, 'research_memo')) {
client.exec(`
CREATE TABLE IF NOT EXISTS \`research_memo\` (
\`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
\`user_id\` text NOT NULL,
\`organization_id\` text,
\`ticker\` text NOT NULL,
\`rating\` text,
\`conviction\` text,
\`time_horizon_months\` integer,
\`packet_title\` text,
\`packet_subtitle\` text,
\`thesis_markdown\` text NOT NULL DEFAULT '',
\`variant_view_markdown\` text NOT NULL DEFAULT '',
\`catalysts_markdown\` text NOT NULL DEFAULT '',
\`risks_markdown\` text NOT NULL DEFAULT '',
\`disconfirming_evidence_markdown\` text NOT NULL DEFAULT '',
\`next_actions_markdown\` text NOT NULL DEFAULT '',
\`created_at\` text NOT NULL,
\`updated_at\` text NOT NULL,
FOREIGN KEY (\`user_id\`) REFERENCES \`user\`(\`id\`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (\`organization_id\`) REFERENCES \`organization\`(\`id\`) ON UPDATE no action ON DELETE set null
);
`);
}
if (!hasTable(client, 'research_memo_evidence')) {
client.exec(`
CREATE TABLE IF NOT EXISTS \`research_memo_evidence\` (
\`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
\`memo_id\` integer NOT NULL,
\`artifact_id\` integer NOT NULL,
\`section\` text NOT NULL,
\`annotation\` text,
\`sort_order\` integer NOT NULL DEFAULT 0,
\`created_at\` text NOT NULL,
FOREIGN KEY (\`memo_id\`) REFERENCES \`research_memo\`(\`id\`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (\`artifact_id\`) REFERENCES \`research_artifact\`(\`id\`) ON UPDATE no action ON DELETE cascade
);
`);
}
client.exec('CREATE INDEX IF NOT EXISTS `research_artifact_ticker_idx` ON `research_artifact` (`user_id`, `ticker`, `updated_at`);');
client.exec('CREATE INDEX IF NOT EXISTS `research_artifact_kind_idx` ON `research_artifact` (`user_id`, `kind`, `updated_at`);');
client.exec('CREATE INDEX IF NOT EXISTS `research_artifact_accession_idx` ON `research_artifact` (`user_id`, `accession_number`);');
client.exec('CREATE INDEX IF NOT EXISTS `research_artifact_source_idx` ON `research_artifact` (`user_id`, `source`, `updated_at`);');
client.exec('CREATE UNIQUE INDEX IF NOT EXISTS `research_memo_ticker_uidx` ON `research_memo` (`user_id`, `ticker`);');
client.exec('CREATE INDEX IF NOT EXISTS `research_memo_updated_idx` ON `research_memo` (`user_id`, `updated_at`);');
client.exec('CREATE INDEX IF NOT EXISTS `research_memo_evidence_memo_idx` ON `research_memo_evidence` (`memo_id`, `section`, `sort_order`);');
client.exec('CREATE INDEX IF NOT EXISTS `research_memo_evidence_artifact_idx` ON `research_memo_evidence` (`artifact_id`);');
client.exec('CREATE UNIQUE INDEX IF NOT EXISTS `research_memo_evidence_unique_uidx` ON `research_memo_evidence` (`memo_id`, `artifact_id`, `section`);');
client.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS \`research_artifact_fts\` USING fts5(
artifact_id UNINDEXED,
user_id UNINDEXED,
ticker UNINDEXED,
title,
summary,
body_markdown,
search_text,
tags_text
);
`);
client.exec(`
INSERT INTO \`research_artifact\` (
\`user_id\`,
\`organization_id\`,
\`ticker\`,
\`accession_number\`,
\`kind\`,
\`source\`,
\`subtype\`,
\`title\`,
\`summary\`,
\`body_markdown\`,
\`search_text\`,
\`visibility_scope\`,
\`tags\`,
\`metadata\`,
\`created_at\`,
\`updated_at\`
)
SELECT
r.\`user_id\`,
NULL,
r.\`ticker\`,
r.\`accession_number\`,
CASE
WHEN r.\`entry_type\` = 'status_change' THEN 'status_change'
ELSE 'note'
END,
CASE
WHEN r.\`entry_type\` = 'status_change' THEN 'system'
ELSE 'user'
END,
r.\`entry_type\`,
r.\`title\`,
CASE
WHEN r.\`body_markdown\` IS NULL OR TRIM(r.\`body_markdown\`) = '' THEN NULL
ELSE SUBSTR(r.\`body_markdown\`, 1, 280)
END,
r.\`body_markdown\`,
r.\`body_markdown\`,
'private',
NULL,
r.\`metadata\`,
r.\`created_at\`,
r.\`updated_at\`
FROM \`research_journal_entry\` r
WHERE EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'research_journal_entry')
AND NOT EXISTS (
SELECT 1
FROM \`research_artifact\` a
WHERE a.\`user_id\` = r.\`user_id\`
AND a.\`ticker\` = r.\`ticker\`
AND IFNULL(a.\`accession_number\`, '') = IFNULL(r.\`accession_number\`, '')
AND a.\`kind\` = CASE
WHEN r.\`entry_type\` = 'status_change' THEN 'status_change'
ELSE 'note'
END
AND IFNULL(a.\`title\`, '') = IFNULL(r.\`title\`, '')
AND a.\`created_at\` = r.\`created_at\`
);
`);
client.exec(`
INSERT INTO \`research_artifact\` (
\`user_id\`,
\`organization_id\`,
\`ticker\`,
\`accession_number\`,
\`kind\`,
\`source\`,
\`subtype\`,
\`title\`,
\`summary\`,
\`body_markdown\`,
\`search_text\`,
\`visibility_scope\`,
\`tags\`,
\`metadata\`,
\`created_at\`,
\`updated_at\`
)
SELECT
w.\`user_id\`,
NULL,
f.\`ticker\`,
f.\`accession_number\`,
'ai_report',
'system',
'filing_analysis',
f.\`filing_type\` || ' AI memo',
COALESCE(json_extract(f.\`analysis\`, '$.text'), json_extract(f.\`analysis\`, '$.legacyInsights')),
'Stored AI memo for ' || f.\`company_name\` || ' (' || f.\`ticker\` || ').' || CHAR(10) ||
'Accession: ' || f.\`accession_number\` || CHAR(10) || CHAR(10) ||
COALESCE(json_extract(f.\`analysis\`, '$.text'), json_extract(f.\`analysis\`, '$.legacyInsights')),
COALESCE(json_extract(f.\`analysis\`, '$.text'), json_extract(f.\`analysis\`, '$.legacyInsights')),
'private',
NULL,
json_object(
'provider', json_extract(f.\`analysis\`, '$.provider'),
'model', json_extract(f.\`analysis\`, '$.model'),
'filingType', f.\`filing_type\`,
'filingDate', f.\`filing_date\`
),
f.\`created_at\`,
f.\`updated_at\`
FROM \`filing\` f
JOIN \`watchlist_item\` w
ON w.\`ticker\` = f.\`ticker\`
WHERE f.\`analysis\` IS NOT NULL
AND TRIM(COALESCE(json_extract(f.\`analysis\`, '$.text'), json_extract(f.\`analysis\`, '$.legacyInsights'), '')) <> ''
AND NOT EXISTS (
SELECT 1
FROM \`research_artifact\` a
WHERE a.\`user_id\` = w.\`user_id\`
AND a.\`ticker\` = f.\`ticker\`
AND a.\`accession_number\` = f.\`accession_number\`
AND a.\`kind\` = 'ai_report'
);
`);
client.exec('DELETE FROM `research_artifact_fts`;');
client.exec(`
INSERT INTO \`research_artifact_fts\` (
\`artifact_id\`,
\`user_id\`,
\`ticker\`,
\`title\`,
\`summary\`,
\`body_markdown\`,
\`search_text\`,
\`tags_text\`
)
SELECT
\`id\`,
\`user_id\`,
\`ticker\`,
COALESCE(\`title\`, ''),
COALESCE(\`summary\`, ''),
COALESCE(\`body_markdown\`, ''),
COALESCE(\`search_text\`, ''),
CASE
WHEN \`tags\` IS NULL OR TRIM(\`tags\`) = '' THEN ''
ELSE REPLACE(REPLACE(REPLACE(\`tags\`, '[', ''), ']', ''), '\"', '')
END
FROM \`research_artifact\`;
`);
}
function ensureLocalSqliteSchema(client: Database) {
const missingBaseSchema = [
'filing',
'watchlist_item',
'holding',
'task_run',
'portfolio_insight'
].some((tableName) => !hasTable(client, tableName));
if (missingBaseSchema) {
applyBaseSchemaCompat(client);
}
if (!hasTable(client, 'user')) {
client.exec(`
CREATE TABLE IF NOT EXISTS \`user\` (
\`id\` text PRIMARY KEY NOT NULL,
\`name\` text NOT NULL,
\`email\` text NOT NULL,
\`emailVerified\` integer NOT NULL DEFAULT 0,
\`image\` text,
\`createdAt\` integer NOT NULL,
\`updatedAt\` integer NOT NULL,
\`role\` text,
\`banned\` integer DEFAULT 0,
\`banReason\` text,
\`banExpires\` integer
);
`);
client.exec('CREATE UNIQUE INDEX IF NOT EXISTS `user_email_uidx` ON `user` (`email`);');
}
if (!hasTable(client, 'organization')) {
client.exec(`
CREATE TABLE IF NOT EXISTS \`organization\` (
\`id\` text PRIMARY KEY NOT NULL,
\`name\` text NOT NULL,
\`slug\` text NOT NULL,
\`logo\` text,
\`createdAt\` integer NOT NULL,
\`metadata\` text
);
`);
client.exec('CREATE UNIQUE INDEX IF NOT EXISTS `organization_slug_uidx` ON `organization` (`slug`);');
}
if (!hasTable(client, 'filing_statement_snapshot')) {
applySqlFile(client, '0001_glossy_statement_snapshots.sql');
}
@@ -142,6 +439,8 @@ function ensureLocalSqliteSchema(client: Database) {
client.exec('CREATE INDEX IF NOT EXISTS `research_journal_ticker_idx` ON `research_journal_entry` (`user_id`, `ticker`, `created_at`);');
client.exec('CREATE INDEX IF NOT EXISTS `research_journal_accession_idx` ON `research_journal_entry` (`user_id`, `accession_number`);');
}
ensureResearchWorkspaceSchema(client);
}
export function getSqliteClient() {

View File

@@ -30,6 +30,18 @@ type TaxonomyMetricValidationStatus = 'not_run' | 'matched' | 'mismatch' | 'erro
type CoverageStatus = 'backlog' | 'active' | 'watch' | 'archive';
type CoveragePriority = 'low' | 'medium' | 'high';
type ResearchJournalEntryType = 'note' | 'filing_note' | 'status_change';
type ResearchArtifactKind = 'filing' | 'ai_report' | 'note' | 'upload' | 'memo_snapshot' | 'status_change';
type ResearchArtifactSource = 'system' | 'user';
type ResearchVisibilityScope = 'private' | 'organization';
type ResearchMemoRating = 'strong_buy' | 'buy' | 'hold' | 'sell';
type ResearchMemoConviction = 'low' | 'medium' | 'high';
type ResearchMemoSection =
| 'thesis'
| 'variant_view'
| 'catalysts'
| 'risks'
| 'disconfirming_evidence'
| 'next_actions';
type FinancialCadence = 'annual' | 'quarterly' | 'ltm';
type FinancialSurfaceKind =
| 'income_statement'
@@ -570,6 +582,72 @@ export const researchJournalEntry = sqliteTable('research_journal_entry', {
researchJournalAccessionIndex: index('research_journal_accession_idx').on(table.user_id, table.accession_number)
}));
export const researchArtifact = sqliteTable('research_artifact', {
id: integer('id').primaryKey({ autoIncrement: true }),
user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
organization_id: text('organization_id').references(() => organization.id, { onDelete: 'set null' }),
ticker: text('ticker').notNull(),
accession_number: text('accession_number'),
kind: text('kind').$type<ResearchArtifactKind>().notNull(),
source: text('source').$type<ResearchArtifactSource>().notNull().default('user'),
subtype: text('subtype'),
title: text('title'),
summary: text('summary'),
body_markdown: text('body_markdown'),
search_text: text('search_text'),
visibility_scope: text('visibility_scope').$type<ResearchVisibilityScope>().notNull().default('private'),
tags: text('tags', { mode: 'json' }).$type<string[]>(),
metadata: text('metadata', { mode: 'json' }).$type<Record<string, unknown> | null>(),
file_name: text('file_name'),
mime_type: text('mime_type'),
file_size_bytes: integer('file_size_bytes'),
storage_path: text('storage_path'),
created_at: text('created_at').notNull(),
updated_at: text('updated_at').notNull()
}, (table) => ({
researchArtifactTickerIndex: index('research_artifact_ticker_idx').on(table.user_id, table.ticker, table.updated_at),
researchArtifactKindIndex: index('research_artifact_kind_idx').on(table.user_id, table.kind, table.updated_at),
researchArtifactAccessionIndex: index('research_artifact_accession_idx').on(table.user_id, table.accession_number),
researchArtifactSourceIndex: index('research_artifact_source_idx').on(table.user_id, table.source, table.updated_at)
}));
export const researchMemo = sqliteTable('research_memo', {
id: integer('id').primaryKey({ autoIncrement: true }),
user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
organization_id: text('organization_id').references(() => organization.id, { onDelete: 'set null' }),
ticker: text('ticker').notNull(),
rating: text('rating').$type<ResearchMemoRating>(),
conviction: text('conviction').$type<ResearchMemoConviction>(),
time_horizon_months: integer('time_horizon_months'),
packet_title: text('packet_title'),
packet_subtitle: text('packet_subtitle'),
thesis_markdown: text('thesis_markdown').notNull().default(''),
variant_view_markdown: text('variant_view_markdown').notNull().default(''),
catalysts_markdown: text('catalysts_markdown').notNull().default(''),
risks_markdown: text('risks_markdown').notNull().default(''),
disconfirming_evidence_markdown: text('disconfirming_evidence_markdown').notNull().default(''),
next_actions_markdown: text('next_actions_markdown').notNull().default(''),
created_at: text('created_at').notNull(),
updated_at: text('updated_at').notNull()
}, (table) => ({
researchMemoTickerUnique: uniqueIndex('research_memo_ticker_uidx').on(table.user_id, table.ticker),
researchMemoUpdatedIndex: index('research_memo_updated_idx').on(table.user_id, table.updated_at)
}));
export const researchMemoEvidence = sqliteTable('research_memo_evidence', {
id: integer('id').primaryKey({ autoIncrement: true }),
memo_id: integer('memo_id').notNull().references(() => researchMemo.id, { onDelete: 'cascade' }),
artifact_id: integer('artifact_id').notNull().references(() => researchArtifact.id, { onDelete: 'cascade' }),
section: text('section').$type<ResearchMemoSection>().notNull(),
annotation: text('annotation'),
sort_order: integer('sort_order').notNull().default(0),
created_at: text('created_at').notNull()
}, (table) => ({
researchMemoEvidenceMemoIndex: index('research_memo_evidence_memo_idx').on(table.memo_id, table.section, table.sort_order),
researchMemoEvidenceArtifactIndex: index('research_memo_evidence_artifact_idx').on(table.artifact_id),
researchMemoEvidenceUnique: uniqueIndex('research_memo_evidence_unique_uidx').on(table.memo_id, table.artifact_id, table.section)
}));
export const authSchema = {
user,
session,
@@ -595,7 +673,10 @@ export const appSchema = {
taskRun,
taskStageEvent,
portfolioInsight,
researchJournalEntry
researchJournalEntry,
researchArtifact,
researchMemo,
researchMemoEvidence
};
export const schema = {

View File

@@ -2,6 +2,11 @@ import type {
FinancialStatementKind,
FinancialUnit
} from '@/lib/types';
import {
BALANCE_SHEET_METRIC_DEFINITIONS,
CASH_FLOW_STATEMENT_METRIC_DEFINITIONS,
INCOME_STATEMENT_METRIC_DEFINITIONS
} from '@/lib/financial-metrics';
export type CanonicalRowDefinition = {
key: string;
@@ -13,73 +18,8 @@ export type CanonicalRowDefinition = {
labelIncludes?: readonly string[];
};
const INCOME_DEFINITIONS: CanonicalRowDefinition[] = [
{ key: 'revenue', label: 'Revenue', category: 'revenue', order: 10, unit: 'currency', localNames: ['RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'SalesRevenueNet', 'TotalRevenuesAndOtherIncome'], labelIncludes: ['revenue', 'sales'] },
{ key: 'cost_of_revenue', label: 'Cost of Revenue', category: 'expense', order: 20, unit: 'currency', localNames: ['CostOfRevenue', 'CostOfGoodsSold', 'CostOfSales', 'CostOfProductsSold', 'CostOfServices'], labelIncludes: ['cost of revenue', 'cost of sales', 'cost of goods sold'] },
{ key: 'gross_profit', label: 'Gross Profit', category: 'profit', order: 30, unit: 'currency', localNames: ['GrossProfit'] },
{ key: 'gross_margin', label: 'Gross Margin', category: 'margin', order: 40, unit: 'percent', labelIncludes: ['gross margin'] },
{ key: 'research_and_development', label: 'Research & Development', category: 'opex', order: 50, unit: 'currency', localNames: ['ResearchAndDevelopmentExpense'], labelIncludes: ['research and development'] },
{ key: 'sales_and_marketing', label: 'Sales & Marketing', category: 'opex', order: 60, unit: 'currency', localNames: ['SellingAndMarketingExpense'], labelIncludes: ['sales and marketing', 'selling and marketing'] },
{ key: 'general_and_administrative', label: 'General & Administrative', category: 'opex', order: 70, unit: 'currency', localNames: ['GeneralAndAdministrativeExpense'], labelIncludes: ['general and administrative'] },
{ key: 'selling_general_and_administrative', label: 'Selling, General & Administrative', category: 'opex', order: 80, unit: 'currency', localNames: ['SellingGeneralAndAdministrativeExpense'], labelIncludes: ['selling, general', 'selling general'] },
{ key: 'operating_income', label: 'Operating Income', category: 'profit', order: 90, unit: 'currency', localNames: ['OperatingIncomeLoss', 'IncomeLossFromOperations'], labelIncludes: ['operating income'] },
{ key: 'operating_margin', label: 'Operating Margin', category: 'margin', order: 100, unit: 'percent', labelIncludes: ['operating margin'] },
{ key: 'interest_income', label: 'Interest Income', category: 'non_operating', order: 110, unit: 'currency', localNames: ['InterestIncomeExpenseNonoperatingNet', 'InterestIncomeOther', 'InvestmentIncomeInterest'], labelIncludes: ['interest income'] },
{ key: 'interest_expense', label: 'Interest Expense', category: 'non_operating', order: 120, unit: 'currency', localNames: ['InterestExpense', 'InterestAndDebtExpense'], labelIncludes: ['interest expense'] },
{ key: 'other_non_operating_income', label: 'Other Non-Operating Income', category: 'non_operating', order: 130, unit: 'currency', localNames: ['OtherNonoperatingIncomeExpense', 'NonoperatingIncomeExpense'], labelIncludes: ['other non-operating', 'non-operating income'] },
{ key: 'pretax_income', label: 'Pretax Income', category: 'profit', order: 140, unit: 'currency', localNames: ['IncomeBeforeTaxExpenseBenefit', 'PretaxIncome'], labelIncludes: ['income before taxes', 'pretax income'] },
{ key: 'income_tax_expense', label: 'Income Tax Expense', category: 'tax', order: 150, unit: 'currency', localNames: ['IncomeTaxExpenseBenefit'], labelIncludes: ['income tax'] },
{ key: 'effective_tax_rate', label: 'Effective Tax Rate', category: 'tax', order: 160, unit: 'percent', labelIncludes: ['effective tax rate'] },
{ key: 'net_income', label: 'Net Income', category: 'profit', order: 170, unit: 'currency', localNames: ['NetIncomeLoss', 'ProfitLoss'], labelIncludes: ['net income'] },
{ key: 'diluted_eps', label: 'Diluted EPS', category: 'per_share', order: 180, unit: 'currency', localNames: ['EarningsPerShareDiluted', 'DilutedEarningsPerShare'], labelIncludes: ['diluted eps', 'diluted earnings per share'] },
{ key: 'basic_eps', label: 'Basic EPS', category: 'per_share', order: 190, unit: 'currency', localNames: ['EarningsPerShareBasic', 'BasicEarningsPerShare'], labelIncludes: ['basic eps', 'basic earnings per share'] },
{ key: 'diluted_shares', label: 'Diluted Shares', category: 'shares', order: 200, unit: 'shares', localNames: ['WeightedAverageNumberOfDilutedSharesOutstanding', 'WeightedAverageNumberOfShareOutstandingDiluted'], labelIncludes: ['diluted shares', 'weighted average diluted'] },
{ key: 'basic_shares', label: 'Basic Shares', category: 'shares', order: 210, unit: 'shares', localNames: ['WeightedAverageNumberOfSharesOutstandingBasic', 'WeightedAverageNumberOfShareOutstandingBasicAndDiluted'], labelIncludes: ['basic shares', 'weighted average basic'] },
{ key: 'depreciation_and_amortization', label: 'Depreciation & Amortization', category: 'non_cash', order: 220, unit: 'currency', localNames: ['DepreciationDepletionAndAmortization', 'DepreciationAmortizationAndAccretionNet', 'DepreciationAndAmortization'], labelIncludes: ['depreciation', 'amortization'] },
{ key: 'ebitda', label: 'EBITDA', category: 'profit', order: 230, unit: 'currency', localNames: ['OperatingIncomeLoss'], labelIncludes: ['ebitda'] },
{ key: 'stock_based_compensation', label: 'Stock-Based Compensation', category: 'non_cash', order: 240, unit: 'currency', localNames: ['ShareBasedCompensation', 'AllocatedShareBasedCompensationExpense'], labelIncludes: ['stock-based compensation', 'share-based compensation'] }
];
const BALANCE_DEFINITIONS: CanonicalRowDefinition[] = [
{ key: 'cash_and_equivalents', label: 'Cash & Equivalents', category: 'asset', order: 10, unit: 'currency', localNames: ['CashAndCashEquivalentsAtCarryingValue', 'CashCashEquivalentsAndShortTermInvestments', 'CashAndShortTermInvestments'], labelIncludes: ['cash and cash equivalents'] },
{ key: 'short_term_investments', label: 'Short-Term Investments', category: 'asset', order: 20, unit: 'currency', localNames: ['AvailableForSaleSecuritiesCurrent', 'ShortTermInvestments'], labelIncludes: ['short-term investments', 'marketable securities'] },
{ key: 'accounts_receivable', label: 'Accounts Receivable', category: 'asset', order: 30, unit: 'currency', localNames: ['AccountsReceivableNetCurrent', 'ReceivablesNetCurrent'], labelIncludes: ['accounts receivable'] },
{ key: 'inventory', label: 'Inventory', category: 'asset', order: 40, unit: 'currency', localNames: ['InventoryNet'], labelIncludes: ['inventory'] },
{ key: 'other_current_assets', label: 'Other Current Assets', category: 'asset', order: 50, unit: 'currency', localNames: ['OtherAssetsCurrent'], labelIncludes: ['other current assets'] },
{ key: 'current_assets', label: 'Current Assets', category: 'asset', order: 60, unit: 'currency', localNames: ['AssetsCurrent'], labelIncludes: ['current assets'] },
{ key: 'property_plant_equipment', label: 'Property, Plant & Equipment', category: 'asset', order: 70, unit: 'currency', localNames: ['PropertyPlantAndEquipmentNet'], labelIncludes: ['property, plant and equipment', 'property and equipment'] },
{ key: 'goodwill', label: 'Goodwill', category: 'asset', order: 80, unit: 'currency', localNames: ['Goodwill'], labelIncludes: ['goodwill'] },
{ key: 'intangible_assets', label: 'Intangible Assets', category: 'asset', order: 90, unit: 'currency', localNames: ['FiniteLivedIntangibleAssetsNet', 'IndefiniteLivedIntangibleAssetsExcludingGoodwill', 'IntangibleAssetsNetExcludingGoodwill'], labelIncludes: ['intangible assets'] },
{ key: 'total_assets', label: 'Total Assets', category: 'asset', order: 100, unit: 'currency', localNames: ['Assets'], labelIncludes: ['total assets'] },
{ key: 'accounts_payable', label: 'Accounts Payable', category: 'liability', order: 110, unit: 'currency', localNames: ['AccountsPayableCurrent'], labelIncludes: ['accounts payable'] },
{ key: 'accrued_liabilities', label: 'Accrued Liabilities', category: 'liability', order: 120, unit: 'currency', localNames: ['AccruedLiabilitiesCurrent'], labelIncludes: ['accrued liabilities'] },
{ key: 'deferred_revenue_current', label: 'Deferred Revenue, Current', category: 'liability', order: 130, unit: 'currency', localNames: ['ContractWithCustomerLiabilityCurrent', 'DeferredRevenueCurrent'], labelIncludes: ['deferred revenue current', 'current deferred revenue'] },
{ key: 'current_liabilities', label: 'Current Liabilities', category: 'liability', order: 140, unit: 'currency', localNames: ['LiabilitiesCurrent'], labelIncludes: ['current liabilities'] },
{ key: 'long_term_debt', label: 'Long-Term Debt', category: 'liability', order: 150, unit: 'currency', localNames: ['LongTermDebtNoncurrent', 'LongTermDebt', 'DebtNoncurrent', 'LongTermDebtAndCapitalLeaseObligations'], labelIncludes: ['long-term debt'] },
{ key: 'current_debt', label: 'Current Debt', category: 'liability', order: 160, unit: 'currency', localNames: ['DebtCurrent', 'ShortTermBorrowings', 'LongTermDebtCurrent'], labelIncludes: ['current debt', 'short-term debt'] },
{ key: 'lease_liabilities', label: 'Lease Liabilities', category: 'liability', order: 170, unit: 'currency', localNames: ['OperatingLeaseLiabilityNoncurrent', 'FinanceLeaseLiabilityNoncurrent', 'LesseeOperatingLeaseLiability'], labelIncludes: ['lease liabilities'] },
{ key: 'total_debt', label: 'Total Debt', category: 'liability', order: 180, unit: 'currency', localNames: ['DebtAndFinanceLeaseLiabilities', 'Debt'], labelIncludes: ['total debt'] },
{ key: 'deferred_revenue_noncurrent', label: 'Deferred Revenue, Noncurrent', category: 'liability', order: 190, unit: 'currency', localNames: ['ContractWithCustomerLiabilityNoncurrent', 'DeferredRevenueNoncurrent'], labelIncludes: ['deferred revenue noncurrent'] },
{ key: 'total_liabilities', label: 'Total Liabilities', category: 'liability', order: 200, unit: 'currency', localNames: ['Liabilities'], labelIncludes: ['total liabilities'] },
{ key: 'retained_earnings', label: 'Retained Earnings', category: 'equity', order: 210, unit: 'currency', localNames: ['RetainedEarningsAccumulatedDeficit'], labelIncludes: ['retained earnings', 'accumulated deficit'] },
{ key: 'total_equity', label: 'Total Equity', category: 'equity', order: 220, unit: 'currency', localNames: ['StockholdersEquity', 'StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest', 'PartnersCapital'], labelIncludes: ['total equity', 'stockholders equity', 'stockholders equity'] },
{ key: 'net_cash_position', label: 'Net Cash Position', category: 'liquidity', order: 230, unit: 'currency', labelIncludes: ['net cash position'] }
];
const CASH_FLOW_DEFINITIONS: CanonicalRowDefinition[] = [
{ key: 'operating_cash_flow', label: 'Operating Cash Flow', category: 'cash_flow', order: 10, unit: 'currency', localNames: ['NetCashProvidedByUsedInOperatingActivities', 'NetCashProvidedByUsedInOperatingActivitiesContinuingOperations'], labelIncludes: ['operating cash flow'] },
{ key: 'capital_expenditures', label: 'Capital Expenditures', category: 'cash_flow', order: 20, unit: 'currency', localNames: ['PaymentsToAcquirePropertyPlantAndEquipment', 'CapitalExpendituresIncurredButNotYetPaid'], labelIncludes: ['capital expenditures', 'capital expenditure'] },
{ key: 'free_cash_flow', label: 'Free Cash Flow', category: 'cash_flow', order: 30, unit: 'currency', labelIncludes: ['free cash flow'] },
{ key: 'stock_based_compensation', label: 'Stock-Based Compensation', category: 'non_cash', order: 40, unit: 'currency', localNames: ['ShareBasedCompensation', 'AllocatedShareBasedCompensationExpense'], labelIncludes: ['stock-based compensation', 'share-based compensation'] },
{ key: 'acquisitions', label: 'Acquisitions', category: 'investing', order: 50, unit: 'currency', localNames: ['PaymentsToAcquireBusinessesNetOfCashAcquired'], labelIncludes: ['acquisitions'] },
{ key: 'share_repurchases', label: 'Share Repurchases', category: 'financing', order: 60, unit: 'currency', localNames: ['PaymentsForRepurchaseOfCommonStock', 'PaymentsForRepurchaseOfEquity'], labelIncludes: ['share repurchases', 'repurchase of common stock'] },
{ key: 'dividends_paid', label: 'Dividends Paid', category: 'financing', order: 70, unit: 'currency', localNames: ['PaymentsOfDividends', 'PaymentsOfDividendsCommonStock'], labelIncludes: ['dividends paid'] },
{ key: 'debt_issued', label: 'Debt Issued', category: 'financing', order: 80, unit: 'currency', localNames: ['ProceedsFromIssuanceOfLongTermDebt'], labelIncludes: ['debt issued'] },
{ key: 'debt_repaid', label: 'Debt Repaid', category: 'financing', order: 90, unit: 'currency', localNames: ['RepaymentsOfLongTermDebt', 'RepaymentsOfDebt'], labelIncludes: ['debt repaid', 'repayment of debt'] }
];
export const CANONICAL_ROW_DEFINITIONS: Record<Extract<FinancialStatementKind, 'income' | 'balance' | 'cash_flow'>, CanonicalRowDefinition[]> = {
income: INCOME_DEFINITIONS,
balance: BALANCE_DEFINITIONS,
cash_flow: CASH_FLOW_DEFINITIONS
income: INCOME_STATEMENT_METRIC_DEFINITIONS,
balance: BALANCE_SHEET_METRIC_DEFINITIONS,
cash_flow: CASH_FLOW_STATEMENT_METRIC_DEFINITIONS
};

View File

@@ -4,6 +4,10 @@ import type {
RatioRow,
StandardizedFinancialRow
} from '@/lib/types';
import {
RATIO_CATEGORY_ORDER,
RATIO_DEFINITIONS
} from '@/lib/financial-metrics';
type StatementRowMap = {
income: StandardizedFinancialRow[];
@@ -11,50 +15,6 @@ type StatementRowMap = {
cashFlow: StandardizedFinancialRow[];
};
type RatioDefinition = {
key: string;
label: string;
category: string;
order: number;
unit: RatioRow['unit'];
denominatorKey: string | null;
};
const RATIO_DEFINITIONS: RatioDefinition[] = [
{ key: 'gross_margin', label: 'Gross Margin', category: 'margins', order: 10, unit: 'percent', denominatorKey: 'revenue' },
{ key: 'operating_margin', label: 'Operating Margin', category: 'margins', order: 20, unit: 'percent', denominatorKey: 'revenue' },
{ key: 'ebitda_margin', label: 'EBITDA Margin', category: 'margins', order: 30, unit: 'percent', denominatorKey: 'revenue' },
{ key: 'net_margin', label: 'Net Margin', category: 'margins', order: 40, unit: 'percent', denominatorKey: 'revenue' },
{ key: 'fcf_margin', label: 'FCF Margin', category: 'margins', order: 50, unit: 'percent', denominatorKey: 'revenue' },
{ key: 'roa', label: 'ROA', category: 'returns', order: 60, unit: 'percent', denominatorKey: 'total_assets' },
{ key: 'roe', label: 'ROE', category: 'returns', order: 70, unit: 'percent', denominatorKey: 'total_equity' },
{ key: 'roic', label: 'ROIC', category: 'returns', order: 80, unit: 'percent', denominatorKey: 'average_invested_capital' },
{ key: 'roce', label: 'ROCE', category: 'returns', order: 90, unit: 'percent', denominatorKey: 'average_capital_employed' },
{ key: 'debt_to_equity', label: 'Debt to Equity', category: 'financial_health', order: 100, unit: 'ratio', denominatorKey: 'total_equity' },
{ key: 'net_debt_to_ebitda', label: 'Net Debt to EBITDA', category: 'financial_health', order: 110, unit: 'ratio', denominatorKey: 'ebitda' },
{ key: 'cash_to_debt', label: 'Cash to Debt', category: 'financial_health', order: 120, unit: 'ratio', denominatorKey: 'total_debt' },
{ key: 'current_ratio', label: 'Current Ratio', category: 'financial_health', order: 130, unit: 'ratio', denominatorKey: 'current_liabilities' },
{ key: 'revenue_per_share', label: 'Revenue per Share', category: 'per_share', order: 140, unit: 'currency', denominatorKey: 'diluted_shares' },
{ key: 'fcf_per_share', label: 'FCF per Share', category: 'per_share', order: 150, unit: 'currency', denominatorKey: 'diluted_shares' },
{ key: 'book_value_per_share', label: 'Book Value per Share', category: 'per_share', order: 160, unit: 'currency', denominatorKey: 'diluted_shares' },
{ key: 'revenue_yoy', label: 'Revenue YoY', category: 'growth', order: 170, unit: 'percent', denominatorKey: 'revenue' },
{ key: 'net_income_yoy', label: 'Net Income YoY', category: 'growth', order: 180, unit: 'percent', denominatorKey: 'net_income' },
{ key: 'eps_yoy', label: 'EPS YoY', category: 'growth', order: 190, unit: 'percent', denominatorKey: 'diluted_eps' },
{ key: 'fcf_yoy', label: 'FCF YoY', category: 'growth', order: 200, unit: 'percent', denominatorKey: 'free_cash_flow' },
{ key: '3y_revenue_cagr', label: '3Y Revenue CAGR', category: 'growth', order: 210, unit: 'percent', denominatorKey: 'revenue' },
{ key: '5y_revenue_cagr', label: '5Y Revenue CAGR', category: 'growth', order: 220, unit: 'percent', denominatorKey: 'revenue' },
{ key: '3y_eps_cagr', label: '3Y EPS CAGR', category: 'growth', order: 230, unit: 'percent', denominatorKey: 'diluted_eps' },
{ key: '5y_eps_cagr', label: '5Y EPS CAGR', category: 'growth', order: 240, unit: 'percent', denominatorKey: 'diluted_eps' },
{ key: 'market_cap', label: 'Market Cap', category: 'valuation', order: 250, unit: 'currency', denominatorKey: null },
{ key: 'enterprise_value', label: 'Enterprise Value', category: 'valuation', order: 260, unit: 'currency', denominatorKey: null },
{ key: 'price_to_earnings', label: 'Price to Earnings', category: 'valuation', order: 270, unit: 'ratio', denominatorKey: 'diluted_eps' },
{ key: 'price_to_fcf', label: 'Price to FCF', category: 'valuation', order: 280, unit: 'ratio', denominatorKey: 'free_cash_flow' },
{ key: 'price_to_book', label: 'Price to Book', category: 'valuation', order: 290, unit: 'ratio', denominatorKey: 'total_equity' },
{ key: 'ev_to_sales', label: 'EV to Sales', category: 'valuation', order: 300, unit: 'ratio', denominatorKey: 'revenue' },
{ key: 'ev_to_ebitda', label: 'EV to EBITDA', category: 'valuation', order: 310, unit: 'ratio', denominatorKey: 'ebitda' },
{ key: 'ev_to_fcf', label: 'EV to FCF', category: 'valuation', order: 320, unit: 'ratio', denominatorKey: 'free_cash_flow' }
];
function valueFor(row: StandardizedFinancialRow | undefined, periodId: string) {
return row?.values[periodId] ?? null;
}
@@ -358,12 +318,3 @@ export function buildRatioRows(input: {
return true;
});
}
export const RATIO_CATEGORY_ORDER = [
'margins',
'returns',
'financial_health',
'per_share',
'growth',
'valuation'
] as const;

View File

@@ -5,8 +5,8 @@ import type {
StructuredKpiRow,
TrendSeries
} from '@/lib/types';
import { RATIO_CATEGORY_ORDER } from '@/lib/financial-metrics';
import { KPI_CATEGORY_ORDER } from '@/lib/server/financials/kpi-registry';
import { RATIO_CATEGORY_ORDER } from '@/lib/server/financials/ratios';
function toTrendSeriesRow(row: {
key: string;

View File

@@ -1,148 +1,6 @@
import { and, desc, eq } from 'drizzle-orm';
import type {
ResearchJournalEntry,
ResearchJournalEntryType
} from '@/lib/types';
import { db } from '@/lib/server/db';
import { researchJournalEntry } from '@/lib/server/db/schema';
type ResearchJournalRow = typeof researchJournalEntry.$inferSelect;
function normalizeTicker(ticker: string) {
return ticker.trim().toUpperCase();
}
function normalizeTitle(title?: string | null) {
const normalized = title?.trim();
return normalized ? normalized : null;
}
function normalizeAccessionNumber(accessionNumber?: string | null) {
const normalized = accessionNumber?.trim();
return normalized ? normalized : null;
}
function normalizeMetadata(metadata?: Record<string, unknown> | null) {
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
return null;
}
return metadata;
}
function toResearchJournalEntry(row: ResearchJournalRow): ResearchJournalEntry {
return {
id: row.id,
user_id: row.user_id,
ticker: row.ticker,
accession_number: row.accession_number ?? null,
entry_type: row.entry_type,
title: row.title ?? null,
body_markdown: row.body_markdown,
metadata: row.metadata ?? null,
created_at: row.created_at,
updated_at: row.updated_at
};
}
export async function listResearchJournalEntries(userId: string, ticker: string, limit = 100) {
const normalizedTicker = normalizeTicker(ticker);
if (!normalizedTicker) {
return [];
}
const safeLimit = Math.min(Math.max(Math.trunc(limit), 1), 250);
const rows = await db
.select()
.from(researchJournalEntry)
.where(and(eq(researchJournalEntry.user_id, userId), eq(researchJournalEntry.ticker, normalizedTicker)))
.orderBy(desc(researchJournalEntry.created_at), desc(researchJournalEntry.id))
.limit(safeLimit);
return rows.map(toResearchJournalEntry);
}
export async function createResearchJournalEntryRecord(input: {
userId: string;
ticker: string;
accessionNumber?: string | null;
entryType: ResearchJournalEntryType;
title?: string | null;
bodyMarkdown: string;
metadata?: Record<string, unknown> | null;
}) {
const ticker = normalizeTicker(input.ticker);
const bodyMarkdown = input.bodyMarkdown.trim();
if (!ticker) {
throw new Error('ticker is required');
}
if (!bodyMarkdown) {
throw new Error('bodyMarkdown is required');
}
const now = new Date().toISOString();
const [created] = await db
.insert(researchJournalEntry)
.values({
user_id: input.userId,
ticker,
accession_number: normalizeAccessionNumber(input.accessionNumber),
entry_type: input.entryType,
title: normalizeTitle(input.title),
body_markdown: bodyMarkdown,
metadata: normalizeMetadata(input.metadata),
created_at: now,
updated_at: now
})
.returning();
return toResearchJournalEntry(created);
}
export async function updateResearchJournalEntryRecord(input: {
userId: string;
id: number;
title?: string | null;
bodyMarkdown?: string;
metadata?: Record<string, unknown> | null;
}) {
const [existing] = await db
.select()
.from(researchJournalEntry)
.where(and(eq(researchJournalEntry.user_id, input.userId), eq(researchJournalEntry.id, input.id)))
.limit(1);
if (!existing) {
return null;
}
const nextBodyMarkdown = input.bodyMarkdown === undefined
? existing.body_markdown
: input.bodyMarkdown.trim();
if (!nextBodyMarkdown) {
throw new Error('bodyMarkdown is required');
}
const [updated] = await db
.update(researchJournalEntry)
.set({
title: input.title === undefined ? existing.title : normalizeTitle(input.title),
body_markdown: nextBodyMarkdown,
metadata: input.metadata === undefined ? existing.metadata ?? null : normalizeMetadata(input.metadata),
updated_at: new Date().toISOString()
})
.where(and(eq(researchJournalEntry.user_id, input.userId), eq(researchJournalEntry.id, input.id)))
.returning();
return updated ? toResearchJournalEntry(updated) : null;
}
export async function deleteResearchJournalEntryRecord(userId: string, id: number) {
const rows = await db
.delete(researchJournalEntry)
.where(and(eq(researchJournalEntry.user_id, userId), eq(researchJournalEntry.id, id)))
.returning({ id: researchJournalEntry.id });
return rows.length > 0;
}
export {
createResearchJournalEntryCompat as createResearchJournalEntryRecord,
deleteResearchJournalEntryCompat as deleteResearchJournalEntryRecord,
listResearchJournalEntriesCompat as listResearchJournalEntries,
updateResearchJournalEntryCompat as updateResearchJournalEntryRecord
} from '@/lib/server/repos/research-library';

File diff suppressed because it is too large Load Diff