From 3d276434fcefcf448ad3cb5bbdd7d9fbb876f57a Mon Sep 17 00:00:00 2001 From: marsalva Date: Sat, 7 Feb 2026 18:58:53 +0000 Subject: [PATCH] Actualizar server.js --- server.js | 336 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 302 insertions(+), 34 deletions(-) diff --git a/server.js b/server.js index 99196f9..72b4310 100644 --- a/server.js +++ b/server.js @@ -1,52 +1,320 @@ import express from "express"; import cors from "cors"; +import bcrypt from "bcryptjs"; +import jwt from "jsonwebtoken"; +import pg from "pg"; + +const { Pool } = pg; const app = express(); app.use(cors()); app.use(express.json()); -// --- “BD” en memoria (se borra al redeploy/restart) --- -const services = []; -let nextId = 1; +// ========================= +// ENV (Coolify variables) +// ========================= +const { + DATABASE_URL, + JWT_SECRET, + EVOLUTION_BASE_URL, + EVOLUTION_API_KEY, + EVOLUTION_INSTANCE, + EVOLUTION_SENDER_NUMBER +} = process.env; + +if (!DATABASE_URL) console.warn("⚠️ DATABASE_URL no definido"); +if (!JWT_SECRET) console.warn("⚠️ JWT_SECRET no definido"); + +// ========================= +// DB +// ========================= +const pool = new Pool({ + connectionString: DATABASE_URL, + ssl: DATABASE_URL?.includes("localhost") ? false : { rejectUnauthorized: false } +}); + +// ========================= +// Helpers +// ========================= +function normalizePhone(phone) { + // Ajusta a tu gusto (E.164). Para España: +34XXXXXXXXX + let p = String(phone || "").trim().replace(/\s+/g, ""); + if (!p) return ""; + if (!p.startsWith("+")) { + // si meten 600..., asumimos España + if (/^\d{9}$/.test(p)) p = "+34" + p; + } + return p; +} + +function genCode6() { + // 6 dígitos + return String(Math.floor(100000 + Math.random() * 900000)); +} + +function signToken(user) { + return jwt.sign( + { sub: user.id, phone: user.phone, email: user.email }, + JWT_SECRET, + { expiresIn: "30d" } + ); +} + +function authMiddleware(req, res, next) { + const h = req.headers.authorization || ""; + const token = h.startsWith("Bearer ") ? h.slice(7) : ""; + if (!token) return res.status(401).json({ ok: false, error: "No token" }); + + try { + const payload = jwt.verify(token, JWT_SECRET); + req.user = payload; + next(); + } catch { + return res.status(401).json({ ok: false, error: "Token inválido" }); + } +} + +// Rate-limit muy simple en memoria (para no freír el WhatsApp) +const lastSendByPhone = new Map(); +function canSendCode(phone) { + const now = Date.now(); + const last = lastSendByPhone.get(phone) || 0; + // 1 envío cada 60s + if (now - last < 60_000) return false; + lastSendByPhone.set(phone, now); + return true; +} + +// ========================= +// Evolution API (WhatsApp) +// ========================= +async function sendWhatsAppCode(phone, code) { + if (!EVOLUTION_BASE_URL || !EVOLUTION_API_KEY || !EVOLUTION_INSTANCE) { + console.warn("⚠️ Evolution API env no configuradas, no envío WhatsApp."); + return { ok: false, skipped: true }; + } + + // OJO: el endpoint exacto puede variar según tu Evolution. + // Ajustamos a un patrón típico: /message/sendText/{instance} + const url = `${EVOLUTION_BASE_URL.replace(/\/$/, "")}/message/sendText/${encodeURIComponent(EVOLUTION_INSTANCE)}`; + + const payload = { + number: phone, // normalmente en formato +34... + text: `Tu código de verificación es: ${code}\nCaduca en 10 minutos.` + }; + + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "apikey": EVOLUTION_API_KEY + }, + body: JSON.stringify(payload) + }); + + const text = await res.text(); + let data; + try { data = JSON.parse(text); } catch { data = text; } + + if (!res.ok) { + console.error("❌ Evolution send failed:", res.status, data); + return { ok: false, status: res.status, data }; + } + return { ok: true, data }; +} + +// ========================= +// Health +// ========================= +app.get("/health", async (req, res) => { + try { + const r = await pool.query("select now() as now"); + res.json({ ok: true, db: true, now: r.rows[0].now }); + } catch (e) { + res.json({ ok: false, db: false, error: String(e?.message || e) }); + } +}); app.get("/", (req, res) => res.send("IntegraRepara API OK")); -app.get("/health", (req, res) => { - res.json({ ok: true, db: false, now: new Date().toISOString() }); -}); +// ========================= +// AUTH +// ========================= -// GET /services -app.get("/services", (req, res) => { - const list = [...services].sort((a, b) => b.id - a.id); - res.json({ ok: true, count: list.length, services: list }); -}); +// Registro: crea user + manda código WhatsApp +app.post("/auth/register", async (req, res) => { + try { + const { + fullName, + phone, + address, + dni, + email, + password + } = req.body || {}; -// POST /services -app.post("/services", (req, res) => { - // Tu HTML manda clientName, aquí lo acepto - const { title, client, clientName, phone, address, notes } = req.body || {}; - const clientFinal = client ?? clientName; + const p = normalizePhone(phone); - if (!title || !clientFinal) { - return res.status(400).json({ - ok: false, - error: "Faltan campos obligatorios: title y client (o clientName)", - }); + if (!fullName || !p || !address || !dni || !email || !password) { + return res.status(400).json({ ok: false, error: "Faltan campos obligatorios" }); + } + + if (!canSendCode(p)) { + return res.status(429).json({ ok: false, error: "Espera 1 minuto antes de pedir otro código" }); + } + + const passwordHash = await bcrypt.hash(String(password), 10); + + // intenta crear usuario + let user; + try { + const q = await pool.query( + `insert into users (full_name, phone, address, dni, email, password_hash, is_verified) + values ($1,$2,$3,$4,$5,$6,false) + returning id, full_name, phone, email, is_verified`, + [String(fullName).trim(), p, String(address).trim(), String(dni).trim(), String(email).trim().toLowerCase(), passwordHash] + ); + user = q.rows[0]; + } catch (e) { + // si ya existe por email o phone + return res.status(409).json({ ok: false, error: "Ya existe un usuario con ese teléfono o email" }); + } + + const code = genCode6(); + const codeHash = await bcrypt.hash(code, 10); + const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 min + + await pool.query( + `insert into login_codes (user_id, phone, code_hash, purpose, expires_at) + values ($1,$2,$3,'register_verify',$4)`, + [user.id, p, codeHash, expiresAt] + ); + + await sendWhatsAppCode(p, code); + + res.json({ ok: true, needsVerify: true, phone: p }); + } catch (e) { + res.status(500).json({ ok: false, error: String(e?.message || e) }); } - - const item = { - id: nextId++, - title: String(title).trim(), - client: String(clientFinal).trim(), - phone: phone ? String(phone).trim() : "", - address: address ? String(address).trim() : "", - notes: notes ? String(notes).trim() : "", - createdAt: new Date().toISOString(), - }; - - services.push(item); - res.status(201).json({ ok: true, service: item }); }); +// Verificar código de registro: activa user + devuelve token +app.post("/auth/verify", async (req, res) => { + try { + const { phone, code } = req.body || {}; + const p = normalizePhone(phone); + + if (!p || !code) return res.status(400).json({ ok: false, error: "Faltan phone o code" }); + + const u = await pool.query(`select * from users where phone=$1`, [p]); + if (!u.rowCount) return res.status(404).json({ ok: false, error: "Usuario no existe" }); + const user = u.rows[0]; + + // busca último código válido no consumido + const c = await pool.query( + `select * from login_codes + where phone=$1 and purpose='register_verify' and consumed_at is null and expires_at > now() + order by created_at desc + limit 1`, + [p] + ); + + if (!c.rowCount) return res.status(400).json({ ok: false, error: "Código inválido o caducado" }); + + const row = c.rows[0]; + const ok = await bcrypt.compare(String(code).trim(), row.code_hash); + if (!ok) return res.status(400).json({ ok: false, error: "Código incorrecto" }); + + await pool.query(`update login_codes set consumed_at=now() where id=$1`, [row.id]); + await pool.query(`update users set is_verified=true where id=$1`, [user.id]); + + // settings por defecto + await pool.query( + `insert into user_settings (user_id, company_name) + values ($1,$2) + on conflict (user_id) do nothing`, + [user.id, user.full_name] + ); + + const token = signToken(user); + res.json({ ok: true, token }); + } catch (e) { + res.status(500).json({ ok: false, error: String(e?.message || e) }); + } +}); + +// Login normal (email+password). Si quieres 2FA por WhatsApp también, lo hacemos luego. +app.post("/auth/login", async (req, res) => { + try { + const { email, password } = req.body || {}; + if (!email || !password) return res.status(400).json({ ok: false, error: "Faltan email o password" }); + + const q = await pool.query(`select * from users where email=$1`, [String(email).trim().toLowerCase()]); + if (!q.rowCount) return res.status(401).json({ ok: false, error: "Credenciales incorrectas" }); + + const user = q.rows[0]; + + const ok = await bcrypt.compare(String(password), user.password_hash); + if (!ok) return res.status(401).json({ ok: false, error: "Credenciales incorrectas" }); + + if (!user.is_verified) return res.status(403).json({ ok: false, error: "Cuenta no verificada por WhatsApp" }); + + const token = signToken(user); + res.json({ ok: true, token }); + } catch (e) { + res.status(500).json({ ok: false, error: String(e?.message || e) }); + } +}); + +// ========================= +// SERVICES (multi-tenant) +// ========================= +app.get("/services", authMiddleware, async (req, res) => { + try { + const userId = req.user.sub; + const q = await pool.query( + `select * from services where user_id=$1 order by created_at desc`, + [userId] + ); + res.json({ ok: true, count: q.rowCount, services: q.rows }); + } catch (e) { + res.status(500).json({ ok: false, error: String(e?.message || e) }); + } +}); + +app.post("/services", authMiddleware, async (req, res) => { + try { + const userId = req.user.sub; + const body = req.body || {}; + const title = body.title; + const clientName = body.clientName ?? body.client ?? ""; + + if (!title) return res.status(400).json({ ok: false, error: "Falta title" }); + + const q = await pool.query( + `insert into services (user_id, title, client_name, phone, address, notes) + values ($1,$2,$3,$4,$5,$6) + returning *`, + [ + userId, + String(title).trim(), + clientName ? String(clientName).trim() : null, + body.phone ? String(body.phone).trim() : null, + body.address ? String(body.address).trim() : null, + body.notes ? String(body.notes).trim() : null + ] + ); + + res.status(201).json({ ok: true, service: q.rows[0] }); + } catch (e) { + res.status(500).json({ ok: false, error: String(e?.message || e) }); + } +}); + +// ========================= +// STATIC (HTML) +// ========================= +app.use(express.static("public")); + const port = process.env.PORT || 3000; app.listen(port, () => console.log(`🚀 API listening on :${port}`)); \ No newline at end of file