Royal Clean CRM · Documentación
Arquitectura

Base de datos

Las entidades, sus relaciones y las restricciones que sostienen las invariantes.

PostgreSQL 17 con Prisma 7. El esquema está en prisma/schema.prisma.

Diagrama

erDiagram
    USER ||--o| EMPLOYEE : "es"
    USER ||--o{ SESSION : "abre"
    USER ||--o{ NOTIFICATION : "recibe"
    USER ||--o{ AUDIT_LOG : "genera"

    CLIENT ||--o{ PROPERTY : "posee"
    PROPERTY ||--o{ PROPERTY_TASK_TEMPLATE : "define"
    PROPERTY ||--o{ SERVICE : "recibe"

    SERVICE ||--o{ SERVICE_ASSIGNMENT : "asigna"
    SERVICE ||--o{ SERVICE_TASK : "contiene"
    SERVICE ||--o{ WORK_SESSION : "registra"
    SERVICE ||--o{ EVIDENCE : "acumula"
    SERVICE ||--o{ INCIDENT : "reporta"
    SERVICE ||--o{ SERVICE_SUPPLY : "consume"

    EMPLOYEE ||--o{ SERVICE_ASSIGNMENT : "recibe"
    EMPLOYEE ||--o{ WORK_SESSION : "trabaja"
    EMPLOYEE ||--o{ INCIDENT : "reporta"
    EMPLOYEE ||--o{ PAYROLL_PAYMENT : "cobra"

    SERVICE_TASK ||--o{ EVIDENCE : "documenta"
    INCIDENT ||--o{ EVIDENCE : "adjunta"

    PAYROLL_PAYMENT ||--o{ PAYROLL_PAYMENT_ITEM : "desglosa"
    SERVICE ||--o{ PAYROLL_PAYMENT_ITEM : "liquida"

    USER {
        string id PK
        string username UK
        string email UK "nullable"
        string passwordHash
        enum role "ADMIN | EMPLOYEE"
        bool isActive
        bool mustChangePassword
        datetime archivedAt "nullable"
    }

    EMPLOYEE {
        string id PK
        string userId FK UK
        string firstName
        string lastName
        int hourlyRateOverride "céntimos, nullable"
        bool isActive
        datetime archivedAt "nullable"
    }

    CLIENT {
        string id PK
        string name
        string companyName "nullable"
        datetime archivedAt "nullable"
    }

    PROPERTY {
        string id PK
        string clientId FK
        string name
        string address
        string doorCode "sensible, nullable"
        string lockboxCode "sensible, nullable"
        int defaultServicePrice "céntimos, nullable"
        datetime archivedAt "nullable"
    }

    PROPERTY_TASK_TEMPLATE {
        string id PK
        string propertyId FK
        string title
        bool requiresAfterPhoto
        int sortOrder
        bool isActive
    }

    SERVICE {
        string id PK
        string propertyId FK
        date scheduledDate "día local CR"
        int scheduledStartMinutes "nullable"
        enum status
        int basePrice "céntimos, congelado"
        datetime completedAt "nullable"
        bool hasTimeWarning
        bool hasIncident
    }

    SERVICE_ASSIGNMENT {
        string id PK
        string serviceId FK
        string employeeId FK
        int hourlyRateSnapshot "céntimos, congelado"
        datetime releasedAt "nullable"
    }

    SERVICE_TASK {
        string id PK
        string serviceId FK
        string sourceTemplateId "nullable"
        string title "copia inmutable"
        bool requiresAfterEvidence
        enum status "PENDING | COMPLETED"
        string completedByEmployeeId FK "nullable"
    }

    WORK_SESSION {
        string id PK
        string serviceId FK
        string employeeId FK
        datetime startedAt
        datetime endedAt "nullable = en curso"
        string editReason "nullable"
    }

    EVIDENCE {
        string id PK
        enum kind "BEFORE | AFTER | INCIDENT | GENERAL"
        enum status "PENDING | CONFIRMED | DELETED | EXPIRED"
        string objectKey UK "clave en R2"
        string serviceTaskId FK "nullable"
        datetime expiresAt "retención"
    }

    INCIDENT {
        string id PK
        string serviceId FK
        enum category
        enum severity
        enum status "OPEN | RESOLVED"
    }

    SERVICE_SUPPLY {
        string id PK
        string serviceId FK
        decimal quantity
        int unitChargePrice "céntimos"
        int totalInternalCost "céntimos, nullable"
        enum status "PENDING_REVIEW | APPROVED | REJECTED"
    }

    PAYROLL_PAYMENT {
        string id PK
        string employeeId FK
        date periodStart
        date periodEnd
        int totalAmount "céntimos, congelado"
        enum status "PENDING | PAID"
    }

    PAYROLL_PAYMENT_ITEM {
        string id PK
        string paymentId FK
        string propertyNameSnapshot
        int minutes
        int hourlyRateSnapshot
        int amount "céntimos"
    }

Convenciones

Dinero: enteros de céntimos

Todo importe es un Int que representa céntimos de CRC (1 colón = 100 céntimos). Nunca Float, nunca Decimal para dinero.

La razón es que la aritmética IEEE-754 no puede representar 0.1 exactamente y 0.1 + 0.2 !== 0.3. En una nómina, esos errores se acumulan y producen descuadres que nadie sabe explicar. Con enteros, la suma es exacta por construcción.

Toda conversión pasa por src/modules/finance/money.ts.

La única excepción es ServiceSupply.quantity, que es Decimal(12,3) porque representa una cantidad física (1,5 kg), no dinero. Al multiplicarla por un precio se escala a enteros antes de operar.

Tiempo: instantes UTC y días de negocio

Los DateTime son instantes absolutos, almacenados en UTC. La presentación se hace siempre en America/Costa_Rica, de forma explícita.

La excepción deliberada es Service.scheduledDate, que es @db.Date: un día del calendario, no un instante. Se acompaña de scheduledStartMinutes (minutos desde medianoche en hora local). Así, «el servicio del 1 de septiembre a las 10:00» significa lo mismo aunque el servidor esté en otro huso.

Costa Rica es UTC−6 todo el año y no observa horario de verano, lo que simplifica los cálculos, pero el código no asume el desplazamiento: usa date-fns-tz.

Utilidades en src/lib/datetime.ts.

Borrado lógico

Ninguna entidad histórica se borra físicamente. archivedAt marca el archivado en User, Employee, Client, Property, Service y PropertyTaskTemplate.

Un empleado desactivado conserva sus servicios, sus horas, su nómina y su rastro de auditoría. Una propiedad archivada conserva todo su historial.

Snapshots

Tres copias inmutables sostienen la integridad histórica:

SnapshotSe congela cuandoQué protege
ServiceTask (desde la plantilla)Al crear el servicioEditar la plantilla no reescribe servicios pasados
Service.basePriceAl crear el servicioCambiar el precio de la propiedad no altera lo cobrado
ServiceAssignment.hourlyRateSnapshotAl asignar el empleadoUn aumento de tarifa no recalcula la nómina de meses anteriores

A los que se suma un cuarto en el momento del pago: PayrollPaymentItem congela el desglose completo del recibo.

Invariantes en la base de datos

La aplicación valida, pero la base de datos garantiza. Las siguientes restricciones viven en prisma/migrations/20260829204500_invariants/migration.sql:

RestricciónQué impide
work_sessions_one_active_per_employeeDos sesiones abiertas del mismo empleado a la vez
work_sessions_end_after_startUna sesión que termina antes de empezar
services_base_price_non_negativePrecios negativos
services_scheduled_minutes_rangeUna hora prevista fuera del día (0–1439)
supplies_quantity_non_negativeCantidades negativas de insumos
payroll_totals_non_negativeTotales de nómina negativos
payroll_period_orderUn período que termina antes de empezar
app_settings_singletonMás de una fila de configuración
app_settings_positive_valuesTipo de cambio o retención en cero
evidence_kind_target_coherentEvidencia BEFORE/AFTER sin tarea, o INCIDENT sin incidente
service_assignments únicoAsignar dos veces al mismo empleado en un servicio

El índice único parcial merece un comentario aparte:

CREATE UNIQUE INDEX "work_sessions_one_active_per_employee"
  ON "work_sessions" ("employeeId")
  WHERE "endedAt" IS NULL;

Convierte el doble toque en «Iniciar servicio» en algo imposible, no simplemente improbable. La aplicación además lo maneja de forma idempotente, pero la garantía última es del motor.

Índices

Definidos según los patrones de consulta reales:

TablaÍndiceConsulta que resuelve
usersusername, email (únicos)Login
sessionstokenHash (único), userIdResolver la cookie en cada petición
servicesstatus, scheduledDatePanel y filtros
servicespropertyId, scheduledDateHistorial de una propiedad
servicesstatus, scheduledDateKanban y calendario
service_assignmentsemployeeId«Mis servicios» del empleado
work_sessionsemployeeId, startedAtNómina por período
work_sessionsserviceId, employeeIdTiempo por empleado en un servicio
evidenceserviceTaskId, kind, status¿La tarea tiene evidencia AFTER?
evidencestatus, expiresAtTrabajo de retención
incidentsstatus, createdAtBandeja de incidentes
audit_logscreatedAt, entityType, entityIdAuditoría y línea de tiempo

Migraciones

En desarrollo:

pnpm exec prisma migrate dev --name descripcion_del_cambio

En producción nunca se usa migrate dev. El contenedor ejecuta prisma migrate deploy al arrancar (ver docker/entrypoint.sh). Si una migración falla, el contenedor no arranca: es preferible que Dokploy conserve la versión anterior a servir la aplicación contra un esquema incompleto.

Migraciones compatibles

Para evitar despliegues destructivos:

  • Una columna nueva obligatoria necesita un valor por defecto, o se añade en dos pasos: primero opcional, se rellena, luego se hace obligatoria.
  • No se renombran columnas en uso: se añade la nueva, se migran los datos y se retira la antigua en un despliegue posterior.
  • Los índices sobre tablas grandes se crean con CONCURRENTLY en una migración manual.

On this page