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:
+209
-287
@@ -1,316 +1,238 @@
|
||||
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';
|
||||
import { io } from '../index';
|
||||
import { Router } from "express";
|
||||
import { io } from "../index";
|
||||
import {
|
||||
type AuthenticatedRequest,
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
} from "../middleware/auth";
|
||||
import { asyncHandler } from "../middleware/errorHandler";
|
||||
import { prisma } from "../utils/prisma";
|
||||
|
||||
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);
|
||||
})
|
||||
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);
|
||||
})
|
||||
router.put(
|
||||
"/settings",
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const settings = await prisma.systemSettings.update({
|
||||
where: { id: "default" },
|
||||
data: {
|
||||
creditsPerMinute: req.body.creditsPerMinute,
|
||||
minWatchPercent: req.body.minWatchPercent,
|
||||
minWatchMinutes: req.body.minWatchMinutes,
|
||||
movieRequestCost: req.body.movieRequestCost,
|
||||
tvRequestCost: req.body.tvRequestCost,
|
||||
tvPerSeasonCost: req.body.tvPerSeasonCost,
|
||||
newReleaseMultiplier: req.body.newReleaseMultiplier,
|
||||
bonusMultiplierActive: req.body.bonusMultiplierActive,
|
||||
bonusMultiplier: req.body.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' });
|
||||
})
|
||||
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' });
|
||||
})
|
||||
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;
|
||||
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 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 })
|
||||
]);
|
||||
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)
|
||||
}
|
||||
});
|
||||
})
|
||||
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);
|
||||
})
|
||||
router.put(
|
||||
"/users/:id",
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data: { isAdmin: req.body.isAdmin, isActive: req.body.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;
|
||||
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" });
|
||||
|
||||
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" });
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.params.id }
|
||||
});
|
||||
const transaction = await prisma.transaction.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: "BONUS",
|
||||
amount,
|
||||
description: reason || "Admin bonus",
|
||||
contentTitle: reason || "Admin bonus",
|
||||
},
|
||||
});
|
||||
|
||||
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 }
|
||||
}
|
||||
});
|
||||
|
||||
// Emit real-time update via WebSocket
|
||||
io.to(`user:${user.id}`).emit('bonus_received', {
|
||||
amount,
|
||||
reason: reason || 'Admin Bonus',
|
||||
transaction
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
amount,
|
||||
solanaSignature: signature,
|
||||
transaction
|
||||
});
|
||||
})
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { totalEarned: { increment: amount } },
|
||||
});
|
||||
io.to(`user:${user.id}`).emit("bonus_received", {
|
||||
amount,
|
||||
reason: reason || "Admin bonus",
|
||||
transaction,
|
||||
});
|
||||
res.json({ success: true, amount, transaction });
|
||||
}),
|
||||
);
|
||||
|
||||
// Get dashboard analytics
|
||||
router.get('/analytics',
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
asyncHandler(async (_req: AuthenticatedRequest, res) => {
|
||||
const sevenDaysAgo = new Date();
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
router.post(
|
||||
"/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 thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
const user = await prisma.user.findUnique({ where: { id: req.params.id } });
|
||||
if (!user) return res.status(404).json({ error: "User not found" });
|
||||
|
||||
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
|
||||
`
|
||||
]);
|
||||
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",
|
||||
},
|
||||
});
|
||||
|
||||
res.json({
|
||||
users: userStats[0],
|
||||
transactions: transactionStats[0],
|
||||
watchStats: watchStats[0],
|
||||
dailyActivity
|
||||
});
|
||||
})
|
||||
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,
|
||||
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([
|
||||
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`,
|
||||
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`,
|
||||
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`,
|
||||
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 };
|
||||
|
||||
+14
-24
@@ -3,7 +3,7 @@ import crypto from "crypto";
|
||||
import { Router } from "express";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { asyncHandler } from "../middleware/errorHandler";
|
||||
import { createWallet, mintTokens } from "../services/solana";
|
||||
import { createWallet } from "../services/solana";
|
||||
import { prisma } from "../utils/prisma";
|
||||
|
||||
const ENCRYPTION_KEY =
|
||||
@@ -374,31 +374,21 @@ async function backfillUserHistory(user: {
|
||||
},
|
||||
});
|
||||
|
||||
// Mint tokens on Solana
|
||||
const signature = await mintTokens(user.walletAddress!, creditsEarned, {
|
||||
sessionId,
|
||||
contentTitle: item.title || item.full_title || "Unknown",
|
||||
watchDurationMinutes,
|
||||
// DB-only credit record
|
||||
await prisma.transaction.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: "EARN",
|
||||
amount: creditsEarned,
|
||||
watchEventId: watchEvent.id,
|
||||
description: `Watched ${item.title || item.full_title || "Unknown"}`,
|
||||
contentTitle: item.title || item.full_title || "Unknown",
|
||||
},
|
||||
});
|
||||
|
||||
if (signature) {
|
||||
// Create transaction record
|
||||
await prisma.transaction.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: "EARN",
|
||||
amount: creditsEarned,
|
||||
watchEventId: watchEvent.id,
|
||||
solanaSignature: signature,
|
||||
description: `Watched ${item.title || item.full_title || "Unknown"}`,
|
||||
contentTitle: item.title || item.full_title || "Unknown",
|
||||
},
|
||||
});
|
||||
|
||||
totalCredits += creditsEarned;
|
||||
totalMinutes += watchDurationMinutes;
|
||||
processedCount++;
|
||||
}
|
||||
totalCredits += creditsEarned;
|
||||
totalMinutes += watchDurationMinutes;
|
||||
processedCount++;
|
||||
}
|
||||
|
||||
// Update user stats
|
||||
|
||||
@@ -6,18 +6,13 @@ import {
|
||||
requireAdmin,
|
||||
} from "../middleware/auth";
|
||||
import { asyncHandler } from "../middleware/errorHandler";
|
||||
import {
|
||||
createWallet,
|
||||
getTokenBalance,
|
||||
requestAirdrop,
|
||||
} from "../services/solana";
|
||||
import { createWallet } from "../services/solana";
|
||||
import { prisma } from "../utils/prisma";
|
||||
|
||||
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 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}`;
|
||||
}
|
||||
|
||||
// Decrypt private key
|
||||
function decrypt(encryptedData: string): string {
|
||||
const parts = encryptedData.split(":");
|
||||
const iv = Buffer.from(parts[0], "hex");
|
||||
@@ -42,7 +36,6 @@ function decrypt(encryptedData: string): string {
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
// Get user's wallet info
|
||||
router.get(
|
||||
"/",
|
||||
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;
|
||||
|
||||
res.json({
|
||||
hasWallet: true,
|
||||
address: user.walletAddress,
|
||||
balance: dbBalance,
|
||||
onChainBalance,
|
||||
totalEarned: user.totalEarned,
|
||||
totalSpent: user.totalSpent,
|
||||
explorerUrl: `https://explorer.solana.com/address/${user.walletAddress}?cluster=devnet`,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Create new wallet
|
||||
router.post(
|
||||
"/create",
|
||||
authenticate,
|
||||
@@ -95,7 +82,6 @@ router.post(
|
||||
return res.status(400).json({ error: "Wallet already exists" });
|
||||
}
|
||||
|
||||
// Create new Solana wallet
|
||||
const wallet = createWallet();
|
||||
const encryptedKey = encrypt(wallet.secretKey);
|
||||
|
||||
@@ -107,20 +93,13 @@ router.post(
|
||||
},
|
||||
});
|
||||
|
||||
// Request airdrop for testing
|
||||
await requestAirdrop(wallet.publicKey);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
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,
|
||||
@@ -128,21 +107,15 @@ router.post(
|
||||
const { address } = req.body;
|
||||
|
||||
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({
|
||||
where: {
|
||||
walletAddress: address,
|
||||
NOT: { id: req.user!.id },
|
||||
},
|
||||
where: { walletAddress: address },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Wallet already connected to another account" });
|
||||
if (existing && existing.id !== req.user!.id) {
|
||||
return res.status(400).json({ error: "Wallet already in use" });
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
@@ -150,14 +123,10 @@ router.post(
|
||||
data: { walletAddress: address },
|
||||
});
|
||||
|
||||
res.json({
|
||||
address,
|
||||
message: "Wallet connected successfully",
|
||||
});
|
||||
res.json({ success: true, address });
|
||||
}),
|
||||
);
|
||||
|
||||
// Get recovery phrase (only shown once at creation)
|
||||
router.post(
|
||||
"/backup",
|
||||
authenticate,
|
||||
@@ -167,20 +136,14 @@ router.post(
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
res.json({
|
||||
privateKey,
|
||||
warning: "Store this securely. Never share it with anyone.",
|
||||
});
|
||||
res.json({ success: true, privateKey });
|
||||
}),
|
||||
);
|
||||
|
||||
// Admin: Get user's wallet
|
||||
router.get(
|
||||
"/admin/:userId",
|
||||
authenticate,
|
||||
@@ -191,9 +154,14 @@ router.get(
|
||||
select: {
|
||||
id: true,
|
||||
plexUsername: true,
|
||||
email: true,
|
||||
isAdmin: true,
|
||||
isActive: true,
|
||||
walletAddress: true,
|
||||
totalEarned: true,
|
||||
totalSpent: true,
|
||||
watchTimeMinutes: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -201,13 +169,55 @@ router.get(
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
const dbBalance = user.totalEarned - user.totalSpent;
|
||||
|
||||
res.json({
|
||||
...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 };
|
||||
|
||||
+43
-110
@@ -2,32 +2,26 @@ import crypto from "crypto";
|
||||
import { Router } from "express";
|
||||
import { io } from "../index";
|
||||
import { asyncHandler } from "../middleware/errorHandler";
|
||||
import { mintTokens } from "../services/solana";
|
||||
import { prisma } from "../utils/prisma";
|
||||
|
||||
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
|
||||
|
||||
if (!WEBHOOK_SECRET) return true;
|
||||
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 webhookSignature = req.headers["x-tautulli-signature"] as string;
|
||||
const payload = JSON.stringify(req.body);
|
||||
|
||||
// Verify signature if configured
|
||||
if (
|
||||
WEBHOOK_SECRET &&
|
||||
webhookSignature &&
|
||||
@@ -37,87 +31,55 @@ router.post(
|
||||
}
|
||||
|
||||
const event = req.body;
|
||||
|
||||
// Only process watched events
|
||||
if (event.action !== "watched") {
|
||||
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) {
|
||||
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`);
|
||||
if (!user) return res.status(404).json({ error: "User not found" });
|
||||
if (!user.walletAddress)
|
||||
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" });
|
||||
|
||||
if (existing) {
|
||||
return res.json({ message: "Event already processed" });
|
||||
}
|
||||
|
||||
// Get system settings
|
||||
const settings = await prisma.systemSettings.findFirst();
|
||||
const creditsPerMinute = settings?.creditsPerMinute || 2;
|
||||
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) {
|
||||
if (percentComplete < minWatchPercent)
|
||||
return res.json({
|
||||
message: "Watch percentage too low",
|
||||
percentComplete,
|
||||
required: minWatchPercent,
|
||||
});
|
||||
}
|
||||
|
||||
if (watchDurationMinutes < minWatchMinutes) {
|
||||
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) {
|
||||
if (settings?.newReleaseMultiplier && event.is_new)
|
||||
creditsEarned = Math.floor(
|
||||
creditsEarned * Number(settings.newReleaseMultiplier),
|
||||
);
|
||||
}
|
||||
|
||||
if (settings?.bonusMultiplierActive) {
|
||||
if (settings?.bonusMultiplierActive)
|
||||
creditsEarned = Math.floor(
|
||||
creditsEarned * Number(settings.bonusMultiplier),
|
||||
);
|
||||
}
|
||||
|
||||
// Create watch event record
|
||||
const watchEvent = await prisma.watchEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
@@ -133,75 +95,46 @@ router.post(
|
||||
},
|
||||
});
|
||||
|
||||
// Mint tokens on Solana
|
||||
const signature = await mintTokens(user.walletAddress, creditsEarned, {
|
||||
sessionId: event.session_key.toString(),
|
||||
contentTitle: event.title,
|
||||
watchDurationMinutes,
|
||||
const transaction = await prisma.transaction.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: "EARN",
|
||||
amount: creditsEarned,
|
||||
watchEventId: watchEvent.id,
|
||||
description: `Watched ${event.title}`,
|
||||
contentTitle: event.title,
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
totalEarned: { increment: creditsEarned },
|
||||
watchTimeMinutes: { increment: watchDurationMinutes },
|
||||
},
|
||||
});
|
||||
|
||||
// Update user stats
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
totalEarned: { increment: creditsEarned },
|
||||
watchTimeMinutes: { increment: watchDurationMinutes },
|
||||
},
|
||||
});
|
||||
await prisma.watchEvent.update({
|
||||
where: { id: watchEvent.id },
|
||||
data: { isProcessed: true },
|
||||
});
|
||||
|
||||
// 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", {
|
||||
io.to(`user:${user.id}`).emit("credits_earned", {
|
||||
amount: creditsEarned,
|
||||
title: event.title,
|
||||
transaction: {
|
||||
id: transaction.id,
|
||||
type: "EARN",
|
||||
amount: creditsEarned,
|
||||
title: event.title,
|
||||
transaction: {
|
||||
id: transaction.id,
|
||||
type: "EARN",
|
||||
amount: creditsEarned,
|
||||
contentTitle: event.title,
|
||||
createdAt: transaction.createdAt,
|
||||
},
|
||||
});
|
||||
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,
|
||||
success: true,
|
||||
creditsEarned,
|
||||
message: `Credited ${creditsEarned} COOP for watching ${event.title}`,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -87,6 +87,8 @@ export const adminApi = {
|
||||
updateUser: (id: string, data: any) => api.put(`/admin/users/${id}`, data),
|
||||
grantBonus: (userId: string, amount: number, reason?: string) =>
|
||||
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"),
|
||||
getAllTransactions: (page = 1, limit = 50) =>
|
||||
api.get(`/transactions/admin/all?page=${page}&limit=${limit}`),
|
||||
|
||||
Reference in New Issue
Block a user