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 テーブルを追記
This commit is contained in:
Ken Yasue
2026-06-25 10:51:50 +02:00
parent b2b3fb00b5
commit 91dd05ba7b
17 changed files with 885 additions and 76 deletions

View File

@ -6,6 +6,23 @@ import { handleApiError, jsonError } from '@/lib/api/handleError';
export const runtime = 'nodejs'; 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( export async function PATCH(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ projectId: string; itemId: string }> } { params }: { params: Promise<{ projectId: string; itemId: string }> }
@ -35,23 +52,29 @@ export async function PATCH(
); );
return NextResponse.json({ item }); 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), { const item = service.updateItem(user.id, Number(itemId), {
title: typeof body.title === 'string' ? body.title : undefined, title: typeof body.title === 'string' ? body.title : undefined,
description: description: nullableString(body.description),
typeof body.description === 'string' ? body.description : undefined, assigneeId: nullableNumber(body.assigneeId),
assigneeId:
body.assigneeId === null || body.assigneeId === undefined
? undefined
: Number(body.assigneeId),
priority: priority:
typeof body.priority === 'string' typeof body.priority === 'string'
? (body.priority as 'low' | 'normal' | 'high') ? (body.priority as 'low' | 'normal' | 'high')
: undefined, : undefined,
dueDate: typeof body.dueDate === 'string' ? body.dueDate : undefined, startDate: nullableString(body.startDate),
milestoneId: dueDate: nullableString(body.dueDate),
body.milestoneId === null || body.milestoneId === undefined tags: nullableString(body.tags),
? undefined milestoneId: nullableNumber(body.milestoneId),
: Number(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 }); return NextResponse.json({ item });
} catch (error) { } catch (error) {

View File

@ -38,6 +38,11 @@ export async function POST(
} }
const service = createTodoService(); const service = createTodoService();
try { 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), { const item = service.createItem(user.id, Number(projectId), {
title: String(body.title ?? ''), title: String(body.title ?? ''),
columnId: Number(body.columnId ?? 0), columnId: Number(body.columnId ?? 0),
@ -51,7 +56,11 @@ export async function POST(
typeof body.priority === 'string' typeof body.priority === 'string'
? (body.priority as 'low' | 'normal' | 'high') ? (body.priority as 'low' | 'normal' | 'high')
: undefined, : undefined,
startDate:
typeof body.startDate === 'string' ? body.startDate : undefined,
dueDate: typeof body.dueDate === 'string' ? body.dueDate : null, dueDate: typeof body.dueDate === 'string' ? body.dueDate : null,
tags: typeof body.tags === 'string' ? body.tags : undefined,
fileIds,
}); });
return NextResponse.json({ item }, { status: 201 }); return NextResponse.json({ item }, { status: 201 });
} catch (error) { } catch (error) {

View File

@ -31,6 +31,7 @@ export default async function TodosPage({
const todoService = createTodoService(); const todoService = createTodoService();
const columns = todoService.getColumns(user.id, project.id); const columns = todoService.getColumns(user.id, project.id);
const items = todoService.getItems(user.id, project.id); const items = todoService.getItems(user.id, project.id);
const members = projectService.getMembers(user.id, project.id);
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
@ -42,6 +43,7 @@ export default async function TodosPage({
projectId={project.id} projectId={project.id}
columns={columns} columns={columns}
initialItems={items} initialItems={items}
members={members}
/> />
</main> </main>
</div> </div>

View File

@ -2,6 +2,8 @@
import { useCallback, useState, type DragEvent } from 'react'; import { useCallback, useState, type DragEvent } from 'react';
import type { TodoColumn, TodoItem } from '@/lib/types'; import type { TodoColumn, TodoItem } from '@/lib/types';
import type { ProjectMemberWithUser } from '@/repositories/ProjectMemberRepository';
import { TodoDialog } from '@/components/todo/TodoDialog';
const PRIORITY_COLORS: Record<string, string> = { const PRIORITY_COLORS: Record<string, string> = {
high: 'bg-red-100 text-red-700', high: 'bg-red-100 text-red-700',
@ -13,13 +15,16 @@ export function KanbanBoard({
projectId, projectId,
columns, columns,
initialItems, initialItems,
members,
}: { }: {
projectId: number; projectId: number;
columns: TodoColumn[]; columns: TodoColumn[];
initialItems: TodoItem[]; initialItems: TodoItem[];
members: ProjectMemberWithUser[];
}) { }) {
const [items, setItems] = useState<TodoItem[]>(initialItems); const [items, setItems] = useState<TodoItem[]>(initialItems);
const [draggingId, setDraggingId] = useState<number | null>(null); const [draggingId, setDraggingId] = useState<number | null>(null);
const [selectedItem, setSelectedItem] = useState<TodoItem | null>(null);
const reload = useCallback(async () => { const reload = useCallback(async () => {
const res = await fetch(`/api/projects/${projectId}/todos/items`); const res = await fetch(`/api/projects/${projectId}/todos/items`);
@ -44,7 +49,7 @@ export function KanbanBoard({
setDraggingId(null); setDraggingId(null);
return; return;
} }
// 楽観的にローカル更新したあとAPIで移動 // 楽観的にローカル更新したあとAPIで移動(失敗時は再取得で巻き戻す)
setItems((prev) => setItems((prev) =>
prev.map((i) => (i.id === itemId ? { ...i, columnId: column.id } : i)) prev.map((i) => (i.id === itemId ? { ...i, columnId: column.id } : i))
); );
@ -52,7 +57,9 @@ export function KanbanBoard({
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ columnId: column.id, orderIndex: 0 }), body: JSON.stringify({ columnId: column.id, orderIndex: 0 }),
}).then(() => reload()); })
.catch(() => undefined)
.finally(() => reload());
setDraggingId(null); setDraggingId(null);
} }
@ -91,31 +98,49 @@ export function KanbanBoard({
{column.name} ({colItems.length}) {column.name} ({colItems.length})
</h2> </h2>
<ul className="space-y-2"> <ul className="space-y-2">
{colItems.map((item) => ( {colItems.map((item) => {
const tags = item.tags
?.split(',')
.map((t) => t.trim())
.filter(Boolean);
const assignee = members.find(
(m) => m.user.id === item.assigneeId
);
return (
<li <li
key={item.id} key={item.id}
draggable draggable
onDragStart={(e) => onDragStart(e, item.id)} onDragStart={(e) => onDragStart(e, item.id)}
onClick={() => setSelectedItem(item)}
className={`cursor-grab rounded border bg-white p-2 shadow-sm ${ className={`cursor-grab rounded border bg-white p-2 shadow-sm ${
draggingId === item.id ? 'opacity-50' : '' draggingId === item.id ? 'opacity-50' : ''
} ${item.completedAt ? 'opacity-60 line-through' : ''}`} } ${item.completedAt ? 'opacity-60 line-through' : ''}`}
data-testid={`todo-card-${item.id}`} data-testid={`todo-card-${item.id}`}
> >
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<span className="text-sm">{item.title}</span> <span
className="text-sm hover:text-blue-600 hover:underline"
data-testid={`todo-open-${item.id}`}
>
{item.title}
</span>
<button <button
type="button" type="button"
onClick={() => toggleComplete(item.id)} onClick={(e) => {
e.stopPropagation();
toggleComplete(item.id);
}}
className="text-xs text-blue-600 hover:underline" className="text-xs text-blue-600 hover:underline"
data-testid={`todo-complete-${item.id}`} data-testid={`todo-complete-${item.id}`}
> >
{item.completedAt ? '戻す' : '完了'} {item.completedAt ? '戻す' : '完了'}
</button> </button>
</div> </div>
<div className="mt-1 flex gap-1"> <div className="mt-1 flex flex-wrap items-center gap-1">
<span <span
className={`rounded px-1.5 py-0.5 text-xs ${ className={`rounded px-1.5 py-0.5 text-xs ${
PRIORITY_COLORS[item.priority] ?? PRIORITY_COLORS.normal PRIORITY_COLORS[item.priority] ??
PRIORITY_COLORS.normal
}`} }`}
> >
{item.priority} {item.priority}
@ -125,14 +150,47 @@ export function KanbanBoard({
: {item.dueDate} : {item.dueDate}
</span> </span>
)} )}
{item.startDate && (
<span className="text-xs text-gray-400">
: {item.startDate}
</span>
)}
{assignee && (
<span className="text-xs text-gray-500">
@{assignee.user.name}
</span>
)}
</div> </div>
</li> {tags && tags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{tags.map((t) => (
<span
key={t}
className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600"
>
{t}
</span>
))} ))}
</div>
)}
</li>
);
})}
</ul> </ul>
<NewTaskInput onAdd={(title) => addTask(column.id, title)} /> <NewTaskInput onAdd={(title) => addTask(column.id, title)} />
</section> </section>
); );
})} })}
{selectedItem && (
<TodoDialog
projectId={projectId}
item={selectedItem}
members={members}
onClose={() => setSelectedItem(null)}
onSaved={reload}
/>
)}
</div> </div>
); );
} }

View File

@ -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<void>;
}) {
const [title, setTitle] = useState(item.title);
const [description, setDescription] = useState(item.description ?? '');
const [assigneeId, setAssigneeId] = useState<string>(
item.assigneeId ? String(item.assigneeId) : ''
);
const [priority, setPriority] = useState<TodoPriority>(item.priority);
const [startDate, setStartDate] = useState(item.startDate ?? '');
const [dueDate, setDueDate] = useState(item.dueDate ?? '');
const [tags, setTags] = useState(item.tags ?? '');
const [attachments, setAttachments] = useState<AttachmentView[]>([]);
const [current, setCurrent] = useState<TodoItem>(item);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [pickerLoading, setPickerLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const pickerRef = useRef<AttachmentPickerHandle>(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 (
<div
className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 p-4"
onClick={onClose}
data-testid="todo-dialog-backdrop"
>
<div
className="mt-8 w-full max-w-xl rounded-lg bg-white shadow-xl"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label={`ToDo ${item.title} の編集`}
data-testid="todo-dialog"
>
<div className="flex items-center justify-between border-b p-4">
<h2 className="text-lg font-bold text-gray-800">ToDoの編集</h2>
<button
type="button"
onClick={onClose}
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
aria-label="閉じる"
data-testid="todo-dialog-close"
>
×
</button>
</div>
{loading ? (
<p className="p-6 text-sm text-gray-400">...</p>
) : (
<div className="max-h-[70vh] space-y-3 overflow-y-auto p-4">
<div>
<label className="block text-sm font-medium"></label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
data-testid="todo-title"
/>
</div>
<div>
<label className="block text-sm font-medium"></label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="mt-1 min-h-[80px] w-full rounded border px-3 py-2"
data-testid="todo-description"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium"></label>
<select
value={assigneeId}
onChange={(e) => setAssigneeId(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
data-testid="todo-assignee"
>
<option value=""></option>
{members.map((m) => (
<option key={m.user.id} value={String(m.user.id)}>
{m.user.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium"></label>
<select
value={priority}
onChange={(e) => setPriority(e.target.value as TodoPriority)}
className="mt-1 w-full rounded border px-3 py-2"
data-testid="todo-priority"
>
{PRIORITIES.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium"></label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
data-testid="todo-start-date"
/>
</div>
<div>
<label className="block text-sm font-medium">
(deadline)
</label>
<input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2"
data-testid="todo-due-date"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium">
()
</label>
<input
type="text"
value={tags}
onChange={(e) => 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 && (
<div className="mt-1 flex flex-wrap gap-1">
{tagList.map((t) => (
<span
key={t}
className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600"
>
{t}
</span>
))}
</div>
)}
</div>
<div>
<p className="mb-1 text-sm font-medium"></p>
{attachments.length > 0 ? (
<AttachmentList attachments={attachments} />
) : (
<p className="text-xs text-gray-400"></p>
)}
<div className="mt-2">
<AttachmentPicker
ref={pickerRef}
projectId={projectId}
onLoadingChange={setPickerLoading}
/>
</div>
</div>
<div className="space-y-1 border-t pt-3 text-xs text-gray-500">
<p>: {current.completedAt ?? '未完了'}</p>
<p>: {current.createdAt}</p>
<p>: {current.updatedAt}</p>
</div>
{error && (
<p className="text-sm text-red-600" role="alert">
{error}
</p>
)}
</div>
)}
<div className="flex justify-end gap-2 border-t p-4">
<button
type="button"
onClick={onClose}
className="rounded border px-4 py-2 text-sm text-gray-600 hover:bg-gray-100"
>
</button>
<button
type="button"
onClick={onSave}
disabled={saving || pickerLoading || loading}
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
data-testid="todo-save"
>
{saving ? '保存中...' : '保存'}
</button>
</div>
</div>
</div>
);
}

View File

@ -200,6 +200,7 @@ interface TodoItem {
completedAt: string | null; completedAt: string | null;
orderIndex: number; orderIndex: number;
milestoneId: number | null; // FK milestones.id milestoneId: number | null; // FK milestones.id
tags: string | null; // カンマ区切りのタグ(project_notes.tags と同じ方式)
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
deletedAt: string | null; deletedAt: string | null;
@ -219,9 +220,31 @@ interface FileAsset {
mimeType: string; // MIMEタイプアップロード時チェック mimeType: string; // MIMEタイプアップロード時チェック
size: number; // バイト数 size: number; // バイト数
path: string; // ローカルパスuploads/... path: string; // ローカルパスuploads/...
source: FileAssetSource; // 'library'(Files一覧公開) | 'attachment'(添付専用)
createdAt: string; createdAt: string;
deletedAt: string | null; 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 ### project_notes
@ -410,6 +433,7 @@ erDiagram
integer assignee_id FK integer assignee_id FK
integer milestone_id FK integer milestone_id FK
text due_date text due_date
text tags
} }
meetings { meetings {
integer id PK integer id PK

View File

@ -235,7 +235,7 @@ components/
├── project/ # ProjectCard, DashboardWidget ├── project/ # ProjectCard, DashboardWidget
├── board/ # ThreadList, ThreadForm, CommentList ├── board/ # ThreadList, ThreadForm, CommentList
├── chat/ # ChatWindow, MessageInput, MessageList ├── chat/ # ChatWindow, MessageInput, MessageList
├── todo/ # KanbanBoard, KanbanColumn, TodoCard ├── todo/ # KanbanBoard, TodoDialog
├── files/ # FileList, Uploader, Lightbox, AttachmentList, AttachmentPicker ├── files/ # FileList, Uploader, Lightbox, AttachmentList, AttachmentPicker
├── calendar/ # CalendarView, MonthView, WeekView, DayView, EventDetailDialog, CalendarEventForm ├── calendar/ # CalendarView, MonthView, WeekView, DayView, EventDetailDialog, CalendarEventForm
├── meetings/ # MeetingForm, ConflictWarning ├── meetings/ # MeetingForm, ConflictWarning

View File

@ -98,12 +98,19 @@ export function createChatService(): ChatService {
export function createTodoService(): TodoService { export function createTodoService(): TodoService {
const db = getDb(); const db = getDb();
const memberRepo = new ProjectMemberRepository(db);
return new TodoService( return new TodoService(
new TodoRepository(db), new TodoRepository(db),
new ProjectMemberRepository(db), memberRepo,
new NotificationService(new NotificationRepository(db)), new NotificationService(new NotificationRepository(db)),
new ActivityLogService(new ActivityLogRepository(db)), new ActivityLogService(new ActivityLogRepository(db)),
getSseHub() getSseHub(),
new AttachmentService(
new AttachmentRepository(db),
new FileRepository(db),
memberRepo
),
db
); );
} }

View File

@ -0,0 +1,4 @@
-- 003_todo_tags.sql
-- ToDoアイテムにタグ列を追加(カンマ区切り、project_notes.tags と同じ方式)。
ALTER TABLE todo_items ADD COLUMN tags TEXT;

View File

@ -127,6 +127,7 @@ export interface TodoItem {
completedAt: string | null; completedAt: string | null;
orderIndex: number; orderIndex: number;
milestoneId: number | null; milestoneId: number | null;
tags: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
deletedAt: string | null; deletedAt: string | null;
@ -153,7 +154,8 @@ export type FileAssetSource = 'library' | 'attachment';
export type AttachmentTargetType = export type AttachmentTargetType =
| 'chat_message' | 'chat_message'
| 'board_thread' | 'board_thread'
| 'board_comment'; | 'board_comment'
| 'todo_item';
/** attachments エンティティ。 */ /** attachments エンティティ。 */
export interface Attachment { export interface Attachment {

View File

@ -1,5 +1,10 @@
import type { SqliteDatabase } from '@/lib/db/sqlite'; 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 { interface MilestoneRow {
id: number; id: number;
@ -27,6 +32,7 @@ interface TodoItemRow {
completed_at: string | null; completed_at: string | null;
order_index: number; order_index: number;
milestone_id: number | null; milestone_id: number | null;
tags: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
deleted_at: string | null; deleted_at: string | null;
@ -55,12 +61,13 @@ function mapTodo(row: TodoItemRow): TodoItem {
description: row.description, description: row.description,
assigneeId: row.assignee_id, assigneeId: row.assignee_id,
creatorId: row.creator_id, creatorId: row.creator_id,
priority: row.priority as never, priority: row.priority as TodoPriority,
startDate: row.start_date, startDate: row.start_date,
dueDate: row.due_date, dueDate: row.due_date,
completedAt: row.completed_at, completedAt: row.completed_at,
orderIndex: row.order_index, orderIndex: row.order_index,
milestoneId: row.milestone_id, milestoneId: row.milestone_id,
tags: row.tags,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
deletedAt: row.deleted_at, deletedAt: row.deleted_at,

View File

@ -24,6 +24,7 @@ interface TodoItemRow {
completed_at: string | null; completed_at: string | null;
order_index: number; order_index: number;
milestone_id: number | null; milestone_id: number | null;
tags: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
deleted_at: string | null; deleted_at: string | null;
@ -55,6 +56,7 @@ function mapItem(row: TodoItemRow): TodoItem {
completedAt: row.completed_at, completedAt: row.completed_at,
orderIndex: row.order_index, orderIndex: row.order_index,
milestoneId: row.milestone_id, milestoneId: row.milestone_id,
tags: row.tags,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
deletedAt: row.deleted_at, deletedAt: row.deleted_at,
@ -75,7 +77,9 @@ export interface CreateItemInput {
description?: string | null; description?: string | null;
assigneeId?: number | null; assigneeId?: number | null;
priority?: TodoPriority; priority?: TodoPriority;
startDate?: string | null;
dueDate?: string | null; dueDate?: string | null;
tags?: string | null;
orderIndex: number; orderIndex: number;
} }
@ -84,7 +88,9 @@ export interface UpdateItemInput {
description?: string | null; description?: string | null;
assigneeId?: number | null; assigneeId?: number | null;
priority?: TodoPriority; priority?: TodoPriority;
startDate?: string | null;
dueDate?: string | null; dueDate?: string | null;
tags?: string | null;
completedAt?: string | null; completedAt?: string | null;
columnId?: number; columnId?: number;
orderIndex?: number; orderIndex?: number;
@ -193,8 +199,8 @@ export class TodoRepository {
createItem(input: CreateItemInput): TodoItem { createItem(input: CreateItemInput): TodoItem {
const now = new Date().toISOString(); const now = new Date().toISOString();
const result = this.db.execute( 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) `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, NULL, @dueDate, NULL, @orderIndex, @milestoneId, @createdAt, @updatedAt, NULL)`, VALUES (@projectId, @columnId, @title, @description, @assigneeId, @creatorId, @priority, @startDate, @dueDate, NULL, @orderIndex, @milestoneId, @tags, @createdAt, @updatedAt, NULL)`,
{ {
projectId: input.projectId, projectId: input.projectId,
columnId: input.columnId, columnId: input.columnId,
@ -203,9 +209,11 @@ export class TodoRepository {
assigneeId: input.assigneeId ?? null, assigneeId: input.assigneeId ?? null,
creatorId: input.creatorId, creatorId: input.creatorId,
priority: input.priority ?? 'normal', priority: input.priority ?? 'normal',
startDate: input.startDate ?? null,
dueDate: input.dueDate ?? null, dueDate: input.dueDate ?? null,
orderIndex: input.orderIndex, orderIndex: input.orderIndex,
milestoneId: null, milestoneId: null,
tags: input.tags ?? null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
} }
@ -237,10 +245,18 @@ export class TodoRepository {
fields.push('priority = @priority'); fields.push('priority = @priority');
params.priority = input.priority; params.priority = input.priority;
} }
if (input.startDate !== undefined) {
fields.push('start_date = @startDate');
params.startDate = input.startDate;
}
if (input.dueDate !== undefined) { if (input.dueDate !== undefined) {
fields.push('due_date = @dueDate'); fields.push('due_date = @dueDate');
params.dueDate = input.dueDate; params.dueDate = input.dueDate;
} }
if (input.tags !== undefined) {
fields.push('tags = @tags');
params.tags = input.tags;
}
if (input.completedAt !== undefined) { if (input.completedAt !== undefined) {
fields.push('completed_at = @completedAt'); fields.push('completed_at = @completedAt');
params.completedAt = input.completedAt; params.completedAt = input.completedAt;

View File

@ -2,9 +2,16 @@ import { TodoRepository } from '@/repositories/TodoRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository'; import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { NotificationService } from '@/services/NotificationService'; import { NotificationService } from '@/services/NotificationService';
import { ActivityLogService } from '@/services/ActivityLogService'; import { ActivityLogService } from '@/services/ActivityLogService';
import { AttachmentService } from '@/services/AttachmentService';
import { SseHub } from '@/lib/sse/hub'; import { SseHub } from '@/lib/sse/hub';
import type { SqliteDatabase } from '@/lib/db/sqlite';
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors'; 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 STANDARD_COLUMNS = ['Backlog', 'To Do', 'In Progress', 'Review', 'Done'];
const VALID_PRIORITIES: TodoPriority[] = ['low', 'normal', 'high']; const VALID_PRIORITIES: TodoPriority[] = ['low', 'normal', 'high'];
@ -15,7 +22,10 @@ export interface CreateItemInput {
description?: string; description?: string;
assigneeId?: number | null; assigneeId?: number | null;
priority?: TodoPriority; priority?: TodoPriority;
startDate?: string | null;
dueDate?: string | null; dueDate?: string | null;
tags?: string | null;
fileIds?: number[];
} }
export interface UpdateItemRequest { export interface UpdateItemRequest {
@ -23,16 +33,20 @@ export interface UpdateItemRequest {
description?: string | null; description?: string | null;
assigneeId?: number | null; assigneeId?: number | null;
priority?: TodoPriority; priority?: TodoPriority;
startDate?: string | null;
dueDate?: string | null; dueDate?: string | null;
tags?: string | null;
columnId?: number; columnId?: number;
orderIndex?: number; orderIndex?: number;
milestoneId?: number | null; milestoneId?: number | null;
fileIds?: number[];
} }
/** /**
* ToDo/Kanbanの業務ロジックを担うService。 * ToDo/Kanbanの業務ロジックを担うService。
* 標準カラム初期生成、権限チェック、タスクCRUD/移動/完了、 * 標準カラム初期生成、権限チェック、タスクCRUD/移動/完了、
* 担当者割当時の通知(todo_assigned)とアクティビティログ(todo_*)、SSE配信を行う。 * 担当者割当時の通知(todo_assigned)とアクティビティログ(todo_*)、
* 添付ファイル紐付け、SSE配信を行う。
*/ */
export class TodoService { export class TodoService {
constructor( constructor(
@ -40,7 +54,9 @@ export class TodoService {
private readonly projectMemberRepository: ProjectMemberRepository, private readonly projectMemberRepository: ProjectMemberRepository,
private readonly notificationService: NotificationService, private readonly notificationService: NotificationService,
private readonly activityLogService: ActivityLogService, 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[] { getColumns(actorId: number, projectId: number): TodoColumn[] {
@ -119,11 +135,11 @@ export class TodoService {
if (input.priority && !VALID_PRIORITIES.includes(input.priority)) { if (input.priority && !VALID_PRIORITIES.includes(input.priority)) {
throw new ValidationError('無効な優先度です', 'priority'); throw new ValidationError('無効な優先度です', 'priority');
} }
// タスク本体と添付紐付けはトランザクション内で一貫保存する
const item = this.db.transaction(() => {
const orderIndex = const orderIndex =
input.columnId !== undefined this.todoRepository.maxItemOrderIndex(input.columnId) + 1;
? this.todoRepository.maxItemOrderIndex(input.columnId) + 1 const created = this.todoRepository.createItem({
: 0;
const item = this.todoRepository.createItem({
projectId, projectId,
columnId: input.columnId, columnId: input.columnId,
title: input.title, title: input.title,
@ -131,9 +147,22 @@ export class TodoService {
description: input.description ?? null, description: input.description ?? null,
assigneeId: input.assigneeId ?? null, assigneeId: input.assigneeId ?? null,
priority: input.priority, priority: input.priority,
startDate: input.startDate ?? null,
dueDate: input.dueDate ?? null, dueDate: input.dueDate ?? null,
tags: input.tags ?? null,
orderIndex, 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) { if (input.assigneeId && input.assigneeId !== actorId) {
this.notifyAssigned(projectId, input.assigneeId, item.title); this.notifyAssigned(projectId, input.assigneeId, item.title);
@ -155,7 +184,19 @@ export class TodoService {
throw new ValidationError('無効な優先度です', 'priority'); throw new ValidationError('無効な優先度です', 'priority');
} }
const previousAssignee = item.assigneeId; 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 (!updated) throw new NotFoundError('TodoItem', itemId);
if ( if (
@ -220,10 +261,29 @@ export class TodoService {
if (item.creatorId !== actorId && role !== 'admin') { if (item.creatorId !== actorId && role !== 'admin') {
throw new ForbiddenError('作成者または管理者のみ削除できます'); throw new ForbiddenError('作成者または管理者のみ削除できます');
} }
this.db.transaction(() => {
this.attachmentService.detach('todo_item', itemId);
this.todoRepository.deleteItem(itemId); this.todoRepository.deleteItem(itemId);
});
this.broadcastTodo(item.projectId); 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 { private requireMember(projectId: number, actorId: number): void {
if (!this.projectMemberRepository.isMember(projectId, actorId)) { if (!this.projectMemberRepository.isMember(projectId, actorId)) {
throw new ForbiddenError('プロジェクトに参加していません'); throw new ForbiddenError('プロジェクトに参加していません');

View File

@ -76,4 +76,99 @@ test.describe('todo / kanban', () => {
page.getByTestId(`kanban-column-${done.id}`).getByText(title) page.getByTestId(`kanban-column-${done.id}`).getByText(title)
).toBeVisible(); ).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();
});
}); });

View File

@ -182,6 +182,7 @@ describe('Migrator', () => {
expect(migrator.getAppliedMigrations().map((m) => m.filename)).toEqual([ expect(migrator.getAppliedMigrations().map((m) => m.filename)).toEqual([
'001_initial.sql', '001_initial.sql',
'002_attachments.sql', '002_attachments.sql',
'003_todo_tags.sql',
]); ]);
}); });
}); });

View File

@ -114,6 +114,49 @@ describe('TodoRepository', () => {
expect(updated?.completedAt).toBeTruthy(); 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', () => { it('isolates items by project', () => {
const p2 = new ProjectRepository(db).create({ const p2 = new ProjectRepository(db).create({
name: 'P2', name: 'P2',

View File

@ -5,10 +5,13 @@ import { UserRepository } from '@/repositories/UserRepository';
import { ProjectRepository } from '@/repositories/ProjectRepository'; import { ProjectRepository } from '@/repositories/ProjectRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository'; import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { TodoRepository } from '@/repositories/TodoRepository'; import { TodoRepository } from '@/repositories/TodoRepository';
import { FileRepository } from '@/repositories/FileRepository';
import { AttachmentRepository } from '@/repositories/AttachmentRepository';
import { NotificationRepository } from '@/repositories/NotificationRepository'; import { NotificationRepository } from '@/repositories/NotificationRepository';
import { ActivityLogRepository } from '@/repositories/ActivityLogRepository'; import { ActivityLogRepository } from '@/repositories/ActivityLogRepository';
import { NotificationService } from '@/services/NotificationService'; import { NotificationService } from '@/services/NotificationService';
import { ActivityLogService } from '@/services/ActivityLogService'; import { ActivityLogService } from '@/services/ActivityLogService';
import { AttachmentService } from '@/services/AttachmentService';
import { TodoService } from '@/services/TodoService'; import { TodoService } from '@/services/TodoService';
import { SseHub } from '@/lib/sse/hub'; import { SseHub } from '@/lib/sse/hub';
import { ForbiddenError, NotFoundError } from '@/lib/errors'; import { ForbiddenError, NotFoundError } from '@/lib/errors';
@ -24,7 +27,13 @@ function makeService(db: SqliteDatabase) {
members, members,
new NotificationService(new NotificationRepository(db)), new NotificationService(new NotificationRepository(db)),
new ActivityLogService(new ActivityLogRepository(db)), new ActivityLogService(new ActivityLogRepository(db)),
hub hub,
new AttachmentService(
new AttachmentRepository(db),
new FileRepository(db),
members
),
db
), ),
members, members,
users, users,
@ -161,4 +170,142 @@ describe('TodoService', () => {
NotFoundError 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();
});
}); });