Assist_Design/VALIDATION_DUPLICATION_REPORT.md

522 lines
14 KiB
Markdown

# 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<typeof simTopUpRequestSchema>;
export type SimPlanChangeRequest = z.infer<typeof simPlanChangeRequestSchema>;
export type SimCancelRequest = z.infer<typeof simCancelRequestSchema>;
```
**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<typeof simTopUpRequestSchema>;
// 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<void> {
await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/top-up", {
params: { path: { subscriptionId } },
body: request,
});
},
async changePlan(subscriptionId: string, request: SimPlanChangeRequest): Promise<void> {
await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/change-plan", {
params: { path: { subscriptionId } },
body: request,
});
},
async cancel(subscriptionId: string, request: SimCancelRequest): Promise<void> {
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<typeof simConfigureFormSchema>;
export type ActivationType = "Immediate" | "Scheduled";
export type MnpData = z.infer<typeof simConfigureFormSchema>["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.