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:
@@ -0,0 +1,38 @@
|
||||
# Database
|
||||
DATABASE_URL="postgresql://coop:coop_password@localhost:5432/coop_credits?schema=public"
|
||||
|
||||
# Server
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
API_URL=http://localhost:3001
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# Plex OAuth
|
||||
PLEX_CLIENT_ID=your-plex-client-id
|
||||
PLEX_CLIENT_SECRET=your-plex-client-secret
|
||||
PLEX_REDIRECT_URI=http://localhost:3000/auth/callback
|
||||
|
||||
# Tautulli
|
||||
TAUTULLI_URL=http://localhost:8181
|
||||
TAUTULLI_API_KEY=your-tautulli-api-key
|
||||
TAUTULLI_WEBHOOK_SECRET=your-webhook-secret
|
||||
|
||||
# Overseer
|
||||
OVERSEER_URL=http://localhost:5055
|
||||
OVERSEER_API_KEY=your-overseer-api-key
|
||||
|
||||
# Solana
|
||||
SOLANA_RPC_URL=https://api.devnet.solana.com
|
||||
SOLANA_PROGRAM_ID=CoopCredits111111111111111111111111111111111
|
||||
SOLANA_MINT_AUTHORITY_KEYPAIR=your-base58-encoded-keypair-for-minting
|
||||
SOLANA_TOKEN_DECIMALS=6
|
||||
|
||||
# Redis (optional - for caching and pub/sub)
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Encryption (for wallet private keys)
|
||||
ENCRYPTION_KEY=your-32-char-encryption-key!!
|
||||
@@ -0,0 +1,45 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
COPY prisma ./prisma/
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Generate Prisma client
|
||||
RUN npx prisma generate
|
||||
|
||||
# Build TypeScript
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install OpenSSL for Prisma
|
||||
RUN apk add --no-cache openssl
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install production dependencies only
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Copy built files from builder
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3001
|
||||
|
||||
# Start application
|
||||
CMD ["npm", "start"]
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "coop-credits-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "CoopCredits API Server",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:seed": "tsx src/seed.ts",
|
||||
"db:studio": "prisma studio",
|
||||
"test": "vitest",
|
||||
"lint": "eslint src --ext .ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.7.0",
|
||||
"@solana/spl-token": "^0.3.9",
|
||||
"@solana/web3.js": "^1.87.6",
|
||||
"axios": "^1.6.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"express-validator": "^7.0.1",
|
||||
"helmet": "^7.1.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"morgan": "^1.10.0",
|
||||
"socket.io": "^4.7.3",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"ws": "^8.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/ws": "^8.5.10",
|
||||
"prisma": "^5.7.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^1.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
plexId String @unique @map("plex_id")
|
||||
plexUsername String @map("plex_username")
|
||||
email String?
|
||||
isAdmin Boolean @default(false) @map("is_admin")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
|
||||
// Solana wallet
|
||||
walletAddress String? @unique @map("wallet_address")
|
||||
encryptedPrivateKey String? @map("encrypted_private_key")
|
||||
|
||||
// Stats
|
||||
totalEarned Int @default(0) @map("total_earned")
|
||||
totalSpent Int @default(0) @map("total_spent")
|
||||
watchTimeMinutes Int @default(0) @map("watch_time_minutes")
|
||||
|
||||
// Relations
|
||||
transactions Transaction[]
|
||||
watchEvents WatchEvent[]
|
||||
contentRequests ContentRequest[]
|
||||
sessions Session[]
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([plexId])
|
||||
@@index([walletAddress])
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Transaction {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
type TransactionType
|
||||
amount Int
|
||||
|
||||
// For earned transactions
|
||||
watchEventId String? @map("watch_event_id")
|
||||
watchEvent WatchEvent? @relation(fields: [watchEventId], references: [id])
|
||||
|
||||
// For spent transactions
|
||||
requestId String? @map("request_id")
|
||||
request ContentRequest? @relation(fields: [requestId], references: [id])
|
||||
|
||||
// Solana transaction reference
|
||||
solanaSignature String? @map("solana_signature")
|
||||
|
||||
// Metadata
|
||||
description String?
|
||||
contentTitle String? @map("content_title")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([userId])
|
||||
@@index([type])
|
||||
@@index([createdAt])
|
||||
@@map("transactions")
|
||||
}
|
||||
|
||||
model WatchEvent {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
// Tautulli data
|
||||
sessionId String @map("session_id")
|
||||
ratingKey String @map("rating_key")
|
||||
contentType String @map("content_type") // movie, episode
|
||||
title String
|
||||
grandparentTitle String? @map("grandparent_title") // Show name for episodes
|
||||
|
||||
// Watch stats
|
||||
duration Int // seconds watched
|
||||
percentComplete Int @map("percent_complete")
|
||||
|
||||
// Credits earned
|
||||
creditsEarned Int @map("credits_earned")
|
||||
isProcessed Boolean @default(false) @map("is_processed")
|
||||
|
||||
// Relations
|
||||
transactions Transaction[]
|
||||
|
||||
watchedAt DateTime @map("watched_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@unique([sessionId])
|
||||
@@index([userId])
|
||||
@@index([watchedAt])
|
||||
@@map("watch_events")
|
||||
}
|
||||
|
||||
model ContentRequest {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
// Overseer data
|
||||
overseerRequestId Int @map("overseer_request_id")
|
||||
|
||||
// Content info
|
||||
mediaType String @map("media_type") // movie, tv
|
||||
tmdbId Int @map("tmdb_id")
|
||||
title String
|
||||
|
||||
// Cost
|
||||
creditsCost Int @map("credits_cost")
|
||||
|
||||
// Status
|
||||
status RequestStatus @default(PENDING)
|
||||
|
||||
// Relations
|
||||
transaction Transaction?
|
||||
|
||||
requestedAt DateTime @map("requested_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
@@map("content_requests")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
token String @unique
|
||||
expiresAt DateTime @map("expires_at")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([userId])
|
||||
@@index([token])
|
||||
@@map("sessions")
|
||||
}
|
||||
|
||||
model SystemSettings {
|
||||
id String @id @default(cuid())
|
||||
|
||||
// Minting settings
|
||||
creditsPerMinute Int @default(10) @map("credits_per_minute")
|
||||
minWatchPercent Int @default(80) @map("min_watch_percent")
|
||||
minWatchMinutes Int @default(5) @map("min_watch_minutes")
|
||||
|
||||
// Spending settings
|
||||
movieRequestCost Int @default(100) @map("movie_request_cost")
|
||||
tvRequestCost Int @default(200) @map("tv_request_cost")
|
||||
tvPerSeasonCost Int @default(50) @map("tv_per_season_cost")
|
||||
|
||||
// Multipliers
|
||||
newReleaseMultiplier Decimal @default(1.5) @map("new_release_multiplier") @db.Decimal(3, 2)
|
||||
bonusMultiplierActive Boolean @default(false) @map("bonus_multiplier_active")
|
||||
bonusMultiplier Decimal @default(2.0) @map("bonus_multiplier") @db.Decimal(3, 2)
|
||||
|
||||
// System state
|
||||
mintingPaused Boolean @default(false) @map("minting_paused")
|
||||
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
updatedBy String? @map("updated_by")
|
||||
|
||||
@@map("system_settings")
|
||||
}
|
||||
|
||||
enum TransactionType {
|
||||
EARN
|
||||
SPEND
|
||||
BONUS
|
||||
ADJUSTMENT
|
||||
}
|
||||
|
||||
enum RequestStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
DECLINED
|
||||
COMPLETED
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"strictNullChecks": true,
|
||||
"strictPropertyInitialization": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user