🏛 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
TypeScript
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.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
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
Functions v2
Nueva lectura IoT
→
onDocumentCreated
→
Evaluar umbrales
→
Crear alerta en Firestore
→
Push notification
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
Firestore Rules
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.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
Guidelines
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+.