🔥 Firebase Backend-as-a-Service

AgroMetrics × Firebase

Integración completa: Auth multitenant, Firestore IoT en tiempo real, Cloud Functions v2 y Storage para una plataforma SaaS agrícola.

Autenticación
Firestore
Cloud Functions v2
Storage
Security Rules
🏛 Arquitectura AgroMetrics
Multi-tenant SaaS para monitorización de sensores IoT agrícolas en tiempo real
😀
Técnico Cooperativa
React SPA autenticado con Google SSO
📷
Firebase Auth
Email + Google SSO · Custom Claims por cooperativa
🔌
Sensores IoT
Temp · Humedad · pH · CO₂
Cloud Functions v2
Alertas · Estadísticas · Limpieza
cooperativas / (collection)
{coopId} → nombre, region, plan, createdAt
parcelas / (subcollection)
{parcelaId} → nombre, hectareas, cultivo, lat, lng
lecturas / (subcollection)
{lecturaId} temp: 24.3, humedad: 68, ph: 6.8, ts: serverTimestamp()
usuarios / → uid, rol, coopId, permisos: []
alertas / → tipo, parcelaId, valor, umbral, resuelta
1
🔒 Autenticación Multi-tenant
Patrón usado: Firebase Auth con Custom Claims para aislar tenants. Cada usuario recibe { coopId, rol } en su token JWT, y las Firestore Rules los validan sin queries adicionales.
📄 src/lib/auth.ts TypeScript
import { initializeApp } from "firebase/app"; import { getAuth, signInWithPopup, GoogleAuthProvider, signInWithEmailAndPassword, createUserWithEmailAndPassword, onAuthStateChanged, signOut, User } from "firebase/auth"; // Inicialización con variables de entorno (NUNCA hardcodear claves) const app = initializeApp({ apiKey: import.meta.env.VITE_FIREBASE_API_KEY, authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, }); export const auth = getAuth(app); // Google SSO — para técnicos con cuenta corporativa export async function signInWithGoogle() { const provider = new GoogleAuthProvider(); provider.addScope("email"); provider.setCustomParameters({ hd: "agrometrics.es" }); const result = await signInWithPopup(auth, provider); return result.user; } // Leer custom claims del token (coopId + rol asignados por Admin SDK) export async function getCoopClaims(user: User) { const token = await user.getIdTokenResult(); return { coopId: token.claims.coopId as string, rol: token.claims.rol as "admin" | "tecnico" | "viewer", }; } export const onAuth = (cb: (u: User | null) => void) => onAuthStateChanged(auth, cb);
2
📄 Firestore — Lecturas IoT en Tiempo Real
📄 src/lib/firestore.ts TypeScript
import { getFirestore, collection, addDoc, query, where, orderBy, limit, onSnapshot, serverTimestamp } from "firebase/firestore"; const db = getFirestore(); // Tipos del dominio AgroMetrics interface Lectura { temp: number; // °C humedad: number; // % ph: number; // 0-14 co2ppm: number; // partes por millón sensorId:string; } // Path helper multi-tenant const lecturasRef = (coopId:string, parcelaId:string) => collection(db, `cooperativas/${coopId}/parcelas/${parcelaId}/lecturas`); // Insertar lectura de sensor IoT export async function registrarLectura( coopId:string, parcelaId:string, data:Lectura ) { return addDoc(lecturasRef(coopId, parcelaId), { ...data, ts: serverTimestamp(), }); } // Escuchar lecturas en tiempo real (últimas 50) export function suscribirLecturas( coopId:string, parcelaId:string, onChange: (lecturas: Lectura[]) => void ) { const q = query( lecturasRef(coopId, parcelaId), orderBy("ts", "desc"), limit(50) ); return onSnapshot(q, (snap) => onChange(snap.docs.map(d => d.data() as Lectura)) ); // retorna unsubscribe fn }
3
⚡ Cloud Functions v2 — Alertas + Estadísticas + Cron
Nueva lectura IoT
onDocumentCreated
Evaluar umbrales
Crear alerta en Firestore
Push notification
📄 functions/src/index.ts TypeScript · Node 20
import { onDocumentCreated } from "firebase-functions/v2/firestore"; import { onCall, HttpsError } from "firebase-functions/v2/https"; import { onSchedule } from "firebase-functions/v2/scheduler"; import { getFirestore, FieldValue } from "firebase-admin/firestore"; import { initializeApp } from "firebase-admin/app"; initializeApp(); const db = getFirestore(); // Umbrales de alerta por cultivo const UMBRALES = { temp: { min: 5, max: 38 }, // °C humedad: { min: 30, max: 90 }, // % ph: { min: 5.5,max: 7.5 }, // pH }; // ① Trigger: nueva lectura → evaluar y crear alerta export const onLecturaCreada = onDocumentCreated( "cooperativas/{coopId}/parcelas/{parcelaId}/lecturas/{lecturaId}", async (event) => { const lectura = event.data?.data(); if (!lectura) return; const { coopId, parcelaId } = event.params; for (const [campo, rango] of Object.entries(UMBRALES)) { const val = lectura[campo] as number; if (val < rango.min || val > rango.max) { await db .collection(`cooperativas/${coopId}/alertas`) .add({ tipo: campo, parcelaId: parcelaId, valor: val, umbral: rango, resuelta: false, createdAt: FieldValue.serverTimestamp(), }); } } } ); // ② Callable: estadísticas 7 días de una parcela export const estadisticasParcela = onCall(async (req) => { if (!req.auth) throw new HttpsError("unauthenticated","Login requerido"); const { coopId, parcelaId } = req.data; const desde = new Date(Date.now() - 7 * 864e5); const snap = await db .collection(`cooperativas/${coopId}/parcelas/${parcelaId}/lecturas`) .where("ts", ">=", desde).get(); const lecturas = snap.docs.map(d => d.data()); const avg = (k:string) => lecturas.reduce((s,l) => s + l[k],0) / lecturas.length; return { totalLecturas: lecturas.length, promedios: { temp: +avg("temp").toFixed(1), humedad: +avg("humedad").toFixed(1), ph: +avg("ph").toFixed(2), }, }; }); // ③ Cron diario: limpiar lecturas > 90 días export const limpiezaDiaria = onSchedule("every day 03:00", async () => { const cutoff = new Date(Date.now() - 90 * 864e5); // Iterar todas las cooperativas → parcelas → lecturas antiguas const coops = await db.collection("cooperativas").get(); let total = 0; for (const coop of coops.docs) { const parcelas = await coop.ref.collection("parcelas").get(); for (const parcela of parcelas.docs) { const old = await parcela.ref .collection("lecturas").where("ts","<",cutoff).get(); const batch = db.batch(); old.docs.forEach(d => batch.delete(d.ref)); await batch.commit(); total += old.size; } } console.log(`Limpieza: ${total} lecturas eliminadas`); });
4
🛡 Reglas de Seguridad Multi-tenant
Principio: Deny-all por defecto. Las reglas validan request.auth.token.coopId para aislar tenants sin queries adicionales. Los sensores IoT escriben solo con un Service Account (Admin SDK), no pasan por estas reglas.
📄 firestore.rules Firestore Rules v2
rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { // Función helper: verifica que el usuario pertenece a la cooperativa function esDeLaCoop(coopId) { return request.auth != null && request.auth.token.coopId == coopId; } function esAdmin(coopId) { return esDeLaCoop(coopId) && request.auth.token.rol == "admin"; } // Cooperativas: admin puede editar, técnicos solo leer SU cooperativa match /cooperativas/{coopId} { allow read: if esDeLaCoop(coopId); allow write: if esAdmin(coopId); // Parcelas: técnicos leen, admin escribe match /parcelas/{parcelaId} { allow read: if esDeLaCoop(coopId); allow write: if esAdmin(coopId); // Lecturas: solo lectura por humanos; escritura via Admin SDK (IoT) match /lecturas/{lecturaId} { allow read: if esDeLaCoop(coopId); allow write: if false; // Solo Admin SDK } } // Alertas: técnicos leen, solo Cloud Functions escriben match /alertas/{alertaId} { allow read: if esDeLaCoop(coopId); allow update: if esDeLaCoop(coopId) && request.resource.data.keys().hasOnly(["resuelta"]); allow write: if false; } } } }
5
📈 Best Practices para AgroMetrics
Emuladores primero
firebase emulators:start — Nunca testear contra producción. Los emuladores soportan Auth, Firestore, Functions y Storage localmente.
🔍
Índices compuestos
La query where("ts",">=") + orderBy("ts") necesita índice. El CLI lo sugiere automáticamente en el primer fallo del emulador.
Batch < 500 docs
El límite de writeBatch() es 500 operaciones. Para el cron de limpieza, procesar en chunks de 499 docs con Promise.all().
📈
Costos por lectura
Usar onSnapshot para el dashboard en tiempo real — es 1 lectura inicial + deltas. Evitar polling con getDocs que cobra por cada llamada.
🔒
Custom Claims vs. Firestore
Poner coopId + rol en el JWT token via Admin SDK. Evitar leer Firestore en las Rules para comprobar permisos — es más lento y cobra reads.
Functions v2 siempre
Usar firebase-functions/v2: mejor cold start, configuración por región, concurrencia ajustable y soporte a runtime Node 20+.