diff --git a/backend/src/app.js b/backend/src/app.js index f929391..e671933 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -79,7 +79,7 @@ app.use('/api/events', require('./routes/events')); app.use('/api/wsdc', require('./routes/wsdc')); app.use('/api/divisions', require('./routes/divisions')); app.use('/api/competition-types', require('./routes/competitionTypes')); -// app.use('/api/matches', require('./routes/matches')); +app.use('/api/matches', require('./routes/matches')); // app.use('/api/ratings', require('./routes/ratings')); // 404 handler diff --git a/backend/src/routes/matches.js b/backend/src/routes/matches.js new file mode 100644 index 0000000..f572b51 --- /dev/null +++ b/backend/src/routes/matches.js @@ -0,0 +1,618 @@ +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, + 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, + 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/:id - Get specific match +router.get('/:id', authenticate, async (req, res, next) => { + try { + const { id } = req.params; + const userId = req.user.id; + + const match = await prisma.match.findUnique({ + where: { id: parseInt(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, + 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; + + res.json({ + success: true, + data: { + id: match.id, + 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, + }, + }); + } catch (error) { + next(error); + } +}); + +// PUT /api/matches/:id/accept - Accept a pending match +router.put('/:id/accept', authenticate, async (req, res, next) => { + try { + const { id } = req.params; + const userId = req.user.id; + + // Find match + const match = await prisma.match.findUnique({ + where: { id: parseInt(id) }, + 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: { id: parseInt(id) }, + 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, + 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, + 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/:id - Reject or cancel a match +router.delete('/:id', authenticate, async (req, res, next) => { + try { + const { id } = req.params; + const userId = req.user.id; + + // Find match + const match = await prisma.match.findUnique({ + where: { id: parseInt(id) }, + 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: { id: parseInt(id) }, + }); + + // 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, + 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); + } +}); + +module.exports = router; diff --git a/backend/src/socket/index.js b/backend/src/socket/index.js index c8f8b6a..12c3d90 100644 --- a/backend/src/socket/index.js +++ b/backend/src/socket/index.js @@ -63,6 +63,11 @@ function initializeSocket(httpServer) { io.on('connection', (socket) => { console.log(`✅ User connected: ${socket.user.username} (${socket.id})`); + // Join user's personal room for notifications + const userRoom = `user_${socket.user.id}`; + socket.join(userRoom); + console.log(`📬 ${socket.user.username} joined personal room: ${userRoom}`); + // Join event room socket.on('join_event_room', async ({ slug }) => { try { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8f3b04a..cef67ec 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -10,6 +10,7 @@ import EventChatPage from './pages/EventChatPage'; import EventDetailsPage from './pages/EventDetailsPage'; import EventCheckinPage from './pages/EventCheckinPage'; import MatchChatPage from './pages/MatchChatPage'; +import MatchesPage from './pages/MatchesPage'; import RatePartnerPage from './pages/RatePartnerPage'; import HistoryPage from './pages/HistoryPage'; import ProfilePage from './pages/ProfilePage'; @@ -118,6 +119,14 @@ function App() { } /> + + + + } + /> { const { user, logout } = useAuth(); const navigate = useNavigate(); + const [pendingMatchesCount, setPendingMatchesCount] = useState(0); const handleLogout = () => { logout(); navigate('/login'); }; + useEffect(() => { + if (user) { + loadPendingMatches(); + + // Connect to socket for real-time updates + const token = localStorage.getItem('token'); + if (token) { + connectSocket(token, user.id); + + const socket = getSocket(); + if (socket) { + // Listen for match notifications + socket.on('match_request_received', () => loadPendingMatches()); + socket.on('match_accepted', () => loadPendingMatches()); + socket.on('match_cancelled', () => loadPendingMatches()); + } + } + + return () => { + const socket = getSocket(); + if (socket) { + socket.off('match_request_received'); + socket.off('match_accepted'); + socket.off('match_cancelled'); + } + }; + } + }, [user]); + + const loadPendingMatches = async () => { + try { + const result = await matchesAPI.getMatches(null, 'pending'); + // Only count incoming requests (where user is not the initiator) + const incomingCount = (result.data || []).filter(m => !m.isInitiator).length; + setPendingMatchesCount(incomingCount); + } catch (error) { + console.error('Failed to load pending matches:', error); + } + }; + if (!user) return null; return ( @@ -25,6 +69,19 @@ const Navbar = () => {
+ + + Matches + {pendingMatchesCount > 0 && ( + + {pendingMatchesCount} + + )} + + { @@ -252,12 +252,29 @@ const EventChatPage = () => { } }; - const handleMatchWith = (userId) => { - // TODO: Implement match request - alert(`Match request sent to user!`); - setTimeout(() => { - navigate(`/matches/1/chat`); - }, 1000); + const handleMatchWith = async (userId) => { + try { + const result = await matchesAPI.createMatch(userId, slug); + + // Show success message + alert(`Match request sent successfully! The user will be notified.`); + + // Optional: Navigate to matches page or refresh matches list + // For now, we just show a success message + } catch (error) { + console.error('Failed to send match request:', error); + + // Show appropriate error message + if (error.status === 400 && error.message.includes('already exists')) { + alert('You already have a match request with this user.'); + } else if (error.status === 403) { + alert('You must be a participant of this event to send match requests.'); + } else if (error.status === 404) { + alert('Event not found.'); + } else { + alert('Failed to send match request. Please try again.'); + } + } }; const handleHeatsSave = () => { diff --git a/frontend/src/pages/MatchesPage.jsx b/frontend/src/pages/MatchesPage.jsx new file mode 100644 index 0000000..3fe142b --- /dev/null +++ b/frontend/src/pages/MatchesPage.jsx @@ -0,0 +1,345 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import Layout from '../components/layout/Layout'; +import { useAuth } from '../contexts/AuthContext'; +import { matchesAPI } from '../services/api'; +import { MessageCircle, Check, X, Loader2, Users, Calendar, MapPin } from 'lucide-react'; +import { connectSocket, disconnectSocket, getSocket } from '../services/socket'; + +const MatchesPage = () => { + const { user } = useAuth(); + const navigate = useNavigate(); + const [matches, setMatches] = useState([]); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState('all'); // 'all', 'pending', 'accepted' + const [processingMatchId, setProcessingMatchId] = useState(null); + + useEffect(() => { + loadMatches(); + + // Connect to socket for real-time updates + const token = localStorage.getItem('token'); + if (token && user) { + connectSocket(token, user.id); + + const socket = getSocket(); + if (socket) { + // Listen for match notifications + socket.on('match_request_received', handleMatchRequestReceived); + socket.on('match_accepted', handleMatchAccepted); + socket.on('match_cancelled', handleMatchCancelled); + } + } + + return () => { + const socket = getSocket(); + if (socket) { + socket.off('match_request_received', handleMatchRequestReceived); + socket.off('match_accepted', handleMatchAccepted); + socket.off('match_cancelled', handleMatchCancelled); + } + disconnectSocket(); + }; + }, [user]); + + const loadMatches = async () => { + try { + setLoading(true); + const result = await matchesAPI.getMatches(); + setMatches(result.data || []); + } catch (error) { + console.error('Failed to load matches:', error); + } finally { + setLoading(false); + } + }; + + const handleMatchRequestReceived = (data) => { + // Reload matches to show new request + loadMatches(); + }; + + const handleMatchAccepted = (data) => { + // Reload matches to update status + loadMatches(); + }; + + const handleMatchCancelled = (data) => { + // Remove cancelled match from list + setMatches(prev => prev.filter(m => m.id !== data.matchId)); + }; + + const handleAccept = async (matchId) => { + try { + setProcessingMatchId(matchId); + await matchesAPI.acceptMatch(matchId); + + // Reload matches + await loadMatches(); + + // Show success message + alert('Match accepted! You can now chat with your partner.'); + } catch (error) { + console.error('Failed to accept match:', error); + alert('Failed to accept match. Please try again.'); + } finally { + setProcessingMatchId(null); + } + }; + + const handleReject = async (matchId) => { + if (!confirm('Are you sure you want to reject this match request?')) { + return; + } + + try { + setProcessingMatchId(matchId); + await matchesAPI.deleteMatch(matchId); + + // Remove from list + setMatches(prev => prev.filter(m => m.id !== matchId)); + } catch (error) { + console.error('Failed to reject match:', error); + alert('Failed to reject match. Please try again.'); + } finally { + setProcessingMatchId(null); + } + }; + + const handleOpenChat = (match) => { + if (match.status === 'accepted' && match.roomId) { + navigate(`/matches/${match.id}/chat`); + } + }; + + // Filter matches based on selected filter + const filteredMatches = matches.filter(match => { + if (filter === 'all') return true; + return match.status === filter; + }); + + // Separate pending incoming matches (where user is recipient) + const pendingIncoming = filteredMatches.filter(m => m.status === 'pending' && !m.isInitiator); + const otherMatches = filteredMatches.filter(m => !(m.status === 'pending' && !m.isInitiator)); + + return ( + +
+
+

Match Requests

+

+ Manage your dance partner connections +

+
+ + {/* Filter Tabs */} +
+ + + +
+ + {loading ? ( +
+ +
+ ) : ( + <> + {/* Pending Incoming Requests Section */} + {pendingIncoming.length > 0 && ( +
+

+ + Incoming Requests +

+
+ {pendingIncoming.map(match => ( + + ))} +
+
+ )} + + {/* Other Matches */} + {otherMatches.length > 0 && ( +
+

+ {pendingIncoming.length > 0 ? 'Other Matches' : 'Your Matches'} +

+
+ {otherMatches.map(match => ( + + ))} +
+
+ )} + + {/* Empty State */} + {filteredMatches.length === 0 && ( +
+ +

+ No matches found +

+

+ {filter === 'all' + ? 'You have no match requests yet. Connect with other dancers at events!' + : `You have no ${filter} matches.`} +

+
+ )} + + )} +
+
+ ); +}; + +const MatchCard = ({ match, onAccept, onReject, onOpenChat, processing }) => { + const isIncoming = !match.isInitiator && match.status === 'pending'; + const isOutgoing = match.isInitiator && match.status === 'pending'; + const isAccepted = match.status === 'accepted'; + + return ( +
+
+
+
+ {match.partner.username} +
+

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

+

@{match.partner.username}

+
+
+ +
+
+ + {match.event.name} +
+
+ + {match.event.location} +
+
+ +
+ {isIncoming && ( + + Incoming Request + + )} + {isOutgoing && ( + + Sent Request + + )} + {isAccepted && ( + + Active Match + + )} +
+
+ +
+ {isIncoming && ( + <> + + + + )} + + {isOutgoing && ( + + )} + + {isAccepted && ( + + )} +
+
+
+ ); +}; + +export default MatchesPage; diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 84c7d52..3905269 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -264,4 +264,46 @@ export const heatsAPI = { }, }; +// Matches API (Phase 2) +export const matchesAPI = { + async createMatch(targetUserId, eventSlug) { + const data = await fetchAPI('/matches', { + method: 'POST', + body: JSON.stringify({ targetUserId, eventSlug }), + }); + return data; + }, + + async getMatches(eventSlug = null, status = null) { + const params = new URLSearchParams(); + if (eventSlug) params.append('eventSlug', eventSlug); + if (status) params.append('status', status); + + const queryString = params.toString(); + const endpoint = queryString ? `/matches?${queryString}` : '/matches'; + + const data = await fetchAPI(endpoint); + return data; + }, + + async getMatch(matchId) { + const data = await fetchAPI(`/matches/${matchId}`); + return data; + }, + + async acceptMatch(matchId) { + const data = await fetchAPI(`/matches/${matchId}/accept`, { + method: 'PUT', + }); + return data; + }, + + async deleteMatch(matchId) { + const data = await fetchAPI(`/matches/${matchId}`, { + method: 'DELETE', + }); + return data; + }, +}; + export { ApiError };