diff --git a/.claude/rules/auth.md b/.claude/rules/auth.md index 1a330eb36..569103cb8 100644 --- a/.claude/rules/auth.md +++ b/.claude/rules/auth.md @@ -1,8 +1,10 @@ --- description: Authentication rules -globs: +paths: - 'src/lib/server/auth.ts' - 'src/routes/(auth)/**' + - 'src/routes/(admin)/_utils/auth.ts' + - 'src/routes/(admin)/**' - 'src/hooks.server.ts' --- @@ -30,6 +32,9 @@ globs: - `src/lib/server/auth.ts`: Lucia configuration - `src/hooks.server.ts`: Global request handler +- `src/routes/(admin)/_utils/auth.ts`: + - `validateAdminAccess(locals)` — for page routes; redirects to `/login` for both unauthenticated and non-admin users (do not use in `+server.ts`) + - `validateAdminAccessForApi(locals)` — for API routes (`+server.ts`); throws `error(401)` if unauthenticated, `error(403)` if not admin - `prisma/schema.prisma`: User, Session, Key models ## Security diff --git a/.claude/rules/coding-style.md b/.claude/rules/coding-style.md new file mode 100644 index 000000000..454db270b --- /dev/null +++ b/.claude/rules/coding-style.md @@ -0,0 +1,48 @@ +# Coding Style + +## Naming + +- **Abbreviations**: avoid non-standard abbreviations (`res` → `response`, `btn` → `button`). When in doubt, spell it out. +- **Lambda parameters**: no single-character names (e.g., use `placement`, `workbook`). Iterator index `i` is the only exception. +- **`upsert`**: only use when the implementation performs both insert and update. For insert-only, use `initialize`, `seed`, or another accurate verb. +- **`any`**: before using `any`, check the value's origin — adding a missing `@types/*` or `devDependency` often provides the correct type. +- **UI labels**: if a label does not match actual behavior, update it or add an inline comment explaining the intentional mismatch. + +## Syntax + +- **Braces**: always use braces for single-statement `if` blocks. Never `if () return;` — write `if () { return; }`. +- **Plural type aliases**: define `type Placements = Placement[]` instead of using `Placement[]` directly in signatures and variables. + +## Markdown Code Blocks + +Always specify a language identifier on every fenced code block. Never write bare ` ``` `. + +Common identifiers: `typescript`, `svelte`, `sql`, `bash`, `mermaid`, `json`, `prisma`, `html`, `css`. + +## SvelteKit: Routes vs API Endpoints + +- Page routes (`+page.server.ts`): use `redirect()` to navigate +- API routes (`+server.ts`): use `error()` — throwing `redirect()` returns a 3xx response; `fetch` follows it by default and receives the HTML page at the redirect target instead of a JSON error + +## Dual-Enforcement Constraints + +When the same constraint is enforced in two layers (e.g. Zod validation + SQL `CHECK`), add an inline comment stating each layer's role and the obligation to keep them in sync: + +```typescript +// XOR constraint: dual enforcement via Zod (early validation) and a CHECK in migration.sql (last line of defence). +// Prisma lacks @@check, so the SQL constraint is maintained manually. Keep both in sync. +.refine(...) +``` + +## Async Rollback: Capture State Before `await` + +Capture `$state` values before the first `await` for safe rollback. A concurrent update can overwrite the variable while awaiting: + +```typescript +const previous = items; // capture before await +try { + await saveToServer(items); +} catch { + items = previous; +} +``` diff --git a/.claude/rules/prisma-db.md b/.claude/rules/prisma-db.md index b54f3612c..6f464f8b3 100644 --- a/.claude/rules/prisma-db.md +++ b/.claude/rules/prisma-db.md @@ -1,9 +1,10 @@ --- description: Prisma and database rules -globs: +paths: - 'prisma/**' - 'src/lib/server/**' - 'src/lib/services/**' + - 'src/features/**/services/**' --- # Prisma & Database @@ -11,30 +12,58 @@ globs: ## Schema Changes 1. Edit `prisma/schema.prisma` -2. Run `pnpm exec prisma migrate dev --name ` to create migration -3. Run `pnpm exec prisma generate` to update client (auto-runs after migrate) +2. Run `pnpm exec prisma migrate dev --name ` ## Naming -- Model names: `PascalCase` (e.g., `User`, `TaskAnswer`) -- Field names: `camelCase` (preferred) or `snake_case` (legacy) -- Relation fields: Descriptive names matching the relation - -## Key Models - -- `User`: User accounts with AtCoder validation status -- `Task`: Tasks with difficulty grades (Q11-D6) -- `TaskAnswer`: User submission status per task -- `WorkBook`: task collections -- `Tag` / `TaskTag`: task categorization +- Models: `PascalCase` | Fields: `camelCase` (preferred) or `snake_case` (legacy) ## Server-Only Code -- Import database client only in `src/lib/server/` -- Use `$lib/server/database` for Prisma client access +- Import DB client only in `src/lib/server/` via `$lib/server/database` - Never import server code in client components +## Service Layer + +- All CRUD through the service layer (`src/lib/services/` or `src/features/**/services/`) +- Route handlers call service methods — no direct Prisma in `+server.ts` / `+page.server.ts` +- Service functions return pure values (`{ error: string } | null`), never `Response` / `json()` + ## Transactions -- Use `prisma.$transaction()` for multi-step operations -- Handle errors with try-catch and proper rollback +Use `prisma.$transaction()` for multi-step operations. + +## N+1 Queries + +Replace per-item DB calls in loops with a bulk fetch + `Map`: + +```typescript +const records = await prisma.foo.findMany({ where: { id: { in: ids } } }); +const map = new Map(records.map((r) => [r.id, r])); +``` + +## Enum Types + +Prisma-generated enums and app-defined enums are distinct TypeScript types even with identical members. Keep explicit casts at the boundary — do not remove them as "redundant". + +## Idempotent Writes + +Prefer `createMany({ skipDuplicates: true })` over catching P2002 for expected unique violations (e.g., double-submit). Maps to `INSERT ... ON CONFLICT DO NOTHING`. Top-level only (not nested); PostgreSQL/CockroachDB/SQLite only. + +## Zod Schema for Int Fields + +`z.number().positive()` passes decimals. For Prisma `Int` fields use `z.number().int().positive()`. + +## Validate Constraints + +Prisma does not support `@@check`. To add one: + +1. `pnpm exec prisma migrate dev --create-only --name ` — generate migration without applying +2. Edit the generated `migration.sql` to add the CHECK constraint manually +3. `pnpm exec prisma migrate dev` — apply + +Document the constraint in `prisma/ERD.md` (the only place it's visible): + +```mermaid +%% XOR constraint: workbookplacement_xor_grade_category — exactly one of taskGrade or solutionCategory must be non-null +``` diff --git a/.claude/rules/svelte-components.md b/.claude/rules/svelte-components.md index 099ffa3c2..4903254c4 100644 --- a/.claude/rules/svelte-components.md +++ b/.claude/rules/svelte-components.md @@ -1,6 +1,6 @@ --- description: Svelte component development rules -globs: +paths: - 'src/**/*.svelte' - 'src/lib/components/**' - 'src/lib/stores/**/*.svelte.ts' @@ -10,12 +10,7 @@ globs: ## Runes Mode (Required) -- Use `$props()` for component props -- Use `$state()` for reactive state -- Use `$derived()` for computed values -- Use `$effect()` for side effects - -## Props Pattern +Use `$props()`, `$state()`, `$derived()`, `$effect()` in all components. Props pattern: ```svelte ``` -## Stores +## File Naming -- Place store files in `src/lib/stores/` with `.svelte.ts` extension -- Use class-based stores with `$state()` for internal state -- Export singleton instances +- Components: `PascalCase.svelte` +- Stores: `snake_case.svelte.ts` in `src/lib/stores/`, class-based with `$state()`, export singleton. Pre-Runes stores (using `writable()`, `.ts` extension) must be migrated to this pattern before adding features or extending them. ## Flowbite Svelte -- Import components from `flowbite-svelte` -- Use Tailwind CSS v4 utility classes -- Dark mode: Use `dark:` prefix for dark mode variants +Import from `flowbite-svelte`. Use Tailwind CSS v4 utility classes. Dark mode: `dark:` prefix. -## File Naming +## `$state()` Initialization with `$props()` -- Components: `PascalCase.svelte` -- Stores: `snake_case.svelte.ts` +Referencing `$props()` inside `$state()` initializer triggers "This reference only captures the initial value". Wrap with `untrack` if intentional: + +```svelte +let count = $state(untrack(() => initialCount)); // intentional: prop is initial seed only +``` + +## `{#snippet}` Placement + +Define snippets at the **top level**, outside component tags. Inside a tag = named slot = type error: + +```svelte + +{#snippet footer()}...{/snippet} + + + +{#snippet footer()}...{/snippet} +``` + +## Snippet vs Component + +Prefer `{#snippet}` when: (1) needs direct `$state` access, (2) pure display only, (3) same-file DRY. +Promote to component when: independent state/lifecycle needed, exceeds ~30 lines, or reused across files. + +## Component Boundaries + +- One component, one responsibility: don't mix display, state management, and data fetching +- Extract `$derived`/`$effect` logic exceeding ~5 lines to a custom store +- Extract repeated UI patterns (2+ uses) to a snippet or component (see Snippet vs Component) + +## Keep Components Thin + +Business logic and pure utilities belong outside ` + + e.stopPropagation()} + onpointerdown={(e) => e.stopPropagation()} +> + {title} + diff --git a/src/features/workbooks/fixtures/solution_category_map.ts b/src/features/workbooks/fixtures/solution_category_map.ts new file mode 100644 index 000000000..c6d2d1b78 --- /dev/null +++ b/src/features/workbooks/fixtures/solution_category_map.ts @@ -0,0 +1,17 @@ +import { SolutionCategory } from '$features/workbooks/types/workbook_placement'; + +/** + * Maps urlSlug → SolutionCategory for seeding. + * SOLUTION workbooks not listed here are initially placed as PENDING. + */ +export const solutionCategoryMap: Record = { + 'greedy-method': SolutionCategory.SEARCH_SIMULATION, + 'recursive-function': SolutionCategory.SEARCH_SIMULATION, + 'bitmask-brute-force-search': SolutionCategory.SEARCH_SIMULATION, + 'map-dict': SolutionCategory.DATA_STRUCTURE, + stack: SolutionCategory.DATA_STRUCTURE, + 'ordered-set': SolutionCategory.DATA_STRUCTURE, + 'priority-queue': SolutionCategory.DATA_STRUCTURE, + 'potentialized-union-find': SolutionCategory.GRAPH, + 'number-theory-search': SolutionCategory.NUMBER_THEORY, +}; diff --git a/src/features/workbooks/fixtures/workbook_placements.ts b/src/features/workbooks/fixtures/workbook_placements.ts new file mode 100644 index 000000000..5476166a7 --- /dev/null +++ b/src/features/workbooks/fixtures/workbook_placements.ts @@ -0,0 +1,215 @@ +import { TaskGrade, type Task } from '$lib/types/task'; +import { WorkBookType } from '$features/workbooks/types/workbook'; +import { + SolutionCategory, + type WorkBookPlacements, + type WorkbooksWithPlacement, + type WorkBooksWithTasks, + type UnplacedCurriculumRows, +} from '$features/workbooks/types/workbook_placement'; + +// --------------------------------------------------------------------------- +// Placement records (WorkBookPlacement shape) +// --------------------------------------------------------------------------- + +// CURRICULUM placements reflecting seed data order: +// workBook 1: 標準入出力(1 個の整数)→ tasks Q10: math_and_algorithm_a, tessoku_book_a, ... +// workBook 2: 標準入出力(2 個以上の整数)→ tasks Q9: tessoku_book_bz, abc169_a, ... +// workBook 6: if 文 ① → tasks Q8: abc174_a, abc334_a, ... +export const curriculumPlacements: WorkBookPlacements = [ + { id: 1, workBookId: 1, taskGrade: TaskGrade.Q10, solutionCategory: null, priority: 1 }, + { id: 2, workBookId: 2, taskGrade: TaskGrade.Q9, solutionCategory: null, priority: 1 }, + { id: 6, workBookId: 6, taskGrade: TaskGrade.Q8, solutionCategory: null, priority: 1 }, +]; + +// SOLUTION placements reflecting solutionCategoryMap: +// stack (workBook 31) → DATA_STRUCTURE +// bitmask-brute-force-search (workBook 33) → SEARCH_SIMULATION +// number-theory-search (workBook 40) → NUMBER_THEORY +// unlisted workbook (workBook 99) → PENDING (state after createInitialPlacements runs; placement: null means not yet initialized) +export const solutionPlacements: WorkBookPlacements = [ + { + id: 101, + workBookId: 31, + taskGrade: null, + solutionCategory: SolutionCategory.DATA_STRUCTURE, + priority: 1, + }, + { + id: 102, + workBookId: 33, + taskGrade: null, + solutionCategory: SolutionCategory.SEARCH_SIMULATION, + priority: 1, + }, + { + id: 103, + workBookId: 40, + taskGrade: null, + solutionCategory: SolutionCategory.NUMBER_THEORY, + priority: 1, + }, + { + id: 104, + workBookId: 99, + taskGrade: null, + solutionCategory: SolutionCategory.PENDING, + priority: 2, + }, +]; + +// --------------------------------------------------------------------------- +// Workbooks with placements (returned by getWorkbooksWithPlacements) +// --------------------------------------------------------------------------- + +export const workbooksWithPlacements: WorkbooksWithPlacement = [ + { + id: 1, + title: '標準入出力(1 個の整数)', + isPublished: true, + workBookType: WorkBookType.CURRICULUM, + placement: curriculumPlacements[0], + }, + { + id: 31, + title: 'スタック(stack)', + isPublished: true, + workBookType: WorkBookType.SOLUTION, + placement: solutionPlacements[0], + }, + { + id: 99, + title: '未分類問題集', + isPublished: false, + workBookType: WorkBookType.SOLUTION, + placement: null, + }, +]; + +// --------------------------------------------------------------------------- +// DB row shapes used in validateAndUpdatePlacements tests +// (WorkBookPlacement + workBook relation from Prisma include) +// --------------------------------------------------------------------------- + +export const curriculumPlacementRow = { + ...curriculumPlacements[0], + workBook: { workBookType: WorkBookType.CURRICULUM }, +}; + +export const solutionPlacementRow = { + ...solutionPlacements[0], + workBook: { workBookType: WorkBookType.SOLUTION }, +}; + +// --------------------------------------------------------------------------- +// Task map (used by initializeCurriculumPlacements tests) +// Task IDs match those in fixtures/workbooks.ts CURRICULUM/SOLUTION workbooks +// --------------------------------------------------------------------------- + +export const tasksMapByIds = new Map([ + [ + 'math_and_algorithm_a', + { + task_id: 'math_and_algorithm_a', + contest_id: 'math_and_algorithm', + task_table_index: 'A', + title: '001. Print 5+N', + grade: TaskGrade.Q10, + }, + ], + [ + 'tessoku_book_a', + { + task_id: 'tessoku_book_a', + contest_id: 'tessoku_book', + task_table_index: 'A', + title: 'A01. The First Problem', + grade: TaskGrade.Q10, + }, + ], + [ + 'tessoku_book_bz', + { + task_id: 'tessoku_book_bz', + contest_id: 'tessoku_book', + task_table_index: 'BZ', + title: 'B01. A+B Problem', + grade: TaskGrade.Q9, + }, + ], + [ + 'abc169_a', + { + task_id: 'abc169_a', + contest_id: 'abc169', + task_table_index: 'A', + title: 'A. Multiplication 1', + grade: TaskGrade.Q9, + }, + ], + [ + 'abc174_a', + { + task_id: 'abc174_a', + contest_id: 'abc174', + task_table_index: 'A', + title: 'A. Air Conditioner', + grade: TaskGrade.Q8, + }, + ], + [ + 'abc219_a', + { + task_id: 'abc219_a', + contest_id: 'abc219', + task_table_index: 'A', + title: 'A. AtCoder Judge', + grade: TaskGrade.Q10, + }, + ], +]); + +// --------------------------------------------------------------------------- +// Curriculum workbooks for initializeCurriculumPlacements tests +// (WorkBooksWithTasks shape — reflects fixtures/workbooks.ts entries 1, 2, 6, 7) +// --------------------------------------------------------------------------- + +export const curriculumWorkbooksForInit: WorkBooksWithTasks = [ + { + id: 1, + workBookTasks: [ + { taskId: 'math_and_algorithm_a', priority: 1, comment: '' }, + { taskId: 'tessoku_book_a', priority: 2, comment: '' }, + ], + }, + { + id: 2, + workBookTasks: [ + { taskId: 'tessoku_book_bz', priority: 1, comment: '' }, + { taskId: 'abc169_a', priority: 2, comment: '' }, + ], + }, + { id: 6, workBookTasks: [{ taskId: 'abc174_a', priority: 1, comment: '' }] }, + { id: 7, workBookTasks: [{ taskId: 'abc219_a', priority: 1, comment: '' }] }, +]; + +// --------------------------------------------------------------------------- +// Unplaced workbook shapes for createInitialPlacements tests +// (shapes returned by fetchUnplacedWorkbooks internals) +// --------------------------------------------------------------------------- + +export const unplacedCurriculumRows: UnplacedCurriculumRows = [ + { + id: 1, + workBookTasks: [ + { task: { task_id: 'math_and_algorithm_a', grade: TaskGrade.Q10 } }, + { task: { task_id: 'tessoku_book_a', grade: TaskGrade.Q10 } }, + ], + }, + { + id: 2, + workBookTasks: [{ task: { task_id: 'tessoku_book_bz', grade: TaskGrade.Q9 } }], + }, +]; + +export const unplacedSolutionWorkbooks = [{ id: 31 }, { id: 33 }]; diff --git a/src/features/workbooks/services/workbook_placements/crud.test.ts b/src/features/workbooks/services/workbook_placements/crud.test.ts new file mode 100644 index 000000000..11ae00e10 --- /dev/null +++ b/src/features/workbooks/services/workbook_placements/crud.test.ts @@ -0,0 +1,289 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; + +import { TaskGrade } from '$lib/types/task'; +import { WorkBookType } from '$features/workbooks/types/workbook'; +import { + SolutionCategory, + type WorkBookPlacements, +} from '$features/workbooks/types/workbook_placement'; + +import { + getWorkbooksWithPlacements, + getPlacementsByWorkBookType, + updateWorkBookPlacements, + createInitialPlacements, + validateAndUpdatePlacements, + createWorkBookPlacements, +} from './crud'; + +import { + curriculumPlacements, + solutionPlacements, + workbooksWithPlacements, + curriculumPlacementRow, + solutionPlacementRow, + unplacedCurriculumRows, + unplacedSolutionWorkbooks, +} from '$features/workbooks/fixtures/workbook_placements'; + +vi.mock('$lib/server/database', () => ({ + default: { + workBook: { + findMany: vi.fn(), + }, + workBookPlacement: { + findMany: vi.fn(), + update: vi.fn(), + createMany: vi.fn(), + }, + $transaction: vi.fn(), + }, +})); + +import prisma from '$lib/server/database'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function mockFindMany(placements: WorkBookPlacements) { + vi.mocked(prisma.workBookPlacement.findMany).mockResolvedValue( + placements as unknown as Awaited>, + ); +} + +function mockPlacementFindManyOnce(placements: WorkBookPlacements) { + vi.mocked(prisma.workBookPlacement.findMany).mockResolvedValueOnce( + placements as unknown as Awaited>, + ); +} + +function mockWorkBookFindManyOnce(result: { id: number }[]) { + vi.mocked(prisma.workBook.findMany).mockResolvedValueOnce( + result as unknown as Awaited>, + ); +} + +describe('getWorkbooksWithPlacements', () => { + test('returns workbooks of type CURRICULUM and SOLUTION with their placements', async () => { + mockWorkBookFindManyOnce(workbooksWithPlacements); + + const result = await getWorkbooksWithPlacements(); + + expect(result).toEqual(workbooksWithPlacements); + expect(prisma.workBook.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + workBookType: { in: [WorkBookType.CURRICULUM, WorkBookType.SOLUTION] }, + }), + }), + ); + }); +}); + +describe('getPlacementsByWorkBookType', () => { + test('returns CURRICULUM placements ordered by priority', async () => { + mockFindMany(curriculumPlacements); + + const result = await getPlacementsByWorkBookType(WorkBookType.CURRICULUM); + + // Verifies the function returns the DB result without transformation + expect(result).toEqual(curriculumPlacements); + expect(prisma.workBookPlacement.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ workBook: { workBookType: WorkBookType.CURRICULUM } }), + orderBy: { priority: 'asc' }, + }), + ); + }); + + test('returns SOLUTION placements ordered by priority', async () => { + mockFindMany(solutionPlacements); + + const result = await getPlacementsByWorkBookType(WorkBookType.SOLUTION); + + expect(result).toEqual(solutionPlacements); + expect(prisma.workBookPlacement.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ workBook: { workBookType: WorkBookType.SOLUTION } }), + orderBy: { priority: 'asc' }, + }), + ); + }); + + test('returns placements with multiple distinct solutionCategory values', async () => { + // Reflects the solutionCategoryMap fixture: + // stack, potentialized-union-find, priority-queue, map-dict, ordered-set → DATA_STRUCTURE + // bitmask-brute-force-search, greedy-method, recursive-function → SEARCH_SIMULATION + // number-theory-search → NUMBER_THEORY + mockFindMany(solutionPlacements); + + const result = await getPlacementsByWorkBookType(WorkBookType.SOLUTION); + const categories = result.map((placement) => placement.solutionCategory); + + expect(categories).toContain(SolutionCategory.DATA_STRUCTURE); + expect(categories).toContain(SolutionCategory.SEARCH_SIMULATION); + expect(categories).toContain(SolutionCategory.NUMBER_THEORY); + expect(categories).toContain(SolutionCategory.PENDING); + expect(result.every((placement) => placement.taskGrade === null)).toBe(true); + }); +}); + +describe('updateWorkBookPlacements', () => { + test('updates multiple placements within a transaction', async () => { + vi.mocked(prisma.$transaction).mockResolvedValue([]); + + const updates = [ + { id: 1, priority: 1, taskGrade: TaskGrade.Q10, solutionCategory: null }, + { id: 2, priority: 2, taskGrade: TaskGrade.Q9, solutionCategory: null }, + ]; + await updateWorkBookPlacements(updates); + + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + }); + + test('does not call transaction when given an empty array', async () => { + await updateWorkBookPlacements([]); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + test('processes a batch containing both CURRICULUM and SOLUTION placements', async () => { + vi.mocked(prisma.$transaction).mockResolvedValue([]); + + const updates = [ + { id: 1, priority: 1, taskGrade: TaskGrade.Q10, solutionCategory: null }, + { id: 2, priority: 2, taskGrade: TaskGrade.Q9, solutionCategory: null }, + { id: 101, priority: 1, taskGrade: null, solutionCategory: SolutionCategory.DATA_STRUCTURE }, + { + id: 102, + priority: 2, + taskGrade: null, + solutionCategory: SolutionCategory.SEARCH_SIMULATION, + }, + ]; + await updateWorkBookPlacements(updates); + + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + const callArg = vi.mocked(prisma.$transaction).mock.calls[0][0]; + expect(Array.isArray(callArg)).toBe(true); + expect(callArg).toHaveLength(4); + }); + + test('updates solutionCategory from PENDING to a specific category', async () => { + vi.mocked(prisma.$transaction).mockResolvedValue([]); + + // Simulates the admin moving a workbook from PENDING to DATA_STRUCTURE on the Kanban board + const updates = [ + { id: 104, priority: 3, taskGrade: null, solutionCategory: SolutionCategory.DATA_STRUCTURE }, + ]; + await updateWorkBookPlacements(updates); + + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + }); +}); + +describe('createInitialPlacements', () => { + test('does nothing when all workbooks are already placed', async () => { + mockWorkBookFindManyOnce([]); // unplaced CURRICULUM + mockWorkBookFindManyOnce([]); // unplaced SOLUTION + + await createInitialPlacements(); + + expect(prisma.workBookPlacement.createMany).not.toHaveBeenCalled(); + }); + + test('creates placements for unplaced curriculum and solution workbooks', async () => { + // unplacedCurriculumRows: 2 workbooks → 2 curriculum placements + // unplacedSolutionWorkbooks: 2 workbooks → 2 solution placements (PENDING) + mockWorkBookFindManyOnce(unplacedCurriculumRows); + mockWorkBookFindManyOnce(unplacedSolutionWorkbooks); + vi.mocked(prisma.workBookPlacement.createMany).mockResolvedValue({ count: 4 }); + + await createInitialPlacements(); + + expect(prisma.workBookPlacement.createMany).toHaveBeenCalledTimes(1); + const callArg = vi.mocked(prisma.workBookPlacement.createMany).mock.calls[0][0]; + expect(callArg?.data).toHaveLength(4); // 2 curriculum + 2 solution + }); + + test('calls createMany with skipDuplicates to tolerate concurrent double-submit', async () => { + mockWorkBookFindManyOnce(unplacedCurriculumRows); + mockWorkBookFindManyOnce(unplacedSolutionWorkbooks); + vi.mocked(prisma.workBookPlacement.createMany).mockResolvedValue({ count: 4 }); + + await createInitialPlacements(); + + const callArg = vi.mocked(prisma.workBookPlacement.createMany).mock.calls[0][0]; + expect(callArg?.skipDuplicates).toBe(true); + }); +}); + +describe('validateAndUpdatePlacements', () => { + test('returns null and calls upsert when all updates are valid', async () => { + mockPlacementFindManyOnce([curriculumPlacementRow]); + vi.mocked(prisma.$transaction).mockResolvedValue([]); + + const result = await validateAndUpdatePlacements([ + { id: 1, priority: 2, taskGrade: TaskGrade.Q10, solutionCategory: null }, + ]); + + expect(result).toBeNull(); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + }); + + test('returns error when placement id does not exist', async () => { + mockPlacementFindManyOnce([]); + + const result = await validateAndUpdatePlacements([ + { id: 999, priority: 1, taskGrade: null, solutionCategory: SolutionCategory.GRAPH }, + ]); + + expect(result).toMatchObject({ error: expect.stringContaining('999') }); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + test('returns error for CURRICULUM → SOLUTION cross-type movement', async () => { + mockPlacementFindManyOnce([curriculumPlacementRow]); + + const result = await validateAndUpdatePlacements([ + { id: 1, priority: 1, taskGrade: null, solutionCategory: SolutionCategory.GRAPH }, + ]); + + expect(result).toMatchObject({ error: expect.stringContaining('not allowed') }); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + test('returns error for SOLUTION → CURRICULUM cross-type movement', async () => { + mockPlacementFindManyOnce([solutionPlacementRow]); + + const result = await validateAndUpdatePlacements([ + { id: 101, priority: 1, taskGrade: TaskGrade.Q10, solutionCategory: null }, + ]); + + expect(result).toMatchObject({ error: expect.stringContaining('not allowed') }); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); +}); + +describe('createWorkBookPlacements', () => { + test('calls createMany when given placement data', async () => { + vi.mocked(prisma.workBookPlacement.createMany).mockResolvedValue({ count: 2 }); + + await createWorkBookPlacements([ + { workBookId: 1, taskGrade: TaskGrade.Q10, solutionCategory: null, priority: 1 }, + { + workBookId: 31, + taskGrade: null, + solutionCategory: SolutionCategory.DATA_STRUCTURE, + priority: 1, + }, + ]); + + expect(prisma.workBookPlacement.createMany).toHaveBeenCalledTimes(1); + }); + + test('does not call createMany when given an empty array', async () => { + await createWorkBookPlacements([]); + expect(prisma.workBookPlacement.createMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/workbooks/services/workbook_placements/crud.ts b/src/features/workbooks/services/workbook_placements/crud.ts new file mode 100644 index 000000000..3391deff2 --- /dev/null +++ b/src/features/workbooks/services/workbook_placements/crud.ts @@ -0,0 +1,180 @@ +import prisma from '$lib/server/database'; + +import { + type WorkBookPlacement, + type WorkBookPlacements, + type WorkbooksWithPlacement, + type PlacementInputs, + type PlacementCreates, +} from '$features/workbooks/types/workbook_placement'; + +import { WorkBookType } from '$features/workbooks/types/workbook'; + +import { + buildTaskMapFromCurriculumRows, + buildCurriculumWorkbooksForInit, + initializeCurriculumPlacements, + initializeSolutionPlacements, +} from './initializers'; + +// --- Basic CRUD operations for placements --- + +/** + * Returns all CURRICULUM and SOLUTION workbooks with their placements, ordered by id. + */ +export async function getWorkbooksWithPlacements(): Promise { + return prisma.workBook.findMany({ + where: { workBookType: { in: [WorkBookType.CURRICULUM, WorkBookType.SOLUTION] } }, + include: { placement: true }, + orderBy: { id: 'asc' }, + }); +} + +/** + * Returns all placements for workbooks of the given type, ordered by priority. + */ +export async function getPlacementsByWorkBookType( + workBookType: typeof WorkBookType.CURRICULUM | typeof WorkBookType.SOLUTION, +): Promise { + return prisma.workBookPlacement.findMany({ + where: { workBook: { workBookType } }, + orderBy: { priority: 'asc' }, + }); +} + +/** + * Updates existing placements in a single transaction. + * No-op when given an empty array. + * + * Intentional N+1: one UPDATE per row. Acceptable for an infrequent admin-only + * operation; raw SQL bulk updates are not worth the type-safety trade-off here. + */ +export async function updateWorkBookPlacements(updatedPlacements: PlacementInputs): Promise { + if (updatedPlacements.length === 0) { + return; + } + + await prisma.$transaction( + updatedPlacements.map((updatedPlacement) => + prisma.workBookPlacement.update({ + where: { id: updatedPlacement.id }, + data: { + priority: updatedPlacement.priority, + taskGrade: updatedPlacement.taskGrade ?? null, + solutionCategory: updatedPlacement.solutionCategory ?? null, + }, + }), + ), + ); +} + +/** + * Queries all unplaced CURRICULUM and SOLUTION workbooks, computes their initial + * placements, and writes them to the database in a single createMany call. + * No-op when all workbooks are already placed. + */ +export async function createInitialPlacements(): Promise { + const { unplacedCurriculum, unplacedSolution } = await fetchUnplacedWorkbooks(); + + if (unplacedCurriculum.length === 0 && unplacedSolution.length === 0) { + return; + } + + const tasksMapByIds = buildTaskMapFromCurriculumRows(unplacedCurriculum); + const curriculumWorkbooksForInit = buildCurriculumWorkbooksForInit(unplacedCurriculum); + + const curriculumPlacements = initializeCurriculumPlacements( + curriculumWorkbooksForInit, + tasksMapByIds, + ); + const solutionPlacements = initializeSolutionPlacements(unplacedSolution); + + await prisma.workBookPlacement.createMany({ + data: [...curriculumPlacements, ...solutionPlacements], + skipDuplicates: true, + }); +} + +async function fetchUnplacedWorkbooks() { + const [unplacedCurriculum, unplacedSolution] = await Promise.all([ + prisma.workBook.findMany({ + where: { workBookType: WorkBookType.CURRICULUM, placement: null }, + include: { + workBookTasks: { + include: { task: { select: { task_id: true, grade: true } } }, + }, + }, + orderBy: { id: 'asc' }, + }), + prisma.workBook.findMany({ + where: { workBookType: WorkBookType.SOLUTION, placement: null }, + orderBy: { id: 'asc' }, + }), + ]); + + return { unplacedCurriculum, unplacedSolution }; +} + +/** + * Validates that no update crosses CURRICULUM/SOLUTION boundary, then upserts. + * Returns { error } on validation failure, null on success. + */ +export async function validateAndUpdatePlacements( + updates: PlacementInputs, +): Promise<{ error: string } | null> { + const validationError = await validatePlacements(updates); + + if (validationError) { + return validationError; + } + + await updateWorkBookPlacements(updates); + + return null; +} + +async function validatePlacements(updates: PlacementInputs): Promise<{ error: string } | null> { + const ids = updates.map((update) => update.id); + const existingPlacements = await prisma.workBookPlacement.findMany({ + where: { id: { in: ids } }, + include: { workBook: { select: { workBookType: true } } }, + }); + + const existingById = new Map(existingPlacements.map((placement) => [placement.id, placement])); + + for (const update of updates) { + const existing = existingById.get(update.id); + + if (!existing) { + return { error: `Not found placement id=${update.id}` }; + } + + const isCurriculumToSolution = + existing.workBook.workBookType === WorkBookType.CURRICULUM && + update.solutionCategory !== null; + const isSolutionToCurriculum = + existing.workBook.workBookType === WorkBookType.SOLUTION && update.taskGrade !== null; + + if (isCurriculumToSolution || isSolutionToCurriculum) { + return { error: 'Moving between CURRICULUM and SOLUTION is not allowed' }; + } + } + + return null; +} + +// --- Only used for seeding initial placements, not exposed to runtime code --- + +/** + * Persists an array of new placement records to the database. + */ +export async function createWorkBookPlacements(placements: PlacementCreates): Promise { + if (placements.length === 0) { + return; + } + + await prisma.workBookPlacement.createMany({ data: placements }); +} + +// Re-export for consumers that only need the placement type (e.g. +server.ts upsert). +export type { WorkBookPlacement }; diff --git a/src/features/workbooks/services/workbook_placements/initializers.test.ts b/src/features/workbooks/services/workbook_placements/initializers.test.ts new file mode 100644 index 000000000..6e9bf607a --- /dev/null +++ b/src/features/workbooks/services/workbook_placements/initializers.test.ts @@ -0,0 +1,262 @@ +import { describe, test, expect } from 'vitest'; + +import { TaskGrade } from '$lib/types/task'; +import { + SolutionCategory, + type UnplacedCurriculumRows, +} from '$features/workbooks/types/workbook_placement'; + +import { + buildTaskMapFromCurriculumRows, + buildCurriculumWorkbooksForInit, + initializeCurriculumPlacements, + groupWorkbooksByGrade, + buildPlacementsFromGroups, + initializeSolutionPlacements, +} from './initializers'; + +import { + tasksMapByIds, + curriculumWorkbooksForInit, +} from '$features/workbooks/fixtures/workbook_placements'; + +describe('buildTaskMapFromCurriculumRows', () => { + test('builds a task_id → Task map from nested workbook rows', () => { + const rows: UnplacedCurriculumRows = [ + { + id: 1, + workBookTasks: [ + { task: { task_id: 'math_and_algorithm_a', grade: TaskGrade.Q10 } }, + { task: { task_id: 'tessoku_book_a', grade: TaskGrade.Q10 } }, + ], + }, + { + id: 2, + workBookTasks: [{ task: { task_id: 'tessoku_book_bz', grade: TaskGrade.Q9 } }], + }, + ]; + + const result = buildTaskMapFromCurriculumRows(rows); + + expect(result.size).toBe(3); + expect(result.get('math_and_algorithm_a')).toMatchObject({ + task_id: 'math_and_algorithm_a', + grade: TaskGrade.Q10, + }); + expect(result.get('tessoku_book_bz')).toMatchObject({ + task_id: 'tessoku_book_bz', + grade: TaskGrade.Q9, + }); + }); + + test('skips workbook tasks where task is null', () => { + const rows: UnplacedCurriculumRows = [{ id: 1, workBookTasks: [{ task: null }] }]; + + expect(buildTaskMapFromCurriculumRows(rows).size).toBe(0); + }); + + test('returns empty map for empty input', () => { + expect(buildTaskMapFromCurriculumRows([])).toEqual(new Map()); + }); +}); + +describe('buildCurriculumWorkbooksForInit', () => { + test('converts DB rows to WorkBooksWithTasks shape', () => { + const rows: UnplacedCurriculumRows = [ + { + id: 1, + workBookTasks: [ + { task: { task_id: 'math_and_algorithm_a', grade: TaskGrade.Q10 } }, + { task: { task_id: 'tessoku_book_a', grade: TaskGrade.Q10 } }, + ], + }, + ]; + + const result = buildCurriculumWorkbooksForInit(rows); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + id: 1, + workBookTasks: [ + { taskId: 'math_and_algorithm_a', priority: 0, comment: '' }, + { taskId: 'tessoku_book_a', priority: 0, comment: '' }, + ], + }); + }); + + test('maps null task to empty string taskId', () => { + const rows: UnplacedCurriculumRows = [{ id: 1, workBookTasks: [{ task: null }] }]; + const result = buildCurriculumWorkbooksForInit(rows); + + expect(result[0].workBookTasks[0]).toEqual({ taskId: '', priority: 0, comment: '' }); + }); + + test('returns empty array for empty input', () => { + expect(buildCurriculumWorkbooksForInit([])).toEqual([]); + }); +}); + +describe('initializeCurriculumPlacements', () => { + test('assigns mode grade and ascending priority within the same grade by workbook id', () => { + // workBook 1: math_and_algorithm_a (Q10), tessoku_book_a (Q10) → mode Q10, priority 1 + // workBook 2: tessoku_book_bz (Q9), abc169_a (Q9) → mode Q9, priority 1 + // workBook 7: abc219_a (Q10) → mode Q10, priority 2 (same grade as 1, id > 1) + const workbooks = curriculumWorkbooksForInit.filter((workbook) => + [1, 2, 7].includes(workbook.id), + ); + + const result = initializeCurriculumPlacements(workbooks, tasksMapByIds); + const byId = new Map(result.map((placement) => [placement.workBookId, placement])); + + expect(byId.get(1)).toMatchObject({ taskGrade: TaskGrade.Q10, priority: 1 }); + expect(byId.get(2)).toMatchObject({ taskGrade: TaskGrade.Q9, priority: 1 }); + expect(byId.get(7)).toMatchObject({ taskGrade: TaskGrade.Q10, priority: 2 }); + }); + + test('assigns PENDING to a workbook with no tasks', () => { + const workbooks = [{ id: 1, workBookTasks: [] }]; + const result = initializeCurriculumPlacements(workbooks, new Map()); + expect(result[0]).toMatchObject({ workBookId: 1, taskGrade: TaskGrade.PENDING, priority: 1 }); + }); + + test('returns empty array for empty input', () => { + expect(initializeCurriculumPlacements([], new Map())).toEqual([]); + }); + + test('assigns correct grades and priorities for fixture-based task data', () => { + // Reflects actual curriculum workbooks from fixtures/workbooks.ts: + // workBook 1: 標準入出力(1 個の整数)→ tasks Q10: math_and_algorithm_a, tessoku_book_a + // workBook 2: 標準入出力(2 個以上の整数)→ tasks Q9: tessoku_book_bz, abc169_a + // workBook 6: if 文 ① → tasks Q8: abc174_a + const workbooks = curriculumWorkbooksForInit.filter((workbook) => + [1, 2, 6].includes(workbook.id), + ); + + const result = initializeCurriculumPlacements(workbooks, tasksMapByIds); + const byId = new Map(result.map((placement) => [placement.workBookId, placement])); + + expect(byId.get(1)).toMatchObject({ + taskGrade: TaskGrade.Q10, + solutionCategory: null, + priority: 1, + }); + expect(byId.get(2)).toMatchObject({ + taskGrade: TaskGrade.Q9, + solutionCategory: null, + priority: 1, + }); + expect(byId.get(6)).toMatchObject({ + taskGrade: TaskGrade.Q8, + solutionCategory: null, + priority: 1, + }); + }); + + test('assigns ascending priorities within the same grade based on workbook id', () => { + // Two Q10 workbooks: id=1 ('標準入出力 1個') and id=7 ('if 文 ②') + // id=1 should get priority:1, id=7 should get priority:2 + const workbooks = curriculumWorkbooksForInit.filter((workbook) => [1, 7].includes(workbook.id)); + + const result = initializeCurriculumPlacements(workbooks, tasksMapByIds); + const byId = new Map(result.map((placement) => [placement.workBookId, placement])); + + expect(byId.get(1)).toMatchObject({ taskGrade: TaskGrade.Q10, priority: 1 }); + expect(byId.get(7)).toMatchObject({ taskGrade: TaskGrade.Q10, priority: 2 }); + }); +}); + +describe('groupWorkbooksByGrade', () => { + test('groups workbooks by mode grade and sorts IDs ascending within each group', () => { + const workbooks = [ + { id: 1, workBookTasks: [] }, + { id: 2, workBookTasks: [] }, + { id: 6, workBookTasks: [] }, + ]; + const gradeModes = new Map([ + [1, TaskGrade.Q10], + [2, TaskGrade.Q9], + [6, TaskGrade.Q10], + ]); + + const result = groupWorkbooksByGrade(workbooks, gradeModes); + + expect(result.get(TaskGrade.Q10)).toEqual([1, 6]); + expect(result.get(TaskGrade.Q9)).toEqual([2]); + }); + + test('returns empty map for empty input', () => { + expect(groupWorkbooksByGrade([], new Map()).size).toBe(0); + }); +}); + +describe('buildPlacementsFromGroups', () => { + test('assigns priority based on ID order within each grade group', () => { + const workbooks = [ + { id: 1, workBookTasks: [] }, + { id: 2, workBookTasks: [] }, + { id: 6, workBookTasks: [] }, + ]; + const gradeModes = new Map([ + [1, TaskGrade.Q10], + [2, TaskGrade.Q9], + [6, TaskGrade.Q10], + ]); + const byGrade = new Map([ + [TaskGrade.Q10, [1, 6]], + [TaskGrade.Q9, [2]], + ]); + + const result = buildPlacementsFromGroups(workbooks, gradeModes, byGrade); + const byId = new Map(result.map((placement) => [placement.workBookId, placement])); + + expect(byId.get(1)).toMatchObject({ taskGrade: TaskGrade.Q10, priority: 1 }); + expect(byId.get(6)).toMatchObject({ taskGrade: TaskGrade.Q10, priority: 2 }); + expect(byId.get(2)).toMatchObject({ taskGrade: TaskGrade.Q9, priority: 1 }); + }); + + test('sets solutionCategory to null for all records', () => { + const workbooks = [{ id: 1, workBookTasks: [] }]; + const gradeModes = new Map([[1, TaskGrade.Q10]]); + const byGrade = new Map([[TaskGrade.Q10, [1]]]); + + const result = buildPlacementsFromGroups(workbooks, gradeModes, byGrade); + + expect(result[0].solutionCategory).toBeNull(); + }); +}); + +describe('initializeSolutionPlacements', () => { + test('initializes all workbooks with PENDING and sequential priority', () => { + const workbooks = [{ id: 31 }, { id: 33 }]; + const result = initializeSolutionPlacements(workbooks); + + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ + workBookId: 31, + solutionCategory: SolutionCategory.PENDING, + taskGrade: null, + priority: 1, + }); + expect(result[1]).toMatchObject({ + workBookId: 33, + solutionCategory: SolutionCategory.PENDING, + taskGrade: null, + priority: 2, + }); + }); + + test('returns empty array for empty input', () => { + expect(initializeSolutionPlacements([])).toEqual([]); + }); + + test('assigns sequential priorities regardless of workbook id order', () => { + // Workbooks may arrive from DB in non-sequential ID order + const workbooks = [{ id: 40 }, { id: 31 }, { id: 33 }]; + const result = initializeSolutionPlacements(workbooks); + + expect(result[0]).toMatchObject({ workBookId: 40, priority: 1 }); + expect(result[1]).toMatchObject({ workBookId: 31, priority: 2 }); + expect(result[2]).toMatchObject({ workBookId: 33, priority: 3 }); + expect(result.every((placement) => placement.taskGrade === null)).toBe(true); + }); +}); diff --git a/src/features/workbooks/services/workbook_placements/initializers.ts b/src/features/workbooks/services/workbook_placements/initializers.ts new file mode 100644 index 000000000..f296522c4 --- /dev/null +++ b/src/features/workbooks/services/workbook_placements/initializers.ts @@ -0,0 +1,111 @@ +import { type Task, type TaskGrade } from '$lib/types/task'; +import { + SolutionCategory, + type WorkBooksWithTasks, + type PlacementCreates, + type UnplacedCurriculumRows, +} from '$features/workbooks/types/workbook_placement'; + +import { calcWorkBookGradeModes } from '$features/workbooks/utils/workbooks'; + +// --- Curriculum-specific initialization --- + +/** + * Builds a task lookup map from unplaced curriculum workbook rows. + * Stub tasks include only task_id and grade; other fields are left empty. + */ +export function buildTaskMapFromCurriculumRows( + workbooks: UnplacedCurriculumRows, +): Map { + return new Map( + workbooks + .flatMap((workbook) => workbook.workBookTasks) + .filter((workBookTask) => workBookTask.task !== null) + .map((workBookTask) => [ + workBookTask.task!.task_id, + { + task_id: workBookTask.task!.task_id, + contest_id: '', + task_table_index: '', + title: '', + grade: workBookTask.task!.grade, + }, + ]), + ); +} + +/** + * Converts unplaced curriculum DB rows into the shape expected by initializeCurriculumPlacements. + */ +export function buildCurriculumWorkbooksForInit( + workbooks: UnplacedCurriculumRows, +): WorkBooksWithTasks { + return workbooks.map((workbook) => ({ + id: workbook.id, + workBookTasks: workbook.workBookTasks.map((workBookTask) => ({ + taskId: workBookTask.task?.task_id ?? '', + priority: 0, + comment: '', + })), + })); +} + +/** + * Returns initial placement records for unplaced CURRICULUM workbooks. + * Each workbook is assigned the mode grade of its tasks, with priority + * determined by ascending workbook ID within each grade group. + */ +export function initializeCurriculumPlacements( + workbooks: WorkBooksWithTasks, + tasksMapByIds: Map, +): PlacementCreates { + const gradeModes = calcWorkBookGradeModes(workbooks, tasksMapByIds); + const byGrade = groupWorkbooksByGrade(workbooks, gradeModes); + return buildPlacementsFromGroups(workbooks, gradeModes, byGrade); +} + +/** + * Groups workbooks by their mode grade, sorted by workbook ID ascending within each group. + */ +export function groupWorkbooksByGrade( + workbooks: WorkBooksWithTasks, + gradeModes: Map, +): Map { + return workbooks.reduce((byGrade, workbook) => { + const grade = gradeModes.get(workbook.id)!; + const ids = [...(byGrade.get(grade) ?? []), workbook.id].sort((a, b) => a - b); + return byGrade.set(grade, ids); + }, new Map()); +} + +/** + * Builds PlacementCreate records from pre-grouped grade data. + * Priority is the 1-based index within each grade group (sorted by workbook ID). + */ +export function buildPlacementsFromGroups( + workbooks: WorkBooksWithTasks, + gradeModes: Map, + byGrade: Map, +): PlacementCreates { + return workbooks.map((workbook) => { + const grade = gradeModes.get(workbook.id)!; + const ids = byGrade.get(grade)!; + const priority = ids.indexOf(workbook.id) + 1; + return { workBookId: workbook.id, taskGrade: grade, solutionCategory: null, priority }; + }); +} + +// --- Solution-specific initialization --- + +/** + * Returns initial placement records for unplaced SOLUTION workbooks. + * All are placed in the PENDING category with sequential priority. + */ +export function initializeSolutionPlacements(workbooks: { id: number }[]): PlacementCreates { + return workbooks.map((workBook, i) => ({ + workBookId: workBook.id, + taskGrade: null, + solutionCategory: SolutionCategory.PENDING, + priority: i + 1, + })); +} diff --git a/src/features/workbooks/services/workbook_tasks.ts b/src/features/workbooks/services/workbook_tasks.ts index 3b209aa6a..d4dcbcfbe 100644 --- a/src/features/workbooks/services/workbook_tasks.ts +++ b/src/features/workbooks/services/workbook_tasks.ts @@ -1,21 +1,11 @@ -import type { - WorkBook, - WorkBookTaskBase, - WorkBookTasksBase, -} from '$features/workbooks/types/workbook'; +import type { WorkBook, WorkBookTasksBase } from '$features/workbooks/types/workbook'; -export async function getWorkBookTasks(workBook: Omit): Promise { - const workBookTasks: WorkBookTasksBase = await Promise.all( - workBook.workBookTasks.map(async (workBookTask: WorkBookTaskBase) => { - return { - taskId: workBookTask.taskId, - priority: workBookTask.priority, - comment: workBookTask.comment, - }; - }), - ); - - return workBookTasks; +export function getWorkBookTasks(workBook: Omit): WorkBookTasksBase { + return workBook.workBookTasks.map((workBookTask) => ({ + taskId: workBookTask.taskId, + priority: workBookTask.priority, + comment: workBookTask.comment, + })); } export function validateRequiredFields(workBookTasks: WorkBookTasksBase): void { diff --git a/src/features/workbooks/services/workbooks.ts b/src/features/workbooks/services/workbooks.ts index f6e69e45e..d24aacccf 100644 --- a/src/features/workbooks/services/workbooks.ts +++ b/src/features/workbooks/services/workbooks.ts @@ -115,7 +115,7 @@ export async function createWorkBook(workBook: Omit): Promise = { + PENDING: '未分類', + SEARCH_SIMULATION: '探索・シミュレーション・実装', + DYNAMIC_PROGRAMMING: '動的計画法', + DATA_STRUCTURE: 'データ構造', + GRAPH: 'グラフ', + TREE: '木', + NUMBER_THEORY: '数学(整数論)', + ALGEBRA: '数学(代数)', + COMBINATORICS: '数え上げ・確率・期待値', + GAME: 'ゲーム', + STRING: '文字列', + GEOMETRY: '幾何', + OPTIMIZATION: '最適化', + OTHERS: 'その他', + ANALYSIS: '考察テクニック', +}; + +// Admin only: Used for ordering of workbooks (curriculums and solution) +export type WorkBookPlacement = { + id: number; + workBookId: number; + taskGrade: TaskGrade | null; + solutionCategory: SolutionCategory | null; + priority: number; +}; + +export type WorkBookPlacements = WorkBookPlacement[]; + +// Input type for updating placements (id + fields to update) +export type PlacementInput = Pick< + WorkBookPlacement, + 'id' | 'taskGrade' | 'solutionCategory' | 'priority' +>; + +export type PlacementInputs = PlacementInput[]; + +// Shape of a placement record to be created in the database +export type PlacementCreate = { + workBookId: number; + taskGrade: TaskGrade | null; + solutionCategory: SolutionCategory | null; + priority: number; +}; + +export type PlacementCreates = PlacementCreate[]; + +// Workbook shape required by initializeCurriculumPlacements +export type WorkBookWithTasks = { + id: number; + workBookTasks: WorkBookTaskBase[]; +}; + +export type WorkBooksWithTasks = WorkBookWithTasks[]; + +// Shape of a curriculum workbook row queried for placement initialization +export type UnplacedCurriculumRow = { + id: number; + workBookTasks: { task: { task_id: string; grade: TaskGrade } | null }[]; +}; + +export type UnplacedCurriculumRows = UnplacedCurriculumRow[]; + +// Shape of workbooks returned from the load function for use in KanbanBoard +export type WorkbookWithPlacement = { + id: number; + title: string; + isPublished: boolean; + workBookType: string; + placement: WorkBookPlacement | null; +}; + +export type WorkbooksWithPlacement = WorkbookWithPlacement[]; diff --git a/src/features/workbooks/utils/workbook_tasks.ts b/src/features/workbooks/utils/workbook_tasks.ts index 19250d96c..4f9ccba1f 100644 --- a/src/features/workbooks/utils/workbook_tasks.ts +++ b/src/features/workbooks/utils/workbook_tasks.ts @@ -8,7 +8,7 @@ import type { WorkBookTasksEdit, } from '$features/workbooks/types/workbook'; -// Note: アプリの表示上、内部処理とも0-indexed +// Note: 0-indexed for both display and internal use export function generateWorkBookTaskOrders(workBookTaskCount: number) { return Array.from({ length: workBookTaskCount + 1 }, (_, index) => ({ name: index, @@ -18,7 +18,7 @@ export function generateWorkBookTaskOrders(workBookTaskCount: number) { export const PENDING = -1; -// Note: 初期値として、便宜的に割り当てている。随時、変更可能。 +// Note: Convenience default value; can be changed at any time. export const NO_COMMENT = ''; export function addTaskToWorkBook( @@ -27,14 +27,14 @@ export function addTaskToWorkBook( workBookTasksForTable: WorkBookTasksCreate | WorkBookTasksEdit, newWorkBookTaskIndex: number, ) { - // データベース用 + // For database const updatedWorkBookTasks = updateWorkBookTasks( workBookTasks, newWorkBookTaskIndex, selectedTask, ); - // アプリの表示用 + // For display const updatedWorkBookTasksForTable: WorkBookTasksCreate | WorkBookTasksEdit = updateWorkBookTaskForTable(workBookTasksForTable, newWorkBookTaskIndex, selectedTask); @@ -48,7 +48,7 @@ export function updateWorkBookTasks( ): WorkBookTasksBase { const newWorkBookTask: WorkBookTaskBase = { taskId: selectedTask.task_id, - priority: PENDING, // 1に近いほど優先度が高い + priority: PENDING, // Lower value = higher priority comment: NO_COMMENT, }; let updatedWorkBookTasks: WorkBookTasksBase = insertTaskToWorkBook( @@ -73,7 +73,7 @@ export function updateWorkBookTaskForTable( priority: PENDING, comment: NO_COMMENT, }; - // HACK: オーバーロードを定義しているにもかかわらず戻り値の型がWorkBookTasksBaseになってしまうため、やむを得ずキャスト + // HACK: Cast required despite overloads - TypeScript infers WorkBookTasksBase as the return type let updatedWorkBookTasksForTable: WorkBookTasksCreate | WorkBookTasksEdit = insertTaskToWorkBook( workBookTasksForTable, selectedIndex, @@ -86,7 +86,7 @@ export function updateWorkBookTaskForTable( return updatedWorkBookTasksForTable; } -// 関数のオーバーロードを定義 +// Function overloads function insertTaskToWorkBook( workBookTasks: WorkBookTasksBase, selectedIndex: number, @@ -107,9 +107,7 @@ function insertTaskToWorkBook( selectedIndex: number, newWorkBookTask: WorkBookTaskBase | WorkBookTaskCreate | WorkBookTaskEdit, ): WorkBookTasksBase | WorkBookTasksCreate | WorkBookTasksEdit { - // 範囲外のインデックスを指定された場合の仕様 - // 負の値: 先頭に追加 - // 元の配列よりも大きな値: 末尾に追加 + // Out-of-bounds behavior: negative index → prepend; index > length → append if (selectedIndex < 0) { selectedIndex = 0; } else if (selectedIndex > workBookTasks.length) { diff --git a/src/features/workbooks/utils/workbooks.test.ts b/src/features/workbooks/utils/workbooks.test.ts index bac75ff51..644427aee 100644 --- a/src/features/workbooks/utils/workbooks.test.ts +++ b/src/features/workbooks/utils/workbooks.test.ts @@ -1,9 +1,25 @@ import { expect, test } from 'vitest'; -import { canViewWorkBook, getUrlSlugFrom } from '$features/workbooks/utils/workbooks'; import { Roles } from '$lib/types/user'; +import { TaskGrade, type Task } from '$lib/types/task'; import { type WorkbookList, WorkBookType } from '$features/workbooks/types/workbook'; +import { + canViewWorkBook, + getUrlSlugFrom, + calcWorkBookGradeModes, +} from '$features/workbooks/utils/workbooks'; + +function createTask(taskId: string, grade: TaskGrade): Task { + return { + task_id: taskId, + contest_id: '', + task_table_index: '', + title: '', + grade, + }; +} + function createWorkBookListBase(overrides: Partial = {}): WorkbookList { return { id: 1, @@ -66,4 +82,96 @@ describe('Workbooks', () => { expect(getUrlSlugFrom(workbook)).toBe('999'); }); }); + + describe('calcWorkBookGradeModes', () => { + test('returns most frequent grade for each workbook', () => { + const tasksMapByIds: Map = new Map([ + ['abc322_d', createTask('abc322_d', TaskGrade.Q1)], + ['abc347_c', createTask('abc347_c', TaskGrade.Q1)], + ['abc307_c', createTask('abc307_c', TaskGrade.Q2)], + ]); + const workbooks = [ + createWorkBookListBase({ + id: 1, + workBookTasks: [ + { taskId: 'abc322_d', priority: 1, comment: '' }, + { taskId: 'abc347_c', priority: 2, comment: '' }, + { taskId: 'abc307_c', priority: 3, comment: '' }, + ], + }), + ]; + const result = calcWorkBookGradeModes(workbooks, tasksMapByIds); + expect(result.get(1)).toBe(TaskGrade.Q1); + }); + + test('returns PENDING for workbook without tasks', () => { + const tasksMapByIds: Map = new Map(); + const workbooks = [createWorkBookListBase({ id: 1, workBookTasks: [] })]; + const result = calcWorkBookGradeModes(workbooks, tasksMapByIds); + expect(result.get(1)).toBe(TaskGrade.PENDING); + }); + + test('returns PENDING for workbook with all PENDING tasks', () => { + const tasksMapByIds: Map = new Map([ + ['abc322_d', createTask('abc322_d', TaskGrade.PENDING)], + ['abc347_c', createTask('abc347_c', TaskGrade.PENDING)], + ['abc307_c', createTask('abc307_c', TaskGrade.PENDING)], + ]); + const workbooks = [ + createWorkBookListBase({ + id: 1, + workBookTasks: [ + { taskId: 'abc322_d', priority: 1, comment: '' }, + { taskId: 'abc347_c', priority: 2, comment: '' }, + { taskId: 'abc307_c', priority: 3, comment: '' }, + ], + }), + ]; + const result = calcWorkBookGradeModes(workbooks, tasksMapByIds); + expect(result.get(1)).toBe(TaskGrade.PENDING); + }); + + test('returns empty map for empty workbooks array', () => { + const tasksMapByIds: Map = new Map(); + const result = calcWorkBookGradeModes([], tasksMapByIds); + expect(result.size).toBe(0); + }); + + test('ignores tasks not found in tasksMapByIds', () => { + const tasksMapByIds: Map = new Map([ + ['abc322_d', createTask('abc322_d', TaskGrade.Q9)], + ]); + const workbooks = [ + createWorkBookListBase({ + id: 1, + workBookTasks: [ + { taskId: 'abc322_d', priority: 1, comment: '' }, + { taskId: 'missing', priority: 2, comment: '' }, + ], + }), + ]; + const result = calcWorkBookGradeModes(workbooks, tasksMapByIds); + expect(result.get(1)).toBe(TaskGrade.Q9); + }); + + test('handles multiple workbooks independently', () => { + const tasksMapByIds: Map = new Map([ + ['abc440_a', createTask('abc440_a', TaskGrade.Q8)], + ['abc425_a', createTask('abc425_a', TaskGrade.Q7)], + ]); + const workbooks = [ + createWorkBookListBase({ + id: 10, + workBookTasks: [{ taskId: 'abc440_a', priority: 1, comment: '' }], + }), + createWorkBookListBase({ + id: 20, + workBookTasks: [{ taskId: 'abc425_a', priority: 1, comment: '' }], + }), + ]; + const result = calcWorkBookGradeModes(workbooks, tasksMapByIds); + expect(result.get(10)).toBe(TaskGrade.Q8); + expect(result.get(20)).toBe(TaskGrade.Q7); + }); + }); }); diff --git a/src/features/workbooks/utils/workbooks.ts b/src/features/workbooks/utils/workbooks.ts index b816e7fa8..e314016cf 100644 --- a/src/features/workbooks/utils/workbooks.ts +++ b/src/features/workbooks/utils/workbooks.ts @@ -1,7 +1,9 @@ import { Roles } from '$lib/types/user'; -import type { WorkBook, WorkbookList } from '$features/workbooks/types/workbook'; +import { TaskGrade, type Task, type TaskGrades } from '$lib/types/task'; +import type { WorkBook, WorkbookList, WorkBookTaskBase } from '$features/workbooks/types/workbook'; import { isAdmin } from '$lib/utils/authorship'; +import { calcGradeMode } from '$lib/utils/task'; // 管理者 + ユーザ向けに公開されている場合 export function canViewWorkBook(role: Roles, isPublished: boolean) { @@ -19,3 +21,38 @@ export function getUrlSlugFrom(workbook: WorkbookList | WorkBook): string { return slug ? slug : workbook.id.toString(); } + +/** + * Calculates the grade modes for a list of workbooks in curriculum based on their tasks. + * + * @param workbooks - Workbooks with their task lists (only `id` and `workBookTasks` are used) + * @param tasksMapByIds - A map of task IDs to task objects + * + * @returns A map of workbook IDs to their corresponding grade modes + * @note The time complexity is O(N * M * log(M)), where N is the number of workbooks and M is the average number of tasks per workbook. + */ +export function calcWorkBookGradeModes( + workbooks: { id: number; workBookTasks: WorkBookTaskBase[] }[], + tasksMapByIds: Map, +): Map { + const gradeModes: Map = new Map(); + + workbooks.forEach((workbook: { id: number; workBookTasks: WorkBookTaskBase[] }) => { + const taskGrades = workbook.workBookTasks.reduce( + (results: TaskGrades, workBookTask: WorkBookTaskBase) => { + const task = tasksMapByIds.get(workBookTask.taskId); + + if (task && task.grade !== TaskGrade.PENDING) { + results.push(task.grade as TaskGrade); + } + + return results; + }, + [], + ); + + gradeModes.set(workbook.id, calcGradeMode(taskGrades)); + }); + + return gradeModes; +} diff --git a/src/features/workbooks/zod/schema.test.ts b/src/features/workbooks/zod/schema.test.ts index 4cc86dae7..a2e8d6e16 100644 --- a/src/features/workbooks/zod/schema.test.ts +++ b/src/features/workbooks/zod/schema.test.ts @@ -1,8 +1,11 @@ import { expect, test } from 'vitest'; import { type ZodSchema } from 'zod'; -import { workBookSchema } from '$features/workbooks/zod/schema'; +import { TaskGrade } from '$lib/types/task'; import { WorkBookType, type WorkBookTasks } from '$features/workbooks/types/workbook'; +import { SolutionCategory } from '$features/workbooks/types/workbook_placement'; + +import { workBookSchema, workBookPlacementSchema } from '$features/workbooks/zod/schema'; type WorkBook = { authorId: string; @@ -453,7 +456,7 @@ describe('workbook schema', () => { function validateWorkBookSchema(schema: ZodSchema, workbook: WorkBook) { const result = schema.safeParse(workbook); - expect(result.success).toBeFalsy(); + expect(result.success).toBe(false); } }); @@ -468,3 +471,109 @@ describe('workbook schema', () => { return 'abc' + randomNumber + '_' + letters[randomIndex]; } }); + +describe('workBookPlacementSchema', () => { + describe('a correct workbook placement is given', () => { + test('only taskGrade is non-null (CURRICULUM)', () => { + const result = workBookPlacementSchema.safeParse({ + id: 1, + priority: 1, + taskGrade: TaskGrade.Q10, + solutionCategory: null, + }); + expect(result.success).toBe(true); + }); + + test('only solutionCategory is non-null (SOLUTION)', () => { + const result = workBookPlacementSchema.safeParse({ + id: 1, + priority: 1, + taskGrade: null, + solutionCategory: SolutionCategory.GRAPH, + }); + expect(result.success).toBe(true); + }); + }); + + describe('an incorrect workbook placement is given', () => { + test('both null', () => { + const result = workBookPlacementSchema.safeParse({ + id: 1, + priority: 1, + taskGrade: null, + solutionCategory: null, + }); + expect(result.success).toBe(false); + }); + + test('both non-null', () => { + const result = workBookPlacementSchema.safeParse({ + id: 1, + priority: 1, + taskGrade: TaskGrade.Q10, + solutionCategory: SolutionCategory.GRAPH, + }); + expect(result.success).toBe(false); + }); + + test('invalid taskGrade', () => { + const result = workBookPlacementSchema.safeParse({ + id: 1, + priority: 1, + taskGrade: 'INVALID' as TaskGrade, + solutionCategory: null, + }); + expect(result.success).toBe(false); + }); + + test('invalid solutionCategory', () => { + const result = workBookPlacementSchema.safeParse({ + id: 1, + priority: 1, + taskGrade: null, + solutionCategory: 'INVALID' as SolutionCategory, + }); + expect(result.success).toBe(false); + }); + + test('priority of 0', () => { + const result = workBookPlacementSchema.safeParse({ + id: 1, + priority: 0, + taskGrade: TaskGrade.Q10, + solutionCategory: null, + }); + expect(result.success).toBe(false); + }); + + test('negative priority', () => { + const result = workBookPlacementSchema.safeParse({ + id: 1, + priority: -1, + taskGrade: null, + solutionCategory: SolutionCategory.GRAPH, + }); + expect(result.success).toBe(false); + }); + + test('fractional priority is rejected', () => { + const result = workBookPlacementSchema.safeParse({ + id: 1, + priority: 1.5, + taskGrade: TaskGrade.Q10, + solutionCategory: null, + }); + expect(result.success).toBe(false); + }); + + test('fractional id is rejected', () => { + const result = workBookPlacementSchema.safeParse({ + id: 1.5, + priority: 1, + taskGrade: null, + solutionCategory: SolutionCategory.GRAPH, + }); + expect(result.success).toBe(false); + }); + }); +}); diff --git a/src/features/workbooks/zod/schema.ts b/src/features/workbooks/zod/schema.ts index e20d3eb61..12496d35a 100644 --- a/src/features/workbooks/zod/schema.ts +++ b/src/features/workbooks/zod/schema.ts @@ -3,7 +3,11 @@ // https://regex101.com/ // https://qiita.com/mpyw/items/886218e7b418dfed254b import { z } from 'zod'; + +import { TaskGrade } from '$lib/types/task'; import { WorkBookType } from '$features/workbooks/types/workbook'; +import { SolutionCategory } from '$features/workbooks/types/workbook_placement'; + import { isValidUrl, isValidUrlSlug } from '$lib/utils/url'; const workBookTaskSchema = z.object({ @@ -57,3 +61,23 @@ export const workBookSchema = z.object({ .min(1, { error: '1問以上登録してください' }) .max(200, { error: '200問以下になるまで削除してください' }), }); + +export const workBookPlacementSchema = z + .object({ + id: z.number().int().positive(), + priority: z.number().int().positive(), + taskGrade: z.nativeEnum(TaskGrade).nullable(), + solutionCategory: z.nativeEnum(SolutionCategory).nullable(), + }) + // Note: XOR constraint: dual enforcement via Zod (early validation) and a CHECK in migration.sql (last line of defence). + // Prisma lacks @@check, so the SQL constraint is maintained manually. Keep both in sync. + .refine( + (value) => + (value.taskGrade !== null && value.solutionCategory === null) || + (value.taskGrade === null && value.solutionCategory !== null), + { error: 'taskGrade と solutionCategory は片方のみ設定できます' }, + ); + +export const updatePlacementsSchema = z.object({ + updates: z.array(workBookPlacementSchema), +}); diff --git a/src/lib/components/ContainerWrapper.svelte b/src/lib/components/ContainerWrapper.svelte index 58eef531a..e9eeafd83 100644 --- a/src/lib/components/ContainerWrapper.svelte +++ b/src/lib/components/ContainerWrapper.svelte @@ -3,12 +3,13 @@ interface Props { defaultWidth?: string; + lgWidth?: string; children?: Snippet; } - let { defaultWidth = 'w-5/6', children }: Props = $props(); + let { defaultWidth = 'w-5/6', lgWidth = 'lg:w-3/4', children }: Props = $props(); -
+
{@render children?.()}
diff --git a/src/lib/constants/http-response-status-codes.ts b/src/lib/constants/http-response-status-codes.ts index 879ddc52d..6444f42dc 100644 --- a/src/lib/constants/http-response-status-codes.ts +++ b/src/lib/constants/http-response-status-codes.ts @@ -1,6 +1,9 @@ // See: // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status +// Successful responses +export const OK = 200; + // Redirection messages export const FOUND = 302; export const SEE_OTHER = 303; diff --git a/src/lib/constants/navbar-links.ts b/src/lib/constants/navbar-links.ts index efd70cffb..fd8fcf0f4 100644 --- a/src/lib/constants/navbar-links.ts +++ b/src/lib/constants/navbar-links.ts @@ -10,6 +10,7 @@ export const PROBLEMS_PAGE = `/problems`; export const IMPORTING_PROBLEMS_PAGE = `/tasks`; export const TAGS_PAGE = `/tags`; export const ACCOUNT_TRANSFER_PAGE = `/account_transfer`; +export const WORKBOOKS_ORDER_PAGE = `/workbooks/order`; export const navbarLinks = [ { title: `ホーム`, path: HOME_PAGE }, @@ -22,6 +23,7 @@ export const navbarDashboardLinks = [ { title: `問題のインポート`, path: IMPORTING_PROBLEMS_PAGE }, { title: `一覧表`, path: PROBLEMS_PAGE }, { title: `問題集`, path: WORKBOOKS_PAGE }, + { title: `問題集(並び替え)`, path: WORKBOOKS_ORDER_PAGE }, { title: `タグ一覧`, path: TAGS_PAGE }, { title: `アカウント移行`, path: ACCOUNT_TRANSFER_PAGE }, ]; diff --git a/src/routes/(admin)/_utils/auth.ts b/src/routes/(admin)/_utils/auth.ts new file mode 100644 index 000000000..916262bda --- /dev/null +++ b/src/routes/(admin)/_utils/auth.ts @@ -0,0 +1,62 @@ +import { redirect, error } from '@sveltejs/kit'; + +import * as userService from '$lib/services/users'; + +import { Roles } from '$lib/types/user'; + +import { isAdmin } from '$lib/utils/authorship'; + +import { LOGIN_PAGE } from '$lib/constants/navbar-links'; +import { + TEMPORARY_REDIRECT, + UNAUTHORIZED, + FORBIDDEN, +} from '$lib/constants/http-response-status-codes'; + +enum AdminStatus { + OK = 'ok', + UNAUTHENTICATED = 'unauthenticated', + UNAUTHORIZED = 'unauthorized', +} + +/** + * Validates that the current session belongs to an admin user. + * Redirects to the login page if the session is missing or the user is not an admin. + */ +export async function validateAdminAccess(locals: App.Locals): Promise { + if ((await validateAdminStatus(locals)) !== AdminStatus.OK) { + redirect(TEMPORARY_REDIRECT, LOGIN_PAGE); + } +} + +/** + * Validates admin access for API routes (+server.ts). + * Throws error(401/403) instead of redirect so fetch() callers receive a proper HTTP status. + */ +export async function validateAdminAccessForApi(locals: App.Locals): Promise { + const status = await validateAdminStatus(locals); + + if (status === AdminStatus.UNAUTHENTICATED) { + error(UNAUTHORIZED); + } + + if (status === AdminStatus.UNAUTHORIZED) { + error(FORBIDDEN); + } +} + +async function validateAdminStatus(locals: App.Locals): Promise { + const session = await locals.auth.validate(); + + if (!session) { + return AdminStatus.UNAUTHENTICATED; + } + + const user = await userService.getUser(session.user.username); + + if (!user || !isAdmin(user.role as Roles)) { + return AdminStatus.UNAUTHORIZED; + } + + return AdminStatus.OK; +} diff --git a/src/routes/(admin)/workbooks/order/+page.server.ts b/src/routes/(admin)/workbooks/order/+page.server.ts new file mode 100644 index 000000000..df4010a8a --- /dev/null +++ b/src/routes/(admin)/workbooks/order/+page.server.ts @@ -0,0 +1,26 @@ +import { type Actions } from '@sveltejs/kit'; + +import { + getWorkbooksWithPlacements, + createInitialPlacements, +} from '$features/workbooks/services/workbook_placements/crud'; + +import { validateAdminAccess } from '../../_utils/auth'; + +export async function load({ locals }) { + await validateAdminAccess(locals); + + const workbooks = await getWorkbooksWithPlacements(); + const hasUnplacedWorkbooks = workbooks.some((workbook) => !workbook.placement); + + return { workbooks, hasUnplacedWorkbooks }; +} + +export const actions: Actions = { + initializePlacements: async ({ locals }) => { + await validateAdminAccess(locals); + await createInitialPlacements(); + + return { success: true }; + }, +}; diff --git a/src/routes/(admin)/workbooks/order/+page.svelte b/src/routes/(admin)/workbooks/order/+page.svelte new file mode 100644 index 000000000..c0f255669 --- /dev/null +++ b/src/routes/(admin)/workbooks/order/+page.svelte @@ -0,0 +1,28 @@ + + + +
+ + + {#if data.hasUnplacedWorkbooks} +
+ +
+ {/if} + + +
+
diff --git a/src/routes/(admin)/workbooks/order/+server.ts b/src/routes/(admin)/workbooks/order/+server.ts new file mode 100644 index 000000000..4e2549d4f --- /dev/null +++ b/src/routes/(admin)/workbooks/order/+server.ts @@ -0,0 +1,28 @@ +import { json, type RequestEvent } from '@sveltejs/kit'; + +import { validateAndUpdatePlacements } from '$features/workbooks/services/workbook_placements/crud'; + +import { updatePlacementsSchema } from '$features/workbooks/zod/schema'; + +import { validateAdminAccessForApi } from '../../_utils/auth'; + +import { BAD_REQUEST } from '$lib/constants/http-response-status-codes'; + +export async function POST({ request, locals }: RequestEvent) { + await validateAdminAccessForApi(locals); + + const body = await request.json(); + const parsed = updatePlacementsSchema.safeParse(body); + + if (!parsed.success) { + return json({ error: 'Invalid request body' }, { status: BAD_REQUEST }); + } + + const validationError = await validateAndUpdatePlacements(parsed.data.updates); + + if (validationError) { + return json(validationError, { status: BAD_REQUEST }); + } + + return json({ success: true }); +} diff --git a/src/routes/(admin)/workbooks/order/_components/ColumnSelector.svelte b/src/routes/(admin)/workbooks/order/_components/ColumnSelector.svelte new file mode 100644 index 000000000..7e11fb38d --- /dev/null +++ b/src/routes/(admin)/workbooks/order/_components/ColumnSelector.svelte @@ -0,0 +1,44 @@ + + +
+ {#each options as option} + {@const isSelected = selected.includes(option.value)} + + + {/each} +
diff --git a/src/routes/(admin)/workbooks/order/_components/KanbanBoard.svelte b/src/routes/(admin)/workbooks/order/_components/KanbanBoard.svelte new file mode 100644 index 000000000..1170c10cb --- /dev/null +++ b/src/routes/(admin)/workbooks/order/_components/KanbanBoard.svelte @@ -0,0 +1,177 @@ + + +{#if errorMessage} + (errorMessage = null)}> + {#snippet icon()} + + {/snippet} + + {errorMessage} + +{/if} + + + { + activeTab = tab; + updateUrl(); + }} + onSolutionCategoriesChange={(columns) => { + selectedSolutionCategories = columns; + updateUrl(); + }} + onGradesChange={(grades) => { + selectedGrades = grades; + updateUrl(); + }} + > + + {#snippet solutionBoard()} +
+ {#each displayedSolutionCategories as column} + + {/each} +
+ {/snippet} + + {#snippet curriculumBoard()} +
+ {#each selectedGrades as column} + + {/each} +
+ {/snippet} +
+
diff --git a/src/routes/(admin)/workbooks/order/_components/KanbanCard.svelte b/src/routes/(admin)/workbooks/order/_components/KanbanCard.svelte new file mode 100644 index 000000000..5029cfac4 --- /dev/null +++ b/src/routes/(admin)/workbooks/order/_components/KanbanCard.svelte @@ -0,0 +1,51 @@ + + +
+ + + +
diff --git a/src/routes/(admin)/workbooks/order/_components/KanbanColumn.svelte b/src/routes/(admin)/workbooks/order/_components/KanbanColumn.svelte new file mode 100644 index 000000000..4d642cf55 --- /dev/null +++ b/src/routes/(admin)/workbooks/order/_components/KanbanColumn.svelte @@ -0,0 +1,53 @@ + + +
+
+

{label}

+ {cards.length} +
+ +
+ {#each cards as card, i (card.id)} + + {:else} +

問題集をドロップ

+ {/each} +
+
diff --git a/src/routes/(admin)/workbooks/order/_components/KanbanTabBar.svelte b/src/routes/(admin)/workbooks/order/_components/KanbanTabBar.svelte new file mode 100644 index 000000000..3e8714ee6 --- /dev/null +++ b/src/routes/(admin)/workbooks/order/_components/KanbanTabBar.svelte @@ -0,0 +1,90 @@ + + + + onTabChange('solution')} + > + {@render solutionContent()} + + + onTabChange('curriculum')} + > + {@render curriculumContent()} + + + +{#snippet solutionContent()} +
+

+ 表示カテゴリ(1つ以上選択。「未分類」は常に表示): +

+ category !== SolutionCategory.PENDING, + )} + onchange={onSolutionCategoriesChange} + minRequired={1} + /> +
+ + {@render solutionBoard()} +{/snippet} + +{#snippet curriculumContent()} +
+

表示グレード(2つ以上選択):

+ + +
+ + {@render curriculumBoard()} +{/snippet} diff --git a/src/routes/(admin)/workbooks/order/_fixtures/kanban.ts b/src/routes/(admin)/workbooks/order/_fixtures/kanban.ts new file mode 100644 index 000000000..5940b9779 --- /dev/null +++ b/src/routes/(admin)/workbooks/order/_fixtures/kanban.ts @@ -0,0 +1,82 @@ +import { TaskGrade } from '$lib/types/task'; +import { WorkBookType } from '$features/workbooks/types/workbook'; +import { + SolutionCategory, + type WorkbooksWithPlacement, +} from '$features/workbooks/types/workbook_placement'; +import type { KanbanColumns } from '../_types/kanban'; + +// Workbooks used in buildKanbanItems tests. +// Covers SOLUTION (GRAPH, DYNAMIC_PROGRAMMING, PENDING), CURRICULUM (Q10), and null placement. +export const workbooks: WorkbooksWithPlacement = [ + { + id: 1, + title: 'Graph Basics', + isPublished: true, + workBookType: WorkBookType.SOLUTION, + placement: { + id: 101, + workBookId: 1, + solutionCategory: SolutionCategory.GRAPH, + taskGrade: null, + priority: 1, + }, + }, + { + id: 2, + title: 'DP Intro', + isPublished: false, + workBookType: WorkBookType.SOLUTION, + placement: { + id: 102, + workBookId: 2, + solutionCategory: SolutionCategory.DYNAMIC_PROGRAMMING, + taskGrade: null, + priority: 2, + }, + }, + { + id: 3, + title: 'Pending Book', + isPublished: true, + workBookType: WorkBookType.SOLUTION, + placement: { + id: 103, + workBookId: 3, + solutionCategory: SolutionCategory.PENDING, + taskGrade: null, + priority: 1, + }, + }, + { + id: 4, + title: 'Curriculum Q10', + isPublished: true, + workBookType: WorkBookType.CURRICULUM, + placement: { + id: 201, + workBookId: 4, + solutionCategory: null, + taskGrade: TaskGrade.Q10, + priority: 1, + }, + }, + { + id: 5, + title: 'No placement', + isPublished: true, + workBookType: WorkBookType.SOLUTION, + placement: null, + }, +]; + +// KanbanColumns snapshot used as the "before drag" baseline in reCalcPriorities tests. +export const solutionColumnsBefore: KanbanColumns = { + [SolutionCategory.GRAPH]: [ + { id: 101, workBookId: 1, title: 'Graph Basics', isPublished: true }, + { id: 102, workBookId: 2, title: 'Graph Advanced', isPublished: false }, + ], + [SolutionCategory.PENDING]: [ + { id: 103, workBookId: 3, title: 'Pending Book', isPublished: true }, + ], +}; diff --git a/src/routes/(admin)/workbooks/order/_types/kanban.ts b/src/routes/(admin)/workbooks/order/_types/kanban.ts new file mode 100644 index 000000000..26d81be46 --- /dev/null +++ b/src/routes/(admin)/workbooks/order/_types/kanban.ts @@ -0,0 +1,49 @@ +import type { DragDropManager, Draggable, Droppable } from '@dnd-kit/dom'; +import type { DragDropEvents } from '@dnd-kit/abstract'; + +// DnD event types derived from dnd-kit abstractions +type DndEvents = DragDropEvents; + +export type DragOverEventArg = Parameters[0]; +export type DragEndEventArg = Parameters[0]; + +export type ColumnKey = 'solutionCategory' | 'taskGrade'; + +export type ActiveTab = 'solution' | 'curriculum'; + +// Static per-tab configuration used to eliminate activeTab === 'solution' if-branches +export type TabConfig = { + labelFn: (column: string) => string; + group: string; + columnKey: ColumnKey; +}; + +export type KanbanColumns = Record; + +// Placement update sent to the server after a drag-and-drop operation +export type PlacementUpdate = { + id: number; + priority: number; + solutionCategory: string | null; + taskGrade: string | null; +}; + +export type PlacementUpdates = PlacementUpdate[]; + +// Props required for dnd-kit sortable positioning +export type SortableProps = { + columnId: string; // droppable zone ID (the column this card belongs to) + group: string; // dnd-kit type that restricts drop targets to the same board + index: number; // position within the column (required by dnd-kit sortable) +}; + +// Card used in the Kanban board (one card = one WorkBookPlacement) +// Column assignment is implicit in the Record key, not stored on the card. +export type Card = { + id: number; // placement.id + workBookId: number; + title: string; + isPublished: boolean; +}; + +export type Cards = Card[]; diff --git a/src/routes/(admin)/workbooks/order/_utils/kanban.test.ts b/src/routes/(admin)/workbooks/order/_utils/kanban.test.ts new file mode 100644 index 000000000..0bd533308 --- /dev/null +++ b/src/routes/(admin)/workbooks/order/_utils/kanban.test.ts @@ -0,0 +1,338 @@ +import { describe, test, expect } from 'vitest'; + +import { SolutionCategory } from '$features/workbooks/types/workbook_placement'; +import { TaskGrade } from '$lib/types/task'; + +import { workbooks, solutionColumnsBefore } from '../_fixtures/kanban'; +import { + buildKanbanItems, + buildUpdatedUrl, + parseInitialCategories, + parseInitialGrades, + parseTab, + reCalcPriorities, +} from './kanban'; + +describe('parseTab', () => { + test('returns the param when it is a valid tab key', () => { + expect(parseTab('solution')).toBe('solution'); + expect(parseTab('curriculum')).toBe('curriculum'); + }); + + test('falls back to solution for null', () => { + expect(parseTab(null)).toBe('solution'); + }); + + test('falls back to solution for an unknown string', () => { + expect(parseTab('unknown')).toBe('solution'); + expect(parseTab('')).toBe('solution'); + }); +}); + +describe('parseInitialCategories', () => { + test('returns parsed categories when param is present', () => { + const params = new URLSearchParams( + `categories=${SolutionCategory.GRAPH},${SolutionCategory.DYNAMIC_PROGRAMMING}`, + ); + expect(parseInitialCategories(params)).toEqual([ + SolutionCategory.GRAPH, + SolutionCategory.DYNAMIC_PROGRAMMING, + ]); + }); + + test('returns default categories when param is absent', () => { + const params = new URLSearchParams(); + expect(parseInitialCategories(params)).toEqual([ + SolutionCategory.PENDING, + SolutionCategory.GRAPH, + ]); + }); + + test('drops invalid category values', () => { + const params = new URLSearchParams(`categories=${SolutionCategory.GRAPH},INVALID`); + expect(parseInitialCategories(params)).toEqual([SolutionCategory.GRAPH]); + }); + + test('returns empty array when all values are invalid', () => { + const params = new URLSearchParams('categories=INVALID'); + expect(parseInitialCategories(params)).toEqual([]); + }); + + test('ignores empty strings from trailing commas', () => { + const params = new URLSearchParams(`categories=${SolutionCategory.GRAPH},`); + expect(parseInitialCategories(params)).toEqual([SolutionCategory.GRAPH]); + }); +}); + +describe('parseInitialGrades', () => { + test('returns parsed grades when param is present', () => { + const params = new URLSearchParams(`grades=${TaskGrade.Q10},${TaskGrade.Q9}`); + expect(parseInitialGrades(params)).toEqual([TaskGrade.Q10, TaskGrade.Q9]); + }); + + test('returns default grades when param is absent', () => { + const params = new URLSearchParams(); + expect(parseInitialGrades(params)).toEqual([TaskGrade.Q10, TaskGrade.Q9]); + }); + + test('drops PENDING even when explicitly included', () => { + const params = new URLSearchParams(`grades=${TaskGrade.PENDING},${TaskGrade.Q10}`); + expect(parseInitialGrades(params)).toEqual([TaskGrade.Q10]); + }); + + test('drops invalid grade values', () => { + const params = new URLSearchParams(`grades=${TaskGrade.Q10},INVALID`); + expect(parseInitialGrades(params)).toEqual([TaskGrade.Q10]); + }); + + test('returns empty array when all values are invalid or PENDING', () => { + const params = new URLSearchParams(`grades=${TaskGrade.PENDING},INVALID`); + expect(parseInitialGrades(params)).toEqual([]); + }); +}); + +describe('buildUpdatedUrl', () => { + const baseUrl = new URL('https://example.com/workbooks/order'); + + test('solution tab: sets tab and categories, deletes grades', () => { + const result = buildUpdatedUrl( + baseUrl, + 'solution', + [SolutionCategory.GRAPH, SolutionCategory.PENDING], + [], + ); + expect(result.searchParams.get('tab')).toBe('solution'); + expect(result.searchParams.get('categories')).toBe( + `${SolutionCategory.GRAPH},${SolutionCategory.PENDING}`, + ); + expect(result.searchParams.has('grades')).toBe(false); + }); + + test('curriculum tab: sets tab and grades, deletes categories', () => { + const result = buildUpdatedUrl(baseUrl, 'curriculum', [], [TaskGrade.Q10, TaskGrade.Q9]); + expect(result.searchParams.get('tab')).toBe('curriculum'); + expect(result.searchParams.get('grades')).toBe(`${TaskGrade.Q10},${TaskGrade.Q9}`); + expect(result.searchParams.has('categories')).toBe(false); + }); + + test('solution tab with empty categories produces empty string value', () => { + const result = buildUpdatedUrl(baseUrl, 'solution', [], []); + expect(result.searchParams.get('categories')).toBe(''); + }); + + test('overwrites existing params without duplicating', () => { + const url = new URL( + `https://example.com/workbooks/order?tab=curriculum&grades=${TaskGrade.Q10}&categories=${SolutionCategory.GRAPH}`, + ); + const result = buildUpdatedUrl(url, 'solution', [SolutionCategory.PENDING], []); + expect(result.searchParams.getAll('tab')).toHaveLength(1); + expect(result.searchParams.get('tab')).toBe('solution'); + expect(result.searchParams.has('grades')).toBe(false); + }); + + test('does not mutate the original URL', () => { + const url = new URL('https://example.com/workbooks/order?tab=curriculum'); + buildUpdatedUrl(url, 'solution', [SolutionCategory.GRAPH], []); + expect(url.searchParams.get('tab')).toBe('curriculum'); + }); +}); + +describe('buildKanbanItems', () => { + test('initializes all enum keys as empty arrays', () => { + const result = buildKanbanItems([], ['PENDING', 'GRAPH', 'DATA_STRUCTURE'], () => null); + expect(result).toEqual({ PENDING: [], GRAPH: [], DATA_STRUCTURE: [] }); + }); + + test('groups workbooks by solutionCategory', () => { + const result = buildKanbanItems( + workbooks, + [SolutionCategory.PENDING, SolutionCategory.GRAPH, SolutionCategory.DYNAMIC_PROGRAMMING], + (workbook) => workbook.placement?.solutionCategory ?? null, + ); + + expect(result[SolutionCategory.PENDING]).toHaveLength(1); + expect(result[SolutionCategory.PENDING][0]).toMatchObject({ + id: 103, + workBookId: 3, + title: 'Pending Book', + }); + expect(result[SolutionCategory.GRAPH]).toHaveLength(1); + expect(result[SolutionCategory.GRAPH][0]).toMatchObject({ + id: 101, + workBookId: 1, + title: 'Graph Basics', + }); + expect(result[SolutionCategory.DYNAMIC_PROGRAMMING]).toHaveLength(1); + expect(result[SolutionCategory.DYNAMIC_PROGRAMMING][0]).toMatchObject({ + id: 102, + workBookId: 2, + }); + }); + + test('excludes workbooks with null column key (no placement or wrong type)', () => { + const result = buildKanbanItems( + workbooks, + [TaskGrade.Q10, TaskGrade.Q9], + (workbook) => workbook.placement?.taskGrade ?? null, + ); + + expect(result[TaskGrade.Q10]).toHaveLength(1); + expect(result[TaskGrade.Q10][0]).toMatchObject({ + id: 201, + workBookId: 4, + title: 'Curriculum Q10', + }); + expect(result[TaskGrade.Q9]).toHaveLength(0); + }); + + test('sorts workbooks by placement priority within each column', () => { + // Two GRAPH workbooks inserted in reverse priority order: priority 2 first, priority 1 second. + const reversed = [ + { ...workbooks[0], placement: { ...workbooks[0].placement!, priority: 2 } }, + { + ...workbooks[0], + id: 99, + placement: { ...workbooks[0].placement!, id: 999, workBookId: 99, priority: 1 }, + }, + ]; + const result = buildKanbanItems( + reversed, + [SolutionCategory.GRAPH], + (workbook) => workbook.placement?.solutionCategory ?? null, + ); + expect(result[SolutionCategory.GRAPH][0].id).toBe(999); // priority 1 first + expect(result[SolutionCategory.GRAPH][1].id).toBe(101); // priority 2 second + }); + + test('card includes isPublished field', () => { + // workbooks[0]: Graph Basics, isPublished: true + const result = buildKanbanItems( + [workbooks[0]], + [SolutionCategory.GRAPH], + (workbook) => workbook.placement?.solutionCategory ?? null, + ); + expect(result[SolutionCategory.GRAPH][0].isPublished).toBe(true); + }); + + test('does not include workbooks where placement is null', () => { + // workbooks[4]: No placement, placement: null + const result = buildKanbanItems( + [workbooks[4]], + [SolutionCategory.GRAPH], + (workbook) => workbook.placement?.solutionCategory ?? null, + ); + expect(result[SolutionCategory.GRAPH]).toHaveLength(0); + }); +}); + +describe('reCalcPriorities', () => { + const before = solutionColumnsBefore; + + test('returns empty array when nothing changed', () => { + const after = structuredClone(before); + expect(reCalcPriorities(before, after, 'solutionCategory')).toEqual([]); + }); + + test('returns updates for reordered cards within a column', () => { + const after = { + ...before, + [SolutionCategory.GRAPH]: [ + before[SolutionCategory.GRAPH][1], + before[SolutionCategory.GRAPH][0], + ], // swapped + }; + + const updates = reCalcPriorities(before, after, 'solutionCategory'); + expect(updates).toHaveLength(2); + expect(updates[0]).toMatchObject({ + id: 102, + priority: 1, + solutionCategory: SolutionCategory.GRAPH, + taskGrade: null, + }); + expect(updates[1]).toMatchObject({ + id: 101, + priority: 2, + solutionCategory: SolutionCategory.GRAPH, + taskGrade: null, + }); + }); + + test('returns updates only for changed columns', () => { + const after = { + [SolutionCategory.GRAPH]: [ + before[SolutionCategory.GRAPH][1], + before[SolutionCategory.GRAPH][0], + ], // changed + [SolutionCategory.PENDING]: before[SolutionCategory.PENDING], // unchanged + }; + + const updates = reCalcPriorities(before, after, 'solutionCategory'); + const updatedIds = updates.map((update) => update.id); + expect(updatedIds).not.toContain(103); + expect(updatedIds).toContain(101); + expect(updatedIds).toContain(102); + }); + + test('sets taskGrade instead of solutionCategory when columnKey is taskGrade', () => { + const gradeBefore = { + [TaskGrade.Q10]: [{ id: 201, workBookId: 4, title: 'Q10 Book', isPublished: true }], + [TaskGrade.Q9]: [{ id: 202, workBookId: 5, title: 'Q9 Book', isPublished: true }], + }; + const gradeAfter = { + [TaskGrade.Q10]: [{ id: 202, workBookId: 5, title: 'Q9 Book', isPublished: true }], // moved from Q9 + [TaskGrade.Q9]: [{ id: 201, workBookId: 4, title: 'Q10 Book', isPublished: true }], + }; + + const updates = reCalcPriorities(gradeBefore, gradeAfter, 'taskGrade'); + expect(updates.every((update) => update.solutionCategory === null)).toBe(true); + expect(updates.find((update) => update.id === 202)).toMatchObject({ + taskGrade: TaskGrade.Q10, + solutionCategory: null, + }); + }); + + test('returns updates for columns missing from before (new column)', () => { + const updates = reCalcPriorities( + {}, + { + [SolutionCategory.GRAPH]: [{ id: 101, workBookId: 1, title: 'Test', isPublished: true }], + }, + 'solutionCategory', + ); + expect(updates).toHaveLength(1); + expect(updates[0]).toMatchObject({ + id: 101, + priority: 1, + solutionCategory: SolutionCategory.GRAPH, + }); + }); + + test('returns updates for both columns when a card is moved across columns', () => { + const crossBefore = { + [SolutionCategory.GRAPH]: [ + { id: 101, workBookId: 1, title: 'Graph Basics', isPublished: true }, + ], + [SolutionCategory.PENDING]: [], + }; + const crossAfter = { + [SolutionCategory.GRAPH]: [], + [SolutionCategory.PENDING]: [ + { id: 101, workBookId: 1, title: 'Graph Basics', isPublished: true }, + ], + }; + + const updates = reCalcPriorities(crossBefore, crossAfter, 'solutionCategory'); + // GRAPH is now empty (changed), PENDING gained a card (changed) → both produce updates + expect(updates).toHaveLength(1); + expect(updates[0]).toMatchObject({ + id: 101, + priority: 1, + solutionCategory: SolutionCategory.PENDING, + }); + }); + + test('returns empty array when after is empty and before is empty', () => { + expect(reCalcPriorities({}, {}, 'solutionCategory')).toEqual([]); + }); +}); diff --git a/src/routes/(admin)/workbooks/order/_utils/kanban.ts b/src/routes/(admin)/workbooks/order/_utils/kanban.ts new file mode 100644 index 000000000..f2be07383 --- /dev/null +++ b/src/routes/(admin)/workbooks/order/_utils/kanban.ts @@ -0,0 +1,191 @@ +import { TaskGrade } from '$lib/types/task'; +import { + SolutionCategory, + SOLUTION_LABELS, + type WorkbooksWithPlacement, + type WorkbookWithPlacement, +} from '$features/workbooks/types/workbook_placement'; +import type { + ActiveTab, + KanbanColumns, + ColumnKey, + PlacementUpdates, + TabConfig, +} from '../_types/kanban'; + +import { getTaskGradeLabel } from '$lib/utils/task'; + +// Per-tab static configuration; eliminates activeTab === 'solution' branches in DnD handlers +export const TAB_CONFIGS: Record = { + solution: { + labelFn: (column) => SOLUTION_LABELS[column] ?? column, + group: 'solution', + columnKey: 'solutionCategory', + }, + curriculum: { + labelFn: getTaskGradeLabel, + group: 'curriculum', + columnKey: 'taskGrade', + }, +}; + +/** + * Parses the `tab` search param into a valid {@link ActiveTab}. + * + * @param param - Raw value of the `tab` search param, or `null` if absent. + * @returns The matching `ActiveTab`, or `'solution'` for unknown/missing values. + */ +export function parseTab(param: string | null): ActiveTab { + if (param !== null && Object.hasOwn(TAB_CONFIGS, param)) { + return param as ActiveTab; + } + + return 'solution'; +} + +// Default columns shown when no 'categories' param is present in the URL. +const DEFAULT_SOLUTION_CATEGORIES: string[] = [SolutionCategory.PENDING, SolutionCategory.GRAPH]; + +/** + * Parses the `categories` search param into a validated list of {@link SolutionCategory} keys. + * + * @param params - The URL search params to read from. + * @returns Validated category keys. Falls back to `DEFAULT_SOLUTION_CATEGORIES` when the param is + * absent; invalid values are silently dropped. + */ +export function parseInitialCategories(params: URLSearchParams): string[] { + return ( + params.get('categories')?.split(',').filter(Boolean) ?? DEFAULT_SOLUTION_CATEGORIES + ).filter((category) => Object.hasOwn(SolutionCategory, category)); +} + +// Default grades shown when no 'grades' param is present in the URL. +const DEFAULT_GRADES: string[] = [TaskGrade.Q10, TaskGrade.Q9]; + +/** + * Parses the `grades` search param into a validated list of {@link TaskGrade} keys. + * + * @param params - The URL search params to read from. + * @returns Validated grade keys. Falls back to `DEFAULT_GRADES` when the param is absent; + * `PENDING` and invalid values are silently dropped. + */ +export function parseInitialGrades(params: URLSearchParams): string[] { + return (params.get('grades')?.split(',').filter(Boolean) ?? DEFAULT_GRADES).filter( + (grade) => Object.hasOwn(TaskGrade, grade) && grade !== TaskGrade.PENDING, + ); +} + +/** + * Returns a new URL with tab/category/grade search params updated. + * Pure function — does not call replaceState. + */ +export function buildUpdatedUrl( + url: URL, + activeTab: ActiveTab, + selectedSolutionCategories: string[], + selectedGrades: string[], +): URL { + const updatedUrl = new URL(url); + updatedUrl.searchParams.set('tab', activeTab); + + if (activeTab === 'solution') { + updatedUrl.searchParams.set('categories', selectedSolutionCategories.join(',')); + updatedUrl.searchParams.delete('grades'); + } else { + updatedUrl.searchParams.set('grades', selectedGrades.join(',')); + updatedUrl.searchParams.delete('categories'); + } + + return updatedUrl; +} + +/** + * Builds a KanbanColumns record from a list of workbooks. + * + * @param workbooks - Workbooks with their placement data + * @param enumKeys - All column keys to initialize (including empty columns) + * @param getColumnKey - Extracts the column key from a workbook; returns null to exclude + * + * @returns A record mapping column keys to arrays of cards, sorted by priority + */ +export function buildKanbanItems( + workbooks: WorkbooksWithPlacement, + columnKeys: string[], + getColumnKey: (workbook: WorkbookWithPlacement) => string | null, +): KanbanColumns { + const record: KanbanColumns = {}; + + for (const key of columnKeys) { + record[key] = []; + } + + workbooks + .filter((workbook) => workbook.placement !== null && getColumnKey(workbook) !== null) + .sort((a, b) => a.placement!.priority - b.placement!.priority) + .forEach((workbook) => { + const column = getColumnKey(workbook)!; + + record[column].push({ + id: workbook.placement!.id, + workBookId: workbook.id, + title: workbook.title, + isPublished: workbook.isPublished, + }); + }); + + return record; +} + +/** + * Compares two KanbanColumns snapshots and returns the placement updates needed + * to persist the new ordering to the server. + * + * @param before - Snapshot taken before the drag operation + * @param after - Current state after the drag operation + * @param columnKey - Which placement field ('solutionCategory' | 'taskGrade') to set + */ +export function reCalcPriorities( + before: KanbanColumns, + after: KanbanColumns, + columnKey: ColumnKey, +): PlacementUpdates { + // The object literal sets both fields to null, then [columnKey]: columnId overrides one. + // JavaScript evaluates the object as a single expression, so the final value always has + // exactly one field set to columnId. workBookPlacementSchema also enforces this invariant + // at the API boundary before any DB write occurs. + return Object.entries(after).flatMap(([columnId, cards]) => { + const snapCards = before[columnId]; + const isUpdated = + !snapCards || + cards.length !== snapCards.length || + cards.some((card, i) => card.id !== snapCards[i]?.id); + + if (!isUpdated) { + return []; + } + + return cards.map((card, i) => ({ + id: card.id, + priority: i + 1, + solutionCategory: null, + taskGrade: null, + [columnKey]: columnId, + })); + }); +} + +/** + * Sends placement updates to the server. + * Throws if the response is not OK. + */ +export async function saveUpdates(updates: PlacementUpdates): Promise { + const response = await fetch('/workbooks/order', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ updates }), + }); + + if (!response.ok) { + throw new Error('Failed to save'); + } +} diff --git a/src/routes/workbooks/+page.server.ts b/src/routes/workbooks/+page.server.ts index da8f8ef19..a2e7b046a 100644 --- a/src/routes/workbooks/+page.server.ts +++ b/src/routes/workbooks/+page.server.ts @@ -36,7 +36,7 @@ export async function load({ locals }) { try { // 問題集を構成する問題のグレードの最頻値を取得するために使用 - const tasksByTaskId = await taskCrud.getTasksByTaskId(); + const tasksMapByIds = await taskCrud.getTasksByTaskId(); // ユーザの回答状況を表示するために使用 const taskResultsByTaskId = await taskResultsCrud.getTaskResultsOnlyResultExists( loggedInUser?.id as string, @@ -45,7 +45,7 @@ export async function load({ locals }) { return { workbooks: workbooksWithAuthors, - tasksByTaskId: tasksByTaskId, + tasksMapByIds: tasksMapByIds, taskResultsByTaskId: taskResultsByTaskId, loggedInUser: loggedInUser, }; diff --git a/src/routes/workbooks/+page.svelte b/src/routes/workbooks/+page.svelte index a64fa2f55..5cdd6cc0c 100644 --- a/src/routes/workbooks/+page.svelte +++ b/src/routes/workbooks/+page.svelte @@ -3,13 +3,7 @@ import { Button, Tabs } from 'flowbite-svelte'; import { Roles } from '$lib/types/user'; - import { - type Task, - TaskGrade, - type TaskGrades, - type TaskResult, - type TaskResults, - } from '$lib/types/task'; + import { type Task, type TaskResult, type TaskResults } from '$lib/types/task'; import { type WorkbookList, type WorkbooksList, @@ -23,8 +17,7 @@ import WorkbookTabItem from '$features/workbooks/components/list/WorkbookTabItem.svelte'; import WorkBookList from '$features/workbooks/components/list/WorkBookList.svelte'; - import { calcGradeMode } from '$lib/utils/task'; - import { canViewWorkBook } from '$features/workbooks/utils/workbooks'; + import { canViewWorkBook, calcWorkBookGradeModes } from '$features/workbooks/utils/workbooks'; let { data } = $props(); @@ -66,34 +59,10 @@ return get(activeWorkbookTabStore).get(workBookType); }; - const tasksByTaskId: Map = data.tasksByTaskId; + const tasksMapByIds: Map = data.tasksMapByIds; let taskResultsByTaskId = data.taskResultsByTaskId as Map; - // 計算量: 問題集の数をN、各問題集の問題の平均値をMとすると、O(N * M * log(M)) - const getWorkBookGradeModes = (workbooks: WorkbooksList): Map => { - const gradeModes: Map = new Map(); - - workbooks.forEach((workbook: WorkbookList) => { - const taskGrades = workbook.workBookTasks.reduce( - (results: TaskGrades, workBookTask: WorkBookTaskBase) => { - const task = tasksByTaskId.get(workBookTask.taskId); - - if (task && task.grade !== TaskGrade.PENDING) { - results.push(task.grade as TaskGrade); - } - return results; - }, - [], - ); - - const gradeMode = calcGradeMode(taskGrades as TaskGrades); - gradeModes.set(workbook.id, gradeMode); - }); - - return gradeModes; - }; - - const workbookGradeModes = getWorkBookGradeModes(data.workbooks as WorkbooksList); + const workbookGradeModes = calcWorkBookGradeModes(data.workbooks as WorkbooksList, tasksMapByIds); // 計算量: 問題集の数をN、各問題集の問題の平均値をMとすると、O(N * M) function fetchTaskResultsWithWorkBookId(workbooks: WorkbooksList, workBookType: WorkBookType) { diff --git a/tests/workbook_order.test.ts b/tests/workbook_order.test.ts new file mode 100644 index 000000000..3d338ff56 --- /dev/null +++ b/tests/workbook_order.test.ts @@ -0,0 +1,338 @@ +import { test, expect, Page } from '@playwright/test'; + +import { SolutionCategory } from '../src/features/workbooks/types/workbook_placement'; +import { TaskGrade } from '../src/lib/types/task'; + +import { BAD_REQUEST, OK } from '../src/lib/constants/http-response-status-codes'; + +const TIMEOUT = 60 * 1000; +const ORDER_URL = '/workbooks/order'; + +test.describe('access control', () => { + test('unauthenticated user is redirected to /login', async ({ page }) => { + await page.goto(ORDER_URL); + await expect(page).toHaveURL('/login', { timeout: TIMEOUT }); + }); +}); + +test.describe('workbook order page', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test('reordering within the same column persists after reload', async ({ page }) => { + await page.goto(`${ORDER_URL}?tab=solution&categories=${SolutionCategory.PENDING}`); + await expect(page.getByRole('heading', { name: '未分類' })).toBeVisible({ timeout: TIMEOUT }); + + const cards = await getCardsInColumn(page, SolutionCategory.PENDING); + + if (cards.length < 2) { + test.skip(); + return; + } + + const [first, second] = cards; + + // Swap the first two cards via API + await postUpdates(page, [ + { + id: first.placementId, + priority: 2, + solutionCategory: SolutionCategory.PENDING, + taskGrade: null, + }, + { + id: second.placementId, + priority: 1, + solutionCategory: SolutionCategory.PENDING, + taskGrade: null, + }, + ]); + + try { + await page.reload(); + await expect(page.getByRole('heading', { name: '未分類' })).toBeVisible({ timeout: TIMEOUT }); + + const reloaded = await getCardsInColumn(page, SolutionCategory.PENDING); + expect(reloaded[0].placementId).toBe(second.placementId); + expect(reloaded[1].placementId).toBe(first.placementId); + } finally { + // Restore + await postUpdates(page, [ + { + id: first.placementId, + priority: 1, + solutionCategory: SolutionCategory.PENDING, + taskGrade: null, + }, + { + id: second.placementId, + priority: 2, + solutionCategory: SolutionCategory.PENDING, + taskGrade: null, + }, + ]); + } + }); + + test('moving a card to a different column persists after reload', async ({ page }) => { + await page.goto( + `${ORDER_URL}?tab=solution&categories=${SolutionCategory.PENDING},${SolutionCategory.GRAPH}`, + ); + await expect(page.getByRole('heading', { name: '未分類' })).toBeVisible({ timeout: TIMEOUT }); + + const pendingCards = await getCardsInColumn(page, SolutionCategory.PENDING); + + if (pendingCards.length === 0) { + test.skip(); + return; + } + + const pendingCard = pendingCards[0]; + + // Move card to GRAPH via API + await postUpdates(page, [ + { + id: pendingCard.placementId, + priority: 1, + solutionCategory: SolutionCategory.GRAPH, + taskGrade: null, + }, + ]); + + try { + await page.reload(); + await expect(page.getByRole('heading', { name: 'グラフ' })).toBeVisible({ timeout: TIMEOUT }); + + const graphCards = await getCardsInColumn(page, SolutionCategory.GRAPH); + expect(graphCards.some((card) => card.placementId === pendingCard.placementId)).toBe(true); + } finally { + // Restore to original PENDING column + await postUpdates(page, [ + { + id: pendingCard.placementId, + priority: 1, + solutionCategory: SolutionCategory.PENDING, + taskGrade: null, + }, + ]); + } + }); + + test('reordering in curriculum tab persists after reload', async ({ page }) => { + await page.goto(`${ORDER_URL}?tab=curriculum&grades=${TaskGrade.Q10},${TaskGrade.Q9}`); + await expect(page.getByRole('heading', { name: '10Q' })).toBeVisible({ timeout: TIMEOUT }); + + const cards = await getCardsInColumn(page, TaskGrade.Q10); + + if (cards.length < 2) { + test.skip(); + return; + } + + const [first, second] = cards; + + // Swap via API + await postUpdates(page, [ + { id: first.placementId, priority: 2, solutionCategory: null, taskGrade: TaskGrade.Q10 }, + { id: second.placementId, priority: 1, solutionCategory: null, taskGrade: TaskGrade.Q10 }, + ]); + + try { + await page.reload(); + await expect(page.getByRole('heading', { name: '10Q' })).toBeVisible({ timeout: TIMEOUT }); + + const reloaded = await getCardsInColumn(page, TaskGrade.Q10); + expect(reloaded[0].placementId).toBe(second.placementId); + expect(reloaded[1].placementId).toBe(first.placementId); + } finally { + // Restore + await postUpdates(page, [ + { id: first.placementId, priority: 1, solutionCategory: null, taskGrade: TaskGrade.Q10 }, + { id: second.placementId, priority: 2, solutionCategory: null, taskGrade: TaskGrade.Q10 }, + ]); + } + }); + + test('switching from solution to curriculum tab removes categories from URL', async ({ + page, + }) => { + await page.goto( + `${ORDER_URL}?tab=solution&categories=${SolutionCategory.PENDING},${SolutionCategory.GRAPH}&grades=${TaskGrade.Q10},${TaskGrade.Q9}`, + ); + await expect(page.getByRole('heading', { name: '未分類' })).toBeVisible({ timeout: TIMEOUT }); + + await page.getByRole('tab', { name: 'カリキュラム' }).click(); + + const url = new URL(page.url()); + expect(url.searchParams.has('categories')).toBe(false); + expect(url.searchParams.get('tab')).toBe('curriculum'); + }); + + test('switching from curriculum to solution tab removes grades from URL', async ({ page }) => { + await page.goto( + `${ORDER_URL}?tab=curriculum&categories=${SolutionCategory.PENDING},${SolutionCategory.GRAPH}&grades=${TaskGrade.Q10},${TaskGrade.Q9}`, + ); + await expect(page.getByRole('heading', { name: '10Q' })).toBeVisible({ timeout: TIMEOUT }); + + await page.getByRole('tab', { name: '解法別' }).click(); + + const url = new URL(page.url()); + expect(url.searchParams.has('grades')).toBe(false); + expect(url.searchParams.get('tab')).toBe('solution'); + }); + + test('renders solution tab with PENDING and GRAPH columns by default when accessing without query string', async ({ + page, + }) => { + await page.goto(ORDER_URL); + // Default state: solution tab active, PENDING (未分類) and GRAPH (グラフ) columns visible + await expect(page.getByRole('heading', { name: '未分類' })).toBeVisible({ timeout: TIMEOUT }); + await expect(page.getByRole('heading', { name: 'グラフ' })).toBeVisible({ timeout: TIMEOUT }); + }); + + test('clicking a category button toggles the column and updates URL', async ({ page }) => { + // Start with two selectable columns (GRAPH + DATA_STRUCTURE) so minRequired=1 allows deselection + await page.goto( + `${ORDER_URL}?tab=solution&categories=${SolutionCategory.PENDING},${SolutionCategory.GRAPH},${SolutionCategory.DATA_STRUCTURE}`, + ); + await expect(page.getByRole('heading', { name: 'グラフ' })).toBeVisible({ timeout: TIMEOUT }); + await expect(page.getByRole('heading', { name: 'データ構造' })).toBeVisible({ + timeout: TIMEOUT, + }); + + // Deselect DATA_STRUCTURE — GRAPH remains so minRequired is satisfied + await page.getByRole('button', { name: 'データ構造' }).click(); + + // データ構造 column should disappear + await expect(page.getByRole('heading', { name: 'データ構造' })).not.toBeVisible(); + + // URL should reflect the new selection + const url = new URL(page.url()); + const categories = url.searchParams.get('categories') ?? ''; + expect(categories.split(',')).not.toContain(SolutionCategory.DATA_STRUCTURE); + expect(categories.split(',')).toContain(SolutionCategory.GRAPH); + }); +}); + +test.describe('API error handling', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + await page.goto(ORDER_URL); + await expect(page.getByRole('heading', { name: '未分類' })).toBeVisible({ timeout: TIMEOUT }); + }); + + test('non-existent placement id returns 400', async ({ page }) => { + const status = await postRaw(page, { + updates: [ + { id: 999999999, priority: 1, solutionCategory: SolutionCategory.PENDING, taskGrade: null }, + ], + }); + expect(status).toBe(BAD_REQUEST); + }); + + test('invalid request body (missing required fields) returns 400', async ({ page }) => { + const status = await postRaw(page, { updates: [{ id: 1 }] }); + expect(status).toBe(BAD_REQUEST); + }); + + test('invalid request body (wrong type for updates) returns 400', async ({ page }) => { + const status = await postRaw(page, { updates: 'not-an-array' }); + expect(status).toBe(BAD_REQUEST); + }); + + test('CURRICULUM↔SOLUTION cross-type move returns 400', async ({ page }) => { + // Find a CURRICULUM placement from the board + await page.goto(`${ORDER_URL}?tab=curriculum&grades=${TaskGrade.Q10}`); + await expect(page.getByRole('heading', { name: '10Q' })).toBeVisible({ timeout: TIMEOUT }); + + const cards = await getCardsInColumn(page, TaskGrade.Q10); + + if (cards.length === 0) { + test.skip(); + return; + } + + // Attempt to set solutionCategory on a CURRICULUM placement → should be rejected + const status = await postRaw(page, { + updates: [ + { + id: cards[0].placementId, + priority: 1, + solutionCategory: SolutionCategory.PENDING, + taskGrade: null, + }, + ], + }); + expect(status).toBe(BAD_REQUEST); + }); +}); + +// Helper functions +async function loginAsAdmin(page: Page): Promise { + const ADMIN_USERNAME = 'admin'; + const ADMIN_PASSWORD = 'Ch0kuda1'; + + await page.goto('/login'); + await expect(page).toHaveURL('/login', { timeout: TIMEOUT }); + await page.locator('input[name="username"]').fill(ADMIN_USERNAME); + await page.locator('input[name="password"]').fill(ADMIN_PASSWORD); + await page.getByRole('button', { name: 'ログイン' }).nth(1).click(); + await expect(page).toHaveURL('/', { timeout: TIMEOUT }); +} + +async function getCardsInColumn( + page: Page, + columnId: string, +): Promise<{ title: string; placementId: number }[]> { + const cards = page.locator(`[data-testid="column-${columnId}"] [data-placement-id]`); + const count = await cards.count(); + const result: { title: string; placementId: number }[] = []; + + for (let i = 0; i < count; i++) { + const card = cards.nth(i); + const title = (await card.textContent()) ?? ''; + const id = await card.getAttribute('data-placement-id'); + result.push({ title: title.trim(), placementId: Number(id) }); + } + + return result; +} + +async function postUpdates( + page: Page, + updates: { + id: number; + priority: number; + solutionCategory: SolutionCategory | null; + taskGrade: TaskGrade | null; + }[], +): Promise { + const status = await page.evaluate( + async ({ url, body }) => { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return res.status; + }, + { url: ORDER_URL, body: { updates } }, + ); + expect(status).toBe(OK); +} + +async function postRaw(page: Page, body: unknown): Promise { + return page.evaluate( + async ({ url, body }) => { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + return response.status; + }, + { url: ORDER_URL, body }, + ); +}