marsalva-citas/server.js

478 lines
16 KiB
JavaScript

// 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: "Servidor de Citas", 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`));