2 Commits

Author SHA1 Message Date
a2e8fbcf94 Fix search.ts to use normalizeTickerOrNull for optional ticker 2026-03-15 16:06:49 -04:00
5f0abbb007 Consolidate server utilities into shared module
- Add lib/server/utils/normalize.ts with normalizeTicker, normalizeTagsOrNull, nowIso, todayIso
- Add lib/server/utils/validation.ts with asRecord, asBoolean, asStringArray, asEnum
- Add lib/server/utils/index.ts re-exporting all utilities
- Remove duplicate lib/server/utils.ts (old file)
- Update all repos and files to use shared utilities
- Remove redundant ?? '' from normalizeTicker calls
- Update watchlist.ts to use normalizeTagsOrNull for null-return tags
2026-03-15 15:56:16 -04:00
14 changed files with 194 additions and 128 deletions

View File

@@ -1,3 +1,5 @@
import { normalizeTicker } from '@/lib/server/utils';
const YAHOO_BASE = 'https://query1.finance.yahoo.com/v8/finance/chart'; const YAHOO_BASE = 'https://query1.finance.yahoo.com/v8/finance/chart';
const QUOTE_CACHE_TTL_MS = 1000 * 60; const QUOTE_CACHE_TTL_MS = 1000 * 60;
const PRICE_HISTORY_CACHE_TTL_MS = 1000 * 60 * 15; const PRICE_HISTORY_CACHE_TTL_MS = 1000 * 60 * 15;
@@ -27,11 +29,11 @@ const quoteCache = new Map<string, QuoteCacheEntry>();
const priceHistoryCache = new Map<string, PriceHistoryCacheEntry>(); const priceHistoryCache = new Map<string, PriceHistoryCacheEntry>();
function buildYahooChartUrl(ticker: string, params: string) { function buildYahooChartUrl(ticker: string, params: string) {
return `${YAHOO_BASE}/${encodeURIComponent(ticker.trim().toUpperCase())}?${params}`; return `${YAHOO_BASE}/${encodeURIComponent(normalizeTicker(ticker))}?${params}`;
} }
export async function getQuote(ticker: string): Promise<QuoteResult> { export async function getQuote(ticker: string): Promise<QuoteResult> {
const normalizedTicker = ticker.trim().toUpperCase(); const normalizedTicker = normalizeTicker(ticker);
const cached = quoteCache.get(normalizedTicker); const cached = quoteCache.get(normalizedTicker);
if (cached && cached.expiresAt > Date.now()) { if (cached && cached.expiresAt > Date.now()) {
@@ -101,7 +103,7 @@ export async function getQuoteOrNull(ticker: string): Promise<number | null> {
} }
export async function getHistoricalClosingPrices(ticker: string, dates: string[]) { export async function getHistoricalClosingPrices(ticker: string, dates: string[]) {
const normalizedTicker = ticker.trim().toUpperCase(); const normalizedTicker = normalizeTicker(ticker);
const normalizedDates = dates const normalizedDates = dates
.map((value) => { .map((value) => {
const parsed = Date.parse(value); const parsed = Date.parse(value);
@@ -169,7 +171,7 @@ export async function getHistoricalClosingPrices(ticker: string, dates: string[]
} }
export async function getPriceHistory(ticker: string): Promise<PriceHistoryResult> { export async function getPriceHistory(ticker: string): Promise<PriceHistoryResult> {
const normalizedTicker = ticker.trim().toUpperCase(); const normalizedTicker = normalizeTicker(ticker);
const cached = priceHistoryCache.get(normalizedTicker); const cached = priceHistoryCache.get(normalizedTicker);
if (cached && cached.expiresAt > Date.now()) { if (cached && cached.expiresAt > Date.now()) {

View File

@@ -1,5 +1,6 @@
import { format } from 'date-fns'; import { format } from 'date-fns';
import type { Filing, RecentDevelopmentItem, RecentDevelopments } from '@/lib/types'; import type { Filing, RecentDevelopmentItem, RecentDevelopments } from '@/lib/types';
import { normalizeTicker } from '@/lib/server/utils';
export type RecentDevelopmentSourceContext = { export type RecentDevelopmentSourceContext = {
filings: Filing[]; filings: Filing[];
@@ -115,9 +116,9 @@ export async function getRecentDevelopments(
limit?: number; limit?: number;
} }
): Promise<RecentDevelopments> { ): Promise<RecentDevelopments> {
const normalizedTicker = ticker.trim().toUpperCase(); const normalizedTicker = normalizeTicker(ticker);
const limit = options?.limit ?? 6; const limit = options?.limit ?? 6;
const cacheKey = `${normalizedTicker}:${context.filings.map((filing) => filing.accession_number).join(',')}`; const cacheKey = `${normalizedTicker ?? ''}:${context.filings.map((filing) => filing.accession_number).join(',')}`;
const cached = recentDevelopmentsCache.get(cacheKey); const cached = recentDevelopmentsCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) { if (cached && cached.expiresAt > Date.now()) {
@@ -128,7 +129,7 @@ export async function getRecentDevelopments(
const itemCollections = await Promise.all( const itemCollections = await Promise.all(
sources.map(async (source) => { sources.map(async (source) => {
try { try {
return await source.fetch(normalizedTicker, context); return await source.fetch(normalizedTicker ?? '', context);
} catch { } catch {
return [] satisfies RecentDevelopmentItem[]; return [] satisfies RecentDevelopmentItem[];
} }

View File

@@ -2,6 +2,7 @@ import { desc, eq, inArray, max } from 'drizzle-orm';
import type { Filing } from '@/lib/types'; import type { Filing } from '@/lib/types';
import { db } from '@/lib/server/db'; import { db } from '@/lib/server/db';
import { filing, filingLink } from '@/lib/server/db/schema'; import { filing, filingLink } from '@/lib/server/db/schema';
import { normalizeTicker, nowIso } from '@/lib/server/utils';
type FilingRow = typeof filing.$inferSelect; type FilingRow = typeof filing.$inferSelect;
@@ -90,7 +91,7 @@ export async function getFilingByAccession(accessionNumber: string) {
export async function listLatestFilingDatesByTickers(tickers: string[]) { export async function listLatestFilingDatesByTickers(tickers: string[]) {
const normalizedTickers = [...new Set( const normalizedTickers = [...new Set(
tickers tickers
.map((ticker) => ticker.trim().toUpperCase()) .map((ticker) => normalizeTicker(ticker))
.filter((ticker) => ticker.length > 0) .filter((ticker) => ticker.length > 0)
)]; )];
@@ -121,7 +122,7 @@ export async function upsertFilingsRecords(items: UpsertFilingInput[]) {
let updated = 0; let updated = 0;
for (const item of items) { for (const item of items) {
const now = new Date().toISOString(); const now = nowIso();
const existing = await getFilingByAccession(item.accession_number); const existing = await getFilingByAccession(item.accession_number);
@@ -192,7 +193,7 @@ export async function saveFilingAnalysis(
.update(filing) .update(filing)
.set({ .set({
analysis, analysis,
updated_at: new Date().toISOString() updated_at: nowIso()
}) })
.where(eq(filing.accession_number, accessionNumber)) .where(eq(filing.accession_number, accessionNumber))
.returning(); .returning();
@@ -208,7 +209,7 @@ export async function updateFilingMetricsById(
.update(filing) .update(filing)
.set({ .set({
metrics, metrics,
updated_at: new Date().toISOString() updated_at: nowIso()
}) })
.where(eq(filing.id, filingId)) .where(eq(filing.id, filingId))
.returning(); .returning();

View File

@@ -3,6 +3,7 @@ import type { Holding } from '@/lib/types';
import { recalculateHolding } from '@/lib/server/portfolio'; import { recalculateHolding } from '@/lib/server/portfolio';
import { db } from '@/lib/server/db'; import { db } from '@/lib/server/db';
import { filing, holding, watchlistItem } from '@/lib/server/db/schema'; import { filing, holding, watchlistItem } from '@/lib/server/db/schema';
import { normalizeTicker, nowIso } from '@/lib/server/utils';
type HoldingRow = typeof holding.$inferSelect; type HoldingRow = typeof holding.$inferSelect;
@@ -30,7 +31,7 @@ function sortByMarketValueDesc(rows: Holding[]) {
function normalizeHoldingInput(input: { ticker: string; shares: number; avgCost: number; currentPrice: number }) { function normalizeHoldingInput(input: { ticker: string; shares: number; avgCost: number; currentPrice: number }) {
return { return {
ticker: input.ticker.trim().toUpperCase(), ticker: normalizeTicker(input.ticker),
shares: input.shares.toFixed(6), shares: input.shares.toFixed(6),
avg_cost: input.avgCost.toFixed(6), avg_cost: input.avgCost.toFixed(6),
current_price: input.currentPrice.toFixed(6) current_price: input.currentPrice.toFixed(6)
@@ -82,7 +83,7 @@ export async function listUserHoldings(userId: string) {
} }
export async function getHoldingByTicker(userId: string, ticker: string) { export async function getHoldingByTicker(userId: string, ticker: string) {
const normalizedTicker = ticker.trim().toUpperCase(); const normalizedTicker = normalizeTicker(ticker);
if (!normalizedTicker) { if (!normalizedTicker) {
return null; return null;
} }
@@ -104,8 +105,8 @@ export async function upsertHoldingRecord(input: {
currentPrice?: number; currentPrice?: number;
companyName?: string; companyName?: string;
}) { }) {
const ticker = input.ticker.trim().toUpperCase(); const ticker = normalizeTicker(input.ticker);
const now = new Date().toISOString(); const now = nowIso();
const [existing] = await db const [existing] = await db
.select() .select()
@@ -251,8 +252,8 @@ export async function updateHoldingByIdRecord(input: {
shares: shares.toFixed(6), shares: shares.toFixed(6),
avg_cost: avgCost.toFixed(6), avg_cost: avgCost.toFixed(6),
current_price: currentPrice.toFixed(6), current_price: currentPrice.toFixed(6),
updated_at: new Date().toISOString(), updated_at: nowIso(),
last_price_at: new Date().toISOString() last_price_at: nowIso()
}); });
const [updated] = await db const [updated] = await db

View File

@@ -27,6 +27,14 @@ import {
} from '@/lib/server/db/schema'; } from '@/lib/server/db/schema';
import { getFilingByAccession, listFilingsRecords } from '@/lib/server/repos/filings'; import { getFilingByAccession, listFilingsRecords } from '@/lib/server/repos/filings';
import { getWatchlistItemByTicker } from '@/lib/server/repos/watchlist'; import { getWatchlistItemByTicker } from '@/lib/server/repos/watchlist';
import {
normalizeTicker,
normalizeTags,
normalizeOptionalString,
normalizeRecord,
normalizePositiveInteger,
nowIso
} from '@/lib/server/utils';
type ResearchArtifactRow = typeof researchArtifact.$inferSelect; type ResearchArtifactRow = typeof researchArtifact.$inferSelect;
type ResearchMemoRow = typeof researchMemo.$inferSelect; type ResearchMemoRow = typeof researchMemo.$inferSelect;
@@ -51,55 +59,6 @@ const RESEARCH_PACKET_SECTION_TITLES: Record<ResearchMemoSection, string> = {
next_actions: 'Next Actions' next_actions: 'Next Actions'
}; };
function normalizeTicker(ticker: string) {
return ticker.trim().toUpperCase();
}
function normalizeOptionalString(value?: string | null) {
const normalized = value?.trim();
return normalized ? normalized : null;
}
function normalizePositiveInteger(value?: number | null) {
if (value === null || value === undefined || !Number.isFinite(value)) {
return null;
}
const normalized = Math.trunc(value);
return normalized > 0 ? normalized : null;
}
function normalizeRecord(value?: Record<string, unknown> | null) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value;
}
function normalizeTags(tags?: string[] | null) {
if (!Array.isArray(tags)) {
return [];
}
const unique = new Set<string>();
for (const entry of tags) {
if (typeof entry !== 'string') {
continue;
}
const normalized = entry.trim();
if (!normalized) {
continue;
}
unique.add(normalized);
}
return [...unique];
}
function buildArtifactSearchText(input: { function buildArtifactSearchText(input: {
title?: string | null; title?: string | null;
summary?: string | null; summary?: string | null;
@@ -409,7 +368,7 @@ export async function createResearchArtifactRecord(input: {
throw new Error('ticker is required'); throw new Error('ticker is required');
} }
const now = new Date().toISOString(); const now = nowIso();
const title = normalizeOptionalString(input.title); const title = normalizeOptionalString(input.title);
const summary = normalizeOptionalString(input.summary); const summary = normalizeOptionalString(input.summary);
const bodyMarkdown = normalizeOptionalString(input.bodyMarkdown); const bodyMarkdown = normalizeOptionalString(input.bodyMarkdown);
@@ -520,7 +479,7 @@ export async function upsertSystemResearchArtifact(input: {
search_text: searchText, search_text: searchText,
tags: normalizeTags(input.tags), tags: normalizeTags(input.tags),
metadata: normalizeRecord(input.metadata), metadata: normalizeRecord(input.metadata),
updated_at: new Date().toISOString() updated_at: nowIso()
}) })
.where(eq(researchArtifact.id, existing.id)) .where(eq(researchArtifact.id, existing.id))
.returning(); .returning();
@@ -566,7 +525,7 @@ export async function updateResearchArtifactRecord(input: {
metadata, metadata,
tags, tags,
search_text: searchText, search_text: searchText,
updated_at: new Date().toISOString() updated_at: nowIso()
}) })
.where(eq(researchArtifact.id, input.id)) .where(eq(researchArtifact.id, input.id))
.returning(); .returning();
@@ -702,7 +661,7 @@ export async function upsertResearchMemoRecord(input: {
throw new Error('ticker is required'); throw new Error('ticker is required');
} }
const now = new Date().toISOString(); const now = nowIso();
const existing = await getResearchMemoByTicker(input.userId, ticker); const existing = await getResearchMemoByTicker(input.userId, ticker);
if (!existing) { if (!existing) {
@@ -796,7 +755,7 @@ export async function addResearchMemoEvidenceLink(input: {
.then((rows) => (rows[0]?.maxOrder ?? 0) + 1) .then((rows) => (rows[0]?.maxOrder ?? 0) + 1)
: Math.max(0, Math.trunc(input.sortOrder)); : Math.max(0, Math.trunc(input.sortOrder));
const now = new Date().toISOString(); const now = nowIso();
if (existing.length > 0) { if (existing.length > 0) {
await db await db
.update(researchMemoEvidence) .update(researchMemoEvidence)
@@ -894,7 +853,7 @@ export async function getResearchPacket(userId: string, ticker: string): Promise
return { return {
ticker: normalizedTicker, ticker: normalizedTicker,
companyName: coverage?.company_name ?? latestFiling?.company_name ?? null, companyName: coverage?.company_name ?? latestFiling?.company_name ?? null,
generated_at: new Date().toISOString(), generated_at: nowIso(),
memo, memo,
sections: toPacketSections(memo, evidenceBySection) sections: toPacketSections(memo, evidenceBySection)
}; };

View File

@@ -3,6 +3,7 @@ import type { Task, TaskStage, TaskStageContext, TaskStageEvent, TaskStatus, Tas
import { db } from '@/lib/server/db'; import { db } from '@/lib/server/db';
import { taskRun, taskStageEvent } from '@/lib/server/db/schema'; import { taskRun, taskStageEvent } from '@/lib/server/db/schema';
import { buildTaskNotification } from '@/lib/server/task-notifications'; import { buildTaskNotification } from '@/lib/server/task-notifications';
import { nowIso } from '@/lib/server/utils';
type TaskRow = typeof taskRun.$inferSelect; type TaskRow = typeof taskRun.$inferSelect;
type TaskStageEventRow = typeof taskStageEvent.$inferSelect; type TaskStageEventRow = typeof taskStageEvent.$inferSelect;
@@ -110,7 +111,7 @@ async function insertTaskStageEvent(executor: InsertExecutor, input: EventInsert
} }
export async function createTaskRunRecord(input: CreateTaskInput) { export async function createTaskRunRecord(input: CreateTaskInput) {
const now = new Date().toISOString(); const now = nowIso();
return await db.transaction(async (tx) => { return await db.transaction(async (tx) => {
const [row] = await tx const [row] = await tx
@@ -219,7 +220,7 @@ async function attemptAtomicInsert(
} }
export async function createTaskRunRecordAtomic(input: CreateTaskInput): Promise<AtomicCreateResult> { export async function createTaskRunRecordAtomic(input: CreateTaskInput): Promise<AtomicCreateResult> {
const now = new Date().toISOString(); const now = nowIso();
return await db.transaction(async (tx) => { return await db.transaction(async (tx) => {
const result = await attemptAtomicInsert(tx, input, now); const result = await attemptAtomicInsert(tx, input, now);
@@ -235,7 +236,7 @@ export async function setTaskWorkflowRunId(taskId: string, workflowRunId: string
.update(taskRun) .update(taskRun)
.set({ .set({
workflow_run_id: workflowRunId, workflow_run_id: workflowRunId,
updated_at: new Date().toISOString() updated_at: nowIso()
}) })
.where(eq(taskRun.id, taskId)); .where(eq(taskRun.id, taskId));
} }
@@ -313,7 +314,7 @@ export async function findInFlightTaskByResourceKey(
} }
export async function markTaskRunning(taskId: string) { export async function markTaskRunning(taskId: string) {
const now = new Date().toISOString(); const now = nowIso();
return await db.transaction(async (tx) => { return await db.transaction(async (tx) => {
const [row] = await tx const [row] = await tx
@@ -354,7 +355,7 @@ export async function updateTaskStage(
detail: string | null = null, detail: string | null = null,
context: TaskStageContext | null = null context: TaskStageContext | null = null
) { ) {
const now = new Date().toISOString(); const now = nowIso();
return await db.transaction(async (tx) => { return await db.transaction(async (tx) => {
const [current] = await tx const [current] = await tx
@@ -403,7 +404,7 @@ export async function completeTask(
result: Record<string, unknown>, result: Record<string, unknown>,
completion: TaskCompletionState = {} completion: TaskCompletionState = {}
) { ) {
const now = new Date().toISOString(); const now = nowIso();
return await db.transaction(async (tx) => { return await db.transaction(async (tx) => {
const [row] = await tx const [row] = await tx
@@ -445,7 +446,7 @@ export async function markTaskFailure(
stage: TaskStage = 'failed', stage: TaskStage = 'failed',
failure: TaskCompletionState = {} failure: TaskCompletionState = {}
) { ) {
const now = new Date().toISOString(); const now = nowIso();
return await db.transaction(async (tx) => { return await db.transaction(async (tx) => {
const [row] = await tx const [row] = await tx
@@ -513,7 +514,7 @@ export async function setTaskStatusFromWorkflow(
return toTask(current); return toTask(current);
} }
const now = new Date().toISOString(); const now = nowIso();
const [row] = await tx const [row] = await tx
.update(taskRun) .update(taskRun)
.set({ .set({
@@ -551,7 +552,7 @@ export async function updateTaskNotificationState(
userId: string, userId: string,
input: UpdateTaskNotificationStateInput input: UpdateTaskNotificationStateInput
) { ) {
const now = new Date().toISOString(); const now = nowIso();
const patch: Partial<typeof taskRun.$inferInsert> = { const patch: Partial<typeof taskRun.$inferInsert> = {
updated_at: now updated_at: now
}; };

View File

@@ -6,38 +6,12 @@ import type {
} from '@/lib/types'; } from '@/lib/types';
import { db } from '@/lib/server/db'; import { db } from '@/lib/server/db';
import { watchlistItem } from '@/lib/server/db/schema'; import { watchlistItem } from '@/lib/server/db/schema';
import { normalizeTicker, normalizeTagsOrNull, nowIso } from '@/lib/server/utils';
type WatchlistRow = typeof watchlistItem.$inferSelect; type WatchlistRow = typeof watchlistItem.$inferSelect;
const DEFAULT_STATUS: CoverageStatus = 'backlog'; const DEFAULT_STATUS: CoverageStatus = 'backlog';
const DEFAULT_PRIORITY: CoveragePriority = 'medium'; const DEFAULT_PRIORITY: CoveragePriority = 'medium';
function normalizeTags(tags?: string[]) {
if (!Array.isArray(tags)) {
return null;
}
const unique = new Set<string>();
for (const entry of tags) {
if (typeof entry !== 'string') {
continue;
}
const tag = entry.trim();
if (!tag) {
continue;
}
unique.add(tag);
}
if (unique.size === 0) {
return null;
}
return [...unique];
}
function toWatchlistItem(row: WatchlistRow, latestFilingDate: string | null = null): WatchlistItem { function toWatchlistItem(row: WatchlistRow, latestFilingDate: string | null = null): WatchlistItem {
return { return {
id: row.id, id: row.id,
@@ -79,7 +53,7 @@ export async function getWatchlistItemById(userId: string, id: number) {
} }
export async function getWatchlistItemByTicker(userId: string, ticker: string) { export async function getWatchlistItemByTicker(userId: string, ticker: string) {
const normalizedTicker = ticker.trim().toUpperCase(); const normalizedTicker = normalizeTicker(ticker);
if (!normalizedTicker) { if (!normalizedTicker) {
return null; return null;
} }
@@ -104,15 +78,15 @@ export async function upsertWatchlistItemRecord(input: {
priority?: CoveragePriority; priority?: CoveragePriority;
lastReviewedAt?: string | null; lastReviewedAt?: string | null;
}) { }) {
const normalizedTicker = input.ticker.trim().toUpperCase(); const normalizedTicker = normalizeTicker(input.ticker);
const normalizedSector = input.sector?.trim() ? input.sector.trim() : null; const normalizedSector = input.sector?.trim() ? input.sector.trim() : null;
const normalizedCategory = input.category?.trim() ? input.category.trim() : null; const normalizedCategory = input.category?.trim() ? input.category.trim() : null;
const normalizedTags = normalizeTags(input.tags); const normalizedTags = normalizeTagsOrNull(input.tags);
const normalizedCompanyName = input.companyName.trim(); const normalizedCompanyName = input.companyName.trim();
const normalizedLastReviewedAt = input.lastReviewedAt?.trim() const normalizedLastReviewedAt = input.lastReviewedAt?.trim()
? input.lastReviewedAt.trim() ? input.lastReviewedAt.trim()
: null; : null;
const now = new Date().toISOString(); const timestamp = nowIso();
const [inserted] = await db const [inserted] = await db
.insert(watchlistItem) .insert(watchlistItem)
@@ -125,8 +99,8 @@ export async function upsertWatchlistItemRecord(input: {
tags: normalizedTags, tags: normalizedTags,
status: input.status ?? DEFAULT_STATUS, status: input.status ?? DEFAULT_STATUS,
priority: input.priority ?? DEFAULT_PRIORITY, priority: input.priority ?? DEFAULT_PRIORITY,
created_at: now, created_at: timestamp,
updated_at: now, updated_at: timestamp,
last_reviewed_at: normalizedLastReviewedAt last_reviewed_at: normalizedLastReviewedAt
}) })
.onConflictDoNothing({ .onConflictDoNothing({
@@ -156,7 +130,7 @@ export async function upsertWatchlistItemRecord(input: {
tags: normalizedTags, tags: normalizedTags,
status: input.status ?? existing?.status ?? DEFAULT_STATUS, status: input.status ?? existing?.status ?? DEFAULT_STATUS,
priority: input.priority ?? existing?.priority ?? DEFAULT_PRIORITY, priority: input.priority ?? existing?.priority ?? DEFAULT_PRIORITY,
updated_at: now, updated_at: timestamp,
last_reviewed_at: normalizedLastReviewedAt ?? existing?.last_reviewed_at ?? null last_reviewed_at: normalizedLastReviewedAt ?? existing?.last_reviewed_at ?? null
}) })
.where(and(eq(watchlistItem.user_id, input.userId), eq(watchlistItem.ticker, normalizedTicker))) .where(and(eq(watchlistItem.user_id, input.userId), eq(watchlistItem.ticker, normalizedTicker)))
@@ -212,7 +186,7 @@ export async function updateWatchlistItemRecord(input: {
: null; : null;
const nextTags = input.tags === undefined const nextTags = input.tags === undefined
? existing.tags ?? null ? existing.tags ?? null
: normalizeTags(input.tags); : normalizeTagsOrNull(input.tags);
const nextLastReviewedAt = input.lastReviewedAt === undefined const nextLastReviewedAt = input.lastReviewedAt === undefined
? existing.last_reviewed_at ? existing.last_reviewed_at
: input.lastReviewedAt?.trim() : input.lastReviewedAt?.trim()
@@ -228,7 +202,7 @@ export async function updateWatchlistItemRecord(input: {
tags: nextTags, tags: nextTags,
status: input.status ?? existing.status ?? DEFAULT_STATUS, status: input.status ?? existing.status ?? DEFAULT_STATUS,
priority: input.priority ?? existing.priority ?? DEFAULT_PRIORITY, priority: input.priority ?? existing.priority ?? DEFAULT_PRIORITY,
updated_at: new Date().toISOString(), updated_at: nowIso(),
last_reviewed_at: nextLastReviewedAt last_reviewed_at: nextLastReviewedAt
}) })
.where(and(eq(watchlistItem.user_id, input.userId), eq(watchlistItem.id, input.id))) .where(and(eq(watchlistItem.user_id, input.userId), eq(watchlistItem.id, input.id)))
@@ -242,7 +216,7 @@ export async function updateWatchlistReviewByTicker(
ticker: string, ticker: string,
reviewedAt: string reviewedAt: string
) { ) {
const normalizedTicker = ticker.trim().toUpperCase(); const normalizedTicker = normalizeTicker(ticker);
if (!normalizedTicker) { if (!normalizedTicker) {
return null; return null;
} }

View File

@@ -17,6 +17,7 @@ import {
listResearchJournalEntries, listResearchJournalEntries,
listResearchJournalEntriesForUser listResearchJournalEntriesForUser
} from '@/lib/server/repos/research-journal'; } from '@/lib/server/repos/research-journal';
import { normalizeTickerOrNull } from '@/lib/server/utils';
type SearchDocumentScope = 'global' | 'user'; type SearchDocumentScope = 'global' | 'user';
type SearchDocumentSourceKind = 'filing_document' | 'filing_brief' | 'research_note'; type SearchDocumentSourceKind = 'filing_document' | 'filing_brief' | 'research_note';
@@ -131,11 +132,6 @@ function escapeLike(value: string) {
return value.replace(/[%_]/g, (match) => `\\${match}`); return value.replace(/[%_]/g, (match) => `\\${match}`);
} }
function normalizeTicker(value: string | null | undefined) {
const normalized = value?.trim().toUpperCase() ?? '';
return normalized.length > 0 ? normalized : null;
}
function normalizeSearchSources(sources?: SearchSource[]) { function normalizeSearchSources(sources?: SearchSource[]) {
const normalized = new Set<SearchSource>(); const normalized = new Set<SearchSource>();
@@ -1205,7 +1201,7 @@ export async function searchKnowledgeBase(input: SearchInput) {
} }
const limit = clampLimit(input.limit); const limit = clampLimit(input.limit);
const normalizedTicker = normalizeTicker(input.ticker); const normalizedTicker = normalizeTickerOrNull(input.ticker);
const includedSources = normalizeSearchSources(input.sources); const includedSources = normalizeSearchSources(input.sources);
const client = getSqliteClient(); const client = getSqliteClient();
const [queryEmbedding] = await runAiEmbeddings([normalizedQuery]); const [queryEmbedding] = await runAiEmbeddings([normalizedQuery]);

View File

@@ -1,4 +1,5 @@
import type { CompanyProfile, CompanyValuationSnapshot } from '@/lib/types'; import type { CompanyProfile, CompanyValuationSnapshot } from '@/lib/types';
import { normalizeTicker } from '@/lib/server/utils';
type FetchImpl = typeof fetch; type FetchImpl = typeof fetch;
@@ -261,7 +262,7 @@ export async function getSecCompanyProfile(
ticker: string, ticker: string,
options?: { fetchImpl?: FetchImpl } options?: { fetchImpl?: FetchImpl }
): Promise<SecCompanyProfileResult | null> { ): Promise<SecCompanyProfileResult | null> {
const normalizedTicker = ticker.trim().toUpperCase(); const normalizedTicker = normalizeTicker(ticker);
if (!normalizedTicker) { if (!normalizedTicker) {
return null; return null;
} }

View File

@@ -39,6 +39,7 @@ import {
} from '@/lib/server/sec'; } from '@/lib/server/sec';
import { enqueueTask } from '@/lib/server/tasks'; import { enqueueTask } from '@/lib/server/tasks';
import { hydrateFilingTaxonomySnapshot } from '@/lib/server/taxonomy/engine'; import { hydrateFilingTaxonomySnapshot } from '@/lib/server/taxonomy/engine';
import { nowIso } from '@/lib/server/utils';
const EXTRACTION_REQUIRED_KEYS = [ const EXTRACTION_REQUIRED_KEYS = [
'summary', 'summary',
@@ -762,7 +763,7 @@ async function processSyncFilings(task: Task) {
await deleteCompanyFinancialBundlesForTicker(filing.ticker); await deleteCompanyFinancialBundlesForTicker(filing.ticker);
taxonomySnapshotsHydrated += 1; taxonomySnapshotsHydrated += 1;
} catch (error) { } catch (error) {
const now = new Date().toISOString(); const now = nowIso();
await upsertFilingTaxonomySnapshot({ await upsertFilingTaxonomySnapshot({
filing_id: filing.id, filing_id: filing.id,
ticker: filing.ticker, ticker: filing.ticker,
@@ -961,7 +962,7 @@ async function processRefreshPrices(task: Task) {
} }
} }
); );
const updatedCount = await applyRefreshedPrices(userId, quotes, new Date().toISOString()); const updatedCount = await applyRefreshedPrices(userId, quotes, nowIso());
const result = { const result = {
updatedCount, updatedCount,

20
lib/server/utils/index.ts Normal file
View File

@@ -0,0 +1,20 @@
export {
normalizeTicker,
normalizeTickerOrNull,
normalizeTags,
normalizeTagsOrNull,
normalizeOptionalString,
normalizeRecord,
normalizePositiveInteger,
nowIso,
todayIso
} from './normalize';
export {
asRecord,
asOptionalRecord,
asPositiveNumber,
asBoolean,
asStringArray,
asEnum
} from './validation';

View File

@@ -0,0 +1,51 @@
export function normalizeTicker(ticker: string): string {
return ticker.trim().toUpperCase();
}
export function normalizeTickerOrNull(ticker: unknown): string | null {
if (typeof ticker !== 'string') return null;
const normalized = ticker.trim().toUpperCase();
return normalized || null;
}
export function normalizeTags(tags?: unknown): string[] {
if (!Array.isArray(tags)) return [];
const unique = new Set<string>();
for (const entry of tags) {
if (typeof entry !== 'string') continue;
const tag = entry.trim();
if (tag) unique.add(tag);
}
return [...unique];
}
export function normalizeTagsOrNull(tags?: unknown): string[] | null {
const result = normalizeTags(tags);
return result.length > 0 ? result : null;
}
export function normalizeOptionalString(value?: unknown): string | null {
if (typeof value !== 'string') return null;
const normalized = value.trim();
return normalized || null;
}
export function normalizeRecord(value?: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
export function normalizePositiveInteger(value?: unknown): number | null {
if (value === null || value === undefined || !Number.isFinite(value as number)) return null;
const normalized = Math.trunc(value as number);
return normalized > 0 ? normalized : null;
}
export function nowIso(): string {
return new Date().toISOString();
}
export function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}

View File

@@ -0,0 +1,56 @@
export function asRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return value as Record<string, unknown>;
}
export function asOptionalRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
export function asPositiveNumber(value: unknown): number | null {
const parsed = typeof value === 'number' ? value : Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
export function asBoolean(value: unknown, fallback = false): boolean {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (normalized === 'true' || normalized === '1' || normalized === 'yes') {
return true;
}
if (normalized === 'false' || normalized === '0' || normalized === 'no') {
return false;
}
}
return fallback;
}
export function asStringArray(value: unknown): string[] {
const source = Array.isArray(value)
? value
: typeof value === 'string'
? value.split(',')
: [];
const unique = new Set<string>();
for (const entry of source) {
if (typeof entry !== 'string') continue;
const tag = entry.trim();
if (tag) unique.add(tag);
}
return [...unique];
}
export function asEnum<T extends string>(value: unknown, allowed: readonly T[]): T | undefined {
return allowed.includes(value as T) ? (value as T) : undefined;
}

View File

@@ -1,3 +1,5 @@
import { normalizeTicker } from '@/lib/server/utils';
type FetchImpl = typeof fetch; type FetchImpl = typeof fetch;
type CacheEntry<T> = { type CacheEntry<T> = {
@@ -142,7 +144,7 @@ export async function getYahooCompanyDescription(
ticker: string, ticker: string,
options?: { fetchImpl?: FetchImpl } options?: { fetchImpl?: FetchImpl }
) { ) {
const normalizedTicker = ticker.trim().toUpperCase(); const normalizedTicker = normalizeTicker(ticker);
if (!normalizedTicker) { if (!normalizedTicker) {
return null; return null;
} }