Files
Neon-Desk/lib/server/repos/watchlist.ts
francy51 52136271d3
Some checks failed
PR Checks / typecheck-and-build (push) Has been cancelled
Implement fiscal-style research MVP flows
2026-03-07 09:51:18 -05:00

270 lines
7.3 KiB
TypeScript

import { and, desc, eq } from 'drizzle-orm';
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)) {
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,
user_id: row.user_id,
ticker: row.ticker,
company_name: row.company_name,
sector: row.sector,
category: row.category,
tags: Array.isArray(row.tags)
? row.tags.filter((entry): entry is string => typeof entry === 'string')
: [],
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
};
}
export async function listWatchlistItems(userId: string) {
const rows = await db
.select()
.from(watchlistItem)
.where(eq(watchlistItem.user_id, userId))
.orderBy(desc(watchlistItem.updated_at), desc(watchlistItem.created_at));
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) {
const normalizedTicker = ticker.trim().toUpperCase();
if (!normalizedTicker) {
return null;
}
const [row] = await db
.select()
.from(watchlistItem)
.where(and(eq(watchlistItem.user_id, userId), eq(watchlistItem.ticker, normalizedTicker)))
.limit(1);
return row ? toWatchlistItem(row) : null;
}
export async function upsertWatchlistItemRecord(input: {
userId: string;
ticker: string;
companyName: string;
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
.insert(watchlistItem)
.values({
user_id: input.userId,
ticker: normalizedTicker,
company_name: normalizedCompanyName,
sector: normalizedSector,
category: normalizedCategory,
tags: normalizedTags,
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],
})
.returning();
if (inserted) {
return {
item: toWatchlistItem(inserted),
created: true
};
}
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: normalizedCompanyName,
sector: normalizedSector,
category: normalizedCategory,
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();
if (!updated) {
throw new Error(`Watchlist item ${normalizedTicker} was not found after upsert conflict resolution`);
}
return {
item: toWatchlistItem(updated),
created: false
};
}
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)
.where(and(eq(watchlistItem.user_id, userId), eq(watchlistItem.id, id)))
.returning({ id: watchlistItem.id });
return removed.length > 0;
}