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:
parent
473a1235c8
commit
6becad1511
@ -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",
|
||||
|
||||
@ -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");
|
||||
@ -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 {}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 {}
|
||||
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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 {}
|
||||
@ -1,6 +0,0 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class CasesService {
|
||||
// TODO: Implement case business logic
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
268
apps/bff/src/modules/catalog/utils/salesforce-product.mapper.ts
Normal file
268
apps/bff/src/modules/catalog/utils/salesforce-product.mapper.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
@ -1,6 +0,0 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class JobsService {
|
||||
// TODO: Implement job service logic
|
||||
}
|
||||
@ -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" };
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
@ -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" };
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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'>>;
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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]
|
||||
);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
@ -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);
|
||||
};
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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;
|
||||
},
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
@ -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';
|
||||
@ -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';
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
63
packages/domain/src/contracts/catalog.ts
Normal file
63
packages/domain/src/contracts/catalog.ts
Normal 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;
|
||||
}
|
||||
|
||||
0
packages/domain/src/contracts/common.ts
Normal file
0
packages/domain/src/contracts/common.ts
Normal file
@ -1,2 +1,4 @@
|
||||
// Export all API contracts
|
||||
export * from "./api";
|
||||
export * from "./catalog";
|
||||
export * from "./salesforce";
|
||||
|
||||
117
packages/domain/src/contracts/salesforce.ts
Normal file
117
packages/domain/src/contracts/salesforce.ts
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
@ -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";
|
||||
@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Dashboard Domain Entities
|
||||
*
|
||||
*
|
||||
* Business entities for dashboard data and statistics
|
||||
*/
|
||||
|
||||
|
||||
@ -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 };
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -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";
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Generic Filter Utilities
|
||||
*
|
||||
*
|
||||
* Reusable filter patterns to eliminate duplication
|
||||
*/
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
31
packages/domain/src/validation/api/responses.ts
Normal file
31
packages/domain/src/validation/api/responses.ts
Normal 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>;
|
||||
@ -3,4 +3,4 @@
|
||||
* Centralized business logic validation
|
||||
*/
|
||||
|
||||
export * from './orders';
|
||||
export * from "./orders";
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
// =====================================================
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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>;
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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>;
|
||||
@ -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";
|
||||
|
||||
@ -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>>>;
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user