Files
Temp_MSSPLASHPage/cp_draft_image.md

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

  1. Admin prueba nueva imagen en preview mode
  2. Admin elimina imagen antigua durante testing
  3. Backend ejecuta: MinIO.DeleteObject("captive-portal/{id}/Logo/old-image.png")
  4. ⚠️ Imagen se borra permanentemente de S3
  5. Si ProdConfiguration referenciaba 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

  1. src/SplashPage.Web.Ui/src/app/dashboard/settings/captive-portal/[id]/page.tsx

    • Ya usa GetPortalConfiguration para draft (correcto)
    • Preview mode ya apunta a draft config
  2. src/SplashPage.Web.Ui/src/app/CaptivePortal/Portal/[id]/page.tsx

    • Ya usa GetPortalProdConfiguration para producción (correcto)
    • No requiere cambios
  3. 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

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

  1. ✏️ src/SplashPage.Application/Perzonalization/CaptivePortalAppService.cs (principal)
  2. ✏️ src/SplashPage.Application/Storage/MinioStorageService.cs (si falta CopyObject)
  3. 📖 src/SplashPage.Application/Perzonalization/ICaptivePortalAppService.cs (revisar interface)

Frontend

  1. 👀 src/SplashPage.Web.Ui/src/app/dashboard/settings/captive-portal/[id]/page.tsx (verificar)
  2. 👀 src/SplashPage.Web.Ui/src/app/CaptivePortal/Portal/[id]/page.tsx (verificar)
  3. 👀 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:

  1. Inmediato: Revertir código a commit anterior
  2. Database: No hay cambios en schema, solo datos en JSON (revertir si necesario)
  3. S3/MinIO: Archivos draft/production coexisten, no hay pérdida de datos
  4. Monitoreo: Revisar logs de PublishConfigurationAsync para 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:

  1. Imágenes existentes quedan en paths antiguos (captive-portal/{id}/{type}/)
  2. Backend las trata como production implícitamente
  3. Próximo upload va a /draft/
  4. Próximo publish copia a /production/
  5. 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

  1. Crear branch: feature/captive-portal-draft-images
  2. Implementar cambios backend según este documento
  3. Testing en ambiente dev/staging
  4. Code review
  5. Deploy a producción
  6. Monitoreo post-deploy (24h)
  7. 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)