algocoach 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +101 -0
- package/cli/index.ts +97 -0
- package/dist/assets/index-C4ELaZwf.css +1 -0
- package/dist/assets/index-D5iezweF.js +55 -0
- package/dist/favicon.svg +11 -0
- package/dist/icons.svg +24 -0
- package/dist/index.html +17 -0
- package/package.json +59 -0
- package/server/auth/index.ts +14 -0
- package/server/db/custom-types.ts +13 -0
- package/server/db/index.ts +32 -0
- package/server/db/schema.ts +130 -0
- package/server/db/setup.ts +144 -0
- package/server/index.ts +57 -0
- package/server/lib/validation.test.ts +91 -0
- package/server/lib/validation.ts +25 -0
- package/server/local-dev/index.ts +5 -0
- package/server/local-dev/test-ai.ts +88 -0
- package/server/middleware/auth.ts +30 -0
- package/server/middleware/rate-limit.test.ts +77 -0
- package/server/middleware/rate-limit.ts +30 -0
- package/server/routes/leetcode.ts +189 -0
- package/server/routes/onboard.ts +75 -0
- package/server/routes/plan.ts +595 -0
- package/server/routes/survey.ts +44 -0
- package/server/services/ai-provider.ts +171 -0
- package/server/services/ai.test.ts +61 -0
- package/server/services/ai.ts +368 -0
- package/server/services/leetcode-search.test.ts +85 -0
- package/server/services/leetcode-search.ts +84 -0
- package/server/services/leetcode.ts +84 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
|
|
2
|
+
import { getDb } from "../db"
|
|
3
|
+
import { userPreferences, roadmapPlan } from "../db/schema"
|
|
4
|
+
import { eq } from "drizzle-orm"
|
|
5
|
+
import { createProvider, extractJson } from "../services/ai-provider"
|
|
6
|
+
|
|
7
|
+
const USER_ID = process.env.LOCAL_USER_ID || "dev-user-id"
|
|
8
|
+
|
|
9
|
+
async function main() {
|
|
10
|
+
console.log("š§ Local Dev: AI Provider Test\n")
|
|
11
|
+
|
|
12
|
+
console.log("Environment:")
|
|
13
|
+
console.log(` AI_PROVIDER: ${process.env.AI_PROVIDER || "(not set, default: google)"}`)
|
|
14
|
+
console.log(` AI_MODEL: ${process.env.AI_MODEL || "(not set, using provider default)"}`)
|
|
15
|
+
console.log(` GEMINI_API_KEY: ${process.env.GEMINI_API_KEY ? "ā
set" : "ā not set"}`)
|
|
16
|
+
console.log(` GROQ_API_KEY: ${process.env.GROQ_API_KEY ? "ā
set" : "ā not set"}`)
|
|
17
|
+
console.log("")
|
|
18
|
+
|
|
19
|
+
console.log("1ļøā£ Creating provider...")
|
|
20
|
+
let provider
|
|
21
|
+
try {
|
|
22
|
+
provider = createProvider()
|
|
23
|
+
console.log(` ā
Provider: ${provider.constructor.name}, model: ${provider.model}\n`)
|
|
24
|
+
} catch (e: any) {
|
|
25
|
+
console.log(` ā ${e.message}\n`)
|
|
26
|
+
process.exit(1)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.log("2ļøā£ Testing simple generation...")
|
|
30
|
+
let rawText = ""
|
|
31
|
+
try {
|
|
32
|
+
rawText = await provider.generate({
|
|
33
|
+
prompt: "Say hello as JSON: {\"message\": \"hello\"}",
|
|
34
|
+
temperature: 0.1,
|
|
35
|
+
jsonMode: true,
|
|
36
|
+
})
|
|
37
|
+
const cleaned = extractJson(rawText)
|
|
38
|
+
const parsed = JSON.parse(cleaned)
|
|
39
|
+
console.log(` ā
Response: ${JSON.stringify(parsed)}\n`)
|
|
40
|
+
} catch (e: any) {
|
|
41
|
+
console.log(` ā ${e.message}`)
|
|
42
|
+
console.log(` Raw AI response (first 500 chars):\n${"ā".repeat(60)}\n${rawText.slice(0, 500)}\n${"ā".repeat(60)}`)
|
|
43
|
+
console.log(` Cleaned (first 500 chars):\n${"ā".repeat(60)}\n${extractJson(rawText).slice(0, 500)}\n${"ā".repeat(60)}`)
|
|
44
|
+
process.exit(1)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
console.log("3ļøā£ Testing roadmap generation...")
|
|
48
|
+
try {
|
|
49
|
+
const prefs = await getDb().query.userPreferences.findFirst({
|
|
50
|
+
where: eq(userPreferences.userId, USER_ID),
|
|
51
|
+
})
|
|
52
|
+
if (!prefs) {
|
|
53
|
+
console.log(" āļø No user preferences found (run onboarding first or create manually)")
|
|
54
|
+
} else {
|
|
55
|
+
const prompt = `You are a LeetCode coach creating a personalized study roadmap.
|
|
56
|
+
|
|
57
|
+
User Profile:
|
|
58
|
+
- Experience: ${prefs.experienceLevel}
|
|
59
|
+
- Goals: ${prefs.goals.join(", ")}
|
|
60
|
+
- Weak topics: ${prefs.weakTopics.join(", ")}
|
|
61
|
+
- Target companies: ${prefs.targetCompanies?.join(", ") || "Not specified"}
|
|
62
|
+
- Hours per week: ${prefs.hoursPerWeek}
|
|
63
|
+
- Target date: ${prefs.targetDate || "No deadline"}
|
|
64
|
+
|
|
65
|
+
Return a JSON array where each entry has: week (number), topic (string), description (string), problemsCount (number).
|
|
66
|
+
Aim for 4 weeks.`
|
|
67
|
+
|
|
68
|
+
rawText = ""
|
|
69
|
+
rawText = await provider.generate({ prompt, temperature: 0.7, jsonMode: true })
|
|
70
|
+
const cleaned = extractJson(rawText)
|
|
71
|
+
const parsed = JSON.parse(cleaned)
|
|
72
|
+
const weeks = Array.isArray(parsed) ? parsed : parsed.weeks || parsed.roadmap || []
|
|
73
|
+
console.log(` ā
Generated ${weeks.length} weeks`)
|
|
74
|
+
weeks.forEach((w: any) => console.log(` Week ${w.week}: ${w.topic}`))
|
|
75
|
+
}
|
|
76
|
+
} catch (e: any) {
|
|
77
|
+
console.log(` ā ${e.message}`)
|
|
78
|
+
console.log(` Raw AI response (first 500 chars):\n${"ā".repeat(60)}\n${rawText.slice(0, 500)}\n${"ā".repeat(60)}`)
|
|
79
|
+
if (e.stack) console.log(` Stack: ${e.stack.split("\n").slice(0, 3).join("\n ")}`)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log("\nā
Done")
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
main().catch((e) => {
|
|
86
|
+
console.error("Fatal:", e)
|
|
87
|
+
process.exit(1)
|
|
88
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createMiddleware } from 'hono/factory'
|
|
2
|
+
import { auth } from '../auth'
|
|
3
|
+
import { db } from '../db'
|
|
4
|
+
import { user } from '../db/schema'
|
|
5
|
+
import { eq } from 'drizzle-orm'
|
|
6
|
+
import { isLocalDev, DEV_USER_ID } from '../local-dev'
|
|
7
|
+
|
|
8
|
+
export const authMiddleware = createMiddleware(async (c, next) => {
|
|
9
|
+
if (isLocalDev()) {
|
|
10
|
+
const userId = process.env.LOCAL_USER_ID || DEV_USER_ID
|
|
11
|
+
const existing = await db.query.user.findFirst({ where: eq(user.id, userId) })
|
|
12
|
+
if (!existing) {
|
|
13
|
+
await db.insert(user).values({
|
|
14
|
+
id: userId,
|
|
15
|
+
name: 'Dev User',
|
|
16
|
+
email: 'dev@local.dev',
|
|
17
|
+
emailVerified: true,
|
|
18
|
+
createdAt: new Date(),
|
|
19
|
+
updatedAt: new Date(),
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
c.set('userId', userId)
|
|
23
|
+
await next()
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
const session = await auth.api.getSession({ headers: c.req.raw.headers })
|
|
27
|
+
if (!session) return c.json({ error: 'Unauthorized' }, 401)
|
|
28
|
+
c.set('userId', session.user.id)
|
|
29
|
+
await next()
|
|
30
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test"
|
|
2
|
+
|
|
3
|
+
describe("rateLimit middleware", () => {
|
|
4
|
+
test("allows requests within limit", async () => {
|
|
5
|
+
const { rateLimit } = await import("./rate-limit")
|
|
6
|
+
const middleware = rateLimit(5, 60000)
|
|
7
|
+
|
|
8
|
+
let status = 0
|
|
9
|
+
const c = {
|
|
10
|
+
req: { header: () => "127.0.0.1", path: "/test" },
|
|
11
|
+
json: (body: any, s?: number) => { status = s || 200; return body },
|
|
12
|
+
} as any
|
|
13
|
+
|
|
14
|
+
for (let i = 0; i < 5; i++) {
|
|
15
|
+
status = 0
|
|
16
|
+
let calledNext = false
|
|
17
|
+
await middleware(c, () => { calledNext = true; return Promise.resolve() })
|
|
18
|
+
expect(calledNext).toBe(true)
|
|
19
|
+
expect(status).not.toBe(429)
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test("blocks requests exceeding limit", async () => {
|
|
24
|
+
// Clear module cache to get fresh rate limit store
|
|
25
|
+
const { rateLimit } = await import("./rate-limit")
|
|
26
|
+
const middleware = rateLimit(3, 60000)
|
|
27
|
+
|
|
28
|
+
let status = 0
|
|
29
|
+
let body: any = null
|
|
30
|
+
const c = {
|
|
31
|
+
req: { header: () => "127.0.0.1", path: "/test-block" },
|
|
32
|
+
json: (b: any, s?: number) => { status = s || 200; body = b; return body },
|
|
33
|
+
} as any
|
|
34
|
+
|
|
35
|
+
for (let i = 0; i < 3; i++) {
|
|
36
|
+
status = 0
|
|
37
|
+
await middleware(c, () => Promise.resolve())
|
|
38
|
+
}
|
|
39
|
+
expect(status).not.toBe(429)
|
|
40
|
+
|
|
41
|
+
// 4th request should be blocked
|
|
42
|
+
status = 0
|
|
43
|
+
await middleware(c, () => Promise.resolve())
|
|
44
|
+
expect(status).toBe(429)
|
|
45
|
+
expect(body.error).toContain("Too many requests")
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test("resets window after expiry", async () => {
|
|
49
|
+
const { rateLimit } = await import("./rate-limit")
|
|
50
|
+
const middleware = rateLimit(2, 50) // 50ms window
|
|
51
|
+
|
|
52
|
+
let status = 0
|
|
53
|
+
const c = {
|
|
54
|
+
req: { header: () => "127.0.0.2", path: "/test-reset" },
|
|
55
|
+
json: (b: any, s?: number) => { status = s || 200; return b },
|
|
56
|
+
} as any
|
|
57
|
+
|
|
58
|
+
// Use up the limit
|
|
59
|
+
await middleware(c, () => Promise.resolve())
|
|
60
|
+
await middleware(c, () => Promise.resolve())
|
|
61
|
+
|
|
62
|
+
// 3rd should block
|
|
63
|
+
status = 0
|
|
64
|
+
await middleware(c, () => Promise.resolve())
|
|
65
|
+
expect(status).toBe(429)
|
|
66
|
+
|
|
67
|
+
// Wait for window to expire
|
|
68
|
+
await new Promise(r => setTimeout(r, 60))
|
|
69
|
+
|
|
70
|
+
// Should be allowed again
|
|
71
|
+
status = 0
|
|
72
|
+
let calledNext = false
|
|
73
|
+
await middleware(c, () => { calledNext = true; return Promise.resolve() })
|
|
74
|
+
expect(calledNext).toBe(true)
|
|
75
|
+
expect(status).not.toBe(429)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createMiddleware } from 'hono/factory'
|
|
2
|
+
|
|
3
|
+
interface Entry {
|
|
4
|
+
count: number
|
|
5
|
+
resetAt: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const store = new Map<string, Entry>()
|
|
9
|
+
|
|
10
|
+
export function rateLimit(maxRequests: number, windowMs: number) {
|
|
11
|
+
return createMiddleware(async (c, next) => {
|
|
12
|
+
const ip = c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'unknown'
|
|
13
|
+
const key = `${ip}:${c.req.path}`
|
|
14
|
+
const now = Date.now()
|
|
15
|
+
|
|
16
|
+
let entry = store.get(key)
|
|
17
|
+
if (!entry || now > entry.resetAt) {
|
|
18
|
+
entry = { count: 0, resetAt: now + windowMs }
|
|
19
|
+
store.set(key, entry)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
entry.count++
|
|
23
|
+
|
|
24
|
+
if (entry.count > maxRequests) {
|
|
25
|
+
return c.json({ error: 'Too many requests, please try again later' }, 429)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
await next()
|
|
29
|
+
})
|
|
30
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { Hono } from 'hono'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { db } from '../db'
|
|
4
|
+
import { leetcodeAccount, dailyProgress } from '../db/schema'
|
|
5
|
+
import { fetchLeetcodeStats } from '../services/leetcode'
|
|
6
|
+
import { linkLeetcodeSchema, logProblemSchema } from '../lib/validation'
|
|
7
|
+
import { authMiddleware } from '../middleware/auth'
|
|
8
|
+
import { eq, and, desc } from 'drizzle-orm'
|
|
9
|
+
|
|
10
|
+
const app = new Hono<{ Variables: { userId: string } }>()
|
|
11
|
+
app.use('/*', authMiddleware)
|
|
12
|
+
|
|
13
|
+
app.post('/link', async (c) => {
|
|
14
|
+
try {
|
|
15
|
+
const userId = c.get('userId')
|
|
16
|
+
const body = await c.req.json()
|
|
17
|
+
const parsed = linkLeetcodeSchema.parse(body)
|
|
18
|
+
|
|
19
|
+
const stats = await fetchLeetcodeStats(parsed.leetcodeUsername)
|
|
20
|
+
|
|
21
|
+
const existing = await db.query.leetcodeAccount.findFirst({
|
|
22
|
+
where: eq(leetcodeAccount.userId, userId),
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const entry = {
|
|
26
|
+
leetcodeUsername: stats.username,
|
|
27
|
+
totalSolved: stats.totalSolved,
|
|
28
|
+
easySolved: stats.easySolved,
|
|
29
|
+
mediumSolved: stats.mediumSolved,
|
|
30
|
+
hardSolved: stats.hardSolved,
|
|
31
|
+
updatedAt: new Date(),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (existing) {
|
|
35
|
+
await db.update(leetcodeAccount).set(entry).where(eq(leetcodeAccount.userId, userId))
|
|
36
|
+
} else {
|
|
37
|
+
await db.insert(leetcodeAccount).values({ id: crypto.randomUUID(), userId, ...entry })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return c.json({ success: true, data: { ...entry, lastFetchedAt: new Date().toISOString() } })
|
|
41
|
+
} catch (err: any) {
|
|
42
|
+
if (err instanceof z.ZodError) return c.json({ success: false, errors: err.issues }, 400)
|
|
43
|
+
return c.json({ success: false, error: err.message || 'Failed to link account' }, 400)
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
app.get('/stats', async (c) => {
|
|
48
|
+
try {
|
|
49
|
+
const userId = c.get('userId')
|
|
50
|
+
|
|
51
|
+
const account = await db.query.leetcodeAccount.findFirst({
|
|
52
|
+
where: eq(leetcodeAccount.userId, userId),
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
if (!account) return c.json({ success: false, error: 'No LeetCode account linked' }, 404)
|
|
56
|
+
|
|
57
|
+
const stale = !account.updatedAt || Date.now() - new Date(account.updatedAt).getTime() > 3_600_000
|
|
58
|
+
|
|
59
|
+
if (stale) {
|
|
60
|
+
try {
|
|
61
|
+
const stats = await fetchLeetcodeStats(account.leetcodeUsername)
|
|
62
|
+
await db.update(leetcodeAccount).set({
|
|
63
|
+
totalSolved: stats.totalSolved,
|
|
64
|
+
easySolved: stats.easySolved,
|
|
65
|
+
mediumSolved: stats.mediumSolved,
|
|
66
|
+
hardSolved: stats.hardSolved,
|
|
67
|
+
updatedAt: new Date(),
|
|
68
|
+
}).where(eq(leetcodeAccount.userId, userId))
|
|
69
|
+
return c.json({ success: true, data: { ...account, ...stats, updatedAt: new Date().toISOString() } })
|
|
70
|
+
} catch {
|
|
71
|
+
return c.json({ success: true, data: account })
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return c.json({ success: true, data: account })
|
|
76
|
+
} catch (err: any) {
|
|
77
|
+
return c.json({ success: false, error: err.message || 'Failed to fetch stats' }, 500)
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
app.post('/refresh', async (c) => {
|
|
82
|
+
try {
|
|
83
|
+
const userId = c.get('userId')
|
|
84
|
+
|
|
85
|
+
const account = await db.query.leetcodeAccount.findFirst({
|
|
86
|
+
where: eq(leetcodeAccount.userId, userId),
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
if (!account) return c.json({ success: false, error: 'No LeetCode account linked' }, 404)
|
|
90
|
+
|
|
91
|
+
const stats = await fetchLeetcodeStats(account.leetcodeUsername)
|
|
92
|
+
|
|
93
|
+
await db.update(leetcodeAccount).set({
|
|
94
|
+
totalSolved: stats.totalSolved,
|
|
95
|
+
easySolved: stats.easySolved,
|
|
96
|
+
mediumSolved: stats.mediumSolved,
|
|
97
|
+
hardSolved: stats.hardSolved,
|
|
98
|
+
updatedAt: new Date(),
|
|
99
|
+
}).where(eq(leetcodeAccount.userId, userId))
|
|
100
|
+
|
|
101
|
+
return c.json({ success: true, data: { ...stats, lastFetchedAt: new Date().toISOString() } })
|
|
102
|
+
} catch (err: any) {
|
|
103
|
+
return c.json({ success: false, error: err.message || 'Failed to refresh stats' }, 500)
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
app.post('/log', async (c) => {
|
|
108
|
+
try {
|
|
109
|
+
const userId = c.get('userId')
|
|
110
|
+
const body = await c.req.json()
|
|
111
|
+
const parsed = logProblemSchema.parse(body)
|
|
112
|
+
|
|
113
|
+
const existing = await db.query.dailyProgress.findFirst({
|
|
114
|
+
where: and(
|
|
115
|
+
eq(dailyProgress.userId, userId),
|
|
116
|
+
eq(dailyProgress.problemId, parsed.problemId),
|
|
117
|
+
),
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
if (existing) {
|
|
121
|
+
return c.json({ success: true, data: existing })
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const entry = {
|
|
125
|
+
id: crypto.randomUUID(),
|
|
126
|
+
userId,
|
|
127
|
+
date: new Date(),
|
|
128
|
+
problemName: parsed.problemName,
|
|
129
|
+
difficulty: parsed.difficulty,
|
|
130
|
+
problemId: parsed.problemId,
|
|
131
|
+
topics: parsed.topics,
|
|
132
|
+
status: 'IN_PROGRESS' as const,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
await db.insert(dailyProgress).values(entry)
|
|
136
|
+
|
|
137
|
+
return c.json({ success: true, data: entry }, 201)
|
|
138
|
+
} catch (err: any) {
|
|
139
|
+
if (err instanceof z.ZodError) return c.json({ success: false, errors: err.issues }, 400)
|
|
140
|
+
return c.json({ success: false, error: err.message || 'Failed to log problem' }, 500)
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
app.patch('/log/:problemId', async (c) => {
|
|
145
|
+
try {
|
|
146
|
+
const userId = c.get('userId')
|
|
147
|
+
const problemId = c.req.param('problemId')
|
|
148
|
+
|
|
149
|
+
const existing = await db.query.dailyProgress.findFirst({
|
|
150
|
+
where: and(
|
|
151
|
+
eq(dailyProgress.userId, userId),
|
|
152
|
+
eq(dailyProgress.problemId, problemId),
|
|
153
|
+
),
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
if (!existing) return c.json({ success: false, error: 'Problem not found' }, 404)
|
|
157
|
+
|
|
158
|
+
await db.update(dailyProgress).set({ status: 'SOLVED' }).where(
|
|
159
|
+
and(eq(dailyProgress.userId, userId), eq(dailyProgress.problemId, problemId)),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
return c.json({ success: true, data: { ...existing, status: 'SOLVED' } })
|
|
163
|
+
} catch (err: any) {
|
|
164
|
+
return c.json({ success: false, error: err.message || 'Failed to update problem' }, 500)
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
app.get('/log', async (c) => {
|
|
169
|
+
try {
|
|
170
|
+
const userId = c.get('userId')
|
|
171
|
+
const status = c.req.query('status')
|
|
172
|
+
|
|
173
|
+
const conditions = [eq(dailyProgress.userId, userId)]
|
|
174
|
+
if (status === 'SOLVED' || status === 'IN_PROGRESS') {
|
|
175
|
+
conditions.push(eq(dailyProgress.status, status))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const problems = await db.query.dailyProgress.findMany({
|
|
179
|
+
where: and(...conditions),
|
|
180
|
+
orderBy: desc(dailyProgress.date),
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
return c.json({ success: true, data: problems })
|
|
184
|
+
} catch (err: any) {
|
|
185
|
+
return c.json({ success: false, error: err.message || 'Failed to fetch problems' }, 500)
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
export default app
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Hono } from 'hono'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { db } from '../db'
|
|
4
|
+
import { userPreferences, roadmapPlan } from '../db/schema'
|
|
5
|
+
import { authMiddleware } from '../middleware/auth'
|
|
6
|
+
import { eq } from 'drizzle-orm'
|
|
7
|
+
import { generateRoadmap } from '../services/ai'
|
|
8
|
+
|
|
9
|
+
const app = new Hono<{ Variables: { userId: string } }>()
|
|
10
|
+
app.use('/*', authMiddleware)
|
|
11
|
+
|
|
12
|
+
const onboardSchema = z.object({
|
|
13
|
+
experienceLevel: z.enum(['beginner', 'intermediate', 'advanced', 'competitive']),
|
|
14
|
+
goals: z.array(z.string()).min(1, 'Select at least one goal'),
|
|
15
|
+
weakTopics: z.array(z.string()).min(1, 'Select at least one weak topic'),
|
|
16
|
+
targetCompanies: z.array(z.string()).optional(),
|
|
17
|
+
hoursPerWeek: z.number().int().min(1).max(168),
|
|
18
|
+
targetDate: z.string().optional(),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
app.get('/', async (c) => {
|
|
22
|
+
try {
|
|
23
|
+
const userId = c.get('userId')
|
|
24
|
+
const prefs = await db.query.userPreferences.findFirst({
|
|
25
|
+
where: eq(userPreferences.userId, userId),
|
|
26
|
+
})
|
|
27
|
+
if (!prefs) return c.json({ success: false, onboarded: false }, 404)
|
|
28
|
+
return c.json({ success: true, onboarded: true, data: prefs })
|
|
29
|
+
} catch (err: any) {
|
|
30
|
+
return c.json({ success: false, error: err.message }, 500)
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
app.post('/', async (c) => {
|
|
35
|
+
try {
|
|
36
|
+
const userId = c.get('userId')
|
|
37
|
+
const body = await c.req.json()
|
|
38
|
+
const parsed = onboardSchema.parse(body)
|
|
39
|
+
|
|
40
|
+
const prefs = {
|
|
41
|
+
userId,
|
|
42
|
+
experienceLevel: parsed.experienceLevel,
|
|
43
|
+
goals: parsed.goals,
|
|
44
|
+
weakTopics: parsed.weakTopics,
|
|
45
|
+
targetCompanies: parsed.targetCompanies || [],
|
|
46
|
+
hoursPerWeek: parsed.hoursPerWeek,
|
|
47
|
+
targetDate: parsed.targetDate ? new Date(parsed.targetDate) : null,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await db.insert(userPreferences).values(prefs).onConflictDoUpdate({
|
|
51
|
+
target: userPreferences.userId,
|
|
52
|
+
set: { ...prefs, updatedAt: new Date() },
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const existingPlan = await db.query.roadmapPlan.findFirst({
|
|
56
|
+
where: eq(roadmapPlan.userId, userId),
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
if (!existingPlan) {
|
|
60
|
+
await db.insert(roadmapPlan).values({
|
|
61
|
+
id: crypto.randomUUID(),
|
|
62
|
+
userId,
|
|
63
|
+
weeks: [],
|
|
64
|
+
currentWeek: 1,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return c.json({ success: true, data: { roadmap: existingPlan?.weeks || null } }, 201)
|
|
69
|
+
} catch (err: any) {
|
|
70
|
+
if (err instanceof z.ZodError) return c.json({ success: false, errors: err.issues }, 400)
|
|
71
|
+
return c.json({ success: false, error: err.message || 'Failed to save preferences' }, 500)
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
export default app
|