# Validation Duplication Report: BFF vs Portal **Date**: October 8, 2025 **Scope**: Cross-layer validation type duplication analysis --- ## Executive Summary After fixing frontend form duplication with domain schemas, I found **additional type duplication** between the portal and domain layers. The portal is defining its own request types instead of importing validated types from the domain layer. **Status**: 🔴 Duplication Found **Impact**: Medium - Types work but lack validation, could diverge over time **Effort to Fix**: Low (1-2 hours) --- ## Duplication Found ### Issue #1: SIM Action Request Types Duplicated **Location**: `apps/portal/src/features/subscriptions/services/sim-actions.service.ts` **Portal defines** (lines 3-15): ```typescript export interface TopUpRequest { quotaMb: number; } export interface ChangePlanRequest { newPlanCode: string; assignGlobalIp: boolean; scheduledAt?: string; } export interface CancelRequest { scheduledAt: string; } ``` **Domain already has** (`packages/domain/sim/schema.ts`): ```typescript export const simTopUpRequestSchema = z.object({ quotaMb: z .number() .int() .min(100, "Quota must be at least 100MB") .max(51200, "Quota must be 50GB or less"), }); export const simPlanChangeRequestSchema = z.object({ newPlanCode: z.string().min(1, "New plan code is required"), assignGlobalIp: z.boolean().optional(), scheduledAt: z .string() .regex(/^\d{8}$/, "Scheduled date must be in YYYYMMDD format") .optional(), }); export const simCancelRequestSchema = z.object({ scheduledAt: z .string() .regex(/^\d{8}$/, "Scheduled date must be in YYYYMMDD format") .optional(), }); // Exported types export type SimTopUpRequest = z.infer; export type SimPlanChangeRequest = z.infer; export type SimCancelRequest = z.infer; ``` **Problems**: 1. ❌ Portal types lack validation (no min/max for quotaMb, no format validation for scheduledAt) 2. ❌ Portal types use different names (TopUpRequest vs SimTopUpRequest) 3. ❌ Field types differ (assignGlobalIp is required in portal but optional in domain) 4. ❌ No single source of truth - types could diverge over time 5. ❌ Portal doesn't benefit from Zod validation rules **Impact**: - Medium priority - The portal service works, but requests aren't validated client-side - Data could be sent in wrong format (e.g., scheduledAt as "2024-10-08" instead of "20241008") - No runtime type checking before API calls --- ### Issue #2: SIM Configuration Schema Missing **Location**: `apps/portal/src/features/catalog/hooks/useSimConfigure.ts` (lines 7-14) **Portal attempts to import**: ```typescript import { simConfigureFormSchema, // ❌ Does not exist simConfigureFormToRequest, // ❌ Does not exist type SimConfigureFormData, // ❌ Does not exist type SimType, // ✅ Exists in domain/sim type ActivationType, // ❌ Does not exist in billing type MnpData, // ❌ Does not exist in billing } from "@customer-portal/domain/billing"; // ❌ Wrong domain ``` **Problems**: 1. ❌ Imports from wrong domain (billing instead of sim/orders) 2. ❌ Schemas don't exist yet in any domain 3. ❌ This is likely causing TypeScript errors **Impact**: - High priority - Broken imports - SIM configuration flow may not be working correctly - Need to create proper schemas in domain layer --- ## BFF Layer Analysis ### ✅ BFF Correctly Uses Domain Schemas The BFF layer is **following the correct pattern**: **Example 1: Auth Controller** ```typescript // apps/bff/src/modules/auth/presentation/http/auth.controller.ts import { signupRequestSchema, passwordResetRequestSchema, passwordResetSchema, setPasswordRequestSchema, linkWhmcsRequestSchema, // ... all from domain } from "@customer-portal/domain/auth"; @UsePipes(new ZodValidationPipe(signupRequestSchema)) async signup(@Body() body: SignupRequest) { // ... } ``` **Example 2: Orders Controller** ```typescript // apps/bff/src/modules/orders/orders.controller.ts import { createOrderRequestSchema, type CreateOrderRequest, } from "@customer-portal/domain/orders"; @UsePipes(new ZodValidationPipe(createOrderRequestSchema)) async create(@Body() body: CreateOrderRequest) { // ... } ``` **Example 3: Subscriptions Controller** ```typescript // apps/bff/src/modules/subscriptions/subscriptions.controller.ts import { simTopupRequestSchema, simChangePlanRequestSchema, simCancelRequestSchema, simFeaturesRequestSchema, type SimTopupRequest, type SimChangePlanRequest, type SimCancelRequest, type SimFeaturesRequest, } from "@customer-portal/domain/sim"; @UsePipes(new ZodValidationPipe(simTopupRequestSchema)) async topUp(@Body() body: SimTopupRequest) { // ... } ``` **Why BFF is correct**: - ✅ Imports validation schemas from domain - ✅ Uses ZodValidationPipe for runtime validation - ✅ Uses domain types for type safety - ✅ No duplicate type definitions - ✅ Single source of truth --- ## Portal Layer Issues ### ❌ Portal Sometimes Bypasses Domain Types The portal defines its own types for API calls instead of importing from domain: **Problem Pattern**: ```typescript // Portal defines its own simple types export interface TopUpRequest { quotaMb: number; } // But domain has validated types export type SimTopUpRequest = z.infer; // Portal should be using domain types import type { SimTopUpRequest } from "@customer-portal/domain/sim"; ``` **Why this is problematic**: 1. No client-side validation before API calls 2. Types could diverge from backend expectations 3. Harder to maintain consistency 4. Duplicates type definitions 5. Loses validation rules (min/max, regex patterns, etc.) --- ## Comparison: What's Working vs. What's Not ### ✅ Good Examples (Portal correctly uses domain) ```typescript // apps/portal/src/features/orders/services/orders.service.ts import type { CreateOrderRequest } from "@customer-portal/domain/orders"; async function createOrder(payload: CreateOrderRequest) { // Uses domain type directly } ``` ```typescript // apps/portal/src/features/auth/stores/auth.store.ts import type { LoginRequest, SignupRequest, LinkWhmcsRequest } from "@customer-portal/domain/auth"; // Uses domain types for all auth operations ``` ### ❌ Bad Examples (Portal defines own types) ```typescript // apps/portal/src/features/subscriptions/services/sim-actions.service.ts // ❌ BAD - Duplicates domain types export interface TopUpRequest { quotaMb: number; } // ✅ GOOD - Should be: import type { SimTopUpRequest } from "@customer-portal/domain/sim"; ``` --- ## Recommended Fixes ### Fix #1: Replace Portal SIM Types with Domain Types **File**: `apps/portal/src/features/subscriptions/services/sim-actions.service.ts` **Current** (lines 1-16): ```typescript import { apiClient, getDataOrDefault } from "@/lib/api"; export interface TopUpRequest { quotaMb: number; } export interface ChangePlanRequest { newPlanCode: string; assignGlobalIp: boolean; scheduledAt?: string; } export interface CancelRequest { scheduledAt: string; } ``` **Should be**: ```typescript import { apiClient, getDataOrDefault } from "@/lib/api"; import type { SimTopUpRequest, SimPlanChangeRequest, SimCancelRequest } from "@customer-portal/domain/sim"; // Remove duplicate type definitions // Types are now imported from domain ``` **Update service methods**: ```typescript export const simActionsService = { async topUp(subscriptionId: string, request: SimTopUpRequest): Promise { await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/top-up", { params: { path: { subscriptionId } }, body: request, }); }, async changePlan(subscriptionId: string, request: SimPlanChangeRequest): Promise { await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/change-plan", { params: { path: { subscriptionId } }, body: request, }); }, async cancel(subscriptionId: string, request: SimCancelRequest): Promise { await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/cancel", { params: { path: { subscriptionId } }, body: request, }); }, }; ``` **Benefits**: - ✅ Single source of truth - ✅ Types match backend exactly - ✅ Can add client-side validation using domain schemas - ✅ Less code to maintain --- ### Fix #2: Create Missing SIM Configuration Schemas **Files to create/update**: 1. `packages/domain/sim/schema.ts` - Add configuration schemas 2. `apps/portal/src/features/catalog/hooks/useSimConfigure.ts` - Fix imports **Add to domain** (`packages/domain/sim/schema.ts`): ```typescript // SIM Configuration Form Schema (for frontend) export const simConfigureFormSchema = z.object({ simType: z.enum(["eSIM", "Physical SIM"]), eid: z.string().min(15).optional(), selectedAddons: z.array(z.string()), activationType: z.enum(["Immediate", "Scheduled"]), scheduledActivationDate: z.string().regex(/^\d{8}$/).optional(), wantsMnp: z.boolean(), mnpData: z.object({ reservationNumber: z.string(), expiryDate: z.string().regex(/^\d{8}$/), phoneNumber: z.string(), mvnoAccountNumber: z.string().optional(), portingLastName: z.string().optional(), portingFirstName: z.string().optional(), portingLastNameKatakana: z.string().optional(), portingFirstNameKatakana: z.string().optional(), portingGender: z.string().optional(), portingDateOfBirth: z.string().regex(/^\d{8}$/).optional(), }).optional(), }); export type SimConfigureFormData = z.infer; export type ActivationType = "Immediate" | "Scheduled"; export type MnpData = z.infer["mnpData"]; // Transform function to convert form data to API request export function simConfigureFormToRequest( formData: SimConfigureFormData ): SimOrderActivationRequest { // Transform logic here return { // ... }; } ``` **Update portal hook** (`apps/portal/src/features/catalog/hooks/useSimConfigure.ts`): ```typescript import { simConfigureFormSchema, simConfigureFormToRequest, type SimConfigureFormData, type ActivationType, type MnpData, } from "@customer-portal/domain/sim"; // ✅ Correct domain import type { SimType } from "@customer-portal/domain/sim"; // ✅ Already exists ``` --- ## Implementation Plan ### Phase 1: Fix Portal SIM Action Types (30 minutes) **Priority**: Medium **Impact**: Improves type safety, enables client validation Tasks: - [ ] Update `apps/portal/src/features/subscriptions/services/sim-actions.service.ts` - [ ] Remove duplicate type definitions - [ ] Import types from `@customer-portal/domain/sim` - [ ] Update all usages in components - [ ] Verify TypeScript compilation --- ### Phase 2: Create SIM Configuration Schemas (1-2 hours) **Priority**: High (fixes broken imports) **Impact**: Enables SIM configuration flow Tasks: - [ ] Create `simConfigureFormSchema` in `packages/domain/sim/schema.ts` - [ ] Export types: `SimConfigureFormData`, `ActivationType`, `MnpData` - [ ] Create `simConfigureFormToRequest()` transform function - [ ] Update exports in `packages/domain/sim/index.ts` - [ ] Fix imports in `apps/portal/src/features/catalog/hooks/useSimConfigure.ts` - [ ] Test SIM configuration flow --- ### Phase 3: Add Client-Side Validation (Optional, 1 hour) **Priority**: Low **Impact**: Better UX with early validation Tasks: - [ ] Add Zod validation in portal services before API calls - [ ] Show validation errors to users before submitting - [ ] Use same validation rules as backend Example: ```typescript async topUp(subscriptionId: string, request: SimTopUpRequest) { // Validate before API call const validated = simTopUpRequestSchema.parse(request); await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/top-up", { params: { path: { subscriptionId } }, body: validated, }); } ``` --- ## Testing Strategy ### After Fix #1 (Portal SIM Types) 1. Import and use domain types in portal service 2. Verify TypeScript compilation succeeds 3. Test SIM top-up, plan change, and cancel flows 4. Verify API receives correctly formatted data ### After Fix #2 (SIM Configuration) 1. Verify all imports resolve correctly 2. Test SIM configuration form 3. Verify form validation works 4. Test API submission with form data --- ## Benefits of Fixes ### Current State Issues - ❌ Duplicate type definitions - ❌ No client-side validation - ❌ Types could diverge from backend - ❌ Broken imports in SIM configuration - ❌ No validation for date formats, min/max values ### After Fixes - ✅ Single source of truth for all types - ✅ Type safety across layers - ✅ Option to add client-side validation - ✅ All imports working correctly - ✅ Validation rules enforced (formats, ranges, etc.) - ✅ Less code to maintain - ✅ Consistency guaranteed --- ## Summary ### Current Architecture ``` Domain Layer (packages/domain/sim) ├── Schemas with validation ✅ ├── Types inferred from schemas ✅ └── Exported for reuse ✅ BFF Layer (apps/bff) ├── Imports domain schemas ✅ ├── Uses ZodValidationPipe ✅ └── No duplication ✅ Portal Layer (apps/portal) ├── Sometimes imports domain types ⚠️ ├── Sometimes defines own types ❌ └── Missing schemas (SIM config) ❌ ``` ### Target Architecture ``` Domain Layer └── Single source of truth for all schemas and types BFF Layer └── Uses domain schemas via ZodValidationPipe Portal Layer └── Uses domain types for all API calls └── Optional: Add client validation using domain schemas ``` --- ## Related Documents - [Frontend Validation Patterns](./docs/validation/FRONTEND_VALIDATION_PATTERNS.md) - [Validation Audit Summary](./VALIDATION_AUDIT_SUMMARY.md) - [Validation Patterns Guide](./docs/validation/VALIDATION_PATTERNS.md) --- **Next Steps**: Review and approve fixes, then implement in priority order.