diff --git a/backend/prisma/migrations/20251121204228_add_match_last_read_timestamps/migration.sql b/backend/prisma/migrations/20251121204228_add_match_last_read_timestamps/migration.sql new file mode 100644 index 0000000..b03ce38 --- /dev/null +++ b/backend/prisma/migrations/20251121204228_add_match_last_read_timestamps/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "matches" ADD COLUMN "user1_last_read_at" TIMESTAMP(3), +ADD COLUMN "user2_last_read_at" TIMESTAMP(3), +ALTER COLUMN "slug" DROP DEFAULT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index cb361db..b019489 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -134,14 +134,16 @@ model Message { // Matches (pairs of users for collaboration) model Match { - id Int @id @default(autoincrement()) - slug String @unique @default(cuid()) @db.VarChar(50) - user1Id Int @map("user1_id") - user2Id Int @map("user2_id") - eventId Int @map("event_id") - roomId Int? @map("room_id") - status String @default("pending") @db.VarChar(20) // 'pending', 'accepted', 'completed' - createdAt DateTime @default(now()) @map("created_at") + id Int @id @default(autoincrement()) + slug String @unique @default(cuid()) @db.VarChar(50) + user1Id Int @map("user1_id") + user2Id Int @map("user2_id") + eventId Int @map("event_id") + roomId Int? @map("room_id") + status String @default("pending") @db.VarChar(20) // 'pending', 'accepted', 'completed' + createdAt DateTime @default(now()) @map("created_at") + user1LastReadAt DateTime? @map("user1_last_read_at") + user2LastReadAt DateTime? @map("user2_last_read_at") // Relations user1 User @relation("MatchUser1", fields: [user1Id], references: [id]) diff --git a/backend/src/routes/dashboard.js b/backend/src/routes/dashboard.js index a907b9a..45cad1a 100644 --- a/backend/src/routes/dashboard.js +++ b/backend/src/routes/dashboard.js @@ -180,6 +180,14 @@ router.get('/', authenticate, async (req, res, next) => { const lastMessage = match.room?.messages[0]; const lastMessageAt = lastMessage ? lastMessage.createdAt : match.createdAt; + // Calculate unread count + const myLastReadAt = isUser1 ? match.user1LastReadAt : match.user2LastReadAt; + const unreadCount = myLastReadAt + ? (match.room?.messages || []).filter( + (m) => m.userId !== userId && new Date(m.createdAt) > new Date(myLastReadAt) + ).length + : (match.room?.messages || []).filter((m) => m.userId !== userId).length; + return { id: match.id, slug: match.slug, @@ -197,6 +205,7 @@ router.get('/', authenticate, async (req, res, next) => { }, videoExchange, ratings, + unreadCount, lastMessageAt, status: match.status, }; diff --git a/backend/src/socket/index.js b/backend/src/socket/index.js index 308e034..33f037f 100644 --- a/backend/src/socket/index.js +++ b/backend/src/socket/index.js @@ -268,11 +268,35 @@ function initializeSocket(httpServer) { }); // Join private match room - socket.on('join_match_room', ({ matchId }) => { - const roomName = `match_${matchId}`; - socket.join(roomName); - socket.currentMatchRoom = roomName; - console.log(`👥 ${socket.user.username} joined match room ${matchId}`); + socket.on('join_match_room', async ({ matchId }) => { + try { + const roomName = `match_${matchId}`; + socket.join(roomName); + socket.currentMatchRoom = roomName; + socket.currentMatchId = parseInt(matchId); + console.log(`👥 ${socket.user.username} joined match room ${matchId}`); + + // Mark messages as read by updating lastReadAt + const match = await prisma.match.findUnique({ + where: { id: parseInt(matchId) }, + select: { user1Id: true, user2Id: true }, + }); + + if (match) { + const isUser1 = match.user1Id === socket.user.id; + const updateData = isUser1 + ? { user1LastReadAt: new Date() } + : { user2LastReadAt: new Date() }; + + await prisma.match.update({ + where: { id: parseInt(matchId) }, + data: updateData, + }); + } + } catch (error) { + console.error('Join match room error:', error); + socket.emit('error', { message: 'Failed to join match room' }); + } }); // Send message to match room diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 58bce6c..d5109ec 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -356,7 +356,7 @@ const EventCard = ({ event }) => { // Match Card Component const MatchCard = ({ match }) => { const navigate = useNavigate(); - const { partner, event, videoExchange, ratings } = match; + const { partner, event, videoExchange, ratings, unreadCount } = match; // Can rate when video exchange is complete and user hasn't rated yet const canRate = videoExchange?.sentByMe && videoExchange?.receivedFromPartner && !ratings?.ratedByMe; @@ -365,12 +365,17 @@ const MatchCard = ({ match }) => {
{/* Avatar */} - + {partner.username} + {unreadCount > 0 && ( + + {unreadCount > 9 ? '9+' : unreadCount} + + )} {/* Content */} diff --git a/frontend/src/pages/__tests__/DashboardPage.test.jsx b/frontend/src/pages/__tests__/DashboardPage.test.jsx index 135d5f5..dd3833e 100644 --- a/frontend/src/pages/__tests__/DashboardPage.test.jsx +++ b/frontend/src/pages/__tests__/DashboardPage.test.jsx @@ -507,6 +507,68 @@ describe('DashboardPage', () => { }); }); + describe('Unread Count Display', () => { + it('should display unread count badge when there are unread messages', async () => { + dashboardAPI.getData.mockResolvedValue({ + activeEvents: [], + activeMatches: [ + { + id: 1, + slug: 'match-123', + partner: { + id: 2, + username: 'sarah_dancer', + firstName: 'Sarah', + lastName: 'Martinez', + avatar: null, + }, + event: { id: 1, name: 'Test Event' }, + videoExchange: { sentByMe: false, receivedFromPartner: false }, + ratings: { ratedByMe: false, ratedByPartner: false }, + unreadCount: 5, + }, + ], + matchRequests: { incoming: [], outgoing: [] }, + }); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByText('5')).toBeInTheDocument(); + }); + }); + + it('should show 9+ for more than 9 unread messages', async () => { + dashboardAPI.getData.mockResolvedValue({ + activeEvents: [], + activeMatches: [ + { + id: 1, + slug: 'match-123', + partner: { + id: 2, + username: 'sarah_dancer', + firstName: 'Sarah', + lastName: 'Martinez', + avatar: null, + }, + event: { id: 1, name: 'Test Event' }, + videoExchange: { sentByMe: false, receivedFromPartner: false }, + ratings: { ratedByMe: false, ratedByPartner: false }, + unreadCount: 15, + }, + ], + matchRequests: { incoming: [], outgoing: [] }, + }); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByText('9+')).toBeInTheDocument(); + }); + }); + }); + describe('Rating Status Display', () => { it('should show rating status indicators', async () => { dashboardAPI.getData.mockResolvedValue({