- Add user management system with all CRUD operations - Add promotion statistics dashboard with export - Simplify Trading & Market settings UI - Fix promotion schema (dates now optional) - Add missing API endpoints and PATCH support - Add comprehensive documentation - Fix critical bugs (deletePromotion, duplicate endpoints) All features tested and production-ready.
1479 lines
41 KiB
JavaScript
1479 lines
41 KiB
JavaScript
import { authenticate } from "../middleware/auth.js";
|
|
import User from "../models/User.js";
|
|
import Transaction from "../models/Transaction.js";
|
|
import SiteConfig from "../models/SiteConfig.js";
|
|
import PromoUsage from "../models/PromoUsage.js";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
|
|
/**
|
|
* Admin management routes for user administration and site configuration
|
|
* @param {FastifyInstance} fastify
|
|
* @param {Object} options
|
|
*/
|
|
export default async function adminManagementRoutes(fastify, options) {
|
|
// Middleware to check if user is admin
|
|
const isAdmin = async (request, reply) => {
|
|
if (!request.user) {
|
|
return reply.status(401).send({
|
|
success: false,
|
|
message: "Authentication required",
|
|
});
|
|
}
|
|
|
|
const adminSteamIds = process.env.ADMIN_STEAM_IDS?.split(",") || [];
|
|
|
|
if (
|
|
!request.user.isAdmin &&
|
|
!adminSteamIds.includes(request.user.steamId)
|
|
) {
|
|
return reply.status(403).send({
|
|
success: false,
|
|
message: "Admin access required",
|
|
});
|
|
}
|
|
};
|
|
|
|
// ============================================
|
|
// USER MANAGEMENT ROUTES
|
|
// ============================================
|
|
|
|
// GET /admin/users/search - Search for users
|
|
fastify.get(
|
|
"/users/search",
|
|
{
|
|
preHandler: [authenticate, isAdmin],
|
|
schema: {
|
|
querystring: {
|
|
type: "object",
|
|
properties: {
|
|
query: { type: "string" },
|
|
limit: { type: "integer", minimum: 1, maximum: 50, default: 20 },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
try {
|
|
const { query, limit = 20 } = request.query;
|
|
|
|
let searchQuery = {};
|
|
|
|
if (query) {
|
|
// Search by username, steamId, or email
|
|
searchQuery = {
|
|
$or: [
|
|
{ username: { $regex: query, $options: "i" } },
|
|
{ steamId: { $regex: query, $options: "i" } },
|
|
{ "email.address": { $regex: query, $options: "i" } },
|
|
],
|
|
};
|
|
}
|
|
|
|
const users = await User.find(searchQuery)
|
|
.select("-twoFactor.secret")
|
|
.limit(limit)
|
|
.sort({ createdAt: -1 });
|
|
|
|
return reply.send({
|
|
success: true,
|
|
users,
|
|
count: users.length,
|
|
});
|
|
} catch (error) {
|
|
console.error("❌ User search failed:", error);
|
|
return reply.status(500).send({
|
|
success: false,
|
|
message: "Failed to search users",
|
|
error: error.message,
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
// GET /admin/users/:id - Get user details by ID
|
|
fastify.get(
|
|
"/users/:id",
|
|
{
|
|
preHandler: [authenticate, isAdmin],
|
|
schema: {
|
|
params: {
|
|
type: "object",
|
|
required: ["id"],
|
|
properties: {
|
|
id: { type: "string" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
try {
|
|
const { id } = request.params;
|
|
|
|
const user = await User.findById(id).select("-twoFactor.secret");
|
|
|
|
if (!user) {
|
|
return reply.status(404).send({
|
|
success: false,
|
|
message: "User not found",
|
|
});
|
|
}
|
|
|
|
// Get user's transaction stats
|
|
const transactionStats = await Transaction.aggregate([
|
|
{ $match: { user: user._id } },
|
|
{
|
|
$group: {
|
|
_id: "$type",
|
|
count: { $sum: 1 },
|
|
total: { $sum: "$amount" },
|
|
},
|
|
},
|
|
]);
|
|
|
|
const totalTransactions = await Transaction.countDocuments({
|
|
user: user._id,
|
|
});
|
|
|
|
return reply.send({
|
|
success: true,
|
|
user,
|
|
stats: {
|
|
totalTransactions,
|
|
byType: transactionStats,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error("❌ Failed to get user:", error);
|
|
return reply.status(500).send({
|
|
success: false,
|
|
message: "Failed to retrieve user",
|
|
error: error.message,
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
// GET /admin/users/:id/stats - Get user statistics
|
|
fastify.get(
|
|
"/users/:id/stats",
|
|
{
|
|
preHandler: [authenticate, isAdmin],
|
|
schema: {
|
|
params: {
|
|
type: "object",
|
|
required: ["id"],
|
|
properties: {
|
|
id: { type: "string" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
try {
|
|
const { id } = request.params;
|
|
|
|
const user = await User.findById(id);
|
|
|
|
if (!user) {
|
|
return reply.status(404).send({
|
|
success: false,
|
|
message: "User not found",
|
|
});
|
|
}
|
|
|
|
// Get transaction statistics
|
|
const transactionStats = await Transaction.aggregate([
|
|
{ $match: { user: user._id } },
|
|
{
|
|
$group: {
|
|
_id: "$type",
|
|
count: { $sum: 1 },
|
|
total: { $sum: "$amount" },
|
|
},
|
|
},
|
|
]);
|
|
|
|
const totalTransactions = await Transaction.countDocuments({
|
|
user: user._id,
|
|
});
|
|
|
|
const totalAmount = transactionStats.reduce(
|
|
(sum, stat) => sum + stat.total,
|
|
0
|
|
);
|
|
|
|
// Get trade count (if Trade model exists)
|
|
let totalTrades = 0;
|
|
try {
|
|
const Trade = fastify.mongoose.model("Trade");
|
|
totalTrades = await Trade.countDocuments({
|
|
$or: [{ sender: user._id }, { receiver: user._id }],
|
|
});
|
|
} catch (err) {
|
|
// Trade model might not exist yet
|
|
console.log("Trade model not found, skipping trade stats");
|
|
}
|
|
|
|
return reply.send({
|
|
success: true,
|
|
stats: {
|
|
totalTransactions,
|
|
totalAmount,
|
|
totalTrades,
|
|
byType: transactionStats,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error("❌ Failed to get user stats:", error);
|
|
return reply.status(500).send({
|
|
success: false,
|
|
message: "Failed to retrieve user statistics",
|
|
error: error.message,
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
// POST /admin/users/:id/balance - Add or remove balance from user
|
|
// Also PATCH for frontend compatibility
|
|
// Also support PATCH for consistency with frontend
|
|
const balanceHandler = {
|
|
preHandler: [authenticate, isAdmin],
|
|
schema: {
|
|
params: {
|
|
type: "object",
|
|
required: ["id"],
|
|
properties: {
|
|
id: { type: "string" },
|
|
},
|
|
},
|
|
body: {
|
|
type: "object",
|
|
required: ["amount", "reason"],
|
|
properties: {
|
|
amount: { type: "number" },
|
|
reason: { type: "string", minLength: 3 },
|
|
type: {
|
|
type: "string",
|
|
enum: ["add", "remove", "credit", "debit"],
|
|
default: "add",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
handler: async (request, reply) => {
|
|
try {
|
|
const { id } = request.params;
|
|
let { amount, reason, type = "add" } = request.body;
|
|
|
|
// Normalize type - support both add/remove and credit/debit
|
|
if (type === "credit") type = "add";
|
|
if (type === "debit") type = "remove";
|
|
|
|
const user = await User.findById(id);
|
|
|
|
if (!user) {
|
|
return reply.status(404).send({
|
|
success: false,
|
|
message: "User not found",
|
|
});
|
|
}
|
|
|
|
const adjustmentAmount =
|
|
type === "add" ? Math.abs(amount) : -Math.abs(amount);
|
|
const newBalance = user.balance + adjustmentAmount;
|
|
|
|
if (newBalance < 0) {
|
|
return reply.status(400).send({
|
|
success: false,
|
|
message: "Insufficient balance for removal",
|
|
});
|
|
}
|
|
|
|
// Update user balance
|
|
user.balance = newBalance;
|
|
await user.save();
|
|
|
|
// Create transaction record
|
|
const transaction = await Transaction.create({
|
|
user: user._id,
|
|
type: type === "add" ? "bonus" : "adjustment",
|
|
amount: adjustmentAmount,
|
|
status: "completed",
|
|
description: `Admin adjustment by ${request.user.username}: ${reason}`,
|
|
metadata: {
|
|
adminId: request.user._id,
|
|
adminUsername: request.user.username,
|
|
reason,
|
|
previousBalance: user.balance - adjustmentAmount,
|
|
newBalance: user.balance,
|
|
},
|
|
});
|
|
|
|
console.log(
|
|
`💰 Admin ${request.user.username} adjusted balance for ${
|
|
user.username
|
|
}: ${
|
|
adjustmentAmount > 0 ? "+" : ""
|
|
}${adjustmentAmount} (Reason: ${reason})`
|
|
);
|
|
|
|
return reply.send({
|
|
success: true,
|
|
message: `Successfully ${
|
|
type === "add" ? "added" : "removed"
|
|
} $${Math.abs(amount).toFixed(2)}`,
|
|
user: {
|
|
id: user._id,
|
|
username: user.username,
|
|
balance: user.balance,
|
|
},
|
|
transaction: {
|
|
id: transaction._id,
|
|
amount: transaction.amount,
|
|
type: transaction.type,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error("❌ Failed to adjust balance:", error);
|
|
return reply.status(500).send({
|
|
success: false,
|
|
message: "Failed to adjust balance",
|
|
error: error.message,
|
|
});
|
|
}
|
|
},
|
|
};
|
|
|
|
fastify.post("/users/:id/balance", balanceHandler);
|
|
fastify.patch("/users/:id/balance", balanceHandler);
|
|
|
|
// POST /admin/users/:id/ban - Ban or unban a user
|
|
// Also PATCH for frontend compatibility
|
|
// Also support PATCH for consistency
|
|
const banHandler = {
|
|
preHandler: [authenticate, isAdmin],
|
|
schema: {
|
|
params: {
|
|
type: "object",
|
|
required: ["id"],
|
|
properties: {
|
|
id: { type: "string" },
|
|
},
|
|
},
|
|
body: {
|
|
type: "object",
|
|
required: ["banned", "reason"],
|
|
properties: {
|
|
banned: { type: "boolean" },
|
|
reason: { type: "string" },
|
|
duration: { type: "number", minimum: 0 },
|
|
},
|
|
},
|
|
},
|
|
handler: async (request, reply) => {
|
|
try {
|
|
const { id } = request.params;
|
|
const { banned, reason, duration = 0 } = request.body;
|
|
|
|
const user = await User.findById(id);
|
|
|
|
if (!user) {
|
|
return reply.status(404).send({
|
|
success: false,
|
|
message: "User not found",
|
|
});
|
|
}
|
|
|
|
// Don't allow banning other admins
|
|
if (user.staffLevel >= 3) {
|
|
return reply.status(403).send({
|
|
success: false,
|
|
message: "Cannot ban admin users",
|
|
});
|
|
}
|
|
|
|
let expires = null;
|
|
if (banned && duration > 0) {
|
|
expires = new Date(Date.now() + duration * 60 * 60 * 1000);
|
|
}
|
|
|
|
user.ban = {
|
|
banned,
|
|
reason: banned ? reason || "Banned by administrator" : null,
|
|
expires: banned ? expires : null,
|
|
};
|
|
|
|
await user.save();
|
|
|
|
console.log(
|
|
`🔨 Admin ${request.user.username} ${
|
|
banned ? "banned" : "unbanned"
|
|
} user ${user.username}${
|
|
banned && reason ? ` (Reason: ${reason})` : ""
|
|
}`
|
|
);
|
|
|
|
return reply.send({
|
|
success: true,
|
|
message: banned
|
|
? `User ${user.username} has been banned`
|
|
: `User ${user.username} has been unbanned`,
|
|
user: {
|
|
id: user._id,
|
|
username: user.username,
|
|
banned: user.ban.banned,
|
|
reason: user.ban.reason,
|
|
expires: user.ban.expires,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error("❌ Failed to ban/unban user:", error);
|
|
return reply.status(500).send({
|
|
success: false,
|
|
message: "Failed to update ban status",
|
|
error: error.message,
|
|
});
|
|
}
|
|
},
|
|
};
|
|
|
|
fastify.post("/users/:id/ban", banHandler);
|
|
fastify.patch("/users/:id/ban", banHandler);
|
|
|
|
// POST /admin/users/:id/staff-level - Update user staff level
|
|
// Also support PATCH for consistency
|
|
const staffLevelHandler = {
|
|
preHandler: [authenticate, isAdmin],
|
|
schema: {
|
|
params: {
|
|
type: "object",
|
|
required: ["id"],
|
|
properties: {
|
|
id: { type: "string" },
|
|
},
|
|
},
|
|
body: {
|
|
type: "object",
|
|
required: ["level"],
|
|
properties: {
|
|
level: { type: "integer", minimum: 0, maximum: 4 },
|
|
},
|
|
},
|
|
},
|
|
handler: async (request, reply) => {
|
|
try {
|
|
const { id } = request.params;
|
|
const { level } = request.body;
|
|
|
|
const user = await User.findById(id);
|
|
|
|
if (!user) {
|
|
return reply.status(404).send({
|
|
success: false,
|
|
message: "User not found",
|
|
});
|
|
}
|
|
|
|
// Only allow super admins (level 5) to promote to admin
|
|
if (level >= 3 && request.user.staffLevel < 5) {
|
|
return reply.status(403).send({
|
|
success: false,
|
|
message: "Only super admins can promote to admin level",
|
|
});
|
|
}
|
|
|
|
const previousLevel = user.staffLevel;
|
|
user.staffLevel = level;
|
|
await user.save();
|
|
|
|
console.log(
|
|
`👮 Admin ${request.user.username} changed ${user.username}'s staff level from ${previousLevel} to ${level}`
|
|
);
|
|
|
|
return reply.send({
|
|
success: true,
|
|
message: `Staff level updated to ${level}`,
|
|
user: {
|
|
id: user._id,
|
|
username: user.username,
|
|
staffLevel: user.staffLevel,
|
|
isAdmin: user.isAdmin,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error("❌ Failed to update staff level:", error);
|
|
return reply.status(500).send({
|
|
success: false,
|
|
message: "Failed to update staff level",
|
|
error: error.message,
|
|
});
|
|
}
|
|
},
|
|
};
|
|
|
|
fastify.post("/users/:id/staff-level", staffLevelHandler);
|
|
fastify.patch("/users/:id/staff-level", staffLevelHandler);
|
|
|
|
// GET /admin/users/:id/transactions - Get user transactions
|
|
fastify.get(
|
|
"/users/:id/transactions",
|
|
{
|
|
preHandler: [authenticate, isAdmin],
|
|
schema: {
|
|
params: {
|
|
type: "object",
|
|
required: ["id"],
|
|
properties: {
|
|
id: { type: "string" },
|
|
},
|
|
},
|
|
querystring: {
|
|
type: "object",
|
|
properties: {
|
|
limit: { type: "integer", minimum: 1, maximum: 100, default: 50 },
|
|
skip: { type: "integer", minimum: 0, default: 0 },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
try {
|
|
const { id } = request.params;
|
|
const { limit = 50, skip = 0 } = request.query;
|
|
|
|
const user = await User.findById(id);
|
|
|
|
if (!user) {
|
|
return reply.status(404).send({
|
|
success: false,
|
|
message: "User not found",
|
|
});
|
|
}
|
|
|
|
const transactions = await Transaction.find({ user: id })
|
|
.sort({ createdAt: -1 })
|
|
.limit(limit)
|
|
.skip(skip);
|
|
|
|
const total = await Transaction.countDocuments({ user: id });
|
|
|
|
return reply.send({
|
|
success: true,
|
|
transactions,
|
|
pagination: {
|
|
total,
|
|
limit,
|
|
skip,
|
|
hasMore: skip + limit < total,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error("❌ Failed to get user transactions:", error);
|
|
return reply.status(500).send({
|
|
success: false,
|
|
message: "Failed to retrieve transactions",
|
|
error: error.message,
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
// ============================================
|
|
// SITE CONFIGURATION ROUTES
|
|
// ============================================
|
|
|
|
// GET /admin/config - Get site configuration
|
|
fastify.get(
|
|
"/config",
|
|
{
|
|
preHandler: [authenticate, isAdmin],
|
|
},
|
|
async (request, reply) => {
|
|
try {
|
|
const config = await SiteConfig.getConfig();
|
|
|
|
return reply.send({
|
|
success: true,
|
|
config,
|
|
});
|
|
} catch (error) {
|
|
console.error("❌ Failed to get config:", error);
|
|
return reply.status(500).send({
|
|
success: false,
|
|
message: "Failed to retrieve configuration",
|
|
error: error.message,
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
// PATCH /admin/config/maintenance - Update maintenance mode
|
|
fastify.patch(
|
|
"/config/maintenance",
|
|
{
|
|
preHandler: [authenticate, isAdmin],
|
|
schema: {
|
|
body: {
|
|
type: "object",
|
|
properties: {
|
|
enabled: { type: "boolean" },
|
|
message: { type: "string" },
|
|
allowedSteamIds: { type: "array", items: { type: "string" } },
|
|
scheduledStart: { type: ["string", "null"] },
|
|
scheduledEnd: { type: ["string", "null"] },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
try {
|
|
const config = await SiteConfig.getConfig();
|
|
|
|
if (request.body.enabled !== undefined) {
|
|
config.maintenance.enabled = request.body.enabled;
|
|
}
|
|
if (request.body.message) {
|
|
config.maintenance.message = request.body.message;
|
|
}
|
|
if (request.body.allowedSteamIds) {
|
|
config.maintenance.allowedSteamIds = request.body.allowedSteamIds;
|
|
}
|
|
if (request.body.scheduledStart !== undefined) {
|
|
config.maintenance.scheduledStart = request.body.scheduledStart
|
|
? new Date(request.body.scheduledStart)
|
|
: null;
|
|
}
|
|
if (request.body.scheduledEnd !== undefined) {
|
|
config.maintenance.scheduledEnd = request.body.scheduledEnd
|
|
? new Date(request.body.scheduledEnd)
|
|
: null;
|
|
}
|
|
|
|
config.lastUpdatedBy = request.user.username;
|
|
config.lastUpdatedAt = new Date();
|
|
|
|
await config.save();
|
|
|
|
console.log(
|
|
`⚙️ Admin ${request.user.username} updated maintenance mode: ${
|
|
config.maintenance.enabled ? "ENABLED" : "DISABLED"
|
|
}`
|
|
);
|
|
|
|
return reply.send({
|
|
success: true,
|
|
message: "Maintenance mode updated",
|
|
maintenance: config.maintenance,
|
|
});
|
|
} catch (error) {
|
|
console.error("❌ Failed to update maintenance mode:", error);
|
|
return reply.status(500).send({
|
|
success: false,
|
|
message: "Failed to update maintenance mode",
|
|
error: error.message,
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
// PATCH /admin/config/trading - Update trading settings
|
|
fastify.patch(
|
|
"/config/trading",
|
|
{
|
|
preHandler: [authenticate, isAdmin],
|
|
schema: {
|
|
body: {
|
|
type: "object",
|
|
properties: {
|
|
enabled: { type: "boolean" },
|
|
depositEnabled: { type: "boolean" },
|
|
withdrawEnabled: { type: "boolean" },
|
|
minDeposit: { type: "number", minimum: 0 },
|
|
minWithdraw: { type: "number", minimum: 0 },
|
|
withdrawFee: { type: "number", minimum: 0, maximum: 1 },
|
|
maxItemsPerTrade: { type: "integer", minimum: 1 },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
try {
|
|
const config = await SiteConfig.getConfig();
|
|
|
|
Object.keys(request.body).forEach((key) => {
|
|
if (request.body[key] !== undefined) {
|
|
config.trading[key] = request.body[key];
|
|
}
|
|
});
|
|
|
|
config.lastUpdatedBy = request.user.username;
|
|
config.lastUpdatedAt = new Date();
|
|
|
|
await config.save();
|
|
|
|
console.log(
|
|
`⚙️ Admin ${request.user.username} updated trading settings`
|
|
);
|
|
|
|
return reply.send({
|
|
success: true,
|
|
message: "Trading settings updated",
|
|
trading: config.trading,
|
|
});
|
|
} catch (error) {
|
|
console.error("❌ Failed to update trading settings:", error);
|
|
return reply.status(500).send({
|
|
success: false,
|
|
message: "Failed to update trading settings",
|
|
error: error.message,
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
// PATCH /admin/config/market - Update market settings
|
|
fastify.patch(
|
|
"/config/market",
|
|
{
|
|
preHandler: [authenticate, isAdmin],
|
|
schema: {
|
|
body: {
|
|
type: "object",
|
|
properties: {
|
|
enabled: { type: "boolean" },
|
|
commission: { type: "number", minimum: 0, maximum: 1 },
|
|
minListingPrice: { type: "number", minimum: 0 },
|
|
maxListingPrice: { type: "number", minimum: 0 },
|
|
autoUpdatePrices: { type: "boolean" },
|
|
priceUpdateInterval: { type: "integer", minimum: 60000 },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
try {
|
|
const config = await SiteConfig.getConfig();
|
|
|
|
Object.keys(request.body).forEach((key) => {
|
|
if (request.body[key] !== undefined) {
|
|
config.market[key] = request.body[key];
|
|
}
|
|
});
|
|
|
|
config.lastUpdatedBy = request.user.username;
|
|
config.lastUpdatedAt = new Date();
|
|
|
|
await config.save();
|
|
|
|
console.log(
|
|
`⚙️ Admin ${request.user.username} updated market settings`
|
|
);
|
|
|
|
return reply.send({
|
|
success: true,
|
|
message: "Market settings updated",
|
|
market: config.market,
|
|
});
|
|
} catch (error) {
|
|
console.error("❌ Failed to update market settings:", error);
|
|
return reply.status(500).send({
|
|
success: false,
|
|
message: "Failed to update market settings",
|
|
error: error.message,
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
// ============================================
|
|
// ANNOUNCEMENTS
|
|
// ============================================
|
|
|
|
// POST /admin/announcements - Create announcement
|
|
fastify.post(
|
|
"/announcements",
|
|
{
|
|
preHandler: [authenticate, isAdmin],
|
|
schema: {
|
|
body: {
|
|
type: "object",
|
|
required: ["message"],
|
|
properties: {
|
|
type: {
|
|
type: "string",
|
|
enum: ["info", "warning", "success", "error"],
|
|
default: "info",
|
|
},
|
|
message: { type: "string", minLength: 1 },
|
|
enabled: { type: "boolean", default: true },
|
|
startDate: { type: ["string", "null"] },
|
|
endDate: { type: ["string", "null"] },
|
|
dismissible: { type: "boolean", default: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
try {
|
|
const config = await SiteConfig.getConfig();
|
|
|
|
const announcement = {
|
|
id: uuidv4(),
|
|
type: request.body.type || "info",
|
|
message: request.body.message,
|
|
enabled:
|
|
request.body.enabled !== undefined ? request.body.enabled : true,
|
|
startDate: request.body.startDate
|
|
? new Date(request.body.startDate)
|
|
: null,
|
|
endDate: request.body.endDate ? new Date(request.body.endDate) : null,
|
|
dismissible:
|
|
request.body.dismissible !== undefined
|
|
? request.body.dismissible
|
|
: true,
|
|
createdBy: request.user.username,
|
|
createdAt: new Date(),
|
|
};
|
|
|
|
config.announcements.push(announcement);
|
|
config.lastUpdatedBy = request.user.username;
|
|
config.lastUpdatedAt = new Date();
|
|
|
|
await config.save();
|
|
|
|
console.log(
|
|
`📢 Admin ${
|
|
request.user.username
|
|
} created announcement: ${announcement.message.substring(0, 50)}`
|
|
);
|
|
|
|
return reply.send({
|
|
success: true,
|
|
message: "Announcement created",
|
|
announcement,
|
|
});
|
|
} catch (error) {
|
|
console.error("❌ Failed to create announcement:", error);
|
|
return reply.status(500).send({
|
|
success: false,
|
|
message: "Failed to create announcement",
|
|
error: error.message,
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
// DELETE /admin/announcements/:id - Delete announcement
|
|
fastify.delete(
|
|
"/announcements/:id",
|
|
{
|
|
preHandler: [authenticate, isAdmin],
|
|
schema: {
|
|
params: {
|
|
type: "object",
|
|
required: ["id"],
|
|
properties: {
|
|
id: { type: "string" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
try {
|
|
const { id } = request.params;
|
|
const config = await SiteConfig.getConfig();
|
|
|
|
const index = config.announcements.findIndex((a) => a.id === id);
|
|
|
|
if (index === -1) {
|
|
return reply.status(404).send({
|
|
success: false,
|
|
message: "Announcement not found",
|
|
});
|
|
}
|
|
|
|
config.announcements.splice(index, 1);
|
|
config.lastUpdatedBy = request.user.username;
|
|
config.lastUpdatedAt = new Date();
|
|
|
|
await config.save();
|
|
|
|
console.log(
|
|
`🗑️ Admin ${request.user.username} deleted announcement ${id}`
|
|
);
|
|
|
|
return reply.send({
|
|
success: true,
|
|
message: "Announcement deleted",
|
|
});
|
|
} catch (error) {
|
|
console.error("❌ Failed to delete announcement:", error);
|
|
return reply.status(500).send({
|
|
success: false,
|
|
message: "Failed to delete announcement",
|
|
error: error.message,
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
// PATCH /admin/announcements/:id - Update announcement
|
|
fastify.patch(
|
|
"/announcements/:id",
|
|
{
|
|
preHandler: [authenticate, isAdmin],
|
|
schema: {
|
|
params: {
|
|
type: "object",
|
|
required: ["id"],
|
|
properties: {
|
|
id: { type: "string" },
|
|
},
|
|
},
|
|
body: {
|
|
type: "object",
|
|
properties: {
|
|
type: {
|
|
type: "string",
|
|
enum: ["info", "warning", "success", "error"],
|
|
},
|
|
message: { type: "string" },
|
|
enabled: { type: "boolean" },
|
|
startDate: { type: ["string", "null"] },
|
|
endDate: { type: ["string", "null"] },
|
|
dismissible: { type: "boolean" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
try {
|
|
const { id } = request.params;
|
|
const config = await SiteConfig.getConfig();
|
|
|
|
const announcement = config.announcements.find((a) => a.id === id);
|
|
|
|
if (!announcement) {
|
|
return reply.status(404).send({
|
|
success: false,
|
|
message: "Announcement not found",
|
|
});
|
|
}
|
|
|
|
Object.keys(request.body).forEach((key) => {
|
|
if (request.body[key] !== undefined) {
|
|
if (key === "startDate" || key === "endDate") {
|
|
announcement[key] = request.body[key]
|
|
? new Date(request.body[key])
|
|
: null;
|
|
} else {
|
|
announcement[key] = request.body[key];
|
|
}
|
|
}
|
|
});
|
|
|
|
config.lastUpdatedBy = request.user.username;
|
|
config.lastUpdatedAt = new Date();
|
|
|
|
await config.save();
|
|
|
|
console.log(
|
|
`📝 Admin ${request.user.username} updated announcement ${id}`
|
|
);
|
|
|
|
return reply.send({
|
|
success: true,
|
|
message: "Announcement updated",
|
|
announcement,
|
|
});
|
|
} catch (error) {
|
|
console.error("❌ Failed to update announcement:", error);
|
|
return reply.status(500).send({
|
|
success: false,
|
|
message: "Failed to update announcement",
|
|
error: error.message,
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
// ============================================
|
|
// PROMOTIONS
|
|
// ============================================
|
|
|
|
// POST /admin/promotions - Create promotion
|
|
fastify.post(
|
|
"/promotions",
|
|
{
|
|
preHandler: [authenticate, isAdmin],
|
|
schema: {
|
|
body: {
|
|
type: "object",
|
|
required: ["name", "description", "type"],
|
|
properties: {
|
|
name: { type: "string", minLength: 1 },
|
|
description: { type: "string", minLength: 1 },
|
|
type: {
|
|
type: "string",
|
|
enum: ["deposit_bonus", "discount", "free_item", "custom"],
|
|
},
|
|
enabled: { type: "boolean", default: true },
|
|
startDate: { type: ["string", "null"] },
|
|
endDate: { type: ["string", "null"] },
|
|
bonusPercentage: { type: "number", minimum: 0, maximum: 100 },
|
|
bonusAmount: { type: "number", minimum: 0 },
|
|
minDeposit: { type: "number", minimum: 0 },
|
|
maxBonus: { type: "number", minimum: 0 },
|
|
discountPercentage: { type: "number", minimum: 0, maximum: 100 },
|
|
maxUsesPerUser: { type: "integer", minimum: 1 },
|
|
maxTotalUses: { type: ["integer", "null"], minimum: 1 },
|
|
newUsersOnly: { type: "boolean", default: false },
|
|
code: { type: "string" },
|
|
bannerImage: { type: "string" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
try {
|
|
console.log(
|
|
"🎁 Creating promotion with data:",
|
|
JSON.stringify(request.body, null, 2)
|
|
);
|
|
|
|
const config = await SiteConfig.getConfig();
|
|
|
|
if (!config) {
|
|
console.error("❌ SiteConfig not found or could not be retrieved");
|
|
return reply.status(500).send({
|
|
success: false,
|
|
message: "Site configuration not found",
|
|
});
|
|
}
|
|
|
|
console.log("✅ SiteConfig retrieved successfully");
|
|
|
|
const promotion = {
|
|
id: uuidv4(),
|
|
name: request.body.name,
|
|
description: request.body.description,
|
|
type: request.body.type,
|
|
enabled:
|
|
request.body.enabled !== undefined ? request.body.enabled : true,
|
|
startDate: request.body.startDate
|
|
? new Date(request.body.startDate)
|
|
: null,
|
|
endDate: request.body.endDate ? new Date(request.body.endDate) : null,
|
|
bonusPercentage: request.body.bonusPercentage || 0,
|
|
bonusAmount: request.body.bonusAmount || 0,
|
|
minDeposit: request.body.minDeposit || 0,
|
|
maxBonus: request.body.maxBonus || 0,
|
|
discountPercentage: request.body.discountPercentage || 0,
|
|
maxUsesPerUser: request.body.maxUsesPerUser || 1,
|
|
maxTotalUses: request.body.maxTotalUses || null,
|
|
currentUses: 0,
|
|
newUsersOnly: request.body.newUsersOnly || false,
|
|
code: request.body.code || null,
|
|
bannerImage: request.body.bannerImage || null,
|
|
createdBy: request.user.username,
|
|
createdAt: new Date(),
|
|
};
|
|
|
|
console.log(
|
|
"📝 Promotion object created:",
|
|
JSON.stringify(promotion, null, 2)
|
|
);
|
|
|
|
config.promotions.push(promotion);
|
|
config.lastUpdatedBy = request.user.username;
|
|
config.lastUpdatedAt = new Date();
|
|
|
|
console.log("💾 Attempting to save to database...");
|
|
await config.save();
|
|
console.log("✅ Database save successful");
|
|
|
|
console.log(
|
|
`🎁 Admin ${request.user.username} created promotion: ${promotion.name}`
|
|
);
|
|
|
|
return reply.send({
|
|
success: true,
|
|
message: "Promotion created",
|
|
promotion,
|
|
});
|
|
} catch (error) {
|
|
console.error("❌ Failed to create promotion:", error.message);
|
|
console.error("❌ Error name:", error.name);
|
|
console.error("❌ Error stack:", error.stack);
|
|
console.error(
|
|
"❌ Request body:",
|
|
JSON.stringify(request.body, null, 2)
|
|
);
|
|
console.error("❌ Request user:", request.user?.username);
|
|
|
|
if (error.name === "ValidationError") {
|
|
console.error("❌ Mongoose validation errors:", error.errors);
|
|
}
|
|
|
|
if (error.validation) {
|
|
console.error("❌ Fastify validation errors:", error.validation);
|
|
}
|
|
|
|
return reply.status(500).send({
|
|
success: false,
|
|
message: "Failed to create promotion",
|
|
error: error.message,
|
|
errorName: error.name,
|
|
details:
|
|
error.validation ||
|
|
(error.errors ? Object.keys(error.errors) : null),
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
// GET /admin/promotions - Get all promotions
|
|
fastify.get(
|
|
"/promotions",
|
|
{
|
|
preHandler: [authenticate, isAdmin],
|
|
},
|
|
async (request, reply) => {
|
|
try {
|
|
const config = await SiteConfig.getConfig();
|
|
|
|
// Get usage stats for each promotion
|
|
const promotionsWithStats = await Promise.all(
|
|
config.promotions.map(async (promo) => {
|
|
const stats = await PromoUsage.getPromoStats(promo.id);
|
|
return {
|
|
...promo.toObject(),
|
|
stats,
|
|
};
|
|
})
|
|
);
|
|
|
|
return reply.send({
|
|
success: true,
|
|
promotions: promotionsWithStats,
|
|
});
|
|
} catch (error) {
|
|
console.error("❌ Failed to get promotions:", error);
|
|
return reply.status(500).send({
|
|
success: false,
|
|
message: "Failed to retrieve promotions",
|
|
error: error.message,
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
// PATCH /admin/promotions/:id - Update promotion
|
|
fastify.patch(
|
|
"/promotions/:id",
|
|
{
|
|
preHandler: [authenticate, isAdmin],
|
|
schema: {
|
|
params: {
|
|
type: "object",
|
|
required: ["id"],
|
|
properties: {
|
|
id: { type: "string" },
|
|
},
|
|
},
|
|
body: {
|
|
type: "object",
|
|
properties: {
|
|
name: { type: "string" },
|
|
description: { type: "string" },
|
|
enabled: { type: "boolean" },
|
|
startDate: { type: ["string", "null"] },
|
|
endDate: { type: ["string", "null"] },
|
|
bonusPercentage: { type: "number" },
|
|
bonusAmount: { type: "number" },
|
|
minDeposit: { type: "number" },
|
|
maxBonus: { type: "number" },
|
|
discountPercentage: { type: "number" },
|
|
maxUsesPerUser: { type: "integer" },
|
|
maxTotalUses: { type: "integer" },
|
|
newUsersOnly: { type: "boolean" },
|
|
code: { type: "string" },
|
|
bannerImage: { type: "string" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
try {
|
|
const { id } = request.params;
|
|
const config = await SiteConfig.getConfig();
|
|
|
|
const promotion = config.promotions.find((p) => p.id === id);
|
|
|
|
if (!promotion) {
|
|
return reply.status(404).send({
|
|
success: false,
|
|
message: "Promotion not found",
|
|
});
|
|
}
|
|
|
|
Object.keys(request.body).forEach((key) => {
|
|
if (request.body[key] !== undefined) {
|
|
if (key === "startDate" || key === "endDate") {
|
|
promotion[key] = request.body[key]
|
|
? new Date(request.body[key])
|
|
: null;
|
|
} else {
|
|
promotion[key] = request.body[key];
|
|
}
|
|
}
|
|
});
|
|
|
|
config.lastUpdatedBy = request.user.username;
|
|
config.lastUpdatedAt = new Date();
|
|
|
|
await config.save();
|
|
|
|
console.log(
|
|
`📝 Admin ${request.user.username} updated promotion ${promotion.name}`
|
|
);
|
|
|
|
return reply.send({
|
|
success: true,
|
|
message: "Promotion updated",
|
|
promotion,
|
|
});
|
|
} catch (error) {
|
|
console.error("❌ Failed to update promotion:", error);
|
|
return reply.status(500).send({
|
|
success: false,
|
|
message: "Failed to update promotion",
|
|
error: error.message,
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
// DELETE /admin/promotions/:id - Delete promotion
|
|
fastify.delete(
|
|
"/promotions/:id",
|
|
{
|
|
preHandler: [authenticate, isAdmin],
|
|
schema: {
|
|
params: {
|
|
type: "object",
|
|
required: ["id"],
|
|
properties: {
|
|
id: { type: "string" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
try {
|
|
const { id } = request.params;
|
|
const config = await SiteConfig.getConfig();
|
|
|
|
const index = config.promotions.findIndex((p) => p.id === id);
|
|
|
|
if (index === -1) {
|
|
return reply.status(404).send({
|
|
success: false,
|
|
message: "Promotion not found",
|
|
});
|
|
}
|
|
|
|
const promoName = config.promotions[index].name;
|
|
config.promotions.splice(index, 1);
|
|
config.lastUpdatedBy = request.user.username;
|
|
config.lastUpdatedAt = new Date();
|
|
|
|
await config.save();
|
|
|
|
console.log(
|
|
`🗑️ Admin ${request.user.username} deleted promotion ${promoName}`
|
|
);
|
|
|
|
return reply.send({
|
|
success: true,
|
|
message: "Promotion deleted",
|
|
});
|
|
} catch (error) {
|
|
console.error("❌ Failed to delete promotion:", error);
|
|
return reply.status(500).send({
|
|
success: false,
|
|
message: "Failed to delete promotion",
|
|
error: error.message,
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
// GET /admin/promotions/:id/stats - Get promotion statistics only
|
|
fastify.get(
|
|
"/promotions/:id/stats",
|
|
{
|
|
preHandler: [authenticate, isAdmin],
|
|
schema: {
|
|
params: {
|
|
type: "object",
|
|
required: ["id"],
|
|
properties: {
|
|
id: { type: "string" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
try {
|
|
const { id } = request.params;
|
|
const stats = await PromoUsage.getPromoStats(id);
|
|
|
|
return reply.send({
|
|
success: true,
|
|
stats,
|
|
});
|
|
} catch (error) {
|
|
console.error("❌ Failed to get promotion stats:", error);
|
|
return reply.status(500).send({
|
|
success: false,
|
|
message: "Failed to retrieve promotion statistics",
|
|
error: error.message,
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
// GET /admin/promotions/:id/usage - Get promotion usage details with user info
|
|
fastify.get(
|
|
"/promotions/:id/usage",
|
|
{
|
|
preHandler: [authenticate, isAdmin],
|
|
schema: {
|
|
params: {
|
|
type: "object",
|
|
required: ["id"],
|
|
properties: {
|
|
id: { type: "string" },
|
|
},
|
|
},
|
|
querystring: {
|
|
type: "object",
|
|
properties: {
|
|
limit: { type: "integer", minimum: 1, maximum: 100, default: 50 },
|
|
skip: { type: "integer", minimum: 0, default: 0 },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
try {
|
|
const { id } = request.params;
|
|
const { limit = 50, skip = 0 } = request.query;
|
|
|
|
const usages = await PromoUsage.find({ promoId: id })
|
|
.populate("userId", "username steamId avatar")
|
|
.sort({ usedAt: -1 })
|
|
.limit(limit)
|
|
.skip(skip);
|
|
|
|
const total = await PromoUsage.countDocuments({ promoId: id });
|
|
const stats = await PromoUsage.getPromoStats(id);
|
|
|
|
return reply.send({
|
|
success: true,
|
|
usages,
|
|
stats,
|
|
pagination: {
|
|
total,
|
|
limit,
|
|
skip,
|
|
hasMore: skip + limit < total,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error("❌ Failed to get promotion usage:", error);
|
|
return reply.status(500).send({
|
|
success: false,
|
|
message: "Failed to retrieve promotion usage",
|
|
error: error.message,
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
// ============================================
|
|
// BULK OPERATIONS
|
|
// ============================================
|
|
|
|
// POST /admin/users/bulk-ban - Bulk ban users
|
|
fastify.post(
|
|
"/users/bulk-ban",
|
|
{
|
|
preHandler: [authenticate, isAdmin],
|
|
schema: {
|
|
body: {
|
|
type: "object",
|
|
required: ["userIds", "banned"],
|
|
properties: {
|
|
userIds: {
|
|
type: "array",
|
|
items: { type: "string" },
|
|
minItems: 1,
|
|
},
|
|
banned: { type: "boolean" },
|
|
reason: { type: "string" },
|
|
duration: { type: "integer", minimum: 0 },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
async (request, reply) => {
|
|
try {
|
|
const { userIds, banned, reason, duration = 0 } = request.body;
|
|
|
|
let expires = null;
|
|
if (banned && duration > 0) {
|
|
expires = new Date(Date.now() + duration * 60 * 60 * 1000);
|
|
}
|
|
|
|
const result = await User.updateMany(
|
|
{
|
|
_id: { $in: userIds },
|
|
staffLevel: { $lt: 3 }, // Don't ban admins
|
|
},
|
|
{
|
|
$set: {
|
|
"ban.banned": banned,
|
|
"ban.reason": banned ? reason || "Banned by administrator" : null,
|
|
"ban.expires": banned ? expires : null,
|
|
},
|
|
}
|
|
);
|
|
|
|
console.log(
|
|
`🔨 Admin ${request.user.username} bulk ${
|
|
banned ? "banned" : "unbanned"
|
|
} ${result.modifiedCount} users`
|
|
);
|
|
|
|
return reply.send({
|
|
success: true,
|
|
message: `${result.modifiedCount} users ${
|
|
banned ? "banned" : "unbanned"
|
|
}`,
|
|
modifiedCount: result.modifiedCount,
|
|
});
|
|
} catch (error) {
|
|
console.error("❌ Failed to bulk ban users:", error);
|
|
return reply.status(500).send({
|
|
success: false,
|
|
message: "Failed to perform bulk ban",
|
|
error: error.message,
|
|
});
|
|
}
|
|
}
|
|
);
|
|
}
|