From 6becad1511a3105aab068bfb923e9fdbe98335dc Mon Sep 17 00:00:00 2001 From: barsa Date: Wed, 24 Sep 2025 18:00:49 +0900 Subject: [PATCH] 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. --- apps/bff/package.json | 2 +- .../migration.sql | 17 + apps/bff/src/app.module.ts | 9 +- apps/bff/src/core/config/field-map.ts | 7 +- .../core/http/success-response.interceptor.ts | 25 +- apps/bff/src/core/validation/index.ts | 32 +- apps/bff/src/infra/utils/user-mapper.util.ts | 11 +- .../salesforce/salesforce.service.ts | 11 +- .../services/salesforce-account.service.ts | 57 +- .../services/salesforce-case.service.ts | 96 ++-- .../src/modules/auth/auth-zod.controller.ts | 40 +- apps/bff/src/modules/auth/auth.module.ts | 3 +- apps/bff/src/modules/auth/auth.service.ts | 26 +- apps/bff/src/modules/auth/auth.types.ts | 9 +- .../modules/auth/services/token.service.ts | 8 +- .../workflows/password-workflow.service.ts | 49 +- .../workflows/signup-workflow.service.ts | 25 +- .../workflows/whmcs-link-workflow.service.ts | 21 +- .../modules/auth/strategies/jwt.strategy.ts | 25 +- .../modules/auth/utils/sanitize-user.util.ts | 23 - .../bff/src/modules/cases/cases.controller.ts | 11 - apps/bff/src/modules/cases/cases.module.ts | 12 - apps/bff/src/modules/cases/cases.service.ts | 6 - .../src/modules/catalog/catalog.controller.ts | 21 +- .../services/internet-catalog.service.ts | 77 +-- .../catalog/services/sim-catalog.service.ts | 49 +- .../catalog/services/vpn-catalog.service.ts | 27 +- .../utils/salesforce-product.mapper.ts | 268 +++++++++ .../utils/salesforce-product.pricing.ts | 64 +++ apps/bff/src/modules/jobs/jobs.module.ts | 10 - apps/bff/src/modules/jobs/jobs.service.ts | 6 - .../src/modules/jobs/reconcile.processor.ts | 22 - .../src/modules/orders/orders.controller.ts | 7 +- .../order-fulfillment-validator.service.ts | 6 +- .../services/order-item-builder.service.ts | 63 ++- .../services/order-orchestrator.service.ts | 159 +++--- .../modules/orders/types/order-details.dto.ts | 54 -- .../subscriptions/subscriptions.controller.ts | 15 +- .../bff/src/modules/users/users.controller.ts | 9 +- apps/bff/src/modules/users/users.service.ts | 10 +- .../account/services/account.service.ts | 6 +- .../src/features/auth/hooks/use-auth.ts | 4 +- .../src/features/auth/services/auth.store.ts | 68 +-- .../internet/InstallationOptions.tsx | 10 +- .../internet/InternetConfigureView.tsx | 22 +- .../components/internet/InternetPlanCard.tsx | 10 +- .../components/sim/SimConfigureView.tsx | 46 +- .../catalog/components/sim/SimPlanCard.tsx | 9 +- .../components/sim/SimPlanTypeSection.tsx | 10 +- .../catalog/hooks/useInternetConfigure.ts | 22 +- .../features/catalog/hooks/useSimConfigure.ts | 13 +- .../catalog/services/catalog.service.ts | 56 +- .../features/catalog/types/catalog.types.ts | 176 ------ .../features/catalog/views/InternetPlans.tsx | 13 +- .../src/features/catalog/views/SimPlans.tsx | 20 +- .../features/checkout/hooks/useCheckout.ts | 14 +- apps/portal/src/lib/api/runtime/client.ts | 2 + apps/portal/src/lib/providers.tsx | 16 +- apps/portal/src/lib/utils/error-handling.ts | 5 +- eslint.config.mjs | 8 +- packages/domain/src/adapters/api-adapters.ts | 204 ------- packages/domain/src/adapters/index.ts | 18 - packages/domain/src/client/index.ts | 17 - packages/domain/src/client/typed-client.ts | 451 --------------- packages/domain/src/common.ts | 40 +- packages/domain/src/contracts/catalog.ts | 63 +++ packages/domain/src/contracts/common.ts | 0 packages/domain/src/contracts/index.ts | 2 + packages/domain/src/contracts/salesforce.ts | 117 ++++ packages/domain/src/entities/billing.ts | 71 +-- packages/domain/src/entities/catalog.ts | 104 ---- packages/domain/src/entities/checkout.ts | 191 ------- packages/domain/src/entities/dashboard.ts | 2 +- .../examples/unified-product-usage.ts | 91 --- packages/domain/src/entities/index.ts | 19 - packages/domain/src/entities/order.ts | 114 ---- packages/domain/src/entities/product.ts | 524 ------------------ packages/domain/src/entities/subscription.ts | 71 --- packages/domain/src/entities/user.ts | 11 +- packages/domain/src/index.ts | 15 +- packages/domain/src/patterns/async-state.ts | 48 +- packages/domain/src/patterns/form-state.ts | 138 ----- packages/domain/src/patterns/index.ts | 19 +- packages/domain/src/patterns/pagination.ts | 2 +- packages/domain/src/utils/currency.ts | 4 +- packages/domain/src/utils/filters.ts | 2 +- packages/domain/src/utils/type-utils.ts | 16 +- packages/domain/src/utils/validation.ts | 2 - .../domain/src/validation/api/requests.ts | 127 +++-- .../domain/src/validation/api/responses.ts | 31 ++ .../domain/src/validation/business/index.ts | 2 +- .../domain/src/validation/business/orders.ts | 137 ++--- packages/domain/src/validation/forms/auth.ts | 71 +-- .../domain/src/validation/forms/orders.ts | 47 -- .../domain/src/validation/forms/profile.ts | 22 +- .../src/validation/forms/sim-configure.ts | 164 +++--- .../src/validation/forms/subscriptions.ts | 79 --- packages/domain/src/validation/index.ts | 75 +-- .../domain/src/validation/shared/common.ts | 37 +- .../domain/src/validation/shared/entities.ts | 140 +++-- .../src/validation/shared/identifiers.ts | 52 +- .../domain/src/validation/shared/index.ts | 17 +- .../domain/src/validation/shared/order.ts | 64 +++ .../src/validation/shared/primitives.ts | 76 +-- .../domain/src/validation/shared/utilities.ts | 2 +- packages/validation/src/index.ts | 6 +- packages/validation/src/nestjs/index.ts | 2 +- packages/validation/src/react/index.ts | 7 +- packages/validation/src/zod-form.ts | 176 +++--- packages/validation/src/zod-pipe.ts | 4 +- pnpm-lock.yaml | 35 +- 111 files changed, 1930 insertions(+), 3721 deletions(-) create mode 100644 apps/bff/prisma/migrations/20250920073101_initial_setup/migration.sql delete mode 100644 apps/bff/src/modules/auth/utils/sanitize-user.util.ts delete mode 100644 apps/bff/src/modules/cases/cases.controller.ts delete mode 100644 apps/bff/src/modules/cases/cases.module.ts delete mode 100644 apps/bff/src/modules/cases/cases.service.ts create mode 100644 apps/bff/src/modules/catalog/utils/salesforce-product.mapper.ts create mode 100644 apps/bff/src/modules/catalog/utils/salesforce-product.pricing.ts delete mode 100644 apps/bff/src/modules/jobs/jobs.module.ts delete mode 100644 apps/bff/src/modules/jobs/jobs.service.ts delete mode 100644 apps/bff/src/modules/jobs/reconcile.processor.ts delete mode 100644 apps/bff/src/modules/orders/types/order-details.dto.ts delete mode 100644 apps/portal/src/features/catalog/types/catalog.types.ts delete mode 100644 packages/domain/src/adapters/api-adapters.ts delete mode 100644 packages/domain/src/adapters/index.ts delete mode 100644 packages/domain/src/client/index.ts delete mode 100644 packages/domain/src/client/typed-client.ts create mode 100644 packages/domain/src/contracts/catalog.ts create mode 100644 packages/domain/src/contracts/common.ts create mode 100644 packages/domain/src/contracts/salesforce.ts delete mode 100644 packages/domain/src/entities/catalog.ts delete mode 100644 packages/domain/src/entities/checkout.ts delete mode 100644 packages/domain/src/entities/examples/unified-product-usage.ts delete mode 100644 packages/domain/src/entities/order.ts delete mode 100644 packages/domain/src/entities/product.ts delete mode 100644 packages/domain/src/entities/subscription.ts delete mode 100644 packages/domain/src/patterns/form-state.ts create mode 100644 packages/domain/src/validation/api/responses.ts delete mode 100644 packages/domain/src/validation/forms/orders.ts delete mode 100644 packages/domain/src/validation/forms/subscriptions.ts create mode 100644 packages/domain/src/validation/shared/order.ts diff --git a/apps/bff/package.json b/apps/bff/package.json index 49a6d790..817ac42b 100644 --- a/apps/bff/package.json +++ b/apps/bff/package.json @@ -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", diff --git a/apps/bff/prisma/migrations/20250920073101_initial_setup/migration.sql b/apps/bff/prisma/migrations/20250920073101_initial_setup/migration.sql new file mode 100644 index 00000000..cc52c63e --- /dev/null +++ b/apps/bff/prisma/migrations/20250920073101_initial_setup/migration.sql @@ -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"); diff --git a/apps/bff/src/app.module.ts b/apps/bff/src/app.module.ts index dc49e871..a6ce9bc7 100644 --- a/apps/bff/src/app.module.ts +++ b/apps/bff/src/app.module.ts @@ -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 {} diff --git a/apps/bff/src/core/config/field-map.ts b/apps/bff/src/core/config/field-map.ts index 37bc9bce..04bc0a0c 100644 --- a/apps/bff/src/core/config/field-map.ts +++ b/apps/bff/src/core/config/field-map.ts @@ -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", diff --git a/apps/bff/src/core/http/success-response.interceptor.ts b/apps/bff/src/core/http/success-response.interceptor.ts index 560c3249..18a57d36 100644 --- a/apps/bff/src/core/http/success-response.interceptor.ts +++ b/apps/bff/src/core/http/success-response.interceptor.ts @@ -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 { - return next.handle().pipe( - map((data: unknown) => { - if (data && typeof data === "object" && "success" in (data as Record)) { - return data; - } - - return { - success: true, - data, - meta: { - timestamp: new Date().toISOString(), - }, - }; - }) - ); - } -} +export {} diff --git a/apps/bff/src/core/validation/index.ts b/apps/bff/src/core/validation/index.ts index 98f68115..8568b257 100644 --- a/apps/bff/src/core/validation/index.ts +++ b/apps/bff/src/core/validation/index.ts @@ -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; +} diff --git a/apps/bff/src/infra/utils/user-mapper.util.ts b/apps/bff/src/infra/utils/user-mapper.util.ts index 87b6afaa..cb2fec64 100644 --- a/apps/bff/src/infra/utils/user-mapper.util.ts +++ b/apps/bff/src/infra/utils/user-mapper.util.ts @@ -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, + }; +} diff --git a/apps/bff/src/integrations/salesforce/salesforce.service.ts b/apps/bff/src/integrations/salesforce/salesforce.service.ts index 137d3b4d..77595155 100644 --- a/apps/bff/src/integrations/salesforce/salesforce.service.ts +++ b/apps/bff/src/integrations/salesforce/salesforce.service.ts @@ -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 | null> { - return this.accountService.getById(accountId) as Promise | null>; + async getAccount(accountId: string): Promise { + return this.accountService.getById(accountId); } async updateAccount(accountId: string, updates: Record): Promise { diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts index 907d3e91..afa7f0f7 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts @@ -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; - return result.totalSize > 0 ? { id: result.records[0].Id } : null; + )) as SalesforceQueryResult; + 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; + )) as SalesforceQueryResult; 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; + )) as SalesforceQueryResult; 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 { + async getById(accountId: string): Promise { 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; + `)) as SalesforceQueryResult; - 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), diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts index 01ec37b4..5cff1fb1 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts @@ -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; - + 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; + `)) as SalesforceQueryResult; 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) => Promise; + const contactCreate = this.connection.sobject("Contact") as unknown as { + create: (data: Record) => Promise; }; - 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 { + ): Promise { 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) => Promise; + const caseCreate = this.connection.sobject("Case") as unknown as { + create: (data: Record) => Promise; }; - 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; - - return createdCase.records[0]; + FROM Case + WHERE Id = '${caseResult.id}' + `)) as SalesforceQueryResult; + 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, }; } diff --git a/apps/bff/src/modules/auth/auth-zod.controller.ts b/apps/bff/src/modules/auth/auth-zod.controller.ts index 8ba415f8..2f26119a 100644 --- a/apps/bff/src/modules/auth/auth-zod.controller.ts +++ b/apps/bff/src/modules/auth/auth-zod.controller.ts @@ -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); diff --git a/apps/bff/src/modules/auth/auth.module.ts b/apps/bff/src/modules/auth/auth.module.ts index 5b0a7354..dc2ea33f 100644 --- a/apps/bff/src/modules/auth/auth.module.ts +++ b/apps/bff/src/modules/auth/auth.module.ts @@ -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, diff --git a/apps/bff/src/modules/auth/auth.service.ts b/apps/bff/src/modules/auth/auth.service.ts index 5d69e156..31b81b84 100644 --- a/apps/bff/src/modules/auth/auth.service.ts +++ b/apps/bff/src/modules/auth/auth.service.ts @@ -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, }; } diff --git a/apps/bff/src/modules/auth/auth.types.ts b/apps/bff/src/modules/auth/auth.types.ts index ffe5812e..c745d525 100644 --- a/apps/bff/src/modules/auth/auth.types.ts +++ b/apps/bff/src/modules/auth/auth.types.ts @@ -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 }; diff --git a/apps/bff/src/modules/auth/services/token.service.ts b/apps/bff/src/modules/auth/services/token.service.ts index 8c06b2c6..fa4df055 100644 --- a/apps/bff/src/modules/auth/services/token.service.ts +++ b/apps/bff/src/modules/auth/services/token.service.ts @@ -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(); diff --git a/apps/bff/src/modules/auth/services/workflows/password-workflow.service.ts b/apps/bff/src/modules/auth/services/workflows/password-workflow.service.ts index a21c2847..2addf450 100644 --- a/apps/bff/src/modules/auth/services/workflows/password-workflow.service.ts +++ b/apps/bff/src/modules/auth/services/workflows/password-workflow.service.ts @@ -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, }; } diff --git a/apps/bff/src/modules/auth/services/workflows/signup-workflow.service.ts b/apps/bff/src/modules/auth/services/workflows/signup-workflow.service.ts index bf42f4b1..5208453e 100644 --- a/apps/bff/src/modules/auth/services/workflows/signup-workflow.service.ts +++ b/apps/bff/src/modules/auth/services/workflows/signup-workflow.service.ts @@ -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) { diff --git a/apps/bff/src/modules/auth/services/workflows/whmcs-link-workflow.service.ts b/apps/bff/src/modules/auth/services/workflows/whmcs-link-workflow.service.ts index 0251d08c..4bc3229e 100644 --- a/apps/bff/src/modules/auth/services/workflows/whmcs-link-workflow.service.ts +++ b/apps/bff/src/modules/auth/services/workflows/whmcs-link-workflow.service.ts @@ -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) { diff --git a/apps/bff/src/modules/auth/strategies/jwt.strategy.ts b/apps/bff/src/modules/auth/strategies/jwt.strategy.ts index b522677a..99fa8791 100644 --- a/apps/bff/src/modules/auth/strategies/jwt.strategy.ts +++ b/apps/bff/src/modules/auth/strategies/jwt.strategy.ts @@ -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 { // 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, }; } } diff --git a/apps/bff/src/modules/auth/utils/sanitize-user.util.ts b/apps/bff/src/modules/auth/utils/sanitize-user.util.ts deleted file mode 100644 index 2bf2640c..00000000 --- a/apps/bff/src/modules/auth/utils/sanitize-user.util.ts +++ /dev/null @@ -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 { - const { - passwordHash: _passwordHash, - failedLoginAttempts: _failedLoginAttempts, - lockedUntil: _lockedUntil, - ...rest - } = user as T & { - passwordHash?: string | null; - failedLoginAttempts?: number | null; - lockedUntil?: Date | null; - }; - - return rest; -} diff --git a/apps/bff/src/modules/cases/cases.controller.ts b/apps/bff/src/modules/cases/cases.controller.ts deleted file mode 100644 index 0e60112c..00000000 --- a/apps/bff/src/modules/cases/cases.controller.ts +++ /dev/null @@ -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 -} diff --git a/apps/bff/src/modules/cases/cases.module.ts b/apps/bff/src/modules/cases/cases.module.ts deleted file mode 100644 index 30e96199..00000000 --- a/apps/bff/src/modules/cases/cases.module.ts +++ /dev/null @@ -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 {} diff --git a/apps/bff/src/modules/cases/cases.service.ts b/apps/bff/src/modules/cases/cases.service.ts deleted file mode 100644 index ea6bc47a..00000000 --- a/apps/bff/src/modules/cases/cases.service.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Injectable } from "@nestjs/common"; - -@Injectable() -export class CasesService { - // TODO: Implement case business logic -} diff --git a/apps/bff/src/modules/catalog/catalog.controller.ts b/apps/bff/src/modules/catalog/catalog.controller.ts index 1d823951..e687ef96 100644 --- a/apps/bff/src/modules/catalog/catalog.controller.ts +++ b/apps/bff/src/modules/catalog/catalog.controller.ts @@ -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 { + async getSimPlans(@Request() req: { user: { id: string } }): Promise { 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 { + async getSimAddons(): Promise { return this.simCatalog.getAddons(); } @Get("vpn/plans") @ApiOperation({ summary: "Get VPN plans" }) - async getVpnPlans(): Promise { + async getVpnPlans(): Promise { return this.vpnCatalog.getPlans(); } @Get("vpn/activation-fees") @ApiOperation({ summary: "Get VPN activation fees" }) - async getVpnActivationFees(): Promise { + async getVpnActivationFees(): Promise { return this.vpnCatalog.getActivationFees(); } } diff --git a/apps/bff/src/modules/catalog/services/internet-catalog.service.ts b/apps/bff/src/modules/catalog/services/internet-catalog.service.ts index f2c0a7d4..4b1563a3 100644 --- a/apps/bff/src/modules/catalog/services/internet-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/internet-catalog.service.ts @@ -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)); } diff --git a/apps/bff/src/modules/catalog/services/sim-catalog.service.ts b/apps/bff/src/modules/catalog/services/sim-catalog.service.ts index 5be47fbe..74d40ca1 100644 --- a/apps/bff/src/modules/catalog/services/sim-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/sim-catalog.service.ts @@ -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 { + async getPlans(): Promise { 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 { + async getAddons(): Promise { 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)[fields.product.displayOrder] ?? 0), + } satisfies SimCatalogProduct; }) .sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0)); } - async getPlansForUser(userId: string): Promise { + async getPlansForUser(userId: string): Promise { try { // Get all plans first const allPlans = await this.getPlans(); diff --git a/apps/bff/src/modules/catalog/services/vpn-catalog.service.ts b/apps/bff/src/modules/catalog/services/vpn-catalog.service.ts index 2737a744..0c0bb9ad 100644 --- a/apps/bff/src/modules/catalog/services/vpn-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/vpn-catalog.service.ts @@ -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 { + async getPlans(): Promise { 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 { + async getActivationFees(): Promise { 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; }); } diff --git a/apps/bff/src/modules/catalog/utils/salesforce-product.mapper.ts b/apps/bff/src/modules/catalog/utils/salesforce-product.mapper.ts new file mode 100644 index 00000000..78f9acd5 --- /dev/null +++ b/apps/bff/src/modules/catalog/utils/salesforce-product.mapper.ts @@ -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; + 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)[tierField] : undefined; + const offeringType = offeringTypeField + ? (product as Record)[offeringTypeField] + : undefined; + + const rawFeatures = productFields.featureList + ? (product as Record)[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 = { + 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)[dataSizeField] : undefined; + const planType = planTypeField ? (product as Record)[planTypeField] : undefined; + const familyDiscount = familyDiscountField + ? (product as Record)[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)[regionField] : undefined; + + const monthlyPrice = getMonthlyPrice(product, pricebookEntry); + const oneTimePrice = getOneTimePrice(product, pricebookEntry); + + return { + ...base, + vpnRegion: typeof region === "string" ? region : undefined, + monthlyPrice, + oneTimePrice, + }; +} + diff --git a/apps/bff/src/modules/catalog/utils/salesforce-product.pricing.ts b/apps/bff/src/modules/catalog/utils/salesforce-product.pricing.ts new file mode 100644 index 00000000..76b061ef --- /dev/null +++ b/apps/bff/src/modules/catalog/utils/salesforce-product.pricing.ts @@ -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)[fields.unitPrice]); + if (productPrice !== undefined) return productPrice; + + const monthlyPrice = coerceNumber((product as Record)[fields.monthlyPrice]); + if (monthlyPrice !== undefined) return monthlyPrice; + + const oneTimePrice = coerceNumber((product as Record)[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)[fields.billingCycle]; + if (typeof billingCycle === "string" && billingCycle.toLowerCase() === "monthly") { + return unitPrice; + } + + const monthlyPrice = coerceNumber((product as Record)[fields.monthlyPrice]); + return monthlyPrice ?? undefined; +} + +export function getOneTimePrice( + product: SalesforceProduct2Record, + pricebookEntry?: SalesforcePricebookEntryRecord +): number | undefined { + const unitPrice = getUnitPrice(product, pricebookEntry); + const billingCycle = (product as Record)[fields.billingCycle]; + + if (typeof billingCycle === "string" && billingCycle.toLowerCase() !== "monthly") { + return unitPrice; + } + + const oneTimePrice = coerceNumber((product as Record)[fields.oneTimePrice]); + return oneTimePrice ?? undefined; +} + diff --git a/apps/bff/src/modules/jobs/jobs.module.ts b/apps/bff/src/modules/jobs/jobs.module.ts deleted file mode 100644 index 22493147..00000000 --- a/apps/bff/src/modules/jobs/jobs.module.ts +++ /dev/null @@ -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 {} diff --git a/apps/bff/src/modules/jobs/jobs.service.ts b/apps/bff/src/modules/jobs/jobs.service.ts deleted file mode 100644 index c5d9b649..00000000 --- a/apps/bff/src/modules/jobs/jobs.service.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Injectable } from "@nestjs/common"; - -@Injectable() -export class JobsService { - // TODO: Implement job service logic -} diff --git a/apps/bff/src/modules/jobs/reconcile.processor.ts b/apps/bff/src/modules/jobs/reconcile.processor.ts deleted file mode 100644 index 49e85b74..00000000 --- a/apps/bff/src/modules/jobs/reconcile.processor.ts +++ /dev/null @@ -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" }; - } -} diff --git a/apps/bff/src/modules/orders/orders.controller.ts b/apps/bff/src/modules/orders/orders.controller.ts index 56c8eb83..d9f58c1f 100644 --- a/apps/bff/src/modules/orders/orders.controller.ts +++ b/apps/bff/src/modules/orders/orders.controller.ts @@ -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, diff --git a/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts b/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts index 662de754..be53eb90 100644 --- a/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts +++ b/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts @@ -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 { + private async validateSalesforceOrder(sfOrderId: string): Promise { const order = await this.salesforceService.getOrder(sfOrderId); if (!order) { diff --git a/apps/bff/src/modules/orders/services/order-item-builder.service.ts b/apps/bff/src/modules/orders/services/order-item-builder.service.ts index 27d64ae4..abfceea0 100644 --- a/apps/bff/src/modules/orders/services/order-item-builder.service.ts +++ b/apps/bff/src/modules/orders/services/order-item-builder.service.ts @@ -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; - }>; - }; + 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).Product2 as Record)?.[ - fields.product.itemClass - ]; - return typeof value === "string" ? value : ""; + const value = product2 ? (product2 as Record)[fields.product.itemClass] : undefined; + return typeof value === "string" ? value : undefined; })(), internetOfferingType: (() => { - const value = ((rec as Record).Product2 as Record)?.[ - fields.product.internetOfferingType - ]; - return typeof value === "string" ? value : ""; + const value = product2 + ? (product2 as Record)[fields.product.internetOfferingType] + : undefined; + return typeof value === "string" ? value : undefined; })(), internetPlanTier: (() => { - const value = ((rec as Record).Product2 as Record)?.[ - fields.product.internetPlanTier - ]; - return typeof value === "string" ? value : ""; + const value = product2 + ? (product2 as Record)[fields.product.internetPlanTier] + : undefined; + return typeof value === "string" ? value : undefined; })(), vpnRegion: (() => { - const value = ((rec as Record).Product2 as Record)?.[ - fields.product.vpnRegion - ]; - return typeof value === "string" ? value : ""; + const value = product2 ? (product2 as Record)[fields.product.vpnRegion] : undefined; + return typeof value === "string" ? value : undefined; })(), }; } catch (error) { diff --git a/apps/bff/src/modules/orders/services/order-orchestrator.service.ts b/apps/bff/src/modules/orders/services/order-orchestrator.service.ts index ec542904..8da4eefa 100644 --- a/apps/bff/src/modules/orders/services/order-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-orchestrator.service.ts @@ -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 { + async getOrder(orderId: string): Promise { 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>, - this.sf.query(orderItemsSoql) as Promise>, + this.sf.query(orderSoql) as Promise>, + this.sf.query(orderItemsSoql) as Promise< + SalesforceQueryResult + >, ]); 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 { + async getOrdersForUser(userId: string): Promise { 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; + const ordersResult = (await this.sf.query( + ordersSoql + )) as SalesforceQueryResult; const orders = ordersResult.records || []; if (orders.length === 0) { @@ -245,22 +220,21 @@ export class OrderOrchestrator { const itemsResult = (await this.sf.query( itemsSoql - )) as SalesforceQueryResult; + )) as SalesforceQueryResult; 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; diff --git a/apps/bff/src/modules/orders/types/order-details.dto.ts b/apps/bff/src/modules/orders/types/order-details.dto.ts deleted file mode 100644 index bc14d076..00000000 --- a/apps/bff/src/modules/orders/types/order-details.dto.ts +++ /dev/null @@ -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[]; -} diff --git a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts index 57e292da..bf7df595 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts @@ -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" }; diff --git a/apps/bff/src/modules/users/users.controller.ts b/apps/bff/src/modules/users/users.controller.ts index 131e74c2..31a4368b 100644 --- a/apps/bff/src/modules/users/users.controller.ts +++ b/apps/bff/src/modules/users/users.controller.ts @@ -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); diff --git a/apps/bff/src/modules/users/users.service.ts b/apps/bff/src/modules/users/users.service.ts index c605fee4..c4694e85 100644 --- a/apps/bff/src/modules/users/users.service.ts +++ b/apps/bff/src/modules/users/users.service.ts @@ -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>; diff --git a/apps/portal/src/features/account/services/account.service.ts b/apps/portal/src/features/account/services/account.service.ts index 4f97a386..ea0794cf 100644 --- a/apps/portal/src/features/account/services/account.service.ts +++ b/apps/portal/src/features/account/services/account.service.ts @@ -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(response); + return getNullableData(response); }, async updateProfile(update: ProfileUpdateInput) { const response = await apiClient.PATCH('/api/me', { body: update }); - return getDataOrThrow(response, "Failed to update profile"); + return getDataOrThrow(response, "Failed to update profile"); }, async getAddress() { diff --git a/apps/portal/src/features/auth/hooks/use-auth.ts b/apps/portal/src/features/auth/hooks/use-auth.ts index 769223ff..7b42993b 100644 --- a/apps/portal/src/features/auth/hooks/use-auth.ts +++ b/apps/portal/src/features/auth/hooks/use-auth.ts @@ -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] ); diff --git a/apps/portal/src/features/auth/services/auth.store.ts b/apps/portal/src/features/auth/services/auth.store.ts index 069a3c4e..aa7cedc7 100644 --- a/apps/portal/src/features/auth/services/auth.store.ts +++ b/apps/portal/src/features/auth/services/auth.store.ts @@ -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()( }); 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()( }); 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()( 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()( 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()( 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 diff --git a/apps/portal/src/features/catalog/components/internet/InstallationOptions.tsx b/apps/portal/src/features/catalog/components/internet/InstallationOptions.tsx index 739b2e50..9a751201 100644 --- a/apps/portal/src/features/catalog/components/internet/InstallationOptions.tsx +++ b/apps/portal/src/features/catalog/components/internet/InstallationOptions.tsx @@ -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"; diff --git a/apps/portal/src/features/catalog/components/internet/InternetConfigureView.tsx b/apps/portal/src/features/catalog/components/internet/InternetConfigureView.tsx index 56448214..6d1ac53a 100644 --- a/apps/portal/src/features/catalog/components/internet/InternetConfigureView.tsx +++ b/apps/portal/src/features/catalog/components/internet/InternetConfigureView.tsx @@ -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, diff --git a/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx b/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx index 196a4acb..1d6fc2ba 100644 --- a/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx +++ b/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx @@ -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; } diff --git a/apps/portal/src/features/catalog/components/sim/SimConfigureView.tsx b/apps/portal/src/features/catalog/components/sim/SimConfigureView.tsx index a10c9f98..0e0c2c27 100644 --- a/apps/portal/src/features/catalog/components/sim/SimConfigureView.tsx +++ b/apps/portal/src/features/catalog/components/sim/SimConfigureView.tsx @@ -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; - 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, diff --git a/apps/portal/src/features/catalog/components/sim/SimPlanCard.tsx b/apps/portal/src/features/catalog/components/sim/SimPlanCard.tsx index 00a28374..759170c8 100644 --- a/apps/portal/src/features/catalog/components/sim/SimPlanCard.tsx +++ b/apps/portal/src/features/catalog/components/sim/SimPlanCard.tsx @@ -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); diff --git a/apps/portal/src/features/catalog/components/sim/SimPlanTypeSection.tsx b/apps/portal/src/features/catalog/components/sim/SimPlanTypeSection.tsx index 6f58f55c..04348f14 100644 --- a/apps/portal/src/features/catalog/components/sim/SimPlanTypeSection.tsx +++ b/apps/portal/src/features/catalog/components/sim/SimPlanTypeSection.tsx @@ -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; diff --git a/apps/portal/src/features/catalog/hooks/useInternetConfigure.ts b/apps/portal/src/features/catalog/hooks/useInternetConfigure.ts index 0bcf259d..42b3d18b 100644 --- a/apps/portal/src/features/catalog/hooks/useInternetConfigure.ts +++ b/apps/portal/src/features/catalog/hooks/useInternetConfigure.ts @@ -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(null); + const [plan, setPlan] = useState(null); const [loading, setLoading] = useState(true); - const [addons, setAddons] = useState([]); - const [installations, setInstallations] = useState([]); + const [addons, setAddons] = useState([]); + const [installations, setInstallations] = useState([]); const [mode, setMode] = useState(null); const [selectedInstallationSku, setSelectedInstallationSku] = useState(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); diff --git a/apps/portal/src/features/catalog/hooks/useSimConfigure.ts b/apps/portal/src/features/catalog/hooks/useSimConfigure.ts index 696deae6..62e878cb 100644 --- a/apps/portal/src/features/catalog/hooks/useSimConfigure.ts +++ b/apps/portal/src/features/catalog/hooks/useSimConfigure.ts @@ -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 diff --git a/apps/portal/src/features/catalog/services/catalog.service.ts b/apps/portal/src/features/catalog/services/catalog.service.ts index 9b5f1038..f979e80a 100644 --- a/apps/portal/src/features/catalog/services/catalog.service.ts +++ b/apps/portal/src/features/catalog/services/catalog.service.ts @@ -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 { + 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 { + async getInternetInstallations(): Promise { const response = await apiClient.GET("/api/catalog/internet/installations"); return getDataOrDefault(response, emptyInternetInstallations); }, - async getInternetAddons(): Promise { + async getInternetAddons(): Promise { const response = await apiClient.GET("/api/catalog/internet/addons"); return getDataOrDefault(response, emptyInternetAddons); }, - async getSimPlans(): Promise { + 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 { + async getSimActivationFees(): Promise { const response = await apiClient.GET("/api/catalog/sim/activation-fees"); return getDataOrDefault(response, emptySimActivationFees); }, - async getSimAddons(): Promise { + async getSimAddons(): Promise { const response = await apiClient.GET("/api/catalog/sim/addons"); return getDataOrDefault(response, emptySimAddons); }, - async getVpnPlans(): Promise { + async getVpnCatalog(): Promise<{ + plans: VpnCatalogProduct[]; + activationFees: VpnCatalogProduct[]; + }> { const response = await apiClient.GET("/api/catalog/vpn/plans"); return getDataOrDefault(response, emptyVpnPlans); }, - async getVpnActivationFees(): Promise { + async getVpnActivationFees(): Promise { const response = await apiClient.GET("/api/catalog/vpn/activation-fees"); return getDataOrDefault(response, emptyVpnPlans); }, diff --git a/apps/portal/src/features/catalog/types/catalog.types.ts b/apps/portal/src/features/catalog/types/catalog.types.ts deleted file mode 100644 index fcfe57bd..00000000 --- a/apps/portal/src/features/catalog/types/catalog.types.ts +++ /dev/null @@ -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); -}; diff --git a/apps/portal/src/features/catalog/views/InternetPlans.tsx b/apps/portal/src/features/catalog/views/InternetPlans.tsx index 2cf2b601..7361919f 100644 --- a/apps/portal/src/features/catalog/views/InternetPlans.tsx +++ b/apps/portal/src/features/catalog/views/InternetPlans.tsx @@ -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(""); const { data: activeSubs } = useActiveSubscriptions(); const hasActiveInternet = Array.isArray(activeSubs) diff --git a/apps/portal/src/features/catalog/views/SimPlans.tsx b/apps/portal/src/features/catalog/views/SimPlans.tsx index 49f34e6b..c60f5c76 100644 --- a/apps/portal/src/features/catalog/views/SimPlans.tsx +++ b/apps/portal/src/features/catalog/views/SimPlans.tsx @@ -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; diff --git a/apps/portal/src/features/checkout/hooks/useCheckout.ts b/apps/portal/src/features/checkout/hooks/useCheckout.ts index ed725742..60235b3a 100644 --- a/apps/portal/src/features/checkout/hooks/useCheckout.ts +++ b/apps/portal/src/features/checkout/hooks/useCheckout.ts @@ -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, diff --git a/apps/portal/src/lib/api/runtime/client.ts b/apps/portal/src/lib/api/runtime/client.ts index eae08ec1..2ec9d221 100644 --- a/apps/portal/src/lib/api/runtime/client.ts +++ b/apps/portal/src/lib/api/runtime/client.ts @@ -15,6 +15,8 @@ export class ApiError extends Error { } } +export const isApiError = (error: unknown): error is ApiError => error instanceof ApiError; + type StrictApiClient = ReturnType>; type FlexibleApiMethods = { diff --git a/apps/portal/src/lib/providers.tsx b/apps/portal/src/lib/providers.tsx index aad292be..3896b790 100644 --- a/apps/portal/src/lib/providers.tsx +++ b/apps/portal/src/lib/providers.tsx @@ -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 | undefined; + const code = typeof body?.code === "string" ? body.code : undefined; + if (code === "AUTHENTICATION_REQUIRED" || code === "FORBIDDEN") { + return false; + } } return failureCount < 3; }, diff --git a/apps/portal/src/lib/utils/error-handling.ts b/apps/portal/src/lib/utils/error-handling.ts index 9d901ef2..8ace263a 100644 --- a/apps/portal/src/lib/utils/error-handling.ts +++ b/apps/portal/src/lib/utils/error-handling.ts @@ -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 && diff --git a/eslint.config.mjs b/eslint.config.mjs index 2484ba23..6f414374 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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, diff --git a/packages/domain/src/adapters/api-adapters.ts b/packages/domain/src/adapters/api-adapters.ts deleted file mode 100644 index feaf2a5b..00000000 --- a/packages/domain/src/adapters/api-adapters.ts +++ /dev/null @@ -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 { - result: "success" | "error"; - message?: string; - data?: T; -} - -export function adaptWhmcsResponse( - response: WhmcsApiResponse -): ApiResponse { - 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 { - success: boolean; - data?: T; - errors?: Array<{ - message: string; - errorCode: string; - fields?: string[]; - }>; -} - -export function adaptSalesforceResponse( - response: SalesforceApiResponse -): ApiResponse { - 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 { - status: 'success' | 'error'; - result?: T; - error_message?: string; - error_code?: string; -} - -export function adaptFreebitResponse( - response: FreebitApiResponse -): ApiResponse { - 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 { - status: number; - statusText: string; - data: T; - headers?: Record; -} - -export function adaptHttpResponse( - response: HttpResponse -): ApiResponse { - 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( - response: ApiResponse -): response is { success: true; data: T } { - return response.success === true; -} - -// Type guard to check if response is an error -export function isErrorResponse( - response: ApiResponse -): response is { success: false; error: any } { - return response.success === false; -} - -// Extract data from response or throw error -export function unwrapResponse(response: ApiResponse): 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(response: ApiResponse): T | null { - if (isSuccessResponse(response)) { - return response.data; - } - - return null; -} - -// Map successful response data -export function mapResponseData( - response: ApiResponse, - mapper: (data: T) => U -): ApiResponse { - if (isSuccessResponse(response)) { - return { - success: true, - data: mapper(response.data), - meta: response.meta, - }; - } - - return response as ApiResponse; -} diff --git a/packages/domain/src/adapters/index.ts b/packages/domain/src/adapters/index.ts deleted file mode 100644 index 74eea5a5..00000000 --- a/packages/domain/src/adapters/index.ts +++ /dev/null @@ -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'; diff --git a/packages/domain/src/client/index.ts b/packages/domain/src/client/index.ts deleted file mode 100644 index 6d8ebbb8..00000000 --- a/packages/domain/src/client/index.ts +++ /dev/null @@ -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'; diff --git a/packages/domain/src/client/typed-client.ts b/packages/domain/src/client/typed-client.ts deleted file mode 100644 index d3afe9fe..00000000 --- a/packages/domain/src/client/typed-client.ts +++ /dev/null @@ -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(endpoint: string, params?: QueryParams): Promise>; - post(endpoint: string, data?: unknown): Promise>; - put(endpoint: string, data?: unknown): Promise>; - patch(endpoint: string, data?: unknown): Promise>; - delete(endpoint: string): Promise>; - - // Paginated operations - getPaginated(endpoint: string, params?: QueryParams): Promise>>; -} - -// ===================================================== -// 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; - 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; - - constructor(baseUrl: string, defaultHeaders: Record = {}) { - this.baseUrl = baseUrl.replace(/\/$/, ''); // Remove trailing slash - this.defaultHeaders = { - 'Content-Type': 'application/json', - ...defaultHeaders, - }; - } - - // ===================================================== - // GENERIC HTTP METHODS - // ===================================================== - - async get(endpoint: string, params?: QueryParams): Promise> { - const url = this.buildUrl(endpoint, params); - return this.request('GET', url); - } - - async post(endpoint: string, data?: unknown): Promise> { - const url = this.buildUrl(endpoint); - return this.request('POST', url, data); - } - - async put(endpoint: string, data?: unknown): Promise> { - const url = this.buildUrl(endpoint); - return this.request('PUT', url, data); - } - - async patch(endpoint: string, data?: unknown): Promise> { - const url = this.buildUrl(endpoint); - return this.request('PATCH', url, data); - } - - async delete(endpoint: string): Promise> { - const url = this.buildUrl(endpoint); - return this.request('DELETE', url); - } - - async getPaginated(endpoint: string, params?: QueryParams): Promise>> { - return this.get>(endpoint, params); - } - - // ===================================================== - // USER OPERATIONS - // ===================================================== - - async getUser(id: UserId): Promise> { - return this.get(`/users/${id}`); - } - - async updateUser(id: UserId, data: UpdateInput): Promise> { - return this.patch(`/users/${id}`, data); - } - - async getUserProfile(): Promise> { - return this.get('/me'); - } - - async updateUserProfile(data: UpdateInput): Promise> { - return this.patch('/me', data); - } - - // ===================================================== - // ORDER OPERATIONS - // ===================================================== - - async getOrder(id: OrderId): Promise> { - return this.get(`/orders/${id}`); - } - - async getUserOrders(userId: UserId, params?: QueryParams): Promise>> { - return this.getPaginated(`/users/${userId}/orders`, params); - } - - async createOrder(data: CreateInput): Promise> { - return this.post('/orders', data); - } - - async updateOrder(id: OrderId, data: UpdateInput): Promise> { - return this.patch(`/orders/${id}`, data); - } - - async cancelOrder(id: OrderId): Promise> { - return this.patch(`/orders/${id}/cancel`); - } - - // ===================================================== - // INVOICE OPERATIONS - // ===================================================== - - async getInvoice(id: InvoiceId): Promise> { - return this.get(`/invoices/${id}`); - } - - async getUserInvoices(userId: UserId, params?: QueryParams): Promise>> { - return this.getPaginated(`/users/${userId}/invoices`, params); - } - - async payInvoice(id: InvoiceId, paymentData: { paymentMethodId: string }): Promise> { - return this.post(`/invoices/${id}/pay`, paymentData); - } - - // ===================================================== - // SUBSCRIPTION OPERATIONS - // ===================================================== - - async getSubscription(id: SubscriptionId): Promise> { - return this.get(`/subscriptions/${id}`); - } - - async getUserSubscriptions(userId: UserId, params?: QueryParams): Promise>> { - return this.getPaginated(`/users/${userId}/subscriptions`, params); - } - - async createSubscription(data: CreateInput): Promise> { - return this.post('/subscriptions', data); - } - - async updateSubscription(id: SubscriptionId, data: UpdateInput): Promise> { - return this.patch(`/subscriptions/${id}`, data); - } - - async cancelSubscription(id: SubscriptionId): Promise> { - return this.patch(`/subscriptions/${id}/cancel`); - } - - async suspendSubscription(id: SubscriptionId): Promise> { - return this.patch(`/subscriptions/${id}/suspend`); - } - - async resumeSubscription(id: SubscriptionId): Promise> { - return this.patch(`/subscriptions/${id}/resume`); - } - - // ===================================================== - // PAYMENT OPERATIONS - // ===================================================== - - async getPayment(id: PaymentId): Promise> { - return this.get(`/payments/${id}`); - } - - async getUserPayments(userId: UserId, params?: QueryParams): Promise>> { - return this.getPaginated(`/users/${userId}/payments`, params); - } - - async createPayment(data: CreateInput): Promise> { - return this.post('/payments', data); - } - - async refundPayment(id: PaymentId, amount?: number): Promise> { - return this.post(`/payments/${id}/refund`, { amount }); - } - - // ===================================================== - // SUPPORT CASE OPERATIONS - // ===================================================== - - async getSupportCase(id: CaseId): Promise> { - return this.get(`/cases/${id}`); - } - - async getUserSupportCases(userId: UserId, params?: QueryParams): Promise>> { - return this.getPaginated(`/users/${userId}/cases`, params); - } - - async createSupportCase(data: CreateInput): Promise> { - return this.post('/cases', data); - } - - async updateSupportCase(id: CaseId, data: UpdateInput): Promise> { - return this.patch(`/cases/${id}`, data); - } - - async closeSupportCase(id: CaseId): Promise> { - return this.patch(`/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( - method: string, - url: string, - data?: unknown - ): Promise> { - 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; - } = {} -): TypedApiClientImpl { - const headers: Record = { - ...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 = {} -): TypedApiClientImpl { - return createTypedApiClient(baseUrl, { - authToken, - headers: additionalHeaders, - }); -} - -// ===================================================== -// TYPE GUARDS AND UTILITIES -// ===================================================== - -/** - * Type guard to check if API response is successful - */ -export function isApiSuccess(response: ApiResponse): response is { success: true; data: T } { - return response.success === true; -} - -/** - * Type guard to check if API response is an error - */ -export function isApiError(response: ApiResponse): response is { success: false; error: any } { - return response.success === false; -} - -/** - * Extract data from API response or throw error - */ -export function extractApiData(response: ApiResponse): 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(response: ApiResponse): T | null { - if (isApiSuccess(response)) { - return response.data; - } - - return null; -} diff --git a/packages/domain/src/common.ts b/packages/domain/src/common.ts index 3f1980c5..1fbd2d1c 100644 --- a/packages/domain/src/common.ts +++ b/packages/domain/src/common.ts @@ -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; diff --git a/packages/domain/src/contracts/catalog.ts b/packages/domain/src/contracts/catalog.ts new file mode 100644 index 00000000..8ef40fce --- /dev/null +++ b/packages/domain/src/contracts/catalog.ts @@ -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; +} + diff --git a/packages/domain/src/contracts/common.ts b/packages/domain/src/contracts/common.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/domain/src/contracts/index.ts b/packages/domain/src/contracts/index.ts index af3b405d..5582ed8d 100644 --- a/packages/domain/src/contracts/index.ts +++ b/packages/domain/src/contracts/index.ts @@ -1,2 +1,4 @@ // Export all API contracts export * from "./api"; +export * from "./catalog"; +export * from "./salesforce"; diff --git a/packages/domain/src/contracts/salesforce.ts b/packages/domain/src/contracts/salesforce.ts new file mode 100644 index 00000000..2bd09f65 --- /dev/null +++ b/packages/domain/src/contracts/salesforce.ts @@ -0,0 +1,117 @@ +import type { IsoDateTimeString } from "../common"; + +export interface SalesforceQueryResult { + 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; +} + + diff --git a/packages/domain/src/entities/billing.ts b/packages/domain/src/entities/billing.ts index 2d2d5a7c..31f48a6a 100644 --- a/packages/domain/src/entities/billing.ts +++ b/packages/domain/src/entities/billing.ts @@ -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 { +export function calculateBillingSummary( + invoices: Array<{ + total: number; + status: string; + dueDate?: string; + paidDate?: string; + }> +): Omit { 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, + }, + } + ); } diff --git a/packages/domain/src/entities/catalog.ts b/packages/domain/src/entities/catalog.ts deleted file mode 100644 index bad0183c..00000000 --- a/packages/domain/src/entities/catalog.ts +++ /dev/null @@ -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).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).category === "SIM" - ); -} - -export function isVpnPlan(product: unknown): product is VpnPlan { - return ( - product !== null && - typeof product === "object" && - "sku" in product && - (product as Record).category === "VPN" && - !("internetPlanTier" in product) && - !("simDataSize" in product) - ); -} diff --git a/packages/domain/src/entities/checkout.ts b/packages/domain/src/entities/checkout.ts deleted file mode 100644 index 12ab3dc1..00000000 --- a/packages/domain/src/entities/checkout.ts +++ /dev/null @@ -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; -} - -/** - * 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"; \ No newline at end of file diff --git a/packages/domain/src/entities/dashboard.ts b/packages/domain/src/entities/dashboard.ts index 2880131b..7aa31ec1 100644 --- a/packages/domain/src/entities/dashboard.ts +++ b/packages/domain/src/entities/dashboard.ts @@ -1,6 +1,6 @@ /** * Dashboard Domain Entities - * + * * Business entities for dashboard data and statistics */ diff --git a/packages/domain/src/entities/examples/unified-product-usage.ts b/packages/domain/src/entities/examples/unified-product-usage.ts deleted file mode 100644 index 3747ed56..00000000 --- a/packages/domain/src/entities/examples/unified-product-usage.ts +++ /dev/null @@ -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 }; diff --git a/packages/domain/src/entities/index.ts b/packages/domain/src/entities/index.ts index a7ef6720..e2378ffb 100644 --- a/packages/domain/src/entities/index.ts +++ b/packages/domain/src/entities/index.ts @@ -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"; diff --git a/packages/domain/src/entities/order.ts b/packages/domain/src/entities/order.ts deleted file mode 100644 index 8f26c6ba..00000000 --- a/packages/domain/src/entities/order.ts +++ /dev/null @@ -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; -} - -// 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; -} - -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); -} diff --git a/packages/domain/src/entities/product.ts b/packages/domain/src/entities/product.ts deleted file mode 100644 index e3d9400e..00000000 --- a/packages/domain/src/entities/product.ts +++ /dev/null @@ -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 { - 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 - }; -} diff --git a/packages/domain/src/entities/subscription.ts b/packages/domain/src/entities/subscription.ts deleted file mode 100644 index 0ddc66a6..00000000 --- a/packages/domain/src/entities/subscription.ts +++ /dev/null @@ -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; - // 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; -} diff --git a/packages/domain/src/entities/user.ts b/packages/domain/src/entities/user.ts index 3dfd000b..dfa4895e 100644 --- a/packages/domain/src/entities/user.ts +++ b/packages/domain/src/entities/user.ts @@ -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; lastLoginAt?: string; diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 5fbf233b..1db7feab 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -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"; \ No newline at end of file +} from "./common"; diff --git a/packages/domain/src/patterns/async-state.ts b/packages/domain/src/patterns/async-state.ts index d19e8f4e..f9ee1f4a 100644 --- a/packages/domain/src/patterns/async-state.ts +++ b/packages/domain/src/patterns/async-state.ts @@ -4,16 +4,16 @@ */ export type AsyncState = - | { 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 = - | { 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 = (): AsyncState => ({ status: 'idle' }); -export const createLoadingState = (): AsyncState => ({ status: 'loading' }); -export const createSuccessState = (data: T): AsyncState => ({ status: 'success', data }); -export const createErrorState = (error: string): AsyncState => ({ status: 'error', error }); +export const createIdleState = (): AsyncState => ({ status: "idle" }); +export const createLoadingState = (): AsyncState => ({ status: "loading" }); +export const createSuccessState = (data: T): AsyncState => ({ status: "success", data }); +export const createErrorState = (error: string): AsyncState => ({ status: "error", error }); // Type guards -export const isIdle = (state: AsyncState): state is { status: 'idle' } => - state.status === 'idle'; +export const isIdle = (state: AsyncState): state is { status: "idle" } => + state.status === "idle"; -export const isLoading = (state: AsyncState): state is { status: 'loading' } => - state.status === 'loading'; +export const isLoading = (state: AsyncState): state is { status: "loading" } => + state.status === "loading"; -export const isSuccess = (state: AsyncState): state is { status: 'success'; data: T } => - state.status === 'success'; +export const isSuccess = (state: AsyncState): state is { status: "success"; data: T } => + state.status === "success"; -export const isError = (state: AsyncState): state is { status: 'error'; error: string } => - state.status === 'error'; +export const isError = (state: AsyncState): state is { status: "error"; error: string } => + state.status === "error"; // Utility functions for working with async states export const getDataOrNull = (state: AsyncState): T | null => @@ -92,7 +92,7 @@ export const updateSelectionState = ( const selected = isSelected ? [...state.selected, item] : state.selected.filter(selectedItem => selectedItem !== item); - + return { selected, selectAll: false, @@ -111,10 +111,10 @@ export const updateFilterState = ( filters: Partial ): FilterState => { 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, diff --git a/packages/domain/src/patterns/form-state.ts b/packages/domain/src/patterns/form-state.ts deleted file mode 100644 index 320d87f9..00000000 --- a/packages/domain/src/patterns/form-state.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Enhanced Form State Types - */ - -export interface FormField { - value: T; - error?: string; - touched: boolean; - dirty: boolean; -} - -export type FormState = { - [K in keyof T]: FormField; -} & { - isValid: boolean; - isSubmitting: boolean; - submitCount: number; - errors: Partial>; -}; - -// Helper functions -export const createFormField = (value: T): FormField => ({ - value, - touched: false, - dirty: false, -}); - -export const createFormState = (initialData: T): FormState => { - const fields = {} as { [K in keyof T]: FormField }; - - 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 = ( - field: FormField, - value: T, - error?: string -): FormField => ({ - ...field, - value, - error, - dirty: true, - touched: true, -}); - -export const touchFormField = (field: FormField): FormField => ({ - ...field, - touched: true, -}); - -export const resetFormField = (initialValue: T): FormField => ({ - value: initialValue, - touched: false, - dirty: false, -}); - -// Form state utilities -export const getFormValues = (formState: FormState): T => { - const values = {} as T; - - for (const key in formState) { - if (Object.prototype.hasOwnProperty.call(formState, key)) { - const field = formState[key as keyof FormState]; - if (typeof field === 'object' && field !== null && 'value' in field) { - (values as any)[key] = (field as FormField).value; - } - } - } - - return values; -}; - -export const hasFormErrors = (formState: FormState): boolean => { - return Object.keys(formState.errors).length > 0 || !formState.isValid; -}; - -export const getFormFieldErrors = (formState: FormState): string[] => { - const errors: string[] = []; - - for (const key in formState) { - if (Object.prototype.hasOwnProperty.call(formState, key)) { - const field = formState[key as keyof FormState]; - if (typeof field === 'object' && field !== null && 'error' in field) { - const formField = field as FormField; - if (formField.error) { - errors.push(formField.error); - } - } - } - } - - return errors; -}; - -export const isFormDirty = (formState: FormState): boolean => { - for (const key in formState) { - if (Object.prototype.hasOwnProperty.call(formState, key)) { - const field = formState[key as keyof FormState]; - if (typeof field === 'object' && field !== null && 'dirty' in field) { - const formField = field as FormField; - if (formField.dirty) { - return true; - } - } - } - } - - return false; -}; - -export const isFormTouched = (formState: FormState): boolean => { - for (const key in formState) { - if (Object.prototype.hasOwnProperty.call(formState, key)) { - const field = formState[key as keyof FormState]; - if (typeof field === 'object' && field !== null && 'touched' in field) { - const formField = field as FormField; - if (formField.touched) { - return true; - } - } - } - } - - return false; -}; diff --git a/packages/domain/src/patterns/index.ts b/packages/domain/src/patterns/index.ts index 17778062..c1a6b8b4 100644 --- a/packages/domain/src/patterns/index.ts +++ b/packages/domain/src/patterns/index.ts @@ -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"; diff --git a/packages/domain/src/patterns/pagination.ts b/packages/domain/src/patterns/pagination.ts index 74916fac..12065170 100644 --- a/packages/domain/src/patterns/pagination.ts +++ b/packages/domain/src/patterns/pagination.ts @@ -42,7 +42,7 @@ export const getDefaultPaginationParams = (): Required => ({ export const validatePaginationParams = (params: PaginationParams): Required => { 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 diff --git a/packages/domain/src/utils/currency.ts b/packages/domain/src/utils/currency.ts index ec3303ea..d4d27fe3 100644 --- a/packages/domain/src/utils/currency.ts +++ b/packages/domain/src/utils/currency.ts @@ -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, diff --git a/packages/domain/src/utils/filters.ts b/packages/domain/src/utils/filters.ts index 037d3169..b321e576 100644 --- a/packages/domain/src/utils/filters.ts +++ b/packages/domain/src/utils/filters.ts @@ -1,6 +1,6 @@ /** * Generic Filter Utilities - * + * * Reusable filter patterns to eliminate duplication */ diff --git a/packages/domain/src/utils/type-utils.ts b/packages/domain/src/utils/type-utils.ts index 3932faa1..c84e1ee7 100644 --- a/packages/domain/src/utils/type-utils.ts +++ b/packages/domain/src/utils/type-utils.ts @@ -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 & { id: string }; export type WithTimestamps = T & BaseEntity; -export type CreateInput = Omit; +export type CreateInput = Omit; export type UpdateInput = Partial>; // Optional field utilities export type WithOptionalId = T & { id?: string }; -export type WithOptionalTimestamps = T & Partial>; +export type WithOptionalTimestamps = T & Partial>; // ===================================================== // API UTILITIES @@ -43,9 +43,7 @@ export type ResponseWithMeta = T & { // Form data type that only includes serializable fields export type FormData = { - [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 extends readonly unknown[] ? true : false; export type IsObject = T extends object ? true : false; // Check if type is function -export type IsFunction = T extends (...args: any[]) => any ? true : false; +export type IsFunction = T extends (...args: unknown[]) => unknown ? true : false; // Extract array element type export type ArrayElement = T extends readonly (infer U)[] ? U : never; @@ -168,7 +166,7 @@ export type ArrayElement = T extends readonly (infer U)[] ? U : never; export type PromiseType = T extends Promise ? U : never; // Extract function return type -export type ReturnTypeOf = T extends (...args: any[]) => infer R ? R : never; +export type ReturnTypeOf = T extends (...args: unknown[]) => infer R ? R : never; // Extract function parameters -export type ParametersOf = T extends (...args: infer P) => any ? P : never; +export type ParametersOf = T extends (...args: infer P) => unknown ? P : never; diff --git a/packages/domain/src/utils/validation.ts b/packages/domain/src/utils/validation.ts index 390d119d..16d64ebc 100644 --- a/packages/domain/src/utils/validation.ts +++ b/packages/domain/src/utils/validation.ts @@ -51,7 +51,6 @@ export function validateEmail(email: string): ValidationResult { return success(trimmed); } - /** * Enhanced phone validation with better error reporting */ @@ -75,7 +74,6 @@ export function validatePhoneNumber(phone: string): ValidationResult { return success(cleaned); } - /** * Validates password strength */ diff --git a/packages/domain/src/validation/api/requests.ts b/packages/domain/src/validation/api/requests.ts index 8283408c..be70a4f7 100644 --- a/packages/domain/src/validation/api/requests.ts +++ b/packages/domain/src/validation/api/requests.ts @@ -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; 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; // ===================================================== 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(), }); diff --git a/packages/domain/src/validation/api/responses.ts b/packages/domain/src/validation/api/responses.ts new file mode 100644 index 00000000..3c864b5e --- /dev/null +++ b/packages/domain/src/validation/api/responses.ts @@ -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; +export type AuthTokensSchema = AuthResponse["tokens"]; + +export { orderDetailsSchema, orderSummarySchema }; +export type OrderDetailsResponse = z.infer; +export type OrderSummaryResponse = z.infer; +export { orderDetailsSchema, orderSummarySchema }; +export type OrderDetailsResponse = z.infer; +export type OrderSummaryResponse = z.infer; diff --git a/packages/domain/src/validation/business/index.ts b/packages/domain/src/validation/business/index.ts index 07f70588..d0d5eb00 100644 --- a/packages/domain/src/validation/business/index.ts +++ b/packages/domain/src/validation/business/index.ts @@ -3,4 +3,4 @@ * Centralized business logic validation */ -export * from './orders'; +export * from "./orders"; diff --git a/packages/domain/src/validation/business/orders.ts b/packages/domain/src/validation/business/orders.ts index 20018fa4..b04e323b 100644 --- a/packages/domain/src/validation/business/orders.ts +++ b/packages/domain/src/validation/business/orders.ts @@ -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(), + }) + ), }); // ===================================================== diff --git a/packages/domain/src/validation/forms/auth.ts b/packages/domain/src/validation/forms/auth.ts index 236facbb..423fc520 100644 --- a/packages/domain/src/validation/forms/auth.ts +++ b/packages/domain/src/validation/forms/auth.ts @@ -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; diff --git a/packages/domain/src/validation/forms/orders.ts b/packages/domain/src/validation/forms/orders.ts deleted file mode 100644 index 5a4d9f19..00000000 --- a/packages/domain/src/validation/forms/orders.ts +++ /dev/null @@ -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; -export type OrderConfigurationFormData = z.infer; diff --git a/packages/domain/src/validation/forms/profile.ts b/packages/domain/src/validation/forms/profile.ts index 0655aa70..2cb9aa84 100644 --- a/packages/domain/src/validation/forms/profile.ts +++ b/packages/domain/src/validation/forms/profile.ts @@ -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; export type AddressFormData = z.infer; export type ContactFormData = z.infer; - -// Re-export API types for convenience -export type { - UpdateProfileRequestData, - UpdateAddressRequestData, - ContactRequestData, -}; diff --git a/packages/domain/src/validation/forms/sim-configure.ts b/packages/domain/src/validation/forms/sim-configure.ts index 4f25ed9b..36c2167f 100644 --- a/packages/domain/src/validation/forms/sim-configure.ts +++ b/packages/domain/src/validation/forms/sim-configure.ts @@ -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; diff --git a/packages/domain/src/validation/forms/subscriptions.ts b/packages/domain/src/validation/forms/subscriptions.ts deleted file mode 100644 index a9fa2a76..00000000 --- a/packages/domain/src/validation/forms/subscriptions.ts +++ /dev/null @@ -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; -export type SimCancelFormData = z.infer; -export type SimChangePlanFormData = z.infer; -export type SimFeaturesFormData = z.infer; diff --git a/packages/domain/src/validation/index.ts b/packages/domain/src/validation/index.ts index 868f9f96..4faa0c5e 100644 --- a/packages/domain/src/validation/index.ts +++ b/packages/domain/src/validation/index.ts @@ -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"; diff --git a/packages/domain/src/validation/shared/common.ts b/packages/domain/src/validation/shared/common.ts index 32e048dc..c2f74707 100644 --- a/packages/domain/src/validation/shared/common.ts +++ b/packages/domain/src/validation/shared/common.ts @@ -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 = (dataSchema: z.ZodSchema) => - z.discriminatedUnion('success', [ - apiSuccessSchema(dataSchema), - apiFailureSchema, - ]); + z.discriminatedUnion("success", [apiSuccessSchema(dataSchema), apiFailureSchema]); // ===================================================== // FORM STATE PATTERNS @@ -92,9 +89,9 @@ export const formFieldSchema = (valueSchema: z.ZodSchema) => dirty: z.boolean().default(false), }); -export const formStateSchema = >(fieldsSchema: z.ZodSchema) => +export const formStateSchema = (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 = >(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 = (dataSchema: z.ZodSchema) => 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 = (dataSchema: z.ZodSchema) => - z.discriminatedUnion('status', [ + z.discriminatedUnion("status", [ asyncStateIdleSchema, asyncStateLoadingSchema, asyncStateSuccessSchema(dataSchema), @@ -147,7 +144,9 @@ export type ApiSuccessSchema = z.infer> export type ApiFailureSchema = z.infer; export type ApiResponseSchema = z.infer>>; export type FormFieldSchema = z.infer>>; -export type FormStateSchema> = z.infer>>; +export type FormStateSchema = z.infer< + ReturnType> +>; export type AsyncStateIdleSchema = z.infer; export type AsyncStateLoadingSchema = z.infer; export type AsyncStateSuccessSchema = z.infer>>; diff --git a/packages/domain/src/validation/shared/entities.ts b/packages/domain/src/validation/shared/entities.ts index d19c8c5c..d909af95 100644 --- a/packages/domain/src/validation/shared/entities.ts +++ b/packages/domain/src/validation/shared/entities.ts @@ -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 = >(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]; }; @@ -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; -export type AuthUserSchema = z.infer; +export type UserProfileSchema = z.infer; export type MnpDetailsSchema = z.infer; export type OrderTotalsSchema = z.infer; export type WhmcsOrderItemSchema = z.infer; diff --git a/packages/domain/src/validation/shared/identifiers.ts b/packages/domain/src/validation/shared/identifiers.ts index 5de78291..0a0da625 100644 --- a/packages/domain/src/validation/shared/identifiers.ts +++ b/packages/domain/src/validation/shared/identifiers.ts @@ -3,35 +3,59 @@ * Branded types for entity IDs and external system identifiers */ -import { z } from 'zod'; +import { z } from "zod"; // ===================================================== // INTERNAL ENTITY IDS (Branded Types) // ===================================================== -export const userIdSchema = z.string().min(1, 'User ID is required').brand<'UserId'>(); -export const orderIdSchema = z.string().min(1, 'Order ID is required').brand<'OrderId'>(); -export const invoiceIdSchema = z.string().min(1, 'Invoice ID is required').brand<'InvoiceId'>(); -export const subscriptionIdSchema = z.string().min(1, 'Subscription ID is required').brand<'SubscriptionId'>(); -export const paymentIdSchema = z.string().min(1, 'Payment ID is required').brand<'PaymentId'>(); -export const caseIdSchema = z.string().min(1, 'Case ID is required').brand<'CaseId'>(); -export const sessionIdSchema = z.string().min(1, 'Session ID is required').brand<'SessionId'>(); +export const userIdSchema = z.string().min(1, "User ID is required").brand<"UserId">(); +export const orderIdSchema = z.string().min(1, "Order ID is required").brand<"OrderId">(); +export const invoiceIdSchema = z.string().min(1, "Invoice ID is required").brand<"InvoiceId">(); +export const subscriptionIdSchema = z + .string() + .min(1, "Subscription ID is required") + .brand<"SubscriptionId">(); +export const paymentIdSchema = z.string().min(1, "Payment ID is required").brand<"PaymentId">(); +export const caseIdSchema = z.string().min(1, "Case ID is required").brand<"CaseId">(); +export const sessionIdSchema = z.string().min(1, "Session ID is required").brand<"SessionId">(); // ===================================================== // WHMCS SYSTEM IDS // ===================================================== -export const whmcsClientIdSchema = z.number().int().positive('WHMCS Client ID must be positive').brand<'WhmcsClientId'>(); -export const whmcsInvoiceIdSchema = z.number().int().positive('WHMCS Invoice ID must be positive').brand<'WhmcsInvoiceId'>(); -export const whmcsProductIdSchema = z.number().int().positive('WHMCS Product ID must be positive').brand<'WhmcsProductId'>(); +export const whmcsClientIdSchema = z + .number() + .int() + .positive("WHMCS Client ID must be positive") + .brand<"WhmcsClientId">(); +export const whmcsInvoiceIdSchema = z + .number() + .int() + .positive("WHMCS Invoice ID must be positive") + .brand<"WhmcsInvoiceId">(); +export const whmcsProductIdSchema = z + .number() + .int() + .positive("WHMCS Product ID must be positive") + .brand<"WhmcsProductId">(); // ===================================================== // SALESFORCE SYSTEM IDS // ===================================================== -export const salesforceContactIdSchema = z.string().length(18, 'Salesforce Contact ID must be 18 characters').brand<'SalesforceContactId'>(); -export const salesforceAccountIdSchema = z.string().length(18, 'Salesforce Account ID must be 18 characters').brand<'SalesforceAccountId'>(); -export const salesforceCaseIdSchema = z.string().length(18, 'Salesforce Case ID must be 18 characters').brand<'SalesforceCaseId'>(); +export const salesforceContactIdSchema = z + .string() + .length(18, "Salesforce Contact ID must be 18 characters") + .brand<"SalesforceContactId">(); +export const salesforceAccountIdSchema = z + .string() + .length(18, "Salesforce Account ID must be 18 characters") + .brand<"SalesforceAccountId">(); +export const salesforceCaseIdSchema = z + .string() + .length(18, "Salesforce Case ID must be 18 characters") + .brand<"SalesforceCaseId">(); // ===================================================== // TYPE EXPORTS diff --git a/packages/domain/src/validation/shared/index.ts b/packages/domain/src/validation/shared/index.ts index 5e70525e..3aaa4df4 100644 --- a/packages/domain/src/validation/shared/index.ts +++ b/packages/domain/src/validation/shared/index.ts @@ -4,7 +4,7 @@ */ // Core validation primitives -export * from './primitives'; +export * from "./primitives"; // Entity identifiers and branded types (schemas only, types are in domain/common) export { @@ -21,7 +21,7 @@ export { salesforceContactIdSchema, salesforceAccountIdSchema, salesforceCaseIdSchema, -} from './identifiers'; +} from "./identifiers"; // Common patterns and schemas (schemas only, avoid type conflicts) export { @@ -42,7 +42,16 @@ export { asyncStateSuccessSchema, asyncStateErrorSchema, asyncStateSchema, -} from './common'; +} from "./common"; // Validation utilities and helpers -export * from './utilities'; +export * from "./utilities"; + +export { userSchema, userProfileSchema } from "./entities"; +export { + orderItemProductSchema, + orderDetailItemSchema, + orderSummaryItemSchema, + orderDetailsSchema, + orderSummarySchema, +} from "./order"; diff --git a/packages/domain/src/validation/shared/order.ts b/packages/domain/src/validation/shared/order.ts new file mode 100644 index 00000000..387dd3a4 --- /dev/null +++ b/packages/domain/src/validation/shared/order.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; + +import { moneyAmountSchema } from "./primitives"; + +export const orderItemProductSchema = z.object({ + id: z.string().optional(), + name: z.string().optional(), + sku: z.string(), + whmcsProductId: z.string().optional(), + itemClass: z.string().optional(), + billingCycle: z.string().optional(), +}); + +export const orderDetailItemSchema = z.object({ + id: z.string(), + orderId: z.string(), + quantity: z.number(), + unitPrice: z.number(), + totalPrice: z.number(), + billingCycle: z.string().optional(), + product: orderItemProductSchema, +}); + +export const orderSummaryItemSchema = z.object({ + name: z.string().optional(), + sku: z.string().optional(), + itemClass: z.string().optional(), + quantity: z.number(), + unitPrice: z.number().optional(), + totalPrice: z.number().optional(), + billingCycle: z.string().optional(), +}); + +export const orderDetailsSchema = z.object({ + id: z.string(), + orderNumber: z.string(), + status: z.string(), + orderType: z.string().optional(), + effectiveDate: z.string(), + totalAmount: moneyAmountSchema, + accountId: z.string().optional(), + accountName: z.string().optional(), + createdDate: z.string(), + lastModifiedDate: z.string(), + activationType: z.string().optional(), + activationStatus: z.string().optional(), + scheduledAt: z.string().optional(), + whmcsOrderId: z.string().optional(), + items: z.array(orderDetailItemSchema), +}); + +export const orderSummarySchema = z.object({ + id: z.string(), + orderNumber: z.string(), + status: z.string(), + orderType: z.string().optional(), + effectiveDate: z.string(), + totalAmount: moneyAmountSchema, + createdDate: z.string(), + lastModifiedDate: z.string(), + whmcsOrderId: z.string().optional(), + itemsSummary: z.array(orderSummaryItemSchema), +}); + diff --git a/packages/domain/src/validation/shared/primitives.ts b/packages/domain/src/validation/shared/primitives.ts index 4126a313..209f8f2d 100644 --- a/packages/domain/src/validation/shared/primitives.ts +++ b/packages/domain/src/validation/shared/primitives.ts @@ -3,7 +3,7 @@ * Core validation building blocks - single source of truth for basic patterns */ -import { z } from 'zod'; +import { z } from "zod"; // ===================================================== // BASIC FIELD VALIDATION @@ -11,27 +11,27 @@ import { z } from 'zod'; export const emailSchema = z .string() - .email('Please enter a valid email address') + .email("Please enter a valid email address") .toLowerCase() .trim(); export const passwordSchema = z .string() - .min(8, 'Password must be at least 8 characters') - .regex(/[A-Z]/, 'Password must contain at least one uppercase letter') - .regex(/[a-z]/, 'Password must contain at least one lowercase letter') - .regex(/[0-9]/, 'Password must contain at least one number') - .regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character'); + .min(8, "Password must be at least 8 characters") + .regex(/[A-Z]/, "Password must contain at least one uppercase letter") + .regex(/[a-z]/, "Password must contain at least one lowercase letter") + .regex(/[0-9]/, "Password must contain at least one number") + .regex(/[^A-Za-z0-9]/, "Password must contain at least one special character"); export const nameSchema = z .string() - .min(1, 'Name is required') - .max(100, 'Name must be less than 100 characters') + .min(1, "Name is required") + .max(100, "Name must be less than 100 characters") .trim(); export const phoneSchema = z .string() - .regex(/^[+]?[0-9\s\-()]{7,20}$/, 'Please enter a valid phone number') + .regex(/^[+]?[0-9\s\-()]{7,20}$/, "Please enter a valid phone number") .trim(); // ===================================================== @@ -40,49 +40,61 @@ export const phoneSchema = z // Canonical address schema - single source of truth export const addressSchema = z.object({ - street: z.string().max(200, 'Street address is too long').nullable(), - streetLine2: z.string().max(200, 'Street address line 2 is too long').nullable(), - city: z.string().max(100, 'City name is too long').nullable(), - state: z.string().max(100, 'State/Prefecture name is too long').nullable(), - postalCode: z.string().max(20, 'Postal code is too long').nullable(), - country: z.string().max(100, 'Country name is too long').nullable(), + street: z.string().max(200, "Street address is too long").nullable(), + streetLine2: z.string().max(200, "Street address line 2 is too long").nullable(), + city: z.string().max(100, "City name is too long").nullable(), + state: z.string().max(100, "State/Prefecture name is too long").nullable(), + postalCode: z.string().max(20, "Postal code is too long").nullable(), + country: z.string().max(100, "Country name is too long").nullable(), }); // Required address schema for forms export const requiredAddressSchema = z.object({ - street: z.string().min(1, 'Street address is required').max(200, 'Street address is too long').trim(), - streetLine2: z.string().max(200, 'Street address line 2 is too long').optional(), - city: z.string().min(1, 'City is required').max(100, 'City name is too long').trim(), - state: z.string().min(1, 'State/Prefecture is required').max(100, 'State/Prefecture name is too long').trim(), - postalCode: z.string().min(1, 'Postal code is required').max(20, 'Postal code is too long').trim(), - country: z.string().min(1, 'Country is required').max(100, 'Country name is too long').trim(), + street: z + .string() + .min(1, "Street address is required") + .max(200, "Street address is too long") + .trim(), + streetLine2: z.string().max(200, "Street address line 2 is too long").optional(), + city: z.string().min(1, "City is required").max(100, "City name is too long").trim(), + state: z + .string() + .min(1, "State/Prefecture is required") + .max(100, "State/Prefecture name is too long") + .trim(), + postalCode: z + .string() + .min(1, "Postal code is required") + .max(20, "Postal code is too long") + .trim(), + country: z.string().min(1, "Country is required").max(100, "Country name is too long").trim(), }); -export const countryCodeSchema = z.string().length(2, 'Country code must be 2 characters'); -export const currencyCodeSchema = z.string().length(3, 'Currency code must be 3 characters'); +export const countryCodeSchema = z.string().length(2, "Country code must be 2 characters"); +export const currencyCodeSchema = z.string().length(3, "Currency code must be 3 characters"); // ===================================================== // TEMPORAL DATA // ===================================================== -export const timestampSchema = z.string().datetime('Invalid timestamp format'); -export const dateSchema = z.string().date('Invalid date format'); +export const timestampSchema = z.string().datetime("Invalid timestamp format"); +export const dateSchema = z.string().date("Invalid date format"); // ===================================================== // NUMERIC DATA // ===================================================== -export const moneyAmountSchema = z.number().int().nonnegative('Amount must be non-negative'); -export const percentageSchema = z.number().min(0).max(100, 'Percentage must be between 0 and 100'); +export const moneyAmountSchema = z.number().int().nonnegative("Amount must be non-negative"); +export const percentageSchema = z.number().min(0).max(100, "Percentage must be between 0 and 100"); // ===================================================== // COMMON ENUMS // ===================================================== -export const genderEnum = z.enum(['male', 'female', 'other']); -export const statusEnum = z.enum(['active', 'inactive', 'pending', 'suspended']); -export const priorityEnum = z.enum(['low', 'medium', 'high', 'urgent']); -export const categoryEnum = z.enum(['technical', 'billing', 'account', 'general']); +export const genderEnum = z.enum(["male", "female", "other"]); +export const statusEnum = z.enum(["active", "inactive", "pending", "suspended"]); +export const priorityEnum = z.enum(["low", "medium", "high", "urgent"]); +export const categoryEnum = z.enum(["technical", "billing", "account", "general"]); // ===================================================== // TYPE EXPORTS diff --git a/packages/domain/src/validation/shared/utilities.ts b/packages/domain/src/validation/shared/utilities.ts index e6da4207..0b5ea3ba 100644 --- a/packages/domain/src/validation/shared/utilities.ts +++ b/packages/domain/src/validation/shared/utilities.ts @@ -3,7 +3,7 @@ * Use Zod directly - these are just convenience re-exports */ -import { z } from 'zod'; +import { z } from "zod"; // ===================================================== // DIRECT ZOD USAGE - NO ABSTRACTIONS diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index c1c362e5..1da6d48e 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -4,8 +4,8 @@ */ // Re-export Zod for convenience -export { z } from 'zod'; +export { z } from "zod"; // Framework-specific exports -export * from './nestjs'; -export * from './react'; +export * from "./nestjs"; +export * from "./react"; diff --git a/packages/validation/src/nestjs/index.ts b/packages/validation/src/nestjs/index.ts index cc043669..79da338c 100644 --- a/packages/validation/src/nestjs/index.ts +++ b/packages/validation/src/nestjs/index.ts @@ -3,4 +3,4 @@ * Simple Zod validation for NestJS */ -export { ZodPipe, createZodPipe } from '../zod-pipe'; +export { ZodPipe, createZodPipe } from "../zod-pipe"; diff --git a/packages/validation/src/react/index.ts b/packages/validation/src/react/index.ts index 374e953d..31fd9e0a 100644 --- a/packages/validation/src/react/index.ts +++ b/packages/validation/src/react/index.ts @@ -4,9 +4,4 @@ */ export { useZodForm } from "../zod-form"; -export type { - ZodFormOptions, - UseZodFormReturn, - FormErrors, - FormTouched, -} from "../zod-form"; +export type { ZodFormOptions, UseZodFormReturn, FormErrors, FormTouched } from "../zod-form"; diff --git a/packages/validation/src/zod-form.ts b/packages/validation/src/zod-form.ts index d690b417..d223b719 100644 --- a/packages/validation/src/zod-form.ts +++ b/packages/validation/src/zod-form.ts @@ -7,33 +7,44 @@ import { useCallback, useMemo, useState } from "react"; import type { FormEvent } from "react"; import { ZodError, type ZodIssue, type ZodSchema } from "zod"; -export type FormErrors = Record; -export type FormTouched = Record; +type FormFieldKey> = Extract; -export interface ZodFormOptions { - schema: ZodSchema; - initialValues: T; - onSubmit?: (data: T) => Promise | unknown; +type ErrorKey> = FormFieldKey | string; + +export type FormErrors> = Partial< + Record, string | undefined> +>; + +export type FormTouched> = Partial< + Record, boolean | undefined> +>; + +export interface ZodFormOptions> { + schema: ZodSchema; + initialValues: TValues; + onSubmit?: (data: TValues) => Promise | void; } -export interface UseZodFormReturn> { - values: T; - errors: FormErrors; - touched: FormTouched; +export interface UseZodFormReturn> { + values: TValues; + errors: FormErrors; + touched: FormTouched; submitError: string | null; isSubmitting: boolean; isValid: boolean; - setValue: (field: K, value: T[K]) => void; - setTouched: (field: K, touched: boolean) => void; - setTouchedField: (field: K, touched?: boolean) => void; + setValue: (field: K, value: TValues[K]) => void; + setTouched: (field: K, touched: boolean) => void; + setTouchedField: (field: K, touched?: boolean) => void; validate: () => boolean; - validateField: (field: K) => boolean; + validateField: (field: K) => boolean; handleSubmit: (event?: FormEvent) => Promise; reset: () => void; } -function issuesToErrors(issues: ZodIssue[]): FormErrors { - const nextErrors: FormErrors = {}; +function issuesToErrors>( + issues: ZodIssue[] +): FormErrors { + const nextErrors: FormErrors = {}; issues.forEach(issue => { const [first, ...rest] = issue.path; @@ -60,18 +71,18 @@ function issuesToErrors(issues: ZodIssue[]): FormErrors { return nextErrors; } -export function useZodForm>({ +export function useZodForm>({ schema, initialValues, onSubmit, -}: ZodFormOptions): UseZodFormReturn { - const [values, setValues] = useState(initialValues); - const [errors, setErrors] = useState>({}); - const [touched, setTouchedState] = useState>({}); +}: ZodFormOptions): UseZodFormReturn { + const [values, setValues] = useState(initialValues); + const [errors, setErrors] = useState>({}); + const [touched, setTouchedState] = useState>({}); const [submitError, setSubmitError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); - const clearFieldError = useCallback((field: keyof T) => { + const clearFieldError = useCallback((field: keyof TValues) => { const fieldKey = String(field); setErrors(prev => { const prefix = `${fieldKey}.`; @@ -82,7 +93,7 @@ export function useZodForm>({ return prev; } - const next: FormErrors = { ...prev }; + const next: FormErrors = { ...prev }; delete next[fieldKey]; Object.keys(next).forEach(key => { if (key.startsWith(prefix)) { @@ -93,83 +104,92 @@ export function useZodForm>({ }); }, []); - const validate = useCallback(() => { + const validate = useCallback((): boolean => { try { schema.parse(values); setErrors({}); return true; } catch (error) { if (error instanceof ZodError) { - setErrors(issuesToErrors(error.issues)); + setErrors(issuesToErrors(error.issues)); } return false; } }, [schema, values]); - const validateField = useCallback((field: K) => { - const result = schema.safeParse(values); + const validateField = useCallback( + (field: K): boolean => { + const result = schema.safeParse(values); + + if (result.success) { + clearFieldError(field); + setErrors(prev => { + if (prev._form === undefined) { + return prev; + } + const next: FormErrors = { ...prev }; + delete next._form; + return next; + }); + return true; + } + + const fieldKey = String(field); + const relatedIssues = result.error.issues.filter(issue => issue.path[0] === field); - if (result.success) { - clearFieldError(field); setErrors(prev => { - if (prev._form === undefined) { - return prev; + const next: FormErrors = { ...prev }; + + if (relatedIssues.length > 0) { + const message = relatedIssues[0]?.message ?? ""; + next[fieldKey] = message; + relatedIssues.forEach(issue => { + const nestedKey = issue.path.join("."); + if (nestedKey) { + next[nestedKey] = issue.message; + } + }); + } else { + delete next[fieldKey]; } - const next: FormErrors = { ...prev }; - delete next._form; + + const formLevelIssue = result.error.issues.find(issue => issue.path.length === 0); + if (formLevelIssue) { + next._form = formLevelIssue.message; + } else if (relatedIssues.length === 0) { + delete next._form; + } + return next; }); - return true; - } - const fieldKey = String(field); - const relatedIssues = result.error.issues.filter(issue => issue.path[0] === field); + return relatedIssues.length === 0; + }, + [schema, values, clearFieldError] + ); - setErrors(prev => { - const next: FormErrors = { ...prev }; + const setValue = useCallback( + (field: K, value: TValues[K]): void => { + setValues(prev => ({ ...prev, [field]: value })); + clearFieldError(field); + }, + [clearFieldError] + ); - if (relatedIssues.length > 0) { - const message = relatedIssues[0]?.message ?? ""; - next[fieldKey] = message; - relatedIssues.forEach(issue => { - const nestedKey = issue.path.join("."); - if (nestedKey) { - next[nestedKey] = issue.message; - } - }); - } else { - delete next[fieldKey]; - } - - const formLevelIssue = result.error.issues.find(issue => issue.path.length === 0); - if (formLevelIssue) { - next._form = formLevelIssue.message; - } else if (relatedIssues.length === 0) { - delete next._form; - } - - return next; - }); - - return relatedIssues.length === 0; - }, [schema, values, clearFieldError]); - - const setValue = useCallback((field: K, value: T[K]) => { - setValues(prev => ({ ...prev, [field]: value })); - clearFieldError(field); - }, [clearFieldError]); - - const setTouched = useCallback((field: K, value: boolean) => { + const setTouched = useCallback((field: K, value: boolean): void => { setTouchedState(prev => ({ ...prev, [String(field)]: value })); }, []); - const setTouchedField = useCallback((field: K, value: boolean = true) => { - setTouched(field, value); - void validateField(field); - }, [setTouched, validateField]); + const setTouchedField = useCallback( + (field: K, value: boolean = true): void => { + setTouched(field, value); + void validateField(field); + }, + [setTouched, validateField] + ); const handleSubmit = useCallback( - async (event?: FormEvent) => { + async (event?: FormEvent): Promise => { event?.preventDefault(); if (!onSubmit) { @@ -187,7 +207,7 @@ export function useZodForm>({ if (prev._form === undefined) { return prev; } - const next: FormErrors = { ...prev }; + const next: FormErrors = { ...prev }; delete next._form; return next; }); @@ -207,7 +227,7 @@ export function useZodForm>({ [validate, onSubmit, values] ); - const reset = useCallback(() => { + const reset = useCallback((): void => { setValues(initialValues); setErrors({}); setTouchedState({}); diff --git a/packages/validation/src/zod-pipe.ts b/packages/validation/src/zod-pipe.ts index fa16cf87..2a5a16f5 100644 --- a/packages/validation/src/zod-pipe.ts +++ b/packages/validation/src/zod-pipe.ts @@ -3,8 +3,8 @@ * Just uses Zod as-is with clean error formatting */ -import { PipeTransform, Injectable, BadRequestException } from "@nestjs/common"; -import type { ArgumentMetadata } from "@nestjs/common"; +import type { PipeTransform, ArgumentMetadata } from "@nestjs/common"; +import { Injectable, BadRequestException } from "@nestjs/common"; import type { ZodSchema } from "zod"; import { ZodError } from "zod"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88a6d307..3f869e99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: husky: specifier: ^9.1.7 version: 9.1.7 + pino: + specifier: ^9.9.0 + version: 9.9.5 prettier: specifier: ^3.6.2 version: 3.6.2 @@ -51,6 +54,9 @@ importers: typescript-eslint: specifier: ^8.40.0 version: 8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) + zod: + specifier: ^4.1.9 + version: 4.1.9 apps/bff: dependencies: @@ -60,9 +66,6 @@ importers: '@customer-portal/logging': specifier: workspace:* version: link:../../packages/logging - '@customer-portal/validation': - specifier: workspace:* - version: link:../../packages/validation '@nestjs/bullmq': specifier: ^11.0.3 version: 11.0.3(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(bullmq@5.58.5) @@ -132,6 +135,9 @@ importers: nestjs-pino: specifier: ^4.4.0 version: 4.4.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@10.5.0)(pino@9.9.5)(rxjs@7.8.2) + nestjs-zod: + specifier: ^5.0.1 + version: 5.0.1(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.2.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.1.5) passport: specifier: ^0.7.0 version: 0.7.0 @@ -241,6 +247,9 @@ importers: apps/portal: dependencies: + '@customer-portal/logging': + specifier: workspace:* + version: link:../../packages/logging '@customer-portal/validation': specifier: workspace:* version: link:../../packages/validation @@ -4134,6 +4143,17 @@ packages: pino-http: ^6.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 rxjs: ^7.1.0 + nestjs-zod@5.0.1: + resolution: {integrity: sha512-IjK99tpK+04UobLJJA5tlHv94Ml3wNWiRr7pRVDFJ62Jy/c/aYhMQa90WKiy7qUcmIDMWyPHQpHQN/MehESOmw==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/swagger': ^7.4.2 || ^8.0.0 || ^11.0.0 + rxjs: ^7.0.0 + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + '@nestjs/swagger': + optional: true + next@15.5.0: resolution: {integrity: sha512-N1lp9Hatw3a9XLt0307lGB4uTKsXDhyOKQo7uYMzX4i0nF/c27grcGXkLdb7VcT8QPYLBa8ouIyEoUQJ2OyeNQ==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} @@ -9675,6 +9695,15 @@ snapshots: pino-http: 10.5.0 rxjs: 7.8.2 + nestjs-zod@5.0.1(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.2.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.1.5): + dependencies: + '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + deepmerge: 4.3.1 + rxjs: 7.8.2 + zod: 4.1.5 + optionalDependencies: + '@nestjs/swagger': 11.2.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2) + next@15.5.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: '@next/env': 15.5.0