AI Prompt: Forma3D.Connect — Phase 3: Fulfillment Loop ⏳¶
Purpose: This prompt instructs an AI to implement Phase 3 of Forma3D.Connect
Estimated Effort: 30 hours (~2 weeks)
Prerequisites: Phase 2 completed (SimplyPrint Core - print job creation and status monitoring)
Output: Complete automation loop: Order → Print → Fulfill, with error recovery and notifications
Status: ✅ COMPLETED (2026-01-14)
🎯 Mission¶
You are continuing development of Forma3D.Connect, building on the Phase 2 foundation. Your task is to implement Phase 3: Fulfillment Loop — completing the automation cycle by automatically creating Shopify fulfillments when print jobs complete, handling order cancellations, and implementing robust error recovery.
Phase 3 delivers:
- Automated Shopify fulfillment creation when all print jobs for an order complete
- Order cancellation handling (cancelling queued/pending print jobs in SimplyPrint)
- Retry mechanisms with exponential backoff for transient failures
- Event-driven notification system for critical failures
Phase 3 marks the completion of the MVP automation goal:
Order → Print Job → Print Complete → Fulfillment → Customer Notified
📋 Phase 3 Context¶
What Was Built in Previous Phases¶
The foundation is already in place:
- Phase 0: Foundation
- Nx monorepo with
apps/api,apps/web, and shared libs - PostgreSQL database with Prisma schema (Order, LineItem, PrintJob, ProductMapping, EventLog)
- NestJS backend structure with modules, services, repositories
-
Azure DevOps CI/CD pipeline
-
Phase 1: Shopify Inbound
- Shopify webhooks receiver with HMAC verification
- Order storage and status management
- Product mapping CRUD operations
- Event logging service
- OpenAPI/Swagger documentation at
/api/docs -
Aikido Security Platform integration
-
Phase 1b: Observability
- Sentry error tracking and performance monitoring
- OpenTelemetry-first architecture
- Structured JSON logging with Pino and correlation IDs
-
React error boundaries
-
Phase 1c: Staging Deployment
- Docker images with multi-stage builds
- Traefik reverse proxy with Let's Encrypt TLS
- Zero-downtime deployments via Docker Compose
-
Staging environment:
https://staging-connect-api.forma3d.be -
Phase 1d: Acceptance Testing (if completed)
- Playwright + Gherkin acceptance tests
- Given/When/Then scenarios for deployment verification
-
Azure DevOps pipeline integration
-
Phase 2: SimplyPrint Core ✅
- SimplyPrint API client with HTTP Basic Auth
- Automated print job creation from orders
- Print job status monitoring (webhook + polling)
- Order-job orchestration with
order.ready-for-fulfillmentevent - Print job retry and cancel endpoints
What Phase 3 Builds¶
| Feature | Description | Effort |
|---|---|---|
| F3.1: Fulfillment Service | Automated Shopify fulfillment creation | 10 hours |
| F3.2: Cancellation Handling | Handle Shopify order cancellations | 6 hours |
| F3.3: Error Recovery Service | Retry mechanisms with exponential backoff | 8 hours |
| F3.4: Event Bus & Notifications | Event-driven notifications for critical failures | 6 hours |
🛠️ Tech Stack Reference¶
All technologies from Phase 2 remain. Additional packages for Phase 3:
| Package | Purpose |
|---|---|
@nestjs/event-emitter |
Internal event system (already installed) |
@nestjs/schedule |
Cron jobs for retry queue processing |
nodemailer |
Email notifications for operators |
handlebars |
Email templating |
🏗️ Architecture Reference¶
Current Database Schema (from Phase 2)¶
The Prisma schema already includes the necessary entities:
model Order {
id String @id @default(uuid())
shopifyOrderId String @unique
shopifyOrderNumber String
status OrderStatus @default(PENDING)
customerName String
customerEmail String?
shippingAddress Json
totalPrice Decimal @db.Decimal(10, 2)
currency String @default("EUR")
shopifyFulfillmentId String?
trackingNumber String?
trackingUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
completedAt DateTime?
lineItems LineItem[]
}
enum OrderStatus {
PENDING
PROCESSING
PARTIALLY_COMPLETED
COMPLETED
FAILED
CANCELLED
}
model PrintJob {
id String @id @default(uuid())
simplyPrintJobId String? @unique
lineItemId String @unique
lineItem LineItem @relation(fields: [lineItemId], references: [id])
status PrintJobStatus @default(PENDING)
printerId String?
startedAt DateTime?
completedAt DateTime?
errorMessage String?
retryCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum PrintJobStatus {
PENDING
QUEUED
ASSIGNED
PRINTING
COMPLETED
FAILED
CANCELLED
}
Phase 3 Event Flow¶
┌─────────────────────────────────┐
│ │
▼ │
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐ │
│ All Print │────▶│ Fulfillment │────▶│ Shopify │ │
│ Jobs Done │ │ Service │ │ Fulfillment │ │
└──────────────┘ └──────────────────┘ └──────────────┘ │
│ │
│ (on failure) │
▼ │
┌──────────────────┐ │
│ Retry Queue │──────────────────────────┘
│ (Exponential │
│ Backoff) │
└──────────────────┘
│
│ (after max retries)
▼
┌──────────────────┐
│ Notification │
│ Service │
└──────────────────┘
Cancellation Flow¶
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Shopify Order │────▶│ Cancellation │────▶│ SimplyPrint │
│ Cancelled │ │ Handler │ │ Cancel Jobs │
└──────────────────┘ └──────────────────┘ └──────────────────┘
│
│ (if already printing)
▼
┌──────────────────┐
│ Flag for Review │
│ + Notify │
└──────────────────┘
📁 Files to Create/Modify¶
Add to the existing structure:
apps/api/src/
├── fulfillment/
│ ├── fulfillment.module.ts # Module definition
│ ├── fulfillment.service.ts # Fulfillment business logic
│ ├── fulfillment.controller.ts # REST endpoints (manual triggers)
│ ├── dto/
│ │ ├── fulfillment.dto.ts # Response DTOs
│ │ └── create-fulfillment.dto.ts # Manual fulfillment DTO
│ ├── events/
│ │ └── fulfillment.events.ts # Fulfillment event definitions
│ └── __tests__/
│ ├── fulfillment.service.spec.ts
│ └── fulfillment.controller.spec.ts
│
├── cancellation/
│ ├── cancellation.module.ts # Module definition
│ ├── cancellation.service.ts # Cancellation business logic
│ ├── cancellation.controller.ts # Manual cancellation endpoint
│ └── __tests__/
│ └── cancellation.service.spec.ts
│
├── retry-queue/
│ ├── retry-queue.module.ts # Module definition
│ ├── retry-queue.service.ts # Retry queue processing
│ ├── retry-queue.processor.ts # Scheduled job processor
│ ├── dto/
│ │ └── retry-job.dto.ts # Retry job DTO
│ └── __tests__/
│ └── retry-queue.service.spec.ts
│
├── notifications/
│ ├── notifications.module.ts # Module definition
│ ├── notifications.service.ts # Notification dispatch
│ ├── email.service.ts # Email sending via nodemailer
│ ├── templates/
│ │ ├── error-notification.hbs # Error email template
│ │ └── daily-summary.hbs # Daily summary template
│ ├── dto/
│ │ └── notification.dto.ts # Notification DTOs
│ └── __tests__/
│ └── notifications.service.spec.ts
│
├── orchestration/
│ └── orchestration.service.ts # UPDATE: Add fulfillment trigger
prisma/
└── schema.prisma # UPDATE: Add RetryQueue model
prisma/migrations/
└── YYYYMMDD_add_retry_queue/ # Migration for retry queue table
🔧 Feature F3.1: Shopify Fulfillment Service¶
Requirements Reference¶
- FR-SH-004: Order Fulfillment
- NFR-PE-002: Fulfillment Latency (< 60 seconds)
Implementation¶
1. Update Prisma Schema¶
Update prisma/schema.prisma to add retry queue table:
model RetryQueue {
id String @id @default(uuid())
jobType RetryJobType
payload Json
attempts Int @default(0)
maxAttempts Int @default(5)
nextAttemptAt DateTime
lastError String?
status RetryStatus @default(PENDING)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum RetryJobType {
FULFILLMENT
PRINT_JOB_CREATION
CANCELLATION
NOTIFICATION
}
enum RetryStatus {
PENDING
PROCESSING
COMPLETED
FAILED
}
Run migration:
pnpm prisma migrate dev --name add_retry_queue
2. Fulfillment Events¶
Create apps/api/src/fulfillment/events/fulfillment.events.ts:
import { Order } from '@prisma/client';
export const FULFILLMENT_EVENTS = {
CREATED: 'fulfillment.created',
FAILED: 'fulfillment.failed',
RETRYING: 'fulfillment.retrying',
} as const;
export class FulfillmentCreatedEvent {
constructor(
public readonly order: Order,
public readonly fulfillmentId: string
) {}
}
export class FulfillmentFailedEvent {
constructor(
public readonly order: Order,
public readonly error: string,
public readonly willRetry: boolean
) {}
}
3. Fulfillment Service¶
Create apps/api/src/fulfillment/fulfillment.service.ts:
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Order, OrderStatus } from '@prisma/client';
import * as Sentry from '@sentry/nestjs';
import { ShopifyApiClient } from '../shopify/shopify-api.client';
import { OrdersRepository } from '../orders/orders.repository';
import { EventLogService } from '../event-log/event-log.service';
import { RetryQueueService } from '../retry-queue/retry-queue.service';
import { ORDER_EVENTS, OrderReadyForFulfillmentEvent } from '../orders/events/order.events';
import {
FULFILLMENT_EVENTS,
FulfillmentCreatedEvent,
FulfillmentFailedEvent,
} from './events/fulfillment.events';
interface FulfillmentInput {
orderId: string;
notify_customer?: boolean;
trackingInfo?: {
number?: string;
url?: string;
company?: string;
};
}
@Injectable()
export class FulfillmentService {
private readonly logger = new Logger(FulfillmentService.name);
constructor(
private readonly shopifyClient: ShopifyApiClient,
private readonly ordersRepository: OrdersRepository,
private readonly eventLogService: EventLogService,
private readonly retryQueueService: RetryQueueService,
private readonly eventEmitter: EventEmitter2
) {}
/**
* Listen for order.ready-for-fulfillment events from orchestration
*/
@OnEvent(ORDER_EVENTS.READY_FOR_FULFILLMENT)
async handleOrderReadyForFulfillment(event: OrderReadyForFulfillmentEvent): Promise<void> {
this.logger.log(`Order ready for fulfillment: ${event.order.id}`);
await this.createFulfillment({ orderId: event.order.id, notify_customer: true });
}
/**
* Create fulfillment in Shopify
*/
async createFulfillment(input: FulfillmentInput): Promise<void> {
const order = await this.ordersRepository.findById(input.orderId);
if (!order) {
throw new NotFoundException(`Order not found: ${input.orderId}`);
}
// Skip if already fulfilled
if (order.shopifyFulfillmentId) {
this.logger.warn(`Order ${order.id} already fulfilled`);
return;
}
// Skip if not completed
if (order.status !== OrderStatus.COMPLETED) {
this.logger.warn(`Order ${order.id} not in COMPLETED status, skipping fulfillment`);
return;
}
try {
this.logger.log(`Creating fulfillment for order ${order.shopifyOrderNumber}`);
// Build fulfillment payload
const fulfillmentPayload = {
line_items: order.lineItems.map((item) => ({
id: parseInt(item.shopifyLineItemId, 10),
quantity: item.quantity,
})),
notify_customer: input.notify_customer ?? true,
tracking_info: input.trackingInfo,
};
// Create fulfillment in Shopify
const fulfillmentResponse = await this.shopifyClient.createFulfillment(
order.shopifyOrderId,
fulfillmentPayload
);
// Update local order record
await this.ordersRepository.updateFulfillment(order.id, {
shopifyFulfillmentId: String(fulfillmentResponse.fulfillment.id),
trackingNumber: fulfillmentResponse.fulfillment.tracking_number,
trackingUrl: fulfillmentResponse.fulfillment.tracking_url,
});
// Log success
await this.eventLogService.log({
orderId: order.id,
eventType: 'ORDER_FULFILLED',
severity: 'INFO',
message: `Order fulfilled in Shopify`,
metadata: {
fulfillmentId: fulfillmentResponse.fulfillment.id,
notifyCustomer: input.notify_customer,
},
});
// Emit success event
this.eventEmitter.emit(
FULFILLMENT_EVENTS.CREATED,
new FulfillmentCreatedEvent(order, String(fulfillmentResponse.fulfillment.id))
);
this.logger.log(`Fulfillment created for order ${order.shopifyOrderNumber}`);
} catch (error) {
await this.handleFulfillmentError(order, error);
}
}
/**
* Force fulfill an order (manual override)
*/
async forceFulfill(orderId: string): Promise<void> {
const order = await this.ordersRepository.findById(orderId);
if (!order) {
throw new NotFoundException(`Order not found: ${orderId}`);
}
await this.eventLogService.log({
orderId,
eventType: 'ORDER_FORCE_FULFILLED',
severity: 'WARNING',
message: 'Order manually force-fulfilled by operator',
});
// Update order status to completed if not already
if (order.status !== OrderStatus.COMPLETED) {
await this.ordersRepository.updateStatus(orderId, OrderStatus.COMPLETED);
}
// Create fulfillment
await this.createFulfillment({ orderId, notify_customer: true });
}
/**
* Handle fulfillment creation errors
*/
private async handleFulfillmentError(order: Order, error: unknown): Promise<void> {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`Fulfillment failed for order ${order.id}: ${errorMessage}`);
// Check if error is retryable
const isRetryable = this.isRetryableError(error);
if (isRetryable) {
// Add to retry queue
await this.retryQueueService.enqueue({
jobType: 'FULFILLMENT',
payload: { orderId: order.id },
maxAttempts: 5,
});
await this.eventLogService.log({
orderId: order.id,
eventType: 'FULFILLMENT_RETRY_SCHEDULED',
severity: 'WARNING',
message: `Fulfillment failed, scheduled for retry: ${errorMessage}`,
metadata: { error: errorMessage },
});
this.eventEmitter.emit(
FULFILLMENT_EVENTS.FAILED,
new FulfillmentFailedEvent(order, errorMessage, true)
);
} else {
// Permanent failure - alert operator
await this.eventLogService.log({
orderId: order.id,
eventType: 'FULFILLMENT_FAILED_PERMANENT',
severity: 'ERROR',
message: `Fulfillment permanently failed: ${errorMessage}`,
metadata: { error: errorMessage, requiresAttention: true },
});
// Update order status to failed
await this.ordersRepository.updateStatus(order.id, OrderStatus.FAILED);
this.eventEmitter.emit(
FULFILLMENT_EVENTS.FAILED,
new FulfillmentFailedEvent(order, errorMessage, false)
);
Sentry.captureException(error, {
tags: { service: 'fulfillment', action: 'create' },
extra: { orderId: order.id, shopifyOrderId: order.shopifyOrderId },
});
}
}
/**
* Determine if error is retryable
*/
private isRetryableError(error: unknown): boolean {
if (error instanceof Error) {
const message = error.message.toLowerCase();
const retryablePatterns = [
'timeout',
'rate limit',
'service unavailable',
'503',
'429',
'econnreset',
'econnrefused',
'etimedout',
];
return retryablePatterns.some((pattern) => message.includes(pattern));
}
return false;
}
}
4. Fulfillment Controller¶
Create apps/api/src/fulfillment/fulfillment.controller.ts:
import { Controller, Post, Param, HttpCode, HttpStatus, Logger } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
import { FulfillmentService } from './fulfillment.service';
@ApiTags('Fulfillment')
@Controller('api/v1/fulfillments')
export class FulfillmentController {
private readonly logger = new Logger(FulfillmentController.name);
constructor(private readonly fulfillmentService: FulfillmentService) {}
@Post('order/:orderId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Create fulfillment for an order' })
@ApiParam({ name: 'orderId', description: 'Order ID to fulfill' })
@ApiResponse({ status: 200, description: 'Fulfillment created successfully' })
@ApiResponse({ status: 404, description: 'Order not found' })
async createFulfillment(
@Param('orderId') orderId: string
): Promise<{ success: boolean; message: string }> {
await this.fulfillmentService.createFulfillment({ orderId });
return { success: true, message: 'Fulfillment created' };
}
@Post('order/:orderId/force')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Force fulfill an order (manual override)' })
@ApiParam({ name: 'orderId', description: 'Order ID to force fulfill' })
@ApiResponse({ status: 200, description: 'Order force fulfilled' })
@ApiResponse({ status: 404, description: 'Order not found' })
async forceFulfill(
@Param('orderId') orderId: string
): Promise<{ success: boolean; message: string }> {
await this.fulfillmentService.forceFulfill(orderId);
return { success: true, message: 'Order force fulfilled' };
}
}
5. Fulfillment Module¶
Create apps/api/src/fulfillment/fulfillment.module.ts:
import { Module } from '@nestjs/common';
import { FulfillmentController } from './fulfillment.controller';
import { FulfillmentService } from './fulfillment.service';
import { ShopifyModule } from '../shopify/shopify.module';
import { OrdersModule } from '../orders/orders.module';
import { EventLogModule } from '../event-log/event-log.module';
import { RetryQueueModule } from '../retry-queue/retry-queue.module';
@Module({
imports: [ShopifyModule, OrdersModule, EventLogModule, RetryQueueModule],
controllers: [FulfillmentController],
providers: [FulfillmentService],
exports: [FulfillmentService],
})
export class FulfillmentModule {}
🔧 Feature F3.2: Cancellation Handling¶
Requirements Reference¶
- FR-SH-005: Order Cancellation Handling
Implementation¶
1. Cancellation Service¶
Create apps/api/src/cancellation/cancellation.service.ts:
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Order, PrintJob, PrintJobStatus, OrderStatus } from '@prisma/client';
import * as Sentry from '@sentry/nestjs';
import { SimplyPrintApiClient } from '../simplyprint/simplyprint-api.client';
import { OrdersRepository } from '../orders/orders.repository';
import { PrintJobsRepository } from '../print-jobs/print-jobs.repository';
import { EventLogService } from '../event-log/event-log.service';
import { NotificationsService } from '../notifications/notifications.service';
import { ORDER_EVENTS } from '../orders/events/order.events';
interface CancellationResult {
orderId: string;
cancelledJobs: string[];
flaggedJobs: string[];
alreadyCompletedJobs: string[];
}
@Injectable()
export class CancellationService {
private readonly logger = new Logger(CancellationService.name);
constructor(
private readonly simplyPrintClient: SimplyPrintApiClient,
private readonly ordersRepository: OrdersRepository,
private readonly printJobsRepository: PrintJobsRepository,
private readonly eventLogService: EventLogService,
private readonly notificationsService: NotificationsService,
private readonly eventEmitter: EventEmitter2
) {}
/**
* Handle order cancellation from Shopify webhook
*/
@OnEvent(ORDER_EVENTS.CANCELLED)
async handleOrderCancelled(event: { orderId: string; reason?: string }): Promise<void> {
this.logger.log(`Handling order cancellation: ${event.orderId}`);
await this.cancelOrder(event.orderId, event.reason);
}
/**
* Cancel an order and its associated print jobs
*/
async cancelOrder(orderId: string, reason?: string): Promise<CancellationResult> {
const order = await this.ordersRepository.findById(orderId);
if (!order) {
throw new NotFoundException(`Order not found: ${orderId}`);
}
const result: CancellationResult = {
orderId,
cancelledJobs: [],
flaggedJobs: [],
alreadyCompletedJobs: [],
};
// Get all print jobs for this order
const printJobs = await this.printJobsRepository.findByOrderId(orderId);
for (const job of printJobs) {
await this.processPrintJobCancellation(job, result);
}
// Update order status
await this.ordersRepository.updateStatus(orderId, OrderStatus.CANCELLED);
// Log cancellation
await this.eventLogService.log({
orderId,
eventType: 'ORDER_CANCELLED',
severity: 'WARNING',
message: reason || 'Order cancelled',
metadata: {
cancelledJobs: result.cancelledJobs.length,
flaggedJobs: result.flaggedJobs.length,
alreadyCompletedJobs: result.alreadyCompletedJobs.length,
},
});
// If there are jobs that need attention, notify operator
if (result.flaggedJobs.length > 0) {
await this.notificationsService.sendOperatorAlert({
type: 'CANCELLATION_NEEDS_REVIEW',
orderId,
message: `Order ${order.shopifyOrderNumber} cancelled but ${result.flaggedJobs.length} job(s) are already printing`,
metadata: { flaggedJobs: result.flaggedJobs },
});
}
this.logger.log(`Order ${orderId} cancellation processed: ${JSON.stringify(result)}`);
return result;
}
/**
* Process individual print job cancellation
*/
private async processPrintJobCancellation(
job: PrintJob,
result: CancellationResult
): Promise<void> {
switch (job.status) {
case PrintJobStatus.PENDING:
case PrintJobStatus.QUEUED:
case PrintJobStatus.ASSIGNED:
// Can be cancelled directly
await this.cancelPrintJob(job);
result.cancelledJobs.push(job.id);
break;
case PrintJobStatus.PRINTING:
// Already printing - flag for operator review
await this.flagForReview(job);
result.flaggedJobs.push(job.id);
break;
case PrintJobStatus.COMPLETED:
// Already printed - log warning
this.logger.warn(`Print job ${job.id} already completed, cannot cancel`);
result.alreadyCompletedJobs.push(job.id);
break;
case PrintJobStatus.CANCELLED:
case PrintJobStatus.FAILED:
// Already in terminal state
this.logger.debug(`Print job ${job.id} already in terminal state: ${job.status}`);
break;
}
}
/**
* Cancel a print job in SimplyPrint
*/
private async cancelPrintJob(job: PrintJob): Promise<void> {
try {
if (job.simplyPrintJobId) {
await this.simplyPrintClient.cancelJob(job.simplyPrintJobId);
this.logger.log(`Cancelled SimplyPrint job: ${job.simplyPrintJobId}`);
}
await this.printJobsRepository.update(job.id, {
status: PrintJobStatus.CANCELLED,
completedAt: new Date(),
});
await this.eventLogService.log({
printJobId: job.id,
eventType: 'PRINT_JOB_CANCELLED',
severity: 'INFO',
message: 'Print job cancelled due to order cancellation',
});
} catch (error) {
this.logger.error(`Failed to cancel print job ${job.id}: ${error.message}`);
// Still mark as cancelled locally even if SimplyPrint fails
await this.printJobsRepository.update(job.id, {
status: PrintJobStatus.CANCELLED,
errorMessage: `Cancellation failed: ${error.message}`,
});
Sentry.captureException(error, {
tags: { service: 'cancellation', action: 'cancel-print-job' },
extra: { printJobId: job.id, simplyPrintJobId: job.simplyPrintJobId },
});
}
}
/**
* Flag a printing job for operator review
*/
private async flagForReview(job: PrintJob): Promise<void> {
await this.eventLogService.log({
printJobId: job.id,
eventType: 'PRINT_JOB_NEEDS_REVIEW',
severity: 'WARNING',
message: 'Order cancelled but print job is already printing - requires operator review',
metadata: {
simplyPrintJobId: job.simplyPrintJobId,
printerId: job.printerId,
requiresAttention: true,
},
});
}
}
2. Update Shopify Service¶
Update apps/api/src/shopify/shopify.service.ts to emit cancellation event:
// In handleOrderCancelled method, emit event for cancellation service:
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;
}
// Emit cancellation event for CancellationService to handle
this.eventEmitter.emit(ORDER_EVENTS.CANCELLED, {
orderId: existingOrder.id,
reason: `Cancelled in Shopify at ${payload.cancelled_at}`,
});
this.logger.log(`Emitted cancellation event for order ${existingOrder.shopifyOrderNumber}`);
}
🔧 Feature F3.3: Error Recovery Service¶
Requirements Reference¶
- NFR-RE-002: Graceful Degradation
- NFR-RE-003: Error Recovery
Implementation¶
1. Retry Queue Repository¶
Create apps/api/src/retry-queue/retry-queue.repository.ts:
import { Injectable, Logger } from '@nestjs/common';
import { Prisma, RetryQueue, RetryJobType, RetryStatus } from '@prisma/client';
import { PrismaService } from '../database/prisma.service';
@Injectable()
export class RetryQueueRepository {
private readonly logger = new Logger(RetryQueueRepository.name);
constructor(private readonly prisma: PrismaService) {}
async create(data: Prisma.RetryQueueCreateInput): Promise<RetryQueue> {
return this.prisma.retryQueue.create({ data });
}
async findById(id: string): Promise<RetryQueue | null> {
return this.prisma.retryQueue.findUnique({ where: { id } });
}
async findPendingJobs(limit = 10): Promise<RetryQueue[]> {
return this.prisma.retryQueue.findMany({
where: {
status: RetryStatus.PENDING,
nextAttemptAt: { lte: new Date() },
},
orderBy: { nextAttemptAt: 'asc' },
take: limit,
});
}
async update(id: string, data: Prisma.RetryQueueUpdateInput): Promise<RetryQueue> {
return this.prisma.retryQueue.update({ where: { id }, data });
}
async markProcessing(id: string): Promise<RetryQueue> {
return this.update(id, { status: RetryStatus.PROCESSING });
}
async markCompleted(id: string): Promise<RetryQueue> {
return this.update(id, { status: RetryStatus.COMPLETED });
}
async markFailed(id: string, error: string): Promise<RetryQueue> {
return this.update(id, {
status: RetryStatus.FAILED,
lastError: error,
});
}
async scheduleRetry(id: string, nextAttemptAt: Date): Promise<RetryQueue> {
const job = await this.findById(id);
return this.update(id, {
status: RetryStatus.PENDING,
nextAttemptAt,
attempts: (job?.attempts || 0) + 1,
});
}
async deleteOldCompleted(olderThan: Date): Promise<number> {
const result = await this.prisma.retryQueue.deleteMany({
where: {
status: RetryStatus.COMPLETED,
updatedAt: { lt: olderThan },
},
});
return result.count;
}
}
2. Retry Queue Service¶
Create apps/api/src/retry-queue/retry-queue.service.ts:
import { Injectable, Logger } from '@nestjs/common';
import { RetryQueue, RetryJobType, RetryStatus } from '@prisma/client';
import { RetryQueueRepository } from './retry-queue.repository';
import { EventLogService } from '../event-log/event-log.service';
interface EnqueueParams {
jobType: RetryJobType;
payload: Record<string, unknown>;
maxAttempts?: number;
delayMs?: number;
}
interface RetryConfig {
maxRetries: number;
initialDelayMs: number;
maxDelayMs: number;
backoffMultiplier: number;
}
@Injectable()
export class RetryQueueService {
private readonly logger = new Logger(RetryQueueService.name);
private readonly config: RetryConfig = {
maxRetries: 5,
initialDelayMs: 1000, // 1 second
maxDelayMs: 3600000, // 1 hour
backoffMultiplier: 2,
};
constructor(
private readonly repository: RetryQueueRepository,
private readonly eventLogService: EventLogService
) {}
/**
* Add a job to the retry queue
*/
async enqueue(params: EnqueueParams): Promise<RetryQueue> {
const nextAttemptAt = new Date(Date.now() + (params.delayMs || this.config.initialDelayMs));
const job = await this.repository.create({
jobType: params.jobType,
payload: params.payload,
maxAttempts: params.maxAttempts || this.config.maxRetries,
nextAttemptAt,
});
this.logger.log(`Enqueued retry job: ${job.id} (${params.jobType})`);
return job;
}
/**
* Get pending jobs ready for processing
*/
async getPendingJobs(limit = 10): Promise<RetryQueue[]> {
return this.repository.findPendingJobs(limit);
}
/**
* Mark job as processing
*/
async startProcessing(jobId: string): Promise<RetryQueue> {
return this.repository.markProcessing(jobId);
}
/**
* Mark job as completed
*/
async completeJob(jobId: string): Promise<RetryQueue> {
return this.repository.markCompleted(jobId);
}
/**
* Handle job failure - either reschedule or mark as failed
*/
async handleFailure(jobId: string, error: string): Promise<RetryQueue> {
const job = await this.repository.findById(jobId);
if (!job) {
throw new Error(`Retry job not found: ${jobId}`);
}
const nextAttempt = job.attempts + 1;
if (nextAttempt >= job.maxAttempts) {
// Maximum retries exceeded
this.logger.error(`Retry job ${jobId} exceeded max attempts (${job.maxAttempts})`);
await this.eventLogService.log({
eventType: 'RETRY_EXHAUSTED',
severity: 'ERROR',
message: `Retry job exhausted after ${job.maxAttempts} attempts`,
metadata: {
jobId,
jobType: job.jobType,
payload: job.payload,
lastError: error,
requiresAttention: true,
},
});
return this.repository.markFailed(jobId, error);
}
// Calculate next attempt time with exponential backoff and jitter
const delay = this.calculateDelay(nextAttempt);
const nextAttemptAt = new Date(Date.now() + delay);
this.logger.log(`Rescheduling retry job ${jobId} for ${nextAttemptAt.toISOString()}`);
return this.repository.scheduleRetry(jobId, nextAttemptAt);
}
/**
* Calculate delay with exponential backoff and jitter
*/
private calculateDelay(attempt: number): number {
// Exponential backoff: initialDelay * (multiplier ^ attempt)
let delay = this.config.initialDelayMs * Math.pow(this.config.backoffMultiplier, attempt - 1);
// Cap at max delay
delay = Math.min(delay, this.config.maxDelayMs);
// Add jitter (±10%)
const jitter = delay * 0.1 * (Math.random() * 2 - 1);
delay = Math.round(delay + jitter);
return delay;
}
/**
* Cleanup old completed jobs
*/
async cleanupOldJobs(daysToKeep = 7): Promise<number> {
const cutoffDate = new Date(Date.now() - daysToKeep * 24 * 60 * 60 * 1000);
return this.repository.deleteOldCompleted(cutoffDate);
}
}
3. Retry Queue Processor¶
Create apps/api/src/retry-queue/retry-queue.processor.ts:
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { RetryQueue, RetryJobType } from '@prisma/client';
import * as Sentry from '@sentry/nestjs';
import { RetryQueueService } from './retry-queue.service';
import { FulfillmentService } from '../fulfillment/fulfillment.service';
import { PrintJobsService } from '../print-jobs/print-jobs.service';
import { NotificationsService } from '../notifications/notifications.service';
import { EventLogService } from '../event-log/event-log.service';
@Injectable()
export class RetryQueueProcessor implements OnModuleInit {
private readonly logger = new Logger(RetryQueueProcessor.name);
private isProcessing = false;
constructor(
private readonly retryQueueService: RetryQueueService,
private readonly fulfillmentService: FulfillmentService,
private readonly printJobsService: PrintJobsService,
private readonly notificationsService: NotificationsService,
private readonly eventLogService: EventLogService
) {}
async onModuleInit(): Promise<void> {
this.logger.log('Retry queue processor initialized');
}
/**
* Process pending retry jobs every 30 seconds
*/
@Cron(CronExpression.EVERY_30_SECONDS)
async processQueue(): Promise<void> {
// Prevent concurrent processing
if (this.isProcessing) {
return;
}
this.isProcessing = true;
try {
const pendingJobs = await this.retryQueueService.getPendingJobs(10);
if (pendingJobs.length === 0) {
return;
}
this.logger.debug(`Processing ${pendingJobs.length} retry jobs`);
for (const job of pendingJobs) {
await this.processJob(job);
}
} catch (error) {
this.logger.error(`Error processing retry queue: ${error.message}`);
Sentry.captureException(error, {
tags: { service: 'retry-queue', action: 'process-queue' },
});
} finally {
this.isProcessing = false;
}
}
/**
* Process a single retry job
*/
private async processJob(job: RetryQueue): Promise<void> {
this.logger.log(`Processing retry job ${job.id} (${job.jobType})`);
try {
await this.retryQueueService.startProcessing(job.id);
switch (job.jobType) {
case 'FULFILLMENT':
await this.processsFulfillmentRetry(job);
break;
case 'PRINT_JOB_CREATION':
await this.processPrintJobRetry(job);
break;
case 'NOTIFICATION':
await this.processNotificationRetry(job);
break;
default:
this.logger.warn(`Unknown retry job type: ${job.jobType}`);
}
await this.retryQueueService.completeJob(job.id);
this.logger.log(`Retry job ${job.id} completed successfully`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`Retry job ${job.id} failed: ${errorMessage}`);
await this.retryQueueService.handleFailure(job.id, errorMessage);
}
}
/**
* Retry fulfillment creation
*/
private async processsFulfillmentRetry(job: RetryQueue): Promise<void> {
const payload = job.payload as { orderId: string };
await this.fulfillmentService.createFulfillment({ orderId: payload.orderId });
}
/**
* Retry print job creation
*/
private async processPrintJobRetry(job: RetryQueue): Promise<void> {
const payload = job.payload as { lineItemId: string; orderId: string };
// Implementation depends on PrintJobsService interface
// await this.printJobsService.createPrintJobForLineItem(...)
throw new Error('Print job retry not yet implemented');
}
/**
* Retry notification sending
*/
private async processNotificationRetry(job: RetryQueue): Promise<void> {
const payload = job.payload as {
type: string;
recipients: string[];
subject: string;
body: string;
};
await this.notificationsService.sendEmail(payload);
}
/**
* Cleanup old completed jobs daily
*/
@Cron(CronExpression.EVERY_DAY_AT_3AM)
async cleanupOldJobs(): Promise<void> {
const deleted = await this.retryQueueService.cleanupOldJobs(7);
this.logger.log(`Cleaned up ${deleted} old retry jobs`);
}
}
🔧 Feature F3.4: Event Bus and Notifications¶
Requirements Reference¶
- FR-NO-001: Error Notifications
Implementation¶
1. Environment Variables¶
Add to .env.example:
# Email Notifications
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=notifications@forma3d.be
SMTP_PASS=your-smtp-password
SMTP_FROM=noreply@forma3d.be
# Notification Recipients
OPERATOR_EMAIL=operator@forma3d.be
ADMIN_EMAIL=admin@forma3d.be
# Notification Settings
NOTIFICATIONS_ENABLED=true
2. Email Service¶
Create apps/api/src/notifications/email.service.ts:
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as nodemailer from 'nodemailer';
import * as handlebars from 'handlebars';
import * as fs from 'fs';
import * as path from 'path';
interface EmailOptions {
to: string | string[];
subject: string;
template: string;
context: Record<string, unknown>;
}
@Injectable()
export class EmailService implements OnModuleInit {
private readonly logger = new Logger(EmailService.name);
private transporter: nodemailer.Transporter;
private templates: Map<string, handlebars.TemplateDelegate> = new Map();
private isEnabled: boolean;
constructor(private readonly configService: ConfigService) {}
async onModuleInit(): Promise<void> {
this.isEnabled = this.configService.get<boolean>('NOTIFICATIONS_ENABLED', false);
if (!this.isEnabled) {
this.logger.warn('Email notifications are disabled');
return;
}
// Create transport
this.transporter = nodemailer.createTransport({
host: this.configService.get<string>('SMTP_HOST'),
port: this.configService.get<number>('SMTP_PORT', 587),
secure: false,
auth: {
user: this.configService.get<string>('SMTP_USER'),
pass: this.configService.get<string>('SMTP_PASS'),
},
});
// Load templates
await this.loadTemplates();
// Verify connection
try {
await this.transporter.verify();
this.logger.log('Email service connected successfully');
} catch (error) {
this.logger.error(`Email service connection failed: ${error.message}`);
}
}
/**
* Load email templates
*/
private async loadTemplates(): Promise<void> {
const templatesDir = path.join(__dirname, 'templates');
const templateFiles = ['error-notification', 'daily-summary'];
for (const templateName of templateFiles) {
const templatePath = path.join(templatesDir, `${templateName}.hbs`);
if (fs.existsSync(templatePath)) {
const source = fs.readFileSync(templatePath, 'utf8');
this.templates.set(templateName, handlebars.compile(source));
}
}
this.logger.log(`Loaded ${this.templates.size} email templates`);
}
/**
* Send an email
*/
async send(options: EmailOptions): Promise<boolean> {
if (!this.isEnabled) {
this.logger.debug('Email disabled, skipping send');
return false;
}
try {
const template = this.templates.get(options.template);
if (!template) {
throw new Error(`Template not found: ${options.template}`);
}
const html = template(options.context);
await this.transporter.sendMail({
from: this.configService.get<string>('SMTP_FROM'),
to: Array.isArray(options.to) ? options.to.join(', ') : options.to,
subject: options.subject,
html,
});
this.logger.log(`Email sent: ${options.subject} to ${options.to}`);
return true;
} catch (error) {
this.logger.error(`Failed to send email: ${error.message}`);
return false;
}
}
}
3. Notifications Service¶
Create apps/api/src/notifications/notifications.service.ts:
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { ConfigService } from '@nestjs/config';
import * as Sentry from '@sentry/nestjs';
import { EmailService } from './email.service';
import { EventLogService } from '../event-log/event-log.service';
import {
FULFILLMENT_EVENTS,
FulfillmentFailedEvent,
} from '../fulfillment/events/fulfillment.events';
import { PRINT_JOB_EVENTS, PrintJobFailedEvent } from '../print-jobs/events/print-job.events';
interface OperatorAlert {
type: string;
orderId?: string;
message: string;
metadata?: Record<string, unknown>;
}
interface EmailPayload {
type: string;
recipients: string[];
subject: string;
body: string;
}
@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
private readonly operatorEmail: string;
private readonly adminEmail: string;
constructor(
private readonly configService: ConfigService,
private readonly emailService: EmailService,
private readonly eventLogService: EventLogService
) {
this.operatorEmail = this.configService.get<string>('OPERATOR_EMAIL', '');
this.adminEmail = this.configService.get<string>('ADMIN_EMAIL', '');
}
/**
* Send alert to operator
*/
async sendOperatorAlert(alert: OperatorAlert): Promise<void> {
if (!this.operatorEmail) {
this.logger.warn('Operator email not configured, skipping alert');
return;
}
try {
await this.emailService.send({
to: this.operatorEmail,
subject: `[Forma3D.Connect] ${alert.type}: Action Required`,
template: 'error-notification',
context: {
alertType: alert.type,
orderId: alert.orderId,
message: alert.message,
metadata: alert.metadata,
timestamp: new Date().toISOString(),
dashboardUrl: `${this.configService.get('APP_URL')}/orders/${alert.orderId}`,
},
});
await this.eventLogService.log({
orderId: alert.orderId,
eventType: 'OPERATOR_ALERT_SENT',
severity: 'INFO',
message: `Operator alert sent: ${alert.type}`,
metadata: { recipient: this.operatorEmail },
});
} catch (error) {
this.logger.error(`Failed to send operator alert: ${error.message}`);
Sentry.captureException(error, {
tags: { service: 'notifications', action: 'operator-alert' },
});
}
}
/**
* Send email (for retry queue)
*/
async sendEmail(payload: EmailPayload): Promise<void> {
await this.emailService.send({
to: payload.recipients,
subject: payload.subject,
template: 'error-notification',
context: { message: payload.body },
});
}
/**
* Listen for fulfillment failures that won't retry
*/
@OnEvent(FULFILLMENT_EVENTS.FAILED)
async handleFulfillmentFailed(event: FulfillmentFailedEvent): Promise<void> {
if (!event.willRetry) {
await this.sendOperatorAlert({
type: 'FULFILLMENT_FAILED',
orderId: event.order.id,
message: `Fulfillment failed and will not be retried: ${event.error}`,
metadata: { shopifyOrderNumber: event.order.shopifyOrderNumber },
});
}
}
/**
* Listen for print job failures
*/
@OnEvent(PRINT_JOB_EVENTS.FAILED)
async handlePrintJobFailed(event: PrintJobFailedEvent): Promise<void> {
await this.sendOperatorAlert({
type: 'PRINT_JOB_FAILED',
message: `Print job ${event.printJob.id} failed: ${event.errorMessage}`,
metadata: {
printJobId: event.printJob.id,
simplyPrintJobId: event.printJob.simplyPrintJobId,
},
});
}
}
4. Email Template¶
Create apps/api/src/notifications/templates/error-notification.hbs:
<html>
<head>
<meta charset='utf-8' />
<title>Forma3D.Connect Alert</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background: #dc3545;
color: white;
padding: 20px;
border-radius: 5px 5px 0 0;
}
.content {
background: #f8f9fa;
padding: 20px;
border: 1px solid #dee2e6;
}
.footer {
background: #e9ecef;
padding: 15px;
border-radius: 0 0 5px 5px;
font-size: 12px;
}
.button {
display: inline-block;
background: #007bff;
color: white;
padding: 10px 20px;
text-decoration: none;
border-radius: 5px;
}
.metadata {
background: #fff;
padding: 10px;
border: 1px solid #dee2e6;
margin: 10px 0;
font-family: monospace;
font-size: 12px;
}
</style>
</head>
<body>
<div class='container'>
<div class='header'>
<h1>⚠️ {{alertType}}</h1>
</div>
<div class='content'>
<p><strong>Time:</strong> {{timestamp}}</p>
{{#if orderId}}
<p><strong>Order ID:</strong> {{orderId}}</p>
{{/if}}
<p><strong>Message:</strong></p>
<p>{{message}}</p>
{{#if metadata}}
<p><strong>Details:</strong></p>
<div class='metadata'>
<pre>{{json metadata}}</pre>
</div>
{{/if}}
{{#if dashboardUrl}}
<p>
<a href='{{dashboardUrl}}' class='button'>View in Dashboard</a>
</p>
{{/if}}
</div>
<div class='footer'>
<p>This is an automated message from Forma3D.Connect. Please do not reply directly.</p>
</div>
</div>
</body>
</html>
🔧 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 { ScheduleModule } from '@nestjs/schedule';
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';
import { SimplyPrintModule } from '../simplyprint/simplyprint.module';
import { PrintJobsModule } from '../print-jobs/print-jobs.module';
import { OrchestrationModule } from '../orchestration/orchestration.module';
// Phase 3 modules
import { FulfillmentModule } from '../fulfillment/fulfillment.module';
import { CancellationModule } from '../cancellation/cancellation.module';
import { RetryQueueModule } from '../retry-queue/retry-queue.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env.local', '.env'],
}),
EventEmitterModule.forRoot(),
ScheduleModule.forRoot(),
ConfigurationModule,
DatabaseModule,
HealthModule,
EventLogModule,
ShopifyModule,
OrdersModule,
ProductMappingsModule,
SimplyPrintModule,
PrintJobsModule,
OrchestrationModule,
// Phase 3
FulfillmentModule,
CancellationModule,
RetryQueueModule,
NotificationsModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
🧪 Testing Requirements¶
Test Coverage Requirements¶
Per requirements.md (NFR-MA-002):
- Unit Tests: > 80% coverage for all new services
- Integration Tests: All API integrations tested
- E2E Tests: Critical paths covered
- Acceptance Tests: New Gherkin scenarios for Phase 3 functionality
Unit Test Scenarios Required¶
| Category | Scenario | Priority |
|---|---|---|
| Fulfillment | Create fulfillment on order ready event | Critical |
| Fulfillment | Skip if already fulfilled | High |
| Fulfillment | Handle Shopify API error | Critical |
| Fulfillment | Add to retry queue on transient failure | Critical |
| Cancellation | Cancel queued print jobs | Critical |
| Cancellation | Flag printing jobs for review | High |
| Cancellation | Handle already completed jobs | Medium |
| Retry Queue | Enqueue with correct delay | Critical |
| Retry Queue | Calculate exponential backoff | High |
| Retry Queue | Mark failed after max attempts | Critical |
| Notifications | Send operator alert | High |
| Notifications | Handle disabled notifications gracefully | Medium |
Acceptance Test Requirements (Playwright + Gherkin)¶
New Feature Files to Create¶
Create apps/acceptance-tests/src/features/fulfillment.feature:
@smoke @api
Feature: Order Fulfillment
As an operator
I want orders to be automatically fulfilled
So that customers receive shipping notifications
Background:
Given the staging API is available
@critical
Scenario: Fulfillment endpoint is accessible
When I request the fulfillment endpoint info
Then the response should not be 404
Scenario: Manual fulfillment can be triggered
Given an order exists in COMPLETED status
When I trigger manual fulfillment for the order
Then the response status should be 200
Create apps/acceptance-tests/src/features/cancellation.feature:
@api
Feature: Order Cancellation
As an operator
I want cancelled orders to stop printing
So that we don't waste resources
@critical
Scenario: Cancellation updates order status
Given an order exists in PROCESSING status
When the order is cancelled
Then the order status should be CANCELLED
✅ Validation Checklist¶
Infrastructure¶
- All new modules compile without errors
-
pnpm nx build apisucceeds -
pnpm lintpasses on all new files - Prisma migration runs successfully
Fulfillment Service (F3.1)¶
- Listens for
order.ready-for-fulfillmentevent - Creates fulfillment in Shopify API
- Updates local order record with fulfillment ID
- Handles API errors with retry queue
- Skips already fulfilled orders
- Force fulfill endpoint working
- Unit tests passing
Cancellation Handling (F3.2)¶
- Listens for
order.cancelledevent - Cancels queued print jobs in SimplyPrint
- Flags printing jobs for operator review
- Logs already completed jobs
- Sends notification when jobs need review
- Unit tests passing
Error Recovery (F3.3)¶
- Retry queue table created
- Exponential backoff calculated correctly
- Jitter added to prevent thundering herd
- Maximum retries enforced
- Failed jobs trigger notifications
- Processor runs every 30 seconds
- Old jobs cleaned up daily
- Unit tests passing
Notifications (F3.4)¶
- Email service connects to SMTP
- Templates loaded correctly
- Operator alerts sent on failures
- Handles disabled notifications gracefully
- Unit tests passing
Integration Tests¶
- End-to-end: Order complete → Fulfillment created
- End-to-end: Order cancelled → Jobs cancelled
- End-to-end: Fulfillment fails → Retry scheduled
Acceptance Tests (Playwright + Gherkin)¶
- Feature files created for fulfillment and cancellation
- Step definitions implemented
- Tests pass against staging
🚫 Constraints and Rules¶
MUST DO¶
- Listen for
order.ready-for-fulfillmentevent (don't poll) - Use retry queue for transient failures
- Send notifications on permanent failures
- Log all fulfillment and cancellation actions
- Verify order status before fulfillment
- Handle partial failures gracefully
- Use idempotency (don't create duplicate fulfillments)
- Write unit tests for all new services (> 80% coverage)
- Add acceptance tests (Playwright + Gherkin)
- Update ALL documentation (see Documentation Updates section)
MUST NOT¶
- Block fulfillment on notification failures
- Retry non-transient errors (4xx)
- Skip cancellation of SimplyPrint jobs
- Use synchronous notification sending (use events)
- Store SMTP credentials in code
- Create fulfillments for cancelled orders
- Skip writing unit tests
- Leave documentation incomplete
🎬 Execution Order¶
Implementation¶
- Update Prisma schema with RetryQueue model
- Run migration for retry queue table
- Create retry queue module (repository, service)
- Create notifications module (email service, notifications service)
- Create fulfillment module with event listener
- Create cancellation module with event listener
- Create retry queue processor with scheduler
- Update app module with new modules
- Update orchestration service to use new events
Testing¶
- Write unit tests for all new services (> 80% coverage)
- Write integration tests for end-to-end flows
- Add acceptance tests for fulfillment and cancellation
- Test email templates locally
- Test retry queue with simulated failures
Documentation¶
- Update Swagger documentation — Add
@Api*decorators to all new endpoints - Update README.md — Add fulfillment automation section
- Update docs/implementation-plan.md — Mark Phase 3 features as complete
- Update docs/requirements.md — Mark FR-SH-004, FR-NO-001 as implemented
- Update docs/architecture/ADR.md — Add any new architectural decisions
- Update C4 diagrams if architecture changed significantly
Validation¶
- Run full validation checklist
- Verify acceptance tests pass in pipeline
- Confirm all documentation is complete
📊 Expected Output¶
When Phase 3 is complete:
Verification Commands¶
# Build all projects
pnpm nx build api
# Run tests
pnpm nx test api
# Start API and verify fulfillment flow
pnpm nx serve api
# Trigger manual fulfillment via API
curl -X POST http://localhost:3000/api/v1/fulfillments/order/{orderId}
# Check retry queue
# (Query database for retry_queue table)
Complete Automation Flow¶
1. Shopify Order Created (webhook)
↓
2. Order Stored in Database
↓
3. Print Jobs Created in SimplyPrint
↓
4. Print Job Status Updates (webhook/polling)
↓
5. All Print Jobs Complete
↓
6. order.ready-for-fulfillment Event
↓
7. Fulfillment Created in Shopify
↓
8. Customer Receives Shipping Notification
📝 Documentation Updates¶
CRITICAL: All documentation must be updated to reflect Phase 3 completion.
README.md Updates Required¶
Add sections for:
- Fulfillment Automation — How orders are automatically fulfilled
- Cancellation Handling — How order cancellations are processed
- Retry Queue — How failed operations are retried
- Email Notifications — SMTP configuration and alert types
- Environment Variables — Document new email-related env vars
docs/implementation-plan.md Updates Required¶
Update the implementation plan to mark Phase 3 as complete:
- Mark F3.1 (Shopify Fulfillment Service) as ✅ Completed
- Mark F3.2 (Cancellation Handling) as ✅ Completed
- Mark F3.3 (Error Recovery Service) as ✅ Completed
- Mark F3.4 (Event Bus and Notifications) as ✅ Completed
- Update Phase 3 Exit Criteria with checkmarks
- Add implementation notes and component paths
- Update revision history with completion date
docs/requirements.md Updates Required¶
Update requirements document to mark Phase 3 requirements as implemented:
- Mark FR-SH-004 (Order Fulfillment) as ✅ Implemented
- Mark FR-NO-001 (Error Notifications) as ✅ Implemented
- Update NFR-RE-002 (Graceful Degradation) as ✅ Implemented
- Update NFR-RE-003 (Error Recovery) as ✅ Implemented
- Update revision history
docs/architecture/ADR.md Updates (If Applicable)¶
Consider adding ADRs for:
- ADR-021: Retry Queue with Exponential Backoff
- ADR-022: Event-Driven Fulfillment Architecture
- ADR-023: Email Notification Strategy
docs/architecture/C4 Diagrams Updates¶
Update diagrams if needed:
- C4_Component.puml — Add fulfillment, cancellation, retry queue components
- C4_Code_Sequences.puml — Add fulfillment sequence diagram
Swagger Documentation¶
Ensure all new endpoints are documented with @Api* decorators:
POST /api/v1/fulfillments/order/:orderId— Create fulfillmentPOST /api/v1/fulfillments/order/:orderId/force— Force fulfill
🔗 Phase 3 Exit Criteria¶
From implementation-plan.md:
- Complete automation loop: Order → Print → Fulfill
- Cancellation handling working
- Error recovery operational
- Notifications sent on failures
- End-to-end automated flow tested
Additional Exit Criteria¶
- Unit tests > 80% coverage for all new code
- Integration tests passing
- Acceptance tests added for Phase 3 functionality
- All acceptance tests passing against staging
- README.md updated with fulfillment section
- docs/implementation-plan.md updated — Phase 3 marked as complete
- docs/requirements.md updated — Phase 3 requirements marked as implemented
- Swagger documentation complete for all new endpoints
🔮 Phase 4 Preview¶
Phase 4 (Dashboard MVP) will build on Phase 3:
- React dashboard foundation with Tailwind CSS
- Order management UI (list, detail, actions)
- Product mapping configuration UI
- Real-time updates via Socket.IO
- Activity logs view
The fulfillment service, cancellation handling, and notification system established in Phase 3 will be exposed through the dashboard UI.
END OF PROMPT
This prompt builds on the Phase 2 foundation. The AI should implement all Phase 3 fulfillment loop features while maintaining the established code style, architectural patterns, and testing standards. Phase 3 completion marks the MVP automation goal.