barsa ff9ee10860 Merge main into alt-design
Resolved merge conflicts between main and alt-design branches.

Key decisions:
- BFF: Adopted SIM-first workflow from main (PA05-18 → PA02-01 → PA05-05 → WHMCS)
- BFF: Kept FreebitFacade pattern, added new services (AccountRegistration, VoiceOptions, SemiBlack)
- BFF: Fixed freebit-usage.service.ts bug (quotaKb → quotaMb)
- BFF: Merged rate limiting + HTTP status parsing in WHMCS error handler
- Portal: Took main's UI implementations
- Deleted: TV page, SignupForm, ServicesGrid (as per main)
- Added whmcsRegistrationUrl to field-maps.ts (was missing after file consolidation)

TODO post-merge:
- Refactor order-fulfillment-orchestrator.service.ts to use buildTransactionSteps abstraction
- Fix ESLint errors from main's code (skipped pre-commit for merge)
2026-02-03 16:12:05 +09:00
..
2026-02-03 16:12:05 +09:00
2026-02-03 16:12:05 +09:00

@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:

  1. Define types in contract.ts
  2. Add Zod schemas in schema.ts
  3. Export from index.ts
  4. Update this README if adding new patterns
  5. Remove any duplicate types from apps
  6. Update imports to use domain package

Maintained by: Customer Portal Team
Last Updated: October 2025