diff --git a/backend/prisma/migrations/20251114183814_add_match_slug/migration.sql b/backend/prisma/migrations/20251114183814_add_match_slug/migration.sql new file mode 100644 index 0000000..abed131 --- /dev/null +++ b/backend/prisma/migrations/20251114183814_add_match_slug/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "matches" ADD COLUMN "slug" VARCHAR(50) NOT NULL DEFAULT gen_random_uuid()::text; + +-- CreateIndex +CREATE UNIQUE INDEX "matches_slug_key" ON "matches"("slug"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 664801f..62c2cca 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -131,6 +131,7 @@ 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") diff --git a/backend/src/routes/matches.js b/backend/src/routes/matches.js index f572b51..d734ae9 100644 --- a/backend/src/routes/matches.js +++ b/backend/src/routes/matches.js @@ -135,6 +135,7 @@ router.post('/', authenticate, async (req, res, next) => { 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, @@ -255,6 +256,7 @@ router.get('/', authenticate, async (req, res, next) => { return { id: match.id, + slug: match.slug, partner: { id: partner.id, username: partner.username, @@ -280,14 +282,94 @@ router.get('/', authenticate, async (req, res, next) => { } }); -// GET /api/matches/:id - Get specific match -router.get('/:id', authenticate, async (req, res, next) => { +// GET /api/matches/:slug/messages - Get messages for a match +router.get('/:slug/messages', authenticate, async (req, res, next) => { try { - const { id } = req.params; + 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: { id: parseInt(id) }, + where: { slug }, include: { user1: { select: { @@ -349,6 +431,7 @@ router.get('/:id', authenticate, async (req, res, next) => { success: true, data: { id: match.id, + slug: match.slug, partner: { id: partner.id, username: partner.username, @@ -368,15 +451,15 @@ router.get('/:id', authenticate, async (req, res, next) => { } }); -// PUT /api/matches/:id/accept - Accept a pending match -router.put('/:id/accept', authenticate, async (req, res, next) => { +// PUT /api/matches/:slug/accept - Accept a pending match +router.put('/:slug/accept', authenticate, async (req, res, next) => { try { - const { id } = req.params; + const { slug } = req.params; const userId = req.user.id; // Find match const match = await prisma.match.findUnique({ - where: { id: parseInt(id) }, + where: { slug }, include: { user1: { select: { @@ -437,7 +520,7 @@ router.put('/:id/accept', authenticate, async (req, res, next) => { // Update match status and link to chat room const updated = await tx.match.update({ - where: { id: parseInt(id) }, + where: { slug }, data: { status: 'accepted', roomId: chatRoom.id, @@ -487,6 +570,7 @@ router.put('/:id/accept', authenticate, async (req, res, next) => { const notification = { matchId: updatedMatch.id, + matchSlug: updatedMatch.slug, roomId: updatedMatch.roomId, event: { slug: updatedMatch.event.slug, @@ -523,6 +607,7 @@ router.put('/:id/accept', authenticate, async (req, res, next) => { success: true, data: { id: updatedMatch.id, + slug: updatedMatch.slug, partner: { id: partner.id, username: partner.username, @@ -542,15 +627,15 @@ router.put('/:id/accept', authenticate, async (req, res, next) => { } }); -// DELETE /api/matches/:id - Reject or cancel a match -router.delete('/:id', authenticate, async (req, res, next) => { +// DELETE /api/matches/:slug - Reject or cancel a match +router.delete('/:slug', authenticate, async (req, res, next) => { try { - const { id } = req.params; + const { slug } = req.params; const userId = req.user.id; // Find match const match = await prisma.match.findUnique({ - where: { id: parseInt(id) }, + where: { slug }, include: { event: { select: { @@ -586,7 +671,7 @@ router.delete('/:id', authenticate, async (req, res, next) => { // Delete match (will cascade delete chat room if exists) await prisma.match.delete({ - where: { id: parseInt(id) }, + where: { slug }, }); // Emit socket event to the other user @@ -597,6 +682,7 @@ router.delete('/:id', authenticate, async (req, res, next) => { io.to(otherUserSocketRoom).emit('match_cancelled', { matchId: match.id, + matchSlug: match.slug, event: { slug: match.event.slug, name: match.event.name, diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index cef67ec..00f532e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -128,7 +128,7 @@ function App() { } /> @@ -136,7 +136,7 @@ function App() { } /> diff --git a/frontend/src/pages/MatchChatPage.jsx b/frontend/src/pages/MatchChatPage.jsx index 4eb238d..5622963 100644 --- a/frontend/src/pages/MatchChatPage.jsx +++ b/frontend/src/pages/MatchChatPage.jsx @@ -2,14 +2,16 @@ import { useState, useRef, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import Layout from '../components/layout/Layout'; import { useAuth } from '../contexts/AuthContext'; -import { mockUsers } from '../mocks/users'; -import { Send, Video, Upload, X, Check, Link as LinkIcon } from 'lucide-react'; +import { matchesAPI } from '../services/api'; +import { Send, Video, Upload, X, Check, Link as LinkIcon, Loader2 } from 'lucide-react'; import { connectSocket, getSocket } from '../services/socket'; const MatchChatPage = () => { - const { matchId } = useParams(); + const { slug } = useParams(); const { user } = useAuth(); const navigate = useNavigate(); + const [match, setMatch] = useState(null); + const [loading, setLoading] = useState(true); const [messages, setMessages] = useState([]); const [newMessage, setNewMessage] = useState(''); const [selectedFile, setSelectedFile] = useState(null); @@ -22,8 +24,40 @@ const MatchChatPage = () => { const messagesEndRef = useRef(null); const fileInputRef = useRef(null); - // Partner user (mockup - TODO: fetch from backend in Phase 2) - const partner = mockUsers[1]; // sarah_swing + // Fetch match data + useEffect(() => { + const loadMatch = async () => { + try { + setLoading(true); + const result = await matchesAPI.getMatch(slug); + setMatch(result.data); + } 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]); + + // Load message history + useEffect(() => { + const loadMessages = async () => { + if (!match) return; + + try { + const result = await matchesAPI.getMatchMessages(slug); + setMessages(result.data || []); + } catch (error) { + console.error('Failed to load messages:', error); + } + }; + loadMessages(); + }, [match, slug]); + + const partner = match?.partner; const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); @@ -34,6 +68,9 @@ const MatchChatPage = () => { }, [messages]); useEffect(() => { + // Wait for match to be loaded + if (!match) return; + // Connect to Socket.IO const socket = connectSocket(); @@ -45,9 +82,9 @@ const MatchChatPage = () => { // Socket event listeners socket.on('connect', () => { setIsConnected(true); - // Join match room - socket.emit('join_match_room', { matchId: parseInt(matchId) }); - console.log(`Joined match room ${matchId}`); + // Join match room using numeric match ID for socket + socket.emit('join_match_room', { matchId: match.id }); + console.log(`Joined match room ${match.id}`); }); socket.on('disconnect', () => { @@ -65,11 +102,11 @@ const MatchChatPage = () => { socket.off('disconnect'); socket.off('match_message'); }; - }, [matchId, user.id]); + }, [match, user.id]); const handleSendMessage = (e) => { e.preventDefault(); - if (!newMessage.trim()) return; + if (!newMessage.trim() || !match) return; const socket = getSocket(); if (!socket || !socket.connected) { @@ -77,9 +114,9 @@ const MatchChatPage = () => { return; } - // Send message via Socket.IO + // Send message via Socket.IO using numeric match ID socket.emit('send_match_message', { - matchId: parseInt(matchId), + matchId: match.id, content: newMessage, }); @@ -168,7 +205,7 @@ const MatchChatPage = () => { }; const handleEndMatch = () => { - navigate(`/matches/${matchId}/rate`); + navigate(`/matches/${slug}/rate`); }; const getWebRTCStatusColor = () => { @@ -197,6 +234,16 @@ const MatchChatPage = () => { } }; + if (loading || !match || !partner) { + return ( + +
+ +
+
+ ); + } + return (
@@ -206,13 +253,17 @@ const MatchChatPage = () => {
{partner.username}
-

{partner.username}

-

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

+

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

+

@{partner.username}

@@ -208,7 +208,7 @@ const MatchesPage = () => { onAccept={handleAccept} onReject={handleReject} onOpenChat={handleOpenChat} - processing={processingMatchId === match.id} + processing={processingMatchId === match.slug} /> ))}
@@ -295,7 +295,7 @@ const MatchCard = ({ match, onAccept, onReject, onOpenChat, processing }) => { {isIncoming && ( <>