diff --git a/.gitignore b/.gitignore index 5702e0a..8f78b68 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,12 @@ target/ .DS_Store .turbo/ +# TypeScript build artifacts +apps/backend/src/**/*.js +apps/backend/src/**/*.js.map +apps/backend/src/**/*.d.ts +apps/backend/src/**/*.d.ts.map + + ISSUES.md IMPLEMENTATION_DOCS.md \ No newline at end of file diff --git a/apps/backend/drizzle.config.d.ts b/apps/backend/drizzle.config.d.ts new file mode 100644 index 0000000..48f7c2e --- /dev/null +++ b/apps/backend/drizzle.config.d.ts @@ -0,0 +1,3 @@ +declare const _default: import("drizzle-kit").Config; +export default _default; +//# sourceMappingURL=drizzle.config.d.ts.map \ No newline at end of file diff --git a/apps/backend/drizzle.config.d.ts.map b/apps/backend/drizzle.config.d.ts.map new file mode 100644 index 0000000..c62be7f --- /dev/null +++ b/apps/backend/drizzle.config.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"drizzle.config.d.ts","sourceRoot":"","sources":["drizzle.config.ts"],"names":[],"mappings":";AAEA,wBAOG"} \ No newline at end of file diff --git a/apps/backend/drizzle.config.js b/apps/backend/drizzle.config.js new file mode 100644 index 0000000..63ca54a --- /dev/null +++ b/apps/backend/drizzle.config.js @@ -0,0 +1,10 @@ +import { defineConfig } from 'drizzle-kit'; +export default defineConfig({ + schema: './src/db/schema.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: process.env['DATABASE_URL'] ?? '', + }, +}); +//# sourceMappingURL=drizzle.config.js.map \ No newline at end of file diff --git a/apps/backend/drizzle.config.js.map b/apps/backend/drizzle.config.js.map new file mode 100644 index 0000000..c6aaa2d --- /dev/null +++ b/apps/backend/drizzle.config.js.map @@ -0,0 +1 @@ +{"version":3,"file":"drizzle.config.js","sourceRoot":"","sources":["drizzle.config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,eAAe,YAAY,CAAC;IAC1B,MAAM,EAAE,oBAAoB;IAC5B,GAAG,EAAE,WAAW;IAChB,OAAO,EAAE,YAAY;IACrB,aAAa,EAAE;QACb,GAAG,EAAE,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,IAAI,EAAE;KACvC;CACF,CAAC,CAAC"} \ No newline at end of file diff --git a/apps/backend/src/__tests__/conversations.cache.test.ts b/apps/backend/src/__tests__/conversations.cache.test.ts index 7e7f679..fdc93b6 100644 --- a/apps/backend/src/__tests__/conversations.cache.test.ts +++ b/apps/backend/src/__tests__/conversations.cache.test.ts @@ -66,6 +66,7 @@ vi.mock('../db/schema.js', () => ({ createdAt: 'createdAt', deletedAt: 'deletedAt', }, + messageEnvelopes: { recipientDeviceId: 'recipientDeviceId' }, tokenTransfers: {}, })); vi.mock('drizzle-orm', () => { @@ -94,7 +95,10 @@ const TEST_USER_ID = 'user-test-123'; vi.mock('../middleware/auth.js', () => ({ requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { - (req as express.Request & { auth: { userId: string } }).auth = { userId: TEST_USER_ID }; + (req as express.Request & { auth: { userId: string; deviceId: string } }).auth = { + userId: TEST_USER_ID, + deviceId: 'device-test-123', + }; next(); }, })); @@ -192,45 +196,12 @@ describe('GET /conversations — Redis caching', () => { describe('GET /conversations/:id/search', () => { beforeEach(() => { vi.clearAllMocks(); - mockRedisInstance = { get: mockGet, setex: mockSetex, del: mockDel }; - }); - - it('returns 400 when the query is empty', async () => { - const res = await request(makeApp()).get('/conversations/conv-1/search?q= '); - - expect(res.status).toBe(400); - expect(mockFindFirst).not.toHaveBeenCalled(); - expect(mockExecute).not.toHaveBeenCalled(); }); - it('returns 403 when the user is not a conversation member', async () => { - mockFindFirst.mockResolvedValue(undefined); - + it('returns 501 for E2EE environments', async () => { const res = await request(makeApp()).get('/conversations/conv-1/search?q=hello'); - expect(res.status).toBe(403); - expect(mockExecute).not.toHaveBeenCalled(); - }); - - it('returns ranked highlighted matches for conversation members', async () => { - const searchResults = [ - { - id: 'msg-1', - conversationId: 'conv-1', - senderId: TEST_USER_ID, - content: 'hello from stellar', - snippet: 'hello from stellar', - rank: '0.1', - }, - ]; - mockFindFirst.mockResolvedValue({ id: 'member-1' }); - mockExecute.mockResolvedValue(searchResults); - - const res = await request(makeApp()).get('/conversations/conv-1/search?q=hello'); - - expect(res.status).toBe(200); - expect(res.body).toEqual({ results: searchResults }); - expect(mockExecute).toHaveBeenCalledTimes(1); + expect(res.status).toBe(501); }); }); diff --git a/apps/backend/src/__tests__/conversations.routes.test.ts b/apps/backend/src/__tests__/conversations.routes.test.ts index f402138..20f1202 100644 --- a/apps/backend/src/__tests__/conversations.routes.test.ts +++ b/apps/backend/src/__tests__/conversations.routes.test.ts @@ -58,6 +58,7 @@ vi.mock('../db/schema.js', () => ({ createdAt: 'createdAt', deletedAt: 'deletedAt', }, + messageEnvelopes: { recipientDeviceId: 'recipientDeviceId' }, tokenTransfers: {}, })); @@ -73,7 +74,10 @@ vi.mock('drizzle-orm', () => ({ vi.mock('../middleware/auth.js', () => ({ requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { - (req as express.Request & { auth: { userId: string } }).auth = { userId: 'user-1' }; + (req as express.Request & { auth: { userId: string; deviceId: string } }).auth = { + userId: 'user-1', + deviceId: 'device-1', + }; next(); }, })); @@ -138,7 +142,7 @@ describe('GET /conversations/:id', () => { id: 'msg-1', conversationId: 'conv-1', senderId: 'user-1', - content: 'hello', + ciphertext: 'hello', deletedAt: null, sender: { id: 'user-1', @@ -157,7 +161,7 @@ describe('GET /conversations/:id', () => { expect(res.status).toBe(200); expect(res.body.id).toBe('conv-1'); expect(res.body.messages).toHaveLength(1); - expect(res.body.messages[0].content).toBe('hello'); + expect(res.body.messages[0].ciphertext).toBe('hello'); }); }); diff --git a/apps/backend/src/__tests__/messages.routes.test.ts b/apps/backend/src/__tests__/messages.routes.test.ts index f48ae04..aa5361b 100644 --- a/apps/backend/src/__tests__/messages.routes.test.ts +++ b/apps/backend/src/__tests__/messages.routes.test.ts @@ -8,6 +8,7 @@ const mockUpdate = vi.fn(); const mockEmit = vi.fn(); const mockTo = vi.fn(() => ({ emit: mockEmit })); +const mockDelete = vi.fn(); let mockSocketServer: { to: typeof mockTo } | null = { to: mockTo }; vi.mock('../lib/socket.js', () => ({ @@ -31,6 +32,7 @@ vi.mock('../db/index.js', () => ({ conversationMembers: { findMany: mockFindMembers }, }, update: mockUpdate, + delete: mockDelete, }, })); @@ -45,6 +47,7 @@ vi.mock('../db/schema.js', () => ({ createdAt: 'createdAt', deletedAt: 'deletedAt', }, + messageEnvelopes: { messageId: 'messageId' }, tokenTransfers: {}, })); @@ -104,14 +107,16 @@ describe('DELETE /messages/:id', () => { const setFn = vi.fn().mockReturnThis(); const whereFn = vi.fn().mockResolvedValue([{ conversationId: 'conv-1' }]); + const deleteWhereFn = vi.fn().mockResolvedValue([]); mockUpdate.mockReturnValue({ set: setFn }); setFn.mockReturnValue({ where: whereFn }); + mockDelete.mockReturnValue({ where: deleteWhereFn }); mockFindMembers.mockResolvedValue([{ userId: 'user-1' }, { userId: 'user-2' }]); const res = await request(makeApp()).delete('/messages/msg-1'); expect(res.status).toBe(204); - expect(setFn).toHaveBeenCalledWith({ deletedAt: expect.any(Date) }); + expect(setFn).toHaveBeenCalledWith({ deletedAt: expect.any(Date), ciphertext: null }); expect(mockTo).toHaveBeenCalledWith('conv-1'); expect(mockEmit).toHaveBeenCalledWith('message_deleted', { messageId: 'msg-1', diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index 988f6cb..9823e8d 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -7,8 +7,8 @@ import { pgEnum, index, integer, + serial, uniqueIndex, - bigint, } from 'drizzle-orm/pg-core'; import { relations, sql } from 'drizzle-orm'; @@ -68,31 +68,45 @@ export const conversationMembers = pgTable('conversation_members', { joinedAt: timestamp('joined_at').notNull().defaultNow(), }); -export const messages = pgTable( - 'messages', +export const messages = pgTable('messages', { + id: uuid('id').primaryKey().defaultRandom(), + conversationId: uuid('conversation_id') + .notNull() + .references(() => conversations.id, { onDelete: 'cascade' }), + senderId: uuid('sender_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + senderDeviceId: uuid('sender_device_id').references(() => userDevices.id, { + onDelete: 'set null', + }), + contentType: text('content_type').notNull().default('text/plain'), + sequenceNumber: serial('sequence_number'), + ciphertext: text('ciphertext'), + createdAt: timestamp('created_at').notNull().defaultNow(), + deletedAt: timestamp('deleted_at'), +}); + +export const messageEnvelopes = pgTable( + 'message_envelopes', { id: uuid('id').primaryKey().defaultRandom(), - conversationId: uuid('conversation_id') + messageId: uuid('message_id') .notNull() - .references(() => conversations.id, { onDelete: 'cascade' }), - senderId: uuid('sender_id') - .notNull() - .references(() => users.id, { onDelete: 'cascade' }), - content: text('content').notNull(), - contentType: contentTypeEnum('content_type').notNull().default('text'), - senderDeviceId: uuid('sender_device_id') + .references(() => messages.id, { onDelete: 'cascade' }), + recipientDeviceId: uuid('recipient_device_id') .notNull() .references(() => userDevices.id, { onDelete: 'cascade' }), - sequenceNumber: bigint('sequence_number', { mode: 'bigint' }).notNull(), - expiresAt: timestamp('expires_at'), + recipientUserId: uuid('recipient_user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + ciphertext: text('ciphertext').notNull(), + deliveredAt: timestamp('delivered_at'), + readAt: timestamp('read_at'), createdAt: timestamp('created_at').notNull().defaultNow(), - deletedAt: timestamp('deleted_at'), }, (table) => [ - index('messages_content_search_idx').using( - 'gin', - sql`to_tsvector('english', ${table.content})`, - ), + index('me_recipient_device_created_idx').on(table.recipientDeviceId, table.createdAt), + index('me_message_idx').on(table.messageId), ], ); @@ -276,7 +290,7 @@ export const conversationMembersRelations = relations(conversationMembers, ({ on user: one(users, { fields: [conversationMembers.userId], references: [users.id] }), })); -export const messagesRelations = relations(messages, ({ one }) => ({ +export const messagesRelations = relations(messages, ({ one, many }) => ({ conversation: one(conversations, { fields: [messages.conversationId], references: [conversations.id], @@ -286,6 +300,16 @@ export const messagesRelations = relations(messages, ({ one }) => ({ fields: [messages.senderDeviceId], references: [userDevices.id], }), + envelopes: many(messageEnvelopes), +})); + +export const messageEnvelopesRelations = relations(messageEnvelopes, ({ one }) => ({ + message: one(messages, { fields: [messageEnvelopes.messageId], references: [messages.id] }), + recipientDevice: one(userDevices, { + fields: [messageEnvelopes.recipientDeviceId], + references: [userDevices.id], + }), + recipientUser: one(users, { fields: [messageEnvelopes.recipientUserId], references: [users.id] }), })); export const tokenTransfersRelations = relations(tokenTransfers, ({ one }) => ({ @@ -329,6 +353,8 @@ export type NewConversation = typeof conversations.$inferInsert; export type ConversationMember = typeof conversationMembers.$inferSelect; export type Message = typeof messages.$inferSelect; export type NewMessage = typeof messages.$inferInsert; +export type MessageEnvelope = typeof messageEnvelopes.$inferSelect; +export type NewMessageEnvelope = typeof messageEnvelopes.$inferInsert; export type TokenTransfer = typeof tokenTransfers.$inferSelect; export type NewTokenTransfer = typeof tokenTransfers.$inferInsert; export type Device = typeof devices.$inferSelect; diff --git a/apps/backend/src/lib/messages.ts b/apps/backend/src/lib/messages.ts index a07cb4c..37601c2 100644 --- a/apps/backend/src/lib/messages.ts +++ b/apps/backend/src/lib/messages.ts @@ -1,15 +1,51 @@ type MessageLike = { - content: string | null; + id: string; + senderId: string; + senderDeviceId?: string | null; + contentType: string; + sequenceNumber: number; + createdAt: Date; + ciphertext?: string | null; deletedAt?: Date | null; + envelopes?: Array<{ ciphertext: string }>; + [key: string]: any; }; export function serializeMessage( message: T, -): Omit & { content: string | null } { - const { deletedAt, ...rest } = message; +): Omit & { + ciphertext: string | null; + unavailable?: boolean; +} { + const { deletedAt, envelopes, ciphertext: baseCiphertext, ...rest } = message; + if (deletedAt) { + return { + ...rest, + ciphertext: null, + }; + } + + // If there's an envelope, its ciphertext takes precedence. + if (envelopes && envelopes.length > 0) { + return { + ...rest, + ciphertext: envelopes[0]!.ciphertext, + }; + } + + // If no envelope but we have base ciphertext (e.g. system message or legacy), use it. + if (baseCiphertext) { + return { + ...rest, + ciphertext: baseCiphertext, + }; + } + + // Otherwise, it's unavailable. return { ...rest, - content: deletedAt ? null : message.content, + ciphertext: null, + unavailable: true, }; } diff --git a/apps/backend/src/routes/conversations.ts b/apps/backend/src/routes/conversations.ts index f0b1252..673385b 100644 --- a/apps/backend/src/routes/conversations.ts +++ b/apps/backend/src/routes/conversations.ts @@ -2,7 +2,13 @@ import { Router } from 'express'; import type { IRouter } from 'express'; import { asc, and, count, desc, eq, lt, sql, ne } from 'drizzle-orm'; import { db } from '../db/index.js'; -import { conversationMembers, conversations, messages, tokenTransfers } from '../db/schema.js'; +import { + conversationMembers, + conversations, + messages, + tokenTransfers, + messageEnvelopes, +} from '../db/schema.js'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { redis, CONV_CACHE_TTL, convCacheKey } from '../lib/redis.js'; import { invalidateConversationCaches } from '../lib/conversationCache.js'; @@ -14,9 +20,7 @@ export const conversationsRouter: IRouter = Router(); conversationsRouter.use(requireAuth); -const SEARCH_RESULT_LIMIT = 20; - -const conversationRelations = { +const getConversationRelations = (deviceId: string) => ({ members: { with: { user: { @@ -28,9 +32,15 @@ const conversationRelations = { messages: { orderBy: desc(messages.createdAt), limit: 1, - with: { sender: { columns: { id: true, username: true, avatarUrl: true } } }, + with: { + sender: { columns: { id: true, username: true, avatarUrl: true } }, + envelopes: { + where: eq(messageEnvelopes.recipientDeviceId, deviceId), + limit: 1, + }, + }, }, -} as const; +}); type ConversationPayload = { messages?: Array>; @@ -40,7 +50,9 @@ type ConversationPayload = { function serializeConversation(conversation: T): T { return { ...conversation, - messages: (conversation.messages ?? []).map((message) => serializeMessage(message)), + messages: (conversation.messages ?? []).map((message) => + serializeMessage(message as any), + ) as any, }; } @@ -93,13 +105,13 @@ conversationsRouter.get('/', async (req: AuthRequest, res) => { showArchived ? undefined : ne(conversationMembers.isArchived, true), ), with: { - conversation: conversationRelations as never, + conversation: getConversationRelations(req.auth!.deviceId) as never, }, })) as unknown as Array<{ conversationId: string; - conversation: ConversationPayload; isMuted: boolean; isArchived: boolean; + conversation: ConversationPayload; }>; // Single subquery for message counts — no N+1 @@ -182,7 +194,7 @@ conversationsRouter.get('/:id', async (req: AuthRequest, res) => { const conversation = (await db.query.conversations.findFirst({ where: eq(conversations.id, conversationId), - with: conversationRelations as never, + with: getConversationRelations(req.auth!.deviceId) as never, })) as ConversationPayload | undefined; if (!conversation) { @@ -476,7 +488,13 @@ conversationsRouter.get('/:id/messages', async (req: AuthRequest, res) => { : eq(messages.conversationId, conversationId), orderBy: desc(messages.createdAt), limit: limit + 1, - with: { sender: { columns: { id: true, username: true, avatarUrl: true } } }, + with: { + sender: { columns: { id: true, username: true, avatarUrl: true } }, + envelopes: { + where: eq(messageEnvelopes.recipientDeviceId, req.auth!.deviceId), + limit: 1, + }, + }, }); const hasMore = rows.length > limit; @@ -491,66 +509,7 @@ conversationsRouter.get('/:id/messages', async (req: AuthRequest, res) => { }); conversationsRouter.get('/:id/search', async (req: AuthRequest, res) => { - const userId = req.auth!.userId; - const conversationId = req.params['id'] as string | undefined; - const query = typeof req.query.q === 'string' ? req.query.q.trim() : ''; - - if (!conversationId) { - res.status(400).json({ error: 'Conversation id is required' }); - return; - } - - if (!query) { - res.status(400).json({ error: 'Search query is required' }); - return; - } - - const membership = await db.query.conversationMembers.findFirst({ - where: and( - eq(conversationMembers.conversationId, conversationId), - eq(conversationMembers.userId, userId), - ), - }); - - if (!membership) { - res.status(403).json({ error: 'Not a member of this conversation' }); - return; - } - - const results = await db.execute<{ - id: string; - conversationId: string; - senderId: string; - content: string; - createdAt: Date; - snippet: string; - rank: string; - }>(sql` - WITH search_query AS ( - SELECT websearch_to_tsquery('english', ${query}) AS query - ) - SELECT - ${messages.id} AS "id", - ${messages.conversationId} AS "conversationId", - ${messages.senderId} AS "senderId", - ${messages.content} AS "content", - ${messages.createdAt} AS "createdAt", - ts_headline( - 'english', - ${messages.content}, - search_query.query, - 'StartSel=, StopSel=, MaxWords=24, MinWords=8, ShortWord=3, HighlightAll=false' - ) AS "snippet", - ts_rank_cd(to_tsvector('english', ${messages.content}), search_query.query) AS "rank" - FROM ${messages}, search_query - WHERE ${messages.conversationId} = ${conversationId} - AND ${messages.deletedAt} IS NULL - AND search_query.query @@ to_tsvector('english', ${messages.content}) - ORDER BY "rank" DESC, ${messages.createdAt} DESC - LIMIT ${SEARCH_RESULT_LIMIT} - `); - - res.json({ results }); + res.status(501).json({ error: 'Search is not supported in E2EE conversations' }); }); // PATCH /conversations/:id/settings — update muted/archived state for the authenticated user diff --git a/apps/backend/src/routes/messages.ts b/apps/backend/src/routes/messages.ts index 0c3838b..32e1ba3 100644 --- a/apps/backend/src/routes/messages.ts +++ b/apps/backend/src/routes/messages.ts @@ -2,7 +2,7 @@ import { Router } from 'express'; import type { IRouter } from 'express'; import { and, eq } from 'drizzle-orm'; import { db } from '../db/index.js'; -import { conversationMembers, messages } from '../db/schema.js'; +import { conversationMembers, messages, messageEnvelopes } from '../db/schema.js'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { invalidateConversationCaches } from '../lib/conversationCache.js'; import { getSocketServer } from '../lib/socket.js'; @@ -36,9 +36,11 @@ messagesRouter.delete('/:id', async (req: AuthRequest, res) => { await db .update(messages) - .set({ deletedAt: new Date() }) + .set({ deletedAt: new Date(), ciphertext: null }) .where(and(eq(messages.id, messageId), eq(messages.senderId, userId))); + await db.delete(messageEnvelopes).where(eq(messageEnvelopes.messageId, messageId)); + getSocketServer()?.to(message.conversationId).emit('message_deleted', { messageId: message.id, conversationId: message.conversationId, diff --git a/apps/backend/src/routes/treasury.ts b/apps/backend/src/routes/treasury.ts index 3a205c0..f11d342 100644 --- a/apps/backend/src/routes/treasury.ts +++ b/apps/backend/src/routes/treasury.ts @@ -1,9 +1,9 @@ -import { Router, type Router as RouterType } from 'express'; +import { Router, type IRouter } from 'express'; import { z } from 'zod'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { validate } from '../middleware/validate.js'; -export const treasuryRouter: RouterType = Router(); +export const treasuryRouter: IRouter = Router(); treasuryRouter.use(requireAuth); diff --git a/apps/backend/src/socket/messaging.ts b/apps/backend/src/socket/messaging.ts index 8c92463..47b1471 100644 --- a/apps/backend/src/socket/messaging.ts +++ b/apps/backend/src/socket/messaging.ts @@ -1,7 +1,13 @@ import type { Server } from 'socket.io'; -import { and, eq, lt, desc, sql } from 'drizzle-orm'; +import { and, eq, lt, desc, sql, inArray } from 'drizzle-orm'; import { db } from '../db/index.js'; -import { conversations, conversationMembers, messages } from '../db/schema.js'; +import { + conversations, + conversationMembers, + messages, + messageEnvelopes, + userDevices, +} from '../db/schema.js'; import type { AuthSocket } from '../middleware/socketAuth.js'; import { invalidateConversationCaches } from '../lib/conversationCache.js'; import { serializeMessage } from '../lib/messages.js'; @@ -35,42 +41,105 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void }); // ── send_message ─────────────────────────────────────────────────────────── - // Payload: { conversationId: string; content: string } + // Payload: { conversationId, messageId, contentType, ciphertext, envelopes } // Persists the message and broadcasts it to all room members. - socket.on('send_message', async (payload: { conversationId: string; content: string }) => { - const { conversationId, content } = payload; + socket.on( + 'send_message', + async (payload: { + conversationId: string; + messageId: string; + contentType?: string; + ciphertext?: string; + envelopes?: Array<{ recipientDeviceId: string; ciphertext: string }>; + }) => { + const { conversationId, messageId, contentType, ciphertext, envelopes } = payload; + const deviceId = socket.auth!.deviceId; + + if (!messageId) { + socket.emit('error', { event: 'send_message', message: 'messageId is required' }); + return; + } - if (!content?.trim()) { - socket.emit('error', { event: 'send_message', message: 'Content must not be empty' }); - return; - } + if (!ciphertext?.trim() && (!envelopes || envelopes.length === 0)) { + socket.emit('error', { event: 'send_message', message: 'Message content is empty' }); + return; + } - const membership = await db.query.conversationMembers.findFirst({ - where: and( - eq(conversationMembers.conversationId, conversationId), - eq(conversationMembers.userId, userId), - ), - }); + const membership = await db.query.conversationMembers.findFirst({ + where: and( + eq(conversationMembers.conversationId, conversationId), + eq(conversationMembers.userId, userId), + ), + }); - if (!membership) { - socket.emit('error', { event: 'send_message', message: 'Not a member of this conversation' }); - return; - } + if (!membership) { + socket.emit('error', { + event: 'send_message', + message: 'Not a member of this conversation', + }); + return; + } - const [message] = await db - .insert(messages) - .values({ conversationId, senderId: userId, content: content.trim() }) - .returning(); + // Idempotency check + const existing = await db.query.messages.findFirst({ + where: eq(messages.id, messageId), + columns: { sequenceNumber: true }, + }); - io.to(conversationId).volatile.emit('new_message', message); + if (existing) { + socket.emit('message_ack', { messageId, sequenceNumber: existing.sequenceNumber }); + return; + } - const members = await db.query.conversationMembers.findMany({ - where: eq(conversationMembers.conversationId, conversationId), - columns: { userId: true }, - }); + const [message] = await db + .insert(messages) + .values({ + id: messageId, + conversationId, + senderId: userId, + senderDeviceId: deviceId, + contentType: contentType || 'text/plain', + ciphertext: ciphertext || null, + }) + .returning(); - await invalidateConversationCaches(members.map((member) => member.userId)); - }); + if (envelopes && envelopes.length > 0) { + const deviceIds = envelopes.map((e) => e.recipientDeviceId); + const devicesList = await db.query.userDevices.findMany({ + where: inArray(userDevices.id, deviceIds), + columns: { id: true, userId: true }, + }); + const deviceToUser = new Map(devicesList.map((d) => [d.id, d.userId])); + + const validEnvelopes = envelopes + .filter((env) => deviceToUser.has(env.recipientDeviceId)) + .map((env) => ({ + messageId, + recipientDeviceId: env.recipientDeviceId, + recipientUserId: deviceToUser.get(env.recipientDeviceId)!, + ciphertext: env.ciphertext, + })); + + if (validEnvelopes.length > 0) { + await db.insert(messageEnvelopes).values(validEnvelopes); + } + } + + // Emit acknowledgment to sender + if (message) { + socket.emit('message_ack', { messageId, sequenceNumber: message.sequenceNumber }); + } + + io.to(conversationId).emit('new_message', message); + + const members = await db.query.conversationMembers.findMany({ + where: eq(conversationMembers.conversationId, conversationId), + columns: { userId: true }, + }); + + await invalidateConversationCaches(members.map((member) => member.userId)); + }, + ); // ── message_history ──────────────────────────────────────────────────────── // Payload: { conversationId: string; before?: string } (before = message id cursor) @@ -116,6 +185,31 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void }); }); + // ── delete_message ───────────────────────────────────────────────────────── + // Payload: { messageId: string } + // Sender retraction + socket.on('delete_message', async (payload: { messageId: string }) => { + const { messageId } = payload; + if (!messageId) return; + + const message = await db.query.messages.findFirst({ + where: eq(messages.id, messageId), + }); + + if (!message || message.senderId !== userId) { + socket.emit('error', { event: 'delete_message', message: 'Message not found or not sender' }); + return; + } + + await db + .update(messages) + .set({ deletedAt: new Date(), ciphertext: null }) + .where(eq(messages.id, messageId)); + await db.delete(messageEnvelopes).where(eq(messageEnvelopes.messageId, messageId)); + + io.to(message.conversationId).emit('message_deleted', { messageId }); + }); + // ── message_read ─────────────────────────────────────────────────────────── // Payload: { conversationId: string; lastReadMessageId: string } // Persists the caller's read position and broadcasts to the room. @@ -317,7 +411,8 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void .values({ conversationId, senderId: ASSISTANT_USER_ID, - content: data.reply, + contentType: 'text/plain', + ciphertext: data.reply, }) .returning(); diff --git a/apps/backend/vitest.config.d.ts b/apps/backend/vitest.config.d.ts new file mode 100644 index 0000000..2b17c25 --- /dev/null +++ b/apps/backend/vitest.config.d.ts @@ -0,0 +1,3 @@ +declare const _default: import("vite").UserConfig; +export default _default; +//# sourceMappingURL=vitest.config.d.ts.map \ No newline at end of file diff --git a/apps/backend/vitest.config.d.ts.map b/apps/backend/vitest.config.d.ts.map new file mode 100644 index 0000000..062d697 --- /dev/null +++ b/apps/backend/vitest.config.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"vitest.config.d.ts","sourceRoot":"","sources":["vitest.config.ts"],"names":[],"mappings":";AAEA,wBAKG"} \ No newline at end of file diff --git a/apps/backend/vitest.config.js b/apps/backend/vitest.config.js new file mode 100644 index 0000000..771b453 --- /dev/null +++ b/apps/backend/vitest.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; +export default defineConfig({ + test: { + environment: 'node', + setupFiles: ['./src/__tests__/setup.ts'], + }, +}); +//# sourceMappingURL=vitest.config.js.map \ No newline at end of file diff --git a/apps/backend/vitest.config.js.map b/apps/backend/vitest.config.js.map new file mode 100644 index 0000000..0958da5 --- /dev/null +++ b/apps/backend/vitest.config.js.map @@ -0,0 +1 @@ +{"version":3,"file":"vitest.config.js","sourceRoot":"","sources":["vitest.config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAE7C,eAAe,YAAY,CAAC;IAC1B,IAAI,EAAE;QACJ,WAAW,EAAE,MAAM;QACnB,UAAU,EAAE,CAAC,0BAA0B,CAAC;KACzC;CACF,CAAC,CAAC"} \ No newline at end of file