Skip to content

AI Prompt: Forma3D.Connect — Phase 2: SimplyPrint Core ⏳

Purpose: This prompt instructs an AI to implement Phase 2 of Forma3D.Connect
Estimated Effort: 48 hours (~3 weeks)
Prerequisites: Phase 1d completed (Acceptance testing with Playwright + Gherkin)
Output: Automated print job creation, status monitoring, and order orchestration via SimplyPrint API
Status:PENDING


🎯 Mission

You are continuing development of Forma3D.Connect, building on the Phase 1 (including 1b, 1c, 1d) foundation. Your task is to implement Phase 2: SimplyPrint Core — establishing the integration with SimplyPrint's print farm management system to automate the creation and monitoring of print jobs.

Phase 2 delivers:

  • Typed SimplyPrint API client with full error handling
  • Automated print job creation from incoming orders
  • Real-time print job status monitoring (webhook or polling)
  • Order completion orchestration based on print job status

📋 Phase 2 Context

What Was Built in Phases 0, 1, 1b, 1c & 1d

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

  • Playwright + Gherkin acceptance tests
  • Given/When/Then scenarios for deployment verification
  • Azure DevOps pipeline integration

What Phase 2 Builds

Feature Description Effort
F2.1: SimplyPrint API Client Typed client for SimplyPrint API interactions 16 hours
F2.2: Print Job Creation Automated print job creation from orders 12 hours
F2.3: Status Monitor Track print job status changes 12 hours
F2.4: Order Orchestration Coordinate order completion from print jobs 8 hours

🛠️ Tech Stack Reference

All technologies from Phase 1d remain. Additional packages for Phase 2:

Package Purpose
@nestjs/schedule Cron jobs for polling (if webhooks unavailable)
@nestjs/event-emitter Internal event system (already installed)
axios HTTP client for SimplyPrint API (already installed)

🏗️ Architecture Reference

Existing Database Schema

The Prisma schema already includes the necessary entities:

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
}

Order Flow (Phase 2 Focus)

┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│   Shopify    │────▶│  Order/Line  │────▶│  PrintJob    │
│   Webhook    │     │   Storage    │     │  Creation    │
└──────────────┘     └──────────────┘     └──────┬───────┘
                                                 │
                     ┌──────────────┐             │
                     │  SimplyPrint │◀────────────┘
                     │     API      │
                     └──────┬───────┘
                            │
                     ┌──────▼───────┐     ┌──────────────┐
                     │   Status     │────▶│    Order     │
                     │   Monitor    │     │ Orchestration│
                     └──────────────┘     └──────────────┘

📁 Files to Create/Modify

Add to the existing structure:

apps/api/src/
├── simplyprint/
│   ├── simplyprint.module.ts           # Module definition
│   ├── simplyprint-api.client.ts       # Typed API client
│   ├── simplyprint.service.ts          # Business logic service
│   ├── simplyprint-webhook.controller.ts # Webhook receiver (if available)
│   ├── dto/
│   │   ├── simplyprint-job.dto.ts      # Job DTOs
│   │   ├── simplyprint-file.dto.ts     # File DTOs
│   │   ├── simplyprint-printer.dto.ts  # Printer DTOs
│   │   └── simplyprint-webhook.dto.ts  # Webhook payload DTOs
│   ├── guards/
│   │   └── simplyprint-webhook.guard.ts # Webhook verification (if applicable)
│   └── __tests__/
│       ├── simplyprint-api.client.spec.ts
│       ├── simplyprint.service.spec.ts
│       └── simplyprint-webhook.controller.spec.ts
│
├── print-jobs/
│   ├── print-jobs.module.ts            # Module definition
│   ├── print-jobs.service.ts           # Print job business logic
│   ├── print-jobs.repository.ts        # Prisma repository
│   ├── print-jobs.controller.ts        # REST API endpoints
│   ├── dto/
│   │   ├── print-job.dto.ts            # Response DTOs
│   │   ├── create-print-job.dto.ts     # Creation DTOs
│   │   └── print-job-query.dto.ts      # Query DTOs
│   ├── events/
│   │   └── print-job.events.ts         # Event definitions
│   └── __tests__/
│       ├── print-jobs.service.spec.ts
│       └── print-jobs.repository.spec.ts
│
├── orchestration/
│   ├── orchestration.module.ts         # Module definition
│   ├── orchestration.service.ts        # Order completion logic
│   └── __tests__/
│       └── orchestration.service.spec.ts

libs/api-client/src/
├── simplyprint/
│   └── simplyprint.types.ts            # Shared SimplyPrint types

🔧 Feature F2.1: SimplyPrint API Client

Requirements Reference

  • FR-SP-001: Authentication
  • FR-SP-002: Print Job Creation
  • FR-SP-003: Print Job Status Monitoring

Implementation

1. Research SimplyPrint API

CRITICAL: Before implementing, thoroughly research the SimplyPrint API:

  1. Access the SimplyPrint API documentation
  2. Determine authentication method (API key, OAuth, etc.)
  3. Identify available endpoints for:
  4. File listing
  5. Job creation
  6. Job status retrieval
  7. Job cancellation
  8. Printer listing/status
  9. Check webhook availability for job status updates
  10. Note any rate limits or usage constraints

Note: SimplyPrint API details must be researched as the exact API specification may differ. The implementation below assumes a REST API with API key authentication. Adjust as needed based on actual documentation.

2. Environment Variables

Add to .env.example:

# SimplyPrint Configuration
SIMPLYPRINT_API_URL=https://api.simplyprint.io/v1
SIMPLYPRINT_API_KEY=your-api-key-here
SIMPLYPRINT_COMPANY_ID=your-company-id
SIMPLYPRINT_WEBHOOK_SECRET=your-webhook-secret

# Polling Configuration (if webhooks unavailable)
SIMPLYPRINT_POLLING_ENABLED=true
SIMPLYPRINT_POLLING_INTERVAL_MS=30000
SIMPLYPRINT_ACTIVE_JOB_POLLING_INTERVAL_MS=10000

3. Shared Types

Create libs/api-client/src/simplyprint/simplyprint.types.ts:

/**
 * SimplyPrint API shared types
 * Based on SimplyPrint API documentation
 */

export interface SimplyPrintConfig {
  apiUrl: string;
  apiKey: string;
  companyId: string;
  webhookSecret?: string;
}

export interface SimplyPrintFile {
  id: string;
  name: string;
  size: number;
  uploadedAt: string;
  thumbnailUrl?: string;
  metadata?: Record<string, unknown>;
}

export interface SimplyPrintPrinter {
  id: string;
  name: string;
  model: string;
  status: SimplyPrintPrinterStatus;
  currentJobId?: string;
  lastSeen?: string;
}

export enum SimplyPrintPrinterStatus {
  OFFLINE = 'offline',
  IDLE = 'idle',
  PRINTING = 'printing',
  PAUSED = 'paused',
  ERROR = 'error',
}

export interface SimplyPrintJob {
  id: string;
  fileId: string;
  fileName: string;
  printerId?: string;
  status: SimplyPrintJobStatus;
  progress?: number;
  estimatedTime?: number;
  startedAt?: string;
  completedAt?: string;
  errorMessage?: string;
  metadata?: Record<string, unknown>;
}

export enum SimplyPrintJobStatus {
  QUEUED = 'queued',
  ASSIGNED = 'assigned',
  PREPARING = 'preparing',
  PRINTING = 'printing',
  COMPLETED = 'completed',
  FAILED = 'failed',
  CANCELLED = 'cancelled',
}

export interface CreateJobParams {
  fileId: string;
  quantity?: number;
  priority?: number;
  printProfile?: PrintProfile;
  metadata?: Record<string, unknown>;
}

export interface PrintProfile {
  material?: string;
  quality?: string;
  infill?: number;
  supports?: boolean;
  layerHeight?: number;
}

export interface SimplyPrintApiResponse<T> {
  success: boolean;
  data?: T;
  error?: {
    code: string;
    message: string;
  };
}

export interface SimplyPrintWebhookPayload {
  event: SimplyPrintWebhookEvent;
  timestamp: string;
  data: {
    jobId: string;
    status?: SimplyPrintJobStatus;
    progress?: number;
    printerId?: string;
    errorMessage?: string;
  };
}

export enum SimplyPrintWebhookEvent {
  JOB_CREATED = 'job.created',
  JOB_STARTED = 'job.started',
  JOB_PROGRESS = 'job.progress',
  JOB_COMPLETED = 'job.completed',
  JOB_FAILED = 'job.failed',
  JOB_CANCELLED = 'job.cancelled',
}

4. SimplyPrint API Client

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

import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance, AxiosError } from 'axios';
import * as Sentry from '@sentry/nestjs';
import {
  SimplyPrintConfig,
  SimplyPrintFile,
  SimplyPrintJob,
  SimplyPrintPrinter,
  SimplyPrintApiResponse,
  CreateJobParams,
  SimplyPrintJobStatus,
} from '@forma3d/api-client';

export class SimplyPrintApiError extends Error {
  constructor(
    message: string,
    public readonly code: string,
    public readonly statusCode?: number
  ) {
    super(message);
    this.name = 'SimplyPrintApiError';
  }
}

@Injectable()
export class SimplyPrintApiClient implements OnModuleInit {
  private readonly logger = new Logger(SimplyPrintApiClient.name);
  private client: AxiosInstance;
  private config: SimplyPrintConfig;

  constructor(private readonly configService: ConfigService) {}

  async onModuleInit(): Promise<void> {
    this.config = {
      apiUrl: this.configService.getOrThrow<string>('SIMPLYPRINT_API_URL'),
      apiKey: this.configService.getOrThrow<string>('SIMPLYPRINT_API_KEY'),
      companyId: this.configService.getOrThrow<string>('SIMPLYPRINT_COMPANY_ID'),
      webhookSecret: this.configService.get<string>('SIMPLYPRINT_WEBHOOK_SECRET'),
    };

    this.client = axios.create({
      baseURL: this.config.apiUrl,
      timeout: 30000,
      headers: {
        'Authorization': `Bearer ${this.config.apiKey}`,
        'Content-Type': 'application/json',
        'X-Company-ID': this.config.companyId,
      },
    });

    // Add request/response interceptors for logging and Sentry
    this.client.interceptors.request.use((config) => {
      Sentry.addBreadcrumb({
        category: 'simplyprint-api',
        message: `${config.method?.toUpperCase()} ${config.url}`,
        level: 'info',
      });
      return config;
    });

    this.client.interceptors.response.use(
      (response) => response,
      (error: AxiosError) => {
        this.handleApiError(error);
        throw error;
      }
    );

    // Verify connection on startup
    await this.verifyConnection();
  }

  /**
   * Verify API connection and authentication
   */
  async verifyConnection(): Promise<boolean> {
    try {
      this.logger.log('Verifying SimplyPrint API connection...');
      const printers = await this.getPrinters();
      this.logger.log(`SimplyPrint API connected. Found ${printers.length} printers.`);
      return true;
    } catch (error) {
      this.logger.error('Failed to verify SimplyPrint API connection', error);
      Sentry.captureException(error, {
        tags: { service: 'simplyprint', action: 'verify-connection' },
      });
      return false;
    }
  }

  // ─────────────────────────────────────────────────────────────────────────────
  // Files
  // ─────────────────────────────────────────────────────────────────────────────

  /**
   * List all available print files
   */
  async getFiles(): Promise<SimplyPrintFile[]> {
    const response = await this.request<SimplyPrintFile[]>('GET', '/files');
    return response.data ?? [];
  }

  /**
   * Get a specific file by ID
   */
  async getFileById(fileId: string): Promise<SimplyPrintFile> {
    const response = await this.request<SimplyPrintFile>('GET', `/files/${fileId}`);
    if (!response.data) {
      throw new SimplyPrintApiError(`File not found: ${fileId}`, 'FILE_NOT_FOUND', 404);
    }
    return response.data;
  }

  // ─────────────────────────────────────────────────────────────────────────────
  // Print Jobs
  // ─────────────────────────────────────────────────────────────────────────────

  /**
   * Create a new print job
   */
  async createJob(params: CreateJobParams): Promise<SimplyPrintJob> {
    this.logger.log(`Creating print job for file: ${params.fileId}`);

    const response = await this.request<SimplyPrintJob>('POST', '/jobs', {
      file_id: params.fileId,
      quantity: params.quantity ?? 1,
      priority: params.priority ?? 0,
      print_profile: params.printProfile,
      metadata: params.metadata,
    });

    if (!response.data) {
      throw new SimplyPrintApiError('Failed to create print job', 'JOB_CREATION_FAILED');
    }

    this.logger.log(`Print job created: ${response.data.id}`);
    return response.data;
  }

  /**
   * Get a specific job by ID
   */
  async getJob(jobId: string): Promise<SimplyPrintJob> {
    const response = await this.request<SimplyPrintJob>('GET', `/jobs/${jobId}`);
    if (!response.data) {
      throw new SimplyPrintApiError(`Job not found: ${jobId}`, 'JOB_NOT_FOUND', 404);
    }
    return response.data;
  }

  /**
   * Get job status
   */
  async getJobStatus(jobId: string): Promise<SimplyPrintJobStatus> {
    const job = await this.getJob(jobId);
    return job.status;
  }

  /**
   * Cancel a job
   */
  async cancelJob(jobId: string): Promise<void> {
    this.logger.log(`Cancelling print job: ${jobId}`);
    await this.request('POST', `/jobs/${jobId}/cancel`);
    this.logger.log(`Print job cancelled: ${jobId}`);
  }

  /**
   * Get all jobs in queue
   */
  async getQueue(): Promise<SimplyPrintJob[]> {
    const response = await this.request<SimplyPrintJob[]>('GET', '/jobs', {
      status: ['queued', 'assigned', 'printing'],
    });
    return response.data ?? [];
  }

  /**
   * Get jobs by status
   */
  async getJobsByStatus(status: SimplyPrintJobStatus[]): Promise<SimplyPrintJob[]> {
    const response = await this.request<SimplyPrintJob[]>('GET', '/jobs', { status });
    return response.data ?? [];
  }

  // ─────────────────────────────────────────────────────────────────────────────
  // Printers
  // ─────────────────────────────────────────────────────────────────────────────

  /**
   * Get all printers
   */
  async getPrinters(): Promise<SimplyPrintPrinter[]> {
    const response = await this.request<SimplyPrintPrinter[]>('GET', '/printers');
    return response.data ?? [];
  }

  /**
   * Get printer status
   */
  async getPrinterStatus(printerId: string): Promise<SimplyPrintPrinter> {
    const response = await this.request<SimplyPrintPrinter>('GET', `/printers/${printerId}`);
    if (!response.data) {
      throw new SimplyPrintApiError(`Printer not found: ${printerId}`, 'PRINTER_NOT_FOUND', 404);
    }
    return response.data;
  }

  // ─────────────────────────────────────────────────────────────────────────────
  // Private Helpers
  // ─────────────────────────────────────────────────────────────────────────────

  private async request<T>(
    method: string,
    endpoint: string,
    data?: unknown
  ): Promise<SimplyPrintApiResponse<T>> {
    try {
      const response = await this.client.request<SimplyPrintApiResponse<T>>({
        method,
        url: endpoint,
        data: method !== 'GET' ? data : undefined,
        params: method === 'GET' ? data : undefined,
      });

      return response.data;
    } catch (error) {
      if (error instanceof AxiosError) {
        const apiError = error.response?.data as SimplyPrintApiResponse<T>;
        throw new SimplyPrintApiError(
          apiError?.error?.message ?? error.message,
          apiError?.error?.code ?? 'API_ERROR',
          error.response?.status
        );
      }
      throw error;
    }
  }

  private handleApiError(error: AxiosError): void {
    const statusCode = error.response?.status;
    const errorData = error.response?.data as SimplyPrintApiResponse<unknown>;

    this.logger.error({
      message: 'SimplyPrint API error',
      statusCode,
      errorCode: errorData?.error?.code,
      errorMessage: errorData?.error?.message,
      url: error.config?.url,
      method: error.config?.method,
    });

    // Only capture 5xx errors to Sentry (avoid rate limit noise)
    if (statusCode && statusCode >= 500) {
      Sentry.captureException(error, {
        tags: {
          service: 'simplyprint',
          statusCode: statusCode.toString(),
        },
        extra: {
          url: error.config?.url,
          method: error.config?.method,
        },
      });
    }
  }
}

5. SimplyPrint Module

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

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { SimplyPrintApiClient } from './simplyprint-api.client';
import { SimplyPrintService } from './simplyprint.service';
import { SimplyPrintWebhookController } from './simplyprint-webhook.controller';
import { SimplyPrintWebhookGuard } from './guards/simplyprint-webhook.guard';

@Module({
  imports: [ConfigModule],
  controllers: [SimplyPrintWebhookController],
  providers: [
    SimplyPrintApiClient,
    SimplyPrintService,
    SimplyPrintWebhookGuard,
  ],
  exports: [SimplyPrintApiClient, SimplyPrintService],
})
export class SimplyPrintModule {}

6. Unit Tests

Create apps/api/src/simplyprint/__tests__/simplyprint-api.client.spec.ts:

import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { SimplyPrintApiClient, SimplyPrintApiError } from '../simplyprint-api.client';
import { SimplyPrintJobStatus } from '@forma3d/api-client';

describe('SimplyPrintApiClient', () => {
  let client: SimplyPrintApiClient;
  let configService: jest.Mocked<ConfigService>;

  const mockConfig = {
    SIMPLYPRINT_API_URL: 'https://api.simplyprint.io/v1',
    SIMPLYPRINT_API_KEY: 'test-api-key',
    SIMPLYPRINT_COMPANY_ID: 'test-company-id',
  };

  beforeEach(async () => {
    configService = {
      getOrThrow: jest.fn((key: string) => mockConfig[key]),
      get: jest.fn(),
    } as unknown as jest.Mocked<ConfigService>;

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        SimplyPrintApiClient,
        { provide: ConfigService, useValue: configService },
      ],
    }).compile();

    client = module.get<SimplyPrintApiClient>(SimplyPrintApiClient);
  });

  describe('configuration', () => {
    it('should throw if API URL is missing', () => {
      configService.getOrThrow.mockImplementation((key: string) => {
        if (key === 'SIMPLYPRINT_API_URL') throw new Error('Missing');
        return mockConfig[key];
      });

      expect(() => client.onModuleInit()).rejects.toThrow();
    });

    it('should throw if API key is missing', () => {
      configService.getOrThrow.mockImplementation((key: string) => {
        if (key === 'SIMPLYPRINT_API_KEY') throw new Error('Missing');
        return mockConfig[key];
      });

      expect(() => client.onModuleInit()).rejects.toThrow();
    });
  });

  describe('error handling', () => {
    it('should wrap API errors in SimplyPrintApiError', () => {
      const error = new SimplyPrintApiError('Test error', 'TEST_ERROR', 500);
      expect(error.name).toBe('SimplyPrintApiError');
      expect(error.code).toBe('TEST_ERROR');
      expect(error.statusCode).toBe(500);
    });
  });
});

🔧 Feature F2.2: Print Job Creation Service

Requirements Reference

  • FR-SP-002: Print Job Creation
  • NFR-PE-001: Order Processing Latency (< 60 seconds)

Implementation

1. Print Job Repository

Create apps/api/src/print-jobs/print-jobs.repository.ts:

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

@Injectable()
export class PrintJobsRepository {
  constructor(private readonly prisma: PrismaService) {}

  async create(data: Prisma.PrintJobCreateInput): Promise<PrintJob> {
    return this.prisma.printJob.create({ data });
  }

  async findById(id: string): Promise<PrintJob | null> {
    return this.prisma.printJob.findUnique({ where: { id } });
  }

  async findBySimplyPrintJobId(simplyPrintJobId: string): Promise<PrintJob | null> {
    return this.prisma.printJob.findUnique({ where: { simplyPrintJobId } });
  }

  async findByLineItemId(lineItemId: string): Promise<PrintJob | null> {
    return this.prisma.printJob.findUnique({ where: { lineItemId } });
  }

  async findByStatus(status: PrintJobStatus | PrintJobStatus[]): Promise<PrintJob[]> {
    const statusArray = Array.isArray(status) ? status : [status];
    return this.prisma.printJob.findMany({
      where: { status: { in: statusArray } },
      include: { lineItem: { include: { order: true } } },
    });
  }

  async findActiveJobs(): Promise<PrintJob[]> {
    return this.findByStatus([
      PrintJobStatus.PENDING,
      PrintJobStatus.QUEUED,
      PrintJobStatus.ASSIGNED,
      PrintJobStatus.PRINTING,
    ]);
  }

  async update(id: string, data: Prisma.PrintJobUpdateInput): Promise<PrintJob> {
    return this.prisma.printJob.update({ where: { id }, data });
  }

  async updateBySimplyPrintJobId(
    simplyPrintJobId: string,
    data: Prisma.PrintJobUpdateInput
  ): Promise<PrintJob> {
    return this.prisma.printJob.update({
      where: { simplyPrintJobId },
      data,
    });
  }

  async findByOrderId(orderId: string): Promise<PrintJob[]> {
    return this.prisma.printJob.findMany({
      where: {
        lineItem: { orderId },
      },
      include: { lineItem: true },
    });
  }

  async countByOrderIdAndStatus(orderId: string, status: PrintJobStatus): Promise<number> {
    return this.prisma.printJob.count({
      where: {
        lineItem: { orderId },
        status,
      },
    });
  }
}

2. Print Jobs Service

Create apps/api/src/print-jobs/print-jobs.service.ts:

import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { PrintJob, PrintJobStatus, LineItem } from '@prisma/client';
import * as Sentry from '@sentry/nestjs';
import { PrintJobsRepository } from './print-jobs.repository';
import { SimplyPrintApiClient, SimplyPrintApiError } from '../simplyprint/simplyprint-api.client';
import { ProductMappingsRepository } from '../product-mappings/product-mappings.repository';
import { EventLogService } from '../event-log/event-log.service';
import {
  PrintJobCreatedEvent,
  PrintJobStatusChangedEvent,
  PrintJobCompletedEvent,
  PrintJobFailedEvent,
  PRINT_JOB_EVENTS,
} from './events/print-job.events';

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

  constructor(
    private readonly printJobsRepository: PrintJobsRepository,
    private readonly simplyPrintClient: SimplyPrintApiClient,
    private readonly productMappingsRepository: ProductMappingsRepository,
    private readonly eventLogService: EventLogService,
    private readonly eventEmitter: EventEmitter2
  ) {}

  /**
   * Create a print job for a line item
   */
  async createPrintJobForLineItem(lineItem: LineItem & { order: { id: string } }): Promise<PrintJob> {
    this.logger.log(`Creating print job for line item: ${lineItem.id}`);

    // Check if print job already exists (idempotency)
    const existingJob = await this.printJobsRepository.findByLineItemId(lineItem.id);
    if (existingJob) {
      this.logger.warn(`Print job already exists for line item: ${lineItem.id}`);
      return existingJob;
    }

    // Lookup product mapping
    const mapping = await this.productMappingsRepository.findBySku(lineItem.productSku);
    if (!mapping) {
      await this.handleUnmappedProduct(lineItem);
      throw new NotFoundException(`No product mapping found for SKU: ${lineItem.productSku}`);
    }

    // Create local print job record first (PENDING status)
    const printJob = await this.printJobsRepository.create({
      lineItem: { connect: { id: lineItem.id } },
      status: PrintJobStatus.PENDING,
    });

    try {
      // Create job in SimplyPrint
      const simplyPrintJob = await this.simplyPrintClient.createJob({
        fileId: mapping.simplyPrintFileId,
        quantity: lineItem.quantity,
        printProfile: mapping.printProfile as Record<string, unknown>,
        metadata: {
          orderId: lineItem.order.id,
          lineItemId: lineItem.id,
          sku: lineItem.productSku,
        },
      });

      // Update local record with SimplyPrint job ID
      const updatedJob = await this.printJobsRepository.update(printJob.id, {
        simplyPrintJobId: simplyPrintJob.id,
        status: PrintJobStatus.QUEUED,
      });

      // Log event
      await this.eventLogService.log({
        orderId: lineItem.order.id,
        eventType: 'PRINT_JOB_CREATED',
        severity: 'INFO',
        message: `Print job created in SimplyPrint: ${simplyPrintJob.id}`,
        metadata: {
          printJobId: printJob.id,
          simplyPrintJobId: simplyPrintJob.id,
          fileId: mapping.simplyPrintFileId,
        },
      });

      // Emit event
      this.eventEmitter.emit(
        PRINT_JOB_EVENTS.CREATED,
        new PrintJobCreatedEvent(updatedJob, lineItem.order.id)
      );

      this.logger.log(`Print job created successfully: ${printJob.id} -> ${simplyPrintJob.id}`);
      return updatedJob;
    } catch (error) {
      // Handle SimplyPrint API failure
      await this.handleJobCreationFailure(printJob, lineItem, error);
      throw error;
    }
  }

  /**
   * Update print job status from SimplyPrint
   */
  async updateJobStatus(
    simplyPrintJobId: string,
    newStatus: PrintJobStatus,
    additionalData?: {
      printerId?: string;
      errorMessage?: string;
      progress?: number;
    }
  ): Promise<PrintJob> {
    const printJob = await this.printJobsRepository.findBySimplyPrintJobId(simplyPrintJobId);
    if (!printJob) {
      throw new NotFoundException(`Print job not found: ${simplyPrintJobId}`);
    }

    const oldStatus = printJob.status;
    if (oldStatus === newStatus) {
      return printJob;
    }

    const updateData: Record<string, unknown> = {
      status: newStatus,
      ...additionalData,
    };

    // Set timestamps based on status
    if (newStatus === PrintJobStatus.PRINTING && !printJob.startedAt) {
      updateData.startedAt = new Date();
    }
    if (newStatus === PrintJobStatus.COMPLETED || newStatus === PrintJobStatus.FAILED) {
      updateData.completedAt = new Date();
    }

    const updatedJob = await this.printJobsRepository.update(printJob.id, updateData);

    // Log event
    await this.eventLogService.log({
      orderId: (printJob as PrintJob & { lineItem: { orderId: string } }).lineItem?.orderId,
      eventType: 'PRINT_JOB_STATUS_CHANGED',
      severity: newStatus === PrintJobStatus.FAILED ? 'ERROR' : 'INFO',
      message: `Print job status changed: ${oldStatus} -> ${newStatus}`,
      metadata: {
        printJobId: printJob.id,
        simplyPrintJobId,
        oldStatus,
        newStatus,
        ...additionalData,
      },
    });

    // Emit appropriate event
    this.eventEmitter.emit(
      PRINT_JOB_EVENTS.STATUS_CHANGED,
      new PrintJobStatusChangedEvent(updatedJob, oldStatus, newStatus)
    );

    if (newStatus === PrintJobStatus.COMPLETED) {
      this.eventEmitter.emit(PRINT_JOB_EVENTS.COMPLETED, new PrintJobCompletedEvent(updatedJob));
    } else if (newStatus === PrintJobStatus.FAILED) {
      this.eventEmitter.emit(
        PRINT_JOB_EVENTS.FAILED,
        new PrintJobFailedEvent(updatedJob, additionalData?.errorMessage)
      );
    }

    return updatedJob;
  }

  /**
   * Cancel a print job
   */
  async cancelJob(printJobId: string): Promise<PrintJob> {
    const printJob = await this.printJobsRepository.findById(printJobId);
    if (!printJob) {
      throw new NotFoundException(`Print job not found: ${printJobId}`);
    }

    // Only cancel if not already completed/cancelled
    if (
      printJob.status === PrintJobStatus.COMPLETED ||
      printJob.status === PrintJobStatus.CANCELLED
    ) {
      this.logger.warn(`Cannot cancel job in status: ${printJob.status}`);
      return printJob;
    }

    // Cancel in SimplyPrint if job was created there
    if (printJob.simplyPrintJobId) {
      try {
        await this.simplyPrintClient.cancelJob(printJob.simplyPrintJobId);
      } catch (error) {
        this.logger.error(`Failed to cancel job in SimplyPrint: ${error.message}`);
        // Continue with local cancellation even if SimplyPrint fails
      }
    }

    return this.printJobsRepository.update(printJobId, {
      status: PrintJobStatus.CANCELLED,
      completedAt: new Date(),
    });
  }

  /**
   * Retry a failed print job
   */
  async retryJob(printJobId: string): Promise<PrintJob> {
    const printJob = await this.printJobsRepository.findById(printJobId);
    if (!printJob) {
      throw new NotFoundException(`Print job not found: ${printJobId}`);
    }

    if (printJob.status !== PrintJobStatus.FAILED) {
      throw new Error(`Cannot retry job in status: ${printJob.status}`);
    }

    // Increment retry count
    const updatedJob = await this.printJobsRepository.update(printJobId, {
      status: PrintJobStatus.PENDING,
      retryCount: { increment: 1 },
      errorMessage: null,
      simplyPrintJobId: null,
    });

    // Attempt to recreate the job
    // This will be picked up by the orchestration service
    this.eventEmitter.emit(PRINT_JOB_EVENTS.RETRY_REQUESTED, updatedJob);

    return updatedJob;
  }

  // ─────────────────────────────────────────────────────────────────────────────
  // Query Methods
  // ─────────────────────────────────────────────────────────────────────────────

  async findById(id: string): Promise<PrintJob | null> {
    return this.printJobsRepository.findById(id);
  }

  async findByOrderId(orderId: string): Promise<PrintJob[]> {
    return this.printJobsRepository.findByOrderId(orderId);
  }

  async findActiveJobs(): Promise<PrintJob[]> {
    return this.printJobsRepository.findActiveJobs();
  }

  // ─────────────────────────────────────────────────────────────────────────────
  // Private Helpers
  // ─────────────────────────────────────────────────────────────────────────────

  private async handleUnmappedProduct(lineItem: LineItem): Promise<void> {
    await this.eventLogService.log({
      orderId: (lineItem as LineItem & { order: { id: string } }).order?.id,
      eventType: 'UNMAPPED_PRODUCT',
      severity: 'WARNING',
      message: `No product mapping found for SKU: ${lineItem.productSku}`,
      metadata: {
        lineItemId: lineItem.id,
        sku: lineItem.productSku,
        productName: lineItem.productName,
      },
    });

    Sentry.captureMessage(`Unmapped product: ${lineItem.productSku}`, {
      level: 'warning',
      tags: { type: 'unmapped-product' },
      extra: { lineItem },
    });
  }

  private async handleJobCreationFailure(
    printJob: PrintJob,
    lineItem: LineItem,
    error: unknown
  ): Promise<void> {
    const errorMessage = error instanceof Error ? error.message : 'Unknown error';

    await this.printJobsRepository.update(printJob.id, {
      status: PrintJobStatus.FAILED,
      errorMessage,
    });

    await this.eventLogService.log({
      orderId: (lineItem as LineItem & { order: { id: string } }).order?.id,
      eventType: 'PRINT_JOB_CREATION_FAILED',
      severity: 'ERROR',
      message: `Failed to create print job: ${errorMessage}`,
      metadata: {
        printJobId: printJob.id,
        lineItemId: lineItem.id,
        error: errorMessage,
      },
    });

    if (!(error instanceof SimplyPrintApiError && error.statusCode && error.statusCode < 500)) {
      Sentry.captureException(error, {
        tags: { service: 'print-jobs', action: 'create' },
        extra: { printJobId: printJob.id, lineItemId: lineItem.id },
      });
    }
  }
}

3. Print Job Events

Create apps/api/src/print-jobs/events/print-job.events.ts:

import { PrintJob, PrintJobStatus } from '@prisma/client';

export const PRINT_JOB_EVENTS = {
  CREATED: 'printjob.created',
  STATUS_CHANGED: 'printjob.status-changed',
  COMPLETED: 'printjob.completed',
  FAILED: 'printjob.failed',
  CANCELLED: 'printjob.cancelled',
  RETRY_REQUESTED: 'printjob.retry-requested',
} as const;

export class PrintJobCreatedEvent {
  constructor(
    public readonly printJob: PrintJob,
    public readonly orderId: string
  ) {}
}

export class PrintJobStatusChangedEvent {
  constructor(
    public readonly printJob: PrintJob,
    public readonly previousStatus: PrintJobStatus,
    public readonly newStatus: PrintJobStatus
  ) {}
}

export class PrintJobCompletedEvent {
  constructor(public readonly printJob: PrintJob) {}
}

export class PrintJobFailedEvent {
  constructor(
    public readonly printJob: PrintJob,
    public readonly errorMessage?: string
  ) {}
}

🔧 Feature F2.3: Print Job Status Monitor

Requirements Reference

  • FR-SP-003: Print Job Status Monitoring
  • NFR-PE-001: Status detection within 60 seconds

Implementation

1. SimplyPrint Service (Polling + Webhook)

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

import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrintJobStatus } from '@prisma/client';
import * as Sentry from '@sentry/nestjs';
import { SimplyPrintApiClient } from './simplyprint-api.client';
import { PrintJobsService } from '../print-jobs/print-jobs.service';
import { PrintJobsRepository } from '../print-jobs/print-jobs.repository';
import { SimplyPrintJobStatus } from '@forma3d/api-client';

@Injectable()
export class SimplyPrintService implements OnModuleInit {
  private readonly logger = new Logger(SimplyPrintService.name);
  private pollingEnabled: boolean;
  private pollingIntervalMs: number;
  private activeJobPollingIntervalMs: number;

  constructor(
    private readonly configService: ConfigService,
    private readonly simplyPrintClient: SimplyPrintApiClient,
    private readonly printJobsService: PrintJobsService,
    private readonly printJobsRepository: PrintJobsRepository
  ) {}

  async onModuleInit(): Promise<void> {
    this.pollingEnabled = this.configService.get<boolean>('SIMPLYPRINT_POLLING_ENABLED', true);
    this.pollingIntervalMs = this.configService.get<number>('SIMPLYPRINT_POLLING_INTERVAL_MS', 30000);
    this.activeJobPollingIntervalMs = this.configService.get<number>(
      'SIMPLYPRINT_ACTIVE_JOB_POLLING_INTERVAL_MS',
      10000
    );

    if (this.pollingEnabled) {
      this.logger.log(`SimplyPrint polling enabled (interval: ${this.pollingIntervalMs}ms)`);
    }
  }

  /**
   * Poll SimplyPrint for job status updates
   * Runs every 30 seconds by default
   */
  @Cron(CronExpression.EVERY_30_SECONDS)
  async pollJobStatuses(): Promise<void> {
    if (!this.pollingEnabled) return;

    try {
      const activeJobs = await this.printJobsRepository.findActiveJobs();
      if (activeJobs.length === 0) return;

      this.logger.debug(`Polling status for ${activeJobs.length} active jobs`);

      for (const job of activeJobs) {
        if (!job.simplyPrintJobId) continue;

        try {
          const simplyPrintJob = await this.simplyPrintClient.getJob(job.simplyPrintJobId);
          const newStatus = this.mapSimplyPrintStatus(simplyPrintJob.status);

          if (newStatus !== job.status) {
            await this.printJobsService.updateJobStatus(job.simplyPrintJobId, newStatus, {
              printerId: simplyPrintJob.printerId,
              errorMessage: simplyPrintJob.errorMessage,
            });
          }
        } catch (error) {
          this.logger.error(`Failed to poll job ${job.simplyPrintJobId}: ${error.message}`);
        }
      }
    } catch (error) {
      this.logger.error(`Job polling failed: ${error.message}`);
      Sentry.captureException(error, { tags: { service: 'simplyprint', action: 'poll' } });
    }
  }

  /**
   * Handle incoming webhook from SimplyPrint
   */
  async handleWebhook(payload: {
    event: string;
    jobId: string;
    status?: SimplyPrintJobStatus;
    progress?: number;
    printerId?: string;
    errorMessage?: string;
  }): Promise<void> {
    this.logger.log(`Received SimplyPrint webhook: ${payload.event} for job ${payload.jobId}`);

    try {
      if (payload.status) {
        const newStatus = this.mapSimplyPrintStatus(payload.status);
        await this.printJobsService.updateJobStatus(payload.jobId, newStatus, {
          printerId: payload.printerId,
          errorMessage: payload.errorMessage,
        });
      }
    } catch (error) {
      this.logger.error(`Failed to process webhook: ${error.message}`);
      Sentry.captureException(error, {
        tags: { service: 'simplyprint', action: 'webhook' },
        extra: payload,
      });
    }
  }

  /**
   * Map SimplyPrint status to internal status
   */
  private mapSimplyPrintStatus(status: SimplyPrintJobStatus): PrintJobStatus {
    const statusMap: Record<SimplyPrintJobStatus, PrintJobStatus> = {
      [SimplyPrintJobStatus.QUEUED]: PrintJobStatus.QUEUED,
      [SimplyPrintJobStatus.ASSIGNED]: PrintJobStatus.ASSIGNED,
      [SimplyPrintJobStatus.PREPARING]: PrintJobStatus.ASSIGNED,
      [SimplyPrintJobStatus.PRINTING]: PrintJobStatus.PRINTING,
      [SimplyPrintJobStatus.COMPLETED]: PrintJobStatus.COMPLETED,
      [SimplyPrintJobStatus.FAILED]: PrintJobStatus.FAILED,
      [SimplyPrintJobStatus.CANCELLED]: PrintJobStatus.CANCELLED,
    };

    return statusMap[status] ?? PrintJobStatus.PENDING;
  }
}

2. SimplyPrint Webhook Controller

Create apps/api/src/simplyprint/simplyprint-webhook.controller.ts:

import { Controller, Post, Body, UseGuards, Logger, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiExcludeController } from '@nestjs/swagger';
import { SimplyPrintService } from './simplyprint.service';
import { SimplyPrintWebhookGuard } from './guards/simplyprint-webhook.guard';
import { SimplyPrintWebhookDto } from './dto/simplyprint-webhook.dto';

@ApiTags('SimplyPrint Webhooks')
@ApiExcludeController() // Hide from public docs
@Controller('webhooks/simplyprint')
export class SimplyPrintWebhookController {
  private readonly logger = new Logger(SimplyPrintWebhookController.name);

  constructor(private readonly simplyPrintService: SimplyPrintService) {}

  @Post()
  @HttpCode(HttpStatus.OK)
  @UseGuards(SimplyPrintWebhookGuard)
  @ApiOperation({ summary: 'Receive SimplyPrint webhook events' })
  @ApiResponse({ status: 200, description: 'Webhook processed successfully' })
  @ApiResponse({ status: 401, description: 'Invalid webhook signature' })
  async handleWebhook(@Body() payload: SimplyPrintWebhookDto): Promise<{ received: boolean }> {
    this.logger.log(`Received SimplyPrint webhook: ${payload.event}`);

    await this.simplyPrintService.handleWebhook({
      event: payload.event,
      jobId: payload.data.jobId,
      status: payload.data.status,
      progress: payload.data.progress,
      printerId: payload.data.printerId,
      errorMessage: payload.data.errorMessage,
    });

    return { received: true };
  }
}

3. Webhook Guard

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

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

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

  constructor(private readonly configService: ConfigService) {
    this.webhookSecret = this.configService.get<string>('SIMPLYPRINT_WEBHOOK_SECRET', '');
  }

  canActivate(context: ExecutionContext): boolean {
    // If no webhook secret is configured, skip verification (for development)
    if (!this.webhookSecret) {
      this.logger.warn('SimplyPrint webhook secret not configured, skipping verification');
      return true;
    }

    const request = context.switchToHttp().getRequest();
    const signature = request.headers['x-simplyprint-signature'];
    const rawBody = request.rawBody;

    if (!signature || !rawBody) {
      this.logger.warn('Missing signature or body in SimplyPrint webhook');
      throw new UnauthorizedException('Invalid webhook signature');
    }

    const expectedSignature = crypto
      .createHmac('sha256', this.webhookSecret)
      .update(rawBody)
      .digest('hex');

    const isValid = crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expectedSignature)
    );

    if (!isValid) {
      this.logger.warn('Invalid SimplyPrint webhook signature');
      throw new UnauthorizedException('Invalid webhook signature');
    }

    return true;
  }
}

🔧 Feature F2.4: Order-PrintJob Orchestration

Requirements Reference

  • FR-SP-004: Print Job Completion Handling
  • NFR-PE-002: Fulfillment Latency (< 60 seconds)

Implementation

1. Orchestration Service

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

import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { PrintJobStatus, OrderStatus } from '@prisma/client';
import * as Sentry from '@sentry/nestjs';
import { PrintJobsRepository } from '../print-jobs/print-jobs.repository';
import { OrdersRepository } from '../orders/orders.repository';
import { EventLogService } from '../event-log/event-log.service';
import {
  PRINT_JOB_EVENTS,
  PrintJobCompletedEvent,
  PrintJobFailedEvent,
  PrintJobStatusChangedEvent,
} from '../print-jobs/events/print-job.events';
import { ORDER_EVENTS, OrderReadyForFulfillmentEvent } from '../orders/events/order.events';

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

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

  /**
   * Handle print job completion - check if all jobs for order are complete
   */
  @OnEvent(PRINT_JOB_EVENTS.COMPLETED)
  async handlePrintJobCompleted(event: PrintJobCompletedEvent): Promise<void> {
    const printJob = event.printJob;
    this.logger.log(`Print job completed: ${printJob.id}`);

    try {
      // Get the order for this print job
      const printJobWithLineItem = await this.printJobsRepository.findById(printJob.id);
      if (!printJobWithLineItem) return;

      const lineItem = (printJobWithLineItem as any).lineItem;
      if (!lineItem?.orderId) return;

      const orderId = lineItem.orderId;

      // Check if all print jobs for this order are complete
      await this.checkOrderCompletion(orderId);
    } catch (error) {
      this.logger.error(`Failed to handle print job completion: ${error.message}`);
      Sentry.captureException(error, {
        tags: { service: 'orchestration', action: 'job-completed' },
      });
    }
  }

  /**
   * Handle print job failure - update order status and alert
   */
  @OnEvent(PRINT_JOB_EVENTS.FAILED)
  async handlePrintJobFailed(event: PrintJobFailedEvent): Promise<void> {
    const printJob = event.printJob;
    this.logger.error(`Print job failed: ${printJob.id} - ${event.errorMessage}`);

    try {
      const printJobWithLineItem = await this.printJobsRepository.findById(printJob.id);
      if (!printJobWithLineItem) return;

      const lineItem = (printJobWithLineItem as any).lineItem;
      if (!lineItem?.orderId) return;

      const orderId = lineItem.orderId;

      // Check if this failure should update the order status
      await this.checkOrderFailure(orderId);

      // Log for operator attention
      await this.eventLogService.log({
        orderId,
        eventType: 'PRINT_JOB_FAILED',
        severity: 'ERROR',
        message: `Print job ${printJob.id} failed: ${event.errorMessage}`,
        metadata: {
          printJobId: printJob.id,
          errorMessage: event.errorMessage,
          requiresAttention: true,
        },
      });
    } catch (error) {
      this.logger.error(`Failed to handle print job failure: ${error.message}`);
      Sentry.captureException(error);
    }
  }

  /**
   * Check if all print jobs for an order are complete
   */
  private async checkOrderCompletion(orderId: string): Promise<void> {
    const printJobs = await this.printJobsRepository.findByOrderId(orderId);

    if (printJobs.length === 0) {
      this.logger.warn(`No print jobs found for order: ${orderId}`);
      return;
    }

    const allCompleted = printJobs.every((job) => job.status === PrintJobStatus.COMPLETED);
    const anyFailed = printJobs.some((job) => job.status === PrintJobStatus.FAILED);
    const anyPending = printJobs.some((job) =>
      [PrintJobStatus.PENDING, PrintJobStatus.QUEUED, PrintJobStatus.ASSIGNED, PrintJobStatus.PRINTING].includes(
        job.status
      )
    );

    this.logger.debug({
      message: 'Order completion check',
      orderId,
      totalJobs: printJobs.length,
      allCompleted,
      anyFailed,
      anyPending,
    });

    if (allCompleted) {
      await this.markOrderReadyForFulfillment(orderId);
    } else if (anyFailed && !anyPending) {
      // All jobs are either completed or failed, with at least one failure
      await this.markOrderPartiallyFailed(orderId);
    }
  }

  /**
   * Check if order should be marked as failed
   */
  private async checkOrderFailure(orderId: string): Promise<void> {
    const printJobs = await this.printJobsRepository.findByOrderId(orderId);

    const anyPending = printJobs.some((job) =>
      [PrintJobStatus.PENDING, PrintJobStatus.QUEUED, PrintJobStatus.ASSIGNED, PrintJobStatus.PRINTING].includes(
        job.status
      )
    );

    // If there are still pending jobs, don't update order status yet
    if (anyPending) return;

    const allFailed = printJobs.every((job) => job.status === PrintJobStatus.FAILED);

    if (allFailed) {
      await this.ordersRepository.update(orderId, { status: OrderStatus.FAILED });

      await this.eventLogService.log({
        orderId,
        eventType: 'ORDER_FAILED',
        severity: 'ERROR',
        message: 'All print jobs for order have failed',
        metadata: { printJobCount: printJobs.length },
      });
    }
  }

  /**
   * Mark order as ready for fulfillment
   */
  private async markOrderReadyForFulfillment(orderId: string): Promise<void> {
    this.logger.log(`All print jobs complete for order: ${orderId}`);

    const order = await this.ordersRepository.update(orderId, {
      status: OrderStatus.COMPLETED,
    });

    await this.eventLogService.log({
      orderId,
      eventType: 'ORDER_READY_FOR_FULFILLMENT',
      severity: 'INFO',
      message: 'All print jobs completed, order ready for fulfillment',
    });

    // Emit event for Phase 3 fulfillment service
    this.eventEmitter.emit(
      ORDER_EVENTS.READY_FOR_FULFILLMENT,
      new OrderReadyForFulfillmentEvent(order)
    );
  }

  /**
   * Mark order as partially failed
   */
  private async markOrderPartiallyFailed(orderId: string): Promise<void> {
    this.logger.warn(`Order ${orderId} has partial failures`);

    await this.ordersRepository.update(orderId, {
      status: OrderStatus.FAILED, // Or a new PARTIALLY_FAILED status if needed
    });

    await this.eventLogService.log({
      orderId,
      eventType: 'ORDER_PARTIALLY_FAILED',
      severity: 'WARNING',
      message: 'Some print jobs failed, operator review required',
      metadata: { requiresAttention: true },
    });
  }
}

2. Update Order Events

Update apps/api/src/orders/events/order.events.ts:

import { Order } from '@prisma/client';

export const ORDER_EVENTS = {
  CREATED: 'order.created',
  UPDATED: 'order.updated',
  READY_FOR_FULFILLMENT: 'order.ready-for-fulfillment',
  FULFILLED: 'order.fulfilled',
  FAILED: 'order.failed',
  CANCELLED: 'order.cancelled',
} as const;

export class OrderCreatedEvent {
  constructor(public readonly order: Order) {}
}

export class OrderUpdatedEvent {
  constructor(
    public readonly order: Order,
    public readonly changes: Partial<Order>
  ) {}
}

export class OrderReadyForFulfillmentEvent {
  constructor(public readonly order: Order) {}
}

export class OrderFulfilledEvent {
  constructor(
    public readonly order: Order,
    public readonly fulfillmentId: string
  ) {}
}

export class OrderFailedEvent {
  constructor(
    public readonly order: Order,
    public readonly reason: string
  ) {}
}

3. Orchestration Module

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

import { Module } from '@nestjs/common';
import { OrchestrationService } from './orchestration.service';
import { PrintJobsModule } from '../print-jobs/print-jobs.module';
import { OrdersModule } from '../orders/orders.module';
import { EventLogModule } from '../event-log/event-log.module';

@Module({
  imports: [PrintJobsModule, OrdersModule, EventLogModule],
  providers: [OrchestrationService],
  exports: [OrchestrationService],
})
export class OrchestrationModule {}

🔧 Integration: Order Creation Flow

Update Order Service to Trigger Print Job Creation

Update apps/api/src/orders/orders.service.ts to emit events for print job creation:

// Add to the createFromShopifyWebhook method, after order is created:

// After creating order and line items:
for (const lineItem of createdLineItems) {
  this.eventEmitter.emit(ORDER_EVENTS.LINE_ITEM_CREATED, {
    lineItem,
    orderId: order.id,
  });
}

Add Order Line Item Event Handler

The orchestration service should listen for new line items and create print jobs:

// Add to OrchestrationService:

@OnEvent('order.line-item-created')
async handleLineItemCreated(event: { lineItem: LineItem; orderId: string }): Promise<void> {
  try {
    await this.printJobsService.createPrintJobForLineItem({
      ...event.lineItem,
      order: { id: event.orderId },
    });
  } catch (error) {
    this.logger.error(`Failed to create print job for line item: ${error.message}`);
    // Don't rethrow - order creation should not fail due to print job creation failure
  }
}

🧪 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 2 functionality

Unit Test Scenarios Required

Category Scenario Priority
API Client Successful authentication Critical
API Client Handle rate limiting High
API Client Handle API errors High
Print Job Create job from mapped product Critical
Print Job Handle unmapped product Critical
Print Job Status update from webhook Critical
Print Job Status update from polling High
Orchestration Complete order when all jobs done Critical
Orchestration Handle partial failures High
Orchestration Handle full order failure High

Acceptance Test Requirements (Playwright + Gherkin)

CRITICAL: Add new acceptance tests in apps/acceptance-tests/ for Phase 2 functionality:

New Feature Files to Create

Create apps/acceptance-tests/src/features/print-jobs.feature:

@smoke @api
Feature: Print Job Management
  As an operator
  I want to verify print job creation and status tracking
  So that I can confirm the SimplyPrint integration works

  Background:
    Given the staging API is available
    And the SimplyPrint integration is configured

  @critical
  Scenario: Print jobs endpoint is accessible
    When I request the print jobs list endpoint
    Then the response status should be 200
    And the response should be valid JSON

  Scenario: Print job details can be retrieved
    Given a print job exists in the system
    When I request the print job details
    Then the response should contain print job status
    And the response should contain SimplyPrint job reference

Create apps/acceptance-tests/src/features/simplyprint-webhook.feature:

@api
Feature: SimplyPrint Webhook Integration
  As a system
  I want to receive SimplyPrint status updates
  So that print job statuses are synchronized

  @critical
  Scenario: SimplyPrint webhook endpoint is accessible
    When I send a health check to the SimplyPrint webhook endpoint
    Then the endpoint should respond without 404

Step Definitions Required

  • Implement step definitions for print job scenarios in apps/acceptance-tests/src/steps/print-jobs.steps.ts
  • Update pipeline to run acceptance tests after deployment

Unit Test Examples

Create comprehensive tests for all services:

// apps/api/src/print-jobs/__tests__/print-jobs.service.spec.ts

describe('PrintJobsService', () => {
  describe('createPrintJobForLineItem', () => {
    it('should create print job for mapped product', async () => { /* ... */ });
    it('should throw for unmapped product', async () => { /* ... */ });
    it('should be idempotent', async () => { /* ... */ });
    it('should handle SimplyPrint API failure', async () => { /* ... */ });
  });

  describe('updateJobStatus', () => {
    it('should update status and emit event', async () => { /* ... */ });
    it('should set timestamps on status change', async () => { /* ... */ });
  });
});

✅ Validation Checklist

Infrastructure

  • SimplyPrint module created in apps/api/src/simplyprint
  • Print jobs module created in apps/api/src/print-jobs
  • Orchestration module created in apps/api/src/orchestration
  • All new modules compile without errors
  • pnpm nx build api succeeds
  • pnpm lint passes on all new files

SimplyPrint API Client (F2.1)

  • API client connects to SimplyPrint
  • Authentication verified on startup
  • Files endpoint working
  • Jobs endpoint working (create, get, cancel)
  • Printers endpoint working
  • Error handling with Sentry capture
  • Unit tests passing
  • Print jobs created from order line items
  • Product mapping lookup working
  • Unmapped products flagged with event log
  • SimplyPrint job ID stored locally
  • Idempotent (no duplicate jobs)
  • Unit tests passing

Status Monitoring (F2.3)

  • Webhook endpoint receiving events (if available)
  • Polling fallback working
  • Status updates propagated to local records
  • Events emitted on status change
  • Unit tests passing

Order Orchestration (F2.4)

  • Order marked complete when all jobs done
  • Partial failures handled correctly
  • Full failures handled correctly
  • order.ready-for-fulfillment event emitted
  • Unit tests passing

Integration Tests

  • End-to-end: Order webhook → Print job created
  • End-to-end: Print job complete → Order ready for fulfillment

Acceptance Tests (Playwright + Gherkin)

  • New feature file created: print-jobs.feature
  • New feature file created: simplyprint-webhook.feature
  • Step definitions implemented for new scenarios
  • Acceptance tests pass against staging environment
  • Pipeline runs acceptance tests after deployment

🚫 Constraints and Rules

MUST DO

  • Research SimplyPrint API documentation before implementing
  • Verify authentication on module initialization
  • Make print job creation idempotent (check for existing jobs)
  • Log all print job status changes to EventLog
  • Capture errors to Sentry (5xx only for API errors)
  • Use correlation IDs in all logs
  • Emit events for downstream services (Phase 3)
  • Handle rate limiting from SimplyPrint API
  • Write unit tests for all new services (> 80% coverage)
  • Add acceptance tests (Playwright + Gherkin) for Phase 2 functionality
  • Update ALL documentation in docs/ folder:
  • README.md with new configuration/features
  • docs/implementation-plan.md — mark Phase 2 as complete
  • docs/requirements.md — mark requirements as implemented

MUST NOT

  • Store SimplyPrint API credentials in code
  • Block order creation if print job fails
  • Create duplicate print jobs for same line item
  • Ignore unmapped products (must flag for review)
  • Skip webhook signature verification
  • Poll more frequently than configured interval
  • Exceed SimplyPrint API rate limits
  • Skip writing unit tests — All new code must have tests
  • Skip acceptance tests — New Gherkin scenarios required for Phase 2
  • Leave documentation incomplete — All docs must be updated before phase completion

🎬 Execution Order

Implementation

  1. Research SimplyPrint API — Understand available endpoints, authentication, webhooks
  2. Update environment variables — Add SimplyPrint configuration to .env.example
  3. Create shared types in libs/api-client/src/simplyprint/
  4. Create SimplyPrint API client
  5. Create SimplyPrint module with webhook controller
  6. Create print jobs repository
  7. Create print jobs service
  8. Create print jobs events
  9. Create orchestration service
  10. Update order service to emit line item events
  11. Update app module to include new modules

Testing

  1. Write unit tests for all new services (> 80% coverage)
  2. Write integration tests for end-to-end flow
  3. Add acceptance tests — Create new Gherkin feature files for print jobs
  4. Implement step definitions for acceptance test scenarios
  5. Test against SimplyPrint sandbox (if available)

Documentation

  1. Update Swagger documentation — Add @Api* decorators to all new endpoints
  2. Update README.md — Add SimplyPrint integration section
  3. Update docs/implementation-plan.md — Mark Phase 2 features as complete
  4. Update docs/requirements.md — Mark FR-SP requirements as implemented
  5. Update architecture docs (if applicable)

Validation

  1. Run full validation checklist
  2. Verify acceptance tests pass in pipeline
  3. Confirm all documentation is complete

📊 Expected Output

When Phase 2 is complete:

Verification Commands

# Build all projects
pnpm nx build api

# Run tests
pnpm nx test api

# Start API and verify SimplyPrint connection
pnpm nx serve api
# Should see: "SimplyPrint API connected. Found X printers."

# Create a test order via Swagger UI
# POST /api/v1/orders with line items
# Verify print job created in SimplyPrint

# Check print job status
# GET /api/v1/print-jobs/:id

SimplyPrint Integration Verification

  1. Create order via Shopify webhook simulation
  2. Verify print job created in SimplyPrint dashboard
  3. Complete print job in SimplyPrint
  4. Verify local status updated to COMPLETED
  5. Verify order marked as COMPLETED

Event Flow Verification

Order Created
    ↓
Line Item Created (event)
    ↓
Print Job Created (event)
    ↓
SimplyPrint Job Created
    ↓
[Status Updates via Webhook/Polling]
    ↓
Print Job Completed (event)
    ↓
Order Ready for Fulfillment (event)

🔗 Phase 2 Exit Criteria

Functional Requirements

  • SimplyPrint API client functional with authentication
  • Print jobs created automatically from orders
  • Print job status changes tracked (webhook or polling)
  • Order completion detected when all jobs done
  • Fulfillment events emitted for Phase 3
  • Unmapped products flagged for review
  • Error handling with Sentry capture

Testing Requirements

  • Unit tests > 80% coverage for all new code
  • Integration tests passing
  • Acceptance tests added for Phase 2 functionality (Playwright + Gherkin)
  • All acceptance tests passing against staging
  • End-to-end flow working: Order → Print Job → Ready for Fulfillment

Documentation Requirements

  • README.md updated with SimplyPrint integration section
  • docs/implementation-plan.md updated — Phase 2 marked as complete
  • docs/requirements.md updated — FR-SP-001 through FR-SP-005 marked as implemented
  • Swagger documentation complete for all new endpoints
  • Architecture docs updated if applicable

📝 Documentation Updates

CRITICAL: All documentation must be updated to reflect Phase 2 completion.

README.md Updates Required

Add sections for:

  1. SimplyPrint Integration — Configuration and setup
  2. Print Job Flow — How orders become print jobs
  3. Status Monitoring — Webhook vs polling configuration
  4. Troubleshooting — Common issues and solutions
  5. Environment Variables — Document new SimplyPrint env vars

docs/implementation-plan.md Updates Required

Update the implementation plan to mark Phase 2 as complete:

  • Mark F2.1 (SimplyPrint API Client) as ✅ Completed
  • Mark F2.2 (Print Job Creation Service) as ✅ Completed
  • Mark F2.3 (Print Job Status Monitor) as ✅ Completed
  • Mark F2.4 (Order-PrintJob Orchestration) as ✅ Completed
  • Update Phase 2 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 SimplyPrint requirements as implemented:

  • Mark FR-SP-001 (Authentication) as ✅ Implemented
  • Mark FR-SP-002 (Print Job Creation) as ✅ Implemented
  • Mark FR-SP-003 (Print Job Status Monitoring) as ✅ Implemented
  • Mark FR-SP-004 (Print Job Completion Handling) as ✅ Implemented
  • Mark FR-SP-005 (Print Job Failure Handling) as ✅ Implemented
  • Update revision history

docs/architecture/ Updates (If Applicable)

If architecture changes are significant, update:

  • Update C4 Component diagram to include SimplyPrint integration
  • Add new ADR if any architectural decisions were made
  • Update sequence diagrams for print job flow

Swagger Documentation

Ensure all new endpoints are documented with @Api* decorators:

  • GET /api/v1/print-jobs — List print jobs
  • GET /api/v1/print-jobs/:id — Get print job details
  • POST /api/v1/print-jobs/:id/retry — Retry failed job
  • POST /api/v1/print-jobs/:id/cancel — Cancel job
  • POST /webhooks/simplyprint — SimplyPrint webhook (hidden from public docs)

🔮 Phase 3 Preview

Phase 3 (Fulfillment Loop) will build on Phase 2:

  • Listen for order.ready-for-fulfillment events
  • Create Shopify fulfillments automatically
  • Handle order cancellations
  • Implement error recovery with retry queues
  • Add email notifications for failures

The orchestration service and event system established in Phase 2 will be the foundation for Phase 3's fulfillment automation.


END OF PROMPT


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