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

@@ -1,170 +1,84 @@
import { and, desc, eq } from 'drizzle-orm';
import type {
ResearchArtifactKind,
ResearchJournalEntry,
ResearchJournalEntryType
} from '@/lib/types';
import { db } from '@/lib/server/db';
import { researchJournalEntry } from '@/lib/server/db/schema';
import { researchArtifact } from '@/lib/server/db/schema';
import {
createResearchJournalEntryCompat as createResearchJournalEntryRecord,
deleteResearchJournalEntryCompat as deleteResearchJournalEntryRecord,
listResearchJournalEntriesCompat as listResearchJournalEntries,
updateResearchJournalEntryCompat as updateResearchJournalEntryRecord
} from '@/lib/server/repos/research-library';
type ResearchJournalRow = typeof researchJournalEntry.$inferSelect;
type ResearchArtifactRow = typeof researchArtifact.$inferSelect;
function normalizeTicker(ticker: string) {
return ticker.trim().toUpperCase();
function toJournalType(kind: ResearchArtifactKind, accessionNumber: string | null): ResearchJournalEntryType | null {
if (kind === 'status_change') {
return 'status_change';
}
if (kind === 'note') {
return accessionNumber ? 'filing_note' : 'note';
}
if (kind === 'filing' || kind === 'ai_report') {
return 'filing_note';
}
return null;
}
function normalizeTitle(title?: string | null) {
const normalized = title?.trim();
return normalized ? normalized : null;
}
function normalizeAccessionNumber(accessionNumber?: string | null) {
const normalized = accessionNumber?.trim();
return normalized ? normalized : null;
}
function normalizeMetadata(metadata?: Record<string, unknown> | null) {
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
function toResearchJournalEntry(row: ResearchArtifactRow): ResearchJournalEntry | null {
const entryType = toJournalType(row.kind, row.accession_number ?? null);
if (!entryType) {
return null;
}
return metadata;
}
function toResearchJournalEntry(row: ResearchJournalRow): ResearchJournalEntry {
return {
id: row.id,
user_id: row.user_id,
ticker: row.ticker,
accession_number: row.accession_number ?? null,
entry_type: row.entry_type,
entry_type: entryType,
title: row.title ?? null,
body_markdown: row.body_markdown,
body_markdown: row.body_markdown ?? row.summary ?? '',
metadata: row.metadata ?? null,
created_at: row.created_at,
updated_at: row.updated_at
};
}
export async function listResearchJournalEntries(userId: string, ticker: string, limit = 100) {
const normalizedTicker = normalizeTicker(ticker);
if (!normalizedTicker) {
return [];
}
const safeLimit = Math.min(Math.max(Math.trunc(limit), 1), 250);
const rows = await db
.select()
.from(researchJournalEntry)
.where(and(eq(researchJournalEntry.user_id, userId), eq(researchJournalEntry.ticker, normalizedTicker)))
.orderBy(desc(researchJournalEntry.created_at), desc(researchJournalEntry.id))
.limit(safeLimit);
return rows.map(toResearchJournalEntry);
}
export async function listResearchJournalEntriesForUser(userId: string, limit = 250) {
const safeLimit = Math.min(Math.max(Math.trunc(limit), 1), 500);
const rows = await db
.select()
.from(researchJournalEntry)
.where(eq(researchJournalEntry.user_id, userId))
.orderBy(desc(researchJournalEntry.updated_at), desc(researchJournalEntry.id))
.limit(safeLimit);
.from(researchArtifact)
.where(eq(researchArtifact.user_id, userId))
.orderBy(desc(researchArtifact.updated_at), desc(researchArtifact.id))
.limit(safeLimit * 2);
return rows.map(toResearchJournalEntry);
return rows
.map(toResearchJournalEntry)
.filter((entry): entry is ResearchJournalEntry => Boolean(entry))
.slice(0, safeLimit);
}
export async function getResearchJournalEntryRecord(userId: string, id: number) {
const [row] = await db
.select()
.from(researchJournalEntry)
.where(and(eq(researchJournalEntry.user_id, userId), eq(researchJournalEntry.id, id)))
.from(researchArtifact)
.where(and(eq(researchArtifact.user_id, userId), eq(researchArtifact.id, id)))
.limit(1);
return row ? toResearchJournalEntry(row) : null;
}
export async function createResearchJournalEntryRecord(input: {
userId: string;
ticker: string;
accessionNumber?: string | null;
entryType: ResearchJournalEntryType;
title?: string | null;
bodyMarkdown: string;
metadata?: Record<string, unknown> | null;
}) {
const ticker = normalizeTicker(input.ticker);
const bodyMarkdown = input.bodyMarkdown.trim();
if (!ticker) {
throw new Error('ticker is required');
}
if (!bodyMarkdown) {
throw new Error('bodyMarkdown is required');
}
const now = new Date().toISOString();
const [created] = await db
.insert(researchJournalEntry)
.values({
user_id: input.userId,
ticker,
accession_number: normalizeAccessionNumber(input.accessionNumber),
entry_type: input.entryType,
title: normalizeTitle(input.title),
body_markdown: bodyMarkdown,
metadata: normalizeMetadata(input.metadata),
created_at: now,
updated_at: now
})
.returning();
return toResearchJournalEntry(created);
}
export async function updateResearchJournalEntryRecord(input: {
userId: string;
id: number;
title?: string | null;
bodyMarkdown?: string;
metadata?: Record<string, unknown> | null;
}) {
const [existing] = await db
.select()
.from(researchJournalEntry)
.where(and(eq(researchJournalEntry.user_id, input.userId), eq(researchJournalEntry.id, input.id)))
.limit(1);
if (!existing) {
return null;
}
const nextBodyMarkdown = input.bodyMarkdown === undefined
? existing.body_markdown
: input.bodyMarkdown.trim();
if (!nextBodyMarkdown) {
throw new Error('bodyMarkdown is required');
}
const [updated] = await db
.update(researchJournalEntry)
.set({
title: input.title === undefined ? existing.title : normalizeTitle(input.title),
body_markdown: nextBodyMarkdown,
metadata: input.metadata === undefined ? existing.metadata ?? null : normalizeMetadata(input.metadata),
updated_at: new Date().toISOString()
})
.where(and(eq(researchJournalEntry.user_id, input.userId), eq(researchJournalEntry.id, input.id)))
.returning();
return updated ? toResearchJournalEntry(updated) : null;
}
export async function deleteResearchJournalEntryRecord(userId: string, id: number) {
const rows = await db
.delete(researchJournalEntry)
.where(and(eq(researchJournalEntry.user_id, userId), eq(researchJournalEntry.id, id)))
.returning({ id: researchJournalEntry.id });
return rows.length > 0;
}
export {
createResearchJournalEntryRecord,
deleteResearchJournalEntryRecord,
listResearchJournalEntries,
updateResearchJournalEntryRecord
};