From ddad5ae78c5e31529cc7124f531c66a36cef5ac2 Mon Sep 17 00:00:00 2001 From: Ken Yasue Date: Thu, 25 Jun 2026 01:45:34 +0200 Subject: [PATCH] feat(m8): SSE hub + realtime chat with mentions, tests, e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../chat/messages/[messageId]/route.ts | 51 +++++++ .../[projectId]/chat/messages/route.ts | 54 +++++++ .../projects/[projectId]/chat/stream/route.ts | 70 +++++++++ app/projects/[projectId]/chat/page.tsx | 41 ++++++ components/chat/ChatWindow.tsx | 133 +++++++++++++++++ components/layout/ProjectNav.tsx | 11 +- lib/api/services.ts | 14 ++ lib/sse/hub.ts | 65 +++++++++ lib/types/index.ts | 21 ++- repositories/ChatRepository.ts | 129 ++++++++++++++++ services/ChatService.ts | 126 ++++++++++++++++ tests/e2e/chat-sse.spec.ts | 68 +++++++++ tests/integration/chat-sse-broadcast.test.ts | 92 ++++++++++++ tests/unit/lib/sse/hub.test.ts | 92 ++++++++++++ .../unit/repositories/ChatRepository.test.ts | 85 +++++++++++ tests/unit/services/ChatService.test.ts | 138 ++++++++++++++++++ 16 files changed, 1183 insertions(+), 7 deletions(-) create mode 100644 app/api/projects/[projectId]/chat/messages/[messageId]/route.ts create mode 100644 app/api/projects/[projectId]/chat/messages/route.ts create mode 100644 app/api/projects/[projectId]/chat/stream/route.ts create mode 100644 app/projects/[projectId]/chat/page.tsx create mode 100644 components/chat/ChatWindow.tsx create mode 100644 lib/sse/hub.ts create mode 100644 repositories/ChatRepository.ts create mode 100644 services/ChatService.ts create mode 100644 tests/e2e/chat-sse.spec.ts create mode 100644 tests/integration/chat-sse-broadcast.test.ts create mode 100644 tests/unit/lib/sse/hub.test.ts create mode 100644 tests/unit/repositories/ChatRepository.test.ts create mode 100644 tests/unit/services/ChatService.test.ts diff --git a/app/api/projects/[projectId]/chat/messages/[messageId]/route.ts b/app/api/projects/[projectId]/chat/messages/[messageId]/route.ts new file mode 100644 index 0000000..aae3925 --- /dev/null +++ b/app/api/projects/[projectId]/chat/messages/[messageId]/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth/getCurrentUser'; +import { createChatService } from '@/lib/api/services'; +import { UnauthorizedError } from '@/lib/errors'; +import { handleApiError, jsonError } from '@/lib/api/handleError'; + +export const runtime = 'nodejs'; + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ projectId: string; messageId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { messageId } = await params; + let body: Record; + try { + body = (await request.json()) as Record; + } catch { + return jsonError(400, 'リクエスト本文が不正です'); + } + + const service = createChatService(); + try { + const message = service.editMessage( + user.id, + Number(messageId), + String(body.body ?? '') + ); + return NextResponse.json({ message }); + } catch (error) { + return handleApiError(error); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ projectId: string; messageId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { messageId } = await params; + + const service = createChatService(); + try { + service.deleteMessage(user.id, Number(messageId)); + return NextResponse.json({ ok: true }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/api/projects/[projectId]/chat/messages/route.ts b/app/api/projects/[projectId]/chat/messages/route.ts new file mode 100644 index 0000000..86ddb87 --- /dev/null +++ b/app/api/projects/[projectId]/chat/messages/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth/getCurrentUser'; +import { createChatService } from '@/lib/api/services'; +import { UnauthorizedError } from '@/lib/errors'; +import { handleApiError, jsonError } from '@/lib/api/handleError'; + +export const runtime = 'nodejs'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { projectId } = await params; + const page = Number(request.nextUrl.searchParams.get('page') ?? '1') || 1; + const search = request.nextUrl.searchParams.get('q') ?? undefined; + + const service = createChatService(); + try { + return NextResponse.json( + service.getHistory(user.id, Number(projectId), { page, search }) + ); + } catch (error) { + return handleApiError(error); + } +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { projectId } = await params; + let body: Record; + try { + body = (await request.json()) as Record; + } catch { + return jsonError(400, 'リクエスト本文が不正です'); + } + + const service = createChatService(); + try { + const message = service.sendMessage( + user.id, + Number(projectId), + String(body.body ?? '') + ); + return NextResponse.json({ message }, { status: 201 }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/api/projects/[projectId]/chat/stream/route.ts b/app/api/projects/[projectId]/chat/stream/route.ts new file mode 100644 index 0000000..b323291 --- /dev/null +++ b/app/api/projects/[projectId]/chat/stream/route.ts @@ -0,0 +1,70 @@ +import { NextRequest } from 'next/server'; +import { getCurrentUser } from '@/lib/auth/getCurrentUser'; +import { createProjectService } from '@/lib/api/services'; +import { getSseHub } from '@/lib/sse/hub'; +import { UnauthorizedError } from '@/lib/errors'; +import { handleApiError } from '@/lib/api/handleError'; + +export const runtime = 'nodejs'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { projectId } = await params; + + const projectService = createProjectService(); + try { + projectService.getProject(user.id, Number(projectId)); + } catch (error) { + return handleApiError(error); + } + + const pid = Number(projectId); + const hub = getSseHub(); + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + start(controller) { + const client = { + enqueue: (chunk: string) => controller.enqueue(encoder.encode(chunk)), + close: () => { + try { + controller.close(); + } catch { + // 既に閉じられている + } + }, + }; + hub.addClient(pid, client); + + const heartbeat = setInterval(() => { + try { + controller.enqueue(encoder.encode(': ping\n\n')); + } catch { + clearInterval(heartbeat); + } + }, 30_000); + + request.signal.addEventListener('abort', () => { + clearInterval(heartbeat); + hub.removeClient(pid, client); + try { + controller.close(); + } catch { + // 既に閉じられている + } + }); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + }, + }); +} diff --git a/app/projects/[projectId]/chat/page.tsx b/app/projects/[projectId]/chat/page.tsx new file mode 100644 index 0000000..25b3739 --- /dev/null +++ b/app/projects/[projectId]/chat/page.tsx @@ -0,0 +1,41 @@ +import { redirect } from 'next/navigation'; +import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser'; +import { createProjectService } from '@/lib/api/services'; +import { Header } from '@/components/layout/Header'; +import { ProjectNav } from '@/components/layout/ProjectNav'; +import { ChatWindow } from '@/components/chat/ChatWindow'; +import { ForbiddenError, NotFoundError } from '@/lib/errors'; + +export const dynamic = 'force-dynamic'; + +export default async function ChatPage({ + params, +}: { + params: Promise<{ projectId: string }>; +}) { + const user = await getCurrentUser(); + if (!user) redirect('/login'); + const { projectId } = await params; + + const projectService = createProjectService(); + let project: Awaited>; + try { + project = projectService.getProject(user.id, Number(projectId)); + } catch (error) { + if (error instanceof ForbiddenError || error instanceof NotFoundError) { + redirect('/dashboard'); + } + throw error; + } + + return ( +
+
+ +
+

チャット

+ +
+
+ ); +} diff --git a/components/chat/ChatWindow.tsx b/components/chat/ChatWindow.tsx new file mode 100644 index 0000000..9f586d6 --- /dev/null +++ b/components/chat/ChatWindow.tsx @@ -0,0 +1,133 @@ +'use client'; + +import { useEffect, useState, type FormEvent } from 'react'; +import type { ChatMessage } from '@/lib/types'; + +interface ChatWindowProps { + projectId: number; + userName: string; +} + +export function ChatWindow({ projectId, userName }: ChatWindowProps) { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [error, setError] = useState(null); + + // 履歴取得 + useEffect(() => { + fetch(`/api/projects/${projectId}/chat/messages`) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (data?.items) setMessages(data.items as ChatMessage[]); + }) + .catch(() => undefined); + }, [projectId]); + + // SSE接続(自動再接続はEventSourceが行う) + useEffect(() => { + const es = new EventSource(`/api/projects/${projectId}/chat/stream`); + es.onmessage = (event) => { + try { + const parsed = JSON.parse(event.data) as { + type: string; + data: { message?: ChatMessage; id?: number }; + }; + if (parsed.type === 'chat.message.created' && parsed.data.message) { + setMessages((prev) => + prev.some((m) => m.id === parsed.data.message!.id) + ? prev + : [parsed.data.message!, ...prev] + ); + } else if ( + parsed.type === 'chat.message.updated' && + parsed.data.message + ) { + setMessages((prev) => + prev.map((m) => + m.id === parsed.data.message!.id ? parsed.data.message! : m + ) + ); + } else if (parsed.type === 'chat.message.deleted' && parsed.data.id) { + setMessages((prev) => prev.filter((m) => m.id !== parsed.data.id)); + } + } catch { + // 無効なペイロードは無視 + } + }; + return () => es.close(); + }, [projectId]); + + async function onSend(event: FormEvent) { + event.preventDefault(); + if (!input.trim()) return; + setError(null); + const res = await fetch(`/api/projects/${projectId}/chat/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ body: input }), + }); + if (res.ok) { + setInput(''); + } else { + const b = (await res.json().catch(() => null)) as { + error?: { message?: string }; + } | null; + setError(b?.error?.message ?? '送信に失敗しました'); + } + } + + return ( +
+
+
    + {messages.map((m) => ( +
  • + + {m.body} + + + {m.createdAt} + +
  • + ))} +
+
+
+ setInput(e.target.value)} + placeholder="メッセージを入力(@email でメンション)" + className="flex-1 rounded border px-3 py-2" + data-testid="chat-input" + /> + +
+ {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/components/layout/ProjectNav.tsx b/components/layout/ProjectNav.tsx index aecae9f..1881691 100644 --- a/components/layout/ProjectNav.tsx +++ b/components/layout/ProjectNav.tsx @@ -2,6 +2,7 @@ const NAV_ITEMS = [ { href: '', label: '概要' }, { href: '/board', label: '掲示板' }, { href: '/notes', label: 'メモ' }, + { href: '/chat', label: 'チャット' }, { href: '/members', label: 'メンバー' }, { href: '/activity', label: 'アクティビティ' }, { href: '/settings', label: '設定' }, @@ -16,12 +17,20 @@ export function ProjectNav({ active, }: { projectId: number; - active: 'overview' | 'board' | 'notes' | 'members' | 'activity' | 'settings'; + active: + | 'overview' + | 'board' + | 'notes' + | 'chat' + | 'members' + | 'activity' + | 'settings'; }) { const activeMap: Record = { overview: active === 'overview', board: active === 'board', notes: active === 'notes', + chat: active === 'chat', members: active === 'members', activity: active === 'activity', settings: active === 'settings', diff --git a/lib/api/services.ts b/lib/api/services.ts index 729b50e..e28ddc3 100644 --- a/lib/api/services.ts +++ b/lib/api/services.ts @@ -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()); } diff --git a/lib/sse/hub.ts b/lib/sse/hub.ts new file mode 100644 index 0000000..b20ca25 --- /dev/null +++ b/lib/sse/hub.ts @@ -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>(); + + 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; +} diff --git a/lib/types/index.ts b/lib/types/index.ts index ca5fd3d..9266a22 100644 --- a/lib/types/index.ts +++ b/lib/types/index.ts @@ -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 { diff --git a/repositories/ChatRepository.ts b/repositories/ChatRepository.ts new file mode 100644 index 0000000..f9719d1 --- /dev/null +++ b/repositories/ChatRepository.ts @@ -0,0 +1,129 @@ +import type { SqliteDatabase } from '@/lib/db/sqlite'; +import type { ChatMessage } from '@/lib/types'; + +const DEFAULT_PAGE_SIZE = 20; + +export interface Paginated { + items: T[]; + total: number; +} + +interface ChatMessageRow { + id: number; + project_id: number; + author_id: number; + body: string; + created_at: string; + updated_at: string; + deleted_at: string | null; +} + +function mapMessage(row: ChatMessageRow): ChatMessage { + return { + id: row.id, + projectId: row.project_id, + authorId: row.author_id, + body: row.body, + createdAt: row.created_at, + updatedAt: row.updated_at, + deletedAt: row.deleted_at, + }; +} + +export interface ListMessagesOptions { + page?: number; + pageSize?: number; + search?: string; +} + +export class ChatRepository { + constructor(private readonly db: SqliteDatabase) {} + + findMessages( + projectId: number, + opts: ListMessagesOptions = {} + ): Paginated { + const page = opts.page ?? 1; + const pageSize = opts.pageSize ?? DEFAULT_PAGE_SIZE; + const offset = (page - 1) * pageSize; + const search = opts.search?.trim(); + + if (search) { + const like = `%${search}%`; + const items = this.db.query( + `SELECT * FROM chat_messages + WHERE project_id = @projectId AND deleted_at IS NULL + AND body LIKE @like + ORDER BY created_at DESC, id DESC + LIMIT @pageSize OFFSET @offset`, + { projectId, like, pageSize, offset } + ); + const total = this.db.get<{ count: number }>( + `SELECT COUNT(*) AS count FROM chat_messages + WHERE project_id = @projectId AND deleted_at IS NULL AND body LIKE @like`, + { projectId, like } + ); + return { items: items.map(mapMessage), total: total?.count ?? 0 }; + } + + const items = this.db.query( + `SELECT * FROM chat_messages + WHERE project_id = @projectId AND deleted_at IS NULL + ORDER BY created_at DESC, id DESC + LIMIT @pageSize OFFSET @offset`, + { projectId, pageSize, offset } + ); + const total = this.db.get<{ count: number }>( + 'SELECT COUNT(*) AS count FROM chat_messages WHERE project_id = @projectId AND deleted_at IS NULL', + { projectId } + ); + return { items: items.map(mapMessage), total: total?.count ?? 0 }; + } + + findMessageById(id: number): ChatMessage | null { + const row = this.db.get( + 'SELECT * FROM chat_messages WHERE id = @id AND deleted_at IS NULL', + { id } + ); + return row ? mapMessage(row) : null; + } + + create(input: { + projectId: number; + authorId: number; + body: string; + }): ChatMessage { + const now = new Date().toISOString(); + const result = this.db.execute( + `INSERT INTO chat_messages (project_id, author_id, body, created_at, updated_at, deleted_at) + VALUES (@projectId, @authorId, @body, @createdAt, @updatedAt, NULL)`, + { + projectId: input.projectId, + authorId: input.authorId, + body: input.body, + createdAt: now, + updatedAt: now, + } + ); + const created = this.findMessageById(Number(result.lastInsertRowid)); + if (!created) throw new Error('Failed to create chat message'); + return created; + } + + update(id: number, body: string): ChatMessage | null { + this.db.execute( + `UPDATE chat_messages SET body = @body, updated_at = @updatedAt + WHERE id = @id AND deleted_at IS NULL`, + { body, updatedAt: new Date().toISOString(), id } + ); + return this.findMessageById(id); + } + + delete(id: number): boolean { + const result = this.db.execute( + 'UPDATE chat_messages SET deleted_at = @now WHERE id = @id AND deleted_at IS NULL', + { now: new Date().toISOString(), id } + ); + return result.changes > 0; + } +} diff --git a/services/ChatService.ts b/services/ChatService.ts new file mode 100644 index 0000000..28045a6 --- /dev/null +++ b/services/ChatService.ts @@ -0,0 +1,126 @@ +import { + ChatRepository, + type ListMessagesOptions, +} from '@/repositories/ChatRepository'; +import type { Paginated } from '@/repositories/ChatRepository'; +import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository'; +import { UserRepository } from '@/repositories/UserRepository'; +import { NotificationService } from '@/services/NotificationService'; +import { SseHub } from '@/lib/sse/hub'; +import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors'; +import type { ChatMessage } from '@/lib/types'; + +const MENTION_RE = /@([^\s@]+@[^\s@]+\.[^\s@]+)/g; + +/** + * チャットの業務ロジックを担うService。 + * 権限チェック、メッセージ送信/編集/削除、メンション通知、SSE配信を行う。 + */ +export class ChatService { + constructor( + private readonly chatRepository: ChatRepository, + private readonly projectMemberRepository: ProjectMemberRepository, + private readonly userRepository: UserRepository, + private readonly notificationService: NotificationService, + private readonly sseHub: SseHub + ) {} + + getHistory( + actorId: number, + projectId: number, + opts: ListMessagesOptions = {} + ): Paginated { + this.requireMember(projectId, actorId); + return this.chatRepository.findMessages(projectId, opts); + } + + sendMessage(actorId: number, projectId: number, body: string): ChatMessage { + this.requireMember(projectId, actorId); + if (!body.trim()) { + throw new ValidationError('メッセージを入力してください', 'body'); + } + const message = this.chatRepository.create({ + projectId, + authorId: actorId, + body, + }); + + // メンション検出(@email) → 通知 + const mentionedEmails = this.extractMentions(body); + for (const email of mentionedEmails) { + const mentioned = this.userRepository.findByEmail(email); + if ( + mentioned && + mentioned.id !== actorId && + this.projectMemberRepository.isMember(projectId, mentioned.id) + ) { + this.notificationService.notifyOnEvent({ + type: 'mention', + projectId, + title: `${email} にメンションされました`, + body: body.slice(0, 100), + mentionedUserId: mentioned.id, + }); + } + } + + this.sseHub.broadcast(projectId, { + type: 'chat.message.created', + data: { projectId, message }, + }); + return message; + } + + editMessage(actorId: number, messageId: number, body: string): ChatMessage { + const message = this.chatRepository.findMessageById(messageId); + if (!message) throw new NotFoundError('ChatMessage', messageId); + this.requireMember(message.projectId, actorId); + if (message.authorId !== actorId) { + throw new ForbiddenError('自分のメッセージのみ編集できます'); + } + if (!body.trim()) { + throw new ValidationError('メッセージを入力してください', 'body'); + } + const updated = this.chatRepository.update(messageId, body); + if (!updated) throw new NotFoundError('ChatMessage', messageId); + this.sseHub.broadcast(message.projectId, { + type: 'chat.message.updated', + data: { projectId: message.projectId, message: updated }, + }); + return updated; + } + + deleteMessage(actorId: number, messageId: number): void { + const message = this.chatRepository.findMessageById(messageId); + if (!message) throw new NotFoundError('ChatMessage', messageId); + this.requireMember(message.projectId, actorId); + const role = this.projectMemberRepository.getRole( + message.projectId, + actorId + ); + if (message.authorId !== actorId && role !== 'admin') { + throw new ForbiddenError('投稿者または管理者のみ削除できます'); + } + this.chatRepository.delete(messageId); + this.sseHub.broadcast(message.projectId, { + type: 'chat.message.deleted', + data: { projectId: message.projectId, id: messageId }, + }); + } + + private requireMember(projectId: number, actorId: number): void { + if (!this.projectMemberRepository.isMember(projectId, actorId)) { + throw new ForbiddenError('プロジェクトに参加していません'); + } + } + + private extractMentions(body: string): string[] { + const emails = new Set(); + let match: RegExpExecArray | null; + MENTION_RE.lastIndex = 0; + while ((match = MENTION_RE.exec(body)) !== null) { + emails.add(match[1]); + } + return [...emails]; + } +} diff --git a/tests/e2e/chat-sse.spec.ts b/tests/e2e/chat-sse.spec.ts new file mode 100644 index 0000000..c6464e9 --- /dev/null +++ b/tests/e2e/chat-sse.spec.ts @@ -0,0 +1,68 @@ +import { test, expect, type Page } from '@playwright/test'; + +function unique(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; +} + +async function registerAndLogin(page: Page, email: string, name: string) { + await page.goto('/login'); + await page.getByRole('button', { name: '新規登録はこちら' }).click(); + await page.getByLabel('表示名').fill(name); + await page.getByLabel('メールアドレス').fill(email); + await page.getByLabel('パスワード').fill('password123'); + await page.getByRole('button', { name: '登録する' }).click(); + await expect(page).toHaveURL(/\/dashboard/); +} + +test.describe('chat (SSE realtime)', () => { + test('a message sent in one context is received in another via SSE', async ({ + browser, + }) => { + const ownerEmail = unique('owner') + '@example.com'; + const memberEmail = unique('member') + '@example.com'; + + // メンバーを先に登録(UIで作成+ログイン) + const memberContext = await browser.newContext(); + const memberPage = await memberContext.newPage(); + await registerAndLogin(memberPage, memberEmail, 'Member'); + + // オーナー登録+プロジェクト作成 + const ownerContext = await browser.newContext(); + const ownerPage = await ownerContext.newPage(); + await registerAndLogin(ownerPage, ownerEmail, 'Owner'); + await ownerPage.getByLabel('プロジェクト名').fill(unique('Proj')); + await ownerPage.getByRole('button', { name: '新規プロジェクト' }).click(); + await expect(ownerPage).toHaveURL(/\/projects\/\d+$/); + const projectId = Number(ownerPage.url().match(/\/projects\/(\d+)/)![1]); + + // メンバーをプロジェクトに追加(オーナーセッション) + const addRes = await ownerPage.request.post( + `/api/projects/${projectId}/members`, + { data: { email: memberEmail, role: 'member' } } + ); + expect(addRes.ok()).toBeTruthy(); + + // 両者チャット画面を開く(SSE接続) + await ownerPage.goto(`/projects/${projectId}/chat`); + await memberPage.goto(`/projects/${projectId}/chat`); + await expect(ownerPage.getByTestId('chat-form')).toBeVisible(); + await expect(memberPage.getByTestId('chat-form')).toBeVisible(); + + // オーナーがメッセージ送信 + const text = unique('hello'); + await ownerPage.getByTestId('chat-input').fill(text); + await ownerPage.getByTestId('chat-send').click(); + + // メンバー側にSSE経由でリアルタイム表示される + await expect( + memberPage.getByTestId('chat-messages').getByText(text) + ).toBeVisible({ timeout: 15_000 }); + // オーナー自身にも表示される + await expect( + ownerPage.getByTestId('chat-messages').getByText(text) + ).toBeVisible(); + + await ownerContext.close(); + await memberContext.close(); + }); +}); diff --git a/tests/integration/chat-sse-broadcast.test.ts b/tests/integration/chat-sse-broadcast.test.ts new file mode 100644 index 0000000..46863b5 --- /dev/null +++ b/tests/integration/chat-sse-broadcast.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createMigratedTestDb } from '@/tests/helpers/db'; +import type { SqliteDatabase } from '@/lib/db/sqlite'; +import { UserRepository } from '@/repositories/UserRepository'; +import { ProjectRepository } from '@/repositories/ProjectRepository'; +import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository'; +import { ChatRepository } from '@/repositories/ChatRepository'; +import { NotificationRepository } from '@/repositories/NotificationRepository'; +import { NotificationService } from '@/services/NotificationService'; +import { ChatService } from '@/services/ChatService'; +import { SseHub, type SseClient } from '@/lib/sse/hub'; + +describe('chat → SSE broadcast (integration)', () => { + let db: SqliteDatabase; + let service: ChatService; + let hub: SseHub; + let projectId: number; + let authorId: number; + + beforeEach(() => { + db = createMigratedTestDb(); + hub = new SseHub(); + const users = new UserRepository(db); + authorId = users.create({ + name: 'A', + email: 'a@example.com', + passwordHash: 'h', + }).id; + projectId = new ProjectRepository(db).create({ + name: 'P', + ownerId: authorId, + }).id; + const members = new ProjectMemberRepository(db); + members.add(projectId, authorId, 'admin'); + service = new ChatService( + new ChatRepository(db), + members, + users, + new NotificationService(new NotificationRepository(db)), + hub + ); + }); + + afterEach(() => db.close()); + + function subscribe(projectId: number): SseClient & { received: string[] } { + const received: string[] = []; + const client: SseClient & { received: string[] } = { + received, + enqueue: (c) => received.push(c), + close: () => undefined, + }; + hub.addClient(projectId, client); + return client; + } + + it('a subscribed client receives the created message event in SSE format', () => { + const client = subscribe(projectId); + + const message = service.sendMessage(authorId, projectId, 'hello realtime'); + + expect(client.received).toHaveLength(1); + const raw = client.received[0]; + expect(raw).toMatch(/^data: .+\n\n$/); + const parsed = JSON.parse(raw.replace(/^data: /, '').replace(/\n\n$/, '')); + expect(parsed.type).toBe('chat.message.created'); + expect(parsed.data.message.id).toBe(message.id); + expect(parsed.data.message.body).toBe('hello realtime'); + }); + + it('a client in another project does not receive the message', () => { + const otherClient = subscribe(99999); + + service.sendMessage(authorId, projectId, 'hello'); + + expect(otherClient.received).toHaveLength(0); + }); + + it('delete broadcasts a deleted event with the message id', () => { + const client = subscribe(projectId); + const message = service.sendMessage(authorId, projectId, 'to be deleted'); + client.received.length = 0; + + service.deleteMessage(authorId, message.id); + + const parsed = JSON.parse( + client.received[0].replace(/^data: /, '').replace(/\n\n$/, '') + ); + expect(parsed.type).toBe('chat.message.deleted'); + expect(parsed.data.id).toBe(message.id); + }); +}); diff --git a/tests/unit/lib/sse/hub.test.ts b/tests/unit/lib/sse/hub.test.ts new file mode 100644 index 0000000..254573a --- /dev/null +++ b/tests/unit/lib/sse/hub.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { SseHub, type SseClient } from '@/lib/sse/hub'; + +function makeClient(): SseClient & { received: string[] } { + const received: string[] = []; + return { + received, + enqueue: (chunk: string) => received.push(chunk), + close: () => undefined, + }; +} + +describe('SseHub', () => { + let hub: SseHub; + + beforeEach(() => { + hub = new SseHub(); + }); + + it('adds and counts clients per project', () => { + hub.addClient(1, makeClient()); + hub.addClient(1, makeClient()); + hub.addClient(2, makeClient()); + + expect(hub.getClientCount(1)).toBe(2); + expect(hub.getClientCount(2)).toBe(1); + expect(hub.getClientCount(999)).toBe(0); + }); + + it('removes a client', () => { + const c = makeClient(); + hub.addClient(1, c); + hub.removeClient(1, c); + expect(hub.getClientCount(1)).toBe(0); + }); + + it('broadcasts only to clients of the target project', () => { + const a1 = makeClient(); + const a2 = makeClient(); + const b1 = makeClient(); + hub.addClient(1, a1); + hub.addClient(1, a2); + hub.addClient(2, b1); + + hub.broadcast(1, { + type: 'chat.message.created', + data: { projectId: 1, message: { id: 1 } as never }, + }); + + expect(a1.received).toHaveLength(1); + expect(a2.received).toHaveLength(1); + expect(b1.received).toHaveLength(0); + expect(a1.received[0]).toContain('chat.message.created'); + }); + + it('broadcast payload is SSE formatted (data: ...\\n\\n)', () => { + const c = makeClient(); + hub.addClient(1, c); + hub.broadcast(1, { + type: 'chat.message.deleted', + data: { projectId: 1, id: 5 }, + }); + + expect(c.received[0]).toMatch(/^data: .+\n\n$/); + const parsed = JSON.parse( + c.received[0].replace(/^data: /, '').replace(/\n\n$/, '') + ); + expect(parsed.type).toBe('chat.message.deleted'); + expect(parsed.data.id).toBe(5); + }); + + it('does nothing when there are no clients for the project', () => { + expect(() => + hub.broadcast(42, { + type: 'note.updated', + data: { projectId: 42 }, + }) + ).not.toThrow(); + }); + + it('removes a client that throws on enqueue', () => { + const broken: SseClient = { + enqueue: () => { + throw new Error('disconnected'); + }, + close: () => undefined, + }; + hub.addClient(1, broken); + hub.broadcast(1, { type: 'note.updated', data: { projectId: 1 } }); + expect(hub.getClientCount(1)).toBe(0); + }); +}); diff --git a/tests/unit/repositories/ChatRepository.test.ts b/tests/unit/repositories/ChatRepository.test.ts new file mode 100644 index 0000000..a5f2459 --- /dev/null +++ b/tests/unit/repositories/ChatRepository.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createMigratedTestDb } from '@/tests/helpers/db'; +import type { SqliteDatabase } from '@/lib/db/sqlite'; +import { UserRepository } from '@/repositories/UserRepository'; +import { ProjectRepository } from '@/repositories/ProjectRepository'; +import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository'; +import { ChatRepository } from '@/repositories/ChatRepository'; + +describe('ChatRepository', () => { + let db: SqliteDatabase; + let repo: ChatRepository; + let projectId: number; + let authorId: number; + + beforeEach(() => { + db = createMigratedTestDb(); + repo = new ChatRepository(db); + authorId = new UserRepository(db).create({ + name: 'A', + email: 'a@example.com', + passwordHash: 'h', + }).id; + projectId = new ProjectRepository(db).create({ + name: 'P', + ownerId: authorId, + }).id; + new ProjectMemberRepository(db).add(projectId, authorId, 'admin'); + }); + + afterEach(() => db.close()); + + it('creates and finds a message', () => { + const m = repo.create({ projectId, authorId, body: 'hello' }); + expect(repo.findMessageById(m.id)?.body).toBe('hello'); + }); + + it('lists messages newest first with total', () => { + repo.create({ projectId, authorId, body: 'first' }); + repo.create({ projectId, authorId, body: 'second' }); + const { items, total } = repo.findMessages(projectId); + expect(total).toBe(2); + expect(items[0].body).toBe('second'); + }); + + it('excludes soft-deleted messages', () => { + const m = repo.create({ projectId, authorId, body: 'x' }); + repo.delete(m.id); + expect(repo.findMessageById(m.id)).toBeNull(); + expect(repo.findMessages(projectId).total).toBe(0); + }); + + it('isolates messages by project', () => { + repo.create({ projectId, authorId, body: 'mine' }); + const p2 = new ProjectRepository(db).create({ + name: 'P2', + ownerId: authorId, + }).id; + repo.create({ projectId: p2, authorId, body: 'theirs' }); + expect(repo.findMessages(projectId).total).toBe(1); + expect(repo.findMessages(p2).total).toBe(1); + }); + + it('searches messages by body', () => { + repo.create({ projectId, authorId, body: 'hello world' }); + repo.create({ projectId, authorId, body: 'goodbye' }); + expect(repo.findMessages(projectId, { search: 'hello' }).total).toBe(1); + expect(repo.findMessages(projectId, { search: 'nope' }).total).toBe(0); + }); + + it('paginates messages', () => { + for (let i = 0; i < 5; i++) + repo.create({ projectId, authorId, body: `m${i}` }); + expect( + repo.findMessages(projectId, { page: 1, pageSize: 2 }).items + ).toHaveLength(2); + expect(repo.findMessages(projectId, { page: 1, pageSize: 2 }).total).toBe( + 5 + ); + }); + + it('updates a message body', () => { + const m = repo.create({ projectId, authorId, body: 'x' }); + expect(repo.update(m.id, 'edited')?.body).toBe('edited'); + }); +}); diff --git a/tests/unit/services/ChatService.test.ts b/tests/unit/services/ChatService.test.ts new file mode 100644 index 0000000..f6987b7 --- /dev/null +++ b/tests/unit/services/ChatService.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createMigratedTestDb } from '@/tests/helpers/db'; +import type { SqliteDatabase } from '@/lib/db/sqlite'; +import { UserRepository } from '@/repositories/UserRepository'; +import { ProjectRepository } from '@/repositories/ProjectRepository'; +import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository'; +import { ChatRepository } from '@/repositories/ChatRepository'; +import { NotificationRepository } from '@/repositories/NotificationRepository'; +import { NotificationService } from '@/services/NotificationService'; +import { ChatService } from '@/services/ChatService'; +import { SseHub, type SseClient } from '@/lib/sse/hub'; +import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors'; + +function makeClient(): SseClient & { received: string[] } { + const received: string[] = []; + return { received, enqueue: (c) => received.push(c), close: () => undefined }; +} + +describe('ChatService', () => { + let db: SqliteDatabase; + let service: ChatService; + let hub: SseHub; + let projectId: number; + let authorId: number; + let memberId: number; + let outsiderId: number; + + beforeEach(() => { + db = createMigratedTestDb(); + hub = new SseHub(); + const users = new UserRepository(db); + authorId = users.create({ + name: 'A', + email: 'a@example.com', + passwordHash: 'h', + }).id; + memberId = users.create({ + name: 'M', + email: 'm@example.com', + passwordHash: 'h', + }).id; + outsiderId = users.create({ + name: 'O', + email: 'o@example.com', + passwordHash: 'h', + }).id; + projectId = new ProjectRepository(db).create({ + name: 'P', + ownerId: authorId, + }).id; + const members = new ProjectMemberRepository(db); + members.add(projectId, authorId, 'admin'); + members.add(projectId, memberId, 'member'); + service = new ChatService( + new ChatRepository(db), + members, + users, + new NotificationService(new NotificationRepository(db)), + hub + ); + }); + + afterEach(() => db.close()); + + it('sends a message and broadcasts chat.message.created', () => { + const client = makeClient(); + hub.addClient(projectId, client); + + const message = service.sendMessage(authorId, projectId, 'hello'); + + expect(message.body).toBe('hello'); + expect(client.received).toHaveLength(1); + expect(client.received[0]).toContain('chat.message.created'); + }); + + it('forbids a non-member from sending', () => { + expect(() => service.sendMessage(outsiderId, projectId, 'hi')).toThrow( + ForbiddenError + ); + }); + + it('rejects an empty message', () => { + expect(() => service.sendMessage(authorId, projectId, ' ')).toThrow( + ValidationError + ); + }); + + it('notifies a mentioned project member (@email) but not the sender', () => { + service.sendMessage(authorId, projectId, `hi @m@example.com`); + + expect(new NotificationRepository(db).countUnreadByUser(memberId)).toBe(1); + expect(new NotificationRepository(db).countUnreadByUser(authorId)).toBe(0); + }); + + it('does not notify a mentioned non-member', () => { + service.sendMessage(authorId, projectId, `hi @o@example.com`); + expect(new NotificationRepository(db).countUnreadByUser(outsiderId)).toBe( + 0 + ); + }); + + it('only the author can edit their message', () => { + const m = service.sendMessage(authorId, projectId, 'x'); + expect(() => service.editMessage(memberId, m.id, 'hacked')).toThrow( + ForbiddenError + ); + const updated = service.editMessage(authorId, m.id, 'edited'); + expect(updated.body).toBe('edited'); + }); + + it('broadcasts chat.message.updated on edit', () => { + const client = makeClient(); + hub.addClient(projectId, client); + const m = service.sendMessage(authorId, projectId, 'x'); + client.received.length = 0; + service.editMessage(authorId, m.id, 'edited'); + expect(client.received[0]).toContain('chat.message.updated'); + }); + + it('admin can delete another member message; broadcasts deleted', () => { + const client = makeClient(); + hub.addClient(projectId, client); + const m = service.sendMessage(memberId, projectId, 'x'); + client.received.length = 0; + service.deleteMessage(authorId, m.id); // authorId is admin + expect(client.received[0]).toContain('chat.message.deleted'); + expect(() => service.getHistory(memberId, projectId)).not.toThrow(); + }); + + it('non-admin non-author cannot delete', () => { + const m = service.sendMessage(authorId, projectId, 'x'); + expect(() => service.deleteMessage(memberId, m.id)).toThrow(ForbiddenError); + }); + + it('throws NotFoundError for a non-existent message', () => { + expect(() => service.deleteMessage(authorId, 99999)).toThrow(NotFoundError); + }); +});