diff --git a/apps/backend/drizzle/0009_message_edits.sql b/apps/backend/drizzle/0009_message_edits.sql new file mode 100644 index 0000000..3f93b9a --- /dev/null +++ b/apps/backend/drizzle/0009_message_edits.sql @@ -0,0 +1,2 @@ +ALTER TABLE "messages" ADD COLUMN "edits_message_id" uuid;--> statement-breakpoint +ALTER TABLE "messages" ADD CONSTRAINT "messages_edits_message_id_messages_id_fk" FOREIGN KEY ("edits_message_id") REFERENCES "public"."messages"("id") ON DELETE set null ON UPDATE no action; diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json index 6f5a899..164ed47 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1783000000000, "tag": "0008_extend_messages", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1783500000000, + "tag": "0009_message_edits", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/backend/src/__tests__/messageEdit.test.ts b/apps/backend/src/__tests__/messageEdit.test.ts new file mode 100644 index 0000000..9c2251d --- /dev/null +++ b/apps/backend/src/__tests__/messageEdit.test.ts @@ -0,0 +1,234 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EventEmitter } from 'events'; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +const mockMessagesFindFirst = vi.fn(); +const mockMembersFindMany = vi.fn(); +const mockUserDevicesFindMany = vi.fn(); + +const mockReturning = vi.fn(); +// values() must work both as `.values(x).returning()` (message insert) and as +// `await db.insert(...).values(x)` (envelope insert), so it returns a thenable +// that also exposes returning(). +const mockValues = vi.fn(() => ({ + returning: mockReturning, + then: (resolve: (value: unknown) => void) => resolve(undefined), +})); +const mockInsert = vi.fn(() => ({ values: mockValues })); + +vi.mock('../db/index.js', () => ({ + db: { + query: { + conversationMembers: { findFirst: vi.fn(), findMany: mockMembersFindMany }, + messages: { findFirst: mockMessagesFindFirst }, + userDevices: { findMany: mockUserDevicesFindMany }, + }, + insert: mockInsert, + update: vi.fn(), + delete: vi.fn(), + }, +})); + +vi.mock('../db/schema.js', () => ({ + conversations: {}, + conversationMembers: {}, + messages: {}, + messageEnvelopes: {}, + userDevices: {}, +})); + +vi.mock('../lib/conversationCache.js', () => ({ + invalidateConversationCaches: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...args: unknown[]) => args), + eq: vi.fn((col: unknown, val: unknown) => ({ col, val })), + lt: vi.fn(), + desc: vi.fn(), + sql: vi.fn(), + inArray: vi.fn((col: unknown, vals: unknown) => ({ col, vals })), +})); + +// ── Socket helpers ─────────────────────────────────────────────────────────── + +function makeSocket(userId: string, deviceId: string) { + const emitter = new EventEmitter(); + const emitted: { event: string; data: unknown }[] = []; + return Object.assign(emitter, { + auth: { userId, deviceId }, + emit: vi.fn((event: string, data: unknown) => { + emitted.push({ event, data }); + }), + join: vi.fn(), + emitted, + }); +} + +function makeIo() { + const roomEmitted: { event: string; data: unknown }[] = []; + const emitFn = vi.fn((event: string, data: unknown) => { + roomEmitted.push({ event, data }); + }); + return { + to: vi.fn(() => ({ emit: emitFn, volatile: { emit: emitFn } })), + roomEmitted, + }; +} + +async function getHandler(socket: EventEmitter, io: unknown) { + const { registerMessagingHandlers } = await import('../socket/messaging.js'); + registerMessagingHandlers(io as never, socket as never); + return socket.listeners('edit_message')[0] as (p: unknown) => Promise; +} + +const USER_ID = 'sender-1'; +const DEVICE_ID = 'device-1'; +const CONVERSATION_ID = 'conv-1'; + +beforeEach(() => { + vi.clearAllMocks(); + mockMembersFindMany.mockResolvedValue([]); + mockUserDevicesFindMany.mockResolvedValue([]); + mockReturning.mockResolvedValue([{ id: 'new-msg', sequenceNumber: 5 }]); +}); + +describe('edit_message socket event', () => { + it('rejects when originalMessageId or messageId is missing', async () => { + const socket = makeSocket(USER_ID, DEVICE_ID); + const handler = await getHandler(socket, makeIo()); + + await handler({ messageId: 'new-msg', ciphertext: 'x' }); + + expect(socket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ event: 'edit_message' }), + ); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it('rejects when the new content is empty', async () => { + const socket = makeSocket(USER_ID, DEVICE_ID); + const handler = await getHandler(socket, makeIo()); + + await handler({ originalMessageId: 'orig', messageId: 'new-msg' }); + + expect(socket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ message: expect.stringContaining('empty') }), + ); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it('rejects edits from anyone other than the original sender', async () => { + mockMessagesFindFirst.mockResolvedValueOnce({ + id: 'orig', + senderId: 'someone-else', + conversationId: CONVERSATION_ID, + editsMessageId: null, + contentType: 'text/plain', + }); + + const socket = makeSocket(USER_ID, DEVICE_ID); + const handler = await getHandler(socket, makeIo()); + + await handler({ originalMessageId: 'orig', messageId: 'new-msg', ciphertext: 'cipher' }); + + expect(socket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ message: expect.stringContaining('original sender') }), + ); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it('creates a linked new message and broadcasts new_message + message_edited', async () => { + mockMessagesFindFirst + .mockResolvedValueOnce({ + id: 'orig', + senderId: USER_ID, + conversationId: CONVERSATION_ID, + editsMessageId: null, + contentType: 'text/plain', + }) + .mockResolvedValueOnce(undefined); // idempotency check: not seen before + + const socket = makeSocket(USER_ID, DEVICE_ID); + const io = makeIo(); + const handler = await getHandler(socket, io); + + await handler({ originalMessageId: 'orig', messageId: 'new-msg', ciphertext: 'cipher' }); + + // New row links back to the original via editsMessageId. + expect(mockValues).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'new-msg', + senderId: USER_ID, + editsMessageId: 'orig', + ciphertext: 'cipher', + }), + ); + + const events = io.roomEmitted.map((e) => e.event); + expect(events).toContain('new_message'); + expect(io.roomEmitted).toContainEqual({ + event: 'message_edited', + data: { originalMessageId: 'orig', newMessageId: 'new-msg' }, + }); + expect(socket.emit).toHaveBeenCalledWith( + 'message_ack', + expect.objectContaining({ messageId: 'new-msg' }), + ); + }); + + it('links an edit-of-an-edit back to the root original', async () => { + mockMessagesFindFirst + .mockResolvedValueOnce({ + id: 'v2', + senderId: USER_ID, + conversationId: CONVERSATION_ID, + editsMessageId: 'root', // editing an already-edited message + contentType: 'text/plain', + }) + .mockResolvedValueOnce(undefined); + + const socket = makeSocket(USER_ID, DEVICE_ID); + const io = makeIo(); + const handler = await getHandler(socket, io); + + await handler({ originalMessageId: 'v2', messageId: 'v3', ciphertext: 'cipher' }); + + expect(mockValues).toHaveBeenCalledWith( + expect.objectContaining({ id: 'v3', editsMessageId: 'root' }), + ); + expect(io.roomEmitted).toContainEqual({ + event: 'message_edited', + data: { originalMessageId: 'root', newMessageId: 'v3' }, + }); + }); + + it('is idempotent: a replayed edit id acks without inserting again', async () => { + mockMessagesFindFirst + .mockResolvedValueOnce({ + id: 'orig', + senderId: USER_ID, + conversationId: CONVERSATION_ID, + editsMessageId: null, + contentType: 'text/plain', + }) + .mockResolvedValueOnce({ sequenceNumber: 9 }); // already exists + + const socket = makeSocket(USER_ID, DEVICE_ID); + const io = makeIo(); + const handler = await getHandler(socket, io); + + await handler({ originalMessageId: 'orig', messageId: 'dup', ciphertext: 'cipher' }); + + expect(mockInsert).not.toHaveBeenCalled(); + expect(socket.emit).toHaveBeenCalledWith('message_ack', { + messageId: 'dup', + sequenceNumber: 9, + }); + expect(io.roomEmitted.map((e) => e.event)).not.toContain('message_edited'); + }); +}); diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index 3305884..0ec572c 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -9,6 +9,7 @@ import { integer, serial, uniqueIndex, + type AnyPgColumn, } from 'drizzle-orm/pg-core'; import { relations, sql } from 'drizzle-orm'; @@ -82,6 +83,14 @@ export const messages = pgTable('messages', { contentType: text('content_type').notNull().default('text/plain'), sequenceNumber: serial('sequence_number'), ciphertext: text('ciphertext'), + // Edits are stored as a brand-new message linked back to the message they + // replace (#190). Plaintext/ciphertext is never mutated in place; clients + // resolve a thread to the newest version sharing the same original id. + // Self-referential FK — `set null` so deleting an original doesn't cascade + // away its edits. + editsMessageId: uuid('edits_message_id').references((): AnyPgColumn => messages.id, { + onDelete: 'set null', + }), createdAt: timestamp('created_at').notNull().defaultNow(), deletedAt: timestamp('deleted_at'), }); @@ -316,6 +325,15 @@ export const messagesRelations = relations(messages, ({ one, many }) => ({ references: [userDevices.id], }), envelopes: many(messageEnvelopes), + // The original message this one edits (null for originals). Paired with + // `edits` below via a shared relation name so Drizzle can disambiguate the + // self-join (#190). + editsMessage: one(messages, { + fields: [messages.editsMessageId], + references: [messages.id], + relationName: 'message_edits', + }), + edits: many(messages, { relationName: 'message_edits' }), })); export const messageEnvelopesRelations = relations(messageEnvelopes, ({ one }) => ({ diff --git a/apps/backend/src/socket/messaging.ts b/apps/backend/src/socket/messaging.ts index 47b1471..0a10d29 100644 --- a/apps/backend/src/socket/messaging.ts +++ b/apps/backend/src/socket/messaging.ts @@ -141,6 +141,127 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void }, ); + // ── edit_message ───────────────────────────────────────────────────────────── + // Payload: { originalMessageId, messageId, contentType?, ciphertext?, envelopes? } + // An edit is never an in-place plaintext mutation (#190). It is a brand-new + // message carrying fresh ciphertext + envelopes, linked back to the original + // via `editsMessageId`. Only the original sender may edit. We broadcast both + // `new_message` (so devices receive the new ciphertext to decrypt) and + // `message_edited` (so clients render the newest version with an "edited" + // marker and supersede the original). + socket.on( + 'edit_message', + async (payload: { + originalMessageId: string; + messageId: string; + contentType?: string; + ciphertext?: string; + envelopes?: Array<{ recipientDeviceId: string; ciphertext: string }>; + }) => { + const { originalMessageId, messageId, contentType, ciphertext, envelopes } = payload; + const deviceId = socket.auth!.deviceId; + + if (!originalMessageId || !messageId) { + socket.emit('error', { + event: 'edit_message', + message: 'originalMessageId and messageId are required', + }); + return; + } + + if (!ciphertext?.trim() && (!envelopes || envelopes.length === 0)) { + socket.emit('error', { event: 'edit_message', message: 'Message content is empty' }); + return; + } + + const original = await db.query.messages.findFirst({ + where: eq(messages.id, originalMessageId), + }); + + if (!original) { + socket.emit('error', { event: 'edit_message', message: 'Original message not found' }); + return; + } + + // Edit authorship is restricted to the original sender. + if (original.senderId !== userId) { + socket.emit('error', { + event: 'edit_message', + message: 'Only the original sender can edit this message', + }); + return; + } + + // Always link to the root original so a chain of edits collapses to one + // logical message: editing an edit still points back to the first version. + const rootMessageId = original.editsMessageId ?? original.id; + const conversationId = original.conversationId; + + // Idempotency: a retried edit with the same new messageId is a no-op. + const existing = await db.query.messages.findFirst({ + where: eq(messages.id, messageId), + columns: { sequenceNumber: true }, + }); + + if (existing) { + socket.emit('message_ack', { messageId, sequenceNumber: existing.sequenceNumber }); + return; + } + + const [message] = await db + .insert(messages) + .values({ + id: messageId, + conversationId, + senderId: userId, + senderDeviceId: deviceId, + contentType: contentType || original.contentType, + ciphertext: ciphertext || null, + editsMessageId: rootMessageId, + }) + .returning(); + + 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); + } + } + + if (message) { + socket.emit('message_ack', { messageId, sequenceNumber: message.sequenceNumber }); + io.to(conversationId).emit('new_message', message); + } + + io.to(conversationId).emit('message_edited', { + originalMessageId: rootMessageId, + newMessageId: messageId, + }); + + 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) // Returns the last PAGE_SIZE messages, optionally before a cursor for pagination.