2ccb018bdf
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.
166 lines
5.4 KiB
JavaScript
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;
|