'use strict' const bcrypt = require('bcryptjs') const crypto = require('crypto') const User = require('../models/User') const Claim = require('../models/Claim') const Notification = require('../models/Notification') const NotificationService = require('../services/notificationService') const { queryOne, execute } = require('../database/postgres') const { signUserToken } = require('../middleware/auth') const COOKIE_SECURE = process.env.COOKIE_SECURE === 'true' const COOKIE_OPTS = { httpOnly: true, maxAge: 24 * 60 * 60 * 1000, secure: COOKIE_SECURE, sameSite: 'lax', } // Cookie hint legível pelo JS — permite o frontend evitar chamada desnecessária a /me const HINT_OPTS = { httpOnly: false, maxAge: 24 * 60 * 60 * 1000, secure: COOKIE_SECURE, sameSite: 'lax', } function setUserCookies(res, token) { res.cookie('user_token', token, COOKIE_OPTS) res.cookie('_usr', '1', HINT_OPTS) } const usersController = { // ── Lead (hotspot) ───────────────────────────────────────────────────────── async receiveLead(req, res) { try { const { nome, email, cpf, telefone, ip, source, session_id } = req.body if (!email && !nome) return res.status(400).json({ error: 'Nome ou e-mail é obrigatório' }) const tenantId = req.tenant?.id || 1 const cleanEmail = email ? email.toLowerCase().trim() : null const cleanCpf = cpf ? cpf.replace(/[^\d]+/g, '') : null const cleanTel = telefone ? telefone.replace(/[^\d]+/g, '') : null const clientIp = ip || req.ip || null const displayNome = nome || cleanEmail // Deduplicação: email primeiro (mais confiável), depois telefone let existing = null if (cleanEmail) existing = await User.findByEmail(cleanEmail, tenantId) if (!existing && cleanTel) existing = await User.findByPhone(cleanTel, tenantId) if (existing) { await execute( `UPDATE users SET ip = $1, portal_session_id = $2, timestamp = NOW() WHERE id = $3`, [clientIp, session_id || null, existing.id] ) console.log(`📥 Lead atualizado (${cleanEmail || cleanTel}): id=${existing.id}`) return res.json({ success: true, id: existing.id, updated: true }) } const result = await User.create({ nome: displayNome, email: cleanEmail, cpf: cleanCpf, telefone: cleanTel, ip: clientIp, source: source || 'hotspot', tenant_id: tenantId, portal_session_id: session_id || null, }) console.log('📥 Lead criado:', cleanEmail || displayNome, session_id ? `(session: ${session_id})` : '') return res.json({ success: true, id: result.id ?? result.lastInsertRowid }) } catch (error) { console.error('Erro ao receber lead:', error) return res.status(500).json({ error: 'Erro interno' }) } }, // ── Admin ────────────────────────────────────────────────────────────────── async getAll(req, res) { try { return res.json(await User.findAll(req.tenant?.id)) } catch { return res.status(500).json({ error: 'Erro interno' }) } }, async getStats(req, res) { try { return res.json(await User.getStats(req.tenant?.id)) } catch { return res.status(500).json({ error: 'Erro interno' }) } }, async deleteUser(req, res) { try { await User.deleteById(req.params.id) return res.json({ success: true }) } catch { return res.status(500).json({ error: 'Erro interno' }) } }, async updateUser(req, res) { try { const { nome, email, cpf, telefone, source } = req.body await User.update(req.params.id, { nome, email, cpf, telefone, source }) return res.json({ success: true }) } catch (e) { console.error('Erro ao atualizar usuário:', e) return res.status(500).json({ error: 'Erro interno' }) } }, // ── Cadastro ─────────────────────────────────────────────────────────────── async registerUser(req, res) { try { const { nome, email, cpf, telefone, password } = req.body if (!nome || !email || !cpf || !telefone || !password) { return res.status(400).json({ error: 'Nome, email, CPF, telefone e senha são obrigatórios' }) } if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return res.status(400).json({ error: 'E-mail inválido' }) } if (password.length < 6) { return res.status(400).json({ error: 'Senha deve ter ao menos 6 caracteres' }) } const tenantId = req.tenant?.id || 1 const existing = await User.findByEmail(email, tenantId) if (existing) { return res.status(409).json({ error: 'E-mail já cadastrado. Faça login.' }) } const password_hash = await bcrypt.hash(password, 10) const result = await User.create({ nome, email, cpf, telefone, source: 'web', password_hash, tenant_id: tenantId }) const newId = Number(result.lastInsertRowid ?? result.rows?.[0]?.id) const token = signUserToken({ id: newId, nome, cpf, email }, tenantId) setUserCookies(res, token) NotificationService.notifyWelcome(newId, nome, telefone, cpf).catch(() => {}) return res.status(201).json({ success: true, redirect: '/area-do-usuario' }) } catch (error) { console.error('Erro no cadastro:', error) if (error.code === '23505') return res.status(409).json({ error: 'E-mail já cadastrado.' }) return res.status(500).json({ error: 'Erro interno' }) } }, // ── Login ────────────────────────────────────────────────────────────────── async loginUser(req, res) { try { const { email, password, cpf, telefone } = req.body const tenantId = req.tenant?.id || 1 let user = null if (email && password) { // Fluxo novo: email + senha if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return res.status(400).json({ error: 'E-mail inválido' }) } user = await User.findByEmailAndTenant(email, tenantId) if (!user) return res.status(401).json({ error: 'E-mail não encontrado.' }) if (!user.password_hash) { return res.status(401).json({ error: 'Conta legada. Use CPF e telefone ou migre sua conta.', legacy: true, }) } const ok = await bcrypt.compare(password, user.password_hash) if (!ok) return res.status(401).json({ error: 'Senha incorreta.' }) } else if (cpf && telefone) { // Fallback legado: CPF + telefone (apenas se sem password_hash) user = await User.findByCpfPhone(cpf, telefone, tenantId) if (!user) return res.status(401).json({ error: 'Usuário não encontrado. Verifique os dados ou cadastre-se.' }) if (user.password_hash) { return res.status(401).json({ error: 'Esta conta usa email e senha. Faça login com seu e-mail.' }) } } else { return res.status(400).json({ error: 'Informe email+senha ou CPF+telefone.' }) } const token = signUserToken(user, tenantId) setUserCookies(res, token) if (user.telefone) { NotificationService.notifySecurityLogin(user.id, user.nome, user.telefone, user.cpf).catch(() => {}) } const safeUser = { id: user.id, nome: user.nome, email: user.email, cpf: user.cpf, telefone: user.telefone, whatsapp_optin: user.whatsapp_optin, } return res.json({ success: true, redirect: '/area-do-usuario', user: safeUser }) } catch (error) { console.error('Erro no login:', error) return res.status(500).json({ error: 'Erro interno' }) } }, // ── Esqueci a senha ──────────────────────────────────────────────────────── async forgotPassword(req, res) { try { const { email } = req.body if (!email) return res.status(400).json({ error: 'E-mail obrigatório' }) const tenantId = req.tenant?.id || 1 const user = await User.findByEmailAndTenant(email, tenantId) // Responde sempre 200 para não revelar se o email existe if (!user) return res.json({ ok: true }) const token = crypto.randomBytes(48).toString('hex') const expiresAt = new Date(Date.now() + 60 * 60 * 1000) // 1h await execute( `INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES ($1, $2, $3)`, [user.id, token, expiresAt] ) const appUrl = req.tenant?.app_url || process.env.APP_URL || 'http://localhost:4001' const link = `${appUrl}/redefinir-senha?token=${token}` if (user.telefone) { NotificationService.notifyPasswordReset(user.id, user.nome, user.telefone, user.cpf, link).catch(() => {}) } return res.json({ ok: true }) } catch (e) { console.error('Erro forgot-password:', e) return res.status(500).json({ error: 'Erro interno' }) } }, // ── Redefinir senha ──────────────────────────────────────────────────────── async resetPassword(req, res) { try { const { token, password } = req.body if (!token || !password) return res.status(400).json({ error: 'Token e senha obrigatórios' }) if (password.length < 6) return res.status(400).json({ error: 'Senha deve ter ao menos 6 caracteres' }) const row = await queryOne( `SELECT * FROM password_reset_tokens WHERE token=$1 AND used=FALSE AND expires_at > NOW()`, [token] ) if (!row) return res.status(400).json({ error: 'Token inválido ou expirado' }) const password_hash = await bcrypt.hash(password, 10) await User.updatePassword(row.user_id, password_hash) await execute(`UPDATE password_reset_tokens SET used=TRUE WHERE id=$1`, [row.id]) return res.json({ ok: true }) } catch (e) { console.error('Erro reset-password:', e) return res.status(500).json({ error: 'Erro interno' }) } }, // ── Ativação de lead do hotspot (sem CPF) ───────────────────────────────── async activateUser(req, res) { try { const { email, telefone, nome, password } = req.body if (!email || !telefone || !nome || !password) { return res.status(400).json({ error: 'Nome, e-mail, celular e senha são obrigatórios' }) } if (password.length < 6) return res.status(400).json({ error: 'Senha deve ter ao menos 6 caracteres' }) const tenantId = req.tenant?.id || 1 const cleanEmail = email.toLowerCase().trim() const cleanTel = telefone.replace(/\D/g, '') // Busca o lead pelo email (ou pelo telefone se email não bater) let user = await User.findByEmailAndTenant(cleanEmail, tenantId) if (!user) user = await User.findByPhone(cleanTel, tenantId) if (!user) return res.status(404).json({ error: 'Cadastro não encontrado. Conecte-se ao WiFi primeiro.' }) if (user.password_hash) return res.status(409).json({ error: 'Conta já ativada. Faça login normalmente.' }) const password_hash = await bcrypt.hash(password, 10) await execute( `UPDATE users SET nome=$1, email=$2, password_hash=$3 WHERE id=$4`, [nome, cleanEmail, password_hash, user.id] ) const token = signUserToken({ ...user, nome, email: cleanEmail }, tenantId) setUserCookies(res, token) NotificationService.notifyWelcome(user.id, nome, user.telefone || cleanTel, user.cpf).catch(() => {}) return res.status(200).json({ success: true, redirect: '/area-do-usuario' }) } catch (e) { console.error('Erro activate:', e) return res.status(500).json({ error: 'Erro interno' }) } }, // ── Migração de conta legada ─────────────────────────────────────────────── async migrateAccount(req, res) { try { const { cpf, telefone, email, password } = req.body if (!cpf || !telefone || !email || !password) { return res.status(400).json({ error: 'CPF, telefone, novo email e senha são obrigatórios' }) } if (password.length < 6) return res.status(400).json({ error: 'Senha deve ter ao menos 6 caracteres' }) const tenantId = req.tenant?.id || 1 const user = await User.findByCpfPhone(cpf, telefone, tenantId) if (!user) return res.status(401).json({ error: 'Dados não encontrados.' }) if (user.password_hash) return res.status(400).json({ error: 'Conta já migrada. Faça login com e-mail e senha.' }) const emailExists = await User.findByEmailAndTenant(email, tenantId) if (emailExists && emailExists.id !== user.id) { return res.status(409).json({ error: 'Este e-mail já está em uso por outra conta.' }) } const password_hash = await bcrypt.hash(password, 10) await execute( `UPDATE users SET email=$1, password_hash=$2 WHERE id=$3`, [email, password_hash, user.id] ) const token = signUserToken({ ...user, email }, tenantId) setUserCookies(res, token) return res.json({ success: true, redirect: '/area-do-usuario' }) } catch (e) { console.error('Erro migrate:', e) return res.status(500).json({ error: 'Erro interno' }) } }, // ── Dados do usuário logado ──────────────────────────────────────────────── async getMe(req, res) { try { if (!req.user) return res.status(401).json({ error: 'Não autorizado' }) const user = await queryOne( 'SELECT id, nome, email, cpf, telefone, whatsapp_optin FROM users WHERE id=$1', [req.user.id] ) if (!user) return res.status(404).json({ error: 'Usuário não encontrado' }) return res.json(user) } catch { return res.status(500).json({ error: 'Erro interno' }) } }, async updateMe(req, res) { try { if (!req.user) return res.status(401).json({ error: 'Não autorizado' }) const { nome, email, telefone } = req.body if (!nome?.trim()) return res.status(400).json({ error: 'Nome é obrigatório' }) const updated = await queryOne( `UPDATE users SET nome=$1, email=$2, telefone=$3, updated_at=NOW() WHERE id=$4 RETURNING id, nome, email, cpf, telefone, whatsapp_optin`, [nome.trim(), email?.trim() || null, telefone?.trim() || null, req.user.id] ) return res.json(updated) } catch { return res.status(500).json({ error: 'Erro interno' }) } }, async updateOptin(req, res) { try { if (!req.user) return res.status(401).json({ error: 'Não autorizado' }) // Aceita tanto { whatsapp_optin } (front-end) quanto { optin } (legado) const optin = req.body.whatsapp_optin ?? req.body.optin if (typeof optin !== 'boolean') return res.status(400).json({ error: 'Campo "whatsapp_optin" (boolean) obrigatório' }) await execute('UPDATE users SET whatsapp_optin=$1 WHERE id=$2', [optin, req.user.id]) return res.json({ ok: true, whatsapp_optin: optin }) } catch { return res.status(500).json({ error: 'Erro interno' }) } }, // ── Notificações ─────────────────────────────────────────────────────────── async getMyNotifications(req, res) { try { if (!req.user?.cpf) return res.status(401).json({ error: 'Não autorizado' }) const notifs = await Notification.getUserNotifications(req.user.cpf) const unreadCount = await Notification.getUnreadCount(req.user.cpf) return res.json({ unread: unreadCount, notifications: notifs.rows || notifs }) } catch { res.status(500).json({ error: 'Erro interno' }) } }, async markNotificationRead(req, res) { try { if (!req.user?.cpf) return res.status(401).json({ error: 'Não autorizado' }) await Notification.markAsRead(req.params.id, req.user.cpf) return res.json({ success: true }) } catch { res.status(500).json({ error: 'Erro interno' }) } }, // ── Resgates ─────────────────────────────────────────────────────────────── async getMyClaims(req, res) { try { if (!req.user?.cpf) return res.status(401).json({ error: 'Não autorizado' }) const claims = await Claim.getUserClaims(req.user.cpf) return res.json(claims.rows || claims) } catch { res.status(500).json({ error: 'Erro interno' }) } }, async createClaim(req, res) { try { if (!req.user?.cpf) return res.status(401).json({ error: 'Não autorizado' }) const { raffle_id, ticket_id, delivery_address, delivery_notes } = req.body if (!raffle_id || !ticket_id) return res.status(400).json({ error: 'Faltam dados da rifa' }) const claim = await Claim.create({ raffle_id, ticket_id, user_cpf: req.user.cpf, delivery_address, delivery_notes }) const claimId = claim.rows?.[0]?.id ?? claim.lastInsertRowid const details = await Claim.getClaimDetails(claimId) if (details) { const u = await queryOne('SELECT telefone, nome FROM users WHERE cpf=$1', [req.user.cpf]) NotificationService.notifyClaimCreated(u?.telefone, req.user.cpf, u?.nome, details.raffle_titulo, details.ticket_numero).catch(() => {}) } return res.json({ success: true, id: claimId }) } catch { res.status(500).json({ error: 'Erro interno' }) } }, /** * Captive portal: verifica se o email/telefone já tem cadastro completo. * Usado pelo portal local antes de redirecionar para a landing page. * Protegido por X-Portal-Key — não expõe dados sensíveis. */ async checkPortalUser(req, res) { try { const { email, telefone } = req.body if (!email && !telefone) return res.json({ found: false }) const tenantId = req.tenant?.id || 1 let user = null if (email) { user = await User.findByEmailAndTenant(email.toLowerCase().trim(), tenantId) } if (!user && telefone) { const cleanTel = telefone.replace(/\D/g, '') // busca só por telefone quando não tem email ou email não bateu user = await queryOne( `SELECT id, nome, email FROM users WHERE REGEXP_REPLACE(telefone,'[^0-9]','','g') = $1 AND tenant_id = $2 LIMIT 1`, [cleanTel, tenantId] ) } if (!user) return res.json({ found: false }) // needs_password: lead do hotspot ainda não criou senha — site pode oferecer ativação return res.json({ found: true, nome: user.nome, needs_password: !user.password_hash }) } catch (err) { console.error('checkPortalUser error:', err.message) // Em caso de erro, retorna found:false para não bloquear o portal return res.json({ found: false }) } }, } module.exports = usersController