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:
@ -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('プロジェクトに参加していません');
|
||||
|
||||
Reference in New Issue
Block a user