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.
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.
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:
Garment (prendas vestibles) y Product (objetos genéricos). Cada Garment se asocia a un Model humano vía GarmentModel, y cada ProductImage se asocia directamente. Ambos caminos producen Vton, que es el registro del disparo a la app externa generadora de imágenes.CampaignAsset (imagen o video) con CampaignAssetVersion y CampaignAssetFeedback. Acá no hay generación, sólo almacenamiento y versionado manual.TenantConfiguration, ZipJob, RejectReason) que viven al lado sin pertenecer a ningún mundo en particular.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.
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.
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).
El flujo productivo actual obliga a elegir entre dos caminos antes de poder generar una imagen:
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:
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.
El nuevo modelo se construye sobre ideas simples (creo), pero que cambian la forma de pensar el sistema:
Project unifica Collection y Campaign. Un proyecto puede ser de foto modelo, foto producto, campaña o genérico.Asset unifica Garment, Product y CampaignAsset. Es la "cosa" con SKU que tiene imágenes o videos asociados.AssetVersion unifica Vton y CampaignAssetVersion. Cada versión es una URL resultante, sin importar si vino de un upload o de una generación.AssetFeedback unifica Feedback y CampaignAssetFeedback. Misma estructura, mismo servicio.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.Addon con category extensible. Agregar un nuevo tipo de configurador no requiere nuevas tablas.Antes de ver el diagrama, conviene tener claro qué representa cada entidad nueva:
kind (podria ser type) que indica de qué tipo de trabajo se trata (foto modelo, foto producto, campaña, genérico).product_kind que reemplaza a los enums separados de Garment y Product.ProductImage y a los campos front_image_url/back_image_url de Garment.Vton (cuando source=GENERATED) y a CampaignAssetVersion (cuando source=MANUAL).AssetVersion. Esto resuelve la dualidad actual de Vton que a la vez era "el trabajo que se disparó" y "la URL resultante".GarmentModel y lo generaliza a cualquier asset.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).
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 |
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 |
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.
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.
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 | Sí |
| 2 | Tabla Project puente |
Bajo–Medio | 1 sprint | Sí |
| 3 | Tabla Asset + AssetVariant paralelas |
Medio | 2 sprints | Sí |
| 4 | AssetVersion + AssetFeedback unificados |
Medio | 2 sprints | Sí |
| 5 | GenerationJob + AssetAddonCombination |
Medio–Alto | 2–3 sprints | Sí |
| 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 |
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.
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 crearAsset/AssetVersionprematuramente. Está parcialmente implementada (ver 5.14).
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:
Garment, GarmentModel, Product ni ProductImage. El assetVton vive anclado a la Collection + SKU directamente.rate.tsx.PHOTO_MODEL en el futuro sin rediseño.Esto resuelve el ticket DV-880.
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:
CampaignAsset ya modela exactamente "cosa con SKU, versionada, calificable, sin relación con prenda/producto". Extenderla con owner_type=COLLECTION + collection_id + sku evita crear Product/Garment ficticios.ProductImage.image_url como input del scheduler (vton.controller.ts:457 buildVtonTaskInputFromProductImage). El assetVton no tiene ProductImage, pero sí tiene una CampaignAssetVersion.url (la imagen subida). El reproceso usa esa URL como input en lugar de ProductImage.image_url. No se necesita Product ni ProductImage.2026-07-09 probó reutilizar Vton con product_image_id creando un Product + ProductImage ficticios (commits dcac2f1b…f849c618, no mergeados a develop). Ese enfoque no desacopla —el usuario exigió desacoplamiento total— y fue revertido. develop está limpio: ningún campo de ALT-A (origin, reframe_config en Vton) existe en el schema actual.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.
La Fase 1 se divide en dos partes que se implementan juntas pero conviene entender por separado:
CampaignAsset): reutilizar la tabla CampaignAsset para apuntar a Collection además de Campaign, con owner_type + collection_id + sku. No toca el modelo productivo.CampaignAssetVersion): extender CampaignAssetVersion con campos de reproceso (status, source, task_id, group_id, gpt_model, resolution, etc.) para que pueda representar tanto un upload manual (source=MANUAL) como una generación del scheduler (source=GENERATED). Esto es un subconjunto de Fase 4 aplicado sobre la tabla existente, sin crear AssetVersion.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.
owner_type (CAMPAIGN | COLLECTION), default CAMPAIGN para compatibilidad con los assets de campaña existentes (backfill implícito por el default).collection_id nullable con relación CollectionAssets a Collection.sku nullable para el SKU del asset en la colección.campaign_id pasa a nullable.campaign_id XOR collection_id (validado en servicio).status reusa VtonStatus (PENDING/IN_PROGRESS/SUCCESS/ERROR/CANCELLED). Cubre exactamente los estados que el scheduler maneja y que el callback setea. Las versions existentes de campaña se defaultean a SUCCESS (ellas no pasan por scheduler). Reusar el enum evita duplicarlo.source (MANUAL | GENERATED) distingue si la versión fue subida manualmente por el usuario (MANUAL) o generada por el scheduler (GENERATED). Es el equivalente de VtonOrigin pero en la tabla correcta. No se toca Vton.task_id es el identificador que el scheduler devuelve (MongoDB ObjectId, único por tarea). Se usa para rutar el callback (ver 5.8).group_id sigue el patrón de Vton.groupId: la primera versión de un asset tiene group_id = version.id; las versiones de reproceso heredan el mismo group_id para agrupar el historial.gpt_model, resolution, aspect_ratio, reframe_config, error parametrizan la generación y registran fallos.Relación con Fase 4: estos campos son los mismos que tendría
AssetVersionen el modelo unificado. Al ponerlos ahora enCampaignAssetVersion, esta tabla se convierte en un proto-AssetVersion. Cuando la Fase 4 cree la tablaAssetVersion, el backfill copia estos registros mapeando campo a campo. No hay trabajo desperdiciado.
| 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) |
task_idReproceso. 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:
imageUrl = parentVersion.url (la imagen a regenerar — no ProductImage.image_url).sku = CampaignAsset.sku.collectionId, tenantId = desde la Collection del asset.campaign = collection.name, productName = CampaignAsset.title || sku.productDescriptionHint = CampaignAsset.description || ''.background, materialImageUrl, materialDescriptionPrompt, brandReferenceImageUrls (el assetVton no tiene Product).El reproceso sin
background/materialpuede 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:
Vton por task_id.CampaignAssetVersion por task_id.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
HistoryModalusabagroupIdpara buscar enVton, lo que mostraba imágenes incorrectas cuando un assetVton y un vton compartían IDs. Solución: endpoint dedicadoGET /api/asset-vton-versions/grouped?groupId=Xy flagisAssetenuseVtonHistory(groupId, isAsset)para rutear a la tabla correcta.
Vton y CampaignAssetVersion son tablas Prisma distintas: no se pueden unir en SQL. El handler getCollectionVtons debe:
VtonService.getSuccessVtonsByCollectionPaginated (vtons normales).AssetVtonService.getSuccessByCollection (asset versions SUCCESS de la colección).update_date desc unificado.groupedBySku=true: agrupar el merge por SKU.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.
CampaignAssetService se extiende con verifyCollectionOwnership, createManualAssetVton, listAssetsByCollectionPaginated. CampaignAssetVersionService.verifyAssetOwnership pasa a aceptar OR de campaign/collection. Nuevo AssetVtonService para el flujo de reproceso y queries por colección. El callback handler se bifurca por task_id.POST /api/collections/:collectionId/asset-vtons (crear manual), GET /api/collections/:id/vtons (extendido: merge vton + assetVton), POST /api/asset-vton-reprocess-task (reproceso), GET /api/asset-vton-versions/grouped (historial). Los endpoints de feedback existentes se reusan o se bifurcan según el tipo.rate.tsx, badge "Manual" para distinguir assetVtons de vtons generados, feedback routing, historial con flag isAsset. No hay pestaña "Assets" separada: el assetVton convive en el carrusel de rate.campaign_id XOR collection_id en CampaignAsset: validado en servicio (no hay CHECK cross-column en MySQL/Prisma). Si owner_type=COLLECTION, campaign_id=null; si CAMPAIGN, collection_id=null.source: si source=MANUAL, no debe tener task_id (no pasó por scheduler); si GENERATED, debe tener task_id.sku por colección: CampaignAsset no tiene constraint unique (collection_id, sku). Validar en createManualAssetVton (409 si ya existe). Considerar migración de constraint único futuro.owner_type default CAMPAIGN: los assets existentes de campaña quedan CAMPAIGN automáticamente (backfill implícito por el default de la migración).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.
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.
| 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. |
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.
Dos contenedores paralelos, sin relación entre ellos.
Project tiene una FK única a Collection o a Campaign (excluyentes). El kind del proyecto indica de qué tipo de contenedor se trata.
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.
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.
/api/projects que leen de Project y resuelven el tipo./api/collections, /api/campaigns) siguen funcionando sin cambios.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.
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.
Tres tablas distintas para lo que conceptualmente es lo mismo: "una cosa con SKU que tiene imágenes".
| 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.
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.
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.
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.
Esta fase unifica dos pares de tablas duplicadas:
Vton + CampaignAssetVersion → AssetVersionFeedback + CampaignAssetFeedback → AssetFeedbackLa 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".
source es la claveLa 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:
source=MANUAL: equivalente al viejo CampaignAssetVersion (upload).source=GENERATED: equivalente al viejo Vton (generación). En este caso, generation_job_id apunta al GenerationJob que lo produjo (esa tabla se introduce en la Fase 5).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.
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.
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).
GarmentModel es la única forma de combinar un asset con addons para generar.
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.
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.
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.
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.
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.
Las lecturas van a los servicios viejos, que leen de las tablas viejas. Las escrituras van a ambos lados (doble escritura) para mantener paridad.
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.
count(Garment) == count(Asset where garment_id not null), etc.). Cualquier diferencia se investiga y corrige.CollectionService delega a ProjectService + AssetService.CampaignService delega a ProjectService + AssetService.VtonService delega a GenerationJobService + AssetVersionService.FeedbackService delega a AssetFeedbackService.Project/Asset. Mismas rutas, distinto backend.UNIFIED_MODEL_ENABLED.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.
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.
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.
Sin tablas puente, sin FKs a Garment/Product/CampaignAsset, sin duplicación. El modelo queda exactamente como se describió en la sección 4.
| 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 |
Antes de ejecutar esta fase, hace falta:
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.
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.
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
Antes de avanzar de una fase a la siguiente, se debe verificar:
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:
owner_type en CampaignAsset para resolver el kind del Project).Project para asignar project_id en Asset).Asset para asignar asset_id en AssetVersion).AssetVersion para vincular GenerationJob a sus resultados).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:
AddonCategory sin tocar la estructura.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.