MovixOpenSource/API/Mainapi/wishboardRoutes.js
Movix 2045dbd2c9 Nouvelle adresse movix + nouvelles fonctionalités
Modification du Oauth :
Pouvoir manager les apps depuis le panel admin

Correction de l'affichage des images des admins

Majs des urls extension, userscript et premid

Correction d'un crash sur les commentaires

Ajout d'un light mode sur le frontend
2026-05-14 17:26:13 +02:00

1001 lines
40 KiB
JavaScript

/**
* Wishboard / Greenlight API Routes
* Content request management system
*
* Uses MySQL database (same as the rest of the app)
*/
const express = require('express');
const jwt = require('jsonwebtoken');
const fs = require('fs').promises;
const path = require('path');
const { verifyAccessKey } = require('./checkVip');
const { searchTmdb } = require('./utils/tmdbCache');
const { verifyTurnstileFromRequest } = require('./utils/turnstile');
const { resolveAdminIdentity } = require('./utils/adminIdentity');
const TURNSTILE_INVISIBLE_SECRETKEY = process.env.TURNSTILE_INVISIBLE_SECRETKEY;
const TMDB_API_URL = 'https://api.themoviedb.org/3';
// JWT Secret (same as commentsRoutes and server.js)
const JWT_SECRET = process.env.JWT_SECRET;
/**
* Search TMDB by name and return matching TMDB IDs.
* Utilise searchTmdb centralisé (cache Redis partagé).
*/
async function searchTmdbIds(query) {
try {
const [movieData, tvData] = await Promise.all([
searchTmdb(TMDB_API_URL, process.env.TMDB_API_KEY, 'movie', query, { page: 1 }).catch(() => null),
searchTmdb(TMDB_API_URL, process.env.TMDB_API_KEY, 'tv', query, { page: 1 }).catch(() => null)
]);
const movieIds = (movieData?.results || []).map(r => r.id);
const tvIds = (tvData?.results || []).map(r => r.id);
return [...new Set([...movieIds, ...tvIds])];
} catch (error) {
console.error('TMDB search error:', error.message);
return [];
}
}
function createWishboardRouter(mysqlPool, redis) {
const router = express.Router();
// Middleware to verify JWT token with MySQL session validation
const requireAuth = async (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Non authentifié' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
const { sub: userId, userType, sessionId } = decoded;
if (!['oauth', 'bip39'].includes(userType) || !userId || !sessionId) {
return res.status(401).json({ error: 'Token invalide' });
}
// Vérifier que la session existe en MySQL
if (!mysqlPool) {
return res.status(503).json({ error: 'Service temporairement indisponible' });
}
const [rows] = await mysqlPool.execute(
'SELECT id FROM user_sessions WHERE id = ? AND user_id = ? AND user_type = ?',
[sessionId, userId, userType]
);
if (rows.length === 0) {
return res.status(401).json({ error: 'Session invalide ou expirée' });
}
req.user = {
userId: userId,
userType: userType,
sessionId: sessionId
};
next();
} catch (error) {
return res.status(401).json({ error: 'Token invalide' });
}
};
// Middleware to check admin or uploader status (same pattern as server.js isUploaderOrAdmin)
const requireUploaderOrAdmin = async (req, res, next) => {
try {
if (!mysqlPool) {
return res.status(503).json({ error: 'Service temporairement indisponible' });
}
const authType = req.user.userType === 'bip39' ? 'bip-39' : req.user.userType;
const [rows] = await mysqlPool.execute(
'SELECT * FROM admins WHERE user_id = ? AND auth_type = ?',
[req.user.userId, authType]
);
if (rows.length === 0) {
return res.status(403).json({ error: 'Accès refusé - Droits requis' });
}
// Récupérer le rôle (par défaut 'admin' si non défini)
const role = rows[0].role || 'admin';
// Autoriser les rôles 'admin' et 'uploader'
if (role !== 'admin' && role !== 'uploader') {
return res.status(403).json({ error: 'Accès refusé - Droits insuffisants' });
}
req.admin = { userId: req.user.userId, userType: req.user.userType, role };
next();
} catch (error) {
console.error('Admin/Uploader check error:', error);
return res.status(500).json({ error: 'Erreur lors de la vérification des droits' });
}
};
// Helper to get user data from JSON files (adapted from commentsRoutes.js)
async function getUserData(userId, userType, profileId = null) {
try {
let userData = { username: 'Utilisateur', avatar: null, isVip: false, isAdmin: false };
const safeUserId = String(userId).replace(/[^a-zA-Z0-9_\-]/g, '');
const safeUserType = ['oauth', 'bip39'].includes(userType) ? userType : 'oauth';
let userFilePath;
if (userType === 'bip39') {
userFilePath = path.join(__dirname, 'data', 'users', `bip39-${safeUserId}.json`);
} else {
userFilePath = path.join(__dirname, 'data', 'users', `${safeUserId}.json`);
}
try {
const userFile = await fs.readFile(userFilePath, 'utf8');
const user = JSON.parse(userFile);
if (user.profiles && user.profiles.length > 0) {
let profile;
if (profileId) {
profile = user.profiles.find(p => p.id === profileId);
}
if (!profile) {
profile = user.profiles[0];
}
if (profile) {
userData.username = profile.name || 'Utilisateur';
userData.avatar = profile.avatar;
}
}
} catch (err) {
console.error('Error reading user file:', err.message);
}
// Vérifier le statut VIP en lisant l'access_code depuis les données du profil
if (profileId && safeUserId) {
try {
const safeProfileId = String(profileId).replace(/[^a-zA-Z0-9_\-]/g, '');
const profileDataPath = path.join(__dirname, 'data', 'users', 'profiles', safeUserType, safeUserId, `${safeProfileId}.json`);
const profileData = JSON.parse(await fs.readFile(profileDataPath, 'utf8'));
const storedAccessCode = profileData.access_code || null;
if (storedAccessCode) {
const vipStatus = await verifyAccessKey(storedAccessCode);
userData.isVip = vipStatus.vip;
}
} catch (err) {
// Pas de données de profil ou pas d'access_code — isVip reste false
}
}
return userData;
} catch (error) {
console.error('Error getUserData:', error);
return { isVip: false };
}
}
// Check request limit helper
const checkLimit = async (userId, userType, profileId, req) => {
// Enforce limits: 1 per 48h for Free, 3 per 48h for VIP
const [recentRequests] = await mysqlPool.execute(
`SELECT COUNT(*) as count FROM wishboard_requests
WHERE profile_id = ?
AND created_at >= DATE_SUB(NOW(), INTERVAL 48 HOUR)`,
[profileId]
);
const count = recentRequests[0].count;
// Vérifier le statut VIP en lisant l'access_code depuis les données du profil
let isVip = false;
if (profileId && userId) {
try {
const safeUserId = String(userId).replace(/[^a-zA-Z0-9_\-]/g, '');
const safeUserType = ['oauth', 'bip39'].includes(userType) ? userType : 'oauth';
const safeProfileId = String(profileId).replace(/[^a-zA-Z0-9_\-]/g, '');
const profileDataPath = path.join(__dirname, 'data', 'users', 'profiles', safeUserType, safeUserId, `${safeProfileId}.json`);
const profileData = JSON.parse(await fs.readFile(profileDataPath, 'utf8'));
const storedAccessCode = profileData.access_code || null;
if (storedAccessCode) {
const vipStatus = await verifyAccessKey(storedAccessCode);
isVip = vipStatus.vip;
}
} catch (err) {
// Pas de données de profil — isVip reste false
}
}
const limit = isVip ? 3 : 1;
const remaining = Math.max(0, limit - count);
return { count, limit, remaining, isVip };
};
// Get profile ID from headers or body
const getProfileId = (req) => {
return req.headers['x-profile-id'] || req.body?.profileId || req.query?.profileId || null;
};
// Verify that the profileId belongs to the authenticated user
async function verifyProfileOwnership(userId, userType, profileId) {
try {
const safeUserId = String(userId).replace(/[^a-zA-Z0-9_\-]/g, '');
let userFilePath;
if (userType === 'bip39') {
userFilePath = path.join(__dirname, 'data', 'users', `bip39-${safeUserId}.json`);
} else {
userFilePath = path.join(__dirname, 'data', 'users', `${safeUserId}.json`);
}
const userFile = await fs.readFile(userFilePath, 'utf8');
const user = JSON.parse(userFile);
if (user.profiles) {
return user.profiles.some(p => p.id === profileId);
}
return false;
} catch {
return false;
}
}
// Helper to record status change in history
const recordStatusHistory = async (requestId, status, reason = null, adminId = null, adminAuthType = null) => {
try {
const [result] = await mysqlPool.execute(
'INSERT INTO wishboard_status_history (request_id, status, reason, admin_id, admin_auth_type) VALUES (?, ?, ?, ?, ?)',
[requestId, status, reason, adminId, adminAuthType]
);
return result.insertId;
} catch (error) {
console.error('Error recording status history:', error);
return null;
}
};
// =====================================
// PUBLIC ENDPOINTS
// =====================================
/**
* GET /api/wishboard
* List all requests with filters and pagination
*/
router.get('/', async (req, res) => {
try {
const { page = 1, limit = 20, search, media_type, status, sort = 'votes_desc' } = req.query;
const offset = (parseInt(page) - 1) * parseInt(limit);
const profileId = getProfileId(req);
let whereConditions = ['1=1'];
let whereParams = [];
if (search) {
const isNumericSearch = /^\d+$/.test(search.trim());
if (isNumericSearch) {
// Direct TMDB ID search
whereConditions.push('tmdb_id = ?');
whereParams.push(parseInt(search));
} else {
// Text search: query TMDB API to find matching IDs
const tmdbIds = await searchTmdbIds(search.trim());
if (tmdbIds.length > 0) {
whereConditions.push(`tmdb_id IN (${tmdbIds.map(() => '?').join(',')})`);
whereParams.push(...tmdbIds);
} else {
// No TMDB results found, return empty
return res.json({ requests: [], has_more: false, stats: { total: 0, pending: 0, added_this_month: 0, movies: 0, tv: 0 } });
}
}
}
if (media_type && media_type !== 'all') {
whereConditions.push('media_type = ?');
whereParams.push(media_type);
}
if (status && status !== 'all') {
whereConditions.push('status = ?');
whereParams.push(status);
}
const whereClause = whereConditions.join(' AND ');
// Determine sort order
let orderBy = 'vote_count DESC';
switch (sort) {
case 'votes_asc':
orderBy = 'vote_count ASC';
break;
case 'date_desc':
orderBy = 'created_at DESC';
break;
case 'date_asc':
orderBy = 'created_at ASC';
break;
default:
orderBy = 'vote_count DESC';
}
// Get requests
const [requests] = await mysqlPool.execute(
`SELECT * FROM wishboard_requests WHERE ${whereClause} ORDER BY ${orderBy} LIMIT ? OFFSET ?`,
[...whereParams, parseInt(limit), offset]
);
// Check vote status and get public notes for each request
const requestsWithVotes = await Promise.all(requests.map(async (r) => {
let hasVoted = false;
if (profileId) {
const [voteRows] = await mysqlPool.execute(
'SELECT id FROM wishboard_votes WHERE request_id = ? AND profile_id = ?',
[r.id, profileId]
);
hasVoted = voteRows.length > 0;
}
// Get public notes
const [notes] = await mysqlPool.execute(
'SELECT id, note, is_public, created_at FROM wishboard_notes WHERE request_id = ? AND is_public = 1 ORDER BY created_at DESC',
[r.id]
);
// Get status history
const [statusHistory] = await mysqlPool.execute(
'SELECT status, reason, changed_at FROM wishboard_status_history WHERE request_id = ? ORDER BY changed_at ASC',
[r.id]
);
return { ...r, has_voted: hasVoted, notes, statusHistory };
}));
// Get total count
const [countResult] = await mysqlPool.execute(
`SELECT COUNT(*) as total FROM wishboard_requests WHERE ${whereClause}`,
whereParams
);
// Get stats
const [statsResult] = await mysqlPool.execute(`
SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN status = 'added' AND updated_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) THEN 1 ELSE 0 END) as added_this_month,
SUM(CASE WHEN media_type = 'movie' THEN 1 ELSE 0 END) as movies,
SUM(CASE WHEN media_type = 'tv' THEN 1 ELSE 0 END) as tv
FROM wishboard_requests
`);
res.json({
requests: requestsWithVotes,
has_more: countResult[0].total > offset + requests.length,
stats: {
total: statsResult[0].total || 0,
pending: statsResult[0].pending || 0,
added_this_month: statsResult[0].added_this_month || 0,
movies: statsResult[0].movies || 0,
tv: statsResult[0].tv || 0
}
});
} catch (error) {
console.error('Error fetching wishboard:', error);
res.status(500).json({ error: 'Failed to fetch wishboard' });
}
});
/**
* POST /api/wishboard
* Create a new content request
*/
router.post('/', requireAuth, async (req, res) => {
try {
const { tmdb_id, media_type, season_number, season_numbers } = req.body;
const userId = req.user.userId;
const profileId = getProfileId(req);
if (!tmdb_id || !media_type) {
return res.status(400).json({ error: 'tmdb_id and media_type are required' });
}
if (!profileId) {
return res.status(400).json({ error: 'Profile ID is required' });
}
const isOwner = await verifyProfileOwnership(userId, req.user.userType, profileId);
if (!isOwner) {
return res.status(403).json({ error: 'Ce profil ne vous appartient pas' });
}
// Check Limits
const { remaining, limit } = await checkLimit(userId, req.user.userType, profileId, req);
if (remaining <= 0) {
return res.status(429).json({
error: `Limite atteinte. Vous avez droit à ${limit} demande(s) toutes les 48h.`,
limit_reached: true
});
}
// Handle both old single season_number and new season_numbers array
// Database column is INTEGER, so we store a single season number
let seasonParam = null;
if (season_numbers && Array.isArray(season_numbers) && season_numbers.length > 0) {
// If array has one element, store just that number
// If array has multiple elements, store the first one (or 0 for "all seasons")
if (season_numbers.length === 1) {
seasonParam = parseInt(season_numbers[0], 10);
} else {
// Multiple seasons requested - store 0 to indicate "all seasons" or first season
seasonParam = season_numbers.includes(0) ? 0 : parseInt(season_numbers[0], 10);
}
} else if (season_number !== undefined && season_number !== null) {
// Backwards compatibility: single number
seasonParam = parseInt(season_number, 10);
}
// Check if request already exists (check for exact match)
let existingQuery;
let existingParams;
if (seasonParam === null) {
existingQuery = 'SELECT id FROM wishboard_requests WHERE tmdb_id = ? AND media_type = ? AND season_number IS NULL';
existingParams = [tmdb_id, media_type];
} else {
existingQuery = 'SELECT id FROM wishboard_requests WHERE tmdb_id = ? AND media_type = ? AND season_number = ?';
existingParams = [tmdb_id, media_type, seasonParam];
}
const [existing] = await mysqlPool.execute(existingQuery, existingParams);
if (existing.length > 0) {
return res.status(409).json({
error: 'Cette demande existe déjà',
existing_id: existing[0].id
});
}
// Insert new request
const [result] = await mysqlPool.execute(
'INSERT INTO wishboard_requests (user_id, profile_id, tmdb_id, media_type, season_number, status, vote_count) VALUES (?, ?, ?, ?, ?, ?, ?)',
[userId, profileId, tmdb_id, media_type, seasonParam, 'pending', 0]
);
const insertId = result.insertId;
// Record initial status in history
await recordStatusHistory(insertId, 'pending');
res.status(201).json({
id: insertId,
message: 'Demande créée avec succès'
});
} catch (error) {
console.error('Error creating wishboard request:', error);
res.status(500).json({ error: 'Failed to create request' });
}
});
/**
* POST /api/wishboard/:id/vote
* Vote for a request
*/
router.post('/:id/vote', requireAuth, async (req, res) => {
try {
if (TURNSTILE_INVISIBLE_SECRETKEY) {
const { turnstileToken } = req.body || {};
const check = await verifyTurnstileFromRequest(req, turnstileToken, TURNSTILE_INVISIBLE_SECRETKEY);
if (!check.valid) {
return res.status(check.status).json({ error: check.error });
}
}
const requestId = req.params.id;
const userId = req.user.userId;
const profileId = getProfileId(req);
if (!profileId) {
return res.status(400).json({ error: 'Profile ID is required' });
}
const isOwner = await verifyProfileOwnership(userId, req.user.userType, profileId);
if (!isOwner) {
return res.status(403).json({ error: 'Ce profil ne vous appartient pas' });
}
// Check if already voted (dedup by user_id to prevent vote stuffing)
const [existing] = await mysqlPool.execute(
'SELECT id FROM wishboard_votes WHERE request_id = ? AND user_id = ?',
[requestId, userId]
);
if (existing.length > 0) {
return res.status(409).json({ error: 'Vous avez déjà voté pour cette demande' });
}
// Add vote
await mysqlPool.execute(
'INSERT INTO wishboard_votes (request_id, user_id, profile_id) VALUES (?, ?, ?)',
[requestId, userId, profileId]
);
res.json({ message: 'Vote enregistré' });
} catch (error) {
console.error('Error voting:', error);
res.status(500).json({ error: 'Failed to vote' });
}
});
/**
* DELETE /api/wishboard/:id/vote
* Remove vote from a request
*/
router.delete('/:id/vote', requireAuth, async (req, res) => {
try {
if (TURNSTILE_INVISIBLE_SECRETKEY) {
const { turnstileToken } = req.body || {};
const check = await verifyTurnstileFromRequest(req, turnstileToken, TURNSTILE_INVISIBLE_SECRETKEY);
if (!check.valid) {
return res.status(check.status).json({ error: check.error });
}
}
const requestId = req.params.id;
const userId = req.user.userId;
await mysqlPool.execute(
'DELETE FROM wishboard_votes WHERE request_id = ? AND user_id = ?',
[requestId, userId]
);
res.json({ message: 'Vote retiré' });
} catch (error) {
console.error('Error removing vote:', error);
res.status(500).json({ error: 'Failed to remove vote' });
}
});
/**
* GET /api/wishboard/user/requests
* Get current user's requests
*/
router.get('/user/requests', requireAuth, async (req, res) => {
try {
const profileId = getProfileId(req);
if (!profileId) {
return res.status(400).json({ error: 'Profile ID is required' });
}
const isOwner = await verifyProfileOwnership(req.user.userId, req.user.userType, profileId);
if (!isOwner) {
return res.status(403).json({ error: 'Ce profil ne vous appartient pas' });
}
const [requests] = await mysqlPool.execute(
'SELECT * FROM wishboard_requests WHERE profile_id = ? ORDER BY created_at DESC',
[profileId]
);
// Get public notes and vote status for each request
const requestsWithNotes = await Promise.all(requests.map(async (r) => {
const [notes] = await mysqlPool.execute(
'SELECT id, note, is_public, created_at FROM wishboard_notes WHERE request_id = ? AND is_public = 1 ORDER BY created_at DESC',
[r.id]
);
const [voteRows] = await mysqlPool.execute(
'SELECT id FROM wishboard_votes WHERE request_id = ? AND profile_id = ?',
[r.id, profileId]
);
// Get status history
const [statusHistory] = await mysqlPool.execute(
'SELECT status, reason, changed_at FROM wishboard_status_history WHERE request_id = ? ORDER BY changed_at ASC',
[r.id]
);
return { ...r, notes, has_voted: voteRows.length > 0, statusHistory };
}));
res.json({ requests: requestsWithNotes });
} catch (error) {
console.error('Error fetching user requests:', error);
res.status(500).json({ error: 'Failed to fetch requests' });
}
});
/**
* GET /api/wishboard/user/votes
* Get current user's voted requests
*/
router.get('/user/votes', requireAuth, async (req, res) => {
try {
const profileId = getProfileId(req);
if (!profileId) {
return res.status(400).json({ error: 'Profile ID is required' });
}
const isOwner = await verifyProfileOwnership(req.user.userId, req.user.userType, profileId);
if (!isOwner) {
return res.status(403).json({ error: 'Ce profil ne vous appartient pas' });
}
const [requests] = await mysqlPool.execute(
`SELECT r.* FROM wishboard_requests r
INNER JOIN wishboard_votes v ON v.request_id = r.id
WHERE v.profile_id = ? AND r.profile_id != ?
ORDER BY v.created_at DESC`,
[profileId, profileId]
);
const requestsWithVoted = await Promise.all(requests.map(async (r) => {
// Get status history
const [statusHistory] = await mysqlPool.execute(
'SELECT status, reason, changed_at FROM wishboard_status_history WHERE request_id = ? ORDER BY changed_at ASC',
[r.id]
);
return { ...r, has_voted: true, statusHistory };
}));
res.json({ requests: requestsWithVoted });
} catch (error) {
console.error('Error fetching voted requests:', error);
res.status(500).json({ error: 'Failed to fetch votes' });
}
});
// =====================================
// LIMITS ENDPOINT
// =====================================
/**
* GET /api/wishboard/limits
* Get user's request limit status (quota)
*/
router.get('/limits', requireAuth, async (req, res) => {
try {
const profileId = getProfileId(req);
if (!profileId) {
return res.status(400).json({ error: 'Profile ID is required' });
}
const isOwner = await verifyProfileOwnership(req.user.userId, req.user.userType, profileId);
if (!isOwner) {
return res.status(403).json({ error: 'Ce profil ne vous appartient pas' });
}
const limitStatus = await checkLimit(req.user.userId, req.user.userType, profileId, req);
res.json(limitStatus);
} catch (error) {
console.error('Error fetching limits:', error);
res.status(500).json({ error: 'Failed to fetch limits' });
}
});
// =====================================
// ADMIN ENDPOINTS
// =====================================
/**
* GET /api/wishboard/admin
* Admin list with all details
*/
router.get('/admin', requireAuth, requireUploaderOrAdmin, async (req, res) => {
try {
const { search, media_type, status, vip_only, page = '1', limit = '50' } = req.query;
const pageNum = Math.max(1, parseInt(page) || 1);
const limitNum = Math.min(100, Math.max(1, parseInt(limit) || 50));
const offset = (pageNum - 1) * limitNum;
let whereConditions = ['1=1'];
let whereParams = [];
if (search) {
const isNumericSearch = /^\d+$/.test(search.trim());
if (isNumericSearch) {
// Direct TMDB ID or request ID search
const numSearch = parseInt(search);
whereConditions.push('(tmdb_id = ? OR id = ?)');
whereParams.push(numSearch, numSearch);
} else {
// Text search: query TMDB API to find matching IDs
const tmdbIds = await searchTmdbIds(search.trim());
if (tmdbIds.length > 0) {
whereConditions.push(`tmdb_id IN (${tmdbIds.map(() => '?').join(',')})`);
whereParams.push(...tmdbIds);
} else {
// No TMDB results, return empty
return res.json({ requests: [], stats: { total: 0, pending: 0, added: 0, rejected: 0, not_found: 0 }, hasMore: false });
}
}
}
if (media_type && media_type !== 'all') {
whereConditions.push('media_type = ?');
whereParams.push(media_type);
}
if (status && status !== 'all') {
whereConditions.push('status = ?');
whereParams.push(status);
}
const whereClause = whereConditions.join(' AND ');
// Get requests with pagination (fetch one extra to check hasMore)
const [requests] = await mysqlPool.execute(
`SELECT * FROM wishboard_requests WHERE ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
[...whereParams, String(limitNum + 1), String(offset)]
);
const hasMore = requests.length > limitNum;
const paginatedRequests = hasMore ? requests.slice(0, limitNum) : requests;
// Get notes for each request and user info
const requestsWithNotes = await Promise.all(paginatedRequests.map(async (r) => {
const [notes] = await mysqlPool.execute(
'SELECT id, admin_id, note, is_public, created_at FROM wishboard_notes WHERE request_id = ? ORDER BY created_at DESC',
[r.id]
);
// Get status history
const [statusHistory] = await mysqlPool.execute(
'SELECT id, status, reason, changed_at FROM wishboard_status_history WHERE request_id = ? ORDER BY changed_at ASC',
[r.id]
);
// Get user info
let user = { username: 'Inconnu', isVip: false };
try {
// Determine user type (bip39 or oauth)
let userType = 'oauth'; // Default to oauth
try {
await fs.access(path.join(__dirname, 'data', 'users', `bip39-${r.user_id}.json`));
userType = 'bip39';
} catch {
// Keep oauth
}
user = await getUserData(r.user_id, userType, r.profile_id);
} catch (e) {
// Ignore error, keep default
}
return { ...r, notes, statusHistory, user };
}));
// Filter VIP-only after fetching user data
const filteredRequests = vip_only === 'true'
? requestsWithNotes.filter(r => r.user?.isVip)
: requestsWithNotes;
// Get stats
const [statsResult] = await mysqlPool.execute(`
SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN status = 'added' THEN 1 ELSE 0 END) as added,
SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) as rejected,
SUM(CASE WHEN status IN ('not_found', 'not_found_recent') THEN 1 ELSE 0 END) as not_found
FROM wishboard_requests
`);
res.json({
requests: filteredRequests,
hasMore,
stats: {
total: statsResult[0].total || 0,
pending: statsResult[0].pending || 0,
added: statsResult[0].added || 0,
rejected: statsResult[0].rejected || 0,
not_found: statsResult[0].not_found || 0
}
});
} catch (error) {
console.error('Error fetching admin wishboard:', error);
res.status(500).json({ error: 'Failed to fetch admin wishboard' });
}
});
/**
* PUT /api/wishboard/admin/:id/status
* Update request status
*/
router.put('/admin/:id/status', requireAuth, requireUploaderOrAdmin, async (req, res) => {
try {
const requestId = req.params.id;
const { status, reason } = req.body;
const validStatuses = ['pending', 'not_found', 'not_found_recent', 'searching', 'added', 'rejected'];
if (!validStatuses.includes(status)) {
return res.status(400).json({ error: 'Invalid status' });
}
// Get admin info from middleware
const adminId = req.admin ? req.admin.userId : null;
const adminAuthType = req.admin ? (req.admin.userType === 'bip39' ? 'bip-39' : req.admin.userType) : null;
await mysqlPool.execute(
'UPDATE wishboard_requests SET status = ?, updated_at = NOW() WHERE id = ?',
[status, requestId]
);
// Record status history with reason and admin info
const historyId = await recordStatusHistory(requestId, status, reason, adminId, adminAuthType);
res.json({ message: 'Statut mis à jour', historyId });
} catch (error) {
console.error('Error updating status:', error);
res.status(500).json({ error: 'Failed to update status' });
}
});
/**
* PUT /api/wishboard/admin/history/:historyId
* Update status history reason
*/
router.put('/admin/history/:historyId', requireAuth, requireUploaderOrAdmin, async (req, res) => {
try {
const historyId = req.params.historyId;
const { reason } = req.body;
await mysqlPool.execute(
'UPDATE wishboard_status_history SET reason = ? WHERE id = ?',
[reason, historyId]
);
res.json({ message: 'Raison mise à jour' });
} catch (error) {
console.error('Error updating history reason:', error);
res.status(500).json({ error: 'Failed to update reason' });
}
});
/**
* POST /api/wishboard/admin/:id/notes
* Add note to request
*/
router.post('/admin/:id/notes', requireAuth, requireUploaderOrAdmin, async (req, res) => {
try {
const requestId = req.params.id;
const { note, is_public = true } = req.body;
const adminId = req.user.userId;
if (!note || !note.trim()) {
return res.status(400).json({ error: 'Note content is required' });
}
// Check if note already exists for this request
const [existing] = await mysqlPool.execute('SELECT id FROM wishboard_notes WHERE request_id = ?', [requestId]);
let resultId;
if (existing.length > 0) {
// Update existing
await mysqlPool.execute(
'UPDATE wishboard_notes SET note = ?, is_public = ?, admin_id = ?, updated_at = NOW() WHERE request_id = ?',
[note.trim(), is_public ? 1 : 0, adminId, requestId]
);
resultId = existing[0].id;
} else {
// Insert new
const [result] = await mysqlPool.execute(
'INSERT INTO wishboard_notes (request_id, admin_id, note, is_public) VALUES (?, ?, ?, ?)',
[requestId, adminId, note.trim(), is_public ? 1 : 0]
);
resultId = result.insertId;
}
res.status(201).json({
id: resultId,
admin_id: adminId,
note: note.trim(),
is_public: is_public ? 1 : 0,
created_at: new Date().toISOString()
});
} catch (error) {
console.error('Error adding note:', error);
res.status(500).json({ error: 'Failed to add note' });
}
});
/**
* GET /api/wishboard/admin/leaderboard
* Get monthly leaderboard of admins/uploaders by greenlight count
*/
router.get('/admin/leaderboard', requireAuth, requireUploaderOrAdmin, async (req, res) => {
try {
const monthParam = Array.isArray(req.query.month) ? req.query.month[0] : req.query.month;
const monthMatch = typeof monthParam === 'string' && monthParam.trim()
? monthParam.trim().match(/^(\d{4})-(\d{2})$/)
: null;
if (monthParam && (!monthMatch || Number(monthMatch[2]) < 1 || Number(monthMatch[2]) > 12)) {
return res.status(400).json({ error: 'Invalid month format. Expected YYYY-MM' });
}
const targetDate = monthMatch
? new Date(Number(monthMatch[1]), Number(monthMatch[2]) - 1, 1)
: new Date();
const periodStart = new Date(targetDate.getFullYear(), targetDate.getMonth(), 1);
const periodEnd = new Date(targetDate.getFullYear(), targetDate.getMonth() + 1, 1);
const formatSqlDate = (date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
// Get all admins/uploaders who have greenlighted at least one request for the selected month
const [rows] = await mysqlPool.execute(`
SELECT
h.admin_id,
h.admin_auth_type,
COUNT(*) as greenlight_count,
MAX(h.changed_at) as last_greenlight_at
FROM wishboard_status_history h
WHERE h.status = 'added'
AND h.admin_id IS NOT NULL
AND h.changed_at >= ?
AND h.changed_at < ?
GROUP BY h.admin_id, h.admin_auth_type
ORDER BY greenlight_count DESC, last_greenlight_at DESC
`, [formatSqlDate(periodStart), formatSqlDate(periodEnd)]);
// Get admin roles from admins table
const adminIds = rows.map(r => r.admin_id);
let adminRoles = {};
if (adminIds.length > 0) {
const placeholders = adminIds.map(() => '?').join(',');
const [roleRows] = await mysqlPool.execute(
`SELECT user_id, role FROM admins WHERE user_id IN (${placeholders})`,
adminIds
);
for (const r of roleRows) {
adminRoles[r.user_id] = r.role || 'admin';
}
}
// Resolve user data (username, avatar) for each admin via the
// shared helper — prefers OAuth provider identity over the
// generic Movix profile, so we display "Maxou DM" instead of
// "Admin" / "Profil".
const leaderboard = await Promise.all(rows.map(async (row) => {
const identity = await resolveAdminIdentity(row.admin_id, row.admin_auth_type);
return {
admin_id: row.admin_id,
admin_auth_type: row.admin_auth_type,
role: adminRoles[row.admin_id] || 'admin',
username: identity.username,
avatar: identity.avatar,
greenlight_count: row.greenlight_count,
last_greenlight_at: row.last_greenlight_at
};
}));
res.json({
leaderboard,
month: `${periodStart.getFullYear()}-${String(periodStart.getMonth() + 1).padStart(2, '0')}`
});
} catch (error) {
console.error('Error fetching leaderboard:', error);
res.status(500).json({ error: 'Failed to fetch leaderboard' });
}
});
/**
* DELETE /api/wishboard/admin/:id
* Delete a request
*/
router.delete('/admin/:id', requireAuth, requireUploaderOrAdmin, async (req, res) => {
try {
const requestId = req.params.id;
// Delete related votes first
await mysqlPool.execute('DELETE FROM wishboard_votes WHERE request_id = ?', [requestId]);
// Delete related notes
await mysqlPool.execute('DELETE FROM wishboard_notes WHERE request_id = ?', [requestId]);
// Delete request
await mysqlPool.execute('DELETE FROM wishboard_requests WHERE id = ?', [requestId]);
res.json({ message: 'Demande supprimée' });
} catch (error) {
console.error('Error deleting request:', error);
res.status(500).json({ error: 'Failed to delete request' });
}
});
return router;
}
module.exports = { createWishboardRouter };