diff --git a/backend/prisma/migrations/20251114142504_add_competition_heats_system/migration.sql b/backend/prisma/migrations/20251114142504_add_competition_heats_system/migration.sql new file mode 100644 index 0000000..57568be --- /dev/null +++ b/backend/prisma/migrations/20251114142504_add_competition_heats_system/migration.sql @@ -0,0 +1,66 @@ +-- CreateTable +CREATE TABLE "divisions" ( + "id" SERIAL NOT NULL, + "name" VARCHAR(50) NOT NULL, + "abbreviation" VARCHAR(3) NOT NULL, + "display_order" INTEGER NOT NULL, + + CONSTRAINT "divisions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "competition_types" ( + "id" SERIAL NOT NULL, + "name" VARCHAR(50) NOT NULL, + "abbreviation" VARCHAR(3) NOT NULL, + + CONSTRAINT "competition_types_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "event_user_heats" ( + "id" SERIAL NOT NULL, + "user_id" INTEGER NOT NULL, + "event_id" INTEGER NOT NULL, + "division_id" INTEGER NOT NULL, + "competition_type_id" INTEGER NOT NULL, + "heat_number" INTEGER NOT NULL, + "role" VARCHAR(10), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "event_user_heats_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "divisions_name_key" ON "divisions"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "divisions_abbreviation_key" ON "divisions"("abbreviation"); + +-- CreateIndex +CREATE UNIQUE INDEX "competition_types_name_key" ON "competition_types"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "competition_types_abbreviation_key" ON "competition_types"("abbreviation"); + +-- CreateIndex +CREATE INDEX "event_user_heats_user_id_event_id_idx" ON "event_user_heats"("user_id", "event_id"); + +-- CreateIndex +CREATE INDEX "event_user_heats_event_id_idx" ON "event_user_heats"("event_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "event_user_heats_user_id_event_id_division_id_competition_t_key" ON "event_user_heats"("user_id", "event_id", "division_id", "competition_type_id", "role"); + +-- AddForeignKey +ALTER TABLE "event_user_heats" ADD CONSTRAINT "event_user_heats_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "event_user_heats" ADD CONSTRAINT "event_user_heats_event_id_fkey" FOREIGN KEY ("event_id") REFERENCES "events"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "event_user_heats" ADD CONSTRAINT "event_user_heats_division_id_fkey" FOREIGN KEY ("division_id") REFERENCES "divisions"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "event_user_heats" ADD CONSTRAINT "event_user_heats_competition_type_id_fkey" FOREIGN KEY ("competition_type_id") REFERENCES "competition_types"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 75325fc..bde4451 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -54,6 +54,7 @@ model User { ratingsGiven Rating[] @relation("RaterRatings") ratingsReceived Rating[] @relation("RatedRatings") eventParticipants EventParticipant[] + heats EventUserHeat[] @@map("users") } @@ -76,6 +77,7 @@ model Event { matches Match[] participants EventParticipant[] checkinToken EventCheckinToken? + userHeats EventUserHeat[] @@map("events") } @@ -187,3 +189,53 @@ model EventParticipant { @@index([eventId]) @@map("event_participants") } + +// Competition divisions (Newcomer, Novice, Intermediate, etc.) +model Division { + id Int @id @default(autoincrement()) + name String @unique @db.VarChar(50) + abbreviation String @unique @db.VarChar(3) + displayOrder Int @map("display_order") + + // Relations + userHeats EventUserHeat[] + + @@map("divisions") +} + +// Competition types (Jack & Jill, Strictly, etc.) +model CompetitionType { + id Int @id @default(autoincrement()) + name String @unique @db.VarChar(50) + abbreviation String @unique @db.VarChar(3) + + // Relations + userHeats EventUserHeat[] + + @@map("competition_types") +} + +// User's declared heats for matchmaking +model EventUserHeat { + id Int @id @default(autoincrement()) + userId Int @map("user_id") + eventId Int @map("event_id") + divisionId Int @map("division_id") + competitionTypeId Int @map("competition_type_id") + heatNumber Int @map("heat_number") // 1-9 + role String? @db.VarChar(10) // 'Leader', 'Follower', or NULL + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // Relations + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + division Division @relation(fields: [divisionId], references: [id]) + competitionType CompetitionType @relation(fields: [competitionTypeId], references: [id]) + + // Constraint: Cannot have same role in same division+competition type + @@unique([userId, eventId, divisionId, competitionTypeId, role]) + @@index([userId, eventId]) + @@index([eventId]) + @@map("event_user_heats") +} diff --git a/backend/prisma/seed.js b/backend/prisma/seed.js index df25f21..e40f523 100644 --- a/backend/prisma/seed.js +++ b/backend/prisma/seed.js @@ -5,10 +5,65 @@ const prisma = new PrismaClient(); async function main() { console.log('🌱 Seeding database...'); + // Create divisions + const divisions = await Promise.all([ + prisma.division.upsert({ + where: { name: 'Newcomer' }, + update: {}, + create: { name: 'Newcomer', abbreviation: 'NEW', displayOrder: 1 }, + }), + prisma.division.upsert({ + where: { name: 'Novice' }, + update: {}, + create: { name: 'Novice', abbreviation: 'NOV', displayOrder: 2 }, + }), + prisma.division.upsert({ + where: { name: 'Intermediate' }, + update: {}, + create: { name: 'Intermediate', abbreviation: 'INT', displayOrder: 3 }, + }), + prisma.division.upsert({ + where: { name: 'Advanced' }, + update: {}, + create: { name: 'Advanced', abbreviation: 'ADV', displayOrder: 4 }, + }), + prisma.division.upsert({ + where: { name: 'All-Star' }, + update: {}, + create: { name: 'All-Star', abbreviation: 'ALL', displayOrder: 5 }, + }), + prisma.division.upsert({ + where: { name: 'Champion' }, + update: {}, + create: { name: 'Champion', abbreviation: 'CHA', displayOrder: 6 }, + }), + ]); + + console.log(`✅ Created ${divisions.length} divisions`); + + // Create competition types + const competitionTypes = await Promise.all([ + prisma.competitionType.upsert({ + where: { name: 'Jack & Jill' }, + update: {}, + create: { name: 'Jack & Jill', abbreviation: 'J&J' }, + }), + prisma.competitionType.upsert({ + where: { name: 'Strictly' }, + update: {}, + create: { name: 'Strictly', abbreviation: 'STR' }, + }), + ]); + + console.log(`✅ Created ${competitionTypes.length} competition types`); + // Create events const events = await Promise.all([ - prisma.event.create({ - data: { + prisma.event.upsert({ + where: { slug: 'warsaw-dance-festival-2025' }, + update: {}, + create: { + slug: 'warsaw-dance-festival-2025', name: 'Warsaw Dance Festival 2025', location: 'Warsaw, Poland', startDate: new Date('2025-03-15'), @@ -18,8 +73,11 @@ async function main() { description: 'The biggest West Coast Swing event in Central Europe', }, }), - prisma.event.create({ - data: { + prisma.event.upsert({ + where: { slug: 'swing-camp-barcelona-2025' }, + update: {}, + create: { + slug: 'swing-camp-barcelona-2025', name: 'Swing Camp Barcelona 2025', location: 'Barcelona, Spain', startDate: new Date('2025-04-20'), @@ -29,8 +87,11 @@ async function main() { description: 'International swing dance camp with workshops and socials', }, }), - prisma.event.create({ - data: { + prisma.event.upsert({ + where: { slug: 'blues-week-herrang-2025' }, + update: {}, + create: { + slug: 'blues-week-herrang-2025', name: 'Blues Week Herräng 2025', location: 'Herräng, Sweden', startDate: new Date('2025-07-14'), @@ -40,8 +101,11 @@ async function main() { description: 'Week-long blues dance intensive in the heart of Sweden', }, }), - prisma.event.create({ - data: { + prisma.event.upsert({ + where: { slug: 'krakow-swing-connection-2025' }, + update: {}, + create: { + slug: 'krakow-swing-connection-2025', name: 'Krakow Swing Connection 2025', location: 'Krakow, Poland', startDate: new Date('2025-05-10'), @@ -72,6 +136,8 @@ async function main() { console.log('🎉 Seeding completed successfully!'); console.log(''); console.log('Created:'); + console.log(` - ${divisions.length} divisions`); + console.log(` - ${competitionTypes.length} competition types`); console.log(` - ${events.length} events`); console.log(` - ${chatRooms.length} chat rooms`); } diff --git a/backend/src/app.js b/backend/src/app.js index 9b78326..f929391 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -77,6 +77,8 @@ app.use('/api/auth', require('./routes/auth')); app.use('/api/users', require('./routes/users')); app.use('/api/events', require('./routes/events')); app.use('/api/wsdc', require('./routes/wsdc')); +app.use('/api/divisions', require('./routes/divisions')); +app.use('/api/competition-types', require('./routes/competitionTypes')); // app.use('/api/matches', require('./routes/matches')); // app.use('/api/ratings', require('./routes/ratings')); diff --git a/backend/src/routes/competitionTypes.js b/backend/src/routes/competitionTypes.js new file mode 100644 index 0000000..4e2054f --- /dev/null +++ b/backend/src/routes/competitionTypes.js @@ -0,0 +1,27 @@ +const express = require('express'); +const { prisma } = require('../utils/db'); + +const router = express.Router(); + +// GET /api/competition-types - List all competition types (public) +router.get('/', async (req, res, next) => { + try { + const competitionTypes = await prisma.competitionType.findMany({ + select: { + id: true, + name: true, + abbreviation: true, + }, + }); + + res.json({ + success: true, + count: competitionTypes.length, + data: competitionTypes, + }); + } catch (error) { + next(error); + } +}); + +module.exports = router; diff --git a/backend/src/routes/divisions.js b/backend/src/routes/divisions.js new file mode 100644 index 0000000..7376db4 --- /dev/null +++ b/backend/src/routes/divisions.js @@ -0,0 +1,31 @@ +const express = require('express'); +const { prisma } = require('../utils/db'); + +const router = express.Router(); + +// GET /api/divisions - List all divisions (public) +router.get('/', async (req, res, next) => { + try { + const divisions = await prisma.division.findMany({ + orderBy: { + displayOrder: 'asc', + }, + select: { + id: true, + name: true, + abbreviation: true, + displayOrder: true, + }, + }); + + res.json({ + success: true, + count: divisions.length, + data: divisions, + }); + } catch (error) { + next(error); + } +}); + +module.exports = router; diff --git a/backend/src/routes/events.js b/backend/src/routes/events.js index d315896..219ce47 100644 --- a/backend/src/routes/events.js +++ b/backend/src/routes/events.js @@ -448,4 +448,336 @@ router.delete('/:slug/leave', authenticate, async (req, res, next) => { } }); +// POST /api/events/:slug/heats - Add/update user's heats for event +router.post('/:slug/heats', authenticate, async (req, res, next) => { + try { + const { slug } = req.params; + const userId = req.user.id; + const { heats } = req.body; + + // Validation + if (!Array.isArray(heats) || heats.length === 0) { + return res.status(400).json({ + success: false, + error: 'Heats must be a non-empty array', + }); + } + + // Validate heat structure + for (const heat of heats) { + if (!heat.divisionId || !heat.competitionTypeId || !heat.heatNumber) { + return res.status(400).json({ + success: false, + error: 'Each heat must have divisionId, competitionTypeId, and heatNumber', + }); + } + + if (heat.heatNumber < 1 || heat.heatNumber > 9) { + return res.status(400).json({ + success: false, + error: 'Heat number must be between 1 and 9', + }); + } + + if (heat.role && !['Leader', 'Follower'].includes(heat.role)) { + return res.status(400).json({ + success: false, + error: 'Role must be either Leader or Follower', + }); + } + } + + // 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 of this event', + }); + } + + // Delete existing heats and create new ones (transaction) + const result = await prisma.$transaction(async (tx) => { + // Delete existing heats for this user and event + await tx.eventUserHeat.deleteMany({ + where: { + userId, + eventId: event.id, + }, + }); + + // Create new heats + const created = await tx.eventUserHeat.createMany({ + data: heats.map((heat) => ({ + userId, + eventId: event.id, + divisionId: heat.divisionId, + competitionTypeId: heat.competitionTypeId, + heatNumber: heat.heatNumber, + role: heat.role || null, + })), + }); + + // Fetch created heats with relations + const userHeats = await tx.eventUserHeat.findMany({ + where: { + userId, + eventId: event.id, + }, + include: { + division: { + select: { + id: true, + name: true, + abbreviation: true, + }, + }, + competitionType: { + select: { + id: true, + name: true, + abbreviation: true, + }, + }, + }, + }); + + return userHeats; + }); + + res.json({ + success: true, + count: result.length, + data: result, + }); + } catch (error) { + // Handle unique constraint violation + if (error.code === 'P2002') { + return res.status(400).json({ + success: false, + error: 'Cannot have duplicate heats with same role in same division and competition type', + }); + } + next(error); + } +}); + +// GET /api/events/:slug/heats/me - Get current user's heats +router.get('/:slug/heats/me', authenticate, async (req, res, next) => { + try { + const { slug } = req.params; + const userId = req.user.id; + + // 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', + }); + } + + // Get user's heats + const heats = await prisma.eventUserHeat.findMany({ + where: { + userId, + eventId: event.id, + }, + include: { + division: { + select: { + id: true, + name: true, + abbreviation: true, + }, + }, + competitionType: { + select: { + id: true, + name: true, + abbreviation: true, + }, + }, + }, + orderBy: [ + { competitionTypeId: 'asc' }, + { divisionId: 'asc' }, + { heatNumber: 'asc' }, + ], + }); + + res.json({ + success: true, + count: heats.length, + data: heats, + }); + } catch (error) { + next(error); + } +}); + +// GET /api/events/:slug/heats/all - Get all users' heats for event +router.get('/:slug/heats/all', authenticate, async (req, res, next) => { + try { + const { slug } = req.params; + + // 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', + }); + } + + // Get all heats with user info + const heats = await prisma.eventUserHeat.findMany({ + where: { + eventId: event.id, + }, + include: { + user: { + select: { + id: true, + username: true, + avatar: true, + }, + }, + division: { + select: { + id: true, + name: true, + abbreviation: true, + }, + }, + competitionType: { + select: { + id: true, + name: true, + abbreviation: true, + }, + }, + }, + }); + + // Group by user + const userHeatsMap = new Map(); + + for (const heat of heats) { + const userId = heat.user.id; + + if (!userHeatsMap.has(userId)) { + userHeatsMap.set(userId, { + userId: heat.user.id, + username: heat.user.username, + avatar: heat.user.avatar, + heats: [], + }); + } + + userHeatsMap.get(userId).heats.push({ + id: heat.id, + divisionId: heat.divisionId, + division: heat.division, + competitionTypeId: heat.competitionTypeId, + competitionType: heat.competitionType, + heatNumber: heat.heatNumber, + role: heat.role, + }); + } + + const result = Array.from(userHeatsMap.values()); + + res.json({ + success: true, + count: result.length, + data: result, + }); + } catch (error) { + next(error); + } +}); + +// DELETE /api/events/:slug/heats/:id - Delete specific heat +router.delete('/:slug/heats/:id', authenticate, async (req, res, next) => { + try { + const { slug, id } = req.params; + const userId = req.user.id; + + // 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', + }); + } + + // Find heat + const heat = await prisma.eventUserHeat.findUnique({ + where: { id: parseInt(id) }, + }); + + if (!heat) { + return res.status(404).json({ + success: false, + error: 'Heat not found', + }); + } + + // Check ownership + if (heat.userId !== userId) { + return res.status(403).json({ + success: false, + error: 'You can only delete your own heats', + }); + } + + // Delete heat + await prisma.eventUserHeat.delete({ + where: { id: parseInt(id) }, + }); + + res.json({ + success: true, + message: 'Heat deleted successfully', + }); + } catch (error) { + next(error); + } +}); + module.exports = router;