feat(events): add competitor number (bib) support

Allow participants to set their bib/competitor number per event.
Display as badge next to username in participant lists.

- Add competitorNumber field to EventParticipant model
- Add PUT /events/:slug/competitor-number endpoint
- Include competitorNumber in heats/me and heats/all responses
- Add input field in HeatsBanner component
- Display badge in UserListItem component
- Add unit tests for competitor number feature
This commit is contained in:
Radosław Gierwiało
2025-11-23 17:55:25 +01:00
parent a2279662dc
commit edf68f2489
9 changed files with 323 additions and 10 deletions

View File

@@ -643,6 +643,14 @@ router.get('/:slug/heats/me', authenticate, async (req, res, next) => {
});
}
// Get user's participation (for competitor number)
const participation = await prisma.eventParticipant.findUnique({
where: {
userId_eventId: { userId, eventId: event.id },
},
select: { competitorNumber: true },
});
// Get user's heats
const heats = await prisma.eventUserHeat.findMany({
where: {
@@ -675,6 +683,7 @@ router.get('/:slug/heats/me', authenticate, async (req, res, next) => {
res.json({
success: true,
count: heats.length,
competitorNumber: participation?.competitorNumber || null,
data: heats,
});
} catch (error) {
@@ -700,6 +709,18 @@ router.get('/:slug/heats/all', authenticate, async (req, res, next) => {
});
}
// Get all participants with competitor numbers
const participants = await prisma.eventParticipant.findMany({
where: { eventId: event.id },
select: {
userId: true,
competitorNumber: true,
},
});
const competitorNumbers = new Map(
participants.map((p) => [p.userId, p.competitorNumber])
);
// Get all heats with user info
const heats = await prisma.eventUserHeat.findMany({
where: {
@@ -741,6 +762,7 @@ router.get('/:slug/heats/all', authenticate, async (req, res, next) => {
userId: heat.user.id,
username: heat.user.username,
avatar: heat.user.avatar,
competitorNumber: competitorNumbers.get(userId) || null,
heats: [],
});
}
@@ -768,6 +790,70 @@ router.get('/:slug/heats/all', authenticate, async (req, res, next) => {
}
});
// PUT /api/events/:slug/competitor-number - Set competitor number (bib number)
router.put('/:slug/competitor-number', authenticate, async (req, res, next) => {
try {
const { slug } = req.params;
const { competitorNumber } = req.body;
const userId = req.user.id;
// Validate competitor number (positive integer or null)
if (competitorNumber !== null && competitorNumber !== undefined) {
const num = parseInt(competitorNumber, 10);
if (isNaN(num) || num < 1 || num > 9999) {
return res.status(400).json({
success: false,
error: 'Competitor number must be between 1 and 9999',
});
}
}
// Find event
const event = await prisma.event.findUnique({
where: { slug },
select: { id: true },
});
if (!event) {
return res.status(404).json({
success: false,
error: 'Event not found',
});
}
// Check if user is participant
const participant = await prisma.eventParticipant.findUnique({
where: {
userId_eventId: { userId, eventId: event.id },
},
});
if (!participant) {
return res.status(403).json({
success: false,
error: 'You must be a participant to set competitor number',
});
}
// Update competitor number
const updated = await prisma.eventParticipant.update({
where: {
userId_eventId: { userId, eventId: event.id },
},
data: {
competitorNumber: competitorNumber ? parseInt(competitorNumber, 10) : null,
},
});
res.json({
success: true,
competitorNumber: updated.competitorNumber,
});
} catch (error) {
next(error);
}
});
// DELETE /api/events/:slug/heats/:id - Delete specific heat
router.delete('/:slug/heats/:id', authenticate, async (req, res, next) => {
try {