Files
opengroupware/repositories/TodoRepository.ts
Ken Yasue 91dd05ba7b feat(todo): richer metadata (tags, startDate, files) + edit/detail dialog
ToDoアイテムのメタデータを拡充し、編集/詳細ダイアログを追加。

- lib/db/migrations/003_todo_tags.sql: todo_items に tags 列を追加
- TodoRepository/TodoService: startDate(従来ハードコードNULL)とtagsをcreate/updateで永続化、fileIdsで添付紐付け(トランザクション)、deleteItemで添付クリーンアップ、getItem/getItemAttachments を追加
- API: GET /todos/items/[itemId] ({item, attachments}) を追加、POST/PATCH で startDate/tags/fileIds を受理、PATCH は null(クリア)と undefined(更新しない)を区別
- UI: TodoDialog(タイトル/説明/担当/優先度/開始日/期限/タグ/添付を編集、完了日時・タイムスタンプは読み取り専用)、KanbanBoard のカードクリックでダイアログを開きメタデータを表示
- MilestoneRepository の todo マッピングに tags を追加し as never キャストを修正
- docs/functional-design.md に tags / file_assets.source / attachments テーブルを追記
2026-06-25 10:51:50 +02:00

308 lines
8.9 KiB
TypeScript

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;
tags: string | 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,
tags: row.tags,
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;
startDate?: string | null;
dueDate?: string | null;
tags?: string | null;
orderIndex: number;
}
export interface UpdateItemInput {
title?: string;
description?: string | null;
assigneeId?: number | null;
priority?: TodoPriority;
startDate?: string | null;
dueDate?: string | null;
tags?: 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, 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,
title: input.title,
description: input.description ?? null,
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,
}
);
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.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;
}
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;
}
/** 指定期間内に期限を迎える、指定担当者の高優先度ToDo(スケジュール重複判定用) */
findHighPriorityByAssignee(
projectId: number,
assigneeId: number,
from: string,
to: string
): TodoItem[] {
const rows = this.db.query<TodoItemRow>(
`SELECT * FROM todo_items
WHERE project_id = @projectId AND assignee_id = @assigneeId
AND priority = 'high' AND deleted_at IS NULL
AND due_date IS NOT NULL AND due_date >= @from AND due_date <= @to`,
{ projectId, assigneeId, from, to }
);
return rows.map(mapItem);
}
}