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:
77
services/AttachmentService.ts
Normal file
77
services/AttachmentService.ts
Normal 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('プロジェクトに参加していません');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user