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:
129
repositories/ChatRepository.ts
Normal file
129
repositories/ChatRepository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user