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:
@ -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);
|
||||
|
||||
Reference in New Issue
Block a user