Royal Clean CRM · Documentación
Arquitectura

Decisiones técnicas

Las decisiones con su contexto y su motivo, para no revertirlas sin saber qué protegían.

Registro ligero de decisiones (ADR-lite). Cada una explica el contexto, la opción elegida y qué se acepta a cambio.


DEC-001 · Monolito modular, no microservicios

Contexto. Royal Clean son dos propietarios y un puñado de empleados. El sistema tiene que desplegarse en una VPS pequeña y costar poco de operar.

Decisión. Una única aplicación Next.js con dominios separados por carpetas en src/modules/.

Por qué. Repartir esto en servicios añadiría despliegues, redes internas y consistencia eventual sin resolver ningún problema que este negocio tenga. Además, operaciones como «finalizar servicio» tocan varias tablas a la vez: en un monolito son una transacción; entre servicios serían una saga.

A cambio. Escalar significa escalar todo junto. Para este volumen, irrelevante.


DEC-002 · PostgreSQL con Prisma

Decisión. PostgreSQL 17 y Prisma 7 como ORM.

Por qué. Es preferencia explícita del propietario técnico y encaja bien: PostgreSQL ofrece restricciones CHECK e índices parciales, que aquí sostienen invariantes de negocio. Prisma aporta tipos derivados del esquema y migraciones versionadas.

A cambio. Prisma no expresa todas las restricciones, así que las avanzadas se escriben en SQL manual y quedan documentadas.


DEC-003 · Cloudflare R2 con bucket privado

Decisión. La evidencia vive en R2. El bucket es privado y cada visualización pasa por una URL firmada de 10 minutos.

Por qué. R2 no cobra por transferencia de salida, que es justo lo que más pesaría al mostrar galerías de fotografías. Guardar imágenes en PostgreSQL inflaría los respaldos; guardarlas en el disco del contenedor las perdería en cada redespliegue.

A cambio. Una dependencia externa más. Se mitiga con MinIO en desarrollo, que habla el mismo protocolo S3.


DEC-004 · Sesiones de trabajo, no un contador

Contexto. Hay que registrar cuánto trabaja cada persona en cada servicio.

Decisión. Filas WorkSession con inicio y fin. El total se calcula sumando intervalos.

Por qué. Un contador mutable haría imposible pausar, reanudar, reabrir o corregir sin perder información, y escribiría en la base cada segundo. Con sesiones, retomar el trabajo simplemente añade una fila y el tiempo previo queda intacto por construcción.

A cambio. Cada lectura suma intervalos. Con decenas de sesiones por servicio, el costo es despreciable.


DEC-005 · Snapshot de tareas

Decisión. Al crear un servicio, las tareas de la plantilla se copian a registros propios de ese servicio.

Por qué. Un reporte de hace seis meses debe describir lo que realmente se pidió aquel día. Si el servicio leyera la plantilla actual, editarla reescribiría la historia de todos los servicios pasados.

A cambio. Datos duplicados. Es exactamente el punto.


DEC-006 · Snapshot de tarifa

Decisión. La tarifa horaria se congela en ServiceAssignment al asignar.

Por qué. Si María pasa de ₡5.000 a ₡5.500/h en octubre, los servicios de agosto no pueden recalcularse: ya se pagaron a la tarifa anterior. Sin el snapshot, cualquier consulta histórica daría cifras distintas cada vez que alguien ajusta una tarifa.

A cambio. Cambiar una tarifa exige entender que sólo aplica hacia adelante. La interfaz lo advierte de forma explícita.


DEC-007 · PWA, no aplicación nativa

Decisión. Aplicación web instalable, con manifiesto e iconos.

Por qué. Una app nativa exigiría dos bases de código, cuentas de desarrollador y ciclos de revisión en tiendas, para un equipo de tres personas. Con la web se instala desde el navegador y se actualiza al recargar.

A cambio. Sin acceso a APIs nativas. La cámara, que es lo único que hace falta, funciona con un input de captura.


DEC-008 · Sesiones opacas en base de datos, no JWT

Contexto. Hay que autenticar y poder expulsar a alguien de inmediato.

Decisión. Tokens aleatorios en una cookie httpOnly; la base guarda su SHA-256.

Por qué. Un JWT sin estado no se puede revocar: desactivar a un empleado dejaría su token válido hasta que caduque. Aquí, desactivar una cuenta o restablecer una contraseña corta el acceso en el acto. La comprobación cuesta una consulta por petición, memoizada con cache() de React.

También se descartó Auth.js: para credenciales propias sin proveedores externos aporta más superficie que valor, y la versión compatible con este stack está en beta.

A cambio. Una consulta por petición y código propio que mantener. Es poco código y está cubierto por tests.


DEC-009 · PDF con @react-pdf/renderer, no Chromium

Decisión. El reporte se genera con @react-pdf/renderer.

Por qué. Generar PDF con un navegador headless añadiría unos 300 MB a la imagen de Docker y varios cientos de MB de RAM por reporte, en una VPS pequeña. Este renderizador corre en el mismo proceso de Node, es determinista y produce el mismo documento siempre.

A cambio. Un subconjunto de CSS más limitado. Para un reporte tabular con fotografías es suficiente.


DEC-010 · PDF bajo demanda, sin almacenar

Decisión. El reporte se genera en cada descarga y no se guarda.

Por qué. Royal Clean genera unos pocos reportes al día y cada uno tarda menos de un segundo. Guardarlos obligaría a versionarlos e invalidarlos cada vez que cambie algo del servicio; generarlos al vuelo garantiza que siempre reflejan el estado actual, sin almacenamiento ni lógica de caducidad.

A cambio. Se recalcula cada vez. Si el volumen crece mucho, se puede cachear sin cambiar la interfaz.


DEC-011 · Dinero en céntimos enteros

Decisión. Todo importe es un entero de céntimos de CRC.

Por qué. 0.1 + 0.2 no es 0.3 en coma flotante. En una nómina esos errores se acumulan y producen descuadres que nadie sabe explicar. Con enteros la suma es exacta por construcción. Se prefirió a Decimal porque los enteros son más rápidos, viajan sin problema al cliente y no arrastran un tipo especial por todo el código.

A cambio. Hay que convertir en los bordes: al leer de un formulario y al mostrar. Ambas conversiones están centralizadas en un módulo con tests.


DEC-012 · Compresión de imágenes en el navegador

Decisión. La foto se redimensiona y se comprime en el teléfono antes de subirla.

Por qué. Una foto de móvil pesa entre 4 y 12 MB. Subirla entera por una red móvil de Guanacaste para que el servidor la reduzca a 300 KB desperdicia el tiempo y los datos del empleado.

Como efecto secundario deseable, volver a codificar la imagen en un canvas descarta los metadatos EXIF, incluidas las coordenadas GPS y el modelo del dispositivo. Royal Clean no rastrea a su personal, así que esos datos no deben ni salir del teléfono.

A cambio. Depende del navegador. Hay respaldo para los que no soportan WebP.


DEC-013 · Rate limiting en PostgreSQL, sin Redis

Decisión. Los intentos de login se cuentan en una tabla.

Por qué. Royal Clean despliega una sola instancia y PostgreSQL ya está ahí. Un contador en memoria se perdería en cada redespliegue —justo cuando un atacante lo aprovecharía— mientras que la tabla sobrevive a los reinicios y además deja rastro auditable.

A cambio. Una consulta más por intento de login. Irrelevante frente al costo de Argon2.


DEC-014 · Sin tiempo real

Decisión. Nada de websockets. El panel se actualiza al navegar.

Por qué. Un equipo de tres personas no necesita ver los cambios de sus compañeros al instante. Añadir una capa de tiempo real traería infraestructura, reconexiones y estado compartido para resolver un problema que aquí no existe.

A cambio. Los datos pueden tener unos segundos de antigüedad.


DEC-015 · Fuente del sistema, sin Google Fonts

Contexto. El primer build de Docker falló al no poder descargar Inter.

Decisión. Pila de fuentes del sistema.

Por qué. next/font/google descarga la tipografía en tiempo de compilación. Un build sin salida a Internet —CI restringido, VPS detrás de un proxy— falla por completo, y un despliegue autohospedado no puede depender de que Google esté accesible para compilar. Además se ahorra una descarga en la red móvil del personal y el texto se pinta sin salto de fuente.

A cambio. La tipografía cambia según la plataforma. Si Royal Clean quiere una propia, se copia el .woff2 al repositorio y se usa next/font/local, que no necesita red.


DEC-016 · Cliente Prisma perezoso

Contexto. El build de Docker fallaba porque next build importa cada módulo para recolectar datos de las páginas, y el cliente Prisma se construía al importarse, exigiendo un DATABASE_URL real.

Decisión. El cliente se crea en el primer uso, detrás de un Proxy.

Por qué. Permite compilar sin secretos —que es lo correcto: el build no debería necesitarlos— y conserva la ergonomía de import { prisma } sin que ningún llamador tenga que acordarse de invocar una función.

A cambio. Una indirección. Invisible en el uso diario.


DEC-017 · CLI de Prisma instalada aparte en la imagen

Contexto. El contenedor no podía aplicar migraciones: el node_modules de pnpm usa enlaces simbólicos al almacén .pnpm, así que copiar sólo la carpeta prisma dejaba fuera sus dependencias transitivas.

Decisión. Una etapa del Dockerfile instala la CLI con npm —que genera un árbol plano y autocontenido— y se copia a /app/migrator.

Por qué. Es explícito y reproducible. Copiar el almacén .pnpm entero habría inflado la imagen; renunciar a migrar al arrancar habría dejado el paso más delicado del despliegue fuera del contenedor.

A cambio. Unos 250 MB extra en la imagen, porque la CLI arrastra Prisma Studio y su servidor de desarrollo.

Se intentó podarlos —el contenedor sólo ejecuta migrate deploy— pero la CLI de Prisma 7 los requiere incluso para responder a --version. El paso de verificación que se añadió al Dockerfile atrapó el fallo durante el build, que es exactamente donde debía atraparlo. Se acepta el peso a cambio de que el despliegue aplique las migraciones por sí mismo en lugar de depender de un paso manual.


DEC-018 · Direccionamiento por ruta en el cliente S3

Contexto. Un test de integración contra MinIO falló con ENOTFOUND royal-clean-evidence.localhost.

Decisión. forcePathStyle: true en el cliente de S3.

Por qué. El SDK usa por defecto el estilo virtual (bucket.host), que depende de comodines DNS que no controlamos en el endpoint de R2 y que en desarrollo produce hosts inexistentes. Con direccionamiento por ruta, el mismo código funciona contra R2 y contra MinIO sin diferencia.

A cambio. Ninguno relevante: R2 soporta ambos estilos.


DEC-019 · Vista de propiedad en secciones apiladas, no en pestañas

Decisión. La ficha de propiedad muestra datos, plantilla, acceso, historial y cifras en secciones apiladas.

Por qué. Las pestañas esconden información y obligan a más toques, especialmente en móvil, donde los propietarios consultan el negocio. Con secciones, un vistazo y un desplazamiento bastan.

A cambio. La página es larga. Aceptable: se lee de arriba abajo en orden de importancia.


DEC-020 · Kanban con arrastre Y menú de teclado

Decisión. Cada tarjeta del tablero ofrece arrastrar y soltar, y además un selector «Mover a…».

Por qué. El arrastre no funciona con teclado, con lector de pantalla ni bien con el dedo. Ofrecer sólo arrastre habría dejado la función inaccesible para parte de los usuarios. Ambos caminos ejecutan exactamente la misma acción del servidor, que pasa por la máquina de estados.

A cambio. Un control más en cada tarjeta.


DEC-021 — Superadministrador como distintivo, no como tercer rol

Contexto. La especificación fija dos roles y sólo dos: ADMIN y EMPLOYEE (§6). Pero hace falta que una persona concreta —quien administra la aplicación, no el negocio— pueda gestionar las cuentas de todos: crear administradores, restablecer contraseñas, desactivar accesos y cerrar sesiones.

Decisión. Un booleano User.isSuperAdmin, no un valor nuevo en Role.

Por qué. No describe a otra clase de persona: quien lo tiene es un administrador con una atribución más. Añadir un rol habría obligado a revisar cada comprobación de role === "ADMIN" del sistema —hay decenas— y cualquier olvido habría dejado a esa persona fuera de pantallas que sí le corresponden. El error habría sido silencioso y molesto de diagnosticar.

Qué lo sostiene.

  • Restricción en el motor: CHECK (NOT "isSuperAdmin" OR "role" = 'ADMIN'). Un empleado no puede acabar gestionando cuentas por un error de asignación.
  • requireSuperAdmin() comprueba el rol y el distintivo, de forma redundante a propósito: si alguien relajara la restricción de la base, la guarda seguiría sosteniendo la regla.
  • Las acciones lo comprueban otra vez contra la base, no contra la sesión: la guarda protege la pantalla, esa segunda comprobación protege la operación aunque alguien invoque la acción por su cuenta.
  • Nadie puede actuar sobre su propia cuenta desde el panel. Quitarse el acceso a uno mismo deja el sistema sin quien lo administre, y recuperarlo exige entrar a la base a mano.

Consecuencia. La ruta /admin/cuentas responde 404 a un ADMIN normal, igual que cualquier otro recurso que no le corresponde, y el enlace no aparece en su navegación. Ocultarlo no es la protección; es no ofrecer una puerta que no se puede abrir.


DEC-022 · Aviso de reporte listo, no PDF pre-generado

Contexto. El cliente pidió que el reporte de un servicio se genere y envíe solo al terminar, sin que alguien tenga que acordarse de hacerlo. Se evaluó generar el PDF automáticamente al completar el servicio y guardarlo listo para compartir.

Decisión. No se pre-genera ni se almacena. Al completarse un servicio se crea una notificación interna (SERVICE_REPORT_READY) para los administradores activos; el PDF se sigue generando al vuelo en cada descarga, como ya fijaba DEC-010.

Por qué se descartó la alternativa. Guardar el PDF automáticamente reintroduciría exactamente el problema que DEC-010 evitó: versionarlo e invalidarlo cada vez que algo del servicio cambie después de completado (una corrección de horas, una revisión de insumos). El aviso resuelve lo que el cliente pidió en realidad —que no se le olvide a nadie que el reporte ya se puede pedir— sin duplicar esa lógica.

A cambio. Sigue sin haber envío de correo real (§6 de pendientes): el aviso es interno, visible en /admin/notificaciones. Si se confirma el envío por correo, se engancha al mismo evento sin tocar service.ts otra vez.


DEC-023 — El impuesto de una cotización, en puntos básicos y congelado

Contexto. Las cotizaciones llevan IVA. En Costa Rica es el 13 %, y Royal Clean lo pidió así.

Decisión. El tipo se guarda como entero en puntos básicos (1300 = 13,00 %) en cada cotización, y se aplica sobre el subtotal ya sumado, nunca línea a línea.

Por qué.

  • Entero y no 0.13. Un porcentaje en coma flotante arrastra el mismo error que los importes, contra el que existe la invariante del dinero. Una cotización que no cuadra al sumarla a mano no se le puede enseñar a nadie.
  • Congelado por cotización y no leído de la configuración. Si el IVA del país cambia, lo que ya se le pasó al cliente debe seguir diciendo lo que dijo (§18). La configuración sólo aporta el valor por defecto al crear una nueva.
  • Sobre el subtotal y no por línea. Redondear en cada línea y sumar después separa el total de la comprobación que hace cualquier cliente: subtotal por 13 %. Hay un test que fija justo esa diferencia.

Consecuencia. AppSettings.defaultTaxRateBasisPoints propone; Quote.taxRateBasisPoints manda. Los tres importes —subtotal, impuesto y total— se derivan siempre en el servidor y se guardan congelados; el formulario calcula los mismos números con la misma función pura sólo para mostrarlos mientras se escribe.

DEC-024 — La variante dark: atada a la clase, no a la preferencia del sistema

Contexto. El tema lo elige la persona con el interruptor, y se aplica poniendo la clase dark en el <html>; los colores salen de variables CSS redefinidas bajo .dark. Pero Tailwind v4 resuelve dark: con prefers-color-scheme, es decir, con la preferencia del sistema operativo.

Decisión. Declarar @custom-variant dark (&:where(.dark, .dark *)) en globals.css.

Por qué. Sin esa línea las dos cosas se contradicen. Quien tuviera el sistema en oscuro y la aplicación en claro veía la interfaz clara con los fondos de marca en oscuro encima: el elemento activo de la navegación, el avatar, las insignias y el día de hoy en el calendario. Se descubrió al sustituir el logotipo por el real, que tiene una versión clara y otra oscura: en modo claro se mostraba la blanca, invisible sobre la barra lateral.

Consecuencia. Las dieciséis utilidades dark: del sistema siguen ahora al interruptor, que es lo que su código ya daba por hecho. No hay ninguna que quiera seguir al sistema operativo: el tema es una elección explícita.


DEC-025 — El horario como imagen se dibuja en SVG y lo rasteriza sharp

Contexto. Royal Clean captura el horario de la pantalla y se lo manda al personal por WhatsApp. Pidieron poder descargarlo como imagen.

Decisión. Se construye un SVG en el servidor y sharp lo convierte a PNG.

Por qué. La alternativa obvia —un navegador headless que fotografíe la página— costaría unos 300 MB en la imagen de Docker y cientos de MB de RAM por captura, en una VPS pequeña. Es exactamente el razonamiento de DEC-009 para el PDF, y la conclusión es la misma. sharp ya es dependencia del proyecto por la compresión de evidencia, así que no se añade nada.

Consecuencia. El SVG se rasteriza al doble de densidad para que se lea en la pantalla de un teléfono, que es donde se va a mirar. El diseño se escribe a mano en coordenadas, lo que limita lo elaborado que puede ser: a cambio es determinista y no depende de ningún navegador.

La imagen y el PDF parten de buildScheduleLayout, un módulo puro con tests. Si cada formato agrupara por su cuenta, el que se manda y el que se imprime acabarían diciendo cosas distintas.


DEC-026 — Los insumos se importan de un catálogo, copiando sus importes

Contexto. Los insumos se registraban sueltos en cada servicio. «Papel higiénico» se volvía a escribir y a valorar una y otra vez, y acababa costando cosas distintas en servicios del mismo mes. Royal Clean pidió un catálogo como el de tareas.

Decisión. Un SupplyCatalogItem global, editable en /admin/insumos. El servicio lo importa y lo que se guarda en ServiceSupply es una copia de nombre, unidad, costo y precio. catalogItemId queda como referencia, pero ninguna cifra se relee del catálogo.

Por qué. Es la invariante 3 aplicada tal cual: cambiar un precio no puede reescribir el pasado. Apuntar al catálogo habría hecho que subir el precio del café cambiara la rentabilidad de servicios cerrados hace meses, y el desglose de una factura ya emitida dejaría de cuadrar con lo que se cobró.

Consecuencia. Corregir un precio mal puesto no arregla los servicios que ya lo importaron: hay que editarlos. Es el precio de que el histórico no se mueva solo, y es el mismo trato que ya tienen las tareas y las tarifas.

El nombre no lleva @unique en la base. El duplicado entre los activos se comprueba en createSupplyCatalogItem, normalizando mayúsculas y acentos: así «Papel Higiénico» y «papel higienico» no son dos productos, el mensaje sale en español en lugar de un error del motor, y un insumo archivado puede volver a crearse con su nombre — que con una restricción del motor sería imposible.


DEC-027 — Transporte, supervisión y alimentación entran en el margen

Contexto. El catálogo de tareas sólo estimaba el costo de insumos. El resto de lo que Royal Clean paga por ejecutar una tarea —traslado del personal, tiempo de supervisión, viáticos— no aparecía en ninguna cifra, así que el margen que enseñaba el sistema era mejor que el real.

Decisión. Tres campos más en el catálogo de tareas, congelados en ServiceTask al crear el servicio y sumados al costo en calculateServiceFinance.

Por qué. La alternativa era dejarlos como dato informativo, sin tocar el cálculo. Se descartó: un margen que se sabe incompleto y se sigue presentando como definitivo es peor que no tenerlo, porque se usa para decidir precios.

Consecuencia. Son estimaciones conviviendo con gastos medidos, que no es ideal. Se acepta porque la alternativa es contarlos como cero, que es una estimación peor. Un valor sin estimar deja las cifras parciales con el motivo OPERATIONAL_COST_MISSING, de modo que la diferencia entre «no cuesta nada» y «no lo hemos calculado» sigue siendo visible.

Los servicios anteriores a estos campos no los tienen y calculan lo mismo que antes. Hay un test que lo fija: desplegar esto no puede reescribir el histórico.

TASK_OPERATIONAL_COST_SELECT se comparte entre las cuatro consultas que calculan rentabilidad. Repetido cuatro veces, bastaba olvidar uno para que una pantalla diera un margen distinto de las demás sin que nada fallara.

On this page