feat(自学): 教程和练一练都留痕,老师能看到谁学了多少
Some checks failed
Deploy / deploy (push) Has been cancelled

自学模块以前一个字节都不落库:读到第几课只存在浏览器的 localStorage 里,
练一练的对错是组件内的一个 ref,刷新即失忆。老师能看到的只有「谁交了题」。

现在两张新表:
* tutorial_progress —— 一个学生 × 一课,记打开次数和累计停留秒数
* exercise_attempt  —— 一个学生 × 一道练习,记试了几次、错了几次、
  第几次做对的、最后一次做错时填的什么

都存聚合不存流水。练习那张表尤其明显:流水会随着学生反复点提交无限长,
而多出来的行回答不了任何新问题 ——「他第 3 次和第 5 次都选了 B」对老师
没有意义,「他试了 7 次才对」有。

停留时长只在页面可见、且十分钟内有过操作时才计。机房的电脑经常开着页面
就走了,不设这道闸的话「停留时长」会变成「电脑开机时长」,老师看到的
数字全是假的。换课、切标签页、关窗口都会先把攒着的秒数冲给**离开的那一课**。

练一练的对错仍然是前端判的:答案本来就随题面一起下发到浏览器,后端再判
一遍也挡不住任何人,只是重复实现七套判题。所以这是教学观察数据,不是成绩。
`last_wrong_answer` 存的是前端拼好的一句人话(「选了 C」「顺序 3-1-2」),
不是原始作答结构 —— 七种题型形状各不相同,存结构就得在后台按题型各写一套
渲染,而老师要看的只是他错在哪。

顺带修掉预测输出题的一个老问题:它的 `submitted` 一旦为真就不再收回,而
`allCorrect` 是跟着输入实时算的,于是学生错一次之后把答案改对,界面直接
跳成「输出正确!」、提交按钮同时禁用,submit() 再也执行不到 —— 这道题
**永远不会被记成做对**。排序/连线/找错/分组四种题本来就在交互处把 submitted
置回 false,只有这里漏了,按同一套补上。

学生端:目录每课显示「✓ 已读 · 11 分钟」和「练一练 3/5」。教程保持免登录
可读,未登录只是不留痕,并明说一句。

老师端:后台新开「自学情况」(教师及以上可进),三个 tab ——
按学生(默认把读得最少的排在最前,这张表要回答的是谁还没开始)、
按练习(每道题的正确率、一次做对几人、做对的人平均试几次;展开看逐人明细
和他们最后错在哪)、按课程。班级框填 3-4 位是具体班级,1-2 位当年级前缀。

外键用了库级 CASCADE,和 Django 建的那批 NO ACTION 不同:删教程、删用户
不必再记得回来手工清子表。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GW5ef6C2kRW8Ru27ghCaUu
This commit is contained in:
2026-09-01 08:54:27 -06:00
parent 49681d04a6
commit bd84599174
30 changed files with 9484 additions and 36 deletions

View File

@@ -6,6 +6,7 @@ import { adminAchievementRoutes } from "./achievement"
import { adminAiRoutes } from "./ai"
import { adminConfRoutes } from "./conf"
import { adminContestRoutes } from "./contest"
import { adminLearnRoutes } from "./learn"
import { adminProblemRoutes } from "./problem"
import { adminProblemSetRoutes } from "./problemset"
import { adminTagRoutes } from "./tag"
@@ -27,6 +28,7 @@ adminRoutes.route("/", adminAchievementRoutes)
adminRoutes.route("/", adminAiRoutes)
adminRoutes.route("/", adminConfRoutes)
adminRoutes.route("/", adminContestRoutes)
adminRoutes.route("/", adminLearnRoutes)
adminRoutes.route("/", adminProblemRoutes)
adminRoutes.route("/", adminProblemSetRoutes)
adminRoutes.route("/", adminTagRoutes)

View File

@@ -0,0 +1,249 @@
import {
learnExerciseAttemptSchema,
learnExerciseProgressListSchema,
learnExerciseProgressSchema,
learnStudentProgressListSchema,
learnStudentProgressSchema,
learnTutorialProgressListSchema,
learnTutorialProgressSchema,
} from "@oj2/contract"
import { and, asc, count, desc, eq, inArray, like, sql } from "drizzle-orm"
import { Hono } from "hono"
import { requireTeacher, type AppEnv } from "../../auth/middleware"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
import { queryInteger, rounded } from "../helpers"
/**
* 自学情况:教程读了没、读了多久。
*
* 路径用 `/learn-analytics` 而不是挂在 `/tutorials` 下,理由同 tag.ts 里那段注释:
* Hono 按注册顺序匹配,`/tutorials/:id` 会把同级的静态段整个吃掉且不报错。
*/
export const adminLearnRoutes = new Hono<AppEnv>()
/** 统计只算学生,不算老师和管理员自己 —— 和班级榜classroom.ts的口径一致 */
const STUDENT_ROLES = ["Regular User", "Student Admin"]
function tutorialTypeOf(value: string | undefined) {
return value === "c" ? "c" : "python"
}
/**
* 班级筛选。班号形如 `241`24 级 1 班),只允许纯数字:
* 直接拼进 like 的话,`%` 会变成通配符,把筛选变成「全选」。
*/
function classFilter(className: string | undefined) {
const value = className?.trim()
if (!value) return { ok: true as const, value: null }
if (!/^\d{1,4}$/.test(value)) return { ok: false as const, value: null }
return { ok: true as const, value }
}
/**
* `className` 传 3 位以上是具体班级(精确匹配),传 1-2 位当年级前缀like
* 年级前缀这条是给「24 级整体读得怎么样」用的,不然老师得一个班一个班点。
*/
function classCondition(value: string | null) {
if (!value) return undefined
return value.length >= 3
? eq(schema.user.className, value)
: like(schema.user.className, `${value}%`)
}
/**
* 没有班级的学生**照样统计**(班级列显示为空)。班级是从用户名的数字前缀推出来的,
* 推不出来时就是 null见 admin/account.ts 的 classNameOf把这些人过滤掉等于让他们
* 在「谁没学」这张表上凭空消失 —— 班级榜可以只算入班的人,这里不行。
*/
function studentCondition(value: string | null) {
return and(
eq(schema.user.isDisabled, false),
inArray(schema.user.adminType, STUDENT_ROLES),
classCondition(value),
)
}
adminLearnRoutes.get("/learn-analytics/students", requireTeacher, async (c) => {
const type = tutorialTypeOf(c.req.query("type"))
const className = classFilter(c.req.query("className"))
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
// 该语言下已公开的教程,既是分母,也是「哪些课算数」的白名单 ——
// 未公开的课学生本来就打不开,混进来会让读完的人显示成没读完
const tutorials = await db.select({ id: schema.tutorial.id }).from(schema.tutorial)
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
const tutorialIds = tutorials.map((row) => row.id)
// 学生表打底 left join 进度:没读过的人也要出现在结果里,这是这张表的重点
const progressJoin = tutorialIds.length
? and(
eq(schema.tutorialProgress.userId, schema.user.id),
inArray(schema.tutorialProgress.tutorialId, tutorialIds),
)
: sql`false`
// 阅读和练习**分两条查**再在内存里拼。写成一条的话,一个学生读了 3 课、
// 做了 8 道练习join 出来是 24 行count 全是错的 —— 两个一对多挂在同一张表上
// 就是这个下场,用 filter 也救不回来
const [rows, exerciseRows] = await Promise.all([
db.select({
userId: schema.user.id,
username: schema.user.username,
realName: schema.userProfile.realName,
className: schema.user.className,
readCount: count(schema.tutorialProgress.tutorialId),
totalSeconds: sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}), 0)`.mapWith(Number),
lastViewedAt: sql<string | null>`max(${schema.tutorialProgress.lastViewedAt})`,
}).from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.leftJoin(schema.tutorialProgress, progressJoin)
.where(studentCondition(className.value))
.groupBy(schema.user.id, schema.user.username, schema.userProfile.realName, schema.user.className),
db.select({
userId: schema.exerciseAttempt.userId,
tried: count(),
solved: sql<number>`count(*) filter (where ${schema.exerciseAttempt.solved})`.mapWith(Number),
attempts: sql<number>`coalesce(sum(${schema.exerciseAttempt.attempts}), 0)`.mapWith(Number),
}).from(schema.exerciseAttempt)
.innerJoin(schema.exercise, eq(schema.exercise.id, schema.exerciseAttempt.exerciseId))
.innerJoin(schema.tutorial, eq(schema.tutorial.id, schema.exercise.tutorialId))
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
.groupBy(schema.exerciseAttempt.userId),
])
const attempts = new Map(exerciseRows.map((row) => [row.userId, row]))
const [exerciseCountRow] = tutorialIds.length
? await db.select({ value: count() }).from(schema.exercise)
.where(inArray(schema.exercise.tutorialId, tutorialIds))
: [{ value: 0 }]
return success(c, learnStudentProgressListSchema.parse({
tutorialCount: tutorialIds.length,
exerciseCount: exerciseCountRow?.value ?? 0,
results: rows.map((row) => learnStudentProgressSchema.parse({
...row,
exerciseTried: attempts.get(row.userId)?.tried ?? 0,
exerciseSolved: attempts.get(row.userId)?.solved ?? 0,
exerciseAttempts: attempts.get(row.userId)?.attempts ?? 0,
})),
}))
})
adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) => {
const type = tutorialTypeOf(c.req.query("type"))
const className = classFilter(c.req.query("className"))
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
const [studentCountRow] = await db.select({ value: count() }).from(schema.user)
.where(studentCondition(className.value))
const studentCount = studentCountRow?.value ?? 0
// 进度行 join 回 user 是为了让班级筛选生效,同时把老师自己试读的记录挡在外面
const rows = await db.select({
tutorialId: schema.tutorial.id,
title: schema.tutorial.title,
order: schema.tutorial.order,
// 数的是 user.id 而不是 progress.user_idjoin 不上的(老师自己试读的、
// 已禁用的、不在所选班级的)在这一列是 NULLcount(distinct) 正好不算它,
// 而 progress.user_id 那边永远非空,会把过滤当没发生
readers: sql<number>`count(distinct ${schema.user.id})`.mapWith(Number),
totalSeconds: sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.user.id} is not null), 0)`.mapWith(Number),
}).from(schema.tutorial)
.leftJoin(schema.tutorialProgress, eq(schema.tutorialProgress.tutorialId, schema.tutorial.id))
.leftJoin(schema.user, and(
eq(schema.user.id, schema.tutorialProgress.userId),
studentCondition(className.value),
))
// 学生条件写在 join 的 on 上而不是 where 上:写 where 会把没人读过的课整行滤掉,
// 而「一节课一个人都没读」恰恰是老师最需要看见的一行
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
.groupBy(schema.tutorial.id, schema.tutorial.title, schema.tutorial.order)
.orderBy(asc(schema.tutorial.order))
return success(c, learnTutorialProgressListSchema.parse({
studentCount,
results: rows.map((row) => learnTutorialProgressSchema.parse({
...row,
avgSeconds: row.readers ? Math.round(row.totalSeconds / row.readers) : 0,
})),
}))
})
/**
* 按练习:哪道练一练卡住了全班。
*
* 一道题一行,含做过/做对的人数、做对的人平均试了几次、一次就做对的人数。
* 没人做过的题也在列表里(一行零)—— 「这道题全班没一个人碰」同样是要看见的。
*/
adminLearnRoutes.get("/learn-analytics/exercises", requireTeacher, async (c) => {
const type = tutorialTypeOf(c.req.query("type"))
const className = classFilter(c.req.query("className"))
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
const [studentCountRow] = await db.select({ value: count() }).from(schema.user)
.where(studentCondition(className.value))
const rows = await db.select({
exerciseId: schema.exercise.id,
tutorialId: schema.tutorial.id,
tutorialTitle: schema.tutorial.title,
tutorialOrder: schema.tutorial.order,
type: schema.exercise.type,
order: schema.exercise.order,
// 题干在 jsonb 里,各题型的字段名都叫 question取不到就给空串别让整行挂掉
question: sql<string>`coalesce(${schema.exercise.data}->>'question', '')`,
triedUsers: sql<number>`count(distinct ${schema.user.id})`.mapWith(Number),
solvedUsers: sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.exerciseAttempt.solved})`.mapWith(Number),
firstTryUsers: sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.exerciseAttempt.attemptsToSolve} = 1)`.mapWith(Number),
attempts: sql<number>`coalesce(sum(${schema.exerciseAttempt.attempts}) filter (where ${schema.user.id} is not null), 0)`.mapWith(Number),
// 只算做对的人:没做对的人「试了几次」还没停,混进平均值只会把它拉花
avgAttemptsToSolve: sql<number>`coalesce(avg(${schema.exerciseAttempt.attemptsToSolve}) filter (where ${schema.user.id} is not null), 0)`.mapWith(Number),
}).from(schema.exercise)
.innerJoin(schema.tutorial, eq(schema.tutorial.id, schema.exercise.tutorialId))
.leftJoin(schema.exerciseAttempt, eq(schema.exerciseAttempt.exerciseId, schema.exercise.id))
// 学生条件挂在 join 的 on 上,不是 where 上:写 where 会把没人做过的题整行滤掉
.leftJoin(schema.user, and(
eq(schema.user.id, schema.exerciseAttempt.userId),
studentCondition(className.value),
))
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
.groupBy(schema.exercise.id, schema.tutorial.id, schema.tutorial.title, schema.tutorial.order)
.orderBy(asc(schema.tutorial.order), asc(schema.exercise.order))
return success(c, learnExerciseProgressListSchema.parse({
studentCount: studentCountRow?.value ?? 0,
results: rows.map((row) => learnExerciseProgressSchema.parse({
...row,
avgAttemptsToSolve: rounded(Number(row.avgAttemptsToSolve), 1),
})),
}))
})
/** 单道练习的逐人明细。后台表格展开某一行时才拉,不跟着列表一起下发 */
adminLearnRoutes.get("/learn-analytics/exercises/:id/attempts", requireTeacher, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const className = classFilter(c.req.query("className"))
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
const rows = await db.select({
userId: schema.user.id,
username: schema.user.username,
realName: schema.userProfile.realName,
className: schema.user.className,
attempts: schema.exerciseAttempt.attempts,
wrongAttempts: schema.exerciseAttempt.wrongAttempts,
solved: schema.exerciseAttempt.solved,
attemptsToSolve: schema.exerciseAttempt.attemptsToSolve,
lastWrongAnswer: schema.exerciseAttempt.lastWrongAnswer,
lastAttemptAt: schema.exerciseAttempt.lastAttemptAt,
}).from(schema.exerciseAttempt)
.innerJoin(schema.user, eq(schema.user.id, schema.exerciseAttempt.userId))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(and(eq(schema.exerciseAttempt.exerciseId, id), studentCondition(className.value)))
// 没做对的排前面,错得最多的最前 —— 展开这一行的人是来找卡住的学生的
.orderBy(asc(schema.exerciseAttempt.solved), desc(schema.exerciseAttempt.wrongAttempts))
return success(c, rows.map((row) => learnExerciseAttemptSchema.parse(row)))
})

View File

@@ -10,10 +10,13 @@ import {
reactionStateSchema,
setReactionRequestSchema,
embeddedSubmissionSchema,
exerciseAttemptRequestSchema,
tutorialProgressPingSchema,
tutorialProgressSchema,
tutorialSchema,
tutorialSummarySchema,
} from "@oj2/contract"
import { and, asc, count, desc, eq, inArray } from "drizzle-orm"
import { and, asc, count, desc, eq, inArray, sql } from "drizzle-orm"
import { Hono } from "hono"
import { requireAuth, requireSuperAdmin, type AppEnv } from "../auth/middleware"
@@ -206,6 +209,162 @@ contentRoutes.get("/tutorials/:id", async (c) => {
}))
})
// ---------------------------------------------------------------- 自学留痕
/**
* 学生自己的自学进度,给学习页的目录打勾用。
*
* 路径特意不放在 `/tutorials` 下Hono 按**注册顺序**匹配(不是静态优先),
* `/tutorials/:id` 就在上面几行,`/tutorials/progress` 会被它整个吃掉,而且不报错
* ——`queryInteger("progress")` 回落成 0学生只会看到一个「教程不存在」。
*/
contentRoutes.get("/learn/progress", requireAuth, async (c) => {
const user = c.get("user")!
const type = c.req.query("type") === "c" ? "c" : "python"
const visible = and(eq(schema.tutorial.type, type), eq(schema.tutorial.isPublic, true))
// 从 tutorial 打底 left join 进度,而不是反过来:没读过的课也要有一行零,
// 否则目录里「练习 0/5」和「这课没有练习」在前端分不出来
const [rows, exerciseRows] = await Promise.all([
db.select({
tutorialId: schema.tutorial.id,
viewCount: schema.tutorialProgress.viewCount,
totalSeconds: schema.tutorialProgress.totalSeconds,
firstViewedAt: schema.tutorialProgress.firstViewedAt,
lastViewedAt: schema.tutorialProgress.lastViewedAt,
}).from(schema.tutorial)
.leftJoin(schema.tutorialProgress, and(
eq(schema.tutorialProgress.tutorialId, schema.tutorial.id),
eq(schema.tutorialProgress.userId, user.id),
))
.where(visible)
.orderBy(asc(schema.tutorial.order)),
db.select({
tutorialId: schema.exercise.tutorialId,
total: count(),
solved: sql<number>`count(*) filter (where ${schema.exerciseAttempt.solved})`.mapWith(Number),
}).from(schema.exercise)
.innerJoin(schema.tutorial, eq(schema.tutorial.id, schema.exercise.tutorialId))
.leftJoin(schema.exerciseAttempt, and(
eq(schema.exerciseAttempt.exerciseId, schema.exercise.id),
eq(schema.exerciseAttempt.userId, user.id),
))
.where(visible)
.groupBy(schema.exercise.tutorialId),
])
const exercises = new Map(exerciseRows.map((row) => [row.tutorialId, row]))
return success(c, rows.map((row) => tutorialProgressSchema.parse({
tutorialId: row.tutorialId,
viewCount: row.viewCount ?? 0,
totalSeconds: row.totalSeconds ?? 0,
firstViewedAt: row.firstViewedAt,
lastViewedAt: row.lastViewedAt,
exerciseTotal: exercises.get(row.tutorialId)?.total ?? 0,
exerciseSolved: exercises.get(row.tutorialId)?.solved ?? 0,
})))
})
/**
* 上报一次自学留痕。`opened` 为真表示「刚进这一课」,计一次打开;
* 否则只是心跳补时长,见 apps/web/src/oj/learn/composables/useLearnTrace.ts。
*
* 未登录一律 401 而不是静默丢弃 —— 教程本身保持免登录可读,前端只在登录后才调它,
* 真收到匿名请求说明前端判断错了,得让它响。
*/
contentRoutes.post("/tutorials/:id/progress", requireAuth, async (c) => {
const user = c.get("user")!
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = tutorialProgressPingSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid progress payload")
const [tutorial] = await db.select({ id: schema.tutorial.id }).from(schema.tutorial)
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true))).limit(1)
if (!tutorial) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const now = new Date().toISOString()
const { seconds, opened } = parsed.data
await db.insert(schema.tutorialProgress).values({
userId: user.id,
tutorialId: id,
viewCount: opened ? 1 : 0,
totalSeconds: seconds,
firstViewedAt: now,
lastViewedAt: now,
}).onConflictDoUpdate({
target: [schema.tutorialProgress.userId, schema.tutorialProgress.tutorialId],
set: {
// 累加在库里做,不是「读出来加一下再写回去」:同一个学生开两个标签页
// 同时上报时,读改写会互相覆盖,时长凭空少掉一半
viewCount: sql`${schema.tutorialProgress.viewCount} + ${opened ? 1 : 0}`,
totalSeconds: sql`${schema.tutorialProgress.totalSeconds} + ${seconds}`,
lastViewedAt: now,
},
})
return success(c, null)
})
/**
* 上报一次练一练的作答。
*
* 对错是**前端判的** —— 练一练的答案本来就随题面一起下发给浏览器(见
* `/tutorials/:id/exercises`),后端再判一遍也挡不住任何人,只是重复实现七套判题。
* 所以这里存的是「学生自己说他做对了」,作为教学观察够用,**不能当考试成绩**。
*
* 做对之后的重复提交只更新时间,不再累加 —— 学生做对后再点几下提交,
* 不该把「他试了几次」这个数字变大。
*/
contentRoutes.post("/exercises/:id/attempts", requireAuth, async (c) => {
const user = c.get("user")!
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = exerciseAttemptRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid attempt payload")
// 练习跟着教程走:教程没公开,它底下的练习也不该能上报
const [exercise] = await db.select({ id: schema.exercise.id }).from(schema.exercise)
.innerJoin(schema.tutorial, eq(schema.tutorial.id, schema.exercise.tutorialId))
.where(and(eq(schema.exercise.id, id), eq(schema.tutorial.isPublic, true))).limit(1)
if (!exercise) return failure(c, 404, "exercise-not-found", "Exercise does not exist")
const now = new Date().toISOString()
const { correct } = parsed.data
const answer = correct ? null : (parsed.data.answer ?? null)
await db.insert(schema.exerciseAttempt).values({
userId: user.id,
exerciseId: id,
attempts: 1,
wrongAttempts: correct ? 0 : 1,
solved: correct,
attemptsToSolve: correct ? 1 : null,
lastWrongAnswer: answer,
firstAttemptAt: now,
lastAttemptAt: now,
solvedAt: correct ? now : null,
}).onConflictDoUpdate({
target: [schema.exerciseAttempt.userId, schema.exerciseAttempt.exerciseId],
set: {
// 一律在库里算,不读出来改了再写回去:两个标签页同时提交会互相覆盖。
//
// 每一列都先看 `solved`:做对之后这一行就冻住了,只有 lastAttemptAt 还动。
// 不冻的话,学生做对后随手再点几下提交,「他试了几次才做对」就被改花了。
attempts: sql`${schema.exerciseAttempt.attempts} + case when ${schema.exerciseAttempt.solved} then 0 else 1 end`,
wrongAttempts: sql`${schema.exerciseAttempt.wrongAttempts} + case when ${schema.exerciseAttempt.solved} or ${correct} then 0 else 1 end`,
solved: sql`${schema.exerciseAttempt.solved} or ${correct}`,
attemptsToSolve: sql`case
when ${schema.exerciseAttempt.solved} then ${schema.exerciseAttempt.attemptsToSolve}
when ${correct} then ${schema.exerciseAttempt.attempts} + 1
else null end`,
solvedAt: sql`case
when ${schema.exerciseAttempt.solved} then ${schema.exerciseAttempt.solvedAt}
when ${correct} then ${now}::timestamptz
else null end`,
lastWrongAnswer: sql`case
when ${schema.exerciseAttempt.solved} or ${correct} then ${schema.exerciseAttempt.lastWrongAnswer}
else ${answer} end`,
lastAttemptAt: now,
},
})
return success(c, null)
})
contentRoutes.get("/tutorials/:id/exercises", async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [tutorial] = await db.select({ id: schema.tutorial.id }).from(schema.tutorial)