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
Relaciones: 1—N con Profesionales, Servicios, Pacientes, Citas (todas llevan clinicaId).
Profesional
Índice: (clinicaId, nombre). Prohibido DELETE si tiene citas (Q3/A → error 409 + aviso es-ES).
Servicio
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)
Validación zod en servidor + cliente; alta rechazada con aviso es-ES si falta campo o formato inválido (FR-004).
Cita
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_asistidano bloquean. Complemento en app: chequeo previo para mensajeEse tramo ya está ocupado+ captura de violación de exclusión (23P01) como409. - RN1 concurrente (FR-008): transacción serializable en
POST /api/citas; testPromise.alldoble reserva → máximo 1 creada (III). - RN2 (FR-009):
inicio < now()(BD) ⇒422+ avisoNo se pueden crear citas en el pasado. Precisión de minuto. - Desenlace (FR-011/FR-012):
PATCH /api/citas/[id]/estadosolo desdereservada;canceladalibera 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