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:
- Access the SimplyPrint API documentation
- Determine authentication method (API key, OAuth, etc.)
- Identify available endpoints for:
- File listing
- Job creation
- Job status retrieval
- Job cancellation
- Printer listing/status
- Check webhook availability for job status updates
- 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 apisucceeds -
pnpm lintpasses 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 Job Creation (F2.2)¶
- 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-fulfillmentevent 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¶
- Research SimplyPrint API — Understand available endpoints, authentication, webhooks
- Update environment variables — Add SimplyPrint configuration to
.env.example - Create shared types in
libs/api-client/src/simplyprint/ - Create SimplyPrint API client
- Create SimplyPrint module with webhook controller
- Create print jobs repository
- Create print jobs service
- Create print jobs events
- Create orchestration service
- Update order service to emit line item events
- Update app module to include new modules
Testing¶
- Write unit tests for all new services (> 80% coverage)
- Write integration tests for end-to-end flow
- Add acceptance tests — Create new Gherkin feature files for print jobs
- Implement step definitions for acceptance test scenarios
- Test against SimplyPrint sandbox (if available)
Documentation¶
- Update Swagger documentation — Add
@Api*decorators to all new endpoints - Update README.md — Add SimplyPrint integration section
- Update docs/implementation-plan.md — Mark Phase 2 features as complete
- Update docs/requirements.md — Mark FR-SP requirements as implemented
- Update architecture docs (if applicable)
Validation¶
- Run full validation checklist
- Verify acceptance tests pass in pipeline
- 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¶
- Create order via Shopify webhook simulation
- Verify print job created in SimplyPrint dashboard
- Complete print job in SimplyPrint
- Verify local status updated to COMPLETED
- 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:
- SimplyPrint Integration — Configuration and setup
- Print Job Flow — How orders become print jobs
- Status Monitoring — Webhook vs polling configuration
- Troubleshooting — Common issues and solutions
- 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 jobsGET /api/v1/print-jobs/:id— Get print job detailsPOST /api/v1/print-jobs/:id/retry— Retry failed jobPOST /api/v1/print-jobs/:id/cancel— Cancel jobPOST /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-fulfillmentevents - 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.