feat: initial project structure (Model Project) - Backend + Multi-Frontend + Docker
This commit is contained in:
@@ -0,0 +1,448 @@
|
||||
'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
|
||||
Reference in New Issue
Block a user