Some checks failed
Deploy / deploy (push) Has been cancelled
## 出参改 satisfies
出参是后端自己刚拼出来的字面量,TS 编译期已经验过;再 xxxSchema.parse({...}) 一遍
拿不到任何新信息,唯一可能失败的输入是库里的历史数据,而失败的代价是 500。136 处
全部撤掉,撤的时候当场炸出两个一直存在的线上故障:
- 后台打开任何一道没编辑过的题都是 500 —— problem.last_update_time 是全库唯一可空
的列(961 道题里 470 道是 NULL),而 adminProblemSchema.lastUpdateTime 写的是
z.string();
- 收到过站内信的人打开消息页全是 500 —— embeddedSubmissionSchema 从
submissionDetailSchema 继承了 problemDisplayId 却没 omit,路由只填了同义的
problem;列表为空时才碰巧不炸,所以一直没人报。
两个都是读出侧校验自己造出来的故障,不是它拦住的故障。
## 校验责任挪回写入侧
- db/schema.ts:枚举型的列和几个形状确定的 JSONB 挂 .$type<>()(submission.result /
.language、problem.difficulty / .languages / .template / .astRules / .sqlConfig /
.sqlDisplay、achievement.rarity / .operator、exercise.type、reaction.type、
tutorial.type、problemset.difficulty / .status、flowchart_submission.status、
problemset_badge.condition_type、acm_contest_rank.submission_info)。只影响 TS、
不产生 SQL,断言逐列拿根目录那份生产备份核过全量数据。
- createProblemRequestSchema.languages 收窄成 problemLanguageSchema,兑现
problem.languages 列上的断言。
- 新增 routes/helpers.ts 的 asFilterValue():query 筛选值(result / language /
difficulty / status)要和收窄过的列比较时做纯类型交接,不加校验 —— 在这儿拦一道
会把「筛出空列表」变成「筛条件被忽略、返回全部」。
- 判题产物(submission.info / statistic_info / exercise.data)照旧放行,形状真相
在判题机那边;judge/sql、flowchart/run、events.ts 里对自家产物的 parse 一并撤掉。
- 仍然 parse 的只有 judge/events.ts 的 parseSubmissionEvent —— 从 Redis 收回来的
报文是真边界,失败返回 null 而不是 500。
另删掉两处与契约等价的本地 stringArray(routes/helpers.ts、routes/submission.ts)。
## 文档
CLAUDE.md 那一节从「契约收紧要挑地方」改写成「出参不 parse,用 satisfies」,写明
三处写入侧闸门(入参 safeParse 58 处、列上 $type、语义校验函数);apps/web/CLAUDE.md
同步 —— 现在收紧字段的后果落在 tsc 编译期,但契约形状仍要对得上存量数据。
## 验证
- 生产备份全量:12.4 万条提交的 result 全在 -2..6,10、961 道题的 languages 均为合法
数组、10050 条榜单条目形状全对,无一例外;
- tsc -p apps/api 与 vue-tsc --noEmit 均 exit 0;check:routes 检查 177 条路由,无遮蔽。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
377 lines
18 KiB
TypeScript
377 lines
18 KiB
TypeScript
import {
|
||
createMessageRequestSchema,
|
||
embeddedSubmissionSchema,
|
||
exerciseAttemptRequestSchema,
|
||
reactionKeySchema,
|
||
setReactionRequestSchema,
|
||
tutorialProgressPingSchema,
|
||
type Announcement,
|
||
type AnnouncementList,
|
||
type AnnouncementListItem,
|
||
type EmbeddedSubmission,
|
||
type Exercise,
|
||
type Message,
|
||
type MessageList,
|
||
type ReactionCounts,
|
||
type ReactionState,
|
||
type Tutorial,
|
||
type TutorialProgress,
|
||
type TutorialSummary,
|
||
} from "@oj2/contract"
|
||
import { and, asc, count, desc, eq, inArray, sql } from "drizzle-orm"
|
||
import { Hono } from "hono"
|
||
|
||
import { requireAuth, requireSuperAdmin, type AppEnv } from "../auth/middleware"
|
||
import { db, schema } from "../db"
|
||
import { failure, success } from "../http"
|
||
import { JudgeStatus } from "../judge/status"
|
||
import { objectValue, queryInteger, sampleUser } from "./helpers"
|
||
|
||
export const contentRoutes = new Hono<AppEnv>()
|
||
|
||
contentRoutes.get("/announcements", async (c) => {
|
||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||
const [totalRows, rows] = await Promise.all([
|
||
db.select({ value: count() }).from(schema.announcement).where(eq(schema.announcement.visible, true)),
|
||
db.select({ announcement: schema.announcement, user: schema.user, realName: schema.userProfile.realName })
|
||
.from(schema.announcement).innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id))
|
||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||
.where(eq(schema.announcement.visible, true))
|
||
.orderBy(desc(schema.announcement.top), desc(schema.announcement.createTime)).limit(limit).offset(offset),
|
||
])
|
||
return success(c, {
|
||
results: rows.map(({ announcement, user, realName }) => ({
|
||
id: announcement.id,
|
||
title: announcement.title,
|
||
tag: announcement.tag,
|
||
top: announcement.top,
|
||
createdBy: sampleUser(user, realName),
|
||
createTime: announcement.createTime,
|
||
lastUpdateTime: announcement.lastUpdateTime,
|
||
} satisfies AnnouncementListItem)),
|
||
total: totalRows[0]?.value ?? 0,
|
||
} satisfies AnnouncementList)
|
||
})
|
||
|
||
contentRoutes.get("/announcements/:id", async (c) => {
|
||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||
const [row] = await db.select({ announcement: schema.announcement, user: schema.user, realName: schema.userProfile.realName })
|
||
.from(schema.announcement).innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id))
|
||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||
.where(and(eq(schema.announcement.id, id), eq(schema.announcement.visible, true))).limit(1)
|
||
if (!row) return failure(c, 404, "announcement-not-found", "Announcement does not exist")
|
||
return success(c, {
|
||
id: row.announcement.id,
|
||
title: row.announcement.title,
|
||
tag: row.announcement.tag,
|
||
content: row.announcement.content,
|
||
top: row.announcement.top,
|
||
createdBy: sampleUser(row.user, row.realName),
|
||
createTime: row.announcement.createTime,
|
||
lastUpdateTime: row.announcement.lastUpdateTime,
|
||
} satisfies Announcement)
|
||
})
|
||
|
||
contentRoutes.get("/messages", requireAuth, async (c) => {
|
||
const user = c.get("user")!
|
||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||
const [totalRows, rows] = await Promise.all([
|
||
db.select({ value: count() }).from(schema.message).where(eq(schema.message.recipientId, user.id)),
|
||
db.select({ message: schema.message, sender: schema.user, realName: schema.userProfile.realName, submission: schema.submission, displayId: schema.problem.displayId })
|
||
.from(schema.message).innerJoin(schema.user, eq(schema.message.senderId, schema.user.id))
|
||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||
.innerJoin(schema.submission, eq(schema.message.submissionId, schema.submission.id))
|
||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
||
.where(eq(schema.message.recipientId, user.id)).orderBy(desc(schema.message.createTime)).limit(limit).offset(offset),
|
||
])
|
||
return success(c, {
|
||
results: rows.map(({ message, sender, realName, submission, displayId }) => ({
|
||
id: message.id,
|
||
sender: sampleUser(sender, realName),
|
||
createTime: message.createTime,
|
||
message: message.message,
|
||
submission: {
|
||
id: submission.id,
|
||
createTime: submission.createTime,
|
||
userId: submission.userId,
|
||
username: submission.username,
|
||
code: submission.code,
|
||
result: submission.result,
|
||
// info / ip / contestId 三个字段不在 embeddedSubmissionSchema 里,故不传 ——
|
||
// 对齐旧后端 SubmissionSafeModelSerializer 的 exclude,这三个键不出现在响应中
|
||
language: submission.language,
|
||
statisticInfo: objectValue(submission.statisticInfo),
|
||
// 展示用题号而非数字主键,站内信页面拿它拼 /problem/<题号>
|
||
problem: displayId,
|
||
showLink: true,
|
||
} satisfies EmbeddedSubmission,
|
||
} satisfies Message)),
|
||
total: totalRows[0]?.value ?? 0,
|
||
} satisfies MessageList)
|
||
})
|
||
|
||
/**
|
||
* 发站内信。**目前没有任何前端在调用它** —— 后台那个页面
|
||
* (apps/web/src/admin/communication/messages.vue)两代前端都只是一句
|
||
* 「未完待续」的占位,ojnext 里定义过一个 createMessage 但同样零调用,
|
||
* 已在前端删掉。端点本身是完整实现的,要接 UI 从这里开始。
|
||
*/
|
||
contentRoutes.post("/messages", requireSuperAdmin, async (c) => {
|
||
const user = c.get("user")!
|
||
const parsed = createMessageRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid message payload")
|
||
if (parsed.data.recipientId === user.id) return failure(c, 400, "invalid-recipient", "Can not send a message to yourself")
|
||
const [[recipient], [submission]] = await Promise.all([
|
||
db.select({ id: schema.user.id }).from(schema.user).where(and(eq(schema.user.id, parsed.data.recipientId), eq(schema.user.isDisabled, false))).limit(1),
|
||
db.select({ id: schema.submission.id }).from(schema.submission).where(eq(schema.submission.id, parsed.data.submissionId)).limit(1),
|
||
])
|
||
if (!recipient) return failure(c, 404, "user-not-found", "User does not exist")
|
||
if (!submission) return failure(c, 404, "submission-not-found", "Submission does not exist")
|
||
await db.insert(schema.message).values({
|
||
message: parsed.data.message,
|
||
createTime: new Date().toISOString(),
|
||
recipientId: recipient.id,
|
||
senderId: user.id,
|
||
submissionId: submission.id,
|
||
})
|
||
return success(c, null, 201)
|
||
})
|
||
|
||
async function reactionState(problemId: number, userId: number) {
|
||
const [mine] = await db.select({ type: schema.reaction.type }).from(schema.reaction)
|
||
.where(and(eq(schema.reaction.problemId, problemId), eq(schema.reaction.userId, userId))).limit(1)
|
||
if (!mine) return { mine: null, counts: null } satisfies ReactionState
|
||
const rows = await db.select({ type: schema.reaction.type, value: count() }).from(schema.reaction)
|
||
.where(eq(schema.reaction.problemId, problemId)).groupBy(schema.reaction.type)
|
||
// fromEntries 推不出这个键集,但 options 就是 ReactionKey 的全集,断言是成立的。
|
||
// row.type 不必再 safeParse:reaction.type 列上挂着 $type<ReactionKey>()
|
||
const counts = Object.fromEntries(
|
||
reactionKeySchema.options.map((key) => [key, 0]),
|
||
) as ReactionCounts
|
||
for (const row of rows) counts[row.type] = row.value
|
||
return { mine: mine.type, counts } satisfies ReactionState
|
||
}
|
||
|
||
contentRoutes.get("/problems/:id/reaction", requireAuth, async (c) => {
|
||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||
return success(c, await reactionState(problemId, c.get("user")!.id))
|
||
})
|
||
|
||
contentRoutes.post("/problems/:id/reaction", requireAuth, async (c) => {
|
||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||
const parsed = setReactionRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid reaction")
|
||
const user = c.get("user")!
|
||
const [[problem], [solved]] = await Promise.all([
|
||
db.select({ id: schema.problem.id }).from(schema.problem).where(and(eq(schema.problem.id, problemId), eq(schema.problem.visible, true))).limit(1),
|
||
db.select({ id: schema.submission.id }).from(schema.submission).where(and(
|
||
eq(schema.submission.userId, user.id), eq(schema.submission.problemId, problemId),
|
||
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]),
|
||
)).limit(1),
|
||
])
|
||
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||
if (!solved) return failure(c, 403, "accepted-submission-required", "An accepted submission is required")
|
||
await db.insert(schema.reaction).values({
|
||
problemId,
|
||
userId: user.id,
|
||
type: parsed.data.type,
|
||
createTime: new Date().toISOString(),
|
||
}).onConflictDoNothing({ target: [schema.reaction.problemId, schema.reaction.userId] })
|
||
return success(c, await reactionState(problemId, user.id))
|
||
})
|
||
|
||
contentRoutes.get("/tutorials", async (c) => {
|
||
const type = c.req.query("type") === "c" ? "c" : "python"
|
||
const rows = await db.select({ id: schema.tutorial.id, title: schema.tutorial.title }).from(schema.tutorial)
|
||
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type))).orderBy(asc(schema.tutorial.order))
|
||
return success(c, rows satisfies TutorialSummary[])
|
||
})
|
||
|
||
contentRoutes.get("/tutorials/:id", async (c) => {
|
||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||
const [row] = await db.select({ tutorial: schema.tutorial, user: schema.user, realName: schema.userProfile.realName })
|
||
.from(schema.tutorial).innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id))
|
||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true))).limit(1)
|
||
if (!row) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||
return success(c, {
|
||
id: row.tutorial.id,
|
||
title: row.tutorial.title,
|
||
content: row.tutorial.content,
|
||
code: row.tutorial.code,
|
||
isPublic: row.tutorial.isPublic,
|
||
order: row.tutorial.order,
|
||
type: row.tutorial.type,
|
||
createdBy: sampleUser(row.user, row.realName),
|
||
createdAt: row.tutorial.createdAt,
|
||
updatedAt: row.tutorial.updatedAt,
|
||
} satisfies Tutorial)
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 自学留痕
|
||
|
||
/**
|
||
* 学生自己的自学进度,给学习页的目录打勾用。
|
||
*
|
||
* 路径特意不放在 `/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) => ({
|
||
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,
|
||
} satisfies TutorialProgress)))
|
||
})
|
||
|
||
/**
|
||
* 上报一次自学留痕。`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)
|
||
.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 rows = await db.select().from(schema.exercise).where(eq(schema.exercise.tutorialId, id)).orderBy(asc(schema.exercise.order))
|
||
return success(c, rows.map((row) => ({ id: row.id, type: row.type, data: objectValue(row.data), order: row.order } satisfies Exercise)))
|
||
})
|