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
This commit is contained in:
2026-03-07 20:39:49 -05:00
38 changed files with 5533 additions and 427 deletions

View File

@@ -12,11 +12,22 @@ import type {
Holding,
PortfolioInsight,
PortfolioSummary,
ResearchArtifact,
ResearchArtifactKind,
ResearchArtifactSource,
ResearchJournalEntry,
ResearchJournalEntryType,
SearchAnswerResponse,
SearchResult,
SearchSource,
ResearchLibraryResponse,
ResearchMemo,
ResearchMemoConviction,
ResearchMemoEvidenceLink,
ResearchMemoRating,
ResearchMemoSection,
ResearchPacket,
ResearchWorkspace,
Task,
TaskStatus,
TaskTimeline,
@@ -108,7 +119,7 @@ async function unwrapData<T>(result: TreatyResult, fallback: string) {
async function requestJson<T>(input: {
path: string;
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
body?: unknown;
}, fallback: string) {
const response = await fetch(`${API_BASE}${input.path}`, {
@@ -209,6 +220,208 @@ export async function createResearchJournalEntry(input: {
}, '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;