Files
Neon-Desk/lib/api.ts
francy51 7a70545f09 Merge branch 't3code/expand-research-management-plan'
# Conflicts:
#	app/analysis/page.tsx
#	app/watchlist/page.tsx
#	components/shell/app-shell.tsx
#	lib/api.ts
#	lib/query/options.ts
#	lib/server/api/app.ts
#	lib/server/db/index.test.ts
#	lib/server/db/index.ts
#	lib/server/db/schema.ts
#	lib/server/repos/research-journal.ts
#	lib/types.ts
2026-03-07 20:39:49 -05:00

668 lines
18 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,
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;
}
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 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');
}
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');
}