Skip to content
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions apps/backend/drizzle.config.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
declare const _default: import("drizzle-kit").Config;
export default _default;
//# sourceMappingURL=drizzle.config.d.ts.map
1 change: 1 addition & 0 deletions apps/backend/drizzle.config.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions apps/backend/drizzle.config.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/backend/drizzle.config.js.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

43 changes: 7 additions & 36 deletions apps/backend/src/__tests__/conversations.cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ vi.mock('../db/schema.js', () => ({
createdAt: 'createdAt',
deletedAt: 'deletedAt',
},
messageEnvelopes: { recipientDeviceId: 'recipientDeviceId' },
tokenTransfers: {},
}));
vi.mock('drizzle-orm', () => {
Expand Down Expand Up @@ -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();
},
}));
Expand Down Expand Up @@ -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: '<mark>hello</mark> 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);
});
});

Expand Down
10 changes: 7 additions & 3 deletions apps/backend/src/__tests__/conversations.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ vi.mock('../db/schema.js', () => ({
createdAt: 'createdAt',
deletedAt: 'deletedAt',
},
messageEnvelopes: { recipientDeviceId: 'recipientDeviceId' },
tokenTransfers: {},
}));

Expand All @@ -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();
},
}));
Expand Down Expand Up @@ -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',
Expand All @@ -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');
});
});

Expand Down
7 changes: 6 additions & 1 deletion apps/backend/src/__tests__/messages.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand All @@ -31,6 +32,7 @@ vi.mock('../db/index.js', () => ({
conversationMembers: { findMany: mockFindMembers },
},
update: mockUpdate,
delete: mockDelete,
},
}));

Expand All @@ -45,6 +47,7 @@ vi.mock('../db/schema.js', () => ({
createdAt: 'createdAt',
deletedAt: 'deletedAt',
},
messageEnvelopes: { messageId: 'messageId' },
tokenTransfers: {},
}));

Expand Down Expand Up @@ -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',
Expand Down
64 changes: 45 additions & 19 deletions apps/backend/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import {
pgEnum,
index,
integer,
serial,
uniqueIndex,
bigint,
} from 'drizzle-orm/pg-core';
import { relations, sql } from 'drizzle-orm';

Expand Down Expand Up @@ -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),
],
);

Expand Down Expand Up @@ -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],
Expand All @@ -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 }) => ({
Expand Down Expand Up @@ -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;
Expand Down
44 changes: 40 additions & 4 deletions apps/backend/src/lib/messages.ts
Original file line number Diff line number Diff line change
@@ -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;

Check warning on line 11 in apps/backend/src/lib/messages.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type

Check warning on line 11 in apps/backend/src/lib/messages.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Test

Unexpected any. Specify a different type
};

export function serializeMessage<T extends MessageLike>(
message: T,
): Omit<T, 'deletedAt'> & { content: string | null } {
const { deletedAt, ...rest } = message;
): Omit<T, 'deletedAt' | 'envelopes' | 'ciphertext'> & {
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,
};
}
Loading
Loading