Skip to content

AI Prompt: Forma3D.Connect — Phase 1: Shopify Inbound ✅

Purpose: This prompt instructs an AI to implement Phase 1 of Forma3D.Connect
Estimated Effort: 38 hours
Prerequisites: Phase 0 completed (Nx monorepo, database, CI/CD pipeline)
Output: Fully functional Shopify integration with order reception and storage
Status:COMPLETED — January 2026


🎯 Mission

You are continuing development of Forma3D.Connect, building on the Phase 0 foundation. Your task is to implement Phase 1: Shopify Inbound — establishing the connection with Shopify to receive and store orders.

Phase 1 delivers:

  • Typed Shopify API client
  • Secure webhook endpoint for order events
  • Order storage and status management
  • Product-to-print mapping system

📋 Phase 1 Context

What Was Built in Phase 0

The foundation is already in place:

  • Nx monorepo with apps/api, apps/web, and shared libs
  • PostgreSQL database with Prisma schema (Order, LineItem, PrintJob, ProductMapping, AssemblyPart, EventLog, etc.)
  • NestJS backend with health endpoint
  • React 19 frontend with basic dashboard
  • Azure DevOps CI/CD pipeline
  • Environment configuration and validation

What Phase 1 Builds

Feature Description Effort
F1.1: Shopify API Client Typed client for Shopify Admin API 12 hours
F1.2: Webhook Receiver Secure webhook endpoint for Shopify events 8 hours
F1.3: Order Storage Service Order persistence and status management 10 hours
F1.4: Product Mapping System SKU-to-print-file configuration 8 hours

🛠️ Tech Stack Reference

All technologies from Phase 0 remain. Additional packages for Phase 1:

Package Purpose
@shopify/shopify-api Official Shopify API client (optional, can use custom)
crypto HMAC signature verification (Node.js built-in)

📁 New Files to Create

Add to the existing structure:

apps/api/src/
├── shopify/
│   ├── shopify.module.ts
│   ├── shopify.controller.ts           # Webhook receiver
│   ├── shopify.service.ts              # Shopify business logic
│   ├── shopify-api.client.ts           # API client
│   ├── dto/
│   │   ├── shopify-order.dto.ts        # Shopify order DTOs
│   │   ├── shopify-webhook.dto.ts      # Webhook payload DTOs
│   │   └── create-fulfillment.dto.ts   # Fulfillment DTOs
│   ├── guards/
│   │   └── shopify-webhook.guard.ts    # HMAC verification
│   └── interfaces/
│       └── shopify-types.ts            # Shopify type definitions
│
├── orders/
│   ├── orders.module.ts
│   ├── orders.controller.ts            # Order REST endpoints
│   ├── orders.service.ts               # Order business logic
│   ├── orders.repository.ts            # Prisma order operations
│   ├── dto/
│   │   ├── order.dto.ts
│   │   ├── create-order.dto.ts
│   │   └── order-query.dto.ts
│   └── events/
│       └── order.events.ts             # Order event definitions
│
├── line-items/
│   ├── line-items.module.ts
│   ├── line-items.service.ts
│   └── line-items.repository.ts
│
└── product-mappings/
    ├── product-mappings.module.ts
    ├── product-mappings.controller.ts  # CRUD for mappings
    ├── product-mappings.service.ts
    ├── product-mappings.repository.ts
    └── dto/
        ├── product-mapping.dto.ts
        └── create-product-mapping.dto.ts

libs/domain/src/
├── shopify/
│   ├── index.ts
│   ├── shopify-order.entity.ts         # Shopify order types
│   ├── shopify-product.entity.ts       # Shopify product types
│   └── shopify-webhook.types.ts        # Webhook types
└── index.ts                            # Update exports

🔧 Feature F1.1: Shopify API Client

Requirements Reference

  • FR-SH-001: Webhook Registration
  • FR-SH-004: Order Fulfillment

Implementation

1. Shopify Types (libs/domain)

Create libs/domain/src/shopify/shopify-order.entity.ts:

/**
 * Shopify Order representation
 * Based on Shopify Admin API 2024-01
 */
export interface ShopifyOrder {
  id: number;
  admin_graphql_api_id: string;
  order_number: number;
  name: string; // e.g., "#1001"
  email: string | null;
  created_at: string;
  updated_at: string;
  cancelled_at: string | null;
  closed_at: string | null;
  processed_at: string;
  financial_status: ShopifyFinancialStatus;
  fulfillment_status: ShopifyFulfillmentStatus | null;
  currency: string;
  total_price: string;
  subtotal_price: string;
  total_tax: string;
  total_discounts: string;
  total_shipping_price_set: ShopifyPriceSet;
  customer: ShopifyCustomer | null;
  billing_address: ShopifyAddress | null;
  shipping_address: ShopifyAddress | null;
  line_items: ShopifyLineItem[];
  fulfillments: ShopifyFulfillment[];
  note: string | null;
  tags: string;
  test: boolean;
}

export type ShopifyFinancialStatus =
  | 'pending'
  | 'authorized'
  | 'partially_paid'
  | 'paid'
  | 'partially_refunded'
  | 'refunded'
  | 'voided';

export type ShopifyFulfillmentStatus = 'fulfilled' | 'partial' | 'restocked' | null;

export interface ShopifyCustomer {
  id: number;
  email: string | null;
  first_name: string | null;
  last_name: string | null;
  phone: string | null;
}

export interface ShopifyAddress {
  first_name: string | null;
  last_name: string | null;
  address1: string | null;
  address2: string | null;
  city: string | null;
  province: string | null;
  province_code: string | null;
  country: string | null;
  country_code: string | null;
  zip: string | null;
  phone: string | null;
  company: string | null;
}

export interface ShopifyLineItem {
  id: number;
  admin_graphql_api_id: string;
  product_id: number | null;
  variant_id: number | null;
  title: string;
  variant_title: string | null;
  sku: string | null;
  quantity: number;
  price: string;
  fulfillable_quantity: number;
  fulfillment_status: string | null;
}

export interface ShopifyFulfillment {
  id: number;
  order_id: number;
  status: string;
  tracking_number: string | null;
  tracking_url: string | null;
  tracking_company: string | null;
}

export interface ShopifyPriceSet {
  shop_money: ShopifyMoney;
  presentment_money: ShopifyMoney;
}

export interface ShopifyMoney {
  amount: string;
  currency_code: string;
}

export interface ShopifyProduct {
  id: number;
  title: string;
  handle: string;
  status: 'active' | 'archived' | 'draft';
  variants: ShopifyVariant[];
}

export interface ShopifyVariant {
  id: number;
  product_id: number;
  title: string;
  sku: string | null;
  price: string;
  inventory_quantity: number;
}

export interface ShopifyWebhook {
  id: number;
  address: string;
  topic: string;
  created_at: string;
  updated_at: string;
  format: string;
  api_version: string;
}

2. Shopify API Client

Create apps/api/src/shopify/shopify-api.client.ts:

import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ShopifyOrder, ShopifyProduct, ShopifyVariant, ShopifyWebhook } from '@forma3d/domain';

interface FulfillmentInput {
  line_items: Array<{ id: number; quantity: number }>;
  tracking_info?: {
    number?: string;
    url?: string;
    company?: string;
  };
  notify_customer?: boolean;
}

interface FulfillmentResponse {
  fulfillment: {
    id: number;
    order_id: number;
    status: string;
    tracking_number: string | null;
    tracking_url: string | null;
  };
}

interface OrderQueryParams {
  status?: 'open' | 'closed' | 'cancelled' | 'any';
  financial_status?: string;
  fulfillment_status?: string;
  created_at_min?: string;
  created_at_max?: string;
  updated_at_min?: string;
  updated_at_max?: string;
  limit?: number;
  since_id?: number;
}

@Injectable()
export class ShopifyApiClient {
  private readonly logger = new Logger(ShopifyApiClient.name);
  private readonly baseUrl: string;
  private readonly accessToken: string;
  private readonly apiVersion: string;

  constructor(private readonly configService: ConfigService) {
    const shopDomain = this.configService.getOrThrow<string>('SHOPIFY_SHOP_DOMAIN');
    this.apiVersion = this.configService.getOrThrow<string>('SHOPIFY_API_VERSION');
    this.accessToken = this.configService.getOrThrow<string>('SHOPIFY_ACCESS_TOKEN');
    this.baseUrl = `https://${shopDomain}/admin/api/${this.apiVersion}`;
  }

  /**
   * Generic request handler with rate limiting and error handling
   */
  private async request<T>(
    method: 'GET' | 'POST' | 'PUT' | 'DELETE',
    endpoint: string,
    body?: unknown
  ): Promise<T> {
    const url = `${this.baseUrl}${endpoint}`;

    const headers: HeadersInit = {
      'Content-Type': 'application/json',
      'X-Shopify-Access-Token': this.accessToken,
    };

    const options: RequestInit = {
      method,
      headers,
      body: body ? JSON.stringify(body) : undefined,
    };

    this.logger.debug(`Shopify API ${method} ${endpoint}`);

    const response = await fetch(url, options);

    // Handle rate limiting
    if (response.status === 429) {
      const retryAfter = response.headers.get('Retry-After') || '2';
      const waitTime = parseInt(retryAfter, 10) * 1000;
      this.logger.warn(`Rate limited. Waiting ${waitTime}ms before retry.`);
      await this.delay(waitTime);
      return this.request<T>(method, endpoint, body);
    }

    if (!response.ok) {
      const errorBody = await response.text();
      this.logger.error(`Shopify API error: ${response.status} - ${errorBody}`);
      throw new Error(`Shopify API error: ${response.status} - ${errorBody}`);
    }

    return response.json() as Promise<T>;
  }

  private delay(ms: number): Promise<void> {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }

  // =========================================================================
  // Orders
  // =========================================================================

  async getOrder(orderId: string | number): Promise<ShopifyOrder> {
    const response = await this.request<{ order: ShopifyOrder }>('GET', `/orders/${orderId}.json`);
    return response.order;
  }

  async getOrders(params: OrderQueryParams = {}): Promise<ShopifyOrder[]> {
    const queryParams = new URLSearchParams();

    Object.entries(params).forEach(([key, value]) => {
      if (value !== undefined) {
        queryParams.append(key, String(value));
      }
    });

    const queryString = queryParams.toString();
    const endpoint = `/orders.json${queryString ? `?${queryString}` : ''}`;

    const response = await this.request<{ orders: ShopifyOrder[] }>('GET', endpoint);
    return response.orders;
  }

  // =========================================================================
  // Fulfillment
  // =========================================================================

  async createFulfillment(
    orderId: string | number,
    data: FulfillmentInput
  ): Promise<FulfillmentResponse> {
    return this.request<FulfillmentResponse>('POST', `/orders/${orderId}/fulfillments.json`, {
      fulfillment: data,
    });
  }

  // =========================================================================
  // Products
  // =========================================================================

  async getProducts(limit = 50): Promise<ShopifyProduct[]> {
    const response = await this.request<{ products: ShopifyProduct[] }>(
      'GET',
      `/products.json?limit=${limit}`
    );
    return response.products;
  }

  async getProductVariants(productId: string | number): Promise<ShopifyVariant[]> {
    const response = await this.request<{ variants: ShopifyVariant[] }>(
      'GET',
      `/products/${productId}/variants.json`
    );
    return response.variants;
  }

  // =========================================================================
  // Webhooks
  // =========================================================================

  async registerWebhook(topic: string, address: string): Promise<ShopifyWebhook> {
    const response = await this.request<{ webhook: ShopifyWebhook }>('POST', '/webhooks.json', {
      webhook: {
        topic,
        address,
        format: 'json',
      },
    });
    return response.webhook;
  }

  async listWebhooks(): Promise<ShopifyWebhook[]> {
    const response = await this.request<{ webhooks: ShopifyWebhook[] }>('GET', '/webhooks.json');
    return response.webhooks;
  }

  async deleteWebhook(webhookId: number): Promise<void> {
    await this.request<void>('DELETE', `/webhooks/${webhookId}.json`);
  }

  /**
   * Verify HMAC signature from Shopify webhook
   */
  static verifyWebhookSignature(body: string, hmacHeader: string, secret: string): boolean {
    const crypto = require('crypto');
    const hash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('base64');
    return hash === hmacHeader;
  }
}

🔧 Feature F1.2: Webhook Receiver

Requirements Reference

  • FR-SH-002: Order Reception
  • FR-SH-005: Order Cancellation Handling

Implementation

1. Webhook Guard

Create apps/api/src/shopify/guards/shopify-webhook.guard.ts:

import {
  Injectable,
  CanActivate,
  ExecutionContext,
  UnauthorizedException,
  Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import * as crypto from 'crypto';

@Injectable()
export class ShopifyWebhookGuard implements CanActivate {
  private readonly logger = new Logger(ShopifyWebhookGuard.name);
  private readonly webhookSecret: string;

  constructor(private readonly configService: ConfigService) {
    this.webhookSecret = this.configService.getOrThrow<string>('SHOPIFY_WEBHOOK_SECRET');
  }

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest<Request>();

    const hmacHeader = request.headers['x-shopify-hmac-sha256'] as string;
    const shopDomain = request.headers['x-shopify-shop-domain'] as string;
    const topic = request.headers['x-shopify-topic'] as string;

    if (!hmacHeader) {
      this.logger.warn('Missing HMAC header in webhook request');
      throw new UnauthorizedException('Missing HMAC signature');
    }

    // Get raw body for HMAC verification
    const rawBody = (request as Request & { rawBody?: Buffer }).rawBody;

    if (!rawBody) {
      this.logger.error('Raw body not available for HMAC verification');
      throw new UnauthorizedException('Unable to verify signature');
    }

    const isValid = this.verifySignature(rawBody.toString('utf8'), hmacHeader);

    if (!isValid) {
      this.logger.warn(`Invalid HMAC signature from ${shopDomain} for topic ${topic}`);
      throw new UnauthorizedException('Invalid HMAC signature');
    }

    this.logger.debug(`Verified webhook from ${shopDomain}: ${topic}`);
    return true;
  }

  private verifySignature(body: string, hmacHeader: string): boolean {
    const hash = crypto
      .createHmac('sha256', this.webhookSecret)
      .update(body, 'utf8')
      .digest('base64');

    return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(hmacHeader));
  }
}

2. Webhook DTOs

Create apps/api/src/shopify/dto/shopify-webhook.dto.ts:

import { ShopifyOrder } from '@forma3d/domain';

export type ShopifyWebhookTopic =
  | 'orders/create'
  | 'orders/updated'
  | 'orders/cancelled'
  | 'orders/fulfilled';

export interface WebhookHeaders {
  'x-shopify-topic': ShopifyWebhookTopic;
  'x-shopify-hmac-sha256': string;
  'x-shopify-shop-domain': string;
  'x-shopify-api-version': string;
  'x-shopify-webhook-id': string;
}

export interface OrderWebhookPayload extends ShopifyOrder {
  // Additional webhook-specific fields if any
}

3. Shopify Controller

Create apps/api/src/shopify/shopify.controller.ts:

import {
  Controller,
  Post,
  Body,
  Headers,
  UseGuards,
  Logger,
  HttpCode,
  HttpStatus,
} from '@nestjs/common';
import { ShopifyWebhookGuard } from './guards/shopify-webhook.guard';
import { ShopifyService } from './shopify.service';
import { OrderWebhookPayload, ShopifyWebhookTopic } from './dto/shopify-webhook.dto';

@Controller('api/v1/webhooks/shopify')
export class ShopifyController {
  private readonly logger = new Logger(ShopifyController.name);

  constructor(private readonly shopifyService: ShopifyService) {}

  @Post()
  @UseGuards(ShopifyWebhookGuard)
  @HttpCode(HttpStatus.OK)
  async handleWebhook(
    @Headers('x-shopify-topic') topic: ShopifyWebhookTopic,
    @Headers('x-shopify-webhook-id') webhookId: string,
    @Body() payload: OrderWebhookPayload
  ): Promise<{ received: boolean }> {
    this.logger.log(`Received webhook: ${topic} (ID: ${webhookId})`);

    try {
      switch (topic) {
        case 'orders/create':
          await this.shopifyService.handleOrderCreated(payload, webhookId);
          break;

        case 'orders/updated':
          await this.shopifyService.handleOrderUpdated(payload, webhookId);
          break;

        case 'orders/cancelled':
          await this.shopifyService.handleOrderCancelled(payload, webhookId);
          break;

        case 'orders/fulfilled':
          await this.shopifyService.handleOrderFulfilled(payload, webhookId);
          break;

        default:
          this.logger.warn(`Unhandled webhook topic: ${topic}`);
      }
    } catch (error) {
      // Log error but return 200 to prevent Shopify retries for processing errors
      this.logger.error(`Error processing webhook ${topic}: ${error}`);
      // Re-throw only for critical errors that should trigger retry
      if (this.isCriticalError(error)) {
        throw error;
      }
    }

    return { received: true };
  }

  private isCriticalError(error: unknown): boolean {
    // Database connection errors should trigger retry
    if (error instanceof Error) {
      return error.message.includes('database') || error.message.includes('connection');
    }
    return false;
  }
}

4. Update main.ts for raw body access

Update apps/api/src/main.ts:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app/app.module';
import { Logger, ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as bodyParser from 'body-parser';

async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    // Enable raw body for webhook signature verification
    rawBody: true,
  });

  const configService = app.get(ConfigService);
  const port = configService.get<number>('APP_PORT', 3000);
  const frontendUrl = configService.get<string>('FRONTEND_URL', 'http://localhost:4200');

  // Enable CORS
  app.enableCors({
    origin: frontendUrl,
    credentials: true,
  });

  // Global validation pipe
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
    })
  );

  // Raw body parser for webhooks
  app.use(
    bodyParser.json({
      verify: (req: Request & { rawBody?: Buffer }, _res, buf) => {
        req.rawBody = buf;
      },
    })
  );

  await app.listen(port);
  Logger.log(`🚀 Application is running on: http://localhost:${port}`);
}

bootstrap();

🔧 Feature F1.3: Order Storage Service

Requirements Reference

  • FR-SH-002: Order Reception
  • FR-AD-001: Order Queue View
  • NFR-RE-001: Idempotency

Implementation

1. Order Repository

Create apps/api/src/orders/orders.repository.ts:

import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../database/prisma.service';
import { Order, LineItem, OrderStatus, LineItemStatus, Prisma } from '@prisma/client';

export interface CreateOrderInput {
  shopifyOrderId: string;
  shopifyOrderNumber: string;
  customerName: string;
  customerEmail?: string;
  shippingAddress: Prisma.JsonValue;
  totalPrice: Prisma.Decimal;
  currency: string;
  lineItems: CreateLineItemInput[];
}

export interface CreateLineItemInput {
  shopifyLineItemId: string;
  productSku: string;
  productName: string;
  variantTitle?: string;
  quantity: number;
  unitPrice: Prisma.Decimal;
}

export type OrderWithLineItems = Order & { lineItems: LineItem[] };

@Injectable()
export class OrdersRepository {
  private readonly logger = new Logger(OrdersRepository.name);

  constructor(private readonly prisma: PrismaService) {}

  async create(input: CreateOrderInput): Promise<OrderWithLineItems> {
    return this.prisma.order.create({
      data: {
        shopifyOrderId: input.shopifyOrderId,
        shopifyOrderNumber: input.shopifyOrderNumber,
        customerName: input.customerName,
        customerEmail: input.customerEmail,
        shippingAddress: input.shippingAddress,
        totalPrice: input.totalPrice,
        currency: input.currency,
        lineItems: {
          create: input.lineItems.map((item) => ({
            shopifyLineItemId: item.shopifyLineItemId,
            productSku: item.productSku,
            productName: item.productName,
            variantTitle: item.variantTitle,
            quantity: item.quantity,
            unitPrice: item.unitPrice,
          })),
        },
      },
      include: { lineItems: true },
    });
  }

  async findByShopifyOrderId(shopifyOrderId: string): Promise<OrderWithLineItems | null> {
    return this.prisma.order.findUnique({
      where: { shopifyOrderId },
      include: { lineItems: true },
    });
  }

  async findById(id: string): Promise<OrderWithLineItems | null> {
    return this.prisma.order.findUnique({
      where: { id },
      include: { lineItems: true },
    });
  }

  async findAll(params: {
    status?: OrderStatus;
    skip?: number;
    take?: number;
    orderBy?: Prisma.OrderOrderByWithRelationInput;
  }): Promise<{ orders: OrderWithLineItems[]; total: number }> {
    const where: Prisma.OrderWhereInput = params.status ? { status: params.status } : {};

    const [orders, total] = await Promise.all([
      this.prisma.order.findMany({
        where,
        skip: params.skip,
        take: params.take,
        orderBy: params.orderBy || { createdAt: 'desc' },
        include: { lineItems: true },
      }),
      this.prisma.order.count({ where }),
    ]);

    return { orders, total };
  }

  async updateStatus(
    id: string,
    status: OrderStatus,
    additionalData?: Partial<Order>
  ): Promise<Order> {
    return this.prisma.order.update({
      where: { id },
      data: {
        status,
        ...additionalData,
        ...(status === OrderStatus.COMPLETED ? { completedAt: new Date() } : {}),
      },
    });
  }

  async updateFulfillment(
    id: string,
    fulfillmentData: {
      shopifyFulfillmentId: string;
      trackingNumber?: string;
      trackingUrl?: string;
    }
  ): Promise<Order> {
    return this.prisma.order.update({
      where: { id },
      data: {
        shopifyFulfillmentId: fulfillmentData.shopifyFulfillmentId,
        trackingNumber: fulfillmentData.trackingNumber,
        trackingUrl: fulfillmentData.trackingUrl,
        status: OrderStatus.COMPLETED,
        completedAt: new Date(),
      },
    });
  }

  async exists(shopifyOrderId: string): Promise<boolean> {
    const count = await this.prisma.order.count({
      where: { shopifyOrderId },
    });
    return count > 0;
  }
}

2. Orders Service

Create apps/api/src/orders/orders.service.ts:

import { Injectable, Logger, NotFoundException, ConflictException } from '@nestjs/common';
import { OrdersRepository, CreateOrderInput, OrderWithLineItems } from './orders.repository';
import { OrderStatus, LineItemStatus } from '@prisma/client';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { EventLogService } from '../event-log/event-log.service';

export interface OrderCreatedEvent {
  orderId: string;
  shopifyOrderId: string;
  lineItemCount: number;
}

export interface OrderStatusChangedEvent {
  orderId: string;
  previousStatus: OrderStatus;
  newStatus: OrderStatus;
}

@Injectable()
export class OrdersService {
  private readonly logger = new Logger(OrdersService.name);

  constructor(
    private readonly ordersRepository: OrdersRepository,
    private readonly eventEmitter: EventEmitter2,
    private readonly eventLogService: EventLogService
  ) {}

  /**
   * Create order from Shopify webhook data
   * Implements idempotency - returns existing order if already exists
   */
  async createFromShopify(input: CreateOrderInput, webhookId: string): Promise<OrderWithLineItems> {
    // Check for existing order (idempotency)
    const existing = await this.ordersRepository.findByShopifyOrderId(input.shopifyOrderId);

    if (existing) {
      this.logger.debug(`Order ${input.shopifyOrderId} already exists, skipping creation`);
      return existing;
    }

    // Create new order
    const order = await this.ordersRepository.create(input);

    this.logger.log(`Created order ${order.id} from Shopify order ${input.shopifyOrderId}`);

    // Log event
    await this.eventLogService.log({
      orderId: order.id,
      eventType: 'order.created',
      severity: 'INFO',
      message: `Order created from Shopify webhook`,
      metadata: {
        webhookId,
        shopifyOrderNumber: input.shopifyOrderNumber,
        lineItemCount: order.lineItems.length,
      },
    });

    // Emit event for downstream processing
    this.eventEmitter.emit('order.created', {
      orderId: order.id,
      shopifyOrderId: input.shopifyOrderId,
      lineItemCount: order.lineItems.length,
    } satisfies OrderCreatedEvent);

    return order;
  }

  async findById(id: string): Promise<OrderWithLineItems> {
    const order = await this.ordersRepository.findById(id);
    if (!order) {
      throw new NotFoundException(`Order ${id} not found`);
    }
    return order;
  }

  async findByShopifyOrderId(shopifyOrderId: string): Promise<OrderWithLineItems | null> {
    return this.ordersRepository.findByShopifyOrderId(shopifyOrderId);
  }

  async findAll(params: {
    status?: OrderStatus;
    page?: number;
    pageSize?: number;
  }): Promise<{ orders: OrderWithLineItems[]; total: number; page: number; pageSize: number }> {
    const page = params.page || 1;
    const pageSize = params.pageSize || 50;

    const { orders, total } = await this.ordersRepository.findAll({
      status: params.status,
      skip: (page - 1) * pageSize,
      take: pageSize,
    });

    return { orders, total, page, pageSize };
  }

  async updateStatus(id: string, newStatus: OrderStatus): Promise<OrderWithLineItems> {
    const order = await this.findById(id);
    const previousStatus = order.status;

    if (previousStatus === newStatus) {
      return order;
    }

    // Validate status transition
    this.validateStatusTransition(previousStatus, newStatus);

    await this.ordersRepository.updateStatus(id, newStatus);

    await this.eventLogService.log({
      orderId: id,
      eventType: 'order.status_changed',
      severity: 'INFO',
      message: `Order status changed from ${previousStatus} to ${newStatus}`,
      metadata: { previousStatus, newStatus },
    });

    this.eventEmitter.emit('order.status_changed', {
      orderId: id,
      previousStatus,
      newStatus,
    } satisfies OrderStatusChangedEvent);

    return this.findById(id);
  }

  async cancelOrder(id: string, reason?: string): Promise<OrderWithLineItems> {
    const order = await this.findById(id);

    if (order.status === OrderStatus.COMPLETED) {
      throw new ConflictException('Cannot cancel a completed order');
    }

    await this.ordersRepository.updateStatus(id, OrderStatus.CANCELLED);

    await this.eventLogService.log({
      orderId: id,
      eventType: 'order.cancelled',
      severity: 'WARNING',
      message: reason || 'Order cancelled',
      metadata: { previousStatus: order.status },
    });

    this.eventEmitter.emit('order.cancelled', { orderId: id, reason });

    return this.findById(id);
  }

  private validateStatusTransition(from: OrderStatus, to: OrderStatus): void {
    const validTransitions: Record<OrderStatus, OrderStatus[]> = {
      [OrderStatus.PENDING]: [OrderStatus.PROCESSING, OrderStatus.CANCELLED, OrderStatus.FAILED],
      [OrderStatus.PROCESSING]: [
        OrderStatus.PARTIALLY_COMPLETED,
        OrderStatus.COMPLETED,
        OrderStatus.FAILED,
        OrderStatus.CANCELLED,
      ],
      [OrderStatus.PARTIALLY_COMPLETED]: [
        OrderStatus.COMPLETED,
        OrderStatus.FAILED,
        OrderStatus.CANCELLED,
      ],
      [OrderStatus.COMPLETED]: [], // Terminal state
      [OrderStatus.FAILED]: [OrderStatus.PENDING], // Can retry
      [OrderStatus.CANCELLED]: [], // Terminal state
    };

    if (!validTransitions[from].includes(to)) {
      throw new ConflictException(`Invalid status transition from ${from} to ${to}`);
    }
  }
}

3. Event Log Service

Create apps/api/src/event-log/event-log.service.ts:

import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../database/prisma.service';
import { EventSeverity, Prisma } from '@prisma/client';

export interface LogEventInput {
  orderId?: string;
  printJobId?: string;
  eventType: string;
  severity: 'INFO' | 'WARNING' | 'ERROR';
  message: string;
  metadata?: Record<string, unknown>;
}

@Injectable()
export class EventLogService {
  private readonly logger = new Logger(EventLogService.name);

  constructor(private readonly prisma: PrismaService) {}

  async log(input: LogEventInput): Promise<void> {
    try {
      await this.prisma.eventLog.create({
        data: {
          orderId: input.orderId,
          printJobId: input.printJobId,
          eventType: input.eventType,
          severity: input.severity as EventSeverity,
          message: input.message,
          metadata: input.metadata as Prisma.JsonValue,
        },
      });

      // Also log to application logger
      const logMethod =
        input.severity === 'ERROR' ? 'error' : input.severity === 'WARNING' ? 'warn' : 'log';

      this.logger[logMethod](`[${input.eventType}] ${input.message}`, input.metadata);
    } catch (error) {
      // Don't fail the main operation if logging fails
      this.logger.error(`Failed to write event log: ${error}`);
    }
  }

  async findByOrderId(
    orderId: string,
    options?: { take?: number }
  ): Promise<
    Array<{
      id: string;
      eventType: string;
      severity: EventSeverity;
      message: string;
      metadata: Prisma.JsonValue;
      createdAt: Date;
    }>
  > {
    return this.prisma.eventLog.findMany({
      where: { orderId },
      orderBy: { createdAt: 'desc' },
      take: options?.take || 100,
      select: {
        id: true,
        eventType: true,
        severity: true,
        message: true,
        metadata: true,
        createdAt: true,
      },
    });
  }
}

🔧 Feature F1.4: Product Mapping System

Requirements Reference

  • FR-SH-003: Product-to-Print Mapping
  • FR-AD-003: Product Mapping Management

Implementation

1. Product Mapping Repository

Create apps/api/src/product-mappings/product-mappings.repository.ts:

import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../database/prisma.service';
import { ProductMapping, AssemblyPart, Prisma } from '@prisma/client';

export interface CreateProductMappingInput {
  shopifyProductId: string;
  shopifyVariantId?: string;
  sku: string;
  productName: string;
  description?: string;
  isAssembly?: boolean;
  defaultPrintProfile?: Prisma.JsonValue;
  assemblyParts?: CreateAssemblyPartInput[];
}

export interface CreateAssemblyPartInput {
  partName: string;
  partNumber: number;
  simplyPrintFileId: string;
  simplyPrintFileName?: string;
  printProfile?: Prisma.JsonValue;
  estimatedPrintTime?: number;
  quantityPerProduct?: number;
}

export type ProductMappingWithParts = ProductMapping & { assemblyParts: AssemblyPart[] };

@Injectable()
export class ProductMappingsRepository {
  private readonly logger = new Logger(ProductMappingsRepository.name);

  constructor(private readonly prisma: PrismaService) {}

  async create(input: CreateProductMappingInput): Promise<ProductMappingWithParts> {
    const { assemblyParts, ...mappingData } = input;

    return this.prisma.productMapping.create({
      data: {
        ...mappingData,
        assemblyParts: assemblyParts
          ? {
              create: assemblyParts.map((part) => ({
                partName: part.partName,
                partNumber: part.partNumber,
                simplyPrintFileId: part.simplyPrintFileId,
                simplyPrintFileName: part.simplyPrintFileName,
                printProfile: part.printProfile,
                estimatedPrintTime: part.estimatedPrintTime,
                quantityPerProduct: part.quantityPerProduct || 1,
              })),
            }
          : undefined,
      },
      include: { assemblyParts: true },
    });
  }

  async findBySku(sku: string): Promise<ProductMappingWithParts | null> {
    return this.prisma.productMapping.findUnique({
      where: { sku },
      include: { assemblyParts: { orderBy: { partNumber: 'asc' } } },
    });
  }

  async findById(id: string): Promise<ProductMappingWithParts | null> {
    return this.prisma.productMapping.findUnique({
      where: { id },
      include: { assemblyParts: { orderBy: { partNumber: 'asc' } } },
    });
  }

  async findAll(params?: {
    isActive?: boolean;
    skip?: number;
    take?: number;
  }): Promise<{ mappings: ProductMappingWithParts[]; total: number }> {
    const where: Prisma.ProductMappingWhereInput = {};

    if (params?.isActive !== undefined) {
      where.isActive = params.isActive;
    }

    const [mappings, total] = await Promise.all([
      this.prisma.productMapping.findMany({
        where,
        skip: params?.skip,
        take: params?.take,
        orderBy: { productName: 'asc' },
        include: { assemblyParts: { orderBy: { partNumber: 'asc' } } },
      }),
      this.prisma.productMapping.count({ where }),
    ]);

    return { mappings, total };
  }

  async update(
    id: string,
    input: Partial<Omit<CreateProductMappingInput, 'assemblyParts'>>
  ): Promise<ProductMapping> {
    return this.prisma.productMapping.update({
      where: { id },
      data: input,
    });
  }

  async setActive(id: string, isActive: boolean): Promise<ProductMapping> {
    return this.prisma.productMapping.update({
      where: { id },
      data: { isActive },
    });
  }

  async delete(id: string): Promise<void> {
    await this.prisma.productMapping.delete({ where: { id } });
  }

  async addAssemblyPart(mappingId: string, part: CreateAssemblyPartInput): Promise<AssemblyPart> {
    return this.prisma.assemblyPart.create({
      data: {
        productMappingId: mappingId,
        partName: part.partName,
        partNumber: part.partNumber,
        simplyPrintFileId: part.simplyPrintFileId,
        simplyPrintFileName: part.simplyPrintFileName,
        printProfile: part.printProfile,
        estimatedPrintTime: part.estimatedPrintTime,
        quantityPerProduct: part.quantityPerProduct || 1,
      },
    });
  }

  async updateAssemblyPart(
    partId: string,
    input: Partial<CreateAssemblyPartInput>
  ): Promise<AssemblyPart> {
    return this.prisma.assemblyPart.update({
      where: { id: partId },
      data: input,
    });
  }

  async deleteAssemblyPart(partId: string): Promise<void> {
    await this.prisma.assemblyPart.delete({ where: { id: partId } });
  }
}

2. Product Mapping Service

Create apps/api/src/product-mappings/product-mappings.service.ts:

import { Injectable, Logger, NotFoundException, ConflictException } from '@nestjs/common';
import {
  ProductMappingsRepository,
  CreateProductMappingInput,
  ProductMappingWithParts,
} from './product-mappings.repository';

@Injectable()
export class ProductMappingsService {
  private readonly logger = new Logger(ProductMappingsService.name);

  constructor(private readonly repository: ProductMappingsRepository) {}

  async create(input: CreateProductMappingInput): Promise<ProductMappingWithParts> {
    // Check for existing mapping with same SKU
    const existing = await this.repository.findBySku(input.sku);
    if (existing) {
      throw new ConflictException(`Product mapping with SKU ${input.sku} already exists`);
    }

    // If has assembly parts, mark as assembly
    const isAssembly = (input.assemblyParts?.length || 0) > 1;

    const mapping = await this.repository.create({
      ...input,
      isAssembly,
    });

    this.logger.log(`Created product mapping: ${mapping.sku} (${mapping.productName})`);
    return mapping;
  }

  async findBySku(sku: string): Promise<ProductMappingWithParts | null> {
    return this.repository.findBySku(sku);
  }

  async findById(id: string): Promise<ProductMappingWithParts> {
    const mapping = await this.repository.findById(id);
    if (!mapping) {
      throw new NotFoundException(`Product mapping ${id} not found`);
    }
    return mapping;
  }

  async findAll(params?: { isActive?: boolean; page?: number; pageSize?: number }): Promise<{
    mappings: ProductMappingWithParts[];
    total: number;
    page: number;
    pageSize: number;
  }> {
    const page = params?.page || 1;
    const pageSize = params?.pageSize || 50;

    const { mappings, total } = await this.repository.findAll({
      isActive: params?.isActive,
      skip: (page - 1) * pageSize,
      take: pageSize,
    });

    return { mappings, total, page, pageSize };
  }

  async update(
    id: string,
    input: Partial<Omit<CreateProductMappingInput, 'assemblyParts' | 'sku'>>
  ): Promise<ProductMappingWithParts> {
    await this.findById(id); // Ensure exists
    await this.repository.update(id, input);
    return this.findById(id);
  }

  async setActive(id: string, isActive: boolean): Promise<ProductMappingWithParts> {
    await this.findById(id); // Ensure exists
    await this.repository.setActive(id, isActive);
    this.logger.log(`Product mapping ${id} ${isActive ? 'activated' : 'deactivated'}`);
    return this.findById(id);
  }

  async delete(id: string): Promise<void> {
    await this.findById(id); // Ensure exists
    await this.repository.delete(id);
    this.logger.log(`Deleted product mapping ${id}`);
  }

  /**
   * Check if all SKUs in a list have active mappings
   * Returns list of unmapped SKUs
   */
  async findUnmappedSkus(skus: string[]): Promise<string[]> {
    const unmapped: string[] = [];

    for (const sku of skus) {
      const mapping = await this.repository.findBySku(sku);
      if (!mapping || !mapping.isActive) {
        unmapped.push(sku);
      }
    }

    return unmapped;
  }
}

3. Product Mapping Controller

Create apps/api/src/product-mappings/product-mappings.controller.ts:

import {
  Controller,
  Get,
  Post,
  Put,
  Delete,
  Body,
  Param,
  Query,
  HttpCode,
  HttpStatus,
  Logger,
} from '@nestjs/common';
import { ProductMappingsService } from './product-mappings.service';
import { CreateProductMappingDto } from './dto/create-product-mapping.dto';
import { ProductMappingDto, ProductMappingListDto } from './dto/product-mapping.dto';

@Controller('api/v1/product-mappings')
export class ProductMappingsController {
  private readonly logger = new Logger(ProductMappingsController.name);

  constructor(private readonly service: ProductMappingsService) {}

  @Post()
  async create(@Body() dto: CreateProductMappingDto): Promise<ProductMappingDto> {
    const mapping = await this.service.create(dto);
    return this.toDto(mapping);
  }

  @Get()
  async findAll(
    @Query('page') page?: string,
    @Query('pageSize') pageSize?: string,
    @Query('isActive') isActive?: string
  ): Promise<ProductMappingListDto> {
    const result = await this.service.findAll({
      page: page ? parseInt(page, 10) : undefined,
      pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
      isActive: isActive ? isActive === 'true' : undefined,
    });

    return {
      mappings: result.mappings.map((m) => this.toDto(m)),
      total: result.total,
      page: result.page,
      pageSize: result.pageSize,
    };
  }

  @Get(':id')
  async findById(@Param('id') id: string): Promise<ProductMappingDto> {
    const mapping = await this.service.findById(id);
    return this.toDto(mapping);
  }

  @Get('sku/:sku')
  async findBySku(@Param('sku') sku: string): Promise<ProductMappingDto | null> {
    const mapping = await this.service.findBySku(sku);
    return mapping ? this.toDto(mapping) : null;
  }

  @Put(':id')
  async update(
    @Param('id') id: string,
    @Body() dto: Partial<CreateProductMappingDto>
  ): Promise<ProductMappingDto> {
    const mapping = await this.service.update(id, dto);
    return this.toDto(mapping);
  }

  @Put(':id/activate')
  @HttpCode(HttpStatus.OK)
  async activate(@Param('id') id: string): Promise<ProductMappingDto> {
    const mapping = await this.service.setActive(id, true);
    return this.toDto(mapping);
  }

  @Put(':id/deactivate')
  @HttpCode(HttpStatus.OK)
  async deactivate(@Param('id') id: string): Promise<ProductMappingDto> {
    const mapping = await this.service.setActive(id, false);
    return this.toDto(mapping);
  }

  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  async delete(@Param('id') id: string): Promise<void> {
    await this.service.delete(id);
  }

  private toDto(mapping: {
    id: string;
    shopifyProductId: string;
    shopifyVariantId: string | null;
    sku: string;
    productName: string;
    description: string | null;
    isAssembly: boolean;
    isActive: boolean;
    createdAt: Date;
    updatedAt: Date;
    assemblyParts: Array<{
      id: string;
      partName: string;
      partNumber: number;
      simplyPrintFileId: string;
      simplyPrintFileName: string | null;
      quantityPerProduct: number;
    }>;
  }): ProductMappingDto {
    return {
      id: mapping.id,
      shopifyProductId: mapping.shopifyProductId,
      shopifyVariantId: mapping.shopifyVariantId,
      sku: mapping.sku,
      productName: mapping.productName,
      description: mapping.description,
      isAssembly: mapping.isAssembly,
      isActive: mapping.isActive,
      createdAt: mapping.createdAt.toISOString(),
      updatedAt: mapping.updatedAt.toISOString(),
      assemblyParts: mapping.assemblyParts.map((part) => ({
        id: part.id,
        partName: part.partName,
        partNumber: part.partNumber,
        simplyPrintFileId: part.simplyPrintFileId,
        simplyPrintFileName: part.simplyPrintFileName,
        quantityPerProduct: part.quantityPerProduct,
      })),
    };
  }
}

4. DTOs

Create apps/api/src/product-mappings/dto/create-product-mapping.dto.ts:

import {
  IsString,
  IsOptional,
  IsBoolean,
  IsArray,
  ValidateNested,
  IsNumber,
  Min,
} from 'class-validator';
import { Type } from 'class-transformer';

export class CreateAssemblyPartDto {
  @IsString()
  partName: string;

  @IsNumber()
  @Min(1)
  partNumber: number;

  @IsString()
  simplyPrintFileId: string;

  @IsOptional()
  @IsString()
  simplyPrintFileName?: string;

  @IsOptional()
  @IsNumber()
  estimatedPrintTime?: number;

  @IsOptional()
  @IsNumber()
  @Min(1)
  quantityPerProduct?: number;
}

export class CreateProductMappingDto {
  @IsString()
  shopifyProductId: string;

  @IsOptional()
  @IsString()
  shopifyVariantId?: string;

  @IsString()
  sku: string;

  @IsString()
  productName: string;

  @IsOptional()
  @IsString()
  description?: string;

  @IsOptional()
  @IsBoolean()
  isAssembly?: boolean;

  @IsOptional()
  @IsArray()
  @ValidateNested({ each: true })
  @Type(() => CreateAssemblyPartDto)
  assemblyParts?: CreateAssemblyPartDto[];
}

Create apps/api/src/product-mappings/dto/product-mapping.dto.ts:

export interface AssemblyPartDto {
  id: string;
  partName: string;
  partNumber: number;
  simplyPrintFileId: string;
  simplyPrintFileName: string | null;
  quantityPerProduct: number;
}

export interface ProductMappingDto {
  id: string;
  shopifyProductId: string;
  shopifyVariantId: string | null;
  sku: string;
  productName: string;
  description: string | null;
  isAssembly: boolean;
  isActive: boolean;
  createdAt: string;
  updatedAt: string;
  assemblyParts: AssemblyPartDto[];
}

export interface ProductMappingListDto {
  mappings: ProductMappingDto[];
  total: number;
  page: number;
  pageSize: number;
}

🔧 Shopify Service (Webhook Handler)

Create apps/api/src/shopify/shopify.service.ts:

import { Injectable, Logger } from '@nestjs/common';
import { OrdersService } from '../orders/orders.service';
import { ProductMappingsService } from '../product-mappings/product-mappings.service';
import { EventLogService } from '../event-log/event-log.service';
import { OrderWebhookPayload } from './dto/shopify-webhook.dto';
import { ShopifyOrder, ShopifyLineItem } from '@forma3d/domain';
import { Decimal } from '@prisma/client/runtime/library';

@Injectable()
export class ShopifyService {
  private readonly logger = new Logger(ShopifyService.name);
  private readonly processedWebhooks = new Set<string>();

  constructor(
    private readonly ordersService: OrdersService,
    private readonly productMappingsService: ProductMappingsService,
    private readonly eventLogService: EventLogService
  ) {}

  async handleOrderCreated(payload: OrderWebhookPayload, webhookId: string): Promise<void> {
    // Idempotency check
    if (this.processedWebhooks.has(webhookId)) {
      this.logger.debug(`Webhook ${webhookId} already processed, skipping`);
      return;
    }
    this.processedWebhooks.add(webhookId);

    // Skip test orders in production
    if (payload.test) {
      this.logger.debug(`Skipping test order ${payload.name}`);
      return;
    }

    // Extract SKUs and check for unmapped products
    const skus = payload.line_items
      .map((item) => item.sku)
      .filter((sku): sku is string => sku !== null);

    const unmappedSkus = await this.productMappingsService.findUnmappedSkus(skus);

    if (unmappedSkus.length > 0) {
      this.logger.warn(`Order ${payload.name} contains unmapped SKUs: ${unmappedSkus.join(', ')}`);
      await this.eventLogService.log({
        eventType: 'order.unmapped_products',
        severity: 'WARNING',
        message: `Order ${payload.name} contains unmapped products`,
        metadata: {
          shopifyOrderId: String(payload.id),
          unmappedSkus,
        },
      });
    }

    // Create order
    const order = await this.ordersService.createFromShopify(
      {
        shopifyOrderId: String(payload.id),
        shopifyOrderNumber: payload.name,
        customerName: this.extractCustomerName(payload),
        customerEmail: payload.email || undefined,
        shippingAddress: payload.shipping_address || {},
        totalPrice: new Decimal(payload.total_price),
        currency: payload.currency,
        lineItems: payload.line_items.map((item) => this.mapLineItem(item)),
      },
      webhookId
    );

    this.logger.log(`Processed order created webhook for ${order.shopifyOrderNumber}`);
  }

  async handleOrderUpdated(payload: OrderWebhookPayload, webhookId: string): Promise<void> {
    const existingOrder = await this.ordersService.findByShopifyOrderId(String(payload.id));

    if (!existingOrder) {
      // Order doesn't exist yet, treat as create
      this.logger.debug(`Order ${payload.id} not found, treating update as create`);
      return this.handleOrderCreated(payload, webhookId);
    }

    // Log the update
    await this.eventLogService.log({
      orderId: existingOrder.id,
      eventType: 'order.updated',
      severity: 'INFO',
      message: `Order updated in Shopify`,
      metadata: {
        webhookId,
        financialStatus: payload.financial_status,
        fulfillmentStatus: payload.fulfillment_status,
      },
    });

    this.logger.log(`Processed order update webhook for ${existingOrder.shopifyOrderNumber}`);
  }

  async handleOrderCancelled(payload: OrderWebhookPayload, webhookId: string): Promise<void> {
    const existingOrder = await this.ordersService.findByShopifyOrderId(String(payload.id));

    if (!existingOrder) {
      this.logger.warn(`Cannot cancel order ${payload.id} - not found in database`);
      return;
    }

    await this.ordersService.cancelOrder(
      existingOrder.id,
      `Cancelled in Shopify at ${payload.cancelled_at}`
    );

    this.logger.log(`Processed order cancellation webhook for ${existingOrder.shopifyOrderNumber}`);
  }

  async handleOrderFulfilled(payload: OrderWebhookPayload, webhookId: string): Promise<void> {
    const existingOrder = await this.ordersService.findByShopifyOrderId(String(payload.id));

    if (!existingOrder) {
      this.logger.warn(`Cannot process fulfillment for order ${payload.id} - not found`);
      return;
    }

    // This handles external fulfillments (not through our system)
    await this.eventLogService.log({
      orderId: existingOrder.id,
      eventType: 'order.fulfilled_externally',
      severity: 'INFO',
      message: `Order fulfilled externally in Shopify`,
      metadata: {
        webhookId,
        fulfillments: payload.fulfillments,
      },
    });

    this.logger.log(
      `Processed external fulfillment webhook for ${existingOrder.shopifyOrderNumber}`
    );
  }

  private extractCustomerName(order: ShopifyOrder): string {
    if (order.customer) {
      const firstName = order.customer.first_name || '';
      const lastName = order.customer.last_name || '';
      const fullName = `${firstName} ${lastName}`.trim();
      if (fullName) return fullName;
    }

    if (order.shipping_address) {
      const firstName = order.shipping_address.first_name || '';
      const lastName = order.shipping_address.last_name || '';
      const fullName = `${firstName} ${lastName}`.trim();
      if (fullName) return fullName;
    }

    return 'Unknown Customer';
  }

  private mapLineItem(item: ShopifyLineItem) {
    return {
      shopifyLineItemId: String(item.id),
      productSku: item.sku || `NOSKU-${item.id}`,
      productName: item.title,
      variantTitle: item.variant_title || undefined,
      quantity: item.quantity,
      unitPrice: new Decimal(item.price),
    };
  }
}

📦 Module Configuration

Shopify Module

Create apps/api/src/shopify/shopify.module.ts:

import { Module } from '@nestjs/common';
import { ShopifyController } from './shopify.controller';
import { ShopifyService } from './shopify.service';
import { ShopifyApiClient } from './shopify-api.client';
import { OrdersModule } from '../orders/orders.module';
import { ProductMappingsModule } from '../product-mappings/product-mappings.module';
import { EventLogModule } from '../event-log/event-log.module';

@Module({
  imports: [OrdersModule, ProductMappingsModule, EventLogModule],
  controllers: [ShopifyController],
  providers: [ShopifyService, ShopifyApiClient],
  exports: [ShopifyService, ShopifyApiClient],
})
export class ShopifyModule {}

Orders Module

Create apps/api/src/orders/orders.module.ts:

import { Module } from '@nestjs/common';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
import { OrdersRepository } from './orders.repository';
import { DatabaseModule } from '../database/database.module';
import { EventLogModule } from '../event-log/event-log.module';

@Module({
  imports: [DatabaseModule, EventLogModule],
  controllers: [OrdersController],
  providers: [OrdersService, OrdersRepository],
  exports: [OrdersService, OrdersRepository],
})
export class OrdersModule {}

Product Mappings Module

Create apps/api/src/product-mappings/product-mappings.module.ts:

import { Module } from '@nestjs/common';
import { ProductMappingsController } from './product-mappings.controller';
import { ProductMappingsService } from './product-mappings.service';
import { ProductMappingsRepository } from './product-mappings.repository';
import { DatabaseModule } from '../database/database.module';

@Module({
  imports: [DatabaseModule],
  controllers: [ProductMappingsController],
  providers: [ProductMappingsService, ProductMappingsRepository],
  exports: [ProductMappingsService, ProductMappingsRepository],
})
export class ProductMappingsModule {}

Event Log Module

Create apps/api/src/event-log/event-log.module.ts:

import { Module, Global } from '@nestjs/common';
import { EventLogService } from './event-log.service';
import { DatabaseModule } from '../database/database.module';

@Global()
@Module({
  imports: [DatabaseModule],
  providers: [EventLogService],
  exports: [EventLogService],
})
export class EventLogModule {}

Update App Module

Update apps/api/src/app/app.module.ts:

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { DatabaseModule } from '../database/database.module';
import { HealthModule } from '../health/health.module';
import { ConfigurationModule } from '../config/config.module';
import { ShopifyModule } from '../shopify/shopify.module';
import { OrdersModule } from '../orders/orders.module';
import { ProductMappingsModule } from '../product-mappings/product-mappings.module';
import { EventLogModule } from '../event-log/event-log.module';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: ['.env.local', '.env'],
    }),
    EventEmitterModule.forRoot(),
    ConfigurationModule,
    DatabaseModule,
    HealthModule,
    EventLogModule,
    ShopifyModule,
    OrdersModule,
    ProductMappingsModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

🧪 Testing Requirements

Test Coverage Requirements

Per requirements.md (NFR-MA-002):

  • Unit Tests: > 80% coverage
  • Integration Tests: All API integrations covered
  • E2E Tests: Critical paths covered

All tests MUST pass in the Azure DevOps pipeline before merge.

Test File Structure

apps/api/src/
├── shopify/
│   ├── __tests__/
│   │   ├── shopify-api.client.spec.ts      # Unit tests
│   │   ├── shopify.service.spec.ts         # Unit tests
│   │   ├── shopify-webhook.guard.spec.ts   # Unit tests
│   │   └── shopify.controller.spec.ts      # Unit tests
│
├── orders/
│   ├── __tests__/
│   │   ├── orders.repository.spec.ts       # Unit tests
│   │   ├── orders.service.spec.ts          # Unit tests
│   │   └── orders.controller.spec.ts       # Unit tests
│
├── product-mappings/
│   ├── __tests__/
│   │   ├── product-mappings.repository.spec.ts
│   │   ├── product-mappings.service.spec.ts
│   │   └── product-mappings.controller.spec.ts
│
└── event-log/
    └── __tests__/
        └── event-log.service.spec.ts

apps/api-e2e/src/
├── shopify-webhooks/
│   └── shopify-webhooks.spec.ts            # E2E webhook tests
├── orders/
│   └── orders.spec.ts                      # E2E order API tests
└── product-mappings/
    └── product-mappings.spec.ts            # E2E mapping API tests

Unit Test Examples

OrdersService Unit Test

Create apps/api/src/orders/__tests__/orders.service.spec.ts:

import { Test, TestingModule } from '@nestjs/testing';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { OrdersService } from '../orders.service';
import { OrdersRepository } from '../orders.repository';
import { EventLogService } from '../../event-log/event-log.service';
import { OrderStatus } from '@prisma/client';
import { ConflictException, NotFoundException } from '@nestjs/common';
import { Decimal } from '@prisma/client/runtime/library';

describe('OrdersService', () => {
  let service: OrdersService;
  let repository: jest.Mocked<OrdersRepository>;
  let eventEmitter: jest.Mocked<EventEmitter2>;
  let eventLogService: jest.Mocked<EventLogService>;

  const mockOrder = {
    id: 'order-uuid-1',
    shopifyOrderId: '123456789',
    shopifyOrderNumber: '#1001',
    status: OrderStatus.PENDING,
    customerName: 'John Doe',
    customerEmail: 'john@example.com',
    shippingAddress: { city: 'Amsterdam' },
    totalPrice: new Decimal('99.99'),
    currency: 'EUR',
    totalParts: 0,
    completedParts: 0,
    shopifyFulfillmentId: null,
    trackingNumber: null,
    trackingUrl: null,
    createdAt: new Date(),
    updatedAt: new Date(),
    completedAt: null,
    lineItems: [],
  };

  beforeEach(async () => {
    const mockRepository = {
      create: jest.fn(),
      findById: jest.fn(),
      findByShopifyOrderId: jest.fn(),
      findAll: jest.fn(),
      updateStatus: jest.fn(),
      exists: jest.fn(),
    };

    const mockEventEmitter = {
      emit: jest.fn(),
    };

    const mockEventLogService = {
      log: jest.fn(),
    };

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        OrdersService,
        { provide: OrdersRepository, useValue: mockRepository },
        { provide: EventEmitter2, useValue: mockEventEmitter },
        { provide: EventLogService, useValue: mockEventLogService },
      ],
    }).compile();

    service = module.get<OrdersService>(OrdersService);
    repository = module.get(OrdersRepository);
    eventEmitter = module.get(EventEmitter2);
    eventLogService = module.get(EventLogService);
  });

  describe('createFromShopify', () => {
    const createInput = {
      shopifyOrderId: '123456789',
      shopifyOrderNumber: '#1001',
      customerName: 'John Doe',
      customerEmail: 'john@example.com',
      shippingAddress: { city: 'Amsterdam' },
      totalPrice: new Decimal('99.99'),
      currency: 'EUR',
      lineItems: [],
    };

    it('should create a new order when it does not exist', async () => {
      repository.findByShopifyOrderId.mockResolvedValue(null);
      repository.create.mockResolvedValue(mockOrder);

      const result = await service.createFromShopify(createInput, 'webhook-123');

      expect(repository.findByShopifyOrderId).toHaveBeenCalledWith('123456789');
      expect(repository.create).toHaveBeenCalledWith(createInput);
      expect(eventLogService.log).toHaveBeenCalled();
      expect(eventEmitter.emit).toHaveBeenCalledWith('order.created', expect.any(Object));
      expect(result).toEqual(mockOrder);
    });

    it('should return existing order for idempotency', async () => {
      repository.findByShopifyOrderId.mockResolvedValue(mockOrder);

      const result = await service.createFromShopify(createInput, 'webhook-123');

      expect(repository.create).not.toHaveBeenCalled();
      expect(result).toEqual(mockOrder);
    });
  });

  describe('updateStatus', () => {
    it('should update status with valid transition', async () => {
      repository.findById.mockResolvedValue(mockOrder);
      repository.updateStatus.mockResolvedValue({ ...mockOrder, status: OrderStatus.PROCESSING });

      await service.updateStatus('order-uuid-1', OrderStatus.PROCESSING);

      expect(repository.updateStatus).toHaveBeenCalledWith('order-uuid-1', OrderStatus.PROCESSING);
      expect(eventEmitter.emit).toHaveBeenCalledWith('order.status_changed', expect.any(Object));
    });

    it('should throw ConflictException for invalid transition', async () => {
      const completedOrder = { ...mockOrder, status: OrderStatus.COMPLETED };
      repository.findById.mockResolvedValue(completedOrder);

      await expect(service.updateStatus('order-uuid-1', OrderStatus.PENDING)).rejects.toThrow(
        ConflictException
      );
    });

    it('should throw NotFoundException when order not found', async () => {
      repository.findById.mockResolvedValue(null);

      await expect(service.updateStatus('non-existent', OrderStatus.PROCESSING)).rejects.toThrow(
        NotFoundException
      );
    });
  });

  describe('cancelOrder', () => {
    it('should cancel a pending order', async () => {
      repository.findById.mockResolvedValue(mockOrder);
      repository.updateStatus.mockResolvedValue({ ...mockOrder, status: OrderStatus.CANCELLED });

      await service.cancelOrder('order-uuid-1', 'Customer requested');

      expect(repository.updateStatus).toHaveBeenCalledWith('order-uuid-1', OrderStatus.CANCELLED);
      expect(eventLogService.log).toHaveBeenCalled();
    });

    it('should throw ConflictException when cancelling completed order', async () => {
      const completedOrder = { ...mockOrder, status: OrderStatus.COMPLETED };
      repository.findById.mockResolvedValue(completedOrder);

      await expect(service.cancelOrder('order-uuid-1')).rejects.toThrow(ConflictException);
    });
  });
});

ShopifyWebhookGuard Unit Test

Create apps/api/src/shopify/__tests__/shopify-webhook.guard.spec.ts:

import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ShopifyWebhookGuard } from '../guards/shopify-webhook.guard';
import * as crypto from 'crypto';

describe('ShopifyWebhookGuard', () => {
  let guard: ShopifyWebhookGuard;
  const webhookSecret = 'test-webhook-secret';

  beforeEach(() => {
    const configService = {
      getOrThrow: jest.fn().mockReturnValue(webhookSecret),
    } as unknown as ConfigService;

    guard = new ShopifyWebhookGuard(configService);
  });

  const createMockContext = (
    headers: Record<string, string>,
    rawBody?: Buffer
  ): ExecutionContext => {
    return {
      switchToHttp: () => ({
        getRequest: () => ({
          headers,
          rawBody,
        }),
      }),
    } as unknown as ExecutionContext;
  };

  const generateValidHmac = (body: string): string => {
    return crypto.createHmac('sha256', webhookSecret).update(body, 'utf8').digest('base64');
  };

  it('should pass with valid HMAC signature', () => {
    const body = JSON.stringify({ id: 123, name: '#1001' });
    const hmac = generateValidHmac(body);

    const context = createMockContext(
      {
        'x-shopify-hmac-sha256': hmac,
        'x-shopify-shop-domain': 'test.myshopify.com',
        'x-shopify-topic': 'orders/create',
      },
      Buffer.from(body)
    );

    expect(guard.canActivate(context)).toBe(true);
  });

  it('should throw UnauthorizedException with missing HMAC header', () => {
    const context = createMockContext(
      {
        'x-shopify-shop-domain': 'test.myshopify.com',
        'x-shopify-topic': 'orders/create',
      },
      Buffer.from('{}')
    );

    expect(() => guard.canActivate(context)).toThrow(UnauthorizedException);
  });

  it('should throw UnauthorizedException with invalid HMAC signature', () => {
    const context = createMockContext(
      {
        'x-shopify-hmac-sha256': 'invalid-signature',
        'x-shopify-shop-domain': 'test.myshopify.com',
        'x-shopify-topic': 'orders/create',
      },
      Buffer.from('{"id": 123}')
    );

    expect(() => guard.canActivate(context)).toThrow(UnauthorizedException);
  });

  it('should throw UnauthorizedException when rawBody is missing', () => {
    const body = '{"id": 123}';
    const hmac = generateValidHmac(body);

    const context = createMockContext({
      'x-shopify-hmac-sha256': hmac,
      'x-shopify-shop-domain': 'test.myshopify.com',
      'x-shopify-topic': 'orders/create',
    });

    expect(() => guard.canActivate(context)).toThrow(UnauthorizedException);
  });
});

ProductMappingsService Unit Test

Create apps/api/src/product-mappings/__tests__/product-mappings.service.spec.ts:

import { Test, TestingModule } from '@nestjs/testing';
import { ProductMappingsService } from '../product-mappings.service';
import { ProductMappingsRepository } from '../product-mappings.repository';
import { ConflictException, NotFoundException } from '@nestjs/common';

describe('ProductMappingsService', () => {
  let service: ProductMappingsService;
  let repository: jest.Mocked<ProductMappingsRepository>;

  const mockMapping = {
    id: 'mapping-uuid-1',
    shopifyProductId: 'shopify-123',
    shopifyVariantId: 'variant-456',
    sku: 'ROBOT-KIT-001',
    productName: 'Robot Kit',
    description: 'A cool robot',
    isAssembly: true,
    defaultPrintProfile: null,
    isActive: true,
    createdAt: new Date(),
    updatedAt: new Date(),
    assemblyParts: [
      {
        id: 'part-1',
        productMappingId: 'mapping-uuid-1',
        partName: 'Body',
        partNumber: 1,
        simplyPrintFileId: 'file-123',
        simplyPrintFileName: 'robot-body.gcode',
        printProfile: null,
        estimatedPrintTime: 3600,
        estimatedFilament: null,
        quantityPerProduct: 1,
        isActive: true,
        createdAt: new Date(),
        updatedAt: new Date(),
      },
    ],
  };

  beforeEach(async () => {
    const mockRepository = {
      create: jest.fn(),
      findById: jest.fn(),
      findBySku: jest.fn(),
      findAll: jest.fn(),
      update: jest.fn(),
      setActive: jest.fn(),
      delete: jest.fn(),
    };

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        ProductMappingsService,
        { provide: ProductMappingsRepository, useValue: mockRepository },
      ],
    }).compile();

    service = module.get<ProductMappingsService>(ProductMappingsService);
    repository = module.get(ProductMappingsRepository);
  });

  describe('create', () => {
    it('should create a new product mapping', async () => {
      repository.findBySku.mockResolvedValue(null);
      repository.create.mockResolvedValue(mockMapping);

      const result = await service.create({
        shopifyProductId: 'shopify-123',
        sku: 'ROBOT-KIT-001',
        productName: 'Robot Kit',
      });

      expect(repository.create).toHaveBeenCalled();
      expect(result).toEqual(mockMapping);
    });

    it('should throw ConflictException if SKU already exists', async () => {
      repository.findBySku.mockResolvedValue(mockMapping);

      await expect(
        service.create({
          shopifyProductId: 'shopify-123',
          sku: 'ROBOT-KIT-001',
          productName: 'Robot Kit',
        })
      ).rejects.toThrow(ConflictException);
    });
  });

  describe('findUnmappedSkus', () => {
    it('should return SKUs without active mappings', async () => {
      repository.findBySku.mockImplementation(async (sku) => {
        if (sku === 'MAPPED-SKU') return mockMapping;
        return null;
      });

      const result = await service.findUnmappedSkus(['MAPPED-SKU', 'UNMAPPED-SKU']);

      expect(result).toEqual(['UNMAPPED-SKU']);
    });

    it('should include inactive mappings as unmapped', async () => {
      const inactiveMapping = { ...mockMapping, isActive: false };
      repository.findBySku.mockResolvedValue(inactiveMapping);

      const result = await service.findUnmappedSkus(['INACTIVE-SKU']);

      expect(result).toEqual(['INACTIVE-SKU']);
    });
  });

  describe('setActive', () => {
    it('should activate a mapping', async () => {
      repository.findById.mockResolvedValue(mockMapping);
      repository.setActive.mockResolvedValue({ ...mockMapping, isActive: true });

      const result = await service.setActive('mapping-uuid-1', true);

      expect(repository.setActive).toHaveBeenCalledWith('mapping-uuid-1', true);
    });

    it('should throw NotFoundException for non-existent mapping', async () => {
      repository.findById.mockResolvedValue(null);

      await expect(service.setActive('non-existent', true)).rejects.toThrow(NotFoundException);
    });
  });
});

E2E Test Examples

Shopify Webhooks E2E Test

Create apps/api-e2e/src/shopify-webhooks/shopify-webhooks.spec.ts:

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import * as crypto from 'crypto';
import { AppModule } from '../../../api/src/app/app.module';
import { PrismaService } from '../../../api/src/database/prisma.service';

describe('Shopify Webhooks (e2e)', () => {
  let app: INestApplication;
  let prisma: PrismaService;
  const webhookSecret = process.env.SHOPIFY_WEBHOOK_SECRET || 'test-secret';

  const generateHmac = (body: string): string => {
    return crypto.createHmac('sha256', webhookSecret).update(body, 'utf8').digest('base64');
  };

  const mockOrderPayload = {
    id: 9876543210,
    name: '#1001',
    email: 'customer@example.com',
    created_at: '2026-01-09T10:00:00Z',
    updated_at: '2026-01-09T10:00:00Z',
    cancelled_at: null,
    closed_at: null,
    processed_at: '2026-01-09T10:00:00Z',
    financial_status: 'paid',
    fulfillment_status: null,
    currency: 'EUR',
    total_price: '99.99',
    subtotal_price: '89.99',
    total_tax: '10.00',
    total_discounts: '0.00',
    total_shipping_price_set: {
      shop_money: { amount: '5.00', currency_code: 'EUR' },
      presentment_money: { amount: '5.00', currency_code: 'EUR' },
    },
    customer: {
      id: 123,
      email: 'customer@example.com',
      first_name: 'John',
      last_name: 'Doe',
      phone: null,
    },
    billing_address: null,
    shipping_address: {
      first_name: 'John',
      last_name: 'Doe',
      address1: 'Main Street 123',
      address2: null,
      city: 'Amsterdam',
      province: 'North Holland',
      province_code: 'NH',
      country: 'Netherlands',
      country_code: 'NL',
      zip: '1012AB',
      phone: null,
      company: null,
    },
    line_items: [
      {
        id: 111222333,
        admin_graphql_api_id: 'gid://shopify/LineItem/111222333',
        product_id: 555666777,
        variant_id: 888999000,
        title: 'Robot Kit',
        variant_title: 'Blue',
        sku: 'ROBOT-KIT-001',
        quantity: 2,
        price: '44.99',
        fulfillable_quantity: 2,
        fulfillment_status: null,
      },
    ],
    fulfillments: [],
    note: null,
    tags: '',
    test: false,
  };

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));

    prisma = app.get(PrismaService);
    await app.init();
  });

  afterAll(async () => {
    await app.close();
  });

  beforeEach(async () => {
    // Clean up test data
    await prisma.eventLog.deleteMany({});
    await prisma.printJob.deleteMany({});
    await prisma.lineItem.deleteMany({});
    await prisma.order.deleteMany({});
  });

  describe('POST /api/v1/webhooks/shopify', () => {
    it('should reject requests without HMAC signature', async () => {
      const body = JSON.stringify(mockOrderPayload);

      await request(app.getHttpServer())
        .post('/api/v1/webhooks/shopify')
        .set('Content-Type', 'application/json')
        .set('X-Shopify-Topic', 'orders/create')
        .set('X-Shopify-Shop-Domain', 'test.myshopify.com')
        .send(body)
        .expect(401);
    });

    it('should reject requests with invalid HMAC signature', async () => {
      const body = JSON.stringify(mockOrderPayload);

      await request(app.getHttpServer())
        .post('/api/v1/webhooks/shopify')
        .set('Content-Type', 'application/json')
        .set('X-Shopify-Topic', 'orders/create')
        .set('X-Shopify-Hmac-Sha256', 'invalid-signature')
        .set('X-Shopify-Shop-Domain', 'test.myshopify.com')
        .set('X-Shopify-Webhook-Id', 'webhook-123')
        .send(body)
        .expect(401);
    });

    it('should process orders/create webhook and store order', async () => {
      const body = JSON.stringify(mockOrderPayload);
      const hmac = generateHmac(body);

      await request(app.getHttpServer())
        .post('/api/v1/webhooks/shopify')
        .set('Content-Type', 'application/json')
        .set('X-Shopify-Topic', 'orders/create')
        .set('X-Shopify-Hmac-Sha256', hmac)
        .set('X-Shopify-Shop-Domain', 'test.myshopify.com')
        .set('X-Shopify-Webhook-Id', 'webhook-123')
        .send(body)
        .expect(200)
        .expect({ received: true });

      // Verify order was created
      const order = await prisma.order.findUnique({
        where: { shopifyOrderId: '9876543210' },
        include: { lineItems: true },
      });

      expect(order).not.toBeNull();
      expect(order?.shopifyOrderNumber).toBe('#1001');
      expect(order?.customerName).toBe('John Doe');
      expect(order?.lineItems).toHaveLength(1);
      expect(order?.lineItems[0].productSku).toBe('ROBOT-KIT-001');
    });

    it('should handle duplicate webhooks idempotently', async () => {
      const body = JSON.stringify(mockOrderPayload);
      const hmac = generateHmac(body);
      const headers = {
        'Content-Type': 'application/json',
        'X-Shopify-Topic': 'orders/create',
        'X-Shopify-Hmac-Sha256': hmac,
        'X-Shopify-Shop-Domain': 'test.myshopify.com',
        'X-Shopify-Webhook-Id': 'webhook-duplicate-test',
      };

      // Send same webhook twice
      await request(app.getHttpServer())
        .post('/api/v1/webhooks/shopify')
        .set(headers)
        .send(body)
        .expect(200);

      await request(app.getHttpServer())
        .post('/api/v1/webhooks/shopify')
        .set(headers)
        .send(body)
        .expect(200);

      // Should only have one order
      const orders = await prisma.order.findMany({
        where: { shopifyOrderId: '9876543210' },
      });

      expect(orders).toHaveLength(1);
    });

    it('should handle orders/cancelled webhook', async () => {
      // First create the order
      const createBody = JSON.stringify(mockOrderPayload);
      const createHmac = generateHmac(createBody);

      await request(app.getHttpServer())
        .post('/api/v1/webhooks/shopify')
        .set('Content-Type', 'application/json')
        .set('X-Shopify-Topic', 'orders/create')
        .set('X-Shopify-Hmac-Sha256', createHmac)
        .set('X-Shopify-Shop-Domain', 'test.myshopify.com')
        .set('X-Shopify-Webhook-Id', 'webhook-create')
        .send(createBody)
        .expect(200);

      // Then cancel it
      const cancelPayload = {
        ...mockOrderPayload,
        cancelled_at: '2026-01-09T12:00:00Z',
      };
      const cancelBody = JSON.stringify(cancelPayload);
      const cancelHmac = generateHmac(cancelBody);

      await request(app.getHttpServer())
        .post('/api/v1/webhooks/shopify')
        .set('Content-Type', 'application/json')
        .set('X-Shopify-Topic', 'orders/cancelled')
        .set('X-Shopify-Hmac-Sha256', cancelHmac)
        .set('X-Shopify-Shop-Domain', 'test.myshopify.com')
        .set('X-Shopify-Webhook-Id', 'webhook-cancel')
        .send(cancelBody)
        .expect(200);

      // Verify order was cancelled
      const order = await prisma.order.findUnique({
        where: { shopifyOrderId: '9876543210' },
      });

      expect(order?.status).toBe('CANCELLED');
    });
  });
});

Orders API E2E Test

Create apps/api-e2e/src/orders/orders.spec.ts:

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../../../api/src/app/app.module';
import { PrismaService } from '../../../api/src/database/prisma.service';
import { OrderStatus } from '@prisma/client';

describe('Orders API (e2e)', () => {
  let app: INestApplication;
  let prisma: PrismaService;

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));

    prisma = app.get(PrismaService);
    await app.init();
  });

  afterAll(async () => {
    await app.close();
  });

  beforeEach(async () => {
    await prisma.eventLog.deleteMany({});
    await prisma.printJob.deleteMany({});
    await prisma.lineItem.deleteMany({});
    await prisma.order.deleteMany({});
  });

  describe('GET /api/v1/orders', () => {
    it('should return empty list when no orders exist', async () => {
      const response = await request(app.getHttpServer()).get('/api/v1/orders').expect(200);

      expect(response.body).toEqual({
        orders: [],
        total: 0,
        page: 1,
        pageSize: 50,
      });
    });

    it('should return paginated orders', async () => {
      // Create test orders
      await prisma.order.createMany({
        data: [
          {
            shopifyOrderId: '1',
            shopifyOrderNumber: '#1001',
            customerName: 'Customer 1',
            shippingAddress: {},
            totalPrice: 99.99,
            currency: 'EUR',
          },
          {
            shopifyOrderId: '2',
            shopifyOrderNumber: '#1002',
            customerName: 'Customer 2',
            shippingAddress: {},
            totalPrice: 149.99,
            currency: 'EUR',
          },
        ],
      });

      const response = await request(app.getHttpServer()).get('/api/v1/orders').expect(200);

      expect(response.body.total).toBe(2);
      expect(response.body.orders).toHaveLength(2);
    });

    it('should filter orders by status', async () => {
      await prisma.order.createMany({
        data: [
          {
            shopifyOrderId: '1',
            shopifyOrderNumber: '#1001',
            customerName: 'Customer 1',
            shippingAddress: {},
            totalPrice: 99.99,
            currency: 'EUR',
            status: OrderStatus.PENDING,
          },
          {
            shopifyOrderId: '2',
            shopifyOrderNumber: '#1002',
            customerName: 'Customer 2',
            shippingAddress: {},
            totalPrice: 149.99,
            currency: 'EUR',
            status: OrderStatus.COMPLETED,
          },
        ],
      });

      const response = await request(app.getHttpServer())
        .get('/api/v1/orders?status=PENDING')
        .expect(200);

      expect(response.body.total).toBe(1);
      expect(response.body.orders[0].shopifyOrderNumber).toBe('#1001');
    });
  });

  describe('GET /api/v1/orders/:id', () => {
    it('should return order by ID', async () => {
      const order = await prisma.order.create({
        data: {
          shopifyOrderId: '123',
          shopifyOrderNumber: '#1001',
          customerName: 'Test Customer',
          shippingAddress: { city: 'Amsterdam' },
          totalPrice: 99.99,
          currency: 'EUR',
        },
      });

      const response = await request(app.getHttpServer())
        .get(`/api/v1/orders/${order.id}`)
        .expect(200);

      expect(response.body.shopifyOrderNumber).toBe('#1001');
      expect(response.body.customerName).toBe('Test Customer');
    });

    it('should return 404 for non-existent order', async () => {
      await request(app.getHttpServer()).get('/api/v1/orders/non-existent-uuid').expect(404);
    });
  });
});

Jest Configuration for Coverage

Verify apps/api/jest.config.ts includes coverage configuration:

export default {
  displayName: 'api',
  preset: '../../jest.preset.js',
  testEnvironment: 'node',
  transform: {
    '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
  },
  moduleFileExtensions: ['ts', 'js', 'html'],
  coverageDirectory: '../../coverage/apps/api',
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.spec.ts',
    '!src/**/*.module.ts',
    '!src/main.ts',
    '!src/**/*.dto.ts',
    '!src/**/*.interface.ts',
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
  coverageReporters: ['text', 'lcov', 'cobertura'],
};

Azure Pipeline Coverage Verification

Ensure azure-pipelines.yml properly handles coverage. The existing pipeline should already include:

- script: pnpm nx affected --target=test --parallel=3 --coverage
  displayName: 'Run Unit Tests'

- task: PublishCodeCoverageResults@1
  displayName: 'Publish Code Coverage'
  condition: succeededOrFailed()
  inputs:
    codeCoverageTool: 'Cobertura'
    summaryFileLocation: '$(System.DefaultWorkingDirectory)/coverage/**/cobertura-coverage.xml'

✅ Validation Checklist

Infrastructure

  • All new modules compile without errors ✅
  • pnpm nx build api succeeds ✅
  • pnpm lint passes on all new files ✅
  • No TypeScript errors (strict mode) ✅

Testing Requirements

  • pnpm nx test api passes all tests ✅
  • pnpm nx test api --coverage shows >80% coverage (in progress)
  • pnpm nx e2e api-e2e passes all E2E tests ✅
  • Coverage reports generated in coverage/apps/api/
  • Cobertura XML generated for Azure pipeline ✅

Shopify Integration (F1.1)

  • ShopifyApiClient instantiates correctly with env vars ✅
  • getOrder() retrieves order by ID ✅
  • getOrders() retrieves order list with filters ✅
  • Rate limiting handles 429 responses ✅
  • Error responses are logged properly ✅

Webhook Receiver (F1.2)

  • POST /api/v1/webhooks/shopify endpoint exists ✅
  • Invalid HMAC returns 401 Unauthorized ✅
  • Valid HMAC passes and processes webhook ✅
  • Duplicate webhooks handled idempotently ✅
  • All webhook topics handled: create, update, cancel, fulfilled ✅

Order Storage (F1.3)

  • Orders created from webhook payload ✅
  • Orders stored with all line items ✅
  • Duplicate orders not created (idempotency) ✅
  • Order status transitions validated ✅
  • Events logged to EventLog table ✅
  • GET /api/v1/orders returns paginated list ✅
  • GET /api/v1/orders/:id returns single order ✅

Product Mapping (F1.4)

  • Product mappings can be created (POST) ✅
  • Product mappings can be queried by SKU ✅
  • Product mappings can be listed (GET) ✅
  • Product mappings can be updated (PUT) ✅
  • Product mappings can be deactivated ✅
  • Unmapped SKUs detected during order creation ✅

OpenAPI/Swagger (F1.0) - Added

  • Swagger UI available at /api/docs
  • OpenAPI 3.0 JSON available at /api/docs-json
  • All controllers decorated with @ApiTags
  • All endpoints decorated with @ApiOperation, @ApiResponse
  • All DTOs decorated with @ApiProperty

Status: Phase 1 COMPLETE ✅ (Completed January 2026) - [ ] Assembly parts can be added to mappings

Integration Tests

  • End-to-end test: mock webhook → order created
  • Idempotency test: same webhook twice → one order
  • Cancellation test: cancel webhook → order cancelled
  • Unmapped product test: warning logged

🚫 Constraints and Rules

MUST DO

  • Verify HMAC signature for ALL webhook requests
  • Use idempotency keys to prevent duplicate processing
  • Log ALL webhook events to EventLog
  • Return 200 OK to webhooks even on processing errors (to prevent infinite retries)
  • Use transactions for multi-table operations
  • Handle rate limiting with exponential backoff

MUST NOT

  • Store raw Shopify payloads (data minimization)
  • Process orders with no line items
  • Skip HMAC verification for any reason
  • Block webhook responses for long-running operations
  • Use any type in DTOs or services
  • Expose internal errors in API responses

🎬 Execution Order

  1. Create Shopify types in libs/domain
  2. Create ShopifyApiClient with authentication
  3. Create webhook guard with HMAC verification
  4. Update main.ts for raw body access
  5. Create EventLogService (needed by other services)
  6. Create OrdersRepository and OrdersService
  7. Create ProductMappingsRepository and ProductMappingsService
  8. Create ShopifyService (webhook handler)
  9. Create ShopifyController (webhook endpoint)
  10. Create modules and update AppModule
  11. Add Orders controller for REST API
  12. Add ProductMappings controller for REST API
  13. Write unit tests for all services and guards
    • OrdersService tests
    • ProductMappingsService tests
    • ShopifyService tests
    • ShopifyWebhookGuard tests
    • EventLogService tests
  14. Write E2E tests for webhook and API endpoints
    • Shopify webhook E2E tests
    • Orders API E2E tests
    • Product Mappings API E2E tests
  15. Verify Jest configuration for coverage thresholds
  16. Run coverage check: pnpm nx test api --coverage
  17. Run E2E tests: pnpm nx e2e api-e2e
  18. Verify Azure pipeline will pass with coverage reports
  19. Run full validation checklist

📊 Expected Output

When Phase 1 is complete:

Test & Coverage Verification

# Run all unit tests with coverage
pnpm nx test api --coverage
# Expected output:
# -------------------------|---------|----------|---------|---------|
# File                     | % Stmts | % Branch | % Funcs | % Lines |
# -------------------------|---------|----------|---------|---------|
# All files                |   >80   |   >80    |   >80   |   >80   |
# -------------------------|---------|----------|---------|---------|
# Test Suites: X passed, X total
# Tests:       Y passed, Y total

# Run E2E tests
pnpm nx e2e api-e2e
# Expected output:
# PASS  apps/api-e2e/src/shopify-webhooks/shopify-webhooks.spec.ts
# PASS  apps/api-e2e/src/orders/orders.spec.ts
# PASS  apps/api-e2e/src/product-mappings/product-mappings.spec.ts
# Test Suites: 3 passed, 3 total

# Verify coverage files exist for Azure pipeline
ls coverage/apps/api/
# Expected: lcov.info, cobertura-coverage.xml, lcov-report/

# Full pipeline simulation
pnpm lint && pnpm nx test api --coverage && pnpm nx e2e api-e2e && pnpm build
# All must pass

API Endpoint Verification

# Test webhook endpoint (simulated - real test requires valid HMAC)
curl -X POST http://localhost:3000/api/v1/webhooks/shopify \
  -H "Content-Type: application/json" \
  -H "X-Shopify-Topic: orders/create" \
  -H "X-Shopify-Hmac-Sha256: <valid-signature>" \
  -d '{"id": 123, "name": "#1001", ...}'
# Returns: {"received": true}

# List orders
curl http://localhost:3000/api/v1/orders
# Returns: {"orders": [...], "total": N, "page": 1, "pageSize": 50}

# Create product mapping
curl -X POST http://localhost:3000/api/v1/product-mappings \
  -H "Content-Type: application/json" \
  -d '{
    "shopifyProductId": "12345",
    "sku": "ROBOT-KIT-001",
    "productName": "Robot Kit",
    "assemblyParts": [
      {"partName": "Body", "partNumber": 1, "simplyPrintFileId": "file-123"},
      {"partName": "Arm", "partNumber": 2, "simplyPrintFileId": "file-456"}
    ]
  }'
# Returns: Created product mapping with assembly parts

# Get mapping by SKU
curl http://localhost:3000/api/v1/product-mappings/sku/ROBOT-KIT-001
# Returns: Product mapping with assembly parts

🔗 Phase 1 Exit Criteria

From implementation-plan.md:

  • Shopify webhooks received and verified
  • Orders stored in database
  • Order status tracking working
  • Product mappings configurable
  • Integration tests passing

Additional Testing Exit Criteria

  • Unit test coverage > 80% for all new code
  • All unit tests pass: pnpm nx test api
  • All E2E tests pass: pnpm nx e2e api-e2e
  • Coverage reports generated (lcov, cobertura)
  • Azure pipeline passes locally: pnpm lint && pnpm test && pnpm build
  • No skipped or pending tests

END OF PROMPT


This prompt builds on the Phase 0 foundation. The AI should implement all Phase 1 features while maintaining the established code style, architectural patterns, and testing standards.