node.jsshopifyangularmysqlexpressjwtwebhookscase-studyecommerce

Case study: integración e-commerce Shopify con Node.js, MySQL y Angular

El cliente tenia una tienda Shopify con decenas de miles de productos y un equipo interno que gestionaba inventario en hojas de cálculo. Necesitaba un dashboard propio — con roles, reportes, y sincronización en tiempo real con Shopify — sin depender del panel de Shopify para operaciones diarias. El proyecto resultó en una plataforma full-stack de 7-12k LOC, 25-35 endpoints REST y una integración bidireccional que tomé más en serio de lo que esperaba.


Arquitectura general

Antes de entrar en los problemas interesantes, el mapa completo:

graph TD
    subgraph Frontend ["Frontend — Angular 17"]
        A[Tablas productos / PrimeNG]
        B[Forms pedidos / Reactive Forms]
        C[Dashboard inventario / Material Design]
    end

    subgraph Backend ["Backend — Node.js + Express 4.18"]
        D[AuthService — JWT]
        E[ShopifyService — SDK 11.x]
        F[InventoryService — sync bidireccional]
        G[ExcelExportService — xlsx / excel4node]
        H[FileUploadService — multer + formidable]
        M[Middleware: Helmet, rate-limit, JWT guard]
    end

    subgraph DB ["Database — MySQL 8.0"]
        I[(products)]
        J[(orders)]
        K[(inventory)]
        L[(export_logs)]
        N[(users)]
    end

    subgraph Shopify ["Shopify Platform"]
        O[Admin GraphQL API]
        P[Webhooks]
        Q[OAuth App]
    end

    A -->|HTTP Axios| M
    B -->|HTTP Axios| M
    C -->|HTTP Axios| M
    M --> D
    M --> E
    M --> F
    M --> G
    M --> H
    E <-->|GraphQL cursor pagination| O
    P -->|product/order events| E
    F <--> I
    F <--> K
    G --> L
    H --> I
    D --> N
    E --> J

Stack completo: Express 4.18, @shopify/shopify-api 11.x, @shopify/shopify-app-express 4.1, MySQL 8.0 con driver mysql2, Angular 17.3, Angular Material 17, PrimeNG 17. Para exports: xlsx 0.18 para leer + excel4node para escribir. Auth: jsonwebtoken 9.x + Passport.js. Seguridad desde el inicio: Helmet 8.1, bcrypt 5.1, express-rate-limit 8.1.


La integración Shopify: OAuth app, no API key simple

El primer punto de fricción fue elegir cómo conectar con Shopify. Hay dos caminos: API key privada (más simple, solo funciona en una tienda) o OAuth app (instalable en cualquier tienda, con flujo de instalación completo). Elegí la app OAuth oficial con el SDK de Shopify porque el cliente eventualmente quería escalar a otras tiendas.

El SDK maneja bastante del flujo: la redirección a Shopify, el intercambio de código por token de acceso, y la verificación de sesiones. Pero hay detalles que el SDK no resuelve por ti:

Webhook signature verification. Shopify firma cada webhook con HMAC-SHA256 usando el secret de la app. Si no verificas la firma, cualquiera puede enviar payloads a tu endpoint. El middleware quedó así en esencia:

// Dentro del middleware de webhook
const hmac = req.headers['x-shopify-hmac-sha256'] as string;
const body = req.rawBody; // rawBody capturado antes del JSON parser
const computed = crypto
  .createHmac('sha256', process.env.SHOPIFY_WEBHOOK_SECRET!)
  .update(body)
  .digest('base64');

if (!crypto.timingSafeEqual(Buffer.from(hmac), Buffer.from(computed))) {
  return res.status(401).json({ error: 'Invalid webhook signature' });
}

El truco con rawBody: el JSON parser de Express consume el stream, así que hay que capturar el body crudo antes de que lo procese. Configuré un middleware que guarda req.rawBody antes de express.json().

Subscripción a webhooks programática. Al instalar la app, suscribí los topics relevantes via SDK: products/update, products/delete, orders/create, orders/paid. Cada topic apunta a su propio endpoint en el backend.


El problema central: sync bidireccional

Aquí está la parte que tomó más tiempo de diseñar. El cliente quería:

Eso es un sync bidireccional con dos escritores concurrentes. El conflicto clásico: ambos lados actualizan el mismo producto en el mismo minuto. ¿Quién gana?

La decisión que tomé: MySQL como source of truth para inventario interno, Shopify como source of truth para datos del catálogo (precios, descripciones, imágenes). Esto separó las responsabilidades:

Para detectar actualizaciones concurrentes usé un campo updated_at con timestamp y un campo shopify_updated_at que guarda el timestamp del updated_at de Shopify. Al recibir un webhook, comparo:

// En InventoryService
async handleProductWebhook(payload: ShopifyProductPayload) {
  const local = await this.productRepo.findByShopifyId(payload.id);
  
  if (!local) {
    // Producto nuevo en Shopify, insertar en MySQL
    return this.productRepo.create(this.mapShopifyToLocal(payload));
  }

  const shopifyTs = new Date(payload.updated_at).getTime();
  const localShopifyTs = new Date(local.shopify_updated_at).getTime();

  if (shopifyTs <= localShopifyTs) {
    // El webhook es más viejo que lo que ya tenemos — ignorar
    return;
  }

  // Actualizar solo campos de catálogo, preservar stock local
  return this.productRepo.updateCatalogFields(local.id, {
    title: payload.title,
    vendor: payload.vendor,
    shopify_updated_at: payload.updated_at,
    // stock NO se toca aquí
  });
}

Para el caso inverso — propagar stock a Shopify — usé la GraphQL API con cursor pagination porque el catálogo tenia entre 10k y 100k productos y offset pagination en Shopify está deprecado. Cada batch de sync procesa un cursor y guarda el último cursor en DB para poder reanudar si el proceso falla:

// Sync batch con cursor
async syncInventoryBatch(cursor?: string) {
  const query = `
    query getProducts($cursor: String) {
      products(first: 50, after: $cursor) {
        pageInfo { hasNextPage endCursor }
        edges {
          node { id variants(first: 1) { edges { node { id inventoryQuantity } } } }
        }
      }
    }
  `;
  const response = await this.shopifyClient.query({ data: { query, variables: { cursor } } });
  // procesar, actualizar Shopify via mutation, guardar endCursor
}

Excel export a escala: no es un download simple

El cliente necesitaba exportar reportes de inventario — catálogo completo, órdenes por rango de fechas, movimientos de stock. Suena simple hasta que el catálogo tiene 80k filas.

El problema con generar el archivo en memoria de una sola pasada: si el proceso Node usa 500MB generando un Excel, el servidor empieza a tener problemas con requests concurrentes. La solución fue chunking: leer la DB en batches de 1000 filas y escribir al archivo incrementalmente con excel4node.

Usé dos librerías distintas a propósito: xlsx 0.18 para leer archivos que el cliente importa (más robusto parseando formatos legacy), y excel4node para generar los exports (API más limpia para escritura streaming). Trade-off real: mantener dos dependencias para una tarea. Lo acepté porque intentar hacer ambas cosas con xlsx sólo resultó en código más complejo.

Cada export queda registrado en la tabla export_logs con estado (pending, processing, done, failed) y path del archivo generado. El frontend hace polling o recibe notificación via endpoint de estado. Los archivos generados se limpian con un cron después de N horas.


Auth dual: Shopify OAuth vs JWT frontend

Hay dos flujos de autenticación completamente separados en este proyecto:

  1. Shopify OAuth: para instalar la app y obtener el access token de la tienda. Este token se guarda en DB asociado al shop domain. Lo usa el backend para llamar a la API de Shopify.

  2. JWT para el dashboard Angular: los usuarios del dashboard (equipo interno del cliente) se autentican con email/password contra la tabla users en MySQL. El backend genera un JWT con jsonwebtoken, el frontend lo guarda y lo manda en cada request via Authorization: Bearer.

Son concerns completamente distintos y mantenerlos separados fue la decisión correcta. El trade-off: dos sistemas de auth a mantener, dos tipos de sesión a entender cuando hay un bug. En la práctica, los problemas siempre fueron en el JWT side (tokens expirados, refresh logic) — el OAuth de Shopify con el SDK oficial funcionó sólido.

Una advertencia sobre Helmet: la configuración default de Helmet incluye Content Security Policy que bloquea scripts inline. Angular en modo desarrollo usa algunos inline scripts, y en producción con ciertos polyfills también. Tuve que afinar la config de CSP para no romper la app Angular:

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      ...helmet.contentSecurityPolicy.getDefaultDirectives(),
      'script-src': ["'self'", "'unsafe-inline'"], // ajustar según necesidad real
    }
  }
}));

Lo que aprendí sobre integraciones con plataformas de terceros

Construir sobre Shopify SDK oficial tiene un costo de abstracción: el SDK oculta complejidad pero también oculta errores. Cuando algo falla en el OAuth flow, el stack trace pasa por capas del SDK antes de llegar a tu código. Entender qué está haciendo el SDK por debajo ahorra horas de debugging.

La regla que consolidé en este proyecto: en integraciones bidireccionales, define ownership de datos antes de escribir una sola línea de código. Quién es source of truth para cada campo, qué pasa en conflicto concurrente, qué eventos se ignoran — todo eso tiene que estar claro en un documento o comentario de arquitectura antes de que haya dos writers en el mismo dato.

El Excel export “simple” casi siempre no lo es. Si el dataset puede crecer, diseña chunking desde el inicio. Agregar streaming después es más costoso que hacerlo bien la primera vez.

Y sobre la dualidad de auth: no forzar un solo sistema si los casos de uso son distintos. Shopify OAuth existe para el ecosistema Shopify. JWT existe para tus usuarios. Mezclarlos genera complejidad sin beneficio real.

El proyecto quedó en producción con ~7-12k LOC, módulos bien separados via path aliases (@services, @repositories, @controllers), y cobertura de tests con Jest para los servicios críticos y Supertest para los endpoints de auth y sync. El cliente gestiona inventario desde su dashboard propio, los reportes Excel se generan sin timeouts, y los webhooks procesan actualizaciones de Shopify en segundos.

Volver al blog