Complete the match lifecycle with partner rating functionality. Backend changes: - Add POST /api/matches/:slug/ratings endpoint to create ratings * Validate score range (1-5) * Prevent duplicate ratings (unique constraint per match+rater+rated) * Auto-complete match when both users have rated * Return detailed rating data with user and event info - Add GET /api/users/:username/ratings endpoint to fetch user ratings * Calculate and return average rating * Include rater details and event context for each rating * Limit to last 50 ratings - Add hasRated field to GET /api/matches/:slug response * Check if current user has already rated the match * Enable frontend to prevent duplicate rating attempts Frontend changes: - Update RatePartnerPage to use real API instead of mocks * Load match data and partner info * Submit ratings with score, comment, and wouldCollaborateAgain * Check hasRated flag and redirect if already rated * Validate match status before allowing rating * Show loading state and proper error handling - Update MatchChatPage to show rating status * Replace "Rate Partner" button with "✓ Rated" badge when user has rated * Improve button text from "End & rate" to "Rate Partner" - Add ratings API functions * matchesAPI.createRating(slug, ratingData) * ratingsAPI.getUserRatings(username) User flow: 1. After match is accepted, users can rate each other 2. Click "Rate Partner" in chat to navigate to rating page 3. Submit 1-5 star rating with optional comment 4. Rating saved and user redirected to matches list 5. Chat shows "✓ Rated" badge instead of rating button 6. Match marked as 'completed' when both users have rated 7. Users cannot rate the same match twice
852 lines
20 KiB
JavaScript
852 lines
20 KiB
JavaScript
const express = require('express');
|
|
const { prisma } = require('../utils/db');
|
|
const { authenticate } = require('../middleware/auth');
|
|
const { getIO } = require('../socket');
|
|
|
|
const router = express.Router();
|
|
|
|
// POST /api/matches - Create a match request
|
|
router.post('/', authenticate, async (req, res, next) => {
|
|
try {
|
|
const { targetUserId, eventSlug } = req.body;
|
|
const requesterId = req.user.id;
|
|
|
|
// Validation
|
|
if (!targetUserId || !eventSlug) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'targetUserId and eventSlug are required',
|
|
});
|
|
}
|
|
|
|
if (requesterId === targetUserId) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'Cannot create match with yourself',
|
|
});
|
|
}
|
|
|
|
// Find event by slug
|
|
const event = await prisma.event.findUnique({
|
|
where: { slug: eventSlug },
|
|
select: { id: true, name: true },
|
|
});
|
|
|
|
if (!event) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: 'Event not found',
|
|
});
|
|
}
|
|
|
|
// Check if both users are participants
|
|
const [requesterParticipant, targetParticipant] = await Promise.all([
|
|
prisma.eventParticipant.findUnique({
|
|
where: {
|
|
userId_eventId: {
|
|
userId: requesterId,
|
|
eventId: event.id,
|
|
},
|
|
},
|
|
}),
|
|
prisma.eventParticipant.findUnique({
|
|
where: {
|
|
userId_eventId: {
|
|
userId: targetUserId,
|
|
eventId: event.id,
|
|
},
|
|
},
|
|
}),
|
|
]);
|
|
|
|
if (!requesterParticipant) {
|
|
return res.status(403).json({
|
|
success: false,
|
|
error: 'You must be a participant of this event',
|
|
});
|
|
}
|
|
|
|
if (!targetParticipant) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'Target user is not a participant of this event',
|
|
});
|
|
}
|
|
|
|
// Check if match already exists (in either direction)
|
|
const existingMatch = await prisma.match.findFirst({
|
|
where: {
|
|
eventId: event.id,
|
|
OR: [
|
|
{ user1Id: requesterId, user2Id: targetUserId },
|
|
{ user1Id: targetUserId, user2Id: requesterId },
|
|
],
|
|
},
|
|
});
|
|
|
|
if (existingMatch) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'Match already exists with this user',
|
|
match: existingMatch,
|
|
});
|
|
}
|
|
|
|
// Create match
|
|
const match = await prisma.match.create({
|
|
data: {
|
|
user1Id: requesterId,
|
|
user2Id: targetUserId,
|
|
eventId: event.id,
|
|
status: 'pending',
|
|
},
|
|
include: {
|
|
user1: {
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
avatar: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
},
|
|
},
|
|
user2: {
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
avatar: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
},
|
|
},
|
|
event: {
|
|
select: {
|
|
id: true,
|
|
slug: true,
|
|
name: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Emit socket event to target user
|
|
try {
|
|
const io = getIO();
|
|
const targetSocketRoom = `user_${targetUserId}`;
|
|
io.to(targetSocketRoom).emit('match_request_received', {
|
|
matchId: match.id,
|
|
matchSlug: match.slug,
|
|
from: {
|
|
id: match.user1.id,
|
|
username: match.user1.username,
|
|
avatar: match.user1.avatar,
|
|
firstName: match.user1.firstName,
|
|
lastName: match.user1.lastName,
|
|
},
|
|
event: {
|
|
slug: match.event.slug,
|
|
name: match.event.name,
|
|
},
|
|
createdAt: match.createdAt,
|
|
});
|
|
} catch (socketError) {
|
|
console.error('Failed to emit match request notification:', socketError);
|
|
}
|
|
|
|
res.status(201).json({
|
|
success: true,
|
|
data: match,
|
|
});
|
|
} catch (error) {
|
|
// Handle unique constraint violation
|
|
if (error.code === 'P2002') {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'Match already exists with this user for this event',
|
|
});
|
|
}
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// GET /api/matches - List matches for current user (optionally filtered by event)
|
|
router.get('/', authenticate, async (req, res, next) => {
|
|
try {
|
|
const userId = req.user.id;
|
|
const { eventSlug, status } = req.query;
|
|
|
|
// Build where clause
|
|
const where = {
|
|
OR: [
|
|
{ user1Id: userId },
|
|
{ user2Id: userId },
|
|
],
|
|
};
|
|
|
|
// Filter by event if provided
|
|
if (eventSlug) {
|
|
const event = await prisma.event.findUnique({
|
|
where: { slug: eventSlug },
|
|
select: { id: true },
|
|
});
|
|
|
|
if (!event) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: 'Event not found',
|
|
});
|
|
}
|
|
|
|
where.eventId = event.id;
|
|
}
|
|
|
|
// Filter by status if provided
|
|
if (status) {
|
|
where.status = status;
|
|
}
|
|
|
|
// Fetch matches
|
|
const matches = await prisma.match.findMany({
|
|
where,
|
|
include: {
|
|
user1: {
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
avatar: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
},
|
|
},
|
|
user2: {
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
avatar: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
},
|
|
},
|
|
event: {
|
|
select: {
|
|
id: true,
|
|
slug: true,
|
|
name: true,
|
|
location: true,
|
|
startDate: true,
|
|
endDate: true,
|
|
},
|
|
},
|
|
room: {
|
|
select: {
|
|
id: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: {
|
|
createdAt: 'desc',
|
|
},
|
|
});
|
|
|
|
// Transform matches to include partner info
|
|
const transformedMatches = matches.map(match => {
|
|
const isUser1 = match.user1Id === userId;
|
|
const partner = isUser1 ? match.user2 : match.user1;
|
|
const isInitiator = match.user1Id === userId;
|
|
|
|
return {
|
|
id: match.id,
|
|
slug: match.slug,
|
|
partner: {
|
|
id: partner.id,
|
|
username: partner.username,
|
|
avatar: partner.avatar,
|
|
firstName: partner.firstName,
|
|
lastName: partner.lastName,
|
|
},
|
|
event: match.event,
|
|
status: match.status,
|
|
roomId: match.roomId,
|
|
isInitiator,
|
|
createdAt: match.createdAt,
|
|
};
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
count: transformedMatches.length,
|
|
data: transformedMatches,
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// GET /api/matches/:slug/messages - Get messages for a match
|
|
router.get('/:slug/messages', authenticate, async (req, res, next) => {
|
|
try {
|
|
const { slug } = req.params;
|
|
const userId = req.user.id;
|
|
|
|
// Find match
|
|
const match = await prisma.match.findUnique({
|
|
where: { slug },
|
|
select: {
|
|
id: true,
|
|
user1Id: true,
|
|
user2Id: true,
|
|
roomId: true,
|
|
status: true,
|
|
},
|
|
});
|
|
|
|
if (!match) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: 'Match not found',
|
|
});
|
|
}
|
|
|
|
// Check authorization
|
|
if (match.user1Id !== userId && match.user2Id !== userId) {
|
|
return res.status(403).json({
|
|
success: false,
|
|
error: 'You are not authorized to view messages for this match',
|
|
});
|
|
}
|
|
|
|
// Check if match is accepted
|
|
if (match.status !== 'accepted' || !match.roomId) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'Match must be accepted before viewing messages',
|
|
});
|
|
}
|
|
|
|
// Get messages
|
|
const messages = await prisma.message.findMany({
|
|
where: { roomId: match.roomId },
|
|
include: {
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
avatar: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: { createdAt: 'asc' },
|
|
take: 100, // Last 100 messages
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
count: messages.length,
|
|
data: messages.map(msg => ({
|
|
id: msg.id,
|
|
roomId: msg.roomId,
|
|
userId: msg.user.id,
|
|
username: msg.user.username,
|
|
avatar: msg.user.avatar,
|
|
firstName: msg.user.firstName,
|
|
lastName: msg.user.lastName,
|
|
content: msg.content,
|
|
type: msg.type,
|
|
createdAt: msg.createdAt,
|
|
})),
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// GET /api/matches/:slug - Get specific match
|
|
router.get('/:slug', authenticate, async (req, res, next) => {
|
|
try {
|
|
const { slug } = req.params;
|
|
const userId = req.user.id;
|
|
|
|
const match = await prisma.match.findUnique({
|
|
where: { slug },
|
|
include: {
|
|
user1: {
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
avatar: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
},
|
|
},
|
|
user2: {
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
avatar: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
},
|
|
},
|
|
event: {
|
|
select: {
|
|
id: true,
|
|
slug: true,
|
|
name: true,
|
|
location: true,
|
|
startDate: true,
|
|
endDate: true,
|
|
},
|
|
},
|
|
room: {
|
|
select: {
|
|
id: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!match) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: 'Match not found',
|
|
});
|
|
}
|
|
|
|
// Check authorization
|
|
if (match.user1Id !== userId && match.user2Id !== userId) {
|
|
return res.status(403).json({
|
|
success: false,
|
|
error: 'You are not authorized to view this match',
|
|
});
|
|
}
|
|
|
|
// Transform match data
|
|
const isUser1 = match.user1Id === userId;
|
|
const partner = isUser1 ? match.user2 : match.user1;
|
|
const isInitiator = match.user1Id === userId;
|
|
|
|
// Check if current user has already rated this match
|
|
const userRating = await prisma.rating.findUnique({
|
|
where: {
|
|
matchId_raterId_ratedId: {
|
|
matchId: match.id,
|
|
raterId: userId,
|
|
ratedId: partner.id,
|
|
},
|
|
},
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
id: match.id,
|
|
slug: match.slug,
|
|
partner: {
|
|
id: partner.id,
|
|
username: partner.username,
|
|
avatar: partner.avatar,
|
|
firstName: partner.firstName,
|
|
lastName: partner.lastName,
|
|
},
|
|
event: match.event,
|
|
status: match.status,
|
|
roomId: match.roomId,
|
|
isInitiator,
|
|
hasRated: !!userRating,
|
|
createdAt: match.createdAt,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// PUT /api/matches/:slug/accept - Accept a pending match
|
|
router.put('/:slug/accept', authenticate, async (req, res, next) => {
|
|
try {
|
|
const { slug } = req.params;
|
|
const userId = req.user.id;
|
|
|
|
// Find match
|
|
const match = await prisma.match.findUnique({
|
|
where: { slug },
|
|
include: {
|
|
user1: {
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
avatar: true,
|
|
},
|
|
},
|
|
user2: {
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
avatar: true,
|
|
},
|
|
},
|
|
event: {
|
|
select: {
|
|
id: true,
|
|
slug: true,
|
|
name: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!match) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: 'Match not found',
|
|
});
|
|
}
|
|
|
|
// Check authorization - only user2 can accept
|
|
if (match.user2Id !== userId) {
|
|
return res.status(403).json({
|
|
success: false,
|
|
error: 'Only the match recipient can accept',
|
|
});
|
|
}
|
|
|
|
// Check status
|
|
if (match.status !== 'pending') {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: `Match is already ${match.status}`,
|
|
});
|
|
}
|
|
|
|
// Create private chat room and update match in transaction
|
|
const updatedMatch = await prisma.$transaction(async (tx) => {
|
|
// Create private chat room
|
|
const chatRoom = await tx.chatRoom.create({
|
|
data: {
|
|
type: 'private',
|
|
eventId: match.eventId,
|
|
},
|
|
});
|
|
|
|
// Update match status and link to chat room
|
|
const updated = await tx.match.update({
|
|
where: { slug },
|
|
data: {
|
|
status: 'accepted',
|
|
roomId: chatRoom.id,
|
|
},
|
|
include: {
|
|
user1: {
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
avatar: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
},
|
|
},
|
|
user2: {
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
avatar: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
},
|
|
},
|
|
event: {
|
|
select: {
|
|
id: true,
|
|
slug: true,
|
|
name: true,
|
|
},
|
|
},
|
|
room: {
|
|
select: {
|
|
id: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
return updated;
|
|
});
|
|
|
|
// Emit socket event to both users
|
|
try {
|
|
const io = getIO();
|
|
const user1SocketRoom = `user_${match.user1Id}`;
|
|
const user2SocketRoom = `user_${match.user2Id}`;
|
|
|
|
const notification = {
|
|
matchId: updatedMatch.id,
|
|
matchSlug: updatedMatch.slug,
|
|
roomId: updatedMatch.roomId,
|
|
event: {
|
|
slug: updatedMatch.event.slug,
|
|
name: updatedMatch.event.name,
|
|
},
|
|
};
|
|
|
|
io.to(user1SocketRoom).emit('match_accepted', {
|
|
...notification,
|
|
partner: {
|
|
id: updatedMatch.user2.id,
|
|
username: updatedMatch.user2.username,
|
|
avatar: updatedMatch.user2.avatar,
|
|
},
|
|
});
|
|
|
|
io.to(user2SocketRoom).emit('match_accepted', {
|
|
...notification,
|
|
partner: {
|
|
id: updatedMatch.user1.id,
|
|
username: updatedMatch.user1.username,
|
|
avatar: updatedMatch.user1.avatar,
|
|
},
|
|
});
|
|
} catch (socketError) {
|
|
console.error('Failed to emit match accepted notification:', socketError);
|
|
}
|
|
|
|
// Transform response
|
|
const isUser1 = updatedMatch.user1Id === userId;
|
|
const partner = isUser1 ? updatedMatch.user2 : updatedMatch.user1;
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
id: updatedMatch.id,
|
|
slug: updatedMatch.slug,
|
|
partner: {
|
|
id: partner.id,
|
|
username: partner.username,
|
|
avatar: partner.avatar,
|
|
firstName: partner.firstName,
|
|
lastName: partner.lastName,
|
|
},
|
|
event: updatedMatch.event,
|
|
status: updatedMatch.status,
|
|
roomId: updatedMatch.roomId,
|
|
isInitiator: isUser1,
|
|
createdAt: updatedMatch.createdAt,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// DELETE /api/matches/:slug - Reject or cancel a match
|
|
router.delete('/:slug', authenticate, async (req, res, next) => {
|
|
try {
|
|
const { slug } = req.params;
|
|
const userId = req.user.id;
|
|
|
|
// Find match
|
|
const match = await prisma.match.findUnique({
|
|
where: { slug },
|
|
include: {
|
|
event: {
|
|
select: {
|
|
slug: true,
|
|
name: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!match) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: 'Match not found',
|
|
});
|
|
}
|
|
|
|
// Check authorization - both users can delete
|
|
if (match.user1Id !== userId && match.user2Id !== userId) {
|
|
return res.status(403).json({
|
|
success: false,
|
|
error: 'You are not authorized to delete this match',
|
|
});
|
|
}
|
|
|
|
// Cannot delete completed matches
|
|
if (match.status === 'completed') {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'Cannot delete completed matches',
|
|
});
|
|
}
|
|
|
|
// Delete match (will cascade delete chat room if exists)
|
|
await prisma.match.delete({
|
|
where: { slug },
|
|
});
|
|
|
|
// Emit socket event to the other user
|
|
try {
|
|
const io = getIO();
|
|
const otherUserId = match.user1Id === userId ? match.user2Id : match.user1Id;
|
|
const otherUserSocketRoom = `user_${otherUserId}`;
|
|
|
|
io.to(otherUserSocketRoom).emit('match_cancelled', {
|
|
matchId: match.id,
|
|
matchSlug: match.slug,
|
|
event: {
|
|
slug: match.event.slug,
|
|
name: match.event.name,
|
|
},
|
|
});
|
|
} catch (socketError) {
|
|
console.error('Failed to emit match cancelled notification:', socketError);
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'Match deleted successfully',
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// POST /api/matches/:slug/ratings - Rate a partner after match
|
|
router.post('/:slug/ratings', authenticate, async (req, res, next) => {
|
|
try {
|
|
const { slug } = req.params;
|
|
const userId = req.user.id;
|
|
const { score, comment, wouldCollaborateAgain } = req.body;
|
|
|
|
// Validation
|
|
if (!score || score < 1 || score > 5) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'Score must be between 1 and 5',
|
|
});
|
|
}
|
|
|
|
// Find match
|
|
const match = await prisma.match.findUnique({
|
|
where: { slug },
|
|
select: {
|
|
id: true,
|
|
user1Id: true,
|
|
user2Id: true,
|
|
status: true,
|
|
},
|
|
});
|
|
|
|
if (!match) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: 'Match not found',
|
|
});
|
|
}
|
|
|
|
// Check authorization - user must be part of this match
|
|
if (match.user1Id !== userId && match.user2Id !== userId) {
|
|
return res.status(403).json({
|
|
success: false,
|
|
error: 'You are not authorized to rate this match',
|
|
});
|
|
}
|
|
|
|
// Check if match is accepted
|
|
if (match.status !== 'accepted' && match.status !== 'completed') {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'Match must be accepted before rating',
|
|
});
|
|
}
|
|
|
|
// Determine who is being rated (the other user in the match)
|
|
const ratedUserId = match.user1Id === userId ? match.user2Id : match.user1Id;
|
|
|
|
// Check if user already rated this match
|
|
const existingRating = await prisma.rating.findUnique({
|
|
where: {
|
|
matchId_raterId_ratedId: {
|
|
matchId: match.id,
|
|
raterId: userId,
|
|
ratedId: ratedUserId,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (existingRating) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'You have already rated this match',
|
|
});
|
|
}
|
|
|
|
// Create rating
|
|
const rating = await prisma.rating.create({
|
|
data: {
|
|
matchId: match.id,
|
|
raterId: userId,
|
|
ratedId: ratedUserId,
|
|
score,
|
|
comment: comment || null,
|
|
wouldCollaborateAgain: wouldCollaborateAgain || false,
|
|
},
|
|
include: {
|
|
rater: {
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
},
|
|
},
|
|
rated: {
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Check if both users have rated - if so, mark match as completed
|
|
const otherUserRating = await prisma.rating.findUnique({
|
|
where: {
|
|
matchId_raterId_ratedId: {
|
|
matchId: match.id,
|
|
raterId: ratedUserId,
|
|
ratedId: userId,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (otherUserRating) {
|
|
// Both users have rated - mark match as completed
|
|
await prisma.match.update({
|
|
where: { id: match.id },
|
|
data: { status: 'completed' },
|
|
});
|
|
}
|
|
|
|
res.status(201).json({
|
|
success: true,
|
|
data: rating,
|
|
});
|
|
} catch (error) {
|
|
// Handle unique constraint violation
|
|
if (error.code === 'P2002') {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'You have already rated this match',
|
|
});
|
|
}
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|