diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 631d414..25d39c2 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -152,14 +152,14 @@ model SystemSettings { id String @id @default(cuid()) // Minting settings - creditsPerMinute Int @default(10) @map("credits_per_minute") + creditsPerMinute Int @default(2) @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") + movieRequestCost Int @default(500) @map("movie_request_cost") + tvRequestCost Int @default(1000) @map("tv_request_cost") + tvPerSeasonCost Int @default(250) @map("tv_per_season_cost") // Multipliers newReleaseMultiplier Decimal @default(1.5) @map("new_release_multiplier") @db.Decimal(3, 2) diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 32efd28..621d6dc 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -320,7 +320,7 @@ async function backfillUserHistory(user: { // Get system settings for credit calculation const settings = await prisma.systemSettings.findFirst(); - const creditsPerMinute = settings?.creditsPerMinute || 10; + const creditsPerMinute = settings?.creditsPerMinute || 2; const minWatchPercent = settings?.minWatchPercent || 80; const minWatchMinutes = settings?.minWatchMinutes || 5; diff --git a/backend/src/routes/overseer.ts b/backend/src/routes/overseer.ts index 4fe5b57..9613a52 100644 --- a/backend/src/routes/overseer.ts +++ b/backend/src/routes/overseer.ts @@ -25,9 +25,9 @@ router.get( const settings = await prisma.systemSettings.findFirst(); res.json({ - movie: settings?.movieRequestCost || 100, - tv: settings?.tvRequestCost || 200, - tvPerSeason: settings?.tvPerSeasonCost || 50, + movie: settings?.movieRequestCost || 500, + tv: settings?.tvRequestCost || 1000, + tvPerSeason: settings?.tvPerSeasonCost || 250, }); }), ); @@ -55,11 +55,11 @@ router.get( res.json({ hasWallet: true, balance: dbBalance, - canRequestMovie: dbBalance >= (settings?.movieRequestCost || 100), - canRequestTV: dbBalance >= (settings?.tvRequestCost || 200), + canRequestMovie: dbBalance >= (settings?.movieRequestCost || 500), + canRequestTV: dbBalance >= (settings?.tvRequestCost || 1000), costs: { - movie: settings?.movieRequestCost || 100, - tv: settings?.tvRequestCost || 200, + movie: settings?.movieRequestCost || 500, + tv: settings?.tvRequestCost || 1000, }, }); }), @@ -108,11 +108,11 @@ router.post( // Calculate cost let cost = 0; if (mediaType === "movie") { - cost = settings?.movieRequestCost || 100; + cost = settings?.movieRequestCost || 500; } else if (mediaType === "tv") { - cost = settings?.tvRequestCost || 200; + cost = settings?.tvRequestCost || 1000; if (seasons && seasons.length > 1) { - cost += (seasons.length - 1) * (settings?.tvPerSeasonCost || 50); + cost += (seasons.length - 1) * (settings?.tvPerSeasonCost || 250); } } @@ -126,7 +126,7 @@ router.post( }); } - // Create request in Overseer + // Create request in Overseer first const overseerRequest = await overseerClient.post("/request", { mediaType, mediaId, @@ -147,11 +147,37 @@ router.post( }, }); + // Deduct credits immediately to prevent race condition + const transaction = await prisma.transaction.create({ + data: { + userId: user.id, + type: "SPEND", + amount: cost, + requestId: request.id, + description: `Request: ${title}`, + contentTitle: title, + }, + }); + + await prisma.user.update({ + where: { id: user.id }, + data: { + totalSpent: { increment: cost }, + }, + }); + + // Emit real-time spend update + io.to(`user:${user.id}`).emit("credits_spent", { + amount: cost, + title, + transaction, + }); + res.json({ success: true, request, cost, - message: "Request submitted. Credits will be deducted when approved.", + message: `Request submitted. ${cost} $COOP deducted.`, }); }), ); @@ -182,35 +208,31 @@ router.post( 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({ + // Credits were already deducted at request time. + // On approval: just confirm and link transaction to request. + // On decline: refund credits back to user. + if (status === "DECLINED") { + const refundTransaction = await prisma.transaction.create({ data: { userId: request.userId, - type: "SPEND", + type: "ADJUSTMENT", amount: request.creditsCost, - requestId: request.id, - description: `Request: ${request.title}`, + description: `Refunded: ${request.title}`, contentTitle: request.title, }, }); - // Update user stats await prisma.user.update({ where: { id: request.userId }, data: { - totalSpent: { increment: request.creditsCost }, + totalSpent: { decrement: request.creditsCost }, }, }); - // Emit update - io.to(`user:${request.userId}`).emit("credits_spent", { + io.to(`user:${request.userId}`).emit("bonus_received", { amount: request.creditsCost, - title: request.title, - transaction, + reason: `Refunded: ${request.title}`, + transaction: refundTransaction, }); } diff --git a/backend/src/routes/webhooks.ts b/backend/src/routes/webhooks.ts index 302d324..7220ea4 100644 --- a/backend/src/routes/webhooks.ts +++ b/backend/src/routes/webhooks.ts @@ -1,204 +1,209 @@ -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'; +import crypto from "crypto"; +import { Router } from "express"; +import { io } from "../index"; +import { asyncHandler } from "../middleware/errorHandler"; +import { mintTokens } from "../services/solana"; +import { prisma } from "../utils/prisma"; const router = Router(); -const WEBHOOK_SECRET = process.env.TAUTULLI_WEBHOOK_SECRET || ''; +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) - ); + 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 webhookSignature = req.headers['x-tautulli-signature'] as string; - const payload = JSON.stringify(req.body); - - // Verify signature if configured - if (WEBHOOK_SECRET && webhookSignature && !verifyWebhookSignature(payload, webhookSignature)) { - return res.status(401).json({ error: 'Invalid signature' }); - } +router.post( + "/tautulli", + asyncHandler(async (req, res) => { + const webhookSignature = req.headers["x-tautulli-signature"] as string; + const payload = JSON.stringify(req.body); - const event = req.body; - - // Only process watched events - if (event.action !== 'watched') { - return res.json({ message: 'Event type not processed' }); - } + // Verify signature if configured + if ( + WEBHOOK_SECRET && + webhookSignature && + !verifyWebhookSignature(payload, webhookSignature) + ) { + return res.status(401).json({ error: "Invalid signature" }); + } - // Validate required fields - if (!event.user_id || !event.rating_key || !event.session_key) { - return res.status(400).json({ error: 'Missing required fields' }); - } + const event = req.body; - // Find user by Plex ID - const user = await prisma.user.findUnique({ - where: { plexId: event.user_id.toString() } - }); + // Only process watched events + if (event.action !== "watched") { + return res.json({ message: "Event type not processed" }); + } - if (!user) { - console.log(`User not found for Plex ID: ${event.user_id}`); - return res.status(404).json({ error: 'User not found' }); - } + // Validate required fields + if (!event.user_id || !event.rating_key || !event.session_key) { + return res.status(400).json({ error: "Missing required fields" }); + } - if (!user.walletAddress) { - console.log(`User ${user.plexUsername} has no wallet`); - return res.status(400).json({ error: 'User has no wallet' }); - } + // Find user by Plex ID + const user = await prisma.user.findUnique({ + where: { plexId: event.user_id.toString() }, + }); - // Check for duplicate events - const existing = await prisma.watchEvent.findUnique({ - where: { sessionId: event.session_key.toString() } - }); + if (!user) { + console.log(`User not found for Plex ID: ${event.user_id}`); + return res.status(404).json({ error: "User not found" }); + } - if (existing) { - return res.json({ message: 'Event already processed' }); - } + if (!user.walletAddress) { + console.log(`User ${user.plexUsername} has no wallet`); + return res.status(400).json({ error: "User has no wallet" }); + } - // Get system settings - const settings = await prisma.systemSettings.findFirst(); - const creditsPerMinute = settings?.creditsPerMinute || 10; - const minWatchPercent = settings?.minWatchPercent || 80; - const minWatchMinutes = settings?.minWatchMinutes || 5; + // Check for duplicate events + const existing = await prisma.watchEvent.findUnique({ + where: { sessionId: event.session_key.toString() }, + }); - // Calculate watch duration - const watchDurationMinutes = Math.floor((event.stopped - event.started) / 60); - const percentComplete = event.percent_complete || 0; + if (existing) { + return res.json({ message: "Event already processed" }); + } - // Validate minimum requirements - if (percentComplete < minWatchPercent) { - return res.json({ - message: 'Watch percentage too low', - percentComplete, - required: minWatchPercent - }); - } + // Get system settings + const settings = await prisma.systemSettings.findFirst(); + const creditsPerMinute = settings?.creditsPerMinute || 2; + const minWatchPercent = settings?.minWatchPercent || 80; + const minWatchMinutes = settings?.minWatchMinutes || 5; - if (watchDurationMinutes < minWatchMinutes) { - return res.json({ - message: 'Watch duration too short', - watchDurationMinutes, - required: minWatchMinutes - }); - } + // Calculate watch duration + const watchDurationMinutes = Math.floor( + (event.stopped - event.started) / 60, + ); + const percentComplete = event.percent_complete || 0; - // 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)); - } + // Validate minimum requirements + if (percentComplete < minWatchPercent) { + return res.json({ + message: "Watch percentage too low", + percentComplete, + required: minWatchPercent, + }); + } - // 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) - } - }); + if (watchDurationMinutes < minWatchMinutes) { + return res.json({ + message: "Watch duration too short", + watchDurationMinutes, + required: minWatchMinutes, + }); + } - // Mint tokens on Solana - const signature = await mintTokens( - user.walletAddress, - creditsEarned, - { - sessionId: event.session_key.toString(), - contentTitle: event.title, - watchDurationMinutes - } - ); + // Calculate credits + let creditsEarned = watchDurationMinutes * creditsPerMinute; - 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 - } - }); + // Apply multipliers + if (settings?.newReleaseMultiplier && event.is_new) { + creditsEarned = Math.floor( + creditsEarned * Number(settings.newReleaseMultiplier), + ); + } - // Update user stats - await prisma.user.update({ - where: { id: user.id }, - data: { - totalEarned: { increment: creditsEarned }, - watchTimeMinutes: { increment: watchDurationMinutes } - } - }); + if (settings?.bonusMultiplierActive) { + creditsEarned = Math.floor( + creditsEarned * Number(settings.bonusMultiplier), + ); + } - // Mark watch event as processed - await prisma.watchEvent.update({ - where: { id: watchEvent.id }, - data: { isProcessed: true } - }); + // 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), + }, + }); - // 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 - } - }); + // Mint tokens on Solana + const signature = await mintTokens(user.walletAddress, creditsEarned, { + sessionId: event.session_key.toString(), + contentTitle: event.title, + watchDurationMinutes, + }); - 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' }); - } - }) + 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 - }); - }) +router.post( + "/test", + asyncHandler(async (req, res) => { + res.json({ + message: "Webhook endpoint working", + timestamp: new Date().toISOString(), + body: req.body, + }); + }), ); export { router as webhookRouter };