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('プロジェクトに参加していません');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -6,8 +6,15 @@ import type { Paginated } from '@/repositories/BoardRepository';
|
||||
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
|
||||
import { NotificationService } from '@/services/NotificationService';
|
||||
import { ActivityLogService } from '@/services/ActivityLogService';
|
||||
import { AttachmentService } from '@/services/AttachmentService';
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import type { BoardCategory, BoardComment, BoardThread } from '@/lib/types';
|
||||
import type {
|
||||
AttachmentView,
|
||||
BoardCategory,
|
||||
BoardComment,
|
||||
BoardThread,
|
||||
} from '@/lib/types';
|
||||
|
||||
const VALID_CATEGORIES: BoardCategory[] = [
|
||||
'notice',
|
||||
@ -23,6 +30,7 @@ export interface CreateThreadInput {
|
||||
title: string;
|
||||
bodyMd: string;
|
||||
category?: BoardCategory;
|
||||
fileIds?: number[];
|
||||
}
|
||||
|
||||
export interface UpdateThreadInput {
|
||||
@ -36,14 +44,17 @@ export interface UpdateThreadInput {
|
||||
/**
|
||||
* 掲示板の業務ロジックを担うService。
|
||||
* 権限チェック(プロジェクト参加者のみ)、スレッド/コメントCRUD、
|
||||
* コメント追加時の通知(board_commented)とアクティビティログ記録を行う。
|
||||
* コメント追加時の通知(board_commented)、アクティビティログ記録、
|
||||
* 添付ファイル紐付けを行う。
|
||||
*/
|
||||
export class BoardService {
|
||||
constructor(
|
||||
private readonly boardRepository: BoardRepository,
|
||||
private readonly projectMemberRepository: ProjectMemberRepository,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly activityLogService: ActivityLogService
|
||||
private readonly activityLogService: ActivityLogService,
|
||||
private readonly attachmentService: AttachmentService,
|
||||
private readonly db: SqliteDatabase
|
||||
) {}
|
||||
|
||||
listThreads(
|
||||
@ -69,12 +80,24 @@ export class BoardService {
|
||||
): BoardThread {
|
||||
this.requireMember(projectId, actorId);
|
||||
this.validateThreadInput(input.title, input.bodyMd, input.category);
|
||||
const thread = this.boardRepository.createThread({
|
||||
projectId,
|
||||
title: input.title,
|
||||
bodyMd: input.bodyMd,
|
||||
authorId: actorId,
|
||||
category: input.category ?? null,
|
||||
const thread = this.db.transaction(() => {
|
||||
const t = this.boardRepository.createThread({
|
||||
projectId,
|
||||
title: input.title,
|
||||
bodyMd: input.bodyMd,
|
||||
authorId: actorId,
|
||||
category: input.category ?? null,
|
||||
});
|
||||
if (input.fileIds && input.fileIds.length > 0) {
|
||||
this.attachmentService.attach(
|
||||
actorId,
|
||||
projectId,
|
||||
'board_thread',
|
||||
t.id,
|
||||
input.fileIds
|
||||
);
|
||||
}
|
||||
return t;
|
||||
});
|
||||
this.activityLogService.logActivity({
|
||||
projectId,
|
||||
@ -118,7 +141,10 @@ export class BoardService {
|
||||
deleteThread(actorId: number, threadId: number): void {
|
||||
const thread = this.getThread(actorId, threadId);
|
||||
this.requireAuthorOrAdmin(thread.projectId, actorId, thread.authorId);
|
||||
this.boardRepository.deleteThread(threadId);
|
||||
this.db.transaction(() => {
|
||||
this.attachmentService.detach('board_thread', threadId);
|
||||
this.boardRepository.deleteThread(threadId);
|
||||
});
|
||||
}
|
||||
|
||||
listComments(
|
||||
@ -133,16 +159,29 @@ export class BoardService {
|
||||
createComment(
|
||||
actorId: number,
|
||||
threadId: number,
|
||||
bodyMd: string
|
||||
bodyMd: string,
|
||||
fileIds?: number[]
|
||||
): BoardComment {
|
||||
const thread = this.getThread(actorId, threadId);
|
||||
if (!bodyMd.trim()) {
|
||||
throw new ValidationError('コメント本文を入力してください', 'bodyMd');
|
||||
}
|
||||
const comment = this.boardRepository.createComment({
|
||||
threadId: thread.id,
|
||||
authorId: actorId,
|
||||
bodyMd,
|
||||
const comment = this.db.transaction(() => {
|
||||
const c = this.boardRepository.createComment({
|
||||
threadId: thread.id,
|
||||
authorId: actorId,
|
||||
bodyMd,
|
||||
});
|
||||
if (fileIds && fileIds.length > 0) {
|
||||
this.attachmentService.attach(
|
||||
actorId,
|
||||
thread.projectId,
|
||||
'board_comment',
|
||||
c.id,
|
||||
fileIds
|
||||
);
|
||||
}
|
||||
return c;
|
||||
});
|
||||
// スレッド投稿者へ通知(自分自身へのコメントは通知しない)
|
||||
if (thread.authorId !== actorId) {
|
||||
@ -188,7 +227,29 @@ export class BoardService {
|
||||
if (!comment) throw new NotFoundError('BoardComment', commentId);
|
||||
const thread = this.getThread(actorId, comment.threadId);
|
||||
this.requireAuthorOrAdmin(thread.projectId, actorId, comment.authorId);
|
||||
this.boardRepository.deleteComment(commentId);
|
||||
this.db.transaction(() => {
|
||||
this.attachmentService.detach('board_comment', commentId);
|
||||
this.boardRepository.deleteComment(commentId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* スレッド本文と指定コメントの添付ファイルを一括取得する。
|
||||
* メンバーシップは getThread で検証する。
|
||||
*/
|
||||
getAttachments(
|
||||
actorId: number,
|
||||
threadId: number,
|
||||
commentIds: number[]
|
||||
): { thread: AttachmentView[]; comments: AttachmentView[] } {
|
||||
const thread = this.getThread(actorId, threadId);
|
||||
return {
|
||||
thread: this.attachmentService.listViews('board_thread', thread.id),
|
||||
comments: this.attachmentService.listViewsBatch(
|
||||
'board_comment',
|
||||
commentIds
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private requireMember(projectId: number, actorId: number): void {
|
||||
|
||||
@ -6,15 +6,21 @@ import type { Paginated } from '@/repositories/ChatRepository';
|
||||
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
|
||||
import { UserRepository } from '@/repositories/UserRepository';
|
||||
import { NotificationService } from '@/services/NotificationService';
|
||||
import { AttachmentService } from '@/services/AttachmentService';
|
||||
import { SseHub } from '@/lib/sse/hub';
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import type { ChatMessage } from '@/lib/types';
|
||||
import type {
|
||||
AttachmentView,
|
||||
ChatMessage,
|
||||
ChatMessageWithAttachments,
|
||||
} from '@/lib/types';
|
||||
|
||||
const MENTION_RE = /@([^\s@]+@[^\s@]+\.[^\s@]+)/g;
|
||||
|
||||
/**
|
||||
* チャットの業務ロジックを担うService。
|
||||
* 権限チェック、メッセージ送信/編集/削除、メンション通知、SSE配信を行う。
|
||||
* 権限チェック、メッセージ送信/編集/削除、メンション通知、添付ファイル紐付け、SSE配信を行う。
|
||||
*/
|
||||
export class ChatService {
|
||||
constructor(
|
||||
@ -22,27 +28,56 @@ export class ChatService {
|
||||
private readonly projectMemberRepository: ProjectMemberRepository,
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly sseHub: SseHub
|
||||
private readonly sseHub: SseHub,
|
||||
private readonly attachmentService: AttachmentService,
|
||||
private readonly db: SqliteDatabase
|
||||
) {}
|
||||
|
||||
getHistory(
|
||||
actorId: number,
|
||||
projectId: number,
|
||||
opts: ListMessagesOptions = {}
|
||||
): Paginated<ChatMessage> {
|
||||
): Paginated<ChatMessageWithAttachments> {
|
||||
this.requireMember(projectId, actorId);
|
||||
return this.chatRepository.findMessages(projectId, opts);
|
||||
const page = this.chatRepository.findMessages(projectId, opts);
|
||||
const byTarget = this.indexAttachments(
|
||||
'chat_message',
|
||||
page.items.map((m) => m.id)
|
||||
);
|
||||
return {
|
||||
items: page.items.map((m) => this.withAttachments(m, byTarget.get(m.id))),
|
||||
total: page.total,
|
||||
};
|
||||
}
|
||||
|
||||
sendMessage(actorId: number, projectId: number, body: string): ChatMessage {
|
||||
sendMessage(
|
||||
actorId: number,
|
||||
projectId: number,
|
||||
body: string,
|
||||
fileIds?: number[]
|
||||
): ChatMessageWithAttachments {
|
||||
this.requireMember(projectId, actorId);
|
||||
if (!body.trim()) {
|
||||
throw new ValidationError('メッセージを入力してください', 'body');
|
||||
}
|
||||
const message = this.chatRepository.create({
|
||||
projectId,
|
||||
authorId: actorId,
|
||||
body,
|
||||
// メッセージ本体と添付紐付けはトランザクション内で一貫保存する
|
||||
const { message, attachments } = this.db.transaction(() => {
|
||||
const msg = this.chatRepository.create({
|
||||
projectId,
|
||||
authorId: actorId,
|
||||
body,
|
||||
});
|
||||
const atts =
|
||||
fileIds && fileIds.length > 0
|
||||
? this.attachmentService.attach(
|
||||
actorId,
|
||||
projectId,
|
||||
'chat_message',
|
||||
msg.id,
|
||||
fileIds
|
||||
)
|
||||
: [];
|
||||
return { message: msg, attachments: atts };
|
||||
});
|
||||
|
||||
// メンション検出(@email) → 通知
|
||||
@ -64,11 +99,12 @@ export class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
const messageWithAttachments = this.withAttachments(message, attachments);
|
||||
this.sseHub.broadcast(projectId, {
|
||||
type: 'chat.message.created',
|
||||
data: { projectId, message },
|
||||
data: { projectId, message: messageWithAttachments },
|
||||
});
|
||||
return message;
|
||||
return messageWithAttachments;
|
||||
}
|
||||
|
||||
editMessage(actorId: number, messageId: number, body: string): ChatMessage {
|
||||
@ -101,13 +137,38 @@ export class ChatService {
|
||||
if (message.authorId !== actorId && role !== 'admin') {
|
||||
throw new ForbiddenError('投稿者または管理者のみ削除できます');
|
||||
}
|
||||
this.chatRepository.delete(messageId);
|
||||
this.db.transaction(() => {
|
||||
this.chatRepository.delete(messageId);
|
||||
this.attachmentService.detach('chat_message', messageId);
|
||||
});
|
||||
this.sseHub.broadcast(message.projectId, {
|
||||
type: 'chat.message.deleted',
|
||||
data: { projectId: message.projectId, id: messageId },
|
||||
});
|
||||
}
|
||||
|
||||
private withAttachments(
|
||||
message: ChatMessage,
|
||||
attachments: AttachmentView[] | undefined
|
||||
): ChatMessageWithAttachments {
|
||||
return { ...message, attachments: attachments ?? [] };
|
||||
}
|
||||
|
||||
private indexAttachments(
|
||||
targetType: 'chat_message',
|
||||
targetIds: number[]
|
||||
): Map<number, AttachmentView[]> {
|
||||
const map = new Map<number, AttachmentView[]>();
|
||||
if (targetIds.length === 0) return map;
|
||||
const views = this.attachmentService.listViewsBatch(targetType, targetIds);
|
||||
for (const v of views) {
|
||||
const list = map.get(v.targetId) ?? [];
|
||||
list.push(v);
|
||||
map.set(v.targetId, list);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private requireMember(projectId: number, actorId: number): void {
|
||||
if (!this.projectMemberRepository.isMember(projectId, actorId)) {
|
||||
throw new ForbiddenError('プロジェクトに参加していません');
|
||||
|
||||
@ -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