Data Model — 001 Núcleo de agenda

Fuente: spec.md (FR-001…FR-020 + clarificaciones 2026-09-29). Dinero en céntimos INTEGER, tiempo en timestamptz, zona Europe/Madrid, idioma es-ES.

Clínica

Campo Tipo Reglas
id uuid PK generado BD
nombre text obligatorio, p. ej. Eleva
telefono text obligatorio, contacto mostrado en avisos de fuera de plazo (enmienda S-01 jul2026); la semilla de Eleva lo incluye
claveHash text hash argon2/bcrypt de la clave de panel; jamás en claro (FR-002, deuda v1)
jornadaInicio / jornadaFin time configurable por clínica; defecto 09:00/20:00 (Q2/B, FR-013)
diasLaborables int[] días ISO de apertura (1=lunes…7=domingo); defecto {1,2,3,4,5} (enmienda S-06 pedida por 006; reconfigurable vía semilla, sin endpoint en 001)
seedVersion int 1 en la 001; versiona la semilla (FR-019)

Relaciones: 1—N con Profesionales, Servicios, Pacientes, Citas (todas llevan clinicaId).

Profesional

Campo Tipo Reglas
id uuid PK  
clinicaId FK → Clínica obligatorio
nombre text obligatorio; admite duplicados (Q5/B); p. ej. María
especialidad text obligatoria; fisioterapia, nutrición
activo bool defecto true; implementa el «MUST NOT eliminar» de FR-003 (en vez de borrar se desactiva — justificación IV en plan)

Índice: (clinicaId, nombre). Prohibido DELETE si tiene citas (Q3/A → error 409 + aviso es-ES).

Servicio

Campo Tipo Reglas
id uuid PK  
clinicaId FK obligatorio
nombre text obligatorio y único por clínica (Q5/B); índice único (clinicaId, nombre)
duracionMin int entero positivo (45, 60, 30)
precioCentimos int ≥0; 4000 = 40,00 € (FR-017)
activo bool defecto true; implementa el «MUST NOT eliminar» de FR-003 (justificación IV en plan)

Semilla: sesión fisio 45′ 4000 · primera visita fisio 60′ 5000 · consulta nutrición 30′ 3500 · primera nutrición 45′ 4500 (38–42 pacientes, tolerancia ±2 — FR-019). Cambiar duración no reescribe citas existentes (edge spec).

Paciente (ficha)

Campo Tipo Reglas
id uuid PK  
clinicaId FK obligatorio
nombre text obligatorio, guarda tildes/ñ tal cual (Q1/C)
telefono text obligatorio, validación ES estricta (Q1/C); admite duplicados (Q5/B)
email text obligatorio, validación estricta (Q1/C); admite duplicados

Validación zod en servidor + cliente; alta rechazada con aviso es-ES si falta campo o formato inválido (FR-004).

Cita

Campo Tipo Reglas
id uuid PK  
clinicaId / profesionalId / servicioId / pacienteId FKs obligatorias; el alta solo acepta fichas existentes (FR-005)
inicio / fin timestamptz fin = inicio + duracionMin vigente al crear; se guardan ambos (FR-006); mismo día y dentro de jornada salvo reconfiguración
precioCongeladoCentimos int copia del precioCentimos del servicio al reservar; inmutable después (enmienda S-05 jul2026); fuente para recordatorios y analítica
estado enum reservada → completada | cancelada | no_asistida; nace reservada (FR-010); solo transiciones desde reservada (FR-011/FR-012)

Reglas de negocio en BD + aplicación

  • RN1 (FR-007): EXCLUDE USING gist (profesionalId WITH =, tstzrange(inicio, fin) WITH &&) WHERE (estado IN ('reservada','completada')). Solape = intersección de [inicio, fin) con entradas truncadas a minuto; contigüidad (fin = inicio) permitida. cancelada/no_asistida no bloquean. Complemento en app: chequeo previo para mensaje Ese tramo ya está ocupado + captura de violación de exclusión (23P01) como 409.
  • RN1 concurrente (FR-008): transacción serializable en POST /api/citas; test Promise.all doble reserva → máximo 1 creada (III).
  • RN2 (FR-009): inicio < now() (BD) ⇒ 422 + aviso No se pueden crear citas en el pasado. Precisión de minuto.
  • Desenlace (FR-011/FR-012): PATCH /api/citas/[id]/estado solo desde reservada; cancelada libera tramo (sale del predicado de exclusión); finales inmutables.

Diagrama (texto)

Clinica 1──N Profesional 1──N Cita N──1 Servicio
   │              │                  │
   │              │                  └──N──1 Paciente (ficha)
   └──N Servicios / Pacientes
Cita --EXCLUDE--> (profesionalId, [inicio,fin)) si estado vigente