In the fourth part of this multi-part series, we present Radixia Maca, our open-source solution for AI-composable commerce.

In this episode, we'll implement the integration with Commerce Layer, a headless e-commerce platform that will handle the product catalog, orders, pricing, and inventory for our solution.

What is Commerce Layer

Commerce Layer is a headless e-commerce platform that provides APIs to manage all aspects of an online store. Unlike traditional monolithic e-commerce systems, Commerce Layer focuses exclusively on the business logic of e-commerce, leaving content management and presentation to other systems like Strapi CMS and our frontend.

Commerce Layer Account Setup

Before getting started, you need to create a Commerce Layer account:

  1. Sign up at Commerce Layer
  2. Create a new organization
  3. Set up your store (market)
  4. Get your API credentials (client ID and client secret)

Integration Between Strapi and Commerce Layer

We've already implemented a Strapi plugin for Commerce Layer integration in the previous section. Now, let's examine how to implement the Lambda function that handles synchronization between the two systems.

Lambda Function for Synchronization

The strapi-cl-sync Lambda function handles bidirectional synchronization between Strapi and Commerce Layer:

// lambda/strapi-cl-sync/index.js
const AWS = require('aws-sdk');
const { SecretsManager } = require('@aws-sdk/client-secrets-manager');
const { DynamoDB } = require('@aws-sdk/client-dynamodb');
const { DynamoDBDocument } = require('@aws-sdk/lib-dynamodb');
const fetch = require('node-fetch');

// Initialize AWS clients
const secretsManager = new SecretsManager();
const dynamoDB = DynamoDBDocument.from(new DynamoDB());

// Function to get secrets from AWS Secrets Manager
async function getSecret(secretArn) {
  const response = await secretsManager.getSecretValue({ SecretId: secretArn });
  return JSON.parse(response.SecretString);
}

// Function to get a Commerce Layer token
async function getCommerceLayerToken(credentials) {
  const { clientId, clientSecret, endpoint } = credentials;
  
  const response = await fetch(`${endpoint}/oauth/token`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Accept': 'application/json'
    },
    body: JSON.stringify({
      grant_type: 'client_credentials',
      client_id: clientId,
      client_secret: clientSecret
    })
  });
  
  const data = await response.json();
  return data.access_token;
}

// Function to get products from Strapi
async function getProductsFromStrapi(strapiCredentials) {
  const { apiKey, endpoint } = strapiCredentials;
  
  const response = await fetch(`${endpoint}/api/products?populate=*`, {
    method: 'GET',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json'
    }
  });
  
  const data = await response.json();
  return data.data;
}

// Function to create or update a product in Commerce Layer
async function upsertProductInCommerceLayer(product, accessToken, commerceLayerCredentials) {
  const { endpoint } = commerceLayerCredentials;
  
  // Check if the product already exists in Commerce Layer
  const checkResponse = await fetch(`${endpoint}/api/skus?filter[code]=${product.attributes.sku}`, {
    method: 'GET',
    headers: {
      'Accept': 'application/vnd.api+json',
      'Authorization': `Bearer ${accessToken}`
    }
  });
  
  const checkData = await checkResponse.json();
  
  if (checkData.data && checkData.data.length > 0) {
    // Update existing product
    const existingProduct = checkData.data[0];
    
    const updateResponse = await fetch(`${endpoint}/api/skus/${existingProduct.id}`, {
      method: 'PATCH',
      headers: {
        'Content-Type': 'application/vnd.api+json',
        'Accept': 'application/vnd.api+json',
        'Authorization': `Bearer ${accessToken}`
      },
      body: JSON.stringify({
        data: {
          type: 'skus',
          id: existingProduct.id,
          attributes: {
            code: product.attributes.sku,
            name: product.attributes.name,
            description: product.attributes.description,
            image_url: product.attributes.image?.data?.attributes?.url || '',
            metadata: {
              category: product.attributes.category?.data?.attributes?.name || '',
              colors: product.attributes.colors || '',
              sizes: product.attributes.sizes || '',
              strapiId: product.id
            }
          }
        }
      })
    });
    
    return await updateResponse.json();
  } else {
    // Create a new product
    const createResponse = await fetch(`${endpoint}/api/skus`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/vnd.api+json',
        'Accept': 'application/vnd.api+json',
        'Authorization': `Bearer ${accessToken}`
      },
      body: JSON.stringify({
        data: {
          type: 'skus',
          attributes: {
            code: product.attributes.sku,
            name: product.attributes.name,
            description: product.attributes.description,
            image_url: product.attributes.image?.data?.attributes?.url || '',
            metadata: {
              category: product.attributes.category?.data?.attributes?.name || '',
              colors: product.attributes.colors || '',
              sizes: product.attributes.sizes || '',
              strapiId: product.id
            }
          }
        }
      })
    });
    
    return await createResponse.json();
  }
}

// Function to create or update a price in Commerce Layer
async function upsertPriceInCommerceLayer(product, skuId, accessToken, commerceLayerCredentials) {
  const { endpoint } = commerceLayerCredentials;
  
  // Check if a price already exists for this SKU
  const checkResponse = await fetch(`${endpoint}/api/prices?filter[sku_code]=${product.attributes.sku}`, {
    method: 'GET',
    headers: {
      'Accept': 'application/vnd.api+json',
      'Authorization': `Bearer ${accessToken}`
    }
  });
  
  const checkData = await checkResponse.json();
  
  if (checkData.data && checkData.data.length > 0) {
    // Update existing price
    const existingPrice = checkData.data[0];
    
    const updateResponse = await fetch(`${endpoint}/api/prices/${existingPrice.id}`, {
      method: 'PATCH',
      headers: {
        'Content-Type': 'application/vnd.api+json',
        'Accept': 'application/vnd.api+json',
        'Authorization': `Bearer ${accessToken}`
      },
      body: JSON.stringify({
        data: {
          type: 'prices',
          id: existingPrice.id,
          attributes: {
            amount_cents: Math.round(product.attributes.price * 100),
            currency_code: 'EUR'
          }
        }
      })
    });
    
    return await updateResponse.json();
  } else {
    // Create a new price
    const createResponse = await fetch(`${endpoint}/api/prices`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/vnd.api+json',
        'Accept': 'application/vnd.api+json',
        'Authorization': `Bearer ${accessToken}`
      },
      body: JSON.stringify({
        data: {
          type: 'prices',
          attributes: {
            amount_cents: Math.round(product.attributes.price * 100),
            currency_code: 'EUR',
            sku_code: product.attributes.sku
          },
          relationships: {
            sku: {
              data: {
                type: 'skus',
                id: skuId
              }
            }
          }
        }
      })
    });
    
    return await createResponse.json();
  }
}

// Function to create or update a stock item in Commerce Layer
async function upsertStockItemInCommerceLayer(product, skuId, accessToken, commerceLayerCredentials) {
  const { endpoint } = commerceLayerCredentials;
  
  // Check if a stock item already exists for this SKU
  const checkResponse = await fetch(`${endpoint}/api/stock_items?filter[sku_code]=${product.attributes.sku}`, {
    method: 'GET',
    headers: {
      'Accept': 'application/vnd.api+json',
      'Authorization': `Bearer ${accessToken}`
    }
  });
  
  const checkData = await checkResponse.json();
  
  if (checkData.data && checkData.data.length > 0) {
    // Update existing stock item
    const existingStockItem = checkData.data[0];
    
    const updateResponse = await fetch(`${endpoint}/api/stock_items/${existingStockItem.id}`, {
      method: 'PATCH',
      headers: {
        'Content-Type': 'application/vnd.api+json',
        'Accept': 'application/vnd.api+json',
        'Authorization': `Bearer ${accessToken}`
      },
      body: JSON.stringify({
        data: {
          type: 'stock_items',
          id: existingStockItem.id,
          attributes: {
            quantity: product.attributes.stock || 0
          }
        }
      })
    });
    
    return await updateResponse.json();
  } else {
    // Create a new stock item
    const createResponse = await fetch(`${endpoint}/api/stock_items`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/vnd.api+json',
        'Accept': 'application/vnd.api+json',
        'Authorization': `Bearer ${accessToken}`
      },
      body: JSON.stringify({
        data: {
          type: 'stock_items',
          attributes: {
            quantity: product.attributes.stock || 0,
            sku_code: product.attributes.sku
          },
          relationships: {
            sku: {
              data: {
                type: 'skus',
                id: skuId
              }
            },
            stock_location: {
              data: {
                type: 'stock_locations',
                id: 'your-stock-location-id' // Replace with your actual location ID
              }
            }
          }
        }
      })
    });
    
    return await createResponse.json();
  }
}

// Function to save the product to DynamoDB
async function saveProductToDynamoDB(product, commerceLayerProduct, tableName) {
  const item = {
    id: commerceLayerProduct.data.id,
    strapiId: product.id,
    name: product.attributes.name,
    description: product.attributes.description,
    sku: product.attributes.sku,
    price: product.attributes.price,
    category: product.attributes.category?.data?.attributes?.name || '',
    imageUrl: product.attributes.image?.data?.attributes?.url || '',
    colors: product.attributes.colors || '',
    sizes: product.attributes.sizes || '',
    stock: product.attributes.stock || 0,
    updatedAt: new Date().toISOString()
  };
  
  await dynamoDB.put({
    TableName: tableName,
    Item: item
  });
  
  return item;
}

// Main Lambda handler
exports.handler = async (event) => {
  try {
    console.log('Event received:', JSON.stringify(event));
    
    // Get credentials from secrets
    const commerceLayerSecretArn = process.env.COMMERCE_LAYER_SECRET_ARN;
    const strapiSecretArn = process.env.STRAPI_SECRET_ARN;
    const productsTableName = process.env.PRODUCTS_TABLE;
    
    const commerceLayerCredentials = await getSecret(commerceLayerSecretArn);
    const strapiCredentials = await getSecret(strapiSecretArn);
    
    // Get an access token for Commerce Layer
    const accessToken = await getCommerceLayerToken(commerceLayerCredentials);
    
    // Get products from Strapi
    const strapiProducts = await getProductsFromStrapi(strapiCredentials);
    
    console.log(`Found ${strapiProducts.length} products in Strapi`);
    
    // Synchronize each product with Commerce Layer
    const results = [];
    
    for (const product of strapiProducts) {
      console.log(`Synchronizing product: ${product.attributes.name}`);
      
      // Create or update the product in Commerce Layer
      const commerceLayerProduct = await upsertProductInCommerceLayer(product, accessToken, commerceLayerCredentials);
      
      // Create or update the price in Commerce Layer
      await upsertPriceInCommerceLayer(product, commerceLayerProduct.data.id, accessToken, commerceLayerCredentials);
      
      // Create or update the stock item in Commerce Layer
      await upsertStockItemInCommerceLayer(product, commerceLayerProduct.data.id, accessToken, commerceLayerCredentials);
      
      // Save the product to DynamoDB
      const savedProduct = await saveProductToDynamoDB(product, commerceLayerProduct, productsTableName);
      
      results.push(savedProduct);
    }
    
    return {
      statusCode: 200,
      headers: {
        'Content-Type': 'application/json',
        'Access-Control-Allow-Origin': '*'
      },
      body: JSON.stringify({
        message: `Successfully synchronized ${results.length} products`,
        products: results
      })
    };
  } catch (error) {
    console.error('Error:', error);
    
    return {
      statusCode: 500,
      headers: {
        'Content-Type': 'application/json',
        'Access-Control-Allow-Origin': '*'
      },
      body: JSON.stringify({ error: error.message })
    };
  }
};

Webhook for Commerce Layer

To handle updates from Commerce Layer to Strapi, we implement a Lambda function that receives Commerce Layer webhooks:

// lambda/strapi-cl-sync/commerce-layer-webhook.js
'use strict';

const AWS = require('aws-sdk');
const { SecretsManager } = require('@aws-sdk/client-secrets-manager');
const fetch = require('node-fetch');

// Initialize AWS Secrets Manager client
const secretsManager = new SecretsManager();

// Function to get secrets from AWS Secrets Manager
async function getSecret(secretArn) {
  const response = await secretsManager.getSecretValue({ SecretId: secretArn });
  return JSON.parse(response.SecretString);
}

// Main Lambda handler
exports.handler = async (event) => {
  try {
    console.log('Event received from Commerce Layer:', JSON.stringify(event));
    
    // Get credentials from secrets
    const strapiSecretArn = process.env.STRAPI_SECRET_ARN;
    const strapiCredentials = await getSecret(strapiSecretArn);
    
    // Extract data from Commerce Layer event
    const { data } = JSON.parse(event.body);
    
    if (!data) {
      throw new Error('Missing data in Commerce Layer event');
    }
    
    // Determine event type
    const eventType = event.headers['X-CL-Webhook-Event'] || 'unknown';
    
    // Handle different event types
    switch (eventType) {
      case 'orders.create':
        await handleOrderCreated(data, strapiCredentials);
        break;
      case 'orders.update':
        await handleOrderUpdated(data, strapiCredentials);
        break;
      case 'skus.update':
        await handleProductUpdated(data, strapiCredentials);
        break;
      default:
        console.log(`Unhandled event type: ${eventType}`);
    }
    
    return {
      statusCode: 200,
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ message: 'Webhook processed successfully' })
    };
  } catch (error) {
    console.error('Error processing webhook:', error);
    
    return {
      statusCode: 500,
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ error: error.message })
    };
  }
};

// Function to handle order creation
async function handleOrderCreated(orderData, strapiCredentials) {
  const { apiKey, endpoint } = strapiCredentials;
  
  // Extract order data
  const order = {
    commerceLayerId: orderData.id,
    number: orderData.attributes.number,
    status: orderData.attributes.status,
    customerEmail: orderData.attributes.customer_email,
    totalAmount: orderData.attributes.total_amount_with_taxes_cents / 100,
    currency: orderData.attributes.currency_code,
    items: orderData.relationships.line_items.data.map(item => ({
      skuCode: item.attributes.sku_code,
      name: item.attributes.name,
      quantity: item.attributes.quantity,
      price: item.attributes.unit_amount_cents / 100
    })),
    createdAt: orderData.attributes.created_at
  };
  
  // Send order data to Strapi
  const response = await fetch(`${endpoint}/api/orders`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      data: order
    })
  });
  
  if (!response.ok) {
    const errorData = await response.json();
    throw new Error(`Error sending order to Strapi: ${JSON.stringify(errorData)}`);
  }
  
  return await response.json();
}

// Function to handle order updates
async function handleOrderUpdated(orderData, strapiCredentials) {
  const { apiKey, endpoint } = strapiCredentials;
  
  // Find the order in Strapi
  const findResponse = await fetch(`${endpoint}/api/orders?filters[commerceLayerId][$eq]=${orderData.id}`, {
    method: 'GET',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json'
    }
  });
  
  const findData = await findResponse.json();
  
  if (!findData.data || findData.data.length === 0) {
    console.log(`Order with commerceLayerId ${orderData.id} not found in Strapi`);
    return await handleOrderCreated(orderData, strapiCredentials);
  }
  
  const strapiOrderId = findData.data[0].id;
  
  // Update the order in Strapi
  const updateResponse = await fetch(`${endpoint}/api/orders/${strapiOrderId}`, {
    method: 'PUT',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      data: {
        status: orderData.attributes.status,
        updatedAt: new Date().toISOString()
      }
    })
  });
  
  if (!updateResponse.ok) {
    const errorData = await updateResponse.json();
    throw new Error(`Error updating order in Strapi: ${JSON.stringify(errorData)}`);
  }
  
  return await updateResponse.json();
}

// Function to handle product updates
async function handleProductUpdated(productData, strapiCredentials) {
  const { apiKey, endpoint } = strapiCredentials;
  
  // Extract the Strapi ID from metadata
  const strapiId = productData.attributes.metadata?.strapiId;
  
  if (!strapiId) {
    console.log('strapiId not found in product metadata');
    return;
  }
  
  // Update the product in Strapi
  const updateResponse = await fetch(`${endpoint}/api/products/${strapiId}`, {
    method: 'PUT',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      data: {
        name: productData.attributes.name,
        description: productData.attributes.description,
        stock: productData.relationships.stock_items?.data[0]?.attributes?.quantity || 0,
        updatedAt: new Date().toISOString()
      }
    })
  });
  
  if (!updateResponse.ok) {
    const errorData = await updateResponse.json();
    throw new Error(`Error updating product in Strapi: ${JSON.stringify(errorData)}`);
  }
  
  return await updateResponse.json();
}

Commerce Layer Configuration

Creating Necessary Resources

Before using Commerce Layer, we need to create some basic resources:

  1. Market: Defines the sales region, currency, and tax settings
  2. Stock Location: Defines where products are stored
  3. Shipping Methods: Defines how products are shipped
  4. Payment Methods: Defines how orders are paid

These resources can be created through the Commerce Layer admin interface or via API.

Webhook Configuration

To receive notifications from Commerce Layer when events like order creation occur, we need to configure webhooks:

  1. Access the Commerce Layer admin interface
  2. Go to the "Webhooks" section
  3. Create a new webhook with the following settings:
    • Topic: orders.create
    • URL: URL of the commerce-layer-webhook Lambda function
    • Active: Yes

Repeat the process for other events like orders.update and skus.update.

Order Processing

To process orders, we implement a dedicated Lambda function:

// lambda/order-processor/index.js
const AWS = require('aws-sdk');
const { SecretsManager } = require('@aws-sdk/client-secrets-manager');
const { DynamoDB } = require('@aws-sdk/client-dynamodb');
const { DynamoDBDocument } = require('@aws-sdk/lib-dynamodb');
const { SES } = require('@aws-sdk/client-ses');
const fetch = require('node-fetch');

// Initialize AWS clients
const secretsManager = new SecretsManager();
const dynamoDB = DynamoDBDocument.from(new DynamoDB());
const ses = new SES();

// Function to get secrets from AWS Secrets Manager
async function getSecret(secretArn) {
  const response = await secretsManager.getSecretValue({ SecretId: secretArn });
  return JSON.parse(response.SecretString);
}

// Function to get a Commerce Layer token
async function getCommerceLayerToken(credentials) {
  const { clientId, clientSecret, endpoint } = credentials;
  
  const response = await fetch(`${endpoint}/oauth/token`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Accept': 'application/json'
    },
    body: JSON.stringify({
      grant_type: 'client_credentials',
      client_id: clientId,
      client_secret: clientSecret
    })
  });
  
  const data = await response.json();
  return data.access_token;
}

// Function to get order details from Commerce Layer
async function getOrderFromCommerceLayer(orderId, accessToken, commerceLayerCredentials) {
  const { endpoint } = commerceLayerCredentials;
  
  const response = await fetch(`${endpoint}/api/orders/${orderId}?include=line_items,shipping_address,customer`, {
    method: 'GET',
    headers: {
      'Accept': 'application/vnd.api+json',
      'Authorization': `Bearer ${accessToken}`
    }
  });
  
  return await response.json();
}

// Function to update order status in Commerce Layer
async function updateOrderStatus(orderId, status, accessToken, commerceLayerCredentials) {
  const { endpoint } = commerceLayerCredentials;
  
  const response = await fetch(`${endpoint}/api/orders/${orderId}`, {
    method: 'PATCH',
    headers: {
      'Content-Type': 'application/vnd.api+json',
      'Accept': 'application/vnd.api+json',
      'Authorization': `Bearer ${accessToken}`
    },
    body: JSON.stringify({
      data: {
        type: 'orders',
        id: orderId,
        attributes: {
          status: status
        }
      }
    })
  });
  
  return await response.json();
}

// Function to save the order to DynamoDB
async function saveOrderToDynamoDB(order, tableName) {
  const item = {
    id: order.data.id,
    number: order.data.attributes.number,
    status: order.data.attributes.status,
    customerEmail: order.data.attributes.customer_email,
    totalAmount: order.data.attributes.total_amount_with_taxes_cents / 100,
    currency: order.data.attributes.currency_code,
    items: order.included
      .filter(item => item.type === 'line_items')
      .map(item => ({
        id: item.id,
        name: item.attributes.name,
        quantity: item.attributes.quantity,
        price: item.attributes.unit_amount_cents / 100
      })),
    shippingAddress: order.included
      .find(item => item.type === 'addresses')?.attributes || {},
    customer: order.included
      .find(item => item.type === 'customers')?.attributes || {},
    createdAt: order.data.attributes.created_at,
    updatedAt: new Date().toISOString()
  };
  
  await dynamoDB.put({
    TableName: tableName,
    Item: item
  });
  
  return item;
}

// Function to send order confirmation email
async function sendOrderConfirmationEmail(order, emailConfig) {
  const { fromEmail, templateName } = emailConfig;
  
  // Format order items for email
  const itemsList = order.items.map(item => 
    `${item.quantity}x ${item.name} - ${item.price} ${order.currency}`
  ).join('\n');
  
  const params = {
    Destination: {
      ToAddresses: [order.customerEmail]
    },
    Source: fromEmail,
    Template: templateName,
    TemplateData: JSON.stringify({
      orderNumber: order.number,
      customerName: `${order.customer.first_name || ''} ${order.customer.last_name || ''}`,
      orderDate: new Date(order.createdAt).toLocaleDateString(),
      items: itemsList,
      totalAmount: `${order.totalAmount} ${order.currency}`,
      shippingAddress: `${order.shippingAddress.line_1 || ''}, ${order.shippingAddress.city || ''}, ${order.shippingAddress.state_code || ''}, ${order.shippingAddress.zip_code || ''}, ${order.shippingAddress.country_code || ''}`,
      orderStatus: order.status
    })
  };
  
  return await ses.sendTemplatedEmail(params);
}

// Main Lambda handler
exports.handler = async (event) => {
  try {
    console.log('Event received:', JSON.stringify(event));
    
    // Get credentials from secrets
    const commerceLayerSecretArn = process.env.COMMERCE_LAYER_SECRET_ARN;
    const ordersTableName = process.env.ORDERS_TABLE;
    const emailConfig = {
      fromEmail: process.env.FROM_EMAIL || 'noreply@serverlessday.com',
      templateName: process.env.EMAIL_TEMPLATE_NAME || 'OrderConfirmation'
    };
    
    const commerceLayerCredentials = await getSecret(commerceLayerSecretArn);
    
    // Get an access token for Commerce Layer
    const accessToken = await getCommerceLayerToken(commerceLayerCredentials);
    
    // Extract the order ID from the event
    const orderId = event.detail?.orderId || JSON.parse(event.body)?.data?.id;
    
    if (!orderId) {
      throw new Error('Order ID not found in event');
    }
    
    // Get order details from Commerce Layer
    const order = await getOrderFromCommerceLayer(orderId, accessToken, commerceLayerCredentials);
    
    // Save the order to DynamoDB
    const savedOrder = await saveOrderToDynamoDB(order, ordersTableName);
    
    // Send order confirmation email
    await sendOrderConfirmationEmail(savedOrder, emailConfig);
    
    // Update order status in Commerce Layer (if needed)
    if (order.data.attributes.status === 'pending') {
      await updateOrderStatus(orderId, 'approved', accessToken, commerceLayerCredentials);
    }
    
    return {
      statusCode: 200,
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        message: 'Order processed successfully',
        orderId: orderId
      })
    };
  } catch (error) {
    console.error('Error processing order:', error);
    
    return {
      statusCode: 500,
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ error: error.message })
    };
  }
};

Inventory Management

To manage inventory, we implement a dedicated Lambda function:

// lambda/inventory-manager/index.js
const AWS = require('aws-sdk');
const { SecretsManager } = require('@aws-sdk/client-secrets-manager');
const { DynamoDB } = require('@aws-sdk/client-dynamodb');
const { DynamoDBDocument } = require('@aws-sdk/lib-dynamodb');
const { SNS } = require('@aws-sdk/client-sns');
const fetch = require('node-fetch');

// Initialize AWS clients
const secretsManager = new SecretsManager();
const dynamoDB = DynamoDBDocument.from(new DynamoDB());
const sns = new SNS();

// Function to get secrets from AWS Secrets Manager
async function getSecret(secretArn) {
  const response = await secretsManager.getSecretValue({ SecretId: secretArn });
  return JSON.parse(response.SecretString);
}

// Function to get a Commerce Layer token
async function getCommerceLayerToken(credentials) {
  const { clientId, clientSecret, endpoint } = credentials;
  
  const response = await fetch(`${endpoint}/oauth/token`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Accept': 'application/json'
    },
    body: JSON.stringify({
      grant_type: 'client_credentials',
      client_id: clientId,
      client_secret: clientSecret
    })
  });
  
  const data = await response.json();
  return data.access_token;
}

// Function to get all stock items from Commerce Layer
async function getStockItemsFromCommerceLayer(accessToken, commerceLayerCredentials) {
  const { endpoint } = commerceLayerCredentials;
  
  const response = await fetch(`${endpoint}/api/stock_items?include=sku`, {
    method: 'GET',
    headers: {
      'Accept': 'application/vnd.api+json',
      'Authorization': `Bearer ${accessToken}`
    }
  });
  
  return await response.json();
}

// Function to update a stock item in Commerce Layer
async function updateStockItemInCommerceLayer(stockItemId, quantity, accessToken, commerceLayerCredentials) {
  const { endpoint } = commerceLayerCredentials;
  
  const response = await fetch(`${endpoint}/api/stock_items/${stockItemId}`, {
    method: 'PATCH',
    headers: {
      'Content-Type': 'application/vnd.api+json',
      'Accept': 'application/vnd.api+json',
      'Authorization': `Bearer ${accessToken}`
    },
    body: JSON.stringify({
      data: {
        type: 'stock_items',
        id: stockItemId,
        attributes: {
          quantity: quantity
        }
      }
    })
  });
  
  return await response.json();
}

// Function to save the stock item to DynamoDB
async function saveStockItemToDynamoDB(stockItem, tableName) {
  const item = {
    id: stockItem.id,
    skuId: stockItem.relationships?.sku?.data?.id,
    skuCode: stockItem.attributes?.sku_code,
    quantity: stockItem.attributes?.quantity,
    updatedAt: new Date().toISOString()
  };
  
  await dynamoDB.put({
    TableName: tableName,
    Item: item
  });
  
  return item;
}

// Function to send low stock notifications
async function sendLowStockNotification(stockItem, threshold, topicArn) {
  if (stockItem.quantity <= threshold) {
    const message = {
      skuCode: stockItem.skuCode,
      skuId: stockItem.skuId,
      currentQuantity: stockItem.quantity,
      threshold: threshold,
      message: `Low stock alert: ${stockItem.skuCode} has only ${stockItem.quantity} items left (threshold: ${threshold})`
    };
    
    await sns.publish({
      TopicArn: topicArn,
      Subject: `Low Stock Alert: ${stockItem.skuCode}`,
      Message: JSON.stringify(message),
      MessageAttributes: {
        'skuCode': {
          DataType: 'String',
          StringValue: stockItem.skuCode
        },
        'currentQuantity': {
          DataType: 'Number',
          StringValue: stockItem.quantity.toString()
        }
      }
    });
    
    return message;
  }
  
  return null;
}

// Main Lambda handler
exports.handler = async (event) => {
  try {
    console.log('Event received:', JSON.stringify(event));
    
    // Get credentials from secrets
    const commerceLayerSecretArn = process.env.COMMERCE_LAYER_SECRET_ARN;
    const inventoryTableName = process.env.INVENTORY_TABLE;
    const lowStockThreshold = parseInt(process.env.LOW_STOCK_THRESHOLD || '5');
    const lowStockTopicArn = process.env.LOW_STOCK_TOPIC_ARN;
    
    const commerceLayerCredentials = await getSecret(commerceLayerSecretArn);
    
    // Get an access token for Commerce Layer
    const accessToken = await getCommerceLayerToken(commerceLayerCredentials);
    
    // Handle different event types
    if (event.detail?.type === 'order.placed') {
      // Update inventory based on order
      const orderId = event.detail.orderId;
      const orderItems = event.detail.items;
      
      const updateResults = [];
      
      for (const item of orderItems) {
        // Get current stock item
        const getResponse = await fetch(`${commerceLayerCredentials.endpoint}/api/stock_items?filter[sku_code]=${item.skuCode}`, {
          method: 'GET',
          headers: {
            'Accept': 'application/vnd.api+json',
            'Authorization': `Bearer ${accessToken}`
          }
        });
        
        const getData = await getResponse.json();
        
        if (getData.data && getData.data.length > 0) {
          const stockItem = getData.data[0];
          const currentQuantity = stockItem.attributes.quantity;
          const newQuantity = Math.max(0, currentQuantity - item.quantity);
          
          // Update quantity
          const updatedStockItem = await updateStockItemInCommerceLayer(
            stockItem.id, 
            newQuantity, 
            accessToken, 
            commerceLayerCredentials
          );
          
          // Save to DynamoDB
          const savedItem = await saveStockItemToDynamoDB(updatedStockItem.data, inventoryTableName);
          
          // Send notification if stock is low
          if (lowStockTopicArn) {
            await sendLowStockNotification(savedItem, lowStockThreshold, lowStockTopicArn);
          }
          
          updateResults.push(savedItem);
        }
      }
      
      return {
        statusCode: 200,
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          message: `Inventory updated for order ${orderId}`,
          updates: updateResults
        })
      };
    } else {
      // Synchronize all inventory
      const stockItems = await getStockItemsFromCommerceLayer(accessToken, commerceLayerCredentials);
      
      const savedItems = [];
      const lowStockNotifications = [];
      
      for (const stockItem of stockItems.data) {
        // Save to DynamoDB
        const savedItem = await saveStockItemToDynamoDB(stockItem, inventoryTableName);
        savedItems.push(savedItem);
        
        // Send notification if stock is low
        if (lowStockTopicArn) {
          const notification = await sendLowStockNotification(savedItem, lowStockThreshold, lowStockTopicArn);
          if (notification) {
            lowStockNotifications.push(notification);
          }
        }
      }
      
      return {
        statusCode: 200,
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          message: `Synchronized ${savedItems.length} inventory items`,
          lowStockAlerts: lowStockNotifications.length,
          items: savedItems
        })
      };
    }
  } catch (error) {
    console.error('Error in inventory management:', error);
    
    return {
      statusCode: 500,
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ error: error.message })
    };
  }
};

Frontend Integration

To integrate Commerce Layer in the frontend, we can use the official Commerce Layer libraries:

# Install Commerce Layer libraries
npm install @commercelayer/js-auth @commercelayer/js-sdk @commercelayer/react-components

Here's an example of how to use the Commerce Layer React components:

// components/ProductList.jsx
import React, { useEffect, useState } from 'react';
import { CommerceLayer, Price, AddToCartButton } from '@commercelayer/react-components';

const ProductList = ({ products }) => {
  return (
    <CommerceLayer
      accessToken={process.env.NEXT_PUBLIC_CL_ACCESS_TOKEN}
      endpoint={process.env.NEXT_PUBLIC_CL_ENDPOINT}
    >
      <div className="product-grid">
        {products.map(product => (
          <div key={product.id} className="product-card">
            <img src={product.imageUrl} alt={product.name} />
            <h3>{product.name}</h3>
            <p>{product.description}</p>
            <div className="product-price">
              <Price skuCode={product.sku} />
            </div>
            <AddToCartButton skuCode={product.sku} />
          </div>
        ))}
      </div>
    </CommerceLayer>
  );
};

export default ProductList;

Conclusion

In this section, we've implemented the integration with Commerce Layer to handle the e-commerce logic of our application. We've created Lambda functions for synchronization between Strapi and Commerce Layer, for order processing, and for inventory management.

In the next chapter, we'll see how to implement semantic product search using AWS Bedrock and the Model Context Protocol.

Share this post