import { edenTreaty } from '@elysiajs/eden'; import type { App } from '@/lib/server/api/app'; import type { CompanyAiReportDetail, CompanyAnalysis, CompanyFinancialStatementsResponse, CoveragePriority, CoverageStatus, Filing, FinancialCadence, FinancialSurfaceKind, Holding, PortfolioInsight, PortfolioSummary, ResearchArtifact, ResearchArtifactKind, ResearchArtifactSource, ResearchJournalEntry, ResearchJournalEntryType, SearchAnswerResponse, SearchResult, SearchSource, ResearchLibraryResponse, ResearchMemo, ResearchMemoConviction, ResearchMemoEvidenceLink, ResearchMemoRating, ResearchMemoSection, ResearchPacket, ResearchWorkspace, Task, TaskStatus, TaskTimeline, User, WatchlistItem } from './types'; import { resolveApiBaseURL } from './runtime-url'; const API_BASE = resolveApiBaseURL(process.env.NEXT_PUBLIC_API_URL); const client = edenTreaty(API_BASE, { $fetch: { credentials: 'include', cache: 'no-store' } }); export class ApiError extends Error { status: number; constructor(message: string, status: number) { super(message); this.name = 'ApiError'; this.status = status; } } function extractErrorMessage(error: unknown, fallback: string) { if (!error || typeof error !== 'object') { return fallback; } const candidate = error as { value?: unknown; message?: string; }; if (typeof candidate.message === 'string' && candidate.message.trim().length > 0) { return candidate.message; } if (candidate.value && typeof candidate.value === 'object') { const nested = candidate.value as { error?: unknown; message?: unknown }; if (typeof nested.error === 'string' && nested.error.trim().length > 0) { return nested.error; } if (typeof nested.message === 'string' && nested.message.trim().length > 0) { return nested.message; } } if (typeof candidate.value === 'string' && candidate.value.trim().length > 0) { return candidate.value; } return fallback; } type TreatyResult = { data: unknown; error: unknown; status: number; }; async function unwrapData(result: TreatyResult, fallback: string) { if (result.error) { throw new ApiError( extractErrorMessage(result.error, fallback), result.status ); } if (result.data === null || result.data === undefined) { throw new ApiError(fallback, result.status); } const payload = result.data instanceof Response ? await result.data.json().catch(() => null) : result.data; if (payload === null || payload === undefined) { throw new ApiError(fallback, result.status); } return payload as T; } async function requestJson(input: { path: string; method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; body?: unknown; }, fallback: string) { const response = await fetch(`${API_BASE}${input.path}`, { method: input.method ?? 'GET', credentials: 'include', cache: 'no-store', headers: input.body === undefined ? undefined : { 'content-type': 'application/json' }, body: input.body === undefined ? undefined : JSON.stringify(input.body) }); const payload = await response.json().catch(() => null); if (!response.ok) { throw new ApiError( extractErrorMessage({ value: payload }, fallback), response.status ); } if (payload === null || payload === undefined) { throw new ApiError(fallback, response.status); } return payload as T; } export async function getMe() { const result = await client.api.me.get(); return await unwrapData<{ user: User }>(result, 'Unable to fetch session'); } export async function listWatchlist() { const result = await client.api.watchlist.get(); return await unwrapData<{ items: WatchlistItem[] }>(result, 'Unable to fetch watchlist'); } export async function upsertWatchlistItem(input: { ticker: string; companyName: string; sector?: string; category?: string; tags?: string[]; status?: CoverageStatus; priority?: CoveragePriority; lastReviewedAt?: string; }) { const result = await client.api.watchlist.post(input); return await unwrapData<{ item: WatchlistItem; autoFilingSyncQueued: boolean }>(result, 'Unable to save watchlist item'); } export async function updateWatchlistItem(id: number, input: { companyName?: string; sector?: string; category?: string; tags?: string[]; status?: CoverageStatus; priority?: CoveragePriority; lastReviewedAt?: string; }) { return await requestJson<{ item: WatchlistItem; statusChangeJournalCreated: boolean }>({ path: `/api/watchlist/${id}`, method: 'PATCH', body: input }, 'Unable to update watchlist item'); } export async function deleteWatchlistItem(id: number) { const result = await client.api.watchlist[id].delete(); return await unwrapData<{ success: boolean }>(result, 'Unable to delete watchlist item'); } export async function listResearchJournal(ticker: string) { const result = await client.api.research.journal.get({ $query: { ticker: ticker.trim().toUpperCase() } }); return await unwrapData<{ entries: ResearchJournalEntry[] }>(result, 'Unable to fetch research journal'); } export async function createResearchJournalEntry(input: { ticker: string; accessionNumber?: string; entryType: ResearchJournalEntryType; title?: string; bodyMarkdown: string; metadata?: Record; }) { return await requestJson<{ entry: ResearchJournalEntry }>({ path: '/api/research/journal', method: 'POST', body: { ...input, ticker: input.ticker.trim().toUpperCase() } }, 'Unable to create journal entry'); } export async function getResearchWorkspace(ticker: string) { return await requestJson<{ workspace: ResearchWorkspace }>({ path: `/api/research/workspace?ticker=${encodeURIComponent(ticker.trim().toUpperCase())}` }, 'Unable to fetch research workspace'); } export async function listResearchLibrary(input: { ticker: string; q?: string; kind?: ResearchArtifactKind; tag?: string; source?: ResearchArtifactSource; linkedToMemo?: boolean; limit?: number; }) { const params = new URLSearchParams({ ticker: input.ticker.trim().toUpperCase() }); if (input.q?.trim()) { params.set('q', input.q.trim()); } if (input.kind) { params.set('kind', input.kind); } if (input.tag?.trim()) { params.set('tag', input.tag.trim()); } if (input.source) { params.set('source', input.source); } if (input.linkedToMemo !== undefined) { params.set('linkedToMemo', input.linkedToMemo ? 'true' : 'false'); } if (typeof input.limit === 'number') { params.set('limit', String(input.limit)); } return await requestJson({ path: `/api/research/library?${params.toString()}` }, 'Unable to fetch research library'); } export async function createResearchArtifact(input: { ticker: string; accessionNumber?: string; kind: ResearchArtifactKind; source?: ResearchArtifactSource; subtype?: string; title?: string; summary?: string; bodyMarkdown?: string; tags?: string[]; metadata?: Record; }) { return await requestJson<{ artifact: ResearchArtifact }>({ path: '/api/research/library', method: 'POST', body: { ...input, ticker: input.ticker.trim().toUpperCase() } }, 'Unable to create research artifact'); } export async function updateResearchArtifact(id: number, input: { title?: string; summary?: string; bodyMarkdown?: string; tags?: string[]; metadata?: Record; }) { return await requestJson<{ artifact: ResearchArtifact }>({ path: `/api/research/library/${id}`, method: 'PATCH', body: input }, 'Unable to update research artifact'); } export async function deleteResearchArtifact(id: number) { return await requestJson<{ success: boolean }>({ path: `/api/research/library/${id}`, method: 'DELETE' }, 'Unable to delete research artifact'); } export async function uploadResearchArtifact(input: { ticker: string; file: File; title?: string; summary?: string; tags?: string[]; }) { const form = new FormData(); form.set('ticker', input.ticker.trim().toUpperCase()); form.set('file', input.file); if (input.title?.trim()) { form.set('title', input.title.trim()); } if (input.summary?.trim()) { form.set('summary', input.summary.trim()); } if (input.tags && input.tags.length > 0) { form.set('tags', input.tags.join(',')); } const response = await fetch(`${API_BASE}/api/research/library/upload`, { method: 'POST', credentials: 'include', cache: 'no-store', body: form }); const payload = await response.json().catch(() => null); if (!response.ok) { throw new ApiError( extractErrorMessage({ value: payload }, 'Unable to upload research file'), response.status ); } if (!payload) { throw new ApiError('Unable to upload research file', response.status); } return payload as { artifact: ResearchArtifact }; } export function getResearchArtifactFileUrl(id: number) { return `${API_BASE}/api/research/library/${id}/file`; } export async function getResearchMemo(ticker: string) { return await requestJson<{ memo: ResearchMemo | null }>({ path: `/api/research/memo?ticker=${encodeURIComponent(ticker.trim().toUpperCase())}` }, 'Unable to fetch research memo'); } export async function upsertResearchMemo(input: { ticker: string; rating?: ResearchMemoRating | null; conviction?: ResearchMemoConviction | null; timeHorizonMonths?: number | null; packetTitle?: string; packetSubtitle?: string; thesisMarkdown?: string; variantViewMarkdown?: string; catalystsMarkdown?: string; risksMarkdown?: string; disconfirmingEvidenceMarkdown?: string; nextActionsMarkdown?: string; }) { return await requestJson<{ memo: ResearchMemo }>({ path: '/api/research/memo', method: 'PUT', body: { ...input, ticker: input.ticker.trim().toUpperCase() } }, 'Unable to save research memo'); } export async function addResearchMemoEvidence(input: { memoId: number; artifactId: number; section: ResearchMemoSection; annotation?: string; sortOrder?: number; }) { return await requestJson<{ evidence: ResearchMemoEvidenceLink[] }>({ path: `/api/research/memo/${input.memoId}/evidence`, method: 'POST', body: { artifactId: input.artifactId, section: input.section, annotation: input.annotation, sortOrder: input.sortOrder } }, 'Unable to attach memo evidence'); } export async function deleteResearchMemoEvidence(memoId: number, linkId: number) { return await requestJson<{ success: boolean }>({ path: `/api/research/memo/${memoId}/evidence/${linkId}`, method: 'DELETE' }, 'Unable to delete memo evidence'); } export async function getResearchPacket(ticker: string) { return await requestJson<{ packet: ResearchPacket }>({ path: `/api/research/packet?ticker=${encodeURIComponent(ticker.trim().toUpperCase())}` }, 'Unable to fetch research packet'); } export async function updateResearchJournalEntry(id: number, input: { title?: string; bodyMarkdown?: string; metadata?: Record; }) { return await requestJson<{ entry: ResearchJournalEntry }>({ path: `/api/research/journal/${id}`, method: 'PATCH', body: input }, 'Unable to update journal entry'); } export async function deleteResearchJournalEntry(id: number) { const result = await client.api.research.journal[id].delete(); return await unwrapData<{ success: boolean }>(result, 'Unable to delete journal entry'); } export async function listHoldings() { const result = await client.api.portfolio.holdings.get(); return await unwrapData<{ holdings: Holding[] }>(result, 'Unable to fetch holdings'); } export async function getPortfolioSummary() { const result = await client.api.portfolio.summary.get(); return await unwrapData<{ summary: PortfolioSummary }>(result, 'Unable to fetch summary'); } export async function upsertHolding(input: { ticker: string; shares: number; avgCost: number; currentPrice?: number; companyName?: string; }) { const result = await client.api.portfolio.holdings.post(input); return await unwrapData<{ holding: Holding }>(result, 'Unable to save holding'); } export async function updateHolding(id: number, input: { shares?: number; avgCost?: number; currentPrice?: number; companyName?: string; }) { const result = await client.api.portfolio.holdings[id].patch(input); return await unwrapData<{ holding: Holding }>(result, 'Unable to update holding'); } export async function deleteHolding(id: number) { const result = await client.api.portfolio.holdings[id].delete(); return await unwrapData<{ success: boolean }>(result, 'Unable to delete holding'); } export async function queuePriceRefresh() { const result = await client.api.portfolio['refresh-prices'].post(); return await unwrapData<{ task: Task }>(result, 'Unable to queue price refresh'); } export async function queuePortfolioInsights() { const result = await client.api.portfolio.insights.generate.post(); return await unwrapData<{ task: Task }>(result, 'Unable to queue portfolio insights'); } export async function getLatestPortfolioInsight() { const result = await client.api.portfolio.insights.latest.get(); return await unwrapData<{ insight: PortfolioInsight | null }>(result, 'Unable to fetch latest insight'); } export async function listFilings(query?: { ticker?: string; limit?: number }) { const queryParams: { ticker?: string; limit?: number; } = {}; if (query?.ticker?.trim()) { queryParams.ticker = query.ticker.trim().toUpperCase(); } if (query?.limit !== undefined) { queryParams.limit = query.limit; } const result = await client.api.filings.get({ $query: queryParams }); return await unwrapData<{ filings: Filing[] }>(result, 'Unable to fetch filings'); } export async function searchKnowledge(input: { query: string; ticker?: string; sources?: SearchSource[]; limit?: number; }) { const result = await client.api.search.get({ $query: { q: input.query.trim(), ...(input.ticker?.trim() ? { ticker: input.ticker.trim().toUpperCase() } : {}), ...(input.sources && input.sources.length > 0 ? { sources: input.sources } : {}), ...(typeof input.limit === 'number' ? { limit: input.limit } : {}) } }); return await unwrapData<{ results: SearchResult[] }>(result, 'Unable to search indexed sources'); } export async function getSearchAnswer(input: { query: string; ticker?: string; sources?: SearchSource[]; limit?: number; }) { return await requestJson({ path: '/api/search/answer', method: 'POST', body: { query: input.query.trim(), ...(input.ticker?.trim() ? { ticker: input.ticker.trim().toUpperCase() } : {}), ...(input.sources && input.sources.length > 0 ? { sources: input.sources } : {}), ...(typeof input.limit === 'number' ? { limit: input.limit } : {}) } }, 'Unable to generate cited answer'); } export async function getCompanyAnalysis(ticker: string, options?: { refresh?: boolean }) { const result = await client.api.analysis.company.get({ $query: { ticker: ticker.trim().toUpperCase(), ...(options?.refresh ? { refresh: 'true' } : {}) } }); return await unwrapData<{ analysis: CompanyAnalysis }>(result, 'Unable to fetch company analysis'); } export async function getCompanyFinancialStatements(input: { ticker: string; surfaceKind: FinancialSurfaceKind; cadence: FinancialCadence; includeDimensions?: boolean; includeFacts?: boolean; factsCursor?: string | null; factsLimit?: number; cursor?: string | null; limit?: number; }) { const query = { ticker: input.ticker.trim().toUpperCase(), surface: input.surfaceKind, cadence: input.cadence, includeDimensions: input.includeDimensions ? 'true' : 'false', includeFacts: input.includeFacts ? 'true' : 'false', ...(typeof input.cursor === 'string' && input.cursor.trim().length > 0 ? { cursor: input.cursor.trim() } : {}), ...(typeof input.limit === 'number' && Number.isFinite(input.limit) ? { limit: input.limit } : {}), ...(typeof input.factsCursor === 'string' && input.factsCursor.trim().length > 0 ? { factsCursor: input.factsCursor.trim() } : {}), ...(typeof input.factsLimit === 'number' && Number.isFinite(input.factsLimit) ? { factsLimit: input.factsLimit } : {}) }; const result = await client.api.financials.company.get({ $query: query }); return await unwrapData<{ financials: CompanyFinancialStatementsResponse }>( result, 'Unable to fetch company financial statements' ); } export async function getCompanyAiReport(accessionNumber: string) { const normalizedAccession = accessionNumber.trim(); const result = await client.api.analysis.reports[normalizedAccession].get(); return await unwrapData<{ report: CompanyAiReportDetail }>(result, 'Unable to fetch AI summary'); } export async function queueFilingSync(input: { ticker: string; limit?: number; category?: string; tags?: string[]; }) { const result = await client.api.filings.sync.post(input); return await unwrapData<{ task: Task }>(result, 'Unable to queue filing sync'); } export async function queueFilingAnalysis(accessionNumber: string) { const result = await client.api.filings[accessionNumber].analyze.post(); return await unwrapData<{ task: Task }>(result, 'Unable to queue filing analysis'); } export async function getTask(taskId: string) { const result = await client.api.tasks[taskId].get(); return await unwrapData<{ task: Task }>(result, 'Unable to fetch task'); } export async function getTaskTimeline(taskId: string) { const result = await client.api.tasks[taskId].timeline.get(); return await unwrapData(result, 'Unable to fetch task timeline'); } export async function updateTaskNotificationState( taskId: string, input: { read?: boolean; silenced?: boolean } ) { const result = await client.api.tasks[taskId].notification.patch(input); return await unwrapData<{ task: Task }>(result, 'Unable to update task notification state'); } export async function listRecentTasks(input: { limit?: number; statuses?: TaskStatus[]; } = {}) { const result = await client.api.tasks.get({ $query: { limit: input.limit ?? 20, ...(input.statuses && input.statuses.length > 0 ? { status: input.statuses } : {}) } }); return await unwrapData<{ tasks: Task[] }>(result, 'Unable to fetch tasks'); }