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:
| Snapshot | Se congela cuando | Qué protege |
|---|---|---|
ServiceTask (desde la plantilla) | Al crear el servicio | Editar la plantilla no reescribe servicios pasados |
Service.basePrice | Al crear el servicio | Cambiar el precio de la propiedad no altera lo cobrado |
ServiceAssignment.hourlyRateSnapshot | Al asignar el empleado | Un 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ón | Qué impide |
|---|---|
work_sessions_one_active_per_employee | Dos sesiones abiertas del mismo empleado a la vez |
work_sessions_end_after_start | Una sesión que termina antes de empezar |
services_base_price_non_negative | Precios negativos |
services_scheduled_minutes_range | Una hora prevista fuera del día (0–1439) |
supplies_quantity_non_negative | Cantidades negativas de insumos |
payroll_totals_non_negative | Totales de nómina negativos |
payroll_period_order | Un período que termina antes de empezar |
app_settings_singleton | Más de una fila de configuración |
app_settings_positive_values | Tipo de cambio o retención en cero |
evidence_kind_target_coherent | Evidencia BEFORE/AFTER sin tarea, o INCIDENT sin incidente |
service_assignments único | Asignar 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 | Índice | Consulta que resuelve |
|---|---|---|
users | username, email (únicos) | Login |
sessions | tokenHash (único), userId | Resolver la cookie en cada petición |
services | status, scheduledDate | Panel y filtros |
services | propertyId, scheduledDate | Historial de una propiedad |
services | status, scheduledDate | Kanban y calendario |
service_assignments | employeeId | «Mis servicios» del empleado |
work_sessions | employeeId, startedAt | Nómina por período |
work_sessions | serviceId, employeeId | Tiempo por empleado en un servicio |
evidence | serviceTaskId, kind, status | ¿La tarea tiene evidencia AFTER? |
evidence | status, expiresAt | Trabajo de retención |
incidents | status, createdAt | Bandeja de incidentes |
audit_logs | createdAt, entityType, entityId | Auditoría y línea de tiempo |
Migraciones
En desarrollo:
pnpm exec prisma migrate dev --name descripcion_del_cambioEn 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
CONCURRENTLYen una migración manual.