Modelo de Datos: Recetas Médicas¶
Identificador: MDL-RX-001 Version: 1.0.0 Fecha: 2025-12-08 Estado: Borrador Autor: DatabaseDrone (Doce de Quince) / SpecQueen Technical Division Modulo Funcional: MTS-RX-001
- 1. Introduccion
- 1.1. Principios
- 1.2. Clasificacion de Datos
- 2. Entidades del Cliente (LOCAL/SYNCED_E2E)
- 2.1. cli_prescriptions
- 2.2. cli_prescription_medications
- 2.3. cli_prescriber
- 2.4. cli_issuing_institution
- 2.5. cli_prescription_images
- 2.6. cli_ocr_results
- 3. Entidades del Servidor (SYNCED_E2E Blobs)
- 3.1. srv_prescription_sync
- 3.2. srv_ocr_requests
- 4. Diagrama ER del Modulo
- 5. Reglas de Negocio Reflejadas en Modelo
- 6. Consideraciones de Cifrado y Anonimizacion
- 6.1. Campos PHI Cifrados E2E
- 6.2. Imagenes de Recetas
- 6.3. OCR Local vs Servidor
- 7. Indices y Performance
- 7.1. Cliente (SQLite/Realm)
- 7.2. Servidor (PostgreSQL)
- 8. Migraciones
- 8.1. Migration 001: Create Prescriptions Schema (Servidor)
- 8.2. Migration 001: Create Prescriptions Schema (Cliente iOS)
- 8.3. Migration 001: Create Prescriptions Schema (Cliente Android)
- 9. Referencias Cruzadas
1. Introduccion¶
Este documento define el modelo de datos para el modulo de Recetas Medicas de MedTime. Permite al paciente digitalizar, organizar y gestionar recetas medicas con OCR local/servidor y vinculacion automatica a medicamentos.
1.1. Principios¶
| Principio | Aplicacion |
|---|---|
| 95% Local | OCR basico local (iOS Vision / Android ML Kit), vinculacion de medicamentos en dispositivo |
| Zero-Knowledge | Servidor solo almacena blobs cifrados de recetas. OCR avanzado requiere anonimizacion manual |
| Offline-First | Recetas y OCR basico funcionan completamente sin conexion |
| Sync para Pro/Perfect | Solo usuarios Pro/Perfect sincronizan recetas. OCR avanzado solo Perfect |
1.2. Clasificacion de Datos¶
| Entidad | Clasificacion | Cifrado | Sync | Razon |
|---|---|---|---|---|
| cli_prescriptions | SYNCED_E2E | Si | Pro/Perfect | Datos de recetas son PHI critico |
| cli_prescription_medications | SYNCED_E2E | Si | Pro/Perfect | Medicamentos prescritos son PHI |
| cli_prescriber | SYNCED_E2E | Si | Pro/Perfect | Datos del medico son PHI |
| cli_issuing_institution | SYNCED_E2E | Si | Pro/Perfect | Institucion emisora es info medica |
| cli_prescription_images | SYNCED_E2E | Si | Pro/Perfect | Imagenes de recetas contienen PHI critico |
| cli_ocr_results | SYNCED_E2E | Si | Pro/Perfect | Texto extraido es PHI |
| srv_prescription_sync | SERVER_ONLY | N/A | N/A | Almacena blobs cifrados |
| srv_ocr_requests | SERVER_ONLY | No | N/A | Solicitudes de OCR avanzado (Perfect) |
2. Entidades del Cliente (LOCAL/SYNCED_E2E)¶
2.1. cli_prescriptions¶
Recetas medicas del usuario.
2.1.1. Clasificacion¶
| Campo | Clasificacion | Razon |
|---|---|---|
| userId | LOCAL_ONLY | Identificador local |
| prescriberId | SYNCED_E2E | FK a medico prescriptor (PHI) |
| institutionId | SYNCED_E2E | FK a institucion emisora (PHI) |
| prescriptionDate | SYNCED_E2E | Fecha de prescripcion es PHI |
| diagnosis | SYNCED_E2E | Diagnostico es PHI critico |
| notes | SYNCED_E2E | Notas pueden contener info medica |
2.1.2. iOS - Swift/Realm¶
// ============================================================
// MODELO: Prescription
// Descripcion: Receta medica del usuario
// Almacenamiento: Realm cifrado
// Sync: E2E via EncryptedBlob (Pro/Perfect)
// ============================================================
import RealmSwift
class Prescription: Object, Identifiable {
// Identificadores
@Persisted(primaryKey: true) var id: String = UUID().uuidString
@Persisted var userId: String = ""
@Persisted var profileId: String? // Para pacientes dependientes
// Prescriptor
@Persisted var prescriberId: String? // FK a cli_prescriber
@Persisted var prescriberName: String? // Nombre del medico (desnormalizado)
// Institucion
@Persisted var institutionId: String? // FK a cli_issuing_institution
@Persisted var institutionName: String? // Nombre de institucion (desnormalizado)
// Fechas
@Persisted var prescriptionDate: Date = Date() // Fecha de emision
@Persisted var validUntil: Date? // Fecha de vencimiento (opcional)
@Persisted var dateUploaded: Date = Date() // Fecha de carga
// Diagnostico y tratamiento
@Persisted var diagnosis: String? // Diagnostico
@Persisted var treatmentPurpose: String? // Proposito del tratamiento
@Persisted var notes: String? // Notas adicionales
// Metadata
@Persisted var isRecurring: Bool = false // Receta recurrente
@Persisted var recurringType: String? // monthly, quarterly, etc.
// Imagenes
@Persisted var hasImages: Bool = false
@Persisted var imageCount: Int = 0
// OCR
@Persisted var hasOcr: Bool = false
@Persisted var ocrProcessed: Bool = false
@Persisted var ocrSource: String? // local, server
// Vinculacion a medicamentos
@Persisted var linkedMedicationsCount: Int = 0
@Persisted var allMedicationsLinked: Bool = false
// Estado
@Persisted var isActive: Bool = true
@Persisted var isExpired: Bool = false
@Persisted var isDeleted: Bool = false
// Sync
@Persisted var syncStatus: String = "pending" // pending, synced, conflict
@Persisted var lastSyncAt: Date?
@Persisted var version: Int = 1
// Timestamps
@Persisted var createdAt: Date = Date()
@Persisted var updatedAt: Date = Date()
// Relationships
@Persisted var medications: List<PrescriptionMedication>
@Persisted var images: List<PrescriptionImage>
@Persisted var ocrResults: List<OcrResult>
}
2.1.3. Android - Kotlin/Room¶
// ============================================================
// MODELO: Prescription
// Descripcion: Receta medica del usuario
// Almacenamiento: Room cifrado (SQLCipher)
// Sync: E2E via EncryptedBlob (Pro/Perfect)
// ============================================================
package com.medtime.data.entities
import androidx.room.*
import java.time.Instant
import java.util.UUID
@Entity(
tableName = "cli_prescriptions",
foreignKeys = [
ForeignKey(
entity = User::class,
parentColumns = ["userId"],
childColumns = ["userId"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = Prescriber::class,
parentColumns = ["id"],
childColumns = ["prescriber_id"],
onDelete = ForeignKey.SET_NULL
),
ForeignKey(
entity = IssuingInstitution::class,
parentColumns = ["id"],
childColumns = ["institution_id"],
onDelete = ForeignKey.SET_NULL
)
],
indices = [
Index(value = ["user_id"]),
Index(value = ["prescriber_id"]),
Index(value = ["institution_id"]),
Index(value = ["prescription_date"]),
Index(value = ["is_active"])
]
)
data class Prescription(
@PrimaryKey
@ColumnInfo(name = "id")
val id: String = UUID.randomUUID().toString(),
@ColumnInfo(name = "user_id", index = true)
val userId: String = "",
@ColumnInfo(name = "profile_id")
val profileId: String? = null,
// Prescriptor
@ColumnInfo(name = "prescriber_id", index = true)
val prescriberId: String? = null,
@ColumnInfo(name = "prescriber_name")
val prescriberName: String? = null,
// Institucion
@ColumnInfo(name = "institution_id", index = true)
val institutionId: String? = null,
@ColumnInfo(name = "institution_name")
val institutionName: String? = null,
// Fechas
@ColumnInfo(name = "prescription_date")
val prescriptionDate: Instant = Instant.now(),
@ColumnInfo(name = "valid_until")
val validUntil: Instant? = null,
@ColumnInfo(name = "date_uploaded")
val dateUploaded: Instant = Instant.now(),
// Diagnostico y tratamiento
@ColumnInfo(name = "diagnosis")
val diagnosis: String? = null,
@ColumnInfo(name = "treatment_purpose")
val treatmentPurpose: String? = null,
@ColumnInfo(name = "notes")
val notes: String? = null,
// Metadata
@ColumnInfo(name = "is_recurring")
val isRecurring: Boolean = false,
@ColumnInfo(name = "recurring_type")
val recurringType: String? = null,
// Imagenes
@ColumnInfo(name = "has_images")
val hasImages: Boolean = false,
@ColumnInfo(name = "image_count")
val imageCount: Int = 0,
// OCR
@ColumnInfo(name = "has_ocr")
val hasOcr: Boolean = false,
@ColumnInfo(name = "ocr_processed")
val ocrProcessed: Boolean = false,
@ColumnInfo(name = "ocr_source")
val ocrSource: OcrSource? = null,
// Vinculacion a medicamentos
@ColumnInfo(name = "linked_medications_count")
val linkedMedicationsCount: Int = 0,
@ColumnInfo(name = "all_medications_linked")
val allMedicationsLinked: Boolean = false,
// Estado
@ColumnInfo(name = "is_active")
val isActive: Boolean = true,
@ColumnInfo(name = "is_expired")
val isExpired: Boolean = false,
@ColumnInfo(name = "is_deleted")
val isDeleted: Boolean = false,
// Sync
@ColumnInfo(name = "sync_status")
val syncStatus: SyncStatus = SyncStatus.PENDING,
@ColumnInfo(name = "last_sync_at")
val lastSyncAt: Instant? = null,
@ColumnInfo(name = "version")
val version: Int = 1,
// Timestamps
@ColumnInfo(name = "created_at")
val createdAt: Instant = Instant.now(),
@ColumnInfo(name = "updated_at")
val updatedAt: Instant = Instant.now()
)
enum class OcrSource {
LOCAL, // iOS Vision / Android ML Kit
SERVER // OCR avanzado (Perfect tier)
}
2.2. cli_prescription_medications¶
Medicamentos en la receta.
2.2.1. Clasificacion¶
| Campo | Clasificacion | Razon |
|---|---|---|
| prescriptionId | SYNCED_E2E | FK a receta (PHI) |
| medicationId | SYNCED_E2E | FK a medicamento (PHI) |
| medicationName | SYNCED_E2E | Nombre de medicamento es PHI |
| dosage | SYNCED_E2E | Dosis es PHI critico |
| instructions | SYNCED_E2E | Instrucciones son PHI |
2.2.2. iOS - Swift/Realm¶
// ============================================================
// MODELO: PrescriptionMedication
// Descripcion: Medicamento en receta
// Almacenamiento: Realm cifrado
// Sync: E2E via EncryptedBlob (Pro/Perfect)
// ============================================================
import RealmSwift
class PrescriptionMedication: Object, Identifiable {
// Identificadores
@Persisted(primaryKey: true) var id: String = UUID().uuidString
@Persisted var userId: String = ""
// Referencias
@Persisted(originProperty: "medications") var prescription: LinkingObjects<Prescription>
@Persisted var prescriptionId: String = ""
// Medicamento
@Persisted var medicationId: String? // FK a cli_medications (si vinculado)
@Persisted var medicationName: String = "" // Nombre del medicamento
@Persisted var activeIngredient: String? // Principio activo
@Persisted var brandName: String? // Marca comercial
// Dosis
@Persisted var dosage: String = "" // ej: "500mg"
@Persisted var dosageQuantity: Double?
@Persisted var dosageUnit: String? // mg, ml, UI, etc.
// Forma farmaceutica
@Persisted var pharmaceuticalForm: String? // tablet, capsule, syrup, etc.
// Instrucciones
@Persisted var instructions: String? // ej: "Tomar cada 8 horas"
@Persisted var frequency: String? // every_8h, daily, weekly, etc.
@Persisted var duration: String? // ej: "14 dias"
@Persisted var route: String? // oral, topical, IV, etc.
// Cantidad prescrita
@Persisted var quantity: Int? // Numero de unidades
@Persisted var refillable: Bool = false // Puede resurtirse
@Persisted var refillsAllowed: Int = 0 // Numero de resurtidos
// Vinculacion
@Persisted var isLinkedToMedication: Bool = false
@Persisted var linkedAt: Date?
// Estado
@Persisted var isDeleted: Bool = false
// Sync
@Persisted var syncStatus: String = "pending"
@Persisted var lastSyncAt: Date?
@Persisted var version: Int = 1
// Timestamps
@Persisted var createdAt: Date = Date()
@Persisted var updatedAt: Date = Date()
}
2.2.3. Android - Kotlin/Room¶
// ============================================================
// MODELO: PrescriptionMedication
// Descripcion: Medicamento en receta
// Almacenamiento: Room cifrado (SQLCipher)
// Sync: E2E via EncryptedBlob (Pro/Perfect)
// ============================================================
package com.medtime.data.entities
import androidx.room.*
import java.time.Instant
import java.util.UUID
@Entity(
tableName = "cli_prescription_medications",
foreignKeys = [
ForeignKey(
entity = Prescription::class,
parentColumns = ["id"],
childColumns = ["prescription_id"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = Medication::class,
parentColumns = ["id"],
childColumns = ["medication_id"],
onDelete = ForeignKey.SET_NULL
)
],
indices = [
Index(value = ["prescription_id"]),
Index(value = ["medication_id"]),
Index(value = ["medication_name"]),
Index(value = ["is_linked_to_medication"])
]
)
data class PrescriptionMedication(
@PrimaryKey
@ColumnInfo(name = "id")
val id: String = UUID.randomUUID().toString(),
@ColumnInfo(name = "user_id")
val userId: String = "",
// Referencias
@ColumnInfo(name = "prescription_id", index = true)
val prescriptionId: String = "",
// Medicamento
@ColumnInfo(name = "medication_id", index = true)
val medicationId: String? = null,
@ColumnInfo(name = "medication_name")
val medicationName: String = "",
@ColumnInfo(name = "active_ingredient")
val activeIngredient: String? = null,
@ColumnInfo(name = "brand_name")
val brandName: String? = null,
// Dosis
@ColumnInfo(name = "dosage")
val dosage: String = "",
@ColumnInfo(name = "dosage_quantity")
val dosageQuantity: Double? = null,
@ColumnInfo(name = "dosage_unit")
val dosageUnit: String? = null,
// Forma farmaceutica
@ColumnInfo(name = "pharmaceutical_form")
val pharmaceuticalForm: String? = null,
// Instrucciones
@ColumnInfo(name = "instructions")
val instructions: String? = null,
@ColumnInfo(name = "frequency")
val frequency: String? = null,
@ColumnInfo(name = "duration")
val duration: String? = null,
@ColumnInfo(name = "route")
val route: String? = null,
// Cantidad prescrita
@ColumnInfo(name = "quantity")
val quantity: Int? = null,
@ColumnInfo(name = "refillable")
val refillable: Boolean = false,
@ColumnInfo(name = "refills_allowed")
val refillsAllowed: Int = 0,
// Vinculacion
@ColumnInfo(name = "is_linked_to_medication")
val isLinkedToMedication: Boolean = false,
@ColumnInfo(name = "linked_at")
val linkedAt: Instant? = null,
// Estado
@ColumnInfo(name = "is_deleted")
val isDeleted: Boolean = false,
// Sync
@ColumnInfo(name = "sync_status")
val syncStatus: SyncStatus = SyncStatus.PENDING,
@ColumnInfo(name = "last_sync_at")
val lastSyncAt: Instant? = null,
@ColumnInfo(name = "version")
val version: Int = 1,
// Timestamps
@ColumnInfo(name = "created_at")
val createdAt: Instant = Instant.now(),
@ColumnInfo(name = "updated_at")
val updatedAt: Instant = Instant.now()
)
2.3. cli_prescriber¶
Datos del medico prescriptor.
2.3.1. Clasificacion¶
| Campo | Clasificacion | Razon |
|---|---|---|
| userId | LOCAL_ONLY | Identificador local |
| fullName | SYNCED_E2E | Nombre del medico es PHI |
| licenseNumber | SYNCED_E2E | Cedula profesional es info medica |
| specialty | SYNCED_E2E | Especialidad revela condicion tratada |
| contact | SYNCED_E2E | Contacto del medico es info personal |
2.3.2. iOS - Swift/Realm¶
// ============================================================
// MODELO: Prescriber
// Descripcion: Medico prescriptor
// Almacenamiento: Realm cifrado
// Sync: E2E via EncryptedBlob (Pro/Perfect)
// ============================================================
import RealmSwift
class Prescriber: Object, Identifiable {
// Identificadores
@Persisted(primaryKey: true) var id: String = UUID().uuidString
@Persisted var userId: String = ""
// Datos del medico
@Persisted var fullName: String = ""
@Persisted var licenseNumber: String? // Cedula profesional
@Persisted var specialty: String? // Especialidad
// Contacto
@Persisted var phoneNumber: String?
@Persisted var email: String?
@Persisted var address: String?
// Institucion
@Persisted var primaryInstitutionId: String? // FK a cli_issuing_institution
@Persisted var primaryInstitutionName: String?
// Metadata
@Persisted var prescriptionCount: Int = 0 // Numero de recetas emitidas
// Estado
@Persisted var isActive: Bool = true
@Persisted var isDeleted: Bool = false
// Sync
@Persisted var syncStatus: String = "pending"
@Persisted var lastSyncAt: Date?
@Persisted var version: Int = 1
// Timestamps
@Persisted var createdAt: Date = Date()
@Persisted var updatedAt: Date = Date()
}
2.3.3. Android - Kotlin/Room¶
// ============================================================
// MODELO: Prescriber
// Descripcion: Medico prescriptor
// Almacenamiento: Room cifrado (SQLCipher)
// Sync: E2E via EncryptedBlob (Pro/Perfect)
// ============================================================
package com.medtime.data.entities
import androidx.room.*
import java.time.Instant
import java.util.UUID
@Entity(
tableName = "cli_prescriber",
foreignKeys = [
ForeignKey(
entity = IssuingInstitution::class,
parentColumns = ["id"],
childColumns = ["primary_institution_id"],
onDelete = ForeignKey.SET_NULL
)
],
indices = [
Index(value = ["user_id"]),
Index(value = ["full_name"]),
Index(value = ["specialty"]),
Index(value = ["primary_institution_id"])
]
)
data class Prescriber(
@PrimaryKey
@ColumnInfo(name = "id")
val id: String = UUID.randomUUID().toString(),
@ColumnInfo(name = "user_id", index = true)
val userId: String = "",
// Datos del medico
@ColumnInfo(name = "full_name")
val fullName: String = "",
@ColumnInfo(name = "license_number")
val licenseNumber: String? = null,
@ColumnInfo(name = "specialty")
val specialty: String? = null,
// Contacto
@ColumnInfo(name = "phone_number")
val phoneNumber: String? = null,
@ColumnInfo(name = "email")
val email: String? = null,
@ColumnInfo(name = "address")
val address: String? = null,
// Institucion
@ColumnInfo(name = "primary_institution_id", index = true)
val primaryInstitutionId: String? = null,
@ColumnInfo(name = "primary_institution_name")
val primaryInstitutionName: String? = null,
// Metadata
@ColumnInfo(name = "prescription_count")
val prescriptionCount: Int = 0,
// Estado
@ColumnInfo(name = "is_active")
val isActive: Boolean = true,
@ColumnInfo(name = "is_deleted")
val isDeleted: Boolean = false,
// Sync
@ColumnInfo(name = "sync_status")
val syncStatus: SyncStatus = SyncStatus.PENDING,
@ColumnInfo(name = "last_sync_at")
val lastSyncAt: Instant? = null,
@ColumnInfo(name = "version")
val version: Int = 1,
// Timestamps
@ColumnInfo(name = "created_at")
val createdAt: Instant = Instant.now(),
@ColumnInfo(name = "updated_at")
val updatedAt: Instant = Instant.now()
)
2.4. cli_issuing_institution¶
Institucion emisora de la receta.
2.4.1. Clasificacion¶
| Campo | Clasificacion | Razon |
|---|---|---|
| userId | LOCAL_ONLY | Identificador local |
| name | SYNCED_E2E | Nombre de institucion es info medica |
| type | SYNCED_E2E | Tipo de institucion revela tratamiento |
| address | SYNCED_E2E | Direccion es info personal |
2.4.2. iOS - Swift/Realm¶
// ============================================================
// MODELO: IssuingInstitution
// Descripcion: Institucion emisora de recetas
// Almacenamiento: Realm cifrado
// Sync: E2E via EncryptedBlob (Pro/Perfect)
// ============================================================
import RealmSwift
class IssuingInstitution: Object, Identifiable {
// Identificadores
@Persisted(primaryKey: true) var id: String = UUID().uuidString
@Persisted var userId: String = ""
// Datos de la institucion
@Persisted var name: String = ""
@Persisted var type: String? // hospital, clinic, pharmacy, etc.
// Contacto
@Persisted var phoneNumber: String?
@Persisted var email: String?
@Persisted var website: String?
// Direccion
@Persisted var address: String?
@Persisted var city: String?
@Persisted var state: String?
@Persisted var country: String?
@Persisted var postalCode: String?
// Metadata
@Persisted var prescriptionCount: Int = 0 // Numero de recetas emitidas
// Estado
@Persisted var isActive: Bool = true
@Persisted var isDeleted: Bool = false
// Sync
@Persisted var syncStatus: String = "pending"
@Persisted var lastSyncAt: Date?
@Persisted var version: Int = 1
// Timestamps
@Persisted var createdAt: Date = Date()
@Persisted var updatedAt: Date = Date()
}
2.4.3. Android - Kotlin/Room¶
// ============================================================
// MODELO: IssuingInstitution
// Descripcion: Institucion emisora de recetas
// Almacenamiento: Room cifrado (SQLCipher)
// Sync: E2E via EncryptedBlob (Pro/Perfect)
// ============================================================
package com.medtime.data.entities
import androidx.room.*
import java.time.Instant
import java.util.UUID
@Entity(
tableName = "cli_issuing_institution",
indices = [
Index(value = ["user_id"]),
Index(value = ["name"]),
Index(value = ["type"])
]
)
data class IssuingInstitution(
@PrimaryKey
@ColumnInfo(name = "id")
val id: String = UUID.randomUUID().toString(),
@ColumnInfo(name = "user_id", index = true)
val userId: String = "",
// Datos de la institucion
@ColumnInfo(name = "name")
val name: String = "",
@ColumnInfo(name = "type")
val type: InstitutionType? = null,
// Contacto
@ColumnInfo(name = "phone_number")
val phoneNumber: String? = null,
@ColumnInfo(name = "email")
val email: String? = null,
@ColumnInfo(name = "website")
val website: String? = null,
// Direccion
@ColumnInfo(name = "address")
val address: String? = null,
@ColumnInfo(name = "city")
val city: String? = null,
@ColumnInfo(name = "state")
val state: String? = null,
@ColumnInfo(name = "country")
val country: String? = null,
@ColumnInfo(name = "postal_code")
val postalCode: String? = null,
// Metadata
@ColumnInfo(name = "prescription_count")
val prescriptionCount: Int = 0,
// Estado
@ColumnInfo(name = "is_active")
val isActive: Boolean = true,
@ColumnInfo(name = "is_deleted")
val isDeleted: Boolean = false,
// Sync
@ColumnInfo(name = "sync_status")
val syncStatus: SyncStatus = SyncStatus.PENDING,
@ColumnInfo(name = "last_sync_at")
val lastSyncAt: Instant? = null,
@ColumnInfo(name = "version")
val version: Int = 1,
// Timestamps
@ColumnInfo(name = "created_at")
val createdAt: Instant = Instant.now(),
@ColumnInfo(name = "updated_at")
val updatedAt: Instant = Instant.now()
)
enum class InstitutionType {
HOSPITAL,
CLINIC,
PHARMACY,
LABORATORY,
OTHER
}
2.5. cli_prescription_images¶
Imagenes de recetas (originales y anonimizadas).
2.5.1. Clasificacion¶
| Campo | Clasificacion | Razon |
|---|---|---|
| prescriptionId | SYNCED_E2E | FK a receta (PHI) |
| fileName | SYNCED_E2E | Nombre de archivo puede contener info |
| originalFileUrl | SYNCED_E2E | URL local cifrada (imagen original) |
| anonymizedFileUrl | SYNCED_E2E | URL local cifrada (imagen anonimizada) |
NOTA: Se almacenan DOS versiones: original (nunca sale del dispositivo) y anonimizada (para OCR servidor).
2.5.2. iOS - Swift/Realm¶
// ============================================================
// MODELO: PrescriptionImage
// Descripcion: Imagen de receta (original + anonimizada)
// Almacenamiento: Realm cifrado
// Sync: E2E via EncryptedBlob (Pro/Perfect)
// ============================================================
import RealmSwift
class PrescriptionImage: Object, Identifiable {
// Identificadores
@Persisted(primaryKey: true) var id: String = UUID().uuidString
@Persisted var userId: String = ""
// Referencias
@Persisted(originProperty: "images") var prescription: LinkingObjects<Prescription>
@Persisted var prescriptionId: String = ""
// Archivo ORIGINAL (NUNCA se envia a servidor)
@Persisted var originalFileName: String = ""
@Persisted var originalFileUrl: String = "" // URL local cifrada
@Persisted var originalFileSizeBytes: Int = 0
@Persisted var originalFileHash: String = "" // SHA-256
// Archivo ANONIMIZADO (para OCR servidor - Perfect)
@Persisted var hasAnonymized: Bool = false
@Persisted var anonymizedFileUrl: String? // URL local cifrada
@Persisted var anonymizedFileSizeBytes: Int?
@Persisted var anonymizedFileHash: String? // SHA-256
// Metadata
@Persisted var fileType: String = "" // jpg, png, pdf
@Persisted var mimeType: String = ""
@Persisted var imageWidth: Int?
@Persisted var imageHeight: Int?
// Anonimizacion
@Persisted var anonymizationConfirmed: Bool = false
@Persisted var anonymizationMethod: String? // manual, auto
@Persisted var anonymizedAt: Date?
// OCR
@Persisted var ocrProcessed: Bool = false
@Persisted var ocrResultId: String? // FK a cli_ocr_results
// Estado
@Persisted var isDeleted: Bool = false
// Sync
@Persisted var syncStatus: String = "pending"
@Persisted var lastSyncAt: Date?
@Persisted var version: Int = 1
// Timestamps
@Persisted var createdAt: Date = Date()
@Persisted var updatedAt: Date = Date()
}
2.5.3. Android - Kotlin/Room¶
// ============================================================
// MODELO: PrescriptionImage
// Descripcion: Imagen de receta (original + anonimizada)
// Almacenamiento: Room cifrado (SQLCipher)
// Sync: E2E via EncryptedBlob (Pro/Perfect)
// ============================================================
package com.medtime.data.entities
import androidx.room.*
import java.time.Instant
import java.util.UUID
@Entity(
tableName = "cli_prescription_images",
foreignKeys = [
ForeignKey(
entity = Prescription::class,
parentColumns = ["id"],
childColumns = ["prescription_id"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = OcrResult::class,
parentColumns = ["id"],
childColumns = ["ocr_result_id"],
onDelete = ForeignKey.SET_NULL
)
],
indices = [
Index(value = ["prescription_id"]),
Index(value = ["ocr_result_id"]),
Index(value = ["file_type"])
]
)
data class PrescriptionImage(
@PrimaryKey
@ColumnInfo(name = "id")
val id: String = UUID.randomUUID().toString(),
@ColumnInfo(name = "user_id")
val userId: String = "",
// Referencias
@ColumnInfo(name = "prescription_id", index = true)
val prescriptionId: String = "",
// Archivo ORIGINAL (NUNCA se envia a servidor)
@ColumnInfo(name = "original_file_name")
val originalFileName: String = "",
@ColumnInfo(name = "original_file_url")
val originalFileUrl: String = "",
@ColumnInfo(name = "original_file_size_bytes")
val originalFileSizeBytes: Int = 0,
@ColumnInfo(name = "original_file_hash")
val originalFileHash: String = "",
// Archivo ANONIMIZADO (para OCR servidor - Perfect)
@ColumnInfo(name = "has_anonymized")
val hasAnonymized: Boolean = false,
@ColumnInfo(name = "anonymized_file_url")
val anonymizedFileUrl: String? = null,
@ColumnInfo(name = "anonymized_file_size_bytes")
val anonymizedFileSizeBytes: Int? = null,
@ColumnInfo(name = "anonymized_file_hash")
val anonymizedFileHash: String? = null,
// Metadata
@ColumnInfo(name = "file_type")
val fileType: String = "",
@ColumnInfo(name = "mime_type")
val mimeType: String = "",
@ColumnInfo(name = "image_width")
val imageWidth: Int? = null,
@ColumnInfo(name = "image_height")
val imageHeight: Int? = null,
// Anonimizacion
@ColumnInfo(name = "anonymization_confirmed")
val anonymizationConfirmed: Boolean = false,
@ColumnInfo(name = "anonymization_method")
val anonymizationMethod: AnonymizationMethod? = null,
@ColumnInfo(name = "anonymized_at")
val anonymizedAt: Instant? = null,
// OCR
@ColumnInfo(name = "ocr_processed")
val ocrProcessed: Boolean = false,
@ColumnInfo(name = "ocr_result_id", index = true)
val ocrResultId: String? = null,
// Estado
@ColumnInfo(name = "is_deleted")
val isDeleted: Boolean = false,
// Sync
@ColumnInfo(name = "sync_status")
val syncStatus: SyncStatus = SyncStatus.PENDING,
@ColumnInfo(name = "last_sync_at")
val lastSyncAt: Instant? = null,
@ColumnInfo(name = "version")
val version: Int = 1,
// Timestamps
@ColumnInfo(name = "created_at")
val createdAt: Instant = Instant.now(),
@ColumnInfo(name = "updated_at")
val updatedAt: Instant = Instant.now()
)
enum class AnonymizationMethod {
MANUAL, // Usuario anonimizo manualmente
AUTO // Anonimizacion automatica (futuro)
}
2.6. cli_ocr_results¶
Resultados de OCR (local o servidor).
2.6.1. Clasificacion¶
| Campo | Clasificacion | Razon |
|---|---|---|
| prescriptionId | SYNCED_E2E | FK a receta (PHI) |
| imageId | SYNCED_E2E | FK a imagen (PHI) |
| rawText | SYNCED_E2E | Texto extraido es PHI critico |
| structuredData | SYNCED_E2E | Datos estructurados son PHI |
2.6.2. iOS - Swift/Realm¶
// ============================================================
// MODELO: OcrResult
// Descripcion: Resultado de OCR (local o servidor)
// Almacenamiento: Realm cifrado
// Sync: E2E via EncryptedBlob (Pro/Perfect)
// ============================================================
import RealmSwift
class OcrResult: Object, Identifiable {
// Identificadores
@Persisted(primaryKey: true) var id: String = UUID().uuidString
@Persisted var userId: String = ""
// Referencias
@Persisted(originProperty: "ocrResults") var prescription: LinkingObjects<Prescription>
@Persisted var prescriptionId: String = ""
@Persisted var imageId: String = "" // FK a cli_prescription_images
// Fuente del OCR
@Persisted var source: String = "" // local, server
@Persisted var ocrEngine: String? // iOS Vision, ML Kit, Server Engine
// Texto extraido
@Persisted var rawText: String = "" // Texto completo sin procesar
@Persisted var confidence: Double? // Confianza del OCR (0.0 - 1.0)
// Datos estructurados (JSON)
@Persisted var structuredData: String? // JSON con datos parseados
// { medications: [...], prescriber: {...}, date: "...", etc. }
// Medicamentos detectados
@Persisted var medicationsDetected: Int = 0
@Persisted var medicationsLinked: Int = 0
// Estado
@Persisted var isParsed: Bool = false
@Persisted var needsReview: Bool = false // Requiere revision manual
@Persisted var reviewedAt: Date?
// Errores
@Persisted var hasErrors: Bool = false
@Persisted var errorMessage: String?
// Sync
@Persisted var syncStatus: String = "pending"
@Persisted var lastSyncAt: Date?
@Persisted var version: Int = 1
// Timestamps
@Persisted var createdAt: Date = Date()
@Persisted var updatedAt: Date = Date()
@Persisted var isDeleted: Bool = false
}
2.6.3. Android - Kotlin/Room¶
// ============================================================
// MODELO: OcrResult
// Descripcion: Resultado de OCR (local o servidor)
// Almacenamiento: Room cifrado (SQLCipher)
// Sync: E2E via EncryptedBlob (Pro/Perfect)
// ============================================================
package com.medtime.data.entities
import androidx.room.*
import java.time.Instant
import java.util.UUID
@Entity(
tableName = "cli_ocr_results",
foreignKeys = [
ForeignKey(
entity = Prescription::class,
parentColumns = ["id"],
childColumns = ["prescription_id"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = PrescriptionImage::class,
parentColumns = ["id"],
childColumns = ["image_id"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index(value = ["prescription_id"]),
Index(value = ["image_id"]),
Index(value = ["source"]),
Index(value = ["needs_review"])
]
)
data class OcrResult(
@PrimaryKey
@ColumnInfo(name = "id")
val id: String = UUID.randomUUID().toString(),
@ColumnInfo(name = "user_id")
val userId: String = "",
// Referencias
@ColumnInfo(name = "prescription_id", index = true)
val prescriptionId: String = "",
@ColumnInfo(name = "image_id", index = true)
val imageId: String = "",
// Fuente del OCR
@ColumnInfo(name = "source")
val source: OcrSource = OcrSource.LOCAL,
@ColumnInfo(name = "ocr_engine")
val ocrEngine: String? = null,
// Texto extraido
@ColumnInfo(name = "raw_text")
val rawText: String = "",
@ColumnInfo(name = "confidence")
val confidence: Double? = null,
// Datos estructurados (JSON)
@ColumnInfo(name = "structured_data")
val structuredData: String? = null,
// Medicamentos detectados
@ColumnInfo(name = "medications_detected")
val medicationsDetected: Int = 0,
@ColumnInfo(name = "medications_linked")
val medicationsLinked: Int = 0,
// Estado
@ColumnInfo(name = "is_parsed")
val isParsed: Boolean = false,
@ColumnInfo(name = "needs_review")
val needsReview: Boolean = false,
@ColumnInfo(name = "reviewed_at")
val reviewedAt: Instant? = null,
// Errores
@ColumnInfo(name = "has_errors")
val hasErrors: Boolean = false,
@ColumnInfo(name = "error_message")
val errorMessage: String? = null,
// Sync
@ColumnInfo(name = "sync_status")
val syncStatus: SyncStatus = SyncStatus.PENDING,
@ColumnInfo(name = "last_sync_at")
val lastSyncAt: Instant? = null,
@ColumnInfo(name = "version")
val version: Int = 1,
// Timestamps
@ColumnInfo(name = "created_at")
val createdAt: Instant = Instant.now(),
@ColumnInfo(name = "updated_at")
val updatedAt: Instant = Instant.now(),
@ColumnInfo(name = "is_deleted")
val isDeleted: Boolean = false
)
3. Entidades del Servidor (SYNCED_E2E Blobs)¶
3.1. srv_prescription_sync¶
Almacena blobs cifrados de recetas para sincronizacion (Pro/Perfect).
3.1.1. Esquema PostgreSQL¶
-- ============================================================
-- TABLA: srv_prescription_sync
-- Descripcion: Blobs cifrados de recetas (Pro/Perfect)
-- RLS: Usuario solo ve sus propios blobs
-- ============================================================
CREATE TABLE srv_prescription_sync (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES srv_users(id) ON DELETE CASCADE,
-- Tipo de entidad cifrada
entity_type VARCHAR(50) NOT NULL,
-- prescription, prescription_medication, prescriber,
-- issuing_institution, prescription_image, ocr_result
entity_id UUID NOT NULL, -- ID de la entidad en cliente
-- Blob cifrado E2E (contiene JSON cifrado)
encrypted_data BYTEA NOT NULL,
-- Metadata de cifrado (sin PHI)
encryption_version VARCHAR(10) NOT NULL DEFAULT 'v1',
nonce BYTEA NOT NULL, -- 96 bits
auth_tag BYTEA NOT NULL, -- 128 bits
-- Hash de integridad
data_hash VARCHAR(64) NOT NULL, -- SHA-256 del blob cifrado
-- Metadata operativa (sin PHI)
blob_size_bytes INTEGER NOT NULL,
is_deleted BOOLEAN DEFAULT FALSE,
-- Versionado para conflictos
version INTEGER NOT NULL DEFAULT 1,
client_timestamp TIMESTAMPTZ NOT NULL, -- Timestamp del cliente
-- Timestamps servidor
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT uq_prescription_entity UNIQUE (user_id, entity_type, entity_id)
);
-- Indices
CREATE INDEX idx_prescription_sync_user ON srv_prescription_sync(user_id, entity_type);
CREATE INDEX idx_prescription_sync_updated ON srv_prescription_sync(updated_at);
CREATE INDEX idx_prescription_sync_deleted ON srv_prescription_sync(is_deleted)
WHERE is_deleted = FALSE;
-- RLS
ALTER TABLE srv_prescription_sync ENABLE ROW LEVEL SECURITY;
CREATE POLICY prescription_sync_user_policy ON srv_prescription_sync
FOR ALL
USING (user_id = current_setting('app.current_user_id')::UUID);
-- Trigger updated_at
CREATE TRIGGER trg_prescription_sync_updated
BEFORE UPDATE ON srv_prescription_sync
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Funcion para limpieza de registros eliminados
CREATE OR REPLACE FUNCTION cleanup_deleted_prescription_sync()
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER;
BEGIN
-- Eliminar registros marcados como deleted hace mas de 90 dias
DELETE FROM srv_prescription_sync
WHERE is_deleted = TRUE
AND updated_at < NOW() - INTERVAL '90 days';
GET DIAGNOSTICS deleted_count = ROW_COUNT;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
3.1.2. Mapeo de Sincronizacion¶
| Entidad Cliente | entity_type | Campos Cifrados en Blob | Campos NO Cifrados |
|---|---|---|---|
| cli_prescriptions | prescription | prescriberId, prescriptionDate, diagnosis, treatmentPurpose, notes | user_id, version, timestamps |
| cli_prescription_medications | prescription_medication | prescriptionId, medicationId, medicationName, dosage, instructions | user_id, version, isDeleted |
| cli_prescriber | prescriber | fullName, licenseNumber, specialty, phoneNumber, email, address | user_id, version, isDeleted |
| cli_issuing_institution | issuing_institution | name, type, phoneNumber, email, address | user_id, version, isDeleted |
| cli_prescription_images | prescription_image | prescriptionId, originalFileName, originalFileUrl, originalFileHash, anonymizedFileUrl, anonymizedFileHash | user_id, version, isDeleted |
| cli_ocr_results | ocr_result | prescriptionId, imageId, rawText, structuredData | user_id, version, isDeleted |
3.2. srv_ocr_requests¶
Solicitudes de OCR avanzado (Perfect tier).
3.2.1. Esquema PostgreSQL¶
-- ============================================================
-- TABLA: srv_ocr_requests
-- Descripcion: Solicitudes de OCR avanzado (Perfect)
-- RLS: Usuario solo ve sus propias solicitudes
-- ============================================================
CREATE TABLE srv_ocr_requests (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES srv_users(id) ON DELETE CASCADE,
-- Referencias a entidades cliente
prescription_id UUID NOT NULL, -- ID en cliente
image_id UUID NOT NULL, -- ID en cliente
-- Imagen anonimizada (temporal)
anonymized_image BYTEA NOT NULL,
image_format VARCHAR(10) NOT NULL, -- jpg, png
image_size_bytes INTEGER NOT NULL,
-- Confirmacion de anonimizacion
anonymization_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
anonymization_disclaimer_accepted BOOLEAN NOT NULL DEFAULT FALSE,
-- Estado del procesamiento
status VARCHAR(20) NOT NULL DEFAULT 'pending',
-- pending, processing, completed, failed
-- Resultado OCR (cifrado E2E)
ocr_result_encrypted BYTEA,
ocr_confidence DOUBLE PRECISION,
ocr_engine VARCHAR(50),
-- Metadata de cifrado resultado
result_nonce BYTEA,
result_auth_tag BYTEA,
-- Errores
error_message TEXT,
retry_count INTEGER DEFAULT 0,
-- Limpieza automatica
image_deleted_at TIMESTAMPTZ, -- Imagen eliminada del servidor
expires_at TIMESTAMPTZ NOT NULL, -- Solicitud expira en 24h
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ,
-- Constraints
CONSTRAINT chk_ocr_status CHECK (status IN ('pending', 'processing', 'completed', 'failed'))
);
-- Indices
CREATE INDEX idx_ocr_requests_user ON srv_ocr_requests(user_id);
CREATE INDEX idx_ocr_requests_status ON srv_ocr_requests(status)
WHERE status IN ('pending', 'processing');
CREATE INDEX idx_ocr_requests_expires ON srv_ocr_requests(expires_at)
WHERE status = 'pending';
-- RLS
ALTER TABLE srv_ocr_requests ENABLE ROW LEVEL SECURITY;
CREATE POLICY ocr_requests_user_policy ON srv_ocr_requests
FOR ALL
USING (user_id = current_setting('app.current_user_id')::UUID);
-- Funcion para eliminar imagen anonimizada despues de procesamiento
CREATE OR REPLACE FUNCTION delete_ocr_image_after_processing()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.status IN ('completed', 'failed') AND OLD.status NOT IN ('completed', 'failed') THEN
NEW.anonymized_image := NULL;
NEW.image_deleted_at := NOW();
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_delete_ocr_image
BEFORE UPDATE ON srv_ocr_requests
FOR EACH ROW
WHEN (NEW.status IN ('completed', 'failed'))
EXECUTE FUNCTION delete_ocr_image_after_processing();
-- Funcion para limpiar solicitudes expiradas (DV2-P3: DB-BAJO-002)
CREATE OR REPLACE FUNCTION cleanup_expired_ocr_requests()
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER;
BEGIN
-- Eliminar solicitudes expiradas hace mas de 7 dias
DELETE FROM srv_ocr_requests
WHERE expires_at < NOW() - INTERVAL '7 days';
GET DIAGNOSTICS deleted_count = ROW_COUNT;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
-- Comentarios de Retention Policy (DV2-P3: DB-BAJO-002)
COMMENT ON TABLE srv_ocr_requests IS 'Solicitudes de OCR avanzado con retention automatico. Imagen anonimizada se elimina tras procesamiento (trigger). Solicitud completa se elimina 7 dias despues de expirar (cleanup job).';
COMMENT ON COLUMN srv_ocr_requests.expires_at IS 'Solicitud expira en 24 horas desde creacion. Cleanup job elimina solicitudes 7 dias despues de expirar.';
COMMENT ON COLUMN srv_ocr_requests.anonymized_image IS 'Imagen anonimizada TEMPORAL. Se elimina automaticamente al completar/fallar procesamiento (trigger trg_delete_ocr_image).';
COMMENT ON COLUMN srv_ocr_requests.image_deleted_at IS 'Timestamp de eliminacion de imagen. Si NULL, imagen aun presente (solo si status=pending/processing).';
4. Retention Policy y Limpieza Automatica (DV2-P3)¶
4.1. Politica de Retencion para srv_ocr_requests¶
DB-BAJO-002: Las solicitudes de OCR avanzado contienen imagenes anonimizadas temporales que DEBEN eliminarse para minimizar exposicion de datos.
4.1.1. Ciclo de Vida de Solicitud OCR¶
TIMELINE:
T=0h T=procesamiento T=24h (expires_at) T=31 dias (cleanup)
| | | |
CREATE COMPLETE EXPIRE HARD DELETE
| | | |
v v v v
[PENDING] -------> [COMPLETED] -------> [EXPIRED] ----------> [DELETED]
| | ^
| anonymized_ | image_deleted= | cleanup_expired_ocr_requests()
| image present | NOW() | ejecuta cada hora
| | image=NULL |
4.1.2. Tabla de Retention¶
| Componente | Retencion | Mecanismo | Trigger/Job |
|---|---|---|---|
| anonymized_image | Hasta procesamiento | Automatico (trigger) | trg_delete_ocr_image |
| ocr_result_encrypted | 24 horas | Expiracion | expires_at = created_at + 24h |
| Solicitud completa | 7 dias post-expiracion | Cleanup job | cleanup_expired_ocr_requests() |
| Metadata (logs) | 90 dias | Auditoria | Separado en audit_trail |
4.1.3. Cleanup Job Configuration¶
Frecuencia: Cada hora (cron: 0 * * * *)
Job SQL:
Monitoring:
-- Verificar solicitudes pendientes de limpieza
SELECT COUNT(*) as pending_cleanup,
MIN(expires_at) as oldest_expired
FROM srv_ocr_requests
WHERE expires_at < NOW() - INTERVAL '7 days';
Alertas:
- Si
pending_cleanup> 1000: Alerta a DevOps (job no esta corriendo) - Si
oldest_expired> 14 dias: Alerta critica (backlog de limpieza)
4.1.4. Emergency Cleanup¶
En caso de backlog critico:
-- Eliminar TODAS las solicitudes expiradas hace mas de 24 horas
-- (ignora grace period de 7 dias)
DELETE FROM srv_ocr_requests
WHERE status IN ('completed', 'failed')
AND expires_at < NOW() - INTERVAL '24 hours';
4.2. Metricas de Retention¶
| Metrica | Target | Alerta Si |
|---|---|---|
Solicitudes en srv_ocr_requests |
< 10,000 | > 50,000 |
Solicitudes con anonymized_image != NULL |
< 100 | > 500 |
| Solicitudes expiradas sin limpiar | < 1,000 | > 5,000 |
Tamano promedio de anonymized_image |
~500 KB | > 2 MB |
5. Diagrama ER del Modulo¶
erDiagram
%% CLIENTE
cli_user ||--o{ cli_prescriptions : "has"
cli_prescriptions ||--o{ cli_prescription_medications : "contains"
cli_prescriptions ||--o{ cli_prescription_images : "has"
cli_prescriptions ||--o{ cli_ocr_results : "generates"
cli_prescriptions }o--|| cli_prescriber : "prescribed_by"
cli_prescriptions }o--|| cli_issuing_institution : "issued_by"
cli_prescription_medications }o--|| cli_medications : "links_to"
cli_prescription_images ||--o| cli_ocr_results : "processed_by"
cli_prescriber }o--|| cli_issuing_institution : "works_at"
%% SERVIDOR
srv_users ||--o{ srv_prescription_sync : "syncs"
srv_users ||--o{ srv_ocr_requests : "requests"
%% Entidades Cliente
cli_prescriptions {
string id PK
string userId FK
string prescriberId FK
string institutionId FK
date prescriptionDate
string diagnosis
string treatmentPurpose
string notes
boolean hasImages
boolean hasOcr
int linkedMedicationsCount
}
cli_prescription_medications {
string id PK
string userId
string prescriptionId FK
string medicationId FK
string medicationName
string dosage
string instructions
string frequency
boolean isLinkedToMedication
}
cli_prescriber {
string id PK
string userId
string fullName
string licenseNumber
string specialty
string phoneNumber
string email
string primaryInstitutionId FK
}
cli_issuing_institution {
string id PK
string userId
string name
string type
string address
string phoneNumber
}
cli_prescription_images {
string id PK
string userId
string prescriptionId FK
string originalFileName
string originalFileUrl
string originalFileHash
boolean hasAnonymized
string anonymizedFileUrl
boolean ocrProcessed
string ocrResultId FK
}
cli_ocr_results {
string id PK
string userId
string prescriptionId FK
string imageId FK
string source
string rawText
double confidence
string structuredData
int medicationsDetected
boolean needsReview
}
%% Entidades Servidor
srv_prescription_sync {
uuid id PK
uuid user_id FK
string entity_type
uuid entity_id
bytea encrypted_data
bytea nonce
bytea auth_tag
string data_hash
int version
}
srv_ocr_requests {
uuid id PK
uuid user_id FK
uuid prescription_id
uuid image_id
bytea anonymized_image
string status
bytea ocr_result_encrypted
double ocr_confidence
timestamptz expires_at
boolean anonymization_confirmed
}
6. Reglas de Negocio Reflejadas en Modelo¶
| ID | Regla de Negocio | Implementacion en Modelo |
|---|---|---|
| RN-RX-001 | Imagen original NUNCA se envia a servidor | cli_prescription_images almacena original y anonimizada por separado |
| RN-RX-002 | OCR servidor requiere anonimizacion manual | srv_ocr_requests.anonymization_confirmed obligatorio |
| RN-RX-003 | Imagen en servidor se elimina tras procesamiento | Trigger trg_delete_ocr_image en srv_ocr_requests |
| RN-RX-004 | Solicitudes OCR expiran en 24 horas | srv_ocr_requests.expires_at + funcion de cleanup |
| RN-RX-005 | Medicamentos de receta pueden vincularse a calendario | cli_prescription_medications.isLinkedToMedication + FK opcional |
| RN-RX-006 | Free: Solo almacenamiento local | No sincronizacion (solo tier Pro/Perfect) |
| RN-RX-007 | Pro: OCR local + sync cloud | OCR local siempre disponible |
| RN-RX-008 | Perfect: OCR avanzado servidor | srv_ocr_requests solo para Perfect |
| RN-RX-009 | Recetas se mantienen indefinidamente | Sin fecha de expiracion en modelo |
| RN-RX-010 | Usuario debe aceptar disclaimer anonimizacion | anonymization_disclaimer_accepted en srv_ocr_requests |
| RN-RX-011 | Resultado OCR requiere revision manual si confianza < 80% | cli_ocr_results.needsReview calculado por logica |
7. Consideraciones de Cifrado y Anonimizacion¶
7.1. Campos PHI Cifrados E2E¶
Los siguientes campos contienen PHI y DEBEN cifrarse antes de sincronizar:
| Entidad | Campos PHI | Nivel PHI | Razon |
|---|---|---|---|
| cli_prescriptions | prescriptionDate, diagnosis, treatmentPurpose, notes | Critico | Diagnostico y tratamiento son PHI critico |
| cli_prescription_medications | medicationName, dosage, instructions | Critico | Medicamentos prescritos son PHI critico |
| cli_prescriber | fullName, licenseNumber, specialty, phoneNumber, email, address | Alto | Datos del medico son PHI |
| cli_issuing_institution | name, type, address, phoneNumber | Medio | Institucion revela tratamiento |
| cli_prescription_images | originalFileUrl, anonymizedFileUrl, fileHash | Critico | Imagenes contienen documentos medicos completos |
| cli_ocr_results | rawText, structuredData | Critico | Texto extraido es PHI critico |
7.2. Imagenes de Recetas¶
Las imagenes de recetas se manejan con un proceso de dos versiones:
7.2.1. Imagen Original (NUNCA sale del dispositivo)¶
FLUJO DE IMAGEN ORIGINAL:
+------------------------------------------------------------------+
| 1. Usuario toma foto de receta |
| 2. Imagen se guarda en almacenamiento cifrado de la app |
| 3. URL local: /app_data/prescriptions/original_<uuid>.jpg (cifrado) |
| 4. Hash SHA-256 para integridad |
| 5. NUNCA se envia al servidor bajo NINGUNA circunstancia |
+------------------------------------------------------------------+
7.2.2. Imagen Anonimizada (para OCR servidor - Perfect)¶
FLUJO DE IMAGEN ANONIMIZADA:
+------------------------------------------------------------------+
| 1. Usuario selecciona "OCR avanzado" (Perfect tier) |
| 2. App muestra herramientas de anonimizacion: |
| - Rectangulo de tachado (negro opaco) |
| - Herramienta de blur/pixelado |
| - Crop para recortar secciones |
| 3. Usuario anonimiza manualmente (tacha nombres, etc.) |
| 4. App guarda VERSION ANONIMIZADA en almacenamiento cifrado |
| 5. URL local: /app_data/prescriptions/anon_<uuid>.jpg (cifrado) |
| 6. Usuario confirma "He revisado y anonimizado" |
| 7. Solo VERSION ANONIMIZADA se envia via TLS a servidor |
| 8. Servidor procesa OCR y elimina imagen INMEDIATAMENTE |
+------------------------------------------------------------------+
7.3. OCR Local vs Servidor¶
7.3.1. OCR Local (All Tiers)¶
| Caracteristica | Valor |
|---|---|
| Engine | iOS Vision / Android ML Kit |
| Procesamiento | 100% en dispositivo |
| Privacidad | Imagen NUNCA sale del dispositivo |
| Precision | Basica (70-85% confianza) |
| Idiomas | Español, Ingles |
| Costo | Gratis (incluido en OS) |
7.3.2. OCR Avanzado Servidor (Perfect Tier Only)¶
| Caracteristica | Valor |
|---|---|
| Engine | Google Cloud Vision AI / AWS Textract |
| Procesamiento | Servidor (requiere GPU) |
| Privacidad | Usuario DEBE anonimizar antes de enviar |
| Precision | Alta (90-98% confianza) |
| Idiomas | 100+ idiomas |
| Costo | Incluido en tier Perfect |
| Retencion | Imagen eliminada INMEDIATAMENTE tras procesamiento |
Flujo de OCR Servidor con Anonimizacion:
sequenceDiagram
participant U as Usuario
participant A as App
participant S as Servidor OCR
U->>A: Toma foto de receta
A->>A: Guardar imagen ORIGINAL (cifrada, local)
A->>A: Ejecutar OCR local (basico)
U->>A: "Quiero OCR avanzado"
A->>A: Verificar tier = Perfect
A->>A: Mostrar herramientas de anonimizacion
U->>A: Anonimiza manualmente (tacha nombres, etc.)
A->>A: Guardar imagen ANONIMIZADA (cifrada, local)
A->>U: Mostrar disclaimer de anonimizacion
U->>A: Confirma "He revisado y anonimizado"
A->>S: POST /ocr/advanced {anonymized_image_encrypted}
S->>S: Descifrar imagen anonimizada
S->>S: Procesar OCR
S->>S: Estructurar datos extraidos
S->>S: ELIMINAR imagen inmediatamente
S->>A: Cifrar resultado E2E
S->>A: Retornar {ocr_result_encrypted}
A->>A: Descifrar resultado
A->>A: Parsear medicamentos, dosis, etc.
A->>A: Guardar en cli_ocr_results (cifrado)
A->>U: Mostrar resultados + sugerencias vinculacion
Note over S: Servidor NO retiene la imagen<br/>procesamiento temporal, sin persistencia
8. Indices y Performance¶
8.1. Cliente (SQLite/Realm)¶
// iOS Realm - Indices criticos
class Prescription: Object {
@Persisted(indexed: true) var userId: String
@Persisted(indexed: true) var prescriberId: String
@Persisted(indexed: true) var prescriptionDate: Date
@Persisted(indexed: true) var isActive: Bool
}
class PrescriptionMedication: Object {
@Persisted(indexed: true) var prescriptionId: String
@Persisted(indexed: true) var medicationId: String
@Persisted(indexed: true) var medicationName: String
@Persisted(indexed: true) var isLinkedToMedication: Bool
}
class OcrResult: Object {
@Persisted(indexed: true) var prescriptionId: String
@Persisted(indexed: true) var imageId: String
@Persisted(indexed: true) var source: String
@Persisted(indexed: true) var needsReview: Bool
}
// Android Room - Indices
@Entity(
tableName = "cli_prescriptions",
indices = [
Index(value = ["user_id", "prescription_date"]),
Index(value = ["prescriber_id"]),
Index(value = ["institution_id"]),
Index(value = ["is_active"])
]
)
@Entity(
tableName = "cli_prescription_medications",
indices = [
Index(value = ["prescription_id"]),
Index(value = ["medication_id"]),
Index(value = ["medication_name"]),
Index(value = ["is_linked_to_medication"])
]
)
@Entity(
tableName = "cli_ocr_results",
indices = [
Index(value = ["prescription_id"]),
Index(value = ["image_id"]),
Index(value = ["source"]),
Index(value = ["needs_review"])
]
)
8.2. Servidor (PostgreSQL)¶
-- Indices criticos para sync
CREATE INDEX idx_prescription_sync_user_type ON srv_prescription_sync(user_id, entity_type);
CREATE INDEX idx_prescription_sync_updated ON srv_prescription_sync(updated_at)
WHERE is_deleted = FALSE;
-- Index para cleanup job
CREATE INDEX idx_prescription_sync_cleanup ON srv_prescription_sync(is_deleted, updated_at)
WHERE is_deleted = TRUE;
-- Indices para OCR requests
CREATE INDEX idx_ocr_requests_user ON srv_ocr_requests(user_id);
CREATE INDEX idx_ocr_requests_status ON srv_ocr_requests(status)
WHERE status IN ('pending', 'processing');
CREATE INDEX idx_ocr_requests_expires ON srv_ocr_requests(expires_at);
9. Migraciones¶
9.1. Migration 001: Create Prescriptions Schema (Servidor)¶
-- Migration: 001_create_prescriptions_schema
-- Date: 2025-12-08
-- Author: DatabaseDrone
BEGIN;
-- Tabla de sincronizacion de recetas
CREATE TABLE srv_prescription_sync (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES srv_users(id) ON DELETE CASCADE,
entity_type VARCHAR(50) NOT NULL,
entity_id UUID NOT NULL,
encrypted_data BYTEA NOT NULL,
encryption_version VARCHAR(10) NOT NULL DEFAULT 'v1',
nonce BYTEA NOT NULL,
auth_tag BYTEA NOT NULL,
data_hash VARCHAR(64) NOT NULL,
blob_size_bytes INTEGER NOT NULL,
is_deleted BOOLEAN DEFAULT FALSE,
version INTEGER NOT NULL DEFAULT 1,
client_timestamp TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_prescription_entity UNIQUE (user_id, entity_type, entity_id)
);
-- Tabla de solicitudes OCR
CREATE TABLE srv_ocr_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES srv_users(id) ON DELETE CASCADE,
prescription_id UUID NOT NULL,
image_id UUID NOT NULL,
anonymized_image BYTEA NOT NULL,
image_format VARCHAR(10) NOT NULL,
image_size_bytes INTEGER NOT NULL,
anonymization_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
anonymization_disclaimer_accepted BOOLEAN NOT NULL DEFAULT FALSE,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
ocr_result_encrypted BYTEA,
ocr_confidence DOUBLE PRECISION,
ocr_engine VARCHAR(50),
result_nonce BYTEA,
result_auth_tag BYTEA,
error_message TEXT,
retry_count INTEGER DEFAULT 0,
image_deleted_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ,
CONSTRAINT chk_ocr_status CHECK (status IN ('pending', 'processing', 'completed', 'failed'))
);
-- Indices
CREATE INDEX idx_prescription_sync_user ON srv_prescription_sync(user_id, entity_type);
CREATE INDEX idx_prescription_sync_updated ON srv_prescription_sync(updated_at);
CREATE INDEX idx_prescription_sync_deleted ON srv_prescription_sync(is_deleted)
WHERE is_deleted = FALSE;
CREATE INDEX idx_ocr_requests_user ON srv_ocr_requests(user_id);
CREATE INDEX idx_ocr_requests_status ON srv_ocr_requests(status)
WHERE status IN ('pending', 'processing');
CREATE INDEX idx_ocr_requests_expires ON srv_ocr_requests(expires_at)
WHERE status = 'pending';
-- RLS
ALTER TABLE srv_prescription_sync ENABLE ROW LEVEL SECURITY;
ALTER TABLE srv_ocr_requests ENABLE ROW LEVEL SECURITY;
CREATE POLICY prescription_sync_user_policy ON srv_prescription_sync
FOR ALL
USING (user_id = current_setting('app.current_user_id')::UUID);
CREATE POLICY ocr_requests_user_policy ON srv_ocr_requests
FOR ALL
USING (user_id = current_setting('app.current_user_id')::UUID);
-- Triggers
CREATE TRIGGER trg_prescription_sync_updated
BEFORE UPDATE ON srv_prescription_sync
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER trg_delete_ocr_image
BEFORE UPDATE ON srv_ocr_requests
FOR EACH ROW
WHEN (NEW.status IN ('completed', 'failed'))
EXECUTE FUNCTION delete_ocr_image_after_processing();
COMMIT;
9.2. Migration 001: Create Prescriptions Schema (Cliente iOS)¶
// Realm Migration - Version 1
let config = Realm.Configuration(
schemaVersion: 1,
migrationBlock: { migration, oldSchemaVersion in
if oldSchemaVersion < 1 {
// Primera version - crear esquema
migration.enumerateObjects(ofType: Prescription.className()) { oldObject, newObject in
// Valores por defecto si vienen de version anterior
}
}
}
)
Realm.Configuration.defaultConfiguration = config
9.3. Migration 001: Create Prescriptions Schema (Cliente Android)¶
// Room Migration - Version 1 to 2
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
// Crear tablas de recetas
database.execSQL("""
CREATE TABLE IF NOT EXISTS cli_prescriptions (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
profile_id TEXT,
prescriber_id TEXT,
prescriber_name TEXT,
institution_id TEXT,
institution_name TEXT,
prescription_date INTEGER NOT NULL,
valid_until INTEGER,
date_uploaded INTEGER NOT NULL,
diagnosis TEXT,
treatment_purpose TEXT,
notes TEXT,
is_recurring INTEGER NOT NULL DEFAULT 0,
recurring_type TEXT,
has_images INTEGER NOT NULL DEFAULT 0,
image_count INTEGER NOT NULL DEFAULT 0,
has_ocr INTEGER NOT NULL DEFAULT 0,
ocr_processed INTEGER NOT NULL DEFAULT 0,
ocr_source TEXT,
linked_medications_count INTEGER NOT NULL DEFAULT 0,
all_medications_linked INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
is_expired INTEGER NOT NULL DEFAULT 0,
is_deleted INTEGER NOT NULL DEFAULT 0,
sync_status TEXT NOT NULL DEFAULT 'PENDING',
last_sync_at INTEGER,
version INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
""")
// Indices
database.execSQL("""
CREATE INDEX IF NOT EXISTS idx_prescriptions_user
ON cli_prescriptions(user_id, prescription_date)
""")
database.execSQL("""
CREATE INDEX IF NOT EXISTS idx_prescriptions_prescriber
ON cli_prescriptions(prescriber_id)
""")
// ... resto de tablas ...
}
}
10. Referencias Cruzadas¶
| Documento | Relacion |
|---|---|
| DB-ERD-001 | Diagrama ER completo |
| MTS-RX-001 | Especificacion funcional |
| MDL-MED-001 | Vinculacion de medicamentos |
| MDL-USR-001 | FK a users |
| API-SYNC-001 | Protocolo de sync |
| API-OCR-001 | API de OCR avanzado |
| 02-arquitectura-cliente-servidor.md | Arquitectura dual |
| 04-seguridad-cliente.md | Cifrado E2E |
| 05-seguridad-servidor.md | RLS policies |
| INV-015-ocr-anonimizacion.md | Proceso de anonimizacion de recetas |
Documento generado por DatabaseDrone (Doce de Quince) - SpecQueen Technical Division "Las recetas son documentos medicos criticos. LOCAL primero, anonimizacion SIEMPRE antes de servidor."