Files
pdga-rating/src/routes/tours.js
T
Samuel Enocsson 2ccb018bdf feat: add async tour system
Players can create tours with selected courses/layouts and a date range.
Others join via a 6-character tour code, play the courses, and report
their total strokes. Live leaderboard with points and +/- par display.

Includes: database schema, model, service, routes, views, and styling.
2026-03-20 07:39:43 +01:00

166 lines
5.4 KiB
JavaScript

const express = require('express');
const router = express.Router();
const { getAllCoursesFromDB, getLayoutsForCourse } = require('../models/course');
const {
createTour, getTourByCode, addCourseToTour, getTourCourses,
getTourCourseById, joinTour, getPlayerInTour, recordResult
} = require('../models/tour');
const { generateTourCode, isTourActive, isTourFinished, calculateLeaderboard } = require('../services/tour-service');
const logger = require('../logger');
// Create a new tour
router.post('/api/tours', async (req, res) => {
try {
const { name, startDate, endDate, courses } = req.body;
if (!name || !startDate || !endDate || !courses || courses.length === 0) {
return res.status(400).json({ error: 'Name, dates, and at least one course are required' });
}
if (new Date(endDate) <= new Date(startDate)) {
return res.status(400).json({ error: 'End date must be after start date' });
}
const code = generateTourCode();
const tourId = await createTour(name, code, startDate, endDate);
for (const course of courses) {
await addCourseToTour(tourId, course.courseId, course.layoutId);
}
logger.info(`Tour created: "${name}" (${code}) with ${courses.length} courses`);
res.json({ success: true, code, message: `Tour "${name}" created` });
} catch (error) {
logger.error('Error creating tour:', error.message);
res.status(500).json({ error: 'Failed to create tour' });
}
});
// Join a tour
router.post('/api/tours/:code/join', async (req, res) => {
try {
const { code } = req.params;
const { pdgaNumber, playerName } = req.body;
if (!pdgaNumber || !playerName) {
return res.status(400).json({ error: 'PDGA number and name are required' });
}
const tour = await getTourByCode(code);
if (!tour) {
return res.status(404).json({ error: 'Tour not found' });
}
await joinTour(tour.id, pdgaNumber, playerName);
logger.info(`Player ${playerName} (${pdgaNumber}) joined tour ${code}`);
res.json({ success: true, message: `Joined tour "${tour.name}"` });
} catch (error) {
logger.error('Error joining tour:', error.message);
res.status(500).json({ error: 'Failed to join tour' });
}
});
// Record a result
router.post('/api/tours/:code/results', async (req, res) => {
try {
const { code } = req.params;
const { pdgaNumber, tourCourseId, totalStrokes } = req.body;
if (!pdgaNumber || !tourCourseId || !totalStrokes) {
return res.status(400).json({ error: 'PDGA number, course, and strokes are required' });
}
const tour = await getTourByCode(code);
if (!tour) {
return res.status(404).json({ error: 'Tour not found' });
}
// Verify tourCourseId belongs to this tour
const tourCourse = await getTourCourseById(tourCourseId);
if (!tourCourse || tourCourse.tour_id !== tour.id) {
return res.status(400).json({ error: 'Invalid course for this tour' });
}
if (!isTourActive(tour)) {
return res.status(400).json({ error: 'Tour is not active. Results can only be recorded during the tour period.' });
}
const player = await getPlayerInTour(tour.id, pdgaNumber);
if (!player) {
return res.status(403).json({ error: 'You must join the tour before recording results' });
}
await recordResult(tourCourseId, pdgaNumber, parseInt(totalStrokes));
logger.info(`Result recorded: ${pdgaNumber} scored ${totalStrokes} on course ${tourCourseId} in tour ${code}`);
res.json({ success: true, message: 'Result recorded' });
} catch (error) {
logger.error('Error recording result:', error.message);
res.status(500).json({ error: 'Failed to record result' });
}
});
// Get leaderboard partial (HTMX)
router.get('/partials/tour-leaderboard/:code', async (req, res) => {
try {
const tour = await getTourByCode(req.params.code);
if (!tour) {
return res.status(404).send('<p>Tour not found</p>');
}
const { courses, leaderboard } = await calculateLeaderboard(tour.id);
res.render('../partials/tour-leaderboard', {
tour,
courses,
leaderboard,
isActive: isTourActive(tour),
isFinished: isTourFinished(tour)
});
} catch (error) {
logger.error('Error loading leaderboard:', error.message);
res.status(500).send('<p>Error loading leaderboard</p>');
}
});
// Get courses and layouts for tour creation form
router.get('/api/tours/courses-with-layouts', async (req, res) => {
try {
const courses = await getAllCoursesFromDB();
const coursesWithLayouts = [];
for (const course of courses) {
const layouts = await getLayoutsForCourse(course.id);
if (layouts.length > 0) {
coursesWithLayouts.push({ ...course, layouts });
}
}
res.json(coursesWithLayouts);
} catch (error) {
logger.error('Error fetching courses with layouts:', error.message);
res.status(500).json({ error: 'Failed to fetch courses' });
}
});
// Tour page
router.get('/tours/:code', async (req, res) => {
try {
const tour = await getTourByCode(req.params.code);
if (!tour) {
return res.status(404).render('tour', { tour: null });
}
const courses = await getTourCourses(tour.id);
res.render('tour', {
tour,
courses,
isActive: isTourActive(tour),
isFinished: isTourFinished(tour)
});
} catch (error) {
logger.error('Error loading tour page:', error.message);
res.status(500).send('Error loading tour');
}
});
module.exports = router;