Notas en citas — plan técnico
Plan técnico ejecutable. Diseño aprobado en
~/.claude/plans/quiero-que-en-las-staged-scone.md. Tocaclinical_notes,agenda,migration_importycore/events. Una sola PR end-to-end.
Resumen
- Extender el modelo polimórfico de
clinical_notesconowner_type='appointment'+ dosnote_type:appointment_clinical,appointment_administrative. - Eliminar el campo libre
Appointment.notes(drop limpio — app no en prod). - UI: feed único con badge por tipo dentro del
AppointmentModal, vía slotappointment.detail.notes. - Migración Gesdén/DPMF: rutear
payload.notesaClinicalNote(appointment_administrative)siguiendo el patrón del commit191c884.
Invariantes que no se rompen
agendano importa nada declinical_notes(ni backend ni frontend). Integración por slot + permisos compartidos (precedente:PATCH /appointment-treatments/{id}ya usaclinical_notes.notes.write).clinical_notes.manifest.dependsya incluyeagenda— importarAppointmentdesde clinical_notes es legal.- No se crean tablas, módulos ni dependencias nuevas.
- Sin FK físico clinical_notes.owner_id → appointments.id (sigue patrón polimórfico actual).
Orden de ejecución
Bloque A — Backend clinical_notes
A1. models.py — constantes + CHECKs
Archivo: backend/app/modules/clinical_notes/models.py
Cambios:
Líneas 46–62: añadir constantes y ampliar tuplas.
pythonNOTE_TYPE_APPOINTMENT_CLINICAL = "appointment_clinical" NOTE_TYPE_APPOINTMENT_ADMINISTRATIVE = "appointment_administrative" NOTE_TYPES = ( NOTE_TYPE_ADMINISTRATIVE, NOTE_TYPE_DIAGNOSIS, NOTE_TYPE_TREATMENT, NOTE_TYPE_TREATMENT_PLAN, NOTE_TYPE_APPOINTMENT_CLINICAL, NOTE_TYPE_APPOINTMENT_ADMINISTRATIVE, ) NOTE_OWNER_APPOINTMENT = "appointment" NOTE_OWNER_TYPES = ( NOTE_OWNER_PATIENT, NOTE_OWNER_TREATMENT, NOTE_OWNER_PLAN, NOTE_OWNER_APPOINTMENT, )Líneas 100–116 (
__table_args__): ampliar los 3 CHECKs.pythonCheckConstraint( "note_type IN ('administrative', 'diagnosis', 'treatment', " "'treatment_plan', 'appointment_clinical', 'appointment_administrative')", name="ck_clinical_notes_note_type", ), CheckConstraint( "owner_type IN ('patient', 'treatment', 'plan', 'appointment')", name="ck_clinical_notes_owner_type", ), CheckConstraint( "(note_type = 'administrative' AND owner_type = 'patient' AND tooth_number IS NULL) " "OR (note_type = 'diagnosis' AND owner_type = 'patient') " "OR (note_type = 'treatment' AND owner_type = 'treatment' AND tooth_number IS NULL) " "OR (note_type = 'treatment_plan' AND owner_type = 'plan' AND tooth_number IS NULL) " "OR (note_type = 'appointment_clinical' AND owner_type = 'appointment' AND tooth_number IS NULL) " "OR (note_type = 'appointment_administrative' AND owner_type = 'appointment' AND tooth_number IS NULL)", name="ck_clinical_notes_type_owner_matrix", ),
A2. service.py — resolver de appointment + event map
Archivo: backend/app/modules/clinical_notes/service.py
Imports: añadir
from app.modules.agenda.models import Appointment(legal pordepends).Tras
_resolve_plan_owner(línea ~118), añadir:pythonasync def _resolve_appointment_owner( db: AsyncSession, clinic_id: UUID, appointment_id: UUID ) -> tuple[UUID, UUID]: result = await db.execute( select(Appointment.id, Appointment.patient_id).where( Appointment.id == appointment_id, Appointment.clinic_id == clinic_id, ) ) row = result.first() if row is None or row[1] is None: raise NoteOwnerError(f"appointment {appointment_id} not found or unbound") return row[0], row[1]Nota: la cita debe tener
patient_idno nulo. Si no lo tiene (cita de bloqueo), no se permite nota.Ampliar
resolve_owner_patient(líneas 134–149): añadir ramaappointment.pythonif owner_type == NOTE_OWNER_APPOINTMENT: _, patient_id = await _resolve_appointment_owner(db, clinic_id, owner_id) return patient_id_NOTE_TYPE_TO_EVENT(líneas 76–81): añadir dos entradas.pythonNOTE_TYPE_APPOINTMENT_CLINICAL: EventType.CLINICAL_NOTE_APPOINTMENT_CLINICAL_CREATED, NOTE_TYPE_APPOINTMENT_ADMINISTRATIVE: EventType.CLINICAL_NOTE_APPOINTMENT_ADMINISTRATIVE_CREATED,
A3. router.py + schemas.py — regex
backend/app/modules/clinical_notes/schemas.py:21— actualizarNOTE_OWNER_PATTERN:pythonNOTE_OWNER_PATTERN = "^(patient|treatment|plan|appointment)$"backend/app/modules/clinical_notes/router.py:48— actualizarATTACHMENT_OWNER_PATTERN:pythonATTACHMENT_OWNER_PATTERN = "^(patient|treatment|plan|appointment|clinical_note)$"- Si hay
Literal[...]paranote_typeenschemas.py, añadir los 2 nuevos valores.
A4. owner_resolvers.py — media registry
Archivo: backend/app/modules/clinical_notes/owner_resolvers.py
- Añadir resolver privado
_resolve_appointment(db, clinic_id, appointment_id) → UUID | Nonesiguiendo molde de_resolve_treatment. ImportarAppointmentigual que en service. - En
register()(líneas 81–93), añadirOwnerSpec(owner_type="appointment", resolver=_resolve_appointment, label="Cita"). - En
_resolve_clinical_note(líneas 59–78), añadir ramaif note_owner_type == "appointment": return await _resolve_appointment(...). Necesario para que adjuntos en notas de cita resuelvan al paciente.
A5. _VALID_ATTACHMENT_OWNER_TYPES
Ya es (*NOTE_OWNER_TYPES, "clinical_note") (línea 58 de service según report) → al ampliar NOTE_OWNER_TYPES queda hecho. Verificar.
A6. Alembic — cn_0003_appointment_owner.py
Archivo nuevo: backend/app/modules/clinical_notes/migrations/versions/cn_0003_appointment_owner.py
"""Add appointment owner type to clinical_notes.
Revision ID: cn_0003
Revises: cn_0002
Create Date: 2026-05-24
"""
from collections.abc import Sequence
from alembic import op
revision: str = "cn_0003"
down_revision: str | None = "cn_0002"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.drop_constraint("ck_clinical_notes_note_type", "clinical_notes", type_="check")
op.create_check_constraint(
"ck_clinical_notes_note_type",
"clinical_notes",
"note_type IN ('administrative', 'diagnosis', 'treatment', "
"'treatment_plan', 'appointment_clinical', 'appointment_administrative')",
)
op.drop_constraint("ck_clinical_notes_owner_type", "clinical_notes", type_="check")
op.create_check_constraint(
"ck_clinical_notes_owner_type",
"clinical_notes",
"owner_type IN ('patient', 'treatment', 'plan', 'appointment')",
)
op.drop_constraint("ck_clinical_notes_type_owner_matrix", "clinical_notes", type_="check")
op.create_check_constraint(
"ck_clinical_notes_type_owner_matrix",
"clinical_notes",
"(note_type = 'administrative' AND owner_type = 'patient' AND tooth_number IS NULL) "
"OR (note_type = 'diagnosis' AND owner_type = 'patient') "
"OR (note_type = 'treatment' AND owner_type = 'treatment' AND tooth_number IS NULL) "
"OR (note_type = 'treatment_plan' AND owner_type = 'plan' AND tooth_number IS NULL) "
"OR (note_type = 'appointment_clinical' AND owner_type = 'appointment' AND tooth_number IS NULL) "
"OR (note_type = 'appointment_administrative' AND owner_type = 'appointment' AND tooth_number IS NULL)",
)
def downgrade() -> None:
op.drop_constraint("ck_clinical_notes_type_owner_matrix", "clinical_notes", type_="check")
op.create_check_constraint(
"ck_clinical_notes_type_owner_matrix",
"clinical_notes",
"(note_type = 'administrative' AND owner_type = 'patient' AND tooth_number IS NULL) "
"OR (note_type = 'diagnosis' AND owner_type = 'patient') "
"OR (note_type = 'treatment' AND owner_type = 'treatment' AND tooth_number IS NULL) "
"OR (note_type = 'treatment_plan' AND owner_type = 'plan' AND tooth_number IS NULL)",
)
op.drop_constraint("ck_clinical_notes_owner_type", "clinical_notes", type_="check")
op.create_check_constraint(
"ck_clinical_notes_owner_type",
"clinical_notes",
"owner_type IN ('patient', 'treatment', 'plan')",
)
op.drop_constraint("ck_clinical_notes_note_type", "clinical_notes", type_="check")
op.create_check_constraint(
"ck_clinical_notes_note_type",
"clinical_notes",
"note_type IN ('administrative', 'diagnosis', 'treatment', 'treatment_plan')",
)Bloque B — Eventos
Archivo: backend/app/core/events/types.py (tras línea 170).
CLINICAL_NOTE_APPOINTMENT_CLINICAL_CREATED = "clinical_notes.appointment_clinical_created"
CLINICAL_NOTE_APPOINTMENT_ADMINISTRATIVE_CREATED = "clinical_notes.appointment_administrative_created"patient_timeline/events.py:631-662 (on_clinical_note_created) ya construye el string f"clinical_notes.{note_type}_created" → los eventos se grabarán automáticamente sin tocar el módulo. Verificar suscripción si los maneja por nombre concreto; si no, añadir suscripción en el dispatch map.
Bloque C — Backend agenda (drop legacy)
C1. Modelo
backend/app/modules/agenda/models.py:110 — eliminar:
notes: Mapped[str | None] = mapped_column(Text, nullable=True)C2. Schemas
backend/app/modules/agenda/schemas.py:
- Línea 147 (
AppointmentCreate): eliminarnotes: str | None = None - Línea 162 (
AppointmentUpdate): eliminarnotes: str | None = None - Línea 272 (
AppointmentResponse): eliminarnotes: str | None
C3. Service
backend/app/modules/agenda/service.py:
- Línea 686 (payload de
transition()): eliminar"notes": appointment.notes, update_appointment()(líneas 544–546): el loopsetattr(appointment, key, value)ya no recibiránotesporque sale del schema. Sin cambios adicionales necesarios.
C4. Alembic — ag_0005_drop_appointment_notes.py
"""Drop legacy Appointment.notes (moved to clinical_notes).
Revision ID: ag_0005
Revises: ag_0004
Create Date: 2026-05-24
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "ag_0005"
down_revision: str | None = "ag_0004"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.drop_column("appointments", "notes")
def downgrade() -> None:
op.add_column("appointments", sa.Column("notes", sa.Text(), nullable=True))C5. Tipo TypeScript
frontend/app/types/index.ts:250 — eliminar notes?: string del interface Appointment.
Bloque D — Frontend agenda (AppointmentModal)
Archivo: backend/app/modules/agenda/frontend/components/clinical/AppointmentModal.vue
D1. Script
- Línea 62 (
formData): eliminarnotes: ''. - Línea 274 (carga en edit): eliminar
formData.notes = apt.notes || ''. - Línea 481 (save payload): eliminar
notes: formData.notes || undefined. - Añadir en setup:typescriptAdaptar
const { resolve } = useModuleSlots() const appointmentNotesSlot = computed(() => appointmentId.value ? resolve('appointment.detail.notes', { appointmentId: appointmentId.value, patientId: formData.patientId, }) : [] )appointmentIdyformData.patientIda los nombres reales del state local del modal (revisar al implementar; el modal ya tieneappointmentIdreactivo para edit mode).
D2. Template
- Reemplazar bloque líneas 756–777 (sección "Notas") por:vueEl slot no se muestra al crear (no hay
<!-- Section 4: Notas (slot — provided by clinical_notes module) --> <div v-if="appointmentId" class="space-y-2"> <h4 class="flex items-center gap-2 text-caption uppercase tracking-wide text-subtle"> <UIcon name="i-lucide-sticky-note" class="w-3.5 h-3.5" /> {{ t('appointments.notesTitle') }} </h4> <div v-if="appointmentNotesSlot.length === 0" class="text-xs text-subtle"> {{ t('appointments.notesEmptyHint') }} </div> <component v-for="entry in appointmentNotesSlot" :is="entry.component" :key="entry.id" /> </div>appointmentIdtodavía). Si el modal soporta "crear + guardar y luego ver notas", el usuario debe guardar primero. Documentar en i18n hint.
Bloque E — Frontend clinical_notes (panel + registro de slot)
E1. Componente nuevo AppointmentNotesPanel.vue
Path: backend/app/modules/clinical_notes/frontend/components/AppointmentNotesPanel.vue
Responsabilidad:
- Recibe
appointmentIdy opcionalmentepatientIdpor contexto del slot. - Estado local:
selectedType(appointment_clinicalpor defecto),body, lista cargada. - Llama a
useApi:GET /api/v1/clinical_notes/notes?owner_type=appointment&owner_id={appointmentId}para listar.POST /api/v1/clinical_notes/notescon{owner_type:'appointment', owner_id, note_type, body}para crear.PATCH /notes/{id}yDELETE /notes/{id}para editar/soft-delete.
- Render: selector de tipo +
NoteComposer(pasándolenoteTypedinámico) arriba, lista cronológica descendente conNoteCarddebajo, conbadgeparametrizado pornote_type. - Permisos: si
can('clinical_notes.notes.write')→ muestra composer; si sóloread→ sólo lista.
E2. Slots plugin de clinical_notes
Path: backend/app/modules/clinical_notes/frontend/plugins/slots.client.ts (crear o ampliar):
import { defineAsyncComponent } from 'vue'
import { registerSlot } from '~~/app/composables/useModuleSlots'
export default defineNuxtPlugin(() => {
registerSlot('appointment.detail.notes', {
id: 'clinical_notes.appointment.detail.notesPanel',
component: defineAsyncComponent(
() => import('../components/AppointmentNotesPanel.vue')
),
order: 10,
permission: 'clinical_notes.notes.read',
})
})E3. NoteCard.vue — colores
Verificar el map de colores por note_type. Añadir entradas para appointment_clinical (reutilizar palette de diagnosis u otro distinguible) y appointment_administrative (reutilizar palette de administrative). Sin nuevos colores hard-coded fuera del map.
E4. RecentNotesFeed.vue — filtros
Si el feed de paciente expone filtros por tipo, añadir los 2 nuevos. Ubicación: clinical_notes/frontend/components/RecentNotesFeed.vue (ALL_SOURCES o equivalente).
Bloque F — migration_import (DPMF → ClinicalNote)
Archivo: backend/app/modules/migration_import/mappers/appointment.py
Patrón: copia del AppliedTreatmentMapper._record_non_clinical_note() (líneas 727–789), adaptado.
Cambios:
- Import:
from app.modules.clinical_notes.models import ClinicalNote(manifest ya declaraclinical_notesen depends). - Tras línea 138 (
appointment = await AppointmentService.create_appointment(...)), añadir:pythonnotes_body = (payload.get("notes") or "").strip() note_canonical = f"{canonical_uuid}:note" if notes_body and not await ctx.resolver.was_skipped("appointment_note", note_canonical): existing_note = await ctx.resolver.get("appointment_note", note_canonical) if existing_note is None: author_id = professional_id or ctx.created_by note = ClinicalNote( clinic_id=ctx.clinic_id, note_type="appointment_administrative", owner_type="appointment", owner_id=appointment.id, tooth_number=None, body=notes_body, author_id=author_id, created_at=appointment.created_at, updated_at=appointment.created_at, ) ctx.db.add(note) await ctx.db.flush() await ctx.resolver.set( entity_type="appointment_note", canonical_uuid=note_canonical, source_system=source_system, dentalpin_table="clinical_notes", dentalpin_id=note.id, ) elif not notes_body: await ctx.resolver.mark_skipped("appointment_note", note_canonical, source_system) - Decisión: tipo por defecto =
appointment_administrative(decisión usuario). - Idempotencia:
resolver.get+resolver.was_skippedantes de insertar;resolver.setdespués. Mismo patrón queapplied_treatment.py.
Bloque G — Tests
G1. tests/modules/clinical_notes/test_appointment_owner.py (nuevo)
Casos:
test_create_appointment_clinical_note_ok: POST conowner_type=appointment,note_type=appointment_clinical→ 201.test_create_appointment_administrative_note_ok.test_create_with_invalid_combo_raises:note_type=appointment_clinical+owner_type=patient→ 422.test_create_for_unbound_appointment_raises: cita sinpatient_id→ 422.test_create_cross_clinic_isolation: cita de clínica B desde clínica A → 422.test_list_filters_by_owner_appointment.test_resolve_owner_patient_via_appointment(unit del service).test_attachment_owner_appointment_resolves_to_patient(unit del media registry).test_event_published_on_create(mock event_bus, assert tipoCLINICAL_NOTE_APPOINTMENT_CLINICAL_CREATED).
Reutilizar fixtures db_session, client, auth_headers, clinic_factory, patient_factory. Si no existe, crear appointment_factory mínimo o llamar al endpoint para crear cita.
G2. tests/modules/agenda/test_appointment_notes_removed.py (nuevo)
test_create_appointment_rejects_notes_field: POST/appointmentsconnotesen body → 422 (Pydantic extra forbidden si está activo; si no, 200 e ignorado — adaptar segúnConfig).test_appointment_response_has_no_notes_key: GET/appointments/{id}no incluye clavenotes.
G3. tests/modules/migration_import/test_appointment_notes_routing.py (nuevo)
Copiar estructura de tests/modules/migration_import/test_applied_treatment_non_clinical_note.py. Casos:
test_appointment_with_notes_creates_clinical_note: payload connotesno vacío → 1 ClinicalNoteappointment_administrativeconowner_id=appointment.id.test_appointment_without_notes_no_clinical_note.test_reimport_is_idempotent: ejecutar 2× con mismocanonical_uuid→ 1 sola nota.test_author_id_from_professional: si payload traeprofessional_uuidresoluble →note.author_id = professional.id.test_author_id_fallback_to_created_by: si no se puede resolver profesional →note.author_id = ctx.created_by.
G4. Smoke frontend (manual)
- Crear cita → guardar → reabrir → crear nota clínica → ver en feed del modal → ver en feed del paciente (filtro
appointment_clinical). - Importar dump DPMF con
notespoblado en una cita → verificar ClinicalNoteappointment_administrativecreada y visible en feed del paciente.
Bloque H — Docs + i18n + catálogos
H1. CHANGELOGs (## Unreleased)
backend/app/modules/agenda/CHANGELOG.md— entrada: dropAppointment.notes, slotappointment.detail.notesañadido.backend/app/modules/clinical_notes/CHANGELOG.md— entrada: nuevoowner_type='appointment'+ 2note_type+ 2 eventos.backend/app/modules/migration_import/CHANGELOG.md— entrada: appointment notes → ClinicalNote(appointment_administrative).
H2. Docs técnicas
docs/technical/clinical_notes/overview.md— añadir filaappointmenta la matriz.docs/technical/clinical_notes/events.md— 2 eventos nuevos.docs/technical/clinical_notes/permissions.md— confirmar reuso denotes.read/write.docs/technical/agenda/events.md— sin cambios (no se publica desde agenda).docs/user-manual/en/agenda/screens/appointment-modal.md+es/— actualizar sección notas con feed + tipos.- Bump
last_verified_commiten los screen MDs tocados.
H3. Catálogos
python backend/scripts/generate_catalogs.pyH4. i18n
frontend/i18n/locales/en.json + es.json:
- Eliminar
appointments.notes,appointments.notesPlaceholder. - Añadir:
appointments.notesTitle("Notes" / "Notas")appointments.notesEmptyHint("Save the appointment to add notes." / "Guarda la cita para añadir notas.")
- En clinical_notes layer i18n (si existe), añadir labels para los 2 nuevos tipos:
clinicalNotes.types.appointment_clinical("Clinical" / "Clínica")clinicalNotes.types.appointment_administrative("Administrative" / "Administrativa")
Verificación end-to-end
# 1. Migraciones aplican
docker-compose exec backend alembic upgrade heads
# 2. Reset + reseed (datos dev se pierden, asumido por decisión)
./scripts/reset-db.sh && ./scripts/seed-demo.sh
# 3. Tests
docker-compose exec backend python -m pytest tests/modules/clinical_notes/test_appointment_owner.py -v
docker-compose exec backend python -m pytest tests/modules/agenda/test_appointment_notes_removed.py -v
docker-compose exec backend python -m pytest tests/modules/migration_import/test_appointment_notes_routing.py -v
docker-compose exec backend python -m pytest -v
# 4. Lint
cd backend && ruff check . && ruff format --check .
cd frontend && npm run lintChecklist funcional (manual, en navegador):
- Login como dentist → abrir cita existente → crear nota clínica → aparece en feed del modal.
- Cambiar tipo a "Administrativa" → crear → aparece con badge distinto.
- Soft-delete propia nota → desaparece del feed.
- Login como receptionist → puede crear nota administrativa (permiso
notes.writeya concedido). - Abrir ficha del paciente de esa cita →
RecentNotesFeedmuestra las notas con filtroappointment_clinical/appointment_administrative. - Adjuntar archivo a una nota de cita → galería del paciente lo lista.
- Ejecutar import DPMF de prueba con
appointment.notes→ ClinicalNote(appointment_administrative) creada; re-import no duplica. - POST
/api/v1/appointmentsconnotesen body → falla validación (campo no existe).
Riesgos y mitigaciones
| Riesgo | Mitigación |
|---|---|
Citas legacy de bloqueo (sin patient_id) → notas imposibles | _resolve_appointment_owner lanza si patient_id IS NULL. Documentado. |
| Cita borrada deja notas huérfanas | Agenda no hard-deletea hoy. Fuera de scope; TODO en CHANGELOG. |
| Cross-module FK física | NO se crea (patrón polimórfico). CI ok. |
patient_timeline no escucha eventos nuevos | El dispatcher actual reconstruye el nombre del evento por convención (clinical_notes.{note_type}_created). Verificar al implementar; si requiere subscripción explícita, añadir 2 líneas en el module init. |
useModuleSlots no expone appointment.detail.notes antes de guardar | Render condicional v-if="appointmentId". Hint i18n explica al usuario. |
| Ruff/Format/CI docs layout | Pasar scripts/check_docs_layout.py localmente; el doc vive en docs/technical/ (carpeta legal). |
Decisiones (recap del plan de diseño)
| Fork | Elección |
|---|---|
Campo legacy Appointment.notes | Drop limpio sin backfill. Reset de dev. |
| Tipo por defecto al importar DPMF | appointment_administrative |
| UI | Feed único con badge por tipo + composer con selector |
Archivos tocados (resumen)
Backend:
backend/app/modules/clinical_notes/models.pybackend/app/modules/clinical_notes/schemas.pybackend/app/modules/clinical_notes/service.pybackend/app/modules/clinical_notes/router.pybackend/app/modules/clinical_notes/owner_resolvers.pybackend/app/modules/clinical_notes/migrations/versions/cn_0003_appointment_owner.py(nuevo)backend/app/modules/agenda/models.pybackend/app/modules/agenda/schemas.pybackend/app/modules/agenda/service.pybackend/app/modules/agenda/migrations/versions/ag_0005_drop_appointment_notes.py(nuevo)backend/app/modules/migration_import/mappers/appointment.pybackend/app/core/events/types.py
Frontend:
backend/app/modules/agenda/frontend/components/clinical/AppointmentModal.vuebackend/app/modules/clinical_notes/frontend/components/AppointmentNotesPanel.vue(nuevo)backend/app/modules/clinical_notes/frontend/plugins/slots.client.ts(nuevo o ampliado)backend/app/modules/clinical_notes/frontend/components/NoteCard.vue(map colores)backend/app/modules/clinical_notes/frontend/components/RecentNotesFeed.vue(filtros)frontend/app/types/index.tsfrontend/i18n/locales/en.jsonfrontend/i18n/locales/es.json
Tests:
backend/tests/modules/clinical_notes/test_appointment_owner.py(nuevo)backend/tests/modules/agenda/test_appointment_notes_removed.py(nuevo)backend/tests/modules/migration_import/test_appointment_notes_routing.py(nuevo)
Docs/CHANGELOG:
backend/app/modules/{agenda,clinical_notes,migration_import}/CHANGELOG.mddocs/technical/clinical_notes/{overview,events,permissions}.mddocs/user-manual/{en,es}/agenda/screens/appointment-modal.md- (este archivo:
docs/technical/appointment-notes.md)