Royal Clean CRM · Documentación
Arquitectura

Arquitectura

Monolito modular, anatomía de un módulo y por qué está dividido así.

Contexto

Royal Clean limpia propiedades vacacionales en Guanacaste. El sistema resuelve un ciclo concreto:

flowchart LR
    A[Administrador<br/>programa servicio] --> B[Empleado recibe<br/>la asignación]
    B --> C[Inicia el servicio<br/>en la propiedad]
    C --> D[Completa tareas<br/>con evidencia]
    D --> E[Finaliza el servicio]
    E --> F[Administrador revisa<br/>tiempos y evidencia]
    F --> G[Nómina y<br/>rentabilidad]
    F --> H[Reporte PDF<br/>al cliente]

Es un sistema de un solo inquilino: existe únicamente para Royal Clean. No hay organizaciones, ni espacios de trabajo, ni conmutación entre empresas.

Forma del sistema

Monolito modular sobre Next.js. Una aplicación, un despliegue, una base de datos.

Se descartó una arquitectura de microservicios de forma deliberada: para un equipo de dos propietarios y un puñado de empleados, repartir esto en servicios añadiría despliegues, redes y consistencia eventual sin resolver ningún problema real. La separación que sí importa —la de dominios— se consigue con carpetas y límites de importación, que además son gratis de mantener.

src/
├── app/                    Rutas de Next.js
│   ├── (auth)/             login, cambio de contraseña
│   ├── admin/              Panel administrativo (rol ADMIN)
│   ├── app/                Aplicación del personal (rol EMPLOYEE)
│   └── api/                Route handlers: evidencia, reportes, salud, jobs
├── components/
│   ├── ui/                 Primitivas del sistema de diseño
│   ├── admin/              Componentes del panel
│   ├── employee/           Componentes de campo
│   ├── evidence/           Captura y galería de fotografías
│   └── layout/             Estructuras de navegación
├── modules/                Dominios de negocio
│   ├── auth/               Sesiones, contraseñas, guardas, rate limiting
│   ├── directory/          Clientes, propiedades, empleados
│   ├── services/           Servicios, tareas, estados, sesiones de trabajo
│   ├── evidence/           Ciclo de vida de la evidencia y almacenamiento
│   ├── incidents/          Reporte y resolución de incidentes
│   ├── payroll/            Cálculo de nómina y pagos
│   ├── finance/            Dinero y rentabilidad
│   ├── reports/            Reporte PDF para el cliente
│   ├── notifications/      Avisos internos
│   ├── audit/              Registro de auditoría
│   ├── settings/           Configuración global
│   └── dashboard/          Consultas agregadas del panel
├── lib/                    Utilidades transversales
└── server/                 Cliente Prisma y trabajos de fondo

Anatomía de un módulo

Cada dominio sigue el mismo reparto, lo que hace predecible dónde buscar:

ArchivoResponsabilidad
calculations.tsLógica pura: sin Prisma, sin React, con tests directos
service.tsOperaciones de dominio: transacciones, invariantes, auditoría
queries.tsLecturas para la interfaz
actions.tsServer Actions: validan, autorizan y delegan en service.ts
validation.tsEsquemas Zod compartidos
labels.tsTextos en español de los enums

La regla que sostiene el diseño: ninguna fórmula de negocio vive en un componente de React. El cálculo de horas, de nómina y de rentabilidad está en módulos puros con tests. La interfaz los consume.

Convención de mutaciones

MecanismoCuándo se usa
Server ActionsToda mutación que nace de la interfaz
Route HandlersLo que no encaja en una acción: servir evidencia, generar el PDF, healthcheck, disparar trabajos

Las Server Actions traen validación de origen incorporada, lo que da protección CSRF sin gestionar tokens a mano. Los Route Handlers se reservan para respuestas que no son navegación —una redirección a R2, un PDF, un JSON para un cron.

Todas las acciones devuelven el mismo contrato discriminado:

type ActionResult<T> =
  | { ok: true; data: T }
  | { ok: false; error: string; fieldErrors?: Record<string, string[]> };

Nunca se propaga una excepción cruda al cliente. Un error de Prisma en pantalla es a la vez inútil para el usuario y una filtración de detalles internos.

Flujo de autenticación

sequenceDiagram
    participant N as Navegador
    participant A as Aplicación
    participant D as PostgreSQL

    N->>A: POST /login (usuario, contraseña)
    A->>D: ¿Excedió el límite de intentos?
    D-->>A: Recuento de la ventana
    A->>D: Buscar usuario
    A->>A: Argon2id verify (hash real o señuelo)
    A->>D: Registrar intento
    A->>D: Crear sesión (guarda SHA-256 del token)
    A-->>N: Cookie httpOnly + secure con el token en claro
    N->>A: Petición siguiente (cookie)
    A->>D: Buscar por hash del token
    D-->>A: Sesión válida y no revocada

Se eligieron sesiones opacas en base de datos y no JWT sin estado porque Royal Clean necesita expulsar a alguien de inmediato: desactivar a un empleado o restablecer su contraseña debe cortar el acceso en el momento, no cuando caduque un token. Ver DECISIONS.md DEC-008.

Flujo de evidencia

sequenceDiagram
    participant E as Teléfono del empleado
    participant A as Aplicación
    participant R as Cloudflare R2

    E->>E: Comprimir, corregir orientación, borrar EXIF
    E->>A: requestUpload (tipo, tamaño)
    A->>A: Autorizar, validar, generar la clave del objeto
    A->>R: Firmar URL de subida (15 min)
    A-->>E: URL firmada + id de evidencia
    E->>R: PUT directo del archivo
    E->>A: confirmUpload (id)
    A->>R: HEAD: ¿existe de verdad?
    A->>A: Marcar CONFIRMED

Tres propiedades que este diseño garantiza:

  • El archivo no atraviesa la aplicación: sube directo a R2, así que un servidor pequeño no se convierte en cuello de botella.
  • La clave del objeto la genera siempre el servidor. El nombre del archivo del usuario nunca se usa como ruta.
  • Sólo la evidencia CONFIRMED cuenta para completar una tarea. Si la subida falla a medias, la tarea sigue incompleta, que es lo correcto.

Detalle en R2_STORAGE.md.

Modelo de tiempo

Es el núcleo del sistema y merece decirse explícitamente: no existe ningún contador acumulativo de segundos en la base de datos.

flowchart TD
    A[Iniciar servicio] --> B[Se crea WorkSession<br/>startedAt = ahora, endedAt = null]
    B --> C{¿Qué pasa después?}
    C -->|Finaliza su parte| D[endedAt = ahora]
    C -->|Retoma el trabajo| E[Se crea OTRA WorkSession]
    D --> F[Total = suma de intervalos]
    E --> F

El tiempo trabajado se deriva siempre de las filas. Consecuencias directas:

  • Reabrir un servicio suma una sesión nueva; el tiempo previo nunca se pierde ni se reinicia.
  • Cada empleado tiene sus propias sesiones: en un servicio con dos personas, cada una cobra sus horas con su tarifa.
  • Corregir un horario es editar una fila, con motivo obligatorio y auditoría.
  • Un índice único parcial en PostgreSQL impide dos sesiones abiertas del mismo empleado, así que el doble toque en «Iniciar» no puede duplicar nada.

Trabajos de fondo

Sin Redis, sin colas, sin workers. Una única rutina idempotente (src/server/jobs/maintenance.ts) que expira evidencia, limpia subidas abandonadas, marca sesiones demasiado largas y purga sesiones y notificaciones antiguas.

Se dispara de dos formas equivalentes:

  • pnpm job:cleanup desde un cron del sistema.
  • POST /api/jobs/run con un token Bearer, para el cron de Dokploy.

Despliegue

flowchart LR
    A[GitHub privado] -->|push a main| B[Dokploy]
    B --> C[docker build]
    C --> D[prisma migrate deploy]
    D --> E[node server.js]
    E --> F[/api/health]
    F -->|sano| G[Traefik enruta el tráfico]
    E --> H[(PostgreSQL<br/>red privada)]
    E --> I[Cloudflare R2]

output: "standalone" de Next.js empaqueta sólo las dependencias de ejecución. La imagen resultante ronda los 420 MB y arranca con node server.js, sin gestor de paquetes dentro del contenedor.

Ver DEPLOYMENT.md y DOKPLOY.md.

Compromisos asumidos

DecisiónSe ganaSe acepta
Monolito modularUn despliegue, transacciones realesEscalar significa escalar todo junto
Sesiones en base de datosRevocación inmediataUna consulta por petición (mitigada con cache())
Dinero en céntimos enterosAritmética exactaHay que convertir en los bordes de entrada y salida
Sin realtimeCero infraestructura extraEl panel se actualiza al navegar, no solo
Compresión en el clienteNo se suben 10 MB por una fotoDepende del navegador del teléfono
PDF bajo demandaSiempre refleja el estado actualSe regenera en cada descarga
Fuente del sistemaBuild sin red, cero peticiones a tercerosLa tipografía cambia según la plataforma

On this page