AI Prompt: Forma3D.Connect — Phase 5: Shipping Integration ✅¶
Purpose: This prompt instructs an AI to implement Phase 5 of Forma3D.Connect
Estimated Effort: 22 hours (~2 weeks)
Prerequisites: Phase 4 completed (Dashboard MVP - order management, product mappings UI, real-time updates)
Output: Sendcloud integration for shipping labels, tracking sync to Shopify, shipping management UI
Status: ✅ COMPLETED (January 16, 2026)
🎯 Mission¶
You are continuing development of Forma3D.Connect, building on the Phase 4 foundation. Your task is to implement Phase 5: Shipping Integration — completing the end-to-end automation by integrating Sendcloud for shipping label generation, syncing tracking information to Shopify, and adding shipping management to the dashboard.
Phase 5 delivers:
- Sendcloud API client for parcel creation and label generation
- Automated shipping label generation when orders are ready to ship
- Tracking information sync to Shopify fulfillments
- Shipping management UI in the dashboard (labels, tracking, carrier selection)
Phase 5 completes the full automation flow:
Order → Print Job → Print Complete → Shipping Label → Fulfillment → Customer Notified
📋 Phase 5 Context¶
What Was Built in Previous Phases¶
The foundation is already in place:
- Phase 0: Foundation
- Nx monorepo with
apps/api,apps/web, and shared libs - PostgreSQL database with Prisma schema (Order, LineItem, PrintJob, ProductMapping, EventLog, RetryQueue)
- 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.forma3d.be -
Phase 1d: Acceptance Testing
- Playwright + Gherkin acceptance tests
- Given/When/Then scenarios for deployment verification
-
Azure DevOps pipeline integration
-
Phase 2: SimplyPrint Core ✅
- SimplyPrint API client with HTTP Basic Auth
- Automated print job creation from orders
- Print job status monitoring (webhook + polling)
-
Order-job orchestration with
order.ready-for-fulfillmentevent -
Phase 3: Fulfillment Loop ✅
- Automated Shopify fulfillment creation
- Order cancellation handling
- Retry queue with exponential backoff
- Email notifications for critical failures
-
API key authentication for admin endpoints
-
Phase 4: Dashboard MVP ✅
- React 19 dashboard with TanStack Query
- Order management UI (list, detail, actions)
- Product mapping configuration UI
- Real-time updates via Socket.IO
- Activity logs with filtering and export
What Phase 5 Builds¶
| Feature | Description | Effort |
|---|---|---|
| F5.1: Sendcloud API Client | Typed client for Sendcloud API interactions | 8 hours |
| F5.2: Shipping Label Generation | Automated label creation on order completion | 8 hours |
| F5.3: Shipping Dashboard UI | View and manage shipping labels in dashboard | 6 hours |
🛠️ Tech Stack Reference¶
All technologies from Phase 4 remain. No additional packages required for Phase 5 (using standard HTTP client for Sendcloud API).
| Package | Purpose |
|---|---|
axios |
HTTP client (already installed) |
@nestjs/event-emitter |
Internal event system (already installed) |
🏗️ Architecture Reference¶
Detailed Architecture Diagrams¶
📐 For detailed architecture, refer to the existing PlantUML diagrams:
Diagram Path Description Fulfillment Flow docs/03-architecture/sequences/C4_Seq_05_Fulfillment.pumlFulfillment sequence diagram Container View docs/03-architecture/c4-model/2-container/C4_Container.pumlSystem containers and interactions Component View docs/03-architecture/c4-model/3-component/C4_Component.pumlBackend component architecture Order State docs/03-architecture/state-machines/C4_Code_State_Order.pumlOrder status state machine Domain Model docs/03-architecture/c4-model/4-code/C4_Code_DomainModel.pumlEntity relationships These PlantUML diagrams should be updated as part of Phase 5 completion.
Current Database Schema¶
The Prisma schema needs extension for shipping:
// Existing Order model (to be updated)
model Order {
id String @id @default(uuid())
shopifyOrderId String @unique
shopifyOrderNumber String
status OrderStatus @default(PENDING)
customerName String
customerEmail String?
shippingAddress Json
totalPrice Decimal @db.Decimal(10, 2)
currency String @default("EUR")
shopifyFulfillmentId String?
trackingNumber String?
trackingUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
completedAt DateTime?
lineItems LineItem[]
shipment Shipment? // NEW: Add relation to Shipment
}
// NEW: Shipment model for Sendcloud integration
model Shipment {
id String @id @default(uuid())
orderId String @unique
order Order @relation(fields: [orderId], references: [id])
sendcloudParcelId Int? @unique
status ShipmentStatus @default(PENDING)
shippingMethodId Int?
shippingMethodName String?
carrierName String?
trackingNumber String?
trackingUrl String?
labelUrl String?
weight Decimal? @db.Decimal(10, 3)
dimensions Json? // { length, width, height }
errorMessage String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
shippedAt DateTime?
}
enum ShipmentStatus {
PENDING
LABEL_CREATED
ANNOUNCED
IN_TRANSIT
DELIVERED
FAILED
CANCELLED
}
Phase 5 Event Flow¶
┌─────────────────────────────────┐
│ │
▼ │
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐ │
│ All Print │────▶│ Shipping │────▶│ Sendcloud │ │
│ Jobs Done │ │ Service │ │ Create Parcel│ │
└──────────────┘ └──────────────────┘ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Get Label │ │
│ │ PDF │ │
│ └──────────────┘ │
│ │ │
▼ ▼ │
┌──────────────────┐ ┌──────────────┐ │
│ Fulfillment │────▶│ Shopify │ │
│ Service │ │ + Tracking │ │
└──────────────────┘ └──────────────┘ │
│ │
│ (on failure) │
▼ │
┌──────────────────┐ │
│ Retry Queue │──────────────────────────┘
└──────────────────┘
Sendcloud Integration Flow¶
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Order Ready │────▶│ Get Shipping │────▶│ Create Parcel │
│ to Ship │ │ Method │ │ (Sendcloud) │
└──────────────────┘ └──────────────────┘ └──────────────────┘
│
┌─────────────────────────────────────────────────┤
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Store Tracking │ │ Download Label │
│ Information │ │ PDF │
└──────────────────┘ └──────────────────┘
│ │
└─────────────────────┬───────────────────────────┘
│
▼
┌──────────────────┐
│ Create Shopify │
│ Fulfillment │
│ + Tracking │
└──────────────────┘
📁 Files to Create/Modify¶
Backend (apps/api)¶
apps/api/src/
├── sendcloud/
│ ├── sendcloud.module.ts # Module definition
│ ├── sendcloud-api.client.ts # Sendcloud API client
│ ├── sendcloud.service.ts # Shipping business logic
│ ├── sendcloud.controller.ts # REST endpoints
│ ├── dto/
│ │ ├── create-parcel.dto.ts # Create parcel DTO
│ │ ├── parcel-response.dto.ts # Parcel response DTO
│ │ ├── shipping-method.dto.ts # Shipping method DTO
│ │ └── shipment.dto.ts # Shipment response DTO
│ ├── events/
│ │ └── shipment.events.ts # Shipment event definitions
│ └── __tests__/
│ ├── sendcloud-api.client.spec.ts
│ └── sendcloud.service.spec.ts
│
├── shipments/
│ ├── shipments.module.ts # Module definition
│ ├── shipments.repository.ts # Shipment database operations
│ ├── shipments.service.ts # Shipment management logic
│ ├── shipments.controller.ts # REST endpoints
│ ├── dto/
│ │ ├── shipment-query.dto.ts # Query params DTO
│ │ └── update-shipment.dto.ts # Update shipment DTO
│ └── __tests__/
│ └── shipments.service.spec.ts
│
├── fulfillment/
│ └── fulfillment.service.ts # UPDATE: Integrate shipping
prisma/
└── schema.prisma # UPDATE: Add Shipment model
prisma/migrations/
└── YYYYMMDD_add_shipment/ # Migration for shipment table
Frontend (apps/web)¶
apps/web/src/
├── hooks/
│ └── use-shipments.ts # Shipment data hooks
│
├── pages/
│ └── orders/
│ └── [id].tsx # UPDATE: Add shipping section
│
├── components/
│ └── orders/
│ ├── shipping-info.tsx # Shipping info display
│ ├── shipping-label-button.tsx # Download label button
│ └── shipping-method-selector.tsx # Shipping method selector
│
├── lib/
│ └── api-client.ts # UPDATE: Add shipping endpoints
Shared Library Updates¶
libs/api-client/src/
├── sendcloud/
│ ├── sendcloud.types.ts # Sendcloud API types
│ └── sendcloud.client.ts # Typed sendcloud client
├── shipments/
│ └── shipments.client.ts # Typed shipments API client
🔧 Feature F5.1: Sendcloud API Client¶
Requirements Reference¶
- FR-SC-001: Generate Shipping Labels via Sendcloud
- FR-SC-002: Tracking Information Sync
Implementation¶
1. Environment Variables¶
Add to .env.example:
# Sendcloud
SENDCLOUD_PUBLIC_KEY=your-sendcloud-public-key
SENDCLOUD_SECRET_KEY=your-sendcloud-secret-key
SENDCLOUD_API_URL=https://panel.sendcloud.sc/api/v2
# Shipping defaults
DEFAULT_SHIPPING_METHOD_ID=8 # PostNL Standard
DEFAULT_SENDER_ADDRESS_ID=12345 # Your sender address ID
SHIPPING_ENABLED=true
2. Sendcloud Types¶
Create libs/api-client/src/sendcloud/sendcloud.types.ts:
/**
* Sendcloud API Types
* @see https://api.sendcloud.dev/docs/sendcloud-public-api
*/
export interface SendcloudAddress {
name: string;
company_name?: string;
address: string;
address_2?: string;
house_number?: string;
city: string;
postal_code: string;
country: string; // ISO 3166-1 alpha-2 (e.g., "NL", "BE")
telephone?: string;
email?: string;
}
export interface SendcloudParcelInput {
name: string;
company_name?: string;
address: string;
address_2?: string;
house_number?: string;
city: string;
postal_code: string;
country: string;
telephone?: string;
email?: string;
order_number?: string;
external_reference?: string;
shipment?: {
id: number; // Shipping method ID
};
weight?: string; // Weight in kg (e.g., "1.5")
length?: number;
width?: number;
height?: number;
request_label?: boolean;
apply_shipping_rules?: boolean;
sender_address?: number; // Sender address ID
}
export interface SendcloudParcel {
id: number;
name: string;
company_name: string | null;
address: string;
address_2: string | null;
house_number: string | null;
city: string;
postal_code: string;
country: {
iso_2: string;
iso_3: string;
name: string;
};
email: string | null;
telephone: string | null;
status: {
id: number;
message: string;
};
tracking_number: string | null;
tracking_url: string | null;
carrier: {
code: string;
} | null;
shipment: {
id: number;
name: string;
} | null;
label: {
normal_printer: string[];
label_printer: string | null;
} | null;
order_number: string | null;
external_reference: string | null;
weight: string;
created_at: string;
updated_at: string;
}
export interface SendcloudShippingMethod {
id: number;
name: string;
carrier: string;
min_weight: number;
max_weight: number;
service_point_input: string;
price: number;
countries: Array<{
id: number;
iso_2: string;
iso_3: string;
name: string;
price: number;
}>;
}
export interface SendcloudLabelFormat {
normal_printer: string[]; // Array of PDF URLs
label_printer: string | null;
}
export interface SendcloudError {
error: {
code: number;
message: string;
request?: string;
};
}
export interface CreateParcelResponse {
parcel: SendcloudParcel;
}
export interface GetParcelResponse {
parcel: SendcloudParcel;
}
export interface GetShippingMethodsResponse {
shipping_methods: SendcloudShippingMethod[];
}
export interface GetLabelResponse {
label: SendcloudLabelFormat;
}
3. Sendcloud API Client¶
Create apps/api/src/sendcloud/sendcloud-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 {
SendcloudParcelInput,
SendcloudParcel,
SendcloudShippingMethod,
CreateParcelResponse,
GetParcelResponse,
GetShippingMethodsResponse,
SendcloudError,
} from '@forma3d/api-client';
interface SendcloudApiClientConfig {
publicKey: string;
secretKey: string;
apiUrl: string;
}
@Injectable()
export class SendcloudApiClient implements OnModuleInit {
private readonly logger = new Logger(SendcloudApiClient.name);
private client: AxiosInstance;
private config: SendcloudApiClientConfig;
private isEnabled: boolean;
constructor(private readonly configService: ConfigService) {}
async onModuleInit(): Promise<void> {
this.isEnabled = this.configService.get<boolean>('SHIPPING_ENABLED', false);
if (!this.isEnabled) {
this.logger.warn('Sendcloud integration is disabled');
return;
}
this.config = {
publicKey: this.configService.getOrThrow<string>('SENDCLOUD_PUBLIC_KEY'),
secretKey: this.configService.getOrThrow<string>('SENDCLOUD_SECRET_KEY'),
apiUrl: this.configService.get<string>(
'SENDCLOUD_API_URL',
'https://panel.sendcloud.sc/api/v2'
),
};
// Create HTTP client with Basic Auth
const auth = Buffer.from(`${this.config.publicKey}:${this.config.secretKey}`).toString(
'base64'
);
this.client = axios.create({
baseURL: this.config.apiUrl,
headers: {
Authorization: `Basic ${auth}`,
'Content-Type': 'application/json',
},
timeout: 30000,
});
// Add response interceptor for error handling
this.client.interceptors.response.use(
(response) => response,
(error: AxiosError<SendcloudError>) => {
this.handleApiError(error);
throw error;
}
);
this.logger.log('Sendcloud API client initialized');
}
/**
* Check if Sendcloud is enabled
*/
isShippingEnabled(): boolean {
return this.isEnabled;
}
/**
* Create a parcel and request label
*/
async createParcel(input: SendcloudParcelInput): Promise<SendcloudParcel> {
this.ensureEnabled();
this.logger.log(`Creating parcel for order: ${input.order_number}`);
try {
const response = await this.client.post<CreateParcelResponse>('/parcels', {
parcel: {
...input,
request_label: true,
},
});
this.logger.log(`Parcel created: ${response.data.parcel.id}`);
return response.data.parcel;
} catch (error) {
this.logger.error(`Failed to create parcel: ${this.getErrorMessage(error)}`);
throw error;
}
}
/**
* Get parcel by ID
*/
async getParcel(parcelId: number): Promise<SendcloudParcel> {
this.ensureEnabled();
const response = await this.client.get<GetParcelResponse>(`/parcels/${parcelId}`);
return response.data.parcel;
}
/**
* Get parcel label URLs
*/
async getLabel(parcelId: number): Promise<string[]> {
this.ensureEnabled();
const parcel = await this.getParcel(parcelId);
if (!parcel.label?.normal_printer?.length) {
throw new Error(`No label available for parcel ${parcelId}`);
}
return parcel.label.normal_printer;
}
/**
* Cancel a parcel
*/
async cancelParcel(parcelId: number): Promise<void> {
this.ensureEnabled();
this.logger.log(`Cancelling parcel: ${parcelId}`);
try {
await this.client.post(`/parcels/${parcelId}/cancel`);
this.logger.log(`Parcel cancelled: ${parcelId}`);
} catch (error) {
this.logger.error(`Failed to cancel parcel: ${this.getErrorMessage(error)}`);
throw error;
}
}
/**
* Get available shipping methods
*/
async getShippingMethods(
senderAddressId?: number,
toCountry?: string
): Promise<SendcloudShippingMethod[]> {
this.ensureEnabled();
const params: Record<string, unknown> = {};
if (senderAddressId) params.sender_address = senderAddressId;
if (toCountry) params.to_country = toCountry;
const response = await this.client.get<GetShippingMethodsResponse>('/shipping_methods', {
params,
});
return response.data.shipping_methods;
}
/**
* Get tracking information for a parcel
*/
async getTracking(parcelId: number): Promise<{
trackingNumber: string | null;
trackingUrl: string | null;
status: { id: number; message: string };
}> {
this.ensureEnabled();
const parcel = await this.getParcel(parcelId);
return {
trackingNumber: parcel.tracking_number,
trackingUrl: parcel.tracking_url,
status: parcel.status,
};
}
/**
* Ensure Sendcloud is enabled
*/
private ensureEnabled(): void {
if (!this.isEnabled) {
throw new Error('Sendcloud integration is disabled');
}
}
/**
* Handle API errors
*/
private handleApiError(error: AxiosError<SendcloudError>): void {
const errorData = error.response?.data?.error;
if (errorData) {
this.logger.error(`Sendcloud API error: [${errorData.code}] ${errorData.message}`);
Sentry.captureException(error, {
tags: { service: 'sendcloud', action: 'api-call' },
extra: {
code: errorData.code,
message: errorData.message,
request: errorData.request,
},
});
}
}
/**
* Get error message from error
*/
private getErrorMessage(error: unknown): string {
if (axios.isAxiosError(error)) {
return error.response?.data?.error?.message || error.message;
}
return error instanceof Error ? error.message : 'Unknown error';
}
}
4. Sendcloud Module¶
Create apps/api/src/sendcloud/sendcloud.module.ts:
import { Module } from '@nestjs/common';
import { SendcloudApiClient } from './sendcloud-api.client';
import { SendcloudService } from './sendcloud.service';
import { SendcloudController } from './sendcloud.controller';
import { ShipmentsModule } from '../shipments/shipments.module';
import { OrdersModule } from '../orders/orders.module';
import { EventLogModule } from '../event-log/event-log.module';
import { RetryQueueModule } from '../retry-queue/retry-queue.module';
@Module({
imports: [ShipmentsModule, OrdersModule, EventLogModule, RetryQueueModule],
controllers: [SendcloudController],
providers: [SendcloudApiClient, SendcloudService],
exports: [SendcloudApiClient, SendcloudService],
})
export class SendcloudModule {}
🔧 Feature F5.2: Shipping Label Generation¶
Requirements Reference¶
- FR-SC-001: Generate Shipping Labels via Sendcloud
- NFR-PE-002: Fulfillment Latency (< 60 seconds)
Implementation¶
1. Update Prisma Schema¶
Update prisma/schema.prisma to add Shipment model:
model Order {
id String @id @default(uuid())
shopifyOrderId String @unique
shopifyOrderNumber String
status OrderStatus @default(PENDING)
customerName String
customerEmail String?
shippingAddress Json
totalPrice Decimal @db.Decimal(10, 2)
currency String @default("EUR")
shopifyFulfillmentId String?
trackingNumber String?
trackingUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
completedAt DateTime?
lineItems LineItem[]
shipment Shipment?
}
model Shipment {
id String @id @default(uuid())
orderId String @unique
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
sendcloudParcelId Int? @unique
status ShipmentStatus @default(PENDING)
shippingMethodId Int?
shippingMethodName String?
carrierName String?
trackingNumber String?
trackingUrl String?
labelUrl String?
weight Decimal? @db.Decimal(10, 3)
dimensions Json?
errorMessage String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
shippedAt DateTime?
@@index([status])
@@index([createdAt])
}
enum ShipmentStatus {
PENDING
LABEL_CREATED
ANNOUNCED
IN_TRANSIT
DELIVERED
FAILED
CANCELLED
}
Run migration:
pnpm prisma migrate dev --name add_shipment
2. Shipment Events¶
Create apps/api/src/sendcloud/events/shipment.events.ts:
import { Order, Shipment } from '@prisma/client';
export const SHIPMENT_EVENTS = {
CREATED: 'shipment.created',
LABEL_READY: 'shipment.label-ready',
FAILED: 'shipment.failed',
UPDATED: 'shipment.updated',
} as const;
export class ShipmentCreatedEvent {
constructor(
public readonly shipment: Shipment,
public readonly order: Order
) {}
}
export class ShipmentLabelReadyEvent {
constructor(
public readonly shipment: Shipment,
public readonly labelUrl: string
) {}
}
export class ShipmentFailedEvent {
constructor(
public readonly orderId: string,
public readonly error: string,
public readonly willRetry: boolean
) {}
}
3. Shipments Repository¶
Create apps/api/src/shipments/shipments.repository.ts:
import { Injectable, Logger } from '@nestjs/common';
import { Prisma, Shipment, ShipmentStatus } from '@prisma/client';
import { PrismaService } from '../database/prisma.service';
@Injectable()
export class ShipmentsRepository {
private readonly logger = new Logger(ShipmentsRepository.name);
constructor(private readonly prisma: PrismaService) {}
async create(data: Prisma.ShipmentCreateInput): Promise<Shipment> {
return this.prisma.shipment.create({ data });
}
async findById(id: string): Promise<Shipment | null> {
return this.prisma.shipment.findUnique({ where: { id } });
}
async findByOrderId(orderId: string): Promise<Shipment | null> {
return this.prisma.shipment.findUnique({ where: { orderId } });
}
async findBySendcloudParcelId(sendcloudParcelId: number): Promise<Shipment | null> {
return this.prisma.shipment.findUnique({ where: { sendcloudParcelId } });
}
async update(id: string, data: Prisma.ShipmentUpdateInput): Promise<Shipment> {
return this.prisma.shipment.update({ where: { id }, data });
}
async updateByOrderId(orderId: string, data: Prisma.ShipmentUpdateInput): Promise<Shipment> {
return this.prisma.shipment.update({ where: { orderId }, data });
}
async updateStatus(id: string, status: ShipmentStatus): Promise<Shipment> {
return this.update(id, { status });
}
async findMany(params: {
status?: ShipmentStatus;
page?: number;
pageSize?: number;
}): Promise<{ shipments: Shipment[]; total: number }> {
const { status, page = 1, pageSize = 50 } = params;
const where: Prisma.ShipmentWhereInput = {};
if (status) where.status = status;
const [shipments, total] = await Promise.all([
this.prisma.shipment.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
order: { select: { shopifyOrderNumber: true, customerName: true } },
},
}),
this.prisma.shipment.count({ where }),
]);
return { shipments, total };
}
}
4. Sendcloud Service¶
Create apps/api/src/sendcloud/sendcloud.service.ts:
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { ConfigService } from '@nestjs/config';
import { Order, Shipment, ShipmentStatus, OrderStatus } from '@prisma/client';
import * as Sentry from '@sentry/nestjs';
import { SendcloudApiClient } from './sendcloud-api.client';
import { ShipmentsRepository } from '../shipments/shipments.repository';
import { OrdersRepository } from '../orders/orders.repository';
import { EventLogService } from '../event-log/event-log.service';
import { RetryQueueService } from '../retry-queue/retry-queue.service';
import { ORDER_EVENTS, OrderReadyForFulfillmentEvent } from '../orders/events/order.events';
import {
SHIPMENT_EVENTS,
ShipmentCreatedEvent,
ShipmentLabelReadyEvent,
ShipmentFailedEvent,
} from './events/shipment.events';
import { SendcloudParcelInput } from '@forma3d/api-client';
interface ShippingAddress {
first_name?: string;
last_name?: string;
name?: string;
company?: string;
address1: string;
address2?: string;
city: string;
zip: string;
country_code: string;
phone?: string;
}
@Injectable()
export class SendcloudService {
private readonly logger = new Logger(SendcloudService.name);
private readonly defaultShippingMethodId: number;
private readonly defaultSenderAddressId: number;
constructor(
private readonly sendcloudClient: SendcloudApiClient,
private readonly shipmentsRepository: ShipmentsRepository,
private readonly ordersRepository: OrdersRepository,
private readonly eventLogService: EventLogService,
private readonly retryQueueService: RetryQueueService,
private readonly eventEmitter: EventEmitter2,
private readonly configService: ConfigService
) {
this.defaultShippingMethodId = this.configService.get<number>('DEFAULT_SHIPPING_METHOD_ID', 8);
this.defaultSenderAddressId = this.configService.get<number>('DEFAULT_SENDER_ADDRESS_ID', 0);
}
/**
* Listen for order.ready-for-fulfillment events
* Create shipping label before fulfillment
*/
@OnEvent(ORDER_EVENTS.READY_FOR_FULFILLMENT, { async: true })
async handleOrderReadyForFulfillment(event: OrderReadyForFulfillmentEvent): Promise<void> {
if (!this.sendcloudClient.isShippingEnabled()) {
this.logger.debug('Shipping disabled, skipping label generation');
return;
}
this.logger.log(`Creating shipping label for order: ${event.order.id}`);
await this.createShipment(event.order.id);
}
/**
* Create shipment and generate label
*/
async createShipment(orderId: string, shippingMethodId?: number): Promise<Shipment> {
const order = await this.ordersRepository.findById(orderId);
if (!order) {
throw new NotFoundException(`Order not found: ${orderId}`);
}
// Check if shipment already exists
const existingShipment = await this.shipmentsRepository.findByOrderId(orderId);
if (existingShipment?.sendcloudParcelId) {
this.logger.warn(`Shipment already exists for order ${orderId}`);
return existingShipment;
}
try {
// Parse shipping address
const shippingAddress = order.shippingAddress as ShippingAddress;
// Build parcel input
const parcelInput: SendcloudParcelInput = {
name: this.buildRecipientName(shippingAddress),
company_name: shippingAddress.company || undefined,
address: shippingAddress.address1,
address_2: shippingAddress.address2 || undefined,
city: shippingAddress.city,
postal_code: shippingAddress.zip,
country: shippingAddress.country_code,
telephone: shippingAddress.phone || undefined,
email: order.customerEmail || undefined,
order_number: order.shopifyOrderNumber,
external_reference: order.id,
shipment: {
id: shippingMethodId || this.defaultShippingMethodId,
},
weight: '1.0', // Default weight, can be enhanced later
request_label: true,
sender_address: this.defaultSenderAddressId || undefined,
};
// Create parcel in Sendcloud
const parcel = await this.sendcloudClient.createParcel(parcelInput);
// Create or update shipment record
const shipment = existingShipment
? await this.shipmentsRepository.update(existingShipment.id, {
sendcloudParcelId: parcel.id,
status: ShipmentStatus.LABEL_CREATED,
shippingMethodId: parcel.shipment?.id,
shippingMethodName: parcel.shipment?.name,
carrierName: parcel.carrier?.code,
trackingNumber: parcel.tracking_number,
trackingUrl: parcel.tracking_url,
labelUrl: parcel.label?.normal_printer?.[0],
})
: await this.shipmentsRepository.create({
order: { connect: { id: orderId } },
sendcloudParcelId: parcel.id,
status: ShipmentStatus.LABEL_CREATED,
shippingMethodId: parcel.shipment?.id,
shippingMethodName: parcel.shipment?.name,
carrierName: parcel.carrier?.code,
trackingNumber: parcel.tracking_number,
trackingUrl: parcel.tracking_url,
labelUrl: parcel.label?.normal_printer?.[0],
});
// Update order with tracking info
await this.ordersRepository.update(orderId, {
trackingNumber: parcel.tracking_number,
trackingUrl: parcel.tracking_url,
});
// Log success
await this.eventLogService.log({
orderId,
eventType: 'SHIPPING_LABEL_CREATED',
severity: 'INFO',
message: `Shipping label created via Sendcloud`,
metadata: {
sendcloudParcelId: parcel.id,
trackingNumber: parcel.tracking_number,
carrier: parcel.carrier?.code,
},
});
// Emit events
this.eventEmitter.emit(SHIPMENT_EVENTS.CREATED, new ShipmentCreatedEvent(shipment, order));
if (shipment.labelUrl) {
this.eventEmitter.emit(
SHIPMENT_EVENTS.LABEL_READY,
new ShipmentLabelReadyEvent(shipment, shipment.labelUrl)
);
}
this.logger.log(
`Shipping label created for order ${order.shopifyOrderNumber}: ${parcel.tracking_number}`
);
return shipment;
} catch (error) {
await this.handleShipmentError(order, error);
throw error;
}
}
/**
* Get shipping label URL
*/
async getLabelUrl(orderId: string): Promise<string> {
const shipment = await this.shipmentsRepository.findByOrderId(orderId);
if (!shipment) {
throw new NotFoundException(`Shipment not found for order: ${orderId}`);
}
if (shipment.labelUrl) {
return shipment.labelUrl;
}
if (!shipment.sendcloudParcelId) {
throw new Error('No Sendcloud parcel ID found');
}
// Fetch label from Sendcloud
const labels = await this.sendcloudClient.getLabel(shipment.sendcloudParcelId);
const labelUrl = labels[0];
// Update shipment with label URL
await this.shipmentsRepository.update(shipment.id, { labelUrl });
return labelUrl;
}
/**
* Cancel shipment
*/
async cancelShipment(orderId: string): Promise<void> {
const shipment = await this.shipmentsRepository.findByOrderId(orderId);
if (!shipment) {
throw new NotFoundException(`Shipment not found for order: ${orderId}`);
}
if (shipment.sendcloudParcelId) {
try {
await this.sendcloudClient.cancelParcel(shipment.sendcloudParcelId);
} catch (error) {
this.logger.error(`Failed to cancel parcel in Sendcloud: ${error.message}`);
// Continue to update local status even if Sendcloud fails
}
}
await this.shipmentsRepository.updateStatus(shipment.id, ShipmentStatus.CANCELLED);
await this.eventLogService.log({
orderId,
eventType: 'SHIPMENT_CANCELLED',
severity: 'INFO',
message: 'Shipment cancelled',
});
}
/**
* Get available shipping methods for a destination
*/
async getShippingMethods(
toCountry: string
): Promise<Array<{ id: number; name: string; carrier: string; price: number }>> {
const methods = await this.sendcloudClient.getShippingMethods(
this.defaultSenderAddressId,
toCountry
);
return methods.map((method) => ({
id: method.id,
name: method.name,
carrier: method.carrier,
price: method.price,
}));
}
/**
* Regenerate label for shipment
*/
async regenerateLabel(orderId: string, shippingMethodId?: number): Promise<Shipment> {
const existingShipment = await this.shipmentsRepository.findByOrderId(orderId);
// Cancel existing parcel if it exists
if (existingShipment?.sendcloudParcelId) {
try {
await this.sendcloudClient.cancelParcel(existingShipment.sendcloudParcelId);
} catch (error) {
this.logger.warn(`Could not cancel existing parcel: ${error.message}`);
}
}
// Create new shipment
return this.createShipment(orderId, shippingMethodId);
}
/**
* Build recipient name from shipping address
*/
private buildRecipientName(address: ShippingAddress): string {
if (address.name) return address.name;
const parts = [address.first_name, address.last_name].filter(Boolean);
return parts.join(' ') || 'Customer';
}
/**
* Handle shipment creation errors
*/
private async handleShipmentError(order: Order, error: unknown): Promise<void> {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`Shipment creation failed for order ${order.id}: ${errorMessage}`);
// Check if error is retryable
const isRetryable = this.isRetryableError(error);
if (isRetryable) {
await this.retryQueueService.enqueue({
jobType: 'NOTIFICATION', // Using existing type, ideally add SHIPMENT type
payload: { orderId: order.id, action: 'create_shipment' },
maxAttempts: 3,
});
await this.eventLogService.log({
orderId: order.id,
eventType: 'SHIPMENT_RETRY_SCHEDULED',
severity: 'WARNING',
message: `Shipment failed, scheduled for retry: ${errorMessage}`,
});
this.eventEmitter.emit(
SHIPMENT_EVENTS.FAILED,
new ShipmentFailedEvent(order.id, errorMessage, true)
);
} else {
await this.eventLogService.log({
orderId: order.id,
eventType: 'SHIPMENT_FAILED_PERMANENT',
severity: 'ERROR',
message: `Shipment permanently failed: ${errorMessage}`,
metadata: { requiresAttention: true },
});
this.eventEmitter.emit(
SHIPMENT_EVENTS.FAILED,
new ShipmentFailedEvent(order.id, errorMessage, false)
);
Sentry.captureException(error, {
tags: { service: 'sendcloud', action: 'create-shipment' },
extra: { orderId: order.id },
});
}
}
/**
* Determine if error is retryable
*/
private isRetryableError(error: unknown): boolean {
if (error instanceof Error) {
const message = error.message.toLowerCase();
return ['timeout', 'rate limit', '503', '429', 'econnreset'].some((pattern) =>
message.includes(pattern)
);
}
return false;
}
}
5. Sendcloud Controller¶
Create apps/api/src/sendcloud/sendcloud.controller.ts:
import {
Controller,
Get,
Post,
Param,
Query,
HttpCode,
HttpStatus,
Logger,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiParam,
ApiQuery,
ApiBearerAuth,
} from '@nestjs/swagger';
import { SendcloudService } from './sendcloud.service';
import { SendcloudApiClient } from './sendcloud-api.client';
import { ApiKeyGuard } from '../auth/guards/api-key.guard';
@ApiTags('Shipping')
@Controller('api/v1/shipping')
export class SendcloudController {
private readonly logger = new Logger(SendcloudController.name);
constructor(
private readonly sendcloudService: SendcloudService,
private readonly sendcloudClient: SendcloudApiClient
) {}
@Get('methods')
@ApiOperation({ summary: 'Get available shipping methods' })
@ApiQuery({
name: 'country',
required: false,
description: 'Destination country ISO code',
})
@ApiResponse({ status: 200, description: 'Shipping methods retrieved' })
async getShippingMethods(@Query('country') country?: string) {
return this.sendcloudService.getShippingMethods(country || 'BE');
}
@Post('order/:orderId/label')
@HttpCode(HttpStatus.OK)
@UseGuards(ApiKeyGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Create shipping label for order' })
@ApiParam({ name: 'orderId', description: 'Order ID' })
@ApiResponse({ status: 200, description: 'Shipping label created' })
@ApiResponse({ status: 404, description: 'Order not found' })
async createLabel(
@Param('orderId') orderId: string,
@Query('shippingMethodId') shippingMethodId?: string
) {
const shipment = await this.sendcloudService.createShipment(
orderId,
shippingMethodId ? parseInt(shippingMethodId, 10) : undefined
);
return {
success: true,
shipment: {
id: shipment.id,
trackingNumber: shipment.trackingNumber,
trackingUrl: shipment.trackingUrl,
labelUrl: shipment.labelUrl,
carrier: shipment.carrierName,
},
};
}
@Get('order/:orderId/label')
@ApiOperation({ summary: 'Get shipping label URL for order' })
@ApiParam({ name: 'orderId', description: 'Order ID' })
@ApiResponse({ status: 200, description: 'Label URL retrieved' })
@ApiResponse({ status: 404, description: 'Shipment not found' })
async getLabelUrl(@Param('orderId') orderId: string) {
const labelUrl = await this.sendcloudService.getLabelUrl(orderId);
return { labelUrl };
}
@Post('order/:orderId/regenerate')
@HttpCode(HttpStatus.OK)
@UseGuards(ApiKeyGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Regenerate shipping label' })
@ApiParam({ name: 'orderId', description: 'Order ID' })
@ApiResponse({ status: 200, description: 'Label regenerated' })
async regenerateLabel(
@Param('orderId') orderId: string,
@Query('shippingMethodId') shippingMethodId?: string
) {
const shipment = await this.sendcloudService.regenerateLabel(
orderId,
shippingMethodId ? parseInt(shippingMethodId, 10) : undefined
);
return {
success: true,
shipment: {
id: shipment.id,
trackingNumber: shipment.trackingNumber,
trackingUrl: shipment.trackingUrl,
labelUrl: shipment.labelUrl,
},
};
}
@Post('order/:orderId/cancel')
@HttpCode(HttpStatus.OK)
@UseGuards(ApiKeyGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Cancel shipment' })
@ApiParam({ name: 'orderId', description: 'Order ID' })
@ApiResponse({ status: 200, description: 'Shipment cancelled' })
async cancelShipment(@Param('orderId') orderId: string) {
await this.sendcloudService.cancelShipment(orderId);
return { success: true, message: 'Shipment cancelled' };
}
@Get('status')
@ApiOperation({ summary: 'Check Sendcloud integration status' })
@ApiResponse({ status: 200, description: 'Integration status' })
async getStatus() {
return {
enabled: this.sendcloudClient.isShippingEnabled(),
};
}
}
🔧 Feature F5.3: Shipping Dashboard UI¶
Requirements Reference¶
- FR-AD-002: Order Detail View (with shipping)
- UI-001: Dashboard Layout
Implementation¶
1. Shipments Hook¶
Create apps/web/src/hooks/use-shipments.ts:
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../lib/api-client';
import { useAuth } from '../contexts/auth-context';
export interface Shipment {
id: string;
orderId: string;
sendcloudParcelId: number | null;
status: string;
shippingMethodId: number | null;
shippingMethodName: string | null;
carrierName: string | null;
trackingNumber: string | null;
trackingUrl: string | null;
labelUrl: string | null;
createdAt: string;
shippedAt: string | null;
}
export interface ShippingMethod {
id: number;
name: string;
carrier: string;
price: number;
}
export function useShipment(orderId: string) {
return useQuery<Shipment | null>({
queryKey: ['shipments', orderId],
queryFn: () => apiClient.getShipment(orderId),
enabled: !!orderId,
});
}
export function useShippingMethods(country = 'BE') {
return useQuery<ShippingMethod[]>({
queryKey: ['shipping-methods', country],
queryFn: () => apiClient.getShippingMethods(country),
});
}
export function useCreateLabel() {
const queryClient = useQueryClient();
const { apiKey } = useAuth();
return useMutation({
mutationFn: ({ orderId, shippingMethodId }: { orderId: string; shippingMethodId?: number }) =>
apiClient.createShippingLabel(orderId, shippingMethodId, apiKey),
onSuccess: (_, { orderId }) => {
queryClient.invalidateQueries({ queryKey: ['shipments', orderId] });
queryClient.invalidateQueries({ queryKey: ['orders', orderId] });
},
});
}
export function useRegenerateLabel() {
const queryClient = useQueryClient();
const { apiKey } = useAuth();
return useMutation({
mutationFn: ({ orderId, shippingMethodId }: { orderId: string; shippingMethodId?: number }) =>
apiClient.regenerateShippingLabel(orderId, shippingMethodId, apiKey),
onSuccess: (_, { orderId }) => {
queryClient.invalidateQueries({ queryKey: ['shipments', orderId] });
queryClient.invalidateQueries({ queryKey: ['orders', orderId] });
},
});
}
export function useCancelShipment() {
const queryClient = useQueryClient();
const { apiKey } = useAuth();
return useMutation({
mutationFn: (orderId: string) => apiClient.cancelShipment(orderId, apiKey),
onSuccess: (_, orderId) => {
queryClient.invalidateQueries({ queryKey: ['shipments', orderId] });
queryClient.invalidateQueries({ queryKey: ['orders', orderId] });
},
});
}
2. Shipping Info Component¶
Create apps/web/src/components/orders/shipping-info.tsx:
import { useState } from "react";
import {
TruckIcon,
ArrowDownTrayIcon,
ArrowPathIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
import toast from "react-hot-toast";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import {
useShipment,
useShippingMethods,
useCreateLabel,
useRegenerateLabel,
useCancelShipment,
} from "../../hooks/use-shipments";
interface ShippingInfoProps {
orderId: string;
orderStatus: string;
shippingAddress: Record<string, unknown>;
}
const SHIPMENT_STATUS_COLORS: Record<
string,
"default" | "success" | "warning" | "danger" | "info"
> = {
PENDING: "default",
LABEL_CREATED: "info",
ANNOUNCED: "info",
IN_TRANSIT: "warning",
DELIVERED: "success",
FAILED: "danger",
CANCELLED: "default",
};
export function ShippingInfo({
orderId,
orderStatus,
shippingAddress,
}: ShippingInfoProps) {
const [selectedMethod, setSelectedMethod] = useState<number | undefined>();
const { data: shipment, isLoading } = useShipment(orderId);
const { data: methods } = useShippingMethods(
(shippingAddress as { country_code?: string })?.country_code || "BE"
);
const createLabelMutation = useCreateLabel();
const regenerateMutation = useRegenerateLabel();
const cancelMutation = useCancelShipment();
const handleCreateLabel = async () => {
try {
await createLabelMutation.mutateAsync({
orderId,
shippingMethodId: selectedMethod,
});
toast.success("Shipping label created");
} catch (error) {
toast.error("Failed to create shipping label");
}
};
const handleRegenerateLabel = async () => {
if (
!window.confirm(
"Regenerate shipping label? The existing label will be cancelled."
)
) {
return;
}
try {
await regenerateMutation.mutateAsync({
orderId,
shippingMethodId: selectedMethod,
});
toast.success("Shipping label regenerated");
} catch (error) {
toast.error("Failed to regenerate label");
}
};
const handleCancelShipment = async () => {
if (!window.confirm("Cancel this shipment?")) return;
try {
await cancelMutation.mutateAsync(orderId);
toast.success("Shipment cancelled");
} catch (error) {
toast.error("Failed to cancel shipment");
}
};
const handleDownloadLabel = () => {
if (shipment?.labelUrl) {
window.open(shipment.labelUrl, "_blank");
}
};
if (isLoading) {
return (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<TruckIcon className="w-5 h-5" />
Shipping
</h2>
<p className="text-gray-500">Loading shipping information...</p>
</div>
);
}
return (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<TruckIcon className="w-5 h-5" />
Shipping
</h2>
{shipment ? (
<div className="space-y-4">
{/* Status */}
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Status</span>
<Badge
variant={SHIPMENT_STATUS_COLORS[shipment.status] || "default"}
>
{shipment.status.replace(/_/g, " ")}
</Badge>
</div>
{/* Carrier */}
{shipment.carrierName && (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Carrier</span>
<span className="text-sm font-medium">
{shipment.carrierName}
</span>
</div>
)}
{/* Shipping Method */}
{shipment.shippingMethodName && (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Method</span>
<span className="text-sm font-medium">
{shipment.shippingMethodName}
</span>
</div>
)}
{/* Tracking */}
{shipment.trackingNumber && (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Tracking</span>
{shipment.trackingUrl ? (
<a
href={shipment.trackingUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-blue-600 hover:underline"
>
{shipment.trackingNumber}
</a>
) : (
<span className="text-sm font-medium">
{shipment.trackingNumber}
</span>
)}
</div>
)}
{/* Actions */}
<div className="flex flex-wrap gap-2 pt-4 border-t">
{shipment.labelUrl && (
<Button
variant="secondary"
size="sm"
onClick={handleDownloadLabel}
>
<ArrowDownTrayIcon className="w-4 h-4 mr-1" />
Download Label
</Button>
)}
{shipment.status !== "CANCELLED" &&
shipment.status !== "DELIVERED" && (
<>
<Button
variant="ghost"
size="sm"
onClick={handleRegenerateLabel}
loading={regenerateMutation.isPending}
>
<ArrowPathIcon className="w-4 h-4 mr-1" />
Regenerate
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleCancelShipment}
loading={cancelMutation.isPending}
>
<XMarkIcon className="w-4 h-4 mr-1" />
Cancel
</Button>
</>
)}
</div>
</div>
) : (
<div className="space-y-4">
<p className="text-sm text-gray-500">
No shipping label created yet.
</p>
{/* Shipping Method Selection */}
{methods && methods.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Shipping Method
</label>
<select
value={selectedMethod || ""}
onChange={(e) =>
setSelectedMethod(
e.target.value ? parseInt(e.target.value, 10) : undefined
)
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="">Default method</option>
{methods.map((method) => (
<option key={method.id} value={method.id}>
{method.carrier} - {method.name} (€{method.price.toFixed(2)}
)
</option>
))}
</select>
</div>
)}
{/* Only show create button if order is ready */}
{(orderStatus === "COMPLETED" || orderStatus === "PROCESSING") && (
<Button
onClick={handleCreateLabel}
loading={createLabelMutation.isPending}
className="w-full"
>
<TruckIcon className="w-4 h-4 mr-2" />
Create Shipping Label
</Button>
)}
</div>
)}
</div>
);
}
3. Update Order Detail Page¶
Update apps/web/src/pages/orders/[id].tsx to include shipping section:
// Add import at the top
import { ShippingInfo } from "../../components/orders/shipping-info";
// In the JSX, add after the Actions Sidebar section:
{
/* Shipping Info */
}
<ShippingInfo
orderId={order.id}
orderStatus={order.status}
shippingAddress={order.shippingAddress as Record<string, unknown>}
/>;
4. Update API Client¶
Update apps/web/src/lib/api-client.ts to add shipping endpoints:
// Add to existing apiClient object:
// Shipping
getShipment: async (orderId: string) => {
const response = await fetch(`${API_BASE}/api/v1/shipments/order/${orderId}`);
if (response.status === 404) return null;
if (!response.ok) throw new Error('Failed to fetch shipment');
return response.json();
},
getShippingMethods: async (country: string) => {
const response = await fetch(`${API_BASE}/api/v1/shipping/methods?country=${country}`);
if (!response.ok) throw new Error('Failed to fetch shipping methods');
return response.json();
},
createShippingLabel: async (orderId: string, shippingMethodId?: number, apiKey?: string | null) => {
const url = new URL(`${API_BASE}/api/v1/shipping/order/${orderId}/label`);
if (shippingMethodId) url.searchParams.set('shippingMethodId', String(shippingMethodId));
const response = await fetch(url.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(apiKey ? { 'X-API-Key': apiKey } : {}),
},
});
if (!response.ok) throw new Error('Failed to create shipping label');
return response.json();
},
regenerateShippingLabel: async (orderId: string, shippingMethodId?: number, apiKey?: string | null) => {
const url = new URL(`${API_BASE}/api/v1/shipping/order/${orderId}/regenerate`);
if (shippingMethodId) url.searchParams.set('shippingMethodId', String(shippingMethodId));
const response = await fetch(url.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(apiKey ? { 'X-API-Key': apiKey } : {}),
},
});
if (!response.ok) throw new Error('Failed to regenerate label');
return response.json();
},
cancelShipment: async (orderId: string, apiKey?: string | null) => {
const response = await fetch(`${API_BASE}/api/v1/shipping/order/${orderId}/cancel`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(apiKey ? { 'X-API-Key': apiKey } : {}),
},
});
if (!response.ok) throw new Error('Failed to cancel shipment');
return response.json();
},
🧪 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 5 functionality
Unit Test Scenarios Required¶
| Category | Scenario | Priority |
|---|---|---|
| Sendcloud Client | Create parcel with valid data | Critical |
| Sendcloud Client | Handle API authentication | Critical |
| Sendcloud Client | Handle rate limiting | High |
| Sendcloud Client | Get shipping methods | High |
| Sendcloud Service | Create shipment on order ready event | Critical |
| Sendcloud Service | Skip if shipping disabled | High |
| Sendcloud Service | Handle shipment creation failure | Critical |
| Sendcloud Service | Regenerate label cancels existing | Medium |
| Shipments Repo | Create and retrieve shipment | High |
| Shipments Repo | Find by order ID | High |
| Shipping UI | Display shipment info | High |
| Shipping UI | Download label button works | Medium |
Acceptance Test Requirements (Playwright + Gherkin)¶
New Feature Files to Create¶
Create apps/acceptance-tests/src/features/shipping.feature:
@smoke @api
Feature: Shipping Integration
As an operator
I want orders to have shipping labels generated
So that packages can be shipped to customers
Background:
Given the staging API is available
@critical
Scenario: Shipping endpoint is accessible
When I request the shipping methods endpoint
Then the response status should be 200
And the response should contain shipping methods
Scenario: Check shipping integration status
When I request the shipping status endpoint
Then the response should contain "enabled" field
@web
Scenario: View shipping info in order detail
Given I am logged in to the dashboard
And an order exists with a shipment
When I navigate to the order detail page
Then I should see the shipping information section
And I should see a "Download Label" button if label exists
✅ Validation Checklist¶
Infrastructure¶
- All new modules compile without errors
-
pnpm nx build apisucceeds -
pnpm nx build websucceeds -
pnpm lintpasses on all new files - Prisma migration runs successfully
Sendcloud API Client (F5.1)¶
- API client authenticates correctly
- Create parcel works with valid data
- Get parcel retrieves parcel info
- Get label returns PDF URLs
- Cancel parcel works
- Get shipping methods works
- Error handling captures issues
- Unit tests passing
Shipping Label Generation (F5.2)¶
- Listens for
order.ready-for-fulfillmentevent - Creates shipment record in database
- Creates parcel in Sendcloud
- Stores tracking information
- Updates order with tracking
- Handles API errors with retry
- Regenerate label works
- Cancel shipment works
- Unit tests passing
Shipping Dashboard UI (F5.3)¶
- Shipment info displays in order detail
- Tracking number with link shown
- Download label button works
- Regenerate label button works
- Cancel shipment button works
- Shipping method selector shows options
- Create label button works
- Real-time updates work
Integration Tests¶
- End-to-end: Order complete → Shipment created
- End-to-end: Label regeneration
- End-to-end: Shipment cancellation
Acceptance Tests (Playwright + Gherkin)¶
- Feature files created for shipping
- Step definitions implemented
- Tests pass against staging
🚫 Constraints and Rules¶
MUST DO¶
- Use existing retry queue for failed shipment creation
- Log all shipping operations via EventLogService
- Verify order status before creating shipment
- Store tracking info in both Shipment and Order tables
- Handle disabled shipping gracefully
- Use Sendcloud API v2
- Write unit tests for all new services (> 80% coverage)
- Add acceptance tests (Playwright + Gherkin)
- Update ALL documentation (see Documentation Updates section)
MUST NOT¶
- Create labels for cancelled orders
- Skip error handling on Sendcloud API calls
- Store Sendcloud credentials in code
- Create duplicate parcels for same order
- Block fulfillment on shipping failures (use async events)
- Skip writing unit tests
- Leave documentation incomplete
🎬 Execution Order¶
Implementation¶
- Update Prisma schema with Shipment model
- Run migration for shipment table
- Create Sendcloud types in libs/api-client
- Create Sendcloud API client with authentication
- Create Shipments repository for database operations
- Create Sendcloud service with event listener
- Create Sendcloud controller with REST endpoints
- Create Sendcloud module and register in app
- Update Fulfillment service to work with shipping
- Create shipping hooks for frontend
- Create ShippingInfo component for order detail
- Update Order Detail page with shipping section
- Update API client with shipping endpoints
Testing¶
- Write unit tests for all new services (> 80% coverage)
- Write integration tests for shipping flow
- Add acceptance tests for shipping features
- Test with staging Sendcloud account
Documentation¶
- Update Swagger documentation — Add
@Api*decorators to all new endpoints - Update README.md — Add shipping integration section
- Update docs/04-development/implementation-plan.md — Mark Phase 5 features as complete
- Update docs/01-business/requirements.md — Mark FR-SC-001, FR-SC-002 as implemented
- Update docs/03-architecture/adr/ADR.md — Add ADR for Sendcloud integration
- Update C4 diagrams — Add Sendcloud component to architecture diagrams
Validation¶
- Run full validation checklist
- Verify acceptance tests pass in pipeline
- Confirm all documentation is complete
📊 Expected Output¶
When Phase 5 is complete:
Verification Commands¶
# Build all projects
pnpm nx build api
pnpm nx build web
# Run tests
pnpm nx test api
pnpm nx test web
# Start development servers
pnpm dev
# Create shipping label via API
curl -X POST http://localhost:3000/api/v1/shipping/order/{orderId}/label \
-H "X-API-Key: your-api-key"
# Get shipping methods
curl http://localhost:3000/api/v1/shipping/methods?country=BE
Complete Automation Flow¶
1. Shopify Order Created (webhook)
↓
2. Order Stored in Database
↓
3. Print Jobs Created in SimplyPrint
↓
4. Print Job Status Updates (webhook/polling)
↓
5. All Print Jobs Complete
↓
6. order.ready-for-fulfillment Event
↓
7. Shipping Label Created (Sendcloud) ← NEW
↓
8. Fulfillment Created in Shopify (with tracking)
↓
9. Customer Receives Shipping Notification with Tracking
📝 Documentation Updates¶
CRITICAL: All documentation must be updated to reflect Phase 5 completion.
README.md Updates Required¶
Add sections for:
- Shipping Integration — How Sendcloud integration works
- Shipping Label Flow — Automatic and manual label creation
- Configuration — Sendcloud environment variables
- Shipping Methods — How shipping methods are selected
docs/04-development/implementation-plan.md Updates Required¶
Update the implementation plan to mark Phase 5 as complete:
- Mark F5.1 (Sendcloud API Client) as ✅ Completed
- Mark F5.2 (Shipping Label Generation) as ✅ Completed
- Mark F5.3 (Shipping Dashboard UI) as ✅ Completed
- Update Phase 5 Exit Criteria with checkmarks
- Add implementation notes and component paths
- Update revision history with completion date
docs/01-business/requirements.md Updates Required¶
Update requirements document to mark Phase 5 requirements as implemented:
- Mark FR-SC-001 (Generate Shipping Labels) as ✅ Implemented
- Mark FR-SC-002 (Tracking Information Sync) as ✅ Implemented
- Update revision history
docs/03-architecture/adr/ADR.md Updates Required¶
Add new ADR:
- ADR-030: Sendcloud Integration for Shipping
Example ADR template:
## ADR-030: Sendcloud Integration for Shipping
| Attribute | Value |
| ----------- | ----------------------------------------------------------- |
| **ID** | ADR-030 |
| **Status** | Accepted |
| **Date** | 2026-01-XX |
| **Context** | Need to generate shipping labels and track package delivery |
### Decision
Use **Sendcloud** as the shipping integration platform.
### Rationale
- Multi-carrier support (PostNL, DPD, DHL, etc.)
- European focus matching Forma3D market
- Simple REST API with label generation
- Automatic tracking updates
- Webhook support for status changes
- Reasonable pricing for small businesses
### Consequences
- ✅ Single integration for multiple carriers
- ✅ Automatic label PDF generation
- ✅ Tracking information synced to Shopify
- ⚠️ Dependent on Sendcloud uptime
- ⚠️ Limited to carriers supported by Sendcloud
docs/03-architecture/c4-model Diagram Updates¶
Update the following PlantUML diagrams:
| Diagram | Path | Updates Required |
|---|---|---|
| Container | docs/03-architecture/c4-model/2-container/C4_Container.puml |
Add Sendcloud as external system |
| Component | docs/03-architecture/c4-model/3-component/C4_Component.puml |
Add SendcloudModule, ShipmentsModule components |
| Domain Model | docs/03-architecture/c4-model/4-code/C4_Code_DomainModel.puml |
Add Shipment entity |
docs/03-architecture/sequences Updates¶
Consider adding:
-
C4_Seq_08_ShippingLabel.puml— Shipping label creation sequence
🔗 Phase 5 Exit Criteria¶
From implementation-plan.md:
- Shipping labels generated automatically
- Tracking synced to Shopify
- Labels downloadable from dashboard
- Complete flow: Order → Print → Label → Fulfill
Additional Exit Criteria¶
- Unit tests > 80% coverage for all new code
- Integration tests passing
- Acceptance tests added for Phase 5 functionality
- All acceptance tests passing against staging
- README.md updated with shipping section
- docs/04-development/implementation-plan.md updated — Phase 5 marked as complete
- docs/01-business/requirements.md updated — Phase 5 requirements marked as implemented
- docs/03-architecture/adr/ADR.md updated — ADR-030 added for Sendcloud
- C4 diagrams updated — Sendcloud and Shipment added
- Swagger documentation complete for all new endpoints
🔮 Phase 6 Preview¶
Phase 6 (Hardening) will focus on production readiness:
- Comprehensive testing (80%+ coverage)
- Performance optimization
- Security hardening
- Documentation completion
- Load testing (500+ orders/day)
- Monitoring and alerting setup
The shipping integration established in Phase 5 will be tested under load and optimized for production reliability.
END OF PROMPT
This prompt builds on the Phase 4 foundation. The AI should implement all Phase 5 shipping integration features while maintaining the established code style, architectural patterns, and testing standards. Phase 5 completes the full automation loop from order to delivery.