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 axios from 'axios';
|
import { Router } from "express";
|
||||||
import { authenticate, AuthenticatedRequest } from '../middleware/auth';
|
import { io } from "../index";
|
||||||
import { prisma } from '../utils/prisma';
|
import { type AuthenticatedRequest, authenticate } from "../middleware/auth";
|
||||||
import { asyncHandler } from '../middleware/errorHandler';
|
import { asyncHandler } from "../middleware/errorHandler";
|
||||||
import { getTokenBalance } from '../services/solana';
|
import { prisma } from "../utils/prisma";
|
||||||
import { io } from '../index';
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
const OVERSEER_URL = process.env.OVERSEER_URL || '';
|
const OVERSEER_URL = process.env.OVERSEER_URL || "";
|
||||||
const OVERSEER_API_KEY = process.env.OVERSEER_API_KEY || '';
|
const OVERSEER_API_KEY = process.env.OVERSEER_API_KEY || "";
|
||||||
|
|
||||||
const overseerClient = axios.create({
|
const overseerClient = axios.create({
|
||||||
baseURL: `${OVERSEER_URL}/api/v1`,
|
baseURL: `${OVERSEER_URL}/api/v1`,
|
||||||
headers: {
|
headers: {
|
||||||
'X-Api-Key': OVERSEER_API_KEY
|
"X-Api-Key": OVERSEER_API_KEY,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get request costs
|
// Get request costs
|
||||||
router.get('/costs',
|
router.get(
|
||||||
authenticate,
|
"/costs",
|
||||||
asyncHandler(async (_req: AuthenticatedRequest, res) => {
|
authenticate,
|
||||||
const settings = await prisma.systemSettings.findFirst();
|
asyncHandler(async (_req: AuthenticatedRequest, res) => {
|
||||||
|
const settings = await prisma.systemSettings.findFirst();
|
||||||
res.json({
|
|
||||||
movie: settings?.movieRequestCost || 100,
|
res.json({
|
||||||
tv: settings?.tvRequestCost || 200,
|
movie: settings?.movieRequestCost || 100,
|
||||||
tvPerSeason: settings?.tvPerSeasonCost || 50
|
tv: settings?.tvRequestCost || 200,
|
||||||
});
|
tvPerSeason: settings?.tvPerSeasonCost || 50,
|
||||||
})
|
});
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get user's balance and request availability
|
// Get user's balance and request availability
|
||||||
router.get('/balance',
|
router.get(
|
||||||
authenticate,
|
"/balance",
|
||||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
authenticate,
|
||||||
const user = await prisma.user.findUnique({
|
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||||
where: { id: req.user!.id }
|
const user = await prisma.user.findUnique({
|
||||||
});
|
where: { id: req.user!.id },
|
||||||
|
});
|
||||||
|
|
||||||
if (!user?.walletAddress) {
|
if (!user?.walletAddress) {
|
||||||
return res.json({
|
return res.json({
|
||||||
hasWallet: false,
|
hasWallet: false,
|
||||||
balance: 0,
|
balance: 0,
|
||||||
canRequest: false
|
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({
|
res.json({
|
||||||
hasWallet: true,
|
hasWallet: true,
|
||||||
balance,
|
balance: dbBalance,
|
||||||
canRequestMovie: balance >= (settings?.movieRequestCost || 100),
|
canRequestMovie: dbBalance >= (settings?.movieRequestCost || 100),
|
||||||
canRequestTV: balance >= (settings?.tvRequestCost || 200),
|
canRequestTV: dbBalance >= (settings?.tvRequestCost || 200),
|
||||||
costs: {
|
costs: {
|
||||||
movie: settings?.movieRequestCost || 100,
|
movie: settings?.movieRequestCost || 100,
|
||||||
tv: settings?.tvRequestCost || 200
|
tv: settings?.tvRequestCost || 200,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Search for content
|
// Search for content
|
||||||
router.get('/search',
|
router.get(
|
||||||
authenticate,
|
"/search",
|
||||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
authenticate,
|
||||||
const { query } = req.query;
|
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', {
|
if (!query) {
|
||||||
params: { 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
|
// Request content
|
||||||
router.post('/request',
|
router.post(
|
||||||
authenticate,
|
"/request",
|
||||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
authenticate,
|
||||||
const { mediaType, mediaId, title, seasons } = req.body;
|
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||||
|
const { mediaType, mediaId, title, seasons } = req.body;
|
||||||
|
|
||||||
if (!mediaType || !mediaId || !title) {
|
if (!mediaType || !mediaId || !title) {
|
||||||
return res.status(400).json({ error: 'Missing required fields' });
|
return res.status(400).json({ error: "Missing required fields" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: req.user!.id }
|
where: { id: req.user!.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user?.walletAddress) {
|
if (!user?.walletAddress) {
|
||||||
return res.status(400).json({ error: 'Wallet required' });
|
return res.status(400).json({ error: "Wallet required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = await prisma.systemSettings.findFirst();
|
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
|
// Calculate cost
|
||||||
const balance = await getTokenBalance(user.walletAddress);
|
let cost = 0;
|
||||||
if (balance < cost) {
|
if (mediaType === "movie") {
|
||||||
return res.status(400).json({
|
cost = settings?.movieRequestCost || 100;
|
||||||
error: 'Insufficient balance',
|
} else if (mediaType === "tv") {
|
||||||
required: cost,
|
cost = settings?.tvRequestCost || 200;
|
||||||
current: balance
|
if (seasons && seasons.length > 1) {
|
||||||
});
|
cost += (seasons.length - 1) * (settings?.tvPerSeasonCost || 50);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create request in Overseer
|
// Check balance
|
||||||
const overseerRequest = await overseerClient.post('/request', {
|
const dbBalance = user.totalEarned - user.totalSpent;
|
||||||
mediaType,
|
if (dbBalance < cost) {
|
||||||
mediaId,
|
return res.status(400).json({
|
||||||
...(seasons && { seasons })
|
error: "Insufficient balance",
|
||||||
});
|
required: cost,
|
||||||
|
current: dbBalance,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Create local request record
|
// Create request in Overseer
|
||||||
const request = await prisma.contentRequest.create({
|
const overseerRequest = await overseerClient.post("/request", {
|
||||||
data: {
|
mediaType,
|
||||||
userId: user.id,
|
mediaId,
|
||||||
overseerRequestId: overseerRequest.data.id,
|
...(seasons && { seasons }),
|
||||||
mediaType,
|
});
|
||||||
tmdbId: mediaId,
|
|
||||||
title,
|
|
||||||
creditsCost: cost,
|
|
||||||
status: 'PENDING',
|
|
||||||
requestedAt: new Date()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
// Create local request record
|
||||||
success: true,
|
const request = await prisma.contentRequest.create({
|
||||||
request,
|
data: {
|
||||||
cost,
|
userId: user.id,
|
||||||
message: 'Request submitted. Credits will be deducted when approved.'
|
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
|
// Webhook: Handle Overseer request status changes
|
||||||
router.post('/webhook',
|
router.post(
|
||||||
asyncHandler(async (req, res) => {
|
"/webhook",
|
||||||
const { request_id, status } = req.body;
|
asyncHandler(async (req, res) => {
|
||||||
|
const { request_id, status } = req.body;
|
||||||
|
|
||||||
if (!request_id || !status) {
|
if (!request_id || !status) {
|
||||||
return res.status(400).json({ error: 'Missing fields' });
|
return res.status(400).json({ error: "Missing fields" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find local request
|
// Find local request
|
||||||
const request = await prisma.contentRequest.findFirst({
|
const request = await prisma.contentRequest.findFirst({
|
||||||
where: { overseerRequestId: request_id },
|
where: { overseerRequestId: request_id },
|
||||||
include: { user: true }
|
include: { user: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!request) {
|
if (!request) {
|
||||||
return res.status(404).json({ error: 'Request not found' });
|
return res.status(404).json({ error: "Request not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update status
|
// Update status
|
||||||
const updatedRequest = await prisma.contentRequest.update({
|
const updatedRequest = await prisma.contentRequest.update({
|
||||||
where: { id: request.id },
|
where: { id: request.id },
|
||||||
data: { status }
|
data: { status },
|
||||||
});
|
});
|
||||||
|
|
||||||
// If approved, deduct credits
|
// If approved, deduct credits
|
||||||
if (status === 'APPROVED' && !request.user.totalSpent) {
|
if (status === "APPROVED" && !request.user.totalSpent) {
|
||||||
// Note: Actual burning would happen here
|
// Note: Actual burning would happen here
|
||||||
// For now, we just record the transaction
|
// 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
|
const transaction = await prisma.transaction.create({
|
||||||
await prisma.user.update({
|
data: {
|
||||||
where: { id: request.userId },
|
userId: request.userId,
|
||||||
data: {
|
type: "SPEND",
|
||||||
totalSpent: { increment: request.creditsCost }
|
amount: request.creditsCost,
|
||||||
}
|
requestId: request.id,
|
||||||
});
|
description: `Request: ${request.title}`,
|
||||||
|
contentTitle: request.title,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Emit update
|
// Update user stats
|
||||||
io.to(`user:${request.userId}`).emit('credits_spent', {
|
await prisma.user.update({
|
||||||
amount: request.creditsCost,
|
where: { id: request.userId },
|
||||||
title: request.title,
|
data: {
|
||||||
transaction
|
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 };
|
export { router as overseerRouter };
|
||||||
|
|||||||
@@ -65,13 +65,16 @@ router.get(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get on-chain balance
|
// Get on-chain balance (may be 0 if using DB-only credits)
|
||||||
const balance = await getTokenBalance(user.walletAddress);
|
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({
|
res.json({
|
||||||
hasWallet: true,
|
hasWallet: true,
|
||||||
address: user.walletAddress,
|
address: user.walletAddress,
|
||||||
balance,
|
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`,
|
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" });
|
return res.status(404).json({ error: "User not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
let balance = 0;
|
const dbBalance = user.totalEarned - user.totalSpent;
|
||||||
if (user.walletAddress) {
|
|
||||||
balance = await getTokenBalance(user.walletAddress);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
...user,
|
...user,
|
||||||
balance,
|
balance: dbBalance,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user