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:
2026-04-14 11:09:50 -04:00
parent f106328f6a
commit e8d9b1fd42
69 changed files with 11382 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import rateLimit from 'express-rate-limit';
import { createServer } from 'http';
import { Server } from 'socket.io';
import dotenv from 'dotenv';
dotenv.config();
import { prisma } from './utils/prisma';
import { errorHandler } from './middleware/errorHandler';
import { authRouter } from './routes/auth';
import { userRouter } from './routes/users';
import { walletRouter } from './routes/wallet';
import { transactionsRouter } from './routes/transactions';
import { adminRouter } from './routes/admin';
import { tautulliRouter } from './routes/tautulli';
import { overseerRouter } from './routes/overseer';
import { webhookRouter } from './routes/webhooks';
import { setupSocketHandlers } from './services/socket';
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {
origin: process.env.FRONTEND_URL || 'http://localhost:3000',
credentials: true
}
});
// Security middleware
app.use(helmet());
app.use(cors({
origin: process.env.FRONTEND_URL || 'http://localhost:3000',
credentials: true
}));
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100 // limit each IP to 100 requests per windowMs
});
app.use(limiter);
// Body parsing
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// Logging
app.use(morgan('combined'));
// Health check
app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// API routes
app.use('/api/auth', authRouter);
app.use('/api/users', userRouter);
app.use('/api/wallet', walletRouter);
app.use('/api/transactions', transactionsRouter);
app.use('/api/admin', adminRouter);
app.use('/api/tautulli', tautulliRouter);
app.use('/api/overseer', overseerRouter);
app.use('/webhooks', webhookRouter);
// Error handling
app.use(errorHandler);
// Setup WebSocket handlers
setupSocketHandlers(io);
// Start server
const PORT = process.env.PORT || 3001;
async function startServer() {
try {
// Connect to database
await prisma.$connect();
console.log('✅ Connected to database');
// Ensure default settings exist
const settings = await prisma.systemSettings.findFirst();
if (!settings) {
await prisma.systemSettings.create({
data: {
id: 'default'
}
});
console.log('✅ Created default system settings');
}
httpServer.listen(PORT, () => {
console.log(`🚀 Server running on port ${PORT}`);
console.log(`📊 API URL: ${process.env.API_URL || `http://localhost:${PORT}`}`);
});
} catch (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
}
// Graceful shutdown
process.on('SIGTERM', async () => {
console.log('SIGTERM received, shutting down gracefully');
await prisma.$disconnect();
httpServer.close(() => {
console.log('Server closed');
process.exit(0);
});
});
process.on('SIGINT', async () => {
console.log('SIGINT received, shutting down gracefully');
await prisma.$disconnect();
httpServer.close(() => {
console.log('Server closed');
process.exit(0);
});
});
startServer();
export { io };
+61
View File
@@ -0,0 +1,61 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { prisma } from '../utils/prisma';
const JWT_SECRET = process.env.JWT_SECRET || 'secret';
export interface AuthenticatedRequest extends Request {
user?: {
id: string;
plexId: string;
isAdmin: boolean;
};
}
export const authenticate = async (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required' });
}
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 },
select: { id: true, plexId: true, isAdmin: true, isActive: true }
});
if (!user || !user.isActive) {
return res.status(401).json({ error: 'User not found or inactive' });
}
req.user = {
id: user.id,
plexId: user.plexId,
isAdmin: user.isAdmin
};
next();
} catch (error) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
};
export const requireAdmin = (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
) => {
if (!req.user?.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
next();
};
+32
View File
@@ -0,0 +1,32 @@
import { Request, Response, NextFunction } from 'express';
export interface ApiError extends Error {
statusCode?: number;
code?: string;
}
export const errorHandler = (
err: ApiError,
_req: Request,
res: Response,
_next: NextFunction
) => {
console.error('Error:', err);
const statusCode = err.statusCode || 500;
const message = err.message || 'Internal Server Error';
res.status(statusCode).json({
error: {
message,
code: err.code || 'INTERNAL_ERROR',
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
}
});
};
export const asyncHandler = (fn: Function) => {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
+308
View File
@@ -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 };
+186
View File
@@ -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 };
+217
View File
@@ -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 };
+93
View File
@@ -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 };
+210
View File
@@ -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 };
+135
View File
@@ -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 };
+193
View File
@@ -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 };
+204
View File
@@ -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 };
+87
View File
@@ -0,0 +1,87 @@
import { Server, Socket } from 'socket.io';
import jwt from 'jsonwebtoken';
import { prisma } from '../utils/prisma';
const JWT_SECRET = process.env.JWT_SECRET || 'secret';
interface AuthenticatedSocket extends Socket {
userId?: string;
isAdmin?: boolean;
}
export function setupSocketHandlers(io: Server) {
// Authentication middleware
io.use(async (socket: AuthenticatedSocket, next) => {
try {
const token = socket.handshake.auth.token;
if (!token) {
return next(new Error('Authentication required'));
}
const decoded = jwt.verify(token, JWT_SECRET) as any;
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { id: true, isAdmin: true, isActive: true }
});
if (!user || !user.isActive) {
return next(new Error('User not found or inactive'));
}
socket.userId = user.id;
socket.isAdmin = user.isAdmin;
next();
} catch (error) {
next(new Error('Invalid token'));
}
});
io.on('connection', (socket: AuthenticatedSocket) => {
console.log(`Client connected: ${socket.userId}`);
// Join user-specific room
if (socket.userId) {
socket.join(`user:${socket.userId}`);
}
// Join admin room if admin
if (socket.isAdmin) {
socket.join('admins');
}
// Handle subscription to transaction updates
socket.on('subscribe_transactions', () => {
if (socket.userId) {
socket.join(`transactions:${socket.userId}`);
console.log(`User ${socket.userId} subscribed to transactions`);
}
});
// Handle unsubscription
socket.on('unsubscribe_transactions', () => {
if (socket.userId) {
socket.leave(`transactions:${socket.userId}`);
}
});
// Handle disconnect
socket.on('disconnect', () => {
console.log(`Client disconnected: ${socket.userId}`);
});
});
}
// Helper to emit to specific user
export function emitToUser(userId: string, event: string, data: any) {
const { io } = require('../index');
io.to(`user:${userId}`).emit(event, data);
}
// Helper to emit to all admins
export function emitToAdmins(event: string, data: any) {
const { io } = require('../index');
io.to('admins').emit(event, data);
}
+220
View File
@@ -0,0 +1,220 @@
import {
Connection,
PublicKey,
Keypair,
Transaction,
SystemProgram,
sendAndConfirmTransaction
} from '@solana/web3.js';
import {
getOrCreateAssociatedTokenAccount,
createMintToInstruction,
createBurnInstruction,
getAccount,
TOKEN_PROGRAM_ID,
ASSOCIATED_TOKEN_PROGRAM_ID
} from '@solana/spl-token';
import bs58 from 'bs58';
import { prisma } from '../utils/prisma';
const RPC_URL = process.env.SOLANA_RPC_URL || 'https://api.devnet.solana.com';
const PROGRAM_ID = new PublicKey(process.env.SOLANA_PROGRAM_ID || 'CoopCredits111111111111111111111111111111111');
const DECIMALS = parseInt(process.env.SOLANA_TOKEN_DECIMALS || '6');
// Backend mint authority keypair (stored securely)
let mintAuthority: Keypair | null = null;
try {
if (process.env.SOLANA_MINT_AUTHORITY_KEYPAIR) {
const secretKey = bs58.decode(process.env.SOLANA_MINT_AUTHORITY_KEYPAIR);
mintAuthority = Keypair.fromSecretKey(secretKey);
}
} catch (error) {
console.warn('Mint authority not configured');
}
export const connection = new Connection(RPC_URL, 'confirmed');
// Get token mint address from program state
export async function getTokenMint(): Promise<PublicKey | null> {
try {
// In a real implementation, you'd derive this from the program state
// For now, return from environment or stored config
const config = await prisma.systemSettings.findFirst();
if (config) {
// Store/retrieve mint address from config
return null; // Placeholder
}
return null;
} catch (error) {
console.error('Failed to get token mint:', error);
return null;
}
}
// Create a new Solana wallet for a user
export function createWallet(): { publicKey: string; secretKey: string } {
const keypair = Keypair.generate();
return {
publicKey: keypair.publicKey.toBase58(),
secretKey: bs58.encode(keypair.secretKey)
};
}
// Get or create token account for user
export async function getOrCreateTokenAccount(
userPublicKey: PublicKey,
mint: PublicKey
): Promise<PublicKey> {
const tokenAccount = await getOrCreateAssociatedTokenAccount(
connection,
mintAuthority!, // payer
mint,
userPublicKey
);
return tokenAccount.address;
}
// Mint tokens to user (called by backend after watch event)
export async function mintTokens(
userWalletAddress: string,
amount: number,
metadata: {
sessionId: string;
contentTitle: string;
watchDurationMinutes: number;
}
): Promise<string | null> {
if (!mintAuthority) {
throw new Error('Mint authority not configured');
}
try {
const userPublicKey = new PublicKey(userWalletAddress);
const mint = await getTokenMint();
if (!mint) {
throw new Error('Token mint not found');
}
// Get or create user's token account
const tokenAccount = await getOrCreateTokenAccount(userPublicKey, mint);
// Calculate amount with decimals
const amountWithDecimals = amount * Math.pow(10, DECIMALS);
// Create mint instruction
const mintInstruction = createMintToInstruction(
mint,
tokenAccount,
mintAuthority.publicKey,
BigInt(Math.floor(amountWithDecimals))
);
// Create and send transaction
const transaction = new Transaction().add(mintInstruction);
const signature = await sendAndConfirmTransaction(
connection,
transaction,
[mintAuthority]
);
console.log(`Minted ${amount} COOP to ${userWalletAddress}: ${signature}`);
return signature;
} catch (error) {
console.error('Failed to mint tokens:', error);
return null;
}
}
// Burn tokens from user (called when content request is approved)
export async function burnTokens(
userWalletAddress: string,
userSecretKey: string,
amount: number
): Promise<string | null> {
try {
const userKeypair = Keypair.fromSecretKey(bs58.decode(userSecretKey));
const mint = await getTokenMint();
if (!mint) {
throw new Error('Token mint not found');
}
const userPublicKey = new PublicKey(userWalletAddress);
const tokenAccount = await getOrCreateTokenAccount(userPublicKey, mint);
// Check balance
const accountInfo = await getAccount(connection, tokenAccount);
const amountWithDecimals = BigInt(Math.floor(amount * Math.pow(10, DECIMALS)));
if (accountInfo.amount < amountWithDecimals) {
throw new Error('Insufficient balance');
}
// Create burn instruction
const burnInstruction = createBurnInstruction(
tokenAccount,
mint,
userKeypair.publicKey,
amountWithDecimals
);
const transaction = new Transaction().add(burnInstruction);
const signature = await sendAndConfirmTransaction(
connection,
transaction,
[userKeypair]
);
console.log(`Burned ${amount} COOP from ${userWalletAddress}: ${signature}`);
return signature;
} catch (error) {
console.error('Failed to burn tokens:', error);
return null;
}
}
// Get token balance for user
export async function getTokenBalance(walletAddress: string): Promise<number> {
try {
const mint = await getTokenMint();
if (!mint) return 0;
const userPublicKey = new PublicKey(walletAddress);
try {
const tokenAccount = await getOrCreateAssociatedTokenAccount(
connection,
mintAuthority!,
mint,
userPublicKey
);
const accountInfo = await getAccount(connection, tokenAccount.address);
return Number(accountInfo.amount) / Math.pow(10, DECIMALS);
} catch {
return 0;
}
} catch (error) {
console.error('Failed to get token balance:', error);
return 0;
}
}
// Request airdrop for testing (devnet only)
export async function requestAirdrop(walletAddress: string): Promise<string | null> {
try {
const publicKey = new PublicKey(walletAddress);
const signature = await connection.requestAirdrop(publicKey, 2 * 1000000000); // 2 SOL
await connection.confirmTransaction(signature);
return signature;
} catch (error) {
console.error('Airdrop failed:', error);
return null;
}
}
+11
View File
@@ -0,0 +1,11 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;