refactor: db-only credits and admin controls

Remove blockchain dependence from credit flow:
- watch credits now DB-only
- webhook earnings now DB-only
- wallet balance now DB-only
- admin bonus/adjustment routes operate on DB totals

Add admin adjustment API for account control.
Keep Solana service file unused for now; safe to delete later.
This commit is contained in:
2026-04-22 13:05:48 -04:00
parent 86a8c7d89a
commit 8ec91acc25
5 changed files with 327 additions and 470 deletions
+118 -196
View File
@@ -1,103 +1,82 @@
import { Router } from 'express'; import { Router } from "express";
import { authenticate, AuthenticatedRequest, requireAdmin } from '../middleware/auth'; import { io } from "../index";
import { prisma } from '../utils/prisma'; import {
import { asyncHandler } from '../middleware/errorHandler'; type AuthenticatedRequest,
import { mintTokens } from '../services/solana'; authenticate,
import { io } from '../index'; requireAdmin,
} from "../middleware/auth";
import { asyncHandler } from "../middleware/errorHandler";
import { prisma } from "../utils/prisma";
const router = Router(); const router = Router();
// Get system settings router.get(
router.get('/settings', "/settings",
authenticate, authenticate,
requireAdmin, requireAdmin,
asyncHandler(async (_req: AuthenticatedRequest, res) => { asyncHandler(async (_req: AuthenticatedRequest, res) => {
const settings = await prisma.systemSettings.findFirst(); const settings = await prisma.systemSettings.findFirst();
if (!settings) return res.status(404).json({ error: "Settings not found" });
if (!settings) {
return res.status(404).json({ error: 'Settings not found' });
}
res.json(settings); res.json(settings);
}) }),
); );
// Update system settings router.put(
router.put('/settings', "/settings",
authenticate, authenticate,
requireAdmin, requireAdmin,
asyncHandler(async (req: AuthenticatedRequest, res) => { asyncHandler(async (req: AuthenticatedRequest, res) => {
const {
creditsPerMinute,
minWatchPercent,
minWatchMinutes,
movieRequestCost,
tvRequestCost,
newReleaseMultiplier,
bonusMultiplierActive,
bonusMultiplier
} = req.body;
const settings = await prisma.systemSettings.update({ const settings = await prisma.systemSettings.update({
where: { id: 'default' }, where: { id: "default" },
data: { data: {
creditsPerMinute, creditsPerMinute: req.body.creditsPerMinute,
minWatchPercent, minWatchPercent: req.body.minWatchPercent,
minWatchMinutes, minWatchMinutes: req.body.minWatchMinutes,
movieRequestCost, movieRequestCost: req.body.movieRequestCost,
tvRequestCost, tvRequestCost: req.body.tvRequestCost,
newReleaseMultiplier, tvPerSeasonCost: req.body.tvPerSeasonCost,
bonusMultiplierActive, newReleaseMultiplier: req.body.newReleaseMultiplier,
bonusMultiplier, bonusMultiplierActive: req.body.bonusMultiplierActive,
updatedBy: req.user!.id bonusMultiplier: req.body.bonusMultiplier,
} updatedBy: req.user!.id,
},
}); });
res.json(settings); res.json(settings);
}) }),
); );
// Pause minting router.post(
router.post('/pause', "/pause",
authenticate, authenticate,
requireAdmin, requireAdmin,
asyncHandler(async (req: AuthenticatedRequest, res) => { asyncHandler(async (req: AuthenticatedRequest, res) => {
await prisma.systemSettings.update({ await prisma.systemSettings.update({
where: { id: 'default' }, where: { id: "default" },
data: { data: { mintingPaused: true, updatedBy: req.user!.id },
mintingPaused: true,
updatedBy: req.user!.id
}
}); });
res.json({ message: "Minting paused" });
res.json({ message: 'Minting paused' }); }),
})
); );
// Resume minting router.post(
router.post('/resume', "/resume",
authenticate, authenticate,
requireAdmin, requireAdmin,
asyncHandler(async (req: AuthenticatedRequest, res) => { asyncHandler(async (req: AuthenticatedRequest, res) => {
await prisma.systemSettings.update({ await prisma.systemSettings.update({
where: { id: 'default' }, where: { id: "default" },
data: { data: { mintingPaused: false, updatedBy: req.user!.id },
mintingPaused: false,
updatedBy: req.user!.id
}
}); });
res.json({ message: "Minting resumed" });
res.json({ message: 'Minting resumed' }); }),
})
); );
// Get all users router.get(
router.get('/users', "/users",
authenticate, authenticate,
requireAdmin, requireAdmin,
asyncHandler(async (req: AuthenticatedRequest, res) => { asyncHandler(async (req: AuthenticatedRequest, res) => {
const { page = '1', limit = '50', search } = req.query; const { page = "1", limit = "50", search } = req.query;
const pageNum = parseInt(page as string); const pageNum = parseInt(page as string);
const limitNum = Math.min(parseInt(limit as string), 100); const limitNum = Math.min(parseInt(limit as string), 100);
const skip = (pageNum - 1) * limitNum; const skip = (pageNum - 1) * limitNum;
@@ -105,15 +84,15 @@ router.get('/users',
const where: any = {}; const where: any = {};
if (search) { if (search) {
where.OR = [ where.OR = [
{ plexUsername: { contains: search as string, mode: 'insensitive' } }, { plexUsername: { contains: search as string, mode: "insensitive" } },
{ email: { contains: search as string, mode: 'insensitive' } } { email: { contains: search as string, mode: "insensitive" } },
]; ];
} }
const [users, total] = await Promise.all([ const [users, total] = await Promise.all([
prisma.user.findMany({ prisma.user.findMany({
where, where,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: "desc" },
skip, skip,
take: limitNum, take: limitNum,
select: { select: {
@@ -127,10 +106,10 @@ router.get('/users',
totalEarned: true, totalEarned: true,
totalSpent: true, totalSpent: true,
watchTimeMinutes: true, watchTimeMinutes: true,
createdAt: true createdAt: true,
} },
}), }),
prisma.user.count({ where }) prisma.user.count({ where }),
]); ]);
res.json({ res.json({
@@ -139,178 +118,121 @@ router.get('/users',
page: pageNum, page: pageNum,
limit: limitNum, limit: limitNum,
total, total,
totalPages: Math.ceil(total / limitNum) totalPages: Math.ceil(total / limitNum),
} },
}); });
}) }),
); );
// Update user router.put(
router.put('/users/:id', "/users/:id",
authenticate, authenticate,
requireAdmin, requireAdmin,
asyncHandler(async (req: AuthenticatedRequest, res) => { asyncHandler(async (req: AuthenticatedRequest, res) => {
const { isAdmin, isActive } = req.body;
const user = await prisma.user.update({ const user = await prisma.user.update({
where: { id: req.params.id }, where: { id: req.params.id },
data: { data: { isAdmin: req.body.isAdmin, isActive: req.body.isActive },
isAdmin, select: { id: true, plexUsername: true, isAdmin: true, isActive: true },
isActive
},
select: {
id: true,
plexUsername: true,
isAdmin: true,
isActive: true
}
}); });
res.json(user); res.json(user);
}) }),
); );
// Grant bonus credits router.post(
router.post('/users/:id/bonus', "/users/:id/bonus",
authenticate, authenticate,
requireAdmin, requireAdmin,
asyncHandler(async (req: AuthenticatedRequest, res) => { asyncHandler(async (req: AuthenticatedRequest, res) => {
const { amount, reason } = req.body; const { amount, reason } = req.body;
if (!amount || amount <= 0)
return res.status(400).json({ error: "Valid amount required" });
if (!amount || amount <= 0) { const user = await prisma.user.findUnique({ where: { id: req.params.id } });
return res.status(400).json({ error: 'Valid amount required' }); if (!user) return res.status(404).json({ error: "User not found" });
}
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({ const transaction = await prisma.transaction.create({
data: { data: {
userId: user.id, userId: user.id,
type: 'BONUS', type: "BONUS",
amount, amount,
solanaSignature: signature, description: reason || "Admin bonus",
description: reason || 'Admin bonus', contentTitle: reason || "Admin bonus",
contentTitle: reason || 'Admin Bonus' },
}
}); });
// Update user stats
await prisma.user.update({ await prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: { totalEarned: { increment: amount } },
totalEarned: { increment: amount }
}
}); });
io.to(`user:${user.id}`).emit("bonus_received", {
// Emit real-time update via WebSocket
io.to(`user:${user.id}`).emit('bonus_received', {
amount, amount,
reason: reason || 'Admin Bonus', reason: reason || "Admin bonus",
transaction transaction,
}); });
res.json({ success: true, amount, transaction });
res.json({ }),
success: true,
amount,
solanaSignature: signature,
transaction
});
})
); );
// Get dashboard analytics router.post(
router.get('/analytics', "/users/:id/adjust",
authenticate,
requireAdmin,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const { amount, reason } = req.body;
if (!amount || Number.isNaN(Number(amount)) || Number(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" });
const delta = Number(amount);
await prisma.transaction.create({
data: {
userId: user.id,
type: "ADJUSTMENT",
amount: Math.abs(delta),
description: reason || "Admin adjustment",
contentTitle: reason || "Admin adjustment",
},
});
await prisma.user.update({
where: { id: user.id },
data:
delta > 0
? { totalEarned: { increment: delta } }
: { totalSpent: { increment: Math.abs(delta) } },
});
res.json({ success: true });
}),
);
router.get(
"/analytics",
authenticate, authenticate,
requireAdmin, requireAdmin,
asyncHandler(async (_req: AuthenticatedRequest, res) => { asyncHandler(async (_req: AuthenticatedRequest, res) => {
const sevenDaysAgo = new Date(); const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const thirtyDaysAgo = new Date(); const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const [ const [userStats, transactionStats, watchStats, dailyActivity] =
userStats, await Promise.all([
transactionStats, 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`,
watchStats, 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`,
dailyActivity 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`,
] = await Promise.all([ 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`,
// 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({ res.json({
users: userStats[0], users: userStats[0],
transactions: transactionStats[0], transactions: transactionStats[0],
watchStats: watchStats[0], watchStats: watchStats[0],
dailyActivity dailyActivity,
}); });
}) }),
); );
export { router as adminRouter }; export { router as adminRouter };
+2 -12
View File
@@ -3,7 +3,7 @@ import crypto from "crypto";
import { Router } from "express"; import { Router } from "express";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { asyncHandler } from "../middleware/errorHandler"; import { asyncHandler } from "../middleware/errorHandler";
import { createWallet, mintTokens } from "../services/solana"; import { createWallet } from "../services/solana";
import { prisma } from "../utils/prisma"; import { prisma } from "../utils/prisma";
const ENCRYPTION_KEY = const ENCRYPTION_KEY =
@@ -374,22 +374,13 @@ async function backfillUserHistory(user: {
}, },
}); });
// Mint tokens on Solana // DB-only credit record
const signature = await mintTokens(user.walletAddress!, creditsEarned, {
sessionId,
contentTitle: item.title || item.full_title || "Unknown",
watchDurationMinutes,
});
if (signature) {
// Create transaction record
await prisma.transaction.create({ await prisma.transaction.create({
data: { data: {
userId: user.id, userId: user.id,
type: "EARN", type: "EARN",
amount: creditsEarned, amount: creditsEarned,
watchEventId: watchEvent.id, watchEventId: watchEvent.id,
solanaSignature: signature,
description: `Watched ${item.title || item.full_title || "Unknown"}`, description: `Watched ${item.title || item.full_title || "Unknown"}`,
contentTitle: item.title || item.full_title || "Unknown", contentTitle: item.title || item.full_title || "Unknown",
}, },
@@ -399,7 +390,6 @@ async function backfillUserHistory(user: {
totalMinutes += watchDurationMinutes; totalMinutes += watchDurationMinutes;
processedCount++; processedCount++;
} }
}
// Update user stats // Update user stats
if (totalCredits > 0) { if (totalCredits > 0) {
+59 -49
View File
@@ -6,18 +6,13 @@ import {
requireAdmin, requireAdmin,
} from "../middleware/auth"; } from "../middleware/auth";
import { asyncHandler } from "../middleware/errorHandler"; import { asyncHandler } from "../middleware/errorHandler";
import { import { createWallet } from "../services/solana";
createWallet,
getTokenBalance,
requestAirdrop,
} from "../services/solana";
import { prisma } from "../utils/prisma"; import { prisma } from "../utils/prisma";
const router = Router(); const router = Router();
const ENCRYPTION_KEY = const ENCRYPTION_KEY =
process.env.ENCRYPTION_KEY || "default-key-32-chars-long!!!!!"; process.env.ENCRYPTION_KEY || "default-key-32-chars-long!!!!!";
// Encrypt private key
function encrypt(text: string): string { function encrypt(text: string): string {
const iv = crypto.randomBytes(16); const iv = crypto.randomBytes(16);
const key = crypto.createHash("sha256").update(ENCRYPTION_KEY).digest(); const key = crypto.createHash("sha256").update(ENCRYPTION_KEY).digest();
@@ -28,7 +23,6 @@ function encrypt(text: string): string {
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`; return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
} }
// Decrypt private key
function decrypt(encryptedData: string): string { function decrypt(encryptedData: string): string {
const parts = encryptedData.split(":"); const parts = encryptedData.split(":");
const iv = Buffer.from(parts[0], "hex"); const iv = Buffer.from(parts[0], "hex");
@@ -42,7 +36,6 @@ function decrypt(encryptedData: string): string {
return decrypted; return decrypted;
} }
// Get user's wallet info
router.get( router.get(
"/", "/",
authenticate, authenticate,
@@ -65,24 +58,18 @@ router.get(
}); });
} }
// Get on-chain balance (may be 0 if using DB-only credits)
const onChainBalance = await getTokenBalance(user.walletAddress);
// Usable credits are tracked in DB (works even when blockchain is down)
const dbBalance = user.totalEarned - user.totalSpent; const dbBalance = user.totalEarned - user.totalSpent;
res.json({ res.json({
hasWallet: true, hasWallet: true,
address: user.walletAddress, address: user.walletAddress,
balance: dbBalance, balance: dbBalance,
onChainBalance,
totalEarned: user.totalEarned, totalEarned: user.totalEarned,
totalSpent: user.totalSpent, totalSpent: user.totalSpent,
explorerUrl: `https://explorer.solana.com/address/${user.walletAddress}?cluster=devnet`,
}); });
}), }),
); );
// Create new wallet
router.post( router.post(
"/create", "/create",
authenticate, authenticate,
@@ -95,7 +82,6 @@ router.post(
return res.status(400).json({ error: "Wallet already exists" }); return res.status(400).json({ error: "Wallet already exists" });
} }
// Create new Solana wallet
const wallet = createWallet(); const wallet = createWallet();
const encryptedKey = encrypt(wallet.secretKey); const encryptedKey = encrypt(wallet.secretKey);
@@ -107,20 +93,13 @@ router.post(
}, },
}); });
// Request airdrop for testing
await requestAirdrop(wallet.publicKey);
res.json({ res.json({
success: true,
address: wallet.publicKey, 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( router.post(
"/connect", "/connect",
authenticate, authenticate,
@@ -128,21 +107,15 @@ router.post(
const { address } = req.body; const { address } = req.body;
if (!address) { if (!address) {
return res.status(400).json({ error: "Wallet address required" }); return res.status(400).json({ error: "Address required" });
} }
// Check if address is already connected to another user
const existing = await prisma.user.findFirst({ const existing = await prisma.user.findFirst({
where: { where: { walletAddress: address },
walletAddress: address,
NOT: { id: req.user!.id },
},
}); });
if (existing) { if (existing && existing.id !== req.user!.id) {
return res return res.status(400).json({ error: "Wallet already in use" });
.status(400)
.json({ error: "Wallet already connected to another account" });
} }
await prisma.user.update({ await prisma.user.update({
@@ -150,14 +123,10 @@ router.post(
data: { walletAddress: address }, data: { walletAddress: address },
}); });
res.json({ res.json({ success: true, address });
address,
message: "Wallet connected successfully",
});
}), }),
); );
// Get recovery phrase (only shown once at creation)
router.post( router.post(
"/backup", "/backup",
authenticate, authenticate,
@@ -167,20 +136,14 @@ router.post(
}); });
if (!user?.encryptedPrivateKey) { if (!user?.encryptedPrivateKey) {
return res.status(400).json({ error: "No wallet found" }); return res.status(400).json({ error: "No wallet backup available" });
} }
// Decrypt and return private key for backup
const privateKey = decrypt(user.encryptedPrivateKey); const privateKey = decrypt(user.encryptedPrivateKey);
res.json({ success: true, privateKey });
res.json({
privateKey,
warning: "Store this securely. Never share it with anyone.",
});
}), }),
); );
// Admin: Get user's wallet
router.get( router.get(
"/admin/:userId", "/admin/:userId",
authenticate, authenticate,
@@ -191,9 +154,14 @@ router.get(
select: { select: {
id: true, id: true,
plexUsername: true, plexUsername: true,
email: true,
isAdmin: true,
isActive: true,
walletAddress: true, walletAddress: true,
totalEarned: true, totalEarned: true,
totalSpent: true, totalSpent: true,
watchTimeMinutes: true,
createdAt: true,
}, },
}); });
@@ -201,13 +169,55 @@ router.get(
return res.status(404).json({ error: "User not found" }); return res.status(404).json({ error: "User not found" });
} }
const dbBalance = user.totalEarned - user.totalSpent;
res.json({ res.json({
...user, ...user,
balance: dbBalance, balance: user.totalEarned - user.totalSpent,
}); });
}), }),
); );
router.post(
"/admin/:userId/adjust",
authenticate,
requireAdmin,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const { amount, reason } = req.body;
if (!amount || Number.isNaN(Number(amount)) || Number(amount) === 0) {
return res.status(400).json({ error: "Valid amount required" });
}
const user = await prisma.user.findUnique({
where: { id: req.params.userId },
});
if (!user) {
return res.status(404).json({ error: "User not found" });
}
const delta = Number(amount);
const type = delta > 0 ? "BONUS" : "ADJUSTMENT";
await prisma.transaction.create({
data: {
userId: user.id,
type,
amount: Math.abs(delta),
description: reason || "Admin adjustment",
contentTitle: reason || "Admin adjustment",
},
});
await prisma.user.update({
where: { id: user.id },
data: {
totalEarned: delta > 0 ? { increment: delta } : undefined,
totalSpent: delta < 0 ? { increment: Math.abs(delta) } : undefined,
},
});
res.json({ success: true });
}),
);
export { router as walletRouter }; export { router as walletRouter };
+11 -78
View File
@@ -2,32 +2,26 @@ import crypto from "crypto";
import { Router } from "express"; import { Router } from "express";
import { io } from "../index"; import { io } from "../index";
import { asyncHandler } from "../middleware/errorHandler"; import { asyncHandler } from "../middleware/errorHandler";
import { mintTokens } from "../services/solana";
import { prisma } from "../utils/prisma"; import { prisma } from "../utils/prisma";
const router = Router(); const router = Router();
const WEBHOOK_SECRET = process.env.TAUTULLI_WEBHOOK_SECRET || ""; const WEBHOOK_SECRET = process.env.TAUTULLI_WEBHOOK_SECRET || "";
// Verify webhook signature
function verifyWebhookSignature(payload: string, signature: string): boolean { function verifyWebhookSignature(payload: string, signature: string): boolean {
if (!WEBHOOK_SECRET) return true; // Skip verification if no secret set if (!WEBHOOK_SECRET) return true;
const expected = crypto const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET) .createHmac("sha256", WEBHOOK_SECRET)
.update(payload) .update(payload)
.digest("hex"); .digest("hex");
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
} }
// Tautulli webhook endpoint
router.post( router.post(
"/tautulli", "/tautulli",
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const webhookSignature = req.headers["x-tautulli-signature"] as string; const webhookSignature = req.headers["x-tautulli-signature"] as string;
const payload = JSON.stringify(req.body); const payload = JSON.stringify(req.body);
// Verify signature if configured
if ( if (
WEBHOOK_SECRET && WEBHOOK_SECRET &&
webhookSignature && webhookSignature &&
@@ -37,87 +31,55 @@ router.post(
} }
const event = req.body; const event = req.body;
if (event.action !== "watched")
// Only process watched events
if (event.action !== "watched") {
return res.json({ message: "Event type not processed" }); return res.json({ message: "Event type not processed" });
} if (!event.user_id || !event.rating_key || !event.session_key)
// Validate required fields
if (!event.user_id || !event.rating_key || !event.session_key) {
return res.status(400).json({ error: "Missing required fields" }); return res.status(400).json({ error: "Missing required fields" });
}
// Find user by Plex ID
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { plexId: event.user_id.toString() }, where: { plexId: event.user_id.toString() },
}); });
if (!user) return res.status(404).json({ error: "User not found" });
if (!user) { if (!user.walletAddress)
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" }); return res.status(400).json({ error: "User has no wallet" });
}
// Check for duplicate events
const existing = await prisma.watchEvent.findUnique({ const existing = await prisma.watchEvent.findUnique({
where: { sessionId: event.session_key.toString() }, where: { sessionId: event.session_key.toString() },
}); });
if (existing) return res.json({ message: "Event already processed" });
if (existing) {
return res.json({ message: "Event already processed" });
}
// Get system settings
const settings = await prisma.systemSettings.findFirst(); const settings = await prisma.systemSettings.findFirst();
const creditsPerMinute = settings?.creditsPerMinute || 2; const creditsPerMinute = settings?.creditsPerMinute || 2;
const minWatchPercent = settings?.minWatchPercent || 80; const minWatchPercent = settings?.minWatchPercent || 80;
const minWatchMinutes = settings?.minWatchMinutes || 5; const minWatchMinutes = settings?.minWatchMinutes || 5;
// Calculate watch duration
const watchDurationMinutes = Math.floor( const watchDurationMinutes = Math.floor(
(event.stopped - event.started) / 60, (event.stopped - event.started) / 60,
); );
const percentComplete = event.percent_complete || 0; const percentComplete = event.percent_complete || 0;
if (percentComplete < minWatchPercent)
// Validate minimum requirements
if (percentComplete < minWatchPercent) {
return res.json({ return res.json({
message: "Watch percentage too low", message: "Watch percentage too low",
percentComplete, percentComplete,
required: minWatchPercent, required: minWatchPercent,
}); });
} if (watchDurationMinutes < minWatchMinutes)
if (watchDurationMinutes < minWatchMinutes) {
return res.json({ return res.json({
message: "Watch duration too short", message: "Watch duration too short",
watchDurationMinutes, watchDurationMinutes,
required: minWatchMinutes, required: minWatchMinutes,
}); });
}
// Calculate credits
let creditsEarned = watchDurationMinutes * creditsPerMinute; let creditsEarned = watchDurationMinutes * creditsPerMinute;
if (settings?.newReleaseMultiplier && event.is_new)
// Apply multipliers
if (settings?.newReleaseMultiplier && event.is_new) {
creditsEarned = Math.floor( creditsEarned = Math.floor(
creditsEarned * Number(settings.newReleaseMultiplier), creditsEarned * Number(settings.newReleaseMultiplier),
); );
} if (settings?.bonusMultiplierActive)
if (settings?.bonusMultiplierActive) {
creditsEarned = Math.floor( creditsEarned = Math.floor(
creditsEarned * Number(settings.bonusMultiplier), creditsEarned * Number(settings.bonusMultiplier),
); );
}
// Create watch event record
const watchEvent = await prisma.watchEvent.create({ const watchEvent = await prisma.watchEvent.create({
data: { data: {
userId: user.id, userId: user.id,
@@ -133,28 +95,17 @@ router.post(
}, },
}); });
// 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({ const transaction = await prisma.transaction.create({
data: { data: {
userId: user.id, userId: user.id,
type: "EARN", type: "EARN",
amount: creditsEarned, amount: creditsEarned,
watchEventId: watchEvent.id, watchEventId: watchEvent.id,
solanaSignature: signature,
description: `Watched ${event.title}`, description: `Watched ${event.title}`,
contentTitle: event.title, contentTitle: event.title,
}, },
}); });
// Update user stats
await prisma.user.update({ await prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: {
@@ -163,13 +114,11 @@ router.post(
}, },
}); });
// Mark watch event as processed
await prisma.watchEvent.update({ await prisma.watchEvent.update({
where: { id: watchEvent.id }, where: { id: watchEvent.id },
data: { isProcessed: true }, data: { isProcessed: true },
}); });
// Emit real-time update via WebSocket
io.to(`user:${user.id}`).emit("credits_earned", { io.to(`user:${user.id}`).emit("credits_earned", {
amount: creditsEarned, amount: creditsEarned,
title: event.title, title: event.title,
@@ -185,23 +134,7 @@ router.post(
res.json({ res.json({
success: true, success: true,
creditsEarned, creditsEarned,
solanaSignature: signature, message: `Credited ${creditsEarned} COOP for watching ${event.title}`,
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,
}); });
}), }),
); );
+2
View File
@@ -87,6 +87,8 @@ export const adminApi = {
updateUser: (id: string, data: any) => api.put(`/admin/users/${id}`, data), updateUser: (id: string, data: any) => api.put(`/admin/users/${id}`, data),
grantBonus: (userId: string, amount: number, reason?: string) => grantBonus: (userId: string, amount: number, reason?: string) =>
api.post(`/admin/users/${userId}/bonus`, { amount, reason }), api.post(`/admin/users/${userId}/bonus`, { amount, reason }),
adjustUser: (userId: string, amount: number, reason?: string) =>
api.post(`/wallet/admin/${userId}/adjust`, { amount, reason }),
getAnalytics: () => api.get("/admin/analytics"), getAnalytics: () => api.get("/admin/analytics"),
getAllTransactions: (page = 1, limit = 50) => getAllTransactions: (page = 1, limit = 50) =>
api.get(`/transactions/admin/all?page=${page}&limit=${limit}`), api.get(`/transactions/admin/all?page=${page}&limit=${limit}`),