import { defineConfig, loadEnv } from "@medusajs/framework/utils" loadEnv(process.env.NODE_ENV || "development", process.cwd()) const required = (name: string): string => { const value = process.env[name] if (!value) { throw new Error(`La variable ${name} es obligatoria.`) } return value } // Mismos campos derivados de metadata/categories/collection/variants que // mapProduct() en workloads/ecommerce/lib/headless/providers/medusa.ts -- // el documento indexado debe reflejar exactamente lo que el storefront // ya muestra, no una vista distinta del catálogo. function stringMetadata( metadata: Record | null | undefined, key: string, ): string { const value = metadata?.[key] return typeof value === "string" ? value : "" } function booleanMetadata( metadata: Record | null | undefined, key: string, ): boolean { const value = metadata?.[key] return value === true || value === "true" } async function meilisearchProductTransformer( document: Record, ) { const metadata = (document.metadata ?? null) as Record< string, unknown > | null const categories = document.categories as | Array<{ name?: string | null }> | null | undefined const collection = document.collection as { title?: string | null } | null const variants = document.variants as | Array<{ inventory_quantity?: number | null manage_inventory?: boolean | null allow_backorder?: boolean | null prices?: Array<{ amount?: number | null currency_code?: string | null }> | null }> | null | undefined const variant = variants?.[0] const tags = document.tags as Array<{ value?: string | null }> | null const category = categories?.[0]?.name || stringMetadata(metadata, "category") || "Variedades" const available = !variant || variant.allow_backorder === true || variant.manage_inventory === false || (variant.inventory_quantity ?? 0) > 0 // variants.calculated_price requiere currency_code/region_id en el // contexto de pricing, que el workflow de sync interno del plugin no // provee -- hace fallar TODO el job de indexado (confirmado en vivo: // "Method calculatePrices requires currency_code in the pricing // context"). variants.prices es la lista cruda de precios (sin // resolver promociones/reglas), no necesita contexto -- alcanza para // el filtro/orden por precio en la busqueda, que no necesita el // precio final promocional. const copPrice = variant?.prices?.find( (price) => price.currency_code?.toLowerCase() === "cop", ) return { id: document.id, name: document.title || "Producto ARI Shopping", shortDescription: document.subtitle || document.title || "", description: document.description || "", category, collection: collection?.title || stringMetadata(metadata, "collection") || "", brand: stringMetadata(metadata, "brand") || "ARI Shopping", ageRange: stringMetadata(metadata, "ageRange") || "Todas", tags: (tags || []).map((tag) => tag.value).filter(Boolean), price: typeof copPrice?.amount === "number" ? copPrice.amount : null, available, isNew: booleanMetadata(metadata, "isNew"), featured: booleanMetadata(metadata, "featured"), } } module.exports = defineConfig({ projectConfig: { databaseUrl: required("DATABASE_URL"), redisUrl: required("REDIS_URL"), workerMode: (process.env.MEDUSA_WORKER_MODE || "shared") as | "shared" | "worker" | "server", http: { storeCors: required("STORE_CORS"), adminCors: required("ADMIN_CORS"), authCors: required("AUTH_CORS"), jwtSecret: required("JWT_SECRET"), cookieSecret: required("COOKIE_SECRET"), }, // Todos los Ingress del lab terminan en Traefik por HTTP plano // (entrypoint "web", sin TLS); Cloudflare es quien atiende HTTPS de // cara al navegador. Con `secure: true` (el default en producción), // express-session nunca emite Set-Cookie porque no detecta la // conexión como HTTPS, y el dashboard admin queda con 401 en // /admin/users/me pese a loguear bien. La API con Bearer token no se // ve afectada por esto. cookieOptions: { secure: false, }, }, admin: { disable: process.env.DISABLE_MEDUSA_ADMIN === "true", backendUrl: process.env.MEDUSA_BACKEND_URL || "https://commerce.cruzcloud.net", storefrontUrl: process.env.STOREFRONT_URL || "https://shop.cruzcloud.net", }, modules: [ // ADR 0009 §2 / EnvíoClick: defineConfig() ya registra por defecto // un modulo de fulfillment con el provider manual_manual // (confirmado leyendo @medusajs/utils/dist/common/define-config.js // directo) -- pero como este bloque propio comparte el mismo tipo de // modulo, transformModules() lo pisa por completo (indexa por // serviceName, el ultimo que llega gana), no lo mezcla. Por eso // manual_manual va explicito ACA tambien: si se omitiera, la // shipping option existente que ya usa ese provider se rompe en // silencio. envioclick-fulfillment implementa el mismo contrato // (AbstractFulfillmentProviderService) contra // developers.envioclickpro.com.co, verificado en vivo (no solo doc) // con una llamada de prueba real que devolvio tarifas reales de // varias transportadoras. { resolve: "@medusajs/medusa/fulfillment", options: { providers: [ { resolve: "@medusajs/medusa/fulfillment-manual", id: "manual", }, { resolve: "./src/modules/envioclick-fulfillment", id: "standard", options: { apiKey: required("ENVIOCLICK_API_KEY"), sandbox: process.env.ENVIOCLICK_SANDBOX !== "false", originDaneCode: process.env.ENVIOCLICK_ORIGIN_DANE_CODE || "76001000", originAddress: required("ENVIOCLICK_ORIGIN_ADDRESS"), originCompany: process.env.ENVIOCLICK_ORIGIN_COMPANY || "ARI Shopping", originFirstName: process.env.ENVIOCLICK_ORIGIN_FIRST_NAME || "ARI", originLastName: process.env.ENVIOCLICK_ORIGIN_LAST_NAME || "Shopping", originEmail: required("ENVIOCLICK_ORIGIN_EMAIL"), originPhone: required("ENVIOCLICK_ORIGIN_PHONE"), }, }, ], }, }, { resolve: "@medusajs/medusa/file", options: { providers: [ { resolve: "@medusajs/medusa/file-s3", id: "s3", options: { file_url: required("S3_FILE_URL"), access_key_id: required("S3_ACCESS_KEY_ID"), secret_access_key: required("S3_SECRET_ACCESS_KEY"), region: process.env.S3_REGION || "us-east-1", bucket: required("S3_BUCKET"), endpoint: required("S3_ENDPOINT"), cache_control: "public, max-age=31536000, immutable", additional_client_config: { forcePathStyle: true, }, }, }, ], }, }, { resolve: "@medusajs/medusa/auth", options: { providers: [ // emailpass se declara explicito junto con google -- una vez // que el modulo auth se customiza, la doc no deja claro si el // provider default (emailpass) se sigue registrando solo. { resolve: "@medusajs/medusa/auth-emailpass", id: "emailpass", options: {}, }, { resolve: "@medusajs/medusa/auth-google", id: "google", options: { clientId: required("GOOGLE_CLIENT_ID"), clientSecret: required("GOOGLE_CLIENT_SECRET"), callbackUrl: required("GOOGLE_CALLBACK_URL"), }, }, ], }, }, // ADR 0009: proveedor de pago -- tercera vuelta. PayU LatAm fue la // decision original, pero Rapyd compro ese negocio en 2023 y el // registro real del usuario quedo con credenciales de Rapyd // (incompatibles, API completamente distinta). Se evaluo Rapyd en // vivo y se termino descartando: comision mas alta que otras // opciones para el volumen real del usuario. ePayco elegido en su // lugar -- 100% de Davivienda desde 2024, comision PSE mas baja, // tarifa preferencial con cuenta Davivienda (que el usuario ya // tiene), sin exigir Bancolombia/Nequi como si exige Wompi. Tampoco // existe plugin real de Medusa para ePayco, modulo custom propio. // Alcance inicial: solo PSE, no tarjetas/otros metodos. // provider_id resultante: "pp_epayco_pse" (identifier "epayco" + id // "pse", convencion de Medusa). { resolve: "@medusajs/medusa/payment", options: { providers: [ { resolve: "./src/modules/epayco-pse", id: "pse", options: { publicKey: required("EPAYCO_PUBLIC_KEY"), privateKey: required("EPAYCO_PRIVATE_KEY"), pCustIdCliente: required("EPAYCO_P_CUST_ID_CLIENTE"), pKey: required("EPAYCO_P_KEY"), env: process.env.EPAYCO_ENV || "sandbox", responseUrl: required("EPAYCO_RESPONSE_URL"), notifyUrl: required("EPAYCO_NOTIFY_URL"), }, }, ], }, }, ], // ADR 0006 Etapa 2. Sin required(): a diferencia de S3/DB, Meilisearch // es un servicio nuevo y todavia no probado en produccion en un host // con historial de swap saturado -- si esta caido o mal configurado, // el plugin se degrada solo (soporta enabled:false, "silently skipped" // segun su propio README) en vez de tumbar el arranque de Medusa // entero, que es lo que si haria required(). plugins: [ { resolve: "@rokmohar/medusa-plugin-meilisearch", options: { config: { host: process.env.MEILISEARCH_HOST ?? "", apiKey: process.env.MEILISEARCH_API_KEY ?? "", }, settings: { products: { type: "products", enabled: Boolean(process.env.MEILISEARCH_HOST), primaryKey: "id", // Campos que el plugin pide via query.graph antes de pasarlos // al transformer de arriba -- si falta alguno acá, llega // undefined al transformer sin importar lo que este haga. fields: [ "id", "title", "subtitle", "description", "handle", "status", "thumbnail", "metadata", "tags.value", "categories.name", "collection.title", "variants.inventory_quantity", "variants.manage_inventory", "variants.allow_backorder", "variants.prices.amount", "variants.prices.currency_code", ], indexSettings: { // Tabla de ADR 0006 §3: Buscable / Filtrable-faceta / Ordenable. searchableAttributes: [ "name", "shortDescription", "description", "category", "collection", "brand", "tags", ], filterableAttributes: [ "category", "collection", "brand", "ageRange", "price", "available", ], sortableAttributes: ["price", "isNew", "featured"], }, transformer: meilisearchProductTransformer, }, }, }, }, ], })