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(); 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; }