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 apisucceeds ✅ -
pnpm lintpasses on all new files ✅ - No TypeScript errors (strict mode) ✅
Testing Requirements¶
-
pnpm nx test apipasses all tests ✅ -
pnpm nx test api --coverageshows >80% coverage (in progress) -
pnpm nx e2e api-e2epasses all E2E tests ✅ - Coverage reports generated in
coverage/apps/api/✅ - Cobertura XML generated for Azure pipeline ✅
Shopify Integration (F1.1)¶
-
ShopifyApiClientinstantiates 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/shopifyendpoint 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/ordersreturns paginated list ✅ -
GET /api/v1/orders/:idreturns 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
anytype in DTOs or services - Expose internal errors in API responses
🎬 Execution Order¶
- Create Shopify types in
libs/domain - Create ShopifyApiClient with authentication
- Create webhook guard with HMAC verification
- Update main.ts for raw body access
- Create EventLogService (needed by other services)
- Create OrdersRepository and OrdersService
- Create ProductMappingsRepository and ProductMappingsService
- Create ShopifyService (webhook handler)
- Create ShopifyController (webhook endpoint)
- Create modules and update AppModule
- Add Orders controller for REST API
- Add ProductMappings controller for REST API
- Write unit tests for all services and guards
- OrdersService tests
- ProductMappingsService tests
- ShopifyService tests
- ShopifyWebhookGuard tests
- EventLogService tests
- Write E2E tests for webhook and API endpoints
- Shopify webhook E2E tests
- Orders API E2E tests
- Product Mappings API E2E tests
- Verify Jest configuration for coverage thresholds
- Run coverage check:
pnpm nx test api --coverage - Run E2E tests:
pnpm nx e2e api-e2e - Verify Azure pipeline will pass with coverage reports
- 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.