feat: initial project structure (Model Project) - Backend + Multi-Frontend + Docker

This commit is contained in:
VPS 4 Builder
2026-05-15 10:39:35 +02:00
commit 3beac0e0f2
288 changed files with 78761 additions and 0 deletions
+448
View File
@@ -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