diff --git a/.gitignore b/.gitignore index 03e73713..42b04cbc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Dependencies node_modules/ .pnpm-store/ +**/package-lock.json # Environment files .env diff --git a/apps/bff/package.json b/apps/bff/package.json index 6b946aa5..32dfffb8 100644 --- a/apps/bff/package.json +++ b/apps/bff/package.json @@ -6,21 +6,21 @@ "private": true, "license": "UNLICENSED", "scripts": { - "build": "NODE_OPTIONS=\"--max-old-space-size=8192\" nest build -c tsconfig.build.json", + "build": "nest build", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "start": "nest start", - "dev": "NODE_OPTIONS=\"--no-deprecation --max-old-space-size=4096\" nest start --watch --preserveWatchOutput -c tsconfig.build.json", - "start:debug": "NODE_OPTIONS=\"--no-deprecation --max-old-space-size=4096\" nest start --debug --watch", + "dev": "nest start --watch --preserveWatchOutput", + "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", - "lint": "NODE_OPTIONS=\"--max-old-space-size=4096\" eslint .", - "lint:fix": "NODE_OPTIONS=\"--max-old-space-size=4096\" eslint . --fix", - "test": "NODE_OPTIONS=\"--max-old-space-size=4096\" jest", - "test:watch": "NODE_OPTIONS=\"--max-old-space-size=4096\" jest --watch", - "test:cov": "NODE_OPTIONS=\"--max-old-space-size=4096\" jest --coverage", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "NODE_OPTIONS=\"--max-old-space-size=4096\" jest --config ./test/jest-e2e.json", - "type-check": "NODE_OPTIONS=\"--max-old-space-size=7168 --max-semi-space-size=256\" tsc --project tsconfig.json --noEmit", - "type-check:watch": "NODE_OPTIONS=\"--max-old-space-size=7168 --max-semi-space-size=256\" tsc --project tsconfig.json --noEmit --watch", + "test:e2e": "jest --config ./test/jest-e2e.json", + "type-check": "tsc --project tsconfig.json --noEmit", + "type-check:watch": "tsc --project tsconfig.json --noEmit --watch", "clean": "rm -rf dist", "db:migrate": "prisma migrate dev", "db:generate": "prisma generate", @@ -42,19 +42,20 @@ "@nestjs/swagger": "^11.2.0", "@nestjs/throttler": "^6.4.0", "@prisma/client": "^6.14.0", - "@sendgrid/mail": "^8.1.3", + "@sendgrid/mail": "^8.1.6", "bcrypt": "^6.0.0", "bullmq": "^5.58.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "cookie-parser": "^1.4.7", - "express": "^4.21.2", + "express": "^5.1.0", "helmet": "^8.1.0", "ioredis": "^5.7.0", "jsforce": "^3.10.4", "jsonwebtoken": "^9.0.2", "nestjs-pino": "^4.4.0", "nestjs-zod": "^5.0.1", + "p-queue": "^7.4.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", @@ -65,7 +66,7 @@ "rxjs": "^7.8.2", "salesforce-pubsub-api-client": "^5.5.0", "speakeasy": "^2.0.0", - "uuid": "^11.1.0", + "uuid": "^13.0.0", "zod": "^4.1.9" }, "devDependencies": { @@ -82,7 +83,7 @@ "@types/passport-local": "^1.0.38", "@types/speakeasy": "^2.0.10", "@types/supertest": "^6.0.3", - "@types/uuid": "^10.0.0", + "@types/uuid": "^11.0.0", "jest": "^30.0.5", "prisma": "^6.14.0", "source-map-support": "^0.5.21", diff --git a/apps/bff/src/app.module.ts b/apps/bff/src/app.module.ts index a6ce9bc7..01300b3b 100644 --- a/apps/bff/src/app.module.ts +++ b/apps/bff/src/app.module.ts @@ -10,6 +10,7 @@ import { apiRoutes } from "@bff/core/config/router.config"; // Core Modules import { LoggingModule } from "@bff/core/logging/logging.module"; +import { SecurityModule } from "@bff/core/security/security.module"; import { PrismaModule } from "@bff/infra/database/prisma.module"; import { RedisModule } from "@bff/infra/redis/redis.module"; import { CacheModule } from "@bff/infra/cache/cache.module"; @@ -52,6 +53,7 @@ import { HealthModule } from "@bff/modules/health/health.module"; // === INFRASTRUCTURE === LoggingModule, + SecurityModule, ThrottlerModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], diff --git a/apps/bff/src/app/bootstrap.ts b/apps/bff/src/app/bootstrap.ts index c58f98e5..f3059428 100644 --- a/apps/bff/src/app/bootstrap.ts +++ b/apps/bff/src/app/bootstrap.ts @@ -20,6 +20,7 @@ declare global { import { GlobalExceptionFilter } from "@bff/core/http/http-exception.filter"; import { AuthErrorFilter } from "@bff/core/http/auth-error.filter"; +import { SecureErrorMapperService } from "@bff/core/security/services/secure-error-mapper.service"; import { AppModule } from "../app.module"; @@ -134,7 +135,7 @@ export async function bootstrap(): Promise { // Global exception filters app.useGlobalFilters( new AuthErrorFilter(app.get(Logger)), // Handle auth errors first - new GlobalExceptionFilter(app.get(Logger), configService) // Handle all other errors + new GlobalExceptionFilter(app.get(Logger), configService, app.get(SecureErrorMapperService)) // Handle all other errors ); // Global authentication guard will be registered via APP_GUARD provider in AuthModule diff --git a/apps/bff/src/core/config/config.module.ts b/apps/bff/src/core/config/config.module.ts new file mode 100644 index 00000000..c8b3e469 --- /dev/null +++ b/apps/bff/src/core/config/config.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { SalesforceFieldMapService } from "./field-map"; + +@Module({ + imports: [ConfigModule], + providers: [SalesforceFieldMapService], + exports: [SalesforceFieldMapService], +}) +export class CoreConfigModule {} diff --git a/apps/bff/src/core/config/env.validation.ts b/apps/bff/src/core/config/env.validation.ts index 4295a69a..c30e4de9 100644 --- a/apps/bff/src/core/config/env.validation.ts +++ b/apps/bff/src/core/config/env.validation.ts @@ -66,6 +66,74 @@ export const envSchema = z.object({ FREEBIT_TIMEOUT: z.coerce.number().int().positive().default(30000), FREEBIT_RETRY_ATTEMPTS: z.coerce.number().int().positive().default(3), FREEBIT_DETAILS_ENDPOINT: z.string().default("/master/getAcnt/"), + + // Portal Configuration + PORTAL_PRICEBOOK_ID: z.string().default("01sTL000008eLVlYAM"), + PORTAL_PRICEBOOK_NAME: z.string().default("Portal"), + + // Salesforce Field Mappings - Account + ACCOUNT_INTERNET_ELIGIBILITY_FIELD: z.string().default("Internet_Eligibility__c"), + ACCOUNT_CUSTOMER_NUMBER_FIELD: z.string().default("SF_Account_No__c"), + + // Salesforce Field Mappings - Product + PRODUCT_SKU_FIELD: z.string().default("StockKeepingUnit"), + PRODUCT_PORTAL_CATEGORY_FIELD: z.string().default("Product2Categories1__c"), + PRODUCT_PORTAL_CATALOG_FIELD: z.string().default("Portal_Catalog__c"), + PRODUCT_PORTAL_ACCESSIBLE_FIELD: z.string().default("Portal_Accessible__c"), + PRODUCT_ITEM_CLASS_FIELD: z.string().default("Item_Class__c"), + PRODUCT_BILLING_CYCLE_FIELD: z.string().default("Billing_Cycle__c"), + PRODUCT_WHMCS_PRODUCT_ID_FIELD: z.string().default("WH_Product_ID__c"), + PRODUCT_WHMCS_PRODUCT_NAME_FIELD: z.string().default("WH_Product_Name__c"), + PRODUCT_INTERNET_PLAN_TIER_FIELD: z.string().default("Internet_Plan_Tier__c"), + PRODUCT_INTERNET_OFFERING_TYPE_FIELD: z.string().default("Internet_Offering_Type__c"), + PRODUCT_DISPLAY_ORDER_FIELD: z.string().default("Catalog_Order__c"), + PRODUCT_BUNDLED_ADDON_FIELD: z.string().default("Bundled_Addon__c"), + PRODUCT_IS_BUNDLED_ADDON_FIELD: z.string().default("Is_Bundled_Addon__c"), + PRODUCT_SIM_DATA_SIZE_FIELD: z.string().default("SIM_Data_Size__c"), + PRODUCT_SIM_PLAN_TYPE_FIELD: z.string().default("SIM_Plan_Type__c"), + PRODUCT_SIM_HAS_FAMILY_DISCOUNT_FIELD: z.string().default("SIM_Has_Family_Discount__c"), + PRODUCT_VPN_REGION_FIELD: z.string().default("VPN_Region__c"), + + // Salesforce Field Mappings - Order + ORDER_TYPE_FIELD: z.string().default("Type"), + ORDER_ACTIVATION_TYPE_FIELD: z.string().default("Activation_Type__c"), + ORDER_ACTIVATION_SCHEDULED_AT_FIELD: z.string().default("Activation_Scheduled_At__c"), + ORDER_ACTIVATION_STATUS_FIELD: z.string().default("Activation_Status__c"), + ORDER_INTERNET_PLAN_TIER_FIELD: z.string().default("Internet_Plan_Tier__c"), + ORDER_INSTALLATION_TYPE_FIELD: z.string().default("Installment_Plan__c"), + ORDER_WEEKEND_INSTALL_FIELD: z.string().default("Weekend_Install__c"), + ORDER_ACCESS_MODE_FIELD: z.string().default("Access_Mode__c"), + ORDER_HIKARI_DENWA_FIELD: z.string().default("Hikari_Denwa__c"), + ORDER_VPN_REGION_FIELD: z.string().default("VPN_Region__c"), + ORDER_SIM_TYPE_FIELD: z.string().default("SIM_Type__c"), + ORDER_EID_FIELD: z.string().default("EID__c"), + ORDER_SIM_VOICE_MAIL_FIELD: z.string().default("SIM_Voice_Mail__c"), + ORDER_SIM_CALL_WAITING_FIELD: z.string().default("SIM_Call_Waiting__c"), + ORDER_MNP_APPLICATION_FIELD: z.string().default("MNP_Application__c"), + ORDER_MNP_RESERVATION_FIELD: z.string().default("MNP_Reservation_Number__c"), + ORDER_MNP_EXPIRY_FIELD: z.string().default("MNP_Expiry_Date__c"), + ORDER_MNP_PHONE_FIELD: z.string().default("MNP_Phone_Number__c"), + ORDER_MVNO_ACCOUNT_NUMBER_FIELD: z.string().default("MVNO_Account_Number__c"), + ORDER_PORTING_DOB_FIELD: z.string().default("Porting_DateOfBirth__c"), + ORDER_PORTING_FIRST_NAME_FIELD: z.string().default("Porting_FirstName__c"), + ORDER_PORTING_LAST_NAME_FIELD: z.string().default("Porting_LastName__c"), + ORDER_PORTING_FIRST_NAME_KATAKANA_FIELD: z.string().default("Porting_FirstName_Katakana__c"), + ORDER_PORTING_LAST_NAME_KATAKANA_FIELD: z.string().default("Porting_LastName_Katakana__c"), + ORDER_PORTING_GENDER_FIELD: z.string().default("Porting_Gender__c"), + ORDER_WHMCS_ORDER_ID_FIELD: z.string().default("WHMCS_Order_ID__c"), + ORDER_ACTIVATION_ERROR_CODE_FIELD: z.string().default("Activation_Error_Code__c"), + ORDER_ACTIVATION_ERROR_MESSAGE_FIELD: z.string().default("Activation_Error_Message__c"), + ORDER_ACTIVATION_LAST_ATTEMPT_AT_FIELD: z.string().default("ActivatedDate"), + ORDER_ADDRESS_CHANGED_FIELD: z.string().default("Address_Changed__c"), + ORDER_BILLING_STREET_FIELD: z.string().default("BillingStreet"), + ORDER_BILLING_CITY_FIELD: z.string().default("BillingCity"), + ORDER_BILLING_STATE_FIELD: z.string().default("BillingState"), + ORDER_BILLING_POSTAL_CODE_FIELD: z.string().default("BillingPostalCode"), + ORDER_BILLING_COUNTRY_FIELD: z.string().default("BillingCountry"), + + // Salesforce Field Mappings - Order Item + ORDER_ITEM_BILLING_CYCLE_FIELD: z.string().default("Billing_Cycle__c"), + ORDER_ITEM_WHMCS_SERVICE_ID_FIELD: z.string().default("WHMCS_Service_ID__c"), }); export function validate(config: Record): Record { diff --git a/apps/bff/src/core/config/field-map.ts b/apps/bff/src/core/config/field-map.ts index d76dc9ef..73c6069c 100644 --- a/apps/bff/src/core/config/field-map.ts +++ b/apps/bff/src/core/config/field-map.ts @@ -1,4 +1,6 @@ import type { SalesforceProductFieldMap } from "@customer-portal/domain"; +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; export type SalesforceFieldMap = { account: { @@ -53,148 +55,159 @@ export type SalesforceFieldMap = { }; }; -export function getSalesforceFieldMap(): SalesforceFieldMap { - return { - account: { - internetEligibility: - process.env.ACCOUNT_INTERNET_ELIGIBILITY_FIELD || "Internet_Eligibility__c", - customerNumber: process.env.ACCOUNT_CUSTOMER_NUMBER_FIELD || "SF_Account_No__c", - }, - product: { - sku: process.env.PRODUCT_SKU_FIELD || "StockKeepingUnit", - portalCategory: process.env.PRODUCT_PORTAL_CATEGORY_FIELD || "Product2Categories1__c", - portalCatalog: process.env.PRODUCT_PORTAL_CATALOG_FIELD || "Portal_Catalog__c", - portalAccessible: process.env.PRODUCT_PORTAL_ACCESSIBLE_FIELD || "Portal_Accessible__c", - itemClass: process.env.PRODUCT_ITEM_CLASS_FIELD || "Item_Class__c", - billingCycle: process.env.PRODUCT_BILLING_CYCLE_FIELD || "Billing_Cycle__c", - whmcsProductId: process.env.PRODUCT_WHMCS_PRODUCT_ID_FIELD || "WH_Product_ID__c", - whmcsProductName: process.env.PRODUCT_WHMCS_PRODUCT_NAME_FIELD || "WH_Product_Name__c", - internetPlanTier: process.env.PRODUCT_INTERNET_PLAN_TIER_FIELD || "Internet_Plan_Tier__c", - internetOfferingType: - process.env.PRODUCT_INTERNET_OFFERING_TYPE_FIELD || "Internet_Offering_Type__c", - displayOrder: process.env.PRODUCT_DISPLAY_ORDER_FIELD || "Catalog_Order__c", - bundledAddon: process.env.PRODUCT_BUNDLED_ADDON_FIELD || "Bundled_Addon__c", - isBundledAddon: process.env.PRODUCT_IS_BUNDLED_ADDON_FIELD || "Is_Bundled_Addon__c", - simDataSize: process.env.PRODUCT_SIM_DATA_SIZE_FIELD || "SIM_Data_Size__c", - simPlanType: process.env.PRODUCT_SIM_PLAN_TYPE_FIELD || "SIM_Plan_Type__c", - simHasFamilyDiscount: - process.env.PRODUCT_SIM_HAS_FAMILY_DISCOUNT_FIELD || "SIM_Has_Family_Discount__c", - vpnRegion: process.env.PRODUCT_VPN_REGION_FIELD || "VPN_Region__c", - }, - order: { - orderType: process.env.ORDER_TYPE_FIELD || "Type", - activationType: process.env.ORDER_ACTIVATION_TYPE_FIELD || "Activation_Type__c", - activationScheduledAt: - process.env.ORDER_ACTIVATION_SCHEDULED_AT_FIELD || "Activation_Scheduled_At__c", - activationStatus: process.env.ORDER_ACTIVATION_STATUS_FIELD || "Activation_Status__c", - internetPlanTier: process.env.ORDER_INTERNET_PLAN_TIER_FIELD || "Internet_Plan_Tier__c", - installationType: process.env.ORDER_INSTALLATION_TYPE_FIELD || "Installment_Plan__c", - weekendInstall: process.env.ORDER_WEEKEND_INSTALL_FIELD || "Weekend_Install__c", - accessMode: process.env.ORDER_ACCESS_MODE_FIELD || "Access_Mode__c", - hikariDenwa: process.env.ORDER_HIKARI_DENWA_FIELD || "Hikari_Denwa__c", - vpnRegion: process.env.ORDER_VPN_REGION_FIELD || "VPN_Region__c", - simType: process.env.ORDER_SIM_TYPE_FIELD || "SIM_Type__c", - eid: process.env.ORDER_EID_FIELD || "EID__c", - simVoiceMail: process.env.ORDER_SIM_VOICE_MAIL_FIELD || "SIM_Voice_Mail__c", - simCallWaiting: process.env.ORDER_SIM_CALL_WAITING_FIELD || "SIM_Call_Waiting__c", - mnp: { - application: process.env.ORDER_MNP_APPLICATION_FIELD || "MNP_Application__c", - reservationNumber: process.env.ORDER_MNP_RESERVATION_FIELD || "MNP_Reservation_Number__c", - expiryDate: process.env.ORDER_MNP_EXPIRY_FIELD || "MNP_Expiry_Date__c", - phoneNumber: process.env.ORDER_MNP_PHONE_FIELD || "MNP_Phone_Number__c", - mvnoAccountNumber: process.env.ORDER_MVNO_ACCOUNT_NUMBER_FIELD || "MVNO_Account_Number__c", - portingDateOfBirth: process.env.ORDER_PORTING_DOB_FIELD || "Porting_DateOfBirth__c", - portingFirstName: process.env.ORDER_PORTING_FIRST_NAME_FIELD || "Porting_FirstName__c", - portingLastName: process.env.ORDER_PORTING_LAST_NAME_FIELD || "Porting_LastName__c", - portingFirstNameKatakana: - process.env.ORDER_PORTING_FIRST_NAME_KATAKANA_FIELD || "Porting_FirstName_Katakana__c", - portingLastNameKatakana: - process.env.ORDER_PORTING_LAST_NAME_KATAKANA_FIELD || "Porting_LastName_Katakana__c", - portingGender: process.env.ORDER_PORTING_GENDER_FIELD || "Porting_Gender__c", +@Injectable() +export class SalesforceFieldMapService { + constructor(private readonly configService: ConfigService) {} + + getFieldMap(): SalesforceFieldMap { + return { + account: { + internetEligibility: this.configService.get("ACCOUNT_INTERNET_ELIGIBILITY_FIELD")!, + customerNumber: this.configService.get("ACCOUNT_CUSTOMER_NUMBER_FIELD")!, }, - whmcsOrderId: process.env.ORDER_WHMCS_ORDER_ID_FIELD || "WHMCS_Order_ID__c", - lastErrorCode: process.env.ORDER_ACTIVATION_ERROR_CODE_FIELD || "Activation_Error_Code__c", - lastErrorMessage: - process.env.ORDER_ACTIVATION_ERROR_MESSAGE_FIELD || "Activation_Error_Message__c", - lastAttemptAt: process.env.ORDER_ACTIVATION_LAST_ATTEMPT_AT_FIELD || "ActivatedDate", - addressChanged: process.env.ORDER_ADDRESS_CHANGED_FIELD || "Address_Changed__c", - billing: { - street: process.env.ORDER_BILLING_STREET_FIELD || "BillingStreet", - city: process.env.ORDER_BILLING_CITY_FIELD || "BillingCity", - state: process.env.ORDER_BILLING_STATE_FIELD || "BillingState", - postalCode: process.env.ORDER_BILLING_POSTAL_CODE_FIELD || "BillingPostalCode", - country: process.env.ORDER_BILLING_COUNTRY_FIELD || "BillingCountry", + product: { + sku: this.configService.get("PRODUCT_SKU_FIELD")!, + portalCategory: this.configService.get("PRODUCT_PORTAL_CATEGORY_FIELD")!, + portalCatalog: this.configService.get("PRODUCT_PORTAL_CATALOG_FIELD")!, + portalAccessible: this.configService.get("PRODUCT_PORTAL_ACCESSIBLE_FIELD")!, + itemClass: this.configService.get("PRODUCT_ITEM_CLASS_FIELD")!, + billingCycle: this.configService.get("PRODUCT_BILLING_CYCLE_FIELD")!, + whmcsProductId: this.configService.get("PRODUCT_WHMCS_PRODUCT_ID_FIELD")!, + whmcsProductName: this.configService.get("PRODUCT_WHMCS_PRODUCT_NAME_FIELD")!, + internetPlanTier: this.configService.get("PRODUCT_INTERNET_PLAN_TIER_FIELD")!, + internetOfferingType: this.configService.get( + "PRODUCT_INTERNET_OFFERING_TYPE_FIELD" + )!, + displayOrder: this.configService.get("PRODUCT_DISPLAY_ORDER_FIELD")!, + bundledAddon: this.configService.get("PRODUCT_BUNDLED_ADDON_FIELD")!, + isBundledAddon: this.configService.get("PRODUCT_IS_BUNDLED_ADDON_FIELD")!, + simDataSize: this.configService.get("PRODUCT_SIM_DATA_SIZE_FIELD")!, + simPlanType: this.configService.get("PRODUCT_SIM_PLAN_TYPE_FIELD")!, + simHasFamilyDiscount: this.configService.get( + "PRODUCT_SIM_HAS_FAMILY_DISCOUNT_FIELD" + )!, + vpnRegion: this.configService.get("PRODUCT_VPN_REGION_FIELD")!, }, - }, - orderItem: { - billingCycle: process.env.ORDER_ITEM_BILLING_CYCLE_FIELD || "Billing_Cycle__c", - whmcsServiceId: process.env.ORDER_ITEM_WHMCS_SERVICE_ID_FIELD || "WHMCS_Service_ID__c", - }, - }; -} + order: { + orderType: this.configService.get("ORDER_TYPE_FIELD")!, + activationType: this.configService.get("ORDER_ACTIVATION_TYPE_FIELD")!, + activationScheduledAt: this.configService.get( + "ORDER_ACTIVATION_SCHEDULED_AT_FIELD" + )!, + activationStatus: this.configService.get("ORDER_ACTIVATION_STATUS_FIELD")!, + internetPlanTier: this.configService.get("ORDER_INTERNET_PLAN_TIER_FIELD")!, + installationType: this.configService.get("ORDER_INSTALLATION_TYPE_FIELD")!, + weekendInstall: this.configService.get("ORDER_WEEKEND_INSTALL_FIELD")!, + accessMode: this.configService.get("ORDER_ACCESS_MODE_FIELD")!, + hikariDenwa: this.configService.get("ORDER_HIKARI_DENWA_FIELD")!, + vpnRegion: this.configService.get("ORDER_VPN_REGION_FIELD")!, + simType: this.configService.get("ORDER_SIM_TYPE_FIELD")!, + eid: this.configService.get("ORDER_EID_FIELD")!, + simVoiceMail: this.configService.get("ORDER_SIM_VOICE_MAIL_FIELD")!, + simCallWaiting: this.configService.get("ORDER_SIM_CALL_WAITING_FIELD")!, + mnp: { + application: this.configService.get("ORDER_MNP_APPLICATION_FIELD")!, + reservationNumber: this.configService.get("ORDER_MNP_RESERVATION_FIELD")!, + expiryDate: this.configService.get("ORDER_MNP_EXPIRY_FIELD")!, + phoneNumber: this.configService.get("ORDER_MNP_PHONE_FIELD")!, + mvnoAccountNumber: this.configService.get("ORDER_MVNO_ACCOUNT_NUMBER_FIELD")!, + portingDateOfBirth: this.configService.get("ORDER_PORTING_DOB_FIELD")!, + portingFirstName: this.configService.get("ORDER_PORTING_FIRST_NAME_FIELD")!, + portingLastName: this.configService.get("ORDER_PORTING_LAST_NAME_FIELD")!, + portingFirstNameKatakana: this.configService.get( + "ORDER_PORTING_FIRST_NAME_KATAKANA_FIELD" + )!, + portingLastNameKatakana: this.configService.get( + "ORDER_PORTING_LAST_NAME_KATAKANA_FIELD" + )!, + portingGender: this.configService.get("ORDER_PORTING_GENDER_FIELD")!, + }, + whmcsOrderId: this.configService.get("ORDER_WHMCS_ORDER_ID_FIELD")!, + lastErrorCode: this.configService.get("ORDER_ACTIVATION_ERROR_CODE_FIELD"), + lastErrorMessage: this.configService.get("ORDER_ACTIVATION_ERROR_MESSAGE_FIELD"), + lastAttemptAt: this.configService.get("ORDER_ACTIVATION_LAST_ATTEMPT_AT_FIELD"), + addressChanged: this.configService.get("ORDER_ADDRESS_CHANGED_FIELD")!, + billing: { + street: this.configService.get("ORDER_BILLING_STREET_FIELD")!, + city: this.configService.get("ORDER_BILLING_CITY_FIELD")!, + state: this.configService.get("ORDER_BILLING_STATE_FIELD")!, + postalCode: this.configService.get("ORDER_BILLING_POSTAL_CODE_FIELD")!, + country: this.configService.get("ORDER_BILLING_COUNTRY_FIELD")!, + }, + }, + orderItem: { + billingCycle: this.configService.get("ORDER_ITEM_BILLING_CYCLE_FIELD")!, + whmcsServiceId: this.configService.get("ORDER_ITEM_WHMCS_SERVICE_ID_FIELD")!, + }, + }; + } -export function getProductQueryFields(): string { - const fields = getSalesforceFieldMap(); - return [ - "Id", - "Name", - fields.product.sku, - fields.product.portalCategory, - fields.product.portalCatalog, - fields.product.portalAccessible, - fields.product.itemClass, - fields.product.billingCycle, - fields.product.whmcsProductId, - fields.product.whmcsProductName, - fields.product.internetPlanTier, - fields.product.internetOfferingType, - fields.product.displayOrder, - fields.product.bundledAddon, - fields.product.isBundledAddon, - fields.product.simDataSize, - fields.product.simPlanType, - fields.product.simHasFamilyDiscount, - ].join(", "); -} + getProductQueryFields(): string { + const fields = this.getFieldMap(); + return [ + "Id", + "Name", + fields.product.sku, + fields.product.portalCategory, + fields.product.portalCatalog, + fields.product.portalAccessible, + fields.product.itemClass, + fields.product.billingCycle, + fields.product.whmcsProductId, + fields.product.whmcsProductName, + fields.product.internetPlanTier, + fields.product.internetOfferingType, + fields.product.displayOrder, + fields.product.bundledAddon, + fields.product.isBundledAddon, + fields.product.simDataSize, + fields.product.simPlanType, + fields.product.simHasFamilyDiscount, + fields.product.vpnRegion, + "UnitPrice", + "IsActive", + ].join(", "); + } -export function getOrderQueryFields(): string { - const fields = getSalesforceFieldMap(); - return [ - "Id", - "AccountId", - "Status", - "EffectiveDate", - fields.order.orderType, - fields.order.activationType, - fields.order.activationScheduledAt, - fields.order.activationStatus, - fields.order.lastErrorCode!, - fields.order.lastErrorMessage!, - fields.order.lastAttemptAt!, - fields.order.internetPlanTier, - fields.order.installationType, - fields.order.weekendInstall, - fields.order.accessMode, - fields.order.hikariDenwa, - fields.order.vpnRegion, - fields.order.simType, - fields.order.simVoiceMail, - fields.order.simCallWaiting, - fields.order.eid, - fields.order.whmcsOrderId, - ].join(", "); -} + getOrderQueryFields(): string { + const fields = this.getFieldMap(); + return [ + "Id", + "AccountId", + "Status", + "EffectiveDate", + fields.order.orderType, + fields.order.activationType, + fields.order.activationScheduledAt, + fields.order.activationStatus, + fields.order.lastErrorCode!, + fields.order.lastErrorMessage!, + fields.order.lastAttemptAt!, + fields.order.internetPlanTier, + fields.order.installationType, + fields.order.weekendInstall, + fields.order.accessMode, + fields.order.hikariDenwa, + fields.order.vpnRegion, + fields.order.simType, + fields.order.simVoiceMail, + fields.order.simCallWaiting, + fields.order.eid, + fields.order.whmcsOrderId, + ].join(", "); + } -export function getOrderItemProduct2Select(additional: string[] = []): string { - const fields = getSalesforceFieldMap(); - const base = [ - "Id", - "Name", - fields.product.sku, - fields.product.whmcsProductId, - fields.product.itemClass, - fields.product.billingCycle, - ]; - const all = [...base, ...additional]; - return all.map(f => `PricebookEntry.Product2.${f}`).join(", "); + getOrderItemProduct2Select(additional: string[] = []): string { + const fields = this.getFieldMap(); + const base = [ + "Id", + "Name", + fields.product.sku, + fields.product.whmcsProductId, + fields.product.itemClass, + fields.product.billingCycle, + ]; + const all = [...base, ...additional]; + return all.map(f => `PricebookEntry.Product2.${f}`).join(", "); + } } diff --git a/apps/bff/src/core/database/database.module.ts b/apps/bff/src/core/database/database.module.ts new file mode 100644 index 00000000..6f155bd4 --- /dev/null +++ b/apps/bff/src/core/database/database.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { PrismaModule } from "@bff/infra/database/prisma.module"; +import { TransactionService } from "./services/transaction.service"; +import { DistributedTransactionService } from "./services/distributed-transaction.service"; + +@Module({ + imports: [PrismaModule], + providers: [TransactionService, DistributedTransactionService], + exports: [TransactionService, DistributedTransactionService], +}) +export class DatabaseModule {} diff --git a/apps/bff/src/core/database/services/distributed-transaction.service.ts b/apps/bff/src/core/database/services/distributed-transaction.service.ts new file mode 100644 index 00000000..074156bb --- /dev/null +++ b/apps/bff/src/core/database/services/distributed-transaction.service.ts @@ -0,0 +1,390 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { TransactionService, TransactionContext } from "./transaction.service"; +import { getErrorMessage } from "@bff/core/utils/error.util"; + +export interface DistributedStep { + id: string; + description: string; + execute: () => Promise; + rollback?: () => Promise; + critical?: boolean; // If true, failure stops entire transaction + retryable?: boolean; // If true, step can be retried on failure +} + +export interface DistributedTransactionOptions { + description: string; + timeout?: number; + maxRetries?: number; + continueOnNonCriticalFailure?: boolean; +} + +export interface DistributedTransactionResult { + success: boolean; + data?: T; + error?: string; + duration: number; + stepsExecuted: number; + stepsRolledBack: number; + stepResults: Record; + failedSteps: string[]; +} + +/** + * Service for managing distributed transactions across multiple external systems + * Provides coordination between database operations and external API calls + */ +@Injectable() +export class DistributedTransactionService { + constructor( + private readonly transactionService: TransactionService, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Execute a distributed transaction with multiple steps across different systems + * + * @example + * ```typescript + * const result = await this.distributedTransactionService.executeDistributedTransaction([ + * { + * id: 'sf_status_update', + * description: 'Update Salesforce order status to Activating', + * execute: async () => { + * return await this.salesforceService.updateOrder({ + * Id: sfOrderId, + * Status: 'Activating' + * }); + * }, + * rollback: async () => { + * await this.salesforceService.updateOrder({ + * Id: sfOrderId, + * Status: 'Draft' + * }); + * }, + * critical: true + * }, + * { + * id: 'whmcs_create_order', + * description: 'Create order in WHMCS', + * execute: async () => { + * return await this.whmcsOrderService.createOrder(orderData); + * }, + * rollback: async () => { + * if (stepResults.whmcs_create_order?.orderId) { + * await this.whmcsOrderService.cancelOrder(stepResults.whmcs_create_order.orderId); + * } + * }, + * critical: true + * } + * ], { + * description: 'Order fulfillment workflow', + * timeout: 120000 + * }); + * ``` + */ + async executeDistributedTransaction( + steps: DistributedStep[], + options: DistributedTransactionOptions + ): Promise { + const { + description, + timeout = 120000, // 2 minutes default for distributed operations + maxRetries = 1, // Less retries for distributed operations + continueOnNonCriticalFailure = false + } = options; + + const transactionId = this.generateTransactionId(); + const startTime = Date.now(); + + this.logger.log(`Starting distributed transaction [${transactionId}]`, { + description, + stepsCount: steps.length, + timeout + }); + + const stepResults: Record = {}; + const executedSteps: string[] = []; + const failedSteps: string[] = []; + let lastError: Error | null = null; + + try { + // Execute steps sequentially + for (const step of steps) { + this.logger.debug(`Executing step: ${step.id} [${transactionId}]`, { + description: step.description, + critical: step.critical + }); + + try { + const stepStartTime = Date.now(); + const result = await this.executeStepWithTimeout(step, timeout); + const stepDuration = Date.now() - stepStartTime; + + stepResults[step.id] = result; + executedSteps.push(step.id); + + this.logger.debug(`Step completed: ${step.id} [${transactionId}]`, { + duration: stepDuration + }); + + } catch (stepError) { + lastError = stepError as Error; + failedSteps.push(step.id); + + this.logger.error(`Step failed: ${step.id} [${transactionId}]`, { + error: getErrorMessage(stepError), + critical: step.critical, + retryable: step.retryable + }); + + // If it's a critical step, stop the entire transaction + if (step.critical) { + throw stepError; + } + + // If we're not continuing on non-critical failures, stop + if (!continueOnNonCriticalFailure) { + throw stepError; + } + + // Otherwise, log and continue + this.logger.warn(`Continuing despite non-critical step failure: ${step.id} [${transactionId}]`); + } + } + + const duration = Date.now() - startTime; + + this.logger.log(`Distributed transaction completed successfully [${transactionId}]`, { + description, + duration, + stepsExecuted: executedSteps.length, + failedSteps: failedSteps.length + }); + + return { + success: true, + data: stepResults, + duration, + stepsExecuted: executedSteps.length, + stepsRolledBack: 0, + stepResults, + failedSteps + }; + + } catch (error) { + const duration = Date.now() - startTime; + + this.logger.error(`Distributed transaction failed [${transactionId}]`, { + description, + error: getErrorMessage(error), + duration, + stepsExecuted: executedSteps.length, + failedSteps: failedSteps.length + }); + + // Execute rollbacks for completed steps + const rollbacksExecuted = await this.executeRollbacks( + steps, + executedSteps, + stepResults, + transactionId + ); + + return { + success: false, + error: getErrorMessage(error), + duration, + stepsExecuted: executedSteps.length, + stepsRolledBack: rollbacksExecuted, + stepResults, + failedSteps + }; + } + } + + /** + * Execute a hybrid transaction that combines database operations with external system calls + */ + async executeHybridTransaction( + databaseOperation: (tx: any, context: TransactionContext) => Promise, + externalSteps: DistributedStep[], + options: DistributedTransactionOptions & { + databaseFirst?: boolean; + rollbackDatabaseOnExternalFailure?: boolean; + } + ): Promise> { + const { + databaseFirst = true, + rollbackDatabaseOnExternalFailure = true, + ...distributedOptions + } = options; + + const transactionId = this.generateTransactionId(); + const startTime = Date.now(); + + this.logger.log(`Starting hybrid transaction [${transactionId}]`, { + description: options.description, + databaseFirst, + externalStepsCount: externalSteps.length + }); + + try { + let databaseResult: T | null = null; + let externalResult: DistributedTransactionResult | null = null; + + if (databaseFirst) { + // Execute database operations first + this.logger.debug(`Executing database operations [${transactionId}]`); + const dbTransactionResult = await this.transactionService.executeTransaction( + databaseOperation, + { + description: `${options.description} - Database Operations`, + timeout: options.timeout + } + ); + + if (!dbTransactionResult.success) { + throw new Error(dbTransactionResult.error || 'Database transaction failed'); + } + + databaseResult = dbTransactionResult.data!; + + // Execute external operations + this.logger.debug(`Executing external operations [${transactionId}]`); + externalResult = await this.executeDistributedTransaction(externalSteps, { + ...distributedOptions, + description: distributedOptions.description || 'External operations' + }); + + if (!externalResult.success && rollbackDatabaseOnExternalFailure) { + // Note: Database transaction already committed, so we can't rollback automatically + // This is a limitation of this approach - consider using saga pattern for true rollback + this.logger.error(`External operations failed but database already committed [${transactionId}]`, { + externalError: externalResult.error + }); + } + + } else { + // Execute external operations first + this.logger.debug(`Executing external operations [${transactionId}]`); + externalResult = await this.executeDistributedTransaction(externalSteps, { + ...distributedOptions, + description: distributedOptions.description || 'External operations' + }); + + if (!externalResult.success) { + throw new Error(externalResult.error || 'External operations failed'); + } + + // Execute database operations + this.logger.debug(`Executing database operations [${transactionId}]`); + const dbTransactionResult = await this.transactionService.executeTransaction( + databaseOperation, + { + description: `${options.description} - Database Operations`, + timeout: options.timeout + } + ); + + if (!dbTransactionResult.success) { + // Rollback external operations + await this.executeRollbacks( + externalSteps, + Object.keys(externalResult.stepResults), + externalResult.stepResults, + transactionId + ); + throw new Error(dbTransactionResult.error || 'Database transaction failed'); + } + + databaseResult = dbTransactionResult.data!; + } + + const duration = Date.now() - startTime; + + this.logger.log(`Hybrid transaction completed successfully [${transactionId}]`, { + description: options.description, + duration + }); + + return { + success: true, + data: databaseResult, + duration, + stepsExecuted: externalResult?.stepsExecuted || 0, + stepsRolledBack: 0, + stepResults: externalResult?.stepResults || {}, + failedSteps: externalResult?.failedSteps || [] + }; + + } catch (error) { + const duration = Date.now() - startTime; + + this.logger.error(`Hybrid transaction failed [${transactionId}]`, { + description: options.description, + error: getErrorMessage(error), + duration + }); + + return { + success: false, + error: getErrorMessage(error), + duration, + stepsExecuted: 0, + stepsRolledBack: 0, + stepResults: {}, + failedSteps: [] + }; + } + } + + private async executeStepWithTimeout(step: DistributedStep, timeout: number): Promise { + return Promise.race([ + step.execute(), + new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(`Step ${step.id} timed out after ${timeout}ms`)); + }, timeout); + }) + ]); + } + + private async executeRollbacks( + steps: DistributedStep[], + executedSteps: string[], + stepResults: Record, + transactionId: string + ): Promise { + this.logger.warn(`Executing rollbacks for ${executedSteps.length} steps [${transactionId}]`); + + let rollbacksExecuted = 0; + + // Execute rollbacks in reverse order (LIFO) + for (let i = executedSteps.length - 1; i >= 0; i--) { + const stepId = executedSteps[i]; + const step = steps.find(s => s.id === stepId); + + if (step?.rollback) { + try { + this.logger.debug(`Executing rollback for step: ${stepId} [${transactionId}]`); + await step.rollback(); + rollbacksExecuted++; + this.logger.debug(`Rollback completed for step: ${stepId} [${transactionId}]`); + } catch (rollbackError) { + this.logger.error(`Rollback failed for step: ${stepId} [${transactionId}]`, { + error: getErrorMessage(rollbackError) + }); + // Continue with other rollbacks even if one fails + } + } + } + + this.logger.log(`Completed ${rollbacksExecuted} rollbacks [${transactionId}]`); + return rollbacksExecuted; + } + + private generateTransactionId(): string { + return `dtx_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + } +} diff --git a/apps/bff/src/core/database/services/transaction.service.ts b/apps/bff/src/core/database/services/transaction.service.ts new file mode 100644 index 00000000..5393b424 --- /dev/null +++ b/apps/bff/src/core/database/services/transaction.service.ts @@ -0,0 +1,332 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { PrismaService } from "@bff/infra/database/prisma.service"; +import { getErrorMessage } from "@bff/core/utils/error.util"; + +export interface TransactionContext { + id: string; + startTime: Date; + operations: string[]; + rollbackActions: (() => Promise)[]; +} + +export interface TransactionOptions { + /** + * Maximum time to wait for transaction to complete (ms) + * Default: 30 seconds + */ + timeout?: number; + + /** + * Maximum number of retry attempts on serialization failures + * Default: 3 + */ + maxRetries?: number; + + /** + * Custom isolation level for the transaction + * Default: ReadCommitted + */ + isolationLevel?: 'ReadUncommitted' | 'ReadCommitted' | 'RepeatableRead' | 'Serializable'; + + /** + * Description of the transaction for logging + */ + description?: string; + + /** + * Whether to automatically rollback external operations on database rollback + * Default: true + */ + autoRollback?: boolean; +} + +export interface TransactionResult { + success: boolean; + data?: T; + error?: string; + duration: number; + operationsCount: number; + rollbacksExecuted: number; +} + +/** + * Service for managing database transactions with external operation coordination + * Provides atomic operations across database and external systems + */ +@Injectable() +export class TransactionService { + private readonly defaultTimeout = 30000; // 30 seconds + private readonly defaultMaxRetries = 3; + + constructor( + private readonly prisma: PrismaService, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Execute operations within a database transaction with rollback support + * + * @example + * ```typescript + * const result = await this.transactionService.executeTransaction( + * async (tx, context) => { + * // Database operations + * const user = await tx.user.create({ data: userData }); + * + * // External operations with rollback + * const whmcsClient = await this.whmcsService.createClient(user.email); + * context.addRollback(async () => { + * await this.whmcsService.deleteClient(whmcsClient.id); + * }); + * + * // Salesforce operations with rollback + * const sfAccount = await this.salesforceService.createAccount(user); + * context.addRollback(async () => { + * await this.salesforceService.deleteAccount(sfAccount.Id); + * }); + * + * return { user, whmcsClient, sfAccount }; + * }, + * { + * description: "User signup with external integrations", + * timeout: 60000 + * } + * ); + * ``` + */ + async executeTransaction( + operation: (tx: any, context: TransactionContext) => Promise, + options: TransactionOptions = {} + ): Promise> { + const { + timeout = this.defaultTimeout, + maxRetries = this.defaultMaxRetries, + isolationLevel = 'ReadCommitted', + description = 'Database transaction', + autoRollback = true + } = options; + + const transactionId = this.generateTransactionId(); + const startTime = new Date(); + + let context: TransactionContext = { + id: transactionId, + startTime, + operations: [], + rollbackActions: [] + }; + + this.logger.log(`Starting transaction [${transactionId}]`, { + description, + timeout, + isolationLevel, + maxRetries + }); + + let attempt = 0; + let lastError: Error | null = null; + + while (attempt < maxRetries) { + attempt++; + + try { + // Reset context for retry attempts + if (attempt > 1) { + context = { + id: transactionId, + startTime, + operations: [], + rollbackActions: [] + }; + } + + const result = await Promise.race([ + this.executeTransactionAttempt(operation, context, isolationLevel), + this.createTimeoutPromise(timeout, transactionId) + ]); + + const duration = Date.now() - startTime.getTime(); + + this.logger.log(`Transaction completed successfully [${transactionId}]`, { + description, + duration, + attempt, + operationsCount: context.operations.length + }); + + return { + success: true, + data: result, + duration, + operationsCount: context.operations.length, + rollbacksExecuted: 0 + }; + + } catch (error) { + lastError = error as Error; + const duration = Date.now() - startTime.getTime(); + + this.logger.error(`Transaction attempt ${attempt} failed [${transactionId}]`, { + description, + error: getErrorMessage(error), + duration, + operationsCount: context.operations.length, + rollbackActionsCount: context.rollbackActions.length + }); + + // Execute rollbacks if this is the final attempt or not a retryable error + if (attempt === maxRetries || !this.isRetryableError(error)) { + const rollbacksExecuted = await this.executeRollbacks(context, autoRollback); + + return { + success: false, + error: getErrorMessage(error), + duration, + operationsCount: context.operations.length, + rollbacksExecuted + }; + } + + // Wait before retry (exponential backoff) + await this.delay(Math.pow(2, attempt - 1) * 1000); + } + } + + // This should never be reached, but just in case + const duration = Date.now() - startTime.getTime(); + return { + success: false, + error: lastError ? getErrorMessage(lastError) : 'Unknown transaction error', + duration, + operationsCount: context.operations.length, + rollbacksExecuted: 0 + }; + } + + /** + * Execute a simple database-only transaction (no external operations) + */ + async executeSimpleTransaction( + operation: (tx: any) => Promise, + options: Omit = {} + ): Promise { + const result = await this.executeTransaction( + async (tx, _context) => operation(tx), + { ...options, autoRollback: false } + ); + + if (!result.success) { + throw new Error(result.error || 'Transaction failed'); + } + + return result.data!; + } + + private async executeTransactionAttempt( + operation: (tx: any, context: TransactionContext) => Promise, + context: TransactionContext, + isolationLevel: string + ): Promise { + return await this.prisma.$transaction( + async (tx) => { + // Enhance context with helper methods + const enhancedContext = this.enhanceContext(context); + + // Execute the operation + return await operation(tx, enhancedContext); + }, + { + isolationLevel: isolationLevel as any, + timeout: 30000 // Prisma transaction timeout + } + ); + } + + private enhanceContext(context: TransactionContext): TransactionContext { + return { + ...context, + addOperation: (description: string) => { + context.operations.push(`${new Date().toISOString()}: ${description}`); + }, + addRollback: (rollbackFn: () => Promise) => { + context.rollbackActions.push(rollbackFn); + } + } as TransactionContext & { + addOperation: (description: string) => void; + addRollback: (rollbackFn: () => Promise) => void; + }; + } + + private async executeRollbacks( + context: TransactionContext, + autoRollback: boolean + ): Promise { + if (!autoRollback || context.rollbackActions.length === 0) { + return 0; + } + + this.logger.warn(`Executing ${context.rollbackActions.length} rollback actions [${context.id}]`); + + let rollbacksExecuted = 0; + + // Execute rollbacks in reverse order (LIFO) + for (let i = context.rollbackActions.length - 1; i >= 0; i--) { + try { + await context.rollbackActions[i](); + rollbacksExecuted++; + this.logger.debug(`Rollback ${i + 1} completed [${context.id}]`); + } catch (rollbackError) { + this.logger.error(`Rollback ${i + 1} failed [${context.id}]`, { + error: getErrorMessage(rollbackError) + }); + // Continue with other rollbacks even if one fails + } + } + + this.logger.log(`Completed ${rollbacksExecuted}/${context.rollbackActions.length} rollbacks [${context.id}]`); + return rollbacksExecuted; + } + + private isRetryableError(error: unknown): boolean { + const errorMessage = getErrorMessage(error).toLowerCase(); + + // Retry on serialization failures, deadlocks, and temporary connection issues + return ( + errorMessage.includes('serialization failure') || + errorMessage.includes('deadlock') || + errorMessage.includes('connection') || + errorMessage.includes('timeout') || + errorMessage.includes('lock wait timeout') + ); + } + + private async createTimeoutPromise(timeout: number, transactionId: string): Promise { + return new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(`Transaction timeout after ${timeout}ms [${transactionId}]`)); + }, timeout); + }); + } + + private async delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + private generateTransactionId(): string { + return `tx_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + } + + /** + * Get transaction statistics for monitoring + */ + async getTransactionStats() { + // This could be enhanced with metrics collection + return { + activeTransactions: 0, // Would need to track active transactions + totalTransactions: 0, // Would need to track total count + successRate: 0, // Would need to track success/failure rates + averageDuration: 0 // Would need to track durations + }; + } +} diff --git a/apps/bff/src/core/health/queue-health.controller.ts b/apps/bff/src/core/health/queue-health.controller.ts new file mode 100644 index 00000000..1881b3ab --- /dev/null +++ b/apps/bff/src/core/health/queue-health.controller.ts @@ -0,0 +1,72 @@ +import { Controller, Get } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger"; +import { WhmcsRequestQueueService } from "@bff/core/queue/services/whmcs-request-queue.service"; +import { SalesforceRequestQueueService } from "@bff/core/queue/services/salesforce-request-queue.service"; + +@ApiTags("Health") +@Controller("health/queues") +export class QueueHealthController { + constructor( + private readonly whmcsQueue: WhmcsRequestQueueService, + private readonly salesforceQueue: SalesforceRequestQueueService + ) {} + + @Get() + @ApiOperation({ + summary: "Get queue health status", + description: "Returns health status and metrics for WHMCS and Salesforce request queues" + }) + @ApiResponse({ + status: 200, + description: "Queue health status retrieved successfully" + }) + getQueueHealth() { + return { + timestamp: new Date().toISOString(), + whmcs: { + health: this.whmcsQueue.getHealthStatus(), + metrics: this.whmcsQueue.getMetrics(), + }, + salesforce: { + health: this.salesforceQueue.getHealthStatus(), + metrics: this.salesforceQueue.getMetrics(), + dailyUsage: this.salesforceQueue.getDailyUsage(), + }, + }; + } + + @Get("whmcs") + @ApiOperation({ + summary: "Get WHMCS queue metrics", + description: "Returns detailed metrics for the WHMCS request queue" + }) + @ApiResponse({ + status: 200, + description: "WHMCS queue metrics retrieved successfully" + }) + getWhmcsQueueMetrics() { + return { + timestamp: new Date().toISOString(), + health: this.whmcsQueue.getHealthStatus(), + metrics: this.whmcsQueue.getMetrics(), + }; + } + + @Get("salesforce") + @ApiOperation({ + summary: "Get Salesforce queue metrics", + description: "Returns detailed metrics for the Salesforce request queue including daily API usage" + }) + @ApiResponse({ + status: 200, + description: "Salesforce queue metrics retrieved successfully" + }) + getSalesforceQueueMetrics() { + return { + timestamp: new Date().toISOString(), + health: this.salesforceQueue.getHealthStatus(), + metrics: this.salesforceQueue.getMetrics(), + dailyUsage: this.salesforceQueue.getDailyUsage(), + }; + } +} diff --git a/apps/bff/src/core/http/http-exception.filter.ts b/apps/bff/src/core/http/http-exception.filter.ts index e2719900..3b71ee50 100644 --- a/apps/bff/src/core/http/http-exception.filter.ts +++ b/apps/bff/src/core/http/http-exception.filter.ts @@ -10,12 +10,14 @@ import { Request, Response } from "express"; import { getClientSafeErrorMessage } from "../utils/error.util"; import { Logger } from "nestjs-pino"; import { ConfigService } from "@nestjs/config"; +import { SecureErrorMapperService } from "../security/services/secure-error-mapper.service"; @Catch() export class GlobalExceptionFilter implements ExceptionFilter { constructor( @Inject(Logger) private readonly logger: Logger, - private readonly configService: ConfigService + private readonly configService: ConfigService, + private readonly secureErrorMapper: SecureErrorMapperService ) {} catch(exception: unknown, host: ArgumentsHost): void { @@ -23,67 +25,76 @@ export class GlobalExceptionFilter implements ExceptionFilter { const response = ctx.getResponse(); const request = ctx.getRequest(); - let status: number; - let message: string; - let error: string; + // Create error context for secure mapping + const errorContext = { + userId: (request as any).user?.id, + requestId: (request as any).requestId || this.generateRequestId(), + userAgent: request.get("user-agent"), + ip: request.ip, + url: request.url, + method: request.method, + }; + let status: number; + let originalError: unknown = exception; + + // Determine HTTP status if (exception instanceof HttpException) { status = exception.getStatus(); + + // Extract the actual error from HttpException response const exceptionResponse = exception.getResponse(); - if (typeof exceptionResponse === "object" && exceptionResponse !== null) { const errorResponse = exceptionResponse as { message?: string; error?: string }; - message = errorResponse.message || exception.message; - error = errorResponse.error || exception.constructor.name; + originalError = errorResponse.message || exception.message; } else { - message = typeof exceptionResponse === "string" ? exceptionResponse : exception.message; - error = exception.constructor.name; + originalError = typeof exceptionResponse === "string" ? exceptionResponse : exception.message; } } else { status = HttpStatus.INTERNAL_SERVER_ERROR; - message = "Internal server error"; - error = "InternalServerError"; - - this.logger.error("Unhandled exception caught", { - error: exception instanceof Error ? exception.message : String(exception), - stack: exception instanceof Error ? exception.stack : undefined, - url: request.url, - method: request.method, - userAgent: request.get("user-agent"), - ip: request.ip, - }); + originalError = exception; } - const clientSafeMessage = - this.configService.get("NODE_ENV") === "production" - ? getClientSafeErrorMessage(message) - : message; - - const code = (error || "InternalServerError") - .replace(/([a-z])([A-Z])/g, "$1_$2") - .replace(/\s+/g, "_") - .toUpperCase(); + // Use secure error mapper to get safe public message and log securely + const errorClassification = this.secureErrorMapper.mapError(originalError, errorContext); + const publicMessage = this.secureErrorMapper.getPublicMessage(originalError, errorContext); + + // Log the error securely (this handles sensitive data filtering) + this.secureErrorMapper.logSecureError(originalError, errorContext, { + httpStatus: status, + exceptionType: exception instanceof Error ? exception.constructor.name : 'Unknown' + }); + // Create secure error response const errorResponse = { success: false, statusCode: status, - code, - error, - message: clientSafeMessage, + code: errorClassification.mapping.code, + error: errorClassification.category.toUpperCase(), + message: publicMessage, timestamp: new Date().toISOString(), path: request.url, + requestId: errorContext.requestId, }; - this.logger.error(`HTTP ${status} Error`, { + // Additional logging for monitoring (without sensitive data) + this.logger.error(`HTTP ${status} Error [${errorClassification.mapping.code}]`, { statusCode: status, method: request.method, url: request.url, userAgent: request.get("user-agent"), ip: request.ip, - error: error, - messageLength: message.length, + errorCode: errorClassification.mapping.code, + category: errorClassification.category, + severity: errorClassification.severity, + requestId: errorContext.requestId, + userId: errorContext.userId, }); response.status(status).json(errorResponse); } + + private generateRequestId(): string { + return `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + } } diff --git a/apps/bff/src/core/http/success-response.interceptor.ts b/apps/bff/src/core/http/success-response.interceptor.ts deleted file mode 100644 index cb0ff5c3..00000000 --- a/apps/bff/src/core/http/success-response.interceptor.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/apps/bff/src/core/queue/queue.module.ts b/apps/bff/src/core/queue/queue.module.ts new file mode 100644 index 00000000..73aaedd6 --- /dev/null +++ b/apps/bff/src/core/queue/queue.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; +import { WhmcsRequestQueueService } from "./services/whmcs-request-queue.service"; +import { SalesforceRequestQueueService } from "./services/salesforce-request-queue.service"; + +@Module({ + providers: [WhmcsRequestQueueService, SalesforceRequestQueueService], + exports: [WhmcsRequestQueueService, SalesforceRequestQueueService], +}) +export class QueueModule {} diff --git a/apps/bff/src/core/queue/services/salesforce-request-queue.service.ts b/apps/bff/src/core/queue/services/salesforce-request-queue.service.ts new file mode 100644 index 00000000..1c384044 --- /dev/null +++ b/apps/bff/src/core/queue/services/salesforce-request-queue.service.ts @@ -0,0 +1,469 @@ +import { Injectable, Inject, OnModuleInit, OnModuleDestroy } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; + +export interface SalesforceQueueMetrics { + totalRequests: number; + completedRequests: number; + failedRequests: number; + queueSize: number; + pendingRequests: number; + averageWaitTime: number; + averageExecutionTime: number; + dailyApiUsage: number; + lastRequestTime?: Date; + lastErrorTime?: Date; + lastRateLimitTime?: Date; +} + +export interface SalesforceRequestOptions { + 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 + isLongRunning?: boolean; // Mark as long-running request (>20s expected) +} + +/** + * Salesforce Request Queue Service + * + * Manages concurrent requests to Salesforce API to prevent: + * - Daily API limit exhaustion (100,000 + 1,000 per user) + * - Concurrent request limit violations (25 long-running requests) + * - Rate limit violations and 429 errors + * - Optimal resource utilization + * + * Based on Salesforce documentation: + * - Daily limit: 100,000 + (1,000 Ɨ users) per 24h + * - Concurrent limit: 25 long-running requests (>20s) + * - Timeout: 10 minutes per request + * - Rate limiting: Conservative 120 requests per minute (2 RPS) + */ +@Injectable() +export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDestroy { + private standardQueue: any; + private longRunningQueue: any; + private readonly metrics: SalesforceQueueMetrics = { + totalRequests: 0, + completedRequests: 0, + failedRequests: 0, + queueSize: 0, + pendingRequests: 0, + averageWaitTime: 0, + averageExecutionTime: 0, + dailyApiUsage: 0, + }; + + private readonly waitTimes: number[] = []; + private readonly executionTimes: number[] = []; + private readonly maxMetricsHistory = 100; + private dailyUsageResetTime: Date; + + constructor(@Inject(Logger) private readonly logger: Logger) { + this.dailyUsageResetTime = this.getNextDayReset(); + } + + private async initializeQueues() { + if (!this.standardQueue) { + const { default: PQueue } = await import("p-queue"); + + // Optimized Salesforce requests queue for better user experience + this.standardQueue = new PQueue({ + concurrency: 15, // Max 15 concurrent standard requests (increased from 10) + interval: 60000, // Per minute + intervalCap: 600, // Max 600 requests per minute (10 RPS - increased from 2 RPS) + timeout: 30000, // 30 second default timeout + throwOnTimeout: true, + carryoverConcurrencyCount: true, + }); + + // Long-running requests queue (separate to respect 25 concurrent limit) + this.longRunningQueue = new PQueue({ + concurrency: 22, // Max 22 concurrent long-running (closer to 25 limit) + timeout: 600000, // 10 minute timeout for long-running + throwOnTimeout: true, + carryoverConcurrencyCount: true, + }); + + // Set up queue event listeners + this.setupQueueListeners(); + } + } + + async onModuleInit() { + await this.initializeQueues(); + this.logger.log("Salesforce Request Queue initialized", { + standardConcurrency: 15, + longRunningConcurrency: 22, + rateLimit: "600 requests/minute (10 RPS)", + standardTimeout: "30 seconds", + longRunningTimeout: "10 minutes", + }); + } + + async onModuleDestroy() { + this.logger.log("Shutting down Salesforce Request Queue", { + standardPending: this.standardQueue.pending, + standardQueueSize: this.standardQueue.size, + longRunningPending: this.longRunningQueue.pending, + longRunningQueueSize: this.longRunningQueue.size, + }); + + // Wait for pending requests to complete (with timeout) + try { + await Promise.all([ + this.standardQueue.onIdle(), + this.longRunningQueue.onIdle(), + ]); + } catch (error) { + this.logger.warn("Some Salesforce requests may not have completed during shutdown", { + error: error instanceof Error ? error.message : String(error), + }); + } + } + + /** + * Execute a Salesforce API request through the appropriate queue + */ + async execute( + requestFn: () => Promise, + options: SalesforceRequestOptions = {} + ): Promise { + await this.initializeQueues(); + // Check daily API usage + this.checkDailyUsage(); + + const startTime = Date.now(); + const requestId = this.generateRequestId(); + const isLongRunning = options.isLongRunning || false; + const queue = isLongRunning ? this.longRunningQueue : this.standardQueue; + + this.metrics.totalRequests++; + this.metrics.dailyApiUsage++; + this.updateQueueMetrics(); + + this.logger.debug("Queueing Salesforce request", { + requestId, + isLongRunning, + queueSize: queue.size, + pending: queue.pending, + priority: options.priority || 0, + dailyUsage: this.metrics.dailyApiUsage, + }); + + try { + const result = await queue.add( + async () => { + const waitTime = Date.now() - startTime; + this.recordWaitTime(waitTime); + + const executionStart = Date.now(); + + try { + const response = await this.executeWithRetry(requestFn, options); + + const executionTime = Date.now() - executionStart; + this.recordExecutionTime(executionTime); + this.metrics.completedRequests++; + this.metrics.lastRequestTime = new Date(); + + this.logger.debug("Salesforce request completed", { + requestId, + isLongRunning, + waitTime, + executionTime, + totalTime: Date.now() - startTime, + }); + + return response; + } catch (error) { + const executionTime = Date.now() - executionStart; + this.recordExecutionTime(executionTime); + this.metrics.failedRequests++; + this.metrics.lastErrorTime = new Date(); + + // Check if it's a rate limit error + if (this.isRateLimitError(error)) { + this.metrics.lastRateLimitTime = new Date(); + this.logger.warn("Salesforce rate limit encountered", { + requestId, + dailyUsage: this.metrics.dailyApiUsage, + }); + } + + this.logger.error("Salesforce request failed", { + requestId, + isLongRunning, + waitTime, + executionTime, + error: error instanceof Error ? error.message : String(error), + }); + + throw error; + } + }, + { + priority: options.priority || 0, + } + ); + + return result; + } catch (error) { + this.metrics.failedRequests++; + this.metrics.lastErrorTime = new Date(); + throw error; + } finally { + this.updateQueueMetrics(); + } + } + + /** + * Execute high-priority Salesforce request (jumps queue) + */ + async executeHighPriority( + requestFn: () => Promise, + isLongRunning = false + ): Promise { + return this.execute(requestFn, { priority: 10, isLongRunning }); + } + + /** + * Execute long-running Salesforce request (uses separate queue) + */ + async executeLongRunning(requestFn: () => Promise): Promise { + return this.execute(requestFn, { isLongRunning: true, timeout: 600000 }); + } + + /** + * Get current queue metrics + */ + getMetrics(): SalesforceQueueMetrics { + this.updateQueueMetrics(); + return { ...this.metrics }; + } + + /** + * Get queue health status + */ + getHealthStatus(): { + status: "healthy" | "degraded" | "unhealthy"; + queueSize: number; + pendingRequests: number; + errorRate: number; + dailyUsagePercent: number; + averageWaitTime: number; + } { + this.updateQueueMetrics(); + + const errorRate = this.metrics.totalRequests > 0 + ? this.metrics.failedRequests / this.metrics.totalRequests + : 0; + + // Estimate daily limit (conservative: 150,000 for ~50 users) + const estimatedDailyLimit = 150000; + const dailyUsagePercent = this.metrics.dailyApiUsage / estimatedDailyLimit; + + let status: "healthy" | "degraded" | "unhealthy" = "healthy"; + + // Adjusted thresholds for higher throughput (15 concurrent, 10 RPS) + if ( + this.metrics.queueSize > 200 || + errorRate > 0.1 || + dailyUsagePercent > 0.9 + ) { + status = "unhealthy"; + } else if ( + this.metrics.queueSize > 80 || + errorRate > 0.05 || + dailyUsagePercent > 0.7 + ) { + status = "degraded"; + } + + return { + status, + queueSize: this.metrics.queueSize, + pendingRequests: this.metrics.pendingRequests, + errorRate, + dailyUsagePercent, + averageWaitTime: this.metrics.averageWaitTime, + }; + } + + /** + * Get daily API usage information + */ + getDailyUsage(): { + usage: number; + resetTime: Date; + hoursUntilReset: number; + } { + return { + usage: this.metrics.dailyApiUsage, + resetTime: this.dailyUsageResetTime, + hoursUntilReset: Math.ceil( + (this.dailyUsageResetTime.getTime() - Date.now()) / (1000 * 60 * 60) + ), + }; + } + + /** + * Clear the queues (emergency use only) + */ + async clearQueues(): Promise { + this.logger.warn("Clearing Salesforce request queues", { + standardQueueSize: this.standardQueue.size, + standardPending: this.standardQueue.pending, + longRunningQueueSize: this.longRunningQueue.size, + longRunningPending: this.longRunningQueue.pending, + }); + + this.standardQueue.clear(); + this.longRunningQueue.clear(); + + await Promise.all([ + this.standardQueue.onIdle(), + this.longRunningQueue.onIdle(), + ]); + } + + private async executeWithRetry( + requestFn: () => Promise, + options: SalesforceRequestOptions + ): Promise { + const maxAttempts = options.retryAttempts || 3; + const baseDelay = options.retryDelay || 1000; + + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await requestFn(); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + if (attempt === maxAttempts) { + break; + } + + // Special handling for rate limit errors + let delay = baseDelay * Math.pow(2, attempt - 1); + + if (this.isRateLimitError(error)) { + // Longer delay for rate limit errors + delay = Math.max(delay, 30000); // At least 30 seconds + } + + // Add jitter + delay += Math.random() * 1000; + + this.logger.debug("Salesforce request failed, retrying", { + attempt, + maxAttempts, + delay, + isRateLimit: this.isRateLimitError(error), + error: lastError.message, + }); + + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + + throw lastError; + } + + private isRateLimitError(error: unknown): boolean { + if (error instanceof Error) { + const message = error.message.toLowerCase(); + return ( + message.includes("rate limit") || + message.includes("too many requests") || + message.includes("429") || + message.includes("request limit exceeded") + ); + } + return false; + } + + private checkDailyUsage(): void { + const now = new Date(); + + // Reset daily usage if we've passed the reset time + if (now >= this.dailyUsageResetTime) { + this.metrics.dailyApiUsage = 0; + this.dailyUsageResetTime = this.getNextDayReset(); + + this.logger.log("Daily Salesforce API usage reset", { + resetTime: this.dailyUsageResetTime, + }); + } + } + + private getNextDayReset(): Date { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(0, 0, 0, 0); + return tomorrow; + } + + private setupQueueListeners(): void { + // Standard queue listeners + this.standardQueue.on("add", () => this.updateQueueMetrics()); + this.standardQueue.on("next", () => this.updateQueueMetrics()); + this.standardQueue.on("idle", () => { + this.logger.debug("Salesforce standard queue is idle"); + this.updateQueueMetrics(); + }); + this.standardQueue.on("error", (error: Error) => { + this.logger.error("Salesforce standard queue error", { + error: error instanceof Error ? error.message : String(error), + }); + }); + + // Long-running queue listeners + this.longRunningQueue.on("add", () => this.updateQueueMetrics()); + this.longRunningQueue.on("next", () => this.updateQueueMetrics()); + this.longRunningQueue.on("idle", () => { + this.logger.debug("Salesforce long-running queue is idle"); + this.updateQueueMetrics(); + }); + this.longRunningQueue.on("error", (error: Error) => { + this.logger.error("Salesforce long-running queue error", { + error: error instanceof Error ? error.message : String(error), + }); + }); + } + + private updateQueueMetrics(): void { + this.metrics.queueSize = this.standardQueue.size + this.longRunningQueue.size; + this.metrics.pendingRequests = this.standardQueue.pending + this.longRunningQueue.pending; + + // Calculate averages + if (this.waitTimes.length > 0) { + this.metrics.averageWaitTime = + this.waitTimes.reduce((sum, time) => sum + time, 0) / this.waitTimes.length; + } + + if (this.executionTimes.length > 0) { + this.metrics.averageExecutionTime = + this.executionTimes.reduce((sum, time) => sum + time, 0) / this.executionTimes.length; + } + } + + private recordWaitTime(waitTime: number): void { + this.waitTimes.push(waitTime); + if (this.waitTimes.length > this.maxMetricsHistory) { + this.waitTimes.shift(); + } + } + + private recordExecutionTime(executionTime: number): void { + this.executionTimes.push(executionTime); + if (this.executionTimes.length > this.maxMetricsHistory) { + this.executionTimes.shift(); + } + } + + private generateRequestId(): string { + return `sf_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + } +} diff --git a/apps/bff/src/core/queue/services/whmcs-request-queue.service.ts b/apps/bff/src/core/queue/services/whmcs-request-queue.service.ts new file mode 100644 index 00000000..31e8eef7 --- /dev/null +++ b/apps/bff/src/core/queue/services/whmcs-request-queue.service.ts @@ -0,0 +1,328 @@ +import { Injectable, Inject, OnModuleInit, OnModuleDestroy } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; + +export interface WhmcsQueueMetrics { + totalRequests: number; + completedRequests: number; + failedRequests: number; + queueSize: number; + pendingRequests: number; + averageWaitTime: number; + averageExecutionTime: number; + lastRequestTime?: Date; + lastErrorTime?: Date; +} + +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 +} + +/** + * WHMCS Request Queue Service + * + * Manages concurrent requests to WHMCS API to prevent: + * - Database connection pool exhaustion + * - Server overload from parallel requests + * - Rate limit violations (conservative approach) + * - Resource contention issues + * + * Based on research: + * - WHMCS has no official rate limits but performance degrades with high concurrency + * - Conservative approach: max 3 concurrent requests + * - Rate limiting: max 30 requests per minute (0.5 RPS) + */ +@Injectable() +export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { + private queue: any; + private readonly metrics: WhmcsQueueMetrics = { + totalRequests: 0, + completedRequests: 0, + failedRequests: 0, + queueSize: 0, + pendingRequests: 0, + averageWaitTime: 0, + averageExecutionTime: 0, + }; + + private readonly waitTimes: number[] = []; + private readonly executionTimes: number[] = []; + private readonly maxMetricsHistory = 100; + + constructor(@Inject(Logger) private readonly logger: Logger) {} + + private async initializeQueue() { + if (!this.queue) { + const { default: PQueue } = await import("p-queue"); + + // Optimized WHMCS queue configuration for better user experience + this.queue = new PQueue({ + concurrency: 15, // Max 15 concurrent WHMCS requests (matches Salesforce) + interval: 60000, // Per minute + intervalCap: 300, // Max 300 requests per minute (5 RPS - increased from 0.5 RPS) + timeout: 30000, // 30 second default timeout + throwOnTimeout: true, + carryoverConcurrencyCount: true, + }); + + // Set up queue event listeners + this.setupQueueListeners(); + } + } + + async onModuleInit() { + await this.initializeQueue(); + this.logger.log("WHMCS Request Queue initialized", { + concurrency: 15, + rateLimit: "300 requests/minute (5 RPS)", + timeout: "30 seconds", + }); + } + + async onModuleDestroy() { + this.logger.log("Shutting down WHMCS Request Queue", { + pendingRequests: this.queue.pending, + queueSize: this.queue.size, + }); + + // Wait for pending requests to complete (with timeout) + try { + await this.queue.onIdle(); + } catch (error) { + this.logger.warn("Some WHMCS requests may not have completed during shutdown", { + error: error instanceof Error ? error.message : String(error), + }); + } + } + + /** + * Execute a WHMCS API request through the queue + */ + async execute( + requestFn: () => Promise, + options: WhmcsRequestOptions = {} + ): Promise { + await this.initializeQueue(); + const startTime = Date.now(); + const requestId = this.generateRequestId(); + + this.metrics.totalRequests++; + this.updateQueueMetrics(); + + this.logger.debug("Queueing WHMCS request", { + requestId, + queueSize: this.queue.size, + pending: this.queue.pending, + priority: options.priority || 0, + }); + + try { + const result = await this.queue.add( + async () => { + const waitTime = Date.now() - startTime; + this.recordWaitTime(waitTime); + + const executionStart = Date.now(); + + try { + const response = await this.executeWithRetry(requestFn, options); + + const executionTime = Date.now() - executionStart; + this.recordExecutionTime(executionTime); + this.metrics.completedRequests++; + this.metrics.lastRequestTime = new Date(); + + this.logger.debug("WHMCS request completed", { + requestId, + waitTime, + executionTime, + totalTime: Date.now() - startTime, + }); + + return response; + } catch (error) { + const executionTime = Date.now() - executionStart; + this.recordExecutionTime(executionTime); + this.metrics.failedRequests++; + this.metrics.lastErrorTime = new Date(); + + this.logger.error("WHMCS request failed", { + requestId, + waitTime, + executionTime, + error: error instanceof Error ? error.message : String(error), + }); + + throw error; + } + }, + { + priority: options.priority || 0, + } + ); + + return result; + } catch (error) { + this.metrics.failedRequests++; + this.metrics.lastErrorTime = new Date(); + throw error; + } finally { + this.updateQueueMetrics(); + } + } + + /** + * Execute high-priority WHMCS request (jumps queue) + */ + async executeHighPriority(requestFn: () => Promise): Promise { + return this.execute(requestFn, { priority: 10 }); + } + + /** + * Get current queue metrics + */ + getMetrics(): WhmcsQueueMetrics { + this.updateQueueMetrics(); + return { ...this.metrics }; + } + + /** + * Get queue health status + */ + getHealthStatus(): { + status: "healthy" | "degraded" | "unhealthy"; + queueSize: number; + pendingRequests: number; + errorRate: number; + averageWaitTime: number; + } { + this.updateQueueMetrics(); + + const errorRate = this.metrics.totalRequests > 0 + ? this.metrics.failedRequests / this.metrics.totalRequests + : 0; + + let status: "healthy" | "degraded" | "unhealthy" = "healthy"; + + // Adjusted thresholds for higher throughput (15 concurrent, 5 RPS) + if (this.metrics.queueSize > 120 || errorRate > 0.1) { + status = "unhealthy"; + } else if (this.metrics.queueSize > 50 || errorRate > 0.05) { + status = "degraded"; + } + + return { + status, + queueSize: this.metrics.queueSize, + pendingRequests: this.metrics.pendingRequests, + errorRate, + averageWaitTime: this.metrics.averageWaitTime, + }; + } + + /** + * Clear the queue (emergency use only) + */ + async clearQueue(): Promise { + this.logger.warn("Clearing WHMCS request queue", { + queueSize: this.queue.size, + pendingRequests: this.queue.pending, + }); + + this.queue.clear(); + await this.queue.onIdle(); + } + + private async executeWithRetry( + requestFn: () => Promise, + options: WhmcsRequestOptions + ): Promise { + const maxAttempts = options.retryAttempts || 3; + const baseDelay = options.retryDelay || 1000; + + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await requestFn(); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + if (attempt === maxAttempts) { + break; + } + + // Exponential backoff with jitter + const delay = baseDelay * Math.pow(2, attempt - 1) + Math.random() * 1000; + + this.logger.debug("WHMCS request failed, retrying", { + attempt, + maxAttempts, + delay, + error: lastError.message, + }); + + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + + throw lastError; + } + + private setupQueueListeners(): void { + this.queue.on("add", () => { + this.updateQueueMetrics(); + }); + + this.queue.on("next", () => { + this.updateQueueMetrics(); + }); + + this.queue.on("idle", () => { + this.logger.debug("WHMCS queue is idle"); + this.updateQueueMetrics(); + }); + + this.queue.on("error", (error: Error) => { + this.logger.error("WHMCS queue error", { + error: error instanceof Error ? error.message : String(error), + }); + }); + } + + private updateQueueMetrics(): void { + this.metrics.queueSize = this.queue.size; + this.metrics.pendingRequests = this.queue.pending; + + // Calculate averages + if (this.waitTimes.length > 0) { + this.metrics.averageWaitTime = + this.waitTimes.reduce((sum, time) => sum + time, 0) / this.waitTimes.length; + } + + if (this.executionTimes.length > 0) { + this.metrics.averageExecutionTime = + this.executionTimes.reduce((sum, time) => sum + time, 0) / this.executionTimes.length; + } + } + + private recordWaitTime(waitTime: number): void { + this.waitTimes.push(waitTime); + if (this.waitTimes.length > this.maxMetricsHistory) { + this.waitTimes.shift(); + } + } + + private recordExecutionTime(executionTime: number): void { + this.executionTimes.push(executionTime); + if (this.executionTimes.length > this.maxMetricsHistory) { + this.executionTimes.shift(); + } + } + + private generateRequestId(): string { + return `whmcs_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + } +} diff --git a/apps/bff/src/core/security/controllers/csrf.controller.ts b/apps/bff/src/core/security/controllers/csrf.controller.ts new file mode 100644 index 00000000..a8e58a64 --- /dev/null +++ b/apps/bff/src/core/security/controllers/csrf.controller.ts @@ -0,0 +1,167 @@ +import { Controller, Get, Post, Req, Res, UseGuards, Inject } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from "@nestjs/swagger"; +import type { Request, Response } from "express"; +import { Logger } from "nestjs-pino"; +import { CsrfService } from "../services/csrf.service"; + +interface AuthenticatedRequest extends Request { + user?: { id: string; sessionId?: string }; +} + +@ApiTags('Security') +@Controller('security/csrf') +export class CsrfController { + constructor( + private readonly csrfService: CsrfService, + @Inject(Logger) private readonly logger: Logger + ) {} + + @Get('token') + @ApiOperation({ + summary: 'Get CSRF token', + description: 'Generates and returns a new CSRF token for the current session' + }) + @ApiResponse({ + status: 200, + description: 'CSRF token generated successfully', + schema: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + token: { type: 'string', example: 'abc123...' }, + expiresAt: { type: 'string', format: 'date-time' } + } + } + }) + getCsrfToken(@Req() req: AuthenticatedRequest, @Res() res: Response) { + const sessionId = req.user?.sessionId || this.extractSessionId(req) || undefined; + const userId = req.user?.id; + + // Generate new CSRF token + const tokenData = this.csrfService.generateToken(sessionId, userId); + + // Set CSRF secret in secure cookie + res.cookie('csrf-secret', tokenData.secret, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: 3600000, // 1 hour + path: '/', + }); + + this.logger.debug("CSRF token requested", { + userId, + sessionId, + userAgent: req.get('user-agent'), + ip: req.ip + }); + + return res.json({ + success: true, + token: tokenData.token, + expiresAt: tokenData.expiresAt.toISOString() + }); + } + + @Post('refresh') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Refresh CSRF token', + description: 'Invalidates current token and generates a new one for authenticated users' + }) + @ApiResponse({ + status: 200, + description: 'CSRF token refreshed successfully', + schema: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + token: { type: 'string', example: 'xyz789...' }, + expiresAt: { type: 'string', format: 'date-time' } + } + } + }) + refreshCsrfToken(@Req() req: AuthenticatedRequest, @Res() res: Response) { + const sessionId = req.user?.sessionId || this.extractSessionId(req) || undefined; + const userId = req.user?.id || 'anonymous'; // Default for unauthenticated users + + // Invalidate existing tokens for this user + this.csrfService.invalidateUserTokens(userId); + + // Generate new CSRF token + const tokenData = this.csrfService.generateToken(sessionId, userId); + + // Set CSRF secret in secure cookie + res.cookie('csrf-secret', tokenData.secret, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: 3600000, // 1 hour + path: '/', + }); + + this.logger.debug("CSRF token refreshed", { + userId, + sessionId, + userAgent: req.get('user-agent'), + ip: req.ip + }); + + return res.json({ + success: true, + token: tokenData.token, + expiresAt: tokenData.expiresAt.toISOString() + }); + } + + @Get('stats') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get CSRF token statistics', + description: 'Returns statistics about CSRF tokens (admin/monitoring endpoint)' + }) + @ApiResponse({ + status: 200, + description: 'CSRF token statistics', + schema: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + stats: { + type: 'object', + properties: { + totalTokens: { type: 'number', example: 150 }, + activeTokens: { type: 'number', example: 120 }, + expiredTokens: { type: 'number', example: 30 }, + cacheSize: { type: 'number', example: 150 }, + maxCacheSize: { type: 'number', example: 10000 } + } + } + } + } + }) + getCsrfStats(@Req() req: AuthenticatedRequest) { + const userId = req.user?.id || 'anonymous'; + + // Only allow admin users to see stats (you might want to add role checking) + this.logger.debug("CSRF stats requested", { + userId, + userAgent: req.get('user-agent'), + ip: req.ip + }); + + const stats = this.csrfService.getTokenStats(); + + return { + success: true, + stats + }; + } + + private extractSessionId(req: AuthenticatedRequest): string | null { + return req.cookies?.['session-id'] || + req.cookies?.['connect.sid'] || + (req as any).sessionID || + null; + } +} diff --git a/apps/bff/src/core/security/middleware/csrf.middleware.ts b/apps/bff/src/core/security/middleware/csrf.middleware.ts new file mode 100644 index 00000000..807f5f1b --- /dev/null +++ b/apps/bff/src/core/security/middleware/csrf.middleware.ts @@ -0,0 +1,221 @@ +import { Injectable, NestMiddleware, ForbiddenException, Inject } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Logger } from "nestjs-pino"; +import type { Request, Response, NextFunction } from "express"; +import { CsrfService } from "../services/csrf.service"; + +interface CsrfRequest extends Request { + csrfToken?: string; + user?: { id: string; sessionId?: string }; + sessionID?: string; +} + +/** + * CSRF Protection Middleware + * Implements double-submit cookie pattern with additional security measures + */ +@Injectable() +export class CsrfMiddleware implements NestMiddleware { + private readonly isProduction: boolean; + private readonly exemptPaths: Set; + private readonly exemptMethods: Set; + + constructor( + private readonly csrfService: CsrfService, + private readonly 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', + '/api/auth/signup', + '/api/auth/refresh', + '/api/health', + '/docs', + '/api/webhooks', // Webhooks typically don't use CSRF + ]); + + // Methods that don't require CSRF protection (safe methods) + this.exemptMethods = new Set(['GET', 'HEAD', 'OPTIONS']); + } + + use(req: CsrfRequest, res: Response, next: NextFunction): void { + // Skip CSRF protection for exempt paths and methods + if (this.isExempt(req)) { + return next(); + } + + // For state-changing requests, validate CSRF token + if (this.requiresCsrfProtection(req)) { + this.validateCsrfToken(req, res, next); + } else { + // For safe requests, generate and set CSRF token if needed + this.ensureCsrfToken(req, res, next); + } + } + + private isExempt(req: CsrfRequest): boolean { + // Check if path is exempt + if (this.exemptPaths.has(req.path)) { + return true; + } + + // Check if method is exempt (safe methods) + if (this.exemptMethods.has(req.method)) { + return true; + } + + // Check for API endpoints that might be exempt + if (req.path.startsWith('/api/webhooks/')) { + return true; + } + + return false; + } + + private requiresCsrfProtection(req: CsrfRequest): boolean { + // State-changing methods require CSRF protection + return ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method); + } + + 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); + const userId = req.user?.id; + + if (!token) { + this.logger.warn("CSRF validation failed - missing token", { + method: req.method, + path: req.path, + userAgent: req.get('user-agent'), + ip: req.ip + }); + throw new ForbiddenException("CSRF token required"); + } + + if (!secret) { + this.logger.warn("CSRF validation failed - missing secret cookie", { + method: req.method, + path: req.path, + userAgent: req.get('user-agent'), + ip: req.ip + }); + throw new ForbiddenException("CSRF secret required"); + } + + const validationResult = this.csrfService.validateToken(token, secret, sessionId || undefined, userId); + + if (!validationResult.isValid) { + this.logger.warn("CSRF validation failed", { + reason: validationResult.reason, + method: req.method, + path: req.path, + userAgent: req.get('user-agent'), + ip: req.ip, + userId, + sessionId + }); + throw new ForbiddenException(`CSRF validation failed: ${validationResult.reason}`); + } + + // Store validated token in request for potential use by controllers + req.csrfToken = token; + + this.logger.debug("CSRF validation successful", { + method: req.method, + path: req.path, + userId, + sessionId + }); + + next(); + } + + private ensureCsrfToken(req: CsrfRequest, res: Response, next: NextFunction): void { + const existingSecret = this.extractSecretFromCookie(req); + const sessionId = req.user?.sessionId || this.extractSessionId(req); + const userId = req.user?.id; + + // If we already have a valid secret, we don't need to generate a new token + if (existingSecret) { + return next(); + } + + // Generate new CSRF token + const tokenData = this.csrfService.generateToken(sessionId || undefined, userId); + + // Set CSRF secret in secure, SameSite cookie + this.setCsrfSecretCookie(res, tokenData.secret); + + // Set CSRF token in response header for client to use + res.setHeader('X-CSRF-Token', tokenData.token); + + this.logger.debug("CSRF token generated and set", { + method: req.method, + path: req.path, + userId, + sessionId + }); + + next(); + } + + private extractTokenFromRequest(req: CsrfRequest): string | null { + // Check multiple possible locations for the CSRF token + + // 1. X-CSRF-Token header (most common) + let token = req.get('X-CSRF-Token'); + if (token) return token; + + // 2. X-Requested-With header (alternative) + token = req.get('X-Requested-With'); + if (token && token !== 'XMLHttpRequest') return token; + + // 3. Authorization header (if using Bearer token pattern) + const authHeader = req.get('Authorization'); + if (authHeader && authHeader.startsWith('CSRF ')) { + return authHeader.substring(5); + } + + // 4. Request body (for form submissions) + if (req.body && typeof req.body === 'object') { + token = req.body._csrf || req.body.csrfToken; + if (token) return token; + } + + // 5. Query parameter (least secure, only for GET requests) + if (req.method === 'GET') { + token = req.query._csrf as string || req.query.csrfToken as string; + if (token) return token; + } + + return null; + } + + private extractSecretFromCookie(req: CsrfRequest): string | null { + return req.cookies?.['csrf-secret'] || null; + } + + private extractSessionId(req: CsrfRequest): string | null { + // Try to extract session ID from various sources + return req.cookies?.['session-id'] || + req.cookies?.['connect.sid'] || + req.sessionID || + null; + } + + private setCsrfSecretCookie(res: Response, secret: string): void { + const cookieOptions = { + httpOnly: true, + secure: this.isProduction, + sameSite: 'strict' as const, + maxAge: 3600000, // 1 hour + path: '/', + }; + + res.cookie('csrf-secret', secret, cookieOptions); + } +} diff --git a/apps/bff/src/core/security/security.module.ts b/apps/bff/src/core/security/security.module.ts new file mode 100644 index 00000000..c0fce801 --- /dev/null +++ b/apps/bff/src/core/security/security.module.ts @@ -0,0 +1,21 @@ +import { Module, MiddlewareConsumer, NestModule } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { SecureErrorMapperService } from "./services/secure-error-mapper.service"; +import { CsrfService } from "./services/csrf.service"; +import { CsrfMiddleware } from "./middleware/csrf.middleware"; +import { CsrfController } from "./controllers/csrf.controller"; + +@Module({ + imports: [ConfigModule], + controllers: [CsrfController], + providers: [SecureErrorMapperService, CsrfService, CsrfMiddleware], + exports: [SecureErrorMapperService, CsrfService], +}) +export class SecurityModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + // Apply CSRF middleware to all routes except those handled by the middleware itself + consumer + .apply(CsrfMiddleware) + .forRoutes('*'); + } +} diff --git a/apps/bff/src/core/security/services/csrf.service.ts b/apps/bff/src/core/security/services/csrf.service.ts new file mode 100644 index 00000000..5621a069 --- /dev/null +++ b/apps/bff/src/core/security/services/csrf.service.ts @@ -0,0 +1,309 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Logger } from "nestjs-pino"; +import * as crypto from "crypto"; + +export interface CsrfTokenData { + token: string; + secret: string; + expiresAt: Date; + sessionId?: string; + userId?: string; +} + +export interface CsrfValidationResult { + isValid: boolean; + reason?: string; + tokenData?: CsrfTokenData; +} + +/** + * Service for CSRF token generation and validation + * Implements double-submit cookie pattern with additional security measures + */ +@Injectable() +export class CsrfService { + private readonly tokenExpiry: number; // Token expiry in milliseconds + private readonly secretKey: string; + private readonly tokenCache = new Map(); + private readonly maxCacheSize = 10000; // Prevent memory leaks + + constructor( + private readonly configService: ConfigService, + @Inject(Logger) private readonly logger: Logger + ) { + this.tokenExpiry = Number(this.configService.get("CSRF_TOKEN_EXPIRY", "3600000")); // 1 hour default + this.secretKey = this.configService.get("CSRF_SECRET_KEY") || this.generateSecretKey(); + + if (!this.configService.get("CSRF_SECRET_KEY")) { + this.logger.warn("CSRF_SECRET_KEY not configured, using generated key (not suitable for production)"); + } + + // Clean up expired tokens periodically + setInterval(() => this.cleanupExpiredTokens(), 300000); // Every 5 minutes + } + + /** + * Generate a new CSRF token for a user session + */ + generateToken(sessionId?: string, userId?: string): CsrfTokenData { + const secret = this.generateSecret(); + const token = this.generateTokenFromSecret(secret, sessionId, userId); + const expiresAt = new Date(Date.now() + this.tokenExpiry); + + const tokenData: CsrfTokenData = { + token, + secret, + expiresAt, + sessionId, + userId + }; + + // Store in cache for validation + this.tokenCache.set(token, tokenData); + + // Prevent memory leaks + if (this.tokenCache.size > this.maxCacheSize) { + this.cleanupExpiredTokens(); + } + + this.logger.debug("CSRF token generated", { + tokenHash: this.hashToken(token), + sessionId, + userId, + expiresAt: expiresAt.toISOString() + }); + + return tokenData; + } + + /** + * Validate a CSRF token against the provided secret + */ + validateToken( + token: string, + secret: string, + sessionId?: string, + userId?: string + ): CsrfValidationResult { + if (!token || !secret) { + return { + isValid: false, + reason: "Missing token or secret" + }; + } + + // Check if token exists in cache + const cachedTokenData = this.tokenCache.get(token); + if (!cachedTokenData) { + return { + isValid: false, + reason: "Token not found or expired" + }; + } + + // Check expiry + if (cachedTokenData.expiresAt < new Date()) { + this.tokenCache.delete(token); + return { + isValid: false, + reason: "Token expired" + }; + } + + // Validate secret matches + if (cachedTokenData.secret !== secret) { + this.logger.warn("CSRF token validation failed - secret mismatch", { + tokenHash: this.hashToken(token), + sessionId, + userId + }); + return { + isValid: false, + reason: "Invalid secret" + }; + } + + // Validate session binding (if provided) + if (sessionId && cachedTokenData.sessionId && cachedTokenData.sessionId !== sessionId) { + this.logger.warn("CSRF token validation failed - session mismatch", { + tokenHash: this.hashToken(token), + expectedSession: cachedTokenData.sessionId, + providedSession: sessionId + }); + return { + isValid: false, + reason: "Session mismatch" + }; + } + + // Validate user binding (if provided) + if (userId && cachedTokenData.userId && cachedTokenData.userId !== userId) { + this.logger.warn("CSRF token validation failed - user mismatch", { + tokenHash: this.hashToken(token), + expectedUser: cachedTokenData.userId, + providedUser: userId + }); + return { + isValid: false, + reason: "User mismatch" + }; + } + + // Regenerate expected token to prevent timing attacks + const expectedToken = this.generateTokenFromSecret( + cachedTokenData.secret, + cachedTokenData.sessionId, + cachedTokenData.userId + ); + + // Constant-time comparison + if (!this.constantTimeEquals(token, expectedToken)) { + this.logger.warn("CSRF token validation failed - token mismatch", { + tokenHash: this.hashToken(token), + sessionId, + userId + }); + return { + isValid: false, + reason: "Invalid token" + }; + } + + this.logger.debug("CSRF token validated successfully", { + tokenHash: this.hashToken(token), + sessionId, + userId + }); + + return { + isValid: true, + tokenData: cachedTokenData + }; + } + + /** + * Invalidate a specific token + */ + invalidateToken(token: string): void { + this.tokenCache.delete(token); + this.logger.debug("CSRF token invalidated", { + tokenHash: this.hashToken(token) + }); + } + + /** + * Invalidate all tokens for a specific session + */ + invalidateSessionTokens(sessionId: string): void { + let invalidatedCount = 0; + for (const [token, tokenData] of this.tokenCache.entries()) { + if (tokenData.sessionId === sessionId) { + this.tokenCache.delete(token); + invalidatedCount++; + } + } + + this.logger.debug("CSRF tokens invalidated for session", { + sessionId, + invalidatedCount + }); + } + + /** + * Invalidate all tokens for a specific user + */ + invalidateUserTokens(userId: string): void { + let invalidatedCount = 0; + for (const [token, tokenData] of this.tokenCache.entries()) { + if (tokenData.userId === userId) { + this.tokenCache.delete(token); + invalidatedCount++; + } + } + + this.logger.debug("CSRF tokens invalidated for user", { + userId, + invalidatedCount + }); + } + + /** + * Get token statistics for monitoring + */ + getTokenStats() { + const now = new Date(); + let activeTokens = 0; + let expiredTokens = 0; + + for (const tokenData of this.tokenCache.values()) { + if (tokenData.expiresAt > now) { + activeTokens++; + } else { + expiredTokens++; + } + } + + return { + totalTokens: this.tokenCache.size, + activeTokens, + expiredTokens, + cacheSize: this.tokenCache.size, + maxCacheSize: this.maxCacheSize + }; + } + + private generateSecret(): string { + return crypto.randomBytes(32).toString('base64url'); + } + + private generateTokenFromSecret(secret: string, sessionId?: string, userId?: string): string { + const data = [secret, sessionId || '', userId || ''].join('|'); + const hmac = crypto.createHmac('sha256', this.secretKey); + hmac.update(data); + return hmac.digest('base64url'); + } + + private generateSecretKey(): string { + const key = crypto.randomBytes(64).toString('base64url'); + this.logger.warn("Generated CSRF secret key - set CSRF_SECRET_KEY environment variable for production"); + return key; + } + + private hashToken(token: string): string { + // Create a hash of the token for logging (never log the actual token) + return crypto.createHash('sha256').update(token).digest('hex').substring(0, 16); + } + + private constantTimeEquals(a: string, b: string): boolean { + if (a.length !== b.length) { + return false; + } + + let result = 0; + for (let i = 0; i < a.length; i++) { + result |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + + return result === 0; + } + + private cleanupExpiredTokens(): void { + const now = new Date(); + let cleanedCount = 0; + + for (const [token, tokenData] of this.tokenCache.entries()) { + if (tokenData.expiresAt < now) { + this.tokenCache.delete(token); + cleanedCount++; + } + } + + if (cleanedCount > 0) { + this.logger.debug("Cleaned up expired CSRF tokens", { + cleanedCount, + remainingTokens: this.tokenCache.size + }); + } + } +} diff --git a/apps/bff/src/core/security/services/secure-error-mapper.service.ts b/apps/bff/src/core/security/services/secure-error-mapper.service.ts new file mode 100644 index 00000000..96d34266 --- /dev/null +++ b/apps/bff/src/core/security/services/secure-error-mapper.service.ts @@ -0,0 +1,454 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Logger } from "nestjs-pino"; + +export interface ErrorContext { + userId?: string; + requestId?: string; + userAgent?: string; + ip?: string; + url?: string; + method?: string; +} + +export interface SecureErrorMapping { + code: string; + publicMessage: string; + logLevel: 'error' | 'warn' | 'info' | 'debug'; + shouldAlert?: boolean; // Whether to send alerts to monitoring +} + +export interface ErrorClassification { + category: 'authentication' | 'authorization' | 'validation' | 'business' | 'system' | 'external'; + severity: 'low' | 'medium' | 'high' | 'critical'; + mapping: SecureErrorMapping; +} + +/** + * Service for secure error message mapping to prevent information leakage + * Maps internal errors to safe public messages while preserving security + */ +@Injectable() +export class SecureErrorMapperService { + private readonly isDevelopment: boolean; + private readonly errorMappings: Map; + private readonly patternMappings: Array<{ pattern: RegExp; mapping: SecureErrorMapping }>; + + constructor( + private readonly configService: ConfigService, + @Inject(Logger) private readonly logger: Logger + ) { + this.isDevelopment = this.configService.get("NODE_ENV") !== "production"; + this.errorMappings = this.initializeErrorMappings(); + this.patternMappings = this.initializePatternMappings(); + } + + /** + * Map an error to a secure public message + */ + mapError( + error: unknown, + context?: ErrorContext + ): ErrorClassification { + const errorMessage = this.extractErrorMessage(error); + const errorCode = this.extractErrorCode(error); + + // Try exact code mapping first + if (errorCode && this.errorMappings.has(errorCode)) { + const mapping = this.errorMappings.get(errorCode)!; + return this.createClassification(errorMessage, mapping, context); + } + + // Try pattern matching + for (const { pattern, mapping } of this.patternMappings) { + if (pattern.test(errorMessage)) { + return this.createClassification(errorMessage, mapping, context); + } + } + + // Default fallback + const defaultMapping = this.getDefaultMapping(errorMessage); + return this.createClassification(errorMessage, defaultMapping, context); + } + + /** + * Get a safe error message for client consumption + */ + getPublicMessage(error: unknown, context?: ErrorContext): string { + const classification = this.mapError(error, context); + + // In development, show more details + if (this.isDevelopment) { + const originalMessage = this.extractErrorMessage(error); + return `${classification.mapping.publicMessage} (Dev: ${this.sanitizeForDevelopment(originalMessage)})`; + } + + return classification.mapping.publicMessage; + } + + /** + * Log error with appropriate security level + */ + logSecureError( + error: unknown, + context?: ErrorContext, + additionalData?: Record + ): void { + const classification = this.mapError(error, context); + const originalMessage = this.extractErrorMessage(error); + + const logData = { + errorCode: classification.mapping.code, + category: classification.category, + severity: classification.severity, + publicMessage: classification.mapping.publicMessage, + originalMessage: this.sanitizeForLogging(originalMessage), + context, + ...additionalData + }; + + // Log based on severity and log level + switch (classification.mapping.logLevel) { + case 'error': + this.logger.error(`Security Error: ${classification.mapping.code}`, logData); + break; + case 'warn': + this.logger.warn(`Security Warning: ${classification.mapping.code}`, logData); + break; + case 'info': + this.logger.log(`Security Info: ${classification.mapping.code}`, logData); + break; + case 'debug': + this.logger.debug(`Security Debug: ${classification.mapping.code}`, logData); + break; + } + + // Send alerts for critical errors + if (classification.mapping.shouldAlert && classification.severity === 'critical') { + this.sendSecurityAlert(classification, context, logData); + } + } + + private initializeErrorMappings(): Map { + return new Map([ + // Authentication Errors + ['INVALID_CREDENTIALS', { + code: 'AUTH_001', + publicMessage: 'Invalid email or password', + logLevel: 'warn' + }], + ['ACCOUNT_LOCKED', { + code: 'AUTH_002', + publicMessage: 'Account temporarily locked. Please try again later', + logLevel: 'warn' + }], + ['TOKEN_EXPIRED', { + code: 'AUTH_003', + publicMessage: 'Session expired. Please log in again', + logLevel: 'info' + }], + ['TOKEN_INVALID', { + code: 'AUTH_004', + publicMessage: 'Invalid session. Please log in again', + logLevel: 'warn' + }], + + // Authorization Errors + ['INSUFFICIENT_PERMISSIONS', { + code: 'AUTHZ_001', + publicMessage: 'You do not have permission to perform this action', + logLevel: 'warn' + }], + ['RESOURCE_NOT_FOUND', { + code: 'AUTHZ_002', + publicMessage: 'The requested resource was not found', + logLevel: 'info' + }], + + // Validation Errors + ['VALIDATION_FAILED', { + code: 'VAL_001', + publicMessage: 'The provided data is invalid', + logLevel: 'info' + }], + ['REQUIRED_FIELD_MISSING', { + code: 'VAL_002', + publicMessage: 'Required information is missing', + logLevel: 'info' + }], + + // Business Logic Errors + ['ORDER_ALREADY_PROCESSED', { + code: 'BIZ_001', + publicMessage: 'This order has already been processed', + logLevel: 'info' + }], + ['INSUFFICIENT_BALANCE', { + code: 'BIZ_002', + publicMessage: 'Insufficient account balance', + logLevel: 'info' + }], + ['SERVICE_UNAVAILABLE', { + code: 'BIZ_003', + publicMessage: 'Service is temporarily unavailable', + logLevel: 'warn' + }], + + // System Errors (High Security) + ['DATABASE_ERROR', { + code: 'SYS_001', + publicMessage: 'A system error occurred. Please try again later', + logLevel: 'error', + shouldAlert: true + }], + ['EXTERNAL_SERVICE_ERROR', { + code: 'SYS_002', + publicMessage: 'External service temporarily unavailable', + logLevel: 'error' + }], + ['CONFIGURATION_ERROR', { + code: 'SYS_003', + publicMessage: 'System configuration error', + logLevel: 'error', + shouldAlert: true + }], + + // Rate Limiting + ['RATE_LIMIT_EXCEEDED', { + code: 'RATE_001', + publicMessage: 'Too many requests. Please try again later', + logLevel: 'warn' + }], + + // Generic Fallbacks + ['UNKNOWN_ERROR', { + code: 'GEN_001', + publicMessage: 'An unexpected error occurred', + logLevel: 'error', + shouldAlert: true + }] + ]); + } + + private initializePatternMappings(): Array<{ pattern: RegExp; mapping: SecureErrorMapping }> { + return [ + // Database-related patterns + { + pattern: /database|connection|sql|prisma|postgres/i, + mapping: { + code: 'SYS_001', + publicMessage: 'A system error occurred. Please try again later', + logLevel: 'error', + shouldAlert: true + } + }, + + // Authentication patterns + { + pattern: /password|credential|token|secret|key|auth/i, + mapping: { + code: 'AUTH_001', + publicMessage: 'Authentication failed', + logLevel: 'warn' + } + }, + + // File system patterns + { + pattern: /file|path|directory|permission denied|enoent|eacces/i, + mapping: { + code: 'SYS_002', + publicMessage: 'System resource error', + logLevel: 'error', + shouldAlert: true + } + }, + + // Network/External service patterns + { + pattern: /network|timeout|connection refused|econnrefused|whmcs|salesforce/i, + mapping: { + code: 'SYS_002', + publicMessage: 'External service temporarily unavailable', + logLevel: 'error' + } + }, + + // Stack trace patterns + { + pattern: /\s+at\s+|\.js:\d+|\.ts:\d+|stack trace/i, + mapping: { + code: 'SYS_001', + publicMessage: 'A system error occurred. Please try again later', + logLevel: 'error', + shouldAlert: true + } + }, + + // Memory/Resource patterns + { + pattern: /memory|heap|out of memory|resource|limit exceeded/i, + mapping: { + code: 'SYS_003', + publicMessage: 'System resources temporarily unavailable', + logLevel: 'error', + shouldAlert: true + } + }, + + // Validation patterns + { + pattern: /invalid|required|missing|validation|format/i, + mapping: { + code: 'VAL_001', + publicMessage: 'The provided data is invalid', + logLevel: 'info' + } + } + ]; + } + + private createClassification( + originalMessage: string, + mapping: SecureErrorMapping, + context?: ErrorContext + ): ErrorClassification { + // Determine category and severity based on error code + const category = this.determineCategory(mapping.code); + const severity = this.determineSeverity(mapping.code, originalMessage); + + return { + category, + severity, + mapping + }; + } + + private determineCategory(code: string): ErrorClassification['category'] { + if (code.startsWith('AUTH_')) return 'authentication'; + if (code.startsWith('AUTHZ_')) return 'authorization'; + if (code.startsWith('VAL_')) return 'validation'; + if (code.startsWith('BIZ_')) return 'business'; + if (code.startsWith('SYS_')) return 'system'; + return 'system'; + } + + private determineSeverity(code: string, message: string): ErrorClassification['severity'] { + // Critical system errors + if (code === 'SYS_001' || code === 'SYS_003') return 'critical'; + + // High severity for authentication issues + if (code.startsWith('AUTH_') && message.toLowerCase().includes('breach')) return 'high'; + + // Medium for external service issues + if (code === 'SYS_002') return 'medium'; + + // Low for validation and business logic + if (code.startsWith('VAL_') || code.startsWith('BIZ_')) return 'low'; + + return 'medium'; + } + + private getDefaultMapping(message: string): SecureErrorMapping { + // Analyze message for sensitivity + if (this.containsSensitiveInfo(message)) { + return { + code: 'SYS_001', + publicMessage: 'A system error occurred. Please try again later', + logLevel: 'error', + shouldAlert: true + }; + } + + return { + code: 'GEN_001', + publicMessage: 'An unexpected error occurred', + logLevel: 'error' + }; + } + + private containsSensitiveInfo(message: string): boolean { + const sensitivePatterns = [ + /password|secret|key|token|credential/i, + /database|sql|connection/i, + /file|path|directory/i, + /\s+at\s+.*\.js:\d+/i, // Stack traces + /[a-zA-Z]:[\\\/]/, // Windows paths + /\/[a-zA-Z0-9._\-/]+\.(js|ts|py|php)/i, // Unix paths + /\b(?:\d{1,3}\.){3}\d{1,3}\b/, // IP addresses + /[A-Za-z0-9]{32,}/ // Long tokens/hashes + ]; + + return sensitivePatterns.some(pattern => pattern.test(message)); + } + + private extractErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === 'string') { + return error; + } + if (typeof error === 'object' && error !== null) { + const obj = error as Record; + if (typeof obj.message === 'string') { + return obj.message; + } + } + return 'Unknown error'; + } + + private extractErrorCode(error: unknown): string | null { + if (typeof error === 'object' && error !== null) { + const obj = error as Record; + if (typeof obj.code === 'string') { + return obj.code; + } + } + return null; + } + + private sanitizeForLogging(message: string): string { + return message + // Remove file paths + .replace(/\/[a-zA-Z0-9._\-/]+\.(js|ts|py|php)/g, '[file]') + // Remove stack traces + .replace(/\s+at\s+.*/g, '') + // Remove absolute paths + .replace(/[a-zA-Z]:[\\\/][^:]+/g, '[path]') + // Remove IP addresses + .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, '[ip]') + // Remove URLs with credentials + .replace(/https?:\/\/[^:]+:[^@]+@[^\s]+/g, '[url]') + // Remove potential secrets + .replace(/\b[A-Za-z0-9]{32,}\b/g, '[token]') + .trim(); + } + + private sanitizeForDevelopment(message: string): string { + // In development, show more but still remove the most sensitive parts + return message + .replace(/password[=:]\s*[^\s]+/gi, 'password=[HIDDEN]') + .replace(/secret[=:]\s*[^\s]+/gi, 'secret=[HIDDEN]') + .replace(/token[=:]\s*[^\s]+/gi, 'token=[HIDDEN]') + .replace(/key[=:]\s*[^\s]+/gi, 'key=[HIDDEN]'); + } + + private sendSecurityAlert( + classification: ErrorClassification, + context?: ErrorContext, + logData?: Record + ): void { + // In a real implementation, this would send alerts to monitoring systems + // like Slack, PagerDuty, or custom alerting systems + this.logger.error('SECURITY ALERT TRIGGERED', { + alertType: 'CRITICAL_ERROR', + errorCode: classification.mapping.code, + category: classification.category, + severity: classification.severity, + context, + timestamp: new Date().toISOString(), + ...logData + }); + } +} diff --git a/apps/bff/src/integrations/salesforce/events/pubsub.subscriber.ts b/apps/bff/src/integrations/salesforce/events/pubsub.subscriber.ts index 9f962de6..53818dd3 100644 --- a/apps/bff/src/integrations/salesforce/events/pubsub.subscriber.ts +++ b/apps/bff/src/integrations/salesforce/events/pubsub.subscriber.ts @@ -52,9 +52,12 @@ type PubSubCtor = new (opts: { @Injectable() export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy { private client: PubSubClient | null = null; + private clientAccessToken: string | null = null; private channel!: string; private replayCorruptionRecovered = false; private subscribeCallback!: SubscribeCallback; + private pubSubCtor: PubSubCtor | null = null; + private clientBuildInFlight: Promise | null = null; constructor( private readonly config: ConfigService, @@ -77,171 +80,8 @@ export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy ); try { - await this.sfConn.connect(); - const accessToken = this.sfConn.getAccessToken(); - const instanceUrl = this.sfConn.getInstanceUrl(); - if (!accessToken || !instanceUrl) { - throw new Error("Salesforce access token || instance URL unavailable"); - } - - const endpoint = this.config.get( - "SF_PUBSUB_ENDPOINT", - "api.pubsub.salesforce.com:7443" - ); - - const maybeCtor: unknown = - (PubSubApiClientPkg as { default?: unknown })?.default ?? (PubSubApiClientPkg as unknown); - const Ctor = maybeCtor as PubSubCtor; - this.client = new Ctor({ - authType: "user-supplied", - accessToken, - instanceUrl, - pubSubEndpoint: endpoint, - }); - await this.client.connect(); - if (!this.client) throw new Error("Pub/Sub client not initialized after connect"); - const client = this.client; - - const _replayKey = sfReplayKey(this.channel); - const _replayMode = this.config.get("SF_EVENTS_REPLAY", "LATEST"); - const numRequested = Number(this.config.get("SF_PUBSUB_NUM_REQUESTED", "50")) || 50; - const maxQueue = Number(this.config.get("SF_PUBSUB_QUEUE_MAX", "100")) || 100; - - await this.cache.set(sfStatusKey(this.channel), { - status: "connecting", - since: Date.now(), - }); - - this.subscribeCallback = async (subscription, callbackType, data) => { - try { - const argTypes = [typeof subscription, typeof callbackType, typeof data]; - const type = callbackType; - const typeNorm = String(type || "").toLowerCase(); - const topic = subscription.topicName || this.channel; - - if (typeNorm === "data" || typeNorm === "event") { - const event = data as SalesforcePubSubEvent; - // Basic breadcrumb to confirm we are handling data callbacks - this.logger.debug("SF Pub/Sub data callback received", { - topic, - argTypes, - hasPayload: Boolean(event?.payload), - }); - const payload = event?.payload; - - // Only check parsed payload - const orderIdVal = payload?.["OrderId__c"] ?? payload?.["OrderId"]; - const orderId = typeof orderIdVal === "string" ? orderIdVal : undefined; - if (!orderId) { - this.logger.warn("Pub/Sub event missing OrderId__c; skipping", { - argTypes, - topic, - payloadKeys: payload ? Object.keys(payload) : [], - }); - const depth = await this.provisioningQueue.depth(); - if (depth < maxQueue) { - await client.requestAdditionalEvents(topic, 1); - } - return; - } - - const replayVal = (event as { replayId?: unknown })?.replayId; - const idempotencyKey = - typeof replayVal === "number" || typeof replayVal === "string" - ? String(replayVal) - : String(Date.now()); - const pubsubReplayId = typeof replayVal === "number" ? replayVal : undefined; - - await this.provisioningQueue.enqueue({ - sfOrderId: orderId, - idempotencyKey, - pubsubReplayId, - }); - this.logger.log("Enqueued provisioning job from SF event", { - sfOrderId: orderId, - replayId: pubsubReplayId, - topic, - }); - - // Do not request more here; rely on 'lastevent' to top-up - } else if (typeNorm === "lastevent") { - const depth = await this.provisioningQueue.depth(); - const available = Math.max(0, maxQueue - depth); - const desired = Math.max(0, Math.min(numRequested, available)); - if (desired > 0) { - await client.requestAdditionalEvents(topic, desired); - } - } else if (typeNorm === "grpckeepalive") { - const latestVal = (data as { latestReplayId?: unknown })?.latestReplayId; - const latest = typeof latestVal === "number" ? latestVal : undefined; - if (typeof latest === "number") { - await this.cache.set(sfLatestSeenKey(this.channel), { - id: String(latest), - at: Date.now(), - }); - } - } else if (typeNorm === "grpcstatus" || typeNorm === "end") { - // No-op; informational - } else if (typeNorm === "error") { - this.logger.warn("SF Pub/Sub stream error", { topic, data }); - try { - // Detect replay id corruption and auto-recover once by clearing the cursor and resubscribing - const errorData = data as SalesforcePubSubError; - const details = errorData.details || ""; - const metadata = errorData.metadata || {}; - const errorCodes = Array.isArray(metadata["error-code"]) - ? metadata["error-code"] - : []; - const hasCorruptionCode = errorCodes.some(code => - String(code).includes("replayid.corrupted") - ); - const mentionsReplayValidation = /Replay ID validation failed/i.test(details); - - if ( - (hasCorruptionCode || mentionsReplayValidation) && - !this.replayCorruptionRecovered - ) { - this.replayCorruptionRecovered = true; - const key = sfReplayKey(this.channel); - await this.cache.del(key); - this.logger.warn( - "Cleared invalid Salesforce Pub/Sub replay cursor; retrying subscription", - { - channel: this.channel, - key, - } - ); - await this.cache.set(sfStatusKey(this.channel), { - status: "reconnecting", - since: Date.now(), - }); - // Try re-subscribing without the invalid cursor - await this.subscribeWithPolicy(); - } - } catch (recoveryErr) { - this.logger.warn("SF Pub/Sub replay corruption auto-recovery failed", { - error: recoveryErr instanceof Error ? recoveryErr.message : String(recoveryErr), - }); - } - } else { - // Unknown callback type: log once with minimal context - const maybeEvent = data as SalesforcePubSubEvent | undefined; - const hasPayload = Boolean(maybeEvent?.payload); - this.logger.debug("SF Pub/Sub callback ignored (unknown type)", { - type, - topic, - argTypes, - hasPayload, - }); - } - } catch (err) { - this.logger.error("Pub/Sub subscribe callback failed", { - error: err instanceof Error ? err.message : String(err), - }); - } - }; - - await this.subscribeWithPolicy(); + this.subscribeCallback = this.buildSubscribeCallback(); + await this.subscribeWithPolicy(true); } catch (error) { this.logger.error("Salesforce Pub/Sub subscription failed", { error: error instanceof Error ? error.message : String(error), @@ -261,10 +101,7 @@ export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy async onModuleDestroy(): Promise { try { - if (this.client) { - await this.client.close(); - this.client = null; - } + await this.safeCloseClient(); await this.cache.set(sfStatusKey(this.channel), { status: "disconnected", since: Date.now(), @@ -276,30 +113,226 @@ export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy } } - private async subscribeWithPolicy(): Promise { - if (!this.client) throw new Error("Pub/Sub client not initialized"); - if (!this.subscribeCallback) throw new Error("Subscribe callback not initialized"); + private buildSubscribeCallback(): SubscribeCallback { + return async (subscription, callbackType, data) => { + try { + const argTypes = [typeof subscription, typeof callbackType, typeof data]; + const type = callbackType; + const typeNorm = String(type || "").toLowerCase(); + const topic = subscription.topicName || this.channel; - const _replayMode = this.config.get("SF_EVENTS_REPLAY", "LATEST"); - const numRequested = Number(this.config.get("SF_PUBSUB_NUM_REQUESTED", "50")) || 50; - const _replayKey = sfReplayKey(this.channel); - const storedReplay = _replayMode !== "ALL" ? await this.cache.get(_replayKey) : null; + if (typeNorm === "data" || typeNorm === "event") { + const event = data as SalesforcePubSubEvent; + this.logger.debug("SF Pub/Sub data callback received", { + topic, + argTypes, + hasPayload: Boolean(event?.payload), + }); + const payload = event?.payload; - if (storedReplay && _replayMode !== "ALL") { - await this.client.subscribeFromReplayId( + const orderIdVal = payload?.["OrderId__c"] ?? payload?.["OrderId"]; + const orderId = typeof orderIdVal === "string" ? orderIdVal : undefined; + if (!orderId) { + this.logger.warn("Pub/Sub event missing OrderId__c; skipping", { + argTypes, + topic, + payloadKeys: payload ? Object.keys(payload) : [], + }); + const depth = await this.provisioningQueue.depth(); + if (depth < this.getMaxQueueSize()) { + const activeClient = this.client; + if (activeClient) { + await activeClient.requestAdditionalEvents(topic, 1); + } + } + return; + } + + const replayVal = (event as { replayId?: unknown })?.replayId; + const idempotencyKey = + typeof replayVal === "number" || typeof replayVal === "string" + ? String(replayVal) + : String(Date.now()); + const pubsubReplayId = typeof replayVal === "number" ? replayVal : undefined; + + await this.provisioningQueue.enqueue({ + sfOrderId: orderId, + idempotencyKey, + pubsubReplayId, + }); + this.logger.log("Enqueued provisioning job from SF event", { + sfOrderId: orderId, + replayId: pubsubReplayId, + topic, + }); + } else if (typeNorm === "lastevent") { + const depth = await this.provisioningQueue.depth(); + const available = Math.max(0, this.getMaxQueueSize() - depth); + const desired = Math.max(0, Math.min(this.getNumRequested(), available)); + if (desired > 0) { + const activeClient = this.client; + if (activeClient) { + await activeClient.requestAdditionalEvents(topic, desired); + } + } + } else if (typeNorm === "grpckeepalive") { + const latestVal = (data as { latestReplayId?: unknown })?.latestReplayId; + const latest = typeof latestVal === "number" ? latestVal : undefined; + if (typeof latest === "number") { + await this.cache.set(sfLatestSeenKey(this.channel), { + id: String(latest), + at: Date.now(), + }); + } + } else if (typeNorm === "grpcstatus" || typeNorm === "end") { + // Informational – no action required + } else if (typeNorm === "error") { + this.logger.warn("SF Pub/Sub stream error", { topic, data }); + try { + const errorData = data as SalesforcePubSubError; + const details = errorData.details || ""; + const metadata = errorData.metadata || {}; + const errorCodes = Array.isArray(metadata["error-code"]) + ? metadata["error-code"] + : []; + const hasCorruptionCode = errorCodes.some(code => + String(code).includes("replayid.corrupted") + ); + const mentionsReplayValidation = /Replay ID validation failed/i.test(details); + + if ((hasCorruptionCode || mentionsReplayValidation) && !this.replayCorruptionRecovered) { + this.replayCorruptionRecovered = true; + const key = sfReplayKey(this.channel); + await this.cache.del(key); + this.logger.warn( + "Cleared invalid Salesforce Pub/Sub replay cursor; retrying subscription", + { + channel: this.channel, + key, + } + ); + } + } catch (recoveryErr) { + this.logger.warn("SF Pub/Sub replay corruption auto-recovery failed", { + error: recoveryErr instanceof Error ? recoveryErr.message : String(recoveryErr), + }); + } finally { + await this.recoverFromStreamError(); + } + } else { + const maybeEvent = data as SalesforcePubSubEvent | undefined; + const hasPayload = Boolean(maybeEvent?.payload); + this.logger.debug("SF Pub/Sub callback ignored (unknown type)", { + type, + topic, + argTypes, + hasPayload, + }); + } + } catch (err) { + this.logger.error("Pub/Sub subscribe callback failed", { + error: err instanceof Error ? err.message : String(err), + }); + } + }; + } + + private getNumRequested(): number { + return Number(this.config.get("SF_PUBSUB_NUM_REQUESTED", "50")) || 50; + } + + private getMaxQueueSize(): number { + return Number(this.config.get("SF_PUBSUB_QUEUE_MAX", "100")) || 100; + } + + private getPubSubCtor(): PubSubCtor { + if (this.pubSubCtor) { + return this.pubSubCtor; + } + const maybeCtor = + (PubSubApiClientPkg as { default?: unknown })?.default ?? (PubSubApiClientPkg as unknown); + this.pubSubCtor = maybeCtor as PubSubCtor; + return this.pubSubCtor; + } + + private async ensureClient(forceRefresh = false): Promise { + if (this.clientBuildInFlight && !forceRefresh) { + return this.clientBuildInFlight; + } + + this.clientBuildInFlight = (async () => { + await this.sfConn.ensureConnected(); + const accessToken = this.sfConn.getAccessToken(); + const instanceUrl = this.sfConn.getInstanceUrl(); + if (!accessToken || !instanceUrl) { + throw new Error("Salesforce access token || instance URL unavailable"); + } + + const tokenChanged = this.clientAccessToken !== accessToken; + + if (!this.client || forceRefresh || tokenChanged) { + await this.safeCloseClient(); + + const endpoint = this.config.get( + "SF_PUBSUB_ENDPOINT", + "api.pubsub.salesforce.com:7443" + ); + const Ctor = this.getPubSubCtor(); + const client = new Ctor({ + authType: "user-supplied", + accessToken, + instanceUrl, + pubSubEndpoint: endpoint, + }); + + await client.connect(); + this.client = client; + this.clientAccessToken = accessToken; + this.replayCorruptionRecovered = false; + } + + return this.client!; + })(); + + try { + return await this.clientBuildInFlight; + } finally { + this.clientBuildInFlight = null; + } + } + + private async subscribeWithPolicy(forceClientRefresh = false): Promise { + if (!this.subscribeCallback) { + throw new Error("Subscribe callback not initialized"); + } + + await this.cache.set(sfStatusKey(this.channel), { + status: "connecting", + since: Date.now(), + }); + + const client = await this.ensureClient(forceClientRefresh); + + const replayMode = this.config.get("SF_EVENTS_REPLAY", "LATEST"); + const replayKey = sfReplayKey(this.channel); + const storedReplay = replayMode !== "ALL" ? await this.cache.get(replayKey) : null; + const numRequested = this.getNumRequested(); + + if (storedReplay && replayMode !== "ALL") { + await client.subscribeFromReplayId( this.channel, this.subscribeCallback, numRequested, Number(storedReplay) ); - } else if (_replayMode === "ALL") { - await this.client.subscribeFromEarliestEvent( + } else if (replayMode === "ALL") { + await client.subscribeFromEarliestEvent( this.channel, this.subscribeCallback, numRequested ); } else { - await this.client.subscribe(this.channel, this.subscribeCallback, numRequested); + await client.subscribe(this.channel, this.subscribeCallback, numRequested); } await this.cache.set(sfStatusKey(this.channel), { @@ -309,5 +342,28 @@ export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy this.logger.log("Salesforce Pub/Sub subscription active", { channel: this.channel }); } - // keys moved to shared util + private async recoverFromStreamError(): Promise { + await this.cache.set(sfStatusKey(this.channel), { + status: "reconnecting", + since: Date.now(), + }); + await this.safeCloseClient(); + await this.subscribeWithPolicy(true); + } + + private async safeCloseClient(): Promise { + if (!this.client) { + return; + } + try { + await this.client.close(); + } catch (error) { + this.logger.warn("Failed to close Salesforce Pub/Sub client", { + error: error instanceof Error ? error.message : String(error), + }); + } finally { + this.client = null; + this.clientAccessToken = null; + } + } } diff --git a/apps/bff/src/integrations/salesforce/salesforce.module.ts b/apps/bff/src/integrations/salesforce/salesforce.module.ts index 0f1c7f2d..e33b0536 100644 --- a/apps/bff/src/integrations/salesforce/salesforce.module.ts +++ b/apps/bff/src/integrations/salesforce/salesforce.module.ts @@ -1,9 +1,11 @@ import { Module } from "@nestjs/common"; +import { QueueModule } from "@bff/core/queue/queue.module"; import { SalesforceService } from "./salesforce.service"; import { SalesforceConnection } from "./services/salesforce-connection.service"; import { SalesforceAccountService } from "./services/salesforce-account.service"; @Module({ + imports: [QueueModule], providers: [SalesforceConnection, SalesforceAccountService, SalesforceService], exports: [SalesforceService, SalesforceConnection], }) diff --git a/apps/bff/src/integrations/salesforce/salesforce.service.ts b/apps/bff/src/integrations/salesforce/salesforce.service.ts index 6b3f8b2d..992b4c24 100644 --- a/apps/bff/src/integrations/salesforce/salesforce.service.ts +++ b/apps/bff/src/integrations/salesforce/salesforce.service.ts @@ -3,7 +3,7 @@ import { Logger } from "nestjs-pino"; import { ConfigService } from "@nestjs/config"; import { getErrorMessage } from "@bff/core/utils/error.util"; import { SalesforceConnection } from "./services/salesforce-connection.service"; -import { getSalesforceFieldMap } from "@bff/core/config/field-map"; +import { SalesforceFieldMapService } from "@bff/core/config/field-map"; import { SalesforceAccountService, type AccountData, @@ -26,6 +26,7 @@ export class SalesforceService implements OnModuleInit { private configService: ConfigService, private connection: SalesforceConnection, private accountService: SalesforceAccountService, + private fieldMapService: SalesforceFieldMapService, @Inject(Logger) private readonly logger: Logger ) {} @@ -116,7 +117,7 @@ export class SalesforceService implements OnModuleInit { throw new Error("Salesforce connection not available"); } - const fields = getSalesforceFieldMap(); + const fields = this.fieldMapService.getFieldMap(); const result = (await this.connection.query( `SELECT Id, Status, ${fields.order.activationStatus}, ${fields.order.whmcsOrderId}, ${fields.order.lastErrorCode}, ${fields.order.lastErrorMessage}, 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 d3c1c1cb..bd90ed8c 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-connection.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-connection.service.ts @@ -2,6 +2,7 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { ConfigService } from "@nestjs/config"; import { getErrorMessage } from "@bff/core/utils/error.util"; +import { SalesforceRequestQueueService } from "@bff/core/queue/services/salesforce-request-queue.service"; import * as jsforce from "jsforce"; import * as jwt from "jsonwebtoken"; import * as fs from "fs/promises"; @@ -15,9 +16,13 @@ export interface SalesforceSObjectApi { @Injectable() export class SalesforceConnection { private connection: jsforce.Connection; + private tokenExpiresAt: number | null = null; + private tokenIssuedAt: number | null = null; + private connectPromise: Promise | null = null; constructor( private configService: ConfigService, + private readonly requestQueue: SalesforceRequestQueueService, @Inject(Logger) private readonly logger: Logger ) { this.connection = new jsforce.Connection({ @@ -43,7 +48,20 @@ export class SalesforceConnection { return this.connection?.instanceUrl as string | undefined; } - async connect(): Promise { + async connect(force = false): Promise { + if (this.connectPromise && !force) { + return this.connectPromise; + } + + this.connectPromise = this.performConnect(); + try { + await this.connectPromise; + } finally { + this.connectPromise = null; + } + } + + private async performConnect(): Promise { const nodeEnv = this.configService.get("NODE_ENV") || process.env.NODE_ENV || "development"; const isProd = nodeEnv === "production"; @@ -149,6 +167,11 @@ export class SalesforceConnection { this.connection.accessToken = tokenResponse.access_token; this.connection.instanceUrl = tokenResponse.instance_url; + const tokenTtlMs = this.getTokenTtl(); + const issuedAt = Date.now(); + this.tokenIssuedAt = issuedAt; + this.tokenExpiresAt = issuedAt + tokenTtlMs; + this.logger.log("āœ… Salesforce connection established"); } catch (error) { const message = getErrorMessage(error); @@ -165,10 +188,7 @@ export class SalesforceConnection { async query(soql: string): Promise { try { // Ensure we have a base URL and token - if (!this.isConnected()) { - this.logger.warn("Salesforce not connected; attempting to establish connection"); - await this.connect(); - } + await this.ensureConnected(); return await this.connection.query(soql); } catch (error: unknown) { // Check if this is a session expiration error @@ -177,7 +197,7 @@ export class SalesforceConnection { try { // Re-authenticate - await this.connect(); + await this.connect(true); // Retry the query once this.logger.debug("Retrying query after re-authentication"); @@ -220,10 +240,7 @@ export class SalesforceConnection { return { create: async (data: object) => { try { - if (!this.isConnected()) { - this.logger.warn("Salesforce not connected; attempting to establish connection"); - await this.connect(); - } + await this.ensureConnected(); return await originalSObject.create(data); } catch (error: unknown) { if (this.isSessionExpiredError(error)) { @@ -232,7 +249,7 @@ export class SalesforceConnection { ); try { - await this.connect(); + await this.connect(true); const newSObject = this.connection.sobject(type); return await newSObject.create(data); } catch (retryError) { @@ -249,10 +266,7 @@ export class SalesforceConnection { update: async (data: object & { Id: string }) => { try { - if (!this.isConnected()) { - this.logger.warn("Salesforce not connected; attempting to establish connection"); - await this.connect(); - } + await this.ensureConnected(); return await originalSObject.update(data); } catch (error: unknown) { if (this.isSessionExpiredError(error)) { @@ -261,7 +275,7 @@ export class SalesforceConnection { ); try { - await this.connect(); + await this.connect(true); const newSObject = this.connection.sobject(type); return await newSObject.update(data); } catch (retryError) { @@ -281,4 +295,110 @@ export class SalesforceConnection { isConnected(): boolean { return !!this.connection.accessToken; } + + private getTokenTtl(): number { + const configured = Number(this.configService.get("SF_TOKEN_TTL_MS")); + if (Number.isFinite(configured) && configured > 0) { + return configured; + } + // Default to 12 minutes (tokens typically expire after 15 minutes) + return 12 * 60 * 1000; + } + + private getRefreshBuffer(): number { + const configured = Number(this.configService.get("SF_TOKEN_REFRESH_BUFFER_MS")); + if (Number.isFinite(configured) && configured >= 0) { + return configured; + } + return 60 * 1000; // 1 minute buffer + } + + private isTokenExpiring(): boolean { + if (!this.tokenExpiresAt) { + return true; + } + return Date.now() + this.getRefreshBuffer() >= this.tokenExpiresAt; + } + + async ensureConnected(): Promise { + if (!this.isConnected() || this.isTokenExpiring()) { + this.logger.debug("Salesforce connection stale; refreshing access token"); + await this.connect(!this.isConnected()); + } + } + + /** + * Execute a high-priority Salesforce request (jumps queue) + */ + async queryHighPriority(soql: string): Promise { + return this.requestQueue.executeHighPriority(async () => { + await this.ensureConnected(); + return await this.connection.query(soql); + }); + } + + /** + * Get queue metrics for monitoring + */ + getQueueMetrics() { + return this.requestQueue.getMetrics(); + } + + /** + * Get queue health status + */ + getQueueHealth() { + return this.requestQueue.getHealthStatus(); + } + + /** + * Get daily API usage information + */ + getDailyUsage() { + return this.requestQueue.getDailyUsage(); + } + + /** + * Determine query priority based on SOQL content + */ + private getQueryPriority(soql: string): number { + const lowerSoql = soql.toLowerCase(); + + // High priority queries (critical for user experience) + if ( + lowerSoql.includes("account") || + lowerSoql.includes("user") || + lowerSoql.includes("where id =") + ) { + return 8; + } + + // Medium priority queries + if ( + lowerSoql.includes("order") || + lowerSoql.includes("invoice") || + lowerSoql.includes("limit 1") + ) { + return 5; + } + + // Low priority (bulk queries, reports) + return 2; + } + + /** + * Determine if query is long-running based on SOQL content + */ + private isLongRunningQuery(soql: string): boolean { + const lowerSoql = soql.toLowerCase(); + + // Queries likely to take >20 seconds + return ( + lowerSoql.includes("count(") || + lowerSoql.includes("group by") || + lowerSoql.includes("order by") || + (lowerSoql.includes("limit") && !lowerSoql.includes("limit 1")) || + lowerSoql.length > 500 // Very complex queries + ); + } } 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 ea622763..abcc9f38 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 @@ -5,6 +5,7 @@ import { WhmcsConfigService } from "../config/whmcs-config.service"; import { WhmcsHttpClientService } from "./whmcs-http-client.service"; import { WhmcsErrorHandlerService } from "./whmcs-error-handler.service"; import { WhmcsApiMethodsService } from "./whmcs-api-methods.service"; +import { WhmcsRequestQueueService } from "@bff/core/queue/services/whmcs-request-queue.service"; import type { WhmcsErrorResponse, WhmcsAddClientParams, @@ -30,7 +31,8 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit { private readonly configService: WhmcsConfigService, private readonly httpClient: WhmcsHttpClientService, private readonly errorHandler: WhmcsErrorHandlerService, - private readonly apiMethods: WhmcsApiMethodsService + private readonly apiMethods: WhmcsApiMethodsService, + private readonly requestQueue: WhmcsRequestQueueService ) {} async onModuleInit() { @@ -57,32 +59,82 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit { // ========================================== /** - * Make a request to WHMCS API with full error handling + * Make a request to WHMCS API with full error handling and queue management */ async makeRequest( action: string, params: Record = {}, options: WhmcsRequestOptions = {} ): Promise { - try { - const config = this.configService.getConfig(); - const response = await this.httpClient.makeRequest(config, action, params, options); + // Wrap the actual request in the queue to prevent race conditions + return this.requestQueue.execute(async () => { + try { + const config = this.configService.getConfig(); + const response = await this.httpClient.makeRequest(config, action, params, options); - if (response.result === "error") { - const errorResponse = response as WhmcsErrorResponse; - this.errorHandler.handleApiError(errorResponse, action, params); + if (response.result === "error") { + const errorResponse = response as WhmcsErrorResponse; + this.errorHandler.handleApiError(errorResponse, action, params); + } + + return response.data as T; + } catch (error) { + // If it's already a handled error, re-throw it + if (this.isHandledException(error)) { + throw error; + } + + // Handle general request errors + this.errorHandler.handleRequestError(error, action, params); } + }, { + priority: this.getRequestPriority(action), + timeout: options.timeout, + retryAttempts: options.retryAttempts, + retryDelay: options.retryDelay, + }); + } - return response.data as T; - } catch (error) { - // If it's already a handled error, re-throw it - if (this.isHandledException(error)) { - throw error; + /** + * Make a high-priority request to WHMCS API (jumps queue) + */ + async makeHighPriorityRequest( + action: string, + params: Record = {}, + options: WhmcsRequestOptions = {} + ): Promise { + return this.requestQueue.executeHighPriority(async () => { + try { + const config = this.configService.getConfig(); + const response = await this.httpClient.makeRequest(config, action, params, options); + + if (response.result === "error") { + const errorResponse = response as WhmcsErrorResponse; + this.errorHandler.handleApiError(errorResponse, action, params); + } + + return response.data as T; + } catch (error) { + if (this.isHandledException(error)) { + throw error; + } + this.errorHandler.handleRequestError(error, action, params); } + }); + } - // Handle general request errors - this.errorHandler.handleRequestError(error, action, params); - } + /** + * Get queue metrics for monitoring + */ + getQueueMetrics() { + return this.requestQueue.getMetrics(); + } + + /** + * Get queue health status + */ + getQueueHealth() { + return this.requestQueue.getHealthStatus(); } // ========================================== @@ -224,6 +276,35 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit { // UTILITY METHODS // ========================================== + /** + * Determine request priority based on action type + */ + private getRequestPriority(action: string): number { + // High priority actions (critical for user experience) + const highPriorityActions = [ + "ValidateLogin", + "GetClientDetails", + "GetInvoice", + "CapturePayment", + "CreateSsoToken" + ]; + + // Medium priority actions (important but can wait) + const mediumPriorityActions = [ + "GetInvoices", + "GetClientsProducts", + "GetPayMethods" + ]; + + if (highPriorityActions.includes(action)) { + return 8; // High priority + } else if (mediumPriorityActions.includes(action)) { + return 5; // Medium priority + } else { + return 2; // Low priority (default) + } + } + /** * Get connection statistics */ 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 6a813e2a..91441367 100644 --- a/apps/bff/src/integrations/whmcs/connection/types/connection.types.ts +++ b/apps/bff/src/integrations/whmcs/connection/types/connection.types.ts @@ -14,6 +14,7 @@ export interface WhmcsRequestOptions { useAdminAuth?: boolean; timeout?: number; retryAttempts?: number; + retryDelay?: number; } export interface WhmcsRetryConfig { diff --git a/apps/bff/src/integrations/whmcs/whmcs.module.ts b/apps/bff/src/integrations/whmcs/whmcs.module.ts index e871ed32..92ca7acb 100644 --- a/apps/bff/src/integrations/whmcs/whmcs.module.ts +++ b/apps/bff/src/integrations/whmcs/whmcs.module.ts @@ -1,5 +1,6 @@ import { Module } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; +import { QueueModule } from "@bff/core/queue/queue.module"; import { WhmcsCacheService } from "./cache/whmcs-cache.service"; import { WhmcsService } from "./whmcs.service"; import { WhmcsInvoiceService } from "./services/whmcs-invoice.service"; @@ -22,7 +23,7 @@ import { WhmcsErrorHandlerService } from "./connection/services/whmcs-error-hand import { WhmcsApiMethodsService } from "./connection/services/whmcs-api-methods.service"; @Module({ - imports: [ConfigModule], + imports: [ConfigModule, QueueModule], providers: [ // New modular transformer services WhmcsTransformerOrchestratorService, diff --git a/apps/bff/src/modules/auth/auth-zod.controller.ts b/apps/bff/src/modules/auth/auth-zod.controller.ts index ae12a638..f1faeaa8 100644 --- a/apps/bff/src/modules/auth/auth-zod.controller.ts +++ b/apps/bff/src/modules/auth/auth-zod.controller.ts @@ -1,5 +1,5 @@ -import { Controller, Post, Body, UseGuards, Get, Req, HttpCode, UsePipes } from "@nestjs/common"; -import type { Request } from "express"; +import { Controller, Post, Body, UseGuards, Get, Req, HttpCode, UsePipes, Res } from "@nestjs/common"; +import type { Request, Response } from "express"; import { Throttle } from "@nestjs/throttler"; import { AuthService } from "./auth.service"; import { LocalAuthGuard } from "./guards/local-auth.guard"; @@ -33,12 +33,62 @@ import { refreshTokenRequestSchema, type RefreshTokenRequestInput, } from "@customer-portal/domain"; +import type { AuthTokens } from "@customer-portal/domain"; + +type RequestWithCookies = Request & { cookies?: Record }; + +const EXTRACT_BEARER = (req: RequestWithCookies): string | undefined => { + const authHeader = req.headers?.authorization; + if (typeof authHeader === "string" && authHeader.startsWith("Bearer ")) { + return authHeader.slice(7); + } + if (Array.isArray(authHeader) && authHeader.length > 0 && authHeader[0]?.startsWith("Bearer ")) { + return authHeader[0]?.slice(7); + } + return undefined; +}; + +const extractTokenFromRequest = (req: RequestWithCookies): string | undefined => { + const headerToken = EXTRACT_BEARER(req); + if (headerToken) { + return headerToken; + } + const cookieToken = req.cookies?.access_token; + return typeof cookieToken === "string" && cookieToken.length > 0 ? cookieToken : undefined; +}; + +const calculateCookieMaxAge = (isoTimestamp: string): number => { + const expiresAt = Date.parse(isoTimestamp); + if (Number.isNaN(expiresAt)) { + return 0; + } + return Math.max(0, expiresAt - Date.now()); +}; @ApiTags("auth") @Controller("auth") export class AuthController { constructor(private authService: AuthService) {} + private setAuthCookies(res: Response, tokens: AuthTokens): void { + const accessMaxAge = calculateCookieMaxAge(tokens.expiresAt); + const refreshMaxAge = calculateCookieMaxAge(tokens.refreshExpiresAt); + + res.setSecureCookie("access_token", tokens.accessToken, { + maxAge: accessMaxAge, + path: "/", + }); + res.setSecureCookie("refresh_token", tokens.refreshToken, { + maxAge: refreshMaxAge, + path: "/", + }); + } + + private clearAuthCookies(res: Response): void { + res.setSecureCookie("access_token", "", { maxAge: 0, path: "/" }); + res.setSecureCookie("refresh_token", "", { maxAge: 0, path: "/" }); + } + @Public() @Post("validate-signup") @UseGuards(AuthThrottleGuard) @@ -49,7 +99,10 @@ export class AuthController { @ApiResponse({ status: 409, description: "Customer already has account" }) @ApiResponse({ status: 400, description: "Customer number not found" }) @ApiResponse({ status: 429, description: "Too many validation attempts" }) - async validateSignup(@Body() validateData: ValidateSignupRequestInput, @Req() req: Request) { + async validateSignup( + @Body() validateData: ValidateSignupRequestInput, + @Req() req: Request + ) { return this.authService.validateSignup(validateData, req); } @@ -91,8 +144,14 @@ export class AuthController { @ApiResponse({ status: 201, description: "User created successfully" }) @ApiResponse({ status: 409, description: "User already exists" }) @ApiResponse({ status: 429, description: "Too many signup attempts" }) - async signup(@Body() signupData: SignupRequestInput, @Req() req: Request) { - return this.authService.signup(signupData, req); + async signup( + @Body() signupData: SignupRequestInput, + @Req() req: Request, + @Res({ passthrough: true }) res: Response + ) { + const result = await this.authService.signup(signupData, req); + this.setAuthCookies(res, result.tokens); + return result; } @Public() @@ -103,23 +162,25 @@ export class AuthController { @ApiResponse({ status: 200, description: "Login successful" }) @ApiResponse({ status: 401, description: "Invalid credentials" }) @ApiResponse({ status: 429, description: "Too many login attempts" }) - async login(@Req() req: Request & { user: { id: string; email: string; role: string } }) { - return this.authService.login(req.user, req); + async login( + @Req() req: Request & { user: { id: string; email: string; role: string } }, + @Res({ passthrough: true }) res: Response + ) { + const result = await this.authService.login(req.user, req); + this.setAuthCookies(res, result.tokens); + return result; } @Post("logout") @ApiOperation({ summary: "Logout user" }) @ApiResponse({ status: 200, description: "Logout successful" }) - async logout(@Req() req: Request & { user: { id: string } }) { - const authHeader = req.headers.authorization as string | string[] | undefined; - let bearer: string | undefined; - if (typeof authHeader === "string") { - bearer = authHeader; - } else if (Array.isArray(authHeader) && authHeader.length > 0) { - bearer = authHeader[0]; - } - const token = bearer?.startsWith("Bearer ") ? bearer.slice(7) : undefined; - await this.authService.logout(req.user.id, token ?? "", req); + async logout( + @Req() req: RequestWithCookies & { user: { id: string } }, + @Res({ passthrough: true }) res: Response + ) { + const token = extractTokenFromRequest(req); + await this.authService.logout(req.user.id, token, req); + this.clearAuthCookies(res); return { message: "Logout successful" }; } @@ -131,11 +192,18 @@ export class AuthController { @ApiResponse({ status: 200, description: "Token refreshed successfully" }) @ApiResponse({ status: 401, description: "Invalid refresh token" }) @ApiResponse({ status: 429, description: "Too many refresh attempts" }) - async refreshToken(@Body() body: RefreshTokenRequestInput, @Req() req: Request) { - return this.authService.refreshTokens(body.refreshToken, { + async refreshToken( + @Body() body: RefreshTokenRequestInput, + @Req() req: RequestWithCookies, + @Res({ passthrough: true }) res: Response + ) { + const refreshToken = body.refreshToken ?? req.cookies?.refresh_token; + const result = await this.authService.refreshTokens(refreshToken, { deviceId: body.deviceId, userAgent: req.headers["user-agent"], }); + this.setAuthCookies(res, result.tokens); + return result; } @Public() @@ -163,8 +231,14 @@ export class AuthController { @ApiResponse({ status: 200, description: "Password set successfully" }) @ApiResponse({ status: 401, description: "User not found" }) @ApiResponse({ status: 429, description: "Too many password attempts" }) - async setPassword(@Body() setPasswordData: SetPasswordRequestInput, @Req() _req: Request) { - return this.authService.setPassword(setPasswordData); + async setPassword( + @Body() setPasswordData: SetPasswordRequestInput, + @Req() _req: Request, + @Res({ passthrough: true }) res: Response + ) { + const result = await this.authService.setPassword(setPasswordData); + this.setAuthCookies(res, result.tokens); + return result; } @Public() @@ -194,8 +268,10 @@ export class AuthController { @UsePipes(new ZodValidationPipe(passwordResetSchema)) @ApiOperation({ summary: "Reset password with token" }) @ApiResponse({ status: 200, description: "Password reset successful" }) - async resetPassword(@Body() body: PasswordResetInput) { - return this.authService.resetPassword(body.token, body.password); + async resetPassword(@Body() body: PasswordResetInput, @Res({ passthrough: true }) res: Response) { + const result = await this.authService.resetPassword(body.token, body.password); + this.setAuthCookies(res, result.tokens); + return result; } @Post("change-password") @@ -205,14 +281,17 @@ export class AuthController { @ApiResponse({ status: 200, description: "Password changed successfully" }) async changePassword( @Req() req: Request & { user: { id: string } }, - @Body() body: ChangePasswordRequestInput + @Body() body: ChangePasswordRequestInput, + @Res({ passthrough: true }) res: Response ) { - return this.authService.changePassword( + const result = await this.authService.changePassword( req.user.id, body.currentPassword, body.newPassword, req ); + this.setAuthCookies(res, result.tokens); + return result; } @Get("me") diff --git a/apps/bff/src/modules/auth/auth.service.ts b/apps/bff/src/modules/auth/auth.service.ts index a2b78705..01b1ebeb 100644 --- a/apps/bff/src/modules/auth/auth.service.ts +++ b/apps/bff/src/modules/auth/auth.service.ts @@ -290,9 +290,19 @@ export class AuthService { } } - async logout(userId: string, token: string, _request?: Request): Promise { - // Blacklist the token - await this.tokenBlacklistService.blacklistToken(token); + async logout(userId: string, token?: string, _request?: Request): Promise { + if (token) { + await this.tokenBlacklistService.blacklistToken(token); + } + + try { + await this.tokenService.revokeAllUserTokens(userId); + } catch (error) { + this.logger.warn("Failed to revoke refresh tokens during logout", { + userId, + error: getErrorMessage(error), + }); + } await this.auditService.logAuthEvent(AuditAction.LOGOUT, userId, {}, _request, true); } @@ -431,10 +441,18 @@ export class AuthService { } async refreshTokens( - refreshToken: string, + refreshToken: string | undefined, deviceInfo?: { deviceId?: string; userAgent?: string } ) { - return this.tokenService.refreshTokens(refreshToken, deviceInfo); + if (!refreshToken) { + throw new UnauthorizedException("Invalid refresh token"); + } + + const { tokens, user } = await this.tokenService.refreshTokens(refreshToken, deviceInfo); + return { + user, + tokens, + }; } /** diff --git a/apps/bff/src/modules/auth/guards/global-auth.guard.ts b/apps/bff/src/modules/auth/guards/global-auth.guard.ts index 9573a90b..0f0aedf0 100644 --- a/apps/bff/src/modules/auth/guards/global-auth.guard.ts +++ b/apps/bff/src/modules/auth/guards/global-auth.guard.ts @@ -9,9 +9,23 @@ import { Reflector } from "@nestjs/core"; import { AuthGuard } from "@nestjs/passport"; import { ExtractJwt } from "passport-jwt"; +import type { Request } from "express"; + import { TokenBlacklistService } from "../services/token-blacklist.service"; import { IS_PUBLIC_KEY } from "../decorators/public.decorator"; +type RequestWithCookies = Request & { cookies?: Record }; + +const headerExtractor = ExtractJwt.fromAuthHeaderAsBearerToken(); +const extractTokenFromRequest = (request: RequestWithCookies): string | undefined => { + const headerToken = headerExtractor(request); + if (headerToken) { + return headerToken; + } + const cookieToken = request.cookies?.access_token; + return typeof cookieToken === "string" && cookieToken.length > 0 ? cookieToken : undefined; +}; + @Injectable() export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate { private readonly logger = new Logger(GlobalAuthGuard.name); @@ -24,11 +38,9 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate { } override async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest<{ - method: string; - url: string; - route?: { path?: string }; - }>(); + const request = context + .switchToHttp() + .getRequest(); const route = `${request.method} ${request.route?.path ?? request.url}`; // Check if the route is marked as public @@ -51,7 +63,7 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate { } // Then check token blacklist - const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request); + const token = extractTokenFromRequest(request); if (token) { const isBlacklisted = await this.tokenBlacklistService.isTokenBlacklisted(token); diff --git a/apps/bff/src/modules/auth/services/token-blacklist.service.ts b/apps/bff/src/modules/auth/services/token-blacklist.service.ts index d38f8246..aa042924 100644 --- a/apps/bff/src/modules/auth/services/token-blacklist.service.ts +++ b/apps/bff/src/modules/auth/services/token-blacklist.service.ts @@ -1,6 +1,7 @@ import { Injectable, Inject } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { JwtService } from "@nestjs/jwt"; +import { createHash } from "crypto"; import { Redis } from "ioredis"; import { Logger } from "nestjs-pino"; import { parseJwtExpiry } from "../utils/jwt-expiry.util"; @@ -41,7 +42,7 @@ export class TokenBlacklistService { const ttl = Math.max(0, Math.floor((expiryTime - currentTime) / 1000)); // Convert to seconds if (ttl > 0) { - await this.redis.setex(`blacklist:${token}`, ttl, "1"); + await this.redis.setex(this.buildBlacklistKey(token), ttl, "1"); this.logger.debug(`Token blacklisted successfully for ${ttl} seconds`); } else { this.logger.debug("Token already expired, not blacklisting"); @@ -50,7 +51,7 @@ export class TokenBlacklistService { // If we can't parse the token, blacklist it for the default JWT expiry time try { const defaultTtl = parseJwtExpiry(this.configService.get("JWT_EXPIRES_IN", "7d")); - await this.redis.setex(`blacklist:${token}`, defaultTtl, "1"); + await this.redis.setex(this.buildBlacklistKey(token), defaultTtl, "1"); this.logger.debug(`Token blacklisted with default TTL: ${defaultTtl} seconds`); } catch (err) { this.logger.warn( @@ -65,7 +66,7 @@ export class TokenBlacklistService { async isTokenBlacklisted(token: string): Promise { try { - const result = await this.redis.get(`blacklist:${token}`); + const result = await this.redis.get(this.buildBlacklistKey(token)); return result !== null; } catch (err) { // If Redis is unavailable, treat as not blacklisted to avoid blocking auth @@ -75,4 +76,12 @@ export class TokenBlacklistService { return false; } } + + private buildBlacklistKey(token: string): string { + return `blacklist:${this.hashToken(token)}`; + } + + private hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); + } } diff --git a/apps/bff/src/modules/auth/services/token.service.ts b/apps/bff/src/modules/auth/services/token.service.ts index 98a56683..84762dde 100644 --- a/apps/bff/src/modules/auth/services/token.service.ts +++ b/apps/bff/src/modules/auth/services/token.service.ts @@ -4,8 +4,9 @@ import { ConfigService } from "@nestjs/config"; import { Redis } from "ioredis"; import { Logger } from "nestjs-pino"; import { randomBytes, createHash } from "crypto"; -import type { AuthTokens } from "@customer-portal/domain"; +import type { AuthTokens, AuthenticatedUser } from "@customer-portal/domain"; import { UsersService } from "@bff/modules/users/users.service"; +import { mapPrismaUserToUserProfile } from "@bff/infra/utils/user-mapper.util"; export interface RefreshTokenPayload { userId: string; @@ -156,7 +157,10 @@ export class AuthTokenService { deviceId?: string; userAgent?: string; } - ): Promise { + ): Promise<{ tokens: AuthTokens; user: AuthenticatedUser }> { + if (!refreshToken) { + throw new UnauthorizedException("Invalid refresh token"); + } try { // Verify refresh token const payload = this.jwtService.verify(refreshToken); @@ -225,10 +229,14 @@ export class AuthTokenService { // Generate new token pair const newTokenPair = await this.generateTokenPair(user, deviceInfo); + const userProfile = mapPrismaUserToUserProfile(prismaUser); this.logger.debug("Refreshed token pair", { userId: payload.userId }); - return newTokenPair; + return { + tokens: newTokenPair, + user: userProfile, + }; } catch (error) { this.logger.error("Token refresh failed", { error: error instanceof Error ? error.message : String(error), @@ -248,7 +256,7 @@ export class AuthTokenService { .catch(() => null); if (fallbackUser) { - return this.generateTokenPair( + const fallbackTokens = await this.generateTokenPair( { id: fallbackUser.id, email: fallbackUser.email, @@ -256,6 +264,11 @@ export class AuthTokenService { }, deviceInfo ); + + return { + tokens: fallbackTokens, + user: mapPrismaUserToUserProfile(fallbackUser), + }; } } } @@ -294,19 +307,26 @@ export class AuthTokenService { */ async revokeAllUserTokens(userId: string): Promise { try { + let cursor = "0"; const pattern = `${this.REFRESH_TOKEN_FAMILY_PREFIX}*`; - const keys = await this.redis.keys(pattern); - for (const key of keys) { - const data = await this.redis.get(key); - if (data) { - const family = this.parseRefreshTokenFamilyRecord(data); - if (family && family.userId === userId) { - await this.redis.del(key); - await this.redis.del(`${this.REFRESH_TOKEN_PREFIX}${family.tokenHash}`); + do { + const [nextCursor, keys] = await this.redis.scan(cursor, "MATCH", pattern, "COUNT", 100); + cursor = nextCursor; + + if (keys && keys.length) { + for (const key of keys) { + const data = await this.redis.get(key); + if (!data) continue; + + const family = this.parseRefreshTokenFamilyRecord(data); + if (family && family.userId === userId) { + await this.redis.del(key); + await this.redis.del(`${this.REFRESH_TOKEN_PREFIX}${family.tokenHash}`); + } } } - } + } while (cursor !== "0"); this.logger.debug("Revoked all tokens for user", { userId }); } catch (error) { diff --git a/apps/bff/src/modules/auth/services/workflows/password-workflow.service.ts b/apps/bff/src/modules/auth/services/workflows/password-workflow.service.ts index 9637d214..bf3d9255 100644 --- a/apps/bff/src/modules/auth/services/workflows/password-workflow.service.ts +++ b/apps/bff/src/modules/auth/services/workflows/password-workflow.service.ts @@ -52,7 +52,10 @@ export class PasswordWorkflowService { throw new BadRequestException("User already has a password set"); } - const passwordHash = await bcrypt.hash(password, 12); + const saltRoundsConfig = this.configService.get("BCRYPT_ROUNDS", 12); + const saltRounds = + typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig; + const passwordHash = await bcrypt.hash(password, saltRounds); await this.usersService.update(user.id, { passwordHash }); const prismaUser = await this.usersService.findByIdInternal(user.id); if (!prismaUser) { diff --git a/apps/bff/src/modules/auth/strategies/jwt.strategy.ts b/apps/bff/src/modules/auth/strategies/jwt.strategy.ts index ce0f9a01..9bb485d6 100644 --- a/apps/bff/src/modules/auth/strategies/jwt.strategy.ts +++ b/apps/bff/src/modules/auth/strategies/jwt.strategy.ts @@ -5,6 +5,15 @@ import { ConfigService } from "@nestjs/config"; import type { AuthenticatedUser } from "@customer-portal/domain"; import { UsersService } from "@bff/modules/users/users.service"; import { mapPrismaUserToUserProfile } from "@bff/infra/utils/user-mapper.util"; +import type { Request } from "express"; + +const cookieExtractor = (req: Request): string | null => { + const cookieToken = req?.cookies?.access_token; + if (typeof cookieToken === "string" && cookieToken.length > 0) { + return cookieToken; + } + return null; +}; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { @@ -18,7 +27,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) { } const options = { - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + jwtFromRequest: ExtractJwt.fromExtractors([ + ExtractJwt.fromAuthHeaderAsBearerToken(), + cookieExtractor, + ]), ignoreExpiration: false, secretOrKey: jwtSecret, }; diff --git a/apps/bff/src/modules/catalog/catalog.module.ts b/apps/bff/src/modules/catalog/catalog.module.ts index a76254e6..37d2289d 100644 --- a/apps/bff/src/modules/catalog/catalog.module.ts +++ b/apps/bff/src/modules/catalog/catalog.module.ts @@ -2,6 +2,7 @@ import { Module } from "@nestjs/common"; import { CatalogController } from "./catalog.controller"; import { IntegrationsModule } from "@bff/integrations/integrations.module"; import { MappingsModule } from "@bff/modules/id-mappings/mappings.module"; +import { CoreConfigModule } from "@bff/core/config/config.module"; import { BaseCatalogService } from "./services/base-catalog.service"; import { InternetCatalogService } from "./services/internet-catalog.service"; @@ -9,7 +10,7 @@ import { SimCatalogService } from "./services/sim-catalog.service"; import { VpnCatalogService } from "./services/vpn-catalog.service"; @Module({ - imports: [IntegrationsModule, MappingsModule], + imports: [IntegrationsModule, MappingsModule, CoreConfigModule], controllers: [CatalogController], providers: [BaseCatalogService, InternetCatalogService, SimCatalogService, VpnCatalogService], exports: [InternetCatalogService, SimCatalogService, VpnCatalogService], diff --git a/apps/bff/src/modules/catalog/services/base-catalog.service.ts b/apps/bff/src/modules/catalog/services/base-catalog.service.ts index d5d967fd..83bb1c4c 100644 --- a/apps/bff/src/modules/catalog/services/base-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/base-catalog.service.ts @@ -1,7 +1,8 @@ import { Injectable, Inject } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { Logger } from "nestjs-pino"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; -import { getSalesforceFieldMap } from "@bff/core/config/field-map"; +import { SalesforceFieldMapService } from "@bff/core/config/field-map"; import { assertSalesforceId, sanitizeSoqlLiteral, @@ -19,14 +20,16 @@ export class BaseCatalogService { constructor( protected readonly sf: SalesforceConnection, + protected readonly fieldMapService: SalesforceFieldMapService, + private readonly configService: ConfigService, @Inject(Logger) protected readonly logger: Logger ) { - const portalPricebook = process.env.PORTAL_PRICEBOOK_ID || "01sTL000008eLVlYAM"; + const portalPricebook = this.configService.get("PORTAL_PRICEBOOK_ID")!; this.portalPriceBookId = assertSalesforceId(portalPricebook, "PORTAL_PRICEBOOK_ID"); } protected getFields() { - return getSalesforceFieldMap(); + return this.fieldMapService.getFieldMap(); } protected async executeQuery( diff --git a/apps/bff/src/modules/catalog/services/internet-catalog.service.ts b/apps/bff/src/modules/catalog/services/internet-catalog.service.ts index 8447543a..090701b6 100644 --- a/apps/bff/src/modules/catalog/services/internet-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/internet-catalog.service.ts @@ -1,4 +1,5 @@ import { Injectable, Inject } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { BaseCatalogService } from "./base-catalog.service"; import type { SalesforceProduct2WithPricebookEntries, @@ -8,6 +9,7 @@ import type { } from "@customer-portal/domain"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; +import { SalesforceFieldMapService } from "@bff/core/config/field-map"; import { Logger } from "nestjs-pino"; import { getErrorMessage } from "@bff/core/utils/error.util"; import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util"; @@ -26,10 +28,12 @@ interface SalesforceAccount { export class InternetCatalogService extends BaseCatalogService { constructor( sf: SalesforceConnection, + fieldMapService: SalesforceFieldMapService, + configService: ConfigService, @Inject(Logger) logger: Logger, private mappingsService: MappingsService ) { - super(sf, logger); + super(sf, fieldMapService, configService, logger); } async getPlans(): Promise { @@ -46,7 +50,7 @@ export class InternetCatalogService extends BaseCatalogService { return records.map(record => { const entry = this.extractPricebookEntry(record); - return mapInternetPlan(record, entry); + return mapInternetPlan(record, fields, entry); }); } @@ -66,7 +70,7 @@ export class InternetCatalogService extends BaseCatalogService { return records .map(record => { const entry = this.extractPricebookEntry(record); - return mapInternetInstallation(record, entry); + return mapInternetInstallation(record, fields, entry); }) .sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0)); } @@ -89,7 +93,7 @@ export class InternetCatalogService extends BaseCatalogService { return records .map(record => { const entry = this.extractPricebookEntry(record); - return mapInternetAddon(record, entry); + return mapInternetAddon(record, fields, entry); }) .sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0)); } diff --git a/apps/bff/src/modules/catalog/services/sim-catalog.service.ts b/apps/bff/src/modules/catalog/services/sim-catalog.service.ts index 295d2cb1..2aac8cee 100644 --- a/apps/bff/src/modules/catalog/services/sim-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/sim-catalog.service.ts @@ -1,5 +1,7 @@ import { Injectable, Inject } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { BaseCatalogService } from "./base-catalog.service"; +import { SalesforceFieldMapService } from "@bff/core/config/field-map"; import type { SalesforceProduct2WithPricebookEntries, SimCatalogProduct, @@ -18,11 +20,13 @@ import { WhmcsConnectionOrchestratorService } from "@bff/integrations/whmcs/conn export class SimCatalogService extends BaseCatalogService { constructor( sf: SalesforceConnection, + fieldMapService: SalesforceFieldMapService, + configService: ConfigService, @Inject(Logger) logger: Logger, private mappingsService: MappingsService, private whmcs: WhmcsConnectionOrchestratorService ) { - super(sf, logger); + super(sf, fieldMapService, configService, logger); } async getPlans(): Promise { @@ -40,7 +44,7 @@ export class SimCatalogService extends BaseCatalogService { return records.map(record => { const entry = this.extractPricebookEntry(record); - const product = mapSimProduct(record, entry); + const product = mapSimProduct(record, fields, entry); return { ...product, @@ -50,6 +54,7 @@ export class SimCatalogService extends BaseCatalogService { } async getActivationFees(): Promise { + const fields = this.getFields(); const soql = this.buildProductQuery("SIM", "Activation", []); const records = await this.executeQuery( soql, @@ -58,7 +63,7 @@ export class SimCatalogService extends BaseCatalogService { return records.map(record => { const entry = this.extractPricebookEntry(record); - return mapSimActivationFee(record, entry); + return mapSimActivationFee(record, fields, entry); }); } @@ -78,7 +83,7 @@ export class SimCatalogService extends BaseCatalogService { return records .map(record => { const entry = this.extractPricebookEntry(record); - const product = mapSimProduct(record, entry); + const product = mapSimProduct(record, fields, entry); return { ...product, diff --git a/apps/bff/src/modules/catalog/services/vpn-catalog.service.ts b/apps/bff/src/modules/catalog/services/vpn-catalog.service.ts index 31a56415..6b1e4eee 100644 --- a/apps/bff/src/modules/catalog/services/vpn-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/vpn-catalog.service.ts @@ -1,4 +1,8 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, Inject } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Logger } from "nestjs-pino"; +import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; +import { SalesforceFieldMapService } from "@bff/core/config/field-map"; import { BaseCatalogService } from "./base-catalog.service"; import type { SalesforceProduct2WithPricebookEntries, @@ -8,6 +12,14 @@ import { mapVpnProduct } from "@bff/modules/catalog/utils/salesforce-product.map @Injectable() export class VpnCatalogService extends BaseCatalogService { + constructor( + sf: SalesforceConnection, + fieldMapService: SalesforceFieldMapService, + configService: ConfigService, + @Inject(Logger) logger: Logger + ) { + super(sf, fieldMapService, configService, logger); + } async getPlans(): Promise { const fields = this.getFields(); const soql = this.buildCatalogServiceQuery("VPN", [ @@ -21,7 +33,7 @@ export class VpnCatalogService extends BaseCatalogService { return records.map(record => { const entry = this.extractPricebookEntry(record); - const product = mapVpnProduct(record, entry); + const product = mapVpnProduct(record, fields, entry); return { ...product, description: product.description || product.name, @@ -39,7 +51,7 @@ export class VpnCatalogService extends BaseCatalogService { return records.map(record => { const pricebookEntry = this.extractPricebookEntry(record); - const product = mapVpnProduct(record, pricebookEntry); + const product = mapVpnProduct(record, fields, pricebookEntry); return { ...product, diff --git a/apps/bff/src/modules/catalog/utils/salesforce-product.mapper.ts b/apps/bff/src/modules/catalog/utils/salesforce-product.mapper.ts index 735a88a6..4f14dc28 100644 --- a/apps/bff/src/modules/catalog/utils/salesforce-product.mapper.ts +++ b/apps/bff/src/modules/catalog/utils/salesforce-product.mapper.ts @@ -12,9 +12,7 @@ import type { SalesforceProduct2WithPricebookEntries, SalesforcePricebookEntryRecord, } from "@customer-portal/domain"; -import { getSalesforceFieldMap } from "@bff/core/config/field-map"; - -const fieldMap = getSalesforceFieldMap(); +import type { SalesforceFieldMap } from "@bff/core/config/field-map"; export type SalesforceCatalogProductRecord = SalesforceProduct2WithPricebookEntries; @@ -86,7 +84,8 @@ function inferInstallationTypeFromSku(sku: string): "One-time" | "12-Month" | "2 function getProductField( product: SalesforceCatalogProductRecord, - fieldKey: keyof typeof fieldMap.product + fieldKey: keyof SalesforceFieldMap["product"], + fieldMap: SalesforceFieldMap ): T | undefined { const salesforceField = fieldMap.product[fieldKey] as keyof SalesforceCatalogProductRecord; const value = product[salesforceField]; @@ -95,9 +94,10 @@ function getProductField( export function getStringField( product: SalesforceCatalogProductRecord, - fieldKey: keyof typeof fieldMap.product + fieldKey: keyof SalesforceFieldMap["product"], + fieldMap: SalesforceFieldMap ): string | undefined { - const value = getProductField(product, fieldKey); + const value = getProductField(product, fieldKey, fieldMap); return typeof value === "string" ? value : undefined; } @@ -110,8 +110,8 @@ function coerceNumber(value: unknown): number | undefined { return undefined; } -function baseProduct(product: SalesforceCatalogProductRecord): CatalogProductBase { - const sku = getStringField(product, "sku") ?? ""; +function baseProduct(product: SalesforceCatalogProductRecord, fieldMap: SalesforceFieldMap): CatalogProductBase { + const sku = getStringField(product, "sku", fieldMap) ?? ""; const base: CatalogProductBase = { id: product.Id, sku, @@ -121,37 +121,42 @@ function baseProduct(product: SalesforceCatalogProductRecord): CatalogProductBas const description = product.Description; if (description) base.description = description; - const billingCycle = getStringField(product, "billingCycle"); + const billingCycle = getStringField(product, "billingCycle", fieldMap); if (billingCycle) base.billingCycle = billingCycle; - const displayOrder = getProductField(product, "displayOrder"); + const displayOrder = getProductField(product, "displayOrder", fieldMap); if (typeof displayOrder === "number") base.displayOrder = displayOrder; return base; } -function getBoolean(product: SalesforceCatalogProductRecord, key: keyof typeof fieldMap.product) { - const value = getProductField(product, key); +function getBoolean( + product: SalesforceCatalogProductRecord, + key: keyof SalesforceFieldMap["product"], + fieldMap: SalesforceFieldMap +) { + const value = getProductField(product, key, fieldMap); return typeof value === "boolean" ? value : undefined; } -function resolveBundledAddonId(product: SalesforceCatalogProductRecord): string | undefined { - const raw = getProductField(product, "bundledAddon"); +function resolveBundledAddonId(product: SalesforceCatalogProductRecord, fieldMap: SalesforceFieldMap): string | undefined { + const raw = getProductField(product, "bundledAddon", fieldMap); return typeof raw === "string" && raw.length > 0 ? raw : undefined; } -function resolveBundledAddon(product: SalesforceCatalogProductRecord) { +function resolveBundledAddon(product: SalesforceCatalogProductRecord, fieldMap: SalesforceFieldMap) { return { - bundledAddonId: resolveBundledAddonId(product), - isBundledAddon: Boolean(getBoolean(product, "isBundledAddon")), + bundledAddonId: resolveBundledAddonId(product, fieldMap), + isBundledAddon: Boolean(getBoolean(product, "isBundledAddon", fieldMap)), }; } function derivePrices( product: SalesforceCatalogProductRecord, + fieldMap: SalesforceFieldMap, pricebookEntry?: SalesforcePricebookEntryRecord ): Pick { - const billingCycle = getStringField(product, "billingCycle")?.toLowerCase(); + const billingCycle = getStringField(product, "billingCycle", fieldMap)?.toLowerCase(); const unitPrice = coerceNumber(pricebookEntry?.UnitPrice); let monthlyPrice: number | undefined; @@ -173,12 +178,13 @@ function derivePrices( export function mapInternetPlan( product: SalesforceCatalogProductRecord, + fieldMap: SalesforceFieldMap, pricebookEntry?: SalesforcePricebookEntryRecord ): InternetPlanCatalogItem { - const base = baseProduct(product); - const prices = derivePrices(product, pricebookEntry); - const tier = getStringField(product, "internetPlanTier"); - const offeringType = getStringField(product, "internetOfferingType"); + const base = baseProduct(product, fieldMap); + const prices = derivePrices(product, fieldMap, pricebookEntry); + const tier = getStringField(product, "internetPlanTier", fieldMap); + const offeringType = getStringField(product, "internetOfferingType", fieldMap); const tierData = getTierTemplate(tier); @@ -200,10 +206,11 @@ export function mapInternetPlan( export function mapInternetInstallation( product: SalesforceCatalogProductRecord, + fieldMap: SalesforceFieldMap, pricebookEntry?: SalesforcePricebookEntryRecord ): InternetInstallationCatalogItem { - const base = baseProduct(product); - const prices = derivePrices(product, pricebookEntry); + const base = baseProduct(product, fieldMap); + const prices = derivePrices(product, fieldMap, pricebookEntry); return { ...base, @@ -216,11 +223,12 @@ export function mapInternetInstallation( export function mapInternetAddon( product: SalesforceCatalogProductRecord, + fieldMap: SalesforceFieldMap, pricebookEntry?: SalesforcePricebookEntryRecord ): InternetAddonCatalogItem { - const base = baseProduct(product); - const prices = derivePrices(product, pricebookEntry); - const { bundledAddonId, isBundledAddon } = resolveBundledAddon(product); + const base = baseProduct(product, fieldMap); + const prices = derivePrices(product, fieldMap, pricebookEntry); + const { bundledAddonId, isBundledAddon } = resolveBundledAddon(product, fieldMap); return { ...base, @@ -232,14 +240,15 @@ export function mapInternetAddon( export function mapSimProduct( product: SalesforceCatalogProductRecord, + fieldMap: SalesforceFieldMap, pricebookEntry?: SalesforcePricebookEntryRecord ): SimCatalogProduct { - const base = baseProduct(product); - const prices = derivePrices(product, pricebookEntry); - const dataSize = getStringField(product, "simDataSize"); - const planType = getStringField(product, "simPlanType"); - const hasFamilyDiscount = getBoolean(product, "simHasFamilyDiscount"); - const { bundledAddonId, isBundledAddon } = resolveBundledAddon(product); + const base = baseProduct(product, fieldMap); + const prices = derivePrices(product, fieldMap, pricebookEntry); + const dataSize = getStringField(product, "simDataSize", fieldMap); + const planType = getStringField(product, "simPlanType", fieldMap); + const hasFamilyDiscount = getBoolean(product, "simHasFamilyDiscount", fieldMap); + const { bundledAddonId, isBundledAddon } = resolveBundledAddon(product, fieldMap); return { ...base, @@ -254,9 +263,10 @@ export function mapSimProduct( export function mapSimActivationFee( product: SalesforceCatalogProductRecord, + fieldMap: SalesforceFieldMap, pricebookEntry?: SalesforcePricebookEntryRecord ): SimActivationFeeCatalogItem { - const simProduct = mapSimProduct(product, pricebookEntry); + const simProduct = mapSimProduct(product, fieldMap, pricebookEntry); return { ...simProduct, @@ -268,11 +278,12 @@ export function mapSimActivationFee( export function mapVpnProduct( product: SalesforceCatalogProductRecord, + fieldMap: SalesforceFieldMap, pricebookEntry?: SalesforcePricebookEntryRecord ): VpnCatalogProduct { - const base = baseProduct(product); - const prices = derivePrices(product, pricebookEntry); - const vpnRegion = getStringField(product, "vpnRegion"); + const base = baseProduct(product, fieldMap); + const prices = derivePrices(product, fieldMap, pricebookEntry); + const vpnRegion = getStringField(product, "vpnRegion", fieldMap); return { ...base, diff --git a/apps/bff/src/modules/orders/orders.module.ts b/apps/bff/src/modules/orders/orders.module.ts index 3ea70c2e..b2a7b8b0 100644 --- a/apps/bff/src/modules/orders/orders.module.ts +++ b/apps/bff/src/modules/orders/orders.module.ts @@ -3,6 +3,8 @@ import { OrdersController } from "./orders.controller"; import { IntegrationsModule } from "@bff/integrations/integrations.module"; import { MappingsModule } from "@bff/modules/id-mappings/mappings.module"; import { UsersModule } from "@bff/modules/users/users.module"; +import { CoreConfigModule } from "@bff/core/config/config.module"; +import { DatabaseModule } from "@bff/core/database/database.module"; // Clean modular order services import { OrderValidator } from "./services/order-validator.service"; @@ -21,7 +23,7 @@ import { ProvisioningQueueService } from "./queue/provisioning.queue"; import { ProvisioningProcessor } from "./queue/provisioning.processor"; @Module({ - imports: [IntegrationsModule, MappingsModule, UsersModule], + imports: [IntegrationsModule, MappingsModule, UsersModule, CoreConfigModule, DatabaseModule], controllers: [OrdersController], providers: [ // Order creation services (modular) diff --git a/apps/bff/src/modules/orders/queue/provisioning.processor.ts b/apps/bff/src/modules/orders/queue/provisioning.processor.ts index afffa845..bb84713d 100644 --- a/apps/bff/src/modules/orders/queue/provisioning.processor.ts +++ b/apps/bff/src/modules/orders/queue/provisioning.processor.ts @@ -3,7 +3,7 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { OrderFulfillmentOrchestrator } from "../services/order-fulfillment-orchestrator.service"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service"; -import { getSalesforceFieldMap } from "@bff/core/config/field-map"; +import { SalesforceFieldMapService } from "@bff/core/config/field-map"; import type { ProvisioningJobData } from "./provisioning.queue"; import { CacheService } from "@bff/infra/cache/cache.service"; import { ConfigService } from "@nestjs/config"; @@ -16,6 +16,7 @@ export class ProvisioningProcessor extends WorkerHost { constructor( private readonly orchestrator: OrderFulfillmentOrchestrator, private readonly salesforceService: SalesforceService, + private readonly fieldMapService: SalesforceFieldMapService, private readonly cache: CacheService, private readonly config: ConfigService, @Inject(Logger) private readonly logger: Logger @@ -33,7 +34,7 @@ export class ProvisioningProcessor extends WorkerHost { }); // Guard: Only process if Salesforce Order is currently 'Activating' - const fields = getSalesforceFieldMap(); + const fields = this.fieldMapService.getFieldMap(); const order = await this.salesforceService.getOrder(sfOrderId); const status = order ? ((Reflect.get(order, fields.order.activationStatus) as string | undefined) ?? "") 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 926bf0f9..4922cf2b 100644 --- a/apps/bff/src/modules/orders/services/order-builder.service.ts +++ b/apps/bff/src/modules/orders/services/order-builder.service.ts @@ -1,10 +1,8 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import type { OrderBusinessValidation, UserMapping } from "@customer-portal/domain"; -import { getSalesforceFieldMap } from "@bff/core/config/field-map"; +import { SalesforceFieldMapService, type SalesforceFieldMap } from "@bff/core/config/field-map"; import { UsersService } from "@bff/modules/users/users.service"; - -const fieldMap = getSalesforceFieldMap(); type OrderBuilderFieldKey = | "orderType" | "activationType" @@ -21,7 +19,7 @@ function assignIfString(target: Record, key: string, value: unk } } -function orderField(key: OrderBuilderFieldKey): string { +function orderField(key: OrderBuilderFieldKey, fieldMap: SalesforceFieldMap): string { const fieldName = fieldMap.order[key]; if (typeof fieldName !== "string") { throw new Error(`Missing Salesforce order field mapping for key ${String(key)}`); @@ -29,16 +27,16 @@ function orderField(key: OrderBuilderFieldKey): string { return fieldName; } -function mnpField(key: keyof typeof fieldMap.order.mnp): string { - const fieldName = fieldMap.order.mnp[key]; +function mnpField(key: string, fieldMap: SalesforceFieldMap): string { + const fieldName = (fieldMap.order.mnp as Record)[key]; if (typeof fieldName !== "string") { throw new Error(`Missing Salesforce order MNP field mapping for key ${String(key)}`); } return fieldName; } -function billingField(key: keyof typeof fieldMap.order.billing): string { - const fieldName = fieldMap.order.billing[key]; +function billingField(key: string, fieldMap: SalesforceFieldMap): string { + const fieldName = (fieldMap.order.billing as Record)[key]; if (typeof fieldName !== "string") { throw new Error(`Missing Salesforce order billing field mapping for key ${String(key)}`); } @@ -49,6 +47,7 @@ function billingField(key: keyof typeof fieldMap.order.billing): string { export class OrderBuilder { constructor( @Inject(Logger) private readonly logger: Logger, + private readonly fieldMapService: SalesforceFieldMapService, private readonly usersService: UsersService ) {} @@ -58,6 +57,7 @@ export class OrderBuilder { pricebookId: string, userId: string ): Promise> { + const fieldMap = this.fieldMapService.getFieldMap(); const today = new Date().toISOString().slice(0, 10); const orderFields: Record = { @@ -65,79 +65,82 @@ export class OrderBuilder { EffectiveDate: today, Status: "Pending Review", Pricebook2Id: pricebookId, - [orderField("orderType")]: body.orderType, + [orderField("orderType", fieldMap)]: body.orderType, ...(body.opportunityId ? { OpportunityId: body.opportunityId } : {}), }; - this.addActivationFields(orderFields, body); + this.addActivationFields(orderFields, body, fieldMap); switch (body.orderType) { case "Internet": - this.addInternetFields(orderFields, body); + this.addInternetFields(orderFields, body, fieldMap); break; case "SIM": - this.addSimFields(orderFields, body); + this.addSimFields(orderFields, body, fieldMap); break; case "VPN": - this.addVpnFields(orderFields, body); + this.addVpnFields(orderFields, body, fieldMap); break; } - await this.addAddressSnapshot(orderFields, userId, body); + await this.addAddressSnapshot(orderFields, userId, body, fieldMap); return orderFields; } private addActivationFields( orderFields: Record, - body: OrderBusinessValidation + body: OrderBusinessValidation, + fieldMap: SalesforceFieldMap ): void { const config = body.configurations || {}; - assignIfString(orderFields, orderField("activationType"), config.activationType); - assignIfString(orderFields, orderField("activationScheduledAt"), config.scheduledAt); - orderFields[orderField("activationStatus")] = "Not Started"; + assignIfString(orderFields, orderField("activationType", fieldMap), config.activationType); + assignIfString(orderFields, orderField("activationScheduledAt", fieldMap), config.scheduledAt); + orderFields[orderField("activationStatus", fieldMap)] = "Not Started"; } private addInternetFields( orderFields: Record, - body: OrderBusinessValidation + body: OrderBusinessValidation, + fieldMap: SalesforceFieldMap ): void { const config = body.configurations || {}; - assignIfString(orderFields, orderField("accessMode"), config.accessMode); + assignIfString(orderFields, orderField("accessMode", fieldMap), config.accessMode); } - private addSimFields(orderFields: Record, body: OrderBusinessValidation): void { + private addSimFields(orderFields: Record, body: OrderBusinessValidation, fieldMap: SalesforceFieldMap): void { const config = body.configurations || {}; - assignIfString(orderFields, orderField("simType"), config.simType); - assignIfString(orderFields, orderField("eid"), config.eid); + assignIfString(orderFields, orderField("simType", fieldMap), config.simType); + assignIfString(orderFields, orderField("eid", fieldMap), config.eid); if (config.isMnp === "true") { - orderFields[mnpField("application")] = true; - assignIfString(orderFields, mnpField("reservationNumber"), config.mnpNumber); - assignIfString(orderFields, mnpField("expiryDate"), config.mnpExpiry); - assignIfString(orderFields, mnpField("phoneNumber"), config.mnpPhone); - assignIfString(orderFields, mnpField("mvnoAccountNumber"), config.mvnoAccountNumber); - assignIfString(orderFields, mnpField("portingLastName"), config.portingLastName); - assignIfString(orderFields, mnpField("portingFirstName"), config.portingFirstName); + orderFields[mnpField("application", fieldMap)] = true; + assignIfString(orderFields, mnpField("reservationNumber", fieldMap), config.mnpNumber); + assignIfString(orderFields, mnpField("expiryDate", fieldMap), config.mnpExpiry); + assignIfString(orderFields, mnpField("phoneNumber", fieldMap), config.mnpPhone); + assignIfString(orderFields, mnpField("mvnoAccountNumber", fieldMap), config.mvnoAccountNumber); + assignIfString(orderFields, mnpField("portingLastName", fieldMap), config.portingLastName); + assignIfString(orderFields, mnpField("portingFirstName", fieldMap), config.portingFirstName); assignIfString( orderFields, - mnpField("portingLastNameKatakana"), + mnpField("portingLastNameKatakana", fieldMap), config.portingLastNameKatakana ); assignIfString( orderFields, - mnpField("portingFirstNameKatakana"), + mnpField("portingFirstNameKatakana", fieldMap), config.portingFirstNameKatakana ); - assignIfString(orderFields, mnpField("portingGender"), config.portingGender); - assignIfString(orderFields, mnpField("portingDateOfBirth"), config.portingDateOfBirth); + assignIfString(orderFields, mnpField("portingGender", fieldMap), config.portingGender); + assignIfString(orderFields, mnpField("portingDateOfBirth", fieldMap), config.portingDateOfBirth); } } private addVpnFields( _orderFields: Record, - _body: OrderBusinessValidation + _body: OrderBusinessValidation, + _fieldMap: SalesforceFieldMap ): void { // No additional fields for VPN orders at this time. } @@ -145,7 +148,8 @@ export class OrderBuilder { private async addAddressSnapshot( orderFields: Record, userId: string, - body: OrderBusinessValidation + body: OrderBusinessValidation, + fieldMap: SalesforceFieldMap ): Promise { try { const address = await this.usersService.getAddress(userId); @@ -160,17 +164,17 @@ export class OrderBuilder { typeof addressToUse?.streetLine2 === "string" ? addressToUse.streetLine2 : ""; const fullStreet = [street, streetLine2].filter(Boolean).join(", "); - orderFields[billingField("street")] = fullStreet; - orderFields[billingField("city")] = + orderFields[billingField("street", fieldMap)] = fullStreet; + orderFields[billingField("city", fieldMap)] = typeof addressToUse?.city === "string" ? addressToUse.city : ""; - orderFields[billingField("state")] = + orderFields[billingField("state", fieldMap)] = typeof addressToUse?.state === "string" ? addressToUse.state : ""; - orderFields[billingField("postalCode")] = + orderFields[billingField("postalCode", fieldMap)] = typeof addressToUse?.postalCode === "string" ? addressToUse.postalCode : ""; - orderFields[billingField("country")] = + orderFields[billingField("country", fieldMap)] = typeof addressToUse?.country === "string" ? addressToUse.country : ""; - orderFields[orderField("addressChanged")] = addressChanged; + orderFields[orderField("addressChanged", fieldMap)] = addressChanged; if (addressChanged) { this.logger.log({ userId }, "Customer updated address during checkout"); 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 a5921dfa..b9464ec2 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 @@ -13,8 +13,9 @@ import { import { OrderWhmcsMapper, OrderItemMappingResult } from "./order-whmcs-mapper.service"; import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service"; import { SimFulfillmentService } from "./sim-fulfillment.service"; +import { DistributedTransactionService } from "@bff/core/database/services/distributed-transaction.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; -import { getSalesforceFieldMap } from "@bff/core/config/field-map"; +import { SalesforceFieldMapService } from "@bff/core/config/field-map"; import type { OrderDetailsResponse } from "@customer-portal/domain"; import type { FulfillmentOrderDetails, FulfillmentOrderItem } from "../types/fulfillment.types"; @@ -44,22 +45,261 @@ export interface OrderFulfillmentContext { export class OrderFulfillmentOrchestrator { constructor( @Inject(Logger) private readonly logger: Logger, + private readonly fieldMapService: SalesforceFieldMapService, private readonly salesforceService: SalesforceService, private readonly whmcsOrderService: WhmcsOrderService, private readonly orderOrchestrator: OrderOrchestrator, private readonly orderFulfillmentValidator: OrderFulfillmentValidator, private readonly orderWhmcsMapper: OrderWhmcsMapper, private readonly orderFulfillmentErrorService: OrderFulfillmentErrorService, - private readonly simFulfillmentService: SimFulfillmentService + private readonly simFulfillmentService: SimFulfillmentService, + private readonly distributedTransactionService: DistributedTransactionService ) {} /** - * Execute complete fulfillment workflow + * Execute complete fulfillment workflow with distributed transaction support */ async executeFulfillment( sfOrderId: string, payload: Record, idempotencyKey: string + ): Promise { + return this.executeFulfillmentWithTransactions(sfOrderId, payload, idempotencyKey); + } + + /** + * Execute fulfillment workflow using distributed transactions for atomicity + */ + private async executeFulfillmentWithTransactions( + sfOrderId: string, + payload: Record, + idempotencyKey: string + ): Promise { + const context: OrderFulfillmentContext = { + sfOrderId, + idempotencyKey, + validation: null, + steps: this.initializeSteps( + typeof payload.orderType === "string" ? payload.orderType : "Unknown" + ), + }; + + this.logger.log("Starting transactional fulfillment orchestration", { + sfOrderId, + idempotencyKey, + }); + + // Step 1: Validation (no rollback needed) + try { + context.validation = await this.orderFulfillmentValidator.validateFulfillmentRequest( + sfOrderId, + idempotencyKey + ); + + if (context.validation.isAlreadyProvisioned) { + this.logger.log("Order already provisioned, skipping fulfillment", { sfOrderId }); + return context; + } + } catch (error) { + this.logger.error("Fulfillment validation failed", { + sfOrderId, + error: getErrorMessage(error) + }); + throw error; + } + + // Step 2: Get order details (no rollback needed) + try { + const orderDetails = await this.orderOrchestrator.getOrder(sfOrderId); + if (!orderDetails) { + throw new Error("Order details could not be retrieved."); + } + context.orderDetails = this.mapOrderDetails(orderDetails); + } catch (error) { + this.logger.error("Failed to get order details", { + sfOrderId, + error: getErrorMessage(error) + }); + throw error; + } + + // Step 3: Execute the main fulfillment workflow as a distributed transaction + const fulfillmentResult = await this.distributedTransactionService.executeDistributedTransaction([ + { + id: 'sf_status_update', + description: 'Update Salesforce order status to Activating', + execute: async () => { + const fields = this.fieldMapService.getFieldMap(); + return await this.salesforceService.updateOrder({ + Id: sfOrderId, + [fields.order.activationStatus]: "Activating", + }); + }, + rollback: async () => { + const fields = this.fieldMapService.getFieldMap(); + await this.salesforceService.updateOrder({ + Id: sfOrderId, + [fields.order.activationStatus]: "Failed", + }); + }, + critical: true + }, + { + id: 'mapping', + description: 'Map OrderItems to WHMCS format', + execute: async () => { + if (!context.orderDetails) { + throw new Error("Order details are required for mapping"); + } + return this.orderWhmcsMapper.mapOrderItemsToWhmcs( + context.orderDetails.items + ); + }, + critical: true + }, + { + id: 'whmcs_create', + description: 'Create order in WHMCS', + execute: async () => { + const mappingResult = fulfillmentResult.stepResults?.mapping; + if (!mappingResult) { + throw new Error("Mapping result is not available"); + } + + const orderNotes = this.orderWhmcsMapper.createOrderNotes( + sfOrderId, + `Provisioned from Salesforce Order ${sfOrderId}` + ); + + return await this.whmcsOrderService.addOrder({ + clientId: context.validation!.clientId, + items: mappingResult.whmcsItems, + paymentMethod: "stripe", + promoCode: "1st Month Free (Monthly Plan)", + sfOrderId, + notes: orderNotes, + noinvoiceemail: true, + noemail: true, + }); + }, + rollback: async () => { + const createResult = fulfillmentResult.stepResults?.whmcs_create; + if (createResult?.orderId) { + // Note: WHMCS doesn't have an automated cancel API + // Manual intervention required for order cleanup + this.logger.error("WHMCS order created but fulfillment failed - manual cleanup required", { + orderId: createResult.orderId, + sfOrderId, + action: "MANUAL_CLEANUP_REQUIRED" + }); + } + }, + critical: true + }, + { + id: 'whmcs_accept', + description: 'Accept/provision order in WHMCS', + execute: async () => { + const createResult = fulfillmentResult.stepResults?.whmcs_create; + if (!createResult?.orderId) { + throw new Error("WHMCS order ID missing before acceptance step"); + } + + return await this.whmcsOrderService.acceptOrder( + createResult.orderId, + sfOrderId + ); + }, + rollback: async () => { + const acceptResult = fulfillmentResult.stepResults?.whmcs_accept; + if (acceptResult?.orderId) { + // Note: WHMCS doesn't have an automated cancel API for accepted orders + // Manual intervention required for service termination + this.logger.error("WHMCS order accepted but fulfillment failed - manual cleanup required", { + orderId: acceptResult.orderId, + serviceIds: acceptResult.serviceIds, + sfOrderId, + action: "MANUAL_SERVICE_TERMINATION_REQUIRED" + }); + } + }, + critical: true + }, + { + id: 'sim_fulfillment', + description: 'SIM-specific fulfillment (if applicable)', + execute: async () => { + if (context.orderDetails?.orderType === "SIM") { + const configurations = this.extractConfigurations(payload.configurations); + await this.simFulfillmentService.fulfillSimOrder({ + orderDetails: context.orderDetails, + configurations, + }); + return { completed: true }; + } + return { skipped: true }; + }, + critical: false // SIM fulfillment failure shouldn't rollback the entire order + }, + { + id: 'sf_success_update', + description: 'Update Salesforce with success', + execute: async () => { + const fields = this.fieldMapService.getFieldMap(); + const whmcsResult = fulfillmentResult.stepResults?.whmcs_accept; + + return await this.salesforceService.updateOrder({ + Id: sfOrderId, + Status: "Completed", + [fields.order.activationStatus]: "Activated", + [fields.order.whmcsOrderId]: whmcsResult?.orderId?.toString(), + }); + }, + rollback: async () => { + const fields = this.fieldMapService.getFieldMap(); + await this.salesforceService.updateOrder({ + Id: sfOrderId, + [fields.order.activationStatus]: "Failed", + }); + }, + critical: true + } + ], { + description: `Order fulfillment for ${sfOrderId}`, + timeout: 300000, // 5 minutes + continueOnNonCriticalFailure: true + }); + + if (!fulfillmentResult.success) { + this.logger.error("Fulfillment transaction failed", { + sfOrderId, + error: fulfillmentResult.error, + stepsExecuted: fulfillmentResult.stepsExecuted, + stepsRolledBack: fulfillmentResult.stepsRolledBack + }); + throw new Error(fulfillmentResult.error || "Fulfillment transaction failed"); + } + + // Update context with results + context.mappingResult = fulfillmentResult.stepResults?.mapping; + context.whmcsResult = fulfillmentResult.stepResults?.whmcs_accept; + + this.logger.log("Transactional fulfillment completed successfully", { + sfOrderId, + stepsExecuted: fulfillmentResult.stepsExecuted, + duration: fulfillmentResult.duration + }); + + return context; + } + + /** + * Legacy fulfillment method (kept for backward compatibility) + */ + private async executeFulfillmentLegacy( + sfOrderId: string, + payload: Record, + idempotencyKey: string ): Promise { const context: OrderFulfillmentContext = { sfOrderId, @@ -104,7 +344,7 @@ export class OrderFulfillmentOrchestrator { // Step 2: Update Salesforce status to "Activating" await this.executeStep(context, "sf_status_update", async () => { - const fields = getSalesforceFieldMap(); + const fields = this.fieldMapService.getFieldMap(); await this.salesforceService.updateOrder({ Id: sfOrderId, [fields.order.activationStatus]: "Activating", @@ -206,7 +446,7 @@ export class OrderFulfillmentOrchestrator { // Step 8: Update Salesforce with success await this.executeStep(context, "sf_success_update", async () => { - const fields = getSalesforceFieldMap(); + const fields = this.fieldMapService.getFieldMap(); await this.salesforceService.updateOrder({ Id: sfOrderId, Status: "Completed", @@ -401,7 +641,7 @@ export class OrderFulfillmentOrchestrator { ): Promise { const errorCode = this.orderFulfillmentErrorService.determineErrorCode(error); const userMessage = error.message; - const fields = getSalesforceFieldMap(); + const fields = this.fieldMapService.getFieldMap(); this.logger.error("Fulfillment orchestration failed", { sfOrderId: context.sfOrderId, 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 146a4766..9130b043 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 @@ -5,9 +5,7 @@ import { WhmcsPaymentService } from "@bff/integrations/whmcs/services/whmcs-paym import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; import type { SalesforceOrderRecord } from "@customer-portal/domain"; -import { getSalesforceFieldMap } from "@bff/core/config/field-map"; - -const fieldMap = getSalesforceFieldMap(); +import { SalesforceFieldMapService, type SalesforceFieldMap } from "@bff/core/config/field-map"; type OrderStringFieldKey = "activationStatus"; export interface OrderFulfillmentValidationResult { @@ -25,6 +23,7 @@ export interface OrderFulfillmentValidationResult { export class OrderFulfillmentValidator { constructor( @Inject(Logger) private readonly logger: Logger, + private readonly fieldMapService: SalesforceFieldMapService, private readonly salesforceService: SalesforceService, private readonly whmcsPaymentService: WhmcsPaymentService, private readonly mappingsService: MappingsService @@ -48,6 +47,7 @@ export class OrderFulfillmentValidator { const sfOrder = await this.validateSalesforceOrder(sfOrderId); // 2. Check if already provisioned (idempotency) + const fieldMap = this.fieldMapService.getFieldMap(); const rawWhmcs = Reflect.get(sfOrder, fieldMap.order.whmcsOrderId) as unknown; const existingWhmcsOrderId = typeof rawWhmcs === "string" ? rawWhmcs : undefined; if (existingWhmcsOrderId) { @@ -113,10 +113,11 @@ export class OrderFulfillmentValidator { throw new BadRequestException(`Cannot provision cancelled order ${sfOrderId}`); } + const fieldMap = this.fieldMapService.getFieldMap(); this.logger.log("Salesforce order validated", { sfOrderId, status: order.Status, - activationStatus: pickOrderString(order, "activationStatus"), + activationStatus: pickOrderString(order, "activationStatus", fieldMap), accountId: order.AccountId, }); @@ -158,7 +159,8 @@ export class OrderFulfillmentValidator { function pickOrderString( order: SalesforceOrderRecord, - key: OrderStringFieldKey + key: OrderStringFieldKey, + fieldMap: SalesforceFieldMap ): string | undefined { const field = fieldMap.order[key]; if (typeof field !== "string") { 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 a3ea911b..f643bc4f 100644 --- a/apps/bff/src/modules/orders/services/order-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-orchestrator.service.ts @@ -14,15 +14,11 @@ import { type SalesforceQueryResult, type SalesforceProduct2Record, } from "@customer-portal/domain"; -import { - getSalesforceFieldMap, - getOrderQueryFields, - getOrderItemProduct2Select, -} from "@bff/core/config/field-map"; +import { SalesforceFieldMapService, type SalesforceFieldMap } from "@bff/core/config/field-map"; import { assertSalesforceId, buildInClause } from "@bff/integrations/salesforce/utils/soql.util"; import { getErrorMessage } from "@bff/core/utils/error.util"; -const fieldMap = getSalesforceFieldMap(); +// fieldMap will be injected via service type OrderFieldKey = | "orderType" | "activationType" @@ -33,7 +29,11 @@ type OrderFieldKey = type OrderDetailsResponse = z.infer; type OrderSummaryResponse = z.infer; -function getOrderStringField(order: SalesforceOrderRecord, key: OrderFieldKey): string | undefined { +function getOrderStringField( + order: SalesforceOrderRecord, + key: OrderFieldKey, + fieldMap: SalesforceFieldMap +): string | undefined { const fieldName = fieldMap.order[key]; if (typeof fieldName !== "string") { return undefined; @@ -44,7 +44,8 @@ function getOrderStringField(order: SalesforceOrderRecord, key: OrderFieldKey): function pickProductString( product: SalesforceProduct2Record | null | undefined, - key: keyof typeof fieldMap.product + key: keyof SalesforceFieldMap["product"], + fieldMap: SalesforceFieldMap ): string | undefined { if (!product) return undefined; const fieldName = fieldMap.product[key] as keyof SalesforceProduct2Record; @@ -52,7 +53,10 @@ function pickProductString( return typeof raw === "string" ? raw : undefined; } -function mapOrderItemRecord(record: SalesforceOrderItemRecord): ParsedOrderItemDetails { +function mapOrderItemRecord( + record: SalesforceOrderItemRecord, + fieldMap: SalesforceFieldMap +): ParsedOrderItemDetails { const product = record.PricebookEntry?.Product2 ?? undefined; return { @@ -65,12 +69,12 @@ function mapOrderItemRecord(record: SalesforceOrderItemRecord): ParsedOrderItemD product: { id: product?.Id, name: product?.Name, - sku: pickProductString(product, "sku"), - itemClass: pickProductString(product, "itemClass"), - whmcsProductId: pickProductString(product, "whmcsProductId"), - internetOfferingType: pickProductString(product, "internetOfferingType"), - internetPlanTier: pickProductString(product, "internetPlanTier"), - vpnRegion: pickProductString(product, "vpnRegion"), + sku: pickProductString(product, "sku", fieldMap), + itemClass: pickProductString(product, "itemClass", fieldMap), + whmcsProductId: pickProductString(product, "whmcsProductId", fieldMap), + internetOfferingType: pickProductString(product, "internetOfferingType", fieldMap), + internetPlanTier: pickProductString(product, "internetPlanTier", fieldMap), + vpnRegion: pickProductString(product, "vpnRegion", fieldMap), }, }; } @@ -115,6 +119,7 @@ export class OrderOrchestrator { constructor( @Inject(Logger) private readonly logger: Logger, private readonly sf: SalesforceConnection, + private readonly fieldMapService: SalesforceFieldMapService, private readonly orderValidator: OrderValidator, private readonly orderBuilder: OrderBuilder, private readonly orderItemBuilder: OrderItemBuilder @@ -194,8 +199,12 @@ export class OrderOrchestrator { const safeOrderId = assertSalesforceId(orderId, "orderId"); this.logger.log({ orderId: safeOrderId }, "Fetching order details with items"); + const fieldMap = this.fieldMapService.getFieldMap(); + const orderQueryFields = this.fieldMapService.getOrderQueryFields(); + const orderItemProduct2Select = this.fieldMapService.getOrderItemProduct2Select(); + const orderSoql = ` - SELECT ${getOrderQueryFields()}, OrderNumber, TotalAmount, + SELECT ${orderQueryFields}, OrderNumber, TotalAmount, Account.Name, CreatedDate, LastModifiedDate FROM Order WHERE Id = '${safeOrderId}' @@ -205,7 +214,7 @@ export class OrderOrchestrator { const orderItemsSoql = ` SELECT Id, OrderId, Quantity, UnitPrice, TotalPrice, PricebookEntry.Id, - ${getOrderItemProduct2Select()} + ${orderItemProduct2Select} FROM OrderItem WHERE OrderId = '${safeOrderId}' ORDER BY CreatedDate ASC @@ -224,7 +233,9 @@ export class OrderOrchestrator { return null; } - const orderItems = (itemsResult.records ?? []).map(mapOrderItemRecord); + const orderItems = (itemsResult.records ?? []).map(record => + mapOrderItemRecord(record, fieldMap) + ); this.logger.log( { orderId: safeOrderId, itemCount: orderItems.length }, @@ -236,16 +247,16 @@ export class OrderOrchestrator { orderNumber: order.OrderNumber, status: order.Status, accountId: order.AccountId, - orderType: getOrderStringField(order, "orderType") ?? order.Type, + orderType: getOrderStringField(order, "orderType", fieldMap) ?? order.Type, effectiveDate: order.EffectiveDate, totalAmount: order.TotalAmount ?? 0, accountName: order.Account?.Name, createdDate: order.CreatedDate, lastModifiedDate: order.LastModifiedDate, - activationType: getOrderStringField(order, "activationType"), - activationStatus: getOrderStringField(order, "activationStatus"), - scheduledAt: getOrderStringField(order, "activationScheduledAt"), - whmcsOrderId: getOrderStringField(order, "whmcsOrderId"), + activationType: getOrderStringField(order, "activationType", fieldMap), + activationStatus: getOrderStringField(order, "activationStatus", fieldMap), + scheduledAt: getOrderStringField(order, "activationScheduledAt", fieldMap), + whmcsOrderId: getOrderStringField(order, "whmcsOrderId", fieldMap), items: orderItems.map(detail => ({ id: detail.id, orderId: detail.orderId, @@ -291,8 +302,12 @@ export class OrderOrchestrator { return []; } + const fieldMap = this.fieldMapService.getFieldMap(); + const orderQueryFields = this.fieldMapService.getOrderQueryFields(); + const orderItemProduct2Select = this.fieldMapService.getOrderItemProduct2Select(); + const ordersSoql = ` - SELECT ${getOrderQueryFields()}, OrderNumber, TotalAmount, CreatedDate, LastModifiedDate + SELECT ${orderQueryFields}, OrderNumber, TotalAmount, CreatedDate, LastModifiedDate FROM Order WHERE AccountId = '${sfAccountId}' ORDER BY CreatedDate DESC @@ -321,7 +336,7 @@ export class OrderOrchestrator { const orderIdsClause = buildInClause(rawOrderIds, "orderIds"); const itemsSoql = ` SELECT Id, OrderId, Quantity, UnitPrice, TotalPrice, - ${getOrderItemProduct2Select()} + ${orderItemProduct2Select} FROM OrderItem WHERE OrderId IN (${orderIdsClause}) ORDER BY OrderId, CreatedDate ASC @@ -333,7 +348,7 @@ export class OrderOrchestrator { const allItems = itemsResult.records || []; const itemsByOrder = allItems.reduce>((acc, record) => { - const details = mapOrderItemRecord(record); + const details = mapOrderItemRecord(record, fieldMap); if (!acc[details.orderId]) acc[details.orderId] = []; acc[details.orderId].push(toOrderItemSummary(details)); return acc; @@ -345,12 +360,12 @@ export class OrderOrchestrator { id: order.Id, orderNumber: order.OrderNumber, status: order.Status, - orderType: getOrderStringField(order, "orderType") ?? order.Type, + orderType: getOrderStringField(order, "orderType", fieldMap) ?? order.Type, effectiveDate: order.EffectiveDate, totalAmount: order.TotalAmount ?? 0, createdDate: order.CreatedDate, lastModifiedDate: order.LastModifiedDate, - whmcsOrderId: getOrderStringField(order, "whmcsOrderId"), + whmcsOrderId: getOrderStringField(order, "whmcsOrderId", fieldMap), itemsSummary: itemsByOrder[order.Id] ?? [], }) ); 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 8c455d45..bab2a5b4 100644 --- a/apps/bff/src/modules/orders/services/order-pricebook.service.ts +++ b/apps/bff/src/modules/orders/services/order-pricebook.service.ts @@ -1,7 +1,8 @@ import { Inject, Injectable, NotFoundException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { Logger } from "nestjs-pino"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; -import { getSalesforceFieldMap } from "@bff/core/config/field-map"; +import { SalesforceFieldMapService } from "@bff/core/config/field-map"; import { getStringField } from "@bff/modules/catalog/utils/salesforce-product.mapper"; import type { SalesforcePricebookEntryRecord, @@ -31,11 +32,13 @@ export class OrderPricebookService { constructor( private readonly sf: SalesforceConnection, + private readonly fieldMapService: SalesforceFieldMapService, + private readonly configService: ConfigService, @Inject(Logger) private readonly logger: Logger ) {} async findPortalPricebookId(): Promise { - const name = process.env.PORTAL_PRICEBOOK_NAME || "Portal"; + const name = this.configService.get("PORTAL_PRICEBOOK_NAME")!; const soql = `SELECT Id, Name FROM Pricebook2 WHERE IsActive = true AND Name LIKE '%${sanitizeSoqlLiteral(name)}%' LIMIT 1`; try { @@ -77,7 +80,7 @@ export class OrderPricebookService { return new Map(); } - const fields = getSalesforceFieldMap(); + const fields = this.fieldMapService.getFieldMap(); const meta = new Map(); for (let i = 0; i < uniqueSkus.length; i += this.chunkSize) { @@ -100,7 +103,7 @@ export class OrderPricebookService { for (const record of res.records ?? []) { const product = record.Product2 ?? undefined; - const sku = product ? getStringField(product, "sku") : undefined; + const sku = product ? getStringField(product, "sku", fields) : undefined; if (!sku) continue; const normalizedSku = sku.trim().toUpperCase(); @@ -109,12 +112,12 @@ export class OrderPricebookService { pricebookEntryId: assertSalesforceId(record.Id ?? undefined, "pricebookEntryId"), product2Id: record.Product2Id ?? undefined, unitPrice: typeof record.UnitPrice === "number" ? record.UnitPrice : undefined, - itemClass: product ? getStringField(product, "itemClass") : undefined, + itemClass: product ? getStringField(product, "itemClass", fields) : undefined, internetOfferingType: product - ? getStringField(product, "internetOfferingType") + ? getStringField(product, "internetOfferingType", fields) : undefined, - internetPlanTier: product ? getStringField(product, "internetPlanTier") : undefined, - vpnRegion: product ? getStringField(product, "vpnRegion") : undefined, + internetPlanTier: product ? getStringField(product, "internetPlanTier", fields) : undefined, + vpnRegion: product ? getStringField(product, "vpnRegion", fields) : undefined, }); } } catch (error) { diff --git a/apps/bff/src/modules/users/users.service.ts b/apps/bff/src/modules/users/users.service.ts index 809aaeda..3e3741d0 100644 --- a/apps/bff/src/modules/users/users.service.ts +++ b/apps/bff/src/modules/users/users.service.ts @@ -4,13 +4,14 @@ import type { UpdateAddressRequest } from "@customer-portal/domain"; import { Injectable, Inject, NotFoundException, BadRequestException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { PrismaService } from "@bff/infra/database/prisma.service"; -import { User, Activity, Address } from "@customer-portal/domain"; +import { User, Activity, Address, type AuthenticatedUser } from "@customer-portal/domain"; import type { Subscription, Invoice } from "@customer-portal/domain"; import type { User as PrismaUser } from "@prisma/client"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; +import { mapPrismaUserToUserProfile } from "@bff/infra/utils/user-mapper.util"; // Use a subset of PrismaUser for updates type UserUpdateData = Partial< @@ -107,7 +108,7 @@ export class UsersService { } } - async findById(id: string): Promise { + async findById(id: string): Promise { const validId = this.validateUserId(id); try { @@ -124,7 +125,7 @@ export class UsersService { error: getErrorMessage(error), userId: validId, }); - return this.toDomainUser(user); + return mapPrismaUserToUserProfile(user); } } catch (error) { this.logger.error("Failed to find user by ID", { @@ -134,7 +135,7 @@ export class UsersService { } } - async getEnhancedProfile(userId: string): Promise { + async getEnhancedProfile(userId: string): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new Error("User not found"); @@ -190,7 +191,7 @@ export class UsersService { email: email || user.email, }; - return this.toDomainUser(enhancedUser); + return mapPrismaUserToUserProfile(enhancedUser); } async create(userData: Partial): Promise { diff --git a/apps/portal/next.config.mjs b/apps/portal/next.config.mjs index 8c1c8cc9..9b74a04b 100644 --- a/apps/portal/next.config.mjs +++ b/apps/portal/next.config.mjs @@ -1,5 +1,9 @@ /* eslint-env node */ -/* no-op */ +import bundleAnalyzer from '@next/bundle-analyzer'; + +const withBundleAnalyzer = bundleAnalyzer({ + enabled: process.env.ANALYZE === 'true', +}); /** @type {import('next').NextConfig} */ const nextConfig = { @@ -77,7 +81,15 @@ const nextConfig = { value: process.env.NODE_ENV === "development" ? "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https: http://localhost:* ws://localhost:*; frame-ancestors 'none';" - : "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:; frame-ancestors 'none';", + : [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self'", + "img-src 'self' data: https:", + "font-src 'self' data:", + "connect-src 'self'", + "frame-ancestors 'none'", + ].join("; "), }, ], }, @@ -90,10 +102,19 @@ const nextConfig = { removeConsole: process.env.NODE_ENV === "production", }, + // Simple bundle optimization + experimental: { + optimizePackageImports: [ + '@heroicons/react', + 'lucide-react', + '@tanstack/react-query', + ], + }, + // Keep type checking enabled; monorepo paths provide types typescript: { ignoreBuildErrors: false }, // Prefer Turbopack; no custom webpack override needed }; -export default nextConfig; +export default withBundleAnalyzer(nextConfig); diff --git a/apps/portal/package.json b/apps/portal/package.json index 26f640df..9880d90e 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -5,14 +5,17 @@ "scripts": { "predev": "node ./scripts/dev-prep.mjs", "dev": "next dev -p ${NEXT_PORT:-3000}", - "build": "NODE_OPTIONS=\"--max-old-space-size=4096\" next build", + "build": "next build", "build:turbo": "next build --turbopack", + "build:analyze": "ANALYZE=true next build", + "analyze": "npm run build:analyze", "start": "next start -p ${NEXT_PORT:-3000}", "lint": "eslint .", "lint:fix": "eslint . --fix", - "type-check": "NODE_OPTIONS=\"--max-old-space-size=6144 --max-semi-space-size=256\" tsc --project tsconfig.json --noEmit", - "type-check:watch": "NODE_OPTIONS=\"--max-old-space-size=6144 --max-semi-space-size=256\" tsc --project tsconfig.json --noEmit --watch", - "test": "echo 'No tests yet'" + "type-check": "tsc --project tsconfig.json --noEmit", + "type-check:watch": "tsc --project tsconfig.json --noEmit --watch", + "test": "echo 'No tests yet'", + "bundle-analyze": "npm run build:analyze && npx @next/bundle-analyzer" }, "dependencies": { "@customer-portal/logging": "workspace:*", @@ -37,11 +40,13 @@ "zustand": "^5.0.8" }, "devDependencies": { + "@next/bundle-analyzer": "^15.5.0", "@tailwindcss/postcss": "^4.1.12", "@types/node": "^24.3.0", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "tailwindcss": "^4.1.12", - "typescript": "^5.9.2" + "typescript": "^5.9.2", + "webpack-bundle-analyzer": "^4.10.2" } } diff --git a/apps/portal/src/components/organisms/AppShell/AppShell.tsx b/apps/portal/src/components/organisms/AppShell/AppShell.tsx index 04285018..d7f631cd 100644 --- a/apps/portal/src/components/organisms/AppShell/AppShell.tsx +++ b/apps/portal/src/components/organisms/AppShell/AppShell.tsx @@ -2,12 +2,13 @@ import { useState, useEffect, useMemo } from "react"; import { usePathname, useRouter } from "next/navigation"; -import { useAuthStore } from "@/features/auth/services/auth.store"; +import { useAuthStore, useAuthSession } from "@/features/auth/services/auth.store"; import { useActiveSubscriptions } from "@/features/subscriptions/hooks"; import { accountService } from "@/features/account/services/account.service"; import { Sidebar } from "./Sidebar"; import { Header } from "./Header"; import { computeNavigation } from "./navigation"; +import type { Subscription } from "@customer-portal/domain"; interface AppShellProps { children: React.ReactNode; @@ -17,12 +18,15 @@ interface AppShellProps { export function AppShell({ children }: AppShellProps) { const [sidebarOpen, setSidebarOpen] = useState(false); - const { user, isAuthenticated, checkAuth } = useAuthStore(); - const { hydrated, hasCheckedAuth, loading } = useAuthStore(); + const { user, isAuthenticated } = useAuthSession(); + const checkAuth = useAuthStore(state => state.checkAuth); + const hasCheckedAuth = useAuthStore(state => state.hasCheckedAuth); + const loading = useAuthStore(state => state.loading); + const hydrateUserProfile = useAuthStore(state => state.hydrateUserProfile); const pathname = usePathname(); const router = useRouter(); const activeSubscriptionsQuery = useActiveSubscriptions(); - const activeSubscriptions = activeSubscriptionsQuery.data ?? []; + const activeSubscriptions: Subscription[] = activeSubscriptionsQuery.data ?? []; // Initialize with a stable default to avoid hydration mismatch const [expandedItems, setExpandedItems] = useState([]); @@ -75,19 +79,11 @@ export function AppShell({ children }: AppShellProps) { if (!prof) { return; } - useAuthStore.setState(state => - state.user - ? { - ...state, - user: { - ...state.user, - firstName: prof.firstName || state.user.firstName, - lastName: prof.lastName || state.user.lastName, - phone: prof.phone || state.user.phone, - }, - } - : state - ); + hydrateUserProfile({ + firstName: prof.firstName ?? undefined, + lastName: prof.lastName ?? undefined, + phone: prof.phone ?? undefined, + }); } catch { // best-effort profile hydration; ignore errors } diff --git a/apps/portal/src/features/account/services/account.service.ts b/apps/portal/src/features/account/services/account.service.ts index 2a635b6b..569591a9 100644 --- a/apps/portal/src/features/account/services/account.service.ts +++ b/apps/portal/src/features/account/services/account.service.ts @@ -9,22 +9,22 @@ type ProfileUpdateInput = { export const accountService = { async getProfile() { - const response = await apiClient.GET("/api/me"); + const response = await apiClient.GET("/me"); return getNullableData(response); }, async updateProfile(update: ProfileUpdateInput) { - const response = await apiClient.PATCH("/api/me", { body: update }); + const response = await apiClient.PATCH("/me", { body: update }); return getDataOrThrow(response, "Failed to update profile"); }, async getAddress() { - const response = await apiClient.GET
("/api/me/address"); + const response = await apiClient.GET
("/me/address"); return getNullableData
(response); }, async updateAddress(address: Address) { - const response = await apiClient.PATCH
("/api/me/address", { body: address }); + const response = await apiClient.PATCH
("/me/address", { body: address }); return getDataOrThrow
(response, "Failed to update address"); }, }; diff --git a/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx b/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx index bb39cbd4..452c9de2 100644 --- a/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx +++ b/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx @@ -3,6 +3,7 @@ import { logger } from "@customer-portal/logging"; import { useEffect, useRef, useState } from "react"; import { useAuthStore } from "@/features/auth/services/auth.store"; +import { useAuthSession } from "@/features/auth/services/auth.store"; import { Button } from "@/components/atoms/button"; interface SessionTimeoutWarningProps { @@ -12,7 +13,9 @@ interface SessionTimeoutWarningProps { export function SessionTimeoutWarning({ warningTime = 5, // Show warning 5 minutes before expiry (reduced since we have auto-refresh) }: SessionTimeoutWarningProps) { - const { isAuthenticated, tokens, logout, checkAuth } = useAuthStore(); + const { isAuthenticated, session } = useAuthSession(); + const logout = useAuthStore(state => state.logout); + const refreshSession = useAuthStore(state => state.refreshSession); const [showWarning, setShowWarning] = useState(false); const [timeLeft, setTimeLeft] = useState(0); const expiryRef = useRef(null); @@ -20,16 +23,16 @@ export function SessionTimeoutWarning({ const previouslyFocusedElement = useRef(null); useEffect(() => { - if (!isAuthenticated || !tokens?.expiresAt) { + if (!isAuthenticated || !session.accessExpiresAt) { expiryRef.current = null; setShowWarning(false); setTimeLeft(0); return undefined; } - const expiryTime = Date.parse(tokens.expiresAt); + const expiryTime = Date.parse(session.accessExpiresAt); if (Number.isNaN(expiryTime)) { - logger.warn({ expiresAt: tokens.expiresAt }, "Invalid expiresAt on auth tokens"); + logger.warn({ expiresAt: session.accessExpiresAt }, "Invalid access token expiry"); expiryRef.current = null; setShowWarning(false); setTimeLeft(0); @@ -60,7 +63,7 @@ export function SessionTimeoutWarning({ }, timeUntilWarning); return () => clearTimeout(warningTimeout); - }, [isAuthenticated, tokens?.expiresAt, warningTime, logout]); + }, [isAuthenticated, session.accessExpiresAt, warningTime, logout]); useEffect(() => { if (!showWarning || !expiryRef.current) return undefined; @@ -139,7 +142,7 @@ export function SessionTimeoutWarning({ const handleExtendSession = () => { void (async () => { try { - await checkAuth(); + await refreshSession(); setShowWarning(false); setTimeLeft(0); } catch (error) { diff --git a/apps/portal/src/features/auth/hooks/use-auth.ts b/apps/portal/src/features/auth/hooks/use-auth.ts index 8a9f8aca..7802f429 100644 --- a/apps/portal/src/features/auth/hooks/use-auth.ts +++ b/apps/portal/src/features/auth/hooks/use-auth.ts @@ -17,56 +17,73 @@ import type { SignupRequestInput, LoginRequestInput } from "@customer-portal/dom export function useAuth() { const router = useRouter(); const searchParams = useSearchParams(); - const store = useAuthStore(); + const { + isAuthenticated, + user, + loading, + hasCheckedAuth, + error, + login: loginAction, + signup: signupAction, + logout: logoutAction, + requestPasswordReset, + resetPassword, + changePassword, + checkPasswordNeeded, + linkWhmcs, + setPassword, + checkAuth, + refreshSession, + clearError, + } = useAuthStore(); // Enhanced login with redirect handling const login = useCallback( async (credentials: LoginRequestInput) => { - await store.login(credentials); + await loginAction(credentials); const redirectTo = getPostLoginRedirect(searchParams); router.push(redirectTo); }, - [store, router, searchParams] + [loginAction, router, searchParams] ); // Enhanced signup with redirect handling const signup = useCallback( async (data: SignupRequestInput) => { - await store.signup(data); + await signupAction(data); const redirectTo = getPostLoginRedirect(searchParams); router.push(redirectTo); }, - [store, router, searchParams] + [signupAction, router, searchParams] ); // Enhanced logout with redirect const logout = useCallback(async () => { - await store.logout(); + await logoutAction(); router.push("/auth/login"); - }, [store, router]); + }, [logoutAction, router]); return { // State - isAuthenticated: store.isAuthenticated, - user: store.user, - loading: store.loading, - hydrated: (store as unknown as { hydrated?: boolean }).hydrated ?? false, - hasCheckedAuth: (store as unknown as { hasCheckedAuth?: boolean }).hasCheckedAuth ?? false, - error: store.error, + isAuthenticated, + user, + loading, + hasCheckedAuth, + error, // Actions login, signup, logout, - requestPasswordReset: store.requestPasswordReset, - resetPassword: store.resetPassword, - changePassword: store.changePassword, - checkPasswordNeeded: store.checkPasswordNeeded, - linkWhmcs: store.linkWhmcs, - setPassword: store.setPassword, - checkAuth: store.checkAuth, - refreshSession: store.refreshSession, - clearError: store.clearError, + requestPasswordReset, + resetPassword, + changePassword, + checkPasswordNeeded, + linkWhmcs, + setPassword, + checkAuth, + refreshSession, + clearError, }; } diff --git a/apps/portal/src/features/auth/services/auth.store.ts b/apps/portal/src/features/auth/services/auth.store.ts index 4fff470d..1c4353e3 100644 --- a/apps/portal/src/features/auth/services/auth.store.ts +++ b/apps/portal/src/features/auth/services/auth.store.ts @@ -1,15 +1,14 @@ /** * Client-Side Authentication Store - * Simple Zustand store for auth state management - calls BFF APIs directly + * Maintains session state using secure httpOnly cookies issued by the BFF. */ import { create } from "zustand"; -import { persist, createJSONStorage } from "zustand/middleware"; import { apiClient, getNullableData } from "@/lib/api"; import { getErrorInfo, handleAuthError } from "@/lib/utils/error-handling"; import logger from "@customer-portal/logging"; import type { - AuthTokens, + AuthTokensSchema, AuthenticatedUser, LinkWhmcsRequestInput, LoginRequestInput, @@ -17,26 +16,19 @@ import type { } from "@customer-portal/domain"; import { authResponseSchema } from "@customer-portal/domain/validation"; -const withAuthHeaders = (accessToken?: string) => - accessToken - ? { - headers: { - Authorization: `Bearer ${accessToken}`, - } as Record, - } - : {}; +interface SessionState { + accessExpiresAt?: string; + refreshExpiresAt?: string; +} -interface AuthState { - // State +export interface AuthState { user: AuthenticatedUser | null; - tokens: AuthTokens | null; + session: SessionState; isAuthenticated: boolean; loading: boolean; error: string | null; - hydrated: boolean; hasCheckedAuth: boolean; - // Actions login: (credentials: LoginRequestInput) => Promise; signup: (data: SignupRequestInput) => Promise; logout: () => Promise; @@ -49,377 +41,301 @@ interface AuthState { ) => Promise<{ needsPasswordSet: boolean; email: string }>; setPassword: (email: string, password: string) => Promise; refreshUser: () => Promise; - refreshTokens: () => Promise; + refreshSession: () => Promise; checkAuth: () => Promise; clearError: () => void; - setTokens: (tokens: AuthTokens) => void; - setHydrated: (hydrated: boolean) => void; - refreshSession: () => Promise; + hydrateUserProfile: (profile: Partial) => void; } -export const useAuthStore = create()( - persist( - (set, get) => ({ - // Initial state - user: null, - tokens: null, - isAuthenticated: false, +type AuthResponseData = { + user: AuthenticatedUser; + tokens: AuthTokensSchema; +}; + +export const useAuthStore = create()((set, get) => { + const applyAuthResponse = (data: AuthResponseData) => { + set({ + user: data.user, + session: { + accessExpiresAt: data.tokens.expiresAt, + refreshExpiresAt: data.tokens.refreshExpiresAt, + }, + isAuthenticated: true, loading: false, error: null, - hydrated: false, - hasCheckedAuth: false, - - // Actions - login: async (credentials: LoginRequestInput) => { - set({ loading: true, error: null }); - try { - // Use shared API client with consistent configuration - const response = await apiClient.POST("/auth/login", { body: credentials }); - const parsed = authResponseSchema.safeParse(response.data); - if (!parsed.success) { - throw new Error(parsed.error.issues?.[0]?.message ?? "Login failed"); - } - - const { user, tokens } = parsed.data; - - set({ - user, - tokens, - isAuthenticated: true, - loading: false, - error: null, - }); - } catch (error) { - const errorInfo = getErrorInfo(error); - set({ - loading: false, - error: errorInfo.message, - }); - throw error; - } - }, - - signup: async (data: SignupRequestInput) => { - set({ loading: true, error: null }); - try { - const response = await apiClient.POST("/auth/signup", { body: data }); - const parsed = authResponseSchema.safeParse(response.data); - if (!parsed.success) { - throw new Error(parsed.error.issues?.[0]?.message ?? "Signup failed"); - } - - const { user, tokens } = parsed.data; - - set({ - user, - tokens, - isAuthenticated: true, - loading: false, - error: null, - }); - } catch (error) { - set({ - loading: false, - error: error instanceof Error ? error.message : "Signup failed", - }); - throw error; - } - }, - - logout: async () => { - const { tokens } = get(); - - try { - if (tokens?.accessToken) { - await apiClient.POST("/auth/logout", { - ...withAuthHeaders(tokens.accessToken), - }); - } - } catch (error) { - // Ignore logout errors - clear local state anyway - logger.warn( - { error: error instanceof Error ? error.message : String(error) }, - "Logout API call failed" - ); - } - - set({ - user: null, - tokens: null, - isAuthenticated: false, - error: null, - }); - }, - - requestPasswordReset: async (email: string) => { - set({ loading: true, error: null }); - try { - const response = await apiClient.POST("/auth/request-password-reset", { - body: { email }, - }); - - if (!response.data) { - throw new Error("Password reset request failed"); - } - - set({ loading: false }); - } catch (error) { - set({ - loading: false, - error: error instanceof Error ? error.message : "Password reset request failed", - }); - throw error; - } - }, - - resetPassword: async (token: string, password: string) => { - set({ loading: true, error: null }); - try { - const response = await apiClient.POST("/auth/reset-password", { - body: { token, password }, - }); - const parsed = authResponseSchema.safeParse(response.data); - if (!parsed.success) { - throw new Error(parsed.error.issues?.[0]?.message ?? "Password reset failed"); - } - - const { user, tokens } = parsed.data; - - set({ - user, - tokens, - isAuthenticated: true, - loading: false, - error: null, - }); - } catch (error) { - set({ - loading: false, - error: error instanceof Error ? error.message : "Password reset failed", - }); - throw error; - } - }, - - changePassword: async (currentPassword: string, newPassword: string) => { - const { tokens } = get(); - if (!tokens?.accessToken) throw new Error("Not authenticated"); - - set({ loading: true, error: null }); - try { - const response = await apiClient.POST("/auth/change-password", { - ...withAuthHeaders(tokens.accessToken), - body: { currentPassword, newPassword }, - }); - - if (!response.data) { - throw new Error("Password change failed"); - } - - set({ loading: false }); - } catch (error) { - set({ - loading: false, - error: error instanceof Error ? error.message : "Password change failed", - }); - throw error; - } - }, - - checkPasswordNeeded: async (email: string) => { - set({ loading: true, error: null }); - try { - const response = await apiClient.POST("/auth/check-password-needed", { - body: { email }, - }); - - if (!response.data) { - throw new Error("Check failed"); - } - - set({ loading: false }); - return response.data as { needsPasswordSet: boolean }; - } catch (error) { - set({ - loading: false, - error: error instanceof Error ? error.message : "Check failed", - }); - throw error; - } - }, - - linkWhmcs: async ({ email, password }: LinkWhmcsRequestInput) => { - set({ loading: true, error: null }); - try { - const response = await apiClient.POST("/auth/link-whmcs", { - body: { email, password }, - }); - - if (!response.data) { - throw new Error("WHMCS link failed"); - } - - set({ loading: false }); - const result = response.data as { needsPasswordSet: boolean }; - return { ...result, email }; - } catch (error) { - set({ - loading: false, - error: error instanceof Error ? error.message : "WHMCS link failed", - }); - throw error; - } - }, - - setPassword: async (email: string, password: string) => { - set({ loading: true, error: null }); - try { - const response = await apiClient.POST("/auth/set-password", { - body: { email, password }, - }); - const parsed = authResponseSchema.safeParse(response.data); - if (!parsed.success) { - throw new Error(parsed.error.issues?.[0]?.message ?? "Set password failed"); - } - - const { user, tokens } = parsed.data; - - set({ - user, - tokens, - isAuthenticated: true, - loading: false, - error: null, - }); - } catch (error) { - set({ - loading: false, - error: error instanceof Error ? error.message : "Set password failed", - }); - throw error; - } - }, - - refreshUser: async () => { - const { tokens } = get(); - if (!tokens?.accessToken) return; - - try { - const response = await apiClient.GET("/me", { - ...withAuthHeaders(tokens.accessToken), - }); - - const profile = getNullableData(response); - if (!profile) { - // Token might be expired, try to refresh - await get().refreshTokens(); - return; - } - - set({ user: profile }); - } catch (error) { - // Token might be expired, try to refresh - const shouldLogout = handleAuthError(error, get().logout); - if (!shouldLogout) { - await get().refreshTokens(); - } - } - }, - - refreshTokens: async () => { - const { tokens } = get(); - if (!tokens?.refreshToken) { - // No refresh token available, logout - await get().logout(); - return; - } - - try { - const response = await apiClient.POST("/auth/refresh", { - body: { - refreshToken: tokens.refreshToken, - deviceId: localStorage.getItem("deviceId") || undefined, - }, - }); - - const parsed = authResponseSchema.safeParse(response.data); - if (!parsed.success) { - throw new Error(parsed.error.issues?.[0]?.message ?? "Token refresh failed"); - } - - const { tokens: newTokens } = parsed.data; - set({ tokens: newTokens, isAuthenticated: true }); - } catch (error) { - // Refresh failed, logout - const shouldLogout = handleAuthError(error, get().logout); - if (!shouldLogout) { - await get().logout(); - } - } - }, - - checkAuth: async () => { - const { tokens, isAuthenticated } = get(); - - set({ hasCheckedAuth: true }); - - if (!isAuthenticated || !tokens) { - return; - } - - // Check if access token is close to expiry (within 5 minutes) - const expiryTime = new Date(tokens.expiresAt).getTime(); - const now = Date.now(); - const fiveMinutes = 5 * 60 * 1000; - - if (expiryTime - now < fiveMinutes) { - await get().refreshTokens(); - } - }, - - clearError: () => set({ error: null }), - - setTokens: (tokens: AuthTokens) => { - set({ tokens, isAuthenticated: true }); - }, - - setHydrated: (hydrated: boolean) => { - set({ hydrated }); - }, - - refreshSession: async () => { - await get().refreshUser(); - }, - }), - { - name: "auth-store", - storage: createJSONStorage(() => localStorage), - partialize: state => ({ - user: state.user, - tokens: state.tokens, - isAuthenticated: state.isAuthenticated, - }), - } - ) -); - -// Selectors for easy access -export const selectAuthTokens = (state: AuthState) => state.tokens; -export const selectIsAuthenticated = (state: AuthState) => state.isAuthenticated; -export const selectAuthUser = (state: AuthState) => state.user; - -export const useAuthSession = () => { - const tokens = useAuthStore(selectAuthTokens); - const isAuthenticated = useAuthStore(selectIsAuthenticated); - const user = useAuthStore(selectAuthUser); - const hasValidToken = Boolean( - tokens?.accessToken && tokens?.expiresAt && new Date(tokens.expiresAt).getTime() > Date.now() - ); + }); + }; + + return { + user: null, + session: {}, + isAuthenticated: false, + loading: false, + error: null, + hasCheckedAuth: false, + + login: async credentials => { + set({ loading: true, error: null }); + try { + const response = await apiClient.POST("/auth/login", { body: credentials }); + const parsed = authResponseSchema.safeParse(response.data); + if (!parsed.success) { + throw new Error(parsed.error.issues?.[0]?.message ?? "Login failed"); + } + applyAuthResponse(parsed.data); + } catch (error) { + const errorInfo = getErrorInfo(error); + set({ loading: false, error: errorInfo.message, isAuthenticated: false }); + throw error; + } + }, + + signup: async data => { + set({ loading: true, error: null }); + try { + const response = await apiClient.POST("/auth/signup", { body: data }); + const parsed = authResponseSchema.safeParse(response.data); + if (!parsed.success) { + throw new Error(parsed.error.issues?.[0]?.message ?? "Signup failed"); + } + applyAuthResponse(parsed.data); + } catch (error) { + set({ + loading: false, + error: error instanceof Error ? error.message : "Signup failed", + }); + throw error; + } + }, + + logout: async () => { + try { + await apiClient.POST("/auth/logout", {}); + } catch (error) { + logger.warn(error, "Logout API call failed"); + } finally { + set({ + user: null, + session: {}, + isAuthenticated: false, + error: null, + loading: false, + }); + } + }, + + requestPasswordReset: async (email: string) => { + set({ loading: true, error: null }); + try { + await apiClient.POST("/auth/request-password-reset", { body: { email } }); + set({ loading: false }); + } catch (error) { + set({ + loading: false, + error: error instanceof Error ? error.message : "Password reset request failed", + }); + throw error; + } + }, + + resetPassword: async (token: string, password: string) => { + set({ loading: true, error: null }); + try { + const response = await apiClient.POST("/auth/reset-password", { + body: { token, password }, + }); + const parsed = authResponseSchema.safeParse(response.data); + if (!parsed.success) { + throw new Error(parsed.error.issues?.[0]?.message ?? "Password reset failed"); + } + applyAuthResponse(parsed.data); + } catch (error) { + set({ + loading: false, + error: error instanceof Error ? error.message : "Password reset failed", + }); + throw error; + } + }, + + changePassword: async (currentPassword: string, newPassword: string) => { + set({ loading: true, error: null }); + try { + const response = await apiClient.POST("/auth/change-password", { + body: { currentPassword, newPassword }, + }); + const parsed = authResponseSchema.safeParse(response.data); + if (!parsed.success) { + throw new Error(parsed.error.issues?.[0]?.message ?? "Password change failed"); + } + applyAuthResponse(parsed.data); + } catch (error) { + set({ + loading: false, + error: error instanceof Error ? error.message : "Password change failed", + }); + throw error; + } + }, + + checkPasswordNeeded: async (email: string) => { + set({ loading: true, error: null }); + try { + const response = await apiClient.POST("/auth/check-password-needed", { + body: { email }, + }); + + if (!response.data) { + throw new Error("Check failed"); + } + + set({ loading: false }); + return response.data as { needsPasswordSet: boolean }; + } catch (error) { + set({ + loading: false, + error: error instanceof Error ? error.message : "Check failed", + }); + throw error; + } + }, + + linkWhmcs: async ({ email, password }: LinkWhmcsRequestInput) => { + set({ loading: true, error: null }); + try { + const response = await apiClient.POST("/auth/link-whmcs", { + body: { email, password }, + }); + + if (!response.data) { + throw new Error("WHMCS link failed"); + } + + set({ loading: false }); + const result = response.data as { needsPasswordSet: boolean }; + return { ...result, email }; + } catch (error) { + set({ + loading: false, + error: error instanceof Error ? error.message : "WHMCS link failed", + }); + throw error; + } + }, + + setPassword: async (email: string, password: string) => { + set({ loading: true, error: null }); + try { + const response = await apiClient.POST("/auth/set-password", { + body: { email, password }, + }); + const parsed = authResponseSchema.safeParse(response.data); + if (!parsed.success) { + throw new Error(parsed.error.issues?.[0]?.message ?? "Set password failed"); + } + applyAuthResponse(parsed.data); + } catch (error) { + set({ + loading: false, + error: error instanceof Error ? error.message : "Set password failed", + }); + throw error; + } + }, + + refreshUser: async () => { + try { + const response = await apiClient.GET<{ isAuthenticated?: boolean; user?: AuthenticatedUser }>( + "/auth/me" + ); + const data = getNullableData(response); + if (data?.isAuthenticated && data.user) { + set({ + user: data.user, + isAuthenticated: true, + error: null, + }); + return; + } + + // No active session + set({ user: null, isAuthenticated: false, session: {} }); + } catch (error) { + const shouldLogout = handleAuthError(error, get().logout); + if (shouldLogout) { + return; + } + + try { + const refreshResponse = await apiClient.POST("/auth/refresh", { body: {} }); + const parsed = authResponseSchema.safeParse(refreshResponse.data); + if (!parsed.success) { + throw new Error(parsed.error.issues?.[0]?.message ?? "Session refresh failed"); + } + applyAuthResponse(parsed.data); + } catch (refreshError) { + logger.error(refreshError, "Failed to refresh session after auth error"); + await get().logout(); + } + } + }, + + refreshSession: async () => { + try { + const response = await apiClient.POST("/auth/refresh", { body: {} }); + const parsed = authResponseSchema.safeParse(response.data); + if (!parsed.success) { + throw new Error(parsed.error.issues?.[0]?.message ?? "Session refresh failed"); + } + applyAuthResponse(parsed.data); + } catch (error) { + logger.error(error, "Failed to refresh session"); + await get().logout(); + } + }, + + checkAuth: async () => { + set({ hasCheckedAuth: true }); + await get().refreshUser(); + }, + + clearError: () => set({ error: null }), + + hydrateUserProfile: profile => { + set(state => { + if (!state.user) { + return state; + } + + const definedEntries = Object.entries(profile).filter(([, value]) => value !== undefined); + if (definedEntries.length === 0) { + return state; + } + + return { + ...state, + user: { + ...state.user, + ...(Object.fromEntries(definedEntries) as Partial), + }, + }; + }); + }, + }; +}); + +export const selectIsAuthenticated = (state: AuthState) => state.isAuthenticated; +export const selectAuthUser = (state: AuthState) => state.user; +export const selectSession = (state: AuthState) => state.session; + +export const useAuthSession = () => { + const user = useAuthStore(selectAuthUser); + const isAuthenticated = useAuthStore(selectIsAuthenticated); + const session = useAuthStore(selectSession); return { - tokens, - isAuthenticated, user, - hasValidToken, + isAuthenticated, + session, }; }; diff --git a/apps/portal/src/features/auth/services/index.ts b/apps/portal/src/features/auth/services/index.ts index 90bd05ec..2ac42881 100644 --- a/apps/portal/src/features/auth/services/index.ts +++ b/apps/portal/src/features/auth/services/index.ts @@ -5,7 +5,8 @@ export { useAuthStore, - selectAuthTokens, + useAuthSession, selectIsAuthenticated, selectAuthUser, + selectSession, } from "./auth.store"; diff --git a/apps/portal/src/features/billing/hooks/useBilling.ts b/apps/portal/src/features/billing/hooks/useBilling.ts index 22792e15..b3f05fa3 100644 --- a/apps/portal/src/features/billing/hooks/useBilling.ts +++ b/apps/portal/src/features/billing/hooks/useBilling.ts @@ -61,31 +61,34 @@ type SsoLinkMutationOptions = UseMutationOptions< >; async function fetchInvoices(params?: InvoiceQueryParams): Promise { - const response = await apiClient.GET( - "/api/invoices", - params ? { params: { query: params } } : undefined + const response = await apiClient.GET( + "/invoices", + params ? { params: { query: params as Record } } : undefined ); - const data = getDataOrDefault(response, emptyInvoiceList); + const data = getDataOrDefault(response, emptyInvoiceList); return invoiceListSchema.parse(data); } async function fetchInvoice(id: string): Promise { - const response = await apiClient.GET("/api/invoices/{id}", { params: { path: { id } } }); - const invoice = getDataOrThrow(response, "Invoice not found"); + const response = await apiClient.GET("/invoices/{id}", { + params: { path: { id } }, + }); + const invoice = getDataOrThrow(response, "Invoice not found"); return sharedInvoiceSchema.parse(invoice); } async function fetchPaymentMethods(): Promise { - const response = await apiClient.GET("/api/invoices/payment-methods"); - return getDataOrDefault(response, emptyPaymentMethods); + const response = await apiClient.GET("/invoices/payment-methods"); + return getDataOrDefault(response, emptyPaymentMethods); } export function useInvoices( params?: InvoiceQueryParams, options?: InvoicesQueryOptions ): UseQueryResult { + const queryParams = params ? (params as Record) : {}; return useQuery({ - queryKey: queryKeys.billing.invoices(params ?? {}), + queryKey: queryKeys.billing.invoices(queryParams), queryFn: () => fetchInvoices(params), ...options, }); @@ -122,7 +125,7 @@ export function useCreateInvoiceSsoLink( > { return useMutation({ mutationFn: async ({ invoiceId, target }) => { - const response = await apiClient.POST("/api/invoices/{id}/sso-link", { + const response = await apiClient.POST("/invoices/{id}/sso-link", { params: { path: { id: invoiceId }, query: target ? { target } : undefined, diff --git a/apps/portal/src/features/billing/hooks/usePaymentRefresh.ts b/apps/portal/src/features/billing/hooks/usePaymentRefresh.ts index 858fdfcf..a21a5c6b 100644 --- a/apps/portal/src/features/billing/hooks/usePaymentRefresh.ts +++ b/apps/portal/src/features/billing/hooks/usePaymentRefresh.ts @@ -29,7 +29,7 @@ export function usePaymentRefresh({ setToast({ visible: true, text: "Refreshing payment methods...", tone: "info" }); try { try { - await apiClient.POST("/api/invoices/payment-methods/refresh"); + await apiClient.POST("/invoices/payment-methods/refresh"); } catch (err) { // Soft-fail cache refresh, still attempt refetch // Payment methods cache refresh failed - silently continue diff --git a/apps/portal/src/features/billing/views/InvoiceDetail.tsx b/apps/portal/src/features/billing/views/InvoiceDetail.tsx index 271b97f1..8346fe31 100644 --- a/apps/portal/src/features/billing/views/InvoiceDetail.tsx +++ b/apps/portal/src/features/billing/views/InvoiceDetail.tsx @@ -56,7 +56,7 @@ export function InvoiceDetailContainer() { void (async () => { setLoadingPaymentMethods(true); try { - const response = await apiClient.POST("/api/auth/sso-link", { + const response = await apiClient.POST("/auth/sso-link", { body: { path: "index.php?rp=/account/paymentmethods" }, }); const sso = getDataOrThrow( diff --git a/apps/portal/src/features/billing/views/PaymentMethods.tsx b/apps/portal/src/features/billing/views/PaymentMethods.tsx index 23762971..bdf3823b 100644 --- a/apps/portal/src/features/billing/views/PaymentMethods.tsx +++ b/apps/portal/src/features/billing/views/PaymentMethods.tsx @@ -57,7 +57,7 @@ export function PaymentMethodsContainer() { } try { - const response = await apiClient.POST("/api/auth/sso-link", { + const response = await apiClient.POST("/auth/sso-link", { body: { path: "index.php?rp=/account/paymentmethods" }, }); const sso = getDataOrThrow(response, "Failed to open payment methods portal"); diff --git a/apps/portal/src/features/catalog/services/catalog.service.ts b/apps/portal/src/features/catalog/services/catalog.service.ts index 26f1c370..a0163782 100644 --- a/apps/portal/src/features/catalog/services/catalog.service.ts +++ b/apps/portal/src/features/catalog/services/catalog.service.ts @@ -16,28 +16,48 @@ const emptySimAddons: SimCatalogProduct[] = []; const emptySimActivationFees: SimActivationFeeCatalogItem[] = []; const emptyVpnPlans: VpnCatalogProduct[] = []; +const defaultInternetCatalog = { + plans: emptyInternetPlans, + installations: emptyInternetInstallations, + addons: emptyInternetAddons, +}; + +const defaultSimCatalog = { + plans: emptySimPlans, + activationFees: emptySimActivationFees, + addons: emptySimAddons, +}; + +const defaultVpnCatalog = { + plans: emptyVpnPlans, + activationFees: emptyVpnPlans, +}; + export const catalogService = { async getInternetCatalog(): Promise<{ plans: InternetPlanCatalogItem[]; installations: InternetInstallationCatalogItem[]; addons: InternetAddonCatalogItem[]; }> { - const response = await apiClient.GET("/api/catalog/internet/plans"); - return getDataOrDefault(response, { - plans: emptyInternetPlans, - installations: emptyInternetInstallations, - addons: emptyInternetAddons, - }); + const response = await apiClient.GET( + "/catalog/internet/plans" + ); + return getDataOrDefault(response, defaultInternetCatalog); }, async getInternetInstallations(): Promise { - const response = await apiClient.GET("/api/catalog/internet/installations"); - return getDataOrDefault(response, emptyInternetInstallations); + const response = await apiClient.GET( + "/catalog/internet/installations" + ); + return getDataOrDefault( + response, + emptyInternetInstallations + ); }, async getInternetAddons(): Promise { - const response = await apiClient.GET("/api/catalog/internet/addons"); - return getDataOrDefault(response, emptyInternetAddons); + const response = await apiClient.GET("/catalog/internet/addons"); + return getDataOrDefault(response, emptyInternetAddons); }, async getSimCatalog(): Promise<{ @@ -45,37 +65,32 @@ export const catalogService = { activationFees: SimActivationFeeCatalogItem[]; addons: SimCatalogProduct[]; }> { - const response = await apiClient.GET("/api/catalog/sim/plans"); - return getDataOrDefault(response, { - plans: emptySimPlans, - activationFees: emptySimActivationFees, - addons: emptySimAddons, - }); + const response = await apiClient.GET("/catalog/sim/plans"); + return getDataOrDefault(response, defaultSimCatalog); }, async getSimActivationFees(): Promise { - const response = await apiClient.GET("/api/catalog/sim/activation-fees"); - return getDataOrDefault(response, emptySimActivationFees); + const response = await apiClient.GET( + "/catalog/sim/activation-fees" + ); + return getDataOrDefault(response, emptySimActivationFees); }, async getSimAddons(): Promise { - const response = await apiClient.GET("/api/catalog/sim/addons"); - return getDataOrDefault(response, emptySimAddons); + const response = await apiClient.GET("/catalog/sim/addons"); + return getDataOrDefault(response, emptySimAddons); }, async getVpnCatalog(): Promise<{ plans: VpnCatalogProduct[]; activationFees: VpnCatalogProduct[]; }> { - const response = await apiClient.GET("/api/catalog/vpn/plans"); - return getDataOrDefault(response, { - plans: emptyVpnPlans, - activationFees: emptyVpnPlans, - }); + const response = await apiClient.GET("/catalog/vpn/plans"); + return getDataOrDefault(response, defaultVpnCatalog); }, async getVpnActivationFees(): Promise { - const response = await apiClient.GET("/api/catalog/vpn/activation-fees"); - return getDataOrDefault(response, emptyVpnPlans); + const response = await apiClient.GET("/catalog/vpn/activation-fees"); + return getDataOrDefault(response, emptyVpnPlans); }, }; diff --git a/apps/portal/src/features/dashboard/hooks/useDashboardSummary.ts b/apps/portal/src/features/dashboard/hooks/useDashboardSummary.ts index 00332537..0f514f9b 100644 --- a/apps/portal/src/features/dashboard/hooks/useDashboardSummary.ts +++ b/apps/portal/src/features/dashboard/hooks/useDashboardSummary.ts @@ -4,7 +4,7 @@ */ import { useQuery } from "@tanstack/react-query"; -import { useAuthStore } from "@/features/auth/services/auth.store"; +import { useAuthSession } from "@/features/auth/services/auth.store"; import { apiClient, queryKeys, getDataOrThrow } from "@/lib/api"; import type { DashboardSummary, DashboardError } from "@customer-portal/domain"; @@ -23,12 +23,12 @@ class DashboardDataError extends Error { * Hook for fetching dashboard summary data */ export function useDashboardSummary() { - const { isAuthenticated, tokens } = useAuthStore(); + const { isAuthenticated } = useAuthSession(); return useQuery({ queryKey: queryKeys.dashboard.summary(), queryFn: async () => { - if (!tokens?.accessToken) { + if (!isAuthenticated) { throw new DashboardDataError( "AUTHENTICATION_REQUIRED", "Authentication required to fetch dashboard data" @@ -36,7 +36,7 @@ export function useDashboardSummary() { } try { - const response = await apiClient.GET("/api/dashboard/summary"); + const response = await apiClient.GET("/dashboard/summary"); return getDataOrThrow(response, "Dashboard summary response was empty"); } catch (error) { // Transform API errors to DashboardError format @@ -55,7 +55,7 @@ export function useDashboardSummary() { }, staleTime: 2 * 60 * 1000, // 2 minutes gcTime: 5 * 60 * 1000, // 5 minutes (formerly cacheTime) - enabled: isAuthenticated && !!tokens?.accessToken, + enabled: isAuthenticated, retry: (failureCount, error) => { // Don't retry authentication errors if (error?.code === "AUTHENTICATION_REQUIRED") { diff --git a/apps/portal/src/features/orders/services/orders.service.ts b/apps/portal/src/features/orders/services/orders.service.ts index 7d35df49..023f09ea 100644 --- a/apps/portal/src/features/orders/services/orders.service.ts +++ b/apps/portal/src/features/orders/services/orders.service.ts @@ -1,40 +1,8 @@ -import { createClient } from "@/lib/api"; +import { apiClient } from "@/lib/api"; import type { CreateOrderRequest } from "@customer-portal/domain"; -const API_BASE = process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000"; - -interface AuthStoreSnapshot { - state?: { - tokens?: { - accessToken?: unknown; - }; - }; -} - -const getAuthHeader = (): string | undefined => { - if (typeof window === "undefined") return undefined; - - const authStore = window.localStorage.getItem("auth-store"); - if (!authStore) return undefined; - - try { - const parsed = JSON.parse(authStore) as AuthStoreSnapshot; - const token = parsed?.state?.tokens?.accessToken; - return typeof token === "string" && token ? `Bearer ${token}` : undefined; - } catch { - return undefined; - } -}; - -const createAuthedClient = () => - createClient({ - baseUrl: API_BASE, - getAuthHeader, - }); - async function createOrder(payload: CreateOrderRequest): Promise { - const apiClient = createAuthedClient(); - const response = await apiClient.POST("/api/orders", { body: payload }); + const response = await apiClient.POST("/orders", { body: payload }); if (!response.data) { throw new Error("Order creation failed"); } @@ -42,14 +10,12 @@ async function createOrder(payload: CreateOrderReques } async function getMyOrders(): Promise { - const apiClient = createAuthedClient(); - const response = await apiClient.GET("/api/orders/user"); + const response = await apiClient.GET("/orders/user"); return (response.data ?? []) as T; } async function getOrderById(orderId: string): Promise { - const apiClient = createAuthedClient(); - const response = await apiClient.GET("/api/orders/{sfOrderId}", { + const response = await apiClient.GET("/orders/{sfOrderId}", { params: { path: { sfOrderId: orderId } }, }); if (!response.data) { diff --git a/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx b/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx index fe22b142..fc510230 100644 --- a/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx +++ b/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx @@ -42,7 +42,7 @@ export function ChangePlanModal({ } setLoading(true); try { - await apiClient.POST("/api/subscriptions/{id}/sim/change-plan", { + await apiClient.POST("/subscriptions/{id}/sim/change-plan", { params: { path: { id: subscriptionId } }, body: { newPlanCode, diff --git a/apps/portal/src/features/sim-management/components/SimActions.tsx b/apps/portal/src/features/sim-management/components/SimActions.tsx index 610c68f0..0ec08721 100644 --- a/apps/portal/src/features/sim-management/components/SimActions.tsx +++ b/apps/portal/src/features/sim-management/components/SimActions.tsx @@ -58,7 +58,7 @@ export function SimActions({ setError(null); try { - await apiClient.POST("/api/subscriptions/{id}/sim/reissue-esim", { + await apiClient.POST("/subscriptions/{id}/sim/reissue-esim", { params: { path: { id: subscriptionId } }, }); @@ -77,7 +77,7 @@ export function SimActions({ setError(null); try { - await apiClient.POST("/api/subscriptions/{id}/sim/cancel", { + await apiClient.POST("/subscriptions/{id}/sim/cancel", { params: { path: { id: subscriptionId } }, }); diff --git a/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx b/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx index 720b4185..00d1a7b4 100644 --- a/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx +++ b/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx @@ -75,7 +75,7 @@ export function SimFeatureToggles({ if (nt !== initial.nt) featurePayload.networkType = nt; if (Object.keys(featurePayload).length > 0) { - await apiClient.POST("/api/subscriptions/{id}/sim/features", { + await apiClient.POST("/subscriptions/{id}/sim/features", { params: { path: { id: subscriptionId } }, body: featurePayload, }); diff --git a/apps/portal/src/features/sim-management/components/SimManagementSection.tsx b/apps/portal/src/features/sim-management/components/SimManagementSection.tsx index 3726fc82..b3f9f181 100644 --- a/apps/portal/src/features/sim-management/components/SimManagementSection.tsx +++ b/apps/portal/src/features/sim-management/components/SimManagementSection.tsx @@ -30,7 +30,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro try { setError(null); - const response = await apiClient.GET("/api/subscriptions/{id}/sim", { + const response = await apiClient.GET("/subscriptions/{id}/sim", { params: { path: { id: subscriptionId } }, }); diff --git a/apps/portal/src/features/sim-management/components/TopUpModal.tsx b/apps/portal/src/features/sim-management/components/TopUpModal.tsx index 1e3358a0..54c317bb 100644 --- a/apps/portal/src/features/sim-management/components/TopUpModal.tsx +++ b/apps/portal/src/features/sim-management/components/TopUpModal.tsx @@ -45,7 +45,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU quotaMb: getCurrentAmountMb(), }; - await apiClient.POST("/api/subscriptions/{id}/sim/top-up", { + await apiClient.POST("/subscriptions/{id}/sim/top-up", { params: { path: { id: subscriptionId } }, body: requestBody, }); diff --git a/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts b/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts index 2cfb98cb..acaf56aa 100644 --- a/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts +++ b/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts @@ -5,7 +5,7 @@ import { useQuery } from "@tanstack/react-query"; import { apiClient, queryKeys, getDataOrDefault, getDataOrThrow, getNullableData } from "@/lib/api"; -import { useAuthStore } from "@/features/auth/services"; +import { useAuthSession } from "@/features/auth/services"; import type { InvoiceList, Subscription, SubscriptionList } from "@customer-portal/domain"; interface UseSubscriptionsOptions { @@ -52,20 +52,20 @@ function toSubscriptionList(payload?: SubscriptionList | Subscription[] | null): */ export function useSubscriptions(options: UseSubscriptionsOptions = {}) { const { status } = options; - const { isAuthenticated, tokens } = useAuthStore(); + const { isAuthenticated } = useAuthSession(); return useQuery({ queryKey: queryKeys.subscriptions.list(status ? { status } : undefined), queryFn: async () => { - const response = await apiClient.GET( - "/api/subscriptions", + const response = await apiClient.GET( + "/subscriptions", status ? { params: { query: { status } } } : undefined ); - return toSubscriptionList(getNullableData(response)); + return toSubscriptionList(getNullableData(response)); }, staleTime: 5 * 60 * 1000, gcTime: 10 * 60 * 1000, - enabled: isAuthenticated && !!tokens?.accessToken, + enabled: isAuthenticated, }); } @@ -73,17 +73,17 @@ export function useSubscriptions(options: UseSubscriptionsOptions = {}) { * Hook to fetch active subscriptions only */ export function useActiveSubscriptions() { - const { isAuthenticated, tokens } = useAuthStore(); + const { isAuthenticated } = useAuthSession(); return useQuery({ queryKey: queryKeys.subscriptions.active(), queryFn: async () => { - const response = await apiClient.GET("/api/subscriptions/active"); - return getDataOrDefault(response, [] as Subscription[]); + const response = await apiClient.GET("/subscriptions/active"); + return getDataOrDefault(response, []); }, staleTime: 5 * 60 * 1000, gcTime: 10 * 60 * 1000, - enabled: isAuthenticated && !!tokens?.accessToken, + enabled: isAuthenticated, }); } @@ -91,17 +91,17 @@ export function useActiveSubscriptions() { * Hook to fetch subscription statistics */ export function useSubscriptionStats() { - const { isAuthenticated, tokens } = useAuthStore(); + const { isAuthenticated } = useAuthSession(); return useQuery({ queryKey: queryKeys.subscriptions.stats(), queryFn: async () => { - const response = await apiClient.GET("/api/subscriptions/stats"); - return getDataOrDefault(response, emptyStats); + const response = await apiClient.GET("/subscriptions/stats"); + return getDataOrDefault(response, emptyStats); }, staleTime: 5 * 60 * 1000, gcTime: 10 * 60 * 1000, - enabled: isAuthenticated && !!tokens?.accessToken, + enabled: isAuthenticated, }); } @@ -109,19 +109,19 @@ export function useSubscriptionStats() { * Hook to fetch a specific subscription */ export function useSubscription(subscriptionId: number) { - const { isAuthenticated, tokens } = useAuthStore(); + const { isAuthenticated } = useAuthSession(); return useQuery({ queryKey: queryKeys.subscriptions.detail(String(subscriptionId)), queryFn: async () => { - const response = await apiClient.GET("/api/subscriptions/{id}", { + const response = await apiClient.GET("/subscriptions/{id}", { params: { path: { id: subscriptionId } }, }); return getDataOrThrow(response, "Subscription not found"); }, staleTime: 5 * 60 * 1000, gcTime: 10 * 60 * 1000, - enabled: isAuthenticated && !!tokens?.accessToken && subscriptionId > 0, + enabled: isAuthenticated && subscriptionId > 0, }); } @@ -133,18 +133,18 @@ export function useSubscriptionInvoices( options: { page?: number; limit?: number } = {} ) { const { page = 1, limit = 10 } = options; - const { isAuthenticated, tokens } = useAuthStore(); + const { isAuthenticated } = useAuthSession(); return useQuery({ queryKey: queryKeys.subscriptions.invoices(subscriptionId, { page, limit }), queryFn: async () => { - const response = await apiClient.GET("/api/subscriptions/{id}/invoices", { + const response = await apiClient.GET("/subscriptions/{id}/invoices", { params: { path: { id: subscriptionId }, query: { page, limit }, }, }); - return getDataOrDefault(response, { + return getDataOrDefault(response, { ...emptyInvoiceList, pagination: { ...emptyInvoiceList.pagination, @@ -154,6 +154,6 @@ export function useSubscriptionInvoices( }, staleTime: 60 * 1000, gcTime: 5 * 60 * 1000, - enabled: isAuthenticated && !!tokens?.accessToken && subscriptionId > 0, + enabled: isAuthenticated && subscriptionId > 0, }); } diff --git a/apps/portal/src/features/subscriptions/services/sim-actions.service.ts b/apps/portal/src/features/subscriptions/services/sim-actions.service.ts index 6cd7c1eb..ba1703e0 100644 --- a/apps/portal/src/features/subscriptions/services/sim-actions.service.ts +++ b/apps/portal/src/features/subscriptions/services/sim-actions.service.ts @@ -21,21 +21,21 @@ export interface SimInfo { export const simActionsService = { async topUp(subscriptionId: string, request: TopUpRequest): Promise { - await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/top-up", { + await apiClient.POST("/subscriptions/{subscriptionId}/sim/top-up", { params: { path: { subscriptionId } }, body: request, }); }, async changePlan(subscriptionId: string, request: ChangePlanRequest): Promise { - await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/change-plan", { + await apiClient.POST("/subscriptions/{subscriptionId}/sim/change-plan", { params: { path: { subscriptionId } }, body: request, }); }, async cancel(subscriptionId: string, request: CancelRequest): Promise { - await apiClient.POST("/api/subscriptions/{subscriptionId}/sim/cancel", { + await apiClient.POST("/subscriptions/{subscriptionId}/sim/cancel", { params: { path: { subscriptionId } }, body: request, }); @@ -43,7 +43,7 @@ export const simActionsService = { async getSimInfo(subscriptionId: string): Promise | null> { const response = await apiClient.GET | null>( - "/api/subscriptions/{subscriptionId}/sim/info", + "/subscriptions/{subscriptionId}/sim/info", { params: { path: { subscriptionId } }, } diff --git a/apps/portal/src/lib/api/index.ts b/apps/portal/src/lib/api/index.ts index 40759e4f..bc4bfcbd 100644 --- a/apps/portal/src/lib/api/index.ts +++ b/apps/portal/src/lib/api/index.ts @@ -9,7 +9,6 @@ export * from "./response-helpers"; // Import createClient for internal use import { createClient } from "./runtime/client"; -// Create the apiClient instance that the app expects export const apiClient = createClient(); // Query keys for React Query - matching the expected structure diff --git a/apps/portal/src/lib/api/runtime/client.ts b/apps/portal/src/lib/api/runtime/client.ts index 88248e09..b1fae16e 100644 --- a/apps/portal/src/lib/api/runtime/client.ts +++ b/apps/portal/src/lib/api/runtime/client.ts @@ -135,19 +135,43 @@ export function createClient(options: CreateClientOptions = {}): ApiClient { const handleError = options.handleError ?? defaultHandleError; + const normalizePath = (path: string): string => { + if (!path) return "/api"; + const ensured = path.startsWith("/") ? path : `/${path}`; + if (ensured === "/api" || ensured.startsWith("/api/")) { + return ensured; + } + return `/api${ensured}`; + }; + if (typeof client.use === "function") { const resolveAuthHeader = options.getAuthHeader; const middleware: Middleware = { onRequest({ request }: MiddlewareCallbackParams) { - if (!resolveAuthHeader) return; - if (!request || typeof request.headers?.has !== "function") return; - if (request.headers.has("Authorization")) return; + if (!request) return; + + const nextRequest = new Request(request, { + credentials: "include", + }); + + if (!resolveAuthHeader) { + return nextRequest; + } + if (typeof nextRequest.headers?.has !== "function") { + return nextRequest; + } + if (nextRequest.headers.has("Authorization")) { + return nextRequest; + } const headerValue = resolveAuthHeader(); - if (!headerValue) return; + if (!headerValue) { + return nextRequest; + } - request.headers.set("Authorization", headerValue); + nextRequest.headers.set("Authorization", headerValue); + return nextRequest; }, async onResponse({ response }: MiddlewareCallbackParams & { response: Response }) { await handleError(response); @@ -157,7 +181,29 @@ export function createClient(options: CreateClientOptions = {}): ApiClient { client.use(middleware as never); } - return client as ApiClient; + const flexibleClient = client as ApiClient; + + flexibleClient.GET = (async (path: string, options?: unknown) => { + return (client.GET as FlexibleApiMethods["GET"])(normalizePath(path), options); + }) as ApiClient["GET"]; + + flexibleClient.POST = (async (path: string, options?: unknown) => { + return (client.POST as FlexibleApiMethods["POST"])(normalizePath(path), options); + }) as ApiClient["POST"]; + + flexibleClient.PUT = (async (path: string, options?: unknown) => { + return (client.PUT as FlexibleApiMethods["PUT"])(normalizePath(path), options); + }) as ApiClient["PUT"]; + + flexibleClient.PATCH = (async (path: string, options?: unknown) => { + return (client.PATCH as FlexibleApiMethods["PATCH"])(normalizePath(path), options); + }) as ApiClient["PATCH"]; + + flexibleClient.DELETE = (async (path: string, options?: unknown) => { + return (client.DELETE as FlexibleApiMethods["DELETE"])(normalizePath(path), options); + }) as ApiClient["DELETE"]; + + return flexibleClient; } export type { paths }; diff --git a/docs/MEMORY_OPTIMIZATION.md b/docs/MEMORY_OPTIMIZATION.md new file mode 100644 index 00000000..ed136f1f --- /dev/null +++ b/docs/MEMORY_OPTIMIZATION.md @@ -0,0 +1,383 @@ +# šŸ“Š Bundle Analysis Guide + +Simple guide for analyzing and optimizing bundle sizes. + +## šŸŽÆ Quick Analysis + +### Frontend Bundle Analysis +```bash +# Analyze bundle size +pnpm analyze + +# Or use the script +pnpm bundle-analyze +``` + +### Key Metrics to Monitor +- **First Load JS**: Should be < 250KB +- **Total Bundle Size**: Should be < 1MB +- **Largest Chunks**: Identify optimization targets + +## šŸŽÆ Frontend Optimizations + +### 1. Bundle Analysis & Code Splitting + +```bash +# Analyze current bundle size +cd apps/portal +pnpm run analyze + +# Build with analysis +pnpm run build:analyze +``` + +### 2. Dynamic Imports + +```typescript +// Before: Static import +import { HeavyComponent } from './HeavyComponent'; + +// After: Dynamic import +const HeavyComponent = lazy(() => import('./HeavyComponent')); + +// Route-level code splitting +const Dashboard = lazy(() => import('./pages/Dashboard')); +const Orders = lazy(() => import('./pages/Orders')); +``` + +### 3. Image Optimization + +```typescript +// Use Next.js Image component with optimization +import Image from 'next/image'; + +Hero +``` + +### 4. Tree Shaking Optimization + +```typescript +// Before: Import entire library +import * as _ from 'lodash'; + +// After: Import specific functions +import { debounce, throttle } from 'lodash-es'; + +// Or use individual packages +import debounce from 'lodash.debounce'; +``` + +### 5. React Query Optimization + +```typescript +// Optimize React Query cache +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // Reduce memory usage + cacheTime: 5 * 60 * 1000, // 5 minutes + staleTime: 1 * 60 * 1000, // 1 minute + // Limit concurrent queries + refetchOnWindowFocus: false, + }, + }, +}); +``` + +## šŸŽÆ Backend Optimizations + +### 1. Heap Size Optimization + +```json +// package.json - Optimized heap sizes +{ + "scripts": { + "dev": "NODE_OPTIONS=\"--max-old-space-size=2048\" nest start --watch", + "build": "NODE_OPTIONS=\"--max-old-space-size=3072\" nest build", + "type-check": "NODE_OPTIONS=\"--max-old-space-size=4096\" tsc --noEmit" + } +} +``` + +### 2. Streaming Responses + +```typescript +// For large data responses +@Get('large-dataset') +async getLargeDataset(@Res() res: Response) { + const stream = this.dataService.createDataStream(); + + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Transfer-Encoding', 'chunked'); + + stream.pipe(res); +} +``` + +### 3. Memory-Efficient Pagination + +```typescript +// Cursor-based pagination instead of offset +interface PaginationOptions { + cursor?: string; + limit: number; // Max 100 +} + +async findWithCursor(options: PaginationOptions) { + return this.prisma.order.findMany({ + take: Math.min(options.limit, 100), + ...(options.cursor && { + cursor: { id: options.cursor }, + skip: 1, + }), + orderBy: { createdAt: 'desc' }, + }); +} +``` + +### 4. Request/Response Caching + +```typescript +// Memory-efficient caching +@Injectable() +export class CacheService { + private readonly cache = new Map(); + private readonly maxSize = 1000; // Limit cache size + + set(key: string, data: any, ttl: number = 300000) { + // Implement LRU eviction + if (this.cache.size >= this.maxSize) { + const firstKey = this.cache.keys().next().value; + this.cache.delete(firstKey); + } + + this.cache.set(key, { + data, + expires: Date.now() + ttl, + }); + } +} +``` + +### 5. Database Connection Optimization + +```typescript +// Optimize Prisma connection pool +const prisma = new PrismaClient({ + datasources: { + db: { + url: process.env.DATABASE_URL, + }, + }, + // Optimize connection pool + __internal: { + engine: { + connectionLimit: 10, // Reduce from default 20 + }, + }, +}); +``` + +## šŸŽÆ Dependency Optimizations + +### 1. Replace Heavy Dependencies + +```bash +# Before: moment.js (67KB) +npm uninstall moment + +# After: date-fns (13KB with tree shaking) +npm install date-fns + +# Before: lodash (71KB) +npm uninstall lodash + +# After: Individual functions or native alternatives +npm install lodash-es # Better tree shaking +``` + +### 2. Bundle Analysis Results + +```bash +# Run bundle analysis +./scripts/memory-optimization.sh + +# Key metrics to monitor: +# - First Load JS: < 250KB +# - Total Bundle Size: < 1MB +# - Largest Chunks: Identify optimization targets +``` + +### 3. Webpack Optimizations (Already Implemented) + +- **Code Splitting**: Separate vendor, common, and UI chunks +- **Tree Shaking**: Remove unused code +- **Compression**: Gzip/Brotli compression +- **Caching**: Long-term caching for static assets + +## šŸŽÆ Runtime Optimizations + +### 1. Memory Leak Detection + +```typescript +// Add memory monitoring +@Injectable() +export class MemoryMonitorService { + @Cron('*/5 * * * *') // Every 5 minutes + checkMemoryUsage() { + const usage = process.memoryUsage(); + + if (usage.heapUsed > 500 * 1024 * 1024) { // 500MB + this.logger.warn('High memory usage detected', { + heapUsed: `${Math.round(usage.heapUsed / 1024 / 1024)}MB`, + heapTotal: `${Math.round(usage.heapTotal / 1024 / 1024)}MB`, + external: `${Math.round(usage.external / 1024 / 1024)}MB`, + }); + } + } +} +``` + +### 2. Garbage Collection Optimization + +```bash +# Enable GC logging in production +NODE_OPTIONS="--max-old-space-size=2048 --gc-interval=100" npm start + +# Monitor GC patterns +NODE_OPTIONS="--trace-gc --trace-gc-verbose" npm run dev +``` + +### 3. Worker Threads for CPU-Intensive Tasks + +```typescript +// For heavy computations +import { Worker, isMainThread, parentPort } from 'worker_threads'; + +if (isMainThread) { + // Main thread + const worker = new Worker(__filename); + worker.postMessage({ data: largeDataset }); + + worker.on('message', (result) => { + // Handle processed result + }); +} else { + // Worker thread + parentPort?.on('message', ({ data }) => { + const result = processLargeDataset(data); + parentPort?.postMessage(result); + }); +} +``` + +## šŸ“ˆ Monitoring & Metrics + +### 1. Performance Monitoring + +```typescript +// Add performance metrics +@Injectable() +export class PerformanceService { + trackMemoryUsage(operation: string) { + const start = process.memoryUsage(); + + return { + end: () => { + const end = process.memoryUsage(); + const diff = { + heapUsed: end.heapUsed - start.heapUsed, + heapTotal: end.heapTotal - start.heapTotal, + }; + + this.logger.debug(`Memory usage for ${operation}`, diff); + }, + }; + } +} +``` + +### 2. Bundle Size Monitoring + +```json +// Add to CI/CD pipeline +{ + "scripts": { + "build:check-size": "npm run build && bundlesize" + }, + "bundlesize": [ + { + "path": ".next/static/js/*.js", + "maxSize": "250kb" + }, + { + "path": ".next/static/css/*.css", + "maxSize": "50kb" + } + ] +} +``` + +## šŸš€ Implementation Checklist + +### Immediate Actions (Week 1) +- [ ] Run bundle analysis: `pnpm run analyze` +- [ ] Implement dynamic imports for heavy components +- [ ] Optimize image loading with Next.js Image +- [ ] Reduce heap allocation in development + +### Short-term (Week 2-3) +- [ ] Replace heavy dependencies (moment → date-fns) +- [ ] Implement request caching +- [ ] Add memory monitoring +- [ ] Optimize database connection pool + +### Long-term (Month 1) +- [ ] Implement streaming for large responses +- [ ] Add worker threads for CPU-intensive tasks +- [ ] Set up continuous bundle size monitoring +- [ ] Implement advanced caching strategies + +## šŸŽÆ Expected Results + +### Memory Reduction Targets +- **Frontend Bundle**: 30-50% reduction +- **Backend Heap**: 25-40% reduction +- **Build Time**: 20-30% improvement +- **Runtime Memory**: 35-50% reduction + +### Performance Improvements +- **First Load**: < 2 seconds +- **Page Transitions**: < 500ms +- **API Response**: < 200ms (95th percentile) +- **Memory Stability**: No memory leaks in 24h+ runs + +## šŸ”§ Tools & Commands + +```bash +# Frontend analysis +cd apps/portal && pnpm run analyze + +# Backend memory check +cd apps/bff && NODE_OPTIONS="--trace-gc" pnpm dev + +# Full optimization analysis +./scripts/memory-optimization.sh + +# Dependency audit +pnpm audit --recursive + +# Bundle size check +pnpm run build && ls -la .next/static/js/ +``` + +--- + +**Note**: Always test memory optimizations in a staging environment before deploying to production. Monitor application performance and user experience after implementing changes. diff --git a/eslint-report.json b/eslint-report.json deleted file mode 100644 index fa9be041..00000000 --- a/eslint-report.json +++ /dev/null @@ -1 +0,0 @@ -[{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/next.config.mjs","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/postcss.config.mjs","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/scripts/bundle-monitor.mjs","messages":[{"ruleId":"no-unused-vars","severity":2,"message":"'PERFORMANCE_BUDGET' is assigned a value but never used.","line":22,"column":7,"nodeType":"Identifier","messageId":"unusedVar","endLine":22,"endColumn":25,"suggestions":[{"messageId":"removeVar","data":{"varName":"PERFORMANCE_BUDGET"},"fix":{"range":[514,787],"text":""},"desc":"Remove unused variable 'PERFORMANCE_BUDGET'."}]},{"ruleId":"no-undef","severity":2,"message":"'console' is not defined.","line":45,"column":7,"nodeType":"Identifier","messageId":"undef","endLine":45,"endColumn":14},{"ruleId":"no-undef","severity":2,"message":"'process' is not defined.","line":46,"column":7,"nodeType":"Identifier","messageId":"undef","endLine":46,"endColumn":14},{"ruleId":"no-undef","severity":2,"message":"'console' is not defined.","line":79,"column":7,"nodeType":"Identifier","messageId":"undef","endLine":79,"endColumn":14},{"ruleId":"no-undef","severity":2,"message":"'process' is not defined.","line":80,"column":7,"nodeType":"Identifier","messageId":"undef","endLine":80,"endColumn":14},{"ruleId":"no-unused-vars","severity":2,"message":"'error' is defined but never used.","line":96,"column":14,"nodeType":"Identifier","messageId":"unusedVar","endLine":96,"endColumn":19},{"ruleId":"no-unused-vars","severity":2,"message":"'issues' is defined but never used.","line":142,"column":37,"nodeType":"Identifier","messageId":"unusedVar","endLine":142,"endColumn":43,"suggestions":[{"messageId":"removeVar","data":{"varName":"issues"},"fix":{"range":[3870,3878],"text":""},"desc":"Remove unused variable 'issues'."}]},{"ruleId":"no-undef","severity":2,"message":"'console' is not defined.","line":190,"column":9,"nodeType":"Identifier","messageId":"undef","endLine":190,"endColumn":16},{"ruleId":"no-undef","severity":2,"message":"'console' is not defined.","line":202,"column":7,"nodeType":"Identifier","messageId":"undef","endLine":202,"endColumn":14},{"ruleId":"no-undef","severity":2,"message":"'console' is not defined.","line":204,"column":7,"nodeType":"Identifier","messageId":"undef","endLine":204,"endColumn":14},{"ruleId":"no-undef","severity":2,"message":"'console' is not defined.","line":230,"column":5,"nodeType":"Identifier","messageId":"undef","endLine":230,"endColumn":12},{"ruleId":"no-undef","severity":2,"message":"'console' is not defined.","line":255,"column":7,"nodeType":"Identifier","messageId":"undef","endLine":255,"endColumn":14},{"ruleId":"no-undef","severity":2,"message":"'process' is not defined.","line":256,"column":7,"nodeType":"Identifier","messageId":"undef","endLine":256,"endColumn":14},{"ruleId":"no-undef","severity":2,"message":"'console' is not defined.","line":258,"column":7,"nodeType":"Identifier","messageId":"undef","endLine":258,"endColumn":14},{"ruleId":"no-undef","severity":2,"message":"'console' is not defined.","line":269,"column":5,"nodeType":"Identifier","messageId":"undef","endLine":269,"endColumn":12},{"ruleId":"no-undef","severity":2,"message":"'console' is not defined.","line":270,"column":5,"nodeType":"Identifier","messageId":"undef","endLine":270,"endColumn":12},{"ruleId":"no-undef","severity":2,"message":"'console' is not defined.","line":271,"column":5,"nodeType":"Identifier","messageId":"undef","endLine":271,"endColumn":12},{"ruleId":"no-undef","severity":2,"message":"'console' is not defined.","line":272,"column":5,"nodeType":"Identifier","messageId":"undef","endLine":272,"endColumn":12},{"ruleId":"no-undef","severity":2,"message":"'console' is not defined.","line":277,"column":7,"nodeType":"Identifier","messageId":"undef","endLine":277,"endColumn":14},{"ruleId":"no-undef","severity":2,"message":"'console' is not defined.","line":283,"column":5,"nodeType":"Identifier","messageId":"undef","endLine":283,"endColumn":12},{"ruleId":"no-undef","severity":2,"message":"'console' is not defined.","line":284,"column":5,"nodeType":"Identifier","messageId":"undef","endLine":284,"endColumn":12},{"ruleId":"no-undef","severity":2,"message":"'console' is not defined.","line":289,"column":9,"nodeType":"Identifier","messageId":"undef","endLine":289,"endColumn":16},{"ruleId":"no-undef","severity":2,"message":"'console' is not defined.","line":294,"column":7,"nodeType":"Identifier","messageId":"undef","endLine":294,"endColumn":14},{"ruleId":"no-undef","severity":2,"message":"'console' is not defined.","line":295,"column":7,"nodeType":"Identifier","messageId":"undef","endLine":295,"endColumn":14},{"ruleId":"no-undef","severity":2,"message":"'console' is not defined.","line":298,"column":9,"nodeType":"Identifier","messageId":"undef","endLine":298,"endColumn":16},{"ruleId":"no-undef","severity":2,"message":"'console' is not defined.","line":304,"column":7,"nodeType":"Identifier","messageId":"undef","endLine":304,"endColumn":14},{"ruleId":"no-undef","severity":2,"message":"'console' is not defined.","line":305,"column":7,"nodeType":"Identifier","messageId":"undef","endLine":305,"endColumn":14},{"ruleId":"no-undef","severity":2,"message":"'console' is not defined.","line":307,"column":9,"nodeType":"Identifier","messageId":"undef","endLine":307,"endColumn":16},{"ruleId":"no-undef","severity":2,"message":"'console' is not defined.","line":309,"column":39,"nodeType":"Identifier","messageId":"undef","endLine":309,"endColumn":46}],"suppressedMessages":[],"errorCount":29,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"#!/usr/bin/env node\n\n/**\n * Bundle size monitoring script\n * Analyzes bundle size and reports on performance metrics\n */\n\nimport { readFileSync, writeFileSync, existsSync } from \"fs\";\nimport { join, dirname } from \"path\";\nimport { fileURLToPath } from \"url\";\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = dirname(__filename);\n\nconst BUNDLE_SIZE_LIMIT = {\n // Size limits in KB\n total: 1000, // 1MB total\n individual: 250, // 250KB per chunk\n vendor: 500, // 500KB for vendor chunks\n};\n\nconst PERFORMANCE_BUDGET = {\n // Performance budget thresholds\n fcp: 1800, // First Contentful Paint (ms)\n lcp: 2500, // Largest Contentful Paint (ms)\n fid: 100, // First Input Delay (ms)\n cls: 0.1, // Cumulative Layout Shift\n ttfb: 800, // Time to First Byte (ms)\n};\n\nclass BundleMonitor {\n constructor() {\n this.projectRoot = join(__dirname, \"..\");\n this.buildDir = join(this.projectRoot, \".next\");\n this.reportFile = join(this.projectRoot, \"bundle-report.json\");\n }\n\n /**\n * Analyze bundle size from Next.js build output\n */\n analyzeBundleSize() {\n const buildManifest = join(this.buildDir, \"build-manifest.json\");\n\n if (!existsSync(buildManifest)) {\n console.error('Build manifest not found. Run \"npm run build\" first.');\n process.exit(1);\n }\n\n try {\n const manifest = JSON.parse(readFileSync(buildManifest, \"utf8\"));\n const chunks = [];\n let totalSize = 0;\n\n // Analyze JavaScript chunks\n Object.entries(manifest.pages || {}).forEach(([page, files]) => {\n files.forEach(file => {\n if (file.endsWith(\".js\")) {\n const filePath = join(this.buildDir, \"static\", file);\n if (existsSync(filePath)) {\n const stats = this.getFileStats(filePath);\n chunks.push({\n page,\n file,\n size: stats.size,\n gzippedSize: stats.gzippedSize,\n });\n totalSize += stats.size;\n }\n }\n });\n });\n\n return {\n totalSize,\n chunks,\n timestamp: Date.now(),\n };\n } catch (error) {\n console.error(\"Error analyzing bundle:\", error.message);\n process.exit(1);\n }\n }\n\n /**\n * Get file statistics including gzipped size\n */\n getFileStats(filePath) {\n try {\n const content = readFileSync(filePath);\n const size = content.length;\n\n // Estimate gzipped size (rough approximation)\n const gzippedSize = Math.round(size * 0.3); // Typical compression ratio\n\n return { size, gzippedSize };\n } catch (error) {\n return { size: 0, gzippedSize: 0 };\n }\n }\n\n /**\n * Check if bundle sizes are within limits\n */\n checkBundleLimits(analysis) {\n const issues = [];\n\n // Check total size\n const totalSizeKB = analysis.totalSize / 1024;\n if (totalSizeKB > BUNDLE_SIZE_LIMIT.total) {\n issues.push({\n type: \"total_size\",\n message: `Total bundle size (${totalSizeKB.toFixed(1)}KB) exceeds limit (${BUNDLE_SIZE_LIMIT.total}KB)`,\n severity: \"error\",\n });\n }\n\n // Check individual chunks\n analysis.chunks.forEach(chunk => {\n const sizeKB = chunk.size / 1024;\n\n if (sizeKB > BUNDLE_SIZE_LIMIT.individual) {\n const isVendor = chunk.file.includes(\"vendor\") || chunk.file.includes(\"node_modules\");\n const limit = isVendor ? BUNDLE_SIZE_LIMIT.vendor : BUNDLE_SIZE_LIMIT.individual;\n\n if (sizeKB > limit) {\n issues.push({\n type: \"chunk_size\",\n message: `Chunk ${chunk.file} (${sizeKB.toFixed(1)}KB) exceeds limit (${limit}KB)`,\n severity: \"warning\",\n chunk: chunk.file,\n });\n }\n }\n });\n\n return issues;\n }\n\n /**\n * Generate recommendations for bundle optimization\n */\n generateRecommendations(analysis, issues) {\n const recommendations = [];\n\n // Large chunks recommendations\n const largeChunks = analysis.chunks\n .filter(chunk => chunk.size / 1024 > 100)\n .sort((a, b) => b.size - a.size);\n\n if (largeChunks.length > 0) {\n recommendations.push({\n type: \"code_splitting\",\n message: \"Consider implementing code splitting for large chunks\",\n chunks: largeChunks.slice(0, 5).map(c => c.file),\n });\n }\n\n // Vendor chunk recommendations\n const vendorChunks = analysis.chunks.filter(\n chunk => chunk.file.includes(\"vendor\") || chunk.file.includes(\"framework\")\n );\n\n if (vendorChunks.some(chunk => chunk.size / 1024 > 300)) {\n recommendations.push({\n type: \"vendor_optimization\",\n message: \"Consider optimizing vendor chunks or using dynamic imports\",\n });\n }\n\n // Duplicate code detection (simplified)\n const pageChunks = analysis.chunks.filter(chunk => chunk.page !== \"_app\");\n if (pageChunks.length > 10) {\n recommendations.push({\n type: \"common_chunks\",\n message: \"Consider extracting common code into shared chunks\",\n });\n }\n\n return recommendations;\n }\n\n /**\n * Load previous report for comparison\n */\n loadPreviousReport() {\n if (existsSync(this.reportFile)) {\n try {\n return JSON.parse(readFileSync(this.reportFile, \"utf8\"));\n } catch (error) {\n console.warn(\"Could not load previous report:\", error.message);\n }\n }\n return null;\n }\n\n /**\n * Save current report\n */\n saveReport(report) {\n try {\n writeFileSync(this.reportFile, JSON.stringify(report, null, 2));\n console.log(`Report saved to ${this.reportFile}`);\n } catch (error) {\n console.error(\"Could not save report:\", error.message);\n }\n }\n\n /**\n * Compare with previous report\n */\n compareWithPrevious(current, previous) {\n if (!previous) return null;\n\n const currentTotal = current.analysis.totalSize;\n const previousTotal = previous.analysis.totalSize;\n const sizeDiff = currentTotal - previousTotal;\n const percentChange = (sizeDiff / previousTotal) * 100;\n\n return {\n sizeDiff,\n percentChange,\n isRegression: sizeDiff > 10240, // 10KB threshold\n };\n }\n\n /**\n * Generate and display report\n */\n run() {\n console.log(\"šŸ” Analyzing bundle size...\\n\");\n\n const analysis = this.analyzeBundleSize();\n const issues = this.checkBundleLimits(analysis);\n const recommendations = this.generateRecommendations(analysis, issues);\n const previous = this.loadPreviousReport();\n const comparison = this.compareWithPrevious({ analysis }, previous);\n\n const report = {\n timestamp: Date.now(),\n analysis,\n issues,\n recommendations,\n comparison,\n };\n\n // Display results\n this.displayReport(report);\n\n // Save report\n this.saveReport(report);\n\n // Exit with error code if there are critical issues\n const hasErrors = issues.some(issue => issue.severity === \"error\");\n if (hasErrors) {\n console.log(\"\\nāŒ Bundle analysis failed due to critical issues.\");\n process.exit(1);\n } else {\n console.log(\"\\nāœ… Bundle analysis completed successfully.\");\n }\n }\n\n /**\n * Display formatted report\n */\n displayReport(report) {\n const { analysis, issues, recommendations, comparison } = report;\n\n // Bundle size summary\n console.log(\"šŸ“Š Bundle Size Summary\");\n console.log(\"─\".repeat(50));\n console.log(`Total Size: ${(analysis.totalSize / 1024).toFixed(1)}KB`);\n console.log(`Chunks: ${analysis.chunks.length}`);\n\n if (comparison) {\n const sign = comparison.sizeDiff > 0 ? \"+\" : \"\";\n const color = comparison.isRegression ? \"\\x1b[31m\" : \"\\x1b[32m\";\n console.log(\n `Change: ${color}${sign}${(comparison.sizeDiff / 1024).toFixed(1)}KB (${comparison.percentChange.toFixed(1)}%)\\x1b[0m`\n );\n }\n\n // Top chunks\n console.log(\"\\nšŸ“¦ Largest Chunks\");\n console.log(\"─\".repeat(50));\n analysis.chunks\n .sort((a, b) => b.size - a.size)\n .slice(0, 10)\n .forEach(chunk => {\n console.log(`${(chunk.size / 1024).toFixed(1)}KB - ${chunk.file}`);\n });\n\n // Issues\n if (issues.length > 0) {\n console.log(\"\\nāš ļø Issues Found\");\n console.log(\"─\".repeat(50));\n issues.forEach(issue => {\n const icon = issue.severity === \"error\" ? \"āŒ\" : \"āš ļø \";\n console.log(`${icon} ${issue.message}`);\n });\n }\n\n // Recommendations\n if (recommendations.length > 0) {\n console.log(\"\\nšŸ’” Recommendations\");\n console.log(\"─\".repeat(50));\n recommendations.forEach(rec => {\n console.log(`• ${rec.message}`);\n if (rec.chunks) {\n rec.chunks.forEach(chunk => console.log(` - ${chunk}`));\n }\n });\n }\n }\n}\n\n// Run the monitor\nconst monitor = new BundleMonitor();\nmonitor.run();\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/scripts/dev-prep.mjs","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/(portal)/account/profile/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/(portal)/billing/invoices/[id]/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/(portal)/billing/invoices/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/(portal)/billing/payments/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/(portal)/catalog/internet/configure/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/(portal)/catalog/internet/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/(portal)/catalog/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/(portal)/catalog/sim/configure/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/(portal)/catalog/sim/page.tsx","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'Link' is defined but never used.","line":4,"column":8,"nodeType":null,"messageId":"unusedVar","endLine":4,"endColumn":12},{"ruleId":"react-hooks/exhaustive-deps","severity":1,"message":"The 'plans' logical expression could make the dependencies of useEffect Hook (at line 141) change on every render. To fix this, wrap the initialization of 'plans' in its own useMemo() Hook.","line":132,"column":9,"nodeType":"VariableDeclarator","endLine":132,"endColumn":34}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\"use client\";\n\nimport { useState, useEffect } from \"react\";\nimport Link from \"next/link\";\nimport { PageLayout } from \"@/components/layout/PageLayout\";\nimport {\n DevicePhoneMobileIcon,\n CurrencyYenIcon,\n CheckIcon,\n InformationCircleIcon,\n UsersIcon,\n PhoneIcon,\n GlobeAltIcon,\n ArrowLeftIcon,\n} from \"@heroicons/react/24/outline\";\nimport { useSimCatalog } from \"@/features/catalog/hooks\";\n\nimport { SimPlan } from \"@/shared/types/catalog.types\";\nimport { LoadingSpinner } from \"@/components/ui/loading-spinner\";\nimport { AnimatedCard } from \"@/components/ui\";\nimport { Button } from \"@/components/ui/button\";\n\ninterface PlansByType {\n DataOnly: SimPlan[];\n DataSmsVoice: SimPlan[];\n VoiceOnly: SimPlan[];\n}\n\nfunction PlanTypeSection({\n title,\n description,\n icon,\n plans,\n showFamilyDiscount,\n}: {\n title: string;\n description: string;\n icon: React.ReactNode;\n plans: SimPlan[];\n showFamilyDiscount: boolean;\n}) {\n if (plans.length === 0) return null;\n\n // Separate regular and family plans\n const regularPlans = plans.filter(p => !p.hasFamilyDiscount);\n const familyPlans = plans.filter(p => p.hasFamilyDiscount);\n\n return (\n
\n
\n {icon}\n
\n

{title}

\n

{description}

\n
\n
\n\n {/* Regular Plans */}\n
\n {regularPlans.map(plan => (\n \n ))}\n
\n\n {/* Family Discount Plans */}\n {showFamilyDiscount && familyPlans.length > 0 && (\n <>\n
\n \n

Family Discount Options

\n \n You qualify!\n \n
\n
\n {familyPlans.map(plan => (\n \n ))}\n
\n \n )}\n
\n );\n}\n\nfunction PlanCard({ plan, isFamily }: { plan: SimPlan; isFamily: boolean }) {\n return (\n \n
\n
\n
\n \n {plan.dataSize}\n
\n {isFamily && (\n
\n \n \n Family\n \n
\n )}\n
\n
\n\n
\n
\n \n \n {plan.monthlyPrice?.toLocaleString()}\n \n /month\n
\n {isFamily && (\n
Discounted price
\n )}\n
\n\n
\n

{plan.description}

\n
\n\n \n
\n );\n}\n\nexport default function SimPlansPage() {\n const { data, isLoading, error } = useSimCatalog();\n const plans = data?.plans || [];\n const [hasExistingSim, setHasExistingSim] = useState(false);\n const [activeTab, setActiveTab] = useState<\"data-voice\" | \"data-only\" | \"voice-only\">(\n \"data-voice\"\n );\n\n useEffect(() => {\n // Check if any plans have family discount (indicates user has existing SIM)\n setHasExistingSim(plans.some(p => p.hasFamilyDiscount));\n }, [plans]);\n\n if (isLoading) {\n return (\n }\n >\n
\n \n
\n \n );\n }\n\n if (error) {\n const errorMessage = error instanceof Error ? error.message : \"An unexpected error occurred\";\n return (\n }\n >\n
\n
Failed to load SIM plans
\n
{errorMessage}
\n \n
\n \n );\n }\n\n // Group plans by type\n const plansByType: PlansByType = plans.reduce(\n (acc, plan) => {\n acc[plan.planType].push(plan);\n return acc;\n },\n {\n DataOnly: [] as SimPlan[],\n DataSmsVoice: [] as SimPlan[],\n VoiceOnly: [] as SimPlan[],\n }\n );\n\n return (\n }\n >\n
\n {/* Navigation */}\n
\n \n
\n\n
\n

Choose Your SIM Plan

\n

\n Wide range of data options and voice plans with both physical SIM and eSIM options.\n

\n
\n {/* Family Discount Banner */}\n {hasExistingSim && (\n
\n
\n
\n \n
\n
\n

\n šŸŽ‰ Family Discount Available!\n

\n

\n You have existing SIM services, so you qualify for family discount pricing on\n additional lines.\n

\n
\n
\n \n Reduced monthly pricing\n
\n
\n \n Same great features\n
\n
\n \n Easy to manage\n
\n
\n
\n
\n
\n )}\n\n {/* Tab Navigation */}\n
\n
\n \n
\n
\n\n {/* Tab Content */}\n
\n \n {activeTab === \"data-voice\" && (\n }\n plans={plansByType.DataSmsVoice}\n showFamilyDiscount={hasExistingSim}\n />\n )}\n
\n\n \n {activeTab === \"data-only\" && (\n }\n plans={plansByType.DataOnly}\n showFamilyDiscount={hasExistingSim}\n />\n )}\n
\n\n \n {activeTab === \"voice-only\" && (\n }\n plans={plansByType.VoiceOnly}\n showFamilyDiscount={hasExistingSim}\n />\n )}\n \n \n\n {/* Features Section */}\n
\n

\n Plan Features & Terms\n

\n
\n
\n \n
\n
3-Month Contract
\n
Minimum 3 billing months
\n
\n
\n
\n \n
\n
First Month Free
\n
Basic fee waived initially
\n
\n
\n
\n \n
\n
5G Network
\n
High-speed coverage
\n
\n
\n
\n \n
\n
eSIM Support
\n
Digital activation
\n
\n
\n
\n \n
\n
Family Discounts
\n
Multi-line savings
\n
\n
\n
\n \n
\n
Plan Switching
\n
Free data plan changes
\n
\n
\n
\n
\n\n {/* Info Section */}\n
\n
\n \n
\n
Important Terms & Conditions
\n
\n
\n
\n
\n
\n
Contract Period
\n

\n Minimum 3 full billing months required. First month (sign-up to end of month) is\n free and doesn't count toward contract.\n

\n
\n
\n
Billing Cycle
\n

\n Monthly billing from 1st to end of month. Regular billing starts on 1st of\n following month after sign-up.\n

\n
\n
\n
Cancellation
\n

\n Can be requested online after 3rd month. Service terminates at end of billing\n cycle.\n

\n
\n
\n
\n
\n
Plan Changes
\n

\n Data plan switching is free and takes effect next month. Voice plan changes\n require new SIM and cancellation policies apply.\n

\n
\n
\n
Calling/SMS Charges
\n

\n Pay-per-use charges apply separately. Billed 5-6 weeks after usage within billing\n cycle.\n

\n
\n
\n
SIM Replacement
\n

\n Reissue fee of 1,500 JPY applies for damaged, lost, or replacement SIM cards.\n

\n
\n
\n
\n
\n \n \n );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/(portal)/catalog/vpn/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/(portal)/checkout/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/(portal)/dashboard/page.tsx","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'useDashboardStore' is defined but never used.","line":9,"column":10,"nodeType":null,"messageId":"unusedVar","endLine":9,"endColumn":27},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'CreditCardIcon' is defined but never used.","line":14,"column":3,"nodeType":null,"messageId":"unusedVar","endLine":14,"endColumn":17},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'ExclamationTriangleIcon' is defined but never used.","line":17,"column":3,"nodeType":null,"messageId":"unusedVar","endLine":17,"endColumn":26},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'PlusIcon' is defined but never used.","line":19,"column":3,"nodeType":null,"messageId":"unusedVar","endLine":19,"endColumn":11},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'ArrowTrendingUpIcon' is defined but never used.","line":21,"column":3,"nodeType":null,"messageId":"unusedVar","endLine":21,"endColumn":22},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'BellIcon' is defined but never used.","line":23,"column":3,"nodeType":null,"messageId":"unusedVar","endLine":23,"endColumn":11},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'ClipboardDocumentListIcon' is defined but never used.","line":24,"column":3,"nodeType":null,"messageId":"unusedVar","endLine":24,"endColumn":28},{"ruleId":"prettier/prettier","severity":1,"message":"Delete `;`","line":56,"column":9,"nodeType":null,"messageId":"delete","endLine":56,"endColumn":10,"fix":{"range":[2157,2158],"text":""}},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'truncateName' is defined but never used.","line":298,"column":10,"nodeType":null,"messageId":"unusedVar","endLine":298,"endColumn":22},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":303,"column":85,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":303,"endColumn":88,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[12711,12714],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[12711,12714],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-unsafe-argument","severity":2,"message":"Unsafe argument of type `any` assigned to a parameter of type `{ nextInvoice?: { id: number; } | null | undefined; stats?: { unpaidInvoices?: number | undefined; openCases?: number | undefined; } | undefined; }`.","line":307,"column":40,"nodeType":"Identifier","messageId":"unsafeArgument","endLine":307,"endColumn":47}],"suppressedMessages":[],"errorCount":2,"fatalErrorCount":0,"warningCount":9,"fixableErrorCount":0,"fixableWarningCount":1,"source":"\"use client\";\nimport { logger } from \"@/lib/logger\";\n\nimport { useState } from \"react\";\nimport Link from \"next/link\";\nimport { useRouter } from \"next/navigation\";\nimport { useAuthStore } from \"@/lib/auth/store\";\nimport { useDashboardSummary } from \"@/features/dashboard/hooks/useDashboardSummary\";\nimport { useDashboardStore } from \"@/features/dashboard/stores/dashboard.store\";\nimport { generateDashboardTasks } from \"@/features/dashboard/utils/dashboard.utils\";\n\nimport type { Activity } from \"@customer-portal/shared\";\nimport {\n CreditCardIcon,\n ServerIcon,\n ChatBubbleLeftRightIcon,\n ExclamationTriangleIcon,\n ChevronRightIcon,\n PlusIcon,\n DocumentTextIcon,\n ArrowTrendingUpIcon,\n CalendarDaysIcon,\n BellIcon,\n ClipboardDocumentListIcon,\n} from \"@heroicons/react/24/outline\";\nimport {\n CreditCardIcon as CreditCardIconSolid,\n ServerIcon as ServerIconSolid,\n ChatBubbleLeftRightIcon as ChatBubbleLeftRightIconSolid,\n ClipboardDocumentListIcon as ClipboardDocumentListIconSolid,\n} from \"@heroicons/react/24/solid\";\nimport { format, formatDistanceToNow } from \"date-fns\";\nimport { StatCard, QuickAction, ActivityFeed } from \"@/features/dashboard/components\";\nimport { LoadingSpinner } from \"@/components/ui/loading-spinner\";\nimport { ErrorState } from \"@/components/ui/error-state\";\nimport { formatCurrency, getCurrencyLocale } from \"@/utils/currency\";\n\nexport default function DashboardPage() {\n const router = useRouter();\n const { user, isAuthenticated, isLoading: authLoading } = useAuthStore();\n const { data: summary, isLoading: summaryLoading, error } = useDashboardSummary();\n\n const [paymentLoading, setPaymentLoading] = useState(false);\n const [paymentError, setPaymentError] = useState(null);\n\n // Handle Pay Now functionality\n const handlePayNow = (invoiceId: number) => {\n setPaymentLoading(true);\n setPaymentError(null);\n\n void (async () => {\n try {\n const { BillingService } = await import(\"@/features/billing/services/billing.service\");\n const ssoLink = await BillingService.createInvoiceSsoLink({ invoiceId, target: \"pay\" });\n // Centralized SSO link opening\n ;(await import(\"@/lib/utils/sso\")).openSsoLink(ssoLink.url, { newTab: true });\n } catch (error) {\n logger.error(error, \"Failed to create payment link\");\n setPaymentError(error instanceof Error ? error.message : \"Failed to open payment page\");\n } finally {\n setPaymentLoading(false);\n }\n })();\n };\n\n // Handle activity item clicks\n const handleActivityClick = (activity: Activity) => {\n if (activity.type === \"invoice_created\" || activity.type === \"invoice_paid\") {\n // Use the related invoice ID for navigation\n if (activity.relatedId) {\n router.push(`/billing/invoices/${activity.relatedId}`);\n }\n }\n };\n\n if (authLoading || summaryLoading || !isAuthenticated) {\n return (\n
\n
\n \n

Loading dashboard...

\n
\n
\n );\n }\n\n // Handle error state\n if (error) {\n return (\n \n );\n }\n\n return (\n <>\n
\n
\n {/* Modern Header */}\n
\n
\n
\n
\n

\n Welcome back, {user?.firstName || user?.email?.split(\"@\")[0] || \"User\"}!\n

\n {/* Tasks chip */}\n \n
\n
Portal / Dashboard
\n
\n {/* No duplicate page-level CTAs here per guidelines */}\n
\n
\n\n {/* Modern Stats Grid */}\n
\n \n 0\n ? \"from-amber-500 to-orange-500\"\n : \"from-gray-500 to-gray-600\"\n }\n href=\"/billing/invoices\"\n zeroHint={{ text: \"Set up auto-pay\", href: \"/billing/payments\" }}\n loading={summaryLoading}\n error={!!error}\n />\n \n 0\n ? \"from-blue-500 to-cyan-500\"\n : \"from-gray-500 to-gray-600\"\n }\n href=\"/support/cases\"\n zeroHint={{ text: \"Open a ticket\", href: \"/support/new\" }}\n loading={summaryLoading}\n error={!!error}\n />\n
\n\n
\n {/* Main Content Area */}\n
\n {/* Upcoming Payment - compressed attention banner */}\n {summary?.nextInvoice && (\n \n
\n
\n
\n \n
\n
\n
\n
\n Upcoming Payment\n •\n Invoice #{summary.nextInvoice.id}\n •\n \n Due{\" \"}\n {formatDistanceToNow(new Date(summary.nextInvoice.dueDate), {\n addSuffix: true,\n })}\n \n
\n
\n {formatCurrency(summary.nextInvoice.amount, {\n currency: summary.nextInvoice.currency || \"JPY\",\n locale: getCurrencyLocale(summary.nextInvoice.currency || \"JPY\"),\n })}\n
\n
\n Exact due date:{\" \"}\n {format(new Date(summary.nextInvoice.dueDate), \"MMMM d, yyyy\")}\n
\n
\n
\n handlePayNow(summary.nextInvoice!.id)}\n disabled={paymentLoading}\n className=\"inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all disabled:opacity-50 disabled:cursor-not-allowed\"\n >\n {paymentLoading ? (\n \n Opening...\n \n ) : null}\n {paymentLoading ? \"Opening Payment...\" : \"Pay Now\"}\n {!paymentLoading && }\n \n \n View invoice\n \n
\n
\n
\n )}\n\n {/* Payment Error Display */}\n {paymentError && (\n setPaymentError(null)}\n retryLabel=\"Dismiss\"\n />\n )}\n\n {/* Recent Activity - filtered list */}\n \n
\n\n {/* Sidebar */}\n
\n {/* Quick Actions - simplified */}\n
\n
\n

Quick Actions

\n
\n
\n \n \n \n
\n
\n
\n
\n
\n \n \n );\n}\n\n// Helpers and small components (local to dashboard)\nfunction truncateName(name: string, len = 28) {\n if (name.length <= len) return name;\n return name.slice(0, Math.max(0, len - 1)) + \"…\";\n}\n\nfunction TasksChip({ summaryLoading, summary }: { summaryLoading: boolean; summary: any }) {\n const router = useRouter();\n if (summaryLoading) return null;\n\n const tasks = generateDashboardTasks(summary);\n const count = tasks.length;\n if (count === 0) return null;\n\n return (\n {\n const first = tasks[0];\n if (first.href.startsWith(\"#\")) {\n const el = document.querySelector(first.href);\n if (el) el.scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n } else {\n router.push(first.href);\n }\n }}\n className=\"inline-flex items-center rounded-full bg-blue-50 text-blue-700 px-2.5 py-1 text-xs font-medium hover:bg-blue-100 transition-colors duration-200\"\n title={tasks.map(t => t.label).join(\" • \")}\n >\n {count} task{count === 1 ? \"\" : \"s\"}\n \n );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/(portal)/layout.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/(portal)/orders/[id]/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/(portal)/orders/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/(portal)/subscriptions/[id]/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/(portal)/subscriptions/[id]/sim/cancel/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/(portal)/subscriptions/[id]/sim/change-plan/page.tsx","messages":[{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":49,"column":17,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":49,"endColumn":20,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[1667,1670],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[1667,1670],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-misused-promises","severity":2,"message":"Promise-returning function provided to attribute where a void return was expected.","line":83,"column":24,"nodeType":"JSXExpressionContainer","messageId":"voidReturnAttribute","endLine":83,"endColumn":32}],"suppressedMessages":[],"errorCount":2,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\"use client\";\n\nimport { useState, useMemo } from \"react\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\nimport { simActionsService } from \"@/features/subscriptions/services/sim-actions.service\";\n\nconst PLAN_CODES = [\"PASI_5G\", \"PASI_10G\", \"PASI_25G\", \"PASI_50G\"] as const;\ntype PlanCode = (typeof PLAN_CODES)[number];\nconst PLAN_LABELS: Record = {\n PASI_5G: \"5GB\",\n PASI_10G: \"10GB\",\n PASI_25G: \"25GB\",\n PASI_50G: \"50GB\",\n};\n\nexport default function SimChangePlanPage() {\n const params = useParams();\n const subscriptionId = parseInt(params.id as string);\n const [currentPlanCode] = useState(\"\");\n const [newPlanCode, setNewPlanCode] = useState<\"\" | PlanCode>(\"\");\n const [assignGlobalIp, setAssignGlobalIp] = useState(false);\n const [scheduledAt, setScheduledAt] = useState(\"\");\n const [message, setMessage] = useState(null);\n const [error, setError] = useState(null);\n const [loading, setLoading] = useState(false);\n\n const options = useMemo(\n () => (PLAN_CODES as readonly PlanCode[]).filter(c => c !== (currentPlanCode as PlanCode)),\n [currentPlanCode]\n );\n\n const submit = async (e: React.FormEvent) => {\n e.preventDefault();\n if (!newPlanCode) {\n setError(\"Please select a new plan\");\n return;\n }\n setLoading(true);\n setMessage(null);\n setError(null);\n try {\n await simActionsService.changePlan(subscriptionId, {\n newPlanCode,\n assignGlobalIp,\n scheduledAt: scheduledAt ? scheduledAt.replace(/-/g, \"\") : undefined,\n });\n setMessage(\"Plan change submitted successfully\");\n } catch (e: any) {\n setError(e instanceof Error ? e.message : \"Failed to change plan\");\n } finally {\n setLoading(false);\n }\n };\n\n return (\n
\n
\n \n ← Back to SIM Management\n \n
\n
\n

Change Plan

\n

\n Change Plan: Switch to a different data plan. Important: Plan changes must be requested\n before the 25th of the month. Changes will take effect on the 1st of the following month.\n

\n {message && (\n
\n {message}\n
\n )}\n {error && (\n
\n {error}\n
\n )}\n\n
\n
\n \n setNewPlanCode(e.target.value as PlanCode)}\n className=\"w-full px-3 py-2 border border-gray-300 rounded-md\"\n >\n \n {options.map(code => (\n \n ))}\n \n
\n\n
\n setAssignGlobalIp(e.target.checked)}\n className=\"h-4 w-4 text-blue-600 border-gray-300 rounded\"\n />\n \n
\n\n
\n \n setScheduledAt(e.target.value)}\n className=\"w-full px-3 py-2 border border-gray-300 rounded-md\"\n />\n
\n\n
\n \n {loading ? \"Processing…\" : \"Submit Plan Change\"}\n \n \n Back\n \n
\n
\n
\n
\n );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/(portal)/subscriptions/[id]/sim/reissue/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/(portal)/subscriptions/[id]/sim/top-up/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/(portal)/subscriptions/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/(portal)/support/cases/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/(portal)/support/new/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/api/auth/login/route.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/api/auth/signup/route.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/api/auth/validate-signup/route.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/api/health/route.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/auth/forgot-password/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/auth/link-whmcs/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/auth/login/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/auth/reset-password/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/auth/set-password/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/auth/signup/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/layout.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/auth/session-timeout-warning.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/common/DataTable/DataTable.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/common/DataTable/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/common/DetailHeader/DetailHeader.tsx","messages":[{"ruleId":"prettier/prettier","severity":1,"message":"Replace `Ā·title,Ā·subtitle,Ā·status,Ā·leftIcon,Ā·actions,Ā·className,Ā·metaĀ·` with `āŽĀ·Ā·title,āŽĀ·Ā·subtitle,āŽĀ·Ā·status,āŽĀ·Ā·leftIcon,āŽĀ·Ā·actions,āŽĀ·Ā·className,āŽĀ·Ā·meta,āŽ`","line":18,"column":31,"nodeType":null,"messageId":"replace","endLine":18,"endColumn":92,"fix":{"range":[465,526],"text":"\n title,\n subtitle,\n status,\n leftIcon,\n actions,\n className,\n meta,\n"}}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":1,"source":"\"use client\";\n\nimport React from \"react\";\nimport { StatusPill } from \"@/components/ui/status-pill\";\n\ntype Variant = \"success\" | \"warning\" | \"error\" | \"neutral\" | \"info\";\n\ninterface DetailHeaderProps {\n title: string;\n subtitle?: string;\n status?: { label: string; variant: Variant };\n leftIcon?: React.ReactNode;\n actions?: React.ReactNode;\n className?: string;\n meta?: React.ReactNode; // optional metadata row under header\n}\n\nexport function DetailHeader({ title, subtitle, status, leftIcon, actions, className, meta }: DetailHeaderProps) {\n return (\n
\n
\n
\n {leftIcon}\n
\n

{title}

\n {subtitle &&

{subtitle}

}\n
\n
\n {status && }\n {actions}\n
\n {meta &&
{meta}
}\n
\n );\n}\n\nexport type { DetailHeaderProps };\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/common/DetailHeader/index.ts","messages":[{"ruleId":"prettier/prettier","severity":1,"message":"Delete `āŽ`","line":2,"column":1,"nodeType":null,"messageId":"delete","endLine":3,"endColumn":1,"fix":{"range":[32,33],"text":""}}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":1,"source":"export * from \"./DetailHeader\";\n\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/common/FormField/FormField.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/common/FormField/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/common/PaginationBar/PaginationBar.tsx","messages":[{"ruleId":"prettier/prettier","severity":1,"message":"Replace `Ā·currentPage,Ā·pageSize,Ā·totalItems,Ā·onPageChange,Ā·classNameĀ·` with `āŽĀ·Ā·currentPage,āŽĀ·Ā·pageSize,āŽĀ·Ā·totalItems,āŽĀ·Ā·onPageChange,āŽĀ·Ā·className,āŽ`","line":13,"column":32,"nodeType":null,"messageId":"replace","endLine":13,"endColumn":92,"fix":{"range":[235,295],"text":"\n currentPage,\n pageSize,\n totalItems,\n onPageChange,\n className,\n"}},{"ruleId":"prettier/prettier","severity":1,"message":"Delete `Ā·`","line":39,"column":95,"nodeType":null,"messageId":"delete","endLine":39,"endColumn":96,"fix":{"range":[1639,1640],"text":""}},{"ruleId":"prettier/prettier","severity":1,"message":"Delete `Ā·`","line":40,"column":99,"nodeType":null,"messageId":"delete","endLine":40,"endColumn":100,"fix":{"range":[1744,1745],"text":""}},{"ruleId":"prettier/prettier","severity":1,"message":"Replace `Ā·className=\"relativeĀ·z-0Ā·inline-flexĀ·rounded-mdĀ·shadow-smĀ·-space-x-px\"Ā·aria-label=\"Pagination\"` with `āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·className=\"relativeĀ·z-0Ā·inline-flexĀ·rounded-mdĀ·shadow-smĀ·-space-x-px\"āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·aria-label=\"Pagination\"āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·`","line":45,"column":15,"nodeType":null,"messageId":"replace","endLine":45,"endColumn":109,"fix":{"range":[1879,1973],"text":"\n className=\"relative z-0 inline-flex rounded-md shadow-sm -space-x-px\"\n aria-label=\"Pagination\"\n "}},{"ruleId":"prettier/prettier","severity":1,"message":"Delete `āŽ`","line":68,"column":1,"nodeType":null,"messageId":"delete","endLine":69,"endColumn":1,"fix":{"range":[2880,2881],"text":""}}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":5,"fixableErrorCount":0,"fixableWarningCount":5,"source":"\"use client\";\n\nimport React from \"react\";\n\ninterface PaginationBarProps {\n currentPage: number;\n pageSize: number;\n totalItems: number;\n onPageChange: (page: number) => void;\n className?: string;\n}\n\nexport function PaginationBar({ currentPage, pageSize, totalItems, onPageChange, className }: PaginationBarProps) {\n const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));\n const canPrev = currentPage > 1;\n const canNext = currentPage < totalPages;\n\n return (\n
\n
\n onPageChange(Math.max(1, currentPage - 1))}\n disabled={!canPrev}\n className=\"relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed\"\n >\n Previous\n \n onPageChange(Math.min(totalPages, currentPage + 1))}\n disabled={!canNext}\n className=\"ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed\"\n >\n Next\n \n
\n
\n
\n

\n Showing {(currentPage - 1) * pageSize + 1} to {\" \"}\n {Math.min(currentPage * pageSize, totalItems)} of {\" \"}\n {totalItems} results\n

\n
\n
\n \n
\n
\n
\n );\n}\n\nexport type { PaginationBarProps };\n\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/common/PaginationBar/index.ts","messages":[{"ruleId":"prettier/prettier","severity":1,"message":"Delete `āŽ`","line":2,"column":1,"nodeType":null,"messageId":"delete","endLine":3,"endColumn":1,"fix":{"range":[33,34],"text":""}}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":1,"source":"export * from \"./PaginationBar\";\n\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/common/SearchFilterBar/SearchFilterBar.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/common/SearchFilterBar/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/common/error-boundary.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/common/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/common/lazy-component.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/common/lazy-wrapper.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/common/optimized-image.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/common/preloading-link.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/common/web-vitals-monitor.tsx","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'error' is defined but never used.","line":57,"column":16,"nodeType":null,"messageId":"unusedVar","endLine":57,"endColumn":21},{"ruleId":"@typescript-eslint/no-floating-promises","severity":2,"message":"Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator.","line":63,"column":5,"nodeType":"ExpressionStatement","messageId":"floatingVoid","endLine":63,"endColumn":21,"suggestions":[{"messageId":"floatingFixVoid","fix":{"range":[1641,1641],"text":"void "},"desc":"Add void operator to ignore."},{"messageId":"floatingFixAwait","fix":{"range":[1641,1641],"text":"await "},"desc":"Add await operator."}]},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":2,"message":"Unsafe assignment of an `any` value.","line":115,"column":13,"nodeType":"VariableDeclarator","messageId":"anyAssignment","endLine":115,"endColumn":38},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":115,"column":35,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":115,"endColumn":38,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[3148,3151],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[3148,3151],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access .processingStart on an `any` value.","line":116,"column":22,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":116,"endColumn":37},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access .startTime on an `any` value.","line":116,"column":52,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":116,"endColumn":61},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access .processingStart on an `any` value.","line":117,"column":32,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":117,"endColumn":47},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access .startTime on an `any` value.","line":117,"column":61,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":117,"endColumn":70},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":133,"column":22,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":133,"endColumn":25,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[3738,3741],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[3738,3741],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access .hadRecentInput on an `any` value.","line":133,"column":27,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":133,"endColumn":41},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":134,"column":31,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":134,"endColumn":34,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[3791,3794],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[3791,3794],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access .value on an `any` value.","line":134,"column":36,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":134,"endColumn":41},{"ruleId":"@typescript-eslint/no-unsafe-call","severity":2,"message":"Unsafe call of a(n) `any` typed value.","line":184,"column":5,"nodeType":"MemberExpression","messageId":"unsafeCall","endLine":184,"endColumn":25},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":184,"column":16,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":184,"endColumn":19,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[5178,5181],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[5178,5181],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access .gtag on an `any` value.","line":184,"column":21,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":184,"endColumn":25},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":209,"column":20,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":209,"endColumn":23,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[5729,5732],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[5729,5732],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-unsafe-return","severity":2,"message":"Unsafe return of a value of type `any[]`.","line":229,"column":5,"nodeType":"ReturnStatement","messageId":"unsafeReturn","endLine":229,"endColumn":20}],"suppressedMessages":[],"errorCount":16,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\"use client\";\n\nimport { useEffect } from \"react\";\n\ninterface WebVitalsMetric {\n name: string;\n value: number;\n rating: \"good\" | \"needs-improvement\" | \"poor\";\n delta: number;\n id: string;\n}\n\ninterface WebVitalsMonitorProps {\n onMetric?: (metric: WebVitalsMetric) => void;\n reportToAnalytics?: boolean;\n}\n\n/**\n * Web Vitals monitoring component\n * Measures and reports Core Web Vitals metrics\n */\nexport function WebVitalsMonitor({ onMetric, reportToAnalytics = true }: WebVitalsMonitorProps) {\n useEffect(() => {\n // Only run in browser\n if (typeof window === \"undefined\") return;\n\n const reportMetric = (metric: WebVitalsMetric) => {\n // Call custom handler\n onMetric?.(metric);\n\n // Report to analytics if enabled\n if (reportToAnalytics) {\n reportToAnalyticsService(metric);\n }\n\n // Log in development\n if (process.env.NODE_ENV === \"development\") {\n console.log(`Web Vital - ${metric.name}:`, {\n value: metric.value,\n rating: metric.rating,\n delta: metric.delta,\n });\n }\n };\n\n // Try to use web-vitals library if available\n const loadWebVitals = async () => {\n try {\n // Dynamic import to avoid bundling if not needed\n const { onCLS, onINP, onFCP, onLCP, onTTFB } = await import(\"web-vitals\");\n\n onCLS(reportMetric);\n onINP(reportMetric);\n onFCP(reportMetric);\n onLCP(reportMetric);\n onTTFB(reportMetric);\n } catch (error) {\n // Fallback to manual measurement if web-vitals is not available\n measureWithPerformanceAPI(reportMetric);\n }\n };\n\n loadWebVitals();\n }, [onMetric, reportToAnalytics]);\n\n return null; // This component doesn't render anything\n}\n\n/**\n * Fallback measurement using Performance API\n */\nfunction measureWithPerformanceAPI(reportMetric: (metric: WebVitalsMetric) => void) {\n if (!(\"PerformanceObserver\" in window)) return;\n\n // Measure FCP\n new PerformanceObserver(list => {\n const entries = list.getEntries();\n const fcp = entries.find(entry => entry.name === \"first-contentful-paint\");\n if (fcp) {\n reportMetric({\n name: \"FCP\",\n value: fcp.startTime,\n rating:\n fcp.startTime <= 1800 ? \"good\" : fcp.startTime <= 3000 ? \"needs-improvement\" : \"poor\",\n delta: fcp.startTime,\n id: generateId(),\n });\n }\n }).observe({ entryTypes: [\"paint\"] });\n\n // Measure LCP\n new PerformanceObserver(list => {\n const entries = list.getEntries();\n const lastEntry = entries[entries.length - 1];\n if (lastEntry) {\n reportMetric({\n name: \"LCP\",\n value: lastEntry.startTime,\n rating:\n lastEntry.startTime <= 2500\n ? \"good\"\n : lastEntry.startTime <= 4000\n ? \"needs-improvement\"\n : \"poor\",\n delta: lastEntry.startTime,\n id: generateId(),\n });\n }\n }).observe({ entryTypes: [\"largest-contentful-paint\"] });\n\n // Measure FID\n new PerformanceObserver(list => {\n const entries = list.getEntries();\n entries.forEach(entry => {\n const eventEntry = entry as any; // Type assertion for processingStart\n if (eventEntry.processingStart && eventEntry.startTime) {\n const fid = eventEntry.processingStart - eventEntry.startTime;\n reportMetric({\n name: \"FID\",\n value: fid,\n rating: fid <= 100 ? \"good\" : fid <= 300 ? \"needs-improvement\" : \"poor\",\n delta: fid,\n id: generateId(),\n });\n }\n });\n }).observe({ entryTypes: [\"first-input\"] });\n\n // Measure CLS\n let clsValue = 0;\n new PerformanceObserver(list => {\n list.getEntries().forEach(entry => {\n if (!(entry as any).hadRecentInput) {\n clsValue += (entry as any).value;\n }\n });\n\n reportMetric({\n name: \"CLS\",\n value: clsValue,\n rating: clsValue <= 0.1 ? \"good\" : clsValue <= 0.25 ? \"needs-improvement\" : \"poor\",\n delta: clsValue,\n id: generateId(),\n });\n }).observe({ entryTypes: [\"layout-shift\"] });\n\n // Measure TTFB\n const navigation = performance.getEntriesByType(\"navigation\")[0];\n if (navigation) {\n const ttfb = navigation.responseStart - navigation.requestStart;\n reportMetric({\n name: \"TTFB\",\n value: ttfb,\n rating: ttfb <= 800 ? \"good\" : ttfb <= 1800 ? \"needs-improvement\" : \"poor\",\n delta: ttfb,\n id: generateId(),\n });\n }\n}\n\n/**\n * Report metrics to analytics service\n */\nfunction reportToAnalyticsService(metric: WebVitalsMetric) {\n // Store in localStorage for now (replace with actual analytics service)\n if (typeof window !== \"undefined\" && window.localStorage) {\n const key = `web_vitals_${metric.name.toLowerCase()}`;\n const data = {\n ...metric,\n timestamp: Date.now(),\n url: window.location.pathname,\n userAgent: navigator.userAgent,\n };\n\n try {\n localStorage.setItem(key, JSON.stringify(data));\n } catch (error) {\n console.warn(\"Failed to store web vitals metric:\", error);\n }\n }\n\n // Send to analytics service (example)\n if (typeof window !== \"undefined\" && \"gtag\" in window) {\n (window as any).gtag(\"event\", metric.name, {\n event_category: \"Web Vitals\",\n event_label: metric.id,\n value: Math.round(metric.value),\n custom_map: {\n metric_rating: metric.rating,\n },\n });\n }\n}\n\n/**\n * Generate unique ID for metrics\n */\nfunction generateId(): string {\n return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;\n}\n\n/**\n * Hook for accessing Web Vitals data\n */\nexport function useWebVitals() {\n const getStoredMetrics = () => {\n if (typeof window === \"undefined\") return [];\n\n const metrics: any[] = [];\n const vitalsKeys = [\n \"web_vitals_fcp\",\n \"web_vitals_lcp\",\n \"web_vitals_fid\",\n \"web_vitals_cls\",\n \"web_vitals_ttfb\",\n ];\n\n vitalsKeys.forEach(key => {\n try {\n const stored = localStorage.getItem(key);\n if (stored) {\n metrics.push(JSON.parse(stored));\n }\n } catch (error) {\n console.warn(`Failed to parse stored metric ${key}:`, error);\n }\n });\n\n return metrics;\n };\n\n const clearStoredMetrics = () => {\n if (typeof window === \"undefined\") return;\n\n const vitalsKeys = [\n \"web_vitals_fcp\",\n \"web_vitals_lcp\",\n \"web_vitals_fid\",\n \"web_vitals_cls\",\n \"web_vitals_ttfb\",\n ];\n vitalsKeys.forEach(key => {\n localStorage.removeItem(key);\n });\n };\n\n return {\n getStoredMetrics,\n clearStoredMetrics,\n };\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/layout/AuthLayout/AuthLayout.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/layout/AuthLayout/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/layout/DashboardLayout/DashboardLayout.tsx","messages":[{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":315,"column":9,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":315,"endColumn":12,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[10010,10013],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[10010,10013],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access .firstName on an `any` value.","line":359,"column":20,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":359,"endColumn":29},{"ruleId":"@typescript-eslint/no-unsafe-call","severity":2,"message":"Unsafe call of a(n) `any` typed value.","line":359,"column":33,"nodeType":"MemberExpression","messageId":"unsafeCall","endLine":359,"endColumn":51},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access .email on an `any` value.","line":359,"column":39,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":359,"endColumn":44},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access [0] on an `any` value.","line":359,"column":57,"nodeType":"Literal","messageId":"unsafeMemberExpression","endLine":359,"endColumn":58}],"suppressedMessages":[],"errorCount":5,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\"use client\";\n\nimport { LoadingSpinner } from \"@/components/ui/loading-spinner\";\n\nimport { useState, useEffect, useMemo, memo } from \"react\";\nimport Link from \"next/link\";\nimport { usePathname, useRouter } from \"next/navigation\";\nimport { useAuthStore } from \"@/lib/auth/store\";\nimport { Logo } from \"@/components/ui/logo\";\nimport {\n HomeIcon,\n CreditCardIcon,\n ServerIcon,\n ChatBubbleLeftRightIcon,\n UserIcon,\n Bars3Icon,\n XMarkIcon,\n BellIcon,\n ArrowRightStartOnRectangleIcon,\n Squares2X2Icon,\n ClipboardDocumentListIcon,\n QuestionMarkCircleIcon,\n} from \"@heroicons/react/24/outline\";\nimport { useActiveSubscriptions } from \"@/features/subscriptions/hooks\";\nimport { SessionTimeoutWarning } from \"@/components/auth/session-timeout-warning\";\nimport type { Subscription } from \"@customer-portal/shared\";\n\ninterface DashboardLayoutProps {\n children: React.ReactNode;\n}\n\ninterface NavigationChild {\n name: string;\n href: string;\n icon?: React.ComponentType>;\n tooltip?: string;\n}\n\ninterface NavigationItem {\n name: string;\n href?: string;\n icon: React.ComponentType>;\n children?: NavigationChild[];\n isLogout?: boolean;\n}\n\nconst baseNavigation: NavigationItem[] = [\n { name: \"Dashboard\", href: \"/dashboard\", icon: HomeIcon },\n { name: \"Orders\", href: \"/orders\", icon: ClipboardDocumentListIcon },\n {\n name: \"Billing\",\n icon: CreditCardIcon,\n children: [\n { name: \"Invoices\", href: \"/billing/invoices\" },\n { name: \"Payment Methods\", href: \"/billing/payments\" },\n ],\n },\n {\n name: \"Subscriptions\",\n icon: ServerIcon,\n children: [{ name: \"All Subscriptions\", href: \"/subscriptions\" }],\n },\n { name: \"Catalog\", href: \"/catalog\", icon: Squares2X2Icon },\n {\n name: \"Support\",\n icon: ChatBubbleLeftRightIcon,\n children: [\n { name: \"Cases\", href: \"/support/cases\" },\n { name: \"New Case\", href: \"/support/new\" },\n { name: \"Knowledge Base\", href: \"/support/kb\" },\n ],\n },\n {\n name: \"Account\",\n icon: UserIcon,\n children: [\n { name: \"Profile\", href: \"/account/profile\" },\n { name: \"Security\", href: \"/account/security\" },\n { name: \"Notifications\", href: \"/account/notifications\" },\n ],\n },\n { name: \"Log out\", href: \"#\", icon: ArrowRightStartOnRectangleIcon, isLogout: true },\n];\n\nexport function DashboardLayout({ children }: DashboardLayoutProps) {\n const [sidebarOpen, setSidebarOpen] = useState(false);\n const [mounted, setMounted] = useState(false);\n const { user, isAuthenticated, checkAuth } = useAuthStore();\n const pathname = usePathname();\n const router = useRouter();\n const { data: activeSubscriptions } = useActiveSubscriptions();\n\n // Initialize expanded items from localStorage\n const [expandedItems, setExpandedItems] = useState(() => {\n if (typeof window !== \"undefined\") {\n const saved = localStorage.getItem(\"sidebar-expanded-items\");\n if (saved) {\n try {\n const parsed = JSON.parse(saved) as unknown;\n if (Array.isArray(parsed) && parsed.every(x => typeof x === \"string\")) {\n return parsed;\n }\n } catch {\n // ignore\n }\n }\n }\n return [];\n });\n\n // Save expanded items to localStorage\n useEffect(() => {\n if (mounted) {\n localStorage.setItem(\"sidebar-expanded-items\", JSON.stringify(expandedItems));\n }\n }, [expandedItems, mounted]);\n\n useEffect(() => {\n setMounted(true);\n void checkAuth();\n }, [checkAuth]);\n\n useEffect(() => {\n if (mounted && !isAuthenticated) {\n router.push(\"/auth/login\");\n }\n }, [mounted, isAuthenticated, router]);\n\n // Auto-expand sections when browsing their routes\n useEffect(() => {\n const newExpanded: string[] = [];\n\n if (pathname.startsWith(\"/subscriptions\") && !expandedItems.includes(\"Subscriptions\")) {\n newExpanded.push(\"Subscriptions\");\n }\n if (pathname.startsWith(\"/billing\") && !expandedItems.includes(\"Billing\")) {\n newExpanded.push(\"Billing\");\n }\n if (pathname.startsWith(\"/support\") && !expandedItems.includes(\"Support\")) {\n newExpanded.push(\"Support\");\n }\n if (pathname.startsWith(\"/account\") && !expandedItems.includes(\"Account\")) {\n newExpanded.push(\"Account\");\n }\n\n if (newExpanded.length > 0) {\n setExpandedItems(prev => [...prev, ...newExpanded]);\n }\n }, [pathname, expandedItems]);\n\n const toggleExpanded = (itemName: string) => {\n setExpandedItems(prev =>\n prev.includes(itemName) ? prev.filter(name => name !== itemName) : [...prev, itemName]\n );\n };\n\n // Memoize navigation to prevent unnecessary re-renders\n const navigation = useMemo(() => computeNavigation(activeSubscriptions), [activeSubscriptions]);\n\n // Show loading state until mounted and auth is checked\n if (!mounted) {\n return (\n
\n
\n \n

Loading...

\n
\n
\n );\n }\n\n return (\n <>\n
\n {/* Mobile sidebar overlay */}\n {sidebarOpen && (\n
\n setSidebarOpen(false)}\n />\n
\n
\n setSidebarOpen(false)}\n >\n \n \n
\n \n
\n
\n )}\n\n {/* Desktop sidebar */}\n
\n
\n \n
\n
\n\n {/* Main content */}\n
\n {/* Header */}\n
setSidebarOpen(true)} user={user} />\n\n {/* Main content area */}\n
{children}
\n
\n
\n\n {/* Session timeout warning */}\n \n \n );\n}\n\nfunction computeNavigation(activeSubscriptions?: Subscription[]): NavigationItem[] {\n // Clone base structure\n const nav: NavigationItem[] = baseNavigation.map(item => ({\n ...item,\n children: item.children ? [...item.children] : undefined,\n }));\n\n // Inject dynamic submenu under Subscriptions\n const subIdx = nav.findIndex(n => n.name === \"Subscriptions\");\n if (subIdx >= 0) {\n const dynamicChildren: NavigationChild[] = (activeSubscriptions || []).map(sub => {\n const href = `/subscriptions/${sub.id}`;\n return {\n name: truncate(sub.productName || `Subscription ${sub.id}`, 28),\n href,\n tooltip: sub.productName || `Subscription ${sub.id}`,\n } as NavigationChild;\n });\n\n nav[subIdx] = {\n ...nav[subIdx],\n children: [{ name: \"All Subscriptions\", href: \"/subscriptions\" }, ...dynamicChildren],\n };\n }\n\n return nav;\n}\n\nfunction truncate(text: string, max: number): string {\n if (text.length <= max) return text;\n return text.slice(0, Math.max(0, max - 1)) + \"…\";\n}\n\ninterface SidebarProps {\n navigation: NavigationItem[];\n pathname: string;\n expandedItems: string[];\n toggleExpanded: (name: string) => void;\n isMobile?: boolean;\n}\n\nconst Sidebar = memo(function Sidebar({\n navigation,\n pathname,\n expandedItems,\n toggleExpanded,\n}: SidebarProps) {\n return (\n
\n {/* Logo Section */}\n
\n
\n
\n \n
\n
\n \n Assist Solutions\n \n

Customer Portal

\n
\n
\n
\n\n {/* Navigation */}\n
\n \n
\n
\n );\n});\n\ninterface HeaderProps {\n onMenuClick: () => void;\n user: any;\n}\n\nconst Header = memo(function Header({ onMenuClick, user }: HeaderProps) {\n return (\n
\n
\n {/* Mobile menu button */}\n \n \n \n\n {/* Spacer */}\n
\n\n {/* Global Utilities */}\n
\n \n \n \n \n\n \n \n \n\n \n {user?.firstName || user?.email?.split(\"@\")[0] || \"Account\"}\n \n
\n
\n
\n );\n});\n\nconst NavigationItem = memo(function NavigationItem({\n item,\n pathname,\n isExpanded,\n toggleExpanded,\n}: {\n item: NavigationItem;\n pathname: string;\n isExpanded: boolean;\n toggleExpanded: (name: string) => void;\n}) {\n const { logout } = useAuthStore();\n const router = useRouter();\n\n const hasChildren = item.children && item.children.length > 0;\n const isActive = hasChildren\n ? item.children?.some((child: NavigationChild) =>\n pathname.startsWith((child.href || \"\").split(/[?#]/)[0])\n ) || false\n : item.href\n ? pathname === item.href\n : false;\n\n const handleLogout = () => {\n void logout().then(() => {\n router.push(\"/\");\n });\n };\n\n if (hasChildren) {\n return (\n
\n toggleExpanded(item.name)}\n aria-expanded={isExpanded}\n className={`group w-full flex items-center px-3 py-2.5 text-left text-sm font-medium rounded-lg transition-all duration-200 relative ${\n isActive\n ? \"text-[var(--cp-sidebar-active-text)] bg-[var(--cp-sidebar-active-bg)]\"\n : \"text-[var(--cp-sidebar-text)] hover:text-[var(--cp-sidebar-text-hover)] hover:bg-[var(--cp-sidebar-hover-bg)]\"\n } focus:outline-none focus:ring-2 focus:ring-primary/20`}\n >\n {/* Active indicator */}\n {isActive && (\n
\n )}\n\n \n \n
\n\n {item.name}\n\n \n \n \n \n\n {/* Animated dropdown */}\n \n
\n {item.children?.map((child: NavigationChild) => {\n const isChildActive = pathname === (child.href || \"\").split(/[?#]/)[0];\n return (\n \n {/* Child active indicator */}\n {isChildActive && (\n
\n )}\n\n \n\n {child.name}\n \n );\n })}\n
\n
\n
\n );\n }\n\n if (item.isLogout) {\n return (\n \n
\n \n
\n {item.name}\n \n );\n }\n\n return (\n \n {/* Active indicator */}\n {isActive && (\n
\n )}\n\n \n \n
\n\n {item.name}\n \n );\n});\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/layout/DashboardLayout/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/layout/PageLayout/PageLayout.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/layout/PageLayout/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/layout/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/lazy/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/ui/animated-card.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/ui/badge.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/ui/button.tsx","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'_as' is assigned a value but never used.","line":84,"column":17,"nodeType":null,"messageId":"unusedVar","endLine":84,"endColumn":20},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'_as' is assigned a value but never used.","line":97,"column":15,"nodeType":null,"messageId":"unusedVar","endLine":97,"endColumn":18}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":"import type { AnchorHTMLAttributes, ButtonHTMLAttributes } from \"react\";\nimport { forwardRef } from \"react\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { cn } from \"@/lib/utils\";\nimport { ArrowPathIcon } from \"@heroicons/react/24/outline\";\n\nconst buttonVariants = cva(\n \"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background\",\n {\n variants: {\n variant: {\n default: \"bg-blue-600 text-white hover:bg-blue-700\",\n destructive: \"bg-red-600 text-white hover:bg-red-700\",\n outline: \"border border-gray-300 bg-white hover:bg-gray-50 text-gray-900\",\n secondary: \"bg-gray-100 text-gray-900 hover:bg-gray-200\",\n ghost: \"hover:bg-gray-100 text-gray-900\",\n link: \"underline-offset-4 hover:underline text-blue-600\",\n },\n size: {\n xs: \"h-8 px-2 text-xs\",\n sm: \"h-9 px-3 text-sm\",\n default: \"h-10 py-2 px-4\",\n lg: \"h-11 px-8 text-base\",\n xl: \"h-12 px-10 text-lg\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"default\",\n },\n }\n);\n\ninterface BaseButtonProps extends VariantProps {\n loading?: boolean;\n loadingText?: string;\n leftIcon?: React.ReactNode;\n rightIcon?: React.ReactNode;\n disabled?: boolean;\n}\n\ntype ButtonAsAnchorProps = {\n as: \"a\";\n href: string;\n} & AnchorHTMLAttributes &\n BaseButtonProps;\n\ntype ButtonAsButtonProps = {\n as?: \"button\";\n} & ButtonHTMLAttributes &\n BaseButtonProps;\n\nexport type ButtonProps = ButtonAsAnchorProps | ButtonAsButtonProps;\n\nconst Button = forwardRef((props, ref) => {\n const {\n className,\n variant,\n size,\n loading = false,\n loadingText,\n leftIcon,\n rightIcon,\n children,\n disabled,\n ...restProps\n } = props;\n\n const isDisabled = disabled || loading;\n\n const content = (\n <>\n {loading ? (\n \n ) : (\n leftIcon && {leftIcon}\n )}\n {loading && loadingText ? loadingText : children}\n {!loading && rightIcon && {rightIcon}}\n \n );\n\n if (props.as === \"a\") {\n const { as: _as, href, ...anchorProps } = restProps as ButtonAsAnchorProps;\n return (\n }\n {...anchorProps}\n >\n {content}\n \n );\n }\n\n const { as: _as, ...buttonProps } = restProps as ButtonAsButtonProps;\n return (\n }\n {...buttonProps}\n >\n {content}\n \n );\n});\nButton.displayName = \"Button\";\n\nexport { Button, buttonVariants };\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/ui/empty-state.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/ui/error-message.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/ui/error-state.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/ui/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/ui/inline-toast.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/ui/input.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/ui/label.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/ui/loading-skeleton.tsx","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'title' is defined but never used.","line":95,"column":36,"nodeType":null,"messageId":"unusedVar","endLine":95,"endColumn":41}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"import { cn } from \"@/lib/utils\";\n\ninterface SkeletonProps {\n className?: string;\n animate?: boolean;\n}\n\nexport function Skeleton({ className, animate = true }: SkeletonProps) {\n return (\n \n );\n}\n\nexport function LoadingCard({ className }: { className?: string }) {\n return (\n \n
\n
\n \n
\n \n \n
\n
\n
\n \n \n \n
\n
\n
\n );\n}\n\nexport function LoadingTable({ rows = 5, columns = 4 }: { rows?: number; columns?: number }) {\n return (\n
\n {/* Header */}\n
\n
\n {Array.from({ length: columns }).map((_, i) => (\n \n ))}\n
\n
\n\n {/* Rows */}\n
\n {Array.from({ length: rows }).map((_, rowIndex) => (\n
\n
\n {Array.from({ length: columns }).map((_, colIndex) => (\n \n ))}\n
\n
\n ))}\n
\n
\n );\n}\n\nexport function LoadingStats({ count = 4 }: { count?: number }) {\n return (\n
\n {Array.from({ length: count }).map((_, i) => (\n \n
\n \n
\n \n \n
\n
\n
\n ))}\n \n );\n}\n\nexport function PageLoadingState({ title }: { title: string }) {\n return (\n
\n
\n {/* Header skeleton */}\n
\n
\n \n
\n \n \n
\n
\n
\n\n {/* Content skeleton */}\n
\n \n \n
\n
\n
\n );\n}\n\nexport function FullPageLoadingState({ title }: { title: string }) {\n return (\n
\n
\n
\n
\n

{title}

\n

Please wait while we load your content...

\n
\n
\n
\n );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/ui/loading-spinner.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/ui/logo.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/ui/progress-steps.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/ui/status-pill.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/ui/step-header.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/ui/sub-card.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/account/components/AddressCard.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/account/components/PasswordChangeCard.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/account/components/PersonalInfoCard.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/account/containers/Profile.tsx","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'loading' is assigned a value but never used.","line":14,"column":5,"nodeType":null,"messageId":"unusedVar","endLine":14,"endColumn":12},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'billingInfo' is assigned a value but never used.","line":16,"column":5,"nodeType":null,"messageId":"unusedVar","endLine":16,"endColumn":16},{"ruleId":"@typescript-eslint/no-misused-promises","severity":2,"message":"Promise-returning function provided to attribute where a void return was expected.","line":76,"column":18,"nodeType":"JSXExpressionContainer","messageId":"voidReturnAttribute","endLine":79,"endColumn":13},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":2,"message":"Unsafe assignment of an `any` value.","line":83,"column":20,"nodeType":"TSAsExpression","messageId":"anyAssignment","endLine":83,"endColumn":38},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":83,"column":35,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":83,"endColumn":38,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[2757,2760],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[2757,2760],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-misused-promises","severity":2,"message":"Promise-returning function provided to attribute where a void return was expected.","line":89,"column":18,"nodeType":"JSXExpressionContainer","messageId":"voidReturnAttribute","endLine":92,"endColumn":13},{"ruleId":"@typescript-eslint/no-unsafe-argument","severity":2,"message":"Unsafe argument of type `any` assigned to a parameter of type `AddressData`.","line":90,"column":42,"nodeType":"TSAsExpression","messageId":"unsafeArgument","endLine":90,"endColumn":60},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":90,"column":57,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":90,"endColumn":60,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[3055,3058],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[3055,3058],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-unsafe-argument","severity":2,"message":"Unsafe argument of type `any` assigned to a parameter of type `SetStateAction`.","line":93,"column":51,"nodeType":"TSAsExpression","messageId":"unsafeArgument","endLine":93,"endColumn":62},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":93,"column":59,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":93,"endColumn":62,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[3180,3183],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[3180,3183],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]}],"suppressedMessages":[],"errorCount":8,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\"use client\";\n\nimport { useState } from \"react\";\nimport { PageLayout } from \"@/components/layout/PageLayout\";\nimport { useAuthStore } from \"@/lib/auth/store\";\nimport { useProfileData } from \"../hooks/useProfileData\";\nimport { PersonalInfoCard } from \"../components/PersonalInfoCard\";\nimport { AddressCard } from \"../components/AddressCard\";\nimport { PasswordChangeCard } from \"../components/PasswordChangeCard\";\n\nexport function ProfileContainer() {\n const { user } = useAuthStore();\n const {\n loading,\n error,\n billingInfo,\n formData,\n setFormData,\n addressData,\n setAddressData,\n saveProfile,\n saveAddress,\n isSavingProfile,\n isSavingAddress,\n } = useProfileData();\n\n const [isEditingInfo, setIsEditingInfo] = useState(false);\n const [isEditingAddress, setIsEditingAddress] = useState(false);\n\n const [pwdError, setPwdError] = useState(null);\n const [pwdSuccess, setPwdSuccess] = useState(null);\n const [isChangingPassword, setIsChangingPassword] = useState(false);\n const [pwdForm, setPwdForm] = useState({\n currentPassword: \"\",\n newPassword: \"\",\n confirmPassword: \"\",\n });\n\n const handleChangePassword = async () => {\n setIsChangingPassword(true);\n setPwdError(null);\n setPwdSuccess(null);\n try {\n if (!pwdForm.currentPassword || !pwdForm.newPassword) {\n setPwdError(\"Please fill in all password fields\");\n return;\n }\n if (pwdForm.newPassword !== pwdForm.confirmPassword) {\n setPwdError(\"New password and confirmation do not match\");\n return;\n }\n await useAuthStore.getState().changePassword(pwdForm.currentPassword, pwdForm.newPassword);\n setPwdSuccess(\"Password changed successfully.\");\n setPwdForm({ currentPassword: \"\", newPassword: \"\", confirmPassword: \"\" });\n } catch (err) {\n setPwdError(err instanceof Error ? err.message : \"Failed to change password\");\n } finally {\n setIsChangingPassword(false);\n }\n };\n\n return (\n }\n >\n
\n setIsEditingInfo(true)}\n onCancel={() => setIsEditingInfo(false)}\n onChange={(field, value) => setFormData(prev => ({ ...prev, [field]: value }))}\n onSave={async () => {\n const ok = await saveProfile(formData);\n if (ok) setIsEditingInfo(false);\n }}\n />\n\n setIsEditingAddress(true)}\n onCancel={() => setIsEditingAddress(false)}\n onSave={async () => {\n const ok = await saveAddress(addressData as any);\n if (ok) setIsEditingAddress(false);\n }}\n onAddressChange={addr => setAddressData(addr as any)}\n />\n\n setPwdForm(prev => ({ ...prev, ...next }))}\n onSubmit={() => {\n void handleChangePassword();\n }}\n />\n
\n \n );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/account/containers/ProfileContainer.tsx","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'PageLayout' is defined but never used.","line":4,"column":10,"nodeType":null,"messageId":"unusedVar","endLine":4,"endColumn":20},{"ruleId":"react-hooks/exhaustive-deps","severity":1,"message":"React Hook useEffect has a missing dependency: 'address'. Either include it or remove the dependency array.","line":63,"column":6,"nodeType":"ArrayExpression","endLine":63,"endColumn":8,"suggestions":[{"desc":"Update the dependencies array to be: [address]","fix":{"range":[1932,1934],"text":"[address]"}}]},{"ruleId":"@typescript-eslint/no-misused-promises","severity":2,"message":"Promise-returning function provided to attribute where a void return was expected.","line":187,"column":27,"nodeType":"JSXExpressionContainer","messageId":"voidReturnAttribute","endLine":190,"endColumn":21},{"ruleId":"@typescript-eslint/no-misused-promises","severity":2,"message":"Promise-returning function provided to attribute where a void return was expected.","line":265,"column":29,"nodeType":"JSXExpressionContainer","messageId":"voidReturnAttribute","endLine":268,"endColumn":23}],"suppressedMessages":[],"errorCount":2,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { PageLayout } from \"@/components/layout/PageLayout\";\nimport { LoadingSpinner } from \"@/components/ui/loading-spinner\";\nimport {\n ExclamationTriangleIcon,\n MapPinIcon,\n PencilIcon,\n CheckIcon,\n XMarkIcon,\n UserIcon,\n} from \"@heroicons/react/24/outline\";\nimport { useAuthStore } from \"@/lib/auth/store\";\nimport { accountService } from \"@/features/account/services/account.service\";\nimport { useProfileEdit } from \"@/features/account/hooks/useProfileEdit\";\nimport { AddressForm } from \"@/features/catalog/components/base/AddressForm\";\nimport { useAddressEdit } from \"@/features/account/hooks/useAddressEdit\";\n\nexport default function ProfileContainer() {\n const { user } = useAuthStore();\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState(null);\n const [editingProfile, setEditingProfile] = useState(false);\n const [editingAddress, setEditingAddress] = useState(false);\n\n const profile = useProfileEdit({\n firstName: user?.firstName || \"\",\n lastName: user?.lastName || \"\",\n phone: user?.phone || \"\",\n });\n\n const address = useAddressEdit({\n street: \"\",\n streetLine2: \"\",\n city: \"\",\n state: \"\",\n postalCode: \"\",\n country: \"\",\n });\n\n useEffect(() => {\n void (async () => {\n try {\n setLoading(true);\n const addr = await accountService.getAddress().catch(() => null);\n if (addr) {\n address.setForm({\n street: addr.street ?? \"\",\n streetLine2: addr.streetLine2 ?? \"\",\n city: addr.city ?? \"\",\n state: addr.state ?? \"\",\n postalCode: addr.postalCode ?? \"\",\n country: addr.country ?? \"\",\n });\n }\n } catch (e) {\n setError(e instanceof Error ? e.message : \"Failed to load profile data\");\n } finally {\n setLoading(false);\n }\n })();\n }, []);\n\n if (loading) {\n return (\n
\n
\n
\n \n Loading profile...\n
\n
\n
\n );\n }\n\n return (\n
\n
\n {error && (\n
\n
\n \n
\n

Error

\n

{error}

\n
\n
\n
\n )}\n\n {/* Personal Information */}\n
\n
\n
\n
\n \n

Personal Information

\n
\n {!editingProfile && (\n setEditingProfile(true)}\n className=\"inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-50 transition-colors\"\n >\n \n Edit\n \n )}\n
\n
\n\n
\n
\n
\n \n {editingProfile ? (\n profile.setField(\"firstName\", e.target.value)}\n className=\"block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors\"\n />\n ) : (\n

\n {user?.firstName || Not provided}\n

\n )}\n
\n
\n \n {editingProfile ? (\n profile.setField(\"lastName\", e.target.value)}\n className=\"block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors\"\n />\n ) : (\n

\n {user?.lastName || Not provided}\n

\n )}\n
\n
\n \n
\n
\n

{user?.email}

\n
\n

\n Email cannot be changed from the portal.\n

\n
\n
\n
\n \n {editingProfile ? (\n profile.setField(\"phone\", e.target.value)}\n placeholder=\"+81 XX-XXXX-XXXX\"\n className=\"block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors\"\n />\n ) : (\n

\n {user?.phone || Not provided}\n

\n )}\n
\n
\n\n {editingProfile && (\n
\n setEditingProfile(false)}\n disabled={profile.saving}\n className=\"inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50\"\n >\n \n Cancel\n \n {\n const ok = await profile.save();\n if (ok) setEditingProfile(false);\n }}\n disabled={profile.saving}\n className=\"inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50\"\n >\n {profile.saving ? (\n <>\n
\n Saving...\n \n ) : (\n <>\n \n Save Changes\n \n )}\n \n
\n )}\n
\n
\n\n {/* Address */}\n
\n
\n
\n
\n \n

Address Information

\n
\n {!editingAddress && (\n setEditingAddress(true)}\n className=\"inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-50 transition-colors\"\n >\n \n Edit\n \n )}\n
\n
\n\n
\n {editingAddress ? (\n
\n \n address.setForm({\n street: a.street,\n streetLine2: a.streetLine2,\n city: a.city,\n state: a.state,\n postalCode: a.postalCode,\n country: a.country,\n })\n }\n title=\"Mailing Address\"\n />\n
\n setEditingAddress(false)}\n disabled={address.saving}\n className=\"inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 transition-colors\"\n >\n \n Cancel\n \n {\n const ok = await address.save();\n if (ok) setEditingAddress(false);\n }}\n disabled={address.saving}\n className=\"inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 transition-colors\"\n >\n {address.saving ? (\n <>\n
\n Saving...\n \n ) : (\n <>\n \n Save Address\n \n )}\n \n
\n {address.error && (\n
\n
\n \n
\n

Address Error

\n

{address.error}

\n
\n
\n
\n )}\n
\n ) : (\n
\n {address.form.street || address.form.city ? (\n
\n
\n {address.form.street &&

{address.form.street}

}\n {address.form.streetLine2 &&

{address.form.streetLine2}

}\n

\n {[address.form.city, address.form.state, address.form.postalCode]\n .filter(Boolean)\n .join(\", \")}\n

\n

{address.form.country}

\n
\n
\n ) : (\n
\n \n

No address on file

\n setEditingAddress(true)}\n className=\"bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors\"\n >\n Add Address\n \n
\n )}\n
\n )}\n
\n
\n
\n
\n );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/account/hooks/useAddressEdit.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/account/hooks/useAddressForm.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/account/hooks/useProfileData.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/account/hooks/useProfileEdit.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/account/services/account.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx","messages":[{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":67,"column":77,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":67,"endColumn":80,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[1678,1681],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[1678,1681],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-unsafe-argument","severity":2,"message":"Unsafe argument of type `any` assigned to a parameter of type `string`.","line":71,"column":34,"nodeType":"Identifier","messageId":"unsafeArgument","endLine":71,"endColumn":39},{"ruleId":"react-hooks/exhaustive-deps","severity":1,"message":"React Hook useCallback has a missing dependency: 'validationConfig'. Either include it or remove the dependency array.","line":73,"column":6,"nodeType":"ArrayExpression","endLine":73,"endColumn":8,"suggestions":[{"desc":"Update the dependencies array to be: [validationConfig]","fix":{"range":[1900,1902],"text":"[validationConfig]"}}]},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":77,"column":41,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":77,"endColumn":44,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[2012,2015],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[2012,2015],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":2,"message":"Unsafe assignment of an `any` value.","line":78,"column":39,"nodeType":"Property","messageId":"anyAssignment","endLine":78,"endColumn":53},{"ruleId":"@typescript-eslint/no-misused-promises","severity":2,"message":"Promise-returning function provided to attribute where a void return was expected.","line":165,"column":20,"nodeType":"JSXExpressionContainer","messageId":"voidReturnAttribute","endLine":165,"endColumn":34},{"ruleId":"react/no-unescaped-entities","severity":2,"message":"`'` can be escaped with `'`, `‘`, `'`, `’`.","line":240,"column":46,"nodeType":"JSXText","messageId":"unescapedEntityAlts","suggestions":[{"messageId":"replaceWithAlt","data":{"alt":"'"},"fix":{"range":[6828,6851],"text":"Don't have an account? "},"desc":"Replace with `'`."},{"messageId":"replaceWithAlt","data":{"alt":"‘"},"fix":{"range":[6828,6851],"text":"Don‘t have an account? "},"desc":"Replace with `‘`."},{"messageId":"replaceWithAlt","data":{"alt":"'"},"fix":{"range":[6828,6851],"text":"Don't have an account? "},"desc":"Replace with `'`."},{"messageId":"replaceWithAlt","data":{"alt":"’"},"fix":{"range":[6828,6851],"text":"Don’t have an account? "},"desc":"Replace with `’`."}]}],"suppressedMessages":[],"errorCount":6,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Login Form Component\n * Reusable login form with validation and error handling\n */\n\n\"use client\";\n\nimport { useState, useCallback } from \"react\";\nimport Link from \"next/link\";\nimport { Button, Input, ErrorMessage } from \"@/components/ui\";\nimport { FormField } from \"@/components/common/FormField\";\nimport { useLogin } from \"../../hooks/use-auth\";\nimport { validationRules, validateField } from \"@/lib/form-validation\";\n\ninterface LoginFormProps {\n onSuccess?: () => void;\n onError?: (error: string) => void;\n showSignupLink?: boolean;\n showForgotPasswordLink?: boolean;\n className?: string;\n}\n\ninterface LoginFormData {\n email: string;\n password: string;\n rememberMe: boolean;\n}\n\ninterface FormErrors {\n email?: string;\n password?: string;\n general?: string;\n}\n\nexport function LoginForm({\n onSuccess,\n onError,\n showSignupLink = true,\n showForgotPasswordLink = true,\n className = \"\",\n}: LoginFormProps) {\n const { login, loading, error, clearError } = useLogin();\n\n const [formData, setFormData] = useState({\n email: \"\",\n password: \"\",\n rememberMe: false,\n });\n\n const [errors, setErrors] = useState({});\n const [touched, setTouched] = useState>({\n email: false,\n password: false,\n rememberMe: false,\n });\n\n // Validation rules\n const validationConfig = {\n email: [\n validationRules.required(\"Email is required\"),\n validationRules.email(\"Please enter a valid email address\"),\n ],\n password: [validationRules.required(\"Password is required\")],\n };\n\n // Validate field\n const validateFormField = useCallback((field: keyof LoginFormData, value: any) => {\n const rules = validationConfig[field as keyof typeof validationConfig];\n if (!rules) return null;\n\n const result = validateField(value, rules);\n return result.isValid ? null : result.errors[0];\n }, []);\n\n // Handle field change\n const handleFieldChange = useCallback(\n (field: keyof LoginFormData, value: any) => {\n setFormData(prev => ({ ...prev, [field]: value }));\n\n // Clear general error when user starts typing\n if (errors.general) {\n setErrors(prev => ({ ...prev, general: undefined }));\n clearError();\n }\n\n // Validate field if it has been touched\n if (touched[field]) {\n const fieldError = validateFormField(field, value);\n setErrors(prev => ({ ...prev, [field]: fieldError || undefined }));\n }\n },\n [errors.general, touched, validateFormField, clearError]\n );\n\n // Handle field blur\n const handleFieldBlur = useCallback(\n (field: keyof LoginFormData) => {\n setTouched(prev => ({ ...prev, [field]: true }));\n\n const fieldError = validateFormField(field, formData[field]);\n setErrors(prev => ({ ...prev, [field]: fieldError || undefined }));\n },\n [formData, validateFormField]\n );\n\n // Validate entire form\n const validateForm = useCallback(() => {\n const newErrors: FormErrors = {};\n let isValid = true;\n\n // Validate email\n const emailError = validateFormField(\"email\", formData.email);\n if (emailError) {\n newErrors.email = emailError;\n isValid = false;\n }\n\n // Validate password\n const passwordError = validateFormField(\"password\", formData.password);\n if (passwordError) {\n newErrors.password = passwordError;\n isValid = false;\n }\n\n setErrors(newErrors);\n return isValid;\n }, [formData, validateFormField]);\n\n // Handle form submission\n const handleSubmit = useCallback(\n async (e: React.FormEvent) => {\n e.preventDefault();\n\n // Mark all fields as touched\n setTouched({\n email: true,\n password: true,\n rememberMe: true,\n });\n\n // Validate form\n if (!validateForm()) {\n return;\n }\n\n try {\n await login({\n email: formData.email.trim(),\n password: formData.password,\n });\n onSuccess?.();\n } catch (err) {\n const errorMessage = err instanceof Error ? err.message : \"Login failed\";\n setErrors(prev => ({ ...prev, general: errorMessage }));\n onError?.(errorMessage);\n }\n },\n [formData, validateForm, login, onSuccess, onError]\n );\n\n // Check if form is valid\n const isFormValidState = !errors.email && !errors.password && formData.email && formData.password;\n\n return (\n
\n {/* General Error */}\n {(errors.general || error) && (\n \n {errors.general || error}\n \n )}\n\n {/* Email Field */}\n \n handleFieldChange(\"email\", e.target.value)}\n onBlur={() => handleFieldBlur(\"email\")}\n placeholder=\"Enter your email address\"\n disabled={loading}\n error={errors.email}\n autoComplete=\"email\"\n autoFocus\n />\n \n\n {/* Password Field */}\n \n handleFieldChange(\"password\", e.target.value)}\n onBlur={() => handleFieldBlur(\"password\")}\n placeholder=\"Enter your password\"\n disabled={loading}\n error={errors.password}\n autoComplete=\"current-password\"\n />\n \n\n {/* Remember Me */}\n
\n \n\n {showForgotPasswordLink && (\n \n Forgot password?\n \n )}\n
\n\n {/* Submit Button */}\n \n {loading ? \"Signing in...\" : \"Sign In\"}\n \n\n {/* Signup Link */}\n {showSignupLink && (\n
\n Don't have an account? \n \n Sign up\n \n
\n )}\n \n );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/auth/components/LoginForm/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/auth/components/PasswordResetForm/PasswordResetForm.tsx","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'ForgotPasswordRequest' is defined but never used.","line":14,"column":15,"nodeType":null,"messageId":"unusedVar","endLine":14,"endColumn":36},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'ResetPasswordRequest' is defined but never used.","line":14,"column":38,"nodeType":null,"messageId":"unusedVar","endLine":14,"endColumn":58},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":83,"column":28,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":83,"endColumn":31,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[2287,2290],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[2287,2290],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-unsafe-argument","severity":2,"message":"Unsafe argument of type `any` assigned to a parameter of type `string`.","line":94,"column":36,"nodeType":"Identifier","messageId":"unsafeArgument","endLine":94,"endColumn":41},{"ruleId":"react-hooks/exhaustive-deps","severity":1,"message":"React Hook useCallback has a missing dependency: 'validationConfig'. Either include it or remove the dependency array.","line":97,"column":5,"nodeType":"ArrayExpression","endLine":97,"endColumn":25,"suggestions":[{"desc":"Update the dependencies array to be: [resetData.password, validationConfig]","fix":{"range":[2777,2797],"text":"[resetData.password, validationConfig]"}}]},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":102,"column":28,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":102,"endColumn":31,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[2897,2900],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[2897,2900],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":2,"message":"Unsafe assignment of an `any` value.","line":104,"column":44,"nodeType":"Property","messageId":"anyAssignment","endLine":104,"endColumn":58},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":2,"message":"Unsafe assignment of an `any` value.","line":106,"column":42,"nodeType":"Property","messageId":"anyAssignment","endLine":106,"endColumn":56},{"ruleId":"react/no-unescaped-entities","severity":2,"message":"`'` can be escaped with `'`, `‘`, `'`, `’`.","line":252,"column":15,"nodeType":"JSXText","messageId":"unescapedEntityAlts","suggestions":[{"messageId":"replaceWithAlt","data":{"alt":"'"},"fix":{"range":[7393,7442],"text":"\n We've sent a password reset link to "},"desc":"Replace with `'`."},{"messageId":"replaceWithAlt","data":{"alt":"‘"},"fix":{"range":[7393,7442],"text":"\n We‘ve sent a password reset link to "},"desc":"Replace with `‘`."},{"messageId":"replaceWithAlt","data":{"alt":"'"},"fix":{"range":[7393,7442],"text":"\n We've sent a password reset link to "},"desc":"Replace with `'`."},{"messageId":"replaceWithAlt","data":{"alt":"’"},"fix":{"range":[7393,7442],"text":"\n We’ve sent a password reset link to "},"desc":"Replace with `’`."}]},{"ruleId":"react/no-unescaped-entities","severity":2,"message":"`'` can be escaped with `'`, `‘`, `'`, `’`.","line":258,"column":17,"nodeType":"JSXText","messageId":"unescapedEntityAlts","suggestions":[{"messageId":"replaceWithAlt","data":{"alt":"'"},"fix":{"range":[7593,7679],"text":"\n Didn't receive the email? Check your spam folder or try again.\n "},"desc":"Replace with `'`."},{"messageId":"replaceWithAlt","data":{"alt":"‘"},"fix":{"range":[7593,7679],"text":"\n Didn‘t receive the email? Check your spam folder or try again.\n "},"desc":"Replace with `‘`."},{"messageId":"replaceWithAlt","data":{"alt":"'"},"fix":{"range":[7593,7679],"text":"\n Didn't receive the email? Check your spam folder or try again.\n "},"desc":"Replace with `'`."},{"messageId":"replaceWithAlt","data":{"alt":"’"},"fix":{"range":[7593,7679],"text":"\n Didn’t receive the email? Check your spam folder or try again.\n "},"desc":"Replace with `’`."}]},{"ruleId":"@typescript-eslint/no-misused-promises","severity":2,"message":"Promise-returning function provided to attribute where a void return was expected.","line":289,"column":20,"nodeType":"JSXExpressionContainer","messageId":"voidReturnAttribute","endLine":289,"endColumn":34},{"ruleId":"react/no-unescaped-entities","severity":2,"message":"`'` can be escaped with `'`, `‘`, `'`, `’`.","line":303,"column":46,"nodeType":"JSXText","messageId":"unescapedEntityAlts","suggestions":[{"messageId":"replaceWithAlt","data":{"alt":"'"},"fix":{"range":[8862,8964],"text":"\n Enter your email address and we'll send you a link to reset your password.\n "},"desc":"Replace with `'`."},{"messageId":"replaceWithAlt","data":{"alt":"‘"},"fix":{"range":[8862,8964],"text":"\n Enter your email address and we‘ll send you a link to reset your password.\n "},"desc":"Replace with `‘`."},{"messageId":"replaceWithAlt","data":{"alt":"'"},"fix":{"range":[8862,8964],"text":"\n Enter your email address and we'll send you a link to reset your password.\n "},"desc":"Replace with `'`."},{"messageId":"replaceWithAlt","data":{"alt":"’"},"fix":{"range":[8862,8964],"text":"\n Enter your email address and we’ll send you a link to reset your password.\n "},"desc":"Replace with `’`."}]}],"suppressedMessages":[],"errorCount":9,"fatalErrorCount":0,"warningCount":3,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Password Reset Form Component\n * Form for requesting and resetting passwords\n */\n\n\"use client\";\n\nimport { useState, useCallback } from \"react\";\nimport Link from \"next/link\";\nimport { Button, Input, ErrorMessage } from \"@/components/ui\";\nimport { FormField } from \"@/components/common/FormField\";\nimport { usePasswordReset } from \"../../hooks/use-auth\";\nimport { validationRules, validateField } from \"@/lib/form-validation\";\nimport type { ForgotPasswordRequest, ResetPasswordRequest } from \"@/lib/types\";\n\ninterface PasswordResetFormProps {\n mode: \"request\" | \"reset\";\n token?: string;\n onSuccess?: () => void;\n onError?: (error: string) => void;\n showLoginLink?: boolean;\n className?: string;\n}\n\ninterface RequestFormData {\n email: string;\n}\n\ninterface ResetFormData {\n password: string;\n confirmPassword: string;\n}\n\ninterface FormErrors {\n email?: string;\n password?: string;\n confirmPassword?: string;\n general?: string;\n}\n\nexport function PasswordResetForm({\n mode,\n token,\n onSuccess,\n onError,\n showLoginLink = true,\n className = \"\",\n}: PasswordResetFormProps) {\n const { requestPasswordReset, resetPassword, loading, error, clearError } = usePasswordReset();\n\n const [requestData, setRequestData] = useState({\n email: \"\",\n });\n\n const [resetData, setResetData] = useState({\n password: \"\",\n confirmPassword: \"\",\n });\n\n const [errors, setErrors] = useState({});\n const [touched, setTouched] = useState>({});\n const [isSubmitted, setIsSubmitted] = useState(false);\n\n // Validation rules\n const validationConfig = {\n email: [\n validationRules.required(\"Email is required\"),\n validationRules.email(\"Please enter a valid email address\"),\n ],\n password: [\n validationRules.required(\"Password is required\"),\n validationRules.minLength(8, \"Password must be at least 8 characters\"),\n validationRules.pattern(\n /^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)/,\n \"Password must contain at least one uppercase letter, one lowercase letter, and one number\"\n ),\n ],\n confirmPassword: [validationRules.required(\"Please confirm your password\")],\n };\n\n // Validate field\n const validateFormField = useCallback(\n (field: string, value: any) => {\n const rules = validationConfig[field as keyof typeof validationConfig];\n if (!rules) return null;\n\n // Special validation for confirm password\n if (field === \"confirmPassword\") {\n if (!value) return \"Please confirm your password\";\n if (value !== resetData.password) return \"Passwords do not match\";\n return null;\n }\n\n const result = validateField(value, rules);\n return result.isValid ? null : result.errors[0];\n },\n [resetData.password]\n );\n\n // Handle field change\n const handleFieldChange = useCallback(\n (field: string, value: any) => {\n if (mode === \"request\") {\n setRequestData(prev => ({ ...prev, [field]: value }));\n } else {\n setResetData(prev => ({ ...prev, [field]: value }));\n }\n\n // Clear general error when user starts typing\n if (errors.general) {\n setErrors(prev => ({ ...prev, general: undefined }));\n clearError();\n }\n\n // Validate field if it has been touched\n if (touched[field]) {\n const fieldError = validateFormField(field, value);\n setErrors(prev => ({ ...prev, [field]: fieldError || undefined }));\n }\n\n // Also validate confirm password when password changes\n if (field === \"password\" && touched.confirmPassword && mode === \"reset\") {\n const confirmPasswordError = validateFormField(\n \"confirmPassword\",\n resetData.confirmPassword\n );\n setErrors(prev => ({ ...prev, confirmPassword: confirmPasswordError || undefined }));\n }\n },\n [mode, errors.general, touched, validateFormField, clearError, resetData.confirmPassword]\n );\n\n // Handle field blur\n const handleFieldBlur = useCallback(\n (field: string) => {\n setTouched(prev => ({ ...prev, [field]: true }));\n\n const value =\n mode === \"request\"\n ? requestData[field as keyof RequestFormData]\n : resetData[field as keyof ResetFormData];\n const fieldError = validateFormField(field, value);\n setErrors(prev => ({ ...prev, [field]: fieldError || undefined }));\n },\n [mode, requestData, resetData, validateFormField]\n );\n\n // Validate form\n const validateForm = useCallback(() => {\n const newErrors: FormErrors = {};\n let isValid = true;\n\n if (mode === \"request\") {\n const emailError = validateFormField(\"email\", requestData.email);\n if (emailError) {\n newErrors.email = emailError;\n isValid = false;\n }\n } else {\n const passwordError = validateFormField(\"password\", resetData.password);\n if (passwordError) {\n newErrors.password = passwordError;\n isValid = false;\n }\n\n const confirmPasswordError = validateFormField(\"confirmPassword\", resetData.confirmPassword);\n if (confirmPasswordError) {\n newErrors.confirmPassword = confirmPasswordError;\n isValid = false;\n }\n }\n\n setErrors(newErrors);\n return isValid;\n }, [mode, requestData, resetData, validateFormField]);\n\n // Handle form submission\n const handleSubmit = useCallback(\n async (e: React.FormEvent) => {\n e.preventDefault();\n\n // Mark all fields as touched\n if (mode === \"request\") {\n setTouched({ email: true });\n } else {\n setTouched({ password: true, confirmPassword: true });\n }\n\n // Validate form\n if (!validateForm()) {\n return;\n }\n\n try {\n if (mode === \"request\") {\n await requestPasswordReset({ email: requestData.email.trim() });\n setIsSubmitted(true);\n } else {\n if (!token) {\n throw new Error(\"Reset token is required\");\n }\n\n await resetPassword({\n token,\n password: resetData.password,\n confirmPassword: resetData.confirmPassword,\n });\n }\n\n onSuccess?.();\n } catch (err) {\n const errorMessage = err instanceof Error ? err.message : \"Operation failed\";\n setErrors(prev => ({ ...prev, general: errorMessage }));\n onError?.(errorMessage);\n }\n },\n [\n mode,\n token,\n requestData,\n resetData,\n validateForm,\n requestPasswordReset,\n resetPassword,\n onSuccess,\n onError,\n ]\n );\n\n // Show success message for request mode\n if (mode === \"request\" && isSubmitted) {\n return (\n
\n
\n
\n \n \n \n
\n

Check your email

\n

\n We've sent a password reset link to {requestData.email}\n

\n
\n\n
\n

\n Didn't receive the email? Check your spam folder or try again.\n

\n\n {\n setIsSubmitted(false);\n setErrors({});\n setTouched({});\n }}\n className=\"w-full\"\n >\n Try Again\n \n
\n\n {showLoginLink && (\n
\n \n Back to Sign In\n \n
\n )}\n
\n );\n }\n\n return (\n
\n {/* General Error */}\n {(errors.general || error) && (\n \n {errors.general || error}\n \n )}\n\n {mode === \"request\" ? (\n <>\n {/* Request Mode - Email Input */}\n
\n

Reset your password

\n

\n Enter your email address and we'll send you a link to reset your password.\n

\n
\n\n \n handleFieldChange(\"email\", e.target.value)}\n onBlur={() => handleFieldBlur(\"email\")}\n placeholder=\"Enter your email address\"\n disabled={loading}\n error={errors.email}\n autoComplete=\"email\"\n autoFocus\n />\n \n\n \n {loading ? \"Sending...\" : \"Send Reset Link\"}\n \n \n ) : (\n <>\n {/* Reset Mode - Password Inputs */}\n
\n

Set new password

\n

Enter your new password below.

\n
\n\n \n handleFieldChange(\"password\", e.target.value)}\n onBlur={() => handleFieldBlur(\"password\")}\n placeholder=\"Enter your new password\"\n disabled={loading}\n error={errors.password}\n autoComplete=\"new-password\"\n autoFocus\n />\n \n\n \n handleFieldChange(\"confirmPassword\", e.target.value)}\n onBlur={() => handleFieldBlur(\"confirmPassword\")}\n placeholder=\"Confirm your new password\"\n disabled={loading}\n error={errors.confirmPassword}\n autoComplete=\"new-password\"\n />\n \n\n \n {loading ? \"Updating...\" : \"Update Password\"}\n \n \n )}\n\n {/* Login Link */}\n {showLoginLink && (\n
\n \n Back to Sign In\n \n
\n )}\n \n );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/auth/components/PasswordResetForm/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/auth/components/SetPasswordForm/SetPasswordForm.tsx","messages":[{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":77,"column":36,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":77,"endColumn":39,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[2080,2083],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[2080,2083],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-unsafe-argument","severity":2,"message":"Unsafe argument of type `any` assigned to a parameter of type `string`.","line":88,"column":36,"nodeType":"Identifier","messageId":"unsafeArgument","endLine":88,"endColumn":41},{"ruleId":"react-hooks/exhaustive-deps","severity":1,"message":"React Hook useCallback has a missing dependency: 'validationConfig'. Either include it or remove the dependency array.","line":91,"column":5,"nodeType":"ArrayExpression","endLine":91,"endColumn":24,"suggestions":[{"desc":"Update the dependencies array to be: [formData.password, validationConfig]","fix":{"range":[2569,2588],"text":"[formData.password, validationConfig]"}}]},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":96,"column":36,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":96,"endColumn":39,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[2696,2699],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[2696,2699],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":2,"message":"Unsafe assignment of an `any` value.","line":97,"column":39,"nodeType":"Property","messageId":"anyAssignment","endLine":97,"endColumn":53},{"ruleId":"@typescript-eslint/no-misused-promises","severity":2,"message":"Promise-returning function provided to attribute where a void return was expected.","line":207,"column":22,"nodeType":"JSXExpressionContainer","messageId":"voidReturnAttribute","endLine":207,"endColumn":36}],"suppressedMessages":[],"errorCount":5,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Set Password Form Component\n * Form for setting password after WHMCS account linking\n */\n\n\"use client\";\n\nimport { useState, useCallback } from \"react\";\nimport Link from \"next/link\";\nimport { Button, Input, ErrorMessage } from \"@/components/ui\";\nimport { FormField } from \"@/components/common/FormField\";\nimport { useWhmcsLink } from \"../../hooks/use-auth\";\nimport { validationRules, validateField } from \"@/lib/form-validation\";\n\ninterface SetPasswordFormProps {\n email?: string;\n onSuccess?: () => void;\n onError?: (error: string) => void;\n showLoginLink?: boolean;\n className?: string;\n}\n\ninterface FormData {\n email: string;\n password: string;\n confirmPassword: string;\n}\n\ninterface FormErrors {\n email?: string;\n password?: string;\n confirmPassword?: string;\n general?: string;\n}\n\nexport function SetPasswordForm({\n email: initialEmail = \"\",\n onSuccess,\n onError,\n showLoginLink = true,\n className = \"\",\n}: SetPasswordFormProps) {\n const { setPassword, loading, error, clearError } = useWhmcsLink();\n\n const [formData, setFormData] = useState({\n email: initialEmail,\n password: \"\",\n confirmPassword: \"\",\n });\n\n const [errors, setErrors] = useState({});\n const [touched, setTouched] = useState>({\n email: false,\n password: false,\n confirmPassword: false,\n });\n\n // Validation rules\n const validationConfig = {\n email: [\n validationRules.required(\"Email is required\"),\n validationRules.email(\"Please enter a valid email address\"),\n ],\n password: [\n validationRules.required(\"Password is required\"),\n validationRules.minLength(8, \"Password must be at least 8 characters\"),\n validationRules.pattern(\n /^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)/,\n \"Password must contain at least one uppercase letter, one lowercase letter, and one number\"\n ),\n ],\n confirmPassword: [validationRules.required(\"Please confirm your password\")],\n };\n\n // Validate field\n const validateFormField = useCallback(\n (field: keyof FormData, value: any) => {\n const rules = validationConfig[field as keyof typeof validationConfig];\n if (!rules) return null;\n\n // Special validation for confirm password\n if (field === \"confirmPassword\") {\n if (!value) return \"Please confirm your password\";\n if (value !== formData.password) return \"Passwords do not match\";\n return null;\n }\n\n const result = validateField(value, rules);\n return result.isValid ? null : result.errors[0];\n },\n [formData.password]\n );\n\n // Handle field change\n const handleFieldChange = useCallback(\n (field: keyof FormData, value: any) => {\n setFormData(prev => ({ ...prev, [field]: value }));\n\n // Clear general error when user starts typing\n if (errors.general) {\n setErrors(prev => ({ ...prev, general: undefined }));\n clearError();\n }\n\n // Validate field if it has been touched\n if (touched[field]) {\n const fieldError = validateFormField(field, value);\n setErrors(prev => ({ ...prev, [field]: fieldError || undefined }));\n }\n\n // Also validate confirm password when password changes\n if (field === \"password\" && touched.confirmPassword) {\n const confirmPasswordError = validateFormField(\"confirmPassword\", formData.confirmPassword);\n setErrors(prev => ({ ...prev, confirmPassword: confirmPasswordError || undefined }));\n }\n },\n [errors.general, touched, validateFormField, clearError, formData.confirmPassword]\n );\n\n // Handle field blur\n const handleFieldBlur = useCallback(\n (field: keyof FormData) => {\n setTouched(prev => ({ ...prev, [field]: true }));\n\n const fieldError = validateFormField(field, formData[field]);\n setErrors(prev => ({ ...prev, [field]: fieldError || undefined }));\n },\n [formData, validateFormField]\n );\n\n // Validate entire form\n const validateForm = useCallback(() => {\n const newErrors: FormErrors = {};\n let isValid = true;\n\n // Validate email\n const emailError = validateFormField(\"email\", formData.email);\n if (emailError) {\n newErrors.email = emailError;\n isValid = false;\n }\n\n // Validate password\n const passwordError = validateFormField(\"password\", formData.password);\n if (passwordError) {\n newErrors.password = passwordError;\n isValid = false;\n }\n\n // Validate confirm password\n const confirmPasswordError = validateFormField(\"confirmPassword\", formData.confirmPassword);\n if (confirmPasswordError) {\n newErrors.confirmPassword = confirmPasswordError;\n isValid = false;\n }\n\n setErrors(newErrors);\n return isValid;\n }, [formData, validateFormField]);\n\n // Handle form submission\n const handleSubmit = useCallback(\n async (e: React.FormEvent) => {\n e.preventDefault();\n\n // Mark all fields as touched\n setTouched({\n email: true,\n password: true,\n confirmPassword: true,\n });\n\n // Validate form\n if (!validateForm()) {\n return;\n }\n\n try {\n await setPassword(formData.email.trim(), formData.password);\n onSuccess?.();\n } catch (err) {\n const errorMessage = err instanceof Error ? err.message : \"Failed to set password\";\n setErrors(prev => ({ ...prev, general: errorMessage }));\n onError?.(errorMessage);\n }\n },\n [formData, validateForm, setPassword, onSuccess, onError]\n );\n\n // Check if form is valid\n const isFormValid =\n !errors.email &&\n !errors.password &&\n !errors.confirmPassword &&\n formData.email &&\n formData.password &&\n formData.confirmPassword;\n\n return (\n
\n {/* Header */}\n
\n

Set Your Password

\n

Complete your account setup by creating a secure password.

\n
\n\n
\n {/* General Error */}\n {(errors.general || error) && (\n \n {errors.general || error}\n \n )}\n\n {/* Email Field */}\n \n handleFieldChange(\"email\", e.target.value)}\n onBlur={() => handleFieldBlur(\"email\")}\n placeholder=\"Enter your email address\"\n disabled={loading || !!initialEmail}\n error={errors.email}\n autoComplete=\"email\"\n autoFocus={!initialEmail}\n />\n {initialEmail && (\n

This email is linked to your WHMCS account

\n )}\n
\n\n {/* Password Field */}\n \n handleFieldChange(\"password\", e.target.value)}\n onBlur={() => handleFieldBlur(\"password\")}\n placeholder=\"Create a secure password\"\n disabled={loading}\n error={errors.password}\n autoComplete=\"new-password\"\n autoFocus={!!initialEmail}\n />\n
\n

Password requirements:

\n
    \n
  • At least 8 characters long
  • \n
  • Contains uppercase and lowercase letters
  • \n
  • Contains at least one number
  • \n
\n
\n
\n\n {/* Confirm Password Field */}\n \n handleFieldChange(\"confirmPassword\", e.target.value)}\n onBlur={() => handleFieldBlur(\"confirmPassword\")}\n placeholder=\"Confirm your password\"\n disabled={loading}\n error={errors.confirmPassword}\n autoComplete=\"new-password\"\n />\n \n\n {/* Submit Button */}\n \n {loading ? \"Setting Password...\" : \"Set Password & Continue\"}\n \n \n\n {/* Login Link */}\n {showLoginLink && (\n
\n Already have a password? \n \n Sign in\n \n
\n )}\n
\n );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/auth/components/SetPasswordForm/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/auth/components/SignupForm/AccountStep.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/auth/components/SignupForm/AddressStep.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/auth/components/SignupForm/MultiStepForm.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/auth/components/SignupForm/PersonalStep.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/auth/components/SignupForm/PreferencesStep.tsx","messages":[{"ruleId":"react/no-unescaped-entities","severity":2,"message":"`\"` can be escaped with `"`, `“`, `"`, `”`.","line":79,"column":23,"nodeType":"JSXText","messageId":"unescapedEntityAlts","suggestions":[{"messageId":"replaceWithAlt","data":{"alt":"""},"fix":{"range":[2556,2697],"text":"\n By clicking "Create Account\", you'll be able to access your dashboard and start using our\n services immediately.\n "},"desc":"Replace with `"`."},{"messageId":"replaceWithAlt","data":{"alt":"“"},"fix":{"range":[2556,2697],"text":"\n By clicking “Create Account\", you'll be able to access your dashboard and start using our\n services immediately.\n "},"desc":"Replace with `“`."},{"messageId":"replaceWithAlt","data":{"alt":"""},"fix":{"range":[2556,2697],"text":"\n By clicking "Create Account\", you'll be able to access your dashboard and start using our\n services immediately.\n "},"desc":"Replace with `"`."},{"messageId":"replaceWithAlt","data":{"alt":"”"},"fix":{"range":[2556,2697],"text":"\n By clicking ”Create Account\", you'll be able to access your dashboard and start using our\n services immediately.\n "},"desc":"Replace with `”`."}]},{"ruleId":"react/no-unescaped-entities","severity":2,"message":"`\"` can be escaped with `"`, `“`, `"`, `”`.","line":79,"column":38,"nodeType":"JSXText","messageId":"unescapedEntityAlts","suggestions":[{"messageId":"replaceWithAlt","data":{"alt":"""},"fix":{"range":[2556,2697],"text":"\n By clicking \"Create Account", you'll be able to access your dashboard and start using our\n services immediately.\n "},"desc":"Replace with `"`."},{"messageId":"replaceWithAlt","data":{"alt":"“"},"fix":{"range":[2556,2697],"text":"\n By clicking \"Create Account“, you'll be able to access your dashboard and start using our\n services immediately.\n "},"desc":"Replace with `“`."},{"messageId":"replaceWithAlt","data":{"alt":"""},"fix":{"range":[2556,2697],"text":"\n By clicking \"Create Account", you'll be able to access your dashboard and start using our\n services immediately.\n "},"desc":"Replace with `"`."},{"messageId":"replaceWithAlt","data":{"alt":"”"},"fix":{"range":[2556,2697],"text":"\n By clicking \"Create Account”, you'll be able to access your dashboard and start using our\n services immediately.\n "},"desc":"Replace with `”`."}]},{"ruleId":"react/no-unescaped-entities","severity":2,"message":"`'` can be escaped with `'`, `‘`, `'`, `’`.","line":79,"column":44,"nodeType":"JSXText","messageId":"unescapedEntityAlts","suggestions":[{"messageId":"replaceWithAlt","data":{"alt":"'"},"fix":{"range":[2556,2697],"text":"\n By clicking \"Create Account\", you'll be able to access your dashboard and start using our\n services immediately.\n "},"desc":"Replace with `'`."},{"messageId":"replaceWithAlt","data":{"alt":"‘"},"fix":{"range":[2556,2697],"text":"\n By clicking \"Create Account\", you‘ll be able to access your dashboard and start using our\n services immediately.\n "},"desc":"Replace with `‘`."},{"messageId":"replaceWithAlt","data":{"alt":"'"},"fix":{"range":[2556,2697],"text":"\n By clicking \"Create Account\", you'll be able to access your dashboard and start using our\n services immediately.\n "},"desc":"Replace with `'`."},{"messageId":"replaceWithAlt","data":{"alt":"’"},"fix":{"range":[2556,2697],"text":"\n By clicking \"Create Account\", you’ll be able to access your dashboard and start using our\n services immediately.\n "},"desc":"Replace with `’`."}]}],"suppressedMessages":[],"errorCount":3,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Preferences Step Component\n * Terms acceptance and marketing preferences\n */\n\n\"use client\";\n\nimport Link from \"next/link\";\n\ninterface PreferencesStepProps {\n formData: {\n acceptTerms: boolean;\n marketingConsent: boolean;\n };\n errors: {\n acceptTerms?: string;\n };\n onFieldChange: (field: string, value: boolean) => void;\n onFieldBlur: (field: string) => void;\n loading?: boolean;\n}\n\nexport function PreferencesStep({\n formData,\n errors,\n onFieldChange,\n onFieldBlur,\n loading = false,\n}: PreferencesStepProps) {\n return (\n
\n
\n
\n onFieldChange(\"acceptTerms\", e.target.checked)}\n onBlur={() => onFieldBlur(\"acceptTerms\")}\n disabled={loading}\n className=\"mt-1 rounded border-gray-300 text-blue-600 focus:ring-blue-500\"\n />\n
\n \n {errors.acceptTerms && (\n

{errors.acceptTerms}

\n )}\n
\n
\n\n
\n onFieldChange(\"marketingConsent\", e.target.checked)}\n disabled={loading}\n className=\"mt-1 rounded border-gray-300 text-blue-600 focus:ring-blue-500\"\n />\n \n
\n
\n\n
\n

Almost done!

\n

\n By clicking \"Create Account\", you'll be able to access your dashboard and start using our\n services immediately.\n

\n
\n
\n );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx","messages":[{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":126,"column":27,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":126,"endColumn":30,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[3703,3706],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[3703,3706],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":128,"column":12,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":128,"endColumn":15,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[3799,3802],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[3799,3802],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":133,"column":32,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":133,"endColumn":35,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[3870,3873],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[3870,3873],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-unsafe-return","severity":2,"message":"Unsafe return of a value of type `any`.","line":134,"column":5,"nodeType":"ReturnStatement","messageId":"unsafeReturn","endLine":134,"endColumn":74},{"ruleId":"@typescript-eslint/no-unsafe-return","severity":2,"message":"Unsafe return of a value of type `any`.","line":134,"column":53,"nodeType":"ChainExpression","messageId":"unsafeReturn","endLine":134,"endColumn":67},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access [key] on an `any` value.","line":134,"column":63,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":134,"endColumn":66},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":138,"column":32,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":138,"endColumn":35,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[4027,4030],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[4027,4030],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":138,"column":58,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":138,"endColumn":61,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[4053,4056],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[4053,4056],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":2,"message":"Unsafe assignment of an `any` value.","line":141,"column":11,"nodeType":"VariableDeclarator","messageId":"anyAssignment","endLine":144,"endColumn":12},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access [key] on an `any` value.","line":142,"column":20,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":142,"endColumn":23},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access [key] on an `any` value.","line":142,"column":34,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":142,"endColumn":37},{"ruleId":"@typescript-eslint/no-unsafe-return","severity":2,"message":"Unsafe return of a value of type `any`.","line":143,"column":7,"nodeType":"ReturnStatement","messageId":"unsafeReturn","endLine":143,"endColumn":27},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access [key] on an `any` value.","line":143,"column":22,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":143,"endColumn":25},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":2,"message":"Unsafe assignment of an `any` value.","line":145,"column":5,"nodeType":"AssignmentExpression","messageId":"anyAssignment","endLine":145,"endColumn":28},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access [lastKey] on an `any` value.","line":145,"column":12,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":145,"endColumn":19},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":150,"column":28,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":150,"endColumn":31,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[4388,4391],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[4388,4391],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-unsafe-argument","severity":2,"message":"Unsafe argument of type `any` assigned to a parameter of type `string`.","line":161,"column":36,"nodeType":"Identifier","messageId":"unsafeArgument","endLine":161,"endColumn":41},{"ruleId":"react-hooks/exhaustive-deps","severity":1,"message":"React Hook useCallback has a missing dependency: 'validationConfig'. Either include it or remove the dependency array.","line":164,"column":5,"nodeType":"ArrayExpression","endLine":164,"endColumn":24,"suggestions":[{"desc":"Update the dependencies array to be: [formData.password, validationConfig]","fix":{"range":[4877,4896],"text":"[formData.password, validationConfig]"}}]},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":169,"column":28,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":169,"endColumn":31,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[4996,4999],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[4996,4999],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":2,"message":"Unsafe assignment of an `any` value.","line":175,"column":11,"nodeType":"AssignmentExpression","messageId":"anyAssignment","endLine":175,"endColumn":42},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":175,"column":23,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":175,"endColumn":26,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[5194,5197],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[5194,5197],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access [field] on an `any` value.","line":175,"column":28,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":175,"endColumn":33},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":2,"message":"Unsafe assignment of an `any` value.","line":206,"column":13,"nodeType":"VariableDeclarator","messageId":"anyAssignment","endLine":208,"endColumn":35},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":208,"column":24,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":208,"endColumn":27,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[6334,6337],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[6334,6337],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access [field] on an `any` value.","line":208,"column":29,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":208,"endColumn":34},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":2,"message":"Unsafe assignment of an `any` value.","line":230,"column":15,"nodeType":"VariableDeclarator","messageId":"anyAssignment","endLine":232,"endColumn":37},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":232,"column":26,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":232,"endColumn":29,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[7184,7187],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[7184,7187],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access [field] on an `any` value.","line":232,"column":31,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":232,"endColumn":36},{"ruleId":"@typescript-eslint/no-misused-promises","severity":2,"message":"Promise-returning function provided to attribute where a void return was expected.","line":373,"column":18,"nodeType":"JSXExpressionContainer","messageId":"voidReturnAttribute","endLine":373,"endColumn":32}],"suppressedMessages":[],"errorCount":28,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Signup Form Component\n * Refactored multi-step signup form using smaller components\n */\n\n\"use client\";\n\nimport { useState, useCallback } from \"react\";\nimport Link from \"next/link\";\nimport { ErrorMessage } from \"@/components/ui\";\nimport { useSignup } from \"../../hooks/use-auth\";\nimport { validationRules, validateField } from \"@/lib/form-validation\";\nimport type { SignupData } from \"@/lib/auth/api\";\n\nimport { MultiStepForm, type FormStep } from \"./MultiStepForm\";\nimport { AccountStep } from \"./AccountStep\";\nimport { PersonalStep } from \"./PersonalStep\";\nimport { AddressStep } from \"./AddressStep\";\nimport { PreferencesStep } from \"./PreferencesStep\";\n\ninterface SignupFormProps {\n onSuccess?: () => void;\n onError?: (error: string) => void;\n showLoginLink?: boolean;\n className?: string;\n}\n\ninterface SignupFormData {\n email: string;\n password: string;\n confirmPassword: string;\n firstName: string;\n lastName: string;\n company?: string;\n phone?: string;\n sfNumber: string;\n address: {\n line1: string;\n line2?: string;\n city: string;\n state: string;\n postalCode: string;\n country: string;\n };\n nationality?: string;\n dateOfBirth?: string;\n gender?: \"male\" | \"female\" | \"other\";\n acceptTerms: boolean;\n marketingConsent: boolean;\n}\n\ninterface FormErrors {\n [key: string]: string | undefined;\n}\n\nexport function SignupForm({\n onSuccess,\n onError,\n showLoginLink = true,\n className = \"\",\n}: SignupFormProps) {\n const { signup, loading, error, clearError } = useSignup();\n\n const [formData, setFormData] = useState({\n email: \"\",\n password: \"\",\n confirmPassword: \"\",\n firstName: \"\",\n lastName: \"\",\n company: \"\",\n phone: \"\",\n sfNumber: \"\",\n address: {\n line1: \"\",\n line2: \"\",\n city: \"\",\n state: \"\",\n postalCode: \"\",\n country: \"US\",\n },\n nationality: \"\",\n dateOfBirth: \"\",\n gender: undefined,\n acceptTerms: false,\n marketingConsent: false,\n });\n\n const [errors, setErrors] = useState({});\n const [touched, setTouched] = useState>({});\n const [currentStepIndex, setCurrentStepIndex] = useState(0);\n\n // Validation rules\n const validationConfig = {\n email: [\n validationRules.required(\"Email is required\"),\n validationRules.email(\"Please enter a valid email address\"),\n ],\n password: [\n validationRules.required(\"Password is required\"),\n validationRules.minLength(8, \"Password must be at least 8 characters\"),\n validationRules.pattern(\n /^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)/,\n \"Password must contain at least one uppercase letter, one lowercase letter, and one number\"\n ),\n ],\n confirmPassword: [validationRules.required(\"Please confirm your password\")],\n firstName: [\n validationRules.required(\"First name is required\"),\n validationRules.minLength(2, \"First name must be at least 2 characters\"),\n ],\n lastName: [\n validationRules.required(\"Last name is required\"),\n validationRules.minLength(2, \"Last name must be at least 2 characters\"),\n ],\n sfNumber: [\n validationRules.required(\"SF Number is required\"),\n validationRules.minLength(6, \"SF Number must be at least 6 characters\"),\n ],\n \"address.line1\": [validationRules.required(\"Address line 1 is required\")],\n \"address.city\": [validationRules.required(\"City is required\")],\n \"address.state\": [validationRules.required(\"State/Province is required\")],\n \"address.postalCode\": [validationRules.required(\"Postal code is required\")],\n \"address.country\": [validationRules.required(\"Country is required\")],\n acceptTerms: [\n {\n validate: (value: any) => value === true,\n message: \"You must accept the terms and conditions\",\n } as any,\n ],\n };\n\n // Get nested value\n const getNestedValue = (obj: any, path: string) => {\n return path.split(\".\").reduce((current, key) => current?.[key], obj);\n };\n\n // Set nested value\n const setNestedValue = (obj: any, path: string, value: any) => {\n const keys = path.split(\".\");\n const lastKey = keys.pop()!;\n const target = keys.reduce((current, key) => {\n if (!current[key]) current[key] = {};\n return current[key];\n }, obj);\n target[lastKey] = value;\n };\n\n // Validate field\n const validateFormField = useCallback(\n (field: string, value: any) => {\n const rules = validationConfig[field as keyof typeof validationConfig];\n if (!rules) return null;\n\n // Special validation for confirm password\n if (field === \"confirmPassword\") {\n if (!value) return \"Please confirm your password\";\n if (value !== formData.password) return \"Passwords do not match\";\n return null;\n }\n\n const result = validateField(value, rules);\n return result.isValid ? null : result.errors[0];\n },\n [formData.password]\n );\n\n // Handle field change\n const handleFieldChange = useCallback(\n (field: string, value: any) => {\n setFormData(prev => {\n const newData = { ...prev };\n if (field.includes(\".\")) {\n setNestedValue(newData, field, value);\n } else {\n (newData as any)[field] = value;\n }\n return newData;\n });\n\n // Clear general error when user starts typing\n if (errors.general) {\n setErrors(prev => ({ ...prev, general: undefined }));\n clearError();\n }\n\n // Validate field if it has been touched\n if (touched[field]) {\n const fieldError = validateFormField(field, value);\n setErrors(prev => ({ ...prev, [field]: fieldError || undefined }));\n }\n\n // Also validate confirm password when password changes\n if (field === \"password\" && touched.confirmPassword) {\n const confirmPasswordError = validateFormField(\"confirmPassword\", formData.confirmPassword);\n setErrors(prev => ({ ...prev, confirmPassword: confirmPasswordError || undefined }));\n }\n },\n [errors.general, touched, validateFormField, clearError, formData.confirmPassword]\n );\n\n // Handle field blur\n const handleFieldBlur = useCallback(\n (field: string) => {\n setTouched(prev => ({ ...prev, [field]: true }));\n\n const fieldValue = field.includes(\".\")\n ? getNestedValue(formData, field)\n : (formData as any)[field];\n const fieldError = validateFormField(field, fieldValue);\n setErrors(prev => ({ ...prev, [field]: fieldError || undefined }));\n },\n [formData, validateFormField]\n );\n\n // Validate step\n const validateStep = useCallback(\n (stepIndex: number) => {\n const stepFields = [\n [\"email\", \"password\", \"confirmPassword\"], // Account\n [\"firstName\", \"lastName\", \"sfNumber\"], // Personal\n [\"address.line1\", \"address.city\", \"address.state\", \"address.postalCode\", \"address.country\"], // Address\n [\"acceptTerms\"], // Preferences\n ];\n\n const fields = stepFields[stepIndex];\n const stepErrors: FormErrors = {};\n let isValid = true;\n\n fields.forEach(field => {\n const fieldValue = field.includes(\".\")\n ? getNestedValue(formData, field)\n : (formData as any)[field];\n const fieldError = validateFormField(field, fieldValue);\n if (fieldError) {\n stepErrors[field] = fieldError;\n isValid = false;\n }\n });\n\n setErrors(prev => ({ ...prev, ...stepErrors }));\n return isValid;\n },\n [formData, validateFormField]\n );\n\n // Handle form submission\n const handleSubmit = useCallback(async () => {\n // Validate all steps\n const allValid = [0, 1, 2, 3].every(stepIndex => validateStep(stepIndex));\n\n if (!allValid) {\n return;\n }\n\n try {\n const signupData: SignupData = {\n email: formData.email.trim(),\n password: formData.password,\n firstName: formData.firstName.trim(),\n lastName: formData.lastName.trim(),\n company: formData.company?.trim() || undefined,\n phone: formData.phone?.trim() || undefined,\n sfNumber: formData.sfNumber.trim(),\n address: {\n line1: formData.address.line1.trim(),\n line2: formData.address.line2?.trim() || undefined,\n city: formData.address.city.trim(),\n state: formData.address.state.trim(),\n postalCode: formData.address.postalCode.trim(),\n country: formData.address.country,\n },\n nationality: formData.nationality?.trim() || undefined,\n dateOfBirth: formData.dateOfBirth || undefined,\n gender: formData.gender || undefined,\n };\n\n await signup(signupData);\n onSuccess?.();\n } catch (err) {\n const errorMessage = err instanceof Error ? err.message : \"Signup failed\";\n setErrors(prev => ({ ...prev, general: errorMessage }));\n onError?.(errorMessage);\n }\n }, [formData, validateStep, signup, onSuccess, onError]);\n\n // Handle step change\n const handleStepChange = useCallback(\n (stepIndex: number) => {\n setCurrentStepIndex(stepIndex);\n // Validate current step when moving to next\n if (stepIndex > currentStepIndex) {\n validateStep(currentStepIndex);\n }\n },\n [currentStepIndex, validateStep]\n );\n\n // Define steps\n const steps: FormStep[] = [\n {\n key: \"account\",\n title: \"Account Details\",\n description: \"Create your account credentials\",\n component: (\n \n ),\n isValid: validateStep(0),\n },\n {\n key: \"personal\",\n title: \"Personal Information\",\n description: \"Tell us about yourself\",\n component: (\n \n ),\n isValid: validateStep(1),\n },\n {\n key: \"address\",\n title: \"Address & Details\",\n description: \"Your address and additional information\",\n component: (\n \n ),\n isValid: validateStep(2),\n },\n {\n key: \"preferences\",\n title: \"Preferences\",\n description: \"Set your preferences\",\n component: (\n \n ),\n isValid: validateStep(3),\n },\n ];\n\n return (\n
\n {/* General Error */}\n {(errors.general || error) && (\n \n {errors.general || error}\n \n )}\n\n \n\n {/* Login Link */}\n {showLoginLink && (\n
\n Already have an account? \n \n Sign in\n \n
\n )}\n
\n );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/auth/components/SignupForm/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/auth/components/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/auth/hooks/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/auth/hooks/use-auth.ts","messages":[{"ruleId":"@typescript-eslint/no-floating-promises","severity":2,"message":"Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator.","line":152,"column":5,"nodeType":"ExpressionStatement","messageId":"floatingVoid","endLine":152,"endColumn":17,"suggestions":[{"messageId":"floatingFixVoid","fix":{"range":[3281,3281],"text":"void "},"desc":"Add void operator to ignore."},{"messageId":"floatingFixAwait","fix":{"range":[3281,3281],"text":"await "},"desc":"Add await operator."}]},{"ruleId":"@typescript-eslint/no-floating-promises","severity":2,"message":"Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator.","line":160,"column":11,"nodeType":"ExpressionStatement","messageId":"floatingVoid","endLine":160,"endColumn":28,"suggestions":[{"messageId":"floatingFixVoid","fix":{"range":[3462,3462],"text":"void "},"desc":"Add void operator to ignore."},{"messageId":"floatingFixAwait","fix":{"range":[3462,3462],"text":"await "},"desc":"Add await operator."}]}],"suppressedMessages":[],"errorCount":2,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Authentication Hooks\n * Custom hooks for authentication functionality\n */\n\n\"use client\";\n\nimport { useCallback, useEffect } from \"react\";\nimport { useRouter, useSearchParams } from \"next/navigation\";\nimport { useAuthStore } from \"../services/auth.store\";\nimport { getPostLoginRedirect } from \"../utils/route-protection\";\nimport type { LoginRequest, SignupRequest } from \"@/lib/types\";\n\n/**\n * Main authentication hook\n */\nexport function useAuth() {\n const router = useRouter();\n const searchParams = useSearchParams();\n const store = useAuthStore();\n\n // Enhanced login with redirect handling\n const login = useCallback(\n async (credentials: LoginRequest) => {\n await store.login(credentials);\n const redirectTo = getPostLoginRedirect(searchParams);\n router.push(redirectTo);\n },\n [store, router, searchParams]\n );\n\n // Enhanced signup with redirect handling\n const signup = useCallback(\n async (data: SignupRequest) => {\n await store.signup(data);\n const redirectTo = getPostLoginRedirect(searchParams);\n router.push(redirectTo);\n },\n [store, router, searchParams]\n );\n\n // Enhanced logout with redirect\n const logout = useCallback(async () => {\n await store.logout();\n router.push(\"/auth/login\");\n }, [store, router]);\n\n return {\n // State\n isAuthenticated: store.isAuthenticated,\n user: store.user,\n loading: store.loading,\n error: store.error,\n\n // Actions\n login,\n signup,\n logout,\n requestPasswordReset: store.requestPasswordReset,\n resetPassword: store.resetPassword,\n changePassword: store.changePassword,\n checkPasswordNeeded: store.checkPasswordNeeded,\n linkWhmcs: store.linkWhmcs,\n setPassword: store.setPassword,\n checkAuth: store.checkAuth,\n refreshSession: store.refreshSession,\n clearError: store.clearError,\n };\n}\n\n/**\n * Hook for login functionality\n */\nexport function useLogin() {\n const { login, loading, error, clearError } = useAuth();\n\n return {\n login,\n loading,\n error,\n clearError,\n };\n}\n\n/**\n * Hook for signup functionality\n */\nexport function useSignup() {\n const { signup, loading, error, clearError } = useAuth();\n\n return {\n signup,\n loading,\n error,\n clearError,\n };\n}\n\n/**\n * Hook for password reset functionality\n */\nexport function usePasswordReset() {\n const { requestPasswordReset, resetPassword, loading, error, clearError } = useAuth();\n\n return {\n requestPasswordReset,\n resetPassword,\n loading,\n error,\n clearError,\n };\n}\n\n/**\n * Hook for password change functionality\n */\nexport function usePasswordChange() {\n const { changePassword, loading, error, clearError } = useAuth();\n\n return {\n changePassword,\n loading,\n error,\n clearError,\n };\n}\n\n/**\n * Hook for WHMCS linking functionality\n */\nexport function useWhmcsLink() {\n const { checkPasswordNeeded, linkWhmcs, setPassword, loading, error, clearError } = useAuth();\n\n return {\n checkPasswordNeeded,\n linkWhmcs,\n setPassword,\n loading,\n error,\n clearError,\n };\n}\n\n/**\n * Hook for session management\n */\nexport function useSession() {\n const { isAuthenticated, user, checkAuth, refreshSession, logout } = useAuth();\n\n // Auto-check auth on mount\n useEffect(() => {\n checkAuth();\n }, [checkAuth]);\n\n // Auto-refresh session periodically\n useEffect(() => {\n if (isAuthenticated) {\n const interval = setInterval(\n () => {\n refreshSession();\n },\n 5 * 60 * 1000\n ); // Check every 5 minutes\n\n return () => clearInterval(interval);\n }\n\n return undefined;\n }, [isAuthenticated, refreshSession]);\n\n return {\n isAuthenticated,\n user,\n checkAuth,\n refreshSession,\n logout,\n };\n}\n\n/**\n * Hook for user profile information\n */\nexport function useUser() {\n const { user, isAuthenticated } = useAuth();\n\n const fullName = user ? `${user.firstName || \"\"} ${user.lastName || \"\"}`.trim() : \"\";\n const initials = user\n ? `${user.firstName?.[0] || \"\"}${user.lastName?.[0] || \"\"}`.toUpperCase()\n : \"\";\n\n return {\n user,\n isAuthenticated,\n fullName,\n initials,\n email: user?.email,\n company: user?.company,\n phone: user?.phone,\n avatar: user?.avatar,\n preferences: user?.preferences,\n };\n}\n\n/**\n * Hook for checking user permissions\n */\nexport function usePermissions() {\n const { user } = useAuth();\n\n const hasRole = useCallback(\n (role: string) => {\n return user?.roles.some(r => r.name === role) || false;\n },\n [user]\n );\n\n const hasPermission = useCallback(\n (resource: string, action: string) => {\n return user?.permissions.some(p => p.resource === resource && p.action === action) || false;\n },\n [user]\n );\n\n const hasAnyRole = useCallback(\n (roles: string[]) => {\n return roles.some(role => hasRole(role));\n },\n [hasRole]\n );\n\n const hasAnyPermission = useCallback(\n (permissions: Array<{ resource: string; action: string }>) => {\n return permissions.some(({ resource, action }) => hasPermission(resource, action));\n },\n [hasPermission]\n );\n\n return {\n roles: user?.roles || [],\n permissions: user?.permissions || [],\n hasRole,\n hasPermission,\n hasAnyRole,\n hasAnyPermission,\n };\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/auth/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/auth/services/auth.service.ts","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'AuthErrorCode' is defined but never used.","line":16,"column":3,"nodeType":null,"messageId":"unusedVar","endLine":16,"endColumn":16},{"ruleId":"@typescript-eslint/only-throw-error","severity":2,"message":"Expected an error object to be thrown.","line":56,"column":13,"nodeType":"CallExpression","messageId":"object","endLine":56,"endColumn":40},{"ruleId":"@typescript-eslint/only-throw-error","severity":2,"message":"Expected an error object to be thrown.","line":83,"column":13,"nodeType":"CallExpression","messageId":"object","endLine":83,"endColumn":40},{"ruleId":"@typescript-eslint/only-throw-error","severity":2,"message":"Expected an error object to be thrown.","line":94,"column":13,"nodeType":"CallExpression","messageId":"object","endLine":94,"endColumn":40},{"ruleId":"@typescript-eslint/only-throw-error","severity":2,"message":"Expected an error object to be thrown.","line":116,"column":13,"nodeType":"CallExpression","messageId":"object","endLine":116,"endColumn":40},{"ruleId":"@typescript-eslint/only-throw-error","severity":2,"message":"Expected an error object to be thrown.","line":141,"column":13,"nodeType":"CallExpression","messageId":"object","endLine":141,"endColumn":40},{"ruleId":"@typescript-eslint/only-throw-error","severity":2,"message":"Expected an error object to be thrown.","line":153,"column":13,"nodeType":"CallExpression","messageId":"object","endLine":153,"endColumn":40},{"ruleId":"@typescript-eslint/only-throw-error","severity":2,"message":"Expected an error object to be thrown.","line":180,"column":13,"nodeType":"CallExpression","messageId":"object","endLine":180,"endColumn":40},{"ruleId":"@typescript-eslint/only-throw-error","severity":2,"message":"Expected an error object to be thrown.","line":192,"column":13,"nodeType":"CallExpression","messageId":"object","endLine":192,"endColumn":40},{"ruleId":"@typescript-eslint/only-throw-error","severity":2,"message":"Expected an error object to be thrown.","line":214,"column":13,"nodeType":"CallExpression","messageId":"object","endLine":214,"endColumn":40},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":236,"column":41,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":236,"endColumn":44,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[5965,5968],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[5965,5968],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":2,"message":"Unsafe assignment of an `any` value.","line":238,"column":7,"nodeType":"Property","messageId":"anyAssignment","endLine":238,"endColumn":21},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access .id on an `any` value.","line":238,"column":19,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":238,"endColumn":21},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":2,"message":"Unsafe assignment of an `any` value.","line":239,"column":7,"nodeType":"Property","messageId":"anyAssignment","endLine":239,"endColumn":27},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access .email on an `any` value.","line":239,"column":22,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":239,"endColumn":27},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":2,"message":"Unsafe assignment of an `any` value.","line":240,"column":7,"nodeType":"Property","messageId":"anyAssignment","endLine":240,"endColumn":35},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access .firstName on an `any` value.","line":240,"column":26,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":240,"endColumn":35},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":2,"message":"Unsafe assignment of an `any` value.","line":241,"column":7,"nodeType":"Property","messageId":"anyAssignment","endLine":241,"endColumn":33},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access .lastName on an `any` value.","line":241,"column":25,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":241,"endColumn":33},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":2,"message":"Unsafe assignment of an `any` value.","line":242,"column":7,"nodeType":"Property","messageId":"anyAssignment","endLine":242,"endColumn":31},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access .company on an `any` value.","line":242,"column":24,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":242,"endColumn":31},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":2,"message":"Unsafe assignment of an `any` value.","line":243,"column":7,"nodeType":"Property","messageId":"anyAssignment","endLine":243,"endColumn":27},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access .phone on an `any` value.","line":243,"column":22,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":243,"endColumn":27},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":2,"message":"Unsafe assignment of an `any` value.","line":244,"column":7,"nodeType":"Property","messageId":"anyAssignment","endLine":244,"endColumn":29},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access .avatar on an `any` value.","line":244,"column":23,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":244,"endColumn":29},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":2,"message":"Unsafe assignment of an `any` value.","line":271,"column":7,"nodeType":"Property","messageId":"anyAssignment","endLine":271,"endColumn":63},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access .createdAt on an `any` value.","line":271,"column":26,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":271,"endColumn":35},{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":2,"message":"Unsafe assignment of an `any` value.","line":272,"column":7,"nodeType":"Property","messageId":"anyAssignment","endLine":272,"endColumn":63},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access .updatedAt on an `any` value.","line":272,"column":26,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":272,"endColumn":35},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'token' is defined but never used.","line":279,"column":32,"nodeType":null,"messageId":"unusedVar","endLine":279,"endColumn":37}],"suppressedMessages":[],"errorCount":28,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Authentication Service\n * Centralized authentication business logic and API interactions\n */\n\nimport { authAPI } from \"@/lib/auth/api\";\nimport type {\n AuthUser,\n AuthTokens,\n LoginRequest,\n SignupRequest,\n ForgotPasswordRequest,\n ResetPasswordRequest,\n ChangePasswordRequest,\n AuthError,\n AuthErrorCode,\n} from \"@/lib/types\";\n\nexport class AuthService {\n private static instance: AuthService;\n\n static getInstance(): AuthService {\n if (!AuthService.instance) {\n AuthService.instance = new AuthService();\n }\n return AuthService.instance;\n }\n\n /**\n * Create SSO link (e.g., to WHMCS destinations)\n */\n async createSsoLink(destination: string): Promise<{ url: string }> {\n const { apiClient } = await import(\"@/lib/api/client\");\n const res = await apiClient.post<{ url: string }>(\"/auth/sso-link\", { destination });\n return res.data as { url: string };\n }\n\n /**\n * Login user with email and password\n */\n async login(credentials: LoginRequest): Promise<{ user: AuthUser; tokens: AuthTokens }> {\n try {\n const response = await authAPI.login({\n email: credentials.email,\n password: credentials.password,\n });\n\n return {\n user: this.mapApiUserToAuthUser(response.user),\n tokens: {\n accessToken: response.access_token,\n expiresAt: this.calculateTokenExpiry(response.access_token),\n },\n };\n } catch (error) {\n throw this.handleAuthError(error);\n }\n }\n\n /**\n * Register new user\n */\n async signup(data: SignupRequest): Promise<{ user: AuthUser; tokens: AuthTokens }> {\n try {\n const response = await authAPI.signup({\n email: data.email,\n password: data.password,\n firstName: data.firstName,\n lastName: data.lastName,\n company: data.company,\n phone: data.phone,\n sfNumber: \"\", // This should be handled by the form\n });\n\n return {\n user: this.mapApiUserToAuthUser(response.user),\n tokens: {\n accessToken: response.access_token,\n expiresAt: this.calculateTokenExpiry(response.access_token),\n },\n };\n } catch (error) {\n throw this.handleAuthError(error);\n }\n }\n\n /**\n * Request password reset\n */\n async requestPasswordReset(data: ForgotPasswordRequest): Promise {\n try {\n await authAPI.requestPasswordReset({ email: data.email });\n } catch (error) {\n throw this.handleAuthError(error);\n }\n }\n\n /**\n * Reset password with token\n */\n async resetPassword(data: ResetPasswordRequest): Promise<{ user: AuthUser; tokens: AuthTokens }> {\n try {\n const response = await authAPI.resetPassword({\n token: data.token,\n password: data.password,\n });\n\n return {\n user: this.mapApiUserToAuthUser(response.user),\n tokens: {\n accessToken: response.access_token,\n expiresAt: this.calculateTokenExpiry(response.access_token),\n },\n };\n } catch (error) {\n throw this.handleAuthError(error);\n }\n }\n\n /**\n * Change user password\n */\n async changePassword(\n token: string,\n data: ChangePasswordRequest\n ): Promise<{ user: AuthUser; tokens: AuthTokens }> {\n try {\n const response = await authAPI.changePassword(token, {\n currentPassword: data.currentPassword,\n newPassword: data.newPassword,\n });\n\n return {\n user: this.mapApiUserToAuthUser(response.user),\n tokens: {\n accessToken: response.access_token,\n expiresAt: this.calculateTokenExpiry(response.access_token),\n },\n };\n } catch (error) {\n throw this.handleAuthError(error);\n }\n }\n\n /**\n * Get current user profile\n */\n async getProfile(token: string): Promise {\n try {\n const user = await authAPI.getProfile(token);\n return this.mapApiUserToAuthUser(user);\n } catch (error) {\n throw this.handleAuthError(error);\n }\n }\n\n /**\n * Logout user\n */\n async logout(token: string): Promise {\n try {\n await authAPI.logout(token);\n } catch (error) {\n // Don't throw on logout errors, just log them\n console.warn(\"Logout API call failed:\", error);\n }\n }\n\n /**\n * Check if password is needed for WHMCS linking\n */\n async checkPasswordNeeded(email: string): Promise<{\n needsPasswordSet: boolean;\n userExists: boolean;\n email?: string;\n }> {\n try {\n return await authAPI.checkPasswordNeeded({ email });\n } catch (error) {\n throw this.handleAuthError(error);\n }\n }\n\n /**\n * Link WHMCS account\n */\n async linkWhmcs(email: string, password: string): Promise<{ needsPasswordSet: boolean }> {\n try {\n const response = await authAPI.linkWhmcs({ email, password });\n return { needsPasswordSet: response.needsPasswordSet };\n } catch (error) {\n throw this.handleAuthError(error);\n }\n }\n\n /**\n * Set password for WHMCS linked account\n */\n async setPassword(\n email: string,\n password: string\n ): Promise<{ user: AuthUser; tokens: AuthTokens }> {\n try {\n const response = await authAPI.setPassword({ email, password });\n\n return {\n user: this.mapApiUserToAuthUser(response.user),\n tokens: {\n accessToken: response.access_token,\n expiresAt: this.calculateTokenExpiry(response.access_token),\n },\n };\n } catch (error) {\n throw this.handleAuthError(error);\n }\n }\n\n /**\n * Check if token is expired\n */\n isTokenExpired(expiresAt: string): boolean {\n return new Date(expiresAt) <= new Date();\n }\n\n /**\n * Check if token will expire soon (within 5 minutes)\n */\n isTokenExpiringSoon(expiresAt: string): boolean {\n const fiveMinutesFromNow = new Date(Date.now() + 5 * 60 * 1000);\n return new Date(expiresAt) <= fiveMinutesFromNow;\n }\n\n /**\n * Map API user response to AuthUser type\n */\n private mapApiUserToAuthUser(apiUser: any): AuthUser {\n return {\n id: apiUser.id,\n email: apiUser.email,\n firstName: apiUser.firstName,\n lastName: apiUser.lastName,\n company: apiUser.company,\n phone: apiUser.phone,\n avatar: apiUser.avatar,\n roles: [], // TODO: Map from API when available\n permissions: [], // TODO: Map from API when available\n preferences: {\n theme: \"system\",\n language: \"en\",\n timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n notifications: {\n email: true,\n push: true,\n sms: false,\n categories: {\n billing: true,\n security: true,\n marketing: false,\n system: true,\n },\n },\n dashboard: {\n layout: \"grid\",\n widgets: [],\n defaultView: \"dashboard\",\n },\n },\n lastLoginAt: new Date().toISOString(),\n emailVerified: true, // TODO: Get from API when available\n mfaEnabled: false, // TODO: Get from API when available\n createdAt: apiUser.createdAt || new Date().toISOString(),\n updatedAt: apiUser.updatedAt || new Date().toISOString(),\n };\n }\n\n /**\n * Calculate token expiry time (default to 1 hour if not provided)\n */\n private calculateTokenExpiry(token: string): string {\n // In a real implementation, you would decode the JWT to get the expiry\n // For now, default to 1 hour from now\n return new Date(Date.now() + 60 * 60 * 1000).toISOString();\n }\n\n /**\n * Handle and normalize authentication errors\n */\n private handleAuthError(error: unknown): AuthError {\n if (error instanceof Error) {\n // Map common error messages to error codes\n const message = error.message.toLowerCase();\n\n if (message.includes(\"invalid credentials\") || message.includes(\"unauthorized\")) {\n return {\n code: \"INVALID_CREDENTIALS\",\n message: \"Invalid email or password\",\n };\n }\n\n if (message.includes(\"account locked\") || message.includes(\"locked\")) {\n return {\n code: \"ACCOUNT_LOCKED\",\n message: \"Account has been locked due to too many failed attempts\",\n };\n }\n\n if (message.includes(\"email not verified\")) {\n return {\n code: \"EMAIL_NOT_VERIFIED\",\n message: \"Please verify your email address before logging in\",\n };\n }\n\n if (message.includes(\"token expired\") || message.includes(\"expired\")) {\n return {\n code: \"TOKEN_EXPIRED\",\n message: \"Your session has expired. Please log in again\",\n };\n }\n\n if (message.includes(\"rate limit\") || message.includes(\"too many\")) {\n return {\n code: \"RATE_LIMITED\",\n message: \"Too many attempts. Please try again later\",\n };\n }\n\n return {\n code: \"INVALID_CREDENTIALS\",\n message: error.message,\n };\n }\n\n return {\n code: \"INVALID_CREDENTIALS\",\n message: \"An unexpected error occurred\",\n };\n }\n}\n\nexport const authService = AuthService.getInstance();\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/auth/services/auth.store.ts","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'error' is defined but never used.","line":265,"column":18,"nodeType":null,"messageId":"unusedVar","endLine":265,"endColumn":23},{"ruleId":"@typescript-eslint/no-misused-promises","severity":2,"message":"Promise returned in function argument where a void return was expected.","line":316,"column":22,"nodeType":"ArrowFunctionExpression","messageId":"voidReturnArgument","endLine":316,"endColumn":45},{"ruleId":"@typescript-eslint/no-floating-promises","severity":2,"message":"Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator.","line":331,"column":9,"nodeType":"ExpressionStatement","messageId":"floatingVoid","endLine":331,"endColumn":24,"suggestions":[{"messageId":"floatingFixVoid","fix":{"range":[9545,9545],"text":"void "},"desc":"Add void operator to ignore."},{"messageId":"floatingFixAwait","fix":{"range":[9545,9545],"text":"await "},"desc":"Add await operator."}]},{"ruleId":"@typescript-eslint/no-floating-promises","severity":2,"message":"Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator.","line":333,"column":9,"nodeType":"ExpressionStatement","messageId":"floatingVoid","endLine":333,"endColumn":32,"suggestions":[{"messageId":"floatingFixVoid","fix":{"range":[9584,9584],"text":"void "},"desc":"Add void operator to ignore."},{"messageId":"floatingFixAwait","fix":{"range":[9584,9584],"text":"await "},"desc":"Add await operator."}]}],"suppressedMessages":[],"errorCount":3,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Authentication Store\n * Centralized authentication state management with Zustand\n */\n\nimport { create } from \"zustand\";\nimport { persist, createJSONStorage } from \"zustand/middleware\";\nimport { authService } from \"./auth.service\";\nimport type { AuthTokens, LoginRequest, SignupRequest } from \"@/lib/types\";\nimport type {\n AuthUser,\n ForgotPasswordRequest,\n ResetPasswordRequest,\n ChangePasswordRequest,\n AuthError,\n} from \"@/lib/types/auth.types\";\n\ninterface AuthState {\n isAuthenticated: boolean;\n user: AuthUser | null;\n tokens: AuthTokens | null;\n loading: boolean;\n error: string | null;\n}\n\ninterface AuthStoreState extends AuthState {\n // Actions\n login: (credentials: LoginRequest) => Promise;\n signup: (data: SignupRequest) => Promise;\n logout: () => Promise;\n requestPasswordReset: (data: ForgotPasswordRequest) => Promise;\n resetPassword: (data: ResetPasswordRequest) => Promise;\n changePassword: (data: ChangePasswordRequest) => Promise;\n checkPasswordNeeded: (email: string) => Promise<{\n needsPasswordSet: boolean;\n userExists: boolean;\n email?: string;\n }>;\n linkWhmcs: (email: string, password: string) => Promise<{ needsPasswordSet: boolean }>;\n setPassword: (email: string, password: string) => Promise;\n\n // Session management\n checkAuth: () => Promise;\n refreshSession: () => Promise;\n clearError: () => void;\n\n // Internal state management\n setLoading: (loading: boolean) => void;\n setError: (error: string | null) => void;\n setUser: (user: AuthUser | null) => void;\n setTokens: (tokens: AuthTokens | null) => void;\n}\n\nexport const useAuthStore = create()(\n persist(\n (set, get) => ({\n // Initial state\n isAuthenticated: false,\n user: null,\n tokens: null,\n loading: false,\n error: null,\n\n // Authentication actions\n login: async (credentials: LoginRequest) => {\n set({ loading: true, error: null });\n try {\n const { user, tokens } = await authService.login(credentials);\n set({\n user,\n tokens,\n isAuthenticated: true,\n loading: false,\n error: null,\n });\n } catch (error) {\n const authError = error as AuthError;\n set({\n loading: false,\n error: authError.message,\n isAuthenticated: false,\n user: null,\n tokens: null,\n });\n throw error;\n }\n },\n\n signup: async (data: SignupRequest) => {\n set({ loading: true, error: null });\n try {\n const { user, tokens } = await authService.signup(data);\n set({\n user,\n tokens,\n isAuthenticated: true,\n loading: false,\n error: null,\n });\n } catch (error) {\n const authError = error as AuthError;\n set({\n loading: false,\n error: authError.message,\n isAuthenticated: false,\n user: null,\n tokens: null,\n });\n throw error;\n }\n },\n\n logout: async () => {\n const { tokens } = get();\n\n // Call logout API if we have tokens\n if (tokens?.accessToken) {\n try {\n await authService.logout(tokens.accessToken);\n } catch (error) {\n console.warn(\"Logout API call failed:\", error);\n // Continue with local logout even if API call fails\n }\n }\n\n set({\n user: null,\n tokens: null,\n isAuthenticated: false,\n loading: false,\n error: null,\n });\n },\n\n requestPasswordReset: async (data: ForgotPasswordRequest) => {\n set({ loading: true, error: null });\n try {\n await authService.requestPasswordReset(data);\n set({ loading: false });\n } catch (error) {\n const authError = error as AuthError;\n set({ loading: false, error: authError.message });\n throw error;\n }\n },\n\n resetPassword: async (data: ResetPasswordRequest) => {\n set({ loading: true, error: null });\n try {\n const { user, tokens } = await authService.resetPassword(data);\n set({\n user,\n tokens,\n isAuthenticated: true,\n loading: false,\n error: null,\n });\n } catch (error) {\n const authError = error as AuthError;\n set({\n loading: false,\n error: authError.message,\n isAuthenticated: false,\n user: null,\n tokens: null,\n });\n throw error;\n }\n },\n\n changePassword: async (data: ChangePasswordRequest) => {\n const { tokens } = get();\n if (!tokens?.accessToken) {\n throw new Error(\"Not authenticated\");\n }\n\n set({ loading: true, error: null });\n try {\n const { user, tokens: newTokens } = await authService.changePassword(\n tokens.accessToken,\n data\n );\n set({\n user,\n tokens: newTokens,\n loading: false,\n error: null,\n });\n } catch (error) {\n const authError = error as AuthError;\n set({ loading: false, error: authError.message });\n throw error;\n }\n },\n\n checkPasswordNeeded: async (email: string) => {\n set({ loading: true, error: null });\n try {\n const result = await authService.checkPasswordNeeded(email);\n set({ loading: false });\n return result;\n } catch (error) {\n const authError = error as AuthError;\n set({ loading: false, error: authError.message });\n throw error;\n }\n },\n\n linkWhmcs: async (email: string, password: string) => {\n set({ loading: true, error: null });\n try {\n const result = await authService.linkWhmcs(email, password);\n set({ loading: false });\n return result;\n } catch (error) {\n const authError = error as AuthError;\n set({ loading: false, error: authError.message });\n throw error;\n }\n },\n\n setPassword: async (email: string, password: string) => {\n set({ loading: true, error: null });\n try {\n const { user, tokens } = await authService.setPassword(email, password);\n set({\n user,\n tokens,\n isAuthenticated: true,\n loading: false,\n error: null,\n });\n } catch (error) {\n const authError = error as AuthError;\n set({\n loading: false,\n error: authError.message,\n isAuthenticated: false,\n user: null,\n tokens: null,\n });\n throw error;\n }\n },\n\n // Session management\n checkAuth: async () => {\n const { tokens } = get();\n\n if (!tokens?.accessToken) {\n set({ isAuthenticated: false, loading: false, user: null, tokens: null });\n return;\n }\n\n // Check if token is expired\n if (authService.isTokenExpired(tokens.expiresAt)) {\n set({ isAuthenticated: false, loading: false, user: null, tokens: null });\n return;\n }\n\n set({ loading: true });\n try {\n const user = await authService.getProfile(tokens.accessToken);\n set({ user, isAuthenticated: true, loading: false, error: null });\n } catch (error) {\n // Token is invalid, clear auth state\n console.info(\"Token validation failed, clearing auth state\");\n set({\n user: null,\n tokens: null,\n isAuthenticated: false,\n loading: false,\n error: null,\n });\n }\n },\n\n refreshSession: async () => {\n const { tokens, checkAuth } = get();\n\n if (!tokens?.accessToken) {\n return;\n }\n\n // Check if token needs refresh (expires within 5 minutes)\n if (authService.isTokenExpiringSoon(tokens.expiresAt)) {\n // For now, just re-validate the token\n // In a real implementation, you would call a refresh token endpoint\n await checkAuth();\n }\n },\n\n // Utility actions\n clearError: () => set({ error: null }),\n\n setLoading: (loading: boolean) => set({ loading }),\n\n setError: (error: string | null) => set({ error }),\n\n setUser: (user: AuthUser | null) => set({ user }),\n\n setTokens: (tokens: AuthTokens | null) => set({ tokens }),\n }),\n {\n name: \"auth-store\",\n storage: createJSONStorage(() => localStorage),\n partialize: state => ({\n user: state.user,\n tokens: state.tokens,\n isAuthenticated: state.isAuthenticated,\n }),\n // Rehydrate the store and check auth status\n onRehydrateStorage: () => state => {\n if (state?.tokens?.accessToken) {\n // Check auth status after rehydration\n setTimeout(() => state.checkAuth(), 0);\n }\n },\n }\n )\n);\n\n// Session timeout detection\nlet sessionTimeoutId: NodeJS.Timeout | null = null;\n\nexport const startSessionTimeout = () => {\n const checkSession = () => {\n const state = useAuthStore.getState();\n if (state.tokens?.accessToken) {\n if (authService.isTokenExpired(state.tokens.expiresAt)) {\n state.logout();\n } else {\n state.refreshSession();\n }\n }\n };\n\n // Check session every minute\n sessionTimeoutId = setInterval(checkSession, 60 * 1000);\n};\n\nexport const stopSessionTimeout = () => {\n if (sessionTimeoutId) {\n clearInterval(sessionTimeoutId);\n sessionTimeoutId = null;\n }\n};\n\n// Auto-start session timeout when store is created\nif (typeof window !== \"undefined\") {\n startSessionTimeout();\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/auth/services/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/auth/types/index.ts","messages":[{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":31,"column":13,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":31,"endColumn":16,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[582,585],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[582,585],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":33,"column":41,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":33,"endColumn":44,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[673,676],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[673,676],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]}],"suppressedMessages":[],"errorCount":2,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Authentication Types\n * Type definitions specific to the authentication feature\n */\n\n// Re-export common auth types from lib\nexport type {\n AuthUser,\n AuthTokens,\n AuthState,\n AuthError,\n AuthErrorCode,\n LoginRequest,\n SignupRequest,\n ForgotPasswordRequest,\n ResetPasswordRequest,\n ChangePasswordRequest,\n UserRole,\n Permission,\n UserPreferences,\n} from \"@/lib/types\";\n\n// Feature-specific types\nexport interface AuthFormProps {\n onSuccess?: () => void;\n onError?: (error: string) => void;\n className?: string;\n}\n\nexport interface AuthStepProps {\n formData: any;\n errors: Record;\n onFieldChange: (field: string, value: any) => void;\n onFieldBlur: (field: string) => void;\n loading?: boolean;\n}\n\nexport interface AuthGuardConfig {\n requireAuth?: boolean;\n roles?: string[];\n permissions?: Array<{ resource: string; action: string }>;\n fallback?: React.ReactNode;\n requireAll?: boolean;\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/auth/utils/auth-guard.tsx","messages":[{"ruleId":"@typescript-eslint/no-floating-promises","severity":2,"message":"Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator.","line":35,"column":5,"nodeType":"ExpressionStatement","messageId":"floatingVoid","endLine":35,"endColumn":17,"suggestions":[{"messageId":"floatingFixVoid","fix":{"range":[898,898],"text":"void "},"desc":"Add void operator to ignore."},{"messageId":"floatingFixAwait","fix":{"range":[898,898],"text":"await "},"desc":"Add await operator."}]},{"ruleId":"react/no-unescaped-entities","severity":2,"message":"`'` can be escaped with `'`, `‘`, `'`, `’`.","line":192,"column":36,"nodeType":"JSXText","messageId":"unescapedEntityAlts","suggestions":[{"messageId":"replaceWithAlt","data":{"alt":"'"},"fix":{"range":[4509,4563],"text":"You don't have the required role to view this content."},"desc":"Replace with `'`."},{"messageId":"replaceWithAlt","data":{"alt":"‘"},"fix":{"range":[4509,4563],"text":"You don‘t have the required role to view this content."},"desc":"Replace with `‘`."},{"messageId":"replaceWithAlt","data":{"alt":"'"},"fix":{"range":[4509,4563],"text":"You don't have the required role to view this content."},"desc":"Replace with `'`."},{"messageId":"replaceWithAlt","data":{"alt":"’"},"fix":{"range":[4509,4563],"text":"You don’t have the required role to view this content."},"desc":"Replace with `’`."}]},{"ruleId":"react/no-unescaped-entities","severity":2,"message":"`'` can be escaped with `'`, `‘`, `'`, `’`.","line":206,"column":36,"nodeType":"JSXText","messageId":"unescapedEntityAlts","suggestions":[{"messageId":"replaceWithAlt","data":{"alt":"'"},"fix":{"range":[4864,4911],"text":"You don't have permission to view this content."},"desc":"Replace with `'`."},{"messageId":"replaceWithAlt","data":{"alt":"‘"},"fix":{"range":[4864,4911],"text":"You don‘t have permission to view this content."},"desc":"Replace with `‘`."},{"messageId":"replaceWithAlt","data":{"alt":"'"},"fix":{"range":[4864,4911],"text":"You don't have permission to view this content."},"desc":"Replace with `'`."},{"messageId":"replaceWithAlt","data":{"alt":"’"},"fix":{"range":[4864,4911],"text":"You don’t have permission to view this content."},"desc":"Replace with `’`."}]}],"suppressedMessages":[],"errorCount":3,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Authentication Guard Components\n * Components for protecting routes and handling authentication state\n */\n\n\"use client\";\n\nimport { useEffect, ReactNode } from \"react\";\nimport { useRouter, usePathname } from \"next/navigation\";\nimport { useAuth } from \"../hooks/use-auth\";\nimport { getAuthRedirect, isProtectedRoute } from \"./route-protection\";\nimport { LoadingSpinner } from \"@/components/ui\";\n\ninterface AuthGuardProps {\n children: ReactNode;\n fallback?: ReactNode;\n requireAuth?: boolean;\n}\n\n/**\n * Auth Guard Component\n * Protects routes based on authentication status\n */\nexport function AuthGuard({\n children,\n fallback = ,\n requireAuth = true,\n}: AuthGuardProps) {\n const { isAuthenticated, loading, checkAuth } = useAuth();\n const router = useRouter();\n const pathname = usePathname();\n\n useEffect(() => {\n // Check authentication status on mount\n checkAuth();\n }, [checkAuth]);\n\n useEffect(() => {\n // Handle redirects based on auth status and current route\n if (!loading) {\n const redirectTo = getAuthRedirect(isAuthenticated, pathname);\n if (redirectTo) {\n router.push(redirectTo);\n return;\n }\n }\n }, [isAuthenticated, loading, pathname, router]);\n\n // Show loading while checking authentication\n if (loading) {\n return <>{fallback};\n }\n\n // For protected routes, ensure user is authenticated\n if (requireAuth && isProtectedRoute(pathname) && !isAuthenticated) {\n return <>{fallback};\n }\n\n // For auth pages, redirect authenticated users\n if (isAuthenticated && pathname.startsWith(\"/auth/\")) {\n return <>{fallback};\n }\n\n return <>{children};\n}\n\n/**\n * Protected Route Component\n * Wrapper for routes that require authentication\n */\nexport function ProtectedRoute({\n children,\n fallback,\n}: {\n children: ReactNode;\n fallback?: ReactNode;\n}) {\n return (\n \n {children}\n \n );\n}\n\n/**\n * Public Route Component\n * Wrapper for routes that don't require authentication\n */\nexport function PublicRoute({ children, fallback }: { children: ReactNode; fallback?: ReactNode }) {\n return (\n \n {children}\n \n );\n}\n\n/**\n * Default fallback component for auth guard\n */\nfunction AuthGuardFallback() {\n return (\n
\n
\n \n

Checking authentication...

\n
\n
\n );\n}\n\n/**\n * Role-based Guard Component\n * Protects content based on user roles\n */\ninterface RoleGuardProps {\n children: ReactNode;\n roles: string[];\n fallback?: ReactNode;\n requireAll?: boolean;\n}\n\nexport function RoleGuard({\n children,\n roles,\n fallback = ,\n requireAll = false,\n}: RoleGuardProps) {\n const { user, isAuthenticated } = useAuth();\n\n if (!isAuthenticated || !user) {\n return <>{fallback};\n }\n\n const userRoles = user.roles.map(role => role.name);\n const hasAccess = requireAll\n ? roles.every(role => userRoles.includes(role))\n : roles.some(role => userRoles.includes(role));\n\n if (!hasAccess) {\n return <>{fallback};\n }\n\n return <>{children};\n}\n\n/**\n * Permission-based Guard Component\n * Protects content based on user permissions\n */\ninterface PermissionGuardProps {\n children: ReactNode;\n permissions: Array<{ resource: string; action: string }>;\n fallback?: ReactNode;\n requireAll?: boolean;\n}\n\nexport function PermissionGuard({\n children,\n permissions,\n fallback = ,\n requireAll = false,\n}: PermissionGuardProps) {\n const { user, isAuthenticated } = useAuth();\n\n if (!isAuthenticated || !user) {\n return <>{fallback};\n }\n\n const hasAccess = requireAll\n ? permissions.every(({ resource, action }) =>\n user.permissions.some(p => p.resource === resource && p.action === action)\n )\n : permissions.some(({ resource, action }) =>\n user.permissions.some(p => p.resource === resource && p.action === action)\n );\n\n if (!hasAccess) {\n return <>{fallback};\n }\n\n return <>{children};\n}\n\n/**\n * Default fallback for role guard\n */\nfunction RoleGuardFallback() {\n return (\n
\n
\n

Access Denied

\n

You don't have the required role to view this content.

\n
\n
\n );\n}\n\n/**\n * Default fallback for permission guard\n */\nfunction PermissionGuardFallback() {\n return (\n
\n
\n

Access Denied

\n

You don't have permission to view this content.

\n
\n
\n );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/auth/utils/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/auth/utils/route-protection.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/components/BillingStatusBadge/BillingStatusBadge.tsx","messages":[{"ruleId":"@typescript-eslint/no-redundant-type-constituents","severity":2,"message":"\"Draft\" | \"Pending\" | \"Paid\" | \"Unpaid\" | \"Overdue\" | \"Cancelled\" | \"Refunded\" | \"Collections\" is overridden by string in this union type.","line":16,"column":11,"nodeType":"TSTypeReference","messageId":"literalOverridden","endLine":16,"endColumn":24}],"suppressedMessages":[],"errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\"use client\";\n\nimport { forwardRef } from \"react\";\nimport {\n CheckCircleIcon,\n ExclamationTriangleIcon,\n ClockIcon,\n DocumentTextIcon,\n XCircleIcon,\n} from \"@heroicons/react/24/outline\";\nimport { StatusPill } from \"@/components/ui/status-pill\";\nimport type { StatusPillProps } from \"@/components/ui/status-pill\";\nimport type { InvoiceStatus } from \"@customer-portal/shared\";\n\ninterface BillingStatusBadgeProps extends Omit {\n status: InvoiceStatus | string;\n showIcon?: boolean;\n}\n\nconst getStatusConfig = (status: string) => {\n switch (status.toLowerCase()) {\n case \"paid\":\n return {\n variant: \"success\" as const,\n icon: ,\n label: \"Paid\",\n };\n case \"overdue\":\n return {\n variant: \"error\" as const,\n icon: ,\n label: \"Overdue\",\n };\n case \"unpaid\":\n return {\n variant: \"warning\" as const,\n icon: ,\n label: \"Unpaid\",\n };\n case \"cancelled\":\n case \"canceled\":\n return {\n variant: \"neutral\" as const,\n icon: ,\n label: \"Cancelled\",\n };\n case \"draft\":\n return {\n variant: \"neutral\" as const,\n icon: ,\n label: \"Draft\",\n };\n case \"refunded\":\n return {\n variant: \"info\" as const,\n icon: ,\n label: \"Refunded\",\n };\n case \"collections\":\n return {\n variant: \"error\" as const,\n icon: ,\n label: \"Collections\",\n };\n case \"payment pending\":\n return {\n variant: \"warning\" as const,\n icon: ,\n label: \"Payment Pending\",\n };\n default:\n return {\n variant: \"neutral\" as const,\n icon: ,\n label: status,\n };\n }\n};\n\nconst BillingStatusBadge = forwardRef(\n ({ status, showIcon = true, children, ...props }, ref) => {\n const config = getStatusConfig(status);\n\n return (\n \n );\n }\n);\n\nBillingStatusBadge.displayName = \"BillingStatusBadge\";\n\nexport { BillingStatusBadge };\nexport type { BillingStatusBadgeProps };\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/components/BillingStatusBadge/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/components/BillingSummary/BillingSummary.tsx","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'BillingStatusBadge' is defined but never used.","line":12,"column":10,"nodeType":null,"messageId":"unusedVar","endLine":12,"endColumn":28}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\"use client\";\n\nimport { forwardRef } from \"react\";\nimport Link from \"next/link\";\nimport {\n CreditCardIcon,\n ExclamationTriangleIcon,\n CheckCircleIcon,\n ClockIcon,\n ArrowRightIcon,\n} from \"@heroicons/react/24/outline\";\nimport { BillingStatusBadge } from \"../BillingStatusBadge\";\nimport type { BillingSummaryData } from \"../../types\";\nimport { formatCurrency, getCurrencyLocale } from \"@/utils/currency\";\nimport { cn } from \"@/lib/utils\";\n\ninterface BillingSummaryProps extends React.HTMLAttributes {\n summary: BillingSummaryData;\n loading?: boolean;\n compact?: boolean;\n}\n\nconst BillingSummary = forwardRef(\n ({ summary, loading = false, compact = false, className, ...props }, ref) => {\n if (loading) {\n return (\n \n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n \n );\n }\n\n const formatAmount = (amount: number) => {\n return formatCurrency(amount, {\n currency: summary.currency,\n currencySymbol: summary.currencySymbol,\n locale: getCurrencyLocale(summary.currency),\n });\n };\n\n const summaryItems = [\n {\n label: \"Outstanding\",\n amount: summary.totalOutstanding,\n count: summary.invoiceCount.unpaid,\n variant: summary.totalOutstanding > 0 ? \"warning\" : \"neutral\",\n icon: summary.totalOutstanding > 0 ? ClockIcon : CheckCircleIcon,\n },\n {\n label: \"Overdue\",\n amount: summary.totalOverdue,\n count: summary.invoiceCount.overdue,\n variant: summary.totalOverdue > 0 ? \"error\" : \"neutral\",\n icon: summary.totalOverdue > 0 ? ExclamationTriangleIcon : CheckCircleIcon,\n },\n {\n label: \"Paid This Period\",\n amount: summary.totalPaid,\n count: summary.invoiceCount.paid,\n variant: \"success\",\n icon: CheckCircleIcon,\n },\n ];\n\n return (\n \n {/* Header */}\n
\n
\n
\n \n
\n

Billing Summary

\n
\n {!compact && (\n \n View All\n \n \n )}\n
\n\n {/* Summary Items */}\n
\n {summaryItems.map((item, index) => {\n const IconComponent = item.icon;\n\n return (\n \n
\n \n
\n
{item.label}
\n {!compact && item.count > 0 && (\n
\n {item.count} invoice{item.count !== 1 ? \"s\" : \"\"}\n
\n )}\n
\n
\n
\n
\n {formatAmount(item.amount)}\n
\n {compact && item.count > 0 && (\n
\n {item.count} invoice{item.count !== 1 ? \"s\" : \"\"}\n
\n )}\n
\n
\n );\n })}\n \n\n {/* Total Invoices */}\n {!compact && (\n
\n
\n Total Invoices\n {summary.invoiceCount.total}\n
\n
\n )}\n\n {/* Quick Actions */}\n {compact && (\n
\n \n View All Invoices\n \n \n
\n )}\n \n );\n }\n);\n\nBillingSummary.displayName = \"BillingSummary\";\n\nexport { BillingSummary };\nexport type { BillingSummaryProps };\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/components/BillingSummary/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceHeader.tsx","messages":[{"ruleId":"prettier/prettier","severity":1,"message":"Insert `Ā·Ā·`","line":57,"column":15,"nodeType":null,"messageId":"insert","endLine":57,"endColumn":15,"fix":{"range":[1479,1479],"text":" "}},{"ruleId":"prettier/prettier","severity":1,"message":"Insert `Ā·Ā·`","line":58,"column":1,"nodeType":null,"messageId":"insert","endLine":58,"endColumn":1,"fix":{"range":[1489,1489],"text":" "}},{"ruleId":"prettier/prettier","severity":1,"message":"Insert `Ā·Ā·Ā·Ā·`","line":59,"column":15,"nodeType":null,"messageId":"insert","endLine":59,"endColumn":15,"fix":{"range":[1547,1547],"text":" "}},{"ruleId":"prettier/prettier","severity":1,"message":"Insert `Ā·Ā·Ā·Ā·`","line":60,"column":1,"nodeType":null,"messageId":"insert","endLine":60,"endColumn":1,"fix":{"range":[1559,1559],"text":" "}},{"ruleId":"prettier/prettier","severity":1,"message":"Replace `Ā·size=\"sm\"Ā·variant=\"gray\"Ā·className=\"mr-1.5\"Ā·label=\"Downloading...\"` with `āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·size=\"sm\"āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·variant=\"gray\"āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·className=\"mr-1.5\"āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·label=\"Downloading...\"āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·`","line":70,"column":32,"nodeType":null,"messageId":"replace","endLine":70,"endColumn":99,"fix":{"range":[2085,2152],"text":"\n size=\"sm\"\n variant=\"gray\"\n className=\"mr-1.5\"\n label=\"Downloading...\"\n "}},{"ruleId":"prettier/prettier","severity":1,"message":"Replace `Ā·size=\"sm\"Ā·variant=\"gray\"Ā·className=\"mr-1.5\"Ā·label=\"Loading...\"` with `āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·size=\"sm\"āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·variant=\"gray\"āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·className=\"mr-1.5\"āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·label=\"Loading...\"āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·`","line":85,"column":36,"nodeType":null,"messageId":"replace","endLine":85,"endColumn":99,"fix":{"range":[2855,2918],"text":"\n size=\"sm\"\n variant=\"gray\"\n className=\"mr-1.5\"\n label=\"Loading...\"\n "}},{"ruleId":"prettier/prettier","severity":1,"message":"Replace `Ā·size=\"sm\"Ā·variant=\"white\"Ā·className=\"mr-2\"Ā·label=\"Processing...\"` with `āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·size=\"sm\"āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·variant=\"white\"āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·className=\"mr-2\"āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·label=\"Processing...\"āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·`","line":102,"column":36,"nodeType":null,"messageId":"replace","endLine":102,"endColumn":101,"fix":{"range":[3722,3787],"text":"\n size=\"sm\"\n variant=\"white\"\n className=\"mr-2\"\n label=\"Processing...\"\n "}},{"ruleId":"prettier/prettier","severity":1,"message":"Insert `Ā·Ā·`","line":128,"column":23,"nodeType":null,"messageId":"insert","endLine":128,"endColumn":23,"fix":{"range":[4862,4862],"text":" "}},{"ruleId":"prettier/prettier","severity":1,"message":"Insert `Ā·Ā·`","line":129,"column":1,"nodeType":null,"messageId":"insert","endLine":129,"endColumn":1,"fix":{"range":[4918,4918],"text":" "}}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":9,"fixableErrorCount":0,"fixableWarningCount":9,"source":"\"use client\";\n\nimport React from \"react\";\nimport { DetailHeader } from \"@/components/common/DetailHeader\";\nimport { LoadingSpinner } from \"@/components/ui/loading-spinner\";\nimport {\n ArrowTopRightOnSquareIcon,\n ArrowDownTrayIcon,\n ServerIcon,\n} from \"@heroicons/react/24/outline\";\nimport { format } from \"date-fns\";\nimport type { Invoice } from \"@customer-portal/shared\";\n\nconst formatDate = (dateString?: string) => {\n if (!dateString || dateString === \"0000-00-00\" || dateString === \"0000-00-00 00:00:00\")\n return \"N/A\";\n try {\n const date = new Date(dateString);\n if (isNaN(date.getTime())) return \"N/A\";\n return format(date, \"MMM d, yyyy\");\n } catch {\n return \"N/A\";\n }\n};\n\ninterface InvoiceHeaderProps {\n invoice: Invoice;\n loadingDownload?: boolean;\n loadingPayment?: boolean;\n loadingPaymentMethods?: boolean;\n onDownload?: () => void;\n onPay?: () => void;\n onManagePaymentMethods?: () => void;\n}\n\nexport function InvoiceHeader(props: InvoiceHeaderProps) {\n const {\n invoice,\n loadingDownload,\n loadingPayment,\n loadingPaymentMethods,\n onDownload,\n onPay,\n onManagePaymentMethods,\n } = props;\n\n return (\n
\n \n \n {loadingDownload ? (\n \n ) : (\n \n )}\n Download\n \n\n {(invoice.status === \"Unpaid\" || invoice.status === \"Overdue\") && (\n <>\n \n {loadingPaymentMethods ? (\n \n ) : (\n \n )}\n Payment\n \n\n \n {loadingPayment ? (\n \n ) : (\n \n )}\n {invoice.status === \"Overdue\" ? \"Pay Overdue\" : \"Pay Now\"}\n \n \n )}\n
\n }\n meta={\n
\n
\n Issued:\n \n {formatDate(invoice.issuedAt)}\n \n
\n {invoice.dueDate && (\n
\n Due:\n \n {formatDate(invoice.dueDate)}\n {invoice.status === \"Overdue\" && \" • OVERDUE\"}\n \n
\n )}\n
\n }\n />\n \n );\n}\n\nexport type { InvoiceHeaderProps };\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceItems.tsx","messages":[{"ruleId":"prettier/prettier","severity":1,"message":"Delete `āŽ`","line":48,"column":1,"nodeType":null,"messageId":"delete","endLine":49,"endColumn":1,"fix":{"range":[1553,1554],"text":""}}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":1,"source":"\"use client\";\n\nimport React from \"react\";\nimport { SubCard } from \"@/components/ui/sub-card\";\nimport { formatCurrency } from \"@/utils/currency\";\nimport type { InvoiceItem } from \"@customer-portal/shared\";\n\ninterface InvoiceItemsProps {\n items?: InvoiceItem[];\n currency: string;\n}\n\nexport function InvoiceItems({ items = [], currency }: InvoiceItemsProps) {\n return (\n \n {items.length > 0 ? (\n
\n {items.map(item => (\n \n
\n
{item.description}
\n {item.quantity && item.quantity > 1 && (\n
Quantity: {item.quantity}
\n )}\n {item.serviceId && (\n
Service ID: {item.serviceId}
\n )}\n
\n
\n
\n {formatCurrency(item.amount || 0, { currency })}\n
\n
\n
\n ))}\n \n ) : (\n
No items found on this invoice.
\n )}\n
\n );\n}\n\nexport type { InvoiceItemsProps };\n\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceTotals.tsx","messages":[{"ruleId":"prettier/prettier","severity":1,"message":"Delete `āŽ`","line":43,"column":1,"nodeType":null,"messageId":"delete","endLine":44,"endColumn":1,"fix":{"range":[1356,1357],"text":""}}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":1,"source":"\"use client\";\n\nimport React from \"react\";\nimport { SubCard } from \"@/components/ui/sub-card\";\nimport { formatCurrency } from \"@/utils/currency\";\n\ninterface InvoiceTotalsProps {\n subtotal: number;\n tax: number;\n total: number;\n currency: string;\n}\n\nexport function InvoiceTotals({ subtotal, tax, total, currency }: InvoiceTotalsProps) {\n const fmt = (amount: number) => formatCurrency(amount, { currency });\n return (\n \n
\n
\n
\n Subtotal\n {fmt(subtotal)}\n
\n {tax > 0 && (\n
\n Tax\n {fmt(tax)}\n
\n )}\n
\n
\n Total\n {fmt(total)}\n
\n
\n
\n
\n
\n );\n}\n\nexport type { InvoiceTotalsProps };\n\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/components/InvoiceDetail/index.ts","messages":[{"ruleId":"prettier/prettier","severity":1,"message":"Delete `āŽ`","line":4,"column":1,"nodeType":null,"messageId":"delete","endLine":5,"endColumn":1,"fix":{"range":[98,99],"text":""}}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":1,"source":"export * from \"./InvoiceHeader\";\nexport * from \"./InvoiceItems\";\nexport * from \"./InvoiceTotals\";\n\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/components/InvoiceList/InvoiceList.tsx","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'showFilters' is assigned a value but never used.","line":25,"column":3,"nodeType":null,"messageId":"unusedVar","endLine":25,"endColumn":14},{"ruleId":"react-hooks/rules-of-hooks","severity":2,"message":"React Hook \"useSubscriptionInvoices\" is called conditionally. React Hooks must be called in the exact same order in every component render.","line":36,"column":7,"nodeType":"Identifier","endLine":36,"endColumn":30},{"ruleId":"@typescript-eslint/no-unnecessary-type-assertion","severity":2,"message":"This assertion is unnecessary since it does not change the type of the expression.","line":36,"column":31,"nodeType":"TSAsExpression","messageId":"unnecessaryAssertion","endLine":36,"endColumn":55,"fix":{"range":[1319,1329],"text":""}},{"ruleId":"react-hooks/rules-of-hooks","severity":2,"message":"React Hook \"useInvoices\" is called conditionally. React Hooks must be called in the exact same order in every component render.","line":37,"column":7,"nodeType":"Identifier","endLine":37,"endColumn":18},{"ruleId":"prettier/prettier","severity":1,"message":"Replace `Ā·page:Ā·currentPage,Ā·limit:Ā·pageSize,Ā·status:Ā·statusFilterĀ·===Ā·\"all\"Ā·?Ā·undefinedĀ·:Ā·statusFilter` with `āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·page:Ā·currentPage,āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·limit:Ā·pageSize,āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·status:Ā·statusFilterĀ·===Ā·\"all\"Ā·?Ā·undefinedĀ·:Ā·statusFilter,āŽĀ·Ā·Ā·Ā·Ā·`","line":37,"column":20,"nodeType":null,"messageId":"replace","endLine":37,"endColumn":114,"fix":{"range":[1390,1484],"text":"\n page: currentPage,\n limit: pageSize,\n status: statusFilter === \"all\" ? undefined : statusFilter,\n "}},{"ruleId":"react-hooks/exhaustive-deps","severity":1,"message":"The 'invoices' logical expression could make the dependencies of useMemo Hook (at line 57) change on every render. Move it inside the useMemo callback. Alternatively, wrap the initialization of 'invoices' in its own useMemo() Hook.","line":45,"column":9,"nodeType":"VariableDeclarator","endLine":45,"endColumn":40}],"suppressedMessages":[],"errorCount":3,"fatalErrorCount":0,"warningCount":3,"fixableErrorCount":1,"fixableWarningCount":1,"source":"\"use client\";\n\nimport React, { useMemo, useState } from \"react\";\nimport { SubCard } from \"@/components/ui/sub-card\";\nimport { LoadingSpinner } from \"@/components/ui/loading-spinner\";\nimport { ErrorState } from \"@/components/ui/error-state\";\nimport { SearchFilterBar } from \"@/components/common/SearchFilterBar\";\nimport { PaginationBar } from \"@/components/common/PaginationBar\";\nimport { InvoiceTable } from \"@/features/billing/components/InvoiceTable/InvoiceTable\";\nimport { useInvoices } from \"@/features/billing/hooks/useBilling\";\nimport { useSubscriptionInvoices } from \"@/features/subscriptions/hooks/useSubscriptions\";\nimport type { Invoice } from \"@customer-portal/shared\";\n\ninterface InvoicesListProps {\n subscriptionId?: number;\n pageSize?: number;\n showFilters?: boolean;\n compact?: boolean;\n className?: string;\n}\n\nexport function InvoicesList({\n subscriptionId,\n pageSize = 10,\n showFilters = true,\n compact = false,\n className,\n}: InvoicesListProps) {\n const [searchTerm, setSearchTerm] = useState(\"\");\n const [statusFilter, setStatusFilter] = useState(\"all\");\n const [currentPage, setCurrentPage] = useState(1);\n\n const isSubscriptionMode = typeof subscriptionId === \"number\" && !isNaN(subscriptionId);\n\n const invoicesQuery = isSubscriptionMode\n ? useSubscriptionInvoices(subscriptionId as number, { page: currentPage, limit: pageSize })\n : useInvoices({ page: currentPage, limit: pageSize, status: statusFilter === \"all\" ? undefined : statusFilter });\n\n const { data, isLoading, error } = invoicesQuery as {\n data?: { invoices: Invoice[]; pagination?: { totalItems: number; totalPages: number } };\n isLoading: boolean;\n error: unknown;\n };\n\n const invoices = data?.invoices || [];\n const pagination = data?.pagination;\n\n const filtered = useMemo(() => {\n if (!searchTerm) return invoices;\n const term = searchTerm.toLowerCase();\n return invoices.filter(inv => {\n return (\n inv.number.toLowerCase().includes(term) ||\n (inv.description ? inv.description.toLowerCase().includes(term) : false)\n );\n });\n }, [invoices, searchTerm]);\n\n const statusFilterOptions = [\n { value: \"all\", label: \"All Status\" },\n { value: \"Unpaid\", label: \"Unpaid\" },\n { value: \"Paid\", label: \"Paid\" },\n { value: \"Overdue\", label: \"Overdue\" },\n { value: \"Cancelled\", label: \"Cancelled\" },\n ];\n\n if (isLoading) {\n return (\n \n
\n
\n \n

Loading invoices...

\n
\n
\n
\n );\n }\n\n if (error) {\n return (\n \n \n \n );\n }\n\n return (\n {\n setStatusFilter(value);\n setCurrentPage(1);\n }}\n filterOptions={isSubscriptionMode ? undefined : statusFilterOptions}\n filterLabel={isSubscriptionMode ? undefined : \"Filter by status\"}\n />\n }\n headerClassName=\"bg-gray-50 rounded md:p-2 p-1 mb-1\"\n footer={\n pagination && filtered.length > 0 ? (\n \n ) : undefined\n }\n className={className}\n >\n \n \n );\n}\n\nexport type { InvoicesListProps };\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/components/InvoiceList/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx","messages":[{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":2,"message":"Unsafe spread of an `any` value in an array.","line":157,"column":13,"nodeType":"SpreadElement","messageId":"unsafeArraySpread","endLine":157,"endColumn":24}],"suppressedMessages":[],"errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\"use client\";\n\nimport { useMemo } from \"react\";\nimport { useRouter } from \"next/navigation\";\nimport Link from \"next/link\";\nimport { format } from \"date-fns\";\nimport {\n DocumentTextIcon,\n ArrowTopRightOnSquareIcon,\n CheckCircleIcon,\n ExclamationTriangleIcon,\n ClockIcon,\n} from \"@heroicons/react/24/outline\";\nimport { DataTable } from \"@/components/common/DataTable\";\nimport { BillingStatusBadge } from \"../BillingStatusBadge\";\nimport type { Invoice } from \"@customer-portal/shared\";\nimport { formatCurrency, getCurrencyLocale } from \"@/utils/currency\";\nimport { cn } from \"@/lib/utils\";\n\ninterface InvoiceTableProps {\n invoices: Invoice[];\n loading?: boolean;\n onInvoiceClick?: (invoice: Invoice) => void;\n showActions?: boolean;\n compact?: boolean;\n className?: string;\n}\n\nconst getStatusIcon = (status: string) => {\n switch (status.toLowerCase()) {\n case \"paid\":\n return ;\n case \"unpaid\":\n return ;\n case \"overdue\":\n return ;\n case \"cancelled\":\n case \"canceled\":\n return ;\n default:\n return ;\n }\n};\n\nexport function InvoiceTable({\n invoices,\n loading = false,\n onInvoiceClick,\n showActions = true,\n compact = false,\n className,\n}: InvoiceTableProps) {\n const router = useRouter();\n\n const handleInvoiceClick = (invoice: Invoice) => {\n if (onInvoiceClick) {\n onInvoiceClick(invoice);\n } else {\n router.push(`/billing/invoices/${invoice.id}`);\n }\n };\n\n const columns = useMemo(() => {\n const baseColumns = [\n {\n key: \"invoice\",\n header: \"Invoice\",\n render: (invoice: Invoice) => (\n
\n {getStatusIcon(invoice.status)}\n
\n
{invoice.number}
\n {!compact && invoice.description && (\n
{invoice.description}
\n )}\n
\n
\n ),\n },\n {\n key: \"status\",\n header: \"Status\",\n render: (invoice: Invoice) => ,\n },\n {\n key: \"amount\",\n header: \"Amount\",\n render: (invoice: Invoice) => (\n \n {formatCurrency(invoice.total, {\n currency: invoice.currency,\n currencySymbol: invoice.currencySymbol,\n locale: getCurrencyLocale(invoice.currency),\n })}\n \n ),\n },\n ];\n\n // Add date columns if not compact\n if (!compact) {\n baseColumns.push(\n {\n key: \"invoiceDate\",\n header: \"Invoice Date\",\n render: (invoice: Invoice) => (\n \n {invoice.issuedAt ? format(new Date(invoice.issuedAt), \"MMM d, yyyy\") : \"N/A\"}\n \n ),\n },\n {\n key: \"dueDate\",\n header: \"Due Date\",\n render: (invoice: Invoice) => (\n \n {invoice.dueDate ? format(new Date(invoice.dueDate), \"MMM d, yyyy\") : \"N/A\"}\n \n ),\n }\n );\n }\n\n // Add actions column if enabled\n if (showActions) {\n baseColumns.push({\n key: \"actions\",\n header: \"\",\n render: (invoice: Invoice) => (\n
\n e.stopPropagation()}\n >\n View\n \n \n
\n ),\n });\n }\n\n return baseColumns;\n }, [compact, showActions]);\n\n const emptyState = {\n icon: ,\n title: \"No invoices found\",\n description: \"No invoices have been generated yet.\",\n };\n\n if (loading) {\n return (\n
\n
\n {[...Array(5)].map((_, i) => (\n
\n ))}\n
\n
\n );\n }\n\n return (\n \n );\n}\n\nexport type { InvoiceTableProps };\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/components/InvoiceTable/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/components/PaymentMethodCard/PaymentMethodCard.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/components/PaymentMethodCard/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/components/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/containers/InvoiceDetail.tsx","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'ExclamationTriangleIcon' is defined but never used.","line":9,"column":27,"nodeType":null,"messageId":"unusedVar","endLine":9,"endColumn":50},{"ruleId":"prettier/prettier","severity":1,"message":"Replace `Ā·InvoiceHeader,Ā·InvoiceItems,Ā·InvoiceTotalsĀ·` with `āŽĀ·Ā·InvoiceHeader,āŽĀ·Ā·InvoiceItems,āŽĀ·Ā·InvoiceTotals,āŽ`","line":16,"column":9,"nodeType":null,"messageId":"replace","endLine":16,"endColumn":53,"fix":{"range":[754,798],"text":"\n InvoiceHeader,\n InvoiceItems,\n InvoiceTotals,\n"}},{"ruleId":"prettier/prettier","severity":1,"message":"Replace `Ā·icon={}Ā·title=\"Invoice\"Ā·description=\"InvoiceĀ·detailsĀ·andĀ·actions\"` with `āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·icon={}āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·title=\"Invoice\"āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·description=\"InvoiceĀ·detailsĀ·andĀ·actions\"āŽĀ·Ā·Ā·Ā·Ā·Ā·`","line":64,"column":18,"nodeType":null,"messageId":"replace","endLine":64,"endColumn":102,"fix":{"range":[2531,2615],"text":"\n icon={}\n title=\"Invoice\"\n description=\"Invoice details and actions\"\n "}},{"ruleId":"prettier/prettier","severity":1,"message":"Replace `Ā·icon={}Ā·title=\"Invoice\"Ā·description=\"InvoiceĀ·detailsĀ·andĀ·actions\"` with `āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·icon={}āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·title=\"Invoice\"āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·description=\"InvoiceĀ·detailsĀ·andĀ·actions\"āŽĀ·Ā·Ā·Ā·Ā·Ā·`","line":77,"column":18,"nodeType":null,"messageId":"replace","endLine":77,"endColumn":102,"fix":{"range":[2957,3041],"text":"\n icon={}\n title=\"Invoice\"\n description=\"Invoice details and actions\"\n "}},{"ruleId":"prettier/prettier","severity":1,"message":"Replace `Ā·href=\"/billing/invoices\"Ā·className=\"inline-flexĀ·items-centerĀ·text-gray-600Ā·hover:text-gray-900Ā·transition-colors\"` with `āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·href=\"/billing/invoices\"āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·className=\"inline-flexĀ·items-centerĀ·text-gray-600Ā·hover:text-gray-900Ā·transition-colors\"āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·`","line":96,"column":16,"nodeType":null,"messageId":"replace","endLine":96,"endColumn":130,"fix":{"range":[3573,3687],"text":"\n href=\"/billing/invoices\"\n className=\"inline-flex items-center text-gray-600 hover:text-gray-900 transition-colors\"\n "}},{"ruleId":"prettier/prettier","severity":1,"message":"Replace `Ā·subtotal={invoice.subtotal}Ā·tax={invoice.tax}Ā·total={invoice.total}Ā·currency={invoice.currency}` with `āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·subtotal={invoice.subtotal}āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·tax={invoice.tax}āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·total={invoice.total}āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·currency={invoice.currency}āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·`","line":124,"column":27,"nodeType":null,"messageId":"replace","endLine":124,"endColumn":123,"fix":{"range":[4853,4949],"text":"\n subtotal={invoice.subtotal}\n tax={invoice.tax}\n total={invoice.total}\n currency={invoice.currency}\n "}}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":6,"fixableErrorCount":0,"fixableWarningCount":5,"source":"\"use client\";\n\nimport { useState } from \"react\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\nimport { SubCard } from \"@/components/ui/sub-card\";\nimport { LoadingSpinner } from \"@/components/ui/loading-spinner\";\nimport { ErrorState } from \"@/components/ui/error-state\";\nimport { CheckCircleIcon, ExclamationTriangleIcon } from \"@heroicons/react/24/outline\";\nimport { PageLayout } from \"@/components/layout/PageLayout\";\nimport { CreditCardIcon } from \"@heroicons/react/24/outline\";\nimport { logger } from \"@/lib/logger\";\nimport { AuthService } from \"@/features/auth/services/auth.service\";\nimport { openSsoLink } from \"@/lib/utils/sso\";\nimport { useInvoice, useCreateInvoiceSsoLink } from \"@/features/billing/hooks\";\nimport { InvoiceHeader, InvoiceItems, InvoiceTotals } from \"@/features/billing/components/InvoiceDetail\";\n\nexport function InvoiceDetailContainer() {\n const params = useParams();\n const [loadingDownload, setLoadingDownload] = useState(false);\n const [loadingPayment, setLoadingPayment] = useState(false);\n const [loadingPaymentMethods, setLoadingPaymentMethods] = useState(false);\n\n const invoiceId = parseInt(params.id as string);\n const createSsoLinkMutation = useCreateInvoiceSsoLink();\n const { data: invoice, isLoading, error } = useInvoice(invoiceId);\n\n const handleCreateSsoLink = (target: \"view\" | \"download\" | \"pay\" = \"view\") => {\n void (async () => {\n if (!invoice) return;\n if (target === \"download\") setLoadingDownload(true);\n else setLoadingPayment(true);\n try {\n const ssoLink = await createSsoLinkMutation.mutateAsync({ invoiceId: invoice.id, target });\n if (target === \"download\") openSsoLink(ssoLink.url, { newTab: false });\n else openSsoLink(ssoLink.url, { newTab: true });\n } catch (err) {\n logger.error(err, \"Failed to create SSO link\");\n } finally {\n if (target === \"download\") setLoadingDownload(false);\n else setLoadingPayment(false);\n }\n })();\n };\n\n const handleManagePaymentMethods = () => {\n void (async () => {\n setLoadingPaymentMethods(true);\n try {\n const sso = await AuthService.getInstance().createSsoLink(\n \"index.php?rp=/account/paymentmethods\"\n );\n openSsoLink(sso.url, { newTab: true });\n } catch (err) {\n logger.error(err, \"Failed to create payment methods SSO link\");\n } finally {\n setLoadingPaymentMethods(false);\n }\n })();\n };\n\n if (isLoading) {\n return (\n } title=\"Invoice\" description=\"Invoice details and actions\">\n
\n
\n \n

Loading invoice...

\n
\n
\n
\n );\n }\n\n if (error || !invoice) {\n return (\n } title=\"Invoice\" description=\"Invoice details and actions\">\n \n
\n \n ← Back to invoices\n \n
\n
\n );\n }\n\n return (\n
\n
\n
\n \n ← Back to Invoices\n \n
\n\n
\n handleCreateSsoLink(\"download\")}\n onPay={() => handleCreateSsoLink(\"pay\")}\n onManagePaymentMethods={handleManagePaymentMethods}\n />\n\n {invoice.status === \"Paid\" && (\n
\n \n
\n Invoice Paid\n • Paid on {invoice.paidDate || invoice.issuedAt}\n
\n
\n )}\n\n
\n \n \n\n {(invoice.status === \"Unpaid\" || invoice.status === \"Overdue\") && (\n \n
\n \n {loadingPaymentMethods ? (\n
\n ) : (\n šŸ’³\n )}\n Payment Methods\n \n handleCreateSsoLink(\"pay\")}\n disabled={loadingPayment}\n className={`inline-flex items-center justify-center px-5 py-2.5 border border-transparent text-sm font-semibold rounded-lg text-white transition-all duration-200 shadow-md whitespace-nowrap ${\n invoice.status === \"Overdue\"\n ? \"bg-red-600 hover:bg-red-700 ring-2 ring-red-200 hover:ring-red-300\"\n : \"bg-blue-600 hover:bg-blue-700 hover:shadow-lg\"\n }`}\n >\n {loadingPayment ? (\n
\n ) : (\n ↗\n )}\n {invoice.status === \"Overdue\" ? \"Pay Overdue\" : \"Pay Now\"}\n \n
\n
\n )}\n
\n
\n
\n
\n );\n}\n\nexport default InvoiceDetailContainer;\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/containers/InvoicesList.tsx","messages":[{"ruleId":"prettier/prettier","severity":1,"message":"Replace `Ā·icon={}Ā·title=\"Invoices\"Ā·description=\"ManageĀ·andĀ·viewĀ·yourĀ·billingĀ·invoices\"` with `āŽĀ·Ā·Ā·Ā·Ā·Ā·icon={}āŽĀ·Ā·Ā·Ā·Ā·Ā·title=\"Invoices\"āŽĀ·Ā·Ā·Ā·Ā·Ā·description=\"ManageĀ·andĀ·viewĀ·yourĀ·billingĀ·invoices\"āŽĀ·Ā·Ā·Ā·`","line":9,"column":16,"nodeType":null,"messageId":"replace","endLine":9,"endColumn":111,"fix":{"range":[293,388],"text":"\n icon={}\n title=\"Invoices\"\n description=\"Manage and view your billing invoices\"\n "}}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":1,"source":"\"use client\";\n\nimport { PageLayout } from \"@/components/layout/PageLayout\";\nimport { CreditCardIcon } from \"@heroicons/react/24/outline\";\nimport { InvoicesList } from \"@/features/billing/components/InvoiceList/InvoiceList\";\n\nexport function InvoicesListContainer() {\n return (\n } title=\"Invoices\" description=\"Manage and view your billing invoices\">\n \n \n );\n}\n\nexport default InvoicesListContainer;\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/containers/PaymentMethods.tsx","messages":[{"ruleId":"@typescript-eslint/require-await","severity":2,"message":"Async method 'refetch' has no 'await' expression.","line":30,"column":5,"nodeType":"ArrowFunctionExpression","messageId":"missingAwait","endLine":30,"endColumn":20,"suggestions":[{"messageId":"removeAsync","fix":{"range":[1236,1242],"text":""},"desc":"Remove 'async'."}]},{"ruleId":"react/no-unescaped-entities","severity":2,"message":"`'` can be escaped with `'`, `‘`, `'`, `’`.","line":149,"column":28,"nodeType":"JSXText","messageId":"unescapedEntityAlts","suggestions":[{"messageId":"replaceWithAlt","data":{"alt":"'"},"fix":{"range":[5712,5823],"text":"\n You haven't added any payment methods yet. Add one to make payments easier.\n "},"desc":"Replace with `'`."},{"messageId":"replaceWithAlt","data":{"alt":"‘"},"fix":{"range":[5712,5823],"text":"\n You haven‘t added any payment methods yet. Add one to make payments easier.\n "},"desc":"Replace with `‘`."},{"messageId":"replaceWithAlt","data":{"alt":"'"},"fix":{"range":[5712,5823],"text":"\n You haven't added any payment methods yet. Add one to make payments easier.\n "},"desc":"Replace with `'`."},{"messageId":"replaceWithAlt","data":{"alt":"’"},"fix":{"range":[5712,5823],"text":"\n You haven’t added any payment methods yet. Add one to make payments easier.\n "},"desc":"Replace with `’`."}]}],"suppressedMessages":[],"errorCount":2,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\"use client\";\n\nimport { useState, useEffect } from \"react\";\nimport { PageLayout } from \"@/components/layout/PageLayout\";\nimport { SubCard } from \"@/components/ui/sub-card\";\nimport { LoadingSpinner } from \"@/components/ui/loading-spinner\";\nimport { ErrorState } from \"@/components/ui/error-state\";\nimport { useSession } from \"@/features/auth/hooks\";\nimport { ApiError } from \"@/lib/api/client\";\nimport { AuthService } from \"@/features/auth/services/auth.service\";\nimport { openSsoLink } from \"@/lib/utils/sso\";\nimport { usePaymentRefresh } from \"@/features/billing/hooks/usePaymentRefresh\";\nimport { PaymentMethodCard, usePaymentMethods } from \"@/features/billing\";\nimport { CreditCardIcon, PlusIcon } from \"@heroicons/react/24/outline\";\nimport { InlineToast } from \"@/components/ui/inline-toast\";\nimport { logger } from \"@/lib/logger\";\n\nexport function PaymentMethodsContainer() {\n const [isLoading, setIsLoading] = useState(false);\n const [error, setError] = useState(null);\n const { isAuthenticated } = useSession();\n\n const {\n data: paymentMethodsData,\n isLoading: isLoadingPaymentMethods,\n error: paymentMethodsError,\n } = usePaymentMethods();\n\n const paymentRefresh = usePaymentRefresh({\n refetch: async () => ({ data: paymentMethodsData }),\n hasMethods: (data?: { totalCount?: number }) => !!data && (data.totalCount || 0) > 0,\n attachFocusListeners: true,\n });\n\n const openPaymentMethods = async () => {\n try {\n setIsLoading(true);\n setError(null);\n if (!isAuthenticated) {\n setError(\"Please log in to access payment methods.\");\n setIsLoading(false);\n return;\n }\n const sso = await AuthService.getInstance().createSsoLink(\n \"index.php?rp=/account/paymentmethods\"\n );\n openSsoLink(sso.url, { newTab: true });\n setIsLoading(false);\n } catch (error) {\n logger.error(error, \"Failed to open payment methods\");\n if (error instanceof ApiError && error.status === 401)\n setError(\"Authentication failed. Please log in again.\");\n else setError(\"Unable to access payment methods. Please try again later.\");\n setIsLoading(false);\n }\n };\n\n useEffect(() => {\n // Placeholder hook for future logic when returning from WHMCS\n }, [isAuthenticated]);\n\n if (error || paymentMethodsError) {\n const errorMessage =\n error ||\n (paymentMethodsError instanceof Error\n ? paymentMethodsError.message\n : \"An unexpected error occurred\");\n return (\n }\n title=\"Payment Methods\"\n description=\"Manage your saved payment methods and billing information\"\n >\n \n \n );\n }\n\n return (\n }\n title=\"Payment Methods\"\n description=\"Manage your saved payment methods and billing information\"\n >\n \n
\n
\n {isLoadingPaymentMethods ? (\n \n
\n
\n \n

Loading payment methods...

\n
\n
\n
\n ) : paymentMethodsData && paymentMethodsData.paymentMethods.length > 0 ? (\n \n

Your Payment Methods

\n {\n void openPaymentMethods();\n }}\n disabled={isLoading}\n className=\"inline-flex items-center gap-2 px-3 py-2 text-sm bg-blue-600 text-white font-medium rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors\"\n >\n \n Add New\n \n
\n }\n >\n
\n {paymentMethodsData.paymentMethods.map(paymentMethod => (\n {\n void openPaymentMethods();\n }}\n onDelete={() => {\n void openPaymentMethods();\n }}\n onSetDefault={() => {\n void openPaymentMethods();\n }}\n />\n ))}\n
\n \n ) : (\n \n
\n
\n \n
\n

No Payment Methods

\n

\n You haven't added any payment methods yet. Add one to make payments easier.\n

\n {\n void openPaymentMethods();\n }}\n disabled={isLoading}\n className=\"inline-flex items-center gap-2 px-6 py-3 bg-blue-600 text-white font-medium rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors\"\n >\n {isLoading ? (\n <>\n
\n Opening...\n \n ) : (\n <>\n \n Add Payment Method\n \n )}\n \n

Opens in a new tab for security

\n
\n
\n )}\n
\n\n
\n
\n
\n
\n \n
\n
\n

Secure & Encrypted

\n

\n All payment information is securely encrypted and protected with industry-standard\n security.\n

\n
\n
\n
\n\n
\n

Supported Payment Methods

\n
    \n
  • • Credit Cards (Visa, MasterCard, American Express)
  • \n
  • • Debit Cards
  • \n
\n
\n
\n \n \n );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/containers/index.ts","messages":[{"ruleId":"prettier/prettier","severity":1,"message":"Delete `āŽ`","line":4,"column":1,"nodeType":null,"messageId":"delete","endLine":5,"endColumn":1,"fix":{"range":[99,100],"text":""}}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":1,"source":"export * from \"./PaymentMethods\";\nexport * from \"./InvoicesList\";\nexport * from \"./InvoiceDetail\";\n\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/hooks/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/hooks/useBilling.ts","messages":[{"ruleId":"@typescript-eslint/unbound-method","severity":2,"message":"Avoid referencing unbound methods which may cause unintentional scoping of `this`.\nIf your function does not access `this`, you can annotate it with `this: void`, or consider using an arrow function instead.","line":89,"column":17,"nodeType":"MemberExpression","messageId":"unboundWithoutThisAnnotation","endLine":89,"endColumn":52},{"ruleId":"@typescript-eslint/unbound-method","severity":2,"message":"Avoid referencing unbound methods which may cause unintentional scoping of `this`.\nIf your function does not access `this`, you can annotate it with `this: void`, or consider using an arrow function instead.","line":98,"column":17,"nodeType":"MemberExpression","messageId":"unboundWithoutThisAnnotation","endLine":98,"endColumn":56},{"ruleId":"@typescript-eslint/unbound-method","severity":2,"message":"Avoid referencing unbound methods which may cause unintentional scoping of `this`.\nIf your function does not access `this`, you can annotate it with `this: void`, or consider using an arrow function instead.","line":109,"column":17,"nodeType":"MemberExpression","messageId":"unboundWithoutThisAnnotation","endLine":109,"endColumn":55},{"ruleId":"@typescript-eslint/no-floating-promises","severity":2,"message":"Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator.","line":112,"column":7,"nodeType":"ExpressionStatement","messageId":"floatingVoid","endLine":112,"endColumn":71,"suggestions":[{"messageId":"floatingFixVoid","fix":{"range":[3089,3089],"text":"void "},"desc":"Add void operator to ignore."},{"messageId":"floatingFixAwait","fix":{"range":[3089,3089],"text":"await "},"desc":"Add await operator."}]},{"ruleId":"@typescript-eslint/unbound-method","severity":2,"message":"Avoid referencing unbound methods which may cause unintentional scoping of `this`.\nIf your function does not access `this`, you can annotate it with `this: void`, or consider using an arrow function instead.","line":124,"column":17,"nodeType":"MemberExpression","messageId":"unboundWithoutThisAnnotation","endLine":124,"endColumn":51},{"ruleId":"@typescript-eslint/no-floating-promises","severity":2,"message":"Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator.","line":127,"column":7,"nodeType":"ExpressionStatement","messageId":"floatingVoid","endLine":127,"endColumn":71,"suggestions":[{"messageId":"floatingFixVoid","fix":{"range":[3467,3467],"text":"void "},"desc":"Add void operator to ignore."},{"messageId":"floatingFixAwait","fix":{"range":[3467,3467],"text":"await "},"desc":"Add await operator."}]}],"suppressedMessages":[],"errorCount":6,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"import { useMemo } from \"react\";\nimport { useQuery, useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { useAuthStore } from \"@/lib/auth/store\";\nimport { BillingService } from \"../services\";\nimport type { InvoiceQueryParams } from \"../services\";\n\n/**\n * Hook for fetching invoices with pagination and filtering\n */\nexport function useInvoices(params: InvoiceQueryParams = {}) {\n const { isAuthenticated, token } = useAuthStore();\n\n return useQuery({\n queryKey: [\"invoices\", params],\n queryFn: () => BillingService.getInvoices(params),\n enabled: isAuthenticated && !!token,\n staleTime: 60 * 1000, // 1 minute\n gcTime: 5 * 60 * 1000, // 5 minutes\n });\n}\n\n/**\n * Hook for fetching a single invoice\n */\nexport function useInvoice(invoiceId: number) {\n const { isAuthenticated, token } = useAuthStore();\n\n return useQuery({\n queryKey: [\"invoice\", invoiceId],\n queryFn: () => BillingService.getInvoice(invoiceId),\n enabled: isAuthenticated && !!token && !!invoiceId,\n staleTime: 60 * 1000, // 1 minute\n gcTime: 5 * 60 * 1000, // 5 minutes\n });\n}\n\n/**\n * Hook for fetching invoice subscriptions\n */\nexport function useInvoiceSubscriptions(invoiceId: number) {\n const { isAuthenticated, token } = useAuthStore();\n\n return useQuery({\n queryKey: [\"invoice-subscriptions\", invoiceId],\n queryFn: () => BillingService.getInvoiceSubscriptions(invoiceId),\n enabled: isAuthenticated && !!token && !!invoiceId,\n staleTime: 60 * 1000, // 1 minute\n gcTime: 5 * 60 * 1000, // 5 minutes\n });\n}\n\n/**\n * Hook for fetching payment methods\n */\nexport function usePaymentMethods() {\n const { isAuthenticated, token } = useAuthStore();\n\n return useQuery({\n queryKey: [\"paymentMethods\"],\n queryFn: () => BillingService.getPaymentMethods(),\n enabled: isAuthenticated && !!token,\n staleTime: 1 * 60 * 1000, // 1 minute\n gcTime: 5 * 60 * 1000, // 5 minutes\n retry: 3,\n retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),\n });\n}\n\n/**\n * Hook for fetching payment gateways\n */\nexport function usePaymentGateways() {\n const { isAuthenticated, token } = useAuthStore();\n\n return useQuery({\n queryKey: [\"paymentGateways\"],\n queryFn: () => BillingService.getPaymentGateways(),\n enabled: isAuthenticated && !!token,\n staleTime: 60 * 60 * 1000, // 1 hour\n gcTime: 2 * 60 * 60 * 1000, // 2 hours\n });\n}\n\n/**\n * Mutation hook for creating invoice SSO links\n */\nexport function useCreateInvoiceSsoLink() {\n return useMutation({\n mutationFn: BillingService.createInvoiceSsoLink,\n });\n}\n\n/**\n * Mutation hook for creating invoice payment links\n */\nexport function useCreateInvoicePaymentLink() {\n return useMutation({\n mutationFn: BillingService.createInvoicePaymentLink,\n });\n}\n\n/**\n * Mutation hook for setting default payment method\n */\nexport function useSetDefaultPaymentMethod() {\n const queryClient = useQueryClient();\n\n return useMutation({\n mutationFn: BillingService.setDefaultPaymentMethod,\n onSuccess: () => {\n // Invalidate payment methods to refresh the list\n queryClient.invalidateQueries({ queryKey: [\"paymentMethods\"] });\n },\n });\n}\n\n/**\n * Mutation hook for deleting payment method\n */\nexport function useDeletePaymentMethod() {\n const queryClient = useQueryClient();\n\n return useMutation({\n mutationFn: BillingService.deletePaymentMethod,\n onSuccess: () => {\n // Invalidate payment methods to refresh the list\n queryClient.invalidateQueries({ queryKey: [\"paymentMethods\"] });\n },\n });\n}\n\n/**\n * Hook for generating billing summary from invoice data\n */\nexport function useBillingSummary(params: InvoiceQueryParams = {}) {\n const { data: invoiceData, ...queryResult } = useInvoices({\n ...params,\n limit: 100, // Get more invoices for summary calculation\n });\n\n const summary = useMemo(() => {\n if (!invoiceData?.invoices) {\n return null;\n }\n\n const invoices = invoiceData.invoices;\n const currency = invoices[0]?.currency || \"USD\";\n const currencySymbol = invoices[0]?.currencySymbol;\n\n const totals = invoices.reduce(\n (acc, invoice) => {\n switch (invoice.status.toLowerCase()) {\n case \"unpaid\":\n acc.totalOutstanding += invoice.total;\n acc.invoiceCount.unpaid += 1;\n break;\n case \"overdue\":\n acc.totalOverdue += invoice.total;\n acc.invoiceCount.overdue += 1;\n break;\n case \"paid\":\n acc.totalPaid += invoice.total;\n acc.invoiceCount.paid += 1;\n break;\n }\n acc.invoiceCount.total += 1;\n return acc;\n },\n {\n totalOutstanding: 0,\n totalOverdue: 0,\n totalPaid: 0,\n currency,\n currencySymbol,\n invoiceCount: {\n total: 0,\n unpaid: 0,\n overdue: 0,\n paid: 0,\n },\n }\n );\n\n return totals;\n }, [invoiceData]);\n\n return {\n ...queryResult,\n data: summary,\n };\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/hooks/usePaymentRefresh.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/services/billing.service.ts","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'PaymentGateway' is defined but never used.","line":8,"column":3,"nodeType":null,"messageId":"unusedVar","endLine":8,"endColumn":17}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"import { apiClient } from \"@/lib/api/client\";\nimport type {\n Invoice,\n InvoiceList,\n InvoiceSsoLink,\n PaymentMethod,\n PaymentMethodList,\n PaymentGateway,\n PaymentGatewayList,\n InvoicePaymentLink,\n Subscription,\n} from \"@customer-portal/shared\";\n\nexport interface InvoiceQueryParams {\n page?: number;\n limit?: number;\n status?: string;\n}\n\nexport interface CreateInvoiceSsoLinkParams {\n invoiceId: number;\n target?: \"view\" | \"download\" | \"pay\";\n}\n\nexport interface CreateInvoicePaymentLinkParams {\n invoiceId: number;\n paymentMethodId?: number;\n gatewayName?: string;\n}\n\n/**\n * Centralized billing service for all invoice and payment operations\n */\nexport class BillingService {\n /**\n * Fetch paginated list of invoices\n */\n static async getInvoices(params: InvoiceQueryParams = {}): Promise {\n const { page = 1, limit = 10, status } = params;\n\n const searchParams = new URLSearchParams({\n page: page.toString(),\n limit: limit.toString(),\n ...(status && { status }),\n });\n\n const res = await apiClient.get(`/invoices?${searchParams}`);\n return res.data as InvoiceList;\n }\n\n /**\n * Fetch a single invoice by ID\n */\n static async getInvoice(invoiceId: number): Promise {\n const res = await apiClient.get(`/invoices/${invoiceId}`);\n return res.data as Invoice;\n }\n\n /**\n * Fetch subscriptions associated with an invoice\n */\n static async getInvoiceSubscriptions(invoiceId: number): Promise {\n const res = await apiClient.get(`/invoices/${invoiceId}/subscriptions`);\n return res.data as Subscription[];\n }\n\n /**\n * Create SSO link for invoice viewing/downloading/payment\n */\n static async createInvoiceSsoLink(params: CreateInvoiceSsoLinkParams): Promise {\n const { invoiceId, target = \"view\" } = params;\n\n const searchParams = new URLSearchParams();\n if (target !== \"view\") {\n searchParams.append(\"target\", target);\n }\n\n const url = `/invoices/${invoiceId}/sso-link${searchParams.toString() ? `?${searchParams.toString()}` : \"\"}`;\n const res = await apiClient.post(url);\n return res.data as InvoiceSsoLink;\n }\n\n /**\n * Create payment link for invoice\n */\n static async createInvoicePaymentLink(\n params: CreateInvoicePaymentLinkParams\n ): Promise {\n const { invoiceId, paymentMethodId, gatewayName } = params;\n\n const searchParams = new URLSearchParams();\n if (paymentMethodId) {\n searchParams.append(\"paymentMethodId\", paymentMethodId.toString());\n }\n if (gatewayName) {\n searchParams.append(\"gatewayName\", gatewayName);\n }\n\n const url = `/invoices/${invoiceId}/payment-link${searchParams.toString() ? `?${searchParams.toString()}` : \"\"}`;\n const res = await apiClient.post(url);\n return res.data as InvoicePaymentLink;\n }\n\n /**\n * Fetch user's payment methods\n */\n static async getPaymentMethods(): Promise {\n const res = await apiClient.get(\"/invoices/payment-methods\");\n return res.data as PaymentMethodList;\n }\n\n /**\n * Fetch available payment gateways\n */\n static async getPaymentGateways(): Promise {\n const res = await apiClient.get(\"/invoices/payment-gateways\");\n return res.data as PaymentGatewayList;\n }\n\n /**\n * Set a payment method as default\n */\n static async setDefaultPaymentMethod(paymentMethodId: number): Promise {\n const res = await apiClient.patch(\n `/invoices/payment-methods/${paymentMethodId}/default`\n );\n return res.data as PaymentMethod;\n }\n\n /**\n * Delete a payment method\n */\n static async deletePaymentMethod(paymentMethodId: number): Promise {\n await apiClient.delete(`/invoices/payment-methods/${paymentMethodId}`);\n return;\n }\n}\n\nexport default BillingService;\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/services/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/types/billing.types.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/types/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/utils/billing.utils.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/utils/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/components/base/AddonGroup.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/components/base/AddressForm.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/components/base/ConfigurationStep.tsx","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'Link' is defined but never used.","line":4,"column":8,"nodeType":null,"messageId":"unusedVar","endLine":4,"endColumn":12}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\"use client\";\n\nimport { ReactNode } from \"react\";\nimport Link from \"next/link\";\nimport {\n CheckCircleIcon,\n ExclamationTriangleIcon,\n InformationCircleIcon,\n} from \"@heroicons/react/24/outline\";\nimport { AnimatedCard } from \"@/components/ui/animated-card\";\nimport { Button } from \"@/components/ui/button\";\n\nexport interface StepValidation {\n isValid: boolean;\n errors?: string[];\n warnings?: string[];\n}\n\nexport interface ConfigurationStepProps {\n // Step identification\n stepNumber: number;\n title: string;\n description?: string;\n\n // Step state\n isActive?: boolean;\n isCompleted?: boolean;\n isDisabled?: boolean;\n validation?: StepValidation;\n\n // Content\n children: ReactNode;\n helpText?: string;\n infoText?: string;\n\n // Actions\n onNext?: () => void;\n onPrevious?: () => void;\n onSkip?: () => void;\n nextLabel?: string;\n previousLabel?: string;\n skipLabel?: string;\n showActions?: boolean;\n\n // Styling\n variant?: \"default\" | \"highlighted\" | \"compact\";\n showStepIndicator?: boolean;\n\n // State\n loading?: boolean;\n disabled?: boolean;\n\n // Custom content\n headerContent?: ReactNode;\n footerContent?: ReactNode;\n}\n\nexport function ConfigurationStep({\n stepNumber,\n title,\n description,\n isActive = true,\n isCompleted = false,\n isDisabled = false,\n validation,\n children,\n helpText,\n infoText,\n onNext,\n onPrevious,\n onSkip,\n nextLabel = \"Continue\",\n previousLabel = \"Back\",\n skipLabel = \"Skip\",\n showActions = true,\n variant = \"default\",\n showStepIndicator = true,\n loading = false,\n disabled = false,\n headerContent,\n footerContent,\n}: ConfigurationStepProps) {\n const getStepIndicatorClasses = () => {\n if (isCompleted) {\n return \"bg-green-500 border-green-500 text-white\";\n }\n if (isActive && !isDisabled) {\n return \"border-blue-500 text-blue-500 bg-blue-50\";\n }\n if (isDisabled) {\n return \"border-gray-300 text-gray-400 bg-gray-50\";\n }\n return \"border-gray-300 text-gray-500 bg-white\";\n };\n\n const getCardVariant = () => {\n if (variant === \"highlighted\") return \"highlighted\";\n if (isDisabled) return \"static\";\n return \"default\";\n };\n\n const hasErrors = validation?.errors && validation.errors.length > 0;\n const hasWarnings = validation?.warnings && validation.warnings.length > 0;\n const isValid = validation?.isValid !== false;\n\n return (\n \n {/* Step Header */}\n
\n
\n {/* Step Indicator */}\n {showStepIndicator && (\n \n {isCompleted ? : {stepNumber}}\n
\n )}\n\n {/* Step Title and Description */}\n
\n \n {title}\n \n {description && (\n \n {description}\n

\n )}\n\n {/* Validation Status */}\n {validation && (\n
\n {hasErrors && (\n
\n \n
\n {validation.errors!.map((error, index) => (\n
{error}
\n ))}\n
\n
\n )}\n\n {hasWarnings && !hasErrors && (\n
\n \n
\n {validation.warnings!.map((warning, index) => (\n
{warning}
\n ))}\n
\n
\n )}\n\n {isValid && !hasWarnings && isCompleted && (\n
\n \n Configuration complete\n
\n )}\n
\n )}\n
\n
\n\n {headerContent &&
{headerContent}
}\n \n\n {/* Step Content */}\n {!isDisabled &&
{children}
}\n\n {/* Help Text */}\n {helpText && !isDisabled && (\n
\n
\n \n

{helpText}

\n
\n
\n )}\n\n {/* Info Text */}\n {infoText && !isDisabled && (\n
\n

{infoText}

\n
\n )}\n\n {/* Actions */}\n {showActions && !isDisabled && (\n
\n
\n {onPrevious && (\n \n {previousLabel}\n \n )}\n\n {onSkip && (\n \n {skipLabel}\n \n )}\n
\n\n {onNext && (\n \n {loading ? (\n \n \n \n \n \n Processing...\n \n ) : (\n nextLabel\n )}\n \n )}\n
\n )}\n\n {/* Footer Content */}\n {footerContent &&
{footerContent}
}\n
\n );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'CurrencyYenIcon' is defined but never used.","line":7,"column":3,"nodeType":null,"messageId":"unusedVar","endLine":7,"endColumn":18},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'serviceItems' is assigned a value but never used.","line":129,"column":9,"nodeType":null,"messageId":"unusedVar","endLine":129,"endColumn":21},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'addonItems' is assigned a value but never used.","line":130,"column":9,"nodeType":null,"messageId":"unusedVar","endLine":130,"endColumn":19},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'installationItems' is assigned a value but never used.","line":131,"column":9,"nodeType":null,"messageId":"unusedVar","endLine":131,"endColumn":26},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'activationItems' is assigned a value but never used.","line":132,"column":9,"nodeType":null,"messageId":"unusedVar","endLine":132,"endColumn":24}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":5,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\"use client\";\n\nimport { ReactNode } from \"react\";\nimport {\n ArrowLeftIcon,\n ArrowRightIcon,\n CurrencyYenIcon,\n InformationCircleIcon,\n} from \"@heroicons/react/24/outline\";\nimport { AnimatedCard } from \"@/components/ui\";\nimport { Button } from \"@/components/ui/button\";\n\nexport interface OrderItem {\n id: string;\n name: string;\n sku: string;\n price?: number;\n billingCycle?: \"Monthly\" | \"Onetime\" | \"Annual\";\n type: \"service\" | \"installation\" | \"addon\" | \"activation\";\n description?: string;\n isAutoAdded?: boolean;\n}\n\nexport interface OrderConfiguration {\n label: string;\n value: string;\n important?: boolean;\n}\n\nexport interface OrderTotals {\n monthlyTotal: number;\n oneTimeTotal: number;\n annualTotal?: number;\n discountAmount?: number;\n taxAmount?: number;\n}\n\nexport interface EnhancedOrderSummaryProps {\n // Core order data\n orderItems: OrderItem[];\n totals: OrderTotals;\n\n // Plan information\n planName: string;\n planTier?: string;\n planDescription?: string;\n\n // Configuration details\n configurations?: OrderConfiguration[];\n\n // Additional information\n infoLines?: string[];\n disclaimers?: string[];\n\n // Pricing breakdown control\n showDetailedBreakdown?: boolean;\n showTaxes?: boolean;\n showDiscounts?: boolean;\n\n // Actions\n onContinue?: () => void;\n onBack?: () => void;\n backUrl?: string;\n backLabel?: string;\n continueLabel?: string;\n showActions?: boolean;\n\n // State\n disabled?: boolean;\n loading?: boolean;\n\n // Styling\n variant?: \"simple\" | \"detailed\" | \"checkout\";\n size?: \"compact\" | \"standard\" | \"large\";\n\n // Custom content\n children?: ReactNode;\n headerContent?: ReactNode;\n footerContent?: ReactNode;\n}\n\nexport function EnhancedOrderSummary({\n orderItems,\n totals,\n planName,\n planTier,\n planDescription,\n configurations = [],\n infoLines = [],\n disclaimers = [],\n showDetailedBreakdown = true,\n showTaxes = false,\n showDiscounts = false,\n onContinue,\n onBack,\n backUrl,\n backLabel = \"Back\",\n continueLabel = \"Continue\",\n showActions = true,\n disabled = false,\n loading = false,\n variant = \"detailed\",\n size = \"standard\",\n children,\n headerContent,\n footerContent,\n}: EnhancedOrderSummaryProps) {\n const sizeClasses = {\n compact: \"p-4\",\n standard: \"p-6\",\n large: \"p-8\",\n };\n\n const getVariantClasses = () => {\n switch (variant) {\n case \"checkout\":\n return \"bg-gradient-to-br from-gray-50 to-blue-50 border-2 border-blue-200 shadow-lg\";\n case \"detailed\":\n return \"bg-white border border-gray-200 shadow-md\";\n default:\n return \"bg-white border border-gray-200\";\n }\n };\n\n const formatPrice = (price: number) => price.toLocaleString();\n\n const monthlyItems = orderItems.filter(item => item.billingCycle === \"Monthly\");\n const oneTimeItems = orderItems.filter(item => item.billingCycle === \"Onetime\");\n const serviceItems = orderItems.filter(item => item.type === \"service\");\n const addonItems = orderItems.filter(item => item.type === \"addon\");\n const installationItems = orderItems.filter(item => item.type === \"installation\");\n const activationItems = orderItems.filter(item => item.type === \"activation\");\n\n return (\n \n {/* Header */}\n
\n
\n

Order Summary

\n {variant === \"checkout\" && (\n
\n
\n Ā„{formatPrice(totals.monthlyTotal)}/mo\n
\n {totals.oneTimeTotal > 0 && (\n
\n + Ā„{formatPrice(totals.oneTimeTotal)} one-time\n
\n )}\n
\n )}\n
\n\n {headerContent}\n
\n\n {/* Plan Information */}\n
\n
\n
\n

\n {planName}\n {planTier && ({planTier})}\n

\n {planDescription &&

{planDescription}

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

\n {line}\n

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

\n {disclaimer}\n

\n ))}\n
\n
\n )}\n\n {/* Custom Content */}\n {children &&
{children}
}\n\n {/* Actions */}\n {showActions && (\n
\n {backUrl ? (\n \n \n {backLabel}\n \n ) : onBack ? (\n \n \n {backLabel}\n \n ) : null}\n\n {onContinue && (\n \n )}\n
\n )}\n\n {/* Footer Content */}\n {footerContent &&
{footerContent}
}\n
\n );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/components/base/OrderSummary.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/components/base/PaymentForm.tsx","messages":[{"ruleId":"react-hooks/exhaustive-deps","severity":1,"message":"React Hook useEffect has a missing dependency: 'validatePayment'. Either include it or remove the dependency array.","line":101,"column":6,"nodeType":"ArrayExpression","endLine":101,"endColumn":61,"suggestions":[{"desc":"Update the dependencies array to be: [selectedMethod, existingMethods, requirePaymentMethod, validatePayment]","fix":{"range":[2347,2402],"text":"[selectedMethod, existingMethods, requirePaymentMethod, validatePayment]"}}]},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'brand' is defined but never used.","line":109,"column":29,"nodeType":null,"messageId":"unusedVar","endLine":109,"endColumn":34}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\"use client\";\n\nimport { LoadingSpinner } from \"@/components/ui/loading-spinner\";\n\nimport { useState, useEffect } from \"react\";\nimport {\n CreditCardIcon,\n ExclamationTriangleIcon,\n CheckCircleIcon,\n} from \"@heroicons/react/24/outline\";\n\nexport interface PaymentMethod {\n id: string;\n type: \"card\" | \"bank\" | \"paypal\";\n last4?: string;\n brand?: string;\n expiryMonth?: number;\n expiryYear?: number;\n isDefault?: boolean;\n name?: string;\n}\n\nexport interface PaymentFormProps {\n // Payment methods\n existingMethods?: PaymentMethod[];\n selectedMethodId?: string;\n\n // Callbacks\n onMethodSelect?: (methodId: string) => void;\n onAddNewMethod?: () => void;\n onValidationChange?: (isValid: boolean, errors: string[]) => void;\n\n // Configuration\n title?: string;\n description?: string;\n showTitle?: boolean;\n allowNewMethod?: boolean;\n requirePaymentMethod?: boolean;\n\n // State\n loading?: boolean;\n disabled?: boolean;\n\n // Styling\n variant?: \"default\" | \"compact\" | \"inline\";\n\n // Custom content\n children?: React.ReactNode;\n footerContent?: React.ReactNode;\n}\n\nexport function PaymentForm({\n existingMethods = [],\n selectedMethodId,\n onMethodSelect,\n onAddNewMethod,\n onValidationChange,\n title = \"Payment Method\",\n description,\n showTitle = true,\n allowNewMethod = true,\n requirePaymentMethod = true,\n loading = false,\n disabled = false,\n variant = \"default\",\n children,\n footerContent,\n}: PaymentFormProps) {\n const [selectedMethod, setSelectedMethod] = useState(selectedMethodId || \"\");\n const [errors, setErrors] = useState([]);\n\n const validatePayment = () => {\n const validationErrors: string[] = [];\n\n if (requirePaymentMethod) {\n if (existingMethods.length === 0) {\n validationErrors.push(\n \"No payment method on file. Please add a payment method to continue.\"\n );\n } else if (!selectedMethod) {\n validationErrors.push(\"Please select a payment method.\");\n }\n }\n\n setErrors(validationErrors);\n const isValid = validationErrors.length === 0;\n onValidationChange?.(isValid, validationErrors);\n\n return isValid;\n };\n\n const handleMethodSelect = (methodId: string) => {\n if (disabled) return;\n\n setSelectedMethod(methodId);\n onMethodSelect?.(methodId);\n };\n\n useEffect(() => {\n validatePayment();\n }, [selectedMethod, existingMethods, requirePaymentMethod]);\n\n useEffect(() => {\n if (selectedMethodId !== undefined) {\n setSelectedMethod(selectedMethodId);\n }\n }, [selectedMethodId]);\n\n const getCardBrandIcon = (brand?: string) => {\n // In a real implementation, you'd return appropriate brand icons\n return ;\n };\n\n const formatCardNumber = (last4?: string) => {\n return last4 ? `•••• •••• •••• ${last4}` : \"•••• •••• •••• ••••\";\n };\n\n const formatExpiry = (month?: number, year?: number) => {\n if (!month || !year) return \"\";\n return `${month.toString().padStart(2, \"0\")}/${year.toString().slice(-2)}`;\n };\n\n const containerClasses =\n variant === \"inline\"\n ? \"\"\n : variant === \"compact\"\n ? \"p-4 bg-gray-50 rounded-lg border border-gray-200\"\n : \"p-6 bg-white border border-gray-200 rounded-lg\";\n\n if (loading) {\n return (\n
\n
\n \n Loading payment methods...\n
\n
\n );\n }\n\n return (\n
\n {showTitle && (\n
\n
\n \n

{title}

\n
\n {description &&

{description}

}\n
\n )}\n\n {/* No payment methods */}\n {existingMethods.length === 0 ? (\n
\n
\n \n
\n

No Payment Method on File

\n

Add a payment method to complete your order.

\n {allowNewMethod && onAddNewMethod && (\n \n Add Payment Method\n \n )}\n
\n ) : (\n
\n {/* Existing payment methods */}\n
\n {existingMethods.map(method => (\n \n handleMethodSelect(method.id)}\n disabled={disabled}\n className=\"text-blue-600 focus:ring-blue-500 mr-4\"\n />\n\n
\n
{getCardBrandIcon(method.brand)}
\n\n
\n
\n \n {method.brand?.toUpperCase()} {formatCardNumber(method.last4)}\n \n {method.isDefault && (\n \n Default\n \n )}\n
\n\n
\n {method.name && {method.name} • }\n Expires {formatExpiry(method.expiryMonth, method.expiryYear)}\n
\n
\n\n {selectedMethod === method.id && (\n \n )}\n
\n \n ))}\n
\n\n {/* Add new payment method option */}\n {allowNewMethod && onAddNewMethod && (\n
\n \n \n Add New Payment Method\n \n
\n )}\n
\n )}\n\n {/* Custom content */}\n {children &&
{children}
}\n\n {/* Validation errors */}\n {errors.length > 0 && (\n
\n
\n \n
\n

Payment Required

\n
    \n {errors.map((error, index) => (\n
  • {error}
  • \n ))}\n
\n
\n
\n
\n )}\n\n {/* Footer content */}\n {footerContent &&
{footerContent}
}\n
\n );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/components/base/PricingDisplay.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/components/base/ProductCard.tsx","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'id' is defined but never used.","line":45,"column":3,"nodeType":null,"messageId":"unusedVar","endLine":45,"endColumn":5},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'sku' is defined but never used.","line":47,"column":3,"nodeType":null,"messageId":"unusedVar","endLine":47,"endColumn":6}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\"use client\";\n\nimport { ReactNode } from \"react\";\nimport { CurrencyYenIcon, ArrowRightIcon } from \"@heroicons/react/24/outline\";\nimport { AnimatedCard } from \"@/components/ui\";\nimport { Button } from \"@/components/ui/button\";\n\nexport interface ProductCardProps {\n // Core product info\n id: string;\n name: string;\n sku: string;\n description?: string;\n\n // Pricing\n monthlyPrice?: number;\n oneTimePrice?: number;\n\n // Visual elements\n icon?: ReactNode;\n badge?: {\n text: string;\n variant: \"default\" | \"recommended\" | \"family\" | \"success\";\n };\n\n // Features list\n features?: string[];\n\n // Styling\n variant?: \"default\" | \"highlighted\" | \"success\";\n size?: \"compact\" | \"standard\" | \"large\";\n\n // Actions\n href?: string;\n onClick?: () => void;\n actionLabel?: string;\n disabled?: boolean;\n\n // Additional content\n children?: ReactNode;\n footer?: ReactNode;\n}\n\nexport function ProductCard({\n id,\n name,\n sku,\n description,\n monthlyPrice,\n oneTimePrice,\n icon,\n badge,\n features = [],\n variant = \"default\",\n size = \"standard\",\n href,\n onClick,\n actionLabel = \"Configure\",\n disabled = false,\n children,\n footer,\n}: ProductCardProps) {\n const sizeClasses = {\n compact: \"p-4\",\n standard: \"p-6\",\n large: \"p-8\",\n };\n\n const getBadgeClasses = (badgeVariant: string) => {\n switch (badgeVariant) {\n case \"recommended\":\n return \"bg-green-100 text-green-800 border-green-300\";\n case \"family\":\n return \"bg-blue-100 text-blue-800 border-blue-300\";\n case \"success\":\n return \"bg-emerald-100 text-emerald-800 border-emerald-300\";\n default:\n return \"bg-gray-100 text-gray-800 border-gray-300\";\n }\n };\n\n return (\n \n {/* Header with badge and icon */}\n
\n
\n {icon &&
{icon}
}\n
\n {badge && (\n \n {badge.text}\n \n )}\n
\n
\n\n {/* Pricing display */}\n {(monthlyPrice || oneTimePrice) && (\n
\n {monthlyPrice && (\n
\n \n {monthlyPrice.toLocaleString()}\n /month\n
\n )}\n {oneTimePrice && (\n
\n \n {oneTimePrice.toLocaleString()}\n one-time\n
\n )}\n
\n )}\n
\n\n {/* Product name and description */}\n
\n

{name}

\n {description &&

{description}

}\n
\n\n {/* Features list */}\n {features.length > 0 && (\n
\n

Features:

\n
    \n {features.map((feature, index) => (\n
  • \n āœ“\n {feature}\n
  • \n ))}\n
\n
\n )}\n\n {/* Custom children content */}\n {children &&
{children}
}\n\n {/* Action button */}\n
\n {href ? (\n \n ) : onClick ? (\n \n ) : null}\n
\n\n {/* Custom footer */}\n {footer &&
{footer}
}\n \n );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/components/base/ProductComparison.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/components/common/FeatureCard.tsx","messages":[{"ruleId":"prettier/prettier","severity":1,"message":"Replace `Ā·icon,Ā·title,Ā·descriptionĀ·}:Ā·{Ā·icon:Ā·React.ReactNode;Ā·title:Ā·string;Ā·description:Ā·stringĀ·` with `āŽĀ·Ā·icon,āŽĀ·Ā·title,āŽĀ·Ā·description,āŽ}:Ā·{āŽĀ·Ā·icon:Ā·React.ReactNode;āŽĀ·Ā·title:Ā·string;āŽĀ·Ā·description:Ā·string;āŽ`","line":6,"column":30,"nodeType":null,"messageId":"replace","endLine":6,"endColumn":119,"fix":{"range":[134,223],"text":"\n icon,\n title,\n description,\n}: {\n icon: React.ReactNode;\n title: string;\n description: string;\n"}},{"ruleId":"prettier/prettier","severity":1,"message":"Delete `āŽ`","line":17,"column":1,"nodeType":null,"messageId":"delete","endLine":18,"endColumn":1,"fix":{"range":[592,593],"text":""}}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":2,"source":"\"use client\";\n\nimport React from \"react\";\nimport { AnimatedCard } from \"@/components/ui/animated-card\";\n\nexport function FeatureCard({ icon, title, description }: { icon: React.ReactNode; title: string; description: string }) {\n return (\n \n
\n
{icon}
\n
\n

{title}

\n

{description}

\n
\n );\n}\n\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/components/common/ServiceHeroCard.tsx","messages":[{"ruleId":"prettier/prettier","severity":1,"message":"Replace `Ā·as=\"a\"Ā·href={href}Ā·className=\"w-fullĀ·font-semiboldĀ·rounded-2xlĀ·relativeĀ·z-10Ā·group\"Ā·size=\"lg\"` with `āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·as=\"a\"āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·href={href}āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·className=\"w-fullĀ·font-semiboldĀ·rounded-2xlĀ·relativeĀ·z-10Ā·group\"āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·size=\"lg\"āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·`","line":76,"column":18,"nodeType":null,"messageId":"replace","endLine":76,"endColumn":112,"fix":{"range":[2221,2315],"text":"\n as=\"a\"\n href={href}\n className=\"w-full font-semibold rounded-2xl relative z-10 group\"\n size=\"lg\"\n "}},{"ruleId":"prettier/prettier","severity":1,"message":"Replace `Ā·className={`absoluteĀ·inset-0Ā·${colors.bg}Ā·opacity-0Ā·group-hover:opacity-10Ā·transition-opacityĀ·duration-300Ā·pointer-events-none`}` with `āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·className={`absoluteĀ·inset-0Ā·${colors.bg}Ā·opacity-0Ā·group-hover:opacity-10Ā·transition-opacityĀ·duration-300Ā·pointer-events-none`}āŽĀ·Ā·Ā·Ā·Ā·`","line":82,"column":11,"nodeType":null,"messageId":"replace","endLine":82,"endColumn":140,"fix":{"range":[2517,2646],"text":"\n className={`absolute inset-0 ${colors.bg} opacity-0 group-hover:opacity-10 transition-opacity duration-300 pointer-events-none`}\n "}},{"ruleId":"prettier/prettier","severity":1,"message":"Delete `āŽ`","line":86,"column":1,"nodeType":null,"messageId":"delete","endLine":87,"endColumn":1,"fix":{"range":[2677,2678],"text":""}}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":3,"fixableErrorCount":0,"fixableWarningCount":3,"source":"\"use client\";\n\nimport React from \"react\";\nimport { AnimatedCard } from \"@/components/ui/animated-card\";\nimport { Button } from \"@/components/ui/button\";\nimport { ArrowRightIcon } from \"@heroicons/react/24/outline\";\n\nexport function ServiceHeroCard({\n title,\n description,\n icon,\n features,\n href,\n color,\n}: {\n title: string;\n description: string;\n icon: React.ReactNode;\n features: string[];\n href: string;\n color: \"blue\" | \"green\" | \"purple\";\n}) {\n const colorClasses = {\n blue: {\n bg: \"bg-blue-50\",\n border: \"border-blue-200\",\n iconBg: \"bg-blue-100\",\n iconText: \"text-blue-600\",\n button: \"bg-blue-600 hover:bg-blue-700\",\n hoverBorder: \"hover:border-blue-300\",\n },\n green: {\n bg: \"bg-green-50\",\n border: \"border-green-200\",\n iconBg: \"bg-green-100\",\n iconText: \"text-green-600\",\n button: \"bg-green-600 hover:bg-green-700\",\n hoverBorder: \"hover:border-green-300\",\n },\n purple: {\n bg: \"bg-purple-50\",\n border: \"border-purple-200\",\n iconBg: \"bg-purple-100\",\n iconText: \"text-purple-600\",\n button: \"bg-purple-600 hover:bg-purple-700\",\n hoverBorder: \"hover:border-purple-300\",\n },\n } as const;\n\n const colors = colorClasses[color];\n\n return (\n \n
\n
\n
\n
{icon}
\n
\n
\n

{title}

\n
\n
\n\n

{description}

\n\n
    \n {features.map((feature, index) => (\n
  • \n
    \n {feature}\n
  • \n ))}\n
\n\n
\n \n
\n
\n
\n \n );\n}\n\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/components/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/components/internet/InstallationOptions.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/components/internet/InternetConfigureView.tsx","messages":[{"ruleId":"react/no-unescaped-entities","severity":2,"message":"`\"` can be escaped with `"`, `“`, `"`, `”`.","line":185,"column":57,"nodeType":"JSXText","messageId":"unescapedEntityAlts","suggestions":[{"messageId":"replaceWithAlt","data":{"alt":"""},"fix":{"range":[6708,6875],"text":"\n * Will appear on the invoice as "Platinum Base Plan\". Device subscriptions\n will be added later.\n "},"desc":"Replace with `"`."},{"messageId":"replaceWithAlt","data":{"alt":"“"},"fix":{"range":[6708,6875],"text":"\n * Will appear on the invoice as “Platinum Base Plan\". Device subscriptions\n will be added later.\n "},"desc":"Replace with `“`."},{"messageId":"replaceWithAlt","data":{"alt":"""},"fix":{"range":[6708,6875],"text":"\n * Will appear on the invoice as "Platinum Base Plan\". Device subscriptions\n will be added later.\n "},"desc":"Replace with `"`."},{"messageId":"replaceWithAlt","data":{"alt":"”"},"fix":{"range":[6708,6875],"text":"\n * Will appear on the invoice as ”Platinum Base Plan\". Device subscriptions\n will be added later.\n "},"desc":"Replace with `”`."}]},{"ruleId":"react/no-unescaped-entities","severity":2,"message":"`\"` can be escaped with `"`, `“`, `"`, `”`.","line":185,"column":76,"nodeType":"JSXText","messageId":"unescapedEntityAlts","suggestions":[{"messageId":"replaceWithAlt","data":{"alt":"""},"fix":{"range":[6708,6875],"text":"\n * Will appear on the invoice as \"Platinum Base Plan". Device subscriptions\n will be added later.\n "},"desc":"Replace with `"`."},{"messageId":"replaceWithAlt","data":{"alt":"“"},"fix":{"range":[6708,6875],"text":"\n * Will appear on the invoice as \"Platinum Base Plan“. Device subscriptions\n will be added later.\n "},"desc":"Replace with `“`."},{"messageId":"replaceWithAlt","data":{"alt":"""},"fix":{"range":[6708,6875],"text":"\n * Will appear on the invoice as \"Platinum Base Plan". Device subscriptions\n will be added later.\n "},"desc":"Replace with `"`."},{"messageId":"replaceWithAlt","data":{"alt":"”"},"fix":{"range":[6708,6875],"text":"\n * Will appear on the invoice as \"Platinum Base Plan”. Device subscriptions\n will be added later.\n "},"desc":"Replace with `”`."}]}],"suppressedMessages":[],"errorCount":2,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\"use client\";\n\nimport { PageLayout } from \"@/components/layout/PageLayout\";\nimport { LoadingSpinner } from \"@/components/ui/loading-spinner\";\nimport { AnimatedCard } from \"@/components/ui\";\nimport { Button } from \"@/components/ui/button\";\nimport { ProgressSteps, StepHeader } from \"@/components/ui\";\nimport { AddonGroup } from \"@/features/catalog/components/base/AddonGroup\";\nimport { InstallationOptions } from \"@/features/catalog/components/internet/InstallationOptions\";\nimport { ServerIcon, ArrowLeftIcon, ArrowRightIcon } from \"@heroicons/react/24/outline\";\nimport type {\n InternetPlan,\n InternetAddon,\n InternetInstallation,\n} from \"@/shared/types/catalog.types\";\nimport type { AccessMode } from \"../../hooks/useConfigureParams\";\n\ntype Props = {\n plan: InternetPlan | null;\n loading: boolean;\n addons: InternetAddon[];\n installations: InternetInstallation[];\n\n mode: AccessMode | null;\n setMode: (mode: AccessMode) => void;\n installPlan: string | null;\n setInstallPlan: (type: string | null) => void;\n selectedAddonSkus: string[];\n setSelectedAddonSkus: (skus: string[]) => void;\n\n currentStep: number;\n isTransitioning: boolean;\n transitionToStep: (nextStep: number) => void;\n\n monthlyTotal: number;\n oneTimeTotal: number;\n\n onConfirm: () => void;\n};\n\nexport function InternetConfigureView({\n plan,\n loading,\n addons,\n installations,\n mode,\n setMode,\n installPlan,\n setInstallPlan,\n selectedAddonSkus,\n setSelectedAddonSkus,\n currentStep,\n isTransitioning,\n transitionToStep,\n monthlyTotal,\n oneTimeTotal,\n onConfirm,\n}: Props) {\n const handleAddonSelection = (newSelectedSkus: string[]) => setSelectedAddonSkus(newSelectedSkus);\n\n if (loading) {\n return (\n }\n title=\"Configure Internet Service\"\n description=\"Set up your internet service options\"\n >\n
\n \n
\n \n );\n }\n\n if (!plan) {\n return (\n }\n title=\"Configure Internet Service\"\n description=\"Set up your internet service options\"\n >\n
\n

Plan not found

\n \n
\n \n );\n }\n\n const steps = [\n { number: 1, title: \"Service Details\", completed: currentStep > 1 },\n { number: 2, title: \"Installation\", completed: currentStep > 2 },\n { number: 3, title: \"Add-ons\", completed: currentStep > 3 },\n { number: 4, title: \"Review Order\", completed: currentStep > 4 },\n ];\n\n return (\n } title=\"\" description=\"\">\n
\n
\n \n \n Back to Internet Plans\n \n\n

Configure {plan.name}

\n\n
\n \n {plan.tier}\n
\n •\n {plan.name}\n {plan.monthlyPrice && (\n <>\n •\n \n Ā„{plan.monthlyPrice.toLocaleString()}/month\n \n \n )}\n
\n
\n\n \n\n
\n {currentStep === 1 && (\n \n
\n
\n
\n 1\n
\n

Service Configuration

\n
\n

Review your plan details and configuration

\n
\n\n {plan?.tier === \"Platinum\" && (\n
\n
\n
\n \n \n \n
\n
\n
\n IMPORTANT - For PLATINUM subscribers\n
\n

\n Additional fees are incurred for the PLATINUM service. Please refer to the\n information from our tech team for details.\n

\n

\n * Will appear on the invoice as \"Platinum Base Plan\". Device subscriptions\n will be added later.\n

\n
\n
\n
\n )}\n\n {plan?.tier === \"Silver\" ? (\n
\n

\n Select Your Router & ISP Configuration:\n

\n
\n setMode(\"PPPoE\")}\n className={`p-6 rounded-xl border-2 text-left transition-all duration-300 ease-in-out transform hover:scale-[1.02] hover:shadow-md ${mode === \"PPPoE\" ? \"border-orange-500 bg-orange-50 shadow-md scale-[1.02]\" : \"border-gray-200 hover:border-orange-300\"}`}\n >\n
PPPoE
\n

\n Requires a PPPoE-capable router and ISP credentials.\n

\n
\n

\n Note: Older standard, may be slower during peak times.\n

\n
\n \n\n setMode(\"IPoE-BYOR\")}\n className={`p-6 rounded-xl border-2 text-left transition-all duration-300 ease-in-out transform hover:scale-[1.02] hover:shadow-md ${mode === \"IPoE-BYOR\" ? \"border-green-500 bg-green-50 shadow-md scale-[1.02]\" : \"border-gray-200 hover:border-green-300\"}`}\n >\n
IPoE (v6plus)
\n

\n Requires a v6plus-compatible router for faster, more stable connection.\n

\n
\n

\n Recommended: Faster speeds with less congestion.{\" \"}\n \n Check compatibility →\n \n

\n
\n \n
\n\n
\n mode && transitionToStep(2)}\n disabled={!mode}\n className=\"flex items-center\"\n >\n Continue to Installation\n \n \n
\n
\n ) : (\n
\n
\n
\n \n \n \n \n Access Mode: IPoE-HGW (Pre-configured for {plan?.tier} plan)\n \n
\n
\n
\n {\n setMode(\"IPoE-BYOR\");\n transitionToStep(2);\n }}\n className=\"flex items-center\"\n >\n Continue to Installation\n \n \n
\n
\n )}\n \n )}\n\n {currentStep === 2 && mode && (\n \n
\n \n
\n\n inst.type === installPlan) || null}\n onInstallationSelect={installation => setInstallPlan(installation.type)}\n showSkus={false}\n />\n\n
\n
\n
\n \n \n \n
\n
\n

Weekend Installation

\n

\n Weekend installation is available with an additional „3,000 charge. Our team\n will contact you to schedule the most convenient time.\n

\n
\n
\n
\n\n
\n transitionToStep(1)}\n variant=\"outline\"\n className=\"flex items-center\"\n >\n \n Back to Service Details\n \n installPlan && transitionToStep(3)}\n disabled={!installPlan}\n className=\"flex items-center\"\n >\n Continue to Add-ons\n \n \n
\n \n )}\n\n {currentStep === 3 && installPlan && (\n \n
\n \n
\n \n
\n transitionToStep(2)}\n variant=\"outline\"\n className=\"flex items-center\"\n >\n \n Back to Installation\n \n \n
\n \n )}\n\n {currentStep === 4 && (\n \n
\n \n
\n\n
\n
\n

Order Summary

\n

Review your configuration

\n
\n\n
\n
\n
\n

{plan.name}

\n

Internet Service

\n
\n
\n

\n Ā„{plan.monthlyPrice?.toLocaleString()}\n

\n

per month

\n
\n
\n
\n\n
\n

Configuration

\n
\n
\n Access Mode:\n {mode || \"Not selected\"}\n
\n
\n
\n\n {selectedAddonSkus.length > 0 && (\n
\n

Add-ons

\n
\n {selectedAddonSkus.map(addonSku => {\n const addon = addons.find(a => a.sku === addonSku);\n return (\n
\n {addon?.name || addonSku}\n \n Ā„\n {(\n addon?.monthlyPrice ||\n addon?.activationPrice ||\n 0\n ).toLocaleString()}\n \n /{addon?.monthlyPrice ? \"mo\" : \"once\"}\n \n \n
\n );\n })}\n
\n
\n )}\n\n {(() => {\n const installation = installations.find(i => i.type === installPlan);\n return installation && installation.price && installation.price > 0 ? (\n
\n

Installation

\n
\n {installation.name}\n \n Ā„{installation.price.toLocaleString()}\n \n /{installation.billingCycle === \"Monthly\" ? \"mo\" : \"once\"}\n \n \n
\n
\n ) : null;\n })()}\n\n
\n
\n
\n Monthly Total\n Ā„{monthlyTotal.toLocaleString()}\n
\n {oneTimeTotal > 0 && (\n
\n One-time Total\n \n Ā„{oneTimeTotal.toLocaleString()}\n \n
\n )}\n
\n
\n
\n\n
\n transitionToStep(3)}\n variant=\"outline\"\n size=\"lg\"\n className=\"px-8 py-4 text-lg\"\n >\n \n Back to Add-ons\n \n \n
\n \n )}\n
\n
\n \n );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/components/sim/ActivationForm.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/components/sim/MnpForm.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/components/sim/SimConfigureView.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/components/sim/SimPlanCard.tsx","messages":[{"ruleId":"prettier/prettier","severity":1,"message":"Replace `Family` with `āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·FamilyāŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·`","line":20,"column":104,"nodeType":null,"messageId":"replace","endLine":20,"endColumn":110,"fix":{"range":[1036,1042],"text":"\n Family\n "}},{"ruleId":"prettier/prettier","severity":1,"message":"Replace `{plan.monthlyPrice?.toLocaleString()}` with `āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·{plan.monthlyPrice?.toLocaleString()}āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·`","line":28,"column":61,"nodeType":null,"messageId":"replace","endLine":28,"endColumn":98,"fix":{"range":[1315,1352],"text":"\n {plan.monthlyPrice?.toLocaleString()}\n "}},{"ruleId":"prettier/prettier","severity":1,"message":"Replace `DiscountedĀ·price` with `(āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·DiscountedĀ·priceāŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·)`","line":31,"column":22,"nodeType":null,"messageId":"replace","endLine":31,"endColumn":101,"fix":{"range":[1460,1539],"text":"(\n
Discounted price
\n )"}},{"ruleId":"prettier/prettier","severity":1,"message":"Delete `āŽ`","line":42,"column":1,"nodeType":null,"messageId":"delete","endLine":43,"endColumn":1,"fix":{"range":[1838,1839],"text":""}}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":4,"fixableErrorCount":0,"fixableWarningCount":4,"source":"\"use client\";\n\nimport { DevicePhoneMobileIcon, UsersIcon, CurrencyYenIcon } from \"@heroicons/react/24/outline\";\nimport { AnimatedCard } from \"@/components/ui/animated-card\";\nimport { Button } from \"@/components/ui/button\";\nimport type { SimPlan } from \"@/shared/types/catalog.types\";\n\nexport function SimPlanCard({ plan, isFamily }: { plan: SimPlan; isFamily?: boolean }) {\n return (\n \n
\n
\n
\n \n {plan.dataSize}\n
\n {isFamily && (\n
\n \n Family\n
\n )}\n
\n
\n
\n
\n \n {plan.monthlyPrice?.toLocaleString()}\n /month\n
\n {isFamily &&
Discounted price
}\n
\n
\n

{plan.description}

\n
\n \n
\n );\n}\n\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/components/sim/SimPlanTypeSection.tsx","messages":[{"ruleId":"prettier/prettier","severity":1,"message":"Replace `YouĀ·qualify!` with `āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·YouĀ·qualify!āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·`","line":44,"column":90,"nodeType":null,"messageId":"replace","endLine":44,"endColumn":102,"fix":{"range":[1502,1514],"text":"\n You qualify!\n "}},{"ruleId":"prettier/prettier","severity":1,"message":"Delete `āŽ`","line":56,"column":1,"nodeType":null,"messageId":"delete","endLine":57,"endColumn":1,"fix":{"range":[1832,1833],"text":""}}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":2,"source":"\"use client\";\n\nimport React from \"react\";\nimport { UsersIcon } from \"@heroicons/react/24/outline\";\nimport type { SimPlan } from \"@/shared/types/catalog.types\";\nimport { SimPlanCard } from \"./SimPlanCard\";\n\nexport function SimPlanTypeSection({\n title,\n description,\n icon,\n plans,\n showFamilyDiscount,\n}: {\n title: string;\n description: string;\n icon: React.ReactNode;\n plans: SimPlan[];\n showFamilyDiscount: boolean;\n}) {\n if (plans.length === 0) return null;\n const regularPlans = plans.filter(p => !p.hasFamilyDiscount);\n const familyPlans = plans.filter(p => p.hasFamilyDiscount);\n\n return (\n
\n
\n {icon}\n
\n

{title}

\n

{description}

\n
\n
\n
\n {regularPlans.map(plan => (\n \n ))}\n
\n {showFamilyDiscount && familyPlans.length > 0 && (\n <>\n
\n \n

Family Discount Options

\n You qualify!\n
\n
\n {familyPlans.map(plan => (\n \n ))}\n
\n \n )}\n
\n );\n}\n\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/components/sim/SimTypeSelector.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/containers/CatalogHome.tsx","messages":[{"ruleId":"prettier/prettier","severity":1,"message":"Replace `Ā·Squares2X2Icon,Ā·ServerIcon,Ā·DevicePhoneMobileIcon,Ā·ShieldCheckIcon,Ā·WifiIcon,Ā·GlobeAltIconĀ·` with `āŽĀ·Ā·Squares2X2Icon,āŽĀ·Ā·ServerIcon,āŽĀ·Ā·DevicePhoneMobileIcon,āŽĀ·Ā·ShieldCheckIcon,āŽĀ·Ā·WifiIcon,āŽĀ·Ā·GlobeAltIcon,āŽ`","line":5,"column":9,"nodeType":null,"messageId":"replace","endLine":5,"endColumn":101,"fix":{"range":[111,203],"text":"\n Squares2X2Icon,\n ServerIcon,\n DevicePhoneMobileIcon,\n ShieldCheckIcon,\n WifiIcon,\n GlobeAltIcon,\n"}},{"ruleId":"prettier/prettier","severity":1,"message":"Replace `ConnectivityĀ·Solution` with `āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·ConnectivityĀ·SolutionāŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·`","line":21,"column":106,"nodeType":null,"messageId":"replace","endLine":21,"endColumn":127,"fix":{"range":[1066,1087],"text":"\n Connectivity Solution\n "}},{"ruleId":"prettier/prettier","severity":1,"message":"Replace `\"UpĀ·toĀ·10GbpsĀ·speeds\",Ā·\"FiberĀ·opticĀ·technology\",Ā·\"MultipleĀ·accessĀ·modes\",Ā·\"ProfessionalĀ·installation\"` with `āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·\"UpĀ·toĀ·10GbpsĀ·speeds\",āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·\"FiberĀ·opticĀ·technology\",āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·\"MultipleĀ·accessĀ·modes\",āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·\"ProfessionalĀ·installation\",āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·`","line":33,"column":24,"nodeType":null,"messageId":"replace","endLine":33,"endColumn":125,"fix":{"range":[1615,1716],"text":"\n \"Up to 10Gbps speeds\",\n \"Fiber optic technology\",\n \"Multiple access modes\",\n \"Professional installation\",\n "}},{"ruleId":"prettier/prettier","severity":1,"message":"Replace `\"PhysicalĀ·SIMĀ·&Ā·eSIM\",Ā·\"DataĀ·+Ā·SMS/VoiceĀ·plans\",Ā·\"FamilyĀ·discounts\",Ā·\"MultipleĀ·dataĀ·options\"` with `āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·\"PhysicalĀ·SIMĀ·&Ā·eSIM\",āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·\"DataĀ·+Ā·SMS/VoiceĀ·plans\",āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·\"FamilyĀ·discounts\",āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·\"MultipleĀ·dataĀ·options\",āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·`","line":41,"column":24,"nodeType":null,"messageId":"replace","endLine":41,"endColumn":116,"fix":{"range":[2036,2128],"text":"\n \"Physical SIM & eSIM\",\n \"Data + SMS/Voice plans\",\n \"Family discounts\",\n \"Multiple data options\",\n "}},{"ruleId":"prettier/prettier","severity":1,"message":"Replace `\"SecureĀ·encryption\",Ā·\"MultipleĀ·locations\",Ā·\"BusinessĀ·&Ā·personal\",Ā·\"24/7Ā·connectivity\"` with `āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·\"SecureĀ·encryption\",āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·\"MultipleĀ·locations\",āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·\"BusinessĀ·&Ā·personal\",āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·\"24/7Ā·connectivity\",āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·`","line":49,"column":24,"nodeType":null,"messageId":"replace","endLine":49,"endColumn":109,"fix":{"range":[2433,2518],"text":"\n \"Secure encryption\",\n \"Multiple locations\",\n \"Business & personal\",\n \"24/7 connectivity\",\n "}},{"ruleId":"prettier/prettier","severity":1,"message":"Replace `Ā·icon={}Ā·title=\"Location-BasedĀ·Plans\"Ā·description=\"InternetĀ·plansĀ·tailoredĀ·toĀ·yourĀ·houseĀ·typeĀ·andĀ·infrastructure\"` with `āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·icon={}āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·title=\"Location-BasedĀ·Plans\"āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·description=\"InternetĀ·plansĀ·tailoredĀ·toĀ·yourĀ·houseĀ·typeĀ·andĀ·infrastructure\"āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·`","line":63,"column":25,"nodeType":null,"messageId":"replace","endLine":63,"endColumn":186,"fix":{"range":[3158,3319],"text":"\n icon={}\n title=\"Location-Based Plans\"\n description=\"Internet plans tailored to your house type and infrastructure\"\n "}},{"ruleId":"prettier/prettier","severity":1,"message":"Replace `Ā·icon={}Ā·title=\"SeamlessĀ·Integration\"Ā·description=\"ManageĀ·allĀ·servicesĀ·fromĀ·aĀ·singleĀ·account\"` with `āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·icon={}āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·title=\"SeamlessĀ·Integration\"āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·description=\"ManageĀ·allĀ·servicesĀ·fromĀ·aĀ·singleĀ·account\"āŽĀ·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·Ā·`","line":64,"column":25,"nodeType":null,"messageId":"replace","endLine":64,"endColumn":172,"fix":{"range":[3347,3494],"text":"\n icon={}\n title=\"Seamless Integration\"\n description=\"Manage all services from a single account\"\n "}},{"ruleId":"prettier/prettier","severity":1,"message":"Delete `āŽ`","line":73,"column":1,"nodeType":null,"messageId":"delete","endLine":74,"endColumn":1,"fix":{"range":[3606,3607],"text":""}}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":8,"fixableErrorCount":0,"fixableWarningCount":8,"source":"\"use client\";\n\nimport React from \"react\";\nimport { PageLayout } from \"@/components/layout/PageLayout\";\nimport { Squares2X2Icon, ServerIcon, DevicePhoneMobileIcon, ShieldCheckIcon, WifiIcon, GlobeAltIcon } from \"@heroicons/react/24/outline\";\nimport { ServiceHeroCard } from \"@/features/catalog/components/common/ServiceHeroCard\";\nimport { FeatureCard } from \"@/features/catalog/components/common/FeatureCard\";\n\nexport function CatalogHomeContainer() {\n return (\n } title=\"\" description=\"\">\n
\n
\n
\n \n Services Catalog\n
\n

\n Choose Your Perfect\n
\n Connectivity Solution\n

\n

\n Discover high-speed internet, mobile data/voice options, and secure VPN services.\n

\n
\n\n
\n }\n features={[\"Up to 10Gbps speeds\", \"Fiber optic technology\", \"Multiple access modes\", \"Professional installation\"]}\n href=\"/catalog/internet\"\n color=\"blue\"\n />\n }\n features={[\"Physical SIM & eSIM\", \"Data + SMS/Voice plans\", \"Family discounts\", \"Multiple data options\"]}\n href=\"/catalog/sim\"\n color=\"green\"\n />\n }\n features={[\"Secure encryption\", \"Multiple locations\", \"Business & personal\", \"24/7 connectivity\"]}\n href=\"/catalog/vpn\"\n color=\"purple\"\n />\n
\n\n
\n
\n

Why Choose Our Services?

\n

\n Personalized recommendations based on your location and account eligibility.\n

\n
\n
\n } title=\"Location-Based Plans\" description=\"Internet plans tailored to your house type and infrastructure\" />\n } title=\"Seamless Integration\" description=\"Manage all services from a single account\" />\n
\n
\n
\n
\n );\n}\n\nexport default CatalogHomeContainer;\n\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/containers/InternetConfigure.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/containers/InternetPlans.tsx","messages":[{"ruleId":"react-hooks/exhaustive-deps","severity":1,"message":"The 'plans' logical expression could make the dependencies of useEffect Hook (at line 30) change on every render. To fix this, wrap the initialization of 'plans' in its own useMemo() Hook.","line":22,"column":9,"nodeType":"VariableDeclarator","endLine":22,"endColumn":34},{"ruleId":"react/no-unescaped-entities","severity":2,"message":"`'` can be escaped with `'`, `‘`, `'`, `’`.","line":148,"column":24,"nodeType":"JSXText","messageId":"unescapedEntityAlts","suggestions":[{"messageId":"replaceWithAlt","data":{"alt":"'"},"fix":{"range":[5836,5941],"text":"\n We couldn't find any internet plans available for your location at this time.\n "},"desc":"Replace with `'`."},{"messageId":"replaceWithAlt","data":{"alt":"‘"},"fix":{"range":[5836,5941],"text":"\n We couldn‘t find any internet plans available for your location at this time.\n "},"desc":"Replace with `‘`."},{"messageId":"replaceWithAlt","data":{"alt":"'"},"fix":{"range":[5836,5941],"text":"\n We couldn't find any internet plans available for your location at this time.\n "},"desc":"Replace with `'`."},{"messageId":"replaceWithAlt","data":{"alt":"’"},"fix":{"range":[5836,5941],"text":"\n We couldn’t find any internet plans available for your location at this time.\n "},"desc":"Replace with `’`."}]},{"ruleId":"react/no-unescaped-entities","severity":2,"message":"`'` can be escaped with `'`, `‘`, `'`, `’`.","line":229,"column":90,"nodeType":"JSXText","messageId":"unescapedEntityAlts","suggestions":[{"messageId":"replaceWithAlt","data":{"alt":"'"},"fix":{"range":[9005,9065],"text":"1 NTT Optical Fiber (Flet's Hikari\n Next - "},"desc":"Replace with `'`."},{"messageId":"replaceWithAlt","data":{"alt":"‘"},"fix":{"range":[9005,9065],"text":"1 NTT Optical Fiber (Flet‘s Hikari\n Next - "},"desc":"Replace with `‘`."},{"messageId":"replaceWithAlt","data":{"alt":"'"},"fix":{"range":[9005,9065],"text":"1 NTT Optical Fiber (Flet's Hikari\n Next - "},"desc":"Replace with `'`."},{"messageId":"replaceWithAlt","data":{"alt":"’"},"fix":{"range":[9005,9065],"text":"1 NTT Optical Fiber (Flet’s Hikari\n Next - "},"desc":"Replace with `’`."}]}],"suppressedMessages":[],"errorCount":2,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\"use client\";\n\nimport { useState, useEffect } from \"react\";\nimport { PageLayout } from \"@/components/layout/PageLayout\";\nimport {\n WifiIcon,\n ServerIcon,\n CurrencyYenIcon,\n ArrowLeftIcon,\n ArrowRightIcon,\n HomeIcon,\n BuildingOfficeIcon,\n} from \"@heroicons/react/24/outline\";\nimport { useInternetCatalog } from \"@/features/catalog/hooks\";\nimport { InternetPlan, InternetInstallation } from \"@/shared/types/catalog.types\";\nimport { LoadingSpinner } from \"@/components/ui/loading-spinner\";\nimport { AnimatedCard } from \"@/components/ui\";\nimport { Button } from \"@/components/ui/button\";\n\nexport function InternetPlansContainer() {\n const { data, isLoading, error } = useInternetCatalog();\n const plans = data?.plans || [];\n const installations = data?.installations || [];\n const [eligibility, setEligibility] = useState(\"\");\n\n useEffect(() => {\n if (plans.length > 0) {\n setEligibility(plans[0].offeringType || \"Home 1G\");\n }\n }, [plans]);\n\n const getEligibilityIcon = (offeringType: string) => {\n if (offeringType.toLowerCase().includes(\"home\")) return ;\n if (offeringType.toLowerCase().includes(\"apartment\"))\n return ;\n return ;\n };\n\n const getEligibilityColor = (offeringType: string) => {\n if (offeringType.toLowerCase().includes(\"home\"))\n return \"text-blue-600 bg-blue-50 border-blue-200\";\n if (offeringType.toLowerCase().includes(\"apartment\"))\n return \"text-green-600 bg-green-50 border-green-200\";\n return \"text-gray-600 bg-gray-50 border-gray-200\";\n };\n\n if (isLoading) {\n return (\n }\n >\n
\n \n
\n \n );\n }\n\n if (error) {\n const errorMessage = error instanceof Error ? error.message : \"An unexpected error occurred\";\n return (\n }\n >\n
\n
Failed to load plans
\n
{errorMessage}
\n \n
\n \n );\n }\n\n return (\n }\n >\n
\n
\n \n
\n\n
\n

Choose Your Internet Plan

\n\n {eligibility && (\n
\n \n {getEligibilityIcon(eligibility)}\n Available for: {eligibility}\n
\n

\n Plans shown are tailored to your house type and local infrastructure\n

\n
\n )}\n
\n\n {plans.length > 0 ? (\n <>\n
\n {plans.map(plan => (\n \n ))}\n
\n\n
\n

Important Notes:

\n
    \n
  • \n •Theoretical internet speed is the\n same for all three packages\n
  • \n
  • \n •One-time fee (Ā„22,800) can be paid\n upfront or in 12- or 24-month installments\n
  • \n
  • \n •Home phone line (Hikari Denwa) can be\n added to GOLD or PLATINUM plans (Ā„450/month + Ā„1,000-3,000 one-time)\n
  • \n
  • \n •In-home technical assistance\n available (Ā„15,000 onsite visiting fee)\n
  • \n
\n
\n \n ) : (\n
\n \n

No Plans Available

\n

\n We couldn't find any internet plans available for your location at this time.\n

\n \n
\n )}\n \n \n );\n}\n\nfunction InternetPlanCard({\n plan,\n installations,\n}: {\n plan: InternetPlan;\n installations: InternetInstallation[];\n}) {\n const isGold = plan.tier === \"Gold\";\n const isPlatinum = plan.tier === \"Platinum\";\n const isSilver = plan.tier === \"Silver\";\n\n const cardVariant = \"default\";\n\n const getBorderClass = () => {\n if (isGold) return \"border-2 border-yellow-400 shadow-lg hover:shadow-xl\";\n if (isPlatinum) return \"border-2 border-indigo-400 shadow-lg hover:shadow-xl\";\n if (isSilver) return \"border-2 border-gray-300 shadow-lg hover:shadow-xl\";\n return \"border border-gray-200 shadow-lg hover:shadow-xl\";\n };\n\n return (\n \n
\n
\n
\n \n {plan.tier}\n \n {isGold && (\n \n Recommended\n \n )}\n
\n {plan.monthlyPrice && (\n
\n
\n \n {plan.monthlyPrice.toLocaleString()}\n \n per month\n \n
\n
\n )}\n
\n\n

{plan.name}

\n

{plan.tierDescription || plan.description}

\n\n
\n

Your Plan Includes:

\n
    \n {plan.features && plan.features.length > 0 ? (\n plan.features.map((feature, index) => (\n
  • \n āœ“\n {feature}\n
  • \n ))\n ) : (\n <>\n
  • \n āœ“1 NTT Optical Fiber (Flet's Hikari\n Next - {plan.offeringType?.includes(\"Apartment\") ? \"Mansion\" : \"Home\"}{\" \"}\n {plan.offeringType?.includes(\"10G\")\n ? \"10Gbps\"\n : plan.offeringType?.includes(\"100M\")\n ? \"100Mbps\"\n : \"1Gbps\"}\n ) Installation + Monthly\n
  • \n
  • \n āœ“\n Monthly: Ā„{plan.monthlyPrice?.toLocaleString()}\n {installations.length > 0 && (\n \n (+ installation from Ā„\n {Math.min(...installations.map(i => i.price || 0)).toLocaleString()})\n \n )}\n
  • \n \n )}\n
\n
\n\n \n Configure Plan\n \n \n
\n
\n );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/containers/SimConfigure.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/containers/VpnPlans.tsx","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'VpnPlan' is defined but never used.","line":6,"column":10,"nodeType":null,"messageId":"unusedVar","endLine":6,"endColumn":17},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'VpnActivationFee' is defined but never used.","line":6,"column":19,"nodeType":null,"messageId":"unusedVar","endLine":6,"endColumn":35},{"ruleId":"react/no-unescaped-entities","severity":2,"message":"`'` can be escaped with `'`, `‘`, `'`, `’`.","line":122,"column":24,"nodeType":"JSXText","messageId":"unescapedEntityAlts","suggestions":[{"messageId":"replaceWithAlt","data":{"alt":"'"},"fix":{"range":[4876,4958],"text":"\n We couldn't find any VPN plans available at this time.\n "},"desc":"Replace with `'`."},{"messageId":"replaceWithAlt","data":{"alt":"‘"},"fix":{"range":[4876,4958],"text":"\n We couldn‘t find any VPN plans available at this time.\n "},"desc":"Replace with `‘`."},{"messageId":"replaceWithAlt","data":{"alt":"'"},"fix":{"range":[4876,4958],"text":"\n We couldn't find any VPN plans available at this time.\n "},"desc":"Replace with `'`."},{"messageId":"replaceWithAlt","data":{"alt":"’"},"fix":{"range":[4876,4958],"text":"\n We couldn’t find any VPN plans available at this time.\n "},"desc":"Replace with `’`."}]}],"suppressedMessages":[],"errorCount":1,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\"use client\";\n\nimport { PageLayout } from \"@/components/layout/PageLayout\";\nimport { ShieldCheckIcon, CurrencyYenIcon, ArrowLeftIcon } from \"@heroicons/react/24/outline\";\nimport { useVpnCatalog } from \"@/features/catalog/hooks\";\nimport { VpnPlan, VpnActivationFee } from \"@/shared/types/catalog.types\";\nimport { LoadingSpinner } from \"@/components/ui/loading-spinner\";\nimport { AnimatedCard } from \"@/components/ui\";\nimport { Button } from \"@/components/ui/button\";\n\nexport function VpnPlansContainer() {\n const { data, isLoading, error } = useVpnCatalog();\n const vpnPlans = data?.plans || [];\n const activationFees = data?.activationFees || [];\n\n if (isLoading) {\n return (\n }\n >\n
\n \n
\n \n );\n }\n\n if (error) {\n const errorMessage = error instanceof Error ? error.message : \"An unexpected error occurred\";\n return (\n }\n >\n
\n
Failed to load VPN plans
\n
{errorMessage}
\n \n
\n \n );\n }\n\n return (\n }\n >\n
\n
\n \n
\n\n
\n

\n SonixNet VPN Rental Router Service\n

\n

\n Fast and secure VPN connection to San Francisco or London for accessing geo-restricted\n content.\n

\n
\n\n {vpnPlans.length > 0 ? (\n
\n

Available Plans

\n

(One region per router)

\n\n
\n {vpnPlans.map(plan => (\n \n
\n

{plan.name}

\n
\n
\n
\n \n \n {plan.monthlyPrice?.toLocaleString()}\n \n /month\n
\n
\n \n Configure Plan\n \n \n ))}\n
\n\n {activationFees.length > 0 && (\n
\n

\n A one-time activation fee of 3000 JPY is incurred seprarately for each rental\n unit. Tax (10%) not included.\n

\n
\n )}\n
\n ) : (\n
\n \n

No VPN Plans Available

\n

\n We couldn't find any VPN plans available at this time.\n

\n \n
\n )}\n\n
\n

How It Works

\n
\n

\n SonixNet VPN is the easiest way to access video streaming services from overseas on\n your network media players such as an Apple TV, Roku, or Amazon Fire.\n

\n

\n A configured Wi-Fi router is provided for rental (no purchase required, no hidden\n fees). All you will need to do is to plug the VPN router into your existing internet\n connection.\n

\n

\n Then you can connect your network media players to the VPN Wi-Fi network, to connect\n to the VPN server.\n

\n

\n For daily Internet usage that does not require a VPN, we recommend connecting to your\n regular home Wi-Fi.\n

\n
\n
\n\n
\n

Important Disclaimer

\n

\n *1: Content subscriptions are NOT included in the SonixNet VPN package. Our VPN service\n will establish a network connection that virtually locates you in the designated server\n location, then you will sign up for the streaming services of your choice. Not all\n services/websites can be unblocked. Assist Solutions does not guarantee or bear any\n responsibility over the unblocking of any websites or the quality of the\n streaming/browsing.\n

\n
\n
\n \n );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/hooks/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/hooks/useCatalog.ts","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'CatalogProduct' is defined but never used.","line":8,"column":15,"nodeType":null,"messageId":"unusedVar","endLine":8,"endColumn":29},{"ruleId":"@typescript-eslint/no-floating-promises","severity":2,"message":"Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator.","line":64,"column":7,"nodeType":"ExpressionStatement","messageId":"floatingVoid","endLine":64,"endColumn":63,"suggestions":[{"messageId":"floatingFixVoid","fix":{"range":[1721,1721],"text":"void "},"desc":"Add void operator to ignore."},{"messageId":"floatingFixAwait","fix":{"range":[1721,1721],"text":"await "},"desc":"Add await operator."}]},{"ruleId":"@typescript-eslint/no-floating-promises","severity":2,"message":"Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator.","line":65,"column":7,"nodeType":"ExpressionStatement","messageId":"floatingVoid","endLine":65,"endColumn":70,"suggestions":[{"messageId":"floatingFixVoid","fix":{"range":[1784,1784],"text":"void "},"desc":"Add void operator to ignore."},{"messageId":"floatingFixAwait","fix":{"range":[1784,1784],"text":"await "},"desc":"Add await operator."}]}],"suppressedMessages":[],"errorCount":2,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Catalog Hooks\n * React hooks for catalog functionality\n */\n\nimport { useQuery, useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { catalogService } from \"../services\";\nimport type { CatalogProduct, CatalogFilters, ProductConfiguration, OrderSummary } from \"../types\";\n\n/**\n * Hook to fetch all products with optional filtering\n */\nexport function useProducts(filters?: CatalogFilters) {\n return useQuery({\n queryKey: [\"catalog\", \"products\", filters],\n queryFn: () => catalogService.getProducts(filters),\n staleTime: 5 * 60 * 1000, // 5 minutes\n });\n}\n\n/**\n * Hook to fetch a specific product\n */\nexport function useProduct(id: string) {\n return useQuery({\n queryKey: [\"catalog\", \"product\", id],\n queryFn: () => catalogService.getProduct(id),\n enabled: !!id,\n staleTime: 5 * 60 * 1000, // 5 minutes\n });\n}\n\n/**\n * Hook to fetch products by category\n */\nexport function useProductsByCategory(category: \"internet\" | \"sim\" | \"vpn\") {\n return useQuery({\n queryKey: [\"catalog\", \"products\", \"category\", category],\n queryFn: () => catalogService.getProductsByCategory(category),\n staleTime: 5 * 60 * 1000, // 5 minutes\n });\n}\n\n/**\n * Hook to calculate order summary\n */\nexport function useCalculateOrder() {\n return useMutation({\n mutationFn: (configuration: ProductConfiguration) =>\n catalogService.calculateOrderSummary(configuration),\n });\n}\n\n/**\n * Hook to submit an order\n */\nexport function useSubmitOrder() {\n const queryClient = useQueryClient();\n\n return useMutation({\n mutationFn: (orderSummary: OrderSummary) => catalogService.submitOrder(orderSummary),\n onSuccess: () => {\n // Invalidate relevant queries after successful order\n queryClient.invalidateQueries({ queryKey: [\"orders\"] });\n queryClient.invalidateQueries({ queryKey: [\"subscriptions\"] });\n },\n });\n}\n\n/**\n * Internet catalog composite hook\n * Fetches plans and installations together\n */\nexport function useInternetCatalog() {\n return useQuery({\n queryKey: [\"catalog\", \"internet\", \"all\"],\n queryFn: async () => {\n const [plans, installations, addons] = await Promise.all([\n catalogService.getInternetPlans(),\n catalogService.getInternetInstallations(),\n catalogService.getInternetAddons(),\n ]);\n return { plans, installations, addons } as const;\n },\n staleTime: 5 * 60 * 1000,\n });\n}\n\n/**\n * SIM catalog composite hook\n * Fetches plans, activation fees, and addons together\n */\nexport function useSimCatalog() {\n return useQuery({\n queryKey: [\"catalog\", \"sim\", \"all\"],\n queryFn: async () => {\n const [plans, activationFees, addons] = await Promise.all([\n catalogService.getSimPlans(),\n catalogService.getSimActivationFees(),\n catalogService.getSimAddons(),\n ]);\n return { plans, activationFees, addons } as const;\n },\n staleTime: 5 * 60 * 1000,\n });\n}\n\n/**\n * VPN catalog hook\n * Fetches VPN plans and activation fees\n */\nexport function useVpnCatalog() {\n return useQuery({\n queryKey: [\"catalog\", \"vpn\", \"all\"],\n queryFn: async () => {\n const [plans, activationFees] = await Promise.all([\n catalogService.getVpnPlans(),\n catalogService.getVpnActivationFees(),\n ]);\n return { plans, activationFees } as const;\n },\n staleTime: 5 * 60 * 1000,\n });\n}\n\n/**\n * Lookup helpers by SKU\n */\nexport function useInternetPlan(sku?: string) {\n const { data, ...rest } = useInternetCatalog();\n const plan = (data?.plans || []).find(p => p.sku === sku);\n return { plan, ...rest } as const;\n}\n\nexport function useSimPlan(sku?: string) {\n const { data, ...rest } = useSimCatalog();\n const plan = (data?.plans || []).find(p => p.sku === sku);\n return { plan, ...rest } as const;\n}\n\nexport function useVpnPlan(sku?: string) {\n const { data, ...rest } = useVpnCatalog();\n const plan = (data?.plans || []).find(p => p.sku === sku);\n return { plan, ...rest } as const;\n}\n\n/**\n * Addon/installation lookup helpers by SKU\n */\nexport function useInternetInstallation(sku?: string) {\n const { data, ...rest } = useInternetCatalog();\n const installation = (data?.installations || []).find(i => i.sku === sku);\n return { installation, ...rest } as const;\n}\n\nexport function useInternetAddon(sku?: string) {\n const { data, ...rest } = useInternetCatalog();\n const addon = (data?.addons || []).find(a => a.sku === sku);\n return { addon, ...rest } as const;\n}\n\nexport function useSimAddon(sku?: string) {\n const { data, ...rest } = useSimCatalog();\n const addon = (data?.addons || []).find(a => a.sku === sku);\n return { addon, ...rest } as const;\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/hooks/useConfigureParams.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/hooks/useInternetConfigure.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/hooks/useSimConfigure.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/services/catalog.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/services/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/types/catalog.types.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/types/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/utils/catalog.utils.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/catalog/utils/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/checkout/containers/CheckoutContainer.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/checkout/hooks/useCheckout.ts","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'InternetPlan' is defined but never used.","line":12,"column":3,"nodeType":null,"messageId":"unusedVar","endLine":12,"endColumn":15},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'InternetAddon' is defined but never used.","line":13,"column":3,"nodeType":null,"messageId":"unusedVar","endLine":13,"endColumn":16},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'InternetInstallation' is defined but never used.","line":14,"column":3,"nodeType":null,"messageId":"unusedVar","endLine":14,"endColumn":23},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'SimPlan' is defined but never used.","line":15,"column":3,"nodeType":null,"messageId":"unusedVar","endLine":15,"endColumn":10},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'SimAddon' is defined but never used.","line":16,"column":3,"nodeType":null,"messageId":"unusedVar","endLine":16,"endColumn":11},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'SimActivationFee' is defined but never used.","line":17,"column":3,"nodeType":null,"messageId":"unusedVar","endLine":17,"endColumn":19}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":6,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\"use client\";\n\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\nimport { useSearchParams, useRouter } from \"next/navigation\";\nimport { catalogService } from \"@/features/catalog/services/catalog.service\";\nimport { ordersService } from \"@/features/orders/services/orders.service\";\nimport { usePaymentMethods } from \"@/features/billing/hooks/useBilling\";\nimport { usePaymentRefresh } from \"@/features/billing/hooks/usePaymentRefresh\";\nimport type {\n CheckoutState,\n OrderItem,\n InternetPlan,\n InternetAddon,\n InternetInstallation,\n SimPlan,\n SimAddon,\n SimActivationFee,\n} from \"@/shared/types/catalog.types\";\nimport {\n buildInternetOrderItems,\n buildSimOrderItems,\n calculateTotals,\n buildOrderSKUs,\n} from \"@/shared/types/catalog.types\";\n\nexport interface Address {\n street: string | null;\n streetLine2: string | null;\n city: string | null;\n state: string | null;\n postalCode: string | null;\n country: string | null;\n}\n\nexport function useCheckout() {\n const params = useSearchParams();\n const router = useRouter();\n\n const [submitting, setSubmitting] = useState(false);\n const [addressConfirmed, setAddressConfirmed] = useState(false);\n const [confirmedAddress, setConfirmedAddress] = useState
(null);\n\n const [checkoutState, setCheckoutState] = useState({\n loading: true,\n error: null,\n orderItems: [],\n totals: { monthlyTotal: 0, oneTimeTotal: 0 },\n });\n\n const {\n data: paymentMethods,\n isLoading: paymentMethodsLoading,\n error: paymentMethodsError,\n refetch: refetchPaymentMethods,\n } = usePaymentMethods();\n\n const paymentRefresh = usePaymentRefresh({\n refetch: refetchPaymentMethods,\n hasMethods: (data?: { totalCount?: number }) => !!data && (data.totalCount || 0) > 0,\n attachFocusListeners: true,\n });\n\n const orderType = useMemo(() => {\n const type = params.get(\"type\") || \"internet\";\n switch (type.toLowerCase()) {\n case \"sim\":\n return \"SIM\" as const;\n case \"internet\":\n return \"Internet\" as const;\n case \"vpn\":\n return \"VPN\" as const;\n default:\n return \"Other\" as const;\n }\n }, [params]);\n\n const selections = useMemo(() => {\n const obj: Record = {};\n params.forEach((v, k) => {\n if (k !== \"type\") obj[k] = v;\n });\n return obj;\n }, [params]);\n\n useEffect(() => {\n let mounted = true;\n void (async () => {\n try {\n setCheckoutState(prev => ({ ...prev, loading: true, error: null }));\n\n if (!selections.plan) {\n throw new Error(\"No plan selected. Please go back and select a plan.\");\n }\n\n let orderItems: OrderItem[] = [];\n\n if (orderType === \"Internet\") {\n const [plans, addons, installations] = await Promise.all([\n catalogService.getInternetPlans(),\n catalogService.getInternetAddons(),\n catalogService.getInternetInstallations(),\n ]);\n\n const plan = plans.find(p => p.sku === selections.plan);\n if (!plan) {\n throw new Error(\n `Internet plan not found for SKU: ${selections.plan}. Please go back and select a valid plan.`\n );\n }\n\n const addonSkus: string[] = [];\n const urlParams = new URLSearchParams(window.location.search);\n urlParams.getAll(\"addonSku\").forEach(sku => {\n if (sku && !addonSkus.includes(sku)) addonSkus.push(sku);\n });\n\n orderItems = buildInternetOrderItems(plan, addons, installations, {\n installationSku: selections.installationSku,\n addonSkus: addonSkus.length > 0 ? addonSkus : undefined,\n });\n } else if (orderType === \"SIM\") {\n const [plans, activationFees, addons] = await Promise.all([\n catalogService.getSimPlans(),\n catalogService.getSimActivationFees(),\n catalogService.getSimAddons(),\n ]);\n\n const plan = plans.find(p => p.sku === selections.plan);\n if (!plan) {\n throw new Error(\n `SIM plan not found for SKU: ${selections.plan}. Please go back and select a valid plan.`\n );\n }\n\n const addonSkus: string[] = [];\n if (selections.addonSku) addonSkus.push(selections.addonSku);\n const urlParams = new URLSearchParams(window.location.search);\n urlParams.getAll(\"addonSku\").forEach(sku => {\n if (sku && !addonSkus.includes(sku)) addonSkus.push(sku);\n });\n\n orderItems = buildSimOrderItems(plan, activationFees, addons, {\n addonSkus: addonSkus.length > 0 ? addonSkus : undefined,\n });\n }\n\n if (mounted) {\n const totals = calculateTotals(orderItems);\n setCheckoutState(prev => ({ ...prev, loading: false, orderItems, totals }));\n }\n } catch (error) {\n if (mounted) {\n setCheckoutState(prev => ({\n ...prev,\n loading: false,\n error: error instanceof Error ? error.message : \"Failed to load checkout data\",\n }));\n }\n }\n })();\n return () => {\n mounted = false;\n };\n }, [orderType, selections]);\n\n const handleSubmitOrder = useCallback(async () => {\n try {\n setSubmitting(true);\n const skus = buildOrderSKUs(checkoutState.orderItems);\n if (!skus || skus.length === 0) {\n throw new Error(\"No products selected for order. Please go back and select products.\");\n }\n\n const configurations: Record = {};\n if (selections.accessMode) configurations.accessMode = selections.accessMode;\n if (selections.simType) configurations.simType = selections.simType;\n if (selections.eid) configurations.eid = selections.eid;\n if (selections.activationType) configurations.activationType = selections.activationType;\n if (selections.scheduledAt) configurations.scheduledAt = selections.scheduledAt;\n if (selections.isMnp) configurations.isMnp = selections.isMnp;\n if (selections.reservationNumber) configurations.mnpNumber = selections.reservationNumber;\n if (selections.expiryDate) configurations.mnpExpiry = selections.expiryDate;\n if (selections.phoneNumber) configurations.mnpPhone = selections.phoneNumber;\n if (selections.mvnoAccountNumber)\n configurations.mvnoAccountNumber = selections.mvnoAccountNumber;\n if (selections.portingLastName) configurations.portingLastName = selections.portingLastName;\n if (selections.portingFirstName)\n configurations.portingFirstName = selections.portingFirstName;\n if (selections.portingLastNameKatakana)\n configurations.portingLastNameKatakana = selections.portingLastNameKatakana;\n if (selections.portingFirstNameKatakana)\n configurations.portingFirstNameKatakana = selections.portingFirstNameKatakana;\n if (selections.portingGender) configurations.portingGender = selections.portingGender;\n if (selections.portingDateOfBirth)\n configurations.portingDateOfBirth = selections.portingDateOfBirth;\n\n if (confirmedAddress) configurations.address = confirmedAddress;\n\n const orderData = {\n orderType,\n skus,\n ...(Object.keys(configurations).length > 0 && { configurations }),\n };\n\n if (orderType === \"SIM\") {\n if (!selections.eid && selections.simType === \"eSIM\") {\n throw new Error(\n \"EID is required for eSIM activation. Please go back and provide your EID.\"\n );\n }\n if (!selections.phoneNumber && !selections.mnpPhone) {\n throw new Error(\n \"Phone number is required for SIM activation. Please go back and provide a phone number.\"\n );\n }\n }\n\n const response = await ordersService.createOrder<{ sfOrderId: string }>(orderData);\n router.push(`/orders/${response.sfOrderId}?status=success`);\n } catch (error) {\n let errorMessage = \"Order submission failed\";\n if (error instanceof Error) errorMessage = error.message;\n setCheckoutState(prev => ({ ...prev, error: errorMessage }));\n } finally {\n setSubmitting(false);\n }\n }, [checkoutState.orderItems, confirmedAddress, orderType, selections, router]);\n\n const confirmAddress = useCallback((address?: Address) => {\n setAddressConfirmed(true);\n setConfirmedAddress(address || null);\n }, []);\n\n const markAddressIncomplete = useCallback(() => {\n setAddressConfirmed(false);\n setConfirmedAddress(null);\n }, []);\n\n const navigateBackToConfigure = useCallback(() => {\n const urlParams = new URLSearchParams(params.toString());\n const reviewStep = orderType === \"Internet\" ? \"4\" : \"5\";\n urlParams.set(\"step\", reviewStep);\n const configureUrl =\n orderType === \"Internet\"\n ? `/catalog/internet/configure?${urlParams.toString()}`\n : `/catalog/sim/configure?${urlParams.toString()}`;\n router.push(configureUrl);\n }, [orderType, params, router]);\n\n return {\n checkoutState,\n submitting,\n orderType,\n addressConfirmed,\n paymentMethods,\n paymentMethodsLoading,\n paymentMethodsError,\n paymentRefresh,\n confirmAddress,\n markAddressIncomplete,\n handleSubmitOrder,\n navigateBackToConfigure,\n } as const;\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/dashboard/components/AccountStatusCard.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/dashboard/components/ActivityFeed.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/dashboard/components/DashboardActivityItem.tsx","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'id' is defined but never used.","line":38,"column":3,"nodeType":null,"messageId":"unusedVar","endLine":38,"endColumn":5}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\"use client\";\n\nimport {\n DocumentTextIcon,\n CheckCircleIcon,\n ServerIcon,\n ChatBubbleLeftRightIcon,\n ExclamationTriangleIcon,\n} from \"@heroicons/react/24/outline\";\nimport { format } from \"date-fns\";\nimport { cn } from \"@/lib/utils\";\nimport { getActivityIconGradient, formatActivityDate } from \"../utils/dashboard.utils\";\nimport type { Activity } from \"@customer-portal/shared\";\n\nexport interface DashboardActivityItemProps {\n id: string | number;\n type: Activity[\"type\"];\n title: string;\n description: string;\n date: string;\n onClick?: () => void;\n className?: string;\n showRelativeTime?: boolean;\n}\n\nconst ACTIVITY_ICONS: Record<\n Activity[\"type\"],\n React.ComponentType>\n> = {\n invoice_created: DocumentTextIcon,\n invoice_paid: CheckCircleIcon,\n service_activated: ServerIcon,\n case_created: ChatBubbleLeftRightIcon,\n case_closed: CheckCircleIcon,\n};\n\nexport function DashboardActivityItem({\n id,\n type,\n title,\n description,\n date,\n onClick,\n className,\n showRelativeTime = true,\n}: DashboardActivityItemProps) {\n const Icon = ACTIVITY_ICONS[type] || ExclamationTriangleIcon;\n const gradient = getActivityIconGradient(type);\n\n const formattedDate = showRelativeTime\n ? formatActivityDate(date)\n : format(new Date(date), \"MMM d, yyyy Ā· h:mm a\");\n\n const Wrapper = onClick ? \"button\" : \"div\";\n\n return (\n \n
\n \n \n
\n \n\n
\n \n {title}\n

\n\n {description &&

{description}

}\n\n

\n \n

\n
\n\n {onClick && (\n
\n \n \n \n
\n )}\n \n );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/dashboard/components/QuickAction.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/dashboard/components/StatCard.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/dashboard/components/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/dashboard/hooks/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/dashboard/hooks/useDashboardSummary.ts","messages":[{"ruleId":"@typescript-eslint/only-throw-error","severity":2,"message":"Expected an error object to be thrown.","line":30,"column":15,"nodeType":"TSAsExpression","messageId":"object","endLine":33,"endColumn":28},{"ruleId":"@typescript-eslint/only-throw-error","severity":2,"message":"Expected an error object to be thrown.","line":41,"column":17,"nodeType":"TSAsExpression","messageId":"object","endLine":45,"endColumn":30},{"ruleId":"@typescript-eslint/only-throw-error","severity":2,"message":"Expected an error object to be thrown.","line":48,"column":15,"nodeType":"TSAsExpression","messageId":"object","endLine":52,"endColumn":28}],"suppressedMessages":[],"errorCount":3,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Dashboard Summary Hook\n * Provides dashboard data with proper error handling, caching, and loading states\n */\n\nimport { useQuery, useQueryClient } from \"@tanstack/react-query\";\nimport { useAuthStore } from \"@/lib/auth/store\";\nimport { dashboardService } from \"../services/dashboard.service\";\nimport type { DashboardSummary, DashboardError } from \"../types/dashboard.types\";\n\n// Query key factory for dashboard queries\nexport const dashboardQueryKeys = {\n all: [\"dashboard\"] as const,\n summary: () => [...dashboardQueryKeys.all, \"summary\"] as const,\n stats: () => [...dashboardQueryKeys.all, \"stats\"] as const,\n activity: (filters?: string[]) => [...dashboardQueryKeys.all, \"activity\", filters] as const,\n nextInvoice: () => [...dashboardQueryKeys.all, \"next-invoice\"] as const,\n};\n\n/**\n * Hook for fetching dashboard summary data\n */\nexport function useDashboardSummary() {\n const { isAuthenticated, token } = useAuthStore();\n\n return useQuery({\n queryKey: dashboardQueryKeys.summary(),\n queryFn: async () => {\n if (!token) {\n throw {\n code: \"AUTHENTICATION_REQUIRED\",\n message: \"Authentication required to fetch dashboard data\",\n } as DashboardError;\n }\n\n try {\n return await dashboardService.getSummary();\n } catch (error) {\n // Transform API errors to DashboardError format\n if (error instanceof Error) {\n throw {\n code: \"FETCH_ERROR\",\n message: error.message,\n details: { originalError: error },\n } as DashboardError;\n }\n\n throw {\n code: \"UNKNOWN_ERROR\",\n message: \"An unexpected error occurred while fetching dashboard data\",\n details: { originalError: error },\n } as DashboardError;\n }\n },\n staleTime: 2 * 60 * 1000, // 2 minutes\n gcTime: 5 * 60 * 1000, // 5 minutes (formerly cacheTime)\n enabled: isAuthenticated && !!token,\n retry: (failureCount, error) => {\n // Don't retry authentication errors\n if (error?.code === \"AUTHENTICATION_REQUIRED\") {\n return false;\n }\n // Retry up to 3 times for other errors\n return failureCount < 3;\n },\n retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), // Exponential backoff\n });\n}\n\n/**\n * Hook for refreshing dashboard data\n */\nexport function useRefreshDashboard() {\n const queryClient = useQueryClient();\n const { isAuthenticated, token } = useAuthStore();\n\n const refreshDashboard = async () => {\n if (!isAuthenticated || !token) {\n throw new Error(\"Authentication required\");\n }\n\n // Invalidate and refetch dashboard queries\n await queryClient.invalidateQueries({\n queryKey: dashboardQueryKeys.all,\n });\n\n // Optionally fetch fresh data immediately\n return queryClient.fetchQuery({\n queryKey: dashboardQueryKeys.summary(),\n queryFn: () => dashboardService.refreshSummary(),\n });\n };\n\n return { refreshDashboard };\n}\n\n/**\n * Hook for fetching dashboard stats only (lightweight)\n */\nexport function useDashboardStats() {\n const { isAuthenticated, token } = useAuthStore();\n\n return useQuery({\n queryKey: dashboardQueryKeys.stats(),\n queryFn: async () => {\n if (!token) {\n throw new Error(\"Authentication required\");\n }\n return dashboardService.getStats();\n },\n staleTime: 1 * 60 * 1000, // 1 minute\n gcTime: 3 * 60 * 1000, // 3 minutes\n enabled: isAuthenticated && !!token,\n });\n}\n\n/**\n * Hook for fetching recent activity with filtering\n */\nexport function useDashboardActivity(filters?: string[], limit = 10) {\n const { isAuthenticated, token } = useAuthStore();\n\n return useQuery({\n queryKey: dashboardQueryKeys.activity(filters),\n queryFn: async () => {\n if (!token) {\n throw new Error(\"Authentication required\");\n }\n return dashboardService.getRecentActivity(limit, filters);\n },\n staleTime: 30 * 1000, // 30 seconds\n gcTime: 2 * 60 * 1000, // 2 minutes\n enabled: isAuthenticated && !!token,\n });\n}\n\n/**\n * Hook for fetching next invoice information\n */\nexport function useNextInvoice() {\n const { isAuthenticated, token } = useAuthStore();\n\n return useQuery({\n queryKey: dashboardQueryKeys.nextInvoice(),\n queryFn: async () => {\n if (!token) {\n throw new Error(\"Authentication required\");\n }\n return dashboardService.getNextInvoice();\n },\n staleTime: 5 * 60 * 1000, // 5 minutes\n gcTime: 10 * 60 * 1000, // 10 minutes\n enabled: isAuthenticated && !!token,\n });\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/dashboard/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/dashboard/services/dashboard.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/dashboard/services/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/dashboard/stores/dashboard.store.ts","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'get' is defined but never used.","line":51,"column":11,"nodeType":null,"messageId":"unusedVar","endLine":51,"endColumn":14}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Dashboard Store\n * Local state management for dashboard UI state and preferences\n */\n\nimport { create } from \"zustand\";\nimport { persist } from \"zustand/middleware\";\nimport type { ActivityFilter } from \"../types/dashboard.types\";\n\ninterface DashboardUIState {\n // Activity filter state\n activityFilter: ActivityFilter;\n setActivityFilter: (filter: ActivityFilter) => void;\n\n // Dashboard preferences\n preferences: {\n showWelcomeMessage: boolean;\n compactView: boolean;\n autoRefresh: boolean;\n refreshInterval: number; // in seconds\n };\n updatePreferences: (preferences: Partial) => void;\n\n // UI state\n isRefreshing: boolean;\n setRefreshing: (refreshing: boolean) => void;\n\n // Error handling\n dismissedErrors: string[];\n dismissError: (errorId: string) => void;\n clearDismissedErrors: () => void;\n\n // Reset all state\n reset: () => void;\n}\n\nconst initialState = {\n activityFilter: \"all\" as ActivityFilter,\n preferences: {\n showWelcomeMessage: true,\n compactView: false,\n autoRefresh: false,\n refreshInterval: 300, // 5 minutes\n },\n isRefreshing: false,\n dismissedErrors: [],\n};\n\nexport const useDashboardStore = create()(\n persist(\n (set, get) => ({\n ...initialState,\n\n setActivityFilter: filter => {\n set({ activityFilter: filter });\n },\n\n updatePreferences: newPreferences => {\n set(state => ({\n preferences: {\n ...state.preferences,\n ...newPreferences,\n },\n }));\n },\n\n setRefreshing: refreshing => {\n set({ isRefreshing: refreshing });\n },\n\n dismissError: errorId => {\n set(state => ({\n dismissedErrors: [...state.dismissedErrors, errorId],\n }));\n },\n\n clearDismissedErrors: () => {\n set({ dismissedErrors: [] });\n },\n\n reset: () => {\n set(initialState);\n },\n }),\n {\n name: \"dashboard-store\",\n // Only persist preferences and dismissed errors\n partialize: state => ({\n preferences: state.preferences,\n dismissedErrors: state.dismissedErrors,\n }),\n }\n )\n);\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/dashboard/stores/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/dashboard/types/dashboard.types.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/dashboard/types/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/dashboard/utils/dashboard.utils.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/dashboard/utils/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/orders/containers/OrderDetail.tsx","messages":[{"ruleId":"react/no-unescaped-entities","severity":2,"message":"`'` can be escaped with `'`, `‘`, `'`, `’`.","line":191,"column":22,"nodeType":"JSXText","messageId":"unescapedEntityAlts","suggestions":[{"messageId":"replaceWithAlt","data":{"alt":"'"},"fix":{"range":[5645,5820],"text":"\n Your order has been created and submitted for processing. We will notify you as soon\n as it's approved and ready for activation.\n "},"desc":"Replace with `'`."},{"messageId":"replaceWithAlt","data":{"alt":"‘"},"fix":{"range":[5645,5820],"text":"\n Your order has been created and submitted for processing. We will notify you as soon\n as it‘s approved and ready for activation.\n "},"desc":"Replace with `‘`."},{"messageId":"replaceWithAlt","data":{"alt":"'"},"fix":{"range":[5645,5820],"text":"\n Your order has been created and submitted for processing. We will notify you as soon\n as it's approved and ready for activation.\n "},"desc":"Replace with `'`."},{"messageId":"replaceWithAlt","data":{"alt":"’"},"fix":{"range":[5645,5820],"text":"\n Your order has been created and submitted for processing. We will notify you as soon\n as it’s approved and ready for activation.\n "},"desc":"Replace with `’`."}]},{"ruleId":"react/no-unescaped-entities","severity":2,"message":"`'` can be escaped with `'`, `‘`, `'`, `’`.","line":199,"column":26,"nodeType":"JSXText","messageId":"unescapedEntityAlts","suggestions":[{"messageId":"replaceWithAlt","data":{"alt":"'"},"fix":{"range":[6167,6217],"text":"You'll receive an email confirmation once approved"},"desc":"Replace with `'`."},{"messageId":"replaceWithAlt","data":{"alt":"‘"},"fix":{"range":[6167,6217],"text":"You‘ll receive an email confirmation once approved"},"desc":"Replace with `‘`."},{"messageId":"replaceWithAlt","data":{"alt":"'"},"fix":{"range":[6167,6217],"text":"You'll receive an email confirmation once approved"},"desc":"Replace with `'`."},{"messageId":"replaceWithAlt","data":{"alt":"’"},"fix":{"range":[6167,6217],"text":"You’ll receive an email confirmation once approved"},"desc":"Replace with `’`."}]}],"suppressedMessages":[],"errorCount":2,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { useParams, useSearchParams } from \"next/navigation\";\nimport { PageLayout } from \"@/components/layout/PageLayout\";\nimport {\n ClipboardDocumentCheckIcon,\n CheckCircleIcon,\n WifiIcon,\n DevicePhoneMobileIcon,\n LockClosedIcon,\n CubeIcon,\n} from \"@heroicons/react/24/outline\";\nimport { SubCard } from \"@/components/ui/sub-card\";\nimport { StatusPill } from \"@/components/ui/status-pill\";\nimport { ordersService } from \"@/features/orders/services/orders.service\";\n\ninterface OrderItem {\n id: string;\n quantity: number;\n unitPrice: number;\n totalPrice: number;\n product: {\n id: string;\n name: string;\n sku: string;\n whmcsProductId?: string;\n itemClass: string;\n billingCycle: string;\n };\n}\n\ninterface StatusInfo {\n label: string;\n color: string;\n bgColor: string;\n description: string;\n nextAction?: string;\n timeline?: string;\n}\n\ninterface OrderSummary {\n id: string;\n orderNumber?: string;\n status: string;\n orderType?: string;\n effectiveDate?: string;\n totalAmount?: number;\n accountName?: string;\n createdDate: string;\n lastModifiedDate: string;\n activationType?: string;\n activationStatus?: string;\n scheduledAt?: string;\n whmcsOrderId?: string;\n items?: OrderItem[];\n}\n\nconst getDetailedStatusInfo = (\n status: string,\n activationStatus?: string,\n activationType?: string,\n scheduledAt?: string\n): StatusInfo => {\n if (activationStatus === \"Activated\") {\n return {\n label: \"Service Active\",\n color: \"text-green-800\",\n bgColor: \"bg-green-50 border-green-200\",\n description: \"Your service is active and ready to use\",\n timeline: \"Service activated successfully\",\n };\n }\n if (status === \"Draft\" || status === \"Pending Review\") {\n return {\n label: \"Under Review\",\n color: \"text-blue-800\",\n bgColor: \"bg-blue-50 border-blue-200\",\n description: \"Our team is reviewing your order details\",\n nextAction: \"We will contact you within 1 business day with next steps\",\n timeline: \"Review typically takes 1 business day\",\n };\n }\n if (activationStatus === \"Scheduled\") {\n const scheduledDate = scheduledAt\n ? new Date(scheduledAt).toLocaleDateString(\"en-US\", {\n weekday: \"long\",\n month: \"long\",\n day: \"numeric\",\n })\n : \"soon\";\n return {\n label: \"Installation Scheduled\",\n color: \"text-orange-800\",\n bgColor: \"bg-orange-50 border-orange-200\",\n description: \"Your installation has been scheduled\",\n nextAction: `Installation scheduled for ${scheduledDate}`,\n timeline: \"Please be available during the scheduled time\",\n };\n }\n if (activationStatus === \"Activating\") {\n return {\n label: \"Setting Up Service\",\n color: \"text-purple-800\",\n bgColor: \"bg-purple-50 border-purple-200\",\n description: \"We're configuring your service\",\n nextAction: \"Installation team will contact you to schedule\",\n timeline: \"Setup typically takes 3-5 business days\",\n };\n }\n return {\n label: status || \"Processing\",\n color: \"text-gray-800\",\n bgColor: \"bg-gray-50 border-gray-200\",\n description: \"Your order is being processed\",\n timeline: \"We will update you as progress is made\",\n };\n};\n\nconst getOrderTypeIcon = (orderType?: string) => {\n switch (orderType) {\n case \"Internet\":\n return ;\n case \"SIM\":\n return ;\n case \"VPN\":\n return ;\n default:\n return ;\n }\n};\n\nconst calculateDetailedTotals = (items: OrderItem[]) => {\n let monthlyTotal = 0;\n let oneTimeTotal = 0;\n items.forEach(item => {\n if (item.product.billingCycle === \"Monthly\") monthlyTotal += item.totalPrice || 0;\n else oneTimeTotal += item.totalPrice || 0;\n });\n return { monthlyTotal, oneTimeTotal };\n};\n\nexport function OrderDetailContainer() {\n const params = useParams<{ id: string }>();\n const searchParams = useSearchParams();\n const [data, setData] = useState(null);\n const [error, setError] = useState(null);\n const isNewOrder = searchParams.get(\"status\") === \"success\";\n\n useEffect(() => {\n let mounted = true;\n const fetchStatus = async () => {\n try {\n const order = await ordersService.getOrderById(params.id);\n if (mounted) setData(order || null);\n } catch (e) {\n if (mounted) setError(e instanceof Error ? e.message : \"Failed to load order\");\n }\n };\n void fetchStatus();\n const interval = setInterval(() => {\n void fetchStatus();\n }, 5000);\n return () => {\n mounted = false;\n clearInterval(interval);\n };\n }, [params.id]);\n\n return (\n }\n title={data ? `${data.orderType} Service Order` : \"Order Details\"}\n description={\n data\n ? `Order #${data.orderNumber || String(data.id).slice(-8)}`\n : \"Loading order details...\"\n }\n >\n {error &&
{error}
}\n {isNewOrder && (\n
\n
\n \n
\n

\n Order Submitted Successfully!\n

\n

\n Your order has been created and submitted for processing. We will notify you as soon\n as it's approved and ready for activation.\n

\n
\n

\n What happens next:\n

\n
    \n
  • Our team will review your order (within 1 business day)
  • \n
  • You'll receive an email confirmation once approved
  • \n
  • We will schedule activation based on your preferences
  • \n
  • This page will update automatically as your order progresses
  • \n
\n
\n
\n
\n
\n )}\n {data &&\n (() => {\n const statusInfo = getDetailedStatusInfo(\n data.status,\n data.activationStatus,\n data.activationType,\n data.scheduledAt\n );\n const statusVariant = statusInfo.label.includes(\"Active\")\n ? \"success\"\n : statusInfo.label.includes(\"Review\") ||\n statusInfo.label.includes(\"Setting Up\") ||\n statusInfo.label.includes(\"Scheduled\")\n ? \"info\"\n : \"neutral\";\n return (\n Status}\n >\n
\n
{statusInfo.description}
\n \n
\n {statusInfo.nextAction && (\n
\n
\n \n \n \n Next Steps\n
\n

{statusInfo.nextAction}

\n
\n )}\n {statusInfo.timeline && (\n
{statusInfo.timeline}
\n )}\n \n );\n })()}\n {data && (\n \n {getOrderTypeIcon(data.orderType)}\n

Order Items

\n \n }\n >\n {!data.items || data.items.length === 0 ? (\n
No items on this order.
\n ) : (\n
\n {data.items.map(item => (\n \n
\n
{item.product.name}
\n
SKU: {item.product.sku}
\n
{item.product.billingCycle}
\n
\n
\n
Qty: {item.quantity}
\n
\n Ā„{(item.totalPrice || 0).toLocaleString()}\n
\n
\n
\n ))}\n {(() => {\n const totals = calculateDetailedTotals(data.items || []);\n return (\n
\n
\n
\n Ā„{totals.monthlyTotal.toLocaleString()}{\" \"}\n /mo\n
\n {totals.oneTimeTotal > 0 && (\n
\n Ā„{totals.oneTimeTotal.toLocaleString()}{\" \"}\n one-time\n
\n )}\n
\n
\n );\n })()}\n \n )}\n \n )}\n \n );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/orders/containers/OrdersList.tsx","messages":[{"ruleId":"react/no-unescaped-entities","severity":2,"message":"`'` can be escaped with `'`, `‘`, `'`, `’`.","line":192,"column":54,"nodeType":"JSXText","messageId":"unescapedEntityAlts","suggestions":[{"messageId":"replaceWithAlt","data":{"alt":"'"},"fix":{"range":[6072,6106],"text":"You haven't placed any orders yet."},"desc":"Replace with `'`."},{"messageId":"replaceWithAlt","data":{"alt":"‘"},"fix":{"range":[6072,6106],"text":"You haven‘t placed any orders yet."},"desc":"Replace with `‘`."},{"messageId":"replaceWithAlt","data":{"alt":"'"},"fix":{"range":[6072,6106],"text":"You haven't placed any orders yet."},"desc":"Replace with `'`."},{"messageId":"replaceWithAlt","data":{"alt":"’"},"fix":{"range":[6072,6106],"text":"You haven’t placed any orders yet."},"desc":"Replace with `’`."}]}],"suppressedMessages":[],"errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\"use client\";\n\nimport { useEffect, useState, Suspense } from \"react\";\nimport { useRouter, useSearchParams } from \"next/navigation\";\nimport { PageLayout } from \"@/components/layout/PageLayout\";\nimport {\n ClipboardDocumentListIcon,\n CheckCircleIcon,\n WifiIcon,\n DevicePhoneMobileIcon,\n LockClosedIcon,\n CubeIcon,\n} from \"@heroicons/react/24/outline\";\nimport { StatusPill } from \"@/components/ui/status-pill\";\nimport { LoadingSpinner } from \"@/components/ui/loading-spinner\";\nimport { AnimatedCard } from \"@/components/ui\";\nimport { ordersService } from \"@/features/orders/services/orders.service\";\n\ninterface OrderSummary {\n id: string | number;\n orderNumber?: string;\n status: string;\n orderType?: string;\n effectiveDate?: string;\n totalAmount?: number;\n createdDate: string;\n activationStatus?: string;\n itemSummary?: string;\n itemsSummary?: Array<{\n name?: string;\n sku?: string;\n itemClass?: string;\n quantity: number;\n unitPrice?: number;\n totalPrice?: number;\n billingCycle?: string;\n }>;\n}\n\ninterface StatusInfo {\n label: string;\n color: string;\n bgColor: string;\n description: string;\n nextAction?: string;\n}\n\nfunction OrdersSuccessBanner() {\n const searchParams = useSearchParams();\n const showSuccess = searchParams.get(\"status\") === \"success\";\n if (!showSuccess) return null;\n return (\n
\n
\n \n
\n

\n Order Submitted Successfully!\n

\n

\n Your order has been created and is now being processed. You can track its progress\n below.\n

\n
\n
\n
\n );\n}\n\nexport function OrdersListContainer() {\n const router = useRouter();\n const [orders, setOrders] = useState([]);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState(null);\n\n useEffect(() => {\n const fetchOrders = async () => {\n try {\n const list = await ordersService.getMyOrders();\n setOrders(list);\n } catch (e) {\n setError(e instanceof Error ? e.message : \"Failed to load orders\");\n } finally {\n setLoading(false);\n }\n };\n void fetchOrders();\n }, []);\n\n const getStatusInfo = (status: string, activationStatus?: string): StatusInfo => {\n if (activationStatus === \"Activated\") {\n return {\n label: \"Active\",\n color: \"text-green-800\",\n bgColor: \"bg-green-100\",\n description: \"Your service is active and ready to use\",\n };\n }\n if (status === \"Draft\" || status === \"Pending Review\") {\n return {\n label: \"Under Review\",\n color: \"text-blue-800\",\n bgColor: \"bg-blue-100\",\n description: \"We're reviewing your order\",\n nextAction: \"We'll contact you within 1 business day\",\n };\n }\n if (activationStatus === \"Activating\") {\n return {\n label: \"Setting Up\",\n color: \"text-orange-800\",\n bgColor: \"bg-orange-100\",\n description: \"We're preparing your service\",\n nextAction: \"Installation will be scheduled soon\",\n };\n }\n return {\n label: status || \"Processing\",\n color: \"text-gray-800\",\n bgColor: \"bg-gray-100\",\n description: \"Order is being processed\",\n };\n };\n\n const getServiceTypeDisplay = (orderType?: string) => {\n switch (orderType) {\n case \"Internet\":\n return { icon: , label: \"Internet Service\" };\n case \"SIM\":\n return { icon: , label: \"Mobile Service\" };\n case \"VPN\":\n return { icon: , label: \"VPN Service\" };\n default:\n return { icon: , label: \"Service\" };\n }\n };\n\n const getServiceSummary = (order: OrderSummary) => {\n if (order.itemsSummary && order.itemsSummary.length > 0) {\n const mainItem = order.itemsSummary[0];\n const additionalCount = order.itemsSummary.length - 1;\n let summary = mainItem.name || \"Service\";\n if (additionalCount > 0) summary += ` +${additionalCount} more`;\n return summary;\n }\n return order.itemSummary || \"Service package\";\n };\n\n const calculateOrderTotals = (order: OrderSummary) => {\n let monthlyTotal = 0;\n let oneTimeTotal = 0;\n if (order.itemsSummary && order.itemsSummary.length > 0) {\n order.itemsSummary.forEach(item => {\n const totalPrice = item.totalPrice || 0;\n const billingCycle = item.billingCycle?.toLowerCase() || \"\";\n if (billingCycle === \"monthly\") monthlyTotal += totalPrice;\n else oneTimeTotal += totalPrice;\n });\n } else {\n monthlyTotal = order.totalAmount || 0;\n }\n return { monthlyTotal, oneTimeTotal } as const;\n };\n\n return (\n }\n title=\"My Orders\"\n description=\"View and track all your orders\"\n >\n \n \n \n\n {error && (\n
\n

{error}

\n
\n )}\n\n {loading ? (\n
\n
\n \n

Loading your orders...

\n
\n
\n ) : orders.length === 0 ? (\n \n \n

No orders yet

\n

You haven't placed any orders yet.

\n router.push(\"/catalog\")}\n className=\"bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors\"\n >\n Browse Catalog\n \n
\n ) : (\n
\n {orders.map(order => {\n const statusInfo = getStatusInfo(order.status, order.activationStatus);\n const serviceType = getServiceTypeDisplay(order.orderType);\n const serviceSummary = getServiceSummary(order);\n return (\n router.push(`/orders/${order.id}`)}\n >\n
\n
\n
{serviceType.icon}
\n
\n

\n {serviceType.label}\n

\n

\n Order #{order.orderNumber || String(order.id).slice(-8)} •{\" \"}\n {new Date(order.createdDate).toLocaleDateString(\"en-US\", {\n month: \"short\",\n day: \"numeric\",\n year: \"numeric\",\n })}\n

\n
\n
\n
\n \n
\n
\n
\n
\n
\n

{serviceSummary}

\n

{statusInfo.description}

\n {statusInfo.nextAction && (\n

\n {statusInfo.nextAction}\n

\n )}\n
\n {(() => {\n const totals = calculateOrderTotals(order);\n if (totals.monthlyTotal <= 0 && totals.oneTimeTotal <= 0) return null;\n return (\n
\n
\n

\n Ā„{totals.monthlyTotal.toLocaleString()}\n

\n

per month

\n {totals.oneTimeTotal > 0 && (\n <>\n

\n Ā„{totals.oneTimeTotal.toLocaleString()}\n

\n

one-time

\n \n )}\n
\n
\n

* Additional fees may apply

\n

(e.g., weekend installation)

\n
\n
\n );\n })()}\n
\n
\n
\n Click to view details\n \n \n \n
\n \n );\n })}\n
\n )}\n \n );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/orders/services/orders.service.ts","messages":[{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":9,"column":25,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":9,"endColumn":28,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[176,179],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[176,179],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":14,"column":26,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":14,"endColumn":29,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[323,326],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[323,326],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":20,"column":65,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":20,"endColumn":68,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[587,590],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[587,590],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]}],"suppressedMessages":[],"errorCount":3,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Orders Service\n * Centralized methods for orders API operations\n */\n\nimport { apiClient } from \"@/lib/api/client\";\n\nexport class OrdersService {\n async getMyOrders(): Promise {\n const res = await apiClient.get(\"/orders/user\");\n return (res.data as T[]) || [];\n }\n\n async getOrderById(id: string): Promise {\n const res = await apiClient.get(`/orders/${id}`);\n return res.data as T;\n }\n\n async createOrder(orderData: unknown): Promise {\n const res = await apiClient.post(\"/orders\", orderData as any);\n return res.data as T;\n }\n}\n\nexport const ordersService = new OrdersService();\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/service-management/components/ServiceManagementSection.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/service-management/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/sim-management/components/DataUsageChart.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/sim-management/components/SimActions.tsx","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'activeInfo' is assigned a value but never used.","line":57,"column":12,"nodeType":null,"messageId":"unusedVar","endLine":57,"endColumn":22}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\"use client\";\n\nimport React, { useState, forwardRef } from \"react\";\nimport { useRouter } from \"next/navigation\";\nimport {\n PlusIcon,\n ArrowPathIcon,\n XMarkIcon,\n ExclamationTriangleIcon,\n CheckCircleIcon,\n Cog6ToothIcon,\n} from \"@heroicons/react/24/outline\";\nimport { Button } from \"@/components/ui/button\";\nimport { SubCard } from \"@/components/ui/sub-card\";\nimport { TopUpModal } from \"./TopUpModal\";\nimport { ChangePlanModal } from \"./ChangePlanModal\";\nimport { simActionsService } from \"@/features/subscriptions/services/sim-actions.service\";\nimport { cn } from \"@/lib/utils\";\n\ninterface SimActionsProps {\n subscriptionId: number;\n simType: \"physical\" | \"esim\";\n status: string;\n onTopUpSuccess?: () => void;\n onPlanChangeSuccess?: () => void;\n onCancelSuccess?: () => void;\n onReissueSuccess?: () => void;\n embedded?: boolean; // when true, render content without card container\n currentPlanCode?: string;\n className?: string;\n}\n\nexport const SimActions = forwardRef(\n (\n {\n subscriptionId,\n simType,\n status,\n onTopUpSuccess,\n onPlanChangeSuccess,\n onCancelSuccess,\n onReissueSuccess,\n embedded = false,\n currentPlanCode,\n className,\n },\n ref\n ) => {\n const router = useRouter();\n const [showTopUpModal, setShowTopUpModal] = useState(false);\n const [showCancelConfirm, setShowCancelConfirm] = useState(false);\n const [showReissueConfirm, setShowReissueConfirm] = useState(false);\n const [loading, setLoading] = useState(null);\n const [error, setError] = useState(null);\n const [success, setSuccess] = useState(null);\n const [showChangePlanModal, setShowChangePlanModal] = useState(false);\n const [activeInfo, setActiveInfo] = useState<\n \"topup\" | \"reissue\" | \"cancel\" | \"changePlan\" | null\n >(null);\n\n const isActive = status === \"active\";\n const canTopUp = isActive;\n const canReissue = isActive && simType === \"esim\";\n const canCancel = isActive;\n\n const handleReissueEsim = async () => {\n setLoading(\"reissue\");\n setError(null);\n\n try {\n await simActionsService.reissueEsim(subscriptionId);\n\n setSuccess(\"eSIM profile reissued successfully\");\n setShowReissueConfirm(false);\n onReissueSuccess?.();\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : \"Failed to reissue eSIM profile\");\n } finally {\n setLoading(null);\n }\n };\n\n const handleCancelSim = async () => {\n setLoading(\"cancel\");\n setError(null);\n\n try {\n await simActionsService.cancel(subscriptionId, {});\n\n setSuccess(\"SIM service cancelled successfully\");\n setShowCancelConfirm(false);\n onCancelSuccess?.();\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : \"Failed to cancel SIM service\");\n } finally {\n setLoading(null);\n }\n };\n\n // Clear success/error messages after 5 seconds\n React.useEffect(() => {\n if (success || error) {\n const timer = setTimeout(() => {\n setSuccess(null);\n setError(null);\n }, 5000);\n return () => clearTimeout(timer);\n }\n return;\n }, [success, error]);\n\n const content = (\n <>\n {/* Header */}\n {!embedded && (\n
\n
\n \n
\n
\n

SIM Management Actions

\n

Manage your SIM service

\n
\n
\n )}\n {/* Status Messages */}\n {success && (\n
\n
\n \n

{success}

\n
\n
\n )}\n\n {error && (\n
\n
\n \n

{error}

\n
\n
\n )}\n\n {!isActive && (\n
\n
\n \n

\n SIM management actions are only available for active services.\n

\n
\n
\n )}\n\n {/* Action Buttons */}\n
\n {/* Top Up Data - Primary Action */}\n
\n
\n
\n \n
\n
\n
\n

Top Up Data

\n

\n Add additional data quota to your SIM service\n

\n
\n
\n {\n setActiveInfo(\"topup\");\n try {\n router.push(`/subscriptions/${subscriptionId}/sim/top-up`);\n } catch {\n setShowTopUpModal(true);\n }\n }}\n >\n Top Up\n \n
\n
\n\n {/* Reissue eSIM (only for eSIMs) */}\n {simType === \"esim\" && (\n
\n
\n
\n \n
\n
\n
\n

Reissue eSIM

\n

\n Generate a new eSIM profile for download\n

\n
\n
\n {\n setActiveInfo(\"reissue\");\n try {\n router.push(`/subscriptions/${subscriptionId}/sim/reissue`);\n } catch {\n setShowReissueConfirm(true);\n }\n }}\n >\n Reissue\n \n
\n
\n )}\n\n {/* Change Plan - Secondary Action */}\n
\n
\n
\n \n \n \n
\n
\n
\n

Change Plan

\n

Switch to a different data plan

\n
\n
\n {\n setActiveInfo(\"changePlan\");\n try {\n router.push(`/subscriptions/${subscriptionId}/sim/change-plan`);\n } catch {\n setShowChangePlanModal(true);\n }\n }}\n >\n Change Plan\n \n
\n
\n\n {/* Cancel SIM - Destructive Action */}\n
\n
\n
\n \n
\n
\n
\n

Cancel SIM

\n

Permanently cancel your SIM service

\n
\n
\n {\n setActiveInfo(\"cancel\");\n try {\n router.push(`/subscriptions/${subscriptionId}/sim/cancel`);\n } catch {\n setShowCancelConfirm(true);\n }\n }}\n >\n Cancel SIM\n \n
\n
\n
\n \n );\n\n if (embedded) {\n return (\n
\n {content}\n {/* Modals and confirmations */}\n {renderModals()}\n
\n );\n }\n\n return (\n }\n className={cn(\"\", className)}\n >\n {content}\n {/* Modals and confirmations */}\n {renderModals()}\n \n );\n\n function renderModals() {\n return (\n <>\n {/* Top Up Modal */}\n {showTopUpModal && (\n {\n setShowTopUpModal(false);\n setActiveInfo(null);\n }}\n onSuccess={() => {\n setShowTopUpModal(false);\n setSuccess(\"Data top-up completed successfully\");\n onTopUpSuccess?.();\n }}\n onError={message => setError(message)}\n />\n )}\n\n {/* Change Plan Modal */}\n {showChangePlanModal && (\n {\n setShowChangePlanModal(false);\n setActiveInfo(null);\n }}\n onSuccess={() => {\n setShowChangePlanModal(false);\n setSuccess(\"SIM plan change submitted successfully\");\n onPlanChangeSuccess?.();\n }}\n onError={message => setError(message)}\n />\n )}\n\n {/* Reissue eSIM Confirmation */}\n {showReissueConfirm && (\n
\n
\n
\n
\n
\n
\n
\n \n
\n
\n

\n Reissue eSIM Profile\n

\n
\n

\n This will generate a new eSIM profile for download. Your current eSIM\n will remain active until you activate the new profile.\n

\n
\n
\n
\n
\n
\n void handleReissueEsim()}\n disabled={loading === \"reissue\"}\n className=\"w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-green-600 text-base font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50\"\n >\n {loading === \"reissue\" ? \"Processing...\" : \"Reissue eSIM\"}\n \n {\n setShowReissueConfirm(false);\n setActiveInfo(null);\n }}\n disabled={loading === \"reissue\"}\n className=\"mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm\"\n >\n Back\n \n
\n
\n
\n
\n )}\n\n {/* Cancel Confirmation */}\n {showCancelConfirm && (\n
\n
\n
\n
\n
\n
\n
\n \n
\n
\n

\n Cancel SIM Service\n

\n
\n

\n Are you sure you want to cancel this SIM service? This action cannot be\n undone and will permanently terminate your service.\n

\n
\n
\n
\n
\n
\n void handleCancelSim()}\n disabled={loading === \"cancel\"}\n className=\"w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50\"\n >\n {loading === \"cancel\" ? \"Processing...\" : \"Cancel SIM\"}\n \n {\n setShowCancelConfirm(false);\n setActiveInfo(null);\n }}\n disabled={loading === \"cancel\"}\n className=\"mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm\"\n >\n Back\n \n
\n
\n
\n
\n )}\n \n );\n }\n }\n);\n\nSimActions.displayName = \"SimActions\";\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/sim-management/components/SimDetailsCard.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/sim-management/components/SimManagementSection.tsx","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'ExclamationTriangleIcon' is defined but never used.","line":6,"column":3,"nodeType":null,"messageId":"unusedVar","endLine":6,"endColumn":26},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'ArrowPathIcon' is defined but never used.","line":7,"column":3,"nodeType":null,"messageId":"unusedVar","endLine":7,"endColumn":16}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\"use client\";\n\nimport React, { useState, useEffect, useCallback } from \"react\";\nimport {\n DevicePhoneMobileIcon,\n ExclamationTriangleIcon,\n ArrowPathIcon,\n} from \"@heroicons/react/24/outline\";\nimport { SimDetailsCard, type SimDetails } from \"./SimDetailsCard\";\nimport { DataUsageChart, type SimUsage } from \"./DataUsageChart\";\nimport { SimActions } from \"./SimActions\";\nimport { SubCard } from \"@/components/ui/sub-card\";\nimport { LoadingSpinner } from \"@/components/ui/loading-spinner\";\nimport { ErrorState } from \"@/components/ui/error-state\";\nimport { simActionsService } from \"@/features/subscriptions/services/sim-actions.service\";\nimport { SimFeatureToggles } from \"./SimFeatureToggles\";\n\ninterface SimManagementSectionProps {\n subscriptionId: number;\n}\n\ninterface SimInfo {\n details: SimDetails;\n usage: SimUsage;\n}\n\nexport function SimManagementSection({ subscriptionId }: SimManagementSectionProps) {\n const [simInfo, setSimInfo] = useState(null);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState(null);\n\n const fetchSimInfo = useCallback(async () => {\n try {\n setError(null);\n\n const info = await simActionsService.getSimInfo(subscriptionId);\n setSimInfo((info as SimInfo) || null);\n } catch (err: unknown) {\n const hasStatus = (v: unknown): v is { status: number } =>\n typeof v === \"object\" &&\n v !== null &&\n \"status\" in v &&\n typeof (v as { status: unknown }).status === \"number\";\n if (hasStatus(err) && err.status === 400) {\n // Not a SIM subscription - this component shouldn't be shown\n setError(\"This subscription is not a SIM service\");\n } else {\n setError(err instanceof Error ? err.message : \"Failed to load SIM information\");\n }\n } finally {\n setLoading(false);\n }\n }, [subscriptionId]);\n\n useEffect(() => {\n void fetchSimInfo();\n }, [fetchSimInfo]);\n\n const handleRefresh = () => {\n setLoading(true);\n void fetchSimInfo();\n };\n\n const handleActionSuccess = () => {\n // Refresh SIM info after any successful action\n void fetchSimInfo();\n };\n\n if (loading) {\n return (\n
\n }>\n
\n
\n \n

Loading your SIM service details...

\n
\n
\n
\n
\n );\n }\n\n if (error) {\n return (\n }>\n \n \n );\n }\n\n if (!simInfo) {\n return null;\n }\n\n return (\n
\n {/* SIM Details and Usage - Main Content */}\n
\n {/* Main Content Area - Actions and Settings (Left Side) */}\n
\n \n \n
\n

Modify service options

\n \n
\n
\n
\n\n {/* Sidebar - Compact Info (Right Side) */}\n
\n {/* Details + Usage combined card for mobile-first */}\n \n
\n \n \n
\n
\n\n {/* Important Information Card */}\n
\n
\n
\n \n \n \n
\n

Important Information

\n
\n
    \n
  • \n \n Data usage is updated in real-time and may take a few minutes to reflect recent\n activity\n
  • \n
  • \n \n Top-up data will be available immediately after successful processing\n
  • \n
  • \n \n SIM cancellation is permanent and cannot be undone\n
  • \n {simInfo.details.simType === \"esim\" && (\n
  • \n \n eSIM profile reissue will provide a new QR code for activation\n
  • \n )}\n
\n
\n\n {/* (On desktop, details+usage are above; on mobile they appear first since this section is above actions) */}\n
\n
\n
\n );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/sim-management/components/TopUpModal.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/sim-management/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/subscriptions/components/SubscriptionActions.tsx","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'isInternetService' is assigned a value but never used.","line":74,"column":9,"nodeType":null,"messageId":"unusedVar","endLine":74,"endColumn":26},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'isVpnService' is assigned a value but never used.","line":78,"column":9,"nodeType":null,"messageId":"unusedVar","endLine":78,"endColumn":21},{"ruleId":"@typescript-eslint/no-misused-promises","severity":2,"message":"Promise-returning function provided to attribute where a void return was expected.","line":193,"column":25,"nodeType":"JSXExpressionContainer","messageId":"voidReturnAttribute","endLine":193,"endColumn":40},{"ruleId":"@typescript-eslint/no-misused-promises","severity":2,"message":"Promise-returning function provided to attribute where a void return was expected.","line":204,"column":25,"nodeType":"JSXExpressionContainer","messageId":"voidReturnAttribute","endLine":204,"endColumn":39},{"ruleId":"@typescript-eslint/no-misused-promises","severity":2,"message":"Promise-returning function provided to attribute where a void return was expected.","line":269,"column":29,"nodeType":"JSXExpressionContainer","messageId":"voidReturnAttribute","endLine":269,"endColumn":43}],"suppressedMessages":[],"errorCount":3,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\"use client\";\n\nimport { useState } from \"react\";\nimport { useRouter } from \"next/navigation\";\nimport {\n PauseIcon,\n PlayIcon,\n XMarkIcon,\n ArrowUpIcon,\n ArrowDownIcon,\n DocumentTextIcon,\n CreditCardIcon,\n Cog6ToothIcon,\n ExclamationTriangleIcon,\n} from \"@heroicons/react/24/outline\";\nimport { Button } from \"@/components/ui/button\";\nimport { SubCard } from \"@/components/ui/sub-card\";\nimport type { Subscription } from \"@customer-portal/shared\";\nimport { cn } from \"@/lib/utils\";\nimport { useSubscriptionAction } from \"../hooks\";\n\ninterface SubscriptionActionsProps {\n subscription: Subscription;\n onActionSuccess?: () => void;\n className?: string;\n}\n\ninterface ActionButtonProps {\n icon: React.ReactNode;\n label: string;\n description: string;\n variant?: \"default\" | \"destructive\" | \"outline\" | \"secondary\";\n disabled?: boolean;\n onClick: () => void;\n}\n\nconst ActionButton = ({\n icon,\n label,\n description,\n variant = \"outline\",\n disabled,\n onClick,\n}: ActionButtonProps) => (\n
\n
{icon}
\n
\n

{label}

\n

{description}

\n
\n
\n \n
\n
\n);\n\nexport function SubscriptionActions({\n subscription,\n onActionSuccess,\n className,\n}: SubscriptionActionsProps) {\n const router = useRouter();\n const [loading, setLoading] = useState(null);\n const subscriptionAction = useSubscriptionAction();\n\n const isActive = subscription.status === \"Active\";\n const isSuspended = subscription.status === \"Suspended\";\n const isCancelled = subscription.status === \"Cancelled\" || subscription.status === \"Terminated\";\n const isPending = subscription.status === \"Pending\";\n\n const isSimService = subscription.productName.toLowerCase().includes(\"sim\");\n const isInternetService =\n subscription.productName.toLowerCase().includes(\"internet\") ||\n subscription.productName.toLowerCase().includes(\"broadband\") ||\n subscription.productName.toLowerCase().includes(\"fiber\");\n const isVpnService = subscription.productName.toLowerCase().includes(\"vpn\");\n\n const handleSuspend = async () => {\n setLoading(\"suspend\");\n try {\n await subscriptionAction.mutateAsync({\n id: subscription.id,\n action: \"suspend\",\n });\n onActionSuccess?.();\n } catch (error) {\n console.error(\"Failed to suspend subscription:\", error);\n } finally {\n setLoading(null);\n }\n };\n\n const handleResume = async () => {\n setLoading(\"resume\");\n try {\n await subscriptionAction.mutateAsync({\n id: subscription.id,\n action: \"resume\",\n });\n onActionSuccess?.();\n } catch (error) {\n console.error(\"Failed to resume subscription:\", error);\n } finally {\n setLoading(null);\n }\n };\n\n const handleCancel = async () => {\n if (\n !confirm(\"Are you sure you want to cancel this subscription? This action cannot be undone.\")\n ) {\n return;\n }\n\n setLoading(\"cancel\");\n try {\n await subscriptionAction.mutateAsync({\n id: subscription.id,\n action: \"cancel\",\n });\n onActionSuccess?.();\n } catch (error) {\n console.error(\"Failed to cancel subscription:\", error);\n } finally {\n setLoading(null);\n }\n };\n\n const handleUpgrade = () => {\n router.push(`/catalog?upgrade=${subscription.id}`);\n };\n\n const handleDowngrade = () => {\n router.push(`/catalog?downgrade=${subscription.id}`);\n };\n\n const handleViewInvoices = () => {\n router.push(`/subscriptions/${subscription.id}#billing`);\n };\n\n const handleManagePayment = () => {\n router.push(\"/billing/payments\");\n };\n\n const handleSimManagement = () => {\n router.push(`/subscriptions/${subscription.id}#sim-management`);\n };\n\n const handleServiceSettings = () => {\n router.push(`/subscriptions/${subscription.id}/settings`);\n };\n\n return (\n }\n className={cn(\"\", className)}\n >\n
\n {/* Service Management Actions */}\n
\n

Service Management

\n
\n {/* SIM Management - Only for SIM services */}\n {isSimService && isActive && (\n }\n label=\"SIM Management\"\n description=\"Manage data usage, top-up, and SIM settings\"\n onClick={handleSimManagement}\n />\n )}\n\n {/* Service Settings - Available for all active services */}\n {isActive && (\n }\n label=\"Service Settings\"\n description=\"Configure service-specific settings and preferences\"\n onClick={handleServiceSettings}\n />\n )}\n\n {/* Suspend/Resume Actions */}\n {isActive && (\n }\n label=\"Suspend Service\"\n description=\"Temporarily suspend this service\"\n variant=\"outline\"\n onClick={handleSuspend}\n disabled={loading === \"suspend\"}\n />\n )}\n\n {isSuspended && (\n }\n label=\"Resume Service\"\n description=\"Resume suspended service\"\n variant=\"outline\"\n onClick={handleResume}\n disabled={loading === \"resume\"}\n />\n )}\n
\n
\n\n {/* Plan Management Actions */}\n {(isActive || isSuspended) && !subscription.cycle.includes(\"One-time\") && (\n
\n

Plan Management

\n
\n }\n label=\"Upgrade Plan\"\n description=\"Upgrade to a higher tier plan with more features\"\n onClick={handleUpgrade}\n />\n\n }\n label=\"Downgrade Plan\"\n description=\"Switch to a lower tier plan\"\n onClick={handleDowngrade}\n />\n
\n
\n )}\n\n {/* Billing Actions */}\n
\n

Billing & Payment

\n
\n }\n label=\"View Invoices\"\n description=\"View billing history and download invoices\"\n onClick={handleViewInvoices}\n />\n\n }\n label=\"Manage Payment\"\n description=\"Update payment methods and billing information\"\n onClick={handleManagePayment}\n />\n
\n
\n\n {/* Cancellation Actions */}\n {!isCancelled && !isPending && (\n
\n

Cancellation

\n
\n
\n \n
\n
Cancel Subscription
\n

\n Permanently cancel this subscription. This action cannot be undone and you will\n lose access to the service.\n

\n }\n >\n Cancel Subscription\n \n
\n
\n
\n
\n )}\n\n {/* Status Information */}\n {(isCancelled || isPending) && (\n
\n
\n \n
\n {isCancelled ? \"Subscription Cancelled\" : \"Subscription Pending\"}\n
\n
\n

\n {isCancelled\n ? \"This subscription has been cancelled and is no longer active. No further actions are available.\"\n : \"This subscription is pending activation. Actions will be available once the subscription is active.\"}\n

\n
\n )}\n
\n \n );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/subscriptions/components/SubscriptionCard.tsx","messages":[{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'Link' is defined but never used.","line":4,"column":8,"nodeType":null,"messageId":"unusedVar","endLine":4,"endColumn":12},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'ServerIcon' is defined but never used.","line":7,"column":3,"nodeType":null,"messageId":"unusedVar","endLine":7,"endColumn":13}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\"use client\";\n\nimport { forwardRef } from \"react\";\nimport Link from \"next/link\";\nimport { format } from \"date-fns\";\nimport {\n ServerIcon,\n CheckCircleIcon,\n ExclamationTriangleIcon,\n ClockIcon,\n XCircleIcon,\n CalendarIcon,\n ArrowTopRightOnSquareIcon,\n} from \"@heroicons/react/24/outline\";\nimport { StatusPill } from \"@/components/ui/status-pill\";\nimport { Button } from \"@/components/ui/button\";\nimport { SubCard } from \"@/components/ui/sub-card\";\nimport { formatCurrency, getCurrencyLocale } from \"@/utils/currency\";\nimport type { Subscription } from \"@customer-portal/shared\";\nimport { cn } from \"@/lib/utils\";\n\ninterface SubscriptionCardProps {\n subscription: Subscription;\n variant?: \"list\" | \"grid\";\n showActions?: boolean;\n onViewClick?: (subscription: Subscription) => void;\n className?: string;\n}\n\nconst getStatusIcon = (status: string) => {\n switch (status) {\n case \"Active\":\n return ;\n case \"Suspended\":\n return ;\n case \"Pending\":\n return ;\n case \"Cancelled\":\n case \"Terminated\":\n return ;\n default:\n return ;\n }\n};\n\nconst getStatusVariant = (status: string) => {\n switch (status) {\n case \"Active\":\n return \"success\" as const;\n case \"Suspended\":\n return \"warning\" as const;\n case \"Pending\":\n return \"info\" as const;\n case \"Cancelled\":\n case \"Terminated\":\n return \"neutral\" as const;\n default:\n return \"neutral\" as const;\n }\n};\n\nconst formatDate = (dateString: string | undefined) => {\n if (!dateString) return \"N/A\";\n try {\n return format(new Date(dateString), \"MMM d, yyyy\");\n } catch {\n return \"Invalid date\";\n }\n};\n\nconst getBillingCycleLabel = (cycle: string) => {\n const name = cycle.toLowerCase();\n const looksLikeActivation = name.includes(\"activation\") || name.includes(\"setup\");\n return looksLikeActivation ? \"One-time\" : cycle;\n};\n\nexport const SubscriptionCard = forwardRef(\n ({ subscription, variant = \"list\", showActions = true, onViewClick, className }, ref) => {\n const handleViewClick = () => {\n if (onViewClick) {\n onViewClick(subscription);\n }\n };\n\n if (variant === \"grid\") {\n return (\n \n
\n {/* Header */}\n
\n
\n {getStatusIcon(subscription.status)}\n
\n

\n {subscription.productName}\n

\n

Service ID: {subscription.serviceId}

\n
\n
\n \n
\n\n {/* Details */}\n
\n
\n

Price

\n

\n {formatCurrency(subscription.amount, {\n currency: \"JPY\",\n locale: getCurrencyLocale(\"JPY\"),\n })}\n

\n

{getBillingCycleLabel(subscription.cycle)}

\n
\n
\n

Next Due

\n
\n \n

{formatDate(subscription.nextDue)}

\n
\n
\n
\n\n {/* Actions */}\n {showActions && (\n
\n

\n Created {formatDate(subscription.registrationDate)}\n

\n
\n }\n >\n View\n \n
\n
\n )}\n
\n
\n );\n }\n\n // List variant (default)\n return (\n \n
\n
\n {getStatusIcon(subscription.status)}\n
\n
\n

\n {subscription.productName}\n

\n \n
\n

Service ID: {subscription.serviceId}

\n
\n
\n\n
\n
\n

\n {formatCurrency(subscription.amount, {\n currency: \"JPY\",\n locale: getCurrencyLocale(\"JPY\"),\n })}\n

\n

{getBillingCycleLabel(subscription.cycle)}

\n
\n\n
\n
\n \n

{formatDate(subscription.nextDue)}

\n
\n

Next due

\n
\n\n {showActions && (\n
\n }\n >\n View\n \n
\n )}\n
\n
\n
\n );\n }\n);\n\nSubscriptionCard.displayName = \"SubscriptionCard\";\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/subscriptions/components/SubscriptionDetails.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/subscriptions/components/SubscriptionStatusBadge.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/subscriptions/components/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/subscriptions/containers/SimCancel.tsx","messages":[{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":51,"column":69,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":51,"endColumn":72,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[1981,1984],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[1981,1984],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/require-await","severity":2,"message":"Async arrow function 'fetchEmail' has no 'await' expression.","line":61,"column":33,"nodeType":"ArrowFunctionExpression","messageId":"missingAwait","endLine":61,"endColumn":35,"suggestions":[{"messageId":"removeAsync","fix":{"range":[2264,2270],"text":""},"desc":"Remove 'async'."}]}],"suppressedMessages":[],"errorCount":2,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"\"use client\";\n\nimport Link from \"next/link\";\nimport { useParams, useRouter } from \"next/navigation\";\nimport { useEffect, useMemo, useState, type ReactNode } from \"react\";\nimport { simActionsService } from \"@/features/subscriptions/services/sim-actions.service\";\nimport { useAuthStore } from \"@/lib/auth/store\";\nimport type { SimDetails } from \"@/features/sim-management/components/SimDetailsCard\";\n\ntype Step = 1 | 2 | 3;\n\nfunction Notice({ title, children }: { title: string; children: ReactNode }) {\n return (\n
\n
{title}
\n
{children}
\n
\n );\n}\n\nfunction InfoRow({ label, value }: { label: string; value: string }) {\n return (\n
\n
{label}
\n
{value}
\n
\n );\n}\n\nexport function SimCancelContainer() {\n const params = useParams();\n const router = useRouter();\n const subscriptionId = parseInt(params.id as string);\n\n const [step, setStep] = useState(1);\n const [loading, setLoading] = useState(false);\n const [details, setDetails] = useState(null);\n const [error, setError] = useState(null);\n const [message, setMessage] = useState(null);\n const [acceptTerms, setAcceptTerms] = useState(false);\n const [confirmMonthEnd, setConfirmMonthEnd] = useState(false);\n const [cancelMonth, setCancelMonth] = useState(\"\");\n const [email, setEmail] = useState(\"\");\n const [email2, setEmail2] = useState(\"\");\n const [notes, setNotes] = useState(\"\");\n const [registeredEmail, setRegisteredEmail] = useState(null);\n\n useEffect(() => {\n const fetchDetails = async () => {\n try {\n const info = await simActionsService.getSimInfo(subscriptionId);\n setDetails(info?.details || null);\n } catch (e: unknown) {\n setError(e instanceof Error ? e.message : \"Failed to load SIM details\");\n }\n };\n void fetchDetails();\n }, [subscriptionId]);\n\n useEffect(() => {\n const fetchEmail = async () => {\n try {\n // Prefer auth store email; fallback to address fetch only if needed\n const emailFromStore = useAuthStore.getState().user?.email;\n if (emailFromStore) {\n setRegisteredEmail(emailFromStore);\n return;\n }\n // If needed, get via /me/address payload enrichment in future; skip extra call for now\n } catch {\n // ignore\n }\n };\n void fetchEmail();\n }, []);\n\n const monthOptions = useMemo(() => {\n const opts: { value: string; label: string }[] = [];\n const now = new Date();\n for (let i = 1; i <= 12; i++) {\n const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + i, 1));\n const y = d.getUTCFullYear();\n const m = String(d.getUTCMonth() + 1).padStart(2, \"0\");\n opts.push({ value: `${y}${m}`, label: `${y} / ${m}` });\n }\n return opts;\n }, []);\n\n const canProceedStep2 = !!details;\n const emailPattern = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n const emailProvided = email.trim().length > 0 || email2.trim().length > 0;\n const emailValid =\n !emailProvided || (emailPattern.test(email.trim()) && emailPattern.test(email2.trim()));\n const emailsMatch = !emailProvided || email.trim() === email2.trim();\n const canProceedStep3 =\n acceptTerms && !!cancelMonth && confirmMonthEnd && emailValid && emailsMatch;\n const runDate = cancelMonth ? `${cancelMonth}01` : undefined;\n\n const submit = async () => {\n setLoading(true);\n setError(null);\n setMessage(null);\n try {\n await simActionsService.cancel(subscriptionId, { scheduledAt: runDate });\n setMessage(\"Cancellation request submitted. You will receive a confirmation email.\");\n setTimeout(() => router.push(`/subscriptions/${subscriptionId}#sim-management`), 1500);\n } catch (e: unknown) {\n setError(e instanceof Error ? e.message : \"Failed to submit cancellation\");\n } finally {\n setLoading(false);\n }\n };\n\n return (\n
\n
\n \n ← Back to SIM Management\n \n
Step {step} of 3
\n
\n\n {error && (\n
{error}
\n )}\n {message && (\n
\n {message}\n
\n )}\n\n
\n

Cancel SIM

\n

\n Cancel SIM: Permanently cancel your SIM service. This action cannot be undone and will\n terminate your service immediately.\n

\n\n {step === 1 && (\n
\n
\n \n \n
\n \n {\n setCancelMonth(e.target.value);\n setConfirmMonthEnd(false);\n }}\n className=\"w-full border border-gray-300 rounded-md px-3 py-2 text-sm\"\n >\n \n {monthOptions.map(opt => (\n \n ))}\n \n

\n Cancellation takes effect at the start of the selected month.\n

\n
\n
\n
\n setStep(2)}\n className=\"px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50\"\n >\n Next\n \n
\n
\n )}\n\n {step === 2 && (\n
\n
\n \n Online cancellations must be made from this website by the 25th of the desired\n cancellation month. Once a request of a cancellation of the SONIXNET SIM is accepted\n from this online form, a confirmation email containing details of the SIM plan will\n be sent to the registered email address. The SIM card is a rental piece of hardware\n and must be returned to Assist Solutions upon cancellation. The cancellation request\n through this website retains to your SIM subscriptions only. To cancel any other\n services with Assist Solutions (home internet etc.) please contact Assist Solutions\n at info@asolutions.co.jp\n \n \n The SONIXNET SIM has a minimum contract term agreement of three months (sign-up\n month is not included in the minimum term of three months; ie. sign-up in January =\n minimum term is February, March, April). If the minimum contract term is not\n fulfilled, the monthly fees of the remaining months will be charged upon\n cancellation.\n \n \n Cancellation of option services only (Voice Mail, Call Waiting) while keeping the\n base plan active is not possible from this online form. Please contact Assist\n Solutions Customer Support (info@asolutions.co.jp) for more information. Upon\n cancelling the base plan, all additional options associated with the requested SIM\n plan will be cancelled.\n \n \n Upon cancellation the SIM phone number will be lost. In order to keep the phone\n number active to be used with a different cellular provider, a request for an MNP\n transfer (administrative fee \\\\1,000yen+tax) is necessary. The MNP cannot be\n requested from this online form. Please contact Assist Solutions Customer Support\n (info@asolutions.co.jp) for more information.\n \n
\n
\n setAcceptTerms(e.target.checked)}\n />\n \n
\n
\n setConfirmMonthEnd(e.target.checked)}\n disabled={!cancelMonth}\n />\n \n
\n
\n setStep(1)}\n className=\"px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50\"\n >\n Back\n \n setStep(3)}\n className=\"px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50\"\n >\n Next\n \n
\n
\n )}\n\n {step === 3 && (\n
\n {registeredEmail && (\n
\n Your registered email address is:{\" \"}\n {registeredEmail}\n
\n )}\n
\n You will receive a cancellation confirmation email. If you would like to receive this\n email on a different address, please enter the address below.\n
\n
\n
\n \n setEmail(e.target.value)}\n placeholder=\"you@example.com\"\n />\n
\n
\n \n setEmail2(e.target.value)}\n placeholder=\"you@example.com\"\n />\n
\n
\n \n setNotes(e.target.value)}\n placeholder=\"If you have any questions or requests, note them here.\"\n />\n
\n
\n {emailProvided && !emailValid && (\n
\n Please enter a valid email address in both fields.\n
\n )}\n {emailProvided && emailValid && !emailsMatch && (\n
Email addresses do not match.
\n )}\n
\n Your cancellation request is not confirmed yet. This is the final page. To finalize\n your cancellation request please proceed from REQUEST CANCELLATION below.\n
\n
\n setStep(2)}\n className=\"px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50\"\n >\n Back\n \n {\n if (\n window.confirm(\n \"Request cancellation now? This will schedule the cancellation for \" +\n (runDate || \"\") +\n \".\"\n )\n ) {\n void submit();\n }\n }}\n disabled={loading || !runDate || !canProceedStep3}\n className=\"px-4 py-2 rounded-md bg-red-600 text-white text-sm disabled:opacity-50\"\n >\n {loading ? \"Processing…\" : \"Request Cancellation\"}\n \n
\n
\n )}\n
\n
\n );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/subscriptions/containers/SubscriptionDetail.tsx","messages":[{"ruleId":"prettier/prettier","severity":1,"message":"Replace `\"useĀ·client\"` with `(\"useĀ·client\")`","line":2,"column":1,"nodeType":null,"messageId":"replace","endLine":2,"endColumn":13,"fix":{"range":[66,78],"text":"(\"use client\")"}},{"ruleId":"@typescript-eslint/no-unused-expressions","severity":1,"message":"Expected an assignment or function call and instead saw an expression.","line":2,"column":1,"nodeType":"ExpressionStatement","messageId":"unusedExpression","endLine":2,"endColumn":14},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'ArrowTopRightOnSquareIcon' is defined but never used.","line":19,"column":3,"nodeType":null,"messageId":"unusedVar","endLine":19,"endColumn":28},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'currentPage' is assigned a value but never used.","line":30,"column":10,"nodeType":null,"messageId":"unusedVar","endLine":30,"endColumn":21},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'setCurrentPage' is assigned a value but never used.","line":30,"column":23,"nodeType":null,"messageId":"unusedVar","endLine":30,"endColumn":37},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'itemsPerPage' is assigned a value but never used.","line":31,"column":9,"nodeType":null,"messageId":"unusedVar","endLine":31,"endColumn":21},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'getStatusColor' is assigned a value but never used.","line":77,"column":9,"nodeType":null,"messageId":"unusedVar","endLine":77,"endColumn":23},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'getInvoiceStatusIcon' is assigned a value but never used.","line":94,"column":9,"nodeType":null,"messageId":"unusedVar","endLine":94,"endColumn":29},{"ruleId":"@typescript-eslint/no-unused-vars","severity":1,"message":"'getInvoiceStatusColor' is assigned a value but never used.","line":107,"column":9,"nodeType":null,"messageId":"unusedVar","endLine":107,"endColumn":30},{"ruleId":"prettier/prettier","severity":1,"message":"Insert `Ā·Ā·`","line":220,"column":19,"nodeType":null,"messageId":"insert","endLine":220,"endColumn":19,"fix":{"range":[7468,7468],"text":" "}},{"ruleId":"prettier/prettier","severity":1,"message":"Insert `Ā·Ā·`","line":221,"column":1,"nodeType":null,"messageId":"insert","endLine":221,"endColumn":1,"fix":{"range":[7480,7480],"text":" "}},{"ruleId":"prettier/prettier","severity":1,"message":"Insert `Ā·Ā·Ā·Ā·`","line":222,"column":19,"nodeType":null,"messageId":"insert","endLine":222,"endColumn":19,"fix":{"range":[7576,7576],"text":" "}},{"ruleId":"prettier/prettier","severity":1,"message":"Insert `Ā·Ā·Ā·Ā·`","line":223,"column":1,"nodeType":null,"messageId":"insert","endLine":223,"endColumn":1,"fix":{"range":[7588,7588],"text":" "}}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":13,"fixableErrorCount":0,"fixableWarningCount":5,"source":"import { LoadingSpinner } from \"@/components/ui/loading-spinner\";\n\"use client\";\n\nimport { SubCard } from \"@/components/ui/sub-card\";\nimport { DetailHeader } from \"@/components/common/DetailHeader\";\n\nimport { useEffect, useState } from \"react\";\nimport { useParams, useSearchParams } from \"next/navigation\";\nimport Link from \"next/link\";\nimport {\n ArrowLeftIcon,\n ServerIcon,\n CheckCircleIcon,\n ExclamationTriangleIcon,\n ClockIcon,\n XCircleIcon,\n CalendarIcon,\n DocumentTextIcon,\n ArrowTopRightOnSquareIcon,\n} from \"@heroicons/react/24/outline\";\nimport { format } from \"date-fns\";\nimport { useSubscription } from \"@/features/subscriptions/hooks\";\nimport { InvoicesList } from \"@/features/billing/components/InvoiceList/InvoiceList\";\nimport { formatCurrency as sharedFormatCurrency, getCurrencyLocale } from \"@/utils/currency\";\nimport { SimManagementSection } from \"@/features/sim-management\";\n\nexport function SubscriptionDetailContainer() {\n const params = useParams();\n const searchParams = useSearchParams();\n const [currentPage, setCurrentPage] = useState(1);\n const itemsPerPage = 10;\n const [showInvoices, setShowInvoices] = useState(true);\n const [showSimManagement, setShowSimManagement] = useState(false);\n\n const subscriptionId = parseInt(params.id as string);\n const { data: subscription, isLoading, error } = useSubscription(subscriptionId);\n // Invoices are now rendered via shared InvoiceList\n\n useEffect(() => {\n const updateVisibility = () => {\n const hash = typeof window !== \"undefined\" ? window.location.hash : \"\";\n const service = (searchParams.get(\"service\") || \"\").toLowerCase();\n const isSimContext = hash.includes(\"sim-management\") || service === \"sim\";\n if (isSimContext) {\n setShowInvoices(false);\n setShowSimManagement(true);\n } else {\n setShowInvoices(true);\n setShowSimManagement(false);\n }\n };\n updateVisibility();\n if (typeof window !== \"undefined\") {\n window.addEventListener(\"hashchange\", updateVisibility);\n return () => window.removeEventListener(\"hashchange\", updateVisibility);\n }\n return;\n }, [searchParams]);\n\n const getStatusIcon = (status: string) => {\n switch (status) {\n case \"Active\":\n return ;\n case \"Suspended\":\n return ;\n case \"Terminated\":\n return ;\n case \"Cancelled\":\n return ;\n case \"Pending\":\n return ;\n default:\n return ;\n }\n };\n\n const getStatusColor = (status: string) => {\n switch (status) {\n case \"Active\":\n return \"bg-green-100 text-green-800\";\n case \"Suspended\":\n return \"bg-yellow-100 text-yellow-800\";\n case \"Terminated\":\n return \"bg-red-100 text-red-800\";\n case \"Cancelled\":\n return \"bg-gray-100 text-gray-800\";\n case \"Pending\":\n return \"bg-blue-100 text-blue-800\";\n default:\n return \"bg-gray-100 text-gray-800\";\n }\n };\n\n const getInvoiceStatusIcon = (status: string) => {\n switch (status) {\n case \"Paid\":\n return ;\n case \"Overdue\":\n return ;\n case \"Unpaid\":\n return ;\n default:\n return ;\n }\n };\n\n const getInvoiceStatusColor = (status: string) => {\n switch (status) {\n case \"Paid\":\n return \"bg-green-100 text-green-800\";\n case \"Overdue\":\n return \"bg-red-100 text-red-800\";\n case \"Unpaid\":\n return \"bg-yellow-100 text-yellow-800\";\n case \"Cancelled\":\n return \"bg-gray-100 text-gray-800\";\n default:\n return \"bg-gray-100 text-gray-800\";\n }\n };\n\n const formatDate = (dateString: string | undefined) => {\n if (!dateString) return \"N/A\";\n try {\n return format(new Date(dateString), \"MMM d, yyyy\");\n } catch {\n return \"Invalid date\";\n }\n };\n\n const formatCurrency = (amount: number) =>\n sharedFormatCurrency(amount || 0, { currency: \"JPY\", locale: getCurrencyLocale(\"JPY\") });\n\n const formatBillingLabel = (cycle: string) => {\n switch (cycle) {\n case \"Monthly\":\n return \"Monthly Billing\";\n case \"Annually\":\n return \"Annual Billing\";\n case \"Quarterly\":\n return \"Quarterly Billing\";\n case \"Semi-Annually\":\n return \"Semi-Annual Billing\";\n case \"Biennially\":\n return \"Biennial Billing\";\n case \"Triennially\":\n return \"Triennial Billing\";\n default:\n return \"One-time Payment\";\n }\n };\n\n if (isLoading) {\n return (\n
\n
\n \n

Loading subscription...

\n
\n
\n );\n }\n\n if (error || !subscription) {\n return (\n
\n
\n
\n
\n \n
\n
\n

Error loading subscription

\n
\n {error instanceof Error ? error.message : \"Subscription not found\"}\n
\n
\n \n ← Back to subscriptions\n \n
\n
\n
\n
\n
\n );\n }\n\n return (\n
\n
\n
\n
\n
\n \n \n \n
\n \n
\n

{subscription.productName}

\n

Service ID: {subscription.serviceId}

\n
\n
\n
\n
\n
\n\n \n \n
\n
\n
\n

\n Billing Amount\n

\n

\n {formatCurrency(subscription.amount)}\n

\n

{formatBillingLabel(subscription.cycle)}

\n
\n
\n

\n Next Due Date\n

\n

{formatDate(subscription.nextDue)}

\n
\n \n Due date\n
\n
\n
\n

\n Registration Date\n

\n

\n {formatDate(subscription.registrationDate)}\n

\n Service created\n
\n
\n
\n
\n\n {subscription.productName.toLowerCase().includes(\"sim\") && (\n
\n \n
\n
\n

Service Management

\n

\n Switch between billing and SIM management views\n

\n
\n
\n \n \n SIM Management\n \n \n \n Invoices\n \n
\n
\n
\n
\n )}\n\n {showSimManagement && (\n
\n \n
\n )}\n\n {showInvoices && }\n
\n
\n );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/subscriptions/containers/SubscriptionsList.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/subscriptions/hooks/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts","messages":[{"ruleId":"@typescript-eslint/no-floating-promises","severity":2,"message":"Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator.","line":172,"column":7,"nodeType":"ExpressionStatement","messageId":"floatingVoid","endLine":172,"endColumn":70,"suggestions":[{"messageId":"floatingFixVoid","fix":{"range":[4770,4770],"text":"void "},"desc":"Add void operator to ignore."},{"messageId":"floatingFixAwait","fix":{"range":[4770,4770],"text":"await "},"desc":"Add await operator."}]},{"ruleId":"@typescript-eslint/no-floating-promises","severity":2,"message":"Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator.","line":173,"column":7,"nodeType":"ExpressionStatement","messageId":"floatingVoid","endLine":173,"endColumn":73,"suggestions":[{"messageId":"floatingFixVoid","fix":{"range":[4840,4840],"text":"void "},"desc":"Add void operator to ignore."},{"messageId":"floatingFixAwait","fix":{"range":[4840,4840],"text":"await "},"desc":"Add await operator."}]}],"suppressedMessages":[],"errorCount":2,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Subscriptions Hooks\n * React hooks for subscription functionality using shared types\n */\n\nimport { useQuery, useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { useAuthStore } from \"@/lib/auth/store\";\nimport { apiClient } from \"@/lib/api/client\";\nimport type { Subscription, SubscriptionList, InvoiceList } from \"@customer-portal/shared\";\n\ninterface UseSubscriptionsOptions {\n status?: string;\n}\n\n/**\n * Hook to fetch all subscriptions\n */\nexport function useSubscriptions(options: UseSubscriptionsOptions = {}) {\n const { status } = options;\n const { token, isAuthenticated } = useAuthStore();\n\n return useQuery({\n queryKey: [\"subscriptions\", status],\n queryFn: async () => {\n if (!token) {\n throw new Error(\"Authentication required\");\n }\n\n const params = new URLSearchParams({\n ...(status && { status }),\n });\n const res = await apiClient.get(\n `/subscriptions?${params}`\n );\n return res.data as SubscriptionList | Subscription[];\n },\n staleTime: 5 * 60 * 1000, // 5 minutes\n gcTime: 10 * 60 * 1000, // 10 minutes\n enabled: isAuthenticated && !!token,\n });\n}\n\n/**\n * Hook to fetch active subscriptions only\n */\nexport function useActiveSubscriptions() {\n const { token, isAuthenticated } = useAuthStore();\n\n return useQuery({\n queryKey: [\"subscriptions\", \"active\"],\n queryFn: async () => {\n if (!token) {\n throw new Error(\"Authentication required\");\n }\n\n const res = await apiClient.get(`/subscriptions/active`);\n return res.data as Subscription[];\n },\n staleTime: 5 * 60 * 1000, // 5 minutes\n gcTime: 10 * 60 * 1000, // 10 minutes\n enabled: isAuthenticated && !!token,\n });\n}\n\n/**\n * Hook to fetch subscription statistics\n */\nexport function useSubscriptionStats() {\n const { token, isAuthenticated } = useAuthStore();\n\n return useQuery<{\n total: number;\n active: number;\n suspended: number;\n cancelled: number;\n pending: number;\n }>({\n queryKey: [\"subscriptions\", \"stats\"],\n queryFn: async () => {\n if (!token) {\n throw new Error(\"Authentication required\");\n }\n\n const res = await apiClient.get<{\n total: number;\n active: number;\n suspended: number;\n cancelled: number;\n pending: number;\n }>(`/subscriptions/stats`);\n return res.data as {\n total: number;\n active: number;\n suspended: number;\n cancelled: number;\n pending: number;\n };\n },\n staleTime: 5 * 60 * 1000, // 5 minutes\n gcTime: 10 * 60 * 1000, // 10 minutes\n enabled: isAuthenticated && !!token,\n });\n}\n\n/**\n * Hook to fetch a specific subscription\n */\nexport function useSubscription(subscriptionId: number) {\n const { token, isAuthenticated } = useAuthStore();\n\n return useQuery({\n queryKey: [\"subscription\", subscriptionId],\n queryFn: async () => {\n if (!token) {\n throw new Error(\"Authentication required\");\n }\n\n const res = await apiClient.get(`/subscriptions/${subscriptionId}`);\n return res.data as Subscription;\n },\n staleTime: 5 * 60 * 1000, // 5 minutes\n gcTime: 10 * 60 * 1000, // 10 minutes\n enabled: isAuthenticated && !!token,\n });\n}\n\n/**\n * Hook to fetch subscription invoices\n */\nexport function useSubscriptionInvoices(\n subscriptionId: number,\n options: { page?: number; limit?: number } = {}\n) {\n const { page = 1, limit = 10 } = options;\n const { token, isAuthenticated } = useAuthStore();\n\n return useQuery({\n queryKey: [\"subscription-invoices\", subscriptionId, page, limit],\n queryFn: async () => {\n if (!token) {\n throw new Error(\"Authentication required\");\n }\n\n const params = new URLSearchParams({\n page: page.toString(),\n limit: limit.toString(),\n });\n const res = await apiClient.get(\n `/subscriptions/${subscriptionId}/invoices?${params}`\n );\n return res.data as InvoiceList;\n },\n staleTime: 60 * 1000, // 1 minute\n gcTime: 5 * 60 * 1000, // 5 minutes\n enabled: isAuthenticated && !!token && !!subscriptionId,\n });\n}\n\n/**\n * Hook to perform subscription actions (suspend, resume, cancel, etc.)\n */\nexport function useSubscriptionAction() {\n const queryClient = useQueryClient();\n\n return useMutation({\n mutationFn: async ({ id, action }: { id: number; action: string }) => {\n const res = await apiClient.post(`/subscriptions/${id}/actions`, { action });\n return res.data;\n },\n onSuccess: (_, { id }) => {\n // Invalidate relevant queries after successful action\n queryClient.invalidateQueries({ queryKey: [\"subscriptions\"] });\n queryClient.invalidateQueries({ queryKey: [\"subscription\", id] });\n },\n });\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/subscriptions/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/subscriptions/services/sim-actions.service.ts","messages":[{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":6,"column":37,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":6,"endColumn":40,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[130,133],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[130,133],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":6,"column":51,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":6,"endColumn":54,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[144,147],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[144,147],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":12,"column":31,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":12,"endColumn":34,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[255,258],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[255,258],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":12,"column":45,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":12,"endColumn":48,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[269,272],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[269,272],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]}],"suppressedMessages":[],"errorCount":4,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * SIM Actions Service (feature layer)\n */\nimport { apiClient } from \"@/lib/api/client\";\n\nexport interface SimInfo {\n details: TDetails;\n usage: TUsage;\n}\n\nexport class SimActionsService {\n async getSimInfo(\n subscriptionId: number\n ): Promise> {\n const res = await apiClient.get>(\n `/subscriptions/${subscriptionId}/sim`\n );\n return res.data as SimInfo;\n }\n\n async changePlan(\n subscriptionId: number,\n body: { newPlanCode: string; assignGlobalIp?: boolean; scheduledAt?: string }\n ) {\n const res = await apiClient.post(`/subscriptions/${subscriptionId}/sim/change-plan`, body);\n return res.data;\n }\n\n async topUp(subscriptionId: number, body: { quotaMb: number; scheduledAt?: string }) {\n const res = await apiClient.post(`/subscriptions/${subscriptionId}/sim/top-up`, body);\n return res.data;\n }\n\n async cancel(subscriptionId: number, body: { scheduledAt?: string } = {}) {\n const res = await apiClient.post(`/subscriptions/${subscriptionId}/sim/cancel`, body);\n return res.data;\n }\n\n async reissueEsim(subscriptionId: number) {\n const res = await apiClient.post(`/subscriptions/${subscriptionId}/sim/reissue-esim`);\n return res.data;\n }\n\n async updateFeatures(\n subscriptionId: number,\n payload: {\n voiceMailEnabled?: boolean;\n callWaitingEnabled?: boolean;\n internationalRoamingEnabled?: boolean;\n networkType?: \"4G\" | \"5G\";\n }\n ) {\n const res = await apiClient.post(`/subscriptions/${subscriptionId}/sim/features`, payload);\n return res.data;\n }\n}\n\nexport const simActionsService = new SimActionsService();\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/subscriptions/types/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/support/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/hooks/use-optimized-query.ts","messages":[{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":47,"column":15,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":47,"endColumn":18,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[1179,1182],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[1179,1182],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":48,"column":28,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":48,"endColumn":31,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[1213,1216],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[1213,1216],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-floating-promises","severity":2,"message":"Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator.","line":56,"column":9,"nodeType":"ExpressionStatement","messageId":"floatingVoid","endLine":60,"endColumn":12,"suggestions":[{"messageId":"floatingFixVoid","fix":{"range":[1489,1489],"text":"void "},"desc":"Add void operator to ignore."},{"messageId":"floatingFixAwait","fix":{"range":[1489,1489],"text":"await "},"desc":"Add await operator."}]},{"ruleId":"@typescript-eslint/no-floating-promises","severity":2,"message":"Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator.","line":65,"column":9,"nodeType":"ExpressionStatement","messageId":"floatingVoid","endLine":69,"endColumn":12,"suggestions":[{"messageId":"floatingFixVoid","fix":{"range":[1704,1704],"text":"void "},"desc":"Add void operator to ignore."},{"messageId":"floatingFixAwait","fix":{"range":[1704,1704],"text":"await "},"desc":"Add await operator."}]},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":81,"column":13,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":81,"endColumn":16,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[1961,1964],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[1961,1964],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":99,"column":54,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":99,"endColumn":57,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[2399,2402],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[2399,2402],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]}],"suppressedMessages":[],"errorCount":6,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"import {\n useQuery,\n useInfiniteQuery,\n useQueryClient,\n UseQueryOptions,\n UseInfiniteQueryOptions,\n} from \"@tanstack/react-query\";\nimport { queryConfigs, queryClient } from \"@/lib/query-client\";\n\ntype DataType = \"static\" | \"profile\" | \"financial\" | \"realtime\" | \"list\";\n\n/**\n * Optimized query hook with predefined configurations for different data types\n */\nexport function useOptimizedQuery(\n options: UseQueryOptions & { dataType?: DataType }\n) {\n const { dataType = \"list\", ...queryOptions } = options;\n const config = queryConfigs[dataType];\n\n return useQuery({\n ...config,\n ...queryOptions,\n });\n}\n\n/**\n * Optimized infinite query hook\n */\nexport function useOptimizedInfiniteQuery(\n options: UseInfiniteQueryOptions & { dataType?: DataType }\n) {\n const { dataType = \"list\", ...queryOptions } = options;\n const config = queryConfigs[dataType];\n\n return useInfiniteQuery({\n ...config,\n ...queryOptions,\n });\n}\n\n/**\n * Hook for prefetching queries with optimized timing\n */\nexport function usePrefetchQuery() {\n const prefetchQuery = (\n queryKey: any[],\n queryFn: () => Promise,\n dataType: DataType = \"list\"\n ) => {\n const config = queryConfigs[dataType];\n\n // Use requestIdleCallback for non-critical prefetching\n if (typeof window !== \"undefined\" && \"requestIdleCallback\" in window) {\n window.requestIdleCallback(() => {\n queryClient.prefetchQuery({\n queryKey,\n queryFn,\n ...config,\n });\n });\n } else {\n // Fallback for browsers without requestIdleCallback\n setTimeout(() => {\n queryClient.prefetchQuery({\n queryKey,\n queryFn,\n ...config,\n });\n }, 100);\n }\n };\n\n return { prefetchQuery };\n}\n\n/**\n * Hook for background data synchronization\n */\nexport function useBackgroundSync(\n queryKey: any[],\n enabled: boolean = true,\n interval: number = 5 * 60 * 1000 // 5 minutes\n) {\n return useQuery({\n queryKey,\n enabled,\n refetchInterval: interval,\n refetchIntervalInBackground: false,\n refetchOnWindowFocus: true,\n refetchOnReconnect: true,\n staleTime: interval / 2, // Half of refetch interval\n });\n}\n\n/**\n * Hook for optimistic updates with rollback\n */\nexport function useOptimisticUpdate(queryKey: any[]) {\n const queryClient = useQueryClient();\n\n const updateOptimistically = async (\n variables: TVariables,\n updater: (oldData: TData | undefined, variables: TVariables) => TData,\n mutationFn: (variables: TVariables) => Promise\n ) => {\n // Cancel outgoing refetches\n await queryClient.cancelQueries({ queryKey });\n\n // Snapshot previous value\n const previousData = queryClient.getQueryData(queryKey);\n\n // Optimistically update\n queryClient.setQueryData(queryKey, old => updater(old, variables));\n\n try {\n // Perform the actual mutation\n const result = await mutationFn(variables);\n\n // Update with real data\n queryClient.setQueryData(queryKey, result);\n\n return result;\n } catch (error) {\n // Rollback on error\n queryClient.setQueryData(queryKey, previousData);\n throw error;\n }\n };\n\n return { updateOptimistically };\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/hooks/use-performance-monitor.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/api/base.service.ts","messages":[{"ruleId":"@typescript-eslint/no-base-to-string","severity":2,"message":"'value' will use Object's default stringification format ('[object Object]') when stringified.","line":46,"column":37,"nodeType":"Identifier","messageId":"baseToString","endLine":46,"endColumn":42}],"suppressedMessages":[],"errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Base Service Class\n * Provides common CRUD operations and utilities for domain-specific services\n */\n\nimport type { ApiClient, ApiResponse } from \"./client\";\nimport type { CrudService } from \"../types/api.types\";\nimport type { PaginatedResponse, QueryParams } from \"../types/common.types\";\n\nexport abstract class BaseService, UpdateT = Partial>\n implements CrudService\n{\n protected abstract readonly basePath: string;\n\n constructor(protected readonly apiClient: ApiClient) {}\n\n /**\n * Build endpoint path\n */\n protected buildPath(path?: string): string {\n if (!path) return this.basePath;\n return `${this.basePath}/${path}`;\n }\n\n /**\n * Transform query parameters for API request\n */\n protected transformParams(params?: QueryParams): Record {\n if (!params) return {};\n\n const transformed: Record = {};\n\n Object.entries(params).forEach(([key, value]) => {\n if (value !== undefined && value !== null) {\n if (Array.isArray(value)) {\n // Convert arrays to comma-separated strings\n transformed[key] = value.join(\",\");\n } else if (\n typeof value === \"string\" ||\n typeof value === \"number\" ||\n typeof value === \"boolean\"\n ) {\n transformed[key] = value;\n } else {\n // Convert other types to string\n transformed[key] = String(value);\n }\n }\n });\n\n return transformed;\n }\n\n /**\n * Extract data from API response\n */\n protected extractData(response: ApiResponse): R {\n if (!response.success || response.data === undefined) {\n throw new Error(\"Invalid API response\");\n }\n return response.data;\n }\n\n /**\n * Get all items with optional pagination and filtering\n */\n async getAll(params?: QueryParams): Promise> {\n const response = await this.apiClient.get>(this.basePath, {\n params: this.transformParams(params),\n });\n return this.extractData(response);\n }\n\n /**\n * Get single item by ID\n */\n async getById(id: string): Promise {\n const response = await this.apiClient.get(this.buildPath(id));\n return this.extractData(response);\n }\n\n /**\n * Create new item\n */\n async create(data: CreateT): Promise {\n const response = await this.apiClient.post(this.basePath, data);\n return this.extractData(response);\n }\n\n /**\n * Update existing item\n */\n async update(id: string, data: UpdateT): Promise {\n const response = await this.apiClient.patch(this.buildPath(id), data);\n return this.extractData(response);\n }\n\n /**\n * Delete item\n */\n async delete(id: string): Promise {\n await this.apiClient.delete(this.buildPath(id));\n }\n\n /**\n * Check if item exists\n */\n async exists(id: string): Promise {\n try {\n await this.getById(id);\n return true;\n } catch {\n return false;\n }\n }\n\n /**\n * Get items by IDs\n */\n async getByIds(ids: string[]): Promise {\n const response = await this.apiClient.get(this.basePath, {\n params: { ids: ids.join(\",\") },\n });\n return this.extractData(response);\n }\n\n /**\n * Bulk create items\n */\n async bulkCreate(items: CreateT[]): Promise {\n const response = await this.apiClient.post(this.buildPath(\"bulk\"), { items });\n return this.extractData(response);\n }\n\n /**\n * Bulk update items\n */\n async bulkUpdate(updates: Array<{ id: string; data: UpdateT }>): Promise {\n const response = await this.apiClient.patch(this.buildPath(\"bulk\"), { updates });\n return this.extractData(response);\n }\n\n /**\n * Bulk delete items\n */\n async bulkDelete(ids: string[]): Promise {\n await this.apiClient.delete(this.buildPath(\"bulk\"), {\n data: { ids },\n });\n }\n\n /**\n * Search items\n */\n async search(query: string, params?: QueryParams): Promise> {\n const searchParams = {\n q: query,\n ...params,\n };\n\n const response = await this.apiClient.get>(this.buildPath(\"search\"), {\n params: this.transformParams(searchParams),\n });\n return this.extractData(response);\n }\n\n /**\n * Count items with optional filtering\n */\n async count(params?: QueryParams): Promise {\n const response = await this.apiClient.get<{ count: number }>(this.buildPath(\"count\"), {\n params: this.transformParams(params),\n });\n const data = this.extractData(response);\n return (data as { count: number }).count;\n }\n}\n\n/**\n * Authenticated Base Service\n * Extends BaseService with authentication-aware methods\n */\nexport abstract class AuthenticatedBaseService<\n T,\n CreateT = Partial,\n UpdateT = Partial,\n> extends BaseService {\n /**\n * Get current user's items\n */\n async getMine(params?: QueryParams): Promise> {\n const response = await this.apiClient.get>(this.buildPath(\"mine\"), {\n params: this.transformParams(params),\n });\n return this.extractData(response);\n }\n\n /**\n * Get items for specific user (admin only)\n */\n async getByUserId(userId: string, params?: QueryParams): Promise> {\n const searchParams = {\n userId,\n ...params,\n };\n\n const response = await this.apiClient.get>(this.basePath, {\n params: this.transformParams(searchParams),\n });\n return this.extractData(response);\n }\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/api/client.ts","messages":[{"ruleId":"@typescript-eslint/require-await","severity":2,"message":"Async arrow function has no 'await' expression.","line":356,"column":46,"nodeType":"ArrowFunctionExpression","messageId":"missingAwait","endLine":356,"endColumn":48,"suggestions":[{"messageId":"removeAsync","fix":{"range":[9748,9754],"text":""},"desc":"Remove 'async'."}]},{"ruleId":"@typescript-eslint/require-await","severity":2,"message":"Async arrow function has no 'await' expression.","line":366,"column":49,"nodeType":"ArrowFunctionExpression","messageId":"missingAwait","endLine":366,"endColumn":51,"suggestions":[{"messageId":"removeAsync","fix":{"range":[10000,10006],"text":""},"desc":"Remove 'async'."}]}],"suppressedMessages":[],"errorCount":2,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Centralized API Client\n * Provides consistent error handling, authentication, and request/response interceptors\n */\n\nimport { env } from \"../env\";\nimport { logger } from \"../logger\";\nimport type { ApiRequestConfig, RequestInterceptor, ResponseInterceptor } from \"../types/api.types\";\n\n// Local ApiResponse interface for the client\nexport interface ApiResponse {\n success: boolean;\n data?: T;\n error?: {\n code: string;\n message: string;\n details?: Record;\n };\n meta?: {\n requestId?: string;\n timestamp?: string;\n };\n}\n\nexport class ApiError extends Error {\n constructor(\n message: string,\n public status: number,\n public code?: string,\n public details?: Record\n ) {\n super(message);\n this.name = \"ApiError\";\n }\n}\n\nexport interface ApiClientConfig {\n baseUrl: string;\n timeout?: number;\n retries?: number;\n defaultHeaders?: Record;\n}\n\nexport class ApiClient {\n private baseUrl: string;\n private timeout: number;\n private retries: number;\n private defaultHeaders: Record;\n private requestInterceptors: RequestInterceptor[] = [];\n private responseInterceptors: ResponseInterceptor[] = [];\n\n constructor(config: ApiClientConfig) {\n this.baseUrl = config.baseUrl.endsWith(\"/\") ? config.baseUrl.slice(0, -1) : config.baseUrl;\n this.timeout = config.timeout ?? 30000;\n this.retries = config.retries ?? 3;\n this.defaultHeaders = {\n \"Content-Type\": \"application/json\",\n ...config.defaultHeaders,\n };\n }\n\n /**\n * Add request interceptor\n */\n addRequestInterceptor(interceptor: RequestInterceptor): void {\n this.requestInterceptors.push(interceptor);\n }\n\n /**\n * Add response interceptor\n */\n addResponseInterceptor(interceptor: ResponseInterceptor): void {\n this.responseInterceptors.push(interceptor);\n }\n\n /**\n * Apply request interceptors\n */\n private async applyRequestInterceptors(config: ApiRequestConfig): Promise {\n let processedConfig = { ...config };\n\n for (const interceptor of this.requestInterceptors) {\n processedConfig = await interceptor(processedConfig);\n }\n\n return processedConfig;\n }\n\n /**\n * Apply response interceptors\n */\n private async applyResponseInterceptors(response: ApiResponse): Promise> {\n let processedResponse = { ...response };\n\n for (const interceptor of this.responseInterceptors) {\n processedResponse = await interceptor(processedResponse);\n }\n\n return processedResponse;\n }\n\n /**\n * Build full URL from endpoint\n */\n private buildUrl(endpoint: string, params?: Record): string {\n const path = endpoint.startsWith(\"/\") ? endpoint : `/${endpoint}`;\n const url = `${this.baseUrl}${path}`;\n\n if (!params || Object.keys(params).length === 0) {\n return url;\n }\n\n const searchParams = new URLSearchParams();\n Object.entries(params).forEach(([key, value]) => {\n searchParams.append(key, String(value));\n });\n\n return `${url}?${searchParams.toString()}`;\n }\n\n /**\n * Create AbortController with timeout\n */\n private createAbortController(timeout?: number): AbortController {\n const controller = new AbortController();\n const timeoutMs = timeout ?? this.timeout;\n\n setTimeout(() => {\n controller.abort();\n }, timeoutMs);\n\n return controller;\n }\n\n /**\n * Parse error response\n */\n private async parseErrorResponse(response: Response): Promise {\n let errorMessage = `HTTP ${response.status}`;\n let errorCode: string | undefined;\n let errorDetails: Record | undefined;\n\n try {\n const errorData = (await response.json()) as unknown;\n\n if (typeof errorData === \"object\" && errorData !== null) {\n const errorObj = errorData as Record;\n\n if (\"message\" in errorObj && typeof errorObj.message === \"string\") {\n errorMessage = errorObj.message;\n }\n\n if (\"code\" in errorObj && typeof errorObj.code === \"string\") {\n errorCode = errorObj.code;\n }\n\n if (\"details\" in errorObj && typeof errorObj.details === \"object\") {\n errorDetails = errorObj.details as Record;\n }\n }\n } catch {\n // If we can't parse the error response, use the status text\n errorMessage = response.statusText || errorMessage;\n }\n\n return new ApiError(errorMessage, response.status, errorCode, errorDetails);\n }\n\n /**\n * Make HTTP request with retries\n */\n private async makeRequest(\n endpoint: string,\n config: ApiRequestConfig = {}\n ): Promise> {\n // Apply request interceptors\n const processedConfig = await this.applyRequestInterceptors(config);\n\n const url = this.buildUrl(\n endpoint,\n processedConfig.params as Record\n );\n const method = processedConfig.method ?? \"GET\";\n\n // Merge headers\n const headers = {\n ...this.defaultHeaders,\n ...processedConfig.headers,\n };\n\n // Create request options\n const requestOptions: RequestInit = {\n method,\n headers,\n credentials: \"include\", // Include cookies for session management\n signal: this.createAbortController(processedConfig.timeout).signal,\n };\n\n // Add body for non-GET requests\n if (processedConfig.data && method !== \"GET\") {\n requestOptions.body =\n typeof processedConfig.data === \"string\"\n ? processedConfig.data\n : JSON.stringify(processedConfig.data);\n }\n\n let lastError: Error | null = null;\n const maxRetries = processedConfig.retries ?? this.retries;\n\n // Retry logic\n for (let attempt = 0; attempt <= maxRetries; attempt++) {\n try {\n logger.debug(`API Request: ${method} ${url}`, {\n attempt: attempt + 1,\n maxRetries: maxRetries + 1,\n });\n\n const response = await fetch(url, requestOptions);\n\n // Handle error responses\n if (!response.ok) {\n const apiError = await this.parseErrorResponse(response);\n\n // Don't retry client errors (4xx) except for 429 (rate limit)\n if (response.status >= 400 && response.status < 500 && response.status !== 429) {\n throw apiError;\n }\n\n // Retry server errors (5xx) and rate limits (429)\n if (attempt < maxRetries) {\n lastError = apiError;\n const delay = Math.pow(2, attempt) * 1000; // Exponential backoff\n await new Promise(resolve => setTimeout(resolve, delay));\n continue;\n }\n\n throw apiError;\n }\n\n // Parse successful response\n let data: T;\n\n if (response.status === 204) {\n // No content\n data = undefined as T;\n } else {\n const contentType = response.headers.get(\"content-type\");\n if (contentType?.includes(\"application/json\")) {\n data = (await response.json()) as T;\n } else {\n data = (await response.text()) as T;\n }\n }\n\n const apiResponse: ApiResponse = {\n success: true,\n data,\n meta: {\n requestId: response.headers.get(\"x-request-id\") || undefined,\n timestamp: new Date().toISOString(),\n },\n };\n\n // Apply response interceptors\n return await this.applyResponseInterceptors(apiResponse);\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n\n // Don't retry on abort (timeout) or network errors on last attempt\n if (attempt >= maxRetries) {\n break;\n }\n\n // Exponential backoff for retries\n const delay = Math.pow(2, attempt) * 1000;\n await new Promise(resolve => setTimeout(resolve, delay));\n }\n }\n\n // If we get here, all retries failed\n throw lastError ?? new ApiError(\"Request failed after retries\", 0);\n }\n\n /**\n * GET request\n */\n async get(endpoint: string, config?: ApiRequestConfig): Promise> {\n return this.makeRequest(endpoint, { ...config, method: \"GET\" });\n }\n\n /**\n * POST request\n */\n async post(\n endpoint: string,\n data?: unknown,\n config?: ApiRequestConfig\n ): Promise> {\n return this.makeRequest(endpoint, { ...config, method: \"POST\", data });\n }\n\n /**\n * PUT request\n */\n async put(\n endpoint: string,\n data?: unknown,\n config?: ApiRequestConfig\n ): Promise> {\n return this.makeRequest(endpoint, { ...config, method: \"PUT\", data });\n }\n\n /**\n * PATCH request\n */\n async patch(\n endpoint: string,\n data?: unknown,\n config?: ApiRequestConfig\n ): Promise> {\n return this.makeRequest(endpoint, { ...config, method: \"PATCH\", data });\n }\n\n /**\n * DELETE request\n */\n async delete(endpoint: string, config?: ApiRequestConfig): Promise> {\n return this.makeRequest(endpoint, { ...config, method: \"DELETE\" });\n }\n}\n\n// Create default API client instance\nexport const apiClient = new ApiClient({\n baseUrl: env.NEXT_PUBLIC_API_BASE,\n timeout: 30000,\n retries: 3,\n});\n\n// Authentication interceptor\napiClient.addRequestInterceptor(async config => {\n // Import auth store dynamically to avoid circular dependencies\n const { useAuthStore } = await import(\"../auth/store\");\n const { token } = useAuthStore.getState();\n\n if (token) {\n config.headers = {\n ...config.headers,\n Authorization: `Bearer ${token}`,\n };\n }\n\n return config;\n});\n\n// Logging interceptor\napiClient.addRequestInterceptor(async config => {\n logger.debug(\"API Request\", {\n method: config.method,\n url: config.params ? \"with params\" : \"no params\",\n hasData: !!config.data,\n });\n return config;\n});\n\n// Response logging interceptor\napiClient.addResponseInterceptor(async response => {\n logger.debug(\"API Response\", {\n success: response.success,\n hasData: !!response.data,\n requestId: response.meta?.requestId,\n });\n return response;\n});\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/api/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/api/services/auth.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/api/services/billing.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/api/services/subscription.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/auth/api.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/auth/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/auth/store.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/design-system.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/env.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/form-validation.ts","messages":[{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":2,"column":32,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":2,"endColumn":35,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[60,63],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[60,63],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":14,"column":66,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":14,"endColumn":69,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[329,332],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[329,332],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"no-useless-escape","severity":2,"message":"Unnecessary escape character: \\+.","line":48,"column":29,"nodeType":"Literal","messageId":"unnecessaryEscape","endLine":48,"endColumn":30,"suggestions":[{"messageId":"removeEscape","fix":{"range":[1501,1502],"text":""},"desc":"Remove the `\\`. This maintains the current functionality."},{"messageId":"escapeBackslash","fix":{"range":[1501,1501],"text":"\\"},"desc":"Replace the `\\` with `\\\\` to include the actual backslash character."}]},{"ruleId":"no-useless-escape","severity":2,"message":"Unnecessary escape character: \\(.","line":49,"column":60,"nodeType":"Literal","messageId":"unnecessaryEscape","endLine":49,"endColumn":61,"suggestions":[{"messageId":"removeEscape","fix":{"range":[1583,1584],"text":""},"desc":"Remove the `\\`. This maintains the current functionality."},{"messageId":"escapeBackslash","fix":{"range":[1583,1583],"text":"\\"},"desc":"Replace the `\\` with `\\\\` to include the actual backslash character."}]},{"ruleId":"no-useless-escape","severity":2,"message":"Unnecessary escape character: \\).","line":49,"column":62,"nodeType":"Literal","messageId":"unnecessaryEscape","endLine":49,"endColumn":63,"suggestions":[{"messageId":"removeEscape","fix":{"range":[1585,1586],"text":""},"desc":"Remove the `\\`. This maintains the current functionality."},{"messageId":"escapeBackslash","fix":{"range":[1585,1585],"text":"\\"},"desc":"Replace the `\\` with `\\\\` to include the actual backslash character."}]},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":104,"column":55,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":104,"endColumn":58,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[2997,3000],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[2997,3000],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":123,"column":54,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":123,"endColumn":57,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[3504,3507],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[3504,3507],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":130,"column":56,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":130,"endColumn":59,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[3743,3746],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[3743,3746],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]}],"suppressedMessages":[],"errorCount":8,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// Form validation utilities\nexport type ValidationRule = {\n validate: (value: T) => boolean;\n message: string;\n};\n\nexport type ValidationResult = {\n isValid: boolean;\n errors: string[];\n};\n\n// Common validation rules\nexport const validationRules = {\n required: (message = \"This field is required\"): ValidationRule => ({\n validate: value => {\n if (typeof value === \"string\") return value.trim().length > 0;\n if (Array.isArray(value)) return value.length > 0;\n return value != null && value !== \"\";\n },\n message,\n }),\n\n email: (message = \"Please enter a valid email address\"): ValidationRule => ({\n validate: value => {\n const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n return !value || emailRegex.test(value);\n },\n message,\n }),\n\n minLength: (min: number, message?: string): ValidationRule => ({\n validate: value => !value || value.length >= min,\n message: message || `Must be at least ${min} characters`,\n }),\n\n maxLength: (max: number, message?: string): ValidationRule => ({\n validate: value => !value || value.length <= max,\n message: message || `Must be no more than ${max} characters`,\n }),\n\n pattern: (regex: RegExp, message = \"Invalid format\"): ValidationRule => ({\n validate: value => !value || regex.test(value),\n message,\n }),\n\n phone: (message = \"Please enter a valid phone number\"): ValidationRule => ({\n validate: value => {\n const phoneRegex = /^[\\+]?[1-9][\\d]{0,15}$/;\n return !value || phoneRegex.test(value.replace(/[\\s\\-\\(\\)]/g, \"\"));\n },\n message,\n }),\n\n url: (message = \"Please enter a valid URL\"): ValidationRule => ({\n validate: value => {\n try {\n return !value || Boolean(new URL(value));\n } catch {\n return false;\n }\n },\n message,\n }),\n\n number: (message = \"Please enter a valid number\"): ValidationRule => ({\n validate: value => !value || !isNaN(Number(value)),\n message,\n }),\n\n min: (min: number, message?: string): ValidationRule => ({\n validate: value => {\n const num = typeof value === \"string\" ? Number(value) : value;\n return !value || num >= min;\n },\n message: message || `Must be at least ${min}`,\n }),\n\n max: (max: number, message?: string): ValidationRule => ({\n validate: value => {\n const num = typeof value === \"string\" ? Number(value) : value;\n return !value || num <= max;\n },\n message: message || `Must be no more than ${max}`,\n }),\n};\n\n// Validate a single field against multiple rules\nexport function validateField(value: T, rules: ValidationRule[]): ValidationResult {\n const errors: string[] = [];\n\n for (const rule of rules) {\n if (!rule.validate(value)) {\n errors.push(rule.message);\n }\n }\n\n return {\n isValid: errors.length === 0,\n errors,\n };\n}\n\n// Validate multiple fields\nexport function validateForm>(\n values: T,\n rules: Partial[]>>\n): Record {\n const results = {} as Record;\n\n for (const [field, fieldRules] of Object.entries(rules) as [\n keyof T,\n ValidationRule[],\n ][]) {\n if (fieldRules) {\n results[field] = validateField(values[field], fieldRules);\n }\n }\n\n return results;\n}\n\n// Check if entire form is valid\nexport function isFormValid>(\n validationResults: Record\n): boolean {\n return Object.values(validationResults).every(result => result.isValid);\n}\n\n// Get first error for a field\nexport function getFieldError>(\n validationResults: Record,\n field: keyof T\n): string | undefined {\n return validationResults[field]?.errors[0];\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/hooks/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/logger.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/plan.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/query-client.ts","messages":[{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":64,"column":26,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":64,"endColumn":29,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[1966,1969],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[1966,1969],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":70,"column":25,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":70,"endColumn":28,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[2129,2132],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[2129,2132],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":72,"column":25,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":72,"endColumn":28,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[2299,2302],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[2299,2302],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":78,"column":21,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":78,"endColumn":24,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[2472,2475],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[2472,2475],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":86,"column":39,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":86,"endColumn":42,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[2816,2819],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[2816,2819],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":94,"column":22,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":94,"endColumn":25,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[3066,3069],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[3066,3069],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]}],"suppressedMessages":[],"errorCount":6,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// TanStack Query client configuration\n\nimport { QueryClient } from \"@tanstack/react-query\";\n\n// Performance-optimized query client configuration\nexport const queryClient = new QueryClient({\n defaultOptions: {\n queries: {\n // Optimized stale times based on data type\n staleTime: 5 * 60 * 1000, // 5 minutes default\n gcTime: 10 * 60 * 1000, // 10 minutes garbage collection\n\n // Retry configuration with exponential backoff\n retry: (failureCount, error) => {\n // Don't retry on 4xx errors (client errors)\n if (error instanceof Error && \"status\" in error) {\n const status = error.status as number;\n if (status >= 400 && status < 500) {\n return false;\n }\n }\n // Retry up to 3 times with exponential backoff\n return failureCount < 3;\n },\n\n // Retry delay with exponential backoff\n retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),\n\n // Network mode for better offline handling\n networkMode: \"online\",\n\n // Refetch configuration\n refetchOnWindowFocus: false, // Disable aggressive refetching\n refetchOnReconnect: true,\n refetchOnMount: true,\n\n // Background refetch interval (disabled by default for performance)\n refetchInterval: false,\n refetchIntervalInBackground: false,\n },\n mutations: {\n // Don't retry mutations by default\n retry: false,\n\n // Network mode for mutations\n networkMode: \"online\",\n },\n },\n});\n\n// Query key factories for consistent caching\nexport const queryKeys = {\n // User-related queries\n user: {\n all: [\"user\"] as const,\n profile: () => [...queryKeys.user.all, \"profile\"] as const,\n preferences: () => [...queryKeys.user.all, \"preferences\"] as const,\n },\n\n // Dashboard queries\n dashboard: {\n all: [\"dashboard\"] as const,\n summary: () => [...queryKeys.dashboard.all, \"summary\"] as const,\n activity: (filters?: any) => [...queryKeys.dashboard.all, \"activity\", filters] as const,\n },\n\n // Billing queries\n billing: {\n all: [\"billing\"] as const,\n invoices: (params?: any) => [...queryKeys.billing.all, \"invoices\", params] as const,\n invoice: (id: string) => [...queryKeys.billing.all, \"invoice\", id] as const,\n payments: (params?: any) => [...queryKeys.billing.all, \"payments\", params] as const,\n },\n\n // Subscription queries\n subscriptions: {\n all: [\"subscriptions\"] as const,\n list: (params?: any) => [...queryKeys.subscriptions.all, \"list\", params] as const,\n detail: (id: string) => [...queryKeys.subscriptions.all, \"detail\", id] as const,\n usage: (id: string) => [...queryKeys.subscriptions.all, \"usage\", id] as const,\n },\n\n // Catalog queries\n catalog: {\n all: [\"catalog\"] as const,\n products: (type: string, params?: any) =>\n [...queryKeys.catalog.all, \"products\", type, params] as const,\n product: (id: string) => [...queryKeys.catalog.all, \"product\", id] as const,\n },\n\n // Support queries\n support: {\n all: [\"support\"] as const,\n cases: (params?: any) => [...queryKeys.support.all, \"cases\", params] as const,\n case: (id: string) => [...queryKeys.support.all, \"case\", id] as const,\n },\n} as const;\n\n// Optimized query configurations for different data types\nexport const queryConfigs = {\n // Static/rarely changing data (longer cache)\n static: {\n staleTime: 30 * 60 * 1000, // 30 minutes\n gcTime: 60 * 60 * 1000, // 1 hour\n },\n\n // User profile data (medium cache)\n profile: {\n staleTime: 10 * 60 * 1000, // 10 minutes\n gcTime: 30 * 60 * 1000, // 30 minutes\n },\n\n // Financial data (shorter cache for accuracy)\n financial: {\n staleTime: 2 * 60 * 1000, // 2 minutes\n gcTime: 10 * 60 * 1000, // 10 minutes\n },\n\n // Real-time data (very short cache)\n realtime: {\n staleTime: 30 * 1000, // 30 seconds\n gcTime: 2 * 60 * 1000, // 2 minutes\n },\n\n // List data (medium cache with background updates)\n list: {\n staleTime: 5 * 60 * 1000, // 5 minutes\n gcTime: 15 * 60 * 1000, // 15 minutes\n refetchOnWindowFocus: true,\n },\n} as const;\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/stores/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/types/api.types.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/types/auth.types.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/types/billing.types.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/types/catalog.types.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/types/common.types.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/types/form.types.ts","messages":[{"ruleId":"no-useless-escape","severity":2,"message":"Unnecessary escape character: \\(.","line":281,"column":38,"nodeType":"Literal","messageId":"unnecessaryEscape","endLine":281,"endColumn":39,"suggestions":[{"messageId":"removeEscape","fix":{"range":[7570,7571],"text":""},"desc":"Remove the `\\`. This maintains the current functionality."},{"messageId":"escapeBackslash","fix":{"range":[7570,7570],"text":"\\"},"desc":"Replace the `\\` with `\\\\` to include the actual backslash character."}]},{"ruleId":"no-useless-escape","severity":2,"message":"Unnecessary escape character: \\).","line":281,"column":40,"nodeType":"Literal","messageId":"unnecessaryEscape","endLine":281,"endColumn":41,"suggestions":[{"messageId":"removeEscape","fix":{"range":[7572,7573],"text":""},"desc":"Remove the `\\`. This maintains the current functionality."},{"messageId":"escapeBackslash","fix":{"range":[7572,7572],"text":"\\"},"desc":"Replace the `\\` with `\\\\` to include the actual backslash character."}]}],"suppressedMessages":[],"errorCount":2,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Form validation and input types\n */\n\n// Base form types\nexport interface FormField {\n name: string;\n value: T;\n error?: string;\n touched: boolean;\n dirty: boolean;\n disabled?: boolean;\n required?: boolean;\n}\n\nexport interface FormState = Record> {\n values: T;\n errors: Partial>;\n touched: Partial>;\n dirty: boolean;\n valid: boolean;\n submitting: boolean;\n submitted: boolean;\n}\n\n// Validation types\nexport type ValidationRule = (value: T) => string | undefined;\n\nexport interface FieldValidation {\n required?: boolean | string;\n min?: number | string;\n max?: number | string;\n minLength?: number | string;\n maxLength?: number | string;\n pattern?: RegExp | string;\n custom?: ValidationRule[];\n}\n\nexport interface FormValidation = Record> {\n fields: Partial>;\n global?: ValidationRule[];\n}\n\n// Input component types\nexport interface BaseInputProps {\n name: string;\n label?: string;\n placeholder?: string;\n disabled?: boolean;\n required?: boolean;\n error?: string;\n helperText?: string;\n className?: string;\n testId?: string;\n}\n\nexport interface TextInputProps extends BaseInputProps {\n type?: \"text\" | \"email\" | \"password\" | \"tel\" | \"url\" | \"search\";\n value: string;\n onChange: (value: string) => void;\n onBlur?: () => void;\n onFocus?: () => void;\n autoComplete?: string;\n maxLength?: number;\n minLength?: number;\n pattern?: string;\n readOnly?: boolean;\n autoFocus?: boolean;\n}\n\nexport interface NumberInputProps extends BaseInputProps {\n value: number | \"\";\n onChange: (value: number | \"\") => void;\n onBlur?: () => void;\n onFocus?: () => void;\n min?: number;\n max?: number;\n step?: number;\n precision?: number;\n format?: \"decimal\" | \"currency\" | \"percentage\";\n currency?: string;\n}\n\nexport interface SelectInputProps extends BaseInputProps {\n value: string | string[];\n onChange: (value: string | string[]) => void;\n onBlur?: () => void;\n onFocus?: () => void;\n options: SelectOption[];\n multiple?: boolean;\n searchable?: boolean;\n clearable?: boolean;\n loading?: boolean;\n onSearch?: (query: string) => void;\n}\n\nexport interface SelectOption {\n value: string;\n label: string;\n disabled?: boolean;\n group?: string;\n icon?: string;\n description?: string;\n}\n\nexport interface CheckboxInputProps extends BaseInputProps {\n checked: boolean;\n onChange: (checked: boolean) => void;\n onBlur?: () => void;\n onFocus?: () => void;\n indeterminate?: boolean;\n}\n\nexport interface RadioInputProps extends BaseInputProps {\n value: string;\n selectedValue: string;\n onChange: (value: string) => void;\n onBlur?: () => void;\n onFocus?: () => void;\n}\n\nexport interface TextareaInputProps extends BaseInputProps {\n value: string;\n onChange: (value: string) => void;\n onBlur?: () => void;\n onFocus?: () => void;\n rows?: number;\n cols?: number;\n maxLength?: number;\n minLength?: number;\n resize?: \"none\" | \"vertical\" | \"horizontal\" | \"both\";\n autoResize?: boolean;\n}\n\nexport interface FileInputProps extends BaseInputProps {\n value: File[];\n onChange: (files: File[]) => void;\n onBlur?: () => void;\n onFocus?: () => void;\n accept?: string;\n multiple?: boolean;\n maxSize?: number;\n maxFiles?: number;\n preview?: boolean;\n dragAndDrop?: boolean;\n}\n\nexport interface DateInputProps extends BaseInputProps {\n value: string;\n onChange: (value: string) => void;\n onBlur?: () => void;\n onFocus?: () => void;\n min?: string;\n max?: string;\n format?: string;\n showTime?: boolean;\n timezone?: string;\n}\n\n// Form component types\nexport interface FormProps = Record> {\n initialValues: T;\n validation?: FormValidation;\n onSubmit: (values: T) => void | Promise;\n onChange?: (values: T) => void;\n children: React.ReactNode;\n className?: string;\n testId?: string;\n}\n\nexport interface FieldProps {\n name: string;\n children: (field: FormField) => React.ReactNode;\n}\n\nexport interface FieldArrayProps {\n name: string;\n children: (fields: {\n items: T[];\n add: (item: T) => void;\n remove: (index: number) => void;\n move: (from: number, to: number) => void;\n replace: (index: number, item: T) => void;\n }) => React.ReactNode;\n}\n\n// Form hooks\nexport interface UseFormOptions = Record> {\n initialValues: T;\n validation?: FormValidation;\n onSubmit?: (values: T) => void | Promise;\n onChange?: (values: T) => void;\n validateOnChange?: boolean;\n validateOnBlur?: boolean;\n}\n\nexport interface UseFormReturn = Record> {\n values: T;\n errors: Partial>;\n touched: Partial>;\n dirty: boolean;\n valid: boolean;\n submitting: boolean;\n submitted: boolean;\n setValue: (name: K, value: T[K]) => void;\n setError: (name: K, error: string) => void;\n setTouched: (name: K, touched: boolean) => void;\n setFieldValue: (name: K, value: T[K]) => void;\n setFieldError: (name: K, error: string) => void;\n setFieldTouched: (name: K, touched: boolean) => void;\n validateField: (name: K) => void;\n validateForm: () => void;\n resetForm: (values?: T) => void;\n submitForm: () => void;\n handleSubmit: (event: React.FormEvent) => void;\n getFieldProps: (name: K) => FormField;\n}\n\n// Validation utilities\nexport interface ValidationError {\n field: string;\n message: string;\n code?: string;\n}\n\nexport interface ValidationResult {\n valid: boolean;\n errors: ValidationError[];\n}\n\n// Common validation rules\nexport const ValidationRules = {\n required:\n (message = \"This field is required\"): ValidationRule =>\n value =>\n !value || (typeof value === \"string\" && !value.trim()) ? message : undefined,\n\n email:\n (message = \"Please enter a valid email address\"): ValidationRule =>\n value => {\n const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n return value && !emailRegex.test(value) ? message : undefined;\n },\n\n minLength:\n (min: number, message?: string): ValidationRule =>\n value => {\n const msg = message || `Must be at least ${min} characters`;\n return value && value.length < min ? msg : undefined;\n },\n\n maxLength:\n (max: number, message?: string): ValidationRule =>\n value => {\n const msg = message || `Must be no more than ${max} characters`;\n return value && value.length > max ? msg : undefined;\n },\n\n pattern:\n (regex: RegExp, message = \"Invalid format\"): ValidationRule =>\n value =>\n value && !regex.test(value) ? message : undefined,\n\n min:\n (min: number, message?: string): ValidationRule =>\n value => {\n const msg = message || `Must be at least ${min}`;\n return typeof value === \"number\" && value < min ? msg : undefined;\n },\n\n max:\n (max: number, message?: string): ValidationRule =>\n value => {\n const msg = message || `Must be no more than ${max}`;\n return typeof value === \"number\" && value > max ? msg : undefined;\n },\n\n phone:\n (message = \"Please enter a valid phone number\"): ValidationRule =>\n value => {\n const phoneRegex = /^\\+?[\\d\\s\\-\\(\\)]+$/;\n return value && !phoneRegex.test(value) ? message : undefined;\n },\n\n url:\n (message = \"Please enter a valid URL\"): ValidationRule =>\n value => {\n try {\n if (value) new URL(value);\n return undefined;\n } catch {\n return message;\n }\n },\n\n password:\n (\n message = \"Password must be at least 8 characters with uppercase, lowercase, and number\"\n ): ValidationRule =>\n value => {\n const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d@$!%*?&]{8,}$/;\n return value && !passwordRegex.test(value) ? message : undefined;\n },\n\n confirmPassword:\n (passwordField: string, message = \"Passwords do not match\"): ValidationRule =>\n (value: string) => {\n // Note: This would need access to form values in actual implementation\n // For now, just validate that value exists\n return !value ? message : undefined;\n },\n} as const;\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/types/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/types/subscription.types.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/utils.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/utils/bundle-monitor.ts","messages":[{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":2,"message":"Unsafe assignment of an `any` value.","line":96,"column":17,"nodeType":"VariableDeclarator","messageId":"anyAssignment","endLine":96,"endColumn":42},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":96,"column":39,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":96,"endColumn":42,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[2562,2565],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[2562,2565],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access .processingStart on an `any` value.","line":97,"column":26,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":97,"endColumn":41},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access .startTime on an `any` value.","line":97,"column":56,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":97,"endColumn":65},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access .processingStart on an `any` value.","line":98,"column":36,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":98,"endColumn":51},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access .startTime on an `any` value.","line":98,"column":65,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":98,"endColumn":74},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":108,"column":26,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":108,"endColumn":29,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[3030,3033],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[3030,3033],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access .hadRecentInput on an `any` value.","line":108,"column":31,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":108,"endColumn":45},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":109,"column":30,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":109,"endColumn":33,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[3082,3085],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[3082,3085],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access .value on an `any` value.","line":109,"column":35,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":109,"endColumn":40}],"suppressedMessages":[],"errorCount":10,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Bundle size and performance monitoring utilities\n */\n\ninterface BundleMetrics {\n totalSize: number;\n gzippedSize: number;\n chunks: Array<{\n name: string;\n size: number;\n gzippedSize: number;\n }>;\n loadTime: number;\n timestamp: number;\n}\n\ninterface PerformanceMetrics {\n fcp: number; // First Contentful Paint\n lcp: number; // Largest Contentful Paint\n fid: number; // First Input Delay\n cls: number; // Cumulative Layout Shift\n ttfb: number; // Time to First Byte\n}\n\n/**\n * Monitor and report bundle metrics\n */\nexport class BundleMonitor {\n private static instance: BundleMonitor;\n private metrics: BundleMetrics[] = [];\n private performanceMetrics: PerformanceMetrics | null = null;\n\n static getInstance(): BundleMonitor {\n if (!BundleMonitor.instance) {\n BundleMonitor.instance = new BundleMonitor();\n }\n return BundleMonitor.instance;\n }\n\n /**\n * Record bundle metrics\n */\n recordBundleMetrics(metrics: Partial): void {\n const fullMetrics: BundleMetrics = {\n totalSize: 0,\n gzippedSize: 0,\n chunks: [],\n loadTime: 0,\n timestamp: Date.now(),\n ...metrics,\n };\n\n this.metrics.push(fullMetrics);\n\n // Keep only last 10 measurements\n if (this.metrics.length > 10) {\n this.metrics = this.metrics.slice(-10);\n }\n\n // Log to console in development\n if (process.env.NODE_ENV === \"development\") {\n console.log(\"Bundle Metrics:\", fullMetrics);\n }\n }\n\n /**\n * Measure and record Core Web Vitals\n */\n measureCoreWebVitals(): void {\n if (typeof window === \"undefined\") return;\n\n // Use the web-vitals library if available, otherwise use Performance API\n if (\"PerformanceObserver\" in window) {\n // Measure FCP\n new PerformanceObserver(list => {\n const entries = list.getEntries();\n const fcp = entries.find(entry => entry.name === \"first-contentful-paint\");\n if (fcp) {\n this.updatePerformanceMetric(\"fcp\", fcp.startTime);\n }\n }).observe({ entryTypes: [\"paint\"] });\n\n // Measure LCP\n new PerformanceObserver(list => {\n const entries = list.getEntries();\n const lastEntry = entries[entries.length - 1];\n if (lastEntry) {\n this.updatePerformanceMetric(\"lcp\", lastEntry.startTime);\n }\n }).observe({ entryTypes: [\"largest-contentful-paint\"] });\n\n // Measure FID\n new PerformanceObserver(list => {\n const entries = list.getEntries();\n entries.forEach(entry => {\n const eventEntry = entry as any; // Type assertion for processingStart\n if (eventEntry.processingStart && eventEntry.startTime) {\n const fid = eventEntry.processingStart - eventEntry.startTime;\n this.updatePerformanceMetric(\"fid\", fid);\n }\n });\n }).observe({ entryTypes: [\"first-input\"] });\n\n // Measure CLS\n new PerformanceObserver(list => {\n let cls = 0;\n list.getEntries().forEach(entry => {\n if (!(entry as any).hadRecentInput) {\n cls += (entry as any).value;\n }\n });\n this.updatePerformanceMetric(\"cls\", cls);\n }).observe({ entryTypes: [\"layout-shift\"] });\n\n // Measure TTFB\n const navigation = performance.getEntriesByType(\"navigation\")[0];\n if (navigation) {\n const ttfb = navigation.responseStart - navigation.requestStart;\n this.updatePerformanceMetric(\"ttfb\", ttfb);\n }\n }\n }\n\n private updatePerformanceMetric(metric: keyof PerformanceMetrics, value: number): void {\n if (!this.performanceMetrics) {\n this.performanceMetrics = {\n fcp: 0,\n lcp: 0,\n fid: 0,\n cls: 0,\n ttfb: 0,\n };\n }\n\n this.performanceMetrics[metric] = value;\n\n // Log to console in development\n if (process.env.NODE_ENV === \"development\") {\n console.log(`Core Web Vital - ${metric.toUpperCase()}:`, value);\n }\n\n // Report to analytics service if configured\n this.reportToAnalytics(metric, value);\n }\n\n /**\n * Get current performance metrics\n */\n getPerformanceMetrics(): PerformanceMetrics | null {\n return this.performanceMetrics;\n }\n\n /**\n * Get bundle metrics history\n */\n getBundleMetrics(): BundleMetrics[] {\n return [...this.metrics];\n }\n\n /**\n * Report metrics to analytics service\n */\n private reportToAnalytics(metric: string, value: number): void {\n // This would integrate with your analytics service\n // For now, we'll just store it locally\n if (typeof window !== \"undefined\" && window.localStorage) {\n const key = `perf_${metric}`;\n const data = {\n value,\n timestamp: Date.now(),\n url: window.location.pathname,\n };\n localStorage.setItem(key, JSON.stringify(data));\n }\n }\n\n /**\n * Check if performance is within acceptable thresholds\n */\n isPerformanceGood(): boolean {\n if (!this.performanceMetrics) return false;\n\n const thresholds = {\n fcp: 1800, // 1.8s\n lcp: 2500, // 2.5s\n fid: 100, // 100ms\n cls: 0.1, // 0.1\n ttfb: 800, // 800ms\n };\n\n return (\n this.performanceMetrics.fcp <= thresholds.fcp &&\n this.performanceMetrics.lcp <= thresholds.lcp &&\n this.performanceMetrics.fid <= thresholds.fid &&\n this.performanceMetrics.cls <= thresholds.cls &&\n this.performanceMetrics.ttfb <= thresholds.ttfb\n );\n }\n\n /**\n * Get performance score (0-100)\n */\n getPerformanceScore(): number {\n if (!this.performanceMetrics) return 0;\n\n const weights = {\n fcp: 0.15,\n lcp: 0.25,\n fid: 0.25,\n cls: 0.25,\n ttfb: 0.1,\n };\n\n const thresholds = {\n fcp: { good: 1800, poor: 3000 },\n lcp: { good: 2500, poor: 4000 },\n fid: { good: 100, poor: 300 },\n cls: { good: 0.1, poor: 0.25 },\n ttfb: { good: 800, poor: 1800 },\n };\n\n let totalScore = 0;\n\n Object.entries(this.performanceMetrics).forEach(([metric, value]) => {\n const threshold = thresholds[metric as keyof typeof thresholds];\n const weight = weights[metric as keyof typeof weights];\n\n let score = 100;\n if (value > threshold.poor) {\n score = 0;\n } else if (value > threshold.good) {\n score = 50;\n }\n\n totalScore += score * weight;\n });\n\n return Math.round(totalScore);\n }\n}\n\n// Export singleton instance\nexport const bundleMonitor = BundleMonitor.getInstance();\n\n// Auto-start monitoring in browser\nif (typeof window !== \"undefined\") {\n bundleMonitor.measureCoreWebVitals();\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/utils/css-variables.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/utils/dynamic-import.ts","messages":[{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":6,"column":61,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":6,"endColumn":64,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[188,191],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[188,191],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":13,"column":23,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":13,"endColumn":26,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[352,355],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[352,355],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-unsafe-member-access","severity":2,"message":"Unsafe member access .displayName on an `any` value.","line":13,"column":28,"nodeType":"Identifier","messageId":"unsafeMemberExpression","endLine":13,"endColumn":39},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":22,"column":59,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":22,"endColumn":62,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[561,564],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[561,564],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":33,"column":58,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":33,"endColumn":61,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[850,853],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[850,853],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]},{"ruleId":"@typescript-eslint/no-explicit-any","severity":2,"message":"Unexpected any. Specify a different type.","line":56,"column":72,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":56,"endColumn":75,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[1466,1469],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[1466,1469],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]}],"suppressedMessages":[],"errorCount":6,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"import { lazy, ComponentType } from \"react\";\n\n/**\n * Utility for creating lazy-loaded components with better error handling\n */\nexport function createLazyComponent>(\n importFn: () => Promise<{ default: T }>,\n displayName?: string\n): T {\n const LazyComponent = lazy(importFn);\n\n if (displayName) {\n (LazyComponent as any).displayName = `Lazy(${displayName})`;\n }\n\n return LazyComponent as unknown as T;\n}\n\n/**\n * Utility for creating lazy-loaded feature modules\n */\nexport function createLazyFeature>(\n featureName: string,\n componentName: string,\n importFn: () => Promise<{ default: T }>\n): T {\n return createLazyComponent(importFn, `${featureName}.${componentName}`);\n}\n\n/**\n * Preload a dynamic import for better UX\n */\nexport function preloadComponent(importFn: () => Promise): void {\n // Only preload in browser environment\n if (typeof window !== \"undefined\") {\n // Use requestIdleCallback if available, otherwise setTimeout\n if (\"requestIdleCallback\" in window) {\n window.requestIdleCallback(() => {\n importFn().catch(() => {\n // Silently ignore preload errors\n });\n });\n } else {\n setTimeout(() => {\n importFn().catch(() => {\n // Silently ignore preload errors\n });\n }, 100);\n }\n }\n}\n\n/**\n * Create a preloadable lazy component\n */\nexport function createPreloadableLazyComponent>(\n importFn: () => Promise<{ default: T }>,\n displayName?: string\n): T & { preload: () => void } {\n const LazyComponent = createLazyComponent(importFn, displayName) as T & { preload: () => void };\n\n LazyComponent.preload = () => preloadComponent(importFn);\n\n return LazyComponent;\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/utils/route-preloader.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/utils/sso.ts","messages":[{"ruleId":"prettier/prettier","severity":1,"message":"Delete `āŽ`","line":13,"column":1,"nodeType":null,"messageId":"delete","endLine":14,"endColumn":1,"fix":{"range":[331,332],"text":""}}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":1,"source":"export function openSsoLink(url: string, options?: { newTab?: boolean }) {\n const { newTab = true } = options || {};\n try {\n if (newTab) {\n window.open(url, \"_blank\", \"noopener,noreferrer\");\n } else {\n window.location.href = url;\n }\n } catch {\n // Silent no-op; callers already handle errors/logging\n }\n}\n\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/providers/query-provider.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/shared/types/catalog.types.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/types/world-countries.d.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/utils/currency.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]}] \ No newline at end of file diff --git a/package.json b/package.json index e4d5289f..4e1f2d4c 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "predev": "pnpm --filter @customer-portal/domain build", "dev": "./scripts/dev/manage.sh apps", "dev:all": "pnpm --parallel --filter @customer-portal/domain --filter @customer-portal/portal --filter @customer-portal/bff run dev", - "build": "NODE_OPTIONS=\"--max-old-space-size=12288 --max-semi-space-size=512\" pnpm --recursive --reporter=default run build", + "build": "pnpm --recursive run build", "start": "pnpm --parallel --filter @customer-portal/portal --filter @customer-portal/bff run start", "test": "pnpm --recursive run test", "lint": "pnpm --recursive run lint", @@ -21,13 +21,15 @@ "format:check": "prettier -c .", "prepare": "husky", "type-check": "pnpm type-check:packages && pnpm type-check:apps", - "type-check:workspace": "NODE_OPTIONS=\"--max-old-space-size=8192 --max-semi-space-size=256\" tsc -b --pretty false --noEmit", + "type-check:workspace": "tsc -b --noEmit", "type-check:packages": "pnpm --workspace-concurrency=1 --filter @customer-portal/domain --filter @customer-portal/validation --filter @customer-portal/logging run type-check", "type-check:apps": "pnpm --workspace-concurrency=1 --filter @customer-portal/bff --filter @customer-portal/portal run type-check", "clean": "pnpm --recursive run clean", "dev:start": "./scripts/dev/manage.sh start", "dev:stop": "./scripts/dev/manage.sh stop", "dev:restart": "./scripts/dev/manage.sh restart", + "analyze": "pnpm --filter @customer-portal/portal run analyze", + "bundle-analyze": "./scripts/bundle-analyze.sh", "dev:tools": "./scripts/dev/manage.sh tools", "dev:apps": "./scripts/dev/manage.sh apps", "dev:logs": "./scripts/dev/manage.sh logs", @@ -71,8 +73,5 @@ "typescript": "^5.9.2", "typescript-eslint": "^8.40.0", "zod": "^4.1.9" - }, - "dependencies": { - "@sendgrid/mail": "^8.1.5" } } diff --git a/packages/domain/package-lock.json b/packages/domain/package-lock.json deleted file mode 100644 index c1fd9f2f..00000000 --- a/packages/domain/package-lock.json +++ /dev/null @@ -1,89 +0,0 @@ -{ - "name": "@customer-portal/domain", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@customer-portal/domain", - "version": "1.0.0", - "dependencies": { - "zod": "^4.1.9" - }, - "devDependencies": { - "typescript": "^5.9.2" - } - }, - "../../node_modules/.pnpm/typescript@5.9.2/node_modules/typescript": { - "version": "5.9.2", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "devDependencies": { - "@dprint/formatter": "^0.4.1", - "@dprint/typescript": "0.93.4", - "@esfx/canceltoken": "^1.0.0", - "@eslint/js": "^9.20.0", - "@octokit/rest": "^21.1.1", - "@types/chai": "^4.3.20", - "@types/diff": "^7.0.1", - "@types/minimist": "^1.2.5", - "@types/mocha": "^10.0.10", - "@types/ms": "^0.7.34", - "@types/node": "latest", - "@types/source-map-support": "^0.5.10", - "@types/which": "^3.0.4", - "@typescript-eslint/rule-tester": "^8.24.1", - "@typescript-eslint/type-utils": "^8.24.1", - "@typescript-eslint/utils": "^8.24.1", - "azure-devops-node-api": "^14.1.0", - "c8": "^10.1.3", - "chai": "^4.5.0", - "chokidar": "^4.0.3", - "diff": "^7.0.0", - "dprint": "^0.49.0", - "esbuild": "^0.25.0", - "eslint": "^9.20.1", - "eslint-formatter-autolinkable-stylish": "^1.4.0", - "eslint-plugin-regexp": "^2.7.0", - "fast-xml-parser": "^4.5.2", - "glob": "^10.4.5", - "globals": "^15.15.0", - "hereby": "^1.10.0", - "jsonc-parser": "^3.3.1", - "knip": "^5.44.4", - "minimist": "^1.2.8", - "mocha": "^10.8.2", - "mocha-fivemat-progress-reporter": "^0.1.0", - "monocart-coverage-reports": "^2.12.1", - "ms": "^2.1.3", - "picocolors": "^1.1.1", - "playwright": "^1.50.1", - "source-map-support": "^0.5.21", - "tslib": "^2.8.1", - "typescript": "^5.7.3", - "typescript-eslint": "^8.24.1", - "which": "^3.0.1" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript": { - "resolved": "../../node_modules/.pnpm/typescript@5.9.2/node_modules/typescript", - "link": true - }, - "node_modules/zod": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.9.tgz", - "integrity": "sha512-HI32jTq0AUAC125z30E8bQNz0RQ+9Uc+4J7V97gLYjZVKRjeydPgGt6dvQzFrav7MYOUGFqqOGiHpA/fdbd0cQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/packages/domain/src/validation/api/requests.ts b/packages/domain/src/validation/api/requests.ts index be70a4f7..b0c00325 100644 --- a/packages/domain/src/validation/api/requests.ts +++ b/packages/domain/src/validation/api/requests.ts @@ -79,7 +79,10 @@ export const checkPasswordNeededRequestSchema = z.object({ }); export const refreshTokenRequestSchema = z.object({ - refreshToken: z.string().min(1, "Refresh token is required"), + refreshToken: z + .string() + .min(1, "Refresh token is required") + .optional(), deviceId: z.string().optional(), }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91287763..e4c7d9e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,10 +7,6 @@ settings: importers: .: - dependencies: - '@sendgrid/mail': - specifier: ^8.1.5 - version: 8.1.5 devDependencies: '@eslint/eslintrc': specifier: ^3.3.1 @@ -94,8 +90,8 @@ importers: specifier: ^6.14.0 version: 6.16.0(prisma@6.16.0(typescript@5.9.2))(typescript@5.9.2) '@sendgrid/mail': - specifier: ^8.1.3 - version: 8.1.5 + specifier: ^8.1.6 + version: 8.1.6 bcrypt: specifier: ^6.0.0 version: 6.0.0 @@ -112,8 +108,8 @@ importers: specifier: ^1.4.7 version: 1.4.7 express: - specifier: ^4.21.2 - version: 4.21.2 + specifier: ^5.1.0 + version: 5.1.0 helmet: specifier: ^8.1.0 version: 8.1.0 @@ -132,6 +128,9 @@ importers: nestjs-zod: specifier: ^5.0.1 version: 5.0.1(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.2.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.1.9) + p-queue: + specifier: ^7.4.1 + version: 7.4.1 passport: specifier: ^0.7.0 version: 0.7.0 @@ -163,8 +162,8 @@ importers: specifier: ^2.0.0 version: 2.0.0 uuid: - specifier: ^11.1.0 - version: 11.1.0 + specifier: ^13.0.0 + version: 13.0.0 zod: specifier: ^4.1.9 version: 4.1.9 @@ -209,8 +208,8 @@ importers: specifier: ^6.0.3 version: 6.0.3 '@types/uuid': - specifier: ^10.0.0 - version: 10.0.0 + specifier: ^11.0.0 + version: 11.0.0 jest: specifier: ^30.0.5 version: 30.1.3(@types/node@24.3.1)(ts-node@10.9.2(@types/node@24.3.1)(typescript@5.9.2)) @@ -1552,8 +1551,8 @@ packages: resolution: {integrity: sha512-Ze7WuW2Xzy5GT5WRx+yEv89fsg/pgy3T1E3FS0QEx0/VvRmigMZ5qyVGhJz4SxomegDkzXv/i0aFPpHKN8qdAA==} engines: {node: '>= 12.0.0'} - '@sendgrid/mail@8.1.5': - resolution: {integrity: sha512-W+YuMnkVs4+HA/bgfto4VHKcPKLc7NiZ50/NH2pzO6UHCCFuq8/GNB98YJlLEr/ESDyzAaDr7lVE7hoBwFTT3Q==} + '@sendgrid/mail@8.1.6': + resolution: {integrity: sha512-/ZqxUvKeEztU9drOoPC/8opEPOk+jLlB2q4+xpx6HVLq6aFu3pMpalkTpAQz8XfRfpLp8O25bh6pGPcHDCYpqg==} engines: {node: '>=12.*'} '@sinclair/typebox@0.34.41': @@ -1832,8 +1831,9 @@ packages: '@types/supertest@6.0.3': resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} - '@types/uuid@10.0.0': - resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@types/uuid@11.0.0': + resolution: {integrity: sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==} + deprecated: This is a stub types definition. uuid provides its own type definitions, so you do not need this installed. '@types/validator@13.15.3': resolution: {integrity: sha512-7bcUmDyS6PN3EuD9SlGGOxM77F8WLVsrwkxyWxKnxzmXoequ6c7741QBrANq6htVRGOITJ7z72mTP6Z4XyuG+Q==} @@ -2052,10 +2052,6 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} - accepts@1.3.8: - resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} - engines: {node: '>= 0.6'} - accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -2172,9 +2168,6 @@ packages: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} - array-flatten@1.1.1: - resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} - array-includes@3.1.9: resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} engines: {node: '>= 0.4'} @@ -2286,10 +2279,6 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - body-parser@1.20.3: - resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - body-parser@2.2.0: resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} engines: {node: '>=18'} @@ -2508,10 +2497,6 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} - content-disposition@0.5.4: - resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} - engines: {node: '>= 0.6'} - content-disposition@1.0.0: resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} engines: {node: '>= 0.6'} @@ -2534,10 +2519,6 @@ packages: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} - cookie@0.7.1: - resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} - engines: {node: '>= 0.6'} - cookie@0.7.2: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} @@ -2612,14 +2593,6 @@ packages: dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} - debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -2685,10 +2658,6 @@ packages: destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} - destroy@1.2.0: - resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - detect-libc@2.0.4: resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} engines: {node: '>=8'} @@ -2753,10 +2722,6 @@ packages: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} - encodeurl@1.0.2: - resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} - engines: {node: '>= 0.8'} - encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -2977,6 +2942,9 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -2993,10 +2961,6 @@ packages: resolution: {integrity: sha512-xvHszRavo28ejws8FpemjhwswGj4w/BetHIL8cU49u4sGyXDw2+p3YbeDbj6xzlxi6kWTjIRSTJ+9sNXPnF0Zg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - express@4.21.2: - resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} - engines: {node: '>= 0.10.0'} - express@5.1.0: resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} engines: {node: '>= 18'} @@ -3083,10 +3047,6 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - finalhandler@1.3.1: - resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} - engines: {node: '>= 0.8'} - finalhandler@2.1.0: resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} engines: {node: '>= 0.8'} @@ -3142,10 +3102,6 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} - fresh@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} - engines: {node: '>= 0.6'} - fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -3322,10 +3278,6 @@ packages: engines: {node: '>=18'} hasBin: true - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} - engines: {node: '>=0.10.0'} - iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -3973,9 +3925,6 @@ packages: resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} engines: {node: '>= 4.0.0'} - merge-descriptors@1.0.3: - resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} - merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} @@ -4011,11 +3960,6 @@ packages: resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} engines: {node: '>= 0.6'} - mime@1.6.0: - resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} - engines: {node: '>=4'} - hasBin: true - mime@2.6.0: resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} engines: {node: '>=4.0.0'} @@ -4056,9 +4000,6 @@ packages: engines: {node: '>=10'} hasBin: true - ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -4096,10 +4037,6 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - negotiator@0.6.3: - resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} - engines: {node: '>= 0.6'} - negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -4285,6 +4222,14 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-queue@7.4.1: + resolution: {integrity: sha512-vRpMXmIkYF2/1hLBKisKeVYJZ8S2tZ0zEAmIJgdVKP2nq0nh4qCdf8bgw+ZgKrkh71AOCaqzwbJJk1WtdcF3VA==} + engines: {node: '>=12'} + + p-timeout@5.1.0: + resolution: {integrity: sha512-auFDyzzzGZZZdHz3BtET9VEz0SE/uMEAx7uWfGPucfzEwwe/xH0iVeZibQmANYE/hp9T2+UUZT5m+BKyrDp3Ew==} + engines: {node: '>=12'} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -4342,9 +4287,6 @@ packages: resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} engines: {node: 20 || >=22} - path-to-regexp@0.1.12: - resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} - path-to-regexp@8.2.0: resolution: {integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==} engines: {node: '>=16'} @@ -4478,10 +4420,6 @@ packages: pure-rand@7.0.1: resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} - qs@6.13.0: - resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} - engines: {node: '>=0.6'} - qs@6.14.0: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} @@ -4499,10 +4437,6 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} - raw-body@2.5.2: - resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} - engines: {node: '>= 0.8'} - raw-body@3.0.1: resolution: {integrity: sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==} engines: {node: '>= 0.10'} @@ -4674,10 +4608,6 @@ packages: engines: {node: '>=10'} hasBin: true - send@0.19.0: - resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} - engines: {node: '>= 0.8.0'} - send@1.2.0: resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} engines: {node: '>= 18'} @@ -4689,10 +4619,6 @@ packages: serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} - serve-static@1.16.2: - resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} - engines: {node: '>= 0.8.0'} - serve-static@2.2.0: resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} engines: {node: '>= 18'} @@ -5197,8 +5123,8 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} - uuid@11.1.0: - resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} hasBin: true uuid@9.0.1: @@ -6575,7 +6501,7 @@ snapshots: dependencies: deepmerge: 4.3.1 - '@sendgrid/mail@8.1.5': + '@sendgrid/mail@8.1.6': dependencies: '@sendgrid/client': 8.1.5 '@sendgrid/helpers': 8.0.0 @@ -6878,7 +6804,9 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 - '@types/uuid@10.0.0': {} + '@types/uuid@11.0.0': + dependencies: + uuid: 13.0.0 '@types/validator@13.15.3': {} @@ -7122,11 +7050,6 @@ snapshots: '@xtuc/long@4.2.2': {} - accepts@1.3.8: - dependencies: - mime-types: 2.1.35 - negotiator: 0.6.3 - accepts@2.0.0: dependencies: mime-types: 3.0.1 @@ -7225,8 +7148,6 @@ snapshots: call-bound: 1.0.4 is-array-buffer: 3.0.5 - array-flatten@1.1.1: {} - array-includes@3.1.9: dependencies: call-bind: 1.0.8 @@ -7394,23 +7315,6 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - body-parser@1.20.3: - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - http-errors: 2.0.0 - iconv-lite: 0.4.24 - on-finished: 2.4.1 - qs: 6.13.0 - raw-body: 2.5.2 - type-is: 1.6.18 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - body-parser@2.2.0: dependencies: bytes: 3.1.2 @@ -7640,10 +7544,6 @@ snapshots: consola@3.4.2: {} - content-disposition@0.5.4: - dependencies: - safe-buffer: 5.2.1 - content-disposition@1.0.0: dependencies: safe-buffer: 5.2.1 @@ -7661,8 +7561,6 @@ snapshots: cookie-signature@1.2.2: {} - cookie@0.7.1: {} - cookie@0.7.2: {} cookiejar@2.1.4: {} @@ -7733,10 +7631,6 @@ snapshots: dateformat@4.6.3: {} - debug@2.6.9: - dependencies: - ms: 2.0.0 - debug@3.2.7: dependencies: ms: 2.1.3 @@ -7779,8 +7673,6 @@ snapshots: destr@2.0.5: {} - destroy@1.2.0: {} - detect-libc@2.0.4: {} detect-newline@3.1.0: {} @@ -7833,8 +7725,6 @@ snapshots: empathic@2.0.0: {} - encodeurl@1.0.2: {} - encodeurl@2.0.0: {} end-of-stream@1.4.5: @@ -8209,6 +8099,8 @@ snapshots: etag@1.8.1: {} + eventemitter3@5.0.1: {} + events@3.3.0: {} execa@5.1.1: @@ -8234,42 +8126,6 @@ snapshots: jest-mock: 30.0.5 jest-util: 30.0.5 - express@4.21.2: - dependencies: - accepts: 1.3.8 - array-flatten: 1.1.1 - body-parser: 1.20.3 - content-disposition: 0.5.4 - content-type: 1.0.5 - cookie: 0.7.1 - cookie-signature: 1.0.6 - debug: 2.6.9 - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 1.3.1 - fresh: 0.5.2 - http-errors: 2.0.0 - merge-descriptors: 1.0.3 - methods: 1.1.2 - on-finished: 2.4.1 - parseurl: 1.3.3 - path-to-regexp: 0.1.12 - proxy-addr: 2.0.7 - qs: 6.13.0 - range-parser: 1.2.1 - safe-buffer: 5.2.1 - send: 0.19.0 - serve-static: 1.16.2 - setprototypeof: 1.2.0 - statuses: 2.0.1 - type-is: 1.6.18 - utils-merge: 1.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - express@5.1.0: dependencies: accepts: 2.0.0 @@ -8388,18 +8244,6 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@1.3.1: - dependencies: - debug: 2.6.9 - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.1 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - finalhandler@2.1.0: dependencies: debug: 4.4.1 @@ -8472,8 +8316,6 @@ snapshots: forwarded@0.2.0: {} - fresh@0.5.2: {} - fresh@2.0.0: {} fs-extra@10.1.0: @@ -8659,10 +8501,6 @@ snapshots: husky@9.1.7: {} - iconv-lite@0.4.24: - dependencies: - safer-buffer: 2.1.2 - iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -9497,8 +9335,6 @@ snapshots: dependencies: fs-monkey: 1.1.0 - merge-descriptors@1.0.3: {} - merge-descriptors@2.0.0: {} merge-stream@2.0.0: {} @@ -9524,8 +9360,6 @@ snapshots: dependencies: mime-db: 1.54.0 - mime@1.6.0: {} - mime@2.6.0: {} mimic-fn@2.1.0: {} @@ -9556,8 +9390,6 @@ snapshots: mkdirp@3.0.1: {} - ms@2.0.0: {} - ms@2.1.3: {} msgpackr-extract@3.0.3: @@ -9601,8 +9433,6 @@ snapshots: natural-compare@1.4.0: {} - negotiator@0.6.3: {} - negotiator@1.0.0: {} neo-async@2.6.2: {} @@ -9797,6 +9627,13 @@ snapshots: dependencies: p-limit: 3.1.0 + p-queue@7.4.1: + dependencies: + eventemitter3: 5.0.1 + p-timeout: 5.1.0 + + p-timeout@5.1.0: {} + p-try@2.2.0: {} package-json-from-dist@1.0.1: {} @@ -9849,8 +9686,6 @@ snapshots: lru-cache: 11.2.1 minipass: 7.1.2 - path-to-regexp@0.1.12: {} - path-to-regexp@8.2.0: {} path-type@4.0.0: {} @@ -10004,10 +9839,6 @@ snapshots: pure-rand@7.0.1: {} - qs@6.13.0: - dependencies: - side-channel: 1.1.0 - qs@6.14.0: dependencies: side-channel: 1.1.0 @@ -10022,13 +9853,6 @@ snapshots: range-parser@1.2.1: {} - raw-body@2.5.2: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.0 - iconv-lite: 0.4.24 - unpipe: 1.0.0 - raw-body@3.0.1: dependencies: bytes: 3.1.2 @@ -10213,24 +10037,6 @@ snapshots: semver@7.7.2: {} - send@0.19.0: - dependencies: - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - encodeurl: 1.0.2 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 0.5.2 - http-errors: 2.0.0 - mime: 1.6.0 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.1 - transitivePeerDependencies: - - supports-color - send@1.2.0: dependencies: debug: 4.4.1 @@ -10253,15 +10059,6 @@ snapshots: dependencies: randombytes: 2.1.0 - serve-static@1.16.2: - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 0.19.0 - transitivePeerDependencies: - - supports-color - serve-static@2.2.0: dependencies: encodeurl: 2.0.0 @@ -10844,7 +10641,7 @@ snapshots: utils-merge@1.0.1: {} - uuid@11.1.0: {} + uuid@13.0.0: {} uuid@9.0.1: {} diff --git a/scripts/bundle-analyze.sh b/scripts/bundle-analyze.sh new file mode 100755 index 00000000..e1deaebd --- /dev/null +++ b/scripts/bundle-analyze.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -e + +echo "šŸ“Š Analyzing bundle sizes..." + +# Frontend bundle analysis +echo "šŸŽÆ Frontend bundle analysis..." +cd apps/portal +pnpm run build:analyze +echo "āœ… Frontend analysis complete - check browser for results" + +cd ../.. + +echo "šŸŽ‰ Bundle analysis complete!" diff --git a/scripts/migrate-field-map.sh b/scripts/migrate-field-map.sh new file mode 100755 index 00000000..e0741c6c --- /dev/null +++ b/scripts/migrate-field-map.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Script to migrate remaining getSalesforceFieldMap usages to SalesforceFieldMapService + +echo "šŸ”„ Migrating remaining field map usages..." + +# Files that still need migration +FILES=( + "apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts" + "apps/bff/src/modules/orders/services/order-builder.service.ts" + "apps/bff/src/integrations/salesforce/salesforce.service.ts" + "apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts" + "apps/bff/src/modules/orders/services/order-pricebook.service.ts" +) + +echo "āš ļø The following files still need manual migration:" +for file in "${FILES[@]}"; do + echo " - $file" +done + +echo "" +echo "šŸ“ These files use getSalesforceFieldMap() and need to be updated to:" +echo " 1. Inject SalesforceFieldMapService in constructor" +echo " 2. Call this.fieldMapService.getFieldMap() instead" +echo " 3. Import CoreConfigModule in their respective modules" + +echo "" +echo "šŸŽÆ After manual migration, the deprecated functions can be completely removed." diff --git a/scripts/validate-deps.sh b/scripts/validate-deps.sh new file mode 100755 index 00000000..5966fa62 --- /dev/null +++ b/scripts/validate-deps.sh @@ -0,0 +1,119 @@ +#!/bin/bash +# šŸ” Dependency Validation Script +# Validates dependency integrity, checks for version drift, and security issues + +set -euo pipefail + +echo "šŸ” Validating dependencies..." + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Check if pnpm is available +if ! command -v pnpm &> /dev/null; then + echo -e "${RED}āŒ pnpm is not installed${NC}" + exit 1 +fi + +# 1. Validate lockfile integrity +echo "šŸ“‹ Checking lockfile integrity..." +if pnpm install --frozen-lockfile --ignore-scripts; then + echo -e "${GREEN}āœ… Lockfile integrity validated${NC}" +else + echo -e "${RED}āŒ Lockfile integrity check failed${NC}" + exit 1 +fi + +# 2. Check for dependency version drift +echo "šŸ” Checking for dependency version drift..." +pnpm list --recursive --depth=0 --json > /tmp/deps.json + +node -e " +const fs = require('fs'); +const deps = JSON.parse(fs.readFileSync('/tmp/deps.json', 'utf8')); +const allDeps = new Map(); + +deps.forEach(pkg => { + if (pkg.dependencies) { + Object.entries(pkg.dependencies).forEach(([name, info]) => { + const version = info.version; + if (!allDeps.has(name)) { + allDeps.set(name, new Set()); + } + allDeps.get(name).add(\`\${pkg.name}@\${version}\`); + }); + } +}); + +let hasDrift = false; +allDeps.forEach((versions, depName) => { + if (versions.size > 1) { + console.log(\`āŒ Version drift detected for \${depName}:\`); + versions.forEach(v => console.log(\` - \${v}\`)); + hasDrift = true; + } +}); + +if (hasDrift) { + console.log('\\nšŸ’” Fix version drift by aligning dependency versions across workspaces.'); + process.exit(1); +} else { + console.log('āœ… No dependency version drift detected.'); +} +" + +# 3. Security audit +echo "šŸ”’ Running security audit..." +if pnpm audit --audit-level moderate; then + echo -e "${GREEN}āœ… Security audit passed${NC}" +else + echo -e "${YELLOW}āš ļø Security vulnerabilities found. Review and update dependencies.${NC}" +fi + +# 4. Check for outdated dependencies +echo "šŸ“… Checking for outdated dependencies..." +pnpm outdated --recursive || echo -e "${YELLOW}āš ļø Some dependencies are outdated${NC}" + +# 5. Validate workspace configuration +echo "āš™ļø Validating workspace configuration..." +node -e " +const fs = require('fs'); +const path = require('path'); + +try { + const rootPkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + console.log('āœ… Root package.json is valid'); + + const workspaceConfig = fs.readFileSync('pnpm-workspace.yaml', 'utf8'); + console.log('āœ… pnpm-workspace.yaml is readable'); + + // Check that all workspace packages exist + const workspaces = ['apps/*', 'packages/*']; + let allValid = true; + + workspaces.forEach(workspace => { + const workspacePath = workspace.replace('/*', ''); + if (!fs.existsSync(workspacePath)) { + console.log(\`āŒ Workspace path does not exist: \${workspacePath}\`); + allValid = false; + } + }); + + if (!allValid) { + process.exit(1); + } + + console.log('āœ… All workspace paths are valid'); +} catch (error) { + console.log(\`āŒ Workspace validation failed: \${error.message}\`); + process.exit(1); +} +" + +# Cleanup +rm -f /tmp/deps.json + +echo -e "${GREEN}šŸŽ‰ Dependency validation completed successfully!${NC}"