diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 40367c9..1b757b6 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -1,12 +1,13 @@ import { Router } from "express"; -import { io } from "../index"; import { - type AuthenticatedRequest, authenticate, + AuthenticatedRequest, requireAdmin, } from "../middleware/auth"; -import { asyncHandler } from "../middleware/errorHandler"; import { prisma } from "../utils/prisma"; +import { asyncHandler } from "../middleware/errorHandler"; +import { io } from "../index"; +import { backfillUserHistory } from "../services/tautulli"; const router = Router(); @@ -20,6 +21,7 @@ router.get( res.json(settings); }), ); + router.put( "/settings", authenticate, @@ -43,30 +45,7 @@ router.put( res.json(settings); }), ); -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: "Paused" }); - }), -); -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: "Resumed" }); - }), -); + router.get( "/users", authenticate, @@ -114,6 +93,7 @@ router.get( }); }), ); + router.put( "/users/:id", authenticate, @@ -127,6 +107,7 @@ router.put( res.json(user); }), ); + router.post( "/users/:id/bonus", authenticate, @@ -158,6 +139,7 @@ router.post( res.json({ success: true, amount, transaction }); }), ); + router.post( "/users/:id/adjust", authenticate, @@ -188,6 +170,22 @@ router.post( res.json({ success: true }); }), ); + +router.post( + "/users/:id/backfill", + authenticate, + requireAdmin, + asyncHandler(async (req: AuthenticatedRequest, res) => { + const user = await prisma.user.findUnique({ where: { id: req.params.id } }); + if (!user) return res.status(404).json({ error: "User not found" }); + await backfillUserHistory(user); + const updatedUser = await prisma.user.findUnique({ + where: { id: user.id }, + }); + res.json({ success: true, user: updatedUser }); + }), +); + router.get( "/analytics", authenticate, diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 5e5f570..2b12223 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,24 +1,9 @@ import axios from "axios"; -import crypto from "crypto"; import { Router } from "express"; import jwt from "jsonwebtoken"; import { asyncHandler } from "../middleware/errorHandler"; import { prisma } from "../utils/prisma"; - -const ENCRYPTION_KEY = - process.env.ENCRYPTION_KEY || "default-key-32-chars-long!!!!!"; -const TAUTULLI_URL = process.env.TAUTULLI_URL || ""; -const TAUTULLI_API_KEY = process.env.TAUTULLI_API_KEY || ""; - -function encrypt(text: string): string { - const iv = crypto.randomBytes(16); - const key = crypto.createHash("sha256").update(ENCRYPTION_KEY).digest(); - const cipher = crypto.createCipheriv("aes-256-gcm", 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}`; -} +import { backfillUserHistory } from "../services/tautulli"; const router = Router(); @@ -183,106 +168,4 @@ router.post( }), ); -async function backfillUserHistory(user: { - id: string; - plexId: string; - plexUsername: string; -}) { - if (!TAUTULLI_URL || !TAUTULLI_API_KEY) return; - try { - const usersResponse = await axios.get(`${TAUTULLI_URL}/api/v2`, { - params: { apikey: TAUTULLI_API_KEY, cmd: "get_users" }, - }); - const tautulliUsers = usersResponse.data?.response?.data || []; - const tautulliUser = tautulliUsers.find( - (u: any) => - u.username?.toLowerCase() === user.plexUsername.toLowerCase() || - u.email?.toLowerCase() === user.plexUsername.toLowerCase() || - u.friendly_name?.toLowerCase() === user.plexUsername.toLowerCase(), - ); - if (!tautulliUser) return; - - const startDate = (() => { - const d = new Date(); - d.setDate(d.getDate() - 30); - return d.toISOString().split("T")[0]; - })(); - const historyResponse = await axios.get(`${TAUTULLI_URL}/api/v2`, { - params: { - apikey: TAUTULLI_API_KEY, - cmd: "get_history", - user_id: tautulliUser.user_id, - start_date: startDate, - length: 1000, - }, - }); - const historyData = historyResponse.data?.response?.data?.data || []; - if (!Array.isArray(historyData) || historyData.length === 0) return; - - const settings = await prisma.systemSettings.findFirst(); - const creditsPerMinute = settings?.creditsPerMinute || 2; - const minWatchPercent = settings?.minWatchPercent || 80; - const minWatchMinutes = settings?.minWatchMinutes || 5; - - let totalCredits = 0; - let totalMinutes = 0; - for (const item of historyData) { - const sessionId = item.reference_id?.toString() || item.id?.toString(); - if (!sessionId) continue; - if (await prisma.watchEvent.findUnique({ where: { sessionId } })) - continue; - const percentComplete = item.percent_complete || 0; - const watchDurationMinutes = Math.floor( - (item.stopped - item.started) / 60, - ); - if ( - percentComplete < minWatchPercent || - watchDurationMinutes < minWatchMinutes - ) - continue; - const creditsEarned = watchDurationMinutes * creditsPerMinute; - const watchEvent = await prisma.watchEvent.create({ - data: { - userId: user.id, - sessionId, - ratingKey: item.rating_key?.toString() || "", - contentType: item.media_type || "movie", - title: item.title || item.full_title || "Unknown", - grandparentTitle: item.grandparent_title || null, - duration: item.stopped - item.started, - percentComplete: Math.floor(percentComplete), - creditsEarned, - isProcessed: true, - watchedAt: new Date(item.stopped * 1000), - }, - }); - await prisma.transaction.create({ - data: { - userId: user.id, - type: "EARN", - amount: creditsEarned, - watchEventId: watchEvent.id, - description: `Watched ${item.title || item.full_title || "Unknown"}`, - contentTitle: item.title || item.full_title || "Unknown", - }, - }); - totalCredits += creditsEarned; - totalMinutes += watchDurationMinutes; - } - if (totalCredits > 0) - await prisma.user.update({ - where: { id: user.id }, - data: { - totalEarned: { increment: totalCredits }, - watchTimeMinutes: { increment: totalMinutes }, - }, - }); - } catch (error) { - console.error( - `Failed to backfill history for ${user.plexUsername}:`, - error, - ); - } -} - export { router as authRouter }; diff --git a/backend/src/services/tautulli.ts b/backend/src/services/tautulli.ts new file mode 100644 index 0000000..0cb5c28 --- /dev/null +++ b/backend/src/services/tautulli.ts @@ -0,0 +1,107 @@ +import axios from "axios"; +import { prisma } from "../utils/prisma"; + +const TAUTULLI_URL = process.env.TAUTULLI_URL || ""; +const TAUTULLI_API_KEY = process.env.TAUTULLI_API_KEY || ""; + +export async function backfillUserHistory(user: { + id: string; + plexId: string; + plexUsername: string; +}) { + if (!TAUTULLI_URL || !TAUTULLI_API_KEY) return; + try { + const usersResponse = await axios.get(`${TAUTULLI_URL}/api/v2`, { + params: { apikey: TAUTULLI_API_KEY, cmd: "get_users" }, + }); + const tautulliUsers = usersResponse.data?.response?.data || []; + const tautulliUser = tautulliUsers.find( + (u: any) => + u.username?.toLowerCase() === user.plexUsername.toLowerCase() || + u.email?.toLowerCase() === user.plexUsername.toLowerCase() || + u.friendly_name?.toLowerCase() === user.plexUsername.toLowerCase(), + ); + if (!tautulliUser) return; + + const startDate = (() => { + const d = new Date(); + d.setDate(d.getDate() - 30); + return d.toISOString().split("T")[0]; + })(); + const historyResponse = await axios.get(`${TAUTULLI_URL}/api/v2`, { + params: { + apikey: TAUTULLI_API_KEY, + cmd: "get_history", + user_id: tautulliUser.user_id, + start_date: startDate, + length: 1000, + }, + }); + const historyData = historyResponse.data?.response?.data?.data || []; + if (!Array.isArray(historyData) || historyData.length === 0) return; + + const settings = await prisma.systemSettings.findFirst(); + const creditsPerMinute = settings?.creditsPerMinute || 2; + const minWatchPercent = settings?.minWatchPercent || 80; + const minWatchMinutes = settings?.minWatchMinutes || 5; + + let totalCredits = 0; + let totalMinutes = 0; + for (const item of historyData) { + const sessionId = item.reference_id?.toString() || item.id?.toString(); + if (!sessionId) continue; + if (await prisma.watchEvent.findUnique({ where: { sessionId } })) + continue; + const percentComplete = item.percent_complete || 0; + const watchDurationMinutes = Math.floor( + (item.stopped - item.started) / 60, + ); + if ( + percentComplete < minWatchPercent || + watchDurationMinutes < minWatchMinutes + ) + continue; + const creditsEarned = watchDurationMinutes * creditsPerMinute; + const watchEvent = await prisma.watchEvent.create({ + data: { + userId: user.id, + sessionId, + ratingKey: item.rating_key?.toString() || "", + contentType: item.media_type || "movie", + title: item.title || item.full_title || "Unknown", + grandparentTitle: item.grandparent_title || null, + duration: item.stopped - item.started, + percentComplete: Math.floor(percentComplete), + creditsEarned, + isProcessed: true, + watchedAt: new Date(item.stopped * 1000), + }, + }); + await prisma.transaction.create({ + data: { + userId: user.id, + type: "EARN", + amount: creditsEarned, + watchEventId: watchEvent.id, + description: `Watched ${item.title || item.full_title || "Unknown"}`, + contentTitle: item.title || item.full_title || "Unknown", + }, + }); + totalCredits += creditsEarned; + totalMinutes += watchDurationMinutes; + } + if (totalCredits > 0) + await prisma.user.update({ + where: { id: user.id }, + data: { + totalEarned: { increment: totalCredits }, + watchTimeMinutes: { increment: totalMinutes }, + }, + }); + } catch (error) { + console.error( + `Failed to backfill history for ${user.plexUsername}:`, + error, + ); + } +}