diff --git a/server.js b/server.js index 87be6d4..127a8fb 100644 --- a/server.js +++ b/server.js @@ -36,7 +36,7 @@ async function autoUpdateDB() { try { console.log("š Verificando estructura DB..."); - // TABLAS PRINCIPALES + // 1. TABLAS PRINCIPALES await client.query(` CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, @@ -102,17 +102,17 @@ async function autoUpdateDB() { created_at TIMESTAMP DEFAULT NOW() ); - -- TABLA DE PLANTILLAS (NUEVO) + -- TABLA DE PLANTILLAS CREATE TABLE IF NOT EXISTS message_templates ( id SERIAL PRIMARY KEY, owner_id INT REFERENCES users(id) ON DELETE CASCADE, - type TEXT NOT NULL, -- 'welcome', 'no_contact', 'appointment', 'update', 'on_way', 'survey' + type TEXT NOT NULL, content TEXT, created_at TIMESTAMP DEFAULT NOW(), UNIQUE(owner_id, type) ); - -- Zonas + -- Zonas (Simplificadas) CREATE TABLE IF NOT EXISTS zones ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, @@ -125,6 +125,7 @@ async function autoUpdateDB() { PRIMARY KEY (user_id, zone_id) ); + -- Servicios CREATE TABLE IF NOT EXISTS services ( id SERIAL PRIMARY KEY, owner_id INT REFERENCES users(id) ON DELETE CASCADE, @@ -161,7 +162,7 @@ async function autoUpdateDB() { ); `); - // PARCHE REPARACIĆN + // PARCHE DE REPARACIĆN DE COLUMNAS await client.query(` DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='services' AND column_name='client_id') THEN ALTER TABLE services ADD COLUMN client_id INT REFERENCES clients(id) ON DELETE SET NULL; END IF; @@ -195,9 +196,9 @@ async function autoUpdateDB() { // HELPERS function normalizePhone(phone) { let p = String(phone || "").trim().replace(/\s+/g, "").replace(/-/g, ""); if (!p) return ""; if (!p.startsWith("+") && /^[6789]\d{8}$/.test(p)) return "+34" + p; return p; } +function genCode6() { return String(Math.floor(100000 + Math.random() * 900000)); } function signToken(user) { const accountId = user.owner_id || user.id; return jwt.sign({ sub: user.id, email: user.email, phone: user.phone, role: user.role || 'operario', accountId }, 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 { req.user = jwt.verify(token, JWT_SECRET); next(); } catch { return res.status(401).json({ ok: false, error: "Token invĆ”lido" }); } } -function genCode6() { return String(Math.floor(100000 + Math.random() * 900000)); } async function sendWhatsAppCode(phone, code) { if (!EVOLUTION_BASE_URL || !EVOLUTION_API_KEY) return; const url = `${EVOLUTION_BASE_URL.replace(/\/$/, "")}/message/sendText/${EVOLUTION_INSTANCE}`; await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "apikey": EVOLUTION_API_KEY }, body: JSON.stringify({ number: phone.replace("+", ""), text: `š Código: *${code}*` }) }).catch(console.error); } // RUTAS AUTH @@ -207,7 +208,9 @@ app.post("/auth/login", async (req, res) => { try { const { email, password } = app.post("/auth/forgot-password", async (req, res) => { try { const { dni, phone } = req.body; const p = normalizePhone(phone); const q = await pool.query("SELECT id FROM users WHERE dni=$1 AND phone=$2", [dni, p]); if (q.rowCount === 0) return res.status(404).json({ ok: false }); const uid = q.rows[0].id; const code = genCode6(); const hash = await bcrypt.hash(code, 10); await pool.query("INSERT INTO login_codes (user_id, phone, code_hash, purpose, expires_at) VALUES ($1, $2, $3, 'password_reset', $4)", [uid, p, hash, new Date(Date.now()+600000)]); await sendWhatsAppCode(p, code); res.json({ ok: true }); } catch (e) { res.status(500).json({ ok: false }); } }); app.post("/auth/reset-password", async (req, res) => { const client = await pool.connect(); try { const { phone, code, newPassword } = req.body; const p = normalizePhone(phone); const q = await client.query(`SELECT lc.*, u.id as uid FROM login_codes lc JOIN users u ON lc.user_id=u.id WHERE lc.phone=$1 AND lc.purpose='password_reset' AND lc.consumed_at IS NULL AND lc.expires_at>NOW() ORDER BY lc.created_at DESC LIMIT 1`, [p]); if(q.rowCount===0) return res.status(400).json({ok:false}); const row=q.rows[0]; if(!(await bcrypt.compare(String(code), row.code_hash))) return res.status(400).json({ok:false}); const hash=await bcrypt.hash(newPassword, 10); await client.query('BEGIN'); await client.query("UPDATE users SET password_hash=$1 WHERE id=$2",[hash, row.uid]); await client.query("UPDATE login_codes SET consumed_at=NOW() WHERE id=$1",[row.id]); await client.query('COMMIT'); res.json({ok:true}); } catch(e){await client.query('ROLLBACK'); res.status(500).json({ok:false});} finally{client.release();} }); +// ========================= // GESTIĆN DE ESTADOS +// ========================= app.get("/statuses", authMiddleware, async (req, res) => { try { let q = await pool.query("SELECT * FROM service_statuses WHERE owner_id=$1 ORDER BY id ASC", [req.user.accountId]); @@ -219,6 +222,7 @@ app.get("/statuses", authMiddleware, async (req, res) => { res.json({ ok: true, statuses: q.rows }); } catch (e) { res.status(500).json({ ok: false }); } }); + app.post("/statuses", authMiddleware, async (req, res) => { try { const { name, color } = req.body; @@ -227,22 +231,29 @@ app.post("/statuses", authMiddleware, async (req, res) => { res.json({ ok: true }); } catch(e) { res.status(500).json({ ok: false }); } }); + +// š„ BORRADO SEGURO DE ESTADOS š„ app.delete("/statuses/:id", authMiddleware, async (req, res) => { const client = await pool.connect(); try { - const statusId = req.params.id; const accountId = req.user.accountId; + const statusId = req.params.id; + const accountId = req.user.accountId; + // 1. Comprobar uso const check = await client.query("SELECT COUNT(*) FROM services WHERE status_id = $1 AND owner_id = $2", [statusId, accountId]); const usageCount = parseInt(check.rows[0].count); - if (usageCount > 0) return res.status(400).json({ ok: false, error: `No se puede borrar: Este estado se usa en ${usageCount} servicios.` }); + if (usageCount > 0) { + return res.status(400).json({ ok: false, error: `No se puede borrar: Este estado se usa en ${usageCount} servicios.` }); + } + // 2. Borrar const del = await client.query("DELETE FROM service_statuses WHERE id=$1 AND owner_id=$2", [statusId, accountId]); if (del.rowCount === 0) return res.status(404).json({ ok: false, error: "Estado no encontrado" }); res.json({ ok: true }); } catch(e) { res.status(500).json({ ok: false, error: "Error interno" }); } finally { client.release(); } }); -// ========================================== -// š GESTIĆN DE PLANTILLAS (NUEVO) -// ========================================== +// ========================= +// GESTIĆN DE PLANTILLAS +// ========================= app.get("/templates", authMiddleware, async (req, res) => { try { const q = await pool.query("SELECT * FROM message_templates WHERE owner_id=$1", [req.user.accountId]); @@ -253,7 +264,6 @@ app.get("/templates", authMiddleware, async (req, res) => { app.post("/templates", authMiddleware, async (req, res) => { try { const { type, content } = req.body; - // Upsert: Si existe actualiza, si no crea await pool.query(` INSERT INTO message_templates (owner_id, type, content) VALUES ($1, $2, $3) @@ -261,22 +271,21 @@ app.post("/templates", authMiddleware, async (req, res) => { DO UPDATE SET content = EXCLUDED.content `, [req.user.accountId, type, content]); res.json({ ok: true }); - } catch (e) { console.error(e); res.status(500).json({ ok: false }); } + } catch (e) { res.status(500).json({ ok: false }); } }); -// ZONAS Y OTROS +// CLIENTES, COMPAĆIAS, OPERARIOS, ZONAS +app.get("/clients/search", authMiddleware, async (req, res) => { try { const { phone } = req.query; const p = normalizePhone(phone); if(!p) return res.json({ok:true,client:null}); const q = await pool.query("SELECT * FROM clients WHERE phone=$1 AND owner_id=$2 LIMIT 1", [p, req.user.accountId]); res.json({ ok: true, client: q.rows[0] || null }); } catch (e) { res.status(500).json({ ok: false }); } }); +app.get("/companies", authMiddleware, async (req, res) => { try { const q = await pool.query("SELECT * FROM companies WHERE owner_id=$1 ORDER BY name ASC", [req.user.accountId]); res.json({ ok: true, companies: q.rows }); } catch (e) { res.status(500).json({ ok: false }); } }); +app.post("/companies", authMiddleware, async (req, res) => { try { const { name } = req.body; await pool.query("INSERT INTO companies (name, owner_id) VALUES ($1, $2)", [name, req.user.accountId]); res.json({ ok: true }); } catch (e) { res.status(500).json({ ok: false }); } }); +app.delete("/companies/:id", authMiddleware, async (req, res) => { try { await pool.query("DELETE FROM companies WHERE id=$1 AND owner_id=$2", [req.params.id, req.user.accountId]); res.json({ ok: true }); } catch (e) { res.status(500).json({ ok: false }); } }); +app.get("/operators", authMiddleware, async (req, res) => { try { const { guild_id } = req.query; let query = `SELECT u.id, u.full_name FROM users u JOIN user_guilds ug ON u.id = ug.user_id WHERE u.owner_id = $1 AND u.role = 'operario'`; const params = [req.user.accountId]; if (guild_id) { query += ` AND ug.guild_id = $2`; params.push(guild_id); } query += ` GROUP BY u.id ORDER BY u.full_name ASC`; const q = await pool.query(query, params); res.json({ ok: true, operators: q.rows }); } catch (e) { res.status(500).json({ ok: false, error: e.message }); } }); app.get("/zones", authMiddleware, async (req, res) => { try { const q = await pool.query("SELECT * FROM zones WHERE owner_id=$1 ORDER BY name ASC", [req.user.accountId]); res.json({ ok: true, zones: q.rows }); } catch (e) { res.status(500).json({ ok: false }); } }); app.post("/zones", authMiddleware, async (req, res) => { try { const { name } = req.body; await pool.query("INSERT INTO zones (name, owner_id) VALUES ($1, $2)", [name, req.user.accountId]); res.json({ ok: true }); } catch (e) { res.status(500).json({ ok: false }); } }); app.delete("/zones/:id", authMiddleware, async (req, res) => { try { await pool.query("DELETE FROM zones WHERE id=$1 AND owner_id=$2", [req.params.id, req.user.accountId]); res.json({ ok: true }); } catch (e) { res.status(500).json({ ok: false }); } }); app.get("/zones/:id/operators", authMiddleware, async (req, res) => { try { const q = await pool.query("SELECT user_id FROM user_zones WHERE zone_id=$1", [req.params.id]); res.json({ ok: true, assignedIds: q.rows.map(r=>r.user_id) }); } catch (e) { res.status(500).json({ ok: false }); } }); app.post("/zones/:id/assign", authMiddleware, async (req, res) => { const client = await pool.connect(); try { const { operator_ids } = req.body; await client.query('BEGIN'); await client.query("DELETE FROM user_zones WHERE zone_id=$1", [req.params.id]); if(operator_ids) for(const uid of operator_ids) await client.query("INSERT INTO user_zones (user_id, zone_id) VALUES ($1, $2)", [uid, req.params.id]); await client.query('COMMIT'); res.json({ok:true}); } catch(e){ await client.query('ROLLBACK'); res.status(500).json({ok:false}); } finally { client.release(); } }); -app.get("/operators", authMiddleware, async (req, res) => { try { const q = await pool.query("SELECT id, full_name FROM users WHERE owner_id=$1 AND role='operario' ORDER BY full_name ASC", [req.user.accountId]); res.json({ ok: true, operators: q.rows }); } catch (e) { res.status(500).json({ ok: false }); } }); -app.get("/clients/search", authMiddleware, async (req, res) => { try { const { phone } = req.query; const p = normalizePhone(phone); if(!p) return res.json({ok:true,client:null}); const q = await pool.query("SELECT * FROM clients WHERE phone=$1 AND owner_id=$2 LIMIT 1", [p, req.user.accountId]); res.json({ ok: true, client: q.rows[0] || null }); } catch (e) { res.status(500).json({ ok: false }); } }); -app.get("/companies", authMiddleware, async (req, res) => { try { const q = await pool.query("SELECT * FROM companies WHERE owner_id=$1 ORDER BY name ASC", [req.user.accountId]); res.json({ ok: true, companies: q.rows }); } catch (e) { res.status(500).json({ ok: false }); } }); -app.post("/companies", authMiddleware, async (req, res) => { try { const { name } = req.body; await pool.query("INSERT INTO companies (name, owner_id) VALUES ($1, $2)", [name, req.user.accountId]); res.json({ ok: true }); } catch (e) { res.status(500).json({ ok: false }); } }); -app.delete("/companies/:id", authMiddleware, async (req, res) => { try { await pool.query("DELETE FROM companies WHERE id=$1 AND owner_id=$2", [req.params.id, req.user.accountId]); res.json({ ok: true }); } catch (e) { res.status(500).json({ ok: false }); } }); - // SERVICIOS CRUD app.get("/services", authMiddleware, async (req, res) => { try { const q = await pool.query(`SELECT s.*, st.name as status_name, st.color as status_color, c.name as company_name, g.name as guild_name, u.full_name as assigned_name FROM services s LEFT JOIN service_statuses st ON s.status_id = st.id LEFT JOIN companies c ON s.company_id = c.id LEFT JOIN guilds g ON s.guild_id = g.id LEFT JOIN users u ON s.assigned_to = u.id WHERE s.owner_id=$1 ORDER BY s.created_at DESC`, [req.user.accountId]); res.json({ ok: true, services: q.rows }); } catch (e) { res.status(500).json({ ok: false }); } }); app.get("/services/:id", authMiddleware, async (req, res) => { try { const q = await pool.query(`SELECT * FROM services WHERE id=$1 AND owner_id=$2`, [req.params.id, req.user.accountId]); res.json({ ok: true, service: q.rows[0] }); } catch (e) { res.status(500).json({ ok: false }); } }); @@ -294,363 +303,4 @@ app.put("/admin/users/:id", authMiddleware, async (req, res) => { const client = app.delete("/admin/users/:id", authMiddleware, async (req, res) => { try { await pool.query("DELETE FROM users WHERE id=$1 AND owner_id=$2", [req.params.id, req.user.accountId]); res.json({ ok: true }); } catch (e) { res.status(500).json({ ok: false }); } }); const port = process.env.PORT || 3000; -autoUpdateDB().then(() => { app.listen(port, "0.0.0.0", () => console.log(`š Server OK en puerto ${port}`)); }); - ---- - -### PASO 2: Actualizar `configuracion.html` (Frontend) - -AquĆ tienes el archivo completo con el **Editor de Plantillas** totalmente funcional. - -Copia y reemplaza tu `configuracion.html`: - -```html - - -
- - -