add landing and subscription plans
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
120
web/backend/config/subscriptionPlans.js
Normal file
120
web/backend/config/subscriptionPlans.js
Normal file
@@ -0,0 +1,120 @@
|
||||
// Planes de suscripción con sus límites y precios
|
||||
export const SUBSCRIPTION_PLANS = {
|
||||
free: {
|
||||
id: 'free',
|
||||
name: 'Gratis',
|
||||
description: 'Perfecto para empezar',
|
||||
price: {
|
||||
monthly: 0,
|
||||
yearly: 0,
|
||||
},
|
||||
limits: {
|
||||
maxWorkers: 2, // Número máximo de búsquedas/workers
|
||||
maxNotificationsPerDay: 50,
|
||||
platforms: ['wallapop'], // Solo Wallapop
|
||||
},
|
||||
features: [
|
||||
'Hasta 2 búsquedas simultáneas',
|
||||
'Solo Wallapop',
|
||||
'50 notificaciones por día',
|
||||
'Soporte por email',
|
||||
],
|
||||
},
|
||||
basic: {
|
||||
id: 'basic',
|
||||
name: 'Básico',
|
||||
description: 'Para usuarios ocasionales',
|
||||
price: {
|
||||
monthly: 9.99,
|
||||
yearly: 99.99, // ~17% descuento
|
||||
},
|
||||
limits: {
|
||||
maxWorkers: 5,
|
||||
maxNotificationsPerDay: 200,
|
||||
platforms: ['wallapop', 'vinted'],
|
||||
},
|
||||
features: [
|
||||
'Hasta 5 búsquedas simultáneas',
|
||||
'Wallapop y Vinted',
|
||||
'200 notificaciones por día',
|
||||
'Soporte prioritario',
|
||||
'Sin límite de favoritos',
|
||||
],
|
||||
},
|
||||
pro: {
|
||||
id: 'pro',
|
||||
name: 'Pro',
|
||||
description: 'Para usuarios avanzados',
|
||||
price: {
|
||||
monthly: 19.99,
|
||||
yearly: 199.99, // ~17% descuento
|
||||
},
|
||||
limits: {
|
||||
maxWorkers: 15,
|
||||
maxNotificationsPerDay: 1000,
|
||||
platforms: ['wallapop', 'vinted', 'buyee'],
|
||||
},
|
||||
features: [
|
||||
'Hasta 15 búsquedas simultáneas',
|
||||
'Todas las plataformas',
|
||||
'1000 notificaciones por día',
|
||||
'Soporte prioritario 24/7',
|
||||
'API access',
|
||||
'Webhooks personalizados',
|
||||
],
|
||||
},
|
||||
enterprise: {
|
||||
id: 'enterprise',
|
||||
name: 'Enterprise',
|
||||
description: 'Para equipos y uso intensivo',
|
||||
price: {
|
||||
monthly: 49.99,
|
||||
yearly: 499.99, // ~17% descuento
|
||||
},
|
||||
limits: {
|
||||
maxWorkers: -1, // Ilimitado
|
||||
maxNotificationsPerDay: -1, // Ilimitado
|
||||
platforms: ['wallapop', 'vinted', 'buyee'], // Todas
|
||||
},
|
||||
features: [
|
||||
'Búsquedas ilimitadas',
|
||||
'Notificaciones ilimitadas',
|
||||
'Todas las plataformas',
|
||||
'Soporte dedicado',
|
||||
'API completa',
|
||||
'Webhooks personalizados',
|
||||
'Gestión de múltiples usuarios',
|
||||
'Estadísticas avanzadas',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Obtener plan por ID
|
||||
export function getPlan(planId) {
|
||||
return SUBSCRIPTION_PLANS[planId] || SUBSCRIPTION_PLANS.free;
|
||||
}
|
||||
|
||||
// Verificar si un plan tiene una característica
|
||||
export function hasFeature(planId, feature) {
|
||||
const plan = getPlan(planId);
|
||||
return plan.features.includes(feature);
|
||||
}
|
||||
|
||||
// Verificar si un plan tiene acceso a una plataforma
|
||||
export function hasPlatformAccess(planId, platform) {
|
||||
const plan = getPlan(planId);
|
||||
return plan.limits.platforms.includes(platform) || plan.limits.platforms.length === 0;
|
||||
}
|
||||
|
||||
// Obtener límite de workers para un plan
|
||||
export function getMaxWorkers(planId) {
|
||||
const plan = getPlan(planId);
|
||||
return plan.limits.maxWorkers;
|
||||
}
|
||||
|
||||
// Obtener límite de notificaciones diarias
|
||||
export function getMaxNotificationsPerDay(planId) {
|
||||
const plan = getPlan(planId);
|
||||
return plan.limits.maxNotificationsPerDay;
|
||||
}
|
||||
|
||||
133
web/backend/middlewares/subscriptionLimits.js
Normal file
133
web/backend/middlewares/subscriptionLimits.js
Normal file
@@ -0,0 +1,133 @@
|
||||
import { getUser, getUserSubscription, getWorkerCount } from '../services/mongodb.js';
|
||||
import { getPlan, getMaxWorkers, hasPlatformAccess } from '../config/subscriptionPlans.js';
|
||||
|
||||
// Middleware para verificar límites de suscripción
|
||||
export async function checkSubscriptionLimits(req, res, next) {
|
||||
try {
|
||||
const username = req.user?.username;
|
||||
|
||||
if (!username) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
// Obtener usuario y suscripción
|
||||
const user = await getUser(username);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'Usuario no encontrado' });
|
||||
}
|
||||
|
||||
// Si es admin, no aplicar límites
|
||||
if (user.role === 'admin') {
|
||||
return next();
|
||||
}
|
||||
|
||||
const subscription = await getUserSubscription(username);
|
||||
const planId = subscription?.planId || 'free';
|
||||
const plan = getPlan(planId);
|
||||
|
||||
// Añadir información del plan al request para uso posterior
|
||||
req.userPlan = {
|
||||
planId,
|
||||
plan,
|
||||
subscription,
|
||||
};
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Error verificando límites de suscripción:', error);
|
||||
res.status(500).json({ error: 'Error verificando límites de suscripción' });
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware para verificar límite de workers
|
||||
export async function checkWorkerLimit(req, res, next) {
|
||||
try {
|
||||
const username = req.user?.username;
|
||||
const userPlan = req.userPlan;
|
||||
|
||||
if (!userPlan) {
|
||||
// Si no hay userPlan, ejecutar checkSubscriptionLimits primero
|
||||
await checkSubscriptionLimits(req, res, () => {});
|
||||
if (res.headersSent) return;
|
||||
}
|
||||
|
||||
const planId = req.userPlan.planId;
|
||||
const maxWorkers = getMaxWorkers(planId);
|
||||
|
||||
// Si es ilimitado (-1), permitir
|
||||
if (maxWorkers === -1) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Obtener número actual de workers
|
||||
const currentWorkerCount = await getWorkerCount(username);
|
||||
|
||||
// Si estamos actualizando workers, verificar el nuevo número
|
||||
if (req.method === 'PUT' && req.body?.items) {
|
||||
const newWorkerCount = (req.body.items || []).filter(
|
||||
(item, index) => !(req.body.disabled || []).includes(index)
|
||||
).length;
|
||||
|
||||
if (newWorkerCount > maxWorkers) {
|
||||
return res.status(403).json({
|
||||
error: 'Límite de búsquedas excedido',
|
||||
message: `Tu plan actual (${req.userPlan.plan.name}) permite hasta ${maxWorkers} búsquedas simultáneas. Estás intentando crear ${newWorkerCount}.`,
|
||||
currentPlan: planId,
|
||||
maxWorkers,
|
||||
currentCount: currentWorkerCount,
|
||||
requestedCount: newWorkerCount,
|
||||
});
|
||||
}
|
||||
} else if (currentWorkerCount >= maxWorkers) {
|
||||
// Si ya alcanzó el límite y está intentando crear más
|
||||
return res.status(403).json({
|
||||
error: 'Límite de búsquedas alcanzado',
|
||||
message: `Tu plan actual (${req.userPlan.plan.name}) permite hasta ${maxWorkers} búsquedas simultáneas.`,
|
||||
currentPlan: planId,
|
||||
maxWorkers,
|
||||
currentCount: currentWorkerCount,
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Error verificando límite de workers:', error);
|
||||
res.status(500).json({ error: 'Error verificando límite de workers' });
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware para verificar acceso a plataforma
|
||||
export async function checkPlatformAccess(req, res, next) {
|
||||
try {
|
||||
const platform = req.body?.platform || req.query?.platform;
|
||||
|
||||
if (!platform) {
|
||||
return next(); // Si no hay plataforma especificada, continuar
|
||||
}
|
||||
|
||||
const userPlan = req.userPlan;
|
||||
|
||||
if (!userPlan) {
|
||||
await checkSubscriptionLimits(req, res, () => {});
|
||||
if (res.headersSent) return;
|
||||
}
|
||||
|
||||
const planId = req.userPlan.planId;
|
||||
|
||||
if (!hasPlatformAccess(planId, platform)) {
|
||||
return res.status(403).json({
|
||||
error: 'Plataforma no disponible en tu plan',
|
||||
message: `La plataforma "${platform}" no está disponible en tu plan actual (${req.userPlan.plan.name}).`,
|
||||
currentPlan: planId,
|
||||
requestedPlatform: platform,
|
||||
availablePlatforms: req.userPlan.plan.limits.platforms,
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Error verificando acceso a plataforma:', error);
|
||||
res.status(500).json({ error: 'Error verificando acceso a plataforma' });
|
||||
}
|
||||
}
|
||||
|
||||
208
web/backend/routes/subscription.js
Normal file
208
web/backend/routes/subscription.js
Normal file
@@ -0,0 +1,208 @@
|
||||
import express from 'express';
|
||||
import { basicAuthMiddleware } from '../middlewares/auth.js';
|
||||
import { adminAuthMiddleware } from '../middlewares/adminAuth.js';
|
||||
import { getUser, getUserSubscription, updateUserSubscription, getWorkerCount } from '../services/mongodb.js';
|
||||
import { SUBSCRIPTION_PLANS, getPlan } from '../config/subscriptionPlans.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Obtener planes disponibles (público)
|
||||
router.get('/plans', (req, res) => {
|
||||
try {
|
||||
const plans = Object.values(SUBSCRIPTION_PLANS).map(plan => ({
|
||||
id: plan.id,
|
||||
name: plan.name,
|
||||
description: plan.description,
|
||||
price: plan.price,
|
||||
limits: {
|
||||
maxWorkers: plan.limits.maxWorkers,
|
||||
maxNotificationsPerDay: plan.limits.maxNotificationsPerDay,
|
||||
platforms: plan.limits.platforms,
|
||||
},
|
||||
features: plan.features,
|
||||
}));
|
||||
|
||||
res.json({ plans });
|
||||
} catch (error) {
|
||||
console.error('Error obteniendo planes:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Obtener suscripción del usuario actual
|
||||
router.get('/me', basicAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const username = req.user.username;
|
||||
const subscription = await getUserSubscription(username);
|
||||
const user = await getUser(username);
|
||||
const workerCount = await getWorkerCount(username);
|
||||
|
||||
const planId = subscription?.planId || 'free';
|
||||
const plan = getPlan(planId);
|
||||
|
||||
res.json({
|
||||
subscription: {
|
||||
planId,
|
||||
plan: {
|
||||
id: plan.id,
|
||||
name: plan.name,
|
||||
description: plan.description,
|
||||
price: plan.price,
|
||||
limits: plan.limits,
|
||||
features: plan.features,
|
||||
},
|
||||
status: subscription?.status || 'active',
|
||||
currentPeriodStart: subscription?.currentPeriodStart || user?.createdAt,
|
||||
currentPeriodEnd: subscription?.currentPeriodEnd || null,
|
||||
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd || false,
|
||||
},
|
||||
usage: {
|
||||
workers: workerCount,
|
||||
maxWorkers: plan.limits.maxWorkers === -1 ? 'Ilimitado' : plan.limits.maxWorkers,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error obteniendo suscripción:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Actualizar suscripción (requiere admin o para el propio usuario en caso de cancelación)
|
||||
router.put('/me', basicAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const username = req.user.username;
|
||||
const { planId, status, cancelAtPeriodEnd } = req.body;
|
||||
|
||||
if (!planId) {
|
||||
return res.status(400).json({ error: 'planId es requerido' });
|
||||
}
|
||||
|
||||
// Verificar que el plan existe
|
||||
const plan = getPlan(planId);
|
||||
if (!plan) {
|
||||
return res.status(400).json({ error: 'Plan no válido' });
|
||||
}
|
||||
|
||||
// Solo permitir actualizar a plan gratuito o cancelar suscripción
|
||||
// Para actualizar a planes de pago, se requiere integración con pasarela de pago
|
||||
if (planId !== 'free' && !req.user.role === 'admin') {
|
||||
return res.status(403).json({
|
||||
error: 'Para actualizar a un plan de pago, contacta con soporte o usa la pasarela de pago'
|
||||
});
|
||||
}
|
||||
|
||||
const subscription = await getUserSubscription(username);
|
||||
|
||||
// Calcular fechas del período
|
||||
const now = new Date();
|
||||
let currentPeriodStart = subscription?.currentPeriodStart || now;
|
||||
let currentPeriodEnd = null;
|
||||
|
||||
if (planId !== 'free') {
|
||||
// Para planes de pago, establecer período mensual o anual según corresponda
|
||||
// Por ahora, asumimos mensual (30 días)
|
||||
currentPeriodEnd = new Date(now);
|
||||
currentPeriodEnd.setMonth(currentPeriodEnd.getMonth() + 1);
|
||||
}
|
||||
|
||||
await updateUserSubscription(username, {
|
||||
planId,
|
||||
status: status || 'active',
|
||||
currentPeriodStart,
|
||||
currentPeriodEnd,
|
||||
cancelAtPeriodEnd: cancelAtPeriodEnd || false,
|
||||
});
|
||||
|
||||
console.log(`✅ Suscripción actualizada para ${username}: ${planId}`);
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Suscripción actualizada correctamente',
|
||||
subscription: {
|
||||
planId,
|
||||
status: status || 'active',
|
||||
currentPeriodStart,
|
||||
currentPeriodEnd,
|
||||
cancelAtPeriodEnd: cancelAtPeriodEnd || false,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error actualizando suscripción:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Obtener suscripción de cualquier usuario (solo admin)
|
||||
router.get('/:username', basicAuthMiddleware, adminAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { username } = req.params;
|
||||
const subscription = await getUserSubscription(username);
|
||||
const user = await getUser(username);
|
||||
const workerCount = await getWorkerCount(username);
|
||||
|
||||
const planId = subscription?.planId || 'free';
|
||||
const plan = getPlan(planId);
|
||||
|
||||
res.json({
|
||||
subscription: {
|
||||
planId,
|
||||
plan: {
|
||||
id: plan.id,
|
||||
name: plan.name,
|
||||
description: plan.description,
|
||||
price: plan.price,
|
||||
limits: plan.limits,
|
||||
features: plan.features,
|
||||
},
|
||||
status: subscription?.status || 'active',
|
||||
currentPeriodStart: subscription?.currentPeriodStart || user?.createdAt,
|
||||
currentPeriodEnd: subscription?.currentPeriodEnd || null,
|
||||
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd || false,
|
||||
},
|
||||
usage: {
|
||||
workers: workerCount,
|
||||
maxWorkers: plan.limits.maxWorkers === -1 ? 'Ilimitado' : plan.limits.maxWorkers,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error obteniendo suscripción:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Actualizar suscripción de cualquier usuario (solo admin)
|
||||
router.put('/:username', basicAuthMiddleware, adminAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { username } = req.params;
|
||||
const { planId, status, currentPeriodStart, currentPeriodEnd, cancelAtPeriodEnd } = req.body;
|
||||
|
||||
if (!planId) {
|
||||
return res.status(400).json({ error: 'planId es requerido' });
|
||||
}
|
||||
|
||||
// Verificar que el plan existe
|
||||
const plan = getPlan(planId);
|
||||
if (!plan) {
|
||||
return res.status(400).json({ error: 'Plan no válido' });
|
||||
}
|
||||
|
||||
await updateUserSubscription(username, {
|
||||
planId,
|
||||
status: status || 'active',
|
||||
currentPeriodStart: currentPeriodStart ? new Date(currentPeriodStart) : new Date(),
|
||||
currentPeriodEnd: currentPeriodEnd ? new Date(currentPeriodEnd) : null,
|
||||
cancelAtPeriodEnd: cancelAtPeriodEnd || false,
|
||||
});
|
||||
|
||||
console.log(`✅ Suscripción actualizada para ${username}: ${planId} por admin ${req.user.username}`);
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Suscripción de ${username} actualizada correctamente`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error actualizando suscripción:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -2,6 +2,7 @@ import express from 'express';
|
||||
import { basicAuthMiddleware } from '../middlewares/auth.js';
|
||||
import { broadcast } from '../services/websocket.js';
|
||||
import { getWorkers, setWorkers, getDB } from '../services/mongodb.js';
|
||||
import { checkSubscriptionLimits, checkWorkerLimit, checkPlatformAccess } from '../middlewares/subscriptionLimits.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -23,7 +24,7 @@ router.get('/', basicAuthMiddleware, async (req, res) => {
|
||||
});
|
||||
|
||||
// Actualizar workers del usuario autenticado (requiere autenticación)
|
||||
router.put('/', basicAuthMiddleware, async (req, res) => {
|
||||
router.put('/', basicAuthMiddleware, checkSubscriptionLimits, checkWorkerLimit, checkPlatformAccess, async (req, res) => {
|
||||
try {
|
||||
const db = getDB();
|
||||
if (!db) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import telegramRouter from './routes/telegram.js';
|
||||
import pushRouter from './routes/push.js';
|
||||
import usersRouter from './routes/users.js';
|
||||
import adminRouter from './routes/admin.js';
|
||||
import subscriptionRouter from './routes/subscription.js';
|
||||
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
@@ -49,6 +50,7 @@ app.use('/api/telegram', telegramRouter);
|
||||
app.use('/api/push', pushRouter);
|
||||
app.use('/api/users', usersRouter);
|
||||
app.use('/api/admin', adminRouter);
|
||||
app.use('/api/subscription', subscriptionRouter);
|
||||
|
||||
// Inicializar servidor
|
||||
async function startServer() {
|
||||
|
||||
@@ -1277,6 +1277,104 @@ export async function updateArticleFavorite(platform, id, is_favorite, username)
|
||||
}
|
||||
}
|
||||
|
||||
// Funciones para suscripciones
|
||||
export async function getUserSubscription(username) {
|
||||
if (!db) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const usersCollection = db.collection('users');
|
||||
const user = await usersCollection.findOne({ username });
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Si no tiene suscripción, retornar plan gratuito por defecto
|
||||
if (!user.subscription) {
|
||||
return {
|
||||
planId: 'free',
|
||||
status: 'active',
|
||||
currentPeriodStart: user.createdAt || new Date(),
|
||||
currentPeriodEnd: null, // Plan gratuito no expira
|
||||
cancelAtPeriodEnd: false,
|
||||
};
|
||||
}
|
||||
|
||||
return user.subscription;
|
||||
} catch (error) {
|
||||
console.error(`Error obteniendo suscripción de ${username}:`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUserSubscription(username, subscriptionData) {
|
||||
if (!db) {
|
||||
throw new Error('MongoDB no está disponible');
|
||||
}
|
||||
|
||||
try {
|
||||
const usersCollection = db.collection('users');
|
||||
|
||||
// Verificar que el usuario existe
|
||||
const user = await usersCollection.findOne({ username });
|
||||
if (!user) {
|
||||
throw new Error(`Usuario ${username} no existe`);
|
||||
}
|
||||
|
||||
// Actualizar suscripción
|
||||
await usersCollection.updateOne(
|
||||
{ username },
|
||||
{
|
||||
$set: {
|
||||
subscription: {
|
||||
planId: subscriptionData.planId || 'free',
|
||||
status: subscriptionData.status || 'active',
|
||||
currentPeriodStart: subscriptionData.currentPeriodStart || new Date(),
|
||||
currentPeriodEnd: subscriptionData.currentPeriodEnd || null,
|
||||
cancelAtPeriodEnd: subscriptionData.cancelAtPeriodEnd || false,
|
||||
stripeCustomerId: subscriptionData.stripeCustomerId || null,
|
||||
stripeSubscriptionId: subscriptionData.stripeSubscriptionId || null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error actualizando suscripción de ${username}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWorkerCount(username) {
|
||||
if (!db) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const workersCollection = db.collection('workers');
|
||||
const workersData = await workersCollection.findOne({ username });
|
||||
|
||||
if (!workersData || !workersData.items) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Contar solo workers activos (no deshabilitados)
|
||||
const activeWorkers = (workersData.items || []).filter(
|
||||
(item, index) => !(workersData.disabled || []).includes(index)
|
||||
);
|
||||
|
||||
return activeWorkers.length;
|
||||
} catch (error) {
|
||||
console.error(`Error contando workers de ${username}:`, error.message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Cerrar conexión
|
||||
export async function closeMongoDB() {
|
||||
if (mongoClient) {
|
||||
|
||||
@@ -139,7 +139,7 @@ function parseUserAgent(userAgent) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera un fingerprint desde el frontend (cuando se envía desde el cliente)
|
||||
* Genera un fingerprint desde el dashboard (cuando se envía desde el cliente)
|
||||
* @param {string} fingerprintHash - Hash del fingerprint generado en el cliente
|
||||
* @param {Object} deviceInfo - Información del dispositivo del cliente
|
||||
* @param {Object} req - Request object de Express
|
||||
|
||||
Reference in New Issue
Block a user