diff --git a/app/api/projects/[projectId]/todos/items/[itemId]/route.ts b/app/api/projects/[projectId]/todos/items/[itemId]/route.ts
index 18e31fd..bb9c6a6 100644
--- a/app/api/projects/[projectId]/todos/items/[itemId]/route.ts
+++ b/app/api/projects/[projectId]/todos/items/[itemId]/route.ts
@@ -6,6 +6,23 @@ import { handleApiError, jsonError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
+export async function GET(
+ _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 {
+ const item = service.getItem(user.id, Number(itemId));
+ const attachments = service.getItemAttachments(user.id, Number(itemId));
+ return NextResponse.json({ item, attachments });
+ } catch (error) {
+ return handleApiError(error);
+ }
+}
+
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string; itemId: string }> }
@@ -35,23 +52,29 @@ export async function PATCH(
);
return NextResponse.json({ item });
}
+ // nullable フィールドは absent(undefined=更新しない) と null(クリア) を区別する
+ const nullableString = (v: unknown): string | null | undefined =>
+ v === undefined ? undefined : v === null ? null : String(v);
+ const nullableNumber = (v: unknown): number | null | undefined =>
+ v === undefined ? undefined : v === null ? null : Number(v);
+
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),
+ description: nullableString(body.description),
+ assigneeId: nullableNumber(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),
+ startDate: nullableString(body.startDate),
+ dueDate: nullableString(body.dueDate),
+ tags: nullableString(body.tags),
+ milestoneId: nullableNumber(body.milestoneId),
+ fileIds: Array.isArray(body.fileIds)
+ ? body.fileIds
+ .map((n) => Number(n))
+ .filter((n) => Number.isFinite(n) && n > 0)
+ : undefined,
});
return NextResponse.json({ item });
} catch (error) {
diff --git a/app/api/projects/[projectId]/todos/items/route.ts b/app/api/projects/[projectId]/todos/items/route.ts
index bc8d040..8149a44 100644
--- a/app/api/projects/[projectId]/todos/items/route.ts
+++ b/app/api/projects/[projectId]/todos/items/route.ts
@@ -38,6 +38,11 @@ export async function POST(
}
const service = createTodoService();
try {
+ const fileIds = Array.isArray(body.fileIds)
+ ? body.fileIds
+ .map((n) => Number(n))
+ .filter((n) => Number.isFinite(n) && n > 0)
+ : [];
const item = service.createItem(user.id, Number(projectId), {
title: String(body.title ?? ''),
columnId: Number(body.columnId ?? 0),
@@ -51,7 +56,11 @@ export async function POST(
typeof body.priority === 'string'
? (body.priority as 'low' | 'normal' | 'high')
: undefined,
+ startDate:
+ typeof body.startDate === 'string' ? body.startDate : undefined,
dueDate: typeof body.dueDate === 'string' ? body.dueDate : null,
+ tags: typeof body.tags === 'string' ? body.tags : undefined,
+ fileIds,
});
return NextResponse.json({ item }, { status: 201 });
} catch (error) {
diff --git a/app/projects/[projectId]/todos/page.tsx b/app/projects/[projectId]/todos/page.tsx
index bcb17d6..52f096f 100644
--- a/app/projects/[projectId]/todos/page.tsx
+++ b/app/projects/[projectId]/todos/page.tsx
@@ -31,6 +31,7 @@ export default async function TodosPage({
const todoService = createTodoService();
const columns = todoService.getColumns(user.id, project.id);
const items = todoService.getItems(user.id, project.id);
+ const members = projectService.getMembers(user.id, project.id);
return (
@@ -42,6 +43,7 @@ export default async function TodosPage({
projectId={project.id}
columns={columns}
initialItems={items}
+ members={members}
/>
diff --git a/components/todo/KanbanBoard.tsx b/components/todo/KanbanBoard.tsx
index 546b2f8..e08c36f 100644
--- a/components/todo/KanbanBoard.tsx
+++ b/components/todo/KanbanBoard.tsx
@@ -2,6 +2,8 @@
import { useCallback, useState, type DragEvent } from 'react';
import type { TodoColumn, TodoItem } from '@/lib/types';
+import type { ProjectMemberWithUser } from '@/repositories/ProjectMemberRepository';
+import { TodoDialog } from '@/components/todo/TodoDialog';
const PRIORITY_COLORS: Record = {
high: 'bg-red-100 text-red-700',
@@ -13,13 +15,16 @@ export function KanbanBoard({
projectId,
columns,
initialItems,
+ members,
}: {
projectId: number;
columns: TodoColumn[];
initialItems: TodoItem[];
+ members: ProjectMemberWithUser[];
}) {
const [items, setItems] = useState(initialItems);
const [draggingId, setDraggingId] = useState(null);
+ const [selectedItem, setSelectedItem] = useState(null);
const reload = useCallback(async () => {
const res = await fetch(`/api/projects/${projectId}/todos/items`);
@@ -44,7 +49,7 @@ export function KanbanBoard({
setDraggingId(null);
return;
}
- // 楽観的にローカル更新したあとAPIで移動
+ // 楽観的にローカル更新したあとAPIで移動(失敗時は再取得で巻き戻す)
setItems((prev) =>
prev.map((i) => (i.id === itemId ? { ...i, columnId: column.id } : i))
);
@@ -52,7 +57,9 @@ export function KanbanBoard({
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ columnId: column.id, orderIndex: 0 }),
- }).then(() => reload());
+ })
+ .catch(() => undefined)
+ .finally(() => reload());
setDraggingId(null);
}
@@ -91,48 +98,99 @@ export function KanbanBoard({
{column.name} ({colItems.length})
addTask(column.id, title)} />
);
})}
+
+ {selectedItem && (
+ setSelectedItem(null)}
+ onSaved={reload}
+ />
+ )}
);
}
diff --git a/components/todo/TodoDialog.tsx b/components/todo/TodoDialog.tsx
new file mode 100644
index 0000000..e4e466c
--- /dev/null
+++ b/components/todo/TodoDialog.tsx
@@ -0,0 +1,311 @@
+'use client';
+
+import { useEffect, useRef, useState } from 'react';
+import type { AttachmentView, TodoItem, TodoPriority } from '@/lib/types';
+import type { ProjectMemberWithUser } from '@/repositories/ProjectMemberRepository';
+import { AttachmentList } from '@/components/files/AttachmentList';
+import {
+ AttachmentPicker,
+ type AttachmentPickerHandle,
+} from '@/components/files/AttachmentPicker';
+
+const PRIORITIES: TodoPriority[] = ['low', 'normal', 'high'];
+
+function parseTags(tags: string | null): string[] {
+ if (!tags) return [];
+ return tags
+ .split(',')
+ .map((t) => t.trim())
+ .filter((t) => t.length > 0);
+}
+
+/**
+ * ToDo編集/詳細ダイアログ。
+ * カードクリックで開き、タイトル/説明/担当/優先度/開始日/期限/タグ/添付を
+ * 編集・閲覧できる。完了日(完了時刻)・作成/更新日時は読み取り専用で表示。
+ */
+export function TodoDialog({
+ projectId,
+ item,
+ members,
+ onClose,
+ onSaved,
+}: {
+ projectId: number;
+ item: TodoItem;
+ members: ProjectMemberWithUser[];
+ onClose: () => void;
+ onSaved: () => void | Promise;
+}) {
+ const [title, setTitle] = useState(item.title);
+ const [description, setDescription] = useState(item.description ?? '');
+ const [assigneeId, setAssigneeId] = useState(
+ item.assigneeId ? String(item.assigneeId) : ''
+ );
+ const [priority, setPriority] = useState(item.priority);
+ const [startDate, setStartDate] = useState(item.startDate ?? '');
+ const [dueDate, setDueDate] = useState(item.dueDate ?? '');
+ const [tags, setTags] = useState(item.tags ?? '');
+ const [attachments, setAttachments] = useState([]);
+ const [current, setCurrent] = useState(item);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [pickerLoading, setPickerLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const pickerRef = useRef(null);
+
+ // 開封時に最新のアイテム+添付を取得
+ useEffect(() => {
+ let alive = true;
+ setLoading(true);
+ fetch(`/api/projects/${projectId}/todos/items/${item.id}`)
+ .then((res) => (res.ok ? res.json() : null))
+ .then((data) => {
+ if (!alive || !data) return;
+ const fetched = data.item as TodoItem;
+ setTitle(fetched.title);
+ setDescription(fetched.description ?? '');
+ setAssigneeId(fetched.assigneeId ? String(fetched.assigneeId) : '');
+ setPriority(fetched.priority);
+ setStartDate(fetched.startDate ?? '');
+ setDueDate(fetched.dueDate ?? '');
+ setTags(fetched.tags ?? '');
+ setAttachments((data.attachments as AttachmentView[]) ?? []);
+ setCurrent(fetched);
+ })
+ .catch(() => undefined)
+ .finally(() => alive && setLoading(false));
+ return () => {
+ alive = false;
+ };
+ }, [projectId, item.id]);
+
+ useEffect(() => {
+ function onKey(e: KeyboardEvent) {
+ if (e.key === 'Escape') onClose();
+ }
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, [onClose]);
+
+ async function onSave() {
+ setSaving(true);
+ setError(null);
+ const fileIds = pickerRef.current?.getFileIds() ?? [];
+ const res = await fetch(
+ `/api/projects/${projectId}/todos/items/${item.id}`,
+ {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ title,
+ description,
+ assigneeId: assigneeId ? Number(assigneeId) : null,
+ priority,
+ startDate: startDate || null,
+ dueDate: dueDate || null,
+ tags: tags || null,
+ fileIds,
+ }),
+ }
+ );
+ setSaving(false);
+ if (res.ok) {
+ pickerRef.current?.clear();
+ await onSaved();
+ onClose();
+ } else {
+ const b = (await res.json().catch(() => null)) as {
+ error?: { message?: string };
+ } | null;
+ setError(b?.error?.message ?? '保存に失敗しました');
+ }
+ }
+
+ const tagList = parseTags(tags);
+
+ return (
+
+
e.stopPropagation()}
+ role="dialog"
+ aria-modal="true"
+ aria-label={`ToDo ${item.title} の編集`}
+ data-testid="todo-dialog"
+ >
+
+
ToDoの編集
+
+
+
+ {loading ? (
+
読み込み中...
+ ) : (
+
+
+
+ setTitle(e.target.value)}
+ className="mt-1 w-full rounded border px-3 py-2"
+ data-testid="todo-title"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
setTags(e.target.value)}
+ placeholder="frontend, urgent"
+ className="mt-1 w-full rounded border px-3 py-2"
+ data-testid="todo-tags"
+ />
+ {tagList.length > 0 && (
+
+ {tagList.map((t) => (
+
+ {t}
+
+ ))}
+
+ )}
+
+
+
+
添付ファイル
+ {attachments.length > 0 ? (
+
+ ) : (
+
添付なし
+ )}
+
+
+
+
+
完了日時: {current.completedAt ?? '未完了'}
+
作成: {current.createdAt}
+
更新: {current.updatedAt}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/docs/functional-design.md b/docs/functional-design.md
index 2c5a4c7..df434ee 100644
--- a/docs/functional-design.md
+++ b/docs/functional-design.md
@@ -200,6 +200,7 @@ interface TodoItem {
completedAt: string | null;
orderIndex: number;
milestoneId: number | null; // FK milestones.id
+ tags: string | null; // カンマ区切りのタグ(project_notes.tags と同じ方式)
createdAt: string;
updatedAt: string;
deletedAt: string | null;
@@ -219,9 +220,31 @@ interface FileAsset {
mimeType: string; // MIMEタイプ(アップロード時チェック)
size: number; // バイト数
path: string; // ローカルパス(uploads/...)
+ source: FileAssetSource; // 'library'(Files一覧公開) | 'attachment'(添付専用)
createdAt: string;
deletedAt: string | null;
}
+type FileAssetSource = 'library' | 'attachment';
+```
+
+### attachments
+
+```typescript
+// file_assets とチャット/掲示板/ToDo を多対多で紐付ける
+interface Attachment {
+ id: number;
+ projectId: number; // FK projects.id ON DELETE CASCADE
+ fileId: number; // FK file_assets.id
+ targetType: AttachmentTargetType;
+ targetId: number; // targetType に応じた chat_message/board_thread/board_comment/todo_item の id
+ createdAt: string;
+ deletedAt: string | null;
+}
+type AttachmentTargetType =
+ | 'chat_message'
+ | 'board_thread'
+ | 'board_comment'
+ | 'todo_item';
```
### project_notes
@@ -410,6 +433,7 @@ erDiagram
integer assignee_id FK
integer milestone_id FK
text due_date
+ text tags
}
meetings {
integer id PK
diff --git a/docs/repository-structure.md b/docs/repository-structure.md
index 1c431a9..efaa033 100644
--- a/docs/repository-structure.md
+++ b/docs/repository-structure.md
@@ -235,7 +235,7 @@ components/
├── project/ # ProjectCard, DashboardWidget
├── board/ # ThreadList, ThreadForm, CommentList
├── chat/ # ChatWindow, MessageInput, MessageList
-├── todo/ # KanbanBoard, KanbanColumn, TodoCard
+├── todo/ # KanbanBoard, TodoDialog
├── files/ # FileList, Uploader, Lightbox, AttachmentList, AttachmentPicker
├── calendar/ # CalendarView, MonthView, WeekView, DayView, EventDetailDialog, CalendarEventForm
├── meetings/ # MeetingForm, ConflictWarning
diff --git a/lib/api/services.ts b/lib/api/services.ts
index 6c38e70..cc3fcb9 100644
--- a/lib/api/services.ts
+++ b/lib/api/services.ts
@@ -98,12 +98,19 @@ export function createChatService(): ChatService {
export function createTodoService(): TodoService {
const db = getDb();
+ const memberRepo = new ProjectMemberRepository(db);
return new TodoService(
new TodoRepository(db),
- new ProjectMemberRepository(db),
+ memberRepo,
new NotificationService(new NotificationRepository(db)),
new ActivityLogService(new ActivityLogRepository(db)),
- getSseHub()
+ getSseHub(),
+ new AttachmentService(
+ new AttachmentRepository(db),
+ new FileRepository(db),
+ memberRepo
+ ),
+ db
);
}
diff --git a/lib/db/migrations/003_todo_tags.sql b/lib/db/migrations/003_todo_tags.sql
new file mode 100644
index 0000000..c53af38
--- /dev/null
+++ b/lib/db/migrations/003_todo_tags.sql
@@ -0,0 +1,4 @@
+-- 003_todo_tags.sql
+-- ToDoアイテムにタグ列を追加(カンマ区切り、project_notes.tags と同じ方式)。
+
+ALTER TABLE todo_items ADD COLUMN tags TEXT;
diff --git a/lib/types/index.ts b/lib/types/index.ts
index 1f8ae70..cd07525 100644
--- a/lib/types/index.ts
+++ b/lib/types/index.ts
@@ -127,6 +127,7 @@ export interface TodoItem {
completedAt: string | null;
orderIndex: number;
milestoneId: number | null;
+ tags: string | null;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
@@ -153,7 +154,8 @@ export type FileAssetSource = 'library' | 'attachment';
export type AttachmentTargetType =
| 'chat_message'
| 'board_thread'
- | 'board_comment';
+ | 'board_comment'
+ | 'todo_item';
/** attachments エンティティ。 */
export interface Attachment {
diff --git a/repositories/MilestoneRepository.ts b/repositories/MilestoneRepository.ts
index c0e7108..39ce1cc 100644
--- a/repositories/MilestoneRepository.ts
+++ b/repositories/MilestoneRepository.ts
@@ -1,5 +1,10 @@
import type { SqliteDatabase } from '@/lib/db/sqlite';
-import type { Milestone, MilestoneStatus, TodoItem } from '@/lib/types';
+import type {
+ Milestone,
+ MilestoneStatus,
+ TodoItem,
+ TodoPriority,
+} from '@/lib/types';
interface MilestoneRow {
id: number;
@@ -27,6 +32,7 @@ interface TodoItemRow {
completed_at: string | null;
order_index: number;
milestone_id: number | null;
+ tags: string | null;
created_at: string;
updated_at: string;
deleted_at: string | null;
@@ -55,12 +61,13 @@ function mapTodo(row: TodoItemRow): TodoItem {
description: row.description,
assigneeId: row.assignee_id,
creatorId: row.creator_id,
- priority: row.priority as never,
+ 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,
+ tags: row.tags,
createdAt: row.created_at,
updatedAt: row.updated_at,
deletedAt: row.deleted_at,
diff --git a/repositories/TodoRepository.ts b/repositories/TodoRepository.ts
index bc5a6bf..a900bac 100644
--- a/repositories/TodoRepository.ts
+++ b/repositories/TodoRepository.ts
@@ -24,6 +24,7 @@ interface TodoItemRow {
completed_at: string | null;
order_index: number;
milestone_id: number | null;
+ tags: string | null;
created_at: string;
updated_at: string;
deleted_at: string | null;
@@ -55,6 +56,7 @@ function mapItem(row: TodoItemRow): TodoItem {
completedAt: row.completed_at,
orderIndex: row.order_index,
milestoneId: row.milestone_id,
+ tags: row.tags,
createdAt: row.created_at,
updatedAt: row.updated_at,
deletedAt: row.deleted_at,
@@ -75,7 +77,9 @@ export interface CreateItemInput {
description?: string | null;
assigneeId?: number | null;
priority?: TodoPriority;
+ startDate?: string | null;
dueDate?: string | null;
+ tags?: string | null;
orderIndex: number;
}
@@ -84,7 +88,9 @@ export interface UpdateItemInput {
description?: string | null;
assigneeId?: number | null;
priority?: TodoPriority;
+ startDate?: string | null;
dueDate?: string | null;
+ tags?: string | null;
completedAt?: string | null;
columnId?: number;
orderIndex?: number;
@@ -193,8 +199,8 @@ export class TodoRepository {
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)`,
+ `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, tags, created_at, updated_at, deleted_at)
+ VALUES (@projectId, @columnId, @title, @description, @assigneeId, @creatorId, @priority, @startDate, @dueDate, NULL, @orderIndex, @milestoneId, @tags, @createdAt, @updatedAt, NULL)`,
{
projectId: input.projectId,
columnId: input.columnId,
@@ -203,9 +209,11 @@ export class TodoRepository {
assigneeId: input.assigneeId ?? null,
creatorId: input.creatorId,
priority: input.priority ?? 'normal',
+ startDate: input.startDate ?? null,
dueDate: input.dueDate ?? null,
orderIndex: input.orderIndex,
milestoneId: null,
+ tags: input.tags ?? null,
createdAt: now,
updatedAt: now,
}
@@ -237,10 +245,18 @@ export class TodoRepository {
fields.push('priority = @priority');
params.priority = input.priority;
}
+ if (input.startDate !== undefined) {
+ fields.push('start_date = @startDate');
+ params.startDate = input.startDate;
+ }
if (input.dueDate !== undefined) {
fields.push('due_date = @dueDate');
params.dueDate = input.dueDate;
}
+ if (input.tags !== undefined) {
+ fields.push('tags = @tags');
+ params.tags = input.tags;
+ }
if (input.completedAt !== undefined) {
fields.push('completed_at = @completedAt');
params.completedAt = input.completedAt;
diff --git a/services/TodoService.ts b/services/TodoService.ts
index b9d88ac..4c15cbb 100644
--- a/services/TodoService.ts
+++ b/services/TodoService.ts
@@ -2,9 +2,16 @@ import { TodoRepository } from '@/repositories/TodoRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { NotificationService } from '@/services/NotificationService';
import { ActivityLogService } from '@/services/ActivityLogService';
+import { AttachmentService } from '@/services/AttachmentService';
import { SseHub } from '@/lib/sse/hub';
+import type { SqliteDatabase } from '@/lib/db/sqlite';
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
-import type { TodoColumn, TodoItem, TodoPriority } from '@/lib/types';
+import type {
+ AttachmentView,
+ TodoColumn,
+ TodoItem,
+ TodoPriority,
+} from '@/lib/types';
const STANDARD_COLUMNS = ['Backlog', 'To Do', 'In Progress', 'Review', 'Done'];
const VALID_PRIORITIES: TodoPriority[] = ['low', 'normal', 'high'];
@@ -15,7 +22,10 @@ export interface CreateItemInput {
description?: string;
assigneeId?: number | null;
priority?: TodoPriority;
+ startDate?: string | null;
dueDate?: string | null;
+ tags?: string | null;
+ fileIds?: number[];
}
export interface UpdateItemRequest {
@@ -23,16 +33,20 @@ export interface UpdateItemRequest {
description?: string | null;
assigneeId?: number | null;
priority?: TodoPriority;
+ startDate?: string | null;
dueDate?: string | null;
+ tags?: string | null;
columnId?: number;
orderIndex?: number;
milestoneId?: number | null;
+ fileIds?: number[];
}
/**
* ToDo/Kanbanの業務ロジックを担うService。
* 標準カラム初期生成、権限チェック、タスクCRUD/移動/完了、
- * 担当者割当時の通知(todo_assigned)とアクティビティログ(todo_*)、SSE配信を行う。
+ * 担当者割当時の通知(todo_assigned)とアクティビティログ(todo_*)、
+ * 添付ファイル紐付け、SSE配信を行う。
*/
export class TodoService {
constructor(
@@ -40,7 +54,9 @@ export class TodoService {
private readonly projectMemberRepository: ProjectMemberRepository,
private readonly notificationService: NotificationService,
private readonly activityLogService: ActivityLogService,
- private readonly sseHub: SseHub
+ private readonly sseHub: SseHub,
+ private readonly attachmentService: AttachmentService,
+ private readonly db: SqliteDatabase
) {}
getColumns(actorId: number, projectId: number): TodoColumn[] {
@@ -119,20 +135,33 @@ export class TodoService {
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,
+ // タスク本体と添付紐付けはトランザクション内で一貫保存する
+ const item = this.db.transaction(() => {
+ const orderIndex =
+ this.todoRepository.maxItemOrderIndex(input.columnId) + 1;
+ const created = this.todoRepository.createItem({
+ projectId,
+ columnId: input.columnId,
+ title: input.title,
+ creatorId: actorId,
+ description: input.description ?? null,
+ assigneeId: input.assigneeId ?? null,
+ priority: input.priority,
+ startDate: input.startDate ?? null,
+ dueDate: input.dueDate ?? null,
+ tags: input.tags ?? null,
+ orderIndex,
+ });
+ if (input.fileIds && input.fileIds.length > 0) {
+ this.attachmentService.attach(
+ actorId,
+ projectId,
+ 'todo_item',
+ created.id,
+ input.fileIds
+ );
+ }
+ return created;
});
if (input.assigneeId && input.assigneeId !== actorId) {
@@ -155,7 +184,19 @@ export class TodoService {
throw new ValidationError('無効な優先度です', 'priority');
}
const previousAssignee = item.assigneeId;
- const updated = this.todoRepository.updateItem(itemId, input);
+ const updated = this.db.transaction(() => {
+ const u = this.todoRepository.updateItem(itemId, input);
+ if (u && input.fileIds && input.fileIds.length > 0) {
+ this.attachmentService.attach(
+ actorId,
+ item.projectId,
+ 'todo_item',
+ itemId,
+ input.fileIds
+ );
+ }
+ return u;
+ });
if (!updated) throw new NotFoundError('TodoItem', itemId);
if (
@@ -220,10 +261,29 @@ export class TodoService {
if (item.creatorId !== actorId && role !== 'admin') {
throw new ForbiddenError('作成者または管理者のみ削除できます');
}
- this.todoRepository.deleteItem(itemId);
+ this.db.transaction(() => {
+ this.attachmentService.detach('todo_item', itemId);
+ this.todoRepository.deleteItem(itemId);
+ });
this.broadcastTodo(item.projectId);
}
+ /** 単一のToDoアイテムを取得(メンバーシップチェック付き)。 */
+ getItem(actorId: number, itemId: number): TodoItem {
+ const item = this.todoRepository.findItemById(itemId);
+ if (!item) throw new NotFoundError('TodoItem', itemId);
+ this.requireMember(item.projectId, actorId);
+ return item;
+ }
+
+ /** ToDoに紐付く添付ファイル一覧を取得(メンバーシップチェック付き)。 */
+ getItemAttachments(actorId: number, itemId: number): AttachmentView[] {
+ const item = this.todoRepository.findItemById(itemId);
+ if (!item) throw new NotFoundError('TodoItem', itemId);
+ this.requireMember(item.projectId, actorId);
+ return this.attachmentService.listViews('todo_item', itemId);
+ }
+
private requireMember(projectId: number, actorId: number): void {
if (!this.projectMemberRepository.isMember(projectId, actorId)) {
throw new ForbiddenError('プロジェクトに参加していません');
diff --git a/tests/e2e/todo-kanban.spec.ts b/tests/e2e/todo-kanban.spec.ts
index 8309566..7eef379 100644
--- a/tests/e2e/todo-kanban.spec.ts
+++ b/tests/e2e/todo-kanban.spec.ts
@@ -76,4 +76,99 @@ test.describe('todo / kanban', () => {
page.getByTestId(`kanban-column-${done.id}`).getByText(title)
).toBeVisible();
});
+
+ test('edit dialog shows and persists metadata + file attachments', 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')!;
+
+ // オーナー(担当者候補)のuser idを取得
+ const members = (
+ (await (
+ await page.request.get(`/api/projects/${projectId}/members`)
+ ).json()) as { members: { user: { id: number; name: string } }[] }
+ ).members;
+ const ownerId = members[0].user.id;
+
+ // タスク作成(API)
+ const title = unique('Task');
+ const createRes = await page.request.post(
+ `/api/projects/${projectId}/todos/items`,
+ { data: { title, columnId: backlog.id } }
+ );
+ const { item } = (await createRes.json()) as { item: { id: number } };
+
+ // 添付ファイル(1x1 PNG)をアップロード
+ const png = Buffer.from(
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
+ 'base64'
+ );
+ const upRes = await page.request.post(
+ `/api/projects/${projectId}/attachments`,
+ {
+ multipart: {
+ file: { name: 'pic.png', mimeType: 'image/png', buffer: png },
+ },
+ }
+ );
+ expect(upRes.ok()).toBeTruthy();
+ const { file } = (await upRes.json()) as { file: { id: number } };
+
+ // ダイアログを開いてメタデータを編集
+ await page.goto(`/projects/${projectId}/todos`);
+ await page.getByTestId(`todo-card-${item.id}`).click();
+ await expect(page.getByTestId('todo-dialog')).toBeVisible();
+ await page.getByTestId('todo-description').fill('detailed desc');
+ await page.getByTestId('todo-tags').fill('frontend, urgent');
+ await page.getByTestId('todo-start-date').fill('2026-07-01');
+ await page.getByTestId('todo-due-date').fill('2026-07-31');
+ await page.getByTestId('todo-assignee').selectOption(String(ownerId));
+ await page.getByTestId('todo-save').click();
+ await expect(page.getByTestId('todo-dialog')).toBeHidden();
+
+ // カードにタグが表示される
+ await expect(page.getByText('frontend')).toBeVisible();
+
+ // APIでファイルを添付付け
+ const attachRes = await page.request.patch(
+ `/api/projects/${projectId}/todos/items/${item.id}`,
+ { data: { fileIds: [file.id] } }
+ );
+ expect(attachRes.ok()).toBeTruthy();
+
+ // GET でアイテム+添付が取得できる
+ const getRes = await page.request.get(
+ `/api/projects/${projectId}/todos/items/${item.id}`
+ );
+ const body = (await getRes.json()) as {
+ item: {
+ description: string;
+ tags: string;
+ startDate: string;
+ dueDate: string;
+ assigneeId: number;
+ };
+ attachments: { fileId: number }[];
+ };
+ expect(body.item.description).toBe('detailed desc');
+ expect(body.item.tags).toBe('frontend, urgent');
+ expect(body.item.startDate).toBe('2026-07-01');
+ expect(body.item.dueDate).toBe('2026-07-31');
+ expect(body.item.assigneeId).toBe(ownerId);
+ expect(body.attachments).toHaveLength(1);
+
+ // ダイアログ再オープンで添付画像が表示される
+ await page.reload();
+ await page.getByTestId(`todo-card-${item.id}`).click();
+ await expect(page.getByTestId('todo-dialog')).toBeVisible();
+ await expect(page.getByTestId('attachment-list')).toBeVisible();
+ });
});
diff --git a/tests/unit/lib/db/migrator.test.ts b/tests/unit/lib/db/migrator.test.ts
index 36c1298..c7e30f1 100644
--- a/tests/unit/lib/db/migrator.test.ts
+++ b/tests/unit/lib/db/migrator.test.ts
@@ -182,6 +182,7 @@ describe('Migrator', () => {
expect(migrator.getAppliedMigrations().map((m) => m.filename)).toEqual([
'001_initial.sql',
'002_attachments.sql',
+ '003_todo_tags.sql',
]);
});
});
diff --git a/tests/unit/repositories/TodoRepository.test.ts b/tests/unit/repositories/TodoRepository.test.ts
index 545643e..0699d05 100644
--- a/tests/unit/repositories/TodoRepository.test.ts
+++ b/tests/unit/repositories/TodoRepository.test.ts
@@ -114,6 +114,49 @@ describe('TodoRepository', () => {
expect(updated?.completedAt).toBeTruthy();
});
+ it('persists startDate and tags on create', () => {
+ const item = repo.createItem({
+ projectId,
+ columnId,
+ title: 't',
+ creatorId: userId,
+ orderIndex: 0,
+ startDate: '2026-07-01',
+ tags: 'frontend,urgent',
+ });
+ const found = repo.findItemById(item.id);
+ expect(found?.startDate).toBe('2026-07-01');
+ expect(found?.tags).toBe('frontend,urgent');
+ });
+
+ it('updates startDate and tags', () => {
+ const item = repo.createItem({
+ projectId,
+ columnId,
+ title: 't',
+ creatorId: userId,
+ orderIndex: 0,
+ });
+ const updated = repo.updateItem(item.id, {
+ startDate: '2026-08-01',
+ tags: 'backend',
+ });
+ expect(updated?.startDate).toBe('2026-08-01');
+ expect(updated?.tags).toBe('backend');
+ });
+
+ it('defaults startDate and tags to null when omitted', () => {
+ const item = repo.createItem({
+ projectId,
+ columnId,
+ title: 't',
+ creatorId: userId,
+ orderIndex: 0,
+ });
+ expect(item.startDate).toBeNull();
+ expect(item.tags).toBeNull();
+ });
+
it('isolates items by project', () => {
const p2 = new ProjectRepository(db).create({
name: 'P2',
diff --git a/tests/unit/services/TodoService.test.ts b/tests/unit/services/TodoService.test.ts
index b0773af..627e705 100644
--- a/tests/unit/services/TodoService.test.ts
+++ b/tests/unit/services/TodoService.test.ts
@@ -5,10 +5,13 @@ import { UserRepository } from '@/repositories/UserRepository';
import { ProjectRepository } from '@/repositories/ProjectRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { TodoRepository } from '@/repositories/TodoRepository';
+import { FileRepository } from '@/repositories/FileRepository';
+import { AttachmentRepository } from '@/repositories/AttachmentRepository';
import { NotificationRepository } from '@/repositories/NotificationRepository';
import { ActivityLogRepository } from '@/repositories/ActivityLogRepository';
import { NotificationService } from '@/services/NotificationService';
import { ActivityLogService } from '@/services/ActivityLogService';
+import { AttachmentService } from '@/services/AttachmentService';
import { TodoService } from '@/services/TodoService';
import { SseHub } from '@/lib/sse/hub';
import { ForbiddenError, NotFoundError } from '@/lib/errors';
@@ -24,7 +27,13 @@ function makeService(db: SqliteDatabase) {
members,
new NotificationService(new NotificationRepository(db)),
new ActivityLogService(new ActivityLogRepository(db)),
- hub
+ hub,
+ new AttachmentService(
+ new AttachmentRepository(db),
+ new FileRepository(db),
+ members
+ ),
+ db
),
members,
users,
@@ -161,4 +170,142 @@ describe('TodoService', () => {
NotFoundError
);
});
+
+ it('createItem persists startDate/tags and attaches files', () => {
+ const col = service.getColumns(authorId, projectId)[0];
+ const fileRepo = new FileRepository(db);
+ const f1 = fileRepo.create({
+ projectId,
+ uploaderId: authorId,
+ filename: 'a.png',
+ originalName: 'a.png',
+ mimeType: 'image/png',
+ size: 1,
+ path: '/tmp/a.png',
+ source: 'attachment',
+ });
+ const item = service.createItem(authorId, projectId, {
+ title: 'rich',
+ columnId: col.id,
+ startDate: '2026-07-01',
+ dueDate: '2026-07-31',
+ tags: 'frontend,urgent',
+ fileIds: [f1.id],
+ });
+ expect(item.startDate).toBe('2026-07-01');
+ expect(item.tags).toBe('frontend,urgent');
+ expect(service.getItemAttachments(authorId, item.id)).toHaveLength(1);
+ });
+
+ it('updateItem updates startDate/tags and attaches new files', () => {
+ const col = service.getColumns(authorId, projectId)[0];
+ const item = service.createItem(authorId, projectId, {
+ title: 't',
+ columnId: col.id,
+ });
+ const fileRepo = new FileRepository(db);
+ const f1 = fileRepo.create({
+ projectId,
+ uploaderId: authorId,
+ filename: 'b.png',
+ originalName: 'b.png',
+ mimeType: 'image/png',
+ size: 1,
+ path: '/tmp/b.png',
+ source: 'attachment',
+ });
+ const updated = service.updateItem(authorId, item.id, {
+ description: 'desc',
+ startDate: '2026-08-01',
+ tags: 'backend',
+ fileIds: [f1.id],
+ });
+ expect(updated.description).toBe('desc');
+ expect(updated.startDate).toBe('2026-08-01');
+ expect(updated.tags).toBe('backend');
+ expect(service.getItemAttachments(authorId, item.id)).toHaveLength(1);
+ });
+
+ it('deleteItem detaches its attachments', () => {
+ const col = service.getColumns(authorId, projectId)[0];
+ const fileRepo = new FileRepository(db);
+ const f1 = fileRepo.create({
+ projectId,
+ uploaderId: authorId,
+ filename: 'a.png',
+ originalName: 'a.png',
+ mimeType: 'image/png',
+ size: 1,
+ path: '/tmp/a.png',
+ source: 'attachment',
+ });
+ const item = service.createItem(authorId, projectId, {
+ title: 't',
+ columnId: col.id,
+ fileIds: [f1.id],
+ });
+ expect(service.getItemAttachments(authorId, item.id)).toHaveLength(1);
+ service.deleteItem(authorId, item.id);
+ expect(
+ new AttachmentRepository(db).findByTarget('todo_item', item.id)
+ ).toHaveLength(0);
+ });
+
+ it('getItem and getItemAttachments enforce membership', () => {
+ const col = service.getColumns(authorId, projectId)[0];
+ const item = service.createItem(authorId, projectId, {
+ title: 't',
+ columnId: col.id,
+ });
+ expect(service.getItem(authorId, item.id).id).toBe(item.id);
+ expect(() => service.getItem(outsiderId, item.id)).toThrow(ForbiddenError);
+ expect(() => service.getItemAttachments(outsiderId, item.id)).toThrow(
+ ForbiddenError
+ );
+ });
+
+ it('only the creator or an admin can delete an item (member-but-not-creator cannot)', () => {
+ const col = service.getColumns(authorId, projectId)[0];
+ // authorId(admin)が作成したアイテムを memberId(非管理者・非作成者)は削除できない
+ const item = service.createItem(authorId, projectId, {
+ title: 't',
+ columnId: col.id,
+ });
+ expect(() => service.deleteItem(memberId, item.id)).toThrow(ForbiddenError);
+ // admin(作成者)は削除可
+ service.deleteItem(authorId, item.id);
+ expect(() => service.getItem(authorId, item.id)).toThrow(NotFoundError);
+ });
+
+ it('deleteColumn: admin can delete, non-admin member cannot', () => {
+ const col = service.getColumns(authorId, projectId)[0];
+ // memberId(非管理者)はカラム削除不可
+ expect(() => service.deleteColumn(memberId, col.id)).toThrow(
+ ForbiddenError
+ );
+ // authorId(admin)は削除可
+ service.deleteColumn(authorId, col.id);
+ expect(() => service.updateColumn(authorId, col.id, { name: 'x' })).toThrow(
+ NotFoundError
+ );
+ });
+
+ it('clears startDate/tags/assigneeId via null on update', () => {
+ const col = service.getColumns(authorId, projectId)[0];
+ const item = service.createItem(authorId, projectId, {
+ title: 't',
+ columnId: col.id,
+ startDate: '2026-07-01',
+ tags: 'a,b',
+ assigneeId: memberId,
+ });
+ const cleared = service.updateItem(authorId, item.id, {
+ startDate: null,
+ tags: null,
+ assigneeId: null,
+ });
+ expect(cleared.startDate).toBeNull();
+ expect(cleared.tags).toBeNull();
+ expect(cleared.assigneeId).toBeNull();
+ });
});