Skip to content

Commit 91c54dc

Browse files
ralyodioclaude
andcommitted
feat(finance): AI Opportunities screen + multiple watchlists (CRUD)
Opportunities: a "Find opportunities" button on the finance hub takes free-text parameters (default: top 10 stocks under $10 with 6-12mo potential), feeds them to the AI, and renders a ranked candidate list (each ticker links to its page) plus copyable markdown. New lib/finance/opportunities module reuses the report OpenAI adapter, cost helper, and shares the report rolling-24h rate limit + run ledger (sentinel symbol). Each run spends tokens; no auto-run, no DB cache. Multiple watchlists: new finance_watchlists table + watchlist_id FK on items (migration backfills a default list per profile so nothing is lost; uniqueness moves to (watchlist_id, symbol)). New GET/POST /api/finance/watchlists and PATCH/DELETE /[id]; the items route now takes a watchlistId, defaulting to the profile's first list so the ticker page add/remove keeps working. UI extracted to watchlist-section.tsx with list tabs (+counts), New/Rename/Delete, and per-card remove. Tests: opportunities parser/pipeline + watchlist name sanitizer (103 finance tests pass). types.ts gains finance_watchlists + the watchlist_id FK relationship so the count-embed type-checks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f6d8ee5 commit 91c54dc

18 files changed

Lines changed: 1513 additions & 218 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* Finance AI "Opportunities" stock screen (token-spending route).
3+
*
4+
* POST /api/finance/opportunities { prompt? } — generate a ranked candidate
5+
* list from free-text parameters. Enforces auth + active paid subscription
6+
* FIRST, then the shared per-user/global daily caps (same rolling-24h ledger as
7+
* reports) before any LLM call. Logs tokens + cost to the run ledger.
8+
*/
9+
10+
import { NextRequest, NextResponse } from 'next/server';
11+
import { requireActiveSubscription } from '@/lib/subscription/guard';
12+
import { getActiveProfileId } from '@/lib/profiles/profile-utils';
13+
import {
14+
countRunsSince,
15+
createOpenAIReportLLM,
16+
evaluateRateLimit,
17+
getRateLimitConfig,
18+
getReportModel,
19+
logRun,
20+
rollingWindowStart,
21+
} from '@/lib/finance/analysis';
22+
import {
23+
DEFAULT_OPPORTUNITIES_PROMPT,
24+
MAX_PROMPT_LENGTH,
25+
OPPORTUNITIES_PROMPT_VERSION,
26+
generateOpportunities,
27+
} from '@/lib/finance/opportunities';
28+
29+
export const dynamic = 'force-dynamic';
30+
31+
/** Sentinel `symbol` for ledger rows so opportunities share the report cap. */
32+
const LEDGER_SYMBOL = '*OPPORTUNITIES*';
33+
34+
export async function POST(request: NextRequest): Promise<NextResponse> {
35+
const gate = await requireActiveSubscription(request);
36+
if (gate) return gate;
37+
38+
const profileId = await getActiveProfileId();
39+
if (!profileId) {
40+
return NextResponse.json({ error: 'No active profile' }, { status: 400 });
41+
}
42+
43+
const body = (await request.json().catch(() => null)) as { prompt?: string } | null;
44+
const prompt = (typeof body?.prompt === 'string' ? body.prompt.trim() : '').slice(0, MAX_PROMPT_LENGTH)
45+
|| DEFAULT_OPPORTUNITIES_PROMPT;
46+
47+
const apiKey = process.env.OPENAI_API_KEY;
48+
if (!apiKey) {
49+
return NextResponse.json({ error: 'AI service not configured' }, { status: 503 });
50+
}
51+
52+
const model = getReportModel();
53+
54+
// Rate limit (rolling 24h) — shared with reports; defends against LLM spend.
55+
const config = getRateLimitConfig();
56+
const counts = await countRunsSince(profileId, rollingWindowStart());
57+
const decision = evaluateRateLimit(counts, config);
58+
if (!decision.allowed) {
59+
await logRun({
60+
profileId,
61+
symbol: LEDGER_SYMBOL,
62+
model,
63+
promptVersion: OPPORTUNITIES_PROMPT_VERSION,
64+
status: 'rate_limited',
65+
});
66+
return NextResponse.json(
67+
{ error: 'rate_limited', message: decision.reason, scope: decision.scope },
68+
{ status: 429 },
69+
);
70+
}
71+
72+
try {
73+
const llm = createOpenAIReportLLM(apiKey);
74+
const result = await generateOpportunities({ prompt, llm, model });
75+
76+
await logRun({
77+
profileId,
78+
symbol: LEDGER_SYMBOL,
79+
model,
80+
promptVersion: OPPORTUNITIES_PROMPT_VERSION,
81+
status: 'success',
82+
promptTokens: result.usage.promptTokens,
83+
completionTokens: result.usage.completionTokens,
84+
totalTokens: result.usage.totalTokens,
85+
costUsd: result.usage.costUsd,
86+
});
87+
88+
return NextResponse.json({ opportunities: result });
89+
} catch (error) {
90+
console.error('[finance/opportunities] generation failed:', error);
91+
await logRun({
92+
profileId,
93+
symbol: LEDGER_SYMBOL,
94+
model,
95+
promptVersion: OPPORTUNITIES_PROMPT_VERSION,
96+
status: 'failure',
97+
error: error instanceof Error ? error.message : 'unknown',
98+
});
99+
return NextResponse.json({ error: 'generation_failed' }, { status: 502 });
100+
}
101+
}

src/app/api/finance/watchlist/route.ts

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
/**
2-
* Finance watchlist (PRD §3.1, §5) — profile-scoped tickers.
2+
* Finance watchlist items (PRD §3.1, §5) — tickers within a named list.
33
*
4-
* GET /api/finance/watchlist — list the active profile's tickers
5-
* POST /api/finance/watchlist {symbol} — add a ticker
6-
* DELETE /api/finance/watchlist?symbol= — remove a ticker
4+
* GET /api/finance/watchlist?watchlistId= — list a list's tickers
5+
* POST /api/finance/watchlist {symbol|symbols, watchlistId?} — add ticker(s)
6+
* DELETE /api/finance/watchlist?symbol=&watchlistId= — remove a ticker
77
*
8-
* Paid-gated. Rows are filtered by the active profile id; the table also has
9-
* RLS as defense in depth.
8+
* Paid-gated. When `watchlistId` is omitted we fall back to the profile's
9+
* default (oldest) list, creating one if needed — so legacy single-list callers
10+
* keep working. Every query is scoped to the active profile (RLS in depth).
1011
*/
1112

1213
import { NextRequest, NextResponse } from 'next/server';
@@ -15,11 +16,23 @@ import { getActiveProfileId } from '@/lib/profiles/profile-utils';
1516
import { getServerClient } from '@/lib/supabase';
1617
import { normalizeSymbol } from '@/lib/finance/market-data/stooq';
1718
import { parseSymbolList } from '@/lib/finance/watchlist';
19+
import { getOrCreateDefaultWatchlistId, ownsWatchlist } from '@/lib/finance/watchlist-db';
1820

1921
export const dynamic = 'force-dynamic';
2022

2123
const SYMBOL_RE = /^[A-Z][A-Z0-9.\-]{0,9}$/;
2224

25+
/**
26+
* Resolve the target list for a profile. Returns the verified list id, or null
27+
* when an explicit (but unowned/unknown) id was supplied.
28+
*/
29+
async function resolveWatchlistId(profileId: string, requested: string | null): Promise<string | null> {
30+
if (requested) {
31+
return (await ownsWatchlist(profileId, requested)) ? requested : null;
32+
}
33+
return getOrCreateDefaultWatchlistId(profileId);
34+
}
35+
2336
export async function GET(request: NextRequest): Promise<NextResponse> {
2437
const gate = await requireActiveSubscription(request);
2538
if (gate) return gate;
@@ -29,18 +42,23 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
2942
return NextResponse.json({ error: 'No active profile' }, { status: 400 });
3043
}
3144

45+
const watchlistId = await resolveWatchlistId(profileId, request.nextUrl.searchParams.get('watchlistId'));
46+
if (!watchlistId) {
47+
return NextResponse.json({ error: 'watchlist not found' }, { status: 404 });
48+
}
49+
3250
const { data, error } = await getServerClient()
3351
.from('finance_watchlist')
3452
.select('id, symbol, exchange, added_at')
35-
.eq('profile_id', profileId)
53+
.eq('watchlist_id', watchlistId)
3654
.order('added_at', { ascending: false });
3755

3856
if (error) {
3957
console.error('[finance/watchlist] list error:', error);
4058
return NextResponse.json({ error: 'failed to load watchlist' }, { status: 500 });
4159
}
4260

43-
return NextResponse.json({ watchlist: data ?? [] });
61+
return NextResponse.json({ watchlistId, watchlist: data ?? [] });
4462
}
4563

4664
export async function POST(request: NextRequest): Promise<NextResponse> {
@@ -53,21 +71,28 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
5371
}
5472

5573
const body = (await request.json().catch(() => null)) as
56-
| { symbol?: string; symbols?: string | string[]; exchange?: string }
74+
| { symbol?: string; symbols?: string | string[]; exchange?: string; watchlistId?: string }
5775
| null;
5876

77+
const watchlistId = await resolveWatchlistId(profileId, body?.watchlistId ?? null);
78+
if (!watchlistId) {
79+
return NextResponse.json({ error: 'watchlist not found' }, { status: 404 });
80+
}
81+
82+
const supabase = getServerClient();
83+
5984
// Bulk add: `symbols` may be a comma/space/newline-separated string or array.
6085
if (body?.symbols !== undefined) {
6186
const { valid, invalid } = parseSymbolList(body.symbols);
6287
if (valid.length === 0) {
6388
return NextResponse.json({ error: 'no valid symbols', invalid }, { status: 400 });
6489
}
6590

66-
const { data, error } = await getServerClient()
91+
const { data, error } = await supabase
6792
.from('finance_watchlist')
6893
.upsert(
69-
valid.map((symbol) => ({ profile_id: profileId, symbol, exchange: null })),
70-
{ onConflict: 'profile_id,symbol' },
94+
valid.map((symbol) => ({ profile_id: profileId, watchlist_id: watchlistId, symbol, exchange: null })),
95+
{ onConflict: 'watchlist_id,symbol' },
7196
)
7297
.select('id, symbol, exchange, added_at');
7398

@@ -76,7 +101,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
76101
return NextResponse.json({ error: 'failed to add symbols' }, { status: 500 });
77102
}
78103

79-
return NextResponse.json({ added: data ?? [], count: data?.length ?? 0, invalid }, { status: 201 });
104+
return NextResponse.json({ watchlistId, added: data ?? [], count: data?.length ?? 0, invalid }, { status: 201 });
80105
}
81106

82107
// Single add.
@@ -85,11 +110,11 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
85110
return NextResponse.json({ error: 'invalid symbol' }, { status: 400 });
86111
}
87112

88-
const { data, error } = await getServerClient()
113+
const { data, error } = await supabase
89114
.from('finance_watchlist')
90115
.upsert(
91-
{ profile_id: profileId, symbol, exchange: body?.exchange ?? null },
92-
{ onConflict: 'profile_id,symbol' },
116+
{ profile_id: profileId, watchlist_id: watchlistId, symbol, exchange: body?.exchange ?? null },
117+
{ onConflict: 'watchlist_id,symbol' },
93118
)
94119
.select('id, symbol, exchange, added_at')
95120
.single();
@@ -99,7 +124,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
99124
return NextResponse.json({ error: 'failed to add symbol' }, { status: 500 });
100125
}
101126

102-
return NextResponse.json({ item: data }, { status: 201 });
127+
return NextResponse.json({ watchlistId, item: data }, { status: 201 });
103128
}
104129

105130
export async function DELETE(request: NextRequest): Promise<NextResponse> {
@@ -116,10 +141,15 @@ export async function DELETE(request: NextRequest): Promise<NextResponse> {
116141
return NextResponse.json({ error: 'invalid symbol' }, { status: 400 });
117142
}
118143

144+
const watchlistId = await resolveWatchlistId(profileId, request.nextUrl.searchParams.get('watchlistId'));
145+
if (!watchlistId) {
146+
return NextResponse.json({ error: 'watchlist not found' }, { status: 404 });
147+
}
148+
119149
const { error } = await getServerClient()
120150
.from('finance_watchlist')
121151
.delete()
122-
.eq('profile_id', profileId)
152+
.eq('watchlist_id', watchlistId)
123153
.eq('symbol', symbol);
124154

125155
if (error) {
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* Finance watchlists (named lists) — single-list routes.
3+
*
4+
* PATCH /api/finance/watchlists/:id {name} — rename
5+
* DELETE /api/finance/watchlists/:id — delete (cascades its items)
6+
*
7+
* Paid-gated; every query is filtered by the active profile so a caller can
8+
* only mutate its own lists (RLS as defense in depth).
9+
*/
10+
11+
import { NextRequest, NextResponse } from 'next/server';
12+
import { requireActiveSubscription } from '@/lib/subscription/guard';
13+
import { getActiveProfileId } from '@/lib/profiles/profile-utils';
14+
import { getServerClient } from '@/lib/supabase';
15+
import { sanitizeWatchlistName } from '@/lib/finance/watchlist';
16+
17+
export const dynamic = 'force-dynamic';
18+
19+
export async function PATCH(
20+
request: NextRequest,
21+
{ params }: { params: Promise<{ id: string }> },
22+
): Promise<NextResponse> {
23+
const gate = await requireActiveSubscription(request);
24+
if (gate) return gate;
25+
26+
const profileId = await getActiveProfileId();
27+
if (!profileId) return NextResponse.json({ error: 'No active profile' }, { status: 400 });
28+
29+
const { id } = await params;
30+
const body = (await request.json().catch(() => null)) as { name?: string } | null;
31+
const name = sanitizeWatchlistName(body?.name);
32+
if (!name) return NextResponse.json({ error: 'invalid name' }, { status: 400 });
33+
34+
const { data, error } = await getServerClient()
35+
.from('finance_watchlists')
36+
.update({ name })
37+
.eq('id', id)
38+
.eq('profile_id', profileId)
39+
.select('id, name, created_at')
40+
.maybeSingle();
41+
42+
if (error) {
43+
console.error('[finance/watchlists] rename error:', error);
44+
return NextResponse.json({ error: 'failed to rename watchlist' }, { status: 500 });
45+
}
46+
if (!data) return NextResponse.json({ error: 'not found' }, { status: 404 });
47+
48+
return NextResponse.json({ watchlist: { id: data.id, name: data.name, createdAt: data.created_at } });
49+
}
50+
51+
export async function DELETE(
52+
request: NextRequest,
53+
{ params }: { params: Promise<{ id: string }> },
54+
): Promise<NextResponse> {
55+
const gate = await requireActiveSubscription(request);
56+
if (gate) return gate;
57+
58+
const profileId = await getActiveProfileId();
59+
if (!profileId) return NextResponse.json({ error: 'No active profile' }, { status: 400 });
60+
61+
const { id } = await params;
62+
const { data, error } = await getServerClient()
63+
.from('finance_watchlists')
64+
.delete()
65+
.eq('id', id)
66+
.eq('profile_id', profileId)
67+
.select('id')
68+
.maybeSingle();
69+
70+
if (error) {
71+
console.error('[finance/watchlists] delete error:', error);
72+
return NextResponse.json({ error: 'failed to delete watchlist' }, { status: 500 });
73+
}
74+
if (!data) return NextResponse.json({ error: 'not found' }, { status: 404 });
75+
76+
return NextResponse.json({ ok: true });
77+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Finance watchlists (named lists) — collection routes.
3+
*
4+
* GET /api/finance/watchlists — the active profile's lists (+counts)
5+
* POST /api/finance/watchlists {name} — create a new list
6+
*
7+
* Paid-gated; profile-scoped (RLS as defense in depth).
8+
*/
9+
10+
import { NextRequest, NextResponse } from 'next/server';
11+
import { requireActiveSubscription } from '@/lib/subscription/guard';
12+
import { getActiveProfileId } from '@/lib/profiles/profile-utils';
13+
import { sanitizeWatchlistName } from '@/lib/finance/watchlist';
14+
import { createWatchlist, listWatchlists } from '@/lib/finance/watchlist-db';
15+
16+
export const dynamic = 'force-dynamic';
17+
18+
export async function GET(request: NextRequest): Promise<NextResponse> {
19+
const gate = await requireActiveSubscription(request);
20+
if (gate) return gate;
21+
22+
const profileId = await getActiveProfileId();
23+
if (!profileId) return NextResponse.json({ error: 'No active profile' }, { status: 400 });
24+
25+
try {
26+
const watchlists = await listWatchlists(profileId);
27+
return NextResponse.json({ watchlists });
28+
} catch (error) {
29+
console.error('[finance/watchlists] list error:', error);
30+
return NextResponse.json({ error: 'failed to load watchlists' }, { status: 500 });
31+
}
32+
}
33+
34+
export async function POST(request: NextRequest): Promise<NextResponse> {
35+
const gate = await requireActiveSubscription(request);
36+
if (gate) return gate;
37+
38+
const profileId = await getActiveProfileId();
39+
if (!profileId) return NextResponse.json({ error: 'No active profile' }, { status: 400 });
40+
41+
const body = (await request.json().catch(() => null)) as { name?: string } | null;
42+
const name = sanitizeWatchlistName(body?.name);
43+
if (!name) return NextResponse.json({ error: 'invalid name' }, { status: 400 });
44+
45+
try {
46+
const watchlist = await createWatchlist(profileId, name);
47+
return NextResponse.json({ watchlist: { ...watchlist, count: 0 } }, { status: 201 });
48+
} catch (error) {
49+
console.error('[finance/watchlists] create error:', error);
50+
return NextResponse.json({ error: 'failed to create watchlist' }, { status: 500 });
51+
}
52+
}

0 commit comments

Comments
 (0)