Add company overview skeleton and cache
This commit is contained in:
@@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
|||||||
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { AppShell } from '@/components/shell/app-shell';
|
import { AppShell } from '@/components/shell/app-shell';
|
||||||
|
import { CompanyAnalysisSkeleton } from '@/components/analysis/company-analysis-skeleton';
|
||||||
import { AnalysisToolbar } from '@/components/analysis/analysis-toolbar';
|
import { AnalysisToolbar } from '@/components/analysis/analysis-toolbar';
|
||||||
import { BullBearPanel } from '@/components/analysis/bull-bear-panel';
|
import { BullBearPanel } from '@/components/analysis/bull-bear-panel';
|
||||||
import { CompanyOverviewCard } from '@/components/analysis/company-overview-card';
|
import { CompanyOverviewCard } from '@/components/analysis/company-overview-card';
|
||||||
@@ -55,21 +56,28 @@ function AnalysisPageContent() {
|
|||||||
setTicker(normalized);
|
setTicker(normalized);
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
const loadAnalysis = useCallback(async (symbol: string) => {
|
const loadAnalysis = useCallback(async (symbol: string, options?: { refresh?: boolean }) => {
|
||||||
const options = companyAnalysisQueryOptions(symbol);
|
const queryOptions = companyAnalysisQueryOptions(symbol, options);
|
||||||
|
|
||||||
if (!queryClient.getQueryData(options.queryKey)) {
|
if (!queryClient.getQueryData(queryOptions.queryKey)) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await queryClient.fetchQuery(options);
|
const response = await queryClient.fetchQuery(queryOptions);
|
||||||
setAnalysis(response.analysis);
|
setAnalysis(response.analysis);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Unable to load company overview');
|
setError(err instanceof Error ? err.message : 'Unable to load company overview');
|
||||||
setAnalysis(null);
|
setAnalysis((current) => {
|
||||||
|
const normalizedTicker = symbol.trim().toUpperCase();
|
||||||
|
if (options?.refresh && current?.company.ticker === normalizedTicker) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -116,7 +124,7 @@ function AnalysisPageContent() {
|
|||||||
onRefresh={() => {
|
onRefresh={() => {
|
||||||
const normalizedTicker = activeTicker.trim().toUpperCase();
|
const normalizedTicker = activeTicker.trim().toUpperCase();
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(normalizedTicker) });
|
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(normalizedTicker) });
|
||||||
void loadAnalysis(normalizedTicker);
|
void loadAnalysis(normalizedTicker, { refresh: true });
|
||||||
}}
|
}}
|
||||||
quickLinks={quickLinks}
|
quickLinks={quickLinks}
|
||||||
onLinkPrefetch={() => prefetchResearchTicker(activeTicker)}
|
onLinkPrefetch={() => prefetchResearchTicker(activeTicker)}
|
||||||
@@ -128,7 +136,9 @@ function AnalysisPageContent() {
|
|||||||
</Panel>
|
</Panel>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{analysis ? (
|
{!analysis && loading ? (
|
||||||
|
<CompanyAnalysisSkeleton />
|
||||||
|
) : analysis ? (
|
||||||
<>
|
<>
|
||||||
<section className="grid gap-6 xl:grid-cols-[minmax(320px,1fr)_minmax(0,2fr)]">
|
<section className="grid gap-6 xl:grid-cols-[minmax(320px,1fr)_minmax(0,2fr)]">
|
||||||
<CompanyOverviewCard
|
<CompanyOverviewCard
|
||||||
|
|||||||
141
components/analysis/company-analysis-skeleton.tsx
Normal file
141
components/analysis/company-analysis-skeleton.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { Panel } from '@/components/ui/panel';
|
||||||
|
|
||||||
|
function SkeletonLine(props: { className: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`rounded-full bg-[color:var(--panel-soft)] motion-safe:animate-pulse ${props.className}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkeletonCard(props: {
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Panel title={props.title} subtitle={props.subtitle} className={props.className}>
|
||||||
|
{props.children}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompanyAnalysisSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6" data-testid="analysis-overview-skeleton" aria-live="polite" aria-busy="true">
|
||||||
|
<span className="sr-only">Loading company overview</span>
|
||||||
|
|
||||||
|
<section className="grid gap-6 xl:grid-cols-[minmax(320px,1fr)_minmax(0,2fr)]">
|
||||||
|
<Panel className="h-full pt-2">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<SkeletonLine className="h-8 w-3/4" />
|
||||||
|
<SkeletonLine className="h-3 w-1/2" />
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-[color:var(--line-weak)] py-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<SkeletonLine className="h-3 w-28" />
|
||||||
|
<SkeletonLine className="h-4 w-full" />
|
||||||
|
<SkeletonLine className="h-4 w-[92%]" />
|
||||||
|
<SkeletonLine className="h-4 w-[84%]" />
|
||||||
|
<SkeletonLine className="h-4 w-[66%]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<SkeletonCard title="Price chart" className="pt-2">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
{Array.from({ length: 3 }, (_, index) => (
|
||||||
|
<div key={index} className="border-b border-[color:var(--line-weak)] px-4 py-4 sm:border-b-0 sm:border-r last:border-r-0">
|
||||||
|
<SkeletonLine className="h-3 w-24" />
|
||||||
|
<SkeletonLine className="mt-3 h-7 w-28" />
|
||||||
|
<SkeletonLine className="mt-3 h-3 w-20" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
||||||
|
<SkeletonLine className="h-[288px] w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SkeletonCard>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-6 xl:grid-cols-2">
|
||||||
|
<SkeletonCard title="Company profile facts" className="pt-2">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 4 }, (_, index) => (
|
||||||
|
<div key={index} className="grid grid-cols-[18%_32%_18%_32%] gap-3 border-t border-[color:var(--line-weak)] py-2">
|
||||||
|
<SkeletonLine className="h-3 w-16" />
|
||||||
|
<SkeletonLine className="h-4 w-20" />
|
||||||
|
<SkeletonLine className="h-3 w-16" />
|
||||||
|
<SkeletonLine className="h-4 w-24" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SkeletonCard>
|
||||||
|
|
||||||
|
<SkeletonCard title="Valuation" className="pt-2">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 4 }, (_, index) => (
|
||||||
|
<div key={index} className="grid grid-cols-[18%_32%_18%_32%] gap-3 border-t border-[color:var(--line-weak)] py-2">
|
||||||
|
<SkeletonLine className="h-3 w-20" />
|
||||||
|
<SkeletonLine className="h-4 w-24" />
|
||||||
|
<SkeletonLine className="h-3 w-20" />
|
||||||
|
<SkeletonLine className="h-4 w-20" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SkeletonCard>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<SkeletonCard title="Bull vs Bear" subtitle="The highest-level reasons investors may lean in or lean out right now." className="pt-2">
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{Array.from({ length: 2 }, (_, index) => (
|
||||||
|
<section key={index} className="border-t border-[color:var(--line-weak)] pt-5">
|
||||||
|
<SkeletonLine className="h-6 w-28" />
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{Array.from({ length: 3 }, (_, bulletIndex) => (
|
||||||
|
<div key={bulletIndex} className="border-t border-[color:var(--line-weak)] pt-3">
|
||||||
|
<SkeletonLine className="h-4 w-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SkeletonCard>
|
||||||
|
|
||||||
|
<section className="grid gap-6 xl:grid-cols-[minmax(280px,0.72fr)_minmax(0,1.28fr)]">
|
||||||
|
<SkeletonCard title="Past 7 Days" className="pt-2">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<SkeletonLine className="h-4 w-full" />
|
||||||
|
<SkeletonLine className="h-4 w-[88%]" />
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
{Array.from({ length: 3 }, (_, index) => (
|
||||||
|
<SkeletonLine key={index} className="h-4 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SkeletonCard>
|
||||||
|
|
||||||
|
<SkeletonCard title="Recent Developments" subtitle="SEC-first event cards sourced from filings and attached analysis." className="pt-2">
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
{Array.from({ length: 4 }, (_, index) => (
|
||||||
|
<article key={index} className="border-t border-[color:var(--line-weak)] pt-4">
|
||||||
|
<SkeletonLine className="h-3 w-28" />
|
||||||
|
<SkeletonLine className="mt-3 h-5 w-3/4" />
|
||||||
|
<SkeletonLine className="mt-3 h-4 w-full" />
|
||||||
|
<SkeletonLine className="mt-2 h-4 w-[90%]" />
|
||||||
|
<SkeletonLine className="mt-4 h-3 w-24" />
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SkeletonCard>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
drizzle/0012_company_overview_cache.sql
Normal file
15
drizzle/0012_company_overview_cache.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE `company_overview_cache` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`ticker` text NOT NULL,
|
||||||
|
`cache_version` integer NOT NULL,
|
||||||
|
`source_signature` text NOT NULL,
|
||||||
|
`payload` text NOT NULL,
|
||||||
|
`created_at` text NOT NULL,
|
||||||
|
`updated_at` text NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `company_overview_cache_uidx` ON `company_overview_cache` (`user_id`,`ticker`);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `company_overview_cache_lookup_idx` ON `company_overview_cache` (`user_id`,`ticker`,`updated_at`);
|
||||||
127
e2e/analysis.spec.ts
Normal file
127
e2e/analysis.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { expect, test, type Page, type TestInfo } from '@playwright/test';
|
||||||
|
|
||||||
|
const PASSWORD = 'Sup3rSecure!123';
|
||||||
|
|
||||||
|
function toSlug(value: string) {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 48);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signUp(page: Page, testInfo: TestInfo) {
|
||||||
|
const email = `playwright-analysis-${testInfo.workerIndex}-${toSlug(testInfo.title)}-${Date.now()}@example.com`;
|
||||||
|
|
||||||
|
await page.goto('/auth/signup');
|
||||||
|
await page.locator('input[autocomplete="name"]').fill('Playwright Analysis User');
|
||||||
|
await page.locator('input[autocomplete="email"]').fill(email);
|
||||||
|
await page.locator('input[autocomplete="new-password"]').first().fill(PASSWORD);
|
||||||
|
await page.locator('input[autocomplete="new-password"]').nth(1).fill(PASSWORD);
|
||||||
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Command Center' })).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page).toHaveURL(/\/$/, { timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('shows the overview skeleton while analysis is loading', async ({ page }, testInfo) => {
|
||||||
|
await signUp(page, testInfo);
|
||||||
|
|
||||||
|
await page.route('**/api/analysis/company**', async (route) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 700));
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
analysis: {
|
||||||
|
company: {
|
||||||
|
ticker: 'MSFT',
|
||||||
|
companyName: 'Microsoft Corporation',
|
||||||
|
sector: 'Technology',
|
||||||
|
category: null,
|
||||||
|
tags: [],
|
||||||
|
cik: '0000789019'
|
||||||
|
},
|
||||||
|
quote: 425.12,
|
||||||
|
position: null,
|
||||||
|
priceHistory: [
|
||||||
|
{ date: '2025-01-01T00:00:00.000Z', close: 380 },
|
||||||
|
{ date: '2026-01-01T00:00:00.000Z', close: 425.12 }
|
||||||
|
],
|
||||||
|
benchmarkHistory: [
|
||||||
|
{ date: '2025-01-01T00:00:00.000Z', close: 5000 },
|
||||||
|
{ date: '2026-01-01T00:00:00.000Z', close: 5400 }
|
||||||
|
],
|
||||||
|
financials: [],
|
||||||
|
filings: [],
|
||||||
|
aiReports: [],
|
||||||
|
coverage: null,
|
||||||
|
journalPreview: [],
|
||||||
|
recentAiReports: [],
|
||||||
|
latestFilingSummary: null,
|
||||||
|
keyMetrics: {
|
||||||
|
referenceDate: null,
|
||||||
|
revenue: null,
|
||||||
|
netIncome: null,
|
||||||
|
totalAssets: null,
|
||||||
|
cash: null,
|
||||||
|
debt: null,
|
||||||
|
netMargin: null
|
||||||
|
},
|
||||||
|
companyProfile: {
|
||||||
|
description: 'Microsoft builds cloud and software products worldwide.',
|
||||||
|
exchange: 'NASDAQ',
|
||||||
|
industry: 'Software',
|
||||||
|
country: 'United States',
|
||||||
|
website: 'https://www.microsoft.com',
|
||||||
|
fiscalYearEnd: '06/30',
|
||||||
|
employeeCount: 220000,
|
||||||
|
source: 'sec_derived'
|
||||||
|
},
|
||||||
|
valuationSnapshot: {
|
||||||
|
sharesOutstanding: 7430000000,
|
||||||
|
marketCap: 3150000000000,
|
||||||
|
enterpriseValue: 3200000000000,
|
||||||
|
trailingPe: 35,
|
||||||
|
evToRevenue: 12,
|
||||||
|
evToEbitda: null,
|
||||||
|
source: 'derived'
|
||||||
|
},
|
||||||
|
bullBear: {
|
||||||
|
source: 'memo_fallback',
|
||||||
|
bull: ['Azure and Copilot demand remain durable.'],
|
||||||
|
bear: ['Valuation leaves less room for execution misses.'],
|
||||||
|
updatedAt: '2026-03-13T00:00:00.000Z'
|
||||||
|
},
|
||||||
|
recentDevelopments: {
|
||||||
|
status: 'ready',
|
||||||
|
items: [{
|
||||||
|
id: 'msft-1',
|
||||||
|
kind: '8-K',
|
||||||
|
title: 'Microsoft filed an 8-K',
|
||||||
|
url: 'https://www.sec.gov/Archives/test.htm',
|
||||||
|
source: 'SEC filings',
|
||||||
|
publishedAt: '2026-03-10',
|
||||||
|
summary: 'The company disclosed a current report with updated commercial details.',
|
||||||
|
accessionNumber: '0000000000-26-000001'
|
||||||
|
}],
|
||||||
|
weeklySnapshot: {
|
||||||
|
summary: 'The week centered on filing-driven updates.',
|
||||||
|
highlights: ['An 8-K added current commercial context.'],
|
||||||
|
itemCount: 1,
|
||||||
|
startDate: '2026-03-07',
|
||||||
|
endDate: '2026-03-13',
|
||||||
|
updatedAt: '2026-03-13T00:00:00.000Z',
|
||||||
|
source: 'heuristic'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/analysis?ticker=MSFT');
|
||||||
|
|
||||||
|
await expect(page.getByTestId('analysis-overview-skeleton')).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Microsoft Corporation' })).toBeVisible();
|
||||||
|
await expect(page.getByText('Bull vs Bear')).toBeVisible();
|
||||||
|
});
|
||||||
@@ -559,10 +559,11 @@ export async function getSearchAnswer(input: {
|
|||||||
}, 'Unable to generate cited answer');
|
}, 'Unable to generate cited answer');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCompanyAnalysis(ticker: string) {
|
export async function getCompanyAnalysis(ticker: string, options?: { refresh?: boolean }) {
|
||||||
const result = await client.api.analysis.company.get({
|
const result = await client.api.analysis.company.get({
|
||||||
$query: {
|
$query: {
|
||||||
ticker: ticker.trim().toUpperCase()
|
ticker: ticker.trim().toUpperCase(),
|
||||||
|
...(options?.refresh ? { refresh: 'true' } : {})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ import type {
|
|||||||
ResearchArtifactSource
|
ResearchArtifactSource
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
|
|
||||||
export function companyAnalysisQueryOptions(ticker: string) {
|
export function companyAnalysisQueryOptions(ticker: string, options?: { refresh?: boolean }) {
|
||||||
const normalizedTicker = ticker.trim().toUpperCase();
|
const normalizedTicker = ticker.trim().toUpperCase();
|
||||||
|
|
||||||
return queryOptions({
|
return queryOptions({
|
||||||
queryKey: queryKeys.companyAnalysis(normalizedTicker),
|
queryKey: queryKeys.companyAnalysis(normalizedTicker),
|
||||||
queryFn: () => getCompanyAnalysis(normalizedTicker),
|
queryFn: () => getCompanyAnalysis(normalizedTicker, options),
|
||||||
staleTime: 120_000
|
staleTime: 120_000
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,12 +70,6 @@ import {
|
|||||||
updateWatchlistReviewByTicker,
|
updateWatchlistReviewByTicker,
|
||||||
upsertWatchlistItemRecord
|
upsertWatchlistItemRecord
|
||||||
} from '@/lib/server/repos/watchlist';
|
} from '@/lib/server/repos/watchlist';
|
||||||
import { getPriceHistory, getQuote } from '@/lib/server/prices';
|
|
||||||
import { synthesizeCompanyOverview } from '@/lib/server/company-overview-synthesis';
|
|
||||||
import { getRecentDevelopments } from '@/lib/server/recent-developments';
|
|
||||||
import { deriveValuationSnapshot, getSecCompanyProfile, toCompanyProfile } from '@/lib/server/sec-company-profile';
|
|
||||||
import { getCompanyDescription } from '@/lib/server/sec-description';
|
|
||||||
import { getYahooCompanyDescription } from '@/lib/server/yahoo-company-profile';
|
|
||||||
import { answerSearchQuery, searchKnowledgeBase } from '@/lib/server/search';
|
import { answerSearchQuery, searchKnowledgeBase } from '@/lib/server/search';
|
||||||
import {
|
import {
|
||||||
enqueueTask,
|
enqueueTask,
|
||||||
@@ -86,6 +80,7 @@ import {
|
|||||||
listRecentTasks,
|
listRecentTasks,
|
||||||
updateTaskNotification
|
updateTaskNotification
|
||||||
} from '@/lib/server/tasks';
|
} from '@/lib/server/tasks';
|
||||||
|
import { getCompanyAnalysisPayload } from '@/lib/server/company-analysis';
|
||||||
|
|
||||||
const ALLOWED_STATUSES: TaskStatus[] = ['queued', 'running', 'completed', 'failed'];
|
const ALLOWED_STATUSES: TaskStatus[] = ['queued', 'running', 'completed', 'failed'];
|
||||||
const FINANCIAL_FORMS: ReadonlySet<Filing['filing_type']> = new Set(['10-K', '10-Q']);
|
const FINANCIAL_FORMS: ReadonlySet<Filing['filing_type']> = new Set(['10-K', '10-Q']);
|
||||||
@@ -1390,145 +1385,20 @@ export const app = new Elysia({ prefix: '/api' })
|
|||||||
if (!ticker) {
|
if (!ticker) {
|
||||||
return jsonError('ticker is required');
|
return jsonError('ticker is required');
|
||||||
}
|
}
|
||||||
|
const refresh = asBoolean(query.refresh, false);
|
||||||
const [filings, holding, watchlistItem, liveQuote, priceHistory, benchmarkHistory, journalPreview, memo, secProfile] = await Promise.all([
|
const analysis = await getCompanyAnalysisPayload({
|
||||||
listFilingsRecords({ ticker, limit: 40 }),
|
userId: session.user.id,
|
||||||
getHoldingByTicker(session.user.id, ticker),
|
|
||||||
getWatchlistItemByTicker(session.user.id, ticker),
|
|
||||||
getQuote(ticker),
|
|
||||||
getPriceHistory(ticker),
|
|
||||||
getPriceHistory('^GSPC'),
|
|
||||||
listResearchJournalEntries(session.user.id, ticker, 6),
|
|
||||||
getResearchMemoByTicker(session.user.id, ticker),
|
|
||||||
getSecCompanyProfile(ticker)
|
|
||||||
]);
|
|
||||||
const redactedFilings = filings
|
|
||||||
.map(redactInternalFilingAnalysisFields)
|
|
||||||
.map(withFinancialMetricsPolicy);
|
|
||||||
|
|
||||||
const latestFiling = redactedFilings[0] ?? null;
|
|
||||||
const companyName = latestFiling?.company_name
|
|
||||||
?? secProfile?.companyName
|
|
||||||
?? holding?.company_name
|
|
||||||
?? watchlistItem?.company_name
|
|
||||||
?? ticker;
|
|
||||||
|
|
||||||
const financials = redactedFilings
|
|
||||||
.filter((entry) => entry.metrics && FINANCIAL_FORMS.has(entry.filing_type))
|
|
||||||
.map((entry) => ({
|
|
||||||
filingDate: entry.filing_date,
|
|
||||||
filingType: entry.filing_type,
|
|
||||||
revenue: entry.metrics?.revenue ?? null,
|
|
||||||
netIncome: entry.metrics?.netIncome ?? null,
|
|
||||||
totalAssets: entry.metrics?.totalAssets ?? null,
|
|
||||||
cash: entry.metrics?.cash ?? null,
|
|
||||||
debt: entry.metrics?.debt ?? null
|
|
||||||
}));
|
|
||||||
|
|
||||||
const aiReports = redactedFilings
|
|
||||||
.filter((entry) => entry.analysis?.text || entry.analysis?.legacyInsights)
|
|
||||||
.slice(0, 8)
|
|
||||||
.map((entry) => ({
|
|
||||||
accessionNumber: entry.accession_number,
|
|
||||||
filingDate: entry.filing_date,
|
|
||||||
filingType: entry.filing_type,
|
|
||||||
provider: entry.analysis?.provider ?? 'unknown',
|
|
||||||
model: entry.analysis?.model ?? 'unknown',
|
|
||||||
summary: entry.analysis?.text ?? entry.analysis?.legacyInsights ?? ''
|
|
||||||
}));
|
|
||||||
const latestMetricsFiling = redactedFilings.find((entry) => entry.metrics) ?? null;
|
|
||||||
const referenceMetrics = latestMetricsFiling?.metrics ?? null;
|
|
||||||
const keyMetrics = {
|
|
||||||
referenceDate: latestMetricsFiling?.filing_date ?? latestFiling?.filing_date ?? null,
|
|
||||||
revenue: referenceMetrics?.revenue ?? null,
|
|
||||||
netIncome: referenceMetrics?.netIncome ?? null,
|
|
||||||
totalAssets: referenceMetrics?.totalAssets ?? null,
|
|
||||||
cash: referenceMetrics?.cash ?? null,
|
|
||||||
debt: referenceMetrics?.debt ?? null,
|
|
||||||
netMargin: referenceMetrics?.revenue && referenceMetrics.netIncome !== null
|
|
||||||
? (referenceMetrics.netIncome / referenceMetrics.revenue) * 100
|
|
||||||
: null
|
|
||||||
};
|
|
||||||
const annualFiling = redactedFilings.find((entry) => entry.filing_type === '10-K') ?? null;
|
|
||||||
const [secDescription, yahooDescription, synthesizedDevelopments] = await Promise.all([
|
|
||||||
getCompanyDescription(annualFiling),
|
|
||||||
getYahooCompanyDescription(ticker),
|
|
||||||
getRecentDevelopments(ticker, { filings: redactedFilings })
|
|
||||||
]);
|
|
||||||
const description = yahooDescription ?? secDescription;
|
|
||||||
const latestFilingSummary = latestFiling
|
|
||||||
? {
|
|
||||||
accessionNumber: latestFiling.accession_number,
|
|
||||||
filingDate: latestFiling.filing_date,
|
|
||||||
filingType: latestFiling.filing_type,
|
|
||||||
filingUrl: latestFiling.filing_url,
|
|
||||||
submissionUrl: latestFiling.submission_url ?? null,
|
|
||||||
summary: latestFiling.analysis?.text ?? latestFiling.analysis?.legacyInsights ?? null,
|
|
||||||
hasAnalysis: Boolean(latestFiling.analysis?.text || latestFiling.analysis?.legacyInsights)
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
const companyProfile = toCompanyProfile(secProfile, description);
|
|
||||||
const valuationSnapshot = deriveValuationSnapshot({
|
|
||||||
quote: liveQuote,
|
|
||||||
sharesOutstanding: secProfile?.sharesOutstanding ?? null,
|
|
||||||
revenue: keyMetrics.revenue,
|
|
||||||
cash: keyMetrics.cash,
|
|
||||||
debt: keyMetrics.debt,
|
|
||||||
netIncome: keyMetrics.netIncome
|
|
||||||
});
|
|
||||||
const synthesis = await synthesizeCompanyOverview({
|
|
||||||
ticker,
|
ticker,
|
||||||
companyName,
|
refresh
|
||||||
description,
|
|
||||||
memo,
|
|
||||||
latestFilingSummary,
|
|
||||||
recentAiReports: aiReports.slice(0, 5),
|
|
||||||
recentDevelopments: synthesizedDevelopments.items
|
|
||||||
});
|
});
|
||||||
const recentDevelopments = {
|
|
||||||
...synthesizedDevelopments,
|
|
||||||
weeklySnapshot: synthesis.weeklySnapshot,
|
|
||||||
status: synthesizedDevelopments.items.length > 0
|
|
||||||
? synthesis.weeklySnapshot ? 'ready' : 'partial'
|
|
||||||
: synthesis.weeklySnapshot ? 'partial' : 'unavailable'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
analysis: {
|
analysis
|
||||||
company: {
|
|
||||||
ticker,
|
|
||||||
companyName,
|
|
||||||
sector: watchlistItem?.sector ?? null,
|
|
||||||
category: watchlistItem?.category ?? null,
|
|
||||||
tags: watchlistItem?.tags ?? [],
|
|
||||||
cik: latestFiling?.cik ?? null
|
|
||||||
},
|
|
||||||
quote: liveQuote,
|
|
||||||
position: holding,
|
|
||||||
priceHistory,
|
|
||||||
benchmarkHistory,
|
|
||||||
financials,
|
|
||||||
filings: redactedFilings.slice(0, 20),
|
|
||||||
aiReports,
|
|
||||||
coverage: watchlistItem
|
|
||||||
? {
|
|
||||||
...watchlistItem,
|
|
||||||
latest_filing_date: latestFiling?.filing_date ?? watchlistItem.latest_filing_date ?? null
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
journalPreview,
|
|
||||||
recentAiReports: aiReports.slice(0, 5),
|
|
||||||
latestFilingSummary,
|
|
||||||
keyMetrics,
|
|
||||||
companyProfile,
|
|
||||||
valuationSnapshot,
|
|
||||||
bullBear: synthesis.bullBear,
|
|
||||||
recentDevelopments
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}, {
|
}, {
|
||||||
query: t.Object({
|
query: t.Object({
|
||||||
ticker: t.String({ minLength: 1 })
|
ticker: t.String({ minLength: 1 }),
|
||||||
|
refresh: t.Optional(t.String())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.get('/financials/company', async ({ query }) => {
|
.get('/financials/company', async ({ query }) => {
|
||||||
|
|||||||
@@ -127,7 +127,8 @@ function applySqlMigrations(client: { exec: (query: string) => void }) {
|
|||||||
'0008_research_workspace.sql',
|
'0008_research_workspace.sql',
|
||||||
'0009_task_notification_context.sql',
|
'0009_task_notification_context.sql',
|
||||||
'0010_taxonomy_surface_sidecar.sql',
|
'0010_taxonomy_surface_sidecar.sql',
|
||||||
'0011_remove_legacy_xbrl_defaults.sql'
|
'0011_remove_legacy_xbrl_defaults.sql',
|
||||||
|
'0012_company_overview_cache.sql'
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const file of migrationFiles) {
|
for (const file of migrationFiles) {
|
||||||
@@ -165,6 +166,7 @@ function clearProjectionTables(client: { exec: (query: string) => void }) {
|
|||||||
client.exec('DELETE FROM holding;');
|
client.exec('DELETE FROM holding;');
|
||||||
client.exec('DELETE FROM watchlist_item;');
|
client.exec('DELETE FROM watchlist_item;');
|
||||||
client.exec('DELETE FROM portfolio_insight;');
|
client.exec('DELETE FROM portfolio_insight;');
|
||||||
|
client.exec('DELETE FROM company_overview_cache;');
|
||||||
client.exec('DELETE FROM filing;');
|
client.exec('DELETE FROM filing;');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,6 +248,73 @@ async function jsonRequest(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildCachedAnalysisPayload(input: {
|
||||||
|
ticker: string;
|
||||||
|
companyName: string;
|
||||||
|
bull?: string[];
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
company: {
|
||||||
|
ticker: input.ticker,
|
||||||
|
companyName: input.companyName,
|
||||||
|
sector: null,
|
||||||
|
category: null,
|
||||||
|
tags: [],
|
||||||
|
cik: null
|
||||||
|
},
|
||||||
|
quote: 100,
|
||||||
|
position: null,
|
||||||
|
priceHistory: [],
|
||||||
|
benchmarkHistory: [],
|
||||||
|
financials: [],
|
||||||
|
filings: [],
|
||||||
|
aiReports: [],
|
||||||
|
coverage: null,
|
||||||
|
journalPreview: [],
|
||||||
|
recentAiReports: [],
|
||||||
|
latestFilingSummary: null,
|
||||||
|
keyMetrics: {
|
||||||
|
referenceDate: null,
|
||||||
|
revenue: null,
|
||||||
|
netIncome: null,
|
||||||
|
totalAssets: null,
|
||||||
|
cash: null,
|
||||||
|
debt: null,
|
||||||
|
netMargin: null
|
||||||
|
},
|
||||||
|
companyProfile: {
|
||||||
|
description: null,
|
||||||
|
exchange: null,
|
||||||
|
industry: null,
|
||||||
|
country: null,
|
||||||
|
website: null,
|
||||||
|
fiscalYearEnd: null,
|
||||||
|
employeeCount: null,
|
||||||
|
source: 'unavailable'
|
||||||
|
},
|
||||||
|
valuationSnapshot: {
|
||||||
|
sharesOutstanding: null,
|
||||||
|
marketCap: null,
|
||||||
|
enterpriseValue: null,
|
||||||
|
trailingPe: null,
|
||||||
|
evToRevenue: null,
|
||||||
|
evToEbitda: null,
|
||||||
|
source: 'unavailable'
|
||||||
|
},
|
||||||
|
bullBear: {
|
||||||
|
source: input.bull && input.bull.length > 0 ? 'memo_fallback' : 'unavailable',
|
||||||
|
bull: input.bull ?? [],
|
||||||
|
bear: [],
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
},
|
||||||
|
recentDevelopments: {
|
||||||
|
status: 'unavailable',
|
||||||
|
items: [],
|
||||||
|
weeklySnapshot: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (process.env.RUN_TASK_WORKFLOW_E2E === '1') {
|
if (process.env.RUN_TASK_WORKFLOW_E2E === '1') {
|
||||||
describe('task workflow hybrid migration e2e', () => {
|
describe('task workflow hybrid migration e2e', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -472,7 +541,7 @@ if (process.env.RUN_TASK_WORKFLOW_E2E === '1') {
|
|||||||
ticker: 'NFLX',
|
ticker: 'NFLX',
|
||||||
accessionNumber: '0000000000-26-000777',
|
accessionNumber: '0000000000-26-000777',
|
||||||
filingType: '10-K',
|
filingType: '10-K',
|
||||||
filingDate: '2026-02-15',
|
filingDate: '2026-03-10',
|
||||||
companyName: 'Netflix, Inc.',
|
companyName: 'Netflix, Inc.',
|
||||||
metrics: {
|
metrics: {
|
||||||
revenue: 41000000000,
|
revenue: 41000000000,
|
||||||
@@ -575,6 +644,157 @@ if (process.env.RUN_TASK_WORKFLOW_E2E === '1') {
|
|||||||
}).entries).toHaveLength(0);
|
}).entries).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('serves cached analysis until refresh is requested', async () => {
|
||||||
|
if (!sqliteClient) {
|
||||||
|
throw new Error('sqlite client not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
seedFilingRecord(sqliteClient, {
|
||||||
|
ticker: 'CACH',
|
||||||
|
accessionNumber: '0000000000-26-000901',
|
||||||
|
filingType: '10-K',
|
||||||
|
filingDate: '2026-02-20',
|
||||||
|
companyName: 'Live Corp'
|
||||||
|
});
|
||||||
|
const filingRow = sqliteClient.query(`
|
||||||
|
SELECT created_at, updated_at
|
||||||
|
FROM filing
|
||||||
|
WHERE ticker = 'CACH'
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
`).get() as { created_at: string; updated_at: string } | null;
|
||||||
|
if (!filingRow) {
|
||||||
|
throw new Error('cached filing row not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { __companyAnalysisInternals } = await import('../company-analysis');
|
||||||
|
const sourceSignature = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
|
||||||
|
ticker: 'CACH',
|
||||||
|
localInputs: {
|
||||||
|
filings: [{
|
||||||
|
id: 1,
|
||||||
|
ticker: 'CACH',
|
||||||
|
filing_type: '10-K',
|
||||||
|
filing_date: '2026-02-20',
|
||||||
|
accession_number: '0000000000-26-000901',
|
||||||
|
cik: '0000000000',
|
||||||
|
company_name: 'Live Corp',
|
||||||
|
filing_url: 'https://www.sec.gov/Archives/0000000000-26-000901.htm',
|
||||||
|
submission_url: 'https://www.sec.gov/submissions/0000000000-26-000901.json',
|
||||||
|
primary_document: '0000000000-26-000901.htm',
|
||||||
|
metrics: null,
|
||||||
|
analysis: null,
|
||||||
|
created_at: filingRow.created_at,
|
||||||
|
updated_at: filingRow.updated_at
|
||||||
|
}],
|
||||||
|
holding: null,
|
||||||
|
watchlistItem: null,
|
||||||
|
journalPreview: [],
|
||||||
|
memo: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
sqliteClient.query(`
|
||||||
|
INSERT INTO company_overview_cache (
|
||||||
|
user_id,
|
||||||
|
ticker,
|
||||||
|
cache_version,
|
||||||
|
source_signature,
|
||||||
|
payload,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
TEST_USER_ID,
|
||||||
|
'CACH',
|
||||||
|
1,
|
||||||
|
sourceSignature,
|
||||||
|
JSON.stringify(buildCachedAnalysisPayload({
|
||||||
|
ticker: 'CACH',
|
||||||
|
companyName: 'Cached Corp'
|
||||||
|
})),
|
||||||
|
now,
|
||||||
|
now
|
||||||
|
);
|
||||||
|
|
||||||
|
const cached = await jsonRequest('GET', '/api/analysis/company?ticker=CACH');
|
||||||
|
expect(cached.response.status).toBe(200);
|
||||||
|
expect((cached.json as {
|
||||||
|
analysis: { company: { companyName: string } };
|
||||||
|
}).analysis.company.companyName).toBe('Cached Corp');
|
||||||
|
|
||||||
|
const refreshed = await jsonRequest('GET', '/api/analysis/company?ticker=CACH&refresh=true');
|
||||||
|
expect(refreshed.response.status).toBe(200);
|
||||||
|
expect((refreshed.json as {
|
||||||
|
analysis: { company: { companyName: string } };
|
||||||
|
}).analysis.company.companyName).toBe('Live Corp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates cached analysis when the memo changes', async () => {
|
||||||
|
if (!sqliteClient) {
|
||||||
|
throw new Error('sqlite client not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
seedFilingRecord(sqliteClient, {
|
||||||
|
ticker: 'MEMO',
|
||||||
|
accessionNumber: '0000000000-26-000902',
|
||||||
|
filingType: '10-K',
|
||||||
|
filingDate: '2026-02-20',
|
||||||
|
companyName: 'Memo Corp'
|
||||||
|
});
|
||||||
|
|
||||||
|
sqliteClient.query(`
|
||||||
|
INSERT INTO research_memo (
|
||||||
|
user_id,
|
||||||
|
organization_id,
|
||||||
|
ticker,
|
||||||
|
rating,
|
||||||
|
conviction,
|
||||||
|
time_horizon_months,
|
||||||
|
packet_title,
|
||||||
|
packet_subtitle,
|
||||||
|
thesis_markdown,
|
||||||
|
variant_view_markdown,
|
||||||
|
catalysts_markdown,
|
||||||
|
risks_markdown,
|
||||||
|
disconfirming_evidence_markdown,
|
||||||
|
next_actions_markdown,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
) VALUES (?, NULL, ?, 'buy', 'high', 24, NULL, NULL, ?, '', '', '', '', '', ?, ?)
|
||||||
|
`).run(
|
||||||
|
TEST_USER_ID,
|
||||||
|
'MEMO',
|
||||||
|
'Legacy thesis still holds.',
|
||||||
|
'2026-03-13T00:00:00.000Z',
|
||||||
|
'2026-03-13T00:00:00.000Z'
|
||||||
|
);
|
||||||
|
|
||||||
|
const first = await jsonRequest('GET', '/api/analysis/company?ticker=MEMO');
|
||||||
|
expect(first.response.status).toBe(200);
|
||||||
|
expect((first.json as {
|
||||||
|
analysis: { bullBear: { bull: string[] } };
|
||||||
|
}).analysis.bullBear.bull.join(' ')).toContain('Legacy thesis');
|
||||||
|
|
||||||
|
sqliteClient.query(`
|
||||||
|
UPDATE research_memo
|
||||||
|
SET thesis_markdown = ?, updated_at = ?
|
||||||
|
WHERE user_id = ? AND ticker = ?
|
||||||
|
`).run(
|
||||||
|
'Updated thesis drives the next refresh.',
|
||||||
|
'2026-03-13T01:00:00.000Z',
|
||||||
|
TEST_USER_ID,
|
||||||
|
'MEMO'
|
||||||
|
);
|
||||||
|
|
||||||
|
const second = await jsonRequest('GET', '/api/analysis/company?ticker=MEMO');
|
||||||
|
expect(second.response.status).toBe(200);
|
||||||
|
expect((second.json as {
|
||||||
|
analysis: { bullBear: { bull: string[] } };
|
||||||
|
}).analysis.bullBear.bull.join(' ')).toContain('Updated thesis');
|
||||||
|
});
|
||||||
|
|
||||||
it('persists nullable holding company names and allows later enrichment', async () => {
|
it('persists nullable holding company names and allows later enrichment', async () => {
|
||||||
const created = await jsonRequest('POST', '/api/portfolio/holdings', {
|
const created = await jsonRequest('POST', '/api/portfolio/holdings', {
|
||||||
ticker: 'ORCL',
|
ticker: 'ORCL',
|
||||||
|
|||||||
251
lib/server/company-analysis.test.ts
Normal file
251
lib/server/company-analysis.test.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import { describe, expect, it, mock } from 'bun:test';
|
||||||
|
import type {
|
||||||
|
CompanyAnalysis,
|
||||||
|
Filing,
|
||||||
|
ResearchMemo
|
||||||
|
} from '@/lib/types';
|
||||||
|
import {
|
||||||
|
__companyAnalysisInternals,
|
||||||
|
getCompanyAnalysisPayload
|
||||||
|
} from './company-analysis';
|
||||||
|
|
||||||
|
function buildFiling(updatedAt = '2026-03-10T00:00:00.000Z'): Filing {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
ticker: 'MSFT',
|
||||||
|
filing_type: '10-K',
|
||||||
|
filing_date: '2026-02-01',
|
||||||
|
accession_number: '0000000000-26-000001',
|
||||||
|
cik: '0000789019',
|
||||||
|
company_name: 'Microsoft Corporation',
|
||||||
|
filing_url: 'https://www.sec.gov/Archives/test.htm',
|
||||||
|
submission_url: 'https://www.sec.gov/submissions/test.json',
|
||||||
|
primary_document: 'test.htm',
|
||||||
|
metrics: null,
|
||||||
|
analysis: null,
|
||||||
|
created_at: updatedAt,
|
||||||
|
updated_at: updatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMemo(updatedAt = '2026-03-10T00:00:00.000Z', thesis = 'Azure remains durable.'): ResearchMemo {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
user_id: 'user-1',
|
||||||
|
organization_id: null,
|
||||||
|
ticker: 'MSFT',
|
||||||
|
rating: 'buy',
|
||||||
|
conviction: 'high',
|
||||||
|
time_horizon_months: 24,
|
||||||
|
packet_title: null,
|
||||||
|
packet_subtitle: null,
|
||||||
|
thesis_markdown: thesis,
|
||||||
|
variant_view_markdown: '',
|
||||||
|
catalysts_markdown: '',
|
||||||
|
risks_markdown: '',
|
||||||
|
disconfirming_evidence_markdown: '',
|
||||||
|
next_actions_markdown: '',
|
||||||
|
created_at: updatedAt,
|
||||||
|
updated_at: updatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLocalInputs(overrides: Partial<Parameters<typeof __companyAnalysisInternals.buildCompanyAnalysisSourceSignature>[0]['localInputs']> = {}) {
|
||||||
|
return {
|
||||||
|
filings: [buildFiling()],
|
||||||
|
holding: null,
|
||||||
|
watchlistItem: null,
|
||||||
|
journalPreview: [],
|
||||||
|
memo: null,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAnalysisPayload(companyName: string): CompanyAnalysis {
|
||||||
|
return {
|
||||||
|
company: {
|
||||||
|
ticker: 'MSFT',
|
||||||
|
companyName,
|
||||||
|
sector: null,
|
||||||
|
category: null,
|
||||||
|
tags: [],
|
||||||
|
cik: null
|
||||||
|
},
|
||||||
|
quote: 100,
|
||||||
|
position: null,
|
||||||
|
priceHistory: [],
|
||||||
|
benchmarkHistory: [],
|
||||||
|
financials: [],
|
||||||
|
filings: [],
|
||||||
|
aiReports: [],
|
||||||
|
coverage: null,
|
||||||
|
journalPreview: [],
|
||||||
|
recentAiReports: [],
|
||||||
|
latestFilingSummary: null,
|
||||||
|
keyMetrics: {
|
||||||
|
referenceDate: null,
|
||||||
|
revenue: null,
|
||||||
|
netIncome: null,
|
||||||
|
totalAssets: null,
|
||||||
|
cash: null,
|
||||||
|
debt: null,
|
||||||
|
netMargin: null
|
||||||
|
},
|
||||||
|
companyProfile: {
|
||||||
|
description: null,
|
||||||
|
exchange: null,
|
||||||
|
industry: null,
|
||||||
|
country: null,
|
||||||
|
website: null,
|
||||||
|
fiscalYearEnd: null,
|
||||||
|
employeeCount: null,
|
||||||
|
source: 'unavailable'
|
||||||
|
},
|
||||||
|
valuationSnapshot: {
|
||||||
|
sharesOutstanding: null,
|
||||||
|
marketCap: null,
|
||||||
|
enterpriseValue: null,
|
||||||
|
trailingPe: null,
|
||||||
|
evToRevenue: null,
|
||||||
|
evToEbitda: null,
|
||||||
|
source: 'unavailable'
|
||||||
|
},
|
||||||
|
bullBear: {
|
||||||
|
source: 'unavailable',
|
||||||
|
bull: [],
|
||||||
|
bear: [],
|
||||||
|
updatedAt: null
|
||||||
|
},
|
||||||
|
recentDevelopments: {
|
||||||
|
status: 'unavailable',
|
||||||
|
items: [],
|
||||||
|
weeklySnapshot: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCacheRecord(payload: CompanyAnalysis, sourceSignature: string) {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
user_id: 'user-1',
|
||||||
|
ticker: 'MSFT',
|
||||||
|
cache_version: 1,
|
||||||
|
source_signature: sourceSignature,
|
||||||
|
payload,
|
||||||
|
created_at: '2026-03-13T11:55:00.000Z',
|
||||||
|
updated_at: '2026-03-13T11:55:00.000Z'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('company analysis cache orchestration', () => {
|
||||||
|
it('changes the source signature when local inputs change', () => {
|
||||||
|
const base = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
|
||||||
|
ticker: 'MSFT',
|
||||||
|
localInputs: buildLocalInputs()
|
||||||
|
});
|
||||||
|
const memoChanged = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
|
||||||
|
ticker: 'MSFT',
|
||||||
|
localInputs: buildLocalInputs({ memo: buildMemo('2026-03-11T00:00:00.000Z') })
|
||||||
|
});
|
||||||
|
const filingChanged = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
|
||||||
|
ticker: 'MSFT',
|
||||||
|
localInputs: buildLocalInputs({ filings: [buildFiling('2026-03-11T00:00:00.000Z')] })
|
||||||
|
});
|
||||||
|
const journalChanged = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
|
||||||
|
ticker: 'MSFT',
|
||||||
|
localInputs: buildLocalInputs({
|
||||||
|
journalPreview: [{
|
||||||
|
id: 7,
|
||||||
|
user_id: 'user-1',
|
||||||
|
ticker: 'MSFT',
|
||||||
|
accession_number: null,
|
||||||
|
entry_type: 'note',
|
||||||
|
title: 'Updated note',
|
||||||
|
body_markdown: 'Body',
|
||||||
|
metadata: null,
|
||||||
|
created_at: '2026-03-01T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-11T00:00:00.000Z'
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(memoChanged).not.toBe(base);
|
||||||
|
expect(filingChanged).not.toBe(base);
|
||||||
|
expect(journalChanged).not.toBe(base);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a fresh cached payload when signature and ttl match', async () => {
|
||||||
|
const localInputs = buildLocalInputs();
|
||||||
|
const sourceSignature = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
|
||||||
|
ticker: 'MSFT',
|
||||||
|
localInputs
|
||||||
|
});
|
||||||
|
const buildOverview = mock(async () => buildAnalysisPayload('Built Corp'));
|
||||||
|
const upsertCachedOverview = mock(async () => buildCacheRecord(buildAnalysisPayload('Cached Corp'), sourceSignature));
|
||||||
|
|
||||||
|
const analysis = await getCompanyAnalysisPayload({
|
||||||
|
userId: 'user-1',
|
||||||
|
ticker: 'MSFT',
|
||||||
|
now: new Date('2026-03-13T12:00:00.000Z')
|
||||||
|
}, {
|
||||||
|
getLocalInputs: async () => localInputs,
|
||||||
|
getCachedOverview: async () => buildCacheRecord(buildAnalysisPayload('Cached Corp'), sourceSignature),
|
||||||
|
buildOverview,
|
||||||
|
upsertCachedOverview
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(analysis.company.companyName).toBe('Cached Corp');
|
||||||
|
expect(buildOverview).not.toHaveBeenCalled();
|
||||||
|
expect(upsertCachedOverview).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bypasses the cache when refresh is requested', async () => {
|
||||||
|
const localInputs = buildLocalInputs();
|
||||||
|
const sourceSignature = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
|
||||||
|
ticker: 'MSFT',
|
||||||
|
localInputs
|
||||||
|
});
|
||||||
|
const buildOverview = mock(async () => buildAnalysisPayload('Refreshed Corp'));
|
||||||
|
const upsertCachedOverview = mock(async () => buildCacheRecord(buildAnalysisPayload('Refreshed Corp'), sourceSignature));
|
||||||
|
|
||||||
|
const analysis = await getCompanyAnalysisPayload({
|
||||||
|
userId: 'user-1',
|
||||||
|
ticker: 'MSFT',
|
||||||
|
refresh: true,
|
||||||
|
now: new Date('2026-03-13T12:00:00.000Z')
|
||||||
|
}, {
|
||||||
|
getLocalInputs: async () => localInputs,
|
||||||
|
getCachedOverview: async () => buildCacheRecord(buildAnalysisPayload('Cached Corp'), sourceSignature),
|
||||||
|
buildOverview,
|
||||||
|
upsertCachedOverview
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(analysis.company.companyName).toBe('Refreshed Corp');
|
||||||
|
expect(buildOverview).toHaveBeenCalledTimes(1);
|
||||||
|
expect(upsertCachedOverview).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rebuilds when local inputs invalidate the cached signature', async () => {
|
||||||
|
const cachedInputs = buildLocalInputs({ memo: buildMemo('2026-03-10T00:00:00.000Z', 'Old memo') });
|
||||||
|
const freshInputs = buildLocalInputs({ memo: buildMemo('2026-03-11T00:00:00.000Z', 'New memo') });
|
||||||
|
const staleSignature = __companyAnalysisInternals.buildCompanyAnalysisSourceSignature({
|
||||||
|
ticker: 'MSFT',
|
||||||
|
localInputs: cachedInputs
|
||||||
|
});
|
||||||
|
const buildOverview = mock(async () => buildAnalysisPayload('Rebuilt Corp'));
|
||||||
|
|
||||||
|
const analysis = await getCompanyAnalysisPayload({
|
||||||
|
userId: 'user-1',
|
||||||
|
ticker: 'MSFT',
|
||||||
|
now: new Date('2026-03-13T12:00:00.000Z')
|
||||||
|
}, {
|
||||||
|
getLocalInputs: async () => freshInputs,
|
||||||
|
getCachedOverview: async () => buildCacheRecord(buildAnalysisPayload('Cached Corp'), staleSignature),
|
||||||
|
buildOverview,
|
||||||
|
upsertCachedOverview: async () => buildCacheRecord(buildAnalysisPayload('Rebuilt Corp'), staleSignature)
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(analysis.company.companyName).toBe('Rebuilt Corp');
|
||||||
|
expect(buildOverview).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
336
lib/server/company-analysis.ts
Normal file
336
lib/server/company-analysis.ts
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import type {
|
||||||
|
CompanyAiReport,
|
||||||
|
CompanyAnalysis,
|
||||||
|
Filing,
|
||||||
|
Holding,
|
||||||
|
ResearchJournalEntry,
|
||||||
|
ResearchMemo,
|
||||||
|
WatchlistItem
|
||||||
|
} from '@/lib/types';
|
||||||
|
import { synthesizeCompanyOverview } from '@/lib/server/company-overview-synthesis';
|
||||||
|
import { getPriceHistory, getQuote } from '@/lib/server/prices';
|
||||||
|
import { getRecentDevelopments } from '@/lib/server/recent-developments';
|
||||||
|
import { deriveValuationSnapshot, getSecCompanyProfile, toCompanyProfile } from '@/lib/server/sec-company-profile';
|
||||||
|
import { getCompanyDescription } from '@/lib/server/sec-description';
|
||||||
|
import { getYahooCompanyDescription } from '@/lib/server/yahoo-company-profile';
|
||||||
|
import { redactInternalFilingAnalysisFields } from '@/lib/server/api/filing-redaction';
|
||||||
|
import { listFilingsRecords } from '@/lib/server/repos/filings';
|
||||||
|
import { getHoldingByTicker } from '@/lib/server/repos/holdings';
|
||||||
|
import { getResearchMemoByTicker } from '@/lib/server/repos/research-library';
|
||||||
|
import { listResearchJournalEntries } from '@/lib/server/repos/research-journal';
|
||||||
|
import { getWatchlistItemByTicker } from '@/lib/server/repos/watchlist';
|
||||||
|
import {
|
||||||
|
CURRENT_COMPANY_OVERVIEW_CACHE_VERSION,
|
||||||
|
getCompanyOverviewCache,
|
||||||
|
upsertCompanyOverviewCache
|
||||||
|
} from '@/lib/server/repos/company-overview-cache';
|
||||||
|
|
||||||
|
const FINANCIAL_FORMS = new Set<Filing['filing_type']>(['10-K', '10-Q']);
|
||||||
|
const COMPANY_OVERVIEW_CACHE_TTL_MS = 1000 * 60 * 15;
|
||||||
|
|
||||||
|
export type CompanyAnalysisLocalInputs = {
|
||||||
|
filings: Filing[];
|
||||||
|
holding: Holding | null;
|
||||||
|
watchlistItem: WatchlistItem | null;
|
||||||
|
journalPreview: ResearchJournalEntry[];
|
||||||
|
memo: ResearchMemo | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function withFinancialMetricsPolicy(filing: Filing): Filing {
|
||||||
|
if (FINANCIAL_FORMS.has(filing.filing_type)) {
|
||||||
|
return filing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...filing,
|
||||||
|
metrics: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCompanyAnalysisSourceSignature(input: {
|
||||||
|
ticker: string;
|
||||||
|
localInputs: CompanyAnalysisLocalInputs;
|
||||||
|
cacheVersion?: number;
|
||||||
|
}) {
|
||||||
|
const payload = {
|
||||||
|
ticker: input.ticker.trim().toUpperCase(),
|
||||||
|
cacheVersion: input.cacheVersion ?? CURRENT_COMPANY_OVERVIEW_CACHE_VERSION,
|
||||||
|
filings: input.localInputs.filings.map((filing) => ({
|
||||||
|
accessionNumber: filing.accession_number,
|
||||||
|
updatedAt: filing.updated_at
|
||||||
|
})),
|
||||||
|
memoUpdatedAt: input.localInputs.memo?.updated_at ?? null,
|
||||||
|
watchlistUpdatedAt: input.localInputs.watchlistItem?.updated_at ?? null,
|
||||||
|
holdingUpdatedAt: input.localInputs.holding?.updated_at ?? null,
|
||||||
|
journalPreview: input.localInputs.journalPreview.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
updatedAt: entry.updated_at
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
return createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCompanyOverviewCacheFresh(input: {
|
||||||
|
updatedAt: string;
|
||||||
|
sourceSignature: string;
|
||||||
|
expectedSourceSignature: string;
|
||||||
|
cacheVersion: number;
|
||||||
|
refresh?: boolean;
|
||||||
|
ttlMs?: number;
|
||||||
|
now?: Date;
|
||||||
|
}) {
|
||||||
|
if (input.refresh) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.cacheVersion !== CURRENT_COMPANY_OVERVIEW_CACHE_VERSION) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.sourceSignature !== input.expectedSourceSignature) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = input.now ?? new Date();
|
||||||
|
const updatedAt = Date.parse(input.updatedAt);
|
||||||
|
if (!Number.isFinite(updatedAt)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return now.getTime() - updatedAt <= (input.ttlMs ?? COMPANY_OVERVIEW_CACHE_TTL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCompanyAnalysisLocalInputs(input: { userId: string; ticker: string }): Promise<CompanyAnalysisLocalInputs> {
|
||||||
|
const ticker = input.ticker.trim().toUpperCase();
|
||||||
|
|
||||||
|
const [filings, holding, watchlistItem, journalPreview, memo] = await Promise.all([
|
||||||
|
listFilingsRecords({ ticker, limit: 40 }),
|
||||||
|
getHoldingByTicker(input.userId, ticker),
|
||||||
|
getWatchlistItemByTicker(input.userId, ticker),
|
||||||
|
listResearchJournalEntries(input.userId, ticker, 6),
|
||||||
|
getResearchMemoByTicker(input.userId, ticker)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
filings,
|
||||||
|
holding,
|
||||||
|
watchlistItem,
|
||||||
|
journalPreview,
|
||||||
|
memo
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildCompanyAnalysisPayload(input: {
|
||||||
|
userId: string;
|
||||||
|
ticker: string;
|
||||||
|
localInputs: CompanyAnalysisLocalInputs;
|
||||||
|
}): Promise<CompanyAnalysis> {
|
||||||
|
const ticker = input.ticker.trim().toUpperCase();
|
||||||
|
const redactedFilings = input.localInputs.filings
|
||||||
|
.map(redactInternalFilingAnalysisFields)
|
||||||
|
.map(withFinancialMetricsPolicy);
|
||||||
|
|
||||||
|
const [liveQuote, priceHistory, benchmarkHistory, secProfile] = await Promise.all([
|
||||||
|
getQuote(ticker),
|
||||||
|
getPriceHistory(ticker),
|
||||||
|
getPriceHistory('^GSPC'),
|
||||||
|
getSecCompanyProfile(ticker)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const latestFiling = redactedFilings[0] ?? null;
|
||||||
|
const companyName = latestFiling?.company_name
|
||||||
|
?? secProfile?.companyName
|
||||||
|
?? input.localInputs.holding?.company_name
|
||||||
|
?? input.localInputs.watchlistItem?.company_name
|
||||||
|
?? ticker;
|
||||||
|
|
||||||
|
const financials = redactedFilings
|
||||||
|
.filter((entry) => entry.metrics && FINANCIAL_FORMS.has(entry.filing_type))
|
||||||
|
.map((entry) => ({
|
||||||
|
filingDate: entry.filing_date,
|
||||||
|
filingType: entry.filing_type,
|
||||||
|
revenue: entry.metrics?.revenue ?? null,
|
||||||
|
netIncome: entry.metrics?.netIncome ?? null,
|
||||||
|
totalAssets: entry.metrics?.totalAssets ?? null,
|
||||||
|
cash: entry.metrics?.cash ?? null,
|
||||||
|
debt: entry.metrics?.debt ?? null
|
||||||
|
}));
|
||||||
|
|
||||||
|
const aiReports: CompanyAiReport[] = redactedFilings
|
||||||
|
.filter((entry) => entry.analysis?.text || entry.analysis?.legacyInsights)
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((entry) => ({
|
||||||
|
accessionNumber: entry.accession_number,
|
||||||
|
filingDate: entry.filing_date,
|
||||||
|
filingType: entry.filing_type,
|
||||||
|
provider: entry.analysis?.provider ?? 'unknown',
|
||||||
|
model: entry.analysis?.model ?? 'unknown',
|
||||||
|
summary: entry.analysis?.text ?? entry.analysis?.legacyInsights ?? ''
|
||||||
|
}));
|
||||||
|
|
||||||
|
const latestMetricsFiling = redactedFilings.find((entry) => entry.metrics) ?? null;
|
||||||
|
const referenceMetrics = latestMetricsFiling?.metrics ?? null;
|
||||||
|
const keyMetrics = {
|
||||||
|
referenceDate: latestMetricsFiling?.filing_date ?? latestFiling?.filing_date ?? null,
|
||||||
|
revenue: referenceMetrics?.revenue ?? null,
|
||||||
|
netIncome: referenceMetrics?.netIncome ?? null,
|
||||||
|
totalAssets: referenceMetrics?.totalAssets ?? null,
|
||||||
|
cash: referenceMetrics?.cash ?? null,
|
||||||
|
debt: referenceMetrics?.debt ?? null,
|
||||||
|
netMargin: referenceMetrics?.revenue && referenceMetrics.netIncome !== null
|
||||||
|
? (referenceMetrics.netIncome / referenceMetrics.revenue) * 100
|
||||||
|
: null
|
||||||
|
};
|
||||||
|
|
||||||
|
const annualFiling = redactedFilings.find((entry) => entry.filing_type === '10-K') ?? null;
|
||||||
|
const [secDescription, yahooDescription, synthesizedDevelopments] = await Promise.all([
|
||||||
|
getCompanyDescription(annualFiling),
|
||||||
|
getYahooCompanyDescription(ticker),
|
||||||
|
getRecentDevelopments(ticker, { filings: redactedFilings })
|
||||||
|
]);
|
||||||
|
|
||||||
|
const description = yahooDescription ?? secDescription;
|
||||||
|
const latestFilingSummary = latestFiling
|
||||||
|
? {
|
||||||
|
accessionNumber: latestFiling.accession_number,
|
||||||
|
filingDate: latestFiling.filing_date,
|
||||||
|
filingType: latestFiling.filing_type,
|
||||||
|
filingUrl: latestFiling.filing_url,
|
||||||
|
submissionUrl: latestFiling.submission_url ?? null,
|
||||||
|
summary: latestFiling.analysis?.text ?? latestFiling.analysis?.legacyInsights ?? null,
|
||||||
|
hasAnalysis: Boolean(latestFiling.analysis?.text || latestFiling.analysis?.legacyInsights)
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
const companyProfile = toCompanyProfile(secProfile, description);
|
||||||
|
const valuationSnapshot = deriveValuationSnapshot({
|
||||||
|
quote: liveQuote,
|
||||||
|
sharesOutstanding: secProfile?.sharesOutstanding ?? null,
|
||||||
|
revenue: keyMetrics.revenue,
|
||||||
|
cash: keyMetrics.cash,
|
||||||
|
debt: keyMetrics.debt,
|
||||||
|
netIncome: keyMetrics.netIncome
|
||||||
|
});
|
||||||
|
const synthesis = await synthesizeCompanyOverview({
|
||||||
|
ticker,
|
||||||
|
companyName,
|
||||||
|
description,
|
||||||
|
memo: input.localInputs.memo,
|
||||||
|
latestFilingSummary,
|
||||||
|
recentAiReports: aiReports.slice(0, 5),
|
||||||
|
recentDevelopments: synthesizedDevelopments.items
|
||||||
|
});
|
||||||
|
const recentDevelopments = {
|
||||||
|
...synthesizedDevelopments,
|
||||||
|
weeklySnapshot: synthesis.weeklySnapshot,
|
||||||
|
status: synthesizedDevelopments.items.length > 0
|
||||||
|
? synthesis.weeklySnapshot ? 'ready' : 'partial'
|
||||||
|
: synthesis.weeklySnapshot ? 'partial' : 'unavailable'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
return {
|
||||||
|
company: {
|
||||||
|
ticker,
|
||||||
|
companyName,
|
||||||
|
sector: input.localInputs.watchlistItem?.sector ?? null,
|
||||||
|
category: input.localInputs.watchlistItem?.category ?? null,
|
||||||
|
tags: input.localInputs.watchlistItem?.tags ?? [],
|
||||||
|
cik: latestFiling?.cik ?? null
|
||||||
|
},
|
||||||
|
quote: liveQuote,
|
||||||
|
position: input.localInputs.holding,
|
||||||
|
priceHistory,
|
||||||
|
benchmarkHistory,
|
||||||
|
financials,
|
||||||
|
filings: redactedFilings.slice(0, 20),
|
||||||
|
aiReports,
|
||||||
|
coverage: input.localInputs.watchlistItem
|
||||||
|
? {
|
||||||
|
...input.localInputs.watchlistItem,
|
||||||
|
latest_filing_date: latestFiling?.filing_date ?? input.localInputs.watchlistItem.latest_filing_date ?? null
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
journalPreview: input.localInputs.journalPreview,
|
||||||
|
recentAiReports: aiReports.slice(0, 5),
|
||||||
|
latestFilingSummary,
|
||||||
|
keyMetrics,
|
||||||
|
companyProfile,
|
||||||
|
valuationSnapshot,
|
||||||
|
bullBear: synthesis.bullBear,
|
||||||
|
recentDevelopments
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetCompanyAnalysisPayloadOptions = {
|
||||||
|
userId: string;
|
||||||
|
ticker: string;
|
||||||
|
refresh?: boolean;
|
||||||
|
now?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetCompanyAnalysisPayloadDeps = {
|
||||||
|
getLocalInputs?: (input: { userId: string; ticker: string }) => Promise<CompanyAnalysisLocalInputs>;
|
||||||
|
getCachedOverview?: typeof getCompanyOverviewCache;
|
||||||
|
upsertCachedOverview?: typeof upsertCompanyOverviewCache;
|
||||||
|
buildOverview?: (input: {
|
||||||
|
userId: string;
|
||||||
|
ticker: string;
|
||||||
|
localInputs: CompanyAnalysisLocalInputs;
|
||||||
|
}) => Promise<CompanyAnalysis>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getCompanyAnalysisPayload(
|
||||||
|
input: GetCompanyAnalysisPayloadOptions,
|
||||||
|
deps?: GetCompanyAnalysisPayloadDeps
|
||||||
|
): Promise<CompanyAnalysis> {
|
||||||
|
const ticker = input.ticker.trim().toUpperCase();
|
||||||
|
const now = input.now ?? new Date();
|
||||||
|
const localInputs = await (deps?.getLocalInputs ?? getCompanyAnalysisLocalInputs)({
|
||||||
|
userId: input.userId,
|
||||||
|
ticker
|
||||||
|
});
|
||||||
|
const sourceSignature = buildCompanyAnalysisSourceSignature({
|
||||||
|
ticker,
|
||||||
|
localInputs
|
||||||
|
});
|
||||||
|
const cached = await (deps?.getCachedOverview ?? getCompanyOverviewCache)({
|
||||||
|
userId: input.userId,
|
||||||
|
ticker
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cached && isCompanyOverviewCacheFresh({
|
||||||
|
updatedAt: cached.updated_at,
|
||||||
|
sourceSignature: cached.source_signature,
|
||||||
|
expectedSourceSignature: sourceSignature,
|
||||||
|
cacheVersion: cached.cache_version,
|
||||||
|
refresh: input.refresh,
|
||||||
|
now
|
||||||
|
})) {
|
||||||
|
return cached.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
const analysis = await (deps?.buildOverview ?? buildCompanyAnalysisPayload)({
|
||||||
|
userId: input.userId,
|
||||||
|
ticker,
|
||||||
|
localInputs
|
||||||
|
});
|
||||||
|
|
||||||
|
await (deps?.upsertCachedOverview ?? upsertCompanyOverviewCache)({
|
||||||
|
userId: input.userId,
|
||||||
|
ticker,
|
||||||
|
sourceSignature,
|
||||||
|
payload: analysis
|
||||||
|
});
|
||||||
|
|
||||||
|
return analysis;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const __companyAnalysisInternals = {
|
||||||
|
COMPANY_OVERVIEW_CACHE_TTL_MS,
|
||||||
|
buildCompanyAnalysisPayload,
|
||||||
|
buildCompanyAnalysisSourceSignature,
|
||||||
|
getCompanyAnalysisLocalInputs,
|
||||||
|
isCompanyOverviewCacheFresh,
|
||||||
|
withFinancialMetricsPolicy
|
||||||
|
};
|
||||||
@@ -47,6 +47,7 @@ describe('sqlite schema compatibility bootstrap', () => {
|
|||||||
expect(__dbInternals.hasTable(client, 'research_artifact')).toBe(true);
|
expect(__dbInternals.hasTable(client, 'research_artifact')).toBe(true);
|
||||||
expect(__dbInternals.hasTable(client, 'research_memo')).toBe(true);
|
expect(__dbInternals.hasTable(client, 'research_memo')).toBe(true);
|
||||||
expect(__dbInternals.hasTable(client, 'research_memo_evidence')).toBe(true);
|
expect(__dbInternals.hasTable(client, 'research_memo_evidence')).toBe(true);
|
||||||
|
expect(__dbInternals.hasTable(client, 'company_overview_cache')).toBe(true);
|
||||||
|
|
||||||
__dbInternals.loadSqliteExtensions(client);
|
__dbInternals.loadSqliteExtensions(client);
|
||||||
__dbInternals.ensureSearchVirtualTables(client);
|
__dbInternals.ensureSearchVirtualTables(client);
|
||||||
|
|||||||
@@ -471,6 +471,10 @@ function ensureLocalSqliteSchema(client: Database) {
|
|||||||
applySqlFile(client, '0007_company_financial_bundles.sql');
|
applySqlFile(client, '0007_company_financial_bundles.sql');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasTable(client, 'company_overview_cache')) {
|
||||||
|
applySqlFile(client, '0012_company_overview_cache.sql');
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasTable(client, 'research_journal_entry')) {
|
if (!hasTable(client, 'research_journal_entry')) {
|
||||||
client.exec(`
|
client.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS \`research_journal_entry\` (
|
CREATE TABLE IF NOT EXISTS \`research_journal_entry\` (
|
||||||
|
|||||||
@@ -607,6 +607,20 @@ export const companyFinancialBundle = sqliteTable('company_financial_bundle', {
|
|||||||
companyFinancialBundleTickerIndex: index('company_financial_bundle_ticker_idx').on(table.ticker, table.updated_at)
|
companyFinancialBundleTickerIndex: index('company_financial_bundle_ticker_idx').on(table.ticker, table.updated_at)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const companyOverviewCache = sqliteTable('company_overview_cache', {
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
ticker: text('ticker').notNull(),
|
||||||
|
cache_version: integer('cache_version').notNull(),
|
||||||
|
source_signature: text('source_signature').notNull(),
|
||||||
|
payload: text('payload', { mode: 'json' }).$type<Record<string, unknown>>().notNull(),
|
||||||
|
created_at: text('created_at').notNull(),
|
||||||
|
updated_at: text('updated_at').notNull()
|
||||||
|
}, (table) => ({
|
||||||
|
companyOverviewCacheUnique: uniqueIndex('company_overview_cache_uidx').on(table.user_id, table.ticker),
|
||||||
|
companyOverviewCacheLookupIndex: index('company_overview_cache_lookup_idx').on(table.user_id, table.ticker, table.updated_at)
|
||||||
|
}));
|
||||||
|
|
||||||
export const filingLink = sqliteTable('filing_link', {
|
export const filingLink = sqliteTable('filing_link', {
|
||||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
filing_id: integer('filing_id').notNull().references(() => filing.id, { onDelete: 'cascade' }),
|
filing_id: integer('filing_id').notNull().references(() => filing.id, { onDelete: 'cascade' }),
|
||||||
@@ -831,6 +845,7 @@ export const appSchema = {
|
|||||||
filingTaxonomyFact,
|
filingTaxonomyFact,
|
||||||
filingTaxonomyMetricValidation,
|
filingTaxonomyMetricValidation,
|
||||||
companyFinancialBundle,
|
companyFinancialBundle,
|
||||||
|
companyOverviewCache,
|
||||||
filingLink,
|
filingLink,
|
||||||
taskRun,
|
taskRun,
|
||||||
taskStageEvent,
|
taskStageEvent,
|
||||||
|
|||||||
64
lib/server/prices.test.ts
Normal file
64
lib/server/prices.test.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||||
|
import { __pricesInternals, getPriceHistory, getQuote } from './prices';
|
||||||
|
|
||||||
|
describe('price caching', () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
__pricesInternals.resetCaches();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
__pricesInternals.resetCaches();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses the cached quote within the ttl window', async () => {
|
||||||
|
const fetchMock = mock(async () => Response.json({
|
||||||
|
chart: {
|
||||||
|
result: [
|
||||||
|
{
|
||||||
|
meta: {
|
||||||
|
regularMarketPrice: 123.45
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})) as unknown as typeof fetch;
|
||||||
|
globalThis.fetch = fetchMock;
|
||||||
|
|
||||||
|
const first = await getQuote('MSFT');
|
||||||
|
const second = await getQuote('MSFT');
|
||||||
|
|
||||||
|
expect(first).toBe(123.45);
|
||||||
|
expect(second).toBe(123.45);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses cached price history within the ttl window', async () => {
|
||||||
|
const fetchMock = mock(async () => Response.json({
|
||||||
|
chart: {
|
||||||
|
result: [
|
||||||
|
{
|
||||||
|
timestamp: [1735689600, 1736294400],
|
||||||
|
indicators: {
|
||||||
|
quote: [
|
||||||
|
{
|
||||||
|
close: [100, 105]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})) as unknown as typeof fetch;
|
||||||
|
globalThis.fetch = fetchMock;
|
||||||
|
|
||||||
|
const first = await getPriceHistory('MSFT');
|
||||||
|
const second = await getPriceHistory('MSFT');
|
||||||
|
|
||||||
|
expect(first).toHaveLength(2);
|
||||||
|
expect(second).toEqual(first);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,14 @@
|
|||||||
const YAHOO_BASE = 'https://query1.finance.yahoo.com/v8/finance/chart';
|
const YAHOO_BASE = 'https://query1.finance.yahoo.com/v8/finance/chart';
|
||||||
|
const QUOTE_CACHE_TTL_MS = 1000 * 60;
|
||||||
|
const PRICE_HISTORY_CACHE_TTL_MS = 1000 * 60 * 15;
|
||||||
|
|
||||||
|
type CacheEntry<T> = {
|
||||||
|
expiresAt: number;
|
||||||
|
value: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
const quoteCache = new Map<string, CacheEntry<number>>();
|
||||||
|
const priceHistoryCache = new Map<string, CacheEntry<Array<{ date: string; close: number }>>>();
|
||||||
|
|
||||||
function buildYahooChartUrl(ticker: string, params: string) {
|
function buildYahooChartUrl(ticker: string, params: string) {
|
||||||
return `${YAHOO_BASE}/${encodeURIComponent(ticker.trim().toUpperCase())}?${params}`;
|
return `${YAHOO_BASE}/${encodeURIComponent(ticker.trim().toUpperCase())}?${params}`;
|
||||||
@@ -17,7 +27,12 @@ function fallbackQuote(ticker: string) {
|
|||||||
|
|
||||||
export async function getQuote(ticker: string): Promise<number> {
|
export async function getQuote(ticker: string): Promise<number> {
|
||||||
const normalizedTicker = ticker.trim().toUpperCase();
|
const normalizedTicker = ticker.trim().toUpperCase();
|
||||||
|
const cached = quoteCache.get(normalizedTicker);
|
||||||
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
|
return cached.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
let quote = fallbackQuote(normalizedTicker);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(buildYahooChartUrl(normalizedTicker, 'interval=1d&range=1d'), {
|
const response = await fetch(buildYahooChartUrl(normalizedTicker, 'interval=1d&range=1d'), {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -27,7 +42,11 @@ export async function getQuote(ticker: string): Promise<number> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return fallbackQuote(normalizedTicker);
|
quoteCache.set(normalizedTicker, {
|
||||||
|
value: quote,
|
||||||
|
expiresAt: Date.now() + QUOTE_CACHE_TTL_MS
|
||||||
|
});
|
||||||
|
return quote;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await response.json() as {
|
const payload = await response.json() as {
|
||||||
@@ -38,13 +57,23 @@ export async function getQuote(ticker: string): Promise<number> {
|
|||||||
|
|
||||||
const price = payload.chart?.result?.[0]?.meta?.regularMarketPrice;
|
const price = payload.chart?.result?.[0]?.meta?.regularMarketPrice;
|
||||||
if (typeof price !== 'number' || !Number.isFinite(price)) {
|
if (typeof price !== 'number' || !Number.isFinite(price)) {
|
||||||
return fallbackQuote(normalizedTicker);
|
quoteCache.set(normalizedTicker, {
|
||||||
|
value: quote,
|
||||||
|
expiresAt: Date.now() + QUOTE_CACHE_TTL_MS
|
||||||
|
});
|
||||||
|
return quote;
|
||||||
|
}
|
||||||
|
quote = price;
|
||||||
|
} catch {
|
||||||
|
// fall through to cached fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
return price;
|
quoteCache.set(normalizedTicker, {
|
||||||
} catch {
|
value: quote,
|
||||||
return fallbackQuote(normalizedTicker);
|
expiresAt: Date.now() + QUOTE_CACHE_TTL_MS
|
||||||
}
|
});
|
||||||
|
|
||||||
|
return quote;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getQuoteOrNull(ticker: string): Promise<number | null> {
|
export async function getQuoteOrNull(ticker: string): Promise<number | null> {
|
||||||
@@ -145,6 +174,10 @@ export async function getHistoricalClosingPrices(ticker: string, dates: string[]
|
|||||||
|
|
||||||
export async function getPriceHistory(ticker: string): Promise<Array<{ date: string; close: number }>> {
|
export async function getPriceHistory(ticker: string): Promise<Array<{ date: string; close: number }>> {
|
||||||
const normalizedTicker = ticker.trim().toUpperCase();
|
const normalizedTicker = ticker.trim().toUpperCase();
|
||||||
|
const cached = priceHistoryCache.get(normalizedTicker);
|
||||||
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
|
return cached.value;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(buildYahooChartUrl(normalizedTicker, 'interval=1wk&range=20y'), {
|
const response = await fetch(buildYahooChartUrl(normalizedTicker, 'interval=1wk&range=20y'), {
|
||||||
@@ -190,6 +223,10 @@ export async function getPriceHistory(ticker: string): Promise<Array<{ date: str
|
|||||||
.filter((entry): entry is { date: string; close: number } => entry !== null);
|
.filter((entry): entry is { date: string; close: number } => entry !== null);
|
||||||
|
|
||||||
if (points.length > 0) {
|
if (points.length > 0) {
|
||||||
|
priceHistoryCache.set(normalizedTicker, {
|
||||||
|
value: points,
|
||||||
|
expiresAt: Date.now() + PRICE_HISTORY_CACHE_TTL_MS
|
||||||
|
});
|
||||||
return points;
|
return points;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -201,7 +238,7 @@ export async function getPriceHistory(ticker: string): Promise<Array<{ date: str
|
|||||||
|
|
||||||
const totalWeeks = 20 * 52;
|
const totalWeeks = 20 * 52;
|
||||||
|
|
||||||
return Array.from({ length: totalWeeks }, (_, index) => {
|
const syntheticHistory = Array.from({ length: totalWeeks }, (_, index) => {
|
||||||
const step = (totalWeeks - 1) - index;
|
const step = (totalWeeks - 1) - index;
|
||||||
const date = new Date(now - step * 7 * 24 * 60 * 60 * 1000).toISOString();
|
const date = new Date(now - step * 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
const wave = Math.sin(index / 8) * 0.06;
|
const wave = Math.sin(index / 8) * 0.06;
|
||||||
@@ -213,4 +250,20 @@ export async function getPriceHistory(ticker: string): Promise<Array<{ date: str
|
|||||||
close: Number(close.toFixed(2))
|
close: Number(close.toFixed(2))
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
priceHistoryCache.set(normalizedTicker, {
|
||||||
|
value: syntheticHistory,
|
||||||
|
expiresAt: Date.now() + PRICE_HISTORY_CACHE_TTL_MS
|
||||||
|
});
|
||||||
|
|
||||||
|
return syntheticHistory;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const __pricesInternals = {
|
||||||
|
PRICE_HISTORY_CACHE_TTL_MS,
|
||||||
|
QUOTE_CACHE_TTL_MS,
|
||||||
|
resetCaches() {
|
||||||
|
quoteCache.clear();
|
||||||
|
priceHistoryCache.clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
202
lib/server/repos/company-overview-cache.test.ts
Normal file
202
lib/server/repos/company-overview-cache.test.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import {
|
||||||
|
afterAll,
|
||||||
|
beforeAll,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'bun:test';
|
||||||
|
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { Database } from 'bun:sqlite';
|
||||||
|
import type { CompanyAnalysis } from '@/lib/types';
|
||||||
|
|
||||||
|
const TEST_USER_ID = 'overview-cache-user';
|
||||||
|
|
||||||
|
let tempDir: string | null = null;
|
||||||
|
let sqliteClient: Database | null = null;
|
||||||
|
let overviewCacheRepo: typeof import('./company-overview-cache') | null = null;
|
||||||
|
|
||||||
|
function resetDbSingletons() {
|
||||||
|
const globalState = globalThis as typeof globalThis & {
|
||||||
|
__fiscalSqliteClient?: Database;
|
||||||
|
__fiscalDrizzleDb?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
globalState.__fiscalSqliteClient?.close();
|
||||||
|
globalState.__fiscalSqliteClient = undefined;
|
||||||
|
globalState.__fiscalDrizzleDb = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMigration(client: Database, fileName: string) {
|
||||||
|
const sql = readFileSync(join(process.cwd(), 'drizzle', fileName), 'utf8');
|
||||||
|
client.exec(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureUser(client: Database) {
|
||||||
|
const now = Date.now();
|
||||||
|
client.exec(`
|
||||||
|
INSERT OR REPLACE INTO user (id, name, email, emailVerified, image, createdAt, updatedAt, role, banned, banReason, banExpires)
|
||||||
|
VALUES ('${TEST_USER_ID}', 'Overview Cache User', 'overview-cache@example.com', 1, NULL, ${now}, ${now}, NULL, 0, NULL, NULL);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCache(client: Database) {
|
||||||
|
client.exec('DELETE FROM company_overview_cache;');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAnalysisPayload(companyName: string): CompanyAnalysis {
|
||||||
|
return {
|
||||||
|
company: {
|
||||||
|
ticker: 'MSFT',
|
||||||
|
companyName,
|
||||||
|
sector: null,
|
||||||
|
category: null,
|
||||||
|
tags: [],
|
||||||
|
cik: null
|
||||||
|
},
|
||||||
|
quote: 100,
|
||||||
|
position: null,
|
||||||
|
priceHistory: [],
|
||||||
|
benchmarkHistory: [],
|
||||||
|
financials: [],
|
||||||
|
filings: [],
|
||||||
|
aiReports: [],
|
||||||
|
coverage: null,
|
||||||
|
journalPreview: [],
|
||||||
|
recentAiReports: [],
|
||||||
|
latestFilingSummary: null,
|
||||||
|
keyMetrics: {
|
||||||
|
referenceDate: null,
|
||||||
|
revenue: null,
|
||||||
|
netIncome: null,
|
||||||
|
totalAssets: null,
|
||||||
|
cash: null,
|
||||||
|
debt: null,
|
||||||
|
netMargin: null
|
||||||
|
},
|
||||||
|
companyProfile: {
|
||||||
|
description: null,
|
||||||
|
exchange: null,
|
||||||
|
industry: null,
|
||||||
|
country: null,
|
||||||
|
website: null,
|
||||||
|
fiscalYearEnd: null,
|
||||||
|
employeeCount: null,
|
||||||
|
source: 'unavailable'
|
||||||
|
},
|
||||||
|
valuationSnapshot: {
|
||||||
|
sharesOutstanding: null,
|
||||||
|
marketCap: null,
|
||||||
|
enterpriseValue: null,
|
||||||
|
trailingPe: null,
|
||||||
|
evToRevenue: null,
|
||||||
|
evToEbitda: null,
|
||||||
|
source: 'unavailable'
|
||||||
|
},
|
||||||
|
bullBear: {
|
||||||
|
source: 'unavailable',
|
||||||
|
bull: [],
|
||||||
|
bear: [],
|
||||||
|
updatedAt: null
|
||||||
|
},
|
||||||
|
recentDevelopments: {
|
||||||
|
status: 'unavailable',
|
||||||
|
items: [],
|
||||||
|
weeklySnapshot: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('company overview cache repo', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
tempDir = mkdtempSync(join(tmpdir(), 'fiscal-overview-cache-'));
|
||||||
|
const env = process.env as Record<string, string | undefined>;
|
||||||
|
env.DATABASE_URL = `file:${join(tempDir, 'repo.sqlite')}`;
|
||||||
|
env.NODE_ENV = 'test';
|
||||||
|
|
||||||
|
resetDbSingletons();
|
||||||
|
|
||||||
|
sqliteClient = new Database(join(tempDir, 'repo.sqlite'), { create: true });
|
||||||
|
sqliteClient.exec('PRAGMA foreign_keys = ON;');
|
||||||
|
applyMigration(sqliteClient, '0000_cold_silver_centurion.sql');
|
||||||
|
applyMigration(sqliteClient, '0012_company_overview_cache.sql');
|
||||||
|
ensureUser(sqliteClient);
|
||||||
|
|
||||||
|
const globalState = globalThis as typeof globalThis & {
|
||||||
|
__fiscalSqliteClient?: Database;
|
||||||
|
__fiscalDrizzleDb?: unknown;
|
||||||
|
};
|
||||||
|
globalState.__fiscalSqliteClient = sqliteClient;
|
||||||
|
globalState.__fiscalDrizzleDb = undefined;
|
||||||
|
|
||||||
|
overviewCacheRepo = await import('./company-overview-cache');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
sqliteClient?.close();
|
||||||
|
resetDbSingletons();
|
||||||
|
if (tempDir) {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
if (!sqliteClient) {
|
||||||
|
throw new Error('sqlite client not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCache(sqliteClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upserts and reloads cached overview payloads', async () => {
|
||||||
|
if (!overviewCacheRepo) {
|
||||||
|
throw new Error('overview cache repo not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = await overviewCacheRepo.upsertCompanyOverviewCache({
|
||||||
|
userId: TEST_USER_ID,
|
||||||
|
ticker: 'msft',
|
||||||
|
sourceSignature: 'sig-1',
|
||||||
|
payload: buildAnalysisPayload('Microsoft Corporation')
|
||||||
|
});
|
||||||
|
|
||||||
|
const loaded = await overviewCacheRepo.getCompanyOverviewCache({
|
||||||
|
userId: TEST_USER_ID,
|
||||||
|
ticker: 'MSFT'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(first.ticker).toBe('MSFT');
|
||||||
|
expect(loaded?.source_signature).toBe('sig-1');
|
||||||
|
expect(loaded?.payload.company.companyName).toBe('Microsoft Corporation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates existing cached rows in place', async () => {
|
||||||
|
if (!overviewCacheRepo) {
|
||||||
|
throw new Error('overview cache repo not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = await overviewCacheRepo.upsertCompanyOverviewCache({
|
||||||
|
userId: TEST_USER_ID,
|
||||||
|
ticker: 'MSFT',
|
||||||
|
sourceSignature: 'sig-1',
|
||||||
|
payload: buildAnalysisPayload('Old Name')
|
||||||
|
});
|
||||||
|
const second = await overviewCacheRepo.upsertCompanyOverviewCache({
|
||||||
|
userId: TEST_USER_ID,
|
||||||
|
ticker: 'MSFT',
|
||||||
|
sourceSignature: 'sig-2',
|
||||||
|
payload: buildAnalysisPayload('New Name')
|
||||||
|
});
|
||||||
|
|
||||||
|
const loaded = await overviewCacheRepo.getCompanyOverviewCache({
|
||||||
|
userId: TEST_USER_ID,
|
||||||
|
ticker: 'MSFT'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(second.id).toBe(first.id);
|
||||||
|
expect(loaded?.source_signature).toBe('sig-2');
|
||||||
|
expect(loaded?.payload.company.companyName).toBe('New Name');
|
||||||
|
});
|
||||||
|
});
|
||||||
102
lib/server/repos/company-overview-cache.ts
Normal file
102
lib/server/repos/company-overview-cache.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||||
|
import type { CompanyAnalysis } from '@/lib/types';
|
||||||
|
import { getSqliteClient } from '@/lib/server/db';
|
||||||
|
import { companyOverviewCache, schema } from '@/lib/server/db/schema';
|
||||||
|
|
||||||
|
export const CURRENT_COMPANY_OVERVIEW_CACHE_VERSION = 1;
|
||||||
|
|
||||||
|
export type CompanyOverviewCacheRecord = {
|
||||||
|
id: number;
|
||||||
|
user_id: string;
|
||||||
|
ticker: string;
|
||||||
|
cache_version: number;
|
||||||
|
source_signature: string;
|
||||||
|
payload: CompanyAnalysis;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toRecord(row: typeof companyOverviewCache.$inferSelect): CompanyOverviewCacheRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
user_id: row.user_id,
|
||||||
|
ticker: row.ticker,
|
||||||
|
cache_version: row.cache_version,
|
||||||
|
source_signature: row.source_signature,
|
||||||
|
payload: row.payload as CompanyAnalysis,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDb() {
|
||||||
|
return drizzle(getSqliteClient(), { schema });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCompanyOverviewCache(input: { userId: string; ticker: string }) {
|
||||||
|
const normalizedTicker = input.ticker.trim().toUpperCase();
|
||||||
|
if (!normalizedTicker) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = await getDb()
|
||||||
|
.select()
|
||||||
|
.from(companyOverviewCache)
|
||||||
|
.where(and(
|
||||||
|
eq(companyOverviewCache.user_id, input.userId),
|
||||||
|
eq(companyOverviewCache.ticker, normalizedTicker)
|
||||||
|
))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return row ? toRecord(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertCompanyOverviewCache(input: {
|
||||||
|
userId: string;
|
||||||
|
ticker: string;
|
||||||
|
sourceSignature: string;
|
||||||
|
payload: CompanyAnalysis;
|
||||||
|
}) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const normalizedTicker = input.ticker.trim().toUpperCase();
|
||||||
|
|
||||||
|
const [saved] = await getDb()
|
||||||
|
.insert(companyOverviewCache)
|
||||||
|
.values({
|
||||||
|
user_id: input.userId,
|
||||||
|
ticker: normalizedTicker,
|
||||||
|
cache_version: CURRENT_COMPANY_OVERVIEW_CACHE_VERSION,
|
||||||
|
source_signature: input.sourceSignature,
|
||||||
|
payload: input.payload as unknown as Record<string, unknown>,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [companyOverviewCache.user_id, companyOverviewCache.ticker],
|
||||||
|
set: {
|
||||||
|
cache_version: CURRENT_COMPANY_OVERVIEW_CACHE_VERSION,
|
||||||
|
source_signature: input.sourceSignature,
|
||||||
|
payload: input.payload as unknown as Record<string, unknown>,
|
||||||
|
updated_at: now
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return toRecord(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCompanyOverviewCache(input: { userId: string; ticker: string }) {
|
||||||
|
const normalizedTicker = input.ticker.trim().toUpperCase();
|
||||||
|
|
||||||
|
return await getDb()
|
||||||
|
.delete(companyOverviewCache)
|
||||||
|
.where(and(
|
||||||
|
eq(companyOverviewCache.user_id, input.userId),
|
||||||
|
eq(companyOverviewCache.ticker, normalizedTicker)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const __companyOverviewCacheInternals = {
|
||||||
|
CACHE_VERSION: CURRENT_COMPANY_OVERVIEW_CACHE_VERSION
|
||||||
|
};
|
||||||
@@ -67,7 +67,8 @@ describe('task repos', () => {
|
|||||||
'0006_coverage_journal_tracking.sql',
|
'0006_coverage_journal_tracking.sql',
|
||||||
'0007_company_financial_bundles.sql',
|
'0007_company_financial_bundles.sql',
|
||||||
'0008_research_workspace.sql',
|
'0008_research_workspace.sql',
|
||||||
'0009_task_notification_context.sql'
|
'0009_task_notification_context.sql',
|
||||||
|
'0012_company_overview_cache.sql'
|
||||||
]) {
|
]) {
|
||||||
applyMigration(sqliteClient, file);
|
applyMigration(sqliteClient, file);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user