チャット送信の即時表示とKanbanのDnD並べ替えを修正。 - chat: 送信成功時に返却メッセージを楽観的にローカルへ先頭追加(SSE到着前でも即表示、idで重複排除)。setSending(false) をレスポンス処理後に移動し二重送信窓を縮小。 - todo: TodoService.reorderItems(projectId, columnId, itemIds[]) を追加(1トランザクションでカラム内を再採番、メンバーシップ+同プロジェクト検証、活動ログ/SSEは1件)。POST /todos/items/reorder を追加。 - KanbanBoard: カード単位のドロップターゲット(ポインタYで前/後挿入)+カラム空き領域は末尾追加。楽観的にorderIndex/columnIdを更新しrenderをorderIndexでソート(即時反映)、失敗時はエラー表示+再取得で巻き戻し。ドラッグ直後の誤クリック(ダイアログ誘起)をdragEndタイムスタンプで抑制。空の並べ替えはSSE/ログを出さない。
375 lines
12 KiB
TypeScript
375 lines
12 KiB
TypeScript
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 },
|
|
});
|
|
}
|
|
}
|