Migración estructural: unificación de Assets, Collections y Campaigns

Proyecto: vton-client Fecha: 11 de julio de 2026 Tipo de documento: Plan de migración estructural


Antes que nada, quiero aclarar que esta es una propuesta de mejora con el fin de que la aplicacion se adapte a las necesidades futuras de negocio. Se comparte para su analisis y discusion. Tal vez sirva como punto de partida para debatir los próximos pasos.


Tabla de contenidos

  1. Resumen
  2. Estado actual del modelo
  3. El modelo al que queremos llegar
  4. Visión general de la migración
  5. Fase 1 — Asset manual en Collection (assetVton)
  6. Fase 2 — Tabla Project como puente
  7. Fase 3 — SourceItem y SourceVariant en paralelo
  8. Fase 4 — Split Vton + unificación Asset/AssetVersion/AssetFeedback
  9. Fase 5 — GenerationJob y combinaciones de addons
  10. Fase 6 — Switch de lecturas y backfill total
  11. Fase 7 — Deprecación de tablas viejas
  12. Matriz de riesgos por fase
  13. Cronograma y criterios de decisión
  14. Cierre y próximos pasos

1. Resumen

La plataforma vton-client nació con un objetivo claro: generar imágenes de modelos vistiendo prendas (lo que conocemos como VTON). Con el tiempo, el producto fue creciendo y arrastró dos extensiones naturales: la posibilidad de trabajar con productos genéricos (mochilas, pelotas, zapatos) y la necesidad de gestionar campañas de imagen/video que no requieren generación automática (por ahora!!). Cada una de esas extensiones se modeló como un mundo aparte, y hoy conviven tres estructuras paralelas que se solapan sin una regla clara de cuándo usar cuál.

Esa fragmentación tiene consecuencias concretas: código duplicado, queries bifurcadas, decisiones de modelado ambiguas (un zapato puede ser prenda o producto según quién lo mire) y la imposibilidad de adjuntar un asset simple a una colección sin inventar un Garment o un Product ficticio.

Este documento describe cómo llegar, en siete fases reversibles, a un modelo unificado basado en cuatro conceptos: Project (contenedor que reemplaza a Collection y Campaign), SourceItem (imagen input cargada por el usuario, que unifica Garment y Product —es la fuente de generación, no el resultado), Asset (imagen output —ya generada por scheduler, ya cargada manualmente por un diseñador— que unifica Vton tras split por groupId y CampaignAsset) y AssetVersion (cada iteración del output, reemplazando a Vton-versiones y CampaignAssetVersion, con source=MANUAL|GENERATED). Las imágenes base del input (ProductImage, front/back) pasan a SourceVariant (variantes del SourceItem, no del Asset). Sobre esa base, la producción opcional vive en GenerationJob y las combinaciones de addons (modelo, fit, material, accesorio, fondo) en AssetAddonCombination.

La migración completa está diseñada para que cada fase sea desplegable y reversible de forma independiente. La única excepción es la Fase 7, que es el punto de no retorno: ahí se eliminan las tablas viejas. Hasta ese momento, cualquier paso puede deshacerse con un feature flag.

La Fase 1 es independiente del resto y puede arrancar de inmediato, en paralelo al diseño detallado de las fases siguientes.


2. Estado actual del modelo

2.1 Los tres mundos que conviven hoy

El modelo actual está partido en tres bloques que comparten estructura conceptual pero no tablas. Esto es lo que genera la mayor parte de la fricción:

El problema es que un mismo objeto de negocio puede caer en cualquiera de los dos mundos según cómo se lo modele. Un zapato, por ejemplo, puede ser Garment con garment_type=CALZADO o Product con product_type=FOOTWEAR. No hay regla que lo decida, y esa ambigüedad se replica en servicios, queries y UI.

2.2 Diagrama ER del estado actual

[Diagram]

Lo primero que salta a la vista es que la mitad izquierda (Collection → Garment/Product → Vton) y la mitad derecha (Campaign → CampaignAsset → CampaignAssetVersion) son casi espejos estructurales, pero con tablas distintas. Vton y CampaignAssetVersion cumplen el mismo rol (una iteración con URL resultante), y Feedback y CampaignAssetFeedback son idénticos en campos pero apuntan a destinos diferentes.

2.3 Ambigüedades y deuda técnica detectada

El relevamiento del modelo actual dejó diez problemas concretos. Algunos son bloqueantes para nuevas funcionalidades, otros son fuentes constantes de bugs:

# Problema Impacto Severidad
A1 Collection.type no alcanza para distinguir Garment de Product Lógica condicional en servicios y UI Alta
A2 ProductType.FOOTWEAR y GarmentType.CALZADO se solapan Doble modelado de zapatos Alta
A3 Garment y Product comparten ~80% de campos en tablas separadas Código duplicado, queries bifurcadas Alta
A4 Vton con FK polimórfico manual (garment_model_id XOR product_image_id) DTOs unión, validación en servicio Media
A5 Feedback y CampaignAssetFeedback duplicados Dos servicios, dos DTOs iguales Media
A6 CampaignAssetVersion y Vton conceptualmente solapados Dos modelos de "versión de asset" Media
A7 No existe asset directo en Collection Hay que crear Garment/Product ficticio para assets simples Alta
A8 Campaign no tiene type ni SKUs No sirve para modelar trabajo productivo Media
A9 RejectReason acoplado a CollectionType No aplica a campañas Baja
A10 ZipJob con collection_id XOR campaign_id Otro polimorfismo manual Baja

El más urgente es A7: hoy no existe forma de adjuntar un asset (una imagen, un video) directamente a una Collection con un SKU, sin pasar por la maquinaria de Garment o Product. Eso obliga a crear registros ficticios cuando el caso de uso es simplemente "guardar esta foto bajo este SKU". La Fase 1 ataca exactamente ese problema mediante el concepto de assetVton (imagen subida manualmente + SKU + reprocesable + calificable, desacoplado de Garment/Product/Vton). Está parcialmente implementada (schema, DTOs, servicios, controllers, callback y frontend de rate.tsx listos; zip/download, tests y verificación pendientes — ver 5.14).

2.4 Cómo funciona la producción hoy

El flujo productivo actual obliga a elegir entre dos caminos antes de poder generar una imagen:

[Diagram]

La unidad productiva mínima es GarmentModel o ProductImage. No hay forma de disparar generación desde un asset "suelto": todo tiene que pasar por Garment o Product, aunque el caso de uso real no necesite modelar prendas ni productos.

El flujo de campaña, en cambio, es completamente paralelo y no produce nada:

[Diagram]

La conclusión del relevamiento es que estos tres caminos paralelos (Collection-productiva, Campaign-no-productiva, y la ausencia de asset manual) conviene unificarlos en un modelo único Project → SourceItem → Asset → AssetVersion, donde SourceItem es el input del usuario, Asset es el output (resultado), la producción VTON sea opcional y se dispare combinando el SourceItem con addons.


3. El modelo al que queremos llegar

3.1 Los siete principios del nuevo modelo

El nuevo modelo se construye sobre ideas simples (creo), pero que cambian la forma de pensar el sistema:

  1. Un solo contenedor. Project unifica Collection y Campaign. Un proyecto puede ser de foto modelo, foto producto, campaña o genérico.
  2. SourceItem: el input del usuario. SourceItem unifica Garment y Product. Es la imagen/imágenes input cargadas por el usuario (cliente), que sirven como fuente para la generación VTON. Tiene SourceVariant (imágenes base: frente, espalda, laterales). No es un "asset": en el lenguaje de negocio, un asset es la imagen ya generada (output). El input tiene su propio nombre para no confundir.
  3. Asset: el output unificado. Asset unifica Vton (output derivado de un SourceItem, generado por scheduler o cargado manualmente por diseñador) y CampaignAsset (output standalone cargado por diseñador, sin SourceItem asociado). Un Asset puede tener:
    • source_item_id (FK nullable a SourceItem): el input del que se derivó. Null para CampaignAsset (standalone).
    • addon_combination_id (FK nullable a AssetAddonCombination): la receta (modelo, fit, material, etc.).
  4. Versiones unificadas. AssetVersion unifica Vton (previo split por groupId: cada grupo → un Asset, cada fila → una AssetVersion) y CampaignAssetVersion. Cada versión es una URL resultante, con source=MANUAL|GENERATED. Si el Asset padre tiene source_item_id set (output derivado), la versión puede ser manual (diseñador sube el resultado) o generada (scheduler). Si el Asset padre es standalone, la versión siempre es manual.
  5. Feedback unificado. AssetFeedback unifica Feedback y CampaignAssetFeedback. Misma estructura, mismo servicio.
  6. Producción opcional. Un AssetVersion puede ser manual (diseñador) o generado (scheduler). La generación parte de un SourceItem (input) o de un AssetVersion padre (reproceso).
  7. Addons flexibles. Los configuradores (modelo, fit, material, accesorio, fondo, calzado) se modelan como Addon con category extensible. Agregar un nuevo tipo de configurador no requiere nuevas tablas.
  8. Soft-delete y multi-tenant en todas las entidades, lo que ya está, queda igual que hoy.

3.2 Los conceptos clave

Antes de ver el diagrama, conviene tener claro qué representa cada entidad nueva:

3.3 Diagrama ER del modelo objetivo

[Diagram]

Comparado con el diagrama del estado actual, la diferencia clave es la separación input vs output: SourceItem (input del usuario) alimenta la generación, y Asset (output) recoge los resultados. La rama izquierda (Collection → Garment/Product → Vton → Feedback) y la rama derecha (Campaign → CampaignAsset → CampaignAssetVersion → CampaignAssetFeedback) colapsan en una jerarquía dual: Project → SourceItem → Asset → AssetVersion → AssetFeedback. La producción deja de ser un caso especial y pasa a ser un atributo más del AssetVersion (source = MANUAL | GENERATED). Un Asset puede ser derivado de un SourceItem (source_item_id set) o standalone (source_item_id=null para CampaignAsset).

3.4 Cómo se mapea el modelo actual al objetivo

Esta tabla es la referencia rápida (comparativa) para entender de dónde sale cada tabla nueva:

Modelo actual Modelo target Notas
Collection Project (kind=PHOTO_MODEL/PHOTO_PRODUCT) También absorbe a Campaign
Campaign Project (kind=CAMPAIGN) Unificación
Garment SourceItem (kind=WEARABLE/FOOTWEAR) INPUT del usuario. fit_id migra a Addon
Product SourceItem (kind=NON_WEARABLE/ACCESSORY/FOOTWEAR) INPUT del usuario. Unificación con Garment
ProductImage SourceVariant Imagen base del input, no del output
Garment.front/back_image_url SourceVariant (view=front/back) Desnormalización del input
GarmentExtraImage SourceVariant
CampaignAsset Asset (is_reference_only=true, source_item_id=null) OUTPUT standalone del diseñador
Vton (grupo por groupId) Asset (source_item_id=set, output derivado) OUTPUT container. Split: un Asset por grupo
Vton (fila individual) AssetVersion (source=GENERATED o MANUAL) Cada iteración de output
CampaignAssetVersion AssetVersion (source=MANUAL)
GarmentModel AssetAddonCombination Receta: SourceItem + Addons → genera Asset
ModelVariant Addon (category=MODEL_VARIANT)
Model Addon (category=MODEL)
Fit Addon (category=FIT)
Material Addon (category=MATERIAL)
Addon actual Addon (category=ACCESSORY/FOOTWEAR/...) Ya es Addon
Background Addon (category=BACKGROUND) o se mantiene Decisión de fase avanzada
Feedback AssetFeedback Feedback del output
CampaignAssetFeedback AssetFeedback Unificación
GarmentTag / ProductTag SourceItemTag Tags del input (separado de AssetTag)
CollectionModel ProjectAddon (category=MODEL) N:N Project↔Addon
RejectReason RejectReason (con project_kind) Desacoplar de CollectionType

Distinción clave: SourceItem (input) vs Asset (output). Garment/Product son input del usuario (SourceItem): tienen SourceVariant (imágenes base) y participan en generación vía AssetAddonCombination. CampaignAsset es output standalone del diseñador (Asset, source_item_id=null): no tiene variantes ni generación; sus AssetVersion son siempre manuales. Vton (output derivado de un SourceItem) se separa por groupId: cada grupo → un Asset, cada fila → una AssetVersion. El caso assetVton (DV-880) es un Asset con source_item_id set y AssetVersion manual — un output del diseñador vinculado a un input real.

3.5 Cómo se resuelven las ambigüedades

Cada uno de los diez problemas detectados en el modelo actual tiene una solución directa en el nuevo:

Ambigüedad Solución
A1 (type no basta) SourceItem.kind único + Project.kind
A2 (FOOTWEAR solapado) SourceItem.kind=FOOTWEAR único
A3 (Garment≈Product) SourceItem única (input del usuario)
A4 (Vton polimórfico) Asset (output) con source_item_id FK a SourceItem. Vton se split por groupIdAsset + AssetVersion
A5 (Feedback duplicado) AssetFeedback único
A6 (Version duplicado) AssetVersion único con source
A7 (sin asset directo) Asset (output) con source_item_id FK a SourceItem (input). No requiere crear Garment/Product ficticio
A8 (Campaign sin type/sku) Project unificado con kind y Asset con SKU
A9 (RejectReason acoplado) RejectReason.project_kind
A10 (ZipJob polimórfico) ZipJob.project_id único

3.6 Por qué el nuevo modelo escala mejor, o al menos eso parece. ja

El modelo unificado tiene una propiedad interesante: la mayoría de las extensiones futuras no requieren nuevas tablas. Si mañana necesitamos soportar un nuevo tipo de producto (digamos, "avatar 3D" "gif" idea de Cris), alcanza con agregar un valor al enum SourceItem.kind. Si aparece un nuevo configurador (por ejemplo, "iluminación de escenario"), se agrega un valor a AddonCategory. Si queremos soportar video o 3D como formato de salida, AssetVersion ya tiene el campo media_type listo para eso.

Eso es lo que hace que el modelo sea escalable: la estructura no cambia, sólo se amplían los enums. Hoy, cada nuevo tipo de producto o configurador implica decidir si crea una tabla nueva o se "estira" una existente.


4. Visión general de la migración

4.1 Las siete fases en una mirada

La migración está diseñada para que cada fase sea desplegable y reversible de forma independiente. La única excepción es la Fase 7, que es el punto de no retorno. Hasta ese momento, cualquier paso puede deshacerse con un feature flag o alguna otra estrategia.

[Diagram]

4.2 Resumen de fases

Las duraciones las propone la IA, tomarlo con pinzas, no las pensé demasiado y tampoco es importante ahora, creo.

Fase Nombre Impacto en prod Duración Reversible
1 Asset manual en Collection (assetVton) Bajo 1–2 sprints
2 Tabla Project puente Bajo–Medio 1 sprint
3 SourceItem + SourceVariant paralelas Medio 2 sprints
4 Split Vton + unificación Asset/AssetVersion/AssetFeedback Medio 2 sprints
5 GenerationJob + AssetAddonCombination Medio–Alto 2–3 sprints
6 Backfill total + switch de lecturas Alto 1–2 sprints Sí (con flag)
7 Deprecación y drop de tablas viejas Alto (irreversible) 1 sprint No

4.3 Los cuatro principios que hacen la migración segura

  1. Cero impacto en producción por fase. Cada fase se despliega sin romper lo que ya funciona. Los endpoints y servicios existentes siguen operando igual hasta que se decide activar el flag correspondiente.
  2. Coexistencia de modelos viejo y nuevo. Durante toda la transición, las tablas viejas y las nuevas conviven (con la cantidad de tablas duplicadas que tenemos no pasa nada si metemos mas por un momento. je). La conexión entre ellas se hace con columnas "puente" (FKs opcionales) que permiten saber a qué registro viejo corresponde cada registro nuevo.
  3. Backfill en migraciones, no en runtime. Los datos se copian de tablas viejas a nuevas mediante scripts SQL idempotentes que corren fuera del tráfico de usuarios. Nunca se hace en tiempo de request.
  4. Feature flags para activar lecturas/escrituras. El switch entre "leer de la tabla vieja" y "leer de la tabla nueva" se controla con flags por tenant o por endpoint. Eso permite rollout gradual y rollback inmediato si algo falla.

4.4 Por qué no se hace todo de una vez

Hacer la migración en un solo paso sería más rápido sobre el papel, pero en la práctica es inviable (una bomba literalmente): cualquier bug en el backfill o en el nuevo modelo dejaría el sistema fuera de servicio, sin forma de revertir sin perder datos. La estrategia de fases paralelas permite operar el sistema en producción mientras se construye el nuevo modelo, validar que los datos están bien migrados, y sólo cuando todo está estable, hacer el switch final.


5. Fase 1 — Asset manual en Collection (assetVton)

Nota: Esta fase combina el contenedor polimórfico original (Fase 1 del plan) con un subconjunto de Fase 4 (campos de reproceso en CampaignAssetVersion, no tabla nueva). Es un puente escalable hacia el modelo unificado: extiende las tablas existentes en vez de crear Asset/AssetVersion prematuramente. Está parcialmente implementada (ver 5.14).

5.1 Qué resuelve

Hoy, si un usuario quiere adjuntar una imagen resultado a una Collection (PHOTO_PRODUCT) bajo un SKU específico, no hay forma directa de hacerlo. Tiene que crear un Garment o un Product ficticio —con todos sus campos obligatorios— sólo para tener un contenedor donde colgar el asset y disparar el flujo de Vton. Es un workaround que ensucia los datos y confunde a la UI.

La Fase 1 resuelve esto introduciendo el assetVton: una imagen resultado subida manualmente a una Collection, vinculada a un SKU (texto ingresado por el usuario, no requiere que exista un Product/Garment con ese SKU). Reutiliza las tablas CampaignAsset/CampaignAssetVersion/CampaignAssetFeedback que ya existen y ya hacen lo que necesitamos: almacenar assets con tipo (IMAGE/VIDEO), versionado y feedback. El assetVton debe cumplir cinco requisitos:

  1. Desacoplado de todo lo productivo: no se crea Garment, GarmentModel, Product ni ProductImage. El assetVton vive anclado a la Collection + SKU directamente.
  2. Calificable: se puede poner feedback (rating, comment, reasons, attachments) igual que en la pantalla rate.tsx.
  3. Reprocesable: el botón "Solicitar cambios" envía la imagen al scheduler para regenerar desde ella. No es un "re-subir imagen" manual: es regeneración AI real.
  4. Integrado en rate.tsx: convive en el mismo carrusel con los vtons normales, agrupado por SKU. El usuario califica/reprocesa assetVtons y vtons sin cambiar de pantalla.
  5. Escalable: la estructura debe servir para PHOTO_MODEL en el futuro sin rediseño.

Esto resuelve el ticket DV-880.

5.2 Por qué desacoplar de Product/Garment (y por qué no hace falta crearlos)

La pregunta natural es: dado el estado actual de la aplicación, ¿no hace falta crear un Product (o Garment) para que el asset tenga dónde colgarse y poder reprocesarlo? En la Fase 1, no. La asociación actual es por SKU texto (no por FK): el usuario ingresa un SKU, y el assetVton se agrupa en rate.tsx con los vtons que comparten ese mismo SKU. No se necesita que exista un Product/Garment real.

Sin embargo, el modelo unificado a futuro contempla que el assetVton manual esté vinculado vía FK a un Asset template (un Product/Garment real), no solo por SKU texto. En ese modelo, un AssetVersion con source=MANUAL tendría asset_id apuntando al Asset template (el producto/prenda). Esto permitiría validación referencial, queries más limpias y asociación inequívoca. La Fase 1 actual es un paso pragmático: funciona sin crear registros ficticios, y cuando llegue la Fase 4 (tabla AssetVersion), el backfill puede resolver el SKU texto a un asset_id real mediante un script de reconciliación.

Las razones por las que no hace falta crear Product/Garment en esta fase:

Extender Vton con una 3ª FK (product_id o collection_id) también se descartó: rompería el invariante de exclusividad de 2 FKs existente, requeriría tocar VtonQueryBuilder (3ª rama OR), mapToDTO, getVtonsCountByCollection, getDistinctSkusByCollection, zip, deleteVton y el callback —y mezclaría entidades con propósito distinto (trabajo de generación con status de scheduler vs. asset subido manualmente).

CampaignAsset ya modela exactamente "cosa con SKU, versionada, calificable, sin relación con prenda/producto". Extenderla evita tocar Vton y alinea con el plan de unificación.

5.3 La estrategia: contenedor polimórfico + proto-AssetVersion

La Fase 1 se divide en dos partes que se implementan juntas pero conviene entender por separado:

[Diagram]

5.4 Estado antes

[Diagram]

CampaignAsset sólo sabe apuntar a Campaign. CampaignAssetVersion no tiene status/source/task_id: no hay integración con el scheduler. No hay forma de vincular un asset a Collection ni de reprocesarlo.

5.5 Estado después — Fase 1a: contenedor en CampaignAsset

[Diagram]

5.6 Estado después — Fase 1b: proto-AssetVersion en CampaignAssetVersion

[Diagram]

Relación con Fase 4: estos campos son los mismos que tendría AssetVersion en el modelo unificado. Al ponerlos ahora en CampaignAssetVersion, esta tabla se convierte en un proto-AssetVersion. Cuando la Fase 4 cree la tabla AssetVersion, el backfill copia estos registros mapeando campo a campo. No hay trabajo desperdiciado.

5.7 Cambios estructurales

Componente Cambio
CampaignAsset + owner_type (enum, default CAMPAIGN), + collection_id (FK nullable), + sku (varchar nullable), campaign_id pasa a nullable
CampaignAssetVersion + status (VtonStatus, default SUCCESS), + source (enum, default MANUAL), + task_id (Text nullable), + group_id (nullable), + gpt_model/resolution/aspect_ratio/reframe_config/error
Collection + relación assets a CampaignAsset (CollectionAssets)
Campaign Sin cambios en la relación, pero campaign_id ahora es nullable en el otro extremo
Índices + (collection_id, deleted_at, order) en CampaignAsset, + (group_id, deleted_at, update_date) en CampaignAssetVersion
Enums + CampaignAssetOwnerType (CAMPAIGN|COLLECTION), + CampaignAssetVersionSource (MANUAL|GENERATED)

5.8 Reproceso y callback por task_id

Reproceso. El botón "Solicitar cambios" en rate.tsx toma la versión padre (la imagen actual), crea una nueva CampaignAssetVersion con status=PENDING, source=GENERATED, mismo group_id, y la envía al scheduler vía vtonApi.createImageGenerationTask. El input del scheduler para assetVton lleva:

El reproceso sin background/material puede producir un resultado distinto al pipeline product-image. Es un caso de uso aceptado: regenerar desde una imagen subida.

Callback. El scheduler envía callbacks con { vtonId, status, resultUrl } donde vtonId es un string. Para assetVtons, el vtonId enviado al scheduler es String(version.id). Como Vton.id y CampaignAssetVersion.id son ambos @id @default(autoincrement()) en tablas distintas, sus IDs pueden colisionar (vton #5 y version #5). Por eso el callback ruta por task_id:

  1. Buscar primero en Vton por task_id.
  2. Si no encuentra, buscar en CampaignAssetVersion por task_id.
  3. Si aparece en ambas (no debería, task_id es MongoDB ObjectId único), rechazar con error.

task_id viene del scheduler y no colisiona entre tablas. El schema vtonTaskUpdateSchema se extiende con taskId opcional como clave de ruteo.

Riesgo de colisión por ID: el HistoryModal usaba groupId para buscar en Vton, lo que mostraba imágenes incorrectas cuando un assetVton y un vton compartían IDs. Solución: endpoint dedicado GET /api/asset-vton-versions/grouped?groupId=X y flag isAsset en useVtonHistory(groupId, isAsset) para rutear a la tabla correcta.

5.9 Integración en rate.tsx

Vton y CampaignAssetVersion son tablas Prisma distintas: no se pueden unir en SQL. El handler getCollectionVtons debe:

  1. Llamar a VtonService.getSuccessVtonsByCollectionPaginated (vtons normales).
  2. Llamar a AssetVtonService.getSuccessByCollection (asset versions SUCCESS de la colección).
  3. Mergear ambas listas por update_date desc unificado.
  4. Para groupedBySku=true: agrupar el merge por SKU.
  5. Paginar el resultado unificado.

El hook useCollectionRatingPage opera sobre una lista unificada de VtonDTO | AssetVtonDTO. El type guard isAssetVton discrimina por la presencia de campos propios de assetVton. El feedback se rutea al endpoint correcto según el tipo.

5.10 Impacto en servicios, API y UI

5.11 Invariantes y validaciones app-level

5.12 Escalabilidad a PHOTO_MODEL

La estructura es type-agnóstica: CampaignAsset solo necesita collection_id + sku + type=IMAGE. Para PHOTO_MODEL futuro, se crearían assetVtons con SKU de garment sin GarmentModel ni ProductImage —mismo flujo de creación, feedback, reproceso y carrusel. No hay bloqueo estructural.

5.13 Reversibilidad

Todas las columnas nuevas son nullable y las relaciones nuevas son opt-in. Los defaults (owner_type=CAMPAIGN, status=SUCCESS, source=MANUAL) hacen que los registros existentes de campaña queden intactos. Si se necesita revertir, basta con no usar owner_type=COLLECTION y no crear versions con source=GENERATED: las tablas quedan en su estado anterior. No se pierde ningún dato. No se toca Vton.

5.14 Estado de implementación (2026-07-13)

Tarea Estado Detalle
Schema + migración CampaignAsset con owner_type/collection_id/sku; CampaignAssetVersion con status/source/task_id/group_id/gpt_model/resolution/aspect_ratio/reframe_config/error. ALT-A revertido.
DTOs AssetVtonDTO, CampaignAssetVersionDTO actualizado, isAssetVton type guard.
Services CampaignAssetService, CampaignAssetVersionService, AssetVtonService.
Controllers + callback asset-vton.controller.ts, validaciones Zod, callback routing por task_id.
Collection logic Merge en getCollectionVtons, SKUs, rating counts, deleteVton fallback.
Frontend rate.tsx Botón "Agregar asset manual", badge "Manual", feedback routing, historial con isAsset.
Zip/download Pendiente: extender prepareZipFiles para incluir asset versions SUCCESS de la colección.
Tests Pendiente.
Verificación Pendiente.

6. Fase 2 — Tabla Project como puente

6.1 Qué resuelve

Una vez resuelto el asset directo, el siguiente problema es que Collection y Campaign son contenedores paralelos. La UI y los servicios tienen que saber si están operando con uno u otro, lo que bifurca la lógica. Esta fase introduce Project como tabla puente: una entidad nueva que referencia a Collection o a Campaign mediante FKs únicas, sin migrar datos todavía.

El objetivo no es reemplazar a Collection/Campaign, sino permitir escribir código nuevo contra Project mientras lo viejo sigue contra Collection/Campaign. Es un paso intermedio que prepara el terreno para las fases siguientes.

6.2 Estado antes

[Diagram]

Dos contenedores paralelos, sin relación entre ellos.

6.3 Estado después

[Diagram]

Project tiene una FK única a Collection o a Campaign (excluyentes). El kind del proyecto indica de qué tipo de contenedor se trata.

6.4 Backfill

Se inserta un Project por cada Collection y cada Campaign existentes, copiando los campos comunes (tenant_id, name, status, active, image_url) y seteando la FK puente correspondiente. El kind se deriva del type de Collection (PHOTO_MODEL/PHOTO_PRODUCT) o se fija en CAMPAIGN para las campañas.

6.5 Doble escritura

A partir de esta fase, cada vez que se crea o edita una Collection o una Campaign, se crea o edita el Project correspondiente. Esto se implementa con un hook en el servicio o un trigger en Prisma. Las escrituras a Project son aditivas: si algo falla, el sistema sigue funcionando contra Collection/Campaign.

6.6 Lecturas

6.7 Validación

collection_id XOR campaign_id: un Project tiene uno y sólo uno de los dos. La validación se hace en servicio. Un Project con kind=PHOTO_MODEL o PHOTO_PRODUCT debe tener collection_id; con kind=CAMPAIGN debe tener campaign_id.


7. Fase 3 — SourceItem y SourceVariant en paralelo

7.1 Qué resuelve

Esta fase unifica Garment y Product (input del usuario) en una sola tabla SourceItem. Las imágenes base (ProductImage, Garment.front_image_url, Garment.back_image_url, GarmentExtraImage) pasan a SourceVariant. No se toca Vton, CampaignAsset ni Asset (esos se unifican como output en Fase 4). El enfoque es introducir las tablas nuevas como paralelas a las viejas, con backfill gradual y escritura doble. No se migra todo de golpe: se hace por tenant, en batches, fuera de horas pico.

7.2 Estado antes

[Diagram]

Dos tablas input distintas para lo que conceptualmente es lo mismo: "un producto/prenda cargado por el usuario como fuente de generación".

7.3 Estado después

[Diagram]

7.4 Cambios estructurales

Tabla nueva Reemplaza a FK puente
SourceItem Garment + Product garment_id, product_id (todas únicas y nullable)
SourceVariant ProductImage + Garment.front_image_url + Garment.back_image_url + GarmentExtraImage product_image_id (única y nullable)

Las FKs puente son la clave de la coexistencia: cada registro nuevo en Asset sabe a qué registro viejo corresponde, lo que permite validar paridad y revertir si hace falta.

7.5 Backfill gradual por tenant

El backfill se hace con scripts SQL idempotentes que copian los datos de las tablas viejas a las nuevas. Para cada Garment se crea un SourceItem con kind=WEARABLE, para cada Product uno con kind=NON_WEARABLE. Las imágenes base (ProductImage, Garment.front_image_url, Garment.back_image_url, GarmentExtraImage) se migran a SourceVariant.

Nota: CampaignAsset NO se migra a SourceItem. CampaignAsset es un output standalone que se unificará con Vton en la Fase 4 como Asset.

El backfill corre por tenant, en batches, fuera de horas pico. Si un tenant tiene mucho histórico, se puede pausar y retomar sin perder el estado.

7.6 Escritura doble

A partir de esta fase, los servicios existentes (garmentService, productService) se extienden para escribir también en SourceItem cuando crean o editan un registro. Se implementa con un helper sourceItemSyncService que mantiene ambos lados sincronizados.

7.7 Lecturas con feature flag

Se introduce un feature flag USE_UNIFIED_SOURCE_ITEMS. Cuando está activo para un tenant, CollectionService.getByIdAndTenantAsDTO lee los inputs desde la tabla nueva SourceItem en vez de Garment/Product. Cuando está inactivo, lee como hoy. Esto permite rollout gradual por tenant y rollback inmediato si algo falla.


8. Fase 4 — Split Vton + unificación Asset/AssetVersion/AssetFeedback

8.1 Qué resuelve

Esta fase realiza dos operaciones que juntas completan la unificación del output:

  1. Split de Vton: el modelo actual confla el "contenedor de generación" y el "resultado con URL" en una misma fila. Se separa: agrupar Vtons por groupId → cada grupo es un Asset (output container), cada fila individual es una AssetVersion (resultado con url, status, task_id). Vtons sin groupId pasan a ser su propio Asset con una sola AssetVersion.
  2. Unificación de output: Asset (del split de Vton) + CampaignAssetAsset unificado. AssetVersion (del split de Vton) + CampaignAssetVersionAssetVersion unificado. Feedback + CampaignAssetFeedbackAssetFeedback unificado.

El resultado: un solo modelo de output (Project → Asset → AssetVersion → AssetFeedback) que cubre tanto outputs derivados de un SourceItem (Vton) como outputs standalone (CampaignAsset).

8.2 Estado antes

[Diagram]

Vton confla job + resultado en una fila. CampaignAsset + CampaignAssetVersion ya están separados. En ambos casos, Feedback y CampaignAssetFeedback apuntan a la versión pero viven en tablas distintas.

8.3 Estado después

[Diagram]

8.4 El split de Vton y el campo source

El split de Vton es el paso previo a la unificación. El modelo actual usa groupId para agrupar Vtons relacionados (original + reprocesos). Cada grupo representa un "output container" y cada fila una iteración.

Split:

El campo source distingue el origen de cada versión:

8.5 Backfill

El backfill tiene dos partes:

Split de Vton:

  1. SELECT groupId, MIN(garment_model_id), MIN(product_image_id) FROM Vton WHERE groupId IS NOT NULL GROUP BY groupId → crear Asset por grupo, con source_item_id navegando: GarmentModel.garment_idSourceItem (o ProductImage.product_idSourceItem). Si el SourceItem no existe (Fase 3 incompleta del todo), dejar source_item_id=null y marcar para reconciliación.
  2. SELECT * FROM Vton → crear AssetVersion por fila, con asset_id = el Asset del paso 1 (resuelto por groupId). Vtons sin groupId → Asset individual + AssetVersion.
  3. FeedbackAssetFeedback con asset_version_id resuelto desde vton_id.

Unificación de CampaignAsset:

  1. CampaignAsset (existente) → Asset con source_item_id=null, project_id resuelto desde collection_id o campaign_id.
  2. CampaignAssetVersionAssetVersion con asset_id resuelto desde campaign_asset_id.
  3. CampaignAssetFeedbackAssetFeedback con asset_version_id resuelto desde campaign_asset_version_id.

Fase 1 (assetVton DV-880) — los CampaignAsset con owner_type=COLLECTION y sku se backfillan como Asset con source_item_id resuelto reconciliando skuSourceItem.sku. Si no hay SourceItem con ese SKU, dejar source_item_id=null (orphan).

8.6 Escritura doble

vtonService.create y campaignAssetService.create se extienden para escribir también en Asset/AssetVersion. feedbackService y campaignAssetFeedbackService hacen lo propio con AssetFeedback. Los servicios nuevos (AssetService, AssetVersionService, AssetFeedbackService) son mirrors de los existentes.


9. Fase 5 — GenerationJob y combinaciones de addons

9.1 Qué resuelve

Esta es la fase más compleja. Modela la producción opcional desacoplada de GarmentModel. Hoy, un GarmentModel es la unidad productiva mínima: combina un Garment (input) con un Model y una variante de modelo, y produce Vton. El nuevo modelo generaliza ese concepto: un GenerationJob dispara generación opcional desde un SourceItem (input) combinado con una AssetAddonCombination (la "receta" de addons: modelo, fit, material, accesorio, fondo, etc.), y produce un Asset (output) con sus AssetVersion (resultados concretos).

Esto permite que cualquier input (no sólo prendas) dispare generación, con cualquier combinación de addons (no sólo modelo+variante).

9.2 Estado antes

[Diagram]

GarmentModel es la única forma de combinar un asset con addons para generar.

9.3 Estado después

[Diagram]

9.4 El concepto de "slot"

El campo addon_slot en AssetAddonCombinationItem es lo que generaliza el modelo. Cada item de la combinación ocupa un "slot": model, model_variant, fit, material, accessory, background, footwear, etc. Esto permite expresar recetas como "remera + modelo X + fit Y + lentes Z + fondo de playa" sin necesidad de crear nuevas tablas para cada combinación posible.

9.5 Backfill de GarmentModel

Cada GarmentModel existente se migra a una AssetAddonCombination con dos items: uno con addon_slot=model apuntando al Model (como Addon) y otro con addon_slot=model_variant apuntando al ModelVariant. El source_item_id se resuelve desde GarmentModel.garment_idSourceItem. Los Vton asociados se vinculan a un GenerationJob que referencia esa combinación y output en Asset.

9.6 Sub-fase 5a: migración de Model, Fit, Material a Addon

Esta es la parte más delicada de toda la migración. Hay dos opciones:

Opción A (recomendada, conservadora): mantener Model, ModelVariant, Fit, Material como tablas hijas y añadir FKs tipadas en Addon (Addon.model_id?, Addon.fit_id?, etc.). AssetAddonCombinationItem.addon_id apunta al Addon puente que referencia la tabla hija. No se pierde tipado.

Opción B (agresiva): migrar todo a Addon con metadata JSON. Más simple a largo plazo, pero pierde validación relacional. Se deja para post-Fase 7.

La recomendación es usar la Opción A durante la migración y reconsiderar la Opción B tras la estabilización.

9.7 Impacto

Esta fase es la que más servicios toca: vtonService se transforma en GenerationJobService, garmentModelService se transforma en lógica de AssetAddonCombinationService, y los servicios de Model, Fit, Material se extienden para exponer Addons. La UI de generación también cambia: el usuario arma combinaciones de addons en vez de seleccionar un GarmentModel predefinido.


10. Fase 6 — Switch de lecturas y backfill total

10.1 Qué resuelve

Hasta esta fase, las tablas nuevas conviven con las viejas y la mayoría de las lecturas siguen yendo a las viejas. La Fase 6 es el momento de activar el modelo unificado en todas las lecturas, con feature flag por tenant para permitir rollout gradual y rollback inmediato.

10.2 Estado antes

[Diagram]

Las lecturas van a los servicios viejos, que leen de las tablas viejas. Las escrituras van a ambos lados (doble escritura) para mantener paridad.

10.3 Estado después

[Diagram]

Con el flag UNIFIED_MODEL_ENABLED activo para un tenant, las lecturas se redirigen a los servicios nuevos, que leen de las tablas nuevas. La doble escritura sigue activa para poder revertir si aparece un bug crítico.

10.4 Pasos de la fase

  1. Backfill completo de Fases 2–5 para todos los tenants. Se corrigen huecos o inconsistencias que hayan quedado.
  2. Validación de paridad: scripts que comparan counts entre tablas viejas y nuevas (count(Garment) == count(Asset where garment_id not null), etc.). Cualquier diferencia se investiga y corrige.
  3. Switch de lecturas:
    • CollectionService delega a ProjectService + AssetService.
    • CampaignService delega a ProjectService + AssetService.
    • VtonService delega a GenerationJobService + AssetVersionService.
    • FeedbackService delega a AssetFeedbackService.
  4. UI: las vistas de Collection y Campaign pasan a leer de Project/Asset. Mismas rutas, distinto backend.
  5. Rollout por tenant con flag UNIFIED_MODEL_ENABLED.

10.5 Por qué la doble escritura sigue

Aunque las lecturas ya van al modelo nuevo, las escrituras siguen yendo a ambas tablas durante esta fase. Eso permite revertir el flag en cualquier momento sin perder datos: si se detecta un bug en el modelo nuevo, se apaga el flag, las lecturas vuelven a las tablas viejas, y como esas tablas siguieron recibiendo escrituras, no hay dato perdido.

10.6 Recomendación operativa

Operar al menos 2 sprints en Fase 6 antes de avanzar a Fase 7. Eso da tiempo a detectar bugs críticos en producción real, con tráfico real, sin haber quemado el puente todavía.


11. Fase 7 — Deprecación de tablas viejas

11.1 Qué resuelve

Esta es la fase del punto de no retorno. Una vez confirmado que el modelo unificado está estable y que no hay lecturas ni escrituras a las tablas viejas, se eliminan esas tablas y las columnas puente. Irreversible.

11.2 Estado final del modelo

[Diagram]

Sin tablas puente, sin FKs a Garment/Product/CampaignAsset, sin duplicación. El modelo queda exactamente como se describió en la sección 4.

11.3 Tablas a eliminar

Bloque Tablas
Input (SourceItem) GarmentModel, GarmentAddon, GarmentAsComplementary, GarmentExtraImage, GarmentTag, Garment, ProductImage, ProductTag, Product
Output (Asset) CampaignAsset, CampaignAssetVersion, CampaignAssetFeedback, Campaign
Output (Vton) Vton, Feedback
Contenedores CollectionModel, Collection
Columnas puente FKs puente en SourceItem, SourceVariant, Asset, AssetVersion, AssetFeedback, GenerationJob, AssetAddonCombination

11.4 Pre-condiciones

Antes de ejecutar esta fase, hace falta:

  1. Snapshot completo de la base de datos. Es el plan de rollback: si algo sale mal, se restaura el snapshot.
  2. Ventana de mantenimiento. El drop de tablas no debe ejecutarse con tráfico activo.
  3. Confirmación de monitoreo: no debe haber lecturas ni escrituras a las tablas viejas en los últimos N días. Se verifica con logs de queries o con tracing de Prisma.
  4. Eliminación previa de la doble escritura. Los servicios dejan de escribir en las tablas viejas antes del drop.

11.5 Por qué es irreversible

Una vez eliminadas las tablas viejas y las columnas puente, no hay forma de volver al modelo anterior. Los datos que estaban en esas tablas ya están en las nuevas (gracias al backfill), pero la estructura vieja ya no existe. Por eso es fundamental operar varios sprints en Fase 6 antes de llegar acá: cualquier bug que aparezca después de Fase 7 se tiene que arreglar en el modelo nuevo, sin opción de volver atrás.


12. Matriz de riesgos por fase

Esta sección lista los riesgos concretos de cada fase, con su probabilidad estimada, impacto y mitigación. La probabilidad se estima sobre la base de la complejidad técnica de la fase y el volumen de datos involucrado; el impacto se mide en términos de downtime, pérdida de datos o regresiones funcionales.

12.1 Riesgos transversales

Riesgo Probabilidad Impacto Mitigación
Backfill incorrecto / pérdida de datos Media Alto Scripts idempotentes + validación de paridad + snapshot pre-fase
Drift en doble escritura Media Medio Jobs de reconciliación nocturnos que comparan counts entre tablas viejas y nuevas
Performance degradada en queries con OR (Collection XOR Campaign) Media Medio Índices compuestos + partición por owner_type
UI rota durante la transición Baja Medio Feature flags + mantener rutas viejas hasta Fase 7
Rollback post-Fase 7 imposible Alto Snapshot + mantener tablas viejas en DB sombra hasta 2 sprints post-Fase 7
Tenants grandes con mucho histórico Alta Medio Backfill por tenant, batched, fuera de horas pico

12.2 Riesgos por fase

Fase 1 — Asset manual en Collection (assetVton)

Riesgo Probabilidad Impacto Mitigación
campaign_id nullable rompe queries existentes que asumen NOT NULL Baja Medio Backfill previo + tests de regresión sobre endpoints de Campaign
Confusión entre owner_type=CAMPAIGN y COLLECTION en la UI Media Bajo Filtros explícitos por owner_type en todas las queries de listado
Soft-delete en cascada no se propaga a assets de Collection Media Medio Extender CollectionService.softDeleteCascadeById con tests dedicados
Callback colisiona por ID (Vton.id vs CampaignAssetVersion.id) Alta Alto Rutar por task_id (MongoDB ObjectId único del scheduler), no por id
HistoryModal muestra vton equivocado (colisión de groupId) Media Medio Endpoint dedicado GET /asset-vton-versions/grouped + flag isAsset
Reproceso sin background/material → resultado distinto al pipeline Media Bajo Aceptado (caso de uso: regenerar desde imagen subida). Documentar.
Zip/download omite assetVtons (sólo busca vía product_image) Alta Medio Extender prepareZipFiles para incluir asset versions SUCCESS (pend.)
Paginación imprecisa sobre unión de dos tablas Media Bajo v1: fetch de ambas fuentes, merge por update_date, slice. Aceptar ligera imprecisión de count

Fase 2 — Tabla Project puente

Riesgo Probabilidad Impacto Mitigación
Doble escritura con drift entre Collection/Campaign y Project Media Medio Job nocturno que compara counts y resincroniza
kind mal derivado del type de Collection Baja Bajo Validación en el backfill + reporte de discrepancias

Fase 3 — SourceItem + SourceVariant paralelas

Riesgo Probabilidad Impacto Mitigación
Backfill de Garment.front_image_url y back_image_url produce duplicados en SourceVariant Media Medio Idempotencia: chequear garment_id antes de insertar
Tenants con millones de garments saturan el backfill Alta Medio Batch por tenant, fuera de horas pico, con pausa/reanudación
SourceItem.kind mal asignado en el backfill Baja Medio Script de validación post-backfill que muestrea y verifica

Fase 4 — Split Vton + unificación Asset/AssetVersion/AssetFeedback

Riesgo Probabilidad Impacto Mitigación
Vton con groupId nullable → Vtons sueltos sin contenedor Media Alto Cada Vton sin groupId → su propio Asset (groupId = vton.id)
Vton con FK polimórfica (garment_model_id XOR product_image_id) se backfill incorrecto Media Alto Tests del script con datos sintéticos que cubran ambos casos
source_item_id no resuelve a ningún SourceItem (Fase 3 incompleta o SKU huérfano) Media Medio Dejar source_item_id=null, reportar para reconciliación
Feedback huérfano (sin AssetVersion correspondiente) Media Medio Reporte de huérfanos + backfill manual
Status mapping incorrecto entre Vton.status y AssetVersion.status Baja Medio Tabla de mapeo explícita + tests unitarios

Fase 5 — GenerationJob + Combination

Riesgo Probabilidad Impacto Mitigación
Migración de Model/Fit/Material a Addon pierde tipado Alta (si Opción B) Alto Usar Opción A (tablas hijas con FK tipada)
AssetAddonCombinationItem.addon_slot mal asignado en backfill Media Medio Validación post-backfill: cada combinación debe tener al menos model y model_variant
Cambios en UI de generación confunden a usuarios Media Medio Rollout gradual con feature flag + capacitación a usuarios

Fase 6 — Switch de lecturas

Riesgo Probabilidad Impacto Mitigación
Bug crítico en modelo nuevo detectado después del switch Media Alto Flag UNIFIED_MODEL_ENABLED permite revertir por tenant
Performance de queries nuevas degradada bajo carga Media Medio Load testing pre-switch + índices optimizados
Datos inconsistentes entre tablas viejas y nuevas Baja Alto Validación de paridad obligatoria antes del switch

Fase 7 — Drop de tablas viejas

Riesgo Probabilidad Impacto Mitigación
Drop ejecutado con lecturas/escrituras residuales a tablas viejas Baja Crítico Monitoreo de queries a tablas viejas durante 2 sprints previos
Snapshot corrupto o incompleto Baja Crítico Validación de snapshot pre-drop + snapshot secundario
Bug post-drop sin opción de rollback Media Alto Operar 2+ sprints en Fase 6 + plan de hotfix en modelo nuevo

13. Cronograma y criterios de decisión

13.1 Estimación por fase

Fase Sprints Notas
1 1–2 Urgente, independiente. Parcialmente implementada
2 1 Project puente
3 2 SourceItem + SourceVariant + backfill gradual
4 2 Split Vton + Asset/AssetVersion/AssetFeedback
5 2–3 GenerationJob + Combination + migración Model→Addon
6 1–2 Switch lecturas + rollout por tenant
7 1 Drop de tablas (con ventana de mantenimiento)
Total 10–13 ~5–7 meses con validación

13.2 Cronograma visual

[Diagram]