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 { AttachmentView, 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; startDate?: string | null; dueDate?: string | null; tags?: string | null; fileIds?: number[]; } export interface UpdateItemRequest { title?: string; 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配信を行う。 */ export class TodoService { constructor( private readonly todoRepository: TodoRepository, private readonly projectMemberRepository: ProjectMemberRepository, private readonly notificationService: NotificationService, private readonly activityLogService: ActivityLogService, private readonly sseHub: SseHub, private readonly attachmentService: AttachmentService, private readonly db: SqliteDatabase ) {} 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 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) { 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.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 ( 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; } /** * 指定カラム内のアイテム順序を itemIds の順で再採番する。 * 同一カラムの並べ替えだけでなく、別カラムからの移動(対象カラムへの挿入)にも使う。 * すべて同一プロジェクト内であることを検証し、1トランザクションで更新する。 */ reorderItems( actorId: number, projectId: number, columnId: number, itemIds: number[] ): TodoItem[] { this.requireMember(projectId, actorId); const column = this.todoRepository.findColumnById(columnId); if (!column || column.projectId !== projectId) { throw new NotFoundError('TodoColumn', columnId); } const ids = Array.isArray(itemIds) ? itemIds : []; if (ids.length === 0) { // 空の並べ替えは何も変更せず、SSE/ログも出さない return this.todoRepository .findItemsByProject(projectId) .filter((i) => i.columnId === columnId); } this.db.transaction(() => { ids.forEach((id, idx) => { const item = this.todoRepository.findItemById(id); if (!item || item.projectId !== projectId) { throw new NotFoundError('TodoItem', id); } this.todoRepository.updateItem(id, { columnId, orderIndex: idx }); }); }); this.logTodo(projectId, actorId, 'todo_updated', ids[0]); this.broadcastTodo(projectId); return this.todoRepository .findItemsByProject(projectId) .filter((i) => i.columnId === columnId); } 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.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('プロジェクトに参加していません'); } } 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 }, }); } }