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,595 @@
|
|
|
1
|
+
import { Hono } from 'hono'
|
|
2
|
+
import { streamSSE } from 'hono/streaming'
|
|
3
|
+
import { db } from '../db'
|
|
4
|
+
import { userPreferences, roadmapPlan, dailyPlan, dailyProgress, roadmapJob } from '../db/schema'
|
|
5
|
+
import { authMiddleware } from '../middleware/auth'
|
|
6
|
+
import { eq, and, gte, desc, sql } from 'drizzle-orm'
|
|
7
|
+
import { selectDailyProblems, startJobProcessing } from '../services/ai'
|
|
8
|
+
|
|
9
|
+
function sleep(ms: number) {
|
|
10
|
+
return new Promise((r) => setTimeout(r, ms))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function tryParseError(msg: string): string {
|
|
14
|
+
try {
|
|
15
|
+
const top = JSON.parse(msg)
|
|
16
|
+
const inner = top.error?.message || top.message || msg
|
|
17
|
+
if (typeof inner === 'string') {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(inner).error?.message || inner
|
|
20
|
+
} catch {
|
|
21
|
+
return inner
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return String(inner)
|
|
25
|
+
} catch {
|
|
26
|
+
return msg
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const app = new Hono<{ Variables: { userId: string } }>()
|
|
31
|
+
app.use('/*', authMiddleware)
|
|
32
|
+
|
|
33
|
+
app.get('/roadmap', async (c) => {
|
|
34
|
+
try {
|
|
35
|
+
const userId = c.get('userId')
|
|
36
|
+
const plan = await db.query.roadmapPlan.findFirst({
|
|
37
|
+
where: eq(roadmapPlan.userId, userId),
|
|
38
|
+
})
|
|
39
|
+
if (!plan) return c.json({ success: false, error: 'No roadmap found' }, 404)
|
|
40
|
+
const weeks = Array.isArray(plan.weeks) ? plan.weeks : []
|
|
41
|
+
return c.json({ success: true, data: { ...plan, ready: weeks.length > 0 } })
|
|
42
|
+
} catch (err: any) {
|
|
43
|
+
return c.json({ success: false, error: err.message }, 500)
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
app.patch('/roadmap/advance', async (c) => {
|
|
48
|
+
try {
|
|
49
|
+
const userId = c.get('userId')
|
|
50
|
+
const plan = await db.query.roadmapPlan.findFirst({
|
|
51
|
+
where: eq(roadmapPlan.userId, userId),
|
|
52
|
+
})
|
|
53
|
+
if (!plan) return c.json({ success: false, error: 'No roadmap found' }, 404)
|
|
54
|
+
|
|
55
|
+
const weeks = Array.isArray(plan.weeks) ? plan.weeks : []
|
|
56
|
+
if (!weeks.length) return c.json({ success: false, error: 'Roadmap not ready' }, 400)
|
|
57
|
+
|
|
58
|
+
if (plan.currentWeek >= weeks.length) {
|
|
59
|
+
return c.json({ success: false, error: 'Roadmap already completed' }, 400)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const nextWeek = plan.currentWeek + 1
|
|
63
|
+
await db.update(roadmapPlan).set({
|
|
64
|
+
currentWeek: nextWeek,
|
|
65
|
+
updatedAt: new Date(),
|
|
66
|
+
}).where(eq(roadmapPlan.userId, userId))
|
|
67
|
+
|
|
68
|
+
const updated = await db.query.roadmapPlan.findFirst({
|
|
69
|
+
where: eq(roadmapPlan.userId, userId),
|
|
70
|
+
})
|
|
71
|
+
return c.json({ success: true, data: { ...updated, ready: true } })
|
|
72
|
+
} catch (err: any) {
|
|
73
|
+
return c.json({ success: false, error: err.message }, 500)
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
app.post('/roadmap/generate', async (c) => {
|
|
78
|
+
const userId = c.get('userId')
|
|
79
|
+
|
|
80
|
+
const planRecord = await db.query.roadmapPlan.findFirst({
|
|
81
|
+
where: eq(roadmapPlan.userId, userId),
|
|
82
|
+
})
|
|
83
|
+
if (!planRecord) {
|
|
84
|
+
return c.json({ success: false, error: 'Complete onboarding first' }, 400)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const force = c.req.query('force') === 'true'
|
|
88
|
+
const existingWeeks = Array.isArray(planRecord.weeks) ? planRecord.weeks : []
|
|
89
|
+
if (existingWeeks.length > 0 && !force) {
|
|
90
|
+
return c.json({ success: true, data: { ...planRecord, ready: true } })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const prefs = await db.query.userPreferences.findFirst({
|
|
94
|
+
where: eq(userPreferences.userId, userId),
|
|
95
|
+
})
|
|
96
|
+
if (!prefs) {
|
|
97
|
+
return c.json({ success: false, error: 'Complete onboarding first' }, 400)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const jobId = crypto.randomUUID()
|
|
101
|
+
await db.insert(roadmapJob).values({
|
|
102
|
+
id: jobId,
|
|
103
|
+
userId,
|
|
104
|
+
status: "pending",
|
|
105
|
+
createdAt: new Date(),
|
|
106
|
+
updatedAt: new Date(),
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
startJobProcessing(jobId)
|
|
110
|
+
|
|
111
|
+
return c.json({ success: true, data: { jobId } })
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
app.get('/roadmap/jobs/:jobId', async (c) => {
|
|
115
|
+
const userId = c.get('userId')
|
|
116
|
+
const { jobId } = c.req.param()
|
|
117
|
+
|
|
118
|
+
const job = await db.query.roadmapJob.findFirst({
|
|
119
|
+
where: and(eq(roadmapJob.id, jobId), eq(roadmapJob.userId, userId)),
|
|
120
|
+
})
|
|
121
|
+
if (!job) return c.json({ success: false, error: 'Job not found' }, 404)
|
|
122
|
+
|
|
123
|
+
return c.json({ success: true, data: job })
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
app.post('/roadmap/jobs/:jobId/stream', async (c) => {
|
|
127
|
+
const userId = c.get('userId')
|
|
128
|
+
const { jobId } = c.req.param()
|
|
129
|
+
|
|
130
|
+
const job = await db.query.roadmapJob.findFirst({
|
|
131
|
+
where: and(eq(roadmapJob.id, jobId), eq(roadmapJob.userId, userId)),
|
|
132
|
+
})
|
|
133
|
+
if (!job) return c.json({ success: false, error: 'Job not found' }, 404)
|
|
134
|
+
|
|
135
|
+
if (job.status === "pending") {
|
|
136
|
+
startJobProcessing(jobId)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return streamSSE(c, async (stream) => {
|
|
140
|
+
let lastProgress = job.progress || ""
|
|
141
|
+
let lastStatus = job.status
|
|
142
|
+
|
|
143
|
+
if (lastProgress) {
|
|
144
|
+
await stream.writeSSE({ data: JSON.stringify({ type: "token", text: lastProgress }) })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const pollStart = Date.now()
|
|
148
|
+
const MAX_POLL_MS = 180_000
|
|
149
|
+
|
|
150
|
+
while (lastStatus === "pending" || lastStatus === "processing") {
|
|
151
|
+
if (Date.now() - pollStart > MAX_POLL_MS) {
|
|
152
|
+
await stream.writeSSE({ data: JSON.stringify({ type: "error", message: "Generation timed out. Please try again." }) })
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
await sleep(500)
|
|
156
|
+
|
|
157
|
+
const current = await db.query.roadmapJob.findFirst({
|
|
158
|
+
where: and(eq(roadmapJob.id, jobId), eq(roadmapJob.userId, userId)),
|
|
159
|
+
})
|
|
160
|
+
if (!current) break
|
|
161
|
+
|
|
162
|
+
const newProgress = current.progress || ""
|
|
163
|
+
if (newProgress.length > lastProgress.length && newProgress.startsWith(lastProgress)) {
|
|
164
|
+
const delta = newProgress.slice(lastProgress.length)
|
|
165
|
+
lastProgress = newProgress
|
|
166
|
+
await stream.writeSSE({ data: JSON.stringify({ type: "token", text: delta }) })
|
|
167
|
+
} else if (newProgress !== lastProgress) {
|
|
168
|
+
lastProgress = newProgress
|
|
169
|
+
await stream.writeSSE({ data: JSON.stringify({ type: "token", text: newProgress }) })
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
lastStatus = current.status
|
|
173
|
+
if (lastStatus === "done") {
|
|
174
|
+
const planRecord = await db.query.roadmapPlan.findFirst({
|
|
175
|
+
where: eq(roadmapPlan.userId, userId),
|
|
176
|
+
})
|
|
177
|
+
await stream.writeSSE({ data: JSON.stringify({ type: "done", data: { ...planRecord, ready: true } }) })
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
if (lastStatus === "error") {
|
|
181
|
+
const msg = current.error || "Unknown error"
|
|
182
|
+
const parsed = tryParseError(msg)
|
|
183
|
+
if (parsed.includes("API_KEY") || parsed.includes("API key")) {
|
|
184
|
+
await stream.writeSSE({ data: JSON.stringify({ type: "error", message: "Invalid or missing AI API key. Check your GEMINI_API_KEY, GROQ_API_KEY, or NVIDIA_API_KEY." }) })
|
|
185
|
+
} else if (parsed.includes("429") || parsed.includes("quota") || parsed.includes("Too Many Requests") || parsed.includes("RATE_LIMIT")) {
|
|
186
|
+
await stream.writeSSE({ data: JSON.stringify({ type: "error", message: "AI quota reached. Please try again in a minute.", retryAfter: 60 }) })
|
|
187
|
+
} else if (parsed.includes("500") || parsed.includes("INTERNAL") || parsed.includes("Internal error") || parsed.includes("internalError") || parsed.includes("internalServerError")) {
|
|
188
|
+
await stream.writeSSE({ data: JSON.stringify({ type: "error", message: `AI provider returned an error: ${parsed}. The model may be unavailable. Try setting AI_PROVIDER=groq, AI_PROVIDER=nvidia, or a different AI_MODEL.` }) })
|
|
189
|
+
} else {
|
|
190
|
+
await stream.writeSSE({ data: JSON.stringify({ type: "error", message: parsed }) })
|
|
191
|
+
}
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (lastStatus === "done") {
|
|
197
|
+
const planRecord = await db.query.roadmapPlan.findFirst({
|
|
198
|
+
where: eq(roadmapPlan.userId, userId),
|
|
199
|
+
})
|
|
200
|
+
await stream.writeSSE({ data: JSON.stringify({ type: "done", data: { ...planRecord, ready: true } }) })
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
app.get('/roadmap/progress', async (c) => {
|
|
206
|
+
try {
|
|
207
|
+
const userId = c.get('userId')
|
|
208
|
+
|
|
209
|
+
const plan = await db.query.roadmapPlan.findFirst({
|
|
210
|
+
where: eq(roadmapPlan.userId, userId),
|
|
211
|
+
})
|
|
212
|
+
if (!plan) return c.json({ success: false, error: 'No roadmap found' }, 404)
|
|
213
|
+
|
|
214
|
+
const weeks = Array.isArray(plan.weeks) ? plan.weeks : []
|
|
215
|
+
if (!weeks.length) return c.json({ success: false, error: 'Roadmap not ready' }, 400)
|
|
216
|
+
|
|
217
|
+
const allPlans = await db.query.dailyPlan.findMany({
|
|
218
|
+
where: eq(dailyPlan.userId, userId),
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
const progress = weeks.map((w: any) => {
|
|
222
|
+
const weekPlans = allPlans.filter((p: any) => p.weekNumber === w.week)
|
|
223
|
+
const solved = weekPlans.reduce((count: number, plan: any) => {
|
|
224
|
+
const problems = Array.isArray(plan.problems) ? plan.problems : []
|
|
225
|
+
return count + problems.filter((p: any) => (p as Record<string, unknown>).status === "SOLVED").length
|
|
226
|
+
}, 0)
|
|
227
|
+
const total = weekPlans.reduce((count: number, plan: any) => {
|
|
228
|
+
const problems = Array.isArray(plan.problems) ? plan.problems : []
|
|
229
|
+
return count + problems.length
|
|
230
|
+
}, 0)
|
|
231
|
+
return {
|
|
232
|
+
week: w.week,
|
|
233
|
+
topic: w.topic,
|
|
234
|
+
targetCount: w.problemsCount,
|
|
235
|
+
assignedCount: total,
|
|
236
|
+
solvedCount: solved,
|
|
237
|
+
percent: total > 0 ? Math.round((solved / total) * 100) : 0,
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
return c.json({ success: true, data: progress })
|
|
242
|
+
} catch (err: any) {
|
|
243
|
+
return c.json({ success: false, error: err.message }, 500)
|
|
244
|
+
}
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
app.get('/today', async (c) => {
|
|
248
|
+
try {
|
|
249
|
+
const userId = c.get('userId')
|
|
250
|
+
const todayMs = Date.now() - (Date.now() % 86400000)
|
|
251
|
+
const tomorrowMs = todayMs + 86400000
|
|
252
|
+
|
|
253
|
+
const plan = await db.query.dailyPlan.findFirst({
|
|
254
|
+
where: and(
|
|
255
|
+
eq(dailyPlan.userId, userId),
|
|
256
|
+
sql`${dailyPlan.date} >= ${todayMs}`,
|
|
257
|
+
sql`${dailyPlan.date} < ${tomorrowMs}`,
|
|
258
|
+
),
|
|
259
|
+
})
|
|
260
|
+
if (!plan) return c.json({ success: false, exists: false }, 404)
|
|
261
|
+
return c.json({ success: true, exists: true, data: plan })
|
|
262
|
+
} catch (err: any) {
|
|
263
|
+
return c.json({ success: false, error: err.message }, 500)
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
app.post('/today', async (c) => {
|
|
268
|
+
try {
|
|
269
|
+
const userId = c.get('userId')
|
|
270
|
+
const body: any = await c.req.json().catch(() => ({}))
|
|
271
|
+
const difficultyFilter = (body.difficulty || "MIXED") as "EASY" | "MEDIUM" | "HARD" | "MIXED"
|
|
272
|
+
|
|
273
|
+
const todayMs = Date.now() - (Date.now() % 86400000)
|
|
274
|
+
const tomorrowMs = todayMs + 86400000
|
|
275
|
+
|
|
276
|
+
const existing = await db.query.dailyPlan.findFirst({
|
|
277
|
+
where: and(
|
|
278
|
+
eq(dailyPlan.userId, userId),
|
|
279
|
+
sql`${dailyPlan.date} >= ${todayMs}`,
|
|
280
|
+
sql`${dailyPlan.date} < ${tomorrowMs}`,
|
|
281
|
+
),
|
|
282
|
+
})
|
|
283
|
+
if (existing) return c.json({ success: true, data: existing, exists: true }, 200)
|
|
284
|
+
|
|
285
|
+
const planRecord = await db.query.roadmapPlan.findFirst({
|
|
286
|
+
where: eq(roadmapPlan.userId, userId),
|
|
287
|
+
})
|
|
288
|
+
if (!planRecord) return c.json({ success: false, error: 'No roadmap found. Complete onboarding first.' }, 400)
|
|
289
|
+
|
|
290
|
+
const roadmap = Array.isArray(planRecord.weeks) ? planRecord.weeks : []
|
|
291
|
+
if (roadmap.length === 0) return c.json({ success: false, error: 'Roadmap is still being generated. Please try again shortly.' }, 400)
|
|
292
|
+
const currentWeek = planRecord.currentWeek
|
|
293
|
+
|
|
294
|
+
const allPlans = await db.query.dailyPlan.findMany({
|
|
295
|
+
where: eq(dailyPlan.userId, userId),
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
const dedupCount: Record<string, number> = {}
|
|
299
|
+
for (const plan of allPlans) {
|
|
300
|
+
const problems = Array.isArray(plan.problems) ? plan.problems : []
|
|
301
|
+
for (const p of problems) {
|
|
302
|
+
const slug = (p as Record<string, unknown>).titleSlug as string
|
|
303
|
+
if (slug) dedupCount[slug] = (dedupCount[slug] || 0) + 1
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const progress = await db.query.dailyProgress.findMany({
|
|
308
|
+
where: eq(dailyProgress.userId, userId),
|
|
309
|
+
})
|
|
310
|
+
const solvedSlugs = progress
|
|
311
|
+
.filter((p) => p.status === 'SOLVED')
|
|
312
|
+
.map((p) => p.problemId)
|
|
313
|
+
|
|
314
|
+
const task = await selectDailyProblems({
|
|
315
|
+
roadmap,
|
|
316
|
+
currentWeek,
|
|
317
|
+
progress: progress.map((p) => ({ problemId: p.problemId, status: p.status })),
|
|
318
|
+
dedupCount,
|
|
319
|
+
solvedSlugs,
|
|
320
|
+
difficultyFilter,
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
const problems = task.problems.map((p) => ({
|
|
324
|
+
...p,
|
|
325
|
+
status: "PENDING",
|
|
326
|
+
completedAt: null,
|
|
327
|
+
}))
|
|
328
|
+
|
|
329
|
+
const entry = {
|
|
330
|
+
id: crypto.randomUUID(),
|
|
331
|
+
userId,
|
|
332
|
+
date: new Date(),
|
|
333
|
+
weekNumber: currentWeek,
|
|
334
|
+
topic: (roadmap[currentWeek - 1] as Record<string, unknown>)?.topic as string || '',
|
|
335
|
+
problems,
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
await db.insert(dailyPlan).values(entry)
|
|
339
|
+
|
|
340
|
+
return c.json({ success: true, data: { ...entry, explanation: task.explanation } }, 201)
|
|
341
|
+
} catch (err: any) {
|
|
342
|
+
return c.json({ success: false, error: `Failed to generate daily plan: ${err.message}` }, 500)
|
|
343
|
+
}
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
app.patch('/today/:planId/problem/:slug', async (c) => {
|
|
347
|
+
try {
|
|
348
|
+
const userId = c.get('userId')
|
|
349
|
+
const { planId, slug } = c.req.param()
|
|
350
|
+
const body: any = await c.req.json()
|
|
351
|
+
const status = body.status as string
|
|
352
|
+
if (!["SOLVED", "TRIED", "SKIPPED", "PENDING"].includes(status)) {
|
|
353
|
+
return c.json({ success: false, error: 'Invalid status. Use SOLVED, TRIED, SKIPPED, or PENDING.' }, 400)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const plan = await db.query.dailyPlan.findFirst({
|
|
357
|
+
where: and(eq(dailyPlan.id, planId), eq(dailyPlan.userId, userId)),
|
|
358
|
+
})
|
|
359
|
+
if (!plan) return c.json({ success: false, error: 'Plan not found' }, 404)
|
|
360
|
+
|
|
361
|
+
const problems = Array.isArray(plan.problems) ? [...plan.problems] : []
|
|
362
|
+
const idx = problems.findIndex((p: any) => (p as Record<string, unknown>).titleSlug === slug)
|
|
363
|
+
if (idx === -1) return c.json({ success: false, error: 'Problem not found in this plan' }, 404)
|
|
364
|
+
|
|
365
|
+
const problem = { ...problems[idx], status, completedAt: status === "SOLVED" ? new Date().toISOString() : null }
|
|
366
|
+
problems[idx] = problem
|
|
367
|
+
|
|
368
|
+
await db.update(dailyPlan)
|
|
369
|
+
.set({ problems })
|
|
370
|
+
.where(eq(dailyPlan.id, planId))
|
|
371
|
+
|
|
372
|
+
const existingProgress = await db.query.dailyProgress.findFirst({
|
|
373
|
+
where: and(
|
|
374
|
+
eq(dailyProgress.userId, userId),
|
|
375
|
+
eq(dailyProgress.problemId, slug),
|
|
376
|
+
),
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
if (existingProgress) {
|
|
380
|
+
await db.update(dailyProgress)
|
|
381
|
+
.set({ status, date: new Date() })
|
|
382
|
+
.where(eq(dailyProgress.id, existingProgress.id))
|
|
383
|
+
} else {
|
|
384
|
+
await db.insert(dailyProgress).values({
|
|
385
|
+
id: crypto.randomUUID(),
|
|
386
|
+
userId,
|
|
387
|
+
date: new Date(),
|
|
388
|
+
problemName: problem.title as string,
|
|
389
|
+
difficulty: problem.difficulty as string,
|
|
390
|
+
problemId: slug,
|
|
391
|
+
topics: (problem.topicTags as string[]) || [],
|
|
392
|
+
status,
|
|
393
|
+
})
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return c.json({ success: true, data: { ...problem, status, completedAt: problem.completedAt } })
|
|
397
|
+
} catch (err: any) {
|
|
398
|
+
return c.json({ success: false, error: err.message }, 500)
|
|
399
|
+
}
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
app.post('/today/:planId/regenerate', async (c) => {
|
|
403
|
+
try {
|
|
404
|
+
const userId = c.get('userId')
|
|
405
|
+
const { planId } = c.req.param()
|
|
406
|
+
const body: any = await c.req.json().catch(() => ({}))
|
|
407
|
+
const slot = body.slot as number | undefined
|
|
408
|
+
const easier = body.easier === true
|
|
409
|
+
|
|
410
|
+
const plan = await db.query.dailyPlan.findFirst({
|
|
411
|
+
where: and(eq(dailyPlan.id, planId), eq(dailyPlan.userId, userId)),
|
|
412
|
+
})
|
|
413
|
+
if (!plan) return c.json({ success: false, error: 'Plan not found' }, 404)
|
|
414
|
+
|
|
415
|
+
const existingProblems = Array.isArray(plan.problems) ? [...plan.problems] : []
|
|
416
|
+
|
|
417
|
+
if (slot !== undefined) {
|
|
418
|
+
if (slot < 0 || slot >= existingProblems.length) {
|
|
419
|
+
return c.json({ success: false, error: `Invalid slot. Must be 0-${existingProblems.length - 1}` }, 400)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const planRecord = await db.query.roadmapPlan.findFirst({
|
|
424
|
+
where: eq(roadmapPlan.userId, userId),
|
|
425
|
+
})
|
|
426
|
+
const roadmap = Array.isArray(planRecord?.weeks) ? planRecord.weeks : []
|
|
427
|
+
|
|
428
|
+
const allPlans = await db.query.dailyPlan.findMany({
|
|
429
|
+
where: eq(dailyPlan.userId, userId),
|
|
430
|
+
})
|
|
431
|
+
const dedupCount: Record<string, number> = {}
|
|
432
|
+
for (const p of allPlans) {
|
|
433
|
+
const probs = Array.isArray(p.problems) ? p.problems : []
|
|
434
|
+
for (const prob of probs) {
|
|
435
|
+
const slug = (prob as Record<string, unknown>).titleSlug as string
|
|
436
|
+
if (slug) dedupCount[slug] = (dedupCount[slug] || 0) + 1
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
const progress = await db.query.dailyProgress.findMany({
|
|
440
|
+
where: eq(dailyProgress.userId, userId),
|
|
441
|
+
})
|
|
442
|
+
const solvedSlugs = progress.filter((p) => p.status === 'SOLVED').map((p) => p.problemId)
|
|
443
|
+
|
|
444
|
+
const currentPlanSlugs = existingProblems.map((p: any) => (p as Record<string, unknown>).titleSlug as string)
|
|
445
|
+
|
|
446
|
+
if (slot !== undefined) {
|
|
447
|
+
const replaced = existingProblems[slot] as Record<string, unknown>
|
|
448
|
+
let diffFilter: "EASY" | "MEDIUM" | "HARD" | "MIXED" = "MIXED"
|
|
449
|
+
if (easier) {
|
|
450
|
+
const curDiff = (replaced.difficulty as string) || "MEDIUM"
|
|
451
|
+
diffFilter = curDiff === "HARD" ? "MEDIUM" : curDiff === "MEDIUM" ? "EASY" : "EASY"
|
|
452
|
+
}
|
|
453
|
+
const result = await selectDailyProblems({
|
|
454
|
+
roadmap,
|
|
455
|
+
currentWeek: plan.weekNumber,
|
|
456
|
+
progress: progress.map((p) => ({ problemId: p.problemId, status: p.status })),
|
|
457
|
+
dedupCount,
|
|
458
|
+
solvedSlugs,
|
|
459
|
+
count: 1,
|
|
460
|
+
difficultyFilter: diffFilter,
|
|
461
|
+
excludeSlugs: currentPlanSlugs,
|
|
462
|
+
})
|
|
463
|
+
const newProblem = result.problems[0]
|
|
464
|
+
if (!newProblem) {
|
|
465
|
+
return c.json({ success: false, error: 'No alternative problem found to replace this slot.' }, 400)
|
|
466
|
+
}
|
|
467
|
+
existingProblems[slot] = { ...newProblem, status: "PENDING", completedAt: null }
|
|
468
|
+
} else {
|
|
469
|
+
const result = await selectDailyProblems({
|
|
470
|
+
roadmap,
|
|
471
|
+
currentWeek: plan.weekNumber,
|
|
472
|
+
progress: progress.map((p) => ({ problemId: p.problemId, status: p.status })),
|
|
473
|
+
dedupCount,
|
|
474
|
+
solvedSlugs,
|
|
475
|
+
count: 3,
|
|
476
|
+
difficultyFilter: "MIXED",
|
|
477
|
+
excludeSlugs: currentPlanSlugs,
|
|
478
|
+
})
|
|
479
|
+
if (!result.problems.length) {
|
|
480
|
+
return c.json({ success: false, error: 'No alternative problems found. Try a different topic or adjust your difficulty filter.' }, 400)
|
|
481
|
+
}
|
|
482
|
+
const newProblems = result.problems.map((p) => ({ ...p, status: "PENDING", completedAt: null }))
|
|
483
|
+
existingProblems.length = 0
|
|
484
|
+
existingProblems.push(...newProblems)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
await db.update(dailyPlan)
|
|
488
|
+
.set({ problems: existingProblems })
|
|
489
|
+
.where(eq(dailyPlan.id, planId))
|
|
490
|
+
|
|
491
|
+
return c.json({ success: true, data: { ...plan, problems: existingProblems } })
|
|
492
|
+
} catch (err: any) {
|
|
493
|
+
return c.json({ success: false, error: err.message }, 500)
|
|
494
|
+
}
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
app.get('/streak', async (c) => {
|
|
498
|
+
try {
|
|
499
|
+
const userId = c.get('userId')
|
|
500
|
+
|
|
501
|
+
const plans = await db.query.dailyPlan.findMany({
|
|
502
|
+
where: eq(dailyPlan.userId, userId),
|
|
503
|
+
orderBy: [desc(dailyPlan.date)],
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
if (!plans.length) {
|
|
507
|
+
return c.json({ success: true, data: { currentStreak: 0, longestStreak: 0, solvedToday: false } })
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const solvedDates = new Set<string>()
|
|
511
|
+
const allDates = new Set<string>()
|
|
512
|
+
|
|
513
|
+
for (const plan of plans) {
|
|
514
|
+
const d = new Date(plan.date)
|
|
515
|
+
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
|
516
|
+
allDates.add(dateStr)
|
|
517
|
+
const problems = Array.isArray(plan.problems) ? plan.problems : []
|
|
518
|
+
const hasSolved = problems.some((p: any) => (p as Record<string, unknown>).status === "SOLVED")
|
|
519
|
+
if (hasSolved) solvedDates.add(dateStr)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const today = new Date()
|
|
523
|
+
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`
|
|
524
|
+
const solvedToday = solvedDates.has(todayStr)
|
|
525
|
+
|
|
526
|
+
let currentStreak = 0
|
|
527
|
+
const d = new Date()
|
|
528
|
+
if (!solvedToday) d.setDate(d.getDate() - 1)
|
|
529
|
+
|
|
530
|
+
while (true) {
|
|
531
|
+
const ds = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
|
532
|
+
if (solvedDates.has(ds)) {
|
|
533
|
+
currentStreak++
|
|
534
|
+
d.setDate(d.getDate() - 1)
|
|
535
|
+
} else {
|
|
536
|
+
break
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
let longestStreak = currentStreak
|
|
541
|
+
let tempStreak = 0
|
|
542
|
+
const sortedDates = [...solvedDates].sort()
|
|
543
|
+
for (let i = 0; i < sortedDates.length; i++) {
|
|
544
|
+
if (i === 0) {
|
|
545
|
+
tempStreak = 1
|
|
546
|
+
} else {
|
|
547
|
+
const prev = new Date(sortedDates[i - 1])
|
|
548
|
+
const curr = new Date(sortedDates[i])
|
|
549
|
+
const diffMs = curr.getTime() - prev.getTime()
|
|
550
|
+
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24))
|
|
551
|
+
if (diffDays === 1) {
|
|
552
|
+
tempStreak++
|
|
553
|
+
} else {
|
|
554
|
+
tempStreak = 1
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
longestStreak = Math.max(longestStreak, tempStreak)
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return c.json({
|
|
561
|
+
success: true,
|
|
562
|
+
data: { currentStreak, longestStreak, solvedToday },
|
|
563
|
+
})
|
|
564
|
+
} catch (err: any) {
|
|
565
|
+
return c.json({ success: false, error: err.message }, 500)
|
|
566
|
+
}
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
app.get('/history', async (c) => {
|
|
570
|
+
try {
|
|
571
|
+
const userId = c.get('userId')
|
|
572
|
+
const plans = await db.query.dailyPlan.findMany({
|
|
573
|
+
where: eq(dailyPlan.userId, userId),
|
|
574
|
+
orderBy: [desc(dailyPlan.date)],
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
const history = plans.flatMap((p) => {
|
|
578
|
+
const problems = Array.isArray(p.problems) ? p.problems : []
|
|
579
|
+
return problems
|
|
580
|
+
.filter((prob: any) => (prob as Record<string, unknown>).status === "SOLVED")
|
|
581
|
+
.map((prob: any) => ({
|
|
582
|
+
...(prob as Record<string, unknown>),
|
|
583
|
+
solvedDate: p.date,
|
|
584
|
+
weekNumber: p.weekNumber,
|
|
585
|
+
planId: p.id,
|
|
586
|
+
}))
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
return c.json({ success: true, data: history })
|
|
590
|
+
} catch (err: any) {
|
|
591
|
+
return c.json({ success: false, error: err.message }, 500)
|
|
592
|
+
}
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
export default app
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Hono } from 'hono'
|
|
2
|
+
import { db } from '../db'
|
|
3
|
+
import { surveyResponse } from '../db/schema'
|
|
4
|
+
import { surveySchema } from '../lib/validation'
|
|
5
|
+
import { z } from 'zod'
|
|
6
|
+
|
|
7
|
+
const survey = new Hono()
|
|
8
|
+
|
|
9
|
+
survey.post('/', async (c) => {
|
|
10
|
+
try {
|
|
11
|
+
const body = await c.req.json()
|
|
12
|
+
const parsed = surveySchema.parse(body)
|
|
13
|
+
|
|
14
|
+
const entry = {
|
|
15
|
+
id: crypto.randomUUID(),
|
|
16
|
+
email: parsed.email,
|
|
17
|
+
struggles: JSON.stringify(parsed.struggles ?? []),
|
|
18
|
+
desiredFeature: parsed.desiredFeature ?? '',
|
|
19
|
+
goals: JSON.stringify(parsed.goals ?? []),
|
|
20
|
+
createdAt: new Date(),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
await db.insert(surveyResponse).values(entry)
|
|
24
|
+
|
|
25
|
+
return c.json({ success: true, data: entry }, 201)
|
|
26
|
+
} catch (err: any) {
|
|
27
|
+
if (err instanceof z.ZodError) {
|
|
28
|
+
return c.json({ success: false, errors: err.issues }, 400)
|
|
29
|
+
}
|
|
30
|
+
return c.json({ success: false, error: err.message || 'Validation failed' }, 400)
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
survey.get('/', async (c) => {
|
|
35
|
+
const entries = await db.select().from(surveyResponse)
|
|
36
|
+
const parsed = entries.map((e: typeof surveyResponse.$inferSelect) => ({
|
|
37
|
+
...e,
|
|
38
|
+
struggles: JSON.parse(e.struggles),
|
|
39
|
+
goals: JSON.parse(e.goals),
|
|
40
|
+
}))
|
|
41
|
+
return c.json({ success: true, data: parsed })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
export default survey
|