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.
@@ -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