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.
Colecciones por dominio
Sección titulada «Colecciones por dominio»Catálogo
Sección titulada «Catálogo»Catálogo de TCGs habilitados (Pokémon, One Piece, etc.).
| Campo | Tipo | Nullable | Default | Descripción |
|---|---|---|---|---|
_id | String | no | — | Slug del TCG (pokemon, one-piece, etc.) |
slug | String | no | — | Slug (mismo valor que _id) — único |
name | String | no | — | Nombre display |
description | String | no | '' | Descripción corta |
enabled | Boolean | no | true | Si está habilitado |
source | String | no | — | 'tcgplayer' u otro |
external | Mixed | no | {} | Metadata de la fuente externa |
Sin índices explícitos (unique en slug).
Sets de cada TCG (e.g. “Scarlet & Violet 151”).
| Campo | Tipo | Nullable | Default | Descripción |
|---|---|---|---|---|
_id | String | no | — | {tcg}-{slug} |
tcg | String | no | — | Slug del TCG (índice) |
slug | String | no | — | Slug del set |
name | String | no | — | Nombre |
code | String | sí | — | Código corto |
releaseDate | Date | sí | — | Fecha lanzamiento |
cardsCount | Number | no | 0 | Cuántas cartas tiene |
pricesRefreshedAt | Date | sí | null | Última vez que el job refresh-prices actualizó los precios de las cartas del set (oldest-first). Independiente de lastSyncedAt. |
showcaseImages | String[] | 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. |
showcaseTile | String | sí | null | Franja 16:10 recortada al arte, para la baldosa del riel. |
showcaseBackdrop | String | sí | null | Miniatura de 64px que el destacado dibuja desenfocada de fondo. |
showcaseCheckedAt | Date | sí | null | Última pasada del job mirror-set-art, haya conseguido arte o no. Distingue “revisado y no había” de “nunca mirado”. |
showcaseCardsCount | Number | sí | null | Cuá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.
| Campo | Tipo | Nullable | Default | Descripción |
|---|---|---|---|---|
_id | String | no | — | {tcg}-{externalProductId} |
tcg | String | no | — | Slug TCG |
setId | String | no | — | Ref al set |
setSlug / setName | String | no | — | Denormalizado para queries |
name | String | no | — | Nombre completo (puede incluir variante) |
baseName | String | no | — | Nombre canónico para autocomplete |
number | String | sí | — | Nú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"] |
rarity | String | sí | — | Rareza |
imageUrl | String | no | — | URL del catálogo (TCGplayer/CDN) |
marketPrices | Sub-doc | sí | — | Precios 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. |
languages | string[] | 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 |
listingsCount | Number | no | 0 | Resumen 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) |
minPrice | Number | sí | null | Resumen: precio mínimo (CLP cents) entre las publicaciones activas |
totalStock | Number | no | 0 | Resumen: suma de quantity de las publicaciones activas |
inStock | Boolean | no | false | Resumen: 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) |
latestListedAt | Date | sí | null | Resumen: createdAt más reciente entre las activas (orden “recientes” del feed) |
releaseDateAt | Date | sí | null | Derivado 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 (24↔024); 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).
sync_reports
Sección titulada «sync_reports»Reportes de sincronización con TCGplayer (auditoría de jobs).
Índices: {source:1}, {tcg:1}.
Listings y carrito
Sección titulada «Listings y carrito»listings
Sección titulada «listings»Publicaciones de los vendedores. Discriminated union por kind.
Campos comunes a todos los kinds:
| Campo | Tipo | Nullable | Descripción |
|---|---|---|---|
_id | String | no | listing-{nanoid} |
sellerId | String | no | Ref al user |
kind | enum card/accessory/bulk/sealed/deck | no | Tipo de producto |
price | Number | no | Precio en CLP cents (mín 1) |
currency | enum CLP | no | Solo CLP por ahora |
quantity | Number | no | Stock disponible (mín 1) |
photos | [String] | no | Hasta N fotos (Cloudinary URLs) |
customPhoto | String | sí | Foto custom legacy |
description | String | sí | Hasta 500 chars |
status | enum | no | active / paused / sold / hidden_by_admin / hidden_by_user_suspension |
location | Sub-doc | no | {region, commune} |
autoPrice | Boolean | no | Si la publicación tiene Precios dinámicos activo (se repricea sola a diario). Default false. Solo aplica a cartas de catálogo TCGplayer |
autoPricedAt | Date | sí | Última vez que el job de precios dinámicos le escribió el precio (null si nunca lo hizo) |
soldCount, lastSoldAt | Number / Date | no / 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, cardAttributes | varios | sí | Denormalizados 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} |
autoPriceSkipped | Sub-doc | sí | {reason, at} — por qué el job la saltó: reason ∈ below_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 tenerphotos(0-2) opcionales: fotos propias del vendedor de su carta real (frente/dorso).customPhotoes el legacy de 1 sola foto.accessory:accessoryType,accessoryCondition,accessoryName,tcg.bulk:bulkComposition,bulkCondition,bulkLanguages([]),cardsPerLot,bulkTitle.sealed:sealedTitle,sealedLanguage.deck:deckTitle,deckCardCount,tcg. Reutilizadescription(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
conditionnilanguage— 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.
| Campo | Tipo | Descripción |
|---|---|---|
_id | String | userId |
sellerId | String | legacy, sin uso — el carrito ahora acepta múltiples vendedores (ver checkouts) |
items | array | {listingId, quantity} |
Órdenes y pagos
Sección titulada «Órdenes y pagos»Las órdenes generadas en checkout.
| Campo | Tipo | Descripción |
|---|---|---|
_id | String | order-{nanoid} — interno, en URLs |
displayId | String | 8 chars + dash — visible para el usuario |
buyerId, sellerId | String | refs |
items | array | snapshot 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) |
totalPrice | Number | suma de subtotales en CLP cents |
buyerAddress | Sub-doc | snapshot 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 |
status | enum | awaiting_payment/pending/accepted/shipped/completed/canceled |
deliveryMethod | enum | shipping/in_person/pickup/null |
pickupPoint | Sub-doc | Mé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, mpInitPoint | — | refs a payments |
checkoutId | String | sí; 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, warningEmailSentAt | — | tracking |
acceptedAt/shippedAt/completedAt/canceledAt | Date | timestamps |
Í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).
payments
Sección titulada «payments»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).
checkouts
Sección titulada «checkouts»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.
| Campo | Tipo | Descripción |
|---|---|---|
_id | String | checkout-{nanoid} — es el external_reference que viaja a MP |
buyerId | String | ref |
orderIds | [String] | las N sub-órdenes (una por vendedor) |
totalPrice | Number | suma de subtotales en CLP cents (= monto cobrado en MP) |
mpPreferenceId, mpPaymentId, mpFee, netAmount, mpStatus | — | datos MP |
status | enum | awaiting_payment/paid/expired/failed/refunded |
paymentTimeoutAt, webhookReceivedAt | Date | tracking |
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.
conversations + messages
Sección titulada «conversations + messages»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}.
Disputes, reviews, reports
Sección titulada «Disputes, reviews, reports»disputes
Sección titulada «disputes»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).
reviews
Sección titulada «reviews»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}.
reports
Sección titulada «reports»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}.
Wallet, withdrawals, cuentas bancarias
Sección titulada «Wallet, withdrawals, cuentas bancarias»wallet_entries
Sección titulada «wallet_entries»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).
withdrawals
Sección titulada «withdrawals»Solicitudes de retiro de wallet a cuenta bancaria.
Estados: pending/processing/completed/canceled.
Índices: {userId:1, createdAt:-1}, {status:1, createdAt:-1}.
bank_accounts
Sección titulada «bank_accounts»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.
Usuarios y moderación
Sección titulada «Usuarios y moderación»Cuentas de usuario.
| Campo | Tipo | Descripción |
|---|---|---|
_id | String | user-{nanoid} |
email | String | unique, lowercase |
username | String | unique |
name, avatarUrl | — | perfil |
coverUrl | String | null | Portada 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, facebookUrl | String | null | presentació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() |
authProviders | array | google por ahora |
region, commune, street, houseNumber, phone, rut | — | dirección + RUT (privados; rut normalizado "12345678-5", solo se comparte con el vendedor al comprarle) |
contactPreferences, notificationPreferences | Sub-doc | toggles |
autoPricing | Sub-doc | Config 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 |
sellerDelivery | Sub-doc | Mé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) |
reviewStats | Sub-doc | rating promedio + count |
isAdmin | Boolean | flag admin |
status | enum | active/warned/suspended/banned |
suspendedUntil, warningsCount, lastWarningAt, warningAcknowledgedAt | — | moderación |
walletBalance | Number | balance actual (denormalizado del ledger) |
isSystem | Boolean | la cuenta system de la plataforma |
Índices: {email:1} (unique), {username:1} (unique), {status:1, suspendedUntil:1} (partial para auto-unsuspend).
homepageBanners
Sección titulada «homepageBanners»Banners visuales para el home.
Campos: imageUrl, imageScale/Offset, CTA primary/secondary, dark variant, status (draft/published/archived), order.
Índices: {status:1, order:1}.
adminActions
Sección titulada «adminActions»Audit log de acciones de admin.
Índices: {adminId:1, createdAt:-1}.
Scanner
Sección titulada «Scanner»scanner_usage
Sección titulada «scanner_usage»Contador diario de requests al OCR de OCR.space (para vigilar el cupo del free tier). Definida en src/scanner/persistence/ScannerUsageSchema.ts.
| Campo | Tipo | Default | Descripción |
|---|---|---|---|
_id | String | — | Fecha YYYY-MM-DD en UTC (la cuota de OCR.space resetea 00:00 UTC) |
count | Number | 0 | Requests consumidos ese día. $inc atómico (findOneAndUpdate + upsert) por cada scan |
alertedAt80 | Boolean | false | Si 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.
batchidempotencies
Sección titulada «batchidempotencies»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.
| Campo | Tipo | Default | Notas |
|---|---|---|---|
key | String | — | ${sellerId}:${clientRef}. Índice único |
sellerId | String | — | Vendedor dueño del registro |
listingId | String | — | Publicación creada (o fusionada) para ese clientRef |
createdAt | Date | now | Í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íaListingService.createpor item; al alcanzarlo, los items restantes vuelven con errordaily_cap.
Correo masivo
Sección titulada «Correo masivo»marketingSuppressions
Sección titulada «marketingSuppressions»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.
| Campo | Tipo | Default | Descripción |
|---|---|---|---|
email | String | — | Dirección en minúsculas (lowercase: true). Índice único |
reason | String | — | bounce (rebote duro) o complaint (nos marcó como spam) |
detail | String | null | Subtipo que reportó SES (Permanent, abuse, …) |
createdAt | Date | now | — |
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.
marketingCampaigns
Sección titulada «marketingCampaigns»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.
| Campo | Tipo | Default | Descripción |
|---|---|---|---|
_id | String | — | campaign-{nanoid} |
subject | String | — | Asunto |
category | String | — | promotional · service_update · essential |
markdownBody | String | — | Cuerpo tal como lo escribió el autor |
authorId | String | — | Quién la envió |
recipientCount | Number | 0 | Audiencia resuelta al momento de enviar |
sentCount / failedCount | Number | 0 | Resultado real |
status | String | sending | sending · sent · failed. Si no salió ni un correo queda failed, no sent |
createdAt / finishedAt | Date | — | Í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.
Resumen
Sección titulada «Resumen»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).