Files
Neon-Desk/lib/api.ts

455 lines
13 KiB
TypeScript

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,
ResearchJournalEntry,
ResearchJournalEntryType,
SearchAnswerResponse,
SearchResult,
SearchSource,
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<App>(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<T>(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<T>(input: {
path: string;
method?: 'GET' | 'POST' | 'PATCH' | '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 }>(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<string, unknown>;
}) {
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 updateResearchJournalEntry(id: number, input: {
title?: string;
bodyMarkdown?: string;
metadata?: Record<string, unknown>;
}) {
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<SearchAnswerResponse>({
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) {
const result = await client.api.analysis.company.get({
$query: {
ticker: ticker.trim().toUpperCase()
}
});
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<TaskTimeline>(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');
}