diff --git a/backend/dist/index.js b/backend/dist/index.js index 9f031e7..193bcc6 100644 --- a/backend/dist/index.js +++ b/backend/dist/index.js @@ -9,12 +9,13 @@ const cors_1 = __importDefault(require("cors")); const http_1 = require("http"); const socket_io_1 = require("socket.io"); const client_1 = require("@prisma/client"); -const auth_js_1 = __importDefault(require("./routes/auth.js")); -const games_js_1 = __importDefault(require("./routes/games.js")); -const teams_js_1 = __importDefault(require("./routes/teams.js")); -const legs_js_1 = __importDefault(require("./routes/legs.js")); -const upload_js_1 = __importDefault(require("./routes/upload.js")); -const index_js_1 = __importDefault(require("./socket/index.js")); +const auth_1 = __importDefault(require("./routes/auth")); +const games_1 = __importDefault(require("./routes/games")); +const teams_1 = __importDefault(require("./routes/teams")); +const routes_1 = __importDefault(require("./routes/routes")); +const users_1 = __importDefault(require("./routes/users")); +const upload_1 = __importDefault(require("./routes/upload")); +const index_1 = __importDefault(require("./socket/index")); const app = (0, express_1.default)(); const httpServer = (0, http_1.createServer)(app); const io = new socket_io_1.Server(httpServer, { @@ -27,15 +28,16 @@ exports.prisma = new client_1.PrismaClient(); app.use((0, cors_1.default)()); app.use(express_1.default.json()); app.use('/uploads', express_1.default.static('uploads')); -app.use('/api/auth', auth_js_1.default); -app.use('/api/games', games_js_1.default); -app.use('/api/teams', teams_js_1.default); -app.use('/api/legs', legs_js_1.default); -app.use('/api/upload', upload_js_1.default); +app.use('/api/auth', auth_1.default); +app.use('/api/games', games_1.default); +app.use('/api/teams', teams_1.default); +app.use('/api/routes', routes_1.default); +app.use('/api/users', users_1.default); +app.use('/api/upload', upload_1.default); app.get('/api/health', (req, res) => { res.json({ status: 'ok' }); }); -(0, index_js_1.default)(io); +(0, index_1.default)(io); const PORT = process.env.PORT || 3001; httpServer.listen(PORT, () => { console.log(`Server running on port ${PORT}`); diff --git a/backend/dist/middleware/auth.js b/backend/dist/middleware/auth.js index 5d97c22..a7d6936 100644 --- a/backend/dist/middleware/auth.js +++ b/backend/dist/middleware/auth.js @@ -5,7 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { Object.defineProperty(exports, "__esModule", { value: true }); exports.optionalAuth = exports.authenticate = void 0; const jsonwebtoken_1 = __importDefault(require("jsonwebtoken")); -const index_js_1 = require("../index.js"); +const index_1 = require("../index"); const JWT_SECRET = process.env.JWT_SECRET || 'treasure-trails-secret-key'; const authenticate = async (req, res, next) => { try { @@ -15,7 +15,7 @@ const authenticate = async (req, res, next) => { } const token = authHeader.split(' ')[1]; const decoded = jsonwebtoken_1.default.verify(token, JWT_SECRET); - const user = await index_js_1.prisma.user.findUnique({ + const user = await index_1.prisma.user.findUnique({ where: { id: decoded.userId }, select: { id: true, email: true, name: true } }); @@ -38,7 +38,7 @@ const optionalAuth = async (req, res, next) => { } const token = authHeader.split(' ')[1]; const decoded = jsonwebtoken_1.default.verify(token, JWT_SECRET); - const user = await index_js_1.prisma.user.findUnique({ + const user = await index_1.prisma.user.findUnique({ where: { id: decoded.userId }, select: { id: true, email: true, name: true } }); diff --git a/backend/dist/routes/auth.js b/backend/dist/routes/auth.js index d90941b..bbe6dcf 100644 --- a/backend/dist/routes/auth.js +++ b/backend/dist/routes/auth.js @@ -6,7 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); const express_1 = require("express"); const bcryptjs_1 = __importDefault(require("bcryptjs")); const jsonwebtoken_1 = __importDefault(require("jsonwebtoken")); -const index_js_1 = require("../index.js"); +const index_1 = require("../index"); const router = (0, express_1.Router)(); const JWT_SECRET = process.env.JWT_SECRET || 'treasure-trails-secret-key'; router.post('/register', async (req, res) => { @@ -15,12 +15,12 @@ router.post('/register', async (req, res) => { if (!email || !password || !name) { return res.status(400).json({ error: 'Email, password, and name are required' }); } - const existingUser = await index_js_1.prisma.user.findUnique({ where: { email } }); + const existingUser = await index_1.prisma.user.findUnique({ where: { email } }); if (existingUser) { return res.status(400).json({ error: 'Email already registered' }); } const passwordHash = await bcryptjs_1.default.hash(password, 10); - const user = await index_js_1.prisma.user.create({ + const user = await index_1.prisma.user.create({ data: { email, passwordHash, name } }); const token = jsonwebtoken_1.default.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' }); @@ -40,7 +40,7 @@ router.post('/login', async (req, res) => { if (!email || !password) { return res.status(400).json({ error: 'Email and password are required' }); } - const user = await index_js_1.prisma.user.findUnique({ where: { email } }); + const user = await index_1.prisma.user.findUnique({ where: { email } }); if (!user) { return res.status(401).json({ error: 'Invalid credentials' }); } @@ -67,7 +67,7 @@ router.get('/me', async (req, res) => { } const token = authHeader.split(' ')[1]; const decoded = jsonwebtoken_1.default.verify(token, JWT_SECRET); - const user = await index_js_1.prisma.user.findUnique({ + const user = await index_1.prisma.user.findUnique({ where: { id: decoded.userId }, select: { id: true, email: true, name: true, createdAt: true } }); diff --git a/backend/dist/routes/games.js b/backend/dist/routes/games.js index 4dc2e23..a0cd6f6 100644 --- a/backend/dist/routes/games.js +++ b/backend/dist/routes/games.js @@ -1,8 +1,8 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const express_1 = require("express"); -const index_js_1 = require("../index.js"); -const auth_js_1 = require("../middleware/auth.js"); +const index_1 = require("../index"); +const auth_1 = require("../middleware/auth"); const uuid_1 = require("uuid"); const router = (0, express_1.Router)(); router.get('/', async (req, res) => { @@ -17,11 +17,11 @@ router.get('/', async (req, res) => { if (search) { where.name = { contains: search, mode: 'insensitive' }; } - const games = await index_js_1.prisma.game.findMany({ + const games = await index_1.prisma.game.findMany({ where, include: { gameMaster: { select: { id: true, name: true } }, - _count: { select: { teams: true, legs: true } } + _count: { select: { teams: true, routes: true } } }, orderBy: { createdAt: 'desc' } }); @@ -32,12 +32,12 @@ router.get('/', async (req, res) => { res.status(500).json({ error: 'Failed to list games' }); } }); -router.get('/my-games', auth_js_1.authenticate, async (req, res) => { +router.get('/my-games', auth_1.authenticate, async (req, res) => { try { - const games = await index_js_1.prisma.game.findMany({ + const games = await index_1.prisma.game.findMany({ where: { gameMasterId: req.user.id }, include: { - _count: { select: { teams: true, legs: true } } + _count: { select: { teams: true, routes: true } } }, orderBy: { createdAt: 'desc' } }); @@ -51,15 +51,27 @@ router.get('/my-games', auth_js_1.authenticate, async (req, res) => { router.get('/:id', async (req, res) => { try { const { id } = req.params; - const game = await index_js_1.prisma.game.findUnique({ - where: { id }, + const game = await index_1.prisma.game.findUnique({ + where: { id: id }, include: { gameMaster: { select: { id: true, name: true } }, - legs: { orderBy: { sequenceNumber: 'asc' } }, + routes: { + include: { + routeLegs: { orderBy: { sequenceNumber: 'asc' } } + } + }, teams: { include: { members: { include: { user: { select: { id: true, name: true, email: true } } } }, - currentLeg: true + teamRoutes: { + include: { + route: { + include: { + routeLegs: { orderBy: { sequenceNumber: 'asc' } } + } + } + } + } } } } @@ -71,16 +83,20 @@ router.get('/:id', async (req, res) => { if (game.visibility === 'PRIVATE' && !isOwner) { return res.status(403).json({ error: 'Access denied' }); } - res.json(game); + const result = { + ...game, + rules: game.rules ? JSON.parse(game.rules) : [] + }; + res.json(result); } catch (error) { console.error('Get game error:', error); res.status(500).json({ error: 'Failed to get game' }); } }); -router.post('/', auth_js_1.authenticate, async (req, res) => { +router.post('/', auth_1.authenticate, async (req, res) => { try { - const { name, description, prizeDetails, visibility, startDate, locationLat, locationLng, searchRadius, timeLimitPerLeg, timeDeductionPenalty } = req.body; + const { name, description, prizeDetails, visibility, startDate, locationLat, locationLng, searchRadius, rules, randomizeRoutes } = req.body; if (!name) { return res.status(400).json({ error: 'Name is required' }); } @@ -88,7 +104,7 @@ router.post('/', auth_js_1.authenticate, async (req, res) => { return res.status(400).json({ error: 'Start date must be in the future' }); } const inviteCode = (0, uuid_1.v4)().slice(0, 8); - const game = await index_js_1.prisma.game.create({ + const game = await index_1.prisma.game.create({ data: { name, description, @@ -98,8 +114,8 @@ router.post('/', auth_js_1.authenticate, async (req, res) => { locationLat, locationLng, searchRadius, - timeLimitPerLeg, - timeDeductionPenalty, + rules: rules ? JSON.stringify(rules) : null, + randomizeRoutes: randomizeRoutes || false, gameMasterId: req.user.id, inviteCode } @@ -111,19 +127,19 @@ router.post('/', auth_js_1.authenticate, async (req, res) => { res.status(500).json({ error: 'Failed to create game' }); } }); -router.put('/:id', auth_js_1.authenticate, async (req, res) => { +router.put('/:id', auth_1.authenticate, async (req, res) => { try { - const { id } = req.params; - const { name, description, prizeDetails, visibility, startDate, locationLat, locationLng, searchRadius, timeLimitPerLeg, timeDeductionPenalty, status } = req.body; - const game = await index_js_1.prisma.game.findUnique({ where: { id } }); + const id = req.params.id; + const { name, description, prizeDetails, visibility, startDate, locationLat, locationLng, searchRadius, rules, status, randomizeRoutes } = req.body; + const game = await index_1.prisma.game.findUnique({ where: { id } }); if (!game) { return res.status(404).json({ error: 'Game not found' }); } if (game.gameMasterId !== req.user.id) { return res.status(403).json({ error: 'Not authorized' }); } - const updated = await index_js_1.prisma.game.update({ - where: { id }, + const updated = await index_1.prisma.game.update({ + where: { id: id }, data: { name, description, @@ -133,8 +149,8 @@ router.put('/:id', auth_js_1.authenticate, async (req, res) => { locationLat, locationLng, searchRadius, - timeLimitPerLeg, - timeDeductionPenalty, + rules: rules ? JSON.stringify(rules) : undefined, + randomizeRoutes, status } }); @@ -145,17 +161,17 @@ router.put('/:id', auth_js_1.authenticate, async (req, res) => { res.status(500).json({ error: 'Failed to update game' }); } }); -router.delete('/:id', auth_js_1.authenticate, async (req, res) => { +router.delete('/:id', auth_1.authenticate, async (req, res) => { try { - const { id } = req.params; - const game = await index_js_1.prisma.game.findUnique({ where: { id } }); + const id = req.params.id; + const game = await index_1.prisma.game.findUnique({ where: { id } }); if (!game) { return res.status(404).json({ error: 'Game not found' }); } if (game.gameMasterId !== req.user.id) { return res.status(403).json({ error: 'Not authorized' }); } - await index_js_1.prisma.game.delete({ where: { id } }); + await index_1.prisma.game.delete({ where: { id } }); res.json({ message: 'Game deleted' }); } catch (error) { @@ -163,12 +179,12 @@ router.delete('/:id', auth_js_1.authenticate, async (req, res) => { res.status(500).json({ error: 'Failed to delete game' }); } }); -router.post('/:id/publish', auth_js_1.authenticate, async (req, res) => { +router.post('/:id/publish', auth_1.authenticate, async (req, res) => { try { const { id } = req.params; - const game = await index_js_1.prisma.game.findUnique({ - where: { id }, - include: { legs: true } + const game = await index_1.prisma.game.findUnique({ + where: { id: id }, + include: { routes: { include: { routeLegs: true } } } }); if (!game) { return res.status(404).json({ error: 'Game not found' }); @@ -176,11 +192,34 @@ router.post('/:id/publish', auth_js_1.authenticate, async (req, res) => { if (game.gameMasterId !== req.user.id) { return res.status(403).json({ error: 'Not authorized' }); } - if (game.legs.length === 0) { - return res.status(400).json({ error: 'Game must have at least one leg' }); + if (!game.routes || game.routes.length === 0) { + return res.status(400).json({ error: 'Game must have at least one route' }); } - const updated = await index_js_1.prisma.game.update({ - where: { id }, + const hasValidRoute = game.routes.some(route => route.routeLegs.length > 0); + if (!hasValidRoute) { + return res.status(400).json({ error: 'At least one route must have legs' }); + } + if (game.randomizeRoutes) { + const teams = await index_1.prisma.team.findMany({ + where: { gameId: id } + }); + const shuffledRoutes = [...game.routes].sort(() => Math.random() - 0.5); + for (let i = 0; i < teams.length; i++) { + const routeIndex = i % game.routes.length; + await index_1.prisma.teamRoute.upsert({ + where: { teamId: teams[i].id }, + create: { + teamId: teams[i].id, + routeId: shuffledRoutes[routeIndex].id + }, + update: { + routeId: shuffledRoutes[routeIndex].id + } + }); + } + } + const updated = await index_1.prisma.game.update({ + where: { id: id }, data: { status: 'LIVE' } }); res.json(updated); @@ -190,17 +229,17 @@ router.post('/:id/publish', auth_js_1.authenticate, async (req, res) => { res.status(500).json({ error: 'Failed to publish game' }); } }); -router.post('/:id/end', auth_js_1.authenticate, async (req, res) => { +router.post('/:id/end', auth_1.authenticate, async (req, res) => { try { - const { id } = req.params; - const game = await index_js_1.prisma.game.findUnique({ where: { id } }); + const id = req.params.id; + const game = await index_1.prisma.game.findUnique({ where: { id } }); if (!game) { return res.status(404).json({ error: 'Game not found' }); } if (game.gameMasterId !== req.user.id) { return res.status(403).json({ error: 'Not authorized' }); } - const updated = await index_js_1.prisma.game.update({ + const updated = await index_1.prisma.game.update({ where: { id }, data: { status: 'ENDED' } }); @@ -211,10 +250,52 @@ router.post('/:id/end', auth_js_1.authenticate, async (req, res) => { res.status(500).json({ error: 'Failed to end game' }); } }); +router.post('/:id/archive', auth_1.authenticate, async (req, res) => { + try { + const id = req.params.id; + const game = await index_1.prisma.game.findUnique({ where: { id } }); + if (!game) { + return res.status(404).json({ error: 'Game not found' }); + } + if (game.gameMasterId !== req.user.id) { + return res.status(403).json({ error: 'Not authorized' }); + } + const updated = await index_1.prisma.game.update({ + where: { id }, + data: { status: 'ARCHIVED' } + }); + res.json(updated); + } + catch (error) { + console.error('Archive game error:', error); + res.status(500).json({ error: 'Failed to archive game' }); + } +}); +router.post('/:id/unarchive', auth_1.authenticate, async (req, res) => { + try { + const id = req.params.id; + const game = await index_1.prisma.game.findUnique({ where: { id } }); + if (!game) { + return res.status(404).json({ error: 'Game not found' }); + } + if (game.gameMasterId !== req.user.id) { + return res.status(403).json({ error: 'Not authorized' }); + } + const updated = await index_1.prisma.game.update({ + where: { id }, + data: { status: 'ENDED' } + }); + res.json(updated); + } + catch (error) { + console.error('Unarchive game error:', error); + res.status(500).json({ error: 'Failed to unarchive game' }); + } +}); router.get('/:id/invite', async (req, res) => { try { - const { id } = req.params; - const game = await index_js_1.prisma.game.findUnique({ where: { id } }); + const id = req.params.id; + const game = await index_1.prisma.game.findUnique({ where: { id } }); if (!game) { return res.status(404).json({ error: 'Game not found' }); } @@ -230,8 +311,8 @@ router.get('/:id/invite', async (req, res) => { }); router.get('/invite/:code', async (req, res) => { try { - const { code } = req.params; - const game = await index_js_1.prisma.game.findUnique({ + const code = req.params.code; + const game = await index_1.prisma.game.findUnique({ where: { inviteCode: code }, include: { gameMaster: { select: { id: true, name: true } } } }); diff --git a/backend/dist/routes/routes.d.ts b/backend/dist/routes/routes.d.ts new file mode 100644 index 0000000..ae2ab41 --- /dev/null +++ b/backend/dist/routes/routes.d.ts @@ -0,0 +1,2 @@ +declare const router: import("express-serve-static-core").Router; +export default router; diff --git a/backend/dist/routes/routes.js b/backend/dist/routes/routes.js new file mode 100644 index 0000000..7622626 --- /dev/null +++ b/backend/dist/routes/routes.js @@ -0,0 +1,338 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const express_1 = require("express"); +const index_1 = require("../index"); +const auth_1 = require("../middleware/auth"); +const router = (0, express_1.Router)(); +router.get('/game/:gameId', async (req, res) => { + try { + const { gameId } = req.params; + const routes = await index_1.prisma.route.findMany({ + where: { gameId: gameId }, + include: { + routeLegs: { + orderBy: { sequenceNumber: 'asc' } + }, + _count: { select: { teamRoutes: true } } + }, + orderBy: { createdAt: 'asc' } + }); + res.json(routes); + } + catch (error) { + console.error('List routes error:', error); + res.status(500).json({ error: 'Failed to list routes' }); + } +}); +router.get('/:id', async (req, res) => { + try { + const { id } = req.params; + const route = await index_1.prisma.route.findUnique({ + where: { id: id }, + include: { + routeLegs: { + orderBy: { sequenceNumber: 'asc' } + }, + teamRoutes: { + include: { + team: { + include: { + members: { include: { user: { select: { id: true, name: true } } } } + } + } + } + } + } + }); + if (!route) { + return res.status(404).json({ error: 'Route not found' }); + } + res.json(route); + } + catch (error) { + console.error('Get route error:', error); + res.status(500).json({ error: 'Failed to get route' }); + } +}); +router.post('/', auth_1.authenticate, async (req, res) => { + try { + const { gameId, name, description, color } = req.body; + if (!gameId || !name) { + return res.status(400).json({ error: 'Game ID and name are required' }); + } + const game = await index_1.prisma.game.findUnique({ where: { id: gameId } }); + if (!game) { + return res.status(404).json({ error: 'Game not found' }); + } + if (game.gameMasterId !== req.user.id) { + return res.status(403).json({ error: 'Not authorized' }); + } + const route = await index_1.prisma.route.create({ + data: { + gameId, + name, + description, + color: color || '#3498db' + }, + include: { + routeLegs: true + } + }); + res.json(route); + } + catch (error) { + console.error('Create route error:', error); + res.status(500).json({ error: 'Failed to create route' }); + } +}); +router.put('/:id', auth_1.authenticate, async (req, res) => { + try { + const { id } = req.params; + const { name, description, color } = req.body; + const route = await index_1.prisma.route.findUnique({ + where: { id: id }, + include: { game: true } + }); + if (!route) { + return res.status(404).json({ error: 'Route not found' }); + } + if (route.game.gameMasterId !== req.user.id) { + return res.status(403).json({ error: 'Not authorized' }); + } + const updated = await index_1.prisma.route.update({ + where: { id: id }, + data: { + name: name || route.name, + description: description !== undefined ? description : route.description, + color: color || route.color + }, + include: { + routeLegs: { + orderBy: { sequenceNumber: 'asc' } + } + } + }); + res.json(updated); + } + catch (error) { + console.error('Update route error:', error); + res.status(500).json({ error: 'Failed to update route' }); + } +}); +router.delete('/:id', auth_1.authenticate, async (req, res) => { + try { + const { id } = req.params; + const route = await index_1.prisma.route.findUnique({ + where: { id: id }, + include: { game: true } + }); + if (!route) { + return res.status(404).json({ error: 'Route not found' }); + } + if (route.game.gameMasterId !== req.user.id) { + return res.status(403).json({ error: 'Not authorized' }); + } + await index_1.prisma.route.delete({ where: { id: id } }); + res.json({ message: 'Route deleted' }); + } + catch (error) { + console.error('Delete route error:', error); + res.status(500).json({ error: 'Failed to delete route' }); + } +}); +router.post('/:id/legs', auth_1.authenticate, async (req, res) => { + try { + const { id } = req.params; + const { description, conditionType, conditionDetails, locationLat, locationLng, timeLimit } = req.body; + const route = await index_1.prisma.route.findUnique({ + where: { id: id }, + include: { + game: true, + routeLegs: true + } + }); + if (!route) { + return res.status(404).json({ error: 'Route not found' }); + } + if (route.game.gameMasterId !== req.user.id) { + return res.status(403).json({ error: 'Not authorized' }); + } + const maxSequence = route.routeLegs.length > 0 + ? Math.max(...route.routeLegs.map(l => l.sequenceNumber)) + : 0; + const routeLeg = await index_1.prisma.routeLeg.create({ + data: { + routeId: id, + sequenceNumber: maxSequence + 1, + description: description || '', + conditionType: conditionType || 'photo', + conditionDetails, + locationLat, + locationLng, + timeLimit + } + }); + res.json(routeLeg); + } + catch (error) { + console.error('Add route leg error:', error); + res.status(500).json({ error: 'Failed to add leg to route' }); + } +}); +router.put('/:id/legs/:legId', auth_1.authenticate, async (req, res) => { + try { + const { id, legId } = req.params; + const { description, conditionType, conditionDetails, locationLat, locationLng, timeLimit, sequenceNumber } = req.body; + const route = await index_1.prisma.route.findUnique({ + where: { id: id }, + include: { game: true } + }); + if (!route) { + return res.status(404).json({ error: 'Route not found' }); + } + if (route.game.gameMasterId !== req.user.id) { + return res.status(403).json({ error: 'Not authorized' }); + } + const routeLeg = await index_1.prisma.routeLeg.update({ + where: { id: legId }, + data: { + description: description !== undefined ? description : undefined, + conditionType: conditionType !== undefined ? conditionType : undefined, + conditionDetails: conditionDetails !== undefined ? conditionDetails : undefined, + locationLat: locationLat !== undefined ? locationLat : undefined, + locationLng: locationLng !== undefined ? locationLng : undefined, + timeLimit: timeLimit !== undefined ? timeLimit : undefined, + sequenceNumber: sequenceNumber !== undefined ? sequenceNumber : undefined + } + }); + res.json(routeLeg); + } + catch (error) { + console.error('Update route leg error:', error); + res.status(500).json({ error: 'Failed to update route leg' }); + } +}); +router.delete('/:id/legs/:legId', auth_1.authenticate, async (req, res) => { + try { + const { id, legId } = req.params; + const route = await index_1.prisma.route.findUnique({ + where: { id: id }, + include: { game: true } + }); + if (!route) { + return res.status(404).json({ error: 'Route not found' }); + } + if (route.game.gameMasterId !== req.user.id) { + return res.status(403).json({ error: 'Not authorized' }); + } + await index_1.prisma.routeLeg.delete({ where: { id: legId } }); + const remainingLegs = await index_1.prisma.routeLeg.findMany({ + where: { routeId: id }, + orderBy: { sequenceNumber: 'asc' } + }); + for (let i = 0; i < remainingLegs.length; i++) { + if (remainingLegs[i].sequenceNumber !== i + 1) { + await index_1.prisma.routeLeg.update({ + where: { id: remainingLegs[i].id }, + data: { sequenceNumber: i + 1 } + }); + } + } + res.json({ message: 'Leg deleted' }); + } + catch (error) { + console.error('Delete route leg error:', error); + res.status(500).json({ error: 'Failed to delete route leg' }); + } +}); +router.post('/:id/legs/:legId/photo', auth_1.authenticate, async (req, res) => { + try { + const { id, legId } = req.params; + const { teamId, photoUrl } = req.body; + const route = await index_1.prisma.route.findUnique({ + where: { id: id }, + include: { game: true } + }); + if (!route) { + return res.status(404).json({ error: 'Route not found' }); + } + const routeLeg = await index_1.prisma.routeLeg.findFirst({ + where: { id: legId, routeId: id } + }); + if (!routeLeg) { + return res.status(404).json({ error: 'Route leg not found' }); + } + const member = await index_1.prisma.teamMember.findFirst({ + where: { teamId, userId: req.user.id } + }); + const team = await index_1.prisma.team.findUnique({ where: { id: teamId } }); + if (!member && team?.captainId !== req.user.id) { + return res.status(403).json({ error: 'Not authorized' }); + } + const submission = await index_1.prisma.photoSubmission.create({ + data: { + teamId, + routeId: id, + routeLegId: legId, + photoUrl + } + }); + res.json(submission); + } + catch (error) { + console.error('Photo submission error:', error); + res.status(500).json({ error: 'Failed to submit photo' }); + } +}); +router.post('/:id/copy', auth_1.authenticate, async (req, res) => { + try { + const { id } = req.params; + const originalRoute = await index_1.prisma.route.findUnique({ + where: { id: id }, + include: { + game: true, + routeLegs: { orderBy: { sequenceNumber: 'asc' } } + } + }); + if (!originalRoute) { + return res.status(404).json({ error: 'Route not found' }); + } + if (originalRoute.game.gameMasterId !== req.user.id) { + return res.status(403).json({ error: 'Not authorized' }); + } + const newRoute = await index_1.prisma.route.create({ + data: { + gameId: originalRoute.gameId, + name: `${originalRoute.name} (Copy)`, + description: originalRoute.description, + color: originalRoute.color + } + }); + for (const leg of originalRoute.routeLegs) { + await index_1.prisma.routeLeg.create({ + data: { + routeId: newRoute.id, + sequenceNumber: leg.sequenceNumber, + description: leg.description, + conditionType: leg.conditionType, + conditionDetails: leg.conditionDetails, + locationLat: leg.locationLat, + locationLng: leg.locationLng, + timeLimit: leg.timeLimit + } + }); + } + const fullRoute = await index_1.prisma.route.findUnique({ + where: { id: newRoute.id }, + include: { + routeLegs: { orderBy: { sequenceNumber: 'asc' } } + } + }); + res.json(fullRoute); + } + catch (error) { + console.error('Copy route error:', error); + res.status(500).json({ error: 'Failed to copy route' }); + } +}); +exports.default = router; diff --git a/backend/dist/routes/teams.js b/backend/dist/routes/teams.js index d177240..eab0c66 100644 --- a/backend/dist/routes/teams.js +++ b/backend/dist/routes/teams.js @@ -1,18 +1,26 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const express_1 = require("express"); -const index_js_1 = require("../index.js"); -const auth_js_1 = require("../middleware/auth.js"); +const index_1 = require("../index"); +const auth_1 = require("../middleware/auth"); const router = (0, express_1.Router)(); router.get('/game/:gameId', async (req, res) => { try { const { gameId } = req.params; - const teams = await index_js_1.prisma.team.findMany({ + const teams = await index_1.prisma.team.findMany({ where: { gameId }, include: { members: { include: { user: { select: { id: true, name: true, email: true } } } }, captain: { select: { id: true, name: true } }, - currentLeg: true + teamRoutes: { + include: { + route: { + include: { + routeLegs: { orderBy: { sequenceNumber: 'asc' } } + } + } + } + } }, orderBy: { createdAt: 'asc' } }); @@ -23,11 +31,11 @@ router.get('/game/:gameId', async (req, res) => { res.status(500).json({ error: 'Failed to get teams' }); } }); -router.post('/game/:gameId', auth_js_1.authenticate, async (req, res) => { +router.post('/game/:gameId', auth_1.authenticate, async (req, res) => { try { const { gameId } = req.params; const { name } = req.body; - const game = await index_js_1.prisma.game.findUnique({ + const game = await index_1.prisma.game.findUnique({ where: { id: gameId }, include: { teams: true } }); @@ -37,7 +45,7 @@ router.post('/game/:gameId', auth_js_1.authenticate, async (req, res) => { if (game.status !== 'DRAFT' && game.status !== 'LIVE') { return res.status(400).json({ error: 'Cannot join game at this time' }); } - const existingMember = await index_js_1.prisma.teamMember.findFirst({ + const existingMember = await index_1.prisma.teamMember.findFirst({ where: { userId: req.user.id, team: { gameId } @@ -46,24 +54,33 @@ router.post('/game/:gameId', auth_js_1.authenticate, async (req, res) => { if (existingMember) { return res.status(400).json({ error: 'Already in a team for this game' }); } - const team = await index_js_1.prisma.team.create({ + const team = await index_1.prisma.team.create({ data: { gameId, name, captainId: req.user.id } }); - await index_js_1.prisma.teamMember.create({ + await index_1.prisma.teamMember.create({ data: { teamId: team.id, userId: req.user.id } }); - const created = await index_js_1.prisma.team.findUnique({ + const created = await index_1.prisma.team.findUnique({ where: { id: team.id }, include: { members: { include: { user: { select: { id: true, name: true, email: true } } } }, - captain: { select: { id: true, name: true } } + captain: { select: { id: true, name: true } }, + teamRoutes: { + include: { + route: { + include: { + routeLegs: { orderBy: { sequenceNumber: 'asc' } } + } + } + } + } } }); res.json(created); @@ -73,10 +90,10 @@ router.post('/game/:gameId', auth_js_1.authenticate, async (req, res) => { res.status(500).json({ error: 'Failed to create team' }); } }); -router.post('/:teamId/join', auth_js_1.authenticate, async (req, res) => { +router.post('/:teamId/join', auth_1.authenticate, async (req, res) => { try { const { teamId } = req.params; - const team = await index_js_1.prisma.team.findUnique({ + const team = await index_1.prisma.team.findUnique({ where: { id: teamId }, include: { game: true, members: true } }); @@ -86,7 +103,7 @@ router.post('/:teamId/join', auth_js_1.authenticate, async (req, res) => { if (team.members.length >= 5) { return res.status(400).json({ error: 'Team is full (max 5 members)' }); } - const existingMember = await index_js_1.prisma.teamMember.findFirst({ + const existingMember = await index_1.prisma.teamMember.findFirst({ where: { userId: req.user.id, teamId @@ -95,7 +112,7 @@ router.post('/:teamId/join', auth_js_1.authenticate, async (req, res) => { if (existingMember) { return res.status(400).json({ error: 'Already in this team' }); } - const gameMember = await index_js_1.prisma.teamMember.findFirst({ + const gameMember = await index_1.prisma.teamMember.findFirst({ where: { userId: req.user.id, team: { gameId: team.gameId } @@ -104,7 +121,7 @@ router.post('/:teamId/join', auth_js_1.authenticate, async (req, res) => { if (gameMember) { return res.status(400).json({ error: 'Already in another team for this game' }); } - await index_js_1.prisma.teamMember.create({ + await index_1.prisma.teamMember.create({ data: { teamId, userId: req.user.id @@ -117,10 +134,10 @@ router.post('/:teamId/join', auth_js_1.authenticate, async (req, res) => { res.status(500).json({ error: 'Failed to join team' }); } }); -router.post('/:teamId/leave', auth_js_1.authenticate, async (req, res) => { +router.post('/:teamId/leave', auth_1.authenticate, async (req, res) => { try { const { teamId } = req.params; - const team = await index_js_1.prisma.team.findUnique({ + const team = await index_1.prisma.team.findUnique({ where: { id: teamId } }); if (!team) { @@ -129,7 +146,7 @@ router.post('/:teamId/leave', auth_js_1.authenticate, async (req, res) => { if (team.captainId === req.user.id) { return res.status(400).json({ error: 'Captain cannot leave the team' }); } - await index_js_1.prisma.teamMember.deleteMany({ + await index_1.prisma.teamMember.deleteMany({ where: { teamId, userId: req.user.id @@ -142,14 +159,65 @@ router.post('/:teamId/leave', auth_js_1.authenticate, async (req, res) => { res.status(500).json({ error: 'Failed to leave team' }); } }); -router.post('/:teamId/advance', auth_js_1.authenticate, async (req, res) => { +router.post('/:teamId/assign-route', auth_1.authenticate, async (req, res) => { try { const { teamId } = req.params; - const team = await index_js_1.prisma.team.findUnique({ + const { routeId } = req.body; + const team = await index_1.prisma.team.findUnique({ + where: { id: teamId }, + include: { game: true, teamRoutes: true } + }); + if (!team) { + return res.status(404).json({ error: 'Team not found' }); + } + if (team.game.gameMasterId !== req.user.id) { + return res.status(403).json({ error: 'Not authorized' }); + } + const route = await index_1.prisma.route.findUnique({ + where: { id: routeId } + }); + if (!route || route.gameId !== team.gameId) { + return res.status(400).json({ error: 'Invalid route for this game' }); + } + await index_1.prisma.teamRoute.deleteMany({ + where: { teamId } + }); + const teamRoute = await index_1.prisma.teamRoute.create({ + data: { + teamId, + routeId + }, + include: { + route: { + include: { + routeLegs: { orderBy: { sequenceNumber: 'asc' } } + } + } + } + }); + res.json(teamRoute); + } + catch (error) { + console.error('Assign route error:', error); + res.status(500).json({ error: 'Failed to assign route' }); + } +}); +router.post('/:teamId/advance', auth_1.authenticate, async (req, res) => { + try { + const { teamId } = req.params; + const team = await index_1.prisma.team.findUnique({ where: { id: teamId }, include: { - game: { include: { legs: { orderBy: { sequenceNumber: 'asc' } } } }, - currentLeg: true + game: true, + teamRoutes: { + include: { + route: { + include: { + routeLegs: { orderBy: { sequenceNumber: 'asc' } } + } + } + } + } } }); if (!team) { @@ -158,27 +226,33 @@ router.post('/:teamId/advance', auth_js_1.authenticate, async (req, res) => { if (team.game.gameMasterId !== req.user.id) { return res.status(403).json({ error: 'Not authorized' }); } - const legs = team.game.legs; - const currentLeg = team.currentLeg; - let nextLeg = null; - if (currentLeg) { - const currentIndex = legs.findIndex((l) => l.id === currentLeg.id); - if (currentIndex < legs.length - 1) { - nextLeg = legs[currentIndex + 1]; - } + const teamRoute = team.teamRoutes[0]; + if (!teamRoute) { + return res.status(400).json({ error: 'Team has no assigned route' }); } - else if (legs.length > 0) { - nextLeg = legs[0]; + const legs = teamRoute.route.routeLegs; + const currentLegIndex = team.currentLegIndex; + let nextLegIndex = currentLegIndex; + if (currentLegIndex < legs.length - 1) { + nextLegIndex = currentLegIndex + 1; } - const updated = await index_js_1.prisma.team.update({ + const updated = await index_1.prisma.team.update({ where: { id: teamId }, data: { - currentLegId: nextLeg?.id || null, - status: nextLeg ? 'ACTIVE' : 'FINISHED' + currentLegIndex: nextLegIndex, + status: nextLegIndex >= legs.length - 1 ? 'FINISHED' : 'ACTIVE' }, include: { members: { include: { user: { select: { id: true, name: true } } } }, - currentLeg: true + teamRoutes: { + include: { + route: { + include: { + routeLegs: { orderBy: { sequenceNumber: 'asc' } } + } + } + } + } } }); res.json(updated); @@ -188,11 +262,11 @@ router.post('/:teamId/advance', auth_js_1.authenticate, async (req, res) => { res.status(500).json({ error: 'Failed to advance team' }); } }); -router.post('/:teamId/deduct', auth_js_1.authenticate, async (req, res) => { +router.post('/:teamId/deduct', auth_1.authenticate, async (req, res) => { try { const { teamId } = req.params; const { seconds } = req.body; - const team = await index_js_1.prisma.team.findUnique({ + const team = await index_1.prisma.team.findUnique({ where: { id: teamId }, include: { game: true } }); @@ -202,8 +276,8 @@ router.post('/:teamId/deduct', auth_js_1.authenticate, async (req, res) => { if (team.game.gameMasterId !== req.user.id) { return res.status(403).json({ error: 'Not authorized' }); } - const deduction = seconds || team.game.timeDeductionPenalty || 60; - const updated = await index_js_1.prisma.team.update({ + const deduction = seconds || 60; + const updated = await index_1.prisma.team.update({ where: { id: teamId }, data: { totalTimeDeduction: { increment: deduction } } }); @@ -214,10 +288,10 @@ router.post('/:teamId/deduct', auth_js_1.authenticate, async (req, res) => { res.status(500).json({ error: 'Failed to deduct time' }); } }); -router.post('/:teamId/disqualify', auth_js_1.authenticate, async (req, res) => { +router.post('/:teamId/disqualify', auth_1.authenticate, async (req, res) => { try { const { teamId } = req.params; - const team = await index_js_1.prisma.team.findUnique({ + const team = await index_1.prisma.team.findUnique({ where: { id: teamId }, include: { game: true } }); @@ -227,7 +301,7 @@ router.post('/:teamId/disqualify', auth_js_1.authenticate, async (req, res) => { if (team.game.gameMasterId !== req.user.id) { return res.status(403).json({ error: 'Not authorized' }); } - const updated = await index_js_1.prisma.team.update({ + const updated = await index_1.prisma.team.update({ where: { id: teamId }, data: { status: 'DISQUALIFIED' } }); @@ -238,23 +312,23 @@ router.post('/:teamId/disqualify', auth_js_1.authenticate, async (req, res) => { res.status(500).json({ error: 'Failed to disqualify team' }); } }); -router.post('/:teamId/location', auth_js_1.authenticate, async (req, res) => { +router.post('/:teamId/location', auth_1.authenticate, async (req, res) => { try { const { teamId } = req.params; const { lat, lng } = req.body; - const team = await index_js_1.prisma.team.findUnique({ + const team = await index_1.prisma.team.findUnique({ where: { id: teamId } }); if (!team) { return res.status(404).json({ error: 'Team not found' }); } - const member = await index_js_1.prisma.teamMember.findFirst({ + const member = await index_1.prisma.teamMember.findFirst({ where: { teamId, userId: req.user.id } }); if (!member && team.captainId !== req.user.id) { return res.status(403).json({ error: 'Not authorized' }); } - const updated = await index_js_1.prisma.team.update({ + const updated = await index_1.prisma.team.update({ where: { id: teamId }, data: { lat, lng } }); @@ -265,16 +339,24 @@ router.post('/:teamId/location', auth_js_1.authenticate, async (req, res) => { res.status(500).json({ error: 'Failed to update location' }); } }); -router.get('/:teamId', auth_js_1.authenticate, async (req, res) => { +router.get('/:teamId', auth_1.authenticate, async (req, res) => { try { const { teamId } = req.params; - const team = await index_js_1.prisma.team.findUnique({ + const team = await index_1.prisma.team.findUnique({ where: { id: teamId }, include: { members: { include: { user: { select: { id: true, name: true, email: true } } } }, captain: { select: { id: true, name: true } }, - currentLeg: true, - game: { include: { legs: { orderBy: { sequenceNumber: 'asc' } } } } + game: { include: { routes: { include: { routeLegs: { orderBy: { sequenceNumber: 'asc' } } } } } }, + teamRoutes: { + include: { + route: { + include: { + routeLegs: { orderBy: { sequenceNumber: 'asc' } } + } + } + } + } } }); if (!team) { diff --git a/backend/dist/routes/upload.js b/backend/dist/routes/upload.js index 164cb62..5251cdc 100644 --- a/backend/dist/routes/upload.js +++ b/backend/dist/routes/upload.js @@ -7,7 +7,7 @@ const express_1 = require("express"); const multer_1 = __importDefault(require("multer")); const path_1 = __importDefault(require("path")); const uuid_1 = require("uuid"); -const auth_js_1 = require("../middleware/auth.js"); +const auth_1 = require("../middleware/auth"); const router = (0, express_1.Router)(); const storage = multer_1.default.diskStorage({ destination: (req, file, cb) => { @@ -31,7 +31,7 @@ const upload = (0, multer_1.default)({ cb(new Error('Only image files are allowed')); } }); -router.post('/upload', auth_js_1.authenticate, upload.single('photo'), (req, res) => { +router.post('/upload', auth_1.authenticate, upload.single('photo'), (req, res) => { try { if (!req.file) { return res.status(400).json({ error: 'No file uploaded' }); diff --git a/backend/dist/routes/users.d.ts b/backend/dist/routes/users.d.ts new file mode 100644 index 0000000..ae2ab41 --- /dev/null +++ b/backend/dist/routes/users.d.ts @@ -0,0 +1,2 @@ +declare const router: import("express-serve-static-core").Router; +export default router; diff --git a/backend/dist/routes/users.js b/backend/dist/routes/users.js new file mode 100644 index 0000000..a7cfe62 --- /dev/null +++ b/backend/dist/routes/users.js @@ -0,0 +1,213 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const express_1 = require("express"); +const index_1 = require("../index"); +const auth_1 = require("../middleware/auth"); +const router = (0, express_1.Router)(); +router.get('/me', auth_1.authenticate, async (req, res) => { + try { + const user = await index_1.prisma.user.findUnique({ + where: { id: req.user.id }, + select: { + id: true, + email: true, + name: true, + screenName: true, + avatarUrl: true, + createdAt: true + } + }); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + res.json(user); + } + catch (error) { + console.error('Get user error:', error); + res.status(500).json({ error: 'Failed to get user' }); + } +}); +router.put('/me', auth_1.authenticate, async (req, res) => { + try { + const { name, screenName, avatarUrl } = req.body; + const updated = await index_1.prisma.user.update({ + where: { id: req.user.id }, + data: { + name: name || undefined, + screenName: screenName !== undefined ? screenName || null : undefined, + avatarUrl: avatarUrl !== undefined ? avatarUrl || null : undefined + }, + select: { + id: true, + email: true, + name: true, + screenName: true, + avatarUrl: true, + createdAt: true + } + }); + res.json(updated); + } + catch (error) { + console.error('Update user error:', error); + res.status(500).json({ error: 'Failed to update user' }); + } +}); +router.get('/me/location-history', auth_1.authenticate, async (req, res) => { + try { + const locations = await index_1.prisma.locationHistory.findMany({ + where: { userId: req.user.id }, + include: { + game: { + select: { id: true, name: true } + } + }, + orderBy: { recordedAt: 'desc' } + }); + const games = await index_1.prisma.game.findMany({ + where: { + teams: { + some: { + members: { + some: { userId: req.user.id } + } + } + } + }, + select: { id: true, name: true } + }); + const locationByGame = games.map(game => { + const gameLocations = locations.filter(l => l.gameId === game.id); + return { + game: game, + locations: gameLocations, + locationCount: gameLocations.length + }; + }).filter(g => g.locationCount > 0); + res.json({ + totalLocations: locations.length, + byGame: locationByGame + }); + } + catch (error) { + console.error('Get location history error:', error); + res.status(500).json({ error: 'Failed to get location history' }); + } +}); +router.get('/me/games', auth_1.authenticate, async (req, res) => { + try { + const memberships = await index_1.prisma.teamMember.findMany({ + where: { userId: req.user.id }, + include: { + team: { + include: { + game: { + select: { + id: true, + name: true, + status: true, + startDate: true, + locationLat: true, + locationLng: true, + gameMasterId: true, + gameMaster: { select: { name: true } } + } + }, + teamRoutes: { + include: { + route: { + include: { + routeLegs: { + orderBy: { sequenceNumber: 'asc' } + } + } + } + } + }, + photoSubmissions: true + } + } + } + }); + const gamesWithDetails = memberships.map(m => { + const team = m.team; + const game = team.game; + const teamRoute = team.teamRoutes[0]; + const route = teamRoute?.route; + const photoSubmissions = team.photoSubmissions; + const routeLegs = route?.routeLegs || []; + const proofLocations = routeLegs.filter(leg => photoSubmissions.some(p => p.routeLegId === leg.id)); + let totalDistance = 0; + if (game.locationLat && game.locationLng) { + let prevLat = game.locationLat; + let prevLng = game.locationLng; + for (const leg of routeLegs) { + if (leg.locationLat && leg.locationLng) { + const R = 6371; + const dLat = (leg.locationLat - prevLat) * Math.PI / 180; + const dLng = (leg.locationLng - prevLng) * Math.PI / 180; + const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(prevLat * Math.PI / 180) * Math.cos(leg.locationLat * Math.PI / 180) * + Math.sin(dLng / 2) * Math.sin(dLng / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + totalDistance += R * c; + prevLat = leg.locationLat; + prevLng = leg.locationLng; + } + } + } + return { + gameId: game.id, + gameName: game.name, + gameStatus: game.status, + gameMaster: game.gameMaster.name, + startDate: game.startDate, + teamId: team.id, + teamName: team.name, + teamStatus: team.status, + routeId: route?.id || null, + routeName: route?.name || null, + routeColor: route?.color || null, + totalLegs: routeLegs.length, + totalDistance: Math.round(totalDistance * 100) / 100, + proofLocations: proofLocations.map(leg => ({ + legNumber: leg.sequenceNumber, + description: leg.description, + locationLat: leg.locationLat, + locationLng: leg.locationLng, + hasPhotoProof: photoSubmissions.some(p => p.routeLegId === leg.id) + })) + }; + }); + res.json(gamesWithDetails); + } + catch (error) { + console.error('Get user games error:', error); + res.status(500).json({ error: 'Failed to get user games' }); + } +}); +router.delete('/me/location-data', auth_1.authenticate, async (req, res) => { + try { + await index_1.prisma.locationHistory.deleteMany({ + where: { userId: req.user.id } + }); + res.json({ message: 'Location data deleted' }); + } + catch (error) { + console.error('Delete location data error:', error); + res.status(500).json({ error: 'Failed to delete location data' }); + } +}); +router.delete('/me/account', auth_1.authenticate, async (req, res) => { + try { + await index_1.prisma.user.delete({ + where: { id: req.user.id } + }); + res.json({ message: 'Account deleted' }); + } + catch (error) { + console.error('Delete account error:', error); + res.status(500).json({ error: 'Failed to delete account' }); + } +}); +exports.default = router; diff --git a/backend/dist/socket/index.js b/backend/dist/socket/index.js index a7e7cf3..b0a5354 100644 --- a/backend/dist/socket/index.js +++ b/backend/dist/socket/index.js @@ -1,7 +1,7 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.default = setupSocket; -const index_js_1 = require("../index.js"); +const index_1 = require("../index"); function setupSocket(io) { io.on('connection', (socket) => { console.log('Client connected:', socket.id); @@ -13,7 +13,7 @@ function setupSocket(io) { socket.leave(`game:${gameId}`); }); socket.on('team-location', async (data) => { - await index_js_1.prisma.team.update({ + await index_1.prisma.team.update({ where: { id: data.teamId }, data: { lat: data.lat, lng: data.lng } }); @@ -24,7 +24,7 @@ function setupSocket(io) { }); }); socket.on('chat-message', async (data) => { - const chatMessage = await index_js_1.prisma.chatMessage.create({ + const chatMessage = await index_1.prisma.chatMessage.create({ data: { gameId: data.gameId, teamId: data.teamId, diff --git a/backend/package-lock.json b/backend/package-lock.json index fe63ddc..3d0058f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -23,7 +23,7 @@ "devDependencies": { "@types/bcryptjs": "^2.4.6", "@types/cors": "^2.8.17", - "@types/express": "^5.0.1", + "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.7", "@types/multer": "^1.4.12", "@types/node": "^22.15.21", @@ -215,21 +215,22 @@ } }, "node_modules/@types/express": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", - "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", - "@types/express-serve-static-core": "^5.0.0", - "@types/serve-static": "^2" + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" } }, "node_modules/@types/express-serve-static-core": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", - "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/backend/package.json b/backend/package.json index d3c88ae..f728e37 100644 --- a/backend/package.json +++ b/backend/package.json @@ -29,7 +29,7 @@ "devDependencies": { "@types/bcryptjs": "^2.4.6", "@types/cors": "^2.8.17", - "@types/express": "^5.0.1", + "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.7", "@types/multer": "^1.4.12", "@types/node": "^22.15.21", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 91ef566..82ffd47 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -12,40 +12,58 @@ model User { email String @unique passwordHash String name String + screenName String? + avatarUrl String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt games Game[] @relation("GameMaster") teams TeamMember[] captainOf Team? @relation("TeamCaptain") chatMessages ChatMessage[] + locationHistory LocationHistory[] } model Game { - id String @id @default(uuid()) - name String - description String? - prizeDetails String? - visibility Visibility @default(PUBLIC) - startDate DateTime? - locationLat Float? - locationLng Float? - searchRadius Float? - rules String? - status GameStatus @default(DRAFT) - inviteCode String? @unique - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - gameMasterId String - gameMaster User @relation("GameMaster", fields: [gameMasterId], references: [id]) - legs Leg[] - teams Team[] - chatMessages ChatMessage[] + id String @id @default(uuid()) + name String + description String? + prizeDetails String? + visibility Visibility @default(PUBLIC) + startDate DateTime? + locationLat Float? + locationLng Float? + searchRadius Float? + rules String? + randomizeRoutes Boolean @default(false) + status GameStatus @default(DRAFT) + inviteCode String? @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + gameMasterId String + gameMaster User @relation("GameMaster", fields: [gameMasterId], references: [id]) + routes Route[] + teams Team[] + chatMessages ChatMessage[] + locationHistory LocationHistory[] } -model Leg { +model Route { + id String @id @default(uuid()) + gameId String + game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) + name String + description String? + color String @default("#3498db") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + routeLegs RouteLeg[] + teamRoutes TeamRoute[] +} + +model RouteLeg { id String @id @default(uuid()) - gameId String - game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) + routeId String + route Route @relation(fields: [routeId], references: [id], onDelete: Cascade) sequenceNumber Int description String conditionType String @default("photo") @@ -53,7 +71,6 @@ model Leg { locationLat Float? locationLng Float? timeLimit Int? - teams Team[] } model Team { @@ -61,19 +78,30 @@ model Team { gameId String game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) name String - captainId String? @unique - captain User? @relation("TeamCaptain", fields: [captainId], references: [id]) - currentLegId String? - currentLeg Leg? @relation(fields: [currentLegId], references: [id]) - status TeamStatus @default(ACTIVE) - totalTimeDeduction Int @default(0) + captainId String? @unique + captain User? @relation("TeamCaptain", fields: [captainId], references: [id]) + currentLegIndex Int @default(0) + status TeamStatus @default(ACTIVE) + totalTimeDeduction Int @default(0) lat Float? lng Float? rank Int? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) members TeamMember[] photoSubmissions PhotoSubmission[] chatMessages ChatMessage[] + teamRoutes TeamRoute[] +} + +model TeamRoute { + id String @id @default(uuid()) + teamId String + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + routeId String + route Route @relation(fields: [routeId], references: [id]) + assignedAt DateTime @default(now()) + + @@unique([teamId]) } model TeamMember { @@ -91,7 +119,8 @@ model PhotoSubmission { id String @id @default(uuid()) teamId String team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) - legId String + routeId String + routeLegId String photoUrl String approved Boolean @default(false) submittedAt DateTime @default(now()) @@ -109,6 +138,19 @@ model ChatMessage { sentAt DateTime @default(now()) } +model LocationHistory { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + gameId String + game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) + teamId String + lat Float + lng Float + accuracy Float? + recordedAt DateTime @default(now()) +} + enum Visibility { PUBLIC PRIVATE diff --git a/backend/src/index.ts b/backend/src/index.ts index f29b269..0951590 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -6,7 +6,8 @@ import { PrismaClient } from '@prisma/client'; import authRoutes from './routes/auth'; import gameRoutes from './routes/games'; import teamRoutes from './routes/teams'; -import legRoutes from './routes/legs'; +import routeRoutes from './routes/routes'; +import userRoutes from './routes/users'; import uploadRoutes from './routes/upload'; import setupSocket from './socket/index'; @@ -28,7 +29,8 @@ app.use('/uploads', express.static('uploads')); app.use('/api/auth', authRoutes); app.use('/api/games', gameRoutes); app.use('/api/teams', teamRoutes); -app.use('/api/legs', legRoutes); +app.use('/api/routes', routeRoutes); +app.use('/api/users', userRoutes); app.use('/api/upload', uploadRoutes); app.get('/api/health', (req, res) => { diff --git a/backend/src/routes/games.ts b/backend/src/routes/games.ts index 19d4acf..0ee4b0b 100644 --- a/backend/src/routes/games.ts +++ b/backend/src/routes/games.ts @@ -25,7 +25,7 @@ router.get('/', async (req: AuthRequest, res: Response) => { where, include: { gameMaster: { select: { id: true, name: true } }, - _count: { select: { teams: true, legs: true } } + _count: { select: { teams: true, routes: true } } }, orderBy: { createdAt: 'desc' } }); @@ -42,7 +42,7 @@ router.get('/my-games', authenticate, async (req: AuthRequest, res: Response) => const games = await prisma.game.findMany({ where: { gameMasterId: req.user!.id }, include: { - _count: { select: { teams: true, legs: true } } + _count: { select: { teams: true, routes: true } } }, orderBy: { createdAt: 'desc' } }); @@ -62,11 +62,23 @@ router.get('/:id', async (req: AuthRequest, res: Response) => { where: { id: id as string }, include: { gameMaster: { select: { id: true, name: true } }, - legs: { orderBy: { sequenceNumber: 'asc' } }, + routes: { + include: { + routeLegs: { orderBy: { sequenceNumber: 'asc' } } + } + }, teams: { include: { members: { include: { user: { select: { id: true, name: true, email: true } } } }, - currentLeg: true + teamRoutes: { + include: { + route: { + include: { + routeLegs: { orderBy: { sequenceNumber: 'asc' } } + } + } + } + } } } } @@ -98,7 +110,7 @@ router.post('/', authenticate, async (req: AuthRequest, res: Response) => { try { const { name, description, prizeDetails, visibility, startDate, - locationLat, locationLng, searchRadius, rules + locationLat, locationLng, searchRadius, rules, randomizeRoutes } = req.body; if (!name) { @@ -122,6 +134,7 @@ router.post('/', authenticate, async (req: AuthRequest, res: Response) => { locationLng, searchRadius, rules: rules ? JSON.stringify(rules) : null, + randomizeRoutes: randomizeRoutes || false, gameMasterId: req.user!.id, inviteCode } @@ -139,7 +152,7 @@ router.put('/:id', authenticate, async (req: AuthRequest, res: Response) => { const id = req.params.id as string; const { name, description, prizeDetails, visibility, startDate, - locationLat, locationLng, searchRadius, rules, status + locationLat, locationLng, searchRadius, rules, status, randomizeRoutes } = req.body; const game = await prisma.game.findUnique({ where: { id } }); @@ -163,6 +176,7 @@ router.put('/:id', authenticate, async (req: AuthRequest, res: Response) => { locationLng, searchRadius, rules: rules ? JSON.stringify(rules) : undefined, + randomizeRoutes, status } }); @@ -202,8 +216,8 @@ router.post('/:id/publish', authenticate, async (req: AuthRequest, res: Response const game = await prisma.game.findUnique({ where: { id: id as string }, - include: { legs: true } - }) as any; + include: { routes: { include: { routeLegs: true } } } + }); if (!game) { return res.status(404).json({ error: 'Game not found' }); @@ -213,8 +227,35 @@ router.post('/:id/publish', authenticate, async (req: AuthRequest, res: Response return res.status(403).json({ error: 'Not authorized' }); } - if (!game.legs || game.legs.length === 0) { - return res.status(400).json({ error: 'Game must have at least one leg' }); + if (!game.routes || game.routes.length === 0) { + return res.status(400).json({ error: 'Game must have at least one route' }); + } + + const hasValidRoute = game.routes.some(route => route.routeLegs.length > 0); + if (!hasValidRoute) { + return res.status(400).json({ error: 'At least one route must have legs' }); + } + + if (game.randomizeRoutes) { + const teams = await prisma.team.findMany({ + where: { gameId: id as string } + }); + + const shuffledRoutes = [...game.routes].sort(() => Math.random() - 0.5); + + for (let i = 0; i < teams.length; i++) { + const routeIndex = i % game.routes.length; + await prisma.teamRoute.upsert({ + where: { teamId: teams[i].id }, + create: { + teamId: teams[i].id, + routeId: shuffledRoutes[routeIndex].id + }, + update: { + routeId: shuffledRoutes[routeIndex].id + } + }); + } } const updated = await prisma.game.update({ diff --git a/backend/src/routes/legs.ts b/backend/src/routes/legs.ts deleted file mode 100644 index 17cd71d..0000000 --- a/backend/src/routes/legs.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { Router, Response } from 'express'; -import { prisma } from '../index'; -import { authenticate, AuthRequest } from '../middleware/auth'; - -const router = Router(); - -router.get('/game/:gameId', authenticate, async (req: AuthRequest, res: Response) => { - try { - const { gameId } = req.params; - - const game = await prisma.game.findUnique({ - where: { id: gameId }, - include: { legs: { orderBy: { sequenceNumber: 'asc' } } } - }); - - if (!game) { - return res.status(404).json({ error: 'Game not found' }); - } - - if (game.gameMasterId !== req.user!.id) { - return res.status(403).json({ error: 'Not authorized' }); - } - - res.json(game.legs); - } catch (error) { - console.error('Get legs error:', error); - res.status(500).json({ error: 'Failed to get legs' }); - } -}); - -router.post('/game/:gameId', authenticate, async (req: AuthRequest, res: Response) => { - try { - const { gameId } = req.params; - const { description, conditionType, conditionDetails, locationLat, locationLng, timeLimit } = req.body; - - const game = await prisma.game.findUnique({ - where: { id: gameId }, - include: { legs: true } - }); - - if (!game) { - return res.status(404).json({ error: 'Game not found' }); - } - - if (game.gameMasterId !== req.user!.id) { - return res.status(403).json({ error: 'Not authorized' }); - } - - const maxSequence = game.legs.reduce((max: number, leg: { sequenceNumber: number }) => Math.max(max, leg.sequenceNumber), 0); - - const leg = await prisma.leg.create({ - data: { - gameId, - sequenceNumber: maxSequence + 1, - description, - conditionType: conditionType || 'photo', - conditionDetails, - locationLat, - locationLng, - timeLimit - } - }); - - res.json(leg); - } catch (error) { - console.error('Create leg error:', error); - res.status(500).json({ error: 'Failed to create leg' }); - } -}); - -router.put('/:legId', authenticate, async (req: AuthRequest, res: Response) => { - try { - const { legId } = req.params; - const { description, conditionType, conditionDetails, locationLat, locationLng, timeLimit } = req.body; - - const leg = await prisma.leg.findUnique({ - where: { id: legId }, - include: { game: true } - }); - - if (!leg) { - return res.status(404).json({ error: 'Leg not found' }); - } - - if (leg.game.gameMasterId !== req.user!.id) { - return res.status(403).json({ error: 'Not authorized' }); - } - - const updated = await prisma.leg.update({ - where: { id: legId }, - data: { - description, - conditionType, - conditionDetails, - locationLat, - locationLng, - timeLimit - } - }); - - res.json(updated); - } catch (error) { - console.error('Update leg error:', error); - res.status(500).json({ error: 'Failed to update leg' }); - } -}); - -router.delete('/:legId', authenticate, async (req: AuthRequest, res: Response) => { - try { - const { legId } = req.params; - - const leg = await prisma.leg.findUnique({ - where: { id: legId }, - include: { game: true } - }); - - if (!leg) { - return res.status(404).json({ error: 'Leg not found' }); - } - - if (leg.game.gameMasterId !== req.user!.id) { - return res.status(403).json({ error: 'Not authorized' }); - } - - await prisma.leg.delete({ where: { id: legId } }); - - res.json({ message: 'Leg deleted' }); - } catch (error) { - console.error('Delete leg error:', error); - res.status(500).json({ error: 'Failed to delete leg' }); - } -}); - -router.post('/:legId/photo', authenticate, async (req: AuthRequest, res: Response) => { - try { - const { legId } = req.params; - const { teamId, photoUrl } = req.body; - - if (!teamId || !photoUrl) { - return res.status(400).json({ error: 'Team ID and photo URL are required' }); - } - - const leg = await prisma.leg.findUnique({ - where: { id: legId }, - include: { game: true } - }); - - if (!leg) { - return res.status(404).json({ error: 'Leg not found' }); - } - - const team = await prisma.team.findUnique({ - where: { id: teamId } - }); - - if (!team || team.gameId !== leg.gameId) { - return res.status(403).json({ error: 'Team not in this game' }); - } - - const submission = await prisma.photoSubmission.create({ - data: { - teamId, - legId, - photoUrl - } - }); - - res.json(submission); - } catch (error) { - console.error('Submit photo error:', error); - res.status(500).json({ error: 'Failed to submit photo' }); - } -}); - -export default router; diff --git a/backend/src/routes/routes.ts b/backend/src/routes/routes.ts new file mode 100644 index 0000000..7435af9 --- /dev/null +++ b/backend/src/routes/routes.ts @@ -0,0 +1,398 @@ +import { Router, Response } from 'express'; +import { prisma } from '../index'; +import { authenticate, AuthRequest } from '../middleware/auth'; + +const router = Router(); + +router.get('/game/:gameId', async (req: AuthRequest, res: Response) => { + try { + const { gameId } = req.params; + + const routes = await prisma.route.findMany({ + where: { gameId: gameId as string }, + include: { + routeLegs: { + orderBy: { sequenceNumber: 'asc' } + }, + _count: { select: { teamRoutes: true } } + }, + orderBy: { createdAt: 'asc' } + }); + + res.json(routes); + } catch (error) { + console.error('List routes error:', error); + res.status(500).json({ error: 'Failed to list routes' }); + } +}); + +router.get('/:id', async (req: AuthRequest, res: Response) => { + try { + const { id } = req.params; + + const route = await prisma.route.findUnique({ + where: { id: id as string }, + include: { + routeLegs: { + orderBy: { sequenceNumber: 'asc' } + }, + teamRoutes: { + include: { + team: { + include: { + members: { include: { user: { select: { id: true, name: true } } } } + } + } + } + } + } + }); + + if (!route) { + return res.status(404).json({ error: 'Route not found' }); + } + + res.json(route); + } catch (error) { + console.error('Get route error:', error); + res.status(500).json({ error: 'Failed to get route' }); + } +}); + +router.post('/', authenticate, async (req: AuthRequest, res: Response) => { + try { + const { gameId, name, description, color } = req.body; + + if (!gameId || !name) { + return res.status(400).json({ error: 'Game ID and name are required' }); + } + + const game = await prisma.game.findUnique({ where: { id: gameId } }); + if (!game) { + return res.status(404).json({ error: 'Game not found' }); + } + + if (game.gameMasterId !== req.user!.id) { + return res.status(403).json({ error: 'Not authorized' }); + } + + const route = await prisma.route.create({ + data: { + gameId, + name, + description, + color: color || '#3498db' + }, + include: { + routeLegs: true + } + }); + + res.json(route); + } catch (error) { + console.error('Create route error:', error); + res.status(500).json({ error: 'Failed to create route' }); + } +}); + +router.put('/:id', authenticate, async (req: AuthRequest, res: Response) => { + try { + const { id } = req.params; + const { name, description, color } = req.body; + + const route = await prisma.route.findUnique({ + where: { id: id as string }, + include: { game: true } + }); + + if (!route) { + return res.status(404).json({ error: 'Route not found' }); + } + + if (route.game.gameMasterId !== req.user!.id) { + return res.status(403).json({ error: 'Not authorized' }); + } + + const updated = await prisma.route.update({ + where: { id: id as string }, + data: { + name: name || route.name, + description: description !== undefined ? description : route.description, + color: color || route.color + }, + include: { + routeLegs: { + orderBy: { sequenceNumber: 'asc' } + } + } + }); + + res.json(updated); + } catch (error) { + console.error('Update route error:', error); + res.status(500).json({ error: 'Failed to update route' }); + } +}); + +router.delete('/:id', authenticate, async (req: AuthRequest, res: Response) => { + try { + const { id } = req.params; + + const route = await prisma.route.findUnique({ + where: { id: id as string }, + include: { game: true } + }); + + if (!route) { + return res.status(404).json({ error: 'Route not found' }); + } + + if (route.game.gameMasterId !== req.user!.id) { + return res.status(403).json({ error: 'Not authorized' }); + } + + await prisma.route.delete({ where: { id: id as string } }); + + res.json({ message: 'Route deleted' }); + } catch (error) { + console.error('Delete route error:', error); + res.status(500).json({ error: 'Failed to delete route' }); + } +}); + +router.post('/:id/legs', authenticate, async (req: AuthRequest, res: Response) => { + try { + const { id } = req.params; + const { + description, conditionType, conditionDetails, + locationLat, locationLng, timeLimit + } = req.body; + + const route = await prisma.route.findUnique({ + where: { id: id as string }, + include: { + game: true, + routeLegs: true + } + }); + + if (!route) { + return res.status(404).json({ error: 'Route not found' }); + } + + if (route.game.gameMasterId !== req.user!.id) { + return res.status(403).json({ error: 'Not authorized' }); + } + + const maxSequence = route.routeLegs.length > 0 + ? Math.max(...route.routeLegs.map(l => l.sequenceNumber)) + : 0; + + const routeLeg = await prisma.routeLeg.create({ + data: { + routeId: id as string, + sequenceNumber: maxSequence + 1, + description: description || '', + conditionType: conditionType || 'photo', + conditionDetails, + locationLat, + locationLng, + timeLimit + } + }); + + res.json(routeLeg); + } catch (error) { + console.error('Add route leg error:', error); + res.status(500).json({ error: 'Failed to add leg to route' }); + } +}); + +router.put('/:id/legs/:legId', authenticate, async (req: AuthRequest, res: Response) => { + try { + const { id, legId } = req.params; + const { + description, conditionType, conditionDetails, + locationLat, locationLng, timeLimit, sequenceNumber + } = req.body; + + const route = await prisma.route.findUnique({ + where: { id: id as string }, + include: { game: true } + }); + + if (!route) { + return res.status(404).json({ error: 'Route not found' }); + } + + if (route.game.gameMasterId !== req.user!.id) { + return res.status(403).json({ error: 'Not authorized' }); + } + + const routeLeg = await prisma.routeLeg.update({ + where: { id: legId as string }, + data: { + description: description !== undefined ? description : undefined, + conditionType: conditionType !== undefined ? conditionType : undefined, + conditionDetails: conditionDetails !== undefined ? conditionDetails : undefined, + locationLat: locationLat !== undefined ? locationLat : undefined, + locationLng: locationLng !== undefined ? locationLng : undefined, + timeLimit: timeLimit !== undefined ? timeLimit : undefined, + sequenceNumber: sequenceNumber !== undefined ? sequenceNumber : undefined + } + }); + + res.json(routeLeg); + } catch (error) { + console.error('Update route leg error:', error); + res.status(500).json({ error: 'Failed to update route leg' }); + } +}); + +router.delete('/:id/legs/:legId', authenticate, async (req: AuthRequest, res: Response) => { + try { + const { id, legId } = req.params; + + const route = await prisma.route.findUnique({ + where: { id: id as string }, + include: { game: true } + }); + + if (!route) { + return res.status(404).json({ error: 'Route not found' }); + } + + if (route.game.gameMasterId !== req.user!.id) { + return res.status(403).json({ error: 'Not authorized' }); + } + + await prisma.routeLeg.delete({ where: { id: legId as string } }); + + const remainingLegs = await prisma.routeLeg.findMany({ + where: { routeId: id as string }, + orderBy: { sequenceNumber: 'asc' } + }); + + for (let i = 0; i < remainingLegs.length; i++) { + if (remainingLegs[i].sequenceNumber !== i + 1) { + await prisma.routeLeg.update({ + where: { id: remainingLegs[i].id }, + data: { sequenceNumber: i + 1 } + }); + } + } + + res.json({ message: 'Leg deleted' }); + } catch (error) { + console.error('Delete route leg error:', error); + res.status(500).json({ error: 'Failed to delete route leg' }); + } +}); + +router.post('/:id/legs/:legId/photo', authenticate, async (req: AuthRequest, res: Response) => { + try { + const { id, legId } = req.params; + const { teamId, photoUrl } = req.body; + + const route = await prisma.route.findUnique({ + where: { id: id as string }, + include: { game: true } + }); + + if (!route) { + return res.status(404).json({ error: 'Route not found' }); + } + + const routeLeg = await prisma.routeLeg.findFirst({ + where: { id: legId as string, routeId: id as string } + }); + + if (!routeLeg) { + return res.status(404).json({ error: 'Route leg not found' }); + } + + const member = await prisma.teamMember.findFirst({ + where: { teamId, userId: req.user!.id } + }); + + const team = await prisma.team.findUnique({ where: { id: teamId } }); + + if (!member && team?.captainId !== req.user!.id) { + return res.status(403).json({ error: 'Not authorized' }); + } + + const submission = await prisma.photoSubmission.create({ + data: { + teamId, + routeId: id as string, + routeLegId: legId as string, + photoUrl + } + }); + + res.json(submission); + } catch (error) { + console.error('Photo submission error:', error); + res.status(500).json({ error: 'Failed to submit photo' }); + } +}); + +router.post('/:id/copy', authenticate, async (req: AuthRequest, res: Response) => { + try { + const { id } = req.params; + + const originalRoute = await prisma.route.findUnique({ + where: { id: id as string }, + include: { + game: true, + routeLegs: { orderBy: { sequenceNumber: 'asc' } } + } + }); + + if (!originalRoute) { + return res.status(404).json({ error: 'Route not found' }); + } + + if (originalRoute.game.gameMasterId !== req.user!.id) { + return res.status(403).json({ error: 'Not authorized' }); + } + + const newRoute = await prisma.route.create({ + data: { + gameId: originalRoute.gameId, + name: `${originalRoute.name} (Copy)`, + description: originalRoute.description, + color: originalRoute.color + } + }); + + for (const leg of originalRoute.routeLegs) { + await prisma.routeLeg.create({ + data: { + routeId: newRoute.id, + sequenceNumber: leg.sequenceNumber, + description: leg.description, + conditionType: leg.conditionType, + conditionDetails: leg.conditionDetails, + locationLat: leg.locationLat, + locationLng: leg.locationLng, + timeLimit: leg.timeLimit + } + }); + } + + const fullRoute = await prisma.route.findUnique({ + where: { id: newRoute.id }, + include: { + routeLegs: { orderBy: { sequenceNumber: 'asc' } } + } + }); + + res.json(fullRoute); + } catch (error) { + console.error('Copy route error:', error); + res.status(500).json({ error: 'Failed to copy route' }); + } +}); + +export default router; diff --git a/backend/src/routes/teams.ts b/backend/src/routes/teams.ts index 73bb16e..e5bf16b 100644 --- a/backend/src/routes/teams.ts +++ b/backend/src/routes/teams.ts @@ -13,7 +13,15 @@ router.get('/game/:gameId', async (req: AuthRequest, res: Response) => { include: { members: { include: { user: { select: { id: true, name: true, email: true } } } }, captain: { select: { id: true, name: true } }, - currentLeg: true + teamRoutes: { + include: { + route: { + include: { + routeLegs: { orderBy: { sequenceNumber: 'asc' } } + } + } + } + } }, orderBy: { createdAt: 'asc' } }); @@ -73,7 +81,16 @@ router.post('/game/:gameId', authenticate, async (req: AuthRequest, res: Respons where: { id: team.id }, include: { members: { include: { user: { select: { id: true, name: true, email: true } } } }, - captain: { select: { id: true, name: true } } + captain: { select: { id: true, name: true } }, + teamRoutes: { + include: { + route: { + include: { + routeLegs: { orderBy: { sequenceNumber: 'asc' } } + } + } + } + } } }); @@ -167,6 +184,57 @@ router.post('/:teamId/leave', authenticate, async (req: AuthRequest, res: Respon } }); +router.post('/:teamId/assign-route', authenticate, async (req: AuthRequest, res: Response) => { + try { + const { teamId } = req.params; + const { routeId } = req.body; + + const team = await prisma.team.findUnique({ + where: { id: teamId }, + include: { game: true, teamRoutes: true } + }); + + if (!team) { + return res.status(404).json({ error: 'Team not found' }); + } + + if (team.game.gameMasterId !== req.user!.id) { + return res.status(403).json({ error: 'Not authorized' }); + } + + const route = await prisma.route.findUnique({ + where: { id: routeId } + }); + + if (!route || route.gameId !== team.gameId) { + return res.status(400).json({ error: 'Invalid route for this game' }); + } + + await prisma.teamRoute.deleteMany({ + where: { teamId } + }); + + const teamRoute = await prisma.teamRoute.create({ + data: { + teamId, + routeId + }, + include: { + route: { + include: { + routeLegs: { orderBy: { sequenceNumber: 'asc' } } + } + } + } + }); + + res.json(teamRoute); + } catch (error) { + console.error('Assign route error:', error); + res.status(500).json({ error: 'Failed to assign route' }); + } +}); + router.post('/:teamId/advance', authenticate, async (req: AuthRequest, res: Response) => { try { const { teamId } = req.params; @@ -174,8 +242,16 @@ router.post('/:teamId/advance', authenticate, async (req: AuthRequest, res: Resp const team = await prisma.team.findUnique({ where: { id: teamId }, include: { - game: { include: { legs: { orderBy: { sequenceNumber: 'asc' } } } }, - currentLeg: true + game: true, + teamRoutes: { + include: { + route: { + include: { + routeLegs: { orderBy: { sequenceNumber: 'asc' } } + } + } + } + } } }); @@ -187,28 +263,36 @@ router.post('/:teamId/advance', authenticate, async (req: AuthRequest, res: Resp return res.status(403).json({ error: 'Not authorized' }); } - const legs = team.game.legs; - const currentLeg = team.currentLeg; + const teamRoute = team.teamRoutes[0]; + if (!teamRoute) { + return res.status(400).json({ error: 'Team has no assigned route' }); + } + + const legs = teamRoute.route.routeLegs; + const currentLegIndex = team.currentLegIndex; - let nextLeg = null; - if (currentLeg) { - const currentIndex = legs.findIndex((l: { id: string }) => l.id === currentLeg.id); - if (currentIndex < legs.length - 1) { - nextLeg = legs[currentIndex + 1]; - } - } else if (legs.length > 0) { - nextLeg = legs[0]; + let nextLegIndex = currentLegIndex; + if (currentLegIndex < legs.length - 1) { + nextLegIndex = currentLegIndex + 1; } const updated = await prisma.team.update({ where: { id: teamId }, data: { - currentLegId: nextLeg?.id || null, - status: nextLeg ? 'ACTIVE' : 'FINISHED' + currentLegIndex: nextLegIndex, + status: nextLegIndex >= legs.length - 1 ? 'FINISHED' : 'ACTIVE' }, include: { members: { include: { user: { select: { id: true, name: true } } } }, - currentLeg: true + teamRoutes: { + include: { + route: { + include: { + routeLegs: { orderBy: { sequenceNumber: 'asc' } } + } + } + } + } } }); @@ -237,7 +321,7 @@ router.post('/:teamId/deduct', authenticate, async (req: AuthRequest, res: Respo return res.status(403).json({ error: 'Not authorized' }); } - const deduction = seconds || team.game.timeDeductionPenalty || 60; + const deduction = seconds || 60; const updated = await prisma.team.update({ where: { id: teamId }, @@ -322,8 +406,16 @@ router.get('/:teamId', authenticate, async (req: AuthRequest, res: Response) => include: { members: { include: { user: { select: { id: true, name: true, email: true } } } }, captain: { select: { id: true, name: true } }, - currentLeg: true, - game: { include: { legs: { orderBy: { sequenceNumber: 'asc' } } } } + game: { include: { routes: { include: { routeLegs: { orderBy: { sequenceNumber: 'asc' } } } } } }, + teamRoutes: { + include: { + route: { + include: { + routeLegs: { orderBy: { sequenceNumber: 'asc' } } + } + } + } + } } }); diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts new file mode 100644 index 0000000..6684296 --- /dev/null +++ b/backend/src/routes/users.ts @@ -0,0 +1,229 @@ +import { Router, Response } from 'express'; +import { prisma } from '../index'; +import { authenticate, AuthRequest } from '../middleware/auth'; + +const router = Router(); + +router.get('/me', authenticate, async (req: AuthRequest, res: Response) => { + try { + const user = await prisma.user.findUnique({ + where: { id: req.user!.id }, + select: { + id: true, + email: true, + name: true, + screenName: true, + avatarUrl: true, + createdAt: true + } + }); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + res.json(user); + } catch (error) { + console.error('Get user error:', error); + res.status(500).json({ error: 'Failed to get user' }); + } +}); + +router.put('/me', authenticate, async (req: AuthRequest, res: Response) => { + try { + const { name, screenName, avatarUrl } = req.body; + + const updated = await prisma.user.update({ + where: { id: req.user!.id }, + data: { + name: name || undefined, + screenName: screenName !== undefined ? screenName || null : undefined, + avatarUrl: avatarUrl !== undefined ? avatarUrl || null : undefined + }, + select: { + id: true, + email: true, + name: true, + screenName: true, + avatarUrl: true, + createdAt: true + } + }); + + res.json(updated); + } catch (error) { + console.error('Update user error:', error); + res.status(500).json({ error: 'Failed to update user' }); + } +}); + +router.get('/me/location-history', authenticate, async (req: AuthRequest, res: Response) => { + try { + const locations = await prisma.locationHistory.findMany({ + where: { userId: req.user!.id }, + include: { + game: { + select: { id: true, name: true } + } + }, + orderBy: { recordedAt: 'desc' } + }); + + const games = await prisma.game.findMany({ + where: { + teams: { + some: { + members: { + some: { userId: req.user!.id } + } + } + } + }, + select: { id: true, name: true } + }); + + const locationByGame = games.map(game => { + const gameLocations = locations.filter(l => l.gameId === game.id); + return { + game: game, + locations: gameLocations, + locationCount: gameLocations.length + }; + }).filter(g => g.locationCount > 0); + + res.json({ + totalLocations: locations.length, + byGame: locationByGame + }); + } catch (error) { + console.error('Get location history error:', error); + res.status(500).json({ error: 'Failed to get location history' }); + } +}); + +router.get('/me/games', authenticate, async (req: AuthRequest, res: Response) => { + try { + const memberships = await prisma.teamMember.findMany({ + where: { userId: req.user!.id }, + include: { + team: { + include: { + game: { + select: { + id: true, + name: true, + status: true, + startDate: true, + locationLat: true, + locationLng: true, + gameMasterId: true, + gameMaster: { select: { name: true } } + } + }, + teamRoutes: { + include: { + route: { + include: { + routeLegs: { + orderBy: { sequenceNumber: 'asc' } + } + } + } + } + }, + photoSubmissions: true + } + } + } + }); + + const gamesWithDetails = memberships.map(m => { + const team = m.team; + const game = team.game; + const teamRoute = team.teamRoutes[0]; + const route = teamRoute?.route; + const photoSubmissions = team.photoSubmissions; + + const routeLegs = route?.routeLegs || []; + const proofLocations = routeLegs.filter(leg => + photoSubmissions.some(p => p.routeLegId === leg.id) + ); + + let totalDistance = 0; + if (game.locationLat && game.locationLng) { + let prevLat = game.locationLat; + let prevLng = game.locationLng; + for (const leg of routeLegs) { + if (leg.locationLat && leg.locationLng) { + const R = 6371; + const dLat = (leg.locationLat - prevLat) * Math.PI / 180; + const dLng = (leg.locationLng - prevLng) * Math.PI / 180; + const a = Math.sin(dLat/2) * Math.sin(dLat/2) + + Math.cos(prevLat * Math.PI / 180) * Math.cos(leg.locationLat * Math.PI / 180) * + Math.sin(dLng/2) * Math.sin(dLng/2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); + totalDistance += R * c; + prevLat = leg.locationLat; + prevLng = leg.locationLng; + } + } + } + + return { + gameId: game.id, + gameName: game.name, + gameStatus: game.status, + gameMaster: game.gameMaster.name, + startDate: game.startDate, + teamId: team.id, + teamName: team.name, + teamStatus: team.status, + routeId: route?.id || null, + routeName: route?.name || null, + routeColor: route?.color || null, + totalLegs: routeLegs.length, + totalDistance: Math.round(totalDistance * 100) / 100, + proofLocations: proofLocations.map(leg => ({ + legNumber: leg.sequenceNumber, + description: leg.description, + locationLat: leg.locationLat, + locationLng: leg.locationLng, + hasPhotoProof: photoSubmissions.some(p => p.routeLegId === leg.id) + })) + }; + }); + + res.json(gamesWithDetails); + } catch (error) { + console.error('Get user games error:', error); + res.status(500).json({ error: 'Failed to get user games' }); + } +}); + +router.delete('/me/location-data', authenticate, async (req: AuthRequest, res: Response) => { + try { + await prisma.locationHistory.deleteMany({ + where: { userId: req.user!.id } + }); + + res.json({ message: 'Location data deleted' }); + } catch (error) { + console.error('Delete location data error:', error); + res.status(500).json({ error: 'Failed to delete location data' }); + } +}); + +router.delete('/me/account', authenticate, async (req: AuthRequest, res: Response) => { + try { + await prisma.user.delete({ + where: { id: req.user!.id } + }); + + res.json({ message: 'Account deleted' }); + } catch (error) { + console.error('Delete account error:', error); + res.status(500).json({ error: 'Failed to delete account' }); + } +}); + +export default router; diff --git a/frontend/src/components/NavBar.vue b/frontend/src/components/NavBar.vue index e0c6e59..dad780e 100644 --- a/frontend/src/components/NavBar.vue +++ b/frontend/src/components/NavBar.vue @@ -32,6 +32,7 @@ function closeNav() {