From 0f6bae840ffb1ba965091bdbf4d47fa310168ce0 Mon Sep 17 00:00:00 2001 From: barsa Date: Thu, 15 Jan 2026 11:28:25 +0900 Subject: [PATCH] feat: add eligibility check flow with form, OTP, and success steps - Implemented FormStep component for user input (name, email, address). - Created OtpStep component for OTP verification. - Developed SuccessStep component to display success messages based on account creation. - Introduced eligibility-check.store for managing state throughout the eligibility check process. - Added commitlint configuration for standardized commit messages. - Configured knip for workspace management and project structure. --- .husky/commit-msg | 1 + CLAUDE.md | 7 +- apps/bff/package.json | 1 + apps/bff/src/app.module.ts | 3 +- apps/bff/src/app/bootstrap.ts | 9 +- apps/bff/src/core/config/app.config.ts | 2 +- apps/bff/src/core/config/auth-dev.config.ts | 8 +- apps/bff/src/core/http/exception.filter.ts | 36 +- .../bff/src/core/http/request-context.util.ts | 8 +- apps/bff/src/core/logging/logging.module.ts | 154 +- .../security/middleware/csrf.middleware.ts | 7 +- .../core/security/services/csrf.service.ts | 8 +- apps/bff/src/core/utils/error.util.ts | 6 +- apps/bff/src/core/utils/retry.util.ts | 6 +- apps/bff/src/infra/audit/audit.service.ts | 68 +- apps/bff/src/infra/cache/cache.service.ts | 19 +- .../infra/cache/distributed-lock.service.ts | 8 +- apps/bff/src/infra/database/prisma.service.ts | 4 +- .../distributed-transaction.service.ts | 10 +- .../database/services/transaction.service.ts | 29 +- .../email/providers/sendgrid.provider.ts | 8 +- apps/bff/src/infra/queue/queue.module.ts | 2 +- .../salesforce-queue-degradation.service.ts | 5 +- .../salesforce-queue-metrics.service.ts | 19 +- .../salesforce-request-queue.service.ts | 18 +- .../services/whmcs-request-queue.service.ts | 8 +- .../bff/src/infra/realtime/realtime.pubsub.ts | 2 +- .../src/infra/realtime/realtime.service.ts | 4 +- .../services/freebit-account.service.ts | 8 +- .../freebit/services/freebit-auth.service.ts | 4 +- .../services/freebit-cancellation.service.ts | 4 +- .../services/freebit-client.service.ts | 6 +- .../freebit/services/freebit-error.service.ts | 6 +- .../freebit/services/freebit-esim.service.ts | 2 +- .../services/freebit-mapper.service.ts | 19 +- .../services/freebit-operations.service.ts | 2 +- .../freebit/services/freebit-plan.service.ts | 12 +- .../services/freebit-rate-limiter.service.ts | 2 +- .../freebit/services/freebit-usage.service.ts | 4 +- .../freebit/services/freebit-voice.service.ts | 6 +- .../services/japanpost-connection.service.ts | 6 +- .../config/order-field-map.service.ts | 2 +- .../events/account-events.subscriber.ts | 10 +- .../events/case-events.subscriber.ts | 2 +- .../events/catalog-cdc.subscriber.ts | 2 +- .../salesforce/events/order-cdc.subscriber.ts | 16 +- .../salesforce/events/shared/pubsub.utils.ts | 4 +- .../salesforce/salesforce.service.ts | 18 +- .../opportunity-resolution.service.ts | 2 +- .../opportunity-mutation.service.ts | 8 +- .../opportunity/opportunity-query.service.ts | 10 +- .../services/salesforce-account.service.ts | 44 +- .../services/salesforce-case.service.ts | 24 +- .../services/salesforce-connection.service.ts | 6 +- .../salesforce-opportunity.service.ts | 55 +- .../integrations/sftp/sftp-client.service.ts | 2 +- .../whmcs/cache/whmcs-cache.service.ts | 42 +- .../connection/config/whmcs-config.service.ts | 4 +- .../whmcs-connection-orchestrator.service.ts | 2 +- .../services/whmcs-http-client.service.ts | 16 +- .../connection/types/connection.types.ts | 18 +- .../whmcs/services/whmcs-currency.service.ts | 20 +- .../whmcs/services/whmcs-invoice.service.ts | 21 +- .../whmcs/services/whmcs-order.service.ts | 41 +- .../whmcs/services/whmcs-payment.service.ts | 4 +- .../whmcs/services/whmcs-sso.service.ts | 2 +- apps/bff/src/main.ts | 1 - .../modules/auth/application/auth.facade.ts | 7 +- apps/bff/src/modules/auth/auth.types.ts | 3 +- .../infra/otp/get-started-session.service.ts | 149 ++ .../src/modules/auth/infra/otp/otp.service.ts | 2 +- .../rate-limiting/auth-rate-limit.service.ts | 1 - .../auth/infra/token/jose-jwt.service.ts | 13 +- .../infra/token/token-migration.service.ts | 10 +- .../infra/token/token-revocation.service.ts | 17 +- .../auth/infra/token/token-storage.service.ts | 14 +- .../modules/auth/infra/token/token.service.ts | 29 +- .../workflows/get-started-workflow.service.ts | 590 ++++-- .../workflows/signup-workflow.service.ts | 12 +- .../infra/workflows/signup/signup.types.ts | 10 +- .../auth/presentation/http/auth.controller.ts | 6 +- .../http/get-started.controller.ts | 69 +- .../http/guards/local-auth.guard.ts | 4 +- .../src/modules/auth/utils/jwt-expiry.util.ts | 2 +- .../auth/utils/token-from-request.util.ts | 4 +- .../services/invoice-retrieval.service.ts | 4 +- .../src/modules/health/health.controller.ts | 8 +- .../cache/mapping-cache.service.ts | 2 +- .../modules/id-mappings/domain/contract.ts | 8 +- .../modules/id-mappings/domain/validation.ts | 10 +- .../modules/id-mappings/mappings.service.ts | 19 +- .../modules/me-status/me-status.service.ts | 18 +- .../notifications/notifications.service.ts | 12 +- .../src/modules/orders/orders.controller.ts | 8 +- .../orders/queue/provisioning.queue.ts | 2 +- .../orders/services/checkout.service.ts | 14 +- .../orders/services/order-builder.service.ts | 2 +- .../order-fulfillment-error.service.ts | 2 +- .../order-fulfillment-orchestrator.service.ts | 55 +- .../order-fulfillment-validator.service.ts | 6 +- .../services/order-orchestrator.service.ts | 12 +- .../services/order-pricebook.service.ts | 18 +- .../services/order-validator.service.ts | 10 +- .../orders/services/orders-cache.service.ts | 8 +- .../services/sim-fulfillment.service.ts | 107 +- .../modules/realtime/realtime.controller.ts | 3 +- .../internet-eligibility.service.ts | 39 +- .../application/internet-eligibility.types.ts | 4 +- .../application/internet-services.service.ts | 2 +- .../application/services-cache.service.ts | 10 +- .../application/sim-services.service.ts | 17 +- .../services/internet-cancellation.service.ts | 2 +- .../subscriptions/sim-management.service.ts | 23 +- .../services/sim-billing.service.ts | 10 +- .../sim-call-history-formatter.service.ts | 8 +- .../sim-call-history-parser.service.ts | 37 +- .../services/sim-call-history.service.ts | 23 +- .../services/sim-cancellation.service.ts | 14 +- .../services/sim-notification.service.ts | 2 +- .../services/sim-orchestrator.service.ts | 11 +- .../services/sim-plan.service.ts | 41 +- .../services/sim-schedule.service.ts | 2 +- .../services/sim-usage.service.ts | 7 +- .../sim-order-activation.service.ts | 20 +- .../subscriptions/sim-usage-store.service.ts | 5 +- .../subscriptions/subscriptions.controller.ts | 5 +- .../subscriptions/subscriptions.service.ts | 43 +- .../src/modules/support/support.service.ts | 4 +- .../modules/users/application/users.facade.ts | 3 +- .../users/infra/user-profile.service.ts | 40 +- .../bff/src/modules/users/users.controller.ts | 3 +- .../verification/residence-card.service.ts | 21 +- .../services/voice-options.service.ts | 10 +- apps/portal/scripts/stubs/core-api.ts | 2 +- .../src/app/(public)/(site)/services/page.tsx | 150 +- apps/portal/src/app/_health/route.ts | 4 +- apps/portal/src/app/account/services/page.tsx | 21 +- .../components/atoms/AnimatedContainer.tsx | 42 - .../src/components/atoms/LoadingOverlay.tsx | 41 - apps/portal/src/components/atoms/Spinner.tsx | 31 - apps/portal/src/components/atoms/button.tsx | 4 +- apps/portal/src/components/atoms/checkbox.tsx | 2 +- .../src/components/atoms/empty-state.tsx | 20 +- .../src/components/atoms/error-state.tsx | 12 +- apps/portal/src/components/atoms/index.ts | 27 +- .../src/components/atoms/loading-skeleton.tsx | 226 +-- .../src/components/atoms/status-pill.tsx | 33 +- .../molecules/AnimatedCard/AnimatedCard.tsx | 8 +- .../molecules/AsyncBlock/AsyncBlock.tsx | 8 +- .../molecules/FormField/FormField.tsx | 18 +- .../molecules/ProgressSteps/ProgressSteps.tsx | 36 +- .../src/components/molecules/RouteLoading.tsx | 8 +- .../components/molecules/SubCard/SubCard.tsx | 36 +- .../components/molecules/error-boundary.tsx | 6 +- apps/portal/src/components/molecules/index.ts | 6 + .../AgentforceWidget/AgentforceWidget.tsx | 12 +- .../organisms/AppShell/AppShell.tsx | 14 +- .../components/organisms/AppShell/Sidebar.tsx | 2 +- .../organisms/AppShell/navigation.ts | 18 +- .../templates/PageLayout/PageLayout.tsx | 41 +- apps/portal/src/config/environment.ts | 2 +- apps/portal/src/config/feature-flags.ts | 6 +- apps/portal/src/core/api/index.ts | 12 +- apps/portal/src/core/api/runtime/client.ts | 6 +- apps/portal/src/core/logger/logger.ts | 2 +- .../src/core/providers/QueryProvider.tsx | 6 +- .../account/components/PersonalInfoCard.tsx | 2 +- .../account/views/ProfileContainer.tsx | 336 ++-- .../address/components/AddressStepJapan.tsx | 38 +- .../address/components/JapanAddressForm.tsx | 32 +- .../address/components/ZipCodeInput.tsx | 22 +- .../auth/components/AuthModal/AuthModal.tsx | 10 +- .../InlineAuthSection/InlineAuthSection.tsx | 10 +- .../LinkWhmcsForm/LinkWhmcsForm.tsx | 8 +- .../auth/components/LoginForm/LoginForm.tsx | 28 +- .../PasswordResetForm/PasswordResetForm.tsx | 24 +- .../auth/components/SessionTimeoutWarning.tsx | 25 +- .../SetPasswordForm/SetPasswordForm.tsx | 10 +- .../auth/components/SignupForm/SignupForm.tsx | 32 +- .../SignupForm/steps/AccountStep.tsx | 6 +- .../SignupForm/steps/AddressStep.tsx | 4 +- .../SignupForm/steps/PasswordStep.tsx | 14 +- .../SignupForm/steps/ReviewStep.tsx | 8 +- .../src/features/auth/hooks/use-auth.ts | 14 +- .../src/features/billing/api/billing.api.ts | 5 +- .../BillingSummary/BillingSummary.tsx | 6 +- .../InvoiceDetail/InvoiceHeader.tsx | 23 +- .../components/InvoiceDetail/InvoiceItems.tsx | 2 +- .../InvoiceDetail/InvoiceSummaryBar.tsx | 10 +- .../components/InvoiceList/InvoiceList.tsx | 2 +- .../billing/components/InvoiceStatusBadge.tsx | 83 +- .../components/InvoiceTable/InvoiceTable.tsx | 10 +- .../features/billing/views/InvoiceDetail.tsx | 4 +- .../features/billing/views/PaymentMethods.tsx | 15 +- .../checkout/api/checkout-params.api.ts | 6 +- .../components/AccountCheckoutContainer.tsx | 48 +- .../checkout/components/CheckoutEntry.tsx | 8 +- .../components/CheckoutStatusBanners.tsx | 68 +- .../checkout/components/OrderSummaryCard.tsx | 82 - .../IdentityVerificationSection.tsx | 80 +- .../PaymentMethodSection.tsx | 100 +- .../ResidenceCardUploadInput.tsx | 6 +- .../src/features/checkout/components/index.ts | 1 - .../components/DashboardActivityItem.tsx | 4 +- .../dashboard/components/QuickStats.tsx | 6 +- .../dashboard/components/TaskCard.tsx | 14 +- .../dashboard/utils/dashboard.utils.ts | 2 +- .../dashboard/views/DashboardView.tsx | 31 +- .../get-started/api/get-started.api.ts | 26 + .../steps/CompleteAccountStep.tsx | 18 +- .../GetStartedForm/steps/VerificationStep.tsx | 2 +- .../components/OtpInput/OtpInput.tsx | 10 +- .../get-started/stores/get-started.store.ts | 31 +- .../get-started/views/GetStartedView.tsx | 3 +- .../components/FloatingGlassCard.tsx | 2 +- .../notifications/api/notification.api.ts | 6 +- .../components/NotificationItem.tsx | 2 +- .../src/features/orders/api/orders.api.ts | 2 +- .../features/orders/components/OrderCard.tsx | 4 +- .../features/orders/hooks/useOrderUpdates.ts | 2 +- .../src/features/orders/views/OrderDetail.tsx | 4 +- .../src/features/orders/views/OrdersList.tsx | 64 +- .../services/components/base/AddonGroup.tsx | 39 +- .../components/base/AddressConfirmation.tsx | 13 +- .../services/components/base/AddressForm.tsx | 4 +- .../services/components/base/CardPricing.tsx | 8 +- .../components/base/EnhancedOrderSummary.tsx | 383 ---- .../components/base/ProductComparison.tsx | 2 +- .../services/components/base/ServiceCTA.tsx | 20 +- .../EligibilityCheckFlow.tsx | 55 + .../components/eligibility-check/index.ts | 2 + .../steps/CompleteAccountStep.tsx | 390 ++++ .../eligibility-check/steps/FormStep.tsx | 216 +++ .../eligibility-check/steps/OtpStep.tsx | 137 ++ .../eligibility-check/steps/SuccessStep.tsx | 169 ++ .../eligibility-check/steps/index.ts | 4 + .../src/features/services/components/index.ts | 7 - .../internet/EligibilityStatusBadge.tsx | 2 +- .../internet/InstallationOptions.tsx | 2 +- .../internet/InternetIneligibleState.tsx | 2 +- .../internet/InternetModalShell.tsx | 11 +- .../internet/InternetOfferingCard.tsx | 14 +- .../internet/InternetPendingState.tsx | 2 +- .../components/internet/InternetPlanCard.tsx | 4 +- .../internet/PublicOfferingCard.tsx | 12 +- .../configure/hooks/useConfigureState.ts | 4 +- .../components/sim/ActivationForm.tsx | 6 +- .../services/components/sim/MnpForm.tsx | 36 +- .../components/sim/SimConfigureView.tsx | 19 +- .../services/components/sim/SimPlanCard.tsx | 11 +- .../components/sim/SimPlanTypeSection.tsx | 6 +- .../components/sim/SimTypeSelector.tsx | 4 +- .../services/hooks/useConfigureParams.ts | 14 +- .../services/hooks/useInternetConfigure.ts | 13 +- .../services/hooks/useInternetEligibility.ts | 4 +- .../services/hooks/useSimConfigure.ts | 2 +- .../stores/eligibility-check.store.ts | 533 ++++++ .../services/stores/services.store.ts | 2 +- .../services/utils/internet-config.ts | 10 +- .../services/views/InternetConfigure.tsx | 2 +- .../views/InternetEligibilityRequest.tsx | 6 +- .../features/services/views/InternetPlans.tsx | 20 +- .../services/views/PublicEligibilityCheck.tsx | 801 +------- .../services/views/PublicInternetPlans.tsx | 2 +- .../subscriptions/api/sim-actions.api.ts | 23 +- .../CancellationFlow/CancellationFlow.tsx | 3 +- .../components/SubscriptionStatusBadge.tsx | 70 +- .../components/sim/SimActions.tsx | 16 +- .../components/sim/SimManagementSection.tsx | 6 +- .../components/sim/TopUpModal.tsx | 12 +- .../views/CancelSubscription.tsx | 9 +- .../subscriptions/views/SimCallHistory.tsx | 2 +- .../subscriptions/views/SimChangePlan.tsx | 2 +- .../subscriptions/views/SimReissue.tsx | 2 +- .../features/subscriptions/views/SimTopUp.tsx | 14 +- .../views/SubscriptionDetail.tsx | 19 +- .../subscriptions/views/SubscriptionsList.tsx | 4 +- .../features/support/hooks/useSupportCases.ts | 7 +- .../support/views/PublicContactView.tsx | 10 +- .../support/views/SupportCaseDetailView.tsx | 5 +- .../ResidenceCardVerificationSettingsView.tsx | 349 ++-- apps/portal/src/proxy.ts | 2 +- apps/portal/src/shared/constants/countries.ts | 8 +- apps/portal/src/shared/hooks/useZodForm.ts | 30 +- apps/portal/src/shared/utils/date.ts | 26 +- .../src/shared/utils/payment-methods.ts | 12 +- commitlint.config.mjs | 23 + .../code-quality-improvement-plan.md | 2 +- docs/operations/database-operations.md | 2 +- docs/operations/release-procedures.md | 6 +- eslint.config.mjs | 96 + knip.json | 18 + package.json | 8 + packages/domain/address/schema.ts | 14 +- .../billing/providers/whmcs/raw.types.ts | 57 +- .../providers/whmcs-utils/custom-fields.ts | 7 +- .../domain/customer/providers/whmcs/mapper.ts | 18 +- .../customer/providers/whmcs/raw.types.ts | 34 +- packages/domain/customer/schema.ts | 10 +- packages/domain/get-started/contract.ts | 4 + packages/domain/get-started/index.ts | 5 + packages/domain/get-started/schema.ts | 46 + packages/domain/opportunity/helpers.ts | 6 +- packages/domain/orders/contract.ts | 10 +- packages/domain/orders/helpers.ts | 36 +- .../domain/orders/providers/whmcs/mapper.ts | 8 +- packages/domain/orders/utils.ts | 8 +- .../domain/services/providers/whmcs/mapper.ts | 41 +- .../domain/sim/providers/freebit/mapper.ts | 4 +- .../domain/sim/providers/freebit/utils.ts | 6 +- .../providers/whmcs/raw.types.ts | 10 +- .../support/providers/salesforce/mapper.ts | 2 +- .../domain/toolkit/formatting/currency.ts | 6 +- packages/domain/toolkit/formatting/date.ts | 19 +- packages/domain/toolkit/typing/assertions.ts | 2 +- packages/domain/toolkit/typing/guards.ts | 2 +- packages/domain/toolkit/validation/email.ts | 2 +- packages/domain/toolkit/validation/helpers.ts | 6 +- pnpm-lock.yaml | 1608 ++++++++++++++++- tsconfig.base.json | 9 +- 320 files changed, 6631 insertions(+), 3998 deletions(-) create mode 100755 .husky/commit-msg delete mode 100644 apps/portal/src/components/atoms/AnimatedContainer.tsx delete mode 100644 apps/portal/src/components/atoms/LoadingOverlay.tsx delete mode 100644 apps/portal/src/components/atoms/Spinner.tsx delete mode 100644 apps/portal/src/features/checkout/components/OrderSummaryCard.tsx delete mode 100644 apps/portal/src/features/services/components/base/EnhancedOrderSummary.tsx create mode 100644 apps/portal/src/features/services/components/eligibility-check/EligibilityCheckFlow.tsx create mode 100644 apps/portal/src/features/services/components/eligibility-check/index.ts create mode 100644 apps/portal/src/features/services/components/eligibility-check/steps/CompleteAccountStep.tsx create mode 100644 apps/portal/src/features/services/components/eligibility-check/steps/FormStep.tsx create mode 100644 apps/portal/src/features/services/components/eligibility-check/steps/OtpStep.tsx create mode 100644 apps/portal/src/features/services/components/eligibility-check/steps/SuccessStep.tsx create mode 100644 apps/portal/src/features/services/components/eligibility-check/steps/index.ts create mode 100644 apps/portal/src/features/services/stores/eligibility-check.store.ts create mode 100644 commitlint.config.mjs create mode 100644 knip.json diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 00000000..cfe75101 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +pnpm commitlint --edit $1 diff --git a/CLAUDE.md b/CLAUDE.md index 5c833cfb..5f5b763d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,9 +6,14 @@ Instructions for Claude Code working in this repository. ## Agent Behavior +**Always use `pnpm`** — never use `npm`, `yarn`, or `npx`: + +- Use `pnpm exec` to run local binaries (e.g., `pnpm exec prisma migrate status`) +- Use `pnpm dlx` for one-off package execution (e.g., `pnpm dlx ts-prune`) + **Do NOT** run long-running processes without explicit permission: -- `pnpm dev`, `pnpm dev:start`, `npm start`, `npm run dev` +- `pnpm dev`, `pnpm dev:start`, or any dev server commands - Any command that starts servers, watchers, or blocking processes **Always ask first** before: diff --git a/apps/bff/package.json b/apps/bff/package.json index c146facd..01bc3c09 100644 --- a/apps/bff/package.json +++ b/apps/bff/package.json @@ -54,6 +54,7 @@ "nestjs-zod": "^5.0.1", "p-queue": "^9.0.1", "pg": "^8.16.3", + "pino-http": "^11.0.0", "prisma": "^7.1.0", "rate-limiter-flexible": "^9.0.0", "reflect-metadata": "^0.2.2", diff --git a/apps/bff/src/app.module.ts b/apps/bff/src/app.module.ts index 527181e7..afadbf69 100644 --- a/apps/bff/src/app.module.ts +++ b/apps/bff/src/app.module.ts @@ -1,6 +1,5 @@ import { Module } from "@nestjs/common"; -import { APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core"; -import { RouterModule } from "@nestjs/core"; +import { APP_INTERCEPTOR, APP_PIPE, RouterModule } from "@nestjs/core"; import { ConfigModule } from "@nestjs/config"; import { ScheduleModule } from "@nestjs/schedule"; import { ZodSerializerInterceptor, ZodValidationPipe } from "nestjs-zod"; diff --git a/apps/bff/src/app/bootstrap.ts b/apps/bff/src/app/bootstrap.ts index 2254f72b..db415026 100644 --- a/apps/bff/src/app/bootstrap.ts +++ b/apps/bff/src/app/bootstrap.ts @@ -7,6 +7,10 @@ import helmet from "helmet"; import cookieParser from "cookie-parser"; import type { CookieOptions, Response, NextFunction, Request } from "express"; +import { UnifiedExceptionFilter } from "../core/http/exception.filter.js"; + +import { AppModule } from "../app.module.js"; + /* eslint-disable @typescript-eslint/no-namespace */ declare global { namespace Express { @@ -15,11 +19,6 @@ declare global { } } } -/* eslint-enable @typescript-eslint/no-namespace */ - -import { UnifiedExceptionFilter } from "../core/http/exception.filter.js"; - -import { AppModule } from "../app.module.js"; export async function bootstrap(): Promise { const app = await NestFactory.create(AppModule, { diff --git a/apps/bff/src/core/config/app.config.ts b/apps/bff/src/core/config/app.config.ts index 9b887352..ce12abc9 100644 --- a/apps/bff/src/core/config/app.config.ts +++ b/apps/bff/src/core/config/app.config.ts @@ -2,7 +2,7 @@ import { resolve } from "node:path"; import type { ConfigModuleOptions } from "@nestjs/config"; import { validate } from "./env.validation.js"; -const nodeEnv = process.env.NODE_ENV || "development"; +const nodeEnv = process.env["NODE_ENV"] || "development"; // pnpm sets cwd to the package directory (apps/bff) when running scripts const bffRoot = process.cwd(); diff --git a/apps/bff/src/core/config/auth-dev.config.ts b/apps/bff/src/core/config/auth-dev.config.ts index a95fff66..99d94ce8 100644 --- a/apps/bff/src/core/config/auth-dev.config.ts +++ b/apps/bff/src/core/config/auth-dev.config.ts @@ -12,17 +12,17 @@ export interface DevAuthConfig { } export const createDevAuthConfig = (): DevAuthConfig => { - const isDevelopment = process.env.NODE_ENV !== "production"; + const isDevelopment = process.env["NODE_ENV"] !== "production"; return { // Disable CSRF protection in development for easier testing - disableCsrf: isDevelopment && process.env.DISABLE_CSRF === "true", + disableCsrf: isDevelopment && process.env["DISABLE_CSRF"] === "true", // Disable rate limiting in development - disableRateLimit: isDevelopment && process.env.DISABLE_RATE_LIMIT === "true", + disableRateLimit: isDevelopment && process.env["DISABLE_RATE_LIMIT"] === "true", // Disable account locking in development - disableAccountLocking: isDevelopment && process.env.DISABLE_ACCOUNT_LOCKING === "true", + disableAccountLocking: isDevelopment && process.env["DISABLE_ACCOUNT_LOCKING"] === "true", // Enable debug logs in development enableDebugLogs: isDevelopment, diff --git a/apps/bff/src/core/http/exception.filter.ts b/apps/bff/src/core/http/exception.filter.ts index b032491a..f6d59ac3 100644 --- a/apps/bff/src/core/http/exception.filter.ts +++ b/apps/bff/src/core/http/exception.filter.ts @@ -47,11 +47,11 @@ function mapHttpStatusToErrorCode(status?: number): ErrorCodeType { */ interface ErrorContext { requestId: string; - userId?: string; + userId?: string | undefined; method: string; path: string; - userAgent?: string; - ip?: string; + userAgent?: string | undefined; + ip?: string | undefined; } /** @@ -123,7 +123,7 @@ export class UnifiedExceptionFilter implements ExceptionFilter { */ private extractExceptionDetails(exception: HttpException): { message: string; - code?: ErrorCodeType; + code?: ErrorCodeType | undefined; } { const response = exception.getResponse(); @@ -136,19 +136,21 @@ export class UnifiedExceptionFilter implements ExceptionFilter { const code = this.extractExplicitCode(responseObj); // Handle NestJS validation errors (array of messages) - if (Array.isArray(responseObj.message)) { - const firstMessage = responseObj.message.find((m): m is string => typeof m === "string"); + const messageField = responseObj["message"]; + if (Array.isArray(messageField)) { + const firstMessage = messageField.find((m): m is string => typeof m === "string"); if (firstMessage) return { message: firstMessage, code }; } // Handle standard message field - if (typeof responseObj.message === "string") { - return { message: responseObj.message, code }; + if (typeof messageField === "string") { + return { message: messageField, code }; } // Handle error field - if (typeof responseObj.error === "string") { - return { message: responseObj.error, code }; + const errorField = responseObj["error"]; + if (typeof errorField === "string") { + return { message: errorField, code }; } return { message: exception.message, code }; @@ -162,16 +164,18 @@ export class UnifiedExceptionFilter implements ExceptionFilter { */ private extractExplicitCode(responseObj: Record): ErrorCodeType | undefined { // 1) Preferred: { code: "AUTH_003" } - if (typeof responseObj.code === "string" && this.isKnownErrorCode(responseObj.code)) { - return responseObj.code as ErrorCodeType; + const codeField = responseObj["code"]; + if (typeof codeField === "string" && this.isKnownErrorCode(codeField)) { + return codeField as ErrorCodeType; } // 2) Standard API error format: { error: { code: "AUTH_003" } } - const maybeError = responseObj.error; + const maybeError = responseObj["error"]; if (maybeError && typeof maybeError === "object") { const errorObj = maybeError as Record; - if (typeof errorObj.code === "string" && this.isKnownErrorCode(errorObj.code)) { - return errorObj.code as ErrorCodeType; + const errorCode = errorObj["code"]; + if (typeof errorCode === "string" && this.isKnownErrorCode(errorCode)) { + return errorCode as ErrorCodeType; } } @@ -209,7 +213,7 @@ export class UnifiedExceptionFilter implements ExceptionFilter { .replace(/secret[=:]\s*[^\s]+/gi, "secret=[HIDDEN]") .replace(/token[=:]\s*[^\s]+/gi, "token=[HIDDEN]") .replace(/key[=:]\s*[^\s]+/gi, "key=[HIDDEN]") - .substring(0, 200); // Limit length + .slice(0, 200); // Limit length } /** diff --git a/apps/bff/src/core/http/request-context.util.ts b/apps/bff/src/core/http/request-context.util.ts index 8b78ee89..e1272dc7 100644 --- a/apps/bff/src/core/http/request-context.util.ts +++ b/apps/bff/src/core/http/request-context.util.ts @@ -1,8 +1,8 @@ export interface RequestContextLike { - headers?: Record; - ip?: string; - connection?: { remoteAddress?: string }; - socket?: { remoteAddress?: string }; + headers?: Record | undefined; + ip?: string | undefined; + connection?: { remoteAddress?: string | undefined } | undefined; + socket?: { remoteAddress?: string | undefined } | undefined; } export function extractClientIp(request?: RequestContextLike): string { diff --git a/apps/bff/src/core/logging/logging.module.ts b/apps/bff/src/core/logging/logging.module.ts index 60082ff1..e596d60d 100644 --- a/apps/bff/src/core/logging/logging.module.ts +++ b/apps/bff/src/core/logging/logging.module.ts @@ -1,86 +1,88 @@ import { Global, Module } from "@nestjs/common"; import { LoggerModule } from "nestjs-pino"; +import type { Options as PinoHttpOptions } from "pino-http"; const prettyLogsEnabled = - process.env.PRETTY_LOGS === "true" || process.env.NODE_ENV !== "production"; + process.env["PRETTY_LOGS"] === "true" || process.env["NODE_ENV"] !== "production"; + +// Build pinoHttp config - extracted to avoid type issues with exactOptionalPropertyTypes +const pinoHttpConfig: PinoHttpOptions = { + level: process.env["LOG_LEVEL"] || "info", + name: process.env["APP_NAME"] || "customer-portal-bff", + /** + * Reduce noise from pino-http auto logging: + * - successful requests => debug (hidden when LOG_LEVEL=info) + * - 4xx => warn + * - 5xx / errors => error + * + * This keeps production logs focused on actionable events while still + * allowing full request logging by setting LOG_LEVEL=debug. + */ + customLogLevel: (_req, res, err) => { + if (err || (res?.statusCode && res.statusCode >= 500)) return "error"; + if (res?.statusCode && res.statusCode >= 400) return "warn"; + return "debug"; + }, + autoLogging: { + ignore: req => { + const url = req.url || ""; + return ( + url.includes("/health") || + url.includes("/favicon") || + url.includes("/_next/") || + url.includes("/api/auth/session") + ); + }, + }, + serializers: { + req: (req: { method?: string; url?: string; headers?: Record }) => ({ + method: req.method, + url: req.url, + ...(process.env["NODE_ENV"] === "development" && { + headers: req.headers, + }), + }), + res: (res: { statusCode?: number }) => ({ + statusCode: res.statusCode, + }), + }, + redact: { + paths: [ + "req.headers.authorization", + "req.headers.cookie", + "req.body", + "res.body", + "password", + "token", + "secret", + "jwt", + "apiKey", + ], + remove: true, + }, + formatters: { + level: (label: string) => ({ level: label }), + bindings: () => ({}), + }, +}; + +// Add transport only in development to avoid type issues +if (prettyLogsEnabled) { + pinoHttpConfig.transport = { + target: "pino-pretty", + options: { + colorize: true, + translateTime: false, + singleLine: true, + // keep level for coloring but drop other noisy metadata + ignore: "pid,req,res,context,name,time", + }, + }; +} @Global() @Module({ - imports: [ - LoggerModule.forRoot({ - pinoHttp: { - level: process.env.LOG_LEVEL || "info", - name: process.env.APP_NAME || "customer-portal-bff", - /** - * Reduce noise from pino-http auto logging: - * - successful requests => debug (hidden when LOG_LEVEL=info) - * - 4xx => warn - * - 5xx / errors => error - * - * This keeps production logs focused on actionable events while still - * allowing full request logging by setting LOG_LEVEL=debug. - */ - customLogLevel: (_req, res, err) => { - if (err || (res?.statusCode && res.statusCode >= 500)) return "error"; - if (res?.statusCode && res.statusCode >= 400) return "warn"; - return "debug"; - }, - autoLogging: { - ignore: req => { - const url = req.url || ""; - return ( - url.includes("/health") || - url.includes("/favicon") || - url.includes("/_next/") || - url.includes("/api/auth/session") - ); - }, - }, - serializers: { - req: (req: { method?: string; url?: string; headers?: Record }) => ({ - method: req.method, - url: req.url, - ...(process.env.NODE_ENV === "development" && { - headers: req.headers, - }), - }), - res: (res: { statusCode?: number }) => ({ - statusCode: res.statusCode, - }), - }, - transport: prettyLogsEnabled - ? { - target: "pino-pretty", - options: { - colorize: true, - translateTime: false, - singleLine: true, - // keep level for coloring but drop other noisy metadata - ignore: "pid,req,res,context,name,time", - }, - } - : undefined, - redact: { - paths: [ - "req.headers.authorization", - "req.headers.cookie", - "req.body", - "res.body", - "password", - "token", - "secret", - "jwt", - "apiKey", - ], - remove: true, - }, - formatters: { - level: (label: string) => ({ level: label }), - bindings: () => ({}), - }, - }, - }), - ], + imports: [LoggerModule.forRoot({ pinoHttp: pinoHttpConfig })], exports: [LoggerModule], }) export class LoggingModule {} diff --git a/apps/bff/src/core/security/middleware/csrf.middleware.ts b/apps/bff/src/core/security/middleware/csrf.middleware.ts index 18cbfecd..2fef75e1 100644 --- a/apps/bff/src/core/security/middleware/csrf.middleware.ts +++ b/apps/bff/src/core/security/middleware/csrf.middleware.ts @@ -31,17 +31,14 @@ type CsrfRequest = Omit & { */ @Injectable() export class CsrfMiddleware implements NestMiddleware { - private readonly isProduction: boolean; private readonly exemptPaths: Set; private readonly safeMethods: Set; constructor( private readonly csrfService: CsrfService, - private readonly configService: ConfigService, + _configService: ConfigService, @Inject(Logger) private readonly logger: Logger ) { - this.isProduction = this.configService.get("NODE_ENV") === "production"; - // Paths that don't require CSRF protection this.exemptPaths = new Set([ "/api/auth/login", @@ -94,7 +91,7 @@ export class CsrfMiddleware implements NestMiddleware { return false; } - private validateCsrfToken(req: CsrfRequest, res: Response, next: NextFunction): void { + private validateCsrfToken(req: CsrfRequest, _res: Response, next: NextFunction): void { const token = this.extractTokenFromRequest(req); const secret = this.extractSecretFromCookie(req); const sessionId = req.user?.sessionId || this.extractSessionId(req); diff --git a/apps/bff/src/core/security/services/csrf.service.ts b/apps/bff/src/core/security/services/csrf.service.ts index 0bdc89f2..5d8e17fe 100644 --- a/apps/bff/src/core/security/services/csrf.service.ts +++ b/apps/bff/src/core/security/services/csrf.service.ts @@ -7,13 +7,13 @@ export interface CsrfTokenData { token: string; secret: string; expiresAt: Date; - sessionId?: string; - userId?: string; + sessionId?: string | undefined; + userId?: string | undefined; } export interface CsrfValidationResult { isValid: boolean; - reason?: string; + reason?: string | undefined; } export interface CsrfTokenStats { @@ -191,7 +191,7 @@ export class CsrfService { } private hashToken(token: string): string { - return crypto.createHash("sha256").update(token).digest("hex").substring(0, 16); + return crypto.createHash("sha256").update(token).digest("hex").slice(0, 16); } private constantTimeEquals(a: string, b: string): boolean { diff --git a/apps/bff/src/core/utils/error.util.ts b/apps/bff/src/core/utils/error.util.ts index 8c383c97..5fe4b941 100644 --- a/apps/bff/src/core/utils/error.util.ts +++ b/apps/bff/src/core/utils/error.util.ts @@ -11,7 +11,7 @@ export function isErrorWithMessage(error: unknown): error is Error { typeof error === "object" && error !== null && Object.hasOwn(error, "message") && - typeof (error as Record).message === "string" + typeof (error as Record)["message"] === "string" ); } @@ -19,8 +19,8 @@ export function isErrorWithMessage(error: unknown): error is Error { * Enhanced error type with common error properties */ interface EnhancedError extends Error { - code?: string; - statusCode?: number; + code?: string | undefined; + statusCode?: number | undefined; cause?: unknown; } diff --git a/apps/bff/src/core/utils/retry.util.ts b/apps/bff/src/core/utils/retry.util.ts index 1425f074..93e3900b 100644 --- a/apps/bff/src/core/utils/retry.util.ts +++ b/apps/bff/src/core/utils/retry.util.ts @@ -71,8 +71,10 @@ export function calculateBackoffDelay( /** * Promise-based sleep utility */ -export function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); +export async function sleep(ms: number): Promise { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); } /** diff --git a/apps/bff/src/infra/audit/audit.service.ts b/apps/bff/src/infra/audit/audit.service.ts index a783e58f..4705c722 100644 --- a/apps/bff/src/infra/audit/audit.service.ts +++ b/apps/bff/src/infra/audit/audit.service.ts @@ -22,16 +22,28 @@ export enum AuditAction { } export interface AuditLogData { - userId?: string; + userId?: string | undefined; action: AuditAction; - resource?: string; - details?: Record | string | number | boolean | null; - ipAddress?: string; - userAgent?: string; - success?: boolean; - error?: string; + resource?: string | undefined; + details?: Record | string | number | boolean | null | undefined; + ipAddress?: string | undefined; + userAgent?: string | undefined; + success?: boolean | undefined; + error?: string | undefined; } +/** + * Minimal request shape for audit logging. + * Compatible with Express Request but only requires the fields needed for IP/UA extraction. + * Must be compatible with RequestContextLike from request-context.util.ts. + */ +export type AuditRequest = { + headers?: Record | undefined; + ip?: string | undefined; + connection?: { remoteAddress?: string | undefined } | undefined; + socket?: { remoteAddress?: string | undefined } | undefined; +}; + @Injectable() export class AuditService { constructor( @@ -41,23 +53,24 @@ export class AuditService { async log(data: AuditLogData): Promise { try { - await this.prisma.auditLog.create({ - data: { - userId: data.userId, - action: data.action, - resource: data.resource, - details: - data.details === undefined - ? undefined - : data.details === null - ? Prisma.JsonNull - : (JSON.parse(JSON.stringify(data.details)) as Prisma.InputJsonValue), - ipAddress: data.ipAddress, - userAgent: data.userAgent, - success: data.success ?? true, - error: data.error, - }, - }); + const createData: Parameters[0]["data"] = { + action: data.action, + success: data.success ?? true, + }; + + if (data.userId !== undefined) createData.userId = data.userId; + if (data.resource !== undefined) createData.resource = data.resource; + if (data.ipAddress !== undefined) createData.ipAddress = data.ipAddress; + if (data.userAgent !== undefined) createData.userAgent = data.userAgent; + if (data.error !== undefined) createData.error = data.error; + if (data.details !== undefined) { + createData.details = + data.details === null + ? Prisma.JsonNull + : (JSON.parse(JSON.stringify(data.details)) as Prisma.InputJsonValue); + } + + await this.prisma.auditLog.create({ data: createData }); } catch (error) { this.logger.error("Audit logging failed", { errorType: error instanceof Error ? error.constructor.name : "Unknown", @@ -70,12 +83,7 @@ export class AuditService { action: AuditAction, userId?: string, details?: Record | string | number | boolean | null, - request?: { - headers?: Record; - ip?: string; - connection?: { remoteAddress?: string }; - socket?: { remoteAddress?: string }; - }, + request?: AuditRequest, success: boolean = true, error?: string ): Promise { diff --git a/apps/bff/src/infra/cache/cache.service.ts b/apps/bff/src/infra/cache/cache.service.ts index 8bd15523..d4f0c0a4 100644 --- a/apps/bff/src/infra/cache/cache.service.ts +++ b/apps/bff/src/infra/cache/cache.service.ts @@ -78,22 +78,23 @@ export class CacheService { */ async delPattern(pattern: string): Promise { const pipeline = this.redis.pipeline(); - let pending = 0; + const state = { pending: 0 }; const flush = async () => { - if (pending === 0) { + if (state.pending === 0) { return; } await pipeline.exec(); - pending = 0; + // eslint-disable-next-line require-atomic-updates -- flush is called sequentially, no concurrent access + state.pending = 0; }; await this.scanPattern(pattern, async keys => { - keys.forEach(key => { + for (const key of keys) { pipeline.del(key); - pending += 1; - }); - if (pending >= 1000) { + state.pending += 1; + } + if (state.pending >= 1000) { await flush(); } }); @@ -122,9 +123,9 @@ export class CacheService { let total = 0; await this.scanPattern(pattern, async keys => { const pipeline = this.redis.pipeline(); - keys.forEach(key => { + for (const key of keys) { pipeline.memory("USAGE", key); - }); + } const results = await pipeline.exec(); if (!results) { return; diff --git a/apps/bff/src/infra/cache/distributed-lock.service.ts b/apps/bff/src/infra/cache/distributed-lock.service.ts index d4e640c7..1a583a5f 100644 --- a/apps/bff/src/infra/cache/distributed-lock.service.ts +++ b/apps/bff/src/infra/cache/distributed-lock.service.ts @@ -65,7 +65,7 @@ export class DistributedLockService { return { key: lockKey, token, - release: () => this.release(lockKey, token), + release: async () => this.release(lockKey, token), }; } @@ -183,7 +183,9 @@ export class DistributedLockService { /** * Delay helper */ - private delay(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); + private async delay(ms: number): Promise { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); } } diff --git a/apps/bff/src/infra/database/prisma.service.ts b/apps/bff/src/infra/database/prisma.service.ts index 1e8de7a7..3417875e 100644 --- a/apps/bff/src/infra/database/prisma.service.ts +++ b/apps/bff/src/infra/database/prisma.service.ts @@ -17,12 +17,11 @@ import { PrismaPg } from "@prisma/adapter-pg"; export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(PrismaService.name); private readonly pool: Pool; - private readonly instanceTag: string; private destroyCalls = 0; private poolEnded = false; constructor() { - const connectionString = process.env.DATABASE_URL; + const connectionString = process.env["DATABASE_URL"]; if (!connectionString) { throw new Error("DATABASE_URL environment variable is required"); } @@ -43,7 +42,6 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul super({ adapter }); this.pool = pool; - this.instanceTag = `${process.pid}:${Date.now()}`; } async onModuleInit() { diff --git a/apps/bff/src/infra/database/services/distributed-transaction.service.ts b/apps/bff/src/infra/database/services/distributed-transaction.service.ts index be61ab32..63d3b17d 100644 --- a/apps/bff/src/infra/database/services/distributed-transaction.service.ts +++ b/apps/bff/src/infra/database/services/distributed-transaction.service.ts @@ -24,8 +24,8 @@ export interface DistributedTransactionResult< TStepResults extends StepResultMap = StepResultMap, > { success: boolean; - data?: TData; - error?: string; + data?: TData | undefined; + error?: string | undefined; duration: number; stepsExecuted: number; stepsRolledBack: number; @@ -254,7 +254,7 @@ export class DistributedTransactionService { databaseOperation, { description: `${options.description} - Database Operations`, - timeout: options.timeout, + ...(options.timeout !== undefined && { timeout: options.timeout }), } ); @@ -305,7 +305,7 @@ export class DistributedTransactionService { databaseOperation, { description: `${options.description} - Database Operations`, - timeout: options.timeout, + ...(options.timeout !== undefined && { timeout: options.timeout }), } ); @@ -442,6 +442,6 @@ export class DistributedTransactionService { } private generateTransactionId(): string { - return `dtx_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + return `dtx_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; } } diff --git a/apps/bff/src/infra/database/services/transaction.service.ts b/apps/bff/src/infra/database/services/transaction.service.ts index cadffa80..cb6167c0 100644 --- a/apps/bff/src/infra/database/services/transaction.service.ts +++ b/apps/bff/src/infra/database/services/transaction.service.ts @@ -28,36 +28,41 @@ export interface TransactionOptions { * Maximum time to wait for transaction to complete (ms) * Default: 30 seconds */ - timeout?: number; + timeout?: number | undefined; /** * Maximum number of retry attempts on serialization failures * Default: 3 */ - maxRetries?: number; + maxRetries?: number | undefined; /** * Custom isolation level for the transaction * Default: ReadCommitted */ - isolationLevel?: "ReadUncommitted" | "ReadCommitted" | "RepeatableRead" | "Serializable"; + isolationLevel?: + | "ReadUncommitted" + | "ReadCommitted" + | "RepeatableRead" + | "Serializable" + | undefined; /** * Description of the transaction for logging */ - description?: string; + description?: string | undefined; /** * Whether to automatically rollback external operations on database rollback * Default: true */ - autoRollback?: boolean; + autoRollback?: boolean | undefined; } export interface TransactionResult { success: boolean; - data?: T; - error?: string; + data?: T | undefined; + error?: string | undefined; duration: number; operationsCount: number; rollbacksExecuted: number; @@ -289,8 +294,10 @@ export class TransactionService { // Execute rollbacks in reverse order (LIFO) for (let i = context.rollbackActions.length - 1; i >= 0; i--) { + const rollbackAction = context.rollbackActions[i]; + if (!rollbackAction) continue; try { - await context.rollbackActions[i](); + await rollbackAction(); rollbacksExecuted++; this.logger.debug(`Rollback ${i + 1} completed [${context.id}]`); } catch (rollbackError) { @@ -321,11 +328,13 @@ export class TransactionService { } private async delay(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise(resolve => { + setTimeout(resolve, ms); + }); } private generateTransactionId(): string { - return `tx_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + return `tx_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; } /** diff --git a/apps/bff/src/infra/email/providers/sendgrid.provider.ts b/apps/bff/src/infra/email/providers/sendgrid.provider.ts index 74931351..bb2db6cb 100644 --- a/apps/bff/src/infra/email/providers/sendgrid.provider.ts +++ b/apps/bff/src/infra/email/providers/sendgrid.provider.ts @@ -19,8 +19,8 @@ export interface ProviderSendOptions { export interface SendGridErrorDetail { message: string; - field?: string; - help?: string; + field?: string | undefined; + help?: string | undefined; } export interface ParsedSendGridError { @@ -215,8 +215,8 @@ export class SendGridEmailProvider implements OnModuleInit { private maskEmail(email: string | string[]): string | string[] { const mask = (e: string): string => { const [local, domain] = e.split("@"); - if (!domain) return "***"; - const maskedLocal = local.length > 2 ? `${local[0]}***${local[local.length - 1]}` : "***"; + if (!domain || !local) return "***"; + const maskedLocal = local.length > 2 ? `${local[0]}***${local.at(-1)}` : "***"; return `${maskedLocal}@${domain}`; }; diff --git a/apps/bff/src/infra/queue/queue.module.ts b/apps/bff/src/infra/queue/queue.module.ts index 604ab636..ef81c62a 100644 --- a/apps/bff/src/infra/queue/queue.module.ts +++ b/apps/bff/src/infra/queue/queue.module.ts @@ -17,7 +17,7 @@ function parseRedisConnection(redisUrl: string) { host: url.hostname, port: Number(url.port || (isTls ? 6380 : 6379)), password: url.password || undefined, - ...(db !== undefined ? { db } : {}), + ...(db === undefined ? {} : { db }), ...(isTls ? { tls: {} } : {}), } as Record; } catch { diff --git a/apps/bff/src/infra/queue/services/salesforce-queue-degradation.service.ts b/apps/bff/src/infra/queue/services/salesforce-queue-degradation.service.ts index e25304d5..be059f35 100644 --- a/apps/bff/src/infra/queue/services/salesforce-queue-degradation.service.ts +++ b/apps/bff/src/infra/queue/services/salesforce-queue-degradation.service.ts @@ -11,7 +11,7 @@ export type DegradationReason = "rate-limit" | "usage-threshold" | "queue-pressu export interface SalesforceDegradationSnapshot { degraded: boolean; reason: DegradationReason | null; - cooldownExpiresAt?: Date; + cooldownExpiresAt?: Date | undefined; usagePercent: number; } @@ -118,8 +118,7 @@ export class SalesforceQueueDegradationService { this.activateDegradeWindow("usage-threshold"); } - const threshold = this.usageWarningLevels - .slice() + const threshold = [...this.usageWarningLevels] .reverse() .find(level => usagePercent >= level && level > this.highestUsageWarningIssued); diff --git a/apps/bff/src/infra/queue/services/salesforce-queue-metrics.service.ts b/apps/bff/src/infra/queue/services/salesforce-queue-metrics.service.ts index f7947a65..5f19ba7b 100644 --- a/apps/bff/src/infra/queue/services/salesforce-queue-metrics.service.ts +++ b/apps/bff/src/infra/queue/services/salesforce-queue-metrics.service.ts @@ -8,16 +8,16 @@ export interface SalesforceRouteMetricsInternal { label: string; totalRequests: number; failedRequests: number; - lastSuccessTime?: Date; - lastErrorTime?: Date; + lastSuccessTime?: Date | undefined; + lastErrorTime?: Date | undefined; } export interface SalesforceRouteMetricsSnapshot { totalRequests: number; failedRequests: number; successRate: number; - lastSuccessTime?: Date; - lastErrorTime?: Date; + lastSuccessTime?: Date | undefined; + lastErrorTime?: Date | undefined; } export interface SalesforceQueueMetricsData { @@ -29,9 +29,9 @@ export interface SalesforceQueueMetricsData { averageWaitTime: number; averageExecutionTime: number; dailyApiUsage: number; - lastRequestTime?: Date; - lastErrorTime?: Date; - lastRateLimitTime?: Date; + lastRequestTime?: Date | undefined; + lastErrorTime?: Date | undefined; + lastRateLimitTime?: Date | undefined; } @Injectable() @@ -52,7 +52,10 @@ export class SalesforceQueueMetricsService { dailyApiUsage: 0, }; - constructor(@Inject(Logger) private readonly logger: Logger) {} + constructor(@Inject(Logger) _logger: Logger) { + // Logger available for future use in metrics logging + void _logger; + } /** * Get current metrics data diff --git a/apps/bff/src/infra/queue/services/salesforce-request-queue.service.ts b/apps/bff/src/infra/queue/services/salesforce-request-queue.service.ts index 3efce907..91b6711e 100644 --- a/apps/bff/src/infra/queue/services/salesforce-request-queue.service.ts +++ b/apps/bff/src/infra/queue/services/salesforce-request-queue.service.ts @@ -22,13 +22,13 @@ export interface SalesforceQueueMetrics { averageWaitTime: number; averageExecutionTime: number; dailyApiUsage: number; - lastRequestTime?: Date; - lastErrorTime?: Date; - lastRateLimitTime?: Date; - dailyApiLimit?: number; - dailyUsagePercent?: number; - routeBreakdown?: Record; - degradation?: SalesforceDegradationSnapshot; + lastRequestTime?: Date | undefined; + lastErrorTime?: Date | undefined; + lastRateLimitTime?: Date | undefined; + dailyApiLimit?: number | undefined; + dailyUsagePercent?: number | undefined; + routeBreakdown?: Record | undefined; + degradation?: SalesforceDegradationSnapshot | undefined; } export interface SalesforceRequestOptions { @@ -480,7 +480,9 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest error: lastError.message, }); - await new Promise(resolve => setTimeout(resolve, delay)); + await new Promise(resolve => { + setTimeout(resolve, delay); + }); } } diff --git a/apps/bff/src/infra/queue/services/whmcs-request-queue.service.ts b/apps/bff/src/infra/queue/services/whmcs-request-queue.service.ts index a492c7ce..2cf954e6 100644 --- a/apps/bff/src/infra/queue/services/whmcs-request-queue.service.ts +++ b/apps/bff/src/infra/queue/services/whmcs-request-queue.service.ts @@ -35,10 +35,10 @@ export interface WhmcsQueueMetrics { } export interface WhmcsRequestOptions { - priority?: number; // Higher number = higher priority (0-10) - timeout?: number; // Request timeout in ms - retryAttempts?: number; // Number of retry attempts - retryDelay?: number; // Base delay between retries in ms + priority?: number | undefined; // Higher number = higher priority (0-10) + timeout?: number | undefined; // Request timeout in ms + retryAttempts?: number | undefined; // Number of retry attempts + retryDelay?: number | undefined; // Base delay between retries in ms } /** diff --git a/apps/bff/src/infra/realtime/realtime.pubsub.ts b/apps/bff/src/infra/realtime/realtime.pubsub.ts index 536ec44c..8be7da68 100644 --- a/apps/bff/src/infra/realtime/realtime.pubsub.ts +++ b/apps/bff/src/infra/realtime/realtime.pubsub.ts @@ -72,7 +72,7 @@ export class RealtimePubSubService implements OnModuleInit, OnModuleDestroy { } } - publish(message: RealtimePubSubMessage): Promise { + async publish(message: RealtimePubSubMessage): Promise { return this.redis.publish(this.CHANNEL, JSON.stringify(message)); } diff --git a/apps/bff/src/infra/realtime/realtime.service.ts b/apps/bff/src/infra/realtime/realtime.service.ts index 6e130dbb..9faa7130 100644 --- a/apps/bff/src/infra/realtime/realtime.service.ts +++ b/apps/bff/src/infra/realtime/realtime.service.ts @@ -109,7 +109,7 @@ export class RealtimeService { } const evt = this.buildMessage(message.event, message.data); - set.forEach(observer => { + for (const observer of set) { try { observer.next(evt); } catch (error) { @@ -118,7 +118,7 @@ export class RealtimeService { error: extractErrorMessage(error), }); } - }); + } } private buildMessage(event: TEvent, data: unknown): MessageEvent { diff --git a/apps/bff/src/integrations/freebit/services/freebit-account.service.ts b/apps/bff/src/integrations/freebit/services/freebit-account.service.ts index 9eb3d474..c4fb90a9 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-account.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-account.service.ts @@ -35,8 +35,8 @@ export class FreebitAccountService { const config = this.auth.getConfig(); const configured = config.detailsEndpoint || "/master/getAcnt/"; - const candidates = Array.from( - new Set([ + const candidates = [ + ...new Set([ configured, configured.replace(/\/$/, ""), "/master/getAcnt/", @@ -53,8 +53,8 @@ export class FreebitAccountService { "/mvno/getInfo", "/master/getDetail/", "/master/getDetail", - ]) - ); + ]), + ]; let response: FreebitAccountDetailsResponse | undefined; let lastError: unknown; diff --git a/apps/bff/src/integrations/freebit/services/freebit-auth.service.ts b/apps/bff/src/integrations/freebit/services/freebit-auth.service.ts index bd5e0c54..010cad66 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-auth.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-auth.service.ts @@ -80,9 +80,9 @@ export class FreebitAuthService { } const data = (await response.json()) as FreebitAuthResponse; - const resultCode = data?.resultCode != null ? String(data.resultCode).trim() : undefined; + const resultCode = data?.resultCode == null ? undefined : String(data.resultCode).trim(); const statusCode = - data?.status?.statusCode != null ? String(data.status.statusCode).trim() : undefined; + data?.status?.statusCode == null ? undefined : String(data.status.statusCode).trim(); if (resultCode !== "100") { throw new FreebitError( diff --git a/apps/bff/src/integrations/freebit/services/freebit-cancellation.service.ts b/apps/bff/src/integrations/freebit/services/freebit-cancellation.service.ts index 101a8e33..467d810d 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-cancellation.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-cancellation.service.ts @@ -30,7 +30,7 @@ export class FreebitCancellationService { await this.rateLimiter.executeWithSpacing(account, "cancellation", async () => { const request: Omit = { account, - runTime: scheduledAt, + ...(scheduledAt !== undefined && { runTime: scheduledAt }), }; this.logger.log(`Cancelling SIM plan via PA05-04 for account ${account}`, { @@ -75,7 +75,7 @@ export class FreebitCancellationService { const request: Omit = { kind: "MVNO", account, - runDate, + ...(runDate !== undefined && { runDate }), }; this.logger.log(`Cancelling SIM account via PA02-04 for account ${account}`, { diff --git a/apps/bff/src/integrations/freebit/services/freebit-client.service.ts b/apps/bff/src/integrations/freebit/services/freebit-client.service.ts index f8abfa99..0dfd2318 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-client.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-client.service.ts @@ -57,7 +57,7 @@ export class FreebitClientService { }); if (!response.ok) { - const isProd = process.env.NODE_ENV === "production"; + const isProd = process.env["NODE_ENV"] === "production"; const bodySnippet = isProd ? undefined : await this.safeReadBodySnippet(response); this.logger.error("Freebit API HTTP error", { url, @@ -78,7 +78,7 @@ export class FreebitClientService { const statusCode = this.normalizeResultCode(responseData.status?.statusCode); if (resultCode && resultCode !== "100") { - const isProd = process.env.NODE_ENV === "production"; + const isProd = process.env["NODE_ENV"] === "production"; this.logger.warn("Freebit API returned error response", { url, resultCode, @@ -166,7 +166,7 @@ export class FreebitClientService { const statusCode = this.normalizeResultCode(responseData.status?.statusCode); if (resultCode && resultCode !== "100") { - const isProd = process.env.NODE_ENV === "production"; + const isProd = process.env["NODE_ENV"] === "production"; this.logger.error("Freebit API returned error result code", { url, resultCode, diff --git a/apps/bff/src/integrations/freebit/services/freebit-error.service.ts b/apps/bff/src/integrations/freebit/services/freebit-error.service.ts index 5c9bd3e7..47ae08ee 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-error.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-error.service.ts @@ -2,9 +2,9 @@ * Custom error class for Freebit API errors */ export class FreebitError extends Error { - public readonly resultCode?: string | number; - public readonly statusCode?: string | number; - public readonly statusMessage?: string; + public readonly resultCode?: string | number | undefined; + public readonly statusCode?: string | number | undefined; + public readonly statusMessage?: string | undefined; constructor( message: string, diff --git a/apps/bff/src/integrations/freebit/services/freebit-esim.service.ts b/apps/bff/src/integrations/freebit/services/freebit-esim.service.ts index 4f743c99..dc11c1d1 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-esim.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-esim.service.ts @@ -86,7 +86,7 @@ export class FreebitEsimService { account, eid: newEid, addKind: "R", - planCode: options.planCode, + ...(options.planCode !== undefined && { planCode: options.planCode }), }; await this.client.makeAuthenticatedRequest( diff --git a/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts b/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts index 059630b7..422a6ef8 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts @@ -187,11 +187,12 @@ export class FreebitMapperService { throw new Error("No traffic data in response"); } - const todayUsageKb = parseInt(response.traffic.today, 10) || 0; + const todayUsageKb = Number.parseInt(response.traffic.today, 10) || 0; const recentDaysData = response.traffic.inRecentDays.split(",").map((usage, index) => ({ - date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000).toISOString().split("T")[0], - usageKb: parseInt(usage, 10) || 0, - usageMb: Math.round(((parseInt(usage, 10) || 0) / 1024) * 100) / 100, + date: + new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000).toISOString().split("T")[0] ?? "", + usageKb: Number.parseInt(usage, 10) || 0, + usageMb: Math.round(((Number.parseInt(usage, 10) || 0) / 1024) * 100) / 100, })); return { @@ -216,8 +217,8 @@ export class FreebitMapperService { totalAdditions: Number(response.total) || 0, additionCount: Number(response.count) || 0, history: response.quotaHistory.map(item => ({ - quotaKb: parseInt(item.quota, 10), - quotaMb: Math.round((parseInt(item.quota, 10) / 1024) * 100) / 100, + quotaKb: Number.parseInt(item.quota, 10), + quotaMb: Math.round((Number.parseInt(item.quota, 10) / 1024) * 100) / 100, addedDate: item.date, expiryDate: item.expire, campaignCode: item.quotaCode, @@ -259,9 +260,9 @@ export class FreebitMapperService { return null; } - const year = parseInt(dateString.substring(0, 4), 10); - const month = parseInt(dateString.substring(4, 6), 10) - 1; // Month is 0-indexed - const day = parseInt(dateString.substring(6, 8), 10); + const year = Number.parseInt(dateString.slice(0, 4), 10); + const month = Number.parseInt(dateString.slice(4, 6), 10) - 1; // Month is 0-indexed + const day = Number.parseInt(dateString.slice(6, 8), 10); return new Date(year, month, day); } diff --git a/apps/bff/src/integrations/freebit/services/freebit-operations.service.ts b/apps/bff/src/integrations/freebit/services/freebit-operations.service.ts index ffdcc19d..5f75be7c 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-operations.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-operations.service.ts @@ -111,7 +111,7 @@ export class FreebitOperationsService { account: string, newPlanCode: string, options: { assignGlobalIp?: boolean; scheduledAt?: string } = {} - ): Promise<{ ipv4?: string; ipv6?: string }> { + ): Promise<{ ipv4?: string | undefined; ipv6?: string | undefined }> { const normalizedAccount = this.normalizeAccount(account); return this.planService.changeSimPlan(normalizedAccount, newPlanCode, options); } diff --git a/apps/bff/src/integrations/freebit/services/freebit-plan.service.ts b/apps/bff/src/integrations/freebit/services/freebit-plan.service.ts index 553d2925..1a264716 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-plan.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-plan.service.ts @@ -37,7 +37,7 @@ export class FreebitPlanService { account: string, newPlanCode: string, options: { assignGlobalIp?: boolean; scheduledAt?: string } = {} - ): Promise<{ ipv4?: string; ipv6?: string }> { + ): Promise<{ ipv4?: string | undefined; ipv6?: string | undefined }> { try { return await this.rateLimiter.executeWithSpacing(account, "plan", async () => { // First, get current SIM details to log for debugging @@ -127,17 +127,17 @@ export class FreebitPlanService { }; if (error instanceof Error) { - errorDetails.errorName = error.name; - errorDetails.errorMessage = error.message; + errorDetails["errorName"] = error.name; + errorDetails["errorMessage"] = error.message; if ("resultCode" in error) { - errorDetails.resultCode = error.resultCode; + errorDetails["resultCode"] = (error as Record)["resultCode"]; } if ("statusCode" in error) { - errorDetails.statusCode = error.statusCode; + errorDetails["statusCode"] = (error as Record)["statusCode"]; } if ("statusMessage" in error) { - errorDetails.statusMessage = error.statusMessage; + errorDetails["statusMessage"] = (error as Record)["statusMessage"]; } } diff --git a/apps/bff/src/integrations/freebit/services/freebit-rate-limiter.service.ts b/apps/bff/src/integrations/freebit/services/freebit-rate-limiter.service.ts index 19ea62d8..09ddd612 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-rate-limiter.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-rate-limiter.service.ts @@ -155,7 +155,7 @@ export class FreebitRateLimiterService { const parsed: OperationTimestamps = {}; for (const [field, value] of Object.entries(raw)) { const num = Number(value); - if (!isNaN(num)) { + if (!Number.isNaN(num)) { parsed[field as keyof OperationTimestamps] = num; } } diff --git a/apps/bff/src/integrations/freebit/services/freebit-usage.service.ts b/apps/bff/src/integrations/freebit/services/freebit-usage.service.ts index abd03e6a..8d36d3fb 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-usage.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-usage.service.ts @@ -62,8 +62,8 @@ export class FreebitUsageService { const baseRequest: Omit = { account, quota: quotaKb, - quotaCode: options.campaignCode, - expire: options.expiryDate, + ...(options.campaignCode !== undefined && { quotaCode: options.campaignCode }), + ...(options.expiryDate !== undefined && { expire: options.expiryDate }), }; const scheduled = !!options.scheduledAt; diff --git a/apps/bff/src/integrations/freebit/services/freebit-voice.service.ts b/apps/bff/src/integrations/freebit/services/freebit-voice.service.ts index ba52a1b1..dc2af5f8 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-voice.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-voice.service.ts @@ -14,9 +14,9 @@ import type { } from "../interfaces/freebit.types.js"; export interface VoiceFeatures { - voiceMailEnabled?: boolean; - callWaitingEnabled?: boolean; - internationalRoamingEnabled?: boolean; + voiceMailEnabled?: boolean | undefined; + callWaitingEnabled?: boolean | undefined; + internationalRoamingEnabled?: boolean | undefined; } /** diff --git a/apps/bff/src/integrations/japanpost/services/japanpost-connection.service.ts b/apps/bff/src/integrations/japanpost/services/japanpost-connection.service.ts index 5f40578f..e8ad5e01 100644 --- a/apps/bff/src/integrations/japanpost/services/japanpost-connection.service.ts +++ b/apps/bff/src/integrations/japanpost/services/japanpost-connection.service.ts @@ -37,9 +37,9 @@ interface ConfigValidationError { * Japan Post API error response format */ interface JapanPostErrorResponse { - request_id?: string; - error_code?: string; - message?: string; + request_id?: string | undefined; + error_code?: string | undefined; + message?: string | undefined; } @Injectable() diff --git a/apps/bff/src/integrations/salesforce/config/order-field-map.service.ts b/apps/bff/src/integrations/salesforce/config/order-field-map.service.ts index 0e88a179..531fe921 100644 --- a/apps/bff/src/integrations/salesforce/config/order-field-map.service.ts +++ b/apps/bff/src/integrations/salesforce/config/order-field-map.service.ts @@ -7,7 +7,7 @@ import { type SalesforceOrderFieldMap, } from "@customer-portal/domain/orders/providers"; -const unique = (values: string[]): string[] => Array.from(new Set(values)); +const unique = (values: string[]): string[] => [...new Set(values)]; const SECTION_PREFIX: Record = { order: "ORDER", diff --git a/apps/bff/src/integrations/salesforce/events/account-events.subscriber.ts b/apps/bff/src/integrations/salesforce/events/account-events.subscriber.ts index 4ab760ea..1dce024b 100644 --- a/apps/bff/src/integrations/salesforce/events/account-events.subscriber.ts +++ b/apps/bff/src/integrations/salesforce/events/account-events.subscriber.ts @@ -80,7 +80,7 @@ export class AccountEventsSubscriber implements OnModuleInit { */ private async handleAccountEvent( channel: string, - subscription: { topicName?: string }, + _subscription: { topicName?: string }, callbackType: string, data: unknown ): Promise { @@ -118,10 +118,10 @@ export class AccountEventsSubscriber implements OnModuleInit { if (this.accountNotificationHandler && (eligibilityStatus || verificationStatus)) { void this.accountNotificationHandler.processAccountEvent({ accountId, - eligibilityStatus, - eligibilityValue: undefined, - verificationStatus, - verificationRejectionMessage: rejectionMessage, + eligibilityStatus: eligibilityStatus ?? null, + eligibilityValue: null, + verificationStatus: verificationStatus ?? null, + verificationRejectionMessage: rejectionMessage ?? null, }); } } diff --git a/apps/bff/src/integrations/salesforce/events/case-events.subscriber.ts b/apps/bff/src/integrations/salesforce/events/case-events.subscriber.ts index e88d1073..d51522ba 100644 --- a/apps/bff/src/integrations/salesforce/events/case-events.subscriber.ts +++ b/apps/bff/src/integrations/salesforce/events/case-events.subscriber.ts @@ -63,7 +63,7 @@ export class CaseEventsSubscriber implements OnModuleInit { */ private async handleCaseEvent( channel: string, - subscription: { topicName?: string }, + _subscription: { topicName?: string }, callbackType: string, data: unknown ): Promise { diff --git a/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts b/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts index a73b146e..0933c208 100644 --- a/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts +++ b/apps/bff/src/integrations/salesforce/events/catalog-cdc.subscriber.ts @@ -127,7 +127,7 @@ export class CatalogCdcSubscriber implements OnModuleInit { private async handlePricebookEvent( channel: string, - subscription: { topicName?: string }, + _subscription: { topicName?: string }, callbackType: string, data: unknown ): Promise { diff --git a/apps/bff/src/integrations/salesforce/events/order-cdc.subscriber.ts b/apps/bff/src/integrations/salesforce/events/order-cdc.subscriber.ts index ec9247d6..93343ec1 100644 --- a/apps/bff/src/integrations/salesforce/events/order-cdc.subscriber.ts +++ b/apps/bff/src/integrations/salesforce/events/order-cdc.subscriber.ts @@ -111,7 +111,7 @@ export class OrderCdcSubscriber implements OnModuleInit { private async handleOrderEvent( channel: string, - subscription: { topicName?: string }, + _subscription: { topicName?: string }, callbackType: string, data: unknown ): Promise { @@ -141,7 +141,7 @@ export class OrderCdcSubscriber implements OnModuleInit { this.logger.debug("Order CDC: only internal field changes; skipping cache invalidation", { channel, orderId, - changedFields: Array.from(changedFields), + changedFields: [...changedFields], }); return; } @@ -150,7 +150,7 @@ export class OrderCdcSubscriber implements OnModuleInit { channel, orderId, accountId, - changedFields: Array.from(changedFields), + changedFields: [...changedFields], }); await this.invalidateOrderCaches(orderId, accountId); @@ -222,7 +222,7 @@ export class OrderCdcSubscriber implements OnModuleInit { private async handleOrderItemEvent( channel: string, - subscription: { topicName?: string }, + _subscription: { topicName?: string }, callbackType: string, data: unknown ): Promise { @@ -245,7 +245,7 @@ export class OrderCdcSubscriber implements OnModuleInit { this.logger.debug("OrderItem CDC: only internal field changes; skipping", { channel, orderId, - changedFields: Array.from(changedFields), + changedFields: [...changedFields], }); return; } @@ -253,7 +253,7 @@ export class OrderCdcSubscriber implements OnModuleInit { this.logger.log("OrderItem CDC: invalidating order cache", { channel, orderId, - changedFields: Array.from(changedFields), + changedFields: [...changedFields], }); await this.invalidateOrderCaches(orderId, accountId); @@ -291,7 +291,7 @@ export class OrderCdcSubscriber implements OnModuleInit { return true; } - const customerFacingChanges = Array.from(changedFields).filter( + const customerFacingChanges = [...changedFields].filter( field => !INTERNAL_ORDER_FIELDS.has(field) ); @@ -303,7 +303,7 @@ export class OrderCdcSubscriber implements OnModuleInit { return true; // Safe default } - const customerFacingChanges = Array.from(changedFields).filter( + const customerFacingChanges = [...changedFields].filter( field => !INTERNAL_ORDER_ITEM_FIELDS.has(field) ); diff --git a/apps/bff/src/integrations/salesforce/events/shared/pubsub.utils.ts b/apps/bff/src/integrations/salesforce/events/shared/pubsub.utils.ts index 1d2538f3..c6417d6d 100644 --- a/apps/bff/src/integrations/salesforce/events/shared/pubsub.utils.ts +++ b/apps/bff/src/integrations/salesforce/events/shared/pubsub.utils.ts @@ -98,8 +98,8 @@ export function extractChangedFields(payload: Record | undefine // CDC provides changed fields in different formats depending on API version const changedFieldsArray = - (payload.changedFields as string[] | undefined) || - (payload.changeOrigin as { changedFields?: string[] })?.changedFields || + (payload["changedFields"] as string[] | undefined) || + (payload["changeOrigin"] as { changedFields?: string[] } | undefined)?.changedFields || []; return new Set([ diff --git a/apps/bff/src/integrations/salesforce/salesforce.service.ts b/apps/bff/src/integrations/salesforce/salesforce.service.ts index c0718678..454816dd 100644 --- a/apps/bff/src/integrations/salesforce/salesforce.service.ts +++ b/apps/bff/src/integrations/salesforce/salesforce.service.ts @@ -45,7 +45,7 @@ export class SalesforceService implements OnModuleInit { } } catch (error) { const nodeEnv = - this.configService.get("NODE_ENV") || process.env.NODE_ENV || "development"; + this.configService.get("NODE_ENV") || process.env["NODE_ENV"] || "development"; const isProd = nodeEnv === "production"; if (isProd) { this.logger.error("Failed to initialize Salesforce connection"); @@ -63,15 +63,19 @@ export class SalesforceService implements OnModuleInit { return this.accountService.findByCustomerNumber(customerNumber); } - async findAccountWithDetailsByCustomerNumber( - customerNumber: string - ): Promise<{ id: string; WH_Account__c?: string | null; Name?: string | null } | null> { + async findAccountWithDetailsByCustomerNumber(customerNumber: string): Promise<{ + id: string; + WH_Account__c?: string | null | undefined; + Name?: string | null | undefined; + } | null> { return this.accountService.findWithDetailsByCustomerNumber(customerNumber); } - async getAccountDetails( - accountId: string - ): Promise<{ id: string; WH_Account__c?: string | null; Name?: string | null } | null> { + async getAccountDetails(accountId: string): Promise<{ + id: string; + WH_Account__c?: string | null | undefined; + Name?: string | null | undefined; + } | null> { return this.accountService.getAccountDetails(accountId); } diff --git a/apps/bff/src/integrations/salesforce/services/opportunity-resolution.service.ts b/apps/bff/src/integrations/salesforce/services/opportunity-resolution.service.ts index 56dbdd82..780de186 100644 --- a/apps/bff/src/integrations/salesforce/services/opportunity-resolution.service.ts +++ b/apps/bff/src/integrations/salesforce/services/opportunity-resolution.service.ts @@ -82,7 +82,7 @@ export class OpportunityResolutionService { async resolveForOrderPlacement(params: { accountId: string | null; orderType: OrderTypeValue; - existingOpportunityId?: string; + existingOpportunityId?: string | undefined; }): Promise { if (!params.accountId) return null; diff --git a/apps/bff/src/integrations/salesforce/services/opportunity/opportunity-mutation.service.ts b/apps/bff/src/integrations/salesforce/services/opportunity/opportunity-mutation.service.ts index 133fccb5..a7074393 100644 --- a/apps/bff/src/integrations/salesforce/services/opportunity/opportunity-mutation.service.ts +++ b/apps/bff/src/integrations/salesforce/services/opportunity/opportunity-mutation.service.ts @@ -110,9 +110,9 @@ export class OpportunityMutationService { if (error && typeof error === "object") { const err = error as Record; - if (err.errorCode) errorDetails.errorCode = err.errorCode; - if (err.fields) errorDetails.fields = err.fields; - if (err.message) errorDetails.rawMessage = err.message; + if (err["errorCode"]) errorDetails["errorCode"] = err["errorCode"]; + if (err["fields"]) errorDetails["fields"] = err["fields"]; + if (err["message"]) errorDetails["rawMessage"] = err["message"]; } this.logger.error(errorDetails, "Failed to create Opportunity"); @@ -246,7 +246,7 @@ export class OpportunityMutationService { // ========================================================================== private calculateCloseDate( - productType: OpportunityProductTypeValue, + _productType: OpportunityProductTypeValue, stage: OpportunityStageValue ): string { const today = new Date(); diff --git a/apps/bff/src/integrations/salesforce/services/opportunity/opportunity-query.service.ts b/apps/bff/src/integrations/salesforce/services/opportunity/opportunity-query.service.ts index 6e09cef5..a6674d9c 100644 --- a/apps/bff/src/integrations/salesforce/services/opportunity/opportunity-query.service.ts +++ b/apps/bff/src/integrations/salesforce/services/opportunity/opportunity-query.service.ts @@ -39,15 +39,15 @@ export interface InternetCancellationStatusResult { stage: OpportunityStageValue; isPending: boolean; isComplete: boolean; - scheduledEndDate?: string; - rentalReturnStatus?: LineReturnStatusValue; + scheduledEndDate?: string | undefined; + rentalReturnStatus?: LineReturnStatusValue | undefined; } export interface SimCancellationStatusResult { stage: OpportunityStageValue; isPending: boolean; isComplete: boolean; - scheduledEndDate?: string; + scheduledEndDate?: string | undefined; } @Injectable() @@ -125,8 +125,8 @@ export class OpportunityQueryService { if (error && typeof error === "object") { const err = error as Record; - if (err.errorCode) errorDetails.errorCode = err.errorCode; - if (err.message) errorDetails.rawMessage = err.message; + if (err["errorCode"]) errorDetails["errorCode"] = err["errorCode"]; + if (err["message"]) errorDetails["rawMessage"] = err["message"]; } this.logger.error(errorDetails, "Failed to find open Opportunity"); 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 5675b6c7..9c970007 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts @@ -40,7 +40,7 @@ export class SalesforceAccountService { private readonly portalSourceField: string; private readonly portalLastSignedInField: string; private readonly whmcsAccountField: string; - private readonly personAccountRecordTypeId?: string; + private readonly personAccountRecordTypeId?: string | undefined; /** * Find Salesforce account by customer number (SF_Account_No__c field) @@ -65,8 +65,8 @@ export class SalesforceAccountService { async findWithDetailsByCustomerNumber(customerNumber: string): Promise<{ id: string; - Name?: string | null; - WH_Account__c?: string | null; + Name?: string | null | undefined; + WH_Account__c?: string | null | undefined; } | null> { const validCustomerNumber = customerNumberSchema.parse(customerNumber); @@ -98,9 +98,11 @@ export class SalesforceAccountService { * Get account details including WH_Account__c field * Used in signup workflow to check if account is already linked to WHMCS */ - async getAccountDetails( - accountId: string - ): Promise<{ id: string; WH_Account__c?: string | null; Name?: string | null } | null> { + async getAccountDetails(accountId: string): Promise<{ + id: string; + WH_Account__c?: string | null | undefined; + Name?: string | null | undefined; + } | null> { const validAccountId = salesforceIdSchema.parse(accountId); try { @@ -196,9 +198,9 @@ export class SalesforceAccountService { this.logger.debug("Person Account creation payload", { recordTypeId: personAccountRecordTypeId, - hasFirstName: Boolean(accountPayload.FirstName), - hasLastName: Boolean(accountPayload.LastName), - hasPersonEmail: Boolean(accountPayload.PersonEmail), + hasFirstName: Boolean(accountPayload["FirstName"]), + hasLastName: Boolean(accountPayload["LastName"]), + hasPersonEmail: Boolean(accountPayload["PersonEmail"]), }); try { @@ -249,18 +251,18 @@ export class SalesforceAccountService { if (error && typeof error === "object") { const err = error as Record; // jsforce errors often have these properties - if (err.errorCode) errorDetails.errorCode = err.errorCode; - if (err.fields) errorDetails.fields = err.fields; - if (err.message) errorDetails.rawMessage = err.message; + if (err["errorCode"]) errorDetails["errorCode"] = err["errorCode"]; + if (err["fields"]) errorDetails["fields"] = err["fields"]; + if (err["message"]) errorDetails["rawMessage"] = err["message"]; // Check for nested error objects - if (err.error) errorDetails.nestedError = err.error; + if (err["error"]) errorDetails["nestedError"] = err["error"]; // Log all enumerable properties in development - if (process.env.NODE_ENV !== "production") { - errorDetails.allProperties = Object.keys(err); + if (process.env["NODE_ENV"] !== "production") { + errorDetails["allProperties"] = Object.keys(err); try { - errorDetails.fullError = JSON.stringify(error, null, 2); + errorDetails["fullError"] = JSON.stringify(error, null, 2); } catch { - errorDetails.fullError = errorMessage; + errorDetails["fullError"] = errorMessage; } } } @@ -298,10 +300,14 @@ export class SalesforceAccountService { ); } - const recordTypeId = recordTypeQuery.records[0].Id; + const record = recordTypeQuery.records[0]; + if (!record) { + throw new Error("Person Account RecordType record not found"); + } + const recordTypeId = record.Id; this.logger.debug("Found Person Account RecordType", { recordTypeId, - name: recordTypeQuery.records[0].Name, + name: record.Name, }); return recordTypeId; } catch (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 8441596a..cc5d7293 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts @@ -19,19 +19,15 @@ import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { CASE_FIELDS } from "../constants/field-maps.js"; import type { SalesforceResponse } from "@customer-portal/domain/common/providers"; import type { SupportCase, CaseMessageList } from "@customer-portal/domain/support"; -import type { - SalesforceCaseRecord, - SalesforceEmailMessage, - SalesforceCaseComment, -} from "@customer-portal/domain/support/providers"; import { + type SalesforceCaseRecord, + type SalesforceEmailMessage, + type SalesforceCaseComment, + type SalesforceCaseOrigin, SALESFORCE_CASE_ORIGIN, SALESFORCE_CASE_STATUS, SALESFORCE_CASE_PRIORITY, toSalesforcePriority, - type SalesforceCaseOrigin, -} from "@customer-portal/domain/support/providers"; -import { buildCaseByIdQuery, buildCaseSelectFields, buildCasesForAccountQuery, @@ -65,11 +61,11 @@ export interface CreateCaseParams { /** Case origin - determines visibility and routing */ origin: SalesforceCaseOrigin; /** Priority (defaults to Medium) */ - priority?: string; + priority?: string | undefined; /** Optional Salesforce Contact ID */ - contactId?: string; + contactId?: string | undefined; /** Optional Opportunity ID for workflow cases */ - opportunityId?: string; + opportunityId?: string | undefined; } /** @@ -80,9 +76,9 @@ export interface CreateWebCaseParams { description: string; suppliedEmail: string; suppliedName: string; - suppliedPhone?: string; - origin?: string; - priority?: string; + suppliedPhone?: string | undefined; + origin?: string | undefined; + priority?: string | undefined; } @Injectable() diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-connection.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-connection.service.ts index 9d3f1555..664e30db 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-connection.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-connection.service.ts @@ -60,8 +60,8 @@ function normalizePrivateKeyInput( } export interface SalesforceSObjectApi { - create: (data: Record) => Promise<{ id?: string }>; - update?: (data: Record & { Id: string }) => Promise; + create: (data: Record) => Promise<{ id?: string | undefined }>; + update?: ((data: Record & { Id: string }) => Promise) | undefined; } @Injectable() @@ -114,7 +114,7 @@ export class SalesforceConnection { private async performConnect(): Promise { const nodeEnv = - this.configService.get("NODE_ENV") || process.env.NODE_ENV || "development"; + this.configService.get("NODE_ENV") || process.env["NODE_ENV"] || "development"; const isProd = nodeEnv === "production"; const authTimeout = this.configService.get("SF_AUTH_TIMEOUT_MS", 30000); const startTime = Date.now(); diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts index 978aae76..cc5dabf7 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts @@ -21,11 +21,14 @@ import { type OpportunityStageValue, type OpportunityProductTypeValue, type CancellationOpportunityData, - type LineReturnStatusValue, type CreateOpportunityRequest, type OpportunityRecord, } from "@customer-portal/domain/opportunity"; -import { OpportunityQueryService } from "./opportunity/opportunity-query.service.js"; +import { + OpportunityQueryService, + type InternetCancellationStatusResult, + type SimCancellationStatusResult, +} from "./opportunity/opportunity-query.service.js"; import { OpportunityCancellationService } from "./opportunity/opportunity-cancellation.service.js"; import { OpportunityMutationService } from "./opportunity/opportunity-mutation.service.js"; import type { @@ -147,63 +150,45 @@ export class SalesforceOpportunityService { /** * Get Internet cancellation status by WHMCS Service ID */ - async getInternetCancellationStatus(whmcsServiceId: number): Promise<{ - stage: OpportunityStageValue; - isPending: boolean; - isComplete: boolean; - scheduledEndDate?: string; - rentalReturnStatus?: LineReturnStatusValue; - } | null> { + async getInternetCancellationStatus( + whmcsServiceId: number + ): Promise { return this.queryService.getInternetCancellationStatus(whmcsServiceId); } /** * Get Internet cancellation status by Opportunity ID */ - async getInternetCancellationStatusByOpportunityId(opportunityId: string): Promise<{ - stage: OpportunityStageValue; - isPending: boolean; - isComplete: boolean; - scheduledEndDate?: string; - rentalReturnStatus?: LineReturnStatusValue; - } | null> { + async getInternetCancellationStatusByOpportunityId( + opportunityId: string + ): Promise { return this.queryService.getInternetCancellationStatusByOpportunityId(opportunityId); } /** * Get SIM cancellation status by WHMCS Service ID */ - async getSimCancellationStatus(whmcsServiceId: number): Promise<{ - stage: OpportunityStageValue; - isPending: boolean; - isComplete: boolean; - scheduledEndDate?: string; - } | null> { + async getSimCancellationStatus( + whmcsServiceId: number + ): Promise { return this.queryService.getSimCancellationStatus(whmcsServiceId); } /** * Get SIM cancellation status by Opportunity ID */ - async getSimCancellationStatusByOpportunityId(opportunityId: string): Promise<{ - stage: OpportunityStageValue; - isPending: boolean; - isComplete: boolean; - scheduledEndDate?: string; - } | null> { + async getSimCancellationStatusByOpportunityId( + opportunityId: string + ): Promise { return this.queryService.getSimCancellationStatusByOpportunityId(opportunityId); } /** * @deprecated Use getInternetCancellationStatus or getSimCancellationStatus */ - async getCancellationStatus(whmcsServiceId: number): Promise<{ - stage: OpportunityStageValue; - isPending: boolean; - isComplete: boolean; - scheduledEndDate?: string; - rentalReturnStatus?: LineReturnStatusValue; - } | null> { + async getCancellationStatus( + whmcsServiceId: number + ): Promise { return this.queryService.getInternetCancellationStatus(whmcsServiceId); } diff --git a/apps/bff/src/integrations/sftp/sftp-client.service.ts b/apps/bff/src/integrations/sftp/sftp-client.service.ts index eb068e00..384d6825 100644 --- a/apps/bff/src/integrations/sftp/sftp-client.service.ts +++ b/apps/bff/src/integrations/sftp/sftp-client.service.ts @@ -16,7 +16,7 @@ export interface SftpConfig { * The value is compared against ssh2's `hostHash: "sha256"` output. * If you paste an OpenSSH-style fingerprint like "SHA256:xxxx", the "SHA256:" prefix is ignored. */ - hostKeySha256?: string; + hostKeySha256?: string | undefined; } @Injectable() diff --git a/apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts b/apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts index 08846246..19ac3ccd 100644 --- a/apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts +++ b/apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts @@ -248,14 +248,14 @@ export class WhmcsCacheService { async invalidateUserCache(userId: string): Promise { try { const patterns = [ - `${this.cacheConfigs.invoices.prefix}:${userId}:*`, - `${this.cacheConfigs.invoice.prefix}:${userId}:*`, - `${this.cacheConfigs.subscriptions.prefix}:${userId}:*`, - `${this.cacheConfigs.subscription.prefix}:${userId}:*`, - `${this.cacheConfigs.subscriptionInvoicesAll.prefix}:${userId}:*`, + `${this.cacheConfigs["invoices"]?.prefix}:${userId}:*`, + `${this.cacheConfigs["invoice"]?.prefix}:${userId}:*`, + `${this.cacheConfigs["subscriptions"]?.prefix}:${userId}:*`, + `${this.cacheConfigs["subscription"]?.prefix}:${userId}:*`, + `${this.cacheConfigs["subscriptionInvoicesAll"]?.prefix}:${userId}:*`, ]; - await Promise.all(patterns.map(pattern => this.cacheService.delPattern(pattern))); + await Promise.all(patterns.map(async pattern => this.cacheService.delPattern(pattern))); this.logger.log(`Invalidated all cache for user ${userId}`); } catch (error) { @@ -292,7 +292,7 @@ export class WhmcsCacheService { .filter(config => config.tags.includes(tag)) .map(config => `${config.prefix}:*`); - await Promise.all(patterns.map(pattern => this.cacheService.delPattern(pattern))); + await Promise.all(patterns.map(async pattern => this.cacheService.delPattern(pattern))); this.logger.log(`Invalidated cache by tag: ${tag}`); } catch (error) { @@ -308,8 +308,8 @@ export class WhmcsCacheService { async invalidateInvoice(userId: string, invoiceId: number): Promise { try { const specificKey = this.buildInvoiceKey(userId, invoiceId); - const listPattern = `${this.cacheConfigs.invoices.prefix}:${userId}:*`; - const subscriptionInvoicesPattern = `${this.cacheConfigs.subscriptionInvoicesAll.prefix}:${userId}:*`; + const listPattern = `${this.cacheConfigs["invoices"]?.prefix}:${userId}:*`; + const subscriptionInvoicesPattern = `${this.cacheConfigs["subscriptionInvoicesAll"]?.prefix}:${userId}:*`; await Promise.all([ this.cacheService.del(specificKey), @@ -432,6 +432,10 @@ export class WhmcsCacheService { private async set(key: string, data: T, configKey: string): Promise { try { const config = this.cacheConfigs[configKey]; + if (!config) { + this.logger.warn(`Cache config not found for key ${configKey}`); + return; + } await this.cacheService.set(key, data, config.ttl); this.logger.debug(`Cache set: ${key} (TTL: ${config.ttl}s)`); } catch (error) { @@ -443,28 +447,28 @@ export class WhmcsCacheService { * Build cache key for invoices list */ private buildInvoicesKey(userId: string, page: number, limit: number, status?: string): string { - return `${this.cacheConfigs.invoices.prefix}:${userId}:${page}:${limit}:${status || "all"}`; + return `${this.cacheConfigs["invoices"]?.prefix}:${userId}:${page}:${limit}:${status || "all"}`; } /** * Build cache key for individual invoice */ private buildInvoiceKey(userId: string, invoiceId: number): string { - return `${this.cacheConfigs.invoice.prefix}:${userId}:${invoiceId}`; + return `${this.cacheConfigs["invoice"]?.prefix}:${userId}:${invoiceId}`; } /** * Build cache key for subscriptions list */ private buildSubscriptionsKey(userId: string): string { - return `${this.cacheConfigs.subscriptions.prefix}:${userId}`; + return `${this.cacheConfigs["subscriptions"]?.prefix}:${userId}`; } /** * Build cache key for individual subscription */ private buildSubscriptionKey(userId: string, subscriptionId: number): string { - return `${this.cacheConfigs.subscription.prefix}:${userId}:${subscriptionId}`; + return `${this.cacheConfigs["subscription"]?.prefix}:${userId}:${subscriptionId}`; } /** @@ -476,35 +480,35 @@ export class WhmcsCacheService { page: number, limit: number ): string { - return `${this.cacheConfigs.subscriptionInvoices.prefix}:${userId}:${subscriptionId}:${page}:${limit}`; + return `${this.cacheConfigs["subscriptionInvoices"]?.prefix}:${userId}:${subscriptionId}:${page}:${limit}`; } /** * Build cache key for full subscription invoices list */ private buildSubscriptionInvoicesAllKey(userId: string, subscriptionId: number): string { - return `${this.cacheConfigs.subscriptionInvoicesAll.prefix}:${userId}:${subscriptionId}`; + return `${this.cacheConfigs["subscriptionInvoicesAll"]?.prefix}:${userId}:${subscriptionId}`; } /** * Build cache key for client data */ private buildClientKey(clientId: number): string { - return `${this.cacheConfigs.client.prefix}:${clientId}`; + return `${this.cacheConfigs["client"]?.prefix}:${clientId}`; } /** * Build cache key for client email mapping */ private buildClientEmailKey(email: string): string { - return `${this.cacheConfigs.clientEmail.prefix}:${email.toLowerCase()}`; + return `${this.cacheConfigs["clientEmail"]?.prefix}:${email.toLowerCase()}`; } /** * Build cache key for payment methods */ private buildPaymentMethodsKey(userId: string): string { - return `${this.cacheConfigs.paymentMethods.prefix}:${userId}`; + return `${this.cacheConfigs["paymentMethods"]?.prefix}:${userId}`; } /** @@ -528,7 +532,7 @@ export class WhmcsCacheService { async clearAllCache(): Promise { try { const patterns = Object.values(this.cacheConfigs).map(config => `${config.prefix}:*`); - await Promise.all(patterns.map(pattern => this.cacheService.delPattern(pattern))); + await Promise.all(patterns.map(async pattern => this.cacheService.delPattern(pattern))); this.logger.warn("Cleared all WHMCS cache"); } catch (error) { this.logger.error("Failed to clear all WHMCS cache", { error: extractErrorMessage(error) }); diff --git a/apps/bff/src/integrations/whmcs/connection/config/whmcs-config.service.ts b/apps/bff/src/integrations/whmcs/connection/config/whmcs-config.service.ts index d75b1025..817d56cc 100644 --- a/apps/bff/src/integrations/whmcs/connection/config/whmcs-config.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/config/whmcs-config.service.ts @@ -93,7 +93,7 @@ export class WhmcsConfigService { const value = this.configService.get(key); if (!value) return defaultValue; - const parsed = parseInt(value, 10); - return isNaN(parsed) ? defaultValue : parsed; + const parsed = Number.parseInt(value, 10); + return Number.isNaN(parsed) ? defaultValue : parsed; } } diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts index be6d7a00..915aabb9 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts @@ -26,8 +26,8 @@ import type { WhmcsUpdateInvoiceResponse, WhmcsCapturePaymentResponse, } from "@customer-portal/domain/billing/providers"; -import type { WhmcsGetPayMethodsParams } from "@customer-portal/domain/payments/providers"; import type { + WhmcsGetPayMethodsParams, WhmcsPaymentMethodListResponse, WhmcsPaymentGatewayListResponse, } from "@customer-portal/domain/payments/providers"; diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts index 9981fb32..4c1d6ce0 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts @@ -128,7 +128,7 @@ export class WhmcsHttpClientService { if (!response.ok) { // Do NOT include response body in thrown error messages (could contain sensitive/PII and // would propagate into unified exception logs). If needed, emit a short snippet only in dev. - if (process.env.NODE_ENV !== "production") { + if (process.env["NODE_ENV"] !== "production") { const snippet = responseText?.slice(0, 300); if (snippet) { this.logger.debug(`WHMCS non-OK response body snippet [${action}]`, { @@ -182,9 +182,9 @@ export class WhmcsHttpClientService { private appendFormParam(formData: URLSearchParams, key: string, value: unknown): void { if (Array.isArray(value)) { - value.forEach((entry, index) => { + for (const [index, entry] of value.entries()) { this.appendFormParam(formData, `${key}[${index}]`, entry); - }); + } return; } @@ -234,11 +234,11 @@ export class WhmcsHttpClientService { try { parsedResponse = JSON.parse(responseText); } catch (parseError) { - const isProd = process.env.NODE_ENV === "production"; + const isProd = process.env["NODE_ENV"] === "production"; this.logger.error(`Invalid JSON response from WHMCS API [${action}]`, { ...(isProd ? { responseTextLength: responseText.length } - : { responseText: responseText.substring(0, 500) }), + : { responseText: responseText.slice(0, 500) }), parseError: extractErrorMessage(parseError), params: redactForLogs(params), }); @@ -247,12 +247,12 @@ export class WhmcsHttpClientService { // Validate basic response structure if (!this.isWhmcsResponse(parsedResponse)) { - const isProd = process.env.NODE_ENV === "production"; + const isProd = process.env["NODE_ENV"] === "production"; this.logger.error(`WHMCS API returned invalid response structure [${action}]`, { responseType: typeof parsedResponse, ...(isProd ? { responseTextLength: responseText.length } - : { responseText: responseText.substring(0, 500) }), + : { responseText: responseText.slice(0, 500) }), params: redactForLogs(params), }); throw new Error("Invalid response structure from WHMCS API"); @@ -308,7 +308,7 @@ export class WhmcsHttpClientService { } const record = value as Record; - const rawResult = record.result; + const rawResult = record["result"]; return rawResult === "success" || rawResult === "error"; } diff --git a/apps/bff/src/integrations/whmcs/connection/types/connection.types.ts b/apps/bff/src/integrations/whmcs/connection/types/connection.types.ts index a91eadfe..80ac3efd 100644 --- a/apps/bff/src/integrations/whmcs/connection/types/connection.types.ts +++ b/apps/bff/src/integrations/whmcs/connection/types/connection.types.ts @@ -2,19 +2,19 @@ export interface WhmcsApiConfig { baseUrl: string; identifier: string; secret: string; - timeout?: number; - retryAttempts?: number; - retryDelay?: number; + timeout?: number | undefined; + retryAttempts?: number | undefined; + retryDelay?: number | undefined; } export interface WhmcsRequestOptions { - timeout?: number; - retryAttempts?: number; - retryDelay?: number; + timeout?: number | undefined; + retryAttempts?: number | undefined; + retryDelay?: number | undefined; /** * If true, the request will jump the queue and execute immediately */ - highPriority?: boolean; + highPriority?: boolean | undefined; } export interface WhmcsRetryConfig { @@ -29,6 +29,6 @@ export interface WhmcsConnectionStats { successfulRequests: number; failedRequests: number; averageResponseTime: number; - lastRequestTime?: Date; - lastErrorTime?: Date; + lastRequestTime?: Date | undefined; + lastErrorTime?: Date | undefined; } diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-currency.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-currency.service.ts index d0e4d338..86a8f5ab 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-currency.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-currency.service.ts @@ -114,13 +114,14 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy { const response = (await this.connectionService.getCurrencies()) as WhmcsCurrenciesResponse; // Check if response has currencies data (success case) or error fields - if (response.result === "success" || (response.currencies && !response.error)) { + if (response.result === "success" || (response.currencies && !response["error"])) { // Parse the WHMCS response format into currency objects this.currencies = this.parseWhmcsCurrenciesResponse(response); if (this.currencies.length > 0) { // Set first currency as default (WHMCS typically returns the primary currency first) - this.defaultCurrency = this.currencies[0]; + const firstCurrency = this.currencies[0]; + this.defaultCurrency = firstCurrency ?? null; this.logger.log(`Loaded ${this.currencies.length} currencies from WHMCS`, { defaultCurrency: this.defaultCurrency?.code, @@ -134,13 +135,13 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy { } else { this.logger.error("WHMCS GetCurrencies returned error", { result: response?.result, - message: response?.message, - error: response?.error, - errorcode: response?.errorcode, + message: response?.["message"], + error: response?.["error"], + errorcode: response?.["errorcode"], fullResponse: JSON.stringify(response, null, 2), }); throw new WhmcsOperationException( - `WHMCS GetCurrencies error: ${response?.message || response?.error || "Unknown error"}`, + `WHMCS GetCurrencies error: ${response?.["message"] || response?.["error"] || "Unknown error"}`, { operation: "getCurrencies" } ); } @@ -171,7 +172,7 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy { for (const currencyData of currencyArray) { const currency: Currency = { - id: parseInt(String(currencyData.id)) || 0, + id: Number.parseInt(String(currencyData.id)) || 0, code: String(currencyData.code || ""), prefix: String(currencyData.prefix || ""), suffix: String(currencyData.suffix || ""), @@ -194,14 +195,15 @@ export class WhmcsCurrencyService implements OnModuleInit, OnModuleDestroy { const currencyIndices = currencyKeys .map(key => { const match = key.match(/currencies\[currency\]\[(\d+)\]\[id\]/); - return match ? parseInt(match[1], 10) : null; + const indexStr = match?.[1]; + return indexStr === undefined ? null : Number.parseInt(indexStr, 10); }) .filter((index): index is number => index !== null); // Build currency objects from the flat response for (const index of currencyIndices) { const currency: Currency = { - id: parseInt(String(response[`currencies[currency][${index}][id]`])) || 0, + id: Number.parseInt(String(response[`currencies[currency][${index}][id]`])) || 0, code: String(response[`currencies[currency][${index}][code]`] || ""), prefix: String(response[`currencies[currency][${index}][prefix]`] || ""), suffix: String(response[`currencies[currency][${index}][suffix]`] || ""), diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts index 379fd93b..6fe80e9d 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts @@ -14,8 +14,6 @@ import type { WhmcsCreateInvoiceParams, WhmcsUpdateInvoiceParams, WhmcsCapturePaymentParams, -} from "@customer-portal/domain/billing/providers"; -import type { WhmcsInvoiceListResponse, WhmcsInvoiceResponse, WhmcsCreateInvoiceResponse, @@ -66,7 +64,7 @@ export class WhmcsInvoiceService { limitnum: limit, orderby: "date", order: "DESC", - ...(status && { status: status as WhmcsGetInvoicesParams["status"] }), + ...(status ? { status: status as WhmcsGetInvoicesParams["status"] } : {}), }; const response: WhmcsInvoiceListResponse = await this.connectionService.getInvoices(params); @@ -118,6 +116,7 @@ export class WhmcsInvoiceService { for (let i = 0; i < batches.length; i++) { const batch = batches[i]; + if (!batch) continue; // Process batch in parallel const batchResults = await Promise.all( @@ -309,10 +308,10 @@ export class WhmcsInvoiceService { status: "Unpaid", sendnotification: false, // Don't send email notification automatically duedate: dueDateStr, - notes: params.notes, itemdescription1: params.description, itemamount1: params.amount, itemtaxed1: false, // No tax for data top-ups for now + ...(params.notes === undefined ? {} : { notes: params.notes }), }; const response: WhmcsCreateInvoiceResponse = @@ -372,9 +371,11 @@ export class WhmcsInvoiceService { const whmcsParams: WhmcsUpdateInvoiceParams = { invoiceid: params.invoiceId, - status: statusForUpdate, - duedate: params.dueDate ? params.dueDate.toISOString().split("T")[0] : undefined, - notes: params.notes, + ...(statusForUpdate === undefined ? {} : { status: statusForUpdate }), + ...(params.dueDate === undefined + ? {} + : { duedate: params.dueDate.toISOString().split("T")[0] }), + ...(params.notes === undefined ? {} : { notes: params.notes }), }; const response: WhmcsUpdateInvoiceResponse = @@ -394,7 +395,7 @@ export class WhmcsInvoiceService { return { success: true, - message: response.message, + ...(response.message === undefined ? {} : { message: response.message }), }; } catch (error) { this.logger.error(`Failed to update invoice ${params.invoiceId}`, { @@ -436,7 +437,9 @@ export class WhmcsInvoiceService { return { success: true, - transactionId: response.transactionid, + ...(response.transactionid === undefined + ? {} + : { transactionId: response.transactionid }), }; } else { this.logger.warn(`Payment capture failed for invoice ${params.invoiceId}`, { diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts index b6874eb1..d6505e9b 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts @@ -10,8 +10,8 @@ import type { WhmcsAddOrderResponse, WhmcsOrderResult, } from "@customer-portal/domain/orders/providers"; -import { buildWhmcsAddOrderPayload } from "@customer-portal/domain/orders/providers"; import { + buildWhmcsAddOrderPayload, whmcsAddOrderResponseSchema, whmcsAcceptOrderResponseSchema, } from "@customer-portal/domain/orders/providers"; @@ -47,14 +47,16 @@ export class WhmcsOrderService { this.logger.debug("Built WHMCS AddOrder payload", { clientId: params.clientId, - productCount: Array.isArray(addOrderPayload.pid) ? addOrderPayload.pid.length : 0, - pids: addOrderPayload.pid, - quantities: addOrderPayload.qty, // CRITICAL: Must be included for products to be added - billingCycles: addOrderPayload.billingcycle, - hasConfigOptions: Boolean(addOrderPayload.configoptions), - hasCustomFields: Boolean(addOrderPayload.customfields), - promoCode: addOrderPayload.promocode, - paymentMethod: addOrderPayload.paymentmethod, + productCount: Array.isArray(addOrderPayload["pid"]) + ? (addOrderPayload["pid"] as unknown[]).length + : 0, + pids: addOrderPayload["pid"], + quantities: addOrderPayload["qty"], // CRITICAL: Must be included for products to be added + billingCycles: addOrderPayload["billingcycle"], + hasConfigOptions: Boolean(addOrderPayload["configoptions"]), + hasCustomFields: Boolean(addOrderPayload["customfields"]), + promoCode: addOrderPayload["promocode"], + paymentMethod: addOrderPayload["paymentmethod"], }); // Call WHMCS AddOrder API @@ -104,7 +106,7 @@ export class WhmcsOrderService { sfOrderId: params.sfOrderId, itemCount: params.items.length, // Include first 100 chars of error stack for debugging - errorStack: error instanceof Error ? error.stack?.substring(0, 100) : undefined, + errorStack: error instanceof Error ? error.stack?.slice(0, 100) : undefined, }); throw error; } @@ -163,7 +165,7 @@ export class WhmcsOrderService { orderId, sfOrderId, // Include first 100 chars of error stack for debugging - errorStack: error instanceof Error ? error.stack?.substring(0, 100) : undefined, + errorStack: error instanceof Error ? error.stack?.slice(0, 100) : undefined, }); throw error; } @@ -179,7 +181,8 @@ export class WhmcsOrderService { id: orderId.toString(), })) as Record; - return (response.orders as { order?: Record[] })?.order?.[0] || null; + const orders = response["orders"] as { order?: Record[] } | undefined; + return orders?.order?.[0] ?? null; } catch (error) { this.logger.error("Failed to get WHMCS order details", { error: extractErrorMessage(error), @@ -231,17 +234,17 @@ export class WhmcsOrderService { this.logger.debug("Built WHMCS AddOrder payload", { clientId: params.clientId, productCount: params.items.length, - pids: payload.pid, - billingCycles: payload.billingcycle, - hasConfigOptions: !!payload.configoptions, - hasCustomFields: !!payload.customfields, + pids: payload["pid"], + billingCycles: payload["billingcycle"], + hasConfigOptions: !!payload["configoptions"], + hasCustomFields: !!payload["customfields"], }); return payload as Record; } private toWhmcsOrderResult(response: WhmcsAddOrderResponse): WhmcsOrderResult { - const orderId = parseInt(String(response.orderid), 10); + const orderId = Number.parseInt(String(response.orderid), 10); if (!orderId || Number.isNaN(orderId)) { throw new WhmcsOperationException("WHMCS AddOrder did not return valid order ID", { response, @@ -250,7 +253,7 @@ export class WhmcsOrderService { return { orderId, - invoiceId: response.invoiceid ? parseInt(String(response.invoiceid), 10) : undefined, + invoiceId: response.invoiceid ? Number.parseInt(String(response.invoiceid), 10) : undefined, serviceIds: this.parseDelimitedIds(response.serviceids), addonIds: this.parseDelimitedIds(response.addonids), domainIds: this.parseDelimitedIds(response.domainids), @@ -264,7 +267,7 @@ export class WhmcsOrderService { return value .toString() .split(",") - .map(entry => parseInt(entry.trim(), 10)) + .map(entry => Number.parseInt(entry.trim(), 10)) .filter(id => !Number.isNaN(id)); } } diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts index 6bfd34a7..9ae64500 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts @@ -16,8 +16,8 @@ import { transformWhmcsCatalogProductsResponse } from "@customer-portal/domain/s import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service.js"; import { WhmcsCacheService } from "../cache/whmcs-cache.service.js"; import type { WhmcsCreateSsoTokenParams } from "@customer-portal/domain/customer/providers"; -import type { WhmcsGetPayMethodsParams } from "@customer-portal/domain/payments/providers"; import type { + WhmcsGetPayMethodsParams, WhmcsPaymentMethod, WhmcsPaymentMethodListResponse, WhmcsPaymentGateway, @@ -270,7 +270,7 @@ export class WhmcsPaymentService { * Debug helper: log only the host of the SSO URL (never the token) in non-production. */ private debugLogRedirectHost(url: string): void { - if (process.env.NODE_ENV === "production") return; + if (process.env["NODE_ENV"] === "production") return; try { const target = new URL(url); const base = new URL(this.connectionService.getBaseUrl()); diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-sso.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-sso.service.ts index 7683ddb7..7e126bd5 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-sso.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-sso.service.ts @@ -207,7 +207,7 @@ export class WhmcsSsoService { * Debug helper: log only the host of the SSO URL (never the token) in non-production. */ private debugLogRedirectHost(url: string): void { - if (process.env.NODE_ENV === "production") return; + if (process.env["NODE_ENV"] === "production") return; try { const target = new URL(url); const base = new URL(this.connectionService.getBaseUrl()); diff --git a/apps/bff/src/main.ts b/apps/bff/src/main.ts index d2d0ae4b..f14a436d 100644 --- a/apps/bff/src/main.ts +++ b/apps/bff/src/main.ts @@ -15,7 +15,6 @@ for (const signal of signals) { if (!app) { logger.warn("Nest application not initialized. Exiting immediately."); process.exit(0); - return; } try { diff --git a/apps/bff/src/modules/auth/application/auth.facade.ts b/apps/bff/src/modules/auth/application/auth.facade.ts index 7a8e8bbe..052a2a1a 100644 --- a/apps/bff/src/modules/auth/application/auth.facade.ts +++ b/apps/bff/src/modules/auth/application/auth.facade.ts @@ -109,15 +109,14 @@ export class AuthFacade { const profile = mapPrismaUserToDomain(prismaUser); + const userAgent = request?.headers?.["user-agent"]; const tokens = await this.tokenService.generateTokenPair( { id: profile.id, email: profile.email, role: prismaUser.role || "USER", }, - { - userAgent: request?.headers["user-agent"], - } + userAgent ? { userAgent } : {} ); await this.updateAccountLastSignIn(user.id); @@ -290,7 +289,7 @@ export class AuthFacade { async refreshTokens( refreshToken: string | undefined, - deviceInfo?: { deviceId?: string; userAgent?: string } + deviceInfo?: { deviceId?: string | undefined; userAgent?: string | undefined } ) { if (!refreshToken) { throw new UnauthorizedException("Invalid refresh token"); diff --git a/apps/bff/src/modules/auth/auth.types.ts b/apps/bff/src/modules/auth/auth.types.ts index 4b862ec4..13e03e0c 100644 --- a/apps/bff/src/modules/auth/auth.types.ts +++ b/apps/bff/src/modules/auth/auth.types.ts @@ -1,7 +1,6 @@ -import type { User } from "@customer-portal/domain/customer"; +import type { User, UserAuth } from "@customer-portal/domain/customer"; import type { Request } from "express"; import type { AuthTokens } from "@customer-portal/domain/auth"; -import type { UserAuth } from "@customer-portal/domain/customer"; export type RequestWithUser = Request & { user: User }; diff --git a/apps/bff/src/modules/auth/infra/otp/get-started-session.service.ts b/apps/bff/src/modules/auth/infra/otp/get-started-session.service.ts index 50dec56c..a6cdefe7 100644 --- a/apps/bff/src/modules/auth/infra/otp/get-started-session.service.ts +++ b/apps/bff/src/modules/auth/infra/otp/get-started-session.service.ts @@ -11,6 +11,7 @@ import type { } from "@customer-portal/domain/get-started"; import { CacheService } from "@/infra/cache/cache.service.js"; +import { DistributedLockService } from "@/infra/cache/distributed-lock.service.js"; /** * Session data stored in Redis (internal representation) @@ -18,6 +19,10 @@ import { CacheService } from "@/infra/cache/cache.service.js"; interface SessionData extends Omit { /** Session ID (for lookup) */ id: string; + /** Timestamp when session was marked as used (for one-time operations) */ + usedAt?: string; + /** The operation that used this session */ + usedFor?: "guest_eligibility" | "signup_with_eligibility" | "complete_account"; } /** @@ -36,11 +41,13 @@ interface SessionData extends Omit { export class GetStartedSessionService { private readonly SESSION_PREFIX = "get-started-session:"; private readonly HANDOFF_PREFIX = "guest-handoff:"; + private readonly SESSION_LOCK_PREFIX = "session-lock:"; private readonly ttlSeconds: number; private readonly handoffTtlSeconds = 1800; // 30 minutes for handoff tokens constructor( private readonly cache: CacheService, + private readonly lockService: DistributedLockService, private readonly config: ConfigService, @Inject(Logger) private readonly logger: Logger ) { @@ -277,6 +284,148 @@ export class GetStartedSessionService { this.logger.debug({ tokenId: token }, "Guest handoff token invalidated"); } + // ============================================================================ + // Session Locking (Idempotency Protection) + // ============================================================================ + + /** + * Atomically acquire a lock and mark the session as used for a specific operation. + * + * This prevents race conditions where the same session could be used + * multiple times (e.g., double-clicking "Create Account"). + * + * @param sessionToken - Session token + * @param operation - The operation being performed + * @returns Object with success flag and session data if acquired + */ + async acquireAndMarkAsUsed( + sessionToken: string, + operation: SessionData["usedFor"] + ): Promise<{ success: true; session: GetStartedSession } | { success: false; reason: string }> { + const lockKey = `${this.SESSION_LOCK_PREFIX}${sessionToken}`; + + // Try to acquire lock with no retries (immediate fail if already locked) + const lockResult = await this.lockService.tryWithLock( + lockKey, + async () => { + // Check session state within lock + const sessionData = await this.cache.get(this.buildKey(sessionToken)); + + if (!sessionData) { + return { success: false as const, reason: "Session not found or expired" }; + } + + if (!sessionData.emailVerified) { + return { success: false as const, reason: "Session email not verified" }; + } + + if (sessionData.usedAt) { + this.logger.warn( + { sessionId: sessionToken, usedFor: sessionData.usedFor, usedAt: sessionData.usedAt }, + "Session already used" + ); + return { + success: false as const, + reason: `Session already used for ${sessionData.usedFor}`, + }; + } + + // Mark as used - build object with required fields, then add optional fields + const updatedData = Object.assign( + { + id: sessionData.id, + email: sessionData.email, + emailVerified: sessionData.emailVerified, + createdAt: sessionData.createdAt, + usedAt: new Date().toISOString(), + usedFor: operation, + }, + sessionData.accountStatus ? { accountStatus: sessionData.accountStatus } : {}, + sessionData.firstName ? { firstName: sessionData.firstName } : {}, + sessionData.lastName ? { lastName: sessionData.lastName } : {}, + sessionData.phone ? { phone: sessionData.phone } : {}, + sessionData.address ? { address: sessionData.address } : {}, + sessionData.sfAccountId ? { sfAccountId: sessionData.sfAccountId } : {}, + sessionData.whmcsClientId === undefined + ? {} + : { whmcsClientId: sessionData.whmcsClientId }, + sessionData.eligibilityStatus ? { eligibilityStatus: sessionData.eligibilityStatus } : {} + ) as SessionData; + + const remainingTtl = this.calculateRemainingTtl(sessionData.createdAt); + await this.cache.set(this.buildKey(sessionToken), updatedData, remainingTtl); + + this.logger.debug({ sessionId: sessionToken, operation }, "Session marked as used"); + + return { + success: true as const, + session: { + ...updatedData, + expiresAt: this.calculateExpiresAt(sessionData.createdAt), + }, + }; + }, + { ttlMs: 65_000, maxRetries: 0 } // TTL must exceed workflow lock (60s) - fail fast + ); + + if (!lockResult.success) { + this.logger.warn( + { sessionId: sessionToken }, + "Failed to acquire session lock - operation in progress" + ); + return { success: false, reason: "Operation already in progress" }; + } + + return lockResult.result; + } + + /** + * Check if a session has already been used for an operation + */ + async isSessionUsed(sessionToken: string): Promise { + const sessionData = await this.cache.get(this.buildKey(sessionToken)); + return sessionData?.usedAt != null; + } + + /** + * Clear the "used" status from a session (for recovery after partial failure) + * + * This should only be called when rolling back a failed operation + * to allow the user to retry. + */ + async clearUsedStatus(sessionToken: string): Promise { + const sessionData = await this.cache.get(this.buildKey(sessionToken)); + + if (!sessionData) { + return false; + } + + // Build clean session data without usedAt and usedFor + const cleanSessionData = Object.assign( + { + id: sessionData.id, + email: sessionData.email, + emailVerified: sessionData.emailVerified, + createdAt: sessionData.createdAt, + }, + sessionData.accountStatus ? { accountStatus: sessionData.accountStatus } : {}, + sessionData.firstName ? { firstName: sessionData.firstName } : {}, + sessionData.lastName ? { lastName: sessionData.lastName } : {}, + sessionData.phone ? { phone: sessionData.phone } : {}, + sessionData.address ? { address: sessionData.address } : {}, + sessionData.sfAccountId ? { sfAccountId: sessionData.sfAccountId } : {}, + sessionData.whmcsClientId === undefined ? {} : { whmcsClientId: sessionData.whmcsClientId }, + sessionData.eligibilityStatus ? { eligibilityStatus: sessionData.eligibilityStatus } : {} + ) as SessionData; + + const remainingTtl = this.calculateRemainingTtl(sessionData.createdAt); + await this.cache.set(this.buildKey(sessionToken), cleanSessionData, remainingTtl); + + this.logger.debug({ sessionId: sessionToken }, "Session used status cleared for retry"); + + return true; + } + private buildKey(sessionId: string): string { return `${this.SESSION_PREFIX}${sessionId}`; } diff --git a/apps/bff/src/modules/auth/infra/otp/otp.service.ts b/apps/bff/src/modules/auth/infra/otp/otp.service.ts index a80afb8c..988a36b7 100644 --- a/apps/bff/src/modules/auth/infra/otp/otp.service.ts +++ b/apps/bff/src/modules/auth/infra/otp/otp.service.ts @@ -17,7 +17,7 @@ interface OtpData { /** When the code was created */ createdAt: string; /** Optional fingerprint binding (SHA256 hash of IP + User-Agent) */ - fingerprint?: string; + fingerprint?: string | undefined; } /** diff --git a/apps/bff/src/modules/auth/infra/rate-limiting/auth-rate-limit.service.ts b/apps/bff/src/modules/auth/infra/rate-limiting/auth-rate-limit.service.ts index 6738a6aa..049e64e7 100644 --- a/apps/bff/src/modules/auth/infra/rate-limiting/auth-rate-limit.service.ts +++ b/apps/bff/src/modules/auth/infra/rate-limiting/auth-rate-limit.service.ts @@ -133,7 +133,6 @@ export class AuthRateLimitService { points: limit, duration, inMemoryBlockOnConsumed: limit + 1, - insuranceLimiter: undefined, }); } catch (error: unknown) { this.logger.error( diff --git a/apps/bff/src/modules/auth/infra/token/jose-jwt.service.ts b/apps/bff/src/modules/auth/infra/token/jose-jwt.service.ts index 9019d07d..8521e3af 100644 --- a/apps/bff/src/modules/auth/infra/token/jose-jwt.service.ts +++ b/apps/bff/src/modules/auth/infra/token/jose-jwt.service.ts @@ -7,10 +7,10 @@ import { parseJwtExpiry } from "../../utils/jwt-expiry.util.js"; export class JoseJwtService { private readonly signingKey: Uint8Array; private readonly verificationKeys: Uint8Array[]; - private readonly issuer?: string; - private readonly audience?: string | string[]; + private readonly issuer: string | undefined; + private readonly audience: string | string[] | undefined; - constructor(private readonly configService: ConfigService) { + constructor(configService: ConfigService) { const secret = configService.get("JWT_SECRET"); if (!secret) { throw new Error("JWT_SECRET is required in environment variables"); @@ -83,15 +83,16 @@ export class JoseJwtService { async verify(token: string): Promise { const options = { - algorithms: ["HS256"], - issuer: this.issuer, - audience: this.audience, + algorithms: ["HS256"] as string[], + ...(this.issuer === undefined ? {} : { issuer: this.issuer }), + ...(this.audience === undefined ? {} : { audience: this.audience }), }; let lastError: unknown; for (let i = 0; i < this.verificationKeys.length; i++) { const key = this.verificationKeys[i]; + if (!key) continue; try { const { payload } = await jwtVerify(token, key, options); return payload as T; diff --git a/apps/bff/src/modules/auth/infra/token/token-migration.service.ts b/apps/bff/src/modules/auth/infra/token/token-migration.service.ts index 91b22b91..fdce7746 100644 --- a/apps/bff/src/modules/auth/infra/token/token-migration.service.ts +++ b/apps/bff/src/modules/auth/infra/token/token-migration.service.ts @@ -1,6 +1,5 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { ConfigService } from "@nestjs/config"; import { Redis } from "ioredis"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; @@ -32,8 +31,7 @@ export class TokenMigrationService { constructor( @Inject("REDIS_CLIENT") private readonly redis: Redis, - @Inject(Logger) private readonly logger: Logger, - private readonly configService: ConfigService + @Inject(Logger) private readonly logger: Logger ) {} /** @@ -415,7 +413,7 @@ export class TokenMigrationService { } const record = parsed as Record; - const userId = record.userId; + const userId = record["userId"]; if (typeof userId !== "string" || userId.length === 0) { this.logger.warn("Invalid family structure, skipping", { familyKey }); @@ -448,8 +446,8 @@ export class TokenMigrationService { } const record = parsed as Record; - const userId = record.userId; - const familyId = record.familyId; + const userId = record["userId"]; + const familyId = record["familyId"]; if (typeof userId !== "string" || typeof familyId !== "string") { this.logger.warn("Invalid token structure, skipping", { tokenKey }); diff --git a/apps/bff/src/modules/auth/infra/token/token-revocation.service.ts b/apps/bff/src/modules/auth/infra/token/token-revocation.service.ts index 4c0c53a0..b64a1c5b 100644 --- a/apps/bff/src/modules/auth/infra/token/token-revocation.service.ts +++ b/apps/bff/src/modules/auth/infra/token/token-revocation.service.ts @@ -145,19 +145,22 @@ export class TokenRevocationService { /** * Get all active refresh token families for a user */ - async getUserRefreshTokenFamilies( - userId: string - ): Promise< - Array<{ familyId: string; deviceId?: string; userAgent?: string; createdAt?: string }> + async getUserRefreshTokenFamilies(userId: string): Promise< + Array<{ + familyId: string; + deviceId?: string | undefined; + userAgent?: string | undefined; + createdAt?: string | undefined; + }> > { try { const familyIds = await this.storage.getUserFamilyIds(userId); const families: Array<{ familyId: string; - deviceId?: string; - userAgent?: string; - createdAt?: string; + deviceId?: string | undefined; + userAgent?: string | undefined; + createdAt?: string | undefined; }> = []; for (const familyId of familyIds) { diff --git a/apps/bff/src/modules/auth/infra/token/token-storage.service.ts b/apps/bff/src/modules/auth/infra/token/token-storage.service.ts index dcda16d0..cc590233 100644 --- a/apps/bff/src/modules/auth/infra/token/token-storage.service.ts +++ b/apps/bff/src/modules/auth/infra/token/token-storage.service.ts @@ -11,10 +11,10 @@ export interface StoredRefreshToken { export interface StoredRefreshTokenFamily { userId: string; tokenHash: string; - deviceId?: string; - userAgent?: string; - createdAt?: string; - absoluteExpiresAt?: string; + deviceId?: string | undefined; + userAgent?: string | undefined; + createdAt?: string | undefined; + absoluteExpiresAt?: string | undefined; } /** @@ -41,7 +41,7 @@ export class TokenStorageService { userId: string, familyId: string, refreshTokenHash: string, - deviceInfo?: { deviceId?: string; userAgent?: string }, + deviceInfo?: { deviceId?: string | undefined; userAgent?: string | undefined }, refreshExpirySeconds?: number, absoluteExpiresAt?: string ): Promise { @@ -86,7 +86,7 @@ export class TokenStorageService { const results = await pipeline.exec(); // Check if user has too many tokens - const cardResult = results?.[results.length - 1]; + const cardResult = results?.at(-1); if ( cardResult && Array.isArray(cardResult) && @@ -155,7 +155,7 @@ export class TokenStorageService { familyId: string, userId: string, newTokenHash: string, - deviceInfo: { deviceId?: string; userAgent?: string } | undefined, + deviceInfo: { deviceId?: string | undefined; userAgent?: string | undefined } | undefined, createdAt: string, absoluteExpiresAt: string, ttlSeconds: number diff --git a/apps/bff/src/modules/auth/infra/token/token.service.ts b/apps/bff/src/modules/auth/infra/token/token.service.ts index 869a71cd..88a31a93 100644 --- a/apps/bff/src/modules/auth/infra/token/token.service.ts +++ b/apps/bff/src/modules/auth/infra/token/token.service.ts @@ -23,14 +23,14 @@ export interface RefreshTokenPayload extends JWTPayload { * Refresh token family identifier (stable across rotations). * Present on newly issued tokens; legacy tokens used `tokenId` for this value. */ - familyId?: string; + familyId?: string | undefined; /** * Refresh token identifier (unique per token). Used for replay/reuse detection. * For legacy tokens, this was equal to the family id. */ tokenId: string; - deviceId?: string; - userAgent?: string; + deviceId?: string | undefined; + userAgent?: string | undefined; type: "refresh"; } @@ -201,8 +201,8 @@ export class AuthTokenService { async refreshTokens( refreshToken: string, deviceInfo?: { - deviceId?: string; - userAgent?: string; + deviceId?: string | undefined; + userAgent?: string | undefined; } ): Promise<{ tokens: AuthTokens; user: User }> { if (!refreshToken) { @@ -297,10 +297,10 @@ export class AuthTokenService { if (absoluteExpiresAt) { const absMs = Date.parse(absoluteExpiresAt); - if (!Number.isNaN(absMs)) { - remainingSeconds = Math.max(0, Math.floor((absMs - Date.now()) / 1000)); - } else { + if (Number.isNaN(absMs)) { absoluteExpiresAt = undefined; + } else { + remainingSeconds = Math.max(0, Math.floor((absMs - Date.now()) / 1000)); } } @@ -426,10 +426,13 @@ export class AuthTokenService { /** * Get all active refresh token families for a user */ - async getUserRefreshTokenFamilies( - userId: string - ): Promise< - Array<{ familyId: string; deviceId?: string; userAgent?: string; createdAt?: string }> + async getUserRefreshTokenFamilies(userId: string): Promise< + Array<{ + familyId: string; + deviceId?: string | undefined; + userAgent?: string | undefined; + createdAt?: string | undefined; + }> > { return this.revocation.getUserRefreshTokenFamilies(userId); } @@ -451,7 +454,7 @@ export class AuthTokenService { private parseExpiryToMs(expiry: string): number { const unit = expiry.slice(-1); - const value = parseInt(expiry.slice(0, -1)); + const value = Number.parseInt(expiry.slice(0, -1)); switch (unit) { case "s": diff --git a/apps/bff/src/modules/auth/infra/workflows/get-started-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/get-started-workflow.service.ts index 8e47526e..135ab719 100644 --- a/apps/bff/src/modules/auth/infra/workflows/get-started-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/get-started-workflow.service.ts @@ -3,6 +3,8 @@ import { ConfigService } from "@nestjs/config"; import { Logger } from "nestjs-pino"; import * as argon2 from "argon2"; +import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js"; + import { ACCOUNT_STATUS, type AccountStatus, @@ -17,6 +19,7 @@ import { type CompleteAccountRequest, type MaybeLaterRequest, type MaybeLaterResponse, + type SignupWithEligibilityRequest, } from "@customer-portal/domain/get-started"; import { EmailService } from "@bff/infra/email/email.service.js"; @@ -43,6 +46,15 @@ import { } from "@bff/modules/auth/constants/portal.constants.js"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js"; +/** + * Remove undefined properties from an object (for exactOptionalPropertyTypes compatibility) + */ +function removeUndefined>(obj: T): Partial { + return Object.fromEntries( + Object.entries(obj).filter(([, value]) => value !== undefined) + ) as Partial; +} + /** * Get Started Workflow Service * @@ -70,6 +82,7 @@ export class GetStartedWorkflowService { private readonly whmcsSignup: SignupWhmcsService, private readonly userCreation: SignupUserCreationService, private readonly tokenService: AuthTokenService, + private readonly lockService: DistributedLockService, @Inject(Logger) private readonly logger: Logger ) {} @@ -155,15 +168,23 @@ export class GetStartedWorkflowService { const prefill = this.getPrefillData(normalizedEmail, accountStatus); // Update session with verified status and account info - await this.sessionService.markEmailVerified(sessionToken, accountStatus.status, { - firstName: prefill?.firstName, - lastName: prefill?.lastName, - phone: prefill?.phone, - address: prefill?.address, - sfAccountId: accountStatus.sfAccountId, - whmcsClientId: accountStatus.whmcsClientId, - eligibilityStatus: prefill?.eligibilityStatus, - }); + // Build prefill data object without undefined values (exactOptionalPropertyTypes) + const prefillData = { + ...(prefill?.firstName && { firstName: prefill.firstName }), + ...(prefill?.lastName && { lastName: prefill.lastName }), + ...(prefill?.phone && { phone: prefill.phone }), + ...(prefill?.address && { address: prefill.address }), + ...(accountStatus.sfAccountId && { sfAccountId: accountStatus.sfAccountId }), + ...(accountStatus.whmcsClientId !== undefined && { + whmcsClientId: accountStatus.whmcsClientId, + }), + ...(prefill?.eligibilityStatus && { eligibilityStatus: prefill.eligibilityStatus }), + }; + await this.sessionService.markEmailVerified( + sessionToken, + accountStatus.status, + Object.keys(prefillData).length > 0 ? prefillData : undefined + ); this.logger.log( { email: normalizedEmail, accountStatus: accountStatus.status }, @@ -221,13 +242,13 @@ export class GetStartedWorkflowService { // Create eligibility case const requestId = await this.createEligibilityCase(sfAccountId, address); - // Update session with SF account info + // Update session with SF account info (clean address to remove undefined values) await this.sessionService.updateWithQuickCheckData(request.sessionToken, { firstName, lastName, - address, - phone, - sfAccountId, + address: removeUndefined(address), + ...(phone && { phone }), + ...(sfAccountId && { sfAccountId }), }); return { @@ -286,6 +307,9 @@ export class GetStartedWorkflowService { * Creates SF Account + eligibility case immediately. * Email verification happens later when user creates an account. * + * Security: + * - Email-level lock prevents concurrent requests creating duplicate SF accounts + * * @param request - Guest eligibility request with name, email, address * @param fingerprint - Request fingerprint for logging/abuse detection */ @@ -301,65 +325,74 @@ export class GetStartedWorkflowService { "Guest eligibility check initiated" ); + // Email-level lock to prevent concurrent requests for the same email + const lockKey = `guest-eligibility:${normalizedEmail}`; + try { - // Check if SF account already exists for this email - let sfAccountId: string; + return await this.lockService.withLock( + lockKey, + async () => { + // Check if SF account already exists for this email + let sfAccountId: string; - const existingSf = await this.salesforceAccountService.findByEmail(normalizedEmail); + const existingSf = await this.salesforceAccountService.findByEmail(normalizedEmail); - if (existingSf) { - sfAccountId = existingSf.id; - this.logger.log( - { email: normalizedEmail, sfAccountId }, - "Using existing SF account for guest eligibility check" - ); - } else { - // Create new SF Account (email NOT verified) - const { accountId } = await this.salesforceAccountService.createAccount({ - firstName, - lastName, - email: normalizedEmail, - phone: phone ?? "", - }); - sfAccountId = accountId; - this.logger.log( - { email: normalizedEmail, sfAccountId }, - "Created SF account for guest eligibility check" - ); - } + if (existingSf) { + sfAccountId = existingSf.id; + this.logger.log( + { email: normalizedEmail, sfAccountId }, + "Using existing SF account for guest eligibility check" + ); + } else { + // Create new SF Account (email NOT verified) + const { accountId } = await this.salesforceAccountService.createAccount({ + firstName, + lastName, + email: normalizedEmail, + phone: phone ?? "", + }); + sfAccountId = accountId; + this.logger.log( + { email: normalizedEmail, sfAccountId }, + "Created SF account for guest eligibility check" + ); + } - // Create eligibility case - const requestId = await this.createEligibilityCase(sfAccountId, address); + // Create eligibility case + const requestId = await this.createEligibilityCase(sfAccountId, address); - // Update Account eligibility status to Pending - this.updateAccountEligibilityStatus(sfAccountId); + // Update Account eligibility status to Pending + this.updateAccountEligibilityStatus(sfAccountId); - // If user wants to continue to account creation, generate a handoff token - let handoffToken: string | undefined; - if (continueToAccount) { - handoffToken = await this.sessionService.createGuestHandoffToken(normalizedEmail, { - firstName, - lastName, - address, - phone, - sfAccountId, - }); - this.logger.debug( - { email: normalizedEmail, handoffToken }, - "Created handoff token for account creation" - ); - } + // If user wants to continue to account creation, generate a handoff token + let handoffToken: string | undefined; + if (continueToAccount) { + handoffToken = await this.sessionService.createGuestHandoffToken(normalizedEmail, { + firstName, + lastName, + address: removeUndefined(address), + ...(phone && { phone }), + sfAccountId, + }); + this.logger.debug( + { email: normalizedEmail, handoffToken }, + "Created handoff token for account creation" + ); + } - // Send confirmation email - await this.sendGuestEligibilityConfirmationEmail(normalizedEmail, firstName, requestId); + // Send confirmation email + await this.sendGuestEligibilityConfirmationEmail(normalizedEmail, firstName, requestId); - return { - submitted: true, - requestId, - sfAccountId, - handoffToken, - message: "Eligibility check submitted. We'll notify you of the results.", - }; + return { + submitted: true, + requestId, + sfAccountId, + handoffToken, + message: "Eligibility check submitted. We'll notify you of the results.", + }; + }, + { ttlMs: 30_000 } // 30 second lock timeout + ); } catch (error) { this.logger.error( { error: extractErrorMessage(error), email: normalizedEmail }, @@ -380,118 +413,334 @@ export class GetStartedWorkflowService { /** * Complete account for users with SF account but no WHMCS/Portal * Creates WHMCS client and Portal user, links to existing SF account + * + * Security: + * - Session is locked to prevent double submissions + * - Email-level lock prevents concurrent account creation */ async completeAccount(request: CompleteAccountRequest): Promise { - const session = await this.sessionService.validateVerifiedSession(request.sessionToken); - if (!session) { - throw new BadRequestException("Invalid or expired session. Please verify your email again."); + // Atomically acquire session lock and mark as used + const sessionResult = await this.sessionService.acquireAndMarkAsUsed( + request.sessionToken, + "complete_account" + ); + + if (!sessionResult.success) { + throw new BadRequestException(sessionResult.reason); } + const session = sessionResult.session; + if (!session.sfAccountId) { throw new BadRequestException("No Salesforce account found. Please check eligibility first."); } const { password, phone, dateOfBirth, gender } = request; - - // Verify SF account still exists - const existingSf = await this.salesforceAccountService.findByEmail(session.email); - if (!existingSf || existingSf.id !== session.sfAccountId) { - throw new BadRequestException("Account verification failed. Please start over."); - } - - // Check for existing WHMCS client (shouldn't exist for SF-only flow) - const existingWhmcs = await this.whmcsDiscovery.findClientByEmail(session.email); - if (existingWhmcs) { - throw new ConflictException( - "A billing account already exists. Please use the account migration flow." - ); - } - - // Check for existing portal user - const existingPortalUser = await this.usersFacade.findByEmailInternal(session.email); - if (existingPortalUser) { - throw new ConflictException("An account already exists. Please log in."); - } - - const passwordHash = await argon2.hash(password); + const lockKey = `complete-account:${session.email}`; try { - // Get address from session or SF - const address = session.address; - if (!address || !address.address1 || !address.city || !address.postcode) { - throw new BadRequestException("Address information is incomplete."); - } + return await this.lockService.withLock( + lockKey, + async () => { + // Verify SF account still exists + const existingSf = await this.salesforceAccountService.findByEmail(session.email); + if (!existingSf || existingSf.id !== session.sfAccountId) { + throw new BadRequestException("Account verification failed. Please start over."); + } - // Create WHMCS client - const whmcsClient = await this.whmcsSignup.createClient({ - firstName: session.firstName!, - lastName: session.lastName!, - email: session.email, - password, - phone, - address: { - address1: address.address1, - address2: address.address2 ?? undefined, - city: address.city, - state: address.state ?? "", - postcode: address.postcode, - country: address.country ?? "Japan", + // Check for existing WHMCS client (shouldn't exist for SF-only flow) + const existingWhmcs = await this.whmcsDiscovery.findClientByEmail(session.email); + if (existingWhmcs) { + throw new ConflictException( + "A billing account already exists. Please use the account migration flow." + ); + } + + // Check for existing portal user + const existingPortalUser = await this.usersFacade.findByEmailInternal(session.email); + if (existingPortalUser) { + throw new ConflictException("An account already exists. Please log in."); + } + + const passwordHash = await argon2.hash(password); + + // Get address from session or SF + const address = session.address; + if (!address || !address.address1 || !address.city || !address.postcode) { + throw new BadRequestException("Address information is incomplete."); + } + + // Create WHMCS client + const whmcsClient = await this.whmcsSignup.createClient({ + firstName: session.firstName!, + lastName: session.lastName!, + email: session.email, + password, + phone, + address: { + address1: address.address1, + ...(address.address2 && { address2: address.address2 }), + city: address.city, + state: address.state ?? "", + postcode: address.postcode, + country: address.country ?? "Japan", + }, + customerNumber: existingSf.accountNumber, + dateOfBirth, + gender, + }); + + // Create portal user and mapping + const { userId } = await this.userCreation.createUserWithMapping({ + email: session.email, + passwordHash, + whmcsClientId: whmcsClient.clientId, + sfAccountId: session.sfAccountId, + }); + + // Fetch fresh user and generate tokens + const freshUser = await this.usersFacade.findByIdInternal(userId); + if (!freshUser) { + throw new Error("Failed to load created user"); + } + + await this.auditService.logAuthEvent(AuditAction.SIGNUP, userId, { + email: session.email, + whmcsClientId: whmcsClient.clientId, + source: "get_started_complete_account", + }); + + const profile = mapPrismaUserToDomain(freshUser); + const tokens = await this.tokenService.generateTokenPair({ + id: profile.id, + email: profile.email, + }); + + // Update Salesforce portal flags + await this.updateSalesforcePortalFlags(session.sfAccountId, whmcsClient.clientId); + + // Invalidate session (fully done) + await this.sessionService.invalidate(request.sessionToken); + + this.logger.log( + { email: session.email, userId }, + "Account completed successfully for SF-only user" + ); + + return { + user: profile, + tokens, + }; }, - customerNumber: existingSf.accountNumber, - dateOfBirth, - gender, - }); - - // Create portal user and mapping - const { userId } = await this.userCreation.createUserWithMapping({ - email: session.email, - passwordHash, - whmcsClientId: whmcsClient.clientId, - sfAccountId: session.sfAccountId, - }); - - // Fetch fresh user and generate tokens - const freshUser = await this.usersFacade.findByIdInternal(userId); - if (!freshUser) { - throw new Error("Failed to load created user"); - } - - await this.auditService.logAuthEvent(AuditAction.SIGNUP, userId, { - email: session.email, - whmcsClientId: whmcsClient.clientId, - source: "get_started_complete_account", - }); - - const profile = mapPrismaUserToDomain(freshUser); - const tokens = await this.tokenService.generateTokenPair({ - id: profile.id, - email: profile.email, - }); - - // Update Salesforce portal flags - await this.updateSalesforcePortalFlags(session.sfAccountId, whmcsClient.clientId); - - // Invalidate session - await this.sessionService.invalidate(request.sessionToken); - - this.logger.log( - { email: session.email, userId }, - "Account completed successfully for SF-only user" + { ttlMs: 60_000 } ); - - return { - user: profile, - tokens, - }; } catch (error) { this.logger.error( { error: extractErrorMessage(error), email: session.email }, "Account completion failed" ); + + // Don't clear usedStatus on error - partial resources may have been created. + // The user must verify their email again to start fresh. + throw error; } } + // ============================================================================ + // Full Signup with Eligibility (Inline Flow) + // ============================================================================ + + /** + * Full signup with eligibility check - creates everything in one operation + * + * This is the primary signup path from the eligibility check page. + * Creates SF Account + Case + WHMCS + Portal after email verification. + * + * Security: + * - Session is locked to prevent double submissions (race condition protection) + * - Email-level lock prevents concurrent signups for the same email + * - Session is invalidated on success, cleared on partial failure for retry + * + * @param request - Signup request with all required data + */ + async signupWithEligibility(request: SignupWithEligibilityRequest): Promise<{ + success: boolean; + message?: string; + eligibilityRequestId?: string; + authResult?: AuthResultInternal; + }> { + // Atomically acquire session lock and mark as used + const sessionResult = await this.sessionService.acquireAndMarkAsUsed( + request.sessionToken, + "signup_with_eligibility" + ); + + if (!sessionResult.success) { + return { + success: false, + message: sessionResult.reason, + }; + } + + const session = sessionResult.session; + const { firstName, lastName, address, phone, password, dateOfBirth, gender } = request; + const normalizedEmail = session.email; + + this.logger.log({ email: normalizedEmail }, "Starting signup with eligibility"); + + // Email-level lock to prevent concurrent signups for the same email + const lockKey = `signup-email:${normalizedEmail}`; + + try { + return await this.lockService.withLock( + lockKey, + async () => { + // Check for existing Portal user + const existingPortalUser = await this.usersFacade.findByEmailInternal(normalizedEmail); + if (existingPortalUser) { + return { + success: false, + message: "An account already exists with this email. Please log in.", + }; + } + + // Check for existing WHMCS client + const existingWhmcs = await this.whmcsDiscovery.findClientByEmail(normalizedEmail); + if (existingWhmcs) { + return { + success: false, + message: + "A billing account already exists with this email. Please use account linking instead.", + }; + } + + // Check for existing SF Account or create new one + let sfAccountId: string; + let customerNumber: string | undefined; + + const existingSf = await this.salesforceAccountService.findByEmail(normalizedEmail); + if (existingSf) { + sfAccountId = existingSf.id; + customerNumber = existingSf.accountNumber; + this.logger.log( + { email: normalizedEmail, sfAccountId }, + "Using existing SF account for signup" + ); + } else { + // Create new SF Account + const { accountId, accountNumber } = await this.salesforceAccountService.createAccount({ + firstName, + lastName, + email: normalizedEmail, + phone: phone ?? "", + }); + sfAccountId = accountId; + customerNumber = accountNumber; + this.logger.log( + { email: normalizedEmail, sfAccountId }, + "Created new SF account for signup" + ); + } + + // Create eligibility case + const eligibilityRequestId = await this.createEligibilityCase(sfAccountId, address); + + // Hash password + const passwordHash = await argon2.hash(password); + + // Create WHMCS client + const whmcsClient = await this.whmcsSignup.createClient({ + firstName, + lastName, + email: normalizedEmail, + password, + phone, + address: { + address1: address.address1, + ...(address.address2 && { address2: address.address2 }), + city: address.city, + state: address.state ?? "", + postcode: address.postcode, + country: address.country ?? "Japan", + }, + customerNumber, + dateOfBirth, + gender, + }); + + // Create Portal user and mapping + const { userId } = await this.userCreation.createUserWithMapping({ + email: normalizedEmail, + passwordHash, + whmcsClientId: whmcsClient.clientId, + sfAccountId, + }); + + // Fetch fresh user and generate tokens + const freshUser = await this.usersFacade.findByIdInternal(userId); + if (!freshUser) { + throw new Error("Failed to load created user"); + } + + await this.auditService.logAuthEvent(AuditAction.SIGNUP, userId, { + email: normalizedEmail, + whmcsClientId: whmcsClient.clientId, + source: "signup_with_eligibility", + }); + + const profile = mapPrismaUserToDomain(freshUser); + const tokens = await this.tokenService.generateTokenPair({ + id: profile.id, + email: profile.email, + }); + + // Update Salesforce portal flags + await this.updateSalesforcePortalFlags(sfAccountId, whmcsClient.clientId); + + // Invalidate session (fully done, no retry needed) + await this.sessionService.invalidate(request.sessionToken); + + // Send welcome email (includes eligibility info) + await this.sendWelcomeWithEligibilityEmail( + normalizedEmail, + firstName, + eligibilityRequestId + ); + + this.logger.log( + { email: normalizedEmail, userId, eligibilityRequestId }, + "Signup with eligibility completed successfully" + ); + + return { + success: true, + eligibilityRequestId, + authResult: { + user: profile, + tokens, + }, + }; + }, + { ttlMs: 60_000 } // 60 second lock timeout for the full operation + ); + } catch (error) { + this.logger.error( + { error: extractErrorMessage(error), email: normalizedEmail }, + "Signup with eligibility failed" + ); + + // Don't clear usedStatus on error - partial resources may have been created. + // The user must verify their email again to start fresh. + // This prevents potential duplicate resource creation on retry. + + return { + success: false, + message: "Account creation failed. Please verify your email again to retry.", + }; + } + } + // ============================================================================ // Private Helpers // ============================================================================ @@ -593,6 +842,41 @@ export class GetStartedWorkflowService { } } + private async sendWelcomeWithEligibilityEmail( + email: string, + firstName: string, + eligibilityRequestId: string + ): Promise { + const appBase = this.config.get("APP_BASE_URL", "http://localhost:3000"); + const templateId = this.config.get("EMAIL_TEMPLATE_WELCOME_WITH_ELIGIBILITY"); + + if (templateId) { + await this.emailService.sendEmail({ + to: email, + subject: "Welcome! Your account is ready", + templateId, + dynamicTemplateData: { + firstName, + portalUrl: appBase, + dashboardUrl: `${appBase}/account/dashboard`, + eligibilityRequestId, + }, + }); + } else { + await this.emailService.sendEmail({ + to: email, + subject: "Welcome! Your account is ready", + html: ` +

Hi ${firstName},

+

Welcome! Your account has been created successfully.

+

We're also checking internet availability at your address. We'll notify you of the results within 1-2 business days.

+

Reference ID: ${eligibilityRequestId}

+

Log in to your dashboard: ${appBase}/account/dashboard

+ `, + }); + } + } + private async determineAccountStatus( email: string ): Promise<{ status: AccountStatus; sfAccountId?: string; whmcsClientId?: number }> { diff --git a/apps/bff/src/modules/auth/infra/workflows/signup-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/signup-workflow.service.ts index 23321d73..e9330975 100644 --- a/apps/bff/src/modules/auth/infra/workflows/signup-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/signup-workflow.service.ts @@ -134,20 +134,20 @@ export class SignupWorkflowService { lastName, email, password, - company: company ?? undefined, + ...(company ? { company } : {}), phone: phone, address: { address1: address!.address1!, - address2: address?.address2 ?? undefined, + ...(address?.address2 ? { address2: address.address2 } : {}), city: address!.city!, state: address!.state!, postcode: address!.postcode!, country: address!.country!, }, customerNumber: customerNumberForWhmcs, - dateOfBirth, - gender, - nationality, + ...(dateOfBirth ? { dateOfBirth } : {}), + ...(gender ? { gender } : {}), + ...(nationality ? { nationality } : {}), }); // Step 5: Create user and mapping in database @@ -228,7 +228,7 @@ export class SignupWorkflowService { status: PORTAL_STATUS_ACTIVE, source, lastSignedInAt: new Date(), - whmcsAccountId, + ...(whmcsAccountId === undefined ? {} : { whmcsAccountId }), }); } catch (error) { this.logger.warn("Failed to update Salesforce portal flags after signup", { diff --git a/apps/bff/src/modules/auth/infra/workflows/signup/signup.types.ts b/apps/bff/src/modules/auth/infra/workflows/signup/signup.types.ts index 2696e64a..887355d9 100644 --- a/apps/bff/src/modules/auth/infra/workflows/signup/signup.types.ts +++ b/apps/bff/src/modules/auth/infra/workflows/signup/signup.types.ts @@ -7,8 +7,8 @@ */ export interface SignupAccountSnapshot { id: string; - Name?: string | null; - WH_Account__c?: string | null; + Name?: string | null | undefined; + WH_Account__c?: string | null | undefined; } /** @@ -38,14 +38,14 @@ export interface SignupPreflightResult { }; portal: { userExists: boolean; - needsPasswordSet?: boolean; + needsPasswordSet?: boolean | undefined; }; salesforce: { - accountId?: string; + accountId?: string | undefined; alreadyMapped: boolean; }; whmcs: { clientExists: boolean; - clientId?: number; + clientId?: number | undefined; }; } diff --git a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts index 1b263b17..b3d6b86c 100644 --- a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts +++ b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts @@ -215,12 +215,12 @@ export class AuthController { @Req() req: RequestWithCookies, @Res({ passthrough: true }) res: Response ) { - const refreshToken = body.refreshToken ?? req.cookies?.refresh_token; + const refreshToken = body.refreshToken ?? req.cookies?.["refresh_token"]; const rawUserAgent = req.headers["user-agent"]; const userAgent = typeof rawUserAgent === "string" ? rawUserAgent : undefined; const result = await this.authFacade.refreshTokens(refreshToken, { - deviceId: body.deviceId, - userAgent, + deviceId: body.deviceId ?? undefined, + userAgent: userAgent ?? undefined, }); this.setAuthCookies(res, result.tokens); return { user: result.user, session: this.toSession(result.tokens) }; diff --git a/apps/bff/src/modules/auth/presentation/http/get-started.controller.ts b/apps/bff/src/modules/auth/presentation/http/get-started.controller.ts index fcf97c3c..d191ff66 100644 --- a/apps/bff/src/modules/auth/presentation/http/get-started.controller.ts +++ b/apps/bff/src/modules/auth/presentation/http/get-started.controller.ts @@ -18,6 +18,8 @@ import { completeAccountRequestSchema, maybeLaterRequestSchema, maybeLaterResponseSchema, + signupWithEligibilityRequestSchema, + signupWithEligibilityResponseSchema, } from "@customer-portal/domain/get-started"; import { GetStartedWorkflowService } from "../../infra/workflows/get-started-workflow.service.js"; @@ -34,6 +36,8 @@ class GuestEligibilityResponseDto extends createZodDto(guestEligibilityResponseS class CompleteAccountRequestDto extends createZodDto(completeAccountRequestSchema) {} class MaybeLaterRequestDto extends createZodDto(maybeLaterRequestSchema) {} class MaybeLaterResponseDto extends createZodDto(maybeLaterResponseSchema) {} +class SignupWithEligibilityRequestDto extends createZodDto(signupWithEligibilityRequestSchema) {} +class SignupWithEligibilityResponseDto extends createZodDto(signupWithEligibilityResponseSchema) {} const ACCESS_COOKIE_PATH = "/api"; const REFRESH_COOKIE_PATH = "/api/auth/refresh"; @@ -177,7 +181,7 @@ export class GetStartedController { res.cookie("access_token", result.tokens.accessToken, { httpOnly: true, - secure: process.env.NODE_ENV === "production", + secure: process.env["NODE_ENV"] === "production", sameSite: "lax", path: ACCESS_COOKIE_PATH, maxAge: calculateCookieMaxAge(accessExpires), @@ -185,7 +189,7 @@ export class GetStartedController { res.cookie("refresh_token", result.tokens.refreshToken, { httpOnly: true, - secure: process.env.NODE_ENV === "production", + secure: process.env["NODE_ENV"] === "production", sameSite: "lax", path: REFRESH_COOKIE_PATH, maxAge: calculateCookieMaxAge(refreshExpires), @@ -200,4 +204,65 @@ export class GetStartedController { }, }; } + + /** + * Full signup with eligibility check (inline flow) + * Creates SF Account + Case + WHMCS + Portal in one operation + * + * Used when user clicks "Create Account" on the eligibility check page. + * This is the primary signup path - creates all accounts at once after OTP verification. + * + * Returns auth tokens (sets httpOnly cookies) + * + * Rate limit: 5 per 15 minutes per IP + */ + @Public() + @Post("signup-with-eligibility") + @HttpCode(200) + @UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard) + @RateLimit({ limit: 5, ttl: 900 }) + async signupWithEligibility( + @Body() body: SignupWithEligibilityRequestDto, + @Res({ passthrough: true }) res: Response + ): Promise { + const result = await this.workflow.signupWithEligibility(body); + + if (!result.success || !result.authResult) { + return { + success: false, + message: result.message, + }; + } + + // Set auth cookies (same pattern as complete-account) + const accessExpires = result.authResult.tokens.expiresAt; + const refreshExpires = result.authResult.tokens.refreshExpiresAt; + + res.cookie("access_token", result.authResult.tokens.accessToken, { + httpOnly: true, + secure: process.env["NODE_ENV"] === "production", + sameSite: "lax", + path: ACCESS_COOKIE_PATH, + maxAge: calculateCookieMaxAge(accessExpires), + }); + + res.cookie("refresh_token", result.authResult.tokens.refreshToken, { + httpOnly: true, + secure: process.env["NODE_ENV"] === "production", + sameSite: "lax", + path: REFRESH_COOKIE_PATH, + maxAge: calculateCookieMaxAge(refreshExpires), + }); + + return { + success: true, + eligibilityRequestId: result.eligibilityRequestId, + user: result.authResult.user, + session: { + expiresAt: accessExpires, + refreshExpiresAt: refreshExpires, + tokenType: TOKEN_TYPE, + }, + }; + } } diff --git a/apps/bff/src/modules/auth/presentation/http/guards/local-auth.guard.ts b/apps/bff/src/modules/auth/presentation/http/guards/local-auth.guard.ts index a6370564..265c1cce 100644 --- a/apps/bff/src/modules/auth/presentation/http/guards/local-auth.guard.ts +++ b/apps/bff/src/modules/auth/presentation/http/guards/local-auth.guard.ts @@ -11,8 +11,8 @@ export class LocalAuthGuard implements CanActivate { const request = context.switchToHttp().getRequest(); const body = (request.body ?? {}) as Record; - const email = typeof body.email === "string" ? body.email : ""; - const password = typeof body.password === "string" ? body.password : ""; + const email = typeof body["email"] === "string" ? body["email"] : ""; + const password = typeof body["password"] === "string" ? body["password"] : ""; if (!email || !password) { throw new UnauthorizedException("Invalid credentials"); diff --git a/apps/bff/src/modules/auth/utils/jwt-expiry.util.ts b/apps/bff/src/modules/auth/utils/jwt-expiry.util.ts index 52318fdb..ab227a98 100644 --- a/apps/bff/src/modules/auth/utils/jwt-expiry.util.ts +++ b/apps/bff/src/modules/auth/utils/jwt-expiry.util.ts @@ -23,7 +23,7 @@ export const parseJwtExpiry = (expiresIn: string | number | undefined | null): n const unit = trimmed.slice(-1); const valuePortion = trimmed.slice(0, -1); - const parsedValue = parseInt(valuePortion, 10); + const parsedValue = Number.parseInt(valuePortion, 10); const toSeconds = (multiplier: number) => { if (Number.isNaN(parsedValue) || parsedValue <= 0) { diff --git a/apps/bff/src/modules/auth/utils/token-from-request.util.ts b/apps/bff/src/modules/auth/utils/token-from-request.util.ts index 89c0dfae..bf6bb04a 100644 --- a/apps/bff/src/modules/auth/utils/token-from-request.util.ts +++ b/apps/bff/src/modules/auth/utils/token-from-request.util.ts @@ -24,7 +24,7 @@ const pickFirstStringHeader = (value: unknown): string | undefined => { }; export const resolveAuthorizationHeader = (headers: RequestHeadersLike): string | undefined => { - const raw = headers?.authorization; + const raw = headers?.["authorization"]; return pickFirstStringHeader(raw); }; @@ -41,6 +41,6 @@ export const extractAccessTokenFromRequest = (request: RequestWithCookies): stri const headerToken = extractBearerToken(request.headers); if (headerToken) return headerToken; - const cookieToken = request.cookies?.access_token; + const cookieToken = request.cookies?.["access_token"]; return typeof cookieToken === "string" && cookieToken.length > 0 ? cookieToken : undefined; }; diff --git a/apps/bff/src/modules/billing/services/invoice-retrieval.service.ts b/apps/bff/src/modules/billing/services/invoice-retrieval.service.ts index 4504eeb1..20f6d408 100644 --- a/apps/bff/src/modules/billing/services/invoice-retrieval.service.ts +++ b/apps/bff/src/modules/billing/services/invoice-retrieval.service.ts @@ -45,7 +45,7 @@ export class InvoiceRetrievalService { const invoiceList = await this.whmcsInvoiceService.getInvoices(whmcsClientId, userId, { page, limit, - status, + ...(status !== undefined && { status }), }); this.logger.log(`Retrieved ${invoiceList.invoices.length} invoices for user ${userId}`, { @@ -100,7 +100,7 @@ export class InvoiceRetrievalService { const queryStatus = status as "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections"; return withErrorHandling( - () => this.getInvoices(userId, { page, limit, status: queryStatus }), + async () => this.getInvoices(userId, { page, limit, status: queryStatus }), this.logger, { context: `Get ${status} invoices for user ${userId}`, diff --git a/apps/bff/src/modules/health/health.controller.ts b/apps/bff/src/modules/health/health.controller.ts index 50bae848..e1b5b145 100644 --- a/apps/bff/src/modules/health/health.controller.ts +++ b/apps/bff/src/modules/health/health.controller.ts @@ -20,10 +20,10 @@ export class HealthController { // Database check try { await this.prisma.$queryRaw`SELECT 1`; - checks.database = "ok"; + checks["database"] = "ok"; } catch (error) { this.logger.error({ error }, "Database health check failed"); - checks.database = "fail"; + checks["database"] = "fail"; } // Cache check @@ -31,11 +31,11 @@ export class HealthController { const key = "health:check"; await this.cache.set(key, { ok: true }, 5); const value = await this.cache.get<{ ok: boolean }>(key); - checks.cache = value?.ok ? "ok" : "fail"; + checks["cache"] = value?.ok ? "ok" : "fail"; await this.cache.del(key); } catch (error) { this.logger.error({ error }, "Cache health check failed"); - checks.cache = "fail"; + checks["cache"] = "fail"; } const status = Object.values(checks).every(v => v === "ok") ? "ok" : "degraded"; diff --git a/apps/bff/src/modules/id-mappings/cache/mapping-cache.service.ts b/apps/bff/src/modules/id-mappings/cache/mapping-cache.service.ts index 53dc86bf..4b7b494e 100644 --- a/apps/bff/src/modules/id-mappings/cache/mapping-cache.service.ts +++ b/apps/bff/src/modules/id-mappings/cache/mapping-cache.service.ts @@ -57,7 +57,7 @@ export class MappingCacheService { if (mapping.sfAccountId) { keys.push(this.buildCacheKey("sfAccountId", mapping.sfAccountId)); } - await Promise.all(keys.map(key => this.cacheService.del(key))); + await Promise.all(keys.map(async key => this.cacheService.del(key))); this.logger.debug(`Deleted mapping cache for user ${mapping.userId}`); } diff --git a/apps/bff/src/modules/id-mappings/domain/contract.ts b/apps/bff/src/modules/id-mappings/domain/contract.ts index 76251711..5df4e1c4 100644 --- a/apps/bff/src/modules/id-mappings/domain/contract.ts +++ b/apps/bff/src/modules/id-mappings/domain/contract.ts @@ -10,7 +10,7 @@ export interface UserIdMapping { id: string; userId: string; whmcsClientId: number; - sfAccountId?: string | null; + sfAccountId?: string | null | undefined; createdAt: IsoDateTimeString | Date; updatedAt: IsoDateTimeString | Date; } @@ -18,12 +18,12 @@ export interface UserIdMapping { export interface CreateMappingRequest { userId: string; whmcsClientId: number; - sfAccountId?: string; + sfAccountId?: string | undefined; } export interface UpdateMappingRequest { - whmcsClientId?: number; - sfAccountId?: string; + whmcsClientId?: number | undefined; + sfAccountId?: string | undefined; } /** diff --git a/apps/bff/src/modules/id-mappings/domain/validation.ts b/apps/bff/src/modules/id-mappings/domain/validation.ts index 13048337..78f4a4ee 100644 --- a/apps/bff/src/modules/id-mappings/domain/validation.ts +++ b/apps/bff/src/modules/id-mappings/domain/validation.ts @@ -98,10 +98,11 @@ export function validateDeletion( * The schema handles validation; this is purely for data cleanup. */ export function sanitizeCreateRequest(request: CreateMappingRequest): CreateMappingRequest { + const trimmedSfAccountId = request.sfAccountId?.trim(); return { userId: request.userId?.trim(), whmcsClientId: request.whmcsClientId, - sfAccountId: request.sfAccountId?.trim() || undefined, + ...(trimmedSfAccountId ? { sfAccountId: trimmedSfAccountId } : {}), }; } @@ -112,14 +113,15 @@ export function sanitizeCreateRequest(request: CreateMappingRequest): CreateMapp * The schema handles validation; this is purely for data cleanup. */ export function sanitizeUpdateRequest(request: UpdateMappingRequest): UpdateMappingRequest { - const sanitized: Partial = {}; + const sanitized: UpdateMappingRequest = {}; if (request.whmcsClientId !== undefined) { sanitized.whmcsClientId = request.whmcsClientId; } - if (request.sfAccountId !== undefined) { - sanitized.sfAccountId = request.sfAccountId?.trim() || undefined; + const trimmedSfAccountId = request.sfAccountId?.trim(); + if (trimmedSfAccountId) { + sanitized.sfAccountId = trimmedSfAccountId; } return sanitized; diff --git a/apps/bff/src/modules/id-mappings/mappings.service.ts b/apps/bff/src/modules/id-mappings/mappings.service.ts index 83ed8d01..a8a6766a 100644 --- a/apps/bff/src/modules/id-mappings/mappings.service.ts +++ b/apps/bff/src/modules/id-mappings/mappings.service.ts @@ -80,7 +80,13 @@ export class MappingsService { let created; try { - created = await this.prisma.idMapping.create({ data: sanitizedRequest }); + // Convert undefined to null for Prisma compatibility + const prismaData = { + userId: sanitizedRequest.userId, + whmcsClientId: sanitizedRequest.whmcsClientId, + sfAccountId: sanitizedRequest.sfAccountId ?? null, + }; + created = await this.prisma.idMapping.create({ data: prismaData }); } catch (e) { const msg = extractErrorMessage(e); if (msg.includes("P2002") || /unique/i.test(msg)) { @@ -245,9 +251,18 @@ export class MappingsService { } } + // Convert undefined to null for Prisma compatibility + const prismaUpdateData: Prisma.IdMappingUpdateInput = { + ...(sanitizedUpdates.whmcsClientId !== undefined && { + whmcsClientId: sanitizedUpdates.whmcsClientId, + }), + ...(sanitizedUpdates.sfAccountId !== undefined && { + sfAccountId: sanitizedUpdates.sfAccountId ?? null, + }), + }; const updated = await this.prisma.idMapping.update({ where: { userId }, - data: sanitizedUpdates, + data: prismaUpdateData, }); const newMapping = mapPrismaMappingToDomain(updated); diff --git a/apps/bff/src/modules/me-status/me-status.service.ts b/apps/bff/src/modules/me-status/me-status.service.ts index d399f39b..aec1e607 100644 --- a/apps/bff/src/modules/me-status/me-status.service.ts +++ b/apps/bff/src/modules/me-status/me-status.service.ts @@ -186,32 +186,32 @@ export class MeStatusService { // Priority 3: pending orders if (orders && orders.length > 0) { - const pendingOrders = orders.filter( + const pendingOrder = orders.find( o => o.status === "Draft" || o.status === "Pending" || (o.status === "Activated" && o.activationStatus !== "Completed") ); - if (pendingOrders.length > 0) { - const order = pendingOrders[0]; + const firstPendingOrder = pendingOrder; + if (firstPendingOrder) { const statusText = - order.status === "Pending" + firstPendingOrder.status === "Pending" ? "awaiting review" - : order.status === "Draft" + : firstPendingOrder.status === "Draft" ? "in draft" : "being activated"; tasks.push({ - id: `order-${order.id}`, + id: `order-${firstPendingOrder.id}`, priority: 3, type: "order", title: "Order in progress", - description: `${order.orderType || "Your"} order is ${statusText}`, + description: `${firstPendingOrder.orderType || "Your"} order is ${statusText}`, actionLabel: "View details", - detailHref: `/account/orders/${order.id}`, + detailHref: `/account/orders/${firstPendingOrder.id}`, tone: "info", - metadata: { orderId: order.id }, + metadata: { orderId: firstPendingOrder.id }, }); } } diff --git a/apps/bff/src/modules/notifications/notifications.service.ts b/apps/bff/src/modules/notifications/notifications.service.ts index d3994fb7..410fa400 100644 --- a/apps/bff/src/modules/notifications/notifications.service.ts +++ b/apps/bff/src/modules/notifications/notifications.service.ts @@ -36,12 +36,12 @@ const NOTIFICATION_DEDUPE_WINDOW_HOURS: Partial item.sku) .filter((sku): sku is string => typeof sku === "string" && sku.trim().length > 0) - ) - ); + ), + ]; if (uniqueSkus.length === 0) { throw new NotFoundException("Checkout session contains no items"); diff --git a/apps/bff/src/modules/orders/queue/provisioning.queue.ts b/apps/bff/src/modules/orders/queue/provisioning.queue.ts index cbbf7a0e..0a332646 100644 --- a/apps/bff/src/modules/orders/queue/provisioning.queue.ts +++ b/apps/bff/src/modules/orders/queue/provisioning.queue.ts @@ -47,6 +47,6 @@ export class ProvisioningQueueService { async depth(): Promise { const counts = await this.queue.getJobCounts("waiting", "active", "delayed"); - return (counts.waiting || 0) + (counts.active || 0) + (counts.delayed || 0); + return (counts["waiting"] || 0) + (counts["active"] || 0) + (counts["delayed"] || 0); } } diff --git a/apps/bff/src/modules/orders/services/checkout.service.ts b/apps/bff/src/modules/orders/services/checkout.service.ts index 26a4f989..ea33c38e 100644 --- a/apps/bff/src/modules/orders/services/checkout.service.ts +++ b/apps/bff/src/modules/orders/services/checkout.service.ts @@ -448,7 +448,11 @@ export class CheckoutService { return { fee: defaultFee, autoAdded: true }; } - return { fee: activationFees[0], autoAdded: true }; + const firstFee = activationFees[0]; + if (!firstFee) { + return null; + } + return { fee: firstFee, autoAdded: true }; } /** @@ -460,14 +464,14 @@ export class CheckoutService { // Handle various addon selection formats if (selections.addonSku) refs.add(selections.addonSku); if (selections.addons) { - selections.addons + for (const value of selections.addons .split(",") .map(value => value.trim()) - .filter(Boolean) - .forEach(value => refs.add(value)); + .filter(Boolean)) + refs.add(value); } - return Array.from(refs); + return [...refs]; } /** diff --git a/apps/bff/src/modules/orders/services/order-builder.service.ts b/apps/bff/src/modules/orders/services/order-builder.service.ts index 5e1fabc3..ef5fee4a 100644 --- a/apps/bff/src/modules/orders/services/order-builder.service.ts +++ b/apps/bff/src/modules/orders/services/order-builder.service.ts @@ -124,7 +124,7 @@ export class OrderBuilder { try { const profile = await this.usersFacade.getProfile(userId); const address = profile.address; - const orderAddress = (body.configurations as Record)?.address as + const orderAddress = (body.configurations as Record)?.["address"] as | Record | undefined; const addressChanged = !!orderAddress; diff --git a/apps/bff/src/modules/orders/services/order-fulfillment-error.service.ts b/apps/bff/src/modules/orders/services/order-fulfillment-error.service.ts index eadbb572..8a897cff 100644 --- a/apps/bff/src/modules/orders/services/order-fulfillment-error.service.ts +++ b/apps/bff/src/modules/orders/services/order-fulfillment-error.service.ts @@ -43,7 +43,7 @@ export class OrderFulfillmentErrorService { * Get user-friendly error message for external consumption * Ensures no sensitive information is exposed */ - getUserFriendlyMessage(error: unknown, errorCode: OrderFulfillmentErrorCode): string { + getUserFriendlyMessage(_error: unknown, errorCode: OrderFulfillmentErrorCode): string { switch (errorCode) { case ORDER_FULFILLMENT_ERROR_CODE.PAYMENT_METHOD_MISSING: return "Payment method missing - please add a payment method before fulfillment"; diff --git a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts index 83dd91c9..85826937 100644 --- a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts @@ -31,18 +31,18 @@ type WhmcsOrderItemMappingResult = ReturnType; export interface OrderFulfillmentStep { step: string; status: "pending" | "in_progress" | "completed" | "failed"; - startedAt?: Date; - completedAt?: Date; - error?: string; + startedAt?: Date | undefined; + completedAt?: Date | undefined; + error?: string | undefined; } export interface OrderFulfillmentContext { sfOrderId: string; idempotencyKey: string; validation: OrderFulfillmentValidationResult | null; - orderDetails?: OrderDetails; - mappingResult?: WhmcsOrderItemMappingResult; - whmcsResult?: WhmcsOrderResult; + orderDetails?: OrderDetails | undefined; + mappingResult?: WhmcsOrderItemMappingResult | undefined; + whmcsResult?: WhmcsOrderResult | undefined; steps: OrderFulfillmentStep[]; } @@ -92,7 +92,7 @@ export class OrderFulfillmentOrchestrator { idempotencyKey, validation: null, steps: this.initializeSteps( - typeof payload.orderType === "string" ? payload.orderType : "Unknown" + typeof payload["orderType"] === "string" ? payload["orderType"] : "Unknown" ), }; @@ -105,6 +105,7 @@ export class OrderFulfillmentOrchestrator { // Step 1: Validation (no rollback needed) this.updateStepStatus(context, "validation", "in_progress"); try { + // eslint-disable-next-line require-atomic-updates -- context is not shared across concurrent executions context.validation = await this.orderFulfillmentValidator.validateFulfillmentRequest( sfOrderId, idempotencyKey @@ -146,6 +147,7 @@ export class OrderFulfillmentOrchestrator { idempotencyKey, }); } + // eslint-disable-next-line require-atomic-updates -- context is not shared across concurrent executions context.orderDetails = orderDetails; } catch (error) { this.logger.error("Failed to get order details", { @@ -197,7 +199,7 @@ export class OrderFulfillmentOrchestrator { { id: "order_details", description: "Retain order details in context", - execute: this.createTrackedStep(context, "order_details", () => + execute: this.createTrackedStep(context, "order_details", async () => Promise.resolve(context.orderDetails) ), critical: false, @@ -205,7 +207,7 @@ export class OrderFulfillmentOrchestrator { { id: "mapping", description: "Map OrderItems to WHMCS format", - execute: this.createTrackedStep(context, "mapping", () => { + execute: this.createTrackedStep(context, "mapping", async () => { if (!context.orderDetails) { return Promise.reject(new Error("Order details are required for mapping")); } @@ -263,7 +265,7 @@ export class OrderFulfillmentOrchestrator { whmcsCreateResult = result; return result; }), - rollback: () => { + rollback: async () => { if (whmcsCreateResult?.orderId) { // Note: WHMCS doesn't have an automated cancel API // Manual intervention required for order cleanup @@ -297,7 +299,7 @@ export class OrderFulfillmentOrchestrator { await this.whmcsOrderService.acceptOrder(whmcsCreateResult.orderId, sfOrderId); return { orderId: whmcsCreateResult.orderId }; }), - rollback: () => { + rollback: async () => { if (whmcsCreateResult?.orderId) { // Note: WHMCS doesn't have an automated cancel API for accepted orders // Manual intervention required for service termination @@ -320,7 +322,7 @@ export class OrderFulfillmentOrchestrator { description: "SIM-specific fulfillment (if applicable)", execute: this.createTrackedStep(context, "sim_fulfillment", async () => { if (context.orderDetails?.orderType === "SIM") { - const configurations = this.extractConfigurations(payload.configurations); + const configurations = this.extractConfigurations(payload["configurations"]); await this.simFulfillmentService.fulfillSimOrder({ orderDetails: context.orderDetails, configurations, @@ -444,7 +446,9 @@ export class OrderFulfillmentOrchestrator { } // Update context with results + // eslint-disable-next-line require-atomic-updates -- context is not shared across concurrent executions context.mappingResult = mappingResult; + // eslint-disable-next-line require-atomic-updates -- context is not shared across concurrent executions context.whmcsResult = whmcsCreateResult; this.logger.log("Transactional fulfillment completed successfully", { @@ -584,8 +588,8 @@ export class OrderFulfillmentOrchestrator { this.orderFulfillmentErrorService.getShortCode(error) || String(errorCode) ) .toString() - .substring(0, 60), - Activation_Error_Message__c: userMessage?.substring(0, 255), + .slice(0, 60), + Activation_Error_Message__c: userMessage?.slice(0, 255), }; await this.salesforceService.updateOrder(updates as { Id: string; [key: string]: unknown }); @@ -608,8 +612,8 @@ export class OrderFulfillmentOrchestrator { getFulfillmentSummary(context: OrderFulfillmentContext): { success: boolean; status: "Already Fulfilled" | "Fulfilled" | "Failed"; - whmcsOrderId?: string; - whmcsServiceIds?: number[]; + whmcsOrderId?: string | undefined; + whmcsServiceIds?: number[] | undefined; message: string; steps: OrderFulfillmentStep[]; } { @@ -617,21 +621,24 @@ export class OrderFulfillmentOrchestrator { const failedStep = context.steps.find((s: OrderFulfillmentStep) => s.status === "failed"); if (context.validation?.isAlreadyProvisioned) { + const whmcsOrderId = context.validation.whmcsOrderId; return { success: true, status: "Already Fulfilled", - whmcsOrderId: context.validation.whmcsOrderId, + ...(whmcsOrderId !== undefined && { whmcsOrderId }), message: "Order was already fulfilled in WHMCS", steps: context.steps, }; } if (isSuccess) { + const whmcsOrderId = context.whmcsResult?.orderId.toString(); + const whmcsServiceIds = context.whmcsResult?.serviceIds; return { success: true, status: "Fulfilled", - whmcsOrderId: context.whmcsResult?.orderId.toString(), - whmcsServiceIds: context.whmcsResult?.serviceIds, + ...(whmcsOrderId !== undefined && { whmcsOrderId }), + ...(whmcsServiceIds !== undefined && { whmcsServiceIds }), message: "Order fulfilled successfully in WHMCS", steps: context.steps, }; @@ -640,7 +647,7 @@ export class OrderFulfillmentOrchestrator { return { success: false, status: "Failed", - message: failedStep?.error || "Fulfillment failed", + message: failedStep?.error ?? "Fulfillment failed", steps: context.steps, }; } @@ -658,13 +665,17 @@ export class OrderFulfillmentOrchestrator { if (status === "in_progress") { step.status = "in_progress"; step.startedAt = timestamp; - step.error = undefined; + delete step.error; return; } step.status = status; step.completedAt = timestamp; - step.error = status === "failed" ? error : undefined; + if (status === "failed" && error !== undefined) { + step.error = error; + } else { + delete step.error; + } } private createTrackedStep( 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 3b1dc5dc..d30ec6e5 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,9 +4,11 @@ import { SalesforceService } from "@bff/integrations/salesforce/salesforce.servi import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { sfOrderIdParamSchema } from "@customer-portal/domain/orders"; -import type { OrderFulfillmentValidationResult } from "@customer-portal/domain/orders/providers"; +import type { + OrderFulfillmentValidationResult, + SalesforceOrderRecord, +} from "@customer-portal/domain/orders/providers"; import { salesforceAccountIdSchema } from "@customer-portal/domain/common"; -import type { SalesforceOrderRecord } from "@customer-portal/domain/orders/providers"; import { PaymentValidatorService } from "./payment-validator.service.js"; /** 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 7a7a6693..d8a3f6ac 100644 --- a/apps/bff/src/modules/orders/services/order-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-orchestrator.service.ts @@ -8,8 +8,12 @@ import { OrderBuilder } from "./order-builder.service.js"; import { OrderItemBuilder } from "./order-item-builder.service.js"; import type { OrderItemCompositePayload } from "./order-item-builder.service.js"; import { OrdersCacheService } from "./orders-cache.service.js"; -import type { OrderDetails, OrderSummary, OrderTypeValue } from "@customer-portal/domain/orders"; -import type { CreateOrderRequest } from "@customer-portal/domain/orders"; +import type { + OrderDetails, + OrderSummary, + OrderTypeValue, + CreateOrderRequest, +} from "@customer-portal/domain/orders"; import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js"; import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity"; @@ -173,7 +177,7 @@ export class OrderOrchestrator { this.logger.log({ orderId: safeOrderId }, "Fetching order details"); // Use integration service - it handles queries and transformations - return this.ordersCache.getOrderDetails(safeOrderId, () => + return this.ordersCache.getOrderDetails(safeOrderId, async () => this.salesforceOrderService.getOrderById(safeOrderId) ); } @@ -232,7 +236,7 @@ export class OrderOrchestrator { } // Use integration service - it handles queries and transformations - return this.ordersCache.getOrderSummaries(sfAccountId, () => + return this.ordersCache.getOrderSummaries(sfAccountId, async () => this.salesforceOrderService.getOrdersForAccount(sfAccountId) ); } diff --git a/apps/bff/src/modules/orders/services/order-pricebook.service.ts b/apps/bff/src/modules/orders/services/order-pricebook.service.ts index 329740e2..12866fec 100644 --- a/apps/bff/src/modules/orders/services/order-pricebook.service.ts +++ b/apps/bff/src/modules/orders/services/order-pricebook.service.ts @@ -16,12 +16,12 @@ import { export interface PricebookProductMeta { sku: string; pricebookEntryId: string; - product2Id?: string; - unitPrice?: number; - itemClass?: string; - internetOfferingType?: string; - internetPlanTier?: string; - vpnRegion?: string; + product2Id?: string | undefined; + unitPrice?: number | undefined; + itemClass?: string | undefined; + internetOfferingType?: string | undefined; + internetPlanTier?: string | undefined; + vpnRegion?: string | undefined; } @Injectable() @@ -72,9 +72,9 @@ export class OrderPricebookService { skus: string[] ): Promise> { const safePricebookId = assertSalesforceId(pricebookId, "pricebookId"); - const uniqueSkus = Array.from( - new Set(skus.map(sku => sku?.trim()).filter((sku): sku is string => Boolean(sku))) - ); + const uniqueSkus = [ + ...new Set(skus.map(sku => sku?.trim()).filter((sku): sku is string => Boolean(sku))), + ]; if (uniqueSkus.length === 0) { return new Map(); diff --git a/apps/bff/src/modules/orders/services/order-validator.service.ts b/apps/bff/src/modules/orders/services/order-validator.service.ts index d2fcf26e..541364bc 100644 --- a/apps/bff/src/modules/orders/services/order-validator.service.ts +++ b/apps/bff/src/modules/orders/services/order-validator.service.ts @@ -10,14 +10,14 @@ import { type OrderBusinessValidation, } from "@customer-portal/domain/orders"; import type * as Providers from "@customer-portal/domain/subscriptions/providers"; - -type WhmcsProduct = Providers.WhmcsProductRaw; import { SimServicesService } from "@bff/modules/services/application/sim-services.service.js"; import { InternetEligibilityService } from "@bff/modules/services/application/internet-eligibility.service.js"; import { OrderPricebookService, type PricebookProductMeta } from "./order-pricebook.service.js"; import { PaymentValidatorService } from "./payment-validator.service.js"; import { ResidenceCardService } from "@bff/modules/verification/residence-card.service.js"; +type WhmcsProduct = Providers.WhmcsProductRaw; + /** * Handles all order validation logic - both format and business rules * @@ -42,7 +42,7 @@ export class OrderValidator { */ async validateUserMapping( userId: string - ): Promise<{ userId: string; sfAccountId?: string; whmcsClientId: number }> { + ): Promise<{ userId: string; sfAccountId?: string | undefined; whmcsClientId: number }> { const mapping = await this.mappings.findByUserId(userId); if (!mapping) { @@ -76,7 +76,7 @@ export class OrderValidator { * In production, enforces the validation and blocks duplicate orders */ async validateInternetDuplication(userId: string, whmcsClientId: number): Promise { - const isDevelopment = process.env.NODE_ENV === "development"; + const isDevelopment = process.env["NODE_ENV"] === "development"; try { const products = await this.whmcs.getClientsProducts({ clientid: whmcsClientId }); @@ -182,7 +182,7 @@ export class OrderValidator { body: CreateOrderRequest ): Promise<{ validatedBody: OrderBusinessValidation; - userMapping: { userId: string; sfAccountId?: string; whmcsClientId: number }; + userMapping: { userId: string; sfAccountId?: string | undefined; whmcsClientId: number }; pricebookId: string; }> { this.logger.log({ userId }, "Starting complete order validation"); diff --git a/apps/bff/src/modules/orders/services/orders-cache.service.ts b/apps/bff/src/modules/orders/services/orders-cache.service.ts index fb712c6e..ff9a25af 100644 --- a/apps/bff/src/modules/orders/services/orders-cache.service.ts +++ b/apps/bff/src/modules/orders/services/orders-cache.service.ts @@ -94,11 +94,9 @@ export class OrdersCacheService { // Check Redis cache first const cached = await this.cache.get(key); - if (cached !== null) { - if (allowNull || cached !== null) { - this.metrics[bucket].hits++; - return cached; - } + if (cached !== null && (allowNull || cached !== null)) { + this.metrics[bucket].hits++; + return cached; } // Check for in-flight request (prevents thundering herd) diff --git a/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts b/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts index 15994e85..4dde7dd9 100644 --- a/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts +++ b/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts @@ -28,12 +28,12 @@ export class SimFulfillmentService { orderType: orderDetails.orderType, }); - const simType = this.readEnum(configurations.simType, ["eSIM", "Physical SIM"]) ?? "eSIM"; - const eid = this.readString(configurations.eid); + const simType = this.readEnum(configurations["simType"], ["eSIM", "Physical SIM"]) ?? "eSIM"; + const eid = this.readString(configurations["eid"]); const activationType = - this.readEnum(configurations.activationType, ["Immediate", "Scheduled"]) ?? "Immediate"; - const scheduledAt = this.readString(configurations.scheduledAt); - const phoneNumber = this.readString(configurations.mnpPhone); + this.readEnum(configurations["activationType"], ["Immediate", "Scheduled"]) ?? "Immediate"; + const scheduledAt = this.readString(configurations["scheduledAt"]); + const phoneNumber = this.readString(configurations["mnpPhone"]); const mnp = this.extractMnpConfig(configurations); const simPlanItem = orderDetails.items.find( @@ -81,8 +81,8 @@ export class SimFulfillmentService { planSku, simType: "eSIM", activationType, - scheduledAt, - mnp, + ...(scheduledAt !== undefined && { scheduledAt }), + ...(mnp !== undefined && { mnp }), }); } else { await this.activateSim({ @@ -90,8 +90,8 @@ export class SimFulfillmentService { planSku, simType: "Physical SIM", activationType, - scheduledAt, - mnp, + ...(scheduledAt !== undefined && { scheduledAt }), + ...(mnp !== undefined && { mnp }), }); } @@ -148,29 +148,29 @@ export class SimFulfillmentService { try { if (simType === "eSIM") { const { eid } = params; + const shipDate = activationType === "Scheduled" ? scheduledAt : undefined; + const mnpData = + mnp && mnp.reserveNumber && mnp.reserveExpireDate + ? { reserveNumber: mnp.reserveNumber, reserveExpireDate: mnp.reserveExpireDate } + : undefined; + const identityData = mnp + ? { + ...(mnp.firstnameKanji !== undefined && { firstnameKanji: mnp.firstnameKanji }), + ...(mnp.lastnameKanji !== undefined && { lastnameKanji: mnp.lastnameKanji }), + ...(mnp.firstnameZenKana !== undefined && { firstnameZenKana: mnp.firstnameZenKana }), + ...(mnp.lastnameZenKana !== undefined && { lastnameZenKana: mnp.lastnameZenKana }), + ...(mnp.gender !== undefined && { gender: mnp.gender }), + ...(mnp.birthday !== undefined && { birthday: mnp.birthday }), + } + : undefined; await this.freebit.activateEsimAccountNew({ account, eid, planCode: planSku, contractLine: "5G", - shipDate: activationType === "Scheduled" ? scheduledAt : undefined, - mnp: - mnp && mnp.reserveNumber && mnp.reserveExpireDate - ? { - reserveNumber: mnp.reserveNumber, - reserveExpireDate: mnp.reserveExpireDate, - } - : undefined, - identity: mnp - ? { - firstnameKanji: mnp.firstnameKanji, - lastnameKanji: mnp.lastnameKanji, - firstnameZenKana: mnp.firstnameZenKana, - lastnameZenKana: mnp.lastnameZenKana, - gender: mnp.gender, - birthday: mnp.birthday, - } - : undefined, + ...(shipDate !== undefined && { shipDate }), + ...(mnpData !== undefined && { mnp: mnpData }), + ...(identityData !== undefined && { identity: identityData }), }); this.logger.log("eSIM activated successfully", { @@ -179,9 +179,12 @@ export class SimFulfillmentService { scheduled: activationType === "Scheduled", }); } else { - await this.freebit.topUpSim(account, 0, { - scheduledAt: activationType === "Scheduled" ? scheduledAt : undefined, - }); + const topUpOptions: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = + {}; + if (activationType === "Scheduled" && scheduledAt !== undefined) { + topUpOptions.scheduledAt = scheduledAt; + } + await this.freebit.topUpSim(account, 0, topUpOptions); this.logger.log("Physical SIM activation scheduled", { account, @@ -207,28 +210,28 @@ export class SimFulfillmentService { } private extractMnpConfig(config: Record) { - const nested = config.mnp; + const nested = config["mnp"]; const source = nested && typeof nested === "object" ? (nested as Record) : config; - const isMnpFlag = this.readString(source.isMnp ?? config.isMnp); + const isMnpFlag = this.readString(source["isMnp"] ?? config["isMnp"]); if (isMnpFlag && isMnpFlag !== "true") { - return undefined; + return; } - const reserveNumber = this.readString(source.mnpNumber ?? source.reserveNumber); - const reserveExpireDate = this.readString(source.mnpExpiry ?? source.reserveExpireDate); - const account = this.readString(source.mvnoAccountNumber ?? source.account); - const firstnameKanji = this.readString(source.portingFirstName ?? source.firstnameKanji); - const lastnameKanji = this.readString(source.portingLastName ?? source.lastnameKanji); + const reserveNumber = this.readString(source["mnpNumber"] ?? source["reserveNumber"]); + const reserveExpireDate = this.readString(source["mnpExpiry"] ?? source["reserveExpireDate"]); + const account = this.readString(source["mvnoAccountNumber"] ?? source["account"]); + const firstnameKanji = this.readString(source["portingFirstName"] ?? source["firstnameKanji"]); + const lastnameKanji = this.readString(source["portingLastName"] ?? source["lastnameKanji"]); const firstnameZenKana = this.readString( - source.portingFirstNameKatakana ?? source.firstnameZenKana + source["portingFirstNameKatakana"] ?? source["firstnameZenKana"] ); const lastnameZenKana = this.readString( - source.portingLastNameKatakana ?? source.lastnameZenKana + source["portingLastNameKatakana"] ?? source["lastnameZenKana"] ); - const gender = this.readString(source.portingGender ?? source.gender); - const birthday = this.readString(source.portingDateOfBirth ?? source.birthday); + const gender = this.readString(source["portingGender"] ?? source["gender"]); + const birthday = this.readString(source["portingDateOfBirth"] ?? source["birthday"]); if ( !reserveNumber && @@ -241,19 +244,19 @@ export class SimFulfillmentService { !gender && !birthday ) { - return undefined; + return; } return { - reserveNumber, - reserveExpireDate, - account, - firstnameKanji, - lastnameKanji, - firstnameZenKana, - lastnameZenKana, - gender, - birthday, + ...(reserveNumber !== undefined && { reserveNumber }), + ...(reserveExpireDate !== undefined && { reserveExpireDate }), + ...(account !== undefined && { account }), + ...(firstnameKanji !== undefined && { firstnameKanji }), + ...(lastnameKanji !== undefined && { lastnameKanji }), + ...(firstnameZenKana !== undefined && { firstnameZenKana }), + ...(lastnameZenKana !== undefined && { lastnameZenKana }), + ...(gender !== undefined && { gender }), + ...(birthday !== undefined && { birthday }), }; } } diff --git a/apps/bff/src/modules/realtime/realtime.controller.ts b/apps/bff/src/modules/realtime/realtime.controller.ts index 44e86a5e..b1df53af 100644 --- a/apps/bff/src/modules/realtime/realtime.controller.ts +++ b/apps/bff/src/modules/realtime/realtime.controller.ts @@ -9,8 +9,7 @@ import { UseGuards, } from "@nestjs/common"; import type { MessageEvent } from "@nestjs/common"; -import { Observable } from "rxjs"; -import { merge } from "rxjs"; +import { Observable, merge } from "rxjs"; import { finalize } from "rxjs/operators"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import { RealtimeService } from "@bff/infra/realtime/realtime.service.js"; diff --git a/apps/bff/src/modules/services/application/internet-eligibility.service.ts b/apps/bff/src/modules/services/application/internet-eligibility.service.ts index 28f5a896..cb4211eb 100644 --- a/apps/bff/src/modules/services/application/internet-eligibility.service.ts +++ b/apps/bff/src/modules/services/application/internet-eligibility.service.ts @@ -8,8 +8,10 @@ import { OpportunityResolutionService } from "@bff/integrations/salesforce/servi import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js"; import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; -import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js"; -import { assertSoqlFieldName } from "@bff/integrations/salesforce/utils/soql.util.js"; +import { + assertSalesforceId, + assertSoqlFieldName, +} from "@bff/integrations/salesforce/utils/soql.util.js"; import type { InternetEligibilityDetails, InternetEligibilityStatus, @@ -19,12 +21,12 @@ import type { InternetEligibilityCheckRequest } from "./internet-eligibility.typ import type { SalesforceResponse } from "@customer-portal/domain/common/providers"; function formatAddressForLog(address: Record): string { - const address1 = typeof address.address1 === "string" ? address.address1.trim() : ""; - const address2 = typeof address.address2 === "string" ? address.address2.trim() : ""; - const city = typeof address.city === "string" ? address.city.trim() : ""; - const state = typeof address.state === "string" ? address.state.trim() : ""; - const postcode = typeof address.postcode === "string" ? address.postcode.trim() : ""; - const country = typeof address.country === "string" ? address.country.trim() : ""; + const address1 = typeof address["address1"] === "string" ? address["address1"].trim() : ""; + const address2 = typeof address["address2"] === "string" ? address["address2"].trim() : ""; + const city = typeof address["city"] === "string" ? address["city"].trim() : ""; + const state = typeof address["state"] === "string" ? address["state"].trim() : ""; + const postcode = typeof address["postcode"] === "string" ? address["postcode"].trim() : ""; + const country = typeof address["country"] === "string" ? address["country"].trim() : ""; return [address1, address2, city, state, postcode, country].filter(Boolean).join(", "); } @@ -68,7 +70,7 @@ export class InternetEligibilityService { .getCachedEligibility(eligibilityKey, async () => this.queryEligibilityDetails(sfAccountId) ) - .then(data => { + .then(async data => { // Safety check: ensure the data matches the schema before returning. // This protects against cache corruption (e.g. missing fields treated as undefined). const result = internetEligibilityDetailsSchema.safeParse(data); @@ -204,7 +206,7 @@ export class InternetEligibilityService { }; const res = await queryAccount(selectBase, "base"); - const record = (res.records?.[0] as Record | undefined) ?? undefined; + const record = res.records?.[0] ?? undefined; if (!record) { return internetEligibilityDetailsSchema.parse({ status: "not_requested", @@ -225,16 +227,15 @@ export class InternetEligibilityService { const statusRaw = record[statusField]; const normalizedStatus = typeof statusRaw === "string" ? statusRaw.trim().toLowerCase() : ""; + const statusMap: Record = { + pending: "pending", + checking: "pending", + eligible: "eligible", + ineligible: "ineligible", + "not available": "ineligible", + }; const status: InternetEligibilityStatus = - normalizedStatus === "pending" || normalizedStatus === "checking" - ? "pending" - : normalizedStatus === "eligible" - ? "eligible" - : normalizedStatus === "ineligible" || normalizedStatus === "not available" - ? "ineligible" - : eligibility - ? "eligible" - : "not_requested"; + statusMap[normalizedStatus] ?? (eligibility ? "eligible" : "not_requested"); const requestedAtRaw = record[requestedAtField]; const checkedAtRaw = record[checkedAtField]; diff --git a/apps/bff/src/modules/services/application/internet-eligibility.types.ts b/apps/bff/src/modules/services/application/internet-eligibility.types.ts index 972deebb..75beae3d 100644 --- a/apps/bff/src/modules/services/application/internet-eligibility.types.ts +++ b/apps/bff/src/modules/services/application/internet-eligibility.types.ts @@ -2,6 +2,6 @@ import type { Address } from "@customer-portal/domain/customer"; export type InternetEligibilityCheckRequest = { email: string; - notes?: string; - address?: Partial
; + notes?: string | undefined; + address?: Partial
| undefined; }; diff --git a/apps/bff/src/modules/services/application/internet-services.service.ts b/apps/bff/src/modules/services/application/internet-services.service.ts index 6ba48a0f..360dbb48 100644 --- a/apps/bff/src/modules/services/application/internet-services.service.ts +++ b/apps/bff/src/modules/services/application/internet-services.service.ts @@ -28,7 +28,7 @@ import { InternetEligibilityService } from "./internet-eligibility.service.js"; export class InternetServicesService extends BaseServicesService { constructor( sf: SalesforceConnection, - private readonly config: ConfigService, + config: ConfigService, @Inject(Logger) logger: Logger, private mappingsService: MappingsService, private catalogCache: ServicesCacheService, diff --git a/apps/bff/src/modules/services/application/services-cache.service.ts b/apps/bff/src/modules/services/application/services-cache.service.ts index b88d3dfc..029f4e70 100644 --- a/apps/bff/src/modules/services/application/services-cache.service.ts +++ b/apps/bff/src/modules/services/application/services-cache.service.ts @@ -338,7 +338,9 @@ export class ServicesCacheService { } const record = payload as Record; - return record.__catalogCache === true && Object.prototype.hasOwnProperty.call(record, "value"); + return ( + record["__catalogCache"] === true && Object.prototype.hasOwnProperty.call(record, "value") + ); } /** @@ -346,7 +348,7 @@ export class ServicesCacheService { * Returns true if any entries were invalidated, false if no matches found */ async invalidateProducts(productIds: string[]): Promise { - const uniqueIds = Array.from(new Set((productIds ?? []).filter(Boolean))); + const uniqueIds = [...new Set((productIds ?? []).filter(Boolean))]; if (uniqueIds.length === 0) { return false; } @@ -357,7 +359,7 @@ export class ServicesCacheService { const indexKey = this.buildProductDependencyKey(productId); const index = await this.cache.get<{ keys?: string[] }>(indexKey); const keys = index?.keys ?? []; - keys.forEach(k => keysToInvalidate.add(k)); + for (const k of keys) keysToInvalidate.add(k); if (keys.length === 0) { continue; } @@ -419,7 +421,7 @@ export class ServicesCacheService { if (!productIds || productIds.length === 0) { return undefined; } - return { productIds: Array.from(new Set(productIds)) }; + return { productIds: [...new Set(productIds)] }; } private async linkDependencies( diff --git a/apps/bff/src/modules/services/application/sim-services.service.ts b/apps/bff/src/modules/services/application/sim-services.service.ts index 3dd5e4f0..64298442 100644 --- a/apps/bff/src/modules/services/application/sim-services.service.ts +++ b/apps/bff/src/modules/services/application/sim-services.service.ts @@ -91,13 +91,16 @@ export class SimServicesService extends BaseServicesService { this.logger.warn( "No default SIM activation fee configured. Marking the first fee as default." ); - activationFees[0] = { - ...activationFees[0], - catalogMetadata: { - ...activationFees[0].catalogMetadata, - isDefault: true, - }, - }; + const firstFee = activationFees[0]; + if (firstFee) { + activationFees[0] = { + ...firstFee, + catalogMetadata: { + ...firstFee.catalogMetadata, + isDefault: true, + }, + }; + } } return activationFees; diff --git a/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts b/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts index b85b3532..52d6249f 100644 --- a/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts +++ b/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts @@ -108,7 +108,7 @@ export class InternetCancellationService { subscription: { id: Number(subscription.id), productName: productName, - amount: parseFloat(String(subscription.amount || subscription.recurringamount || 0)), + amount: Number.parseFloat(String(subscription.amount || subscription.recurringamount || 0)), nextDue: String(subscription.nextduedate || ""), registrationDate: String(subscription.regdate || ""), }, diff --git a/apps/bff/src/modules/subscriptions/sim-management.service.ts b/apps/bff/src/modules/subscriptions/sim-management.service.ts index 69a45674..b4c6c4b6 100644 --- a/apps/bff/src/modules/subscriptions/sim-management.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management.service.ts @@ -1,6 +1,5 @@ import { Injectable } from "@nestjs/common"; import { SimOrchestratorService } from "./sim-management/services/sim-orchestrator.service.js"; -import { SimNotificationService } from "./sim-management/services/sim-notification.service.js"; import type { SimDetails, SimUsage, @@ -12,23 +11,10 @@ import type { SimFeaturesUpdateRequest, SimReissueRequest, } from "@customer-portal/domain/sim"; -import type { SimNotificationContext } from "./sim-management/interfaces/sim-base.interface.js"; @Injectable() export class SimManagementService { - constructor( - private readonly simOrchestrator: SimOrchestratorService, - private readonly simNotification: SimNotificationService - ) {} - - // Delegate to notification service for backward compatibility - private async notifySimAction( - action: string, - status: "SUCCESS" | "ERROR", - context: SimNotificationContext - ): Promise { - return this.simNotification.notifySimAction(action, status, context); - } + constructor(private readonly simOrchestrator: SimOrchestratorService) {} /** * Debug method to check subscription data for SIM services @@ -135,11 +121,4 @@ export class SimManagementService { }> { return this.simOrchestrator.getSimInfo(userId, subscriptionId); } - - /** - * Convert technical errors to user-friendly messages for SIM operations - */ - private getUserFriendlySimError(technicalError: string): string { - return this.simNotification.getUserFriendlySimError(technicalError); - } } diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-billing.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-billing.service.ts index 56081db4..120c1499 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-billing.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-billing.service.ts @@ -47,15 +47,15 @@ export class SimBillingService { description, amount: amountJpy, currency, - dueDate, - notes, + ...(dueDate === undefined ? {} : { dueDate }), + ...(notes === undefined ? {} : { notes }), }); const paymentResult = await this.whmcsInvoiceService.capturePayment({ invoiceId: invoice.id, amount: amountJpy, currency, - userId, + ...(userId === undefined ? {} : { userId }), }); if (!paymentResult.success) { @@ -85,7 +85,9 @@ export class SimBillingService { return { invoice, - transactionId: paymentResult.transactionId, + ...(paymentResult.transactionId === undefined + ? {} + : { transactionId: paymentResult.transactionId }), }; } diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history-formatter.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history-formatter.service.ts index 30a55737..72b58c66 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history-formatter.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history-formatter.service.ts @@ -12,7 +12,7 @@ export class SimCallHistoryFormatterService { formatTime(timeStr: string): string { if (!timeStr || timeStr.length < 6) return timeStr; const clean = timeStr.replace(/[^0-9]/g, "").padStart(6, "0"); - return `${clean.substring(0, 2)}:${clean.substring(2, 4)}:${clean.substring(4, 6)}`; + return `${clean.slice(0, 2)}:${clean.slice(2, 4)}:${clean.slice(4, 6)}`; } /** @@ -44,12 +44,12 @@ export class SimCallHistoryFormatterService { clean.length === 11 && (clean.startsWith("080") || clean.startsWith("070") || clean.startsWith("090")) ) { - return `${clean.substring(0, 3)}-${clean.substring(3, 7)}-${clean.substring(7)}`; + return `${clean.slice(0, 3)}-${clean.slice(3, 7)}-${clean.slice(7)}`; } // 03-XXXX-XXXX format (landline) if (clean.length === 10 && clean.startsWith("0")) { - return `${clean.substring(0, 2)}-${clean.substring(2, 6)}-${clean.substring(6)}`; + return `${clean.slice(0, 2)}-${clean.slice(2, 6)}-${clean.slice(6)}`; } return clean; @@ -79,6 +79,6 @@ export class SimCallHistoryFormatterService { * Convert YYYYMM to YYYY-MM format */ normalizeMonth(yearMonth: string): string { - return `${yearMonth.substring(0, 4)}-${yearMonth.substring(4, 6)}`; + return `${yearMonth.slice(0, 4)}-${yearMonth.slice(4, 6)}`; } } diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history-parser.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history-parser.service.ts index a51277c6..9b7db675 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history-parser.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history-parser.service.ts @@ -94,15 +94,15 @@ export class SimCallHistoryParserService { } const [ - phoneNumber, - dateStr, - timeStr, - calledTo, - callType, - location, - durationStr, - tokensStr, - altChargeStr, + phoneNumber = "", + dateStr = "", + timeStr = "", + calledTo = "", + callType = "", + location = "", + durationStr = "", + tokensStr = "", + altChargeStr = "", ] = columns; // Parse date @@ -178,7 +178,8 @@ export class SimCallHistoryParserService { continue; } - const [phoneNumber, dateStr, timeStr, sentTo, , smsTypeStr] = columns; + const [phoneNumber = "", dateStr = "", timeStr = "", sentTo = "", , smsTypeStr = ""] = + columns; // Parse date const smsDate = this.parseDate(dateStr); @@ -253,11 +254,11 @@ export class SimCallHistoryParserService { const clean = dateStr.replace(/[^0-9]/g, ""); if (clean.length < 8) return null; - const year = parseInt(clean.substring(0, 4), 10); - const month = parseInt(clean.substring(4, 6), 10) - 1; - const day = parseInt(clean.substring(6, 8), 10); + const year = Number.parseInt(clean.slice(0, 4), 10); + const month = Number.parseInt(clean.slice(4, 6), 10) - 1; + const day = Number.parseInt(clean.slice(6, 8), 10); - if (isNaN(year) || isNaN(month) || isNaN(day)) return null; + if (Number.isNaN(year) || Number.isNaN(month) || Number.isNaN(day)) return null; return new Date(year, month, day); } @@ -267,8 +268,8 @@ export class SimCallHistoryParserService { */ private parseDuration(durationStr: string): number { const durationVal = durationStr.padStart(5, "0"); - const minutes = parseInt(durationVal.slice(0, -3), 10) || 0; - const seconds = parseInt(durationVal.slice(-3, -1), 10) || 0; + const minutes = Number.parseInt(durationVal.slice(0, -3), 10) || 0; + const seconds = Number.parseInt(durationVal.slice(-3, -1), 10) || 0; return minutes * 60 + seconds; } @@ -281,9 +282,9 @@ export class SimCallHistoryParserService { altChargeStr: string | undefined ): number { if (location && location.includes("他社") && altChargeStr) { - return parseInt(altChargeStr, 10) || 0; + return Number.parseInt(altChargeStr, 10) || 0; } - return (parseInt(tokensStr, 10) || 0) * 10; + return (Number.parseInt(tokensStr, 10) || 0) * 10; } /** diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history.service.ts index 86bfbc79..1eb03c04 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history.service.ts @@ -80,7 +80,7 @@ export class SimCallHistoryService { domesticCount = await this.processInBatches( parsed.domestic, 50, - record => + async record => this.prisma.simCallHistoryDomestic.upsert({ where: { account_callDate_callTime_calledTo: { @@ -104,7 +104,7 @@ export class SimCallHistoryService { internationalCount = await this.processInBatches( parsed.international, 50, - record => + async record => this.prisma.simCallHistoryInternational.upsert({ where: { account_callDate_startTime_calledTo: { @@ -147,7 +147,7 @@ export class SimCallHistoryService { smsCount = await this.processInBatches( parsed.records, 50, - record => + async record => this.prisma.simSmsHistory.upsert({ where: { account_smsDate_smsTime_sentTo: { @@ -234,7 +234,7 @@ export class SimCallHistoryService { chargeYen: number; }) => ({ id: call.id, - date: call.callDate.toISOString().split("T")[0], + date: call.callDate.toISOString().split("T")[0] ?? "", time: this.formatter.formatTime(call.callTime), calledTo: this.formatter.formatPhoneNumber(call.calledTo), callLength: this.formatter.formatDuration(call.durationSec), @@ -290,7 +290,7 @@ export class SimCallHistoryService { chargeYen: number; }) => ({ id: call.id, - date: call.callDate.toISOString().split("T")[0], + date: call.callDate.toISOString().split("T")[0] ?? "", startTime: this.formatter.formatTime(call.startTime), stopTime: call.stopTime ? this.formatter.formatTime(call.stopTime) : null, country: call.country, @@ -345,7 +345,7 @@ export class SimCallHistoryService { smsType: SmsType; }) => ({ id: msg.id, - date: msg.smsDate.toISOString().split("T")[0], + date: msg.smsDate.toISOString().split("T")[0] ?? "", time: this.formatter.formatTime(msg.smsTime), sentTo: this.formatter.formatPhoneNumber(msg.sentTo), type: msg.smsType === "INTERNATIONAL" ? "International SMS" : "SMS", @@ -400,15 +400,18 @@ export class SimCallHistoryService { for (let i = 0; i < records.length; i += batchSize) { const batch = records.slice(i, i + batchSize); - const results = await Promise.allSettled(batch.map(record => handler(record))); + const results = await Promise.allSettled(batch.map(async record => handler(record))); - results.forEach((result, index) => { + for (const [index, result] of results.entries()) { if (result.status === "fulfilled") { successCount++; } else { - onError(batch[index], result.reason); + const record = batch[index]; + if (record !== undefined) { + onError(record, result.reason); + } } - }); + } } return successCount; diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts index b2f0297a..e727940f 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts @@ -52,11 +52,11 @@ export class SimCancellationService { if (!startDateStr || startDateStr.length < 8) return null; // Parse YYYYMMDD format - const year = parseInt(startDateStr.substring(0, 4), 10); - const month = parseInt(startDateStr.substring(4, 6), 10) - 1; - const day = parseInt(startDateStr.substring(6, 8), 10); + const year = Number.parseInt(startDateStr.slice(0, 4), 10); + const month = Number.parseInt(startDateStr.slice(4, 6), 10) - 1; + const day = Number.parseInt(startDateStr.slice(6, 8), 10); - if (isNaN(year) || isNaN(month) || isNaN(day)) return null; + if (Number.isNaN(year) || Number.isNaN(month) || Number.isNaN(day)) return null; const startDate = new Date(year, month, day); // Minimum term is 3 months after signup month (signup month not included) @@ -256,7 +256,7 @@ export class SimCancellationService { try { const caseResult = await this.caseService.createCase({ accountId: mapping.sfAccountId, - opportunityId: opportunityId || undefined, + ...(opportunityId ? { opportunityId } : {}), subject: `Cancellation Request - SIM (${request.cancellationMonth})`, description: descriptionLines.join("\n"), origin: SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION, @@ -324,10 +324,10 @@ export class SimCancellationService { const adminEmailBody = this.simNotification.buildCancellationAdminEmail({ customerName, simNumber: account, - serialNumber: simDetails.iccid, + ...(simDetails.iccid === undefined ? {} : { serialNumber: simDetails.iccid }), cancellationMonth: request.cancellationMonth, registeredEmail: customerEmail, - comments: request.comments, + ...(request.comments === undefined ? {} : { comments: request.comments }), }); await this.simNotification.sendApiResultsEmail( diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-notification.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-notification.service.ts index e50c7f38..a5e41b46 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-notification.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-notification.service.ts @@ -334,7 +334,7 @@ Comments: ${params.comments || "N/A"}`; } if (typeof value === "string" && value.length > 200) { - sanitized[key] = `${value.substring(0, 200)}…`; + sanitized[key] = `${value.slice(0, 200)}…`; continue; } diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-orchestrator.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-orchestrator.service.ts index c043eec5..50cfd10f 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-orchestrator.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-orchestrator.service.ts @@ -9,8 +9,11 @@ import { EsimManagementService } from "./esim-management.service.js"; import { SimValidationService } from "./sim-validation.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { simInfoSchema } from "@customer-portal/domain/sim"; -import type { SimInfo, SimDetails, SimTopUpHistory, SimUsage } from "@customer-portal/domain/sim"; import type { + SimInfo, + SimDetails, + SimTopUpHistory, + SimUsage, SimTopUpRequest, SimPlanChangeRequest, SimCancelRequest, @@ -120,15 +123,15 @@ export class SimOrchestratorService { // If Freebit doesn't return remaining quota, derive it from plan code (e.g., PASI_50G) // by subtracting measured usage (today + recentDays) from the plan cap. - const normalizeNumber = (n: number) => (isFinite(n) && n > 0 ? n : 0); + const normalizeNumber = (n: number) => (Number.isFinite(n) && n > 0 ? n : 0); const usedMb = normalizeNumber(usage.todayUsageMb) + (usage.recentDaysUsage || []).reduce((sum, d) => sum + normalizeNumber(d.usageMb), 0); const planCapMatch = (details.planCode || "").match(/(\d+)\s*G/i); if ((details.remainingQuotaMb === 0 || details.remainingQuotaMb == null) && planCapMatch) { - const capGb = parseInt(planCapMatch[1], 10); - if (!isNaN(capGb) && capGb > 0) { + const capGb = Number.parseInt(planCapMatch[1] ?? "", 10); + if (!Number.isNaN(capGb) && capGb > 0) { const capMb = capGb * 1000; const remainingMb = Math.max(capMb - usedMb, 0); details.remainingQuotaMb = Math.round(remainingMb * 100) / 100; diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts index b920d86c..238b2f03 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts @@ -170,8 +170,8 @@ export class SimPlanService { ); return { - ipv4: response.ipv4, - ipv6: response.ipv6, + ...(response.ipv4 === undefined ? {} : { ipv4: response.ipv4 }), + ...(response.ipv6 === undefined ? {} : { ipv6: response.ipv6 }), }; } @@ -247,8 +247,8 @@ export class SimPlanService { ); return { - ipv4: result.ipv4, - ipv6: result.ipv6, + ...(result.ipv4 === undefined ? {} : { ipv4: result.ipv4 }), + ...(result.ipv6 === undefined ? {} : { ipv6: result.ipv6 }), scheduledAt, }; } @@ -294,9 +294,15 @@ export class SimPlanService { if (doVoice && doContract) { await this.freebitService.updateSimFeatures(account, { - voiceMailEnabled: request.voiceMailEnabled, - callWaitingEnabled: request.callWaitingEnabled, - internationalRoamingEnabled: request.internationalRoamingEnabled, + ...(request.voiceMailEnabled === undefined + ? {} + : { voiceMailEnabled: request.voiceMailEnabled }), + ...(request.callWaitingEnabled === undefined + ? {} + : { callWaitingEnabled: request.callWaitingEnabled }), + ...(request.internationalRoamingEnabled === undefined + ? {} + : { internationalRoamingEnabled: request.internationalRoamingEnabled }), }); await this.simQueue.scheduleNetworkTypeChange({ @@ -313,7 +319,26 @@ export class SimPlanService { networkType: request.networkType, }); } else { - await this.freebitService.updateSimFeatures(account, request); + // Filter out undefined values to satisfy exactOptionalPropertyTypes + const filteredRequest: { + voiceMailEnabled?: boolean; + callWaitingEnabled?: boolean; + internationalRoamingEnabled?: boolean; + networkType?: "4G" | "5G"; + } = {}; + if (request.voiceMailEnabled !== undefined) { + filteredRequest.voiceMailEnabled = request.voiceMailEnabled; + } + if (request.callWaitingEnabled !== undefined) { + filteredRequest.callWaitingEnabled = request.callWaitingEnabled; + } + if (request.internationalRoamingEnabled !== undefined) { + filteredRequest.internationalRoamingEnabled = request.internationalRoamingEnabled; + } + if (request.networkType !== undefined) { + filteredRequest.networkType = request.networkType; + } + await this.freebitService.updateSimFeatures(account, filteredRequest); } this.logger.log(`Updated SIM features for subscription ${subscriptionId}`, { diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-schedule.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-schedule.service.ts index bf5f9c8a..641c77cb 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-schedule.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-schedule.service.ts @@ -66,6 +66,6 @@ export class SimScheduleService { } formatIsoDate(date: Date): string { - return date.toISOString().split("T")[0]; + return date.toISOString().split("T")[0] ?? ""; } } diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-usage.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-usage.service.ts index f4478420..cc4a1771 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-usage.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-usage.service.ts @@ -4,8 +4,11 @@ import { FreebitOperationsService } from "@bff/integrations/freebit/services/fre import { SimValidationService } from "./sim-validation.service.js"; import { SimUsageStoreService } from "../../sim-usage-store.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; -import type { SimTopUpHistory, SimUsage } from "@customer-portal/domain/sim"; -import type { SimTopUpHistoryRequest } from "@customer-portal/domain/sim"; +import type { + SimTopUpHistory, + SimUsage, + SimTopUpHistoryRequest, +} from "@customer-portal/domain/sim"; import { SimScheduleService } from "./sim-schedule.service.js"; @Injectable() diff --git a/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts b/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts index a0465958..07c3362b 100644 --- a/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts @@ -126,13 +126,17 @@ export class SimOrderActivationService { eid: req.eid!, planCode: req.planSku, contractLine: "5G", - shipDate: req.activationType === "Scheduled" ? req.scheduledAt : undefined, - mnp: req.mnp + ...(req.activationType === "Scheduled" && req.scheduledAt !== undefined + ? { shipDate: req.scheduledAt } + : {}), + ...(req.mnp ? { - reserveNumber: req.mnp.reserveNumber || "", - reserveExpireDate: req.mnp.reserveExpireDate || "", + mnp: { + reserveNumber: req.mnp.reserveNumber || "", + reserveExpireDate: req.mnp.reserveExpireDate || "", + }, } - : undefined, + : {}), }); if (req.addons && (req.addons.voiceMail || req.addons.callWaiting)) { @@ -180,10 +184,12 @@ export class SimOrderActivationService { invoiceId: billingResult.invoice.id, }); - const result = { + const result: { success: boolean; invoiceId: number; transactionId?: string } = { success: true, invoiceId: billingResult.invoice.id, - transactionId: billingResult.transactionId, + ...(billingResult.transactionId === undefined + ? {} + : { transactionId: billingResult.transactionId }), }; await this.cache.set(cacheKey, result, 86400); diff --git a/apps/bff/src/modules/subscriptions/sim-usage-store.service.ts b/apps/bff/src/modules/subscriptions/sim-usage-store.service.ts index 9cdeae3e..dead4655 100644 --- a/apps/bff/src/modules/subscriptions/sim-usage-store.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-usage-store.service.ts @@ -65,7 +65,10 @@ export class SimUsageStoreService { where: { account, date: { gte: start, lte: end } }, orderBy: { date: "desc" }, })) as Array<{ date: Date; usageMb: number }>; - return rows.map(r => ({ date: r.date.toISOString().split("T")[0], usageMb: r.usageMb })); + return rows.map(r => ({ + date: r.date.toISOString().split("T")[0] ?? "", + usageMb: r.usageMb, + })); } async cleanupPreviousMonths(): Promise { diff --git a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts index 08d72923..6be0b8fb 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts @@ -57,7 +57,10 @@ export class SubscriptionsController { @Query() query: SubscriptionQueryDto ): Promise { const { status } = query; - return this.subscriptionsService.getSubscriptions(req.user.id, { status }); + return this.subscriptionsService.getSubscriptions( + req.user.id, + status === undefined ? {} : { status } + ); } @Get("active") diff --git a/apps/bff/src/modules/subscriptions/subscriptions.service.ts b/apps/bff/src/modules/subscriptions/subscriptions.service.ts index 466131e0..230cfc2c 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.service.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.service.ts @@ -1,4 +1,3 @@ -import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { Injectable, Inject, BadRequestException } from "@nestjs/common"; import { subscriptionListSchema, @@ -19,9 +18,7 @@ import { WhmcsSubscriptionService } from "@bff/integrations/whmcs/services/whmcs import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { Logger } from "nestjs-pino"; import { withErrorHandling } from "@bff/core/utils/error-handler.util.js"; -import type * as Providers from "@customer-portal/domain/subscriptions/providers"; - -type WhmcsProduct = Providers.WhmcsProductRaw; +import { extractErrorMessage } from "@bff/core/utils/error.util.js"; export interface GetSubscriptionsOptions { status?: SubscriptionStatus; @@ -54,7 +51,7 @@ export class SubscriptionsService { const subscriptionList = await this.whmcsSubscriptionService.getSubscriptions( whmcsClientId, userId, - { status } + status === undefined ? {} : { status } ); const parsed = subscriptionListSchema.parse(subscriptionList); @@ -354,9 +351,9 @@ export class SubscriptionsService { totalPages = invoiceBatch.pagination.totalPages; - invoiceBatch.invoices.forEach(invoice => { + for (const invoice of invoiceBatch.invoices) { if (!invoice.items?.length) { - return; + continue; } const hasMatchingService = invoice.items.some( @@ -366,7 +363,7 @@ export class SubscriptionsService { if (hasMatchingService) { relatedInvoices.push(invoice); } - }); + } currentPage += 1; } while (currentPage <= totalPages); @@ -466,34 +463,4 @@ export class SubscriptionsService { }; } } - - private async checkUserHasExistingSim(userId: string): Promise { - try { - const mapping = await this.mappingsService.findByUserId(userId); - if (!mapping?.whmcsClientId) { - return false; - } - - const productsResponse = await this.whmcsConnectionService.getClientsProducts({ - clientid: mapping.whmcsClientId, - }); - const productContainer = productsResponse.products?.product; - const services = Array.isArray(productContainer) - ? productContainer - : productContainer - ? [productContainer] - : []; - - return services.some((service: WhmcsProduct) => { - const group = typeof service.groupname === "string" ? service.groupname.toLowerCase() : ""; - const status = typeof service.status === "string" ? service.status.toLowerCase() : ""; - return group.includes("sim") && status === "active"; - }); - } catch (error: unknown) { - this.logger.warn(`Failed to check existing SIM for user ${userId}`, { - error: extractErrorMessage(error), - }); - return false; - } - } } diff --git a/apps/bff/src/modules/support/support.service.ts b/apps/bff/src/modules/support/support.service.ts index 709234fd..cc7a1cf1 100644 --- a/apps/bff/src/modules/support/support.service.ts +++ b/apps/bff/src/modules/support/support.service.ts @@ -124,7 +124,7 @@ export class SupportService { const result = await this.caseService.createCase({ subject: request.subject, description: request.description, - priority: request.priority, + ...(request.priority === undefined ? {} : { priority: request.priority }), accountId, origin: SALESFORCE_CASE_ORIGIN.PORTAL_SUPPORT, }); @@ -163,7 +163,7 @@ export class SupportService { description: `Contact from: ${request.name}\nEmail: ${request.email}\nPhone: ${request.phone || "Not provided"}\n\n${request.message}`, suppliedEmail: request.email, suppliedName: request.name, - suppliedPhone: request.phone, + ...(request.phone === undefined ? {} : { suppliedPhone: request.phone }), origin: "Web", priority: "Medium", }); diff --git a/apps/bff/src/modules/users/application/users.facade.ts b/apps/bff/src/modules/users/application/users.facade.ts index 5b2bf7c6..118e836b 100644 --- a/apps/bff/src/modules/users/application/users.facade.ts +++ b/apps/bff/src/modules/users/application/users.facade.ts @@ -1,8 +1,7 @@ import { Injectable, Inject, BadRequestException, NotFoundException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import type { User as PrismaUser } from "@prisma/client"; -import type { User } from "@customer-portal/domain/customer"; -import type { Address } from "@customer-portal/domain/customer"; +import type { User, Address } from "@customer-portal/domain/customer"; import type { BilingualAddress } from "@customer-portal/domain/address"; import type { DashboardSummary } from "@customer-portal/domain/dashboard"; import type { UpdateCustomerProfileRequest } from "@customer-portal/domain/auth"; diff --git a/apps/bff/src/modules/users/infra/user-profile.service.ts b/apps/bff/src/modules/users/infra/user-profile.service.ts index b811010b..2f004ce2 100644 --- a/apps/bff/src/modules/users/infra/user-profile.service.ts +++ b/apps/bff/src/modules/users/infra/user-profile.service.ts @@ -328,12 +328,12 @@ export class UserProfileService { let recentInvoices: Array<{ id: number; status: string; - dueDate?: string; + dueDate?: string | undefined; total: number; number: string; - issuedAt?: string; - paidDate?: string; - currency?: string | null; + issuedAt?: string | undefined; + paidDate?: string | undefined; + currency?: string | null | undefined; }> = []; // Process unpaid invoices count @@ -365,12 +365,14 @@ export class UserProfileService { if (upcomingInvoices.length > 0) { const invoice = upcomingInvoices[0]; - nextInvoice = { - id: invoice.id, - dueDate: invoice.dueDate!, - amount: invoice.total, - currency: invoice.currency ?? "JPY", - }; + if (invoice) { + nextInvoice = { + id: invoice.id, + dueDate: invoice.dueDate!, + amount: invoice.total, + currency: invoice.currency ?? "JPY", + }; + } } recentInvoices = invoices @@ -397,14 +399,14 @@ export class UserProfileService { const activities: Activity[] = []; - recentInvoices.forEach(invoice => { + for (const invoice of recentInvoices) { if (invoice.status === "Paid") { const metadata: Record = { amount: invoice.total, currency: invoice.currency ?? "JPY", }; - if (invoice.dueDate) metadata.dueDate = invoice.dueDate; - if (invoice.number) metadata.invoiceNumber = invoice.number; + if (invoice.dueDate) metadata["dueDate"] = invoice.dueDate; + if (invoice.number) metadata["invoiceNumber"] = invoice.number; activities.push({ id: `invoice-paid-${invoice.id}`, type: "invoice_paid", @@ -420,8 +422,8 @@ export class UserProfileService { currency: invoice.currency ?? "JPY", status: invoice.status, }; - if (invoice.dueDate) metadata.dueDate = invoice.dueDate; - if (invoice.number) metadata.invoiceNumber = invoice.number; + if (invoice.dueDate) metadata["dueDate"] = invoice.dueDate; + if (invoice.number) metadata["invoiceNumber"] = invoice.number; activities.push({ id: `invoice-created-${invoice.id}`, type: "invoice_created", @@ -432,15 +434,15 @@ export class UserProfileService { metadata, }); } - }); + } - recentSubscriptions.forEach(subscription => { + for (const subscription of recentSubscriptions) { const metadata: Record = { productName: subscription.productName, status: subscription.status, }; if (subscription.registrationDate) { - metadata.registrationDate = subscription.registrationDate; + metadata["registrationDate"] = subscription.registrationDate; } activities.push({ id: `service-activated-${subscription.id}`, @@ -451,7 +453,7 @@ export class UserProfileService { relatedId: subscription.id, metadata, }); - }); + } activities.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); const recentActivity = activities.slice(0, 10); diff --git a/apps/bff/src/modules/users/users.controller.ts b/apps/bff/src/modules/users/users.controller.ts index 7a809714..379544ad 100644 --- a/apps/bff/src/modules/users/users.controller.ts +++ b/apps/bff/src/modules/users/users.controller.ts @@ -14,8 +14,7 @@ import { updateCustomerProfileRequestSchema } from "@customer-portal/domain/auth import { dashboardSummarySchema } from "@customer-portal/domain/dashboard"; import { addressSchema, userSchema } from "@customer-portal/domain/customer"; import { bilingualAddressSchema } from "@customer-portal/domain/address"; -import type { Address } from "@customer-portal/domain/customer"; -import type { User } from "@customer-portal/domain/customer"; +import type { Address, User } from "@customer-portal/domain/customer"; import type { RequestWithUser, RequestWithOptionalUser } from "@bff/modules/auth/auth.types.js"; import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js"; import { OptionalAuth } from "@bff/modules/auth/decorators/public.decorator.js"; diff --git a/apps/bff/src/modules/verification/residence-card.service.ts b/apps/bff/src/modules/verification/residence-card.service.ts index f93fefa9..5465ae9b 100644 --- a/apps/bff/src/modules/verification/residence-card.service.ts +++ b/apps/bff/src/modules/verification/residence-card.service.ts @@ -67,20 +67,19 @@ export class ResidenceCardService { label: "verification:residence_card:account", })) as SalesforceResponse>; - const account = (accountRes.records?.[0] as Record | undefined) ?? undefined; + const account = accountRes.records?.[0] ?? undefined; const statusRaw = account ? account[fields.status] : undefined; const statusText = typeof statusRaw === "string" ? statusRaw.trim().toLowerCase() : ""; - const status: ResidenceCardVerificationStatus = - statusText === "verified" - ? "verified" - : statusText === "rejected" - ? "rejected" - : statusText === "submitted" - ? "pending" - : statusText === "not submitted" || statusText === "not_submitted" || statusText === "" - ? "not_submitted" - : "pending"; + const statusMap: Record = { + verified: "verified", + rejected: "rejected", + submitted: "pending", + "not submitted": "not_submitted", + not_submitted: "not_submitted", + "": "not_submitted", + }; + const status: ResidenceCardVerificationStatus = statusMap[statusText] ?? "pending"; const submittedAtRaw = account ? account[fields.submittedAt] : undefined; const verifiedAtRaw = account ? account[fields.verifiedAt] : undefined; diff --git a/apps/bff/src/modules/voice-options/services/voice-options.service.ts b/apps/bff/src/modules/voice-options/services/voice-options.service.ts index e1a3a598..ff17b230 100644 --- a/apps/bff/src/modules/voice-options/services/voice-options.service.ts +++ b/apps/bff/src/modules/voice-options/services/voice-options.service.ts @@ -46,7 +46,15 @@ export class VoiceOptionsService { /** * Save or update voice options for a SIM account */ - async saveVoiceOptions(account: string, settings: Partial): Promise { + async saveVoiceOptions( + account: string, + settings: { + voiceMailEnabled?: boolean | undefined; + callWaitingEnabled?: boolean | undefined; + internationalRoamingEnabled?: boolean | undefined; + networkType?: string | undefined; + } + ): Promise { try { await this.prisma.simVoiceOptions.upsert({ where: { account }, diff --git a/apps/portal/scripts/stubs/core-api.ts b/apps/portal/scripts/stubs/core-api.ts index a21dca65..335cdb45 100644 --- a/apps/portal/scripts/stubs/core-api.ts +++ b/apps/portal/scripts/stubs/core-api.ts @@ -13,4 +13,4 @@ export const apiClient = { DELETE: () => Promise.resolve({ data: null } as const), }; -export const configureApiClientAuth = () => undefined; +export const configureApiClientAuth = () => {}; diff --git a/apps/portal/src/app/(public)/(site)/services/page.tsx b/apps/portal/src/app/(public)/(site)/services/page.tsx index 2870ad51..9c48566a 100644 --- a/apps/portal/src/app/(public)/(site)/services/page.tsx +++ b/apps/portal/src/app/(public)/(site)/services/page.tsx @@ -1,151 +1,5 @@ -import Link from "next/link"; -import { - Wifi, - Smartphone, - ShieldCheck, - ArrowRight, - Phone, - CheckCircle2, - Globe, - Headphones, - Building2, - Wrench, - Tv, -} from "lucide-react"; -import { ServiceCard } from "@/components/molecules/ServiceCard"; +import { PublicServicesOverview } from "@/features/services/views/PublicServicesOverview"; export default function ServicesPage() { - return ( -
- {/* Hero */} -
-
- - - Full English Support - -
- -

- Our Services -

- -

- Connectivity and support solutions for Japan's international community. -

-
- - {/* Value Props - Compact */} -
-
- - One provider, all services -
-
- - English support -
-
- - No hidden fees -
-
- - {/* All Services - Clean Grid with staggered animations */} -
- } - title="Internet" - description="NTT Optical Fiber for homes and apartments. Speeds up to 10Gbps with professional installation." - price="¥3,200/mo" - accentColor="blue" - /> - - } - title="SIM & eSIM" - description="Data, voice & SMS on NTT Docomo network. Physical SIM or instant eSIM activation." - price="¥1,100/mo" - badge="1st month free" - accentColor="green" - /> - - } - title="VPN Router" - description="Access US & UK streaming content with a pre-configured router. Simple plug-and-play." - price="¥2,500/mo" - accentColor="purple" - /> - - } - title="Business" - description="Enterprise solutions for offices and commercial spaces. Dedicated support and SLAs." - accentColor="orange" - /> - - } - title="Onsite Support" - description="Professional technicians visit your location for setup, troubleshooting, and maintenance." - accentColor="cyan" - /> - - } - title="TV" - description="Streaming TV packages with international channels. Watch content from home countries." - accentColor="pink" - /> -
- - {/* CTA */} -
-

Need help choosing?

-

- Our bilingual team can help you find the right solution. -

- -
- - Contact Us - - - - -
-
-
- ); + return ; } diff --git a/apps/portal/src/app/_health/route.ts b/apps/portal/src/app/_health/route.ts index ed59f4c4..0e8d8004 100644 --- a/apps/portal/src/app/_health/route.ts +++ b/apps/portal/src/app/_health/route.ts @@ -5,8 +5,8 @@ export function GET() { { status: "ok", timestamp: new Date().toISOString(), - environment: process.env.NODE_ENV || "development", - version: process.env.NEXT_PUBLIC_APP_VERSION || "unknown", + environment: process.env["NODE_ENV"] || "development", + version: process.env["NEXT_PUBLIC_APP_VERSION"] || "unknown", }, { status: 200 } ); diff --git a/apps/portal/src/app/account/services/page.tsx b/apps/portal/src/app/account/services/page.tsx index 94bc3d1c..2dc116a4 100644 --- a/apps/portal/src/app/account/services/page.tsx +++ b/apps/portal/src/app/account/services/page.tsx @@ -1,22 +1,5 @@ -import { ServicesGrid } from "@/features/services/components/common/ServicesGrid"; +import { AccountServicesOverview } from "@/features/services/views/AccountServicesOverview"; export default function AccountServicesPage() { - return ( -
-
- {/* Header */} -
-

- Our Services -

-

- From high-speed internet to onsite support, we provide comprehensive solutions for your - home and business. -

-
- - -
-
- ); + return ; } diff --git a/apps/portal/src/components/atoms/AnimatedContainer.tsx b/apps/portal/src/components/atoms/AnimatedContainer.tsx deleted file mode 100644 index 8b16dd47..00000000 --- a/apps/portal/src/components/atoms/AnimatedContainer.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import { cn } from "@/shared/utils"; - -interface AnimatedContainerProps { - children: React.ReactNode; - className?: string; - /** Animation type */ - animation?: "fade-up" | "fade-scale" | "slide-left" | "none"; - /** Whether to stagger children animations */ - stagger?: boolean; - /** Delay before animation starts in ms */ - delay?: number; -} - -/** - * Reusable animation wrapper component - * Provides consistent entrance animations for page content - */ -export function AnimatedContainer({ - children, - className, - animation = "fade-up", - stagger = false, - delay = 0, -}: AnimatedContainerProps) { - const animationClass = { - "fade-up": "cp-animate-in", - "fade-scale": "cp-animate-scale-in", - "slide-left": "cp-animate-slide-left", - none: "", - }[animation]; - - return ( -
0 ? { animationDelay: `${delay}ms` } : undefined} - > - {children} -
- ); -} diff --git a/apps/portal/src/components/atoms/LoadingOverlay.tsx b/apps/portal/src/components/atoms/LoadingOverlay.tsx deleted file mode 100644 index 4a2399db..00000000 --- a/apps/portal/src/components/atoms/LoadingOverlay.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Spinner } from "./Spinner"; - -interface LoadingOverlayProps { - /** Whether the overlay is visible */ - isVisible: boolean; - /** Main loading message */ - title: string; - /** Optional subtitle/description */ - subtitle?: string; - /** Spinner size */ - spinnerSize?: "xs" | "sm" | "md" | "lg" | "xl"; - /** Custom spinner color */ - spinnerClassName?: string; - /** Custom overlay background */ - overlayClassName?: string; -} - -export function LoadingOverlay({ - isVisible, - title, - subtitle, - spinnerSize = "xl", - spinnerClassName = "text-primary", - overlayClassName = "bg-background/80 backdrop-blur-sm", -}: LoadingOverlayProps) { - if (!isVisible) { - return null; - } - - return ( -
-
-
- -
-

{title}

- {subtitle &&

{subtitle}

} -
-
- ); -} diff --git a/apps/portal/src/components/atoms/Spinner.tsx b/apps/portal/src/components/atoms/Spinner.tsx deleted file mode 100644 index e2e1afda..00000000 --- a/apps/portal/src/components/atoms/Spinner.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { cn } from "@/shared/utils"; - -interface SpinnerProps { - size?: "xs" | "sm" | "md" | "lg" | "xl"; - className?: string; -} - -const sizeClasses = { - xs: "h-3 w-3", - sm: "h-4 w-4", - md: "h-6 w-6", - lg: "h-8 w-8", - xl: "h-10 w-10", -}; - -export function Spinner({ size = "sm", className }: SpinnerProps) { - return ( - - - - - ); -} diff --git a/apps/portal/src/components/atoms/button.tsx b/apps/portal/src/components/atoms/button.tsx index 9f7e6444..bbba8d07 100644 --- a/apps/portal/src/components/atoms/button.tsx +++ b/apps/portal/src/components/atoms/button.tsx @@ -3,7 +3,7 @@ import { forwardRef } from "react"; import Link from "next/link"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/shared/utils"; -import { Spinner } from "./Spinner"; +import { Spinner } from "./spinner"; const buttonVariants = cva( "group inline-flex items-center justify-center rounded-lg text-sm font-medium transition-all duration-[var(--cp-duration-normal)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:opacity-50 disabled:pointer-events-none hover:scale-[1.01] active:scale-[0.98]", @@ -104,7 +104,7 @@ const Button = forwardRef((p href={href} ref={ref as React.Ref} aria-busy={loading || undefined} - {...anchorProps} + {...(anchorProps as Omit)} > {loading ? : leftIcon} diff --git a/apps/portal/src/components/atoms/checkbox.tsx b/apps/portal/src/components/atoms/checkbox.tsx index 03c83e64..9e2cab93 100644 --- a/apps/portal/src/components/atoms/checkbox.tsx +++ b/apps/portal/src/components/atoms/checkbox.tsx @@ -14,7 +14,7 @@ export interface CheckboxProps extends Omit( ({ className, label, error, helperText, id, ...props }, ref) => { - const checkboxId = id || `checkbox-${Math.random().toString(36).substr(2, 9)}`; + const checkboxId = id || `checkbox-${Math.random().toString(36).slice(2, 11)}`; return (
diff --git a/apps/portal/src/components/atoms/empty-state.tsx b/apps/portal/src/components/atoms/empty-state.tsx index 2dd0fe1b..9184f579 100644 --- a/apps/portal/src/components/atoms/empty-state.tsx +++ b/apps/portal/src/components/atoms/empty-state.tsx @@ -3,16 +3,18 @@ import { Button } from "./button"; import { cn } from "@/shared/utils"; interface EmptyStateProps { - icon?: React.ReactNode; + icon?: React.ReactNode | undefined; title: string; - description?: string; - action?: { - label: string; - href?: string; - onClick?: () => void; - }; - className?: string; - variant?: "default" | "compact"; + description?: string | undefined; + action?: + | { + label: string; + href?: string | undefined; + onClick?: (() => void) | undefined; + } + | undefined; + className?: string | undefined; + variant?: "default" | "compact" | undefined; } export function EmptyState({ diff --git a/apps/portal/src/components/atoms/error-state.tsx b/apps/portal/src/components/atoms/error-state.tsx index 37ce60a9..f533d06d 100644 --- a/apps/portal/src/components/atoms/error-state.tsx +++ b/apps/portal/src/components/atoms/error-state.tsx @@ -3,12 +3,12 @@ import { Button } from "./button"; import { cn } from "@/shared/utils"; interface ErrorStateProps { - title?: string; - message?: string; - onRetry?: () => void; - retryLabel?: string; - className?: string; - variant?: "page" | "card" | "inline"; + title?: string | undefined; + message?: string | undefined; + onRetry?: (() => void) | undefined; + retryLabel?: string | undefined; + className?: string | undefined; + variant?: "page" | "card" | "inline" | undefined; } export function ErrorState({ diff --git a/apps/portal/src/components/atoms/index.ts b/apps/portal/src/components/atoms/index.ts index 3d47ffc1..565cb1ea 100644 --- a/apps/portal/src/components/atoms/index.ts +++ b/apps/portal/src/components/atoms/index.ts @@ -27,8 +27,8 @@ export { Badge, badgeVariants } from "./badge"; export type { BadgeProps } from "./badge"; // Loading components -export { Spinner } from "./Spinner"; -export { LoadingOverlay } from "./LoadingOverlay"; +export { Spinner } from "./spinner"; +export { LoadingOverlay } from "./loading-overlay"; export { ErrorState, @@ -41,17 +41,26 @@ export { EmptyState, NoDataEmptyState, SearchEmptyState, FilterEmptyState } from // Additional UI Components export { InlineToast } from "./inline-toast"; +// Base skeleton primitive +export { Skeleton } from "./skeleton"; + +// Generic loading skeletons (re-exported from molecules for backward compatibility) +// New code should import directly from "@/components/molecules" or "@/components/molecules/LoadingSkeletons" +export { LoadingCard, LoadingTable, LoadingStats } from "../molecules/LoadingSkeletons"; + +// Feature-specific skeletons (re-exported for backward compatibility) +// New code should import from features/[feature]/components/skeletons/ export { - Skeleton, - LoadingCard, - LoadingTable, - LoadingStats, - // PageLoadingState and FullPageLoadingState removed - use skeleton loading via PageLayout -} from "./loading-skeleton"; + SubscriptionStatsCardsSkeleton, + SubscriptionTableSkeleton, + SubscriptionDetailStatsSkeleton, +} from "../../features/subscriptions/components/skeletons"; + +export { InvoiceListSkeleton } from "../../features/billing/components/skeletons"; export { Logo } from "./logo"; // Navigation and Steps export { StepHeader } from "./step-header"; // Animation -export { AnimatedContainer } from "./AnimatedContainer"; +export { AnimatedContainer } from "./animated-container"; diff --git a/apps/portal/src/components/atoms/loading-skeleton.tsx b/apps/portal/src/components/atoms/loading-skeleton.tsx index db3ab04a..e2cc1c07 100644 --- a/apps/portal/src/components/atoms/loading-skeleton.tsx +++ b/apps/portal/src/components/atoms/loading-skeleton.tsx @@ -1,202 +1,32 @@ -import { cn } from "@/shared/utils"; +/** + * Loading Skeletons - Backward Compatibility Module + * + * This file provides backward compatibility for imports from "@/components/atoms/loading-skeleton". + * Components have been reorganized: + * + * Generic skeletons (LoadingCard, LoadingTable, LoadingStats): + * → components/molecules/LoadingSkeletons/ + * + * Feature-specific skeletons: + * → features/subscriptions/components/skeletons/ + * → features/billing/components/skeletons/ + * + * Base Skeleton primitive: + * → components/atoms/skeleton.tsx + */ -interface SkeletonProps { - className?: string; - animate?: boolean; -} +// Re-export base Skeleton primitive +export { Skeleton } from "./skeleton"; -export function Skeleton({ className, animate = true }: SkeletonProps) { - return ( -
- ); -} +// Re-export generic loading molecules +export { LoadingCard, LoadingTable, LoadingStats } from "../molecules/LoadingSkeletons"; -export function LoadingCard({ className }: { className?: string }) { - return ( -
-
-
- -
- - -
-
-
- - - -
-
-
- ); -} +// Re-export subscription skeletons +export { + SubscriptionStatsCardsSkeleton, + SubscriptionTableSkeleton, + SubscriptionDetailStatsSkeleton, +} from "../../features/subscriptions/components/skeletons"; -export function LoadingTable({ rows = 5, columns = 4 }: { rows?: number; columns?: number }) { - return ( -
- {/* Header */} -
-
- {Array.from({ length: columns }).map((_, i) => ( - - ))} -
-
- - {/* Rows */} -
- {Array.from({ length: rows }).map((_, rowIndex) => ( -
-
- {Array.from({ length: columns }).map((_, colIndex) => ( - - ))} -
-
- ))} -
-
- ); -} - -export function LoadingStats({ count = 4 }: { count?: number }) { - return ( -
- {Array.from({ length: count }).map((_, i) => ( -
-
- -
- - -
-
-
- ))} -
- ); -} - -// ============================================================================= -// Subscription Skeletons - for consistent loading across route and component -// ============================================================================= - -/** Stats cards skeleton (3 cards for subscriptions list) */ -export function SubscriptionStatsCardsSkeleton() { - return ( -
- {Array.from({ length: 3 }).map((_, i) => ( -
-
- -
- - -
-
-
- ))} -
- ); -} - -/** Subscription table skeleton (3 columns: Service, Amount, Next Due) */ -export function SubscriptionTableSkeleton({ rows = 6 }: { rows?: number }) { - return ( -
-
- -
-
- {/* Header skeleton */} -
-
- - - -
-
- {/* Row skeletons */} -
- {Array.from({ length: rows }).map((_, i) => ( -
-
-
- - -
-
- -
-
- - -
-
-
- ))} -
-
-
- ); -} - -/** Subscription detail stats skeleton (4 columns) */ -export function SubscriptionDetailStatsSkeleton() { - return ( -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- ); -} - -/** Invoice/Billing list skeleton */ -export function InvoiceListSkeleton({ rows = 5 }: { rows?: number }) { - return ( -
- -
-
- {Array.from({ length: rows }).map((_, i) => ( -
-
- - -
- -
- ))} -
-
-
- ); -} - -// Note: PageLoadingState is now handled by PageLayout component with proper skeleton loading -// FullPageLoadingState removed - use skeleton loading instead +// Re-export billing skeletons +export { InvoiceListSkeleton } from "../../features/billing/components/skeletons"; diff --git a/apps/portal/src/components/atoms/status-pill.tsx b/apps/portal/src/components/atoms/status-pill.tsx index 48ba87f6..ff741d86 100644 --- a/apps/portal/src/components/atoms/status-pill.tsx +++ b/apps/portal/src/components/atoms/status-pill.tsx @@ -8,25 +8,24 @@ export type StatusPillProps = HTMLAttributes & { icon?: ReactNode; }; +const toneStyles: Record, string> = { + success: "bg-success-soft text-success ring-success/25", + warning: "bg-warning-soft text-foreground ring-warning/25", + info: "bg-info-soft text-info ring-info/25", + error: "bg-danger-soft text-danger ring-danger/25", + neutral: "bg-muted text-muted-foreground ring-border-muted/40", +}; + +const sizeStyles: Record, string> = { + sm: "px-2 py-0.5 text-xs", + md: "px-3 py-1 text-xs", + lg: "px-4 py-1.5 text-sm", +}; + export const StatusPill = forwardRef( ({ label, variant = "neutral", size = "md", icon, className, ...rest }, ref) => { - const tone = - variant === "success" - ? "bg-success-soft text-success ring-success/25" - : variant === "warning" - ? "bg-warning-soft text-foreground ring-warning/25" - : variant === "info" - ? "bg-info-soft text-info ring-info/25" - : variant === "error" - ? "bg-danger-soft text-danger ring-danger/25" - : "bg-muted text-muted-foreground ring-border-muted/40"; - - const sizing = - size === "sm" - ? "px-2 py-0.5 text-xs" - : size === "lg" - ? "px-4 py-1.5 text-sm" - : "px-3 py-1 text-xs"; + const tone = toneStyles[variant]; + const sizing = sizeStyles[size]; return ( void; - disabled?: boolean; + className?: string | undefined; + variant?: "default" | "highlighted" | "success" | "static" | undefined; + onClick?: (() => void) | undefined; + disabled?: boolean | undefined; } export function AnimatedCard({ diff --git a/apps/portal/src/components/molecules/AsyncBlock/AsyncBlock.tsx b/apps/portal/src/components/molecules/AsyncBlock/AsyncBlock.tsx index 7ddd3c76..a6793af3 100644 --- a/apps/portal/src/components/molecules/AsyncBlock/AsyncBlock.tsx +++ b/apps/portal/src/components/molecules/AsyncBlock/AsyncBlock.tsx @@ -59,11 +59,17 @@ export function AsyncBlock({ } if (error) { + // Map variant to ErrorState variant (they're the same values) + const errorVariantMap = { + page: "page", + card: "card", + inline: "inline", + } as const; return ( ); } diff --git a/apps/portal/src/components/molecules/FormField/FormField.tsx b/apps/portal/src/components/molecules/FormField/FormField.tsx index 577d7c51..9b735a84 100644 --- a/apps/portal/src/components/molecules/FormField/FormField.tsx +++ b/apps/portal/src/components/molecules/FormField/FormField.tsx @@ -8,15 +8,15 @@ interface FormFieldProps extends Omit< InputProps, "id" | "aria-describedby" | "aria-invalid" | "children" | "dangerouslySetInnerHTML" > { - label?: string; - error?: string; - helperText?: string; - required?: boolean; - labelProps?: Omit; - fieldId?: string; - children?: React.ReactNode; - containerClassName?: string; - inputClassName?: string; + label?: string | undefined; + error?: string | undefined; + helperText?: string | undefined; + required?: boolean | undefined; + labelProps?: Omit | undefined; + fieldId?: string | undefined; + children?: React.ReactNode | undefined; + containerClassName?: string | undefined; + inputClassName?: string | undefined; } const FormField = forwardRef( diff --git a/apps/portal/src/components/molecules/ProgressSteps/ProgressSteps.tsx b/apps/portal/src/components/molecules/ProgressSteps/ProgressSteps.tsx index d0743ec9..eab50d80 100644 --- a/apps/portal/src/components/molecules/ProgressSteps/ProgressSteps.tsx +++ b/apps/portal/src/components/molecules/ProgressSteps/ProgressSteps.tsx @@ -12,6 +12,26 @@ interface ProgressStepsProps { className?: string; } +function getStepCircleStyles(step: Step, currentStep: number | undefined): string { + if (step.completed) { + return "bg-success border-success text-success-foreground"; + } + if (currentStep === step.number) { + return "border-primary text-primary bg-accent"; + } + return "border-border text-muted-foreground bg-card"; +} + +function getStepTextStyles(step: Step, currentStep: number | undefined): string { + if (step.completed) { + return "text-success"; + } + if (currentStep === step.number) { + return "text-primary"; + } + return "text-muted-foreground"; +} + export function ProgressSteps({ steps, currentStep, className = "" }: ProgressStepsProps) { return (
@@ -22,13 +42,7 @@ export function ProgressSteps({ steps, currentStep, className = "" }: ProgressSt
{step.completed ? ( @@ -39,13 +53,7 @@ export function ProgressSteps({ steps, currentStep, className = "" }: ProgressSt )}
{step.title} diff --git a/apps/portal/src/components/molecules/RouteLoading.tsx b/apps/portal/src/components/molecules/RouteLoading.tsx index ae22a4cc..0e4a0af1 100644 --- a/apps/portal/src/components/molecules/RouteLoading.tsx +++ b/apps/portal/src/components/molecules/RouteLoading.tsx @@ -2,11 +2,11 @@ import type { ReactNode } from "react"; import { PageLayout } from "@/components/templates/PageLayout"; interface RouteLoadingProps { - icon?: ReactNode; + icon?: ReactNode | undefined; title: string; - description?: string; - mode?: "skeleton" | "content"; - children?: ReactNode; + description?: string | undefined; + mode?: "skeleton" | "content" | undefined; + children?: ReactNode | undefined; } // Shared route-level loading wrapper used by segment loading.tsx files diff --git a/apps/portal/src/components/molecules/SubCard/SubCard.tsx b/apps/portal/src/components/molecules/SubCard/SubCard.tsx index b2207fd7..c0fb07ee 100644 --- a/apps/portal/src/components/molecules/SubCard/SubCard.tsx +++ b/apps/portal/src/components/molecules/SubCard/SubCard.tsx @@ -15,6 +15,30 @@ export interface SubCardProps { interactive?: boolean; } +function renderSubCardHeader( + header: ReactNode | undefined, + title: string | undefined, + icon: ReactNode | undefined, + right: ReactNode | undefined, + headerClassName: string +): ReactNode { + if (header) { + return
{header}
; + } + if (title) { + return ( +
+
+ {icon &&
{icon}
} +

{title}

+
+ {right} +
+ ); + } + return null; +} + export const SubCard = forwardRef( ( { @@ -40,17 +64,7 @@ export const SubCard = forwardRef( className )} > - {header ? ( -
{header}
- ) : title ? ( -
-
- {icon &&
{icon}
} -

{title}

-
- {right} -
- ) : null} + {renderSubCardHeader(header, title, icon, right, headerClassName)}
{children}
{footer ?
{footer}
: null}
diff --git a/apps/portal/src/components/molecules/error-boundary.tsx b/apps/portal/src/components/molecules/error-boundary.tsx index 5d408cb2..639af977 100644 --- a/apps/portal/src/components/molecules/error-boundary.tsx +++ b/apps/portal/src/components/molecules/error-boundary.tsx @@ -6,13 +6,13 @@ import { Button } from "@/components/atoms/button"; interface ErrorBoundaryState { hasError: boolean; - error?: Error; + error?: Error | undefined; } interface ErrorBoundaryProps { children: ReactNode; - fallback?: ReactNode; - onError?: (error: Error, errorInfo: ErrorInfo) => void; + fallback?: ReactNode | undefined; + onError?: ((error: Error, errorInfo: ErrorInfo) => void) | undefined; } /** diff --git a/apps/portal/src/components/molecules/index.ts b/apps/portal/src/components/molecules/index.ts index ddbb1312..deb20ef7 100644 --- a/apps/portal/src/components/molecules/index.ts +++ b/apps/portal/src/components/molecules/index.ts @@ -23,5 +23,11 @@ export * from "./SubCard/SubCard"; export * from "./AnimatedCard/AnimatedCard"; export * from "./ServiceCard/ServiceCard"; +// Loading skeleton molecules +export * from "./LoadingSkeletons"; + +// Status display molecules +export * from "./StatusBadge"; + // Performance and lazy loading utilities export { ErrorBoundary } from "./error-boundary"; diff --git a/apps/portal/src/components/organisms/AgentforceWidget/AgentforceWidget.tsx b/apps/portal/src/components/organisms/AgentforceWidget/AgentforceWidget.tsx index 16c3aadb..94a55fc3 100644 --- a/apps/portal/src/components/organisms/AgentforceWidget/AgentforceWidget.tsx +++ b/apps/portal/src/components/organisms/AgentforceWidget/AgentforceWidget.tsx @@ -51,11 +51,11 @@ export function AgentforceWidget({ showFloatingButton = true }: AgentforceWidget const [error, setError] = useState(null); // Configuration from environment variables - const scriptUrl = process.env.NEXT_PUBLIC_SF_EMBEDDED_SERVICE_URL; - const orgId = process.env.NEXT_PUBLIC_SF_ORG_ID; - const deploymentId = process.env.NEXT_PUBLIC_SF_EMBEDDED_SERVICE_DEPLOYMENT_ID; - const baseSiteUrl = process.env.NEXT_PUBLIC_SF_EMBEDDED_SERVICE_SITE_URL; - const scrt2Url = process.env.NEXT_PUBLIC_SF_EMBEDDED_SERVICE_SCRT2_URL; + const scriptUrl = process.env["NEXT_PUBLIC_SF_EMBEDDED_SERVICE_URL"]; + const orgId = process.env["NEXT_PUBLIC_SF_ORG_ID"]; + const deploymentId = process.env["NEXT_PUBLIC_SF_EMBEDDED_SERVICE_DEPLOYMENT_ID"]; + const baseSiteUrl = process.env["NEXT_PUBLIC_SF_EMBEDDED_SERVICE_SITE_URL"]; + const scrt2Url = process.env["NEXT_PUBLIC_SF_EMBEDDED_SERVICE_SCRT2_URL"]; useEffect(() => { // Skip if not configured @@ -104,7 +104,7 @@ export function AgentforceWidget({ showFloatingButton = true }: AgentforceWidget // If not configured, show nothing or a placeholder if (error) { // In development, show the error; in production, fail silently - if (process.env.NODE_ENV === "development") { + if (process.env["NODE_ENV"] === "development") { return (

{error}

diff --git a/apps/portal/src/components/organisms/AppShell/AppShell.tsx b/apps/portal/src/components/organisms/AppShell/AppShell.tsx index 9090ac18..3a6753fe 100644 --- a/apps/portal/src/components/organisms/AppShell/AppShell.tsx +++ b/apps/portal/src/components/organisms/AppShell/AppShell.tsx @@ -102,7 +102,7 @@ export function AppShell({ children }: AppShellProps) { if (pathname.startsWith("/account/billing")) next.add("Billing"); if (pathname.startsWith("/account/support")) next.add("Support"); if (pathname.startsWith("/account/settings")) next.add("Settings"); - const result = Array.from(next); + const result = [...next]; // Avoid state update if unchanged if (result.length === prev.length && result.every(v => prev.includes(v))) return prev; return result; @@ -122,22 +122,22 @@ export function AppShell({ children }: AppShellProps) { useEffect(() => { try { const hrefs = new Set(); - navigation.forEach(item => { + for (const item of navigation) { if (item.href && item.href !== "#") hrefs.add(item.href); if (item.children && item.children.length > 0) { // Prefetch only the first few children to avoid heavy prefetch - item.children.slice(0, 5).forEach(child => { + for (const child of item.children.slice(0, 5)) { if (child.href && child.href !== "#") hrefs.add(child.href); - }); + } } - }); - hrefs.forEach(href => { + } + for (const href of hrefs) { try { router.prefetch(href); } catch { // best-effort prefetch, ignore errors (dev or unsupported) } - }); + } } catch { // ignore } diff --git a/apps/portal/src/components/organisms/AppShell/Sidebar.tsx b/apps/portal/src/components/organisms/AppShell/Sidebar.tsx index 8f86c744..51e925be 100644 --- a/apps/portal/src/components/organisms/AppShell/Sidebar.tsx +++ b/apps/portal/src/components/organisms/AppShell/Sidebar.tsx @@ -69,7 +69,7 @@ const NavigationItem = memo(function NavigationItem({ const hasChildren = item.children && item.children.length > 0; const isActive = hasChildren ? item.children?.some((child: NavigationChild) => - pathname.startsWith((child.href || "").split(/[?#]/)[0]) + pathname.startsWith((child.href || "").split(/[?#]/)[0] ?? "") ) || false : item.href ? pathname === item.href diff --git a/apps/portal/src/components/organisms/AppShell/navigation.ts b/apps/portal/src/components/organisms/AppShell/navigation.ts index 1667487f..136e82d4 100644 --- a/apps/portal/src/components/organisms/AppShell/navigation.ts +++ b/apps/portal/src/components/organisms/AppShell/navigation.ts @@ -13,16 +13,16 @@ import { export interface NavigationChild { name: string; href: string; - icon?: React.ComponentType>; - tooltip?: string; + icon?: React.ComponentType> | undefined; + tooltip?: string | undefined; } export interface NavigationItem { name: string; - href?: string; + href?: string | undefined; icon: React.ComponentType>; - children?: NavigationChild[]; - isLogout?: boolean; + children?: NavigationChild[] | undefined; + isLogout?: boolean | undefined; } export const baseNavigation: NavigationItem[] = [ @@ -65,7 +65,8 @@ export function computeNavigation(activeSubscriptions?: Subscription[]): Navigat })); const subIdx = nav.findIndex(n => n.name === "Subscriptions"); - if (subIdx >= 0) { + const currentItem = nav[subIdx]; + if (subIdx >= 0 && currentItem) { const dynamicChildren = (activeSubscriptions || []).map(sub => ({ name: truncate(sub.productName || `Subscription ${sub.id}`, 28), href: `/account/subscriptions/${sub.id}`, @@ -73,7 +74,10 @@ export function computeNavigation(activeSubscriptions?: Subscription[]): Navigat })); nav[subIdx] = { - ...nav[subIdx], + name: currentItem.name, + icon: currentItem.icon, + href: currentItem.href, + isLogout: currentItem.isLogout, children: [{ name: "All Subscriptions", href: "/account/subscriptions" }, ...dynamicChildren], }; } diff --git a/apps/portal/src/components/templates/PageLayout/PageLayout.tsx b/apps/portal/src/components/templates/PageLayout/PageLayout.tsx index c1795fc5..8669a69e 100644 --- a/apps/portal/src/components/templates/PageLayout/PageLayout.tsx +++ b/apps/portal/src/components/templates/PageLayout/PageLayout.tsx @@ -6,18 +6,18 @@ import { ErrorState } from "@/components/atoms/error-state"; export interface BreadcrumbItem { label: string; - href?: string; + href?: string | undefined; } interface PageLayoutProps { - icon?: ReactNode; + icon?: ReactNode | undefined; title: string; - description?: string; - actions?: ReactNode; - breadcrumbs?: BreadcrumbItem[]; - loading?: boolean; - error?: Error | string | null; - onRetry?: () => void; + description?: string | undefined; + actions?: ReactNode | undefined; + breadcrumbs?: BreadcrumbItem[] | undefined; + loading?: boolean | undefined; + error?: Error | string | null | undefined; + onRetry?: (() => void) | undefined; children: ReactNode; } @@ -118,19 +118,28 @@ export function PageLayout({ {/* Content with loading and error states */}
- {loading ? ( - - ) : error ? ( - - ) : ( - children - )} + {renderPageContent(loading, error ?? undefined, children, onRetry)}
); } +function renderPageContent( + loading: boolean | undefined, + error: Error | string | undefined, + children: React.ReactNode, + onRetry?: () => void +): React.ReactNode { + if (loading) { + return ; + } + if (error) { + return ; + } + return children; +} + function PageLoadingState() { return (
@@ -160,7 +169,7 @@ function PageLoadingState() { interface PageErrorStateProps { error: Error | string; - onRetry?: () => void; + onRetry?: (() => void) | undefined; } function PageErrorState({ error, onRetry }: PageErrorStateProps) { diff --git a/apps/portal/src/config/environment.ts b/apps/portal/src/config/environment.ts index 76888c93..439fadb4 100644 --- a/apps/portal/src/config/environment.ts +++ b/apps/portal/src/config/environment.ts @@ -1 +1 @@ -export const IS_DEVELOPMENT = process.env.NODE_ENV === "development"; +export const IS_DEVELOPMENT = process.env["NODE_ENV"] === "development"; diff --git a/apps/portal/src/config/feature-flags.ts b/apps/portal/src/config/feature-flags.ts index a787c669..5f78992f 100644 --- a/apps/portal/src/config/feature-flags.ts +++ b/apps/portal/src/config/feature-flags.ts @@ -9,17 +9,17 @@ export const FEATURE_FLAGS = { /** * Enable public services browsing (browse without login) */ - PUBLIC_SERVICES: process.env.NEXT_PUBLIC_FEATURE_PUBLIC_SERVICES !== "false", + PUBLIC_SERVICES: process.env["NEXT_PUBLIC_FEATURE_PUBLIC_SERVICES"] !== "false", /** * Enable unified checkout (authenticated checkout flow) */ - UNIFIED_CHECKOUT: process.env.NEXT_PUBLIC_FEATURE_UNIFIED_CHECKOUT !== "false", + UNIFIED_CHECKOUT: process.env["NEXT_PUBLIC_FEATURE_UNIFIED_CHECKOUT"] !== "false", /** * Enable public support (FAQ and contact without login) */ - PUBLIC_SUPPORT: process.env.NEXT_PUBLIC_FEATURE_PUBLIC_SUPPORT !== "false", + PUBLIC_SUPPORT: process.env["NEXT_PUBLIC_FEATURE_PUBLIC_SUPPORT"] !== "false", } as const; /** diff --git a/apps/portal/src/core/api/index.ts b/apps/portal/src/core/api/index.ts index 3920dbdf..01494fe4 100644 --- a/apps/portal/src/core/api/index.ts +++ b/apps/portal/src/core/api/index.ts @@ -1,3 +1,9 @@ +// Import dependencies for internal use +import { createClient, ApiError } from "./runtime/client"; +import { getApiErrorMessage } from "./runtime/error-message"; +import { logger } from "@/core/logger"; +import { emitUnauthorized } from "./unauthorized"; + export { createClient, resolveBaseUrl } from "./runtime/client"; export type { ApiClient, @@ -12,12 +18,6 @@ export { onUnauthorized } from "./unauthorized"; // Re-export API helpers export * from "./response-helpers"; -// Import createClient for internal use -import { createClient, ApiError } from "./runtime/client"; -import { getApiErrorMessage } from "./runtime/error-message"; -import { logger } from "@/core/logger"; -import { emitUnauthorized } from "./unauthorized"; - /** * Auth endpoints that should NOT trigger automatic logout on 401 * These are endpoints where 401 means "invalid credentials", not "session expired" diff --git a/apps/portal/src/core/api/runtime/client.ts b/apps/portal/src/core/api/runtime/client.ts index 2aa9a252..d6769fde 100644 --- a/apps/portal/src/core/api/runtime/client.ts +++ b/apps/portal/src/core/api/runtime/client.ts @@ -70,7 +70,7 @@ export const resolveBaseUrl = (explicitBase?: string): string => { // 2. Check NEXT_PUBLIC_API_BASE env var (works in both browser and SSR) // In development: set to http://localhost:4000 for direct CORS calls // In production: typically not set, falls through to same-origin - const envBase = process.env.NEXT_PUBLIC_API_BASE; + const envBase = process.env["NEXT_PUBLIC_API_BASE"]; if (envBase?.trim() && envBase.startsWith("http")) { return envBase.replace(/\/+$/, ""); } @@ -118,7 +118,7 @@ const buildQueryString = (query?: QueryParams): string => { } if (Array.isArray(value)) { - (value as readonly QueryPrimitive[]).forEach(entry => appendPrimitive(key, entry)); + for (const entry of value as readonly QueryPrimitive[]) appendPrimitive(key, entry); continue; } @@ -327,7 +327,7 @@ export function createClient(options: CreateClientOptions = {}): ApiClient { method, headers, credentials, - signal: opts.signal, + signal: opts.signal ?? null, }; const body = opts.body; diff --git a/apps/portal/src/core/logger/logger.ts b/apps/portal/src/core/logger/logger.ts index 1c287c44..a4b8ed04 100644 --- a/apps/portal/src/core/logger/logger.ts +++ b/apps/portal/src/core/logger/logger.ts @@ -19,7 +19,7 @@ const formatMeta = (meta?: LogMeta): LogMeta | undefined => { }; class Logger { - private readonly isDevelopment = process.env.NODE_ENV === "development"; + private readonly isDevelopment = process.env["NODE_ENV"] === "development"; debug(message: string, meta?: LogMeta): void { if (this.isDevelopment) { diff --git a/apps/portal/src/core/providers/QueryProvider.tsx b/apps/portal/src/core/providers/QueryProvider.tsx index 9e293f3f..8eff695c 100644 --- a/apps/portal/src/core/providers/QueryProvider.tsx +++ b/apps/portal/src/core/providers/QueryProvider.tsx @@ -13,7 +13,7 @@ import { useAuthStore } from "@/features/auth/stores/auth.store"; interface QueryProviderProps { children: React.ReactNode; - nonce?: string; + nonce?: string | undefined; } export function QueryProvider({ children }: QueryProviderProps) { @@ -35,7 +35,7 @@ export function QueryProvider({ children }: QueryProviderProps) { return false; } const body = error.body as Record | undefined; - const code = typeof body?.code === "string" ? body.code : undefined; + const code = typeof body?.["code"] === "string" ? body["code"] : undefined; // Don't retry on auth errors or rate limits if (code === "AUTHENTICATION_REQUIRED" || code === "FORBIDDEN") { return false; @@ -64,7 +64,7 @@ export function QueryProvider({ children }: QueryProviderProps) { return ( {children} - {process.env.NODE_ENV === "development" && } + {process.env["NODE_ENV"] === "development" && } ); } diff --git a/apps/portal/src/features/account/components/PersonalInfoCard.tsx b/apps/portal/src/features/account/components/PersonalInfoCard.tsx index 6542f85a..c2493746 100644 --- a/apps/portal/src/features/account/components/PersonalInfoCard.tsx +++ b/apps/portal/src/features/account/components/PersonalInfoCard.tsx @@ -120,7 +120,7 @@ export function PersonalInfoCard({ disabled={isSaving} isLoading={isSaving} loadingText="Saving…" - leftIcon={!isSaving ? : undefined} + leftIcon={isSaving ? undefined : } > Save Changes diff --git a/apps/portal/src/features/account/views/ProfileContainer.tsx b/apps/portal/src/features/account/views/ProfileContainer.tsx index b8382c71..7c2ef54e 100644 --- a/apps/portal/src/features/account/views/ProfileContainer.tsx +++ b/apps/portal/src/features/account/views/ProfileContainer.tsx @@ -58,6 +58,76 @@ export default function ProfileContainer() { const verificationFileInputRef = useRef(null); const canUploadVerification = verificationStatus !== "verified"; + // Helper to render verification status pill + const renderVerificationStatusPill = () => { + if (verificationQuery.isLoading) { + return ; + } + switch (verificationStatus) { + case "verified": + return ; + case "pending": + return ; + case "rejected": + return ; + default: + return ; + } + }; + + // Helper to render verification content based on status + const renderVerificationContent = () => { + if (verificationQuery.isLoading) { + return ( +
+ + +
+ ); + } + + if (verificationStatus === "verified") { + return ( +
+

+ Your identity has been verified. No further action is needed. +

+ {verificationQuery.data?.reviewedAt && ( +

+ Verified on{" "} + {formatIsoDate(verificationQuery.data.reviewedAt, { dateStyle: "medium" })} +

+ )} +
+ ); + } + + if (verificationStatus === "pending") { + return ( +
+ + Your residence card has been submitted. We'll verify it before activating SIM + service. + + {verificationQuery.data?.submittedAt && ( +
+
+ Submission status +
+
+ Submitted on{" "} + {formatIsoDate(verificationQuery.data.submittedAt, { dateStyle: "medium" })} +
+
+ )} +
+ ); + } + + // Default: rejected or not submitted + return null; + }; + // Extract stable setValue functions to avoid infinite re-render loop. // The hook objects (address, profile) are recreated every render, but // the setValue callbacks inside them are stable (memoized with useCallback). @@ -364,7 +434,7 @@ export default function ProfileContainer() { }); }} isLoading={profile.isSubmitting} - leftIcon={!profile.isSubmitting ? : undefined} + leftIcon={profile.isSubmitting ? undefined : } > {profile.isSubmitting ? "Saving..." : "Save Changes"} @@ -444,7 +514,7 @@ export default function ProfileContainer() { }); }} isLoading={address.isSubmitting} - leftIcon={!address.isSubmitting ? : undefined} + leftIcon={address.isSubmitting ? undefined : } > {address.isSubmitting ? "Saving..." : "Save Address"} @@ -501,172 +571,132 @@ export default function ProfileContainer() {

Identity Verification

- {verificationQuery.isLoading ? ( - - ) : verificationStatus === "verified" ? ( - - ) : verificationStatus === "pending" ? ( - - ) : verificationStatus === "rejected" ? ( - - ) : ( - - )} + {renderVerificationStatusPill()}
- {verificationQuery.isLoading ? ( -
- - -
- ) : verificationStatus === "verified" ? ( -
-

- Your identity has been verified. No further action is needed. -

- {verificationQuery.data?.reviewedAt && ( -

- Verified on{" "} - {formatIsoDate(verificationQuery.data.reviewedAt, { dateStyle: "medium" })} -

- )} -
- ) : verificationStatus === "pending" ? ( -
- - Your residence card has been submitted. We'll verify it before activating SIM - service. - - {verificationQuery.data?.submittedAt && ( -
-
- Submission status -
-
- Submitted on{" "} - {formatIsoDate(verificationQuery.data.submittedAt, { dateStyle: "medium" })} -
-
- )} -
- ) : ( -
- {verificationStatus === "rejected" ? ( - -
- {verificationQuery.data?.reviewerNotes && ( -

{verificationQuery.data.reviewerNotes}

- )} -

Please upload a new, clear photo or scan of your residence card.

-
    -
  • Make sure all text is readable and the full card is visible.
  • -
  • Avoid glare/reflections and blurry photos.
  • -
  • Maximum file size: 5MB.
  • -
-
-
- ) : ( -

- Upload your residence card to activate SIM services. This is required for SIM - orders. -

- )} + {renderVerificationContent()} - {(verificationQuery.data?.submittedAt || verificationQuery.data?.reviewedAt) && ( -
-
- Latest submission -
- {verificationQuery.data?.submittedAt && ( -
- Submitted on{" "} - {formatIsoDate(verificationQuery.data.submittedAt, { dateStyle: "medium" })} + {/* Upload section for rejected or not submitted status */} + {!verificationQuery.isLoading && + verificationStatus !== "verified" && + verificationStatus !== "pending" && ( +
+ {verificationStatus === "rejected" ? ( + +
+ {verificationQuery.data?.reviewerNotes && ( +

{verificationQuery.data.reviewerNotes}

+ )} +

Please upload a new, clear photo or scan of your residence card.

+
    +
  • Make sure all text is readable and the full card is visible.
  • +
  • Avoid glare/reflections and blurry photos.
  • +
  • Maximum file size: 5MB.
  • +
- )} - {verificationQuery.data?.reviewedAt && ( -
- Reviewed on{" "} - {formatIsoDate(verificationQuery.data.reviewedAt, { dateStyle: "medium" })} + + ) : ( +

+ Upload your residence card to activate SIM services. This is required for SIM + orders. +

+ )} + + {(verificationQuery.data?.submittedAt || verificationQuery.data?.reviewedAt) && ( +
+
+ Latest submission
- )} -
- )} - - {canUploadVerification && ( -
- setVerificationFile(e.target.files?.[0] ?? null)} - className="block w-full text-sm text-foreground file:mr-4 file:py-2 file:px-3 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-muted file:text-foreground hover:file:bg-muted/80" - /> - - {verificationFile && ( -
-
-
- Selected file -
-
- {verificationFile.name} -
+ {verificationQuery.data?.submittedAt && ( +
+ Submitted on{" "} + {formatIsoDate(verificationQuery.data.submittedAt, { dateStyle: "medium" })}
- -
- )} + )} + {verificationQuery.data?.reviewedAt && ( +
+ Reviewed on{" "} + {formatIsoDate(verificationQuery.data.reviewedAt, { dateStyle: "medium" })} +
+ )} +
+ )} -
- -
+ }} + > + Change + +
+ )} - {submitResidenceCard.isError && ( -

- {submitResidenceCard.error instanceof Error - ? submitResidenceCard.error.message - : "Failed to submit residence card."} +

+ +
+ + {submitResidenceCard.isError && ( +

+ {submitResidenceCard.error instanceof Error + ? submitResidenceCard.error.message + : "Failed to submit residence card."} +

+ )} + +

+ Accepted formats: JPG, PNG, or PDF (max 5MB). Make sure all text is readable.

- )} - -

- Accepted formats: JPG, PNG, or PDF (max 5MB). Make sure all text is readable. -

-
- )} -
- )} +
+ )} +
+ )}
diff --git a/apps/portal/src/features/address/components/AddressStepJapan.tsx b/apps/portal/src/features/address/components/AddressStepJapan.tsx index 6a34d340..87528fb1 100644 --- a/apps/portal/src/features/address/components/AddressStepJapan.tsx +++ b/apps/portal/src/features/address/components/AddressStepJapan.tsx @@ -23,8 +23,17 @@ import { type BilingualAddress, RESIDENCE_TYPE, prepareWhmcsAddressFields, + type ResidenceType, } from "@customer-portal/domain/address"; +/** + * Partial form data type that allows undefined residenceType + * for cases where user hasn't selected yet + */ +type PartialJapanAddressFormData = Omit & { + residenceType?: ResidenceType | undefined; +}; + // ============================================================================ // Types // ============================================================================ @@ -40,7 +49,7 @@ interface LegacyAddressData { state: string; postcode: string; country: string; - countryCode?: string; + countryCode?: string | undefined; } /** @@ -88,7 +97,7 @@ function toWhmcsFormat(data: JapanAddressFormData): LegacyAddressData { * Convert legacy address format to JapanAddressFormData * Used for initializing form from existing data */ -function fromLegacyFormat(address: LegacyAddressData): Partial { +function fromLegacyFormat(address: LegacyAddressData): PartialJapanAddressFormData { // Try to parse address1 into building name and room number // Format: "BuildingName RoomNumber" or just "BuildingName" const address1Parts = (address.address1 || "").trim(); @@ -120,6 +129,7 @@ function fromLegacyFormat(address: LegacyAddressData): Partial { - return { + const initialValues = useMemo((): PartialJapanAddressFormData => { + const base: PartialJapanAddressFormData = { postcode: address.postcode || "", prefecture: address.state || "", city: address.city || "", town: address.address2 || "", + streetAddress: "", buildingName: "", roomNumber: "", residenceType: undefined, prefectureJa: "", cityJa: "", townJa: "", + }; + return { + ...base, ...fromLegacyFormat(address), }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -162,7 +176,7 @@ export function AddressStepJapan({ form, onJapaneseAddressChange }: AddressStepJ // Extract address field errors const getError = (field: string): string | undefined => { const key = `address.${field}`; - return touched[key] || touched.address ? (errors[key] ?? errors[field]) : undefined; + return touched[key] || touched["address"] ? (errors[key] ?? errors[field]) : undefined; }; // Map Japan address field names to legacy field names for error display @@ -176,13 +190,13 @@ export function AddressStepJapan({ form, onJapaneseAddressChange }: AddressStepJ }; // Map touched fields - const japanFieldTouched: Partial> = { - postcode: touched["address.postcode"] || touched.address, - prefecture: touched["address.state"] || touched.address, - city: touched["address.city"] || touched.address, - town: touched["address.address2"] || touched.address, - buildingName: touched["address.address1"] || touched.address, - roomNumber: touched["address.address1"] || touched.address, + const japanFieldTouched: Partial> = { + postcode: touched["address.postcode"] || touched["address"], + prefecture: touched["address.state"] || touched["address"], + city: touched["address.city"] || touched["address"], + town: touched["address.address2"] || touched["address"], + buildingName: touched["address.address1"] || touched["address"], + roomNumber: touched["address.address1"] || touched["address"], }; // Handle Japan address form changes diff --git a/apps/portal/src/features/address/components/JapanAddressForm.tsx b/apps/portal/src/features/address/components/JapanAddressForm.tsx index e5a7132e..fb7df639 100644 --- a/apps/portal/src/features/address/components/JapanAddressForm.tsx +++ b/apps/portal/src/features/address/components/JapanAddressForm.tsx @@ -31,21 +31,30 @@ import { export type JapanAddressFormData = BilingualAddress; +/** + * Type for partial initial values that allows undefined residenceType. + * This is needed because with exactOptionalPropertyTypes, Partial + * makes properties optional but doesn't allow explicitly setting undefined. + */ +type JapanAddressFormInitialValues = Omit & { + residenceType?: ResidenceType | undefined; +}; + export interface JapanAddressFormProps { /** Initial address values */ - initialValues?: Partial; + initialValues?: Partial | undefined; /** Called when any address field changes */ - onChange?: (address: JapanAddressFormData, isComplete: boolean) => void; + onChange?: ((address: JapanAddressFormData, isComplete: boolean) => void) | undefined; /** Field-level errors (keyed by field name) */ - errors?: Partial>; + errors?: Partial> | undefined; /** Fields that have been touched */ - touched?: Partial>; + touched?: Partial> | undefined; /** Mark a field as touched */ - onBlur?: (field: keyof JapanAddressFormData) => void; + onBlur?: ((field: keyof JapanAddressFormData) => void) | undefined; /** Whether the form is disabled */ - disabled?: boolean; + disabled?: boolean | undefined; /** Custom class name for container */ - className?: string; + className?: string | undefined; } // ============================================================================ @@ -171,6 +180,8 @@ export function JapanAddressForm({ const [address, setAddress] = useState(() => ({ ...DEFAULT_ADDRESS, ...initialValues, + // Convert undefined residenceType to empty string for internal state + residenceType: initialValues?.residenceType ?? DEFAULT_ADDRESS.residenceType, })); const [isAddressVerified, setIsAddressVerified] = useState(false); @@ -199,7 +210,12 @@ export function JapanAddressForm({ useEffect(() => { if (initialValues && !hasInitializedRef.current) { hasInitializedRef.current = true; - setAddress(prev => ({ ...prev, ...initialValues })); + setAddress(prev => ({ + ...prev, + ...initialValues, + // Convert undefined residenceType to empty string for internal state + residenceType: initialValues.residenceType ?? prev.residenceType, + })); if (initialValues.prefecture && initialValues.city && initialValues.town) { setIsAddressVerified(true); setVerifiedZipCode(initialValues.postcode || ""); diff --git a/apps/portal/src/features/address/components/ZipCodeInput.tsx b/apps/portal/src/features/address/components/ZipCodeInput.tsx index 25a1e999..2531b4b4 100644 --- a/apps/portal/src/features/address/components/ZipCodeInput.tsx +++ b/apps/portal/src/features/address/components/ZipCodeInput.tsx @@ -22,25 +22,25 @@ export interface ZipCodeInputProps { /** Called when ZIP code changes */ onChange: (value: string) => void; /** Called when address is found from ZIP lookup */ - onAddressFound?: (address: JapanPostAddress) => void; + onAddressFound?: ((address: JapanPostAddress) => void) | undefined; /** Called when lookup completes (found or not found) */ - onLookupComplete?: (found: boolean, addresses: JapanPostAddress[]) => void; + onLookupComplete?: ((found: boolean, addresses: JapanPostAddress[]) => void) | undefined; /** Field error message */ - error?: string; + error?: string | undefined; /** Whether the field is required */ - required?: boolean; + required?: boolean | undefined; /** Whether the input is disabled */ - disabled?: boolean; + disabled?: boolean | undefined; /** Auto-focus the input */ - autoFocus?: boolean; + autoFocus?: boolean | undefined; /** Custom label */ - label?: string; + label?: string | undefined; /** Helper text below input */ - helperText?: string; + helperText?: string | undefined; /** Whether to auto-trigger lookup on valid ZIP */ - autoLookup?: boolean; + autoLookup?: boolean | undefined; /** Debounce delay for auto-lookup (ms) */ - debounceMs?: number; + debounceMs?: number | undefined; } export function ZipCodeInput({ @@ -159,7 +159,7 @@ export function ZipCodeInput({ label={label} error={error} required={required} - helperText={!error ? computedHelperText : undefined} + helperText={error ? undefined : computedHelperText} >
void; - initialMode?: "signup" | "login"; - redirectTo?: string; - title?: string; - description?: string; - showCloseButton?: boolean; + initialMode?: "signup" | "login" | undefined; + redirectTo?: string | undefined; + title?: string | undefined; + description?: string | undefined; + showCloseButton?: boolean | undefined; } export function AuthModal({ diff --git a/apps/portal/src/features/auth/components/InlineAuthSection/InlineAuthSection.tsx b/apps/portal/src/features/auth/components/InlineAuthSection/InlineAuthSection.tsx index 5b64b706..fba5ceeb 100644 --- a/apps/portal/src/features/auth/components/InlineAuthSection/InlineAuthSection.tsx +++ b/apps/portal/src/features/auth/components/InlineAuthSection/InlineAuthSection.tsx @@ -15,11 +15,11 @@ interface HighlightItem { interface InlineAuthSectionProps { title: string; - description?: string; - redirectTo?: string; - initialMode?: "signup" | "login"; - highlights?: HighlightItem[]; - className?: string; + description?: string | undefined; + redirectTo?: string | undefined; + initialMode?: "signup" | "login" | undefined; + highlights?: HighlightItem[] | undefined; + className?: string | undefined; } export function InlineAuthSection({ diff --git a/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx b/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx index 77019c39..072c4464 100644 --- a/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx +++ b/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx @@ -11,8 +11,8 @@ import { linkWhmcsRequestSchema, type LinkWhmcsResponse } from "@customer-portal import { useZodForm } from "@/shared/hooks"; interface LinkWhmcsFormProps { - onTransferred?: (result: LinkWhmcsResponse) => void; - className?: string; + onTransferred?: ((result: LinkWhmcsResponse) => void) | undefined; + className?: string | undefined; } export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormProps) { @@ -34,7 +34,7 @@ export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormPr
void form.handleSubmit(e)} className={`space-y-5 ${className}`}> void; - onError?: (error: string) => void; - showSignupLink?: boolean; - showForgotPasswordLink?: boolean; - className?: string; - redirectTo?: string; - initialEmail?: string; + onSuccess?: (() => void) | undefined; + onError?: ((error: string) => void) | undefined; + showSignupLink?: boolean | undefined; + showForgotPasswordLink?: boolean | undefined; + className?: string | undefined; + redirectTo?: string | undefined; + initialEmail?: string | undefined; } /** @@ -48,7 +48,7 @@ export function LoginForm({ initialEmail, }: LoginFormProps) { const searchParams = useSearchParams(); - const { login, loading, error, clearError } = useLogin({ redirectTo }); + const { login, loading, error, clearError } = useLogin(redirectTo ? { redirectTo } : undefined); const redirectCandidate = redirectTo || searchParams?.get("next") || searchParams?.get("redirect"); const redirect = getSafeRedirect(redirectCandidate, ""); @@ -85,7 +85,11 @@ export function LoginForm({ return (
void handleSubmit(event)} className="space-y-6"> - + - + void; - onError?: (error: string) => void; - showLoginLink?: boolean; - className?: string; + token?: string | undefined; + onSuccess?: (() => void) | undefined; + onError?: ((error: string) => void) | undefined; + showLoginLink?: boolean | undefined; + className?: string | undefined; } export function PasswordResetForm({ @@ -114,7 +114,7 @@ export function PasswordResetForm({ void requestForm.handleSubmit(event)} className="space-y-4"> requestForm.setValue("email", e.target.value)} onBlur={() => requestForm.setTouchedField("email")} disabled={loading || requestForm.isSubmitting} - className={requestForm.errors.email ? "border-red-300" : ""} + className={requestForm.errors["email"] ? "border-red-300" : ""} /> @@ -160,7 +160,7 @@ export function PasswordResetForm({ void resetForm.handleSubmit(event)} className="space-y-4"> resetForm.setValue("password", e.target.value)} onBlur={() => resetForm.setTouchedField("password")} disabled={loading || resetForm.isSubmitting} - className={resetForm.errors.password ? "border-red-300" : ""} + className={resetForm.errors["password"] ? "border-red-300" : ""} /> resetForm.setValue("confirmPassword", e.target.value)} onBlur={() => resetForm.setTouchedField("confirmPassword")} disabled={loading || resetForm.isSubmitting} - className={resetForm.errors.confirmPassword ? "border-red-300" : ""} + className={resetForm.errors["confirmPassword"] ? "border-red-300" : ""} /> diff --git a/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx b/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx index 8c3ca023..2ea1d090 100644 --- a/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx +++ b/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx @@ -26,7 +26,7 @@ export function SessionTimeoutWarning({ expiryRef.current = null; setShowWarning(false); setTimeLeft(0); - return undefined; + return; } const expiryTime = Date.parse(session.accessExpiresAt); @@ -35,14 +35,14 @@ export function SessionTimeoutWarning({ expiryRef.current = null; setShowWarning(false); setTimeLeft(0); - return undefined; + return; } expiryRef.current = expiryTime; if (Date.now() >= expiryTime) { void logout({ reason: "session-expired" }); - return undefined; + return; } const warningThreshold = warningTime * 60 * 1000; @@ -53,7 +53,7 @@ export function SessionTimeoutWarning({ if (timeUntilWarning <= 0) { setShowWarning(true); setTimeLeft(Math.max(1, Math.ceil(timeUntilExpiry / (60 * 1000)))); - return undefined; + return; } const warningTimeout = setTimeout(() => { @@ -65,7 +65,7 @@ export function SessionTimeoutWarning({ }, [isAuthenticated, session.accessExpiresAt, warningTime, logout]); useEffect(() => { - if (!showWarning || !expiryRef.current) return undefined; + if (!showWarning || !expiryRef.current) return; previouslyFocusedElement.current = document.activeElement as HTMLElement | null; @@ -97,7 +97,7 @@ export function SessionTimeoutWarning({ }, [showWarning, logout]); useEffect(() => { - if (!showWarning) return undefined; + if (!showWarning) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { @@ -107,26 +107,27 @@ export function SessionTimeoutWarning({ } if (event.key === "Tab") { - const focusableElements = dialogRef.current?.querySelectorAll( + const nodeList = dialogRef.current?.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); - if (!focusableElements || focusableElements.length === 0) { + if (!nodeList || nodeList.length === 0) { event.preventDefault(); return; } + const focusableElements = [...nodeList]; const firstElement = focusableElements[0]; - const lastElement = focusableElements[focusableElements.length - 1]; + const lastElement = focusableElements.at(-1); if (!event.shiftKey && document.activeElement === lastElement) { event.preventDefault(); - firstElement.focus(); + firstElement?.focus(); } if (event.shiftKey && document.activeElement === firstElement) { event.preventDefault(); - lastElement.focus(); + lastElement?.focus(); } } }; @@ -185,7 +186,7 @@ export function SessionTimeoutWarning({

Your session will expire in{" "} - {timeLeft} minute{timeLeft !== 1 ? "s" : ""} + {timeLeft} minute{timeLeft === 1 ? "" : "s"} . Would you like to extend your session?

diff --git a/apps/portal/src/features/auth/components/SetPasswordForm/SetPasswordForm.tsx b/apps/portal/src/features/auth/components/SetPasswordForm/SetPasswordForm.tsx index 4d27e7a8..e713b001 100644 --- a/apps/portal/src/features/auth/components/SetPasswordForm/SetPasswordForm.tsx +++ b/apps/portal/src/features/auth/components/SetPasswordForm/SetPasswordForm.tsx @@ -76,7 +76,7 @@ export function SetPasswordForm({ return ( void form.handleSubmit(e)} className={`space-y-5 ${className}`}> - + )} - {(error || form.errors._form) && {form.errors._form || error}} + {(error || form.errors["_form"]) && ( + {form.errors["_form"] || error} + )}
{compact && item.count > 0 && (
- {item.count} invoice{item.count !== 1 ? "s" : ""} + {item.count} invoice{item.count === 1 ? "" : "s"}
)}
diff --git a/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceHeader.tsx b/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceHeader.tsx index b9f132af..7bf4126e 100644 --- a/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceHeader.tsx +++ b/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceHeader.tsx @@ -16,6 +16,19 @@ const formatDate = (dateString?: string) => { return formatted === "Invalid date" ? "N/A" : formatted; }; +const getStatusBadgeClass = (status: Invoice["status"]) => { + switch (status) { + case "Paid": + return "bg-emerald-100 text-emerald-800 border border-emerald-200"; + case "Overdue": + return "bg-red-100 text-red-800 border border-red-200"; + case "Unpaid": + return "bg-amber-100 text-amber-800 border border-amber-200"; + default: + return "bg-slate-100 text-slate-800 border border-slate-200"; + } +}; + interface InvoiceHeaderProps { invoice: Invoice; loadingDownload?: boolean; @@ -61,15 +74,7 @@ export function InvoiceHeader(props: InvoiceHeaderProps) {
{invoice.status === "Paid" && ( diff --git a/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceItems.tsx b/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceItems.tsx index 2c48d90d..94d1d44c 100644 --- a/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceItems.tsx +++ b/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceItems.tsx @@ -3,9 +3,9 @@ import React from "react"; import Link from "next/link"; import { Formatting } from "@customer-portal/domain/toolkit"; +import type { InvoiceItem } from "@customer-portal/domain/billing"; const { formatCurrency } = Formatting; -import type { InvoiceItem } from "@customer-portal/domain/billing"; interface InvoiceItemsProps { items?: InvoiceItem[]; diff --git a/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceSummaryBar.tsx b/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceSummaryBar.tsx index da1fdc83..9d8fdcbe 100644 --- a/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceSummaryBar.tsx +++ b/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceSummaryBar.tsx @@ -1,13 +1,13 @@ import { useMemo } from "react"; import { ArrowDownTrayIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/24/outline"; import { Formatting } from "@customer-portal/domain/toolkit"; - -const { formatCurrency } = Formatting; import type { Invoice } from "@customer-portal/domain/billing"; import { Button } from "@/components/atoms/button"; import { StatusPill } from "@/components/atoms/status-pill"; import { cn, formatIsoDate, formatIsoRelative } from "@/shared/utils"; +const { formatCurrency } = Formatting; + interface InvoiceSummaryBarProps { invoice: Invoice; loadingDownload?: boolean; @@ -55,7 +55,7 @@ function formatRelativeDue( if (status === "Paid") return null; if (status === "Overdue" && daysOverdue) { - return `${daysOverdue} day${daysOverdue !== 1 ? "s" : ""} overdue`; + return `${daysOverdue} day${daysOverdue === 1 ? "" : "s"} overdue`; } else if (status === "Unpaid") { const relative = formatIsoRelative(dateString); if (relative === "N/A" || relative === "Invalid date") return null; @@ -116,7 +116,7 @@ export function InvoiceSummaryBar({ variant="outline" onClick={onDownload} disabled={!onDownload} - loading={loadingDownload} + loading={loadingDownload ?? false} leftIcon={} > Download PDF @@ -125,7 +125,7 @@ export function InvoiceSummaryBar({ } variant="default" > diff --git a/apps/portal/src/features/billing/components/InvoiceList/InvoiceList.tsx b/apps/portal/src/features/billing/components/InvoiceList/InvoiceList.tsx index 14dd1a37..c935efb4 100644 --- a/apps/portal/src/features/billing/components/InvoiceList/InvoiceList.tsx +++ b/apps/portal/src/features/billing/components/InvoiceList/InvoiceList.tsx @@ -135,7 +135,7 @@ export function InvoicesList({ {/* Total Count */} {pagination?.totalItems && ( - {pagination.totalItems} invoice{pagination.totalItems !== 1 ? "s" : ""} + {pagination.totalItems} invoice{pagination.totalItems === 1 ? "" : "s"} )} diff --git a/apps/portal/src/features/billing/components/InvoiceStatusBadge.tsx b/apps/portal/src/features/billing/components/InvoiceStatusBadge.tsx index e0413536..184fa4cc 100644 --- a/apps/portal/src/features/billing/components/InvoiceStatusBadge.tsx +++ b/apps/portal/src/features/billing/components/InvoiceStatusBadge.tsx @@ -1,6 +1,5 @@ "use client"; -import type { ReactElement } from "react"; import { CheckCircleIcon, ExclamationTriangleIcon, @@ -8,89 +7,83 @@ import { DocumentTextIcon, } from "@heroicons/react/24/outline"; import type { InvoiceStatus } from "@customer-portal/domain/billing"; - -type StatusConfig = { - icon: ReactElement; - color: string; - iconColor: string; - label: string; -}; +import { StatusBadge, type StatusConfigMap } from "@/components/molecules"; /** + * Status configuration for invoice statuses. + * Maps each status to its visual variant, icon, and label. + * * Status → Semantic color mapping: * - Paid → success (green) * - Pending, Unpaid → warning (amber) * - Draft, Cancelled → neutral (navy) - * - Overdue, Collections → danger (red) + * - Overdue, Collections → error (red) * - Refunded → info (blue) */ -const STATUS_CONFIG: Record = { - Draft: { +const INVOICE_STATUS_CONFIG: StatusConfigMap = { + draft: { + variant: "neutral", icon: , - iconColor: "text-neutral", - color: "bg-neutral-bg text-neutral border-neutral-border", label: "Draft", }, - Pending: { + pending: { + variant: "warning", icon: , - iconColor: "text-warning", - color: "bg-warning-bg text-warning border-warning-border", label: "Pending", }, - Paid: { + paid: { + variant: "success", icon: , - iconColor: "text-success", - color: "bg-success-bg text-success border-success-border", label: "Paid", }, - Unpaid: { + unpaid: { + variant: "warning", icon: , - iconColor: "text-warning", - color: "bg-warning-bg text-warning border-warning-border", label: "Unpaid", }, - Overdue: { + overdue: { + variant: "error", icon: , - iconColor: "text-danger", - color: "bg-danger-bg text-danger border-danger-border", label: "Overdue", }, - Cancelled: { + cancelled: { + variant: "neutral", icon: , - iconColor: "text-neutral", - color: "bg-neutral-bg text-neutral border-neutral-border", label: "Cancelled", }, - Refunded: { + refunded: { + variant: "info", icon: , - iconColor: "text-info", - color: "bg-info-bg text-info border-info-border", label: "Refunded", }, - Collections: { + collections: { + variant: "error", icon: , - iconColor: "text-danger", - color: "bg-danger-bg text-danger border-danger-border", label: "Collections", }, }; -const DEFAULT_STATUS: StatusConfig = { +const DEFAULT_CONFIG = { + variant: "neutral" as const, icon: , - iconColor: "text-neutral", - color: "bg-neutral-bg text-neutral border-neutral-border", label: "Unknown", }; -export function InvoiceStatusBadge({ status }: { status: InvoiceStatus }) { - const config = STATUS_CONFIG[status] ?? DEFAULT_STATUS; +interface InvoiceStatusBadgeProps { + status: InvoiceStatus; +} +/** + * InvoiceStatusBadge - Displays the status of an invoice. + * + * @example + * ```tsx + * + * + * ``` + */ +export function InvoiceStatusBadge({ status }: InvoiceStatusBadgeProps) { return ( - - {config.icon} - {config.label} - + ); } diff --git a/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx b/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx index fd95ded7..b8a95a51 100644 --- a/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx +++ b/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx @@ -14,13 +14,13 @@ import { Button } from "@/components/atoms/button"; import { BillingStatusBadge } from "../BillingStatusBadge"; import type { Invoice } from "@customer-portal/domain/billing"; import { Formatting } from "@customer-portal/domain/toolkit"; - -const { formatCurrency } = Formatting; import { cn, formatIsoDate } from "@/shared/utils"; import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling"; import { openSsoLink } from "@/features/billing/utils/sso"; import { logger } from "@/core/logger"; +const { formatCurrency } = Formatting; + interface InvoiceTableProps { invoices: Invoice[]; loading?: boolean; @@ -165,7 +165,7 @@ export function InvoiceTable({ {invoice.daysOverdue && (
- {invoice.daysOverdue} day{invoice.daysOverdue !== 1 ? "s" : ""} overdue + {invoice.daysOverdue} day{invoice.daysOverdue === 1 ? "" : "s"} overdue
)}
@@ -244,9 +244,7 @@ export function InvoiceTable({ void handleDownload(invoice, event); }} loading={isDownloadLoading} - leftIcon={ - !isDownloadLoading ? : undefined - } + leftIcon={isDownloadLoading ? undefined : } className="text-xs font-medium" title="Download PDF" > diff --git a/apps/portal/src/features/billing/views/InvoiceDetail.tsx b/apps/portal/src/features/billing/views/InvoiceDetail.tsx index 6e1117c7..cb5e43c1 100644 --- a/apps/portal/src/features/billing/views/InvoiceDetail.tsx +++ b/apps/portal/src/features/billing/views/InvoiceDetail.tsx @@ -21,7 +21,7 @@ export function InvoiceDetailContainer() { const [loadingDownload, setLoadingDownload] = useState(false); const [loadingPayment, setLoadingPayment] = useState(false); - const rawInvoiceParam = params.id; + const rawInvoiceParam = params["id"]; const invoiceIdParam = Array.isArray(rawInvoiceParam) ? rawInvoiceParam[0] : rawInvoiceParam; const createSsoLinkMutation = useCreateInvoiceSsoLink(); const { data: invoice, error } = useInvoice(invoiceIdParam ?? ""); @@ -140,7 +140,7 @@ export function InvoiceDetailContainer() {
- +
{ + if (error) return new Error(error); + if (paymentMethodsError instanceof Error) return paymentMethodsError; + if (paymentMethodsError) return new Error(String(paymentMethodsError)); + return null; + })(); if (combinedError) { return ( @@ -153,7 +152,7 @@ export function PaymentMethodsContainer() {

Your Payment Methods

{paymentMethodsData.paymentMethods.length} payment method - {paymentMethodsData.paymentMethods.length !== 1 ? "s" : ""} on file + {paymentMethodsData.paymentMethods.length === 1 ? "" : "s"} on file

diff --git a/apps/portal/src/features/checkout/api/checkout-params.api.ts b/apps/portal/src/features/checkout/api/checkout-params.api.ts index ad19b5e7..317ed4b4 100644 --- a/apps/portal/src/features/checkout/api/checkout-params.api.ts +++ b/apps/portal/src/features/checkout/api/checkout-params.api.ts @@ -11,7 +11,7 @@ import { export interface CheckoutParamsSnapshot { orderType: OrderTypeValue; selections: OrderSelections; - configuration?: OrderConfigurations; + configuration?: OrderConfigurations | undefined; planReference: string | null; warnings: string[]; } @@ -19,11 +19,11 @@ export interface CheckoutParamsSnapshot { export class CheckoutParamsService { private static toRecord(params: URLSearchParams): Record { const record: Record = {}; - params.forEach((value, key) => { + for (const [key, value] of params.entries()) { if (key !== "type") { record[key] = value; } - }); + } return record; } diff --git a/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx b/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx index 2d70bdc0..41832c25 100644 --- a/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx +++ b/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx @@ -18,8 +18,10 @@ import { billingService } from "@/features/billing/api/billing.api"; import { openSsoLink } from "@/features/billing/utils/sso"; import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions"; import { ACTIVE_INTERNET_SUBSCRIPTION_WARNING } from "@/features/checkout/constants"; -import { useInternetEligibility } from "@/features/services/hooks/useInternetEligibility"; -import { useRequestInternetEligibilityCheck } from "@/features/services/hooks/useInternetEligibility"; +import { + useInternetEligibility, + useRequestInternetEligibilityCheck, +} from "@/features/services/hooks/useInternetEligibility"; import { useResidenceCardVerification, useSubmitResidenceCard, @@ -96,10 +98,14 @@ export function AccountCheckoutContainer() { // Eligibility const eligibilityQuery = useInternetEligibility({ enabled: isInternetOrder }); - const eligibilityValue = eligibilityQuery.data?.eligibility; - const eligibilityStatus = eligibilityQuery.data?.status; - const eligibilityRequestedAt = eligibilityQuery.data?.requestedAt; - const eligibilityNotes = eligibilityQuery.data?.notes; + const eligibilityData = eligibilityQuery.data as + | { eligibility?: string; status?: string; requestedAt?: string; notes?: string } + | null + | undefined; + const eligibilityValue = eligibilityData?.eligibility; + const eligibilityStatus = eligibilityData?.status; + const eligibilityRequestedAt = eligibilityData?.requestedAt; + const eligibilityNotes = eligibilityData?.notes; const eligibilityRequest = useRequestInternetEligibilityCheck(); const eligibilityLoading = Boolean(isInternetOrder && eligibilityQuery.isLoading); const eligibilityNotRequested = Boolean( @@ -290,14 +296,26 @@ export function AccountCheckoutContainer() { isPending: eligibilityPending, isNotRequested: eligibilityNotRequested, isIneligible: eligibilityIneligible, - notes: eligibilityNotes, - requestedAt: eligibilityRequestedAt, + notes: eligibilityNotes ?? null, + requestedAt: eligibilityRequestedAt ?? null, refetch: () => void eligibilityQuery.refetch(), }} - eligibilityRequest={eligibilityRequest} + eligibilityRequest={{ + isPending: eligibilityRequest.isPending, + mutate: (data: { + address?: Partial["address"]> | undefined; + notes?: string | undefined; + }) => { + const { notes, address: addr } = data; + eligibilityRequest.mutate({ + ...(addr !== undefined && { address: addr }), + ...(notes !== undefined && { notes }), + }); + }, + }} hasServiceAddress={hasServiceAddress} addressLabel={addressLabel} - userAddress={user?.address} + userAddress={user?.address ?? {}} planSku={cartItem.planSku} /> @@ -331,11 +349,17 @@ export function AccountCheckoutContainer() { isLoading={residenceCardQuery.isLoading} isError={residenceCardQuery.isError} status={residenceStatus} - data={residenceCardQuery.data} + data={ + residenceCardQuery.data ?? { + submittedAt: null, + reviewedAt: null, + reviewerNotes: null, + } + } onRefetch={() => void residenceCardQuery.refetch()} onSubmitFile={handleSubmitResidenceCard} isSubmitting={submitResidenceCard.isPending} - submitError={submitResidenceCard.error ?? undefined} + submitError={submitResidenceCard.error ?? null} formatDateTime={formatDateTime} />
diff --git a/apps/portal/src/features/checkout/components/CheckoutEntry.tsx b/apps/portal/src/features/checkout/components/CheckoutEntry.tsx index 82f21e39..bd0c8637 100644 --- a/apps/portal/src/features/checkout/components/CheckoutEntry.tsx +++ b/apps/portal/src/features/checkout/components/CheckoutEntry.tsx @@ -18,7 +18,7 @@ import { useAuthSession, useAuthStore } from "@/features/auth/stores/auth.store" import { logger } from "@/core/logger"; const signatureFromSearchParams = (params: URLSearchParams): string => { - const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)); + const entries = [...params.entries()].sort(([a], [b]) => a.localeCompare(b)); return entries.map(([key, value]) => `${key}=${value}`).join("&"); }; @@ -35,9 +35,9 @@ const cartItemFromCheckoutCart = ( if (!planSku) { throw new Error("Checkout cart did not include a plan. Please re-select your plan."); } - const addonSkus = Array.from( - new Set(cart.items.map(item => item.sku).filter(sku => sku && sku !== planSku)) - ); + const addonSkus = [ + ...new Set(cart.items.map(item => item.sku).filter(sku => sku && sku !== planSku)), + ]; return { orderType: checkoutOrderType, diff --git a/apps/portal/src/features/checkout/components/CheckoutStatusBanners.tsx b/apps/portal/src/features/checkout/components/CheckoutStatusBanners.tsx index 68470f38..2e5b084b 100644 --- a/apps/portal/src/features/checkout/components/CheckoutStatusBanners.tsx +++ b/apps/portal/src/features/checkout/components/CheckoutStatusBanners.tsx @@ -11,18 +11,18 @@ interface CheckoutStatusBannersProps { isPending: boolean; isNotRequested: boolean; isIneligible: boolean; - notes?: string | null; - requestedAt?: string | null; + notes?: string | null | undefined; + requestedAt?: string | null | undefined; refetch: () => void; }; eligibilityRequest: { isPending: boolean; - mutate: (data: { address?: Partial
; notes: string }) => void; + mutate: (data: { address?: Partial
| undefined; notes?: string | undefined }) => void; }; hasServiceAddress: boolean; addressLabel: string; - userAddress?: Partial
; - planSku?: string; + userAddress?: Partial
| undefined; + planSku?: string | undefined; } export function CheckoutStatusBanners({ @@ -34,19 +34,17 @@ export function CheckoutStatusBanners({ userAddress, planSku, }: CheckoutStatusBannersProps) { - return ( - <> - {activeInternetWarning && ( - - {activeInternetWarning} - - )} - - {eligibility.isLoading ? ( + const renderEligibilityBanner = () => { + if (eligibility.isLoading) { + return ( - We’re loading your current eligibility status. + We're loading your current eligibility status. - ) : eligibility.isError ? ( + ); + } + + if (eligibility.isError) { + return (
@@ -62,11 +60,15 @@ export function CheckoutStatusBanners({
- ) : eligibility.isPending ? ( + ); + } + + if (eligibility.isPending) { + return (
- We’re verifying whether our service is available at your residence. Once eligibility + We're verifying whether our service is available at your residence. Once eligibility is confirmed, you can submit your internet order.
- ) : eligibility.isNotRequested ? ( + ); + } + + if (eligibility.isNotRequested) { + return (
@@ -114,11 +120,15 @@ export function CheckoutStatusBanners({ )}
- ) : eligibility.isIneligible ? ( + ); + } + + if (eligibility.isIneligible) { + return (

- Our team reviewed your address and determined service isn’t available right now. + Our team reviewed your address and determined service isn't available right now.

{eligibility.notes ? (

{eligibility.notes}

@@ -132,7 +142,21 @@ export function CheckoutStatusBanners({
- ) : null} + ); + } + + return null; + }; + + return ( + <> + {activeInternetWarning && ( + + {activeInternetWarning} + + )} + + {renderEligibilityBanner()} ); } diff --git a/apps/portal/src/features/checkout/components/OrderSummaryCard.tsx b/apps/portal/src/features/checkout/components/OrderSummaryCard.tsx deleted file mode 100644 index bf0e50fd..00000000 --- a/apps/portal/src/features/checkout/components/OrderSummaryCard.tsx +++ /dev/null @@ -1,82 +0,0 @@ -"use client"; - -import type { CartItem } from "@customer-portal/domain/checkout"; -import { ShoppingCartIcon } from "@heroicons/react/24/outline"; - -interface OrderSummaryCardProps { - cartItem: CartItem; -} - -/** - * OrderSummaryCard - Sidebar component showing cart summary - */ -export function OrderSummaryCard({ cartItem }: OrderSummaryCardProps) { - const { planName, orderType, pricing, addonSkus } = cartItem; - - return ( -
-
- -

Order Summary

-
- - {/* Plan info */} -
-
-
-

{planName}

-

- {orderType.toLowerCase()} Plan -

-
-
-
- - {/* Price breakdown */} -
- {pricing.breakdown.map((item, index) => ( -
- {item.label} - - {item.monthlyPrice ? `¥${item.monthlyPrice.toLocaleString()}/mo` : ""} - {item.oneTimePrice ? `¥${item.oneTimePrice.toLocaleString()}` : ""} - -
- ))} - - {addonSkus.length > 0 && ( -
- + {addonSkus.length} add-on{addonSkus.length > 1 ? "s" : ""} -
- )} -
- - {/* Totals */} -
- {pricing.monthlyTotal > 0 && ( -
- Monthly - - ¥{pricing.monthlyTotal.toLocaleString()}/mo - -
- )} - {pricing.oneTimeTotal > 0 && ( -
- One-time - - ¥{pricing.oneTimeTotal.toLocaleString()} - -
- )} -
- - {/* Secure checkout badge */} -
-

- 🔒 Your payment information is encrypted and secure -

-
-
- ); -} diff --git a/apps/portal/src/features/checkout/components/checkout-sections/IdentityVerificationSection.tsx b/apps/portal/src/features/checkout/components/checkout-sections/IdentityVerificationSection.tsx index 1f8c0a3b..e13bd4b2 100644 --- a/apps/portal/src/features/checkout/components/checkout-sections/IdentityVerificationSection.tsx +++ b/apps/portal/src/features/checkout/components/checkout-sections/IdentityVerificationSection.tsx @@ -10,20 +10,20 @@ import { ResidenceCardUploadInput } from "./ResidenceCardUploadInput"; type VerificationStatus = "verified" | "pending" | "rejected" | "not_submitted" | undefined; interface VerificationData { - submittedAt?: string | null; - reviewedAt?: string | null; - reviewerNotes?: string | null; + submittedAt?: string | null | undefined; + reviewedAt?: string | null | undefined; + reviewerNotes?: string | null | undefined; } interface IdentityVerificationSectionProps { isLoading: boolean; isError: boolean; status: VerificationStatus; - data?: VerificationData; + data?: VerificationData | undefined; onRefetch: () => void; onSubmitFile: (file: File) => void; isSubmitting: boolean; - submitError?: Error | null; + submitError?: Error | null | undefined; formatDateTime: (iso?: string | null) => string | null; } @@ -51,15 +51,13 @@ export function IdentityVerificationSection({ } }; - return ( - } - right={getStatusPill()} - > - {isLoading ? ( -
Checking residence card status...
- ) : isError ? ( + const renderContent = () => { + if (isLoading) { + return
Checking residence card status...
; + } + + if (isError) { + return ( - ) : status === "verified" ? ( + ); + } + + if (status === "verified") { + return ( - ) : status === "pending" ? ( + ); + } + + if (status === "pending") { + return ( - ) : ( - - )} + ); + } + + return ( + + ); + }; + + return ( + } + right={getStatusPill()} + > + {renderContent()} ); } interface VerifiedContentProps { - data?: VerificationData; + data?: VerificationData | undefined; formatDateTime: (iso?: string | null) => string | null; onSubmitFile: (file: File) => void; isSubmitting: boolean; - submitError?: Error | null; + submitError?: Error | null | undefined; } function VerifiedContent({ @@ -156,11 +174,11 @@ function VerifiedContent({ } interface PendingContentProps { - data?: VerificationData; + data?: VerificationData | undefined; formatDateTime: (iso?: string | null) => string | null; onSubmitFile: (file: File) => void; isSubmitting: boolean; - submitError?: Error | null; + submitError?: Error | null | undefined; } function PendingContent({ @@ -208,10 +226,10 @@ function PendingContent({ interface NotSubmittedContentProps { status: VerificationStatus; - reviewerNotes?: string | null; + reviewerNotes?: string | null | undefined; onSubmitFile: (file: File) => void; isSubmitting: boolean; - submitError?: Error | null; + submitError?: Error | null | undefined; } function NotSubmittedContent({ diff --git a/apps/portal/src/features/checkout/components/checkout-sections/PaymentMethodSection.tsx b/apps/portal/src/features/checkout/components/checkout-sections/PaymentMethodSection.tsx index bacc4941..dc5979e5 100644 --- a/apps/portal/src/features/checkout/components/checkout-sections/PaymentMethodSection.tsx +++ b/apps/portal/src/features/checkout/components/checkout-sections/PaymentMethodSection.tsx @@ -8,7 +8,7 @@ import { StatusPill } from "@/components/atoms/status-pill"; interface PaymentMethodDisplay { title: string; - subtitle?: string; + subtitle?: string | undefined; } interface PaymentMethodSectionProps { @@ -30,29 +30,13 @@ export function PaymentMethodSection({ onRefresh, isOpeningPortal, }: PaymentMethodSectionProps) { - return ( - } - right={ -
- {hasPaymentMethod && } - -
- } - > - {isLoading ? ( -
Checking payment methods...
- ) : isError ? ( + const renderContent = () => { + if (isLoading) { + return
Checking payment methods...
; + } + + if (isError) { + return (
- ) : hasPaymentMethod ? ( + ); + } + + if (hasPaymentMethod) { + return (
{paymentMethodDisplay && (
@@ -94,24 +82,50 @@ export function PaymentMethodSection({ We securely charge your saved payment method after the order is approved.

- ) : ( - -
- - -
-
- )} + ); + } + + return ( + +
+ + +
+
+ ); + }; + + return ( + } + right={ +
+ {hasPaymentMethod && } + +
+ } + > + {renderContent()}
); } diff --git a/apps/portal/src/features/checkout/components/checkout-sections/ResidenceCardUploadInput.tsx b/apps/portal/src/features/checkout/components/checkout-sections/ResidenceCardUploadInput.tsx index 0cdb0c58..a684d14a 100644 --- a/apps/portal/src/features/checkout/components/checkout-sections/ResidenceCardUploadInput.tsx +++ b/apps/portal/src/features/checkout/components/checkout-sections/ResidenceCardUploadInput.tsx @@ -7,9 +7,9 @@ interface ResidenceCardUploadInputProps { onSubmit: (file: File) => void; isPending: boolean; isError: boolean; - error?: Error | null; - submitLabel?: string; - description?: string; + error?: Error | null | undefined; + submitLabel?: string | undefined; + description?: string | undefined; } export function ResidenceCardUploadInput({ diff --git a/apps/portal/src/features/checkout/components/index.ts b/apps/portal/src/features/checkout/components/index.ts index bde188f0..6c17ec82 100644 --- a/apps/portal/src/features/checkout/components/index.ts +++ b/apps/portal/src/features/checkout/components/index.ts @@ -1,5 +1,4 @@ export { CheckoutShell } from "./CheckoutShell"; -export { OrderSummaryCard } from "./OrderSummaryCard"; export { EmptyCartRedirect } from "./EmptyCartRedirect"; export { OrderConfirmation } from "./OrderConfirmation"; export { CheckoutErrorBoundary } from "./CheckoutErrorBoundary"; diff --git a/apps/portal/src/features/dashboard/components/DashboardActivityItem.tsx b/apps/portal/src/features/dashboard/components/DashboardActivityItem.tsx index 8634acb8..7dabfb13 100644 --- a/apps/portal/src/features/dashboard/components/DashboardActivityItem.tsx +++ b/apps/portal/src/features/dashboard/components/DashboardActivityItem.tsx @@ -13,8 +13,8 @@ import { formatActivityDescription } from "../utils/dashboard.utils"; interface DashboardActivityItemProps { activity: Activity; - onClick?: () => void; - showConnector?: boolean; + onClick?: (() => void) | undefined; + showConnector?: boolean | undefined; } const ICON_COMPONENTS: Record>> = { diff --git a/apps/portal/src/features/dashboard/components/QuickStats.tsx b/apps/portal/src/features/dashboard/components/QuickStats.tsx index c776f177..d31617f1 100644 --- a/apps/portal/src/features/dashboard/components/QuickStats.tsx +++ b/apps/portal/src/features/dashboard/components/QuickStats.tsx @@ -12,9 +12,9 @@ import { cn } from "@/shared/utils"; interface QuickStatsProps { activeSubscriptions: number; openCases: number; - recentOrders?: number; - isLoading?: boolean; - className?: string; + recentOrders?: number | undefined; + isLoading?: boolean | undefined; + className?: string | undefined; } type StatTone = "primary" | "info" | "warning" | "success"; diff --git a/apps/portal/src/features/dashboard/components/TaskCard.tsx b/apps/portal/src/features/dashboard/components/TaskCard.tsx index 6bd478c0..7c9f02fa 100644 --- a/apps/portal/src/features/dashboard/components/TaskCard.tsx +++ b/apps/portal/src/features/dashboard/components/TaskCard.tsx @@ -19,17 +19,17 @@ export interface TaskCardProps { /** Action button label */ actionLabel: string; /** Link destination for the card click (navigates to detail page) */ - detailHref?: string; + detailHref?: string | undefined; /** Click handler for the action button */ - onAction?: () => void; + onAction?: (() => void) | undefined; /** Visual tone based on priority */ - tone?: TaskTone; + tone?: TaskTone | undefined; /** Loading state for the action button */ - isLoading?: boolean; + isLoading?: boolean | undefined; /** Loading text for the action button */ - loadingText?: string; + loadingText?: string | undefined; /** Additional className */ - className?: string; + className?: string | undefined; } const toneStyles: Record< @@ -119,7 +119,7 @@ export function TaskCard({ }} isLoading={isLoading} loadingText={loadingText} - rightIcon={!isLoading ? : undefined} + rightIcon={isLoading ? undefined : } className="shrink-0" > {actionLabel} diff --git a/apps/portal/src/features/dashboard/utils/dashboard.utils.ts b/apps/portal/src/features/dashboard/utils/dashboard.utils.ts index 1683b1b0..33eebf91 100644 --- a/apps/portal/src/features/dashboard/utils/dashboard.utils.ts +++ b/apps/portal/src/features/dashboard/utils/dashboard.utils.ts @@ -72,7 +72,7 @@ export function formatActivityDate(date: string): string { return activityDate.toLocaleDateString("en-US", { month: "short", day: "numeric", - year: activityDate.getFullYear() !== now.getFullYear() ? "numeric" : undefined, + year: activityDate.getFullYear() === now.getFullYear() ? undefined : "numeric", }); } } catch { diff --git a/apps/portal/src/features/dashboard/views/DashboardView.tsx b/apps/portal/src/features/dashboard/views/DashboardView.tsx index fbea01a1..3c083aaf 100644 --- a/apps/portal/src/features/dashboard/views/DashboardView.tsx +++ b/apps/portal/src/features/dashboard/views/DashboardView.tsx @@ -35,7 +35,11 @@ export function DashboardView() { useEffect(() => { if (!isAuthenticated || !user?.id) return; - const status = eligibility?.status; + const eligibilityData = eligibility as + | { status?: string; eligibility?: string } + | null + | undefined; + const status = eligibilityData?.status; if (!status) return; // query not ready yet const key = `cp:internet-eligibility:last:${user.id}`; @@ -46,12 +50,13 @@ export function DashboardView() { return; } - if (status === "eligible" && typeof eligibility?.eligibility === "string") { - const current = eligibility.eligibility.trim(); + const eligibilityValue = eligibilityData?.eligibility; + if (status === "eligible" && typeof eligibilityValue === "string") { + const current = eligibilityValue.trim(); if (last === "PENDING") { setEligibilityToast({ visible: true, - text: "We’ve finished reviewing your address — you can now choose personalized internet plans.", + text: "We've finished reviewing your address — you can now choose personalized internet plans.", tone: "success", }); if (hideToastTimeout.current) window.clearTimeout(hideToastTimeout.current); @@ -62,7 +67,7 @@ export function DashboardView() { } localStorage.setItem(key, current); } - }, [eligibility?.eligibility, eligibility?.status, isAuthenticated, user?.id]); + }, [eligibility, isAuthenticated, user?.id]); useEffect(() => { return () => { @@ -97,14 +102,14 @@ export function DashboardView() { // Handle error state if (error) { - const errorMessage = - typeof error === "string" - ? error - : error instanceof Error - ? error.message - : typeof error === "object" && error && "message" in error - ? String((error as { message?: unknown }).message) - : "An unexpected error occurred"; + const errorMessage = (() => { + if (typeof error === "string") return error; + if (error instanceof Error) return error.message; + if (typeof error === "object" && "message" in error) { + return String((error as { message?: unknown }).message); + } + return "An unexpected error occurred"; + })(); return ( diff --git a/apps/portal/src/features/get-started/api/get-started.api.ts b/apps/portal/src/features/get-started/api/get-started.api.ts index 0778999f..0c162949 100644 --- a/apps/portal/src/features/get-started/api/get-started.api.ts +++ b/apps/portal/src/features/get-started/api/get-started.api.ts @@ -11,6 +11,7 @@ import { quickEligibilityResponseSchema, guestEligibilityResponseSchema, maybeLaterResponseSchema, + signupWithEligibilityResponseSchema, type SendVerificationCodeRequest, type SendVerificationCodeResponse, type VerifyCodeRequest, @@ -22,6 +23,8 @@ import { type CompleteAccountRequest, type MaybeLaterRequest, type MaybeLaterResponse, + type SignupWithEligibilityRequest, + type SignupWithEligibilityResponse, } from "@customer-portal/domain/get-started"; import { authResponseSchema, type AuthResponse } from "@customer-portal/domain/auth"; @@ -102,3 +105,26 @@ export async function completeAccount(request: CompleteAccountRequest): Promise< const data = getDataOrThrow(response, "Failed to complete account"); return authResponseSchema.parse(data); } + +/** + * Full signup with eligibility check (inline flow) + * Creates SF Account + Case + WHMCS + Portal in one operation + * Used when user clicks "Create Account" on eligibility check page + */ +export async function signupWithEligibility( + request: SignupWithEligibilityRequest +): Promise { + const response = await apiClient.POST< + SignupWithEligibilityResponse & { user?: unknown; session?: unknown } + >(`${BASE_PATH}/signup-with-eligibility`, { + body: request, + }); + const data = getDataOrThrow(response, "Failed to create account"); + // Parse the base response, but allow extra fields (user, session) + const baseResponse = signupWithEligibilityResponseSchema.parse(data); + return { + ...baseResponse, + user: data.user, + session: data.session, + }; +} diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx index 8b30f70e..39f27458 100644 --- a/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx +++ b/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx @@ -20,15 +20,15 @@ import { useGetStartedStore } from "../../../stores/get-started.store"; import { useRouter } from "next/navigation"; interface FormErrors { - firstName?: string; - lastName?: string; - address?: string; - password?: string; - confirmPassword?: string; - phone?: string; - dateOfBirth?: string; - gender?: string; - acceptTerms?: string; + firstName?: string | undefined; + lastName?: string | undefined; + address?: string | undefined; + password?: string | undefined; + confirmPassword?: string | undefined; + phone?: string | undefined; + dateOfBirth?: string | undefined; + gender?: string | undefined; + acceptTerms?: string | undefined; } export function CompleteAccountStep() { diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/steps/VerificationStep.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/steps/VerificationStep.tsx index 27b5af0f..be1b3e9c 100644 --- a/apps/portal/src/features/get-started/components/GetStartedForm/steps/VerificationStep.tsx +++ b/apps/portal/src/features/get-started/components/GetStartedForm/steps/VerificationStep.tsx @@ -59,7 +59,7 @@ export function VerificationStep() { onChange={handleCodeChange} onComplete={handleCodeComplete} disabled={loading} - error={error || undefined} + error={error ?? undefined} autoFocus /> diff --git a/apps/portal/src/features/get-started/components/OtpInput/OtpInput.tsx b/apps/portal/src/features/get-started/components/OtpInput/OtpInput.tsx index bb8bc55b..93b90fc2 100644 --- a/apps/portal/src/features/get-started/components/OtpInput/OtpInput.tsx +++ b/apps/portal/src/features/get-started/components/OtpInput/OtpInput.tsx @@ -17,13 +17,13 @@ import { import { cn } from "@/shared/utils"; interface OtpInputProps { - length?: number; + length?: number | undefined; value: string; onChange: (value: string) => void; - onComplete?: (value: string) => void; - disabled?: boolean; - error?: string; - autoFocus?: boolean; + onComplete?: ((value: string) => void) | undefined; + disabled?: boolean | undefined; + error?: string | undefined; + autoFocus?: boolean | undefined; } export function OtpInput({ diff --git a/apps/portal/src/features/get-started/stores/get-started.store.ts b/apps/portal/src/features/get-started/stores/get-started.store.ts index 352c9fd3..570ef308 100644 --- a/apps/portal/src/features/get-started/stores/get-started.store.ts +++ b/apps/portal/src/features/get-started/stores/get-started.store.ts @@ -23,13 +23,13 @@ export type GetStartedStep = * Address data format used in the get-started form */ export interface GetStartedAddress { - address1?: string; - address2?: string; - city?: string; - state?: string; - postcode?: string; - country?: string; - countryCode?: string; + address1?: string | undefined; + address2?: string | undefined; + city?: string | undefined; + state?: string | undefined; + postcode?: string | undefined; + country?: string | undefined; + countryCode?: string | undefined; } export interface GetStartedFormData { @@ -184,7 +184,17 @@ export const useGetStartedStore = create()((set, get) => ({ firstName: prefill?.firstName ?? currentFormData.firstName, lastName: prefill?.lastName ?? currentFormData.lastName, phone: prefill?.phone ?? currentFormData.phone, - address: prefill?.address ?? currentFormData.address, + address: prefill?.address + ? { + address1: prefill.address.address1, + address2: prefill.address.address2, + city: prefill.address.city, + state: prefill.address.state, + postcode: prefill.address.postcode, + country: prefill.address.country, + countryCode: prefill.address.countryCode, + } + : currentFormData.address, }, step: "account-status", }); @@ -251,8 +261,9 @@ export const useGetStartedStore = create()((set, get) => ({ "complete-account", ]; const currentIndex = stepOrder.indexOf(step); - if (currentIndex > 0) { - set({ step: stepOrder[currentIndex - 1], error: null }); + const prevStep = stepOrder[currentIndex - 1]; + if (currentIndex > 0 && prevStep) { + set({ step: prevStep, error: null }); } }, diff --git a/apps/portal/src/features/get-started/views/GetStartedView.tsx b/apps/portal/src/features/get-started/views/GetStartedView.tsx index 250620a4..d6fa4fbd 100644 --- a/apps/portal/src/features/get-started/views/GetStartedView.tsx +++ b/apps/portal/src/features/get-started/views/GetStartedView.tsx @@ -74,7 +74,8 @@ export function GetStartedView() { // Validate timestamp to prevent stale data const isStale = - !storedTimestamp || Date.now() - parseInt(storedTimestamp, 10) > SESSION_STALE_THRESHOLD_MS; + !storedTimestamp || + Date.now() - Number.parseInt(storedTimestamp, 10) > SESSION_STALE_THRESHOLD_MS; // Clear sessionStorage immediately after reading clearGetStartedSessionStorage(); diff --git a/apps/portal/src/features/landing-page/components/FloatingGlassCard.tsx b/apps/portal/src/features/landing-page/components/FloatingGlassCard.tsx index 2b183a45..10ab02d1 100644 --- a/apps/portal/src/features/landing-page/components/FloatingGlassCard.tsx +++ b/apps/portal/src/features/landing-page/components/FloatingGlassCard.tsx @@ -34,7 +34,7 @@ export function FloatingGlassCard({ accentColor = "primary", style, }: FloatingGlassCardProps) { - const colorClass = accentColorMap[accentColor] || accentColorMap.primary; + const colorClass = accentColorMap[accentColor] || accentColorMap["primary"]; return (
{ const query: Record = {}; - if (params?.limit) query.limit = String(params.limit); - if (params?.offset) query.offset = String(params.offset); - if (params?.includeRead !== undefined) query.includeRead = String(params.includeRead); + if (params?.limit) query["limit"] = String(params.limit); + if (params?.offset) query["offset"] = String(params.offset); + if (params?.includeRead !== undefined) query["includeRead"] = String(params.includeRead); const response = await apiClient.GET(BASE_PATH, { params: { query }, diff --git a/apps/portal/src/features/notifications/components/NotificationItem.tsx b/apps/portal/src/features/notifications/components/NotificationItem.tsx index 4f056420..3a7a7460 100644 --- a/apps/portal/src/features/notifications/components/NotificationItem.tsx +++ b/apps/portal/src/features/notifications/components/NotificationItem.tsx @@ -70,7 +70,7 @@ export const NotificationItem = memo(function NotificationItem({

{notification.title} diff --git a/apps/portal/src/features/orders/api/orders.api.ts b/apps/portal/src/features/orders/api/orders.api.ts index bb47df9a..86c5070f 100644 --- a/apps/portal/src/features/orders/api/orders.api.ts +++ b/apps/portal/src/features/orders/api/orders.api.ts @@ -66,7 +66,7 @@ async function getOrderById( ): Promise { const response = await apiClient.GET("/api/orders/{sfOrderId}", { params: { path: { sfOrderId: orderId } }, - signal: options.signal, + ...(options.signal ? { signal: options.signal } : {}), }); if (!response.data) { throw new Error("Order not found"); diff --git a/apps/portal/src/features/orders/components/OrderCard.tsx b/apps/portal/src/features/orders/components/OrderCard.tsx index d84be1dc..88b03ec4 100644 --- a/apps/portal/src/features/orders/components/OrderCard.tsx +++ b/apps/portal/src/features/orders/components/OrderCard.tsx @@ -38,12 +38,12 @@ const SERVICE_ICON_STYLES = { export function OrderCard({ order, onClick, footer, className }: OrderCardProps) { const statusDescriptor = deriveOrderStatusDescriptor({ status: order.status, - activationStatus: order.activationStatus, + activationStatus: order.activationStatus ?? "", }); const statusVariant = STATUS_PILL_VARIANT[statusDescriptor.tone]; const serviceCategory = getServiceCategory(order.orderType); const iconStyles = SERVICE_ICON_STYLES[serviceCategory]; - const serviceIcon = ; + const serviceIcon = ; const displayItems = useMemo( () => buildOrderDisplayItems(order.itemsSummary), [order.itemsSummary] diff --git a/apps/portal/src/features/orders/hooks/useOrderUpdates.ts b/apps/portal/src/features/orders/hooks/useOrderUpdates.ts index 5ff76f13..e884be6f 100644 --- a/apps/portal/src/features/orders/hooks/useOrderUpdates.ts +++ b/apps/portal/src/features/orders/hooks/useOrderUpdates.ts @@ -19,7 +19,7 @@ export function useOrderUpdates(orderId: string | undefined, onUpdate: OrderUpda useEffect(() => { if (!orderId) { - return undefined; + return; } let isCancelled = false; diff --git a/apps/portal/src/features/orders/views/OrderDetail.tsx b/apps/portal/src/features/orders/views/OrderDetail.tsx index 9a2e020b..83b2c826 100644 --- a/apps/portal/src/features/orders/views/OrderDetail.tsx +++ b/apps/portal/src/features/orders/views/OrderDetail.tsx @@ -133,8 +133,8 @@ export function OrderDetailContainer() { const statusDescriptor = data ? deriveOrderStatusDescriptor({ status: data.status, - activationStatus: data.activationStatus, - scheduledAt: data.activationScheduledAt, + activationStatus: data.activationStatus ?? "", + scheduledAt: data.activationScheduledAt ?? "", }) : null; diff --git a/apps/portal/src/features/orders/views/OrdersList.tsx b/apps/portal/src/features/orders/views/OrdersList.tsx index fafd57dd..043d7a7a 100644 --- a/apps/portal/src/features/orders/views/OrdersList.tsx +++ b/apps/portal/src/features/orders/views/OrdersList.tsx @@ -78,32 +78,44 @@ export function OrdersListContainer() { )} - {isLoading || !orders ? ( -

- {Array.from({ length: 4 }).map((_, idx) => ( - - ))} -
- ) : isError ? null : orders.length === 0 ? ( - - } - title="No orders yet" - description="You haven't placed any orders yet." - action={{ label: "Browse Services", onClick: () => router.push("/account/services") }} - /> - - ) : ( -
- {orders.map(order => ( - router.push(`/account/orders/${order.id}`)} - /> - ))} -
- )} + {(() => { + if (isLoading || !orders) { + return ( +
+ {Array.from({ length: 4 }).map((_, idx) => ( + + ))} +
+ ); + } + if (isError) return null; + if (orders.length === 0) { + return ( + + } + title="No orders yet" + description="You haven't placed any orders yet." + action={{ + label: "Browse Services", + onClick: () => router.push("/account/services"), + }} + /> + + ); + } + return ( +
+ {orders.map(order => ( + router.push(`/account/orders/${order.id}`)} + /> + ))} +
+ ); + })()} ); } diff --git a/apps/portal/src/features/services/components/base/AddonGroup.tsx b/apps/portal/src/features/services/components/base/AddonGroup.tsx index 0b6b84a4..14f1280b 100644 --- a/apps/portal/src/features/services/components/base/AddonGroup.tsx +++ b/apps/portal/src/features/services/components/base/AddonGroup.tsx @@ -1,28 +1,42 @@ "use client"; import { CheckCircleIcon } from "@heroicons/react/24/solid"; -import type { CatalogProductBase } from "@customer-portal/domain/services"; + +// Local type that includes all properties we need +// This avoids type intersection issues with exactOptionalPropertyTypes +type AddonItem = { + id: string; + sku: string; + name: string; + description?: string | undefined; + displayOrder?: number | undefined; + billingCycle?: string | undefined; + monthlyPrice?: number | undefined; + oneTimePrice?: number | undefined; + unitPrice?: number | undefined; + bundledAddonId?: string | undefined; + isBundledAddon?: boolean | undefined; +}; + interface AddonGroupProps { - addons: Array; + addons: AddonItem[]; selectedAddonSkus: string[]; onAddonToggle: (skus: string[]) => void; - showSkus?: boolean; + showSkus?: boolean | undefined; } type BundledAddonGroup = { id: string; name: string; description: string; - monthlyPrice?: number; - activationPrice?: number; + monthlyPrice?: number | undefined; + activationPrice?: number | undefined; skus: string[]; isBundled: boolean; displayOrder: number; }; -function buildGroupedAddons( - addons: Array -): BundledAddonGroup[] { +function buildGroupedAddons(addons: AddonItem[]): BundledAddonGroup[] { const groups: BundledAddonGroup[] = []; const processed = new Set(); @@ -54,10 +68,7 @@ function buildGroupedAddons( return groups; } -function createBundle( - addon1: CatalogProductBase & { bundledAddonId?: string; isBundledAddon?: boolean }, - addon2: CatalogProductBase & { bundledAddonId?: string; isBundledAddon?: boolean } -): BundledAddonGroup { +function createBundle(addon1: AddonItem, addon2: AddonItem): BundledAddonGroup { // Determine which is monthly vs onetime const monthlyAddon = addon1.billingCycle === "Monthly" ? addon1 : addon2; const onetimeAddon = addon1.billingCycle === "Onetime" ? addon1 : addon2; @@ -83,9 +94,7 @@ function createBundle( }; } -function createStandaloneItem( - addon: CatalogProductBase & { bundledAddonId?: string; isBundledAddon?: boolean } -): BundledAddonGroup { +function createStandaloneItem(addon: AddonItem): BundledAddonGroup { return { id: addon.sku, name: addon.name, diff --git a/apps/portal/src/features/services/components/base/AddressConfirmation.tsx b/apps/portal/src/features/services/components/base/AddressConfirmation.tsx index cac57dbb..630c32c1 100644 --- a/apps/portal/src/features/services/components/base/AddressConfirmation.tsx +++ b/apps/portal/src/features/services/components/base/AddressConfirmation.tsx @@ -224,13 +224,12 @@ export function AddressConfirmation({ }; const statusVariant = addressConfirmed || !requiresAddressVerification ? "success" : "warning"; - const headerTitle = titleOverride - ? titleOverride - : isInternetOrder - ? "Installation Address" - : billingInfo?.isComplete - ? "Service Address" - : "Complete Your Address"; + const headerTitle = (() => { + if (titleOverride) return titleOverride; + if (isInternetOrder) return "Installation Address"; + if (billingInfo?.isComplete) return "Service Address"; + return "Complete Your Address"; + })(); const confirmationMessage = confirmationPrompt ?? "Please confirm this is the correct installation address for your internet service."; diff --git a/apps/portal/src/features/services/components/base/AddressForm.tsx b/apps/portal/src/features/services/components/base/AddressForm.tsx index 0cef444e..4e3d72e2 100644 --- a/apps/portal/src/features/services/components/base/AddressForm.tsx +++ b/apps/portal/src/features/services/components/base/AddressForm.tsx @@ -190,7 +190,7 @@ export function AddressForm({ phoneCountryCode, }; - Object.entries(nextValues).forEach(([key, value]) => { + for (const [key, value] of Object.entries(nextValues)) { if (value !== undefined) { const normalizedValue = key === "country" || key === "countryCode" @@ -198,7 +198,7 @@ export function AddressForm({ : value || ""; formSetValue(key as keyof Address, normalizedValue); } - }); + } const normalizedCountry = normalizeCountryValue(country); const normalizedCountryCode = normalizeCountryValue(countryCode ?? country); diff --git a/apps/portal/src/features/services/components/base/CardPricing.tsx b/apps/portal/src/features/services/components/base/CardPricing.tsx index 5d05a567..7b9b4f79 100644 --- a/apps/portal/src/features/services/components/base/CardPricing.tsx +++ b/apps/portal/src/features/services/components/base/CardPricing.tsx @@ -3,10 +3,10 @@ import { CurrencyYenIcon } from "@heroicons/react/24/outline"; interface CardPricingProps { - monthlyPrice?: number | null; - oneTimePrice?: number | null; - size?: "sm" | "md" | "lg"; - alignment?: "left" | "right"; + monthlyPrice?: number | null | undefined; + oneTimePrice?: number | null | undefined; + size?: "sm" | "md" | "lg" | undefined; + alignment?: "left" | "right" | undefined; } export function CardPricing({ diff --git a/apps/portal/src/features/services/components/base/EnhancedOrderSummary.tsx b/apps/portal/src/features/services/components/base/EnhancedOrderSummary.tsx deleted file mode 100644 index db195a6e..00000000 --- a/apps/portal/src/features/services/components/base/EnhancedOrderSummary.tsx +++ /dev/null @@ -1,383 +0,0 @@ -"use client"; - -import { ReactNode } from "react"; -import { ArrowLeftIcon, ArrowRightIcon, InformationCircleIcon } from "@heroicons/react/24/outline"; -import { AnimatedCard } from "@/components/molecules"; -import { Formatting } from "@customer-portal/domain/toolkit"; - -const { formatCurrency } = Formatting; -import { Button } from "@/components/atoms/button"; -import { useRouter } from "next/navigation"; - -// Align with shared services contracts -import type { CatalogProductBase } from "@customer-portal/domain/services"; -import type { CheckoutTotals } from "@customer-portal/domain/orders"; - -// Enhanced order item representation for UI summary -export type OrderItem = CatalogProductBase & { - id?: string; - quantity?: number; - autoAdded?: boolean; - itemClass?: string; -}; - -export interface OrderSummaryConfiguration { - label: string; - value: string; - important?: boolean; -} - -// Totals summary for UI; base fields mirror API aggregates -export type OrderTotals = CheckoutTotals & { - annualTotal?: number; - discountAmount?: number; - taxAmount?: number; -}; - -export interface EnhancedOrderSummaryProps { - // Core order data - items: OrderItem[]; - totals: OrderTotals; - - // Plan information - planName: string; - planTier?: string; - planDescription?: string; - - // Configuration details - configurations?: OrderSummaryConfiguration[]; - - // Additional information - infoLines?: string[]; - disclaimers?: string[]; - - // Pricing breakdown control - showDetailedBreakdown?: boolean; - showTaxes?: boolean; - showDiscounts?: boolean; - - // Actions - onContinue?: () => void; - onBack?: () => void; - backUrl?: string; - backLabel?: string; - continueLabel?: string; - showActions?: boolean; - - // State - disabled?: boolean; - loading?: boolean; - - // Styling - variant?: "simple" | "detailed" | "checkout"; - size?: "compact" | "standard" | "large"; - - // Custom content - children?: ReactNode; - headerContent?: ReactNode; - footerContent?: ReactNode; -} - -export function EnhancedOrderSummary({ - items, - totals, - planName, - planTier, - planDescription, - configurations = [], - infoLines = [], - disclaimers = [], - showDetailedBreakdown = true, - showTaxes = false, - showDiscounts = false, - onContinue, - onBack, - backUrl, - backLabel = "Back", - continueLabel = "Continue", - showActions = true, - disabled = false, - loading = false, - variant = "detailed", - size = "standard", - children, - headerContent, - footerContent, -}: EnhancedOrderSummaryProps) { - const router = useRouter(); - const sizeClasses = { - compact: "p-4", - standard: "p-6", - large: "p-8", - }; - - const getVariantClasses = () => { - switch (variant) { - case "checkout": - return "bg-gradient-to-br from-gray-50 to-blue-50 border-2 border-blue-200 shadow-lg"; - case "detailed": - return "bg-white border border-gray-200 shadow-md"; - default: - return "bg-white border border-gray-200"; - } - }; - - const monthlyItems = items.filter(item => item.billingCycle === "Monthly"); - const oneTimeItems = items.filter(item => item.billingCycle === "Onetime"); - - return ( - - {/* Header */} -
-
-

Order Summary

- {variant === "checkout" && ( -
-
- ¥{formatCurrency(totals.monthlyTotal)}/mo -
- {totals.oneTimeTotal > 0 && ( -
- + ¥{formatCurrency(totals.oneTimeTotal)} one-time -
- )} -
- )} -
- - {headerContent} -
- - {/* Plan Information */} -
-
-
-

- {planName} - {planTier && ({planTier})} -

- {planDescription &&

{planDescription}

} -
-
- - {/* Configuration Details */} - {configurations.length > 0 && ( -
- {configurations.map((config, index) => ( -
- - {config.label}: - - - {config.value} - -
- ))} -
- )} -
- - {/* Detailed Pricing Breakdown */} - {showDetailedBreakdown && variant !== "simple" && ( -
- {/* Monthly Services */} - {monthlyItems.length > 0 && ( -
-
Monthly Charges
-
- {monthlyItems.map((item, index) => ( -
-
- {String(item.name)} - {Boolean(item.autoAdded) && ( - (Auto-added) - )} - {item.description && ( -
{String(item.description)}
- )} -
- - ¥{formatCurrency(Number(item.monthlyPrice || item.unitPrice || 0))} - -
- ))} -
-
- )} - - {/* One-time Charges */} - {oneTimeItems.length > 0 && ( -
-
One-time Charges
-
- {oneTimeItems.map((item, index) => ( -
-
- {String(item.name)} - {item.description && ( -
{String(item.description)}
- )} -
- - ¥{formatCurrency(Number(item.oneTimePrice || item.unitPrice || 0))} - -
- ))} -
-
- )} - - {/* Discounts */} - {showDiscounts && totals.discountAmount && totals.discountAmount > 0 && ( -
-
- Discount Applied - - -¥{formatCurrency(totals.discountAmount)} - -
-
- )} - - {/* Taxes */} - {showTaxes && totals.taxAmount && totals.taxAmount > 0 && ( -
-
- Tax (10%) - - ¥{formatCurrency(totals.taxAmount)} - -
-
- )} -
- )} - - {/* Simple Item List for simple variant */} - {variant === "simple" && ( -
- {items.map((item, index) => ( -
- {String(item.name)} - - ¥ - {formatCurrency( - Number( - item.billingCycle === "Monthly" - ? item.monthlyPrice || item.unitPrice || 0 - : item.oneTimePrice || item.unitPrice || 0 - ) - )} - {item.billingCycle === "Monthly" ? "/mo" : " one-time"} - -
- ))} -
- )} - - {/* Totals */} -
-
-
- Monthly Total: - {formatCurrency(totals.monthlyTotal)} -
- - {totals.oneTimeTotal > 0 && ( -
- One-time Total: - {formatCurrency(totals.oneTimeTotal)} -
- )} - - {totals.annualTotal && ( -
- Annual Total: - {formatCurrency(totals.annualTotal)} -
- )} -
-
- - {/* Info Lines */} - {infoLines.length > 0 && ( -
-
- -
- {infoLines.map((line, index) => ( -

- {line} -

- ))} -
-
-
- )} - - {/* Disclaimers */} - {disclaimers.length > 0 && ( -
-
- {disclaimers.map((disclaimer, index) => ( -

- {disclaimer} -

- ))} -
-
- )} - - {/* Custom Content */} - {children &&
{children}
} - - {/* Actions */} - {showActions && ( -
- {backUrl ? ( - - ) : onBack ? ( - - ) : null} - - {onContinue && ( - - )} -
- )} - - {/* Footer Content */} - {footerContent &&
{footerContent}
} -
- ); -} diff --git a/apps/portal/src/features/services/components/base/ProductComparison.tsx b/apps/portal/src/features/services/components/base/ProductComparison.tsx index 3c030a35..eca3d0ac 100644 --- a/apps/portal/src/features/services/components/base/ProductComparison.tsx +++ b/apps/portal/src/features/services/components/base/ProductComparison.tsx @@ -49,7 +49,7 @@ export function ProductComparison({ }: ProductComparisonProps) { const displayProducts = products.slice(0, maxColumns); - const renderFeatureValue = (value: string | boolean | number | null) => { + const renderFeatureValue = (value: string | boolean | number | null | undefined) => { if (value === null || value === undefined) { return ; } diff --git a/apps/portal/src/features/services/components/base/ServiceCTA.tsx b/apps/portal/src/features/services/components/base/ServiceCTA.tsx index d6c28d32..74d4ca8c 100644 --- a/apps/portal/src/features/services/components/base/ServiceCTA.tsx +++ b/apps/portal/src/features/services/components/base/ServiceCTA.tsx @@ -6,20 +6,22 @@ import { Button } from "@/components/atoms/button"; import { cn } from "@/shared/utils/cn"; export interface ServiceCTAProps { - eyebrow?: string; - eyebrowIcon?: ReactNode; + eyebrow?: string | undefined; + eyebrowIcon?: ReactNode | undefined; headline: string; description: string; primaryAction: { label: string; - href?: string; - onClick?: (e: MouseEvent) => void; + href?: string | undefined; + onClick?: ((e: MouseEvent) => void) | undefined; }; - secondaryAction?: { - label: string; - href: string; - }; - className?: string; + secondaryAction?: + | { + label: string; + href: string; + } + | undefined; + className?: string | undefined; } /** diff --git a/apps/portal/src/features/services/components/eligibility-check/EligibilityCheckFlow.tsx b/apps/portal/src/features/services/components/eligibility-check/EligibilityCheckFlow.tsx new file mode 100644 index 00000000..9d2f851d --- /dev/null +++ b/apps/portal/src/features/services/components/eligibility-check/EligibilityCheckFlow.tsx @@ -0,0 +1,55 @@ +/** + * EligibilityCheckFlow - Container for eligibility check multi-step flow + * + * Orchestrates the step rendering based on store state. + * Two paths: + * 1. "Create Account & Submit" → OTP → Complete Account → Success + * 2. "Just Submit Request" → Success (guest) + */ + +"use client"; + +import { useEffect } from "react"; +import { useEligibilityCheckStore } from "../../stores/eligibility-check.store"; +import { FormStep, OtpStep, CompleteAccountStep, SuccessStep } from "./steps"; + +interface EligibilityCheckFlowProps { + /** Optional callback when flow completes */ + onComplete?: () => void; +} + +export function EligibilityCheckFlow({ onComplete }: EligibilityCheckFlowProps) { + const { step, reset, cleanup } = useEligibilityCheckStore(); + + // Reset store on mount, cleanup on unmount to prevent memory leaks + useEffect(() => { + reset(); + return () => { + cleanup(); + }; + }, [reset, cleanup]); + + // Notify parent when flow completes + useEffect(() => { + if (step === "success" && onComplete) { + onComplete(); + } + }, [step, onComplete]); + + const renderStep = () => { + switch (step) { + case "form": + return ; + case "otp": + return ; + case "complete-account": + return ; + case "success": + return ; + default: + return ; + } + }; + + return
{renderStep()}
; +} diff --git a/apps/portal/src/features/services/components/eligibility-check/index.ts b/apps/portal/src/features/services/components/eligibility-check/index.ts new file mode 100644 index 00000000..357390b4 --- /dev/null +++ b/apps/portal/src/features/services/components/eligibility-check/index.ts @@ -0,0 +1,2 @@ +export { EligibilityCheckFlow } from "./EligibilityCheckFlow"; +export * from "./steps"; diff --git a/apps/portal/src/features/services/components/eligibility-check/steps/CompleteAccountStep.tsx b/apps/portal/src/features/services/components/eligibility-check/steps/CompleteAccountStep.tsx new file mode 100644 index 00000000..ec2e1536 --- /dev/null +++ b/apps/portal/src/features/services/components/eligibility-check/steps/CompleteAccountStep.tsx @@ -0,0 +1,390 @@ +/** + * CompleteAccountStep - Complete account creation after OTP verification + * + * Collects: Password, Phone, DOB, Gender, Terms + * Shows pre-filled info from form step + */ + +"use client"; + +import { useState, useCallback } from "react"; +import { Lock, ArrowLeft, Check, X } from "lucide-react"; +import { Button, Input, Label, ErrorMessage } from "@/components/atoms"; +import { Checkbox } from "@/components/atoms/checkbox"; +import { useEligibilityCheckStore } from "../../../stores/eligibility-check.store"; + +interface AccountFormErrors { + password?: string; + confirmPassword?: string; + phone?: string; + dateOfBirth?: string; + gender?: string; + acceptTerms?: string; +} + +/** Helper component for password requirement indicators */ +function RequirementCheck({ met, label }: { met: boolean; label: string }) { + return ( +
+ {met ? ( + + ) : ( + + )} + {label} +
+ ); +} + +export function CompleteAccountStep() { + const { + formData, + accountData, + updateAccountData, + completeAccount, + goBack, + loading, + error, + clearError, + } = useEligibilityCheckStore(); + + const [accountErrors, setAccountErrors] = useState({}); + + // Clear specific error + const handleClearError = useCallback((field: keyof AccountFormErrors) => { + setAccountErrors(prev => { + const newErrors = { ...prev }; + delete newErrors[field]; + return newErrors; + }); + }, []); + + // Password requirement checks for real-time feedback + const passwordChecks = { + length: accountData.password.length >= 8, + uppercase: /[A-Z]/.test(accountData.password), + lowercase: /[a-z]/.test(accountData.password), + number: /[0-9]/.test(accountData.password), + }; + + // Validate password + const validatePassword = useCallback((pass: string): string | undefined => { + if (!pass) return "Password is required"; + if (pass.length < 8) return "Password must be at least 8 characters"; + if (!/[A-Z]/.test(pass)) return "Password must contain an uppercase letter"; + if (!/[a-z]/.test(pass)) return "Password must contain a lowercase letter"; + if (!/[0-9]/.test(pass)) return "Password must contain a number"; + return undefined; + }, []); + + // Check if password is valid (for canSubmit) + const isPasswordValid = validatePassword(accountData.password) === undefined; + const doPasswordsMatch = accountData.password === accountData.confirmPassword; + const showPasswordMatch = accountData.confirmPassword.length > 0; + + // Validate account form + const validateAccountForm = useCallback((): boolean => { + const errors: AccountFormErrors = {}; + + const passwordError = validatePassword(accountData.password); + if (passwordError) { + errors.password = passwordError; + } + + if (accountData.password !== accountData.confirmPassword) { + errors.confirmPassword = "Passwords do not match"; + } + + if (!accountData.phone.trim()) { + errors.phone = "Phone number is required"; + } + + if (!accountData.dateOfBirth) { + errors.dateOfBirth = "Date of birth is required"; + } + + if (!accountData.gender) { + errors.gender = "Please select a gender"; + } + + if (!accountData.acceptTerms) { + errors.acceptTerms = "You must accept the terms of service"; + } + + setAccountErrors(errors); + return Object.keys(errors).length === 0; + }, [accountData]); + + const handleSubmit = async () => { + if (!validateAccountForm()) return; + clearError(); + await completeAccount(); + }; + + const canSubmit = + accountData.password && + accountData.confirmPassword && + accountData.phone && + accountData.dateOfBirth && + accountData.gender && + accountData.acceptTerms && + isPasswordValid && + doPasswordsMatch && + Object.keys(accountErrors).length === 0; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+
+

Complete Your Account

+

+ Creating account for{" "} + {formData.email} +

+
+
+ + {/* Pre-filled info display */} +
+

Account details:

+

+ {formData.firstName} {formData.lastName} +

+ {formData.address && ( +

+ {formData.address.city}, {formData.address.prefecture} +

+ )} +
+ + {/* Password */} +
+ + { + updateAccountData({ password: e.target.value }); + handleClearError("password"); + }} + placeholder="Create a strong password" + disabled={loading} + error={accountErrors.password} + autoComplete="new-password" + /> + {accountErrors.password} + {/* Real-time password requirements */} + {accountData.password.length > 0 && ( +
+ + + + +
+ )} + {accountData.password.length === 0 && ( +

+ At least 8 characters with uppercase, lowercase, and numbers +

+ )} +
+ + {/* Confirm Password */} +
+ + { + updateAccountData({ confirmPassword: e.target.value }); + handleClearError("confirmPassword"); + }} + placeholder="Confirm your password" + disabled={loading} + error={accountErrors.confirmPassword} + autoComplete="new-password" + /> + {accountErrors.confirmPassword} + {/* Real-time password match indicator */} + {showPasswordMatch && !accountErrors.confirmPassword && ( +
+ {doPasswordsMatch ? ( + <> + + Passwords match + + ) : ( + <> + + Passwords do not match + + )} +
+ )} +
+ + {/* Phone */} +
+ + { + updateAccountData({ phone: e.target.value }); + handleClearError("phone"); + }} + placeholder="090-1234-5678" + disabled={loading} + error={accountErrors.phone} + /> + {accountErrors.phone} +
+ + {/* Date of Birth */} +
+ + { + updateAccountData({ dateOfBirth: e.target.value }); + handleClearError("dateOfBirth"); + }} + disabled={loading} + error={accountErrors.dateOfBirth} + max={new Date().toISOString().split("T")[0]} + /> + {accountErrors.dateOfBirth} +
+ + {/* Gender */} +
+ +
+ {(["male", "female", "other"] as const).map(option => ( + + ))} +
+ {accountErrors.gender} +
+ + {/* Terms & Marketing */} +
+
+ { + updateAccountData({ acceptTerms: e.target.checked }); + handleClearError("acceptTerms"); + }} + disabled={loading} + /> + +
+ {accountErrors.acceptTerms} + +
+ updateAccountData({ marketingConsent: e.target.checked })} + disabled={loading} + /> + +
+
+ + {/* API Error */} + {error && ( +
+ {error} +
+ )} + + {/* Actions */} +
+ + + +
+
+ ); +} diff --git a/apps/portal/src/features/services/components/eligibility-check/steps/FormStep.tsx b/apps/portal/src/features/services/components/eligibility-check/steps/FormStep.tsx new file mode 100644 index 00000000..a88914c7 --- /dev/null +++ b/apps/portal/src/features/services/components/eligibility-check/steps/FormStep.tsx @@ -0,0 +1,216 @@ +/** + * FormStep - Initial form for eligibility check + * + * Collects: Name, Email, Address + * Two buttons: "Create Account & Submit" (primary) and "Just Submit Request" (secondary) + */ + +"use client"; + +import { useState, useCallback } from "react"; +import { UserPlus, ArrowRight } from "lucide-react"; +import { Button, Input, Label, ErrorMessage } from "@/components/atoms"; +import { + JapanAddressForm, + type JapanAddressFormData, +} from "@/features/address/components/JapanAddressForm"; +import { useEligibilityCheckStore } from "../../../stores/eligibility-check.store"; + +interface FormErrors { + firstName?: string; + lastName?: string; + email?: string; + address?: string; +} + +export function FormStep() { + const { + formData, + updateFormData, + setAddressComplete, + isAddressComplete, + submitOnly, + submitAndCreate, + loading, + error, + clearError, + } = useEligibilityCheckStore(); + + const [formErrors, setFormErrors] = useState({}); + + // Validate email format + const isValidEmail = (email: string): boolean => { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + }; + + // Validate form + const validateForm = useCallback((): boolean => { + const errors: FormErrors = {}; + + if (!formData.firstName.trim()) { + errors.firstName = "First name is required"; + } + if (!formData.lastName.trim()) { + errors.lastName = "Last name is required"; + } + if (!formData.email.trim()) { + errors.email = "Email is required"; + } else if (!isValidEmail(formData.email)) { + errors.email = "Enter a valid email address"; + } + if (!isAddressComplete) { + errors.address = "Please complete the address"; + } + + setFormErrors(errors); + return Object.keys(errors).length === 0; + }, [formData, isAddressComplete]); + + // Clear specific form error + const handleClearFormError = useCallback((field: keyof FormErrors) => { + setFormErrors(prev => { + const newErrors = { ...prev }; + delete newErrors[field]; + return newErrors; + }); + }, []); + + // Handle address form changes + const handleAddressChange = useCallback( + (data: JapanAddressFormData, isComplete: boolean) => { + updateFormData({ address: data }); + setAddressComplete(isComplete); + if (isComplete) { + handleClearFormError("address"); + } + }, + [updateFormData, setAddressComplete, handleClearFormError] + ); + + // Handle "Just Submit Request" (secondary action) + const handleSubmitOnly = async () => { + if (!validateForm()) return; + clearError(); + await submitOnly(); + }; + + // Handle "Create Account & Submit" (primary action) + const handleSubmitAndCreate = async () => { + if (!validateForm()) return; + clearError(); + await submitAndCreate(); + }; + + return ( +
+ {/* Name fields */} +
+
+ + { + updateFormData({ firstName: e.target.value }); + handleClearFormError("firstName"); + }} + placeholder="Taro" + disabled={loading} + error={formErrors.firstName} + /> + {formErrors.firstName} +
+ +
+ + { + updateFormData({ lastName: e.target.value }); + handleClearFormError("lastName"); + }} + placeholder="Yamada" + disabled={loading} + error={formErrors.lastName} + /> + {formErrors.lastName} +
+
+ + {/* Email */} +
+ + { + updateFormData({ email: e.target.value }); + handleClearFormError("email"); + }} + placeholder="your@email.com" + disabled={loading} + error={formErrors.email} + /> + {!formErrors.email && ( +

+ We'll send availability results to this email +

+ )} + {formErrors.email} +
+ + {/* Address */} +
+ + + {formErrors.address} +
+ + {/* API Error */} + {error && ( +
+ {error} +
+ )} + + {/* Two buttons - Primary on top, Secondary below */} +
+ + + + +

+ Creating an account lets you track your request and order services faster. +

+
+
+ ); +} diff --git a/apps/portal/src/features/services/components/eligibility-check/steps/OtpStep.tsx b/apps/portal/src/features/services/components/eligibility-check/steps/OtpStep.tsx new file mode 100644 index 00000000..a78e9919 --- /dev/null +++ b/apps/portal/src/features/services/components/eligibility-check/steps/OtpStep.tsx @@ -0,0 +1,137 @@ +/** + * OtpStep - Enter 6-digit OTP verification code + */ + +"use client"; + +import { useState, useCallback } from "react"; +import { Mail } from "lucide-react"; +import { Button } from "@/components/atoms"; +import { OtpInput } from "@/features/get-started/components/OtpInput/OtpInput"; +import { useEligibilityCheckStore } from "../../../stores/eligibility-check.store"; + +export function OtpStep() { + const { + formData, + verifyOtp, + resendOtp, + goToStep, + loading, + otpError, + clearOtpError, + attemptsRemaining, + resendDisabled, + resendCountdown, + } = useEligibilityCheckStore(); + + const [otpValue, setOtpValue] = useState(""); + + const handleCodeChange = useCallback( + (value: string) => { + setOtpValue(value); + clearOtpError(); + }, + [clearOtpError] + ); + + const handleComplete = useCallback( + async (code: string) => { + await verifyOtp(code); + }, + [verifyOtp] + ); + + const handleVerify = async () => { + if (otpValue.length === 6) { + await verifyOtp(otpValue); + } + }; + + const handleResend = async () => { + setOtpValue(""); + await resendOtp(); + }; + + const handleChangeEmail = () => { + goToStep("form"); + }; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+
+

Verify Your Email

+

+ We sent a 6-digit code to{" "} + {formData.email} +

+
+
+ + {/* OTP Input */} +
+ +
+ + {/* Attempts remaining warning */} + {attemptsRemaining !== null && attemptsRemaining < 3 && ( +

+ {attemptsRemaining} attempt{attemptsRemaining === 1 ? "" : "s"} remaining +

+ )} + + {/* Verify Button */} + + + {/* Actions */} +
+ + + +
+ + {/* Help text */} +

+ The code expires in 10 minutes. Check your spam folder if you don't see it. +

+
+ ); +} diff --git a/apps/portal/src/features/services/components/eligibility-check/steps/SuccessStep.tsx b/apps/portal/src/features/services/components/eligibility-check/steps/SuccessStep.tsx new file mode 100644 index 00000000..878555b0 --- /dev/null +++ b/apps/portal/src/features/services/components/eligibility-check/steps/SuccessStep.tsx @@ -0,0 +1,169 @@ +/** + * SuccessStep - Display success after eligibility check + * + * Shows different messages based on: + * - hasAccount: User created an account + * - !hasAccount: User just submitted a request (guest) + */ + +"use client"; + +import { useEffect, useState, useRef, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { CheckCircle, ArrowRight, Home } from "lucide-react"; +import { Button } from "@/components/atoms"; +import { useEligibilityCheckStore } from "../../../stores/eligibility-check.store"; + +const AUTO_REDIRECT_DELAY = 5; // seconds + +export function SuccessStep() { + const router = useRouter(); + const { hasAccount, requestId, formData, reset } = useEligibilityCheckStore(); + + const [countdown, setCountdown] = useState(AUTO_REDIRECT_DELAY); + const timerRef = useRef | null>(null); + + // Clear timer helper + const clearTimer = useCallback(() => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }, []); + + // Auto-redirect to dashboard if user created an account + useEffect(() => { + if (!hasAccount) return; + + timerRef.current = setInterval(() => { + setCountdown(prev => { + if (prev <= 1) { + clearTimer(); + router.push("/dashboard"); + return 0; + } + return prev - 1; + }); + }, 1000); + + return clearTimer; + }, [hasAccount, router, clearTimer]); + + const handleGoToDashboard = useCallback(() => { + clearTimer(); // Clear timer before navigation + reset(); + router.push("/dashboard"); + }, [clearTimer, reset, router]); + + const handleBackToPlans = useCallback(() => { + clearTimer(); // Clear timer before navigation + reset(); + router.push("/internet"); + }, [clearTimer, reset, router]); + + return ( +
+ {/* Success Icon */} +
+
+
+ +
+
+ +
+

+ {hasAccount ? "Account Created!" : "Request Submitted!"} +

+

+ {hasAccount + ? "Your account has been created and your eligibility check is being processed." + : "Your eligibility check request has been submitted."} +

+
+
+ + {/* Request ID */} + {requestId && ( +
+

Request ID

+

{requestId}

+
+ )} + + {/* Email notification */} +
+

+ We'll send the results to {formData.email} +

+

+ You should receive an update within 1-2 business days. +

+
+ + {/* Actions */} + {hasAccount ? ( +
+ +

+ Redirecting in {countdown} second{countdown === 1 ? "" : "s"}... +

+
+ ) : ( +
+ +

+ Create an account to track your request and order services faster. +

+
+ )} + + {/* What happens next */} +
+

What happens next?

+
    +
  • + + 1 + + + We check service availability at your address + +
  • +
  • + + 2 + + + You receive an email with available plans and pricing + +
  • +
  • + + 3 + + + {hasAccount + ? "Log in to your dashboard to complete your order" + : "Create an account or reply to the email to proceed"} + +
  • +
+
+
+ ); +} diff --git a/apps/portal/src/features/services/components/eligibility-check/steps/index.ts b/apps/portal/src/features/services/components/eligibility-check/steps/index.ts new file mode 100644 index 00000000..19cd0790 --- /dev/null +++ b/apps/portal/src/features/services/components/eligibility-check/steps/index.ts @@ -0,0 +1,4 @@ +export { FormStep } from "./FormStep"; +export { OtpStep } from "./OtpStep"; +export { CompleteAccountStep } from "./CompleteAccountStep"; +export { SuccessStep } from "./SuccessStep"; diff --git a/apps/portal/src/features/services/components/index.ts b/apps/portal/src/features/services/components/index.ts index 46cc9c44..c3dde8ee 100644 --- a/apps/portal/src/features/services/components/index.ts +++ b/apps/portal/src/features/services/components/index.ts @@ -9,7 +9,6 @@ export { PricingDisplay } from "./base/PricingDisplay"; export { ProductComparison } from "./base/ProductComparison"; // Order and configuration components -export { EnhancedOrderSummary } from "./base/EnhancedOrderSummary"; export { ConfigurationStep } from "./base/ConfigurationStep"; export { AddressForm } from "./base/AddressForm"; export { PaymentForm } from "./base/PaymentForm"; @@ -32,12 +31,6 @@ export type { ComparisonProduct, ComparisonFeature, } from "./base/ProductComparison"; -export type { - EnhancedOrderSummaryProps, - OrderItem, - OrderSummaryConfiguration, - OrderTotals, -} from "./base/EnhancedOrderSummary"; export type { ConfigurationStepProps, StepValidation } from "./base/ConfigurationStep"; export type { AddressFormProps } from "./base/AddressForm"; export type { PaymentFormProps } from "./base/PaymentForm"; diff --git a/apps/portal/src/features/services/components/internet/EligibilityStatusBadge.tsx b/apps/portal/src/features/services/components/internet/EligibilityStatusBadge.tsx index d03575aa..78af2987 100644 --- a/apps/portal/src/features/services/components/internet/EligibilityStatusBadge.tsx +++ b/apps/portal/src/features/services/components/internet/EligibilityStatusBadge.tsx @@ -7,7 +7,7 @@ export type EligibilityStatus = "eligible" | "pending" | "not_requested" | "inel interface EligibilityStatusBadgeProps { status: EligibilityStatus; - speed?: string; + speed?: string | undefined; } const STATUS_CONFIGS = { diff --git a/apps/portal/src/features/services/components/internet/InstallationOptions.tsx b/apps/portal/src/features/services/components/internet/InstallationOptions.tsx index 4c685cec..1fb6944d 100644 --- a/apps/portal/src/features/services/components/internet/InstallationOptions.tsx +++ b/apps/portal/src/features/services/components/internet/InstallationOptions.tsx @@ -89,7 +89,7 @@ export function InstallationOptions({ installation.billingCycle === "Monthly" ? installation.monthlyPrice : null } oneTimePrice={ - installation.billingCycle !== "Monthly" ? installation.oneTimePrice : null + installation.billingCycle === "Monthly" ? null : installation.oneTimePrice } size="md" alignment="left" diff --git a/apps/portal/src/features/services/components/internet/InternetIneligibleState.tsx b/apps/portal/src/features/services/components/internet/InternetIneligibleState.tsx index d002ba0f..ad17789f 100644 --- a/apps/portal/src/features/services/components/internet/InternetIneligibleState.tsx +++ b/apps/portal/src/features/services/components/internet/InternetIneligibleState.tsx @@ -4,7 +4,7 @@ import { TriangleAlert } from "lucide-react"; import { Button } from "@/components/atoms/button"; interface InternetIneligibleStateProps { - rejectionNotes?: string | null; + rejectionNotes?: string | null | undefined; } /** diff --git a/apps/portal/src/features/services/components/internet/InternetModalShell.tsx b/apps/portal/src/features/services/components/internet/InternetModalShell.tsx index c2a7e4d0..2ebb4d15 100644 --- a/apps/portal/src/features/services/components/internet/InternetModalShell.tsx +++ b/apps/portal/src/features/services/components/internet/InternetModalShell.tsx @@ -54,26 +54,27 @@ export function InternetModalShell({ } if (event.key === "Tab") { - const focusableElements = dialogRef.current?.querySelectorAll( + const nodeList = dialogRef.current?.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); - if (!focusableElements || focusableElements.length === 0) { + if (!nodeList || nodeList.length === 0) { event.preventDefault(); return; } + const focusableElements = [...nodeList]; const firstElement = focusableElements[0]; - const lastElement = focusableElements[focusableElements.length - 1]; + const lastElement = focusableElements.at(-1); if (!event.shiftKey && document.activeElement === lastElement) { event.preventDefault(); - firstElement.focus(); + firstElement?.focus(); } if (event.shiftKey && document.activeElement === firstElement) { event.preventDefault(); - lastElement.focus(); + lastElement?.focus(); } } }; diff --git a/apps/portal/src/features/services/components/internet/InternetOfferingCard.tsx b/apps/portal/src/features/services/components/internet/InternetOfferingCard.tsx index f50e66a2..1e819aa4 100644 --- a/apps/portal/src/features/services/components/internet/InternetOfferingCard.tsx +++ b/apps/portal/src/features/services/components/internet/InternetOfferingCard.tsx @@ -11,8 +11,8 @@ interface TierInfo { monthlyPrice: number; description: string; features: string[]; - recommended?: boolean; - pricingNote?: string; + recommended?: boolean | undefined; + pricingNote?: string | undefined; } interface InternetOfferingCardProps { @@ -24,13 +24,13 @@ interface InternetOfferingCardProps { startingPrice: number; setupFee: number; tiers: TierInfo[]; - isPremium?: boolean; + isPremium?: boolean | undefined; ctaPath: string; // defaultExpanded is no longer used but kept for prop compatibility if needed upstream - defaultExpanded?: boolean; - disabled?: boolean; - disabledReason?: string; - previewMode?: boolean; + defaultExpanded?: boolean | undefined; + disabled?: boolean | undefined; + disabledReason?: string | undefined; + previewMode?: boolean | undefined; } const tierStyles = { diff --git a/apps/portal/src/features/services/components/internet/InternetPendingState.tsx b/apps/portal/src/features/services/components/internet/InternetPendingState.tsx index 62d7843e..0332f47b 100644 --- a/apps/portal/src/features/services/components/internet/InternetPendingState.tsx +++ b/apps/portal/src/features/services/components/internet/InternetPendingState.tsx @@ -5,7 +5,7 @@ import { Button } from "@/components/atoms/button"; import { formatIsoDate } from "@/shared/utils"; interface InternetPendingStateProps { - requestedAt?: string | null; + requestedAt?: string | null | undefined; servicesBasePath: string; } diff --git a/apps/portal/src/features/services/components/internet/InternetPlanCard.tsx b/apps/portal/src/features/services/components/internet/InternetPlanCard.tsx index e2abbd4c..3665d789 100644 --- a/apps/portal/src/features/services/components/internet/InternetPlanCard.tsx +++ b/apps/portal/src/features/services/components/internet/InternetPlanCard.tsx @@ -118,7 +118,7 @@ export function InternetPlanCard({ const renderFeature = (feature: string, index: number) => { const [label, detail] = feature.split(":"); - if (detail) { + if (detail && label) { return (
  • @@ -237,7 +237,7 @@ export function InternetPlanCard({
  • @@ -87,8 +87,8 @@ export function MnpForm({ onChange={e => handleInputChange("expiryDate", e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" /> - {errors.expiryDate && ( -

    {errors.expiryDate}

    + {errors["expiryDate"] && ( +

    {errors["expiryDate"]}

    )}
    @@ -105,8 +105,8 @@ export function MnpForm({ className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="090-1234-5678" /> - {errors.phoneNumber && ( -

    {errors.phoneNumber}

    + {errors["phoneNumber"] && ( +

    {errors["phoneNumber"]}

    )}
    @@ -144,8 +144,8 @@ export function MnpForm({ className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="Tanaka" /> - {errors.portingLastName && ( -

    {errors.portingLastName}

    + {errors["portingLastName"] && ( +

    {errors["portingLastName"]}

    )}
    @@ -165,8 +165,8 @@ export function MnpForm({ className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="Taro" /> - {errors.portingFirstName && ( -

    {errors.portingFirstName}

    + {errors["portingFirstName"] && ( +

    {errors["portingFirstName"]}

    )} @@ -186,8 +186,8 @@ export function MnpForm({ className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="タナカ" /> - {errors.portingLastNameKatakana && ( -

    {errors.portingLastNameKatakana}

    + {errors["portingLastNameKatakana"] && ( +

    {errors["portingLastNameKatakana"]}

    )} @@ -207,8 +207,8 @@ export function MnpForm({ className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="タロウ" /> - {errors.portingFirstNameKatakana && ( -

    {errors.portingFirstNameKatakana}

    + {errors["portingFirstNameKatakana"] && ( +

    {errors["portingFirstNameKatakana"]}

    )} @@ -231,8 +231,8 @@ export function MnpForm({ - {errors.portingGender && ( -

    {errors.portingGender}

    + {errors["portingGender"] && ( +

    {errors["portingGender"]}

    )} @@ -251,8 +251,8 @@ export function MnpForm({ onChange={e => handleInputChange("portingDateOfBirth", e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" /> - {errors.portingDateOfBirth && ( -

    {errors.portingDateOfBirth}

    + {errors["portingDateOfBirth"] && ( +

    {errors["portingDateOfBirth"]}

    )} diff --git a/apps/portal/src/features/services/components/sim/SimConfigureView.tsx b/apps/portal/src/features/services/components/sim/SimConfigureView.tsx index b07bbad8..6797ca96 100644 --- a/apps/portal/src/features/services/components/sim/SimConfigureView.tsx +++ b/apps/portal/src/features/services/components/sim/SimConfigureView.tsx @@ -2,13 +2,12 @@ import { PageLayout } from "@/components/templates/PageLayout"; import { Button } from "@/components/atoms/button"; -import { AnimatedCard } from "@/components/molecules"; +import { AnimatedCard, ProgressSteps } from "@/components/molecules"; import { AddonGroup } from "@/features/services/components/base/AddonGroup"; import { StepHeader } from "@/components/atoms"; import { SimTypeSelector } from "@/features/services/components/sim/SimTypeSelector"; import { ActivationForm } from "@/features/services/components/sim/ActivationForm"; import { MnpForm } from "@/features/services/components/sim/MnpForm"; -import { ProgressSteps } from "@/components/molecules"; import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink"; import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; import { @@ -60,11 +59,15 @@ export function SimConfigureView({ return fees.find(fee => fee.catalogMetadata?.isDefault) || fees[0]; }; - const resolveOneTimeCharge = (value?: { - oneTimePrice?: number; - unitPrice?: number; - monthlyPrice?: number; - }): number => { + const resolveOneTimeCharge = ( + value?: + | { + oneTimePrice?: number | undefined; + unitPrice?: number | undefined; + monthlyPrice?: number | undefined; + } + | undefined + ): number => { if (!value) return 0; return value.oneTimePrice ?? value.unitPrice ?? value.monthlyPrice ?? 0; }; @@ -449,7 +452,7 @@ export function SimConfigureView({
    EID: - {eid.substring(0, 12)}... + {eid.slice(0, 12)}...
    )} diff --git a/apps/portal/src/features/services/components/sim/SimPlanCard.tsx b/apps/portal/src/features/services/components/sim/SimPlanCard.tsx index 2ea6495d..abcbefd3 100644 --- a/apps/portal/src/features/services/components/sim/SimPlanCard.tsx +++ b/apps/portal/src/features/services/components/sim/SimPlanCard.tsx @@ -1,9 +1,8 @@ "use client"; -import { DevicePhoneMobileIcon } from "@heroicons/react/24/outline"; +import { DevicePhoneMobileIcon, ArrowRightIcon } from "@heroicons/react/24/outline"; import { AnimatedCard } from "@/components/molecules/AnimatedCard/AnimatedCard"; import { Button } from "@/components/atoms/button"; -import { ArrowRightIcon } from "@heroicons/react/24/outline"; import type { SimCatalogProduct } from "@customer-portal/domain/services"; import { CardPricing } from "@/features/services/components/base/CardPricing"; import { CardBadge } from "@/features/services/components/base/CardBadge"; @@ -24,10 +23,10 @@ export function SimPlanCard({ disabledReason, }: { plan: SimCatalogProduct; - isFamily?: boolean; - action?: SimPlanCardActionResolver; - disabled?: boolean; - disabledReason?: string; + isFamily?: boolean | undefined; + action?: SimPlanCardActionResolver | undefined; + disabled?: boolean | undefined; + disabledReason?: string | undefined; }) { const displayPrice = plan.monthlyPrice ?? plan.unitPrice ?? plan.oneTimePrice ?? 0; const isFamilyPlan = isFamily ?? Boolean(plan.simHasFamilyDiscount); diff --git a/apps/portal/src/features/services/components/sim/SimPlanTypeSection.tsx b/apps/portal/src/features/services/components/sim/SimPlanTypeSection.tsx index 1a50119e..8517c1a2 100644 --- a/apps/portal/src/features/services/components/sim/SimPlanTypeSection.tsx +++ b/apps/portal/src/features/services/components/sim/SimPlanTypeSection.tsx @@ -20,9 +20,9 @@ export function SimPlanTypeSection({ icon: React.ReactNode; plans: SimCatalogProduct[]; showFamilyDiscount: boolean; - cardAction?: SimPlanCardActionResolver; - cardDisabled?: boolean; - cardDisabledReason?: string; + cardAction?: SimPlanCardActionResolver | undefined; + cardDisabled?: boolean | undefined; + cardDisabledReason?: string | undefined; }) { if (plans.length === 0) return null; const regularPlans = plans.filter(p => !p.simHasFamilyDiscount); diff --git a/apps/portal/src/features/services/components/sim/SimTypeSelector.tsx b/apps/portal/src/features/services/components/sim/SimTypeSelector.tsx index def836c0..9df27b50 100644 --- a/apps/portal/src/features/services/components/sim/SimTypeSelector.tsx +++ b/apps/portal/src/features/services/components/sim/SimTypeSelector.tsx @@ -168,12 +168,12 @@ export function SimTypeSelector({ value={eid} onChange={e => onEidChange(e.target.value)} className={`w-full px-4 py-3 bg-card border rounded-lg text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-colors ${ - errors.eid ? "border-destructive" : "border-border" + errors["eid"] ? "border-destructive" : "border-border" }`} placeholder="32-digit EID number" maxLength={32} /> - {errors.eid &&

    {errors.eid}

    } + {errors["eid"] &&

    {errors["eid"]}

    } - - - -

    - You can create your account anytime later using the same email address. -

    - - - ); -} - -// ============================================================================ -// OTP Step Component -// ============================================================================ - -interface OtpStepProps { - email: string; - loading: boolean; - error: string | null; - attemptsRemaining: number | null; - resendDisabled: boolean; - resendCountdown: number; - onVerify: (code: string) => void; - onResend: () => void; - onChangeEmail: () => void; -} - -function OtpStep({ - email, - loading, - error, - attemptsRemaining, - resendDisabled, - resendCountdown, - onVerify, - onResend, - onChangeEmail, -}: OtpStepProps) { - const [otpValue, setOtpValue] = useState(""); - - const handleComplete = useCallback( - (code: string) => { - onVerify(code); - }, - [onVerify] - ); - - return ( -
    - {/* Header */} -
    -
    -
    - -
    -
    -
    -

    Verify Your Email

    -

    - We sent a 6-digit code to {email} -

    -
    -
    - - {/* OTP Input */} -
    - -
    - - {/* Attempts remaining warning */} - {attemptsRemaining !== null && attemptsRemaining < 3 && ( -

    - {attemptsRemaining} attempt{attemptsRemaining !== 1 ? "s" : ""} remaining -

    - )} - - {/* Verify Button */} - - - {/* Actions */} -
    - - - -
    -
    - ); -} - -// ============================================================================ -// Success Step Component -// ============================================================================ - -interface SuccessStepProps { - email: string; - requestId: string | null; - onBackToPlans: () => void; -} - -function SuccessStep({ email, requestId, onBackToPlans }: SuccessStepProps) { - return ( -
    -
    -
    -
    - -
    -
    -
    -

    Request Submitted!

    -

    - We're checking internet availability at your address. Our team will review this and - email you the results within 1-2 business days. -

    -
    - {requestId && ( -

    - Reference: {requestId} -

    - )} -
    - - {/* What's next */} -
    -
    - - Check your email for updates -
    - - - - {/* Divider */} -
    -
    - -
    -
    - or -
    -
    - - {/* Create Account CTA */} - - - - -

    - Creating an account lets you track your request and order services faster. -

    -
    -
    - ); -} - -// ============================================================================ -// Main Component -// ============================================================================ - export function PublicEligibilityCheckView() { - const router = useRouter(); const servicesBasePath = useServicesBasePath(); - const [step, setStep] = useState("form"); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const { step, hasAccount } = useEligibilityCheckStore(); - // Form state - const [formData, setFormData] = useState({ - firstName: "", - lastName: "", - email: "", - address: null, - }); - const [formErrors, setFormErrors] = useState({}); - const [isAddressComplete, setIsAddressComplete] = useState(false); - - // OTP state - const [handoffToken, setHandoffToken] = useState(null); - const [otpError, setOtpError] = useState(null); - const [attemptsRemaining, setAttemptsRemaining] = useState(null); - const [resendDisabled, setResendDisabled] = useState(false); - const [resendCountdown, setResendCountdown] = useState(0); - const resendTimerRef = useRef | null>(null); - - // Success state - const [requestId, setRequestId] = useState(null); - - // Cleanup timer on unmount - useEffect(() => { - return () => { - if (resendTimerRef.current) { - clearInterval(resendTimerRef.current); - } - }; - }, []); - - // Handle form data changes - const handleFormDataChange = useCallback((data: Partial) => { - setFormData(prev => ({ ...prev, ...data })); - }, []); - - // Handle address form changes - const handleAddressChange = useCallback((data: JapanAddressFormData, isComplete: boolean) => { - setFormData(prev => ({ ...prev, address: data })); - setIsAddressComplete(isComplete); - if (isComplete) { - setFormErrors(prev => ({ ...prev, address: undefined })); - } - }, []); - - // Clear specific form error - const handleClearError = useCallback((field: keyof FormErrors) => { - setFormErrors(prev => ({ ...prev, [field]: undefined })); - }, []); - - // Validate email format - const isValidEmail = (email: string): boolean => { - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); - }; - - // Validate form - const validateForm = (): boolean => { - const errors: FormErrors = {}; - - if (!formData.firstName.trim()) { - errors.firstName = "First name is required"; - } - if (!formData.lastName.trim()) { - errors.lastName = "Last name is required"; - } - if (!formData.email.trim()) { - errors.email = "Email is required"; - } else if (!isValidEmail(formData.email)) { - errors.email = "Enter a valid email address"; - } - if (!isAddressComplete) { - errors.address = "Please complete the address"; - } - - setFormErrors(errors); - return Object.keys(errors).length === 0; - }; - - // Start resend countdown timer - const startResendTimer = useCallback(() => { - setResendDisabled(true); - setResendCountdown(60); - - if (resendTimerRef.current) { - clearInterval(resendTimerRef.current); - } - - resendTimerRef.current = setInterval(() => { - setResendCountdown(prev => { - if (prev <= 1) { - if (resendTimerRef.current) { - clearInterval(resendTimerRef.current); - } - setResendDisabled(false); - return 0; - } - return prev - 1; - }); - }, 1000); - }, []); - - // Submit eligibility check (core logic) - const submitEligibilityCheck = async (continueToAccount: boolean) => { - if (!validateForm()) return null; - - setLoading(true); - setError(null); - - try { - const whmcsAddress = formData.address ? prepareWhmcsAddressFields(formData.address) : null; - - const result = await guestEligibilityCheck({ - email: formData.email.trim(), - firstName: formData.firstName.trim(), - lastName: formData.lastName.trim(), - address: { - address1: whmcsAddress?.address1 || "", - address2: whmcsAddress?.address2 || "", - city: whmcsAddress?.city || "", - state: whmcsAddress?.state || "", - postcode: whmcsAddress?.postcode || "", - country: "JP", - }, - continueToAccount, - }); - - if (!result.submitted) { - setError(result.message || "Failed to submit eligibility check"); - return null; - } - - return result; - } catch (err) { - const message = getErrorMessage(err); - logger.error("Failed to submit eligibility check", { error: message, email: formData.email }); - setError(message); - return null; - } finally { - setLoading(false); - } - }; - - // Handle "Send Request Only" - submit and show success - const handleSubmitOnly = async () => { - const result = await submitEligibilityCheck(false); - if (result) { - setRequestId(result.requestId || null); - setStep("success"); - } - }; - - // Handle "Continue to Create Account" - submit, send OTP, show inline OTP - const handleSubmitAndCreate = async () => { - if (!validateForm()) return; - - setLoading(true); - setError(null); - - try { - // 1. Submit eligibility check (creates SF Account + Case) - const whmcsAddress = formData.address ? prepareWhmcsAddressFields(formData.address) : null; - - const eligibilityResult = await guestEligibilityCheck({ - email: formData.email.trim(), - firstName: formData.firstName.trim(), - lastName: formData.lastName.trim(), - address: { - address1: whmcsAddress?.address1 || "", - address2: whmcsAddress?.address2 || "", - city: whmcsAddress?.city || "", - state: whmcsAddress?.state || "", - postcode: whmcsAddress?.postcode || "", - country: "JP", - }, - continueToAccount: true, - }); - - if (!eligibilityResult.submitted) { - setError(eligibilityResult.message || "Failed to submit eligibility check"); - return; - } - - // Store handoff token for OTP verification - if (eligibilityResult.handoffToken) { - setHandoffToken(eligibilityResult.handoffToken); - } - - // 2. Send OTP code - const otpResult = await sendVerificationCode({ email: formData.email.trim() }); - - if (otpResult.sent) { - setStep("otp"); - startResendTimer(); - } else { - setError(otpResult.message || "Failed to send verification code"); - } - } catch (err) { - const message = getErrorMessage(err); - logger.error("Failed to submit eligibility and send OTP", { - error: message, - email: formData.email, - }); - setError(message); - } finally { - setLoading(false); - } - }; - - // Handle OTP verification - const handleVerifyOtp = async (code: string) => { - if (code.length !== 6) return; - - setLoading(true); - setOtpError(null); - - try { - const result = await verifyCode({ - email: formData.email.trim(), - code, - ...(handoffToken && { handoffToken }), - }); - - if (result.verified && result.sessionToken) { - // Clear timer immediately on success - if (resendTimerRef.current) { - clearInterval(resendTimerRef.current); - } - setResendDisabled(false); - setResendCountdown(0); - - // Store session data for get-started page with timestamp for staleness validation - sessionStorage.setItem("get-started-session-token", result.sessionToken); - sessionStorage.setItem("get-started-account-status", result.accountStatus || ""); - sessionStorage.setItem("get-started-email", formData.email); - sessionStorage.setItem("get-started-timestamp", Date.now().toString()); - - // Store prefill data if available - if (result.prefill) { - sessionStorage.setItem("get-started-prefill", JSON.stringify(result.prefill)); - } - - // Redirect to complete-account step directly - router.push("/auth/get-started?verified=true"); - } else { - setOtpError(result.error || "Verification failed. Please try again."); - setAttemptsRemaining(result.attemptsRemaining ?? null); - setLoading(false); - } - } catch (err) { - const message = getErrorMessage(err); - logger.error("Failed to verify OTP", { error: message, email: formData.email }); - setOtpError(message); - setLoading(false); - } - }; - - // Handle OTP resend - const handleResendOtp = async () => { - if (resendDisabled) return; - - setLoading(true); - setOtpError(null); - - try { - const result = await sendVerificationCode({ email: formData.email.trim() }); - - if (result.sent) { - startResendTimer(); - } else { - setOtpError(result.message || "Failed to resend code"); - } - } catch (err) { - const message = getErrorMessage(err); - logger.error("Failed to resend OTP", { error: message, email: formData.email }); - setOtpError(message); - } finally { - setLoading(false); - } - }; - - // Handle "Change email" from OTP step - const handleChangeEmail = () => { - setStep("form"); - setOtpError(null); - setAttemptsRemaining(null); - setHandoffToken(null); - if (resendTimerRef.current) { - clearInterval(resendTimerRef.current); - } - setResendDisabled(false); - setResendCountdown(0); - }; - - // Handle "Back to Plans" - const handleBackToPlans = () => { - router.push(`${servicesBasePath}/internet`); - }; - - // Step meta for header - const stepMeta: Record< - Step, - { title: string; description: string; icon: "form" | "otp" | "success" } - > = { - form: { - title: "Check Availability", - description: "Enter your details to check if internet service is available at your address.", - icon: "form", - }, - otp: { - title: "Verify Email", - description: "Enter the verification code we sent to your email.", - icon: "otp", - }, - success: { - title: "Request Submitted", - description: "Your availability check request has been submitted.", - icon: "success", - }, - }; + // Dynamic title for success step based on account status + const currentMeta = stepMeta[step]; + const title = + step === "success" ? (hasAccount ? "Account Created" : "Request Submitted") : currentMeta.title; + const description = + step === "success" + ? hasAccount + ? "Your account is ready and eligibility check is in progress." + : "Your availability check request has been submitted." + : currentMeta.description; return (
    @@ -716,60 +81,16 @@ export function PublicEligibilityCheckView() {
    - {stepMeta[step].icon === "form" && } - {stepMeta[step].icon === "otp" && } - {stepMeta[step].icon === "success" && } +
    -

    - {stepMeta[step].title} -

    -

    - {stepMeta[step].description} -

    +

    {title}

    +

    {description}

    - {/* Card */} + {/* Card with Flow */}
    - {/* Form Step */} - {step === "form" && ( - - )} - - {/* OTP Step */} - {step === "otp" && ( - - )} - - {/* Success Step */} - {step === "success" && ( - - )} +
    {/* Help text */} diff --git a/apps/portal/src/features/services/views/PublicInternetPlans.tsx b/apps/portal/src/features/services/views/PublicInternetPlans.tsx index 0c4653fd..229a3156 100644 --- a/apps/portal/src/features/services/views/PublicInternetPlans.tsx +++ b/apps/portal/src/features/services/views/PublicInternetPlans.tsx @@ -223,7 +223,7 @@ export function PublicInternetPlansContent({ } } - const sortedTierPlans = Array.from(uniqueTiers.values()).sort( + const sortedTierPlans = [...uniqueTiers.values()].sort( (a, b) => (TIER_ORDER_MAP[a.internetPlanTier ?? ""] ?? 99) - (TIER_ORDER_MAP[b.internetPlanTier ?? ""] ?? 99) diff --git a/apps/portal/src/features/subscriptions/api/sim-actions.api.ts b/apps/portal/src/features/subscriptions/api/sim-actions.api.ts index 9d459521..2117a7f8 100644 --- a/apps/portal/src/features/subscriptions/api/sim-actions.api.ts +++ b/apps/portal/src/features/subscriptions/api/sim-actions.api.ts @@ -16,8 +16,6 @@ import { type SimInternationalCallHistoryResponse, type SimSmsHistoryResponse, type SimReissueFullRequest, -} from "@customer-portal/domain/sim"; -import type { SimTopUpRequest, SimPlanChangeRequest, SimCancelRequest, @@ -56,7 +54,8 @@ export const simActionsService = { body: request, } ); - return { scheduledAt: response.data?.scheduledAt }; + const scheduledAt = response.data?.scheduledAt; + return scheduledAt ? { scheduledAt } : {}; }, async cancel(subscriptionId: string, request: SimCancelRequest): Promise { @@ -121,9 +120,9 @@ export const simActionsService = { limit: number = 50 ): Promise { const params: Record = {}; - if (month) params.month = month; - params.page = String(page); - params.limit = String(limit); + if (month) params["month"] = month; + params["page"] = String(page); + params["limit"] = String(limit); const response = await apiClient.GET( "/api/subscriptions/{subscriptionId}/sim/call-history/domestic", @@ -142,9 +141,9 @@ export const simActionsService = { limit: number = 50 ): Promise { const params: Record = {}; - if (month) params.month = month; - params.page = String(page); - params.limit = String(limit); + if (month) params["month"] = month; + params["page"] = String(page); + params["limit"] = String(limit); const response = await apiClient.GET( "/api/subscriptions/{subscriptionId}/sim/call-history/international", @@ -163,9 +162,9 @@ export const simActionsService = { limit: number = 50 ): Promise { const params: Record = {}; - if (month) params.month = month; - params.page = String(page); - params.limit = String(limit); + if (month) params["month"] = month; + params["page"] = String(page); + params["limit"] = String(limit); const response = await apiClient.GET( "/api/subscriptions/{subscriptionId}/sim/sms-history", diff --git a/apps/portal/src/features/subscriptions/components/CancellationFlow/CancellationFlow.tsx b/apps/portal/src/features/subscriptions/components/CancellationFlow/CancellationFlow.tsx index eca9702b..e63dbfd0 100644 --- a/apps/portal/src/features/subscriptions/components/CancellationFlow/CancellationFlow.tsx +++ b/apps/portal/src/features/subscriptions/components/CancellationFlow/CancellationFlow.tsx @@ -176,11 +176,12 @@ export function CancellationFlow({ const handleConfirmSubmit = () => { setShowConfirmDialog(false); + const trimmedComments = comments.trim(); void onSubmit({ cancellationMonth: selectedMonth, confirmRead: acceptTerms, confirmCancel: confirmMonthEnd, - comments: comments.trim() || undefined, + ...(trimmedComments ? { comments: trimmedComments } : {}), }); }; diff --git a/apps/portal/src/features/subscriptions/components/SubscriptionStatusBadge.tsx b/apps/portal/src/features/subscriptions/components/SubscriptionStatusBadge.tsx index 93927e76..c64ef82d 100644 --- a/apps/portal/src/features/subscriptions/components/SubscriptionStatusBadge.tsx +++ b/apps/portal/src/features/subscriptions/components/SubscriptionStatusBadge.tsx @@ -4,30 +4,66 @@ import { SUBSCRIPTION_STATUS, type SubscriptionStatus, } from "@customer-portal/domain/subscriptions"; +import { StatusBadge, type StatusConfigMap } from "@/components/molecules"; /** + * Status configuration for subscription statuses. + * Maps each status to its visual variant. + * * Status → Semantic color mapping: - * - Active, Paid → success (green) - * - Pending, Unpaid → warning (amber) - * - Suspended, Completed, Cancelled, Inactive → neutral (navy) - * - Terminated, Overdue → danger (red) + * - Active → success (green) + * - Pending → warning (amber) + * - Inactive, Suspended, Completed, Cancelled → neutral (navy) + * - Terminated → error (red) */ -const STATUS_COLOR_MAP: Record = { - [SUBSCRIPTION_STATUS.ACTIVE]: "bg-success-bg text-success border-success-border", - [SUBSCRIPTION_STATUS.INACTIVE]: "bg-neutral-bg text-neutral border-neutral-border", - [SUBSCRIPTION_STATUS.PENDING]: "bg-warning-bg text-warning border-warning-border", - [SUBSCRIPTION_STATUS.SUSPENDED]: "bg-neutral-bg text-neutral border-neutral-border", - [SUBSCRIPTION_STATUS.CANCELLED]: "bg-neutral-bg text-neutral border-neutral-border", - [SUBSCRIPTION_STATUS.TERMINATED]: "bg-danger-bg text-danger border-danger-border", - [SUBSCRIPTION_STATUS.COMPLETED]: "bg-neutral-bg text-neutral border-neutral-border", +const SUBSCRIPTION_STATUS_CONFIG: StatusConfigMap = { + [SUBSCRIPTION_STATUS.ACTIVE.toLowerCase()]: { + variant: "success", + }, + [SUBSCRIPTION_STATUS.INACTIVE.toLowerCase()]: { + variant: "neutral", + }, + [SUBSCRIPTION_STATUS.PENDING.toLowerCase()]: { + variant: "warning", + }, + [SUBSCRIPTION_STATUS.SUSPENDED.toLowerCase()]: { + variant: "neutral", + }, + [SUBSCRIPTION_STATUS.CANCELLED.toLowerCase()]: { + variant: "neutral", + }, + [SUBSCRIPTION_STATUS.TERMINATED.toLowerCase()]: { + variant: "error", + }, + [SUBSCRIPTION_STATUS.COMPLETED.toLowerCase()]: { + variant: "neutral", + }, }; -export function SubscriptionStatusBadge({ status }: { status: SubscriptionStatus }) { - const color = STATUS_COLOR_MAP[status] ?? "bg-neutral-bg text-neutral border-neutral-border"; +const DEFAULT_CONFIG = { + variant: "neutral" as const, +}; +interface SubscriptionStatusBadgeProps { + status: SubscriptionStatus; +} + +/** + * SubscriptionStatusBadge - Displays the status of a subscription. + * + * @example + * ```tsx + * + * + * ``` + */ +export function SubscriptionStatusBadge({ status }: SubscriptionStatusBadgeProps) { return ( - - {status} - + ); } diff --git a/apps/portal/src/features/subscriptions/components/sim/SimActions.tsx b/apps/portal/src/features/subscriptions/components/sim/SimActions.tsx index 4181818e..2ce2831e 100644 --- a/apps/portal/src/features/subscriptions/components/sim/SimActions.tsx +++ b/apps/portal/src/features/subscriptions/components/sim/SimActions.tsx @@ -90,14 +90,12 @@ export function SimActions({ // Clear success/error messages after 5 seconds React.useEffect(() => { - if (success || error) { - const timer = setTimeout(() => { - setSuccess(null); - setError(null); - }, 5000); - return () => clearTimeout(timer); - } - return; + if (!success && !error) return; + const timer = setTimeout(() => { + setSuccess(null); + setError(null); + }, 5000); + return () => clearTimeout(timer); }, [success, error]); return ( @@ -341,7 +339,7 @@ export function SimActions({ {showChangePlanModal && ( { setShowChangePlanModal(false); setActiveInfo(null); diff --git a/apps/portal/src/features/subscriptions/components/sim/SimManagementSection.tsx b/apps/portal/src/features/subscriptions/components/sim/SimManagementSection.tsx index cafee239..5bcf44e6 100644 --- a/apps/portal/src/features/subscriptions/components/sim/SimManagementSection.tsx +++ b/apps/portal/src/features/subscriptions/components/sim/SimManagementSection.tsx @@ -86,7 +86,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro setSimInfo({ details: payload.details, - usage: payload.usage, + usage: payload.usage ?? { todayUsageMb: 0 }, }); } catch (err: unknown) { const hasStatus = (v: unknown): v is { status: number } => @@ -165,8 +165,8 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro : "0.00"; // Calculate percentage for circle - const totalMB = parseFloat(remainingMB) + parseFloat(usedMB); - const usagePercentage = totalMB > 0 ? (parseFloat(usedMB) / totalMB) * 100 : 0; + const totalMB = Number.parseFloat(remainingMB) + Number.parseFloat(usedMB); + const usagePercentage = totalMB > 0 ? (Number.parseFloat(usedMB) / totalMB) * 100 : 0; const circumference = 2 * Math.PI * 88; const strokeDashoffset = circumference - (usagePercentage / 100) * circumference; diff --git a/apps/portal/src/features/subscriptions/components/sim/TopUpModal.tsx b/apps/portal/src/features/subscriptions/components/sim/TopUpModal.tsx index 1c721e9a..bdbc7414 100644 --- a/apps/portal/src/features/subscriptions/components/sim/TopUpModal.tsx +++ b/apps/portal/src/features/subscriptions/components/sim/TopUpModal.tsx @@ -21,8 +21,8 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU const maxGb = pricing ? Math.floor(pricing.maxQuotaMb / 1000) : 50; const getCurrentAmountMb = () => { - const gb = parseInt(gbAmount, 10); - return isNaN(gb) ? 0 : gb * 1000; + const gb = Number.parseInt(gbAmount, 10); + return Number.isNaN(gb) ? 0 : gb * 1000; }; const isValidAmount = () => { @@ -31,8 +31,8 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU }; const calculateCost = () => { - const gb = parseInt(gbAmount, 10); - return isNaN(gb) ? 0 : gb * pricePerGb; + const gb = Number.parseInt(gbAmount, 10); + return Number.isNaN(gb) ? 0 : gb * pricePerGb; }; const handleSubmit = async (e: React.FormEvent) => { @@ -126,7 +126,9 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
    - {gbAmount && !isNaN(parseInt(gbAmount, 10)) ? `${gbAmount} GB` : "0 GB"} + {gbAmount && !Number.isNaN(Number.parseInt(gbAmount, 10)) + ? `${gbAmount} GB` + : "0 GB"}
    = {getCurrentAmountMb()} MB
    diff --git a/apps/portal/src/features/subscriptions/views/CancelSubscription.tsx b/apps/portal/src/features/subscriptions/views/CancelSubscription.tsx index 7403be77..bc8be150 100644 --- a/apps/portal/src/features/subscriptions/views/CancelSubscription.tsx +++ b/apps/portal/src/features/subscriptions/views/CancelSubscription.tsx @@ -119,7 +119,7 @@ function CancellationPendingView({ export function CancelSubscriptionContainer() { const params = useParams(); const router = useRouter(); - const subscriptionId = params.id as string; + const subscriptionId = params["id"] as string; const user = useAuthStore(state => state.user); const [loading, setLoading] = useState(true); @@ -257,7 +257,12 @@ export function CancelSubscriptionContainer() { serviceInfo={ {preview.serviceInfo.map((info, idx) => ( - + ))} } diff --git a/apps/portal/src/features/subscriptions/views/SimCallHistory.tsx b/apps/portal/src/features/subscriptions/views/SimCallHistory.tsx index 0bd02f05..066198ca 100644 --- a/apps/portal/src/features/subscriptions/views/SimCallHistory.tsx +++ b/apps/portal/src/features/subscriptions/views/SimCallHistory.tsx @@ -22,7 +22,7 @@ type TabType = "domestic" | "international" | "sms"; export function SimCallHistoryContainer() { const params = useParams(); - const subscriptionId = params.id as string; + const subscriptionId = params["id"] as string; const [activeTab, setActiveTab] = useState("domestic"); // Use September 2025 as the current month (latest available - 2 months behind) diff --git a/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx b/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx index b5e1a4d2..1069d2c1 100644 --- a/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx +++ b/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx @@ -16,7 +16,7 @@ const { formatCurrency } = Formatting; export function SimChangePlanContainer() { const params = useParams(); - const subscriptionId = params.id as string; + const subscriptionId = params["id"] as string; const [plans, setPlans] = useState([]); const [selectedPlan, setSelectedPlan] = useState(null); const [assignGlobalIp, setAssignGlobalIp] = useState(false); diff --git a/apps/portal/src/features/subscriptions/views/SimReissue.tsx b/apps/portal/src/features/subscriptions/views/SimReissue.tsx index fee48252..cc035aaa 100644 --- a/apps/portal/src/features/subscriptions/views/SimReissue.tsx +++ b/apps/portal/src/features/subscriptions/views/SimReissue.tsx @@ -16,7 +16,7 @@ type SimType = "physical" | "esim"; export function SimReissueContainer() { const params = useParams(); - const subscriptionId = params.id as string; + const subscriptionId = params["id"] as string; const [simType, setSimType] = useState(null); const [newEid, setNewEid] = useState(""); const [currentEid, setCurrentEid] = useState(null); diff --git a/apps/portal/src/features/subscriptions/views/SimTopUp.tsx b/apps/portal/src/features/subscriptions/views/SimTopUp.tsx index e10614f6..2d94784c 100644 --- a/apps/portal/src/features/subscriptions/views/SimTopUp.tsx +++ b/apps/portal/src/features/subscriptions/views/SimTopUp.tsx @@ -13,7 +13,7 @@ import { useSimTopUpPricing } from "@/features/subscriptions/hooks/useSimTopUpPr export function SimTopUpContainer() { const params = useParams(); - const subscriptionId = params.id as string; + const subscriptionId = params["id"] as string; const [gbAmount, setGbAmount] = useState("1"); const [loading, setLoading] = useState(false); const [message, setMessage] = useState(null); @@ -24,8 +24,8 @@ export function SimTopUpContainer() { const maxGb = pricing ? Math.floor(pricing.maxQuotaMb / 1000) : 50; const getCurrentAmountMb = () => { - const gb = parseInt(gbAmount, 10); - return isNaN(gb) ? 0 : gb * 1000; + const gb = Number.parseInt(gbAmount, 10); + return Number.isNaN(gb) ? 0 : gb * 1000; }; const isValidAmount = () => { @@ -34,8 +34,8 @@ export function SimTopUpContainer() { }; const calculateCost = () => { - const gb = parseInt(gbAmount, 10); - return isNaN(gb) ? 0 : gb * pricePerGb; + const gb = Number.parseInt(gbAmount, 10); + return Number.isNaN(gb) ? 0 : gb * pricePerGb; }; const handleSubmit = async (e: React.FormEvent) => { @@ -129,7 +129,9 @@ export function SimTopUpContainer() {
    - {gbAmount && !isNaN(parseInt(gbAmount, 10)) ? `${gbAmount} GB` : "0 GB"} + {gbAmount && !Number.isNaN(Number.parseInt(gbAmount, 10)) + ? `${gbAmount} GB` + : "0 GB"}
    = {getCurrentAmountMb()} MB
    diff --git a/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx b/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx index acf01ce3..98ebde34 100644 --- a/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx +++ b/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx @@ -18,22 +18,21 @@ import { SubscriptionDetailStatsSkeleton, InvoiceListSkeleton, } from "@/components/atoms/loading-skeleton"; -import { formatIsoDate } from "@/shared/utils"; - -const { formatCurrency: sharedFormatCurrency } = Formatting; +import { formatIsoDate, cn } from "@/shared/utils"; import { SimManagementSection } from "@/features/subscriptions/components/sim"; import { getBillingCycleLabel, getSubscriptionStatusVariant, } from "@/features/subscriptions/utils/status-presenters"; -import { cn } from "@/shared/utils"; + +const { formatCurrency: sharedFormatCurrency } = Formatting; export function SubscriptionDetailContainer() { const params = useParams(); const searchParams = useSearchParams(); const [activeTab, setActiveTab] = useState<"overview" | "sim">("overview"); - const subscriptionId = parseInt(params.id as string); + const subscriptionId = Number.parseInt(params["id"] as string); const { data: subscription, error } = useSubscription(subscriptionId); // Simple loading check: show skeleton until we have data or an error @@ -41,17 +40,15 @@ export function SubscriptionDetailContainer() { useEffect(() => { const updateTab = () => { - const hash = typeof window !== "undefined" ? window.location.hash : ""; + const hash = typeof window === "undefined" ? "" : window.location.hash; const service = (searchParams.get("service") || "").toLowerCase(); const isSimContext = hash.includes("sim-management") || service === "sim"; setActiveTab(isSimContext ? "sim" : "overview"); }; updateTab(); - if (typeof window !== "undefined") { - window.addEventListener("hashchange", updateTab); - return () => window.removeEventListener("hashchange", updateTab); - } - return; + if (typeof window === "undefined") return; + window.addEventListener("hashchange", updateTab); + return () => window.removeEventListener("hashchange", updateTab); }, [searchParams]); const formatDate = (dateString: string | undefined) => formatIsoDate(dateString); diff --git a/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx b/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx index 8b0fa1a4..9f8a98dd 100644 --- a/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx +++ b/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx @@ -29,9 +29,7 @@ export function SubscriptionsListContainer() { data: subscriptionData, error, isFetching, - } = useSubscriptions({ - status: statusFilter === "all" ? undefined : statusFilter, - }); + } = useSubscriptions(statusFilter === "all" ? {} : { status: statusFilter }); const { data: stats } = useSubscriptionStats(); // Simple loading check: show skeleton until we have data or an error diff --git a/apps/portal/src/features/support/hooks/useSupportCases.ts b/apps/portal/src/features/support/hooks/useSupportCases.ts index 8128bd23..706f00b2 100644 --- a/apps/portal/src/features/support/hooks/useSupportCases.ts +++ b/apps/portal/src/features/support/hooks/useSupportCases.ts @@ -13,9 +13,10 @@ export function useSupportCases(filters?: SupportCaseFilter) { return useQuery({ queryKey: queryKeys.support.cases(queryFilters), queryFn: async () => { - const response = await apiClient.GET("/api/support/cases", { - params: Object.keys(queryFilters).length > 0 ? { query: queryFilters } : undefined, - }); + const response = await apiClient.GET( + "/api/support/cases", + Object.keys(queryFilters).length > 0 ? { params: { query: queryFilters } } : {} + ); return getDataOrThrow(response, "Failed to load support cases"); }, enabled: isAuthenticated, diff --git a/apps/portal/src/features/support/views/PublicContactView.tsx b/apps/portal/src/features/support/views/PublicContactView.tsx index 114e6c64..cc40fe04 100644 --- a/apps/portal/src/features/support/views/PublicContactView.tsx +++ b/apps/portal/src/features/support/views/PublicContactView.tsx @@ -116,7 +116,7 @@ export function PublicContactView() {