1258 lines
53 KiB
TypeScript
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 }; |