Modelo de datos
La base de datos cultivaleads-prod fue creada en edición Enterprise para acceder a colección group queries, mayor throughput y SLAs garantizados. Multi-tenant por
agencyId en cada documento.Estado del lead — flujo de transición
New
Contacted
Qualified
Closed Won
Closed Lost
agencies
Enterprise
- name string Nombre de la agencia (<100 chars) required
- ownerUid string UID Firebase Auth del owner required
- plan string starter | growth | enterprise required
- createdAt timestamp Inmutable tras creación required
- leadsQuota number Límite de leads según plan optional
users
PII privado
- email string Email corporativo (solo owner lee) required
- displayName string <80 chars required
- role string admin | agent (solo admin modifica) required
- agencyId string Ref a agencies (inmutable) required
- photoURL string https:// URL <500 chars optional
leads
Multi-tenant
- agencyId string Tenant ID (inmutable) required
- fullName string <120 chars required
- email string Email del lead (validado) required
- status string new→contacted→qualified→closed_* required
- assignedAgentId string UID del agente asignado optional
- campaignId string Ref a campaigns optional
- score number 0–100 lead scoring optional
- createdAt timestamp Inmutable, validado con isRecent() required
campaigns
Multi-tenant
- agencyId string Tenant ID (inmutable) required
- name string <200 chars required
- channel string google_ads | meta | linkedin | email required
- budget number > 0 (EUR) optional
- leadsCount number Contador atómico (getAfter) required
- createdAt timestamp Inmutable required
leads/{leadId}/notes
Subcollection
- authorUid string Agente que escribe (inmutable) required
- body string <2000 chars required
- agencyId string Desnormalizado para reglas required
- createdAt timestamp isRecent() en create required
Reglas de Seguridad (Prototype v1.0)
He configurado reglas prototipo para mantener los datos de Firestore seguros. Están diseñadas para ser seguras gracias al aislamiento por agencyId, validadores de datos estrictos, protección RBAC contra escalada de privilegios y checks de campos inmutables. Sin embargo, debes revisarlas y verificarlas antes de lanzar la app a producción.
Firebase Rules
firestore.rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// ============================================================
// HELPER FUNCTIONS
// ============================================================
function isAuthenticated() {
return request.auth != null;
}
function isOwner(userId) {
return isAuthenticated() && request.auth.uid == userId;
}
function isDocOwner() {
return isAuthenticated() && request.auth.uid == resource.data.authorUid;
}
// Verifica que el usuario pertenece a la agencia del documento
function isSameAgency(agencyId) {
return isAuthenticated() &&
get(/databases/$(database)/documents/users/$(request.auth.uid)).data.agencyId == agencyId;
}
function isAgencyAdmin(agencyId) {
let u = get(/databases/$(database)/documents/users/$(request.auth.uid)).data;
return isAuthenticated() && u.agencyId == agencyId && u.role == 'admin';
}
function hasOnlyAllowedFields(fields) {
return request.resource.data.keys().hasOnly(fields);
}
function isRecent(time) {
return time is timestamp &&
time > request.time - duration.value(5, 'm') &&
time <= request.time;
}
function isValidEmail(email) {
return email is string &&
email.matches("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$");
}
function isValidUrl(url) {
return url is string && url.matches("^https://.*") && url.size() < 500;
}
// ============================================================
// DOMAIN VALIDATORS
// ============================================================
function isValidLead(data) {
return hasOnlyAllowedFields(['agencyId', 'fullName', 'email', 'status',
'assignedAgentId', 'campaignId', 'score', 'createdAt', 'updatedAt']) &&
data.agencyId is string && data.agencyId.size() > 0 &&
data.fullName is string && data.fullName.size() > 0 && data.fullName.size() < 120 &&
isValidEmail(data.email) &&
data.status in ['new', 'contacted', 'qualified', 'closed_won', 'closed_lost'];
}
function validLeadStatusTransition() {
let prev = resource.data.status;
let next = request.resource.data.status;
return (prev == 'new' && next == 'contacted') ||
(prev == 'contacted' && next == 'qualified') ||
(prev == 'qualified' && next in ['closed_won', 'closed_lost']) ||
prev == next;
}
function isValidCampaign(data) {
return hasOnlyAllowedFields(['agencyId', 'name', 'channel', 'budget',
'leadsCount', 'createdAt']) &&
data.agencyId is string &&
data.name is string && data.name.size() > 0 && data.name.size() < 200 &&
data.channel in ['google_ads', 'meta', 'linkedin', 'email'] &&
data.leadsCount is number && data.leadsCount >= 0;
}
function isValidUser(data) {
return hasOnlyAllowedFields(['email', 'displayName', 'role', 'agencyId', 'photoURL', 'createdAt']) &&
isValidEmail(data.email) &&
data.displayName is string && data.displayName.size() > 0 && data.displayName.size() < 80 &&
data.role in ['admin', 'agent'] &&
data.agencyId is string && data.agencyId.size() > 0;
}
// ============================================================
// COLLECTION RULES
// ============================================================
// agencies — solo owner de la agencia puede leer/editar
match /agencies/{agencyId} {
allow read: if isAuthenticated() && isSameAgency(agencyId);
allow create: if isAuthenticated() && request.resource.data.ownerUid == request.auth.uid;
allow update: if isAgencyAdmin(agencyId);
allow delete: if false; // nunca borrar agencias desde cliente
}
// users — PII: solo el propio usuario puede leer su doc
// Solo admin de la agencia puede crear/editar roles
match /users/{userId} {
allow read: if isOwner(userId);
allow create: if isAuthenticated() && isValidUser(request.resource.data) &&
// Usuario solo puede crearse a sí mismo con rol 'agent'
((isOwner(userId) && request.resource.data.role == 'agent') ||
isAgencyAdmin(request.resource.data.agencyId));
allow update: if isAuthenticated() && isValidUser(request.resource.data) &&
// El agente no puede cambiar su propio rol
((isOwner(userId) && request.resource.data.role == resource.data.role) ||
isAgencyAdmin(resource.data.agencyId)) &&
// agencyId inmutable
request.resource.data.agencyId == resource.data.agencyId;
allow delete: if isAgencyAdmin(resource.data.agencyId);
}
// leads — aislamiento por agencyId + transición de estado validada
match /leads/{leadId} {
allow read: if isSameAgency(resource.data.agencyId);
allow create: if isAuthenticated() && isValidLead(request.resource.data) &&
isSameAgency(request.resource.data.agencyId) &&
request.resource.data.status == 'new' &&
isRecent(request.resource.data.createdAt);
allow update: if isAuthenticated() && isValidLead(request.resource.data) &&
isSameAgency(resource.data.agencyId) &&
validLeadStatusTransition() &&
// campos inmutables
request.resource.data.agencyId == resource.data.agencyId &&
request.resource.data.createdAt == resource.data.createdAt;
allow delete: if isAgencyAdmin(resource.data.agencyId);
// Notas: cualquier miembro de la agencia puede crear/leer; solo el autor edita
match /notes/{noteId} {
allow read: if isSameAgency(resource.data.agencyId);
allow create: if isAuthenticated() && isSameAgency(request.resource.data.agencyId) &&
request.resource.data.authorUid == request.auth.uid &&
request.resource.data.body is string &&
request.resource.data.body.size() > 0 &&
request.resource.data.body.size() < 2000 &&
isRecent(request.resource.data.createdAt);
allow update: if isDocOwner() &&
request.resource.data.body is string &&
request.resource.data.body.size() < 2000 &&
request.resource.data.authorUid == resource.data.authorUid &&
request.resource.data.createdAt == resource.data.createdAt;
allow delete: if isDocOwner() || isAgencyAdmin(resource.data.agencyId);
}
}
// campaigns — CRUD para admins; agentes solo leen
match /campaigns/{campaignId} {
allow read: if isSameAgency(resource.data.agencyId);
allow create: if isAgencyAdmin(request.resource.data.agencyId) &&
isValidCampaign(request.resource.data) &&
isRecent(request.resource.data.createdAt) &&
request.resource.data.leadsCount == 0;
allow update: if isSameAgency(resource.data.agencyId) &&
isValidCampaign(request.resource.data) &&
request.resource.data.agencyId == resource.data.agencyId &&
request.resource.data.createdAt == resource.data.createdAt;
allow delete: if isAgencyAdmin(resource.data.agencyId);
}
}
}
Índices Compuestos
1
leads
agencyId ▲ ASC
status ▲ ASC
createdAt ▼ DESC
Query: leads por agencia + status
2
leads
agencyId ▲ ASC
assignedAgentId ▲ ASC
createdAt ▼ DESC
Query: mis leads asignados
3
leads
agencyId ▲ ASC
campaignId ▲ ASC
score ▼ DESC
Query: leads por campaña + scoring
4
campaigns
agencyId ▲ ASC
channel ▲ ASC
createdAt ▼ DESC
Query: campañas por canal
5
leads/{leadId}/notes
agencyId ▲ ASC
createdAt ▼ DESC
Timeline de notas por lead
TypeScript
src/lib/firestore/leads.ts — SDK queries
import { db } from './firebase';
import { collection, query, where, orderBy, limit, getDocs, Timestamp } from 'firebase/firestore';
// Query 1: leads de mi agencia filtrados por status
export async function getLeadsByStatus(agencyId: string, status: LeadStatus) {
const q = query(
collection(db, 'leads'),
where('agencyId', '==', agencyId),
where('status', '==', status),
orderBy('createdAt', 'desc'),
limit(50)
);
const snap = await getDocs(q);
return snap.docs.map(d => ({ id: d.id, ...d.data() }));
}
// Query 2: leads asignados al agente actual
export async function getMyLeads(agencyId: string, agentUid: string) {
const q = query(
collection(db, 'leads'),
where('agencyId', '==', agencyId),
where('assignedAgentId', '==', agentUid),
orderBy('createdAt', 'desc')
);
const snap = await getDocs(q);
return snap.docs.map(d => ({ id: d.id, ...d.data() }));
}
// Crear lead con validación client-side + server-side (reglas)
export async function createLead(agencyId: string, lead: NewLeadInput) {
const ref = doc(collection(db, 'leads'));
await setDoc(ref, {
...lead,
agencyId,
status: 'new',
leadsCount: 0,
createdAt: Timestamp.now(),
});
return ref.id;
}
Análisis Devil's Advocate — Vectores bloqueados
Cross-tenant leak
Agente de agencia A no puede leer leads de agencia B.
isSameAgency() hace get del perfil del usuario y compara agencyId.Escalada de rol
Un agente no puede escribir
role: 'admin' en su propio documento. La regla exige que el rol sea igual al actual si no eres admin.Bypass de transición
No se puede pasar un lead de
new a closed_won directamente. validLeadStatusTransition() valida cada salto.Contaminación de schema
hasOnlyAllowedFields() en create y update rechaza cualquier campo no declarado en el modelo.Ataque DoS (string 1MB)
Todos los campos de texto tienen límite
.size(): fullName <120, body <2000, name <200. No hay strings sin acotar.Manipulación de timestamp
isRecent() valida que createdAt esté dentro de los últimos 5 minutos. No se puede poner fecha futura ni pasada.Campo inmutable modificado
createdAt y agencyId en leads/campaigns no pueden cambiar en update. Validado explícitamente en cada regla.PII expuesto entre usuarios
La colección
users solo permite leer al propio usuario (isOwner(userId)). Email y datos privados nunca accesibles por terceros.