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

@ -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<T> {
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<ChatMessage> {
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<ChatMessageRow>(
`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<ChatMessageRow>(
`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<ChatMessageRow>(
'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;
}
}