883 lines
25 KiB
JavaScript
883 lines
25 KiB
JavaScript
import express from 'express';
|
|
import cors from 'cors';
|
|
import cookieParser from 'cookie-parser';
|
|
import multer from 'multer';
|
|
import bcrypt from 'bcryptjs';
|
|
import jwt from 'jsonwebtoken';
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
import { fileURLToPath } from 'url';
|
|
import dotenv from 'dotenv';
|
|
import { PrismaClient } from '@prisma/client';
|
|
|
|
dotenv.config();
|
|
|
|
const prisma = new PrismaClient();
|
|
const app = express();
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const uploadDir = path.join(__dirname, '..', 'uploads');
|
|
const fsPromises = fs.promises;
|
|
const jwtSecret = process.env.JWT_SECRET || 'super-secret-key';
|
|
const frontendOrigin = process.env.FRONTEND_ORIGIN || 'http://localhost:3100';
|
|
const port = process.env.PORT || 4000;
|
|
const host = process.env.HOST || '0.0.0.0';
|
|
|
|
if (!fs.existsSync(uploadDir)) {
|
|
fs.mkdirSync(uploadDir, { recursive: true });
|
|
}
|
|
|
|
const storage = multer.diskStorage({
|
|
destination: (_req, _file, cb) => cb(null, uploadDir),
|
|
filename: (_req, file, cb) => {
|
|
const unique = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
const ext = path.extname(file.originalname) || '.png';
|
|
cb(null, `${unique}${ext}`);
|
|
},
|
|
});
|
|
|
|
const upload = multer({ storage });
|
|
|
|
app.use(
|
|
cors({
|
|
origin: frontendOrigin,
|
|
credentials: true,
|
|
})
|
|
);
|
|
app.use(express.json());
|
|
app.use(cookieParser());
|
|
app.use('/uploads', express.static(uploadDir));
|
|
|
|
function setAuthCookie(res, token) {
|
|
res.cookie('token', token, {
|
|
httpOnly: true,
|
|
sameSite: 'lax',
|
|
secure: process.env.COOKIE_SECURE === 'true',
|
|
maxAge: 7 * 24 * 60 * 60 * 1000,
|
|
});
|
|
}
|
|
|
|
function clearAuthCookie(res) {
|
|
res.clearCookie('token');
|
|
}
|
|
|
|
async function getUserFromToken(req) {
|
|
const token = req.cookies.token;
|
|
if (!token) return null;
|
|
try {
|
|
const payload = jwt.verify(token, jwtSecret);
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: payload.sub },
|
|
include: {
|
|
armyCategories: true,
|
|
bases: {
|
|
include: {
|
|
defenses: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
return user;
|
|
} catch (_err) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function requireAuth(req, res, next) {
|
|
const user = await getUserFromToken(req);
|
|
if (!user) {
|
|
return res.status(401).json({ error: 'Authentication required' });
|
|
}
|
|
req.user = user;
|
|
return next();
|
|
}
|
|
|
|
app.post('/auth/signup', async (req, res) => {
|
|
try {
|
|
const { username, password } = req.body || {};
|
|
if (!username || !password) {
|
|
return res.status(400).json({ error: 'Username and password are required' });
|
|
}
|
|
if (username.length < 3) {
|
|
return res.status(400).json({ error: 'Username must be at least 3 characters' });
|
|
}
|
|
if (password.length < 6) {
|
|
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
|
}
|
|
|
|
const existing = await prisma.user.findUnique({ where: { username: username.toLowerCase() } });
|
|
if (existing) {
|
|
return res.status(400).json({ error: 'Username already in use' });
|
|
}
|
|
|
|
const passwordHash = await bcrypt.hash(password, 10);
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
username: username.toLowerCase(),
|
|
passwordHash,
|
|
},
|
|
});
|
|
|
|
const token = jwt.sign({ sub: user.id }, jwtSecret, { expiresIn: '7d' });
|
|
setAuthCookie(res, token);
|
|
|
|
return res.status(201).json({ user: sanitizeUser(user) });
|
|
} catch (error) {
|
|
console.error(error);
|
|
return res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
app.post('/auth/login', async (req, res) => {
|
|
try {
|
|
const { username, password } = req.body || {};
|
|
if (!username || !password) {
|
|
return res.status(400).json({ error: 'Username and password are required' });
|
|
}
|
|
|
|
const user = await prisma.user.findUnique({ where: { username: username.toLowerCase() } });
|
|
if (!user) {
|
|
return res.status(401).json({ error: 'Invalid username or password' });
|
|
}
|
|
|
|
const valid = await bcrypt.compare(password, user.passwordHash);
|
|
if (!valid) {
|
|
return res.status(401).json({ error: 'Invalid username or password' });
|
|
}
|
|
|
|
const token = jwt.sign({ sub: user.id }, jwtSecret, { expiresIn: '7d' });
|
|
setAuthCookie(res, token);
|
|
|
|
return res.json({ user: sanitizeUser(user) });
|
|
} catch (error) {
|
|
console.error(error);
|
|
return res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
app.post('/auth/logout', (_req, res) => {
|
|
clearAuthCookie(res);
|
|
res.json({ message: 'Logged out' });
|
|
});
|
|
|
|
app.get('/auth/me', async (req, res) => {
|
|
const user = await getUserFromToken(req);
|
|
if (!user) {
|
|
return res.status(401).json({ error: 'Authentication required' });
|
|
}
|
|
return res.json({ user: sanitizeUser(user) });
|
|
});
|
|
|
|
app.get('/army-categories', requireAuth, async (req, res) => {
|
|
const categories = await prisma.armyCategory.findMany({
|
|
where: { userId: req.user.id },
|
|
orderBy: { createdAt: 'asc' },
|
|
});
|
|
res.json({ categories: categories.map((category) => ({
|
|
id: category.id,
|
|
name: category.name,
|
|
createdAt: category.createdAt,
|
|
})) });
|
|
});
|
|
|
|
app.post('/army-categories', requireAuth, async (req, res) => {
|
|
const { name } = req.body || {};
|
|
if (!name || !name.trim()) {
|
|
return res.status(400).json({ error: 'Category name is required' });
|
|
}
|
|
try {
|
|
const category = await prisma.armyCategory.create({
|
|
data: {
|
|
name: name.trim(),
|
|
userId: req.user.id,
|
|
},
|
|
});
|
|
return res.status(201).json({ category: {
|
|
id: category.id,
|
|
name: category.name,
|
|
createdAt: category.createdAt,
|
|
} });
|
|
} catch (error) {
|
|
if (error.code === 'P2002') {
|
|
return res.status(400).json({ error: 'Category name already exists' });
|
|
}
|
|
console.error(error);
|
|
return res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
app.delete('/army-categories/:categoryId', requireAuth, async (req, res) => {
|
|
try {
|
|
const { categoryId } = req.params;
|
|
const category = await prisma.armyCategory.findFirst({
|
|
where: { id: categoryId, userId: req.user.id },
|
|
});
|
|
|
|
if (!category) {
|
|
return res.status(404).json({ error: 'Army category not found' });
|
|
}
|
|
|
|
await prisma.$transaction([
|
|
prisma.defense.deleteMany({
|
|
where: {
|
|
armyCategoryId: category.id,
|
|
base: { userId: req.user.id },
|
|
},
|
|
}),
|
|
prisma.armyCategory.delete({ where: { id: category.id } }),
|
|
]);
|
|
|
|
return res.json({ message: 'Army category deleted' });
|
|
} catch (error) {
|
|
console.error(error);
|
|
return res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
app.get('/bases', requireAuth, async (req, res) => {
|
|
const bases = await prisma.base.findMany({
|
|
where: { userId: req.user.id },
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
res.json({
|
|
bases: bases.map((base) => ({
|
|
id: base.id,
|
|
title: base.title,
|
|
description: base.description || '',
|
|
url: base.url || '',
|
|
imageUrl: buildImageUrl(base),
|
|
isPrivate: base.isPrivate,
|
|
createdAt: base.createdAt,
|
|
})),
|
|
});
|
|
});
|
|
|
|
app.post('/bases', requireAuth, upload.single('imageFile'), async (req, res) => {
|
|
try {
|
|
const { title, description, url, imageMode, imageUrl, isPrivate } = req.body;
|
|
if (!title || !title.trim()) {
|
|
return res.status(400).json({ error: 'Title is required' });
|
|
}
|
|
if (url && !isValidUrl(url)) {
|
|
return res.status(400).json({ error: 'Planning link must be a valid URL' });
|
|
}
|
|
|
|
let storedImageUrl = null;
|
|
let storedImagePath = null;
|
|
if (imageMode === 'url') {
|
|
if (!imageUrl || !isValidUrl(imageUrl)) {
|
|
return res.status(400).json({ error: 'Image URL must be valid' });
|
|
}
|
|
storedImageUrl = imageUrl.trim();
|
|
} else if (req.file) {
|
|
storedImagePath = req.file.filename;
|
|
}
|
|
|
|
const base = await prisma.base.create({
|
|
data: {
|
|
title: title.trim(),
|
|
description: description?.trim() || null,
|
|
url: url?.trim() || null,
|
|
imageUrl: storedImageUrl,
|
|
imagePath: storedImagePath,
|
|
isPrivate: parseBoolean(isPrivate),
|
|
userId: req.user.id,
|
|
},
|
|
});
|
|
|
|
return res.status(201).json({
|
|
base: {
|
|
id: base.id,
|
|
title: base.title,
|
|
description: base.description || '',
|
|
url: base.url || '',
|
|
imageUrl: buildImageUrl(base),
|
|
isPrivate: base.isPrivate,
|
|
createdAt: base.createdAt,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error(error);
|
|
if (req.file) {
|
|
await deleteImageFile(req.file.filename);
|
|
}
|
|
return res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
app.put('/bases/:baseId', requireAuth, upload.single('imageFile'), async (req, res) => {
|
|
let newUploadFilename = null;
|
|
try {
|
|
const { baseId } = req.params;
|
|
const {
|
|
title,
|
|
description,
|
|
url,
|
|
imageMode,
|
|
imageUrl,
|
|
removeImage,
|
|
isPrivate,
|
|
} = req.body;
|
|
|
|
if (!title || !title.trim()) {
|
|
if (req.file) {
|
|
await deleteImageFile(req.file.filename);
|
|
}
|
|
return res.status(400).json({ error: 'Title is required' });
|
|
}
|
|
|
|
if (url && !isValidUrl(url)) {
|
|
if (req.file) {
|
|
await deleteImageFile(req.file.filename);
|
|
}
|
|
return res.status(400).json({ error: 'Planning link must be a valid URL' });
|
|
}
|
|
|
|
const base = await prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } });
|
|
if (!base) {
|
|
if (req.file) {
|
|
await deleteImageFile(req.file.filename);
|
|
}
|
|
return res.status(404).json({ error: 'Base not found' });
|
|
}
|
|
|
|
const shouldRemoveImage = parseBoolean(removeImage);
|
|
const parsedIsPrivate = parseBoolean(isPrivate);
|
|
|
|
let imagePath = base.imagePath;
|
|
let storedImageUrl = base.imageUrl;
|
|
let previousImagePathToDelete = null;
|
|
|
|
if (shouldRemoveImage) {
|
|
previousImagePathToDelete = base.imagePath;
|
|
imagePath = null;
|
|
storedImageUrl = null;
|
|
} else if (imageMode === 'url') {
|
|
if (imageUrl) {
|
|
if (!isValidUrl(imageUrl)) {
|
|
if (req.file) {
|
|
await deleteImageFile(req.file.filename);
|
|
}
|
|
return res.status(400).json({ error: 'Image URL must be valid' });
|
|
}
|
|
previousImagePathToDelete = base.imagePath;
|
|
imagePath = null;
|
|
storedImageUrl = imageUrl.trim();
|
|
}
|
|
} else if (req.file) {
|
|
newUploadFilename = req.file.filename;
|
|
previousImagePathToDelete = base.imagePath;
|
|
imagePath = req.file.filename;
|
|
storedImageUrl = null;
|
|
}
|
|
|
|
const updatedBase = await prisma.base.update({
|
|
where: { id: base.id },
|
|
data: {
|
|
title: title.trim(),
|
|
description: description?.trim() || null,
|
|
url: url?.trim() || null,
|
|
imageUrl: storedImageUrl,
|
|
imagePath,
|
|
isPrivate: parsedIsPrivate,
|
|
},
|
|
});
|
|
|
|
if (previousImagePathToDelete && previousImagePathToDelete !== imagePath) {
|
|
await deleteImageFile(previousImagePathToDelete);
|
|
}
|
|
|
|
return res.json({
|
|
base: {
|
|
id: updatedBase.id,
|
|
title: updatedBase.title,
|
|
description: updatedBase.description || '',
|
|
url: updatedBase.url || '',
|
|
imageUrl: buildImageUrl(updatedBase),
|
|
isPrivate: updatedBase.isPrivate,
|
|
createdAt: updatedBase.createdAt,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error(error);
|
|
if (req.file) {
|
|
await deleteImageFile(req.file.filename);
|
|
} else if (newUploadFilename) {
|
|
await deleteImageFile(newUploadFilename);
|
|
}
|
|
return res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
app.delete('/bases/:baseId', requireAuth, async (req, res) => {
|
|
try {
|
|
const { baseId } = req.params;
|
|
const base = await prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } });
|
|
if (!base) {
|
|
return res.status(404).json({ error: 'Base not found' });
|
|
}
|
|
|
|
await prisma.$transaction([
|
|
prisma.defense.deleteMany({ where: { baseId: base.id } }),
|
|
prisma.base.delete({ where: { id: base.id } }),
|
|
]);
|
|
|
|
if (base.imagePath) {
|
|
await deleteImageFile(base.imagePath);
|
|
}
|
|
|
|
return res.json({ message: 'Base deleted' });
|
|
} catch (error) {
|
|
console.error(error);
|
|
return res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
app.post('/bases/:baseId/defenses', requireAuth, async (req, res) => {
|
|
try {
|
|
const { baseId } = req.params;
|
|
const { stars, percent, trophies, armyCategoryId } = req.body || {};
|
|
|
|
const parsedStars = Number(stars);
|
|
const parsedPercent = Number(percent);
|
|
const parsedTrophies = Number(trophies ?? 0);
|
|
|
|
if (!armyCategoryId) {
|
|
return res.status(400).json({ error: 'Army category is required' });
|
|
}
|
|
if (!Number.isFinite(parsedStars) || parsedStars < 0 || parsedStars > 3) {
|
|
return res.status(400).json({ error: 'Stars must be between 0 and 3' });
|
|
}
|
|
if (!Number.isFinite(parsedPercent) || parsedPercent < 0 || parsedPercent > 100) {
|
|
return res.status(400).json({ error: 'Percent must be between 0 and 100' });
|
|
}
|
|
if (!Number.isFinite(parsedTrophies) || parsedTrophies < -200 || parsedTrophies > 200) {
|
|
return res.status(400).json({ error: 'Trophies must be between -200 and 200' });
|
|
}
|
|
|
|
const [base, category] = await Promise.all([
|
|
prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } }),
|
|
prisma.armyCategory.findFirst({ where: { id: armyCategoryId, userId: req.user.id } }),
|
|
]);
|
|
|
|
if (!base) {
|
|
return res.status(404).json({ error: 'Base not found' });
|
|
}
|
|
if (!category) {
|
|
return res.status(404).json({ error: 'Army category not found' });
|
|
}
|
|
|
|
await prisma.defense.create({
|
|
data: {
|
|
baseId: base.id,
|
|
armyCategoryId: category.id,
|
|
stars: parsedStars,
|
|
percent: parsedPercent,
|
|
trophies: parsedTrophies,
|
|
},
|
|
});
|
|
|
|
return res.status(201).json({ message: 'Defense logged' });
|
|
} catch (error) {
|
|
console.error(error);
|
|
return res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
app.put('/defenses/:defenseId', requireAuth, async (req, res) => {
|
|
try {
|
|
const { defenseId } = req.params;
|
|
const { baseId, armyCategoryId, stars, percent, trophies } = req.body || {};
|
|
|
|
if (!baseId) {
|
|
return res.status(400).json({ error: 'Base is required' });
|
|
}
|
|
if (!armyCategoryId) {
|
|
return res.status(400).json({ error: 'Army category is required' });
|
|
}
|
|
|
|
const parsedStars = Number(stars);
|
|
const parsedPercent = Number(percent);
|
|
const parsedTrophies = Number(trophies ?? 0);
|
|
|
|
if (!Number.isFinite(parsedStars) || parsedStars < 0 || parsedStars > 3) {
|
|
return res.status(400).json({ error: 'Stars must be between 0 and 3' });
|
|
}
|
|
if (!Number.isFinite(parsedPercent) || parsedPercent < 0 || parsedPercent > 100) {
|
|
return res.status(400).json({ error: 'Percent must be between 0 and 100' });
|
|
}
|
|
if (!Number.isFinite(parsedTrophies) || parsedTrophies < -200 || parsedTrophies > 200) {
|
|
return res.status(400).json({ error: 'Trophies must be between -200 and 200' });
|
|
}
|
|
|
|
const [defense, base, category] = await Promise.all([
|
|
prisma.defense.findFirst({
|
|
where: { id: defenseId, base: { userId: req.user.id } },
|
|
}),
|
|
prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } }),
|
|
prisma.armyCategory.findFirst({ where: { id: armyCategoryId, userId: req.user.id } }),
|
|
]);
|
|
|
|
if (!defense) {
|
|
return res.status(404).json({ error: 'Defense not found' });
|
|
}
|
|
if (!base) {
|
|
return res.status(404).json({ error: 'Base not found' });
|
|
}
|
|
if (!category) {
|
|
return res.status(404).json({ error: 'Army category not found' });
|
|
}
|
|
|
|
await prisma.defense.update({
|
|
where: { id: defense.id },
|
|
data: {
|
|
baseId: base.id,
|
|
armyCategoryId: category.id,
|
|
stars: parsedStars,
|
|
percent: parsedPercent,
|
|
trophies: parsedTrophies,
|
|
},
|
|
});
|
|
|
|
return res.json({ message: 'Defense updated' });
|
|
} catch (error) {
|
|
console.error(error);
|
|
return res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
app.delete('/defenses/:defenseId', requireAuth, async (req, res) => {
|
|
try {
|
|
const { defenseId } = req.params;
|
|
const result = await prisma.defense.deleteMany({
|
|
where: { id: defenseId, base: { userId: req.user.id } },
|
|
});
|
|
|
|
if (!result.count) {
|
|
return res.status(404).json({ error: 'Defense not found' });
|
|
}
|
|
|
|
return res.json({ message: 'Defense deleted' });
|
|
} catch (error) {
|
|
console.error(error);
|
|
return res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
app.get('/defenses', requireAuth, async (req, res) => {
|
|
try {
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: req.user.id },
|
|
include: {
|
|
armyCategories: true,
|
|
bases: {
|
|
include: {
|
|
defenses: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const categoryLookup = new Map(
|
|
user.armyCategories.map((category) => [category.id, category.name])
|
|
);
|
|
|
|
const defenses = [];
|
|
const baseBuckets = new Map();
|
|
const categoryBuckets = new Map();
|
|
|
|
user.bases.forEach((base) => {
|
|
baseBuckets.set(base.id, {
|
|
title: base.title,
|
|
items: [],
|
|
categories: new Map(),
|
|
});
|
|
});
|
|
|
|
user.armyCategories.forEach((category) => {
|
|
categoryBuckets.set(category.id, {
|
|
name: category.name,
|
|
items: [],
|
|
bases: new Map(),
|
|
});
|
|
});
|
|
|
|
user.bases.forEach((base) => {
|
|
base.defenses.forEach((defense) => {
|
|
defenses.push({
|
|
id: defense.id,
|
|
stars: defense.stars,
|
|
percent: defense.percent,
|
|
trophies: defense.trophies,
|
|
armyCategoryId: defense.armyCategoryId,
|
|
createdAt: defense.createdAt,
|
|
baseId: base.id,
|
|
baseTitle: base.title,
|
|
});
|
|
|
|
const baseBucket = baseBuckets.get(base.id);
|
|
baseBucket.items.push(defense);
|
|
if (!baseBucket.categories.has(defense.armyCategoryId)) {
|
|
baseBucket.categories.set(defense.armyCategoryId, []);
|
|
}
|
|
baseBucket.categories.get(defense.armyCategoryId).push(defense);
|
|
|
|
if (!categoryBuckets.has(defense.armyCategoryId)) {
|
|
categoryBuckets.set(defense.armyCategoryId, {
|
|
name: categoryLookup.get(defense.armyCategoryId) || 'Unknown Army',
|
|
items: [],
|
|
bases: new Map(),
|
|
});
|
|
}
|
|
const categoryBucket = categoryBuckets.get(defense.armyCategoryId);
|
|
categoryBucket.items.push({ ...defense, baseId: base.id, baseTitle: base.title });
|
|
if (!categoryBucket.bases.has(base.id)) {
|
|
categoryBucket.bases.set(base.id, []);
|
|
}
|
|
categoryBucket.bases.get(base.id).push(defense);
|
|
});
|
|
});
|
|
|
|
defenses.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
|
|
|
const bases = Array.from(baseBuckets.entries()).map(([baseId, bucket]) => {
|
|
const summary = summarizeDefenses(bucket.items);
|
|
const categories = Array.from(bucket.categories.entries()).map(([categoryId, items]) => ({
|
|
categoryId,
|
|
name: categoryLookup.get(categoryId) || 'Unknown Army',
|
|
...summarizeDefenses(items),
|
|
}));
|
|
categories.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
|
return {
|
|
baseId,
|
|
title: bucket.title,
|
|
...summary,
|
|
categories,
|
|
};
|
|
});
|
|
bases.sort((a, b) => a.title.localeCompare(b.title));
|
|
|
|
const categories = Array.from(categoryBuckets.entries()).map(([categoryId, bucket]) => {
|
|
const summary = summarizeDefenses(bucket.items);
|
|
const baseSummaries = Array.from(bucket.bases.entries()).map(([baseId, items]) => ({
|
|
baseId,
|
|
title: baseBuckets.get(baseId)?.title || 'Unknown Base',
|
|
...summarizeDefenses(items),
|
|
}));
|
|
baseSummaries.sort((a, b) => b.count - a.count || a.title.localeCompare(b.title));
|
|
return {
|
|
categoryId,
|
|
name: bucket.name,
|
|
...summary,
|
|
bases: baseSummaries,
|
|
};
|
|
});
|
|
categories.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
res.json({
|
|
overall: summarizeDefenses(defenses),
|
|
categories,
|
|
bases,
|
|
defenses,
|
|
});
|
|
} catch (error) {
|
|
console.error(error);
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
app.get('/profiles', requireAuth, async (req, res) => {
|
|
try {
|
|
const searchTerm = (req.query.search ?? '').toString().trim();
|
|
const users = await prisma.user.findMany({
|
|
where: searchTerm
|
|
? {
|
|
username: {
|
|
contains: searchTerm,
|
|
mode: 'insensitive',
|
|
},
|
|
}
|
|
: undefined,
|
|
orderBy: { username: 'asc' },
|
|
take: 25,
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
createdAt: true,
|
|
bases: {
|
|
where: { isPrivate: false },
|
|
select: {
|
|
id: true,
|
|
_count: { select: { defenses: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const profiles = users.map((user) => {
|
|
const publicBaseCount = user.bases.length;
|
|
const publicDefenseCount = user.bases.reduce((sum, base) => sum + base._count.defenses, 0);
|
|
return {
|
|
id: user.id,
|
|
username: user.username,
|
|
createdAt: user.createdAt,
|
|
publicBaseCount,
|
|
publicDefenseCount,
|
|
};
|
|
});
|
|
|
|
res.json({ profiles });
|
|
} catch (error) {
|
|
console.error(error);
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
app.get('/profiles/:username', requireAuth, async (req, res) => {
|
|
try {
|
|
const usernameParam = req.params.username.toLowerCase();
|
|
const profileUser = await prisma.user.findUnique({
|
|
where: { username: usernameParam },
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
createdAt: true,
|
|
},
|
|
});
|
|
|
|
if (!profileUser) {
|
|
return res.status(404).json({ error: 'Profile not found' });
|
|
}
|
|
|
|
const isOwner = profileUser.id === req.user.id;
|
|
|
|
const bases = await prisma.base.findMany({
|
|
where: {
|
|
userId: profileUser.id,
|
|
...(isOwner ? {} : { isPrivate: false }),
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
include: {
|
|
defenses: {
|
|
orderBy: { createdAt: 'desc' },
|
|
include: {
|
|
armyCategory: {
|
|
select: { id: true, name: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const serializedBases = bases.map((base) => {
|
|
const defenses = base.defenses.map((defense) => ({
|
|
id: defense.id,
|
|
stars: defense.stars,
|
|
percent: defense.percent,
|
|
trophies: defense.trophies,
|
|
createdAt: defense.createdAt,
|
|
armyCategoryId: defense.armyCategoryId,
|
|
armyCategoryName: defense.armyCategory?.name || 'Unknown Army',
|
|
}));
|
|
return {
|
|
id: base.id,
|
|
title: base.title,
|
|
description: base.description || '',
|
|
url: base.url || '',
|
|
imageUrl: buildImageUrl(base),
|
|
isPrivate: base.isPrivate,
|
|
createdAt: base.createdAt,
|
|
summary: summarizeDefenses(defenses),
|
|
defenses,
|
|
};
|
|
});
|
|
|
|
const allVisibleDefenses = serializedBases.flatMap((base) => base.defenses);
|
|
const overallSummary = summarizeDefenses(allVisibleDefenses);
|
|
|
|
res.json({
|
|
profile: {
|
|
id: profileUser.id,
|
|
username: profileUser.username,
|
|
createdAt: profileUser.createdAt,
|
|
isOwner,
|
|
visibleBaseCount: serializedBases.length,
|
|
defenseCount: allVisibleDefenses.length,
|
|
summary: overallSummary,
|
|
},
|
|
bases: serializedBases,
|
|
});
|
|
} catch (error) {
|
|
console.error(error);
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
function summarizeDefenses(defenses) {
|
|
if (!defenses.length) {
|
|
return { count: 0, averageStars: 0, averagePercent: 0, averageTrophies: 0 };
|
|
}
|
|
const totalStars = defenses.reduce((sum, defense) => sum + Number(defense.stars || 0), 0);
|
|
const totalPercent = defenses.reduce((sum, defense) => sum + Number(defense.percent || 0), 0);
|
|
const totalTrophies = defenses.reduce((sum, defense) => sum + Number(defense.trophies || 0), 0);
|
|
const count = defenses.length;
|
|
return {
|
|
count,
|
|
averageStars: Number((totalStars / count).toFixed(2)),
|
|
averagePercent: Number((totalPercent / count).toFixed(2)),
|
|
averageTrophies: Number((totalTrophies / count).toFixed(2)),
|
|
};
|
|
}
|
|
|
|
async function deleteImageFile(imagePath) {
|
|
if (!imagePath) return;
|
|
const filePath = path.isAbsolute(imagePath) ? imagePath : path.join(uploadDir, imagePath);
|
|
try {
|
|
await fsPromises.unlink(filePath);
|
|
} catch (error) {
|
|
if (error.code !== 'ENOENT') {
|
|
console.error(`Failed to delete image file ${filePath}`, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
function parseBoolean(value) {
|
|
if (typeof value === 'boolean') {
|
|
return value;
|
|
}
|
|
if (typeof value === 'number') {
|
|
return value !== 0;
|
|
}
|
|
if (typeof value === 'string') {
|
|
const normalized = value.trim().toLowerCase();
|
|
return ['true', '1', 'yes', 'on'].includes(normalized);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function sanitizeUser(user) {
|
|
return {
|
|
id: user.id,
|
|
username: user.username,
|
|
createdAt: user.createdAt,
|
|
};
|
|
}
|
|
|
|
function buildImageUrl(base) {
|
|
if (base.imageUrl) return base.imageUrl;
|
|
if (base.imagePath) return `/uploads/${base.imagePath}`;
|
|
return '';
|
|
}
|
|
|
|
function isValidUrl(value) {
|
|
try {
|
|
const url = new URL(value);
|
|
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
} catch (_err) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
app.listen(port, host, () => {
|
|
console.log(`Backend listening on http://${host}:${port}`);
|
|
});
|