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('プロジェクトに参加していません');
}
}
}