feat(chat,board): file/image attachments on chat, board threads and comments

チャット・掲示板(スレッド/コメント)へファイル/画像添付を追加。

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

View File

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

View File

@ -6,8 +6,15 @@ import type { Paginated } from '@/repositories/BoardRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { NotificationService } from '@/services/NotificationService';
import { ActivityLogService } from '@/services/ActivityLogService';
import { AttachmentService } from '@/services/AttachmentService';
import type { SqliteDatabase } from '@/lib/db/sqlite';
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
import type { BoardCategory, BoardComment, BoardThread } from '@/lib/types';
import type {
AttachmentView,
BoardCategory,
BoardComment,
BoardThread,
} from '@/lib/types';
const VALID_CATEGORIES: BoardCategory[] = [
'notice',
@ -23,6 +30,7 @@ export interface CreateThreadInput {
title: string;
bodyMd: string;
category?: BoardCategory;
fileIds?: number[];
}
export interface UpdateThreadInput {
@ -36,14 +44,17 @@ export interface UpdateThreadInput {
/**
* 掲示板の業務ロジックを担うService。
* 権限チェック(プロジェクト参加者のみ)、スレッド/コメントCRUD、
* コメント追加時の通知(board_commented)アクティビティログ記録を行う。
* コメント追加時の通知(board_commented)アクティビティログ記録
* 添付ファイル紐付けを行う。
*/
export class BoardService {
constructor(
private readonly boardRepository: BoardRepository,
private readonly projectMemberRepository: ProjectMemberRepository,
private readonly notificationService: NotificationService,
private readonly activityLogService: ActivityLogService
private readonly activityLogService: ActivityLogService,
private readonly attachmentService: AttachmentService,
private readonly db: SqliteDatabase
) {}
listThreads(
@ -69,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 {

View File

@ -6,15 +6,21 @@ import type { Paginated } from '@/repositories/ChatRepository';
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
import { UserRepository } from '@/repositories/UserRepository';
import { NotificationService } from '@/services/NotificationService';
import { AttachmentService } from '@/services/AttachmentService';
import { SseHub } from '@/lib/sse/hub';
import type { SqliteDatabase } from '@/lib/db/sqlite';
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
import type { ChatMessage } from '@/lib/types';
import type {
AttachmentView,
ChatMessage,
ChatMessageWithAttachments,
} from '@/lib/types';
const MENTION_RE = /@([^\s@]+@[^\s@]+\.[^\s@]+)/g;
/**
* チャットの業務ロジックを担うService。
* 権限チェック、メッセージ送信/編集/削除、メンション通知、SSE配信を行う。
* 権限チェック、メッセージ送信/編集/削除、メンション通知、添付ファイル紐付け、SSE配信を行う。
*/
export class ChatService {
constructor(
@ -22,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<ChatMessage> {
): Paginated<ChatMessageWithAttachments> {
this.requireMember(projectId, actorId);
return this.chatRepository.findMessages(projectId, opts);
const page = this.chatRepository.findMessages(projectId, opts);
const byTarget = this.indexAttachments(
'chat_message',
page.items.map((m) => m.id)
);
return {
items: page.items.map((m) => this.withAttachments(m, byTarget.get(m.id))),
total: page.total,
};
}
sendMessage(actorId: number, projectId: number, body: string): ChatMessage {
sendMessage(
actorId: number,
projectId: number,
body: string,
fileIds?: number[]
): ChatMessageWithAttachments {
this.requireMember(projectId, actorId);
if (!body.trim()) {
throw new ValidationError('メッセージを入力してください', 'body');
}
const message = this.chatRepository.create({
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<number, AttachmentView[]> {
const map = new Map<number, AttachmentView[]>();
if (targetIds.length === 0) return map;
const views = this.attachmentService.listViewsBatch(targetType, targetIds);
for (const v of views) {
const list = map.get(v.targetId) ?? [];
list.push(v);
map.set(v.targetId, list);
}
return map;
}
private requireMember(projectId: number, actorId: number): void {
if (!this.projectMemberRepository.isMember(projectId, actorId)) {
throw new ForbiddenError('プロジェクトに参加していません');

View File

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