Skip to content

Commit e64248c

Browse files
dongzhang84claude
andcommitted
feat: add save-and-scan onboarding step and /api/opportunities/refresh (v1.1 steps 3&4)
- lib/refresh-opportunities.ts: shared fetch/score/save logic for single user - api/onboarding: replace generate-keywords+save with save-and-scan (fire-and-forget refresh) - api/opportunities/refresh: new POST endpoint for on-demand per-user scan Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
1 parent f64855d commit e64248c

3 files changed

Lines changed: 176 additions & 18 deletions

File tree

app/api/onboarding/route.ts

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server'
22
import { createServerSupabaseClient } from '@/lib/supabase/server'
33
import { generateKeywordsAndSubreddits } from '@/lib/keyword-generator'
44
import { prisma } from '@/lib/db/client'
5+
import { refreshOpportunitiesForUser } from '@/lib/refresh-opportunities'
56

67
export async function POST(request: NextRequest) {
78
const supabase = await createServerSupabaseClient()
@@ -20,29 +21,17 @@ export async function POST(request: NextRequest) {
2021

2122
const { step } = body
2223

23-
if (step === 'generate-keywords') {
24-
const { productDescription, targetCustomer } = body as {
25-
productDescription: string
26-
targetCustomer: string
27-
}
28-
29-
const result = await generateKeywordsAndSubreddits(productDescription)
30-
return NextResponse.json(result)
31-
}
24+
if (step === 'save-and-scan') {
25+
const { productDescription } = body as { productDescription: string }
3226

33-
if (step === 'save') {
34-
const { productDescription, targetCustomer, keywords, subreddits } = body as {
35-
productDescription: string
36-
targetCustomer: string
37-
keywords: string[]
38-
subreddits: string[]
39-
}
27+
// Generate keywords and subreddits from product description (user never sees this)
28+
const { keywords, subreddits } = await generateKeywordsAndSubreddits(productDescription)
4029

30+
// Save profile
4131
await prisma.profile.upsert({
4232
where: { id: user.id },
4333
update: {
4434
productDescription,
45-
targetCustomer,
4635
keywords,
4736
subreddits,
4837
onboardingComplete: true,
@@ -52,7 +41,6 @@ export async function POST(request: NextRequest) {
5241
email: user.email ?? '',
5342
password: '',
5443
productDescription,
55-
targetCustomer,
5644
keywords,
5745
subreddits,
5846
onboardingComplete: true,
@@ -61,8 +49,20 @@ export async function POST(request: NextRequest) {
6149
},
6250
})
6351

52+
// Fire and forget — scan runs in background, we return immediately
53+
refreshOpportunitiesForUser(user.id).catch((err) =>
54+
console.error('[onboarding] background refresh failed:', err)
55+
)
56+
6457
return NextResponse.json({ success: true })
6558
}
6659

60+
// Legacy steps kept for reference but no longer used by the UI
61+
if (step === 'generate-keywords') {
62+
const { productDescription } = body as { productDescription: string }
63+
const result = await generateKeywordsAndSubreddits(productDescription)
64+
return NextResponse.json(result)
65+
}
66+
6767
return NextResponse.json({ error: 'Unknown step' }, { status: 400 })
6868
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { NextResponse } from 'next/server'
2+
import { createServerSupabaseClient } from '@/lib/supabase/server'
3+
import { refreshOpportunitiesForUser } from '@/lib/refresh-opportunities'
4+
5+
export const maxDuration = 60
6+
7+
export async function POST() {
8+
const supabase = await createServerSupabaseClient()
9+
const { data: { user } } = await supabase.auth.getUser()
10+
11+
if (!user) {
12+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
13+
}
14+
15+
try {
16+
const opportunitiesSaved = await refreshOpportunitiesForUser(user.id)
17+
return NextResponse.json({ success: true, opportunitiesSaved })
18+
} catch (err) {
19+
const message = err instanceof Error ? err.message : 'Unknown error'
20+
console.error('[refresh] Unhandled error:', message)
21+
return NextResponse.json({ success: false, error: message })
22+
}
23+
}

lib/refresh-opportunities.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { prisma } from '@/lib/db/client'
2+
import { fetchSubredditPosts, type RedditPost } from '@/lib/reddit'
3+
import { fetchHNStories, type HNStory } from '@/lib/hn'
4+
import { scorePosts, type PostToScore } from '@/lib/scorer'
5+
6+
interface NormalizedPost {
7+
externalId: string
8+
title: string
9+
body: string
10+
author: string
11+
score: number
12+
commentCount: number
13+
url: string
14+
postedAt: Date
15+
platform: string
16+
subreddit: string | null
17+
}
18+
19+
function fromReddit(post: RedditPost): NormalizedPost {
20+
return { ...post, platform: 'reddit', subreddit: post.subreddit }
21+
}
22+
23+
function fromHN(story: HNStory): NormalizedPost {
24+
return { ...story, platform: 'hn', subreddit: null }
25+
}
26+
27+
function matchesKeywords(post: NormalizedPost, keywords: string[]): boolean {
28+
if (keywords.length === 0) return false
29+
const haystack = `${post.title} ${post.body}`.toLowerCase()
30+
return keywords.some((kw) => {
31+
const phrase = kw.toLowerCase()
32+
if (haystack.includes(phrase)) return true
33+
const words = phrase.split(/\s+/).filter((w) => w.length > 4)
34+
return words.some((word) => haystack.includes(word))
35+
})
36+
}
37+
38+
export async function refreshOpportunitiesForUser(userId: string): Promise<number> {
39+
const profile = await prisma.profile.findUnique({
40+
where: { id: userId },
41+
select: { keywords: true, subreddits: true, productDescription: true, targetCustomer: true },
42+
})
43+
44+
if (!profile || profile.keywords.length === 0) {
45+
console.log(`[refresh] User ${userId} — no keywords, skipping`)
46+
return 0
47+
}
48+
49+
// Fetch Reddit posts for each of the user's subreddits
50+
const redditPosts: NormalizedPost[] = []
51+
await Promise.all(
52+
profile.subreddits.map(async (subreddit) => {
53+
try {
54+
const posts = await fetchSubredditPosts(subreddit)
55+
redditPosts.push(...posts.map(fromReddit))
56+
console.log(`[refresh] r/${subreddit}: fetched ${posts.length} post(s)`)
57+
} catch {
58+
console.error(`[refresh] Failed to fetch r/${subreddit}`)
59+
}
60+
})
61+
)
62+
63+
// Fetch HN stories
64+
let hnStories: NormalizedPost[] = []
65+
try {
66+
hnStories = (await fetchHNStories(150)).map(fromHN)
67+
console.log(`[refresh] HN: fetched ${hnStories.length} story(ies)`)
68+
} catch {
69+
console.error('[refresh] Failed to fetch HN stories')
70+
}
71+
72+
const candidates = [...redditPosts, ...hnStories]
73+
const matched = candidates.filter((post) => matchesKeywords(post, profile.keywords))
74+
75+
console.log(`[refresh] User ${userId} — candidates: ${candidates.length}, matched: ${matched.length}`)
76+
77+
if (matched.length === 0) return 0
78+
79+
// Cap at 30 most recent
80+
const capped = matched
81+
.sort((a, b) => b.postedAt.getTime() - a.postedAt.getTime())
82+
.slice(0, 30)
83+
84+
const postsToScore: PostToScore[] = capped.map((post) => ({
85+
externalId: post.externalId,
86+
title: post.title,
87+
body: post.body,
88+
subreddit: post.subreddit,
89+
postedAt: post.postedAt,
90+
commentCount: post.commentCount,
91+
}))
92+
93+
let scored = await scorePosts(
94+
postsToScore,
95+
profile.productDescription ?? '',
96+
profile.targetCustomer ?? ''
97+
).catch((err) => {
98+
console.error(`[refresh] Scoring failed for user ${userId}:`, err)
99+
return []
100+
})
101+
102+
scored = scored.filter((p) => p.relevanceScore >= 40)
103+
console.log(`[refresh] User ${userId} — scored (relevance>=40): ${scored.length}`)
104+
105+
if (scored.length === 0) return 0
106+
107+
const metaMap = new Map(capped.map((p) => [p.externalId, p]))
108+
109+
const result = await prisma.opportunity.createMany({
110+
data: scored.map((p) => {
111+
const meta = metaMap.get(p.externalId)!
112+
return {
113+
userId,
114+
platform: meta.platform,
115+
externalId: p.externalId,
116+
url: meta.url,
117+
title: p.title,
118+
body: p.body || null,
119+
subreddit: p.subreddit,
120+
author: meta.author,
121+
score: meta.score,
122+
commentCount: p.commentCount,
123+
postedAt: p.postedAt,
124+
relevanceScore: p.relevanceScore,
125+
intentLevel: p.intentLevel,
126+
reasoning: p.reasoning,
127+
suggestedReplies: [],
128+
}
129+
}),
130+
skipDuplicates: true,
131+
})
132+
133+
console.log(`[refresh] User ${userId} — saved ${result.count} new opportunity(ies)`)
134+
return result.count
135+
}

0 commit comments

Comments
 (0)