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,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