feat: add CoopCredits Solana media rewards ecosystem
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.
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
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 };
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Router } from 'express';
|
||||
import axios from 'axios';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { prisma } from '../utils/prisma';
|
||||
import { asyncHandler } from '../middleware/errorHandler';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Plex OAuth configuration
|
||||
const PLEX_CLIENT_ID = process.env.PLEX_CLIENT_ID || '';
|
||||
const PLEX_REDIRECT_URI = process.env.PLEX_REDIRECT_URI || '';
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'secret';
|
||||
|
||||
// Step 1: Get Plex OAuth URL
|
||||
router.get('/plex/url', asyncHandler(async (_req, res) => {
|
||||
const params = new URLSearchParams({
|
||||
client_id: PLEX_CLIENT_ID,
|
||||
redirect_uri: PLEX_REDIRECT_URI,
|
||||
response_type: 'code',
|
||||
scope: 'openid profile'
|
||||
});
|
||||
|
||||
const authUrl = `https://app.plex.tv/auth#?${params.toString()}`;
|
||||
|
||||
res.json({ authUrl });
|
||||
}));
|
||||
|
||||
// Step 2: Handle Plex OAuth callback
|
||||
router.post('/plex/callback', asyncHandler(async (req, res) => {
|
||||
const { code } = req.body;
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: 'Authorization code required' });
|
||||
}
|
||||
|
||||
// Exchange code for Plex token
|
||||
const tokenResponse = await axios.post(
|
||||
'https://plex.tv/api/v2/oauth/token',
|
||||
{
|
||||
code,
|
||||
client_id: PLEX_CLIENT_ID,
|
||||
grant_type: 'authorization_code'
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Plex-Client-Identifier': PLEX_CLIENT_ID
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const plexToken = tokenResponse.data.access_token;
|
||||
|
||||
// Get user info from Plex
|
||||
const userResponse = await axios.get('https://plex.tv/api/v2/user', {
|
||||
headers: {
|
||||
'X-Plex-Token': plexToken,
|
||||
'X-Plex-Client-Identifier': PLEX_CLIENT_ID,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const plexUser = userResponse.data;
|
||||
|
||||
// Check if user exists, create if not
|
||||
let user = await prisma.user.findUnique({
|
||||
where: { plexId: plexUser.id }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
plexId: plexUser.id,
|
||||
plexUsername: plexUser.username || plexUser.email,
|
||||
email: plexUser.email,
|
||||
// Check if user is Plex admin (implement your logic)
|
||||
isAdmin: false
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Update user info
|
||||
user = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
plexUsername: plexUser.username || plexUser.email,
|
||||
email: plexUser.email
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Create session
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
token: jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' }),
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||
}
|
||||
});
|
||||
|
||||
// Generate JWT
|
||||
const token = jwt.sign(
|
||||
{
|
||||
userId: user.id,
|
||||
plexId: user.plexId,
|
||||
isAdmin: user.isAdmin
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
sessionToken: session.token,
|
||||
user: {
|
||||
id: user.id,
|
||||
plexId: user.plexId,
|
||||
plexUsername: user.plexUsername,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
walletAddress: user.walletAddress,
|
||||
totalEarned: user.totalEarned,
|
||||
totalSpent: user.totalSpent
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
// Verify token
|
||||
router.get('/verify', asyncHandler(async (req, res) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as any;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.userId }
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
return res.status(401).json({ error: 'User not found or inactive' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
plexId: user.plexId,
|
||||
plexUsername: user.plexUsername,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
walletAddress: user.walletAddress,
|
||||
totalEarned: user.totalEarned,
|
||||
totalSpent: user.totalSpent
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
}));
|
||||
|
||||
// Logout
|
||||
router.post('/logout', asyncHandler(async (req, res) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as any;
|
||||
|
||||
await prisma.session.deleteMany({
|
||||
where: { userId: decoded.userId }
|
||||
});
|
||||
} catch {
|
||||
// Invalid token, ignore
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
}));
|
||||
|
||||
export { router as authRouter };
|
||||
@@ -0,0 +1,217 @@
|
||||
import { Router } from 'express';
|
||||
import axios from 'axios';
|
||||
import { authenticate, AuthenticatedRequest } from '../middleware/auth';
|
||||
import { prisma } from '../utils/prisma';
|
||||
import { asyncHandler } from '../middleware/errorHandler';
|
||||
import { getTokenBalance } from '../services/solana';
|
||||
import { io } from '../index';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const OVERSEER_URL = process.env.OVERSEER_URL || '';
|
||||
const OVERSEER_API_KEY = process.env.OVERSEER_API_KEY || '';
|
||||
|
||||
const overseerClient = axios.create({
|
||||
baseURL: `${OVERSEER_URL}/api/v1`,
|
||||
headers: {
|
||||
'X-Api-Key': OVERSEER_API_KEY
|
||||
}
|
||||
});
|
||||
|
||||
// Get request costs
|
||||
router.get('/costs',
|
||||
authenticate,
|
||||
asyncHandler(async (_req: AuthenticatedRequest, res) => {
|
||||
const settings = await prisma.systemSettings.findFirst();
|
||||
|
||||
res.json({
|
||||
movie: settings?.movieRequestCost || 100,
|
||||
tv: settings?.tvRequestCost || 200,
|
||||
tvPerSeason: settings?.tvPerSeasonCost || 50
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Get user's balance and request availability
|
||||
router.get('/balance',
|
||||
authenticate,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user!.id }
|
||||
});
|
||||
|
||||
if (!user?.walletAddress) {
|
||||
return res.json({
|
||||
hasWallet: false,
|
||||
balance: 0,
|
||||
canRequest: false
|
||||
});
|
||||
}
|
||||
|
||||
const balance = await getTokenBalance(user.walletAddress);
|
||||
const settings = await prisma.systemSettings.findFirst();
|
||||
|
||||
res.json({
|
||||
hasWallet: true,
|
||||
balance,
|
||||
canRequestMovie: balance >= (settings?.movieRequestCost || 100),
|
||||
canRequestTV: balance >= (settings?.tvRequestCost || 200),
|
||||
costs: {
|
||||
movie: settings?.movieRequestCost || 100,
|
||||
tv: settings?.tvRequestCost || 200
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Search for content
|
||||
router.get('/search',
|
||||
authenticate,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const { query } = req.query;
|
||||
|
||||
if (!query) {
|
||||
return res.status(400).json({ error: 'Query required' });
|
||||
}
|
||||
|
||||
const response = await overseerClient.get('/search', {
|
||||
params: { query }
|
||||
});
|
||||
|
||||
res.json(response.data);
|
||||
})
|
||||
);
|
||||
|
||||
// Request content
|
||||
router.post('/request',
|
||||
authenticate,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const { mediaType, mediaId, title, seasons } = req.body;
|
||||
|
||||
if (!mediaType || !mediaId || !title) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user!.id }
|
||||
});
|
||||
|
||||
if (!user?.walletAddress) {
|
||||
return res.status(400).json({ error: 'Wallet required' });
|
||||
}
|
||||
|
||||
const settings = await prisma.systemSettings.findFirst();
|
||||
|
||||
// Calculate cost
|
||||
let cost = 0;
|
||||
if (mediaType === 'movie') {
|
||||
cost = settings?.movieRequestCost || 100;
|
||||
} else if (mediaType === 'tv') {
|
||||
cost = settings?.tvRequestCost || 200;
|
||||
if (seasons && seasons.length > 1) {
|
||||
cost += (seasons.length - 1) * (settings?.tvPerSeasonCost || 50);
|
||||
}
|
||||
}
|
||||
|
||||
// Check balance
|
||||
const balance = await getTokenBalance(user.walletAddress);
|
||||
if (balance < cost) {
|
||||
return res.status(400).json({
|
||||
error: 'Insufficient balance',
|
||||
required: cost,
|
||||
current: balance
|
||||
});
|
||||
}
|
||||
|
||||
// Create request in Overseer
|
||||
const overseerRequest = await overseerClient.post('/request', {
|
||||
mediaType,
|
||||
mediaId,
|
||||
...(seasons && { seasons })
|
||||
});
|
||||
|
||||
// Create local request record
|
||||
const request = await prisma.contentRequest.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
overseerRequestId: overseerRequest.data.id,
|
||||
mediaType,
|
||||
tmdbId: mediaId,
|
||||
title,
|
||||
creditsCost: cost,
|
||||
status: 'PENDING',
|
||||
requestedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
request,
|
||||
cost,
|
||||
message: 'Request submitted. Credits will be deducted when approved.'
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Webhook: Handle Overseer request status changes
|
||||
router.post('/webhook',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { request_id, status } = req.body;
|
||||
|
||||
if (!request_id || !status) {
|
||||
return res.status(400).json({ error: 'Missing fields' });
|
||||
}
|
||||
|
||||
// Find local request
|
||||
const request = await prisma.contentRequest.findFirst({
|
||||
where: { overseerRequestId: request_id },
|
||||
include: { user: true }
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
return res.status(404).json({ error: 'Request not found' });
|
||||
}
|
||||
|
||||
// Update status
|
||||
const updatedRequest = await prisma.contentRequest.update({
|
||||
where: { id: request.id },
|
||||
data: { status }
|
||||
});
|
||||
|
||||
// If approved, deduct credits
|
||||
if (status === 'APPROVED' && !request.user.totalSpent) {
|
||||
// Note: Actual burning would happen here
|
||||
// For now, we just record the transaction
|
||||
|
||||
const transaction = await prisma.transaction.create({
|
||||
data: {
|
||||
userId: request.userId,
|
||||
type: 'SPEND',
|
||||
amount: request.creditsCost,
|
||||
requestId: request.id,
|
||||
description: `Request: ${request.title}`,
|
||||
contentTitle: request.title
|
||||
}
|
||||
});
|
||||
|
||||
// Update user stats
|
||||
await prisma.user.update({
|
||||
where: { id: request.userId },
|
||||
data: {
|
||||
totalSpent: { increment: request.creditsCost }
|
||||
}
|
||||
});
|
||||
|
||||
// Emit update
|
||||
io.to(`user:${request.userId}`).emit('credits_spent', {
|
||||
amount: request.creditsCost,
|
||||
title: request.title,
|
||||
transaction
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true, request: updatedRequest });
|
||||
})
|
||||
);
|
||||
|
||||
export { router as overseerRouter };
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Router } from 'express';
|
||||
import axios from 'axios';
|
||||
import { authenticate, AuthenticatedRequest, requireAdmin } from '../middleware/auth';
|
||||
import { asyncHandler } from '../middleware/errorHandler';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const TAUTULLI_URL = process.env.TAUTULLI_URL || '';
|
||||
const TAUTULLI_API_KEY = process.env.TAUTULLI_API_KEY || '';
|
||||
|
||||
// Get Tautulli connection status
|
||||
router.get('/status',
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (_req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${TAUTULLI_URL}/api/v2`, {
|
||||
params: {
|
||||
apikey: TAUTULLI_API_KEY,
|
||||
cmd: 'get_server_info'
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
connected: true,
|
||||
data: response.data.response.data
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
connected: false,
|
||||
error: 'Failed to connect to Tautulli'
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Get watch statistics
|
||||
router.get('/stats',
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (_req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${TAUTULLI_URL}/api/v2`, {
|
||||
params: {
|
||||
apikey: TAUTULLI_API_KEY,
|
||||
cmd: 'get_libraries'
|
||||
}
|
||||
});
|
||||
|
||||
res.json(response.data.response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch stats' });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Get webhook configuration guide
|
||||
router.get('/webhook-config',
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (_req: AuthenticatedRequest, res) => {
|
||||
const webhookUrl = `${process.env.API_URL}/webhooks/tautulli`;
|
||||
|
||||
res.json({
|
||||
webhookUrl,
|
||||
instructions: [
|
||||
'1. Open Tautulli Settings',
|
||||
'2. Go to Notification Agents',
|
||||
'3. Add Webhook',
|
||||
'4. Set Webhook URL to the URL above',
|
||||
'5. Set Webhook Method to POST',
|
||||
'6. Configure triggers for "Watched" events',
|
||||
'7. Set payload to JSON format'
|
||||
],
|
||||
payloadTemplate: {
|
||||
action: 'watched',
|
||||
user_id: '{user_id}',
|
||||
username: '{username}',
|
||||
rating_key: '{rating_key}',
|
||||
session_key: '{session_key}',
|
||||
media_type: '{media_type}',
|
||||
title: '{title}',
|
||||
grandparent_title: '{grandparent_title}',
|
||||
started: '{started}',
|
||||
stopped: '{stopped}',
|
||||
percent_complete: '{percent_complete}',
|
||||
is_new: '{is_new}'
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export { router as tautulliRouter };
|
||||
@@ -0,0 +1,210 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate, AuthenticatedRequest, requireAdmin } from '../middleware/auth';
|
||||
import { prisma } from '../utils/prisma';
|
||||
import { asyncHandler } from '../middleware/errorHandler';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Get user's transactions
|
||||
router.get('/',
|
||||
authenticate,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const { page = '1', limit = '20', type } = 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 = { userId: req.user!.id };
|
||||
if (type) {
|
||||
where.type = type;
|
||||
}
|
||||
|
||||
const [transactions, total] = await Promise.all([
|
||||
prisma.transaction.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: limitNum,
|
||||
include: {
|
||||
watchEvent: {
|
||||
select: {
|
||||
duration: true,
|
||||
percentComplete: true
|
||||
}
|
||||
},
|
||||
request: {
|
||||
select: {
|
||||
mediaType: true,
|
||||
status: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
prisma.transaction.count({ where })
|
||||
]);
|
||||
|
||||
res.json({
|
||||
transactions,
|
||||
pagination: {
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limitNum)
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Get transaction stats
|
||||
router.get('/stats',
|
||||
authenticate,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
const [
|
||||
totalStats,
|
||||
recentStats,
|
||||
byType
|
||||
] = await Promise.all([
|
||||
// All time stats
|
||||
prisma.transaction.groupBy({
|
||||
by: ['type'],
|
||||
where: { userId: req.user!.id },
|
||||
_sum: { amount: true },
|
||||
_count: { id: true }
|
||||
}),
|
||||
// Last 30 days
|
||||
prisma.transaction.groupBy({
|
||||
by: ['type'],
|
||||
where: {
|
||||
userId: req.user!.id,
|
||||
createdAt: { gte: thirtyDaysAgo }
|
||||
},
|
||||
_sum: { amount: true },
|
||||
_count: { id: true }
|
||||
}),
|
||||
// By type breakdown
|
||||
prisma.transaction.findMany({
|
||||
where: { userId: req.user!.id },
|
||||
select: {
|
||||
type: true,
|
||||
amount: true,
|
||||
createdAt: true
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100
|
||||
})
|
||||
]);
|
||||
|
||||
// Calculate daily earnings for chart
|
||||
const dailyEarnings = await prisma.$queryRaw`
|
||||
SELECT
|
||||
DATE(created_at) as date,
|
||||
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 user_id = ${req.user!.id}
|
||||
AND created_at >= ${thirtyDaysAgo}
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date DESC
|
||||
`;
|
||||
|
||||
res.json({
|
||||
total: totalStats,
|
||||
recent: recentStats,
|
||||
dailyEarnings,
|
||||
recentTransactions: byType.slice(0, 10)
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Admin: Get all transactions
|
||||
router.get('/admin/all',
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const { page = '1', limit = '50', userId, type } = 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 (userId) where.userId = userId;
|
||||
if (type) where.type = type;
|
||||
|
||||
const [transactions, total] = await Promise.all([
|
||||
prisma.transaction.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: limitNum,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
plexUsername: true,
|
||||
walletAddress: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
prisma.transaction.count({ where })
|
||||
]);
|
||||
|
||||
res.json({
|
||||
transactions,
|
||||
pagination: {
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limitNum)
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Admin: Get system-wide stats
|
||||
router.get('/admin/stats',
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (_req: AuthenticatedRequest, res) => {
|
||||
const [
|
||||
totalMinted,
|
||||
totalBurned,
|
||||
totalUsers,
|
||||
activeUsers,
|
||||
recentTransactions
|
||||
] = await Promise.all([
|
||||
prisma.transaction.aggregate({
|
||||
where: { type: 'EARN' },
|
||||
_sum: { amount: true }
|
||||
}),
|
||||
prisma.transaction.aggregate({
|
||||
where: { type: 'SPEND' },
|
||||
_sum: { amount: true }
|
||||
}),
|
||||
prisma.user.count(),
|
||||
prisma.user.count({ where: { walletAddress: { not: null } } }),
|
||||
prisma.transaction.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: new Date(Date.now() - 24 * 60 * 60 * 1000)
|
||||
}
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
res.json({
|
||||
totalMinted: totalMinted._sum.amount || 0,
|
||||
totalBurned: totalBurned._sum.amount || 0,
|
||||
netSupply: (totalMinted._sum.amount || 0) - (totalBurned._sum.amount || 0),
|
||||
totalUsers,
|
||||
activeUsers,
|
||||
recentTransactions
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export { router as transactionsRouter };
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate, AuthenticatedRequest } from '../middleware/auth';
|
||||
import { prisma } from '../utils/prisma';
|
||||
import { asyncHandler } from '../middleware/errorHandler';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Get current user profile
|
||||
router.get('/me',
|
||||
authenticate,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user!.id },
|
||||
select: {
|
||||
id: true,
|
||||
plexId: true,
|
||||
plexUsername: true,
|
||||
email: true,
|
||||
isAdmin: true,
|
||||
walletAddress: true,
|
||||
totalEarned: true,
|
||||
totalSpent: true,
|
||||
watchTimeMinutes: true,
|
||||
createdAt: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
res.json(user);
|
||||
})
|
||||
);
|
||||
|
||||
// Update user profile
|
||||
router.put('/me',
|
||||
authenticate,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const { email } = req.body;
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.user!.id },
|
||||
data: { email },
|
||||
select: {
|
||||
id: true,
|
||||
plexUsername: true,
|
||||
email: true,
|
||||
walletAddress: true
|
||||
}
|
||||
});
|
||||
|
||||
res.json(user);
|
||||
})
|
||||
);
|
||||
|
||||
// Get user's watch history
|
||||
router.get('/me/watch-history',
|
||||
authenticate,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const { page = '1', limit = '20' } = req.query;
|
||||
|
||||
const pageNum = parseInt(page as string);
|
||||
const limitNum = Math.min(parseInt(limit as string), 50);
|
||||
const skip = (pageNum - 1) * limitNum;
|
||||
|
||||
const [events, total] = await Promise.all([
|
||||
prisma.watchEvent.findMany({
|
||||
where: { userId: req.user!.id },
|
||||
orderBy: { watchedAt: 'desc' },
|
||||
skip,
|
||||
take: limitNum,
|
||||
select: {
|
||||
id: true,
|
||||
contentType: true,
|
||||
title: true,
|
||||
grandparentTitle: true,
|
||||
duration: true,
|
||||
percentComplete: true,
|
||||
creditsEarned: true,
|
||||
isProcessed: true,
|
||||
watchedAt: true
|
||||
}
|
||||
}),
|
||||
prisma.watchEvent.count({ where: { userId: req.user!.id } })
|
||||
]);
|
||||
|
||||
res.json({
|
||||
events,
|
||||
pagination: {
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limitNum)
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Get user's content requests
|
||||
router.get('/me/requests',
|
||||
authenticate,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const { page = '1', limit = '20', status } = req.query;
|
||||
|
||||
const pageNum = parseInt(page as string);
|
||||
const limitNum = Math.min(parseInt(limit as string), 50);
|
||||
const skip = (pageNum - 1) * limitNum;
|
||||
|
||||
const where: any = { userId: req.user!.id };
|
||||
if (status) where.status = status;
|
||||
|
||||
const [requests, total] = await Promise.all([
|
||||
prisma.contentRequest.findMany({
|
||||
where,
|
||||
orderBy: { requestedAt: 'desc' },
|
||||
skip,
|
||||
take: limitNum
|
||||
}),
|
||||
prisma.contentRequest.count({ where })
|
||||
]);
|
||||
|
||||
res.json({
|
||||
requests,
|
||||
pagination: {
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limitNum)
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export { router as userRouter };
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate, AuthenticatedRequest, requireAdmin } from '../middleware/auth';
|
||||
import { prisma } from '../utils/prisma';
|
||||
import { createWallet, getTokenBalance, requestAirdrop } from '../services/solana';
|
||||
import { asyncHandler } from '../middleware/errorHandler';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const router = Router();
|
||||
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'default-key-32-chars-long!!!!!';
|
||||
|
||||
// Encrypt private key
|
||||
function encrypt(text: string): string {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', Buffer.from(ENCRYPTION_KEY), iv);
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
const authTag = cipher.getAuthTag();
|
||||
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
|
||||
}
|
||||
|
||||
// Decrypt private key
|
||||
function decrypt(encryptedData: string): string {
|
||||
const parts = encryptedData.split(':');
|
||||
const iv = Buffer.from(parts[0], 'hex');
|
||||
const authTag = Buffer.from(parts[1], 'hex');
|
||||
const encrypted = parts[2];
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(ENCRYPTION_KEY), iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
// Get user's wallet info
|
||||
router.get('/',
|
||||
authenticate,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user!.id },
|
||||
select: {
|
||||
walletAddress: true,
|
||||
totalEarned: true,
|
||||
totalSpent: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!user?.walletAddress) {
|
||||
return res.json({
|
||||
hasWallet: false,
|
||||
balance: 0,
|
||||
totalEarned: user?.totalEarned || 0,
|
||||
totalSpent: user?.totalSpent || 0
|
||||
});
|
||||
}
|
||||
|
||||
// Get on-chain balance
|
||||
const balance = await getTokenBalance(user.walletAddress);
|
||||
|
||||
res.json({
|
||||
hasWallet: true,
|
||||
address: user.walletAddress,
|
||||
balance,
|
||||
totalEarned: user.totalEarned,
|
||||
totalSpent: user.totalSpent,
|
||||
explorerUrl: `https://explorer.solana.com/address/${user.walletAddress}?cluster=devnet`
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Create new wallet
|
||||
router.post('/create',
|
||||
authenticate,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user!.id }
|
||||
});
|
||||
|
||||
if (user?.walletAddress) {
|
||||
return res.status(400).json({ error: 'Wallet already exists' });
|
||||
}
|
||||
|
||||
// Create new Solana wallet
|
||||
const wallet = createWallet();
|
||||
const encryptedKey = encrypt(wallet.secretKey);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: req.user!.id },
|
||||
data: {
|
||||
walletAddress: wallet.publicKey,
|
||||
encryptedPrivateKey: encryptedKey
|
||||
}
|
||||
});
|
||||
|
||||
// Request airdrop for testing
|
||||
await requestAirdrop(wallet.publicKey);
|
||||
|
||||
res.json({
|
||||
address: wallet.publicKey,
|
||||
message: 'Wallet created successfully. Funded with 2 SOL for transaction fees.',
|
||||
warning: 'Please backup your recovery phrase if shown. This is the only time it will be displayed.'
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Connect existing wallet
|
||||
router.post('/connect',
|
||||
authenticate,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const { address } = req.body;
|
||||
|
||||
if (!address) {
|
||||
return res.status(400).json({ error: 'Wallet address required' });
|
||||
}
|
||||
|
||||
// Check if address is already connected to another user
|
||||
const existing = await prisma.user.findFirst({
|
||||
where: {
|
||||
walletAddress: address,
|
||||
NOT: { id: req.user!.id }
|
||||
}
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return res.status(400).json({ error: 'Wallet already connected to another account' });
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: req.user!.id },
|
||||
data: { walletAddress: address }
|
||||
});
|
||||
|
||||
res.json({
|
||||
address,
|
||||
message: 'Wallet connected successfully'
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Get recovery phrase (only shown once at creation)
|
||||
router.post('/backup',
|
||||
authenticate,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user!.id }
|
||||
});
|
||||
|
||||
if (!user?.encryptedPrivateKey) {
|
||||
return res.status(400).json({ error: 'No wallet found' });
|
||||
}
|
||||
|
||||
// Decrypt and return private key for backup
|
||||
const privateKey = decrypt(user.encryptedPrivateKey);
|
||||
|
||||
res.json({
|
||||
privateKey,
|
||||
warning: 'Store this securely. Never share it with anyone.'
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Admin: Get user's wallet
|
||||
router.get('/admin/:userId',
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.params.userId },
|
||||
select: {
|
||||
id: true,
|
||||
plexUsername: true,
|
||||
walletAddress: true,
|
||||
totalEarned: true,
|
||||
totalSpent: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
let balance = 0;
|
||||
if (user.walletAddress) {
|
||||
balance = await getTokenBalance(user.walletAddress);
|
||||
}
|
||||
|
||||
res.json({
|
||||
...user,
|
||||
balance
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export { router as walletRouter };
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Router } from 'express';
|
||||
import { prisma } from '../utils/prisma';
|
||||
import { mintTokens } from '../services/solana';
|
||||
import { asyncHandler } from '../middleware/errorHandler';
|
||||
import { io } from '../index';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const router = Router();
|
||||
const WEBHOOK_SECRET = process.env.TAUTULLI_WEBHOOK_SECRET || '';
|
||||
|
||||
// Verify webhook signature
|
||||
function verifyWebhookSignature(payload: string, signature: string): boolean {
|
||||
if (!WEBHOOK_SECRET) return true; // Skip verification if no secret set
|
||||
|
||||
const expected = crypto
|
||||
.createHmac('sha256', WEBHOOK_SECRET)
|
||||
.update(payload)
|
||||
.digest('hex');
|
||||
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(signature),
|
||||
Buffer.from(expected)
|
||||
);
|
||||
}
|
||||
|
||||
// Tautulli webhook endpoint
|
||||
router.post('/tautulli',
|
||||
asyncHandler(async (req, res) => {
|
||||
const signature = req.headers['x-tautulli-signature'] as string;
|
||||
const payload = JSON.stringify(req.body);
|
||||
|
||||
// Verify signature if configured
|
||||
if (WEBHOOK_SECRET && signature && !verifyWebhookSignature(payload, signature)) {
|
||||
return res.status(401).json({ error: 'Invalid signature' });
|
||||
}
|
||||
|
||||
const event = req.body;
|
||||
|
||||
// Only process watched events
|
||||
if (event.action !== 'watched') {
|
||||
return res.json({ message: 'Event type not processed' });
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!event.user_id || !event.rating_key || !event.session_key) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
// Find user by Plex ID
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { plexId: event.user_id.toString() }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
console.log(`User not found for Plex ID: ${event.user_id}`);
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
if (!user.walletAddress) {
|
||||
console.log(`User ${user.plexUsername} has no wallet`);
|
||||
return res.status(400).json({ error: 'User has no wallet' });
|
||||
}
|
||||
|
||||
// Check for duplicate events
|
||||
const existing = await prisma.watchEvent.findUnique({
|
||||
where: { sessionId: event.session_key.toString() }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return res.json({ message: 'Event already processed' });
|
||||
}
|
||||
|
||||
// Get system settings
|
||||
const settings = await prisma.systemSettings.findFirst();
|
||||
const creditsPerMinute = settings?.creditsPerMinute || 10;
|
||||
const minWatchPercent = settings?.minWatchPercent || 80;
|
||||
const minWatchMinutes = settings?.minWatchMinutes || 5;
|
||||
|
||||
// Calculate watch duration
|
||||
const watchDurationMinutes = Math.floor((event.stopped - event.started) / 60);
|
||||
const percentComplete = event.percent_complete || 0;
|
||||
|
||||
// Validate minimum requirements
|
||||
if (percentComplete < minWatchPercent) {
|
||||
return res.json({
|
||||
message: 'Watch percentage too low',
|
||||
percentComplete,
|
||||
required: minWatchPercent
|
||||
});
|
||||
}
|
||||
|
||||
if (watchDurationMinutes < minWatchMinutes) {
|
||||
return res.json({
|
||||
message: 'Watch duration too short',
|
||||
watchDurationMinutes,
|
||||
required: minWatchMinutes
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate credits
|
||||
let creditsEarned = watchDurationMinutes * creditsPerMinute;
|
||||
|
||||
// Apply multipliers
|
||||
if (settings?.newReleaseMultiplier && event.is_new) {
|
||||
creditsEarned = Math.floor(creditsEarned * Number(settings.newReleaseMultiplier));
|
||||
}
|
||||
|
||||
if (settings?.bonusMultiplierActive) {
|
||||
creditsEarned = Math.floor(creditsEarned * Number(settings.bonusMultiplier));
|
||||
}
|
||||
|
||||
// Create watch event record
|
||||
const watchEvent = await prisma.watchEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
sessionId: event.session_key.toString(),
|
||||
ratingKey: event.rating_key.toString(),
|
||||
contentType: event.media_type,
|
||||
title: event.title,
|
||||
grandparentTitle: event.grandparent_title,
|
||||
duration: event.stopped - event.started,
|
||||
percentComplete: Math.floor(percentComplete),
|
||||
creditsEarned,
|
||||
watchedAt: new Date(event.stopped * 1000)
|
||||
}
|
||||
});
|
||||
|
||||
// Mint tokens on Solana
|
||||
const signature = await mintTokens(
|
||||
user.walletAddress,
|
||||
creditsEarned,
|
||||
{
|
||||
sessionId: event.session_key.toString(),
|
||||
contentTitle: event.title,
|
||||
watchDurationMinutes
|
||||
}
|
||||
);
|
||||
|
||||
if (signature) {
|
||||
// Create transaction record
|
||||
const transaction = await prisma.transaction.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: 'EARN',
|
||||
amount: creditsEarned,
|
||||
watchEventId: watchEvent.id,
|
||||
solanaSignature: signature,
|
||||
description: `Watched ${event.title}`,
|
||||
contentTitle: event.title
|
||||
}
|
||||
});
|
||||
|
||||
// Update user stats
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
totalEarned: { increment: creditsEarned },
|
||||
watchTimeMinutes: { increment: watchDurationMinutes }
|
||||
}
|
||||
});
|
||||
|
||||
// Mark watch event as processed
|
||||
await prisma.watchEvent.update({
|
||||
where: { id: watchEvent.id },
|
||||
data: { isProcessed: true }
|
||||
});
|
||||
|
||||
// Emit real-time update via WebSocket
|
||||
io.to(`user:${user.id}`).emit('credits_earned', {
|
||||
amount: creditsEarned,
|
||||
title: event.title,
|
||||
transaction: {
|
||||
id: transaction.id,
|
||||
type: 'EARN',
|
||||
amount: creditsEarned,
|
||||
contentTitle: event.title,
|
||||
createdAt: transaction.createdAt
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
creditsEarned,
|
||||
solanaSignature: signature,
|
||||
message: `Minted ${creditsEarned} COOP for watching ${event.title}`
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({ error: 'Failed to mint tokens' });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Test webhook endpoint
|
||||
router.post('/test',
|
||||
asyncHandler(async (req, res) => {
|
||||
res.json({
|
||||
message: 'Webhook endpoint working',
|
||||
timestamp: new Date().toISOString(),
|
||||
body: req.body
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export { router as webhookRouter };
|
||||
Reference in New Issue
Block a user