Skip to content

feat(tasks): split AOJ client by source and improve task import UI (#3525)#3561

Open
KATO-Hiro wants to merge 23 commits into
stagingfrom
#3525
Open

feat(tasks): split AOJ client by source and improve task import UI (#3525)#3561
KATO-Hiro wants to merge 23 commits into
stagingfrom
#3525

Conversation

@KATO-Hiro
Copy link
Copy Markdown
Collaborator

@KATO-Hiro KATO-Hiro commented May 18, 2026

close #3525

Summary by CodeRabbit

リリースノート

  • 新機能

    • タスクインポート画面をリニューアル。複数の取得元(AtCoder、AOJ コース、AOJ チャレンジ)から選択してインポート可能に。
    • フォーム送信方式を改善し、インポート処理をより効率的に。
  • バグ修正

    • コンテスト名・タスク名の末尾の余分なスペースを自動削除。
  • その他

    • テストデータを更新。
    • 内部ロジックを整理・最適化。

KATO-Hiro and others added 23 commits May 17, 2026 08:49
- Split AojApiClient into AojCoursesApiClient and AojChallengesApiClient
- Reorganize AOJ fixtures into source-specific directories (courses/challenges)
- Add ContestTaskFetcher to unify per-source fetch logic
- Replace TaskListForEdit with TaskSearchBox and TaskTableForImport components
- Update task import page to support source selection and pagination

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n vite config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ementation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… flow

- Add trim() to contest/task titles from AtCoder and AOJ API clients
- Return fail() with proper HTTP status in import and update actions
- Log caught errors instead of silently swallowing them
- Surface import failure to user via error message in TaskTableForImport

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Delete task-import-by-source review.md (findings addressed in prior commit)
- Expand inline object literals in atcoder_problems.test.ts for readability

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 18, 2026

📝 Walkthrough

Walkthrough

AOJクライアント実装を基底クラス中心の設計から、汎用キャッシュ取得ユーティリティベースの設計に移行。クライアント取得元をContestTaskImportSourceで分岐可能にし、管理者向けタスクインポートUIを取得元選択・フェッチオンデマンド・検索フィルタリング対応に刷新。

Changes

AOJ Client Refactoring & Import Flow

Layer / File(s) Summary
Generic Fetcher Utility & AOJ Client Implementation
src/lib/clients/contest_task_fetcher.ts, src/lib/clients/aizu_online_judge/clients.ts
getCachedOrFetchContests/getCachedOrFetchTasksユーティリティを導入、AOJ両クライアントがHttpRequestClientContestTaskCacheをコンストラクタで明示化。基底クラスAojTasksApiClientBase削除、コース課題のフィルタと変換、チャレンジのcontests.days走査ロジックをインライン化。
AOJ Client HTTP Mock Tests
src/lib/clients/aizu_online_judge/clients.test.ts
nockによるエンドポイント統合テスト。コースGET /courses/GET /problems?size=10000、チャレンジGET /challenges/cl/{CONTEST}/{ROUND}の各ラウンドをスタブし、件数一致・必須フィールド・ID形式検証。
Title Whitespace Normalization
src/lib/clients/aizu_online_judge/utils.ts, src/lib/clients/atcoder/atcoder_problems.ts, src/lib/clients/aizu_online_judge/utils.test.ts, src/lib/clients/atcoder/atcoder_problems.test.ts
AOJとAtCoderでtitle.trim()を追加、末尾空白を除去。対応テスト追加。
Client Index Source-Based Selection
src/lib/clients/index.ts
ContestTaskImportSource型導入、getContests/getTasksfetchContests(source)/fetchTasks(source)に移行。importSourcesマップで各ソースのクライアント設定を集約、buildAojChallengeConfigChallengeParamsから設定生成。
Admin Task Import Server Actions
src/routes/(admin)/tasks/+page.server.ts
loadをアクセス検証のみに。actions.fetchsourceバリデーション、外部取得とDB既存タスク取得を並列実行し未登録importContestsを返却。createtasks JSON解析・SHA256キー生成・作成実行。
Admin Task Import UI & Components
src/routes/(admin)/tasks/+page.svelte, src/routes/(admin)/tasks/_components/TaskSearchBox.svelte, src/routes/(admin)/tasks/_components/TaskTableForImport.svelte
取得元選択フォーム・検索フィルタ・ページング対応に刷新。TaskSearchBoxTaskTableForImportを新規追加、取得状態分岐表示・検索リセット・ページネーション実装。
Task Detail Page & Import Utility Functions
src/routes/(admin)/tasks/[task_id]/+page.server.ts, src/routes/(admin)/tasks/_utils/contests.ts, src/routes/(admin)/tasks/_utils/contests.test.ts
actions.updatetask_grade更新。filterContestsid/title/task.title部分一致フィルタリング(大文字小文字無視)実装。対応テスト追加。
Test Fixtures & Record Requests Tool
src/lib/clients/fixtures/aizu_online_judge/courses/{contests,tasks}.json, src/lib/clients/fixtures/atcoder_problems/{contests,tasks}.json, src/lib/clients/fixtures/record_requests.ts
AOJ フィクスチャをcourses/配下に分割、AtCoder フィクスチャを大規模更新。record_requests.tsを個別サイト向け保存関数へ切り替え。
Minor Updates & Configuration
.claude/rules/coding-style.md, src/lib/components/TaskForm.svelte, vite.config.ts
コーディング規約追記、フォームaction相対化、ルートテスト追加。

Sequence Diagram(s)

以下の条件を確認:

  1. 新機能またはキーフローの追加(import source選択フロー) → ✓
  2. 3つ以上の独立したコンポーネント間の相互作用 → ✓
  3. 順序立ったオペレーション → ✓
sequenceDiagram
  participant Admin as Admin User
  participant Page as +page.svelte
  participant Server as +page.server.ts
  participant Fetcher as fetchContests/Tasks
  participant HttpClient as HttpRequestClient
  participant DB as Database
  Admin->>Page: source選択・Fetch実行
  Page->>Server: ?/fetch (source)
  Server->>Fetcher: fetchContests(source)
  Server->>HttpClient: API取得
  HttpClient->>Fetcher: レスポンス
  Server->>DB: getTasks()
  DB->>Server: 既存タスク一覧
  Fetcher->>Server: 外部コンテスト/タスク
  Server->>Page: importContests (未登録含む)
  Page->>Page: 検索フィルタ・ページング表示
  Admin->>Page: タスク選択・インポート
  Page->>Server: ?/create (tasks JSON)
  Server->>DB: createTask (各タスク)
  DB->>Page: success
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

AOJ クライアントも新しく衣替え 🎌
ベースクラス脱ぎ捨て 関数ベースへ
取得元分岐で柔軟性 UP ⬆️
管理画面も検索・フィルタ装備
空白もトリムして さあ次へ 🚀

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 17.39% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PRタイトルは「feat(tasks): split AOJ client by source and improve task import UI (#3525)」で、主要な変更(AOJクライアントの分割とタスクインポートUI改善)を明確に表現しており、これは実際の変更内容と一致しています。
Linked Issues check ✅ Passed PR内の全ての変更が #3525 の要件を満たしています。AojApiClientの分割、AojTasksApiClientBaseの設計見直し、コンテスト種別のenum化が実装され、fetchAllDataパターンの改善も含まれています。
Out of Scope Changes check ✅ Passed 全ての変更が #3525 の目的に関連しています。APIクライアント再構築、フィクスチャの再編成、UIコンポーネント追加、テスト・ドキュメント更新は全て対象要件内です。

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch #3525

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/clients/fixtures/record_requests.ts`:
- Around line 53-60: The fixtures are sampled non-deterministically and can
produce tasks that reference contests not present in the sampled contests;
change the sampling to deterministic (e.g., use a seeded RNG or sort+slice) and
ensure referential integrity by filtering the sampled tasks to only include
tasks whose contest_id exists in the sampled contests before writing; update the
calls around getRandomElementsFromArray(contests, 100) and
getRandomElementsFromArray(tasks, 100) (and the other similar occurrences noted)
so you first deterministically select contests into a variable (e.g.,
sampledContests), then deterministically select or sort tasks and filter them
with sampledContests' IDs (e.g., sampledTasks = tasks.filter(t =>
sampledContestsIds.has(t.contest_id)) then slice to desired count) and pass
those filtered arrays to toJson so the output is reproducible and consistent.

In `@src/routes/`(admin)/tasks/_utils/contests.ts:
- Around line 4-6: Treat strings that are only whitespace as empty: change the
check and normalization to use trim(). Replace the current hasNoQuery and
lowerQuery logic so hasNoQuery becomes "!query || query.trim() === ''" and
lowerQuery is computed from "query.trim().toLowerCase()" (ensuring you guard for
undefined/null query before calling trim()). Update the assignments where
"hasNoQuery" and "lowerQuery" are defined (the current lines with hasNoQuery and
lowerQuery) accordingly.

In `@src/routes/`(admin)/tasks/[task_id]/+page.server.ts:
- Around line 54-80: Replace the manual formData parsing in the actions.update
handler with Superforms + Zod: create a Zod schema (e.g., TaskUpdateSchema) that
validates task_id as a nonempty string and task_grade as an optional
enum/nullable matching your TaskGrade values (or a string refined to TaskGrade),
then call superValidate(request, TaskUpdateSchema) inside update and branch on
validation result instead of ad-hoc checks; extract validated values from
result.data, map/convert task_grade to your TaskGrade type (or let Zod produce
it) and call taskService.updateTask(task_id, task_grade), and on validation
failure return the Superforms-consistent error response so errors from
actions.update follow the same Superforms + Zod pattern used elsewhere (leave
validateAdminAccess and taskService.updateTask calls intact).
- Around line 55-73: The update handler currently trusts the form field task_id
(taskId) allowing tampering; change it to use the route parameter instead: read
the task id from the handler's params (use params.task_id) and remove/ignore the
form-derived task_id, then pass that params-derived id into
taskService.updateTask; keep existing validation for task_grade (getTaskGrade)
and the bad-request/fail branches but ensure any checks reference params.task_id
(and return fail if it's missing) rather than the form value.

In `@src/routes/`(admin)/tasks/+page.server.ts:
- Around line 70-96: The handler currently trusts the client-provided tasks JSON
(tasksJson → tasks) and uses client-supplied fields (task.title,
task.contest_id, task.source) when calling taskService.createTask; instead,
validate/trust only an identifier from the client (e.g., task.id or task.source)
and re-fetch the canonical task data on the server (via your service/DB) before
creating records. Change the import loop that maps over TaskForImport so it
discards client title/contest_id and instead: lookup the authoritative task
payload using the submitted task.id/source, derive the server-side values
(server_contest_id, server_title, server_problem_index), compute the id with
sha256(contest_id + server_title) and call taskService.createTask with those
server-validated fields; ensure any missing or invalid lookups return
BAD_REQUEST or a clear failure path.
- Around line 21-104: The handlers fetch and create currently parse and validate
request.formData() manually; replace them with Superforms + Zod: define Zod
schemas (e.g. ContestImportQuerySchema with source for fetch, and
ContestImportCreateSchema with source:string, contest_id:string, tasks:string
(or structured TasksForImport) for create), use superValidate(request, schema)
in both handlers and return the validated form (or validation errors) so
Superforms handles client errors; keep the admin check
(validateAdminAccess(locals)), then in fetch use data.source from the validated
form to call fetchContests/fetchTasks and the existing
prepareTaskMap/filterUnregisteredTasks/mergeContestsAndUnregisteredTasks flow,
and in create use data.contest_id and the parsed tasks (parse JSON after
validation or use a Zod preprocessor to parse tasks into TasksForImport) and
then run the existing sha256 + taskService.createTask loop; ensure all
validation failures return the superform validation result instead of manual
fail responses and preserve try/catch for server errors with the same
INTERNAL_SERVER_ERROR handling.
- Around line 85-94: The current ID generation uses sha256(contest_id +
task.title) which risks collisions when multiple tasks share the same title;
change the seed to use the original external identifier by hashing contest_id +
task.id instead. Locate the tasks.map block where TaskForImport is processed,
replace usage of task.title in the sha256 call with task.id so the generated id
passed into taskService.createTask is derived from contest_id + task.id.

In `@src/routes/`(admin)/tasks/+page.svelte:
- Around line 94-97: 選択している取得元を切り替えたときに古い結果が残って誤インポートを誘発しているので、onchange ハンドラ内で
currentPage = 1 に加えて importContests を空配列にリセットし、インポート失敗の表示を消すためのエラー状態(例:
importError または importErrors を使っている箇所)を null/空にクリアしてください。該当箇所は +page.svelte の
onchange コールバックなので、そのブロックに importContests = [] と importError = null
を追加して一覧とエラー状態を同時に初期化してください。
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e1ec287c-2d74-4277-a94d-231211adee9d

📥 Commits

Reviewing files that changed from the base of the PR and between 848183c and 06b7f09.

📒 Files selected for processing (30)
  • .claude/rules/coding-style.md
  • src/lib/clients/aizu_online_judge/clients.test.ts
  • src/lib/clients/aizu_online_judge/clients.ts
  • src/lib/clients/aizu_online_judge/utils.test.ts
  • src/lib/clients/aizu_online_judge/utils.ts
  • src/lib/clients/atcoder/atcoder_problems.test.ts
  • src/lib/clients/atcoder/atcoder_problems.ts
  • src/lib/clients/contest_task_fetcher.ts
  • src/lib/clients/fixtures/aizu_online_judge/challenges/jag_prelim/contests.json
  • src/lib/clients/fixtures/aizu_online_judge/challenges/jag_regional/contests.json
  • src/lib/clients/fixtures/aizu_online_judge/challenges/pck_final/contests.json
  • src/lib/clients/fixtures/aizu_online_judge/challenges/pck_prelim/contests.json
  • src/lib/clients/fixtures/aizu_online_judge/contests.json
  • src/lib/clients/fixtures/aizu_online_judge/courses/contests.json
  • src/lib/clients/fixtures/aizu_online_judge/courses/tasks.json
  • src/lib/clients/fixtures/aizu_online_judge/tasks.json
  • src/lib/clients/fixtures/atcoder_problems/contests.json
  • src/lib/clients/fixtures/atcoder_problems/tasks.json
  • src/lib/clients/fixtures/record_requests.ts
  • src/lib/clients/index.ts
  • src/lib/components/TaskForm.svelte
  • src/lib/components/TaskListForEdit.svelte
  • src/routes/(admin)/tasks/+page.server.ts
  • src/routes/(admin)/tasks/+page.svelte
  • src/routes/(admin)/tasks/[task_id]/+page.server.ts
  • src/routes/(admin)/tasks/_components/TaskSearchBox.svelte
  • src/routes/(admin)/tasks/_components/TaskTableForImport.svelte
  • src/routes/(admin)/tasks/_utils/contests.test.ts
  • src/routes/(admin)/tasks/_utils/contests.ts
  • vite.config.ts
💤 Files with no reviewable changes (3)
  • src/lib/clients/fixtures/aizu_online_judge/tasks.json
  • src/lib/components/TaskListForEdit.svelte
  • src/lib/clients/fixtures/aizu_online_judge/contests.json

Comment on lines +53 to +60
await toJson(
path.join(TEST_DATA_BASE_DIR, 'atcoder_problems', 'contests.json'),
getRandomElementsFromArray(contests, 100),
);
await toJson(
path.join(TEST_DATA_BASE_DIR, 'atcoder_problems', 'tasks.json'),
getRandomElementsFromArray(tasks, 100),
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

fixtureサンプリングが非決定かつ参照不整合を作り得ます

現状だと実行ごとに内容が変わり、さらに atcoder_problems/tasks.json に「存在しない contest を参照する task」が混入し得ます。テスト再現性とデータ整合性に影響が大きいです。

修正案(決定的サンプリング + contest/task の整合確保)
 async function saveAtCoder(): Promise<void> {
   const contests = await atCoderClient.getContests();
   const tasks = await atCoderClient.getTasks();
+  const sampledContests = getDeterministicElementsFromArray(contests, 100);
+  const contestIds = new Set(sampledContests.map((contest) => contest.id));
+  const linkedTasks = tasks.filter((task) => contestIds.has(task.contest_id));
+  const sampledTasks = getDeterministicElementsFromArray(linkedTasks, 100);

   await toJson(
     path.join(TEST_DATA_BASE_DIR, 'atcoder_problems', 'contests.json'),
-    getRandomElementsFromArray(contests, 100),
+    sampledContests,
   );
   await toJson(
     path.join(TEST_DATA_BASE_DIR, 'atcoder_problems', 'tasks.json'),
-    getRandomElementsFromArray(tasks, 100),
+    sampledTasks,
   );
 }
@@
-  const sampled = getRandomElementsFromArray(raw, 100);
+  const sampled = getDeterministicElementsFromArray(raw, 100);
@@
-  const sampled = { ...raw, contests: getRandomElementsFromArray(raw.contests, 100) };
+  const sampled = {
+    ...raw,
+    contests: getDeterministicElementsFromArray(raw.contests, 100),
+  };
@@
-function getRandomElementsFromArray<T>(array: T[], count: number): T[] {
+function getDeterministicElementsFromArray<T>(array: T[], count: number): T[] {
   if (!Array.isArray(array)) {
     throw new Error('Input must be an array');
   }
   if (count < 0) {
     throw new Error('Count must be non-negative');
   }
-
-  count = Math.min(count, array.length);
-  const results = [];
-  const selectedIndices = new Set<number>();
-
-  while (results.length < count) {
-    const index = Math.floor(Math.random() * array.length);
-
-    if (selectedIndices.has(index)) {
-      continue;
-    }
-
-    selectedIndices.add(index);
-    results.push(array[index]);
-  }
-
-  return results;
+  const normalizedCount = Math.min(count, array.length);
+  return [...array]
+    .map((value, index) => ({ value, index, key: JSON.stringify(value) }))
+    .sort((a, b) => (a.key === b.key ? a.index - b.index : a.key.localeCompare(b.key)))
+    .slice(0, normalizedCount)
+    .map((entry) => entry.value);
 }

Also applies to: 76-77, 93-93, 107-130

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/clients/fixtures/record_requests.ts` around lines 53 - 60, The
fixtures are sampled non-deterministically and can produce tasks that reference
contests not present in the sampled contests; change the sampling to
deterministic (e.g., use a seeded RNG or sort+slice) and ensure referential
integrity by filtering the sampled tasks to only include tasks whose contest_id
exists in the sampled contests before writing; update the calls around
getRandomElementsFromArray(contests, 100) and getRandomElementsFromArray(tasks,
100) (and the other similar occurrences noted) so you first deterministically
select contests into a variable (e.g., sampledContests), then deterministically
select or sort tasks and filter them with sampledContests' IDs (e.g.,
sampledTasks = tasks.filter(t => sampledContestsIds.has(t.contest_id)) then
slice to desired count) and pass those filtered arrays to toJson so the output
is reproducible and consistent.

Comment on lines +4 to +6
const hasNoQuery = !query;
const lowerQuery = query.toLowerCase();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

空白のみクエリは未入力として扱う方が安全です。

' ' が全件非表示になり得るため、trim() して判定してください。

diff案
-  const hasNoQuery = !query;
-  const lowerQuery = query.toLowerCase();
+  const normalizedQuery = query.trim().toLowerCase();
+  const hasNoQuery = normalizedQuery.length === 0;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/`(admin)/tasks/_utils/contests.ts around lines 4 - 6, Treat
strings that are only whitespace as empty: change the check and normalization to
use trim(). Replace the current hasNoQuery and lowerQuery logic so hasNoQuery
becomes "!query || query.trim() === ''" and lowerQuery is computed from
"query.trim().toLowerCase()" (ensuring you guard for undefined/null query before
calling trim()). Update the assignments where "hasNoQuery" and "lowerQuery" are
defined (the current lines with hasNoQuery and lowerQuery) accordingly.

Comment on lines +54 to +80
export const actions: Actions = {
update: async ({ request, locals }) => {
await validateAdminAccess(locals);

const formData = await request.formData();
const taskId = formData.get('task_id')?.toString();
const taskGradeStr = formData.get('task_grade')?.toString() ?? '';

if (taskGradeStr === '') {
return { success: true };
}

const task_grade: TaskGrade | undefined = getTaskGrade(taskGradeStr);

if (!taskId || task_grade === undefined) {
return fail(BAD_REQUEST, { success: false });
}

const updateResult = await taskService.updateTask(taskId, task_grade);

if (updateResult === null) {
return fail(INTERNAL_SERVER_ERROR, { success: false });
}

return { success: true };
},
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

このフォームも Superforms + Zod へ寄せてください。

手動バリデーションのままだと、同ルート内の検証/エラー返却の一貫性が維持しづらいです。

As per coding guidelines, "src/routes/**/*.{server.ts,svelte}: Use Superforms + Zod validation for forms."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/`(admin)/tasks/[task_id]/+page.server.ts around lines 54 - 80,
Replace the manual formData parsing in the actions.update handler with
Superforms + Zod: create a Zod schema (e.g., TaskUpdateSchema) that validates
task_id as a nonempty string and task_grade as an optional enum/nullable
matching your TaskGrade values (or a string refined to TaskGrade), then call
superValidate(request, TaskUpdateSchema) inside update and branch on validation
result instead of ad-hoc checks; extract validated values from result.data,
map/convert task_grade to your TaskGrade type (or let Zod produce it) and call
taskService.updateTask(task_id, task_grade), and on validation failure return
the Superforms-consistent error response so errors from actions.update follow
the same Superforms + Zod pattern used elsewhere (leave validateAdminAccess and
taskService.updateTask calls intact).

Comment on lines +55 to +73
update: async ({ request, locals }) => {
await validateAdminAccess(locals);

const formData = await request.formData();
const taskId = formData.get('task_id')?.toString();
const taskGradeStr = formData.get('task_grade')?.toString() ?? '';

if (taskGradeStr === '') {
return { success: true };
}

const task_grade: TaskGrade | undefined = getTaskGrade(taskGradeStr);

if (!taskId || task_grade === undefined) {
return fail(BAD_REQUEST, { success: false });
}

const updateResult = await taskService.updateTask(taskId, task_grade);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

更新対象の task_id をフォーム値に依存しないでください。

ルートパラメータではなく form の task_id を採用しているため、リクエスト改変で別タスク更新が可能です。params.task_id を唯一の更新対象として使ってください。

diff案
-export const actions: Actions = {
-  update: async ({ request, locals }) => {
+export const actions: Actions = {
+  update: async ({ request, locals, params }) => {
     await validateAdminAccess(locals);

     const formData = await request.formData();
-    const taskId = formData.get('task_id')?.toString();
+    const taskId = params.task_id;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/`(admin)/tasks/[task_id]/+page.server.ts around lines 55 - 73, The
update handler currently trusts the form field task_id (taskId) allowing
tampering; change it to use the route parameter instead: read the task id from
the handler's params (use params.task_id) and remove/ignore the form-derived
task_id, then pass that params-derived id into taskService.updateTask; keep
existing validation for task_grade (getTaskGrade) and the bad-request/fail
branches but ensure any checks reference params.task_id (and return fail if it's
missing) rather than the form value.

Comment on lines +21 to +104
fetch: async ({ request, locals }) => {
await validateAdminAccess(locals);

const tasksFromDB = await taskService.getTasks();
const registeredTaskMap = prepareTaskMap(tasksFromDB);
const formData = await request.formData();
const source = formData.get('source');

const unregisteredTasks = filterUnregisteredTasks(
contestsForImport,
tasksForImport,
registeredTaskMap,
);
const contestsWithUnregisteredTasks: Contests = mergeContestsAndUnregisteredTasks(
contestsForImport,
unregisteredTasks,
);
if (!isContestTaskImportSource(source)) {
return fail(BAD_REQUEST, { message: 'コンテストサイト・種別が不正です。' });
}

return {
importContests: contestsWithUnregisteredTasks,
};
}
try {
const [contestsForImport, tasksForImport, tasksFromDB] = await Promise.all([
fetchContests(source),
fetchTasks(source),
taskService.getTasks(),
]);

const registeredTaskMap = prepareTaskMap(tasksFromDB);
const unregisteredTasks = filterUnregisteredTasks(
contestsForImport,
tasksForImport,
registeredTaskMap,
);

async function fetchContestsAndTasksFromAPI(): Promise<{
contestsForImport: ContestsForImport;
tasksForImport: TasksForImport;
}> {
const contestsForImport = await apiClient.getContests();
const tasksForImport = await apiClient.getTasks();
return {
importContests: mergeContestsAndUnregisteredTasks(contestsForImport, unregisteredTasks),
};
} catch (error) {
console.error('Failed to fetch contests/tasks:', error);
return fail(INTERNAL_SERVER_ERROR, { message: 'データ取得に失敗しました。' });
}
},

return { contestsForImport, tasksForImport };
}
create: async ({ request, locals }) => {
await validateAdminAccess(locals);

const formData = await request.formData();
const source = formData.get('source');

if (!isContestTaskImportSource(source)) {
return fail(BAD_REQUEST, { message: 'コンテストサイト・種別が不正です。' });
}

const contest_id = formData.get('contest_id');

if (typeof contest_id !== 'string' || !contest_id) {
return fail(BAD_REQUEST, { message: 'コンテストIDが指定されていません。' });
}

const tasksJson = formData.get('tasks');

if (typeof tasksJson !== 'string') {
return fail(BAD_REQUEST, { message: '問題データが不正です。' });
}

let tasks: TasksForImport;

try {
tasks = JSON.parse(tasksJson) as TasksForImport;
} catch {
return fail(BAD_REQUEST, { message: '問題データの解析に失敗しました。' });
}

try {
await Promise.all(
tasks.map(async (task: TaskForImport) => {
const id = (await sha256(contest_id + task.title)) as string;
await taskService.createTask(
id,
task.id,
task.contest_id,
task.problem_index,
task.title,
);
}),
);
} catch (error) {
console.error('Failed to create tasks:', error);
return fail(INTERNAL_SERVER_ERROR, { success: false });
}

return { success: true };
},
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

フォーム処理を Superforms + Zod に統一してください。

このルートの fetch/create は手動パース実装になっており、バリデーションとエラー整形の一貫性が崩れています。

As per coding guidelines, "src/routes/**/*.{server.ts,svelte}: Use Superforms + Zod validation for forms."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/`(admin)/tasks/+page.server.ts around lines 21 - 104, The handlers
fetch and create currently parse and validate request.formData() manually;
replace them with Superforms + Zod: define Zod schemas (e.g.
ContestImportQuerySchema with source for fetch, and ContestImportCreateSchema
with source:string, contest_id:string, tasks:string (or structured
TasksForImport) for create), use superValidate(request, schema) in both handlers
and return the validated form (or validation errors) so Superforms handles
client errors; keep the admin check (validateAdminAccess(locals)), then in fetch
use data.source from the validated form to call fetchContests/fetchTasks and the
existing
prepareTaskMap/filterUnregisteredTasks/mergeContestsAndUnregisteredTasks flow,
and in create use data.contest_id and the parsed tasks (parse JSON after
validation or use a Zod preprocessor to parse tasks into TasksForImport) and
then run the existing sha256 + taskService.createTask loop; ensure all
validation failures return the superform validation result instead of manual
fail responses and preserve try/catch for server errors with the same
INTERNAL_SERVER_ERROR handling.

Comment on lines +70 to +96
const tasksJson = formData.get('tasks');

if (typeof tasksJson !== 'string') {
return fail(BAD_REQUEST, { message: '問題データが不正です。' });
}

let tasks: TasksForImport;

try {
tasks = JSON.parse(tasksJson) as TasksForImport;
} catch {
return fail(BAD_REQUEST, { message: '問題データの解析に失敗しました。' });
}

try {
await Promise.all(
tasks.map(async (task: TaskForImport) => {
const id = (await sha256(contest_id + task.title)) as string;
await taskService.createTask(
id,
task.id,
task.contest_id,
task.problem_index,
task.title,
);
}),
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

クライアント送信の tasks JSON を信頼しないでください。

現在は hidden input を改変すると任意の task payload を登録できます。source/contest_id を受けてサーバ側で再取得した正データのみを登録対象にしてください。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/`(admin)/tasks/+page.server.ts around lines 70 - 96, The handler
currently trusts the client-provided tasks JSON (tasksJson → tasks) and uses
client-supplied fields (task.title, task.contest_id, task.source) when calling
taskService.createTask; instead, validate/trust only an identifier from the
client (e.g., task.id or task.source) and re-fetch the canonical task data on
the server (via your service/DB) before creating records. Change the import loop
that maps over TaskForImport so it discards client title/contest_id and instead:
lookup the authoritative task payload using the submitted task.id/source, derive
the server-side values (server_contest_id, server_title, server_problem_index),
compute the id with sha256(contest_id + server_title) and call
taskService.createTask with those server-validated fields; ensure any missing or
invalid lookups return BAD_REQUEST or a clear failure path.

Comment on lines +85 to +94
await Promise.all(
tasks.map(async (task: TaskForImport) => {
const id = (await sha256(contest_id + task.title)) as string;
await taskService.createTask(
id,
task.id,
task.contest_id,
task.problem_index,
task.title,
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

タスクID生成に title を使うと衝突リスクがあります。

同一コンテスト内で同名タイトルがあるとハッシュ衝突し、登録失敗や上書き競合の原因になります。task.id ベースに変更してください。

diff案
-          const id = (await sha256(contest_id + task.title)) as string;
+          const id = (await sha256(contest_id + task.id)) as string;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/`(admin)/tasks/+page.server.ts around lines 85 - 94, The current
ID generation uses sha256(contest_id + task.title) which risks collisions when
multiple tasks share the same title; change the seed to use the original
external identifier by hashing contest_id + task.id instead. Locate the
tasks.map block where TaskForImport is processed, replace usage of task.title in
the sha256 call with task.id so the generated id passed into
taskService.createTask is derived from contest_id + task.id.

Comment on lines +94 to +97
onchange={() => {
currentPage = 1;
}}
/>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

取得元切替時に一覧をクリアしてください。

source を変えても旧結果が残るため、別ソースのデータに見えて誤インポートしやすいです。切替時に importContests とエラー状態をリセットしてください。

diff案
               onchange={() => {
                 currentPage = 1;
+                importContests = [];
+                fetchError = null;
+                importError = null;
               }}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/`(admin)/tasks/+page.svelte around lines 94 - 97,
選択している取得元を切り替えたときに古い結果が残って誤インポートを誘発しているので、onchange ハンドラ内で currentPage = 1 に加えて
importContests を空配列にリセットし、インポート失敗の表示を消すためのエラー状態(例: importError または importErrors
を使っている箇所)を null/空にクリアしてください。該当箇所は +page.svelte の onchange コールバックなので、そのブロックに
importContests = [] と importError = null を追加して一覧とエラー状態を同時に初期化してください。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[refactor] AOJ の API クライアント をコンテスト単位で取得するように変更しましょう

1 participant