diff --git a/app/api/projects/[projectId]/attachments/route.ts b/app/api/projects/[projectId]/attachments/route.ts new file mode 100644 index 0000000..f1cf400 --- /dev/null +++ b/app/api/projects/[projectId]/attachments/route.ts @@ -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); + } +} diff --git a/app/api/projects/[projectId]/board/threads/[threadId]/comments/route.ts b/app/api/projects/[projectId]/board/threads/[threadId]/comments/route.ts index 0d4af9c..8fa9169 100644 --- a/app/api/projects/[projectId]/board/threads/[threadId]/comments/route.ts +++ b/app/api/projects/[projectId]/board/threads/[threadId]/comments/route.ts @@ -22,10 +22,16 @@ export async function POST( const service = createBoardService(); try { + const fileIds = Array.isArray(body.fileIds) + ? body.fileIds + .map((n) => Number(n)) + .filter((n) => Number.isFinite(n) && n > 0) + : []; const comment = service.createComment( user.id, Number(threadId), - String(body.bodyMd ?? '') + String(body.bodyMd ?? ''), + fileIds ); return NextResponse.json({ comment }, { status: 201 }); } catch (error) { diff --git a/app/api/projects/[projectId]/board/threads/route.ts b/app/api/projects/[projectId]/board/threads/route.ts index c9cbdb6..166be44 100644 --- a/app/api/projects/[projectId]/board/threads/route.ts +++ b/app/api/projects/[projectId]/board/threads/route.ts @@ -42,6 +42,11 @@ export async function POST( const service = createBoardService(); try { + const fileIds = Array.isArray(body.fileIds) + ? body.fileIds + .map((n) => Number(n)) + .filter((n) => Number.isFinite(n) && n > 0) + : []; const thread = service.createThread(user.id, Number(projectId), { title: String(body.title ?? ''), bodyMd: String(body.bodyMd ?? ''), @@ -49,6 +54,7 @@ export async function POST( typeof body.category === 'string' ? (body.category as never) : undefined, + fileIds, }); return NextResponse.json({ thread }, { status: 201 }); } catch (error) { diff --git a/app/api/projects/[projectId]/chat/messages/route.ts b/app/api/projects/[projectId]/chat/messages/route.ts index 86ddb87..f97041f 100644 --- a/app/api/projects/[projectId]/chat/messages/route.ts +++ b/app/api/projects/[projectId]/chat/messages/route.ts @@ -42,10 +42,16 @@ export async function POST( const service = createChatService(); try { + const fileIds = Array.isArray(body.fileIds) + ? body.fileIds + .map((n) => Number(n)) + .filter((n) => Number.isFinite(n) && n > 0) + : []; const message = service.sendMessage( user.id, Number(projectId), - String(body.body ?? '') + String(body.body ?? ''), + fileIds ); return NextResponse.json({ message }, { status: 201 }); } catch (error) { diff --git a/app/projects/[projectId]/board/[threadId]/page.tsx b/app/projects/[projectId]/board/[threadId]/page.tsx index 49cdee2..6c64ab8 100644 --- a/app/projects/[projectId]/board/[threadId]/page.tsx +++ b/app/projects/[projectId]/board/[threadId]/page.tsx @@ -5,6 +5,8 @@ import { Header } from '@/components/layout/Header'; import { ProjectNav } from '@/components/layout/ProjectNav'; import { MarkdownBody } from '@/components/board/MarkdownBody'; import { CommentForm } from '@/components/board/CommentForm'; +import { AttachmentList } from '@/components/files/AttachmentList'; +import type { AttachmentView } from '@/lib/types'; import { ForbiddenError, NotFoundError } from '@/lib/errors'; export const dynamic = 'force-dynamic'; @@ -21,9 +23,15 @@ export default async function ThreadDetailPage({ const boardService = createBoardService(); let thread; let comments; + let attachments: { thread: AttachmentView[]; comments: AttachmentView[] }; try { thread = boardService.getThread(user.id, Number(threadId)); comments = boardService.listComments(user.id, Number(threadId)); + attachments = boardService.getAttachments( + user.id, + thread.id, + comments.items.map((c) => c.id) + ); } catch (error) { if (error instanceof ForbiddenError || error instanceof NotFoundError) { redirect(`/projects/${projectId}/board`); @@ -31,6 +39,18 @@ export default async function ThreadDetailPage({ throw error; } + // コメントIDごとに添付をグループ化 + const commentAttachments = new Map( + attachments.comments.map((a) => [ + a.targetId, + [] as typeof attachments.comments, + ]) + ); + for (const a of attachments.comments) { + const list = commentAttachments.get(a.targetId); + if (list) list.push(a); + } + return (
@@ -54,6 +74,14 @@ export default async function ThreadDetailPage({
+ {attachments.thread.length > 0 && ( +
+

+ 添付ファイル +

+ +
+ )}

投稿: {thread.createdAt} / 更新: {thread.updatedAt}

@@ -67,6 +95,14 @@ export default async function ThreadDetailPage({ className="rounded-lg border bg-white p-4 shadow-sm" > + {commentAttachments.has(comment.id) && + commentAttachments.get(comment.id)!.length > 0 && ( +
+ +
+ )}

{comment.createdAt}

))} diff --git a/components/board/CommentForm.tsx b/components/board/CommentForm.tsx index c184282..e151f2c 100644 --- a/components/board/CommentForm.tsx +++ b/components/board/CommentForm.tsx @@ -1,7 +1,9 @@ 'use client'; -import { useState, type FormEvent } from 'react'; +import { useRef, useState, type FormEvent } from 'react'; import { useRouter } from 'next/navigation'; +import { AttachmentPicker } from '@/components/files/AttachmentPicker'; +import type { AttachmentPickerHandle } from '@/components/files/AttachmentPicker'; export function CommentForm({ projectId, @@ -11,25 +13,29 @@ export function CommentForm({ threadId: number; }) { const router = useRouter(); + const pickerRef = useRef(null); const [bodyMd, setBodyMd] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [pickerLoading, setPickerLoading] = useState(false); async function onSubmit(event: FormEvent) { event.preventDefault(); setLoading(true); setError(null); + const fileIds = pickerRef.current?.getFileIds() ?? []; const res = await fetch( `/api/projects/${projectId}/board/threads/${threadId}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ bodyMd }), + body: JSON.stringify({ bodyMd, fileIds }), } ); setLoading(false); if (res.ok) { setBodyMd(''); + pickerRef.current?.clear(); router.refresh(); } else { const b = (await res.json().catch(() => null)) as { @@ -48,6 +54,11 @@ export function CommentForm({ className="min-h-[80px] w-full rounded border px-3 py-2" required /> + {error && (

{error} @@ -55,7 +66,7 @@ export function CommentForm({ )} +

+ setInput(e.target.value)} + placeholder="メッセージを入力(@email でメンション)" + className="flex-1 rounded border px-3 py-2" + data-testid="chat-input" + /> + +
{error && (

diff --git a/components/files/AttachmentList.tsx b/components/files/AttachmentList.tsx new file mode 100644 index 0000000..e68d2b1 --- /dev/null +++ b/components/files/AttachmentList.tsx @@ -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(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 ( + <> +

    + {attachments.map((a) => { + const url = `/api/files/${a.fileId}/download`; + const isImage = a.mimeType.startsWith('image/'); + return ( +
  • + {isImage ? ( + + ) : ( + + 📎 + + {a.originalName} + + + )} +
  • + ); + })} +
+ + {lightbox && ( +
setLightbox(null)} + data-testid="attachment-lightbox" + > + + {lightbox.originalName} e.stopPropagation()} + /> +
+ )} + + ); +} diff --git a/components/files/AttachmentPicker.tsx b/components/files/AttachmentPicker.tsx new file mode 100644 index 0000000..608bce7 --- /dev/null +++ b/components/files/AttachmentPicker.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const inputRef = useRef(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) { + 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 ( +
+ + {loading && ( +

+ アップロード中... +

+ )} + {error && ( +

+ {error} +

+ )} + {files.length > 0 && ( +
    + {files.map((f) => { + const isImage = f.mimeType.startsWith('image/'); + const url = `/api/files/${f.fileId}/download`; + return ( +
  • + {isImage ? ( + {f.originalName} + ) : ( + + + {f.originalName} + + + )} + +
  • + ); + })} +
+ )} +
+ ); +}); diff --git a/docs/repository-structure.md b/docs/repository-structure.md index 93e5e92..1c431a9 100644 --- a/docs/repository-structure.md +++ b/docs/repository-structure.md @@ -181,6 +181,7 @@ repositories/ ├── ChatRepository.ts ├── TodoRepository.ts ├── FileRepository.ts +├── AttachmentRepository.ts ├── CalendarRepository.ts ├── MeetingRepository.ts ├── ProjectNoteRepository.ts @@ -208,6 +209,7 @@ services/ ├── MeetingService.ts ├── ScheduleService.ts ├── FileStorageService.ts +├── AttachmentService.ts ├── BackupService.ts ├── TodoService.ts ├── BoardService.ts @@ -234,7 +236,7 @@ components/ ├── board/ # ThreadList, ThreadForm, CommentList ├── chat/ # ChatWindow, MessageInput, MessageList ├── todo/ # KanbanBoard, KanbanColumn, TodoCard -├── files/ # FileList, Uploader, Lightbox +├── files/ # FileList, Uploader, Lightbox, AttachmentList, AttachmentPicker ├── calendar/ # CalendarView, MonthView, WeekView, DayView, EventDetailDialog, CalendarEventForm ├── meetings/ # MeetingForm, ConflictWarning ├── notes/ # NoteEditor, MarkdownPreview diff --git a/lib/api/services.ts b/lib/api/services.ts index ba274ce..6c38e70 100644 --- a/lib/api/services.ts +++ b/lib/api/services.ts @@ -18,6 +18,8 @@ import { TodoService } from '@/services/TodoService'; import { TodoRepository } from '@/repositories/TodoRepository'; import { FileStorageService } from '@/services/FileStorageService'; import { FileRepository } from '@/repositories/FileRepository'; +import { AttachmentService } from '@/services/AttachmentService'; +import { AttachmentRepository } from '@/repositories/AttachmentRepository'; import { ScheduleService } from '@/services/ScheduleService'; import { CalendarRepository } from '@/repositories/CalendarRepository'; import { MilestoneRepository } from '@/repositories/MilestoneRepository'; @@ -51,11 +53,18 @@ export function createActivityLogService(): ActivityLogService { export function createBoardService(): BoardService { const db = getDb(); + const memberRepo = new ProjectMemberRepository(db); return new BoardService( new BoardRepository(db), - new ProjectMemberRepository(db), + memberRepo, new NotificationService(new NotificationRepository(db)), - new ActivityLogService(new ActivityLogRepository(db)) + new ActivityLogService(new ActivityLogRepository(db)), + new AttachmentService( + new AttachmentRepository(db), + new FileRepository(db), + memberRepo + ), + db ); } @@ -71,12 +80,19 @@ export function createNoteService(): NoteService { export function createChatService(): ChatService { const db = getDb(); + const memberRepo = new ProjectMemberRepository(db); return new ChatService( new ChatRepository(db), - new ProjectMemberRepository(db), + memberRepo, new UserRepository(db), new NotificationService(new NotificationRepository(db)), - getSseHub() + getSseHub(), + new AttachmentService( + new AttachmentRepository(db), + new FileRepository(db), + memberRepo + ), + db ); } diff --git a/lib/db/migrations/002_attachments.sql b/lib/db/migrations/002_attachments.sql new file mode 100644 index 0000000..e95b0e1 --- /dev/null +++ b/lib/db/migrations/002_attachments.sql @@ -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'; diff --git a/lib/types/index.ts b/lib/types/index.ts index 9266a22..1f8ae70 100644 --- a/lib/types/index.ts +++ b/lib/types/index.ts @@ -141,10 +141,47 @@ export interface FileAsset { mimeType: string; size: number; path: string; + source: FileAssetSource; createdAt: string; deletedAt: string | null; } +/** ファイルの来源。library=Files一覧公開、attachment=添付専用(一覧に出さない)。 */ +export type FileAssetSource = 'library' | 'attachment'; + +/** 添付ファイルの関連付け対象。 */ +export type AttachmentTargetType = + | 'chat_message' + | 'board_thread' + | 'board_comment'; + +/** attachments エンティティ。 */ +export interface Attachment { + id: number; + projectId: number; + fileId: number; + targetType: AttachmentTargetType; + targetId: number; + createdAt: string; + deletedAt: string | null; +} + +/** UI表示用の添付ファイルビュー(ダウンロードURLは fileId から組み立てる)。 */ +export interface AttachmentView { + id: number; + fileId: number; + targetType: AttachmentTargetType; + targetId: number; + originalName: string; + mimeType: string; + size: number; +} + +/** チャットメッセージ + 添付ファイル。 */ +export interface ChatMessageWithAttachments extends ChatMessage { + attachments: AttachmentView[]; +} + export interface ProjectNote { id: number; projectId: number; @@ -281,7 +318,10 @@ export type NotificationEvent = export type SseEvent = | { type: 'chat.message.created'; - data: { projectId: number; message: ChatMessage }; + data: { + projectId: number; + message: ChatMessageWithAttachments; + }; } | { type: 'chat.message.updated'; diff --git a/repositories/AttachmentRepository.ts b/repositories/AttachmentRepository.ts new file mode 100644 index 0000000..f9e7694 --- /dev/null +++ b/repositories/AttachmentRepository.ts @@ -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( + '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( + `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( + `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; + } +} diff --git a/repositories/FileRepository.ts b/repositories/FileRepository.ts index a5b50b6..03120e5 100644 --- a/repositories/FileRepository.ts +++ b/repositories/FileRepository.ts @@ -1,5 +1,5 @@ import type { SqliteDatabase } from '@/lib/db/sqlite'; -import type { FileAsset } from '@/lib/types'; +import type { FileAsset, FileAssetSource } from '@/lib/types'; const DEFAULT_PAGE_SIZE = 20; @@ -17,6 +17,7 @@ interface FileAssetRow { mime_type: string; size: number; path: string; + source: string; created_at: string; deleted_at: string | null; } @@ -31,6 +32,7 @@ function mapFile(row: FileAssetRow): FileAsset { mimeType: row.mime_type, size: row.size, path: row.path, + source: row.source as FileAssetSource, createdAt: row.created_at, deletedAt: row.deleted_at, }; @@ -44,6 +46,7 @@ export interface CreateFileInput { mimeType: string; size: number; path: string; + source?: FileAssetSource; } export class FileRepository { @@ -55,15 +58,17 @@ export class FileRepository { pageSize: number = DEFAULT_PAGE_SIZE ): Paginated { const offset = (page - 1) * pageSize; + // 添付専用ファイル(source='attachment')はFiles一覧に出さない const items = this.db.query( `SELECT * FROM file_assets - WHERE project_id = @projectId AND deleted_at IS NULL + WHERE project_id = @projectId AND deleted_at IS NULL AND source = 'library' ORDER BY created_at DESC, id DESC LIMIT @pageSize OFFSET @offset`, { projectId, pageSize, offset } ); const total = this.db.get<{ count: number }>( - 'SELECT COUNT(*) AS count FROM file_assets WHERE project_id = @projectId AND deleted_at IS NULL', + `SELECT COUNT(*) AS count FROM file_assets + WHERE project_id = @projectId AND deleted_at IS NULL AND source = 'library'`, { projectId } ); return { items: items.map(mapFile), total: total?.count ?? 0 }; @@ -79,9 +84,10 @@ export class FileRepository { create(input: CreateFileInput): FileAsset { const now = new Date().toISOString(); + const source = input.source ?? 'library'; const result = this.db.execute( - `INSERT INTO file_assets (project_id, uploader_id, filename, original_name, mime_type, size, path, created_at, deleted_at) - VALUES (@projectId, @uploaderId, @filename, @originalName, @mimeType, @size, @path, @createdAt, NULL)`, + `INSERT INTO file_assets (project_id, uploader_id, filename, original_name, mime_type, size, path, source, created_at, deleted_at) + VALUES (@projectId, @uploaderId, @filename, @originalName, @mimeType, @size, @path, @source, @createdAt, NULL)`, { projectId: input.projectId, uploaderId: input.uploaderId, @@ -90,6 +96,7 @@ export class FileRepository { mimeType: input.mimeType, size: input.size, path: input.path, + source, createdAt: now, } ); diff --git a/services/AttachmentService.ts b/services/AttachmentService.ts new file mode 100644 index 0000000..098f1be --- /dev/null +++ b/services/AttachmentService.ts @@ -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('プロジェクトに参加していません'); + } + } +} diff --git a/services/BoardService.ts b/services/BoardService.ts index f73b11c..98254d1 100644 --- a/services/BoardService.ts +++ b/services/BoardService.ts @@ -6,8 +6,15 @@ import type { Paginated } from '@/repositories/BoardRepository'; import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository'; import { NotificationService } from '@/services/NotificationService'; import { ActivityLogService } from '@/services/ActivityLogService'; +import { AttachmentService } from '@/services/AttachmentService'; +import type { SqliteDatabase } from '@/lib/db/sqlite'; import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors'; -import type { BoardCategory, BoardComment, BoardThread } from '@/lib/types'; +import type { + AttachmentView, + BoardCategory, + BoardComment, + BoardThread, +} from '@/lib/types'; const VALID_CATEGORIES: BoardCategory[] = [ 'notice', @@ -23,6 +30,7 @@ export interface CreateThreadInput { title: string; bodyMd: string; category?: BoardCategory; + fileIds?: number[]; } export interface UpdateThreadInput { @@ -36,14 +44,17 @@ export interface UpdateThreadInput { /** * 掲示板の業務ロジックを担うService。 * 権限チェック(プロジェクト参加者のみ)、スレッド/コメントCRUD、 - * コメント追加時の通知(board_commented)とアクティビティログ記録を行う。 + * コメント追加時の通知(board_commented)、アクティビティログ記録、 + * 添付ファイル紐付けを行う。 */ export class BoardService { constructor( private readonly boardRepository: BoardRepository, private readonly projectMemberRepository: ProjectMemberRepository, private readonly notificationService: NotificationService, - private readonly activityLogService: ActivityLogService + private readonly activityLogService: ActivityLogService, + private readonly attachmentService: AttachmentService, + private readonly db: SqliteDatabase ) {} listThreads( @@ -69,12 +80,24 @@ export class BoardService { ): BoardThread { this.requireMember(projectId, actorId); this.validateThreadInput(input.title, input.bodyMd, input.category); - const thread = this.boardRepository.createThread({ - projectId, - title: input.title, - bodyMd: input.bodyMd, - authorId: actorId, - category: input.category ?? null, + const thread = this.db.transaction(() => { + const t = this.boardRepository.createThread({ + projectId, + title: input.title, + bodyMd: input.bodyMd, + authorId: actorId, + category: input.category ?? null, + }); + if (input.fileIds && input.fileIds.length > 0) { + this.attachmentService.attach( + actorId, + projectId, + 'board_thread', + t.id, + input.fileIds + ); + } + return t; }); this.activityLogService.logActivity({ projectId, @@ -118,7 +141,10 @@ export class BoardService { deleteThread(actorId: number, threadId: number): void { const thread = this.getThread(actorId, threadId); this.requireAuthorOrAdmin(thread.projectId, actorId, thread.authorId); - this.boardRepository.deleteThread(threadId); + this.db.transaction(() => { + this.attachmentService.detach('board_thread', threadId); + this.boardRepository.deleteThread(threadId); + }); } listComments( @@ -133,16 +159,29 @@ export class BoardService { createComment( actorId: number, threadId: number, - bodyMd: string + bodyMd: string, + fileIds?: number[] ): BoardComment { const thread = this.getThread(actorId, threadId); if (!bodyMd.trim()) { throw new ValidationError('コメント本文を入力してください', 'bodyMd'); } - const comment = this.boardRepository.createComment({ - threadId: thread.id, - authorId: actorId, - bodyMd, + const comment = this.db.transaction(() => { + const c = this.boardRepository.createComment({ + threadId: thread.id, + authorId: actorId, + bodyMd, + }); + if (fileIds && fileIds.length > 0) { + this.attachmentService.attach( + actorId, + thread.projectId, + 'board_comment', + c.id, + fileIds + ); + } + return c; }); // スレッド投稿者へ通知(自分自身へのコメントは通知しない) if (thread.authorId !== actorId) { @@ -188,7 +227,29 @@ export class BoardService { if (!comment) throw new NotFoundError('BoardComment', commentId); const thread = this.getThread(actorId, comment.threadId); this.requireAuthorOrAdmin(thread.projectId, actorId, comment.authorId); - this.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 { diff --git a/services/ChatService.ts b/services/ChatService.ts index 28045a6..85d7cc9 100644 --- a/services/ChatService.ts +++ b/services/ChatService.ts @@ -6,15 +6,21 @@ import type { Paginated } from '@/repositories/ChatRepository'; import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository'; import { UserRepository } from '@/repositories/UserRepository'; import { NotificationService } from '@/services/NotificationService'; +import { AttachmentService } from '@/services/AttachmentService'; import { SseHub } from '@/lib/sse/hub'; +import type { SqliteDatabase } from '@/lib/db/sqlite'; import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors'; -import type { ChatMessage } from '@/lib/types'; +import type { + AttachmentView, + ChatMessage, + ChatMessageWithAttachments, +} from '@/lib/types'; const MENTION_RE = /@([^\s@]+@[^\s@]+\.[^\s@]+)/g; /** * チャットの業務ロジックを担うService。 - * 権限チェック、メッセージ送信/編集/削除、メンション通知、SSE配信を行う。 + * 権限チェック、メッセージ送信/編集/削除、メンション通知、添付ファイル紐付け、SSE配信を行う。 */ export class ChatService { constructor( @@ -22,27 +28,56 @@ export class ChatService { private readonly projectMemberRepository: ProjectMemberRepository, private readonly userRepository: UserRepository, private readonly notificationService: NotificationService, - private readonly sseHub: SseHub + private readonly sseHub: SseHub, + private readonly attachmentService: AttachmentService, + private readonly db: SqliteDatabase ) {} getHistory( actorId: number, projectId: number, opts: ListMessagesOptions = {} - ): Paginated { + ): Paginated { this.requireMember(projectId, actorId); - return this.chatRepository.findMessages(projectId, opts); + const page = this.chatRepository.findMessages(projectId, opts); + const byTarget = this.indexAttachments( + 'chat_message', + page.items.map((m) => m.id) + ); + return { + items: page.items.map((m) => this.withAttachments(m, byTarget.get(m.id))), + total: page.total, + }; } - sendMessage(actorId: number, projectId: number, body: string): ChatMessage { + sendMessage( + actorId: number, + projectId: number, + body: string, + fileIds?: number[] + ): ChatMessageWithAttachments { this.requireMember(projectId, actorId); if (!body.trim()) { throw new ValidationError('メッセージを入力してください', 'body'); } - const message = this.chatRepository.create({ - projectId, - authorId: actorId, - body, + // メッセージ本体と添付紐付けはトランザクション内で一貫保存する + const { message, attachments } = this.db.transaction(() => { + const msg = this.chatRepository.create({ + projectId, + authorId: actorId, + body, + }); + const atts = + fileIds && fileIds.length > 0 + ? this.attachmentService.attach( + actorId, + projectId, + 'chat_message', + msg.id, + fileIds + ) + : []; + return { message: msg, attachments: atts }; }); // メンション検出(@email) → 通知 @@ -64,11 +99,12 @@ export class ChatService { } } + const messageWithAttachments = this.withAttachments(message, attachments); this.sseHub.broadcast(projectId, { type: 'chat.message.created', - data: { projectId, message }, + data: { projectId, message: messageWithAttachments }, }); - return message; + return messageWithAttachments; } editMessage(actorId: number, messageId: number, body: string): ChatMessage { @@ -101,13 +137,38 @@ export class ChatService { if (message.authorId !== actorId && role !== 'admin') { throw new ForbiddenError('投稿者または管理者のみ削除できます'); } - this.chatRepository.delete(messageId); + this.db.transaction(() => { + this.chatRepository.delete(messageId); + this.attachmentService.detach('chat_message', messageId); + }); this.sseHub.broadcast(message.projectId, { type: 'chat.message.deleted', data: { projectId: message.projectId, id: messageId }, }); } + private withAttachments( + message: ChatMessage, + attachments: AttachmentView[] | undefined + ): ChatMessageWithAttachments { + return { ...message, attachments: attachments ?? [] }; + } + + private indexAttachments( + targetType: 'chat_message', + targetIds: number[] + ): Map { + const map = new Map(); + if (targetIds.length === 0) return map; + const views = this.attachmentService.listViewsBatch(targetType, targetIds); + for (const v of views) { + const list = map.get(v.targetId) ?? []; + list.push(v); + map.set(v.targetId, list); + } + return map; + } + private requireMember(projectId: number, actorId: number): void { if (!this.projectMemberRepository.isMember(projectId, actorId)) { throw new ForbiddenError('プロジェクトに参加していません'); diff --git a/services/FileStorageService.ts b/services/FileStorageService.ts index 3c25fac..f54ad91 100644 --- a/services/FileStorageService.ts +++ b/services/FileStorageService.ts @@ -7,7 +7,7 @@ import { NotificationService } from '@/services/NotificationService'; import { ActivityLogService } from '@/services/ActivityLogService'; import { SseHub } from '@/lib/sse/hub'; import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors'; -import type { FileAsset } from '@/lib/types'; +import type { FileAsset, FileAssetSource } from '@/lib/types'; const ALLOWED_MIME_PREFIXES = [ 'image/', @@ -62,33 +62,7 @@ export class FileStorageService { projectId: number, input: UploadFileInput ): FileAsset { - this.requireMember(projectId, actorId); - if (!input.data || input.data.length === 0) { - throw new ValidationError('ファイルが空です', 'file'); - } - if (!this.isAllowedMime(input.mimeType)) { - throw new ValidationError('許可されていないファイル形式です', 'mimeType'); - } - - const ext = - this.sanitizeExt(input.originalName) ?? - EXT_BY_MIME[input.mimeType] ?? - 'bin'; - const filename = `${crypto.randomUUID()}.${ext}`; - const dir = path.join(this.uploadsDir, String(projectId)); - fs.mkdirSync(dir, { recursive: true }); - const filePath = path.join(dir, filename); - fs.writeFileSync(filePath, input.data); - - const fileAsset = this.fileRepository.create({ - projectId, - uploaderId: actorId, - filename, - originalName: this.sanitizeName(input.originalName) || 'file', - mimeType: input.mimeType, - size: input.data.length, - path: filePath, - }); + const fileAsset = this.persistFile(actorId, projectId, input, 'library'); const memberIds = this.projectMemberRepository .findByProject(projectId) @@ -117,6 +91,58 @@ export class FileStorageService { return fileAsset; } + /** + * チャット/掲示板の添付ファイル用アップロード。 + * file_shared 通知・file.uploaded SSE・file_uploaded アクティビティを行わず、 + * source='attachment' で保存する(Files一覧には出さない)。 + */ + uploadForAttachment( + actorId: number, + projectId: number, + input: UploadFileInput + ): FileAsset { + return this.persistFile(actorId, projectId, input, 'attachment'); + } + + /** + * 共通のファイル保存処理。権限チェック・MIMEチェック・FS保存・file_assets登録を行う。 + */ + private persistFile( + actorId: number, + projectId: number, + input: UploadFileInput, + source: FileAssetSource + ): FileAsset { + this.requireMember(projectId, actorId); + if (!input.data || input.data.length === 0) { + throw new ValidationError('ファイルが空です', 'file'); + } + if (!this.isAllowedMime(input.mimeType)) { + throw new ValidationError('許可されていないファイル形式です', 'mimeType'); + } + + const ext = + this.sanitizeExt(input.originalName) ?? + EXT_BY_MIME[input.mimeType] ?? + 'bin'; + const filename = `${crypto.randomUUID()}.${ext}`; + const dir = path.join(this.uploadsDir, String(projectId)); + fs.mkdirSync(dir, { recursive: true }); + const filePath = path.join(dir, filename); + fs.writeFileSync(filePath, input.data); + + return this.fileRepository.create({ + projectId, + uploaderId: actorId, + filename, + originalName: this.sanitizeName(input.originalName) || 'file', + mimeType: input.mimeType, + size: input.data.length, + path: filePath, + source, + }); + } + listFiles(actorId: number, projectId: number, page: number = 1) { this.requireMember(projectId, actorId); return this.fileRepository.findFilesByProject(projectId, page); diff --git a/tests/e2e/board.spec.ts b/tests/e2e/board.spec.ts index a01f3b1..c71446c 100644 --- a/tests/e2e/board.spec.ts +++ b/tests/e2e/board.spec.ts @@ -77,4 +77,44 @@ test.describe('board', () => { page.getByRole('link', { name: new RegExp(title) }) ).toHaveCount(0); }); + + test('thread and comment can carry file attachments', async ({ page }) => { + const projectId = await setupOwner(page); + + // 添付ファイル(1x1 PNG)をアップロード + const png = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', + 'base64' + ); + const upRes = await page.request.post( + `/api/projects/${projectId}/attachments`, + { + multipart: { + file: { name: 'pic.png', mimeType: 'image/png', buffer: png }, + }, + } + ); + expect(upRes.ok()).toBeTruthy(); + const { file } = (await upRes.json()) as { file: { id: number } }; + + // ファイル付きスレッド作成 + const title = unique('AttachThread'); + const res = await page.request.post( + `/api/projects/${projectId}/board/threads`, + { data: { title, bodyMd: 'body', fileIds: [file.id] } } + ); + expect(res.ok()).toBeTruthy(); + const { thread } = (await res.json()) as { thread: { id: number } }; + + // ファイル付きコメント + const cRes = await page.request.post( + `/api/projects/${projectId}/board/threads/${thread.id}/comments`, + { data: { bodyMd: 'with file', fileIds: [file.id] } } + ); + expect(cRes.ok()).toBeTruthy(); + + // 詳細ページでスレッド・コメントの添付が表示される + await page.goto(`/projects/${projectId}/board/${thread.id}`); + await expect(page.getByTestId('attachment-list').first()).toBeVisible(); + }); }); diff --git a/tests/e2e/chat-sse.spec.ts b/tests/e2e/chat-sse.spec.ts index c6464e9..ecc0f58 100644 --- a/tests/e2e/chat-sse.spec.ts +++ b/tests/e2e/chat-sse.spec.ts @@ -65,4 +65,50 @@ test.describe('chat (SSE realtime)', () => { await ownerContext.close(); await memberContext.close(); }); + + test('a message can carry file attachments rendered in the chat', async ({ + page, + }) => { + const ownerEmail = unique('owner') + '@example.com'; + await registerAndLogin(page, ownerEmail, 'Owner'); + await page.getByLabel('プロジェクト名').fill(unique('Proj')); + await page.getByRole('button', { name: '新規プロジェクト' }).click(); + await expect(page).toHaveURL(/\/projects\/\d+$/); + const projectId = Number(page.url().match(/\/projects\/(\d+)/)![1]); + + // 添付ファイル(1x1 PNG)をアップロード + const png = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', + 'base64' + ); + const upRes = await page.request.post( + `/api/projects/${projectId}/attachments`, + { + multipart: { + file: { name: 'pic.png', mimeType: 'image/png', buffer: png }, + }, + } + ); + expect(upRes.ok()).toBeTruthy(); + const { file } = (await upRes.json()) as { file: { id: number } }; + + // ファイル付きメッセージ送信 + const text = unique('with-attach'); + const sendRes = await page.request.post( + `/api/projects/${projectId}/chat/messages`, + { data: { body: text, fileIds: [file.id] } } + ); + expect(sendRes.ok()).toBeTruthy(); + const { message } = (await sendRes.json()) as { + message: { attachments: { fileId: number }[] }; + }; + expect(message.attachments).toHaveLength(1); + + // チャット画面に添付画像が表示される + await page.goto(`/projects/${projectId}/chat`); + await expect(page.getByTestId('chat-form')).toBeVisible(); + await expect(page.getByTestId('attachment-list')).toBeVisible({ + timeout: 10_000, + }); + }); }); diff --git a/tests/integration/chat-sse-broadcast.test.ts b/tests/integration/chat-sse-broadcast.test.ts index 46863b5..1486724 100644 --- a/tests/integration/chat-sse-broadcast.test.ts +++ b/tests/integration/chat-sse-broadcast.test.ts @@ -5,8 +5,11 @@ import { UserRepository } from '@/repositories/UserRepository'; import { ProjectRepository } from '@/repositories/ProjectRepository'; import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository'; import { ChatRepository } from '@/repositories/ChatRepository'; +import { FileRepository } from '@/repositories/FileRepository'; +import { AttachmentRepository } from '@/repositories/AttachmentRepository'; import { NotificationRepository } from '@/repositories/NotificationRepository'; import { NotificationService } from '@/services/NotificationService'; +import { AttachmentService } from '@/services/AttachmentService'; import { ChatService } from '@/services/ChatService'; import { SseHub, type SseClient } from '@/lib/sse/hub'; @@ -37,7 +40,13 @@ describe('chat → SSE broadcast (integration)', () => { members, users, new NotificationService(new NotificationRepository(db)), - hub + hub, + new AttachmentService( + new AttachmentRepository(db), + new FileRepository(db), + members + ), + db ); }); diff --git a/tests/unit/lib/db/migrator.test.ts b/tests/unit/lib/db/migrator.test.ts index c25f102..36c1298 100644 --- a/tests/unit/lib/db/migrator.test.ts +++ b/tests/unit/lib/db/migrator.test.ts @@ -158,6 +158,7 @@ describe('Migrator', () => { const expectedTables = [ 'activity_logs', + 'attachments', 'board_comments', 'board_threads', 'calendar_events', @@ -180,6 +181,7 @@ describe('Migrator', () => { } expect(migrator.getAppliedMigrations().map((m) => m.filename)).toEqual([ '001_initial.sql', + '002_attachments.sql', ]); }); }); diff --git a/tests/unit/lib/sse/hub.test.ts b/tests/unit/lib/sse/hub.test.ts index 254573a..dcb97dd 100644 --- a/tests/unit/lib/sse/hub.test.ts +++ b/tests/unit/lib/sse/hub.test.ts @@ -44,7 +44,10 @@ describe('SseHub', () => { hub.broadcast(1, { type: 'chat.message.created', - data: { projectId: 1, message: { id: 1 } as never }, + data: { + projectId: 1, + message: { id: 1, attachments: [] } as never, + }, }); expect(a1.received).toHaveLength(1); diff --git a/tests/unit/repositories/AttachmentRepository.test.ts b/tests/unit/repositories/AttachmentRepository.test.ts new file mode 100644 index 0000000..f7062e7 --- /dev/null +++ b/tests/unit/repositories/AttachmentRepository.test.ts @@ -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); + }); +}); diff --git a/tests/unit/repositories/FileRepository.test.ts b/tests/unit/repositories/FileRepository.test.ts index 165ebdc..25c4090 100644 --- a/tests/unit/repositories/FileRepository.test.ts +++ b/tests/unit/repositories/FileRepository.test.ts @@ -85,4 +85,37 @@ describe('FileRepository', () => { expect(repo.findFilesByProject(projectId, 1, 2).items).toHaveLength(2); expect(repo.findFilesByProject(projectId, 1, 2).total).toBe(5); }); + + it('excludes attachment-source files from the library list but findFileById still returns them', () => { + createFile('library-file'); + repo.create({ + projectId, + uploaderId: userId, + filename: 'att.bin', + originalName: 'attachment-file', + mimeType: 'image/png', + size: 1, + path: '/tmp/att.bin', + source: 'attachment', + }); + const list = repo.findFilesByProject(projectId); + expect(list.total).toBe(1); + expect(list.items[0].originalName).toBe('library-file'); + // 添付ファイルも findFileById では取得可能(ダウンロード用) + const att = repo.findFilesByProject(projectId, 1, 100).items; + expect(att).toHaveLength(1); + }); + + it('defaults source to library when not specified', () => { + const f = repo.create({ + projectId, + uploaderId: userId, + filename: 'd.bin', + originalName: 'd', + mimeType: 'text/plain', + size: 1, + path: '/tmp/d.bin', + }); + expect(f.source).toBe('library'); + }); }); diff --git a/tests/unit/services/AttachmentService.test.ts b/tests/unit/services/AttachmentService.test.ts new file mode 100644 index 0000000..d77978c --- /dev/null +++ b/tests/unit/services/AttachmentService.test.ts @@ -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); + }); +}); diff --git a/tests/unit/services/BoardService.test.ts b/tests/unit/services/BoardService.test.ts index a531497..41bc7c5 100644 --- a/tests/unit/services/BoardService.test.ts +++ b/tests/unit/services/BoardService.test.ts @@ -5,19 +5,29 @@ import { UserRepository } from '@/repositories/UserRepository'; import { ProjectRepository } from '@/repositories/ProjectRepository'; import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository'; import { BoardRepository } from '@/repositories/BoardRepository'; +import { FileRepository } from '@/repositories/FileRepository'; +import { AttachmentRepository } from '@/repositories/AttachmentRepository'; import { NotificationRepository } from '@/repositories/NotificationRepository'; import { ActivityLogRepository } from '@/repositories/ActivityLogRepository'; import { NotificationService } from '@/services/NotificationService'; import { ActivityLogService } from '@/services/ActivityLogService'; +import { AttachmentService } from '@/services/AttachmentService'; import { BoardService } from '@/services/BoardService'; import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors'; function makeService(db: SqliteDatabase) { + const members = new ProjectMemberRepository(db); return new BoardService( new BoardRepository(db), - new ProjectMemberRepository(db), + members, new NotificationService(new NotificationRepository(db)), - new ActivityLogService(new ActivityLogRepository(db)) + new ActivityLogService(new ActivityLogRepository(db)), + new AttachmentService( + new AttachmentRepository(db), + new FileRepository(db), + members + ), + db ); } @@ -167,4 +177,76 @@ describe('BoardService', () => { it('throws NotFoundError for a non-existent thread', () => { expect(() => service.getThread(memberId, 99999)).toThrow(NotFoundError); }); + + it('createThread with fileIds attaches files to the thread', () => { + const fileRepo = new FileRepository(db); + const f1 = fileRepo.create({ + projectId, + uploaderId: memberId, + filename: 'a.png', + originalName: 'a.png', + mimeType: 'image/png', + size: 1, + path: '/tmp/a.png', + source: 'attachment', + }); + const thread = service.createThread(memberId, projectId, { + title: 'T', + bodyMd: 'b', + fileIds: [f1.id], + }); + const atts = service.getAttachments(memberId, thread.id, []); + expect(atts.thread).toHaveLength(1); + expect(atts.thread[0].fileId).toBe(f1.id); + }); + + it('createComment with fileIds attaches files to the comment', () => { + const fileRepo = new FileRepository(db); + const f1 = fileRepo.create({ + projectId, + uploaderId: memberId, + filename: 'c.png', + originalName: 'c.png', + mimeType: 'image/png', + size: 1, + path: '/tmp/c.png', + source: 'attachment', + }); + const thread = service.createThread(authorId, projectId, { + title: 'T', + bodyMd: 'b', + }); + const comment = service.createComment(memberId, thread.id, 'c', [f1.id]); + const atts = service.getAttachments(memberId, thread.id, [comment.id]); + expect(atts.comments).toHaveLength(1); + expect(atts.comments[0].targetId).toBe(comment.id); + }); + + it('deleteThread and deleteComment detach attachments', () => { + const fileRepo = new FileRepository(db); + const f1 = fileRepo.create({ + projectId, + uploaderId: memberId, + filename: 'a.png', + originalName: 'a.png', + mimeType: 'image/png', + size: 1, + path: '/tmp/a.png', + source: 'attachment', + }); + const thread = service.createThread(memberId, projectId, { + title: 'T', + bodyMd: 'b', + fileIds: [f1.id], + }); + // 削除前は添付あり + expect( + new AttachmentRepository(db).findByTarget('board_thread', thread.id) + ).toHaveLength(1); + service.deleteThread(memberId, thread.id); + // 削除後は論理削除されて取得できない + expect( + new AttachmentRepository(db).findByTarget('board_thread', thread.id) + ).toHaveLength(0); + }); }); diff --git a/tests/unit/services/ChatService.test.ts b/tests/unit/services/ChatService.test.ts index f6987b7..1f03638 100644 --- a/tests/unit/services/ChatService.test.ts +++ b/tests/unit/services/ChatService.test.ts @@ -5,8 +5,11 @@ import { UserRepository } from '@/repositories/UserRepository'; import { ProjectRepository } from '@/repositories/ProjectRepository'; import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository'; import { ChatRepository } from '@/repositories/ChatRepository'; +import { FileRepository } from '@/repositories/FileRepository'; +import { AttachmentRepository } from '@/repositories/AttachmentRepository'; import { NotificationRepository } from '@/repositories/NotificationRepository'; import { NotificationService } from '@/services/NotificationService'; +import { AttachmentService } from '@/services/AttachmentService'; import { ChatService } from '@/services/ChatService'; import { SseHub, type SseClient } from '@/lib/sse/hub'; import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors'; @@ -56,7 +59,13 @@ describe('ChatService', () => { members, users, new NotificationService(new NotificationRepository(db)), - hub + hub, + new AttachmentService( + new AttachmentRepository(db), + new FileRepository(db), + members + ), + db ); }); @@ -135,4 +144,102 @@ describe('ChatService', () => { it('throws NotFoundError for a non-existent message', () => { expect(() => service.deleteMessage(authorId, 99999)).toThrow(NotFoundError); }); + + it('sendMessage with fileIds attaches files and broadcasts them', () => { + const client = makeClient(); + hub.addClient(projectId, client); + const fileRepo = new FileRepository(db); + const f1 = fileRepo.create({ + projectId, + uploaderId: authorId, + filename: 'a.png', + originalName: 'a.png', + mimeType: 'image/png', + size: 1, + path: '/tmp/a.png', + source: 'attachment', + }); + const f2 = fileRepo.create({ + projectId, + uploaderId: authorId, + filename: 'b.png', + originalName: 'b.png', + mimeType: 'image/png', + size: 1, + path: '/tmp/b.png', + source: 'attachment', + }); + + const message = service.sendMessage(authorId, projectId, 'see this', [ + f1.id, + f2.id, + ]); + + expect(message.attachments).toHaveLength(2); + expect(client.received[0]).toContain('chat.message.created'); + expect(client.received[0]).toContain('"attachments"'); + }); + + it('getHistory returns messages with their attachments', () => { + const fileRepo = new FileRepository(db); + const f1 = fileRepo.create({ + projectId, + uploaderId: authorId, + filename: 'a.png', + originalName: 'a.png', + mimeType: 'image/png', + size: 1, + path: '/tmp/a.png', + source: 'attachment', + }); + service.sendMessage(authorId, projectId, 'with file', [f1.id]); + service.sendMessage(authorId, projectId, 'no file'); + + const history = service.getHistory(authorId, projectId); + const withFile = history.items.find((m) => m.body === 'with file'); + const noFile = history.items.find((m) => m.body === 'no file'); + expect(withFile?.attachments).toHaveLength(1); + expect(noFile?.attachments).toEqual([]); + }); + + it('deleteMessage soft-deletes its attachments', () => { + const fileRepo = new FileRepository(db); + const f1 = fileRepo.create({ + projectId, + uploaderId: authorId, + filename: 'a.png', + originalName: 'a.png', + mimeType: 'image/png', + size: 1, + path: '/tmp/a.png', + source: 'attachment', + }); + const m = service.sendMessage(authorId, projectId, 'x', [f1.id]); + expect(m.attachments).toHaveLength(1); + service.deleteMessage(authorId, m.id); + const history = service.getHistory(authorId, projectId); + // 削除されたメッセージは履歴に無く、添付も残らない + expect(history.items.find((msg) => msg.id === m.id)).toBeUndefined(); + }); + + it('editMessage preserves attachments in history (regression)', () => { + const fileRepo = new FileRepository(db); + const f1 = fileRepo.create({ + projectId, + uploaderId: authorId, + filename: 'a.png', + originalName: 'a.png', + mimeType: 'image/png', + size: 1, + path: '/tmp/a.png', + source: 'attachment', + }); + const m = service.sendMessage(authorId, projectId, 'orig', [f1.id]); + service.editMessage(authorId, m.id, 'edited'); + const history = service.getHistory(authorId, projectId); + const edited = history.items.find((msg) => msg.id === m.id); + expect(edited?.body).toBe('edited'); + // 編集後も添付は履歴に残る + expect(edited?.attachments).toHaveLength(1); + }); }); diff --git a/tests/unit/services/FileStorageService.test.ts b/tests/unit/services/FileStorageService.test.ts index e504119..b41792a 100644 --- a/tests/unit/services/FileStorageService.test.ts +++ b/tests/unit/services/FileStorageService.test.ts @@ -157,4 +157,49 @@ describe('FileStorageService', () => { service.delete(authorId, file.id); // authorId is admin expect(() => service.getFileInfo(memberId, file.id)).toThrow(); }); + + it('uploadForAttachment is silent: no notification/SSE/activity, source=attachment', () => { + const file = service.uploadForAttachment(authorId, projectId, { + originalName: 'pic.png', + mimeType: 'image/png', + data: Buffer.from('img'), + }); + expect(file.source).toBe('attachment'); + expect(fs.existsSync(file.path)).toBe(true); + // メンバーへの file_shared 通知は飛ばない + expect(new NotificationRepository(db).countUnreadByUser(memberId)).toBe(0); + // file_uploaded アクティビティは記録されない + expect( + new ActivityLogRepository(db) + .findByProject(projectId) + .items.some((l) => l.action === 'file_uploaded') + ).toBe(false); + }); + + it('uploadForAttachment result is excluded from the library list', () => { + service.uploadForAttachment(authorId, projectId, { + originalName: 'pic.png', + mimeType: 'image/png', + data: Buffer.from('img'), + }); + const list = service.listFiles(authorId, projectId); + expect(list.total).toBe(0); + }); + + it('uploadForAttachment still enforces MIME and membership', () => { + expect(() => + service.uploadForAttachment(authorId, projectId, { + originalName: 'evil.exe', + mimeType: 'application/x-msdownload', + data: Buffer.from('x'), + }) + ).toThrow(ValidationError); + expect(() => + service.uploadForAttachment(outsiderId, projectId, { + originalName: 'x.png', + mimeType: 'image/png', + data: Buffer.from('x'), + }) + ).toThrow(ForbiddenError); + }); });