From 49e492a8f822290522af2f32bbbe19d3cf000584 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Gierwia=C5=82o?= Date: Fri, 14 Nov 2025 22:35:32 +0100 Subject: [PATCH] feat: implement Ratings API (Phase 2.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/src/routes/matches.js | 147 +++++++++++++++++++++++++ backend/src/routes/users.js | 88 +++++++++++++++ frontend/src/pages/MatchChatPage.jsx | 19 +++- frontend/src/pages/RatePartnerPage.jsx | 87 ++++++++++++--- frontend/src/services/api.js | 16 +++ 5 files changed, 338 insertions(+), 19 deletions(-) diff --git a/backend/src/routes/matches.js b/backend/src/routes/matches.js index d734ae9..60510e7 100644 --- a/backend/src/routes/matches.js +++ b/backend/src/routes/matches.js @@ -427,6 +427,17 @@ router.get('/:slug', authenticate, async (req, res, next) => { 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: { @@ -443,6 +454,7 @@ router.get('/:slug', authenticate, async (req, res, next) => { status: match.status, roomId: match.roomId, isInitiator, + hasRated: !!userRating, createdAt: match.createdAt, }, }); @@ -701,4 +713,139 @@ router.delete('/:slug', authenticate, async (req, res, next) => { } }); +// 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; diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index ca36d70..6b62646 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -148,4 +148,92 @@ router.get('/:username', authenticate, async (req, res, next) => { } }); +// GET /api/users/:username/ratings - Get ratings for a user +router.get('/:username/ratings', authenticate, async (req, res, next) => { + try { + const { username } = req.params; + + // Find user + const user = await prisma.user.findUnique({ + where: { username }, + select: { id: true, username: true }, + }); + + if (!user) { + return res.status(404).json({ + success: false, + error: 'User not found', + }); + } + + // Get ratings received by this user + const ratings = await prisma.rating.findMany({ + where: { ratedId: user.id }, + include: { + rater: { + select: { + id: true, + username: true, + firstName: true, + lastName: true, + avatar: true, + }, + }, + match: { + select: { + id: true, + slug: true, + event: { + select: { + id: true, + slug: true, + name: true, + startDate: true, + }, + }, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + take: 50, // Last 50 ratings + }); + + // Calculate average + const averageRating = ratings.length > 0 + ? ratings.reduce((sum, r) => sum + r.score, 0) / ratings.length + : 0; + + res.json({ + success: true, + data: { + username: user.username, + averageRating: averageRating.toFixed(1), + ratingsCount: ratings.length, + ratings: ratings.map(r => ({ + id: r.id, + score: r.score, + comment: r.comment, + wouldCollaborateAgain: r.wouldCollaborateAgain, + createdAt: r.createdAt, + rater: { + id: r.rater.id, + username: r.rater.username, + firstName: r.rater.firstName, + lastName: r.rater.lastName, + avatar: r.rater.avatar, + }, + event: { + id: r.match.event.id, + slug: r.match.event.slug, + name: r.match.event.name, + startDate: r.match.event.startDate, + }, + })), + }, + }); + } catch (error) { + next(error); + } +}); + module.exports = router; diff --git a/frontend/src/pages/MatchChatPage.jsx b/frontend/src/pages/MatchChatPage.jsx index 5622963..c888a51 100644 --- a/frontend/src/pages/MatchChatPage.jsx +++ b/frontend/src/pages/MatchChatPage.jsx @@ -266,12 +266,19 @@ const MatchChatPage = () => {

@{partner.username}

- + {!match.hasRated && ( + + )} + {match.hasRated && ( +
+ ✓ Rated +
+ )} diff --git a/frontend/src/pages/RatePartnerPage.jsx b/frontend/src/pages/RatePartnerPage.jsx index 9931e47..dbb992f 100644 --- a/frontend/src/pages/RatePartnerPage.jsx +++ b/frontend/src/pages/RatePartnerPage.jsx @@ -1,20 +1,53 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import Layout from '../components/layout/Layout'; -import { mockUsers } from '../mocks/users'; -import { Star } from 'lucide-react'; +import { matchesAPI } from '../services/api'; +import { Star, Loader2 } from 'lucide-react'; const RatePartnerPage = () => { const { slug } = useParams(); const navigate = useNavigate(); + const [match, setMatch] = useState(null); + const [loading, setLoading] = useState(true); const [rating, setRating] = useState(0); const [hoveredRating, setHoveredRating] = useState(0); const [comment, setComment] = useState(''); const [wouldCollaborateAgain, setWouldCollaborateAgain] = useState(true); const [submitting, setSubmitting] = useState(false); - // Partner user (mockup) - const partner = mockUsers[1]; // sarah_swing + // Load match data and check if already rated + useEffect(() => { + const loadMatch = async () => { + try { + setLoading(true); + const result = await matchesAPI.getMatch(slug); + setMatch(result.data); + + // Check if this match can be rated + if (result.data.status !== 'accepted' && result.data.status !== 'completed') { + alert('This match must be accepted before rating.'); + navigate('/matches'); + return; + } + + // Check if user has already rated this match + if (result.data.hasRated) { + alert('You have already rated this match.'); + navigate('/matches'); + return; + } + } catch (error) { + console.error('Failed to load match:', error); + alert('Failed to load match. Redirecting to matches page.'); + navigate('/matches'); + } finally { + setLoading(false); + } + }; + loadMatch(); + }, [slug, navigate]); + + const partner = match?.partner; const handleSubmit = async (e) => { e.preventDefault(); @@ -25,13 +58,37 @@ const RatePartnerPage = () => { setSubmitting(true); - // Mockup - in the future will be API call - setTimeout(() => { - alert('Rating saved!'); - navigate('/history'); - }, 500); + try { + await matchesAPI.createRating(slug, { + score: rating, + comment: comment.trim() || null, + wouldCollaborateAgain, + }); + + alert('Rating submitted successfully!'); + navigate('/matches'); + } catch (error) { + console.error('Failed to submit rating:', error); + if (error.message?.includes('already rated')) { + alert('You have already rated this match.'); + } else { + alert('Failed to submit rating. Please try again.'); + } + } finally { + setSubmitting(false); + } }; + if (loading || !match || !partner) { + return ( + +
+ +
+
+ ); + } + return (
@@ -43,13 +100,17 @@ const RatePartnerPage = () => { {/* Partner Info */}
{partner.username}
-

{partner.username}

-

⭐ {partner.rating} • {partner.matches_count} collaborations

+

+ {partner.firstName && partner.lastName + ? `${partner.firstName} ${partner.lastName}` + : partner.username} +

+

@{partner.username}

diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index df9cbe9..f2357cf 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -309,6 +309,22 @@ export const matchesAPI = { const data = await fetchAPI(`/matches/${matchSlug}/messages`); return data; }, + + async createRating(matchSlug, { score, comment, wouldCollaborateAgain }) { + const data = await fetchAPI(`/matches/${matchSlug}/ratings`, { + method: 'POST', + body: JSON.stringify({ score, comment, wouldCollaborateAgain }), + }); + return data; + }, +}; + +// Ratings API +export const ratingsAPI = { + async getUserRatings(username) { + const data = await fetchAPI(`/users/${username}/ratings`); + return data; + }, }; export { ApiError };