チャット・掲示板(スレッド/コメント)へファイル/画像添付を追加。 - 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(権限チェック済)で配信
78 lines
2.8 KiB
TypeScript
78 lines
2.8 KiB
TypeScript
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('プロジェクトに参加していません');
|
|
}
|
|
}
|
|
}
|