522 lines
14 KiB
Markdown
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.
|
|
|