feat(dashboard): add unread count for match chats
Track unread messages in match chats and display count badge: - Schema: Add user1LastReadAt/user2LastReadAt to Match model - Backend: Calculate unreadCount in dashboard API - Socket: Update lastReadAt when user joins match room - Frontend: Display red badge with unread count on match avatar
This commit is contained in:
@@ -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;
|
||||||
@@ -134,14 +134,16 @@ model Message {
|
|||||||
|
|
||||||
// Matches (pairs of users for collaboration)
|
// Matches (pairs of users for collaboration)
|
||||||
model Match {
|
model Match {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
slug String @unique @default(cuid()) @db.VarChar(50)
|
slug String @unique @default(cuid()) @db.VarChar(50)
|
||||||
user1Id Int @map("user1_id")
|
user1Id Int @map("user1_id")
|
||||||
user2Id Int @map("user2_id")
|
user2Id Int @map("user2_id")
|
||||||
eventId Int @map("event_id")
|
eventId Int @map("event_id")
|
||||||
roomId Int? @map("room_id")
|
roomId Int? @map("room_id")
|
||||||
status String @default("pending") @db.VarChar(20) // 'pending', 'accepted', 'completed'
|
status String @default("pending") @db.VarChar(20) // 'pending', 'accepted', 'completed'
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
user1LastReadAt DateTime? @map("user1_last_read_at")
|
||||||
|
user2LastReadAt DateTime? @map("user2_last_read_at")
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
user1 User @relation("MatchUser1", fields: [user1Id], references: [id])
|
user1 User @relation("MatchUser1", fields: [user1Id], references: [id])
|
||||||
|
|||||||
@@ -180,6 +180,14 @@ router.get('/', authenticate, async (req, res, next) => {
|
|||||||
const lastMessage = match.room?.messages[0];
|
const lastMessage = match.room?.messages[0];
|
||||||
const lastMessageAt = lastMessage ? lastMessage.createdAt : match.createdAt;
|
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 {
|
return {
|
||||||
id: match.id,
|
id: match.id,
|
||||||
slug: match.slug,
|
slug: match.slug,
|
||||||
@@ -197,6 +205,7 @@ router.get('/', authenticate, async (req, res, next) => {
|
|||||||
},
|
},
|
||||||
videoExchange,
|
videoExchange,
|
||||||
ratings,
|
ratings,
|
||||||
|
unreadCount,
|
||||||
lastMessageAt,
|
lastMessageAt,
|
||||||
status: match.status,
|
status: match.status,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -268,11 +268,35 @@ function initializeSocket(httpServer) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Join private match room
|
// Join private match room
|
||||||
socket.on('join_match_room', ({ matchId }) => {
|
socket.on('join_match_room', async ({ matchId }) => {
|
||||||
const roomName = `match_${matchId}`;
|
try {
|
||||||
socket.join(roomName);
|
const roomName = `match_${matchId}`;
|
||||||
socket.currentMatchRoom = roomName;
|
socket.join(roomName);
|
||||||
console.log(`👥 ${socket.user.username} joined match room ${matchId}`);
|
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
|
// Send message to match room
|
||||||
|
|||||||
@@ -356,7 +356,7 @@ const EventCard = ({ event }) => {
|
|||||||
// Match Card Component
|
// Match Card Component
|
||||||
const MatchCard = ({ match }) => {
|
const MatchCard = ({ match }) => {
|
||||||
const navigate = useNavigate();
|
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
|
// Can rate when video exchange is complete and user hasn't rated yet
|
||||||
const canRate = videoExchange?.sentByMe && videoExchange?.receivedFromPartner && !ratings?.ratedByMe;
|
const canRate = videoExchange?.sentByMe && videoExchange?.receivedFromPartner && !ratings?.ratedByMe;
|
||||||
@@ -365,12 +365,17 @@ const MatchCard = ({ match }) => {
|
|||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 hover:shadow-md transition-shadow">
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 hover:shadow-md transition-shadow">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<Link to={`/${partner.username}`} className="flex-shrink-0">
|
<Link to={`/${partner.username}`} className="flex-shrink-0 relative">
|
||||||
<img
|
<img
|
||||||
src={partner.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${partner.username}`}
|
src={partner.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${partner.username}`}
|
||||||
alt={partner.username}
|
alt={partner.username}
|
||||||
className="w-12 h-12 rounded-full hover:ring-2 hover:ring-primary-500 transition-all"
|
className="w-12 h-12 rounded-full hover:ring-2 hover:ring-primary-500 transition-all"
|
||||||
/>
|
/>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center font-semibold">
|
||||||
|
{unreadCount > 9 ? '9+' : unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
|
|||||||
@@ -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(<DashboardPage />);
|
||||||
|
|
||||||
|
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(<DashboardPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('9+')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Rating Status Display', () => {
|
describe('Rating Status Display', () => {
|
||||||
it('should show rating status indicators', async () => {
|
it('should show rating status indicators', async () => {
|
||||||
dashboardAPI.getData.mockResolvedValue({
|
dashboardAPI.getData.mockResolvedValue({
|
||||||
|
|||||||
Reference in New Issue
Block a user