add admin-center

This commit is contained in:
2025-11-22 13:51:40 +01:00
parent 2e93e49414
commit ca76138ba6
6 changed files with 750 additions and 629 deletions

View File

@@ -18,4 +18,8 @@ DISCORD_REDIRECT_URI=http://localhost:3000/auth/discord/callback
SESSION_SECRET=change_me_please
# Lokale Umgebung: HTTP only -> false
# Später hinter echter HTTPS-Domain: true
COOKIE_SECURE=false
COOKIE_SECURE=false
# Admins
# Kommagetrennte Liste von Discord-IDs, die Adminrechte haben
ADMIN_DISCORD_IDS=123456789012345678,987654321098765432

View File

@@ -1,10 +1,17 @@
import dotenv from "dotenv";
dotenv.config();
function parseAdminIds(raw: string | undefined): string[] {
if (!raw) return [];
return raw
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
export const config = {
port: parseInt(process.env.APP_PORT || "3000", 10),
databaseUrl: process.env.DATABASE_URL as string,
appBaseUrl: process.env.APP_BASE_URL || "http://localhost",
discord: {
clientId: process.env.DISCORD_CLIENT_ID as string,
clientSecret: process.env.DISCORD_CLIENT_SECRET as string,
@@ -12,5 +19,5 @@ export const config = {
},
sessionSecret: process.env.SESSION_SECRET || "dev-secret",
frontendOrigin: process.env.FRONTEND_ORIGIN || "http://localhost:5173",
cookieSecure: process.env.COOKIE_SECURE === "true"
adminDiscordIds: parseAdminIds(process.env.ADMIN_DISCORD_IDS)
};

View File

@@ -2,23 +2,26 @@ import express from "express";
import session from "express-session";
import cors from "cors";
import cookieParser from "cookie-parser";
import { pool } from "./db";
import { pool } from "./db";
import { config } from "./config";
import { authRouter } from "./routes/auth";
import { meRouter } from "./routes/me";
import { walletRouter } from "./routes/wallet";
import { slotRouter } from "./routes/slot";
import { BalanceLeaderboardEntry, BigWinLeaderboardEntry} from "./routes/leaderboard";
import { BalanceLeaderboardEntry, BigWinLeaderboardEntry } from "./routes/leaderboard";
import { adminRouter } from "./routes/admin";
const app = express();
// Reverse Proxy (NGINX) vertrauen
// Reverse Proxy
app.set("trust proxy", 1);
app.use(cors({
origin: config.frontendOrigin,
credentials: true
}));
app.use(
cors({
origin: config.frontendOrigin,
credentials: true
})
);
app.use(express.json());
app.use(cookieParser());
app.use(
@@ -28,8 +31,7 @@ app.use(
saveUninitialized: false,
cookie: {
httpOnly: true,
// WICHTIG: per ENV steuerbar, nicht stumpf NODE_ENV
secure: config.cookieSecure,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 1000 * 60 * 60 * 24 * 7
}
@@ -64,7 +66,7 @@ app.get("/api/leaderboard/balance", async (req, res) => {
}
});
// --- Leaderboard: Größter Einzelgewinn pro User ---
// Leaderboard: Größter Einzelgewinn pro User
app.get("/api/leaderboard/bigwin", async (req, res) => {
try {
const { rows } = await pool.query<BigWinLeaderboardEntry>(
@@ -94,6 +96,7 @@ app.use("/auth", authRouter);
app.use("/me", meRouter);
app.use("/wallet", walletRouter);
app.use("/slot", slotRouter);
app.use("/admin", adminRouter);
app.listen(config.port, () => {
console.log(`Bierbaron backend läuft auf Port ${config.port}`);

298
backend/src/routes/admin.ts Normal file
View File

@@ -0,0 +1,298 @@
// path: backend/src/routes/admin.ts
import { Router } from "express";
import { query, pool } from "../db";
import { config } from "../config";
export const adminRouter = Router();
interface SessionUser {
id: number;
discord_id: string;
discord_name: string;
}
// einfacher Auth-Guard
function requireAuth(req: any, res: any, next: any) {
const userId = req.session?.userId as number | undefined;
if (!userId) {
return res.status(401).json({ error: "Not logged in" });
}
next();
}
async function getSessionUser(req: any): Promise<SessionUser | null> {
const userId = req.session?.userId as number | undefined;
if (!userId) return null;
const rows = await query<SessionUser>(
`
SELECT id, discord_id, discord_name
FROM users
WHERE id = $1
`,
[userId]
);
if (rows.length === 0) return null;
return rows[0];
}
async function requireAdmin(req: any, res: any, next: any) {
try {
const user = await getSessionUser(req);
if (!user) {
return res.status(401).json({ error: "Not logged in" });
}
const isAdmin = config.adminDiscordIds.includes(user.discord_id);
if (!isAdmin) {
return res.status(403).json({ error: "Not an admin" });
}
req.adminUser = user;
next();
} catch (err) {
console.error("requireAdmin error:", err);
return res.status(500).json({ error: "Admin check failed" });
}
}
// GET /admin/me -> zeigt ob aktueller User Admin ist
adminRouter.get("/me", requireAuth, async (req: any, res) => {
try {
const user = await getSessionUser(req);
if (!user) {
return res.status(401).json({ error: "Not logged in" });
}
const isAdmin = config.adminDiscordIds.includes(user.discord_id);
res.json({
is_admin: isAdmin,
discord_id: user.discord_id,
discord_name: user.discord_name
});
} catch (err) {
console.error("GET /admin/me error:", err);
res.status(500).json({ error: "Failed to load admin info" });
}
});
// GET /admin/user/by-discord/:discordId
// Sucht User + Wallet per Discord-ID
adminRouter.get(
"/user/by-discord/:discordId",
requireAdmin,
async (req: any, res) => {
const discordId = req.params.discordId;
try {
const rows = await query<{
user_id: number;
discord_id: string;
discord_name: string;
avatar_url: string | null;
balance: number | null;
last_claim_at: string | null;
free_spins_bob_remaining: number | null;
free_spins_bob_bet: number | null;
}>(
`
SELECT
u.id AS user_id,
u.discord_id,
u.discord_name,
u.avatar_url,
w.balance,
w.last_claim_at,
w.free_spins_bob_remaining,
w.free_spins_bob_bet
FROM users u
LEFT JOIN wallets w ON w.user_id = u.id
WHERE u.discord_id = $1
`,
[discordId]
);
if (rows.length === 0) {
return res.status(404).json({ error: "User not found" });
}
const row = rows[0];
res.json({
user_id: row.user_id,
discord_id: row.discord_id,
discord_name: row.discord_name,
avatar_url: row.avatar_url,
balance: row.balance ?? 0,
last_claim_at: row.last_claim_at,
free_spins_bob_remaining: row.free_spins_bob_remaining ?? 0,
free_spins_bob_bet: row.free_spins_bob_bet
});
} catch (err) {
console.error("GET /admin/user/by-discord error:", err);
res.status(500).json({ error: "Failed to load user" });
}
}
);
// POST /admin/user/:userId/adjust-balance
// Body: { amount: number, reason?: string }
adminRouter.post(
"/user/:userId/adjust-balance",
requireAdmin,
async (req: any, res) => {
const userId = parseInt(req.params.userId, 10);
const rawAmount = req.body?.amount;
const reason = (req.body?.reason as string | undefined)?.trim() || "admin_adjust";
const amount = Number(rawAmount);
if (!Number.isFinite(amount) || amount === 0) {
return res.status(400).json({ error: "Invalid amount (must be non-zero)" });
}
if (!Number.isInteger(userId) || userId <= 0) {
return res.status(400).json({ error: "Invalid userId" });
}
const client = await pool.connect();
try {
await client.query("BEGIN");
// Wallet holen/erzeugen
const wRes = await client.query<{
user_id: number;
balance: number;
}>(
`
SELECT user_id, balance
FROM wallets
WHERE user_id = $1
FOR UPDATE
`,
[userId]
);
let wallet: { user_id: number; balance: number };
if (wRes.rows.length === 0) {
const inserted = await client.query<{
user_id: number;
balance: number;
}>(
`
INSERT INTO wallets (user_id, balance, last_claim_at, free_spins_bob_remaining, free_spins_bob_bet)
VALUES ($1, 0, NULL, 0, NULL)
RETURNING user_id, balance
`,
[userId]
);
wallet = inserted.rows[0];
} else {
wallet = wRes.rows[0];
}
const newBalance = wallet.balance + amount;
const updated = await client.query<{
user_id: number;
balance: number;
}>(
`
UPDATE wallets
SET balance = $2
WHERE user_id = $1
RETURNING user_id, balance
`,
[userId, newBalance]
);
await client.query(
`
INSERT INTO wallet_transactions (user_id, amount, reason)
VALUES ($1, $2, $3)
`,
[userId, amount, `admin:${reason}`]
);
await client.query("COMMIT");
res.json({
user_id: updated.rows[0].user_id,
balance: updated.rows[0].balance
});
} catch (err) {
await client.query("ROLLBACK");
console.error("POST /admin/user/:userId/adjust-balance error:", err);
res.status(500).json({ error: "Failed to adjust balance" });
} finally {
client.release();
}
}
);
// POST /admin/user/:userId/reset-wallet
// Body (optional): { reset_balance_to?: number, clear_free_spins?: boolean }
adminRouter.post(
"/user/:userId/reset-wallet",
requireAdmin,
async (req: any, res) => {
const userId = parseInt(req.params.userId, 10);
const rawTarget = req.body?.reset_balance_to;
const clearFreeSpins = req.body?.clear_free_spins !== false; // default: true
const targetBalance =
rawTarget === undefined ? 0 : Number(rawTarget);
if (!Number.isFinite(targetBalance)) {
return res.status(400).json({ error: "Invalid reset_balance_to" });
}
if (!Number.isInteger(userId) || userId <= 0) {
return res.status(400).json({ error: "Invalid userId" });
}
const client = await pool.connect();
try {
await client.query("BEGIN");
const updated = await client.query<{
user_id: number;
balance: number;
last_claim_at: string | null;
free_spins_bob_remaining: number;
free_spins_bob_bet: number | null;
}>(
`
INSERT INTO wallets (user_id, balance, last_claim_at, free_spins_bob_remaining, free_spins_bob_bet)
VALUES ($1, $2, NULL, 0, NULL)
ON CONFLICT (user_id)
DO UPDATE SET
balance = EXCLUDED.balance,
last_claim_at = NULL,
free_spins_bob_remaining = CASE WHEN $3 THEN 0 ELSE wallets.free_spins_bob_remaining END,
free_spins_bob_bet = CASE WHEN $3 THEN NULL ELSE wallets.free_spins_bob_bet END
RETURNING user_id, balance, last_claim_at, free_spins_bob_remaining, free_spins_bob_bet
`,
[userId, targetBalance, clearFreeSpins]
);
await client.query(
`
INSERT INTO wallet_transactions (user_id, amount, reason)
VALUES ($1, 0, $2)
`,
[userId, "admin:reset_wallet"]
);
await client.query("COMMIT");
res.json(updated.rows[0]);
} catch (err) {
await client.query("ROLLBACK");
console.error("POST /admin/user/:userId/reset-wallet error:", err);
res.status(500).json({ error: "Failed to reset wallet" });
} finally {
client.release();
}
}
);

File diff suppressed because it is too large Load Diff

View File

@@ -92,6 +92,25 @@ export interface BigWinLeaderboardEntry {
biggest_win: number;
}
// --- Admin Types ---
export interface AdminMeResponse {
is_admin: boolean;
discord_id: string;
discord_name: string;
}
export interface AdminUserSummary {
user_id: number;
discord_id: string;
discord_name: string;
avatar_url: string | null;
balance: number;
last_claim_at: string | null;
free_spins_bob_remaining: number;
free_spins_bob_bet: number | null;
}
// --- Auth / User ---
export async function getMe(): Promise<MeResponse> {
@@ -99,7 +118,6 @@ export async function getMe(): Promise<MeResponse> {
}
export function getLoginUrl(): string {
// Route im Backend: /auth/discord
return `${API_BASE}/auth/discord`;
}
@@ -133,4 +151,41 @@ export async function getBalanceLeaderboard(): Promise<BalanceLeaderboardEntry[]
export async function getBigWinLeaderboard(): Promise<BigWinLeaderboardEntry[]> {
return apiGet<BigWinLeaderboardEntry[]>("/api/leaderboard/bigwin");
}
// --- Admin API ---
export async function getAdminMe(): Promise<AdminMeResponse> {
return apiGet<AdminMeResponse>("/admin/me");
}
export async function adminFindUserByDiscord(
discordId: string
): Promise<AdminUserSummary> {
return apiGet<AdminUserSummary>(`/admin/user/by-discord/${encodeURIComponent(discordId)}`);
}
export async function adminAdjustBalance(
userId: number,
amount: number,
reason?: string
): Promise<{ user_id: number; balance: number }> {
return apiPost<{ user_id: number; balance: number }>(
`/admin/user/${userId}/adjust-balance`,
{ amount, reason }
);
}
export async function adminResetWallet(
userId: number,
resetBalanceTo?: number,
clearFreeSpins: boolean = true
): Promise<AdminUserSummary> {
return apiPost<AdminUserSummary>(
`/admin/user/${userId}/reset-wallet`,
{
reset_balance_to: resetBalanceTo,
clear_free_spins: clearFreeSpins
}
);
}