From 8c53e28dcb5cd5c1a27f21629ce0949df61b7d9e Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Fri, 9 Oct 2026 22:01:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E9=A2=98=E5=8D=95):=20=E3=80=8C=E6=88=AA?= =?UTF-8?q?=E6=AD=A2=E6=97=B6=E9=97=B4=E3=80=8D=E6=94=B9=E6=88=90=E3=80=8C?= =?UTF-8?q?=E5=B8=83=E7=BD=AE=E6=9C=9F=E3=80=8D=EF=BC=8C=E5=B8=83=E7=BD=AE?= =?UTF-8?q?=E6=9C=9F=E5=86=85=E6=B2=A1=E5=8A=A0=E5=85=A5=E7=9A=84=E4=BA=BA?= =?UTF-8?q?=E4=B9=9F=E8=97=8F=E6=97=A7=E4=BB=A3=E7=A0=81=EF=BC=9B=E8=80=81?= =?UTF-8?q?=E5=B8=88=E8=83=BD=E7=9C=8B=E5=85=A8=E7=8F=AD=E6=83=85=E5=86=B5?= =?UTF-8?q?=EF=BC=8C=E5=90=8E=E5=8F=B0=E4=B8=80=E6=AC=A1=E5=8A=A0=E5=A5=BD?= =?UTF-8?q?=E5=87=A0=E9=81=93=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 布置期(列名仍是 end_time,机房共库不改名)+ 新列 assigned_at:布置期内题单里这几道题, 学生在布置开始之前交的代码先藏起来,不管加没加入,堵住「先抄再加入」;做对或到期放出来,不拦做题 - 「总分达到 N」奖章换成「做对 N 道」,草稿并进「不公开」(0026) - /problem-sets 带布置期、必做题数、奖章获得时间;题目带做对时刻、错几次、旧代码藏着没有 - 新增 /problem-sets/:id/class-view(老师看一个班:真名 × 每题,加入前就做对过)、/problem-sets/locks - 后台:批量加题、拖动排序、切必做;去掉状态、难度、分数、提示和单独的进度页 Co-Authored-By: Claude Opus 5.5 --- .../api/src/db/0026_problemset_assignment.sql | 10 + apps/api/src/db/meta/0026_snapshot.json | 4140 +++++++++++++++++ apps/api/src/db/meta/_journal.json | 7 + apps/api/src/db/schema.ts | 12 +- apps/api/src/routes/admin/problemset.ts | 304 +- apps/api/src/routes/flowchart.ts | 36 +- apps/api/src/routes/problemset.ts | 725 +-- apps/api/src/routes/submission.ts | 22 +- apps/api/src/services/problemset.ts | 103 +- packages/contract/src/admin.ts | 82 +- packages/contract/src/problemset.ts | 134 +- 11 files changed, 4958 insertions(+), 617 deletions(-) create mode 100644 apps/api/src/db/0026_problemset_assignment.sql create mode 100644 apps/api/src/db/meta/0026_snapshot.json diff --git a/apps/api/src/db/0026_problemset_assignment.sql b/apps/api/src/db/0026_problemset_assignment.sql new file mode 100644 index 0000000..e1c4df4 --- /dev/null +++ b/apps/api/src/db/0026_problemset_assignment.sql @@ -0,0 +1,10 @@ +-- 题单的「截止时间」改成「布置到」:它从来不截止任何东西,只管布置期内藏学生以前的代码(防抄), +-- 见 services/problemset.ts 的 problemSetLockCutoffs。列名 end_time 不改(机房和服务器共库, +-- 改名会让没升级的那边直接报错);旧值是按旧语义填的(3 个题单,都已过期),清掉。 +ALTER TABLE "problemset" ADD COLUMN "assigned_at" timestamp with time zone;--> statement-breakpoint +UPDATE "problemset" SET "end_time" = NULL;--> statement-breakpoint +-- 「状态」和「可见」两道闸合成一个「公开」:草稿等于不公开,归档等于公开(一直都还能进) +UPDATE "problemset" SET "visible" = false WHERE "status" = 'draft';--> statement-breakpoint +UPDATE "problemset" SET "status" = 'active' WHERE "status" <> 'active';--> statement-breakpoint +-- 分数拿掉了,「总分达到 N」的奖章换成「做对 N 道」(线上那几套题每题 10 分,门槛不变) +UPDATE "problemset_badge" SET "condition_type" = 'problem_count', "condition_value" = GREATEST(1, CEIL("condition_value" / 10.0)) WHERE "condition_type" = 'score'; diff --git a/apps/api/src/db/meta/0026_snapshot.json b/apps/api/src/db/meta/0026_snapshot.json new file mode 100644 index 0000000..f8baec8 --- /dev/null +++ b/apps/api/src/db/meta/0026_snapshot.json @@ -0,0 +1,4140 @@ +{ + "id": "b7250c6f-0673-4742-9708-73091508f5f1", + "prevId": "078927db-9fd3-47c6-973f-b55739842b3e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.achievement": { + "name": "achievement", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "achievement_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden": { + "name": "hidden", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "operator": { + "name": "operator", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "threshold": { + "name": "threshold", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "visible": { + "name": "visible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "unlock_count": { + "name": "unlock_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.acm_contest_rank": { + "name": "acm_contest_rank", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "submission_number": { + "name": "submission_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "accepted_number": { + "name": "accepted_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_time": { + "name": "total_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "submission_info": { + "name": "submission_info", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "contest_id": { + "name": "contest_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "acm_rank_contest_user_idx": { + "name": "acm_rank_contest_user_idx", + "columns": [ + { + "expression": "contest_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "acm_rank_order_idx": { + "name": "acm_rank_order_idx", + "columns": [ + { + "expression": "contest_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "accepted_number", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "total_time", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "acm_contest_rank_contest_id_21030ccd_fk_contest_id": { + "name": "acm_contest_rank_contest_id_21030ccd_fk_contest_id", + "tableFrom": "acm_contest_rank", + "tableTo": "contest", + "columnsFrom": [ + "contest_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "acm_contest_rank_user_id_40391ab2_fk_user_id": { + "name": "acm_contest_rank_user_id_40391ab2_fk_user_id", + "tableFrom": "acm_contest_rank", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_acm_rank_user_contest": { + "name": "unique_acm_rank_user_contest", + "nullsNotDistinct": false, + "columns": [ + "contest_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_analysis": { + "name": "ai_analysis", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "ai_analysis_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_prompt": { + "name": "user_prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "analysis": { + "name": "analysis", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_pinned": { + "name": "is_pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ai_analysis_user_id_3aa23011": { + "name": "ai_analysis_user_id_3aa23011", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_analysis_user_id_3aa23011_fk_user_id": { + "name": "ai_analysis_user_id_3aa23011_fk_user_id", + "tableFrom": "ai_analysis", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_hint": { + "name": "ai_hint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "ai_hint_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "submission_id": { + "name": "submission_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt_version": { + "name": "prompt_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "diagnosis": { + "name": "diagnosis", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "diagnosis_error": { + "name": "diagnosis_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "filter_attempt": { + "name": "filter_attempt", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "filter_blocked": { + "name": "filter_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "filter_reason": { + "name": "filter_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "helpful": { + "name": "helpful", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "feedback_time": { + "name": "feedback_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ai_hint_submission_id_idx": { + "name": "ai_hint_submission_id_idx", + "columns": [ + { + "expression": "submission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_hint_submission_id_fk_submission_id": { + "name": "ai_hint_submission_id_fk_submission_id", + "tableFrom": "ai_hint", + "tableTo": "submission", + "columnsFrom": [ + "submission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.announcement": { + "name": "announcement", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_update_time": { + "name": "last_update_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "visible": { + "name": "visible", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "top": { + "name": "top", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "announcement_created_by_id_359ccf50": { + "name": "announcement_created_by_id_359ccf50", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "announcement_list_idx": { + "name": "announcement_list_idx", + "columns": [ + { + "expression": "visible", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "top", + "isExpression": false, + "asc": false, + "nulls": "first" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "announcement_created_by_id_359ccf50_fk_user_id": { + "name": "announcement_created_by_id_359ccf50_fk_user_id", + "tableFrom": "announcement", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.class_lesson": { + "name": "class_lesson", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "class_lesson_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "class_name": { + "name": "class_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "problem_ids": { + "name": "problem_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "class_lesson_created_by_idx": { + "name": "class_lesson_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "class_lesson_created_by_fk_user_id": { + "name": "class_lesson_created_by_fk_user_id", + "tableFrom": "class_lesson", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "class_lesson_class_day_unique": { + "name": "class_lesson_class_day_unique", + "nullsNotDistinct": false, + "columns": [ + "class_name", + "day" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contest": { + "name": "contest", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_time": { + "name": "start_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_update_time": { + "name": "last_update_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "visible": { + "name": "visible", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "contest_created_by_id_a763ca7e": { + "name": "contest_created_by_id_a763ca7e", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contest_created_by_id_a763ca7e_fk_user_id": { + "name": "contest_created_by_id_a763ca7e_fk_user_id", + "tableFrom": "contest", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.exercise": { + "name": "exercise", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "exercise_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "tutorial_id": { + "name": "tutorial_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "exercise_tutorial_id_6fd04055": { + "name": "exercise_tutorial_id_6fd04055", + "columns": [ + { + "expression": "tutorial_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "exercise_tutorial_id_6fd04055_fk_tutorial_id": { + "name": "exercise_tutorial_id_6fd04055_fk_tutorial_id", + "tableFrom": "exercise", + "tableTo": "tutorial", + "columnsFrom": [ + "tutorial_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.exercise_attempt": { + "name": "exercise_attempt", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "exercise_id": { + "name": "exercise_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "wrong_attempts": { + "name": "wrong_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "solved": { + "name": "solved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "attempts_to_solve": { + "name": "attempts_to_solve", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_wrong_answer": { + "name": "last_wrong_answer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_attempt_at": { + "name": "first_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "solved_at": { + "name": "solved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "exercise_attempt_exercise_id_idx": { + "name": "exercise_attempt_exercise_id_idx", + "columns": [ + { + "expression": "exercise_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "exercise_attempt_user_id_fk_user_id": { + "name": "exercise_attempt_user_id_fk_user_id", + "tableFrom": "exercise_attempt", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "exercise_attempt_exercise_id_fk_exercise_id": { + "name": "exercise_attempt_exercise_id_fk_exercise_id", + "tableFrom": "exercise_attempt", + "tableTo": "exercise", + "columnsFrom": [ + "exercise_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "exercise_attempt_pkey": { + "name": "exercise_attempt_pkey", + "columns": [ + "user_id", + "exercise_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.flowchart_submission": { + "name": "flowchart_submission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mermaid_code": { + "name": "mermaid_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "flowchart_data": { + "name": "flowchart_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ai_score": { + "name": "ai_score", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "ai_grade": { + "name": "ai_grade", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "ai_feedback": { + "name": "ai_feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_suggestions": { + "name": "ai_suggestions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_criteria_details": { + "name": "ai_criteria_details", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "ai_provider": { + "name": "ai_provider", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "ai_model": { + "name": "ai_model", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "processing_time": { + "name": "processing_time", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "evaluation_time": { + "name": "evaluation_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "problem_id": { + "name": "problem_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "flowchart_problem_time_idx": { + "name": "flowchart_problem_time_idx", + "columns": [ + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flowchart_status_idx": { + "name": "flowchart_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flowchart_create_time_idx": { + "name": "flowchart_create_time_idx", + "columns": [ + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flowchart_user_time_idx": { + "name": "flowchart_user_time_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "flowchart_submission_problem_id_8551edbf_fk_problem_id": { + "name": "flowchart_submission_problem_id_8551edbf_fk_problem_id", + "tableFrom": "flowchart_submission", + "tableTo": "problem", + "columnsFrom": [ + "problem_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "flowchart_submission_user_id_225c83e8_fk_user_id": { + "name": "flowchart_submission_user_id_225c83e8_fk_user_id", + "tableFrom": "flowchart_submission", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.judge_server": { + "name": "judge_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "judger_version": { + "name": "judger_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cpu_core": { + "name": "cpu_core", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "memory_usage": { + "name": "memory_usage", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "cpu_usage": { + "name": "cpu_usage", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "last_heartbeat": { + "name": "last_heartbeat", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "task_number": { + "name": "task_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "service_url": { + "name": "service_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_disabled": { + "name": "is_disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message": { + "name": "message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "message_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "recipient_id": { + "name": "recipient_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "submission_id": { + "name": "submission_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "message_recipient_time_idx": { + "name": "message_recipient_time_idx", + "columns": [ + { + "expression": "recipient_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "timestamptz_ops" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_sender_id_a2a2e825": { + "name": "message_sender_id_a2a2e825", + "columns": [ + { + "expression": "sender_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_submission_id_2fdf8a47": { + "name": "message_submission_id_2fdf8a47", + "columns": [ + { + "expression": "submission_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_recipient_id_2aa5dd76_fk_user_id": { + "name": "message_recipient_id_2aa5dd76_fk_user_id", + "tableFrom": "message", + "tableTo": "user", + "columnsFrom": [ + "recipient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "message_sender_id_a2a2e825_fk_user_id": { + "name": "message_sender_id_a2a2e825_fk_user_id", + "tableFrom": "message", + "tableTo": "user", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "message_submission_id_2fdf8a47_fk_submission_id": { + "name": "message_submission_id_2fdf8a47_fk_submission_id", + "tableFrom": "message", + "tableTo": "submission", + "columnsFrom": [ + "submission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.options_sysoptions": { + "name": "options_sysoptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "options_sysoptions_key_key": { + "name": "options_sysoptions_key_key", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problem": { + "name": "problem", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_description": { + "name": "input_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "output_description": { + "name": "output_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "samples": { + "name": "samples", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "test_case_id": { + "name": "test_case_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "test_case_score": { + "name": "test_case_score", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "hint": { + "name": "hint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "languages": { + "name": "languages", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_update_time": { + "name": "last_update_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_limit": { + "name": "time_limit", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "memory_limit": { + "name": "memory_limit", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "visible": { + "name": "visible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "difficulty": { + "name": "difficulty", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "submission_number": { + "name": "submission_number", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "accepted_number": { + "name": "accepted_number", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_id": { + "name": "_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "statistic_info": { + "name": "statistic_info", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "contest_id": { + "name": "contest_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "share_submission": { + "name": "share_submission", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "answers": { + "name": "answers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allow_flowchart": { + "name": "allow_flowchart", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "flowchart_data": { + "name": "flowchart_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "flowchart_hint": { + "name": "flowchart_hint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mermaid_code": { + "name": "mermaid_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "show_flowchart": { + "name": "show_flowchart", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ast_rules": { + "name": "ast_rules", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sql_config": { + "name": "sql_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sql_display": { + "name": "sql_display", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "problem_contest_visible_idx": { + "name": "problem_contest_visible_idx", + "columns": [ + { + "expression": "contest_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + }, + { + "expression": "visible", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "problem_created_by_id_cb362143": { + "name": "problem_created_by_id_cb362143", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "problem_visible_idx": { + "name": "problem_visible_idx", + "columns": [ + { + "expression": "visible", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "problem_contest_id_328e013a_fk_contest_id": { + "name": "problem_contest_id_328e013a_fk_contest_id", + "tableFrom": "problem", + "tableTo": "contest", + "columnsFrom": [ + "contest_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "problem_created_by_id_cb362143_fk_user_id": { + "name": "problem_created_by_id_cb362143_fk_user_id", + "tableFrom": "problem", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_problem_id_contest": { + "name": "unique_problem_id_contest", + "nullsNotDistinct": false, + "columns": [ + "_id", + "contest_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problem_tag": { + "name": "problem_tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'knowledge'" + }, + "category_confirmed": { + "name": "category_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "problem_tag_name_ci_unique": { + "name": "problem_tag_name_ci_unique", + "columns": [ + { + "expression": "lower(name)", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problem_tags": { + "name": "problem_tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "problem_id": { + "name": "problem_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "problemtag_id": { + "name": "problemtag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "problem_tags_problemtag_id_72d20571": { + "name": "problem_tags_problemtag_id_72d20571", + "columns": [ + { + "expression": "problemtag_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "problem_tags_problem_id_866ecb8d_fk_problem_id": { + "name": "problem_tags_problem_id_866ecb8d_fk_problem_id", + "tableFrom": "problem_tags", + "tableTo": "problem", + "columnsFrom": [ + "problem_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "problem_tags_problemtag_id_72d20571_fk_problem_tag_id": { + "name": "problem_tags_problemtag_id_72d20571_fk_problem_tag_id", + "tableFrom": "problem_tags", + "tableTo": "problem_tag", + "columnsFrom": [ + "problemtag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "problem_tags_problem_id_problemtag_id_318459d1_uniq": { + "name": "problem_tags_problem_id_problemtag_id_318459d1_uniq", + "nullsNotDistinct": false, + "columns": [ + "problem_id", + "problemtag_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problemset": { + "name": "problemset", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "problemset_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_update_time": { + "name": "last_update_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "visible": { + "name": "visible", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "difficulty": { + "name": "difficulty", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "problemset_created_by_id_01b5197f": { + "name": "problemset_created_by_id_01b5197f", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "problemset_created_by_id_01b5197f_fk_user_id": { + "name": "problemset_created_by_id_01b5197f_fk_user_id", + "tableFrom": "problemset", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problemset_badge": { + "name": "problemset_badge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "problemset_badge_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "condition_type": { + "name": "condition_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "condition_value": { + "name": "condition_value", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "problemset_id": { + "name": "problemset_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "problemset_badge_problemset_id_6cb6c74f": { + "name": "problemset_badge_problemset_id_6cb6c74f", + "columns": [ + { + "expression": "problemset_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int8_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "problemset_badge_problemset_id_6cb6c74f_fk_problemset_id": { + "name": "problemset_badge_problemset_id_6cb6c74f_fk_problemset_id", + "tableFrom": "problemset_badge", + "tableTo": "problemset", + "columnsFrom": [ + "problemset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problemset_problem": { + "name": "problemset_problem", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "problemset_problem_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_required": { + "name": "is_required", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "hint": { + "name": "hint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "problem_id": { + "name": "problem_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "problemset_id": { + "name": "problemset_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "problemset_problem_problem_id_fff2d686": { + "name": "problemset_problem_problem_id_fff2d686", + "columns": [ + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "problemset_problem_problem_id_fff2d686_fk_problem_id": { + "name": "problemset_problem_problem_id_fff2d686_fk_problem_id", + "tableFrom": "problemset_problem", + "tableTo": "problem", + "columnsFrom": [ + "problem_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "problemset_problem_problemset_id_350d17fb_fk_problemset_id": { + "name": "problemset_problem_problemset_id_350d17fb_fk_problemset_id", + "tableFrom": "problemset_problem", + "tableTo": "problemset", + "columnsFrom": [ + "problemset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_problemset_problem": { + "name": "unique_problemset_problem", + "nullsNotDistinct": false, + "columns": [ + "problem_id", + "problemset_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problemset_progress": { + "name": "problemset_progress", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "problemset_progress_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "join_time": { + "name": "join_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "complete_time": { + "name": "complete_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_completed": { + "name": "is_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "progress_percentage": { + "name": "progress_percentage", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "completed_problems_count": { + "name": "completed_problems_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_problems_count": { + "name": "total_problems_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_score": { + "name": "total_score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "progress_detail": { + "name": "progress_detail", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "problemset_id": { + "name": "problemset_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "problemset_progress_user_id_c8041a80": { + "name": "problemset_progress_user_id_c8041a80", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "problemset_progress_problemset_id_20a9632e_fk_problemset_id": { + "name": "problemset_progress_problemset_id_20a9632e_fk_problemset_id", + "tableFrom": "problemset_progress", + "tableTo": "problemset", + "columnsFrom": [ + "problemset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "problemset_progress_user_id_c8041a80_fk_user_id": { + "name": "problemset_progress_user_id_c8041a80_fk_user_id", + "tableFrom": "problemset_progress", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_problemset_progress_user": { + "name": "unique_problemset_progress_user", + "nullsNotDistinct": false, + "columns": [ + "problemset_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problemset_submission": { + "name": "problemset_submission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "problemset_submission_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "problem_id": { + "name": "problem_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "problemset_id": { + "name": "problemset_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "submission_id": { + "name": "submission_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "problemset__problem_1f39fa_idx": { + "name": "problemset__problem_1f39fa_idx", + "columns": [ + { + "expression": "problemset_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "problemset__problem_22f053_idx": { + "name": "problemset__problem_22f053_idx", + "columns": [ + { + "expression": "problemset_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int8_ops" + }, + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int8_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "problemset__user_id_2f1501_idx": { + "name": "problemset__user_id_2f1501_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "problemset_submission_problem_id_5629b105": { + "name": "problemset_submission_problem_id_5629b105", + "columns": [ + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "problemset_submission_submission_id_78e2b807": { + "name": "problemset_submission_submission_id_78e2b807", + "columns": [ + { + "expression": "submission_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "problemset_submission_problem_id_5629b105_fk_problem_id": { + "name": "problemset_submission_problem_id_5629b105_fk_problem_id", + "tableFrom": "problemset_submission", + "tableTo": "problem", + "columnsFrom": [ + "problem_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "problemset_submission_problemset_id_85290e17_fk_problemset_id": { + "name": "problemset_submission_problemset_id_85290e17_fk_problemset_id", + "tableFrom": "problemset_submission", + "tableTo": "problemset", + "columnsFrom": [ + "problemset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "problemset_submission_submission_id_78e2b807_fk_submission_id": { + "name": "problemset_submission_submission_id_78e2b807_fk_submission_id", + "tableFrom": "problemset_submission", + "tableTo": "submission", + "columnsFrom": [ + "submission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "problemset_submission_user_id_915fc9c6_fk_user_id": { + "name": "problemset_submission_user_id_915fc9c6_fk_user_id", + "tableFrom": "problemset_submission", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reaction": { + "name": "reaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "reaction_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "problem_id": { + "name": "problem_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "reaction_problem_type_idx": { + "name": "reaction_problem_type_idx", + "columns": [ + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reaction_user_id_cfa7f469": { + "name": "reaction_user_id_cfa7f469", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reaction_problem_id_a7f3b9f3_fk_problem_id": { + "name": "reaction_problem_id_a7f3b9f3_fk_problem_id", + "tableFrom": "reaction", + "tableTo": "problem", + "columnsFrom": [ + "problem_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reaction_user_id_cfa7f469_fk_user_id": { + "name": "reaction_user_id_cfa7f469_fk_user_id", + "tableFrom": "reaction", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reaction_problem_user_unique": { + "name": "reaction_problem_user_unique", + "nullsNotDistinct": false, + "columns": [ + "problem_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.submission": { + "name": "submission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contest_id": { + "name": "contest_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "problem_id": { + "name": "problem_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 6 + }, + "info": { + "name": "info", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared": { + "name": "shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "statistic_info": { + "name": "statistic_info", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "problemset_id": { + "name": "problemset_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "contest_create_time_idx": { + "name": "contest_create_time_idx", + "columns": [ + { + "expression": "contest_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_public_create_time_id_idx": { + "name": "submission_public_create_time_id_idx", + "columns": [ + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"submission\".\"contest_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "problem_user_idx": { + "name": "problem_user_idx", + "columns": [ + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_result_37e2f67a": { + "name": "submission_result_37e2f67a", + "columns": [ + { + "expression": "result", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_create_time_idx": { + "name": "user_create_time_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_language_time_idx": { + "name": "submission_language_time_idx", + "columns": [ + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"submission\".\"contest_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_result_time_idx": { + "name": "submission_result_time_idx", + "columns": [ + { + "expression": "result", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"submission\".\"contest_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_public_problem_time_idx": { + "name": "submission_public_problem_time_idx", + "columns": [ + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"submission\".\"contest_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_public_username_trgm_idx": { + "name": "submission_public_username_trgm_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "where": "\"submission\".\"contest_id\" is null", + "concurrently": false, + "method": "gin", + "with": {} + }, + "submission_public_metrics_idx": { + "name": "submission_public_metrics_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "result", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"submission\".\"contest_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_public_problem_user_idx": { + "name": "submission_public_problem_user_idx", + "columns": [ + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "result", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"submission\".\"contest_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_problemset_id_idx": { + "name": "submission_problemset_id_idx", + "columns": [ + { + "expression": "problemset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"submission\".\"problemset_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "submission_contest_id_775716d5_fk_contest_id": { + "name": "submission_contest_id_775716d5_fk_contest_id", + "tableFrom": "submission", + "tableTo": "contest", + "columnsFrom": [ + "contest_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "submission_problem_id_76847b55_fk_problem_id": { + "name": "submission_problem_id_76847b55_fk_problem_id", + "tableFrom": "submission", + "tableTo": "problem", + "columnsFrom": [ + "problem_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "submission_problemset_id_fk_problemset_id": { + "name": "submission_problemset_id_fk_problemset_id", + "tableFrom": "submission", + "tableTo": "problemset", + "columnsFrom": [ + "problemset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.submission_trace": { + "name": "submission_trace", + "schema": "", + "columns": { + "submission_id": { + "name": "submission_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "active_ms": { + "name": "active_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "since_open_ms": { + "name": "since_open_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "typed_chars": { + "name": "typed_chars", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pasted_chars": { + "name": "pasted_chars", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "paste_count": { + "name": "paste_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "max_paste": { + "name": "max_paste", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_chars": { + "name": "deleted_chars", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "blur_count": { + "name": "blur_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "initial_len": { + "name": "initial_len", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "collab": { + "name": "collab", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "since_prev_ms": { + "name": "since_prev_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "submission_trace_submission_id_fk_submission_id": { + "name": "submission_trace_submission_id_fk_submission_id", + "tableFrom": "submission_trace", + "tableTo": "submission", + "columnsFrom": [ + "submission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tutorial": { + "name": "tutorial", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "tutorial_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tutorial_created_by_id_07973cab": { + "name": "tutorial_created_by_id_07973cab", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tutorial_created_by_id_07973cab_fk_user_id": { + "name": "tutorial_created_by_id_07973cab_fk_user_id", + "tableFrom": "tutorial", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tutorial_progress": { + "name": "tutorial_progress", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tutorial_id": { + "name": "tutorial_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "view_count": { + "name": "view_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_seconds": { + "name": "total_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_viewed_at": { + "name": "first_viewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tutorial_progress_tutorial_id_idx": { + "name": "tutorial_progress_tutorial_id_idx", + "columns": [ + { + "expression": "tutorial_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tutorial_progress_user_id_fk_user_id": { + "name": "tutorial_progress_user_id_fk_user_id", + "tableFrom": "tutorial_progress", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tutorial_progress_tutorial_id_fk_tutorial_id": { + "name": "tutorial_progress_tutorial_id_fk_tutorial_id", + "tableFrom": "tutorial_progress", + "tableTo": "tutorial", + "columnsFrom": [ + "tutorial_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tutorial_progress_pkey": { + "name": "tutorial_progress_pkey", + "columns": [ + "user_id", + "tutorial_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "last_login": { + "name": "last_login", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "admin_type": { + "name": "admin_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_disabled": { + "name": "is_disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "problem_permission": { + "name": "problem_permission", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "raw_password": { + "name": "raw_password", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "class_name": { + "name": "class_name", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_active_idx": { + "name": "user_active_idx", + "columns": [ + { + "expression": "is_disabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_login", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_class_name_idx": { + "name": "user_class_name_idx", + "columns": [ + { + "expression": "class_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_username_key": { + "name": "user_username_key", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_achievement": { + "name": "user_achievement", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "user_achievement_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "unlock_time": { + "name": "unlock_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "backfilled": { + "name": "backfilled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notified": { + "name": "notified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "achievement_id": { + "name": "achievement_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_achievement_achievement_id_29db600d": { + "name": "user_achievement_achievement_id_29db600d", + "columns": [ + { + "expression": "achievement_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int8_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_achv_notified_idx": { + "name": "user_achv_notified_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "notified", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_achv_time_idx": { + "name": "user_achv_time_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "unlock_time", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_achievement_achievement_id_29db600d_fk_achievement_id": { + "name": "user_achievement_achievement_id_29db600d_fk_achievement_id", + "tableFrom": "user_achievement", + "tableTo": "achievement", + "columnsFrom": [ + "achievement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_achievement_user_id_b8ec7d6a_fk_user_id": { + "name": "user_achievement_user_id_b8ec7d6a_fk_user_id", + "tableFrom": "user_achievement", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_user_achievement": { + "name": "unique_user_achievement", + "nullsNotDistinct": false, + "columns": [ + "achievement_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_badge": { + "name": "user_badge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "user_badge_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "earned_time": { + "name": "earned_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "badge_id": { + "name": "badge_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_badge_badge_id_92a983e9": { + "name": "user_badge_badge_id_92a983e9", + "columns": [ + { + "expression": "badge_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int8_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_badge_badge_id_92a983e9_fk_problemset_badge_id": { + "name": "user_badge_badge_id_92a983e9_fk_problemset_badge_id", + "tableFrom": "user_badge", + "tableTo": "problemset_badge", + "columnsFrom": [ + "badge_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_badge_user_id_a286d718_fk_user_id": { + "name": "user_badge_user_id_a286d718_fk_user_id", + "tableFrom": "user_badge", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_user_badge": { + "name": "unique_user_badge", + "nullsNotDistinct": false, + "columns": [ + "badge_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_profile": { + "name": "user_profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "acm_problems_status": { + "name": "acm_problems_status", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mood": { + "name": "mood", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accepted_number": { + "name": "accepted_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "submission_number": { + "name": "submission_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "real_name": { + "name": "real_name", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_profile_user_id_8fdce8e2_fk_user_id": { + "name": "user_profile_user_id_8fdce8e2_fk_user_id", + "tableFrom": "user_profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_profile_user_id_key": { + "name": "user_profile_user_id_key", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stat": { + "name": "user_stat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "user_stat_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "metrics": { + "name": "metrics", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "update_time": { + "name": "update_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_stat_user_id_73337fc0_fk_user_id": { + "name": "user_stat_user_id_73337fc0_fk_user_id", + "tableFrom": "user_stat", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stat_user_id_key": { + "name": "user_stat_user_id_key", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/src/db/meta/_journal.json b/apps/api/src/db/meta/_journal.json index 09564ac..8d0559f 100644 --- a/apps/api/src/db/meta/_journal.json +++ b/apps/api/src/db/meta/_journal.json @@ -183,6 +183,13 @@ "when": 1791521256834, "tag": "0025_submission_problem_user_idx", "breakpoints": true + }, + { + "idx": 26, + "version": "7", + "when": 1791553481647, + "tag": "0026_problemset_assignment", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 2528de0..9a84bb6 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -417,7 +417,17 @@ export const problemset = pgTable( difficulty: text().notNull().$type(), status: text().notNull().$type(), createdById: integer("created_by_id").notNull(), - endTime: timestamp("end_time", { withTimezone: true, mode: "string" }), + /** + * 布置期:assignedAt 是这一轮布置开始的时刻,assignedUntil 是布置到哪天(东八区当天结束)。 + * 布置期内,题单里这几道题学生在 assignedAt 之前交的代码先藏起来(防抄),见 + * services/problemset.ts 的 problemSetLockCutoffs。不拦做题,过期照样做、照样记进度。 + * 两个都为空 = 没布置。 + * + * 列名还是 end_time(以前叫「截止时间」,名不副实):机房那套和服务器共用这个库、 + * 不一定同时升级,改列名会让没升级的那边一查题单就报错,所以只改代码里的名字 + */ + assignedUntil: timestamp("end_time", { withTimezone: true, mode: "string" }), + assignedAt: timestamp("assigned_at", { withTimezone: true, mode: "string" }), }, (table) => [ index("problemset_created_by_id_01b5197f").using( diff --git a/apps/api/src/routes/admin/problemset.ts b/apps/api/src/routes/admin/problemset.ts index 8bfbefd..0a75cf1 100644 --- a/apps/api/src/routes/admin/problemset.ts +++ b/apps/api/src/routes/admin/problemset.ts @@ -1,18 +1,18 @@ import { - addProblemToSetRequestSchema, + addProblemsToSetRequestSchema, createProblemSetBadgeRequestSchema, createProblemSetRequestSchema, + reorderProblemSetProblemsRequestSchema, updateProblemInSetRequestSchema, updateProblemSetBadgeRequestSchema, updateProblemSetRequestSchema, - updateProblemSetStatusRequestSchema, + type AddProblemsToSetResult, type AdminProblemSet, type AdminProblemSetBadge, type AdminProblemSetList, type AdminProblemSetProblem, - type AdminProblemSetProgress, } from "@oj2/contract" -import { and, asc, count, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm" +import { and, asc, count, desc, eq, ilike, inArray, isNull, max, or, sql } from "drizzle-orm" import { Hono } from "hono" import { requireTeacher, type AppEnv } from "../../auth/middleware" @@ -20,7 +20,7 @@ import type { AuthUser } from "../../auth/session" import { db, schema } from "../../db" import { failure, parseBody, success } from "../../http" import { recalculateBadge, resyncProgress } from "../../services/problemset" -import { asFilterValue, queryInteger, sampleUser } from "../helpers" +import { queryInteger, sampleUser } from "../helpers" export const adminProblemSetRoutes = new Hono() @@ -49,11 +49,11 @@ async function serialize(row: typeof schema.problemset.$inferSelect) { return (await serializeMany([row]))[0]! } -/** 批量版:列表接口走这个,固定 3 条查询,与行数无关(按行 serialize 就是 N+1) */ +/** 批量版:列表接口走这个,固定 4 条查询,与行数无关(按行 serialize 就是 N+1) */ async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) { if (rows.length === 0) return [] const ids = rows.map((row) => row.id) - const [problems, participants, creators] = await Promise.all([ + const [problems, participants, badges, creators] = await Promise.all([ db .select({ problemsetId: schema.problemsetProblem.problemsetId, @@ -66,10 +66,19 @@ async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) { .select({ problemsetId: schema.problemsetProgress.problemsetId, value: count(), + completed: sql`count(*) filter (where ${schema.problemsetProgress.isCompleted})::int`, }) .from(schema.problemsetProgress) .where(inArray(schema.problemsetProgress.problemsetId, ids)) .groupBy(schema.problemsetProgress.problemsetId), + db + .select({ + problemsetId: schema.problemsetBadge.problemsetId, + value: count(), + }) + .from(schema.problemsetBadge) + .where(inArray(schema.problemsetBadge.problemsetId, ids)) + .groupBy(schema.problemsetBadge.problemsetId), db .select({ id: schema.user.id, @@ -81,27 +90,57 @@ async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) { .where(inArray(schema.user.id, [...new Set(rows.map((row) => row.createdById))])), ]) const problemsBySet = new Map(problems.map((item) => [item.problemsetId, item.value])) - const participantsBySet = new Map(participants.map((item) => [item.problemsetId, item.value])) + const participantsBySet = new Map(participants.map((item) => [item.problemsetId, item])) + const badgesBySet = new Map(badges.map((item) => [item.problemsetId, item.value])) const creatorById = new Map(creators.map((item) => [item.id, item])) + const now = Date.now() return rows.map((row) => { const creator = creatorById.get(row.createdById) return { id: row.id, title: row.title, description: row.description, - difficulty: row.difficulty, - status: row.status, - endTime: row.endTime, + assignedUntil: row.assignedUntil, + assignedAt: row.assignedAt, + assigning: + row.assignedAt !== null && + row.assignedUntil !== null && + Date.parse(row.assignedUntil) > now, visible: row.visible, createdBy: sampleUser(creator ?? { id: row.createdById, username: "" }, creator?.realName), createTime: row.createTime, lastUpdateTime: row.lastUpdateTime, problemsCount: problemsBySet.get(row.id) ?? 0, - participantCount: participantsBySet.get(row.id) ?? 0, + participantCount: participantsBySet.get(row.id)?.value ?? 0, + completedCount: participantsBySet.get(row.id)?.completed ?? 0, + badgeCount: badgesBySet.get(row.id) ?? 0, } satisfies AdminProblemSet }) } +/** + * 布置期怎么落库。老师给的是「布置到哪天」(东八区当天结束的时刻),开始时刻由服务端定: + * - 不布置 → 两个都清掉; + * - 现在正在布置 → 只改结束,开始不动(延长、缩短都不该把「以前的代码」的分界挪到现在); + * - 没在布置(从没布置过,或者上一轮已经过期)→ 开始记成现在,这就是「再布置一次」。 + */ +function assignment( + requested: string | null, + current: { assignedAt: string | null; assignedUntil: string | null } | null, +) { + if (requested === null) return { assignedAt: null, assignedUntil: null } + const until = Date.parse(requested) + if (!Number.isFinite(until) || until <= Date.now()) return "past" as const + const running = + current?.assignedAt != null && + current.assignedUntil != null && + Date.parse(current.assignedUntil) > Date.now() + return { + assignedAt: running ? current.assignedAt : new Date().toISOString(), + assignedUntil: new Date(until).toISOString(), + } +} + // ---------------------------------------------------------------- 题单本体 adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => { @@ -114,8 +153,6 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => { // 再也没法在界面上改回来。后台必须能看见自己管的全部题单。 if (user.adminType !== "Super Admin") filters.push(eq(schema.problemset.createdById, user.id)) const keyword = c.req.query("keyword")?.trim() - const difficulty = c.req.query("difficulty")?.trim() - const status = c.req.query("status")?.trim() if (keyword) { filters.push( or( @@ -124,8 +161,6 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => { )!, ) } - if (difficulty) filters.push(eq(schema.problemset.difficulty, asFilterValue(difficulty))) - if (status) filters.push(eq(schema.problemset.status, asFilterValue(status))) const where = filters.length ? and(...filters) : undefined const [totalRows, rows] = await Promise.all([ @@ -147,12 +182,19 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => { adminProblemSetRoutes.post("/problem-sets", requireTeacher, async (c) => { const parsed = await parseBody(c, createProblemSetRequestSchema) if (!parsed.success) return parsed.response + const period = assignment(parsed.data.assignedUntil, null) + if (period === "past") return failure(c, 400, "assign-in-past", "布置到的日期已经过了") const now = new Date().toISOString() const [created] = await db .insert(schema.problemset) .values({ - ...parsed.data, - endTime: parsed.data.endTime ? new Date(parsed.data.endTime).toISOString() : null, + title: parsed.data.title, + description: parsed.data.description, + visible: parsed.data.visible, + ...period, + // 难度、状态两列还在表里、非空,但已经不用了(见契约 admin.ts 的说明) + difficulty: "Easy", + status: "active", createdById: c.get("user")!.id, createTime: now, lastUpdateTime: now, @@ -172,11 +214,15 @@ adminProblemSetRoutes.put("/problem-sets/:id", requireTeacher, async (c) => { if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在") const parsed = await parseBody(c, updateProblemSetRequestSchema) if (!parsed.success) return parsed.response + const period = assignment(parsed.data.assignedUntil, row) + if (period === "past") return failure(c, 400, "assign-in-past", "布置到的日期已经过了") const [updated] = await db .update(schema.problemset) .set({ - ...parsed.data, - endTime: parsed.data.endTime ? new Date(parsed.data.endTime).toISOString() : null, + title: parsed.data.title, + description: parsed.data.description, + visible: parsed.data.visible, + ...period, lastUpdateTime: new Date().toISOString(), }) .where(eq(schema.problemset.id, row.id)) @@ -196,22 +242,6 @@ adminProblemSetRoutes.put("/problem-sets/:id/visibility", requireTeacher, async return success(c, await serialize(updated!)) }) -adminProblemSetRoutes.put("/problem-sets/:id/status", requireTeacher, async (c) => { - const row = await loadOwned(c, c.get("user")!) - if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在") - const parsed = await parseBody(c, updateProblemSetStatusRequestSchema, "status 不合法") - if (!parsed.success) return parsed.response - const [updated] = await db - .update(schema.problemset) - .set({ - status: parsed.data.status, - lastUpdateTime: new Date().toISOString(), - }) - .where(eq(schema.problemset.id, row.id)) - .returning() - return success(c, await serialize(updated!)) -}) - adminProblemSetRoutes.delete("/problem-sets/:id", requireTeacher, async (c) => { const row = await loadOwned(c, c.get("user")!) if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在") @@ -245,59 +275,97 @@ adminProblemSetRoutes.get("/problem-sets/:id/problems", requireTeacher, async (c difficulty: problem.difficulty, order: item.order, isRequired: item.isRequired, - score: item.score, - hint: item.hint, }) satisfies AdminProblemSetProblem, ), ) }) +/** + * 加题:一次可以给好几个题号(后台搜索框里粘一串「3065 3066 3067」),按给的顺序接在最后。 + * 找不到的、本来就在里面的不报错,分开报回去,前端告诉老师哪几个没加上。 + */ adminProblemSetRoutes.post("/problem-sets/:id/problems", requireTeacher, async (c) => { const row = await loadOwned(c, c.get("user")!) if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在") - const parsed = await parseBody(c, addProblemToSetRequestSchema) + const parsed = await parseBody(c, addProblemsToSetRequestSchema) if (!parsed.success) return parsed.response - const [problem] = await db - .select({ id: schema.problem.id }) - .from(schema.problem) - .where( - and( - sql`lower(${schema.problem.displayId}) = lower(${parsed.data.problemId})`, - eq(schema.problem.visible, true), - isNull(schema.problem.contestId), + const wanted = [...new Set(parsed.data.problemIds.map((id) => id.toLowerCase()))] + const [problems, existing, last] = await Promise.all([ + db + .select({ id: schema.problem.id, displayId: schema.problem.displayId }) + .from(schema.problem) + .where( + and( + inArray(sql`lower(${schema.problem.displayId})`, wanted), + eq(schema.problem.visible, true), + isNull(schema.problem.contestId), + ), ), - ) - .limit(1) - if (!problem) return failure(c, 404, "problem-not-found", "题目不存在或不可见") + db + .select({ problemId: schema.problemsetProblem.problemId }) + .from(schema.problemsetProblem) + .where(eq(schema.problemsetProblem.problemsetId, row.id)), + db + .select({ value: max(schema.problemsetProblem.order) }) + .from(schema.problemsetProblem) + .where(eq(schema.problemsetProblem.problemsetId, row.id)), + ]) + const byDisplayId = new Map(problems.map((problem) => [problem.displayId.toLowerCase(), problem])) + const inSet = new Set(existing.map((item) => item.problemId)) + const result: AddProblemsToSetResult = { added: [], missing: [], duplicate: [] } + const values: (typeof schema.problemsetProblem.$inferInsert)[] = [] + let order = last[0]?.value ?? 0 + for (const id of wanted) { + const problem = byDisplayId.get(id) + if (!problem) result.missing.push(id) + else if (inSet.has(problem.id)) result.duplicate.push(problem.displayId) + else { + inSet.add(problem.id) + order += 1 + // 分数、提示两列还在表里但已经不用了:score 非空给 0 + values.push({ + problemsetId: row.id, + problemId: problem.id, + order, + isRequired: true, + score: 0, + }) + result.added.push(problem.displayId) + } + } + if (values.length) { + await db.insert(schema.problemsetProblem).values(values) + // 题目集变了,已加入的人的分母、完成状态、奖章都得跟着变(旧栈靠 post_save 信号, + // 不在 views 里,别因为翻不到显式调用就以为它没做,见 services/problemset.ts) + await resyncProgress(row.id) + } + return success(c, result, values.length ? 201 : 200) +}) - const [duplicate] = await db +// 必须注册在 /problems/:itemId 前面:Hono 按注册顺序匹配,order 会被当成 :itemId 吃掉 +adminProblemSetRoutes.put("/problem-sets/:id/problems/order", requireTeacher, async (c) => { + const row = await loadOwned(c, c.get("user")!) + if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在") + const parsed = await parseBody(c, reorderProblemSetProblemsRequestSchema) + if (!parsed.success) return parsed.response + const links = await db .select({ id: schema.problemsetProblem.id }) .from(schema.problemsetProblem) - .where( - and( - eq(schema.problemsetProblem.problemsetId, row.id), - eq(schema.problemsetProblem.problemId, problem.id), - ), - ) - .limit(1) - if (duplicate) return failure(c, 409, "problem-already-in-set", "题目已在该题单中") - - const [created] = await db - .insert(schema.problemsetProblem) - .values({ - problemsetId: row.id, - problemId: problem.id, - order: parsed.data.order, - isRequired: parsed.data.isRequired, - score: parsed.data.score, - hint: parsed.data.hint, - }) - .returning({ id: schema.problemsetProblem.id }) - // 题目集变了,已加入的人的 totalProblemsCount / 百分比都得跟着变, - // 否则学生看到的进度分母还是老的。旧栈是靠 ProblemSetProblem 的 post_save 信号做的, - // 不在 views 里,别因为翻不到显式调用就以为它没做(见 services/problemset.ts)。 - await resyncProgress(row.id) - return success(c, { id: created!.id }, 201) + .where(eq(schema.problemsetProblem.problemsetId, row.id)) + const have = new Set(links.map((link) => link.id)) + const given = new Set(parsed.data.ids) + if (given.size !== have.size || [...given].some((id) => !have.has(id))) { + return failure(c, 409, "problems-changed", "题目列表变了,刷新一下再排") + } + await db.transaction(async (tx) => { + for (const [index, id] of parsed.data.ids.entries()) { + await tx + .update(schema.problemsetProblem) + .set({ order: index + 1 }) + .where(eq(schema.problemsetProblem.id, id)) + } + }) + return success(c, null) }) adminProblemSetRoutes.put("/problem-sets/:id/problems/:itemId", requireTeacher, async (c) => { @@ -316,7 +384,8 @@ adminProblemSetRoutes.put("/problem-sets/:id/problems/:itemId", requireTeacher, ) .returning({ id: schema.problemsetProblem.id }) if (updated.length === 0) return failure(c, 404, "problem-not-in-set", "题目不在该题单中") - if (parsed.data.score !== undefined) await resyncProgress(row.id) + // 必做 / 选做变了,分母和「做完」跟着变 + await resyncProgress(row.id) return success(c, null) }) @@ -453,84 +522,3 @@ adminProblemSetRoutes.delete("/problem-sets/:id/badges/:badgeId", requireTeacher await db.delete(schema.problemsetBadge).where(eq(schema.problemsetBadge.id, badge.id)) return success(c, null) }) - -// ---------------------------------------------------------------- 学生进度 - -adminProblemSetRoutes.get("/problem-sets/:id/progress", requireTeacher, async (c) => { - const row = await loadOwned(c, c.get("user")!) - if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在") - const rows = await db - .select({ - progress: schema.problemsetProgress, - username: schema.user.username, - realName: schema.userProfile.realName, - }) - .from(schema.problemsetProgress) - .innerJoin(schema.user, eq(schema.problemsetProgress.userId, schema.user.id)) - .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) - .where(eq(schema.problemsetProgress.problemsetId, row.id)) - .orderBy(desc(schema.problemsetProgress.joinTime)) - return success( - c, - rows.map( - ({ progress, username, realName }) => - ({ - id: progress.id, - userId: progress.userId, - username, - // 真名有意下发:这是老师看本班完成情况的页面,已由 requireTeacher + 归属校验把关 - realName, - joinTime: progress.joinTime, - completeTime: progress.completeTime, - isCompleted: progress.isCompleted, - progressPercentage: progress.progressPercentage, - completedProblemsCount: progress.completedProblemsCount, - totalProblemsCount: progress.totalProblemsCount, - totalScore: progress.totalScore, - }) satisfies AdminProblemSetProgress, - ), - ) -}) - -adminProblemSetRoutes.delete("/problem-sets/:id/progress/:userId", requireTeacher, async (c) => { - const row = await loadOwned(c, c.get("user")!) - if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在") - const userId = queryInteger(c.req.param("userId"), 0, { min: 1 }) - const deleted = await db.transaction(async (tx) => { - // 把人踢出题单,他基于这份题单拿到的奖章也该收回,否则奖章会悬空 - const badges = await tx - .select({ id: schema.problemsetBadge.id }) - .from(schema.problemsetBadge) - .where(eq(schema.problemsetBadge.problemsetId, row.id)) - if (badges.length) { - await tx.delete(schema.userBadge).where( - and( - eq(schema.userBadge.userId, userId), - inArray( - schema.userBadge.badgeId, - badges.map((badge) => badge.id), - ), - ), - ) - } - await tx - .delete(schema.problemsetSubmission) - .where( - and( - eq(schema.problemsetSubmission.problemsetId, row.id), - eq(schema.problemsetSubmission.userId, userId), - ), - ) - return tx - .delete(schema.problemsetProgress) - .where( - and( - eq(schema.problemsetProgress.problemsetId, row.id), - eq(schema.problemsetProgress.userId, userId), - ), - ) - .returning({ id: schema.problemsetProgress.id }) - }) - if (deleted.length === 0) return failure(c, 404, "progress-not-found", "用户未加入该题单") - return success(c, null) -}) diff --git a/apps/api/src/routes/flowchart.ts b/apps/api/src/routes/flowchart.ts index 1a4311e..813ba69 100644 --- a/apps/api/src/routes/flowchart.ts +++ b/apps/api/src/routes/flowchart.ts @@ -23,7 +23,7 @@ import { db, schema } from "../db" import { failure, readJson, success } from "../http" import { flowchartQueue } from "../queue" import { getBooleanOption } from "../services/options" -import { problemSetJoinTimes } from "../services/problemset" +import { problemSetLockCutoffs } from "../services/problemset" import { consumeToken } from "../services/throttling" import { buildWordFrequencies } from "../services/word-frequency" import { dayStart } from "../time" @@ -49,36 +49,36 @@ function flowchartThrottleKey(userId: number) { } /** - * 题单防抄:学生加入含这道题的题单之前画的图,先对他本人藏起来 —— 和代码提交同一道闸 - * (routes/submission.ts 的 canViewSubmission,规则见 problemSetJoinTimes)。原来流程图 - * 这边没有这一条,加入题单之前画到 A 的那张图点一下就能载回画布,交上去就是 A。 + * 题单防抄:题单布置期内,学生在布置开始之前画的图先对他本人藏起来 —— 和代码提交同一道闸 + * (routes/submission.ts 的 canViewSubmission,规则见 problemSetLockCutoffs)。原来流程图 + * 这边没有这一条,以前画到 A 的那张图点一下就能载回画布,交上去就是 A。 */ function hiddenByProblemSet( user: AuthUser, row: { userId: number; problemId: number; createTime: string }, - joinTimes: Map, + cutoffs: Map, ) { if (row.userId !== user.id || isTeacherOrAbove(user)) return false - const joinTime = joinTimes.get(row.problemId) - return joinTime !== undefined && Date.parse(row.createTime) < Date.parse(joinTime) + const cutoff = cutoffs.get(row.problemId) + return cutoff !== undefined && Date.parse(row.createTime) < Date.parse(cutoff) } function canView( user: AuthUser, row: { userId: number; problemId: number; createTime: string }, problem: { createdById: number }, - joinTimes: Map, + cutoffs: Map, ) { - if (hiddenByProblemSet(user, row, joinTimes)) return false + if (hiddenByProblemSet(user, row, cutoffs)) return false // 角色捷径只给老师:学生管理员算学生,不能看同学的流程图(同 submission.ts 的 canViewSubmission) return row.userId === user.id || isTeacherOrAbove(user) || problem.createdById === user.id } /** 只有学生看自己的提交时才需要查题单;其余情况给一张空表,省一次查询 */ -function joinTimesFor(user: AuthUser, rows: Array<{ userId: number; problemId: number }>) { +function cutoffsFor(user: AuthUser, rows: Array<{ userId: number; problemId: number }>) { if (isTeacherOrAbove(user)) return Promise.resolve(new Map()) const own = rows.filter((row) => row.userId === user.id).map((row) => row.problemId) - return problemSetJoinTimes(user.id, [...new Set(own)]) + return problemSetLockCutoffs(user.id, [...new Set(own)]) } function flowchartData( @@ -321,7 +321,7 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => { .offset(offset), displayId ? unknownDisplayIds(displayId, null) : [], ]) - const joinTimes = await joinTimesFor( + const cutoffs = await cutoffsFor( user, rows.map((row) => row.flowchart), ) @@ -341,7 +341,7 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => { aiModel: flowchart.aiModel, processingTime: flowchart.processingTime, evaluationTime: flowchart.evaluationTime, - showLink: canView(user, flowchart, problem, joinTimes), + showLink: canView(user, flowchart, problem, cutoffs), }) satisfies FlowchartListItem, ), total: totalRows[0]?.value ?? 0, @@ -656,7 +656,7 @@ flowchartRoutes.get("/flowcharts/:id", requireAuth, async (c) => { .where(eq(schema.flowchartSubmission.id, c.req.param("id"))) .limit(1) const user = c.get("user")! - if (!row || !canView(user, row.flowchart, row.problem, await joinTimesFor(user, [row.flowchart]))) + if (!row || !canView(user, row.flowchart, row.problem, await cutoffsFor(user, [row.flowchart]))) return failure(c, 404, "flowchart-not-found", "Submission does not exist") return success(c, flowchartData(row.flowchart, row.username)) }) @@ -669,7 +669,7 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => { .innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id)) .where(eq(schema.flowchartSubmission.id, c.req.param("id"))) .limit(1) - if (!row || !canView(user, row.flowchart, row.problem, await joinTimesFor(user, [row.flowchart]))) + if (!row || !canView(user, row.flowchart, row.problem, await cutoffsFor(user, [row.flowchart]))) return failure(c, 404, "flowchart-not-found", "Submission does not exist") // 看得见不等于能重评:canView 放进来的出题人只能看。重评会清掉原来的 // 评分再抽一次,别人的提交只有老师能动 @@ -737,7 +737,7 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => { * userId / problemId / createTime 三列是 hiddenByProblemSet 要的,别删。 */ async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) { - const [rows, joinTimes] = await Promise.all([ + const [rows, cutoffs] = await Promise.all([ db .select({ id: schema.flowchartSubmission.id, @@ -756,9 +756,9 @@ async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) { ), ) .orderBy(asc(schema.flowchartSubmission.createTime)), - joinTimesFor(user, [{ userId: user.id, problemId }]), + cutoffsFor(user, [{ userId: user.id, problemId }]), ]) - const visible = rows.filter((row) => !hiddenByProblemSet(user, row, joinTimes)) + const visible = rows.filter((row) => !hiddenByProblemSet(user, row, cutoffs)) return { visible, hidden: rows.length - visible.length } } diff --git a/apps/api/src/routes/problemset.ts b/apps/api/src/routes/problemset.ts index c025c18..b13c33f 100644 --- a/apps/api/src/routes/problemset.ts +++ b/apps/api/src/routes/problemset.ts @@ -1,44 +1,83 @@ import { joinProblemSetRequestSchema, + STUDENT_ROLES, type ProblemSet, type ProblemSetBadge, + type ProblemSetClassView, type ProblemSetList, + type ProblemSetLock, type ProblemSetProblem, - type ProblemSetProgress, - type ProblemSetProgressList, type UserBadge, } from "@oj2/contract" -import { and, asc, avg, count, desc, eq, gt, ilike, inArray, ne, or, sql } from "drizzle-orm" +import { and, asc, count, desc, eq, ilike, inArray, isNull, notInArray, or, sql } from "drizzle-orm" import { Hono } from "hono" import { optionalAuth, requireAuth, requireTeacher, type AppEnv } from "../auth/middleware" +import type { AuthUser } from "../auth/session" import { db, schema } from "../db" import { failure, parseBody, success } from "../http" -import { computeProgress } from "../services/problemset" -import { asFilterValue, asRecord, classPrefixCondition, queryInteger, sampleUser } from "./helpers" +import { JudgeStatus, NON_FAILURE_RESULTS } from "../judge/status" +import { + assigningSql, + computeProgress, + problemSetLockCutoffs, + problemSetLocksFor, +} from "../services/problemset" +import { asRecord, isTeacherOrAbove, queryInteger, sampleUser } from "./helpers" export const problemsetRoutes = new Hono() type ProblemSetRow = typeof schema.problemset.$inferSelect +type ProgressRow = typeof schema.problemsetProgress.$inferSelect -function progressSummary(progress: typeof schema.problemsetProgress.$inferSelect | undefined) { +const SOLVED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED] + +/** 必做题的 problemId;一道必做都没标的题单退回全部(和 computeProgress 同一个口径) */ +function gradedIds(links: { problemId: number; isRequired: boolean }[]) { + const required = links.filter((link) => link.isRequired) + return new Set((required.length ? required : links).map((link) => link.problemId)) +} + +function progressSummary(progress: ProgressRow | undefined) { return progress ? { isJoined: true, - progressPercentage: progress.progressPercentage, + joinTime: progress.joinTime, completedCount: progress.completedProblemsCount, totalCount: progress.totalProblemsCount, + solvedCount: Object.keys(asRecord(progress.progressDetail)).length, isCompleted: progress.isCompleted, + completeTime: progress.completeTime, } : { isJoined: false, - progressPercentage: 0, + joinTime: null, completedCount: 0, totalCount: 0, + solvedCount: 0, isCompleted: false, + completeTime: null, } } +/** + * 学生能看到的题单:公开的。老师连没公开的也能打开 —— 后台编辑完要到前台看看学生 + * 看到的样子、看全班情况,不该被 404 挡在外面。 + */ +async function loadVisible(id: number, user: AuthUser | null | undefined) { + const [row] = await db + .select({ set: schema.problemset, assigning: assigningSql }) + .from(schema.problemset) + .where( + and( + eq(schema.problemset.id, id), + isTeacherOrAbove(user) ? undefined : eq(schema.problemset.visible, true), + ), + ) + .limit(1) + return row ?? null +} + async function problemSetCreators(ids: number[]) { const map = new Map>() if (ids.length === 0) return map @@ -55,7 +94,7 @@ async function problemSetCreators(ids: number[]) { return map } -function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earned?: boolean) { +function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earnedTime?: string | null) { return { id: badge.id, problemSetId: badge.problemsetId, @@ -64,70 +103,79 @@ function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earned?: b icon: badge.icon, conditionType: badge.conditionType, conditionValue: badge.conditionValue, - isEarned: earned, + isEarned: earnedTime === undefined ? undefined : earnedTime !== null, + earnedTime, } satisfies ProblemSetBadge } /** * 一次把整页题单的附属数据全查回来,再在内存里按 problemsetId 分组。 - * - * 以前是每行 5 条查询(题目数 / 我的进度 / 奖章 / 已获奖章 / 创建者), - * limit 最大 250 就是 1250 次往返。这里固定 5 条,与行数无关。 + * 固定 6 条查询,与行数无关(按行查就是 N+1)。 */ -async function serializeProblemSets(rows: ProblemSetRow[], userId?: number, includeBadges = false) { +async function serializeProblemSets( + rows: { set: ProblemSetRow; assigning: boolean }[], + userId?: number, +) { if (rows.length === 0) return [] - const ids = rows.map((row) => row.id) - const [problemCounts, progresses, badges, earnedRows, creators] = await Promise.all([ - db - .select({ - problemsetId: schema.problemsetProblem.problemsetId, - value: count(), - }) - .from(schema.problemsetProblem) - .where(inArray(schema.problemsetProblem.problemsetId, ids)) - .groupBy(schema.problemsetProblem.problemsetId), - userId - ? db - .select() - .from(schema.problemsetProgress) - .where( - and( - inArray(schema.problemsetProgress.problemsetId, ids), - eq(schema.problemsetProgress.userId, userId), - ), - ) - : Promise.resolve([] as (typeof schema.problemsetProgress.$inferSelect)[]), - includeBadges - ? db - .select() - .from(schema.problemsetBadge) - .where(inArray(schema.problemsetBadge.problemsetId, ids)) - .orderBy(asc(schema.problemsetBadge.id)) - : Promise.resolve([] as (typeof schema.problemsetBadge.$inferSelect)[]), - includeBadges && userId - ? db - .select({ id: schema.userBadge.badgeId }) - .from(schema.userBadge) - .innerJoin( - schema.problemsetBadge, - eq(schema.userBadge.badgeId, schema.problemsetBadge.id), - ) - .where( - and( - eq(schema.userBadge.userId, userId), - inArray(schema.problemsetBadge.problemsetId, ids), - ), - ) - : Promise.resolve([] as { id: number }[]), - problemSetCreators([...new Set(rows.map((row) => row.createdById))]), - ]) - const countBySet = new Map(problemCounts.map((item) => [item.problemsetId, item.value])) + const ids = rows.map((row) => row.set.id) + const [problemCounts, joinedCounts, progresses, badges, earnedRows, creators] = await Promise.all( + [ + db + .select({ + problemsetId: schema.problemsetProblem.problemsetId, + value: count(), + required: sql`count(*) filter (where ${schema.problemsetProblem.isRequired})::int`, + }) + .from(schema.problemsetProblem) + .where(inArray(schema.problemsetProblem.problemsetId, ids)) + .groupBy(schema.problemsetProblem.problemsetId), + db + .select({ problemsetId: schema.problemsetProgress.problemsetId, value: count() }) + .from(schema.problemsetProgress) + .where(inArray(schema.problemsetProgress.problemsetId, ids)) + .groupBy(schema.problemsetProgress.problemsetId), + userId + ? db + .select() + .from(schema.problemsetProgress) + .where( + and( + inArray(schema.problemsetProgress.problemsetId, ids), + eq(schema.problemsetProgress.userId, userId), + ), + ) + : Promise.resolve([] as ProgressRow[]), + db + .select() + .from(schema.problemsetBadge) + .where(inArray(schema.problemsetBadge.problemsetId, ids)) + .orderBy(asc(schema.problemsetBadge.id)), + userId + ? db + .select({ id: schema.userBadge.badgeId, earnedTime: schema.userBadge.earnedTime }) + .from(schema.userBadge) + .innerJoin( + schema.problemsetBadge, + eq(schema.userBadge.badgeId, schema.problemsetBadge.id), + ) + .where( + and( + eq(schema.userBadge.userId, userId), + inArray(schema.problemsetBadge.problemsetId, ids), + ), + ) + : Promise.resolve([] as { id: number; earnedTime: string }[]), + problemSetCreators([...new Set(rows.map((row) => row.set.createdById))]), + ], + ) + const countBySet = new Map(problemCounts.map((item) => [item.problemsetId, item])) + const joinedBySet = new Map(joinedCounts.map((item) => [item.problemsetId, item.value])) const progressBySet = new Map(progresses.map((item) => [item.problemsetId, item])) const badgesBySet = new Map() for (const badge of badges) badgesBySet.set(badge.problemsetId, [...(badgesBySet.get(badge.problemsetId) ?? []), badge]) - const earned = new Set(earnedRows.map((item) => item.id)) - return rows.map((row) => { + const earned = new Map(earnedRows.map((item) => [item.id, item.earnedTime])) + return rows.map(({ set: row, assigning }) => { const progress = progressBySet.get(row.id) return { id: row.id, @@ -136,17 +184,16 @@ async function serializeProblemSets(rows: ProblemSetRow[], userId?: number, incl createdBy: creators.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null), createTime: row.createTime, - lastUpdateTime: row.lastUpdateTime, - difficulty: row.difficulty, - status: row.status, - endTime: row.endTime, - visible: row.visible, - problemsCount: countBySet.get(row.id) ?? 0, - completedCount: progress?.completedProblemsCount ?? 0, + assignedUntil: row.assignedUntil, + assigning, + problemsCount: countBySet.get(row.id)?.value ?? 0, + // 一道必做都没标的题单退回「全部都算必做」,和 computeProgress 同一个口径 + requiredCount: countBySet.get(row.id)?.required || (countBySet.get(row.id)?.value ?? 0), + joinedCount: joinedBySet.get(row.id) ?? 0, userProgress: progressSummary(progress), - badges: includeBadges - ? (badgesBySet.get(row.id) ?? []).map((badge) => badgeData(badge, earned.has(badge.id))) - : undefined, + badges: (badgesBySet.get(row.id) ?? []).map((badge) => + badgeData(badge, userId ? (earned.get(badge.id) ?? null) : undefined), + ), } satisfies ProblemSet }) } @@ -154,10 +201,8 @@ async function serializeProblemSets(rows: ProblemSetRow[], userId?: number, incl problemsetRoutes.get("/problem-sets", optionalAuth, 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 filters = [eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft")] + const filters = [eq(schema.problemset.visible, true)] const keyword = c.req.query("keyword")?.trim() - const difficulty = c.req.query("difficulty")?.trim() - const status = c.req.query("status")?.trim() if (keyword) filters.push( or( @@ -165,13 +210,11 @@ problemsetRoutes.get("/problem-sets", optionalAuth, async (c) => { ilike(schema.problemset.description, `%${keyword}%`), )!, ) - if (difficulty) filters.push(eq(schema.problemset.difficulty, asFilterValue(difficulty))) - if (status) filters.push(eq(schema.problemset.status, asFilterValue(status))) const where = and(...filters) const [totalRows, rows] = await Promise.all([ db.select({ value: count() }).from(schema.problemset).where(where), db - .select() + .select({ set: schema.problemset, assigning: assigningSql }) .from(schema.problemset) .where(where) .orderBy(desc(schema.problemset.createTime)) @@ -179,49 +222,45 @@ problemsetRoutes.get("/problem-sets", optionalAuth, async (c) => { .offset(offset), ]) return success(c, { - results: await serializeProblemSets(rows, c.get("user")?.id, true), + results: await serializeProblemSets(rows, c.get("user")?.id), total: totalRows[0]?.value ?? 0, } satisfies ProblemSetList) }) +// 字面量路由要排在 /problem-sets/:id 前面:Hono 按注册顺序匹配,不是静态优先 +// 题号(problemDisplayId)和主键(problemId)都认:提交列表那边手里只有题号 +problemsetRoutes.get("/problem-sets/locks", requireAuth, async (c) => { + const user = c.get("user")! + if (isTeacherOrAbove(user)) return success(c, [] as ProblemSetLock[]) + let problemId = queryInteger(c.req.query("problemId"), 0, { min: 1 }) + const displayId = c.req.query("problemDisplayId")?.trim() + if (!problemId && displayId) { + const [problem] = await db + .select({ id: schema.problem.id }) + .from(schema.problem) + .where(and(eq(schema.problem.displayId, displayId), isNull(schema.problem.contestId))) + .limit(1) + problemId = problem?.id ?? 0 + } + if (!problemId) return success(c, [] as ProblemSetLock[]) + const rows = await problemSetLocksFor(user.id, problemId) + return success(c, rows satisfies ProblemSetLock[]) +}) + problemsetRoutes.get("/problem-sets/:id", optionalAuth, async (c) => { - const id = queryInteger(c.req.param("id"), 0, { min: 1 }) - const [row] = await db - .select() - .from(schema.problemset) - .where( - and( - eq(schema.problemset.id, id), - eq(schema.problemset.visible, true), - ne(schema.problemset.status, "draft"), - ), - ) - .limit(1) + const row = await loadVisible(queryInteger(c.req.param("id"), 0, { min: 1 }), c.get("user")) if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在") const [data] = await serializeProblemSets([row], c.get("user")?.id) return success(c, data) }) problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => { - const id = queryInteger(c.req.param("id"), 0, { min: 1 }) - const [problemSet] = await db - .select({ id: schema.problemset.id }) - .from(schema.problemset) - .where( - and( - eq(schema.problemset.id, id), - eq(schema.problemset.visible, true), - ne(schema.problemset.status, "draft"), - ), - ) - .limit(1) - if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在") - // 只取卡片要渲染的四列。取 schema.problem 整行会把题面、样例、答案、ast_rules、 - // flowchart_data、sql_display 一起拉回来,题单页一个都不用。 - // - // order 后面必须再跟一个 tiebreaker:并列时 Postgres 不保证次序,而卡片是按数组 - // 下标编号的(#1 #2 #3),题单 8 / 11 / 14 实际就存在 order 重复,不定死的话 - // 「第 3 题」指哪道题每次刷新都可能不一样。后台那条列表一直是这么排的。 + const user = c.get("user") + const found = await loadVisible(queryInteger(c.req.param("id"), 0, { min: 1 }), user) + if (!found) return failure(c, 404, "problem-set-not-found", "题单不存在") + const id = found.set.id + // order 后面必须再跟一个 tiebreaker:并列时 Postgres 不保证次序,而题目是按数组下标 + // 编号的(1、2、3),老题单里 order 大量重复,不定死的话「第 3 题」每次刷新都可能变 const rows = await db .select({ link: schema.problemsetProblem, @@ -234,76 +273,86 @@ problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => { .innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id)) .where(eq(schema.problemsetProblem.problemsetId, id)) .orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id)) - const progressRows = c.get("user") - ? await db - .select({ detail: schema.problemsetProgress.progressDetail }) - .from(schema.problemsetProgress) - .where( - and( - eq(schema.problemsetProgress.problemsetId, id), - eq(schema.problemsetProgress.userId, c.get("user")!.id), - ), - ) - .limit(1) - : [] - const completed = asRecord(progressRows[0]?.detail) + const problemIds = rows.map((row) => row.problemId) + const [progressRows, wrongRows, cutoffs] = user + ? await Promise.all([ + db + .select({ detail: schema.problemsetProgress.progressDetail }) + .from(schema.problemsetProgress) + .where( + and( + eq(schema.problemsetProgress.problemsetId, id), + eq(schema.problemsetProgress.userId, user.id), + ), + ) + .limit(1), + db + .select({ problemId: schema.submission.problemId, value: count() }) + .from(schema.submission) + .where( + and( + eq(schema.submission.userId, user.id), + eq(schema.submission.problemsetId, id), + notInArray(schema.submission.result, NON_FAILURE_RESULTS), + ), + ) + .groupBy(schema.submission.problemId), + isTeacherOrAbove(user) + ? new Map() + : problemSetLockCutoffs(user.id, problemIds), + ]) + : [[], [], new Map()] + // 「以前做对过 · 旧代码先藏着」:这道题正被闸门挡着,而且分界之前确实做对过 + const hiddenSolved = + user && cutoffs.size + ? await db + .select({ problemId: schema.submission.problemId }) + .from(schema.submission) + .where( + and( + eq(schema.submission.userId, user.id), + inArray(schema.submission.problemId, [...cutoffs.keys()]), + inArray(schema.submission.result, SOLVED_RESULTS), + or( + ...[...cutoffs].map( + ([problemId, cutoff]) => + sql`(${schema.submission.problemId} = ${problemId} and ${schema.submission.createTime} < ${cutoff})`, + ), + ), + ), + ) + .groupBy(schema.submission.problemId) + : [] + const hidden = new Set(hiddenSolved.map((row) => row.problemId)) + const solved = asRecord(progressRows[0]?.detail) + const wrong = new Map(wrongRows.map((row) => [row.problemId, row.value])) return success( c, - rows.map( - ({ link, problemId, displayId, title, difficulty }) => - ({ - id: link.id, - problemSetId: link.problemsetId, - problem: { id: problemId, _id: displayId, title, difficulty }, - order: link.order, - isRequired: link.isRequired, - score: link.score, - hint: link.hint, - isCompleted: String(problemId) in completed, - }) satisfies ProblemSetProblem, - ), + rows.map(({ link, problemId, displayId, title, difficulty }) => { + const entry = solved[String(problemId)] + const solvedTime = entry === undefined ? null : asRecord(entry).submit_time + return { + id: link.id, + problemSetId: link.problemsetId, + problem: { id: problemId, _id: displayId, title, difficulty }, + order: link.order, + isRequired: link.isRequired, + isCompleted: entry !== undefined, + solvedTime: typeof solvedTime === "string" ? solvedTime : null, + wrongCount: wrong.get(problemId) ?? 0, + oldCodeHidden: hidden.has(problemId), + } satisfies ProblemSetProblem + }), ) }) -async function recomputeProgress( - tx: Parameters[0]>[0], - progress: typeof schema.problemsetProgress.$inferSelect, - detail: Record, -) { - const links = await tx - .select({ - problemId: schema.problemsetProblem.problemId, - score: schema.problemsetProblem.score, - isRequired: schema.problemsetProblem.isRequired, - }) - .from(schema.problemsetProblem) - .where(eq(schema.problemsetProblem.problemsetId, progress.problemsetId)) - // 算法本身在 services/problemset.ts —— 后台改题目后的批量重算走的是同一份, - // 两边曾经各写一遍,结果后台那份少算了 total_score 和 is_completed - const update = computeProgress(detail, links, progress.completeTime) - await tx - .update(schema.problemsetProgress) - .set(update) - .where(eq(schema.problemsetProgress.id, progress.id)) - return { ...progress, ...update } -} - problemsetRoutes.post("/problem-set-progress", requireAuth, async (c) => { const parsed = await parseBody(c, joinProblemSetRequestSchema, "Invalid problem set") if (!parsed.success) return parsed.response const user = c.get("user")! - const [problemSet] = await db - .select({ id: schema.problemset.id }) - .from(schema.problemset) - .where( - and( - eq(schema.problemset.id, parsed.data.problemSetId), - eq(schema.problemset.visible, true), - ne(schema.problemset.status, "draft"), - ), - ) - .limit(1) - if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在") + const found = await loadVisible(parsed.data.problemSetId, user) + if (!found) return failure(c, 404, "problem-set-not-found", "题单不存在") + const problemSet = found.set const [existing] = await db .select({ id: schema.problemsetProgress.id }) .from(schema.problemsetProgress) @@ -316,22 +365,21 @@ problemsetRoutes.post("/problem-set-progress", requireAuth, async (c) => { .limit(1) if (existing) return failure(c, 409, "already-joined", "已经加入该题单") await db.transaction(async (tx) => { - const [created] = await tx - .insert(schema.problemsetProgress) - .values({ - problemsetId: problemSet.id, - userId: user.id, - joinTime: new Date().toISOString(), - completeTime: null, - isCompleted: false, - progressPercentage: 0, - completedProblemsCount: 0, - totalProblemsCount: 0, - totalScore: 0, - progressDetail: {}, + const links = await tx + .select({ + problemId: schema.problemsetProblem.problemId, + score: schema.problemsetProblem.score, + isRequired: schema.problemsetProblem.isRequired, }) - .returning() - if (created) await recomputeProgress(tx, created, {}) + .from(schema.problemsetProblem) + .where(eq(schema.problemsetProblem.problemsetId, problemSet.id)) + // 算法本身在 services/problemset.ts —— 后台改题目后的批量重算走的是同一份 + await tx.insert(schema.problemsetProgress).values({ + problemsetId: problemSet.id, + userId: user.id, + joinTime: new Date().toISOString(), + ...computeProgress({}, links, null), + }) }) return success(c, null, 201) }) @@ -372,137 +420,198 @@ problemsetRoutes.get("/users/:username/badges", optionalAuth, async (c) => { ) }) -problemsetRoutes.get("/problem-sets/:id/badges", async (c) => { - const id = queryInteger(c.req.param("id"), 0, { min: 1 }) - const [problemSet] = await db - .select({ id: schema.problemset.id }) - .from(schema.problemset) - .where( - and( - eq(schema.problemset.id, id), - eq(schema.problemset.visible, true), - ne(schema.problemset.status, "draft"), - ), - ) - .limit(1) - if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在") - const badges = await db - .select() - .from(schema.problemsetBadge) - .where(eq(schema.problemsetBadge.problemsetId, id)) - return success( - c, - badges.map((badge) => badgeData(badge)), - ) -}) - -problemsetRoutes.get("/problem-sets/:id/user-progress", requireTeacher, async (c) => { - const id = queryInteger(c.req.param("id"), 0, { min: 1 }) - const [problemSet] = await db - .select({ id: schema.problemset.id }) - .from(schema.problemset) - .where( - and( - eq(schema.problemset.id, id), - eq(schema.problemset.visible, true), - ne(schema.problemset.status, "draft"), - ), - ) - .limit(1) - // 所有老师都能看,不只是题单的创建者:系统里没有「班级归哪个老师」,课堂看板、数据统计 - // 也是任何老师看任何班,这里和它们一致。后台那条(admin/problemset.ts 的 loadOwned) - // 能改能删,仍然只给创建者 - if (!problemSet) { - return failure(c, 404, "problem-set-not-found", "题单不存在") - } - const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 }) - const offset = queryInteger(c.req.query("offset"), 0, { min: 0 }) - const className = c.req.query("className")?.trim() - const completion = c.req.query("completionStatus")?.trim() - const filters = [eq(schema.problemsetProgress.problemsetId, id)] - // 只填数字(251)当班级号补上 ks;班级按 classPrefixCondition 匹配, - // 否则 251 会把 2511 班也算进来。别的输入照旧按用户名包含 - if (className) { - const classPrefix = /^\d+$/.test(className) ? `ks${className}` : className - filters.push(classPrefixCondition(classPrefix) ?? ilike(schema.user.username, `%${className}%`)) - } - if (completion === "completed") filters.push(eq(schema.problemsetProgress.isCompleted, true)) - else if (completion === "in_progress") - filters.push( - and( - eq(schema.problemsetProgress.isCompleted, false), - gt(schema.problemsetProgress.completedProblemsCount, 0), - )!, - ) - else if (completion === "not_started") - filters.push(eq(schema.problemsetProgress.completedProblemsCount, 0)) - const where = and(...filters) - const [statsRows, rows, problemRows] = await Promise.all([ +/** + * 老师看一个班在这份题单里的情况:真名 × 每道题(几点做对 / 错几次 / 加入前就做对过)。 + * + * 所有老师都能看,不只是题单的创建者:系统里没有「班级归哪个老师」,课堂看板、数据统计 + * 也是任何老师看任何班,这里和它们一致。 + * + * 班级不用老师选:从加入过的学生里数出有哪些班,最近有人加入的排第一个、默认就看它, + * 想看别的班再换(和比赛「全班情况」按参赛者推断班级是一个思路)。名单是那个班的全部学生, + * 没加入的也列出来,好点名。 + */ +problemsetRoutes.get("/problem-sets/:id/class-view", requireTeacher, async (c) => { + const found = await loadVisible(queryInteger(c.req.param("id"), 0, { min: 1 }), c.get("user")) + if (!found) return failure(c, 404, "problem-set-not-found", "题单不存在") + const id = found.set.id + const studentRole = inArray(schema.user.adminType, [...STUDENT_ROLES]) + const [links, joinedClasses] = await Promise.all([ db .select({ - total: count(), - completed: sql`count(*) filter (where ${schema.problemsetProgress.isCompleted})::int`, - avgProgress: avg(schema.problemsetProgress.progressPercentage), - }) - .from(schema.problemsetProgress) - .innerJoin(schema.user, eq(schema.problemsetProgress.userId, schema.user.id)) - .where(where), - db - .select({ - progress: schema.problemsetProgress, - user: schema.user, - realName: schema.userProfile.realName, - }) - .from(schema.problemsetProgress) - .innerJoin(schema.user, eq(schema.problemsetProgress.userId, schema.user.id)) - .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) - .where(where) - .orderBy( - desc(schema.problemsetProgress.isCompleted), - desc(schema.problemsetProgress.progressPercentage), - asc(schema.problemsetProgress.joinTime), - ) - .limit(limit) - .offset(offset), - db - .select({ - id: schema.problem.id, + problemId: schema.problem.id, _id: schema.problem.displayId, title: schema.problem.title, + isRequired: schema.problemsetProblem.isRequired, }) .from(schema.problemsetProblem) .innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id)) .where(eq(schema.problemsetProblem.problemsetId, id)) .orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id)), - ]) - const problemMap = new Map(problemRows.map((problem) => [String(problem.id), problem])) - const results = rows.map( - ({ progress, user: progressUser, realName }) => - ({ - id: progress.id, - problemSetId: progress.problemsetId, - user: sampleUser(progressUser, realName), - joinTime: progress.joinTime, - completeTime: progress.completeTime, - isCompleted: progress.isCompleted, - progressPercentage: progress.progressPercentage, - completedProblemsCount: progress.completedProblemsCount, - totalProblemsCount: progress.totalProblemsCount, - totalScore: progress.totalScore, - completedProblems: Object.keys(asRecord(progress.progressDetail)).flatMap( - (key) => problemMap.get(key) ?? [], + db + .select({ + className: sql`${schema.user.className}`, + joined: count(), + last: sql`max(${schema.problemsetProgress.joinTime})`, + }) + .from(schema.problemsetProgress) + .innerJoin(schema.user, eq(schema.user.id, schema.problemsetProgress.userId)) + .where( + and( + eq(schema.problemsetProgress.problemsetId, id), + studentRole, + sql`coalesce(${schema.user.className}, '') <> ''`, ), - }) satisfies ProblemSetProgress, - ) - const stats = statsRows[0] + ) + .groupBy(schema.user.className) + .orderBy(sql`max(${schema.problemsetProgress.joinTime}) desc`), + ]) + const sizes = joinedClasses.length + ? await db + .select({ className: sql`${schema.user.className}`, value: count() }) + .from(schema.user) + .where( + and( + inArray( + schema.user.className, + joinedClasses.map((row) => row.className), + ), + studentRole, + eq(schema.user.isDisabled, false), + ), + ) + .groupBy(schema.user.className) + : [] + const sizeOf = new Map(sizes.map((row) => [row.className, row.value])) + // 只有零星几个人加入的班(别的班的学生自己点进来的)排到后面去:至少 5 个、三分之一的人加入了 + // 才算「这个班在做」,这样的班按最近一次有人加入排,默认看第一个 + const classes = joinedClasses + .map((row) => ({ + className: row.className, + joined: row.joined, + size: sizeOf.get(row.className) ?? row.joined, + })) + .map((row, recency) => ({ row, recency, doing: row.joined >= 5 && row.joined * 3 >= row.size })) + .sort((a, b) => + a.doing !== b.doing + ? Number(b.doing) - Number(a.doing) + : a.doing + ? a.recency - b.recency + : b.row.joined - a.row.joined, + ) + .map((item) => item.row) + const wanted = c.req.query("className")?.trim() + const className = wanted || classes[0]?.className || null + const graded = gradedIds(links) + const empty: ProblemSetClassView = { + classes, + className, + problems: links.map((link) => ({ + id: link.problemId, + _id: link._id, + title: link.title, + isRequired: link.isRequired, + solved: 0, + })), + totalCount: graded.size, + students: [], + } + if (!className || links.length === 0) return success(c, empty) + + const roster = await db + .select({ + userId: schema.user.id, + username: schema.user.username, + realName: schema.userProfile.realName, + progress: schema.problemsetProgress, + }) + .from(schema.user) + .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) + .leftJoin( + schema.problemsetProgress, + and( + eq(schema.problemsetProgress.userId, schema.user.id), + eq(schema.problemsetProgress.problemsetId, id), + ), + ) + .where( + and(eq(schema.user.className, className), studentRole, eq(schema.user.isDisabled, false)), + ) + .orderBy(asc(schema.user.username)) + if (roster.length === 0) return success(c, empty) + const userIds = roster.map((row) => row.userId) + const problemIds = links.map((link) => link.problemId) + const [wrongRows, beforeRows] = await Promise.all([ + db + .select({ + userId: schema.submission.userId, + problemId: schema.submission.problemId, + value: count(), + }) + .from(schema.submission) + .where( + and( + eq(schema.submission.problemsetId, id), + inArray(schema.submission.userId, userIds), + notInArray(schema.submission.result, NON_FAILURE_RESULTS), + ), + ) + .groupBy(schema.submission.userId, schema.submission.problemId), + // 加入之前就做对过(哪个入口都算):老师据此知道谁可能是凭记忆默写的 + db + .select({ userId: schema.submission.userId, problemId: schema.submission.problemId }) + .from(schema.submission) + .innerJoin( + schema.problemsetProgress, + and( + eq(schema.problemsetProgress.userId, schema.submission.userId), + eq(schema.problemsetProgress.problemsetId, id), + ), + ) + .where( + and( + inArray(schema.submission.userId, userIds), + inArray(schema.submission.problemId, problemIds), + inArray(schema.submission.result, SOLVED_RESULTS), + sql`${schema.submission.createTime} < ${schema.problemsetProgress.joinTime}`, + ), + ) + .groupBy(schema.submission.userId, schema.submission.problemId), + ]) + const key = (userId: number, problemId: number) => `${userId}:${problemId}` + const wrong = new Map(wrongRows.map((row) => [key(row.userId, row.problemId), row.value])) + const before = new Set(beforeRows.map((row) => key(row.userId, row.problemId))) + const solvedCount = new Map() + const students = roster.map((row) => { + const detail = asRecord(row.progress?.progressDetail) + let completed = 0 + const cells = links.map((link) => { + const entry = detail[String(link.problemId)] + const time = entry === undefined ? null : asRecord(entry).submit_time + if (entry !== undefined) { + solvedCount.set(link.problemId, (solvedCount.get(link.problemId) ?? 0) + 1) + if (graded.has(link.problemId)) completed += 1 + } + return { + solvedTime: typeof time === "string" ? time : null, + wrongCount: wrong.get(key(row.userId, link.problemId)) ?? 0, + solvedBefore: before.has(key(row.userId, link.problemId)), + } + }) + return { + userId: row.userId, + username: row.username, + realName: row.realName, + joinTime: row.progress?.joinTime ?? null, + completedCount: completed, + cells, + } + }) return success(c, { - results, - total: stats?.total ?? 0, - statistics: { - total: stats?.total ?? 0, - completed: stats?.completed ?? 0, - avgProgress: Number(stats?.avgProgress ?? 0), - }, - problems: problemRows, - } satisfies ProblemSetProgressList) + ...empty, + problems: empty.problems.map((problem) => ({ + ...problem, + solved: solvedCount.get(problem.id) ?? 0, + })), + students, + } satisfies ProblemSetClassView) }) diff --git a/apps/api/src/routes/submission.ts b/apps/api/src/routes/submission.ts index 526bd74..33bafd5 100644 --- a/apps/api/src/routes/submission.ts +++ b/apps/api/src/routes/submission.ts @@ -30,7 +30,7 @@ import { } from "../services/contest" import { CodeFormatError, formatCode } from "../services/format-code" import { getBooleanOption } from "../services/options" -import { problemSetJoinTimes } from "../services/problemset" +import { problemSetLockCutoffs } from "../services/problemset" import { consumeToken } from "../services/throttling" import { dayStart } from "../time" import { asFilterValue, asRecord, isAdminRole, isTeacherOrAbove, queryInteger } from "./helpers" @@ -289,14 +289,14 @@ function canViewSubmission( user: AuthUser | null, row: { userId: number; problemId: number; createTime: string }, problem: { createdById: number }, - problemSetJoinTime?: Map, + problemSetCutoffs?: Map, ) { if (!user) return false - // 题单防作弊,见 problemSetJoinTimes。只对学生自己的提交生效,老师不受限。 + // 题单防作弊,见 problemSetLockCutoffs。只对学生自己的提交生效,老师不受限。 // 学生管理员算学生,同样受限(旧后端 `is_regular_user()` 放过了他)。 if (row.userId === user.id && !isTeacherOrAbove(user)) { - const joinTime = problemSetJoinTime?.get(row.problemId) - if (joinTime !== undefined && Date.parse(row.createTime) < Date.parse(joinTime)) return false + const cutoff = problemSetCutoffs?.get(row.problemId) + if (cutoff !== undefined && Date.parse(row.createTime) < Date.parse(cutoff)) return false } // 看别人代码的角色捷径只给老师和超管。学生管理员算学生(STUDENT_ROLES):他参赛、上榜、 // 和同学做同一份作业,能读别人的提交就是开卷。旧后端这里是 `not user.is_regular_user()`, @@ -399,11 +399,11 @@ async function submissionDetail(id: string, user: AuthUser) { if (!row) return null // 详情也要过闸门。旧后端只挡了列表里的链接,`SubmissionAPI.get`(views/oj.py:103) // 光走 check_user_permission——知道 submission id 直接访问照样拿得到代码,遮挡是虚的。 - const joinTimes = + const cutoffs = isTeacherOrAbove(user) || row.submission.userId !== user.id ? undefined - : await problemSetJoinTimes(user.id, [row.submission.problemId]) - if (!canViewSubmission(user, row.submission, row.problem, joinTimes)) return null + : await problemSetLockCutoffs(user.id, [row.submission.problemId]) + if (!canViewSubmission(user, row.submission, row.problem, cutoffs)) return null // info(含每个测试点的 test_case 编号与 output_md5)只给管理员,对齐旧后端: // submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与 // SubmissionSafeModelSerializer 之间二选一,把关的是角色,不是「是不是自己的提交」。 @@ -569,9 +569,9 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => { displayId ? unknownDisplayIds(displayId, null) : [], ]) // 闸门只对学生自己的提交生效,所以只拿这一页里属于他自己的题目去查,一页一次查询 - const [joinTimes, problemsetTitles] = await Promise.all([ + const [cutoffs, problemsetTitles] = await Promise.all([ user && !isTeacherOrAbove(user) - ? problemSetJoinTimes(user.id, [ + ? problemSetLockCutoffs(user.id, [ ...new Set( rows .filter((row) => row.submission.userId === user.id) @@ -589,7 +589,7 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => { id: submission.id, problemDisplayId: problem.displayId, problemTitle: problem.title, - showLink: user ? canViewSubmission(user, submission, problem, joinTimes) : false, + showLink: user ? canViewSubmission(user, submission, problem, cutoffs) : false, createTime: submission.createTime, userId: submission.userId, username: submission.username, diff --git a/apps/api/src/services/problemset.ts b/apps/api/src/services/problemset.ts index 42abda2..e436461 100644 --- a/apps/api/src/services/problemset.ts +++ b/apps/api/src/services/problemset.ts @@ -1,4 +1,4 @@ -import { and, eq, gt, inArray, isNull, notInArray, or, sql } from "drizzle-orm" +import { and, asc, eq, inArray, notInArray, sql } from "drizzle-orm" import { db, schema } from "../db" import { publishAchievementNotification } from "../events" @@ -10,7 +10,7 @@ type ProgressRow = typeof schema.problemsetProgress.$inferSelect type ProblemLink = { problemId: number; score: number; isRequired: boolean } type BadgeCheck = Pick< ProgressRow, - "completedProblemsCount" | "totalProblemsCount" | "totalScore" | "progressDetail" + "completedProblemsCount" | "totalProblemsCount" | "progressDetail" > /** @@ -132,7 +132,7 @@ export function eligibleForBadge(badge: BadgeRow, progress: BadgeCheck) { if (badge.conditionType === "problem_count") { return Object.keys(asRecord(progress.progressDetail)).length >= badge.conditionValue } - if (badge.conditionType === "score") return progress.totalScore >= badge.conditionValue + // 「总分达到 N」随分数一起拿掉了,0026 把它们换成了「做对 N 道」 return false } @@ -242,8 +242,8 @@ export async function recordSolvedProblem( problemId: number, /** * 做出来的那次代码提交。流程图画到 A/S 算完成时传 null:problemset_submission.submission_id - * 外键指向代码提交表,流程图提交的 id 进不去。不影响「解锁加入题单之前的提交」—— - * 那道闸看的是 progress_detail(本文件下面的 problemSetJoinTimes) + * 外键指向代码提交表,流程图提交的 id 进不去。不影响防抄闸门的解锁 —— + * 那道闸看的是 progress_detail(本文件下面的 problemSetLockCutoffs) */ submissionId: string | null, solvedAt: string, @@ -390,50 +390,85 @@ export async function recordSolvedAndNotify( } } +/** 题单现在是不是在布置期内。按数据库的钟算(`now()`),和防抄闸门同一个口径 */ +export const assigningSql = sql`(${schema.problemset.assignedAt} is not null and ${schema.problemset.assignedUntil} > now())` + /** - * 题单防作弊闸门:查出这些题目里,哪些题的旧提交要对该用户藏起来,返回 problemId → 加入时间。 - * 代码提交(routes/submission.ts)和流程图提交(routes/flowchart.ts)共用这一道闸。 + * 题单防抄闸门:查出这些题目里,哪些题的旧提交要对该用户藏起来,返回 problemId → 分界时刻 + * (早于它的提交藏起来)。代码提交(routes/submission.ts)和流程图提交(routes/flowchart.ts) + * 共用这一道闸。 * - * 对齐旧后端 `submission/serializers.py:12` 的 `bulk_fetch_problemset_progress`。学生加入含 - * 某道题的题单后,他在加入之前留下的 AC 代码还摆在提交列表里,复制粘贴就能把题单刷完。 - * 备份快照里 1734 人次、188 名学生进过这个窗口(占已解题次的 22.5%),不是边角情况。 + * 规则(2026-10 题单重设计,用户拍板):题单**布置期内**,题单里这几道题,学生在**布置开始之前** + * 交的代码一律先藏起来 —— 不管他加没加入。老师常拿刚讲过的题攒复习题单:快照里 16% 的 + * 「学生 × 题」加入前就做对过,不藏的话把自己以前的答案贴进去就完了。 * - * 解锁的三条路全写在 where 里,任一成立就查不出来、也就不遮挡: - * - 已经在题单里做出这道题(progress_detail 里有这道题的 key) - * - 题单过了截止时间(end_time;为空表示不设期限,只能靠做出来解锁) - * - 题单被归档(status 不是 active) + * 以前是「加入之后藏加入之前的」,于是先去提交记录里把旧答案复制下来、再点加入,闸就白设了; + * 现在分界是布置开始,没加入的人也藏,这个口子就没了。代价是别的班以前做过这几道题的人, + * 在这一两周里也看不到自己的旧代码,用户认了。 * - * 一道题可能同时落在多个已加入的题单里,取最晚的 join_time——「存在任一题单要求遮挡就遮挡」 - * 等价于「提交时间早于最晚的那次加入」。旧后端这里用 `.first()` 取任意一条,一题多题单时 - * 行为不确定,换成聚合顺手定死。 + * 解锁的路全写在 where 里,任一成立就查不出来、也就不遮挡: + * - 题单不在布置期(没布置,或者布置期过了) + * - 题单没公开 + * - 已经在这份题单里做对了这道题(progress_detail 里有这道题的 key) + * + * 一道题可能同时落在几个正在布置的题单里,取最晚的布置开始 ——「任一题单要求遮挡就遮挡」。 */ -export async function problemSetJoinTimes(userId: number, problemIds: number[]) { - const joinTimes = new Map() - if (problemIds.length === 0) return joinTimes +export async function problemSetLockCutoffs(userId: number, problemIds: number[]) { + const cutoffs = new Map() + if (problemIds.length === 0) return cutoffs const rows = await db .select({ problemId: schema.problemsetProblem.problemId, // 聚合表达式不走列的类型映射,但 OID 还是 1184 —— db/index.ts 给这个 OID 挂了 - // 「转成 ISO 8601」的 parser,所以这里拿到的和 `mode:"string"` 的列同形状。 - // 原来那个 `::text` 要撤掉:它的 OID 是 25、绕过那个 parser,反而会变成 PG 文本。 - joinTime: sql`max(${schema.problemsetProgress.joinTime})`, + // 「转成 ISO 8601」的 parser,所以这里拿到的和 `mode:"string"` 的列同形状 + cutoff: sql`max(${schema.problemset.assignedAt})`, }) - .from(schema.problemsetProgress) - .innerJoin(schema.problemset, eq(schema.problemset.id, schema.problemsetProgress.problemsetId)) - .innerJoin( - schema.problemsetProblem, - eq(schema.problemsetProblem.problemsetId, schema.problemset.id), + .from(schema.problemsetProblem) + .innerJoin(schema.problemset, eq(schema.problemset.id, schema.problemsetProblem.problemsetId)) + .leftJoin( + schema.problemsetProgress, + and( + eq(schema.problemsetProgress.problemsetId, schema.problemset.id), + eq(schema.problemsetProgress.userId, userId), + ), ) .where( and( - eq(schema.problemsetProgress.userId, userId), inArray(schema.problemsetProblem.problemId, problemIds), - eq(schema.problemset.status, "active"), - or(isNull(schema.problemset.endTime), gt(schema.problemset.endTime, sql`now()`)), - sql`not jsonb_exists(${schema.problemsetProgress.progressDetail}, ${schema.problemsetProblem.problemId}::text)`, + eq(schema.problemset.visible, true), + assigningSql, + sql`not coalesce(jsonb_exists(${schema.problemsetProgress.progressDetail}, ${schema.problemsetProblem.problemId}::text), false)`, ), ) .groupBy(schema.problemsetProblem.problemId) - for (const row of rows) joinTimes.set(row.problemId, row.joinTime) - return joinTimes + for (const row of rows) cutoffs.set(row.problemId, row.cutoff) + return cutoffs +} + +/** 这道题的旧代码为什么藏着:它落在哪些正在布置、我还没在里面做对的题单里 */ +export async function problemSetLocksFor(userId: number, problemId: number) { + return db + .select({ + problemSetId: schema.problemset.id, + title: schema.problemset.title, + assignedUntil: sql`${schema.problemset.assignedUntil}`, + }) + .from(schema.problemsetProblem) + .innerJoin(schema.problemset, eq(schema.problemset.id, schema.problemsetProblem.problemsetId)) + .leftJoin( + schema.problemsetProgress, + and( + eq(schema.problemsetProgress.problemsetId, schema.problemset.id), + eq(schema.problemsetProgress.userId, userId), + ), + ) + .where( + and( + eq(schema.problemsetProblem.problemId, problemId), + eq(schema.problemset.visible, true), + assigningSql, + sql`not coalesce(jsonb_exists(${schema.problemsetProgress.progressDetail}, ${schema.problemsetProblem.problemId}::text), false)`, + ), + ) + .orderBy(asc(schema.problemset.assignedUntil)) } diff --git a/packages/contract/src/admin.ts b/packages/contract/src/admin.ts index a5ef449..854dd19 100644 --- a/packages/contract/src/admin.ts +++ b/packages/contract/src/admin.ts @@ -6,6 +6,7 @@ import { achievementRaritySchema } from "./achievement" import { rankProfileSchema } from "./account" import { paginatedSchema, sampleUserSchema } from "./common" import { reactionKeySchema } from "./content" +import { badgeConditionTypeSchema } from "./problemset" import { problemLanguageSchema, runnableLanguageSchema } from "./language" import { astRuleSchema, @@ -358,17 +359,22 @@ export const updateAcmHelperRequestSchema = z.object({ // ---------------------------------------------------------------- 题单管理 +// 难度、状态两列还留在表里(0026 没删列,删列要手工放行破坏性迁移), +// 但界面和接口都不再用:线上 16 个题单全是 Easy / active,「状态 + 可见」两道闸合成了「公开」 export const problemSetDifficultySchema = z.enum(["Easy", "Medium", "Hard"]) export const problemSetStatusSchema = z.enum(["draft", "active", "archived"]) -export const badgeConditionTypeSchema = z.enum(["all_problems", "problem_count", "score"]) export const adminProblemSetSchema = z.object({ id: z.number().int(), title: z.string(), description: z.string(), - difficulty: problemSetDifficultySchema, - status: problemSetStatusSchema, - endTime: z.string().nullable(), + /** 布置到哪天(东八区当天结束);null = 没布置 */ + assignedUntil: z.string().nullable(), + /** 这一轮布置开始的时刻 */ + assignedAt: z.string().nullable(), + /** 现在在不在布置期内(服务端的钟) */ + assigning: z.boolean(), + /** 公开:学生能在题单列表里看到、能进 */ visible: z.boolean(), createdBy: sampleUserSchema, createTime: z.string(), @@ -376,23 +382,22 @@ export const adminProblemSetSchema = z.object({ problemsCount: z.number().int(), /** 加入这份题单的人数,后台用来判断改动会影响多少人 */ participantCount: z.number().int(), + /** 做完(必做题全对)的人数 */ + completedCount: z.number().int(), + badgeCount: z.number().int(), }) export const adminProblemSetListSchema = paginatedSchema(adminProblemSetSchema) export const createProblemSetRequestSchema = z.object({ title: z.string().trim().min(1).max(200), - description: z.string(), - difficulty: problemSetDifficultySchema.default("Easy"), - status: problemSetStatusSchema.default("active"), - endTime: z.string().nullable().default(null), + description: z.string().default(""), + /** 布置到哪天;null = 不布置。从「没在布置」变成「布置到将来」时,服务端把布置开始记成现在 */ + assignedUntil: z.string().nullable().default(null), visible: z.boolean().default(true), }) export const updateProblemSetRequestSchema = createProblemSetRequestSchema -export const updateProblemSetStatusRequestSchema = z.object({ - status: problemSetStatusSchema, -}) export const adminProblemSetProblemSchema = z.object({ id: z.number().int(), @@ -403,24 +408,28 @@ export const adminProblemSetProblemSchema = z.object({ difficulty: z.string(), order: z.number().int(), isRequired: z.boolean(), - score: z.number().int(), - hint: z.string().nullable(), }) -export const addProblemToSetRequestSchema = z.object({ - /** 展示用题号(_id),不是自增主键 —— 老师手里只有题号 */ - problemId: z.string().trim().min(1), - order: z.number().int().default(0), - isRequired: z.boolean().default(true), - score: z.number().int().default(0), - hint: z.string().default(""), +export const addProblemsToSetRequestSchema = z.object({ + /** 展示用题号(_id),不是自增主键 —— 老师手里只有题号。按给的顺序接在最后 */ + problemIds: z.array(z.string().trim().min(1)).min(1).max(100), +}) + +export const addProblemsToSetResultSchema = z.object({ + added: z.array(z.string()), + /** 题号不存在、不可见或是比赛题 */ + missing: z.array(z.string()), + /** 本来就在题单里 */ + duplicate: z.array(z.string()), }) export const updateProblemInSetRequestSchema = z.object({ - order: z.number().int().optional(), - isRequired: z.boolean().optional(), - score: z.number().int().optional(), - hint: z.string().optional(), + isRequired: z.boolean(), +}) + +/** 拖动排序:题单里题目的链接 id,按新顺序给全 */ +export const reorderProblemSetProblemsRequestSchema = z.object({ + ids: z.array(z.number().int()).min(1), }) export const adminProblemSetBadgeSchema = z.object({ @@ -438,27 +447,13 @@ export const adminProblemSetBadgeSchema = z.object({ export const createProblemSetBadgeRequestSchema = z.object({ name: z.string().trim().min(1).max(100), description: z.string(), - icon: z.string(), + icon: z.string().min(1), conditionType: badgeConditionTypeSchema, conditionValue: z.number().int().default(0), }) export const updateProblemSetBadgeRequestSchema = createProblemSetBadgeRequestSchema -export const adminProblemSetProgressSchema = z.object({ - id: z.number().int(), - userId: z.number().int(), - username: z.string(), - realName: z.string().nullable(), - joinTime: z.string(), - completeTime: z.string().nullable(), - isCompleted: z.boolean(), - progressPercentage: z.number(), - completedProblemsCount: z.number().int(), - totalProblemsCount: z.number().int(), - totalScore: z.number().int(), -}) - // ---------------------------------------------------------------- 标签与题目分析 export const adminTagSchema = z.object({ @@ -927,7 +922,6 @@ export type AstCheckRequest = z.infer export type AstCheckResponse = z.infer export type GenerateTestInputsRequest = z.infer export type GenerateTestInputsResponse = z.infer -export type AdminProblemSetProgress = z.infer export type AdminAnnouncementList = z.infer export type CreateAnnouncementRequest = z.infer @@ -959,14 +953,16 @@ export type UpdateContestRequest = z.infer export type UpdateAcmHelperRequest = z.infer export type ProblemSetDifficulty = z.infer export type ProblemSetStatus = z.infer -export type BadgeConditionType = z.infer export type AdminProblemSet = z.infer export type AdminProblemSetList = z.infer export type CreateProblemSetRequest = z.infer export type UpdateProblemSetRequest = z.infer -export type UpdateProblemSetStatusRequest = z.infer export type AdminProblemSetProblem = z.infer -export type AddProblemToSetRequest = z.infer +export type AddProblemsToSetRequest = z.infer +export type AddProblemsToSetResult = z.infer +export type ReorderProblemSetProblemsRequest = z.infer< + typeof reorderProblemSetProblemsRequestSchema +> export type UpdateProblemInSetRequest = z.infer export type AdminProblemSetBadge = z.infer export type CreateProblemSetBadgeRequest = z.infer diff --git a/packages/contract/src/problemset.ts b/packages/contract/src/problemset.ts index b17cca3..bc2cba0 100644 --- a/packages/contract/src/problemset.ts +++ b/packages/contract/src/problemset.ts @@ -3,23 +3,38 @@ import { z } from "zod" import { paginatedSchema, sampleUserSchema } from "./common" import { maskedProblemDifficultySchema } from "./problem" +/** + * 我在这份题单里的进度。 + * + * 两个计数口径不同,别混用: + * - completedCount / totalCount 只数**必做题**,进度条和「做完了」看它; + * - solvedCount 数做对的**全部**题(含选做),「做对 N 道」奖章看它 —— + * 和后端 eligibleForBadge 的 problem_count 同一个口径。 + */ export const problemSetUserProgressSummarySchema = z.object({ isJoined: z.boolean(), - progressPercentage: z.number(), + joinTime: z.string().nullable(), completedCount: z.number().int(), totalCount: z.number().int(), + solvedCount: z.number().int(), isCompleted: z.boolean(), + completeTime: z.string().nullable(), }) +/** 奖章只有两种条件:全部做完、做对 N 道(「总分达到 N」随分数一起拿掉了,0026 换算过) */ +export const badgeConditionTypeSchema = z.enum(["all_problems", "problem_count"]) + export const problemSetBadgeSchema = z.object({ id: z.number().int(), problemSetId: z.number().int(), name: z.string(), description: z.string(), icon: z.string(), - conditionType: z.enum(["all_problems", "problem_count", "score"]), + conditionType: badgeConditionTypeSchema, conditionValue: z.number().int(), isEarned: z.boolean().optional(), + /** 我拿到它的时刻;没拿到、没登录为 null */ + earnedTime: z.string().nullable().optional(), }) export const problemSetSchema = z.object({ @@ -28,26 +43,26 @@ export const problemSetSchema = z.object({ description: z.string(), createdBy: sampleUserSchema, createTime: z.string(), - lastUpdateTime: z.string().optional(), - difficulty: z.enum(["Easy", "Medium", "Hard"]), - status: z.enum(["active", "archived", "draft"]), - endTime: z.string().nullable(), - visible: z.boolean(), + /** 布置到哪天(东八区当天结束的时刻);null = 没布置 */ + assignedUntil: z.string().nullable(), + /** 现在是不是在布置期内。服务端按自己的钟算好,前端不用拿本机时间去比 */ + assigning: z.boolean(), + /** 全部题数(含选做) */ problemsCount: z.number().int(), - completedCount: z.number().int().optional(), + /** 必做题数(一道必做都没标时等于全部题数)——「做完」的分母,没加入时也要用 */ + requiredCount: z.number().int(), + /** 加入过的人数 */ + joinedCount: z.number().int(), userProgress: problemSetUserProgressSummarySchema, - badges: z.array(problemSetBadgeSchema).optional(), + badges: z.array(problemSetBadgeSchema), }) export const problemSetListSchema = paginatedSchema(problemSetSchema) /** - * 题单页的题目卡片只渲染题号、标题、难度,所以只下发这三样(外加题目主键)。 - * - * 以前这里复用 problemListItemSchema,代价是服务端每次都要多 join 一次 user + - * user_profile 凑 createdBy、再多查一次标签表凑 tags,而 tags / submissionNumber / - * acceptedNumber / createdBy / flowchart / hasAstRules / myStatus 在题单页一个都不渲染 - * —— myStatus 甚至是写死的 null。 + * 题单页的题目行只渲染题号、标题,所以只下发这几样(外加题目主键)。 + * 以前复用 problemListItemSchema,服务端每次要多 join 一次 user / 标签表, + * 而那些字段题单页一个都不渲染。 */ export const problemSetProblemItemSchema = z.object({ id: z.number().int(), @@ -62,42 +77,72 @@ export const problemSetProblemSchema = z.object({ problem: problemSetProblemItemSchema, order: z.number().int(), isRequired: z.boolean(), - score: z.number().int(), - hint: z.string().nullable(), + /** 在这份题单里做对了没有(代码通过,或流程图拿到 A / S) */ isCompleted: z.boolean(), + /** 在这份题单里做对的时刻 */ + solvedTime: z.string().nullable(), + /** 在这份题单里交错了几次(还没做对时显示「交过 n 次」) */ + wrongCount: z.number().int(), + /** + * 布置期内、以前做对过、旧代码正被藏着 —— 行上标「以前做对过 · 旧代码先藏着」。 + * 规则见后端 services/problemset.ts 的 problemSetLockCutoffs + */ + oldCodeHidden: z.boolean(), }) export const joinProblemSetRequestSchema = z.object({ problemSetId: z.number().int().positive(), }) -export const completedProblemSchema = z.object({ - id: z.number().int(), - _id: z.string(), - title: z.string(), +/** 老师看某个班在这份题单里的情况:真名 × 每道题 */ +export const problemSetClassCellSchema = z.object({ + /** 在题单里做对的时刻 */ + solvedTime: z.string().nullable(), + /** 在题单里交错的次数 */ + wrongCount: z.number().int(), + /** 加入之前就做对过这道题(任何入口) */ + solvedBefore: z.boolean(), }) -export const problemSetProgressSchema = z.object({ - id: z.number().int(), +export const problemSetClassStudentSchema = z.object({ + userId: z.number().int(), + username: z.string(), + realName: z.string().nullable(), + /** 没加入为 null */ + joinTime: z.string().nullable(), + /** 做对的必做题数 */ + completedCount: z.number().int(), + cells: z.array(problemSetClassCellSchema), +}) + +export const problemSetClassViewSchema = z.object({ + /** 加入过这份题单的学生分属哪些班:三分之一以上的人加入了的班在前(按最近一次加入),零星几个人的在后 */ + classes: z.array( + z.object({ className: z.string(), joined: z.number().int(), size: z.number().int() }), + ), + /** 这次看的是哪个班;谁都没加入时为 null */ + className: z.string().nullable(), + problems: z.array( + z.object({ + id: z.number().int(), + _id: z.string(), + title: z.string(), + isRequired: z.boolean(), + /** 这个班在题单里做对的人数 */ + solved: z.number().int(), + }), + ), + /** 必做题数(全是选做时等于全部题数),「做完」的分母 */ + totalCount: z.number().int(), + /** 这个班的全部学生(按学号),没加入的 joinTime 为 null */ + students: z.array(problemSetClassStudentSchema), +}) + +/** 某道题为什么藏着旧代码:落在哪些正在布置的题单里 */ +export const problemSetLockSchema = z.object({ problemSetId: z.number().int(), - user: sampleUserSchema, - joinTime: z.string(), - completeTime: z.string().nullable(), - isCompleted: z.boolean(), - progressPercentage: z.number(), - completedProblemsCount: z.number().int(), - totalProblemsCount: z.number().int(), - totalScore: z.number().int(), - completedProblems: z.array(completedProblemSchema), -}) - -export const problemSetProgressListSchema = paginatedSchema(problemSetProgressSchema).extend({ - statistics: z.object({ - total: z.number().int(), - completed: z.number().int(), - avgProgress: z.number(), - }), - problems: z.array(completedProblemSchema), + title: z.string(), + assignedUntil: z.string(), }) export const userBadgeSchema = z.object({ @@ -112,10 +157,11 @@ export type ProblemSet = z.infer export type ProblemSetList = z.infer export type ProblemSetBadge = z.infer export type ProblemSetProblem = z.infer -export type ProblemSetProgress = z.infer -export type ProblemSetProgressList = z.infer +export type ProblemSetClassView = z.infer +export type ProblemSetClassStudent = z.infer +export type ProblemSetLock = z.infer export type UserBadge = z.infer -export type CompletedProblem = z.infer +export type BadgeConditionType = z.infer export type ProblemSetUserProgressSummary = z.infer export type JoinProblemSetRequest = z.infer