Implement fiscal-style research MVP flows
Some checks failed
PR Checks / typecheck-and-build (push) Has been cancelled

This commit is contained in:
2026-03-07 09:51:18 -05:00
parent f69e5b671b
commit 52136271d3
26 changed files with 2719 additions and 243 deletions

View File

@@ -1,4 +1,4 @@
import { desc, eq } from 'drizzle-orm';
import { desc, eq, inArray, max } from 'drizzle-orm';
import type { Filing } from '@/lib/types';
import { db } from '@/lib/server/db';
import { filing, filingLink } from '@/lib/server/db/schema';
@@ -87,6 +87,35 @@ export async function getFilingByAccession(accessionNumber: string) {
return row ? toFiling(row) : null;
}
export async function listLatestFilingDatesByTickers(tickers: string[]) {
const normalizedTickers = [...new Set(
tickers
.map((ticker) => ticker.trim().toUpperCase())
.filter((ticker) => ticker.length > 0)
)];
if (normalizedTickers.length === 0) {
return new Map<string, string>();
}
const rows = await db
.select({
ticker: filing.ticker,
latest_filing_date: max(filing.filing_date)
})
.from(filing)
.where(inArray(filing.ticker, normalizedTickers))
.groupBy(filing.ticker);
return new Map(
rows
.filter((row): row is { ticker: string; latest_filing_date: string } => {
return typeof row.ticker === 'string' && typeof row.latest_filing_date === 'string';
})
.map((row) => [row.ticker, row.latest_filing_date])
);
}
export async function upsertFilingsRecords(items: UpsertFilingInput[]) {
let inserted = 0;
let updated = 0;

View File

@@ -1,8 +1,8 @@
import { and, eq } from 'drizzle-orm';
import { and, desc, eq } from 'drizzle-orm';
import type { Holding } from '@/lib/types';
import { recalculateHolding } from '@/lib/server/portfolio';
import { db } from '@/lib/server/db';
import { holding } from '@/lib/server/db/schema';
import { filing, holding, watchlistItem } from '@/lib/server/db/schema';
type HoldingRow = typeof holding.$inferSelect;
@@ -11,6 +11,7 @@ function toHolding(row: HoldingRow): Holding {
id: row.id,
user_id: row.user_id,
ticker: row.ticker,
company_name: row.company_name ?? null,
shares: row.shares,
avg_cost: row.avg_cost,
current_price: row.current_price,
@@ -36,6 +37,41 @@ function normalizeHoldingInput(input: { ticker: string; shares: number; avgCost:
};
}
async function resolveHoldingCompanyName(input: {
userId: string;
ticker: string;
companyName?: string;
existingCompanyName?: string | null;
}) {
const explicitCompanyName = input.companyName?.trim();
if (explicitCompanyName) {
return explicitCompanyName;
}
if (input.existingCompanyName?.trim()) {
return input.existingCompanyName.trim();
}
const [watchlistMatch] = await db
.select({ company_name: watchlistItem.company_name })
.from(watchlistItem)
.where(and(eq(watchlistItem.user_id, input.userId), eq(watchlistItem.ticker, input.ticker)))
.limit(1);
if (watchlistMatch?.company_name?.trim()) {
return watchlistMatch.company_name.trim();
}
const [filingMatch] = await db
.select({ company_name: filing.company_name })
.from(filing)
.where(eq(filing.ticker, input.ticker))
.orderBy(desc(filing.filing_date), desc(filing.updated_at))
.limit(1);
return filingMatch?.company_name?.trim() ? filingMatch.company_name.trim() : null;
}
export async function listUserHoldings(userId: string) {
const rows = await db
.select()
@@ -45,12 +81,28 @@ export async function listUserHoldings(userId: string) {
return sortByMarketValueDesc(rows.map(toHolding));
}
export async function getHoldingByTicker(userId: string, ticker: string) {
const normalizedTicker = ticker.trim().toUpperCase();
if (!normalizedTicker) {
return null;
}
const [row] = await db
.select()
.from(holding)
.where(and(eq(holding.user_id, userId), eq(holding.ticker, normalizedTicker)))
.limit(1);
return row ? toHolding(row) : null;
}
export async function upsertHoldingRecord(input: {
userId: string;
ticker: string;
shares: number;
avgCost: number;
currentPrice?: number;
companyName?: string;
}) {
const ticker = input.ticker.trim().toUpperCase();
const now = new Date().toISOString();
@@ -64,6 +116,12 @@ export async function upsertHoldingRecord(input: {
const currentPrice = Number.isFinite(input.currentPrice)
? Number(input.currentPrice)
: input.avgCost;
const companyName = await resolveHoldingCompanyName({
userId: input.userId,
ticker,
companyName: input.companyName,
existingCompanyName: existing?.company_name ?? null
});
if (existing) {
const normalized = normalizeHoldingInput({
@@ -84,6 +142,7 @@ export async function upsertHoldingRecord(input: {
.update(holding)
.set({
ticker: next.ticker,
company_name: companyName,
shares: next.shares,
avg_cost: next.avg_cost,
current_price: next.current_price,
@@ -113,6 +172,7 @@ export async function upsertHoldingRecord(input: {
id: 0,
user_id: input.userId,
ticker: normalized.ticker,
company_name: companyName,
shares: normalized.shares,
avg_cost: normalized.avg_cost,
current_price: normalized.current_price,
@@ -131,6 +191,7 @@ export async function upsertHoldingRecord(input: {
.values({
user_id: created.user_id,
ticker: created.ticker,
company_name: created.company_name,
shares: created.shares,
avg_cost: created.avg_cost,
current_price: created.current_price,
@@ -155,6 +216,7 @@ export async function updateHoldingByIdRecord(input: {
shares?: number;
avgCost?: number;
currentPrice?: number;
companyName?: string;
}) {
const [existing] = await db
.select()
@@ -176,9 +238,16 @@ export async function updateHoldingByIdRecord(input: {
const currentPrice = Number.isFinite(input.currentPrice)
? Number(input.currentPrice)
: Number(current.current_price ?? current.avg_cost);
const companyName = await resolveHoldingCompanyName({
userId: input.userId,
ticker: current.ticker,
companyName: input.companyName,
existingCompanyName: current.company_name
});
const next = recalculateHolding({
...current,
company_name: companyName,
shares: shares.toFixed(6),
avg_cost: avgCost.toFixed(6),
current_price: currentPrice.toFixed(6),
@@ -189,6 +258,7 @@ export async function updateHoldingByIdRecord(input: {
const [updated] = await db
.update(holding)
.set({
company_name: companyName,
shares: next.shares,
avg_cost: next.avg_cost,
current_price: next.current_price,

View File

@@ -0,0 +1,148 @@
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;
}

View File

@@ -1,9 +1,15 @@
import { and, desc, eq } from 'drizzle-orm';
import type { WatchlistItem } from '@/lib/types';
import type {
CoveragePriority,
CoverageStatus,
WatchlistItem
} from '@/lib/types';
import { db } from '@/lib/server/db';
import { watchlistItem } from '@/lib/server/db/schema';
type WatchlistRow = typeof watchlistItem.$inferSelect;
const DEFAULT_STATUS: CoverageStatus = 'backlog';
const DEFAULT_PRIORITY: CoveragePriority = 'medium';
function normalizeTags(tags?: string[]) {
if (!Array.isArray(tags)) {
@@ -32,7 +38,7 @@ function normalizeTags(tags?: string[]) {
return [...unique];
}
function toWatchlistItem(row: WatchlistRow): WatchlistItem {
function toWatchlistItem(row: WatchlistRow, latestFilingDate: string | null = null): WatchlistItem {
return {
id: row.id,
user_id: row.user_id,
@@ -43,7 +49,12 @@ function toWatchlistItem(row: WatchlistRow): WatchlistItem {
tags: Array.isArray(row.tags)
? row.tags.filter((entry): entry is string => typeof entry === 'string')
: [],
created_at: row.created_at
created_at: row.created_at,
status: row.status ?? DEFAULT_STATUS,
priority: row.priority ?? DEFAULT_PRIORITY,
updated_at: row.updated_at || row.created_at,
last_reviewed_at: row.last_reviewed_at ?? null,
latest_filing_date: latestFilingDate
};
}
@@ -52,9 +63,19 @@ export async function listWatchlistItems(userId: string) {
.select()
.from(watchlistItem)
.where(eq(watchlistItem.user_id, userId))
.orderBy(desc(watchlistItem.created_at));
.orderBy(desc(watchlistItem.updated_at), desc(watchlistItem.created_at));
return rows.map(toWatchlistItem);
return rows.map((row) => toWatchlistItem(row));
}
export async function getWatchlistItemById(userId: string, id: number) {
const [row] = await db
.select()
.from(watchlistItem)
.where(and(eq(watchlistItem.user_id, userId), eq(watchlistItem.id, id)))
.limit(1);
return row ? toWatchlistItem(row) : null;
}
export async function getWatchlistItemByTicker(userId: string, ticker: string) {
@@ -79,11 +100,18 @@ export async function upsertWatchlistItemRecord(input: {
sector?: string;
category?: string;
tags?: string[];
status?: CoverageStatus;
priority?: CoveragePriority;
lastReviewedAt?: string | null;
}) {
const normalizedTicker = input.ticker.trim().toUpperCase();
const normalizedSector = input.sector?.trim() ? input.sector.trim() : null;
const normalizedCategory = input.category?.trim() ? input.category.trim() : null;
const normalizedTags = normalizeTags(input.tags);
const normalizedCompanyName = input.companyName.trim();
const normalizedLastReviewedAt = input.lastReviewedAt?.trim()
? input.lastReviewedAt.trim()
: null;
const now = new Date().toISOString();
const [inserted] = await db
@@ -91,11 +119,15 @@ export async function upsertWatchlistItemRecord(input: {
.values({
user_id: input.userId,
ticker: normalizedTicker,
company_name: input.companyName,
company_name: normalizedCompanyName,
sector: normalizedSector,
category: normalizedCategory,
tags: normalizedTags,
created_at: now
status: input.status ?? DEFAULT_STATUS,
priority: input.priority ?? DEFAULT_PRIORITY,
created_at: now,
updated_at: now,
last_reviewed_at: normalizedLastReviewedAt
})
.onConflictDoNothing({
target: [watchlistItem.user_id, watchlistItem.ticker],
@@ -109,13 +141,23 @@ export async function upsertWatchlistItemRecord(input: {
};
}
const [existing] = await db
.select()
.from(watchlistItem)
.where(and(eq(watchlistItem.user_id, input.userId), eq(watchlistItem.ticker, normalizedTicker)))
.limit(1);
const [updated] = await db
.update(watchlistItem)
.set({
company_name: input.companyName,
company_name: normalizedCompanyName,
sector: normalizedSector,
category: normalizedCategory,
tags: normalizedTags
tags: normalizedTags,
status: input.status ?? existing?.status ?? DEFAULT_STATUS,
priority: input.priority ?? existing?.priority ?? DEFAULT_PRIORITY,
updated_at: now,
last_reviewed_at: normalizedLastReviewedAt ?? existing?.last_reviewed_at ?? null
})
.where(and(eq(watchlistItem.user_id, input.userId), eq(watchlistItem.ticker, normalizedTicker)))
.returning();
@@ -130,6 +172,93 @@ export async function upsertWatchlistItemRecord(input: {
};
}
export async function updateWatchlistItemRecord(input: {
userId: string;
id: number;
companyName?: string;
sector?: string;
category?: string;
tags?: string[];
status?: CoverageStatus;
priority?: CoveragePriority;
lastReviewedAt?: string | null;
}) {
const [existing] = await db
.select()
.from(watchlistItem)
.where(and(eq(watchlistItem.user_id, input.userId), eq(watchlistItem.id, input.id)))
.limit(1);
if (!existing) {
return null;
}
const nextCompanyName = input.companyName === undefined
? existing.company_name
: input.companyName.trim();
if (!nextCompanyName) {
throw new Error('companyName is required');
}
const nextSector = input.sector === undefined
? existing.sector
: input.sector.trim()
? input.sector.trim()
: null;
const nextCategory = input.category === undefined
? existing.category
: input.category.trim()
? input.category.trim()
: null;
const nextTags = input.tags === undefined
? existing.tags ?? null
: normalizeTags(input.tags);
const nextLastReviewedAt = input.lastReviewedAt === undefined
? existing.last_reviewed_at
: input.lastReviewedAt?.trim()
? input.lastReviewedAt.trim()
: null;
const [updated] = await db
.update(watchlistItem)
.set({
company_name: nextCompanyName,
sector: nextSector,
category: nextCategory,
tags: nextTags,
status: input.status ?? existing.status ?? DEFAULT_STATUS,
priority: input.priority ?? existing.priority ?? DEFAULT_PRIORITY,
updated_at: new Date().toISOString(),
last_reviewed_at: nextLastReviewedAt
})
.where(and(eq(watchlistItem.user_id, input.userId), eq(watchlistItem.id, input.id)))
.returning();
return updated ? toWatchlistItem(updated) : null;
}
export async function updateWatchlistReviewByTicker(
userId: string,
ticker: string,
reviewedAt: string
) {
const normalizedTicker = ticker.trim().toUpperCase();
if (!normalizedTicker) {
return null;
}
const [updated] = await db
.update(watchlistItem)
.set({
last_reviewed_at: reviewedAt,
updated_at: reviewedAt
})
.where(and(eq(watchlistItem.user_id, userId), eq(watchlistItem.ticker, normalizedTicker)))
.returning();
return updated ? toWatchlistItem(updated) : null;
}
export async function deleteWatchlistItemRecord(userId: string, id: number) {
const removed = await db
.delete(watchlistItem)