Compare commits
2 Commits
c747978f3d
...
b2b3fb00b5
| Author | SHA1 | Date | |
|---|---|---|---|
| b2b3fb00b5 | |||
| 25d800a529 |
41
app/api/projects/[projectId]/attachments/route.ts
Normal file
41
app/api/projects/[projectId]/attachments/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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 ? '投稿中...' : 'コメント投稿'}
|
||||||
|
|||||||
@ -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 ? '作成中...' : 'スレッド作成'}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
93
components/files/AttachmentList.tsx
Normal file
93
components/files/AttachmentList.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
components/files/AttachmentPicker.tsx
Normal file
163
components/files/AttachmentPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
22
lib/db/migrations/002_attachments.sql
Normal file
22
lib/db/migrations/002_attachments.sql
Normal 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';
|
||||||
@ -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';
|
||||||
|
|||||||
135
repositories/AttachmentRepository.ts
Normal file
135
repositories/AttachmentRepository.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
77
services/AttachmentService.ts
Normal file
77
services/AttachmentService.ts
Normal 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('プロジェクトに参加していません');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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('プロジェクトに参加していません');
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
152
tests/unit/repositories/AttachmentRepository.test.ts
Normal file
152
tests/unit/repositories/AttachmentRepository.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
168
tests/unit/services/AttachmentService.test.ts
Normal file
168
tests/unit/services/AttachmentService.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user