Files
johnruina/server/src/index.ts
T
2026-07-14 22:32:35 -04:00

1258 lines
53 KiB
TypeScript

import express from "express";
import cors from "cors";
import multer from "multer";
import { OAuth2Client } from "google-auth-library";
const googleClient = new OAuth2Client(process.env.GOOGLE_CLIENT_ID);
import { PrismaClient } from "@prisma/client";
import path from "path";
import fs from "fs";
import crypto from "crypto";
const app = express();
const prisma = new PrismaClient();
type RatingCacheEntry = { id: number; ratingTypeId: number; name: string; picture: string; description: string; icon: string; attachments: string; tags: { id: number; name: string; color: string }[] };
const cache = {
ratings: new Map<number, RatingCacheEntry>(),
ratingTypes: new Map<number, { id: number; name: string; hasAttachments: boolean; categories: { id: number; name: string }[] }>(),
ratingTypeNameToId: new Map<string, number>(),
userVotes: new Map<number, Map<number, Map<number, number>>>(),
anonVotes: new Map<string, Map<number, Map<number, number>>>(),
userStars: new Map<number, Map<number, Set<number>>>(),
catRanks: new Map<number, Map<number, number>>(),
totalInType: new Map<number, number>(),
tags: [] as { id: number; name: string; color: string; ratingTypeId: number }[],
polls: [] as { id: number; name: string; description: string; image: string; completed: boolean; resultEntryId: number | null; categoryId: number | null; entries: { id: number; pollId: number; option: string; amount: number; supporters: number }[]; tags: { pollId: number; tagId: number; tag: { id: number; name: string; color: string; image: string } }[] }[],
pollCategories: [] as { id: number; name: string }[],
pollTags: [] as { id: number; name: string; color: string; image: string }[],
siteUpdates: [] as { id: number; title: string; text: string; createdAt: Date }[],
userPollVotes: new Map<number, Map<number, { id: number; amount: number }>>(),
anonPollVotes: new Map<string, Map<number, { id: number; amount: number }>>(),
};
function getAvgAndTotal(ratingId: number, categoryId: number) {
let sum = 0, count = 0;
for (const perUser of cache.userVotes.values()) {
const score = perUser.get(ratingId)?.get(categoryId);
if (score) { sum += score; count++; }
}
for (const perIp of cache.anonVotes.values()) {
const score = perIp.get(ratingId)?.get(categoryId);
if (score) { sum += score; count++; }
}
return { average: count > 0 ? sum / count : 0, totalVotes: count };
}
function getStarCount(ratingId: number, categoryId: number) {
let count = 0;
for (const perUser of cache.userStars.values()) {
if (perUser.get(ratingId)?.has(categoryId)) count++;
}
return count;
}
function isUserStarred(userId: number, ratingId: number, categoryId: number) {
return cache.userStars.get(userId)?.get(ratingId)?.has(categoryId) ?? false;
}
function getUserVotesForRating(userId: number, ratingId: number): Record<number, number> {
const result: Record<number, number> = {};
const perRating = cache.userVotes.get(userId)?.get(ratingId);
if (perRating) {
for (const [catId, score] of perRating) result[catId] = score;
}
return result;
}
async function rebuildCache() {
await flushPendingOps();
const [ratings, ratingTypes, allVotes, anonVotes, allStars, tags, polls, pollCategories, pollTags, siteUpdates, userPollVotes, anonPollVotes] = await Promise.all([
prisma.rating.findMany({ include: { tags: { include: { tag: true } } } }),
prisma.ratingType.findMany({ include: { categories: true } }),
prisma.userRating.findMany(),
prisma.anonymousRatingVote.findMany(),
prisma.ratingStar.findMany(),
prisma.tag.findMany({ orderBy: { name: "asc" } }),
prisma.poll.findMany({ include: { entries: { orderBy: { id: "asc" } }, tags: { include: { tag: true } } }, orderBy: { id: "asc" } }),
prisma.pollCategory.findMany({ orderBy: { id: "asc" } }),
prisma.pollTag.findMany({ orderBy: { name: "asc" } }),
prisma.siteUpdate.findMany({ orderBy: { createdAt: "desc" } }),
prisma.userPoll.findMany(),
prisma.anonymousPollVote.findMany(),
]);
cache.ratings.clear();
for (const r of ratings) cache.ratings.set(r.id, { ...r, tags: r.tags.map(rt => ({ id: rt.tag.id, name: rt.tag.name, color: rt.tag.color })) });
cache.ratingTypes.clear();
cache.ratingTypeNameToId.clear();
for (const t of ratingTypes) { cache.ratingTypes.set(t.id, t); cache.ratingTypeNameToId.set(t.name, t.id); }
cache.userVotes.clear();
for (const v of allVotes) {
let u = cache.userVotes.get(v.userId); if (!u) { u = new Map(); cache.userVotes.set(v.userId, u); }
let r = u.get(v.ratingId); if (!r) { r = new Map(); u.set(v.ratingId, r); }
r.set(v.ratingCategoryId, v.score);
}
cache.anonVotes.clear();
for (const v of anonVotes) {
let a = cache.anonVotes.get(v.ip); if (!a) { a = new Map(); cache.anonVotes.set(v.ip, a); }
let r = a.get(v.ratingId); if (!r) { r = new Map(); a.set(v.ratingId, r); }
r.set(v.ratingCategoryId, v.score);
}
cache.userStars.clear();
for (const s of allStars) {
let u = cache.userStars.get(s.userId); if (!u) { u = new Map(); cache.userStars.set(s.userId, u); }
let r = u.get(s.ratingId); if (!r) { r = new Set(); u.set(s.ratingId, r); }
r.add(s.ratingCategoryId);
}
cache.totalInType.clear();
for (const r of cache.ratings.values()) {
cache.totalInType.set(r.ratingTypeId, (cache.totalInType.get(r.ratingTypeId) ?? 0) + 1);
}
cache.catRanks.clear();
for (const [, rtype] of cache.ratingTypes) {
for (const cat of rtype.categories) {
const catRatings = Array.from(cache.ratings.values()).filter(r => r.ratingTypeId === rtype.id);
catRatings.sort((a, b) => {
const aAvg = getAvgAndTotal(a.id, cat.id).average;
const bAvg = getAvgAndTotal(b.id, cat.id).average;
return bAvg - aAvg;
});
const rankMap = new Map<number, number>();
catRatings.forEach((r, i) => rankMap.set(r.id, i + 1));
cache.catRanks.set(cat.id, rankMap);
}
}
cache.tags = tags;
cache.polls = polls.map(p => ({
id: p.id, name: p.name, description: p.description, image: p.image, completed: p.completed, resultEntryId: p.resultEntryId, categoryId: p.categoryId,
entries: p.entries.map(e => ({ id: e.id, pollId: e.pollId, option: e.option, amount: e.amount, supporters: e.supporters })),
tags: p.tags.map(t => ({ pollId: t.pollId, tagId: t.tagId, tag: t.tag })),
}));
cache.pollCategories = pollCategories;
cache.pollTags = pollTags;
cache.siteUpdates = siteUpdates;
cache.userPollVotes.clear();
for (const v of userPollVotes) {
let m = cache.userPollVotes.get(v.userId);
if (!m) { m = new Map(); cache.userPollVotes.set(v.userId, m); }
m.set(v.pollEntryId, { id: v.id, amount: v.amount });
}
cache.anonPollVotes.clear();
for (const v of anonPollVotes) {
let m = cache.anonPollVotes.get(v.ip);
if (!m) { m = new Map(); cache.anonPollVotes.set(v.ip, m); }
m.set(v.pollEntryId, { id: v.id, amount: v.amount });
}
}
async function flushPendingOps() {
const userRatingData: { userId: number; ratingId: number; ratingCategoryId: number; score: number }[] = [];
for (const [userId, perRating] of cache.userVotes) {
for (const [ratingId, perCat] of perRating) {
for (const [catId, score] of perCat) userRatingData.push({ userId, ratingId, ratingCategoryId: catId, score });
}
}
const anonVoteData: { ip: string; ratingId: number; ratingCategoryId: number; score: number }[] = [];
for (const [ip, perRating] of cache.anonVotes) {
for (const [ratingId, perCat] of perRating) {
for (const [catId, score] of perCat) anonVoteData.push({ ip, ratingId, ratingCategoryId: catId, score });
}
}
const starData: { userId: number; ratingId: number; ratingCategoryId: number }[] = [];
for (const [userId, perRating] of cache.userStars) {
for (const [ratingId, cats] of perRating) {
for (const catId of cats) starData.push({ userId, ratingId, ratingCategoryId: catId });
}
}
await prisma.$transaction(async (tx) => {
await tx.userRating.deleteMany();
await tx.anonymousRatingVote.deleteMany();
await tx.ratingStar.deleteMany();
if (userRatingData.length) await tx.userRating.createMany({ data: userRatingData });
if (anonVoteData.length) await tx.anonymousRatingVote.createMany({ data: anonVoteData });
if (starData.length) await tx.ratingStar.createMany({ data: starData });
});
}
const uploadsDir = path.join(__dirname, "../uploads");
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
app.set("trust proxy", true);
app.use(cors());
app.use((_req, res, next) => { res.charset = "utf-8"; next(); });
app.use("/uploads", express.static(uploadsDir));
app.use(express.json({ limit: "100mb" }));
const upload = multer({ dest: uploadsDir, limits: { fileSize: 200 * 1024 * 1024 } });
const rateLimitStore = new Map<string, number[]>();
setInterval(() => {
const cutoff = Date.now() - 60000;
for (const [key, times] of rateLimitStore) {
const filtered = times.filter(t => t > cutoff);
if (filtered.length === 0) rateLimitStore.delete(key);
else rateLimitStore.set(key, filtered);
}
}, 60000);
app.use(async (req, res, next) => {
const rawUserId = req.body?.userId || (req.query?.userId as string) || undefined;
const userId = rawUserId ? Number(rawUserId) : undefined;
const sessionToken = (req.body?.sessionToken as string) || (req.query?.sessionToken as string) || getCookie(req, "sessionToken");
if (sessionToken) {
const session = await prisma.session.findUnique({ where: { token: sessionToken }, include: { user: true } }).catch(() => null);
if (session && session.expiresAt >= new Date() && session.user.email === "invoictoan@gmail.com") { next(); return; }
}
const key = userId ? String(userId) : getIp(req);
const now = Date.now();
const cutoff = now - 60000;
let times = rateLimitStore.get(key) || [];
times = times.filter(t => t > cutoff);
const limit = userId ? 120 : 60;
if (times.length >= limit) {
res.status(429).json({ error: "rate limit exceeded" });
return;
}
times.push(now);
rateLimitStore.set(key, times);
next();
});
async function saveImage(base64: string): Promise<string> {
if (!base64 || base64.startsWith("http") || base64.startsWith("/uploads")) return base64;
const match = base64.match(/^data:image\/(\w+);base64,(.+)$/);
if (!match) return base64;
const ext = match[1] === "jpeg" ? "jpg" : match[1];
const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
const filepath = path.join(uploadsDir, filename);
const buf = Buffer.from(match[2], "base64");
try {
const sharp = (await import("sharp")).default;
await sharp(buf).resize(256, 256, { fit: "cover", position: "center" }).toFile(filepath);
} catch {
fs.writeFileSync(filepath, match[2], "base64");
}
return `/uploads/${filename}`;
}
function getIp(req: express.Request): string {
return (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() || req.ip || "unknown";
}
function getCookie(req: express.Request, name: string): string | null {
const match = req.headers.cookie?.match(`(?:^|;\\s*)${name}=([^;]*)`);
return match ? decodeURIComponent(match[1]) : null;
}
async function requireAdmin(req: express.Request, res: express.Response): Promise<boolean> {
const token = (req.body?.sessionToken as string) || (req.query?.sessionToken as string);
if (!token) { res.status(401).json({ error: "unauthorized" }); return false; }
const session = await prisma.session.findUnique({ where: { token }, include: { user: true } });
if (!session || session.expiresAt < new Date()) {
if (session) await prisma.session.delete({ where: { id: session.id } }).catch(() => {});
res.status(401).json({ error: "session expired" }); return false;
}
if (session.user.email !== "invoictoan@gmail.com") {
res.status(403).json({ error: "forbidden" }); return false;
}
return true;
}
app.get("/api/health", (_req, res) => {
res.json({ status: "ok" });
});
app.get("/api/ip-count", async (req, res) => {
const ip = getIp(req);
const account = await prisma.ipAccount.findUnique({ where: { ip } });
res.json({ count: account?.count ?? 0 });
});
app.post("/api/google-login", async (req, res) => {
const { credential } = req.body;
if (!credential) { res.status(400).json({ error: "credential required" }); return; }
try {
const ticket = await googleClient.verifyIdToken({
idToken: credential,
audience: process.env.GOOGLE_CLIENT_ID,
});
const payload = ticket.getPayload();
if (!payload || !payload.email) { res.status(400).json({ error: "Invalid token" }); return; }
const email = payload.email;
let user = await prisma.user.findUnique({ where: { email } });
if (!user) {
user = await prisma.user.create({ data: { email } });
fetch("https://discord.com/api/webhooks/1525698155012952247/1Mo-j3U0_OPRsozpx656ZCpYfl-C2jeLW3gv0_FUUTeIby2FXHCAqYkvP7jAdeHBJeK3", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: `New account: ${email}` }),
}).catch(() => {});
const ip = getIp(req);
await prisma.ipAccount.upsert({
where: { ip },
create: { ip, count: 1 },
update: { count: { increment: 1 } },
});
}
const sessionToken = crypto.randomBytes(32).toString("hex");
await prisma.session.create({
data: { token: sessionToken, userId: user.id, expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) },
});
const resp: Record<string, unknown> = { id: user.id, email: user.email, sessionToken };
res.json(resp);
} catch (e) {
res.status(401).json({ error: "Invalid Google token" });
}
});
app.get("/api/session", async (req, res) => {
const token = req.query.sessionToken as string;
if (!token) { res.status(401).json({ error: "no session" }); return; }
const session = await prisma.session.findUnique({ where: { token }, include: { user: true } });
if (!session || session.expiresAt < new Date()) {
if (session) await prisma.session.delete({ where: { id: session.id } }).catch(() => {});
res.status(401).json({ error: "session expired" }); return;
}
res.json({ id: session.user.id, email: session.user.email });
});
app.delete("/api/session", async (req, res) => {
const token = req.query.sessionToken as string;
if (token) await prisma.session.delete({ where: { token } }).catch(() => {});
res.json({ ok: true });
});
app.get("/api/ratings", (req, res) => {
const userId = req.query.userId ? Number(req.query.userId) : undefined;
const type = req.query.type as string | undefined;
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const limit = Math.min(10000, Math.max(1, parseInt(req.query.limit as string) || 50));
const search = (req.query.search as string || "").toLowerCase();
const tagsParam = req.query.tags as string | undefined;
const selectedTagIds = tagsParam ? tagsParam.split(",").filter(Boolean).map(Number) : [];
const showVoted = req.query.showVoted !== "false";
const showUnvoted = req.query.showUnvoted !== "false";
const sortMode = (req.query.sortMode as string) || "default";
const selectedCatId = req.query.selectedCatId ? Number(req.query.selectedCatId) : undefined;
let items = Array.from(cache.ratings.values());
if (type) {
const resolvedTypeId = cache.ratingTypeNameToId.get(type);
if (resolvedTypeId !== undefined) items = items.filter(r => r.ratingTypeId === resolvedTypeId);
else items = [];
}
const availableTags: { id: number; name: string; color: string }[] = [];
const seen = new Set<number>();
for (const r of items) {
for (const t of r.tags || []) {
if (!seen.has(t.id)) { seen.add(t.id); availableTags.push(t); }
}
}
if (search) {
items = items.filter(r => r.name.toLowerCase().includes(search) || (r.description && r.description.toLowerCase().includes(search)));
}
if (selectedTagIds.length > 0) {
items = items.filter(r => selectedTagIds.every(tagId => r.tags?.some(t => t.id === tagId)));
}
const enriched = items.map(r => {
const rtype = cache.ratingTypes.get(r.ratingTypeId);
const categories = rtype?.categories ?? [];
const userVotes = userId ? getUserVotesForRating(userId, r.id) : {};
const ip = !userId ? getIp(req) : undefined;
const anonMap = ip ? cache.anonVotes.get(ip) : undefined;
const anonPerRating = anonMap?.get(r.id);
const totalInType = cache.totalInType.get(r.ratingTypeId) ?? 0;
let catData = categories.map(c => {
const { average, totalVotes } = getAvgAndTotal(r.id, c.id);
const rank = cache.catRanks.get(c.id)?.get(r.id) ?? 0;
return { id: c.id, name: c.name, average, totalVotes, userVote: userVotes[c.id] ?? anonPerRating?.get(c.id) ?? null, starCount: getStarCount(r.id, c.id), userStarred: userId ? isUserStarred(userId, r.id, c.id) : false, rank, totalInType };
});
if (catData.length === 0 && rtype?.hasAttachments) {
const firstCat = rtype.categories[0];
if (firstCat) {
const { average, totalVotes } = getAvgAndTotal(r.id, firstCat.id);
const rank = cache.catRanks.get(firstCat.id)?.get(r.id) ?? 0;
catData.push({ id: firstCat.id, name: firstCat.name, average, totalVotes, userVote: userVotes[firstCat.id] ?? anonPerRating?.get(firstCat.id) ?? null, starCount: getStarCount(r.id, firstCat.id), userStarred: userId ? isUserStarred(userId, r.id, firstCat.id) : false, rank, totalInType });
}
}
const totalVotes = catData.reduce((s, c) => s + c.totalVotes, 0);
const totalSum = catData.reduce((s, c) => s + c.average * c.totalVotes, 0);
return { ...r, categories: catData, average: totalVotes > 0 ? totalSum / totalVotes : 0, totalVotes, userVotes };
});
let afterVote = enriched;
if (!(showVoted && showUnvoted)) {
const ip = !userId ? getIp(req) : undefined;
const anonMap = ip ? cache.anonVotes.get(ip) : undefined;
afterVote = enriched.filter(r => {
let voted = false;
if (userId) {
const perUser = cache.userVotes.get(userId);
const perRating = perUser?.get(r.id);
voted = !!perRating && perRating.size > 0;
} else if (anonMap) {
voted = anonMap.has(r.id);
}
if (showVoted && voted) return true;
if (showUnvoted && !voted) return true;
return false;
});
}
const sorted = [...afterVote].sort((a, b) => {
if (sortMode === "stars") {
const aStars = selectedCatId ? a.categories.find(c => c.id === selectedCatId)?.starCount ?? 0 : a.categories.reduce((s, c) => s + c.starCount, 0);
const bStars = selectedCatId ? b.categories.find(c => c.id === selectedCatId)?.starCount ?? 0 : b.categories.reduce((s, c) => s + c.starCount, 0);
return bStars - aStars;
}
if (sortMode === "avg") return b.average - a.average;
if (selectedCatId && sortMode === "cat") {
const aCat = a.categories.find(c => c.id === selectedCatId);
const bCat = b.categories.find(c => c.id === selectedCatId);
return (bCat?.average ?? 0) - (aCat?.average ?? 0);
}
if (sortMode === "alpha") return a.name.localeCompare(b.name);
return 0;
});
const withRank = sorted.map((r, i) => ({ ...r, rank: i + 1 }));
const total = withRank.length;
const start = (page - 1) * limit;
res.json({ items: withRank.slice(start, start + limit), total, page, totalPages: Math.ceil(total / limit), availableTags });
});
app.get("/api/rating-detail/:ratingId", (req, res) => {
const ratingId = Number(req.params.ratingId);
const userId = req.query.userId ? Number(req.query.userId) : undefined;
const rating = cache.ratings.get(ratingId);
if (!rating) { res.status(404).json({ error: "not found" }); return; }
const type = cache.ratingTypes.get(rating.ratingTypeId);
const categories = type?.categories ?? [];
const userVotes = userId ? getUserVotesForRating(userId, ratingId) : {};
const totalInType = cache.totalInType.get(rating.ratingTypeId) ?? 0;
const catData = categories.map(c => {
const { average, totalVotes } = getAvgAndTotal(ratingId, c.id);
const rank = cache.catRanks.get(c.id)?.get(ratingId) ?? 0;
return { id: c.id, name: c.name, average, totalVotes, userVote: userVotes[c.id] ?? null, starCount: getStarCount(ratingId, c.id), userStarred: userId ? isUserStarred(userId, ratingId, c.id) : false, rank, totalInType };
});
if (catData.length === 0 && type?.hasAttachments) {
const firstCat = type.categories[0];
if (firstCat) {
const { average, totalVotes } = getAvgAndTotal(ratingId, firstCat.id);
const rank = cache.catRanks.get(firstCat.id)?.get(ratingId) ?? 0;
catData.push({ id: firstCat.id, name: firstCat.name, average, totalVotes, userVote: userVotes[firstCat.id] ?? null, starCount: getStarCount(ratingId, firstCat.id), userStarred: userId ? isUserStarred(userId, ratingId, firstCat.id) : false, rank, totalInType });
}
}
const totalVotes = catData.reduce((s, c) => s + c.totalVotes, 0);
const totalSum = catData.reduce((s, c) => s + c.average * c.totalVotes, 0);
res.json({ ...rating, categories: catData, average: totalVotes > 0 ? totalSum / totalVotes : 0, totalVotes, userVotes });
});
app.get("/api/rating-distribution/:ratingId", (req, res) => {
const ratingId = Number(req.params.ratingId);
const rating = cache.ratings.get(ratingId);
if (!rating) { res.status(404).json({ error: "not found" }); return; }
const type = cache.ratingTypes.get(rating.ratingTypeId);
const categories = type ? type.categories : [];
if (categories.length === 0 && type?.hasAttachments) {
const firstCat = type.categories[0];
if (firstCat) {
const accountRatings = Array(10).fill(0);
const anonRatings = Array(10).fill(0);
for (const perUser of cache.userVotes.values()) { const score = perUser.get(ratingId)?.get(firstCat.id); if (score) accountRatings[score - 1]++; }
for (const perIp of cache.anonVotes.values()) { const score = perIp.get(ratingId)?.get(firstCat.id); if (score) anonRatings[score - 1]++; }
const total = accountRatings.reduce((s, v) => s + v, 0) + anonRatings.reduce((s, v) => s + v, 0);
const sum = accountRatings.reduce((s, v, i) => s + v * (i + 1), 0) + anonRatings.reduce((s, v, i) => s + v * (i + 1), 0);
res.json({ categories: [{ id: firstCat.id, name: firstCat.name, accountRatings, anonRatings, average: total > 0 ? sum / total : 0, totalVotes: total }] });
return;
}
}
const catData = categories.map(c => {
const accountRatings = Array(10).fill(0);
const anonRatings = Array(10).fill(0);
for (const perUser of cache.userVotes.values()) { const score = perUser.get(ratingId)?.get(c.id); if (score) accountRatings[score - 1]++; }
for (const perIp of cache.anonVotes.values()) { const score = perIp.get(ratingId)?.get(c.id); if (score) anonRatings[score - 1]++; }
const total = accountRatings.reduce((s, v) => s + v, 0) + anonRatings.reduce((s, v) => s + v, 0);
const sum = accountRatings.reduce((s, v, i) => s + v * (i + 1), 0) + anonRatings.reduce((s, v, i) => s + v * (i + 1), 0);
return { id: c.id, name: c.name, accountRatings, anonRatings, average: total > 0 ? sum / total : 0, totalVotes: total };
});
res.json({ categories: catData });
});
app.post("/api/star", async (req, res) => {
const userId = Number(req.body.userId);
const ratingId = Number(req.body.ratingId);
const ratingCategoryId = req.body.ratingCategoryId ? Number(req.body.ratingCategoryId) : undefined;
if (!userId || !ratingId) { res.status(400).json({ error: "userId and ratingId required" }); return; }
const catId = ratingCategoryId;
if (!catId) { res.status(400).json({ error: "ratingCategoryId is required" }); return; }
let perUser = cache.userStars.get(userId);
if (!perUser) { perUser = new Map(); cache.userStars.set(userId, perUser); }
let cats = perUser.get(ratingId);
if (!cats) { cats = new Set(); perUser.set(ratingId, cats); }
if (cats.has(catId)) {
cats.delete(catId);
res.json({ starred: false });
} else {
let catCount = 0;
for (const sci of perUser.values()) { if (sci.has(catId)) catCount++; }
if (catCount >= 3) { res.status(400).json({ error: "Max 3 stars per category" }); return; }
cats.add(catId);
res.json({ starred: true });
}
});
app.get("/api/my-stars", (req, res) => {
const userId = req.query.userId ? Number(req.query.userId) : undefined;
if (!userId) { res.json([]); return; }
const perUser = cache.userStars.get(userId);
const map: Record<number, number[]> = {};
if (perUser) {
for (const [ratingId, cats] of perUser) map[ratingId] = Array.from(cats);
}
res.json(map);
});
app.post("/api/vote", async (req, res) => {
const userId = req.body.userId ? Number(req.body.userId) : undefined;
const ratingId = Number(req.body.ratingId);
let ratingCategoryId = req.body.ratingCategoryId ? Number(req.body.ratingCategoryId) : undefined;
const score = Number(req.body.score);
if (!ratingId || !score || score < 1 || score > 10) {
res.status(400).json({ error: "ratingId and score (1-10) are required" });
return;
}
if (!ratingCategoryId) {
res.status(400).json({ error: "ratingCategoryId is required" });
return;
}
if (userId) {
let u = cache.userVotes.get(userId); if (!u) { u = new Map(); cache.userVotes.set(userId, u); }
let r = u.get(ratingId); if (!r) { r = new Map(); u.set(ratingId, r); }
r.set(ratingCategoryId, score);
} else {
const ip = getIp(req);
let a = cache.anonVotes.get(ip); if (!a) { a = new Map(); cache.anonVotes.set(ip, a); }
let r = a.get(ratingId); if (!r) { r = new Map(); a.set(ratingId, r); }
r.set(ratingCategoryId, score);
}
res.json(getAvgAndTotal(ratingId, ratingCategoryId));
});
app.post("/api/unvote", async (req, res) => {
const userId = req.body.userId ? Number(req.body.userId) : undefined;
const ratingId = Number(req.body.ratingId);
const ratingCategoryId = Number(req.body.ratingCategoryId);
if (!ratingId || !ratingCategoryId) {
res.status(400).json({ error: "ratingId and ratingCategoryId are required" });
return;
}
if (userId) {
cache.userVotes.get(userId)?.get(ratingId)?.delete(ratingCategoryId);
} else {
const ip = getIp(req);
cache.anonVotes.get(ip)?.get(ratingId)?.delete(ratingCategoryId);
}
res.json(getAvgAndTotal(ratingId, ratingCategoryId));
});
app.get("/api/anonymous-votes/:ratingId", (req, res) => {
const ratingId = Number(req.params.ratingId);
const ip = getIp(req);
const perIp = cache.anonVotes.get(ip);
const map: Record<string, number> = {};
if (perIp) {
const perRating = perIp.get(ratingId);
if (perRating) { for (const [c, s] of perRating) map[c] = s; }
}
if (Object.keys(map).length > 0) {
const rating = cache.ratings.get(ratingId);
if (rating) {
const rtype = cache.ratingTypes.get(rating.ratingTypeId);
if (rtype?.hasAttachments && !map.score) {
map.score = Object.values(map)[0];
}
}
}
res.json(map);
});
app.get("/api/my-anonymous-votes", (req, res) => {
const ip = getIp(req);
const perIp = cache.anonVotes.get(ip);
const map: Record<string, number> = {};
if (perIp) {
for (const [ratingId, cats] of perIp) {
for (const [catId, score] of cats) map[`${ratingId}_${catId}`] = score;
}
}
res.json(map);
});
app.post("/api/reports", async (req, res) => {
const userId = req.body.userId ? Number(req.body.userId) : undefined;
const { subject, text } = req.body;
if (!userId || !subject || !text) {
res.status(400).json({ error: "userId, subject, and text are required" });
return;
}
const ip = (req.headers["x-forwarded-for"] as string || req.socket.remoteAddress || "unknown").split(",")[0].trim();
const last = await prisma.report.findFirst({
where: { userId },
orderBy: { createdAt: "desc" },
});
if (last && Date.now() - last.createdAt.getTime() < 60000) {
res.status(429).json({ error: "wait 60s before submitting another report" });
return;
}
const report = await prisma.report.create({ data: { userId, subject, text } });
res.json(report);
});
app.post("/api/admin/rebuild-cache", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
await rebuildCache();
res.json({ ok: true });
});
app.get("/api/admin/reports", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const reports = await prisma.report.findMany({
include: { user: { select: { email: true } } },
orderBy: { createdAt: "desc" },
});
res.json(reports);
});
app.delete("/api/admin/reports/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const id = Number(req.params.id);
await prisma.report.delete({ where: { id } });
res.json({ success: true });
});
app.post("/api/admin/ratings", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const ratingTypeId = Number(req.body.ratingTypeId);
const { name, description, picture, icon, attachments } = req.body;
if (!ratingTypeId || !name) {
res.status(400).json({ error: "ratingTypeId and name are required" });
return;
}
const rating = await prisma.rating.create({ data: { ratingTypeId, name, description: description ?? "", picture: await saveImage(picture ?? ""), icon: await saveImage(icon ?? ""), attachments: await saveFile(attachments ?? "") } });
res.json(rating);
});
async function saveFile(base64: string): Promise<string> {
if (!base64 || base64.startsWith("http") || base64.startsWith("/uploads")) return base64;
const match = base64.match(/^data:(audio\/\w+);base64,(.+)$/);
if (!match) return base64;
const ext = match[1].split("/")[1];
const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
const filepath = path.join(uploadsDir, filename);
fs.writeFileSync(filepath, match[2], "base64");
return `/uploads/${filename}`;
}
app.put("/api/admin/ratings/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const id = Number(req.params.id);
const { name, description, picture, icon, attachments } = req.body;
const data: Record<string, unknown> = {};
if (name !== undefined) data.name = name;
if (description !== undefined) data.description = description;
if (picture !== undefined) data.picture = await saveImage(picture);
if (icon !== undefined) data.icon = await saveImage(icon);
if (attachments !== undefined) data.attachments = await saveFile(attachments);
const rating = await prisma.rating.update({ where: { id }, data });
res.json(rating);
});
app.delete("/api/admin/ratings/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const id = Number(req.params.id);
await prisma.userRating.deleteMany({ where: { ratingId: id } });
await prisma.anonymousRatingVote.deleteMany({ where: { ratingId: id } });
await prisma.ratingStar.deleteMany({ where: { ratingId: id } });
await prisma.ratingTag.deleteMany({ where: { ratingId: id } });
await prisma.rating.delete({ where: { id } });
res.json({ success: true });
});
app.get("/api/rating-types", (_req, res) => {
res.json(Array.from(cache.ratingTypes.values()));
});
app.post("/api/admin/rating-types", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { name } = req.body;
if (!name) { res.status(400).json({ error: "name is required" }); return; }
const max = await prisma.ratingType.aggregate({ _max: { sortOrder: true } });
const t = await prisma.ratingType.upsert({ where: { name }, create: { name, sortOrder: (max._max.sortOrder ?? 0) + 1 }, update: {} });
res.json(t);
});
app.put("/api/admin/rating-types/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const id = Number(req.params.id);
const { name, hasAttachments } = req.body;
const data: Record<string, unknown> = {};
if (hasAttachments !== undefined) data.hasAttachments = hasAttachments;
if (name !== undefined) {
data.name = name;
}
const t = await prisma.ratingType.update({ where: { id }, data, include: { categories: true } });
res.json(t);
});
app.delete("/api/admin/rating-types/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const id = Number(req.params.id);
const catIds = (await prisma.ratingCategory.findMany({ where: { ratingTypeId: id }, select: { id: true } })).map(c => c.id);
const ratingIds = (await prisma.rating.findMany({ where: { ratingTypeId: id }, select: { id: true } })).map(r => r.id);
if (catIds.length) {
await prisma.userRating.deleteMany({ where: { ratingCategoryId: { in: catIds } } });
await prisma.anonymousRatingVote.deleteMany({ where: { ratingCategoryId: { in: catIds } } });
await prisma.ratingStar.deleteMany({ where: { ratingCategoryId: { in: catIds } } });
}
if (ratingIds.length) {
await prisma.ratingTag.deleteMany({ where: { ratingId: { in: ratingIds } } });
}
await prisma.ratingCategory.deleteMany({ where: { ratingTypeId: id } });
await prisma.rating.deleteMany({ where: { ratingTypeId: id } });
await prisma.ratingType.delete({ where: { id } });
res.json({ success: true });
});
app.put("/api/admin/rating-types/reorder", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { ids }: { ids: number[] } = req.body;
if (!ids || !Array.isArray(ids)) { res.status(400).json({ error: "ids array is required" }); return; }
for (let i = 0; i < ids.length; i++) {
await prisma.ratingType.update({ where: { id: ids[i] }, data: { sortOrder: i } });
}
res.json({ success: true });
});
app.post("/api/admin/rating-types/:id/categories", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const id = Number(req.params.id);
const { name } = req.body;
if (!name) { res.status(400).json({ error: "name is required" }); return; }
const cat = await prisma.ratingCategory.create({ data: { ratingTypeId: id, name } });
res.json(cat);
});
app.delete("/api/admin/rating-types/:typeId/categories/:categoryId", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const categoryId = Number(req.params.categoryId);
await prisma.userRating.deleteMany({ where: { ratingCategoryId: categoryId } });
await prisma.anonymousRatingVote.deleteMany({ where: { ratingCategoryId: categoryId } });
await prisma.ratingStar.deleteMany({ where: { ratingCategoryId: categoryId } });
await prisma.ratingCategory.delete({ where: { id: categoryId } });
res.json({ success: true });
});
app.put("/api/admin/rating-types/:typeId/categories/:categoryId", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const categoryId = Number(req.params.categoryId);
const { name } = req.body;
if (!name) { res.status(400).json({ error: "name is required" }); return; }
const cat = await prisma.ratingCategory.update({ where: { id: categoryId }, data: { name } });
res.json(cat);
});
app.get("/api/tags", (_req, res) => {
res.json(cache.tags);
});
app.post("/api/admin/tags", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { name, color } = req.body;
const ratingTypeId = Number(req.body.ratingTypeId);
if (!name || !color || !ratingTypeId) { res.status(400).json({ error: "name, color, and ratingTypeId are required" }); return; }
const tag = await prisma.tag.create({ data: { name, color, ratingTypeId } });
res.json(tag);
});
app.put("/api/admin/tags/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const id = Number(req.params.id);
const { name, color } = req.body;
const ratingTypeId = Number(req.body.ratingTypeId);
if (!name || !color || !ratingTypeId) { res.status(400).json({ error: "name, color, and ratingTypeId are required" }); return; }
const tag = await prisma.tag.update({ where: { id }, data: { name, color, ratingTypeId } });
res.json(tag);
});
app.delete("/api/admin/tags/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const id = Number(req.params.id);
await prisma.ratingTag.deleteMany({ where: { tagId: id } });
await prisma.tag.delete({ where: { id } });
res.json({ success: true });
});
app.get("/api/tags-by-type/:ratingTypeId", (req, res) => {
const ratingTypeId = Number(req.params.ratingTypeId);
res.json(cache.tags.filter(t => t.ratingTypeId === ratingTypeId));
});
app.post("/api/admin/ratings/:id/tags", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const id = Number(req.params.id);
const tagId = Number(req.body.tagId);
if (!tagId) { res.status(400).json({ error: "tagId is required" }); return; }
await prisma.ratingTag.upsert({
where: { ratingId_tagId: { ratingId: id, tagId } },
create: { ratingId: id, tagId },
update: {},
});
res.json({ success: true });
});
app.delete("/api/admin/ratings/:id/tags/:tagId", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const id = Number(req.params.id);
const tagId = Number(req.params.tagId);
await prisma.ratingTag.delete({ where: { ratingId_tagId: { ratingId: id, tagId } } });
res.json({ success: true });
});
app.post("/api/admin/upload-attachments", async (req, res, next) => { if (!(await requireAdmin(req, res))) return; next(); }, upload.array("files"), async (req, res) => {
const files = req.files as Express.Multer.File[];
const typeName = req.body.type || "music";
if (!files || !files.length) { res.status(400).json({ error: "files are required" }); return; }
let ratingType = await prisma.ratingType.findFirst({ where: { name: typeName }, include: { categories: true } });
if (!ratingType) {
ratingType = await prisma.ratingType.create({ data: { name: typeName, hasAttachments: true }, include: { categories: true } });
} else if (!ratingType.hasAttachments) {
ratingType = await prisma.ratingType.update({ where: { id: ratingType.id }, data: { hasAttachments: true }, include: { categories: true } });
}
if (!ratingType.categories.length) {
await prisma.ratingCategory.create({ data: { ratingTypeId: ratingType.id, name: "Score" } });
}
const created: { id: number; name: string }[] = [];
for (const f of files) {
const name = f.originalname.replace(/\.[^.]+$/, "");
const ext = path.extname(f.originalname);
const safeName = `${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`;
const destPath = path.join(uploadsDir, safeName);
fs.renameSync(f.path, destPath);
const attachments = `/uploads/${safeName}`;
const rating = await prisma.rating.create({ data: { ratingTypeId: ratingType.id, name, description: "", picture: "", icon: "", attachments } });
created.push({ id: rating.id, name });
}
res.json(created);
});
app.delete("/api/admin/music/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const id = Number(req.params.id);
await prisma.userRating.deleteMany({ where: { ratingId: id } });
await prisma.anonymousRatingVote.deleteMany({ where: { ratingId: id } });
await prisma.ratingTag.deleteMany({ where: { ratingId: id } });
await prisma.rating.delete({ where: { id } });
res.json({ success: true });
});
app.get("/api/polls", (req, res) => {
const userId = req.query.userId ? Number(req.query.userId) : undefined;
const ip = getIp(req);
const anonVotes = cache.anonPollVotes.get(ip);
const polls = cache.polls.map(p => ({
...p,
entries: p.entries.map(e => ({
...e,
...(userId ? { userPolls: (() => { const v = cache.userPollVotes.get(userId)?.get(e.id); return v ? [v] : []; })() } : {}),
...(anonVotes?.has(e.id) ? { anonPollVotes: [anonVotes.get(e.id)!] } : {}),
})),
}));
res.json(polls);
});
app.get("/api/poll-categories", (req, res) => {
const userId = req.query.userId ? Number(req.query.userId) : undefined;
const ip = getIp(req);
const anonVotes = cache.anonPollVotes.get(ip);
const cats = cache.pollCategories.map(pc => ({
...pc,
polls: cache.polls.filter(p => p.categoryId === pc.id).map(p => ({
...p,
entries: p.entries.map(e => ({
...e,
...(userId ? { userPolls: (() => { const v = cache.userPollVotes.get(userId)?.get(e.id); return v ? [v] : []; })() } : {}),
...(anonVotes?.has(e.id) ? { anonPollVotes: [anonVotes.get(e.id)!] } : {}),
})),
})),
}));
res.json(cats);
});
app.post("/api/poll", async (req, res) => {
const pollEntryId = Number(req.body.pollEntryId);
const amount = Number(req.body.amount);
if (!pollEntryId || !amount || amount <= 0) {
res.status(400).json({ error: "pollEntryId and positive amount are required" });
return;
}
const cacheEntry = cache.polls.flatMap(p => p.entries).find(e => e.id === pollEntryId);
if (!cacheEntry) {
res.status(404).json({ error: "Poll entry not found" });
return;
}
const poll = cache.polls.find(p => p.entries.some(e => e.id === pollEntryId));
if (poll?.completed) {
res.status(400).json({ error: "This poll is already completed" });
return;
}
const userId = req.body.userId ? Number(req.body.userId) : undefined;
if (userId) {
let existingOnThis = cache.userPollVotes.get(userId)?.get(pollEntryId);
if (!existingOnThis) {
const dbExisting = await prisma.userPoll.findUnique({ where: { userId_pollEntryId: { userId, pollEntryId } } }).catch(() => null);
if (dbExisting) existingOnThis = { id: dbExisting.id, amount: dbExisting.amount };
}
if (existingOnThis) {
await prisma.userPoll.delete({ where: { id: existingOnThis.id } }).catch(() => {});
await prisma.pollEntry.update({ where: { id: pollEntryId }, data: { amount: { decrement: existingOnThis.amount }, supporters: { decrement: 1 } } }).catch(() => {});
cacheEntry.amount -= existingOnThis.amount;
cacheEntry.supporters -= 1;
cache.userPollVotes.get(userId)?.delete(pollEntryId);
res.json({ success: true, action: "unvoted" });
return;
}
const entryPollId = poll?.id;
if (entryPollId) {
const existingInPollEntries = cache.polls.find(p => p.id === entryPollId)?.entries ?? [];
for (const e of existingInPollEntries) {
const existingInPoll = cache.userPollVotes.get(userId)?.get(e.id);
if (existingInPoll) {
await prisma.userPoll.delete({ where: { id: existingInPoll.id } }).catch(() => {});
await prisma.pollEntry.update({ where: { id: e.id }, data: { amount: { decrement: existingInPoll.amount }, supporters: { decrement: 1 } } }).catch(() => {});
e.amount -= existingInPoll.amount;
e.supporters -= 1;
cache.userPollVotes.get(userId)?.delete(e.id);
break;
}
}
}
const newVote = await prisma.userPoll.create({ data: { userId, pollEntryId, amount } }).catch(() => null);
if (!newVote) { res.status(400).json({ error: "Could not record vote" }); return; }
await prisma.pollEntry.update({ where: { id: pollEntryId }, data: { amount: { increment: amount }, supporters: { increment: 1 } } });
cacheEntry.amount += amount;
cacheEntry.supporters += 1;
let userMap = cache.userPollVotes.get(userId);
if (!userMap) { userMap = new Map(); cache.userPollVotes.set(userId, userMap); }
userMap.set(pollEntryId, { id: newVote.id, amount });
res.json({ success: true, action: "voted" });
} else {
const ip = getIp(req);
let existingOnThis = cache.anonPollVotes.get(ip)?.get(pollEntryId);
if (!existingOnThis) {
const dbExisting = await prisma.anonymousPollVote.findUnique({ where: { ip_pollEntryId: { ip, pollEntryId } } }).catch(() => null);
if (dbExisting) existingOnThis = { id: dbExisting.id, amount: dbExisting.amount };
}
if (existingOnThis) {
await prisma.anonymousPollVote.delete({ where: { id: existingOnThis.id } }).catch(() => {});
await prisma.pollEntry.update({ where: { id: pollEntryId }, data: { amount: { decrement: existingOnThis.amount }, supporters: { decrement: 1 } } }).catch(() => {});
cacheEntry.amount -= existingOnThis.amount;
cacheEntry.supporters -= 1;
cache.anonPollVotes.get(ip)?.delete(pollEntryId);
res.json({ success: true, action: "unvoted" });
return;
}
const entryPollId = poll?.id;
if (entryPollId) {
const existingInPollEntries = cache.polls.find(p => p.id === entryPollId)?.entries ?? [];
for (const e of existingInPollEntries) {
const existingInPoll = cache.anonPollVotes.get(ip)?.get(e.id);
if (existingInPoll) {
await prisma.anonymousPollVote.delete({ where: { id: existingInPoll.id } }).catch(() => {});
await prisma.pollEntry.update({ where: { id: e.id }, data: { amount: { decrement: existingInPoll.amount }, supporters: { decrement: 1 } } }).catch(() => {});
e.amount -= existingInPoll.amount;
e.supporters -= 1;
cache.anonPollVotes.get(ip)?.delete(e.id);
break;
}
}
}
const newVote = await prisma.anonymousPollVote.create({ data: { ip, pollEntryId, amount } }).catch(() => null);
if (!newVote) { res.status(400).json({ error: "Could not record vote" }); return; }
await prisma.pollEntry.update({ where: { id: pollEntryId }, data: { amount: { increment: amount }, supporters: { increment: 1 } } });
cacheEntry.amount += amount;
cacheEntry.supporters += 1;
let ipMap = cache.anonPollVotes.get(ip);
if (!ipMap) { ipMap = new Map(); cache.anonPollVotes.set(ip, ipMap); }
ipMap.set(pollEntryId, { id: newVote.id, amount });
res.json({ success: true, action: "voted" });
}
});
app.get("/api/my-anonymous-polls", (req, res) => {
const ip = getIp(req);
const perIp = cache.anonPollVotes.get(ip);
const map: Record<number, number> = {};
if (perIp) {
for (const [pollEntryId, v] of perIp) {
map[pollEntryId] = v.id;
}
}
res.json(map);
});
app.post("/api/admin/polls", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { name, description, image, categoryId } = req.body;
if (!name) { res.status(400).json({ error: "name is required" }); return; }
const poll = await prisma.poll.create({ data: { name, description: description ?? "", image: await saveImage(image ?? ""), categoryId: categoryId ? Number(categoryId) : null } });
res.json(poll);
});
app.put("/api/admin/polls/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const id = Number(req.params.id);
const { name, description, image, categoryId } = req.body;
const data: Record<string, unknown> = {};
if (name !== undefined) data.name = name;
if (description !== undefined) data.description = description;
if (image !== undefined) data.image = await saveImage(image);
if (categoryId !== undefined) data.categoryId = categoryId ? Number(categoryId) : null;
const poll = await prisma.poll.update({ where: { id }, data });
res.json(poll);
});
app.delete("/api/admin/polls/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const id = Number(req.params.id);
await prisma.pollToTag.deleteMany({ where: { pollId: id } });
const entryIds = (await prisma.pollEntry.findMany({ where: { pollId: id }, select: { id: true } })).map(e => e.id);
await prisma.anonymousPollVote.deleteMany({ where: { pollEntryId: { in: entryIds } } });
await prisma.userPoll.deleteMany({ where: { pollEntryId: { in: entryIds } } });
await prisma.pollEntry.deleteMany({ where: { pollId: id } });
await prisma.poll.delete({ where: { id } });
res.json({ success: true });
});
app.get("/api/admin/poll-categories", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
res.json(cache.pollCategories);
});
app.post("/api/admin/poll-categories", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { name } = req.body;
if (!name) { res.status(400).json({ error: "name is required" }); return; }
const cat = await prisma.pollCategory.upsert({ where: { name }, create: { name }, update: {} });
res.json(cat);
});
app.put("/api/admin/poll-categories/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const id = Number(req.params.id);
const { name } = req.body;
if (!name) { res.status(400).json({ error: "name is required" }); return; }
const cat = await prisma.pollCategory.update({ where: { id }, data: { name } });
res.json(cat);
});
app.delete("/api/admin/poll-categories/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const id = Number(req.params.id);
const polls = await prisma.poll.findMany({ where: { categoryId: id } });
const pollIds = polls.map(p => p.id);
const entryIds = (await prisma.pollEntry.findMany({ where: { pollId: { in: pollIds } }, select: { id: true } })).map(e => e.id);
await prisma.pollToTag.deleteMany({ where: { pollId: { in: pollIds } } });
await prisma.anonymousPollVote.deleteMany({ where: { pollEntryId: { in: entryIds } } });
await prisma.userPoll.deleteMany({ where: { pollEntryId: { in: entryIds } } });
await prisma.pollEntry.deleteMany({ where: { pollId: { in: pollIds } } });
await prisma.poll.deleteMany({ where: { categoryId: id } });
await prisma.pollCategory.delete({ where: { id } });
res.json({ success: true });
});
app.put("/api/admin/polls/:id/complete", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const id = Number(req.params.id);
const resultEntryId = Number(req.body.resultEntryId);
if (!resultEntryId) { res.status(400).json({ error: "resultEntryId is required" }); return; }
const poll = await prisma.poll.update({
where: { id },
data: { completed: true, resultEntryId },
});
res.json(poll);
});
app.post("/api/admin/polls/:id/entries", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const id = Number(req.params.id);
const { option } = req.body;
if (!option) { res.status(400).json({ error: "option is required" }); return; }
const entry = await prisma.pollEntry.create({ data: { pollId: id, option } });
res.json(entry);
});
app.delete("/api/admin/polls/:pollId/entries/:entryId", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const entryId = Number(req.params.entryId);
await prisma.anonymousPollVote.deleteMany({ where: { pollEntryId: entryId } });
await prisma.userPoll.deleteMany({ where: { pollEntryId: entryId } });
await prisma.pollEntry.delete({ where: { id: entryId } });
res.json({ success: true });
});
app.get("/api/poll-tags", (_req, res) => {
res.json(cache.pollTags);
});
app.post("/api/admin/poll-tags", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { name, color, image } = req.body;
if (!name) { res.status(400).json({ error: "name is required" }); return; }
const tag = await prisma.pollTag.create({ data: { name, color: color ?? "#424e88", image: await saveImage(image ?? "") } });
res.json(tag);
});
app.delete("/api/admin/poll-tags/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const id = Number(req.params.id);
await prisma.pollToTag.deleteMany({ where: { tagId: id } });
await prisma.pollTag.delete({ where: { id } });
res.json({ success: true });
});
app.post("/api/admin/polls/:id/tags", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const id = Number(req.params.id);
const tagId = Number(req.body.tagId);
if (!tagId) { res.status(400).json({ error: "tagId is required" }); return; }
await prisma.pollToTag.upsert({
where: { pollId_tagId: { pollId: id, tagId } },
create: { pollId: id, tagId },
update: {},
});
res.json({ success: true });
});
app.delete("/api/admin/polls/:id/tags/:tagId", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const id = Number(req.params.id);
const tagId = Number(req.params.tagId);
await prisma.pollToTag.delete({ where: { pollId_tagId: { pollId: id, tagId } } });
res.json({ success: true });
});
app.get("/api/admin/assets", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const files = fs.readdirSync(uploadsDir).filter((f) => f !== "logo.png").map((f) => ({ name: f, url: `/uploads/${f}` }));
res.json(files);
});
app.post("/api/admin/assets", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { image } = req.body;
if (!image) { res.status(400).json({ error: "image is required" }); return; }
const url = await saveImage(image);
res.json({ name: url.replace("/uploads/", ""), url });
});
app.delete("/api/admin/assets/:filename", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { filename } = req.params;
if (filename === "logo.png") { res.status(403).json({ error: "Cannot delete logo" }); return; }
const filepath = path.join(uploadsDir, filename);
if (fs.existsSync(filepath)) fs.unlinkSync(filepath);
res.json({ success: true });
});
app.get("/api/site-updates", (_req, res) => {
res.json(cache.siteUpdates);
});
app.post("/api/admin/site-updates", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const { title, text } = req.body;
if (!title) { res.status(400).json({ error: "Title required" }); return; }
const update = await prisma.siteUpdate.create({ data: { title, text: text || "" } });
res.json(update);
});
app.put("/api/admin/site-updates/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const id = Number(req.params.id);
const { title, text } = req.body;
if (!title) { res.status(400).json({ error: "Title required" }); return; }
const update = await prisma.siteUpdate.update({ where: { id }, data: { title, text: text || "" } });
res.json(update);
});
app.delete("/api/admin/site-updates/:id", async (req, res) => {
if (!(await requireAdmin(req, res))) return;
const id = Number(req.params.id);
await prisma.siteUpdate.delete({ where: { id } });
res.json({ success: true });
});
const PORT = process.env.PORT ?? 4000;
rebuildCache().then(() => {
setInterval(rebuildCache, 300000);
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
});
export { app, prisma };