diff --git a/server.js b/server.js new file mode 100644 index 0000000..befc84b --- /dev/null +++ b/server.js @@ -0,0 +1,478 @@ +// server.js (V22 - OnlineAppointmentRequests FIX + compat HTML) +const express = require("express"); +const cors = require("cors"); +const admin = require("firebase-admin"); + +// =============== 1. INICIALIZACIÓN =============== +if (!admin.apps.length) { + const projectId = process.env.FIREBASE_PROJECT_ID; + const clientEmail = process.env.FIREBASE_CLIENT_EMAIL; + const rawPrivateKey = process.env.FIREBASE_PRIVATE_KEY; + + if (!projectId || !clientEmail || !rawPrivateKey) { + console.error("❌ ERROR: Faltan variables de Firebase."); + process.exit(1); + } + + admin.initializeApp({ + credential: admin.credential.cert({ + projectId, + clientEmail, + privateKey: rawPrivateKey.replace(/\\n/g, "\n"), + }), + }); +} + +const db = admin.firestore(); +const app = express(); +app.use(cors({ origin: true })); +app.use(express.json()); + +const PORT = process.env.PORT || 10000; + +// =============== 2. SEGURIDAD (MEJORADA PARA ADMIN) =============== +const verifyFirebaseUser = async (req, res, next) => { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return res.status(401).json({ error: "No autorizado. Falta token." }); + } + + const token = authHeader.split("Bearer ")[1]; + + // --- SOPORTE PARA TOKEN ADMIN PROPIO --- + if (token.startsWith("MARSALVA_ADMIN_")) { + try { + const encoded = token.replace("MARSALVA_ADMIN_", ""); + const decoded = Buffer.from(encoded, "base64").toString("utf-8"); + const [user, pass] = decoded.split(":"); + + const doc = await db.collection("settings").doc("homeserve").get(); + const config = doc.data() || {}; + + if (config.user === user && config.pass === pass) { + req.user = { uid: "admin", email: "admin@marsalva.com" }; + return next(); + } else { + return res.status(403).json({ error: "Credenciales de admin inválidas." }); + } + } catch (e) { + console.error("Error verificando admin token:", e); + return res.status(403).json({ error: "Token de admin corrupto." }); + } + } + + // --- Firebase normal --- + try { + const decodedToken = await admin.auth().verifyIdToken(token); + req.user = decodedToken; + next(); + } catch (error) { + console.error("Error verificando token:", error); + return res.status(403).json({ error: "Token inválido o caducado." }); + } +}; + +// =============== 3. UTILS =============== +const MAX_DISTANCE_KM = 5; +const SCHEDULE = { + morning: { startHour: 9, endHour: 14 }, + afternoon: { startHour: 16, endHour: 20 }, +}; + +function toSpainDate(d = new Date()) { + return new Date(new Date(d).toLocaleString("en-US", { timeZone: "Europe/Madrid" })); +} +function getSpainNow() { + return toSpainDate(new Date()); +} +function addDays(d, days) { + return new Date(d.getTime() + days * 86400000); +} +function isOverlapping(startA, endA, startB, endB) { + return startA < endB && startB < endA; +} +function getDistanceInKm(lat1, lon1, lat2, lon2) { + if (lat1 == null || lon1 == null || lat2 == null || lon2 == null) return 0; + const R = 6371; + const dLat = (lat2 - lat1) * (Math.PI / 180); + const dLon = (lon2 - lon1) * (Math.PI / 180); + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(lat1 * (Math.PI / 180)) * + Math.cos(lat2 * (Math.PI / 180)) * + Math.sin(dLon / 2) ** 2; + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; +} +function getDayLabel(dateObj) { + const days = ["Domingo", "Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado"]; + const months = ["Enero","Febrero","Marzo","Abril","Mayo","Junio","Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"]; + return `${days[dateObj.getDay()]} ${dateObj.getDate()} de ${months[dateObj.getMonth()]}`; +} + +// =============== 4. ADMIN LOGIN =============== +app.post("/admin/login", async (req, res) => { + const { user, pass } = req.body; + try { + const doc = await db.collection("settings").doc("homeserve").get(); + const config = doc.data() || {}; + + if (config.user === user && config.pass === pass) { + const payload = Buffer.from(`${user}:${pass}`).toString("base64"); + const customToken = `MARSALVA_ADMIN_${payload}`; + return res.json({ token: customToken }); + } else { + return res.status(401).json({ error: "Credenciales incorrectas" }); + } + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +// =============== 5. ADMIN ENDPOINTS BLOQUEOS =============== +app.get("/admin/blocks", verifyFirebaseUser, async (req, res) => { + try { + const snap = await db.collection("calendarBlocks").get(); + const items = snap.docs.map((d) => { + const data = d.data(); + return { + id: d.id, + ...data, + start: data.start && data.start.toDate ? data.start.toDate().toISOString() : data.start, + end: data.end && data.end.toDate ? data.end.toDate().toISOString() : data.end, + }; + }); + res.json({ items }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +app.post("/admin/blocks", verifyFirebaseUser, async (req, res) => { + try { + const { startISO, endISO, allDay, reason, city } = req.body; + await db.collection("calendarBlocks").add({ + start: startISO, + end: endISO, + allDay: !!allDay, + reason: reason || "Bloqueo manual", + city: city || "", + createdAt: admin.firestore.FieldValue.serverTimestamp(), + }); + res.json({ success: true }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +app.delete("/admin/blocks/:id", verifyFirebaseUser, async (req, res) => { + try { + await db.collection("calendarBlocks").doc(req.params.id).delete(); + res.json({ success: true }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +app.get("/admin/config/homeserve", verifyFirebaseUser, async (req, res) => { + try { + const doc = await db.collection("settings").doc("homeserve").get(); + if (!doc.exists) return res.json({ user: "", hasPass: false, lastChange: null }); + const data = doc.data(); + res.json({ + user: data.user, + hasPass: !!data.pass, + lastChange: data.lastChange ? data.lastChange.toDate().toISOString() : null, + }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +// =============== 6. ENDPOINTS PÚBLICOS =============== +app.get("/version", (req, res) => { + res.json({ version: "V22 - OnlineAppointmentRequests FIX", status: "online" }); +}); + +/** + * availability-smart + * Acepta tanto: + * - { lat, lng, durationMinutes, timePreference } + * como: + * - { token, block, rangeDays } + */ +app.post("/availability-smart", async (req, res) => { + try { + let { + lat, + lng, + durationMinutes = 60, + timePreference, + timeSlot, + token, + block, + rangeDays = 10, + } = req.body; + + // Compat: si viene token, intentamos sacar coords/duración de la ficha + if (token && (!lat || !lng)) { + const tokenDoc = await db.collection("appointments").doc(token).get(); + if (tokenDoc.exists) { + const t = tokenDoc.data() || {}; + if (t.location?.lat != null && t.location?.lng != null) { + lat = t.location.lat; + lng = t.location.lng; + } + if (typeof t.durationMinutes === "number") durationMinutes = t.durationMinutes; + if (typeof t.duration === "number") durationMinutes = t.duration; + } + } + + // Compat: si viene block (morning/afternoon) lo tratamos como preferencia + const requestedTimeRaw = String(timePreference || timeSlot || block || "").toLowerCase(); + + const hasCoords = lat != null && lng != null && !isNaN(lat) && !isNaN(lng); + const today = getSpainNow(); + const daysToCheck = Math.min(Math.max(parseInt(rangeDays, 10) || 10, 3), 21); + + const startRange = new Date(today); + startRange.setHours(0, 0, 0, 0); + const endRange = addDays(startRange, daysToCheck); + + // 1) CITAS (solo las que tienen campo date timestamp) + const appSnap = await db + .collection("appointments") + .where("date", ">=", startRange) + .where("date", "<=", endRange) + .get(); + + // 2) BLOQUEOS + const blockSnap = await db.collection("calendarBlocks").get(); + + const busyItems = []; + + appSnap.docs.forEach((doc) => { + const data = doc.data() || {}; + if (!data.date || !data.date.toDate) return; + + const start = data.date.toDate(); + const dur = typeof data.duration === "number" ? data.duration : 60; + const end = new Date(start.getTime() + dur * 60000); + + busyItems.push({ + start, + end, + lat: data.location?.lat, + lng: data.location?.lng, + type: "appointment", + }); + }); + + blockSnap.docs.forEach((doc) => { + const data = doc.data() || {}; + + const bStart = data.start && data.start.toDate ? data.start.toDate() : new Date(data.start); + const bEnd = data.end && data.end.toDate ? data.end.toDate() : new Date(data.end); + + busyItems.push({ + start: bStart, + end: bEnd, + type: "block", + allDay: !!data.allDay, + }); + }); + + // 3) GENERAR HUECOS (franjas de 1h) + const availableSlots = []; + + for (let i = 0; i < daysToCheck; i++) { + const currentDay = addDays(today, i); + const dayNum = currentDay.getDay(); + if (dayNum === 0 || dayNum === 6) continue; // finde fuera + + const dayStart = new Date(currentDay); + dayStart.setHours(0, 0, 0, 0); + const dayEnd = new Date(currentDay); + dayEnd.setHours(23, 59, 59, 999); + + const dayBusyItems = busyItems.filter((item) => isOverlapping(item.start, item.end, dayStart, dayEnd)); + + // bloque día completo + const hasFullDayBlock = dayBusyItems.some((item) => item.type === "block" && item.allDay); + if (hasFullDayBlock) continue; + + // filtro 5km por día (si ya tienes citas ese día lejos) + const appointmentsToday = dayBusyItems.filter((x) => x.type === "appointment"); + if (hasCoords && appointmentsToday.length > 0) { + let blockedByDistance = false; + for (const a of appointmentsToday) { + if (a.lat != null && a.lng != null) { + const dist = getDistanceInKm(lat, lng, a.lat, a.lng); + if (dist > MAX_DISTANCE_KM) { + blockedByDistance = true; + break; + } + } + } + if (blockedByDistance) continue; + } + + // elegir turno(s) + let blocksToUse = []; + if (requestedTimeRaw.includes("morning") || requestedTimeRaw.includes("mañana") || requestedTimeRaw === "morning") { + blocksToUse = [SCHEDULE.morning]; + } else if (requestedTimeRaw.includes("afternoon") || requestedTimeRaw.includes("tarde") || requestedTimeRaw === "afternoon") { + blocksToUse = [SCHEDULE.afternoon]; + } else { + blocksToUse = [SCHEDULE.morning, SCHEDULE.afternoon]; + } + + for (const blk of blocksToUse) { + for (let hour = blk.startHour; hour < blk.endHour; hour++) { + const slotStart = new Date(currentDay); + slotStart.setHours(hour, 0, 0, 0); + + if (slotStart < new Date()) continue; + + const slotEndWindow = new Date(currentDay); + slotEndWindow.setHours(hour + 1, 0, 0, 0); + + const workEnd = new Date(slotStart.getTime() + durationMinutes * 60000); + + // si el trabajo se sale del final del turno, descartamos + const endOfShift = new Date(currentDay); + endOfShift.setHours(blk.endHour, 0, 0, 0); + if (workEnd > endOfShift) continue; + + let isOccupied = false; + for (const item of dayBusyItems) { + if (isOverlapping(slotStart, workEnd, item.start, item.end)) { + isOccupied = true; + break; + } + } + + if (!isOccupied) { + const startStr = slotStart.toLocaleTimeString("es-ES", { hour: "2-digit", minute: "2-digit" }); + const endStr = slotEndWindow.toLocaleTimeString("es-ES", { hour: "2-digit", minute: "2-digit" }); + + availableSlots.push({ + date: slotStart.toISOString().split("T")[0], + startTime: startStr, + endTime: endStr, + label: `${startStr} - ${endStr}`, + message: `La visita se realizará entre las ${startStr} y las ${endStr}`, + isoStart: slotStart.toISOString(), + }); + } + } + } + } + + // agrupar por día + const grouped = availableSlots.reduce((acc, slot) => { + if (!acc[slot.date]) acc[slot.date] = []; + acc[slot.date].push(slot); + return acc; + }, {}); + + const responseArray = Object.keys(grouped) + .sort() + .map((dateKey) => { + const dateObj = new Date(dateKey); + const labelDia = getDayLabel(dateObj); + return { + date: dateKey, + dayLabel: labelDia, + title: labelDia, + slots: grouped[dateKey], + }; + }); + + res.json({ days: responseArray }); + } catch (error) { + console.error("Error availability:", error); + res.json({ days: [] }); + } +}); + +/** + * appointment-request (FIX) + * En vez de crear una cita "real", crea una solicitud en: + * onlineAppointmentRequests + * que es lo que tu app lee. + */ +app.post("/appointment-request", async (req, res) => { + try { + const { token, slot, date, startTime, reason } = req.body; + + // 1) determinar requestedDate + let requestedDate = null; + + if (slot?.isoStart) { + requestedDate = new Date(slot.isoStart); + } else if (date && startTime) { + // date: "YYYY-MM-DD", startTime: "09:00" + requestedDate = new Date(`${date}T${startTime}:00`); + } + + if (!requestedDate || isNaN(requestedDate.getTime())) { + return res.status(400).json({ error: "Fecha/hora inválida (requestedDate)" }); + } + + // 2) cargar datos del servicio desde el token (para rellenar client/address/phone/originalDate) + let clientName = "Cliente"; + let address = ""; + let phone = ""; + let originalDate = null; // Timestamp o null + let appointmentId = token || ""; // lo que tu app usa para abrir la cita + + if (token) { + const d = await db.collection("appointments").doc(token).get(); + if (d.exists) { + const data = d.data() || {}; + clientName = data.clientName || data.name || data.nombre || clientName; + address = data.address || data.direccion || ""; + if (data.city) address = address ? `${address}, ${data.city}` : String(data.city); + phone = data.phone || data.clientPhone || data.telefono || ""; + appointmentId = data.appointmentId || data.serviceNumber || token; + + // originalDate si existe en ese doc + const od = data.originalDate || data.date; + if (od && od.toDate) originalDate = od; + } + } + + // 3) crear solicitud en la colección que tu app lee + const payload = { + appointmentId: String(appointmentId || token || ""), + clientName: String(clientName || "Cliente"), + address: String(address || ""), + phone: String(phone || ""), + originalDate: originalDate || null, + requestedDate: admin.firestore.Timestamp.fromDate(requestedDate), + reason: String(reason || "Solicitud desde portal"), + status: "pending", + createdAt: admin.firestore.FieldValue.serverTimestamp(), + // (extras opcionales, no molestan) + token: token || null, + source: "web_portal", + }; + + await db.collection("onlineAppointmentRequests").add(payload); + + res.json({ success: true }); + } catch (e) { + console.error("❌ appointment-request error:", e); + res.status(500).json({ error: e.message }); + } +}); + +// CLIENT INFO (para tu HTML) +app.post("/client-from-token", async (req, res) => { + const d = await db.collection("appointments").doc(req.body.token).get(); + if (d.exists) res.json(d.data()); + else res.status(404).json({}); +}); + +app.listen(PORT, () => console.log(`✅ Marsalva Server V22 Running`)); \ No newline at end of file