Merge branch 'feature/m9-todo-kanban' - M9 todo/kanban
This commit is contained in:
@ -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<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/api/projects/[projectId]/todos/columns/route.ts
Normal file
51
app/api/projects/[projectId]/todos/columns/route.ts
Normal file
@ -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<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
app/api/projects/[projectId]/todos/items/[itemId]/route.ts
Normal file
76
app/api/projects/[projectId]/todos/items/[itemId]/route.ts
Normal file
@ -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<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/api/projects/[projectId]/todos/items/route.ts
Normal file
60
app/api/projects/[projectId]/todos/items/route.ts
Normal file
@ -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<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Record<string, unknown>;
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/projects/[projectId]/todos/page.tsx
Normal file
49
app/projects/[projectId]/todos/page.tsx
Normal file
@ -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<ReturnType<typeof projectService.getProject>>;
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Header user={toPublicUser(user)} />
|
||||||
|
<ProjectNav projectId={project.id} active="todos" />
|
||||||
|
<main className="space-y-4 p-6">
|
||||||
|
<h1 className="text-2xl font-bold">ToDo / Kanban</h1>
|
||||||
|
<KanbanBoard
|
||||||
|
projectId={project.id}
|
||||||
|
columns={columns}
|
||||||
|
initialItems={items}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ const NAV_ITEMS = [
|
|||||||
{ href: '/board', label: '掲示板' },
|
{ href: '/board', label: '掲示板' },
|
||||||
{ href: '/notes', label: 'メモ' },
|
{ href: '/notes', label: 'メモ' },
|
||||||
{ href: '/chat', label: 'チャット' },
|
{ href: '/chat', label: 'チャット' },
|
||||||
|
{ href: '/todos', label: 'ToDo' },
|
||||||
{ href: '/members', label: 'メンバー' },
|
{ href: '/members', label: 'メンバー' },
|
||||||
{ href: '/activity', label: 'アクティビティ' },
|
{ href: '/activity', label: 'アクティビティ' },
|
||||||
{ href: '/settings', label: '設定' },
|
{ href: '/settings', label: '設定' },
|
||||||
@ -22,6 +23,7 @@ export function ProjectNav({
|
|||||||
| 'board'
|
| 'board'
|
||||||
| 'notes'
|
| 'notes'
|
||||||
| 'chat'
|
| 'chat'
|
||||||
|
| 'todos'
|
||||||
| 'members'
|
| 'members'
|
||||||
| 'activity'
|
| 'activity'
|
||||||
| 'settings';
|
| 'settings';
|
||||||
@ -31,6 +33,7 @@ export function ProjectNav({
|
|||||||
board: active === 'board',
|
board: active === 'board',
|
||||||
notes: active === 'notes',
|
notes: active === 'notes',
|
||||||
chat: active === 'chat',
|
chat: active === 'chat',
|
||||||
|
todos: active === 'todos',
|
||||||
members: active === 'members',
|
members: active === 'members',
|
||||||
activity: active === 'activity',
|
activity: active === 'activity',
|
||||||
settings: active === 'settings',
|
settings: active === 'settings',
|
||||||
|
|||||||
162
components/todo/KanbanBoard.tsx
Normal file
162
components/todo/KanbanBoard.tsx
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useState, type DragEvent } from 'react';
|
||||||
|
import type { TodoColumn, TodoItem } from '@/lib/types';
|
||||||
|
|
||||||
|
const PRIORITY_COLORS: Record<string, string> = {
|
||||||
|
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<TodoItem[]>(initialItems);
|
||||||
|
const [draggingId, setDraggingId] = useState<number | null>(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<HTMLElement>, itemId: number) {
|
||||||
|
setDraggingId(itemId);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', String(itemId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e: DragEvent<HTMLElement>, 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 (
|
||||||
|
<div className="flex gap-4 overflow-x-auto pb-4" data-testid="kanban-board">
|
||||||
|
{columns.map((column) => {
|
||||||
|
const colItems = items.filter((i) => i.columnId === column.id);
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={column.id}
|
||||||
|
className="w-64 shrink-0 rounded-lg bg-gray-100 p-3"
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => onDrop(e, column)}
|
||||||
|
data-testid={`kanban-column-${column.id}`}
|
||||||
|
data-column-id={column.id}
|
||||||
|
>
|
||||||
|
<h2 className="mb-2 text-sm font-semibold text-gray-700">
|
||||||
|
{column.name} ({colItems.length})
|
||||||
|
</h2>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{colItems.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => 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}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span className="text-sm">{item.title}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleComplete(item.id)}
|
||||||
|
className="text-xs text-blue-600 hover:underline"
|
||||||
|
data-testid={`todo-complete-${item.id}`}
|
||||||
|
>
|
||||||
|
{item.completedAt ? '戻す' : '完了'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex gap-1">
|
||||||
|
<span
|
||||||
|
className={`rounded px-1.5 py-0.5 text-xs ${
|
||||||
|
PRIORITY_COLORS[item.priority] ?? PRIORITY_COLORS.normal
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.priority}
|
||||||
|
</span>
|
||||||
|
{item.dueDate && (
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
期限: {item.dueDate}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<NewTaskInput onAdd={(title) => addTask(column.id, title)} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewTaskInput({ onAdd }: { onAdd: (title: string) => void }) {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="mt-2"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!title.trim()) return;
|
||||||
|
onAdd(title);
|
||||||
|
setTitle('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="タスクを追加"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="w-full rounded border px-2 py-1 text-sm"
|
||||||
|
data-testid="new-task-input"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -14,6 +14,8 @@ import { ProjectNoteRepository } from '@/repositories/ProjectNoteRepository';
|
|||||||
import { ChatService } from '@/services/ChatService';
|
import { ChatService } from '@/services/ChatService';
|
||||||
import { ChatRepository } from '@/repositories/ChatRepository';
|
import { ChatRepository } from '@/repositories/ChatRepository';
|
||||||
import { getSseHub } from '@/lib/sse/hub';
|
import { getSseHub } from '@/lib/sse/hub';
|
||||||
|
import { TodoService } from '@/services/TodoService';
|
||||||
|
import { TodoRepository } from '@/repositories/TodoRepository';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route Handler用に各Repository/Serviceを構築するファクトリ。
|
* 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 {
|
export function createUserRepository(): UserRepository {
|
||||||
return new UserRepository(getDb());
|
return new UserRepository(getDb());
|
||||||
}
|
}
|
||||||
|
|||||||
274
repositories/TodoRepository.ts
Normal file
274
repositories/TodoRepository.ts
Normal file
@ -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<TodoColumnRow>(
|
||||||
|
'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<TodoColumnRow>(
|
||||||
|
'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<string, unknown> = {
|
||||||
|
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<TodoItemRow>(
|
||||||
|
`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<TodoItemRow>(
|
||||||
|
'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<string, unknown> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
275
services/TodoService.ts
Normal file
275
services/TodoService.ts
Normal file
@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
79
tests/e2e/todo-kanban.spec.ts
Normal file
79
tests/e2e/todo-kanban.spec.ts
Normal file
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
144
tests/unit/repositories/TodoRepository.test.ts
Normal file
144
tests/unit/repositories/TodoRepository.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
164
tests/unit/services/TodoService.test.ts
Normal file
164
tests/unit/services/TodoService.test.ts
Normal file
@ -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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user