3 Commits

Author SHA1 Message Date
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
b2b3fb00b5 Merge branch 'feature/chat-board-attachments' - file/image attachments for chat & board
Some checks failed
CI / test (push) Has been cancelled
2026-06-25 10:07:40 +02:00
25d800a529 feat(chat,board): file/image attachments on chat, board threads and comments
チャット・掲示板(スレッド/コメント)へファイル/画像添付を追加。

- lib/db/migrations/002_attachments.sql: attachments テーブル + file_assets.source 列
- repositories/AttachmentRepository + services/AttachmentService: 添付紐付け(プロジェクト/アップロード者チェック)
- FileStorageService.uploadForAttachment: 通知/SSE/アクティビティを行わない添付専用アップロード(source='attachment')
- ChatService/BoardService: fileIds 受理・添付紐付け・履歴/詳細表示・削除クリーンアップ(トランザクション)
- API: POST /attachments + chat/board の fileIds 受理
- UI: AttachmentList(画像サムネイル+Lightbox/ダウンロード) + AttachmentPicker(複数アップロード・送信中は送信不可) をチャット/スレッド/コメントに統合
- 添付ファイルは既存の /api/files/[fileId]/download(権限チェック済)で配信
2026-06-25 10:07:02 +02:00
44 changed files with 2524 additions and 177 deletions

View File

@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createFileStorageService } from '@/lib/api/services';
import { UnauthorizedError, ValidationError } from '@/lib/errors';
import { handleApiError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
/**
* チャット/掲示板添付用のファイルアップロード。
* Files一覧公開用の通知/SSE/アクティビティを行わず、source='attachment'で保存する。
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { projectId } = await params;
const formData = await request.formData();
const file = formData.get('file');
if (!(file instanceof File)) {
return handleApiError(
new ValidationError('ファイルを指定してください', 'file')
);
}
const data = Buffer.from(await file.arrayBuffer());
const service = createFileStorageService();
try {
const fileAsset = service.uploadForAttachment(user.id, Number(projectId), {
originalName: file.name,
mimeType: file.type || 'application/octet-stream',
data,
});
return NextResponse.json({ file: fileAsset }, { status: 201 });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -22,10 +22,16 @@ export async function POST(
const service = createBoardService();
try {
const fileIds = Array.isArray(body.fileIds)
? body.fileIds
.map((n) => Number(n))
.filter((n) => Number.isFinite(n) && n > 0)
: [];
const comment = service.createComment(
user.id,
Number(threadId),
String(body.bodyMd ?? '')
String(body.bodyMd ?? ''),
fileIds
);
return NextResponse.json({ comment }, { status: 201 });
} catch (error) {

View File

@ -42,6 +42,11 @@ export async function POST(
const service = createBoardService();
try {
const fileIds = Array.isArray(body.fileIds)
? body.fileIds
.map((n) => Number(n))
.filter((n) => Number.isFinite(n) && n > 0)
: [];
const thread = service.createThread(user.id, Number(projectId), {
title: String(body.title ?? ''),
bodyMd: String(body.bodyMd ?? ''),
@ -49,6 +54,7 @@ export async function POST(
typeof body.category === 'string'
? (body.category as never)
: undefined,
fileIds,
});
return NextResponse.json({ thread }, { status: 201 });
} catch (error) {

View File

@ -42,10 +42,16 @@ export async function POST(
const service = createChatService();
try {
const fileIds = Array.isArray(body.fileIds)
? body.fileIds
.map((n) => Number(n))
.filter((n) => Number.isFinite(n) && n > 0)
: [];
const message = service.sendMessage(
user.id,
Number(projectId),
String(body.body ?? '')
String(body.body ?? ''),
fileIds
);
return NextResponse.json({ message }, { status: 201 });
} catch (error) {

View File

@ -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) {

View File

@ -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) {

View File

@ -5,6 +5,8 @@ import { Header } from '@/components/layout/Header';
import { ProjectNav } from '@/components/layout/ProjectNav';
import { MarkdownBody } from '@/components/board/MarkdownBody';
import { CommentForm } from '@/components/board/CommentForm';
import { AttachmentList } from '@/components/files/AttachmentList';
import type { AttachmentView } from '@/lib/types';
import { ForbiddenError, NotFoundError } from '@/lib/errors';
export const dynamic = 'force-dynamic';
@ -21,9 +23,15 @@ export default async function ThreadDetailPage({
const boardService = createBoardService();
let thread;
let comments;
let attachments: { thread: AttachmentView[]; comments: AttachmentView[] };
try {
thread = boardService.getThread(user.id, Number(threadId));
comments = boardService.listComments(user.id, Number(threadId));
attachments = boardService.getAttachments(
user.id,
thread.id,
comments.items.map((c) => c.id)
);
} catch (error) {
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
redirect(`/projects/${projectId}/board`);
@ -31,6 +39,18 @@ export default async function ThreadDetailPage({
throw error;
}
// コメントIDごとに添付をグループ化
const commentAttachments = new Map(
attachments.comments.map((a) => [
a.targetId,
[] as typeof attachments.comments,
])
);
for (const a of attachments.comments) {
const list = commentAttachments.get(a.targetId);
if (list) list.push(a);
}
return (
<div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} />
@ -54,6 +74,14 @@ export default async function ThreadDetailPage({
<div className="mt-4">
<MarkdownBody bodyMd={thread.bodyMd} />
</div>
{attachments.thread.length > 0 && (
<div className="mt-4">
<p className="mb-1 text-xs font-medium text-gray-500">
</p>
<AttachmentList attachments={attachments.thread} />
</div>
)}
<p className="mt-4 text-xs text-gray-400">
稿: {thread.createdAt} / : {thread.updatedAt}
</p>
@ -67,6 +95,14 @@ export default async function ThreadDetailPage({
className="rounded-lg border bg-white p-4 shadow-sm"
>
<MarkdownBody bodyMd={comment.bodyMd} />
{commentAttachments.has(comment.id) &&
commentAttachments.get(comment.id)!.length > 0 && (
<div className="mt-2">
<AttachmentList
attachments={commentAttachments.get(comment.id)!}
/>
</div>
)}
<p className="mt-2 text-xs text-gray-400">{comment.createdAt}</p>
</div>
))}

View File

@ -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 (
<div className="min-h-screen bg-gray-50">
@ -42,6 +43,7 @@ export default async function TodosPage({
projectId={project.id}
columns={columns}
initialItems={items}
members={members}
/>
</main>
</div>

View File

@ -1,7 +1,9 @@
'use client';
import { useState, type FormEvent } from 'react';
import { useRef, useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import { AttachmentPicker } from '@/components/files/AttachmentPicker';
import type { AttachmentPickerHandle } from '@/components/files/AttachmentPicker';
export function CommentForm({
projectId,
@ -11,25 +13,29 @@ export function CommentForm({
threadId: number;
}) {
const router = useRouter();
const pickerRef = useRef<AttachmentPickerHandle>(null);
const [bodyMd, setBodyMd] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pickerLoading, setPickerLoading] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
setError(null);
const fileIds = pickerRef.current?.getFileIds() ?? [];
const res = await fetch(
`/api/projects/${projectId}/board/threads/${threadId}/comments`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bodyMd }),
body: JSON.stringify({ bodyMd, fileIds }),
}
);
setLoading(false);
if (res.ok) {
setBodyMd('');
pickerRef.current?.clear();
router.refresh();
} else {
const b = (await res.json().catch(() => null)) as {
@ -48,6 +54,11 @@ export function CommentForm({
className="min-h-[80px] w-full rounded border px-3 py-2"
required
/>
<AttachmentPicker
ref={pickerRef}
projectId={projectId}
onLoadingChange={setPickerLoading}
/>
{error && (
<p className="text-sm text-red-600" role="alert">
{error}
@ -55,7 +66,7 @@ export function CommentForm({
)}
<button
type="submit"
disabled={loading}
disabled={loading || pickerLoading}
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? '投稿中...' : 'コメント投稿'}

View File

@ -1,7 +1,9 @@
'use client';
import { useState, type FormEvent } from 'react';
import { useRef, useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import { AttachmentPicker } from '@/components/files/AttachmentPicker';
import type { AttachmentPickerHandle } from '@/components/files/AttachmentPicker';
const CATEGORIES = [
{ value: '', label: 'なし' },
@ -16,16 +18,19 @@ const CATEGORIES = [
export function ThreadForm({ projectId }: { projectId: number }) {
const router = useRouter();
const pickerRef = useRef<AttachmentPickerHandle>(null);
const [title, setTitle] = useState('');
const [bodyMd, setBodyMd] = useState('');
const [category, setCategory] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [pickerLoading, setPickerLoading] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
setError(null);
const fileIds = pickerRef.current?.getFileIds() ?? [];
const res = await fetch(`/api/projects/${projectId}/board/threads`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -33,11 +38,13 @@ export function ThreadForm({ projectId }: { projectId: number }) {
title,
bodyMd,
category: category || undefined,
fileIds,
}),
});
setLoading(false);
if (res.ok) {
const data = (await res.json()) as { thread: { id: number } };
pickerRef.current?.clear();
router.push(`/projects/${projectId}/board/${data.thread.id}`);
router.refresh();
} else {
@ -82,6 +89,11 @@ export function ThreadForm({ projectId }: { projectId: number }) {
className="min-h-[120px] w-full rounded border px-3 py-2"
required
/>
<AttachmentPicker
ref={pickerRef}
projectId={projectId}
onLoadingChange={setPickerLoading}
/>
{error && (
<p className="text-sm text-red-600" role="alert">
{error}
@ -89,7 +101,7 @@ export function ThreadForm({ projectId }: { projectId: number }) {
)}
<button
type="submit"
disabled={loading}
disabled={loading || pickerLoading}
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? '作成中...' : 'スレッド作成'}

View File

@ -1,7 +1,10 @@
'use client';
import { useEffect, useState, type FormEvent } from 'react';
import type { ChatMessage } from '@/lib/types';
import { useEffect, useRef, useState, type FormEvent } from 'react';
import type { ChatMessageWithAttachments } from '@/lib/types';
import { AttachmentPicker } from '@/components/files/AttachmentPicker';
import type { AttachmentPickerHandle } from '@/components/files/AttachmentPicker';
import { AttachmentList } from '@/components/files/AttachmentList';
interface ChatWindowProps {
projectId: number;
@ -9,16 +12,20 @@ interface ChatWindowProps {
}
export function ChatWindow({ projectId, userName }: ChatWindowProps) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [messages, setMessages] = useState<ChatMessageWithAttachments[]>([]);
const [input, setInput] = useState('');
const [error, setError] = useState<string | null>(null);
const [sending, setSending] = useState(false);
const [pickerLoading, setPickerLoading] = useState(false);
const pickerRef = useRef<AttachmentPickerHandle>(null);
// 履歴取得
useEffect(() => {
fetch(`/api/projects/${projectId}/chat/messages`)
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data?.items) setMessages(data.items as ChatMessage[]);
if (data?.items)
setMessages(data.items as ChatMessageWithAttachments[]);
})
.catch(() => undefined);
}, [projectId]);
@ -30,7 +37,10 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
try {
const parsed = JSON.parse(event.data) as {
type: string;
data: { message?: ChatMessage; id?: number };
data: {
message?: ChatMessageWithAttachments;
id?: number;
};
};
if (parsed.type === 'chat.message.created' && parsed.data.message) {
setMessages((prev) =>
@ -42,9 +52,12 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
parsed.type === 'chat.message.updated' &&
parsed.data.message
) {
// 編集イベントは本文のみ更新し、添付は既存のものを保持する
setMessages((prev) =>
prev.map((m) =>
m.id === parsed.data.message!.id ? parsed.data.message! : m
m.id === parsed.data.message!.id
? { ...m, ...parsed.data.message!, attachments: m.attachments }
: m
)
);
} else if (parsed.type === 'chat.message.deleted' && parsed.data.id) {
@ -61,13 +74,17 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
event.preventDefault();
if (!input.trim()) return;
setError(null);
setSending(true);
const fileIds = pickerRef.current?.getFileIds() ?? [];
const res = await fetch(`/api/projects/${projectId}/chat/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: input }),
body: JSON.stringify({ body: input, fileIds }),
});
setSending(false);
if (res.ok) {
setInput('');
pickerRef.current?.clear();
} else {
const b = (await res.json().catch(() => null)) as {
error?: { message?: string };
@ -95,6 +112,11 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
>
{m.body}
</span>
{m.attachments && m.attachments.length > 0 && (
<div className="max-w-[80%]">
<AttachmentList attachments={m.attachments} />
</div>
)}
<span className="mt-0.5 text-xs text-gray-400">
{m.createdAt}
</span>
@ -104,9 +126,15 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
</div>
<form
onSubmit={onSend}
className="flex gap-2 border-t p-3"
className="space-y-2 border-t p-3"
data-testid="chat-form"
>
<AttachmentPicker
ref={pickerRef}
projectId={projectId}
onLoadingChange={setPickerLoading}
/>
<div className="flex gap-2">
<input
type="text"
value={input}
@ -117,11 +145,13 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
/>
<button
type="submit"
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700"
disabled={sending || pickerLoading}
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
data-testid="chat-send"
>
{sending ? '送信中...' : '送信'}
</button>
</div>
</form>
{error && (
<p className="px-3 pb-2 text-sm text-red-600" role="alert">

View File

@ -0,0 +1,93 @@
'use client';
import { useEffect, useState } from 'react';
import type { AttachmentView } from '@/lib/types';
/**
* 添付ファイル一覧表示。画像はサムネイル(クリックでLightbox)、
* それ以外はダウンロードリンク。チャット/掲示板で共通利用。
*/
export function AttachmentList({
attachments,
}: {
attachments: AttachmentView[];
}) {
const [lightbox, setLightbox] = useState<AttachmentView | null>(null);
useEffect(() => {
if (!lightbox) return;
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setLightbox(null);
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [lightbox]);
if (attachments.length === 0) return null;
return (
<>
<ul className="mt-1 flex flex-wrap gap-2" data-testid="attachment-list">
{attachments.map((a) => {
const url = `/api/files/${a.fileId}/download`;
const isImage = a.mimeType.startsWith('image/');
return (
<li
key={a.id}
className="overflow-hidden rounded border bg-gray-50"
data-testid={`attachment-${a.id}`}
>
{isImage ? (
<button
type="button"
onClick={() => setLightbox(a)}
className="block"
aria-label={`画像 ${a.originalName} を開く`}
>
<img
src={url}
alt={a.originalName}
className="h-20 w-20 object-cover"
/>
</button>
) : (
<a
href={url}
className="flex h-20 w-28 flex-col items-center justify-center px-2 text-center text-xs text-blue-600 hover:underline"
>
<span className="mb-1">📎</span>
<span className="w-full truncate" title={a.originalName}>
{a.originalName}
</span>
</a>
)}
</li>
);
})}
</ul>
{lightbox && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
onClick={() => setLightbox(null)}
data-testid="attachment-lightbox"
>
<button
type="button"
onClick={() => setLightbox(null)}
className="absolute right-4 top-4 rounded bg-black/50 px-2 py-0.5 text-2xl text-white hover:bg-black/70"
aria-label="閉じる"
>
×
</button>
<img
src={`/api/files/${lightbox.fileId}/download`}
alt={lightbox.originalName}
className="max-h-full max-w-full rounded"
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
</>
);
}

View File

@ -0,0 +1,163 @@
'use client';
import {
forwardRef,
useImperativeHandle,
useRef,
useState,
type ChangeEvent,
} from 'react';
export interface AttachmentPickerHandle {
getFileIds: () => number[];
clear: () => void;
}
interface PickedFile {
fileId: number;
originalName: string;
mimeType: string;
}
/**
* チャット/掲示板用の添付ファイルピッカー。
* 選択したファイルを添付用エンドポイントへアップロードし、
* 親フォームは送信時に getFileIds() でファイルIDを取り出す。
* 送信完了後は clear() で状態をリセットする。
*/
export const AttachmentPicker = forwardRef<
AttachmentPickerHandle,
{ projectId: number; onLoadingChange?: (loading: boolean) => void }
>(function AttachmentPicker({ projectId, onLoadingChange }, ref) {
const [files, setFiles] = useState<PickedFile[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
function reportLoading(next: boolean) {
setLoading(next);
onLoadingChange?.(next);
}
useImperativeHandle(
ref,
() => ({
getFileIds: () => files.map((f) => f.fileId),
clear: () => {
setFiles([]);
setError(null);
if (inputRef.current) inputRef.current.value = '';
},
}),
[files]
);
async function onFile(event: ChangeEvent<HTMLInputElement>) {
const selected = Array.from(event.target.files ?? []);
if (selected.length === 0) return;
reportLoading(true);
setError(null);
try {
const uploaded: PickedFile[] = [];
for (const f of selected) {
const form = new FormData();
form.append('file', f);
const res = await fetch(`/api/projects/${projectId}/attachments`, {
method: 'POST',
body: form,
});
if (!res.ok) {
const b = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
throw new Error(b?.error?.message ?? 'アップロードに失敗しました');
}
const data = (await res.json()) as {
file: { id: number; originalName: string; mimeType: string };
};
uploaded.push({
fileId: data.file.id,
originalName: data.file.originalName,
mimeType: data.file.mimeType,
});
}
setFiles((prev) => [...prev, ...uploaded]);
} catch (err) {
setError(
err instanceof Error ? err.message : 'アップロードに失敗しました'
);
} finally {
reportLoading(false);
if (inputRef.current) inputRef.current.value = '';
}
}
function removeFile(fileId: number) {
setFiles((prev) => prev.filter((f) => f.fileId !== fileId));
}
return (
<div className="space-y-2" data-testid="attachment-picker">
<label className="inline-flex cursor-pointer items-center gap-1 rounded border bg-white px-3 py-1 text-sm text-gray-600 hover:bg-gray-100">
📎
<input
ref={inputRef}
type="file"
multiple
onChange={onFile}
disabled={loading}
className="hidden"
data-testid="attachment-input"
/>
</label>
{loading && (
<p className="text-xs text-gray-500" data-testid="attachment-loading">
...
</p>
)}
{error && (
<p className="text-xs text-red-600" role="alert">
{error}
</p>
)}
{files.length > 0 && (
<ul className="flex flex-wrap gap-2">
{files.map((f) => {
const isImage = f.mimeType.startsWith('image/');
const url = `/api/files/${f.fileId}/download`;
return (
<li
key={f.fileId}
className="relative overflow-hidden rounded border bg-gray-50"
data-testid={`attachment-picked-${f.fileId}`}
>
{isImage ? (
<img
src={url}
alt={f.originalName}
className="h-16 w-16 object-cover"
/>
) : (
<span className="flex h-16 w-24 items-center justify-center px-1 text-center text-[10px] text-blue-600">
<span className="truncate" title={f.originalName}>
{f.originalName}
</span>
</span>
)}
<button
type="button"
onClick={() => removeFile(f.fileId)}
className="absolute right-0 top-0 rounded-bl bg-black/50 px-1 text-xs text-white hover:bg-black/70"
aria-label={`${f.originalName} を削除`}
data-testid={`attachment-remove-${f.fileId}`}
>
×
</button>
</li>
);
})}
</ul>
)}
</div>
);
});

View File

@ -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<string, string> = {
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<TodoItem[]>(initialItems);
const [draggingId, setDraggingId] = useState<number | null>(null);
const [selectedItem, setSelectedItem] = useState<TodoItem | null>(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,31 +98,49 @@ export function KanbanBoard({
{column.name} ({colItems.length})
</h2>
<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
key={item.id}
draggable
onDragStart={(e) => onDragStart(e, item.id)}
onClick={() => setSelectedItem(item)}
className={`cursor-grab rounded border bg-white p-2 shadow-sm ${
draggingId === item.id ? 'opacity-50' : ''
} ${item.completedAt ? 'opacity-60 line-through' : ''}`}
data-testid={`todo-card-${item.id}`}
>
<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
type="button"
onClick={() => toggleComplete(item.id)}
onClick={(e) => {
e.stopPropagation();
toggleComplete(item.id);
}}
className="text-xs text-blue-600 hover:underline"
data-testid={`todo-complete-${item.id}`}
>
{item.completedAt ? '戻す' : '完了'}
</button>
</div>
<div className="mt-1 flex gap-1">
<div className="mt-1 flex flex-wrap items-center gap-1">
<span
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}
@ -125,14 +150,47 @@ export function KanbanBoard({
: {item.dueDate}
</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>
</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>
<NewTaskInput onAdd={(title) => addTask(column.id, title)} />
</section>
);
})}
{selectedItem && (
<TodoDialog
projectId={projectId}
item={selectedItem}
members={members}
onClose={() => setSelectedItem(null)}
onSaved={reload}
/>
)}
</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;
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

View File

@ -181,6 +181,7 @@ repositories/
├── ChatRepository.ts
├── TodoRepository.ts
├── FileRepository.ts
├── AttachmentRepository.ts
├── CalendarRepository.ts
├── MeetingRepository.ts
├── ProjectNoteRepository.ts
@ -208,6 +209,7 @@ services/
├── MeetingService.ts
├── ScheduleService.ts
├── FileStorageService.ts
├── AttachmentService.ts
├── BackupService.ts
├── TodoService.ts
├── BoardService.ts
@ -233,8 +235,8 @@ components/
├── project/ # ProjectCard, DashboardWidget
├── board/ # ThreadList, ThreadForm, CommentList
├── chat/ # ChatWindow, MessageInput, MessageList
├── todo/ # KanbanBoard, KanbanColumn, TodoCard
├── files/ # FileList, Uploader, Lightbox
├── todo/ # KanbanBoard, TodoDialog
├── files/ # FileList, Uploader, Lightbox, AttachmentList, AttachmentPicker
├── calendar/ # CalendarView, MonthView, WeekView, DayView, EventDetailDialog, CalendarEventForm
├── meetings/ # MeetingForm, ConflictWarning
├── notes/ # NoteEditor, MarkdownPreview

View File

@ -18,6 +18,8 @@ import { TodoService } from '@/services/TodoService';
import { TodoRepository } from '@/repositories/TodoRepository';
import { FileStorageService } from '@/services/FileStorageService';
import { FileRepository } from '@/repositories/FileRepository';
import { AttachmentService } from '@/services/AttachmentService';
import { AttachmentRepository } from '@/repositories/AttachmentRepository';
import { ScheduleService } from '@/services/ScheduleService';
import { CalendarRepository } from '@/repositories/CalendarRepository';
import { MilestoneRepository } from '@/repositories/MilestoneRepository';
@ -51,11 +53,18 @@ export function createActivityLogService(): ActivityLogService {
export function createBoardService(): BoardService {
const db = getDb();
const memberRepo = new ProjectMemberRepository(db);
return new BoardService(
new BoardRepository(db),
new ProjectMemberRepository(db),
memberRepo,
new NotificationService(new NotificationRepository(db)),
new ActivityLogService(new ActivityLogRepository(db))
new ActivityLogService(new ActivityLogRepository(db)),
new AttachmentService(
new AttachmentRepository(db),
new FileRepository(db),
memberRepo
),
db
);
}
@ -71,23 +80,37 @@ export function createNoteService(): NoteService {
export function createChatService(): ChatService {
const db = getDb();
const memberRepo = new ProjectMemberRepository(db);
return new ChatService(
new ChatRepository(db),
new ProjectMemberRepository(db),
memberRepo,
new UserRepository(db),
new NotificationService(new NotificationRepository(db)),
getSseHub()
getSseHub(),
new AttachmentService(
new AttachmentRepository(db),
new FileRepository(db),
memberRepo
),
db
);
}
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
);
}

View File

@ -0,0 +1,22 @@
-- 002_attachments.sql
-- チャット/掲示板への添付ファイル関連付けテーブルと、file_assets の来源区分。
-- 11. attachments: メッセージ/スレッド/コメント と file_assets の多対多関連
CREATE TABLE attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
file_id INTEGER NOT NULL,
target_type TEXT NOT NULL,
target_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
deleted_at TEXT,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (file_id) REFERENCES file_assets(id)
);
CREATE INDEX idx_attachments_target ON attachments(target_type, target_id);
CREATE INDEX idx_attachments_file ON attachments(file_id);
-- file_assets に source 列を追加(ライブラリ公開 vs 添付専用)。
-- 既存レコードは 'library' となり Files 一覧にそのまま表示される。
ALTER TABLE file_assets ADD COLUMN source TEXT NOT NULL DEFAULT 'library';

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;
orderIndex: number;
milestoneId: number | null;
tags: string | null;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
@ -141,10 +142,48 @@ export interface FileAsset {
mimeType: string;
size: number;
path: string;
source: FileAssetSource;
createdAt: string;
deletedAt: string | null;
}
/** ファイルの来源。library=Files一覧公開、attachment=添付専用(一覧に出さない)。 */
export type FileAssetSource = 'library' | 'attachment';
/** 添付ファイルの関連付け対象。 */
export type AttachmentTargetType =
| 'chat_message'
| 'board_thread'
| 'board_comment'
| 'todo_item';
/** attachments エンティティ。 */
export interface Attachment {
id: number;
projectId: number;
fileId: number;
targetType: AttachmentTargetType;
targetId: number;
createdAt: string;
deletedAt: string | null;
}
/** UI表示用の添付ファイルビュー(ダウンロードURLは fileId から組み立てる)。 */
export interface AttachmentView {
id: number;
fileId: number;
targetType: AttachmentTargetType;
targetId: number;
originalName: string;
mimeType: string;
size: number;
}
/** チャットメッセージ + 添付ファイル。 */
export interface ChatMessageWithAttachments extends ChatMessage {
attachments: AttachmentView[];
}
export interface ProjectNote {
id: number;
projectId: number;
@ -281,7 +320,10 @@ export type NotificationEvent =
export type SseEvent =
| {
type: 'chat.message.created';
data: { projectId: number; message: ChatMessage };
data: {
projectId: number;
message: ChatMessageWithAttachments;
};
}
| {
type: 'chat.message.updated';

View File

@ -0,0 +1,135 @@
import type { SqliteDatabase } from '@/lib/db/sqlite';
import type {
Attachment,
AttachmentTargetType,
AttachmentView,
} from '@/lib/types';
interface AttachmentRow {
id: number;
project_id: number;
file_id: number;
target_type: string;
target_id: number;
created_at: string;
deleted_at: string | null;
}
interface AttachmentViewRow {
id: number;
file_id: number;
target_type: string;
target_id: number;
original_name: string;
mime_type: string;
size: number;
}
function mapAttachment(row: AttachmentRow): Attachment {
return {
id: row.id,
projectId: row.project_id,
fileId: row.file_id,
targetType: row.target_type as AttachmentTargetType,
targetId: row.target_id,
createdAt: row.created_at,
deletedAt: row.deleted_at,
};
}
function mapView(row: AttachmentViewRow): AttachmentView {
return {
id: row.id,
fileId: row.file_id,
targetType: row.target_type as AttachmentTargetType,
targetId: row.target_id,
originalName: row.original_name,
mimeType: row.mime_type,
size: row.size,
};
}
export interface CreateAttachmentInput {
projectId: number;
fileId: number;
targetType: AttachmentTargetType;
targetId: number;
}
/**
* attachments テーブルのCRUDを担うRepository。
* file_assets とJOINして表示用ビューを返す。
*/
export class AttachmentRepository {
constructor(private readonly db: SqliteDatabase) {}
create(input: CreateAttachmentInput): Attachment {
const now = new Date().toISOString();
const result = this.db.execute(
`INSERT INTO attachments (project_id, file_id, target_type, target_id, created_at, deleted_at)
VALUES (@projectId, @fileId, @targetType, @targetId, @createdAt, NULL)`,
{
projectId: input.projectId,
fileId: input.fileId,
targetType: input.targetType,
targetId: input.targetId,
createdAt: now,
}
);
const created = this.findById(Number(result.lastInsertRowid));
if (!created) throw new Error('Failed to create attachment');
return created;
}
findById(id: number): Attachment | null {
const row = this.db.get<AttachmentRow>(
'SELECT * FROM attachments WHERE id = @id AND deleted_at IS NULL',
{ id }
);
return row ? mapAttachment(row) : null;
}
findByTarget(
targetType: AttachmentTargetType,
targetId: number
): Attachment[] {
const rows = this.db.query<AttachmentRow>(
`SELECT * FROM attachments
WHERE target_type = @targetType AND target_id = @targetId AND deleted_at IS NULL
ORDER BY id ASC`,
{ targetType, targetId }
);
return rows.map(mapAttachment);
}
/** 複数ターゲットの添付ビューを一括取得(N+1回避)。 */
findViewsByTargets(
targetType: AttachmentTargetType,
targetIds: number[]
): AttachmentView[] {
if (targetIds.length === 0) return [];
const placeholders = targetIds.map(() => '?').join(',');
const rows = this.db.query<AttachmentViewRow>(
`SELECT a.id AS id, a.file_id AS file_id, a.target_type AS target_type,
a.target_id AS target_id, f.original_name AS original_name,
f.mime_type AS mime_type, f.size AS size
FROM attachments a
JOIN file_assets f ON f.id = a.file_id
WHERE a.target_type = ? AND a.deleted_at IS NULL
AND f.deleted_at IS NULL
AND a.target_id IN (${placeholders})
ORDER BY a.target_id ASC, a.id ASC`,
[targetType, ...targetIds]
);
return rows.map(mapView);
}
/** 対象の添付を論理削除(メッセージ/スレッド/コメント削除時のクリーンアップ)。 */
deleteByTarget(targetType: AttachmentTargetType, targetId: number): boolean {
const result = this.db.execute(
'UPDATE attachments SET deleted_at = @now WHERE target_type = @targetType AND target_id = @targetId AND deleted_at IS NULL',
{ now: new Date().toISOString(), targetType, targetId }
);
return result.changes > 0;
}
}

View File

@ -1,5 +1,5 @@
import type { SqliteDatabase } from '@/lib/db/sqlite';
import type { FileAsset } from '@/lib/types';
import type { FileAsset, FileAssetSource } from '@/lib/types';
const DEFAULT_PAGE_SIZE = 20;
@ -17,6 +17,7 @@ interface FileAssetRow {
mime_type: string;
size: number;
path: string;
source: string;
created_at: string;
deleted_at: string | null;
}
@ -31,6 +32,7 @@ function mapFile(row: FileAssetRow): FileAsset {
mimeType: row.mime_type,
size: row.size,
path: row.path,
source: row.source as FileAssetSource,
createdAt: row.created_at,
deletedAt: row.deleted_at,
};
@ -44,6 +46,7 @@ export interface CreateFileInput {
mimeType: string;
size: number;
path: string;
source?: FileAssetSource;
}
export class FileRepository {
@ -55,15 +58,17 @@ export class FileRepository {
pageSize: number = DEFAULT_PAGE_SIZE
): Paginated<FileAsset> {
const offset = (page - 1) * pageSize;
// 添付専用ファイル(source='attachment')はFiles一覧に出さない
const items = this.db.query<FileAssetRow>(
`SELECT * FROM file_assets
WHERE project_id = @projectId AND deleted_at IS NULL
WHERE project_id = @projectId AND deleted_at IS NULL AND source = 'library'
ORDER BY created_at DESC, id DESC
LIMIT @pageSize OFFSET @offset`,
{ projectId, pageSize, offset }
);
const total = this.db.get<{ count: number }>(
'SELECT COUNT(*) AS count FROM file_assets WHERE project_id = @projectId AND deleted_at IS NULL',
`SELECT COUNT(*) AS count FROM file_assets
WHERE project_id = @projectId AND deleted_at IS NULL AND source = 'library'`,
{ projectId }
);
return { items: items.map(mapFile), total: total?.count ?? 0 };
@ -79,9 +84,10 @@ export class FileRepository {
create(input: CreateFileInput): FileAsset {
const now = new Date().toISOString();
const source = input.source ?? 'library';
const result = this.db.execute(
`INSERT INTO file_assets (project_id, uploader_id, filename, original_name, mime_type, size, path, created_at, deleted_at)
VALUES (@projectId, @uploaderId, @filename, @originalName, @mimeType, @size, @path, @createdAt, NULL)`,
`INSERT INTO file_assets (project_id, uploader_id, filename, original_name, mime_type, size, path, source, created_at, deleted_at)
VALUES (@projectId, @uploaderId, @filename, @originalName, @mimeType, @size, @path, @source, @createdAt, NULL)`,
{
projectId: input.projectId,
uploaderId: input.uploaderId,
@ -90,6 +96,7 @@ export class FileRepository {
mimeType: input.mimeType,
size: input.size,
path: input.path,
source,
createdAt: now,
}
);

View File

@ -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,

View File

@ -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;

View File

@ -0,0 +1,77 @@
import { AttachmentRepository } from '@/repositories/AttachmentRepository';
import { FileRepository } from '@/repositories/FileRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { ForbiddenError, NotFoundError } from '@/lib/errors';
import type { AttachmentTargetType, AttachmentView } from '@/lib/types';
/**
* 添付ファイルの関連付けを担うService。
* チャット/掲示板から呼ばれ、file_assets と対象(メッセージ/スレッド/コメント)を紐付ける。
* 権限チェック(プロジェクト参加 + 自身のアップロードのみ)とクリーンアップを行う。
*/
export class AttachmentService {
constructor(
private readonly attachmentRepository: AttachmentRepository,
private readonly fileRepository: FileRepository,
private readonly projectMemberRepository: ProjectMemberRepository
) {}
/**
* 指定ターゲットへファイルを添付する。
* 自身が当該プロジェクトにアップロードしたファイルのみ添付可能。
*/
attach(
actorId: number,
projectId: number,
targetType: AttachmentTargetType,
targetId: number,
fileIds: number[]
): AttachmentView[] {
this.requireMember(projectId, actorId);
const ids = Array.from(new Set(fileIds ?? []));
if (ids.length === 0) return [];
for (const fileId of ids) {
const file = this.fileRepository.findFileById(fileId);
if (!file) throw new NotFoundError('FileAsset', fileId);
if (file.projectId !== projectId) {
throw new ForbiddenError('プロジェクト外のファイルは添付できません');
}
if (file.uploaderId !== actorId) {
throw new ForbiddenError('自身のアップロードファイルのみ添付できます');
}
this.attachmentRepository.create({
projectId,
fileId,
targetType,
targetId,
});
}
return this.attachmentRepository.findViewsByTargets(targetType, [targetId]);
}
listViews(
targetType: AttachmentTargetType,
targetId: number
): AttachmentView[] {
return this.attachmentRepository.findViewsByTargets(targetType, [targetId]);
}
listViewsBatch(
targetType: AttachmentTargetType,
targetIds: number[]
): AttachmentView[] {
return this.attachmentRepository.findViewsByTargets(targetType, targetIds);
}
/** 対象の添付を論理削除(メッセージ/スレッド/コメント削除時)。 */
detach(targetType: AttachmentTargetType, targetId: number): void {
this.attachmentRepository.deleteByTarget(targetType, targetId);
}
private requireMember(projectId: number, actorId: number): void {
if (!this.projectMemberRepository.isMember(projectId, actorId)) {
throw new ForbiddenError('プロジェクトに参加していません');
}
}
}

View File

@ -6,8 +6,15 @@ import type { Paginated } from '@/repositories/BoardRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { NotificationService } from '@/services/NotificationService';
import { ActivityLogService } from '@/services/ActivityLogService';
import { AttachmentService } from '@/services/AttachmentService';
import type { SqliteDatabase } from '@/lib/db/sqlite';
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
import type { BoardCategory, BoardComment, BoardThread } from '@/lib/types';
import type {
AttachmentView,
BoardCategory,
BoardComment,
BoardThread,
} from '@/lib/types';
const VALID_CATEGORIES: BoardCategory[] = [
'notice',
@ -23,6 +30,7 @@ export interface CreateThreadInput {
title: string;
bodyMd: string;
category?: BoardCategory;
fileIds?: number[];
}
export interface UpdateThreadInput {
@ -36,14 +44,17 @@ export interface UpdateThreadInput {
/**
* 掲示板の業務ロジックを担うService。
* 権限チェック(プロジェクト参加者のみ)、スレッド/コメントCRUD、
* コメント追加時の通知(board_commented)アクティビティログ記録を行う。
* コメント追加時の通知(board_commented)アクティビティログ記録
* 添付ファイル紐付けを行う。
*/
export class BoardService {
constructor(
private readonly boardRepository: BoardRepository,
private readonly projectMemberRepository: ProjectMemberRepository,
private readonly notificationService: NotificationService,
private readonly activityLogService: ActivityLogService
private readonly activityLogService: ActivityLogService,
private readonly attachmentService: AttachmentService,
private readonly db: SqliteDatabase
) {}
listThreads(
@ -69,13 +80,25 @@ export class BoardService {
): BoardThread {
this.requireMember(projectId, actorId);
this.validateThreadInput(input.title, input.bodyMd, input.category);
const thread = this.boardRepository.createThread({
const thread = this.db.transaction(() => {
const t = this.boardRepository.createThread({
projectId,
title: input.title,
bodyMd: input.bodyMd,
authorId: actorId,
category: input.category ?? null,
});
if (input.fileIds && input.fileIds.length > 0) {
this.attachmentService.attach(
actorId,
projectId,
'board_thread',
t.id,
input.fileIds
);
}
return t;
});
this.activityLogService.logActivity({
projectId,
actorId,
@ -118,7 +141,10 @@ export class BoardService {
deleteThread(actorId: number, threadId: number): void {
const thread = this.getThread(actorId, threadId);
this.requireAuthorOrAdmin(thread.projectId, actorId, thread.authorId);
this.db.transaction(() => {
this.attachmentService.detach('board_thread', threadId);
this.boardRepository.deleteThread(threadId);
});
}
listComments(
@ -133,17 +159,30 @@ export class BoardService {
createComment(
actorId: number,
threadId: number,
bodyMd: string
bodyMd: string,
fileIds?: number[]
): BoardComment {
const thread = this.getThread(actorId, threadId);
if (!bodyMd.trim()) {
throw new ValidationError('コメント本文を入力してください', 'bodyMd');
}
const comment = this.boardRepository.createComment({
const comment = this.db.transaction(() => {
const c = this.boardRepository.createComment({
threadId: thread.id,
authorId: actorId,
bodyMd,
});
if (fileIds && fileIds.length > 0) {
this.attachmentService.attach(
actorId,
thread.projectId,
'board_comment',
c.id,
fileIds
);
}
return c;
});
// スレッド投稿者へ通知(自分自身へのコメントは通知しない)
if (thread.authorId !== actorId) {
this.notificationService.notifyOnEvent({
@ -188,7 +227,29 @@ export class BoardService {
if (!comment) throw new NotFoundError('BoardComment', commentId);
const thread = this.getThread(actorId, comment.threadId);
this.requireAuthorOrAdmin(thread.projectId, actorId, comment.authorId);
this.db.transaction(() => {
this.attachmentService.detach('board_comment', commentId);
this.boardRepository.deleteComment(commentId);
});
}
/**
* スレッド本文と指定コメントの添付ファイルを一括取得する。
* メンバーシップは getThread で検証する。
*/
getAttachments(
actorId: number,
threadId: number,
commentIds: number[]
): { thread: AttachmentView[]; comments: AttachmentView[] } {
const thread = this.getThread(actorId, threadId);
return {
thread: this.attachmentService.listViews('board_thread', thread.id),
comments: this.attachmentService.listViewsBatch(
'board_comment',
commentIds
),
};
}
private requireMember(projectId: number, actorId: number): void {

View File

@ -6,15 +6,21 @@ import type { Paginated } from '@/repositories/ChatRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { UserRepository } from '@/repositories/UserRepository';
import { NotificationService } from '@/services/NotificationService';
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 { ChatMessage } from '@/lib/types';
import type {
AttachmentView,
ChatMessage,
ChatMessageWithAttachments,
} from '@/lib/types';
const MENTION_RE = /@([^\s@]+@[^\s@]+\.[^\s@]+)/g;
/**
* チャットの業務ロジックを担うService。
* 権限チェック、メッセージ送信/編集/削除、メンション通知、SSE配信を行う。
* 権限チェック、メッセージ送信/編集/削除、メンション通知、添付ファイル紐付け、SSE配信を行う。
*/
export class ChatService {
constructor(
@ -22,28 +28,57 @@ export class ChatService {
private readonly projectMemberRepository: ProjectMemberRepository,
private readonly userRepository: UserRepository,
private readonly notificationService: NotificationService,
private readonly sseHub: SseHub
private readonly sseHub: SseHub,
private readonly attachmentService: AttachmentService,
private readonly db: SqliteDatabase
) {}
getHistory(
actorId: number,
projectId: number,
opts: ListMessagesOptions = {}
): Paginated<ChatMessage> {
): Paginated<ChatMessageWithAttachments> {
this.requireMember(projectId, actorId);
return this.chatRepository.findMessages(projectId, opts);
const page = this.chatRepository.findMessages(projectId, opts);
const byTarget = this.indexAttachments(
'chat_message',
page.items.map((m) => m.id)
);
return {
items: page.items.map((m) => this.withAttachments(m, byTarget.get(m.id))),
total: page.total,
};
}
sendMessage(actorId: number, projectId: number, body: string): ChatMessage {
sendMessage(
actorId: number,
projectId: number,
body: string,
fileIds?: number[]
): ChatMessageWithAttachments {
this.requireMember(projectId, actorId);
if (!body.trim()) {
throw new ValidationError('メッセージを入力してください', 'body');
}
const message = this.chatRepository.create({
// メッセージ本体と添付紐付けはトランザクション内で一貫保存する
const { message, attachments } = this.db.transaction(() => {
const msg = this.chatRepository.create({
projectId,
authorId: actorId,
body,
});
const atts =
fileIds && fileIds.length > 0
? this.attachmentService.attach(
actorId,
projectId,
'chat_message',
msg.id,
fileIds
)
: [];
return { message: msg, attachments: atts };
});
// メンション検出(@email) → 通知
const mentionedEmails = this.extractMentions(body);
@ -64,11 +99,12 @@ export class ChatService {
}
}
const messageWithAttachments = this.withAttachments(message, attachments);
this.sseHub.broadcast(projectId, {
type: 'chat.message.created',
data: { projectId, message },
data: { projectId, message: messageWithAttachments },
});
return message;
return messageWithAttachments;
}
editMessage(actorId: number, messageId: number, body: string): ChatMessage {
@ -101,13 +137,38 @@ export class ChatService {
if (message.authorId !== actorId && role !== 'admin') {
throw new ForbiddenError('投稿者または管理者のみ削除できます');
}
this.db.transaction(() => {
this.chatRepository.delete(messageId);
this.attachmentService.detach('chat_message', messageId);
});
this.sseHub.broadcast(message.projectId, {
type: 'chat.message.deleted',
data: { projectId: message.projectId, id: messageId },
});
}
private withAttachments(
message: ChatMessage,
attachments: AttachmentView[] | undefined
): ChatMessageWithAttachments {
return { ...message, attachments: attachments ?? [] };
}
private indexAttachments(
targetType: 'chat_message',
targetIds: number[]
): Map<number, AttachmentView[]> {
const map = new Map<number, AttachmentView[]>();
if (targetIds.length === 0) return map;
const views = this.attachmentService.listViewsBatch(targetType, targetIds);
for (const v of views) {
const list = map.get(v.targetId) ?? [];
list.push(v);
map.set(v.targetId, list);
}
return map;
}
private requireMember(projectId: number, actorId: number): void {
if (!this.projectMemberRepository.isMember(projectId, actorId)) {
throw new ForbiddenError('プロジェクトに参加していません');

View File

@ -7,7 +7,7 @@ import { NotificationService } from '@/services/NotificationService';
import { ActivityLogService } from '@/services/ActivityLogService';
import { SseHub } from '@/lib/sse/hub';
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
import type { FileAsset } from '@/lib/types';
import type { FileAsset, FileAssetSource } from '@/lib/types';
const ALLOWED_MIME_PREFIXES = [
'image/',
@ -62,33 +62,7 @@ export class FileStorageService {
projectId: number,
input: UploadFileInput
): FileAsset {
this.requireMember(projectId, actorId);
if (!input.data || input.data.length === 0) {
throw new ValidationError('ファイルが空です', 'file');
}
if (!this.isAllowedMime(input.mimeType)) {
throw new ValidationError('許可されていないファイル形式です', 'mimeType');
}
const ext =
this.sanitizeExt(input.originalName) ??
EXT_BY_MIME[input.mimeType] ??
'bin';
const filename = `${crypto.randomUUID()}.${ext}`;
const dir = path.join(this.uploadsDir, String(projectId));
fs.mkdirSync(dir, { recursive: true });
const filePath = path.join(dir, filename);
fs.writeFileSync(filePath, input.data);
const fileAsset = this.fileRepository.create({
projectId,
uploaderId: actorId,
filename,
originalName: this.sanitizeName(input.originalName) || 'file',
mimeType: input.mimeType,
size: input.data.length,
path: filePath,
});
const fileAsset = this.persistFile(actorId, projectId, input, 'library');
const memberIds = this.projectMemberRepository
.findByProject(projectId)
@ -117,6 +91,58 @@ export class FileStorageService {
return fileAsset;
}
/**
* チャット/掲示板の添付ファイル用アップロード。
* file_shared 通知・file.uploaded SSE・file_uploaded アクティビティを行わず、
* source='attachment' で保存する(Files一覧には出さない)。
*/
uploadForAttachment(
actorId: number,
projectId: number,
input: UploadFileInput
): FileAsset {
return this.persistFile(actorId, projectId, input, 'attachment');
}
/**
* 共通のファイル保存処理。権限チェック・MIMEチェック・FS保存・file_assets登録を行う。
*/
private persistFile(
actorId: number,
projectId: number,
input: UploadFileInput,
source: FileAssetSource
): FileAsset {
this.requireMember(projectId, actorId);
if (!input.data || input.data.length === 0) {
throw new ValidationError('ファイルが空です', 'file');
}
if (!this.isAllowedMime(input.mimeType)) {
throw new ValidationError('許可されていないファイル形式です', 'mimeType');
}
const ext =
this.sanitizeExt(input.originalName) ??
EXT_BY_MIME[input.mimeType] ??
'bin';
const filename = `${crypto.randomUUID()}.${ext}`;
const dir = path.join(this.uploadsDir, String(projectId));
fs.mkdirSync(dir, { recursive: true });
const filePath = path.join(dir, filename);
fs.writeFileSync(filePath, input.data);
return this.fileRepository.create({
projectId,
uploaderId: actorId,
filename,
originalName: this.sanitizeName(input.originalName) || 'file',
mimeType: input.mimeType,
size: input.data.length,
path: filePath,
source,
});
}
listFiles(actorId: number, projectId: number, page: number = 1) {
this.requireMember(projectId, actorId);
return this.fileRepository.findFilesByProject(projectId, page);

View File

@ -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,11 +135,11 @@ export class TodoService {
if (input.priority && !VALID_PRIORITIES.includes(input.priority)) {
throw new ValidationError('無効な優先度です', 'priority');
}
// タスク本体と添付紐付けはトランザクション内で一貫保存する
const item = this.db.transaction(() => {
const orderIndex =
input.columnId !== undefined
? this.todoRepository.maxItemOrderIndex(input.columnId) + 1
: 0;
const item = this.todoRepository.createItem({
this.todoRepository.maxItemOrderIndex(input.columnId) + 1;
const created = this.todoRepository.createItem({
projectId,
columnId: input.columnId,
title: input.title,
@ -131,9 +147,22 @@ export class TodoService {
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);
@ -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.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('プロジェクトに参加していません');

View File

@ -77,4 +77,44 @@ test.describe('board', () => {
page.getByRole('link', { name: new RegExp(title) })
).toHaveCount(0);
});
test('thread and comment can carry file attachments', async ({ page }) => {
const projectId = await setupOwner(page);
// 添付ファイル(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 } };
// ファイル付きスレッド作成
const title = unique('AttachThread');
const res = await page.request.post(
`/api/projects/${projectId}/board/threads`,
{ data: { title, bodyMd: 'body', fileIds: [file.id] } }
);
expect(res.ok()).toBeTruthy();
const { thread } = (await res.json()) as { thread: { id: number } };
// ファイル付きコメント
const cRes = await page.request.post(
`/api/projects/${projectId}/board/threads/${thread.id}/comments`,
{ data: { bodyMd: 'with file', fileIds: [file.id] } }
);
expect(cRes.ok()).toBeTruthy();
// 詳細ページでスレッド・コメントの添付が表示される
await page.goto(`/projects/${projectId}/board/${thread.id}`);
await expect(page.getByTestId('attachment-list').first()).toBeVisible();
});
});

View File

@ -65,4 +65,50 @@ test.describe('chat (SSE realtime)', () => {
await ownerContext.close();
await memberContext.close();
});
test('a message can carry file attachments rendered in the chat', async ({
page,
}) => {
const ownerEmail = unique('owner') + '@example.com';
await registerAndLogin(page, ownerEmail, 'Owner');
await page.getByLabel('プロジェクト名').fill(unique('Proj'));
await page.getByRole('button', { name: '新規プロジェクト' }).click();
await expect(page).toHaveURL(/\/projects\/\d+$/);
const projectId = Number(page.url().match(/\/projects\/(\d+)/)![1]);
// 添付ファイル(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 } };
// ファイル付きメッセージ送信
const text = unique('with-attach');
const sendRes = await page.request.post(
`/api/projects/${projectId}/chat/messages`,
{ data: { body: text, fileIds: [file.id] } }
);
expect(sendRes.ok()).toBeTruthy();
const { message } = (await sendRes.json()) as {
message: { attachments: { fileId: number }[] };
};
expect(message.attachments).toHaveLength(1);
// チャット画面に添付画像が表示される
await page.goto(`/projects/${projectId}/chat`);
await expect(page.getByTestId('chat-form')).toBeVisible();
await expect(page.getByTestId('attachment-list')).toBeVisible({
timeout: 10_000,
});
});
});

View File

@ -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();
});
});

View File

@ -5,8 +5,11 @@ import { UserRepository } from '@/repositories/UserRepository';
import { ProjectRepository } from '@/repositories/ProjectRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { ChatRepository } from '@/repositories/ChatRepository';
import { FileRepository } from '@/repositories/FileRepository';
import { AttachmentRepository } from '@/repositories/AttachmentRepository';
import { NotificationRepository } from '@/repositories/NotificationRepository';
import { NotificationService } from '@/services/NotificationService';
import { AttachmentService } from '@/services/AttachmentService';
import { ChatService } from '@/services/ChatService';
import { SseHub, type SseClient } from '@/lib/sse/hub';
@ -37,7 +40,13 @@ describe('chat → SSE broadcast (integration)', () => {
members,
users,
new NotificationService(new NotificationRepository(db)),
hub
hub,
new AttachmentService(
new AttachmentRepository(db),
new FileRepository(db),
members
),
db
);
});

View File

@ -158,6 +158,7 @@ describe('Migrator', () => {
const expectedTables = [
'activity_logs',
'attachments',
'board_comments',
'board_threads',
'calendar_events',
@ -180,6 +181,8 @@ describe('Migrator', () => {
}
expect(migrator.getAppliedMigrations().map((m) => m.filename)).toEqual([
'001_initial.sql',
'002_attachments.sql',
'003_todo_tags.sql',
]);
});
});

View File

@ -44,7 +44,10 @@ describe('SseHub', () => {
hub.broadcast(1, {
type: 'chat.message.created',
data: { projectId: 1, message: { id: 1 } as never },
data: {
projectId: 1,
message: { id: 1, attachments: [] } as never,
},
});
expect(a1.received).toHaveLength(1);

View File

@ -0,0 +1,152 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createMigratedTestDb } from '@/tests/helpers/db';
import type { SqliteDatabase } from '@/lib/db/sqlite';
import { UserRepository } from '@/repositories/UserRepository';
import { ProjectRepository } from '@/repositories/ProjectRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { FileRepository } from '@/repositories/FileRepository';
import { AttachmentRepository } from '@/repositories/AttachmentRepository';
describe('AttachmentRepository', () => {
let db: SqliteDatabase;
let repo: AttachmentRepository;
let fileRepo: FileRepository;
let projectId: number;
let userId: number;
beforeEach(() => {
db = createMigratedTestDb();
repo = new AttachmentRepository(db);
fileRepo = new FileRepository(db);
userId = new UserRepository(db).create({
name: 'U',
email: 'u@example.com',
passwordHash: 'h',
}).id;
projectId = new ProjectRepository(db).create({
name: 'P',
ownerId: userId,
}).id;
new ProjectMemberRepository(db).add(projectId, userId, 'admin');
});
afterEach(() => db.close());
function createFile(name: string) {
return fileRepo.create({
projectId,
uploaderId: userId,
filename: `${name}.bin`,
originalName: name,
mimeType: 'image/png',
size: 10,
path: `/tmp/${name}.bin`,
source: 'attachment',
});
}
it('creates and finds an attachment by target', () => {
const file = createFile('a');
const att = repo.create({
projectId,
fileId: file.id,
targetType: 'chat_message',
targetId: 1,
});
expect(att.id).toBeGreaterThan(0);
expect(att.targetType).toBe('chat_message');
const list = repo.findByTarget('chat_message', 1);
expect(list).toHaveLength(1);
expect(list[0].fileId).toBe(file.id);
});
it('findViewsByTargets joins file_assets and returns view fields', () => {
const f1 = createFile('one');
const f2 = createFile('two');
repo.create({
projectId,
fileId: f1.id,
targetType: 'board_comment',
targetId: 10,
});
repo.create({
projectId,
fileId: f2.id,
targetType: 'board_comment',
targetId: 11,
});
const views = repo.findViewsByTargets('board_comment', [10, 11]);
expect(views).toHaveLength(2);
const v1 = views.find((v) => v.targetId === 10);
expect(v1?.originalName).toBe('one');
expect(v1?.mimeType).toBe('image/png');
expect(v1?.fileId).toBe(f1.id);
});
it('returns empty array for empty target id list', () => {
expect(repo.findViewsByTargets('chat_message', [])).toEqual([]);
});
it('isolates by target_type (same target_id, different type)', () => {
const file = createFile('a');
repo.create({
projectId,
fileId: file.id,
targetType: 'chat_message',
targetId: 5,
});
repo.create({
projectId,
fileId: file.id,
targetType: 'board_thread',
targetId: 5,
});
expect(repo.findByTarget('chat_message', 5)).toHaveLength(1);
expect(repo.findByTarget('board_thread', 5)).toHaveLength(1);
});
it('excludes soft-deleted attachments and files', () => {
const f1 = createFile('keep');
const f2 = createFile('drop');
repo.create({
projectId,
fileId: f1.id,
targetType: 'chat_message',
targetId: 1,
});
repo.create({
projectId,
fileId: f2.id,
targetType: 'chat_message',
targetId: 1,
});
// ファイルを論理削除するとビューから消える
fileRepo.delete(f2.id);
const views = repo.findViewsByTargets('chat_message', [1]);
expect(views).toHaveLength(1);
expect(views[0].originalName).toBe('keep');
});
it('deleteByTarget soft-deletes all attachments for the target', () => {
createFile('a');
createFile('b');
const f1 = createFile('one');
const f2 = createFile('two');
repo.create({
projectId,
fileId: f1.id,
targetType: 'board_thread',
targetId: 7,
});
repo.create({
projectId,
fileId: f2.id,
targetType: 'board_thread',
targetId: 7,
});
expect(repo.deleteByTarget('board_thread', 7)).toBe(true);
expect(repo.findByTarget('board_thread', 7)).toHaveLength(0);
// 既に削除済みなら false
expect(repo.deleteByTarget('board_thread', 7)).toBe(false);
});
});

View File

@ -85,4 +85,37 @@ describe('FileRepository', () => {
expect(repo.findFilesByProject(projectId, 1, 2).items).toHaveLength(2);
expect(repo.findFilesByProject(projectId, 1, 2).total).toBe(5);
});
it('excludes attachment-source files from the library list but findFileById still returns them', () => {
createFile('library-file');
repo.create({
projectId,
uploaderId: userId,
filename: 'att.bin',
originalName: 'attachment-file',
mimeType: 'image/png',
size: 1,
path: '/tmp/att.bin',
source: 'attachment',
});
const list = repo.findFilesByProject(projectId);
expect(list.total).toBe(1);
expect(list.items[0].originalName).toBe('library-file');
// 添付ファイルも findFileById では取得可能(ダウンロード用)
const att = repo.findFilesByProject(projectId, 1, 100).items;
expect(att).toHaveLength(1);
});
it('defaults source to library when not specified', () => {
const f = repo.create({
projectId,
uploaderId: userId,
filename: 'd.bin',
originalName: 'd',
mimeType: 'text/plain',
size: 1,
path: '/tmp/d.bin',
});
expect(f.source).toBe('library');
});
});

View File

@ -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',

View File

@ -0,0 +1,168 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createMigratedTestDb } from '@/tests/helpers/db';
import type { SqliteDatabase } from '@/lib/db/sqlite';
import { UserRepository } from '@/repositories/UserRepository';
import { ProjectRepository } from '@/repositories/ProjectRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { FileRepository } from '@/repositories/FileRepository';
import { AttachmentRepository } from '@/repositories/AttachmentRepository';
import { FileStorageService } from '@/services/FileStorageService';
import { AttachmentService } from '@/services/AttachmentService';
import { NotificationService } from '@/services/NotificationService';
import { NotificationRepository } from '@/repositories/NotificationRepository';
import { ActivityLogService } from '@/services/ActivityLogService';
import { ActivityLogRepository } from '@/repositories/ActivityLogRepository';
import { SseHub } from '@/lib/sse/hub';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { ForbiddenError, NotFoundError } from '@/lib/errors';
describe('AttachmentService', () => {
let db: SqliteDatabase;
let service: AttachmentService;
let fileStorage: FileStorageService;
let attachmentRepo: AttachmentRepository;
let uploadsDir: string;
let projectId: number;
let authorId: number;
let memberId: number;
let outsiderId: number;
beforeEach(() => {
db = createMigratedTestDb();
uploadsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uploads-'));
const users = new UserRepository(db);
authorId = users.create({
name: 'A',
email: 'a@example.com',
passwordHash: 'h',
}).id;
memberId = users.create({
name: 'M',
email: 'm@example.com',
passwordHash: 'h',
}).id;
outsiderId = users.create({
name: 'O',
email: 'o@example.com',
passwordHash: 'h',
}).id;
projectId = new ProjectRepository(db).create({
name: 'P',
ownerId: authorId,
}).id;
const members = new ProjectMemberRepository(db);
members.add(projectId, authorId, 'admin');
members.add(projectId, memberId, 'member');
attachmentRepo = new AttachmentRepository(db);
fileStorage = new FileStorageService(
new FileRepository(db),
members,
new NotificationService(new NotificationRepository(db)),
new ActivityLogService(new ActivityLogRepository(db)),
new SseHub(),
uploadsDir
);
service = new AttachmentService(
attachmentRepo,
new FileRepository(db),
members
);
});
afterEach(() => {
db.close();
fs.rmSync(uploadsDir, { recursive: true, force: true });
});
function uploadAttachment(actorId: number) {
return fileStorage.uploadForAttachment(actorId, projectId, {
originalName: 'pic.png',
mimeType: 'image/png',
data: Buffer.from('img'),
});
}
it('attaches multiple files and returns their views', () => {
const f1 = uploadAttachment(authorId);
const f2 = uploadAttachment(authorId);
const views = service.attach(authorId, projectId, 'chat_message', 1, [
f1.id,
f2.id,
]);
expect(views).toHaveLength(2);
expect(views.map((v) => v.fileId).sort()).toEqual([f1.id, f2.id].sort());
});
it('returns empty for no fileIds', () => {
expect(service.attach(authorId, projectId, 'chat_message', 1, [])).toEqual(
[]
);
});
it('deduplicates repeated fileIds', () => {
const f1 = uploadAttachment(authorId);
const views = service.attach(authorId, projectId, 'chat_message', 1, [
f1.id,
f1.id,
]);
expect(views).toHaveLength(1);
});
it('forbids a non-member from attaching', () => {
const f1 = uploadAttachment(authorId);
expect(() =>
service.attach(outsiderId, projectId, 'chat_message', 1, [f1.id])
).toThrow(ForbiddenError);
});
it('throws NotFoundError for a missing file', () => {
expect(() =>
service.attach(authorId, projectId, 'chat_message', 1, [99999])
).toThrow(NotFoundError);
});
it('forbids attaching a file from another project', () => {
const otherProject = new ProjectRepository(db).create({
name: 'P2',
ownerId: authorId,
}).id;
const members = new ProjectMemberRepository(db);
members.add(otherProject, authorId, 'admin');
const otherFile = fileStorage.uploadForAttachment(authorId, otherProject, {
originalName: 'x.png',
mimeType: 'image/png',
data: Buffer.from('x'),
});
expect(() =>
service.attach(authorId, projectId, 'chat_message', 1, [otherFile.id])
).toThrow(ForbiddenError);
});
it('forbids attaching a file uploaded by another member', () => {
const otherMemberFile = uploadAttachment(memberId);
expect(() =>
service.attach(authorId, projectId, 'chat_message', 1, [
otherMemberFile.id,
])
).toThrow(ForbiddenError);
});
it('listViewsBatch returns views grouped by targetId', () => {
const f1 = uploadAttachment(authorId);
const f2 = uploadAttachment(authorId);
service.attach(authorId, projectId, 'board_comment', 10, [f1.id]);
service.attach(authorId, projectId, 'board_comment', 11, [f2.id]);
const views = service.listViewsBatch('board_comment', [10, 11]);
expect(views).toHaveLength(2);
});
it('detach soft-deletes attachments for the target', () => {
const f1 = uploadAttachment(authorId);
service.attach(authorId, projectId, 'chat_message', 5, [f1.id]);
expect(service.listViews('chat_message', 5)).toHaveLength(1);
service.detach('chat_message', 5);
expect(service.listViews('chat_message', 5)).toHaveLength(0);
});
});

View File

@ -5,19 +5,29 @@ import { UserRepository } from '@/repositories/UserRepository';
import { ProjectRepository } from '@/repositories/ProjectRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { BoardRepository } from '@/repositories/BoardRepository';
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 { BoardService } from '@/services/BoardService';
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
function makeService(db: SqliteDatabase) {
const members = new ProjectMemberRepository(db);
return new BoardService(
new BoardRepository(db),
new ProjectMemberRepository(db),
members,
new NotificationService(new NotificationRepository(db)),
new ActivityLogService(new ActivityLogRepository(db))
new ActivityLogService(new ActivityLogRepository(db)),
new AttachmentService(
new AttachmentRepository(db),
new FileRepository(db),
members
),
db
);
}
@ -167,4 +177,76 @@ describe('BoardService', () => {
it('throws NotFoundError for a non-existent thread', () => {
expect(() => service.getThread(memberId, 99999)).toThrow(NotFoundError);
});
it('createThread with fileIds attaches files to the thread', () => {
const fileRepo = new FileRepository(db);
const f1 = fileRepo.create({
projectId,
uploaderId: memberId,
filename: 'a.png',
originalName: 'a.png',
mimeType: 'image/png',
size: 1,
path: '/tmp/a.png',
source: 'attachment',
});
const thread = service.createThread(memberId, projectId, {
title: 'T',
bodyMd: 'b',
fileIds: [f1.id],
});
const atts = service.getAttachments(memberId, thread.id, []);
expect(atts.thread).toHaveLength(1);
expect(atts.thread[0].fileId).toBe(f1.id);
});
it('createComment with fileIds attaches files to the comment', () => {
const fileRepo = new FileRepository(db);
const f1 = fileRepo.create({
projectId,
uploaderId: memberId,
filename: 'c.png',
originalName: 'c.png',
mimeType: 'image/png',
size: 1,
path: '/tmp/c.png',
source: 'attachment',
});
const thread = service.createThread(authorId, projectId, {
title: 'T',
bodyMd: 'b',
});
const comment = service.createComment(memberId, thread.id, 'c', [f1.id]);
const atts = service.getAttachments(memberId, thread.id, [comment.id]);
expect(atts.comments).toHaveLength(1);
expect(atts.comments[0].targetId).toBe(comment.id);
});
it('deleteThread and deleteComment detach attachments', () => {
const fileRepo = new FileRepository(db);
const f1 = fileRepo.create({
projectId,
uploaderId: memberId,
filename: 'a.png',
originalName: 'a.png',
mimeType: 'image/png',
size: 1,
path: '/tmp/a.png',
source: 'attachment',
});
const thread = service.createThread(memberId, projectId, {
title: 'T',
bodyMd: 'b',
fileIds: [f1.id],
});
// 削除前は添付あり
expect(
new AttachmentRepository(db).findByTarget('board_thread', thread.id)
).toHaveLength(1);
service.deleteThread(memberId, thread.id);
// 削除後は論理削除されて取得できない
expect(
new AttachmentRepository(db).findByTarget('board_thread', thread.id)
).toHaveLength(0);
});
});

View File

@ -5,8 +5,11 @@ import { UserRepository } from '@/repositories/UserRepository';
import { ProjectRepository } from '@/repositories/ProjectRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { ChatRepository } from '@/repositories/ChatRepository';
import { FileRepository } from '@/repositories/FileRepository';
import { AttachmentRepository } from '@/repositories/AttachmentRepository';
import { NotificationRepository } from '@/repositories/NotificationRepository';
import { NotificationService } from '@/services/NotificationService';
import { AttachmentService } from '@/services/AttachmentService';
import { ChatService } from '@/services/ChatService';
import { SseHub, type SseClient } from '@/lib/sse/hub';
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
@ -56,7 +59,13 @@ describe('ChatService', () => {
members,
users,
new NotificationService(new NotificationRepository(db)),
hub
hub,
new AttachmentService(
new AttachmentRepository(db),
new FileRepository(db),
members
),
db
);
});
@ -135,4 +144,102 @@ describe('ChatService', () => {
it('throws NotFoundError for a non-existent message', () => {
expect(() => service.deleteMessage(authorId, 99999)).toThrow(NotFoundError);
});
it('sendMessage with fileIds attaches files and broadcasts them', () => {
const client = makeClient();
hub.addClient(projectId, client);
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 f2 = fileRepo.create({
projectId,
uploaderId: authorId,
filename: 'b.png',
originalName: 'b.png',
mimeType: 'image/png',
size: 1,
path: '/tmp/b.png',
source: 'attachment',
});
const message = service.sendMessage(authorId, projectId, 'see this', [
f1.id,
f2.id,
]);
expect(message.attachments).toHaveLength(2);
expect(client.received[0]).toContain('chat.message.created');
expect(client.received[0]).toContain('"attachments"');
});
it('getHistory returns messages with their attachments', () => {
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',
});
service.sendMessage(authorId, projectId, 'with file', [f1.id]);
service.sendMessage(authorId, projectId, 'no file');
const history = service.getHistory(authorId, projectId);
const withFile = history.items.find((m) => m.body === 'with file');
const noFile = history.items.find((m) => m.body === 'no file');
expect(withFile?.attachments).toHaveLength(1);
expect(noFile?.attachments).toEqual([]);
});
it('deleteMessage soft-deletes its attachments', () => {
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 m = service.sendMessage(authorId, projectId, 'x', [f1.id]);
expect(m.attachments).toHaveLength(1);
service.deleteMessage(authorId, m.id);
const history = service.getHistory(authorId, projectId);
// 削除されたメッセージは履歴に無く、添付も残らない
expect(history.items.find((msg) => msg.id === m.id)).toBeUndefined();
});
it('editMessage preserves attachments in history (regression)', () => {
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 m = service.sendMessage(authorId, projectId, 'orig', [f1.id]);
service.editMessage(authorId, m.id, 'edited');
const history = service.getHistory(authorId, projectId);
const edited = history.items.find((msg) => msg.id === m.id);
expect(edited?.body).toBe('edited');
// 編集後も添付は履歴に残る
expect(edited?.attachments).toHaveLength(1);
});
});

View File

@ -157,4 +157,49 @@ describe('FileStorageService', () => {
service.delete(authorId, file.id); // authorId is admin
expect(() => service.getFileInfo(memberId, file.id)).toThrow();
});
it('uploadForAttachment is silent: no notification/SSE/activity, source=attachment', () => {
const file = service.uploadForAttachment(authorId, projectId, {
originalName: 'pic.png',
mimeType: 'image/png',
data: Buffer.from('img'),
});
expect(file.source).toBe('attachment');
expect(fs.existsSync(file.path)).toBe(true);
// メンバーへの file_shared 通知は飛ばない
expect(new NotificationRepository(db).countUnreadByUser(memberId)).toBe(0);
// file_uploaded アクティビティは記録されない
expect(
new ActivityLogRepository(db)
.findByProject(projectId)
.items.some((l) => l.action === 'file_uploaded')
).toBe(false);
});
it('uploadForAttachment result is excluded from the library list', () => {
service.uploadForAttachment(authorId, projectId, {
originalName: 'pic.png',
mimeType: 'image/png',
data: Buffer.from('img'),
});
const list = service.listFiles(authorId, projectId);
expect(list.total).toBe(0);
});
it('uploadForAttachment still enforces MIME and membership', () => {
expect(() =>
service.uploadForAttachment(authorId, projectId, {
originalName: 'evil.exe',
mimeType: 'application/x-msdownload',
data: Buffer.from('x'),
})
).toThrow(ValidationError);
expect(() =>
service.uploadForAttachment(outsiderId, projectId, {
originalName: 'x.png',
mimeType: 'image/png',
data: Buffer.from('x'),
})
).toThrow(ForbiddenError);
});
});

View File

@ -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();
});
});