Initial commit
This commit is contained in:
commit
b3a51a4115
10336 changed files with 2381973 additions and 0 deletions
4
backend/.env.example
Normal file
4
backend/.env.example
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# For local development
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/treasure_trails?schema=public"
|
||||
JWT_SECRET="treasure-trails-jwt-secret-change-in-production"
|
||||
PORT=3001
|
||||
5
backend/.gitignore
vendored
Normal file
5
backend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
# Keep environment variables out of version control
|
||||
.env
|
||||
|
||||
/generated/prisma
|
||||
17
backend/Dockerfile
Normal file
17
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
FROM node:20-bookworm
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY prisma ./prisma
|
||||
RUN npx prisma generate
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["sh", "-c", "npx prisma db push && npm run dev"]
|
||||
2
backend/dist/index.d.ts
vendored
Normal file
2
backend/dist/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
export declare const prisma: PrismaClient<import(".prisma/client").Prisma.PrismaClientOptions, never, import("@prisma/client/runtime/library").DefaultArgs>;
|
||||
46
backend/dist/index.js
vendored
Normal file
46
backend/dist/index.js
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.prisma = void 0;
|
||||
const express_1 = __importDefault(require("express"));
|
||||
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 app = (0, express_1.default)();
|
||||
const httpServer = (0, http_1.createServer)(app);
|
||||
const io = new socket_io_1.Server(httpServer, {
|
||||
cors: {
|
||||
origin: ['http://localhost:5173', 'http://localhost:3000'],
|
||||
methods: ['GET', 'POST']
|
||||
}
|
||||
});
|
||||
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.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
(0, index_js_1.default)(io);
|
||||
const PORT = process.env.PORT || 3001;
|
||||
httpServer.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
process.on('SIGINT', async () => {
|
||||
await exports.prisma.$disconnect();
|
||||
process.exit();
|
||||
});
|
||||
10
backend/dist/middleware/auth.d.ts
vendored
Normal file
10
backend/dist/middleware/auth.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Request, Response, NextFunction } from 'express';
|
||||
export interface AuthRequest extends Request {
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
export declare const authenticate: (req: AuthRequest, res: Response, next: NextFunction) => Promise<Response<any, Record<string, any>>>;
|
||||
export declare const optionalAuth: (req: AuthRequest, res: Response, next: NextFunction) => Promise<void>;
|
||||
54
backend/dist/middleware/auth.js
vendored
Normal file
54
backend/dist/middleware/auth.js
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": 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 JWT_SECRET = process.env.JWT_SECRET || 'treasure-trails-secret-key';
|
||||
const authenticate = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jsonwebtoken_1.default.verify(token, JWT_SECRET);
|
||||
const user = await index_js_1.prisma.user.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
select: { id: true, email: true, name: true }
|
||||
});
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'User not found' });
|
||||
}
|
||||
req.user = user;
|
||||
next();
|
||||
}
|
||||
catch (error) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
};
|
||||
exports.authenticate = authenticate;
|
||||
const optionalAuth = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return next();
|
||||
}
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jsonwebtoken_1.default.verify(token, JWT_SECRET);
|
||||
const user = await index_js_1.prisma.user.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
select: { id: true, email: true, name: true }
|
||||
});
|
||||
if (user) {
|
||||
req.user = user;
|
||||
}
|
||||
next();
|
||||
}
|
||||
catch {
|
||||
next();
|
||||
}
|
||||
};
|
||||
exports.optionalAuth = optionalAuth;
|
||||
2
backend/dist/routes/auth.d.ts
vendored
Normal file
2
backend/dist/routes/auth.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
declare const router: import("express-serve-static-core").Router;
|
||||
export default router;
|
||||
83
backend/dist/routes/auth.js
vendored
Normal file
83
backend/dist/routes/auth.js
vendored
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
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 router = (0, express_1.Router)();
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'treasure-trails-secret-key';
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const { email, password, name } = req.body;
|
||||
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 } });
|
||||
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({
|
||||
data: { email, passwordHash, name }
|
||||
});
|
||||
const token = jsonwebtoken_1.default.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
|
||||
res.json({
|
||||
token,
|
||||
user: { id: user.id, email: user.email, name: user.name }
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Register error:', error);
|
||||
res.status(500).json({ error: 'Failed to register' });
|
||||
}
|
||||
});
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
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 } });
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
const validPassword = await bcryptjs_1.default.compare(password, user.passwordHash);
|
||||
if (!validPassword) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
const token = jsonwebtoken_1.default.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
|
||||
res.json({
|
||||
token,
|
||||
user: { id: user.id, email: user.email, name: user.name }
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Failed to login' });
|
||||
}
|
||||
});
|
||||
router.get('/me', async (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jsonwebtoken_1.default.verify(token, JWT_SECRET);
|
||||
const user = await index_js_1.prisma.user.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
select: { id: true, email: true, name: true, createdAt: true }
|
||||
});
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'User not found' });
|
||||
}
|
||||
res.json(user);
|
||||
}
|
||||
catch {
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
});
|
||||
exports.default = router;
|
||||
2
backend/dist/routes/games.d.ts
vendored
Normal file
2
backend/dist/routes/games.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
declare const router: import("express-serve-static-core").Router;
|
||||
export default router;
|
||||
248
backend/dist/routes/games.js
vendored
Normal file
248
backend/dist/routes/games.js
vendored
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
"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 uuid_1 = require("uuid");
|
||||
const router = (0, express_1.Router)();
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { search, status } = req.query;
|
||||
const where = {
|
||||
visibility: 'PUBLIC'
|
||||
};
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
if (search) {
|
||||
where.name = { contains: search, mode: 'insensitive' };
|
||||
}
|
||||
const games = await index_js_1.prisma.game.findMany({
|
||||
where,
|
||||
include: {
|
||||
gameMaster: { select: { id: true, name: true } },
|
||||
_count: { select: { teams: true, legs: true } }
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
res.json(games);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('List games error:', error);
|
||||
res.status(500).json({ error: 'Failed to list games' });
|
||||
}
|
||||
});
|
||||
router.get('/my-games', auth_js_1.authenticate, async (req, res) => {
|
||||
try {
|
||||
const games = await index_js_1.prisma.game.findMany({
|
||||
where: { gameMasterId: req.user.id },
|
||||
include: {
|
||||
_count: { select: { teams: true, legs: true } }
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
res.json(games);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('My games error:', error);
|
||||
res.status(500).json({ error: 'Failed to get games' });
|
||||
}
|
||||
});
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const game = await index_js_1.prisma.game.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
gameMaster: { select: { id: true, name: true } },
|
||||
legs: { orderBy: { sequenceNumber: 'asc' } },
|
||||
teams: {
|
||||
include: {
|
||||
members: { include: { user: { select: { id: true, name: true, email: true } } } },
|
||||
currentLeg: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!game) {
|
||||
return res.status(404).json({ error: 'Game not found' });
|
||||
}
|
||||
const isOwner = req.user?.id === game.gameMasterId;
|
||||
if (game.visibility === 'PRIVATE' && !isOwner) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
res.json(game);
|
||||
}
|
||||
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) => {
|
||||
try {
|
||||
const { name, description, prizeDetails, visibility, startDate, locationLat, locationLng, searchRadius, timeLimitPerLeg, timeDeductionPenalty } = req.body;
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Name is required' });
|
||||
}
|
||||
if (startDate && new Date(startDate) < new Date()) {
|
||||
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({
|
||||
data: {
|
||||
name,
|
||||
description,
|
||||
prizeDetails,
|
||||
visibility: visibility || 'PUBLIC',
|
||||
startDate: startDate ? new Date(startDate) : null,
|
||||
locationLat,
|
||||
locationLng,
|
||||
searchRadius,
|
||||
timeLimitPerLeg,
|
||||
timeDeductionPenalty,
|
||||
gameMasterId: req.user.id,
|
||||
inviteCode
|
||||
}
|
||||
});
|
||||
res.json(game);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Create game error:', error);
|
||||
res.status(500).json({ error: 'Failed to create game' });
|
||||
}
|
||||
});
|
||||
router.put('/:id', auth_js_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 } });
|
||||
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 },
|
||||
data: {
|
||||
name,
|
||||
description,
|
||||
prizeDetails,
|
||||
visibility,
|
||||
startDate: startDate ? new Date(startDate) : undefined,
|
||||
locationLat,
|
||||
locationLng,
|
||||
searchRadius,
|
||||
timeLimitPerLeg,
|
||||
timeDeductionPenalty,
|
||||
status
|
||||
}
|
||||
});
|
||||
res.json(updated);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Update game error:', error);
|
||||
res.status(500).json({ error: 'Failed to update game' });
|
||||
}
|
||||
});
|
||||
router.delete('/:id', auth_js_1.authenticate, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const game = await index_js_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 } });
|
||||
res.json({ message: 'Game deleted' });
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Delete game error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete game' });
|
||||
}
|
||||
});
|
||||
router.post('/:id/publish', auth_js_1.authenticate, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const game = await index_js_1.prisma.game.findUnique({
|
||||
where: { id },
|
||||
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' });
|
||||
}
|
||||
if (game.legs.length === 0) {
|
||||
return res.status(400).json({ error: 'Game must have at least one leg' });
|
||||
}
|
||||
const updated = await index_js_1.prisma.game.update({
|
||||
where: { id },
|
||||
data: { status: 'LIVE' }
|
||||
});
|
||||
res.json(updated);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Publish game error:', error);
|
||||
res.status(500).json({ error: 'Failed to publish game' });
|
||||
}
|
||||
});
|
||||
router.post('/:id/end', auth_js_1.authenticate, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const game = await index_js_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 },
|
||||
data: { status: 'ENDED' }
|
||||
});
|
||||
res.json(updated);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('End game error:', error);
|
||||
res.status(500).json({ error: 'Failed to end game' });
|
||||
}
|
||||
});
|
||||
router.get('/:id/invite', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const game = await index_js_1.prisma.game.findUnique({ where: { id } });
|
||||
if (!game) {
|
||||
return res.status(404).json({ error: 'Game not found' });
|
||||
}
|
||||
if (!game.inviteCode) {
|
||||
return res.status(404).json({ error: 'No invite code' });
|
||||
}
|
||||
res.json({ inviteCode: game.inviteCode });
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Get invite error:', error);
|
||||
res.status(500).json({ error: 'Failed to get invite code' });
|
||||
}
|
||||
});
|
||||
router.get('/invite/:code', async (req, res) => {
|
||||
try {
|
||||
const { code } = req.params;
|
||||
const game = await index_js_1.prisma.game.findUnique({
|
||||
where: { inviteCode: code },
|
||||
include: { gameMaster: { select: { id: true, name: true } } }
|
||||
});
|
||||
if (!game) {
|
||||
return res.status(404).json({ error: 'Game not found' });
|
||||
}
|
||||
res.json(game);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Get game by invite error:', error);
|
||||
res.status(500).json({ error: 'Failed to get game' });
|
||||
}
|
||||
});
|
||||
exports.default = router;
|
||||
2
backend/dist/routes/legs.d.ts
vendored
Normal file
2
backend/dist/routes/legs.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
declare const router: import("express-serve-static-core").Router;
|
||||
export default router;
|
||||
148
backend/dist/routes/legs.js
vendored
Normal file
148
backend/dist/routes/legs.js
vendored
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
"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 router = (0, express_1.Router)();
|
||||
router.get('/game/:gameId', auth_js_1.authenticate, async (req, res) => {
|
||||
try {
|
||||
const { gameId } = req.params;
|
||||
const game = await index_js_1.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', auth_js_1.authenticate, async (req, res) => {
|
||||
try {
|
||||
const { gameId } = req.params;
|
||||
const { description, conditionType, conditionDetails, locationLat, locationLng, timeLimit } = req.body;
|
||||
const game = await index_js_1.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, leg) => Math.max(max, leg.sequenceNumber), 0);
|
||||
const leg = await index_js_1.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', auth_js_1.authenticate, async (req, res) => {
|
||||
try {
|
||||
const { legId } = req.params;
|
||||
const { description, conditionType, conditionDetails, locationLat, locationLng, timeLimit } = req.body;
|
||||
const leg = await index_js_1.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 index_js_1.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', auth_js_1.authenticate, async (req, res) => {
|
||||
try {
|
||||
const { legId } = req.params;
|
||||
const leg = await index_js_1.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 index_js_1.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', auth_js_1.authenticate, async (req, res) => {
|
||||
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 index_js_1.prisma.leg.findUnique({
|
||||
where: { id: legId },
|
||||
include: { game: true }
|
||||
});
|
||||
if (!leg) {
|
||||
return res.status(404).json({ error: 'Leg not found' });
|
||||
}
|
||||
const team = await index_js_1.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 index_js_1.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' });
|
||||
}
|
||||
});
|
||||
exports.default = router;
|
||||
2
backend/dist/routes/teams.d.ts
vendored
Normal file
2
backend/dist/routes/teams.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
declare const router: import("express-serve-static-core").Router;
|
||||
export default router;
|
||||
290
backend/dist/routes/teams.js
vendored
Normal file
290
backend/dist/routes/teams.js
vendored
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
"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 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({
|
||||
where: { gameId },
|
||||
include: {
|
||||
members: { include: { user: { select: { id: true, name: true, email: true } } } },
|
||||
captain: { select: { id: true, name: true } },
|
||||
currentLeg: true
|
||||
},
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
res.json(teams);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Get teams error:', error);
|
||||
res.status(500).json({ error: 'Failed to get teams' });
|
||||
}
|
||||
});
|
||||
router.post('/game/:gameId', auth_js_1.authenticate, async (req, res) => {
|
||||
try {
|
||||
const { gameId } = req.params;
|
||||
const { name } = req.body;
|
||||
const game = await index_js_1.prisma.game.findUnique({
|
||||
where: { id: gameId },
|
||||
include: { teams: true }
|
||||
});
|
||||
if (!game) {
|
||||
return res.status(404).json({ error: 'Game not found' });
|
||||
}
|
||||
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({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
team: { gameId }
|
||||
}
|
||||
});
|
||||
if (existingMember) {
|
||||
return res.status(400).json({ error: 'Already in a team for this game' });
|
||||
}
|
||||
const team = await index_js_1.prisma.team.create({
|
||||
data: {
|
||||
gameId,
|
||||
name,
|
||||
captainId: req.user.id
|
||||
}
|
||||
});
|
||||
await index_js_1.prisma.teamMember.create({
|
||||
data: {
|
||||
teamId: team.id,
|
||||
userId: req.user.id
|
||||
}
|
||||
});
|
||||
const created = await index_js_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 } }
|
||||
}
|
||||
});
|
||||
res.json(created);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Create team error:', error);
|
||||
res.status(500).json({ error: 'Failed to create team' });
|
||||
}
|
||||
});
|
||||
router.post('/:teamId/join', auth_js_1.authenticate, async (req, res) => {
|
||||
try {
|
||||
const { teamId } = req.params;
|
||||
const team = await index_js_1.prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
include: { game: true, members: true }
|
||||
});
|
||||
if (!team) {
|
||||
return res.status(404).json({ error: 'Team not found' });
|
||||
}
|
||||
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({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
teamId
|
||||
}
|
||||
});
|
||||
if (existingMember) {
|
||||
return res.status(400).json({ error: 'Already in this team' });
|
||||
}
|
||||
const gameMember = await index_js_1.prisma.teamMember.findFirst({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
team: { gameId: team.gameId }
|
||||
}
|
||||
});
|
||||
if (gameMember) {
|
||||
return res.status(400).json({ error: 'Already in another team for this game' });
|
||||
}
|
||||
await index_js_1.prisma.teamMember.create({
|
||||
data: {
|
||||
teamId,
|
||||
userId: req.user.id
|
||||
}
|
||||
});
|
||||
res.json({ message: 'Joined team successfully' });
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Join team error:', error);
|
||||
res.status(500).json({ error: 'Failed to join team' });
|
||||
}
|
||||
});
|
||||
router.post('/:teamId/leave', auth_js_1.authenticate, async (req, res) => {
|
||||
try {
|
||||
const { teamId } = req.params;
|
||||
const team = await index_js_1.prisma.team.findUnique({
|
||||
where: { id: teamId }
|
||||
});
|
||||
if (!team) {
|
||||
return res.status(404).json({ error: 'Team not found' });
|
||||
}
|
||||
if (team.captainId === req.user.id) {
|
||||
return res.status(400).json({ error: 'Captain cannot leave the team' });
|
||||
}
|
||||
await index_js_1.prisma.teamMember.deleteMany({
|
||||
where: {
|
||||
teamId,
|
||||
userId: req.user.id
|
||||
}
|
||||
});
|
||||
res.json({ message: 'Left team successfully' });
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Leave team error:', error);
|
||||
res.status(500).json({ error: 'Failed to leave team' });
|
||||
}
|
||||
});
|
||||
router.post('/:teamId/advance', auth_js_1.authenticate, async (req, res) => {
|
||||
try {
|
||||
const { teamId } = req.params;
|
||||
const team = await index_js_1.prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
include: {
|
||||
game: { include: { legs: { orderBy: { sequenceNumber: 'asc' } } } },
|
||||
currentLeg: 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 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];
|
||||
}
|
||||
}
|
||||
else if (legs.length > 0) {
|
||||
nextLeg = legs[0];
|
||||
}
|
||||
const updated = await index_js_1.prisma.team.update({
|
||||
where: { id: teamId },
|
||||
data: {
|
||||
currentLegId: nextLeg?.id || null,
|
||||
status: nextLeg ? 'ACTIVE' : 'FINISHED'
|
||||
},
|
||||
include: {
|
||||
members: { include: { user: { select: { id: true, name: true } } } },
|
||||
currentLeg: true
|
||||
}
|
||||
});
|
||||
res.json(updated);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Advance team error:', error);
|
||||
res.status(500).json({ error: 'Failed to advance team' });
|
||||
}
|
||||
});
|
||||
router.post('/:teamId/deduct', auth_js_1.authenticate, async (req, res) => {
|
||||
try {
|
||||
const { teamId } = req.params;
|
||||
const { seconds } = req.body;
|
||||
const team = await index_js_1.prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
include: { game: 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 deduction = seconds || team.game.timeDeductionPenalty || 60;
|
||||
const updated = await index_js_1.prisma.team.update({
|
||||
where: { id: teamId },
|
||||
data: { totalTimeDeduction: { increment: deduction } }
|
||||
});
|
||||
res.json(updated);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Deduct time error:', error);
|
||||
res.status(500).json({ error: 'Failed to deduct time' });
|
||||
}
|
||||
});
|
||||
router.post('/:teamId/disqualify', auth_js_1.authenticate, async (req, res) => {
|
||||
try {
|
||||
const { teamId } = req.params;
|
||||
const team = await index_js_1.prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
include: { game: 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 updated = await index_js_1.prisma.team.update({
|
||||
where: { id: teamId },
|
||||
data: { status: 'DISQUALIFIED' }
|
||||
});
|
||||
res.json(updated);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Disqualify team error:', error);
|
||||
res.status(500).json({ error: 'Failed to disqualify team' });
|
||||
}
|
||||
});
|
||||
router.post('/:teamId/location', auth_js_1.authenticate, async (req, res) => {
|
||||
try {
|
||||
const { teamId } = req.params;
|
||||
const { lat, lng } = req.body;
|
||||
const team = await index_js_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({
|
||||
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({
|
||||
where: { id: teamId },
|
||||
data: { lat, lng }
|
||||
});
|
||||
res.json(updated);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Update location error:', error);
|
||||
res.status(500).json({ error: 'Failed to update location' });
|
||||
}
|
||||
});
|
||||
router.get('/:teamId', auth_js_1.authenticate, async (req, res) => {
|
||||
try {
|
||||
const { teamId } = req.params;
|
||||
const team = await index_js_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' } } } }
|
||||
}
|
||||
});
|
||||
if (!team) {
|
||||
return res.status(404).json({ error: 'Team not found' });
|
||||
}
|
||||
res.json(team);
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Get team error:', error);
|
||||
res.status(500).json({ error: 'Failed to get team' });
|
||||
}
|
||||
});
|
||||
exports.default = router;
|
||||
2
backend/dist/routes/upload.d.ts
vendored
Normal file
2
backend/dist/routes/upload.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
declare const router: import("express-serve-static-core").Router;
|
||||
export default router;
|
||||
47
backend/dist/routes/upload.js
vendored
Normal file
47
backend/dist/routes/upload.js
vendored
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
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 router = (0, express_1.Router)();
|
||||
const storage = multer_1.default.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, 'uploads/');
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path_1.default.extname(file.originalname);
|
||||
cb(null, `${(0, uuid_1.v4)()}${ext}`);
|
||||
}
|
||||
});
|
||||
const upload = (0, multer_1.default)({
|
||||
storage,
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedTypes = /jpeg|jpg|png|gif|webp/;
|
||||
const extname = allowedTypes.test(path_1.default.extname(file.originalname).toLowerCase());
|
||||
const mimetype = allowedTypes.test(file.mimetype);
|
||||
if (extname && mimetype) {
|
||||
return cb(null, true);
|
||||
}
|
||||
cb(new Error('Only image files are allowed'));
|
||||
}
|
||||
});
|
||||
router.post('/upload', auth_js_1.authenticate, upload.single('photo'), (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
const url = `/uploads/${req.file.filename}`;
|
||||
res.json({ url });
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
res.status(500).json({ error: 'Failed to upload file' });
|
||||
}
|
||||
});
|
||||
exports.default = router;
|
||||
2
backend/dist/socket/index.d.ts
vendored
Normal file
2
backend/dist/socket/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { Server } from 'socket.io';
|
||||
export default function setupSocket(io: Server): Server<import("socket.io").DefaultEventsMap, import("socket.io").DefaultEventsMap, import("socket.io").DefaultEventsMap, any>;
|
||||
54
backend/dist/socket/index.js
vendored
Normal file
54
backend/dist/socket/index.js
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.default = setupSocket;
|
||||
const index_js_1 = require("../index.js");
|
||||
function setupSocket(io) {
|
||||
io.on('connection', (socket) => {
|
||||
console.log('Client connected:', socket.id);
|
||||
socket.on('join-game', async (gameId) => {
|
||||
socket.join(`game:${gameId}`);
|
||||
console.log(`Socket ${socket.id} joined game:${gameId}`);
|
||||
});
|
||||
socket.on('leave-game', (gameId) => {
|
||||
socket.leave(`game:${gameId}`);
|
||||
});
|
||||
socket.on('team-location', async (data) => {
|
||||
await index_js_1.prisma.team.update({
|
||||
where: { id: data.teamId },
|
||||
data: { lat: data.lat, lng: data.lng }
|
||||
});
|
||||
io.to(`game:${data.gameId}`).emit('team-location', {
|
||||
teamId: data.teamId,
|
||||
lat: data.lat,
|
||||
lng: data.lng
|
||||
});
|
||||
});
|
||||
socket.on('chat-message', async (data) => {
|
||||
const chatMessage = await index_js_1.prisma.chatMessage.create({
|
||||
data: {
|
||||
gameId: data.gameId,
|
||||
teamId: data.teamId,
|
||||
userId: data.userId,
|
||||
message: data.message
|
||||
}
|
||||
});
|
||||
io.to(`game:${data.gameId}`).emit('chat-message', {
|
||||
id: chatMessage.id,
|
||||
teamId: data.teamId,
|
||||
userId: data.userId,
|
||||
userName: data.userName,
|
||||
message: data.message,
|
||||
sentAt: chatMessage.sentAt
|
||||
});
|
||||
});
|
||||
socket.on('team-advanced', async (data) => {
|
||||
io.to(`game:${data.gameId}`).emit('team-advanced', {
|
||||
teamId: data.teamId
|
||||
});
|
||||
});
|
||||
socket.on('disconnect', () => {
|
||||
console.log('Client disconnected:', socket.id);
|
||||
});
|
||||
});
|
||||
return io;
|
||||
}
|
||||
2335
backend/package-lock.json
generated
Normal file
2335
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
42
backend/package.json
Normal file
42
backend/package.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"name": "treasure-trails-backend",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"dev": "nodemon --exec 'npx ts-node --transpile-only src/index.ts'",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:migrate": "prisma migrate dev"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "Treasure Trails Backend API",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.22.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^4.21.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"pg": "^8.14.0",
|
||||
"socket.io": "^4.8.1",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.15.21",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"nodemon": "^3.1.9",
|
||||
"prisma": "^5.22.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
128
backend/prisma/schema.prisma
Normal file
128
backend/prisma/schema.prisma
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
games Game[] @relation("GameMaster")
|
||||
teams TeamMember[]
|
||||
captainOf Team? @relation("TeamCaptain")
|
||||
chatMessages ChatMessage[]
|
||||
}
|
||||
|
||||
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?
|
||||
timeLimitPerLeg Int?
|
||||
timeDeductionPenalty Int?
|
||||
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[]
|
||||
}
|
||||
|
||||
model Leg {
|
||||
id String @id @default(uuid())
|
||||
gameId String
|
||||
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
|
||||
sequenceNumber Int
|
||||
description String
|
||||
conditionType String @default("photo")
|
||||
conditionDetails String?
|
||||
locationLat Float?
|
||||
locationLng Float?
|
||||
timeLimit Int?
|
||||
teams Team[]
|
||||
}
|
||||
|
||||
model Team {
|
||||
id String @id @default(uuid())
|
||||
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)
|
||||
lat Float?
|
||||
lng Float?
|
||||
rank Int?
|
||||
createdAt DateTime @default(now())
|
||||
members TeamMember[]
|
||||
photoSubmissions PhotoSubmission[]
|
||||
chatMessages ChatMessage[]
|
||||
}
|
||||
|
||||
model TeamMember {
|
||||
id String @id @default(uuid())
|
||||
teamId String
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
joinedAt DateTime @default(now())
|
||||
|
||||
@@unique([teamId, userId])
|
||||
}
|
||||
|
||||
model PhotoSubmission {
|
||||
id String @id @default(uuid())
|
||||
teamId String
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
legId String
|
||||
photoUrl String
|
||||
approved Boolean @default(false)
|
||||
submittedAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model ChatMessage {
|
||||
id String @id @default(uuid())
|
||||
gameId String
|
||||
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
|
||||
teamId String?
|
||||
team Team? @relation(fields: [teamId], references: [id])
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
message String
|
||||
sentAt DateTime @default(now())
|
||||
}
|
||||
|
||||
enum Visibility {
|
||||
PUBLIC
|
||||
PRIVATE
|
||||
}
|
||||
|
||||
enum GameStatus {
|
||||
DRAFT
|
||||
LIVE
|
||||
ENDED
|
||||
}
|
||||
|
||||
enum TeamStatus {
|
||||
ACTIVE
|
||||
DISQUALIFIED
|
||||
FINISHED
|
||||
}
|
||||
49
backend/src/index.ts
Normal file
49
backend/src/index.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { createServer } from 'http';
|
||||
import { Server } from 'socket.io';
|
||||
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 uploadRoutes from './routes/upload';
|
||||
import setupSocket from './socket/index';
|
||||
|
||||
const app = express();
|
||||
const httpServer = createServer(app);
|
||||
const io = new Server(httpServer, {
|
||||
cors: {
|
||||
origin: ['http://localhost:5173', 'http://localhost:3000'],
|
||||
methods: ['GET', 'POST']
|
||||
}
|
||||
});
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
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/upload', uploadRoutes);
|
||||
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
setupSocket(io);
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
httpServer.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
await prisma.$disconnect();
|
||||
process.exit();
|
||||
});
|
||||
63
backend/src/middleware/auth.ts
Normal file
63
backend/src/middleware/auth.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { prisma } from '../index';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'treasure-trails-secret-key';
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const authenticate = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as { userId: string };
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
select: { id: true, email: true, name: true }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (error) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
};
|
||||
|
||||
export const optionalAuth = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as { userId: string };
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
select: { id: true, email: true, name: true }
|
||||
});
|
||||
|
||||
if (user) {
|
||||
req.user = user;
|
||||
}
|
||||
next();
|
||||
} catch {
|
||||
next();
|
||||
}
|
||||
};
|
||||
94
backend/src/routes/auth.ts
Normal file
94
backend/src/routes/auth.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { Router, Request, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { prisma } from '../index';
|
||||
|
||||
const router = Router();
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'treasure-trails-secret-key';
|
||||
|
||||
router.post('/register', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { email, password, name } = req.body;
|
||||
|
||||
if (!email || !password || !name) {
|
||||
return res.status(400).json({ error: 'Email, password, and name are required' });
|
||||
}
|
||||
|
||||
const existingUser = await prisma.user.findUnique({ where: { email } });
|
||||
if (existingUser) {
|
||||
return res.status(400).json({ error: 'Email already registered' });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
const user = await prisma.user.create({
|
||||
data: { email, passwordHash, name }
|
||||
});
|
||||
|
||||
const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
|
||||
|
||||
res.json({
|
||||
token,
|
||||
user: { id: user.id, email: user.email, name: user.name }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Register error:', error);
|
||||
res.status(500).json({ error: 'Failed to register' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/login', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: 'Email and password are required' });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const validPassword = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!validPassword) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
|
||||
|
||||
res.json({
|
||||
token,
|
||||
user: { id: user.id, email: user.email, name: user.name }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Failed to login' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/me', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as { userId: string };
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
select: { id: true, email: true, name: true, createdAt: true }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
res.json(user);
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
294
backend/src/routes/games.ts
Normal file
294
backend/src/routes/games.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
import { Router, Response } from 'express';
|
||||
import { prisma } from '../index';
|
||||
import { authenticate, AuthRequest } from '../middleware/auth';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { search, status } = req.query;
|
||||
|
||||
const where: any = {
|
||||
visibility: 'PUBLIC'
|
||||
};
|
||||
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where.name = { contains: search as string, mode: 'insensitive' };
|
||||
}
|
||||
|
||||
const games = await prisma.game.findMany({
|
||||
where,
|
||||
include: {
|
||||
gameMaster: { select: { id: true, name: true } },
|
||||
_count: { select: { teams: true, legs: true } }
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
res.json(games);
|
||||
} catch (error) {
|
||||
console.error('List games error:', error);
|
||||
res.status(500).json({ error: 'Failed to list games' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/my-games', authenticate, async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const games = await prisma.game.findMany({
|
||||
where: { gameMasterId: req.user!.id },
|
||||
include: {
|
||||
_count: { select: { teams: true, legs: true } }
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
res.json(games);
|
||||
} catch (error) {
|
||||
console.error('My games error:', error);
|
||||
res.status(500).json({ error: 'Failed to get games' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const game = await prisma.game.findUnique({
|
||||
where: { id: id as string },
|
||||
include: {
|
||||
gameMaster: { select: { id: true, name: true } },
|
||||
legs: { orderBy: { sequenceNumber: 'asc' } },
|
||||
teams: {
|
||||
include: {
|
||||
members: { include: { user: { select: { id: true, name: true, email: true } } } },
|
||||
currentLeg: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!game) {
|
||||
return res.status(404).json({ error: 'Game not found' });
|
||||
}
|
||||
|
||||
const isOwner = req.user?.id === game.gameMasterId;
|
||||
|
||||
if (game.visibility === 'PRIVATE' && !isOwner) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
res.json(game);
|
||||
} catch (error) {
|
||||
console.error('Get game error:', error);
|
||||
res.status(500).json({ error: 'Failed to get game' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', authenticate, async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
name, description, prizeDetails, visibility, startDate,
|
||||
locationLat, locationLng, searchRadius, timeLimitPerLeg, timeDeductionPenalty
|
||||
} = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Name is required' });
|
||||
}
|
||||
|
||||
if (startDate && new Date(startDate) < new Date()) {
|
||||
return res.status(400).json({ error: 'Start date must be in the future' });
|
||||
}
|
||||
|
||||
const inviteCode = uuidv4().slice(0, 8);
|
||||
|
||||
const game = await prisma.game.create({
|
||||
data: {
|
||||
name,
|
||||
description,
|
||||
prizeDetails,
|
||||
visibility: visibility || 'PUBLIC',
|
||||
startDate: startDate ? new Date(startDate) : null,
|
||||
locationLat,
|
||||
locationLng,
|
||||
searchRadius,
|
||||
timeLimitPerLeg,
|
||||
timeDeductionPenalty,
|
||||
gameMasterId: req.user!.id,
|
||||
inviteCode
|
||||
}
|
||||
});
|
||||
|
||||
res.json(game);
|
||||
} catch (error) {
|
||||
console.error('Create game error:', error);
|
||||
res.status(500).json({ error: 'Failed to create game' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:id', authenticate, async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const {
|
||||
name, description, prizeDetails, visibility, startDate,
|
||||
locationLat, locationLng, searchRadius, timeLimitPerLeg, timeDeductionPenalty, status
|
||||
} = req.body;
|
||||
|
||||
const game = await 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 prisma.game.update({
|
||||
where: { id: id as string },
|
||||
data: {
|
||||
name,
|
||||
description,
|
||||
prizeDetails,
|
||||
visibility,
|
||||
startDate: startDate ? new Date(startDate) : undefined,
|
||||
locationLat,
|
||||
locationLng,
|
||||
searchRadius,
|
||||
timeLimitPerLeg,
|
||||
timeDeductionPenalty,
|
||||
status
|
||||
}
|
||||
});
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
console.error('Update game error:', error);
|
||||
res.status(500).json({ error: 'Failed to update game' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
|
||||
const game = await 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 prisma.game.delete({ where: { id } });
|
||||
|
||||
res.json({ message: 'Game deleted' });
|
||||
} catch (error) {
|
||||
console.error('Delete game error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete game' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/publish', authenticate, async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const game = await prisma.game.findUnique({
|
||||
where: { id: id as string },
|
||||
include: { legs: true }
|
||||
}) as any;
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
if (!game.legs || game.legs.length === 0) {
|
||||
return res.status(400).json({ error: 'Game must have at least one leg' });
|
||||
}
|
||||
|
||||
const updated = await prisma.game.update({
|
||||
where: { id: id as string },
|
||||
data: { status: 'LIVE' }
|
||||
});
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
console.error('Publish game error:', error);
|
||||
res.status(500).json({ error: 'Failed to publish game' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/end', authenticate, async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
|
||||
const game = await 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 prisma.game.update({
|
||||
where: { id },
|
||||
data: { status: 'ENDED' }
|
||||
});
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
console.error('End game error:', error);
|
||||
res.status(500).json({ error: 'Failed to end game' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id/invite', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
|
||||
const game = await prisma.game.findUnique({ where: { id } });
|
||||
if (!game) {
|
||||
return res.status(404).json({ error: 'Game not found' });
|
||||
}
|
||||
|
||||
if (!game.inviteCode) {
|
||||
return res.status(404).json({ error: 'No invite code' });
|
||||
}
|
||||
|
||||
res.json({ inviteCode: game.inviteCode });
|
||||
} catch (error) {
|
||||
console.error('Get invite error:', error);
|
||||
res.status(500).json({ error: 'Failed to get invite code' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/invite/:code', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const code = req.params.code as string;
|
||||
|
||||
const game = await prisma.game.findUnique({
|
||||
where: { inviteCode: code },
|
||||
include: { gameMaster: { select: { id: true, name: true } } }
|
||||
});
|
||||
|
||||
if (!game) {
|
||||
return res.status(404).json({ error: 'Game not found' });
|
||||
}
|
||||
|
||||
res.json(game);
|
||||
} catch (error) {
|
||||
console.error('Get game by invite error:', error);
|
||||
res.status(500).json({ error: 'Failed to get game' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
175
backend/src/routes/legs.ts
Normal file
175
backend/src/routes/legs.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
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;
|
||||
341
backend/src/routes/teams.ts
Normal file
341
backend/src/routes/teams.ts
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
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 teams = await prisma.team.findMany({
|
||||
where: { gameId },
|
||||
include: {
|
||||
members: { include: { user: { select: { id: true, name: true, email: true } } } },
|
||||
captain: { select: { id: true, name: true } },
|
||||
currentLeg: true
|
||||
},
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
|
||||
res.json(teams);
|
||||
} catch (error) {
|
||||
console.error('Get teams error:', error);
|
||||
res.status(500).json({ error: 'Failed to get teams' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/game/:gameId', authenticate, async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { gameId } = req.params;
|
||||
const { name } = req.body;
|
||||
|
||||
const game = await prisma.game.findUnique({
|
||||
where: { id: gameId },
|
||||
include: { teams: true }
|
||||
});
|
||||
|
||||
if (!game) {
|
||||
return res.status(404).json({ error: 'Game not found' });
|
||||
}
|
||||
|
||||
if (game.status !== 'DRAFT' && game.status !== 'LIVE') {
|
||||
return res.status(400).json({ error: 'Cannot join game at this time' });
|
||||
}
|
||||
|
||||
const existingMember = await prisma.teamMember.findFirst({
|
||||
where: {
|
||||
userId: req.user!.id,
|
||||
team: { gameId }
|
||||
}
|
||||
});
|
||||
|
||||
if (existingMember) {
|
||||
return res.status(400).json({ error: 'Already in a team for this game' });
|
||||
}
|
||||
|
||||
const team = await prisma.team.create({
|
||||
data: {
|
||||
gameId,
|
||||
name,
|
||||
captainId: req.user!.id
|
||||
}
|
||||
});
|
||||
|
||||
await prisma.teamMember.create({
|
||||
data: {
|
||||
teamId: team.id,
|
||||
userId: req.user!.id
|
||||
}
|
||||
});
|
||||
|
||||
const created = await prisma.team.findUnique({
|
||||
where: { id: team.id },
|
||||
include: {
|
||||
members: { include: { user: { select: { id: true, name: true, email: true } } } },
|
||||
captain: { select: { id: true, name: true } }
|
||||
}
|
||||
});
|
||||
|
||||
res.json(created);
|
||||
} catch (error) {
|
||||
console.error('Create team error:', error);
|
||||
res.status(500).json({ error: 'Failed to create team' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:teamId/join', authenticate, async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { teamId } = req.params;
|
||||
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
include: { game: true, members: true }
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return res.status(404).json({ error: 'Team not found' });
|
||||
}
|
||||
|
||||
if (team.members.length >= 5) {
|
||||
return res.status(400).json({ error: 'Team is full (max 5 members)' });
|
||||
}
|
||||
|
||||
const existingMember = await prisma.teamMember.findFirst({
|
||||
where: {
|
||||
userId: req.user!.id,
|
||||
teamId
|
||||
}
|
||||
});
|
||||
|
||||
if (existingMember) {
|
||||
return res.status(400).json({ error: 'Already in this team' });
|
||||
}
|
||||
|
||||
const gameMember = await prisma.teamMember.findFirst({
|
||||
where: {
|
||||
userId: req.user!.id,
|
||||
team: { gameId: team.gameId }
|
||||
}
|
||||
});
|
||||
|
||||
if (gameMember) {
|
||||
return res.status(400).json({ error: 'Already in another team for this game' });
|
||||
}
|
||||
|
||||
await prisma.teamMember.create({
|
||||
data: {
|
||||
teamId,
|
||||
userId: req.user!.id
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ message: 'Joined team successfully' });
|
||||
} catch (error) {
|
||||
console.error('Join team error:', error);
|
||||
res.status(500).json({ error: 'Failed to join team' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:teamId/leave', authenticate, async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { teamId } = req.params;
|
||||
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { id: teamId }
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return res.status(404).json({ error: 'Team not found' });
|
||||
}
|
||||
|
||||
if (team.captainId === req.user!.id) {
|
||||
return res.status(400).json({ error: 'Captain cannot leave the team' });
|
||||
}
|
||||
|
||||
await prisma.teamMember.deleteMany({
|
||||
where: {
|
||||
teamId,
|
||||
userId: req.user!.id
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ message: 'Left team successfully' });
|
||||
} catch (error) {
|
||||
console.error('Leave team error:', error);
|
||||
res.status(500).json({ error: 'Failed to leave team' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:teamId/advance', authenticate, async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { teamId } = req.params;
|
||||
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
include: {
|
||||
game: { include: { legs: { orderBy: { sequenceNumber: 'asc' } } } },
|
||||
currentLeg: 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 legs = team.game.legs;
|
||||
const currentLeg = team.currentLeg;
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
const updated = await prisma.team.update({
|
||||
where: { id: teamId },
|
||||
data: {
|
||||
currentLegId: nextLeg?.id || null,
|
||||
status: nextLeg ? 'ACTIVE' : 'FINISHED'
|
||||
},
|
||||
include: {
|
||||
members: { include: { user: { select: { id: true, name: true } } } },
|
||||
currentLeg: true
|
||||
}
|
||||
});
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
console.error('Advance team error:', error);
|
||||
res.status(500).json({ error: 'Failed to advance team' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:teamId/deduct', authenticate, async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { teamId } = req.params;
|
||||
const { seconds } = req.body;
|
||||
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
include: { game: 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 deduction = seconds || team.game.timeDeductionPenalty || 60;
|
||||
|
||||
const updated = await prisma.team.update({
|
||||
where: { id: teamId },
|
||||
data: { totalTimeDeduction: { increment: deduction } }
|
||||
});
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
console.error('Deduct time error:', error);
|
||||
res.status(500).json({ error: 'Failed to deduct time' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:teamId/disqualify', authenticate, async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { teamId } = req.params;
|
||||
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
include: { game: 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 updated = await prisma.team.update({
|
||||
where: { id: teamId },
|
||||
data: { status: 'DISQUALIFIED' }
|
||||
});
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
console.error('Disqualify team error:', error);
|
||||
res.status(500).json({ error: 'Failed to disqualify team' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:teamId/location', authenticate, async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { teamId } = req.params;
|
||||
const { lat, lng } = req.body;
|
||||
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { id: teamId }
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return res.status(404).json({ error: 'Team not found' });
|
||||
}
|
||||
|
||||
const member = await 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 prisma.team.update({
|
||||
where: { id: teamId },
|
||||
data: { lat, lng }
|
||||
});
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
console.error('Update location error:', error);
|
||||
res.status(500).json({ error: 'Failed to update location' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:teamId', authenticate, async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { teamId } = req.params;
|
||||
|
||||
const team = await 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' } } } }
|
||||
}
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return res.status(404).json({ error: 'Team not found' });
|
||||
}
|
||||
|
||||
res.json(team);
|
||||
} catch (error) {
|
||||
console.error('Get team error:', error);
|
||||
res.status(500).json({ error: 'Failed to get team' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
47
backend/src/routes/upload.ts
Normal file
47
backend/src/routes/upload.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { Router, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { authenticate, AuthRequest } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, 'uploads/');
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `${uuidv4()}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedTypes = /jpeg|jpg|png|gif|webp/;
|
||||
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
|
||||
const mimetype = allowedTypes.test(file.mimetype);
|
||||
if (extname && mimetype) {
|
||||
return cb(null, true);
|
||||
}
|
||||
cb(new Error('Only image files are allowed'));
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/upload', authenticate, upload.single('photo'), (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
|
||||
const url = `/uploads/${req.file.filename}`;
|
||||
res.json({ url });
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
res.status(500).json({ error: 'Failed to upload file' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
61
backend/src/socket/index.ts
Normal file
61
backend/src/socket/index.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { Server, Socket } from 'socket.io';
|
||||
import { prisma } from '../index';
|
||||
|
||||
export default function setupSocket(io: Server) {
|
||||
io.on('connection', (socket: Socket) => {
|
||||
console.log('Client connected:', socket.id);
|
||||
|
||||
socket.on('join-game', async (gameId: string) => {
|
||||
socket.join(`game:${gameId}`);
|
||||
console.log(`Socket ${socket.id} joined game:${gameId}`);
|
||||
});
|
||||
|
||||
socket.on('leave-game', (gameId: string) => {
|
||||
socket.leave(`game:${gameId}`);
|
||||
});
|
||||
|
||||
socket.on('team-location', async (data: { gameId: string; teamId: string; lat: number; lng: number }) => {
|
||||
await prisma.team.update({
|
||||
where: { id: data.teamId },
|
||||
data: { lat: data.lat, lng: data.lng }
|
||||
});
|
||||
io.to(`game:${data.gameId}`).emit('team-location', {
|
||||
teamId: data.teamId,
|
||||
lat: data.lat,
|
||||
lng: data.lng
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('chat-message', async (data: { gameId: string; teamId?: string; message: string; userId: string; userName: string }) => {
|
||||
const chatMessage = await prisma.chatMessage.create({
|
||||
data: {
|
||||
gameId: data.gameId,
|
||||
teamId: data.teamId,
|
||||
userId: data.userId,
|
||||
message: data.message
|
||||
}
|
||||
});
|
||||
|
||||
io.to(`game:${data.gameId}`).emit('chat-message', {
|
||||
id: chatMessage.id,
|
||||
teamId: data.teamId,
|
||||
userId: data.userId,
|
||||
userName: data.userName,
|
||||
message: data.message,
|
||||
sentAt: chatMessage.sentAt
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('team-advanced', async (data: { gameId: string; teamId: string }) => {
|
||||
io.to(`game:${data.gameId}`).emit('team-advanced', {
|
||||
teamId: data.teamId
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('Client disconnected:', socket.id);
|
||||
});
|
||||
});
|
||||
|
||||
return io;
|
||||
}
|
||||
17
backend/tsconfig.json
Normal file
17
backend/tsconfig.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"esModuleInterop": true,
|
||||
"strict": false,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"noImplicitAny": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue