feat(m8): SSE hub + realtime chat with mentions, tests, e2e

- SseHub (project-scoped client mgmt + broadcast, implements SseBroadcaster)
  with SSE-formatted payload; singleton getSseHub
- ChatRepository (message CRUD, soft-delete, search, pagination, project
  isolation) + ChatService (membership permission, send/edit/delete,
  @email mention -> mention notification, SSE broadcast of created/updated/deleted)
- SSE stream endpoint (ReadableStream + heartbeat + abort cleanup, membership)
- Chat message API routes (history/send/edit/delete)
- ChatWindow client (EventSource auto-reconnect, history fetch, realtime
  append/update/delete), chat page, ProjectNav チャット link
- SseEvent type now carries ChatMessage for chat events
- Unit (SseHub, ChatRepository, ChatService) + integration chat-sse-broadcast
  + e2e chat-sse (two contexts realtime)
This commit is contained in:
Ken Yasue
2026-06-25 01:45:34 +02:00
parent 83a02265f5
commit ddad5ae78c
16 changed files with 1183 additions and 7 deletions

View File

@ -11,6 +11,9 @@ import { BoardService } from '@/services/BoardService';
import { BoardRepository } from '@/repositories/BoardRepository';
import { NoteService } from '@/services/NoteService';
import { ProjectNoteRepository } from '@/repositories/ProjectNoteRepository';
import { ChatService } from '@/services/ChatService';
import { ChatRepository } from '@/repositories/ChatRepository';
import { getSseHub } from '@/lib/sse/hub';
/**
* Route Handler用に各Repository/Serviceを構築するファクトリ。
@ -54,6 +57,17 @@ export function createNoteService(): NoteService {
);
}
export function createChatService(): ChatService {
const db = getDb();
return new ChatService(
new ChatRepository(db),
new ProjectMemberRepository(db),
new UserRepository(db),
new NotificationService(new NotificationRepository(db)),
getSseHub()
);
}
export function createUserRepository(): UserRepository {
return new UserRepository(getDb());
}

65
lib/sse/hub.ts Normal file
View File

@ -0,0 +1,65 @@
import type { SseBroadcaster, SseEvent } from '@/lib/types';
/**
* SSEクライアントの抽象。ReadableStreamのcontrollerを包む。
*/
export interface SseClient {
enqueue(chunk: string): void;
close(): void;
}
/**
* プロジェクト単位のSSEクライアント管理・イベント配信を行うHub。
* 配信は当該プロジェクトのクライアントのみに行い、他プロジェクトへ漏らさない。
* モジュールシングルトン(getSseHub)でプロセス内共有する。
*/
export class SseHub implements SseBroadcaster {
private readonly clients = new Map<number, Set<SseClient>>();
addClient(projectId: number, client: SseClient): void {
let set = this.clients.get(projectId);
if (!set) {
set = new Set();
this.clients.set(projectId, set);
}
set.add(client);
}
removeClient(projectId: number, client: SseClient): void {
this.clients.get(projectId)?.delete(client);
}
broadcast(projectId: number, event: SseEvent): void {
const set = this.clients.get(projectId);
if (!set || set.size === 0) return;
const payload = `data: ${JSON.stringify(event)}\n\n`;
for (const client of set) {
try {
client.enqueue(payload);
} catch {
// 書き込み失敗(切断済み等)のクライアントは除去
set.delete(client);
}
}
}
/** テスト/監視用: プロジェクトの接続クライアント数 */
getClientCount(projectId: number): number {
return this.clients.get(projectId)?.size ?? 0;
}
}
let hubInstance: SseHub | null = null;
/** プロセス全体で共有するSseHubシングルトンを取得する */
export function getSseHub(): SseHub {
if (!hubInstance) {
hubInstance = new SseHub();
}
return hubInstance;
}
/** テスト用途: シングルトンをリセットする */
export function resetSseHub(): void {
hubInstance = null;
}

View File

@ -277,16 +277,25 @@ export type NotificationEvent =
projectMemberIds: number[];
});
/** SSE配信イベント(M8でSseHubが扱う)。M5ではbroadcasterの型として使用 */
/** SSE配信イベント(M8でSseHubが扱う)。 */
export type SseEvent =
| { type: 'notification.created'; data: { projectId: number | null } }
| { type: 'chat.message.created'; data: { projectId: number } }
| { type: 'chat.message.updated'; data: { projectId: number } }
| { type: 'chat.message.deleted'; data: { projectId: number; id: number } }
| {
type: 'chat.message.created';
data: { projectId: number; message: ChatMessage };
}
| {
type: 'chat.message.updated';
data: { projectId: number; message: ChatMessage };
}
| {
type: 'chat.message.deleted';
data: { projectId: number; id: number };
}
| { type: 'todo.updated'; data: { projectId: number } }
| { type: 'file.uploaded'; data: { projectId: number } }
| { type: 'meeting.created'; data: { projectId: number } }
| { type: 'note.updated'; data: { projectId: number } };
| { type: 'note.updated'; data: { projectId: number } }
| { type: 'notification.created'; data: { projectId: number | null } };
/** SSE配信を行うコンポーネントの抽象(M8のSseHubが実装) */
export interface SseBroadcaster {