# Validation Patterns Guide

## Overview

This guide establishes consistent validation patterns across the customer portal codebase. Follow these patterns to ensure maintainable, DRY (Don't Repeat Yourself) validation logic.

## Core Principles

1. **Schema-First Validation**: Define validation rules in Zod schemas, not in imperative code
2. **Single Source of Truth**: Each validation rule should be defined once and reused
3. **Layer Separation**: Keep format validation in schemas, business logic in validation functions
4. **No Duplicate Logic**: Never duplicate validation between schemas and manual checks

## Architecture

### Domain Layer (`packages/domain/`)

Each domain should have:

- **`schema.ts`**: Zod schemas for runtime type validation
- **`validation.ts`**: Business validation functions (using schemas internally)
- **`contract.ts`**: TypeScript types (inferred from schemas)

#### Example Structure:

```
packages/domain/orders/
├── schema.ts          # Zod schemas (format validation)
├── validation.ts      # Business validation functions
├── contract.ts        # TypeScript types
└── index.ts          # Public exports
```

### BFF Layer (`apps/bff/src/`)

- **Controllers**: Use `@UsePipes(ZodValidationPipe)` for request validation
- **Services**: Call domain validation functions (not schemas directly)
- **Validators**: Infrastructure-dependent validation only (DB checks, API calls)

## Validation Patterns

### Pattern 1: Controller Input Validation

**✅ DO: Use ZodValidationPipe**

```typescript
import { Controller, Post, Body, UsePipes } from "@nestjs/common";
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) {
    return this.orderService.createOrder(body);
  }
}
```

**❌ DON'T: Validate manually in controllers**

```typescript
// Bad - manual validation in controller
@Post()
async create(@Body() body: any) {
  if (!body.orderType || typeof body.orderType !== "string") {
    throw new BadRequestException("Invalid order type");
  }
  // ...
}
```

### Pattern 2: Schema Definition

**✅ DO: Define schemas in domain layer**

```typescript
// packages/domain/orders/schema.ts
import { z } from "zod";

export const createOrderRequestSchema = z.object({
  orderType: z.enum(["Internet", "SIM", "VPN"]),
  skus: z.array(z.string().min(1)),
  configurations: z.object({
    activationType: z.enum(["Immediate", "Scheduled"]).optional(),
  }).optional(),
});

export type CreateOrderRequest = z.infer<typeof createOrderRequestSchema>;
```

**❌ DON'T: Define validation logic in multiple places**

```typescript
// Bad - validation in service duplicates schema
async createOrder(data: any) {
  if (!data.orderType || !["Internet", "SIM", "VPN"].includes(data.orderType)) {
    throw new Error("Invalid order type");
  }
  // This duplicates the schema validation!
}
```

### Pattern 3: Business Validation Functions

**✅ DO: Use helper functions with schema refinements**

```typescript
// packages/domain/orders/validation.ts

// Helper function (reusable)
export function hasSimServicePlan(skus: string[]): boolean {
  return skus.some(sku => 
    sku.toUpperCase().includes("SIM") && 
    !sku.toUpperCase().includes("ACTIVATION")
  );
}

// Schema uses helper function (DRY)
export const orderWithSkuValidationSchema = baseOrderSchema
  .refine(
    (data) => data.orderType !== "SIM" || hasSimServicePlan(data.skus),
    { message: "SIM orders must include a service plan", path: ["skus"] }
  );
```

**❌ DON'T: Duplicate logic in refinements**

```typescript
// Bad - logic duplicated between helper and schema
export function hasSimServicePlan(skus: string[]): boolean {
  return skus.some(sku => sku.includes("SIM"));
}

const schema = baseSchema.refine((data) => {
  // Duplicates the helper function logic!
  if (data.orderType === "SIM") {
    return data.skus.some(sku => sku.includes("SIM"));
  }
  return true;
});
```

### Pattern 4: Sanitization

**✅ DO: Use schema transforms for sanitization**

```typescript
// Good - sanitization in schema
export const emailSchema = z
  .string()
  .email()
  .toLowerCase()
  .trim();
```

**✅ DO: Separate sanitization functions (if needed)**

```typescript
// Good - pure sanitization (no validation)
export function sanitizeCreateRequest(request: CreateMappingRequest): CreateMappingRequest {
  return {
    userId: request.userId?.trim(),
    whmcsClientId: request.whmcsClientId,
    sfAccountId: request.sfAccountId?.trim() || undefined,
  };
}
```

**❌ DON'T: Mix sanitization with validation**

```typescript
// Bad - mixing sanitization and validation
export function sanitizeAndValidate(request: any) {
  const sanitized = { userId: request.userId?.trim() };
  
  // Validation mixed with sanitization
  if (!sanitized.userId || sanitized.userId.length === 0) {
    throw new Error("Invalid userId");
  }
  
  return sanitized;
}
```

### Pattern 5: Validation Wrapper Functions

**✅ DO: Keep wrappers simple and documented**

```typescript
/**
 * Validate and normalize email address
 * 
 * This is a convenience wrapper that throws on invalid input.
 * For validation without throwing, use emailSchema.safeParse()
 * 
 * @throws Error if email format is invalid
 */
export function normalizeAndValidateEmail(email: string): string {
  const emailValidationSchema = z.string().email().transform(e => e.toLowerCase().trim());
  return emailValidationSchema.parse(email);
}
```

**❌ DON'T: Use safeParse + manual error handling**

```typescript
// Bad - unnecessary complexity
export function normalizeAndValidateEmail(email: string): string {
  const result = emailSchema.safeParse(email);
  if (!result.success) {
    throw new Error("Invalid email");
  }
  return result.data;
}

// Better - just use .parse()
export function normalizeAndValidateEmail(email: string): string {
  return emailSchema.parse(email);
}
```

### Pattern 6: Infrastructure Validation

**✅ DO: Keep infrastructure checks in BFF services**

```typescript
// Good - infrastructure validation in BFF
@Injectable()
export class OrderValidator {
  async validateUserMapping(userId: string) {
    const mapping = await this.mappings.findByUserId(userId);
    if (!mapping) {
      throw new BadRequestException("User mapping required");
    }
    return mapping;
  }
}
```

**✅ DO: Use schemas for type/format validation even in services**

```typescript
// Good - use schema for format validation
const salesforceAccountIdSchema = z.string().min(1);

async validateOrder(order: SalesforceOrder) {
  const accountId = salesforceAccountIdSchema.parse(order.AccountId);
  // Continue with business logic...
}
```

**❌ DON'T: Use manual type checks**

```typescript
// Bad - manual type checking
if (typeof order.AccountId !== "string" || order.AccountId.length === 0) {
  throw new Error("Invalid AccountId");
}
```

## Common Anti-Patterns

### Anti-Pattern 1: Duplicate Password Validation

**❌ DON'T:**

```typescript
// Schema defines password rules
const passwordSchema = z.string()
  .min(8)
  .regex(/[A-Z]/, "Must contain uppercase")
  .regex(/[a-z]/, "Must contain lowercase");

// Service duplicates the same rules!
function validatePassword(password: string) {
  if (password.length < 8 || !/[A-Z]/.test(password) || !/[a-z]/.test(password)) {
    throw new Error("Invalid password");
  }
}
```

**✅ DO:**

```typescript
// Schema defines rules once
const passwordSchema = z.string()
  .min(8)
  .regex(/[A-Z]/, "Must contain uppercase")
  .regex(/[a-z]/, "Must contain lowercase");

// Service uses the schema
function validatePassword(password: string) {
  return passwordSchema.parse(password);
}
```

### Anti-Pattern 2: Manual String Matching for Validation

**❌ DON'T** (for input validation):

```typescript
// Bad - manual string checks for validation
function validateOrderType(type: any) {
  if (typeof type !== "string") return false;
  if (!["Internet", "SIM", "VPN"].includes(type)) return false;
  return true;
}
```

**✅ DO:**

```typescript
// Good - use Zod enum
const orderTypeSchema = z.enum(["Internet", "SIM", "VPN"]);
```

**✅ OKAY** (for business logic with external data):

```typescript
// This is fine - checking WHMCS API response data
const hasInternet = products.some(p => 
  p.groupname.toLowerCase().includes("internet")
);
```

### Anti-Pattern 3: Validation in Multiple Layers

**❌ DON'T:**

```typescript
// Controller validates
@Post()
async create(@Body() body: any) {
  if (!body.email) throw new BadRequestException("Email required");
  
  // Service validates again
  await this.service.create(body);
}

// Service
async create(data: any) {
  if (!data.email || !data.email.includes("@")) {
    throw new Error("Invalid email");
  }
}
```

**✅ DO:**

```typescript
// Schema validates once
const createUserSchema = z.object({
  email: z.string().email(),
});

// Controller uses schema
@Post()
@UsePipes(new ZodValidationPipe(createUserSchema))
async create(@Body() body: CreateUserRequest) {
  return this.service.create(body);
}

// Service trusts the validated input
async create(data: CreateUserRequest) {
  // No validation needed - input is already validated
  return this.repository.save(data);
}
```

## Pagination Schemas

The codebase provides two approaches for pagination validation:

### Standard Pagination: `paginationParamsSchema`

Use this for most cases where you need standard pagination with fixed defaults.

**Location**: `packages/domain/common/schema.ts`

```typescript
import { paginationParamsSchema } from "@customer-portal/domain/common";

// Standard pagination with defaults: page=1, limit=20, max=100
export const orderQueryParamsSchema = z.object({
  status: z.string().optional(),
  orderType: z.string().optional(),
}).merge(paginationParamsSchema);
```

**Defaults**:
- `page`: 1
- `limit`: 20
- Maximum limit: 100

**Use cases**:
- Standard API endpoints (orders, invoices, subscriptions)
- When you want consistent pagination across endpoints
- When default limits are appropriate for your data

### Custom Pagination: `createPaginationSchema()`

Use this when you need different limits or defaults for specific endpoints.

**Location**: `packages/domain/toolkit/validation/helpers.ts`

```typescript
import { createPaginationSchema } from "@customer-portal/domain/toolkit/validation";

// Custom pagination for large datasets
const customPaginationSchema = createPaginationSchema({
  minLimit: 5,
  maxLimit: 500,
  defaultLimit: 50,
});

export const bulkOrderQuerySchema = z.object({
  status: z.string().optional(),
}).merge(customPaginationSchema);
```

**Configuration options**:
- `minLimit`: Minimum records per page (default: 1)
- `maxLimit`: Maximum records per page (default: 100)
- `defaultLimit`: Default records if not specified (default: 10)

**Use cases**:
- Admin endpoints with larger datasets
- Bulk operations requiring different limits
- Special cases where 20/page doesn't make sense

### Decision Guide

```
Do you need custom pagination limits?
│
├─ NO  → Use paginationParamsSchema
│        ✅ Simple, consistent
│        ✅ Standard defaults
│
└─ YES → Use createPaginationSchema()
         ✅ Configurable limits
         ✅ Flexible defaults
```

### Example: Orders API

```typescript
// Standard orders list (20 per page by default)
@Get()
@UsePipes(new ZodValidationPipe(orderQueryParamsSchema))
async getOrders(@Query() query: OrderQuery) {
  // query.page defaults to 1
  // query.limit defaults to 20
  // query.limit max is 100
}

// Admin bulk orders (50 per page, up to 500)
const adminOrderQuerySchema = z.object({
  status: z.string().optional(),
}).merge(createPaginationSchema({
  minLimit: 10,
  maxLimit: 500,
  defaultLimit: 50,
}));

@Get("admin/orders")
@UsePipes(new ZodValidationPipe(adminOrderQuerySchema))
async getAdminOrders(@Query() query: AdminOrderQuery) {
  // query.page defaults to 1
  // query.limit defaults to 50
  // query.limit max is 500
}
```

## Migration Guide

When refactoring existing validation code:

1. **Identify Duplicate Logic**: Find manual validation that duplicates schemas
2. **Move to Domain Layer**: Define schemas in `packages/domain/*/schema.ts`
3. **Create Helper Functions**: Extract reusable business logic to `validation.ts`
4. **Update Services**: Replace manual checks with schema/function calls
5. **Update Controllers**: Add `@UsePipes(ZodValidationPipe)` decorators
6. **Remove Duplicate Code**: Delete manual validation that duplicates schemas
7. **Add Documentation**: Document why each validation exists

## Testing Validation

```typescript
import { describe, it, expect } from "vitest";
import { orderTypeSchema } from "./schema";

describe("Order Type Validation", () => {
  it("should accept valid order types", () => {
    expect(orderTypeSchema.parse("Internet")).toBe("Internet");
    expect(orderTypeSchema.parse("SIM")).toBe("SIM");
  });

  it("should reject invalid order types", () => {
    expect(() => orderTypeSchema.parse("Invalid")).toThrow();
    expect(() => orderTypeSchema.parse(null)).toThrow();
  });
});
```

## Summary

- **Use schemas for format validation**
- **Use functions for business validation**
- **Never duplicate validation logic**
- **Validate once at the entry point**
- **Trust validated data downstream**
- **Keep infrastructure validation separate**

Following these patterns ensures maintainable, consistent validation across the entire codebase.

