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
This commit is contained in:
@@ -2,6 +2,7 @@ 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';
|
||||
import { normalizeTicker, nowIso } from '@/lib/server/utils';
|
||||
|
||||
type FilingRow = typeof filing.$inferSelect;
|
||||
|
||||
@@ -90,7 +91,7 @@ export async function getFilingByAccession(accessionNumber: string) {
|
||||
export async function listLatestFilingDatesByTickers(tickers: string[]) {
|
||||
const normalizedTickers = [...new Set(
|
||||
tickers
|
||||
.map((ticker) => ticker.trim().toUpperCase())
|
||||
.map((ticker) => normalizeTicker(ticker))
|
||||
.filter((ticker) => ticker.length > 0)
|
||||
)];
|
||||
|
||||
@@ -121,7 +122,7 @@ export async function upsertFilingsRecords(items: UpsertFilingInput[]) {
|
||||
let updated = 0;
|
||||
|
||||
for (const item of items) {
|
||||
const now = new Date().toISOString();
|
||||
const now = nowIso();
|
||||
|
||||
const existing = await getFilingByAccession(item.accession_number);
|
||||
|
||||
@@ -192,7 +193,7 @@ export async function saveFilingAnalysis(
|
||||
.update(filing)
|
||||
.set({
|
||||
analysis,
|
||||
updated_at: new Date().toISOString()
|
||||
updated_at: nowIso()
|
||||
})
|
||||
.where(eq(filing.accession_number, accessionNumber))
|
||||
.returning();
|
||||
@@ -208,7 +209,7 @@ export async function updateFilingMetricsById(
|
||||
.update(filing)
|
||||
.set({
|
||||
metrics,
|
||||
updated_at: new Date().toISOString()
|
||||
updated_at: nowIso()
|
||||
})
|
||||
.where(eq(filing.id, filingId))
|
||||
.returning();
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Holding } from '@/lib/types';
|
||||
import { recalculateHolding } from '@/lib/server/portfolio';
|
||||
import { db } from '@/lib/server/db';
|
||||
import { filing, holding, watchlistItem } from '@/lib/server/db/schema';
|
||||
import { normalizeTicker, nowIso } from '@/lib/server/utils';
|
||||
|
||||
type HoldingRow = typeof holding.$inferSelect;
|
||||
|
||||
@@ -30,7 +31,7 @@ function sortByMarketValueDesc(rows: Holding[]) {
|
||||
|
||||
function normalizeHoldingInput(input: { ticker: string; shares: number; avgCost: number; currentPrice: number }) {
|
||||
return {
|
||||
ticker: input.ticker.trim().toUpperCase(),
|
||||
ticker: normalizeTicker(input.ticker),
|
||||
shares: input.shares.toFixed(6),
|
||||
avg_cost: input.avgCost.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) {
|
||||
const normalizedTicker = ticker.trim().toUpperCase();
|
||||
const normalizedTicker = normalizeTicker(ticker);
|
||||
if (!normalizedTicker) {
|
||||
return null;
|
||||
}
|
||||
@@ -104,8 +105,8 @@ export async function upsertHoldingRecord(input: {
|
||||
currentPrice?: number;
|
||||
companyName?: string;
|
||||
}) {
|
||||
const ticker = input.ticker.trim().toUpperCase();
|
||||
const now = new Date().toISOString();
|
||||
const ticker = normalizeTicker(input.ticker);
|
||||
const now = nowIso();
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
@@ -251,8 +252,8 @@ export async function updateHoldingByIdRecord(input: {
|
||||
shares: shares.toFixed(6),
|
||||
avg_cost: avgCost.toFixed(6),
|
||||
current_price: currentPrice.toFixed(6),
|
||||
updated_at: new Date().toISOString(),
|
||||
last_price_at: new Date().toISOString()
|
||||
updated_at: nowIso(),
|
||||
last_price_at: nowIso()
|
||||
});
|
||||
|
||||
const [updated] = await db
|
||||
|
||||
@@ -27,6 +27,14 @@ import {
|
||||
} from '@/lib/server/db/schema';
|
||||
import { getFilingByAccession, listFilingsRecords } from '@/lib/server/repos/filings';
|
||||
import { getWatchlistItemByTicker } from '@/lib/server/repos/watchlist';
|
||||
import {
|
||||
normalizeTicker,
|
||||
normalizeTags,
|
||||
normalizeOptionalString,
|
||||
normalizeRecord,
|
||||
normalizePositiveInteger,
|
||||
nowIso
|
||||
} from '@/lib/server/utils';
|
||||
|
||||
type ResearchArtifactRow = typeof researchArtifact.$inferSelect;
|
||||
type ResearchMemoRow = typeof researchMemo.$inferSelect;
|
||||
@@ -51,55 +59,6 @@ const RESEARCH_PACKET_SECTION_TITLES: Record<ResearchMemoSection, string> = {
|
||||
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: {
|
||||
title?: string | null;
|
||||
summary?: string | null;
|
||||
@@ -409,7 +368,7 @@ export async function createResearchArtifactRecord(input: {
|
||||
throw new Error('ticker is required');
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const now = nowIso();
|
||||
const title = normalizeOptionalString(input.title);
|
||||
const summary = normalizeOptionalString(input.summary);
|
||||
const bodyMarkdown = normalizeOptionalString(input.bodyMarkdown);
|
||||
@@ -520,7 +479,7 @@ export async function upsertSystemResearchArtifact(input: {
|
||||
search_text: searchText,
|
||||
tags: normalizeTags(input.tags),
|
||||
metadata: normalizeRecord(input.metadata),
|
||||
updated_at: new Date().toISOString()
|
||||
updated_at: nowIso()
|
||||
})
|
||||
.where(eq(researchArtifact.id, existing.id))
|
||||
.returning();
|
||||
@@ -566,7 +525,7 @@ export async function updateResearchArtifactRecord(input: {
|
||||
metadata,
|
||||
tags,
|
||||
search_text: searchText,
|
||||
updated_at: new Date().toISOString()
|
||||
updated_at: nowIso()
|
||||
})
|
||||
.where(eq(researchArtifact.id, input.id))
|
||||
.returning();
|
||||
@@ -702,7 +661,7 @@ export async function upsertResearchMemoRecord(input: {
|
||||
throw new Error('ticker is required');
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const now = nowIso();
|
||||
const existing = await getResearchMemoByTicker(input.userId, ticker);
|
||||
|
||||
if (!existing) {
|
||||
@@ -796,7 +755,7 @@ export async function addResearchMemoEvidenceLink(input: {
|
||||
.then((rows) => (rows[0]?.maxOrder ?? 0) + 1)
|
||||
: Math.max(0, Math.trunc(input.sortOrder));
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const now = nowIso();
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(researchMemoEvidence)
|
||||
@@ -894,7 +853,7 @@ export async function getResearchPacket(userId: string, ticker: string): Promise
|
||||
return {
|
||||
ticker: normalizedTicker,
|
||||
companyName: coverage?.company_name ?? latestFiling?.company_name ?? null,
|
||||
generated_at: new Date().toISOString(),
|
||||
generated_at: nowIso(),
|
||||
memo,
|
||||
sections: toPacketSections(memo, evidenceBySection)
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Task, TaskStage, TaskStageContext, TaskStageEvent, TaskStatus, Tas
|
||||
import { db } from '@/lib/server/db';
|
||||
import { taskRun, taskStageEvent } from '@/lib/server/db/schema';
|
||||
import { buildTaskNotification } from '@/lib/server/task-notifications';
|
||||
import { nowIso } from '@/lib/server/utils';
|
||||
|
||||
type TaskRow = typeof taskRun.$inferSelect;
|
||||
type TaskStageEventRow = typeof taskStageEvent.$inferSelect;
|
||||
@@ -110,7 +111,7 @@ async function insertTaskStageEvent(executor: InsertExecutor, input: EventInsert
|
||||
}
|
||||
|
||||
export async function createTaskRunRecord(input: CreateTaskInput) {
|
||||
const now = new Date().toISOString();
|
||||
const now = nowIso();
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
@@ -219,7 +220,7 @@ async function attemptAtomicInsert(
|
||||
}
|
||||
|
||||
export async function createTaskRunRecordAtomic(input: CreateTaskInput): Promise<AtomicCreateResult> {
|
||||
const now = new Date().toISOString();
|
||||
const now = nowIso();
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
const result = await attemptAtomicInsert(tx, input, now);
|
||||
@@ -235,7 +236,7 @@ export async function setTaskWorkflowRunId(taskId: string, workflowRunId: string
|
||||
.update(taskRun)
|
||||
.set({
|
||||
workflow_run_id: workflowRunId,
|
||||
updated_at: new Date().toISOString()
|
||||
updated_at: nowIso()
|
||||
})
|
||||
.where(eq(taskRun.id, taskId));
|
||||
}
|
||||
@@ -313,7 +314,7 @@ export async function findInFlightTaskByResourceKey(
|
||||
}
|
||||
|
||||
export async function markTaskRunning(taskId: string) {
|
||||
const now = new Date().toISOString();
|
||||
const now = nowIso();
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
@@ -354,7 +355,7 @@ export async function updateTaskStage(
|
||||
detail: string | null = null,
|
||||
context: TaskStageContext | null = null
|
||||
) {
|
||||
const now = new Date().toISOString();
|
||||
const now = nowIso();
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
const [current] = await tx
|
||||
@@ -403,7 +404,7 @@ export async function completeTask(
|
||||
result: Record<string, unknown>,
|
||||
completion: TaskCompletionState = {}
|
||||
) {
|
||||
const now = new Date().toISOString();
|
||||
const now = nowIso();
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
@@ -445,7 +446,7 @@ export async function markTaskFailure(
|
||||
stage: TaskStage = 'failed',
|
||||
failure: TaskCompletionState = {}
|
||||
) {
|
||||
const now = new Date().toISOString();
|
||||
const now = nowIso();
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
@@ -513,7 +514,7 @@ export async function setTaskStatusFromWorkflow(
|
||||
return toTask(current);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const now = nowIso();
|
||||
const [row] = await tx
|
||||
.update(taskRun)
|
||||
.set({
|
||||
@@ -551,7 +552,7 @@ export async function updateTaskNotificationState(
|
||||
userId: string,
|
||||
input: UpdateTaskNotificationStateInput
|
||||
) {
|
||||
const now = new Date().toISOString();
|
||||
const now = nowIso();
|
||||
const patch: Partial<typeof taskRun.$inferInsert> = {
|
||||
updated_at: now
|
||||
};
|
||||
|
||||
@@ -6,38 +6,12 @@ import type {
|
||||
} from '@/lib/types';
|
||||
import { db } from '@/lib/server/db';
|
||||
import { watchlistItem } from '@/lib/server/db/schema';
|
||||
import { normalizeTicker, normalizeTagsOrNull, nowIso } from '@/lib/server/utils';
|
||||
|
||||
type WatchlistRow = typeof watchlistItem.$inferSelect;
|
||||
const DEFAULT_STATUS: CoverageStatus = 'backlog';
|
||||
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 {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -79,7 +53,7 @@ export async function getWatchlistItemById(userId: string, id: number) {
|
||||
}
|
||||
|
||||
export async function getWatchlistItemByTicker(userId: string, ticker: string) {
|
||||
const normalizedTicker = ticker.trim().toUpperCase();
|
||||
const normalizedTicker = normalizeTicker(ticker);
|
||||
if (!normalizedTicker) {
|
||||
return null;
|
||||
}
|
||||
@@ -104,15 +78,15 @@ export async function upsertWatchlistItemRecord(input: {
|
||||
priority?: CoveragePriority;
|
||||
lastReviewedAt?: string | null;
|
||||
}) {
|
||||
const normalizedTicker = input.ticker.trim().toUpperCase();
|
||||
const normalizedTicker = normalizeTicker(input.ticker);
|
||||
const normalizedSector = input.sector?.trim() ? input.sector.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 normalizedLastReviewedAt = input.lastReviewedAt?.trim()
|
||||
? input.lastReviewedAt.trim()
|
||||
: null;
|
||||
const now = new Date().toISOString();
|
||||
const timestamp = nowIso();
|
||||
|
||||
const [inserted] = await db
|
||||
.insert(watchlistItem)
|
||||
@@ -125,8 +99,8 @@ export async function upsertWatchlistItemRecord(input: {
|
||||
tags: normalizedTags,
|
||||
status: input.status ?? DEFAULT_STATUS,
|
||||
priority: input.priority ?? DEFAULT_PRIORITY,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
last_reviewed_at: normalizedLastReviewedAt
|
||||
})
|
||||
.onConflictDoNothing({
|
||||
@@ -156,7 +130,7 @@ export async function upsertWatchlistItemRecord(input: {
|
||||
tags: normalizedTags,
|
||||
status: input.status ?? existing?.status ?? DEFAULT_STATUS,
|
||||
priority: input.priority ?? existing?.priority ?? DEFAULT_PRIORITY,
|
||||
updated_at: now,
|
||||
updated_at: timestamp,
|
||||
last_reviewed_at: normalizedLastReviewedAt ?? existing?.last_reviewed_at ?? null
|
||||
})
|
||||
.where(and(eq(watchlistItem.user_id, input.userId), eq(watchlistItem.ticker, normalizedTicker)))
|
||||
@@ -212,7 +186,7 @@ export async function updateWatchlistItemRecord(input: {
|
||||
: null;
|
||||
const nextTags = input.tags === undefined
|
||||
? existing.tags ?? null
|
||||
: normalizeTags(input.tags);
|
||||
: normalizeTagsOrNull(input.tags);
|
||||
const nextLastReviewedAt = input.lastReviewedAt === undefined
|
||||
? existing.last_reviewed_at
|
||||
: input.lastReviewedAt?.trim()
|
||||
@@ -228,7 +202,7 @@ export async function updateWatchlistItemRecord(input: {
|
||||
tags: nextTags,
|
||||
status: input.status ?? existing.status ?? DEFAULT_STATUS,
|
||||
priority: input.priority ?? existing.priority ?? DEFAULT_PRIORITY,
|
||||
updated_at: new Date().toISOString(),
|
||||
updated_at: nowIso(),
|
||||
last_reviewed_at: nextLastReviewedAt
|
||||
})
|
||||
.where(and(eq(watchlistItem.user_id, input.userId), eq(watchlistItem.id, input.id)))
|
||||
@@ -242,7 +216,7 @@ export async function updateWatchlistReviewByTicker(
|
||||
ticker: string,
|
||||
reviewedAt: string
|
||||
) {
|
||||
const normalizedTicker = ticker.trim().toUpperCase();
|
||||
const normalizedTicker = normalizeTicker(ticker);
|
||||
if (!normalizedTicker) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user