feat: add match slugs for security and fix message history loading
Security improvements: - Add random CUID slugs to Match model to prevent ID enumeration attacks - Update all match URLs from /matches/:id to /matches/:slug - Keep numeric IDs for internal Socket.IO operations only Backend changes: - Add slug field to matches table with unique index - Update all match endpoints to use slug-based lookups (GET, PUT, DELETE) - Add GET /api/matches/:slug/messages endpoint to fetch message history - Include matchSlug in all Socket.IO notifications Frontend changes: - Update all match routes to use slug parameter - Update MatchesPage to use slug for accept/reject/navigate operations - Update MatchChatPage to fetch match data by slug and load message history - Update RatePartnerPage to use slug parameter - Add matchesAPI.getMatchMessages() function Bug fixes: - Fix MatchChatPage not loading message history from database on mount - Messages now persist and display correctly when users reconnect
This commit is contained in:
@@ -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 (
|
||||
<Layout>
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary-600" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="max-w-4xl mx-auto">
|
||||
@@ -206,13 +253,17 @@ const MatchChatPage = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<img
|
||||
src={partner.avatar}
|
||||
src={partner.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${partner.username}`}
|
||||
alt={partner.username}
|
||||
className="w-12 h-12 rounded-full border-2 border-white"
|
||||
/>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{partner.username}</h2>
|
||||
<p className="text-sm text-primary-100">⭐ {partner.rating} • {partner.matches_count} collaborations</p>
|
||||
<h2 className="text-xl font-bold">
|
||||
{partner.firstName && partner.lastName
|
||||
? `${partner.firstName} ${partner.lastName}`
|
||||
: partner.username}
|
||||
</h2>
|
||||
<p className="text-sm text-primary-100">@{partner.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -66,13 +66,13 @@ const MatchesPage = () => {
|
||||
|
||||
const handleMatchCancelled = (data) => {
|
||||
// Remove cancelled match from list
|
||||
setMatches(prev => prev.filter(m => m.id !== data.matchId));
|
||||
setMatches(prev => prev.filter(m => m.slug !== data.matchSlug));
|
||||
};
|
||||
|
||||
const handleAccept = async (matchId) => {
|
||||
const handleAccept = async (matchSlug) => {
|
||||
try {
|
||||
setProcessingMatchId(matchId);
|
||||
await matchesAPI.acceptMatch(matchId);
|
||||
setProcessingMatchId(matchSlug);
|
||||
await matchesAPI.acceptMatch(matchSlug);
|
||||
|
||||
// Reload matches
|
||||
await loadMatches();
|
||||
@@ -87,17 +87,17 @@ const MatchesPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (matchId) => {
|
||||
const handleReject = async (matchSlug) => {
|
||||
if (!confirm('Are you sure you want to reject this match request?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setProcessingMatchId(matchId);
|
||||
await matchesAPI.deleteMatch(matchId);
|
||||
setProcessingMatchId(matchSlug);
|
||||
await matchesAPI.deleteMatch(matchSlug);
|
||||
|
||||
// Remove from list
|
||||
setMatches(prev => prev.filter(m => m.id !== matchId));
|
||||
setMatches(prev => prev.filter(m => m.slug !== matchSlug));
|
||||
} catch (error) {
|
||||
console.error('Failed to reject match:', error);
|
||||
alert('Failed to reject match. Please try again.');
|
||||
@@ -108,7 +108,7 @@ const MatchesPage = () => {
|
||||
|
||||
const handleOpenChat = (match) => {
|
||||
if (match.status === 'accepted' && match.roomId) {
|
||||
navigate(`/matches/${match.id}/chat`);
|
||||
navigate(`/matches/${match.slug}/chat`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -187,7 +187,7 @@ const MatchesPage = () => {
|
||||
onAccept={handleAccept}
|
||||
onReject={handleReject}
|
||||
onOpenChat={handleOpenChat}
|
||||
processing={processingMatchId === match.id}
|
||||
processing={processingMatchId === match.slug}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -208,7 +208,7 @@ const MatchesPage = () => {
|
||||
onAccept={handleAccept}
|
||||
onReject={handleReject}
|
||||
onOpenChat={handleOpenChat}
|
||||
processing={processingMatchId === match.id}
|
||||
processing={processingMatchId === match.slug}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -295,7 +295,7 @@ const MatchCard = ({ match, onAccept, onReject, onOpenChat, processing }) => {
|
||||
{isIncoming && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onAccept(match.id)}
|
||||
onClick={() => onAccept(match.slug)}
|
||||
disabled={processing}
|
||||
className="p-2 bg-green-600 text-white rounded-full hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
title="Accept"
|
||||
@@ -307,7 +307,7 @@ const MatchCard = ({ match, onAccept, onReject, onOpenChat, processing }) => {
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onReject(match.id)}
|
||||
onClick={() => onReject(match.slug)}
|
||||
disabled={processing}
|
||||
className="p-2 bg-red-600 text-white rounded-full hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
title="Reject"
|
||||
@@ -319,7 +319,7 @@ const MatchCard = ({ match, onAccept, onReject, onOpenChat, processing }) => {
|
||||
|
||||
{isOutgoing && (
|
||||
<button
|
||||
onClick={() => onReject(match.id)}
|
||||
onClick={() => onReject(match.slug)}
|
||||
disabled={processing}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { mockUsers } from '../mocks/users';
|
||||
import { Star } from 'lucide-react';
|
||||
|
||||
const RatePartnerPage = () => {
|
||||
const { matchId } = useParams();
|
||||
const { slug } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [rating, setRating] = useState(0);
|
||||
const [hoveredRating, setHoveredRating] = useState(0);
|
||||
|
||||
Reference in New Issue
Block a user