Order wizard was skipping steps (jumping to add-ons) due to stale currentStep persisting in localStorage from previous orders. Reset store on plan selection and exclude currentStep from persistence. Also add max(11) validation on MNP phone number to prevent Salesforce STRING_TOO_LONG errors. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@customer-portal/domain
Single Source of Truth for Types and Validation
The @customer-portal/domain package is the centralized domain layer containing all types, Zod validation schemas, and provider-specific adapters for the customer portal application.
📦 Package Structure
packages/domain/
├── common/ # Shared types and utilities
│ ├── types.ts # Common types, API responses, pagination
│ ├── schema.ts # Zod schemas for validation
│ └── index.ts
│
├── auth/ # Authentication & authorization
│ ├── contract.ts # User, AuthTokens, AuthResponse types
│ ├── schema.ts # Login, signup, password validation
│ └── index.ts
│
├── billing/ # Invoices and billing
│ ├── contract.ts # Invoice, InvoiceItem, InvoiceList
│ ├── schema.ts # Zod schemas + query params
│ ├── providers/whmcs/ # WHMCS adapter
│ └── index.ts
│
├── subscriptions/ # Service subscriptions
│ ├── contract.ts # Subscription, SubscriptionStatus
│ ├── schema.ts # Zod schemas + query params
│ ├── providers/whmcs/ # WHMCS adapter
│ └── index.ts
│
├── orders/ # Order management
│ ├── contract.ts # OrderSummary, OrderDetails
│ ├── schema.ts # Zod schemas + query params
│ ├── providers/
│ │ ├── salesforce/ # Read orders from Salesforce
│ │ └── whmcs/ # Create orders in WHMCS
│ └── index.ts
│
├── payments/ # Payment methods & gateways
│ ├── contract.ts # PaymentMethod, PaymentGateway
│ ├── schema.ts # Zod validation schemas
│ └── index.ts
│
├── sim/ # SIM card management
│ ├── contract.ts # SimDetails, SimUsage
│ ├── schema.ts # Zod schemas + activation
│ ├── providers/freebit/ # Freebit adapter
│ └── index.ts
│
├── catalog/ # Product catalog
│ ├── contract.ts # CatalogProduct types
│ ├── schema.ts # Product validation
│ └── index.ts
│
├── customer/ # Customer data
│ ├── contract.ts # Customer, CustomerAddress
│ ├── schema.ts # Customer validation
│ └── index.ts
│
└── toolkit/ # Utilities
├── formatting/ # Currency, date formatting
├── validation/ # Validation helpers
└── index.ts
🎯 Design Principles
1. Domain-First Organization
Each business domain owns its:
contract.ts- TypeScript interfaces (provider-agnostic)schema.ts- Zod validation schemas (runtime safety)providers/- Provider-specific adapters (WHMCS, Salesforce, Freebit)
2. Single Source of Truth
- ✅ All types defined in domain package
- ✅ All validation schemas in domain package
- ✅ No duplicate type definitions in apps
- ✅ Shared between frontend (Next.js) and backend (NestJS)
3. Type Safety + Runtime Validation
- TypeScript provides compile-time type checking
- Zod schemas provide runtime validation
- Use
z.infer<typeof schema>to derive types from schemas
📚 Usage Guide
Basic Import Pattern
// Import domain types and schemas
import { Invoice, invoiceSchema, InvoiceQueryParams } from "@customer-portal/domain/billing";
import { Subscription, subscriptionSchema } from "@customer-portal/domain/subscriptions";
import { ApiResponse, PaginationParams } from "@customer-portal/domain/common";
API Response Handling
import {
ApiResponse,
ApiSuccessResponse,
ApiErrorResponse,
apiResponseSchema,
} from "@customer-portal/domain/common";
// Type-safe API responses
const response: ApiResponse<Invoice> = {
success: true,
data: {
/* invoice data */
},
};
// With validation
const validated = apiResponseSchema(invoiceSchema).parse(rawResponse);
Query Parameters with Validation
import {
InvoiceQueryParams,
invoiceQueryParamsSchema
} from "@customer-portal/domain/billing";
// In BFF controller
@Get()
@UsePipes(new ZodValidationPipe(invoiceQueryParamsSchema))
async getInvoices(@Query() query: InvoiceQueryParams) {
// query is validated and typed
}
// In frontend
const params: InvoiceQueryParams = {
page: 1,
limit: 20,
status: "Unpaid"
};
Form Validation (Frontend)
import { useZodForm } from "@customer-portal/validation";
import { loginRequestSchema, type LoginRequest } from "@customer-portal/domain/auth";
function LoginForm() {
const form = useZodForm<LoginRequest>({
schema: loginRequestSchema,
initialValues: { email: "", password: "" },
onSubmit: async (data) => {
// data is validated and typed
await login(data);
},
});
return (
<form onSubmit={form.handleSubmit}>
{/* form fields */}
</form>
);
}
Backend Validation (BFF)
import { ZodValidationPipe } from "@bff/core/validation";
import { createOrderRequestSchema, type CreateOrderRequest } from "@customer-portal/domain/orders";
@Controller("orders")
export class OrdersController {
@Post()
@UsePipes(new ZodValidationPipe(createOrderRequestSchema))
async create(@Body() body: CreateOrderRequest) {
// body is validated by Zod before reaching here
return this.orderService.create(body);
}
}
🔧 Common Schemas Reference
API Responses
| Schema | Description |
|---|---|
apiSuccessResponseSchema(dataSchema) |
Successful API response wrapper |
apiErrorResponseSchema |
Error API response with code/message |
apiResponseSchema(dataSchema) |
Discriminated union of success/error |
Pagination & Queries
| Schema | Description |
|---|---|
paginationParamsSchema |
Page, limit, offset parameters |
paginatedResponseSchema(itemSchema) |
Paginated list response |
filterParamsSchema |
Search, sortBy, sortOrder |
queryParamsSchema |
Combined pagination + filters |
Domain-Specific Query Params
| Schema | Description |
|---|---|
invoiceQueryParamsSchema |
Invoice list filtering (status, dates) |
subscriptionQueryParamsSchema |
Subscription filtering (status, type) |
orderQueryParamsSchema |
Order filtering (status, orderType) |
Validation Primitives
| Schema | Description |
|---|---|
emailSchema |
Email validation (lowercase, trimmed) |
passwordSchema |
Strong password (8+ chars, mixed case, number, special) |
nameSchema |
Name validation (1-100 chars) |
phoneSchema |
Phone number validation |
timestampSchema |
ISO datetime string |
dateSchema |
ISO date string |
🚀 Adding New Domain Types
1. Create Domain Files
// packages/domain/my-domain/contract.ts
export interface MyEntity {
id: string;
name: string;
status: "active" | "inactive";
}
export interface MyEntityList {
entities: MyEntity[];
totalCount: number;
}
// packages/domain/my-domain/schema.ts
import { z } from "zod";
export const myEntityStatusSchema = z.enum(["active", "inactive"]);
export const myEntitySchema = z.object({
id: z.string(),
name: z.string().min(1),
status: myEntityStatusSchema,
});
export const myEntityListSchema = z.object({
entities: z.array(myEntitySchema),
totalCount: z.number().int().nonnegative(),
});
// Query params
export const myEntityQueryParamsSchema = z.object({
page: z.coerce.number().int().positive().optional(),
limit: z.coerce.number().int().positive().max(100).optional(),
status: myEntityStatusSchema.optional(),
});
export type MyEntityQueryParams = z.infer<typeof myEntityQueryParamsSchema>;
// packages/domain/my-domain/index.ts
export * from "./contract";
export * from "./schema";
2. Use in Backend (BFF)
import { ZodValidationPipe } from "@bff/core/validation";
import {
myEntitySchema,
myEntityQueryParamsSchema,
type MyEntity,
type MyEntityQueryParams,
} from "@customer-portal/domain/my-domain";
@Controller("my-entities")
export class MyEntitiesController {
@Get()
@UsePipes(new ZodValidationPipe(myEntityQueryParamsSchema))
async list(@Query() query: MyEntityQueryParams): Promise<MyEntityList> {
return this.service.list(query);
}
}
3. Use in Frontend
import { useQuery } from "@tanstack/react-query";
import { myEntitySchema, type MyEntity } from "@customer-portal/domain/my-domain";
function useMyEntities() {
return useQuery({
queryKey: ["my-entities"],
queryFn: async () => {
const response = await apiClient.get("/my-entities");
return myEntitySchema.array().parse(response.data);
},
});
}
✅ Validation Best Practices
1. Always Define Both Type and Schema
// ✅ Good - Type and schema together
export const userSchema = z.object({
id: z.string(),
email: emailSchema,
});
export type User = z.infer<typeof userSchema>;
// ❌ Bad - Type only (no runtime validation)
export interface User {
id: string;
email: string;
}
2. Use Zod Schema Composition
// Base schema
const baseProductSchema = z.object({
id: z.string(),
name: z.string(),
});
// Extended schema
export const fullProductSchema = baseProductSchema.extend({
description: z.string(),
price: z.number().positive(),
});
3. Query Params Use z.coerce for URL Strings
// ✅ Good - coerce string params to numbers
export const paginationSchema = z.object({
page: z.coerce.number().int().positive().optional(),
limit: z.coerce.number().int().positive().optional(),
});
// ❌ Bad - will fail on URL query strings
export const paginationSchema = z.object({
page: z.number().int().positive().optional(), // "1" !== 1
});
4. Use Refinements for Complex Validation
export const simActivationSchema = z
.object({
simType: z.enum(["eSIM", "Physical SIM"]),
eid: z.string().optional(),
})
.refine(data => data.simType !== "eSIM" || (data.eid && data.eid.length >= 15), {
message: "EID required for eSIM",
path: ["eid"],
});
🔄 Migration from Local Types
If you find types defined locally in apps, migrate them to domain:
// ❌ Before: apps/bff/src/modules/invoices/types.ts
export interface InvoiceQuery {
status?: string;
page?: number;
}
// ✅ After: packages/domain/billing/schema.ts
export const invoiceQueryParamsSchema = z.object({
status: invoiceStatusSchema.optional(),
page: z.coerce.number().int().positive().optional(),
});
export type InvoiceQueryParams = z.infer<typeof invoiceQueryParamsSchema>;
// Update imports
import { InvoiceQueryParams } from "@customer-portal/domain/billing";
📖 Additional Resources
- Zod Documentation: https://zod.dev/
- Provider-Aware Architecture: See
docs/DOMAIN-STRUCTURE.md - Type System: See
docs/CONSOLIDATED-TYPE-SYSTEM.md
🤝 Contributing
When adding new types or schemas:
- ✅ Define types in
contract.ts - ✅ Add Zod schemas in
schema.ts - ✅ Export from
index.ts - ✅ Update this README if adding new patterns
- ✅ Remove any duplicate types from apps
- ✅ Update imports to use domain package
Maintained by: Customer Portal Team
Last Updated: October 2025