Refactor Freebit and WHMCS integrations to enhance maintainability and error handling. Update type definitions and streamline service methods across various modules. Improve import paths and clean up unused code to ensure better organization and clarity in the project structure.

This commit is contained in:
barsa 2025-09-25 17:42:36 +09:00
parent b9c24b6dc5
commit ec69e3dcbb
166 changed files with 4195 additions and 4106 deletions

View File

@ -6,7 +6,7 @@ import { Logger } from "nestjs-pino";
import helmet from "helmet";
import cookieParser from "cookie-parser";
import * as express from "express";
import type { CookieOptions, Response, NextFunction } from "express";
import type { CookieOptions, Response, NextFunction, Request } from "express";
/* eslint-disable @typescript-eslint/no-namespace */
declare global {
@ -79,7 +79,7 @@ export async function bootstrap(): Promise<INestApplication> {
secure: configService.get("NODE_ENV") === "production",
};
app.use((_req, res: Response, next: NextFunction) => {
app.use((_req: Request, res: Response, next: NextFunction) => {
res.setSecureCookie = (name: string, value: string, options: CookieOptions = {}) => {
res.cookie(name, value, { ...secureCookieDefaults, ...options });
};
@ -134,7 +134,7 @@ export async function bootstrap(): Promise<INestApplication> {
// Global exception filters
app.useGlobalFilters(
new AuthErrorFilter(app.get(Logger)), // Handle auth errors first
new GlobalExceptionFilter(app.get(Logger)) // Handle all other errors
new GlobalExceptionFilter(app.get(Logger), configService) // Handle all other errors
);
// Global authentication guard will be registered via APP_GUARD provider in AuthModule

View File

@ -9,7 +9,6 @@ import { FreebitOperationsService } from "./services/freebit-operations.service"
FreebitMapperService,
FreebitOperationsService,
FreebitOrchestratorService,
],
exports: [
// Export orchestrator in case other services need direct access

View File

@ -27,20 +27,33 @@ export interface FreebitAccountDetail {
kind: "MASTER" | "MVNO";
account: string | number;
state: "active" | "suspended" | "temporary" | "waiting" | "obsolete";
status?: "active" | "suspended" | "temporary" | "waiting" | "obsolete";
startDate?: string | number;
relationCode?: string;
resultCode?: string | number;
planCode?: string;
planName?: string;
iccid?: string | number;
imsi?: string | number;
eid?: string;
contractLine?: string;
size?: "standard" | "nano" | "micro" | "esim";
simSize?: "standard" | "nano" | "micro" | "esim";
msisdn?: string | number;
sms?: number; // 10=active, 20=inactive
talk?: number; // 10=active, 20=inactive
ipv4?: string;
ipv6?: string;
quota?: number; // Remaining quota
remainingQuotaMb?: string | number | null;
remainingQuotaKb?: string | number | null;
voicemail?: "10" | "20" | number | null;
voiceMail?: "10" | "20" | number | null;
callwaiting?: "10" | "20" | number | null;
callWaiting?: "10" | "20" | number | null;
worldwing?: "10" | "20" | number | null;
worldWing?: "10" | "20" | number | null;
networkType?: string;
async?: { func: string; date: string | number };
}

View File

@ -2,10 +2,10 @@ import { Injectable, Inject, InternalServerErrorException } from "@nestjs/common
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import { getErrorMessage } from "@bff/core/utils/error.util";
import type {
FreebitConfig,
FreebitAuthRequest,
FreebitAuthResponse
import type {
FreebitConfig,
FreebitAuthRequest,
FreebitAuthResponse,
} from "../interfaces/freebit.types";
import { FreebitError } from "./freebit-error.service";

View File

@ -22,13 +22,13 @@ export class FreebitClientService {
/**
* Make an authenticated request to Freebit API with retry logic
*/
async makeAuthenticatedRequest<
TResponse extends FreebitResponseBase,
TPayload extends object,
>(endpoint: string, payload: TPayload): Promise<TResponse> {
async makeAuthenticatedRequest<TResponse extends FreebitResponseBase, TPayload extends object>(
endpoint: string,
payload: TPayload
): Promise<TResponse> {
const authKey = await this.authService.getAuthKey();
const config = this.authService.getConfig();
const requestPayload = { ...payload, authKey };
const url = `${config.baseUrl}${endpoint}`;
@ -176,10 +176,13 @@ export class FreebitClientService {
if (attempt === config.retryAttempts) {
const message = getErrorMessage(error);
this.logger.error(`Freebit JSON API request failed after ${config.retryAttempts} attempts`, {
url,
error: message,
});
this.logger.error(
`Freebit JSON API request failed after ${config.retryAttempts} attempts`,
{
url,
error: message,
}
);
throw new FreebitError(`Request failed: ${message}`);
}
@ -213,7 +216,7 @@ export class FreebitClientService {
});
clearTimeout(timeout);
return response.ok;
} catch (error) {
this.logger.debug("Simple request failed", {

View File

@ -68,19 +68,19 @@ export class FreebitError extends Error {
if (this.isAuthError()) {
return "SIM service is temporarily unavailable. Please try again later.";
}
if (this.isRateLimitError()) {
return "Service is busy. Please wait a moment and try again.";
}
if (this.message.toLowerCase().includes("account not found")) {
return "SIM account not found. Please contact support to verify your SIM configuration.";
}
if (this.message.toLowerCase().includes("timeout")) {
return "SIM service request timed out. Please try again.";
}
return "SIM operation failed. Please try again or contact support.";
}
}

View File

@ -42,7 +42,7 @@ export class FreebitMapperService {
if (account.eid) {
simType = "esim";
} else if (account.simSize) {
simType = account.simSize as "standard" | "nano" | "micro" | "esim";
simType = account.simSize;
}
return {
@ -75,15 +75,11 @@ export class FreebitMapperService {
}
const todayUsageKb = parseInt(response.traffic.today, 10) || 0;
const recentDaysData = response.traffic.inRecentDays
.split(",")
.map((usage, index) => ({
date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000)
.toISOString()
.split("T")[0],
usageKb: parseInt(usage, 10) || 0,
usageMb: Math.round(((parseInt(usage, 10) || 0) / 1024) * 100) / 100,
}));
const recentDaysData = response.traffic.inRecentDays.split(",").map((usage, index) => ({
date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000).toISOString().split("T")[0],
usageKb: parseInt(usage, 10) || 0,
usageMb: Math.round(((parseInt(usage, 10) || 0) / 1024) * 100) / 100,
}));
return {
account: String(response.account ?? ""),
@ -106,7 +102,7 @@ export class FreebitMapperService {
account,
totalAdditions: Number(response.total) || 0,
additionCount: Number(response.count) || 0,
history: response.quotaHistory.map((item) => ({
history: response.quotaHistory.map(item => ({
quotaKb: parseInt(item.quota, 10),
quotaMb: Math.round((parseInt(item.quota, 10) / 1024) * 100) / 100,
addedDate: item.date,
@ -149,11 +145,11 @@ export class FreebitMapperService {
if (!/^\d{8}$/.test(dateString)) {
return null;
}
const year = parseInt(dateString.substring(0, 4), 10);
const month = parseInt(dateString.substring(4, 6), 10) - 1; // Month is 0-indexed
const day = parseInt(dateString.substring(6, 8), 10);
return new Date(year, month, day);
}
}

View File

@ -74,7 +74,7 @@ export class FreebitOperationsService {
let response: FreebitAccountDetailsResponse | undefined;
let lastError: unknown;
for (const ep of candidates) {
try {
if (ep !== candidates[0]) {
@ -92,7 +92,7 @@ export class FreebitOperationsService {
}
}
}
if (!response) {
if (lastError instanceof Error) {
throw lastError;
@ -189,10 +189,10 @@ export class FreebitOperationsService {
toDate: string
): Promise<SimTopUpHistory> {
try {
const request: Omit<FreebitQuotaHistoryRequest, "authKey"> = {
account,
fromDate,
toDate
const request: Omit<FreebitQuotaHistoryRequest, "authKey"> = {
account,
fromDate,
toDate,
};
const response = await this.client.makeAuthenticatedRequest<
@ -240,7 +240,7 @@ export class FreebitOperationsService {
assignGlobalIp: options.assignGlobalIp,
scheduled: !!options.scheduledAt,
});
return {
ipv4: response.ipv4,
ipv6: response.ipv6,
@ -289,7 +289,7 @@ export class FreebitOperationsService {
}
await this.client.makeAuthenticatedRequest<FreebitAddSpecResponse, typeof request>(
"/master/addSpec/",
"/master/addSpec/",
request
);
@ -316,9 +316,9 @@ export class FreebitOperationsService {
*/
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
try {
const request: Omit<FreebitCancelPlanRequest, "authKey"> = {
account,
runTime: scheduledAt
const request: Omit<FreebitCancelPlanRequest, "authKey"> = {
account,
runTime: scheduledAt,
};
await this.client.makeAuthenticatedRequest<FreebitCancelPlanResponse, typeof request>(
@ -326,9 +326,9 @@ export class FreebitOperationsService {
request
);
this.logger.log(`Successfully cancelled SIM for account ${account}`, {
account,
runTime: scheduledAt
this.logger.log(`Successfully cancelled SIM for account ${account}`, {
account,
runTime: scheduledAt,
});
} catch (error) {
const message = getErrorMessage(error);
@ -425,7 +425,16 @@ export class FreebitOperationsService {
birthday?: string;
};
}): Promise<void> {
const { account, eid, planCode, contractLine, aladinOperated = "10", shipDate, mnp, identity } = params;
const {
account,
eid,
planCode,
contractLine,
aladinOperated = "10",
shipDate,
mnp,
identity,
} = params;
if (!account || !eid) {
throw new BadRequestException("activateEsimAccountNew requires account and eid");
@ -450,10 +459,7 @@ export class FreebitOperationsService {
await this.client.makeAuthenticatedJsonRequest<
FreebitEsimAccountActivationResponse,
FreebitEsimAccountActivationRequest
>(
"/mvno/esim/addAcct/",
payload
);
>("/mvno/esim/addAcct/", payload);
this.logger.log("Successfully activated new eSIM account via PA05-41", {
account,

View File

@ -1,4 +1,4 @@
// Export all Freebit services
export { FreebitOrchestratorService } from './freebit-orchestrator.service';
export { FreebitMapperService } from './freebit-mapper.service';
export { FreebitOperationsService } from './freebit-operations.service';
export { FreebitOrchestratorService } from "./freebit-orchestrator.service";
export { FreebitMapperService } from "./freebit-mapper.service";
export { FreebitOperationsService } from "./freebit-operations.service";

View File

@ -188,7 +188,9 @@ export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy
const errorData = data as SalesforcePubSubError;
const details = errorData.details || "";
const metadata = errorData.metadata || {};
const errorCodes = Array.isArray(metadata["error-code"]) ? metadata["error-code"] : [];
const errorCodes = Array.isArray(metadata["error-code"])
? metadata["error-code"]
: [];
const hasCorruptionCode = errorCodes.some(code =>
String(code).includes("replayid.corrupted")
);

View File

@ -61,15 +61,15 @@ export class WhmcsConfigService {
* Validate that required configuration is present
*/
validateConfig(): void {
const required = ['baseUrl', 'identifier', 'secret'];
const required = ["baseUrl", "identifier", "secret"];
const missing = required.filter(key => !this.config[key as keyof WhmcsApiConfig]);
if (missing.length > 0) {
throw new Error(`Missing required WHMCS configuration: ${missing.join(', ')}`);
throw new Error(`Missing required WHMCS configuration: ${missing.join(", ")}`);
}
if (!this.config.baseUrl.startsWith('http')) {
throw new Error('WHMCS baseUrl must start with http:// or https://');
if (!this.config.baseUrl.startsWith("http")) {
throw new Error("WHMCS baseUrl must start with http:// or https://");
}
}
@ -81,21 +81,15 @@ export class WhmcsConfigService {
const isDev = nodeEnv !== "production";
// Resolve and normalize base URL (trim trailing slashes)
const rawBaseUrl = this.getFirst([
isDev ? "WHMCS_DEV_BASE_URL" : undefined,
"WHMCS_BASE_URL"
]) || "";
const rawBaseUrl =
this.getFirst([isDev ? "WHMCS_DEV_BASE_URL" : undefined, "WHMCS_BASE_URL"]) || "";
const baseUrl = rawBaseUrl.replace(/\/+$/, "");
const identifier = this.getFirst([
isDev ? "WHMCS_DEV_API_IDENTIFIER" : undefined,
"WHMCS_API_IDENTIFIER"
]) || "";
const identifier =
this.getFirst([isDev ? "WHMCS_DEV_API_IDENTIFIER" : undefined, "WHMCS_API_IDENTIFIER"]) || "";
const secret = this.getFirst([
isDev ? "WHMCS_DEV_API_SECRET" : undefined,
"WHMCS_API_SECRET"
]) || "";
const secret =
this.getFirst([isDev ? "WHMCS_DEV_API_SECRET" : undefined, "WHMCS_API_SECRET"]) || "";
const adminUsername = this.getFirst([
isDev ? "WHMCS_DEV_ADMIN_USERNAME" : undefined,
@ -127,10 +121,7 @@ export class WhmcsConfigService {
const nodeEnv = this.configService.get<string>("NODE_ENV", "development");
const isDev = nodeEnv !== "production";
return this.getFirst([
isDev ? "WHMCS_DEV_API_ACCESS_KEY" : undefined,
"WHMCS_API_ACCESS_KEY",
]);
return this.getFirst([isDev ? "WHMCS_DEV_API_ACCESS_KEY" : undefined, "WHMCS_API_ACCESS_KEY"]);
}
/**
@ -153,7 +144,7 @@ export class WhmcsConfigService {
private getNumberConfig(key: string, defaultValue: number): number {
const value = this.configService.get<string>(key);
if (!value) return defaultValue;
const parsed = parseInt(value, 10);
return isNaN(parsed) ? defaultValue : parsed;
}

View File

@ -116,7 +116,6 @@ export class WhmcsApiMethodsService {
return this.makeRequest("GetInvoice", { invoiceid: invoiceId });
}
// ==========================================
// PRODUCT/SUBSCRIPTION API METHODS
// ==========================================
@ -137,7 +136,6 @@ export class WhmcsApiMethodsService {
return this.makeRequest("GetPayMethods", params);
}
async getPaymentGateways(): Promise<WhmcsPaymentGatewaysResponse> {
return this.makeRequest("GetPaymentMethods", {});
}
@ -176,11 +174,11 @@ export class WhmcsApiMethodsService {
}
return this.makeRequest<{ result: string }>(
"AcceptOrder",
{
"AcceptOrder",
{
orderid: orderId.toString(),
autosetup: true,
sendemail: false
sendemail: false,
},
{ useAdminAuth: true }
);
@ -192,13 +190,12 @@ export class WhmcsApiMethodsService {
}
return this.makeRequest<{ result: string }>(
"CancelOrder",
"CancelOrder",
{ orderid: orderId },
{ useAdminAuth: true }
);
}
// ==========================================
// SSO API METHODS
// ==========================================
@ -207,7 +204,6 @@ export class WhmcsApiMethodsService {
return this.makeRequest("CreateSsoToken", params);
}
async getProducts() {
return this.makeRequest("GetProducts", {});
}

View File

@ -5,8 +5,8 @@ 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 type {
WhmcsApiResponse,
import type {
WhmcsApiResponse,
WhmcsErrorResponse,
WhmcsAddClientParams,
WhmcsValidateLoginParams,
@ -18,10 +18,7 @@ import type {
WhmcsUpdateInvoiceParams,
WhmcsCapturePaymentParams,
} from "../../types/whmcs-api.types";
import type {
WhmcsRequestOptions,
WhmcsConnectionStats
} from "../types/connection.types";
import type { WhmcsRequestOptions, WhmcsConnectionStats } from "../types/connection.types";
/**
* Main orchestrator service for WHMCS connections
@ -41,7 +38,7 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit {
try {
// Validate configuration on startup
this.configService.validateConfig();
// Test connection
const isAvailable = await this.apiMethods.isAvailable();
if (isAvailable) {
@ -71,7 +68,7 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit {
try {
const config = this.configService.getConfig();
const response = await this.httpClient.makeRequest<T>(config, action, params, options);
if (response.result === "error") {
const errorResponse = response as WhmcsErrorResponse;
this.errorHandler.handleApiError(errorResponse, action, params);
@ -180,7 +177,6 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit {
return this.apiMethods.cancelOrder(orderId);
}
// ==========================================
// PRODUCT/SUBSCRIPTION API METHODS
// ==========================================
@ -193,7 +189,6 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit {
return this.apiMethods.getCatalogProducts();
}
async getProducts() {
return this.apiMethods.getProducts();
}
@ -206,7 +201,6 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit {
return this.apiMethods.getPaymentMethods(params);
}
async getPaymentGateways() {
return this.apiMethods.getPaymentGateways();
}
@ -227,7 +221,6 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit {
return this.configService.getBaseUrl();
}
// ==========================================
// UTILITY METHODS
// ==========================================
@ -272,7 +265,9 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit {
* Check if error is already a handled exception
*/
private isHandledException(error: unknown): boolean {
return error instanceof Error &&
(error.name.includes('Exception') || error.message.includes('WHMCS'));
return (
error instanceof Error &&
(error.name.includes("Exception") || error.message.includes("WHMCS"))
);
}
}

View File

@ -1,4 +1,9 @@
import { Injectable, NotFoundException, BadRequestException, UnauthorizedException } from "@nestjs/common";
import {
Injectable,
NotFoundException,
BadRequestException,
UnauthorizedException,
} from "@nestjs/common";
import { getErrorMessage } from "@bff/core/utils/error.util";
import type { WhmcsErrorResponse } from "../../types/whmcs-api.types";
@ -33,7 +38,7 @@ export class WhmcsErrorHandlerService {
}
// Generic WHMCS API error
throw new Error(`WHMCS API Error [${action}]: ${message} (${errorCode || 'unknown'})`);
throw new Error(`WHMCS API Error [${action}]: ${message} (${errorCode || "unknown"})`);
}
/**
@ -64,15 +69,17 @@ export class WhmcsErrorHandlerService {
*/
private isNotFoundError(action: string, message: string): boolean {
const lowerMessage = message.toLowerCase();
// Client not found errors
if (action === "GetClientsDetails" && lowerMessage.includes("client not found")) {
return true;
}
// Invoice not found errors
if ((action === "GetInvoice" || action === "UpdateInvoice") &&
lowerMessage.includes("invoice not found")) {
if (
(action === "GetInvoice" || action === "UpdateInvoice") &&
lowerMessage.includes("invoice not found")
) {
return true;
}
@ -89,12 +96,14 @@ export class WhmcsErrorHandlerService {
*/
private isAuthenticationError(message: string, errorCode?: string): boolean {
const lowerMessage = message.toLowerCase();
return lowerMessage.includes("authentication") ||
lowerMessage.includes("unauthorized") ||
lowerMessage.includes("invalid credentials") ||
lowerMessage.includes("access denied") ||
errorCode === "AUTHENTICATION_FAILED";
return (
lowerMessage.includes("authentication") ||
lowerMessage.includes("unauthorized") ||
lowerMessage.includes("invalid credentials") ||
lowerMessage.includes("access denied") ||
errorCode === "AUTHENTICATION_FAILED"
);
}
/**
@ -102,12 +111,14 @@ export class WhmcsErrorHandlerService {
*/
private isValidationError(message: string, errorCode?: string): boolean {
const lowerMessage = message.toLowerCase();
return lowerMessage.includes("required") ||
lowerMessage.includes("invalid") ||
lowerMessage.includes("missing") ||
lowerMessage.includes("validation") ||
errorCode === "VALIDATION_ERROR";
return (
lowerMessage.includes("required") ||
lowerMessage.includes("invalid") ||
lowerMessage.includes("missing") ||
lowerMessage.includes("validation") ||
errorCode === "VALIDATION_ERROR"
);
}
/**
@ -125,21 +136,21 @@ export class WhmcsErrorHandlerService {
}
const clientIdParam = params["clientid"];
const identifier =
const identifier =
typeof clientIdParam === "string" || typeof clientIdParam === "number"
? clientIdParam
: "unknown";
return new NotFoundException(`Client with ID ${identifier} not found`);
}
if (action === "GetInvoice" || action === "UpdateInvoice") {
const invoiceIdParam = params["invoiceid"];
const identifier =
const identifier =
typeof invoiceIdParam === "string" || typeof invoiceIdParam === "number"
? invoiceIdParam
: "unknown";
return new NotFoundException(`Invoice with ID ${identifier} not found`);
}
@ -152,9 +163,11 @@ export class WhmcsErrorHandlerService {
*/
private isTimeoutError(error: unknown): boolean {
const message = getErrorMessage(error).toLowerCase();
return message.includes("timeout") ||
message.includes("aborted") ||
(error instanceof Error && error.name === "AbortError");
return (
message.includes("timeout") ||
message.includes("aborted") ||
(error instanceof Error && error.name === "AbortError")
);
}
/**
@ -162,20 +175,24 @@ export class WhmcsErrorHandlerService {
*/
private isNetworkError(error: unknown): boolean {
const message = getErrorMessage(error).toLowerCase();
return message.includes("network") ||
message.includes("connection") ||
message.includes("econnrefused") ||
message.includes("enotfound") ||
message.includes("fetch");
return (
message.includes("network") ||
message.includes("connection") ||
message.includes("econnrefused") ||
message.includes("enotfound") ||
message.includes("fetch")
);
}
/**
* Check if error is already a known NestJS exception
*/
private isKnownException(error: unknown): boolean {
return error instanceof NotFoundException ||
error instanceof BadRequestException ||
error instanceof UnauthorizedException;
return (
error instanceof NotFoundException ||
error instanceof BadRequestException ||
error instanceof UnauthorizedException
);
}
/**

View File

@ -1,14 +1,11 @@
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { getErrorMessage } from "@bff/core/utils/error.util";
import type {
WhmcsApiResponse,
WhmcsErrorResponse
} from "../../types/whmcs-api.types";
import type {
WhmcsApiConfig,
import type { WhmcsApiResponse, WhmcsErrorResponse } from "../../types/whmcs-api.types";
import type {
WhmcsApiConfig,
WhmcsRequestOptions,
WhmcsConnectionStats
WhmcsConnectionStats,
} from "../types/connection.types";
/**
@ -41,22 +38,22 @@ export class WhmcsHttpClientService {
try {
const response = await this.executeRequest<T>(config, action, params, options);
const responseTime = Date.now() - startTime;
this.updateSuccessStats(responseTime);
return response;
} catch (error) {
this.stats.failedRequests++;
this.stats.lastErrorTime = new Date();
this.logger.error(`WHMCS HTTP request failed [${action}]`, {
error: getErrorMessage(error),
action,
params: this.sanitizeLogParams(params),
responseTime: Date.now() - startTime,
});
throw error;
}
}
@ -97,7 +94,7 @@ export class WhmcsHttpClientService {
return await this.performSingleRequest<T>(config, action, params, options);
} catch (error) {
lastError = error as Error;
if (attempt === maxAttempts) {
break;
}
@ -176,7 +173,7 @@ export class WhmcsHttpClientService {
options: WhmcsRequestOptions
): string {
const formData = new URLSearchParams();
// Add authentication
if (options.useAdminAuth && config.adminUsername && config.adminPasswordHash) {
formData.append("username", config.adminUsername);
@ -223,7 +220,9 @@ export class WhmcsHttpClientService {
if (data.result === "error") {
const errorResponse = data as WhmcsErrorResponse;
throw new Error(`WHMCS API Error: ${errorResponse.message} (${errorResponse.errorcode || 'unknown'})`);
throw new Error(
`WHMCS API Error: ${errorResponse.message} (${errorResponse.errorcode || "unknown"})`
);
}
return data;
@ -234,19 +233,19 @@ export class WhmcsHttpClientService {
*/
private shouldNotRetry(error: unknown): boolean {
const message = getErrorMessage(error).toLowerCase();
// Don't retry authentication errors
if (message.includes('authentication') || message.includes('unauthorized')) {
if (message.includes("authentication") || message.includes("unauthorized")) {
return true;
}
// Don't retry validation errors
if (message.includes('invalid') || message.includes('required')) {
if (message.includes("invalid") || message.includes("required")) {
return true;
}
// Don't retry not found errors
if (message.includes('not found')) {
if (message.includes("not found")) {
return true;
}
@ -274,10 +273,10 @@ export class WhmcsHttpClientService {
*/
private updateSuccessStats(responseTime: number): void {
this.stats.successfulRequests++;
// Update average response time
const totalSuccessful = this.stats.successfulRequests;
this.stats.averageResponseTime =
this.stats.averageResponseTime =
(this.stats.averageResponseTime * (totalSuccessful - 1) + responseTime) / totalSuccessful;
}
@ -286,18 +285,25 @@ export class WhmcsHttpClientService {
*/
private sanitizeLogParams(params: Record<string, unknown>): Record<string, unknown> {
const sensitiveKeys = [
'password', 'secret', 'token', 'key', 'auth',
'credit_card', 'cvv', 'ssn', 'social_security'
"password",
"secret",
"token",
"key",
"auth",
"credit_card",
"cvv",
"ssn",
"social_security",
];
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(params)) {
const keyLower = key.toLowerCase();
const isSensitive = sensitiveKeys.some(sensitive => keyLower.includes(sensitive));
if (isSensitive) {
sanitized[key] = '[REDACTED]';
sanitized[key] = "[REDACTED]";
} else {
sanitized[key] = value;
}

View File

@ -1,7 +1,11 @@
import { getErrorMessage } from "@bff/core/utils/error.util";
import { Logger } from "nestjs-pino";
import { Injectable, NotFoundException, Inject } from "@nestjs/common";
import { Invoice, InvoiceList, invoiceListSchema, invoiceSchema } from "@customer-portal/domain";
import { Invoice, InvoiceList } from "@customer-portal/domain";
import {
invoiceListSchema,
invoiceSchema as invoiceEntitySchema,
} from "@customer-portal/domain/validation/shared/entities";
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service";
import { InvoiceTransformerService } from "../transformers/services/invoice-transformer.service";
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
@ -113,7 +117,7 @@ export class WhmcsInvoiceService {
try {
// Get detailed invoice with items
const detailedInvoice = await this.getInvoiceById(clientId, userId, invoice.id);
const parseResult = invoiceSchema.safeParse(detailedInvoice);
const parseResult = invoiceEntitySchema.safeParse(detailedInvoice);
if (!parseResult.success) {
this.logger.error("Failed to parse detailed invoice", {
error: parseResult.error.issues,
@ -180,7 +184,7 @@ export class WhmcsInvoiceService {
// Transform invoice
const invoice = this.invoiceTransformer.transformInvoice(response);
const parseResult = invoiceSchema.safeParse(invoice);
const parseResult = invoiceEntitySchema.safeParse(invoice);
if (!parseResult.success) {
throw new Error(`Invalid invoice data after transformation`);
}
@ -206,7 +210,6 @@ export class WhmcsInvoiceService {
this.logger.log(`Invalidated invoice cache for user ${userId}, invoice ${invoiceId}`);
}
private transformInvoicesResponse(
response: WhmcsInvoicesResponse,
clientId: number,
@ -225,18 +228,18 @@ export class WhmcsInvoiceService {
} satisfies InvoiceList;
}
const invoices = response.invoices.invoice
.map(whmcsInvoice => {
try {
return this.invoiceTransformer.transformInvoice(whmcsInvoice);
} catch (error) {
this.logger.error(`Failed to transform invoice ${whmcsInvoice.id}`, {
error: getErrorMessage(error),
});
return null;
}
})
.filter((invoice): invoice is Invoice => invoice !== null);
const invoices: Invoice[] = [];
for (const whmcsInvoice of response.invoices.invoice) {
try {
const transformed = this.invoiceTransformer.transformInvoice(whmcsInvoice);
const parsed = invoiceEntitySchema.parse(transformed);
invoices.push(parsed);
} catch (error) {
this.logger.error(`Failed to transform invoice ${whmcsInvoice.id}`, {
error: getErrorMessage(error),
});
}
}
this.logger.debug(`WHMCS GetInvoices Response Analysis for Client ${clientId}:`, {
totalresults: response.totalresults,

View File

@ -243,7 +243,6 @@ export class WhmcsPaymentService {
}
}
/**
* Normalize WHMCS SSO redirect URLs to absolute using configured base URL.
*/

View File

@ -1,9 +1,6 @@
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import {
Invoice,
InvoiceItem as BaseInvoiceItem,
} from "@customer-portal/domain";
import { Invoice, InvoiceItem as BaseInvoiceItem } from "@customer-portal/domain";
import type {
WhmcsInvoice,
WhmcsInvoiceItems,
@ -33,7 +30,7 @@ export class InvoiceTransformerService {
*/
transformInvoice(whmcsInvoice: WhmcsInvoice): Invoice {
const invoiceId = whmcsInvoice.invoiceid || whmcsInvoice.id;
if (!this.validator.validateWhmcsInvoiceData(whmcsInvoice)) {
throw new Error("Invalid invoice data from WHMCS");
}
@ -45,7 +42,7 @@ export class InvoiceTransformerService {
status: StatusNormalizer.normalizeInvoiceStatus(whmcsInvoice.status),
currency: whmcsInvoice.currencycode || "JPY",
currencySymbol:
whmcsInvoice.currencyprefix ||
whmcsInvoice.currencyprefix ||
DataUtils.getCurrencySymbol(whmcsInvoice.currencycode || "JPY"),
total: DataUtils.parseAmount(whmcsInvoice.total),
subtotal: DataUtils.parseAmount(whmcsInvoice.subtotal),
@ -90,14 +87,14 @@ export class InvoiceTransformerService {
// WHMCS API returns either an array or single item
const itemsArray = Array.isArray(items.item) ? items.item : [items.item];
return itemsArray.map(item => this.transformSingleInvoiceItem(item));
}
/**
* Transform a single invoice item using exact WHMCS API structure
*/
private transformSingleInvoiceItem(item: WhmcsInvoiceItems['item'][0]): InvoiceItem {
private transformSingleInvoiceItem(item: WhmcsInvoiceItems["item"][0]): InvoiceItem {
const transformedItem: InvoiceItem = {
id: item.id,
description: item.description,

View File

@ -85,8 +85,6 @@ export class PaymentTransformerService {
return transformed;
}
/**
* Normalize expiry date to MM/YY format
*/
@ -95,12 +93,12 @@ export class PaymentTransformerService {
// Handle various formats: MM/YY, MM/YYYY, MMYY, MMYYYY
const cleaned = expiryDate.replace(/\D/g, "");
if (cleaned.length === 4) {
// MMYY format
return `${cleaned.substring(0, 2)}/${cleaned.substring(2, 4)}`;
}
if (cleaned.length === 6) {
// MMYYYY format - convert to MM/YY
return `${cleaned.substring(0, 2)}/${cleaned.substring(4, 6)}`;
@ -185,7 +183,9 @@ export class PaymentTransformerService {
/**
* Normalize gateway type to match our enum
*/
private normalizeGatewayType(type: string): "merchant" | "thirdparty" | "tokenization" | "manual" {
private normalizeGatewayType(
type: string
): "merchant" | "thirdparty" | "tokenization" | "manual" {
const normalizedType = type.toLowerCase();
switch (normalizedType) {
case "merchant":
@ -207,14 +207,25 @@ export class PaymentTransformerService {
/**
* Normalize payment method type to match our enum
*/
private normalizePaymentType(gatewayName?: string): "CreditCard" | "BankAccount" | "RemoteCreditCard" | "RemoteBankAccount" | "Manual" {
private normalizePaymentType(
gatewayName?: string
): "CreditCard" | "BankAccount" | "RemoteCreditCard" | "RemoteBankAccount" | "Manual" {
if (!gatewayName) return "Manual";
const normalized = gatewayName.toLowerCase();
if (normalized.includes("credit") || normalized.includes("card") || normalized.includes("visa") || normalized.includes("mastercard")) {
if (
normalized.includes("credit") ||
normalized.includes("card") ||
normalized.includes("visa") ||
normalized.includes("mastercard")
) {
return "CreditCard";
}
if (normalized.includes("bank") || normalized.includes("ach") || normalized.includes("account")) {
if (
normalized.includes("bank") ||
normalized.includes("ach") ||
normalized.includes("account")
) {
return "BankAccount";
}
if (normalized.includes("remote") || normalized.includes("token")) {

View File

@ -65,7 +65,9 @@ export class SubscriptionTransformerService {
cycle: subscription.cycle,
amount: subscription.amount,
currency: subscription.currency,
hasCustomFields: Boolean(subscription.customFields && Object.keys(subscription.customFields).length > 0),
hasCustomFields: Boolean(
subscription.customFields && Object.keys(subscription.customFields).length > 0
),
});
return subscription;
@ -95,7 +97,9 @@ export class SubscriptionTransformerService {
/**
* Extract and normalize custom fields from WHMCS format
*/
private extractCustomFields(customFields: WhmcsCustomField[] | undefined): Record<string, string> | undefined {
private extractCustomFields(
customFields: WhmcsCustomField[] | undefined
): Record<string, string> | undefined {
if (!customFields || !Array.isArray(customFields) || customFields.length === 0) {
return undefined;
}

View File

@ -1,11 +1,6 @@
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import {
Invoice,
Subscription,
PaymentMethod,
PaymentGateway,
} from "@customer-portal/domain";
import { Invoice, Subscription, PaymentMethod, PaymentGateway } from "@customer-portal/domain";
import type {
WhmcsInvoice,
WhmcsProduct,
@ -174,7 +169,7 @@ export class WhmcsTransformerOrchestratorService {
}
}
this.logger.info("Batch invoice transformation completed", {
this.logger.log("Batch invoice transformation completed", {
total: whmcsInvoices.length,
successful: successful.length,
failed: failed.length,
@ -205,7 +200,7 @@ export class WhmcsTransformerOrchestratorService {
}
}
this.logger.info("Batch subscription transformation completed", {
this.logger.log("Batch subscription transformation completed", {
total: whmcsProducts.length,
successful: successful.length,
failed: failed.length,
@ -236,7 +231,7 @@ export class WhmcsTransformerOrchestratorService {
}
}
this.logger.info("Batch payment method transformation completed", {
this.logger.log("Batch payment method transformation completed", {
total: whmcsPayMethods.length,
successful: successful.length,
failed: failed.length,
@ -267,7 +262,7 @@ export class WhmcsTransformerOrchestratorService {
}
}
this.logger.info("Batch payment gateway transformation completed", {
this.logger.log("Batch payment gateway transformation completed", {
total: whmcsGateways.length,
successful: successful.length,
failed: failed.length,
@ -336,17 +331,12 @@ export class WhmcsTransformerOrchestratorService {
validationRules: string[];
} {
return {
supportedTypes: [
"invoices",
"subscriptions",
"payment_methods",
"payment_gateways"
],
supportedTypes: ["invoices", "subscriptions", "payment_methods", "payment_gateways"],
validationRules: [
"required_fields_validation",
"data_type_validation",
"format_validation",
"business_rule_validation"
"business_rule_validation",
],
};
}

View File

@ -140,5 +140,4 @@ export class DataUtils {
return undefined;
}
}

View File

@ -1,4 +1,4 @@
import {
import type {
InvoiceStatus,
SubscriptionStatus,
SubscriptionBillingCycle,

View File

@ -1,10 +1,5 @@
import { Injectable } from "@nestjs/common";
import {
Invoice,
Subscription,
PaymentMethod,
PaymentGateway,
} from "@customer-portal/domain";
import { Invoice, Subscription, PaymentMethod, PaymentGateway } from "@customer-portal/domain";
import type { WhmcsInvoice, WhmcsProduct } from "../../types/whmcs-api.types";
/**
@ -18,15 +13,15 @@ export class TransformationValidator {
validateInvoice(invoice: Invoice): boolean {
const requiredFields = [
"id",
"number",
"number",
"status",
"currency",
"total",
"subtotal",
"tax",
"issuedAt"
"issuedAt",
];
return requiredFields.every(field => {
const value = invoice[field as keyof Invoice];
return value !== undefined && value !== null;
@ -37,14 +32,8 @@ export class TransformationValidator {
* Validate subscription transformation result
*/
validateSubscription(subscription: Subscription): boolean {
const requiredFields = [
"id",
"serviceId",
"productName",
"status",
"currency"
];
const requiredFields = ["id", "serviceId", "productName", "status", "currency"];
return requiredFields.every(field => {
const value = subscription[field as keyof Subscription];
return value !== undefined && value !== null;
@ -56,7 +45,7 @@ export class TransformationValidator {
*/
validatePaymentMethod(paymentMethod: PaymentMethod): boolean {
const requiredFields = ["id", "type", "description"];
return requiredFields.every(field => {
const value = paymentMethod[field as keyof PaymentMethod];
return value !== undefined && value !== null;
@ -68,7 +57,7 @@ export class TransformationValidator {
*/
validatePaymentGateway(gateway: PaymentGateway): boolean {
const requiredFields = ["name", "displayName", "type", "isActive"];
return requiredFields.every(field => {
const value = gateway[field as keyof PaymentGateway];
return value !== undefined && value !== null;
@ -78,9 +67,11 @@ export class TransformationValidator {
/**
* Validate invoice items array
*/
validateInvoiceItems(items: Array<{ description: string; amount: string; id: number; type: string; relid: number }>): boolean {
validateInvoiceItems(
items: Array<{ description: string; amount: string; id: number; type: string; relid: number }>
): boolean {
if (!Array.isArray(items)) return false;
return items.every(item => {
return Boolean(item.description && item.amount && item.id);
});
@ -105,7 +96,7 @@ export class TransformationValidator {
*/
validateCurrencyCode(currency: string): boolean {
if (!currency || typeof currency !== "string") return false;
// Check if it's a valid 3-letter currency code
return /^[A-Z]{3}$/.test(currency.toUpperCase());
}
@ -117,12 +108,12 @@ export class TransformationValidator {
if (typeof amount === "number") {
return !isNaN(amount) && isFinite(amount);
}
if (typeof amount === "string") {
const parsed = parseFloat(amount);
return !isNaN(parsed) && isFinite(parsed);
}
return false;
}
@ -131,7 +122,7 @@ export class TransformationValidator {
*/
validateDateString(dateStr: string): boolean {
if (!dateStr) return false;
const date = new Date(dateStr);
return !isNaN(date.getTime());
}

View File

@ -288,6 +288,7 @@ export interface WhmcsCatalogProductsResponse {
// Payment Method Types
export interface WhmcsPaymentMethod {
id: number;
paymethodid?: number;
type: "CreditCard" | "BankAccount" | "RemoteCreditCard" | "RemoteBankAccount";
description: string;
gateway_name?: string;
@ -314,7 +315,6 @@ export interface WhmcsGetPayMethodsParams {
[key: string]: unknown;
}
// Payment Gateway Types
export interface WhmcsPaymentGateway {
name: string;
@ -421,4 +421,3 @@ export interface WhmcsCapturePaymentResponse {
message?: string;
error?: string;
}

View File

@ -28,7 +28,6 @@ import {
} from "./types/whmcs-api.types";
import { Logger } from "nestjs-pino";
@Injectable()
export class WhmcsService {
constructor(
@ -279,7 +278,6 @@ export class WhmcsService {
return this.paymentService.getProducts() as Promise<WhmcsCatalogProductsResponse>;
}
// ==========================================
// SSO OPERATIONS (delegate to SsoService)
// ==========================================
@ -335,7 +333,6 @@ export class WhmcsService {
return this.connectionService.getClientsProducts(params);
}
// ==========================================
// ORDER OPERATIONS (delegate to OrderService)
// ==========================================

View File

@ -9,6 +9,7 @@ import {
import { getErrorMessage } from "@bff/core/utils/error.util";
import type {
SalesforceProduct2WithPricebookEntries,
SalesforcePricebookEntryRecord,
SalesforceQueryResult,
} from "@customer-portal/domain";
@ -45,16 +46,15 @@ export class BaseCatalogService {
}
}
protected extractPricebookEntry(record: SalesforceProduct2WithPricebookEntries) {
const pricebookEntries =
record.PricebookEntries && typeof record.PricebookEntries === "object"
? (record.PricebookEntries as { records?: unknown[] })
: { records: undefined };
const entry = Array.isArray(pricebookEntries.records) ? pricebookEntries.records[0] : undefined;
protected extractPricebookEntry(
record: SalesforceProduct2WithPricebookEntries
): SalesforcePricebookEntryRecord | undefined {
const pricebookEntries = record.PricebookEntries?.records;
const entry = Array.isArray(pricebookEntries) ? pricebookEntries[0] : undefined;
if (!entry) {
const fields = this.getFields();
const skuField = fields.product.sku;
const skuRaw = (record as Record<string, unknown>)[skuField];
const skuRaw = Reflect.get(record, skuField) as unknown;
const sku = typeof skuRaw === "string" ? skuRaw : undefined;
this.logger.warn(
`No pricebook entry found for product ${String(record.Name)} (SKU: ${String(sku ?? "")}). Pricebook ID: ${this.portalPriceBookId}.`

View File

@ -32,7 +32,10 @@ export class VpnCatalogService extends BaseCatalogService {
async getActivationFees(): Promise<VpnCatalogProduct[]> {
const fields = this.getFields();
const soql = this.buildProductQuery("VPN", "Activation", [fields.product.vpnRegion]);
const records = await this.executeQuery(soql, "VPN Activation Fees");
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql,
"VPN Activation Fees"
);
return records.map(record => {
const pricebookEntry = this.extractPricebookEntry(record);

View File

@ -57,7 +57,8 @@ function getTierTemplate(tier?: string): InternetPlanTemplate {
case "platinum":
return {
tierDescription: "Tailored set up with premier Wi-Fi management support",
description: "Tailored set up with premier Wi-Fi management support - Recommended for homes & apartments larger than 50m²",
description:
"Tailored set up with premier Wi-Fi management support - Recommended for homes & apartments larger than 50m²",
features: [
"NTT modem + Netgear INSIGHT Wi-Fi routers",
"Cloud management support for remote router management",
@ -146,7 +147,6 @@ function resolveBundledAddon(product: SalesforceCatalogProductRecord) {
};
}
function derivePrices(
product: SalesforceCatalogProductRecord,
pricebookEntry?: SalesforcePricebookEntryRecord

View File

@ -16,9 +16,8 @@ import {
UpdateMappingRequest,
MappingSearchFilters,
MappingStats,
_BulkMappingResult,
} from "./types/mapping.types";
import type { IdMapping as PrismaIdMapping } from "@prisma/client";
import type { Prisma, IdMapping as PrismaIdMapping } from "@prisma/client";
@Injectable()
export class MappingsService {
@ -273,15 +272,22 @@ export class MappingsService {
async searchMappings(filters: MappingSearchFilters): Promise<UserIdMapping[]> {
try {
const whereClause: Record<string, unknown> = {};
const whereClause: Prisma.IdMappingWhereInput = {};
if (filters.userId) whereClause.userId = filters.userId;
if (filters.whmcsClientId) whereClause.whmcsClientId = filters.whmcsClientId;
if (filters.sfAccountId) whereClause.sfAccountId = filters.sfAccountId;
if (filters.hasWhmcsMapping !== undefined) {
whereClause.whmcsClientId = filters.hasWhmcsMapping ? { not: null } : null;
if (filters.hasWhmcsMapping) {
whereClause.whmcsClientId = { gt: 0 };
} else {
this.logger.debug(
"Filtering mappings without WHMCS client IDs (expected to be empty until optional linking ships)"
);
whereClause.NOT = { whmcsClientId: { gt: 0 } };
}
}
if (filters.hasSfMapping !== undefined) {
whereClause.sfAccountId = filters.hasSfMapping ? { not: null } : null;
whereClause.sfAccountId = filters.hasSfMapping ? { not: null } : { equals: null };
}
const dbMappings = await this.prisma.idMapping.findMany({
@ -301,10 +307,10 @@ export class MappingsService {
try {
const [totalCount, whmcsCount, sfCount, completeCount] = await Promise.all([
this.prisma.idMapping.count(),
this.prisma.idMapping.count({ where: { whmcsClientId: { not: null } } }),
this.prisma.idMapping.count({ where: { whmcsClientId: { gt: 0 } } }),
this.prisma.idMapping.count({ where: { sfAccountId: { not: null } } }),
this.prisma.idMapping.count({
where: { whmcsClientId: { not: null }, sfAccountId: { not: null } },
where: { whmcsClientId: { gt: 0 }, sfAccountId: { not: null } },
}),
]);

View File

@ -219,12 +219,12 @@ export class InvoicesController {
if (!mapping?.whmcsClientId) {
throw new Error("WHMCS client mapping not found");
}
const ssoResult = await this.whmcsService.createSsoToken(
mapping.whmcsClientId,
invoiceId ? `index.php?rp=/invoice/${invoiceId}` : undefined
);
return {
url: ssoResult.url,
expiresAt: ssoResult.expiresAt,
@ -272,14 +272,14 @@ export class InvoicesController {
if (!mapping?.whmcsClientId) {
throw new Error("WHMCS client mapping not found");
}
const ssoResult = await this.whmcsService.createPaymentSsoToken(
mapping.whmcsClientId,
invoiceId,
paymentMethodIdNum,
gatewayName || "stripe"
);
return {
url: ssoResult.url,
expiresAt: ssoResult.expiresAt,

View File

@ -36,15 +36,21 @@ export class InvoiceHealthService {
const whmcsResult = checks[0];
const mappingsResult = checks[1];
const isHealthy =
whmcsResult.status === "fulfilled" && whmcsResult.value &&
mappingsResult.status === "fulfilled" && mappingsResult.value;
const isHealthy =
whmcsResult.status === "fulfilled" &&
whmcsResult.value &&
mappingsResult.status === "fulfilled" &&
mappingsResult.value;
return {
status: isHealthy ? "healthy" : "unhealthy",
details: {
whmcsApi: whmcsResult.status === "fulfilled" && whmcsResult.value ? "connected" : "disconnected",
mappingsService: mappingsResult.status === "fulfilled" && mappingsResult.value ? "available" : "unavailable",
whmcsApi:
whmcsResult.status === "fulfilled" && whmcsResult.value ? "connected" : "disconnected",
mappingsService:
mappingsResult.status === "fulfilled" && mappingsResult.value
? "available"
: "unavailable",
timestamp: new Date().toISOString(),
},
};
@ -142,7 +148,7 @@ export class InvoiceHealthService {
} catch (error) {
// We expect this to fail for a non-existent user, but if the service responds, it's healthy
const errorMessage = getErrorMessage(error);
// If it's a "not found" error, the service is working
if (errorMessage.toLowerCase().includes("not found")) {
return true;
@ -159,15 +165,15 @@ export class InvoiceHealthService {
* Update average response time
*/
private updateAverageResponseTime(responseTime: number): void {
const totalRequests =
this.stats.totalInvoicesRetrieved +
this.stats.totalPaymentLinksCreated +
const totalRequests =
this.stats.totalInvoicesRetrieved +
this.stats.totalPaymentLinksCreated +
this.stats.totalSsoLinksCreated;
if (totalRequests === 1) {
this.stats.averageResponseTime = responseTime;
} else {
this.stats.averageResponseTime =
this.stats.averageResponseTime =
(this.stats.averageResponseTime * (totalRequests - 1) + responseTime) / totalRequests;
}
}
@ -182,7 +188,7 @@ export class InvoiceHealthService {
lastCheck: string;
}> {
const health = await this.healthCheck();
return {
status: health.status,
uptime: process.uptime(),

View File

@ -1,15 +1,20 @@
import { Injectable, NotFoundException, InternalServerErrorException, Inject } from "@nestjs/common";
import {
Injectable,
NotFoundException,
InternalServerErrorException,
Inject,
} from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { Invoice, InvoiceList } from "@customer-portal/domain";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
import { InvoiceValidatorService } from "../validators/invoice-validator.service";
import type {
GetInvoicesOptions,
import type {
GetInvoicesOptions,
InvoiceStatus,
PaginationOptions,
UserMappingInfo
UserMappingInfo,
} from "../types/invoice-service.types";
/**
@ -34,7 +39,7 @@ export class InvoiceRetrievalService {
// Validate inputs
this.validator.validateUserId(userId);
this.validator.validatePagination({ page, limit });
if (status) {
this.validator.validateInvoiceStatus(status);
}
@ -160,14 +165,20 @@ export class InvoiceRetrievalService {
/**
* Get cancelled invoices for a user
*/
async getCancelledInvoices(userId: string, options: PaginationOptions = {}): Promise<InvoiceList> {
async getCancelledInvoices(
userId: string,
options: PaginationOptions = {}
): Promise<InvoiceList> {
return this.getInvoicesByStatus(userId, "Cancelled", options);
}
/**
* Get invoices in collections for a user
*/
async getCollectionsInvoices(userId: string, options: PaginationOptions = {}): Promise<InvoiceList> {
async getCollectionsInvoices(
userId: string,
options: PaginationOptions = {}
): Promise<InvoiceList> {
return this.getInvoicesByStatus(userId, "Collections", options);
}
@ -176,7 +187,7 @@ export class InvoiceRetrievalService {
*/
private async getUserMapping(userId: string): Promise<UserMappingInfo> {
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) {
throw new NotFoundException("WHMCS client mapping not found");
}

View File

@ -1,22 +1,22 @@
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import {
Invoice,
InvoiceList,
InvoiceSsoLink,
import {
Invoice,
InvoiceList,
InvoiceSsoLink,
InvoicePaymentLink,
PaymentMethodList,
PaymentGatewayList
PaymentGatewayList,
} from "@customer-portal/domain";
import { InvoiceRetrievalService } from "./invoice-retrieval.service";
import { InvoiceHealthService } from "./invoice-health.service";
import { InvoiceValidatorService } from "../validators/invoice-validator.service";
import type {
GetInvoicesOptions,
import type {
GetInvoicesOptions,
InvoiceStatus,
PaginationOptions,
InvoiceHealthStatus,
InvoiceServiceStats
InvoiceServiceStats,
} from "../types/invoice-service.types";
/**
@ -41,7 +41,7 @@ export class InvoicesOrchestratorService {
*/
async getInvoices(userId: string, options: GetInvoicesOptions = {}): Promise<InvoiceList> {
const startTime = Date.now();
try {
const result = await this.retrievalService.getInvoices(userId, options);
this.healthService.recordInvoiceRetrieval(Date.now() - startTime);
@ -57,7 +57,7 @@ export class InvoicesOrchestratorService {
*/
async getInvoiceById(userId: string, invoiceId: number): Promise<Invoice> {
const startTime = Date.now();
try {
const result = await this.retrievalService.getInvoiceById(userId, invoiceId);
this.healthService.recordInvoiceRetrieval(Date.now() - startTime);
@ -77,7 +77,7 @@ export class InvoicesOrchestratorService {
options: PaginationOptions = {}
): Promise<InvoiceList> {
const startTime = Date.now();
try {
const result = await this.retrievalService.getInvoicesByStatus(userId, status, options);
this.healthService.recordInvoiceRetrieval(Date.now() - startTime);
@ -112,14 +112,20 @@ export class InvoicesOrchestratorService {
/**
* Get cancelled invoices for a user
*/
async getCancelledInvoices(userId: string, options: PaginationOptions = {}): Promise<InvoiceList> {
async getCancelledInvoices(
userId: string,
options: PaginationOptions = {}
): Promise<InvoiceList> {
return this.retrievalService.getCancelledInvoices(userId, options);
}
/**
* Get invoices in collections for a user
*/
async getCollectionsInvoices(userId: string, options: PaginationOptions = {}): Promise<InvoiceList> {
async getCollectionsInvoices(
userId: string,
options: PaginationOptions = {}
): Promise<InvoiceList> {
return this.retrievalService.getCollectionsInvoices(userId, options);
}
@ -127,11 +133,6 @@ export class InvoicesOrchestratorService {
// INVOICE OPERATIONS METHODS
// ==========================================
// ==========================================
// UTILITY METHODS
// ==========================================

View File

@ -1,9 +1,9 @@
import { Injectable, BadRequestException } from "@nestjs/common";
import type {
GetInvoicesOptions,
import type {
GetInvoicesOptions,
InvoiceValidationResult,
InvoiceStatus,
PaginationOptions
PaginationOptions,
} from "../types/invoice-service.types";
/**
@ -12,7 +12,11 @@ import type {
@Injectable()
export class InvoiceValidatorService {
private readonly validStatuses: readonly InvoiceStatus[] = [
"Paid", "Unpaid", "Cancelled", "Overdue", "Collections"
"Paid",
"Unpaid",
"Cancelled",
"Overdue",
"Collections",
] as const;
private readonly maxLimit = 100;
@ -148,7 +152,7 @@ export class InvoiceValidatorService {
*/
sanitizePaginationOptions(options: PaginationOptions): Required<PaginationOptions> {
const { page = 1, limit = 10 } = options;
return {
page: Math.max(1, Math.floor(page)),
limit: Math.max(this.minLimit, Math.min(this.maxLimit, Math.floor(limit))),

View File

@ -35,9 +35,13 @@ export class ProvisioningProcessor extends WorkerHost {
// Guard: Only process if Salesforce Order is currently 'Activating'
const fields = getSalesforceFieldMap();
const order = await this.salesforceService.getOrder(sfOrderId);
const status = (order?.[fields.order.activationStatus] as string) || "";
const status = order
? ((Reflect.get(order, fields.order.activationStatus) as string | undefined) ?? "")
: "";
const lastErrorCodeField = fields.order.lastErrorCode;
const lastErrorCode = lastErrorCodeField ? (order?.[lastErrorCodeField] as string) || "" : "";
const lastErrorCode = lastErrorCodeField
? ((order ? (Reflect.get(order, lastErrorCodeField) as string | undefined) : undefined) ?? "")
: "";
if (status !== "Activating") {
this.logger.log("Skipping provisioning job: Order not in Activating state", {
sfOrderId,

View File

@ -5,6 +5,15 @@ import { getSalesforceFieldMap } from "@bff/core/config/field-map";
import { UsersService } from "@bff/modules/users/users.service";
const fieldMap = getSalesforceFieldMap();
type OrderBuilderFieldKey =
| "orderType"
| "activationType"
| "activationScheduledAt"
| "activationStatus"
| "accessMode"
| "simType"
| "eid"
| "addressChanged";
function assignIfString(target: Record<string, unknown>, key: string, value: unknown): void {
if (typeof value === "string" && value.trim().length > 0) {
@ -12,16 +21,28 @@ function assignIfString(target: Record<string, unknown>, key: string, value: unk
}
}
function orderField(key: keyof typeof fieldMap.order): string {
return fieldMap.order[key];
function orderField(key: OrderBuilderFieldKey): string {
const fieldName = fieldMap.order[key];
if (typeof fieldName !== "string") {
throw new Error(`Missing Salesforce order field mapping for key ${String(key)}`);
}
return fieldName;
}
function mnpField(key: keyof typeof fieldMap.order.mnp): string {
return fieldMap.order.mnp[key];
const fieldName = fieldMap.order.mnp[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 {
return fieldMap.order.billing[key];
const fieldName = fieldMap.order.billing[key];
if (typeof fieldName !== "string") {
throw new Error(`Missing Salesforce order billing field mapping for key ${String(key)}`);
}
return fieldName;
}
@Injectable()

View File

@ -8,6 +8,7 @@ import type { SalesforceOrderRecord } from "@customer-portal/domain";
import { getSalesforceFieldMap } from "@bff/core/config/field-map";
const fieldMap = getSalesforceFieldMap();
type OrderStringFieldKey = "activationStatus";
export interface OrderFulfillmentValidationResult {
sfOrder: SalesforceOrderRecord;
@ -47,7 +48,7 @@ export class OrderFulfillmentValidator {
const sfOrder = await this.validateSalesforceOrder(sfOrderId);
// 2. Check if already provisioned (idempotency)
const rawWhmcs = (sfOrder as Record<string, unknown>)[fieldMap.order.whmcsOrderId];
const rawWhmcs = Reflect.get(sfOrder, fieldMap.order.whmcsOrderId) as unknown;
const existingWhmcsOrderId = typeof rawWhmcs === "string" ? rawWhmcs : undefined;
if (existingWhmcsOrderId) {
this.logger.log("Order already provisioned", {
@ -157,9 +158,12 @@ export class OrderFulfillmentValidator {
function pickOrderString(
order: SalesforceOrderRecord,
key: keyof typeof fieldMap.order
key: OrderStringFieldKey
): string | undefined {
const field = fieldMap.order[key] as keyof SalesforceOrderRecord;
const raw = order[field];
const field = fieldMap.order[key];
if (typeof field !== "string") {
return undefined;
}
const raw = Reflect.get(order, field) as unknown;
return typeof raw === "string" ? raw : undefined;
}

View File

@ -23,16 +23,22 @@ import { assertSalesforceId, buildInClause } from "@bff/integrations/salesforce/
import { getErrorMessage } from "@bff/core/utils/error.util";
const fieldMap = getSalesforceFieldMap();
type OrderFieldKey =
| "orderType"
| "activationType"
| "activationStatus"
| "activationScheduledAt"
| "whmcsOrderId";
type OrderDetailsResponse = z.infer<typeof orderDetailsSchema>;
type OrderSummaryResponse = z.infer<typeof orderSummarySchema>;
function getOrderStringField(
order: SalesforceOrderRecord,
key: keyof typeof fieldMap.order
): string | undefined {
const fieldName = fieldMap.order[key] as keyof SalesforceOrderRecord;
const raw = order[fieldName];
function getOrderStringField(order: SalesforceOrderRecord, key: OrderFieldKey): string | undefined {
const fieldName = fieldMap.order[key];
if (typeof fieldName !== "string") {
return undefined;
}
const raw = Reflect.get(order, fieldName) as unknown;
return typeof raw === "string" ? raw : undefined;
}
@ -53,8 +59,8 @@ function mapOrderItemRecord(record: SalesforceOrderItemRecord): ParsedOrderItemD
id: record.Id ?? "",
orderId: record.OrderId ?? "",
quantity: record.Quantity ?? 0,
unitPrice: typeof record.UnitPrice === "number" ? record.UnitPrice : undefined,
totalPrice: typeof record.TotalPrice === "number" ? record.TotalPrice : undefined,
unitPrice: coerceNumber(record.UnitPrice),
totalPrice: coerceNumber(record.TotalPrice),
billingCycle: typeof record.Billing_Cycle__c === "string" ? record.Billing_Cycle__c : undefined,
product: {
id: product?.Id,
@ -71,13 +77,10 @@ function mapOrderItemRecord(record: SalesforceOrderItemRecord): ParsedOrderItemD
function toOrderItemSummary(details: ParsedOrderItemDetails): OrderItemSummary {
return {
orderId: details.orderId,
product: {
name: details.product.name,
sku: details.product.sku,
itemClass: details.product.itemClass,
},
quantity: details.quantity,
name: details.product.name,
sku: details.product.sku,
itemClass: details.product.itemClass,
unitPrice: details.unitPrice,
totalPrice: details.totalPrice,
billingCycle: details.billingCycle,
@ -247,8 +250,8 @@ export class OrderOrchestrator {
id: detail.id,
orderId: detail.orderId,
quantity: detail.quantity,
unitPrice: detail.unitPrice,
totalPrice: detail.totalPrice,
unitPrice: detail.unitPrice ?? 0,
totalPrice: detail.totalPrice ?? 0,
billingCycle: detail.billingCycle,
product: {
id: detail.product.id,
@ -360,3 +363,11 @@ export class OrderOrchestrator {
}
}
}
const coerceNumber = (value: unknown): number | undefined => {
if (typeof value === "number") return value;
if (typeof value === "string") {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
};

View File

@ -126,9 +126,7 @@ export class OrderValidator {
const products = await this.whmcs.getClientsProducts({ clientid: whmcsClientId });
const existing = products?.products?.product || [];
const hasInternet = existing.some(product =>
(product.groupname || "")
.toLowerCase()
.includes("internet")
(product.groupname || "").toLowerCase().includes("internet")
);
if (hasInternet) {
throw new BadRequestException("An Internet service already exists for this account");

View File

@ -14,7 +14,6 @@ import type {
SimFeaturesUpdateRequest,
} from "./sim-management/types/sim-requests.types";
@Injectable()
export class SimManagementService {
constructor(

View File

@ -4,10 +4,7 @@ import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/f
import { SimValidationService } from "./sim-validation.service";
import { SimNotificationService } from "./sim-notification.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
import type {
SimPlanChangeRequest,
SimFeaturesUpdateRequest
} from "../types/sim-requests.types";
import type { SimPlanChangeRequest, SimFeaturesUpdateRequest } from "../types/sim-requests.types";
@Injectable()
export class SimPlanService {

View File

@ -25,7 +25,7 @@ export class SimTopUpService {
*/
async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise<void> {
let account: string = "";
try {
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
account = validation.account;
@ -52,7 +52,7 @@ export class SimTopUpService {
if (!mapping?.whmcsClientId) {
throw new BadRequestException("WHMCS client mapping not found");
}
const whmcsClientId = mapping.whmcsClientId;
this.logger.log(`Starting SIM top-up process for subscription ${subscriptionId}`, {

View File

@ -4,10 +4,7 @@ import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/f
import { SimValidationService } from "./sim-validation.service";
import { SimUsageStoreService } from "../../sim-usage-store.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
import type {
SimUsage,
SimTopUpHistory,
} from "@bff/integrations/freebit/interfaces/freebit.types";
import type { SimUsage, SimTopUpHistory } from "@bff/integrations/freebit/interfaces/freebit.types";
import type { SimTopUpHistoryRequest } from "../types/sim-requests.types";
import { BadRequestException } from "@nestjs/common";

View File

@ -18,17 +18,12 @@ import { SimValidationService } from "./services/sim-validation.service";
import { SimNotificationService } from "./services/sim-notification.service";
@Module({
imports: [
FreebitModule,
WhmcsModule,
MappingsModule,
EmailModule,
],
imports: [FreebitModule, WhmcsModule, MappingsModule, EmailModule],
providers: [
// Core services that the SIM services depend on
SimUsageStoreService,
SubscriptionsService,
// SIM management services
SimValidationService,
SimNotificationService,

View File

@ -91,11 +91,11 @@ export class SimOrderActivationService {
contractLine: "5G",
shipDate: req.activationType === "Scheduled" ? req.scheduledAt : undefined,
mnp: req.mnp
? {
? {
reserveNumber: req.mnp.reserveNumber || "",
reserveExpireDate: req.mnp.reserveExpireDate || ""
reserveExpireDate: req.mnp.reserveExpireDate || "",
}
: undefined
: undefined,
});
} else {
this.logger.warn("Physical SIM activation path is not implemented; skipping Freebit call", {

View File

@ -12,13 +12,7 @@ import { EmailModule } from "@bff/infra/email/email.module";
import { SimManagementModule } from "./sim-management/sim-management.module";
@Module({
imports: [
WhmcsModule,
MappingsModule,
FreebitModule,
EmailModule,
SimManagementModule
],
imports: [WhmcsModule, MappingsModule, FreebitModule, EmailModule, SimManagementModule],
controllers: [SubscriptionsController, SimOrdersController],
providers: [
SubscriptionsService,

View File

@ -6,9 +6,7 @@ import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { Logger } from "nestjs-pino";
import { z } from "zod";
import {
subscriptionSchema,
} from "@customer-portal/domain/validation/shared/entities";
import { subscriptionSchema } from "@customer-portal/domain/validation/shared/entities";
import type {
WhmcsProduct,
WhmcsProductsResponse,

View File

@ -53,7 +53,6 @@ export class UsersService {
};
}
private validateEmail(email: string): string {
return normalizeAndValidateEmail(email);
}
@ -300,10 +299,15 @@ export class UsersService {
).length;
recentSubscriptions = subscriptions
.filter((sub: Subscription) => sub.status === "Active")
.sort(
(a: Subscription, b: Subscription) =>
new Date(b.registrationDate).getTime() - new Date(a.registrationDate).getTime()
)
.sort((a: Subscription, b: Subscription) => {
const aTime = a.registrationDate
? new Date(a.registrationDate).getTime()
: Number.NEGATIVE_INFINITY;
const bTime = b.registrationDate
? new Date(b.registrationDate).getTime()
: Number.NEGATIVE_INFINITY;
return bTime - aTime;
})
.slice(0, 3)
.map((sub: Subscription) => ({
id: sub.id.toString(),
@ -343,10 +347,11 @@ export class UsersService {
.filter(
(inv: Invoice) => (inv.status === "Unpaid" || inv.status === "Overdue") && inv.dueDate
)
.sort(
(a: Invoice, b: Invoice) =>
new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime()
);
.sort((a: Invoice, b: Invoice) => {
const aTime = a.dueDate ? new Date(a.dueDate).getTime() : Number.POSITIVE_INFINITY;
const bTime = b.dueDate ? new Date(b.dueDate).getTime() : Number.POSITIVE_INFINITY;
return aTime - bTime;
});
if (upcomingInvoices.length > 0) {
const invoice = upcomingInvoices[0];
@ -360,10 +365,11 @@ export class UsersService {
// Recent invoices for activity
recentInvoices = invoices
.sort(
(a: Invoice, b: Invoice) =>
new Date(b.issuedAt || "").getTime() - new Date(a.issuedAt || "").getTime()
)
.sort((a: Invoice, b: Invoice) => {
const aTime = a.issuedAt ? new Date(a.issuedAt).getTime() : Number.NEGATIVE_INFINITY;
const bTime = b.issuedAt ? new Date(b.issuedAt).getTime() : Number.NEGATIVE_INFINITY;
return bTime - aTime;
})
.slice(0, 5)
.map((inv: Invoice) => ({
id: inv.id.toString(),

View File

@ -7,10 +7,10 @@ export const apiClient = {
postCalls.push([path, options]);
return { data: null } as const;
},
GET: async () => ({ data: null } as const),
PUT: async () => ({ data: null } as const),
PATCH: async () => ({ data: null } as const),
DELETE: async () => ({ data: null } as const),
GET: async () => ({ data: null }) as const,
PUT: async () => ({ data: null }) as const,
PATCH: async () => ({ data: null }) as const,
DELETE: async () => ({ data: null }) as const,
};
export const configureApiClientAuth = () => undefined;

View File

@ -93,7 +93,9 @@ const { useAuthStore } = require("../src/features/auth/services/auth.store.ts");
const [endpoint, options] = coreApiStub.postCalls[0];
if (endpoint !== "/auth/request-password-reset") {
throw new Error(`Expected endpoint \"/auth/request-password-reset\" but received \"${endpoint}\"`);
throw new Error(
`Expected endpoint \"/auth/request-password-reset\" but received \"${endpoint}\"`
);
}
if (!options || typeof options !== "object") {

View File

@ -39,4 +39,3 @@ export default function AccountLoading() {
</RouteLoading>
);
}

View File

@ -18,4 +18,3 @@ export default function CatalogLoading() {
</RouteLoading>
);
}

View File

@ -21,5 +21,3 @@ export default function CheckoutLoading() {
</RouteLoading>
);
}

View File

@ -26,4 +26,3 @@ export default function DashboardLoading() {
</RouteLoading>
);
}

View File

@ -4,4 +4,3 @@ import { AppShell } from "@/components/organisms";
export default function PortalLayout({ children }: { children: ReactNode }) {
return <AppShell>{children}</AppShell>;
}

View File

@ -14,5 +14,3 @@ export default function SupportCasesLoading() {
</RouteLoading>
);
}

View File

@ -27,5 +27,3 @@ export default function NewSupportLoading() {
</RouteLoading>
);
}

View File

@ -12,5 +12,3 @@ export default function AuthSegmentLoading() {
</AuthLayout>
);
}

View File

@ -78,15 +78,24 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((p
<span className="inline-flex items-center gap-2">
{loading ? (
<div className="animate-spin rounded-full h-4 w-4 border border-current border-t-transparent" />
) : leftIcon}
<span>{loading ? loadingText ?? children : children}</span>
) : (
leftIcon
)}
<span>{loading ? (loadingText ?? children) : children}</span>
{!loading && rightIcon ? <span className="ml-1">{rightIcon}</span> : null}
</span>
</a>
);
}
const { className, variant, size, as: _as, disabled, ...buttonProps } = rest as ButtonAsButtonProps;
const {
className,
variant,
size,
as: _as,
disabled,
...buttonProps
} = rest as ButtonAsButtonProps;
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
@ -98,8 +107,10 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((p
<span className="inline-flex items-center gap-2">
{loading ? (
<div className="animate-spin rounded-full h-4 w-4 border border-current border-t-transparent" />
) : leftIcon}
<span>{loading ? loadingText ?? children : children}</span>
) : (
leftIcon
)}
<span>{loading ? (loadingText ?? children) : children}</span>
{!loading && rightIcon ? <span className="ml-1">{rightIcon}</span> : null}
</span>
</button>

View File

@ -6,7 +6,7 @@
import React from "react";
import { cn } from "@/lib/utils";
export interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
export interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
label?: string;
error?: string;
helperText?: string;
@ -42,12 +42,8 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
</label>
)}
</div>
{helperText && !error && (
<p className="text-xs text-gray-500">{helperText}</p>
)}
{error && (
<p className="text-xs text-red-600">{error}</p>
)}
{helperText && !error && <p className="text-xs text-gray-500">{helperText}</p>}
{error && <p className="text-xs text-red-600">{error}</p>}
</div>
);
}

View File

@ -15,8 +15,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
isInvalid &&
"border-red-500 focus-visible:ring-red-500 focus-visible:ring-offset-2",
isInvalid && "border-red-500 focus-visible:ring-red-500 focus-visible:ring-offset-2",
className
)}
aria-invalid={isInvalid || undefined}

View File

@ -10,23 +10,23 @@ export type StatusPillProps = HTMLAttributes<HTMLSpanElement> & {
export const StatusPill = forwardRef<HTMLSpanElement, StatusPillProps>(
({ label, variant = "neutral", size = "md", icon, className, ...rest }, ref) => {
const tone =
variant === "success"
? "bg-green-50 text-green-700 ring-green-600/20"
: variant === "warning"
? "bg-amber-50 text-amber-700 ring-amber-600/20"
: variant === "info"
? "bg-blue-50 text-blue-700 ring-blue-600/20"
: variant === "error"
? "bg-red-50 text-red-700 ring-red-600/20"
: "bg-gray-50 text-gray-700 ring-gray-400/30";
const tone =
variant === "success"
? "bg-green-50 text-green-700 ring-green-600/20"
: variant === "warning"
? "bg-amber-50 text-amber-700 ring-amber-600/20"
: variant === "info"
? "bg-blue-50 text-blue-700 ring-blue-600/20"
: variant === "error"
? "bg-red-50 text-red-700 ring-red-600/20"
: "bg-gray-50 text-gray-700 ring-gray-400/30";
const sizing =
size === "sm"
? "px-2 py-0.5 text-xs"
: size === "lg"
? "px-4 py-1.5 text-sm"
: "px-3 py-1 text-xs";
const sizing =
size === "sm"
? "px-2 py-0.5 text-xs"
: size === "lg"
? "px-4 py-1.5 text-sm"
: "px-3 py-1 text-xs";
return (
<span

View File

@ -6,7 +6,7 @@
// Atoms - Basic building blocks
export * from "./atoms";
// Molecules - Combinations of atoms
// Molecules - Combinations of atoms
export * from "./molecules";
// Organisms - Complex UI sections

View File

@ -62,13 +62,15 @@ const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
</Label>
)}
{children ? (
isValidElement(children)
? cloneElement(children, {
id,
"aria-invalid": error ? "true" : undefined,
"aria-describedby": cn(errorId, helperTextId) || undefined,
} as Record<string, unknown>)
: children
isValidElement(children) ? (
cloneElement(children, {
id,
"aria-invalid": error ? "true" : undefined,
"aria-describedby": cn(errorId, helperTextId) || undefined,
} as Record<string, unknown>)
) : (
children
)
) : (
<Input
id={id}
@ -76,8 +78,7 @@ const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
aria-invalid={error ? "true" : undefined}
aria-describedby={cn(errorId, helperTextId) || undefined}
className={cn(
error &&
"border-red-500 focus-visible:ring-red-500 focus-visible:ring-offset-2",
error && "border-red-500 focus-visible:ring-red-500 focus-visible:ring-offset-2",
inputClassName,
inputProps.className
)}

View File

@ -10,17 +10,17 @@ interface RouteLoadingProps {
}
// Shared route-level loading wrapper used by segment loading.tsx files
export function RouteLoading({ icon, title, description, mode = "skeleton", children }: RouteLoadingProps) {
export function RouteLoading({
icon,
title,
description,
mode = "skeleton",
children,
}: RouteLoadingProps) {
// Always use PageLayout with loading state for consistent skeleton loading
return (
<PageLayout
icon={icon}
title={title}
description={description}
loading={mode === "skeleton"}
>
<PageLayout icon={icon} title={title} description={description} loading={mode === "skeleton"}>
{children}
</PageLayout>
);
}

View File

@ -10,7 +10,7 @@ interface SectionHeaderProps {
export function SectionHeader({ title, children, className }: SectionHeaderProps) {
return (
<div className={["flex items-center justify-between", className].filter(Boolean).join(" ")}>
<div className={["flex items-center justify-between", className].filter(Boolean).join(" ")}>
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
{children}
</div>
@ -18,5 +18,3 @@ export function SectionHeader({ title, children, className }: SectionHeaderProps
}
export type { SectionHeaderProps };

View File

@ -29,13 +29,13 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// Log to external error service in production
if (process.env.NODE_ENV === 'production') {
if (process.env.NODE_ENV === "production") {
// TODO: Send to error tracking service (Sentry, LogRocket, etc.)
} else {
log.error("ErrorBoundary caught an error", {
error: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack
log.error("ErrorBoundary caught an error", {
error: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
});
}
this.props.onError?.(error, errorInfo);

View File

@ -24,5 +24,3 @@ export * from "./AnimatedCard/AnimatedCard";
// Performance and lazy loading utilities
export { ErrorBoundary } from "./error-boundary";

View File

@ -42,7 +42,7 @@ export function AppShell({ children }: AppShellProps) {
const parsed = JSON.parse(saved) as unknown;
if (Array.isArray(parsed) && parsed.every(x => typeof x === "string")) {
setExpandedItems(prev => {
const next = parsed as string[];
const next = parsed;
if (next.length === prev.length && next.every(v => prev.includes(v))) return prev;
return next;
});

View File

@ -12,7 +12,9 @@ interface HeaderProps {
export const Header = memo(function Header({ onMenuClick, user, profileReady }: HeaderProps) {
const displayName = profileReady
? [user?.firstName, user?.lastName].filter(Boolean).join(" ") || user?.email?.split("@")[0] || "Account"
? [user?.firstName, user?.lastName].filter(Boolean).join(" ") ||
user?.email?.split("@")[0] ||
"Account"
: user?.email?.split("@")[0] || "Account";
return (

View File

@ -29,7 +29,9 @@ export const Sidebar = memo(function Sidebar({
<Logo size={20} />
</div>
<div>
<span className="text-base font-bold text-[var(--cp-sidebar-text)]">Assist Solutions</span>
<span className="text-base font-bold text-[var(--cp-sidebar-text)]">
Assist Solutions
</span>
<p className="text-xs text-[var(--cp-sidebar-text)]/60">Customer Portal</p>
</div>
</div>
@ -221,4 +223,3 @@ const NavigationItem = memo(function NavigationItem({
</Link>
);
});

View File

@ -87,4 +87,3 @@ export function truncate(text: string, max: number): string {
if (text.length <= max) return text;
return text.slice(0, Math.max(0, max - 1)) + "…";
}

View File

@ -0,0 +1,2 @@
export { AuthLayout } from "./AuthLayout";
export type { AuthLayoutProps } from "./AuthLayout";

View File

@ -0,0 +1,2 @@
export { PageLayout } from "./PageLayout";
export type { BreadcrumbItem } from "./PageLayout";

View File

@ -8,4 +8,3 @@ export type { AuthLayoutProps } from "./AuthLayout/AuthLayout";
export { PageLayout } from "./PageLayout/PageLayout";
export type { BreadcrumbItem } from "./PageLayout/PageLayout";

View File

@ -2,10 +2,10 @@
import { useCallback } from "react";
import { accountService } from "@/features/account/services/account.service";
import {
addressFormSchema,
import {
addressFormSchema,
addressFormToRequest,
type AddressFormData
type AddressFormData,
} from "@customer-portal/domain";
import { useZodForm } from "@customer-portal/validation";

View File

@ -3,10 +3,10 @@
import { useCallback } from "react";
import { accountService } from "@/features/account/services/account.service";
import { useAuthStore } from "@/features/auth/services/auth.store";
import {
profileEditFormSchema,
import {
profileEditFormSchema,
profileFormToRequest,
type ProfileEditFormData
type ProfileEditFormData,
} from "@customer-portal/domain";
import { useZodForm } from "@customer-portal/validation";
@ -15,7 +15,7 @@ export function useProfileEdit(initial: ProfileEditFormData) {
try {
const requestData = profileFormToRequest(formData);
const updated = await accountService.updateProfile(requestData);
useAuthStore.setState(state => ({
...state,
user: state.user ? { ...state.user, ...updated } : state.user,

View File

@ -9,22 +9,22 @@ type ProfileUpdateInput = {
export const accountService = {
async getProfile() {
const response = await apiClient.GET('/api/me');
const response = await apiClient.GET<UserProfile>("/api/me");
return getNullableData<UserProfile>(response);
},
async updateProfile(update: ProfileUpdateInput) {
const response = await apiClient.PATCH('/api/me', { body: update });
const response = await apiClient.PATCH<UserProfile>("/api/me", { body: update });
return getDataOrThrow<UserProfile>(response, "Failed to update profile");
},
async getAddress() {
const response = await apiClient.GET('/api/me/address');
const response = await apiClient.GET<Address>("/api/me/address");
return getNullableData<Address>(response);
},
async updateAddress(address: Address) {
const response = await apiClient.PATCH('/api/me/address', { body: address });
const response = await apiClient.PATCH<Address>("/api/me/address", { body: address });
return getDataOrThrow<Address>(response, "Failed to update address");
},
};

View File

@ -3,7 +3,13 @@
import { useEffect, useState } from "react";
import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { MapPinIcon, PencilIcon, CheckIcon, XMarkIcon, UserIcon } from "@heroicons/react/24/outline";
import {
MapPinIcon,
PencilIcon,
CheckIcon,
XMarkIcon,
UserIcon,
} from "@heroicons/react/24/outline";
import { useAuthStore } from "@/features/auth/services/auth.store";
import { accountService } from "@/features/account/services/account.service";
import { useProfileEdit } from "@/features/account/hooks/useProfileEdit";
@ -245,11 +251,14 @@ export default function ProfileContainer() {
<Button
size="sm"
onClick={() => {
void profile.handleSubmit().then(() => {
setEditingProfile(false);
}).catch(() => {
// Error is handled by useZodForm
});
void profile
.handleSubmit()
.then(() => {
setEditingProfile(false);
})
.catch(() => {
// Error is handled by useZodForm
});
}}
disabled={profile.isSubmitting}
>
@ -299,12 +308,12 @@ export default function ProfileContainer() {
country: address.values.country,
}}
onChange={a => {
address.setValue("street", a.street);
address.setValue("streetLine2", a.streetLine2);
address.setValue("city", a.city);
address.setValue("state", a.state);
address.setValue("postalCode", a.postalCode);
address.setValue("country", a.country);
address.setValue("street", a.street ?? "");
address.setValue("streetLine2", a.streetLine2 ?? "");
address.setValue("city", a.city ?? "");
address.setValue("state", a.state ?? "");
address.setValue("postalCode", a.postalCode ?? "");
address.setValue("country", a.country ?? "");
}}
title="Mailing Address"
/>
@ -321,11 +330,14 @@ export default function ProfileContainer() {
<Button
size="sm"
onClick={() => {
void address.handleSubmit().then(() => {
setEditingAddress(false);
}).catch(() => {
// Error is handled by useZodForm
});
void address
.handleSubmit()
.then(() => {
setEditingAddress(false);
})
.catch(() => {
// Error is handled by useZodForm
});
}}
disabled={address.isSubmitting}
>
@ -353,7 +365,9 @@ export default function ProfileContainer() {
{address.values.street || address.values.city ? (
<div className="bg-gray-50 rounded-lg p-4">
<div className="text-gray-900 space-y-1">
{address.values.street && <p className="font-medium">{address.values.street}</p>}
{address.values.street && (
<p className="font-medium">{address.values.street}</p>
)}
{address.values.streetLine2 && <p>{address.values.streetLine2}</p>}
<p>
{[address.values.city, address.values.state, address.values.postalCode]
@ -378,5 +392,3 @@ export default function ProfileContainer() {
</div>
);
}

View File

@ -19,28 +19,25 @@ interface LinkWhmcsFormProps {
export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormProps) {
const { linkWhmcs, loading, error, clearError } = useWhmcsLink();
const handleLink = useCallback(async (formData: LinkWhmcsFormData) => {
clearError();
try {
const payload: LinkWhmcsRequestData = {
email: formData.email,
password: formData.password,
};
const result = await linkWhmcs(payload);
onTransferred?.({ ...result, email: formData.email });
} catch (err) {
// Error is handled by useZodForm
throw err;
}
}, [linkWhmcs, onTransferred, clearError]);
const handleLink = useCallback(
async (formData: LinkWhmcsFormData) => {
clearError();
try {
const payload: LinkWhmcsRequestData = {
email: formData.email,
password: formData.password,
};
const result = await linkWhmcs(payload);
onTransferred?.({ ...result, email: formData.email });
} catch (err) {
// Error is handled by useZodForm
throw err;
}
},
[linkWhmcs, onTransferred, clearError]
);
const {
values,
errors,
isSubmitting,
setValue,
handleSubmit,
} = useZodForm({
const { values, errors, isSubmitting, setValue, handleSubmit } = useZodForm({
schema: linkWhmcsRequestSchema,
initialValues: {
email: "",
@ -53,56 +50,38 @@ export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormPr
<div className={`w-full max-w-md mx-auto ${className}`}>
<div className="bg-white shadow-sm rounded-lg border border-gray-200 p-6">
<div className="mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-2">
Link Your WHMCS Account
</h2>
<h2 className="text-lg font-semibold text-gray-900 mb-2">Link Your WHMCS Account</h2>
<p className="text-sm text-gray-600">
Enter your existing WHMCS credentials to link your account and migrate your data.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<FormField
label="Email Address"
error={errors.email}
required
>
<FormField label="Email Address" error={errors.email} required>
<Input
type="email"
value={values.email}
onChange={(e) => setValue("email", e.target.value)}
onChange={e => setValue("email", e.target.value)}
placeholder="Enter your WHMCS email"
disabled={isSubmitting || loading}
className="w-full"
/>
</FormField>
<FormField
label="Password"
error={errors.password}
required
>
<FormField label="Password" error={errors.password} required>
<Input
type="password"
value={values.password}
onChange={(e) => setValue("password", e.target.value)}
onChange={e => setValue("password", e.target.value)}
placeholder="Enter your WHMCS password"
disabled={isSubmitting || loading}
className="w-full"
/>
</FormField>
{error && (
<ErrorMessage className="text-center">
{error}
</ErrorMessage>
)}
{error && <ErrorMessage className="text-center">{error}</ErrorMessage>}
<Button
type="submit"
disabled={isSubmitting || loading}
className="w-full"
>
<Button type="submit" disabled={isSubmitting || loading} className="w-full">
{isSubmitting || loading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>

View File

@ -10,12 +10,9 @@ import Link from "next/link";
import { Button, Input, ErrorMessage } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
import { useLogin } from "../../hooks/use-auth";
import {
loginFormSchema,
loginFormToRequest,
type LoginFormData
} from "@customer-portal/domain";
import { loginFormSchema, loginFormToRequest, type LoginFormData } from "@customer-portal/domain";
import { useZodForm } from "@customer-portal/validation";
import { z } from "zod";
interface LoginFormProps {
onSuccess?: () => void;
@ -25,6 +22,12 @@ interface LoginFormProps {
className?: string;
}
const loginSchema = loginFormSchema.extend({
rememberMe: z.boolean().optional(),
});
type LoginFormValues = z.infer<typeof loginSchema>;
export function LoginForm({
onSuccess,
onError,
@ -34,18 +37,21 @@ export function LoginForm({
}: LoginFormProps) {
const { login, loading, error, clearError } = useLogin();
const handleLogin = useCallback(async (formData: LoginFormData) => {
clearError();
try {
const requestData = loginFormToRequest(formData);
await login(requestData);
onSuccess?.();
} catch (err) {
const message = err instanceof Error ? err.message : "Login failed";
onError?.(message);
throw err; // Re-throw to let useZodForm handle the error state
}
}, [login, onSuccess, onError, clearError]);
const handleLogin = useCallback(
async ({ rememberMe: _rememberMe, ...formData }: LoginFormValues) => {
clearError();
try {
const requestData = loginFormToRequest(formData);
await login(requestData);
onSuccess?.();
} catch (err) {
const message = err instanceof Error ? err.message : "Login failed";
onError?.(message);
throw err; // Re-throw to let useZodForm handle the error state
}
},
[login, onSuccess, onError, clearError]
);
const {
values,
@ -56,8 +62,8 @@ export function LoginForm({
setTouchedField,
handleSubmit,
validateField,
} = useZodForm({
schema: loginFormSchema,
} = useZodForm<LoginFormValues>({
schema: loginSchema,
initialValues: {
email: "",
password: "",
@ -69,15 +75,11 @@ export function LoginForm({
return (
<div className={`w-full max-w-md mx-auto ${className}`}>
<form onSubmit={handleSubmit} className="space-y-6">
<FormField
label="Email Address"
error={touched.email ? errors.email : undefined}
required
>
<FormField label="Email Address" error={touched.email ? errors.email : undefined} required>
<Input
type="email"
value={values.email}
onChange={(e) => setValue("email", e.target.value)}
onChange={e => setValue("email", e.target.value)}
onBlur={() => setTouchedField("email")}
placeholder="Enter your email"
disabled={isSubmitting || loading}
@ -85,15 +87,11 @@ export function LoginForm({
/>
</FormField>
<FormField
label="Password"
error={touched.password ? errors.password : undefined}
required
>
<FormField label="Password" error={touched.password ? errors.password : undefined} required>
<Input
type="password"
value={values.password}
onChange={(e) => setValue("password", e.target.value)}
onChange={e => setValue("password", e.target.value)}
onBlur={() => setTouchedField("password")}
placeholder="Enter your password"
disabled={isSubmitting || loading}
@ -108,7 +106,7 @@ export function LoginForm({
name="remember-me"
type="checkbox"
checked={values.rememberMe}
onChange={(e) => setValue("rememberMe", e.target.checked)}
onChange={e => setValue("rememberMe", e.target.checked)}
disabled={isSubmitting || loading}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
@ -129,17 +127,9 @@ export function LoginForm({
)}
</div>
{error && (
<ErrorMessage className="text-center">
{error}
</ErrorMessage>
)}
{error && <ErrorMessage className="text-center">{error}</ErrorMessage>}
<Button
type="submit"
disabled={isSubmitting || loading}
className="w-full"
>
<Button type="submit" disabled={isSubmitting || loading} className="w-full">
{isSubmitting || loading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
@ -166,4 +156,4 @@ export function LoginForm({
</form>
</div>
);
}
}

View File

@ -11,12 +11,13 @@ import { Button, Input, ErrorMessage } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
import { usePasswordReset } from "../../hooks/use-auth";
import { useZodForm } from "@customer-portal/validation";
import {
passwordResetRequestFormSchema,
import {
passwordResetRequestFormSchema,
passwordResetFormSchema,
type PasswordResetRequestFormData,
type PasswordResetFormData
type PasswordResetFormData,
} from "@customer-portal/domain";
import { z } from "zod";
interface PasswordResetFormProps {
mode: "request" | "reset";
@ -38,10 +39,10 @@ export function PasswordResetForm({
const { requestPasswordReset, resetPassword, loading, error, clearError } = usePasswordReset();
// Zod form for password reset request
const requestForm = useZodForm({
const requestForm = useZodForm<PasswordResetRequestFormData>({
schema: passwordResetRequestFormSchema,
initialValues: { email: "" },
onSubmit: async (data) => {
onSubmit: async data => {
try {
await requestPasswordReset(data.email);
onSuccess?.();
@ -53,10 +54,26 @@ export function PasswordResetForm({
});
// Zod form for password reset (with confirm password)
const resetForm = useZodForm({
schema: passwordResetFormSchema,
const resetSchema = passwordResetFormSchema
.extend({
confirmPassword: z.string().min(1, "Please confirm your new password"),
})
.superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["confirmPassword"],
message: "Passwords do not match",
});
}
});
type ResetFormValues = z.infer<typeof resetSchema>;
const resetForm = useZodForm<ResetFormValues>({
schema: resetSchema,
initialValues: { token: token || "", password: "", confirmPassword: "" },
onSubmit: async (data) => {
onSubmit: async ({ confirmPassword: _ignore, ...data }) => {
try {
await resetPassword(data.token, data.password);
onSuccess?.();
@ -95,27 +112,19 @@ export function PasswordResetForm({
</div>
<form onSubmit={requestForm.handleSubmit} className="space-y-4">
<FormField
label="Email address"
error={requestForm.errors.email}
required
>
<FormField label="Email address" error={requestForm.errors.email} required>
<Input
type="email"
placeholder="Enter your email"
value={requestForm.values.email}
onChange={(e) => requestForm.setValue("email", e.target.value)}
onChange={e => requestForm.setValue("email", e.target.value)}
onBlur={() => requestForm.validate()}
disabled={loading || requestForm.isSubmitting}
className={requestForm.errors.email ? "border-red-300" : ""}
/>
</FormField>
{error && (
<ErrorMessage>
{error}
</ErrorMessage>
)}
{error && <ErrorMessage>{error}</ErrorMessage>}
<Button
type="submit"
@ -129,10 +138,7 @@ export function PasswordResetForm({
{showLoginLink && (
<div className="text-center">
<Link
href="/login"
className="text-sm text-blue-600 hover:text-blue-500 font-medium"
>
<Link href="/login" className="text-sm text-blue-600 hover:text-blue-500 font-medium">
Back to login
</Link>
</div>
@ -146,49 +152,35 @@ export function PasswordResetForm({
<div className={`space-y-6 ${className}`}>
<div className="text-center">
<h2 className="text-2xl font-bold text-gray-900">Set new password</h2>
<p className="mt-2 text-sm text-gray-600">
Enter your new password below.
</p>
<p className="mt-2 text-sm text-gray-600">Enter your new password below.</p>
</div>
<form onSubmit={resetForm.handleSubmit} className="space-y-4">
<FormField
label="New password"
error={resetForm.errors.password}
required
>
<FormField label="New password" error={resetForm.errors.password} required>
<Input
type="password"
placeholder="Enter new password"
value={resetForm.values.password}
onChange={(e) => resetForm.setValue("password", e.target.value)}
onChange={e => resetForm.setValue("password", e.target.value)}
onBlur={() => resetForm.validate()}
disabled={loading || resetForm.isSubmitting}
className={resetForm.errors.password ? "border-red-300" : ""}
/>
</FormField>
<FormField
label="Confirm password"
error={resetForm.errors.confirmPassword}
required
>
<FormField label="Confirm password" error={resetForm.errors.confirmPassword} required>
<Input
type="password"
placeholder="Confirm new password"
value={resetForm.values.confirmPassword}
onChange={(e) => resetForm.setValue("confirmPassword", e.target.value)}
onChange={e => resetForm.setValue("confirmPassword", e.target.value)}
onBlur={() => resetForm.validate()}
disabled={loading || resetForm.isSubmitting}
className={resetForm.errors.confirmPassword ? "border-red-300" : ""}
/>
</FormField>
{error && (
<ErrorMessage>
{error}
</ErrorMessage>
)}
{error && <ErrorMessage>{error}</ErrorMessage>}
<Button
type="submit"
@ -202,10 +194,7 @@ export function PasswordResetForm({
{showLoginLink && (
<div className="text-center">
<Link
href="/login"
className="text-sm text-blue-600 hover:text-blue-500 font-medium"
>
<Link href="/login" className="text-sm text-blue-600 hover:text-blue-500 font-medium">
Back to login
</Link>
</div>

View File

@ -56,9 +56,7 @@ export function SessionTimeoutWarning({
const warningTimeout = setTimeout(() => {
setShowWarning(true);
setTimeLeft(
Math.max(1, Math.ceil((expiryRef.current! - Date.now()) / (60 * 1000)))
);
setTimeLeft(Math.max(1, Math.ceil((expiryRef.current! - Date.now()) / (60 * 1000))));
}, timeUntilWarning);
return () => clearTimeout(warningTimeout);
@ -200,4 +198,3 @@ export function SessionTimeoutWarning({
</div>
);
}

View File

@ -11,10 +11,8 @@ import { Button, Input, ErrorMessage } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
import { useWhmcsLink } from "../../hooks/use-auth";
import { useZodForm } from "@customer-portal/validation";
import {
setPasswordFormSchema,
type SetPasswordFormData
} from "@customer-portal/domain";
import { setPasswordFormSchema, type SetPasswordFormData } from "@customer-portal/domain";
import { z } from "zod";
interface SetPasswordFormProps {
email?: string;
@ -34,14 +32,30 @@ export function SetPasswordForm({
const { setPassword, loading, error, clearError } = useWhmcsLink();
// Zod form with confirm password validation
const form = useZodForm({
schema: setPasswordFormSchema,
initialValues: {
email,
password: "",
confirmPassword: ""
const formSchema = setPasswordFormSchema
.extend({
confirmPassword: z.string().min(1, "Please confirm your password"),
})
.superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["confirmPassword"],
message: "Passwords do not match",
});
}
});
type SetPasswordFormValues = z.infer<typeof formSchema>;
const form = useZodForm<SetPasswordFormValues>({
schema: formSchema,
initialValues: {
email,
password: "",
confirmPassword: "",
},
onSubmit: async (data) => {
onSubmit: async ({ confirmPassword: _ignore, ...data }) => {
try {
await setPassword(data.email, data.password);
onSuccess?.();
@ -76,59 +90,43 @@ export function SetPasswordForm({
</div>
<form onSubmit={form.handleSubmit} className="space-y-4">
<FormField
label="Email address"
error={form.errors.email}
required
>
<FormField label="Email address" error={form.errors.email} required>
<Input
type="email"
placeholder="Enter your email"
value={form.values.email}
onChange={(e) => form.setValue("email", e.target.value)}
onChange={e => form.setValue("email", e.target.value)}
onBlur={() => form.setTouched("email", true)}
disabled={loading || form.isSubmitting}
className={form.errors.email ? "border-red-300" : ""}
/>
</FormField>
<FormField
label="Password"
error={form.errors.password}
required
>
<FormField label="Password" error={form.errors.password} required>
<Input
type="password"
placeholder="Enter your password"
value={form.values.password}
onChange={(e) => form.setValue("password", e.target.value)}
onChange={e => form.setValue("password", e.target.value)}
onBlur={() => form.setTouched("password", true)}
disabled={loading || form.isSubmitting}
className={form.errors.password ? "border-red-300" : ""}
/>
</FormField>
<FormField
label="Confirm password"
error={form.errors.confirmPassword}
required
>
<FormField label="Confirm password" error={form.errors.confirmPassword} required>
<Input
type="password"
placeholder="Confirm your password"
value={form.values.confirmPassword}
onChange={(e) => form.setValue("confirmPassword", e.target.value)}
onChange={e => form.setValue("confirmPassword", e.target.value)}
onBlur={() => form.setTouched("confirmPassword", true)}
disabled={loading || form.isSubmitting}
className={form.errors.confirmPassword ? "border-red-300" : ""}
/>
</FormField>
{(error || form.errors._form) && (
<ErrorMessage>
{form.errors._form || error}
</ErrorMessage>
)}
{(error || form.errors._form) && <ErrorMessage>{form.errors._form || error}</ErrorMessage>}
<Button
type="submit"
@ -142,14 +140,11 @@ export function SetPasswordForm({
{showLoginLink && (
<div className="text-center">
<Link
href="/login"
className="text-sm text-blue-600 hover:text-blue-500 font-medium"
>
<Link href="/login" className="text-sm text-blue-600 hover:text-blue-500 font-medium">
Back to login
</Link>
</div>
)}
</div>
);
}
}

View File

@ -8,8 +8,8 @@
import { useCallback } from "react";
import { Input } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
import type { SignupFormData } from "@customer-portal/domain";
import type { FormErrors, FormTouched, UseZodFormReturn } from "@customer-portal/validation";
import type { SignupFormValues } from "./SignupForm";
const COUNTRIES = [
{ code: "US", name: "United States" },
@ -35,11 +35,11 @@ const COUNTRIES = [
];
interface AddressStepProps {
address: SignupFormData["address"];
errors: FormErrors<SignupFormData>;
touched: FormTouched<SignupFormData>;
onAddressChange: (address: SignupFormData["address"]) => void;
setTouchedField: UseZodFormReturn<SignupFormData>["setTouchedField"];
address: SignupFormValues["address"];
errors: FormErrors<SignupFormValues>;
touched: FormTouched<SignupFormValues>;
onAddressChange: (address: SignupFormValues["address"]) => void;
setTouchedField: UseZodFormReturn<SignupFormValues>["setTouchedField"];
}
export function AddressStep({
@ -49,20 +49,26 @@ export function AddressStep({
onAddressChange,
setTouchedField,
}: AddressStepProps) {
const updateAddressField = useCallback((field: keyof SignupFormData["address"], value: string) => {
onAddressChange({ ...address, [field]: value });
}, [address, onAddressChange]);
const updateAddressField = useCallback(
(field: keyof SignupFormValues["address"], value: string) => {
onAddressChange({ ...address, [field]: value });
},
[address, onAddressChange]
);
const getFieldError = useCallback((field: keyof SignupFormData["address"]) => {
const fieldKey = `address.${field as string}`;
const isTouched = touched[fieldKey] ?? touched.address;
const getFieldError = useCallback(
(field: keyof SignupFormValues["address"]) => {
const fieldKey = `address.${field as string}`;
const isTouched = touched[fieldKey] ?? touched.address;
if (!isTouched) {
return undefined;
}
if (!isTouched) {
return undefined;
}
return errors[fieldKey] ?? errors[field as string] ?? errors.address;
}, [errors, touched]);
return errors[fieldKey] ?? errors[field as string] ?? errors.address;
},
[errors, touched]
);
const markTouched = useCallback(() => {
setTouchedField("address");
@ -70,29 +76,22 @@ export function AddressStep({
return (
<div className="space-y-6">
<FormField
label="Street Address"
error={getFieldError("street")}
required
>
<FormField label="Street Address" error={getFieldError("street")} required>
<Input
type="text"
value={address.street}
onChange={(e) => updateAddressField("street", e.target.value)}
onChange={e => updateAddressField("street", e.target.value)}
onBlur={markTouched}
placeholder="Enter your street address"
className="w-full"
/>
</FormField>
<FormField
label="Address Line 2 (Optional)"
error={getFieldError("streetLine2")}
>
<FormField label="Address Line 2 (Optional)" error={getFieldError("streetLine2")}>
<Input
type="text"
value={address.streetLine2 || ""}
onChange={(e) => updateAddressField("streetLine2", e.target.value)}
onChange={e => updateAddressField("streetLine2", e.target.value)}
onBlur={markTouched}
placeholder="Apartment, suite, etc."
className="w-full"
@ -100,30 +99,22 @@ export function AddressStep({
</FormField>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<FormField
label="City"
error={getFieldError("city")}
required
>
<FormField label="City" error={getFieldError("city")} required>
<Input
type="text"
value={address.city}
onChange={(e) => updateAddressField("city", e.target.value)}
onChange={e => updateAddressField("city", e.target.value)}
onBlur={markTouched}
placeholder="Enter your city"
className="w-full"
/>
</FormField>
<FormField
label="State/Province"
error={getFieldError("state")}
required
>
<FormField label="State/Province" error={getFieldError("state")} required>
<Input
type="text"
value={address.state}
onChange={(e) => updateAddressField("state", e.target.value)}
onChange={e => updateAddressField("state", e.target.value)}
onBlur={markTouched}
placeholder="Enter your state/province"
className="w-full"
@ -132,34 +123,26 @@ export function AddressStep({
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<FormField
label="Postal Code"
error={getFieldError("postalCode")}
required
>
<FormField label="Postal Code" error={getFieldError("postalCode")} required>
<Input
type="text"
value={address.postalCode}
onChange={(e) => updateAddressField("postalCode", e.target.value)}
onChange={e => updateAddressField("postalCode", e.target.value)}
onBlur={markTouched}
placeholder="Enter your postal code"
className="w-full"
/>
</FormField>
<FormField
label="Country"
error={getFieldError("country")}
required
>
<FormField label="Country" error={getFieldError("country")} required>
<select
value={address.country}
onChange={(e) => updateAddressField("country", e.target.value)}
onChange={e => updateAddressField("country", e.target.value)}
onBlur={markTouched}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Select a country</option>
{COUNTRIES.map((country) => (
{COUNTRIES.map(country => (
<option key={country.code} value={country.name}>
{country.name}
</option>

View File

@ -5,14 +5,16 @@
"use client";
import { Input, Checkbox } from "@/components/atoms";
import { Input } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
import { type SignupFormData } from "@customer-portal/domain";
import type { UseZodFormReturn } from "@customer-portal/validation";
import type { SignupFormValues } from "./SignupForm";
interface PasswordStepProps extends Pick<UseZodFormReturn<SignupFormData>,
'values' | 'errors' | 'touched' | 'setValue' | 'setTouchedField'> {
}
interface PasswordStepProps
extends Pick<
UseZodFormReturn<SignupFormValues>,
"values" | "errors" | "touched" | "setValue" | "setTouchedField"
> {}
export function PasswordStep({
values,
@ -32,7 +34,7 @@ export function PasswordStep({
<Input
type="password"
value={values.password}
onChange={(e) => setValue("password", e.target.value)}
onChange={e => setValue("password", e.target.value)}
onBlur={() => setTouchedField("password")}
placeholder="Create a secure password"
className="w-full"
@ -47,7 +49,7 @@ export function PasswordStep({
<Input
type="password"
value={values.confirmPassword}
onChange={(e) => setValue("confirmPassword", e.target.value)}
onChange={e => setValue("confirmPassword", e.target.value)}
onBlur={() => setTouchedField("confirmPassword")}
placeholder="Confirm your password"
className="w-full"
@ -62,7 +64,7 @@ export function PasswordStep({
name="accept-terms"
type="checkbox"
checked={values.acceptTerms}
onChange={(e) => setValue("acceptTerms", e.target.checked)}
onChange={e => setValue("acceptTerms", e.target.checked)}
onBlur={() => setTouchedField("acceptTerms")}
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
@ -91,7 +93,7 @@ export function PasswordStep({
name="marketing-consent"
type="checkbox"
checked={values.marketingConsent}
onChange={(e) => setValue("marketingConsent", e.target.checked)}
onChange={e => setValue("marketingConsent", e.target.checked)}
onBlur={() => setTouchedField("marketingConsent")}
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
@ -105,4 +107,4 @@ export function PasswordStep({
</div>
</div>
);
}
}

View File

@ -7,15 +7,15 @@
import { Input } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
import { type SignupFormData } from "@customer-portal/domain";
import type { FormErrors, FormTouched, UseZodFormReturn } from "@customer-portal/validation";
import type { SignupFormValues } from "./SignupForm";
interface PersonalStepProps {
values: SignupFormData;
errors: FormErrors<SignupFormData>;
touched: FormTouched<SignupFormData>;
setValue: UseZodFormReturn<SignupFormData>["setValue"];
setTouchedField: UseZodFormReturn<SignupFormData>["setTouchedField"];
values: SignupFormValues;
errors: FormErrors<SignupFormValues>;
touched: FormTouched<SignupFormValues>;
setValue: UseZodFormReturn<SignupFormValues>["setValue"];
setTouchedField: UseZodFormReturn<SignupFormValues>["setTouchedField"];
}
export function PersonalStep({
@ -25,37 +25,29 @@ export function PersonalStep({
setValue,
setTouchedField,
}: PersonalStepProps) {
const getError = (field: keyof SignupFormData) => {
const getError = (field: keyof SignupFormValues) => {
return touched[field as string] ? errors[field as string] : undefined;
};
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<FormField
label="First Name"
error={getError("firstName")}
required
>
<FormField label="First Name" error={getError("firstName")} required>
<Input
type="text"
value={values.firstName}
onChange={(e) => setValue("firstName", e.target.value)}
onChange={e => setValue("firstName", e.target.value)}
onBlur={() => setTouchedField("firstName")}
placeholder="Enter your first name"
className="w-full"
/>
</FormField>
<FormField
label="Last Name"
error={getError("lastName")}
required
>
<FormField label="Last Name" error={getError("lastName")} required>
<Input
type="text"
value={values.lastName}
onChange={(e) => setValue("lastName", e.target.value)}
onChange={e => setValue("lastName", e.target.value)}
onBlur={() => setTouchedField("lastName")}
placeholder="Enter your last name"
className="w-full"
@ -63,30 +55,22 @@ export function PersonalStep({
</FormField>
</div>
<FormField
label="Email Address"
error={getError("email")}
required
>
<FormField label="Email Address" error={getError("email")} required>
<Input
type="email"
value={values.email}
onChange={(e) => setValue("email", e.target.value)}
onChange={e => setValue("email", e.target.value)}
onBlur={() => setTouchedField("email")}
placeholder="Enter your email address"
className="w-full"
/>
</FormField>
<FormField
label="Phone Number"
error={getError("phone")}
required
>
<FormField label="Phone Number" error={getError("phone")} required>
<Input
type="tel"
value={values.phone || ""}
onChange={(e) => setValue("phone", e.target.value)}
onChange={e => setValue("phone", e.target.value)}
onBlur={() => setTouchedField("phone")}
placeholder="+81 XX-XXXX-XXXX"
className="w-full"
@ -102,21 +86,18 @@ export function PersonalStep({
<Input
type="text"
value={values.sfNumber}
onChange={(e) => setValue("sfNumber", e.target.value)}
onChange={e => setValue("sfNumber", e.target.value)}
onBlur={() => setTouchedField("sfNumber")}
placeholder="Enter your customer number"
className="w-full"
/>
</FormField>
<FormField
label="Company (Optional)"
error={getError("company")}
>
<FormField label="Company (Optional)" error={getError("company")}>
<Input
type="text"
value={values.company || ""}
onChange={(e) => setValue("company", e.target.value)}
onChange={e => setValue("company", e.target.value)}
onBlur={() => setTouchedField("company")}
placeholder="Enter your company name"
className="w-full"

View File

@ -9,12 +9,9 @@ import { useState, useCallback, useMemo } from "react";
import Link from "next/link";
import { ErrorMessage } from "@/components/atoms";
import { useSignup } from "../../hooks/use-auth";
import {
signupFormSchema,
signupFormToRequest,
type SignupFormData
} from "@customer-portal/domain";
import { signupFormSchema, signupFormToRequest, type SignupRequest } from "@customer-portal/domain";
import { useZodForm } from "@customer-portal/validation";
import { z } from "zod";
import { MultiStepForm, type FormStep } from "./MultiStepForm";
import { AddressStep } from "./AddressStep";
@ -28,6 +25,26 @@ interface SignupFormProps {
className?: string;
}
export const signupSchema = signupFormSchema
.extend({
confirmPassword: z.string().min(1, "Please confirm your password"),
acceptTerms: z
.boolean()
.refine(Boolean, { message: "You must accept the terms and conditions" }),
marketingConsent: z.boolean().optional(),
})
.superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["confirmPassword"],
message: "Passwords do not match",
});
}
});
export type SignupFormValues = z.infer<typeof signupSchema>;
export function SignupForm({
onSuccess,
onError,
@ -37,18 +54,31 @@ export function SignupForm({
const { signup, loading, error, clearError } = useSignup();
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const handleSignup = useCallback(async (formData: SignupFormData) => {
clearError();
try {
const requestData = signupFormToRequest(formData);
await signup(requestData);
onSuccess?.();
} catch (err) {
const message = err instanceof Error ? err.message : "Signup failed";
onError?.(message);
throw err; // Re-throw to let useZodForm handle the error state
}
}, [signup, onSuccess, onError, clearError]);
const handleSignup = useCallback(
async ({
confirmPassword: _confirm,
acceptTerms,
marketingConsent,
...formData
}: SignupFormValues) => {
clearError();
try {
const baseRequest = signupFormToRequest(formData);
const request: SignupRequest = {
...baseRequest,
acceptTerms,
marketingConsent,
};
await signup(request);
onSuccess?.();
} catch (err) {
const message = err instanceof Error ? err.message : "Signup failed";
onError?.(message);
throw err; // Re-throw to let useZodForm handle the error state
}
},
[signup, onSuccess, onError, clearError]
);
const {
values,
@ -59,8 +89,8 @@ export function SignupForm({
setTouchedField,
handleSubmit,
validate,
} = useZodForm({
schema: signupFormSchema,
} = useZodForm<SignupFormValues>({
schema: signupSchema,
initialValues: {
email: "",
password: "",
@ -88,12 +118,9 @@ export function SignupForm({
});
// Handle step change with validation
const handleStepChange = useCallback(
(stepIndex: number) => {
setCurrentStepIndex(stepIndex);
},
[]
);
const handleStepChange = useCallback((stepIndex: number) => {
setCurrentStepIndex(stepIndex);
}, []);
// Step field definitions (memoized for performance)
const stepFields = useMemo(
@ -107,15 +134,18 @@ export function SignupForm({
);
// Validate specific step fields (optimized)
const validateStep = useCallback((stepIndex: number): boolean => {
const fields = stepFields[stepIndex as keyof typeof stepFields] || [];
// Mark fields as touched and check for errors
fields.forEach(field => setTouchedField(field));
// Use the validate function to get current validation state
return validate() || !fields.some(field => Boolean(errors[String(field)]));
}, [stepFields, setTouchedField, validate, errors]);
const validateStep = useCallback(
(stepIndex: number): boolean => {
const fields = stepFields[stepIndex as keyof typeof stepFields] || [];
// Mark fields as touched and check for errors
fields.forEach(field => setTouchedField(field));
// Use the validate function to get current validation state
return validate() || !fields.some(field => Boolean(errors[String(field)]));
},
[stepFields, setTouchedField, validate, errors]
);
const steps: FormStep[] = [
{
@ -141,7 +171,7 @@ export function SignupForm({
address={values.address}
errors={errors}
touched={touched}
onAddressChange={(address) => setValue("address", address)}
onAddressChange={address => setValue("address", address)}
setTouchedField={setTouchedField}
/>
),
@ -163,9 +193,10 @@ export function SignupForm({
];
const currentStepFields = stepFields[currentStepIndex as keyof typeof stepFields] ?? [];
const canProceed = currentStepIndex === steps.length - 1
? true
: currentStepFields.every(field => !errors[String(field)]);
const canProceed =
currentStepIndex === steps.length - 1
? true
: currentStepFields.every(field => !errors[String(field)]);
return (
<div className={`w-full max-w-2xl mx-auto ${className}`}>
@ -200,11 +231,7 @@ export function SignupForm({
canProceed={canProceed}
/>
{error && (
<ErrorMessage className="mt-4 text-center">
{error}
</ErrorMessage>
)}
{error && <ErrorMessage className="mt-4 text-center">{error}</ErrorMessage>}
{showLoginLink && (
<div className="mt-6 text-center">

View File

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

View File

@ -5,7 +5,7 @@
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import { apiClient } from "@/lib/api";
import { apiClient, getNullableData } from "@/lib/api";
import { getErrorInfo, handleAuthError } from "@/lib/utils/error-handling";
import logger from "@customer-portal/logging";
import type {
@ -44,7 +44,9 @@ interface AuthState {
resetPassword: (token: string, password: string) => Promise<void>;
changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
checkPasswordNeeded: (email: string) => Promise<{ needsPasswordSet: boolean }>;
linkWhmcs: (request: LinkWhmcsRequestData) => Promise<{ needsPasswordSet: boolean; email: string }>;
linkWhmcs: (
request: LinkWhmcsRequestData
) => Promise<{ needsPasswordSet: boolean; email: string }>;
setPassword: (email: string, password: string) => Promise<void>;
refreshUser: () => Promise<void>;
refreshTokens: () => Promise<void>;
@ -72,10 +74,10 @@ export const useAuthStore = create<AuthState>()(
set({ loading: true, error: null });
try {
// Use shared API client with consistent configuration
const response = await apiClient.POST('/auth/login', { body: credentials });
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');
throw new Error(parsed.error.issues?.[0]?.message ?? "Login failed");
}
const { user, tokens } = parsed.data;
@ -100,10 +102,10 @@ export const useAuthStore = create<AuthState>()(
signup: async (data: SignupRequest) => {
set({ loading: true, error: null });
try {
const response = await apiClient.POST('/auth/signup', { body: data });
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');
throw new Error(parsed.error.issues?.[0]?.message ?? "Signup failed");
}
const { user, tokens } = parsed.data;
@ -118,7 +120,7 @@ export const useAuthStore = create<AuthState>()(
} catch (error) {
set({
loading: false,
error: error instanceof Error ? error.message : 'Signup failed',
error: error instanceof Error ? error.message : "Signup failed",
});
throw error;
}
@ -126,16 +128,19 @@ export const useAuthStore = create<AuthState>()(
logout: async () => {
const { tokens } = get();
try {
if (tokens?.accessToken) {
await apiClient.POST('/auth/logout', {
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');
logger.warn(
{ error: error instanceof Error ? error.message : String(error) },
"Logout API call failed"
);
}
set({
@ -149,19 +154,19 @@ export const useAuthStore = create<AuthState>()(
requestPasswordReset: async (email: string) => {
set({ loading: true, error: null });
try {
const response = await apiClient.POST('/auth/request-password-reset', {
body: { email }
const response = await apiClient.POST("/auth/request-password-reset", {
body: { email },
});
if (!response.data) {
throw new Error('Password reset request failed');
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',
error: error instanceof Error ? error.message : "Password reset request failed",
});
throw error;
}
@ -170,12 +175,12 @@ export const useAuthStore = create<AuthState>()(
resetPassword: async (token: string, password: string) => {
set({ loading: true, error: null });
try {
const response = await apiClient.POST('/auth/reset-password', {
body: { token, password }
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');
throw new Error(parsed.error.issues?.[0]?.message ?? "Password reset failed");
}
const { user, tokens } = parsed.data;
@ -190,7 +195,7 @@ export const useAuthStore = create<AuthState>()(
} catch (error) {
set({
loading: false,
error: error instanceof Error ? error.message : 'Password reset failed',
error: error instanceof Error ? error.message : "Password reset failed",
});
throw error;
}
@ -198,24 +203,24 @@ export const useAuthStore = create<AuthState>()(
changePassword: async (currentPassword: string, newPassword: string) => {
const { tokens } = get();
if (!tokens?.accessToken) throw new Error('Not authenticated');
if (!tokens?.accessToken) throw new Error("Not authenticated");
set({ loading: true, error: null });
try {
const response = await apiClient.POST('/auth/change-password', {
const response = await apiClient.POST("/auth/change-password", {
...withAuthHeaders(tokens.accessToken),
body: { currentPassword, newPassword }
body: { currentPassword, newPassword },
});
if (!response.data) {
throw new Error('Password change failed');
throw new Error("Password change failed");
}
set({ loading: false });
} catch (error) {
set({
loading: false,
error: error instanceof Error ? error.message : 'Password change failed',
error: error instanceof Error ? error.message : "Password change failed",
});
throw error;
}
@ -224,12 +229,12 @@ export const useAuthStore = create<AuthState>()(
checkPasswordNeeded: async (email: string) => {
set({ loading: true, error: null });
try {
const response = await apiClient.POST('/auth/check-password-needed', {
body: { email }
const response = await apiClient.POST("/auth/check-password-needed", {
body: { email },
});
if (!response.data) {
throw new Error('Check failed');
throw new Error("Check failed");
}
set({ loading: false });
@ -237,7 +242,7 @@ export const useAuthStore = create<AuthState>()(
} catch (error) {
set({
loading: false,
error: error instanceof Error ? error.message : 'Check failed',
error: error instanceof Error ? error.message : "Check failed",
});
throw error;
}
@ -246,12 +251,12 @@ export const useAuthStore = create<AuthState>()(
linkWhmcs: async ({ email, password }: LinkWhmcsRequestData) => {
set({ loading: true, error: null });
try {
const response = await apiClient.POST('/auth/link-whmcs', {
body: { email, password }
const response = await apiClient.POST("/auth/link-whmcs", {
body: { email, password },
});
if (!response.data) {
throw new Error('WHMCS link failed');
throw new Error("WHMCS link failed");
}
set({ loading: false });
@ -260,7 +265,7 @@ export const useAuthStore = create<AuthState>()(
} catch (error) {
set({
loading: false,
error: error instanceof Error ? error.message : 'WHMCS link failed',
error: error instanceof Error ? error.message : "WHMCS link failed",
});
throw error;
}
@ -269,12 +274,12 @@ export const useAuthStore = create<AuthState>()(
setPassword: async (email: string, password: string) => {
set({ loading: true, error: null });
try {
const response = await apiClient.POST('/auth/set-password', {
body: { email, password }
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');
throw new Error(parsed.error.issues?.[0]?.message ?? "Set password failed");
}
const { user, tokens } = parsed.data;
@ -289,7 +294,7 @@ export const useAuthStore = create<AuthState>()(
} catch (error) {
set({
loading: false,
error: error instanceof Error ? error.message : 'Set password failed',
error: error instanceof Error ? error.message : "Set password failed",
});
throw error;
}
@ -300,17 +305,18 @@ export const useAuthStore = create<AuthState>()(
if (!tokens?.accessToken) return;
try {
const response = await apiClient.GET('/me', {
const response = await apiClient.GET<UserProfile>("/me", {
...withAuthHeaders(tokens.accessToken),
});
if (!response.data) {
const profile = getNullableData<UserProfile>(response);
if (!profile) {
// Token might be expired, try to refresh
await get().refreshTokens();
return;
}
set({ user: response.data });
set({ user: profile });
} catch (error) {
// Token might be expired, try to refresh
handleAuthError(error, get().logout);
@ -327,16 +333,16 @@ export const useAuthStore = create<AuthState>()(
}
try {
const response = await apiClient.POST('/auth/refresh', {
const response = await apiClient.POST("/auth/refresh", {
body: {
refreshToken: tokens.refreshToken,
deviceId: localStorage.getItem('deviceId') || undefined,
}
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');
throw new Error(parsed.error.issues?.[0]?.message ?? "Token refresh failed");
}
const { tokens: newTokens } = parsed.data;
@ -349,9 +355,9 @@ export const useAuthStore = create<AuthState>()(
checkAuth: async () => {
const { tokens, isAuthenticated } = get();
set({ hasCheckedAuth: true });
if (!isAuthenticated || !tokens) {
return;
}
@ -360,7 +366,7 @@ export const useAuthStore = create<AuthState>()(
const expiryTime = new Date(tokens.expiresAt).getTime();
const now = Date.now();
const fiveMinutes = 5 * 60 * 1000;
if (expiryTime - now < fiveMinutes) {
await get().refreshTokens();
}
@ -381,9 +387,9 @@ export const useAuthStore = create<AuthState>()(
},
}),
{
name: 'auth-store',
name: "auth-store",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
partialize: state => ({
user: state.user,
tokens: state.tokens,
isAuthenticated: state.isAuthenticated,
@ -402,9 +408,7 @@ export const useAuthSession = () => {
const isAuthenticated = useAuthStore(selectIsAuthenticated);
const user = useAuthStore(selectAuthUser);
const hasValidToken = Boolean(
tokens?.accessToken &&
tokens?.expiresAt &&
new Date(tokens.expiresAt).getTime() > Date.now()
tokens?.accessToken && tokens?.expiresAt && new Date(tokens.expiresAt).getTime() > Date.now()
);
return {

View File

@ -3,4 +3,9 @@
* Centralized exports for authentication services
*/
export { useAuthStore, selectAuthTokens, selectIsAuthenticated, selectAuthUser } from "./auth.store";
export {
useAuthStore,
selectAuthTokens,
selectIsAuthenticated,
selectAuthUser,
} from "./auth.store";

View File

@ -6,5 +6,3 @@ export function getPostLoginRedirect(searchParams: ReadonlyURLSearchParams): str
if (dest.startsWith("http://") || dest.startsWith("https://")) return "/";
return dest;
}

View File

@ -7,7 +7,7 @@ export function ForgotPasswordView() {
return (
<AuthLayout
title="Forgot password"
subtitle="Enter your email address and we&apos;ll send you a reset link"
subtitle="Enter your email address and we'll send you a reset link"
>
<PasswordResetForm mode="request" />
</AuthLayout>

View File

@ -27,8 +27,8 @@ export function LinkWhmcsView() {
</div>
<div className="ml-3 text-sm text-blue-700 space-y-2">
<p>
We&apos;ve upgraded our customer portal. Use your existing Assist Solutions credentials
to transfer your account and gain access to the new experience.
We&apos;ve upgraded our customer portal. Use your existing Assist Solutions
credentials to transfer your account and gain access to the new experience.
</p>
<ul className="list-disc list-inside space-y-1">
<li>All of your services and billing history will come with you</li>

View File

@ -12,10 +12,11 @@ function ResetPasswordContent() {
if (!token) {
return (
<AuthLayout title="Reset your password" subtitle="We couldn&apos;t validate your reset link">
<AuthLayout title="Reset your password" subtitle="We couldn't validate your reset link">
<div className="space-y-4">
<p className="text-sm text-red-600">
The password reset link is missing or has expired. Please request a new link to continue.
The password reset link is missing or has expired. Please request a new link to
continue.
</p>
<Link
href="/auth/forgot-password"

View File

@ -0,0 +1 @@
export { BillingStatusBadge } from "./BillingStatusBadge";

View File

@ -16,6 +16,10 @@ import type {
InvoiceSsoLink,
PaymentMethodList,
} from "@customer-portal/domain";
import {
invoiceListSchema,
invoiceSchema as sharedInvoiceSchema,
} from "@customer-portal/domain/validation/shared/entities";
const emptyInvoiceList: InvoiceList = {
invoices: [],
@ -50,7 +54,6 @@ type PaymentMethodsQueryOptions = Omit<
"queryKey" | "queryFn"
>;
type SsoLinkMutationOptions = UseMutationOptions<
InvoiceSsoLink,
Error,
@ -58,13 +61,18 @@ type SsoLinkMutationOptions = UseMutationOptions<
>;
async function fetchInvoices(params?: InvoiceQueryParams): Promise<InvoiceList> {
const response = await apiClient.GET("/api/invoices", params ? { params: { query: params } } : undefined);
return getDataOrDefault(response, emptyInvoiceList);
const response = await apiClient.GET(
"/api/invoices",
params ? { params: { query: params } } : undefined
);
const data = getDataOrDefault(response, emptyInvoiceList);
return invoiceListSchema.parse(data);
}
async function fetchInvoice(id: string): Promise<Invoice> {
const response = await apiClient.GET("/api/invoices/{id}", { params: { path: { id } } });
return getDataOrThrow(response, "Invoice not found");
const invoice = getDataOrThrow(response, "Invoice not found");
return sharedInvoiceSchema.parse(invoice);
}
async function fetchPaymentMethods(): Promise<PaymentMethodList> {
@ -105,7 +113,6 @@ export function usePaymentMethods(
});
}
export function useCreateInvoiceSsoLink(
options?: SsoLinkMutationOptions
): UseMutationResult<
@ -115,13 +122,13 @@ export function useCreateInvoiceSsoLink(
> {
return useMutation({
mutationFn: async ({ invoiceId, target }) => {
const response = await apiClient.POST("/api/invoices/{id}/sso-link", {
const response = await apiClient.POST<InvoiceSsoLink>("/api/invoices/{id}/sso-link", {
params: {
path: { id: invoiceId },
query: target ? { target } : undefined,
},
});
return getDataOrThrow(response, "Failed to create SSO link");
return getDataOrThrow<InvoiceSsoLink>(response, "Failed to create SSO link");
},
...options,
});

View File

@ -1,3 +1 @@
export * from "./sso";

View File

@ -13,6 +13,7 @@ import { logger } from "@customer-portal/logging";
import { apiClient, getDataOrThrow } from "@/lib/api";
import { openSsoLink } from "@/features/billing/utils/sso";
import { useInvoice, useCreateInvoiceSsoLink } from "@/features/billing/hooks";
import type { InvoiceSsoLink } from "@customer-portal/domain";
import {
InvoiceHeader,
InvoiceItems,
@ -55,12 +56,12 @@ export function InvoiceDetailContainer() {
void (async () => {
setLoadingPaymentMethods(true);
try {
const response = await apiClient.POST('/api/auth/sso-link', {
const response = await apiClient.POST<InvoiceSsoLink>("/api/auth/sso-link", {
body: { path: "index.php?rp=/account/paymentmethods" },
});
const sso = getDataOrThrow<{ url: string }>(
const sso = getDataOrThrow<InvoiceSsoLink>(
response,
'Failed to create payment methods SSO link'
"Failed to create payment methods SSO link"
);
openSsoLink(sso.url, { newTab: true });
} catch (err) {

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