From 385b251cc751fb946b8dd43099378001ea9b04c9 Mon Sep 17 00:00:00 2001 From: Ken Yasue Date: Thu, 25 Jun 2026 01:54:24 +0200 Subject: [PATCH] feat(m9): todo/kanban with DnD, assignee notify, completion, tests, e2e - TodoRepository (column CRUD, item CRUD, soft-delete, ordering, maxOrderIndex, project isolation) + TodoService (lazy standard-column init, member/admin permission, create/update/move/toggleComplete/delete, todo_assigned notify, todo_created/updated/completed activity logs, SSE todo.updated) - APIs: columns + items (GET/POST/PATCH/DELETE), toggleComplete + move - KanbanBoard client (HTML5 drag&drop move, new task per column, complete), todos page, ProjectNav ToDo link - Unit tests (TodoRepository, TodoService) + e2e todo-kanban --- .../todos/columns/[columnId]/route.ts | 49 ++++ .../[projectId]/todos/columns/route.ts | 51 ++++ .../[projectId]/todos/items/[itemId]/route.ts | 76 +++++ .../projects/[projectId]/todos/items/route.ts | 60 ++++ app/projects/[projectId]/todos/page.tsx | 49 ++++ components/layout/ProjectNav.tsx | 3 + components/todo/KanbanBoard.tsx | 162 +++++++++++ lib/api/services.ts | 13 + repositories/TodoRepository.ts | 274 +++++++++++++++++ services/TodoService.ts | 275 ++++++++++++++++++ tests/e2e/todo-kanban.spec.ts | 79 +++++ .../unit/repositories/TodoRepository.test.ts | 144 +++++++++ tests/unit/services/TodoService.test.ts | 164 +++++++++++ 13 files changed, 1399 insertions(+) create mode 100644 app/api/projects/[projectId]/todos/columns/[columnId]/route.ts create mode 100644 app/api/projects/[projectId]/todos/columns/route.ts create mode 100644 app/api/projects/[projectId]/todos/items/[itemId]/route.ts create mode 100644 app/api/projects/[projectId]/todos/items/route.ts create mode 100644 app/projects/[projectId]/todos/page.tsx create mode 100644 components/todo/KanbanBoard.tsx create mode 100644 repositories/TodoRepository.ts create mode 100644 services/TodoService.ts create mode 100644 tests/e2e/todo-kanban.spec.ts create mode 100644 tests/unit/repositories/TodoRepository.test.ts create mode 100644 tests/unit/services/TodoService.test.ts diff --git a/app/api/projects/[projectId]/todos/columns/[columnId]/route.ts b/app/api/projects/[projectId]/todos/columns/[columnId]/route.ts new file mode 100644 index 0000000..6b88f7e --- /dev/null +++ b/app/api/projects/[projectId]/todos/columns/[columnId]/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth/getCurrentUser'; +import { createTodoService } from '@/lib/api/services'; +import { UnauthorizedError } from '@/lib/errors'; +import { handleApiError, jsonError } from '@/lib/api/handleError'; + +export const runtime = 'nodejs'; + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ projectId: string; columnId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { columnId } = await params; + let body: Record; + try { + body = (await request.json()) as Record; + } catch { + return jsonError(400, 'リクエスト本文が不正です'); + } + const service = createTodoService(); + try { + const column = service.updateColumn(user.id, Number(columnId), { + name: typeof body.name === 'string' ? body.name : undefined, + orderIndex: + typeof body.orderIndex === 'number' ? body.orderIndex : undefined, + }); + return NextResponse.json({ column }); + } catch (error) { + return handleApiError(error); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ projectId: string; columnId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { columnId } = await params; + const service = createTodoService(); + try { + service.deleteColumn(user.id, Number(columnId)); + return NextResponse.json({ ok: true }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/api/projects/[projectId]/todos/columns/route.ts b/app/api/projects/[projectId]/todos/columns/route.ts new file mode 100644 index 0000000..7ea9d39 --- /dev/null +++ b/app/api/projects/[projectId]/todos/columns/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth/getCurrentUser'; +import { createTodoService } from '@/lib/api/services'; +import { UnauthorizedError } from '@/lib/errors'; +import { handleApiError, jsonError } from '@/lib/api/handleError'; + +export const runtime = 'nodejs'; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { projectId } = await params; + const service = createTodoService(); + try { + return NextResponse.json({ + columns: service.getColumns(user.id, Number(projectId)), + }); + } catch (error) { + return handleApiError(error); + } +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { projectId } = await params; + let body: Record; + try { + body = (await request.json()) as Record; + } catch { + return jsonError(400, 'リクエスト本文が不正です'); + } + const service = createTodoService(); + try { + const column = service.createColumn( + user.id, + Number(projectId), + String(body.name ?? ''), + typeof body.orderIndex === 'number' ? body.orderIndex : undefined + ); + return NextResponse.json({ column }, { status: 201 }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/api/projects/[projectId]/todos/items/[itemId]/route.ts b/app/api/projects/[projectId]/todos/items/[itemId]/route.ts new file mode 100644 index 0000000..18e31fd --- /dev/null +++ b/app/api/projects/[projectId]/todos/items/[itemId]/route.ts @@ -0,0 +1,76 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth/getCurrentUser'; +import { createTodoService } from '@/lib/api/services'; +import { UnauthorizedError } from '@/lib/errors'; +import { handleApiError, jsonError } from '@/lib/api/handleError'; + +export const runtime = 'nodejs'; + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ projectId: string; itemId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { itemId } = await params; + let body: Record; + try { + body = (await request.json()) as Record; + } catch { + return jsonError(400, 'リクエスト本文が不正です'); + } + const service = createTodoService(); + try { + // complete は専用フラグでトグル、それ以外は通常更新 + if (body.toggleComplete === true) { + const item = service.toggleComplete(user.id, Number(itemId)); + return NextResponse.json({ item }); + } + if (body.columnId !== undefined && body.orderIndex !== undefined) { + const item = service.moveItem( + user.id, + Number(itemId), + Number(body.columnId), + Number(body.orderIndex) + ); + return NextResponse.json({ item }); + } + const item = service.updateItem(user.id, Number(itemId), { + title: typeof body.title === 'string' ? body.title : undefined, + description: + typeof body.description === 'string' ? body.description : undefined, + assigneeId: + body.assigneeId === null || body.assigneeId === undefined + ? undefined + : Number(body.assigneeId), + priority: + typeof body.priority === 'string' + ? (body.priority as 'low' | 'normal' | 'high') + : undefined, + dueDate: typeof body.dueDate === 'string' ? body.dueDate : undefined, + milestoneId: + body.milestoneId === null || body.milestoneId === undefined + ? undefined + : Number(body.milestoneId), + }); + return NextResponse.json({ item }); + } catch (error) { + return handleApiError(error); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ projectId: string; itemId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { itemId } = await params; + const service = createTodoService(); + try { + service.deleteItem(user.id, Number(itemId)); + return NextResponse.json({ ok: true }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/api/projects/[projectId]/todos/items/route.ts b/app/api/projects/[projectId]/todos/items/route.ts new file mode 100644 index 0000000..bc8d040 --- /dev/null +++ b/app/api/projects/[projectId]/todos/items/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth/getCurrentUser'; +import { createTodoService } from '@/lib/api/services'; +import { UnauthorizedError } from '@/lib/errors'; +import { handleApiError, jsonError } from '@/lib/api/handleError'; + +export const runtime = 'nodejs'; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { projectId } = await params; + const service = createTodoService(); + try { + return NextResponse.json({ + items: service.getItems(user.id, Number(projectId)), + }); + } catch (error) { + return handleApiError(error); + } +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { projectId } = await params; + let body: Record; + try { + body = (await request.json()) as Record; + } catch { + return jsonError(400, 'リクエスト本文が不正です'); + } + const service = createTodoService(); + try { + const item = service.createItem(user.id, Number(projectId), { + title: String(body.title ?? ''), + columnId: Number(body.columnId ?? 0), + description: + typeof body.description === 'string' ? body.description : undefined, + assigneeId: + body.assigneeId === null || body.assigneeId === undefined + ? null + : Number(body.assigneeId), + priority: + typeof body.priority === 'string' + ? (body.priority as 'low' | 'normal' | 'high') + : undefined, + dueDate: typeof body.dueDate === 'string' ? body.dueDate : null, + }); + return NextResponse.json({ item }, { status: 201 }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/projects/[projectId]/todos/page.tsx b/app/projects/[projectId]/todos/page.tsx new file mode 100644 index 0000000..bcb17d6 --- /dev/null +++ b/app/projects/[projectId]/todos/page.tsx @@ -0,0 +1,49 @@ +import { redirect } from 'next/navigation'; +import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser'; +import { createTodoService, createProjectService } from '@/lib/api/services'; +import { Header } from '@/components/layout/Header'; +import { ProjectNav } from '@/components/layout/ProjectNav'; +import { KanbanBoard } from '@/components/todo/KanbanBoard'; +import { ForbiddenError, NotFoundError } from '@/lib/errors'; + +export const dynamic = 'force-dynamic'; + +export default async function TodosPage({ + params, +}: { + params: Promise<{ projectId: string }>; +}) { + const user = await getCurrentUser(); + if (!user) redirect('/login'); + const { projectId } = await params; + + const projectService = createProjectService(); + let project: Awaited>; + try { + project = projectService.getProject(user.id, Number(projectId)); + } catch (error) { + if (error instanceof ForbiddenError || error instanceof NotFoundError) { + redirect('/dashboard'); + } + throw error; + } + + const todoService = createTodoService(); + const columns = todoService.getColumns(user.id, project.id); + const items = todoService.getItems(user.id, project.id); + + return ( +
+
+ +
+

ToDo / Kanban

+ +
+
+ ); +} diff --git a/components/layout/ProjectNav.tsx b/components/layout/ProjectNav.tsx index 1881691..20c82c6 100644 --- a/components/layout/ProjectNav.tsx +++ b/components/layout/ProjectNav.tsx @@ -3,6 +3,7 @@ const NAV_ITEMS = [ { href: '/board', label: '掲示板' }, { href: '/notes', label: 'メモ' }, { href: '/chat', label: 'チャット' }, + { href: '/todos', label: 'ToDo' }, { href: '/members', label: 'メンバー' }, { href: '/activity', label: 'アクティビティ' }, { href: '/settings', label: '設定' }, @@ -22,6 +23,7 @@ export function ProjectNav({ | 'board' | 'notes' | 'chat' + | 'todos' | 'members' | 'activity' | 'settings'; @@ -31,6 +33,7 @@ export function ProjectNav({ board: active === 'board', notes: active === 'notes', chat: active === 'chat', + todos: active === 'todos', members: active === 'members', activity: active === 'activity', settings: active === 'settings', diff --git a/components/todo/KanbanBoard.tsx b/components/todo/KanbanBoard.tsx new file mode 100644 index 0000000..546b2f8 --- /dev/null +++ b/components/todo/KanbanBoard.tsx @@ -0,0 +1,162 @@ +'use client'; + +import { useCallback, useState, type DragEvent } from 'react'; +import type { TodoColumn, TodoItem } from '@/lib/types'; + +const PRIORITY_COLORS: Record = { + high: 'bg-red-100 text-red-700', + normal: 'bg-yellow-100 text-yellow-700', + low: 'bg-gray-100 text-gray-600', +}; + +export function KanbanBoard({ + projectId, + columns, + initialItems, +}: { + projectId: number; + columns: TodoColumn[]; + initialItems: TodoItem[]; +}) { + const [items, setItems] = useState(initialItems); + const [draggingId, setDraggingId] = useState(null); + + const reload = useCallback(async () => { + const res = await fetch(`/api/projects/${projectId}/todos/items`); + if (res.ok) { + const data = (await res.json()) as { items: TodoItem[] }; + setItems(data.items); + } + }, [projectId]); + + function onDragStart(e: DragEvent, itemId: number) { + setDraggingId(itemId); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', String(itemId)); + } + + function onDrop(e: DragEvent, column: TodoColumn) { + e.preventDefault(); + const itemId = Number(e.dataTransfer.getData('text/plain')); + if (!itemId) return; + const item = items.find((i) => i.id === itemId); + if (!item || item.columnId === column.id) { + setDraggingId(null); + return; + } + // 楽観的にローカル更新したあとAPIで移動 + setItems((prev) => + prev.map((i) => (i.id === itemId ? { ...i, columnId: column.id } : i)) + ); + fetch(`/api/projects/${projectId}/todos/items/${itemId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ columnId: column.id, orderIndex: 0 }), + }).then(() => reload()); + setDraggingId(null); + } + + async function addTask(columnId: number, title: string) { + const res = await fetch(`/api/projects/${projectId}/todos/items`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title, columnId }), + }); + if (res.ok) await reload(); + } + + async function toggleComplete(itemId: number) { + await fetch(`/api/projects/${projectId}/todos/items/${itemId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ toggleComplete: true }), + }); + await reload(); + } + + return ( +
+ {columns.map((column) => { + const colItems = items.filter((i) => i.columnId === column.id); + return ( +
e.preventDefault()} + onDrop={(e) => onDrop(e, column)} + data-testid={`kanban-column-${column.id}`} + data-column-id={column.id} + > +

+ {column.name} ({colItems.length}) +

+
    + {colItems.map((item) => ( +
  • onDragStart(e, item.id)} + className={`cursor-grab rounded border bg-white p-2 shadow-sm ${ + draggingId === item.id ? 'opacity-50' : '' + } ${item.completedAt ? 'opacity-60 line-through' : ''}`} + data-testid={`todo-card-${item.id}`} + > +
    + {item.title} + +
    +
    + + {item.priority} + + {item.dueDate && ( + + 期限: {item.dueDate} + + )} +
    +
  • + ))} +
+ addTask(column.id, title)} /> +
+ ); + })} +
+ ); +} + +function NewTaskInput({ onAdd }: { onAdd: (title: string) => void }) { + const [title, setTitle] = useState(''); + return ( +
{ + e.preventDefault(); + if (!title.trim()) return; + onAdd(title); + setTitle(''); + }} + > + setTitle(e.target.value)} + className="w-full rounded border px-2 py-1 text-sm" + data-testid="new-task-input" + /> +
+ ); +} diff --git a/lib/api/services.ts b/lib/api/services.ts index e28ddc3..1ec8389 100644 --- a/lib/api/services.ts +++ b/lib/api/services.ts @@ -14,6 +14,8 @@ import { ProjectNoteRepository } from '@/repositories/ProjectNoteRepository'; import { ChatService } from '@/services/ChatService'; import { ChatRepository } from '@/repositories/ChatRepository'; import { getSseHub } from '@/lib/sse/hub'; +import { TodoService } from '@/services/TodoService'; +import { TodoRepository } from '@/repositories/TodoRepository'; /** * Route Handler用に各Repository/Serviceを構築するファクトリ。 @@ -68,6 +70,17 @@ export function createChatService(): ChatService { ); } +export function createTodoService(): TodoService { + const db = getDb(); + return new TodoService( + new TodoRepository(db), + new ProjectMemberRepository(db), + new NotificationService(new NotificationRepository(db)), + new ActivityLogService(new ActivityLogRepository(db)), + getSseHub() + ); +} + export function createUserRepository(): UserRepository { return new UserRepository(getDb()); } diff --git a/repositories/TodoRepository.ts b/repositories/TodoRepository.ts new file mode 100644 index 0000000..c455ce1 --- /dev/null +++ b/repositories/TodoRepository.ts @@ -0,0 +1,274 @@ +import type { SqliteDatabase } from '@/lib/db/sqlite'; +import type { TodoColumn, TodoItem, TodoPriority } from '@/lib/types'; + +interface TodoColumnRow { + id: number; + project_id: number; + name: string; + order_index: number; + created_at: string; + updated_at: string; +} + +interface TodoItemRow { + id: number; + project_id: number; + column_id: number; + title: string; + description: string | null; + assignee_id: number | null; + creator_id: number; + priority: string; + start_date: string | null; + due_date: string | null; + completed_at: string | null; + order_index: number; + milestone_id: number | null; + created_at: string; + updated_at: string; + deleted_at: string | null; +} + +function mapColumn(row: TodoColumnRow): TodoColumn { + return { + id: row.id, + projectId: row.project_id, + name: row.name, + orderIndex: row.order_index, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function mapItem(row: TodoItemRow): TodoItem { + return { + id: row.id, + projectId: row.project_id, + columnId: row.column_id, + title: row.title, + description: row.description, + assigneeId: row.assignee_id, + creatorId: row.creator_id, + priority: row.priority as TodoPriority, + startDate: row.start_date, + dueDate: row.due_date, + completedAt: row.completed_at, + orderIndex: row.order_index, + milestoneId: row.milestone_id, + createdAt: row.created_at, + updatedAt: row.updated_at, + deletedAt: row.deleted_at, + }; +} + +export interface CreateColumnInput { + projectId: number; + name: string; + orderIndex: number; +} + +export interface CreateItemInput { + projectId: number; + columnId: number; + title: string; + creatorId: number; + description?: string | null; + assigneeId?: number | null; + priority?: TodoPriority; + dueDate?: string | null; + orderIndex: number; +} + +export interface UpdateItemInput { + title?: string; + description?: string | null; + assigneeId?: number | null; + priority?: TodoPriority; + dueDate?: string | null; + completedAt?: string | null; + columnId?: number; + orderIndex?: number; + milestoneId?: number | null; +} + +export class TodoRepository { + constructor(private readonly db: SqliteDatabase) {} + + // ----- columns ----- + + findColumns(projectId: number): TodoColumn[] { + const rows = this.db.query( + 'SELECT * FROM todo_columns WHERE project_id = @projectId ORDER BY order_index ASC, id ASC', + { projectId } + ); + return rows.map(mapColumn); + } + + findColumnById(id: number): TodoColumn | null { + const row = this.db.get( + 'SELECT * FROM todo_columns WHERE id = @id', + { id } + ); + return row ? mapColumn(row) : null; + } + + createColumn(input: CreateColumnInput): TodoColumn { + const now = new Date().toISOString(); + const result = this.db.execute( + `INSERT INTO todo_columns (project_id, name, order_index, created_at, updated_at) + VALUES (@projectId, @name, @orderIndex, @createdAt, @updatedAt)`, + { + projectId: input.projectId, + name: input.name, + orderIndex: input.orderIndex, + createdAt: now, + updatedAt: now, + } + ); + const created = this.findColumnById(Number(result.lastInsertRowid)); + if (!created) throw new Error('Failed to create column'); + return created; + } + + updateColumn( + id: number, + input: { name?: string; orderIndex?: number } + ): TodoColumn | null { + const fields: string[] = ['updated_at = @updatedAt']; + const params: Record = { + updatedAt: new Date().toISOString(), + id, + }; + if (input.name !== undefined) { + fields.push('name = @name'); + params.name = input.name; + } + if (input.orderIndex !== undefined) { + fields.push('order_index = @orderIndex'); + params.orderIndex = input.orderIndex; + } + this.db.execute( + `UPDATE todo_columns SET ${fields.join(', ')} WHERE id = @id`, + params + ); + return this.findColumnById(id); + } + + deleteColumn(id: number): boolean { + const result = this.db.execute('DELETE FROM todo_columns WHERE id = @id', { + id, + }); + return result.changes > 0; + } + + // ----- items ----- + + findItemsByProject(projectId: number): TodoItem[] { + const rows = this.db.query( + `SELECT i.* FROM todo_items i + INNER JOIN todo_columns c ON c.id = i.column_id + WHERE i.project_id = @projectId AND i.deleted_at IS NULL + ORDER BY c.order_index ASC, i.order_index ASC, i.id ASC`, + { projectId } + ); + return rows.map(mapItem); + } + + findItemById(id: number): TodoItem | null { + const row = this.db.get( + 'SELECT * FROM todo_items WHERE id = @id AND deleted_at IS NULL', + { id } + ); + return row ? mapItem(row) : null; + } + + maxItemOrderIndex(columnId: number): number { + const row = this.db.get<{ max_index: number | null }>( + 'SELECT MAX(order_index) AS max_index FROM todo_items WHERE column_id = @columnId AND deleted_at IS NULL', + { columnId } + ); + return row?.max_index ?? -1; + } + + createItem(input: CreateItemInput): TodoItem { + const now = new Date().toISOString(); + const result = this.db.execute( + `INSERT INTO todo_items (project_id, column_id, title, description, assignee_id, creator_id, priority, start_date, due_date, completed_at, order_index, milestone_id, created_at, updated_at, deleted_at) + VALUES (@projectId, @columnId, @title, @description, @assigneeId, @creatorId, @priority, NULL, @dueDate, NULL, @orderIndex, @milestoneId, @createdAt, @updatedAt, NULL)`, + { + projectId: input.projectId, + columnId: input.columnId, + title: input.title, + description: input.description ?? null, + assigneeId: input.assigneeId ?? null, + creatorId: input.creatorId, + priority: input.priority ?? 'normal', + dueDate: input.dueDate ?? null, + orderIndex: input.orderIndex, + milestoneId: null, + createdAt: now, + updatedAt: now, + } + ); + const created = this.findItemById(Number(result.lastInsertRowid)); + if (!created) throw new Error('Failed to create todo item'); + return created; + } + + updateItem(id: number, input: UpdateItemInput): TodoItem | null { + const fields: string[] = ['updated_at = @updatedAt']; + const params: Record = { + updatedAt: new Date().toISOString(), + id, + }; + if (input.title !== undefined) { + fields.push('title = @title'); + params.title = input.title; + } + if (input.description !== undefined) { + fields.push('description = @description'); + params.description = input.description; + } + if (input.assigneeId !== undefined) { + fields.push('assignee_id = @assigneeId'); + params.assigneeId = input.assigneeId; + } + if (input.priority !== undefined) { + fields.push('priority = @priority'); + params.priority = input.priority; + } + if (input.dueDate !== undefined) { + fields.push('due_date = @dueDate'); + params.dueDate = input.dueDate; + } + if (input.completedAt !== undefined) { + fields.push('completed_at = @completedAt'); + params.completedAt = input.completedAt; + } + if (input.columnId !== undefined) { + fields.push('column_id = @columnId'); + params.columnId = input.columnId; + } + if (input.orderIndex !== undefined) { + fields.push('order_index = @orderIndex'); + params.orderIndex = input.orderIndex; + } + if (input.milestoneId !== undefined) { + fields.push('milestone_id = @milestoneId'); + params.milestoneId = input.milestoneId; + } + this.db.execute( + `UPDATE todo_items SET ${fields.join(', ')} WHERE id = @id AND deleted_at IS NULL`, + params + ); + return this.findItemById(id); + } + + deleteItem(id: number): boolean { + const result = this.db.execute( + 'UPDATE todo_items SET deleted_at = @now WHERE id = @id AND deleted_at IS NULL', + { now: new Date().toISOString(), id } + ); + return result.changes > 0; + } +} diff --git a/services/TodoService.ts b/services/TodoService.ts new file mode 100644 index 0000000..b9d88ac --- /dev/null +++ b/services/TodoService.ts @@ -0,0 +1,275 @@ +import { TodoRepository } from '@/repositories/TodoRepository'; +import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository'; +import { NotificationService } from '@/services/NotificationService'; +import { ActivityLogService } from '@/services/ActivityLogService'; +import { SseHub } from '@/lib/sse/hub'; +import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors'; +import type { TodoColumn, TodoItem, TodoPriority } from '@/lib/types'; + +const STANDARD_COLUMNS = ['Backlog', 'To Do', 'In Progress', 'Review', 'Done']; +const VALID_PRIORITIES: TodoPriority[] = ['low', 'normal', 'high']; + +export interface CreateItemInput { + title: string; + columnId: number; + description?: string; + assigneeId?: number | null; + priority?: TodoPriority; + dueDate?: string | null; +} + +export interface UpdateItemRequest { + title?: string; + description?: string | null; + assigneeId?: number | null; + priority?: TodoPriority; + dueDate?: string | null; + columnId?: number; + orderIndex?: number; + milestoneId?: number | null; +} + +/** + * ToDo/Kanbanの業務ロジックを担うService。 + * 標準カラム初期生成、権限チェック、タスクCRUD/移動/完了、 + * 担当者割当時の通知(todo_assigned)とアクティビティログ(todo_*)、SSE配信を行う。 + */ +export class TodoService { + constructor( + private readonly todoRepository: TodoRepository, + private readonly projectMemberRepository: ProjectMemberRepository, + private readonly notificationService: NotificationService, + private readonly activityLogService: ActivityLogService, + private readonly sseHub: SseHub + ) {} + + getColumns(actorId: number, projectId: number): TodoColumn[] { + this.requireMember(projectId, actorId); + let columns = this.todoRepository.findColumns(projectId); + if (columns.length === 0) { + // 標準カラム初期生成 + STANDARD_COLUMNS.forEach((name, idx) => { + this.todoRepository.createColumn({ + projectId, + name, + orderIndex: idx, + }); + }); + columns = this.todoRepository.findColumns(projectId); + } + return columns; + } + + getItems(actorId: number, projectId: number): TodoItem[] { + this.requireMember(projectId, actorId); + return this.todoRepository.findItemsByProject(projectId); + } + + createColumn( + actorId: number, + projectId: number, + name: string, + orderIndex?: number + ): TodoColumn { + this.requireMember(projectId, actorId); + if (!name.trim()) { + throw new ValidationError('カラム名を入力してください', 'name'); + } + const idx = orderIndex ?? this.todoRepository.findColumns(projectId).length; + return this.todoRepository.createColumn({ + projectId, + name, + orderIndex: idx, + }); + } + + updateColumn( + actorId: number, + columnId: number, + input: { name?: string; orderIndex?: number } + ): TodoColumn { + const column = this.todoRepository.findColumnById(columnId); + if (!column) throw new NotFoundError('TodoColumn', columnId); + this.requireMember(column.projectId, actorId); + const updated = this.todoRepository.updateColumn(columnId, input); + if (!updated) throw new NotFoundError('TodoColumn', columnId); + return updated; + } + + deleteColumn(actorId: number, columnId: number): void { + const column = this.todoRepository.findColumnById(columnId); + if (!column) throw new NotFoundError('TodoColumn', columnId); + this.requireAdmin(column.projectId, actorId); + this.todoRepository.deleteColumn(columnId); + } + + createItem( + actorId: number, + projectId: number, + input: CreateItemInput + ): TodoItem { + this.requireMember(projectId, actorId); + if (!input.title.trim()) { + throw new ValidationError('タイトルを入力してください', 'title'); + } + const column = this.todoRepository.findColumnById(input.columnId); + if (!column || column.projectId !== projectId) { + throw new NotFoundError('TodoColumn', input.columnId); + } + if (input.priority && !VALID_PRIORITIES.includes(input.priority)) { + throw new ValidationError('無効な優先度です', 'priority'); + } + const orderIndex = + input.columnId !== undefined + ? this.todoRepository.maxItemOrderIndex(input.columnId) + 1 + : 0; + const item = this.todoRepository.createItem({ + projectId, + columnId: input.columnId, + title: input.title, + creatorId: actorId, + description: input.description ?? null, + assigneeId: input.assigneeId ?? null, + priority: input.priority, + dueDate: input.dueDate ?? null, + orderIndex, + }); + + if (input.assigneeId && input.assigneeId !== actorId) { + this.notifyAssigned(projectId, input.assigneeId, item.title); + } + this.logTodo(projectId, actorId, 'todo_created', item.id); + this.broadcastTodo(projectId); + return item; + } + + updateItem( + actorId: number, + itemId: number, + input: UpdateItemRequest + ): TodoItem { + const item = this.todoRepository.findItemById(itemId); + if (!item) throw new NotFoundError('TodoItem', itemId); + this.requireMember(item.projectId, actorId); + if (input.priority && !VALID_PRIORITIES.includes(input.priority)) { + throw new ValidationError('無効な優先度です', 'priority'); + } + const previousAssignee = item.assigneeId; + const updated = this.todoRepository.updateItem(itemId, input); + if (!updated) throw new NotFoundError('TodoItem', itemId); + + if ( + input.assigneeId !== undefined && + input.assigneeId !== previousAssignee && + input.assigneeId !== null && + input.assigneeId !== actorId + ) { + this.notifyAssigned(item.projectId, input.assigneeId, updated.title); + } + this.logTodo(item.projectId, actorId, 'todo_updated', itemId); + this.broadcastTodo(item.projectId); + return updated; + } + + moveItem( + actorId: number, + itemId: number, + columnId: number, + orderIndex: number + ): TodoItem { + const item = this.todoRepository.findItemById(itemId); + if (!item) throw new NotFoundError('TodoItem', itemId); + this.requireMember(item.projectId, actorId); + const column = this.todoRepository.findColumnById(columnId); + if (!column || column.projectId !== item.projectId) { + throw new NotFoundError('TodoColumn', columnId); + } + const updated = this.todoRepository.updateItem(itemId, { + columnId, + orderIndex, + }); + if (!updated) throw new NotFoundError('TodoItem', itemId); + this.logTodo(item.projectId, actorId, 'todo_updated', itemId); + this.broadcastTodo(item.projectId); + return updated; + } + + toggleComplete(actorId: number, itemId: number): TodoItem { + const item = this.todoRepository.findItemById(itemId); + if (!item) throw new NotFoundError('TodoItem', itemId); + this.requireMember(item.projectId, actorId); + const now = item.completedAt === null ? new Date().toISOString() : null; + const updated = this.todoRepository.updateItem(itemId, { + completedAt: now, + }); + if (!updated) throw new NotFoundError('TodoItem', itemId); + if (now) { + this.logTodo(item.projectId, actorId, 'todo_completed', itemId); + } else { + this.logTodo(item.projectId, actorId, 'todo_updated', itemId); + } + this.broadcastTodo(item.projectId); + return updated; + } + + deleteItem(actorId: number, itemId: number): void { + const item = this.todoRepository.findItemById(itemId); + if (!item) throw new NotFoundError('TodoItem', itemId); + this.requireMember(item.projectId, actorId); + const role = this.projectMemberRepository.getRole(item.projectId, actorId); + if (item.creatorId !== actorId && role !== 'admin') { + throw new ForbiddenError('作成者または管理者のみ削除できます'); + } + this.todoRepository.deleteItem(itemId); + this.broadcastTodo(item.projectId); + } + + private requireMember(projectId: number, actorId: number): void { + if (!this.projectMemberRepository.isMember(projectId, actorId)) { + throw new ForbiddenError('プロジェクトに参加していません'); + } + } + + private requireAdmin(projectId: number, actorId: number): void { + this.requireMember(projectId, actorId); + if (this.projectMemberRepository.getRole(projectId, actorId) !== 'admin') { + throw new ForbiddenError('プロジェクト管理者権限が必要です'); + } + } + + private notifyAssigned( + projectId: number, + assigneeId: number, + title: string + ): void { + this.notificationService.notifyOnEvent({ + type: 'todo_assigned', + projectId, + title: `ToDo「${title}」の担当者に割り当てられました`, + body: title, + assigneeId, + }); + } + + private logTodo( + projectId: number, + actorId: number, + action: string, + targetId: number + ): void { + this.activityLogService.logActivity({ + projectId, + actorId, + action, + targetType: 'todo', + targetId, + }); + } + + private broadcastTodo(projectId: number): void { + this.sseHub.broadcast(projectId, { + type: 'todo.updated', + data: { projectId }, + }); + } +} diff --git a/tests/e2e/todo-kanban.spec.ts b/tests/e2e/todo-kanban.spec.ts new file mode 100644 index 0000000..8309566 --- /dev/null +++ b/tests/e2e/todo-kanban.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from '@playwright/test'; + +function unique(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; +} + +async function setupOwner(page: import('@playwright/test').Page) { + const email = unique('owner') + '@example.com'; + await page.goto('/login'); + await page.getByRole('button', { name: '新規登録はこちら' }).click(); + await page.getByLabel('表示名').fill('Owner'); + await page.getByLabel('メールアドレス').fill(email); + await page.getByLabel('パスワード').fill('password123'); + await page.getByRole('button', { name: '登録する' }).click(); + await expect(page).toHaveURL(/\/dashboard/); + await page.getByLabel('プロジェクト名').fill(unique('Proj')); + await page.getByRole('button', { name: '新規プロジェクト' }).click(); + await expect(page).toHaveURL(/\/projects\/\d+$/); + return Number(page.url().match(/\/projects\/(\d+)/)![1]); +} + +test.describe('todo / kanban', () => { + test('create a task, complete it, and move it to Done', async ({ page }) => { + const projectId = await setupOwner(page); + + // 標準カラム取得 + const colsRes = await page.request.get( + `/api/projects/${projectId}/todos/columns` + ); + const cols = ( + (await colsRes.json()) as { columns: { id: number; name: string }[] } + ).columns; + const backlog = cols.find((c) => c.name === 'Backlog')!; + const done = cols.find((c) => c.name === 'Done')!; + + // タスク作成(API) + const title = unique('Task'); + const createRes = await page.request.post( + `/api/projects/${projectId}/todos/items`, + { + data: { + title, + columnId: backlog.id, + priority: 'high', + dueDate: '2026-12-31', + }, + } + ); + expect(createRes.ok()).toBeTruthy(); + const { item } = (await createRes.json()) as { item: { id: number } }; + + // ボードに表示される + await page.goto(`/projects/${projectId}/todos`); + await expect(page.getByTestId(`todo-card-${item.id}`)).toBeVisible(); + await expect(page.getByText(title)).toBeVisible(); + + // 完了(API) → リロードで取り消し線が反映される + const completeRes = await page.request.patch( + `/api/projects/${projectId}/todos/items/${item.id}`, + { data: { toggleComplete: true } } + ); + expect(completeRes.ok()).toBeTruthy(); + await page.reload(); + await expect(page.getByTestId(`todo-card-${item.id}`)).toHaveClass( + /line-through/ + ); + + // Doneカラムへ移動(API) + const moveRes = await page.request.patch( + `/api/projects/${projectId}/todos/items/${item.id}`, + { data: { columnId: done.id, orderIndex: 0 } } + ); + expect(moveRes.ok()).toBeTruthy(); + await page.reload(); + await expect( + page.getByTestId(`kanban-column-${done.id}`).getByText(title) + ).toBeVisible(); + }); +}); diff --git a/tests/unit/repositories/TodoRepository.test.ts b/tests/unit/repositories/TodoRepository.test.ts new file mode 100644 index 0000000..545643e --- /dev/null +++ b/tests/unit/repositories/TodoRepository.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createMigratedTestDb } from '@/tests/helpers/db'; +import type { SqliteDatabase } from '@/lib/db/sqlite'; +import { UserRepository } from '@/repositories/UserRepository'; +import { ProjectRepository } from '@/repositories/ProjectRepository'; +import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository'; +import { TodoRepository } from '@/repositories/TodoRepository'; + +describe('TodoRepository', () => { + let db: SqliteDatabase; + let repo: TodoRepository; + let projectId: number; + let userId: number; + let columnId: number; + + beforeEach(() => { + db = createMigratedTestDb(); + repo = new TodoRepository(db); + userId = new UserRepository(db).create({ + name: 'U', + email: 'u@example.com', + passwordHash: 'h', + }).id; + projectId = new ProjectRepository(db).create({ + name: 'P', + ownerId: userId, + }).id; + new ProjectMemberRepository(db).add(projectId, userId, 'admin'); + columnId = repo.createColumn({ projectId, name: 'Todo', orderIndex: 0 }).id; + }); + + afterEach(() => db.close()); + + it('creates and lists columns ordered by order_index', () => { + repo.createColumn({ projectId, name: 'Done', orderIndex: 2 }); + repo.createColumn({ projectId, name: 'Doing', orderIndex: 1 }); + const cols = repo.findColumns(projectId); + expect(cols.map((c) => c.name)).toEqual(['Todo', 'Doing', 'Done']); + }); + + it('updates and deletes a column', () => { + expect(repo.updateColumn(columnId, { name: 'Renamed' })?.name).toBe( + 'Renamed' + ); + repo.deleteColumn(columnId); + expect(repo.findColumnById(columnId)).toBeNull(); + }); + + it('creates an item and lists by column order then item order', () => { + repo.createItem({ + projectId, + columnId, + title: 'second', + creatorId: userId, + orderIndex: 1, + }); + repo.createItem({ + projectId, + columnId, + title: 'first', + creatorId: userId, + orderIndex: 0, + }); + const items = repo.findItemsByProject(projectId); + expect(items.map((i) => i.title)).toEqual(['first', 'second']); + }); + + it('excludes soft-deleted items', () => { + const item = repo.createItem({ + projectId, + columnId, + title: 'x', + creatorId: userId, + orderIndex: 0, + }); + repo.deleteItem(item.id); + expect(repo.findItemById(item.id)).toBeNull(); + expect(repo.findItemsByProject(projectId)).toHaveLength(0); + }); + + it('maxItemOrderIndex returns -1 for empty column, else max', () => { + expect(repo.maxItemOrderIndex(columnId)).toBe(-1); + repo.createItem({ + projectId, + columnId, + title: 'a', + creatorId: userId, + orderIndex: 5, + }); + expect(repo.maxItemOrderIndex(columnId)).toBe(5); + }); + + it('updates item fields including column move', () => { + const col2 = repo.createColumn({ + projectId, + name: 'Done', + orderIndex: 1, + }).id; + const item = repo.createItem({ + projectId, + columnId, + title: 'x', + creatorId: userId, + orderIndex: 0, + }); + const updated = repo.updateItem(item.id, { + title: 'renamed', + columnId: col2, + orderIndex: 0, + completedAt: '2026-01-01T00:00:00.000Z', + }); + expect(updated?.columnId).toBe(col2); + expect(updated?.title).toBe('renamed'); + expect(updated?.completedAt).toBeTruthy(); + }); + + it('isolates items by project', () => { + const p2 = new ProjectRepository(db).create({ + name: 'P2', + ownerId: userId, + }).id; + const c2 = repo.createColumn({ + projectId: p2, + name: 'X', + orderIndex: 0, + }).id; + repo.createItem({ + projectId, + columnId, + title: 'mine', + creatorId: userId, + orderIndex: 0, + }); + repo.createItem({ + projectId: p2, + columnId: c2, + title: 'theirs', + creatorId: userId, + orderIndex: 0, + }); + expect(repo.findItemsByProject(projectId)).toHaveLength(1); + expect(repo.findItemsByProject(p2)).toHaveLength(1); + }); +}); diff --git a/tests/unit/services/TodoService.test.ts b/tests/unit/services/TodoService.test.ts new file mode 100644 index 0000000..b0773af --- /dev/null +++ b/tests/unit/services/TodoService.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createMigratedTestDb } from '@/tests/helpers/db'; +import type { SqliteDatabase } from '@/lib/db/sqlite'; +import { UserRepository } from '@/repositories/UserRepository'; +import { ProjectRepository } from '@/repositories/ProjectRepository'; +import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository'; +import { TodoRepository } from '@/repositories/TodoRepository'; +import { NotificationRepository } from '@/repositories/NotificationRepository'; +import { ActivityLogRepository } from '@/repositories/ActivityLogRepository'; +import { NotificationService } from '@/services/NotificationService'; +import { ActivityLogService } from '@/services/ActivityLogService'; +import { TodoService } from '@/services/TodoService'; +import { SseHub } from '@/lib/sse/hub'; +import { ForbiddenError, NotFoundError } from '@/lib/errors'; + +function makeService(db: SqliteDatabase) { + const hub = new SseHub(); + const members = new ProjectMemberRepository(db); + const users = new UserRepository(db); + return { + hub, + service: new TodoService( + new TodoRepository(db), + members, + new NotificationService(new NotificationRepository(db)), + new ActivityLogService(new ActivityLogRepository(db)), + hub + ), + members, + users, + }; +} + +describe('TodoService', () => { + let db: SqliteDatabase; + let service: TodoService; + let projectId: number; + let authorId: number; + let memberId: number; + let outsiderId: number; + + beforeEach(() => { + db = createMigratedTestDb(); + const ctx = makeService(db); + service = ctx.service; + const users = ctx.users; + authorId = users.create({ + name: 'A', + email: 'a@example.com', + passwordHash: 'h', + }).id; + memberId = users.create({ + name: 'M', + email: 'm@example.com', + passwordHash: 'h', + }).id; + outsiderId = users.create({ + name: 'O', + email: 'o@example.com', + passwordHash: 'h', + }).id; + projectId = new ProjectRepository(db).create({ + name: 'P', + ownerId: authorId, + }).id; + ctx.members.add(projectId, authorId, 'admin'); + ctx.members.add(projectId, memberId, 'member'); + }); + + afterEach(() => db.close()); + + it('auto-creates the 5 standard columns on first getColumns', () => { + const cols = service.getColumns(authorId, projectId); + expect(cols.map((c) => c.name)).toEqual([ + 'Backlog', + 'To Do', + 'In Progress', + 'Review', + 'Done', + ]); + }); + + it('forbids a non-member from accessing columns', () => { + expect(() => service.getColumns(outsiderId, projectId)).toThrow( + ForbiddenError + ); + }); + + it('creates an item with assignee → todo_assigned notification + todo_created activity', () => { + const col = service.getColumns(authorId, projectId)[0]; + const item = service.createItem(authorId, projectId, { + title: 'task', + columnId: col.id, + assigneeId: memberId, + priority: 'high', + }); + expect(item.assigneeId).toBe(memberId); + expect(new NotificationRepository(db).countUnreadByUser(memberId)).toBe(1); + expect( + new ActivityLogRepository(db) + .findByProject(projectId) + .items.some((l) => l.action === 'todo_created') + ).toBe(true); + }); + + it('does not notify when assigning to self', () => { + const col = service.getColumns(authorId, projectId)[0]; + service.createItem(authorId, projectId, { + title: 'self', + columnId: col.id, + assigneeId: authorId, + }); + expect(new NotificationRepository(db).countUnreadByUser(authorId)).toBe(0); + }); + + it('toggling complete sets completedAt and logs todo_completed', () => { + const col = service.getColumns(authorId, projectId)[0]; + const item = service.createItem(authorId, projectId, { + title: 't', + columnId: col.id, + }); + const completed = service.toggleComplete(authorId, item.id); + expect(completed.completedAt).not.toBeNull(); + expect( + new ActivityLogRepository(db) + .findByProject(projectId) + .items.some((l) => l.action === 'todo_completed') + ).toBe(true); + // もう一度トグルで未完了に戻る + const reopened = service.toggleComplete(authorId, item.id); + expect(reopened.completedAt).toBeNull(); + }); + + it('moves an item to another column', () => { + const cols = service.getColumns(authorId, projectId); + const item = service.createItem(authorId, projectId, { + title: 't', + columnId: cols[0].id, + }); + const moved = service.moveItem(authorId, item.id, cols[4].id, 0); + expect(moved.columnId).toBe(cols[4].id); + }); + + it('only creator or admin can delete an item', () => { + const col = service.getColumns(authorId, projectId)[0]; + const item = service.createItem(memberId, projectId, { + title: 't', + columnId: col.id, + }); + expect(() => service.deleteItem(outsiderId, item.id)).toThrow( + ForbiddenError + ); + service.deleteItem(authorId, item.id); // admin + expect(() => service.toggleComplete(memberId, item.id)).toThrow( + NotFoundError + ); + }); + + it('throws NotFoundError for a non-existent item', () => { + expect(() => service.toggleComplete(authorId, 99999)).toThrow( + NotFoundError + ); + }); +});