diff --git a/backend/prisma/migrations/20251121210620_add_competitor_number/migration.sql b/backend/prisma/migrations/20251121210620_add_competitor_number/migration.sql new file mode 100644 index 0000000..deb017c --- /dev/null +++ b/backend/prisma/migrations/20251121210620_add_competitor_number/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "event_participants" ADD COLUMN "competitor_number" INTEGER; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b019489..0d38514 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -182,10 +182,11 @@ model Rating { // Event participants (tracks which users joined which events) model EventParticipant { - id Int @id @default(autoincrement()) - userId Int @map("user_id") - eventId Int @map("event_id") - joinedAt DateTime @default(now()) @map("joined_at") + id Int @id @default(autoincrement()) + userId Int @map("user_id") + eventId Int @map("event_id") + competitorNumber Int? @map("competitor_number") // Bib number - one per user per event + joinedAt DateTime @default(now()) @map("joined_at") // Relations user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/backend/src/__tests__/events.test.js b/backend/src/__tests__/events.test.js index 59e243d..849b68f 100644 --- a/backend/src/__tests__/events.test.js +++ b/backend/src/__tests__/events.test.js @@ -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; diff --git a/backend/src/routes/events.js b/backend/src/routes/events.js index 33099fb..3e8f4b4 100644 --- a/backend/src/routes/events.js +++ b/backend/src/routes/events.js @@ -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 { diff --git a/frontend/src/components/events/ParticipantsSidebar.jsx b/frontend/src/components/events/ParticipantsSidebar.jsx index f345ce1..ce2924c 100644 --- a/frontend/src/components/events/ParticipantsSidebar.jsx +++ b/frontend/src/components/events/ParticipantsSidebar.jsx @@ -8,6 +8,7 @@ import UserListItem from '../users/UserListItem'; * @param {Array} users - Array of user objects to display (already filtered) * @param {Array} activeUsers - Array of currently online users (for count) * @param {Map} userHeats - Map of userId -> heats array + * @param {Map} userCompetitorNumbers - Map of userId -> competitor number (bib) * @param {Array} myHeats - Current user's heats (for filter checkbox visibility) * @param {boolean} hideMyHeats - Filter state * @param {function} onHideMyHeatsChange - Filter change handler @@ -19,6 +20,7 @@ import UserListItem from '../users/UserListItem'; * users={filteredUsers} * activeUsers={activeUsers} * userHeats={userHeats} + * userCompetitorNumbers={userCompetitorNumbers} * myHeats={myHeats} * hideMyHeats={hideMyHeats} * onHideMyHeatsChange={setHideMyHeats} @@ -29,6 +31,7 @@ const ParticipantsSidebar = ({ users = [], activeUsers = [], userHeats = new Map(), + userCompetitorNumbers = new Map(), myHeats = [], hideMyHeats = false, onHideMyHeatsChange, @@ -73,6 +76,7 @@ const ParticipantsSidebar = ({