Creates SQL migration script for federation tables in Supabase and adds detailed logging to federation API endpoints in webServer.js. Replit-Commit-Author: Agent Replit-Commit-Session-Id: aed2e46d-25bb-4b73-81a1-bb9e8437c261 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: f067ae59-c14c-4c16-bbf9-bb3883920fcc Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3bdfff67-975a-46ad-9845-fbb6b4a4c4b5/aed2e46d-25bb-4b73-81a1-bb9e8437c261/BXwPVpJ Replit-Helium-Checkpoint-Created: true
3104 lines
99 KiB
JavaScript
3104 lines
99 KiB
JavaScript
const express = require('express');
|
|
const session = require('express-session');
|
|
const cookieParser = require('cookie-parser');
|
|
const cors = require('cors');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const Stripe = require('stripe');
|
|
const { invalidateCooldownCache } = require('../utils/cooldownManager');
|
|
|
|
function createWebServer(discordClient, supabase, options = {}) {
|
|
const app = express();
|
|
|
|
const stripe = process.env.STRIPE_SECRET_KEY ? new Stripe(process.env.STRIPE_SECRET_KEY) : null;
|
|
const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET;
|
|
|
|
const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID;
|
|
const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET;
|
|
const SESSION_SECRET = process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex');
|
|
// Production URL for OAuth
|
|
const BASE_URL = process.env.BASE_URL || 'https://bot.aethex.dev';
|
|
|
|
app.use(cors({
|
|
origin: true,
|
|
credentials: true
|
|
}));
|
|
app.use(cookieParser());
|
|
|
|
// Stripe webhook must be registered BEFORE express.json() to access raw body
|
|
app.post('/api/stripe/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
|
|
if (!stripe || !STRIPE_WEBHOOK_SECRET) {
|
|
return res.status(503).json({ error: 'Stripe webhook not configured' });
|
|
}
|
|
|
|
const sig = req.headers['stripe-signature'];
|
|
let event;
|
|
|
|
try {
|
|
event = stripe.webhooks.constructEvent(req.body, sig, STRIPE_WEBHOOK_SECRET);
|
|
} catch (err) {
|
|
console.error('Webhook signature verification failed:', err.message);
|
|
return res.status(400).send(`Webhook Error: ${err.message}`);
|
|
}
|
|
|
|
try {
|
|
switch (event.type) {
|
|
case 'checkout.session.completed': {
|
|
const session = event.data.object;
|
|
const { guild_id, plan_type } = session.metadata;
|
|
|
|
if (plan_type === 'premium') {
|
|
await supabase.from('federation_servers').update({
|
|
tier: 'premium',
|
|
subscription_id: session.subscription,
|
|
subscription_status: 'active',
|
|
updated_at: new Date().toISOString()
|
|
}).eq('guild_id', guild_id);
|
|
|
|
console.log(`[Stripe] Guild ${guild_id} upgraded to premium`);
|
|
} else if (plan_type === 'featured') {
|
|
const expiresAt = new Date();
|
|
expiresAt.setDate(expiresAt.getDate() + 7);
|
|
|
|
await supabase.from('federation_featured').upsert({
|
|
guild_id: guild_id,
|
|
subscription_id: session.subscription,
|
|
active: true,
|
|
expires_at: expiresAt.toISOString(),
|
|
created_at: new Date().toISOString()
|
|
}, { onConflict: 'guild_id' });
|
|
|
|
console.log(`[Stripe] Guild ${guild_id} purchased featured slot`);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'customer.subscription.updated': {
|
|
const subscription = event.data.object;
|
|
const status = subscription.status;
|
|
|
|
if (status === 'active') {
|
|
await supabase.from('federation_servers').update({
|
|
subscription_status: 'active'
|
|
}).eq('subscription_id', subscription.id);
|
|
} else if (status === 'past_due' || status === 'unpaid') {
|
|
await supabase.from('federation_servers').update({
|
|
subscription_status: status
|
|
}).eq('subscription_id', subscription.id);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'customer.subscription.deleted': {
|
|
const subscription = event.data.object;
|
|
|
|
await supabase.from('federation_servers').update({
|
|
tier: 'free',
|
|
subscription_id: null,
|
|
subscription_status: null,
|
|
updated_at: new Date().toISOString()
|
|
}).eq('subscription_id', subscription.id);
|
|
|
|
await supabase.from('federation_featured').update({
|
|
active: false
|
|
}).eq('subscription_id', subscription.id);
|
|
|
|
console.log(`[Stripe] Subscription ${subscription.id} canceled`);
|
|
break;
|
|
}
|
|
}
|
|
|
|
res.json({ received: true });
|
|
} catch (error) {
|
|
console.error('Webhook processing error:', error);
|
|
res.status(500).json({ error: 'Webhook processing failed' });
|
|
}
|
|
});
|
|
|
|
app.use(express.json());
|
|
app.use(express.urlencoded({ extended: true }));
|
|
|
|
app.use(session({
|
|
secret: SESSION_SECRET,
|
|
resave: false,
|
|
saveUninitialized: false,
|
|
cookie: {
|
|
secure: process.env.NODE_ENV === 'production',
|
|
httpOnly: true,
|
|
maxAge: 7 * 24 * 60 * 60 * 1000
|
|
}
|
|
}));
|
|
|
|
app.use((req, res, next) => {
|
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
res.setHeader('Pragma', 'no-cache');
|
|
res.setHeader('Expires', '0');
|
|
next();
|
|
});
|
|
|
|
app.use(express.static(path.join(__dirname, '../public')));
|
|
|
|
app.get('/auth/discord', (req, res) => {
|
|
if (!DISCORD_CLIENT_SECRET) {
|
|
return res.status(500).json({ error: 'Discord OAuth not configured. Please set DISCORD_CLIENT_SECRET.' });
|
|
}
|
|
|
|
const state = crypto.randomBytes(16).toString('hex');
|
|
req.session.oauthState = state;
|
|
|
|
const redirectUri = `${BASE_URL}/auth/callback`;
|
|
const scope = 'identify guilds';
|
|
|
|
const authUrl = `https://discord.com/api/oauth2/authorize?` +
|
|
`client_id=${DISCORD_CLIENT_ID}` +
|
|
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
|
`&response_type=code` +
|
|
`&scope=${encodeURIComponent(scope)}` +
|
|
`&state=${state}`;
|
|
|
|
res.redirect(authUrl);
|
|
});
|
|
|
|
app.get('/auth/callback', async (req, res) => {
|
|
const { code, state } = req.query;
|
|
|
|
if (!code) {
|
|
return res.redirect('/?error=no_code');
|
|
}
|
|
|
|
if (state !== req.session.oauthState) {
|
|
return res.redirect('/?error=invalid_state');
|
|
}
|
|
|
|
try {
|
|
const redirectUri = `${BASE_URL}/auth/callback`;
|
|
|
|
const tokenResponse = await fetch('https://discord.com/api/oauth2/token', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded'
|
|
},
|
|
body: new URLSearchParams({
|
|
client_id: DISCORD_CLIENT_ID,
|
|
client_secret: DISCORD_CLIENT_SECRET,
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
redirect_uri: redirectUri
|
|
})
|
|
});
|
|
|
|
if (!tokenResponse.ok) {
|
|
console.error('Token exchange failed:', await tokenResponse.text());
|
|
return res.redirect('/?error=token_failed');
|
|
}
|
|
|
|
const tokens = await tokenResponse.json();
|
|
|
|
const userResponse = await fetch('https://discord.com/api/users/@me', {
|
|
headers: {
|
|
Authorization: `Bearer ${tokens.access_token}`
|
|
}
|
|
});
|
|
|
|
if (!userResponse.ok) {
|
|
return res.redirect('/?error=user_fetch_failed');
|
|
}
|
|
|
|
const user = await userResponse.json();
|
|
|
|
const guildsResponse = await fetch('https://discord.com/api/users/@me/guilds', {
|
|
headers: {
|
|
Authorization: `Bearer ${tokens.access_token}`
|
|
}
|
|
});
|
|
|
|
let guilds = [];
|
|
if (guildsResponse.ok) {
|
|
guilds = await guildsResponse.json();
|
|
}
|
|
|
|
req.session.user = {
|
|
id: user.id,
|
|
username: user.username,
|
|
discriminator: user.discriminator,
|
|
avatar: user.avatar,
|
|
globalName: user.global_name,
|
|
accessToken: tokens.access_token,
|
|
refreshToken: tokens.refresh_token,
|
|
guilds: guilds.map(g => ({
|
|
id: g.id,
|
|
name: g.name,
|
|
icon: g.icon,
|
|
owner: g.owner,
|
|
permissions: g.permissions,
|
|
isAdmin: (parseInt(g.permissions) & 0x20) === 0x20
|
|
}))
|
|
};
|
|
|
|
res.redirect('/dashboard');
|
|
|
|
} catch (error) {
|
|
console.error('OAuth callback error:', error);
|
|
res.redirect('/?error=auth_failed');
|
|
}
|
|
});
|
|
|
|
app.get('/auth/logout', (req, res) => {
|
|
req.session.destroy();
|
|
res.redirect('/');
|
|
});
|
|
|
|
app.get('/api/me', (req, res) => {
|
|
if (!req.session.user) {
|
|
return res.status(401).json({ error: 'Not authenticated' });
|
|
}
|
|
|
|
const user = req.session.user;
|
|
res.json({
|
|
id: user.id,
|
|
username: user.username,
|
|
discriminator: user.discriminator,
|
|
avatar: user.avatar,
|
|
globalName: user.globalName,
|
|
avatarUrl: user.avatar
|
|
? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`
|
|
: `https://cdn.discordapp.com/embed/avatars/${parseInt(user.discriminator || '0') % 5}.png`,
|
|
guilds: user.guilds.map(g => ({
|
|
...g,
|
|
icon: g.icon ? `https://cdn.discordapp.com/icons/${g.id}/${g.icon}.png` : null
|
|
}))
|
|
});
|
|
});
|
|
|
|
// Profile handler function
|
|
async function handleProfileRequest(req, res, userId) {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const targetUserId = userId || req.session.user?.id;
|
|
if (!targetUserId) {
|
|
return res.status(400).json({ error: 'User ID required' });
|
|
}
|
|
|
|
try {
|
|
const { data: link } = await supabase
|
|
.from('discord_links')
|
|
.select('user_id')
|
|
.eq('discord_id', targetUserId)
|
|
.maybeSingle();
|
|
|
|
if (!link) {
|
|
return res.json({ linked: false, message: 'Discord account not linked' });
|
|
}
|
|
|
|
const { data: profile } = await supabase
|
|
.from('user_profiles')
|
|
.select('*')
|
|
.eq('id', link.user_id)
|
|
.maybeSingle();
|
|
|
|
if (!profile) {
|
|
return res.json({ linked: true, profile: null });
|
|
}
|
|
|
|
res.json({
|
|
linked: true,
|
|
profile: {
|
|
id: profile.id,
|
|
username: profile.username,
|
|
xp: profile.xp || 0,
|
|
prestigeLevel: profile.prestige_level || 0,
|
|
totalXpEarned: profile.total_xp_earned || 0,
|
|
dailyStreak: profile.daily_streak || 0,
|
|
title: profile.title,
|
|
bio: profile.bio,
|
|
avatarUrl: profile.avatar_url,
|
|
createdAt: profile.created_at
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Profile fetch error:', error);
|
|
res.status(500).json({ error: 'Failed to fetch profile' });
|
|
}
|
|
}
|
|
|
|
// Route for current user's profile
|
|
app.get('/api/profile', (req, res) => handleProfileRequest(req, res, null));
|
|
|
|
// Route for specific user's profile
|
|
app.get('/api/profile/:userId', (req, res) => handleProfileRequest(req, res, req.params.userId));
|
|
|
|
// Verification callback endpoint - called by aethex.dev when user verifies
|
|
app.post('/api/verify-callback', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const { discord_id, user_id, secret } = req.body;
|
|
|
|
// Verify the callback secret (should match what aethex.dev uses)
|
|
const expectedSecret = process.env.VERIFY_CALLBACK_SECRET;
|
|
if (expectedSecret && secret !== expectedSecret) {
|
|
return res.status(401).json({ error: 'Invalid callback secret' });
|
|
}
|
|
|
|
if (!discord_id || !user_id) {
|
|
return res.status(400).json({ error: 'Missing discord_id or user_id' });
|
|
}
|
|
|
|
try {
|
|
// Get server configs that have a verified role configured
|
|
const { data: configs } = await supabase
|
|
.from('server_config')
|
|
.select('guild_id, verified_role_id')
|
|
.not('verified_role_id', 'is', null);
|
|
|
|
let rolesAssigned = 0;
|
|
|
|
// Assign verified role in all guilds where the user is a member
|
|
for (const config of configs || []) {
|
|
if (!config.verified_role_id) continue;
|
|
|
|
try {
|
|
const guild = client.guilds.cache.get(config.guild_id);
|
|
if (!guild) continue;
|
|
|
|
const member = await guild.members.fetch(discord_id).catch(() => null);
|
|
if (!member) continue;
|
|
|
|
const role = guild.roles.cache.get(config.verified_role_id);
|
|
if (!role) continue;
|
|
|
|
await member.roles.add(role, 'Account verified via aethex.dev');
|
|
rolesAssigned++;
|
|
console.log(`[Verify] Assigned verified role to ${member.user.tag} in ${guild.name}`);
|
|
} catch (err) {
|
|
console.error(`[Verify] Failed to assign role in guild ${config.guild_id}:`, err.message);
|
|
}
|
|
}
|
|
|
|
// Clean up the verification code
|
|
await supabase.from('discord_verifications').delete().eq('discord_id', discord_id);
|
|
|
|
res.json({ success: true, rolesAssigned });
|
|
} catch (error) {
|
|
console.error('Verify callback error:', error);
|
|
res.status(500).json({ error: 'Failed to process verification callback' });
|
|
}
|
|
});
|
|
|
|
// Verification status endpoint
|
|
app.get('/api/verify-status/:discordId', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const { discordId } = req.params;
|
|
|
|
try {
|
|
const { data: link } = await supabase
|
|
.from('discord_links')
|
|
.select('user_id, linked_at')
|
|
.eq('discord_id', discordId)
|
|
.maybeSingle();
|
|
|
|
const { data: pending } = await supabase
|
|
.from('discord_verifications')
|
|
.select('verification_code, expires_at')
|
|
.eq('discord_id', discordId)
|
|
.maybeSingle();
|
|
|
|
res.json({
|
|
verified: !!link,
|
|
userId: link?.user_id || null,
|
|
linkedAt: link?.linked_at || null,
|
|
pendingVerification: !!pending,
|
|
pendingExpires: pending?.expires_at || null
|
|
});
|
|
} catch (error) {
|
|
console.error('Verify status error:', error);
|
|
res.status(500).json({ error: 'Failed to fetch verification status' });
|
|
}
|
|
});
|
|
|
|
// Verification success endpoint - called by aethex.dev when verification completes
|
|
app.post('/api/verify-success', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const webhookSecret = process.env.DISCORD_BOT_WEBHOOK_SECRET;
|
|
const providedSecret = req.headers['x-webhook-secret'] || req.body.secret;
|
|
|
|
if (webhookSecret && providedSecret !== webhookSecret) {
|
|
console.log('[Verify-Success] Invalid webhook secret provided');
|
|
return res.status(401).json({ error: 'Invalid webhook secret' });
|
|
}
|
|
|
|
const { discord_id, user_id, username } = req.body;
|
|
|
|
if (!discord_id) {
|
|
return res.status(400).json({ error: 'Missing discord_id' });
|
|
}
|
|
|
|
console.log(`[Verify-Success] Received verification for Discord ID: ${discord_id}`);
|
|
|
|
try {
|
|
// Update or create the discord link
|
|
if (user_id) {
|
|
await supabase.from('discord_links').upsert({
|
|
discord_id,
|
|
user_id,
|
|
username: username || null,
|
|
linked_at: new Date().toISOString()
|
|
}, { onConflict: 'discord_id' });
|
|
}
|
|
|
|
// Get server configs that have a verified role configured
|
|
const { data: configs } = await supabase
|
|
.from('server_config')
|
|
.select('guild_id, verified_role_id')
|
|
.not('verified_role_id', 'is', null);
|
|
|
|
let rolesAssigned = 0;
|
|
const assignedIn = [];
|
|
|
|
// Assign verified role in all guilds where the user is a member
|
|
for (const config of configs || []) {
|
|
if (!config.verified_role_id) continue;
|
|
|
|
try {
|
|
const guild = client.guilds.cache.get(config.guild_id);
|
|
if (!guild) continue;
|
|
|
|
const member = await guild.members.fetch(discord_id).catch(() => null);
|
|
if (!member) continue;
|
|
|
|
const role = guild.roles.cache.get(config.verified_role_id);
|
|
if (!role) continue;
|
|
|
|
if (!member.roles.cache.has(role.id)) {
|
|
await member.roles.add(role, 'Account verified via aethex.dev');
|
|
rolesAssigned++;
|
|
assignedIn.push(guild.name);
|
|
console.log(`[Verify-Success] Assigned verified role to ${member.user.tag} in ${guild.name}`);
|
|
}
|
|
} catch (err) {
|
|
console.error(`[Verify-Success] Failed to assign role in guild ${config.guild_id}:`, err.message);
|
|
}
|
|
}
|
|
|
|
// Clean up any pending verification codes
|
|
await supabase.from('discord_verifications').delete().eq('discord_id', discord_id);
|
|
|
|
res.json({
|
|
success: true,
|
|
rolesAssigned,
|
|
assignedIn,
|
|
message: rolesAssigned > 0
|
|
? `Verified role assigned in ${rolesAssigned} server(s)`
|
|
: 'Verification recorded (user not found in any configured servers)'
|
|
});
|
|
} catch (error) {
|
|
console.error('[Verify-Success] Error:', error);
|
|
res.status(500).json({ error: 'Failed to process verification' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/stats/:userId/:guildId', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const { userId, guildId } = req.params;
|
|
|
|
try {
|
|
const { data: link } = await supabase
|
|
.from('discord_links')
|
|
.select('user_id')
|
|
.eq('discord_id', userId)
|
|
.maybeSingle();
|
|
|
|
if (!link) {
|
|
return res.json({ stats: null });
|
|
}
|
|
|
|
const { data: stats } = await supabase
|
|
.from('user_stats')
|
|
.select('*')
|
|
.eq('user_id', link.user_id)
|
|
.eq('guild_id', guildId)
|
|
.maybeSingle();
|
|
|
|
res.json({ stats });
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to fetch stats' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/achievements/:guildId', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const { guildId } = req.params;
|
|
const userId = req.session.user?.id;
|
|
|
|
try {
|
|
const { data: achievements } = await supabase
|
|
.from('achievements')
|
|
.select('*')
|
|
.eq('guild_id', guildId)
|
|
.eq('hidden', false)
|
|
.order('created_at', { ascending: true });
|
|
|
|
let userAchievements = [];
|
|
if (userId) {
|
|
const { data: link } = await supabase
|
|
.from('discord_links')
|
|
.select('user_id')
|
|
.eq('discord_id', userId)
|
|
.maybeSingle();
|
|
|
|
if (link) {
|
|
const { data } = await supabase
|
|
.from('user_achievements')
|
|
.select('achievement_id, earned_at')
|
|
.eq('user_id', link.user_id)
|
|
.eq('guild_id', guildId);
|
|
userAchievements = data || [];
|
|
}
|
|
}
|
|
|
|
res.json({ achievements, userAchievements });
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to fetch achievements' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/quests/:guildId', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const { guildId } = req.params;
|
|
const userId = req.session.user?.id;
|
|
|
|
try {
|
|
const now = new Date().toISOString();
|
|
const { data: quests } = await supabase
|
|
.from('quests')
|
|
.select('*')
|
|
.eq('guild_id', guildId)
|
|
.eq('active', true)
|
|
.or(`starts_at.is.null,starts_at.lte.${now}`)
|
|
.or(`expires_at.is.null,expires_at.gt.${now}`)
|
|
.order('created_at', { ascending: false });
|
|
|
|
let userQuests = [];
|
|
if (userId) {
|
|
const { data: link } = await supabase
|
|
.from('discord_links')
|
|
.select('user_id')
|
|
.eq('discord_id', userId)
|
|
.maybeSingle();
|
|
|
|
if (link) {
|
|
const { data } = await supabase
|
|
.from('user_quests')
|
|
.select('*')
|
|
.eq('user_id', link.user_id)
|
|
.eq('guild_id', guildId);
|
|
userQuests = data || [];
|
|
}
|
|
}
|
|
|
|
res.json({ quests, userQuests });
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to fetch quests' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/shop/:guildId', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const { guildId } = req.params;
|
|
|
|
try {
|
|
const { data: items } = await supabase
|
|
.from('shop_items')
|
|
.select('*')
|
|
.eq('guild_id', guildId)
|
|
.eq('enabled', true)
|
|
.order('price', { ascending: true });
|
|
|
|
res.json({ items: items || [] });
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to fetch shop items' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/inventory/:guildId', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) {
|
|
return res.status(401).json({ error: 'Not authenticated' });
|
|
}
|
|
|
|
const { guildId } = req.params;
|
|
|
|
try {
|
|
const { data: link } = await supabase
|
|
.from('discord_links')
|
|
.select('user_id')
|
|
.eq('discord_id', userId)
|
|
.maybeSingle();
|
|
|
|
if (!link) {
|
|
return res.json({ inventory: [] });
|
|
}
|
|
|
|
const { data: inventory } = await supabase
|
|
.from('user_inventory')
|
|
.select(`
|
|
*,
|
|
shop_items (name, description, item_type, item_data)
|
|
`)
|
|
.eq('user_id', link.user_id)
|
|
.eq('guild_id', guildId);
|
|
|
|
res.json({ inventory: inventory || [] });
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to fetch inventory' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/leaderboard/:guildId', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const { guildId } = req.params;
|
|
const { type = 'all', limit = 50 } = req.query;
|
|
|
|
try {
|
|
let tableName = 'leaderboard_alltime';
|
|
if (type === 'weekly') tableName = 'leaderboard_weekly';
|
|
else if (type === 'monthly') tableName = 'leaderboard_monthly';
|
|
|
|
const { data: leaderboard } = await supabase
|
|
.from(tableName)
|
|
.select(`
|
|
*,
|
|
user_profiles!inner (username, avatar_url, prestige_level)
|
|
`)
|
|
.eq('guild_id', guildId)
|
|
.order('xp', { ascending: false })
|
|
.limit(parseInt(limit));
|
|
|
|
res.json({ leaderboard: leaderboard || [], type });
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to fetch leaderboard' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/bot-stats', (req, res) => {
|
|
const stats = {
|
|
guilds: discordClient.guilds.cache.size,
|
|
users: discordClient.guilds.cache.reduce((acc, g) => acc + g.memberCount, 0),
|
|
commands: discordClient.commands?.size || 0,
|
|
uptime: Math.floor(process.uptime()),
|
|
status: discordClient.user ? 'online' : 'offline',
|
|
botName: discordClient.user?.username || 'AeThex',
|
|
botAvatar: discordClient.user?.displayAvatarURL() || null
|
|
};
|
|
res.json(stats);
|
|
});
|
|
|
|
app.get('/api/guild/:guildId/config', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) {
|
|
return res.status(401).json({ error: 'Not authenticated' });
|
|
}
|
|
|
|
const { guildId } = req.params;
|
|
|
|
const userGuild = req.session.user.guilds.find(g => g.id === guildId);
|
|
if (!userGuild || !userGuild.isAdmin) {
|
|
return res.status(403).json({ error: 'No admin access to this server' });
|
|
}
|
|
|
|
try {
|
|
const { data: xpConfig } = await supabase
|
|
.from('xp_config')
|
|
.select('*')
|
|
.eq('guild_id', guildId)
|
|
.maybeSingle();
|
|
|
|
const { data: serverConfig } = await supabase
|
|
.from('server_config')
|
|
.select('*')
|
|
.eq('guild_id', guildId)
|
|
.maybeSingle();
|
|
|
|
res.json({ xpConfig, serverConfig });
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to fetch config' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/guild/:guildId/xp-config', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) {
|
|
return res.status(401).json({ error: 'Not authenticated' });
|
|
}
|
|
|
|
const { guildId } = req.params;
|
|
|
|
const userGuild = req.session.user.guilds.find(g => g.id === guildId);
|
|
if (!userGuild || !userGuild.isAdmin) {
|
|
return res.status(403).json({ error: 'No admin access to this server' });
|
|
}
|
|
|
|
const {
|
|
message_xp,
|
|
message_cooldown,
|
|
reaction_xp_enabled,
|
|
reaction_xp_given,
|
|
reaction_xp_received,
|
|
reaction_cooldown,
|
|
level_curve,
|
|
xp_enabled,
|
|
levelup_channel_id,
|
|
levelup_message,
|
|
levelup_dm,
|
|
levelup_embed,
|
|
levelup_embed_color,
|
|
multiplier_roles,
|
|
bonus_channels
|
|
} = req.body;
|
|
|
|
try {
|
|
const { data: existing } = await supabase
|
|
.from('xp_config')
|
|
.select('guild_id')
|
|
.eq('guild_id', guildId)
|
|
.maybeSingle();
|
|
|
|
const configData = {
|
|
guild_id: guildId,
|
|
message_xp: message_xp ?? 15,
|
|
message_cooldown: message_cooldown ?? 60,
|
|
reaction_xp_enabled: reaction_xp_enabled !== false,
|
|
reaction_xp_given: reaction_xp_given ?? 2,
|
|
reaction_xp_received: reaction_xp_received ?? 5,
|
|
reaction_cooldown: reaction_cooldown ?? 30,
|
|
level_curve: level_curve || 'normal',
|
|
xp_enabled: xp_enabled !== false,
|
|
levelup_channel_id: levelup_channel_id || null,
|
|
levelup_message: levelup_message || null,
|
|
levelup_dm: levelup_dm === true,
|
|
levelup_embed: levelup_embed !== false,
|
|
levelup_embed_color: levelup_embed_color || '#7c3aed',
|
|
multiplier_roles: multiplier_roles || [],
|
|
bonus_channels: bonus_channels || [],
|
|
updated_at: new Date().toISOString()
|
|
};
|
|
|
|
if (existing) {
|
|
const { error } = await supabase
|
|
.from('xp_config')
|
|
.update(configData)
|
|
.eq('guild_id', guildId);
|
|
|
|
if (error) throw error;
|
|
} else {
|
|
const { error } = await supabase
|
|
.from('xp_config')
|
|
.insert(configData);
|
|
|
|
if (error) throw error;
|
|
}
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Failed to save XP config:', error);
|
|
res.status(500).json({ error: 'Failed to save settings', details: error.message });
|
|
}
|
|
});
|
|
|
|
app.get('/api/guild/:guildId/quests', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) {
|
|
return res.status(401).json({ error: 'Not authenticated' });
|
|
}
|
|
|
|
const { guildId } = req.params;
|
|
|
|
const userGuild = req.session.user.guilds.find(g => g.id === guildId);
|
|
if (!userGuild || !userGuild.isAdmin) {
|
|
return res.status(403).json({ error: 'No admin access' });
|
|
}
|
|
|
|
try {
|
|
const { data: quests } = await supabase
|
|
.from('quests')
|
|
.select('*')
|
|
.eq('guild_id', guildId)
|
|
.order('created_at', { ascending: false });
|
|
|
|
res.json({ quests: quests || [] });
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to fetch quests' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/guild/:guildId/quests', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) {
|
|
return res.status(401).json({ error: 'Not authenticated' });
|
|
}
|
|
|
|
const { guildId } = req.params;
|
|
|
|
const userGuild = req.session.user.guilds.find(g => g.id === guildId);
|
|
if (!userGuild || !userGuild.isAdmin) {
|
|
return res.status(403).json({ error: 'No admin access' });
|
|
}
|
|
|
|
const { name, description, quest_type, objective, target_value, xp_reward, duration_hours, repeatable, active } = req.body;
|
|
|
|
try {
|
|
const questData = {
|
|
guild_id: guildId,
|
|
name,
|
|
description: description || null,
|
|
quest_type: quest_type || 'daily',
|
|
objective: objective || 'messages',
|
|
target_value: target_value || 10,
|
|
xp_reward: xp_reward || 100,
|
|
repeatable: repeatable || false,
|
|
active: active !== false,
|
|
created_at: new Date().toISOString()
|
|
};
|
|
|
|
if (duration_hours && duration_hours > 0) {
|
|
const expiry = new Date();
|
|
expiry.setHours(expiry.getHours() + duration_hours);
|
|
questData.expires_at = expiry.toISOString();
|
|
}
|
|
|
|
const { error } = await supabase.from('quests').insert(questData);
|
|
|
|
if (error) throw error;
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Failed to create quest:', error);
|
|
res.status(500).json({ error: 'Failed to create quest' });
|
|
}
|
|
});
|
|
|
|
app.put('/api/guild/:guildId/quests/:questId', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) {
|
|
return res.status(401).json({ error: 'Not authenticated' });
|
|
}
|
|
|
|
const { guildId, questId } = req.params;
|
|
|
|
const userGuild = req.session.user.guilds.find(g => g.id === guildId);
|
|
if (!userGuild || !userGuild.isAdmin) {
|
|
return res.status(403).json({ error: 'No admin access' });
|
|
}
|
|
|
|
const { name, description, quest_type, objective, target_value, xp_reward, repeatable, active } = req.body;
|
|
|
|
try {
|
|
const { error } = await supabase
|
|
.from('quests')
|
|
.update({
|
|
name,
|
|
description: description || null,
|
|
quest_type,
|
|
objective,
|
|
target_value,
|
|
xp_reward,
|
|
repeatable: repeatable || false,
|
|
active
|
|
})
|
|
.eq('id', questId)
|
|
.eq('guild_id', guildId);
|
|
|
|
if (error) throw error;
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Failed to update quest:', error);
|
|
res.status(500).json({ error: 'Failed to update quest' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/guild/:guildId/quests/:questId', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) {
|
|
return res.status(401).json({ error: 'Not authenticated' });
|
|
}
|
|
|
|
const { guildId, questId } = req.params;
|
|
|
|
const userGuild = req.session.user.guilds.find(g => g.id === guildId);
|
|
if (!userGuild || !userGuild.isAdmin) {
|
|
return res.status(403).json({ error: 'No admin access' });
|
|
}
|
|
|
|
try {
|
|
const { error } = await supabase
|
|
.from('quests')
|
|
.delete()
|
|
.eq('id', questId)
|
|
.eq('guild_id', guildId);
|
|
|
|
if (error) throw error;
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Failed to delete quest:', error);
|
|
res.status(500).json({ error: 'Failed to delete quest' });
|
|
}
|
|
});
|
|
|
|
// ============ ACHIEVEMENTS ADMIN API ============
|
|
|
|
app.get('/api/guild/:guildId/achievements', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) {
|
|
return res.status(401).json({ error: 'Not authenticated' });
|
|
}
|
|
|
|
const { guildId } = req.params;
|
|
|
|
const userGuild = req.session.user.guilds.find(g => g.id === guildId);
|
|
if (!userGuild || !userGuild.isAdmin) {
|
|
return res.status(403).json({ error: 'No admin access' });
|
|
}
|
|
|
|
try {
|
|
const { data: achievements } = await supabase
|
|
.from('achievements')
|
|
.select('*')
|
|
.eq('guild_id', guildId)
|
|
.order('created_at', { ascending: false });
|
|
|
|
res.json({ achievements: achievements || [] });
|
|
} catch (error) {
|
|
console.error('Failed to fetch achievements:', error);
|
|
res.status(500).json({ error: 'Failed to fetch achievements' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/guild/:guildId/achievements', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) {
|
|
return res.status(401).json({ error: 'Not authenticated' });
|
|
}
|
|
|
|
const { guildId } = req.params;
|
|
|
|
const userGuild = req.session.user.guilds.find(g => g.id === guildId);
|
|
if (!userGuild || !userGuild.isAdmin) {
|
|
return res.status(403).json({ error: 'No admin access' });
|
|
}
|
|
|
|
const { name, icon, description, trigger_type, trigger_value, reward_xp, reward_role_id, hidden } = req.body;
|
|
|
|
try {
|
|
const achievementData = {
|
|
guild_id: guildId,
|
|
name,
|
|
icon: icon || '🏆',
|
|
description: description || null,
|
|
trigger_type: trigger_type || 'level',
|
|
trigger_value: trigger_value || 1,
|
|
reward_xp: reward_xp || 0,
|
|
reward_role_id: reward_role_id || null,
|
|
hidden: hidden || false,
|
|
created_at: new Date().toISOString()
|
|
};
|
|
|
|
const { error } = await supabase.from('achievements').insert(achievementData);
|
|
|
|
if (error) throw error;
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Failed to create achievement:', error);
|
|
res.status(500).json({ error: 'Failed to create achievement' });
|
|
}
|
|
});
|
|
|
|
app.put('/api/guild/:guildId/achievements/:achievementId', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) {
|
|
return res.status(401).json({ error: 'Not authenticated' });
|
|
}
|
|
|
|
const { guildId, achievementId } = req.params;
|
|
|
|
const userGuild = req.session.user.guilds.find(g => g.id === guildId);
|
|
if (!userGuild || !userGuild.isAdmin) {
|
|
return res.status(403).json({ error: 'No admin access' });
|
|
}
|
|
|
|
const { name, icon, description, trigger_type, trigger_value, reward_xp, reward_role_id, hidden } = req.body;
|
|
|
|
try {
|
|
const { error } = await supabase
|
|
.from('achievements')
|
|
.update({
|
|
name,
|
|
icon: icon || '🏆',
|
|
description: description || null,
|
|
trigger_type,
|
|
trigger_value,
|
|
reward_xp: reward_xp || 0,
|
|
reward_role_id: reward_role_id || null,
|
|
hidden: hidden || false
|
|
})
|
|
.eq('id', achievementId)
|
|
.eq('guild_id', guildId);
|
|
|
|
if (error) throw error;
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Failed to update achievement:', error);
|
|
res.status(500).json({ error: 'Failed to update achievement' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/guild/:guildId/achievements/:achievementId', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) {
|
|
return res.status(401).json({ error: 'Not authenticated' });
|
|
}
|
|
|
|
const { guildId, achievementId } = req.params;
|
|
|
|
const userGuild = req.session.user.guilds.find(g => g.id === guildId);
|
|
if (!userGuild || !userGuild.isAdmin) {
|
|
return res.status(403).json({ error: 'No admin access' });
|
|
}
|
|
|
|
try {
|
|
const { error } = await supabase
|
|
.from('achievements')
|
|
.delete()
|
|
.eq('id', achievementId)
|
|
.eq('guild_id', guildId);
|
|
|
|
if (error) throw error;
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Failed to delete achievement:', error);
|
|
res.status(500).json({ error: 'Failed to delete achievement' });
|
|
}
|
|
});
|
|
|
|
// ============ SHOP ADMIN API ============
|
|
|
|
app.get('/api/guild/:guildId/shop/admin', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) {
|
|
return res.status(401).json({ error: 'Not authenticated' });
|
|
}
|
|
|
|
const { guildId } = req.params;
|
|
|
|
const userGuild = req.session.user.guilds.find(g => g.id === guildId);
|
|
if (!userGuild || !userGuild.isAdmin) {
|
|
return res.status(403).json({ error: 'No admin access' });
|
|
}
|
|
|
|
try {
|
|
const { data: items } = await supabase
|
|
.from('shop_items')
|
|
.select('*')
|
|
.eq('guild_id', guildId)
|
|
.order('created_at', { ascending: false });
|
|
|
|
res.json({ items: items || [] });
|
|
} catch (error) {
|
|
console.error('Failed to fetch shop items:', error);
|
|
res.status(500).json({ error: 'Failed to fetch shop items' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/guild/:guildId/shop', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) {
|
|
return res.status(401).json({ error: 'Not authenticated' });
|
|
}
|
|
|
|
const { guildId } = req.params;
|
|
|
|
const userGuild = req.session.user.guilds.find(g => g.id === guildId);
|
|
if (!userGuild || !userGuild.isAdmin) {
|
|
return res.status(403).json({ error: 'No admin access' });
|
|
}
|
|
|
|
const { name, item_type, description, price, stock, level_required, prestige_required, enabled, category, item_data } = req.body;
|
|
|
|
try {
|
|
const itemData = {
|
|
guild_id: guildId,
|
|
name,
|
|
item_type: item_type || 'cosmetic',
|
|
description: description || null,
|
|
category: category || null,
|
|
price: price || 100,
|
|
stock: stock || null,
|
|
item_data: item_data || null,
|
|
level_required: level_required || 0,
|
|
prestige_required: prestige_required || 0,
|
|
enabled: enabled !== false,
|
|
created_at: new Date().toISOString()
|
|
};
|
|
|
|
const { error } = await supabase.from('shop_items').insert(itemData);
|
|
|
|
if (error) throw error;
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Failed to create shop item:', error);
|
|
res.status(500).json({ error: 'Failed to create shop item', details: error.message });
|
|
}
|
|
});
|
|
|
|
app.put('/api/guild/:guildId/shop/:itemId', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) {
|
|
return res.status(401).json({ error: 'Not authenticated' });
|
|
}
|
|
|
|
const { guildId, itemId } = req.params;
|
|
|
|
const userGuild = req.session.user.guilds.find(g => g.id === guildId);
|
|
if (!userGuild || !userGuild.isAdmin) {
|
|
return res.status(403).json({ error: 'No admin access' });
|
|
}
|
|
|
|
const { name, item_type, description, price, stock, level_required, prestige_required, enabled, category, item_data } = req.body;
|
|
|
|
try {
|
|
const { error } = await supabase
|
|
.from('shop_items')
|
|
.update({
|
|
name,
|
|
item_type: item_type || 'cosmetic',
|
|
description: description || null,
|
|
category: category || null,
|
|
price: price || 100,
|
|
stock: stock || null,
|
|
item_data: item_data || null,
|
|
level_required: level_required || 0,
|
|
prestige_required: prestige_required || 0,
|
|
enabled: enabled !== false,
|
|
updated_at: new Date().toISOString()
|
|
})
|
|
.eq('id', itemId)
|
|
.eq('guild_id', guildId);
|
|
|
|
if (error) throw error;
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Failed to update shop item:', error);
|
|
res.status(500).json({ error: 'Failed to update shop item', details: error.message });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/guild/:guildId/shop/:itemId', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) {
|
|
return res.status(401).json({ error: 'Not authenticated' });
|
|
}
|
|
|
|
const { guildId, itemId } = req.params;
|
|
|
|
const userGuild = req.session.user.guilds.find(g => g.id === guildId);
|
|
if (!userGuild || !userGuild.isAdmin) {
|
|
return res.status(403).json({ error: 'No admin access' });
|
|
}
|
|
|
|
try {
|
|
const { error } = await supabase
|
|
.from('shop_items')
|
|
.delete()
|
|
.eq('id', itemId)
|
|
.eq('guild_id', guildId);
|
|
|
|
if (error) throw error;
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Failed to delete shop item:', error);
|
|
res.status(500).json({ error: 'Failed to delete shop item' });
|
|
}
|
|
});
|
|
|
|
// ============ FEDERATION API ============
|
|
|
|
app.get('/api/federation/stats', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
try {
|
|
const [servers, bans, applications] = await Promise.all([
|
|
supabase.from('federation_servers').select('id', { count: 'exact', head: true }).eq('status', 'approved'),
|
|
supabase.from('federation_bans').select('id', { count: 'exact', head: true }).eq('active', true),
|
|
supabase.from('federation_applications').select('id', { count: 'exact', head: true }).eq('status', 'pending')
|
|
]);
|
|
|
|
console.log('[Federation Stats] servers:', servers);
|
|
console.log('[Federation Stats] bans:', bans);
|
|
console.log('[Federation Stats] applications:', applications);
|
|
|
|
res.json({
|
|
totalServers: servers.count || 0,
|
|
activeBans: bans.count || 0,
|
|
pendingApplications: applications.count || 0
|
|
});
|
|
} catch (error) {
|
|
console.error('[Federation Stats] Error:', error);
|
|
res.status(500).json({ error: 'Failed to fetch stats' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/federation/bans', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const { limit = 50, severity } = req.query;
|
|
|
|
try {
|
|
let query = supabase
|
|
.from('federation_bans')
|
|
.select('*')
|
|
.eq('active', true)
|
|
.order('created_at', { ascending: false })
|
|
.limit(parseInt(limit));
|
|
|
|
if (severity) {
|
|
query = query.eq('severity', severity);
|
|
}
|
|
|
|
const { data: bans } = await query;
|
|
res.json({ bans: bans || [] });
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to fetch bans' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/federation/servers', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
try {
|
|
const { data: servers, error } = await supabase
|
|
.from('federation_servers')
|
|
.select('*')
|
|
.eq('status', 'approved')
|
|
.order('member_count', { ascending: false });
|
|
|
|
console.log('[Federation Servers] Query result:', { servers, error });
|
|
|
|
if (error) {
|
|
console.error('[Federation Servers] Supabase error:', error);
|
|
}
|
|
|
|
res.json({ servers: servers || [] });
|
|
} catch (error) {
|
|
console.error('[Federation Servers] Error:', error);
|
|
res.status(500).json({ error: 'Failed to fetch servers' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/federation/applications', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) {
|
|
return res.status(401).json({ error: 'Not authenticated' });
|
|
}
|
|
|
|
try {
|
|
const { data: applications } = await supabase
|
|
.from('federation_applications')
|
|
.select('*')
|
|
.order('created_at', { ascending: false });
|
|
|
|
res.json({ applications: applications || [] });
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to fetch applications' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/federation/applications/:appId/approve', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) {
|
|
return res.status(401).json({ error: 'Not authenticated' });
|
|
}
|
|
|
|
const { appId } = req.params;
|
|
|
|
try {
|
|
const { data: app } = await supabase
|
|
.from('federation_applications')
|
|
.select('*')
|
|
.eq('id', appId)
|
|
.maybeSingle();
|
|
|
|
if (!app) {
|
|
return res.status(404).json({ error: 'Application not found' });
|
|
}
|
|
|
|
await supabase.from('federation_applications').update({
|
|
status: 'approved',
|
|
reviewed_by: userId,
|
|
reviewed_at: new Date().toISOString()
|
|
}).eq('id', appId);
|
|
|
|
await supabase.from('federation_servers').insert({
|
|
guild_id: app.guild_id,
|
|
guild_name: app.guild_name,
|
|
guild_icon: app.guild_icon,
|
|
description: app.description,
|
|
category: app.category,
|
|
member_count: app.member_count,
|
|
owner_id: app.admin_id,
|
|
status: 'approved',
|
|
treaty_accepted: true,
|
|
treaty_accepted_at: new Date().toISOString()
|
|
});
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Failed to approve application:', error);
|
|
res.status(500).json({ error: 'Failed to approve application' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/federation/applications/:appId/reject', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) {
|
|
return res.status(401).json({ error: 'Not authenticated' });
|
|
}
|
|
|
|
const { appId } = req.params;
|
|
const { reason } = req.body;
|
|
|
|
try {
|
|
await supabase.from('federation_applications').update({
|
|
status: 'rejected',
|
|
reviewed_by: userId,
|
|
reviewed_at: new Date().toISOString(),
|
|
rejection_reason: reason || 'No reason provided'
|
|
}).eq('id', appId);
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to reject application' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/federation/leaderboard', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const { limit = 50 } = req.query;
|
|
|
|
try {
|
|
const { data: leaders } = await supabase
|
|
.from('federation_reputation')
|
|
.select('*')
|
|
.order('reputation_score', { ascending: false })
|
|
.limit(parseInt(limit));
|
|
|
|
res.json({ leaderboard: leaders || [] });
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to fetch leaderboard' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/federation/guild/:guildId', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const { guildId } = req.params;
|
|
|
|
try {
|
|
const { data: server } = await supabase
|
|
.from('federation_servers')
|
|
.select('*')
|
|
.eq('guild_id', guildId)
|
|
.maybeSingle();
|
|
|
|
if (!server) {
|
|
return res.json({
|
|
member: false,
|
|
message: 'This server is not a federation member'
|
|
});
|
|
}
|
|
|
|
const { getProgressToNextLevel, getTrustLevelInfo, TRUST_LEVELS } = require('../utils/trustLevels');
|
|
|
|
const trustLevelInfo = getTrustLevelInfo(server.trust_level || 'bronze');
|
|
const progression = getProgressToNextLevel(server);
|
|
|
|
const guild = discordClient.guilds.cache.get(guildId);
|
|
const memberCount = guild?.memberCount || server.member_count || 0;
|
|
|
|
res.json({
|
|
member: true,
|
|
server: {
|
|
guild_id: server.guild_id,
|
|
guild_name: server.guild_name,
|
|
guild_icon: server.guild_icon,
|
|
member_count: memberCount,
|
|
tier: server.tier,
|
|
status: server.status,
|
|
joined_at: server.created_at,
|
|
joined_federation_at: server.joined_federation_at || server.created_at
|
|
},
|
|
trustLevel: {
|
|
level: server.trust_level || 'bronze',
|
|
name: trustLevelInfo.name,
|
|
emoji: trustLevelInfo.emoji,
|
|
color: trustLevelInfo.color,
|
|
benefits: trustLevelInfo.benefits
|
|
},
|
|
reputation: {
|
|
score: server.reputation_score || 0,
|
|
reports_submitted: server.reports_submitted || 0,
|
|
false_positives: server.false_positives || 0,
|
|
last_activity: server.last_activity
|
|
},
|
|
progression: {
|
|
nextLevel: progression.nextLevel,
|
|
nextLevelInfo: progression.nextLevelInfo ? {
|
|
name: progression.nextLevelInfo.name,
|
|
emoji: progression.nextLevelInfo.emoji
|
|
} : null,
|
|
progress: progression.progress,
|
|
allMet: progression.allMet
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to fetch guild federation stats:', error);
|
|
res.status(500).json({ error: 'Failed to fetch federation stats' });
|
|
}
|
|
});
|
|
|
|
// ============ STRIPE PAYMENT API ============
|
|
|
|
app.post('/api/stripe/create-checkout', async (req, res) => {
|
|
if (!stripe) {
|
|
return res.status(503).json({ error: 'Stripe not configured' });
|
|
}
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) {
|
|
return res.status(401).json({ error: 'Not authenticated' });
|
|
}
|
|
|
|
const { guildId, planType } = req.body;
|
|
|
|
if (!guildId || !planType) {
|
|
return res.status(400).json({ error: 'Missing guildId or planType' });
|
|
}
|
|
|
|
const userGuild = req.session.user.guilds?.find(g => g.id === guildId);
|
|
if (!userGuild || !userGuild.isAdmin) {
|
|
return res.status(403).json({ error: 'Must be server admin to purchase' });
|
|
}
|
|
|
|
try {
|
|
const prices = {
|
|
premium: {
|
|
amount: 5000,
|
|
name: 'Federation Premium',
|
|
description: 'Full protection from all ban severity levels - auto-kick for all threats',
|
|
recurring: { interval: 'month' }
|
|
},
|
|
featured: {
|
|
amount: 20000,
|
|
name: 'Featured Server Slot',
|
|
description: 'Cross-promotion across all federation servers for 1 week',
|
|
recurring: { interval: 'week' }
|
|
}
|
|
};
|
|
|
|
const plan = prices[planType];
|
|
if (!plan) {
|
|
return res.status(400).json({ error: 'Invalid plan type' });
|
|
}
|
|
|
|
const session = await stripe.checkout.sessions.create({
|
|
payment_method_types: ['card'],
|
|
mode: 'subscription',
|
|
line_items: [{
|
|
price_data: {
|
|
currency: 'usd',
|
|
product_data: {
|
|
name: plan.name,
|
|
description: plan.description
|
|
},
|
|
unit_amount: plan.amount,
|
|
recurring: plan.recurring
|
|
},
|
|
quantity: 1
|
|
}],
|
|
metadata: {
|
|
guild_id: guildId,
|
|
user_id: userId,
|
|
plan_type: planType
|
|
},
|
|
success_url: `${BASE_URL}/federation?success=true&plan=${planType}`,
|
|
cancel_url: `${BASE_URL}/federation?canceled=true`
|
|
});
|
|
|
|
res.json({ url: session.url });
|
|
} catch (error) {
|
|
console.error('Stripe checkout error:', error);
|
|
res.status(500).json({ error: 'Failed to create checkout session' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/federation/subscription/:guildId', async (req, res) => {
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not available' });
|
|
}
|
|
|
|
const { guildId } = req.params;
|
|
|
|
try {
|
|
const { data: server } = await supabase
|
|
.from('federation_servers')
|
|
.select('tier, subscription_status')
|
|
.eq('guild_id', guildId)
|
|
.maybeSingle();
|
|
|
|
const { data: featured } = await supabase
|
|
.from('federation_featured')
|
|
.select('active, expires_at')
|
|
.eq('guild_id', guildId)
|
|
.eq('active', true)
|
|
.maybeSingle();
|
|
|
|
res.json({
|
|
tier: server?.tier || 'free',
|
|
subscriptionStatus: server?.subscription_status || null,
|
|
featured: featured?.active || false,
|
|
featuredExpiresAt: featured?.expires_at || null
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to fetch subscription' });
|
|
}
|
|
});
|
|
|
|
// Moderation API Endpoints
|
|
app.get('/api/guild/:guildId/moderation/stats', async (req, res) => {
|
|
if (!supabase) return res.status(503).json({ error: 'Database not available' });
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
|
|
|
|
const { guildId } = req.params;
|
|
const userGuild = req.session.user.guilds?.find(g => g.id === guildId);
|
|
if (!userGuild?.isAdmin) return res.status(403).json({ error: 'No admin access' });
|
|
|
|
try {
|
|
const [warningsRes, bansRes, actionsRes] = await Promise.all([
|
|
supabase.from('warnings').select('*', { count: 'exact', head: true }).eq('guild_id', guildId),
|
|
supabase.from('mod_actions').select('*', { count: 'exact', head: true }).eq('guild_id', guildId).eq('action_type', 'ban'),
|
|
supabase.from('mod_actions').select('*', { count: 'exact', head: true }).eq('guild_id', guildId).gte('created_at', new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString())
|
|
]);
|
|
|
|
res.json({
|
|
totalWarnings: warningsRes.count || 0,
|
|
activeBans: bansRes.count || 0,
|
|
recentActions: actionsRes.count || 0
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to fetch stats' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/guild/:guildId/moderation/warnings', async (req, res) => {
|
|
if (!supabase) return res.status(503).json({ error: 'Database not available' });
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
|
|
|
|
const { guildId } = req.params;
|
|
const userGuild = req.session.user.guilds?.find(g => g.id === guildId);
|
|
if (!userGuild?.isAdmin) return res.status(403).json({ error: 'No admin access' });
|
|
|
|
try {
|
|
const { data: warnings } = await supabase
|
|
.from('warnings')
|
|
.select('*')
|
|
.eq('guild_id', guildId)
|
|
.order('created_at', { ascending: false })
|
|
.limit(100);
|
|
|
|
res.json({ warnings: warnings || [] });
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to fetch warnings' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/guild/:guildId/moderation/warnings/:warningId', async (req, res) => {
|
|
if (!supabase) return res.status(503).json({ error: 'Database not available' });
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
|
|
|
|
const { guildId, warningId } = req.params;
|
|
const userGuild = req.session.user.guilds?.find(g => g.id === guildId);
|
|
if (!userGuild?.isAdmin) return res.status(403).json({ error: 'No admin access' });
|
|
|
|
try {
|
|
const { error } = await supabase
|
|
.from('warnings')
|
|
.delete()
|
|
.eq('id', warningId)
|
|
.eq('guild_id', guildId);
|
|
|
|
if (error) throw error;
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to delete warning' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/guild/:guildId/moderation/bans', async (req, res) => {
|
|
if (!supabase) return res.status(503).json({ error: 'Database not available' });
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
|
|
|
|
const { guildId } = req.params;
|
|
const userGuild = req.session.user.guilds?.find(g => g.id === guildId);
|
|
if (!userGuild?.isAdmin) return res.status(403).json({ error: 'No admin access' });
|
|
|
|
try {
|
|
const { data: bans } = await supabase
|
|
.from('mod_actions')
|
|
.select('*')
|
|
.eq('guild_id', guildId)
|
|
.eq('action_type', 'ban')
|
|
.order('created_at', { ascending: false })
|
|
.limit(100);
|
|
|
|
res.json({ bans: bans || [] });
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to fetch bans' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/guild/:guildId/moderation/activity', async (req, res) => {
|
|
if (!supabase) return res.status(503).json({ error: 'Database not available' });
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
|
|
|
|
const { guildId } = req.params;
|
|
const userGuild = req.session.user.guilds?.find(g => g.id === guildId);
|
|
if (!userGuild?.isAdmin) return res.status(403).json({ error: 'No admin access' });
|
|
|
|
try {
|
|
const { data: activity } = await supabase
|
|
.from('mod_actions')
|
|
.select('*')
|
|
.eq('guild_id', guildId)
|
|
.order('created_at', { ascending: false })
|
|
.limit(50);
|
|
|
|
res.json({ activity: activity || [] });
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to fetch activity' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/guild/:guildId/moderation/search', async (req, res) => {
|
|
if (!supabase) return res.status(503).json({ error: 'Database not available' });
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
|
|
|
|
const { guildId } = req.params;
|
|
const { q } = req.query;
|
|
const userGuild = req.session.user.guilds?.find(g => g.id === guildId);
|
|
if (!userGuild?.isAdmin) return res.status(403).json({ error: 'No admin access' });
|
|
|
|
if (!q) return res.json({ results: [] });
|
|
|
|
try {
|
|
const { data: warnings } = await supabase
|
|
.from('warnings')
|
|
.select('user_id, username')
|
|
.eq('guild_id', guildId)
|
|
.or(`user_id.eq.${q},username.ilike.%${q}%`)
|
|
.limit(20);
|
|
|
|
const userMap = new Map();
|
|
(warnings || []).forEach(w => {
|
|
if (!userMap.has(w.user_id)) {
|
|
userMap.set(w.user_id, { user_id: w.user_id, username: w.username, warnings_count: 0, is_banned: false });
|
|
}
|
|
userMap.get(w.user_id).warnings_count++;
|
|
});
|
|
|
|
const { data: bans } = await supabase
|
|
.from('mod_actions')
|
|
.select('target_id')
|
|
.eq('guild_id', guildId)
|
|
.eq('action_type', 'ban');
|
|
|
|
const bannedIds = new Set((bans || []).map(b => b.target_id));
|
|
userMap.forEach(u => { u.is_banned = bannedIds.has(u.user_id); });
|
|
|
|
res.json({ results: Array.from(userMap.values()) });
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Search failed' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/guild/:guildId/moderation/bulk', async (req, res) => {
|
|
const userId = req.session.user?.id;
|
|
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
|
|
|
|
const { guildId } = req.params;
|
|
const userGuild = req.session.user.guilds?.find(g => g.id === guildId);
|
|
if (!userGuild?.isAdmin) return res.status(403).json({ error: 'No admin access' });
|
|
|
|
const { action, userIds, reason, duration, deleteDays } = req.body;
|
|
|
|
if (!action || !userIds || !Array.isArray(userIds)) {
|
|
return res.status(400).json({ error: 'Invalid request' });
|
|
}
|
|
|
|
if (userIds.length > 25) {
|
|
return res.status(400).json({ error: 'Maximum 25 users per bulk action' });
|
|
}
|
|
|
|
const validActions = ['ban', 'kick', 'timeout', 'warn', 'remove_timeout'];
|
|
if (!validActions.includes(action)) {
|
|
return res.status(400).json({ error: 'Invalid action' });
|
|
}
|
|
|
|
const guild = discordClient.guilds.cache.get(guildId);
|
|
if (!guild) {
|
|
return res.status(404).json({ error: 'Guild not found' });
|
|
}
|
|
|
|
const moderator = req.session.user;
|
|
const results = { success: [], failed: [] };
|
|
|
|
const durationMap = {
|
|
'5m': 5 * 60 * 1000,
|
|
'10m': 10 * 60 * 1000,
|
|
'30m': 30 * 60 * 1000,
|
|
'1h': 60 * 60 * 1000,
|
|
'6h': 6 * 60 * 60 * 1000,
|
|
'12h': 12 * 60 * 60 * 1000,
|
|
'1d': 24 * 60 * 60 * 1000,
|
|
'3d': 3 * 24 * 60 * 60 * 1000,
|
|
'1w': 7 * 24 * 60 * 60 * 1000
|
|
};
|
|
|
|
for (const targetId of userIds) {
|
|
try {
|
|
switch (action) {
|
|
case 'ban': {
|
|
await guild.members.ban(targetId, {
|
|
reason: `[Dashboard Bulk] ${reason || 'No reason'} | By ${moderator.username}`,
|
|
deleteMessageSeconds: (deleteDays || 0) * 24 * 60 * 60
|
|
});
|
|
|
|
if (supabase) {
|
|
const user = await discordClient.users.fetch(targetId).catch(() => null);
|
|
await supabase.from('mod_actions').insert({
|
|
guild_id: guildId,
|
|
action: 'ban',
|
|
user_id: targetId,
|
|
user_tag: user?.tag || 'Unknown',
|
|
moderator_id: moderator.id,
|
|
moderator_tag: moderator.username,
|
|
reason: `[Dashboard Bulk] ${reason || 'No reason'}`
|
|
}).catch(() => {});
|
|
}
|
|
results.success.push(targetId);
|
|
break;
|
|
}
|
|
|
|
case 'kick': {
|
|
const member = await guild.members.fetch(targetId).catch(() => null);
|
|
if (!member) {
|
|
results.failed.push({ id: targetId, reason: 'Not in server' });
|
|
continue;
|
|
}
|
|
if (!member.kickable) {
|
|
results.failed.push({ id: targetId, reason: 'Not kickable' });
|
|
continue;
|
|
}
|
|
|
|
await member.kick(`[Dashboard Bulk] ${reason || 'No reason'} | By ${moderator.username}`);
|
|
|
|
if (supabase) {
|
|
await supabase.from('mod_actions').insert({
|
|
guild_id: guildId,
|
|
action: 'kick',
|
|
user_id: targetId,
|
|
user_tag: member.user.tag,
|
|
moderator_id: moderator.id,
|
|
moderator_tag: moderator.username,
|
|
reason: `[Dashboard Bulk] ${reason || 'No reason'}`
|
|
}).catch(() => {});
|
|
}
|
|
results.success.push(targetId);
|
|
break;
|
|
}
|
|
|
|
case 'timeout': {
|
|
const member = await guild.members.fetch(targetId).catch(() => null);
|
|
if (!member) {
|
|
results.failed.push({ id: targetId, reason: 'Not in server' });
|
|
continue;
|
|
}
|
|
if (!member.moderatable) {
|
|
results.failed.push({ id: targetId, reason: 'Not moderatable' });
|
|
continue;
|
|
}
|
|
|
|
const timeoutMs = durationMap[duration] || durationMap['1h'];
|
|
await member.timeout(timeoutMs, `[Dashboard Bulk] ${reason || 'No reason'} | By ${moderator.username}`);
|
|
|
|
if (supabase) {
|
|
await supabase.from('mod_actions').insert({
|
|
guild_id: guildId,
|
|
action: 'timeout',
|
|
user_id: targetId,
|
|
user_tag: member.user.tag,
|
|
moderator_id: moderator.id,
|
|
moderator_tag: moderator.username,
|
|
reason: `[Dashboard Bulk] ${reason || 'No reason'}`,
|
|
duration: duration
|
|
}).catch(() => {});
|
|
}
|
|
results.success.push(targetId);
|
|
break;
|
|
}
|
|
|
|
case 'warn': {
|
|
const member = await guild.members.fetch(targetId).catch(() => null);
|
|
if (!member) {
|
|
results.failed.push({ id: targetId, reason: 'Not in server' });
|
|
continue;
|
|
}
|
|
|
|
if (supabase) {
|
|
await supabase.from('warnings').insert({
|
|
guild_id: guildId,
|
|
user_id: targetId,
|
|
username: member.user.tag,
|
|
moderator_id: moderator.id,
|
|
moderator_tag: moderator.username,
|
|
reason: `[Dashboard Bulk] ${reason || 'No reason'}`
|
|
});
|
|
|
|
await supabase.from('mod_actions').insert({
|
|
guild_id: guildId,
|
|
action: 'warn',
|
|
user_id: targetId,
|
|
user_tag: member.user.tag,
|
|
moderator_id: moderator.id,
|
|
moderator_tag: moderator.username,
|
|
reason: `[Dashboard Bulk] ${reason || 'No reason'}`
|
|
}).catch(() => {});
|
|
}
|
|
results.success.push(targetId);
|
|
break;
|
|
}
|
|
|
|
case 'remove_timeout': {
|
|
const member = await guild.members.fetch(targetId).catch(() => null);
|
|
if (!member) {
|
|
results.failed.push({ id: targetId, reason: 'Not in server' });
|
|
continue;
|
|
}
|
|
if (!member.isCommunicationDisabled()) {
|
|
results.failed.push({ id: targetId, reason: 'Not timed out' });
|
|
continue;
|
|
}
|
|
|
|
await member.timeout(null, `Timeout removed via Dashboard by ${moderator.username}`);
|
|
|
|
if (supabase) {
|
|
await supabase.from('mod_actions').insert({
|
|
guild_id: guildId,
|
|
action: 'untimeout',
|
|
user_id: targetId,
|
|
user_tag: member.user.tag,
|
|
moderator_id: moderator.id,
|
|
moderator_tag: moderator.username,
|
|
reason: 'Dashboard bulk timeout removal'
|
|
}).catch(() => {});
|
|
}
|
|
results.success.push(targetId);
|
|
break;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
results.failed.push({ id: targetId, reason: error.message?.substring(0, 50) || 'Unknown error' });
|
|
}
|
|
}
|
|
|
|
res.json(results);
|
|
});
|
|
|
|
// Analytics API Endpoints
|
|
app.get('/api/guild/:guildId/analytics/stats', async (req, res) => {
|
|
if (!supabase) return res.status(503).json({ error: 'Database not available' });
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
|
|
|
|
const { guildId } = req.params;
|
|
const userGuild = req.session.user.guilds?.find(g => g.id === guildId);
|
|
if (!userGuild?.isAdmin) return res.status(403).json({ error: 'No admin access' });
|
|
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
|
|
try {
|
|
const [messagesRes, activeRes, xpRes, commandsRes] = await Promise.all([
|
|
supabase.from('message_logs').select('*', { count: 'exact', head: true }).eq('guild_id', guildId).gte('created_at', today.toISOString()),
|
|
supabase.from('user_stats').select('*', { count: 'exact', head: true }).eq('guild_id', guildId).gte('last_active', weekAgo.toISOString()),
|
|
supabase.from('xp_logs').select('xp_amount').eq('guild_id', guildId).gte('created_at', today.toISOString()),
|
|
supabase.from('command_logs').select('*', { count: 'exact', head: true }).eq('guild_id', guildId).gte('created_at', today.toISOString())
|
|
]);
|
|
|
|
const xpToday = (xpRes.data || []).reduce((sum, r) => sum + (r.xp_amount || 0), 0);
|
|
|
|
res.json({
|
|
messagesToday: messagesRes.count || 0,
|
|
activeUsers: activeRes.count || 0,
|
|
xpToday,
|
|
commandsUsed: commandsRes.count || 0
|
|
});
|
|
} catch (error) {
|
|
res.json({ messagesToday: 0, activeUsers: 0, xpToday: 0, commandsUsed: 0 });
|
|
}
|
|
});
|
|
|
|
app.get('/api/guild/:guildId/analytics/activity', async (req, res) => {
|
|
if (!supabase) return res.status(503).json({ error: 'Database not available' });
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
|
|
|
|
const { guildId } = req.params;
|
|
const userGuild = req.session.user.guilds?.find(g => g.id === guildId);
|
|
if (!userGuild?.isAdmin) return res.status(403).json({ error: 'No admin access' });
|
|
|
|
try {
|
|
const days = [];
|
|
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
|
|
for (let i = 6; i >= 0; i--) {
|
|
const date = new Date();
|
|
date.setDate(date.getDate() - i);
|
|
date.setHours(0, 0, 0, 0);
|
|
|
|
const nextDate = new Date(date);
|
|
nextDate.setDate(nextDate.getDate() + 1);
|
|
|
|
const { count } = await supabase
|
|
.from('message_logs')
|
|
.select('*', { count: 'exact', head: true })
|
|
.eq('guild_id', guildId)
|
|
.gte('created_at', date.toISOString())
|
|
.lt('created_at', nextDate.toISOString());
|
|
|
|
days.push({
|
|
label: dayNames[date.getDay()],
|
|
messages: count || 0
|
|
});
|
|
}
|
|
|
|
res.json({ days });
|
|
} catch (error) {
|
|
res.json({ days: [] });
|
|
}
|
|
});
|
|
|
|
app.get('/api/guild/:guildId/analytics/top-earners', async (req, res) => {
|
|
if (!supabase) return res.status(503).json({ error: 'Database not available' });
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
|
|
|
|
const { guildId } = req.params;
|
|
const userGuild = req.session.user.guilds?.find(g => g.id === guildId);
|
|
if (!userGuild?.isAdmin) return res.status(403).json({ error: 'No admin access' });
|
|
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
try {
|
|
const { data } = await supabase
|
|
.from('xp_logs')
|
|
.select('user_id, xp_amount')
|
|
.eq('guild_id', guildId)
|
|
.gte('created_at', today.toISOString());
|
|
|
|
const userXp = new Map();
|
|
(data || []).forEach(r => {
|
|
userXp.set(r.user_id, (userXp.get(r.user_id) || 0) + (r.xp_amount || 0));
|
|
});
|
|
|
|
const earners = Array.from(userXp.entries())
|
|
.map(([user_id, xp_earned]) => ({ user_id, xp_earned }))
|
|
.sort((a, b) => b.xp_earned - a.xp_earned)
|
|
.slice(0, 10);
|
|
|
|
res.json({ earners });
|
|
} catch (error) {
|
|
res.json({ earners: [] });
|
|
}
|
|
});
|
|
|
|
app.get('/api/guild/:guildId/analytics/top-channels', async (req, res) => {
|
|
if (!supabase) return res.status(503).json({ error: 'Database not available' });
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
|
|
|
|
const { guildId } = req.params;
|
|
const userGuild = req.session.user.guilds?.find(g => g.id === guildId);
|
|
if (!userGuild?.isAdmin) return res.status(403).json({ error: 'No admin access' });
|
|
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
try {
|
|
const { data } = await supabase
|
|
.from('message_logs')
|
|
.select('channel_id, channel_name')
|
|
.eq('guild_id', guildId)
|
|
.gte('created_at', today.toISOString());
|
|
|
|
const channelCounts = new Map();
|
|
(data || []).forEach(r => {
|
|
const key = r.channel_id;
|
|
if (!channelCounts.has(key)) {
|
|
channelCounts.set(key, { channel_id: r.channel_id, channel_name: r.channel_name, message_count: 0 });
|
|
}
|
|
channelCounts.get(key).message_count++;
|
|
});
|
|
|
|
const channels = Array.from(channelCounts.values())
|
|
.sort((a, b) => b.message_count - a.message_count)
|
|
.slice(0, 10);
|
|
|
|
res.json({ channels });
|
|
} catch (error) {
|
|
res.json({ channels: [] });
|
|
}
|
|
});
|
|
|
|
app.get('/api/guild/:guildId/analytics/commands', async (req, res) => {
|
|
if (!supabase) return res.status(503).json({ error: 'Database not available' });
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
|
|
|
|
const { guildId } = req.params;
|
|
const userGuild = req.session.user.guilds?.find(g => g.id === guildId);
|
|
if (!userGuild?.isAdmin) return res.status(403).json({ error: 'No admin access' });
|
|
|
|
try {
|
|
const { data } = await supabase
|
|
.from('command_logs')
|
|
.select('command_name')
|
|
.eq('guild_id', guildId);
|
|
|
|
const commandCounts = new Map();
|
|
(data || []).forEach(r => {
|
|
commandCounts.set(r.command_name, (commandCounts.get(r.command_name) || 0) + 1);
|
|
});
|
|
|
|
const commands = Array.from(commandCounts.entries())
|
|
.map(([command_name, use_count]) => ({ command_name, use_count }))
|
|
.sort((a, b) => b.use_count - a.use_count)
|
|
.slice(0, 10);
|
|
|
|
res.json({ commands });
|
|
} catch (error) {
|
|
res.json({ commands: [] });
|
|
}
|
|
});
|
|
|
|
// Titles API endpoints
|
|
app.get('/api/guild/:guildId/titles', async (req, res) => {
|
|
if (!supabase) return res.status(503).json({ error: 'Database not available' });
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
|
|
|
|
const { guildId } = req.params;
|
|
|
|
try {
|
|
// Get user's link
|
|
const { data: link } = await supabase
|
|
.from('discord_links')
|
|
.select('user_id')
|
|
.eq('discord_id', userId)
|
|
.maybeSingle();
|
|
|
|
if (!link) {
|
|
return res.json({ titles: [], activeTitle: null });
|
|
}
|
|
|
|
// Get user's inventory items that are titles
|
|
const { data: inventory } = await supabase
|
|
.from('user_inventory')
|
|
.select(`
|
|
id,
|
|
item_id,
|
|
purchased_at,
|
|
is_active,
|
|
shop_items!inner (id, name, description, item_type)
|
|
`)
|
|
.eq('user_id', link.user_id)
|
|
.eq('guild_id', guildId)
|
|
.eq('shop_items.item_type', 'title');
|
|
|
|
const titles = (inventory || []).map(inv => ({
|
|
id: inv.id,
|
|
item_id: inv.item_id,
|
|
name: inv.shop_items?.name || 'Unknown Title',
|
|
description: inv.shop_items?.description || '',
|
|
purchased_at: inv.purchased_at,
|
|
is_active: inv.is_active === true
|
|
}));
|
|
|
|
const activeTitle = titles.find(t => t.is_active) || null;
|
|
|
|
res.json({ titles, activeTitle });
|
|
} catch (error) {
|
|
console.error('Failed to fetch titles:', error);
|
|
res.status(500).json({ error: 'Failed to load titles', titles: [], activeTitle: null });
|
|
}
|
|
});
|
|
|
|
app.post('/api/guild/:guildId/titles/active', async (req, res) => {
|
|
if (!supabase) return res.status(503).json({ error: 'Database not available' });
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
|
|
|
|
const { guildId } = req.params;
|
|
const { title_id } = req.body;
|
|
|
|
if (!title_id) {
|
|
return res.status(400).json({ error: 'Title ID required' });
|
|
}
|
|
|
|
try {
|
|
const { data: link } = await supabase
|
|
.from('discord_links')
|
|
.select('user_id')
|
|
.eq('discord_id', userId)
|
|
.maybeSingle();
|
|
|
|
if (!link) {
|
|
return res.status(400).json({ error: 'Account not linked' });
|
|
}
|
|
|
|
// Get all title-type inventory items and clear their active status
|
|
const { data: titleItems } = await supabase
|
|
.from('user_inventory')
|
|
.select('id, shop_items!inner(item_type)')
|
|
.eq('user_id', link.user_id)
|
|
.eq('guild_id', guildId)
|
|
.eq('shop_items.item_type', 'title');
|
|
|
|
if (titleItems && titleItems.length > 0) {
|
|
const titleIds = titleItems.map(t => t.id);
|
|
await supabase
|
|
.from('user_inventory')
|
|
.update({ is_active: false })
|
|
.in('id', titleIds);
|
|
}
|
|
|
|
// Set the selected title as active
|
|
const { error } = await supabase
|
|
.from('user_inventory')
|
|
.update({ is_active: true })
|
|
.eq('id', title_id)
|
|
.eq('user_id', link.user_id)
|
|
.eq('guild_id', guildId);
|
|
|
|
if (error) throw error;
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Failed to set active title:', error);
|
|
res.status(500).json({ error: 'Failed to set title' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/guild/:guildId/titles/active', async (req, res) => {
|
|
if (!supabase) return res.status(503).json({ error: 'Database not available' });
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
|
|
|
|
const { guildId } = req.params;
|
|
|
|
try {
|
|
const { data: link } = await supabase
|
|
.from('discord_links')
|
|
.select('user_id')
|
|
.eq('discord_id', userId)
|
|
.maybeSingle();
|
|
|
|
if (!link) {
|
|
return res.status(400).json({ error: 'Account not linked' });
|
|
}
|
|
|
|
// Get all title-type inventory items and clear their active status
|
|
const { data: titleItems } = await supabase
|
|
.from('user_inventory')
|
|
.select('id, shop_items!inner(item_type)')
|
|
.eq('user_id', link.user_id)
|
|
.eq('guild_id', guildId)
|
|
.eq('shop_items.item_type', 'title');
|
|
|
|
if (titleItems && titleItems.length > 0) {
|
|
const titleIds = titleItems.map(t => t.id);
|
|
await supabase
|
|
.from('user_inventory')
|
|
.update({ is_active: false })
|
|
.in('id', titleIds);
|
|
}
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Failed to clear active title:', error);
|
|
res.status(500).json({ error: 'Failed to clear title' });
|
|
}
|
|
});
|
|
|
|
// Coins API endpoints
|
|
app.get('/api/guild/:guildId/coins', async (req, res) => {
|
|
if (!supabase) return res.status(503).json({ error: 'Database not available' });
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
|
|
|
|
const { guildId } = req.params;
|
|
|
|
try {
|
|
const { data: link } = await supabase
|
|
.from('discord_links')
|
|
.select('user_id')
|
|
.eq('discord_id', userId)
|
|
.maybeSingle();
|
|
|
|
if (!link) {
|
|
return res.json({ coins: 0, coinName: 'Coins' });
|
|
}
|
|
|
|
const { data: stats } = await supabase
|
|
.from('user_stats')
|
|
.select('coins')
|
|
.eq('user_id', link.user_id)
|
|
.eq('guild_id', guildId)
|
|
.maybeSingle();
|
|
|
|
const { data: config } = await supabase
|
|
.from('xp_config')
|
|
.select('coin_name')
|
|
.eq('guild_id', guildId)
|
|
.maybeSingle();
|
|
|
|
res.json({
|
|
coins: stats?.coins || 0,
|
|
coinName: config?.coin_name || 'Coins'
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to fetch coins:', error);
|
|
res.status(500).json({ error: 'Failed to load coins' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/guild/:guildId/coins/config', async (req, res) => {
|
|
if (!supabase) return res.status(503).json({ error: 'Database not available' });
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
|
|
|
|
const { guildId } = req.params;
|
|
|
|
const userGuild = req.session.user.guilds.find(g => g.id === guildId);
|
|
if (!userGuild || !userGuild.isAdmin) {
|
|
return res.status(403).json({ error: 'No admin access' });
|
|
}
|
|
|
|
try {
|
|
const { data: config } = await supabase
|
|
.from('xp_config')
|
|
.select('coins_enabled, message_coins, daily_coins, coin_name')
|
|
.eq('guild_id', guildId)
|
|
.maybeSingle();
|
|
|
|
res.json({
|
|
config: config || { coins_enabled: true, message_coins: 1, daily_coins: 50, coin_name: 'Coins' }
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to fetch coin config:', error);
|
|
res.status(500).json({ error: 'Failed to load coin config' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/guild/:guildId/coins/config', async (req, res) => {
|
|
if (!supabase) return res.status(503).json({ error: 'Database not available' });
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
|
|
|
|
const { guildId } = req.params;
|
|
|
|
const userGuild = req.session.user.guilds.find(g => g.id === guildId);
|
|
if (!userGuild || !userGuild.isAdmin) {
|
|
return res.status(403).json({ error: 'No admin access' });
|
|
}
|
|
|
|
const { coins_enabled, message_coins, daily_coins, coin_name } = req.body;
|
|
|
|
// Validate inputs
|
|
const validMessageCoins = Math.max(0, Math.min(100, parseInt(message_coins) || 1));
|
|
const validDailyCoins = Math.max(0, Math.min(1000, parseInt(daily_coins) || 50));
|
|
const validCoinName = (coin_name || 'Coins').toString().substring(0, 32);
|
|
const validCoinsEnabled = coins_enabled === true;
|
|
|
|
try {
|
|
const { data: existing } = await supabase
|
|
.from('xp_config')
|
|
.select('guild_id')
|
|
.eq('guild_id', guildId)
|
|
.maybeSingle();
|
|
|
|
const coinData = {
|
|
coins_enabled: validCoinsEnabled,
|
|
message_coins: validMessageCoins,
|
|
daily_coins: validDailyCoins,
|
|
coin_name: validCoinName,
|
|
updated_at: new Date().toISOString()
|
|
};
|
|
|
|
if (existing) {
|
|
const { error } = await supabase
|
|
.from('xp_config')
|
|
.update(coinData)
|
|
.eq('guild_id', guildId);
|
|
if (error) throw error;
|
|
} else {
|
|
const { error } = await supabase
|
|
.from('xp_config')
|
|
.insert({ guild_id: guildId, ...coinData });
|
|
if (error) throw error;
|
|
}
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Failed to save coin config:', error);
|
|
res.status(500).json({ error: 'Failed to save coin settings' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/guild/:guildId/coins/leaderboard', async (req, res) => {
|
|
if (!supabase) return res.status(503).json({ error: 'Database not available' });
|
|
|
|
const { guildId } = req.params;
|
|
const { limit = 50 } = req.query;
|
|
|
|
try {
|
|
const { data: leaderboard } = await supabase
|
|
.from('user_stats')
|
|
.select('user_id, coins')
|
|
.eq('guild_id', guildId)
|
|
.gt('coins', 0)
|
|
.order('coins', { ascending: false })
|
|
.limit(parseInt(limit));
|
|
|
|
const { data: config } = await supabase
|
|
.from('xp_config')
|
|
.select('coin_name')
|
|
.eq('guild_id', guildId)
|
|
.maybeSingle();
|
|
|
|
res.json({
|
|
leaderboard: leaderboard || [],
|
|
coinName: config?.coin_name || 'Coins'
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to fetch coin leaderboard:', error);
|
|
res.status(500).json({ error: 'Failed to load leaderboard' });
|
|
}
|
|
});
|
|
|
|
// Activity Roles API endpoints
|
|
app.get('/api/guild/:guildId/activity-roles', async (req, res) => {
|
|
if (!supabase) return res.status(503).json({ error: 'Database not available' });
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
|
|
|
|
const { guildId } = req.params;
|
|
|
|
const userGuild = req.session.user.guilds.find(g => g.id === guildId);
|
|
if (!userGuild || !userGuild.isAdmin) {
|
|
return res.status(403).json({ error: 'No admin access' });
|
|
}
|
|
|
|
try {
|
|
const { data: roles } = await supabase
|
|
.from('activity_roles')
|
|
.select('*')
|
|
.eq('guild_id', guildId)
|
|
.order('milestone_type')
|
|
.order('milestone_value', { ascending: true });
|
|
|
|
// Get Discord role names from the bot
|
|
let guild = discordClient.guilds.cache.get(guildId);
|
|
|
|
// Try to fetch guild if not in cache
|
|
if (!guild) {
|
|
try {
|
|
guild = await discordClient.guilds.fetch(guildId);
|
|
if (guild && !guild.roles.cache.size) {
|
|
await guild.roles.fetch();
|
|
}
|
|
} catch (fetchErr) {
|
|
// Guild not accessible, continue with unknown role names
|
|
console.log(`Guild ${guildId} not accessible for role names`);
|
|
}
|
|
}
|
|
|
|
const rolesWithNames = (roles || []).map(r => {
|
|
const discordRole = guild?.roles.cache.get(r.role_id);
|
|
return {
|
|
...r,
|
|
role_name: discordRole?.name || `Unknown (${r.role_id})`,
|
|
role_color: discordRole?.hexColor || '#99aab5'
|
|
};
|
|
});
|
|
|
|
res.json({ roles: rolesWithNames });
|
|
} catch (error) {
|
|
console.error('Failed to fetch activity roles:', error);
|
|
res.status(500).json({ error: 'Failed to load activity roles' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/guild/:guildId/activity-roles', async (req, res) => {
|
|
if (!supabase) return res.status(503).json({ error: 'Database not available' });
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
|
|
|
|
const { guildId } = req.params;
|
|
|
|
const userGuild = req.session.user.guilds.find(g => g.id === guildId);
|
|
if (!userGuild || !userGuild.isAdmin) {
|
|
return res.status(403).json({ error: 'No admin access' });
|
|
}
|
|
|
|
const { role_id, milestone_type, milestone_value, stack_roles } = req.body;
|
|
|
|
if (!role_id || !milestone_type || milestone_value === undefined || milestone_value === null) {
|
|
return res.status(400).json({ error: 'Missing required fields' });
|
|
}
|
|
|
|
const validTypes = ['messages', 'voice_hours', 'daily_streak', 'reactions_given', 'reactions_received', 'commands_used'];
|
|
if (!validTypes.includes(milestone_type)) {
|
|
return res.status(400).json({ error: 'Invalid milestone type' });
|
|
}
|
|
|
|
const parsedValue = parseInt(milestone_value, 10);
|
|
if (isNaN(parsedValue) || parsedValue < 1) {
|
|
return res.status(400).json({ error: 'Milestone value must be a positive integer (1 or greater)' });
|
|
}
|
|
|
|
try {
|
|
const { error } = await supabase
|
|
.from('activity_roles')
|
|
.upsert({
|
|
guild_id: guildId,
|
|
role_id,
|
|
milestone_type,
|
|
milestone_value: parsedValue,
|
|
stack_roles: stack_roles !== false,
|
|
created_at: new Date().toISOString()
|
|
}, { onConflict: 'guild_id,role_id' });
|
|
|
|
if (error) throw error;
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Failed to add activity role:', error);
|
|
res.status(500).json({ error: 'Failed to add activity role' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/guild/:guildId/activity-roles/:roleId', async (req, res) => {
|
|
if (!supabase) return res.status(503).json({ error: 'Database not available' });
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
|
|
|
|
const { guildId, roleId } = req.params;
|
|
|
|
const userGuild = req.session.user.guilds.find(g => g.id === guildId);
|
|
if (!userGuild || !userGuild.isAdmin) {
|
|
return res.status(403).json({ error: 'No admin access' });
|
|
}
|
|
|
|
try {
|
|
const { error } = await supabase
|
|
.from('activity_roles')
|
|
.delete()
|
|
.eq('guild_id', guildId)
|
|
.eq('role_id', roleId);
|
|
|
|
if (error) throw error;
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Failed to delete activity role:', error);
|
|
res.status(500).json({ error: 'Failed to delete activity role' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/guild/:guildId/roles', async (req, res) => {
|
|
const userId = req.session.user?.id;
|
|
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
|
|
|
|
const { guildId } = req.params;
|
|
|
|
const userGuild = req.session.user.guilds.find(g => g.id === guildId);
|
|
if (!userGuild || !userGuild.isAdmin) {
|
|
return res.status(403).json({ error: 'No admin access' });
|
|
}
|
|
|
|
try {
|
|
let guild = discordClient.guilds.cache.get(guildId);
|
|
|
|
// Try to fetch guild if not in cache
|
|
if (!guild) {
|
|
try {
|
|
guild = await discordClient.guilds.fetch(guildId);
|
|
} catch (fetchErr) {
|
|
console.log(`Guild ${guildId} not accessible by bot`);
|
|
return res.json({ roles: [], message: 'Bot is not in this server or cannot access it' });
|
|
}
|
|
}
|
|
|
|
// Ensure roles are cached
|
|
if (!guild.roles.cache.size) {
|
|
await guild.roles.fetch();
|
|
}
|
|
|
|
const roles = guild.roles.cache
|
|
.filter(r => !r.managed && r.id !== guild.id)
|
|
.sort((a, b) => b.position - a.position)
|
|
.map(r => ({
|
|
id: r.id,
|
|
name: r.name,
|
|
color: r.hexColor,
|
|
position: r.position
|
|
}));
|
|
|
|
res.json({ roles });
|
|
} catch (error) {
|
|
console.error('Failed to fetch roles:', error);
|
|
res.status(500).json({ error: 'Failed to load roles' });
|
|
}
|
|
});
|
|
|
|
// Command Cooldowns API endpoints
|
|
const DEFAULT_COOLDOWNS = {
|
|
work: 3600,
|
|
daily: 72000,
|
|
slots: 60,
|
|
coinflip: 30,
|
|
rep: 43200,
|
|
trivia: 30,
|
|
heist: 1800,
|
|
duel: 300,
|
|
gift: 60,
|
|
trade: 60
|
|
};
|
|
|
|
app.get('/api/guild/:guildId/cooldowns', async (req, res) => {
|
|
if (!supabase) return res.status(503).json({ error: 'Database not available' });
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
|
|
|
|
const { guildId } = req.params;
|
|
|
|
const userGuild = req.session.user.guilds.find(g => g.id === guildId);
|
|
if (!userGuild || !userGuild.isAdmin) {
|
|
return res.status(403).json({ error: 'No admin access' });
|
|
}
|
|
|
|
try {
|
|
const { data: customCooldowns } = await supabase
|
|
.from('command_cooldowns')
|
|
.select('*')
|
|
.eq('guild_id', guildId)
|
|
.order('command_name');
|
|
|
|
const customMap = {};
|
|
(customCooldowns || []).forEach(c => {
|
|
customMap[c.command_name] = c.cooldown_seconds;
|
|
});
|
|
|
|
const cooldowns = Object.entries(DEFAULT_COOLDOWNS).map(([cmd, defaultSec]) => ({
|
|
command: cmd,
|
|
default_seconds: defaultSec,
|
|
custom_seconds: customMap[cmd] ?? null,
|
|
is_custom: customMap[cmd] !== undefined
|
|
}));
|
|
|
|
res.json({ cooldowns });
|
|
} catch (error) {
|
|
console.error('Failed to fetch cooldowns:', error);
|
|
res.status(500).json({ error: 'Failed to load cooldowns' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/guild/:guildId/cooldowns', async (req, res) => {
|
|
if (!supabase) return res.status(503).json({ error: 'Database not available' });
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
|
|
|
|
const { guildId } = req.params;
|
|
|
|
const userGuild = req.session.user.guilds.find(g => g.id === guildId);
|
|
if (!userGuild || !userGuild.isAdmin) {
|
|
return res.status(403).json({ error: 'No admin access' });
|
|
}
|
|
|
|
const { command, seconds } = req.body;
|
|
|
|
if (!command || !DEFAULT_COOLDOWNS.hasOwnProperty(command)) {
|
|
return res.status(400).json({ error: 'Invalid command' });
|
|
}
|
|
|
|
const parsedSeconds = parseInt(seconds, 10);
|
|
if (isNaN(parsedSeconds) || parsedSeconds < 0 || parsedSeconds > 604800) {
|
|
return res.status(400).json({ error: 'Seconds must be between 0 and 604800 (7 days)' });
|
|
}
|
|
|
|
try {
|
|
const { error } = await supabase
|
|
.from('command_cooldowns')
|
|
.upsert({
|
|
guild_id: guildId,
|
|
command_name: command,
|
|
cooldown_seconds: parsedSeconds,
|
|
updated_at: new Date().toISOString()
|
|
}, { onConflict: 'guild_id,command_name' });
|
|
|
|
if (error) throw error;
|
|
|
|
invalidateCooldownCache(guildId, command);
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Failed to set cooldown:', error);
|
|
res.status(500).json({ error: 'Failed to save cooldown' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/guild/:guildId/cooldowns/:command', async (req, res) => {
|
|
if (!supabase) return res.status(503).json({ error: 'Database not available' });
|
|
|
|
const userId = req.session.user?.id;
|
|
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
|
|
|
|
const { guildId, command } = req.params;
|
|
|
|
const userGuild = req.session.user.guilds.find(g => g.id === guildId);
|
|
if (!userGuild || !userGuild.isAdmin) {
|
|
return res.status(403).json({ error: 'No admin access' });
|
|
}
|
|
|
|
try {
|
|
const { error } = await supabase
|
|
.from('command_cooldowns')
|
|
.delete()
|
|
.eq('guild_id', guildId)
|
|
.eq('command_name', command);
|
|
|
|
if (error) throw error;
|
|
|
|
invalidateCooldownCache(guildId, command);
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Failed to reset cooldown:', error);
|
|
res.status(500).json({ error: 'Failed to reset cooldown' });
|
|
}
|
|
});
|
|
|
|
// =============================================================================
|
|
// BACKUP MANAGEMENT API
|
|
// =============================================================================
|
|
|
|
app.get('/api/guild/:guildId/backups', async (req, res) => {
|
|
const { guildId } = req.params;
|
|
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not connected' });
|
|
}
|
|
|
|
try {
|
|
const { data: backups, error } = await supabase
|
|
.from('server_backups')
|
|
.select('id, name, description, backup_type, created_at, created_by, roles_count, channels_count, size_bytes')
|
|
.eq('guild_id', guildId)
|
|
.order('created_at', { ascending: false })
|
|
.limit(50);
|
|
|
|
if (error) throw error;
|
|
|
|
res.json({ backups: backups || [] });
|
|
} catch (error) {
|
|
console.error('Failed to get backups:', error);
|
|
res.status(500).json({ error: 'Failed to fetch backups' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/guild/:guildId/backups/:backupId', async (req, res) => {
|
|
const { guildId, backupId } = req.params;
|
|
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not connected' });
|
|
}
|
|
|
|
try {
|
|
const { data: backup, error } = await supabase
|
|
.from('server_backups')
|
|
.select('*')
|
|
.eq('id', backupId)
|
|
.eq('guild_id', guildId)
|
|
.single();
|
|
|
|
if (error || !backup) {
|
|
return res.status(404).json({ error: 'Backup not found' });
|
|
}
|
|
|
|
res.json({ backup });
|
|
} catch (error) {
|
|
console.error('Failed to get backup:', error);
|
|
res.status(500).json({ error: 'Failed to fetch backup' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/guild/:guildId/backups', async (req, res) => {
|
|
const { guildId } = req.params;
|
|
const { name, description } = req.body;
|
|
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not connected' });
|
|
}
|
|
|
|
try {
|
|
const guild = discordClient.guilds.cache.get(guildId);
|
|
if (!guild) {
|
|
return res.status(404).json({ error: 'Guild not found' });
|
|
}
|
|
|
|
const { createBackup } = require('../commands/backup');
|
|
const backupData = await createBackup(guild, supabase, guildId);
|
|
|
|
const { data: backup, error } = await supabase.from('server_backups').insert({
|
|
guild_id: guildId,
|
|
name: name || `Dashboard Backup - ${new Date().toLocaleDateString()}`,
|
|
description: description || 'Created from dashboard',
|
|
backup_type: 'manual',
|
|
created_by: req.session?.user?.id || null,
|
|
data: backupData,
|
|
roles_count: backupData.roles?.length || 0,
|
|
channels_count: backupData.channels?.length || 0,
|
|
size_bytes: JSON.stringify(backupData).length
|
|
}).select().single();
|
|
|
|
if (error) throw error;
|
|
|
|
res.json({ success: true, backup });
|
|
} catch (error) {
|
|
console.error('Failed to create backup:', error);
|
|
res.status(500).json({ error: 'Failed to create backup' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/guild/:guildId/backups/:backupId', async (req, res) => {
|
|
const { guildId, backupId } = req.params;
|
|
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not connected' });
|
|
}
|
|
|
|
try {
|
|
const { error } = await supabase
|
|
.from('server_backups')
|
|
.delete()
|
|
.eq('id', backupId)
|
|
.eq('guild_id', guildId);
|
|
|
|
if (error) throw error;
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Failed to delete backup:', error);
|
|
res.status(500).json({ error: 'Failed to delete backup' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/guild/:guildId/backup-settings', async (req, res) => {
|
|
const { guildId } = req.params;
|
|
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not connected' });
|
|
}
|
|
|
|
try {
|
|
const { data: settings } = await supabase
|
|
.from('backup_settings')
|
|
.select('*')
|
|
.eq('guild_id', guildId)
|
|
.single();
|
|
|
|
res.json({
|
|
settings: settings || {
|
|
auto_enabled: false,
|
|
interval_hours: 24,
|
|
max_backups: 7
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to get backup settings:', error);
|
|
res.status(500).json({ error: 'Failed to fetch backup settings' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/guild/:guildId/backup-settings', async (req, res) => {
|
|
const { guildId } = req.params;
|
|
const { auto_enabled, interval_hours, max_backups } = req.body;
|
|
|
|
if (!supabase) {
|
|
return res.status(503).json({ error: 'Database not connected' });
|
|
}
|
|
|
|
try {
|
|
const { error } = await supabase.from('backup_settings').upsert({
|
|
guild_id: guildId,
|
|
auto_enabled: auto_enabled ?? false,
|
|
interval_hours: interval_hours || 24,
|
|
max_backups: max_backups || 7,
|
|
updated_at: new Date().toISOString(),
|
|
updated_by: req.session?.user?.id || null
|
|
});
|
|
|
|
if (error) throw error;
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Failed to update backup settings:', error);
|
|
res.status(500).json({ error: 'Failed to update backup settings' });
|
|
}
|
|
});
|
|
|
|
app.get('/health', (req, res) => {
|
|
res.json({
|
|
status: 'ok',
|
|
bot: discordClient.user ? 'online' : 'offline',
|
|
uptime: process.uptime()
|
|
});
|
|
});
|
|
|
|
app.get('/api/bot-stats', async (req, res) => {
|
|
try {
|
|
const guilds = discordClient.guilds?.cache?.size || 0;
|
|
let users = 0;
|
|
|
|
if (discordClient.guilds?.cache) {
|
|
discordClient.guilds.cache.forEach(guild => {
|
|
users += guild.memberCount || 0;
|
|
});
|
|
}
|
|
|
|
let linkedProfiles = 0;
|
|
if (supabase) {
|
|
const { count } = await supabase
|
|
.from('discord_links')
|
|
.select('*', { count: 'exact', head: true });
|
|
linkedProfiles = count || 0;
|
|
}
|
|
|
|
res.json({
|
|
guilds,
|
|
users,
|
|
linkedProfiles,
|
|
commands: 66,
|
|
uptime: process.uptime()
|
|
});
|
|
} catch (error) {
|
|
res.json({ guilds: 0, users: 0, linkedProfiles: 0, commands: 66 });
|
|
}
|
|
});
|
|
|
|
app.get('/federation', (req, res) => {
|
|
res.sendFile(path.join(__dirname, '../public/federation.html'));
|
|
});
|
|
|
|
app.get('/pricing', (req, res) => {
|
|
res.sendFile(path.join(__dirname, '../public/pricing.html'));
|
|
});
|
|
|
|
app.get('/dashboard', (req, res) => {
|
|
res.sendFile(path.join(__dirname, '../public/dashboard.html'));
|
|
});
|
|
|
|
app.get('/leaderboard', (req, res) => {
|
|
res.redirect('/dashboard?page=leaderboard');
|
|
});
|
|
|
|
app.get('/features', (req, res) => {
|
|
res.sendFile(path.join(__dirname, '../public/features.html'));
|
|
});
|
|
|
|
app.get('/commands', (req, res) => {
|
|
res.sendFile(path.join(__dirname, '../public/commands.html'));
|
|
});
|
|
|
|
app.get('/', (req, res) => {
|
|
res.sendFile(path.join(__dirname, '../public/index.html'));
|
|
});
|
|
|
|
// Catch-all route for SPA - use middleware instead of wildcard
|
|
app.use((req, res, next) => {
|
|
if (req.method === 'GET' && !req.path.startsWith('/api/')) {
|
|
res.sendFile(path.join(__dirname, '../public/index.html'));
|
|
} else if (req.path.startsWith('/api/')) {
|
|
res.status(404).json({ error: 'API endpoint not found' });
|
|
} else {
|
|
next();
|
|
}
|
|
});
|
|
|
|
return app;
|
|
}
|
|
|
|
module.exports = { createWebServer };
|