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:
@@ -711,6 +711,166 @@ describe('Events API Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/events/:slug/competitor-number', () => {
|
||||
it('should set competitor number for participant', async () => {
|
||||
const response = await request(app)
|
||||
.put(`/api/events/${testEvent.slug}/competitor-number`)
|
||||
.set('Authorization', `Bearer ${testToken1}`)
|
||||
.send({ competitorNumber: 123 })
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('success', true);
|
||||
expect(response.body).toHaveProperty('competitorNumber', 123);
|
||||
|
||||
// Verify in database
|
||||
const participant = await prisma.eventParticipant.findUnique({
|
||||
where: {
|
||||
userId_eventId: {
|
||||
userId: testUser1.id,
|
||||
eventId: testEvent.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(participant.competitorNumber).toBe(123);
|
||||
});
|
||||
|
||||
it('should clear competitor number when set to null', async () => {
|
||||
// First set a number
|
||||
await request(app)
|
||||
.put(`/api/events/${testEvent.slug}/competitor-number`)
|
||||
.set('Authorization', `Bearer ${testToken1}`)
|
||||
.send({ competitorNumber: 456 })
|
||||
.expect(200);
|
||||
|
||||
// Then clear it
|
||||
const response = await request(app)
|
||||
.put(`/api/events/${testEvent.slug}/competitor-number`)
|
||||
.set('Authorization', `Bearer ${testToken1}`)
|
||||
.send({ competitorNumber: null })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('success', true);
|
||||
expect(response.body.competitorNumber).toBeNull();
|
||||
});
|
||||
|
||||
it('should reject invalid competitor number (too high)', async () => {
|
||||
const response = await request(app)
|
||||
.put(`/api/events/${testEvent.slug}/competitor-number`)
|
||||
.set('Authorization', `Bearer ${testToken1}`)
|
||||
.send({ competitorNumber: 10000 })
|
||||
.expect(400);
|
||||
|
||||
expect(response.body).toHaveProperty('success', false);
|
||||
expect(response.body.error).toContain('between 1 and 9999');
|
||||
});
|
||||
|
||||
it('should reject invalid competitor number (zero)', async () => {
|
||||
const response = await request(app)
|
||||
.put(`/api/events/${testEvent.slug}/competitor-number`)
|
||||
.set('Authorization', `Bearer ${testToken1}`)
|
||||
.send({ competitorNumber: 0 })
|
||||
.expect(400);
|
||||
|
||||
expect(response.body).toHaveProperty('success', false);
|
||||
});
|
||||
|
||||
it('should reject invalid competitor number (negative)', async () => {
|
||||
const response = await request(app)
|
||||
.put(`/api/events/${testEvent.slug}/competitor-number`)
|
||||
.set('Authorization', `Bearer ${testToken1}`)
|
||||
.send({ competitorNumber: -5 })
|
||||
.expect(400);
|
||||
|
||||
expect(response.body).toHaveProperty('success', false);
|
||||
});
|
||||
|
||||
it('should return 403 for non-participant', async () => {
|
||||
const response = await request(app)
|
||||
.put(`/api/events/${testEvent2.slug}/competitor-number`)
|
||||
.set('Authorization', `Bearer ${testToken1}`) // testUser1 is not participant of testEvent2
|
||||
.send({ competitorNumber: 100 })
|
||||
.expect(403);
|
||||
|
||||
expect(response.body).toHaveProperty('success', false);
|
||||
expect(response.body.error).toContain('must be a participant');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent event', async () => {
|
||||
const response = await request(app)
|
||||
.put('/api/events/non-existent-slug/competitor-number')
|
||||
.set('Authorization', `Bearer ${testToken1}`)
|
||||
.send({ competitorNumber: 100 })
|
||||
.expect(404);
|
||||
|
||||
expect(response.body).toHaveProperty('success', false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/events/:slug/heats/me - competitorNumber', () => {
|
||||
it('should return competitorNumber in response', async () => {
|
||||
// Set a competitor number first
|
||||
await request(app)
|
||||
.put(`/api/events/${testEvent.slug}/competitor-number`)
|
||||
.set('Authorization', `Bearer ${testToken1}`)
|
||||
.send({ competitorNumber: 789 })
|
||||
.expect(200);
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/api/events/${testEvent.slug}/heats/me`)
|
||||
.set('Authorization', `Bearer ${testToken1}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('success', true);
|
||||
expect(response.body).toHaveProperty('competitorNumber', 789);
|
||||
});
|
||||
|
||||
it('should return null competitorNumber when not set', async () => {
|
||||
// Clear competitor number
|
||||
await request(app)
|
||||
.put(`/api/events/${testEvent.slug}/competitor-number`)
|
||||
.set('Authorization', `Bearer ${testToken1}`)
|
||||
.send({ competitorNumber: null })
|
||||
.expect(200);
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/api/events/${testEvent.slug}/heats/me`)
|
||||
.set('Authorization', `Bearer ${testToken1}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('competitorNumber', null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/events/:slug/heats/all - competitorNumber', () => {
|
||||
it('should return competitorNumber for each user', async () => {
|
||||
// Set competitor number for testUser1
|
||||
await request(app)
|
||||
.put(`/api/events/${testEvent.slug}/competitor-number`)
|
||||
.set('Authorization', `Bearer ${testToken1}`)
|
||||
.send({ competitorNumber: 111 })
|
||||
.expect(200);
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/api/events/${testEvent.slug}/heats/all`)
|
||||
.set('Authorization', `Bearer ${testToken1}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('success', true);
|
||||
|
||||
// Find testUser1 in the response
|
||||
const user1Data = response.body.data.find(u => u.userId === testUser1.id);
|
||||
if (user1Data) {
|
||||
expect(user1Data).toHaveProperty('competitorNumber', 111);
|
||||
}
|
||||
|
||||
// Other users should have competitorNumber (either number or null)
|
||||
response.body.data.forEach(userData => {
|
||||
expect(userData).toHaveProperty('competitorNumber');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/events/:slug/heats/:id', () => {
|
||||
let heatToDelete;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user