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()); const { DATABASE_URL, JWT_SECRET, EVOLUTION_BASE_URL, EVOLUTION_API_KEY, EVOLUTION_INSTANCE, } = process.env; if (!DATABASE_URL || !JWT_SECRET) { console.error("❌ ERROR FATAL: Faltan variables de entorno"); process.exit(1); } const pool = new Pool({ connectionString: DATABASE_URL, ssl: false }); // ========================================== // 🧠 AUTO-ACTUALIZACIÓN DB Y CARGA DE DATOS GEOGRÁFICOS // ========================================== async function autoUpdateDB() { const client = await pool.connect(); try { console.log("🔄 Verificando salud de la base de datos..."); // 1. ESTRUCTURA BÁSICA await client.query(` CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, full_name TEXT NOT NULL, phone TEXT NOT NULL, email TEXT NOT NULL, dni TEXT, address TEXT, password_hash TEXT NOT NULL, is_verified BOOLEAN DEFAULT FALSE, owner_id INT, role TEXT DEFAULT 'operario', created_at TIMESTAMP DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS login_codes ( id SERIAL PRIMARY KEY, user_id INT REFERENCES users(id) ON DELETE CASCADE, phone TEXT NOT NULL, code_hash TEXT NOT NULL, purpose TEXT DEFAULT 'register_verify', consumed_at TIMESTAMP, expires_at TIMESTAMP NOT NULL, created_at TIMESTAMP DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS guilds ( id SERIAL PRIMARY KEY, owner_id INT REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, created_at TIMESTAMP DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS user_guilds ( user_id INT REFERENCES users(id) ON DELETE CASCADE, guild_id INT REFERENCES guilds(id) ON DELETE CASCADE, PRIMARY KEY (user_id, guild_id) ); CREATE TABLE IF NOT EXISTS companies ( id SERIAL PRIMARY KEY, owner_id INT REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, created_at TIMESTAMP DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS clients ( id SERIAL PRIMARY KEY, owner_id INT REFERENCES users(id) ON DELETE CASCADE, full_name TEXT NOT NULL, phone TEXT NOT NULL, email TEXT, addresses JSONB DEFAULT '[]', notes TEXT, created_at TIMESTAMP DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS service_statuses ( id SERIAL PRIMARY KEY, owner_id INT REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, color TEXT DEFAULT 'gray', is_default BOOLEAN DEFAULT FALSE, is_final BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS services ( id SERIAL PRIMARY KEY, owner_id INT REFERENCES users(id) ON DELETE CASCADE, client_id INT REFERENCES clients(id) ON DELETE SET NULL, status_id INT REFERENCES service_statuses(id) ON DELETE SET NULL, guild_id INT REFERENCES guilds(id) ON DELETE SET NULL, assigned_to INT REFERENCES users(id) ON DELETE SET NULL, title TEXT, description TEXT, contact_phone TEXT, contact_name TEXT, address TEXT, email TEXT, scheduled_date DATE DEFAULT CURRENT_DATE, scheduled_time TIME DEFAULT CURRENT_TIME, duration_minutes INT DEFAULT 30, is_urgent BOOLEAN DEFAULT FALSE, is_company BOOLEAN DEFAULT FALSE, company_id INT REFERENCES companies(id) ON DELETE SET NULL, company_ref TEXT, internal_notes TEXT, client_notes TEXT, closed_at TIMESTAMP, created_at TIMESTAMP DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS service_logs ( id SERIAL PRIMARY KEY, service_id INT REFERENCES services(id) ON DELETE CASCADE, user_id INT REFERENCES users(id) ON DELETE SET NULL, old_status_id INT REFERENCES service_statuses(id), new_status_id INT REFERENCES service_statuses(id), comment TEXT, created_at TIMESTAMP DEFAULT NOW() ); `); // 2. NUEVAS TABLAS GEOGRÁFICAS (PROVINCIAS Y ZONAS) await client.query(` CREATE TABLE IF NOT EXISTS provinces ( id SERIAL PRIMARY KEY, name TEXT UNIQUE NOT NULL ); CREATE TABLE IF NOT EXISTS zones ( id SERIAL PRIMARY KEY, province_id INT REFERENCES provinces(id) ON DELETE CASCADE, name TEXT NOT NULL, -- Ej: Madrid Capital, Zona Sur, etc. owner_id INT, -- NULL = Zona del sistema, ID = Zona personalizada created_at TIMESTAMP DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS user_zones ( user_id INT REFERENCES users(id) ON DELETE CASCADE, zone_id INT REFERENCES zones(id) ON DELETE CASCADE, PRIMARY KEY (user_id, zone_id) ); `); // 3. SEEDING (POBLAR DATOS DE ESPAÑA SI ESTÁ VACÍO) const provCheck = await client.query("SELECT COUNT(*) FROM provinces"); if (parseInt(provCheck.rows[0].count) === 0) { console.log("🇪🇸 Poblando base de datos con Provincias y Zonas de España..."); const spainProvinces = [ "Álava", "Albacete", "Alicante", "Almería", "Asturias", "Ávila", "Badajoz", "Barcelona", "Burgos", "Cáceres", "Cádiz", "Cantabria", "Castellón", "Ciudad Real", "Córdoba", "Cuenca", "Girona", "Granada", "Guadalajara", "Guipúzcoa", "Huelva", "Huesca", "Illes Balears", "Jaén", "La Coruña", "La Rioja", "Las Palmas", "León", "Lleida", "Lugo", "Madrid", "Málaga", "Murcia", "Navarra", "Ourense", "Palencia", "Pontevedra", "Salamanca", "Santa Cruz de Tenerife", "Segovia", "Sevilla", "Soria", "Tarragona", "Teruel", "Toledo", "Valencia", "Valladolid", "Vizcaya", "Zamora", "Zaragoza", "Ceuta", "Melilla" ]; for (const provName of spainProvinces) { const resProv = await client.query("INSERT INTO provinces (name) VALUES ($1) RETURNING id", [provName]); const provId = resProv.rows[0].id; // Zonas automáticas básicas para seguros const zones = ["Capital", "Provincia (Resto)"]; // Zonas detalladas para grandes capitales if (["Madrid", "Barcelona", "Valencia", "Sevilla", "Málaga", "Alicante", "Vizcaya"].includes(provName)) { zones.push("Área Metropolitana", "Zona Norte", "Zona Sur", "Zona Este", "Zona Oeste"); } for (const z of zones) { await client.query("INSERT INTO zones (province_id, name, owner_id) VALUES ($1, $2, NULL)", [provId, z]); } } console.log("✅ Datos geográficos cargados."); } // 4. PARCHE DE REPARACIÓN await client.query(` DO $$ BEGIN -- Columnas faltantes previas 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; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='services' AND column_name='status_id') THEN ALTER TABLE services ADD COLUMN status_id INT REFERENCES service_statuses(id) ON DELETE SET NULL; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='services' AND column_name='guild_id') THEN ALTER TABLE services ADD COLUMN guild_id INT REFERENCES guilds(id) ON DELETE SET NULL; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='services' AND column_name='assigned_to') THEN ALTER TABLE services ADD COLUMN assigned_to INT REFERENCES users(id) ON DELETE SET NULL; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='services' AND column_name='contact_phone') THEN ALTER TABLE services ADD COLUMN contact_phone TEXT; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='services' AND column_name='contact_name') THEN ALTER TABLE services ADD COLUMN contact_name TEXT; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='services' AND column_name='address') THEN ALTER TABLE services ADD COLUMN address TEXT; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='services' AND column_name='email') THEN ALTER TABLE services ADD COLUMN email TEXT; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='services' AND column_name='title') THEN ALTER TABLE services ADD COLUMN title TEXT; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='services' AND column_name='description') THEN ALTER TABLE services ADD COLUMN description TEXT; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='services' AND column_name='scheduled_date') THEN ALTER TABLE services ADD COLUMN scheduled_date DATE DEFAULT CURRENT_DATE; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='services' AND column_name='scheduled_time') THEN ALTER TABLE services ADD COLUMN scheduled_time TIME DEFAULT CURRENT_TIME; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='services' AND column_name='duration_minutes') THEN ALTER TABLE services ADD COLUMN duration_minutes INT DEFAULT 30; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='services' AND column_name='is_urgent') THEN ALTER TABLE services ADD COLUMN is_urgent BOOLEAN DEFAULT FALSE; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='services' AND column_name='company_id') THEN ALTER TABLE services ADD COLUMN company_id INT REFERENCES companies(id) ON DELETE SET NULL; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='services' AND column_name='is_company') THEN ALTER TABLE services ADD COLUMN is_company BOOLEAN DEFAULT FALSE; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='services' AND column_name='company_ref') THEN ALTER TABLE services ADD COLUMN company_ref TEXT; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='services' AND column_name='internal_notes') THEN ALTER TABLE services ADD COLUMN internal_notes TEXT; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='services' AND column_name='client_notes') THEN ALTER TABLE services ADD COLUMN client_notes TEXT; END IF; BEGIN ALTER TABLE users DROP CONSTRAINT IF EXISTS users_phone_key; EXCEPTION WHEN OTHERS THEN NULL; END; BEGIN ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key; EXCEPTION WHEN OTHERS THEN NULL; END; END $$; `); console.log("✅ DB Sincronizada."); } catch (e) { console.error("❌ Error DB:", e); } finally { client.release(); } } // 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" }); } } 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 app.post("/auth/register", async (req, res) => { const client = await pool.connect(); try { const { fullName, phone, address, dni, email, password } = req.body; const p = normalizePhone(phone); if (!fullName || !p || !email || !password) return res.status(400).json({ ok: false, error: "Faltan datos" }); const passwordHash = await bcrypt.hash(password, 10); await client.query('BEGIN'); const insert = await client.query("INSERT INTO users (full_name, phone, address, dni, email, password_hash, role, owner_id) VALUES ($1, $2, $3, $4, $5, $6, 'admin', NULL) RETURNING id", [fullName, p, address, dni, email, passwordHash]); const userId = insert.rows[0].id; const code = genCode6(); const codeHash = await bcrypt.hash(code, 10); const expiresAt = new Date(Date.now() + 10 * 60 * 1000); await client.query("INSERT INTO login_codes (user_id, phone, code_hash, expires_at) VALUES ($1, $2, $3, $4)", [userId, p, codeHash, expiresAt]); await sendWhatsAppCode(p, code); await client.query('COMMIT'); res.json({ ok: true, phone: p }); } catch (e) { await client.query('ROLLBACK'); res.status(500).json({ ok: false }); } finally { client.release(); } }); app.post("/auth/verify", async (req, res) => { try { const { phone, code } = req.body; const p = normalizePhone(phone); const q = await pool.query(`SELECT lc.*, u.id as uid, u.email, u.role, u.owner_id FROM login_codes lc JOIN users u ON lc.user_id = u.id WHERE lc.phone=$1 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 }); 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", [row.uid]); res.json({ ok: true, token: signToken({ id: row.uid, email: row.email, phone: p, role: row.role, owner_id: row.owner_id }) }); } catch (e) { res.status(500).json({ ok: false }); } }); app.post("/auth/login", async (req, res) => { try { const { email, password } = req.body; const q = await pool.query("SELECT * FROM users WHERE email=$1", [email]); if (q.rowCount === 0) return res.status(401).json({ ok: false }); let user = null; for (const u of q.rows) { if (await bcrypt.compare(password, u.password_hash)) { user = u; break; } } if (!user) return res.status(401).json({ ok: false }); res.json({ ok: true, token: signToken(user) }); } catch(e) { res.status(500).json({ ok: false }); } }); 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();} }); // ========================= // NUEVAS RUTAS GEOGRÁFICAS // ========================= app.get("/provinces", authMiddleware, async (req, res) => { try { const q = await pool.query("SELECT * FROM provinces ORDER BY name ASC"); res.json({ ok: true, provinces: q.rows }); } catch (e) { res.status(500).json({ ok: false }); } }); app.get("/zones", authMiddleware, async (req, res) => { try { const { province_id } = req.query; let query = "SELECT * FROM zones WHERE (owner_id IS NULL OR owner_id=$1)"; const params = [req.user.accountId]; if(province_id) { query += " AND province_id=$2"; params.push(province_id); } query += " ORDER BY name ASC"; const q = await pool.query(query, params); res.json({ ok: true, zones: q.rows }); } catch (e) { res.status(500).json({ ok: false }); } }); // DATOS MAESTROS 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]); if (q.rowCount === 0) { const defaults = [{name:'Pendiente',c:'gray',d:true,f:false},{name:'En Proceso',c:'blue',d:false,f:false},{name:'Terminado',c:'green',d:false,f:true},{name:'Cancelado',c:'red',d:false,f:true}]; for (const s of defaults) await pool.query("INSERT INTO service_statuses (owner_id,name,color,is_default,is_final) VALUES ($1,$2,$3,$4,$5)", [req.user.accountId,s.name,s.c,s.d,s.f]); q = await pool.query("SELECT * FROM service_statuses WHERE owner_id=$1 ORDER BY id ASC", [req.user.accountId]); } res.json({ ok: true, statuses: 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 }); } }); // OBTENER OPERARIOS POR GREMIO 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 }); } }); // 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 }); } }); app.get("/services/:id/logs", authMiddleware, async (req, res) => { try { const q = await pool.query(`SELECT l.*, u.full_name as user_name, s2.name as new_status, s2.color as new_color FROM service_logs l LEFT JOIN users u ON l.user_id=u.id LEFT JOIN service_statuses s2 ON l.new_status_id=s2.id WHERE l.service_id=$1 ORDER BY l.created_at DESC`, [req.params.id]); res.json({ ok: true, logs: q.rows }); } catch (e) { res.status(500).json({ ok: false }); } }); app.put("/services/:id/status", authMiddleware, async (req, res) => { const client = await pool.connect(); try { const { status_id, comment } = req.body; await client.query('BEGIN'); const curr = await client.query("SELECT status_id FROM services WHERE id=$1", [req.params.id]); const old = curr.rows[0].status_id; await client.query("UPDATE services SET status_id=$1 WHERE id=$2", [status_id, req.params.id]); await client.query("INSERT INTO service_logs (service_id, user_id, old_status_id, new_status_id, comment) VALUES ($1, $2, $3, $4, $5)", [req.params.id, req.user.sub, old, status_id, comment]); await client.query('COMMIT'); res.json({ ok: true }); } catch (e) { await client.query('ROLLBACK'); res.status(500).json({ ok: false }); } finally { client.release(); } }); // CREAR SERVICIO (SANITIZADO) app.post("/services", authMiddleware, async (req, res) => { const client = await pool.connect(); try { const { phone, name, address, email, description, scheduled_date, scheduled_time, duration, is_urgent, is_company, company_id, company_ref, internal_notes, client_notes, status_id, guild_id, assigned_to } = req.body; const p = normalizePhone(phone); // SANITIZAR const safeDuration = (duration === "" || duration === null) ? 30 : duration; const safeCompanyId = (company_id === "" || company_id === null) ? null : company_id; const safeGuildId = (guild_id === "" || guild_id === null) ? null : guild_id; const safeAssignedTo = (assigned_to === "" || assigned_to === null) ? null : assigned_to; await client.query('BEGIN'); let finalStatus = status_id; if (!finalStatus) { const def = await client.query("SELECT id FROM service_statuses WHERE owner_id=$1 AND is_default=TRUE LIMIT 1", [req.user.accountId]); finalStatus = def.rows[0]?.id; } let clientId; const cCheck = await client.query("SELECT id, addresses FROM clients WHERE phone=$1 AND owner_id=$2", [p, req.user.accountId]); if (cCheck.rowCount > 0) { clientId = cCheck.rows[0].id; let addrs = cCheck.rows[0].addresses || []; if(!addrs.includes(address)) { addrs.push(address); await client.query("UPDATE clients SET addresses=$1 WHERE id=$2", [JSON.stringify(addrs), clientId]); } } else { const newC = await client.query("INSERT INTO clients (owner_id, full_name, phone, email, addresses) VALUES ($1, $2, $3, $4, $5) RETURNING id", [req.user.accountId, name, p, email, JSON.stringify([address])]); clientId = newC.rows[0].id; } const insert = await client.query(` INSERT INTO services ( owner_id, client_id, status_id, contact_phone, contact_name, address, email, description, scheduled_date, scheduled_time, duration_minutes, is_urgent, is_company, company_id, company_ref, internal_notes, client_notes, title, guild_id, assigned_to ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) RETURNING id `, [ req.user.accountId, clientId, finalStatus, p, name, address, email, description, scheduled_date || 'NOW()', scheduled_time || 'NOW()', safeDuration, is_urgent || false, is_company || false, safeCompanyId, company_ref, internal_notes, client_notes, name + " - Svc", safeGuildId, safeAssignedTo ]); await client.query("INSERT INTO service_logs (service_id, user_id, new_status_id, comment) VALUES ($1, $2, $3, 'Servicio Creado')", [insert.rows[0].id, req.user.sub, finalStatus]); await client.query('COMMIT'); res.json({ ok: true }); } catch (e) { await client.query('ROLLBACK'); console.error(e); res.status(500).json({ ok: false, error: e.message }); } finally { client.release(); } }); // EDITAR SERVICIO (SANITIZADO) app.put("/services/:id", authMiddleware, async (req, res) => { const client = await pool.connect(); try { const { name, address, email, description, scheduled_date, scheduled_time, duration, is_urgent, is_company, company_id, company_ref, internal_notes, client_notes, guild_id, assigned_to } = req.body; const safeDuration = (duration === "" || duration === null) ? 30 : duration; const safeCompanyId = (company_id === "" || company_id === null) ? null : company_id; const safeGuildId = (guild_id === "" || guild_id === null) ? null : guild_id; const safeAssignedTo = (assigned_to === "" || assigned_to === null) ? null : assigned_to; await client.query('BEGIN'); await client.query(` UPDATE services SET contact_name=$1, address=$2, email=$3, description=$4, scheduled_date=$5, scheduled_time=$6, duration_minutes=$7, is_urgent=$8, is_company=$9, company_id=$10, company_ref=$11, internal_notes=$12, client_notes=$13, guild_id=$14, assigned_to=$15 WHERE id=$16 AND owner_id=$17 `, [ name, address, email, description, scheduled_date, scheduled_time, safeDuration, is_urgent, is_company, safeCompanyId, company_ref, internal_notes, client_notes, safeGuildId, safeAssignedTo, req.params.id, req.user.accountId ]); await client.query("INSERT INTO service_logs (service_id, user_id, new_status_id, comment) VALUES ($1, $2, (SELECT status_id FROM services WHERE id=$1), 'Datos editados')", [req.params.id, req.user.sub]); await client.query('COMMIT'); res.json({ ok: true }); } catch (e) { await client.query('ROLLBACK'); console.error(e); res.status(500).json({ ok: false }); } finally { client.release(); } }); app.delete("/services/:id", authMiddleware, async (req, res) => { try { await pool.query("DELETE FROM services 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 }); } }); // RUTAS USERS/GREMIOS app.get("/guilds", authMiddleware, async (req, res) => { try { const q = await pool.query("SELECT * FROM guilds WHERE owner_id=$1 ORDER BY name ASC", [req.user.accountId]); res.json({ ok: true, guilds: q.rows }); } catch (e) { res.status(500).json({ ok: false }); } }); app.post("/guilds", authMiddleware, async (req, res) => { try { const { name } = req.body; await pool.query("INSERT INTO guilds (name, owner_id) VALUES ($1, $2)", [name, req.user.accountId]); res.json({ ok: true }); } catch (e) { res.status(500).json({ ok: false }); } }); app.delete("/guilds/:id", authMiddleware, async (req, res) => { try { await pool.query("DELETE FROM guilds 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("/admin/users", authMiddleware, async (req, res) => { try { const q = await pool.query(`SELECT u.id, u.full_name, u.email, u.phone, u.role, COALESCE(json_agg(g.id) FILTER (WHERE g.id IS NOT NULL), '[]') as guilds FROM users u LEFT JOIN user_guilds ug ON u.id=ug.user_id LEFT JOIN guilds g ON ug.guild_id=g.id WHERE u.owner_id=$1 GROUP BY u.id ORDER BY u.id DESC`, [req.user.accountId]); res.json({ ok: true, users: q.rows }); } catch (e) { res.status(500).json({ ok: false }); } }); app.post("/admin/users", authMiddleware, async (req, res) => { const client = await pool.connect(); try { const { fullName, email, password, role, guilds, phone } = req.body; if (!email || !password || !fullName || !phone) return res.status(400).json({ ok: false }); const p = normalizePhone(phone); const hash = await bcrypt.hash(password, 10); const check = await client.query("SELECT id FROM users WHERE (phone=$1 OR email=$2) AND owner_id=$3", [p, email, req.user.accountId]); if (check.rowCount > 0) return res.status(400).json({ ok: false, error: "Duplicado" }); await client.query('BEGIN'); const insert = await client.query("INSERT INTO users (full_name, email, password_hash, role, phone, is_verified, owner_id) VALUES ($1, $2, $3, $4, $5, TRUE, $6) RETURNING id", [fullName, email, hash, role || 'operario', p, req.user.accountId]); const uid = insert.rows[0].id; if (guilds) for (const gid of guilds) await client.query("INSERT INTO user_guilds (user_id, guild_id) VALUES ($1, $2)", [uid, gid]); await client.query('COMMIT'); res.json({ ok: true }); } catch (e) { await client.query('ROLLBACK'); res.status(500).json({ ok: false }); } finally { client.release(); } }); app.put("/admin/users/:id", authMiddleware, async (req, res) => { const client = await pool.connect(); try { const userId = req.params.id; const { fullName, email, phone, role, guilds, password } = req.body; const p = normalizePhone(phone); await client.query('BEGIN'); if(password) { const hash = await bcrypt.hash(password, 10); await client.query("UPDATE users SET full_name=$1, email=$2, phone=$3, role=$4, password_hash=$5 WHERE id=$6", [fullName, email, p, role, hash, userId]); } else { await client.query("UPDATE users SET full_name=$1, email=$2, phone=$3, role=$4 WHERE id=$5", [fullName, email, p, role, userId]); } if (guilds && Array.isArray(guilds)) { await client.query("DELETE FROM user_guilds WHERE user_id=$1", [userId]); for (const gid of guilds) await client.query("INSERT INTO user_guilds (user_id, guild_id) VALUES ($1, $2)", [userId, gid]); } await client.query('COMMIT'); res.json({ ok: true }); } catch (e) { await client.query('ROLLBACK'); res.status(500).json({ ok: false }); } finally { client.release(); } }); 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}`)); });