Commit Graph
17 Commits
Author SHA1 Message Date
devopsandClaude Sonnet 5 c507da8abc feat(fulfillment): integrar EnvíoClick para cálculo real de envío
ADR 0009 §2 ya había anticipado esto: la shipping option actual es un
placeholder de negocio ($15.000 COP fijo), no un cálculo real por
peso/volumen -- y el modelo de direcciones del checkout (IDs DIVIPOLA
en vez de texto libre en lib/data/colombia.ts) ya se había construido
pensando en esta integración. Este commit la implementa.

Investigación en vivo (no solo documentación -- misma disciplina que
ya costó varios rehechos con las pasarelas de pago): la SPA de docs
públicas (apidoc.envioclickpro.com.co) no renderiza sin sesión, pero
está generada con apiDoc, así que se bajó y leyó directo su
api_data.json/api_project.json (spec completa real), y se probó
/quotation en vivo con la API key real del usuario -- 200 OK con
tarifas reales de PIBOX/COORDINADORA/ENVIA/RAPPI/TCC. Esto corrigió
varios datos mal asumidos de un plugin de WordPress desactualizado
usado como referencia inicial (API v1): la URL base real es
api.envioclickpro.com.co/api/v2 (no api.envioclickpro.com a secas), y
el header de auth real es "Authorization" a secas, no
"AuthorizationKey".

Hallazgo crítico evitado leyendo el código fuente de
@medusajs/utils/dist/common/define-config.js: defineConfig() ya
registra por defecto un módulo de fulfillment con el provider
manual_manual, aunque medusa-config.ts no lo mencione explícitamente
-- pero transformModules() indexa por serviceName y el último que
llega pisa al anterior (no mezcla providers). Agregar un bloque propio
de fulfillment sin incluir también manual_manual ahí habría roto en
silencio el envío manual que ya usa la shipping option existente.
Verificado cargando la config compilada real: ambos providers quedan
registrados juntos.

- src/modules/envioclick-fulfillment/: AbstractFulfillmentProviderService.
  calculatePrice() cotiza en vivo durante el checkout -- Medusa advierte
  explícitamente que un error acá bloquea el checkout completo, así que
  NUNCA se deja propagar una excepción: cualquier falla (red, dirección
  sin id DIVIPOLA, EnvíoClick caído) cae al precio fijo de $15.000 que
  ya se usaba, para no romper la compra. createFulfillment() vuelve a
  cotizar en el momento de generar la guía real en vez de reusar el
  idRate mostrado en checkout (más correcto: CalculatedShippingOptionPrice
  no tiene forma de persistir ese id entre llamadas, confirmado leyendo
  el tipo real, y un idRate viejo podría estar vencido si el fulfillment
  pasa horas/días después). cancelFulfillment() sí cancela de verdad
  (cancellation/batch/order existe y está confirmado, a diferencia del
  refund de ePayco que quedó sin implementar).
- Solo 1 paquete por cotización/guía (límite real de la API, confirmado:
  "min 1, max 1") -- el carrito se agrega a un solo paquete. Como hoy
  NINGÚN producto tiene peso/dimensiones cargados (confirmado en vivo:
  18 productos activos, 0 con weight), se usa un paquete de fallback
  conservador completo en vez de mezclar datos reales con inventados a
  medias -- la cotización no va a ser precisa hasta que se carguen esos
  datos (tarea de datos, no de código, fuera de alcance de este commit).
- Frontend: cambio chico pero necesario en checkout-client.tsx/
  medusa-cart.ts -- el checkout mandaba el nombre de la ciudad a Medusa,
  nunca el id DIVIPOLA (aunque el form sí lo tenía disponible). Hay
  municipios con el mismo nombre en distintos departamentos de Colombia,
  así que el nombre solo no alcanza para identificar la ciudad sin
  ambigüedad -- se guarda el id real en la metadata de la dirección
  (Medusa sí tiene ese campo), que el fulfillment provider lee para
  derivar el código DANE real que pide EnvíoClick (5 dígitos + "000" de
  relleno, confirmado contra su propio ejemplo: "11001000" = Bogotá).

Pendiente de verificar con una llamada real (no se quiso generar una
guía de prueba en la cuenta real del usuario sin avisar): /shipment_sandbox
y cancellation/batch/order, verificados contra la spec oficial real
pero no probados en vivo todavía.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-08 21:11:43 +00:00
devopsandClaude Sonnet 5 12c4afa09c fix(payments): reemplazar PayU LatAm por ePayco como pasarela PSE
PayU LatAm (integrado en el commit anterior) resultó inviable: Rapyd
compró ese negocio completo en LatAm/África en 2023, y el registro real
del usuario quedó en la plataforma de Rapyd (board.rapyd.net,
credenciales access_key/secret_key incompatibles con el módulo ya
construido, API completamente distinta). Se evaluó reconstruir contra
Rapyd, pero el usuario comparó costos para su volumen real (~8
ventas/mes) y encontró que ePayco cobra menos en PSE. Se investigó
ePayco en vivo: es 100% de Davivienda desde 2024, acepta liquidación a
cualquier banco colombiano (no exige Bancolombia/Nequi como Wompi) y da
tarifa preferencial a cuentas Davivienda -- el usuario ya tiene una.
Mejor encaje de negocio y más barato que PayU/Rapyd. Confirmado como
elección definitiva.

- src/modules/epayco-pse/ reemplaza a src/modules/payu-pse/: mismo
  contrato AbstractPaymentProvider (initiatePayment/authorizePayment
  "pending_authorization"/getWebhookActionAndData con firma SHA256
  verificada), pero envuelve el SDK oficial `epayco-sdk-node` en vez de
  llamar la API cruda -- leyendo el código fuente del SDK (no la
  documentación pública, que resultó desactualizada en más de un campo)
  se confirmó que el protocolo real de ePayco para PSE es bastante más
  particular de lo que sugieren los ejemplos: cada campo del payload se
  cifra con AES usando la private key del comercio, más un login previo
  que da un bearer token que también viaja en la misma request.
  Reimplementar esto a mano tenía mucho más riesgo de error que delegar
  en el SDK mantenido por el propio proveedor.
- Corregido en el camino: el método real para listar bancos es
  `bank.getBanks()`, no `pseBank()` (así aparecía en un resumen de
  búsqueda, pero no existe en el SDK real -- confirmado leyendo
  node_modules/epayco-sdk-node/lib/resources/bank.js directamente). El
  campo real para la URL de confirmación es `method_confirmation`
  (con guión bajo en medio), no `metodoconfirmacion` como mostraba la
  documentación pública -- confirmado contra tests/bank.js del propio
  paquete.
- retrievePayment sí se implementó esta vez (endpoint de validación de
  ePayco documentado); refundPayment sigue sin implementar -- ese
  endpoint no quedó verificado en vivo, tira error explícito en vez de
  fingir éxito.
- Frontend: el step de pago recupera el selector de banco + tipo de
  persona + documento (a diferencia del diseño que se había planeado
  para Rapyd, donde esos datos se recolectaban en la página del propio
  Rapyd/SafetyPay) -- ePayco sí necesita esos campos de nuestro lado,
  como PayU.
- Dos puntos quedan sin verificar contra un sandbox real (anotados en
  el código con qué hacer si fallan): el nombre exacto del campo con la
  URL del banco en la respuesta de bank.create() (initiatePayment tira
  un error con el payload completo si no matchea ningún candidato
  probado), y si p_cust_id_cliente/p_key del webhook son iguales a las
  API keys o valores aparte del dashboard de ePayco.

Verificado: build real de Medusa con las 7 env vars EPAYCO_* dummy,
smoke test del wrapper del SDK cargando y la verificación de firma
dando el resultado esperado contra un caso conocido, typecheck+lint+
build completo del frontend.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-07 03:58:33 +00:00
devopsandClaude Sonnet 5 2ceea23f40 feat(payments): integrar PayU LatAm (PSE) como pasarela de pagos
ADR 0009 ya había decidido PayU LatAm como proveedor (única opción que
no exige cuenta Bancolombia/Nequi) y confirmado que no existe ningún
plugin real de Medusa para su API -- los paquetes que aparecen
buscando "PayU" son para PayU Polonia/CEE, API completamente distinta
(clientId/posId/secondKey vs. apiLogin/apiKey/merchantId/accountId).
Este commit implementa esa decisión: módulo de pago custom
(AbstractPaymentProvider) para PSE (transferencia bancaria), primer
alcance del método de pago -- Nequi/Daviplata quedan fuera por ahora.

Backend (commerce-backend):
- src/modules/payu-pse/: initiatePayment arma y postea SUBMIT_TRANSACTION
  contra la API real de PayU LatAm; authorizePayment devuelve
  "pending_authorization" (PSE es async, la confirmación real llega por
  webhook, no en el momento de completar el carrito); getWebhookActionAndData
  valida la firma MD5 del webhook antes de confiar en cualquier
  confirmación de pago -- falla cerrado si no matchea. refundPayment/
  retrievePayment tiran error explícito en vez de simular éxito: esos
  endpoints de PayU no quedaron verificados en vivo y hay dinero real de
  por medio.
- No hace falta un subscriber propio para completar la orden -- el
  subscriber nativo de Medusa (payment-webhook + process-payment-workflow)
  ya hace ese trabajo con los métodos de arriba bien implementados
  (confirmado leyendo el código fuente instalado de @medusajs/medusa
  2.18.0, no la documentación).
- Correlación webhook↔sesión: Medusa inyecta su propio session_id en
  `data` antes de initiatePayment; se usa ese mismo valor como
  referenceCode de PayU, así el reference_sale que vuelve en el webhook
  ya es directamente el session_id de Medusa.
- src/lib/payu.ts: cliente HTTP + firmas (orden y webhook, fórmulas
  distintas, confirmadas contra el SDK PHP oficial de PayU).
- src/api/store/payments/pse-banks/route.ts: lista de bancos PSE,
  cacheada en memoria.
- medusa-config.ts, Dockerfile, configmap/secrets.template: 7 env vars
  nuevas (PAYU_API_LOGIN/API_KEY/MERCHANT_ID/ACCOUNT_ID/ENV/RESPONSE_URL/
  NOTIFY_URL), dummies de build agregados, secrets con placeholder
  "CAMBIAR".

Frontend (ecommerce):
- Nuevo step "payment" en components/checkout/checkout-client.tsx
  (reemplaza al step "review" que solo ofrecía WhatsApp): selector de
  banco, tipo de persona (N/J), tipo/número de documento, botón "Pagar
  con PSE" que crea la sesión de pago, completa el carrito y redirige
  al banco (navegación completa, no iframe -- así lo exige PayU).
  WhatsApp queda como respaldo manual si falla la iniciación del pago.
- lib/headless/providers/medusa-payment.ts + 3 rutas proxy nuevas bajo
  app/api/cart/, mismo patrón que el resto del carrito
  (medusaCartFetch, ahora exportado desde medusa-cart.ts).
- app/checkout/pse/return/: página de vuelta del banco -- nunca afirma
  éxito, la confirmación real es el webhook async.

Arranca contra las credenciales de sandbox genéricas y públicas de
PayU (developers.payulatam.com) mientras el usuario termina su propio
registro de comercio -- swap de las 4 credenciales via secret cuando
estén listas, sin cambios de código.

Verificado: build real de Medusa con las env vars dummy (no solo tsc),
tipos de AbstractPaymentProvider/WebhookActionResult/PaymentActions
confirmados contra @medusajs/utils y @medusajs/types instalados
(node_modules limpiado después), typecheck + lint + build completo del
frontend, páginas nuevas renderizando sin error. Pendiente: prueba
end-to-end contra el sandbox real de PayU (necesita el backend
desplegado) y habilitar el provider en la región Colombia desde el
Admin.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-07 01:17:22 +00:00
devops 77389aea19 feat(ecommerce): login con Google para clientes
Implementa el flujo completo de OAuth social de Medusa para el
storefront: boton "Continuar con Google" en account-dialog, ruta proxy
que arranca el flujo (GET /api/customer/google/start) y pagina de
callback en /auth/google/callback (coincide con GOOGLE_CALLBACK_URL del
secret) que resuelve el code/state contra el backend, decodifica el JWT
para distinguir cliente nuevo vs existente, crea el customer si hace
falta y refresca el token a sesion final -- mismo patron de
store/customer-store.ts que login/register por email.

Flujo verificado en vivo contra el backend ya desplegado (GET
/auth/customer/google, /auth/customer/google/callback con state
invalido, /auth/token/refresh con bearer invalido) antes de escribir el
codigo, no solo contra la documentacion.
2026-08-29 17:01:32 +00:00
devops 3b748cc014 feat(ecommerce): cuentas de cliente reales (registro + login)
Conecta el dialogo 'Tu cuenta ARI' (antes solo ofrecia WhatsApp) al
Store API real de Medusa. Flujo verificado en vivo con un cliente de
prueba real antes de escribir el codigo: registro -> crear customer
-> login -> /store/customers/me, todo confirmado funcionando.

Detalle no documentado que se confirmo en vivo: el token que devuelve
/auth/customer/emailpass/register sirve para crear el customer via
POST /store/customers, pero no queda como sesion real -- hay que
loguear de nuevo (/auth/customer/emailpass) para obtener el token de
sesion. Mensajes de error tambien verificados: correo repetido -> 401
'Identity with email already exists'; credenciales invalidas -> 401
'Invalid email or password'.

Mismo patron arquitectonico que el carrito real (ADR 0009): rutas
puente /api/customer/* (MEDUSA_BACKEND_URL es DNS interno, no resuelve
desde el navegador), store de Zustand con sesion persistida y
resincronizada contra el servidor al rehidratar. WhatsApp se mantiene
como opcion secundaria, ya no es la unica salida.
2026-08-29 14:24:43 +00:00
devops bb2c0bbe85 feat(ecommerce): ADR 0009 - checkout con direccion y seleccion de envio
Siguiente corte tras el carrito real: pagina /checkout de 3 pasos
(direccion -> envio -> confirmar) contra el Store API real, usando la
shipping option que ya se verifico en produccion. Endpoints nuevos
(setShippingAddress, getShippingOptions, addShippingMethod) probados
en vivo con un carrito de prueba real antes de escribir el codigo --
direccion, opciones de envio y total ya confirmados correctos
(80.000 + 5.000 = 95.000).

El pago (PayU custom) sigue sin existir, asi que el ultimo paso sigue
siendo WhatsApp -- pero ahora el mensaje incluye direccion, metodo de
envio y total real en vez de solo la lista de productos. El boton del
CartDrawer pasa de abrir WhatsApp directo a llevar a /checkout.
2026-08-29 04:12:11 +00:00
devops b8ce9ded87 fix(ecommerce): carrito muestra la foto real del producto
Reportado en produccion: el carrito mostraba el logo generico de ARI
en vez de la foto del producto. Verificado en vivo: item.thumbnail
viene null en la respuesta de Medusa para todo el catalogo real
(confirmado tambien contra /store/products directamente -- el campo
thumbnail del producto es null en la fuente, no solo omitido en la
respuesta del carrito). El catalogo ya resuelve esto cayendo a
images[0] (mapProduct() en medusa.ts); la linea del carrito necesita
pedir esa relacion explicita via fields=*items.product.images (no
viene por defecto) y aplicar el mismo fallback.
2026-08-29 03:24:09 +00:00
devops 3fc77c6cdc feat(ecommerce): ADR 0009 - carrito real conectado a Medusa (Store API)
Primer corte de checkout real, a proposito acotado: el carrito pasa de
estado 100% local (localStorage con productos completos) a sincronizar
con un cart_id real de Medusa via /api/cart/* (rutas puente
same-origin, mismo patron que /api/search -- MEDUSA_BACKEND_URL es DNS
interno del cluster, no resuelve desde el navegador).

Formas de respuesta de la API verificadas en vivo con un carrito de
prueba real antes de escribir el mapeo (no asumidas contra
documentacion): agregar el mismo variant_id dos veces hace merge de
cantidad automatico (no crea linea duplicada); eliminar una linea
devuelve el carrito actualizado en 'parent', no en 'cart' como los
demas endpoints.

La firma publica de useCartStore (addItem/removeItem/updateQuantity/
items) no cambia, asi que ningun componente consumidor (cart-drawer,
navbar, product-card, product-purchase, mobile-add-to-cart) necesito
tocarse. Sigue cerrando por WhatsApp -- direccion/envio/pago quedan
para los siguientes cortes del ADR 0009.
2026-08-29 02:42:29 +00:00
devops 83d448b282 feat(ecommerce): facetas reales de Meilisearch en el sidebar del catalogo
Fase 3b (ultima) de ADR 0006 Etapa 2: los conteos de categoria/marca/edad
del sidebar de /catalog dejan de ser un snapshot client-side fijo. Ahora
se refetchean en vivo contra /api/search en cada cambio de filtro (query
debounced 300ms), tres llamadas paralelas -- una por dimension, cada una
omitiendo su propio filtro para preservar el patron Amazon de "cuantos
resultados habria si eligiera este otro valor".

- catalog-client.tsx: useMemo client-side originales pasan a ser fallback
  (mock/shopify sin searchWithFacets, o cuando la categoria activa es un
  grupo raiz sintetico que Meilisearch no puede filtrar).
- ProductFilters gana `limit` opcional; /api/search y el provider medusa
  lo reenvian -- las llamadas de solo-conteo piden limit=0 para no
  rehidratar precios de productos que no se van a mostrar.
2026-08-26 22:53:49 -05:00
devops 902350b58b Fase 3a ADR 0006 Etapa 2: buscador del navbar via Meilisearch
PredictiveSearch pasa de filtrar en memoria el arreglo completo de
productos (prop products, substring match) a un fetch debounced
(250ms, con AbortController) contra la nueva ruta /api/search --
mismo componente visual, solo cambia de donde salen los datos, tal
como pide el ADR 0006 seccion 5.

/api/search (nueva, server-side) es el puente same-origin: el
componente es cliente y no puede pegarle directo al provider
server-side (MEDUSA_BACKEND_URL es DNS interno del cluster, no
resuelve desde el navegador).

CatalogProvider gana un metodo OPCIONAL searchWithFacets? (no cambia
la firma de getProducts/searchProducts) -- mock.ts y shopify.ts
compilan sin tocarlos, siguen sin implementarlo. medusa.ts lo
implementa contra /store/search (Fase 2), reutilizando mapProduct()
ya que la ruta devuelve el mismo shape de producto que /store/products.
searchProducts tambien se repunta a /store/search, dejando de usar el
?q= + re-filtro client-side de fetchProducts/matchesFilters para busqueda
(que sigue sirviendo para navegacion sin busqueda, sin cambios).

Navbar deja de pasarle products a PredictiveSearch (ya no los necesita)
pero los sigue recibiendo el mismo -- collections todavia se deriva de
ahi.

Limpieza: borrado patches/frontend-medusa-env.yaml, confirmado
huerfano (ningun kustomization lo referencia; el patch que si esta
vivo es frontend/patches/deployment.yaml).

Validado: build completo de Docker (target builder) -- npm ci,
next build con typecheck, /api/search aparece en la tabla de rutas
generada. Cero errores nuevos (los warnings de ESLint son
preexistentes en catalog-client.tsx, no tocado en esta fase).
2026-08-26 17:30:47 -05:00
devops 8fc9df3b50 feat(ecommerce): mensaje de urgencia por inventario real
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 26s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
Expone ProductVariant.inventoryQuantity (ya calculado desde
variant.inventory_quantity en Medusa, hoy colapsado al booleano
available) y lo usa para mostrar "Quedan N" cuando el stock real es
bajo (<=5), tanto en la tarjeta de producto como en la ficha. null
indica inventario no gestionado (backorder / manage_inventory=false),
donde nunca se muestra un numero. ADR 0005 seccion 3 (patron eBay).

Catalogo mock actualizado (data/products.json) con
inventoryQuantity=null en todas las variantes para seguir tipando
contra Product[] via `satisfies`.
2026-08-25 14:33:14 -05:00
devopsandClaude Sonnet 5 8742ce714c fix(ecommerce): nav y tiles data-driven contra la jerarquia real de categorias
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 35s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
Sub-fase 2 de Etapa 2 (ADR 0005 seccion 6). Navbar y CategoryTiles usaban
arrays hardcodeados (Figuras/Peluches/Mochilas/Marvel) que ya no coinciden
con los nombres reales de categoria en Medusa desde que se elimino
legacyProductMeta (PR #29) -- todos esos links de navegacion quedaron
rotos silenciosamente (0 productos al filtrar).

- lib/headless: nuevo tipo Category + metodo getCategories() en
  CatalogProvider (medusa via /store/product-categories, mock deriva de
  su catalogo plano, shopify sin implementar como el resto de sus metodos).
- Navbar: arbol real de categorias (6 raices/21 nodos), dropdown de
  hijos por raiz en desktop, lista anidada en el sheet movil. Colecciones
  del sheet ahora se derivan de los productos reales en vez de un array
  hardcodeado con 2 nombres obsoletos (Marvel/Frozen vs. los reales
  Universo Marvel/Reino de Frozen).
- CategoryTiles: tiles de home por categoria raiz real; imagen solo donde
  ya existe asset, gradiente honesto donde no (no hay fotografia para las
  6 raices todavia, deuda explicita del ADR).
- catalog-client/catalog page: cuando el query param `category` es una
  raiz con hijos, se expande a los nombres reales de sus hijos para que
  el filtro (igualdad exacta de producto.category) siga funcionando --
  sin esto, tiles/nav apuntando a una raiz mostrarian 0 productos porque
  ningun producto tiene la raiz asignada directamente.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013dunTcReWMKhWgCPkvAVHD
2026-08-24 21:21:18 -05:00
devops 9a8e5d2b04 fix(ecommerce): eliminar legacyProductMeta, datos reales cubren los 17 productos
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 1m6s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
Los 17 productos legacy ya tienen category, collection, brand, ageRange,
imagenes y featured/isNew reales en Medusa (migrados en fix #2/#2b de
Etapa 2 sub-fase 1), por lo que el fallback hardcodeado en mapProduct()
ya no aporta nada y solo duplica datos que pueden desincronizarse.
2026-08-24 18:50:55 -05:00
devops 2693d05d07 fix(ecommerce): sintaxis correcta de +categories en fields de /store/products
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 33s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
+categories sin .* no expande la relacion (Medusa la omite en
silencio del response); +categories.* si trae los sub-campos.
Confirmado en vivo via kubectl exec antes de este commit: demo
con categoria real asignada ahora la trae, legacy sigue en []
(pendiente fix #2, fuera de alcance de este cambio).
2026-08-24 00:08:15 -05:00
devops 60072cfcbe fix: usar region_id en vez de currency_code para precios de Medusa
El validador de /store/products (StoreGetProductsParams, .strict()) solo
acepta region_id/country_code/province/cart_id para resolver contexto de
precio -- currency_code como campo plano no existe y rompía con 400
"Unrecognized fields: 'currency_code'" en cada carga de /catalog.

country_code tampoco alcanza: probado en vivo, devuelve "Missing required
pricing context to calculate prices - region_id". Se necesita region_id
explícito.

Se agrega MEDUSA_REGION_ID (env var) apuntando a la región "Colombia"
(COP) creada vía Admin API, ya que no existía ninguna región configurada
en el backend.
2026-08-12 02:01:49 -05:00
devops aa857bc2ae fase 1
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 9m23s
Build and Push Medusa / Construir y publicar Medusa (push) Failing after 15m12s
2026-07-31 23:27:01 -05:00
devops f263a9cc55 feat(ecommerce): migrate Ari Shopping to Next.js 15 headless storefront v4
Build and Push Frontend / build (push) Failing after 5m39s
2026-07-19 19:37:41 -05:00