From e7f37be62ce1fad8916e2f48e6a737971b94f215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=97=D0=B0=D0=B8=D0=B4=20=D0=9E=D0=BC=D0=B0=D1=80=20?= =?UTF-8?q?=D0=9C=D0=B5=D0=B4=D1=85=D0=B0=D1=82=20=7C=20Zaid=20Omar=20Medh?= =?UTF-8?q?at?= Date: Fri, 10 Jul 2026 15:32:34 +0500 Subject: [PATCH] simple chat init --- .../1720000000002_rooms_messages.cjs | 52 +++++++ packages/backend/src/app.ts | 17 +++ packages/backend/src/db/schema.ts | 30 ++++ .../backend/src/modules/messages/index.ts | 6 + .../src/modules/messages/messages.mapper.ts | 20 +++ .../modules/messages/messages.repository.ts | 100 ++++++++++++ .../src/modules/messages/messages.routes.ts | 69 +++++++++ .../src/modules/messages/messages.service.ts | 81 ++++++++++ .../backend/src/modules/realtime/index.ts | 2 +- .../src/modules/realtime/realtime.routes.ts | 8 +- .../src/modules/realtime/realtime.service.ts | 39 +++-- packages/backend/src/modules/rooms/index.ts | 6 + .../backend/src/modules/rooms/rooms.mapper.ts | 21 +++ .../src/modules/rooms/rooms.repository.ts | 103 +++++++++++++ .../backend/src/modules/rooms/rooms.routes.ts | 144 ++++++++++++++++++ .../src/modules/rooms/rooms.service.ts | 101 ++++++++++++ packages/backend/src/plugins/db.ts | 4 + packages/backend/src/shared/publisher.ts | 3 + packages/core/package.json | 1 + packages/core/src/api/index.ts | 3 + packages/core/src/api/messages.ts | 37 +++++ packages/core/src/api/rooms.ts | 45 ++++++ packages/core/src/channels/index.ts | 8 + packages/core/src/events/index.ts | 18 +++ packages/core/src/index.ts | 1 + packages/core/src/messages/index.ts | 39 +++++ packages/core/src/schemas/entities.ts | 59 +++++++ packages/core/src/schemas/index.ts | 8 + packages/core/src/schemas/room.ts | 36 +++++ packages/core/src/types/index.ts | 1 + packages/core/src/types/message.ts | 16 +- packages/core/src/types/room.ts | 15 ++ packages/web/src/app/App.tsx | 65 ++++++-- packages/web/src/app/index.css | 134 ++++++++++++++++ packages/web/src/features/messaging/index.ts | 3 + packages/web/src/features/messaging/model.ts | 86 +++++++++++ .../src/features/messaging/ui/ChatView.tsx | 53 +++++++ packages/web/src/features/realtime/index.ts | 1 - packages/web/src/features/realtime/model.tsx | 62 +++++--- .../features/realtime/ui/ConnectionPanel.tsx | 55 ------- packages/web/src/features/rooms/index.ts | 3 + packages/web/src/features/rooms/model.ts | 50 ++++++ .../web/src/features/rooms/ui/RoomSidebar.tsx | 104 +++++++++++++ 43 files changed, 1601 insertions(+), 108 deletions(-) create mode 100644 packages/backend/migrations/1720000000002_rooms_messages.cjs create mode 100644 packages/backend/src/modules/messages/index.ts create mode 100644 packages/backend/src/modules/messages/messages.mapper.ts create mode 100644 packages/backend/src/modules/messages/messages.repository.ts create mode 100644 packages/backend/src/modules/messages/messages.routes.ts create mode 100644 packages/backend/src/modules/messages/messages.service.ts create mode 100644 packages/backend/src/modules/rooms/index.ts create mode 100644 packages/backend/src/modules/rooms/rooms.mapper.ts create mode 100644 packages/backend/src/modules/rooms/rooms.repository.ts create mode 100644 packages/backend/src/modules/rooms/rooms.routes.ts create mode 100644 packages/backend/src/modules/rooms/rooms.service.ts create mode 100644 packages/backend/src/shared/publisher.ts create mode 100644 packages/core/src/api/messages.ts create mode 100644 packages/core/src/api/rooms.ts create mode 100644 packages/core/src/messages/index.ts create mode 100644 packages/core/src/schemas/room.ts create mode 100644 packages/core/src/types/room.ts create mode 100644 packages/web/src/features/messaging/index.ts create mode 100644 packages/web/src/features/messaging/model.ts create mode 100644 packages/web/src/features/messaging/ui/ChatView.tsx delete mode 100644 packages/web/src/features/realtime/ui/ConnectionPanel.tsx create mode 100644 packages/web/src/features/rooms/index.ts create mode 100644 packages/web/src/features/rooms/model.ts create mode 100644 packages/web/src/features/rooms/ui/RoomSidebar.tsx diff --git a/packages/backend/migrations/1720000000002_rooms_messages.cjs b/packages/backend/migrations/1720000000002_rooms_messages.cjs new file mode 100644 index 0000000..758ad5e --- /dev/null +++ b/packages/backend/migrations/1720000000002_rooms_messages.cjs @@ -0,0 +1,52 @@ +// Phase 3 — rooms, membership, and messages. +// Messages use a crypto-agnostic content envelope so E2EE can be layered on +// later with no schema change (content holds plaintext now, ciphertext later). + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = (pgm) => { + pgm.createTable('rooms', { + id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') }, + name: { type: 'text', notNull: true }, + created_by: { type: 'uuid', notNull: true, references: 'users', onDelete: 'RESTRICT' }, + created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + }); + + pgm.createTable('room_members', { + room_id: { type: 'uuid', notNull: true, references: 'rooms', onDelete: 'CASCADE' }, + user_id: { type: 'uuid', notNull: true, references: 'users', onDelete: 'CASCADE' }, + role: { type: 'text', notNull: true, default: 'member' }, + joined_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + }); + pgm.addConstraint('room_members', 'room_members_pkey', { + primaryKey: ['room_id', 'user_id'], + }); + pgm.createIndex('room_members', 'user_id'); + + pgm.createTable('messages', { + id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') }, + // Server-assigned monotonic ordering. + seq: { type: 'bigserial', notNull: true }, + room_id: { type: 'uuid', notNull: true, references: 'rooms', onDelete: 'CASCADE' }, + sender_id: { type: 'uuid', notNull: true, references: 'users', onDelete: 'RESTRICT' }, + // Client-generated idempotency / dedupe key. + client_msg_id: { type: 'text', notNull: true }, + // Opaque content envelope (see file header). + content: { type: 'text', notNull: true }, + content_type: { type: 'text', notNull: true, default: 'text' }, + encryption: { type: 'text' }, + created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + }); + pgm.createIndex('messages', 'seq', { unique: true }); + pgm.createIndex('messages', ['room_id', 'seq']); + // Idempotent sends: one message per (room, sender, client_msg_id). + pgm.addConstraint('messages', 'messages_dedupe_uq', { + unique: ['room_id', 'sender_id', 'client_msg_id'], + }); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = (pgm) => { + pgm.dropTable('messages'); + pgm.dropTable('room_members'); + pgm.dropTable('rooms'); +}; diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 93d242a..2be45a3 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -15,7 +15,10 @@ import { swaggerPlugin } from './plugins/swagger'; import { healthRoutes } from './routes/health'; import { createUsersRepository, createUsersService, usersRoutes } from './modules/users'; import { createRefreshTokensRepository, createAuthService, authPlugin, authRoutes } from './modules/auth'; +import { createRoomsRepository, createRoomsService, roomsRoutes } from './modules/rooms'; +import { createMessagesRepository, createMessagesService, messagesRoutes } from './modules/messages'; import { realtimeRoutes } from './modules/realtime'; +import type { Publisher } from './shared/publisher'; declare module 'fastify' { interface FastifyInstance { @@ -70,8 +73,12 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise app.centrifugo.publish(channel, data); const usersRepository = createUsersRepository(app.db); const refreshTokens = createRefreshTokensRepository(app.db); + const roomsRepository = createRoomsRepository(app.db); + const messagesRepository = createMessagesRepository(app.db); + app.decorate('usersService', createUsersService(usersRepository)); app.decorate( 'authService', @@ -87,6 +94,14 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise; + name: string; + created_by: string; + created_at: Generated; +} + +export interface RoomMembersTable { + room_id: string; + user_id: string; + role: Generated; + joined_at: Generated; +} + +export interface MessagesTable { + id: Generated; + // int8 — parsed to a JS number by the pg type parser in the db plugin. + seq: Generated; + room_id: string; + sender_id: string; + client_msg_id: string; + content: string; + content_type: Generated; + encryption: string | null; + created_at: Generated; +} + export interface Database { users: UsersTable; refresh_tokens: RefreshTokensTable; + rooms: RoomsTable; + room_members: RoomMembersTable; + messages: MessagesTable; } diff --git a/packages/backend/src/modules/messages/index.ts b/packages/backend/src/modules/messages/index.ts new file mode 100644 index 0000000..7f672ef --- /dev/null +++ b/packages/backend/src/modules/messages/index.ts @@ -0,0 +1,6 @@ +export { createMessagesRepository } from './messages.repository'; +export type { MessagesRepository, MessageWithSenderRow, NewMessage } from './messages.repository'; +export { createMessagesService } from './messages.service'; +export type { MessagesService, MessagesServiceDeps, SentMessage } from './messages.service'; +export { toMessage } from './messages.mapper'; +export { messagesRoutes } from './messages.routes'; diff --git a/packages/backend/src/modules/messages/messages.mapper.ts b/packages/backend/src/modules/messages/messages.mapper.ts new file mode 100644 index 0000000..34b9919 --- /dev/null +++ b/packages/backend/src/modules/messages/messages.mapper.ts @@ -0,0 +1,20 @@ +import type { Message } from '@altricade/core'; +import type { MessageWithSenderRow } from './messages.repository'; + +export const toMessage = (row: MessageWithSenderRow): Message => ({ + id: row.id, + roomId: row.room_id, + senderId: row.sender_id, + sender: { + id: row.sender_id, + username: row.sender_username, + displayName: row.sender_display_name, + avatarUrl: row.sender_avatar_ref, + }, + content: row.content, + contentType: row.content_type, + encryption: row.encryption, + clientMsgId: row.client_msg_id, + seq: row.seq, + createdAt: row.created_at.toISOString(), +}); diff --git a/packages/backend/src/modules/messages/messages.repository.ts b/packages/backend/src/modules/messages/messages.repository.ts new file mode 100644 index 0000000..b04470b --- /dev/null +++ b/packages/backend/src/modules/messages/messages.repository.ts @@ -0,0 +1,100 @@ +import type { Kysely } from 'kysely'; +import type { Database } from '../../db/schema'; + +export interface MessageWithSenderRow { + id: string; + seq: number; + room_id: string; + sender_id: string; + client_msg_id: string; + content: string; + content_type: string; + encryption: string | null; + created_at: Date; + sender_username: string; + sender_display_name: string; + sender_avatar_ref: string | null; +} + +export interface NewMessage { + roomId: string; + senderId: string; + clientMsgId: string; + content: string; + contentType: string; + encryption: string | null; +} + +export interface MessagesRepository { + insert(input: NewMessage): Promise<{ id: string } | undefined>; + findIdByDedupe( + roomId: string, + senderId: string, + clientMsgId: string, + ): Promise; + getWithSenderById(id: string): Promise; + listHistory( + roomId: string, + beforeSeq: number | null, + limit: number, + ): Promise; +} + +export const createMessagesRepository = (db: Kysely): MessagesRepository => { + const withSender = () => + db + .selectFrom('messages') + .innerJoin('users', 'users.id', 'messages.sender_id') + .select([ + 'messages.id as id', + 'messages.seq as seq', + 'messages.room_id as room_id', + 'messages.sender_id as sender_id', + 'messages.client_msg_id as client_msg_id', + 'messages.content as content', + 'messages.content_type as content_type', + 'messages.encryption as encryption', + 'messages.created_at as created_at', + 'users.username as sender_username', + 'users.display_name as sender_display_name', + 'users.avatar_ref as sender_avatar_ref', + ]); + + return { + insert: (input) => + db + .insertInto('messages') + .values({ + room_id: input.roomId, + sender_id: input.senderId, + client_msg_id: input.clientMsgId, + content: input.content, + content_type: input.contentType, + encryption: input.encryption, + }) + .onConflict((oc) => oc.columns(['room_id', 'sender_id', 'client_msg_id']).doNothing()) + .returning('id') + .executeTakeFirst(), + + findIdByDedupe: async (roomId, senderId, clientMsgId) => { + const row = await db + .selectFrom('messages') + .select('id') + .where('room_id', '=', roomId) + .where('sender_id', '=', senderId) + .where('client_msg_id', '=', clientMsgId) + .executeTakeFirst(); + return row?.id; + }, + + getWithSenderById: (id) => withSender().where('messages.id', '=', id).executeTakeFirst(), + + listHistory: (roomId, beforeSeq, limit) => { + let query = withSender().where('messages.room_id', '=', roomId); + if (beforeSeq !== null) { + query = query.where('messages.seq', '<', beforeSeq); + } + return query.orderBy('messages.seq', 'desc').limit(limit).execute(); + }, + }; +}; diff --git a/packages/backend/src/modules/messages/messages.routes.ts b/packages/backend/src/modules/messages/messages.routes.ts new file mode 100644 index 0000000..1002fd7 --- /dev/null +++ b/packages/backend/src/modules/messages/messages.routes.ts @@ -0,0 +1,69 @@ +import type { FastifyInstance } from 'fastify'; +import { sendMessageBodySchema, messageSchema, messageListSchema, errorSchema } from '@altricade/core'; +import type { SendMessageBody } from '@altricade/core'; + +const bearerAuth = [{ bearerAuth: [] }]; +const roomParamsSchema = { + type: 'object', + required: ['id'], + properties: { id: { type: 'string', format: 'uuid' } }, +} as const; +const historyQuerySchema = { + type: 'object', + properties: { + before: { type: 'integer', minimum: 1 }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 50 }, + }, +} as const; + +export const messagesRoutes = (app: FastifyInstance): Promise => { + app.get<{ Params: { id: string }; Querystring: { before?: number; limit: number } }>( + '/rooms/:id/messages', + { + schema: { + tags: ['messages'], + summary: 'Load room message history (newest first)', + security: bearerAuth, + params: roomParamsSchema, + querystring: historyQuerySchema, + response: { 200: messageListSchema, 401: errorSchema, 403: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + const before = request.query.before ?? null; + const history = await app.messagesService.history( + request.params.id, + user.id, + before, + request.query.limit, + ); + return reply.send(history); + }, + ); + + app.post<{ Params: { id: string }; Body: SendMessageBody }>( + '/rooms/:id/messages', + { + schema: { + tags: ['messages'], + summary: 'Send a message to a room', + security: bearerAuth, + params: roomParamsSchema, + body: sendMessageBodySchema, + response: { 200: messageSchema, 201: messageSchema, 401: errorSchema, 403: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + const result = await app.messagesService.send(request.params.id, user.id, request.body); + return reply.code(result.created ? 201 : 200).send(result.message); + }, + ); + + return Promise.resolve(); +}; diff --git a/packages/backend/src/modules/messages/messages.service.ts b/packages/backend/src/modules/messages/messages.service.ts new file mode 100644 index 0000000..46c4d7d --- /dev/null +++ b/packages/backend/src/modules/messages/messages.service.ts @@ -0,0 +1,81 @@ +import type { Message, MessageNewEvent, SendMessageBody } from '@altricade/core'; +import { roomChannel } from '@altricade/core'; +import { HttpError } from '../../shared/http-error'; +import type { Publisher } from '../../shared/publisher'; +import type { RoomsRepository } from '../rooms'; +import type { MessagesRepository } from './messages.repository'; +import { toMessage } from './messages.mapper'; + +export interface MessagesServiceDeps { + messages: MessagesRepository; + rooms: RoomsRepository; + publish: Publisher; +} + +export interface SentMessage { + message: Message; + created: boolean; +} + +export interface MessagesService { + send(roomId: string, senderId: string, input: SendMessageBody): Promise; + history( + roomId: string, + userId: string, + beforeSeq: number | null, + limit: number, + ): Promise; +} + +export const createMessagesService = (deps: MessagesServiceDeps): MessagesService => { + const { messages, rooms, publish } = deps; + + const assertMember = async (roomId: string, userId: string): Promise => { + if (!(await rooms.isMember(roomId, userId))) { + throw new HttpError(403, 'not_a_member', 'You are not a member of this room'); + } + }; + + return { + send: async (roomId, senderId, input) => { + await assertMember(roomId, senderId); + + const inserted = await messages.insert({ + roomId, + senderId, + clientMsgId: input.clientMsgId, + content: input.content, + contentType: input.contentType ?? 'text', + encryption: input.encryption ?? null, + }); + + const id = inserted?.id ?? (await messages.findIdByDedupe(roomId, senderId, input.clientMsgId)); + if (id === undefined) { + throw new HttpError(500, 'internal_error', 'Message could not be persisted'); + } + const row = await messages.getWithSenderById(id); + if (row === undefined) { + throw new HttpError(500, 'internal_error', 'Message not found after insert'); + } + const message = toMessage(row); + + // Publish only for a genuinely new message (not an idempotent replay). + if (inserted !== undefined) { + const event: MessageNewEvent = { type: 'message.new', message }; + await publish(roomChannel(roomId), event); + } + return { message, created: inserted !== undefined }; + }, + + history: async (roomId, userId, beforeSeq, limit) => { + await assertMember(roomId, userId); + return (await messages.listHistory(roomId, beforeSeq, limit)).map(toMessage); + }, + }; +}; + +declare module 'fastify' { + interface FastifyInstance { + messagesService: MessagesService; + } +} diff --git a/packages/backend/src/modules/realtime/index.ts b/packages/backend/src/modules/realtime/index.ts index 87d341b..1e606c4 100644 --- a/packages/backend/src/modules/realtime/index.ts +++ b/packages/backend/src/modules/realtime/index.ts @@ -1,3 +1,3 @@ export { authorizeSubscription } from './realtime.service'; -export type { SubscribeDecision } from './realtime.service'; +export type { SubscribeDecision, SubscribeDeps } from './realtime.service'; export { realtimeRoutes } from './realtime.routes'; diff --git a/packages/backend/src/modules/realtime/realtime.routes.ts b/packages/backend/src/modules/realtime/realtime.routes.ts index 4c5c639..15d7ca0 100644 --- a/packages/backend/src/modules/realtime/realtime.routes.ts +++ b/packages/backend/src/modules/realtime/realtime.routes.ts @@ -27,9 +27,13 @@ export const realtimeRoutes = (app: FastifyInstance): Promise => { app.post<{ Body: { user?: string; channel: string } }>( '/centrifugo/subscribe', { schema: { body: subscribeProxyBodySchema } }, - (request, reply) => { + async (request, reply) => { const { user, channel } = request.body; - const decision = authorizeSubscription(user ?? '', channel); + const decision = await authorizeSubscription( + { isRoomMember: (roomId, userId) => app.roomsService.isMember(roomId, userId) }, + user ?? '', + channel, + ); if (!decision.allowed) { return reply.send({ error: { code: decision.errorCode, message: decision.errorMessage } }); } diff --git a/packages/backend/src/modules/realtime/realtime.service.ts b/packages/backend/src/modules/realtime/realtime.service.ts index 68fcfb2..a2011d9 100644 --- a/packages/backend/src/modules/realtime/realtime.service.ts +++ b/packages/backend/src/modules/realtime/realtime.service.ts @@ -1,21 +1,38 @@ -import { isUserChannel, isRoomChannel, userChannel } from '@altricade/core'; +import { isUserChannel, roomChannelId, userChannel } from '@altricade/core'; export type SubscribeDecision = | { allowed: true } | { allowed: false; errorCode: number; errorMessage: string }; +export interface SubscribeDeps { + isRoomMember: (roomId: string, userId: string) => Promise; +} + +const deny = (errorMessage: string): SubscribeDecision => ({ + allowed: false, + errorCode: 103, + errorMessage, +}); + // Subscription authorization for the Centrifugo subscribe-proxy. -// user: → allowed only for that user's own personal channel. -// room: → denied for now; DB membership check lands in Phase 3. -export const authorizeSubscription = (userId: string, channel: string): SubscribeDecision => { +// user: → only that user's own personal channel. +// room: → only current members (live DB check → kicked users lose access). +export const authorizeSubscription = async ( + deps: SubscribeDeps, + userId: string, + channel: string, +): Promise => { if (isUserChannel(channel)) { - if (channel === userChannel(userId)) { - return { allowed: true }; + return channel === userChannel(userId) ? { allowed: true } : deny('permission denied'); + } + + const roomId = roomChannelId(channel); + if (roomId !== null) { + if (userId === '') { + return deny('permission denied'); } - return { allowed: false, errorCode: 103, errorMessage: 'permission denied' }; + return (await deps.isRoomMember(roomId, userId)) ? { allowed: true } : deny('not a member'); } - if (isRoomChannel(channel)) { - return { allowed: false, errorCode: 103, errorMessage: 'room membership not available yet' }; - } - return { allowed: false, errorCode: 103, errorMessage: 'unknown channel' }; + + return deny('unknown channel'); }; diff --git a/packages/backend/src/modules/rooms/index.ts b/packages/backend/src/modules/rooms/index.ts new file mode 100644 index 0000000..c640071 --- /dev/null +++ b/packages/backend/src/modules/rooms/index.ts @@ -0,0 +1,6 @@ +export { createRoomsRepository } from './rooms.repository'; +export type { RoomsRepository, RoomRow, RoomMemberWithUser } from './rooms.repository'; +export { createRoomsService } from './rooms.service'; +export type { RoomsService, RoomsServiceDeps } from './rooms.service'; +export { toRoom, toRoomMember } from './rooms.mapper'; +export { roomsRoutes } from './rooms.routes'; diff --git a/packages/backend/src/modules/rooms/rooms.mapper.ts b/packages/backend/src/modules/rooms/rooms.mapper.ts new file mode 100644 index 0000000..15d095d --- /dev/null +++ b/packages/backend/src/modules/rooms/rooms.mapper.ts @@ -0,0 +1,21 @@ +import type { Room, RoomMember } from '@altricade/core'; +import type { RoomRow, RoomMemberWithUser } from './rooms.repository'; + +export const toRoom = (row: RoomRow): Room => ({ + id: row.id, + name: row.name, + createdBy: row.created_by, + createdAt: row.created_at.toISOString(), +}); + +export const toRoomMember = (row: RoomMemberWithUser): RoomMember => ({ + userId: row.user_id, + role: row.role, + joinedAt: row.joined_at.toISOString(), + user: { + id: row.user_id, + username: row.username, + displayName: row.display_name, + avatarUrl: row.avatar_ref, + }, +}); diff --git a/packages/backend/src/modules/rooms/rooms.repository.ts b/packages/backend/src/modules/rooms/rooms.repository.ts new file mode 100644 index 0000000..5582760 --- /dev/null +++ b/packages/backend/src/modules/rooms/rooms.repository.ts @@ -0,0 +1,103 @@ +import type { Kysely, Selectable } from 'kysely'; +import type { Database, RoomsTable } from '../../db/schema'; + +export type RoomRow = Selectable; + +export interface RoomMemberWithUser { + user_id: string; + role: string; + joined_at: Date; + username: string; + display_name: string; + avatar_ref: string | null; +} + +export interface RoomsRepository { + createWithOwner(name: string, ownerId: string): Promise; + findById(id: string): Promise; + listForUser(userId: string): Promise; + isMember(roomId: string, userId: string): Promise; + getRole(roomId: string, userId: string): Promise; + addMember(roomId: string, userId: string, role: string): Promise; + removeMember(roomId: string, userId: string): Promise; + listMembers(roomId: string): Promise; +} + +export const createRoomsRepository = (db: Kysely): RoomsRepository => ({ + createWithOwner: (name, ownerId) => + db.transaction().execute(async (trx) => { + const room = await trx + .insertInto('rooms') + .values({ name, created_by: ownerId }) + .returningAll() + .executeTakeFirstOrThrow(); + await trx + .insertInto('room_members') + .values({ room_id: room.id, user_id: ownerId, role: 'owner' }) + .execute(); + return room; + }), + + findById: (id) => db.selectFrom('rooms').selectAll().where('id', '=', id).executeTakeFirst(), + + listForUser: (userId) => + db + .selectFrom('rooms') + .innerJoin('room_members', 'room_members.room_id', 'rooms.id') + .where('room_members.user_id', '=', userId) + .selectAll('rooms') + .orderBy('rooms.created_at', 'desc') + .execute(), + + isMember: async (roomId, userId) => { + const row = await db + .selectFrom('room_members') + .select('user_id') + .where('room_id', '=', roomId) + .where('user_id', '=', userId) + .executeTakeFirst(); + return row !== undefined; + }, + + getRole: async (roomId, userId) => { + const row = await db + .selectFrom('room_members') + .select('role') + .where('room_id', '=', roomId) + .where('user_id', '=', userId) + .executeTakeFirst(); + return row?.role; + }, + + addMember: async (roomId, userId, role) => { + await db + .insertInto('room_members') + .values({ room_id: roomId, user_id: userId, role }) + .onConflict((oc) => oc.columns(['room_id', 'user_id']).doNothing()) + .execute(); + }, + + removeMember: async (roomId, userId) => { + await db + .deleteFrom('room_members') + .where('room_id', '=', roomId) + .where('user_id', '=', userId) + .execute(); + }, + + listMembers: (roomId) => + db + .selectFrom('room_members') + .innerJoin('users', 'users.id', 'room_members.user_id') + .where('room_members.room_id', '=', roomId) + .select([ + 'room_members.user_id as user_id', + 'room_members.role as role', + 'room_members.joined_at as joined_at', + 'users.username as username', + 'users.display_name as display_name', + 'users.avatar_ref as avatar_ref', + ]) + .orderBy('room_members.joined_at', 'asc') + .execute(), +}); diff --git a/packages/backend/src/modules/rooms/rooms.routes.ts b/packages/backend/src/modules/rooms/rooms.routes.ts new file mode 100644 index 0000000..58823a5 --- /dev/null +++ b/packages/backend/src/modules/rooms/rooms.routes.ts @@ -0,0 +1,144 @@ +import type { FastifyInstance } from 'fastify'; +import { + createRoomBodySchema, + addMemberBodySchema, + roomSchema, + roomListSchema, + roomMemberSchema, + roomMemberListSchema, + errorSchema, +} from '@altricade/core'; +import type { CreateRoomBody, AddMemberBody } from '@altricade/core'; + +const bearerAuth = [{ bearerAuth: [] }]; +const roomParamsSchema = { + type: 'object', + required: ['id'], + properties: { id: { type: 'string', format: 'uuid' } }, +} as const; +const memberParamsSchema = { + type: 'object', + required: ['id', 'userId'], + properties: { id: { type: 'string', format: 'uuid' }, userId: { type: 'string', format: 'uuid' } }, +} as const; + +export const roomsRoutes = (app: FastifyInstance): Promise => { + app.post<{ Body: CreateRoomBody }>( + '/rooms', + { + schema: { + tags: ['rooms'], + summary: 'Create a room', + security: bearerAuth, + body: createRoomBodySchema, + response: { 201: roomSchema, 401: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + const room = await app.roomsService.create(request.body.name, user.id); + return reply.code(201).send(room); + }, + ); + + app.get( + '/rooms', + { + schema: { + tags: ['rooms'], + summary: 'List rooms I belong to', + security: bearerAuth, + response: { 200: roomListSchema, 401: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + return reply.send(await app.roomsService.listMine(user.id)); + }, + ); + + app.get<{ Params: { id: string } }>( + '/rooms/:id', + { + schema: { + tags: ['rooms'], + summary: 'Get a room (member only)', + security: bearerAuth, + params: roomParamsSchema, + response: { 200: roomSchema, 401: errorSchema, 403: errorSchema, 404: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + return reply.send(await app.roomsService.get(request.params.id, user.id)); + }, + ); + + app.get<{ Params: { id: string } }>( + '/rooms/:id/members', + { + schema: { + tags: ['rooms'], + summary: 'List room members (member only)', + security: bearerAuth, + params: roomParamsSchema, + response: { 200: roomMemberListSchema, 401: errorSchema, 403: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + return reply.send(await app.roomsService.listMembers(request.params.id, user.id)); + }, + ); + + app.post<{ Params: { id: string }; Body: AddMemberBody }>( + '/rooms/:id/members', + { + schema: { + tags: ['rooms'], + summary: 'Add a member by username (owner only)', + security: bearerAuth, + params: roomParamsSchema, + body: addMemberBodySchema, + response: { 201: roomMemberSchema, 401: errorSchema, 403: errorSchema, 404: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + const member = await app.roomsService.addMember(request.params.id, user.id, request.body.username); + return reply.code(201).send(member); + }, + ); + + app.delete<{ Params: { id: string; userId: string } }>( + '/rooms/:id/members/:userId', + { + schema: { + tags: ['rooms'], + summary: 'Remove a member (owner) or leave (self)', + security: bearerAuth, + params: memberParamsSchema, + response: { 401: errorSchema, 403: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) return reply.code(401).send({ error: 'unauthorized' }); + await app.roomsService.removeMember(request.params.id, user.id, request.params.userId); + return reply.code(204).send(); + }, + ); + + return Promise.resolve(); +}; diff --git a/packages/backend/src/modules/rooms/rooms.service.ts b/packages/backend/src/modules/rooms/rooms.service.ts new file mode 100644 index 0000000..0349f82 --- /dev/null +++ b/packages/backend/src/modules/rooms/rooms.service.ts @@ -0,0 +1,101 @@ +import type { Room, RoomMember, RoomMembershipEvent } from '@altricade/core'; +import { userChannel } from '@altricade/core'; +import { HttpError } from '../../shared/http-error'; +import type { Publisher } from '../../shared/publisher'; +import type { UsersRepository } from '../users'; +import type { RoomsRepository } from './rooms.repository'; +import { toRoom, toRoomMember } from './rooms.mapper'; + +export interface RoomsServiceDeps { + rooms: RoomsRepository; + users: UsersRepository; + publish: Publisher; +} + +export interface RoomsService { + create(name: string, ownerId: string): Promise; + get(roomId: string, userId: string): Promise; + listMine(userId: string): Promise; + listMembers(roomId: string, userId: string): Promise; + addMember(roomId: string, actorId: string, username: string): Promise; + removeMember(roomId: string, actorId: string, targetUserId: string): Promise; + isMember(roomId: string, userId: string): Promise; + assertMember(roomId: string, userId: string): Promise; +} + +export const createRoomsService = (deps: RoomsServiceDeps): RoomsService => { + const { rooms, users, publish } = deps; + + const assertMember = async (roomId: string, userId: string): Promise => { + if (!(await rooms.isMember(roomId, userId))) { + throw new HttpError(403, 'not_a_member', 'You are not a member of this room'); + } + }; + + const assertOwner = async (roomId: string, userId: string): Promise => { + const role = await rooms.getRole(roomId, userId); + if (role !== 'owner') { + throw new HttpError(403, 'not_owner', 'Only the room owner can do this'); + } + }; + + const membershipEvent = ( + action: RoomMembershipEvent['action'], + roomId: string, + userId: string, + ): RoomMembershipEvent => ({ type: 'room.membership', action, roomId, userId }); + + return { + create: async (name, ownerId) => toRoom(await rooms.createWithOwner(name, ownerId)), + + get: async (roomId, userId) => { + await assertMember(roomId, userId); + const room = await rooms.findById(roomId); + if (room === undefined) { + throw new HttpError(404, 'not_found', 'Room not found'); + } + return toRoom(room); + }, + + listMine: async (userId) => (await rooms.listForUser(userId)).map(toRoom), + + listMembers: async (roomId, userId) => { + await assertMember(roomId, userId); + return (await rooms.listMembers(roomId)).map(toRoomMember); + }, + + addMember: async (roomId, actorId, username) => { + await assertOwner(roomId, actorId); + const target = await users.findByUsername(username); + if (target === undefined) { + throw new HttpError(404, 'user_not_found', 'No such user'); + } + await rooms.addMember(roomId, target.id, 'member'); + await publish(userChannel(target.id), membershipEvent('added', roomId, target.id)); + const members = await rooms.listMembers(roomId); + const added = members.find((m) => m.user_id === target.id); + if (added === undefined) { + throw new HttpError(500, 'internal_error', 'Member not found after add'); + } + return toRoomMember(added); + }, + + removeMember: async (roomId, actorId, targetUserId) => { + if (actorId !== targetUserId) { + await assertOwner(roomId, actorId); + } + await rooms.removeMember(roomId, targetUserId); + await publish(userChannel(targetUserId), membershipEvent('removed', roomId, targetUserId)); + }, + + isMember: (roomId, userId) => rooms.isMember(roomId, userId), + + assertMember, + }; +}; + +declare module 'fastify' { + interface FastifyInstance { + roomsService: RoomsService; + } +} diff --git a/packages/backend/src/plugins/db.ts b/packages/backend/src/plugins/db.ts index 9ced507..3404553 100644 --- a/packages/backend/src/plugins/db.ts +++ b/packages/backend/src/plugins/db.ts @@ -3,6 +3,10 @@ import pg from 'pg'; import { Kysely, PostgresDialect } from 'kysely'; import type { Database } from '../db/schema'; +// Parse int8 (OID 20) as a JS number. Safe well past any realistic message +// count (Number.MAX_SAFE_INTEGER ≈ 9e15); revisit if a counter could exceed it. +pg.types.setTypeParser(20, (value) => Number(value)); + declare module 'fastify' { interface FastifyInstance { db: Kysely; diff --git a/packages/backend/src/shared/publisher.ts b/packages/backend/src/shared/publisher.ts new file mode 100644 index 0000000..2ea1e20 --- /dev/null +++ b/packages/backend/src/shared/publisher.ts @@ -0,0 +1,3 @@ +// Injected into services that need to publish realtime events, so they depend on +// a function rather than reaching into the Centrifugo plugin decoration directly. +export type Publisher = (channel: string, data: unknown) => Promise; diff --git a/packages/core/package.json b/packages/core/package.json index 9c5987f..053bc2a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -9,6 +9,7 @@ "./events": "./src/events/index.ts", "./types": "./src/types/index.ts", "./schemas": "./src/schemas/index.ts", + "./messages": "./src/messages/index.ts", "./api": "./src/api/index.ts", "./realtime": "./src/realtime/index.ts" }, diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index 992e2d1..d986b98 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -13,3 +13,6 @@ export { getCentrifugoToken, } from './auth'; export { sendEcho } from './realtime'; +export { createRoom, listRooms, getRoom, listMembers, addMember, removeMember } from './rooms'; +export { sendMessage, getHistory } from './messages'; +export type { HistoryOptions } from './messages'; diff --git a/packages/core/src/api/messages.ts b/packages/core/src/api/messages.ts new file mode 100644 index 0000000..8cfae21 --- /dev/null +++ b/packages/core/src/api/messages.ts @@ -0,0 +1,37 @@ +import { messageSchema, messageListSchema } from '../schemas/index'; +import type { SendMessageBody } from '../schemas/index'; +import type { Message } from '../types/index'; +import { compileValidator, parse, requestJson } from './http'; +import type { ApiClientConfig } from './http'; + +const messageV = compileValidator(messageSchema); +const messageListV = compileValidator(messageListSchema); + +export const sendMessage = async ( + config: ApiClientConfig, + roomId: string, + body: SendMessageBody, +): Promise => + parse(messageV, await requestJson(config, 'POST', `/rooms/${roomId}/messages`, body)); + +export interface HistoryOptions { + before?: number; + limit?: number; +} + +export const getHistory = async ( + config: ApiClientConfig, + roomId: string, + options: HistoryOptions = {}, +): Promise => { + const params = new URLSearchParams(); + if (options.before !== undefined) { + params.set('before', String(options.before)); + } + if (options.limit !== undefined) { + params.set('limit', String(options.limit)); + } + const qs = params.toString(); + const path = qs.length > 0 ? `/rooms/${roomId}/messages?${qs}` : `/rooms/${roomId}/messages`; + return parse(messageListV, await requestJson(config, 'GET', path)); +}; diff --git a/packages/core/src/api/rooms.ts b/packages/core/src/api/rooms.ts new file mode 100644 index 0000000..b69317a --- /dev/null +++ b/packages/core/src/api/rooms.ts @@ -0,0 +1,45 @@ +import { + roomSchema, + roomListSchema, + roomMemberSchema, + roomMemberListSchema, +} from '../schemas/index'; +import type { CreateRoomBody, AddMemberBody } from '../schemas/index'; +import type { Room, RoomMember } from '../types/index'; +import { compileValidator, parse, requestJson } from './http'; +import type { ApiClientConfig } from './http'; + +const roomV = compileValidator(roomSchema); +const roomListV = compileValidator(roomListSchema); +const memberV = compileValidator(roomMemberSchema); +const memberListV = compileValidator(roomMemberListSchema); + +export const createRoom = async (config: ApiClientConfig, body: CreateRoomBody): Promise => + parse(roomV, await requestJson(config, 'POST', '/rooms', body)); + +export const listRooms = async (config: ApiClientConfig): Promise => + parse(roomListV, await requestJson(config, 'GET', '/rooms')); + +export const getRoom = async (config: ApiClientConfig, roomId: string): Promise => + parse(roomV, await requestJson(config, 'GET', `/rooms/${roomId}`)); + +export const listMembers = async ( + config: ApiClientConfig, + roomId: string, +): Promise => + parse(memberListV, await requestJson(config, 'GET', `/rooms/${roomId}/members`)); + +export const addMember = async ( + config: ApiClientConfig, + roomId: string, + body: AddMemberBody, +): Promise => + parse(memberV, await requestJson(config, 'POST', `/rooms/${roomId}/members`, body)); + +export const removeMember = async ( + config: ApiClientConfig, + roomId: string, + userId: string, +): Promise => { + await requestJson(config, 'DELETE', `/rooms/${roomId}/members/${userId}`); +}; diff --git a/packages/core/src/channels/index.ts b/packages/core/src/channels/index.ts index 5b6c594..8be0a65 100644 --- a/packages/core/src/channels/index.ts +++ b/packages/core/src/channels/index.ts @@ -19,3 +19,11 @@ export const isRoomChannel = (channel: string): boolean => channel.startsWith(RO /** True for a `user:` channel name. */ export const isUserChannel = (channel: string): boolean => channel.startsWith(USER_PREFIX); + +/** Extract the room id from a `room:` channel, or null. */ +export const roomChannelId = (channel: string): string | null => + channel.startsWith(ROOM_PREFIX) ? channel.slice(ROOM_PREFIX.length) : null; + +/** Extract the user id from a `user:` channel, or null. */ +export const userChannelId = (channel: string): string | null => + channel.startsWith(USER_PREFIX) ? channel.slice(USER_PREFIX.length) : null; diff --git a/packages/core/src/events/index.ts b/packages/core/src/events/index.ts index 9fc0e8f..5beafca 100644 --- a/packages/core/src/events/index.ts +++ b/packages/core/src/events/index.ts @@ -6,6 +6,8 @@ // Bucket B — ephemeral, throwaway → published, not persisted // Bucket C — connection-derived → Centrifugo built-in presence +import type { Message } from '../types/message'; + export const EventType = { // Bucket A MessageNew: 'message.new', @@ -29,3 +31,19 @@ export const EventType = { } as const; export type EventType = (typeof EventType)[keyof typeof EventType]; + +// --- Realtime event payloads (published by the backend, handled by clients) --- + +export interface MessageNewEvent { + type: 'message.new'; + message: Message; +} + +export type RoomMembershipAction = 'added' | 'removed'; + +export interface RoomMembershipEvent { + type: 'room.membership'; + action: RoomMembershipAction; + roomId: string; + userId: string; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 598d01d..5f380b5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,3 +3,4 @@ export * from './channels/index'; export * from './events/index'; export * from './types/index'; export * from './schemas/index'; +export * from './messages/index'; diff --git a/packages/core/src/messages/index.ts b/packages/core/src/messages/index.ts new file mode 100644 index 0000000..96b002c --- /dev/null +++ b/packages/core/src/messages/index.ts @@ -0,0 +1,39 @@ +// Client-side message dedupe + ordering. Messages arrive both live (over the +// socket) and via history load (REST), and can overlap; clients also render +// optimistic sends before the server confirms them. This merges all of that +// into one ordered, duplicate-free list. + +export interface OrderedMessage { + id: string; + clientMsgId: string; + seq: number; +} + +// Sentinel seq for an optimistic (not-yet-confirmed) message so it sorts last +// until the server's confirmation (with a real seq) replaces it. +export const OPTIMISTIC_SEQ = Number.MAX_SAFE_INTEGER; + +export const mergeMessages = ( + current: readonly T[], + incoming: readonly T[], +): T[] => { + // Any optimistic entry whose clientMsgId is confirmed in `incoming` is dropped + // in favor of the confirmed copy. + const confirmedClientIds = new Set( + incoming.map((message) => message.clientMsgId).filter((id) => id.length > 0), + ); + + const byId = new Map(); + for (const message of current) { + if (message.clientMsgId.length > 0 && confirmedClientIds.has(message.clientMsgId)) { + continue; + } + byId.set(message.id, message); + } + // Incoming wins on id collisions (history/live overlap). + for (const message of incoming) { + byId.set(message.id, message); + } + + return [...byId.values()].sort((a, b) => a.seq - b.seq); +}; diff --git a/packages/core/src/schemas/entities.ts b/packages/core/src/schemas/entities.ts index 3b0ce3c..b619b58 100644 --- a/packages/core/src/schemas/entities.ts +++ b/packages/core/src/schemas/entities.ts @@ -89,3 +89,62 @@ export const errorSchema = { message: { type: 'string' }, }, } as const; + +export const roomSchema = { + type: 'object', + additionalProperties: false, + required: ['id', 'name', 'createdBy', 'createdAt'], + properties: { + id: { type: 'string', format: 'uuid' }, + name: { type: 'string' }, + createdBy: { type: 'string', format: 'uuid' }, + createdAt: { type: 'string', format: 'date-time' }, + }, +} as const; + +export const roomListSchema = { type: 'array', items: roomSchema } as const; + +export const roomMemberSchema = { + type: 'object', + additionalProperties: false, + required: ['userId', 'role', 'joinedAt', 'user'], + properties: { + userId: { type: 'string', format: 'uuid' }, + role: { type: 'string' }, + joinedAt: { type: 'string', format: 'date-time' }, + user: publicUserSchema, + }, +} as const; + +export const roomMemberListSchema = { type: 'array', items: roomMemberSchema } as const; + +export const messageSchema = { + type: 'object', + additionalProperties: false, + required: [ + 'id', + 'roomId', + 'senderId', + 'sender', + 'content', + 'contentType', + 'encryption', + 'clientMsgId', + 'seq', + 'createdAt', + ], + properties: { + id: { type: 'string', format: 'uuid' }, + roomId: { type: 'string', format: 'uuid' }, + senderId: { type: 'string', format: 'uuid' }, + sender: publicUserSchema, + content: { type: 'string' }, + contentType: { type: 'string' }, + encryption: { type: ['string', 'null'] }, + clientMsgId: { type: 'string' }, + seq: { type: 'integer' }, + createdAt: { type: 'string', format: 'date-time' }, + }, +} as const; + +export const messageListSchema = { type: 'array', items: messageSchema } as const; diff --git a/packages/core/src/schemas/index.ts b/packages/core/src/schemas/index.ts index 997a6ab..65c5791 100644 --- a/packages/core/src/schemas/index.ts +++ b/packages/core/src/schemas/index.ts @@ -1,5 +1,7 @@ export { registerBodySchema, loginBodySchema, updateMeBodySchema } from './auth'; export type { RegisterBody, LoginBody, UpdateMeBody } from './auth'; +export { createRoomBodySchema, addMemberBodySchema, sendMessageBodySchema } from './room'; +export type { CreateRoomBody, AddMemberBody, SendMessageBody } from './room'; export { publicUserSchema, userSchema, @@ -8,4 +10,10 @@ export { sessionListSchema, centrifugoTokenSchema, errorSchema, + roomSchema, + roomListSchema, + roomMemberSchema, + roomMemberListSchema, + messageSchema, + messageListSchema, } from './entities'; diff --git a/packages/core/src/schemas/room.ts b/packages/core/src/schemas/room.ts new file mode 100644 index 0000000..3023ee5 --- /dev/null +++ b/packages/core/src/schemas/room.ts @@ -0,0 +1,36 @@ +import type { FromSchema } from 'json-schema-to-ts'; + +export const createRoomBodySchema = { + type: 'object', + additionalProperties: false, + required: ['name'], + properties: { + name: { type: 'string', minLength: 1, maxLength: 100 }, + }, +} as const; + +export const addMemberBodySchema = { + type: 'object', + additionalProperties: false, + required: ['username'], + properties: { + username: { type: 'string', pattern: '^[a-zA-Z0-9_]{3,32}$' }, + }, +} as const; + +export const sendMessageBodySchema = { + type: 'object', + additionalProperties: false, + required: ['content', 'clientMsgId'], + properties: { + // Plaintext today; base64 ciphertext once E2EE lands (hence the generous max). + content: { type: 'string', minLength: 1, maxLength: 16000 }, + clientMsgId: { type: 'string', minLength: 1, maxLength: 64 }, + contentType: { type: 'string', enum: ['text', 'text/ciphertext'] }, + encryption: { type: ['string', 'null'] }, + }, +} as const; + +export type CreateRoomBody = FromSchema; +export type AddMemberBody = FromSchema; +export type SendMessageBody = FromSchema; diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 471d54f..a4d89e1 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -3,4 +3,5 @@ export const CORE_VERSION = '0.1.0'; export type { User, PublicUser } from './user'; export type { AuthResult, Session, CentrifugoToken } from './auth'; +export type { Room, RoomMember } from './room'; export type { Message } from './message'; diff --git a/packages/core/src/types/message.ts b/packages/core/src/types/message.ts index 03bcfa9..2cb53e2 100644 --- a/packages/core/src/types/message.ts +++ b/packages/core/src/types/message.ts @@ -1,9 +1,17 @@ -// Placeholder message shape — the full model (server-assigned seq, dedupe id, -// media refs, edits, reactions) is defined in Phases 3–5. +import type { PublicUser } from './user'; + +// A chat message. `content` is a crypto-agnostic envelope: plaintext today, +// base64 ciphertext once E2EE lands — the server never needs to interpret it +// beyond storing/relaying. `seq` is the server-assigned monotonic order. export interface Message { id: string; - conversationId: string; + roomId: string; senderId: string; - body: string; + sender: PublicUser; + content: string; + contentType: string; + encryption: string | null; + clientMsgId: string; + seq: number; createdAt: string; } diff --git a/packages/core/src/types/room.ts b/packages/core/src/types/room.ts new file mode 100644 index 0000000..f2ace06 --- /dev/null +++ b/packages/core/src/types/room.ts @@ -0,0 +1,15 @@ +import type { PublicUser } from './user'; + +export interface Room { + id: string; + name: string; + createdBy: string; + createdAt: string; +} + +export interface RoomMember { + userId: string; + role: string; + joinedAt: string; + user: PublicUser; +} diff --git a/packages/web/src/app/App.tsx b/packages/web/src/app/App.tsx index 351918f..ffa4952 100644 --- a/packages/web/src/app/App.tsx +++ b/packages/web/src/app/App.tsx @@ -1,8 +1,11 @@ +import { useState } from 'react'; import type { ReactElement } from 'react'; -import type { User } from '@altricade/core'; +import type { PublicUser, User } from '@altricade/core'; import { SessionProvider, useSession } from '../entities/session'; import { AuthForm } from '../features/auth'; -import { RealtimeProvider, ConnectionPanel } from '../features/realtime'; +import { RealtimeProvider, useRealtime } from '../features/realtime'; +import { useRooms, RoomSidebar } from '../features/rooms'; +import { ChatView } from '../features/messaging'; import { useTheme } from '../shared/theme'; import type { ThemePreference } from '../shared/theme'; @@ -28,21 +31,51 @@ const ThemeSwitch = (): ReactElement => { ); }; -const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): ReactElement => ( -
-

Altricade

-

- Signed in as @{user.username} ({user.displayName}) -

- -
- +const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): ReactElement => { + const me: PublicUser = { + id: user.id, + username: user.username, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + }; + const { state } = useRealtime(); + const { rooms, createRoom } = useRooms(user.id); + const [currentRoomId, setCurrentRoomId] = useState(null); + + return ( +
+
+ + @{user.username} · socket: {state} + + + + + +
+
+ { + const room = await createRoom(name); + setCurrentRoomId(room.id); + }} + /> + {currentRoomId !== null ? ( + + ) : ( +
+

Select a room, or create one to start chatting.

+
+ )} +
- -
-); + ); +}; const Shell = (): ReactElement => { const { status, user, logout } = useSession(); diff --git a/packages/web/src/app/index.css b/packages/web/src/app/index.css index b1f7100..3e2d990 100644 --- a/packages/web/src/app/index.css +++ b/packages/web/src/app/index.css @@ -144,3 +144,137 @@ body { .actions { margin: 1rem 0; } + +/* Chat layout */ +.layout { + display: flex; + flex-direction: column; + height: 100vh; +} + +.topbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--color-border); + background: var(--color-surface); +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.workspace { + display: flex; + flex: 1; + min-height: 0; +} + +.sidebar { + width: 260px; + border-right: 1px solid var(--color-border); + padding: 1rem; + overflow-y: auto; +} + +.sidebar-form { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.sidebar-form input { + flex: 1; + min-width: 0; + padding: 0.4rem; + border-radius: 8px; + border: 1px solid var(--color-border); + background: var(--color-background); + color: var(--color-text); +} + +.room-list { + list-style: none; + padding: 0; + margin: 0 0 1rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.room-list button { + width: 100%; + text-align: left; + padding: 0.5rem 0.75rem; + border-radius: 8px; + border: 1px solid transparent; + background: none; + color: var(--color-text); + cursor: pointer; +} + +.room-list button[aria-pressed='true'] { + background: var(--color-surface); + border-color: var(--color-accent); + color: var(--color-accent); +} + +.chat { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + padding: 1rem; +} + +.chat-empty { + align-items: center; + justify-content: center; + color: var(--color-textMuted); +} + +.message-list { + list-style: none; + margin: 0; + padding: 0; + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.message-list li { + padding: 0.4rem 0.6rem; + border-radius: 8px; + background: var(--color-surface); +} + +.message-list li.mine { + align-self: flex-end; + background: var(--color-accent); + color: #fff; +} + +.msg-author { + font-weight: 600; + margin-right: 0.5rem; +} + +.composer { + display: flex; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.composer input { + flex: 1; + padding: 0.6rem; + border-radius: 8px; + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text); +} diff --git a/packages/web/src/features/messaging/index.ts b/packages/web/src/features/messaging/index.ts new file mode 100644 index 0000000..c0dc515 --- /dev/null +++ b/packages/web/src/features/messaging/index.ts @@ -0,0 +1,3 @@ +export { useRoomMessages } from './model'; +export type { UseRoomMessages } from './model'; +export { ChatView } from './ui/ChatView'; diff --git a/packages/web/src/features/messaging/model.ts b/packages/web/src/features/messaging/model.ts new file mode 100644 index 0000000..8d9249f --- /dev/null +++ b/packages/web/src/features/messaging/model.ts @@ -0,0 +1,86 @@ +import { useCallback, useEffect, useState } from 'react'; +import type { Message, MessageNewEvent, PublicUser } from '@altricade/core'; +import { roomChannel, mergeMessages, OPTIMISTIC_SEQ } from '@altricade/core'; +import { getHistory, sendMessage } from '@altricade/core/api'; +import { apiConfig } from '../../shared/api'; +import { useRealtime } from '../realtime'; + +export interface UseRoomMessages { + messages: Message[]; + send: (content: string) => Promise; + loading: boolean; +} + +const isMessageNew = (data: unknown): data is MessageNewEvent => { + if (typeof data !== 'object' || data === null) { + return false; + } + if (!('type' in data) || data.type !== 'message.new') { + return false; + } + return 'message' in data; +}; + +export const useRoomMessages = (roomId: string, me: PublicUser): UseRoomMessages => { + const { subscribe } = useRealtime(); + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(true); + + // Reconnect-safe ordering: subscribe FIRST (live events buffer into state), + // THEN load history, then merge/dedupe — so nothing slips through the gap. + useEffect(() => { + let cancelled = false; + setMessages([]); + setLoading(true); + + const unsubscribe = subscribe(roomChannel(roomId), (event) => { + if (isMessageNew(event.data)) { + const incoming = event.data.message; + setMessages((prev) => mergeMessages(prev, [incoming])); + } + }); + + const load = async (): Promise => { + try { + const history = await getHistory(apiConfig, roomId, { limit: 50 }); + if (!cancelled) { + setMessages((prev) => mergeMessages(prev, history)); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + void load(); + + return () => { + cancelled = true; + unsubscribe(); + }; + }, [roomId, subscribe]); + + const send = useCallback( + async (content: string): Promise => { + const clientMsgId = crypto.randomUUID(); + const optimistic: Message = { + id: `optimistic:${clientMsgId}`, + roomId, + senderId: me.id, + sender: me, + content, + contentType: 'text', + encryption: null, + clientMsgId, + seq: OPTIMISTIC_SEQ, + createdAt: new Date().toISOString(), + }; + setMessages((prev) => mergeMessages(prev, [optimistic])); + const confirmed = await sendMessage(apiConfig, roomId, { content, clientMsgId }); + setMessages((prev) => mergeMessages(prev, [confirmed])); + }, + [roomId, me], + ); + + return { messages, send, loading }; +}; diff --git a/packages/web/src/features/messaging/ui/ChatView.tsx b/packages/web/src/features/messaging/ui/ChatView.tsx new file mode 100644 index 0000000..08aa28e --- /dev/null +++ b/packages/web/src/features/messaging/ui/ChatView.tsx @@ -0,0 +1,53 @@ +import { useState } from 'react'; +import type { ReactElement, SyntheticEvent } from 'react'; +import type { PublicUser } from '@altricade/core'; +import { useRoomMessages } from '../model'; + +interface Props { + roomId: string; + me: PublicUser; +} + +export const ChatView = ({ roomId, me }: Props): ReactElement => { + const { messages, send, loading } = useRoomMessages(roomId, me); + const [text, setText] = useState(''); + + const submit = async (event: SyntheticEvent): Promise => { + event.preventDefault(); + const trimmed = text.trim(); + if (trimmed === '') { + return; + } + setText(''); + await send(trimmed); + }; + + return ( +
+ {loading ?

Loading…

: null} +
    + {messages.map((message) => ( +
  • + @{message.sender.username} + {message.content} +
  • + ))} +
+
{ + void submit(event); + }} + > + { + setText(event.target.value); + }} + /> + +
+
+ ); +}; diff --git a/packages/web/src/features/realtime/index.ts b/packages/web/src/features/realtime/index.ts index 2f4f35e..31344ba 100644 --- a/packages/web/src/features/realtime/index.ts +++ b/packages/web/src/features/realtime/index.ts @@ -1,3 +1,2 @@ export { RealtimeProvider, useRealtime } from './model'; export type { RealtimeContextValue } from './model'; -export { ConnectionPanel } from './ui/ConnectionPanel'; diff --git a/packages/web/src/features/realtime/model.tsx b/packages/web/src/features/realtime/model.tsx index 3517ced..28d3cf4 100644 --- a/packages/web/src/features/realtime/model.tsx +++ b/packages/web/src/features/realtime/model.tsx @@ -1,22 +1,22 @@ -import { createContext, useContext, useEffect, useMemo, useState } from 'react'; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import type { ReactElement, ReactNode } from 'react'; import { RealtimeClient } from '@altricade/core/realtime'; import type { ConnectionState, RealtimeEvent } from '@altricade/core/realtime'; import { getCentrifugoToken } from '@altricade/core/api'; -import { userChannel } from '@altricade/core'; import { apiConfig } from '../../shared/api'; import { env } from '../../shared/config'; import { useSession } from '../../entities/session'; +type EventHandler = (event: RealtimeEvent) => void; + export interface RealtimeContextValue { state: ConnectionState; - events: RealtimeEvent[]; - channel: string | null; + /** Subscribe to a channel; returns an unsubscribe cleanup. */ + subscribe: (channel: string, handler: EventHandler) => () => void; } const RealtimeContext = createContext(null); -// Turn a same-origin path into an absolute ws/wss URL (Centrifugo needs a full URL). const resolveWsUrl = (raw: string): string => { if (raw.startsWith('ws://') || raw.startsWith('wss://')) { return raw; @@ -26,15 +26,13 @@ const resolveWsUrl = (raw: string): string => { }; export const RealtimeProvider = ({ children }: { children: ReactNode }): ReactElement => { - const { status, user } = useSession(); + const { status } = useSession(); const [state, setState] = useState('disconnected'); - const [events, setEvents] = useState([]); - - const userId = user?.id ?? null; - const channel = userId === null ? null : userChannel(userId); + const clientRef = useRef(null); + const handlersRef = useRef>>(new Map()); useEffect(() => { - if (status !== 'authenticated' || userId === null) { + if (status !== 'authenticated') { return undefined; } const client = new RealtimeClient({ @@ -42,20 +40,48 @@ export const RealtimeProvider = ({ children }: { children: ReactNode }): ReactEl getToken: async () => (await getCentrifugoToken(apiConfig)).token, onState: setState, onEvent: (event) => { - setEvents((prev) => [...prev, event]); + const handlers = handlersRef.current.get(event.channel); + if (handlers !== undefined) { + for (const handler of handlers) { + handler(event); + } + } }, }); + clientRef.current = client; client.connect(); - client.subscribe(userChannel(userId)); + for (const channel of handlersRef.current.keys()) { + client.subscribe(channel); + } return () => { client.disconnect(); + clientRef.current = null; }; - }, [status, userId]); + }, [status]); - const value = useMemo( - () => ({ state, events, channel }), - [state, events, channel], - ); + const subscribe = useCallback((channel: string, handler: EventHandler): (() => void) => { + let handlers = handlersRef.current.get(channel); + if (handlers === undefined) { + handlers = new Set(); + handlersRef.current.set(channel, handlers); + } + handlers.add(handler); + clientRef.current?.subscribe(channel); + + return () => { + const current = handlersRef.current.get(channel); + if (current === undefined) { + return; + } + current.delete(handler); + if (current.size === 0) { + handlersRef.current.delete(channel); + clientRef.current?.unsubscribe(channel); + } + }; + }, []); + + const value = useMemo(() => ({ state, subscribe }), [state, subscribe]); return {children}; }; diff --git a/packages/web/src/features/realtime/ui/ConnectionPanel.tsx b/packages/web/src/features/realtime/ui/ConnectionPanel.tsx deleted file mode 100644 index 7896214..0000000 --- a/packages/web/src/features/realtime/ui/ConnectionPanel.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useState } from 'react'; -import type { ReactElement, SyntheticEvent } from 'react'; -import { sendEcho, ApiError } from '@altricade/core/api'; -import { apiConfig } from '../../../shared/api'; -import { useRealtime } from '../model'; - -export const ConnectionPanel = (): ReactElement => { - const { state, events, channel } = useRealtime(); - const [text, setText] = useState('hello'); - const [error, setError] = useState(null); - - const echo = async (event: SyntheticEvent): Promise => { - event.preventDefault(); - setError(null); - try { - await sendEcho(apiConfig, text); - } catch (caught) { - setError(caught instanceof ApiError ? caught.message : 'Echo failed'); - } - }; - - return ( -
-
-
socket
-
{state}
-
channel
-
{channel ?? '—'}
-
-
{ - void echo(event); - }} - > - { - setText(event.target.value); - }} - /> - -
- {error !== null ?

{error}

: null} -

Events on {channel ?? 'your channel'}

-
    - {events.map((event, index) => ( -
  • {JSON.stringify(event.data)}
  • - ))} -
-
- ); -}; diff --git a/packages/web/src/features/rooms/index.ts b/packages/web/src/features/rooms/index.ts new file mode 100644 index 0000000..75d2c99 --- /dev/null +++ b/packages/web/src/features/rooms/index.ts @@ -0,0 +1,3 @@ +export { useRooms } from './model'; +export type { UseRooms } from './model'; +export { RoomSidebar } from './ui/RoomSidebar'; diff --git a/packages/web/src/features/rooms/model.ts b/packages/web/src/features/rooms/model.ts new file mode 100644 index 0000000..66fdeef --- /dev/null +++ b/packages/web/src/features/rooms/model.ts @@ -0,0 +1,50 @@ +import { useCallback, useEffect, useState } from 'react'; +import type { Room } from '@altricade/core'; +import { userChannel } from '@altricade/core'; +import { listRooms, createRoom as apiCreateRoom } from '@altricade/core/api'; +import { apiConfig } from '../../shared/api'; +import { useRealtime } from '../realtime'; + +export interface UseRooms { + rooms: Room[]; + loading: boolean; + error: string | null; + createRoom: (name: string) => Promise; + reload: () => Promise; +} + +export const useRooms = (userId: string): UseRooms => { + const { subscribe } = useRealtime(); + const [rooms, setRooms] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const reload = useCallback(async (): Promise => { + try { + setRooms(await listRooms(apiConfig)); + } catch { + setError('Failed to load rooms'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void reload(); + }, [reload]); + + // A room.membership event on the personal channel means we were added/removed. + useEffect(() => { + return subscribe(userChannel(userId), () => { + void reload(); + }); + }, [subscribe, userId, reload]); + + const createRoom = useCallback(async (name: string): Promise => { + const room = await apiCreateRoom(apiConfig, { name }); + setRooms((prev) => [room, ...prev]); + return room; + }, []); + + return { rooms, loading, error, createRoom, reload }; +}; diff --git a/packages/web/src/features/rooms/ui/RoomSidebar.tsx b/packages/web/src/features/rooms/ui/RoomSidebar.tsx new file mode 100644 index 0000000..51f2e42 --- /dev/null +++ b/packages/web/src/features/rooms/ui/RoomSidebar.tsx @@ -0,0 +1,104 @@ +import { useState } from 'react'; +import type { ReactElement, SyntheticEvent } from 'react'; +import type { Room } from '@altricade/core'; +import { ApiError, addMember } from '@altricade/core/api'; +import { apiConfig } from '../../../shared/api'; + +interface Props { + rooms: Room[]; + currentRoomId: string | null; + onSelect: (roomId: string) => void; + onCreate: (name: string) => Promise; +} + +export const RoomSidebar = ({ rooms, currentRoomId, onSelect, onCreate }: Props): ReactElement => { + const [name, setName] = useState(''); + const [memberName, setMemberName] = useState(''); + const [error, setError] = useState(null); + + const submitCreate = async (event: SyntheticEvent): Promise => { + event.preventDefault(); + const trimmed = name.trim(); + if (trimmed === '') { + return; + } + setError(null); + try { + await onCreate(trimmed); + setName(''); + } catch { + setError('Could not create room'); + } + }; + + const submitAddMember = async (event: SyntheticEvent): Promise => { + event.preventDefault(); + const trimmed = memberName.trim(); + if (currentRoomId === null || trimmed === '') { + return; + } + setError(null); + try { + await addMember(apiConfig, currentRoomId, { username: trimmed }); + setMemberName(''); + } catch (caught) { + setError(caught instanceof ApiError ? caught.message : 'Could not add member'); + } + }; + + return ( + + ); +};