chore: remove solana wallet stack
This commit is contained in:
@@ -9,33 +9,25 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
plexId String @unique @map("plex_id")
|
plexId String @unique @map("plex_id")
|
||||||
plexUsername String @map("plex_username")
|
plexUsername String @map("plex_username")
|
||||||
email String?
|
email String?
|
||||||
isAdmin Boolean @default(false) @map("is_admin")
|
isAdmin Boolean @default(false) @map("is_admin")
|
||||||
isActive Boolean @default(true) @map("is_active")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
totalEarned Int @default(0) @map("total_earned")
|
||||||
// Solana wallet
|
totalSpent Int @default(0) @map("total_spent")
|
||||||
walletAddress String? @unique @map("wallet_address")
|
watchTimeMinutes Int @default(0) @map("watch_time_minutes")
|
||||||
encryptedPrivateKey String? @map("encrypted_private_key")
|
|
||||||
|
transactions Transaction[]
|
||||||
// Stats
|
watchEvents WatchEvent[]
|
||||||
totalEarned Int @default(0) @map("total_earned")
|
contentRequests ContentRequest[]
|
||||||
totalSpent Int @default(0) @map("total_spent")
|
sessions Session[]
|
||||||
watchTimeMinutes Int @default(0) @map("watch_time_minutes")
|
|
||||||
|
|
||||||
// Relations
|
|
||||||
transactions Transaction[]
|
|
||||||
watchEvents WatchEvent[]
|
|
||||||
contentRequests ContentRequest[]
|
|
||||||
sessions Session[]
|
|
||||||
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
@@index([plexId])
|
@@index([plexId])
|
||||||
@@index([walletAddress])
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,29 +35,16 @@ model Transaction {
|
|||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
type TransactionType
|
type TransactionType
|
||||||
amount Int
|
amount Int
|
||||||
|
|
||||||
// For earned transactions
|
|
||||||
watchEventId String? @map("watch_event_id")
|
watchEventId String? @map("watch_event_id")
|
||||||
watchEvent WatchEvent? @relation(fields: [watchEventId], references: [id])
|
watchEvent WatchEvent? @relation(fields: [watchEventId], references: [id])
|
||||||
|
|
||||||
// For spent transactions
|
|
||||||
requestId String? @unique @map("request_id")
|
requestId String? @unique @map("request_id")
|
||||||
request ContentRequest? @relation(fields: [requestId], references: [id])
|
request ContentRequest? @relation(fields: [requestId], references: [id])
|
||||||
|
|
||||||
// Solana transaction reference
|
|
||||||
solanaSignature String? @map("solana_signature")
|
|
||||||
|
|
||||||
// Metadata
|
|
||||||
description String?
|
description String?
|
||||||
contentTitle String? @map("content_title")
|
contentTitle String? @map("content_title")
|
||||||
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([type])
|
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@map("transactions")
|
@@map("transactions")
|
||||||
}
|
}
|
||||||
@@ -74,28 +53,18 @@ model WatchEvent {
|
|||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
// Tautulli data
|
|
||||||
sessionId String @map("session_id")
|
sessionId String @map("session_id")
|
||||||
ratingKey String @map("rating_key")
|
ratingKey String @map("rating_key")
|
||||||
contentType String @map("content_type") // movie, episode
|
contentType String @map("content_type")
|
||||||
title String
|
title String
|
||||||
grandparentTitle String? @map("grandparent_title") // Show name for episodes
|
grandparentTitle String? @map("grandparent_title")
|
||||||
|
duration Int
|
||||||
// Watch stats
|
|
||||||
duration Int // seconds watched
|
|
||||||
percentComplete Int @map("percent_complete")
|
percentComplete Int @map("percent_complete")
|
||||||
|
|
||||||
// Credits earned
|
|
||||||
creditsEarned Int @map("credits_earned")
|
creditsEarned Int @map("credits_earned")
|
||||||
isProcessed Boolean @default(false) @map("is_processed")
|
isProcessed Boolean @default(false) @map("is_processed")
|
||||||
|
|
||||||
// Relations
|
|
||||||
transactions Transaction[]
|
transactions Transaction[]
|
||||||
|
|
||||||
watchedAt DateTime @map("watched_at")
|
watchedAt DateTime @map("watched_at")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
@@unique([sessionId])
|
@@unique([sessionId])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([watchedAt])
|
@@index([watchedAt])
|
||||||
@@ -106,30 +75,17 @@ model ContentRequest {
|
|||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
// Overseer data
|
|
||||||
overseerRequestId Int @map("overseer_request_id")
|
overseerRequestId Int @map("overseer_request_id")
|
||||||
|
mediaType String @map("media_type")
|
||||||
// Content info
|
|
||||||
mediaType String @map("media_type") // movie, tv
|
|
||||||
tmdbId Int @map("tmdb_id")
|
tmdbId Int @map("tmdb_id")
|
||||||
title String
|
title String
|
||||||
|
|
||||||
// Cost
|
|
||||||
creditsCost Int @map("credits_cost")
|
creditsCost Int @map("credits_cost")
|
||||||
|
|
||||||
// Status
|
|
||||||
status RequestStatus @default(PENDING)
|
status RequestStatus @default(PENDING)
|
||||||
|
|
||||||
// Relations
|
|
||||||
transaction Transaction?
|
transaction Transaction?
|
||||||
|
|
||||||
requestedAt DateTime @map("requested_at")
|
requestedAt DateTime @map("requested_at")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([status])
|
|
||||||
@@map("content_requests")
|
@@map("content_requests")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,12 +93,9 @@ model Session {
|
|||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
token String @unique
|
token String @unique
|
||||||
expiresAt DateTime @map("expires_at")
|
expiresAt DateTime @map("expires_at")
|
||||||
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([token])
|
@@index([token])
|
||||||
@@map("sessions")
|
@@map("sessions")
|
||||||
@@ -150,28 +103,18 @@ model Session {
|
|||||||
|
|
||||||
model SystemSettings {
|
model SystemSettings {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|
||||||
// Minting settings
|
|
||||||
creditsPerMinute Int @default(2) @map("credits_per_minute")
|
creditsPerMinute Int @default(2) @map("credits_per_minute")
|
||||||
minWatchPercent Int @default(80) @map("min_watch_percent")
|
minWatchPercent Int @default(80) @map("min_watch_percent")
|
||||||
minWatchMinutes Int @default(5) @map("min_watch_minutes")
|
minWatchMinutes Int @default(5) @map("min_watch_minutes")
|
||||||
|
|
||||||
// Spending settings
|
|
||||||
movieRequestCost Int @default(500) @map("movie_request_cost")
|
movieRequestCost Int @default(500) @map("movie_request_cost")
|
||||||
tvRequestCost Int @default(1000) @map("tv_request_cost")
|
tvRequestCost Int @default(1000) @map("tv_request_cost")
|
||||||
tvPerSeasonCost Int @default(250) @map("tv_per_season_cost")
|
tvPerSeasonCost Int @default(250) @map("tv_per_season_cost")
|
||||||
|
|
||||||
// Multipliers
|
|
||||||
newReleaseMultiplier Decimal @default(1.5) @map("new_release_multiplier") @db.Decimal(3, 2)
|
newReleaseMultiplier Decimal @default(1.5) @map("new_release_multiplier") @db.Decimal(3, 2)
|
||||||
bonusMultiplierActive Boolean @default(false) @map("bonus_multiplier_active")
|
bonusMultiplierActive Boolean @default(false) @map("bonus_multiplier_active")
|
||||||
bonusMultiplier Decimal @default(2.0) @map("bonus_multiplier") @db.Decimal(3, 2)
|
bonusMultiplier Decimal @default(2.0) @map("bonus_multiplier") @db.Decimal(3, 2)
|
||||||
|
|
||||||
// System state
|
|
||||||
mintingPaused Boolean @default(false) @map("minting_paused")
|
mintingPaused Boolean @default(false) @map("minting_paused")
|
||||||
|
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
updatedBy String? @map("updated_by")
|
updatedBy String? @map("updated_by")
|
||||||
|
|
||||||
@@map("system_settings")
|
@@map("system_settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-155
@@ -3,7 +3,6 @@ import crypto from "crypto";
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import jwt from "jsonwebtoken";
|
import jwt from "jsonwebtoken";
|
||||||
import { asyncHandler } from "../middleware/errorHandler";
|
import { asyncHandler } from "../middleware/errorHandler";
|
||||||
import { createWallet } from "../services/solana";
|
|
||||||
import { prisma } from "../utils/prisma";
|
import { prisma } from "../utils/prisma";
|
||||||
|
|
||||||
const ENCRYPTION_KEY =
|
const ENCRYPTION_KEY =
|
||||||
@@ -27,18 +26,11 @@ const PLEX_CLIENT_ID = process.env.PLEX_CLIENT_ID || "";
|
|||||||
const PLEX_REDIRECT_URI = process.env.PLEX_REDIRECT_URI || "";
|
const PLEX_REDIRECT_URI = process.env.PLEX_REDIRECT_URI || "";
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || "secret";
|
const JWT_SECRET = process.env.JWT_SECRET || "secret";
|
||||||
|
|
||||||
if (!PLEX_CLIENT_ID) {
|
|
||||||
console.error("ERROR: PLEX_CLIENT_ID not set");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Generate Plex PIN and return auth URL
|
|
||||||
router.get(
|
router.get(
|
||||||
"/plex/url",
|
"/plex/url",
|
||||||
asyncHandler(async (_req, res) => {
|
asyncHandler(async (_req, res) => {
|
||||||
if (!PLEX_CLIENT_ID) {
|
if (!PLEX_CLIENT_ID)
|
||||||
return res.status(500).json({ error: "Plex not configured" });
|
return res.status(500).json({ error: "Plex not configured" });
|
||||||
}
|
|
||||||
|
|
||||||
const pinResponse = await axios.post(
|
const pinResponse = await axios.post(
|
||||||
"https://plex.tv/api/v2/pins?strong=true",
|
"https://plex.tv/api/v2/pins?strong=true",
|
||||||
null,
|
null,
|
||||||
@@ -53,25 +45,18 @@ router.get(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const pin = pinResponse.data;
|
const pin = pinResponse.data;
|
||||||
const authUrl = `https://app.plex.tv/auth#?clientID=${encodeURIComponent(PLEX_CLIENT_ID)}&code=${pin.code}&forwardUrl=${encodeURIComponent(PLEX_REDIRECT_URI)}`;
|
const authUrl = `https://app.plex.tv/auth#?clientID=${encodeURIComponent(PLEX_CLIENT_ID)}&code=${pin.code}&forwardUrl=${encodeURIComponent(PLEX_REDIRECT_URI)}`;
|
||||||
|
|
||||||
res.json({ authUrl, pinId: pin.id });
|
res.json({ authUrl, pinId: pin.id });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Step 2: Exchange PIN for Plex token and create user session
|
|
||||||
router.post(
|
router.post(
|
||||||
"/plex/callback",
|
"/plex/callback",
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const { pinId } = req.body;
|
const { pinId } = req.body;
|
||||||
|
if (!pinId) return res.status(400).json({ error: "PIN ID required" });
|
||||||
|
|
||||||
if (!pinId) {
|
|
||||||
return res.status(400).json({ error: "PIN ID required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check PIN status to get auth token
|
|
||||||
const pinResponse = await axios.get(
|
const pinResponse = await axios.get(
|
||||||
`https://plex.tv/api/v2/pins/${pinId}`,
|
`https://plex.tv/api/v2/pins/${pinId}`,
|
||||||
{
|
{
|
||||||
@@ -81,33 +66,23 @@ router.post(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const pin = pinResponse.data;
|
const pin = pinResponse.data;
|
||||||
|
if (!pin.authToken)
|
||||||
if (!pin.authToken) {
|
|
||||||
return res.status(400).json({ error: "Authentication not completed" });
|
return res.status(400).json({ error: "Authentication not completed" });
|
||||||
}
|
|
||||||
|
|
||||||
const plexToken = pin.authToken;
|
|
||||||
|
|
||||||
// Get user info from Plex
|
|
||||||
const userResponse = await axios.get("https://plex.tv/api/v2/user", {
|
const userResponse = await axios.get("https://plex.tv/api/v2/user", {
|
||||||
headers: {
|
headers: {
|
||||||
"X-Plex-Token": plexToken,
|
"X-Plex-Token": pin.authToken,
|
||||||
"X-Plex-Client-Identifier": PLEX_CLIENT_ID,
|
"X-Plex-Client-Identifier": PLEX_CLIENT_ID,
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const plexUser = userResponse.data;
|
const plexUser = userResponse.data;
|
||||||
|
|
||||||
// Find or create user
|
|
||||||
let user = await prisma.user.findUnique({
|
let user = await prisma.user.findUnique({
|
||||||
where: { plexId: String(plexUser.id) },
|
where: { plexId: String(plexUser.id) },
|
||||||
});
|
});
|
||||||
|
|
||||||
const isNewUser = !user;
|
const isNewUser = !user;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
user = await prisma.user.create({
|
user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -127,7 +102,6 @@ router.post(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete old sessions and create new one
|
|
||||||
await prisma.session.deleteMany({ where: { userId: user.id } });
|
await prisma.session.deleteMany({ where: { userId: user.id } });
|
||||||
const sessionToken = jwt.sign(
|
const sessionToken = jwt.sign(
|
||||||
{ userId: user.id, nonce: Date.now() },
|
{ userId: user.id, nonce: Date.now() },
|
||||||
@@ -142,22 +116,13 @@ router.post(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Backfill 30 days of watch history for new users
|
if (isNewUser) await backfillUserHistory(user);
|
||||||
if (isNewUser) {
|
|
||||||
await backfillUserHistory(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate JWT
|
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{
|
{ userId: user.id, plexId: user.plexId, isAdmin: user.isAdmin },
|
||||||
userId: user.id,
|
|
||||||
plexId: user.plexId,
|
|
||||||
isAdmin: user.isAdmin,
|
|
||||||
},
|
|
||||||
JWT_SECRET,
|
JWT_SECRET,
|
||||||
{ expiresIn: "7d" },
|
{ expiresIn: "7d" },
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
token,
|
token,
|
||||||
sessionToken: session.token,
|
sessionToken: session.token,
|
||||||
@@ -167,7 +132,6 @@ router.post(
|
|||||||
plexUsername: user.plexUsername,
|
plexUsername: user.plexUsername,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
isAdmin: user.isAdmin,
|
isAdmin: user.isAdmin,
|
||||||
walletAddress: user.walletAddress,
|
|
||||||
totalEarned: user.totalEarned,
|
totalEarned: user.totalEarned,
|
||||||
totalSpent: user.totalSpent,
|
totalSpent: user.totalSpent,
|
||||||
},
|
},
|
||||||
@@ -179,24 +143,15 @@ router.get(
|
|||||||
"/verify",
|
"/verify",
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader?.startsWith("Bearer "))
|
||||||
if (!authHeader?.startsWith("Bearer ")) {
|
|
||||||
return res.status(401).json({ error: "No token provided" });
|
return res.status(401).json({ error: "No token provided" });
|
||||||
}
|
|
||||||
|
|
||||||
const token = authHeader.substring(7);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, JWT_SECRET) as any;
|
const decoded = jwt.verify(authHeader.substring(7), JWT_SECRET) as any;
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: decoded.userId },
|
where: { id: decoded.userId },
|
||||||
});
|
});
|
||||||
|
if (!user || !user.isActive)
|
||||||
if (!user || !user.isActive) {
|
|
||||||
return res.status(401).json({ error: "User not found or inactive" });
|
return res.status(401).json({ error: "User not found or inactive" });
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -204,12 +159,11 @@ router.get(
|
|||||||
plexUsername: user.plexUsername,
|
plexUsername: user.plexUsername,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
isAdmin: user.isAdmin,
|
isAdmin: user.isAdmin,
|
||||||
walletAddress: user.walletAddress,
|
|
||||||
totalEarned: user.totalEarned,
|
totalEarned: user.totalEarned,
|
||||||
totalSpent: user.totalSpent,
|
totalSpent: user.totalSpent,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch {
|
||||||
res.status(401).json({ error: "Invalid token" });
|
res.status(401).json({ error: "Invalid token" });
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -219,21 +173,12 @@ router.post(
|
|||||||
"/logout",
|
"/logout",
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
if (authHeader?.startsWith("Bearer ")) {
|
if (authHeader?.startsWith("Bearer ")) {
|
||||||
const token = authHeader.substring(7);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, JWT_SECRET) as any;
|
const decoded = jwt.verify(authHeader.substring(7), JWT_SECRET) as any;
|
||||||
|
await prisma.session.deleteMany({ where: { userId: decoded.userId } });
|
||||||
await prisma.session.deleteMany({
|
} catch {}
|
||||||
where: { userId: decoded.userId },
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Ignore invalid tokens on logout
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ message: "Logged out successfully" });
|
res.json({ message: "Logged out successfully" });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -242,38 +187,12 @@ async function backfillUserHistory(user: {
|
|||||||
id: string;
|
id: string;
|
||||||
plexId: string;
|
plexId: string;
|
||||||
plexUsername: string;
|
plexUsername: string;
|
||||||
walletAddress: string | null;
|
|
||||||
}) {
|
}) {
|
||||||
if (!TAUTULLI_URL || !TAUTULLI_API_KEY) {
|
if (!TAUTULLI_URL || !TAUTULLI_API_KEY) return;
|
||||||
console.log("Tautulli not configured, skipping backfill");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create wallet if user doesn't have one
|
|
||||||
if (!user.walletAddress) {
|
|
||||||
const wallet = createWallet();
|
|
||||||
await prisma.user.update({
|
|
||||||
where: { id: user.id },
|
|
||||||
data: {
|
|
||||||
walletAddress: wallet.publicKey,
|
|
||||||
encryptedPrivateKey: encrypt(wallet.secretKey),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
user.walletAddress = wallet.publicKey;
|
|
||||||
console.log(
|
|
||||||
`Created wallet for new user ${user.plexUsername}: ${wallet.publicKey}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map Plex username to Tautulli user ID
|
|
||||||
const usersResponse = await axios.get(`${TAUTULLI_URL}/api/v2`, {
|
const usersResponse = await axios.get(`${TAUTULLI_URL}/api/v2`, {
|
||||||
params: {
|
params: { apikey: TAUTULLI_API_KEY, cmd: "get_users" },
|
||||||
apikey: TAUTULLI_API_KEY,
|
|
||||||
cmd: "get_users",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const tautulliUsers = usersResponse.data?.response?.data || [];
|
const tautulliUsers = usersResponse.data?.response?.data || [];
|
||||||
const tautulliUser = tautulliUsers.find(
|
const tautulliUser = tautulliUsers.find(
|
||||||
(u: any) =>
|
(u: any) =>
|
||||||
@@ -281,44 +200,25 @@ async function backfillUserHistory(user: {
|
|||||||
u.email?.toLowerCase() === user.plexUsername.toLowerCase() ||
|
u.email?.toLowerCase() === user.plexUsername.toLowerCase() ||
|
||||||
u.friendly_name?.toLowerCase() === user.plexUsername.toLowerCase(),
|
u.friendly_name?.toLowerCase() === user.plexUsername.toLowerCase(),
|
||||||
);
|
);
|
||||||
|
if (!tautulliUser) return;
|
||||||
|
|
||||||
if (!tautulliUser) {
|
const startDate = (() => {
|
||||||
console.log(
|
const d = new Date();
|
||||||
`Tautulli user not found for Plex username ${user.plexUsername}, skipping backfill`,
|
d.setDate(d.getDate() - 30);
|
||||||
);
|
return d.toISOString().split("T")[0];
|
||||||
return;
|
})();
|
||||||
}
|
|
||||||
|
|
||||||
const tautulliUserId = tautulliUser.user_id;
|
|
||||||
console.log(
|
|
||||||
`Mapped ${user.plexUsername} to Tautulli user ID ${tautulliUserId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Calculate 30 days ago
|
|
||||||
const thirtyDaysAgo = new Date();
|
|
||||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
|
||||||
const startDate = thirtyDaysAgo.toISOString().split("T")[0];
|
|
||||||
|
|
||||||
// Fetch history from Tautulli
|
|
||||||
const historyResponse = await axios.get(`${TAUTULLI_URL}/api/v2`, {
|
const historyResponse = await axios.get(`${TAUTULLI_URL}/api/v2`, {
|
||||||
params: {
|
params: {
|
||||||
apikey: TAUTULLI_API_KEY,
|
apikey: TAUTULLI_API_KEY,
|
||||||
cmd: "get_history",
|
cmd: "get_history",
|
||||||
user_id: tautulliUserId,
|
user_id: tautulliUser.user_id,
|
||||||
start_date: startDate,
|
start_date: startDate,
|
||||||
length: 1000,
|
length: 1000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const historyData = historyResponse.data?.response?.data?.data || [];
|
const historyData = historyResponse.data?.response?.data?.data || [];
|
||||||
if (!Array.isArray(historyData) || historyData.length === 0) {
|
if (!Array.isArray(historyData) || historyData.length === 0) return;
|
||||||
console.log(
|
|
||||||
`No watch history found for user ${user.plexUsername} in last 30 days`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get system settings for credit calculation
|
|
||||||
const settings = await prisma.systemSettings.findFirst();
|
const settings = await prisma.systemSettings.findFirst();
|
||||||
const creditsPerMinute = settings?.creditsPerMinute || 2;
|
const creditsPerMinute = settings?.creditsPerMinute || 2;
|
||||||
const minWatchPercent = settings?.minWatchPercent || 80;
|
const minWatchPercent = settings?.minWatchPercent || 80;
|
||||||
@@ -326,38 +226,21 @@ async function backfillUserHistory(user: {
|
|||||||
|
|
||||||
let totalCredits = 0;
|
let totalCredits = 0;
|
||||||
let totalMinutes = 0;
|
let totalMinutes = 0;
|
||||||
let processedCount = 0;
|
|
||||||
|
|
||||||
for (const item of historyData) {
|
for (const item of historyData) {
|
||||||
const sessionId = item.reference_id?.toString() || item.id?.toString();
|
const sessionId = item.reference_id?.toString() || item.id?.toString();
|
||||||
if (!sessionId) continue;
|
if (!sessionId) continue;
|
||||||
|
if (await prisma.watchEvent.findUnique({ where: { sessionId } }))
|
||||||
// Skip duplicates
|
continue;
|
||||||
const existing = await prisma.watchEvent.findUnique({
|
|
||||||
where: { sessionId },
|
|
||||||
});
|
|
||||||
if (existing) continue;
|
|
||||||
|
|
||||||
const percentComplete = item.percent_complete || 0;
|
const percentComplete = item.percent_complete || 0;
|
||||||
const watchDurationMinutes = Math.floor(
|
const watchDurationMinutes = Math.floor(
|
||||||
(item.stopped - item.started) / 60,
|
(item.stopped - item.started) / 60,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
percentComplete < minWatchPercent ||
|
percentComplete < minWatchPercent ||
|
||||||
watchDurationMinutes < minWatchMinutes
|
watchDurationMinutes < minWatchMinutes
|
||||||
) {
|
)
|
||||||
continue;
|
continue;
|
||||||
}
|
const creditsEarned = watchDurationMinutes * creditsPerMinute;
|
||||||
|
|
||||||
let creditsEarned = watchDurationMinutes * creditsPerMinute;
|
|
||||||
if (settings?.newReleaseMultiplier && item.is_new) {
|
|
||||||
creditsEarned = Math.floor(
|
|
||||||
creditsEarned * Number(settings.newReleaseMultiplier),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create watch event
|
|
||||||
const watchEvent = await prisma.watchEvent.create({
|
const watchEvent = await prisma.watchEvent.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -373,8 +256,6 @@ async function backfillUserHistory(user: {
|
|||||||
watchedAt: new Date(item.stopped * 1000),
|
watchedAt: new Date(item.stopped * 1000),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// DB-only credit record
|
|
||||||
await prisma.transaction.create({
|
await prisma.transaction.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -385,14 +266,10 @@ async function backfillUserHistory(user: {
|
|||||||
contentTitle: item.title || item.full_title || "Unknown",
|
contentTitle: item.title || item.full_title || "Unknown",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
totalCredits += creditsEarned;
|
totalCredits += creditsEarned;
|
||||||
totalMinutes += watchDurationMinutes;
|
totalMinutes += watchDurationMinutes;
|
||||||
processedCount++;
|
|
||||||
}
|
}
|
||||||
|
if (totalCredits > 0)
|
||||||
// Update user stats
|
|
||||||
if (totalCredits > 0) {
|
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: {
|
data: {
|
||||||
@@ -400,10 +277,6 @@ async function backfillUserHistory(user: {
|
|||||||
watchTimeMinutes: { increment: totalMinutes },
|
watchTimeMinutes: { increment: totalMinutes },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log(
|
|
||||||
`Backfilled ${processedCount} watch events for ${user.plexUsername}: ${totalCredits} $COOP, ${totalMinutes} minutes`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
`Failed to backfill history for ${user.plexUsername}:`,
|
`Failed to backfill history for ${user.plexUsername}:`,
|
||||||
|
|||||||
+29
-210
@@ -1,223 +1,42 @@
|
|||||||
import crypto from "crypto";
|
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import {
|
import { type AuthenticatedRequest, authenticate, requireAdmin } from "../middleware/auth";
|
||||||
type AuthenticatedRequest,
|
|
||||||
authenticate,
|
|
||||||
requireAdmin,
|
|
||||||
} from "../middleware/auth";
|
|
||||||
import { asyncHandler } from "../middleware/errorHandler";
|
import { asyncHandler } from "../middleware/errorHandler";
|
||||||
import { createWallet } from "../services/solana";
|
|
||||||
import { prisma } from "../utils/prisma";
|
import { prisma } from "../utils/prisma";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const ENCRYPTION_KEY =
|
|
||||||
process.env.ENCRYPTION_KEY || "default-key-32-chars-long!!!!!";
|
|
||||||
|
|
||||||
function encrypt(text: string): string {
|
router.get("/", authenticate, asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||||
const iv = crypto.randomBytes(16);
|
const user = await prisma.user.findUnique({ where: { id: req.user!.id }, select: { totalEarned: true, totalSpent: true } });
|
||||||
const key = crypto.createHash("sha256").update(ENCRYPTION_KEY).digest();
|
res.json({ hasWallet: false, balance: (user?.totalEarned || 0) - (user?.totalSpent || 0), totalEarned: user?.totalEarned || 0, totalSpent: user?.totalSpent || 0 });
|
||||||
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}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function decrypt(encryptedData: string): string {
|
router.post("/create", authenticate, asyncHandler(async (_req: AuthenticatedRequest, res) => {
|
||||||
const parts = encryptedData.split(":");
|
res.status(410).json({ error: "Wallets removed" });
|
||||||
const iv = Buffer.from(parts[0], "hex");
|
}));
|
||||||
const authTag = Buffer.from(parts[1], "hex");
|
|
||||||
const encrypted = parts[2];
|
|
||||||
const key = crypto.createHash("sha256").update(ENCRYPTION_KEY).digest();
|
|
||||||
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
|
|
||||||
decipher.setAuthTag(authTag);
|
|
||||||
let decrypted = decipher.update(encrypted, "hex", "utf8");
|
|
||||||
decrypted += decipher.final("utf8");
|
|
||||||
return decrypted;
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get(
|
router.post("/connect", authenticate, asyncHandler(async (_req: AuthenticatedRequest, res) => {
|
||||||
"/",
|
res.status(410).json({ error: "Wallets removed" });
|
||||||
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) {
|
router.post("/backup", authenticate, asyncHandler(async (_req: AuthenticatedRequest, res) => {
|
||||||
return res.json({
|
res.status(410).json({ error: "Wallets removed" });
|
||||||
hasWallet: false,
|
}));
|
||||||
balance: 0,
|
|
||||||
totalEarned: user?.totalEarned || 0,
|
|
||||||
totalSpent: user?.totalSpent || 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const dbBalance = user.totalEarned - user.totalSpent;
|
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, email: true, isAdmin: true, isActive: true, totalEarned: true, totalSpent: true, watchTimeMinutes: true, createdAt: true } });
|
||||||
|
if (!user) return res.status(404).json({ error: "User not found" });
|
||||||
|
res.json({ ...user, balance: user.totalEarned - user.totalSpent });
|
||||||
|
}));
|
||||||
|
|
||||||
res.json({
|
router.post("/admin/:userId/adjust", authenticate, requireAdmin, asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||||
hasWallet: true,
|
const { amount, reason } = req.body;
|
||||||
address: user.walletAddress,
|
if (!amount || Number.isNaN(Number(amount)) || Number(amount) === 0) return res.status(400).json({ error: "Valid amount required" });
|
||||||
balance: dbBalance,
|
const user = await prisma.user.findUnique({ where: { id: req.params.userId } });
|
||||||
totalEarned: user.totalEarned,
|
if (!user) return res.status(404).json({ error: "User not found" });
|
||||||
totalSpent: user.totalSpent,
|
const delta = Number(amount);
|
||||||
});
|
await prisma.transaction.create({ data: { userId: user.id, type: "ADJUSTMENT", amount: Math.abs(delta), description: reason || "Admin adjustment", contentTitle: reason || "Admin adjustment" } });
|
||||||
}),
|
await prisma.user.update({ where: { id: user.id }, data: delta > 0 ? { totalEarned: { increment: delta } } : { totalSpent: { increment: Math.abs(delta) } } });
|
||||||
);
|
res.json({ success: true });
|
||||||
|
}));
|
||||||
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" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const wallet = createWallet();
|
|
||||||
const encryptedKey = encrypt(wallet.secretKey);
|
|
||||||
|
|
||||||
await prisma.user.update({
|
|
||||||
where: { id: req.user!.id },
|
|
||||||
data: {
|
|
||||||
walletAddress: wallet.publicKey,
|
|
||||||
encryptedPrivateKey: encryptedKey,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
address: wallet.publicKey,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/connect",
|
|
||||||
authenticate,
|
|
||||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
|
||||||
const { address } = req.body;
|
|
||||||
|
|
||||||
if (!address) {
|
|
||||||
return res.status(400).json({ error: "Address required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await prisma.user.findFirst({
|
|
||||||
where: { walletAddress: address },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existing && existing.id !== req.user!.id) {
|
|
||||||
return res.status(400).json({ error: "Wallet already in use" });
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.user.update({
|
|
||||||
where: { id: req.user!.id },
|
|
||||||
data: { walletAddress: address },
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ success: true, address });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
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 backup available" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const privateKey = decrypt(user.encryptedPrivateKey);
|
|
||||||
res.json({ success: true, privateKey });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
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,
|
|
||||||
email: true,
|
|
||||||
isAdmin: true,
|
|
||||||
isActive: 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,
|
|
||||||
balance: user.totalEarned - user.totalSpent,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/admin/:userId/adjust",
|
|
||||||
authenticate,
|
|
||||||
requireAdmin,
|
|
||||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
|
||||||
const { amount, reason } = req.body;
|
|
||||||
|
|
||||||
if (!amount || Number.isNaN(Number(amount)) || Number(amount) === 0) {
|
|
||||||
return res.status(400).json({ error: "Valid amount required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { id: req.params.userId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return res.status(404).json({ error: "User not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const delta = Number(amount);
|
|
||||||
const type = delta > 0 ? "BONUS" : "ADJUSTMENT";
|
|
||||||
|
|
||||||
await prisma.transaction.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
type,
|
|
||||||
amount: Math.abs(delta),
|
|
||||||
description: reason || "Admin adjustment",
|
|
||||||
contentTitle: reason || "Admin adjustment",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.user.update({
|
|
||||||
where: { id: user.id },
|
|
||||||
data: {
|
|
||||||
totalEarned: delta > 0 ? { increment: delta } : undefined,
|
|
||||||
totalSpent: delta < 0 ? { increment: Math.abs(delta) } : undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ success: true });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export { router as walletRouter };
|
export { router as walletRouter };
|
||||||
|
|||||||
@@ -1,267 +0,0 @@
|
|||||||
import {
|
|
||||||
createBurnInstruction,
|
|
||||||
createMint,
|
|
||||||
createMintToInstruction,
|
|
||||||
getAccount,
|
|
||||||
getOrCreateAssociatedTokenAccount,
|
|
||||||
} from "@solana/spl-token";
|
|
||||||
import {
|
|
||||||
Connection,
|
|
||||||
Keypair,
|
|
||||||
PublicKey,
|
|
||||||
sendAndConfirmTransaction,
|
|
||||||
Transaction,
|
|
||||||
} from "@solana/web3.js";
|
|
||||||
import bs58 from "bs58";
|
|
||||||
import { prisma } from "../utils/prisma";
|
|
||||||
|
|
||||||
const RPC_URL = process.env.SOLANA_RPC_URL || "https://api.devnet.solana.com";
|
|
||||||
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 raw = process.env.SOLANA_MINT_AUTHORITY_KEYPAIR.trim();
|
|
||||||
let secretKey: Uint8Array;
|
|
||||||
if (raw.startsWith("[")) {
|
|
||||||
secretKey = new Uint8Array(JSON.parse(raw));
|
|
||||||
} else {
|
|
||||||
secretKey = bs58.decode(raw);
|
|
||||||
}
|
|
||||||
mintAuthority = Keypair.fromSecretKey(secretKey);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Mint authority not configured");
|
|
||||||
}
|
|
||||||
|
|
||||||
export const connection = new Connection(RPC_URL, "confirmed");
|
|
||||||
|
|
||||||
let cachedMint: PublicKey | null = null;
|
|
||||||
|
|
||||||
// Get or create token mint address
|
|
||||||
export async function getTokenMint(): Promise<PublicKey | null> {
|
|
||||||
try {
|
|
||||||
// Return cached mint
|
|
||||||
if (cachedMint) {
|
|
||||||
return cachedMint;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check env first
|
|
||||||
if (process.env.SOLANA_MINT_ADDRESS) {
|
|
||||||
cachedMint = new PublicKey(process.env.SOLANA_MINT_ADDRESS);
|
|
||||||
return cachedMint;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-create mint on devnet if authority exists
|
|
||||||
if (mintAuthority && RPC_URL.includes("devnet")) {
|
|
||||||
try {
|
|
||||||
// Fund mint authority with SOL first
|
|
||||||
const balance = await connection.getBalance(mintAuthority.publicKey);
|
|
||||||
if (balance < 500000000) {
|
|
||||||
const signature = await connection.requestAirdrop(
|
|
||||||
mintAuthority.publicKey,
|
|
||||||
2 * 1000000000,
|
|
||||||
);
|
|
||||||
await connection.confirmTransaction(signature);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mintKeypair = Keypair.generate();
|
|
||||||
const mint = await createMint(
|
|
||||||
connection,
|
|
||||||
mintAuthority,
|
|
||||||
mintAuthority.publicKey,
|
|
||||||
null,
|
|
||||||
DECIMALS,
|
|
||||||
mintKeypair,
|
|
||||||
);
|
|
||||||
cachedMint = mint;
|
|
||||||
console.log(`Token mint created: ${mint.toBase58()}`);
|
|
||||||
console.log(
|
|
||||||
`Add SOLANA_MINT_ADDRESS=${mint.toBase58()} to your .env to persist it`,
|
|
||||||
);
|
|
||||||
return mint;
|
|
||||||
} catch {
|
|
||||||
// Devnet faucet rate-limited, will use DB-only credits
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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) {
|
|
||||||
console.warn("Mint authority not configured, using DB-only credits");
|
|
||||||
return `db-only-${Date.now()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const userPublicKey = new PublicKey(userWalletAddress);
|
|
||||||
const mint = await getTokenMint();
|
|
||||||
|
|
||||||
if (!mint) {
|
|
||||||
console.warn("Token mint not available, using DB-only credits");
|
|
||||||
return `db-only-${Date.now()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get or create user's token account
|
|
||||||
const tokenAccount = await getOrCreateTokenAccount(userPublicKey, mint);
|
|
||||||
|
|
||||||
// Calculate amount with decimals
|
|
||||||
const amountWithDecimals = amount * 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("Solana minting failed, using DB-only credits:", error);
|
|
||||||
return `db-only-${Date.now()}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 * 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) / 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -46,8 +46,7 @@ interface User {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
walletAddress: string | null;
|
totalEarned: number;
|
||||||
totalEarned: number;
|
|
||||||
totalSpent: number;
|
totalSpent: number;
|
||||||
watchTimeMinutes: number;
|
watchTimeMinutes: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -256,7 +255,6 @@ export default function AdminPage() {
|
|||||||
<div className="flex gap-2 mt-1">
|
<div className="flex gap-2 mt-1">
|
||||||
{u.isAdmin && <Badge variant="default">Admin</Badge>}
|
{u.isAdmin && <Badge variant="default">Admin</Badge>}
|
||||||
{!u.isActive && <Badge variant="destructive">Inactive</Badge>}
|
{!u.isActive && <Badge variant="destructive">Inactive</Badge>}
|
||||||
{u.walletAddress && <Badge variant="secondary">Wallet</Badge>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import {
|
|||||||
Film,
|
Film,
|
||||||
History,
|
History,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
TrendingUp,
|
TrendingUp
|
||||||
Wallet,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -24,7 +23,7 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { overseerApi, transactionApi, walletApi } from "@/lib/api";
|
import { overseerApi, transactionApi } from "@/lib/api";
|
||||||
import { useSocket } from "@/lib/socket";
|
import { useSocket } from "@/lib/socket";
|
||||||
import { useStore } from "@/lib/store";
|
import { useStore } from "@/lib/store";
|
||||||
import { formatNumber, truncateAddress } from "@/lib/utils";
|
import { formatNumber, truncateAddress } from "@/lib/utils";
|
||||||
@@ -50,9 +49,7 @@ export default function DashboardPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, isAuthenticated, logout } = useStore();
|
const { user, isAuthenticated, logout } = useStore();
|
||||||
const [wallet, setWallet] = useState<WalletData | null>(null);
|
const [wallet, setWallet] = useState<WalletData | null>(null);
|
||||||
const [requestCosts, setRequestCosts] = useState({ movie: 100, tv: 200 });
|
const [requestCosts, setRequestCosts] = useState({ movie: 100, tv: 200 }); const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
|
||||||
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const socket = useSocket();
|
const socket = useSocket();
|
||||||
|
|
||||||
@@ -138,9 +135,7 @@ export default function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="wallet-adapter-custom-wrapper hidden md:block">
|
<div className="wallet-adapter-custom-wrapper hidden md:block"> </div>
|
||||||
<WalletMultiButton className="!h-9 !px-4 !text-sm !rounded-md !bg-secondary !text-secondary-foreground hover:!bg-secondary/80" />
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" onClick={logout}>
|
<Button variant="ghost" onClick={logout}>
|
||||||
Sign Out
|
Sign Out
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+22
-23
@@ -1,32 +1,31 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from "zustand";
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
plexId: string;
|
plexId: string;
|
||||||
plexUsername: string;
|
plexUsername: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
walletAddress: string | null;
|
totalEarned: number;
|
||||||
totalEarned: number;
|
totalSpent: number;
|
||||||
totalSpent: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
setUser: (user: User | null) => void;
|
setUser: (user: User | null) => void;
|
||||||
setToken: (token: string | null) => void;
|
setToken: (token: string | null) => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useStore = create<AppState>()((set) => ({
|
export const useStore = create<AppState>()((set) => ({
|
||||||
user: null,
|
user: null,
|
||||||
token: null,
|
token: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
setUser: (user) => set({ user, isAuthenticated: !!user }),
|
setUser: (user) => set({ user, isAuthenticated: !!user }),
|
||||||
setToken: (token) => set({ token }),
|
setToken: (token) => set({ token }),
|
||||||
logout: () => {
|
logout: () => {
|
||||||
set({ user: null, token: null, isAuthenticated: false });
|
set({ user: null, token: null, isAuthenticated: false });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user