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:
Radosław Gierwiało
2025-11-14 22:22:11 +01:00
parent 4a3e32f3b6
commit c2010246e3
8 changed files with 201 additions and 53 deletions

View File

@@ -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");

View File

@@ -131,6 +131,7 @@ model Message {
// Matches (pairs of users for collaboration) // Matches (pairs of users for collaboration)
model Match { model Match {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
slug String @unique @default(cuid()) @db.VarChar(50)
user1Id Int @map("user1_id") user1Id Int @map("user1_id")
user2Id Int @map("user2_id") user2Id Int @map("user2_id")
eventId Int @map("event_id") eventId Int @map("event_id")

View File

@@ -135,6 +135,7 @@ router.post('/', authenticate, async (req, res, next) => {
const targetSocketRoom = `user_${targetUserId}`; const targetSocketRoom = `user_${targetUserId}`;
io.to(targetSocketRoom).emit('match_request_received', { io.to(targetSocketRoom).emit('match_request_received', {
matchId: match.id, matchId: match.id,
matchSlug: match.slug,
from: { from: {
id: match.user1.id, id: match.user1.id,
username: match.user1.username, username: match.user1.username,
@@ -255,6 +256,7 @@ router.get('/', authenticate, async (req, res, next) => {
return { return {
id: match.id, id: match.id,
slug: match.slug,
partner: { partner: {
id: partner.id, id: partner.id,
username: partner.username, username: partner.username,
@@ -280,14 +282,94 @@ router.get('/', authenticate, async (req, res, next) => {
} }
}); });
// GET /api/matches/:id - Get specific match // GET /api/matches/:slug/messages - Get messages for a match
router.get('/:id', authenticate, async (req, res, next) => { router.get('/:slug/messages', authenticate, async (req, res, next) => {
try { 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 userId = req.user.id;
const match = await prisma.match.findUnique({ const match = await prisma.match.findUnique({
where: { id: parseInt(id) }, where: { slug },
include: { include: {
user1: { user1: {
select: { select: {
@@ -349,6 +431,7 @@ router.get('/:id', authenticate, async (req, res, next) => {
success: true, success: true,
data: { data: {
id: match.id, id: match.id,
slug: match.slug,
partner: { partner: {
id: partner.id, id: partner.id,
username: partner.username, username: partner.username,
@@ -368,15 +451,15 @@ router.get('/:id', authenticate, async (req, res, next) => {
} }
}); });
// PUT /api/matches/:id/accept - Accept a pending match // PUT /api/matches/:slug/accept - Accept a pending match
router.put('/:id/accept', authenticate, async (req, res, next) => { router.put('/:slug/accept', authenticate, async (req, res, next) => {
try { try {
const { id } = req.params; const { slug } = req.params;
const userId = req.user.id; const userId = req.user.id;
// Find match // Find match
const match = await prisma.match.findUnique({ const match = await prisma.match.findUnique({
where: { id: parseInt(id) }, where: { slug },
include: { include: {
user1: { user1: {
select: { select: {
@@ -437,7 +520,7 @@ router.put('/:id/accept', authenticate, async (req, res, next) => {
// Update match status and link to chat room // Update match status and link to chat room
const updated = await tx.match.update({ const updated = await tx.match.update({
where: { id: parseInt(id) }, where: { slug },
data: { data: {
status: 'accepted', status: 'accepted',
roomId: chatRoom.id, roomId: chatRoom.id,
@@ -487,6 +570,7 @@ router.put('/:id/accept', authenticate, async (req, res, next) => {
const notification = { const notification = {
matchId: updatedMatch.id, matchId: updatedMatch.id,
matchSlug: updatedMatch.slug,
roomId: updatedMatch.roomId, roomId: updatedMatch.roomId,
event: { event: {
slug: updatedMatch.event.slug, slug: updatedMatch.event.slug,
@@ -523,6 +607,7 @@ router.put('/:id/accept', authenticate, async (req, res, next) => {
success: true, success: true,
data: { data: {
id: updatedMatch.id, id: updatedMatch.id,
slug: updatedMatch.slug,
partner: { partner: {
id: partner.id, id: partner.id,
username: partner.username, 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 // DELETE /api/matches/:slug - Reject or cancel a match
router.delete('/:id', authenticate, async (req, res, next) => { router.delete('/:slug', authenticate, async (req, res, next) => {
try { try {
const { id } = req.params; const { slug } = req.params;
const userId = req.user.id; const userId = req.user.id;
// Find match // Find match
const match = await prisma.match.findUnique({ const match = await prisma.match.findUnique({
where: { id: parseInt(id) }, where: { slug },
include: { include: {
event: { event: {
select: { select: {
@@ -586,7 +671,7 @@ router.delete('/:id', authenticate, async (req, res, next) => {
// Delete match (will cascade delete chat room if exists) // Delete match (will cascade delete chat room if exists)
await prisma.match.delete({ await prisma.match.delete({
where: { id: parseInt(id) }, where: { slug },
}); });
// Emit socket event to the other user // 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', { io.to(otherUserSocketRoom).emit('match_cancelled', {
matchId: match.id, matchId: match.id,
matchSlug: match.slug,
event: { event: {
slug: match.event.slug, slug: match.event.slug,
name: match.event.name, name: match.event.name,

View File

@@ -128,7 +128,7 @@ function App() {
} }
/> />
<Route <Route
path="/matches/:matchId/chat" path="/matches/:slug/chat"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<MatchChatPage /> <MatchChatPage />
@@ -136,7 +136,7 @@ function App() {
} }
/> />
<Route <Route
path="/matches/:matchId/rate" path="/matches/:slug/rate"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<RatePartnerPage /> <RatePartnerPage />

View File

@@ -2,14 +2,16 @@ import { useState, useRef, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import Layout from '../components/layout/Layout'; import Layout from '../components/layout/Layout';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { mockUsers } from '../mocks/users'; import { matchesAPI } from '../services/api';
import { Send, Video, Upload, X, Check, Link as LinkIcon } from 'lucide-react'; import { Send, Video, Upload, X, Check, Link as LinkIcon, Loader2 } from 'lucide-react';
import { connectSocket, getSocket } from '../services/socket'; import { connectSocket, getSocket } from '../services/socket';
const MatchChatPage = () => { const MatchChatPage = () => {
const { matchId } = useParams(); const { slug } = useParams();
const { user } = useAuth(); const { user } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [match, setMatch] = useState(null);
const [loading, setLoading] = useState(true);
const [messages, setMessages] = useState([]); const [messages, setMessages] = useState([]);
const [newMessage, setNewMessage] = useState(''); const [newMessage, setNewMessage] = useState('');
const [selectedFile, setSelectedFile] = useState(null); const [selectedFile, setSelectedFile] = useState(null);
@@ -22,8 +24,40 @@ const MatchChatPage = () => {
const messagesEndRef = useRef(null); const messagesEndRef = useRef(null);
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
// Partner user (mockup - TODO: fetch from backend in Phase 2) // Fetch match data
const partner = mockUsers[1]; // sarah_swing 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 = () => { const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
@@ -34,6 +68,9 @@ const MatchChatPage = () => {
}, [messages]); }, [messages]);
useEffect(() => { useEffect(() => {
// Wait for match to be loaded
if (!match) return;
// Connect to Socket.IO // Connect to Socket.IO
const socket = connectSocket(); const socket = connectSocket();
@@ -45,9 +82,9 @@ const MatchChatPage = () => {
// Socket event listeners // Socket event listeners
socket.on('connect', () => { socket.on('connect', () => {
setIsConnected(true); setIsConnected(true);
// Join match room // Join match room using numeric match ID for socket
socket.emit('join_match_room', { matchId: parseInt(matchId) }); socket.emit('join_match_room', { matchId: match.id });
console.log(`Joined match room ${matchId}`); console.log(`Joined match room ${match.id}`);
}); });
socket.on('disconnect', () => { socket.on('disconnect', () => {
@@ -65,11 +102,11 @@ const MatchChatPage = () => {
socket.off('disconnect'); socket.off('disconnect');
socket.off('match_message'); socket.off('match_message');
}; };
}, [matchId, user.id]); }, [match, user.id]);
const handleSendMessage = (e) => { const handleSendMessage = (e) => {
e.preventDefault(); e.preventDefault();
if (!newMessage.trim()) return; if (!newMessage.trim() || !match) return;
const socket = getSocket(); const socket = getSocket();
if (!socket || !socket.connected) { if (!socket || !socket.connected) {
@@ -77,9 +114,9 @@ const MatchChatPage = () => {
return; return;
} }
// Send message via Socket.IO // Send message via Socket.IO using numeric match ID
socket.emit('send_match_message', { socket.emit('send_match_message', {
matchId: parseInt(matchId), matchId: match.id,
content: newMessage, content: newMessage,
}); });
@@ -168,7 +205,7 @@ const MatchChatPage = () => {
}; };
const handleEndMatch = () => { const handleEndMatch = () => {
navigate(`/matches/${matchId}/rate`); navigate(`/matches/${slug}/rate`);
}; };
const getWebRTCStatusColor = () => { 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 ( return (
<Layout> <Layout>
<div className="max-w-4xl mx-auto"> <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 justify-between">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<img <img
src={partner.avatar} src={partner.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${partner.username}`}
alt={partner.username} alt={partner.username}
className="w-12 h-12 rounded-full border-2 border-white" className="w-12 h-12 rounded-full border-2 border-white"
/> />
<div> <div>
<h2 className="text-xl font-bold">{partner.username}</h2> <h2 className="text-xl font-bold">
<p className="text-sm text-primary-100"> {partner.rating} {partner.matches_count} collaborations</p> {partner.firstName && partner.lastName
? `${partner.firstName} ${partner.lastName}`
: partner.username}
</h2>
<p className="text-sm text-primary-100">@{partner.username}</p>
</div> </div>
</div> </div>
<button <button

View File

@@ -66,13 +66,13 @@ const MatchesPage = () => {
const handleMatchCancelled = (data) => { const handleMatchCancelled = (data) => {
// Remove cancelled match from list // 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 { try {
setProcessingMatchId(matchId); setProcessingMatchId(matchSlug);
await matchesAPI.acceptMatch(matchId); await matchesAPI.acceptMatch(matchSlug);
// Reload matches // Reload matches
await loadMatches(); 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?')) { if (!confirm('Are you sure you want to reject this match request?')) {
return; return;
} }
try { try {
setProcessingMatchId(matchId); setProcessingMatchId(matchSlug);
await matchesAPI.deleteMatch(matchId); await matchesAPI.deleteMatch(matchSlug);
// Remove from list // Remove from list
setMatches(prev => prev.filter(m => m.id !== matchId)); setMatches(prev => prev.filter(m => m.slug !== matchSlug));
} catch (error) { } catch (error) {
console.error('Failed to reject match:', error); console.error('Failed to reject match:', error);
alert('Failed to reject match. Please try again.'); alert('Failed to reject match. Please try again.');
@@ -108,7 +108,7 @@ const MatchesPage = () => {
const handleOpenChat = (match) => { const handleOpenChat = (match) => {
if (match.status === 'accepted' && match.roomId) { if (match.status === 'accepted' && match.roomId) {
navigate(`/matches/${match.id}/chat`); navigate(`/matches/${match.slug}/chat`);
} }
}; };
@@ -187,7 +187,7 @@ const MatchesPage = () => {
onAccept={handleAccept} onAccept={handleAccept}
onReject={handleReject} onReject={handleReject}
onOpenChat={handleOpenChat} onOpenChat={handleOpenChat}
processing={processingMatchId === match.id} processing={processingMatchId === match.slug}
/> />
))} ))}
</div> </div>
@@ -208,7 +208,7 @@ const MatchesPage = () => {
onAccept={handleAccept} onAccept={handleAccept}
onReject={handleReject} onReject={handleReject}
onOpenChat={handleOpenChat} onOpenChat={handleOpenChat}
processing={processingMatchId === match.id} processing={processingMatchId === match.slug}
/> />
))} ))}
</div> </div>
@@ -295,7 +295,7 @@ const MatchCard = ({ match, onAccept, onReject, onOpenChat, processing }) => {
{isIncoming && ( {isIncoming && (
<> <>
<button <button
onClick={() => onAccept(match.id)} onClick={() => onAccept(match.slug)}
disabled={processing} 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" 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" title="Accept"
@@ -307,7 +307,7 @@ const MatchCard = ({ match, onAccept, onReject, onOpenChat, processing }) => {
)} )}
</button> </button>
<button <button
onClick={() => onReject(match.id)} onClick={() => onReject(match.slug)}
disabled={processing} 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" 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" title="Reject"
@@ -319,7 +319,7 @@ const MatchCard = ({ match, onAccept, onReject, onOpenChat, processing }) => {
{isOutgoing && ( {isOutgoing && (
<button <button
onClick={() => onReject(match.id)} onClick={() => onReject(match.slug)}
disabled={processing} 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" 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"
> >

View File

@@ -5,7 +5,7 @@ import { mockUsers } from '../mocks/users';
import { Star } from 'lucide-react'; import { Star } from 'lucide-react';
const RatePartnerPage = () => { const RatePartnerPage = () => {
const { matchId } = useParams(); const { slug } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const [rating, setRating] = useState(0); const [rating, setRating] = useState(0);
const [hoveredRating, setHoveredRating] = useState(0); const [hoveredRating, setHoveredRating] = useState(0);

View File

@@ -286,24 +286,29 @@ export const matchesAPI = {
return data; return data;
}, },
async getMatch(matchId) { async getMatch(matchSlug) {
const data = await fetchAPI(`/matches/${matchId}`); const data = await fetchAPI(`/matches/${matchSlug}`);
return data; return data;
}, },
async acceptMatch(matchId) { async acceptMatch(matchSlug) {
const data = await fetchAPI(`/matches/${matchId}/accept`, { const data = await fetchAPI(`/matches/${matchSlug}/accept`, {
method: 'PUT', method: 'PUT',
}); });
return data; return data;
}, },
async deleteMatch(matchId) { async deleteMatch(matchSlug) {
const data = await fetchAPI(`/matches/${matchId}`, { const data = await fetchAPI(`/matches/${matchSlug}`, {
method: 'DELETE', method: 'DELETE',
}); });
return data; return data;
}, },
async getMatchMessages(matchSlug) {
const data = await fetchAPI(`/matches/${matchSlug}/messages`);
return data;
},
}; };
export { ApiError }; export { ApiError };