16 KiB
Plan: Path-based Image Versioning para Captive Portal
Problema Identificado
Las imágenes eliminadas en modo preview/draft se borran permanentemente de S3/MinIO, rompiendo la configuración productiva que aún las referencia.
Causa Raíz
- Configuración: Tiene separación entre draft (
Configuration) y production (ProdConfiguration) - Imágenes: NO tienen separación - todas comparten el mismo path en S3
- Resultado: Eliminar una imagen durante testing la borra para TODOS, incluyendo producción
Flujo Actual Problemático
- Admin prueba nueva imagen en preview mode
- Admin elimina imagen antigua durante testing
- Backend ejecuta:
MinIO.DeleteObject("captive-portal/{id}/Logo/old-image.png") - ⚠️ Imagen se borra permanentemente de S3
- Si
ProdConfigurationreferenciaba esa imagen → producción se rompe
Solución Propuesta: Path-based Versioning con Copy-on-Publish
Estrategia
Separar imágenes draft y production en paths distintos de S3/MinIO:
- Draft:
captive-portal/{id}/draft/{imageType}/{filename} - Production:
captive-portal/{id}/production/{imageType}/{filename}
Arquitectura de la Solución
┌─────────────────────────────────────────────────────────────┐
│ S3/MinIO Storage │
├─────────────────────────────────────────────────────────────┤
│ │
│ captive-portal/123/ │
│ ├── draft/ │
│ │ ├── Logo/ │
│ │ │ ├── logo-v1.png ← Testing zone │
│ │ │ └── logo-v2.png ← Safe to delete │
│ │ └── Background/ │
│ │ └── bg-new.jpg │
│ │ │
│ └── production/ │
│ ├── Logo/ │
│ │ └── logo-v1.png ← Protected │
│ └── Background/ ← Never deleted during │
│ └── bg-current.jpg draft operations │
│ │
└─────────────────────────────────────────────────────────────┘
Database: SplashCaptivePortal (id=123)
├── Configuration: "...draft/Logo/logo-v2.png..." ← References draft
└── ProdConfiguration: "...production/Logo/logo-v1.png..." ← References production
Cambios Requeridos
1. Backend (C#) - CaptivePortalAppService.cs
Archivo Principal
Path: src/SplashPage.Application/Perzonalization/CaptivePortalAppService.cs
Modificaciones Necesarias
A. UploadImageAsync (Línea ~766)
Cambio: Subir a path /draft/ en lugar de raíz
// ANTES:
string path = $"captive-portal/{id}/{imageType}/{uniqueFileName}";
// DESPUÉS:
string path = $"captive-portal/{id}/draft/{imageType}/{uniqueFileName}";
B. GetImagesAsync (Línea ~909)
Cambio: Listar desde path /draft/ para configuración en edición
// ANTES:
string prefix = $"captive-portal/{id}/{imageType}/";
// DESPUÉS:
string prefix = $"captive-portal/{id}/draft/{imageType}/";
C. DeleteImageAsync (Línea ~852)
Cambio: Solo borrar de /draft/, NUNCA de /production/
// ANTES:
await _minioStorageService.DeleteObjectAsync(bucket, pathToDelete);
// DESPUÉS:
// Asegurar que solo se borran imágenes draft
if (!pathToDelete.Contains("/draft/"))
{
throw new UserFriendlyException("Solo se pueden eliminar imágenes en modo draft");
}
await _minioStorageService.DeleteObjectAsync(bucket, pathToDelete);
D. PublishConfigurationAsync (Línea ~749) - CAMBIO CRÍTICO
Cambio: Implementar copy-on-publish de imágenes
public async Task PublishConfigurationAsync(int Id)
{
var portalEntity = await _captivePortalRepository.GetAsync(Id);
// 1. Parsear configuración draft
var draftConfig = JsonConvert.DeserializeObject<CaptivePortalCfgDto>(
portalEntity.Configuration
);
// 2. Extraer todas las rutas de imágenes del draft
var imagePathsToCopy = new List<string>();
if (!string.IsNullOrEmpty(draftConfig.SelectedLogoImagePath))
imagePathsToCopy.Add(draftConfig.SelectedLogoImagePath);
if (!string.IsNullOrEmpty(draftConfig.SelectedBackgroundImagePath))
imagePathsToCopy.Add(draftConfig.SelectedBackgroundImagePath);
// TODO: Agregar más campos de imágenes si existen
// 3. Copiar imágenes de draft a production
string bucket = GetBucketName(); // Método existente
var productionConfig = draftConfig; // Clonar
foreach (var draftPath in imagePathsToCopy)
{
if (string.IsNullOrEmpty(draftPath)) continue;
// Convertir path: draft/Logo/image.png → production/Logo/image.png
string productionPath = draftPath.Replace("/draft/", "/production/");
// Copiar en MinIO
await _minioStorageService.CopyObjectAsync(
sourceBucket: bucket,
sourceObject: draftPath,
destBucket: bucket,
destObject: productionPath
);
// Actualizar referencias en config de producción
productionConfig = UpdateImagePathInConfig(productionConfig, draftPath, productionPath);
}
// 4. Serializar y guardar
portalEntity.ProdConfiguration = JsonConvert.SerializeObject(
productionConfig,
Formatting.Indented
);
await _captivePortalRepository.UpdateAsync(portalEntity);
Logger.Info($"Configuración publicada para portal {Id} con {imagePathsToCopy.Count} imágenes copiadas");
}
// Método helper para actualizar paths en el objeto de configuración
private CaptivePortalCfgDto UpdateImagePathInConfig(
CaptivePortalCfgDto config,
string oldPath,
string newPath)
{
if (config.SelectedLogoImagePath == oldPath)
config.SelectedLogoImagePath = newPath;
if (config.SelectedBackgroundImagePath == oldPath)
config.SelectedBackgroundImagePath = newPath;
return config;
}
E. Verificar MinioStorageService tiene método CopyObjectAsync
Path: src/SplashPage.Application/Storage/MinioStorageService.cs
Si no existe, implementar:
public async Task CopyObjectAsync(
string sourceBucket,
string sourceObject,
string destBucket,
string destObject)
{
var args = new CopyObjectArgs()
.WithBucket(destBucket)
.WithObject(destObject)
.WithCopyObjectSource(new CopySourceObjectArgs()
.WithBucket(sourceBucket)
.WithObject(sourceObject));
await _minioClient.CopyObjectAsync(args);
}
2. Frontend (Next.js) - Verificación
Archivos a Revisar
-
src/SplashPage.Web.Ui/src/app/dashboard/settings/captive-portal/[id]/page.tsx- ✅ Ya usa
GetPortalConfigurationpara draft (correcto) - ✅ Preview mode ya apunta a draft config
- ✅ Ya usa
-
src/SplashPage.Web.Ui/src/app/CaptivePortal/Portal/[id]/page.tsx- ✅ Ya usa
GetPortalProdConfigurationpara producción (correcto) - ✅ No requiere cambios
- ✅ Ya usa
-
src/SplashPage.Web.Ui/src/hooks/captive-portal/use-portal-images.ts- ✅ Ya funciona correctamente con la API
- ✅ No requiere cambios (paths manejados por backend)
Conclusión Frontend: No se requieren cambios, solo verificar que todo siga funcionando después de los cambios del backend.
Flujo Completo Después de la Implementación
Escenario: Admin Configura Nuevo Logo
1. Admin sube logo-new.png
→ Backend: Guarda en captive-portal/123/draft/Logo/logo-new-uuid.png
→ Draft config actualizado: "draft/Logo/logo-new-uuid.png"
→ Production config sin cambios: "production/Logo/logo-old-uuid.png"
2. Admin prueba en Preview Mode
→ Preview carga desde GetPortalConfiguration (draft)
→ Muestra logo-new.png
→ Portal público sigue mostrando logo-old.png ✅
3. Admin decide eliminar logo anterior del draft
→ DELETE /api/.../DeleteImage
→ Backend borra: captive-portal/123/draft/Logo/logo-old-uuid.png
→ Production NO afectada (tiene su propia copia) ✅
4. Admin hace "Guardar"
→ SaveConfiguration actualiza campo Configuration
→ Production sigue sin cambios ✅
5. Admin hace "Publicar"
→ PublishConfigurationAsync:
a. Lee Configuration (draft)
b. Encuentra: "draft/Logo/logo-new-uuid.png"
c. Copia MinIO: draft/Logo/logo-new-uuid.png → production/Logo/logo-new-uuid.png
d. Actualiza ProdConfiguration: "production/Logo/logo-new-uuid.png"
e. Guarda en BD
→ Ahora portal público muestra logo nuevo ✅
→ Logo viejo en production puede limpiarse después (garbage collection)
Beneficios de Esta Solución
Técnicos
- ✅ Zero Downtime: No afecta producción actual durante implementación
- ✅ Aislamiento Completo: Draft y production totalmente independientes
- ✅ Rollback Simple: Feature "Restore from Production" sigue funcionando
- ✅ No Requiere Migración DB: Solo cambios en lógica de aplicación
- ✅ Backward Compatible: No rompe configuraciones existentes
Operacionales
- ✅ Testing Seguro: Admins pueden probar sin riesgo
- ✅ Preview Confiable: Preview siempre refleja draft real
- ✅ Publicación Atómica: Publish copia todo junto
- ✅ Auditoría Simple: Path indica estado (draft vs production)
De Negocio
- ✅ Previene Pérdida de Datos: Producción protegida
- ✅ Reduce Errores: No más portales rotos por testing
- ✅ Workflow Claro: Draft → Test → Publish
- ✅ Sin Costos Adicionales: Storage es barato
Mejoras Futuras (Post-MVP)
1. Garbage Collection (Opcional)
Implementar job nocturno que limpie:
- Imágenes draft no referenciadas por más de 30 días
- Imágenes production huérfanas después de publish
// HangFire recurring job
public async Task CleanupOrphanedImagesAsync()
{
// Lógica de limpieza
}
2. Validación Pre-Delete (Opcional)
Agregar warning en UI si se intenta eliminar imagen que existe en production:
public async Task<ImageDeleteWarning> CheckImageBeforeDeleteAsync(string imagePath)
{
// Verificar si existe versión production
string productionPath = imagePath.Replace("/draft/", "/production/");
bool existsInProduction = await _minioStorageService.ObjectExistsAsync(bucket, productionPath);
return new ImageDeleteWarning
{
CanDelete = true,
Warning = existsInProduction
? "Esta imagen tiene una versión publicada en producción"
: null
};
}
3. Historial de Versiones (Futuro)
Si necesitan auditoría completa en el futuro:
- Agregar tabla
CaptivePortalImageHistory - Trackear:
PortalId,ImagePath,Action,User,Timestamp
Archivos Impactados
Backend
- ✏️
src/SplashPage.Application/Perzonalization/CaptivePortalAppService.cs(principal) - ✏️
src/SplashPage.Application/Storage/MinioStorageService.cs(si falta CopyObject) - 📖
src/SplashPage.Application/Perzonalization/ICaptivePortalAppService.cs(revisar interface)
Frontend
- 👀
src/SplashPage.Web.Ui/src/app/dashboard/settings/captive-portal/[id]/page.tsx(verificar) - 👀
src/SplashPage.Web.Ui/src/app/CaptivePortal/Portal/[id]/page.tsx(verificar) - 👀
src/SplashPage.Web.Ui/src/hooks/captive-portal/use-portal-images.ts(verificar)
Leyenda: ✏️ Modificar | 👀 Verificar (no modificar)
Testing Plan
1. Testing Manual (Crítico)
- Subir imagen nueva en draft
- Verificar preview muestra imagen nueva
- Verificar producción sigue mostrando imagen vieja
- Eliminar imagen en draft
- Verificar producción NO se rompe
- Publicar configuración
- Verificar imagen copiada a production
- Verificar producción muestra imagen nueva
- Probar "Restore from Production"
- Probar con múltiples imágenes (logo + background)
2. Testing de Edge Cases
- Publicar sin cambios de imágenes
- Publicar con imagen ya existente en production (sobrescribir)
- Eliminar imagen y subir otra con mismo nombre
- Verificar errores si MinIO falla (copy error)
3. Testing de Regresión
- Portal existente sigue funcionando
- Preview mode sigue funcionando
- Upload/delete/select desde UI funcionan
- API endpoints existentes no rotos
Estimación de Tiempo
| Tarea | Tiempo Estimado |
|---|---|
Modificar UploadImageAsync |
15 min |
Modificar GetImagesAsync |
15 min |
Modificar DeleteImageAsync |
20 min |
Implementar CopyObjectAsync en MinioStorageService |
30 min |
Refactorizar PublishConfigurationAsync |
90 min |
| Testing manual completo | 60 min |
| Testing edge cases | 30 min |
| Testing de regresión | 30 min |
| TOTAL | ~4.5 horas |
Riesgos y Mitigaciones
| Riesgo | Probabilidad | Impacto | Mitigación |
|---|---|---|---|
| MinIO CopyObject falla | Baja | Alto | Try-catch con rollback, logging detallado |
| Portales existentes rompen | Media | Alto | Deploy en staging primero, backup DB |
| Performance de copy en publish | Baja | Bajo | Es async, no bloquea. Futuro: job background |
| Espacio duplicado en S3 | Baja | Bajo | Storage barato, futuro: garbage collection |
Rollback Plan
Si algo falla después del deploy:
- Inmediato: Revertir código a commit anterior
- Database: No hay cambios en schema, solo datos en JSON (revertir si necesario)
- S3/MinIO: Archivos draft/production coexisten, no hay pérdida de datos
- Monitoreo: Revisar logs de
PublishConfigurationAsyncpara errores
Notas Adicionales
Por Qué Esta Solución (vs Alternativas)
❌ Reference Counting
- Más complejo: requiere analizar ambas configs en cada delete
- Más lento: queries adicionales en cada operación
- Más frágil: bugs si el conteo se desincroniza
❌ Soft Delete / Immutable Storage
- Overhead de storage innecesario para bajo volumen
- Complejidad en queries (filtrar deleted)
- Necesita garbage collection desde día 1
✅ Path-based Versioning (Seleccionada)
- Simple: Solo cambios en paths
- Rápida: Implementación en 1-2 días
- Robusta: Aislamiento físico garantizado
- Escalable: Funciona igual con 10 o 10,000 imágenes
- Clara: Path indica estado (draft vs production)
Consideraciones de Migración
¿Qué pasa con portales existentes que ya tienen imágenes?
Respuesta: No requieren migración forzosa:
- Imágenes existentes quedan en paths antiguos (
captive-portal/{id}/{type}/) - Backend las trata como production implícitamente
- Próximo upload va a
/draft/ - Próximo publish copia a
/production/ - Después de primer ciclo draft→publish, portal está en nuevo esquema
Migración opcional (recomendado pero no crítico):
-- Script one-time para mover imágenes existentes a /production/
-- Ejecutar en MinIO o via script C#
Aprobación y Próximos Pasos
Para Aprobar Este Plan
- Revisión técnica del equipo
- Aprobación de arquitectura
- Confirmación de ventana de deploy
Para Implementar
- Crear branch:
feature/captive-portal-draft-images - Implementar cambios backend según este documento
- Testing en ambiente dev/staging
- Code review
- Deploy a producción
- Monitoreo post-deploy (24h)
- Documentar en changelog.MD
Documento Creado: 2025-10-30 Autor: Claude Code (basado en análisis del codebase) Estado: Pendiente de Aprobación Prioridad: Alta (previene pérdida de datos en producción)