Modelo de Datos: Usuarios¶
Identificador: MDL-USR-001 Version: 1.0.0 Fecha: 2025-12-07 Estado: Aprobado Autor: DatabaseDrone / SpecQueen Technical Division Modulo Funcional: MTS-USR-001
- 1. Introduccion
- 1.1. Principios
- 1.2. Clasificacion de Datos
- 2. Entidades del Cliente
- 2.1. cli_user
- 2.2. cli_medical_profile
- 2.3. cli_emergency_contact
- 2.4. cli_dependents
- 2.5. cli_caregiver_perms
- 2.6. cli_preferences
- 3. Diagrama ER del Modulo
- 4. Funciones de Validacion
- 4.1. Validar Limite de Dependientes
- 4.2. Validar Edad para Acceso PD
- 4.3. Validar Permisos CS
- 5. Serializacion para Sync E2E
- 5.1. Estructura del Blob Cifrado
- 5.2. Campos No Cifrados (Metadata)
- 6. Migraciones
- 6.1. Esquema Cliente (SQLite/Realm)
- 7. Referencias Cruzadas
1. Introduccion¶
Este documento define el modelo de datos para el modulo de usuarios de MedTime. Incluye perfiles, dependientes, cuidadores, contactos de emergencia y preferencias. Todos los datos de usuario son SYNCED_E2E, excepto preferencias que son LOCAL_ONLY.
1.1. Principios¶
| Principio | Aplicacion |
|---|---|
| PHI Cifrado | Todos los datos medicos cifrados E2E |
| PII Protegido | Datos personales con blind index |
| Perfiles Anidados | PD tiene perfil dentro de cuenta CR |
| Preferencias Locales | Configuracion de UI nunca sincroniza |
1.2. Clasificacion de Datos¶
| Entidad | Clasificacion | Cifrado | Sync |
|---|---|---|---|
| cli_user | SYNCED_E2E | Si | Si |
| cli_medical_profile | SYNCED_E2E | Si | Si |
| cli_emergency_contact | SYNCED_E2E | Si | Si |
| cli_dependents | SYNCED_E2E | Si | Si |
| cli_caregiver_perms | SYNCED_E2E | Si | Si |
| cli_preferences | LOCAL_ONLY | Si | No |
2. Entidades del Cliente¶
2.1. cli_user¶
Perfil principal del usuario.
// iOS - Swift/Realm
class User: Object {
@Persisted(primaryKey: true) var userId: String = UUID().uuidString
// Identidad (Cifrada E2E, blind index en servidor)
@Persisted var email: String?
@Persisted var phone: String?
@Persisted var displayName: String
// Clasificacion
@Persisted var role: String // PI, CR, CS
@Persisted var tier: String // free, pro, perfect
// Perfil Basico
@Persisted var photoUrl: String?
@Persisted var locale: String = "es-MX" // es-MX, en-US, pt-BR
@Persisted var timezone: String // America/Mexico_City
// Estado de Cuenta
@Persisted var isActive: Bool = true
@Persisted var emailVerified: Bool = false
@Persisted var phoneVerified: Bool = false
@Persisted var mfaEnabled: Bool = false
// Onboarding
@Persisted var onboardingCompleted: Bool = false
@Persisted var termsAcceptedAt: Date?
@Persisted var privacyAcceptedAt: Date?
// Sync
@Persisted var createdAt: Date = Date()
@Persisted var updatedAt: Date = Date()
@Persisted var syncVersion: Int64 = 0
// Relationships
@Persisted var medicalProfile: MedicalProfile?
@Persisted var emergencyContacts: List<EmergencyContact>
@Persisted var dependents: List<Dependent>
@Persisted var caregiverPermissions: List<CaregiverPermission>
@Persisted var preferences: Preferences?
}
// Android - Kotlin/Room
@Entity(tableName = "cli_users")
data class User(
@PrimaryKey
val userId: String = UUID.randomUUID().toString(),
// Identity
val email: String?,
val phone: String?,
val displayName: String,
// Classification
val role: String, // PI, CR, CS
val tier: String, // free, pro, perfect
// Profile
val photoUrl: String?,
val locale: String = "es-MX",
val timezone: String,
// Account Status
val isActive: Boolean = true,
val emailVerified: Boolean = false,
val phoneVerified: Boolean = false,
val mfaEnabled: Boolean = false,
// Onboarding
val onboardingCompleted: Boolean = false,
val termsAcceptedAt: Long?,
val privacyAcceptedAt: Long?,
// Sync
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
val syncVersion: Long = 0
)
2.1.1. Campos PHI/PII¶
| Campo | Tipo | Nivel | Tratamiento |
|---|---|---|---|
| PII | Medio | Blind index en servidor | |
| phone | PII | Medio | Blind index en servidor |
| displayName | PII | Bajo | Cifrado E2E |
| photoUrl | PII | Bajo | Referencia a imagen cifrada |
2.1.2. Roles y Tiers¶
| Rol | Descripcion | Tiers Permitidos |
|---|---|---|
| PI | Paciente Independiente | free, pro, perfect |
| CR | Cuidador Responsable | free, pro, perfect |
| CS | Cuidador Solidario | free unicamente |
2.2. cli_medical_profile¶
Perfil medico del usuario (1:1 con User).
// iOS - Swift/Realm
class MedicalProfile: Object {
@Persisted(primaryKey: true) var profileId: String = UUID().uuidString
// Foreign Key
@Persisted(originProperty: "medicalProfile") var user: LinkingObjects<User>
// Datos Basicos
@Persisted var birthDate: Date?
@Persisted var biologicalSex: String? // male, female, intersex
@Persisted var genderIdentity: String? // man, woman, non-binary, other
// Medidas
@Persisted var heightCm: Double?
@Persisted var weightKg: Double?
@Persisted var bloodType: String? // A+, A-, B+, B-, AB+, AB-, O+, O-
// Condiciones Medicas
@Persisted var allergies: List<Allergy>
@Persisted var conditions: List<Condition>
@Persisted var surgeries: List<Surgery>
// Estilo de Vida
@Persisted var smokingStatus: String? // never, former, current
@Persisted var alcoholUse: String? // none, occasional, moderate, heavy
@Persisted var exerciseFrequency: String? // none, light, moderate, active
// Notas
@Persisted var notes: String?
// Sync
@Persisted var updatedAt: Date = Date()
@Persisted var syncVersion: Int64 = 0
}
// Embedded Objects for Medical Data
class Allergy: EmbeddedObject {
@Persisted var name: String
@Persisted var severity: String // mild, moderate, severe
@Persisted var reaction: String?
@Persisted var diagnosedAt: Date?
}
class Condition: EmbeddedObject {
@Persisted var name: String
@Persisted var icd10Code: String?
@Persisted var status: String // active, resolved, chronic
@Persisted var diagnosedAt: Date?
@Persisted var treatingDoctor: String?
}
class Surgery: EmbeddedObject {
@Persisted var name: String
@Persisted var performedAt: Date?
@Persisted var hospital: String?
@Persisted var surgeon: String?
@Persisted var notes: String?
}
2.2.1. Campos PHI (Todos Cifrados E2E)¶
| Campo | Nivel PHI | Justificacion |
|---|---|---|
| birthDate | Alto | Identificador demografico |
| biologicalSex | Alto | Dato medico sensible |
| bloodType | Alto | Dato medico critico |
| allergies | Critico | Puede revelar condiciones |
| conditions | Critico | Diagnosticos medicos |
| surgeries | Alto | Historial medico |
2.3. cli_emergency_contact¶
Contactos de emergencia.
// iOS - Swift/Realm
class EmergencyContact: Object {
@Persisted(primaryKey: true) var contactId: String = UUID().uuidString
// Foreign Key
@Persisted(originProperty: "emergencyContacts") var user: LinkingObjects<User>
// Contacto
@Persisted var name: String
@Persisted var phone: String
@Persisted var email: String?
// Relacion
@Persisted var relationship: String // spouse, parent, child, sibling, friend, doctor
@Persisted var isPrimary: Bool = false
// Prioridad (1 = mas alta)
@Persisted var priority: Int = 1
// Permisos de Notificacion
@Persisted var notifyOnMissedDose: Bool = false
@Persisted var notifyOnEmergency: Bool = true
@Persisted var notifyOnLowStock: Bool = false
// Sync
@Persisted var createdAt: Date = Date()
@Persisted var updatedAt: Date = Date()
@Persisted var syncVersion: Int64 = 0
}
2.3.1. Campos PII¶
| Campo | Nivel | Tratamiento |
|---|---|---|
| name | PII Medio | Cifrado E2E |
| phone | PII Alto | Cifrado E2E |
| PII Medio | Cifrado E2E | |
| relationship | PII Bajo | Cifrado E2E |
2.4. cli_dependents¶
Pacientes dependientes (gestionados por CR).
// iOS - Swift/Realm
class Dependent: Object {
@Persisted(primaryKey: true) var dependentId: String = UUID().uuidString
// Foreign Key - Guardian (CR)
@Persisted(originProperty: "dependents") var guardian: LinkingObjects<User>
// Datos Basicos
@Persisted var name: String
@Persisted var nickname: String?
@Persisted var birthDate: Date
@Persisted var photoUrl: String?
// Relacion
@Persisted var relationship: String // child, parent, spouse, sibling, ward
// Perfil Medico (embebido, no relacion separada)
@Persisted var medicalProfile: DependentMedicalProfile?
// Acceso (para PD > 13 anos)
@Persisted var hasOwnAccess: Bool = false
@Persisted var accessPin: String? // PIN hasheado si tiene acceso
// Estado
@Persisted var isActive: Bool = true
// Sync
@Persisted var createdAt: Date = Date()
@Persisted var updatedAt: Date = Date()
@Persisted var syncVersion: Int64 = 0
// Computed
var age: Int {
Calendar.current.dateComponents([.year], from: birthDate, to: Date()).year ?? 0
}
var canHaveOwnAccess: Bool {
age >= 13
}
}
// Perfil Medico de Dependiente (embebido)
class DependentMedicalProfile: EmbeddedObject {
@Persisted var biologicalSex: String?
@Persisted var heightCm: Double?
@Persisted var weightKg: Double?
@Persisted var bloodType: String?
@Persisted var allergies: List<Allergy>
@Persisted var conditions: List<Condition>
@Persisted var notes: String?
}
2.4.1. Reglas de Negocio¶
| Regla | Descripcion |
|---|---|
| RN-USR-001 | Solo CR puede crear dependientes |
| RN-USR-002 | Limite de dependientes segun tier: Free=1, Pro=5, Perfect=10 |
| RN-USR-003 | PD >= 13 anos puede tener acceso de lectura |
| RN-USR-004 | PD >= 18 anos debe migrar a cuenta PI |
2.5. cli_caregiver_perms¶
Permisos granulares para cuidadores solidarios.
// iOS - Swift/Realm
class CaregiverPermission: Object {
@Persisted(primaryKey: true) var permissionId: String = UUID().uuidString
// Relacion Paciente-Cuidador
@Persisted var patientUserId: String // PI que otorga permiso
@Persisted var caregiverUserId: String // CS que recibe permiso
// Permiso Especifico
@Persisted var permissionType: String // ver tipo abajo
// Estado
@Persisted var isGranted: Bool = true
// Auditoria
@Persisted var grantedAt: Date = Date()
@Persisted var grantedBy: String // user_id de quien otorgo
@Persisted var revokedAt: Date?
@Persisted var revokedBy: String?
// Sync
@Persisted var syncVersion: Int64 = 0
}
2.5.1. Tipos de Permiso¶
| Tipo | Descripcion | Default |
|---|---|---|
view_medications |
Ver lista de medicamentos | Si |
view_adherence |
Ver estadisticas de tomas | Si |
confirm_doses |
Confirmar toma en nombre del PI | No |
receive_missed_alerts |
Recibir alerta de dosis omitida | Si |
view_prescriptions |
Ver recetas digitalizadas | No |
view_appointments |
Ver agenda de citas | No |
view_lab_results |
Ver resultados de laboratorio | No |
view_medical_profile |
Ver perfil medico | No |
2.6. cli_preferences¶
Preferencias locales (nunca sincroniza).
// iOS - Swift/Realm
class Preferences: Object {
@Persisted(primaryKey: true) var preferenceId: String = "preferences"
// Foreign Key
@Persisted(originProperty: "preferences") var user: LinkingObjects<User>
// Apariencia
@Persisted var theme: String = "system" // light, dark, system
@Persisted var fontSize: String = "medium" // small, medium, large
@Persisted var highContrast: Bool = false
// Notificaciones
@Persisted var notificationsEnabled: Bool = true
@Persisted var reminderSound: String = "default"
@Persisted var reminderVibrate: Bool = true
@Persisted var quietHoursEnabled: Bool = false
@Persisted var quietHoursStart: String = "22:00"
@Persisted var quietHoursEnd: String = "07:00"
// Idioma
@Persisted var language: String = "es" // es, en, pt
// Seguridad Local
@Persisted var biometricEnabled: Bool = false
@Persisted var pinHash: String? // Argon2 hash
@Persisted var autoLockMinutes: Int = 5
@Persisted var requireAuthOnOpen: Bool = true
// Datos
@Persisted var syncOnCellular: Bool = false
@Persisted var lowDataMode: Bool = false
@Persisted var cacheImages: Bool = true
// Privacidad
@Persisted var shareAnonymousData: Bool = true // Para mejora de catalogos
@Persisted var showMedicationNames: Bool = true // En widgets/notificaciones
// Actualizacion
@Persisted var updatedAt: Date = Date()
}
2.6.1. Clasificacion: LOCAL_ONLY¶
| Caracteristica | Valor |
|---|---|
| Sincronizacion | Nunca |
| Cifrado | En reposo (Keychain/Keystore) |
| Backup | Incluido en backup local |
| Multi-dispositivo | Cada dispositivo tiene sus propias preferencias |
3. Diagrama ER del Modulo¶
erDiagram
cli_user ||--|| cli_medical_profile : "has"
cli_user ||--o{ cli_emergency_contact : "has"
cli_user ||--o{ cli_dependents : "manages"
cli_user ||--o{ cli_caregiver_perms : "as_patient"
cli_user ||--o{ cli_caregiver_perms : "as_caregiver"
cli_user ||--|| cli_preferences : "has"
cli_dependents ||--|| cli_dependent_medical_profile : "has"
cli_user {
uuid userId PK
string email
string phone
string displayName
string role
string tier
boolean isActive
}
cli_medical_profile {
uuid profileId PK
uuid userId FK
date birthDate
string bloodType
json allergies
json conditions
}
cli_emergency_contact {
uuid contactId PK
uuid userId FK
string name
string phone
string relationship
int priority
}
cli_dependents {
uuid dependentId PK
uuid guardianId FK
string name
date birthDate
string relationship
boolean hasOwnAccess
}
cli_caregiver_perms {
uuid permissionId PK
uuid patientUserId FK
uuid caregiverUserId FK
string permissionType
boolean isGranted
}
cli_preferences {
uuid preferenceId PK
uuid userId FK
string theme
boolean biometricEnabled
string language
}
4. Funciones de Validacion¶
4.1. Validar Limite de Dependientes¶
// iOS
func canAddDependent(for user: User) -> Bool {
guard user.role == "CR" else { return false }
let currentCount = user.dependents.filter { $0.isActive }.count
let limit: Int
switch user.tier {
case "free": limit = 1
case "pro": limit = 5
case "perfect": limit = 10
default: limit = 0
}
return currentCount < limit
}
4.2. Validar Edad para Acceso PD¶
// iOS
func canGrantAccessToDependent(_ dependent: Dependent) -> Bool {
let age = Calendar.current.dateComponents([.year], from: dependent.birthDate, to: Date()).year ?? 0
return age >= 13
}
func mustMigrateToPI(_ dependent: Dependent) -> Bool {
let age = Calendar.current.dateComponents([.year], from: dependent.birthDate, to: Date()).year ?? 0
return age >= 18
}
4.3. Validar Permisos CS¶
// iOS
func hasPermission(caregiver: User, patient: User, permission: String) -> Bool {
guard caregiver.role == "CS" else { return false }
let perms = patient.caregiverPermissions.filter {
$0.caregiverUserId == caregiver.userId &&
$0.permissionType == permission &&
$0.isGranted &&
$0.revokedAt == nil
}
return !perms.isEmpty
}
5. Serializacion para Sync E2E¶
5.1. Estructura del Blob Cifrado¶
{
"entity_type": "user_profile",
"version": 1,
"encrypted_at": "2025-12-07T12:00:00Z",
"data": {
"user": {
"userId": "uuid",
"email": "encrypted_base64",
"phone": "encrypted_base64",
"displayName": "encrypted_base64",
"role": "PI",
"tier": "pro"
},
"medicalProfile": {
"birthDate": "encrypted_base64",
"bloodType": "encrypted_base64",
"allergies": "encrypted_base64_json_array",
"conditions": "encrypted_base64_json_array"
},
"emergencyContacts": [
{
"contactId": "uuid",
"name": "encrypted_base64",
"phone": "encrypted_base64",
"relationship": "encrypted_base64"
}
]
},
"checksum": "sha256_of_plaintext"
}
5.2. Campos No Cifrados (Metadata)¶
| Campo | Razon |
|---|---|
| entity_type | Routing de sync |
| version | Versionado de schema |
| encrypted_at | Timestamp de cifrado |
| checksum | Verificacion de integridad |
6. Migraciones¶
6.1. Esquema Cliente (SQLite/Realm)¶
-- SQLite Migration for Android Room
-- Version: 1
CREATE TABLE IF NOT EXISTS users (
userId TEXT PRIMARY KEY NOT NULL,
email TEXT,
phone TEXT,
displayName TEXT NOT NULL,
role TEXT NOT NULL,
tier TEXT NOT NULL,
photoUrl TEXT,
locale TEXT NOT NULL DEFAULT 'es-MX',
timezone TEXT NOT NULL,
isActive INTEGER NOT NULL DEFAULT 1,
emailVerified INTEGER NOT NULL DEFAULT 0,
phoneVerified INTEGER NOT NULL DEFAULT 0,
mfaEnabled INTEGER NOT NULL DEFAULT 0,
onboardingCompleted INTEGER NOT NULL DEFAULT 0,
termsAcceptedAt INTEGER,
privacyAcceptedAt INTEGER,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL,
syncVersion INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS medical_profiles (
profileId TEXT PRIMARY KEY NOT NULL,
userId TEXT NOT NULL,
birthDate INTEGER,
biologicalSex TEXT,
genderIdentity TEXT,
heightCm REAL,
weightKg REAL,
bloodType TEXT,
allergiesJson TEXT,
conditionsJson TEXT,
surgeriesJson TEXT,
smokingStatus TEXT,
alcoholUse TEXT,
exerciseFrequency TEXT,
notes TEXT,
updatedAt INTEGER NOT NULL,
syncVersion INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (userId) REFERENCES users(userId) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS emergency_contacts (
contactId TEXT PRIMARY KEY NOT NULL,
userId TEXT NOT NULL,
name TEXT NOT NULL,
phone TEXT NOT NULL,
email TEXT,
relationship TEXT NOT NULL,
isPrimary INTEGER NOT NULL DEFAULT 0,
priority INTEGER NOT NULL DEFAULT 1,
notifyOnMissedDose INTEGER NOT NULL DEFAULT 0,
notifyOnEmergency INTEGER NOT NULL DEFAULT 1,
notifyOnLowStock INTEGER NOT NULL DEFAULT 0,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL,
syncVersion INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (userId) REFERENCES users(userId) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS dependents (
dependentId TEXT PRIMARY KEY NOT NULL,
guardianId TEXT NOT NULL,
name TEXT NOT NULL,
nickname TEXT,
birthDate INTEGER NOT NULL,
photoUrl TEXT,
relationship TEXT NOT NULL,
medicalProfileJson TEXT,
hasOwnAccess INTEGER NOT NULL DEFAULT 0,
accessPinHash TEXT,
isActive INTEGER NOT NULL DEFAULT 1,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL,
syncVersion INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (guardianId) REFERENCES users(userId) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS caregiver_permissions (
permissionId TEXT PRIMARY KEY NOT NULL,
patientUserId TEXT NOT NULL,
caregiverUserId TEXT NOT NULL,
permissionType TEXT NOT NULL,
isGranted INTEGER NOT NULL DEFAULT 1,
grantedAt INTEGER NOT NULL,
grantedBy TEXT NOT NULL,
revokedAt INTEGER,
revokedBy TEXT,
syncVersion INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (patientUserId) REFERENCES users(userId) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS preferences (
preferenceId TEXT PRIMARY KEY NOT NULL DEFAULT 'preferences',
userId TEXT NOT NULL,
theme TEXT NOT NULL DEFAULT 'system',
fontSize TEXT NOT NULL DEFAULT 'medium',
highContrast INTEGER NOT NULL DEFAULT 0,
notificationsEnabled INTEGER NOT NULL DEFAULT 1,
reminderSound TEXT NOT NULL DEFAULT 'default',
reminderVibrate INTEGER NOT NULL DEFAULT 1,
quietHoursEnabled INTEGER NOT NULL DEFAULT 0,
quietHoursStart TEXT NOT NULL DEFAULT '22:00',
quietHoursEnd TEXT NOT NULL DEFAULT '07:00',
language TEXT NOT NULL DEFAULT 'es',
biometricEnabled INTEGER NOT NULL DEFAULT 0,
pinHash TEXT,
autoLockMinutes INTEGER NOT NULL DEFAULT 5,
requireAuthOnOpen INTEGER NOT NULL DEFAULT 1,
syncOnCellular INTEGER NOT NULL DEFAULT 0,
lowDataMode INTEGER NOT NULL DEFAULT 0,
cacheImages INTEGER NOT NULL DEFAULT 1,
shareAnonymousData INTEGER NOT NULL DEFAULT 1,
showMedicationNames INTEGER NOT NULL DEFAULT 1,
updatedAt INTEGER NOT NULL,
FOREIGN KEY (userId) REFERENCES users(userId) ON DELETE CASCADE
);
-- Indexes
CREATE INDEX idx_users_role_tier ON users(role, tier);
CREATE INDEX idx_medical_profiles_user ON medical_profiles(userId);
CREATE INDEX idx_emergency_contacts_user ON emergency_contacts(userId);
CREATE INDEX idx_dependents_guardian ON dependents(guardianId);
CREATE INDEX idx_caregiver_perms_patient ON caregiver_permissions(patientUserId);
CREATE INDEX idx_caregiver_perms_caregiver ON caregiver_permissions(caregiverUserId);
7. Procedimiento de Migracion: PD a PI (DV2-P3)¶
7.1. Contexto: DB-BAJO-003¶
Problema: Cuando un Paciente Dependiente (PD) cumple 18 anos, DEBE migrar a cuenta independiente (Paciente Independiente - PI). El proceso debe ser:
- Seguro (preservar todos los datos medicos)
- Completo (migrar medicamentos, logs, recetas, etc.)
- Reversible (en caso de error)
- Auditable (registro completo de migracion)
7.2. Regla de Negocio: RN-USR-004¶
| Campo | Valor |
|---|---|
| ID | RN-USR-004 |
| Descripcion | PD >= 18 anos debe migrar a cuenta PI |
| Trigger | Cumpleanos 18 (batch job diario) o accion manual CR |
| Estado previo | PD tiene perfil dentro de cuenta CR |
| Estado post | PD se convierte en PI con cuenta propia |
7.3. Proceso de Migracion¶
7.3.1. Pre-requisitos¶
-- Verificar que PD es elegible para migracion
SELECT
d.dependent_id,
d.name,
d.birth_date,
EXTRACT(YEAR FROM AGE(d.birth_date)) as age,
d.guardian_id
FROM cli_dependents d
WHERE EXTRACT(YEAR FROM AGE(d.birth_date)) >= 18
AND d.is_active = TRUE
AND d.has_own_access = FALSE; -- Aun no tiene cuenta propia
7.3.2. Pasos de Migracion (Cliente)¶
IMPORTANTE: La migracion se ejecuta EN EL CLIENTE, no en servidor. El servidor solo ve blobs cifrados.
// iOS - Swift
class DependentToIndependentMigration {
func migrate(dependent: Dependent, guardian: User) async throws -> User {
let realm = try await Realm()
try await realm.write {
// 1. Crear nueva cuenta PI
let newUser = User()
newUser.userId = UUID().uuidString
newUser.email = nil // Usuario debera configurar
newUser.phone = nil // Usuario debera configurar
newUser.displayName = dependent.name
newUser.role = "PI" // Paciente Independiente
newUser.tier = guardian.tier // Hereda tier de CR (por 30 dias)
newUser.locale = guardian.locale
newUser.timezone = guardian.timezone
// 2. Migrar perfil medico
if let depProfile = dependent.medicalProfile {
let newProfile = MedicalProfile()
newProfile.profileId = UUID().uuidString
newProfile.birthDate = depProfile.birthDate
newProfile.biologicalSex = depProfile.biologicalSex
newProfile.heightCm = depProfile.heightCm
newProfile.weightKg = depProfile.weightKg
newProfile.bloodType = depProfile.bloodType
newProfile.allergies = depProfile.allergies
newProfile.conditions = depProfile.conditions
newProfile.notes = depProfile.notes
newUser.medicalProfile = newProfile
}
// 3. Migrar medicamentos
let medications = realm.objects(Medication.self)
.filter("userId == %@", guardian.userId)
.filter("profileId == %@", dependent.dependentId)
for oldMed in medications {
let newMed = Medication()
newMed.medicationId = UUID().uuidString
newMed.userId = newUser.userId
newMed.profileId = nil // Ya no es dependiente
newMed.customName = oldMed.customName
newMed.dosage = oldMed.dosage
newMed.unit = oldMed.unit
newMed.form = oldMed.form
newMed.route = oldMed.route
newMed.instructions = oldMed.instructions
newMed.isActive = oldMed.isActive
newMed.startDate = oldMed.startDate
newMed.endDate = oldMed.endDate
// Migrar schedules
for oldSchedule in oldMed.schedules {
let newSchedule = Schedule()
newSchedule.scheduleId = UUID().uuidString
newSchedule.frequencyType = oldSchedule.frequencyType
newSchedule.timesPerDay = oldSchedule.timesPerDay
newSchedule.specificTimes = oldSchedule.specificTimes
newSchedule.daysOfWeek = oldSchedule.daysOfWeek
newSchedule.intervalHours = oldSchedule.intervalHours
newSchedule.startDate = oldSchedule.startDate
newSchedule.endDate = oldSchedule.endDate
newSchedule.isActive = oldSchedule.isActive
newMed.schedules.append(newSchedule)
// Migrar dose logs (ultimos 90 dias)
let ninetyDaysAgo = Date().addingTimeInterval(-90 * 24 * 3600)
let recentLogs = oldSchedule.doseLogs.filter { $0.scheduledTime >= ninetyDaysAgo }
for oldLog in recentLogs {
let newLog = DoseLog()
newLog.logId = UUID().uuidString
newLog.scheduledTime = oldLog.scheduledTime
newLog.actualTime = oldLog.actualTime
newLog.status = oldLog.status
newLog.skippedReason = oldLog.skippedReason
newLog.notes = oldLog.notes
newSchedule.doseLogs.append(newLog)
}
}
realm.add(newMed)
}
// 4. Migrar recetas (ultimas 2 anos)
let twoYearsAgo = Date().addingTimeInterval(-2 * 365 * 24 * 3600)
let prescriptions = realm.objects(Prescription.self)
.filter("userId == %@", guardian.userId)
.filter("profileId == %@", dependent.dependentId)
.filter("prescriptionDate >= %@", twoYearsAgo)
for oldRx in prescriptions {
let newRx = Prescription()
newRx.id = UUID().uuidString
newRx.userId = newUser.userId
newRx.profileId = nil
newRx.prescriberId = oldRx.prescriberId
newRx.prescriberName = oldRx.prescriberName
newRx.institutionId = oldRx.institutionId
newRx.institutionName = oldRx.institutionName
newRx.prescriptionDate = oldRx.prescriptionDate
newRx.diagnosis = oldRx.diagnosis
newRx.notes = oldRx.notes
// Migrar imagenes y OCR results
for oldImg in oldRx.images {
let newImg = PrescriptionImage()
newImg.id = UUID().uuidString
newImg.userId = newUser.userId
newImg.originalFileName = oldImg.originalFileName
newImg.originalFileUrl = oldImg.originalFileUrl
newImg.originalFileHash = oldImg.originalFileHash
newRx.images.append(newImg)
}
realm.add(newRx)
}
// 5. Crear auditoria de migracion
let audit = MigrationAudit()
audit.auditId = UUID().uuidString
audit.migrationType = "PD_TO_PI"
audit.fromUserId = guardian.userId
audit.fromDependentId = dependent.dependentId
audit.toUserId = newUser.userId
audit.migratedAt = Date()
audit.medicationsCount = medications.count
audit.prescriptionsCount = prescriptions.count
realm.add(audit)
// 6. Marcar dependiente como migrado
dependent.isActive = false
dependent.migratedToUserId = newUser.userId
dependent.migratedAt = Date()
realm.add(newUser)
}
return newUser
}
}
7.3.3. Entidades Migradas¶
| Entidad | Se Migra | Periodo | Notas |
|---|---|---|---|
| cli_user | ✅ Nuevo | N/A | Se crea cuenta PI nueva |
| cli_medical_profile | ✅ Completo | Todos | Copia completa |
| cli_medications | ✅ Completo | Todos | Solo activos + ultimos 2 anos |
| cli_schedules | ✅ Completo | Todos | Schedules activos |
| cli_dose_logs | ✅ Parcial | Ultimos 90 dias | Historial reciente |
| cli_prescriptions | ✅ Parcial | Ultimos 2 anos | Recetas recientes |
| cli_prescription_images | ✅ Completo | Segun recetas | Solo de recetas migradas |
| cli_emergency_contacts | ❌ No | N/A | Usuario debera configurar nuevos |
| cli_preferences | ❌ No | N/A | Usuario configura desde cero |
7.3.4. Esquema de Auditoria¶
// Nuevo modelo para auditar migraciones
class MigrationAudit: Object {
@Persisted(primaryKey: true) var auditId: String = UUID().uuidString
@Persisted var migrationType: String // PD_TO_PI, PI_TO_PD (reversa)
// From
@Persisted var fromUserId: String
@Persisted var fromDependentId: String?
// To
@Persisted var toUserId: String
@Persisted var toDependentId: String?
// Estadisticas
@Persisted var medicationsCount: Int = 0
@Persisted var schedulesCount: Int = 0
@Persisted var doseLogsCount: Int = 0
@Persisted var prescriptionsCount: Int = 0
// Metadata
@Persisted var migratedAt: Date = Date()
@Persisted var migratedBy: String // user_id de quien ejecuto
@Persisted var wasAutomatic: Bool = false // true si fue batch job
// Reversible
@Persisted var isReversed: Bool = false
@Persisted var reversedAt: Date?
@Persisted var reversedBy: String?
}
7.4. Flujo de Usuario (UX)¶
7.4.1. Notificacion al CR (Cuidador Responsable)¶
TRIGGER: 30 dias antes del cumpleanos 18 del PD
MENSAJE:
"Tu paciente dependiente [Nombre] cumplira 18 anos el [Fecha].
Deberas ayudarle a crear su cuenta independiente.
Opciones:
1. Migrar ahora (adelantado)
2. Migrar en la fecha
3. Recordarme en 7 dias"
7.4.2. Wizard de Migracion¶
PASO 1: Confirmacion
- "¿Seguro que deseas convertir a [Nombre] en paciente independiente?"
- "[Nombre] podra gestionar sus propios medicamentos y datos medicos"
PASO 2: Configuracion de Cuenta
- Email del nuevo usuario PI
- Telefono (opcional)
- Contraseña temporal
PASO 3: Revision de Datos
- Mostrar resumen de datos a migrar:
* N medicamentos activos
* N recetas (ultimos 2 anos)
* N registros de tomas (ultimos 90 dias)
PASO 4: Ejecucion
- Barra de progreso
- Migrando medicamentos...
- Migrando recetas...
- Creando cuenta...
PASO 5: Completado
- "¡Migracion exitosa!"
- "Hemos enviado instrucciones a [email] para que [Nombre] configure su cuenta"
- Codigo de activacion temporal: XXXX-XXXX
7.5. Rollback y Reversa¶
7.5.1. Rollback (en caso de error)¶
Si la migracion falla a mitad de proceso:
func rollbackMigration(audit: MigrationAudit) async throws {
let realm = try await Realm()
try await realm.write {
// 1. Eliminar cuenta PI creada
if let newUser = realm.object(ofType: User.self, forPrimaryKey: audit.toUserId) {
realm.delete(newUser)
}
// 2. Reactivar dependiente
if let dependent = realm.object(ofType: Dependent.self, forPrimaryKey: audit.fromDependentId) {
dependent.isActive = true
dependent.migratedToUserId = nil
dependent.migratedAt = nil
}
// 3. Marcar audit como fallido
audit.isReversed = true
audit.reversedAt = Date()
audit.reversedBy = "system_rollback"
}
}
7.5.2. Reversa (PI vuelve a PD)¶
Si el usuario PI desea volver a ser PD (raro, pero posible):
- Solo permitido en primeros 30 dias post-migracion
- Requiere autorizacion del CR original
- Proceso inverso al de migracion
7.6. Consideraciones de Sincronizacion¶
IMPORTANTE: La migracion genera dos eventos de sync independientes:
- Desactivacion de PD en cuenta CR → Sync E2E de cli_dependents
- Creacion de cuenta PI → Sync E2E de nuevo user + entidades
Estos eventos NO estan vinculados en el servidor (zero-knowledge). El servidor solo ve:
- Blob cifrado de dependent con
is_active=false - Blob cifrado de nuevo user PI (sin relacion visible con PD)
8. Referencias Cruzadas¶
| Documento | Relacion |
|---|---|
| DB-ERD-001 | Diagrama ER completo |
| MDL-AUTH-001 | Modelo de autenticacion |
| MTS-USR-001 | Especificacion funcional |
| MTS-AUTH-001 | Roles y tiers |
| MTS-PRI-001 | Consentimientos |
| 04-seguridad-cliente.md | Cifrado E2E |
Documento generado por DatabaseDrone - SpecQueen Technical Division "Cada perfil es una historia de salud que merece proteccion absoluta."