Ir al contenido

Modelo de datos (Mongo)

Base de datos en MongoDB Atlas, database api-cards. Driver: Mongoose 8. Cada colección está definida en src/**/persistence/*Schema.ts del repo tcgcards-api.

Catálogo de TCGs habilitados (Pokémon, One Piece, etc.).

CampoTipoNullableDefaultDescripción
_idStringnoSlug del TCG (pokemon, one-piece, etc.)
slugStringnoSlug (mismo valor que _id) — único
nameStringnoNombre display
descriptionStringno''Descripción corta
enabledBooleannotrueSi está habilitado
sourceStringno'tcgplayer' u otro
externalMixedno{}Metadata de la fuente externa

Sin índices explícitos (unique en slug).

Sets de cada TCG (e.g. “Scarlet & Violet 151”).

CampoTipoNullableDefaultDescripción
_idStringno{tcg}-{slug}
tcgStringnoSlug del TCG (índice)
slugStringnoSlug del set
nameStringnoNombre
codeStringCódigo corto
releaseDateDateFecha lanzamiento
cardsCountNumberno0Cuántas cartas tiene
pricesRefreshedAtDatenullÚltima vez que el job refresh-prices actualizó los precios de las cartas del set (oldest-first). Independiente de lastSyncedAt.
showcaseImagesString[]no[]Cartas del set espejadas en R2, para el abanico del destacado de la portada. Vacío = todavía sin arte verificado; se dibuja el logo del juego.
showcaseTileStringnullFranja 16:10 recortada al arte, para la baldosa del riel.
showcaseBackdropStringnullMiniatura de 64px que el destacado dibuja desenfocada de fondo.
showcaseCheckedAtDatenullÚltima pasada del job mirror-set-art, haya conseguido arte o no. Distingue “revisado y no había” de “nunca mirado”.
showcaseCardsCountNumbernullCuántas cartas tenía el set al espejarlo. Si hoy tiene otra cantidad, se vuelve a elegir el arte.

Índices: {tcg:1, slug:1} (unique), {tcg:1, releaseDate:-1}, {source:1, pricesRefreshedAt:1} (para el refresco de precios oldest-first).

Los cinco campos showcase* los escribe SOLO el job mirror-set-art, con $set puntual. SetRepository.upsert —el que corre en cada sync diario— los descarta explícitamente: hace $set con el objeto entero del set, así que si viajaran por ahí el sync borraría el arte de la portada todas las noches. Hay dos pruebas que fallan si alguien los reintroduce.

Cartas individuales sincronizadas desde TCGplayer.

CampoTipoNullableDefaultDescripción
_idStringno{tcg}-{externalProductId}
tcgStringnoSlug TCG
setIdStringnoRef al set
setSlug / setNameStringnoDenormalizado para queries
nameStringnoNombre completo (puede incluir variante)
baseNameStringnoNombre canónico para autocomplete
numberStringNúmero en el set (ej. 97/101, OP09-095)
numberSearch[String]no[]Derivado de number: tokens normalizados (minúscula, número completo + segmento con/sin ceros) para buscar por número con prefijo anclado. Lo calcula numberSearchTokens() dentro de CardRepository.upsert() → el sync lo puebla solo. Ej: 024/078["024/078","024","24"]
rarityStringRareza
imageUrlStringnoURL del catálogo (TCGplayer/CDN)
marketPricesSub-docPrecios USD de TCGplayer (market/median/low/listingsCount/pricesUpdatedAt). Refrescados a diario por el job refresh-prices (ver crons), no por el sync. Se exponen al vendedor como referencia.
languagesstring[]no[]Denormalizado (jul 2026, filtros /cartas): idiomas distintos de las publicaciones ACTIVAS de la carta (orden estable del enum; [] sin stock). Escrito en el mismo $set atómico del resumen por CardStockRecalcService; reconciliado por reconcile-denorm (Familia A) y backfill scripts/backfill-card-stock.ts. Índices (a mano en prod): tcg_instock_languages_latest_idx, tcg_instock_languages_minprice_idx
listingsCountNumberno0Resumen de publicaciones (jul 2026, proyecto perf-escala): cantidad de publicaciones activas de la carta. Mantenido por CardStockRecalcService (recálculo derivado tras cada transición del listing, diferido 1500ms) + reconciliación nocturna. El upsert del sync JAMÁS lo pisa (va en $setOnInsert)
minPriceNumbernullResumen: precio mínimo (CLP cents) entre las publicaciones activas
totalStockNumberno0Resumen: suma de quantity de las publicaciones activas
inStockBooleannofalseResumen: totalStock > 0. Alimenta feed/facetas/búsqueda/sitemap. La ficha de carta sigue calculando stock EN VIVO (invariante: lo que toca compra lee datos vivos)
latestListedAtDatenullResumen: createdAt más reciente entre las activas (orden “recientes” del feed)
releaseDateAtDatenullDerivado de attributes.raw.releaseDate (string→Date UTC) en CardRepository.upsert() (como numberSearch) — eliminó el $dateFromString por doc del sort del catálogo

Índices: {tcg:1, setSlug:1}, text index {tcg:1, name:'text'}, {tcg:1, rarity:1}, {tcg:1, baseName:1}, {tcg:1, 'attributes.raw.releaseDate':-1}, {tcg:1, numberSearch:1} (multikey; soporta la búsqueda por número con prefijo anclado). Perf-escala (jul 2026): instock_latest_idx {inStock:1,latestListedAt:-1}, tcg_instock_latest_idx, tcg_instock_minprice_idx, tcg_instock_release_idx {tcg:1,inStock:-1,releaseDateAt:-1,baseName:1} — OJO: en deploys se crean A MANO (autoIndex no corre al boot; ver runbook perf-escala-prod-rollout.md del repo API).

Búsqueda por número (jun 2026): la búsqueda de cartas matchea nombre O número. Una query con dígitos (ej. 97, 97/101, OP09-095) se resuelve por prefijo anclado sobre numberSearch (índice arriba), tolerando ceros (24024); combinada con un set queda única. Cubre catálogo, autocomplete, mis publicaciones, perfiles públicos y el feed de publicaciones (NO el escáner, que ya identifica por número con su propio path). Backfill inicial: scripts/backfill-number-search.ts.

Para los filtros por TCG hay ~19 índices parciales {tcg:1, 'attributes.raw.<campo>':1} con partialFilterExpression: { 'attributes.raw.<campo>': { $exists: true } } — uno por campo filtrable del registro tcgFilters.ts. El partialFilterExpression mantiene cada índice chico (solo cartas que tienen ese atributo) y ayuda a respetar el límite de 64 índices por colección de MongoDB. Las búsquedas por texto (q) y las facetas cuando hay búsqueda usan el índice de Atlas Search card-search (autocomplete sobre baseName).

Reportes de sincronización con TCGplayer (auditoría de jobs).

Índices: {source:1}, {tcg:1}.

Publicaciones de los vendedores. Discriminated union por kind.

Campos comunes a todos los kinds:

CampoTipoNullableDescripción
_idStringnolisting-{nanoid}
sellerIdStringnoRef al user
kindenum card/accessory/bulk/sealed/decknoTipo de producto
priceNumbernoPrecio en CLP cents (mín 1)
currencyenum CLPnoSolo CLP por ahora
quantityNumbernoStock disponible (mín 1)
photos[String]noHasta N fotos (Cloudinary URLs)
customPhotoStringFoto custom legacy
descriptionStringHasta 500 chars
statusenumnoactive / paused / sold / hidden_by_admin / hidden_by_user_suspension
locationSub-docno{region, commune}
autoPriceBooleannoSi la publicación tiene Precios dinámicos activo (se repricea sola a diario). Default false. Solo aplica a cartas de catálogo TCGplayer
autoPricedAtDateÚltima vez que el job de precios dinámicos le escribió el precio (null si nunca lo hizo)
soldCount, lastSoldAtNumber / Dateno / síContador de ventas (jul 2026): unidades vendidas NETAS + última venta, mantenidos en el MISMO update atómico que el stock (decrementStock +N / restoreStock −N con clamp a 0). El filtro “Vendidos” de Mis publicaciones = soldCount>0 O status:'sold' (cubre ventas manuales), ordenado por lastSoldAt. Privado del vendedor (excluido de payloads públicos). Backfill/reconciliación: scripts/backfill-sold-count.ts = familia C de reconcile-denorm
tcg, cardSource, setId, setName, cardBaseName, cardNumberSearch, cardAttributesvariosDenormalizados de la carta (jul 2026, perf-escala): copiados de cards al CREAR la publicación (datos estáticos: una carta no cambia de juego/set/nombre). Permiten que “Mis publicaciones” filtre/facetee/busque SIN $lookup (O(página)). update() jamás los pisa; backfill/reconciliación: scripts/backfill-listing-tcg.ts (cubre legacy sin kind). Índices: seller_status_tcg_idx {sellerId,status,tcg}, status_cardId_idx {status,cardId}
autoPriceSkippedSub-doc{reason, at} — por qué el job la saltó: reasonbelow_min / no_fresh_price. Alimenta el badge “Fuera de precios dinámicos”. null si no se saltó

Campos por kind:

  • card: cardId, condition, language, variant, hasLeagueStamp, customTitle (legacy). Puede tener photos (0-2) opcionales: fotos propias del vendedor de su carta real (frente/dorso). customPhoto es el legacy de 1 sola foto.
  • accessory: accessoryType, accessoryCondition, accessoryName, tcg.
  • bulk: bulkComposition, bulkCondition, bulkLanguages ([]), cardsPerLot, bulkTitle.
  • sealed: sealedTitle, sealedLanguage.
  • deck: deckTitle, deckCardCount, tcg. Reutiliza description (obligatoria, min 10), photos (1-5), price, quantity, location.
    • deckTitle: string | null — Nombre libre del mazo (5-80 chars).
    • deckCardCount: number | null — Cantidad total de cartas (1-500).
    • No incluye condition ni language — un mazo puede mezclar cartas de distintos idiomas y condiciones.

Precios dinámicos (jun 2026): opt-in por vendedor. Cuando autoPrice está en true, el job diario de precios dinámicos repricea la publicación a precio_CLP = round100(card.marketPrices.market[USD] × usdRate) (redondeo half-up al múltiplo de $100 más cercano, en aritmética entera; mínimo de publicación $50). Solo aplica a cartas de catálogo TCGplayer (card.source == "tcgplayer"): nunca a Mitos (source: api.myl.cl), sellado, accesorios ni custom. Exige frescura del precio (≤26h tanto en sets.pricesRefreshedAt como en cards.marketPrices.pricesUpdatedAt); si no está fresco, salta la carta y deja autoPriceSkipped con reason: "no_fresh_price" (o "below_min" cuando el precio calculado cae bajo $50). La escritura es quirúrgica con guard CAS {status: active, autoPrice: true} y no toca el flujo de pago — las órdenes congelan el precio al crearse. El interruptor maestro del vendedor vive en users.autoPricing (ver abajo). En el código se llama autoPrice/autoPricing; la marca visible es “Precios dinámicos”.

Índices: {autoPrice:1, status:1} (autoprice_active_idx, soporta el $match de candidatos del job de precios dinámicos), {cardId:1, status:1}, {sellerId:1, status:1}, {status:1, createdAt:-1}, {cardId:1, condition:1, language:1}, {sellerId:1, createdAt:-1} (seller_createdAt_idx).

Un cart por usuario. _id = userId.

CampoTipoDescripción
_idStringuserId
sellerIdStringlegacy, sin uso — el carrito ahora acepta múltiples vendedores (ver checkouts)
itemsarray{listingId, quantity}

Las órdenes generadas en checkout.

CampoTipoDescripción
_idStringorder-{nanoid} — interno, en URLs
displayIdString8 chars + dash — visible para el usuario
buyerId, sellerIdStringrefs
itemsarraysnapshot de items al momento del checkout. Jul 2026: además de condición/idioma/variante, el snapshot guarda hasLeagueStamp (cards — el sello distingue publicaciones idénticas; el vendedor lo necesita para preparar el pedido) y deckCardCount (decks). Sin default: kinds ajenos y órdenes legacy no llevan el campo (UI/correos lo omiten). Se muestra como badge/chip Liga en detalle de orden, tarjetas de ventas/compras y TODOS los correos con productos (renderizador único renderOrderItems)
totalPriceNumbersuma de subtotales en CLP cents
buyerAddressSub-docsnapshot del comprador al checkout: name, phone, email, rut + street/houseNumber/commune/region/deliveryPreference. Solo lo ven comprador/vendedor de la orden; el vendedor ve rut/correo/dirección únicamente en envío a domicilio. deliveryPreference acepta pickup (jul 2026, Métodos de entrega) además de shipping/in_person/no_preference
statusenumawaiting_payment/pending/accepted/shipped/completed/canceled
deliveryMethodenumshipping/in_person/pickup/null
pickupPointSub-docMétodos de entrega (jul 2026): null salvo deliveryMethod:'pickup'. Snapshot inmutable del punto de retiro elegido en el checkout: {title, region, commune, address, instructions} (instructions nullable) — editar/borrar el punto “vivo” del vendedor después NO afecta órdenes pasadas. La respuesta al usuario lo redacta a null mientras la orden esté awaiting_payment o cancelada sin pago (la dirección exacta solo se muestra tras la compra); el documento en Mongo conserva el snapshot siempre, lo necesitan ship() y los correos (ver API)
paymentId, paymentTimeoutAt, mpInitPointrefs a payments
checkoutIdStringsí; set en sub-órdenes de carrito multi-vendedor (pago combinado). Invariante: una orden tiene paymentId XOR checkoutId — individual (MP) vs grupal (wallet), nunca ambos
openDisputeId, warningEmailSentAttracking
acceptedAt/shippedAt/completedAt/canceledAtDatetimestamps

Índices: {buyerId:1, createdAt:-1}, {sellerId:1, createdAt:-1}, {status:1, updatedAt:-1}, {displayId:1} (unique sparse), {createdAt:-1, _id:-1}, {status:1, paymentTimeoutAt:1} (← agregado 2026-05-20 para cron de cleanup).

Tabla de pagos contra MP. Un payment por order individual (las sub-órdenes de carrito multi-vendedor no tienen payment propio — su pago es el checkout agregado).

Índices: {userId:1, createdAt:-1}, {mpPaymentId:1} (unique partial).

Agregado de un carrito multi-vendedor pagado en un solo pago combinado (MP). Une las N sub-órdenes (una por vendedor) a un único pago.

CampoTipoDescripción
_idStringcheckout-{nanoid} — es el external_reference que viaja a MP
buyerIdStringref
orderIds[String]las N sub-órdenes (una por vendedor)
totalPriceNumbersuma de subtotales en CLP cents (= monto cobrado en MP)
mpPreferenceId, mpPaymentId, mpFee, netAmount, mpStatusdatos MP
statusenumawaiting_payment/paid/expired/failed/refunded
paymentTimeoutAt, webhookReceivedAtDatetracking

Al confirmarse el pago (webhook con external_reference que empieza con checkout-), confirmCheckoutAndAdvanceOrders aplica el fan-out de wallet (topup +total al comprador, −subtotal por orden, mp_fee al sistema; net-0 del comprador con guard) y avanza cada sub-orden a pending. Cada sub-orden luego se acepta/envía/completa/cancela por separado; el reembolso de una sub-orden cancelada va al wallet.

Chat entre comprador y vendedor por cada orden.

conversations índices: {orderId:1} (unique), {buyerId:1, lastMessageAt:-1}, {sellerId:1, lastMessageAt:-1}. messages índices: {conversationId:1, createdAt:1}.

Una dispute por orden (máximo 1).

Campos clave: reason (not_received/damaged/incorrect/counterfeit/no_response/other), description (min 30 chars), photos (max 4), status (open/resolved), resolution (canceled_in_favor_of_buyer/closed_in_favor_of_seller/dismissed), response (sub-doc).

Índices: {status:1, createdAt:-1}, {orderId:1} (unique).

Reseñas de buyer → seller. Una por orden completada.

Campos: rating (1-5), comment (max 500). Índices: {orderId:1} (unique), {sellerId:1, createdAt:-1}, {buyerId:1, createdAt:-1}.

Reportes de usuarios (a listings o a otros users).

Índices: {reporterId:1, targetType:1, targetId:1} (unique partial), {status:1, createdAt:-1}, {targetType:1, targetId:1, status:1}, {reporterId:1, createdAt:-1}.

Ledger inmutable de movimientos.

Índices: {userId:1, createdAt:-1}, {metadata.orderId:1} (sparse), {metadata.withdrawalId:1} (sparse), {type:1, createdAt:-1}.

Tipos de movimiento: topup, purchase, sale, commission, mp_fee, refund, reversas (*_reversal), manual_credit/manual_debit, owner_draw, withdrawal_*, y iva_payment. Hay dos cuentas de sistema en users (isSystem:true): user-system-tcgcards (comisión/ganancia + mp_fee + owner_draw) y user-system-iva (registra pagos de IVA al SII vía iva_payment). Sus saldos + las órdenes completadas alimentan el panel de finanzas (ver decisiones).

Solicitudes de retiro de wallet a cuenta bancaria.

Estados: pending/processing/completed/canceled. Índices: {userId:1, createdAt:-1}, {status:1, createdAt:-1}.

Cuentas bancarias guardadas, encriptadas con AES-GCM.

Campos: userId (unique), ciphertext, iv, authTag, keyVersion, last4, bankName.

La encriptación usa la env var BANK_ACCOUNT_ENCRYPTION_KEY (32 bytes base64). Ver runbooks para rotación.

Cuentas de usuario.

CampoTipoDescripción
_idStringuser-{nanoid}
emailStringunique, lowercase
usernameStringunique
name, avatarUrlperfil
coverUrlString | nullPortada del perfil (jul 2026): URL de Cloudinary de la imagen de cabecera. null —o ausente, sin migración— = el front dibuja una trama derivada del nombre de usuario. Público: se ve igual en /me y en /u/[username]
bio, instagramUrl, facebookUrlString | nullpresentación pública del vendedor. ⚠️ Ver el aviso sobre PATCH parcial en API: bio se estuvo borrando sola en producción por un transform encadenado fuera de .optional()
authProvidersarraygoogle por ahora
region, commune, street, houseNumber, phone, rutdirección + RUT (privados; rut normalizado "12345678-5", solo se comparte con el vendedor al comprarle)
contactPreferences, notificationPreferencesSub-doctoggles
autoPricingSub-docConfig maestra de Precios dinámicos del vendedor: {enabled, usdRate}. enabled (Boolean, default false) = si tiene precios dinámicos activos; usdRate (Number entero 1..100000 o null) = a cuánto toma el dólar para repreciar. Validador de entero en el schema + runValidators en el update
sellerDeliverySub-docMétodos de entrega (jul 2026): config de entrega del vendedor, privada. {shipping, inPerson, coordinate, pickup, pickupPoints: [{id, title, region, commune, address, instructions, active}]} (instructions nullable; pickupPoints máx 5). Sin subdocumento (null, vendedor legacy) → defaults vía effectiveSellerDelivery(): los 3 métodos clásicos ON, pickup OFF, sin puntos — sin migración. address/instructions de cada punto son privados; el perfil público (toPublicSellerDelivery()) solo expone id/title/commune/region de los puntos activos (ver API)
reviewStatsSub-docrating promedio + count
isAdminBooleanflag admin
statusenumactive/warned/suspended/banned
suspendedUntil, warningsCount, lastWarningAt, warningAcknowledgedAtmoderación
walletBalanceNumberbalance actual (denormalizado del ledger)
isSystemBooleanla cuenta system de la plataforma

Índices: {email:1} (unique), {username:1} (unique), {status:1, suspendedUntil:1} (partial para auto-unsuspend).

Banners visuales para el home.

Campos: imageUrl, imageScale/Offset, CTA primary/secondary, dark variant, status (draft/published/archived), order. Índices: {status:1, order:1}.

Audit log de acciones de admin.

Índices: {adminId:1, createdAt:-1}.

Contador diario de requests al OCR de OCR.space (para vigilar el cupo del free tier). Definida en src/scanner/persistence/ScannerUsageSchema.ts.

CampoTipoDefaultDescripción
_idStringFecha YYYY-MM-DD en UTC (la cuota de OCR.space resetea 00:00 UTC)
countNumber0Requests consumidos ese día. $inc atómico (findOneAndUpdate + upsert) por cada scan
alertedAt80BooleanfalseSi ya se envió el email de alerta al 80% ese día (evita re-alertar)

Sin índices explícitos: la key _id por fecha basta para el $inc atómico y el query del día. El agregado mensual y de últimos 30 días se hace por prefijo del _id.

Idempotencia del Publicador Pro (publicación masiva). Mapea ${sellerId}:${clientRef}listingId creado, para que reintentar un item tras un corte de red no duplique la publicación.

CampoTipoDefaultNotas
keyString${sellerId}:${clientRef}. Índice único
sellerIdStringVendedor dueño del registro
listingIdStringPublicación creada (o fusionada) para ese clientRef
createdAtDatenowÍndice TTL expireAfterSeconds: 48h — el registro se borra solo pasada la ventana de reintentos

El tope diario de publicaciones por vendedor es 2000/24h (LISTING_DAILY_CAP). El Publicador Pro lo respeta vía ListingService.create por item; al alcanzarlo, los items restantes vuelven con error daily_cap.

Direcciones a las que no se envía nunca más. Distinta de la baja voluntaria: esto lo decide el proveedor de correo, no la persona. Definida en src/marketing/persistence/SuppressionSchema.ts.

CampoTipoDefaultDescripción
emailStringDirección en minúsculas (lowercase: true). Índice único
reasonStringbounce (rebote duro) o complaint (nos marcó como spam)
detailStringnullSubtipo que reportó SES (Permanent, abuse, …)
createdAtDatenow

lowercase no es cosmético: los proveedores devuelven la dirección con la caja que se les ocurre, y comparar literal significaría seguir escribiéndole a quien nos denunció. La supresión gana sobre las preferencias del usuario y también sobre los correos esenciales. Un segundo aviso sobre la misma dirección no pisa el motivo original ($setOnInsert): entre un rebote y una queja, lo que importa recordar es la queja.

Registro de cada campaña enviada. Es la prueba de qué se mandó, a cuánta gente y cuándo, y por eso guarda el cuerpo en Markdown tal como se escribió. Definida en src/marketing/persistence/CampaignSchema.ts.

CampoTipoDefaultDescripción
_idStringcampaign-{nanoid}
subjectStringAsunto
categoryStringpromotional · service_update · essential
markdownBodyStringCuerpo tal como lo escribió el autor
authorIdStringQuién la envió
recipientCountNumber0Audiencia resuelta al momento de enviar
sentCount / failedCountNumber0Resultado real
statusStringsendingsending · sent · failed. Si no salió ni un correo queda failed, no sent
createdAt / finishedAtDateÍndice { createdAt: -1 } para el historial del panel

Un envío en seco no crea documento: el historial es el registro de lo que sí se envió, y una prueba anotada ahí sería indistinguible de un envío real.

Además, dentro de users hay un sub-objeto emailPreferences (serviceUpdates, promotions, consentRecordedAt, updatedAt). Se escribe siempre campo a campo ($set de emailPreferences.promotions, nunca del objeto completo): un $set del sub-objeto entero borraría consentRecordedAt, que es el respaldo de que la persona aceptó recibir.

25 colecciones en total. Las más voluminosas a futuro: cards (catálogo), listings, messages. Las más críticas para integridad: orders, payments, wallet_entries (ledger).