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

@ -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);