feat(matches): show both manual match requests and auto recording suggestions

Backend:
- Extend GET /api/matches to include RecordingSuggestions alongside Match objects
- Add 'type' field: 'manual' for user-created matches, 'auto' for algorithm suggestions
- Fetch suggestions where user is dancer (to be recorded) or recorder (recording others)
- Transform suggestions to match format with partner info
- Support status filtering for both types

Frontend:
- Display 'Auto' (purple) or 'Manual' (gray) badge on match cards
- For pending auto suggestions: show 'Go to Records' button instead of Accept/Reject
- For accepted auto suggestions without slug: show 'Chat not available yet'
- Only allow Accept/Reject actions on manual match requests
This commit is contained in:
Radosław Gierwiało
2025-11-30 15:30:49 +01:00
parent d8799d03af
commit f45cadae7d
2 changed files with 185 additions and 20 deletions

View File

@@ -177,15 +177,7 @@ router.get('/', authenticate, async (req, res, next) => {
const userId = req.user.id; const userId = req.user.id;
const { eventSlug, status } = req.query; const { eventSlug, status } = req.query;
// Build where clause let eventId = null;
const where = {
OR: [
{ user1Id: userId },
{ user2Id: userId },
],
};
// Filter by event if provided
if (eventSlug) { if (eventSlug) {
const event = await prisma.event.findUnique({ const event = await prisma.event.findUnique({
where: { slug: eventSlug }, where: { slug: eventSlug },
@@ -199,17 +191,28 @@ router.get('/', authenticate, async (req, res, next) => {
}); });
} }
where.eventId = event.id; eventId = event.id;
}
// Build where clause for matches
const matchWhere = {
OR: [
{ user1Id: userId },
{ user2Id: userId },
],
};
if (eventId) {
matchWhere.eventId = eventId;
} }
// Filter by status if provided
if (status) { if (status) {
where.status = status; matchWhere.status = status;
} }
// Fetch matches // Fetch matches (manual match requests)
const matches = await prisma.match.findMany({ const matches = await prisma.match.findMany({
where, where: matchWhere,
include: { include: {
user1: { user1: {
select: { select: {
@@ -250,7 +253,85 @@ router.get('/', authenticate, async (req, res, next) => {
}, },
}); });
// Transform matches to include partner info // Fetch recording suggestions (auto matches from matching algorithm)
// Get user's heats first
const userHeats = await prisma.eventUserHeat.findMany({
where: {
userId: userId,
...(eventId ? { eventId } : {}),
},
select: {
id: true,
eventId: true,
},
});
const heatIds = userHeats.map(h => h.id);
// Fetch suggestions where user is dancer (to be recorded)
const suggestionsAsDancer = await prisma.recordingSuggestion.findMany({
where: {
heatId: { in: heatIds },
...(status ? { status } : {}),
},
include: {
recorder: {
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,
},
},
},
});
// Fetch suggestions where user is recorder (recording others)
const suggestionsAsRecorder = await prisma.recordingSuggestion.findMany({
where: {
recorderId: userId,
...(eventId ? { eventId } : {}),
...(status ? { status } : {}),
},
include: {
heat: {
include: {
user: {
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,
},
},
},
});
// Transform matches to include partner info and type
const transformedMatches = matches.map(match => { const transformedMatches = matches.map(match => {
const isUser1 = match.user1Id === userId; const isUser1 = match.user1Id === userId;
const partner = isUser1 ? match.user2 : match.user1; const partner = isUser1 ? match.user2 : match.user1;
@@ -259,6 +340,7 @@ router.get('/', authenticate, async (req, res, next) => {
return { return {
id: match.id, id: match.id,
slug: match.slug, slug: match.slug,
type: 'manual',
partner: { partner: {
id: partner.id, id: partner.id,
username: partner.username, username: partner.username,
@@ -274,10 +356,60 @@ router.get('/', authenticate, async (req, res, next) => {
}; };
}); });
// Transform recording suggestions to match format
const transformedSuggestionsAsDancer = suggestionsAsDancer
.filter(s => s.recorder) // Only include if recorder exists
.map(suggestion => ({
id: `suggestion-${suggestion.id}`,
slug: null,
type: 'auto',
partner: {
id: suggestion.recorder.id,
username: suggestion.recorder.username,
avatar: suggestion.recorder.avatar,
firstName: suggestion.recorder.firstName,
lastName: suggestion.recorder.lastName,
},
event: suggestion.event,
status: suggestion.status,
roomId: null,
isInitiator: false,
createdAt: suggestion.createdAt,
suggestionId: suggestion.id,
}));
const transformedSuggestionsAsRecorder = suggestionsAsRecorder
.filter(s => s.heat?.user) // Only include if dancer exists
.map(suggestion => ({
id: `suggestion-${suggestion.id}`,
slug: null,
type: 'auto',
partner: {
id: suggestion.heat.user.id,
username: suggestion.heat.user.username,
avatar: suggestion.heat.user.avatar,
firstName: suggestion.heat.user.firstName,
lastName: suggestion.heat.user.lastName,
},
event: suggestion.event,
status: suggestion.status,
roomId: null,
isInitiator: true,
createdAt: suggestion.createdAt,
suggestionId: suggestion.id,
}));
// Combine all and sort by createdAt
const allItems = [
...transformedMatches,
...transformedSuggestionsAsDancer,
...transformedSuggestionsAsRecorder,
].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
res.json({ res.json({
success: true, success: true,
count: transformedMatches.length, count: allItems.length,
data: transformedMatches, data: allItems,
}); });
} catch (error) { } catch (error) {
next(error); next(error);

View File

@@ -47,6 +47,15 @@ const MatchCard = ({ match, onAccept, onReject, onOpenChat, processing }) => {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{match.type === 'auto' ? (
<span className="text-xs px-2 py-1 bg-purple-100 text-purple-700 rounded-full font-medium">
Auto
</span>
) : (
<span className="text-xs px-2 py-1 bg-gray-100 text-gray-700 rounded-full font-medium">
Manual
</span>
)}
{isIncoming && ( {isIncoming && (
<span className="text-xs px-2 py-1 bg-amber-100 text-amber-700 rounded-full font-medium"> <span className="text-xs px-2 py-1 bg-amber-100 text-amber-700 rounded-full font-medium">
Incoming Request Incoming Request
@@ -66,7 +75,7 @@ const MatchCard = ({ match, onAccept, onReject, onOpenChat, processing }) => {
</div> </div>
<div className="flex items-center gap-2 ml-4"> <div className="flex items-center gap-2 ml-4">
{isIncoming && ( {isIncoming && match.type === 'manual' && (
<> <>
<button <button
onClick={() => onAccept(match.slug)} onClick={() => onAccept(match.slug)}
@@ -91,7 +100,16 @@ const MatchCard = ({ match, onAccept, onReject, onOpenChat, processing }) => {
</> </>
)} )}
{isOutgoing && ( {isIncoming && match.type === 'auto' && (
<Link
to={`/events/${match.event.slug}/chat?tab=records`}
className="px-3 py-1.5 text-sm bg-purple-600 text-white rounded-md hover:bg-purple-700 transition-colors"
>
Go to Records
</Link>
)}
{isOutgoing && match.type === 'manual' && (
<button <button
onClick={() => onReject(match.slug)} onClick={() => onReject(match.slug)}
disabled={processing} disabled={processing}
@@ -101,7 +119,16 @@ const MatchCard = ({ match, onAccept, onReject, onOpenChat, processing }) => {
</button> </button>
)} )}
{isAccepted && ( {isOutgoing && match.type === 'auto' && (
<Link
to={`/events/${match.event.slug}/chat?tab=records`}
className="px-3 py-1.5 text-sm bg-purple-600 text-white rounded-md hover:bg-purple-700 transition-colors"
>
Go to Records
</Link>
)}
{isAccepted && match.slug && (
<button <button
onClick={() => onOpenChat(match)} onClick={() => onOpenChat(match)}
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors" className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors"
@@ -110,6 +137,12 @@ const MatchCard = ({ match, onAccept, onReject, onOpenChat, processing }) => {
Open Chat Open Chat
</button> </button>
)} )}
{isAccepted && !match.slug && (
<span className="text-sm text-gray-500 italic">
Chat not available yet
</span>
)}
</div> </div>
</div> </div>
</div> </div>