684 lines
19 KiB
TypeScript
684 lines
19 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,
|
|
ResearchArtifact,
|
|
ResearchArtifactKind,
|
|
ResearchArtifactSource,
|
|
ResearchJournalEntry,
|
|
ResearchJournalEntryType,
|
|
SearchAnswerResponse,
|
|
SearchResult,
|
|
SearchSource,
|
|
ResearchLibraryResponse,
|
|
ResearchMemo,
|
|
ResearchMemoConviction,
|
|
ResearchMemoEvidenceLink,
|
|
ResearchMemoRating,
|
|
ResearchMemoSection,
|
|
ResearchPacket,
|
|
ResearchWorkspace,
|
|
Task,
|
|
TaskStatus,
|
|
TaskTimeline,
|
|
TickerAutomationSource,
|
|
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' | '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;
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
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 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<ResearchLibraryResponse>({
|
|
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<string, unknown>;
|
|
}) {
|
|
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<string, unknown>;
|
|
}) {
|
|
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');
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
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, 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 ensureTickerAutomation(input: {
|
|
ticker: string;
|
|
source: TickerAutomationSource;
|
|
}) {
|
|
return await requestJson<{ queued: boolean; task: Task | null }>({
|
|
path: '/api/tickers/ensure',
|
|
method: 'POST',
|
|
body: {
|
|
ticker: input.ticker.trim().toUpperCase(),
|
|
source: input.source
|
|
}
|
|
}, 'Unable to ensure ticker automation');
|
|
}
|
|
|
|
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');
|
|
}
|