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 — Asset y AssetVariant en paralelo
  8. Fase 4 — AssetVersion y AssetFeedback unificados
  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 tres conceptos: Project (contenedor que reemplaza a Collection y Campaign), Asset (la "cosa" (imagen) con SKU, que reemplaza a Garment/Product/CampaignAsset) y AssetVersion (cada iteración producida o subida, reemplazando a Vton y CampaignAssetVersion). 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 directo) conviene unificarlos en un modelo único Project → Asset → AssetVersion, donde la producción VTON sea opcional y se dispare combinando el asset 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. Un solo Asset. Asset unifica Garment, Product y CampaignAsset. Es la "cosa" con SKU que tiene imágenes o videos asociados.
  3. Versiones unificadas. AssetVersion unifica Vton y CampaignAssetVersion. Cada versión es una URL resultante, sin importar si vino de un upload o de una generación.
  4. Feedback unificado. AssetFeedback unifica Feedback y CampaignAssetFeedback. Misma estructura, mismo servicio.
  5. Producción opcional. Un AssetVersion puede ser manual (upload) o generado (VTON). El disparo de generación se modela con GenerationJob, vinculado a un asset y a una combinación de addons.
  6. 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.
  7. 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 salta a la vista: la rama izquierda (Collection → Garment/Product → Vton → Feedback) y la rama derecha (Campaign → CampaignAsset → CampaignAssetVersion → CampaignAssetFeedback) colapsan en una única jerarquía Project → 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).

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 Asset (product_kind=WEARABLE/FOOTWEAR) fit_id migra a Addon
Product Asset (product_kind=NON_WEARABLE/ACCESSORY/FOOTWEAR) Unificación con Garment
CampaignAsset Asset (product_kind=GENERIC, is_reference_only=true) Asset de campaña
ProductImage AssetVariant view_name, order
Garment.front/back_image_url AssetVariant (view=front/back) Desnormalización
GarmentExtraImage AssetVariant
GarmentModel AssetAddonCombination + GenerationJob prenda+modelo+variante = combinación
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
Vton GenerationJob + AssetVersion (source=GENERATED) Separar trabajo vs resultado
CampaignAssetVersion AssetVersion (source=MANUAL)
Feedback AssetFeedback Unificación
CampaignAssetFeedback AssetFeedback Unificación
GarmentTag / ProductTag AssetTag Unificación
CollectionModel ProjectAddon (category=MODEL) N:N Project↔Addon
RejectReason RejectReason (con project_kind) Desacoplar de CollectionType

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) Asset.product_kind único + Project.kind
A2 (FOOTWEAR solapado) product_kind=FOOTWEAR único, sin Garment/Product separados
A3 (Garment≈Product) Tabla Asset única
A4 (Vton polimórfico) GenerationJob con FK a Asset + AssetVariant
A5 (Feedback duplicado) AssetFeedback único
A6 (Version duplicado) AssetVersion único con source
A7 (sin asset directo) Asset puede ser is_reference_only y no tener variantes
A8 (Campaign sin type/sku) Project unificado con kind y assets 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 AssetProductKind. 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 Tabla Asset + AssetVariant paralelas Medio 2 sprints
4 AssetVersion + AssetFeedback unificados 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? No. Las razones:

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 — Asset y AssetVariant en paralelo

7.1 Qué resuelve

Esta es la fase donde realmente empieza la unificación de Garment, Product y CampaignAsset en una sola tabla Asset. 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]

Tres tablas distintas para lo que conceptualmente es lo mismo: "una cosa con SKU que tiene imágenes".

7.3 Estado después

[Diagram]

7.4 Cambios estructurales

Tabla nueva Reemplaza a FK puente
Asset Garment + Product + CampaignAsset garment_id, product_id, campaign_asset_id (todas únicas y nullable)
AssetVariant 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 Asset con product_kind=WEARABLE, para cada Product uno con product_kind=NON_WEARABLE, y para cada CampaignAsset uno con product_kind=GENERIC y is_reference_only=true. Las imágenes base (ProductImage, Garment.front_image_url, Garment.back_image_url, GarmentExtraImage) se migran a AssetVariant.

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, campaignAssetService) se extienden para escribir también en Asset cuando crean o editan un registro. Se implementa con un helper assetSyncService que mantiene ambos lados sincronizados.

7.7 Lecturas con feature flag

Se introduce un feature flag USE_UNIFIED_ASSETS. Cuando está activo para un tenant, CollectionService.getByIdAndTenantAsDTO lee los assets desde la tabla nueva Asset 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 — AssetVersion y AssetFeedback unificados

8.1 Qué resuelve

Esta fase unifica dos pares de tablas duplicadas:

La duplicación actual genera dos servicios, dos DTOs y dos modelos mentales para lo que conceptualmente es lo mismo: "una iteración de un asset con una URL resultante" y "feedback sobre esa iteración".

8.2 Estado antes

[Diagram]

8.3 Estado después

[Diagram]

8.4 El campo source es la clave

La diferencia entre Vton y CampaignAssetVersion era que uno disparaba producción y el otro era upload manual. En el nuevo modelo, eso se expresa con el campo source:

8.5 Backfill

El backfill copia Vton a AssetVersion con source=GENERATED, y CampaignAssetVersion a AssetVersion con source=MANUAL. Para Feedback y CampaignAssetFeedback, ambos se copian a AssetFeedback manteniendo la FK puente para poder validar paridad.

El caso de Vton es el más delicado porque hoy tiene FK polimórfica (garment_model_id XOR product_image_id). El backfill resuelve esto navegando: Vton → GarmentModel → Garment → Asset o Vton → ProductImage → Product → Asset.

8.6 Escritura doble

vtonService.create y campaignAssetVersionService.create se extienden para escribir también en AssetVersion. feedbackService y campaignAssetFeedbackService hacen lo propio con AssetFeedback. Los servicios nuevos (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 una prenda con un modelo y una variante de modelo, y produce Vton. El nuevo modelo generaliza ese concepto: un GenerationJob dispara generación sobre un Asset combinado con una AssetAddonCombination (la "receta" de addons).

Esto permite que cualquier asset (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. Los Vton asociados se vinculan a un GenerationJob que referencia esa combinación.

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
Garment GarmentModel, GarmentAddon, GarmentAsComplementary, GarmentExtraImage, GarmentTag, Garment
Product ProductImage, ProductTag, Product
Campaign CampaignAsset, CampaignAssetVersion, CampaignAssetFeedback, Campaign
Collection CollectionModel, Collection
Vton/Feedback Vton, Feedback
Columnas puente FKs puente en 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 — Asset + AssetVariant paralelas

Riesgo Probabilidad Impacto Mitigación
Backfill de Garment.front_image_url y back_image_url produce duplicados en AssetVariant 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
product_kind mal asignado en el backfill Baja Medio Script de validación post-backfill que muestrea y verifica

Fase 4 — AssetVersion + AssetFeedback

Riesgo Probabilidad Impacto Mitigación
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
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 Asset + AssetVariant + backfill gradual
4 2 Versiones + feedback unificados
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]

13.3 Criterios de go/no-go por fase

Antes de avanzar de una fase a la siguiente, se debe verificar:

13.4 Cuándo detener la migración

La migración es segura hasta Fase 6 inclusive (siempre reversible con flag). Fase 7 es el único punto de no retorno. La recomendación es:

13.5 Dependencias entre fases


14. Cierre y próximos pasos

14.1 Qué ganamos con el modelo unificado

El modelo Project → Asset → AssetVersion con producción opcional vía GenerationJob resuelve los diez problemas detectados en el relevamiento inicial, pero más importante que eso es lo que habilita hacia adelante:

14.2 Próximos pasos inmediatos

  1. Completar Fase 1. Está parcialmente implementada (schema, servicios, controllers, callback y rate.tsx listos). Faltan zip/download, tests y verificación. Es independiente del resto, de bajo riesgo, y resuelve una necesidad operativa concreta (assetVton en Collection). Puede ejecutarse en paralelo al diseño detallado de Fases 2–7.
  2. Detallar el diseño de Fases 2 y 3. Son las fases que más cambios estructurales introducen y las que más conviene tener bien diseñadas antes de arrancar.
  3. Definir el plan de feature flags. Qué flags se crean, cómo se administran por tenant, qué endpoints responden a cada flag.
  4. Definir el plan de monitoreo. Qué métricas se siguen durante la migración (drift entre tablas, latencia de queries, errores por endpoint).
  5. Definir el plan de comunicación a usuarios. Sobre todo para Fase 5, donde la UI de generación cambia: conviene anticipar a los usuarios que la forma de armar combinaciones de addons va a cambiar.

14.3 Lo que no hay que olvidar

La Fase 7 es el único punto de no retorno. Hasta ese momento, cualquier paso puede deshacerse. La tentación de acelerar el cronograma para llegar antes a Fase 7 es real, pero el costo de un bug crítico post-Fase 7 (sin opción de rollback) es mucho mayor que el costo de operar 2 sprints extra en Fase 6. El plan está diseñado para ser reversible hasta el último momento posible; conviene aprovechar esa propiedad.