Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 1 addition & 46 deletions apps/backend/src/__tests__/conversations.cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ vi.mock('../db/schema.js', () => ({
id: 'id',
conversationId: 'conversationId',
senderId: 'senderId',
content: 'content',
ciphertext: 'ciphertext',
createdAt: 'createdAt',
deletedAt: 'deletedAt',
},
Expand Down Expand Up @@ -189,51 +189,6 @@ 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);

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);
});
});

describe('GET /conversations — isArchived filter', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down
6 changes: 3 additions & 3 deletions apps/backend/src/__tests__/conversations.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ vi.mock('../db/schema.js', () => ({
id: 'id',
conversationId: 'conversationId',
senderId: 'senderId',
content: 'content',
ciphertext: 'ciphertext',
createdAt: 'createdAt',
deletedAt: 'deletedAt',
},
Expand Down Expand Up @@ -138,7 +138,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 +157,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
6 changes: 3 additions & 3 deletions apps/backend/src/__tests__/messages.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ vi.mock('../db/schema.js', () => ({
id: 'id',
conversationId: 'conversationId',
senderId: 'senderId',
content: 'content',
ciphertext: 'ciphertext',
createdAt: 'createdAt',
deletedAt: 'deletedAt',
},
Expand Down Expand Up @@ -83,7 +83,7 @@ describe('DELETE /messages/:id', () => {
id: 'msg-1',
conversationId: 'conv-1',
senderId: 'user-2',
content: 'hello',
ciphertext: 'hello',
deletedAt: null,
});

Expand All @@ -98,7 +98,7 @@ describe('DELETE /messages/:id', () => {
id: 'msg-1',
conversationId: 'conv-1',
senderId: 'user-1',
content: 'hello',
ciphertext: 'hello',
deletedAt: null,
});

Expand Down
8 changes: 1 addition & 7 deletions apps/backend/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,16 +67,10 @@ export const messages = pgTable(
senderId: uuid('sender_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
content: text('content').notNull(),
ciphertext: text('ciphertext'),
createdAt: timestamp('created_at').notNull().defaultNow(),
deletedAt: timestamp('deleted_at'),
},
(table) => [
index('messages_content_search_idx').using(
'gin',
sql`to_tsvector('english', ${table.content})`,
),
],
);

// ─── Devices & prekeys (issues #158, #159, #162) ─────────────────────────────
Expand Down
6 changes: 3 additions & 3 deletions apps/backend/src/lib/messages.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
type MessageLike = {
content: string | null;
ciphertext: string | null;
deletedAt?: Date | null;
};

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

return {
...rest,
content: deletedAt ? null : message.content,
ciphertext: deletedAt ? null : message.ciphertext,
};
}
65 changes: 0 additions & 65 deletions apps/backend/src/routes/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ export const conversationsRouter: IRouter = Router();

conversationsRouter.use(requireAuth);

const SEARCH_RESULT_LIMIT = 20;

const conversationRelations = {
members: {
with: {
Expand Down Expand Up @@ -485,69 +483,6 @@ conversationsRouter.get('/:id/messages', async (req: AuthRequest, res) => {
res.json({ messages: page, nextCursor });
});

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=<mark>, StopSel=</mark>, 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 });
});

// PATCH /conversations/:id/settings — update muted/archived state for the authenticated user
conversationsRouter.patch('/:id/settings', async (req: AuthRequest, res) => {
const userId = req.auth!.userId;
Expand Down
24 changes: 12 additions & 12 deletions apps/backend/src/socket/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void
});

// ── send_message ───────────────────────────────────────────────────────────
// Payload: { conversationId: string; content: string }
// Payload: { conversationId: string; ciphertext: string }
// 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; ciphertext: string }) => {
const { conversationId, ciphertext } = payload;

if (!content?.trim()) {
socket.emit('error', { event: 'send_message', message: 'Content must not be empty' });
if (!ciphertext?.trim()) {
socket.emit('error', { event: 'send_message', message: 'Ciphertext must not be empty' });
return;
}

Expand All @@ -59,7 +59,7 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void

const [message] = await db
.insert(messages)
.values({ conversationId, senderId: userId, content: content.trim() })
.values({ conversationId, senderId: userId, ciphertext: ciphertext.trim() })
.returning();

io.to(conversationId).emit('new_message', message);
Expand Down Expand Up @@ -238,15 +238,15 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void
});

// ── ask_assistant ──────────────────────────────────────────────────────────
// Payload: { conversationId: string; content: string }
// Payload: { conversationId: string; ciphertext: string }
// Forwards to AI agent and posts reply from reserved assistant user.
// Rate-limit: 5 requests per user per minute.
const ASSISTANT_USER_ID = '00000000-0000-4000-8000-000000000000';

socket.on('ask_assistant', async (payload: { conversationId: string; content: string }) => {
const { conversationId, content } = payload;
socket.on('ask_assistant', async (payload: { conversationId: string; ciphertext: string }) => {
const { conversationId, ciphertext } = payload;

if (!content?.trim().startsWith('@assistant')) {
if (!ciphertext?.trim().startsWith('@assistant')) {
return;
}

Expand Down Expand Up @@ -284,7 +284,7 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: content,
message: ciphertext,
conversation_id: conversationId,
}),
});
Expand Down Expand Up @@ -317,7 +317,7 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void
.values({
conversationId,
senderId: ASSISTANT_USER_ID,
content: data.reply,
ciphertext: data.reply,
})
.returning();

Expand Down