Servicios
services/README.md
Servicios de Firestore
La capa de servicios actúa como la única interfaz entre la lógica de la aplicación y Firebase Firestore. Ningún componente ni hook accede al adaptador directamente; toda operación de datos pasa obligatoriamente por estas funciones.
Descripción General
Cada servicio encapsula las operaciones CRUD y las suscripciones en tiempo real de un dominio específico. Los servicios dependen de dos capas:
@/adapters/firebase-client-adapter— Abstracción sobre el SDK de Firebase (Firestore, Auth, Storage).@/schemas/— Tipos TypeScript inferidos de Zod, que actúan como Single Source of Truth para la forma de cada entidad.
El patrón aplicado en todos los servicios es idéntico: se obtiene la instancia de Firestore con getFirestoreInstance(), se delega la operación al adaptador con la colección o ruta correcta, y se inyectan createdAt/updatedAt donde corresponde.
Estructura de Archivos
services/
├── categories/
│ ├── category-catalog.ts # Composición: agrupa grupos + categorías + subcategorías en paralelo
│ ├── category-catalog.test.ts
│ ├── group-service.ts # CRUD + suscripciones para grupos
│ ├── group-service.test.ts
│ ├── category-service.ts # CRUD + suscripciones para categorías
│ ├── category-service.test.ts
│ ├── subcategory-service.ts # CRUD + suscripciones para subcategorías
│ └── subcategory-service.test.ts
├── users/
│ ├── user-service.ts # CRUD + suscripciones para usuarios
│ └── user-service.test.ts
├── materials/
│ ├── material-service.ts # CRUD + suscripciones (SDK cliente, tiempo real)
│ ├── material-service.test.ts
│ ├── material-admin-service.ts # Consultas servidor con Admin SDK (caché + filtros)
│ ├── material-admin-service.test.ts
│ └── material-categorization.ts # Resuelve IGroup + ICategory + ISubcategory para un material
└── auth/ # (pendiente de implementación)
Patrón Común
Todos los servicios siguen la misma estructura interna:
// 1. Obtener instancia de Firestore
const db = getFirestoreInstance();
// 2. Delegar al adaptador con la colección (getCollection, createDocument…)
// o con la ruta de documento (getDocument, updateDocument, deleteDocument)
return getCollection<IEntidad>(db, COLLECTIONS.ENTIDADES);
// 3. En create: inyectar timestamps
const now = new Date();
return createDocument<IEntidad>(db, COLLECTIONS.ENTIDADES, {
...data,
createdAt: now,
updatedAt: now,
});
// 4. En update: inyectar updatedAt
await updateDocument<IEntidad>(db, COLLECTION_PATHS.entidad(id), {
...data,
updatedAt: new Date(),
});
Las rutas de documento se construyen con los builders de COLLECTION_PATHS (definidos en schemas/collections.ts). Los nombres de colección provienen de la constante COLLECTIONS (definida en constants/firebase.constants.ts).
� Composición de Catálogo
Archivo: services/categories/category-catalog.ts
Función de composición que obtiene grupos, categorías y subcategorías en paralelo con Promise.all y los agrupa en un array de ICategoryCatalog.
export async function getCategoryCatalog(): Promise<Array<ICategoryCatalog>> {
const [groups, categories, subcategories] = await Promise.all([
getAllGroups(),
getAllCategories(),
getAllSubcategories(),
]);
return groups.map((group) => {
const groupCategories = categories.filter((c) => c.groupId === group.id);
const categoryIds = new Set(groupCategories.map((c) => c.id));
const groupSubcategories = subcategories.filter((s) => categoryIds.has(s.categoryId));
return { group, categories: groupCategories, subcategories: groupSubcategories };
});
}
El resultado se transforma luego a IGroupCatalogView[] mediante buildGroupCatalogViews() en utils/categories/build-catalog.ts antes de ser consumido por los componentes.
Testing: category-catalog.test.ts cubre 7 escenarios: resultado vacío, agrupación correcta, exclusión cruzada de grupos, categorías vacías, llamadas paralelas verificadas, propagación de errores y múltiples grupos.
🗂️ Servicio de Grupos
Colección Firestore: groups
Tipos: IGroup, ICreateGroup, IUpdateGroup — de @/schemas/categories/category.schema
Funciones CRUD
| Función | Firma | Descripción |
|---|---|---|
getAllGroups | () → Promise<IGroup[]> | Retorna todos los documentos de la colección groups |
getGroupById | (id: string) → Promise<IGroup | null> | Busca un grupo por su ID. Retorna null si no existe |
createGroup | (data: ICreateGroup) → Promise<IGroup> | Crea un nuevo grupo con createdAt y updatedAt inyectados |
updateGroup | (id: string, data: IUpdateGroup) → Promise<void> | Actualiza campos del grupo e inyecta updatedAt |
deleteGroup | (id: string) → Promise<void> | Elimina el documento del grupo |
Funciones de Suscripción
| Función | Firma | Descripción |
|---|---|---|
subscribeToGroups | (onNext, onError?) → Unsubscribe | Escucha cambios en toda la colección groups |
subscribeToGroup | (id, onNext, onError?) → Unsubscribe | Escucha cambios en un grupo específico |
🗂️ Servicio de Categorías
Archivo: services/categories/category-service.ts
Colección Firestore: categories
Tipos: ICategory, ICreateCategory, IUpdateCategory — de @/schemas/categories/category.schema
Una categoría pertenece a un grupo a través del campo groupId.
Funciones CRUD
| Función | Firma | Descripción |
|---|---|---|
getAllCategories | () → Promise<ICategory[]> | Retorna todos los documentos de la colección categories |
getCategoryById | (id: string) → Promise<ICategory | null> | Busca una categoría por ID. Retorna null si no existe |
createCategory | (data: ICreateCategory) → Promise<ICategory> | Crea una nueva categoría con timestamps inyectados |
updateCategory | (id: string, data: IUpdateCategory) → Promise<void> | Actualiza campos e inyecta updatedAt |
deleteCategory | (id: string) → Promise<void> | Elimina el documento de la categoría |
Funciones de Suscripción
| Función | Firma | Descripción |
|---|---|---|
subscribeToCategories | (onNext, onError?) → Unsubscribe | Escucha cambios en toda la colección categories |
subscribeToCategory | (id, onNext, onError?) → Unsubscribe | Escucha cambios en una categoría específica |
🗂️ Servicio de Subcategorías
Archivo: services/categories/subcategory-service.ts
Colección Firestore: subcategories
Tipos: ISubcategory, ICreateSubcategory, IUpdateSubcategory — de @/schemas/categories/category.schema
Una subcategoría pertenece a una categoría a través del campo categoryId.
Funciones CRUD
| Función | Firma | Descripción |
|---|---|---|
getAllSubcategories | () → Promise<ISubcategory[]> | Retorna todos los documentos de la colección subcategories |
getSubcategoryById | (id: string) → Promise<ISubcategory | null> | Busca una subcategoría por ID. Retorna null si no existe |
createSubcategory | (data: ICreateSubcategory) → Promise<ISubcategory> | Crea una nueva subcategoría con timestamps inyectados |
updateSubcategory | (id: string, data: IUpdateSubcategory) → Promise<void> | Actualiza campos e inyecta updatedAt |
deleteSubcategory | (id: string) → Promise<void> | Elimina el documento de la subcategoría |
Funciones de Suscripción
| Función | Firma | Descripción |
|---|---|---|
subscribeToSubcategories | (onNext, onError?) → Unsubscribe | Escucha cambios en toda la colección subcategories |
subscribeToSubcategory | (id, onNext, onError?) → Unsubscribe | Escucha cambios en una subcategoría específica |
👤 Servicio de Usuarios
Archivo: services/users/user-service.ts
Colección Firestore: users
Tipos: IUser, ICreateUser, IUpdateUser — de @/schemas/users/user.schema
Funciones CRUD
| Función | Firma | Descripción |
|---|---|---|
getAllUsers | () → Promise<IUser[]> | Retorna todos los documentos de la colección users |
getUserById | (id: string) → Promise<IUser | null> | Busca un usuario por ID. Retorna null si no existe |
createUser | (data: ICreateUser) → Promise<IUser> | Crea un nuevo usuario con timestamps inyectados |
updateUser | (id: string, data: IUpdateUser) → Promise<void> | Actualiza campos del usuario e inyecta updatedAt |
deleteUser | (id: string) → Promise<void> | Elimina el documento del usuario |
Funciones de Suscripción
| Función | Firma | Descripción |
|---|---|---|
subscribeToUsers | (onNext, onError?) → Unsubscribe | Escucha cambios en toda la colección users |
subscribeToUser | (id, onNext, onError?) → Unsubscribe | Escucha cambios en un usuario específico |
📦 Servicio de Materiales (Cliente)
Archivo: services/materials/material-service.ts
Colección Firestore: materials
Tipos: IMaterial, ICreateMaterial, IUpdateMaterial — de @/schemas/materials/material.schema
Este servicio usa el SDK cliente de Firebase y opera en el contexto del navegador. Además del CRUD estándar, expone createMaterialsBatch para importaciones masivas.
Un material almacena de forma desnormalizada los tres niveles de categorización: groupId, categoryId, subcategoryId y los nombres correspondientes (groupName, categoryName, subcategoryName).
Funciones CRUD
| Función | Firma | Descripción |
|---|---|---|
getAllMaterials | () → Promise<IMaterial[]> | Retorna todos los documentos de la colección materials |
getMaterialById | (id: string) → Promise<IMaterial | null> | Busca un material por ID. Retorna null si no existe |
createMaterial | (data: ICreateMaterial) → Promise<IMaterial> | Crea un nuevo material con timestamps y todos los campos de categorización |
updateMaterial | (id, data: IUpdateMaterial) → Promise<void> | Actualiza campos del material e inyecta updatedAt |
deleteMaterial | (id: string) → Promise<void> | Elimina el documento del material |
createMaterialsBatch | (items: ICreateMaterial[]) → Promise<void> | Crea múltiples materiales en lote usando writeBatch. Asigna id y path a cada documento. Límite de 499 por chunk. |
Funciones de Suscripción
| Función | Firma | Descripción |
|---|---|---|
subscribeToMaterials | (onNext, onError?) → Unsubscribe | Escucha cambios en toda la colección materials |
subscribeToMaterial | (id, onNext, onError?) → Unsubscribe | Escucha cambios en un material específico |
📦 Servicio de Materiales (Admin / Servidor)
Archivo: services/materials/material-admin-service.ts
Adaptador: @/adapters/firebase-admin-adapter
Uso: Exclusivo en Route Handlers de Next.js (contexto de servidor)
Este servicio usa el SDK Admin de Firebase, que opera con privilegios elevados y no está sujeto a las Firestore Security Rules del cliente. Expone dos funciones con comportamientos diferenciados según el rol del usuario.
Funciones
| Función | Firma | Descripción |
|---|---|---|
getCachedMaterials | () → Promise<IMaterial[]> | Consulta cacheada con 'use cache' (Next.js ISR). Se invalida con cacheTag('materials'). Filtra por isActive === true. |
getMaterials | (filters?: IMaterialFilters) → Promise<IMaterial[]> | Consulta en vivo, sin caché. Aplica filtros compuestos en Firestore. Usada para el rol admin. |
Filtros disponibles (IMaterialFilters)
interface IMaterialFilters {
groupId?: string;
categoryId?: string;
subcategoryId?: string;
}
Los filtros se componen internamente con buildConstraints(): cada filtro presente agrega un where() sobre la query de Firestore. La presencia de múltiples filtros requiere índices compuestos (ver firestore.indexes.json).
Testing: material-admin-service.test.ts cubre 10 escenarios: constraint activo base, filtro por grupo/categoría/subcategoría/todos combinados, resultado vacío y propagación de errores.
🔄 Flujo de Datos
Componente / Hook
│
│ llama a función del servicio
▼
Service (services/[dominio]/[entidad]-service.ts)
│
│ getFirestoreInstance() + adaptador cliente
▼
Firebase Client Adapter (@/adapters/firebase-client-adapter)
│
│ SDK de Firebase (firebase/firestore)
▼
Firestore (colección en GCP)
Para consultas de servidor (Next.js Route Handlers), el flujo usa el Admin SDK:
Route Handler (app/api/[ruta]/route.ts)
│
│ verifyIdToken() + getMaterials() / getCachedMaterials()
▼
Service Admin (services/materials/material-admin-service.ts)
│
│ getCollection() + AdminQueryConstraint[]
▼
Firebase Admin Adapter (@/adapters/firebase-admin-adapter)
│
│ firebase-admin SDK (sin reglas de seguridad del cliente)
▼
Firestore (colección en GCP)
Para suscripciones en tiempo real, el flujo es inverso: Firestore emite un snapshot → el adaptador lo deserializa → el servicio lo propaga vía onNext → el hook actualiza el estado.
🔐 Seguridad y Roles
El control de acceso a las colecciones se define en las Firestore Security Rules del proyecto (firestore.rules), no en la capa de servicios. Los servicios asumen que el usuario autenticado tiene permisos sobre los recursos que solicita; si Firestore rechaza la operación, el error se propaga tal cual hacia arriba.
Nota: En el estado actual de desarrollo,
firestore.rulespermiteread, write: if truepara facilitar la iteración. Las reglas de producción por rol se definirán en una fase posterior.
La API REST de materiales (app/api/materials/route.ts) implementa su propio control de acceso a nivel de Route Handler: verifica el token Authorization: Bearer <token> con verifyIdToken() del Admin SDK y deriva a getMaterials() (sin caché, con filtros Firestore) para el rol admin, o a getCachedMaterials() para todos los demás.
| Colección | Acceso esperado |
|---|---|
users | Solo admin puede listar todos; cada usuario puede leer/actualizar su propio documento |
groups, categories, subcategories | Lectura pública para usuarios autenticados; escritura solo para admin |
materials | Lectura para todos los roles; escritura para admin y supplier_owner |
🔁 Uso con Suscripciones en tiempo real
Las funciones subscribeTo* retornan una función Unsubscribe que debe invocarse al desmontar el componente o cuando la suscripción ya no sea necesaria, para evitar memory leaks.
// Ejemplo de uso en un hook con useEffect
useEffect(() => {
const unsubscribe = subscribeToGroups(
(groups) => setGroups(groups),
(error) => console.error(error),
);
return () => unsubscribe(); // limpieza al desmontar
}, []);
Cómo Agregar un Nuevo Servicio
- Crear el archivo en
services/[dominio]/[entidad]-service.ts. - Importar los adaptadores desde
@/adapters/firebase-client-adapter. - Importar
COLLECTIONSdesde@/constants/firebase.constantsyCOLLECTION_PATHSdesde@/schemas/collections. - Importar los tipos
I[Entidad],ICreate[Entidad],IUpdate[Entidad]del schema correspondiente. - Implementar las 5 funciones CRUD + 2 de suscripción siguiendo el patrón descrito.
- Crear el archivo de tests
[entidad]-service.test.tsmockeando@/adapters/firebase-client-adapter.
