Saltar al contenido principal

Rol Staff (equipo comercial interno de Marko)

Acceso transversal a todas las empresas, limitado a datos comerciales. Es el rol con el que el equipo de Marko hace prospección y seguimiento de clientes sin ver la operación de ninguno.

Descripción

Marko es multi-empresa: un usuario normal tiene roles por empresa (roles[companyId]) y solo ve la empresa en la que está. Eso no sirve para el equipo comercial de Marko, que necesita mirar a todos los clientes y a los leads que todavía no son clientes.

staff es ese rol transversal. No es un rol dentro de una empresa ni una variante de admin: es un flag propio del documento de cuenta, deliberadamente separado de los otros dos.

Por qué no se usa admin

admin es el administrador de plataforma y las reglas de Firestore le conceden read + write sobre companies/{id}/{document=**}: facturas, banco, inventario y documentos tributarios de todos los clientes. Eso es exactamente lo que un comercial interno no debe poder.

roles[companyId]staffadmin
AlcanceUna empresaTodas las empresasTodas las empresas
Datos comerciales de otras empresas
Datos operativos (facturas, banco, inventario)✅ (su empresa)
Credenciales de integración de clientes
Pipeline de leads (leads)

Un admin también entra al CRM: el superusuario ya puede todo lo que puede staff. Lo que nunca debe hacerse es fusionar los dos flags ni conceder staff dentro del wildcard de companies/, porque las reglas de Firestore se combinan con OR y no tienen precedencia por especificidad.

Cómo se asigna

El flag se escribe a mano en el documento de la cuenta: no hay casilla en la pantalla de Usuarios y Roles, y es a propósito — son cuentas del equipo interno, no de clientes.

accounts/{uid}
staff: true // habilita el acceso transversal comercial
admin: false // NO es administrador de plataforma
active: true // una cuenta desactivada no opera, aunque sea staff
roles: {} // sin roles: no pertenece a ninguna empresa cliente

Una cuenta staff no debe tener roles en ninguna empresa. Si se le asigna uno, deja de ser "staff puro" y vuelve a navegar como usuario de esa empresa (ver Alcance de navegación).

Qué ve y qué no

Ve:

  • La colección leads completa (pipeline comercial) y su historial de eventos.
  • De cada empresa, solo los campos comerciales: nombre, razón social, RUT, giro, comuna, dirección, contacto, estado de pago, plan, precio de licencia, fechas de trial, código de organismo y fecha de creación.
  • La actividad pública en ChileCompra de cada cuenta (volumen de OC de los últimos 12 meses, canal dominante).
  • El estado del Acuerdo de Servicio de cualquier empresa.

Puede cambiar:

  • El plan de una empresa: plan, estado de pago, precio de licencia y fechas de trial.
  • El Acuerdo de Servicio: generarlo, descargar el contrato firmado con su acta, rotar el enlace de firma y anularlo mientras esté pendiente.
  • Del pipeline: el seguimiento comercial del lead (estado, próxima acción, dueño, notas, etiquetas).

No ve:

  • Facturas, guías, banco, gastos, inventario, OC ni ningún dato operativo de los clientes.
  • Las credenciales de integración que viven en companies.config (maimagToken, mercadoApiKey, tokens de Fintoc, credenciales del SII).

El recorte de la empresa lo hace una whitelist explícita en el backend (COMPANY_COMMERCIAL_FIELDS en functions/src/crm/lib/company-fields.ts), no una lista negra: si mañana se agrega otro secreto al documento de empresa, no se filtra por olvido.

Por eso ninguna pantalla del rol lee companies con el SDK del navegador: piden la lista por Cloud Function (crmListCompanies) y reciben solo los campos comerciales. El costo es perder el tiempo real sobre companies —45 documentos que cambian poco: un plan, un trial—, mientras leads, que es el pipeline vivo y no guarda secretos, se sigue leyendo con onSnapshot.

Desde MRKO-327 eso además lo imponen las reglas: el scope staff ya no tiene get ni list sobre companies. Los tuvo durante MRKO-320 sin que ninguna pantalla los usara, y un permiso sin dueño es una consola de navegador de distancia: bastaba firebase.firestore().collection('companies').get() para bajar los 45 documentos completos. Que el recorte viva en las reglas y no en la disciplina del código es lo que hace que una pantalla nueva no pueda filtrarlo por descuido.

Reglas de Firestore

function isStaff() {
return isAuthenticated() &&
get(/databases/$(database)/documents/accounts/$(request.auth.uid))
.data.get('staff', false) == true;
}
ColecciónLecturaEscritura
leads/{leadId}isAdmin() || isStaff()Solo Admin SDK (Cloud Function)
leads/{leadId}/events/{eventId}isAdmin() || isStaff()Solo Admin SDK — append-only
leads/{leadId}/campaigns/{campaignId}isAdmin() || isStaff()Solo Admin SDK
serviceAgreements/{id}isAdmin() || isStaff() || hasRoleInCompany(...)Solo Admin SDK
staffAccessLog/{logId}❌ cerrada al cliente❌ cerrada al cliente
companies/{id} y subcoleccionesSin acceso staffSin acceso staff
configuration/{doc}Cualquier autenticado (catálogo de planes)Solo admin

Detalles que no son casuales:

  • leads vive en la raíz, hermana de companies y accounts. Un lead es justamente una empresa que todavía no existe como cliente; y anidar la colección bajo companies/ la haría escribible por cualquier miembro de esa empresa, porque las reglas se combinan con OR (la misma razón por la que la bitácora de firmas de MRKO-308 está en raíz).
  • Toda escritura pasa por Cloud Function. Las reglas no pueden garantizar los tres invariantes que el rol necesita: dejar el registro en staffAccessLog, crear la ficha del lead antes del evento (si no, la subcolección queda colgando y el lead se vuelve invisible en todo listado) y traducir el toque al enum cerrado de events.

Las reglas se verifican ejecutándolas, no leyéndolas:

firebase emulators:exec --only firestore --project marko-devenv \
--config firebase.emulator-crm.json "python3 scripts/test-staff-rules.py"

El config aparte existe porque el emulador del repo usa el puerto 8080, ocupado en las máquinas del equipo. Cubre 28 casos, y los que más valen son los negativos: que el staff no lea una empresa, sus facturas, su banco, su inventario ni la propia auditoría.

Auditoría (staffAccessLog)

Un rol que ve a todos los clientes necesita traza desde el día uno. Cada operación del CRM escribe un registro:

staffAccessLog/{autoId}
at // serverTimestamp
uid // quién
email // quién, legible
via // "staff" | "admin"
action // crm-list | crm-view-account | crm-register-touch |
// crm-schedule-action | crm-create-account |
// company-update-plan | agreement-create | agreement-download |
// agreement-revoke | agreement-rotate-link
leadId // cuenta comercial tocada, o null
companyId // empresa cuyos datos se vieron, o null
detail // contexto extra de la operación
  • La colección está cerrada al cliente por completo: si el propio staff pudiera leerla o escribirla, no sería una auditoría. Se consulta desde la consola de Firebase o desde un job.
  • El registro es parte del camino crítico: si la auditoría no se puede escribir, la operación falla y los datos no se entregan. Un acceso sin registrar es justo lo que el criterio de aceptación prohíbe.

Gestión de planes y acuerdos (MRKO-327)

El rol nació sin poder cerrar el ciclo comercial: cambiar el plan de un cliente que compró o generar su Acuerdo de Servicio dependía de un administrador de plataforma. Desde MRKO-327 el staff entra a Configuración → Empresas para las dos cosas.

Qué se le abre y qué no

En esa pestaña, compartida con el administrador de plataforma, el staff ve el listado con datos comerciales y dos acciones por empresa:

  • Configurar Plan — plan, estado de pago, precio de licencia y fechas de trial.
  • Acuerdo de Servicio — generar, ver estado, descargar el contrato firmado con su acta, rotar el enlace de firma y anular mientras esté pendiente.

No se le renderiza ninguna de las demás: crear y editar empresa, eliminar, cargar créditos, configuración de compras ágiles, indicadores operativos y migración a BigQuery siguen siendo del administrador. Y del hub /company-settings no ve ninguna otra pestaña: se le recorta la lista, en vez de mostrarle botones deshabilitados, porque el resto configura una empresa activa que un staff no tiene.

Cómo se escribe

OperaciónCaminoPor qué
Ver el listadocrmListCompaniesWhitelist de campos comerciales; el config con las credenciales nunca sale del backend
Cambiar el plancrmUpdateCompanyPlanEscribe solo los cinco campos del plan y registra el valor anterior y el nuevo
Acuerdo de ServiciocreateServiceAgreement, getAgreementDocumentUrls, revokeServiceAgreement, rotateSignatureLinkYa existían (MRKO-308); ahora aceptan el scope staff y registran quién actuó

Al staff no se le concede write sobre companies, y no es una formalidad: las reglas de Firestore no filtran campos, así que poder escribir el documento para cambiar un plan permitiría reescribir su config — o sea, las credenciales de integración del cliente. El administrador de plataforma, cuyo alcance ya cubre el documento completo, sigue escribiendo directo con el SDK.

crmUpdateCompanyPlan valida además que el plan exista y esté activo en el catálogo (configuration/plans): un id mal escrito deja a la empresa con un plan que ninguna pantalla resuelve y que se muestra como "No asignado" sin que nada falle.

Alcance de navegación

Un usuario staff no pertenece a ninguna empresa, y la app estaba construida asumiendo que todos pertenecen a una. Estos son los puntos donde el rol se comporta distinto:

MomentoComportamiento
LoginSin empresas y con staff (o admin) → va a /crm, no a /dashboard ni a /onboarding.
Onboarding (OnboardingGuard)No aplica: sin esta salida, un staff queda atrapado en /onboarding porque nunca tiene roles.
Menú lateralCRM comercial —fuera del grupo "Comercial", que son las herramientas de la empresa activa— y Configuración.
Selector de empresaOculto: un staff no cambia de empresa.
Rol mostrado en el header"Comercial Marko".
Empresa suspendida (PaymentStatusGuard)La suspensión es del cliente y solo bloquea a sus miembros. A un staff sin rol en esa empresa no lo bloquea.
Configuración (/company-settings)Entra, pero el hub le muestra solo la pestaña Empresas: las otras 14 configuran una empresa activa que no tiene. ManagerAdminGuard lo deja pasar sin empresa.
Rutas operativas escritas a manoStaffScopeGuard lo devuelve al CRM. Solo se permiten /crm, /profile, /help-center y /company-settings.

StaffGuard y StaffScopeGuard

  • StaffGuard protege la entrada a /crm: exige staff === true o admin === true, y que la cuenta no esté desactivada (active !== false).
  • StaffScopeGuard es canActivateChild del layout: restringe a quien no tiene ningún rol en ninguna empresa y no es admin. La condición es estrecha a propósito — todo usuario con al menos un rol navega como siempre, así que el guard no puede dejar afuera a nadie que hoy entre.

Ambos esperan el primer evento de Firebase Auth (waitForAuthUser) en vez de leer currentUser directo. Al entrar por URL —una recarga completa, no una navegación del router— el SDK todavía no restauró la sesión: leyendo currentUser el StaffGuard expulsaba al usuario al dashboard (el CRM no aguantaba un refresco ni un enlace compartido) y el StaffScopeGuard concedía el paso, dejando la restricción sin efecto.

Los guards son solo la puerta de la UI. Lo que de verdad protege los datos son las reglas de Firestore y la validación de cada Cloud Function (assertStaff), que además re-chequea active === false: el login del frontend ya frena una cuenta desactivada, pero ese chequeo es de UI —la sesión de Auth se crea igual y las reglas no miran active en ninguna parte.

Enlaces Relacionados


⬅️ Volver al índice del módulo