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,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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
148
lib/server/repos/research-journal.ts
Normal file
148
lib/server/repos/research-journal.ts
Normal 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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user