fix(balance): use database credits instead of on-chain balance
Balance checks were reading on-chain Solana token balance (0 during db-only mode). Now wallet and overseer routes return user.totalEarned - user.totalSpent as the usable credit balance. This makes credits spendable even when devnet faucet is dry. - /wallet returns dbBalance, with onChainBalance as extra info - /overseer/balance returns dbBalance for request eligibility - /overseer/request checks dbBalance before approving spend - /wallet/admin returns dbBalance
This commit is contained in:
+177
-173
@@ -1,217 +1,221 @@
|
||||
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';
|
||||
import axios from "axios";
|
||||
import { Router } from "express";
|
||||
import { io } from "../index";
|
||||
import { type AuthenticatedRequest, authenticate } from "../middleware/auth";
|
||||
import { asyncHandler } from "../middleware/errorHandler";
|
||||
import { prisma } from "../utils/prisma";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const OVERSEER_URL = process.env.OVERSEER_URL || '';
|
||||
const OVERSEER_API_KEY = process.env.OVERSEER_API_KEY || '';
|
||||
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
|
||||
}
|
||||
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
|
||||
});
|
||||
})
|
||||
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 }
|
||||
});
|
||||
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
|
||||
});
|
||||
}
|
||||
if (!user?.walletAddress) {
|
||||
return res.json({
|
||||
hasWallet: false,
|
||||
balance: 0,
|
||||
canRequest: false,
|
||||
});
|
||||
}
|
||||
|
||||
const balance = await getTokenBalance(user.walletAddress);
|
||||
const settings = await prisma.systemSettings.findFirst();
|
||||
const settings = await prisma.systemSettings.findFirst();
|
||||
const dbBalance = user.totalEarned - user.totalSpent;
|
||||
|
||||
res.json({
|
||||
hasWallet: true,
|
||||
balance,
|
||||
canRequestMovie: balance >= (settings?.movieRequestCost || 100),
|
||||
canRequestTV: balance >= (settings?.tvRequestCost || 200),
|
||||
costs: {
|
||||
movie: settings?.movieRequestCost || 100,
|
||||
tv: settings?.tvRequestCost || 200
|
||||
}
|
||||
});
|
||||
})
|
||||
res.json({
|
||||
hasWallet: true,
|
||||
balance: dbBalance,
|
||||
canRequestMovie: dbBalance >= (settings?.movieRequestCost || 100),
|
||||
canRequestTV: dbBalance >= (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' });
|
||||
}
|
||||
router.get(
|
||||
"/search",
|
||||
authenticate,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const { query } = req.query;
|
||||
|
||||
const response = await overseerClient.get('/search', {
|
||||
params: { query }
|
||||
});
|
||||
if (!query) {
|
||||
return res.status(400).json({ error: "Query required" });
|
||||
}
|
||||
|
||||
res.json(response.data);
|
||||
})
|
||||
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;
|
||||
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' });
|
||||
}
|
||||
if (!mediaType || !mediaId || !title) {
|
||||
return res.status(400).json({ error: "Missing required fields" });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user!.id }
|
||||
});
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user!.id },
|
||||
});
|
||||
|
||||
if (!user?.walletAddress) {
|
||||
return res.status(400).json({ error: 'Wallet required' });
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
const settings = await prisma.systemSettings.findFirst();
|
||||
|
||||
// Check balance
|
||||
const balance = await getTokenBalance(user.walletAddress);
|
||||
if (balance < cost) {
|
||||
return res.status(400).json({
|
||||
error: 'Insufficient balance',
|
||||
required: cost,
|
||||
current: balance
|
||||
});
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Create request in Overseer
|
||||
const overseerRequest = await overseerClient.post('/request', {
|
||||
mediaType,
|
||||
mediaId,
|
||||
...(seasons && { seasons })
|
||||
});
|
||||
// Check balance
|
||||
const dbBalance = user.totalEarned - user.totalSpent;
|
||||
if (dbBalance < cost) {
|
||||
return res.status(400).json({
|
||||
error: "Insufficient balance",
|
||||
required: cost,
|
||||
current: dbBalance,
|
||||
});
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
});
|
||||
// Create request in Overseer
|
||||
const overseerRequest = await overseerClient.post("/request", {
|
||||
mediaType,
|
||||
mediaId,
|
||||
...(seasons && { seasons }),
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
request,
|
||||
cost,
|
||||
message: 'Request submitted. Credits will be deducted when approved.'
|
||||
});
|
||||
})
|
||||
// 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;
|
||||
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' });
|
||||
}
|
||||
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 }
|
||||
});
|
||||
// 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' });
|
||||
}
|
||||
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 }
|
||||
});
|
||||
// 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
|
||||
}
|
||||
});
|
||||
// If approved, deduct credits
|
||||
if (status === "APPROVED" && !request.user.totalSpent) {
|
||||
// Note: Actual burning would happen here
|
||||
// For now, we just record the transaction
|
||||
|
||||
// Update user stats
|
||||
await prisma.user.update({
|
||||
where: { id: request.userId },
|
||||
data: {
|
||||
totalSpent: { increment: request.creditsCost }
|
||||
}
|
||||
});
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
// Emit update
|
||||
io.to(`user:${request.userId}`).emit('credits_spent', {
|
||||
amount: request.creditsCost,
|
||||
title: request.title,
|
||||
transaction
|
||||
});
|
||||
}
|
||||
// Update user stats
|
||||
await prisma.user.update({
|
||||
where: { id: request.userId },
|
||||
data: {
|
||||
totalSpent: { increment: request.creditsCost },
|
||||
},
|
||||
});
|
||||
|
||||
res.json({ success: true, request: updatedRequest });
|
||||
})
|
||||
// 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 };
|
||||
|
||||
@@ -65,13 +65,16 @@ router.get(
|
||||
});
|
||||
}
|
||||
|
||||
// Get on-chain balance
|
||||
const balance = await getTokenBalance(user.walletAddress);
|
||||
// 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,
|
||||
balance: dbBalance,
|
||||
onChainBalance,
|
||||
totalEarned: user.totalEarned,
|
||||
totalSpent: user.totalSpent,
|
||||
explorerUrl: `https://explorer.solana.com/address/${user.walletAddress}?cluster=devnet`,
|
||||
@@ -198,14 +201,11 @@ router.get(
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
let balance = 0;
|
||||
if (user.walletAddress) {
|
||||
balance = await getTokenBalance(user.walletAddress);
|
||||
}
|
||||
const dbBalance = user.totalEarned - user.totalSpent;
|
||||
|
||||
res.json({
|
||||
...user,
|
||||
balance,
|
||||
balance: dbBalance,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user