Skip to content
Closed
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
10 changes: 7 additions & 3 deletions babel.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@ module.exports = {
[
'@babel/preset-env',
{
targets: {
node: 'current',
},
targets: { node: 'current' },
modules: 'commonjs',
},
],
[
'@babel/preset-typescript',
{
allowDeclareFields: true,
},
],
],
plugins: [
[
Expand Down
32 changes: 32 additions & 0 deletions backend/convex/__mocks__/server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Mock for Convex _generated/server module for testing

module.exports = {
query: (config) => {
return async (...args) => {
if (config.handler) {
return config.handler(...args);
}
return config;
};
},
mutation: (config) => {
return async (...args) => {
if (config.handler) {
return config.handler(...args);
}
return config;
};
},
action: (config) => {
return async (...args) => {
if (config.handler) {
return config.handler(...args);
}
return config;
};
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
internalQuery: (config) => config,
internalMutation: (config) => config,
internalAction: (config) => config,
httpAction: (config) => config,
};
135 changes: 135 additions & 0 deletions backend/convex/__tests__/messages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { listUserConversationsHandler } from '../messages';

function buildCtx({
userId,
participantRows,
docsById,
}: {
userId: string | null;
participantRows?: any[];
docsById?: Record<string, any>;
}) {
return {
auth: {
getUserIdentity: async () => (userId ? { subject: userId } : null),
},
db: {
get: async (id: string) => docsById?.[id] ?? null,
query: (table: any) => {
if (table === 'conversationParticipants') {
return {
withIndex: (_index: string, filterBuilder: any) => {
const state: { requestedUserId?: string; cursor?: number } = {};
filterBuilder({
eq: (_field: string, value: string) => {
state.requestedUserId = value;
return {
lt: (_ltField: string, ltValue: number) => {
state.cursor = ltValue;
return null;
},
};
},
});

const filtered = (participantRows ?? [])
.filter((row: any) => row.userId === state.requestedUserId)
.filter(
(row: any) => state.cursor === undefined || row.lastActivityAt < state.cursor
)
.sort((a: any, b: any) => b.lastActivityAt - a.lastActivityAt);

return {
order: () => ({
take: async (count: number) => filtered.slice(0, count),
}),
};
},
};
}

throw new Error(`Unexpected query table: ${table}`);
},
},
};
}

describe('listUserConversations', () => {
it('returns empty list for user with no conversations', async () => {
const ctx = buildCtx({
userId: 'user_123',
participantRows: [],
});

const result = await listUserConversationsHandler(ctx, {});

expect(result.conversations).toEqual([]);
expect(result.nextCursor).toBeNull();
});

it('returns only conversations for the authenticated user', async () => {
const userId = 'user_123';
const ctx = buildCtx({
userId,
participantRows: [
{ conversationId: 'conv_2', userId, lastActivityAt: 2000, unreadCount: 0 },
{ conversationId: 'conv_1', userId: 'other_user', lastActivityAt: 3000 },
],
docsById: {
conv_2: {
_id: 'conv_2',
buyerId: userId,
sellerId: 'user_999',
lastMessageAt: 2000,
createdAt: 1500,
listingId: 'listing_2',
},
},
});

const result = await listUserConversationsHandler(ctx, {});

expect(result.conversations).toHaveLength(1);
expect(result.conversations[0].conversationId).toBe('conv_2');
});

it('throws Unauthorized when no auth identity', async () => {
const ctx = buildCtx({
userId: null,
participantRows: [],
});

await expect(listUserConversationsHandler(ctx, {})).rejects.toThrow('Unauthorized');
});

it('calculates unread count correctly', async () => {
const userId = 'user_123';

const ctx = buildCtx({
userId,
participantRows: [{ conversationId: 'conv_1', userId, lastActivityAt: 2000, unreadCount: 1 }],
docsById: {
conv_1: {
_id: 'conv_1',
buyerId: userId,
sellerId: 'user_999',
lastMessageAt: 2000,
lastMessageId: 'msg_1',
createdAt: 1500,
listingId: 'listing_2',
},
msg_1: {
_id: 'msg_1',
senderId: 'user_999',
body: 'Hi there',
read: false,
createdAt: 2000,
},
},
});

const result = await listUserConversationsHandler(ctx, {});

expect(result.conversations[0].unreadCount).toBe(1);
});
});
4 changes: 4 additions & 0 deletions backend/convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
* @module
*/

import type * as __mocks___server from "../__mocks__/server.js";
import type * as listings from "../listings.js";
import type * as messages from "../messages.js";

import type {
ApiFromModules,
Expand All @@ -17,7 +19,9 @@ import type {
} from "convex/server";

declare const fullApi: ApiFromModules<{
"__mocks__/server": typeof __mocks___server;
listings: typeof listings;
messages: typeof messages;
}>;

/**
Expand Down
174 changes: 174 additions & 0 deletions backend/convex/messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { mutation, query } from './_generated/server';
import { v } from 'convex/values';

export const sendMessage = mutation({
args: {
conversationId: v.id('conversations'),
body: v.string(),
},
handler: async (ctx, args) => {
// Get the authenticated user's identity
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error('Unauthorized');
}
const senderId = identity.subject;

// Fetch the conversation
const conversation = await ctx.db.get(args.conversationId);
if (!conversation) {
throw new Error('Conversation not found');
}

// Verify the authenticated user is a participant
if (!conversation.participantIds.includes(senderId)) {
throw new Error('User is not a participant in this conversation');
}

const now = Date.now();

// Insert the message
const messageId = await ctx.db.insert('messages', {
conversationId: args.conversationId,
senderId: senderId,
body: args.body,
type: 'text',
createdAt: now,
read: false,
});

// Update the conversation's metadata
await ctx.db.patch(args.conversationId, {
lastMessageAt: now,
lastMessageId: messageId,
updatedAt: now,
});

// Keep participant lookup rows in sync for server-side indexed conversation queries.
const participantRows = await ctx.db
.query('conversationParticipants')
.withIndex('by_conversationId', (q: any) => q.eq('conversationId', args.conversationId))
.collect();

const participantRowByUserId = new Map(participantRows.map((row: any) => [row.userId, row]));

for (const participantId of conversation.participantIds) {
const existingRow = participantRowByUserId.get(participantId);
if (existingRow) {
await ctx.db.patch(existingRow._id, {
lastActivityAt: now,
unreadCount:
participantId === senderId
? (existingRow.unreadCount ?? 0)
: (existingRow.unreadCount ?? 0) + 1,
updatedAt: now,
});
} else {
await ctx.db.insert('conversationParticipants', {
conversationId: args.conversationId,
userId: participantId,
lastActivityAt: now,
unreadCount: participantId === senderId ? 0 : 1,
createdAt: now,
updatedAt: now,
});
}
}
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

// Export handler separately for testing
export const listUserConversationsHandler = async (ctx: any, args: any) => {
// Step 1: Get userId from auth context
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error('Unauthorized');
}
const userId = identity.subject;

// Step 2: Query participant rows by indexed userId + activity timestamp.
const limit = args.limit || 20;
let cursorValue: number | undefined;
if (args.cursor) {
const parsedCursor = Number.parseInt(args.cursor, 10);
if (!Number.isNaN(parsedCursor)) {
cursorValue = parsedCursor;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const participantRows = await ctx.db
.query('conversationParticipants')
.withIndex('by_user_lastActivityAt', (q: any) =>
cursorValue === undefined
? q.eq('userId', userId)
: q.eq('userId', userId).lt('lastActivityAt', cursorValue)
)
.order('desc')
.take(limit + 1);

// Step 3: For each conversation (up to limit):
const conversationList = [];
const displayedParticipantRows = participantRows.slice(0, limit);
const displayedConversations = (
await Promise.all(
displayedParticipantRows.map(async (participantRow: any) => ({
participantRow,
conversation: await ctx.db.get(participantRow.conversationId),
}))
)
).filter((row: any) => row.conversation !== null);

const lastMessageByConversationId = new Map();
await Promise.all(
displayedConversations.map(async ({ conversation }: any) => {
if (!conversation.lastMessageId) {
return;
}
const lastMessage = await ctx.db.get(conversation.lastMessageId);
if (lastMessage) {
lastMessageByConversationId.set(conversation._id, lastMessage);
}
})
);

for (const { participantRow, conversation: conv } of displayedConversations) {
// - Calculate otherUserId
const otherUserId = userId === conv.buyerId ? conv.sellerId : conv.buyerId;

// - Get lastMessagePreview
const lastMsg = lastMessageByConversationId.get(conv._id);

// - Calculate unreadCount
const unreadCount = participantRow.unreadCount ?? 0;

// - Build response object
conversationList.push({
conversationId: conv._id,
listingId: conv.listingId,
otherUserId,
lastMessageAt: conv.lastMessageAt,
lastMessagePreview: lastMsg?.body || 'No messages yet',
unreadCount,
createdAt: conv.createdAt,
});
}

// Step 4: Determine nextCursor
let nextCursor = null;
if (participantRows.length > limit && displayedParticipantRows.length > 0) {
const lastParticipantRow = displayedParticipantRows[displayedParticipantRows.length - 1];
nextCursor = String(lastParticipantRow.lastActivityAt);
}

return {
conversations: conversationList,
nextCursor,
};
};

export const listUserConversations = query({
args: {
limit: v.optional(v.number()),
cursor: v.optional(v.string()), // lastMessageAt value from previous page
},
handler: listUserConversationsHandler,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading