1 Commits

Author SHA1 Message Date
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
31 changed files with 1640 additions and 102 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(); const service = createBoardService();
try { try {
const fileIds = Array.isArray(body.fileIds)
? body.fileIds
.map((n) => Number(n))
.filter((n) => Number.isFinite(n) && n > 0)
: [];
const comment = service.createComment( const comment = service.createComment(
user.id, user.id,
Number(threadId), Number(threadId),
String(body.bodyMd ?? '') String(body.bodyMd ?? ''),
fileIds
); );
return NextResponse.json({ comment }, { status: 201 }); return NextResponse.json({ comment }, { status: 201 });
} catch (error) { } catch (error) {

View File

@ -42,6 +42,11 @@ export async function POST(
const service = createBoardService(); const service = createBoardService();
try { 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), { const thread = service.createThread(user.id, Number(projectId), {
title: String(body.title ?? ''), title: String(body.title ?? ''),
bodyMd: String(body.bodyMd ?? ''), bodyMd: String(body.bodyMd ?? ''),
@ -49,6 +54,7 @@ export async function POST(
typeof body.category === 'string' typeof body.category === 'string'
? (body.category as never) ? (body.category as never)
: undefined, : undefined,
fileIds,
}); });
return NextResponse.json({ thread }, { status: 201 }); return NextResponse.json({ thread }, { status: 201 });
} catch (error) { } catch (error) {

View File

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

View File

@ -5,6 +5,8 @@ import { Header } from '@/components/layout/Header';
import { ProjectNav } from '@/components/layout/ProjectNav'; import { ProjectNav } from '@/components/layout/ProjectNav';
import { MarkdownBody } from '@/components/board/MarkdownBody'; import { MarkdownBody } from '@/components/board/MarkdownBody';
import { CommentForm } from '@/components/board/CommentForm'; import { CommentForm } from '@/components/board/CommentForm';
import { AttachmentList } from '@/components/files/AttachmentList';
import type { AttachmentView } from '@/lib/types';
import { ForbiddenError, NotFoundError } from '@/lib/errors'; import { ForbiddenError, NotFoundError } from '@/lib/errors';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@ -21,9 +23,15 @@ export default async function ThreadDetailPage({
const boardService = createBoardService(); const boardService = createBoardService();
let thread; let thread;
let comments; let comments;
let attachments: { thread: AttachmentView[]; comments: AttachmentView[] };
try { try {
thread = boardService.getThread(user.id, Number(threadId)); thread = boardService.getThread(user.id, Number(threadId));
comments = boardService.listComments(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) { } catch (error) {
if (error instanceof ForbiddenError || error instanceof NotFoundError) { if (error instanceof ForbiddenError || error instanceof NotFoundError) {
redirect(`/projects/${projectId}/board`); redirect(`/projects/${projectId}/board`);
@ -31,6 +39,18 @@ export default async function ThreadDetailPage({
throw error; 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 ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} /> <Header user={toPublicUser(user)} />
@ -54,6 +74,14 @@ export default async function ThreadDetailPage({
<div className="mt-4"> <div className="mt-4">
<MarkdownBody bodyMd={thread.bodyMd} /> <MarkdownBody bodyMd={thread.bodyMd} />
</div> </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"> <p className="mt-4 text-xs text-gray-400">
稿: {thread.createdAt} / : {thread.updatedAt} 稿: {thread.createdAt} / : {thread.updatedAt}
</p> </p>
@ -67,6 +95,14 @@ export default async function ThreadDetailPage({
className="rounded-lg border bg-white p-4 shadow-sm" className="rounded-lg border bg-white p-4 shadow-sm"
> >
<MarkdownBody bodyMd={comment.bodyMd} /> <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> <p className="mt-2 text-xs text-gray-400">{comment.createdAt}</p>
</div> </div>
))} ))}

View File

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

View File

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

View File

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

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

View File

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

@ -141,10 +141,47 @@ export interface FileAsset {
mimeType: string; mimeType: string;
size: number; size: number;
path: string; path: string;
source: FileAssetSource;
createdAt: string; createdAt: string;
deletedAt: string | null; deletedAt: string | null;
} }
/** ファイルの来源。library=Files一覧公開、attachment=添付専用(一覧に出さない)。 */
export type FileAssetSource = 'library' | 'attachment';
/** 添付ファイルの関連付け対象。 */
export type AttachmentTargetType =
| 'chat_message'
| 'board_thread'
| 'board_comment';
/** 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 { export interface ProjectNote {
id: number; id: number;
projectId: number; projectId: number;
@ -281,7 +318,10 @@ export type NotificationEvent =
export type SseEvent = export type SseEvent =
| { | {
type: 'chat.message.created'; type: 'chat.message.created';
data: { projectId: number; message: ChatMessage }; data: {
projectId: number;
message: ChatMessageWithAttachments;
};
} }
| { | {
type: 'chat.message.updated'; 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 { SqliteDatabase } from '@/lib/db/sqlite';
import type { FileAsset } from '@/lib/types'; import type { FileAsset, FileAssetSource } from '@/lib/types';
const DEFAULT_PAGE_SIZE = 20; const DEFAULT_PAGE_SIZE = 20;
@ -17,6 +17,7 @@ interface FileAssetRow {
mime_type: string; mime_type: string;
size: number; size: number;
path: string; path: string;
source: string;
created_at: string; created_at: string;
deleted_at: string | null; deleted_at: string | null;
} }
@ -31,6 +32,7 @@ function mapFile(row: FileAssetRow): FileAsset {
mimeType: row.mime_type, mimeType: row.mime_type,
size: row.size, size: row.size,
path: row.path, path: row.path,
source: row.source as FileAssetSource,
createdAt: row.created_at, createdAt: row.created_at,
deletedAt: row.deleted_at, deletedAt: row.deleted_at,
}; };
@ -44,6 +46,7 @@ export interface CreateFileInput {
mimeType: string; mimeType: string;
size: number; size: number;
path: string; path: string;
source?: FileAssetSource;
} }
export class FileRepository { export class FileRepository {
@ -55,15 +58,17 @@ export class FileRepository {
pageSize: number = DEFAULT_PAGE_SIZE pageSize: number = DEFAULT_PAGE_SIZE
): Paginated<FileAsset> { ): Paginated<FileAsset> {
const offset = (page - 1) * pageSize; const offset = (page - 1) * pageSize;
// 添付専用ファイル(source='attachment')はFiles一覧に出さない
const items = this.db.query<FileAssetRow>( const items = this.db.query<FileAssetRow>(
`SELECT * FROM file_assets `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 ORDER BY created_at DESC, id DESC
LIMIT @pageSize OFFSET @offset`, LIMIT @pageSize OFFSET @offset`,
{ projectId, pageSize, offset } { projectId, pageSize, offset }
); );
const total = this.db.get<{ count: number }>( 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 } { projectId }
); );
return { items: items.map(mapFile), total: total?.count ?? 0 }; return { items: items.map(mapFile), total: total?.count ?? 0 };
@ -79,9 +84,10 @@ export class FileRepository {
create(input: CreateFileInput): FileAsset { create(input: CreateFileInput): FileAsset {
const now = new Date().toISOString(); const now = new Date().toISOString();
const source = input.source ?? 'library';
const result = this.db.execute( const result = this.db.execute(
`INSERT INTO file_assets (project_id, uploader_id, filename, original_name, mime_type, size, path, created_at, deleted_at) `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, @createdAt, NULL)`, VALUES (@projectId, @uploaderId, @filename, @originalName, @mimeType, @size, @path, @source, @createdAt, NULL)`,
{ {
projectId: input.projectId, projectId: input.projectId,
uploaderId: input.uploaderId, uploaderId: input.uploaderId,
@ -90,6 +96,7 @@ export class FileRepository {
mimeType: input.mimeType, mimeType: input.mimeType,
size: input.size, size: input.size,
path: input.path, path: input.path,
source,
createdAt: now, createdAt: now,
} }
); );

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 { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { NotificationService } from '@/services/NotificationService'; import { NotificationService } from '@/services/NotificationService';
import { ActivityLogService } from '@/services/ActivityLogService'; import { ActivityLogService } from '@/services/ActivityLogService';
import { AttachmentService } from '@/services/AttachmentService';
import type { SqliteDatabase } from '@/lib/db/sqlite';
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors'; 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[] = [ const VALID_CATEGORIES: BoardCategory[] = [
'notice', 'notice',
@ -23,6 +30,7 @@ export interface CreateThreadInput {
title: string; title: string;
bodyMd: string; bodyMd: string;
category?: BoardCategory; category?: BoardCategory;
fileIds?: number[];
} }
export interface UpdateThreadInput { export interface UpdateThreadInput {
@ -36,14 +44,17 @@ export interface UpdateThreadInput {
/** /**
* 掲示板の業務ロジックを担うService。 * 掲示板の業務ロジックを担うService。
* 権限チェック(プロジェクト参加者のみ)、スレッド/コメントCRUD、 * 権限チェック(プロジェクト参加者のみ)、スレッド/コメントCRUD、
* コメント追加時の通知(board_commented)アクティビティログ記録を行う。 * コメント追加時の通知(board_commented)アクティビティログ記録
* 添付ファイル紐付けを行う。
*/ */
export class BoardService { export class BoardService {
constructor( constructor(
private readonly boardRepository: BoardRepository, private readonly boardRepository: BoardRepository,
private readonly projectMemberRepository: ProjectMemberRepository, private readonly projectMemberRepository: ProjectMemberRepository,
private readonly notificationService: NotificationService, private readonly notificationService: NotificationService,
private readonly activityLogService: ActivityLogService private readonly activityLogService: ActivityLogService,
private readonly attachmentService: AttachmentService,
private readonly db: SqliteDatabase
) {} ) {}
listThreads( listThreads(
@ -69,12 +80,24 @@ export class BoardService {
): BoardThread { ): BoardThread {
this.requireMember(projectId, actorId); this.requireMember(projectId, actorId);
this.validateThreadInput(input.title, input.bodyMd, input.category); this.validateThreadInput(input.title, input.bodyMd, input.category);
const thread = this.boardRepository.createThread({ const thread = this.db.transaction(() => {
projectId, const t = this.boardRepository.createThread({
title: input.title, projectId,
bodyMd: input.bodyMd, title: input.title,
authorId: actorId, bodyMd: input.bodyMd,
category: input.category ?? null, 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({ this.activityLogService.logActivity({
projectId, projectId,
@ -118,7 +141,10 @@ export class BoardService {
deleteThread(actorId: number, threadId: number): void { deleteThread(actorId: number, threadId: number): void {
const thread = this.getThread(actorId, threadId); const thread = this.getThread(actorId, threadId);
this.requireAuthorOrAdmin(thread.projectId, actorId, thread.authorId); this.requireAuthorOrAdmin(thread.projectId, actorId, thread.authorId);
this.boardRepository.deleteThread(threadId); this.db.transaction(() => {
this.attachmentService.detach('board_thread', threadId);
this.boardRepository.deleteThread(threadId);
});
} }
listComments( listComments(
@ -133,16 +159,29 @@ export class BoardService {
createComment( createComment(
actorId: number, actorId: number,
threadId: number, threadId: number,
bodyMd: string bodyMd: string,
fileIds?: number[]
): BoardComment { ): BoardComment {
const thread = this.getThread(actorId, threadId); const thread = this.getThread(actorId, threadId);
if (!bodyMd.trim()) { if (!bodyMd.trim()) {
throw new ValidationError('コメント本文を入力してください', 'bodyMd'); throw new ValidationError('コメント本文を入力してください', 'bodyMd');
} }
const comment = this.boardRepository.createComment({ const comment = this.db.transaction(() => {
threadId: thread.id, const c = this.boardRepository.createComment({
authorId: actorId, threadId: thread.id,
bodyMd, 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) { if (thread.authorId !== actorId) {
@ -188,7 +227,29 @@ export class BoardService {
if (!comment) throw new NotFoundError('BoardComment', commentId); if (!comment) throw new NotFoundError('BoardComment', commentId);
const thread = this.getThread(actorId, comment.threadId); const thread = this.getThread(actorId, comment.threadId);
this.requireAuthorOrAdmin(thread.projectId, actorId, comment.authorId); this.requireAuthorOrAdmin(thread.projectId, actorId, comment.authorId);
this.boardRepository.deleteComment(commentId); 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 { 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 { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { UserRepository } from '@/repositories/UserRepository'; import { UserRepository } from '@/repositories/UserRepository';
import { NotificationService } from '@/services/NotificationService'; import { NotificationService } from '@/services/NotificationService';
import { AttachmentService } from '@/services/AttachmentService';
import { SseHub } from '@/lib/sse/hub'; import { SseHub } from '@/lib/sse/hub';
import type { SqliteDatabase } from '@/lib/db/sqlite';
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors'; import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
import type { ChatMessage } from '@/lib/types'; import type {
AttachmentView,
ChatMessage,
ChatMessageWithAttachments,
} from '@/lib/types';
const MENTION_RE = /@([^\s@]+@[^\s@]+\.[^\s@]+)/g; const MENTION_RE = /@([^\s@]+@[^\s@]+\.[^\s@]+)/g;
/** /**
* チャットの業務ロジックを担うService。 * チャットの業務ロジックを担うService。
* 権限チェック、メッセージ送信/編集/削除、メンション通知、SSE配信を行う。 * 権限チェック、メッセージ送信/編集/削除、メンション通知、添付ファイル紐付け、SSE配信を行う。
*/ */
export class ChatService { export class ChatService {
constructor( constructor(
@ -22,27 +28,56 @@ export class ChatService {
private readonly projectMemberRepository: ProjectMemberRepository, private readonly projectMemberRepository: ProjectMemberRepository,
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
private readonly notificationService: NotificationService, private readonly notificationService: NotificationService,
private readonly sseHub: SseHub private readonly sseHub: SseHub,
private readonly attachmentService: AttachmentService,
private readonly db: SqliteDatabase
) {} ) {}
getHistory( getHistory(
actorId: number, actorId: number,
projectId: number, projectId: number,
opts: ListMessagesOptions = {} opts: ListMessagesOptions = {}
): Paginated<ChatMessage> { ): Paginated<ChatMessageWithAttachments> {
this.requireMember(projectId, actorId); 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); this.requireMember(projectId, actorId);
if (!body.trim()) { if (!body.trim()) {
throw new ValidationError('メッセージを入力してください', 'body'); throw new ValidationError('メッセージを入力してください', 'body');
} }
const message = this.chatRepository.create({ // メッセージ本体と添付紐付けはトランザクション内で一貫保存する
projectId, const { message, attachments } = this.db.transaction(() => {
authorId: actorId, const msg = this.chatRepository.create({
body, 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) → 通知 // メンション検出(@email) → 通知
@ -64,11 +99,12 @@ export class ChatService {
} }
} }
const messageWithAttachments = this.withAttachments(message, attachments);
this.sseHub.broadcast(projectId, { this.sseHub.broadcast(projectId, {
type: 'chat.message.created', type: 'chat.message.created',
data: { projectId, message }, data: { projectId, message: messageWithAttachments },
}); });
return message; return messageWithAttachments;
} }
editMessage(actorId: number, messageId: number, body: string): ChatMessage { editMessage(actorId: number, messageId: number, body: string): ChatMessage {
@ -101,13 +137,38 @@ export class ChatService {
if (message.authorId !== actorId && role !== 'admin') { if (message.authorId !== actorId && role !== 'admin') {
throw new ForbiddenError('投稿者または管理者のみ削除できます'); throw new ForbiddenError('投稿者または管理者のみ削除できます');
} }
this.chatRepository.delete(messageId); this.db.transaction(() => {
this.chatRepository.delete(messageId);
this.attachmentService.detach('chat_message', messageId);
});
this.sseHub.broadcast(message.projectId, { this.sseHub.broadcast(message.projectId, {
type: 'chat.message.deleted', type: 'chat.message.deleted',
data: { projectId: message.projectId, id: messageId }, 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 { private requireMember(projectId: number, actorId: number): void {
if (!this.projectMemberRepository.isMember(projectId, actorId)) { if (!this.projectMemberRepository.isMember(projectId, actorId)) {
throw new ForbiddenError('プロジェクトに参加していません'); throw new ForbiddenError('プロジェクトに参加していません');

View File

@ -7,7 +7,7 @@ import { NotificationService } from '@/services/NotificationService';
import { ActivityLogService } from '@/services/ActivityLogService'; import { ActivityLogService } from '@/services/ActivityLogService';
import { SseHub } from '@/lib/sse/hub'; import { SseHub } from '@/lib/sse/hub';
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors'; import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
import type { FileAsset } from '@/lib/types'; import type { FileAsset, FileAssetSource } from '@/lib/types';
const ALLOWED_MIME_PREFIXES = [ const ALLOWED_MIME_PREFIXES = [
'image/', 'image/',
@ -62,33 +62,7 @@ export class FileStorageService {
projectId: number, projectId: number,
input: UploadFileInput input: UploadFileInput
): FileAsset { ): FileAsset {
this.requireMember(projectId, actorId); const fileAsset = this.persistFile(actorId, projectId, input, 'library');
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 memberIds = this.projectMemberRepository const memberIds = this.projectMemberRepository
.findByProject(projectId) .findByProject(projectId)
@ -117,6 +91,58 @@ export class FileStorageService {
return fileAsset; 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) { listFiles(actorId: number, projectId: number, page: number = 1) {
this.requireMember(projectId, actorId); this.requireMember(projectId, actorId);
return this.fileRepository.findFilesByProject(projectId, page); return this.fileRepository.findFilesByProject(projectId, page);

View File

@ -77,4 +77,44 @@ test.describe('board', () => {
page.getByRole('link', { name: new RegExp(title) }) page.getByRole('link', { name: new RegExp(title) })
).toHaveCount(0); ).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 ownerContext.close();
await memberContext.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

@ -5,8 +5,11 @@ import { UserRepository } from '@/repositories/UserRepository';
import { ProjectRepository } from '@/repositories/ProjectRepository'; import { ProjectRepository } from '@/repositories/ProjectRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository'; import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { ChatRepository } from '@/repositories/ChatRepository'; import { ChatRepository } from '@/repositories/ChatRepository';
import { FileRepository } from '@/repositories/FileRepository';
import { AttachmentRepository } from '@/repositories/AttachmentRepository';
import { NotificationRepository } from '@/repositories/NotificationRepository'; import { NotificationRepository } from '@/repositories/NotificationRepository';
import { NotificationService } from '@/services/NotificationService'; import { NotificationService } from '@/services/NotificationService';
import { AttachmentService } from '@/services/AttachmentService';
import { ChatService } from '@/services/ChatService'; import { ChatService } from '@/services/ChatService';
import { SseHub, type SseClient } from '@/lib/sse/hub'; import { SseHub, type SseClient } from '@/lib/sse/hub';
@ -37,7 +40,13 @@ describe('chat → SSE broadcast (integration)', () => {
members, members,
users, users,
new NotificationService(new NotificationRepository(db)), 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 = [ const expectedTables = [
'activity_logs', 'activity_logs',
'attachments',
'board_comments', 'board_comments',
'board_threads', 'board_threads',
'calendar_events', 'calendar_events',
@ -180,6 +181,7 @@ describe('Migrator', () => {
} }
expect(migrator.getAppliedMigrations().map((m) => m.filename)).toEqual([ expect(migrator.getAppliedMigrations().map((m) => m.filename)).toEqual([
'001_initial.sql', '001_initial.sql',
'002_attachments.sql',
]); ]);
}); });
}); });

View File

@ -44,7 +44,10 @@ describe('SseHub', () => {
hub.broadcast(1, { hub.broadcast(1, {
type: 'chat.message.created', 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); 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).items).toHaveLength(2);
expect(repo.findFilesByProject(projectId, 1, 2).total).toBe(5); 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

@ -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 { ProjectRepository } from '@/repositories/ProjectRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository'; import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { BoardRepository } from '@/repositories/BoardRepository'; import { BoardRepository } from '@/repositories/BoardRepository';
import { FileRepository } from '@/repositories/FileRepository';
import { AttachmentRepository } from '@/repositories/AttachmentRepository';
import { NotificationRepository } from '@/repositories/NotificationRepository'; import { NotificationRepository } from '@/repositories/NotificationRepository';
import { ActivityLogRepository } from '@/repositories/ActivityLogRepository'; import { ActivityLogRepository } from '@/repositories/ActivityLogRepository';
import { NotificationService } from '@/services/NotificationService'; import { NotificationService } from '@/services/NotificationService';
import { ActivityLogService } from '@/services/ActivityLogService'; import { ActivityLogService } from '@/services/ActivityLogService';
import { AttachmentService } from '@/services/AttachmentService';
import { BoardService } from '@/services/BoardService'; import { BoardService } from '@/services/BoardService';
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors'; import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
function makeService(db: SqliteDatabase) { function makeService(db: SqliteDatabase) {
const members = new ProjectMemberRepository(db);
return new BoardService( return new BoardService(
new BoardRepository(db), new BoardRepository(db),
new ProjectMemberRepository(db), members,
new NotificationService(new NotificationRepository(db)), 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', () => { it('throws NotFoundError for a non-existent thread', () => {
expect(() => service.getThread(memberId, 99999)).toThrow(NotFoundError); 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 { ProjectRepository } from '@/repositories/ProjectRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository'; import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { ChatRepository } from '@/repositories/ChatRepository'; import { ChatRepository } from '@/repositories/ChatRepository';
import { FileRepository } from '@/repositories/FileRepository';
import { AttachmentRepository } from '@/repositories/AttachmentRepository';
import { NotificationRepository } from '@/repositories/NotificationRepository'; import { NotificationRepository } from '@/repositories/NotificationRepository';
import { NotificationService } from '@/services/NotificationService'; import { NotificationService } from '@/services/NotificationService';
import { AttachmentService } from '@/services/AttachmentService';
import { ChatService } from '@/services/ChatService'; import { ChatService } from '@/services/ChatService';
import { SseHub, type SseClient } from '@/lib/sse/hub'; import { SseHub, type SseClient } from '@/lib/sse/hub';
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors'; import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
@ -56,7 +59,13 @@ describe('ChatService', () => {
members, members,
users, users,
new NotificationService(new NotificationRepository(db)), 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', () => { it('throws NotFoundError for a non-existent message', () => {
expect(() => service.deleteMessage(authorId, 99999)).toThrow(NotFoundError); 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 service.delete(authorId, file.id); // authorId is admin
expect(() => service.getFileInfo(memberId, file.id)).toThrow(); 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);
});
}); });