Update ESLint configuration and package dependencies; refactor BFF modules for improved structure and validation handling. Remove deprecated files and enhance user profile management across services. Streamline API client and validation utilities for better consistency and maintainability.

This commit is contained in:
barsa 2025-09-24 18:00:49 +09:00
parent 473a1235c8
commit 6becad1511
111 changed files with 1930 additions and 3721 deletions

View File

@ -32,7 +32,6 @@
"dependencies": {
"@customer-portal/domain": "workspace:*",
"@customer-portal/logging": "workspace:*",
"@customer-portal/validation": "workspace:*",
"@nestjs/bullmq": "^11.0.3",
"@nestjs/common": "^11.1.6",
"@nestjs/config": "^4.0.2",
@ -56,6 +55,7 @@
"jsforce": "^3.10.4",
"jsonwebtoken": "^9.0.2",
"nestjs-pino": "^4.4.0",
"nestjs-zod": "^5.0.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",

View File

@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "public"."sim_usage_daily" (
"id" SERIAL NOT NULL,
"account" TEXT NOT NULL,
"date" DATE NOT NULL,
"usageMb" DOUBLE PRECISION NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "sim_usage_daily_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "sim_usage_daily_account_date_idx" ON "public"."sim_usage_daily"("account", "date");
-- CreateIndex
CREATE UNIQUE INDEX "sim_usage_daily_account_date_key" ON "public"."sim_usage_daily"("account", "date");

View File

@ -1,5 +1,4 @@
import { Module } from "@nestjs/common";
import { APP_INTERCEPTOR } from "@nestjs/core";
import { RouterModule } from "@nestjs/core";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { ThrottlerModule } from "@nestjs/throttler";
@ -33,7 +32,6 @@ import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.mo
// System Modules
import { HealthModule } from "@bff/modules/health/health.module";
import { SuccessResponseInterceptor } from "@bff/core/http/success-response.interceptor";
/**
* Main application module
@ -87,11 +85,6 @@ import { SuccessResponseInterceptor } from "@bff/core/http/success-response.inte
// === ROUTING ===
RouterModule.register(apiRoutes),
],
providers: [
{
provide: APP_INTERCEPTOR,
useClass: SuccessResponseInterceptor,
},
],
providers: [],
})
export class AppModule {}

View File

@ -5,7 +5,10 @@ export type SalesforceFieldMap = {
internetEligibility: string;
customerNumber: string;
};
product: SalesforceProductFieldMap;
product: SalesforceProductFieldMap & {
featureList?: string;
featureSet?: string;
};
order: {
orderType: string;
activationType: string;
@ -73,6 +76,8 @@ export function getSalesforceFieldMap(): SalesforceFieldMap {
internetOfferingType:
process.env.PRODUCT_INTERNET_OFFERING_TYPE_FIELD || "Internet_Offering_Type__c",
displayOrder: process.env.PRODUCT_DISPLAY_ORDER_FIELD || "Catalog_Order__c",
featureList: process.env.PRODUCT_FEATURE_LIST_FIELD,
featureSet: process.env.PRODUCT_FEATURE_SET_FIELD,
bundledAddon: process.env.PRODUCT_BUNDLED_ADDON_FIELD || "Bundled_Addon__c",
isBundledAddon: process.env.PRODUCT_IS_BUNDLED_ADDON_FIELD || "Is_Bundled_Addon__c",
simDataSize: process.env.PRODUCT_SIM_DATA_SIZE_FIELD || "SIM_Data_Size__c",

View File

@ -1,26 +1,3 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from "@nestjs/common";
import { Observable } from "rxjs";
import { map } from "rxjs/operators";
@Injectable()
export class SuccessResponseInterceptor implements NestInterceptor {
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
return next.handle().pipe(
map((data: unknown) => {
if (data && typeof data === "object" && "success" in (data as Record<string, unknown>)) {
return data;
}
return {
success: true,
data,
meta: {
timestamp: new Date().toISOString(),
},
};
})
);
}
}
export {}

View File

@ -1,6 +1,34 @@
/**
* Validation Module Exports
* Simple Zod validation using validation-service
* Direct Zod validation without separate validation package
*/
export { ZodPipe, createZodPipe } from '@customer-portal/validation/nestjs';
import { ZodValidationPipe, createZodDto } from 'nestjs-zod';
import type { ZodSchema } from 'zod';
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
// Re-export the proper ZodPipe from nestjs-zod
export { ZodValidationPipe, createZodDto };
// For use with @UsePipes() decorator - this creates a pipe instance
export function ZodPipe(schema: ZodSchema) {
return new ZodValidationPipe(schema);
}
// For use with @Body() decorator - this creates a class factory
export function ZodPipeClass(schema: ZodSchema) {
@Injectable()
class ZodPipeClass implements PipeTransform {
transform(value: any, metadata: ArgumentMetadata) {
const result = schema.safeParse(value);
if (!result.success) {
throw new BadRequestException({
message: 'Validation failed',
errors: result.error.issues,
});
}
return result.data;
}
}
return ZodPipeClass;
}

View File

@ -1,4 +1,4 @@
import type { User } from "@customer-portal/domain";
import type { User, UserProfile } from "@customer-portal/domain";
import type { User as PrismaUser } from "@prisma/client";
export function mapPrismaUserToSharedUser(user: PrismaUser): User {
@ -42,4 +42,13 @@ export function mapPrismaUserToEnhancedBase(user: PrismaUser): {
};
}
export function mapPrismaUserToUserProfile(user: PrismaUser): UserProfile {
const shared = mapPrismaUserToSharedUser(user);
return {
...shared,
avatar: undefined,
preferences: {},
lastLoginAt: user.lastLoginAt ? user.lastLoginAt.toISOString() : undefined,
};
}

View File

@ -6,8 +6,8 @@ import { SalesforceConnection } from "./services/salesforce-connection.service";
import { getSalesforceFieldMap } from "@bff/core/config/field-map";
import {
SalesforceAccountService,
AccountData,
UpsertResult,
type AccountData,
type UpsertResult,
} from "./services/salesforce-account.service";
import {
SalesforceCaseService,
@ -15,6 +15,9 @@ import {
CreateCaseUserData,
} from "./services/salesforce-case.service";
import { SupportCase, CreateCaseRequest } from "@customer-portal/domain";
import type {
SalesforceAccountRecord,
} from "@customer-portal/domain";
/**
* Clean Salesforce Service - Only includes actually used functionality
@ -77,8 +80,8 @@ export class SalesforceService implements OnModuleInit {
return this.accountService.upsert(accountData);
}
async getAccount(accountId: string): Promise<Record<string, unknown> | null> {
return this.accountService.getById(accountId) as Promise<Record<string, unknown> | null>;
async getAccount(accountId: string): Promise<SalesforceAccountRecord | null> {
return this.accountService.getById(accountId);
}
async updateAccount(accountId: string, updates: Record<string, unknown>): Promise<void> {

View File

@ -2,18 +2,13 @@ import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { getErrorMessage } from "@bff/core/utils/error.util";
import { SalesforceConnection } from "./salesforce-connection.service";
import { SalesforceQueryResult as SfQueryResult } from "@customer-portal/domain";
import type {
SalesforceAccountRecord,
SalesforceQueryResult,
} from "@customer-portal/domain";
export interface AccountData {
name: string;
phone?: string;
mailingStreet?: string;
mailingCity?: string;
mailingState?: string;
mailingPostalCode?: string;
mailingCountry?: string;
buildingName?: string;
roomNumber?: string;
}
export interface UpsertResult {
@ -21,12 +16,6 @@ export interface UpsertResult {
created: boolean;
}
interface SalesforceAccount {
Id: string;
Name: string;
WH_Account__c?: string;
}
@Injectable()
export class SalesforceAccountService {
constructor(
@ -40,8 +29,8 @@ export class SalesforceAccountService {
try {
const result = (await this.connection.query(
`SELECT Id FROM Account WHERE SF_Account_No__c = '${this.safeSoql(customerNumber.trim())}'`
)) as SfQueryResult<SalesforceAccount>;
return result.totalSize > 0 ? { id: result.records[0].Id } : null;
)) as SalesforceQueryResult<SalesforceAccountRecord>;
return result.totalSize > 0 ? { id: result.records[0]?.Id ?? "" } : null;
} catch (error) {
this.logger.error("Failed to find account by customer number", {
error: getErrorMessage(error),
@ -58,7 +47,7 @@ export class SalesforceAccountService {
try {
const result = (await this.connection.query(
`SELECT Id, Name, WH_Account__c FROM Account WHERE Id = '${this.safeSoql(accountId.trim())}'`
)) as SfQueryResult<SalesforceAccount>;
)) as SalesforceQueryResult<SalesforceAccountRecord>;
if (result.totalSize === 0) {
return null;
@ -66,9 +55,9 @@ export class SalesforceAccountService {
const record = result.records[0];
return {
id: record.Id,
Name: record.Name,
WH_Account__c: record.WH_Account__c || undefined,
id: record?.Id ?? "",
Name: record?.Name,
WH_Account__c: record?.WH_Account__c || undefined,
};
} catch (error) {
this.logger.error("Failed to get account details", {
@ -111,30 +100,14 @@ export class SalesforceAccountService {
try {
const existingAccount = (await this.connection.query(
`SELECT Id FROM Account WHERE Name = '${this.safeSoql(accountData.name.trim())}'`
)) as SfQueryResult<SalesforceAccount>;
)) as SalesforceQueryResult<SalesforceAccountRecord>;
const sfData = {
Name: accountData.name.trim(),
Mobile: accountData.phone, // Account mobile field
PersonMobilePhone: accountData.phone, // Person Account mobile field (Contact)
PersonMailingStreet: accountData.mailingStreet,
PersonMailingCity: accountData.mailingCity,
PersonMailingState: accountData.mailingState,
PersonMailingPostalCode: accountData.mailingPostalCode,
PersonMailingCountry: accountData.mailingCountry,
BillingStreet: accountData.mailingStreet, // Also update billing address
BillingCity: accountData.mailingCity,
BillingState: accountData.mailingState,
BillingPostalCode: accountData.mailingPostalCode,
BillingCountry: accountData.mailingCountry,
BuildingName__pc: accountData.buildingName, // Person Account custom field
RoomNumber__pc: accountData.roomNumber, // Person Account custom field
BuildingName__c: accountData.buildingName, // Business Account custom field
RoomNumber__c: accountData.roomNumber, // Business Account custom field
};
if (existingAccount.totalSize > 0) {
const accountId = existingAccount.records[0].Id;
const accountId = existingAccount.records[0]?.Id ?? "";
const sobject = this.connection.sobject("Account");
await sobject.update?.({ Id: accountId, ...sfData });
return { id: accountId, created: false };
@ -151,7 +124,7 @@ export class SalesforceAccountService {
}
}
async getById(accountId: string): Promise<SalesforceAccount | null> {
async getById(accountId: string): Promise<SalesforceAccountRecord | null> {
if (!accountId?.trim()) throw new Error("Account ID is required");
try {
@ -159,9 +132,9 @@ export class SalesforceAccountService {
SELECT Id, Name
FROM Account
WHERE Id = '${this.validateId(accountId)}'
`)) as SfQueryResult<SalesforceAccount>;
`)) as SalesforceQueryResult<SalesforceAccountRecord>;
return result.totalSize > 0 ? result.records[0] : null;
return result.totalSize > 0 ? result.records[0] ?? null : null;
} catch (error) {
this.logger.error("Failed to get account", {
error: getErrorMessage(error),

View File

@ -4,9 +4,11 @@ import { getErrorMessage } from "@bff/core/utils/error.util";
import { SalesforceConnection } from "./salesforce-connection.service";
import { SupportCase, CreateCaseRequest, CaseType } from "@customer-portal/domain";
import { CaseStatus, CasePriority, CASE_STATUS, CASE_PRIORITY } from "@customer-portal/domain";
import {
SalesforceQueryResult as SfQueryResult,
SalesforceCreateResult as SfCreateResult,
import type {
SalesforceCaseRecord,
SalesforceContactRecord,
SalesforceCreateResult,
SalesforceQueryResult,
} from "@customer-portal/domain";
export interface CaseQueryParams {
@ -31,26 +33,6 @@ interface CaseData {
origin?: string;
}
interface SalesforceCase {
Id: string;
CaseNumber: string;
Subject: string;
Description: string;
Status: string;
Priority: string;
Type: string;
Origin: string;
CreatedDate: string;
LastModifiedDate?: string;
ClosedDate?: string;
ContactId: string;
AccountId: string;
OwnerId: string;
Owner?: {
Name: string;
};
}
@Injectable()
export class SalesforceCaseService {
constructor(
@ -86,8 +68,9 @@ export class SalesforceCaseService {
query += ` OFFSET ${params.offset}`;
}
const result = (await this.connection.query(query)) as SfQueryResult<SalesforceCase>;
const result = (await this.connection.query(query)) as SalesforceQueryResult<
SalesforceCaseRecord & { Owner?: { Name?: string } }
>;
const cases = result.records.map(record => this.transformCase(record));
return { cases, totalSize: result.totalSize };
@ -151,10 +134,10 @@ export class SalesforceCaseService {
WHERE Email = '${this.safeSoql(userData.email)}'
AND AccountId = '${userData.accountId}'
LIMIT 1
`)) as SfQueryResult<SalesforceCase>;
`)) as SalesforceQueryResult<SalesforceContactRecord>;
if (existingContact.totalSize > 0) {
return existingContact.records[0].Id;
return existingContact.records[0]?.Id ?? "";
}
// Create new contact
@ -165,11 +148,11 @@ export class SalesforceCaseService {
AccountId: userData.accountId,
};
const sobject = this.connection.sobject("Contact") as unknown as {
create: (data: Record<string, unknown>) => Promise<SfCreateResult>;
const contactCreate = this.connection.sobject("Contact") as unknown as {
create: (data: Record<string, unknown>) => Promise<SalesforceCreateResult>;
};
const result = await sobject.create(contactData);
return result.id;
const contactResult = await contactCreate.create(contactData);
return contactResult.id;
} catch (error) {
this.logger.error("Failed to find or create contact for case", {
error: getErrorMessage(error),
@ -180,7 +163,7 @@ export class SalesforceCaseService {
private async createSalesforceCase(
caseData: CaseData & { contactId: string }
): Promise<SalesforceCase> {
): Promise<SalesforceCaseRecord & { Owner?: { Name?: string } }> {
const validTypes = ["Question", "Problem", "Feature Request"];
const validPriorities = ["Low", "Medium", "High", "Critical"];
@ -194,37 +177,38 @@ export class SalesforceCaseService {
Origin: caseData.origin || "Web",
};
const sobject = this.connection.sobject("Case") as unknown as {
create: (data: Record<string, unknown>) => Promise<SfCreateResult>;
const caseCreate = this.connection.sobject("Case") as unknown as {
create: (data: Record<string, unknown>) => Promise<SalesforceCreateResult>;
};
const result = await sobject.create(sfData);
// Fetch the created case with all fields
const createdCase = (await this.connection.query(`
SELECT Id, CaseNumber, Subject, Description, Status, Priority, Type, Origin,
const caseResult = await caseCreate.create(sfData);
const createdCases = (await this.connection.query(`
SELECT Id, CaseNumber, Subject, Description, Status, Priority, Type, Origin,
CreatedDate, LastModifiedDate, ClosedDate, ContactId, AccountId, OwnerId, Owner.Name
FROM Case
WHERE Id = '${result.id}'
`)) as SfQueryResult<SalesforceCase>;
return createdCase.records[0];
FROM Case
WHERE Id = '${caseResult.id}'
`)) as SalesforceQueryResult<SalesforceCaseRecord & { Owner?: { Name?: string } }>;
return createdCases.records[0] ?? ({} as SalesforceCaseRecord);
}
private transformCase(sfCase: SalesforceCase): SupportCase {
private transformCase(sfCase: SalesforceCaseRecord & { Owner?: { Name?: string } }): SupportCase {
const status = sfCase.Status ?? "";
const priority = sfCase.Priority ?? "";
const type = sfCase.Type ?? "";
const createdDate = sfCase.CreatedDate ?? new Date().toISOString();
return {
id: sfCase.Id,
number: sfCase.CaseNumber, // Use 'number' instead of 'caseNumber'
subject: sfCase.Subject,
description: sfCase.Description,
status: this.mapSalesforceStatus(sfCase.Status),
priority: this.mapSalesforcePriority(sfCase.Priority),
type: this.mapSalesforceType(sfCase.Type),
createdDate: sfCase.CreatedDate,
lastModifiedDate: sfCase.LastModifiedDate || sfCase.CreatedDate,
number: sfCase.CaseNumber ?? "",
subject: sfCase.Subject ?? "",
description: sfCase.Description ?? "",
status: this.mapSalesforceStatus(status),
priority: this.mapSalesforcePriority(priority),
type: this.mapSalesforceType(type),
createdDate,
lastModifiedDate: sfCase.LastModifiedDate ?? createdDate,
closedDate: sfCase.ClosedDate,
contactId: sfCase.ContactId,
accountId: sfCase.AccountId,
ownerId: sfCase.OwnerId,
contactId: sfCase.ContactId ?? "",
accountId: sfCase.AccountId ?? "",
ownerId: sfCase.OwnerId ?? "",
ownerName: sfCase.Owner?.Name,
};
}

View File

@ -1,4 +1,4 @@
import { Controller, Post, Body, UseGuards, Get, Req, HttpCode } from "@nestjs/common";
import { Controller, Post, Body, UseGuards, Get, Req, HttpCode, UsePipes } from "@nestjs/common";
import type { Request } from "express";
import { Throttle } from "@nestjs/throttler";
import { AuthService } from "./auth.service";
@ -6,7 +6,7 @@ import { LocalAuthGuard } from "./guards/local-auth.guard";
import { AuthThrottleGuard } from "./guards/auth-throttle.guard";
import { ApiTags, ApiOperation, ApiResponse, ApiOkResponse } from "@nestjs/swagger";
import { Public } from "./decorators/public.decorator";
import { ZodPipe } from "@bff/core/validation";
import { ZodValidationPipe } from "@bff/core/validation";
// Import Zod schemas from domain
import {
@ -45,13 +45,14 @@ export class AuthController {
@Post("validate-signup")
@UseGuards(AuthThrottleGuard)
@Throttle({ default: { limit: 10, ttl: 900000 } }) // 10 validations per 15 minutes per IP
@UsePipes(new ZodValidationPipe(validateSignupRequestSchema))
@ApiOperation({ summary: "Validate customer number for signup" })
@ApiResponse({ status: 200, description: "Validation successful" })
@ApiResponse({ status: 409, description: "Customer already has account" })
@ApiResponse({ status: 400, description: "Customer number not found" })
@ApiResponse({ status: 429, description: "Too many validation attempts" })
async validateSignup(
@Body(ZodPipe(validateSignupRequestSchema)) validateData: ValidateSignupRequestInput,
@Body() validateData: ValidateSignupRequestInput,
@Req() req: Request
) {
return this.authService.validateSignup(validateData, req);
@ -69,18 +70,20 @@ export class AuthController {
@Post("signup-preflight")
@UseGuards(AuthThrottleGuard)
@Throttle({ default: { limit: 10, ttl: 900000 } })
@UsePipes(new ZodValidationPipe(signupRequestSchema))
@HttpCode(200)
@ApiOperation({ summary: "Validate full signup data without creating anything" })
@ApiResponse({ status: 200, description: "Preflight results with next action guidance" })
async signupPreflight(@Body(ZodPipe(signupRequestSchema)) signupData: SignupRequestInput) {
async signupPreflight(@Body() signupData: SignupRequestInput) {
return this.authService.signupPreflight(signupData);
}
@Public()
@Post("account-status")
@UsePipes(new ZodValidationPipe(accountStatusRequestSchema))
@ApiOperation({ summary: "Get account status by email" })
@ApiOkResponse({ description: "Account status" })
async accountStatus(@Body(ZodPipe(accountStatusRequestSchema)) body: AccountStatusRequestInput) {
async accountStatus(@Body() body: AccountStatusRequestInput) {
return this.authService.getAccountStatus(body.email);
}
@ -88,11 +91,12 @@ export class AuthController {
@Post("signup")
@UseGuards(AuthThrottleGuard)
@Throttle({ default: { limit: 3, ttl: 900000 } }) // 3 signups per 15 minutes per IP
@UsePipes(new ZodValidationPipe(signupRequestSchema))
@ApiOperation({ summary: "Create new user account" })
@ApiResponse({ status: 201, description: "User created successfully" })
@ApiResponse({ status: 409, description: "User already exists" })
@ApiResponse({ status: 429, description: "Too many signup attempts" })
async signup(@Body(ZodPipe(signupRequestSchema)) signupData: SignupRequestInput, @Req() req: Request) {
async signup(@Body() signupData: SignupRequestInput, @Req() req: Request) {
return this.authService.signup(signupData, req);
}
@ -127,12 +131,13 @@ export class AuthController {
@Public()
@Post("refresh")
@Throttle({ default: { limit: 10, ttl: 300000 } }) // 10 attempts per 5 minutes per IP
@UsePipes(new ZodValidationPipe(refreshTokenRequestSchema))
@ApiOperation({ summary: "Refresh access token using refresh token" })
@ApiResponse({ status: 200, description: "Token refreshed successfully" })
@ApiResponse({ status: 401, description: "Invalid refresh token" })
@ApiResponse({ status: 429, description: "Too many refresh attempts" })
async refreshToken(
@Body(ZodPipe(refreshTokenRequestSchema)) body: RefreshTokenRequestInput,
@Body() body: RefreshTokenRequestInput,
@Req() req: Request
) {
return this.authService.refreshTokens(body.refreshToken, {
@ -145,6 +150,7 @@ export class AuthController {
@Post("link-whmcs")
@UseGuards(AuthThrottleGuard)
@Throttle({ default: { limit: 3, ttl: 900000 } }) // 3 attempts per 15 minutes per IP
@UsePipes(new ZodValidationPipe(linkWhmcsRequestSchema))
@ApiOperation({ summary: "Link existing WHMCS user" })
@ApiResponse({
status: 200,
@ -152,7 +158,7 @@ export class AuthController {
})
@ApiResponse({ status: 401, description: "Invalid WHMCS credentials" })
@ApiResponse({ status: 429, description: "Too many link attempts" })
async linkWhmcs(@Body(ZodPipe(linkWhmcsRequestSchema)) linkData: LinkWhmcsRequestInput, @Req() _req: Request) {
async linkWhmcs(@Body() linkData: LinkWhmcsRequestInput, @Req() _req: Request) {
return this.authService.linkWhmcsUser(linkData);
}
@ -160,29 +166,32 @@ export class AuthController {
@Post("set-password")
@UseGuards(AuthThrottleGuard)
@Throttle({ default: { limit: 3, ttl: 300000 } }) // 3 attempts per 5 minutes per IP+UA
@UsePipes(new ZodValidationPipe(setPasswordRequestSchema))
@ApiOperation({ summary: "Set password for linked user" })
@ApiResponse({ status: 200, description: "Password set successfully" })
@ApiResponse({ status: 401, description: "User not found" })
@ApiResponse({ status: 429, description: "Too many password attempts" })
async setPassword(@Body(ZodPipe(setPasswordRequestSchema)) setPasswordData: SetPasswordRequestInput, @Req() _req: Request) {
async setPassword(@Body() setPasswordData: SetPasswordRequestInput, @Req() _req: Request) {
return this.authService.setPassword(setPasswordData);
}
@Public()
@Post("check-password-needed")
@UsePipes(new ZodValidationPipe(checkPasswordNeededRequestSchema))
@HttpCode(200)
@ApiOperation({ summary: "Check if user needs to set password" })
@ApiResponse({ status: 200, description: "Password status checked" })
async checkPasswordNeeded(@Body(ZodPipe(checkPasswordNeededRequestSchema)) data: CheckPasswordNeededRequestInput) {
async checkPasswordNeeded(@Body() data: CheckPasswordNeededRequestInput) {
return this.authService.checkPasswordNeeded(data.email);
}
@Public()
@Post("request-password-reset")
@Throttle({ default: { limit: 5, ttl: 900000 } })
@UsePipes(new ZodValidationPipe(passwordResetRequestSchema))
@ApiOperation({ summary: "Request password reset email" })
@ApiResponse({ status: 200, description: "Reset email sent if account exists" })
async requestPasswordReset(@Body(ZodPipe(passwordResetRequestSchema)) body: PasswordResetRequestInput) {
async requestPasswordReset(@Body() body: PasswordResetRequestInput) {
await this.authService.requestPasswordReset(body.email);
return { message: "If an account exists, a reset email has been sent" };
}
@ -190,19 +199,21 @@ export class AuthController {
@Public()
@Post("reset-password")
@Throttle({ default: { limit: 5, ttl: 900000 } })
@UsePipes(new ZodValidationPipe(passwordResetSchema))
@ApiOperation({ summary: "Reset password with token" })
@ApiResponse({ status: 200, description: "Password reset successful" })
async resetPassword(@Body(ZodPipe(passwordResetSchema)) body: PasswordResetInput) {
async resetPassword(@Body() body: PasswordResetInput) {
return this.authService.resetPassword(body.token, body.password);
}
@Post("change-password")
@Throttle({ default: { limit: 5, ttl: 300000 } })
@UsePipes(new ZodValidationPipe(changePasswordRequestSchema))
@ApiOperation({ summary: "Change password (authenticated)" })
@ApiResponse({ status: 200, description: "Password changed successfully" })
async changePassword(
@Req() req: Request & { user: { id: string } },
@Body(ZodPipe(changePasswordRequestSchema)) body: ChangePasswordRequestInput
@Body() body: ChangePasswordRequestInput
) {
return this.authService.changePassword(
req.user.id,
@ -227,6 +238,7 @@ export class AuthController {
}
@Post("sso-link")
@UsePipes(new ZodValidationPipe(ssoLinkRequestSchema))
@ApiOperation({ summary: "Create SSO link to WHMCS" })
@ApiResponse({ status: 200, description: "SSO link created successfully" })
@ApiResponse({
@ -235,7 +247,7 @@ export class AuthController {
})
async createSsoLink(
@Req() req: Request & { user: { id: string } },
@Body(ZodPipe(ssoLinkRequestSchema)) body: SsoLinkRequestInput
@Body() body: SsoLinkRequestInput
) {
const destination = body?.destination;
return this.authService.createSsoLink(req.user.id, destination);

View File

@ -5,7 +5,6 @@ import { ConfigService } from "@nestjs/config";
import { APP_GUARD } from "@nestjs/core";
import { AuthService } from "./auth.service";
import { AuthController } from "./auth-zod.controller";
import { AuthAdminController } from "./auth-admin.controller";
import { UsersModule } from "@bff/modules/users/users.module";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module";
import { IntegrationsModule } from "@bff/integrations/integrations.module";
@ -34,7 +33,7 @@ import { WhmcsLinkWorkflowService } from "./services/workflows/whmcs-link-workfl
IntegrationsModule,
EmailModule,
],
controllers: [AuthController, AuthAdminController],
controllers: [AuthController],
providers: [
AuthService,
JwtStrategy,

View File

@ -18,6 +18,8 @@ import { getErrorMessage } from "@bff/core/utils/error.util";
import { Logger } from "nestjs-pino";
import { sanitizeWhmcsRedirectPath } from "@bff/core/utils/sso.util";
import {
authResponseSchema,
type AuthResponse,
type SignupRequestInput,
type ValidateSignupRequestInput,
type LinkWhmcsRequestInput,
@ -31,7 +33,7 @@ import { AuthTokenService } from "./services/token.service";
import { SignupWorkflowService } from "./services/workflows/signup-workflow.service";
import { PasswordWorkflowService } from "./services/workflows/password-workflow.service";
import { WhmcsLinkWorkflowService } from "./services/workflows/whmcs-link-workflow.service";
import { sanitizeUser } from "./utils/sanitize-user.util";
import { mapPrismaUserToUserProfile } from "@bff/infra/utils/user-mapper.util";
@Injectable()
export class AuthService {
@ -135,11 +137,25 @@ export class AuthService {
true
);
const tokens = await this.tokenService.generateTokenPair(user, {
userAgent: request?.headers['user-agent'],
});
const prismaUser = await this.usersService.findByIdInternal(user.id);
if (!prismaUser) {
throw new UnauthorizedException("User record missing");
}
const profile = mapPrismaUserToUserProfile(prismaUser);
const tokens = await this.tokenService.generateTokenPair(
{
id: profile.id,
email: profile.email,
},
{
userAgent: request?.headers['user-agent'],
}
);
return {
user: sanitizeUser(user),
user: profile,
tokens,
};
}

View File

@ -1,9 +1,4 @@
import type { UserProfile } from "@customer-portal/domain";
import type { Request } from "express";
export interface AuthUser {
id: string;
email: string;
role?: string;
}
export type RequestWithUser = Request & { user: AuthUser };
export type RequestWithUser = Request & { user: UserProfile };

View File

@ -74,7 +74,13 @@ export class AuthTokenService {
const refreshTokenHash = this.hashToken(refreshToken);
const refreshExpirySeconds = this.parseExpiryToSeconds(this.REFRESH_TOKEN_EXPIRY);
if (this.redis.status !== "ready") {
this.logger.error("Redis not ready for token issuance", { status: this.redis.status });
throw new UnauthorizedException("Session service unavailable");
}
try {
await this.redis.ping();
await this.redis.setex(
`${this.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`,
refreshExpirySeconds,
@ -102,7 +108,7 @@ export class AuthTokenService {
error: error instanceof Error ? error.message : String(error),
userId: user.id
});
// Continue without Redis storage - tokens will still work but won't have rotation protection
throw new UnauthorizedException("Unable to issue session tokens. Please try again.");
}
const accessExpiresAt = new Date(Date.now() + this.parseExpiryToMs(this.ACCESS_TOKEN_EXPIRY)).toISOString();

View File

@ -14,10 +14,11 @@ import { AuditService, AuditAction } from "@bff/infra/audit/audit.service";
import { EmailService } from "@bff/infra/email/email.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
import { AuthTokenService } from "../token.service";
import { type User, type AuthTokens } from "@customer-portal/domain";
import { type AuthTokens, type UserProfile } from "@customer-portal/domain";
import { mapPrismaUserToUserProfile } from "@bff/infra/utils/user-mapper.util";
interface PasswordChangeResult {
user: User;
export interface PasswordChangeResult {
user: UserProfile;
tokens: AuthTokens;
}
@ -58,14 +59,19 @@ export class PasswordWorkflowService {
const passwordHash = await bcrypt.hash(password, 12);
const updatedUser = await this.usersService.update(user.id, { passwordHash });
const prismaUser = await this.usersService.findByIdInternal(user.id);
if (!prismaUser) {
throw new Error("Failed to load user after password setup");
}
const userProfile = mapPrismaUserToUserProfile(prismaUser);
const tokens = await this.tokenService.generateTokenPair({
id: updatedUser.id,
email: updatedUser.email,
role: user.role?.toLowerCase() // Use the role from the original Prisma user
id: userProfile.id,
email: userProfile.email,
});
return {
user: updatedUser,
user: userProfile,
tokens,
};
}
@ -120,15 +126,20 @@ export class PasswordWorkflowService {
typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig;
const passwordHash = await bcrypt.hash(newPassword, saltRounds);
const updatedUser = await this.usersService.update(prismaUser.id, { passwordHash });
await this.usersService.update(prismaUser.id, { passwordHash });
const freshUser = await this.usersService.findByIdInternal(prismaUser.id);
if (!freshUser) {
throw new Error("Failed to load user after password reset");
}
const userProfile = mapPrismaUserToUserProfile(freshUser);
const tokens = await this.tokenService.generateTokenPair({
id: updatedUser.id,
email: updatedUser.email,
role: prismaUser.role?.toLowerCase()
id: userProfile.id,
email: userProfile.email,
});
return {
user: updatedUser,
user: userProfile,
tokens,
};
} catch (error) {
@ -180,7 +191,12 @@ export class PasswordWorkflowService {
typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig;
const passwordHash = await bcrypt.hash(newPassword, saltRounds);
const updatedUser = await this.usersService.update(user.id, { passwordHash });
await this.usersService.update(user.id, { passwordHash });
const prismaUser = await this.usersService.findByIdInternal(user.id);
if (!prismaUser) {
throw new Error("Failed to load user after password change");
}
const userProfile = mapPrismaUserToUserProfile(prismaUser);
await this.auditService.logAuthEvent(
AuditAction.PASSWORD_CHANGE,
@ -191,12 +207,11 @@ export class PasswordWorkflowService {
);
const tokens = await this.tokenService.generateTokenPair({
id: updatedUser.id,
email: updatedUser.email,
role: user.role?.toLowerCase() // Use the role from the original Prisma user
id: userProfile.id,
email: userProfile.email,
});
return {
user: updatedUser,
user: userProfile,
tokens,
};
}

View File

@ -16,14 +16,15 @@ import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
import { PrismaService } from "@bff/infra/database/prisma.service";
import { AuthTokenService } from "../token.service";
import { sanitizeUser } from "../../utils/sanitize-user.util";
import { getErrorMessage } from "@bff/core/utils/error.util";
import {
signupRequestSchema,
type SignupRequestInput,
type ValidateSignupRequestInput,
type AuthTokens,
type UserProfile,
} from "@customer-portal/domain";
import { mapPrismaUserToUserProfile } from "@bff/infra/utils/user-mapper.util";
import type { User as PrismaUser } from "@prisma/client";
type SanitizedPrismaUser = Omit<
@ -31,8 +32,8 @@ type SanitizedPrismaUser = Omit<
"passwordHash" | "failedLoginAttempts" | "lockedUntil"
>;
interface SignupResult {
user: SanitizedPrismaUser;
export interface SignupResult {
user: UserProfile;
tokens: AuthTokens;
}
@ -305,16 +306,20 @@ export class SignupWorkflowService {
true
);
const tokens = await this.tokenService.generateTokenPair({
id: createdUserId,
email,
role: "user" // Default role for new signups
const prismaUser = freshUser ?? (await this.usersService.findByIdInternal(createdUserId));
if (!prismaUser) {
throw new Error("Failed to load created user");
}
const profile = mapPrismaUserToUserProfile(prismaUser);
const tokens = await this.tokenService.generateTokenPair({
id: profile.id,
email: profile.email,
});
return {
user: sanitizeUser(
freshUser ?? ({ id: createdUserId, email } as PrismaUser)
),
user: profile,
tokens,
};
} catch (error) {

View File

@ -11,8 +11,8 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
import { sanitizeUser } from "../../utils/sanitize-user.util";
import type { User as SharedUser } from "@customer-portal/domain";
import { mapPrismaUserToUserProfile } from "@bff/infra/utils/user-mapper.util";
import type { UserProfile } from "@customer-portal/domain";
import type { WhmcsClientResponse } from "@bff/integrations/whmcs/types/whmcs-api.types";
@Injectable()
@ -30,10 +30,10 @@ export class WhmcsLinkWorkflowService {
if (existingUser) {
if (!existingUser.passwordHash) {
this.logger.log("User exists but has no password - allowing password setup to continue", {
userId: existingUser.id
userId: existingUser.id,
});
return {
user: sanitizeUser(existingUser),
user: mapPrismaUserToUserProfile(existingUser),
needsPasswordSet: true,
};
}
@ -100,7 +100,7 @@ export class WhmcsLinkWorkflowService {
throw new BadRequestException("Unable to verify customer information. Please contact support.");
}
const user: SharedUser = await this.usersService.create({
const createdUser = await this.usersService.create({
email,
passwordHash: null,
firstName: clientDetails.firstname || "",
@ -111,13 +111,20 @@ export class WhmcsLinkWorkflowService {
});
await this.mappingsService.createMapping({
userId: user.id,
userId: createdUser.id,
whmcsClientId: clientDetails.id,
sfAccountId: sfAccount.id,
});
const prismaUser = await this.usersService.findByIdInternal(createdUser.id);
if (!prismaUser) {
throw new Error("Failed to load newly linked user");
}
const userProfile: UserProfile = mapPrismaUserToUserProfile(prismaUser);
return {
user: sanitizeUser(user),
user: userProfile,
needsPasswordSet: true,
};
} catch (error) {

View File

@ -2,6 +2,7 @@ import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { ConfigService } from "@nestjs/config";
import type { UserProfile } from "@customer-portal/domain";
import { TokenBlacklistService } from "../services/token-blacklist.service";
@Injectable()
@ -19,13 +20,19 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: jwtSecret,
// Remove passReqToCallback to avoid body parsing interference
};
super(options);
}
async validate(payload: { sub: string; email: string; role: string; iat?: number; exp?: number }) {
async validate(payload: {
sub: string;
email: string;
role: string;
iat?: number;
exp?: number;
}): Promise<UserProfile> {
// Validate payload structure
if (!payload.sub || !payload.email) {
throw new Error('Invalid JWT payload');
@ -36,9 +43,17 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
return {
id: payload.sub,
email: payload.email,
role: payload.role,
iat: payload.iat,
exp: payload.exp,
firstName: undefined,
lastName: undefined,
company: undefined,
phone: undefined,
mfaEnabled: false,
emailVerified: true,
createdAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(),
avatar: undefined,
preferences: {},
lastLoginAt: undefined,
};
}
}

View File

@ -1,23 +0,0 @@
export function sanitizeUser<
T extends {
id: string;
email: string;
role?: string;
passwordHash?: string | null;
failedLoginAttempts?: number | null;
lockedUntil?: Date | null;
},
>(user: T): Omit<T, "passwordHash" | "failedLoginAttempts" | "lockedUntil"> {
const {
passwordHash: _passwordHash,
failedLoginAttempts: _failedLoginAttempts,
lockedUntil: _lockedUntil,
...rest
} = user as T & {
passwordHash?: string | null;
failedLoginAttempts?: number | null;
lockedUntil?: Date | null;
};
return rest;
}

View File

@ -1,11 +0,0 @@
import { Controller } from "@nestjs/common";
import { CasesService } from "./cases.service";
import { ApiTags } from "@nestjs/swagger";
@ApiTags("cases")
@Controller("cases")
export class CasesController {
constructor(private casesService: CasesService) {}
// TODO: Implement case endpoints
}

View File

@ -1,12 +0,0 @@
import { Module } from "@nestjs/common";
import { CasesController } from "./cases.controller";
import { CasesService } from "./cases.service";
import { IntegrationsModule } from "@bff/integrations/integrations.module";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module";
@Module({
imports: [IntegrationsModule, MappingsModule],
controllers: [CasesController],
providers: [CasesService],
})
export class CasesModule {}

View File

@ -1,6 +0,0 @@
import { Injectable } from "@nestjs/common";
@Injectable()
export class CasesService {
// TODO: Implement case business logic
}

View File

@ -1,17 +1,16 @@
import { Controller, Get, Request } from "@nestjs/common";
import { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger";
import {
import type {
InternetAddonCatalogItem,
InternetCatalogService,
InternetInstallationCatalogItem,
InternetPlanCatalogItem,
} from "./services/internet-catalog.service";
import {
SimCatalogProduct,
SimActivationFeeCatalogItem,
SimCatalogService,
} from "./services/sim-catalog.service";
VpnCatalogProduct,
} from "@customer-portal/domain";
import { InternetCatalogService } from "./services/internet-catalog.service";
import { SimCatalogService } from "./services/sim-catalog.service";
import { VpnCatalogService } from "./services/vpn-catalog.service";
import { SimProduct, VpnProduct } from "@customer-portal/domain";
@ApiTags("catalog")
@Controller("catalog")
@ -50,7 +49,7 @@ export class CatalogController {
@Get("sim/plans")
@ApiOperation({ summary: "Get SIM plans filtered by user's existing services" })
async getSimPlans(@Request() req: { user: { id: string } }): Promise<SimProduct[]> {
async getSimPlans(@Request() req: { user: { id: string } }): Promise<SimCatalogProduct[]> {
const userId = req.user?.id;
if (!userId) {
// Fallback to all regular plans if no user context
@ -68,19 +67,19 @@ export class CatalogController {
@Get("sim/addons")
@ApiOperation({ summary: "Get SIM add-ons" })
async getSimAddons(): Promise<SimProduct[]> {
async getSimAddons(): Promise<SimCatalogProduct[]> {
return this.simCatalog.getAddons();
}
@Get("vpn/plans")
@ApiOperation({ summary: "Get VPN plans" })
async getVpnPlans(): Promise<VpnProduct[]> {
async getVpnPlans(): Promise<VpnCatalogProduct[]> {
return this.vpnCatalog.getPlans();
}
@Get("vpn/activation-fees")
@ApiOperation({ summary: "Get VPN activation fees" })
async getVpnActivationFees(): Promise<VpnProduct[]> {
async getVpnActivationFees(): Promise<VpnCatalogProduct[]> {
return this.vpnCatalog.getActivationFees();
}
}

View File

@ -1,38 +1,17 @@
import { Injectable, Inject } from "@nestjs/common";
import { BaseCatalogService } from "./base-catalog.service";
import { InternetProduct, fromSalesforceProduct2, SalesforceProduct2Record } from "@customer-portal/domain";
import type { SalesforcePricebookEntryRecord, InternetPlanCatalogItem, InternetInstallationCatalogItem, InternetAddonCatalogItem } from "@customer-portal/domain";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
import { Logger } from "nestjs-pino";
import { getErrorMessage } from "@bff/core/utils/error.util";
import { mapInternetPlan, mapInternetInstallation, mapInternetAddon } from "@bff/modules/catalog/utils/salesforce-product.mapper";
interface SalesforceAccount {
Id: string;
Internet_Eligibility__c?: string;
}
export type InternetPlanCatalogItem = InternetProduct & {
catalogMetadata: {
tierDescription: string;
features: string[];
isRecommended: boolean;
};
};
export type InternetInstallationCatalogItem = InternetProduct & {
catalogMetadata: {
installationTerm: "One-time" | "12-Month" | "24-Month";
};
};
export type InternetAddonCatalogItem = InternetProduct & {
catalogMetadata: {
addonCategory: "hikari-denwa-service" | "hikari-denwa-installation" | "other";
autoAdd: boolean;
requiredWith: string[];
};
};
@Injectable()
export class InternetCatalogService extends BaseCatalogService {
constructor(
@ -54,23 +33,7 @@ export class InternetCatalogService extends BaseCatalogService {
return records.map(record => {
const pricebookEntry = this.extractPricebookEntry(record);
const product = fromSalesforceProduct2(
record as SalesforceProduct2Record,
pricebookEntry,
fields.product
) as InternetProduct;
const tierData = this.getTierData(product.internetPlanTier || "Silver");
return {
...product,
catalogMetadata: {
tierDescription: tierData.tierDescription,
features: tierData.features,
isRecommended: product.internetPlanTier === "Gold",
},
description: product.description ?? tierData.description,
} satisfies InternetPlanCatalogItem;
return mapInternetPlan(record as SalesforceProduct2Record, pricebookEntry);
});
}
@ -87,21 +50,7 @@ export class InternetCatalogService extends BaseCatalogService {
return records
.map(record => {
const pricebookEntry = this.extractPricebookEntry(record);
const product = fromSalesforceProduct2(
record as SalesforceProduct2Record,
pricebookEntry,
fields.product
) as InternetProduct;
const installationType = this.inferInstallationTypeFromSku(product.sku);
return {
...product,
catalogMetadata: {
installationTerm: installationType,
},
displayOrder: product.displayOrder ?? Number(record[fields.product.displayOrder] ?? 0),
} satisfies InternetInstallationCatalogItem;
return mapInternetInstallation(record as SalesforceProduct2Record, pricebookEntry);
})
.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
}
@ -121,23 +70,7 @@ export class InternetCatalogService extends BaseCatalogService {
return records
.map(record => {
const pricebookEntry = this.extractPricebookEntry(record);
const product = fromSalesforceProduct2(
record as SalesforceProduct2Record,
pricebookEntry,
fields.product
) as InternetProduct;
const addonType = this.inferAddonTypeFromSku(product.sku);
return {
...product,
catalogMetadata: {
addonCategory: addonType,
autoAdd: false,
requiredWith: [],
},
displayOrder: product.displayOrder ?? Number(record[fields.product.displayOrder] ?? 0),
} satisfies InternetAddonCatalogItem;
return mapInternetAddon(record as SalesforceProduct2Record, pricebookEntry);
})
.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
}

View File

@ -1,17 +1,12 @@
import { Injectable, Inject } from "@nestjs/common";
import { BaseCatalogService } from "./base-catalog.service";
import { SimProduct, fromSalesforceProduct2, SalesforceProduct2Record } from "@customer-portal/domain";
import type { SimCatalogProduct, SimActivationFeeCatalogItem } from "@customer-portal/domain";
import { mapSimProduct } from "@bff/modules/catalog/utils/salesforce-product.mapper";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
import { Logger } from "nestjs-pino";
import { WhmcsConnectionService } from "@bff/integrations/whmcs/services/whmcs-connection.service";
export type SimActivationFeeCatalogItem = SimProduct & {
catalogMetadata: {
isDefault: boolean;
};
};
@Injectable()
export class SimCatalogService extends BaseCatalogService {
constructor(
@ -23,7 +18,7 @@ export class SimCatalogService extends BaseCatalogService {
super(sf, logger);
}
async getPlans(): Promise<SimProduct[]> {
async getPlans(): Promise<SimCatalogProduct[]> {
const fields = this.getFields();
const soql = this.buildCatalogServiceQuery("SIM", [
fields.product.simDataSize,
@ -35,17 +30,14 @@ export class SimCatalogService extends BaseCatalogService {
return records.map(record => {
const pricebookEntry = this.extractPricebookEntry(record);
const product = fromSalesforceProduct2(
record as SalesforceProduct2Record,
pricebookEntry,
fields.product
) as SimProduct;
const product = mapSimProduct(record, pricebookEntry);
return {
...product,
description: product.name,
features: product.features ?? [],
} satisfies SimProduct;
description: product.description ?? product.name,
monthlyPrice: product.monthlyPrice,
oneTimePrice: product.oneTimePrice,
} satisfies SimCatalogProduct;
});
}
@ -56,15 +48,11 @@ export class SimCatalogService extends BaseCatalogService {
return records.map(record => {
const pricebookEntry = this.extractPricebookEntry(record);
const product = fromSalesforceProduct2(
record as SalesforceProduct2Record,
pricebookEntry,
fields.product
) as SimProduct;
const product = mapSimProduct(record, pricebookEntry);
return {
...product,
description: product.name,
description: product.description ?? product.name,
catalogMetadata: {
isDefault: true,
},
@ -72,7 +60,7 @@ export class SimCatalogService extends BaseCatalogService {
});
}
async getAddons(): Promise<SimProduct[]> {
async getAddons(): Promise<SimCatalogProduct[]> {
const fields = this.getFields();
const soql = this.buildProductQuery("SIM", "Add-on", [
fields.product.billingCycle,
@ -85,22 +73,19 @@ export class SimCatalogService extends BaseCatalogService {
return records
.map(record => {
const pricebookEntry = this.extractPricebookEntry(record);
const product = fromSalesforceProduct2(
record as SalesforceProduct2Record,
pricebookEntry,
fields.product
) as SimProduct;
const product = mapSimProduct(record, pricebookEntry);
return {
...product,
description: product.name,
displayOrder: product.displayOrder ?? Number(record[fields.product.displayOrder] ?? 0),
} satisfies SimProduct;
description: product.description ?? product.name,
displayOrder:
product.displayOrder ?? Number((record as Record<string, unknown>)[fields.product.displayOrder] ?? 0),
} satisfies SimCatalogProduct;
})
.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
}
async getPlansForUser(userId: string): Promise<SimProduct[]> {
async getPlansForUser(userId: string): Promise<SimCatalogProduct[]> {
try {
// Get all plans first
const allPlans = await this.getPlans();

View File

@ -1,10 +1,11 @@
import { Injectable } from "@nestjs/common";
import { BaseCatalogService } from "./base-catalog.service";
import { VpnProduct, fromSalesforceProduct2, SalesforceProduct2Record } from "@customer-portal/domain";
import type { VpnCatalogProduct } from "@customer-portal/domain";
import { mapVpnProduct } from "@bff/modules/catalog/utils/salesforce-product.mapper";
@Injectable()
export class VpnCatalogService extends BaseCatalogService {
async getPlans(): Promise<VpnProduct[]> {
async getPlans(): Promise<VpnCatalogProduct[]> {
const fields = this.getFields();
const soql = this.buildCatalogServiceQuery("VPN", [
fields.product.vpnRegion,
@ -14,36 +15,28 @@ export class VpnCatalogService extends BaseCatalogService {
return records.map(record => {
const pricebookEntry = this.extractPricebookEntry(record);
const product = fromSalesforceProduct2(
record as SalesforceProduct2Record,
pricebookEntry,
fields.product
) as VpnProduct;
const product = mapVpnProduct(record, pricebookEntry);
return {
...product,
description: product.description || product.name,
} satisfies VpnProduct;
description: product.description ?? product.name,
} satisfies VpnCatalogProduct;
});
}
async getActivationFees(): Promise<VpnProduct[]> {
async getActivationFees(): Promise<VpnCatalogProduct[]> {
const fields = this.getFields();
const soql = this.buildProductQuery("VPN", "Activation", [fields.product.vpnRegion]);
const records = await this.executeQuery(soql, "VPN Activation Fees");
return records.map(record => {
const pricebookEntry = this.extractPricebookEntry(record);
const product = fromSalesforceProduct2(
record as SalesforceProduct2Record,
pricebookEntry,
fields.product
) as VpnProduct;
const product = mapVpnProduct(record, pricebookEntry);
return {
...product,
description: product.description || product.name,
} satisfies VpnProduct;
description: product.description ?? product.name,
} satisfies VpnCatalogProduct;
});
}

View File

@ -0,0 +1,268 @@
import type {
CatalogProductBase,
CatalogPricebookEntry,
SalesforceProduct2Record,
SalesforcePricebookEntryRecord,
InternetCatalogProduct,
InternetPlanCatalogItem,
InternetInstallationCatalogItem,
InternetAddonCatalogItem,
SimCatalogProduct,
SimActivationFeeCatalogItem,
VpnCatalogProduct,
} from "@customer-portal/domain";
import { getSalesforceFieldMap } from "@bff/core/config/field-map";
import { getMonthlyPrice, getOneTimePrice } from "@bff/modules/catalog/utils/salesforce-product.pricing";
import type { InternetPlanTemplate } from "@customer-portal/domain";
const { product: productFields } = getSalesforceFieldMap();
function normalizePricebookEntry(entry?: SalesforcePricebookEntryRecord): CatalogPricebookEntry | undefined {
if (!entry) return undefined;
return {
id: typeof entry.Id === "string" ? entry.Id : undefined,
name: typeof entry.Name === "string" ? entry.Name : undefined,
unitPrice:
typeof entry.UnitPrice === "number"
? entry.UnitPrice
: typeof entry.UnitPrice === "string"
? Number.parseFloat(entry.UnitPrice)
: undefined,
pricebook2Id: typeof entry.Pricebook2Id === "string" ? entry.Pricebook2Id : undefined,
product2Id: typeof entry.Product2Id === "string" ? entry.Product2Id : undefined,
isActive: typeof entry.IsActive === "boolean" ? entry.IsActive : undefined,
};
}
function baseProduct(product: SalesforceProduct2Record): CatalogProductBase {
const safeRecord = product as Record<string, unknown>;
const id = typeof product.Id === "string" ? product.Id : "";
const skuField = productFields.sku;
const skuRaw = skuField ? safeRecord[skuField] : undefined;
const sku = typeof skuRaw === "string" ? skuRaw : "";
const base: CatalogProductBase = {
id,
sku,
name: typeof product.Name === "string" ? product.Name : sku,
};
const description = typeof product.Description === "string" ? product.Description : undefined;
if (description) {
base.description = description;
}
const billingCycleField = productFields.billingCycle;
const billingRaw = billingCycleField ? safeRecord[billingCycleField] : undefined;
if (typeof billingRaw === "string") {
base.billingCycle = billingRaw;
}
const displayOrderField = productFields.displayOrder;
const displayOrderRaw = displayOrderField ? safeRecord[displayOrderField] : undefined;
if (typeof displayOrderRaw === "number") {
base.displayOrder = displayOrderRaw;
}
return base;
}
function extractArray(raw: unknown): string[] | undefined {
if (Array.isArray(raw)) {
return raw.filter((value): value is string => typeof value === "string");
}
if (typeof raw === "string") {
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? extractArray(parsed) : undefined;
} catch {
return undefined;
}
}
return undefined;
}
export function mapInternetProduct(
product: SalesforceProduct2Record,
pricebookEntry?: SalesforcePricebookEntryRecord
): InternetCatalogProduct {
const base = baseProduct(product);
const tierField = productFields.internetPlanTier;
const offeringTypeField = productFields.internetOfferingType;
const tier = tierField ? (product as Record<string, unknown>)[tierField] : undefined;
const offeringType = offeringTypeField
? (product as Record<string, unknown>)[offeringTypeField]
: undefined;
const rawFeatures = productFields.featureList
? (product as Record<string, unknown>)[productFields.featureList]
: undefined;
const features = extractArray(rawFeatures);
const monthlyPrice = getMonthlyPrice(product, pricebookEntry);
const oneTimePrice = getOneTimePrice(product, pricebookEntry);
return {
...base,
internetPlanTier: typeof tier === "string" ? tier : undefined,
internetOfferingType: typeof offeringType === "string" ? offeringType : undefined,
features,
monthlyPrice,
oneTimePrice,
};
}
const tierTemplates: Record<string, InternetPlanTemplate> = {
Silver: {
tierDescription: "Simple package with broadband-modem and ISP only",
description: "Simple package with broadband-modem and ISP only",
features: [
"NTT modem + ISP connection",
"Two ISP connection protocols: IPoE (recommended) or PPPoE",
"Self-configuration of router (you provide your own)",
"Monthly: ¥6,000 | One-time: ¥22,800",
],
},
Gold: {
tierDescription: "Standard all-inclusive package with basic Wi-Fi",
description: "Standard all-inclusive package with basic Wi-Fi",
features: [
"NTT modem + wireless router (rental)",
"ISP (IPoE) configured automatically within 24 hours",
"Basic wireless router included",
"Optional: TP-LINK RE650 range extender (¥500/month)",
"Monthly: ¥6,500 | One-time: ¥22,800",
],
},
Platinum: {
tierDescription: "Tailored set up with premier Wi-Fi management support",
description:
"Tailored set up with premier Wi-Fi management support - Recommended for homes & apartments larger than 50m²",
features: [
"NTT modem + Netgear INSIGHT Wi-Fi routers",
"Cloud management support for remote router management",
"Automatic updates and quicker support",
"Seamless wireless network setup",
"Monthly: ¥6,500 | One-time: ¥22,800",
"Cloud management: ¥500/month per router",
],
},
};
function getTierTemplate(tier?: string): InternetPlanTemplate {
if (!tier) return tierTemplates.Silver;
return tierTemplates[tier] ?? tierTemplates.Silver;
}
export function mapInternetPlan(
product: SalesforceProduct2Record,
pricebookEntry?: SalesforcePricebookEntryRecord
): InternetPlanCatalogItem {
const mapped = mapInternetProduct(product, pricebookEntry);
const tierData = getTierTemplate(mapped.internetPlanTier);
return {
...mapped,
description: mapped.description ?? tierData.description,
catalogMetadata: {
tierDescription: tierData.tierDescription,
features: tierData.features,
isRecommended: mapped.internetPlanTier === "Gold",
},
};
}
export function mapInternetInstallation(
product: SalesforceProduct2Record,
pricebookEntry?: SalesforcePricebookEntryRecord
): InternetInstallationCatalogItem {
const mapped = mapInternetProduct(product, pricebookEntry);
return {
...mapped,
catalogMetadata: {
installationTerm: inferInstallationTypeFromSku(mapped.sku),
},
displayOrder: mapped.displayOrder,
};
}
export function mapInternetAddon(
product: SalesforceProduct2Record,
pricebookEntry?: SalesforcePricebookEntryRecord
): InternetAddonCatalogItem {
const mapped = mapInternetProduct(product, pricebookEntry);
return {
...mapped,
catalogMetadata: {
addonCategory: inferAddonTypeFromSku(mapped.sku),
autoAdd: false,
requiredWith: [],
},
displayOrder: mapped.displayOrder,
};
}
export function mapSimProduct(
product: SalesforceProduct2Record,
pricebookEntry?: SalesforcePricebookEntryRecord
): SimCatalogProduct {
const base = baseProduct(product);
const dataSizeField = productFields.simDataSize;
const planTypeField = productFields.simPlanType;
const familyDiscountField = productFields.simHasFamilyDiscount;
const dataSize = dataSizeField ? (product as Record<string, unknown>)[dataSizeField] : undefined;
const planType = planTypeField ? (product as Record<string, unknown>)[planTypeField] : undefined;
const familyDiscount = familyDiscountField
? (product as Record<string, unknown>)[familyDiscountField]
: undefined;
const monthlyPrice = getMonthlyPrice(product, pricebookEntry);
const oneTimePrice = getOneTimePrice(product, pricebookEntry);
return {
...base,
simDataSize: typeof dataSize === "string" ? dataSize : undefined,
simPlanType: typeof planType === "string" ? planType : undefined,
simHasFamilyDiscount: typeof familyDiscount === "boolean" ? familyDiscount : undefined,
monthlyPrice,
oneTimePrice,
};
}
export function mapSimActivationFee(
product: SalesforceProduct2Record,
pricebookEntry?: SalesforcePricebookEntryRecord
): SimActivationFeeCatalogItem {
const mapped = mapSimProduct(product, pricebookEntry);
return {
...mapped,
catalogMetadata: {
isDefault: true,
},
};
}
export function mapVpnProduct(
product: SalesforceProduct2Record,
pricebookEntry?: SalesforcePricebookEntryRecord
): VpnCatalogProduct {
const base = baseProduct(product);
const regionField = productFields.vpnRegion;
const region = regionField ? (product as Record<string, unknown>)[regionField] : undefined;
const monthlyPrice = getMonthlyPrice(product, pricebookEntry);
const oneTimePrice = getOneTimePrice(product, pricebookEntry);
return {
...base,
vpnRegion: typeof region === "string" ? region : undefined,
monthlyPrice,
oneTimePrice,
};
}

View File

@ -0,0 +1,64 @@
import type { SalesforceProduct2Record, SalesforcePricebookEntryRecord } from "@customer-portal/domain";
import { getSalesforceFieldMap } from "@bff/core/config/field-map";
const fields = getSalesforceFieldMap().product;
function coerceNumber(value: unknown): number | undefined {
if (typeof value === "number") return value;
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
export function getUnitPrice(
product: SalesforceProduct2Record,
pricebookEntry?: SalesforcePricebookEntryRecord
): number | undefined {
const entryPrice = coerceNumber(pricebookEntry?.UnitPrice);
if (entryPrice !== undefined) return entryPrice;
const productPrice = coerceNumber((product as Record<string, unknown>)[fields.unitPrice]);
if (productPrice !== undefined) return productPrice;
const monthlyPrice = coerceNumber((product as Record<string, unknown>)[fields.monthlyPrice]);
if (monthlyPrice !== undefined) return monthlyPrice;
const oneTimePrice = coerceNumber((product as Record<string, unknown>)[fields.oneTimePrice]);
if (oneTimePrice !== undefined) return oneTimePrice;
return undefined;
}
export function getMonthlyPrice(
product: SalesforceProduct2Record,
pricebookEntry?: SalesforcePricebookEntryRecord
): number | undefined {
const unitPrice = getUnitPrice(product, pricebookEntry);
if (!unitPrice) return undefined;
const billingCycle = (product as Record<string, unknown>)[fields.billingCycle];
if (typeof billingCycle === "string" && billingCycle.toLowerCase() === "monthly") {
return unitPrice;
}
const monthlyPrice = coerceNumber((product as Record<string, unknown>)[fields.monthlyPrice]);
return monthlyPrice ?? undefined;
}
export function getOneTimePrice(
product: SalesforceProduct2Record,
pricebookEntry?: SalesforcePricebookEntryRecord
): number | undefined {
const unitPrice = getUnitPrice(product, pricebookEntry);
const billingCycle = (product as Record<string, unknown>)[fields.billingCycle];
if (typeof billingCycle === "string" && billingCycle.toLowerCase() !== "monthly") {
return unitPrice;
}
const oneTimePrice = coerceNumber((product as Record<string, unknown>)[fields.oneTimePrice]);
return oneTimePrice ?? undefined;
}

View File

@ -1,10 +0,0 @@
import { Module } from "@nestjs/common";
import { JobsService } from "./jobs.service";
import { ReconcileProcessor } from "./reconcile.processor";
@Module({
imports: [],
providers: [JobsService, ReconcileProcessor],
exports: [JobsService],
})
export class JobsModule {}

View File

@ -1,6 +0,0 @@
import { Injectable } from "@nestjs/common";
@Injectable()
export class JobsService {
// TODO: Implement job service logic
}

View File

@ -1,22 +0,0 @@
import { Processor, WorkerHost } from "@nestjs/bullmq";
import { Logger } from "@nestjs/common";
import { Job } from "bullmq";
import { QUEUE_NAMES } from "@bff/infra/queue/queue.constants";
@Processor(QUEUE_NAMES.RECONCILE)
export class ReconcileProcessor extends WorkerHost {
private readonly logger = new Logger(ReconcileProcessor.name);
async process(job: Job) {
this.logger.warn(
`Skipping reconciliation job while JobsModule is temporarily disabled`,
{
jobId: job.id,
name: job.name,
attemptsMade: job.attemptsMade,
},
);
return { status: "skipped", reason: "jobs_module_disabled" };
}
}

View File

@ -1,9 +1,9 @@
import { Body, Controller, Get, Param, Post, Request } from "@nestjs/common";
import { Body, Controller, Get, Param, Post, Request, UsePipes } from "@nestjs/common";
import { OrderOrchestrator } from "./services/order-orchestrator.service";
import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from "@nestjs/swagger";
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
import { Logger } from "nestjs-pino";
import { ZodPipe } from "@bff/core/validation";
import { ZodValidationPipe } from "@bff/core/validation";
import {
createOrderRequestSchema,
type CreateOrderRequest
@ -19,10 +19,11 @@ export class OrdersController {
@ApiBearerAuth()
@Post()
@UsePipes(new ZodValidationPipe(createOrderRequestSchema))
@ApiOperation({ summary: "Create Salesforce Order" })
@ApiResponse({ status: 201, description: "Order created successfully" })
@ApiResponse({ status: 400, description: "Invalid request data" })
async create(@Request() req: RequestWithUser, @Body(ZodPipe(createOrderRequestSchema)) body: CreateOrderRequest) {
async create(@Request() req: RequestWithUser, @Body() body: CreateOrderRequest) {
this.logger.log(
{
userId: req.user?.id,

View File

@ -4,7 +4,7 @@ import { SalesforceService } from "@bff/integrations/salesforce/salesforce.servi
import { WhmcsPaymentService } from "@bff/integrations/whmcs/services/whmcs-payment.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
import { SalesforceOrder } from "@customer-portal/domain";
import type { SalesforceOrderRecord } from "@customer-portal/domain";
import { getSalesforceFieldMap } from "@bff/core/config/field-map";
import {
userMappingValidationSchema,
@ -14,7 +14,7 @@ import {
} from "@customer-portal/domain";
export interface OrderFulfillmentValidationResult {
sfOrder: SalesforceOrder;
sfOrder: SalesforceOrderRecord;
clientId: number;
isAlreadyProvisioned: boolean;
whmcsOrderId?: string;
@ -106,7 +106,7 @@ export class OrderFulfillmentValidator {
/**
* Validate Salesforce order exists and is in valid state
*/
private async validateSalesforceOrder(sfOrderId: string): Promise<SalesforceOrder> {
private async validateSalesforceOrder(sfOrderId: string): Promise<SalesforceOrderRecord> {
const order = await this.salesforceService.getOrder(sfOrderId);
if (!order) {

View File

@ -2,6 +2,11 @@ import { Injectable, BadRequestException, NotFoundException, Inject } from "@nes
import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
import { getSalesforceFieldMap } from "@bff/core/config/field-map";
import type {
SalesforcePricebookEntryRecord,
SalesforceProduct2Record,
SalesforceQueryResult,
} from "@customer-portal/domain";
/**
* Handles building order items from SKU data
@ -75,17 +80,16 @@ export class OrderItemBuilder {
const soql = `SELECT Id, Name FROM Pricebook2 WHERE IsActive = true AND Name LIKE '%${name}%' LIMIT 1`;
try {
const result = (await this.sf.query(soql)) as { records?: Array<{ Id?: string }> };
const result = (await this.sf.query(soql)) as SalesforceQueryResult<{ Id?: string }>;
if (result.records?.length) {
return result.records[0].Id || "";
return result.records[0]?.Id ?? "";
}
// fallback to Standard Price Book
const std = (await this.sf.query(
"SELECT Id FROM Pricebook2 WHERE IsStandard = true AND IsActive = true LIMIT 1"
)) as { records?: Array<{ Id?: string }> };
)) as SalesforceQueryResult<{ Id?: string }>;
return std.records?.[0]?.Id || "";
return std.records?.[0]?.Id ?? "";
} catch (error) {
this.logger.error({ error }, "Failed to find pricebook");
throw new NotFoundException("Portal pricebook not found or inactive");
@ -125,20 +129,17 @@ export class OrderItemBuilder {
try {
this.logger.debug({ sku, pricebookId }, "Querying PricebookEntry for SKU");
const res = (await this.sf.query(soql)) as {
records?: Array<{
Id?: string;
Product2Id?: string;
UnitPrice?: number;
Product2?: Record<string, unknown>;
}>;
};
const res = (await this.sf.query(soql)) as SalesforceQueryResult<
SalesforcePricebookEntryRecord & {
Product2?: SalesforceProduct2Record | null;
}
>;
this.logger.debug(
{
sku,
found: !!res.records?.length,
hasPrice: !!(res.records?.[0] as { UnitPrice?: number })?.UnitPrice,
hasPrice: res.records?.[0]?.UnitPrice != null,
},
"PricebookEntry query result"
);
@ -146,33 +147,31 @@ export class OrderItemBuilder {
const rec = res.records?.[0];
if (!rec?.Id) return null;
const product2 = rec.Product2 ?? null;
return {
pbeId: rec.Id,
product2Id: rec.Product2Id || "",
unitPrice: rec.UnitPrice,
product2Id: rec.Product2Id ?? "",
unitPrice: typeof rec.UnitPrice === "number" ? rec.UnitPrice : undefined,
itemClass: (() => {
const value = ((rec as Record<string, unknown>).Product2 as Record<string, unknown>)?.[
fields.product.itemClass
];
return typeof value === "string" ? value : "";
const value = product2 ? (product2 as Record<string, unknown>)[fields.product.itemClass] : undefined;
return typeof value === "string" ? value : undefined;
})(),
internetOfferingType: (() => {
const value = ((rec as Record<string, unknown>).Product2 as Record<string, unknown>)?.[
fields.product.internetOfferingType
];
return typeof value === "string" ? value : "";
const value = product2
? (product2 as Record<string, unknown>)[fields.product.internetOfferingType]
: undefined;
return typeof value === "string" ? value : undefined;
})(),
internetPlanTier: (() => {
const value = ((rec as Record<string, unknown>).Product2 as Record<string, unknown>)?.[
fields.product.internetPlanTier
];
return typeof value === "string" ? value : "";
const value = product2
? (product2 as Record<string, unknown>)[fields.product.internetPlanTier]
: undefined;
return typeof value === "string" ? value : undefined;
})(),
vpnRegion: (() => {
const value = ((rec as Record<string, unknown>).Product2 as Record<string, unknown>)?.[
fields.product.vpnRegion
];
return typeof value === "string" ? value : "";
const value = product2 ? (product2 as Record<string, unknown>)[fields.product.vpnRegion] : undefined;
return typeof value === "string" ? value : undefined;
})(),
};
} catch (error) {

View File

@ -4,19 +4,24 @@ import { SalesforceConnection } from "@bff/integrations/salesforce/services/sale
import { OrderValidator } from "./order-validator.service";
import { OrderBuilder } from "./order-builder.service";
import { OrderItemBuilder } from "./order-item-builder.service";
import {
SalesforceOrder,
SalesforceOrderItem,
import type {
SalesforceOrderRecord,
SalesforceOrderItemRecord,
SalesforceProduct2Record,
SalesforceQueryResult,
fromSalesforceAPI,
fromSalesforceOrderItemAPI,
} from "@customer-portal/domain";
import { OrderDetailsDto, OrderSummaryDto } from "../types/order-details.dto";
import {
orderDetailsSchema,
orderSummarySchema,
type OrderDetailsResponse,
type OrderSummaryResponse,
} from "@customer-portal/domain/validation/api/responses";
import {
getSalesforceFieldMap,
getOrderQueryFields,
getOrderItemProduct2Select,
} from "@bff/core/config/field-map";
import type { SalesforceFieldMap } from "@bff/core/config/field-map";
/**
* Main orchestrator for order operations
@ -52,7 +57,7 @@ export class OrderOrchestrator {
);
// 2) Build order fields (includes address snapshot)
const orderFieldsInput = { ...validatedBody, userId: userId as any };
const orderFieldsInput = { ...validatedBody, userId };
const orderFields = await this.orderBuilder.buildOrderFields(
orderFieldsInput,
userMapping,
@ -103,7 +108,7 @@ export class OrderOrchestrator {
/**
* Get order by ID with order items
*/
async getOrder(orderId: string): Promise<OrderDetailsDto | null> {
async getOrder(orderId: string): Promise<OrderDetailsResponse | null> {
this.logger.log({ orderId }, "Fetching order details with items");
const fields = getSalesforceFieldMap();
@ -126,8 +131,10 @@ export class OrderOrchestrator {
try {
const [orderResult, itemsResult] = await Promise.all([
this.sf.query(orderSoql) as Promise<SalesforceQueryResult<SalesforceOrder>>,
this.sf.query(orderItemsSoql) as Promise<SalesforceQueryResult<SalesforceOrderItem>>,
this.sf.query(orderSoql) as Promise<SalesforceQueryResult<SalesforceOrderRecord>>,
this.sf.query(orderItemsSoql) as Promise<
SalesforceQueryResult<SalesforceOrderItemRecord>
>,
]);
const order = orderResult.records?.[0];
@ -137,23 +144,9 @@ export class OrderOrchestrator {
return null;
}
const orderItems = (itemsResult.records || []).map((item: any) => {
const domainItem = fromSalesforceOrderItemAPI(item);
return {
id: domainItem.id,
quantity: domainItem.quantity,
unitPrice: domainItem.unitPrice,
totalPrice: domainItem.totalPrice,
product: {
id: domainItem.pricebookEntry.product2.id,
name: domainItem.pricebookEntry.product2.name,
sku: domainItem.pricebookEntry.product2.sku,
whmcsProductId: domainItem.pricebookEntry.product2.whmcsProductId?.toString() || "",
itemClass: domainItem.pricebookEntry.product2.itemClass,
billingCycle: domainItem.pricebookEntry.product2.billingCycle,
},
};
});
const orderItems = (itemsResult.records ?? []).map(item =>
mapOrderItemForDetails(item, fields)
);
this.logger.log(
{ orderId, itemCount: orderItems.length },
@ -161,44 +154,26 @@ export class OrderOrchestrator {
);
// Transform raw Salesforce data to domain types
const domainOrder = fromSalesforceAPI(order);
const domainOrderItems = orderItems.map(item => {
const domainItem = fromSalesforceOrderItemAPI(item);
// Transform to DTO format
return {
id: domainItem.id,
quantity: domainItem.quantity,
unitPrice: domainItem.unitPrice,
totalPrice: domainItem.totalPrice,
product: {
id: domainItem.pricebookEntry.product2.id,
name: domainItem.pricebookEntry.product2.name,
sku: domainItem.pricebookEntry.product2.sku,
whmcsProductId: domainItem.pricebookEntry.product2.whmcsProductId?.toString() || "",
category: domainItem.pricebookEntry.product2.category,
itemClass: domainItem.pricebookEntry.product2.itemClass,
billingCycle: domainItem.pricebookEntry.product2.billingCycle,
}
};
});
const domainOrder = order;
const domainOrderItems = orderItems.map(item => mapOrderItemForSummary(item, fields));
return {
id: domainOrder.id,
orderNumber: domainOrder.orderNumber,
status: domainOrder.status,
accountId: domainOrder.accountId,
orderType: domainOrder.orderType || domainOrder.type,
effectiveDate: domainOrder.effectiveDate,
totalAmount: domainOrder.totalAmount,
accountName: domainOrder.account?.name,
createdDate: domainOrder.createdDate,
lastModifiedDate: domainOrder.lastModifiedDate,
return orderDetailsSchema.parse({
id: domainOrder.Id,
orderNumber: domainOrder.OrderNumber,
status: domainOrder.Status,
accountId: domainOrder.AccountId,
orderType: (domainOrder as any).OrderType || (domainOrder as any).Type,
effectiveDate: domainOrder.EffectiveDate,
totalAmount: domainOrder.TotalAmount,
accountName: order.Account?.Name,
createdDate: domainOrder.CreatedDate,
lastModifiedDate: domainOrder.LastModifiedDate,
activationType: (order as any)[fields.order.activationType],
activationStatus: domainOrder.activationStatus,
activationStatus: (order as any)[fields.order.activationStatus],
scheduledAt: (order as any)[fields.order.activationScheduledAt],
whmcsOrderId: domainOrder.whmcsOrderId,
whmcsOrderId: (order as any)[fields.order.whmcsOrderId],
items: domainOrderItems,
};
});
} catch (error) {
this.logger.error({ error, orderId }, "Failed to fetch order with items");
throw error;
@ -208,7 +183,7 @@ export class OrderOrchestrator {
/**
* Get orders for a user with basic item summary
*/
async getOrdersForUser(userId: string): Promise<OrderSummaryDto[]> {
async getOrdersForUser(userId: string): Promise<OrderSummaryResponse[]> {
this.logger.log({ userId }, "Fetching user orders with item summaries");
// Get user mapping
@ -224,9 +199,9 @@ export class OrderOrchestrator {
`;
try {
const ordersResult = (await this.sf.query(
ordersSoql
)) as SalesforceQueryResult<SalesforceOrder>;
const ordersResult = (await this.sf.query(
ordersSoql
)) as SalesforceQueryResult<SalesforceOrderRecord>;
const orders = ordersResult.records || [];
if (orders.length === 0) {
@ -245,22 +220,21 @@ export class OrderOrchestrator {
const itemsResult = (await this.sf.query(
itemsSoql
)) as SalesforceQueryResult<SalesforceOrderItem>;
)) as SalesforceQueryResult<SalesforceOrderItemRecord>;
const allItems = itemsResult.records || [];
// Transform order items to domain types and group by order ID
const domainOrderItems = allItems.map((item: any) => fromSalesforceOrderItemAPI(item));
const itemsByOrder = domainOrderItems.reduce(
const itemsByOrder = allItems.reduce(
(acc, item) => {
if (!acc[item.orderId]) acc[item.orderId] = [];
acc[item.orderId].push({
name: item.pricebookEntry.product2.name,
sku: item.pricebookEntry.product2.sku,
itemClass: item.pricebookEntry.product2.itemClass,
quantity: item.quantity,
unitPrice: item.unitPrice,
totalPrice: item.totalPrice,
billingCycle: item.billingCycle || item.pricebookEntry.product2.billingCycle,
const mapped = mapOrderItemForSummary(item, fields);
if (!acc[mapped.orderId]) acc[mapped.orderId] = [];
acc[mapped.orderId].push({
name: mapped.product.name,
sku: mapped.product.sku,
itemClass: mapped.product.itemClass,
quantity: mapped.quantity,
unitPrice: mapped.unitPrice,
totalPrice: mapped.totalPrice,
billingCycle: mapped.billingCycle,
});
return acc;
},
@ -279,21 +253,20 @@ export class OrderOrchestrator {
);
// Transform orders to domain types and return summary
return orders.map((order: any) => {
const domainOrder = fromSalesforceAPI(order);
return {
id: domainOrder.id,
orderNumber: domainOrder.orderNumber,
status: domainOrder.status,
orderType: domainOrder.orderType || domainOrder.type,
effectiveDate: domainOrder.effectiveDate,
totalAmount: domainOrder.totalAmount,
createdDate: domainOrder.createdDate,
lastModifiedDate: domainOrder.lastModifiedDate,
whmcsOrderId: domainOrder.whmcsOrderId,
itemsSummary: itemsByOrder[domainOrder.id] || [],
};
});
return orders.map(order =>
orderSummarySchema.parse({
id: order.Id,
orderNumber: order.OrderNumber,
status: order.Status,
orderType: (order as any).OrderType || order.Type,
effectiveDate: order.EffectiveDate,
totalAmount: order.TotalAmount ?? 0,
createdDate: order.CreatedDate,
lastModifiedDate: order.LastModifiedDate,
whmcsOrderId: (order as any).WhmcsOrderId,
itemsSummary: itemsByOrder[order.Id] || [],
})
);
} catch (error) {
this.logger.error({ error, userId }, "Failed to fetch user orders with items");
throw error;

View File

@ -1,54 +0,0 @@
export interface OrderItemProductDto {
id?: string;
name?: string;
sku: string;
whmcsProductId: string;
itemClass: string;
billingCycle: string;
}
export interface OrderItemDto {
id: string;
quantity: number;
unitPrice: number;
totalPrice: number;
product: OrderItemProductDto;
}
export interface OrderDetailsDto {
id: string;
orderNumber: string;
status: string;
orderType?: string;
effectiveDate: string;
totalAmount: number;
accountId?: string;
accountName?: string;
createdDate: string;
lastModifiedDate: string;
activationType?: string;
activationStatus?: string;
scheduledAt?: string;
whmcsOrderId?: string;
items: OrderItemDto[];
}
export interface OrderSummaryItemDto {
name?: string;
sku?: string;
itemClass?: string;
quantity: number;
}
export interface OrderSummaryDto {
id: string;
orderNumber: string;
status: string;
orderType?: string;
effectiveDate: string;
totalAmount: number;
createdDate: string;
lastModifiedDate: string;
whmcsOrderId?: string;
itemsSummary: OrderSummaryItemDto[];
}

View File

@ -8,6 +8,7 @@ import {
Request,
ParseIntPipe,
BadRequestException,
UsePipes,
} from "@nestjs/common";
import {
ApiTags,
@ -35,7 +36,7 @@ import {
type SimCancelRequest,
type SimFeaturesRequest
} from "@customer-portal/domain";
import { ZodPipe } from "@bff/core/validation";
import { ZodValidationPipe } from "@bff/core/validation";
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
@ApiTags("subscriptions")
@ -272,6 +273,7 @@ export class SubscriptionsController {
}
@Post(":id/sim/top-up")
@UsePipes(new ZodValidationPipe(simTopupRequestSchema))
@ApiOperation({
summary: "Top up SIM data quota",
description: "Add data quota to the SIM service",
@ -291,13 +293,14 @@ export class SubscriptionsController {
async topUpSim(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,
@Body(ZodPipe(simTopupRequestSchema)) body: SimTopupRequest
@Body() body: SimTopupRequest
) {
await this.simManagementService.topUpSim(req.user.id, subscriptionId, body);
return { success: true, message: "SIM top-up completed successfully" };
}
@Post(":id/sim/change-plan")
@UsePipes(new ZodValidationPipe(simChangePlanRequestSchema))
@ApiOperation({
summary: "Change SIM plan",
description:
@ -318,7 +321,7 @@ export class SubscriptionsController {
async changeSimPlan(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,
@Body(ZodPipe(simChangePlanRequestSchema)) body: SimChangePlanRequest
@Body() body: SimChangePlanRequest
) {
const result = await this.simManagementService.changeSimPlan(req.user.id, subscriptionId, body);
return {
@ -329,6 +332,7 @@ export class SubscriptionsController {
}
@Post(":id/sim/cancel")
@UsePipes(new ZodValidationPipe(simCancelRequestSchema))
@ApiOperation({
summary: "Cancel SIM service",
description: "Cancel the SIM service (immediate or scheduled)",
@ -352,7 +356,7 @@ export class SubscriptionsController {
async cancelSim(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,
@Body(ZodPipe(simCancelRequestSchema)) body: SimCancelRequest
@Body() body: SimCancelRequest
) {
await this.simManagementService.cancelSim(req.user.id, subscriptionId, body);
return { success: true, message: "SIM cancellation completed successfully" };
@ -391,6 +395,7 @@ export class SubscriptionsController {
}
@Post(":id/sim/features")
@UsePipes(new ZodValidationPipe(simFeaturesRequestSchema))
@ApiOperation({
summary: "Update SIM features",
description:
@ -413,7 +418,7 @@ export class SubscriptionsController {
async updateSimFeatures(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,
@Body(ZodPipe(simFeaturesRequestSchema)) body: SimFeaturesRequest
@Body() body: SimFeaturesRequest
) {
await this.simManagementService.updateSimFeatures(req.user.id, subscriptionId, body);
return { success: true, message: "SIM features updated successfully" };

View File

@ -6,10 +6,11 @@ import {
Req,
UseInterceptors,
ClassSerializerInterceptor,
UsePipes,
} from "@nestjs/common";
import { UsersService } from "./users.service";
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from "@nestjs/swagger";
import { ZodPipe } from "@bff/core/validation";
import { ZodValidationPipe } from "@bff/core/validation";
import {
updateProfileRequestSchema,
updateAddressRequestSchema,
@ -42,11 +43,12 @@ export class UsersController {
}
@Patch()
@UsePipes(new ZodValidationPipe(updateProfileRequestSchema))
@ApiOperation({ summary: "Update user profile" })
@ApiResponse({ status: 200, description: "Profile updated successfully" })
@ApiResponse({ status: 400, description: "Invalid input data" })
@ApiResponse({ status: 401, description: "Unauthorized" })
async updateProfile(@Req() req: RequestWithUser, @Body(ZodPipe(updateProfileRequestSchema)) updateData: UpdateProfileRequest) {
async updateProfile(@Req() req: RequestWithUser, @Body() updateData: UpdateProfileRequest) {
return this.usersService.update(req.user.id, updateData);
}
@ -61,11 +63,12 @@ export class UsersController {
// Removed PATCH /me/billing in favor of PATCH /me/address to keep address updates explicit.
@Patch("address")
@UsePipes(new ZodValidationPipe(updateAddressRequestSchema))
@ApiOperation({ summary: "Update mailing address" })
@ApiResponse({ status: 200, description: "Address updated successfully" })
@ApiResponse({ status: 400, description: "Invalid input data" })
@ApiResponse({ status: 401, description: "Unauthorized" })
async updateAddress(@Req() req: RequestWithUser, @Body(ZodPipe(updateAddressRequestSchema)) address: UpdateAddressRequest) {
async updateAddress(@Req() req: RequestWithUser, @Body() address: UpdateAddressRequest) {
await this.usersService.updateAddress(req.user.id, address);
// Return fresh address snapshot
return this.usersService.getAddress(req.user.id);

View File

@ -4,7 +4,11 @@ import {
mapPrismaUserToSharedUser,
mapPrismaUserToEnhancedBase,
} from "@bff/infra/utils/user-mapper.util";
import type { UpdateAddressRequest } from "@customer-portal/domain";
import type {
UpdateAddressRequest,
SalesforceAccountRecord,
SalesforceContactRecord,
} from "@customer-portal/domain";
import { Injectable, Inject, NotFoundException, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { PrismaService } from "@bff/infra/database/prisma.service";
@ -17,9 +21,7 @@ import { SalesforceService } from "@bff/integrations/salesforce/salesforce.servi
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
// Salesforce Account interface based on the data model
interface SalesforceAccount {
Id: string;
}
interface SalesforceAccount extends SalesforceAccountRecord {}
// Use a subset of PrismaUser for updates
type UserUpdateData = Partial<Pick<PrismaUser, 'firstName' | 'lastName' | 'company' | 'phone' | 'passwordHash' | 'failedLoginAttempts' | 'lastLoginAt' | 'lockedUntil'>>;

View File

@ -1,5 +1,5 @@
import { apiClient, getDataOrThrow, getNullableData } from "@/lib/api";
import type { Address, AuthUser } from "@customer-portal/domain";
import type { Address, UserProfile } from "@customer-portal/domain";
type ProfileUpdateInput = {
firstName?: string;
@ -10,12 +10,12 @@ type ProfileUpdateInput = {
export const accountService = {
async getProfile() {
const response = await apiClient.GET('/api/me');
return getNullableData<AuthUser>(response);
return getNullableData<UserProfile>(response);
},
async updateProfile(update: ProfileUpdateInput) {
const response = await apiClient.PATCH('/api/me', { body: update });
return getDataOrThrow<AuthUser>(response, "Failed to update profile");
return getDataOrThrow<UserProfile>(response, "Failed to update profile");
},
async getAddress() {

View File

@ -211,14 +211,14 @@ export function usePermissions() {
const hasRole = useCallback(
(role: string) => {
return user?.roles.some(r => r.name === role) || false;
return user?.roles?.some(r => r.name === role) ?? false;
},
[user]
);
const hasPermission = useCallback(
(resource: string, action: string) => {
return user?.permissions.some(p => p.resource === resource && p.action === action) || false;
return user?.permissions?.some(p => p.resource === resource && p.action === action) ?? false;
},
[user]
);

View File

@ -10,11 +10,12 @@ import { getErrorInfo, handleAuthError } from "@/lib/utils/error-handling";
import logger from "@customer-portal/logging";
import type {
AuthTokens,
AuthUser,
UserProfile,
LinkWhmcsRequestData,
LoginRequest,
SignupRequest,
} from "@customer-portal/domain";
import { authResponseSchema } from "@customer-portal/domain/validation";
// Create API client instance
const apiClient = createClient({
@ -23,7 +24,7 @@ const apiClient = createClient({
interface AuthState {
// State
user: AuthUser | null;
user: UserProfile | null;
tokens: AuthTokens | null;
isAuthenticated: boolean;
loading: boolean;
@ -72,13 +73,13 @@ export const useAuthStore = create<AuthState>()(
});
const response = await client.POST('/api/auth/login', { body: credentials });
if (!response.data) {
throw new Error('Login failed');
const parsed = authResponseSchema.safeParse(response.data);
if (!parsed.success) {
throw new Error(parsed.error.issues?.[0]?.message ?? 'Login failed');
}
const { user, tokens } = response.data as { user: AuthUser; tokens: AuthTokens };
const { user, tokens } = parsed.data;
set({
user,
tokens,
@ -104,13 +105,13 @@ export const useAuthStore = create<AuthState>()(
});
const response = await client.POST('/api/auth/signup', { body: data });
if (!response.data) {
throw new Error('Signup failed');
const parsed = authResponseSchema.safeParse(response.data);
if (!parsed.success) {
throw new Error(parsed.error.issues?.[0]?.message ?? 'Signup failed');
}
const { user, tokens } = response.data as { user: AuthUser; tokens: AuthTokens };
const { user, tokens } = parsed.data;
set({
user,
tokens,
@ -184,16 +185,16 @@ export const useAuthStore = create<AuthState>()(
baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000",
});
const response = await client.POST('/api/auth/reset-password', {
body: { token, password }
const response = await client.POST('/api/auth/reset-password', {
body: { token, password }
});
if (!response.data) {
throw new Error('Password reset failed');
const parsed = authResponseSchema.safeParse(response.data);
if (!parsed.success) {
throw new Error(parsed.error.issues?.[0]?.message ?? 'Password reset failed');
}
const { user, tokens } = response.data as { user: AuthUser; tokens: AuthTokens };
const { user, tokens } = parsed.data;
set({
user,
tokens,
@ -299,16 +300,16 @@ export const useAuthStore = create<AuthState>()(
baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000",
});
const response = await client.POST('/api/auth/set-password', {
body: { email, password }
const response = await client.POST('/api/auth/set-password', {
body: { email, password }
});
if (!response.data) {
throw new Error('Set password failed');
const parsed = authResponseSchema.safeParse(response.data);
if (!parsed.success) {
throw new Error(parsed.error.issues?.[0]?.message ?? 'Set password failed');
}
const { user, tokens } = response.data as { user: AuthUser; tokens: AuthTokens };
const { user, tokens } = parsed.data;
set({
user,
tokens,
@ -364,18 +365,19 @@ export const useAuthStore = create<AuthState>()(
baseUrl: process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000",
});
const response = await client.POST('/api/auth/refresh', {
body: {
const response = await client.POST('/api/auth/refresh', {
body: {
refreshToken: tokens.refreshToken,
deviceId: localStorage.getItem('deviceId') || undefined,
}
}
});
if (!response.data) {
throw new Error('Token refresh failed');
const parsed = authResponseSchema.safeParse(response.data);
if (!parsed.success) {
throw new Error(parsed.error.issues?.[0]?.message ?? 'Token refresh failed');
}
const newTokens = response.data as AuthTokens;
const { tokens: newTokens } = parsed.data;
set({ tokens: newTokens, isAuthenticated: true });
} catch (error) {
// Refresh failed, logout

View File

@ -1,17 +1,17 @@
"use client";
import type { InternetProduct } from "@customer-portal/domain";
import type { InternetInstallationCatalogItem } from "@customer-portal/domain";
import { getDisplayPrice } from "../../utils/pricing";
import { inferInstallationTypeFromSku, type InstallationType } from "../../utils/inferInstallationType";
interface InstallationOptionsProps {
installations: InternetProduct[];
installations: InternetInstallationCatalogItem[];
selectedInstallationSku: string | null;
onInstallationSelect: (installation: InternetProduct | null) => void;
onInstallationSelect: (installation: InternetInstallationCatalogItem | null) => void;
showSkus?: boolean;
}
function getCleanName(installation: InternetProduct, inferredType: InstallationType): string {
function getCleanName(installation: InternetInstallationCatalogItem, inferredType: InstallationType): string {
const baseName = installation.name.replace(/^(NTT\s*)?Installation\s*Fee\s*/i, "");
switch (inferredType) {
case "One-time":
@ -39,7 +39,7 @@ function getCleanDescription(inferredType: InstallationType, description: string
}
}
function getPriceLabel(installation: InternetProduct): string {
function getPriceLabel(installation: InternetInstallationCatalogItem): string {
const priceInfo = getDisplayPrice(installation);
if (!priceInfo) {
return "Price not available";

View File

@ -9,35 +9,35 @@ import { StepHeader } from "@/components/atoms";
import { AddonGroup } from "@/features/catalog/components/base/AddonGroup";
import { InstallationOptions } from "@/features/catalog/components/internet/InstallationOptions";
import { ServerIcon, ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
import type { InternetProduct } from "@customer-portal/domain";
import type {
InternetPlanCatalogItem,
InternetInstallationCatalogItem,
InternetAddonCatalogItem,
} from "@customer-portal/domain";
import type { AccessMode } from "../../hooks/useConfigureParams";
import { AlertBanner } from "@/components/molecules/AlertBanner";
import { getMonthlyPrice, getOneTimePrice } from "../../utils/pricing";
import { inferInstallationTypeFromSku } from "../../utils/inferInstallationType";
type Props = {
plan: InternetProduct | null;
interface Props {
plan: InternetPlanCatalogItem | null;
loading: boolean;
addons: InternetProduct[];
installations: InternetProduct[];
addons: InternetAddonCatalogItem[];
installations: InternetInstallationCatalogItem[];
mode: AccessMode | null;
setMode: (mode: AccessMode) => void;
selectedInstallation: InternetProduct | null;
selectedInstallation: InternetInstallationCatalogItem | null;
setSelectedInstallationSku: (sku: string | null) => void;
selectedInstallationType: string | null;
selectedAddonSkus: string[];
setSelectedAddonSkus: (skus: string[]) => void;
currentStep: number;
isTransitioning: boolean;
transitionToStep: (nextStep: number) => void;
monthlyTotal: number;
oneTimeTotal: number;
onConfirm: () => void;
};
}
export function InternetConfigureView({
plan,

View File

@ -4,15 +4,15 @@ import { AnimatedCard } from "@/components/molecules";
import { Button } from "@/components/atoms/button";
import { CurrencyYenIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
import type {
InternetPlan,
InternetInstallation,
} from "@/features/catalog/types/catalog.types";
InternetPlanCatalogItem,
InternetInstallationCatalogItem,
} from "@customer-portal/domain";
import { useRouter } from "next/navigation";
import { getMonthlyPrice, getOneTimePrice } from "../../utils/pricing";
interface InternetPlanCardProps {
plan: InternetPlan;
installations: InternetInstallation[];
plan: InternetPlanCatalogItem;
installations: InternetInstallationCatalogItem[];
disabled?: boolean;
disabledReason?: string;
}

View File

@ -16,44 +16,20 @@ import {
ExclamationTriangleIcon,
UsersIcon,
} from "@heroicons/react/24/outline";
import type { SimPlan, SimActivationFee, SimAddon } from "../../types/catalog.types";
import type { SimType, ActivationType, MnpData } from "@customer-portal/domain";
import type {
SimCatalogProduct,
SimActivationFeeCatalogItem,
} from "@customer-portal/domain";
type Props = {
plan: SimPlan | null;
activationFees: SimActivationFee[];
addons: SimAddon[];
interface Props {
plan: SimCatalogProduct | null;
loading: boolean;
simType: SimType;
setSimType: (value: SimType) => void;
eid: string;
setEid: (value: string) => void;
selectedAddons: string[];
setSelectedAddons: (value: string[]) => void;
activationType: ActivationType;
setActivationType: (value: ActivationType) => void;
scheduledActivationDate: string;
setScheduledActivationDate: (value: string) => void;
wantsMnp: boolean;
setWantsMnp: (value: boolean) => void;
mnpData: MnpData;
setMnpData: (value: MnpData) => void;
errors: Record<string, string | undefined>;
validate: () => boolean;
currentStep: number;
isTransitioning: boolean;
transitionToStep: (nextStep: number) => void;
monthlyTotal: number;
oneTimeTotal: number;
activationFees: SimActivationFeeCatalogItem[];
addons: SimCatalogProduct[];
selectedAddonSkus: string[];
onAddonChange: (addons: string[]) => void;
onConfirm: () => void;
};
}
export function SimConfigureView({
plan,

View File

@ -3,10 +3,15 @@
import { DevicePhoneMobileIcon, UsersIcon, CurrencyYenIcon } from "@heroicons/react/24/outline";
import { AnimatedCard } from "@/components/molecules/AnimatedCard";
import { Button } from "@/components/atoms/button";
import type { SimPlan } from "@/features/catalog/types/catalog.types";
import type { SimCatalogProduct } from "@customer-portal/domain";
import { getMonthlyPrice } from "../../utils/pricing";
export function SimPlanCard({ plan, isFamily }: { plan: SimPlan; isFamily?: boolean }) {
interface SimPlanCardProps {
plan: SimCatalogProduct;
onSelect: (plan: SimCatalogProduct) => void;
}
export function SimPlanCard({ plan, isFamily }: { plan: SimCatalogProduct; isFamily?: boolean }) {
const monthlyPrice = getMonthlyPrice(plan);
const isFamilyPlan = isFamily ?? Boolean(plan.simHasFamilyDiscount);

View File

@ -2,9 +2,15 @@
import React from "react";
import { UsersIcon } from "@heroicons/react/24/outline";
import type { SimPlan } from "@/features/catalog/types/catalog.types";
import type { SimCatalogProduct } from "@customer-portal/domain";
import { SimPlanCard } from "./SimPlanCard";
interface SimPlanTypeSectionProps {
plans: SimCatalogProduct[];
selectedPlanId: string | null;
onPlanSelect: (plan: SimCatalogProduct) => void;
}
export function SimPlanTypeSection({
title,
description,
@ -15,7 +21,7 @@ export function SimPlanTypeSection({
title: string;
description: string;
icon: React.ReactNode;
plans: SimPlan[];
plans: SimCatalogProduct[];
showFamilyDiscount: boolean;
}) {
if (plans.length === 0) return null;

View File

@ -3,21 +3,25 @@
import { useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useInternetCatalog, useInternetPlan, useInternetConfigureParams } from ".";
import type { InternetProduct } from "@customer-portal/domain";
import type {
InternetPlanCatalogItem,
InternetInstallationCatalogItem,
InternetAddonCatalogItem,
} from "@customer-portal/domain";
import { inferInstallationTypeFromSku } from "../utils/inferInstallationType";
import { getMonthlyPrice, getOneTimePrice } from "../utils/pricing";
type InternetAccessMode = "IPoE-BYOR" | "PPPoE";
export type UseInternetConfigureResult = {
plan: InternetProduct | null;
plan: InternetPlanCatalogItem | null;
loading: boolean;
addons: InternetProduct[];
installations: InternetProduct[];
addons: InternetAddonCatalogItem[];
installations: InternetInstallationCatalogItem[];
mode: InternetAccessMode | null;
setMode: (mode: InternetAccessMode) => void;
selectedInstallation: InternetProduct | null;
selectedInstallation: InternetInstallationCatalogItem | null;
setSelectedInstallationSku: (sku: string | null) => void;
selectedInstallationType: string | null;
selectedAddonSkus: string[];
@ -42,10 +46,10 @@ export function useInternetConfigure(): UseInternetConfigureResult {
const { plan: selectedPlan } = useInternetPlan(planSku || undefined);
const { accessMode, installationSku, addonSkus } = useInternetConfigureParams();
const [plan, setPlan] = useState<InternetProduct | null>(null);
const [plan, setPlan] = useState<InternetPlanCatalogItem | null>(null);
const [loading, setLoading] = useState(true);
const [addons, setAddons] = useState<InternetProduct[]>([]);
const [installations, setInstallations] = useState<InternetProduct[]>([]);
const [addons, setAddons] = useState<InternetAddonCatalogItem[]>([]);
const [installations, setInstallations] = useState<InternetInstallationCatalogItem[]>([]);
const [mode, setMode] = useState<InternetAccessMode | null>(null);
const [selectedInstallationSku, setSelectedInstallationSku] = useState<string | null>(null);
@ -70,7 +74,7 @@ export function useInternetConfigure(): UseInternetConfigureResult {
if (selectedPlan) {
setPlan(selectedPlan);
setAddons(addonsData);
setInstallations(installationsData);
setInstallations(installationsData);
if (accessMode) setMode(accessMode as InternetAccessMode);
if (installationSku) setSelectedInstallationSku(installationSku);

View File

@ -13,16 +13,15 @@ import {
type MnpData,
} from "@customer-portal/domain";
import type {
SimPlan,
SimAddon,
SimActivationFee,
} from "@/features/catalog/types/catalog.types";
SimCatalogProduct,
SimActivationFeeCatalogItem,
} from "@customer-portal/domain";
export type UseSimConfigureResult = {
// data
plan: SimPlan | null;
activationFees: SimActivationFee[];
addons: SimAddon[];
plan: SimCatalogProduct | null;
activationFees: SimActivationFeeCatalogItem[];
addons: SimCatalogProduct[];
loading: boolean;
// Zod form integration

View File

@ -1,59 +1,69 @@
import { apiClient, getDataOrDefault } from "@/lib/api";
import type {
InternetPlan,
InternetAddon,
InternetInstallation,
SimPlan,
SimAddon,
SimActivationFee,
VpnPlan,
} from "@/features/catalog/types/catalog.types";
InternetPlanCatalogItem,
InternetInstallationCatalogItem,
InternetAddonCatalogItem,
SimCatalogProduct,
SimActivationFeeCatalogItem,
VpnCatalogProduct,
} from "@customer-portal/domain";
const emptyInternetPlans: InternetPlan[] = [];
const emptyInternetAddons: InternetAddon[] = [];
const emptyInternetInstallations: InternetInstallation[] = [];
const emptySimPlans: SimPlan[] = [];
const emptySimAddons: SimAddon[] = [];
const emptySimActivationFees: SimActivationFee[] = [];
const emptyVpnPlans: VpnPlan[] = [];
const emptyInternetPlans: InternetPlanCatalogItem[] = [];
const emptyInternetAddons: InternetAddonCatalogItem[] = [];
const emptyInternetInstallations: InternetInstallationCatalogItem[] = [];
const emptySimPlans: SimCatalogProduct[] = [];
const emptySimAddons: SimCatalogProduct[] = [];
const emptySimActivationFees: SimActivationFeeCatalogItem[] = [];
const emptyVpnPlans: VpnCatalogProduct[] = [];
export const catalogService = {
async getInternetPlans(): Promise<InternetPlan[]> {
async getInternetCatalog(): Promise<{
plans: InternetPlanCatalogItem[];
installations: InternetInstallationCatalogItem[];
addons: InternetAddonCatalogItem[];
}> {
const response = await apiClient.GET("/api/catalog/internet/plans");
return getDataOrDefault(response, emptyInternetPlans);
},
async getInternetInstallations(): Promise<InternetInstallation[]> {
async getInternetInstallations(): Promise<InternetInstallationCatalogItem[]> {
const response = await apiClient.GET("/api/catalog/internet/installations");
return getDataOrDefault(response, emptyInternetInstallations);
},
async getInternetAddons(): Promise<InternetAddon[]> {
async getInternetAddons(): Promise<InternetAddonCatalogItem[]> {
const response = await apiClient.GET("/api/catalog/internet/addons");
return getDataOrDefault(response, emptyInternetAddons);
},
async getSimPlans(): Promise<SimPlan[]> {
async getSimCatalog(): Promise<{
plans: SimCatalogProduct[];
activationFees: SimActivationFeeCatalogItem[];
addons: SimCatalogProduct[];
}> {
const response = await apiClient.GET("/api/catalog/sim/plans");
return getDataOrDefault(response, emptySimPlans);
},
async getSimActivationFees(): Promise<SimActivationFee[]> {
async getSimActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
const response = await apiClient.GET("/api/catalog/sim/activation-fees");
return getDataOrDefault(response, emptySimActivationFees);
},
async getSimAddons(): Promise<SimAddon[]> {
async getSimAddons(): Promise<SimCatalogProduct[]> {
const response = await apiClient.GET("/api/catalog/sim/addons");
return getDataOrDefault(response, emptySimAddons);
},
async getVpnPlans(): Promise<VpnPlan[]> {
async getVpnCatalog(): Promise<{
plans: VpnCatalogProduct[];
activationFees: VpnCatalogProduct[];
}> {
const response = await apiClient.GET("/api/catalog/vpn/plans");
return getDataOrDefault(response, emptyVpnPlans);
},
async getVpnActivationFees(): Promise<VpnPlan[]> {
async getVpnActivationFees(): Promise<VpnCatalogProduct[]> {
const response = await apiClient.GET("/api/catalog/vpn/activation-fees");
return getDataOrDefault(response, emptyVpnPlans);
},

View File

@ -1,176 +0,0 @@
import type {
InternetProduct,
SimProduct,
VpnProduct,
ProductWithPricing,
} from "@customer-portal/domain";
// API shapes returned by the catalog endpoints. We model them as extensions of the
// unified domain types so field names stay aligned with Salesforce structures.
export type InternetPlan = InternetProduct & {
catalogMetadata: {
tierDescription: string;
features: string[];
isRecommended: boolean;
};
};
export type InternetInstallation = InternetProduct & {
catalogMetadata: {
installationTerm: "One-time" | "12-Month" | "24-Month";
};
};
export type InternetAddon = InternetProduct & {
catalogMetadata: {
addonCategory: "hikari-denwa-service" | "hikari-denwa-installation" | "other";
autoAdd: boolean;
requiredWith: string[];
};
};
export type SimPlan = SimProduct;
export type SimActivationFee = SimProduct & {
catalogMetadata: {
isDefault: boolean;
};
};
export type SimAddon = SimProduct;
export type VpnPlan = VpnProduct;
export type VpnActivationFee = VpnProduct;
export type ProductType = "Internet" | "SIM" | "VPN";
export type ItemClass = "Service" | "Installation" | "Add-on" | "Activation";
export type BillingCycle = "Monthly" | "Onetime";
export type PlanTier = "Silver" | "Gold" | "Platinum";
export interface CheckoutState {
loading: boolean;
error: string | null;
orderItems: OrderItem[];
totals: OrderTotals;
}
export interface OrderItem {
name: string;
sku: string;
monthlyPrice?: number;
oneTimePrice?: number;
type: "service" | "installation" | "addon";
autoAdded?: boolean;
}
export interface OrderTotals {
monthlyTotal: number;
oneTimeTotal: number;
}
export const buildInternetOrderItems = (
plan: InternetPlan,
addons: InternetAddon[],
installations: InternetInstallation[],
selections: {
installationSku?: string;
addonSkus?: string[];
}
): OrderItem[] => {
const items: OrderItem[] = [];
items.push({
name: plan.name,
sku: plan.sku,
monthlyPrice: plan.monthlyPrice,
oneTimePrice: plan.oneTimePrice,
type: "service",
});
if (selections.installationSku) {
const installation = installations.find(inst => inst.sku === selections.installationSku);
if (installation) {
items.push({
name: installation.name,
sku: installation.sku,
monthlyPrice: installation.billingCycle === "Monthly" ? installation.monthlyPrice : undefined,
oneTimePrice: installation.billingCycle !== "Monthly" ? installation.oneTimePrice : undefined,
type: "installation",
});
}
}
if (selections.addonSkus && selections.addonSkus.length > 0) {
selections.addonSkus.forEach(addonSku => {
const selectedAddon = addons.find(addon => addon.sku === addonSku);
if (selectedAddon) {
items.push({
name: selectedAddon.name,
sku: selectedAddon.sku,
monthlyPrice: selectedAddon.monthlyPrice,
oneTimePrice: selectedAddon.oneTimePrice,
type: "addon",
});
}
});
}
return items;
};
export const buildSimOrderItems = (
plan: SimPlan,
activationFees: SimActivationFee[],
addons: SimAddon[],
selections: {
addonSkus?: string[];
}
): OrderItem[] => {
const items: OrderItem[] = [];
items.push({
name: plan.name,
sku: plan.sku,
monthlyPrice: plan.monthlyPrice,
oneTimePrice: plan.oneTimePrice,
type: "service",
});
const activationFee = activationFees.find(fee => fee.catalogMetadata.isDefault) || activationFees[0];
if (activationFee) {
items.push({
name: activationFee.name,
sku: activationFee.sku,
oneTimePrice: activationFee.oneTimePrice ?? activationFee.unitPrice,
type: "addon",
});
}
if (selections.addonSkus && selections.addonSkus.length > 0) {
selections.addonSkus.forEach(addonSku => {
const selectedAddon = addons.find(addon => addon.sku === addonSku);
if (selectedAddon) {
items.push({
name: selectedAddon.name,
sku: selectedAddon.sku,
monthlyPrice: selectedAddon.billingCycle === "Monthly" ? selectedAddon.monthlyPrice ?? selectedAddon.unitPrice : undefined,
oneTimePrice: selectedAddon.billingCycle !== "Monthly" ? selectedAddon.oneTimePrice ?? selectedAddon.unitPrice : undefined,
type: "addon",
});
}
});
}
return items;
};
export const calculateTotals = (items: OrderItem[]): OrderTotals => {
return {
monthlyTotal: items.reduce((sum, item) => sum + (item.monthlyPrice || 0), 0),
oneTimeTotal: items.reduce((sum, item) => sum + (item.oneTimePrice || 0), 0),
};
};
export const buildOrderSKUs = (items: OrderItem[]): string[] => {
return items.map(item => item.sku);
};

View File

@ -14,10 +14,10 @@ import {
import { useInternetCatalog } from "@/features/catalog/hooks";
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
import type {
InternetPlan,
InternetInstallation,
InternetAddon,
} from "@/features/catalog/types/catalog.types";
InternetPlanCatalogItem,
InternetInstallationCatalogItem,
InternetAddonCatalogItem,
} from "@customer-portal/domain";
import { getMonthlyPrice } from "../utils/pricing";
import { LoadingCard, Skeleton, LoadingTable } from "@/components/atoms/loading-skeleton";
import { AnimatedCard } from "@/components/molecules";
@ -28,8 +28,9 @@ import { AlertBanner } from "@/components/molecules/AlertBanner";
export function InternetPlansContainer() {
const { data, isLoading, error } = useInternetCatalog();
const plans: InternetPlan[] = data?.plans || [];
const installations: InternetInstallation[] = data?.installations || [];
const plans: InternetPlanCatalogItem[] = data?.plans || [];
const installations: InternetInstallationCatalogItem[] = data?.installations || [];
const addons: InternetAddonCatalogItem[] = data?.addons || [];
const [eligibility, setEligibility] = useState<string>("");
const { data: activeSubs } = useActiveSubscriptions();
const hasActiveInternet = Array.isArray(activeSubs)

View File

@ -15,26 +15,26 @@ import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
import { Button } from "@/components/atoms/button";
import { AlertBanner } from "@/components/molecules/AlertBanner";
import { useSimCatalog } from "@/features/catalog/hooks";
import type { SimPlan } from "@/features/catalog/types/catalog.types";
import type { SimCatalogProduct } from "@customer-portal/domain";
import { SimPlanTypeSection } from "@/features/catalog/components/sim/SimPlanTypeSection";
interface PlansByType {
DataOnly: SimPlan[];
DataSmsVoice: SimPlan[];
VoiceOnly: SimPlan[];
DataOnly: SimCatalogProduct[];
DataSmsVoice: SimCatalogProduct[];
VoiceOnly: SimCatalogProduct[];
}
export function SimPlansView() {
export function SimPlansContainer() {
const { data, isLoading, error } = useSimCatalog();
const plans = data?.plans || [];
const simPlans: SimCatalogProduct[] = data?.plans ?? [];
const [hasExistingSim, setHasExistingSim] = useState(false);
const [activeTab, setActiveTab] = useState<"data-voice" | "data-only" | "voice-only">(
"data-voice"
);
useEffect(() => {
setHasExistingSim(plans.some(p => p.simHasFamilyDiscount));
}, [plans]);
setHasExistingSim(simPlans.some(p => p.simHasFamilyDiscount));
}, [simPlans]);
if (isLoading) {
return (
@ -118,7 +118,7 @@ export function SimPlansView() {
);
}
const plansByType: PlansByType = plans.reduce(
const plansByType: PlansByType = simPlans.reduce(
(acc, plan) => {
const planType = plan.simPlanType || "DataOnly";
if (planType === "DataOnly") acc.DataOnly.push(plan);
@ -355,4 +355,4 @@ export function SimPlansView() {
);
}
export default SimPlansView;
export default SimPlansContainer;

View File

@ -7,13 +7,13 @@ import { ordersService } from "@/features/orders/services/orders.service";
import { usePaymentMethods } from "@/features/billing/hooks/useBilling";
import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
import type {
InternetPlan,
InternetAddon,
InternetInstallation,
SimPlan,
SimAddon,
SimActivationFee,
} from "@/features/catalog/types/catalog.types";
InternetPlanCatalogItem,
InternetInstallationCatalogItem,
InternetAddonCatalogItem,
SimCatalogProduct,
SimActivationFeeCatalogItem,
VpnCatalogProduct,
} from "@customer-portal/domain";
import {
buildInternetOrderItems,
buildSimOrderItems,

View File

@ -15,6 +15,8 @@ export class ApiError extends Error {
}
}
export const isApiError = (error: unknown): error is ApiError => error instanceof ApiError;
type StrictApiClient = ReturnType<typeof createOpenApiClient<paths>>;
type FlexibleApiMethods = {

View File

@ -8,6 +8,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";
import { ApiError, isApiError } from "@/lib/api/runtime/client";
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
@ -17,10 +18,17 @@ export function QueryProvider({ children }: { children: React.ReactNode }) {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
retry: (failureCount, error: any) => {
// Don't retry on 4xx errors
if (error?.status >= 400 && error?.status < 500) {
return false;
retry: (failureCount, error: unknown) => {
if (isApiError(error)) {
const status = error.response?.status;
if (status && status >= 400 && status < 500) {
return false;
}
const body = error.body as Record<string, unknown> | undefined;
const code = typeof body?.code === "string" ? body.code : undefined;
if (code === "AUTHENTICATION_REQUIRED" || code === "FORBIDDEN") {
return false;
}
}
return failureCount < 3;
},

View File

@ -68,10 +68,7 @@ export function getErrorInfo(error: unknown): ApiErrorInfo {
};
}
/**
* Check if an error is a structured API error
*/
function isApiError(error: unknown): error is ApiError {
export function isApiError(error: unknown): error is ApiError {
return (
typeof error === 'object' &&
error !== null &&

View File

@ -46,7 +46,13 @@ export default [
},
})),
{
files: ["apps/bff/**/*.ts", "packages/domain/**/*.ts", "packages/logging/**/*.ts", "packages/api-client/**/*.ts"],
files: [
"apps/bff/**/*.ts",
"packages/domain/**/*.ts",
"packages/logging/**/*.ts",
"packages/api-client/**/*.ts",
"packages/validation/**/*.ts"
],
languageOptions: {
parserOptions: {
projectService: true,

View File

@ -1,204 +0,0 @@
/**
* API Response Adapters
* Convert different API response formats to our standard format
*/
import type { ApiResponse } from '../contracts/api';
// =====================================================
// WHMCS API ADAPTER
// =====================================================
export interface WhmcsApiResponse<T = unknown> {
result: "success" | "error";
message?: string;
data?: T;
}
export function adaptWhmcsResponse<T>(
response: WhmcsApiResponse<T>
): ApiResponse<T> {
if (response.result === 'success') {
return {
success: true,
data: response.data!,
meta: {
timestamp: new Date().toISOString(),
},
};
}
return {
success: false,
error: {
code: 'WHMCS_ERROR',
message: response.message || 'Unknown WHMCS error',
statusCode: 500,
timestamp: new Date().toISOString(),
},
};
}
// =====================================================
// SALESFORCE API ADAPTER
// =====================================================
export interface SalesforceApiResponse<T = unknown> {
success: boolean;
data?: T;
errors?: Array<{
message: string;
errorCode: string;
fields?: string[];
}>;
}
export function adaptSalesforceResponse<T>(
response: SalesforceApiResponse<T>
): ApiResponse<T> {
if (response.success && response.data !== undefined) {
return {
success: true,
data: response.data,
meta: {
timestamp: new Date().toISOString(),
},
};
}
const errorMessage = response.errors?.[0]?.message || 'Unknown Salesforce error';
const errorCode = response.errors?.[0]?.errorCode || 'SALESFORCE_ERROR';
return {
success: false,
error: {
code: errorCode,
message: errorMessage,
details: response.errors ? { errors: response.errors } : undefined,
statusCode: 500,
timestamp: new Date().toISOString(),
},
};
}
// =====================================================
// FREEBIT API ADAPTER
// =====================================================
export interface FreebitApiResponse<T = unknown> {
status: 'success' | 'error';
result?: T;
error_message?: string;
error_code?: string;
}
export function adaptFreebitResponse<T>(
response: FreebitApiResponse<T>
): ApiResponse<T> {
if (response.status === 'success' && response.result !== undefined) {
return {
success: true,
data: response.result,
meta: {
timestamp: new Date().toISOString(),
},
};
}
return {
success: false,
error: {
code: response.error_code || 'FREEBIT_ERROR',
message: response.error_message || 'Unknown Freebit error',
statusCode: 500,
timestamp: new Date().toISOString(),
},
};
}
// =====================================================
// GENERIC HTTP RESPONSE ADAPTER
// =====================================================
export interface HttpResponse<T = unknown> {
status: number;
statusText: string;
data: T;
headers?: Record<string, string>;
}
export function adaptHttpResponse<T>(
response: HttpResponse<T>
): ApiResponse<T> {
if (response.status >= 200 && response.status < 300) {
return {
success: true,
data: response.data,
meta: {
timestamp: new Date().toISOString(),
},
};
}
return {
success: false,
error: {
code: `HTTP_${response.status}`,
message: response.statusText || `HTTP Error ${response.status}`,
statusCode: response.status,
timestamp: new Date().toISOString(),
},
};
}
// =====================================================
// ADAPTER UTILITIES
// =====================================================
// Type guard to check if response is successful
export function isSuccessResponse<T>(
response: ApiResponse<T>
): response is { success: true; data: T } {
return response.success === true;
}
// Type guard to check if response is an error
export function isErrorResponse<T>(
response: ApiResponse<T>
): response is { success: false; error: any } {
return response.success === false;
}
// Extract data from response or throw error
export function unwrapResponse<T>(response: ApiResponse<T>): T {
if (isSuccessResponse(response)) {
return response.data;
}
throw new Error(response.error.message || 'API request failed');
}
// Extract data from response or return null
export function unwrapResponseSafely<T>(response: ApiResponse<T>): T | null {
if (isSuccessResponse(response)) {
return response.data;
}
return null;
}
// Map successful response data
export function mapResponseData<T, U>(
response: ApiResponse<T>,
mapper: (data: T) => U
): ApiResponse<U> {
if (isSuccessResponse(response)) {
return {
success: true,
data: mapper(response.data),
meta: response.meta,
};
}
return response as ApiResponse<U>;
}

View File

@ -1,18 +0,0 @@
/**
* Export all adapters
*/
export * from './api-adapters';
// Re-export commonly used adapter functions
export {
adaptWhmcsResponse,
adaptSalesforceResponse,
adaptFreebitResponse,
adaptHttpResponse,
isSuccessResponse,
isErrorResponse,
unwrapResponse,
unwrapResponseSafely,
mapResponseData,
} from './api-adapters';

View File

@ -1,17 +0,0 @@
/**
* Type-Safe API Client Exports
*/
export * from './typed-client';
// Re-export commonly used client utilities
export type { TypedApiClient } from './typed-client';
export {
TypedApiClientImpl,
createTypedApiClient,
createAuthenticatedApiClient,
isApiSuccess,
isApiError,
extractApiData,
extractApiDataSafely,
} from './typed-client';

View File

@ -1,451 +0,0 @@
/**
* Type-Safe API Client with Branded Types
*/
import type { ApiResponse, QueryParams, PaginatedResponse } from '../contracts/api';
import type {
UserId,
OrderId,
InvoiceId,
SubscriptionId,
PaymentId,
CaseId,
} from '../common';
import type { CreateInput, UpdateInput } from '../utils/type-utils';
// =====================================================
// BASE API CLIENT INTERFACE
// =====================================================
export interface TypedApiClient {
// Generic CRUD operations
get<T>(endpoint: string, params?: QueryParams): Promise<ApiResponse<T>>;
post<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>>;
put<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>>;
patch<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>>;
delete(endpoint: string): Promise<ApiResponse<void>>;
// Paginated operations
getPaginated<T>(endpoint: string, params?: QueryParams): Promise<ApiResponse<PaginatedResponse<T>>>;
}
// =====================================================
// ENTITY-SPECIFIC API CLIENTS
// =====================================================
// Import entity types (these would be imported from the actual entity files)
interface User {
id: UserId;
email: string;
name: string;
phone?: string;
emailVerified: boolean;
createdAt: string;
updatedAt: string;
}
interface Order {
id: OrderId;
userId: UserId;
status: string;
items: any[];
totals: any;
configuration: Record<string, unknown>;
createdAt: string;
updatedAt: string;
}
interface Invoice {
id: InvoiceId;
userId: UserId;
invoiceNumber: string;
status: string;
totalAmount: number;
createdAt: string;
updatedAt: string;
}
interface Subscription {
id: SubscriptionId;
userId: UserId;
planId: string;
status: string;
monthlyPrice: number;
createdAt: string;
updatedAt: string;
}
interface Payment {
id: PaymentId;
userId: UserId;
amount: number;
status: string;
createdAt: string;
updatedAt: string;
}
interface SupportCase {
id: CaseId;
userId: UserId;
subject: string;
status: string;
priority: string;
createdAt: string;
updatedAt: string;
}
// =====================================================
// TYPED API CLIENT IMPLEMENTATION
// =====================================================
export class TypedApiClientImpl implements TypedApiClient {
private baseUrl: string;
private defaultHeaders: Record<string, string>;
constructor(baseUrl: string, defaultHeaders: Record<string, string> = {}) {
this.baseUrl = baseUrl.replace(/\/$/, ''); // Remove trailing slash
this.defaultHeaders = {
'Content-Type': 'application/json',
...defaultHeaders,
};
}
// =====================================================
// GENERIC HTTP METHODS
// =====================================================
async get<T>(endpoint: string, params?: QueryParams): Promise<ApiResponse<T>> {
const url = this.buildUrl(endpoint, params);
return this.request<T>('GET', url);
}
async post<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> {
const url = this.buildUrl(endpoint);
return this.request<T>('POST', url, data);
}
async put<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> {
const url = this.buildUrl(endpoint);
return this.request<T>('PUT', url, data);
}
async patch<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> {
const url = this.buildUrl(endpoint);
return this.request<T>('PATCH', url, data);
}
async delete(endpoint: string): Promise<ApiResponse<void>> {
const url = this.buildUrl(endpoint);
return this.request<void>('DELETE', url);
}
async getPaginated<T>(endpoint: string, params?: QueryParams): Promise<ApiResponse<PaginatedResponse<T>>> {
return this.get<PaginatedResponse<T>>(endpoint, params);
}
// =====================================================
// USER OPERATIONS
// =====================================================
async getUser(id: UserId): Promise<ApiResponse<User>> {
return this.get<User>(`/users/${id}`);
}
async updateUser(id: UserId, data: UpdateInput<User>): Promise<ApiResponse<User>> {
return this.patch<User>(`/users/${id}`, data);
}
async getUserProfile(): Promise<ApiResponse<User>> {
return this.get<User>('/me');
}
async updateUserProfile(data: UpdateInput<User>): Promise<ApiResponse<User>> {
return this.patch<User>('/me', data);
}
// =====================================================
// ORDER OPERATIONS
// =====================================================
async getOrder(id: OrderId): Promise<ApiResponse<Order>> {
return this.get<Order>(`/orders/${id}`);
}
async getUserOrders(userId: UserId, params?: QueryParams): Promise<ApiResponse<PaginatedResponse<Order>>> {
return this.getPaginated<Order>(`/users/${userId}/orders`, params);
}
async createOrder(data: CreateInput<Order>): Promise<ApiResponse<Order>> {
return this.post<Order>('/orders', data);
}
async updateOrder(id: OrderId, data: UpdateInput<Order>): Promise<ApiResponse<Order>> {
return this.patch<Order>(`/orders/${id}`, data);
}
async cancelOrder(id: OrderId): Promise<ApiResponse<Order>> {
return this.patch<Order>(`/orders/${id}/cancel`);
}
// =====================================================
// INVOICE OPERATIONS
// =====================================================
async getInvoice(id: InvoiceId): Promise<ApiResponse<Invoice>> {
return this.get<Invoice>(`/invoices/${id}`);
}
async getUserInvoices(userId: UserId, params?: QueryParams): Promise<ApiResponse<PaginatedResponse<Invoice>>> {
return this.getPaginated<Invoice>(`/users/${userId}/invoices`, params);
}
async payInvoice(id: InvoiceId, paymentData: { paymentMethodId: string }): Promise<ApiResponse<Invoice>> {
return this.post<Invoice>(`/invoices/${id}/pay`, paymentData);
}
// =====================================================
// SUBSCRIPTION OPERATIONS
// =====================================================
async getSubscription(id: SubscriptionId): Promise<ApiResponse<Subscription>> {
return this.get<Subscription>(`/subscriptions/${id}`);
}
async getUserSubscriptions(userId: UserId, params?: QueryParams): Promise<ApiResponse<PaginatedResponse<Subscription>>> {
return this.getPaginated<Subscription>(`/users/${userId}/subscriptions`, params);
}
async createSubscription(data: CreateInput<Subscription>): Promise<ApiResponse<Subscription>> {
return this.post<Subscription>('/subscriptions', data);
}
async updateSubscription(id: SubscriptionId, data: UpdateInput<Subscription>): Promise<ApiResponse<Subscription>> {
return this.patch<Subscription>(`/subscriptions/${id}`, data);
}
async cancelSubscription(id: SubscriptionId): Promise<ApiResponse<Subscription>> {
return this.patch<Subscription>(`/subscriptions/${id}/cancel`);
}
async suspendSubscription(id: SubscriptionId): Promise<ApiResponse<Subscription>> {
return this.patch<Subscription>(`/subscriptions/${id}/suspend`);
}
async resumeSubscription(id: SubscriptionId): Promise<ApiResponse<Subscription>> {
return this.patch<Subscription>(`/subscriptions/${id}/resume`);
}
// =====================================================
// PAYMENT OPERATIONS
// =====================================================
async getPayment(id: PaymentId): Promise<ApiResponse<Payment>> {
return this.get<Payment>(`/payments/${id}`);
}
async getUserPayments(userId: UserId, params?: QueryParams): Promise<ApiResponse<PaginatedResponse<Payment>>> {
return this.getPaginated<Payment>(`/users/${userId}/payments`, params);
}
async createPayment(data: CreateInput<Payment>): Promise<ApiResponse<Payment>> {
return this.post<Payment>('/payments', data);
}
async refundPayment(id: PaymentId, amount?: number): Promise<ApiResponse<Payment>> {
return this.post<Payment>(`/payments/${id}/refund`, { amount });
}
// =====================================================
// SUPPORT CASE OPERATIONS
// =====================================================
async getSupportCase(id: CaseId): Promise<ApiResponse<SupportCase>> {
return this.get<SupportCase>(`/cases/${id}`);
}
async getUserSupportCases(userId: UserId, params?: QueryParams): Promise<ApiResponse<PaginatedResponse<SupportCase>>> {
return this.getPaginated<SupportCase>(`/users/${userId}/cases`, params);
}
async createSupportCase(data: CreateInput<SupportCase>): Promise<ApiResponse<SupportCase>> {
return this.post<SupportCase>('/cases', data);
}
async updateSupportCase(id: CaseId, data: UpdateInput<SupportCase>): Promise<ApiResponse<SupportCase>> {
return this.patch<SupportCase>(`/cases/${id}`, data);
}
async closeSupportCase(id: CaseId): Promise<ApiResponse<SupportCase>> {
return this.patch<SupportCase>(`/cases/${id}/close`);
}
// =====================================================
// PRIVATE HELPER METHODS
// =====================================================
private buildUrl(endpoint: string, params?: QueryParams): string {
const url = `${this.baseUrl}${endpoint.startsWith('/') ? endpoint : `/${endpoint}`}`;
if (!params || Object.keys(params).length === 0) {
return url;
}
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
});
const queryString = searchParams.toString();
return queryString ? `${url}?${queryString}` : url;
}
private async request<T>(
method: string,
url: string,
data?: unknown
): Promise<ApiResponse<T>> {
try {
const config: RequestInit = {
method,
headers: this.defaultHeaders,
};
if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
config.body = JSON.stringify(data);
}
const response = await fetch(url, config);
const responseData: any = await response.json();
if (!response.ok) {
return {
success: false,
error: {
code: `HTTP_${response.status}`,
message: responseData?.message || response.statusText,
statusCode: response.status,
timestamp: new Date().toISOString(),
},
};
}
return {
success: true,
data: responseData as T,
meta: {
timestamp: new Date().toISOString(),
},
};
} catch (error) {
return {
success: false,
error: {
code: 'NETWORK_ERROR',
message: error instanceof Error ? error.message : 'Network request failed',
timestamp: new Date().toISOString(),
},
};
}
}
// =====================================================
// CONFIGURATION METHODS
// =====================================================
setAuthToken(token: string): void {
this.defaultHeaders['Authorization'] = `Bearer ${token}`;
}
removeAuthToken(): void {
delete this.defaultHeaders['Authorization'];
}
setHeader(key: string, value: string): void {
this.defaultHeaders[key] = value;
}
removeHeader(key: string): void {
delete this.defaultHeaders[key];
}
}
// =====================================================
// FACTORY FUNCTIONS
// =====================================================
/**
* Create a new typed API client instance
*/
export function createTypedApiClient(
baseUrl: string,
options: {
authToken?: string;
headers?: Record<string, string>;
} = {}
): TypedApiClientImpl {
const headers: Record<string, string> = {
...options.headers,
};
if (options.authToken) {
headers['Authorization'] = `Bearer ${options.authToken}`;
}
return new TypedApiClientImpl(baseUrl, headers);
}
/**
* Create a typed API client with authentication
*/
export function createAuthenticatedApiClient(
baseUrl: string,
authToken: string,
additionalHeaders: Record<string, string> = {}
): TypedApiClientImpl {
return createTypedApiClient(baseUrl, {
authToken,
headers: additionalHeaders,
});
}
// =====================================================
// TYPE GUARDS AND UTILITIES
// =====================================================
/**
* Type guard to check if API response is successful
*/
export function isApiSuccess<T>(response: ApiResponse<T>): response is { success: true; data: T } {
return response.success === true;
}
/**
* Type guard to check if API response is an error
*/
export function isApiError<T>(response: ApiResponse<T>): response is { success: false; error: any } {
return response.success === false;
}
/**
* Extract data from API response or throw error
*/
export function extractApiData<T>(response: ApiResponse<T>): T {
if (isApiSuccess(response)) {
return response.data;
}
throw new Error(response.error.message || 'API request failed');
}
/**
* Extract data from API response or return null
*/
export function extractApiDataSafely<T>(response: ApiResponse<T>): T | null {
if (isApiSuccess(response)) {
return response.data;
}
return null;
}

View File

@ -5,23 +5,23 @@
// =====================================================
// Branded types for critical identifiers
export type UserId = string & { readonly __brand: 'UserId' };
export type OrderId = string & { readonly __brand: 'OrderId' };
export type InvoiceId = string & { readonly __brand: 'InvoiceId' };
export type SubscriptionId = string & { readonly __brand: 'SubscriptionId' };
export type PaymentId = string & { readonly __brand: 'PaymentId' };
export type CaseId = string & { readonly __brand: 'CaseId' };
export type SessionId = string & { readonly __brand: 'SessionId' };
export type UserId = string & { readonly __brand: "UserId" };
export type OrderId = string & { readonly __brand: "OrderId" };
export type InvoiceId = string & { readonly __brand: "InvoiceId" };
export type SubscriptionId = string & { readonly __brand: "SubscriptionId" };
export type PaymentId = string & { readonly __brand: "PaymentId" };
export type CaseId = string & { readonly __brand: "CaseId" };
export type SessionId = string & { readonly __brand: "SessionId" };
// WHMCS-specific branded types
export type WhmcsClientId = number & { readonly __brand: 'WhmcsClientId' };
export type WhmcsInvoiceId = number & { readonly __brand: 'WhmcsInvoiceId' };
export type WhmcsProductId = number & { readonly __brand: 'WhmcsProductId' };
export type WhmcsClientId = number & { readonly __brand: "WhmcsClientId" };
export type WhmcsInvoiceId = number & { readonly __brand: "WhmcsInvoiceId" };
export type WhmcsProductId = number & { readonly __brand: "WhmcsProductId" };
// Salesforce-specific branded types
export type SalesforceContactId = string & { readonly __brand: 'SalesforceContactId' };
export type SalesforceAccountId = string & { readonly __brand: 'SalesforceAccountId' };
export type SalesforceCaseId = string & { readonly __brand: 'SalesforceCaseId' };
export type SalesforceContactId = string & { readonly __brand: "SalesforceContactId" };
export type SalesforceAccountId = string & { readonly __brand: "SalesforceAccountId" };
export type SalesforceCaseId = string & { readonly __brand: "SalesforceCaseId" };
// Helper functions for creating branded types
export const createUserId = (id: string): UserId => id as UserId;
@ -36,15 +36,17 @@ export const createWhmcsClientId = (id: number): WhmcsClientId => id as WhmcsCli
export const createWhmcsInvoiceId = (id: number): WhmcsInvoiceId => id as WhmcsInvoiceId;
export const createWhmcsProductId = (id: number): WhmcsProductId => id as WhmcsProductId;
export const createSalesforceContactId = (id: string): SalesforceContactId => id as SalesforceContactId;
export const createSalesforceAccountId = (id: string): SalesforceAccountId => id as SalesforceAccountId;
export const createSalesforceContactId = (id: string): SalesforceContactId =>
id as SalesforceContactId;
export const createSalesforceAccountId = (id: string): SalesforceAccountId =>
id as SalesforceAccountId;
export const createSalesforceCaseId = (id: string): SalesforceCaseId => id as SalesforceCaseId;
// Type guards for branded types
export const isUserId = (id: string): id is UserId => typeof id === 'string';
export const isOrderId = (id: string): id is OrderId => typeof id === 'string';
export const isInvoiceId = (id: string): id is InvoiceId => typeof id === 'string';
export const isWhmcsClientId = (id: number): id is WhmcsClientId => typeof id === 'number';
export const isUserId = (id: string): id is UserId => typeof id === "string";
export const isOrderId = (id: string): id is OrderId => typeof id === "string";
export const isInvoiceId = (id: string): id is InvoiceId => typeof id === "string";
export const isWhmcsClientId = (id: number): id is WhmcsClientId => typeof id === "number";
// Shared ISO8601 timestamp string type used for serialized dates
export type IsoDateTimeString = string;

View File

@ -0,0 +1,63 @@
// Shared catalog contracts consumed by both BFF and Portal
export interface CatalogProductBase {
id: string;
sku: string;
name: string;
description?: string;
displayOrder?: number;
billingCycle?: string;
monthlyPrice?: number;
oneTimePrice?: number;
}
export interface InternetCatalogProduct extends CatalogProductBase {
internetPlanTier?: string;
internetOfferingType?: string;
features?: string[];
}
export interface InternetPlanTemplate {
tierDescription: string;
description?: string;
features?: string[];
}
export interface InternetPlanCatalogItem extends InternetCatalogProduct {
catalogMetadata?: {
tierDescription?: string;
features?: string[];
isRecommended?: boolean;
};
}
export interface InternetInstallationCatalogItem extends InternetCatalogProduct {
catalogMetadata?: {
installationTerm: "One-time" | "12-Month" | "24-Month";
};
}
export interface InternetAddonCatalogItem extends InternetCatalogProduct {
catalogMetadata?: {
addonCategory: "hikari-denwa-service" | "hikari-denwa-installation" | "other";
autoAdd: boolean;
requiredWith: string[];
};
}
export interface SimCatalogProduct extends CatalogProductBase {
simDataSize?: string;
simPlanType?: string;
simHasFamilyDiscount?: boolean;
}
export interface SimActivationFeeCatalogItem extends SimCatalogProduct {
catalogMetadata?: {
isDefault: boolean;
};
}
export interface VpnCatalogProduct extends CatalogProductBase {
vpnRegion?: string;
}

View File

View File

@ -1,2 +1,4 @@
// Export all API contracts
export * from "./api";
export * from "./catalog";
export * from "./salesforce";

View File

@ -0,0 +1,117 @@
import type { IsoDateTimeString } from "../common";
export interface SalesforceQueryResult<TRecord> {
totalSize: number;
done: boolean;
records: TRecord[];
}
export interface SalesforceCreateResult {
id: string;
success: boolean;
errors?: string[];
}
export interface SalesforceSObjectBase {
Id: string;
CreatedDate?: IsoDateTimeString;
LastModifiedDate?: IsoDateTimeString;
}
export interface SalesforceProduct2Record extends SalesforceSObjectBase {
Name?: string;
StockKeepingUnit?: string;
Description?: string;
Product2Categories1__c?: string | null;
Portal_Catalog__c?: boolean | null;
Portal_Accessible__c?: boolean | null;
Item_Class__c?: string | null;
Billing_Cycle__c?: string | null;
Catalog_Order__c?: number | null;
Bundled_Addon__c?: string | null;
Is_Bundled_Addon__c?: boolean | null;
Internet_Plan_Tier__c?: string | null;
Internet_Offering_Type__c?: string | null;
Feature_List__c?: string | null;
SIM_Data_Size__c?: string | null;
SIM_Plan_Type__c?: string | null;
SIM_Has_Family_Discount__c?: boolean | null;
VPN_Region__c?: string | null;
WH_Product_ID__c?: number | null;
WH_Product_Name__c?: string | null;
Price__c?: number | null;
Monthly_Price__c?: number | null;
One_Time_Price__c?: number | null;
}
export interface SalesforcePricebookEntryRecord extends SalesforceSObjectBase {
Name?: string;
UnitPrice?: number | string | null;
Pricebook2Id?: string | null;
Product2Id?: string | null;
IsActive?: boolean | null;
Product2?: SalesforceProduct2Record | null;
}
export interface SalesforceAccountRecord extends SalesforceSObjectBase {
Name?: string;
SF_Account_No__c?: string | null;
WH_Account__c?: string | null;
BillingStreet?: string | null;
BillingCity?: string | null;
BillingState?: string | null;
BillingPostalCode?: string | null;
BillingCountry?: string | null;
}
export interface SalesforceOrderRecord extends SalesforceSObjectBase {
OrderNumber?: string;
Status?: string;
Type?: string;
EffectiveDate?: IsoDateTimeString | null;
TotalAmount?: number | null;
AccountId?: string | null;
Pricebook2Id?: string | null;
Activation_Type__c?: string | null;
Activation_Status__c?: string | null;
Activation_Scheduled_At__c?: IsoDateTimeString | null;
Internet_Plan_Tier__c?: string | null;
Installment_Plan__c?: string | null;
Access_Mode__c?: string | null;
Weekend_Install__c?: boolean | null;
Hikari_Denwa__c?: boolean | null;
VPN_Region__c?: string | null;
SIM_Type__c?: string | null;
SIM_Voice_Mail__c?: boolean | null;
SIM_Call_Waiting__c?: boolean | null;
EID__c?: string | null;
WHMCS_Order_ID__c?: string | null;
Activation_Error_Code__c?: string | null;
Activation_Error_Message__c?: string | null;
ActivatedDate?: IsoDateTimeString | null;
}
export interface SalesforceOrderItemRecord extends SalesforceSObjectBase {
OrderId?: string | null;
Quantity?: number | null;
UnitPrice?: number | null;
TotalPrice?: number | null;
PricebookEntryId?: string | null;
PricebookEntry?: SalesforcePricebookEntryRecord | null;
Billing_Cycle__c?: string | null;
WHMCS_Service_ID__c?: string | null;
}
export interface SalesforceAccountContactRecord extends SalesforceSObjectBase {
AccountId?: string | null;
ContactId?: string | null;
}
export interface SalesforceContactRecord extends SalesforceSObjectBase {
FirstName?: string | null;
LastName?: string | null;
Email?: string | null;
Phone?: string | null;
}

View File

@ -1,6 +1,6 @@
/**
* Billing Domain Entities
*
*
* Business entities for billing calculations and summaries
*/
@ -33,39 +33,44 @@ export interface CurrencyFormatOptions {
}
// Business logic for billing calculations
export function calculateBillingSummary(invoices: Array<{
total: number;
status: string;
dueDate?: string;
paidDate?: string;
}>): Omit<BillingSummaryData, 'currency' | 'currencySymbol'> {
export function calculateBillingSummary(
invoices: Array<{
total: number;
status: string;
dueDate?: string;
paidDate?: string;
}>
): Omit<BillingSummaryData, "currency" | "currencySymbol"> {
const now = new Date();
return invoices.reduce((summary, invoice) => {
const isOverdue = invoice.dueDate && new Date(invoice.dueDate) < now && !invoice.paidDate;
const isPaid = !!invoice.paidDate;
const isUnpaid = !isPaid;
return {
totalOutstanding: summary.totalOutstanding + (isUnpaid ? invoice.total : 0),
totalOverdue: summary.totalOverdue + (isOverdue ? invoice.total : 0),
totalPaid: summary.totalPaid + (isPaid ? invoice.total : 0),
invoiceCount: {
total: summary.invoiceCount.total + 1,
unpaid: summary.invoiceCount.unpaid + (isUnpaid ? 1 : 0),
overdue: summary.invoiceCount.overdue + (isOverdue ? 1 : 0),
paid: summary.invoiceCount.paid + (isPaid ? 1 : 0),
},
};
}, {
totalOutstanding: 0,
totalOverdue: 0,
totalPaid: 0,
invoiceCount: {
total: 0,
unpaid: 0,
overdue: 0,
paid: 0,
return invoices.reduce(
(summary, invoice) => {
const isOverdue = invoice.dueDate && new Date(invoice.dueDate) < now && !invoice.paidDate;
const isPaid = !!invoice.paidDate;
const isUnpaid = !isPaid;
return {
totalOutstanding: summary.totalOutstanding + (isUnpaid ? invoice.total : 0),
totalOverdue: summary.totalOverdue + (isOverdue ? invoice.total : 0),
totalPaid: summary.totalPaid + (isPaid ? invoice.total : 0),
invoiceCount: {
total: summary.invoiceCount.total + 1,
unpaid: summary.invoiceCount.unpaid + (isUnpaid ? 1 : 0),
overdue: summary.invoiceCount.overdue + (isOverdue ? 1 : 0),
paid: summary.invoiceCount.paid + (isPaid ? 1 : 0),
},
};
},
});
{
totalOutstanding: 0,
totalOverdue: 0,
totalPaid: 0,
invoiceCount: {
total: 0,
unpaid: 0,
overdue: 0,
paid: 0,
},
}
);
}

View File

@ -1,104 +0,0 @@
// Import unified product types
import type {
BaseProduct,
InternetProduct,
SimProduct,
VpnProduct,
GenericProduct,
Product,
ProductWithPricing,
OrderItemRequest,
ItemClass as ProductItemClass,
ProductBillingCycle,
ProductCategory
} from './product';
import {
isInternetProduct,
isSimProduct,
isVpnProduct,
isGenericProduct,
isServiceProduct,
isAddonProduct,
isInstallationProduct,
isActivationProduct,
isCatalogVisible,
isOrderable,
fromSalesforceProduct2
} from './product';
// Re-export unified product types
export type {
BaseProduct,
InternetProduct,
SimProduct,
VpnProduct,
GenericProduct,
Product,
OrderItemRequest,
ProductBillingCycle,
ProductCategory,
// Note: Salesforce types are exported from product.ts
};
export {
isInternetProduct,
isSimProduct,
isVpnProduct,
isGenericProduct,
isServiceProduct,
isAddonProduct,
isInstallationProduct,
isActivationProduct,
isCatalogVisible,
isOrderable,
fromSalesforceProduct2,
// Note: DEFAULT_SALESFORCE_PRODUCT_FIELD_MAP is exported from product.ts
};
// Legacy type aliases for backward compatibility
export type InternetPlan = InternetProduct;
export type SimPlan = SimProduct;
export type VpnPlan = VpnProduct;
// CatalogItem is now just an alias for Product (since they represent the same thing)
export type CatalogItem = Product;
// OrderTotals moved to order.ts to avoid duplication
export type ProductType = "Internet" | "SIM" | "VPN";
// Note: ItemClass is exported from product.ts, not redefined here
export type CatalogBillingCycle = "Monthly" | "Onetime";
export type PlanTier = "Silver" | "Gold" | "Platinum";
// Fixed legacy type guards to match actual unified structure
export function isInternetPlan(product: unknown): product is InternetPlan {
return (
product !== null &&
typeof product === "object" &&
"internetPlanTier" in product &&
"internetOfferingType" in product &&
(product as Record<string, unknown>).category === "Internet"
);
}
export function isSimPlan(product: unknown): product is SimPlan {
return (
product !== null &&
typeof product === "object" &&
"simDataSize" in product &&
"simPlanType" in product &&
(product as Record<string, unknown>).category === "SIM"
);
}
export function isVpnPlan(product: unknown): product is VpnPlan {
return (
product !== null &&
typeof product === "object" &&
"sku" in product &&
(product as Record<string, unknown>).category === "VPN" &&
!("internetPlanTier" in product) &&
!("simDataSize" in product)
);
}

View File

@ -1,191 +0,0 @@
/**
* Checkout Domain Logic
*
* This contains ALL checkout and cart business logic.
* Portal should ONLY import from here, never duplicate.
*/
import type { CatalogOrderItem, OrderTotals } from "./order";
import { calculateOrderTotals } from "./order";
import type {
InternetProduct,
SimProduct,
Product,
ProductWithPricing,
isInternetProduct,
isSimProduct
} from "./product";
// =====================================================
// CORE CHECKOUT BUSINESS ENTITIES
// =====================================================
/**
* Pure checkout cart (business logic only)
*/
export interface CheckoutCart {
items: CatalogOrderItem[];
totals: OrderTotals;
configuration: Record<string, unknown>;
}
/**
* Checkout session state (business logic)
*/
export interface CheckoutSession extends CheckoutCart {
sessionId: string;
userId?: string;
createdAt: string;
expiresAt: string;
}
// =====================================================
// BUSINESS LOGIC FUNCTIONS (Domain Rules)
// =====================================================
/**
* Build order items from Internet plan selection
* Contains business rules for Internet product combinations
*/
export function buildInternetOrderItems(
plan: InternetProduct,
addons: Product[] = [],
installations: Product[] = [],
options?: { installationSku?: string; addonSkus?: string[] }
): CatalogOrderItem[] {
const items: CatalogOrderItem[] = [
{
...plan,
quantity: 1,
autoAdded: false
}
];
// Business rule: Add selected installation
if (options?.installationSku) {
const installation = installations.find(inst => inst.sku === options.installationSku);
if (installation) {
items.push({
...installation,
quantity: 1,
autoAdded: false
});
}
}
// Business rule: Add selected addons
const targetAddonSkus = options?.addonSkus || [];
addons.forEach(addon => {
if (targetAddonSkus.length === 0 || targetAddonSkus.includes(addon.sku)) {
items.push({
...addon,
quantity: 1,
autoAdded: addon.itemClass === "Add-on"
});
}
});
return items;
}
/**
* Build order items from SIM plan selection
* Contains business rules for SIM product combinations
*/
export function buildSimOrderItems(
plan: SimProduct,
activationFees: Product[] = [],
addons: Product[] = [],
options?: { activationFeeSku?: string; addonSkus?: string[] }
): CatalogOrderItem[] {
const items: CatalogOrderItem[] = [
{
...plan,
quantity: 1,
autoAdded: false
}
];
// Business rule: Add activation fee if selected
if (options?.activationFeeSku) {
const activationFee = activationFees.find(fee => fee.sku === options.activationFeeSku);
if (activationFee) {
items.push({
...activationFee,
quantity: 1,
autoAdded: false
});
}
}
// Business rule: Add selected addons
const targetAddonSkus = options?.addonSkus || [];
addons.forEach(addon => {
if (targetAddonSkus.length === 0 || targetAddonSkus.includes(addon.sku)) {
items.push({
...addon,
quantity: 1,
autoAdded: addon.itemClass === "Add-on"
});
}
});
return items;
}
/**
* Validate checkout cart (business rules)
*/
export function validateCheckoutCart(cart: CheckoutCart): { valid: boolean; errors: string[] } {
const errors: string[] = [];
if (cart.items.length === 0) {
errors.push("Cart cannot be empty");
}
// Business rule: Must have at least one service
const hasService = cart.items.some(item => item.itemClass === "Service");
if (!hasService) {
errors.push("Cart must contain at least one service");
}
// Business rule: Validate totals match items
const calculatedTotals = calculateOrderTotals(cart.items);
if (Math.abs(cart.totals.monthlyTotal - calculatedTotals.monthlyTotal) > 0.01) {
errors.push("Monthly total mismatch");
}
return {
valid: errors.length === 0,
errors,
};
}
/**
* Create checkout session (business logic)
*/
export function createCheckoutSession(
items: CatalogOrderItem[],
userId?: string
): CheckoutSession {
const now = new Date();
const expiresAt = new Date(now.getTime() + 30 * 60 * 1000); // 30 minutes
return {
sessionId: generateSessionId(),
userId,
items,
totals: calculateOrderTotals(items),
configuration: {},
createdAt: now.toISOString(),
expiresAt: expiresAt.toISOString(),
};
}
// Helper function (internal to domain)
function generateSessionId(): string {
return `checkout_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
// Re-export from order.ts for convenience
export { calculateOrderTotals, extractOrderSKUs } from "./order";

View File

@ -1,6 +1,6 @@
/**
* Dashboard Domain Entities
*
*
* Business entities for dashboard data and statistics
*/

View File

@ -1,91 +0,0 @@
// Example demonstrating the unified product type approach
// This shows how the same Salesforce Product2 data can be used across different contexts
import {
Product,
InternetProduct,
SimProduct,
OrderItemRequest,
fromSalesforceProduct2,
isInternetProduct,
isServiceProduct,
isCatalogVisible
} from '../product';
// Example: Salesforce Product2 data (what comes from the API)
const salesforceInternetProduct = {
Id: "01t4x000000ABCD",
Name: "Internet Gold Plan (Home 1G)",
StockKeepingUnit: "INTERNET-GOLD-HOME-1G",
Description: "High-speed internet for home users",
Product2Categories1__c: "Internet",
Item_Class__c: "Service",
Billing_Cycle__c: "Monthly",
Portal_Catalog__c: true,
Portal_Accessible__c: true,
WH_Product_ID__c: 188,
WH_Product_Name__c: "Internet Gold Monthly",
Internet_Plan_Tier__c: "Gold",
Internet_Offering_Type__c: "Home",
Internet_Monthly_Price__c: 5980
};
const pricebookEntry = {
Id: "01u4x000000EFGH",
UnitPrice: 5980
};
// Transform Salesforce data to unified Product type
const product: Product = fromSalesforceProduct2(salesforceInternetProduct, pricebookEntry);
// Now this same product can be used in different contexts:
// 1. CATALOG CONTEXT - Display in catalog
if (isCatalogVisible(product) && isInternetProduct(product)) {
// Catalog item details: ${product.name} - ${product.internetPlanTier} tier, $${product.monthlyPrice/100}/month
}
// 2. ORDER CONTEXT - Add to order with quantity
const orderItem: OrderItemRequest = {
...product,
quantity: 1,
autoAdded: false
};
// Order item created: ${orderItem.name} x${orderItem.quantity} @ $${(orderItem.unitPrice || 0)/100} = $${((orderItem.unitPrice || 0) * orderItem.quantity)/100}
// 3. BUSINESS LOGIC - Same type guards work everywhere
if (isServiceProduct(product)) {
// Service product identified: ${product.name}
}
// 4. SALESFORCE INTEGRATION - Direct field access still works
// Product integration IDs: WHMCS=${product.whmcsProductId}, Salesforce=${product.id}
// Example with SIM product
const salesforceSimProduct = {
Id: "01t4x000000IJKL",
Name: "SIM Data + Voice 50GB",
StockKeepingUnit: "SIM-DATA-VOICE-50GB",
Product2Categories1__c: "SIM",
Item_Class__c: "Service",
Billing_Cycle__c: "Monthly",
Portal_Catalog__c: true,
Portal_Accessible__c: true,
WH_Product_ID__c: 195,
WH_Product_Name__c: "SIM 50GB Monthly",
SIM_Data_Size__c: "50GB",
SIM_Plan_Type__c: "DataSmsVoice",
SIM_Has_Family_Discount__c: false
};
const simProduct = fromSalesforceProduct2(salesforceSimProduct, { UnitPrice: 2980 });
// Same unified approach works for all product types
// SIM product details: ${simProduct.name}
if (simProduct.category === "SIM") {
const sim = simProduct as SimProduct;
// SIM product specifications: ${sim.simDataSize} ${sim.simPlanType}
}
export { product, orderItem, simProduct };

View File

@ -6,22 +6,3 @@ export * from "./case";
export * from "./dashboard";
export * from "./billing";
export * from "./skus";
// Export product types first (they are the source of truth)
export * from "./product";
// Export order types (which use product types)
export * from "./order";
// Export catalog types (which re-export product types with legacy aliases)
export * from "./catalog";
// Export checkout types (which use catalog/product types)
export * from "./checkout";
// Export subscription types last (to avoid conflicts with Product/BillingCycle)
export type {
Subscription,
SubscriptionList,
SubscriptionBillingCycle
} from "./subscription";

View File

@ -1,114 +0,0 @@
// Order and checkout types
import type { WhmcsEntity } from "../common";
import type {
OrderItemRequest,
SalesforceOrder,
SalesforceOrderItem,
SalesforceQueryResult,
SalesforceCreateResult,
Product,
ProductWithPricing,
ProductBillingCycle
} from "./product";
// Re-export for convenience
export type {
OrderItemRequest,
SalesforceOrder,
SalesforceOrderItem,
SalesforceQueryResult,
SalesforceCreateResult
} from "./product";
export type OrderStatus = "Pending" | "Active" | "Cancelled" | "Fraud";
// Note: SalesforceOrder and SalesforceOrderItem are now defined in product.ts as single source of truth
// WHMCS Order structure (for existing orders from WHMCS)
export interface WhmcsOrder extends WhmcsEntity {
orderNumber: string;
status: OrderStatus;
date: string; // ISO
amount: number;
currency: string;
paymentMethod?: string;
items: WhmcsOrderItem[];
invoiceId?: number;
}
// WHMCS-specific order item (for existing orders from WHMCS)
export interface WhmcsOrderItem {
productId: number;
productName: string;
domain?: string;
cycle: string;
quantity: number;
price: number;
setup?: number;
configOptions?: Record<string, string>;
}
// Order item for new orders (before Salesforce creation) - uses unified Product structure
export type CatalogOrderItem = OrderItemRequest;
// Generic order item (union type for different contexts)
export type OrderItem = WhmcsOrderItem | CatalogOrderItem | SalesforceOrderItem;
// Generic order (union type for different contexts)
export type Order = WhmcsOrder | SalesforceOrder;
// Order totals for checkout
export interface OrderTotals {
monthlyTotal: number;
oneTimeTotal: number;
}
export interface CreateOrderRequestEntity {
items: CreateOrderItem[];
paymentMethod?: string;
promoCode?: string;
notes?: string;
}
export interface CreateOrderItem {
productId: number;
domain?: string;
cycle: string;
quantity?: number;
configOptions?: Record<string, string>;
}
export interface OrderCalculation {
subtotal: number;
setup: number;
tax: number;
discount: number;
total: number;
currency: string;
items: OrderCalculationItem[];
}
export interface OrderCalculationItem {
productId: number;
productName: string;
cycle: string;
quantity: number;
subtotal: number;
setup: number;
discount: number;
}
// Business logic utilities (belong in domain)
export function calculateOrderTotals(items: CatalogOrderItem[]): OrderTotals {
return items.reduce(
(totals, item) => ({
monthlyTotal: totals.monthlyTotal + (item.monthlyPrice || 0) * item.quantity,
oneTimeTotal: totals.oneTimeTotal + (item.oneTimePrice || 0) * item.quantity,
}),
{ monthlyTotal: 0, oneTimeTotal: 0 }
);
}
export function extractOrderSKUs(items: CatalogOrderItem[]): string[] {
return items.map(item => item.sku);
}

View File

@ -1,524 +0,0 @@
// Unified Product types based on Salesforce Product2 structure
// This eliminates duplication between catalog items, order items, and product types
export type ItemClass = "Service" | "Installation" | "Add-on" | "Activation";
export type ProductBillingCycle = "Monthly" | "Onetime";
export type ProductCategory = "Internet" | "SIM" | "VPN" | "Other";
// Legacy alias for backward compatibility
export type BillingCycle = ProductBillingCycle;
// Base product interface that maps directly to Salesforce Product2 fields
export interface SalesforceProduct2Record {
Id: string;
Name: string;
Description?: string | null;
ProductCode?: string | null;
StockKeepingUnit?: string | null;
[key: string]: unknown;
}
export interface SalesforcePricebookEntryRecord {
Id?: string;
Name?: string | null;
UnitPrice?: number | string | null;
Pricebook2Id?: string | null;
Product2Id?: string | null;
IsActive?: boolean | null;
[key: string]: unknown;
}
export interface SalesforceProductFieldMap {
sku: string;
portalCategory: string;
portalCatalog: string;
portalAccessible: string;
itemClass: string;
billingCycle: string;
whmcsProductId: string;
whmcsProductName: string;
internetPlanTier: string;
internetOfferingType: string;
displayOrder: string;
bundledAddon: string;
isBundledAddon: string;
simDataSize: string;
simPlanType: string;
simHasFamilyDiscount: string;
vpnRegion: string;
}
export const DEFAULT_SALESFORCE_PRODUCT_FIELD_MAP: SalesforceProductFieldMap = {
sku: "StockKeepingUnit",
portalCategory: "Product2Categories1__c",
portalCatalog: "Portal_Catalog__c",
portalAccessible: "Portal_Accessible__c",
itemClass: "Item_Class__c",
billingCycle: "Billing_Cycle__c",
whmcsProductId: "WH_Product_ID__c",
whmcsProductName: "WH_Product_Name__c",
internetPlanTier: "Internet_Plan_Tier__c",
internetOfferingType: "Internet_Offering_Type__c",
displayOrder: "Catalog_Order__c",
bundledAddon: "Bundled_Addon__c",
isBundledAddon: "Is_Bundled_Addon__c",
simDataSize: "SIM_Data_Size__c",
simPlanType: "SIM_Plan_Type__c",
simHasFamilyDiscount: "SIM_Has_Family_Discount__c",
vpnRegion: "VPN_Region__c",
};
export interface BaseProduct {
// Standard Salesforce fields
id: string; // Product2.Id
name: string; // Product2.Name
sku: string; // Product2.StockKeepingUnit
description?: string; // Product2.Description
// Portal-specific fields (custom fields on Product2)
category: ProductCategory; // Product2Categories1__c
itemClass: ItemClass; // Item_Class__c
billingCycle: ProductBillingCycle; // Billing_Cycle__c
portalCatalog: boolean; // Portal_Catalog__c
portalAccessible: boolean; // Portal_Accessible__c
// WHMCS integration fields
whmcsProductId?: number; // WH_Product_ID__c
whmcsProductName?: string; // WH_Product_Name__c
// Catalog ordering helpers from Salesforce custom fields
displayOrder?: number;
bundledAddonId?: string;
isBundledAddon?: boolean;
// Original Salesforce record (for auditing / downstream transforms)
raw: SalesforceProduct2Record;
}
// PricebookEntry represents Salesforce pricing structure
export interface PricebookEntry {
id: string; // PricebookEntry.Id
name: string; // PricebookEntry.Name
unitPrice: number; // PricebookEntry.UnitPrice
pricebook2Id: string; // PricebookEntry.Pricebook2Id
product2Id: string; // PricebookEntry.Product2Id
isActive: boolean; // PricebookEntry.IsActive
}
// Product with pricing from PricebookEntry
export interface ProductWithPricing extends BaseProduct {
pricebookEntry?: PricebookEntry;
// Convenience fields derived from pricebookEntry and billingCycle
unitPrice?: number; // PricebookEntry.UnitPrice
monthlyPrice?: number; // UnitPrice if billingCycle === "Monthly"
oneTimePrice?: number; // UnitPrice if billingCycle === "Onetime"
}
// Internet-specific product fields
export interface InternetProduct extends ProductWithPricing {
category: "Internet";
// Internet-specific fields
internetPlanTier?: "Silver" | "Gold" | "Platinum"; // Internet_Plan_Tier__c
internetOfferingType?: string; // Internet_Offering_Type__c
// UI-specific fields (can be derived or stored)
features?: string[];
tierDescription?: string;
isRecommended?: boolean;
}
// SIM-specific product fields
export interface SimProduct extends ProductWithPricing {
category: "SIM";
// SIM-specific fields
simDataSize?: string; // SIM_Data_Size__c
simPlanType?: "DataOnly" | "DataSmsVoice" | "VoiceOnly"; // SIM_Plan_Type__c
simHasFamilyDiscount?: boolean; // SIM_Has_Family_Discount__c
// UI-specific fields
features?: string[];
}
// VPN-specific product fields
export interface VpnProduct extends ProductWithPricing {
category: "VPN";
// VPN-specific fields
vpnRegion?: string; // VPN_Region__c
}
// Generic product for "Other" category
export interface GenericProduct extends ProductWithPricing {
category: "Other";
}
// Union type for all product types
export type Product = InternetProduct | SimProduct | VpnProduct | GenericProduct;
// =====================================================
// SALESFORCE TYPES (Single source of truth - domain format)
// =====================================================
// Salesforce Order structure (camelCase for TypeScript domain)
export interface SalesforceOrder {
id: string; // Order.Id
orderNumber: string; // Order.OrderNumber
status: string; // Order.Status
type: string; // Order.Type
effectiveDate: string; // Order.EffectiveDate
totalAmount: number; // Order.TotalAmount
accountId: string; // Order.AccountId
pricebook2Id: string; // Order.Pricebook2Id
createdDate: string; // Order.CreatedDate
lastModifiedDate: string; // Order.LastModifiedDate
// Custom fields (camelCase, mapped via field-map)
orderType?: string; // Order_Type__c
activationStatus?: string; // Activation_Status__c
whmcsOrderId?: string; // WHMCS_Order_ID__c
// Related records
account?: {
id: string;
name: string;
[key: string]: unknown;
};
orderItems?: SalesforceOrderItem[];
[key: string]: unknown;
}
// Salesforce OrderItem structure (camelCase for TypeScript domain)
export interface SalesforceOrderItem {
id: string; // OrderItem.Id
orderId: string; // OrderItem.OrderId
quantity: number; // OrderItem.Quantity
unitPrice: number; // OrderItem.UnitPrice
totalPrice: number; // OrderItem.TotalPrice
pricebookEntry: PricebookEntry & {
product2: Product; // PricebookEntry.Product2 with full product data
};
// Custom fields
whmcsServiceId?: string; // WHMCS_Service_ID__c
billingCycle?: ProductBillingCycle; // Billing_Cycle__c (derived from Product2)
[key: string]: unknown;
}
// Salesforce Query Result structure
export interface SalesforceQueryResult<T> {
totalSize: number;
done: boolean;
records: T[];
}
// Salesforce Create Result structure
export interface SalesforceCreateResult {
id: string;
success: boolean;
errors: Array<{
message: string;
statusCode: string;
fields: string[];
}>;
}
// Order item for new orders (before Salesforce creation)
export interface OrderItemRequest extends ProductWithPricing {
quantity: number;
autoAdded?: boolean;
}
// Legacy alias for backward compatibility
export type ProductOrderItem = OrderItemRequest;
// Type guards
export function isInternetProduct(product: Product): product is InternetProduct {
return product.category === "Internet";
}
export function isSimProduct(product: Product): product is SimProduct {
return product.category === "SIM";
}
export function isVpnProduct(product: Product): product is VpnProduct {
return product.category === "VPN";
}
export function isGenericProduct(product: Product): product is GenericProduct {
return product.category === "Other";
}
// Utility functions
export function isServiceProduct(product: Product): boolean {
return product.itemClass === "Service";
}
export function isAddonProduct(product: Product): boolean {
return product.itemClass === "Add-on";
}
export function isInstallationProduct(product: Product): boolean {
return product.itemClass === "Installation";
}
export function isActivationProduct(product: Product): boolean {
return product.itemClass === "Activation";
}
export function isCatalogVisible(product: Product): boolean {
return product.portalCatalog && product.portalAccessible;
}
export function isOrderable(product: Product): boolean {
return product.portalAccessible;
}
// Transform Salesforce Product2 to our Product type
export function fromSalesforceProduct2(
sfProduct: SalesforceProduct2Record,
pricebookEntry?: SalesforcePricebookEntryRecord | null,
fieldMap: SalesforceProductFieldMap = DEFAULT_SALESFORCE_PRODUCT_FIELD_MAP
): Product {
const billingCycleRaw = readField(sfProduct, fieldMap.billingCycle);
const billingCycle = normalizeBillingCycle(billingCycleRaw) ?? "Onetime";
const category = normalizeCategory(readField(sfProduct, fieldMap.portalCategory));
const itemClass = normalizeItemClass(readField(sfProduct, fieldMap.itemClass));
const normalizedPricebook = normalizePricebookEntry(pricebookEntry, sfProduct.Id);
const unitPrice = normalizedPricebook?.unitPrice;
const baseProduct: ProductWithPricing = {
id: sfProduct.Id,
name: sfProduct.Name,
sku: normalizeSku(sfProduct, fieldMap),
description: typeof sfProduct.Description === "string" ? sfProduct.Description : undefined,
category,
itemClass,
billingCycle,
portalCatalog: normalizeBoolean(readField(sfProduct, fieldMap.portalCatalog)) ?? false,
portalAccessible: normalizeBoolean(readField(sfProduct, fieldMap.portalAccessible)) ?? false,
whmcsProductId: normalizeNumeric(readField(sfProduct, fieldMap.whmcsProductId)),
whmcsProductName: coerceString(readField(sfProduct, fieldMap.whmcsProductName)) || undefined,
displayOrder: normalizeNumeric(readField(sfProduct, fieldMap.displayOrder)),
bundledAddonId: coerceString(readField(sfProduct, fieldMap.bundledAddon)) ?? undefined,
isBundledAddon: normalizeBoolean(readField(sfProduct, fieldMap.isBundledAddon)),
raw: sfProduct,
pricebookEntry: normalizedPricebook ?? undefined,
unitPrice,
monthlyPrice: billingCycle === "Monthly" ? unitPrice : undefined,
oneTimePrice: billingCycle === "Onetime" ? unitPrice : undefined,
};
switch (baseProduct.category) {
case "Internet":
return {
...baseProduct,
category: "Internet" as const,
internetPlanTier: coerceString(readField(sfProduct, fieldMap.internetPlanTier)) as
| InternetProduct["internetPlanTier"]
| undefined,
internetOfferingType: coerceString(readField(sfProduct, fieldMap.internetOfferingType)) || undefined,
} satisfies InternetProduct;
case "SIM":
return {
...baseProduct,
category: "SIM" as const,
simDataSize: coerceString(readField(sfProduct, fieldMap.simDataSize)) || undefined,
simPlanType: normalizeSimPlanType(readField(sfProduct, fieldMap.simPlanType)) || undefined,
simHasFamilyDiscount: normalizeBoolean(readField(sfProduct, fieldMap.simHasFamilyDiscount)),
} satisfies SimProduct;
case "VPN":
return {
...baseProduct,
category: "VPN" as const,
vpnRegion: coerceString(readField(sfProduct, fieldMap.vpnRegion)) || undefined,
} satisfies VpnProduct;
default:
return {
...baseProduct,
category: "Other",
} satisfies GenericProduct;
}
}
function readField(record: SalesforceProduct2Record, field: string): unknown {
return record[field];
}
function normalizeSku(record: SalesforceProduct2Record, fieldMap: SalesforceProductFieldMap): string {
const skuFieldValue = readField(record, fieldMap.sku);
const sku = typeof skuFieldValue === "string" && skuFieldValue.trim().length > 0
? skuFieldValue
: typeof record.StockKeepingUnit === "string" && record.StockKeepingUnit.trim().length > 0
? record.StockKeepingUnit
: typeof record.ProductCode === "string" && record.ProductCode.trim().length > 0
? record.ProductCode
: record.Id;
return sku;
}
function normalizeBillingCycle(value: unknown): ProductBillingCycle | undefined {
if (typeof value !== "string") return undefined;
const normalized = value.toLowerCase();
if (normalized === "monthly") return "Monthly";
if (normalized === "onetime" || normalized === "one-time" || normalized === "one time") {
return "Onetime";
}
return undefined;
}
function normalizeCategory(value: unknown): ProductCategory {
if (typeof value !== "string") return "Other";
const normalized = value.toLowerCase();
switch (normalized) {
case "internet":
return "Internet";
case "sim":
return "SIM";
case "vpn":
return "VPN";
default:
return "Other";
}
}
function normalizeItemClass(value: unknown): ItemClass {
if (typeof value !== "string") return "Service";
const normalized = value.toLowerCase();
switch (normalized) {
case "installation":
return "Installation";
case "add-on":
case "addon":
return "Add-on";
case "activation":
return "Activation";
default:
return "Service";
}
}
function normalizeBoolean(value: unknown): boolean | undefined {
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
if (typeof value === "string") {
const lower = value.trim().toLowerCase();
if (["true", "1", "yes", "y"].includes(lower)) return true;
if (["false", "0", "no", "n"].includes(lower)) return false;
}
return undefined;
}
function normalizeNumeric(value: unknown): number | undefined {
if (typeof value === "number" && !Number.isNaN(value)) return value;
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
if (!Number.isNaN(parsed)) {
return parsed;
}
}
return undefined;
}
function normalizeSimPlanType(value: unknown): SimProduct["simPlanType"] | undefined {
if (typeof value !== "string") return undefined;
const normalized = value.replace(/\s+/g, "").toLowerCase();
if (normalized === "dataonly") return "DataOnly";
if (normalized === "datasmsvoice" || normalized === "regular") return "DataSmsVoice";
if (normalized === "voiceonly") return "VoiceOnly";
return undefined;
}
function normalizePricebookEntry(
pricebookEntry: SalesforcePricebookEntryRecord | null | undefined,
productId: string
): PricebookEntry | undefined {
if (!pricebookEntry) return undefined;
const unitPrice = normalizeNumeric(pricebookEntry.UnitPrice) ?? undefined;
return {
id: coerceString(pricebookEntry.Id) ?? `${productId}-pricebook-entry`,
name: coerceString(pricebookEntry.Name) ?? "",
unitPrice: unitPrice ?? 0,
pricebook2Id: coerceString(pricebookEntry.Pricebook2Id) ?? "",
product2Id: coerceString(pricebookEntry.Product2Id) ?? productId,
isActive: normalizeBoolean(pricebookEntry.IsActive) ?? true,
};
}
function coerceString(value: unknown): string | null {
if (typeof value === "string") return value;
if (typeof value === "number" && !Number.isNaN(value)) return value.toString();
return null;
}
// Transform Salesforce API response to domain SalesforceOrder
// BFF should call this when receiving data from Salesforce API
export function fromSalesforceAPI(apiOrder: any): SalesforceOrder {
return {
id: apiOrder.Id,
orderNumber: apiOrder.OrderNumber,
status: apiOrder.Status,
type: apiOrder.Type,
effectiveDate: apiOrder.EffectiveDate,
totalAmount: apiOrder.TotalAmount,
accountId: apiOrder.AccountId,
pricebook2Id: apiOrder.Pricebook2Id,
createdDate: apiOrder.CreatedDate,
lastModifiedDate: apiOrder.LastModifiedDate,
// Transform custom fields using field-map (BFF should handle this)
orderType: apiOrder.Order_Type__c,
activationStatus: apiOrder.Activation_Status__c,
whmcsOrderId: apiOrder.WHMCS_Order_ID__c,
// Transform nested objects
account: apiOrder.Account ? {
id: apiOrder.Account.Id,
name: apiOrder.Account.Name,
...apiOrder.Account
} : undefined,
// Transform order items if present
orderItems: apiOrder.OrderItems?.records?.map((item: any) =>
fromSalesforceOrderItemAPI(item)
),
// Include all other fields for dynamic access
...apiOrder
};
}
// Transform Salesforce API OrderItem response to domain SalesforceOrderItem
export function fromSalesforceOrderItemAPI(apiOrderItem: any): SalesforceOrderItem {
const product = fromSalesforceProduct2(
apiOrderItem.PricebookEntry?.Product2,
apiOrderItem.PricebookEntry
);
return {
id: apiOrderItem.Id,
orderId: apiOrderItem.OrderId,
quantity: apiOrderItem.Quantity,
unitPrice: apiOrderItem.UnitPrice,
totalPrice: apiOrderItem.TotalPrice,
pricebookEntry: {
id: apiOrderItem.PricebookEntry.Id,
name: apiOrderItem.PricebookEntry.Name,
unitPrice: apiOrderItem.PricebookEntry.UnitPrice || apiOrderItem.UnitPrice,
pricebook2Id: apiOrderItem.PricebookEntry.Pricebook2Id,
product2Id: apiOrderItem.PricebookEntry.Product2.Id,
isActive: true,
product2: product
},
whmcsServiceId: apiOrderItem.WHMCS_Service_ID__c,
billingCycle: product.billingCycle,
...apiOrderItem
};
}

View File

@ -1,71 +0,0 @@
// Subscription types from WHMCS
import type { SubscriptionStatus } from "../enums/status";
import type { WhmcsEntity } from "../common";
export type SubscriptionBillingCycle =
| "Monthly"
| "Quarterly"
| "Semi-Annually"
| "Annually"
| "Biennially"
| "Triennially"
| "One-time";
export interface Subscription extends WhmcsEntity {
serviceId: number;
productName: string;
domain?: string;
cycle: SubscriptionBillingCycle;
status: SubscriptionStatus;
nextDue?: string; // ISO
amount: number;
currency: string;
currencySymbol?: string; // e.g., '¥', '¥'
registrationDate: string; // ISO
notes?: string;
customFields?: Record<string, string>;
// Additional WHMCS fields
orderNumber?: string;
groupName?: string;
paymentMethod?: string;
serverName?: string;
}
export interface SubscriptionList {
subscriptions: Subscription[];
totalCount: number;
}
export interface Product {
id: number;
name: string;
description?: string;
group: string;
pricing: ProductPricing[];
features?: string[];
configOptions?: ConfigOption[];
available: boolean;
}
export interface ProductPricing {
cycle: string;
price: number;
currency: string;
setup?: number;
}
export type ConfigOptionType = "dropdown" | "radio" | "quantity";
export interface ConfigOption {
id: number;
name: string;
type: ConfigOptionType;
options: ConfigOptionValue[];
}
export interface ConfigOptionValue {
id: number;
name: string;
price?: number;
}

View File

@ -12,8 +12,6 @@ export interface User extends BaseEntity {
emailVerified: boolean;
}
export interface UserSummary {
user: User;
stats: {
@ -108,13 +106,8 @@ export type AuthErrorCode =
| "RATE_LIMITED"
| "NETWORK_ERROR";
// Enhanced user with UI-specific data (but still business logic)
export interface AuthUser extends User {
roles: Array<{ name: string; description?: string }>;
permissions: Array<{ resource: string; action: string }>;
// Enhanced user profile exposed to clients
export interface UserProfile extends User {
avatar?: string;
preferences?: Record<string, unknown>;
lastLoginAt?: string;

View File

@ -21,17 +21,6 @@ export * from "./patterns";
// NEW: Runtime validation with Zod
export * from "./validation";
// NEW: API response adapters
export * from "./adapters";
// NEW: Type-safe API client
export * from "./client";
// All types are now exported cleanly through entities barrel export
// Re-export key types for convenience
export type { Address } from "./common";
// Re-export commonly used pattern types for convenience
export type {
AsyncState,
@ -39,8 +28,6 @@ export type {
PaginationInfo,
PaginationParams,
PaginatedResponse,
FormState,
FormField,
} from "./patterns";
// Re-export commonly used utility types for convenience
@ -70,4 +57,4 @@ export type {
SalesforceContactId,
SalesforceAccountId,
SalesforceCaseId,
} from "./common";
} from "./common";

View File

@ -4,16 +4,16 @@
*/
export type AsyncState<TData = unknown, TError = string> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: TData }
| { status: 'error'; error: TError };
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: TData }
| { status: "error"; error: TError };
export type PaginatedAsyncState<TData, TError = string> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: TData[]; pagination: PaginationInfo }
| { status: 'error'; error: TError };
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: TData[]; pagination: PaginationInfo }
| { status: "error"; error: TError };
export interface PaginationInfo {
page: number;
@ -25,23 +25,23 @@ export interface PaginationInfo {
}
// Helper functions for state transitions
export const createIdleState = <T>(): AsyncState<T> => ({ status: 'idle' });
export const createLoadingState = <T>(): AsyncState<T> => ({ status: 'loading' });
export const createSuccessState = <T>(data: T): AsyncState<T> => ({ status: 'success', data });
export const createErrorState = <T>(error: string): AsyncState<T> => ({ status: 'error', error });
export const createIdleState = <T>(): AsyncState<T> => ({ status: "idle" });
export const createLoadingState = <T>(): AsyncState<T> => ({ status: "loading" });
export const createSuccessState = <T>(data: T): AsyncState<T> => ({ status: "success", data });
export const createErrorState = <T>(error: string): AsyncState<T> => ({ status: "error", error });
// Type guards
export const isIdle = <T>(state: AsyncState<T>): state is { status: 'idle' } =>
state.status === 'idle';
export const isIdle = <T>(state: AsyncState<T>): state is { status: "idle" } =>
state.status === "idle";
export const isLoading = <T>(state: AsyncState<T>): state is { status: 'loading' } =>
state.status === 'loading';
export const isLoading = <T>(state: AsyncState<T>): state is { status: "loading" } =>
state.status === "loading";
export const isSuccess = <T>(state: AsyncState<T>): state is { status: 'success'; data: T } =>
state.status === 'success';
export const isSuccess = <T>(state: AsyncState<T>): state is { status: "success"; data: T } =>
state.status === "success";
export const isError = <T>(state: AsyncState<T>): state is { status: 'error'; error: string } =>
state.status === 'error';
export const isError = <T>(state: AsyncState<T>): state is { status: "error"; error: string } =>
state.status === "error";
// Utility functions for working with async states
export const getDataOrNull = <T>(state: AsyncState<T>): T | null =>
@ -92,7 +92,7 @@ export const updateSelectionState = <T>(
const selected = isSelected
? [...state.selected, item]
: state.selected.filter(selectedItem => selectedItem !== item);
return {
selected,
selectAll: false,
@ -111,10 +111,10 @@ export const updateFilterState = <T>(
filters: Partial<T>
): FilterState<T> => {
const newFilters = { ...state.filters, ...filters };
const activeCount = Object.values(newFilters).filter(value =>
value !== null && value !== undefined && value !== ''
const activeCount = Object.values(newFilters).filter(
value => value !== null && value !== undefined && value !== ""
).length;
return {
filters: newFilters,
activeCount,

View File

@ -1,138 +0,0 @@
/**
* Enhanced Form State Types
*/
export interface FormField<T> {
value: T;
error?: string;
touched: boolean;
dirty: boolean;
}
export type FormState<T> = {
[K in keyof T]: FormField<T[K]>;
} & {
isValid: boolean;
isSubmitting: boolean;
submitCount: number;
errors: Partial<Record<keyof T, string>>;
};
// Helper functions
export const createFormField = <T>(value: T): FormField<T> => ({
value,
touched: false,
dirty: false,
});
export const createFormState = <T>(initialData: T): FormState<T> => {
const fields = {} as { [K in keyof T]: FormField<T[K]> };
for (const key in initialData) {
if (Object.prototype.hasOwnProperty.call(initialData, key)) {
fields[key] = createFormField(initialData[key]);
}
}
return {
...fields,
isValid: true,
isSubmitting: false,
submitCount: 0,
errors: {},
};
};
// Form field utilities
export const updateFormField = <T>(
field: FormField<T>,
value: T,
error?: string
): FormField<T> => ({
...field,
value,
error,
dirty: true,
touched: true,
});
export const touchFormField = <T>(field: FormField<T>): FormField<T> => ({
...field,
touched: true,
});
export const resetFormField = <T>(initialValue: T): FormField<T> => ({
value: initialValue,
touched: false,
dirty: false,
});
// Form state utilities
export const getFormValues = <T>(formState: FormState<T>): T => {
const values = {} as T;
for (const key in formState) {
if (Object.prototype.hasOwnProperty.call(formState, key)) {
const field = formState[key as keyof FormState<T>];
if (typeof field === 'object' && field !== null && 'value' in field) {
(values as any)[key] = (field as FormField<any>).value;
}
}
}
return values;
};
export const hasFormErrors = <T>(formState: FormState<T>): boolean => {
return Object.keys(formState.errors).length > 0 || !formState.isValid;
};
export const getFormFieldErrors = <T>(formState: FormState<T>): string[] => {
const errors: string[] = [];
for (const key in formState) {
if (Object.prototype.hasOwnProperty.call(formState, key)) {
const field = formState[key as keyof FormState<T>];
if (typeof field === 'object' && field !== null && 'error' in field) {
const formField = field as FormField<any>;
if (formField.error) {
errors.push(formField.error);
}
}
}
}
return errors;
};
export const isFormDirty = <T>(formState: FormState<T>): boolean => {
for (const key in formState) {
if (Object.prototype.hasOwnProperty.call(formState, key)) {
const field = formState[key as keyof FormState<T>];
if (typeof field === 'object' && field !== null && 'dirty' in field) {
const formField = field as FormField<any>;
if (formField.dirty) {
return true;
}
}
}
}
return false;
};
export const isFormTouched = <T>(formState: FormState<T>): boolean => {
for (const key in formState) {
if (Object.prototype.hasOwnProperty.call(formState, key)) {
const field = formState[key as keyof FormState<T>];
if (typeof field === 'object' && field !== null && 'touched' in field) {
const formField = field as FormField<any>;
if (formField.touched) {
return true;
}
}
}
}
return false;
};

View File

@ -3,13 +3,10 @@
*/
// Async state patterns
export * from './async-state';
// Form state patterns
export * from './form-state';
export * from "./async-state";
// Pagination patterns
export * from './pagination';
export * from "./pagination";
// Re-export commonly used types for convenience
export type {
@ -18,14 +15,6 @@ export type {
PaginationInfo,
SelectionState,
FilterState,
} from './async-state';
} from "./async-state";
export type {
PaginationParams,
PaginatedResponse,
} from './pagination';
export type {
FormState,
FormField,
} from './form-state';
export type { PaginationParams, PaginatedResponse } from "./pagination";

View File

@ -42,7 +42,7 @@ export const getDefaultPaginationParams = (): Required<PaginationParams> => ({
export const validatePaginationParams = (params: PaginationParams): Required<PaginationParams> => {
const { page = 1, limit = 10 } = params;
return {
page: Math.max(1, page),
limit: Math.min(Math.max(1, limit), 100), // Cap at 100 items per page

View File

@ -24,9 +24,7 @@ export const formatCurrency = (
currencyOrOptions: string | FormatCurrencyOptions = "JPY"
): string => {
const options =
typeof currencyOrOptions === "string"
? { currency: currencyOrOptions }
: currencyOrOptions;
typeof currencyOrOptions === "string" ? { currency: currencyOrOptions } : currencyOrOptions;
const {
currency,

View File

@ -1,6 +1,6 @@
/**
* Generic Filter Utilities
*
*
* Reusable filter patterns to eliminate duplication
*/

View File

@ -2,7 +2,7 @@
* Common Utility Types
*/
import type { BaseEntity } from '../common';
import type { BaseEntity } from "../common";
// =====================================================
// ENTITY UTILITIES
@ -11,12 +11,12 @@ import type { BaseEntity } from '../common';
// Entity utilities
export type WithId<T> = T & { id: string };
export type WithTimestamps<T> = T & BaseEntity;
export type CreateInput<T extends BaseEntity> = Omit<T, 'id' | 'createdAt' | 'updatedAt'>;
export type CreateInput<T extends BaseEntity> = Omit<T, "id" | "createdAt" | "updatedAt">;
export type UpdateInput<T extends BaseEntity> = Partial<CreateInput<T>>;
// Optional field utilities
export type WithOptionalId<T> = T & { id?: string };
export type WithOptionalTimestamps<T> = T & Partial<Pick<BaseEntity, 'createdAt' | 'updatedAt'>>;
export type WithOptionalTimestamps<T> = T & Partial<Pick<BaseEntity, "createdAt" | "updatedAt">>;
// =====================================================
// API UTILITIES
@ -43,9 +43,7 @@ export type ResponseWithMeta<T> = T & {
// Form data type that only includes serializable fields
export type FormData<T> = {
[K in keyof T]: T[K] extends string | number | boolean | Date | null | undefined
? T[K]
: never;
[K in keyof T]: T[K] extends string | number | boolean | Date | null | undefined ? T[K] : never;
};
// Form validation utilities
@ -159,7 +157,7 @@ export type IsArray<T> = T extends readonly unknown[] ? true : false;
export type IsObject<T> = T extends object ? true : false;
// Check if type is function
export type IsFunction<T> = T extends (...args: any[]) => any ? true : false;
export type IsFunction<T> = T extends (...args: unknown[]) => unknown ? true : false;
// Extract array element type
export type ArrayElement<T> = T extends readonly (infer U)[] ? U : never;
@ -168,7 +166,7 @@ export type ArrayElement<T> = T extends readonly (infer U)[] ? U : never;
export type PromiseType<T> = T extends Promise<infer U> ? U : never;
// Extract function return type
export type ReturnTypeOf<T> = T extends (...args: any[]) => infer R ? R : never;
export type ReturnTypeOf<T> = T extends (...args: unknown[]) => infer R ? R : never;
// Extract function parameters
export type ParametersOf<T> = T extends (...args: infer P) => any ? P : never;
export type ParametersOf<T> = T extends (...args: infer P) => unknown ? P : never;

View File

@ -51,7 +51,6 @@ export function validateEmail(email: string): ValidationResult<string> {
return success(trimmed);
}
/**
* Enhanced phone validation with better error reporting
*/
@ -75,7 +74,6 @@ export function validatePhoneNumber(phone: string): ValidationResult<string> {
return success(cleaned);
}
/**
* Validates password strength
*/

View File

@ -4,7 +4,7 @@
* These are the "source of truth" for business logic validation
*/
import { z } from 'zod';
import { z } from "zod";
import {
emailSchema,
passwordSchema,
@ -13,7 +13,7 @@ import {
addressSchema,
requiredAddressSchema,
genderEnum,
} from '../shared/primitives';
} from "../shared/primitives";
// =====================================================
// AUTH REQUEST SCHEMAS
@ -21,7 +21,7 @@ import {
export const loginRequestSchema = z.object({
email: emailSchema,
password: z.string().min(1, 'Password is required'),
password: z.string().min(1, "Password is required"),
});
export const signupRequestSchema = z.object({
@ -31,10 +31,10 @@ export const signupRequestSchema = z.object({
lastName: nameSchema,
company: z.string().optional(),
phone: phoneSchema,
sfNumber: z.string().min(6, 'Customer number must be at least 6 characters'),
sfNumber: z.string().min(6, "Customer number must be at least 6 characters"),
address: requiredAddressSchema,
nationality: z.string().optional(),
dateOfBirth: z.string().date().optional(),
dateOfBirth: z.string().optional(),
gender: genderEnum.optional(),
});
@ -43,7 +43,7 @@ export const passwordResetRequestSchema = z.object({
});
export const passwordResetSchema = z.object({
token: z.string().min(1, 'Reset token is required'),
token: z.string().min(1, "Reset token is required"),
password: passwordSchema,
});
@ -53,17 +53,17 @@ export const setPasswordRequestSchema = z.object({
});
export const changePasswordRequestSchema = z.object({
currentPassword: z.string().min(1, 'Current password is required'),
currentPassword: z.string().min(1, "Current password is required"),
newPassword: passwordSchema,
});
export const linkWhmcsRequestSchema = z.object({
email: emailSchema,
password: z.string().min(1, 'Password is required'),
password: z.string().min(1, "Password is required"),
});
export const validateSignupRequestSchema = z.object({
sfNumber: z.string().min(1, 'Customer number is required'),
sfNumber: z.string().min(1, "Customer number is required"),
});
export const accountStatusRequestSchema = z.object({
@ -79,11 +79,10 @@ export const checkPasswordNeededRequestSchema = z.object({
});
export const refreshTokenRequestSchema = z.object({
refreshToken: z.string().min(1, 'Refresh token is required'),
refreshToken: z.string().min(1, "Refresh token is required"),
deviceId: z.string().optional(),
});
// =====================================================
// TYPE EXPORTS
// =====================================================
@ -120,14 +119,14 @@ export const updateAddressRequestSchema = addressSchema;
export const orderConfigurationsSchema = z.object({
// Activation (All order types)
activationType: z.enum(['Immediate', 'Scheduled']).optional(),
activationType: z.enum(["Immediate", "Scheduled"]).optional(),
scheduledAt: z.string().datetime().optional(),
// Internet specific
accessMode: z.enum(['IPoE-BYOR', 'IPoE-HGW', 'PPPoE']).optional(),
accessMode: z.enum(["IPoE-BYOR", "IPoE-HGW", "PPPoE"]).optional(),
// SIM specific
simType: z.enum(['eSIM', 'Physical SIM']).optional(),
simType: z.enum(["eSIM", "Physical SIM"]).optional(),
eid: z.string().optional(), // Required for eSIM
// MNP/Porting
@ -140,7 +139,7 @@ export const orderConfigurationsSchema = z.object({
portingFirstName: z.string().optional(),
portingLastNameKatakana: z.string().optional(),
portingFirstNameKatakana: z.string().optional(),
portingGender: z.enum(['Male', 'Female', 'Corporate/Other']).optional(),
portingGender: z.enum(["Male", "Female", "Corporate/Other"]).optional(),
portingDateOfBirth: z.string().date().optional(),
// Optional address override captured at checkout
@ -148,8 +147,8 @@ export const orderConfigurationsSchema = z.object({
});
export const createOrderRequestSchema = z.object({
orderType: z.enum(['Internet', 'SIM', 'VPN', 'Other']),
skus: z.array(z.string().min(1, 'SKU cannot be empty')),
orderType: z.enum(["Internet", "SIM", "VPN", "Other"]),
skus: z.array(z.string().min(1, "SKU cannot be empty")),
configurations: orderConfigurationsSchema.optional(),
});
@ -158,19 +157,22 @@ export const createOrderRequestSchema = z.object({
// =====================================================
export const simTopupRequestSchema = z.object({
amount: z.number().positive('Amount must be positive'),
currency: z.string().length(3, 'Currency must be 3 characters').default('JPY'),
quotaMb: z.number().positive('Quota in MB must be positive'),
amount: z.number().positive("Amount must be positive"),
currency: z.string().length(3, "Currency must be 3 characters").default("JPY"),
quotaMb: z.number().positive("Quota in MB must be positive"),
});
export const simCancelRequestSchema = z.object({
reason: z.string().min(1, 'Cancellation reason is required').optional(),
scheduledAt: z.string().regex(/^\d{8}$/, 'Scheduled date must be in YYYYMMDD format').optional(),
reason: z.string().min(1, "Cancellation reason is required").optional(),
scheduledAt: z
.string()
.regex(/^\d{8}$/, "Scheduled date must be in YYYYMMDD format")
.optional(),
});
export const simChangePlanRequestSchema = z.object({
newPlanSku: z.string().min(1, 'New plan SKU is required'),
newPlanCode: z.string().min(1, 'New plan code is required'),
newPlanSku: z.string().min(1, "New plan SKU is required"),
newPlanCode: z.string().min(1, "New plan code is required"),
effectiveDate: z.string().date().optional(),
});
@ -178,7 +180,7 @@ export const simFeaturesRequestSchema = z.object({
voiceMailEnabled: z.boolean().optional(),
callWaitingEnabled: z.boolean().optional(),
internationalRoamingEnabled: z.boolean().optional(),
networkType: z.enum(['4G', '5G']).optional(),
networkType: z.enum(["4G", "5G"]).optional(),
});
// =====================================================
@ -186,10 +188,10 @@ export const simFeaturesRequestSchema = z.object({
// =====================================================
export const contactRequestSchema = z.object({
subject: z.string().min(1, 'Subject is required').max(200, 'Subject is too long'),
message: z.string().min(1, 'Message is required').max(2000, 'Message is too long'),
category: z.enum(['technical', 'billing', 'account', 'general']),
priority: z.enum(['low', 'medium', 'high', 'urgent']).default('medium'),
subject: z.string().min(1, "Subject is required").max(200, "Subject is too long"),
message: z.string().min(1, "Message is required").max(2000, "Message is too long"),
category: z.enum(["technical", "billing", "account", "general"]),
priority: z.enum(["low", "medium", "high", "urgent"]).default("medium"),
});
// =====================================================
@ -212,22 +214,22 @@ export type ContactRequest = z.infer<typeof contactRequestSchema>;
export const invoiceItemSchema = z.object({
id: z.number().int().positive(),
description: z.string().min(1, 'Description is required'),
amount: z.number().nonnegative('Amount must be non-negative'),
description: z.string().min(1, "Description is required"),
amount: z.number().nonnegative("Amount must be non-negative"),
quantity: z.number().int().positive().optional().default(1),
type: z.string().min(1, 'Type is required'),
type: z.string().min(1, "Type is required"),
serviceId: z.number().int().positive().optional(),
});
export const invoiceSchema = z.object({
id: z.number().int().positive(),
number: z.string().min(1, 'Invoice number is required'),
status: z.string().min(1, 'Status is required'),
currency: z.string().length(3, 'Currency must be 3 characters'),
number: z.string().min(1, "Invoice number is required"),
status: z.string().min(1, "Status is required"),
currency: z.string().length(3, "Currency must be 3 characters"),
currencySymbol: z.string().optional(),
total: z.number().nonnegative('Total must be non-negative'),
subtotal: z.number().nonnegative('Subtotal must be non-negative'),
tax: z.number().nonnegative('Tax must be non-negative'),
total: z.number().nonnegative("Total must be non-negative"),
subtotal: z.number().nonnegative("Subtotal must be non-negative"),
tax: z.number().nonnegative("Tax must be non-negative"),
issuedAt: z.string().datetime().optional(),
dueDate: z.string().datetime().optional(),
paidDate: z.string().datetime().optional(),
@ -263,23 +265,46 @@ export type InvoiceList = z.infer<typeof invoiceListSchema>;
// =====================================================
export const createMappingRequestSchema = z.object({
userId: z.string().uuid('User ID must be a valid UUID'),
whmcsClientId: z.number().int().positive('WHMCS client ID must be a positive integer'),
sfAccountId: z.string().regex(/^[a-zA-Z0-9]{15}$|^[a-zA-Z0-9]{18}$/, 'Salesforce account ID must be a valid 15 or 18 character ID').optional(),
userId: z.string().uuid("User ID must be a valid UUID"),
whmcsClientId: z.number().int().positive("WHMCS client ID must be a positive integer"),
sfAccountId: z
.string()
.regex(
/^[a-zA-Z0-9]{15}$|^[a-zA-Z0-9]{18}$/,
"Salesforce account ID must be a valid 15 or 18 character ID"
)
.optional(),
});
export const updateMappingRequestSchema = z.object({
whmcsClientId: z.number().int().positive('WHMCS client ID must be a positive integer').optional(),
sfAccountId: z.string().regex(/^[a-zA-Z0-9]{15}$|^[a-zA-Z0-9]{18}$/, 'Salesforce account ID must be a valid 15 or 18 character ID').optional(),
}).refine(
(data) => data.whmcsClientId !== undefined || data.sfAccountId !== undefined,
{ message: 'At least one field must be provided for update' }
);
export const updateMappingRequestSchema = z
.object({
whmcsClientId: z
.number()
.int()
.positive("WHMCS client ID must be a positive integer")
.optional(),
sfAccountId: z
.string()
.regex(
/^[a-zA-Z0-9]{15}$|^[a-zA-Z0-9]{18}$/,
"Salesforce account ID must be a valid 15 or 18 character ID"
)
.optional(),
})
.refine(data => data.whmcsClientId !== undefined || data.sfAccountId !== undefined, {
message: "At least one field must be provided for update",
});
export const userIdMappingSchema = z.object({
userId: z.string().uuid('User ID must be a valid UUID'),
whmcsClientId: z.number().int().positive('WHMCS client ID must be a positive integer'),
sfAccountId: z.string().regex(/^[a-zA-Z0-9]{15}$|^[a-zA-Z0-9]{18}$/, 'Salesforce account ID must be a valid 15 or 18 character ID').optional(),
userId: z.string().uuid("User ID must be a valid UUID"),
whmcsClientId: z.number().int().positive("WHMCS client ID must be a positive integer"),
sfAccountId: z
.string()
.regex(
/^[a-zA-Z0-9]{15}$|^[a-zA-Z0-9]{18}$/,
"Salesforce account ID must be a valid 15 or 18 character ID"
)
.optional(),
createdAt: z.string().datetime().optional(),
updatedAt: z.string().datetime().optional(),
});

View File

@ -0,0 +1,31 @@
import { z } from "zod";
import { userProfileSchema } from "../shared/entities";
import {
orderDetailItemSchema,
orderDetailItemProductSchema,
orderDetailsSchema,
orderSummaryItemSchema,
orderSummarySchema,
} from "../shared/order";
export const authResponseSchema = z.object({
user: userProfileSchema,
tokens: z.object({
accessToken: z.string().min(1, "Access token is required"),
refreshToken: z.string().min(1, "Refresh token is required"),
expiresAt: z.string().min(1, "Access token expiry required"),
refreshExpiresAt: z.string().min(1, "Refresh token expiry required"),
tokenType: z.literal("Bearer"),
}),
});
export type AuthResponse = z.infer<typeof authResponseSchema>;
export type AuthTokensSchema = AuthResponse["tokens"];
export { orderDetailsSchema, orderSummarySchema };
export type OrderDetailsResponse = z.infer<typeof orderDetailsSchema>;
export type OrderSummaryResponse = z.infer<typeof orderSummarySchema>;
export { orderDetailsSchema, orderSummarySchema };
export type OrderDetailsResponse = z.infer<typeof orderDetailsSchema>;
export type OrderSummaryResponse = z.infer<typeof orderSummarySchema>;

View File

@ -3,4 +3,4 @@
* Centralized business logic validation
*/
export * from './orders';
export * from "./orders";

View File

@ -3,76 +3,83 @@
* Business logic validation rules for orders
*/
import { z } from 'zod';
import { createOrderRequestSchema } from '../api/requests';
import { userIdSchema } from '../shared/identifiers';
import { z } from "zod";
import { createOrderRequestSchema } from "../api/requests";
import { userIdSchema } from "../shared/identifiers";
// =====================================================
// BUSINESS VALIDATION SCHEMAS
// =====================================================
export const orderBusinessValidationSchema = createOrderRequestSchema.extend({
userId: userIdSchema,
opportunityId: z.string().optional(),
}).refine(
(data) => {
// Business rule: Internet orders can only have one main service SKU
if (data.orderType === 'Internet') {
const mainServiceSkus = data.skus.filter(sku => !sku.includes('addon') && !sku.includes('fee'));
return mainServiceSkus.length === 1;
export const orderBusinessValidationSchema = createOrderRequestSchema
.extend({
userId: userIdSchema,
opportunityId: z.string().optional(),
})
.refine(
data => {
// Business rule: Internet orders can only have one main service SKU
if (data.orderType === "Internet") {
const mainServiceSkus = data.skus.filter(
sku => !sku.includes("addon") && !sku.includes("fee")
);
return mainServiceSkus.length === 1;
}
return true;
},
{
message: "Internet orders must have exactly one main service SKU",
path: ["skus"],
}
return true;
},
{
message: 'Internet orders must have exactly one main service SKU',
path: ['skus'],
}
).refine(
(data) => {
// Business rule: SIM orders require SIM-specific configuration
if (data.orderType === 'SIM' && data.configurations) {
return data.configurations.simType !== undefined;
)
.refine(
data => {
// Business rule: SIM orders require SIM-specific configuration
if (data.orderType === "SIM" && data.configurations) {
return data.configurations.simType !== undefined;
}
return true;
},
{
message: "SIM orders must specify SIM type",
path: ["configurations", "simType"],
}
return true;
},
{
message: 'SIM orders must specify SIM type',
path: ['configurations', 'simType'],
}
).refine(
(data) => {
// Business rule: eSIM orders require EID
if (data.configurations?.simType === 'eSIM') {
return data.configurations.eid !== undefined && data.configurations.eid.length > 0;
)
.refine(
data => {
// Business rule: eSIM orders require EID
if (data.configurations?.simType === "eSIM") {
return data.configurations.eid !== undefined && data.configurations.eid.length > 0;
}
return true;
},
{
message: "eSIM orders must provide EID",
path: ["configurations", "eid"],
}
return true;
},
{
message: 'eSIM orders must provide EID',
path: ['configurations', 'eid'],
}
).refine(
(data) => {
// Business rule: MNP orders require additional fields
if (data.configurations?.isMnp === 'true') {
const required = ['mnpNumber', 'portingLastName', 'portingFirstName'];
return required.every(field =>
data.configurations?.[field as keyof typeof data.configurations] !== undefined
);
)
.refine(
data => {
// Business rule: MNP orders require additional fields
if (data.configurations?.isMnp === "true") {
const required = ["mnpNumber", "portingLastName", "portingFirstName"];
return required.every(
field => data.configurations?.[field as keyof typeof data.configurations] !== undefined
);
}
return true;
},
{
message: "MNP orders must provide porting information",
path: ["configurations"],
}
return true;
},
{
message: 'MNP orders must provide porting information',
path: ['configurations'],
}
);
);
// SKU validation schema
export const skuValidationSchema = z.object({
sku: z.string().min(1, 'SKU is required'),
sku: z.string().min(1, "SKU is required"),
isActive: z.boolean(),
productType: z.enum(['Internet', 'SIM', 'VPN', 'Addon', 'Fee']),
productType: z.enum(["Internet", "SIM", "VPN", "Addon", "Fee"]),
price: z.number().nonnegative(),
currency: z.string().length(3),
});
@ -80,8 +87,8 @@ export const skuValidationSchema = z.object({
// User mapping validation schema
export const userMappingValidationSchema = z.object({
userId: userIdSchema,
sfAccountId: z.string().min(15, 'Salesforce Account ID must be at least 15 characters'),
whmcsClientId: z.number().int().positive('WHMCS Client ID must be positive'),
sfAccountId: z.string().min(15, "Salesforce Account ID must be at least 15 characters"),
whmcsClientId: z.number().int().positive("WHMCS Client ID must be positive"),
});
// Payment method validation schema
@ -89,11 +96,13 @@ export const paymentMethodValidationSchema = z.object({
userId: userIdSchema,
whmcsClientId: z.number().int().positive(),
hasValidPaymentMethod: z.boolean(),
paymentMethods: z.array(z.object({
id: z.string(),
type: z.string(),
isDefault: z.boolean(),
})),
paymentMethods: z.array(
z.object({
id: z.string(),
type: z.string(),
isDefault: z.boolean(),
})
),
});
// =====================================================

View File

@ -3,7 +3,6 @@
* Frontend form schemas that extend API request schemas with UI-specific fields
*/
import { z } from 'zod';
import {
loginRequestSchema,
signupRequestSchema,
@ -12,48 +11,23 @@ import {
setPasswordRequestSchema,
changePasswordRequestSchema,
linkWhmcsRequestSchema,
} from '../api/requests';
import { passwordSchema } from '../shared/primitives';
} from "../api/requests";
// =====================================================
// FORM SCHEMAS (Extend API schemas with UI fields)
// =====================================================
export const loginFormSchema = loginRequestSchema.extend({
rememberMe: z.boolean().optional().default(false),
});
export const loginFormSchema = loginRequestSchema;
export const signupFormSchema = signupRequestSchema.extend({
confirmPassword: passwordSchema,
acceptTerms: z.boolean().refine(val => val === true, 'You must accept the terms and conditions'),
marketingConsent: z.boolean().optional().default(false),
}).refine(data => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
export const signupFormSchema = signupRequestSchema;
export const passwordResetRequestFormSchema = passwordResetRequestSchema;
export const passwordResetFormSchema = passwordResetSchema.extend({
confirmPassword: passwordSchema,
}).refine(data => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
export const passwordResetFormSchema = passwordResetSchema;
export const setPasswordFormSchema = setPasswordRequestSchema.extend({
confirmPassword: passwordSchema,
}).refine(data => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
export const setPasswordFormSchema = setPasswordRequestSchema;
export const changePasswordFormSchema = changePasswordRequestSchema.extend({
confirmNewPassword: passwordSchema,
}).refine(data => data.newPassword === data.confirmNewPassword, {
message: "New passwords don't match",
path: ["confirmNewPassword"],
});
export const changePasswordFormSchema = changePasswordRequestSchema;
export const linkWhmcsFormSchema = linkWhmcsRequestSchema;
@ -61,30 +35,21 @@ export const linkWhmcsFormSchema = linkWhmcsRequestSchema;
// FORM TO API TRANSFORMATIONS
// =====================================================
export const loginFormToRequest = (formData: LoginFormData): LoginRequestData => {
const { rememberMe, ...requestData } = formData;
return requestData;
};
export const loginFormToRequest = (formData: LoginFormData): LoginRequestData =>
loginRequestSchema.parse(formData);
export const signupFormToRequest = (formData: SignupFormData): SignupRequestData => {
const { confirmPassword, acceptTerms, marketingConsent, ...requestData } = formData;
return requestData;
};
export const signupFormToRequest = (formData: SignupFormData): SignupRequestData =>
signupRequestSchema.parse(formData);
export const passwordResetFormToRequest = (formData: PasswordResetFormData): PasswordResetData => {
const { confirmPassword, ...requestData } = formData;
return requestData;
};
export const passwordResetFormToRequest = (formData: PasswordResetFormData): PasswordResetData =>
passwordResetSchema.parse(formData);
export const setPasswordFormToRequest = (formData: SetPasswordFormData): SetPasswordRequestData => {
const { confirmPassword, ...requestData } = formData;
return requestData;
};
export const setPasswordFormToRequest = (formData: SetPasswordFormData): SetPasswordRequestData =>
setPasswordRequestSchema.parse(formData);
export const changePasswordFormToRequest = (formData: ChangePasswordFormData): ChangePasswordRequestData => {
const { confirmNewPassword, ...requestData } = formData;
return requestData;
};
export const changePasswordFormToRequest = (
formData: ChangePasswordFormData
): ChangePasswordRequestData => changePasswordRequestSchema.parse(formData);
// =====================================================
// TYPE EXPORTS
@ -99,7 +64,7 @@ import type {
SetPasswordRequestInput as SetPasswordRequestData,
ChangePasswordRequestInput as ChangePasswordRequestData,
LinkWhmcsRequestInput as LinkWhmcsRequestData,
} from '../api/requests';
} from "../api/requests";
// Export form types
export type LoginFormData = z.infer<typeof loginFormSchema>;

View File

@ -1,47 +0,0 @@
/**
* Order Form Schemas
* Frontend form schemas for order management
*/
import { z } from 'zod';
import {
createOrderRequestSchema,
orderConfigurationsSchema,
} from '../api/requests';
// =====================================================
// ORDER FORM SCHEMAS
// =====================================================
export const orderFormSchema = createOrderRequestSchema.extend({
// UI-specific fields
saveAsTemplate: z.boolean().optional().default(false),
templateName: z.string().optional(),
agreeToTerms: z.boolean().refine(val => val === true, 'You must agree to the terms and conditions'),
});
export const orderConfigurationFormSchema = orderConfigurationsSchema.extend({
// UI-specific configuration fields
confirmScheduledActivation: z.boolean().optional(),
notifyOnActivation: z.boolean().optional().default(true),
});
// =====================================================
// FORM TO API TRANSFORMATIONS
// =====================================================
export const orderFormToRequest = (formData: OrderFormData): CreateOrderRequest => {
const { saveAsTemplate, templateName, agreeToTerms, ...requestData } = formData;
return requestData;
};
// =====================================================
// TYPE EXPORTS
// =====================================================
import type {
CreateOrderRequest,
} from '../api/requests';
export type OrderFormData = z.infer<typeof orderFormSchema>;
export type OrderConfigurationFormData = z.infer<typeof orderConfigurationFormSchema>;

View File

@ -3,9 +3,9 @@
* Frontend form schemas for profile and address management
*/
import { z } from 'zod';
import { updateProfileRequestSchema, updateAddressRequestSchema, contactRequestSchema } from '../api/requests';
import { addressSchema, requiredAddressSchema, nameSchema, emailSchema } from '../shared/primitives';
import { z } from "zod";
import { updateProfileRequestSchema, contactRequestSchema } from "../api/requests";
import { requiredAddressSchema, nameSchema } from "../shared/primitives";
// =====================================================
// PROFILE FORM SCHEMAS
@ -23,7 +23,7 @@ export const addressFormSchema = requiredAddressSchema;
// Contact form extends the API schema with user-friendly defaults
export const contactFormSchema = contactRequestSchema.extend({
// Make priority optional in the form with a default
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional().default('medium'),
priority: z.enum(["low", "medium", "high", "urgent"]).optional().default("medium"),
});
// =====================================================
@ -50,7 +50,7 @@ export const contactFormToRequest = (formData: ContactFormData): ContactRequestD
// Ensure priority has a default value
return {
...formData,
priority: formData.priority || 'medium',
priority: formData.priority || "medium",
};
};
@ -61,18 +61,10 @@ export const contactFormToRequest = (formData: ContactFormData): ContactRequestD
// Import API types
import type {
UpdateProfileRequest as UpdateProfileRequestData,
UpdateAddressRequest as UpdateAddressRequestData,
ContactRequest as ContactRequestData,
} from '../api/requests';
} from "../api/requests";
// Export form types
// Export form types and API request types
export type ProfileEditFormData = z.infer<typeof profileEditFormSchema>;
export type AddressFormData = z.infer<typeof addressFormSchema>;
export type ContactFormData = z.infer<typeof contactFormSchema>;
// Re-export API types for convenience
export type {
UpdateProfileRequestData,
UpdateAddressRequestData,
ContactRequestData,
};

View File

@ -1,101 +1,103 @@
import { z } from 'zod';
import { z } from "zod";
// SIM configuration enums
export const simTypeEnum = z.enum(['eSIM', 'Physical SIM']);
export const activationTypeEnum = z.enum(['Immediate', 'Scheduled']);
export const mnpGenderEnum = z.enum(['Male', 'Female', 'Corporate/Other', '']);
export const simTypeEnum = z.enum(["eSIM", "Physical SIM"]);
export const activationTypeEnum = z.enum(["Immediate", "Scheduled"]);
export const mnpGenderEnum = z.enum(["Male", "Female", "Corporate/Other", ""]);
// MNP (Mobile Number Portability) data schema
export const mnpDataSchema = z.object({
reservationNumber: z.string().min(1, 'Reservation number is required'),
expiryDate: z.string().min(1, 'Expiry date is required'),
phoneNumber: z.string().min(1, 'Phone number is required'),
mvnoAccountNumber: z.string().min(1, 'MVNO account number is required'),
portingLastName: z.string().min(1, 'Last name is required'),
portingFirstName: z.string().min(1, 'First name is required'),
portingLastNameKatakana: z.string().min(1, 'Last name (Katakana) is required'),
portingFirstNameKatakana: z.string().min(1, 'First name (Katakana) is required'),
reservationNumber: z.string().min(1, "Reservation number is required"),
expiryDate: z.string().min(1, "Expiry date is required"),
phoneNumber: z.string().min(1, "Phone number is required"),
mvnoAccountNumber: z.string().min(1, "MVNO account number is required"),
portingLastName: z.string().min(1, "Last name is required"),
portingFirstName: z.string().min(1, "First name is required"),
portingLastNameKatakana: z.string().min(1, "Last name (Katakana) is required"),
portingFirstNameKatakana: z.string().min(1, "First name (Katakana) is required"),
portingGender: mnpGenderEnum,
portingDateOfBirth: z.string().min(1, 'Date of birth is required'),
portingDateOfBirth: z.string().min(1, "Date of birth is required"),
});
// SIM configuration form schema
export const simConfigureFormSchema = z.object({
// Basic SIM configuration
simType: simTypeEnum,
eid: z.string().optional(),
// Selected addons
selectedAddons: z.array(z.string()).default([]),
// Activation configuration
activationType: activationTypeEnum,
scheduledActivationDate: z.string().optional(),
// MNP configuration
wantsMnp: z.boolean().default(false),
mnpData: mnpDataSchema.optional(),
}).superRefine((data, ctx) => {
// EID validation for eSIM
if (data.simType === 'eSIM') {
if (!data.eid || data.eid.trim().length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'EID is required for eSIM activation',
path: ['eid'],
});
} else if (data.eid.length < 15) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'EID must be at least 15 characters',
path: ['eid'],
});
}
}
// Scheduled activation date validation
if (data.activationType === 'Scheduled') {
if (!data.scheduledActivationDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Activation date is required when scheduling activation',
path: ['scheduledActivationDate'],
});
} else {
const selectedDate = new Date(data.scheduledActivationDate);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (selectedDate < today) {
export const simConfigureFormSchema = z
.object({
// Basic SIM configuration
simType: simTypeEnum,
eid: z.string().optional(),
// Selected addons
selectedAddons: z.array(z.string()).default([]),
// Activation configuration
activationType: activationTypeEnum,
scheduledActivationDate: z.string().optional(),
// MNP configuration
wantsMnp: z.boolean().default(false),
mnpData: mnpDataSchema.optional(),
})
.superRefine((data, ctx) => {
// EID validation for eSIM
if (data.simType === "eSIM") {
if (!data.eid || data.eid.trim().length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Activation date cannot be in the past',
path: ['scheduledActivationDate'],
message: "EID is required for eSIM activation",
path: ["eid"],
});
}
const maxDate = new Date();
maxDate.setDate(maxDate.getDate() + 30);
if (selectedDate > maxDate) {
} else if (data.eid.length < 15) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Activation date cannot be more than 30 days in the future',
path: ['scheduledActivationDate'],
message: "EID must be at least 15 characters",
path: ["eid"],
});
}
}
}
// MNP data validation
if (data.wantsMnp && !data.mnpData) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'MNP data is required when mobile number portability is selected',
path: ['mnpData'],
});
}
});
// Scheduled activation date validation
if (data.activationType === "Scheduled") {
if (!data.scheduledActivationDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Activation date is required when scheduling activation",
path: ["scheduledActivationDate"],
});
} else {
const selectedDate = new Date(data.scheduledActivationDate);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (selectedDate < today) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Activation date cannot be in the past",
path: ["scheduledActivationDate"],
});
}
const maxDate = new Date();
maxDate.setDate(maxDate.getDate() + 30);
if (selectedDate > maxDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Activation date cannot be more than 30 days in the future",
path: ["scheduledActivationDate"],
});
}
}
}
// MNP data validation
if (data.wantsMnp && !data.mnpData) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "MNP data is required when mobile number portability is selected",
path: ["mnpData"],
});
}
});
// Type exports
export type SimType = z.infer<typeof simTypeEnum>;

View File

@ -1,79 +0,0 @@
/**
* Subscription Form Schemas
* Frontend form schemas for subscription management
*/
import { z } from 'zod';
import {
simTopupRequestSchema,
simCancelRequestSchema,
simChangePlanRequestSchema,
simFeaturesRequestSchema,
} from '../api/requests';
// =====================================================
// SUBSCRIPTION FORM SCHEMAS
// =====================================================
export const simTopupFormSchema = simTopupRequestSchema.extend({
// UI-specific fields
confirmTopup: z.boolean().refine(val => val === true, 'You must confirm the top-up'),
notifyOnCompletion: z.boolean().optional().default(true),
});
export const simCancelFormSchema = simCancelRequestSchema.extend({
// UI-specific fields
confirmCancellation: z.boolean().refine(val => val === true, 'You must confirm the cancellation'),
feedbackOptional: z.string().max(500).optional(),
});
export const simChangePlanFormSchema = simChangePlanRequestSchema.extend({
// UI-specific fields
confirmPlanChange: z.boolean().refine(val => val === true, 'You must confirm the plan change'),
notifyOnChange: z.boolean().optional().default(true),
});
export const simFeaturesFormSchema = simFeaturesRequestSchema.extend({
// UI-specific fields
confirmFeatureChanges: z.boolean().refine(val => val === true, 'You must confirm the feature changes'),
});
// =====================================================
// FORM TO API TRANSFORMATIONS
// =====================================================
export const simTopupFormToRequest = (formData: SimTopupFormData): SimTopupRequest => {
const { confirmTopup, notifyOnCompletion, ...requestData } = formData;
return requestData;
};
export const simCancelFormToRequest = (formData: SimCancelFormData): SimCancelRequest => {
const { confirmCancellation, feedbackOptional, ...requestData } = formData;
return requestData;
};
export const simChangePlanFormToRequest = (formData: SimChangePlanFormData): SimChangePlanRequest => {
const { confirmPlanChange, notifyOnChange, ...requestData } = formData;
return requestData;
};
export const simFeaturesFormToRequest = (formData: SimFeaturesFormData): SimFeaturesRequest => {
const { confirmFeatureChanges, ...requestData } = formData;
return requestData;
};
// =====================================================
// TYPE EXPORTS
// =====================================================
import type {
SimTopupRequest,
SimCancelRequest,
SimChangePlanRequest,
SimFeaturesRequest,
} from '../api/requests';
export type SimTopupFormData = z.infer<typeof simTopupFormSchema>;
export type SimCancelFormData = z.infer<typeof simCancelFormSchema>;
export type SimChangePlanFormData = z.infer<typeof simChangePlanFormSchema>;
export type SimFeaturesFormData = z.infer<typeof simFeaturesFormSchema>;

View File

@ -1,8 +1,8 @@
/**
* Validation Module - Unified Architecture
*
*
* Clean, organized validation system with clear separation of concerns:
*
*
* 1. shared/* - Modular validation primitives and patterns
* 2. api/requests.ts - Backend API request schemas
* 3. forms/* - Frontend form schemas (extend API schemas with UI fields)
@ -14,7 +14,7 @@
// =====================================================
// Shared validation modules (modular architecture)
export * from './shared';
export * from "./shared";
// API request schemas (backend) - explicit exports for better tree shaking
export {
@ -31,24 +31,24 @@ export {
ssoLinkRequestSchema,
checkPasswordNeededRequestSchema,
refreshTokenRequestSchema,
// User management API schemas
updateProfileRequestSchema,
updateAddressRequestSchema,
// Order API schemas
createOrderRequestSchema,
orderConfigurationsSchema,
// Subscription API schemas
simTopupRequestSchema,
simCancelRequestSchema,
simChangePlanRequestSchema,
simFeaturesRequestSchema,
// Contact API schemas
contactRequestSchema,
// API types
type LoginRequestInput,
type SignupRequestInput,
@ -71,9 +71,15 @@ export {
type SimChangePlanRequest,
type SimFeaturesRequest,
type ContactRequest,
} from './api/requests';
} from "./api/requests";
// Form schemas (frontend) - explicit exports for better tree shaking
export {
authResponseSchema,
type AuthResponse,
type AuthTokensSchema,
} from "./api/responses";
export {
// Auth form schemas
loginFormSchema,
@ -99,57 +105,26 @@ export {
// Auth API type aliases
type LinkWhmcsRequestData,
} from './forms/auth';
} from "./forms/auth";
export {
// Profile form schemas
profileEditFormSchema,
addressFormSchema,
contactFormSchema,
// Profile form types
type ProfileEditFormData,
type AddressFormData,
type ContactFormData,
// Profile transformations
profileFormToRequest,
addressFormToRequest,
contactFormToRequest,
} from './forms/profile';
} from "./forms/profile";
export {
// Order form schemas
orderFormSchema,
orderConfigurationFormSchema,
// Order form types
type OrderFormData,
type OrderConfigurationFormData,
// Order transformations
orderFormToRequest,
} from './forms/orders';
export {
// Subscription form schemas
simTopupFormSchema,
simCancelFormSchema,
simChangePlanFormSchema,
simFeaturesFormSchema,
// Subscription form types
type SimTopupFormData,
type SimCancelFormData,
type SimChangePlanFormData,
type SimFeaturesFormData,
// Subscription transformations
simTopupFormToRequest,
simCancelFormToRequest,
simChangePlanFormToRequest,
simFeaturesFormToRequest,
} from './forms/subscriptions';
// Order and subscription form schemas were legacy-specific; remove exports if not in use
// SIM configuration forms
export {
@ -164,7 +139,7 @@ export {
type MnpGender,
type MnpData,
type SimConfigureFormData,
} from './forms/sim-configure';
} from "./forms/sim-configure";
// Business validation schemas - use schema.safeParse() directly
export {
@ -176,11 +151,7 @@ export {
type SkuValidation,
type UserMappingValidation,
type PaymentMethodValidation,
} from './business';
} from "./business";
// Simple validation utilities (direct Zod usage)
export {
z,
parseOrThrow,
safeParse,
} from './shared/utilities';
export { z, parseOrThrow, safeParse } from "./shared/utilities";

View File

@ -3,25 +3,25 @@
* Reusable validation patterns for pagination, API responses, etc.
*/
import { z } from 'zod';
import { timestampSchema } from './primitives';
import { z } from "zod";
import { timestampSchema } from "./primitives";
// =====================================================
// BASE ENTITY PATTERNS
// =====================================================
export const baseEntitySchema = z.object({
id: z.string().min(1, 'ID is required'),
id: z.string().min(1, "ID is required"),
createdAt: timestampSchema,
updatedAt: timestampSchema,
});
export const whmcsEntitySchema = z.object({
id: z.number().int().positive('WHMCS ID must be a positive integer'),
id: z.number().int().positive("WHMCS ID must be a positive integer"),
});
export const salesforceEntitySchema = z.object({
id: z.string().min(15, 'Salesforce ID must be at least 15 characters'),
id: z.string().min(15, "Salesforce ID must be at least 15 characters"),
createdDate: timestampSchema,
lastModifiedDate: timestampSchema,
});
@ -31,8 +31,8 @@ export const salesforceEntitySchema = z.object({
// =====================================================
export const paginationParamsSchema = z.object({
page: z.number().int().min(1, 'Page must be at least 1').default(1),
limit: z.number().int().min(1).max(100, 'Limit must be between 1 and 100').default(20),
page: z.number().int().min(1, "Page must be at least 1").default(1),
limit: z.number().int().min(1).max(100, "Limit must be between 1 and 100").default(20),
});
export const paginationInfoSchema = z.object({
@ -75,10 +75,7 @@ export const apiFailureSchema = z.object({
});
export const apiResponseSchema = <T>(dataSchema: z.ZodSchema<T>) =>
z.discriminatedUnion('success', [
apiSuccessSchema(dataSchema),
apiFailureSchema,
]);
z.discriminatedUnion("success", [apiSuccessSchema(dataSchema), apiFailureSchema]);
// =====================================================
// FORM STATE PATTERNS
@ -92,9 +89,9 @@ export const formFieldSchema = <T>(valueSchema: z.ZodSchema<T>) =>
dirty: z.boolean().default(false),
});
export const formStateSchema = <T extends Record<string, any>>(fieldsSchema: z.ZodSchema<T>) =>
export const formStateSchema = <TField extends z.ZodTypeAny>(fieldSchema: TField) =>
z.object({
fields: z.record(z.string(), formFieldSchema(z.unknown())),
fields: z.record(z.string(), formFieldSchema(fieldSchema)),
isValid: z.boolean(),
isSubmitting: z.boolean().default(false),
submitCount: z.number().int().min(0).default(0),
@ -106,26 +103,26 @@ export const formStateSchema = <T extends Record<string, any>>(fieldsSchema: z.Z
// =====================================================
export const asyncStateIdleSchema = z.object({
status: z.literal('idle'),
status: z.literal("idle"),
});
export const asyncStateLoadingSchema = z.object({
status: z.literal('loading'),
status: z.literal("loading"),
});
export const asyncStateSuccessSchema = <T>(dataSchema: z.ZodSchema<T>) =>
z.object({
status: z.literal('success'),
status: z.literal("success"),
data: dataSchema,
});
export const asyncStateErrorSchema = z.object({
status: z.literal('error'),
status: z.literal("error"),
error: z.string(),
});
export const asyncStateSchema = <T>(dataSchema: z.ZodSchema<T>) =>
z.discriminatedUnion('status', [
z.discriminatedUnion("status", [
asyncStateIdleSchema,
asyncStateLoadingSchema,
asyncStateSuccessSchema(dataSchema),
@ -147,7 +144,9 @@ export type ApiSuccessSchema<T> = z.infer<ReturnType<typeof apiSuccessSchema<T>>
export type ApiFailureSchema = z.infer<typeof apiFailureSchema>;
export type ApiResponseSchema<T> = z.infer<ReturnType<typeof apiResponseSchema<T>>>;
export type FormFieldSchema<T> = z.infer<ReturnType<typeof formFieldSchema<T>>>;
export type FormStateSchema<T extends Record<string, any>> = z.infer<ReturnType<typeof formStateSchema<T>>>;
export type FormStateSchema<TField extends z.ZodTypeAny> = z.infer<
ReturnType<typeof formStateSchema<TField>>
>;
export type AsyncStateIdleSchema = z.infer<typeof asyncStateIdleSchema>;
export type AsyncStateLoadingSchema = z.infer<typeof asyncStateLoadingSchema>;
export type AsyncStateSuccessSchema<T> = z.infer<ReturnType<typeof asyncStateSuccessSchema<T>>>;

View File

@ -3,32 +3,28 @@
* Business entity validation schemas aligned with documented domain entities.
*/
import { z } from 'zod';
import { z } from "zod";
import {
emailSchema,
nameSchema,
phoneSchema,
moneyAmountSchema,
timestampSchema,
} from './primitives';
} from "./primitives";
import {
userIdSchema,
invoiceIdSchema,
subscriptionIdSchema,
paymentIdSchema,
} from './identifiers';
import {
baseEntitySchema,
whmcsEntitySchema,
salesforceEntitySchema,
} from './common';
} from "./identifiers";
import { baseEntitySchema, whmcsEntitySchema, salesforceEntitySchema } from "./common";
import {
INVOICE_STATUS,
SUBSCRIPTION_STATUS,
CASE_STATUS,
CASE_PRIORITY,
PAYMENT_STATUS,
} from '../../enums/status';
} from "../../enums/status";
// =====================================================
// HELPERS
@ -37,7 +33,7 @@ import {
const tupleFromEnum = <T extends Record<string, string>>(enumObject: T) => {
const values = Object.values(enumObject);
if (values.length === 0) {
throw new Error('Enum must have at least one value');
throw new Error("Enum must have at least one value");
}
return [...new Set(values)] as [T[keyof T], ...Array<T[keyof T]>];
};
@ -51,40 +47,30 @@ const addressRecordSchema = z.object({
country: z.string().nullable(),
});
const roleSchema = z.object({
name: z.string().min(1, 'Role name is required'),
description: z.string().optional(),
});
const permissionSchema = z.object({
resource: z.string().min(1, 'Permission resource is required'),
action: z.string().min(1, 'Permission action is required'),
});
const paymentMethodTypeSchema = z.enum([
'CreditCard',
'BankAccount',
'RemoteCreditCard',
'RemoteBankAccount',
'Manual',
"CreditCard",
"BankAccount",
"RemoteCreditCard",
"RemoteBankAccount",
"Manual",
]);
const orderStatusSchema = z.enum(['Pending', 'Active', 'Cancelled', 'Fraud']);
const orderStatusSchema = z.enum(["Pending", "Active", "Cancelled", "Fraud"]);
const invoiceStatusSchema = z.enum(tupleFromEnum(INVOICE_STATUS));
const subscriptionStatusSchema = z.enum(tupleFromEnum(SUBSCRIPTION_STATUS));
const caseStatusSchema = z.enum(tupleFromEnum(CASE_STATUS));
const casePrioritySchema = z.enum(tupleFromEnum(CASE_PRIORITY));
const paymentStatusSchema = z.enum(tupleFromEnum(PAYMENT_STATUS));
const caseTypeSchema = z.enum(['Question', 'Problem', 'Feature Request']);
const caseTypeSchema = z.enum(["Question", "Problem", "Feature Request"]);
const subscriptionCycleSchema = z.enum([
'Monthly',
'Quarterly',
'Semi-Annually',
'Annually',
'Biennially',
'Triennially',
'One-time',
"Monthly",
"Quarterly",
"Semi-Annually",
"Annually",
"Biennially",
"Triennially",
"One-time",
]);
// =====================================================
@ -102,19 +88,31 @@ export const userSchema = baseEntitySchema.extend({
emailVerified: z.boolean(),
});
export const authUserSchema = userSchema.extend({
roles: z.array(roleSchema),
permissions: z.array(permissionSchema),
export const userProfileSchema = userSchema.extend({
avatar: z.string().optional(),
preferences: z.record(z.string(), z.unknown()).optional(),
lastLoginAt: timestampSchema.optional(),
});
export const prismaUserProfileSchema = z.object({
id: z.string().uuid(),
email: emailSchema,
firstName: z.string().nullable(),
lastName: z.string().nullable(),
company: z.string().nullable(),
phone: z.string().nullable(),
mfaSecret: z.string().nullable(),
emailVerified: z.boolean(),
createdAt: z.date(),
updatedAt: z.date(),
lastLoginAt: z.date().nullable(),
});
export const mnpDetailsSchema = z.object({
currentProvider: z.string().min(1, 'Current provider is required'),
currentProvider: z.string().min(1, "Current provider is required"),
phoneNumber: phoneSchema,
accountNumber: z.string().min(1, 'Account number is required').optional(),
pin: z.string().min(1, 'PIN is required').optional(),
accountNumber: z.string().min(1, "Account number is required").optional(),
pin: z.string().min(1, "PIN is required").optional(),
});
// =====================================================
@ -122,27 +120,27 @@ export const mnpDetailsSchema = z.object({
// =====================================================
export const orderTotalsSchema = z.object({
monthlyTotal: z.number().nonnegative('Monthly total must be non-negative'),
oneTimeTotal: z.number().nonnegative('One-time total must be non-negative'),
monthlyTotal: z.number().nonnegative("Monthly total must be non-negative"),
oneTimeTotal: z.number().nonnegative("One-time total must be non-negative"),
});
export const whmcsOrderItemSchema = z.object({
productId: z.number().int().positive('Product id must be positive'),
productName: z.string().min(1, 'Product name is required'),
productId: z.number().int().positive("Product id must be positive"),
productName: z.string().min(1, "Product name is required"),
domain: z.string().optional(),
cycle: z.string().min(1, 'Billing cycle is required'),
quantity: z.number().int().positive('Quantity must be positive'),
cycle: z.string().min(1, "Billing cycle is required"),
quantity: z.number().int().positive("Quantity must be positive"),
price: z.number(),
setup: z.number().optional(),
configOptions: z.record(z.string(), z.string()).optional(),
});
export const whmcsOrderSchema = whmcsEntitySchema.extend({
orderNumber: z.string().min(1, 'Order number is required'),
orderNumber: z.string().min(1, "Order number is required"),
status: orderStatusSchema,
date: z.string().min(1, 'Order date is required'),
date: z.string().min(1, "Order date is required"),
amount: z.number(),
currency: z.string().min(1, 'Currency is required'),
currency: z.string().min(1, "Currency is required"),
paymentMethod: z.string().optional(),
items: z.array(whmcsOrderItemSchema),
invoiceId: z.number().int().positive().optional(),
@ -153,19 +151,19 @@ export const whmcsOrderSchema = whmcsEntitySchema.extend({
// =====================================================
export const invoiceItemSchema = z.object({
id: z.number().int().positive('Invoice item id must be positive'),
description: z.string().min(1, 'Description is required'),
id: z.number().int().positive("Invoice item id must be positive"),
description: z.string().min(1, "Description is required"),
amount: z.number(),
quantity: z.number().int().positive('Quantity must be positive').optional(),
type: z.string().min(1, 'Item type is required'),
quantity: z.number().int().positive("Quantity must be positive").optional(),
type: z.string().min(1, "Item type is required"),
serviceId: z.number().int().positive().optional(),
});
export const invoiceSchema = whmcsEntitySchema.extend({
number: z.string().min(1, 'Invoice number is required'),
number: z.string().min(1, "Invoice number is required"),
status: invoiceStatusSchema,
currency: z.string().min(1, 'Currency is required'),
currencySymbol: z.string().min(1, 'Currency symbol is required').optional(),
currency: z.string().min(1, "Currency is required"),
currencySymbol: z.string().min(1, "Currency symbol is required").optional(),
total: z.number(),
subtotal: z.number(),
tax: z.number(),
@ -183,16 +181,16 @@ export const invoiceSchema = whmcsEntitySchema.extend({
// =====================================================
export const subscriptionSchema = whmcsEntitySchema.extend({
serviceId: z.number().int().positive('Service id is required'),
productName: z.string().min(1, 'Product name is required'),
serviceId: z.number().int().positive("Service id is required"),
productName: z.string().min(1, "Product name is required"),
domain: z.string().optional(),
cycle: subscriptionCycleSchema,
status: subscriptionStatusSchema,
nextDue: z.string().optional(),
amount: z.number(),
currency: z.string().min(1, 'Currency is required'),
currency: z.string().min(1, "Currency is required"),
currencySymbol: z.string().optional(),
registrationDate: z.string().min(1, 'Registration date is required'),
registrationDate: z.string().min(1, "Registration date is required"),
notes: z.string().optional(),
customFields: z.record(z.string(), z.string()).optional(),
orderNumber: z.string().optional(),
@ -207,11 +205,11 @@ export const subscriptionSchema = whmcsEntitySchema.extend({
export const paymentMethodSchema = whmcsEntitySchema.extend({
type: paymentMethodTypeSchema,
description: z.string().min(1, 'Payment method description is required'),
description: z.string().min(1, "Payment method description is required"),
gatewayName: z.string().optional(),
gatewayDisplayName: z.string().optional(),
isDefault: z.boolean().optional(),
lastFour: z.string().length(4, 'Last four must be exactly 4 digits').optional(),
lastFour: z.string().length(4, "Last four must be exactly 4 digits").optional(),
expiryDate: z.string().optional(),
bankName: z.string().optional(),
accountType: z.string().optional(),
@ -229,7 +227,7 @@ export const paymentSchema = z.object({
invoiceId: invoiceIdSchema.optional(),
subscriptionId: subscriptionIdSchema.optional(),
amount: moneyAmountSchema,
currency: z.string().length(3, 'Currency must be a 3-letter ISO code').optional(),
currency: z.string().length(3, "Currency must be a 3-letter ISO code").optional(),
status: paymentStatusSchema,
transactionId: z.string().optional(),
failureReason: z.string().optional(),
@ -243,20 +241,20 @@ export const paymentSchema = z.object({
// =====================================================
export const caseCommentSchema = z.object({
id: z.string().min(1, 'Comment id is required'),
body: z.string().min(1, 'Comment body is required'),
id: z.string().min(1, "Comment id is required"),
body: z.string().min(1, "Comment body is required"),
isPublic: z.boolean(),
createdDate: timestampSchema,
createdBy: z.object({
id: z.string().min(1, 'Created by id is required'),
name: z.string().min(1, 'Created by name is required'),
type: z.enum(['user', 'customer']),
id: z.string().min(1, "Created by id is required"),
name: z.string().min(1, "Created by name is required"),
type: z.enum(["user", "customer"]),
}),
});
export const supportCaseSchema = salesforceEntitySchema.extend({
number: z.string().min(1, 'Case number is required'),
subject: z.string().min(1, 'Subject is required'),
number: z.string().min(1, "Case number is required"),
subject: z.string().min(1, "Subject is required"),
description: z.string().optional(),
status: caseStatusSchema,
priority: casePrioritySchema,
@ -274,7 +272,7 @@ export const supportCaseSchema = salesforceEntitySchema.extend({
// =====================================================
export type UserSchema = z.infer<typeof userSchema>;
export type AuthUserSchema = z.infer<typeof authUserSchema>;
export type UserProfileSchema = z.infer<typeof userProfileSchema>;
export type MnpDetailsSchema = z.infer<typeof mnpDetailsSchema>;
export type OrderTotalsSchema = z.infer<typeof orderTotalsSchema>;
export type WhmcsOrderItemSchema = z.infer<typeof whmcsOrderItemSchema>;

Some files were not shown because too many files have changed in this diff Show More