e8d9b1fd42
Add complete token system with Plex/Tautulli/Overseer integration: - Anchor program for SPL token mint/burn/transfer - Express backend with OAuth, webhooks, Solana integration - Next.js frontend with dashboard, admin panel, wallet management - Docker deployment for 172.20.1.0/24 infrastructure - Production configs with SSL, Nginx, health monitoring Tautulli webhooks auto-mint on watch events. Overseer integration burns for content requests.
309 lines
7.2 KiB
TypeScript
309 lines
7.2 KiB
TypeScript
import { Router } from 'express';
|
|
import { authenticate, AuthenticatedRequest, requireAdmin } from '../middleware/auth';
|
|
import { prisma } from '../utils/prisma';
|
|
import { asyncHandler } from '../middleware/errorHandler';
|
|
import { mintTokens } from '../services/solana';
|
|
|
|
const router = Router();
|
|
|
|
// Get system settings
|
|
router.get('/settings',
|
|
authenticate,
|
|
requireAdmin,
|
|
asyncHandler(async (_req: AuthenticatedRequest, res) => {
|
|
const settings = await prisma.systemSettings.findFirst();
|
|
|
|
if (!settings) {
|
|
return res.status(404).json({ error: 'Settings not found' });
|
|
}
|
|
|
|
res.json(settings);
|
|
})
|
|
);
|
|
|
|
// Update system settings
|
|
router.put('/settings',
|
|
authenticate,
|
|
requireAdmin,
|
|
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
|
const {
|
|
creditsPerMinute,
|
|
minWatchPercent,
|
|
minWatchMinutes,
|
|
movieRequestCost,
|
|
tvRequestCost,
|
|
newReleaseMultiplier,
|
|
bonusMultiplierActive,
|
|
bonusMultiplier
|
|
} = req.body;
|
|
|
|
const settings = await prisma.systemSettings.update({
|
|
where: { id: 'default' },
|
|
data: {
|
|
creditsPerMinute,
|
|
minWatchPercent,
|
|
minWatchMinutes,
|
|
movieRequestCost,
|
|
tvRequestCost,
|
|
newReleaseMultiplier,
|
|
bonusMultiplierActive,
|
|
bonusMultiplier,
|
|
updatedBy: req.user!.id
|
|
}
|
|
});
|
|
|
|
res.json(settings);
|
|
})
|
|
);
|
|
|
|
// Pause minting
|
|
router.post('/pause',
|
|
authenticate,
|
|
requireAdmin,
|
|
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
|
await prisma.systemSettings.update({
|
|
where: { id: 'default' },
|
|
data: {
|
|
mintingPaused: true,
|
|
updatedBy: req.user!.id
|
|
}
|
|
});
|
|
|
|
res.json({ message: 'Minting paused' });
|
|
})
|
|
);
|
|
|
|
// Resume minting
|
|
router.post('/resume',
|
|
authenticate,
|
|
requireAdmin,
|
|
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
|
await prisma.systemSettings.update({
|
|
where: { id: 'default' },
|
|
data: {
|
|
mintingPaused: false,
|
|
updatedBy: req.user!.id
|
|
}
|
|
});
|
|
|
|
res.json({ message: 'Minting resumed' });
|
|
})
|
|
);
|
|
|
|
// Get all users
|
|
router.get('/users',
|
|
authenticate,
|
|
requireAdmin,
|
|
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
|
const { page = '1', limit = '50', search } = req.query;
|
|
|
|
const pageNum = parseInt(page as string);
|
|
const limitNum = Math.min(parseInt(limit as string), 100);
|
|
const skip = (pageNum - 1) * limitNum;
|
|
|
|
const where: any = {};
|
|
if (search) {
|
|
where.OR = [
|
|
{ plexUsername: { contains: search as string, mode: 'insensitive' } },
|
|
{ email: { contains: search as string, mode: 'insensitive' } }
|
|
];
|
|
}
|
|
|
|
const [users, total] = await Promise.all([
|
|
prisma.user.findMany({
|
|
where,
|
|
orderBy: { createdAt: 'desc' },
|
|
skip,
|
|
take: limitNum,
|
|
select: {
|
|
id: true,
|
|
plexId: true,
|
|
plexUsername: true,
|
|
email: true,
|
|
isAdmin: true,
|
|
isActive: true,
|
|
walletAddress: true,
|
|
totalEarned: true,
|
|
totalSpent: true,
|
|
watchTimeMinutes: true,
|
|
createdAt: true
|
|
}
|
|
}),
|
|
prisma.user.count({ where })
|
|
]);
|
|
|
|
res.json({
|
|
users,
|
|
pagination: {
|
|
page: pageNum,
|
|
limit: limitNum,
|
|
total,
|
|
totalPages: Math.ceil(total / limitNum)
|
|
}
|
|
});
|
|
})
|
|
);
|
|
|
|
// Update user
|
|
router.put('/users/:id',
|
|
authenticate,
|
|
requireAdmin,
|
|
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
|
const { isAdmin, isActive } = req.body;
|
|
|
|
const user = await prisma.user.update({
|
|
where: { id: req.params.id },
|
|
data: {
|
|
isAdmin,
|
|
isActive
|
|
},
|
|
select: {
|
|
id: true,
|
|
plexUsername: true,
|
|
isAdmin: true,
|
|
isActive: true
|
|
}
|
|
});
|
|
|
|
res.json(user);
|
|
})
|
|
);
|
|
|
|
// Grant bonus credits
|
|
router.post('/users/:id/bonus',
|
|
authenticate,
|
|
requireAdmin,
|
|
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
|
const { amount, reason } = req.body;
|
|
|
|
if (!amount || amount <= 0) {
|
|
return res.status(400).json({ error: 'Valid amount required' });
|
|
}
|
|
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: req.params.id }
|
|
});
|
|
|
|
if (!user) {
|
|
return res.status(404).json({ error: 'User not found' });
|
|
}
|
|
|
|
if (!user.walletAddress) {
|
|
return res.status(400).json({ error: 'User has no wallet' });
|
|
}
|
|
|
|
// Mint bonus tokens
|
|
const signature = await mintTokens(
|
|
user.walletAddress,
|
|
amount,
|
|
{
|
|
sessionId: `BONUS-${Date.now()}`,
|
|
contentTitle: reason || 'Admin Bonus',
|
|
watchDurationMinutes: 0
|
|
}
|
|
);
|
|
|
|
if (!signature) {
|
|
return res.status(500).json({ error: 'Failed to mint bonus' });
|
|
}
|
|
|
|
// Create transaction record
|
|
const transaction = await prisma.transaction.create({
|
|
data: {
|
|
userId: user.id,
|
|
type: 'BONUS',
|
|
amount,
|
|
solanaSignature: signature,
|
|
description: reason || 'Admin bonus',
|
|
contentTitle: reason || 'Admin Bonus'
|
|
}
|
|
});
|
|
|
|
// Update user stats
|
|
await prisma.user.update({
|
|
where: { id: user.id },
|
|
data: {
|
|
totalEarned: { increment: amount }
|
|
}
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
amount,
|
|
solanaSignature: signature,
|
|
transaction
|
|
});
|
|
})
|
|
);
|
|
|
|
// Get dashboard analytics
|
|
router.get('/analytics',
|
|
authenticate,
|
|
requireAdmin,
|
|
asyncHandler(async (_req: AuthenticatedRequest, res) => {
|
|
const sevenDaysAgo = new Date();
|
|
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
|
|
|
const thirtyDaysAgo = new Date();
|
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
|
|
|
const [
|
|
userStats,
|
|
transactionStats,
|
|
watchStats,
|
|
dailyActivity
|
|
] = await Promise.all([
|
|
// User stats
|
|
prisma.$queryRaw`
|
|
SELECT
|
|
COUNT(*) as total,
|
|
COUNT(CASE WHEN wallet_address IS NOT NULL THEN 1 END) as with_wallet,
|
|
COUNT(CASE WHEN created_at >= ${sevenDaysAgo} THEN 1 END) as new_this_week
|
|
FROM users
|
|
`,
|
|
|
|
// Transaction stats
|
|
prisma.$queryRaw`
|
|
SELECT
|
|
SUM(CASE WHEN type = 'EARN' THEN amount ELSE 0 END) as total_earned,
|
|
SUM(CASE WHEN type = 'SPEND' THEN amount ELSE 0 END) as total_spent,
|
|
COUNT(*) as total_transactions
|
|
FROM transactions
|
|
`,
|
|
|
|
// Watch stats
|
|
prisma.$queryRaw`
|
|
SELECT
|
|
SUM(duration) as total_seconds,
|
|
SUM(credits_earned) as total_credits,
|
|
COUNT(*) as total_events
|
|
FROM watch_events
|
|
WHERE is_processed = true
|
|
`,
|
|
|
|
// Daily activity (last 30 days)
|
|
prisma.$queryRaw`
|
|
SELECT
|
|
DATE(created_at) as date,
|
|
COUNT(DISTINCT user_id) as active_users,
|
|
SUM(CASE WHEN type = 'EARN' THEN amount ELSE 0 END) as earned,
|
|
SUM(CASE WHEN type = 'SPEND' THEN amount ELSE 0 END) as spent
|
|
FROM transactions
|
|
WHERE created_at >= ${thirtyDaysAgo}
|
|
GROUP BY DATE(created_at)
|
|
ORDER BY date DESC
|
|
LIMIT 30
|
|
`
|
|
]);
|
|
|
|
res.json({
|
|
users: userStats[0],
|
|
transactions: transactionStats[0],
|
|
watchStats: watchStats[0],
|
|
dailyActivity
|
|
});
|
|
})
|
|
);
|
|
|
|
export { router as adminRouter };
|