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:
parent
b9c24b6dc5
commit
ec69e3dcbb
@ -6,7 +6,7 @@ import { Logger } from "nestjs-pino";
|
|||||||
import helmet from "helmet";
|
import helmet from "helmet";
|
||||||
import cookieParser from "cookie-parser";
|
import cookieParser from "cookie-parser";
|
||||||
import * as express from "express";
|
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 */
|
/* eslint-disable @typescript-eslint/no-namespace */
|
||||||
declare global {
|
declare global {
|
||||||
@ -79,7 +79,7 @@ export async function bootstrap(): Promise<INestApplication> {
|
|||||||
secure: configService.get("NODE_ENV") === "production",
|
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.setSecureCookie = (name: string, value: string, options: CookieOptions = {}) => {
|
||||||
res.cookie(name, value, { ...secureCookieDefaults, ...options });
|
res.cookie(name, value, { ...secureCookieDefaults, ...options });
|
||||||
};
|
};
|
||||||
@ -134,7 +134,7 @@ export async function bootstrap(): Promise<INestApplication> {
|
|||||||
// Global exception filters
|
// Global exception filters
|
||||||
app.useGlobalFilters(
|
app.useGlobalFilters(
|
||||||
new AuthErrorFilter(app.get(Logger)), // Handle auth errors first
|
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
|
// Global authentication guard will be registered via APP_GUARD provider in AuthModule
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import { FreebitOperationsService } from "./services/freebit-operations.service"
|
|||||||
FreebitMapperService,
|
FreebitMapperService,
|
||||||
FreebitOperationsService,
|
FreebitOperationsService,
|
||||||
FreebitOrchestratorService,
|
FreebitOrchestratorService,
|
||||||
|
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
// Export orchestrator in case other services need direct access
|
// Export orchestrator in case other services need direct access
|
||||||
|
|||||||
@ -27,20 +27,33 @@ export interface FreebitAccountDetail {
|
|||||||
kind: "MASTER" | "MVNO";
|
kind: "MASTER" | "MVNO";
|
||||||
account: string | number;
|
account: string | number;
|
||||||
state: "active" | "suspended" | "temporary" | "waiting" | "obsolete";
|
state: "active" | "suspended" | "temporary" | "waiting" | "obsolete";
|
||||||
|
status?: "active" | "suspended" | "temporary" | "waiting" | "obsolete";
|
||||||
startDate?: string | number;
|
startDate?: string | number;
|
||||||
relationCode?: string;
|
relationCode?: string;
|
||||||
resultCode?: string | number;
|
resultCode?: string | number;
|
||||||
planCode?: string;
|
planCode?: string;
|
||||||
|
planName?: string;
|
||||||
iccid?: string | number;
|
iccid?: string | number;
|
||||||
imsi?: string | number;
|
imsi?: string | number;
|
||||||
eid?: string;
|
eid?: string;
|
||||||
contractLine?: string;
|
contractLine?: string;
|
||||||
size?: "standard" | "nano" | "micro" | "esim";
|
size?: "standard" | "nano" | "micro" | "esim";
|
||||||
|
simSize?: "standard" | "nano" | "micro" | "esim";
|
||||||
|
msisdn?: string | number;
|
||||||
sms?: number; // 10=active, 20=inactive
|
sms?: number; // 10=active, 20=inactive
|
||||||
talk?: number; // 10=active, 20=inactive
|
talk?: number; // 10=active, 20=inactive
|
||||||
ipv4?: string;
|
ipv4?: string;
|
||||||
ipv6?: string;
|
ipv6?: string;
|
||||||
quota?: number; // Remaining quota
|
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 };
|
async?: { func: string; date: string | number };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,10 +2,10 @@ import { Injectable, Inject, InternalServerErrorException } from "@nestjs/common
|
|||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
import type {
|
import type {
|
||||||
FreebitConfig,
|
FreebitConfig,
|
||||||
FreebitAuthRequest,
|
FreebitAuthRequest,
|
||||||
FreebitAuthResponse
|
FreebitAuthResponse,
|
||||||
} from "../interfaces/freebit.types";
|
} from "../interfaces/freebit.types";
|
||||||
import { FreebitError } from "./freebit-error.service";
|
import { FreebitError } from "./freebit-error.service";
|
||||||
|
|
||||||
|
|||||||
@ -22,13 +22,13 @@ export class FreebitClientService {
|
|||||||
/**
|
/**
|
||||||
* Make an authenticated request to Freebit API with retry logic
|
* Make an authenticated request to Freebit API with retry logic
|
||||||
*/
|
*/
|
||||||
async makeAuthenticatedRequest<
|
async makeAuthenticatedRequest<TResponse extends FreebitResponseBase, TPayload extends object>(
|
||||||
TResponse extends FreebitResponseBase,
|
endpoint: string,
|
||||||
TPayload extends object,
|
payload: TPayload
|
||||||
>(endpoint: string, payload: TPayload): Promise<TResponse> {
|
): Promise<TResponse> {
|
||||||
const authKey = await this.authService.getAuthKey();
|
const authKey = await this.authService.getAuthKey();
|
||||||
const config = this.authService.getConfig();
|
const config = this.authService.getConfig();
|
||||||
|
|
||||||
const requestPayload = { ...payload, authKey };
|
const requestPayload = { ...payload, authKey };
|
||||||
const url = `${config.baseUrl}${endpoint}`;
|
const url = `${config.baseUrl}${endpoint}`;
|
||||||
|
|
||||||
@ -176,10 +176,13 @@ export class FreebitClientService {
|
|||||||
|
|
||||||
if (attempt === config.retryAttempts) {
|
if (attempt === config.retryAttempts) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
this.logger.error(`Freebit JSON API request failed after ${config.retryAttempts} attempts`, {
|
this.logger.error(
|
||||||
url,
|
`Freebit JSON API request failed after ${config.retryAttempts} attempts`,
|
||||||
error: message,
|
{
|
||||||
});
|
url,
|
||||||
|
error: message,
|
||||||
|
}
|
||||||
|
);
|
||||||
throw new FreebitError(`Request failed: ${message}`);
|
throw new FreebitError(`Request failed: ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -213,7 +216,7 @@ export class FreebitClientService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
||||||
return response.ok;
|
return response.ok;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.debug("Simple request failed", {
|
this.logger.debug("Simple request failed", {
|
||||||
|
|||||||
@ -68,19 +68,19 @@ export class FreebitError extends Error {
|
|||||||
if (this.isAuthError()) {
|
if (this.isAuthError()) {
|
||||||
return "SIM service is temporarily unavailable. Please try again later.";
|
return "SIM service is temporarily unavailable. Please try again later.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isRateLimitError()) {
|
if (this.isRateLimitError()) {
|
||||||
return "Service is busy. Please wait a moment and try again.";
|
return "Service is busy. Please wait a moment and try again.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.message.toLowerCase().includes("account not found")) {
|
if (this.message.toLowerCase().includes("account not found")) {
|
||||||
return "SIM account not found. Please contact support to verify your SIM configuration.";
|
return "SIM account not found. Please contact support to verify your SIM configuration.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.message.toLowerCase().includes("timeout")) {
|
if (this.message.toLowerCase().includes("timeout")) {
|
||||||
return "SIM service request timed out. Please try again.";
|
return "SIM service request timed out. Please try again.";
|
||||||
}
|
}
|
||||||
|
|
||||||
return "SIM operation failed. Please try again or contact support.";
|
return "SIM operation failed. Please try again or contact support.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,7 +42,7 @@ export class FreebitMapperService {
|
|||||||
if (account.eid) {
|
if (account.eid) {
|
||||||
simType = "esim";
|
simType = "esim";
|
||||||
} else if (account.simSize) {
|
} else if (account.simSize) {
|
||||||
simType = account.simSize as "standard" | "nano" | "micro" | "esim";
|
simType = account.simSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -75,15 +75,11 @@ export class FreebitMapperService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const todayUsageKb = parseInt(response.traffic.today, 10) || 0;
|
const todayUsageKb = parseInt(response.traffic.today, 10) || 0;
|
||||||
const recentDaysData = response.traffic.inRecentDays
|
const recentDaysData = response.traffic.inRecentDays.split(",").map((usage, index) => ({
|
||||||
.split(",")
|
date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000).toISOString().split("T")[0],
|
||||||
.map((usage, index) => ({
|
usageKb: parseInt(usage, 10) || 0,
|
||||||
date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000)
|
usageMb: Math.round(((parseInt(usage, 10) || 0) / 1024) * 100) / 100,
|
||||||
.toISOString()
|
}));
|
||||||
.split("T")[0],
|
|
||||||
usageKb: parseInt(usage, 10) || 0,
|
|
||||||
usageMb: Math.round(((parseInt(usage, 10) || 0) / 1024) * 100) / 100,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
account: String(response.account ?? ""),
|
account: String(response.account ?? ""),
|
||||||
@ -106,7 +102,7 @@ export class FreebitMapperService {
|
|||||||
account,
|
account,
|
||||||
totalAdditions: Number(response.total) || 0,
|
totalAdditions: Number(response.total) || 0,
|
||||||
additionCount: Number(response.count) || 0,
|
additionCount: Number(response.count) || 0,
|
||||||
history: response.quotaHistory.map((item) => ({
|
history: response.quotaHistory.map(item => ({
|
||||||
quotaKb: parseInt(item.quota, 10),
|
quotaKb: parseInt(item.quota, 10),
|
||||||
quotaMb: Math.round((parseInt(item.quota, 10) / 1024) * 100) / 100,
|
quotaMb: Math.round((parseInt(item.quota, 10) / 1024) * 100) / 100,
|
||||||
addedDate: item.date,
|
addedDate: item.date,
|
||||||
@ -149,11 +145,11 @@ export class FreebitMapperService {
|
|||||||
if (!/^\d{8}$/.test(dateString)) {
|
if (!/^\d{8}$/.test(dateString)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const year = parseInt(dateString.substring(0, 4), 10);
|
const year = parseInt(dateString.substring(0, 4), 10);
|
||||||
const month = parseInt(dateString.substring(4, 6), 10) - 1; // Month is 0-indexed
|
const month = parseInt(dateString.substring(4, 6), 10) - 1; // Month is 0-indexed
|
||||||
const day = parseInt(dateString.substring(6, 8), 10);
|
const day = parseInt(dateString.substring(6, 8), 10);
|
||||||
|
|
||||||
return new Date(year, month, day);
|
return new Date(year, month, day);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -74,7 +74,7 @@ export class FreebitOperationsService {
|
|||||||
|
|
||||||
let response: FreebitAccountDetailsResponse | undefined;
|
let response: FreebitAccountDetailsResponse | undefined;
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
|
|
||||||
for (const ep of candidates) {
|
for (const ep of candidates) {
|
||||||
try {
|
try {
|
||||||
if (ep !== candidates[0]) {
|
if (ep !== candidates[0]) {
|
||||||
@ -92,7 +92,7 @@ export class FreebitOperationsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response) {
|
if (!response) {
|
||||||
if (lastError instanceof Error) {
|
if (lastError instanceof Error) {
|
||||||
throw lastError;
|
throw lastError;
|
||||||
@ -189,10 +189,10 @@ export class FreebitOperationsService {
|
|||||||
toDate: string
|
toDate: string
|
||||||
): Promise<SimTopUpHistory> {
|
): Promise<SimTopUpHistory> {
|
||||||
try {
|
try {
|
||||||
const request: Omit<FreebitQuotaHistoryRequest, "authKey"> = {
|
const request: Omit<FreebitQuotaHistoryRequest, "authKey"> = {
|
||||||
account,
|
account,
|
||||||
fromDate,
|
fromDate,
|
||||||
toDate
|
toDate,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await this.client.makeAuthenticatedRequest<
|
const response = await this.client.makeAuthenticatedRequest<
|
||||||
@ -240,7 +240,7 @@ export class FreebitOperationsService {
|
|||||||
assignGlobalIp: options.assignGlobalIp,
|
assignGlobalIp: options.assignGlobalIp,
|
||||||
scheduled: !!options.scheduledAt,
|
scheduled: !!options.scheduledAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ipv4: response.ipv4,
|
ipv4: response.ipv4,
|
||||||
ipv6: response.ipv6,
|
ipv6: response.ipv6,
|
||||||
@ -289,7 +289,7 @@ export class FreebitOperationsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.client.makeAuthenticatedRequest<FreebitAddSpecResponse, typeof request>(
|
await this.client.makeAuthenticatedRequest<FreebitAddSpecResponse, typeof request>(
|
||||||
"/master/addSpec/",
|
"/master/addSpec/",
|
||||||
request
|
request
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -316,9 +316,9 @@ export class FreebitOperationsService {
|
|||||||
*/
|
*/
|
||||||
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
|
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const request: Omit<FreebitCancelPlanRequest, "authKey"> = {
|
const request: Omit<FreebitCancelPlanRequest, "authKey"> = {
|
||||||
account,
|
account,
|
||||||
runTime: scheduledAt
|
runTime: scheduledAt,
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.client.makeAuthenticatedRequest<FreebitCancelPlanResponse, typeof request>(
|
await this.client.makeAuthenticatedRequest<FreebitCancelPlanResponse, typeof request>(
|
||||||
@ -326,9 +326,9 @@ export class FreebitOperationsService {
|
|||||||
request
|
request
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`Successfully cancelled SIM for account ${account}`, {
|
this.logger.log(`Successfully cancelled SIM for account ${account}`, {
|
||||||
account,
|
account,
|
||||||
runTime: scheduledAt
|
runTime: scheduledAt,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
@ -425,7 +425,16 @@ export class FreebitOperationsService {
|
|||||||
birthday?: string;
|
birthday?: string;
|
||||||
};
|
};
|
||||||
}): Promise<void> {
|
}): 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) {
|
if (!account || !eid) {
|
||||||
throw new BadRequestException("activateEsimAccountNew requires account and eid");
|
throw new BadRequestException("activateEsimAccountNew requires account and eid");
|
||||||
@ -450,10 +459,7 @@ export class FreebitOperationsService {
|
|||||||
await this.client.makeAuthenticatedJsonRequest<
|
await this.client.makeAuthenticatedJsonRequest<
|
||||||
FreebitEsimAccountActivationResponse,
|
FreebitEsimAccountActivationResponse,
|
||||||
FreebitEsimAccountActivationRequest
|
FreebitEsimAccountActivationRequest
|
||||||
>(
|
>("/mvno/esim/addAcct/", payload);
|
||||||
"/mvno/esim/addAcct/",
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log("Successfully activated new eSIM account via PA05-41", {
|
this.logger.log("Successfully activated new eSIM account via PA05-41", {
|
||||||
account,
|
account,
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// Export all Freebit services
|
// Export all Freebit services
|
||||||
export { FreebitOrchestratorService } from './freebit-orchestrator.service';
|
export { FreebitOrchestratorService } from "./freebit-orchestrator.service";
|
||||||
export { FreebitMapperService } from './freebit-mapper.service';
|
export { FreebitMapperService } from "./freebit-mapper.service";
|
||||||
export { FreebitOperationsService } from './freebit-operations.service';
|
export { FreebitOperationsService } from "./freebit-operations.service";
|
||||||
|
|||||||
@ -188,7 +188,9 @@ export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy
|
|||||||
const errorData = data as SalesforcePubSubError;
|
const errorData = data as SalesforcePubSubError;
|
||||||
const details = errorData.details || "";
|
const details = errorData.details || "";
|
||||||
const metadata = errorData.metadata || {};
|
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 =>
|
const hasCorruptionCode = errorCodes.some(code =>
|
||||||
String(code).includes("replayid.corrupted")
|
String(code).includes("replayid.corrupted")
|
||||||
);
|
);
|
||||||
|
|||||||
@ -61,15 +61,15 @@ export class WhmcsConfigService {
|
|||||||
* Validate that required configuration is present
|
* Validate that required configuration is present
|
||||||
*/
|
*/
|
||||||
validateConfig(): void {
|
validateConfig(): void {
|
||||||
const required = ['baseUrl', 'identifier', 'secret'];
|
const required = ["baseUrl", "identifier", "secret"];
|
||||||
const missing = required.filter(key => !this.config[key as keyof WhmcsApiConfig]);
|
const missing = required.filter(key => !this.config[key as keyof WhmcsApiConfig]);
|
||||||
|
|
||||||
if (missing.length > 0) {
|
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')) {
|
if (!this.config.baseUrl.startsWith("http")) {
|
||||||
throw new Error('WHMCS baseUrl must start with http:// or https://');
|
throw new Error("WHMCS baseUrl must start with http:// or https://");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,21 +81,15 @@ export class WhmcsConfigService {
|
|||||||
const isDev = nodeEnv !== "production";
|
const isDev = nodeEnv !== "production";
|
||||||
|
|
||||||
// Resolve and normalize base URL (trim trailing slashes)
|
// Resolve and normalize base URL (trim trailing slashes)
|
||||||
const rawBaseUrl = this.getFirst([
|
const rawBaseUrl =
|
||||||
isDev ? "WHMCS_DEV_BASE_URL" : undefined,
|
this.getFirst([isDev ? "WHMCS_DEV_BASE_URL" : undefined, "WHMCS_BASE_URL"]) || "";
|
||||||
"WHMCS_BASE_URL"
|
|
||||||
]) || "";
|
|
||||||
const baseUrl = rawBaseUrl.replace(/\/+$/, "");
|
const baseUrl = rawBaseUrl.replace(/\/+$/, "");
|
||||||
|
|
||||||
const identifier = this.getFirst([
|
const identifier =
|
||||||
isDev ? "WHMCS_DEV_API_IDENTIFIER" : undefined,
|
this.getFirst([isDev ? "WHMCS_DEV_API_IDENTIFIER" : undefined, "WHMCS_API_IDENTIFIER"]) || "";
|
||||||
"WHMCS_API_IDENTIFIER"
|
|
||||||
]) || "";
|
|
||||||
|
|
||||||
const secret = this.getFirst([
|
const secret =
|
||||||
isDev ? "WHMCS_DEV_API_SECRET" : undefined,
|
this.getFirst([isDev ? "WHMCS_DEV_API_SECRET" : undefined, "WHMCS_API_SECRET"]) || "";
|
||||||
"WHMCS_API_SECRET"
|
|
||||||
]) || "";
|
|
||||||
|
|
||||||
const adminUsername = this.getFirst([
|
const adminUsername = this.getFirst([
|
||||||
isDev ? "WHMCS_DEV_ADMIN_USERNAME" : undefined,
|
isDev ? "WHMCS_DEV_ADMIN_USERNAME" : undefined,
|
||||||
@ -127,10 +121,7 @@ export class WhmcsConfigService {
|
|||||||
const nodeEnv = this.configService.get<string>("NODE_ENV", "development");
|
const nodeEnv = this.configService.get<string>("NODE_ENV", "development");
|
||||||
const isDev = nodeEnv !== "production";
|
const isDev = nodeEnv !== "production";
|
||||||
|
|
||||||
return this.getFirst([
|
return this.getFirst([isDev ? "WHMCS_DEV_API_ACCESS_KEY" : undefined, "WHMCS_API_ACCESS_KEY"]);
|
||||||
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 {
|
private getNumberConfig(key: string, defaultValue: number): number {
|
||||||
const value = this.configService.get<string>(key);
|
const value = this.configService.get<string>(key);
|
||||||
if (!value) return defaultValue;
|
if (!value) return defaultValue;
|
||||||
|
|
||||||
const parsed = parseInt(value, 10);
|
const parsed = parseInt(value, 10);
|
||||||
return isNaN(parsed) ? defaultValue : parsed;
|
return isNaN(parsed) ? defaultValue : parsed;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -116,7 +116,6 @@ export class WhmcsApiMethodsService {
|
|||||||
return this.makeRequest("GetInvoice", { invoiceid: invoiceId });
|
return this.makeRequest("GetInvoice", { invoiceid: invoiceId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// PRODUCT/SUBSCRIPTION API METHODS
|
// PRODUCT/SUBSCRIPTION API METHODS
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@ -137,7 +136,6 @@ export class WhmcsApiMethodsService {
|
|||||||
return this.makeRequest("GetPayMethods", params);
|
return this.makeRequest("GetPayMethods", params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async getPaymentGateways(): Promise<WhmcsPaymentGatewaysResponse> {
|
async getPaymentGateways(): Promise<WhmcsPaymentGatewaysResponse> {
|
||||||
return this.makeRequest("GetPaymentMethods", {});
|
return this.makeRequest("GetPaymentMethods", {});
|
||||||
}
|
}
|
||||||
@ -176,11 +174,11 @@ export class WhmcsApiMethodsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.makeRequest<{ result: string }>(
|
return this.makeRequest<{ result: string }>(
|
||||||
"AcceptOrder",
|
"AcceptOrder",
|
||||||
{
|
{
|
||||||
orderid: orderId.toString(),
|
orderid: orderId.toString(),
|
||||||
autosetup: true,
|
autosetup: true,
|
||||||
sendemail: false
|
sendemail: false,
|
||||||
},
|
},
|
||||||
{ useAdminAuth: true }
|
{ useAdminAuth: true }
|
||||||
);
|
);
|
||||||
@ -192,13 +190,12 @@ export class WhmcsApiMethodsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.makeRequest<{ result: string }>(
|
return this.makeRequest<{ result: string }>(
|
||||||
"CancelOrder",
|
"CancelOrder",
|
||||||
{ orderid: orderId },
|
{ orderid: orderId },
|
||||||
{ useAdminAuth: true }
|
{ useAdminAuth: true }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// SSO API METHODS
|
// SSO API METHODS
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@ -207,7 +204,6 @@ export class WhmcsApiMethodsService {
|
|||||||
return this.makeRequest("CreateSsoToken", params);
|
return this.makeRequest("CreateSsoToken", params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async getProducts() {
|
async getProducts() {
|
||||||
return this.makeRequest("GetProducts", {});
|
return this.makeRequest("GetProducts", {});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,8 +5,8 @@ import { WhmcsConfigService } from "../config/whmcs-config.service";
|
|||||||
import { WhmcsHttpClientService } from "./whmcs-http-client.service";
|
import { WhmcsHttpClientService } from "./whmcs-http-client.service";
|
||||||
import { WhmcsErrorHandlerService } from "./whmcs-error-handler.service";
|
import { WhmcsErrorHandlerService } from "./whmcs-error-handler.service";
|
||||||
import { WhmcsApiMethodsService } from "./whmcs-api-methods.service";
|
import { WhmcsApiMethodsService } from "./whmcs-api-methods.service";
|
||||||
import type {
|
import type {
|
||||||
WhmcsApiResponse,
|
WhmcsApiResponse,
|
||||||
WhmcsErrorResponse,
|
WhmcsErrorResponse,
|
||||||
WhmcsAddClientParams,
|
WhmcsAddClientParams,
|
||||||
WhmcsValidateLoginParams,
|
WhmcsValidateLoginParams,
|
||||||
@ -18,10 +18,7 @@ import type {
|
|||||||
WhmcsUpdateInvoiceParams,
|
WhmcsUpdateInvoiceParams,
|
||||||
WhmcsCapturePaymentParams,
|
WhmcsCapturePaymentParams,
|
||||||
} from "../../types/whmcs-api.types";
|
} from "../../types/whmcs-api.types";
|
||||||
import type {
|
import type { WhmcsRequestOptions, WhmcsConnectionStats } from "../types/connection.types";
|
||||||
WhmcsRequestOptions,
|
|
||||||
WhmcsConnectionStats
|
|
||||||
} from "../types/connection.types";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main orchestrator service for WHMCS connections
|
* Main orchestrator service for WHMCS connections
|
||||||
@ -41,7 +38,7 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit {
|
|||||||
try {
|
try {
|
||||||
// Validate configuration on startup
|
// Validate configuration on startup
|
||||||
this.configService.validateConfig();
|
this.configService.validateConfig();
|
||||||
|
|
||||||
// Test connection
|
// Test connection
|
||||||
const isAvailable = await this.apiMethods.isAvailable();
|
const isAvailable = await this.apiMethods.isAvailable();
|
||||||
if (isAvailable) {
|
if (isAvailable) {
|
||||||
@ -71,7 +68,7 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit {
|
|||||||
try {
|
try {
|
||||||
const config = this.configService.getConfig();
|
const config = this.configService.getConfig();
|
||||||
const response = await this.httpClient.makeRequest<T>(config, action, params, options);
|
const response = await this.httpClient.makeRequest<T>(config, action, params, options);
|
||||||
|
|
||||||
if (response.result === "error") {
|
if (response.result === "error") {
|
||||||
const errorResponse = response as WhmcsErrorResponse;
|
const errorResponse = response as WhmcsErrorResponse;
|
||||||
this.errorHandler.handleApiError(errorResponse, action, params);
|
this.errorHandler.handleApiError(errorResponse, action, params);
|
||||||
@ -180,7 +177,6 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit {
|
|||||||
return this.apiMethods.cancelOrder(orderId);
|
return this.apiMethods.cancelOrder(orderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// PRODUCT/SUBSCRIPTION API METHODS
|
// PRODUCT/SUBSCRIPTION API METHODS
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@ -193,7 +189,6 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit {
|
|||||||
return this.apiMethods.getCatalogProducts();
|
return this.apiMethods.getCatalogProducts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async getProducts() {
|
async getProducts() {
|
||||||
return this.apiMethods.getProducts();
|
return this.apiMethods.getProducts();
|
||||||
}
|
}
|
||||||
@ -206,7 +201,6 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit {
|
|||||||
return this.apiMethods.getPaymentMethods(params);
|
return this.apiMethods.getPaymentMethods(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async getPaymentGateways() {
|
async getPaymentGateways() {
|
||||||
return this.apiMethods.getPaymentGateways();
|
return this.apiMethods.getPaymentGateways();
|
||||||
}
|
}
|
||||||
@ -227,7 +221,6 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit {
|
|||||||
return this.configService.getBaseUrl();
|
return this.configService.getBaseUrl();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// UTILITY METHODS
|
// UTILITY METHODS
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@ -272,7 +265,9 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit {
|
|||||||
* Check if error is already a handled exception
|
* Check if error is already a handled exception
|
||||||
*/
|
*/
|
||||||
private isHandledException(error: unknown): boolean {
|
private isHandledException(error: unknown): boolean {
|
||||||
return error instanceof Error &&
|
return (
|
||||||
(error.name.includes('Exception') || error.message.includes('WHMCS'));
|
error instanceof Error &&
|
||||||
|
(error.name.includes("Exception") || error.message.includes("WHMCS"))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
import type { WhmcsErrorResponse } from "../../types/whmcs-api.types";
|
import type { WhmcsErrorResponse } from "../../types/whmcs-api.types";
|
||||||
|
|
||||||
@ -33,7 +38,7 @@ export class WhmcsErrorHandlerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generic WHMCS API error
|
// 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 {
|
private isNotFoundError(action: string, message: string): boolean {
|
||||||
const lowerMessage = message.toLowerCase();
|
const lowerMessage = message.toLowerCase();
|
||||||
|
|
||||||
// Client not found errors
|
// Client not found errors
|
||||||
if (action === "GetClientsDetails" && lowerMessage.includes("client not found")) {
|
if (action === "GetClientsDetails" && lowerMessage.includes("client not found")) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invoice not found errors
|
// Invoice not found errors
|
||||||
if ((action === "GetInvoice" || action === "UpdateInvoice") &&
|
if (
|
||||||
lowerMessage.includes("invoice not found")) {
|
(action === "GetInvoice" || action === "UpdateInvoice") &&
|
||||||
|
lowerMessage.includes("invoice not found")
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,12 +96,14 @@ export class WhmcsErrorHandlerService {
|
|||||||
*/
|
*/
|
||||||
private isAuthenticationError(message: string, errorCode?: string): boolean {
|
private isAuthenticationError(message: string, errorCode?: string): boolean {
|
||||||
const lowerMessage = message.toLowerCase();
|
const lowerMessage = message.toLowerCase();
|
||||||
|
|
||||||
return lowerMessage.includes("authentication") ||
|
return (
|
||||||
lowerMessage.includes("unauthorized") ||
|
lowerMessage.includes("authentication") ||
|
||||||
lowerMessage.includes("invalid credentials") ||
|
lowerMessage.includes("unauthorized") ||
|
||||||
lowerMessage.includes("access denied") ||
|
lowerMessage.includes("invalid credentials") ||
|
||||||
errorCode === "AUTHENTICATION_FAILED";
|
lowerMessage.includes("access denied") ||
|
||||||
|
errorCode === "AUTHENTICATION_FAILED"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -102,12 +111,14 @@ export class WhmcsErrorHandlerService {
|
|||||||
*/
|
*/
|
||||||
private isValidationError(message: string, errorCode?: string): boolean {
|
private isValidationError(message: string, errorCode?: string): boolean {
|
||||||
const lowerMessage = message.toLowerCase();
|
const lowerMessage = message.toLowerCase();
|
||||||
|
|
||||||
return lowerMessage.includes("required") ||
|
return (
|
||||||
lowerMessage.includes("invalid") ||
|
lowerMessage.includes("required") ||
|
||||||
lowerMessage.includes("missing") ||
|
lowerMessage.includes("invalid") ||
|
||||||
lowerMessage.includes("validation") ||
|
lowerMessage.includes("missing") ||
|
||||||
errorCode === "VALIDATION_ERROR";
|
lowerMessage.includes("validation") ||
|
||||||
|
errorCode === "VALIDATION_ERROR"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -125,21 +136,21 @@ export class WhmcsErrorHandlerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const clientIdParam = params["clientid"];
|
const clientIdParam = params["clientid"];
|
||||||
const identifier =
|
const identifier =
|
||||||
typeof clientIdParam === "string" || typeof clientIdParam === "number"
|
typeof clientIdParam === "string" || typeof clientIdParam === "number"
|
||||||
? clientIdParam
|
? clientIdParam
|
||||||
: "unknown";
|
: "unknown";
|
||||||
|
|
||||||
return new NotFoundException(`Client with ID ${identifier} not found`);
|
return new NotFoundException(`Client with ID ${identifier} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "GetInvoice" || action === "UpdateInvoice") {
|
if (action === "GetInvoice" || action === "UpdateInvoice") {
|
||||||
const invoiceIdParam = params["invoiceid"];
|
const invoiceIdParam = params["invoiceid"];
|
||||||
const identifier =
|
const identifier =
|
||||||
typeof invoiceIdParam === "string" || typeof invoiceIdParam === "number"
|
typeof invoiceIdParam === "string" || typeof invoiceIdParam === "number"
|
||||||
? invoiceIdParam
|
? invoiceIdParam
|
||||||
: "unknown";
|
: "unknown";
|
||||||
|
|
||||||
return new NotFoundException(`Invoice with ID ${identifier} not found`);
|
return new NotFoundException(`Invoice with ID ${identifier} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,9 +163,11 @@ export class WhmcsErrorHandlerService {
|
|||||||
*/
|
*/
|
||||||
private isTimeoutError(error: unknown): boolean {
|
private isTimeoutError(error: unknown): boolean {
|
||||||
const message = getErrorMessage(error).toLowerCase();
|
const message = getErrorMessage(error).toLowerCase();
|
||||||
return message.includes("timeout") ||
|
return (
|
||||||
message.includes("aborted") ||
|
message.includes("timeout") ||
|
||||||
(error instanceof Error && error.name === "AbortError");
|
message.includes("aborted") ||
|
||||||
|
(error instanceof Error && error.name === "AbortError")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -162,20 +175,24 @@ export class WhmcsErrorHandlerService {
|
|||||||
*/
|
*/
|
||||||
private isNetworkError(error: unknown): boolean {
|
private isNetworkError(error: unknown): boolean {
|
||||||
const message = getErrorMessage(error).toLowerCase();
|
const message = getErrorMessage(error).toLowerCase();
|
||||||
return message.includes("network") ||
|
return (
|
||||||
message.includes("connection") ||
|
message.includes("network") ||
|
||||||
message.includes("econnrefused") ||
|
message.includes("connection") ||
|
||||||
message.includes("enotfound") ||
|
message.includes("econnrefused") ||
|
||||||
message.includes("fetch");
|
message.includes("enotfound") ||
|
||||||
|
message.includes("fetch")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if error is already a known NestJS exception
|
* Check if error is already a known NestJS exception
|
||||||
*/
|
*/
|
||||||
private isKnownException(error: unknown): boolean {
|
private isKnownException(error: unknown): boolean {
|
||||||
return error instanceof NotFoundException ||
|
return (
|
||||||
error instanceof BadRequestException ||
|
error instanceof NotFoundException ||
|
||||||
error instanceof UnauthorizedException;
|
error instanceof BadRequestException ||
|
||||||
|
error instanceof UnauthorizedException
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,14 +1,11 @@
|
|||||||
import { Injectable, Inject } from "@nestjs/common";
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
import type {
|
import type { WhmcsApiResponse, WhmcsErrorResponse } from "../../types/whmcs-api.types";
|
||||||
WhmcsApiResponse,
|
import type {
|
||||||
WhmcsErrorResponse
|
WhmcsApiConfig,
|
||||||
} from "../../types/whmcs-api.types";
|
|
||||||
import type {
|
|
||||||
WhmcsApiConfig,
|
|
||||||
WhmcsRequestOptions,
|
WhmcsRequestOptions,
|
||||||
WhmcsConnectionStats
|
WhmcsConnectionStats,
|
||||||
} from "../types/connection.types";
|
} from "../types/connection.types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -41,22 +38,22 @@ export class WhmcsHttpClientService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.executeRequest<T>(config, action, params, options);
|
const response = await this.executeRequest<T>(config, action, params, options);
|
||||||
|
|
||||||
const responseTime = Date.now() - startTime;
|
const responseTime = Date.now() - startTime;
|
||||||
this.updateSuccessStats(responseTime);
|
this.updateSuccessStats(responseTime);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.stats.failedRequests++;
|
this.stats.failedRequests++;
|
||||||
this.stats.lastErrorTime = new Date();
|
this.stats.lastErrorTime = new Date();
|
||||||
|
|
||||||
this.logger.error(`WHMCS HTTP request failed [${action}]`, {
|
this.logger.error(`WHMCS HTTP request failed [${action}]`, {
|
||||||
error: getErrorMessage(error),
|
error: getErrorMessage(error),
|
||||||
action,
|
action,
|
||||||
params: this.sanitizeLogParams(params),
|
params: this.sanitizeLogParams(params),
|
||||||
responseTime: Date.now() - startTime,
|
responseTime: Date.now() - startTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -97,7 +94,7 @@ export class WhmcsHttpClientService {
|
|||||||
return await this.performSingleRequest<T>(config, action, params, options);
|
return await this.performSingleRequest<T>(config, action, params, options);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error as Error;
|
lastError = error as Error;
|
||||||
|
|
||||||
if (attempt === maxAttempts) {
|
if (attempt === maxAttempts) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -176,7 +173,7 @@ export class WhmcsHttpClientService {
|
|||||||
options: WhmcsRequestOptions
|
options: WhmcsRequestOptions
|
||||||
): string {
|
): string {
|
||||||
const formData = new URLSearchParams();
|
const formData = new URLSearchParams();
|
||||||
|
|
||||||
// Add authentication
|
// Add authentication
|
||||||
if (options.useAdminAuth && config.adminUsername && config.adminPasswordHash) {
|
if (options.useAdminAuth && config.adminUsername && config.adminPasswordHash) {
|
||||||
formData.append("username", config.adminUsername);
|
formData.append("username", config.adminUsername);
|
||||||
@ -223,7 +220,9 @@ export class WhmcsHttpClientService {
|
|||||||
|
|
||||||
if (data.result === "error") {
|
if (data.result === "error") {
|
||||||
const errorResponse = data as WhmcsErrorResponse;
|
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;
|
return data;
|
||||||
@ -234,19 +233,19 @@ export class WhmcsHttpClientService {
|
|||||||
*/
|
*/
|
||||||
private shouldNotRetry(error: unknown): boolean {
|
private shouldNotRetry(error: unknown): boolean {
|
||||||
const message = getErrorMessage(error).toLowerCase();
|
const message = getErrorMessage(error).toLowerCase();
|
||||||
|
|
||||||
// Don't retry authentication errors
|
// Don't retry authentication errors
|
||||||
if (message.includes('authentication') || message.includes('unauthorized')) {
|
if (message.includes("authentication") || message.includes("unauthorized")) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't retry validation errors
|
// Don't retry validation errors
|
||||||
if (message.includes('invalid') || message.includes('required')) {
|
if (message.includes("invalid") || message.includes("required")) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't retry not found errors
|
// Don't retry not found errors
|
||||||
if (message.includes('not found')) {
|
if (message.includes("not found")) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -274,10 +273,10 @@ export class WhmcsHttpClientService {
|
|||||||
*/
|
*/
|
||||||
private updateSuccessStats(responseTime: number): void {
|
private updateSuccessStats(responseTime: number): void {
|
||||||
this.stats.successfulRequests++;
|
this.stats.successfulRequests++;
|
||||||
|
|
||||||
// Update average response time
|
// Update average response time
|
||||||
const totalSuccessful = this.stats.successfulRequests;
|
const totalSuccessful = this.stats.successfulRequests;
|
||||||
this.stats.averageResponseTime =
|
this.stats.averageResponseTime =
|
||||||
(this.stats.averageResponseTime * (totalSuccessful - 1) + responseTime) / totalSuccessful;
|
(this.stats.averageResponseTime * (totalSuccessful - 1) + responseTime) / totalSuccessful;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -286,18 +285,25 @@ export class WhmcsHttpClientService {
|
|||||||
*/
|
*/
|
||||||
private sanitizeLogParams(params: Record<string, unknown>): Record<string, unknown> {
|
private sanitizeLogParams(params: Record<string, unknown>): Record<string, unknown> {
|
||||||
const sensitiveKeys = [
|
const sensitiveKeys = [
|
||||||
'password', 'secret', 'token', 'key', 'auth',
|
"password",
|
||||||
'credit_card', 'cvv', 'ssn', 'social_security'
|
"secret",
|
||||||
|
"token",
|
||||||
|
"key",
|
||||||
|
"auth",
|
||||||
|
"credit_card",
|
||||||
|
"cvv",
|
||||||
|
"ssn",
|
||||||
|
"social_security",
|
||||||
];
|
];
|
||||||
|
|
||||||
const sanitized: Record<string, unknown> = {};
|
const sanitized: Record<string, unknown> = {};
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(params)) {
|
for (const [key, value] of Object.entries(params)) {
|
||||||
const keyLower = key.toLowerCase();
|
const keyLower = key.toLowerCase();
|
||||||
const isSensitive = sensitiveKeys.some(sensitive => keyLower.includes(sensitive));
|
const isSensitive = sensitiveKeys.some(sensitive => keyLower.includes(sensitive));
|
||||||
|
|
||||||
if (isSensitive) {
|
if (isSensitive) {
|
||||||
sanitized[key] = '[REDACTED]';
|
sanitized[key] = "[REDACTED]";
|
||||||
} else {
|
} else {
|
||||||
sanitized[key] = value;
|
sanitized[key] = value;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { Injectable, NotFoundException, Inject } from "@nestjs/common";
|
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 { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service";
|
||||||
import { InvoiceTransformerService } from "../transformers/services/invoice-transformer.service";
|
import { InvoiceTransformerService } from "../transformers/services/invoice-transformer.service";
|
||||||
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
|
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
|
||||||
@ -113,7 +117,7 @@ export class WhmcsInvoiceService {
|
|||||||
try {
|
try {
|
||||||
// Get detailed invoice with items
|
// Get detailed invoice with items
|
||||||
const detailedInvoice = await this.getInvoiceById(clientId, userId, invoice.id);
|
const detailedInvoice = await this.getInvoiceById(clientId, userId, invoice.id);
|
||||||
const parseResult = invoiceSchema.safeParse(detailedInvoice);
|
const parseResult = invoiceEntitySchema.safeParse(detailedInvoice);
|
||||||
if (!parseResult.success) {
|
if (!parseResult.success) {
|
||||||
this.logger.error("Failed to parse detailed invoice", {
|
this.logger.error("Failed to parse detailed invoice", {
|
||||||
error: parseResult.error.issues,
|
error: parseResult.error.issues,
|
||||||
@ -180,7 +184,7 @@ export class WhmcsInvoiceService {
|
|||||||
// Transform invoice
|
// Transform invoice
|
||||||
const invoice = this.invoiceTransformer.transformInvoice(response);
|
const invoice = this.invoiceTransformer.transformInvoice(response);
|
||||||
|
|
||||||
const parseResult = invoiceSchema.safeParse(invoice);
|
const parseResult = invoiceEntitySchema.safeParse(invoice);
|
||||||
if (!parseResult.success) {
|
if (!parseResult.success) {
|
||||||
throw new Error(`Invalid invoice data after transformation`);
|
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}`);
|
this.logger.log(`Invalidated invoice cache for user ${userId}, invoice ${invoiceId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private transformInvoicesResponse(
|
private transformInvoicesResponse(
|
||||||
response: WhmcsInvoicesResponse,
|
response: WhmcsInvoicesResponse,
|
||||||
clientId: number,
|
clientId: number,
|
||||||
@ -225,18 +228,18 @@ export class WhmcsInvoiceService {
|
|||||||
} satisfies InvoiceList;
|
} satisfies InvoiceList;
|
||||||
}
|
}
|
||||||
|
|
||||||
const invoices = response.invoices.invoice
|
const invoices: Invoice[] = [];
|
||||||
.map(whmcsInvoice => {
|
for (const whmcsInvoice of response.invoices.invoice) {
|
||||||
try {
|
try {
|
||||||
return this.invoiceTransformer.transformInvoice(whmcsInvoice);
|
const transformed = this.invoiceTransformer.transformInvoice(whmcsInvoice);
|
||||||
} catch (error) {
|
const parsed = invoiceEntitySchema.parse(transformed);
|
||||||
this.logger.error(`Failed to transform invoice ${whmcsInvoice.id}`, {
|
invoices.push(parsed);
|
||||||
error: getErrorMessage(error),
|
} catch (error) {
|
||||||
});
|
this.logger.error(`Failed to transform invoice ${whmcsInvoice.id}`, {
|
||||||
return null;
|
error: getErrorMessage(error),
|
||||||
}
|
});
|
||||||
})
|
}
|
||||||
.filter((invoice): invoice is Invoice => invoice !== null);
|
}
|
||||||
|
|
||||||
this.logger.debug(`WHMCS GetInvoices Response Analysis for Client ${clientId}:`, {
|
this.logger.debug(`WHMCS GetInvoices Response Analysis for Client ${clientId}:`, {
|
||||||
totalresults: response.totalresults,
|
totalresults: response.totalresults,
|
||||||
|
|||||||
@ -243,7 +243,6 @@ export class WhmcsPaymentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize WHMCS SSO redirect URLs to absolute using configured base URL.
|
* Normalize WHMCS SSO redirect URLs to absolute using configured base URL.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,9 +1,6 @@
|
|||||||
import { Injectable, Inject } from "@nestjs/common";
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import {
|
import { Invoice, InvoiceItem as BaseInvoiceItem } from "@customer-portal/domain";
|
||||||
Invoice,
|
|
||||||
InvoiceItem as BaseInvoiceItem,
|
|
||||||
} from "@customer-portal/domain";
|
|
||||||
import type {
|
import type {
|
||||||
WhmcsInvoice,
|
WhmcsInvoice,
|
||||||
WhmcsInvoiceItems,
|
WhmcsInvoiceItems,
|
||||||
@ -33,7 +30,7 @@ export class InvoiceTransformerService {
|
|||||||
*/
|
*/
|
||||||
transformInvoice(whmcsInvoice: WhmcsInvoice): Invoice {
|
transformInvoice(whmcsInvoice: WhmcsInvoice): Invoice {
|
||||||
const invoiceId = whmcsInvoice.invoiceid || whmcsInvoice.id;
|
const invoiceId = whmcsInvoice.invoiceid || whmcsInvoice.id;
|
||||||
|
|
||||||
if (!this.validator.validateWhmcsInvoiceData(whmcsInvoice)) {
|
if (!this.validator.validateWhmcsInvoiceData(whmcsInvoice)) {
|
||||||
throw new Error("Invalid invoice data from WHMCS");
|
throw new Error("Invalid invoice data from WHMCS");
|
||||||
}
|
}
|
||||||
@ -45,7 +42,7 @@ export class InvoiceTransformerService {
|
|||||||
status: StatusNormalizer.normalizeInvoiceStatus(whmcsInvoice.status),
|
status: StatusNormalizer.normalizeInvoiceStatus(whmcsInvoice.status),
|
||||||
currency: whmcsInvoice.currencycode || "JPY",
|
currency: whmcsInvoice.currencycode || "JPY",
|
||||||
currencySymbol:
|
currencySymbol:
|
||||||
whmcsInvoice.currencyprefix ||
|
whmcsInvoice.currencyprefix ||
|
||||||
DataUtils.getCurrencySymbol(whmcsInvoice.currencycode || "JPY"),
|
DataUtils.getCurrencySymbol(whmcsInvoice.currencycode || "JPY"),
|
||||||
total: DataUtils.parseAmount(whmcsInvoice.total),
|
total: DataUtils.parseAmount(whmcsInvoice.total),
|
||||||
subtotal: DataUtils.parseAmount(whmcsInvoice.subtotal),
|
subtotal: DataUtils.parseAmount(whmcsInvoice.subtotal),
|
||||||
@ -90,14 +87,14 @@ export class InvoiceTransformerService {
|
|||||||
|
|
||||||
// WHMCS API returns either an array or single item
|
// WHMCS API returns either an array or single item
|
||||||
const itemsArray = Array.isArray(items.item) ? items.item : [items.item];
|
const itemsArray = Array.isArray(items.item) ? items.item : [items.item];
|
||||||
|
|
||||||
return itemsArray.map(item => this.transformSingleInvoiceItem(item));
|
return itemsArray.map(item => this.transformSingleInvoiceItem(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform a single invoice item using exact WHMCS API structure
|
* 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 = {
|
const transformedItem: InvoiceItem = {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
description: item.description,
|
description: item.description,
|
||||||
|
|||||||
@ -85,8 +85,6 @@ export class PaymentTransformerService {
|
|||||||
return transformed;
|
return transformed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize expiry date to MM/YY format
|
* Normalize expiry date to MM/YY format
|
||||||
*/
|
*/
|
||||||
@ -95,12 +93,12 @@ export class PaymentTransformerService {
|
|||||||
|
|
||||||
// Handle various formats: MM/YY, MM/YYYY, MMYY, MMYYYY
|
// Handle various formats: MM/YY, MM/YYYY, MMYY, MMYYYY
|
||||||
const cleaned = expiryDate.replace(/\D/g, "");
|
const cleaned = expiryDate.replace(/\D/g, "");
|
||||||
|
|
||||||
if (cleaned.length === 4) {
|
if (cleaned.length === 4) {
|
||||||
// MMYY format
|
// MMYY format
|
||||||
return `${cleaned.substring(0, 2)}/${cleaned.substring(2, 4)}`;
|
return `${cleaned.substring(0, 2)}/${cleaned.substring(2, 4)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cleaned.length === 6) {
|
if (cleaned.length === 6) {
|
||||||
// MMYYYY format - convert to MM/YY
|
// MMYYYY format - convert to MM/YY
|
||||||
return `${cleaned.substring(0, 2)}/${cleaned.substring(4, 6)}`;
|
return `${cleaned.substring(0, 2)}/${cleaned.substring(4, 6)}`;
|
||||||
@ -185,7 +183,9 @@ export class PaymentTransformerService {
|
|||||||
/**
|
/**
|
||||||
* Normalize gateway type to match our enum
|
* 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();
|
const normalizedType = type.toLowerCase();
|
||||||
switch (normalizedType) {
|
switch (normalizedType) {
|
||||||
case "merchant":
|
case "merchant":
|
||||||
@ -207,14 +207,25 @@ export class PaymentTransformerService {
|
|||||||
/**
|
/**
|
||||||
* Normalize payment method type to match our enum
|
* 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";
|
if (!gatewayName) return "Manual";
|
||||||
|
|
||||||
const normalized = gatewayName.toLowerCase();
|
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";
|
return "CreditCard";
|
||||||
}
|
}
|
||||||
if (normalized.includes("bank") || normalized.includes("ach") || normalized.includes("account")) {
|
if (
|
||||||
|
normalized.includes("bank") ||
|
||||||
|
normalized.includes("ach") ||
|
||||||
|
normalized.includes("account")
|
||||||
|
) {
|
||||||
return "BankAccount";
|
return "BankAccount";
|
||||||
}
|
}
|
||||||
if (normalized.includes("remote") || normalized.includes("token")) {
|
if (normalized.includes("remote") || normalized.includes("token")) {
|
||||||
|
|||||||
@ -65,7 +65,9 @@ export class SubscriptionTransformerService {
|
|||||||
cycle: subscription.cycle,
|
cycle: subscription.cycle,
|
||||||
amount: subscription.amount,
|
amount: subscription.amount,
|
||||||
currency: subscription.currency,
|
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;
|
return subscription;
|
||||||
@ -95,7 +97,9 @@ export class SubscriptionTransformerService {
|
|||||||
/**
|
/**
|
||||||
* Extract and normalize custom fields from WHMCS format
|
* 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) {
|
if (!customFields || !Array.isArray(customFields) || customFields.length === 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,6 @@
|
|||||||
import { Injectable, Inject } from "@nestjs/common";
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import {
|
import { Invoice, Subscription, PaymentMethod, PaymentGateway } from "@customer-portal/domain";
|
||||||
Invoice,
|
|
||||||
Subscription,
|
|
||||||
PaymentMethod,
|
|
||||||
PaymentGateway,
|
|
||||||
} from "@customer-portal/domain";
|
|
||||||
import type {
|
import type {
|
||||||
WhmcsInvoice,
|
WhmcsInvoice,
|
||||||
WhmcsProduct,
|
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,
|
total: whmcsInvoices.length,
|
||||||
successful: successful.length,
|
successful: successful.length,
|
||||||
failed: failed.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,
|
total: whmcsProducts.length,
|
||||||
successful: successful.length,
|
successful: successful.length,
|
||||||
failed: failed.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,
|
total: whmcsPayMethods.length,
|
||||||
successful: successful.length,
|
successful: successful.length,
|
||||||
failed: failed.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,
|
total: whmcsGateways.length,
|
||||||
successful: successful.length,
|
successful: successful.length,
|
||||||
failed: failed.length,
|
failed: failed.length,
|
||||||
@ -336,17 +331,12 @@ export class WhmcsTransformerOrchestratorService {
|
|||||||
validationRules: string[];
|
validationRules: string[];
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
supportedTypes: [
|
supportedTypes: ["invoices", "subscriptions", "payment_methods", "payment_gateways"],
|
||||||
"invoices",
|
|
||||||
"subscriptions",
|
|
||||||
"payment_methods",
|
|
||||||
"payment_gateways"
|
|
||||||
],
|
|
||||||
validationRules: [
|
validationRules: [
|
||||||
"required_fields_validation",
|
"required_fields_validation",
|
||||||
"data_type_validation",
|
"data_type_validation",
|
||||||
"format_validation",
|
"format_validation",
|
||||||
"business_rule_validation"
|
"business_rule_validation",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -140,5 +140,4 @@ export class DataUtils {
|
|||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import {
|
import type {
|
||||||
InvoiceStatus,
|
InvoiceStatus,
|
||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
SubscriptionBillingCycle,
|
SubscriptionBillingCycle,
|
||||||
|
|||||||
@ -1,10 +1,5 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import {
|
import { Invoice, Subscription, PaymentMethod, PaymentGateway } from "@customer-portal/domain";
|
||||||
Invoice,
|
|
||||||
Subscription,
|
|
||||||
PaymentMethod,
|
|
||||||
PaymentGateway,
|
|
||||||
} from "@customer-portal/domain";
|
|
||||||
import type { WhmcsInvoice, WhmcsProduct } from "../../types/whmcs-api.types";
|
import type { WhmcsInvoice, WhmcsProduct } from "../../types/whmcs-api.types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -18,15 +13,15 @@ export class TransformationValidator {
|
|||||||
validateInvoice(invoice: Invoice): boolean {
|
validateInvoice(invoice: Invoice): boolean {
|
||||||
const requiredFields = [
|
const requiredFields = [
|
||||||
"id",
|
"id",
|
||||||
"number",
|
"number",
|
||||||
"status",
|
"status",
|
||||||
"currency",
|
"currency",
|
||||||
"total",
|
"total",
|
||||||
"subtotal",
|
"subtotal",
|
||||||
"tax",
|
"tax",
|
||||||
"issuedAt"
|
"issuedAt",
|
||||||
];
|
];
|
||||||
|
|
||||||
return requiredFields.every(field => {
|
return requiredFields.every(field => {
|
||||||
const value = invoice[field as keyof Invoice];
|
const value = invoice[field as keyof Invoice];
|
||||||
return value !== undefined && value !== null;
|
return value !== undefined && value !== null;
|
||||||
@ -37,14 +32,8 @@ export class TransformationValidator {
|
|||||||
* Validate subscription transformation result
|
* Validate subscription transformation result
|
||||||
*/
|
*/
|
||||||
validateSubscription(subscription: Subscription): boolean {
|
validateSubscription(subscription: Subscription): boolean {
|
||||||
const requiredFields = [
|
const requiredFields = ["id", "serviceId", "productName", "status", "currency"];
|
||||||
"id",
|
|
||||||
"serviceId",
|
|
||||||
"productName",
|
|
||||||
"status",
|
|
||||||
"currency"
|
|
||||||
];
|
|
||||||
|
|
||||||
return requiredFields.every(field => {
|
return requiredFields.every(field => {
|
||||||
const value = subscription[field as keyof Subscription];
|
const value = subscription[field as keyof Subscription];
|
||||||
return value !== undefined && value !== null;
|
return value !== undefined && value !== null;
|
||||||
@ -56,7 +45,7 @@ export class TransformationValidator {
|
|||||||
*/
|
*/
|
||||||
validatePaymentMethod(paymentMethod: PaymentMethod): boolean {
|
validatePaymentMethod(paymentMethod: PaymentMethod): boolean {
|
||||||
const requiredFields = ["id", "type", "description"];
|
const requiredFields = ["id", "type", "description"];
|
||||||
|
|
||||||
return requiredFields.every(field => {
|
return requiredFields.every(field => {
|
||||||
const value = paymentMethod[field as keyof PaymentMethod];
|
const value = paymentMethod[field as keyof PaymentMethod];
|
||||||
return value !== undefined && value !== null;
|
return value !== undefined && value !== null;
|
||||||
@ -68,7 +57,7 @@ export class TransformationValidator {
|
|||||||
*/
|
*/
|
||||||
validatePaymentGateway(gateway: PaymentGateway): boolean {
|
validatePaymentGateway(gateway: PaymentGateway): boolean {
|
||||||
const requiredFields = ["name", "displayName", "type", "isActive"];
|
const requiredFields = ["name", "displayName", "type", "isActive"];
|
||||||
|
|
||||||
return requiredFields.every(field => {
|
return requiredFields.every(field => {
|
||||||
const value = gateway[field as keyof PaymentGateway];
|
const value = gateway[field as keyof PaymentGateway];
|
||||||
return value !== undefined && value !== null;
|
return value !== undefined && value !== null;
|
||||||
@ -78,9 +67,11 @@ export class TransformationValidator {
|
|||||||
/**
|
/**
|
||||||
* Validate invoice items array
|
* 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;
|
if (!Array.isArray(items)) return false;
|
||||||
|
|
||||||
return items.every(item => {
|
return items.every(item => {
|
||||||
return Boolean(item.description && item.amount && item.id);
|
return Boolean(item.description && item.amount && item.id);
|
||||||
});
|
});
|
||||||
@ -105,7 +96,7 @@ export class TransformationValidator {
|
|||||||
*/
|
*/
|
||||||
validateCurrencyCode(currency: string): boolean {
|
validateCurrencyCode(currency: string): boolean {
|
||||||
if (!currency || typeof currency !== "string") return false;
|
if (!currency || typeof currency !== "string") return false;
|
||||||
|
|
||||||
// Check if it's a valid 3-letter currency code
|
// Check if it's a valid 3-letter currency code
|
||||||
return /^[A-Z]{3}$/.test(currency.toUpperCase());
|
return /^[A-Z]{3}$/.test(currency.toUpperCase());
|
||||||
}
|
}
|
||||||
@ -117,12 +108,12 @@ export class TransformationValidator {
|
|||||||
if (typeof amount === "number") {
|
if (typeof amount === "number") {
|
||||||
return !isNaN(amount) && isFinite(amount);
|
return !isNaN(amount) && isFinite(amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof amount === "string") {
|
if (typeof amount === "string") {
|
||||||
const parsed = parseFloat(amount);
|
const parsed = parseFloat(amount);
|
||||||
return !isNaN(parsed) && isFinite(parsed);
|
return !isNaN(parsed) && isFinite(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -131,7 +122,7 @@ export class TransformationValidator {
|
|||||||
*/
|
*/
|
||||||
validateDateString(dateStr: string): boolean {
|
validateDateString(dateStr: string): boolean {
|
||||||
if (!dateStr) return false;
|
if (!dateStr) return false;
|
||||||
|
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
return !isNaN(date.getTime());
|
return !isNaN(date.getTime());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -288,6 +288,7 @@ export interface WhmcsCatalogProductsResponse {
|
|||||||
// Payment Method Types
|
// Payment Method Types
|
||||||
export interface WhmcsPaymentMethod {
|
export interface WhmcsPaymentMethod {
|
||||||
id: number;
|
id: number;
|
||||||
|
paymethodid?: number;
|
||||||
type: "CreditCard" | "BankAccount" | "RemoteCreditCard" | "RemoteBankAccount";
|
type: "CreditCard" | "BankAccount" | "RemoteCreditCard" | "RemoteBankAccount";
|
||||||
description: string;
|
description: string;
|
||||||
gateway_name?: string;
|
gateway_name?: string;
|
||||||
@ -314,7 +315,6 @@ export interface WhmcsGetPayMethodsParams {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Payment Gateway Types
|
// Payment Gateway Types
|
||||||
export interface WhmcsPaymentGateway {
|
export interface WhmcsPaymentGateway {
|
||||||
name: string;
|
name: string;
|
||||||
@ -421,4 +421,3 @@ export interface WhmcsCapturePaymentResponse {
|
|||||||
message?: string;
|
message?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,7 +28,6 @@ import {
|
|||||||
} from "./types/whmcs-api.types";
|
} from "./types/whmcs-api.types";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WhmcsService {
|
export class WhmcsService {
|
||||||
constructor(
|
constructor(
|
||||||
@ -279,7 +278,6 @@ export class WhmcsService {
|
|||||||
return this.paymentService.getProducts() as Promise<WhmcsCatalogProductsResponse>;
|
return this.paymentService.getProducts() as Promise<WhmcsCatalogProductsResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// SSO OPERATIONS (delegate to SsoService)
|
// SSO OPERATIONS (delegate to SsoService)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@ -335,7 +333,6 @@ export class WhmcsService {
|
|||||||
return this.connectionService.getClientsProducts(params);
|
return this.connectionService.getClientsProducts(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// ORDER OPERATIONS (delegate to OrderService)
|
// ORDER OPERATIONS (delegate to OrderService)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
import type {
|
import type {
|
||||||
SalesforceProduct2WithPricebookEntries,
|
SalesforceProduct2WithPricebookEntries,
|
||||||
|
SalesforcePricebookEntryRecord,
|
||||||
SalesforceQueryResult,
|
SalesforceQueryResult,
|
||||||
} from "@customer-portal/domain";
|
} from "@customer-portal/domain";
|
||||||
|
|
||||||
@ -45,16 +46,15 @@ export class BaseCatalogService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected extractPricebookEntry(record: SalesforceProduct2WithPricebookEntries) {
|
protected extractPricebookEntry(
|
||||||
const pricebookEntries =
|
record: SalesforceProduct2WithPricebookEntries
|
||||||
record.PricebookEntries && typeof record.PricebookEntries === "object"
|
): SalesforcePricebookEntryRecord | undefined {
|
||||||
? (record.PricebookEntries as { records?: unknown[] })
|
const pricebookEntries = record.PricebookEntries?.records;
|
||||||
: { records: undefined };
|
const entry = Array.isArray(pricebookEntries) ? pricebookEntries[0] : undefined;
|
||||||
const entry = Array.isArray(pricebookEntries.records) ? pricebookEntries.records[0] : undefined;
|
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
const fields = this.getFields();
|
const fields = this.getFields();
|
||||||
const skuField = fields.product.sku;
|
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;
|
const sku = typeof skuRaw === "string" ? skuRaw : undefined;
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`No pricebook entry found for product ${String(record.Name)} (SKU: ${String(sku ?? "")}). Pricebook ID: ${this.portalPriceBookId}.`
|
`No pricebook entry found for product ${String(record.Name)} (SKU: ${String(sku ?? "")}). Pricebook ID: ${this.portalPriceBookId}.`
|
||||||
|
|||||||
@ -32,7 +32,10 @@ export class VpnCatalogService extends BaseCatalogService {
|
|||||||
async getActivationFees(): Promise<VpnCatalogProduct[]> {
|
async getActivationFees(): Promise<VpnCatalogProduct[]> {
|
||||||
const fields = this.getFields();
|
const fields = this.getFields();
|
||||||
const soql = this.buildProductQuery("VPN", "Activation", [fields.product.vpnRegion]);
|
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 => {
|
return records.map(record => {
|
||||||
const pricebookEntry = this.extractPricebookEntry(record);
|
const pricebookEntry = this.extractPricebookEntry(record);
|
||||||
|
|||||||
@ -57,7 +57,8 @@ function getTierTemplate(tier?: string): InternetPlanTemplate {
|
|||||||
case "platinum":
|
case "platinum":
|
||||||
return {
|
return {
|
||||||
tierDescription: "Tailored set up with premier Wi-Fi management support",
|
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: [
|
features: [
|
||||||
"NTT modem + Netgear INSIGHT Wi-Fi routers",
|
"NTT modem + Netgear INSIGHT Wi-Fi routers",
|
||||||
"Cloud management support for remote router management",
|
"Cloud management support for remote router management",
|
||||||
@ -146,7 +147,6 @@ function resolveBundledAddon(product: SalesforceCatalogProductRecord) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function derivePrices(
|
function derivePrices(
|
||||||
product: SalesforceCatalogProductRecord,
|
product: SalesforceCatalogProductRecord,
|
||||||
pricebookEntry?: SalesforcePricebookEntryRecord
|
pricebookEntry?: SalesforcePricebookEntryRecord
|
||||||
|
|||||||
@ -16,9 +16,8 @@ import {
|
|||||||
UpdateMappingRequest,
|
UpdateMappingRequest,
|
||||||
MappingSearchFilters,
|
MappingSearchFilters,
|
||||||
MappingStats,
|
MappingStats,
|
||||||
_BulkMappingResult,
|
|
||||||
} from "./types/mapping.types";
|
} from "./types/mapping.types";
|
||||||
import type { IdMapping as PrismaIdMapping } from "@prisma/client";
|
import type { Prisma, IdMapping as PrismaIdMapping } from "@prisma/client";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MappingsService {
|
export class MappingsService {
|
||||||
@ -273,15 +272,22 @@ export class MappingsService {
|
|||||||
|
|
||||||
async searchMappings(filters: MappingSearchFilters): Promise<UserIdMapping[]> {
|
async searchMappings(filters: MappingSearchFilters): Promise<UserIdMapping[]> {
|
||||||
try {
|
try {
|
||||||
const whereClause: Record<string, unknown> = {};
|
const whereClause: Prisma.IdMappingWhereInput = {};
|
||||||
if (filters.userId) whereClause.userId = filters.userId;
|
if (filters.userId) whereClause.userId = filters.userId;
|
||||||
if (filters.whmcsClientId) whereClause.whmcsClientId = filters.whmcsClientId;
|
if (filters.whmcsClientId) whereClause.whmcsClientId = filters.whmcsClientId;
|
||||||
if (filters.sfAccountId) whereClause.sfAccountId = filters.sfAccountId;
|
if (filters.sfAccountId) whereClause.sfAccountId = filters.sfAccountId;
|
||||||
if (filters.hasWhmcsMapping !== undefined) {
|
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) {
|
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({
|
const dbMappings = await this.prisma.idMapping.findMany({
|
||||||
@ -301,10 +307,10 @@ export class MappingsService {
|
|||||||
try {
|
try {
|
||||||
const [totalCount, whmcsCount, sfCount, completeCount] = await Promise.all([
|
const [totalCount, whmcsCount, sfCount, completeCount] = await Promise.all([
|
||||||
this.prisma.idMapping.count(),
|
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: { sfAccountId: { not: null } } }),
|
||||||
this.prisma.idMapping.count({
|
this.prisma.idMapping.count({
|
||||||
where: { whmcsClientId: { not: null }, sfAccountId: { not: null } },
|
where: { whmcsClientId: { gt: 0 }, sfAccountId: { not: null } },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -219,12 +219,12 @@ export class InvoicesController {
|
|||||||
if (!mapping?.whmcsClientId) {
|
if (!mapping?.whmcsClientId) {
|
||||||
throw new Error("WHMCS client mapping not found");
|
throw new Error("WHMCS client mapping not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const ssoResult = await this.whmcsService.createSsoToken(
|
const ssoResult = await this.whmcsService.createSsoToken(
|
||||||
mapping.whmcsClientId,
|
mapping.whmcsClientId,
|
||||||
invoiceId ? `index.php?rp=/invoice/${invoiceId}` : undefined
|
invoiceId ? `index.php?rp=/invoice/${invoiceId}` : undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: ssoResult.url,
|
url: ssoResult.url,
|
||||||
expiresAt: ssoResult.expiresAt,
|
expiresAt: ssoResult.expiresAt,
|
||||||
@ -272,14 +272,14 @@ export class InvoicesController {
|
|||||||
if (!mapping?.whmcsClientId) {
|
if (!mapping?.whmcsClientId) {
|
||||||
throw new Error("WHMCS client mapping not found");
|
throw new Error("WHMCS client mapping not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const ssoResult = await this.whmcsService.createPaymentSsoToken(
|
const ssoResult = await this.whmcsService.createPaymentSsoToken(
|
||||||
mapping.whmcsClientId,
|
mapping.whmcsClientId,
|
||||||
invoiceId,
|
invoiceId,
|
||||||
paymentMethodIdNum,
|
paymentMethodIdNum,
|
||||||
gatewayName || "stripe"
|
gatewayName || "stripe"
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: ssoResult.url,
|
url: ssoResult.url,
|
||||||
expiresAt: ssoResult.expiresAt,
|
expiresAt: ssoResult.expiresAt,
|
||||||
|
|||||||
@ -36,15 +36,21 @@ export class InvoiceHealthService {
|
|||||||
const whmcsResult = checks[0];
|
const whmcsResult = checks[0];
|
||||||
const mappingsResult = checks[1];
|
const mappingsResult = checks[1];
|
||||||
|
|
||||||
const isHealthy =
|
const isHealthy =
|
||||||
whmcsResult.status === "fulfilled" && whmcsResult.value &&
|
whmcsResult.status === "fulfilled" &&
|
||||||
mappingsResult.status === "fulfilled" && mappingsResult.value;
|
whmcsResult.value &&
|
||||||
|
mappingsResult.status === "fulfilled" &&
|
||||||
|
mappingsResult.value;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: isHealthy ? "healthy" : "unhealthy",
|
status: isHealthy ? "healthy" : "unhealthy",
|
||||||
details: {
|
details: {
|
||||||
whmcsApi: whmcsResult.status === "fulfilled" && whmcsResult.value ? "connected" : "disconnected",
|
whmcsApi:
|
||||||
mappingsService: mappingsResult.status === "fulfilled" && mappingsResult.value ? "available" : "unavailable",
|
whmcsResult.status === "fulfilled" && whmcsResult.value ? "connected" : "disconnected",
|
||||||
|
mappingsService:
|
||||||
|
mappingsResult.status === "fulfilled" && mappingsResult.value
|
||||||
|
? "available"
|
||||||
|
: "unavailable",
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -142,7 +148,7 @@ export class InvoiceHealthService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// We expect this to fail for a non-existent user, but if the service responds, it's healthy
|
// We expect this to fail for a non-existent user, but if the service responds, it's healthy
|
||||||
const errorMessage = getErrorMessage(error);
|
const errorMessage = getErrorMessage(error);
|
||||||
|
|
||||||
// If it's a "not found" error, the service is working
|
// If it's a "not found" error, the service is working
|
||||||
if (errorMessage.toLowerCase().includes("not found")) {
|
if (errorMessage.toLowerCase().includes("not found")) {
|
||||||
return true;
|
return true;
|
||||||
@ -159,15 +165,15 @@ export class InvoiceHealthService {
|
|||||||
* Update average response time
|
* Update average response time
|
||||||
*/
|
*/
|
||||||
private updateAverageResponseTime(responseTime: number): void {
|
private updateAverageResponseTime(responseTime: number): void {
|
||||||
const totalRequests =
|
const totalRequests =
|
||||||
this.stats.totalInvoicesRetrieved +
|
this.stats.totalInvoicesRetrieved +
|
||||||
this.stats.totalPaymentLinksCreated +
|
this.stats.totalPaymentLinksCreated +
|
||||||
this.stats.totalSsoLinksCreated;
|
this.stats.totalSsoLinksCreated;
|
||||||
|
|
||||||
if (totalRequests === 1) {
|
if (totalRequests === 1) {
|
||||||
this.stats.averageResponseTime = responseTime;
|
this.stats.averageResponseTime = responseTime;
|
||||||
} else {
|
} else {
|
||||||
this.stats.averageResponseTime =
|
this.stats.averageResponseTime =
|
||||||
(this.stats.averageResponseTime * (totalRequests - 1) + responseTime) / totalRequests;
|
(this.stats.averageResponseTime * (totalRequests - 1) + responseTime) / totalRequests;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -182,7 +188,7 @@ export class InvoiceHealthService {
|
|||||||
lastCheck: string;
|
lastCheck: string;
|
||||||
}> {
|
}> {
|
||||||
const health = await this.healthCheck();
|
const health = await this.healthCheck();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: health.status,
|
status: health.status,
|
||||||
uptime: process.uptime(),
|
uptime: process.uptime(),
|
||||||
|
|||||||
@ -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 { Logger } from "nestjs-pino";
|
||||||
import { Invoice, InvoiceList } from "@customer-portal/domain";
|
import { Invoice, InvoiceList } from "@customer-portal/domain";
|
||||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
import { InvoiceValidatorService } from "../validators/invoice-validator.service";
|
import { InvoiceValidatorService } from "../validators/invoice-validator.service";
|
||||||
import type {
|
import type {
|
||||||
GetInvoicesOptions,
|
GetInvoicesOptions,
|
||||||
InvoiceStatus,
|
InvoiceStatus,
|
||||||
PaginationOptions,
|
PaginationOptions,
|
||||||
UserMappingInfo
|
UserMappingInfo,
|
||||||
} from "../types/invoice-service.types";
|
} from "../types/invoice-service.types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -34,7 +39,7 @@ export class InvoiceRetrievalService {
|
|||||||
// Validate inputs
|
// Validate inputs
|
||||||
this.validator.validateUserId(userId);
|
this.validator.validateUserId(userId);
|
||||||
this.validator.validatePagination({ page, limit });
|
this.validator.validatePagination({ page, limit });
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
this.validator.validateInvoiceStatus(status);
|
this.validator.validateInvoiceStatus(status);
|
||||||
}
|
}
|
||||||
@ -160,14 +165,20 @@ export class InvoiceRetrievalService {
|
|||||||
/**
|
/**
|
||||||
* Get cancelled invoices for a user
|
* 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);
|
return this.getInvoicesByStatus(userId, "Cancelled", options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get invoices in collections for a user
|
* 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);
|
return this.getInvoicesByStatus(userId, "Collections", options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,7 +187,7 @@ export class InvoiceRetrievalService {
|
|||||||
*/
|
*/
|
||||||
private async getUserMapping(userId: string): Promise<UserMappingInfo> {
|
private async getUserMapping(userId: string): Promise<UserMappingInfo> {
|
||||||
const mapping = await this.mappingsService.findByUserId(userId);
|
const mapping = await this.mappingsService.findByUserId(userId);
|
||||||
|
|
||||||
if (!mapping?.whmcsClientId) {
|
if (!mapping?.whmcsClientId) {
|
||||||
throw new NotFoundException("WHMCS client mapping not found");
|
throw new NotFoundException("WHMCS client mapping not found");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +1,22 @@
|
|||||||
import { Injectable, Inject } from "@nestjs/common";
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import {
|
import {
|
||||||
Invoice,
|
Invoice,
|
||||||
InvoiceList,
|
InvoiceList,
|
||||||
InvoiceSsoLink,
|
InvoiceSsoLink,
|
||||||
InvoicePaymentLink,
|
InvoicePaymentLink,
|
||||||
PaymentMethodList,
|
PaymentMethodList,
|
||||||
PaymentGatewayList
|
PaymentGatewayList,
|
||||||
} from "@customer-portal/domain";
|
} from "@customer-portal/domain";
|
||||||
import { InvoiceRetrievalService } from "./invoice-retrieval.service";
|
import { InvoiceRetrievalService } from "./invoice-retrieval.service";
|
||||||
import { InvoiceHealthService } from "./invoice-health.service";
|
import { InvoiceHealthService } from "./invoice-health.service";
|
||||||
import { InvoiceValidatorService } from "../validators/invoice-validator.service";
|
import { InvoiceValidatorService } from "../validators/invoice-validator.service";
|
||||||
import type {
|
import type {
|
||||||
GetInvoicesOptions,
|
GetInvoicesOptions,
|
||||||
InvoiceStatus,
|
InvoiceStatus,
|
||||||
PaginationOptions,
|
PaginationOptions,
|
||||||
InvoiceHealthStatus,
|
InvoiceHealthStatus,
|
||||||
InvoiceServiceStats
|
InvoiceServiceStats,
|
||||||
} from "../types/invoice-service.types";
|
} from "../types/invoice-service.types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -41,7 +41,7 @@ export class InvoicesOrchestratorService {
|
|||||||
*/
|
*/
|
||||||
async getInvoices(userId: string, options: GetInvoicesOptions = {}): Promise<InvoiceList> {
|
async getInvoices(userId: string, options: GetInvoicesOptions = {}): Promise<InvoiceList> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.retrievalService.getInvoices(userId, options);
|
const result = await this.retrievalService.getInvoices(userId, options);
|
||||||
this.healthService.recordInvoiceRetrieval(Date.now() - startTime);
|
this.healthService.recordInvoiceRetrieval(Date.now() - startTime);
|
||||||
@ -57,7 +57,7 @@ export class InvoicesOrchestratorService {
|
|||||||
*/
|
*/
|
||||||
async getInvoiceById(userId: string, invoiceId: number): Promise<Invoice> {
|
async getInvoiceById(userId: string, invoiceId: number): Promise<Invoice> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.retrievalService.getInvoiceById(userId, invoiceId);
|
const result = await this.retrievalService.getInvoiceById(userId, invoiceId);
|
||||||
this.healthService.recordInvoiceRetrieval(Date.now() - startTime);
|
this.healthService.recordInvoiceRetrieval(Date.now() - startTime);
|
||||||
@ -77,7 +77,7 @@ export class InvoicesOrchestratorService {
|
|||||||
options: PaginationOptions = {}
|
options: PaginationOptions = {}
|
||||||
): Promise<InvoiceList> {
|
): Promise<InvoiceList> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.retrievalService.getInvoicesByStatus(userId, status, options);
|
const result = await this.retrievalService.getInvoicesByStatus(userId, status, options);
|
||||||
this.healthService.recordInvoiceRetrieval(Date.now() - startTime);
|
this.healthService.recordInvoiceRetrieval(Date.now() - startTime);
|
||||||
@ -112,14 +112,20 @@ export class InvoicesOrchestratorService {
|
|||||||
/**
|
/**
|
||||||
* Get cancelled invoices for a user
|
* 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);
|
return this.retrievalService.getCancelledInvoices(userId, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get invoices in collections for a user
|
* 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);
|
return this.retrievalService.getCollectionsInvoices(userId, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,11 +133,6 @@ export class InvoicesOrchestratorService {
|
|||||||
// INVOICE OPERATIONS METHODS
|
// INVOICE OPERATIONS METHODS
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// UTILITY METHODS
|
// UTILITY METHODS
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { Injectable, BadRequestException } from "@nestjs/common";
|
import { Injectable, BadRequestException } from "@nestjs/common";
|
||||||
import type {
|
import type {
|
||||||
GetInvoicesOptions,
|
GetInvoicesOptions,
|
||||||
InvoiceValidationResult,
|
InvoiceValidationResult,
|
||||||
InvoiceStatus,
|
InvoiceStatus,
|
||||||
PaginationOptions
|
PaginationOptions,
|
||||||
} from "../types/invoice-service.types";
|
} from "../types/invoice-service.types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -12,7 +12,11 @@ import type {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class InvoiceValidatorService {
|
export class InvoiceValidatorService {
|
||||||
private readonly validStatuses: readonly InvoiceStatus[] = [
|
private readonly validStatuses: readonly InvoiceStatus[] = [
|
||||||
"Paid", "Unpaid", "Cancelled", "Overdue", "Collections"
|
"Paid",
|
||||||
|
"Unpaid",
|
||||||
|
"Cancelled",
|
||||||
|
"Overdue",
|
||||||
|
"Collections",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
private readonly maxLimit = 100;
|
private readonly maxLimit = 100;
|
||||||
@ -148,7 +152,7 @@ export class InvoiceValidatorService {
|
|||||||
*/
|
*/
|
||||||
sanitizePaginationOptions(options: PaginationOptions): Required<PaginationOptions> {
|
sanitizePaginationOptions(options: PaginationOptions): Required<PaginationOptions> {
|
||||||
const { page = 1, limit = 10 } = options;
|
const { page = 1, limit = 10 } = options;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
page: Math.max(1, Math.floor(page)),
|
page: Math.max(1, Math.floor(page)),
|
||||||
limit: Math.max(this.minLimit, Math.min(this.maxLimit, Math.floor(limit))),
|
limit: Math.max(this.minLimit, Math.min(this.maxLimit, Math.floor(limit))),
|
||||||
|
|||||||
@ -35,9 +35,13 @@ export class ProvisioningProcessor extends WorkerHost {
|
|||||||
// Guard: Only process if Salesforce Order is currently 'Activating'
|
// Guard: Only process if Salesforce Order is currently 'Activating'
|
||||||
const fields = getSalesforceFieldMap();
|
const fields = getSalesforceFieldMap();
|
||||||
const order = await this.salesforceService.getOrder(sfOrderId);
|
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 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") {
|
if (status !== "Activating") {
|
||||||
this.logger.log("Skipping provisioning job: Order not in Activating state", {
|
this.logger.log("Skipping provisioning job: Order not in Activating state", {
|
||||||
sfOrderId,
|
sfOrderId,
|
||||||
|
|||||||
@ -5,6 +5,15 @@ import { getSalesforceFieldMap } from "@bff/core/config/field-map";
|
|||||||
import { UsersService } from "@bff/modules/users/users.service";
|
import { UsersService } from "@bff/modules/users/users.service";
|
||||||
|
|
||||||
const fieldMap = getSalesforceFieldMap();
|
const fieldMap = getSalesforceFieldMap();
|
||||||
|
type OrderBuilderFieldKey =
|
||||||
|
| "orderType"
|
||||||
|
| "activationType"
|
||||||
|
| "activationScheduledAt"
|
||||||
|
| "activationStatus"
|
||||||
|
| "accessMode"
|
||||||
|
| "simType"
|
||||||
|
| "eid"
|
||||||
|
| "addressChanged";
|
||||||
|
|
||||||
function assignIfString(target: Record<string, unknown>, key: string, value: unknown): void {
|
function assignIfString(target: Record<string, unknown>, key: string, value: unknown): void {
|
||||||
if (typeof value === "string" && value.trim().length > 0) {
|
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 {
|
function orderField(key: OrderBuilderFieldKey): string {
|
||||||
return fieldMap.order[key];
|
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 {
|
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 {
|
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()
|
@Injectable()
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import type { SalesforceOrderRecord } from "@customer-portal/domain";
|
|||||||
import { getSalesforceFieldMap } from "@bff/core/config/field-map";
|
import { getSalesforceFieldMap } from "@bff/core/config/field-map";
|
||||||
|
|
||||||
const fieldMap = getSalesforceFieldMap();
|
const fieldMap = getSalesforceFieldMap();
|
||||||
|
type OrderStringFieldKey = "activationStatus";
|
||||||
|
|
||||||
export interface OrderFulfillmentValidationResult {
|
export interface OrderFulfillmentValidationResult {
|
||||||
sfOrder: SalesforceOrderRecord;
|
sfOrder: SalesforceOrderRecord;
|
||||||
@ -47,7 +48,7 @@ export class OrderFulfillmentValidator {
|
|||||||
const sfOrder = await this.validateSalesforceOrder(sfOrderId);
|
const sfOrder = await this.validateSalesforceOrder(sfOrderId);
|
||||||
|
|
||||||
// 2. Check if already provisioned (idempotency)
|
// 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;
|
const existingWhmcsOrderId = typeof rawWhmcs === "string" ? rawWhmcs : undefined;
|
||||||
if (existingWhmcsOrderId) {
|
if (existingWhmcsOrderId) {
|
||||||
this.logger.log("Order already provisioned", {
|
this.logger.log("Order already provisioned", {
|
||||||
@ -157,9 +158,12 @@ export class OrderFulfillmentValidator {
|
|||||||
|
|
||||||
function pickOrderString(
|
function pickOrderString(
|
||||||
order: SalesforceOrderRecord,
|
order: SalesforceOrderRecord,
|
||||||
key: keyof typeof fieldMap.order
|
key: OrderStringFieldKey
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
const field = fieldMap.order[key] as keyof SalesforceOrderRecord;
|
const field = fieldMap.order[key];
|
||||||
const raw = order[field];
|
if (typeof field !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const raw = Reflect.get(order, field) as unknown;
|
||||||
return typeof raw === "string" ? raw : undefined;
|
return typeof raw === "string" ? raw : undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,16 +23,22 @@ import { assertSalesforceId, buildInClause } from "@bff/integrations/salesforce/
|
|||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
|
|
||||||
const fieldMap = getSalesforceFieldMap();
|
const fieldMap = getSalesforceFieldMap();
|
||||||
|
type OrderFieldKey =
|
||||||
|
| "orderType"
|
||||||
|
| "activationType"
|
||||||
|
| "activationStatus"
|
||||||
|
| "activationScheduledAt"
|
||||||
|
| "whmcsOrderId";
|
||||||
|
|
||||||
type OrderDetailsResponse = z.infer<typeof orderDetailsSchema>;
|
type OrderDetailsResponse = z.infer<typeof orderDetailsSchema>;
|
||||||
type OrderSummaryResponse = z.infer<typeof orderSummarySchema>;
|
type OrderSummaryResponse = z.infer<typeof orderSummarySchema>;
|
||||||
|
|
||||||
function getOrderStringField(
|
function getOrderStringField(order: SalesforceOrderRecord, key: OrderFieldKey): string | undefined {
|
||||||
order: SalesforceOrderRecord,
|
const fieldName = fieldMap.order[key];
|
||||||
key: keyof typeof fieldMap.order
|
if (typeof fieldName !== "string") {
|
||||||
): string | undefined {
|
return undefined;
|
||||||
const fieldName = fieldMap.order[key] as keyof SalesforceOrderRecord;
|
}
|
||||||
const raw = order[fieldName];
|
const raw = Reflect.get(order, fieldName) as unknown;
|
||||||
return typeof raw === "string" ? raw : undefined;
|
return typeof raw === "string" ? raw : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,8 +59,8 @@ function mapOrderItemRecord(record: SalesforceOrderItemRecord): ParsedOrderItemD
|
|||||||
id: record.Id ?? "",
|
id: record.Id ?? "",
|
||||||
orderId: record.OrderId ?? "",
|
orderId: record.OrderId ?? "",
|
||||||
quantity: record.Quantity ?? 0,
|
quantity: record.Quantity ?? 0,
|
||||||
unitPrice: typeof record.UnitPrice === "number" ? record.UnitPrice : undefined,
|
unitPrice: coerceNumber(record.UnitPrice),
|
||||||
totalPrice: typeof record.TotalPrice === "number" ? record.TotalPrice : undefined,
|
totalPrice: coerceNumber(record.TotalPrice),
|
||||||
billingCycle: typeof record.Billing_Cycle__c === "string" ? record.Billing_Cycle__c : undefined,
|
billingCycle: typeof record.Billing_Cycle__c === "string" ? record.Billing_Cycle__c : undefined,
|
||||||
product: {
|
product: {
|
||||||
id: product?.Id,
|
id: product?.Id,
|
||||||
@ -71,13 +77,10 @@ function mapOrderItemRecord(record: SalesforceOrderItemRecord): ParsedOrderItemD
|
|||||||
|
|
||||||
function toOrderItemSummary(details: ParsedOrderItemDetails): OrderItemSummary {
|
function toOrderItemSummary(details: ParsedOrderItemDetails): OrderItemSummary {
|
||||||
return {
|
return {
|
||||||
orderId: details.orderId,
|
|
||||||
product: {
|
|
||||||
name: details.product.name,
|
|
||||||
sku: details.product.sku,
|
|
||||||
itemClass: details.product.itemClass,
|
|
||||||
},
|
|
||||||
quantity: details.quantity,
|
quantity: details.quantity,
|
||||||
|
name: details.product.name,
|
||||||
|
sku: details.product.sku,
|
||||||
|
itemClass: details.product.itemClass,
|
||||||
unitPrice: details.unitPrice,
|
unitPrice: details.unitPrice,
|
||||||
totalPrice: details.totalPrice,
|
totalPrice: details.totalPrice,
|
||||||
billingCycle: details.billingCycle,
|
billingCycle: details.billingCycle,
|
||||||
@ -247,8 +250,8 @@ export class OrderOrchestrator {
|
|||||||
id: detail.id,
|
id: detail.id,
|
||||||
orderId: detail.orderId,
|
orderId: detail.orderId,
|
||||||
quantity: detail.quantity,
|
quantity: detail.quantity,
|
||||||
unitPrice: detail.unitPrice,
|
unitPrice: detail.unitPrice ?? 0,
|
||||||
totalPrice: detail.totalPrice,
|
totalPrice: detail.totalPrice ?? 0,
|
||||||
billingCycle: detail.billingCycle,
|
billingCycle: detail.billingCycle,
|
||||||
product: {
|
product: {
|
||||||
id: detail.product.id,
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@ -126,9 +126,7 @@ export class OrderValidator {
|
|||||||
const products = await this.whmcs.getClientsProducts({ clientid: whmcsClientId });
|
const products = await this.whmcs.getClientsProducts({ clientid: whmcsClientId });
|
||||||
const existing = products?.products?.product || [];
|
const existing = products?.products?.product || [];
|
||||||
const hasInternet = existing.some(product =>
|
const hasInternet = existing.some(product =>
|
||||||
(product.groupname || "")
|
(product.groupname || "").toLowerCase().includes("internet")
|
||||||
.toLowerCase()
|
|
||||||
.includes("internet")
|
|
||||||
);
|
);
|
||||||
if (hasInternet) {
|
if (hasInternet) {
|
||||||
throw new BadRequestException("An Internet service already exists for this account");
|
throw new BadRequestException("An Internet service already exists for this account");
|
||||||
|
|||||||
@ -14,7 +14,6 @@ import type {
|
|||||||
SimFeaturesUpdateRequest,
|
SimFeaturesUpdateRequest,
|
||||||
} from "./sim-management/types/sim-requests.types";
|
} from "./sim-management/types/sim-requests.types";
|
||||||
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SimManagementService {
|
export class SimManagementService {
|
||||||
constructor(
|
constructor(
|
||||||
|
|||||||
@ -4,10 +4,7 @@ import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/f
|
|||||||
import { SimValidationService } from "./sim-validation.service";
|
import { SimValidationService } from "./sim-validation.service";
|
||||||
import { SimNotificationService } from "./sim-notification.service";
|
import { SimNotificationService } from "./sim-notification.service";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
import type {
|
import type { SimPlanChangeRequest, SimFeaturesUpdateRequest } from "../types/sim-requests.types";
|
||||||
SimPlanChangeRequest,
|
|
||||||
SimFeaturesUpdateRequest
|
|
||||||
} from "../types/sim-requests.types";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SimPlanService {
|
export class SimPlanService {
|
||||||
|
|||||||
@ -25,7 +25,7 @@ export class SimTopUpService {
|
|||||||
*/
|
*/
|
||||||
async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise<void> {
|
async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise<void> {
|
||||||
let account: string = "";
|
let account: string = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
account = validation.account;
|
account = validation.account;
|
||||||
@ -52,7 +52,7 @@ export class SimTopUpService {
|
|||||||
if (!mapping?.whmcsClientId) {
|
if (!mapping?.whmcsClientId) {
|
||||||
throw new BadRequestException("WHMCS client mapping not found");
|
throw new BadRequestException("WHMCS client mapping not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const whmcsClientId = mapping.whmcsClientId;
|
const whmcsClientId = mapping.whmcsClientId;
|
||||||
|
|
||||||
this.logger.log(`Starting SIM top-up process for subscription ${subscriptionId}`, {
|
this.logger.log(`Starting SIM top-up process for subscription ${subscriptionId}`, {
|
||||||
|
|||||||
@ -4,10 +4,7 @@ import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/f
|
|||||||
import { SimValidationService } from "./sim-validation.service";
|
import { SimValidationService } from "./sim-validation.service";
|
||||||
import { SimUsageStoreService } from "../../sim-usage-store.service";
|
import { SimUsageStoreService } from "../../sim-usage-store.service";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
import type {
|
import type { SimUsage, SimTopUpHistory } from "@bff/integrations/freebit/interfaces/freebit.types";
|
||||||
SimUsage,
|
|
||||||
SimTopUpHistory,
|
|
||||||
} from "@bff/integrations/freebit/interfaces/freebit.types";
|
|
||||||
import type { SimTopUpHistoryRequest } from "../types/sim-requests.types";
|
import type { SimTopUpHistoryRequest } from "../types/sim-requests.types";
|
||||||
import { BadRequestException } from "@nestjs/common";
|
import { BadRequestException } from "@nestjs/common";
|
||||||
|
|
||||||
|
|||||||
@ -18,17 +18,12 @@ import { SimValidationService } from "./services/sim-validation.service";
|
|||||||
import { SimNotificationService } from "./services/sim-notification.service";
|
import { SimNotificationService } from "./services/sim-notification.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [FreebitModule, WhmcsModule, MappingsModule, EmailModule],
|
||||||
FreebitModule,
|
|
||||||
WhmcsModule,
|
|
||||||
MappingsModule,
|
|
||||||
EmailModule,
|
|
||||||
],
|
|
||||||
providers: [
|
providers: [
|
||||||
// Core services that the SIM services depend on
|
// Core services that the SIM services depend on
|
||||||
SimUsageStoreService,
|
SimUsageStoreService,
|
||||||
SubscriptionsService,
|
SubscriptionsService,
|
||||||
|
|
||||||
// SIM management services
|
// SIM management services
|
||||||
SimValidationService,
|
SimValidationService,
|
||||||
SimNotificationService,
|
SimNotificationService,
|
||||||
|
|||||||
@ -91,11 +91,11 @@ export class SimOrderActivationService {
|
|||||||
contractLine: "5G",
|
contractLine: "5G",
|
||||||
shipDate: req.activationType === "Scheduled" ? req.scheduledAt : undefined,
|
shipDate: req.activationType === "Scheduled" ? req.scheduledAt : undefined,
|
||||||
mnp: req.mnp
|
mnp: req.mnp
|
||||||
? {
|
? {
|
||||||
reserveNumber: req.mnp.reserveNumber || "",
|
reserveNumber: req.mnp.reserveNumber || "",
|
||||||
reserveExpireDate: req.mnp.reserveExpireDate || ""
|
reserveExpireDate: req.mnp.reserveExpireDate || "",
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn("Physical SIM activation path is not implemented; skipping Freebit call", {
|
this.logger.warn("Physical SIM activation path is not implemented; skipping Freebit call", {
|
||||||
|
|||||||
@ -12,13 +12,7 @@ import { EmailModule } from "@bff/infra/email/email.module";
|
|||||||
import { SimManagementModule } from "./sim-management/sim-management.module";
|
import { SimManagementModule } from "./sim-management/sim-management.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [WhmcsModule, MappingsModule, FreebitModule, EmailModule, SimManagementModule],
|
||||||
WhmcsModule,
|
|
||||||
MappingsModule,
|
|
||||||
FreebitModule,
|
|
||||||
EmailModule,
|
|
||||||
SimManagementModule
|
|
||||||
],
|
|
||||||
controllers: [SubscriptionsController, SimOrdersController],
|
controllers: [SubscriptionsController, SimOrdersController],
|
||||||
providers: [
|
providers: [
|
||||||
SubscriptionsService,
|
SubscriptionsService,
|
||||||
|
|||||||
@ -6,9 +6,7 @@ import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
|||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import { subscriptionSchema } from "@customer-portal/domain/validation/shared/entities";
|
||||||
subscriptionSchema,
|
|
||||||
} from "@customer-portal/domain/validation/shared/entities";
|
|
||||||
import type {
|
import type {
|
||||||
WhmcsProduct,
|
WhmcsProduct,
|
||||||
WhmcsProductsResponse,
|
WhmcsProductsResponse,
|
||||||
|
|||||||
@ -53,7 +53,6 @@ export class UsersService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private validateEmail(email: string): string {
|
private validateEmail(email: string): string {
|
||||||
return normalizeAndValidateEmail(email);
|
return normalizeAndValidateEmail(email);
|
||||||
}
|
}
|
||||||
@ -300,10 +299,15 @@ export class UsersService {
|
|||||||
).length;
|
).length;
|
||||||
recentSubscriptions = subscriptions
|
recentSubscriptions = subscriptions
|
||||||
.filter((sub: Subscription) => sub.status === "Active")
|
.filter((sub: Subscription) => sub.status === "Active")
|
||||||
.sort(
|
.sort((a: Subscription, b: Subscription) => {
|
||||||
(a: Subscription, b: Subscription) =>
|
const aTime = a.registrationDate
|
||||||
new Date(b.registrationDate).getTime() - new Date(a.registrationDate).getTime()
|
? 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)
|
.slice(0, 3)
|
||||||
.map((sub: Subscription) => ({
|
.map((sub: Subscription) => ({
|
||||||
id: sub.id.toString(),
|
id: sub.id.toString(),
|
||||||
@ -343,10 +347,11 @@ export class UsersService {
|
|||||||
.filter(
|
.filter(
|
||||||
(inv: Invoice) => (inv.status === "Unpaid" || inv.status === "Overdue") && inv.dueDate
|
(inv: Invoice) => (inv.status === "Unpaid" || inv.status === "Overdue") && inv.dueDate
|
||||||
)
|
)
|
||||||
.sort(
|
.sort((a: Invoice, b: Invoice) => {
|
||||||
(a: Invoice, b: Invoice) =>
|
const aTime = a.dueDate ? new Date(a.dueDate).getTime() : Number.POSITIVE_INFINITY;
|
||||||
new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime()
|
const bTime = b.dueDate ? new Date(b.dueDate).getTime() : Number.POSITIVE_INFINITY;
|
||||||
);
|
return aTime - bTime;
|
||||||
|
});
|
||||||
|
|
||||||
if (upcomingInvoices.length > 0) {
|
if (upcomingInvoices.length > 0) {
|
||||||
const invoice = upcomingInvoices[0];
|
const invoice = upcomingInvoices[0];
|
||||||
@ -360,10 +365,11 @@ export class UsersService {
|
|||||||
|
|
||||||
// Recent invoices for activity
|
// Recent invoices for activity
|
||||||
recentInvoices = invoices
|
recentInvoices = invoices
|
||||||
.sort(
|
.sort((a: Invoice, b: Invoice) => {
|
||||||
(a: Invoice, b: Invoice) =>
|
const aTime = a.issuedAt ? new Date(a.issuedAt).getTime() : Number.NEGATIVE_INFINITY;
|
||||||
new Date(b.issuedAt || "").getTime() - new Date(a.issuedAt || "").getTime()
|
const bTime = b.issuedAt ? new Date(b.issuedAt).getTime() : Number.NEGATIVE_INFINITY;
|
||||||
)
|
return bTime - aTime;
|
||||||
|
})
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
.map((inv: Invoice) => ({
|
.map((inv: Invoice) => ({
|
||||||
id: inv.id.toString(),
|
id: inv.id.toString(),
|
||||||
|
|||||||
@ -7,10 +7,10 @@ export const apiClient = {
|
|||||||
postCalls.push([path, options]);
|
postCalls.push([path, options]);
|
||||||
return { data: null } as const;
|
return { data: null } as const;
|
||||||
},
|
},
|
||||||
GET: async () => ({ data: null } as const),
|
GET: async () => ({ data: null }) as const,
|
||||||
PUT: async () => ({ data: null } as const),
|
PUT: async () => ({ data: null }) as const,
|
||||||
PATCH: async () => ({ data: null } as const),
|
PATCH: async () => ({ data: null }) as const,
|
||||||
DELETE: async () => ({ data: null } as const),
|
DELETE: async () => ({ data: null }) as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const configureApiClientAuth = () => undefined;
|
export const configureApiClientAuth = () => undefined;
|
||||||
|
|||||||
@ -93,7 +93,9 @@ const { useAuthStore } = require("../src/features/auth/services/auth.store.ts");
|
|||||||
|
|
||||||
const [endpoint, options] = coreApiStub.postCalls[0];
|
const [endpoint, options] = coreApiStub.postCalls[0];
|
||||||
if (endpoint !== "/auth/request-password-reset") {
|
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") {
|
if (!options || typeof options !== "object") {
|
||||||
|
|||||||
@ -39,4 +39,3 @@ export default function AccountLoading() {
|
|||||||
</RouteLoading>
|
</RouteLoading>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,4 +18,3 @@ export default function CatalogLoading() {
|
|||||||
</RouteLoading>
|
</RouteLoading>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -21,5 +21,3 @@ export default function CheckoutLoading() {
|
|||||||
</RouteLoading>
|
</RouteLoading>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -26,4 +26,3 @@ export default function DashboardLoading() {
|
|||||||
</RouteLoading>
|
</RouteLoading>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,4 +4,3 @@ import { AppShell } from "@/components/organisms";
|
|||||||
export default function PortalLayout({ children }: { children: ReactNode }) {
|
export default function PortalLayout({ children }: { children: ReactNode }) {
|
||||||
return <AppShell>{children}</AppShell>;
|
return <AppShell>{children}</AppShell>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -14,5 +14,3 @@ export default function SupportCasesLoading() {
|
|||||||
</RouteLoading>
|
</RouteLoading>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -27,5 +27,3 @@ export default function NewSupportLoading() {
|
|||||||
</RouteLoading>
|
</RouteLoading>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -12,5 +12,3 @@ export default function AuthSegmentLoading() {
|
|||||||
</AuthLayout>
|
</AuthLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -78,15 +78,24 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((p
|
|||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border border-current border-t-transparent" />
|
<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}
|
{!loading && rightIcon ? <span className="ml-1">{rightIcon}</span> : null}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { className, variant, size, as: _as, disabled, ...buttonProps } = rest as ButtonAsButtonProps;
|
const {
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
as: _as,
|
||||||
|
disabled,
|
||||||
|
...buttonProps
|
||||||
|
} = rest as ButtonAsButtonProps;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
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">
|
<span className="inline-flex items-center gap-2">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border border-current border-t-transparent" />
|
<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}
|
{!loading && rightIcon ? <span className="ml-1">{rightIcon}</span> : null}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { cn } from "@/lib/utils";
|
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;
|
label?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
helperText?: string;
|
helperText?: string;
|
||||||
@ -42,12 +42,8 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{helperText && !error && (
|
{helperText && !error && <p className="text-xs text-gray-500">{helperText}</p>}
|
||||||
<p className="text-xs text-gray-500">{helperText}</p>
|
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<p className="text-xs text-red-600">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,8 +15,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
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",
|
"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 &&
|
isInvalid && "border-red-500 focus-visible:ring-red-500 focus-visible:ring-offset-2",
|
||||||
"border-red-500 focus-visible:ring-red-500 focus-visible:ring-offset-2",
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
aria-invalid={isInvalid || undefined}
|
aria-invalid={isInvalid || undefined}
|
||||||
|
|||||||
@ -10,23 +10,23 @@ export type StatusPillProps = HTMLAttributes<HTMLSpanElement> & {
|
|||||||
|
|
||||||
export const StatusPill = forwardRef<HTMLSpanElement, StatusPillProps>(
|
export const StatusPill = forwardRef<HTMLSpanElement, StatusPillProps>(
|
||||||
({ label, variant = "neutral", size = "md", icon, className, ...rest }, ref) => {
|
({ label, variant = "neutral", size = "md", icon, className, ...rest }, ref) => {
|
||||||
const tone =
|
const tone =
|
||||||
variant === "success"
|
variant === "success"
|
||||||
? "bg-green-50 text-green-700 ring-green-600/20"
|
? "bg-green-50 text-green-700 ring-green-600/20"
|
||||||
: variant === "warning"
|
: variant === "warning"
|
||||||
? "bg-amber-50 text-amber-700 ring-amber-600/20"
|
? "bg-amber-50 text-amber-700 ring-amber-600/20"
|
||||||
: variant === "info"
|
: variant === "info"
|
||||||
? "bg-blue-50 text-blue-700 ring-blue-600/20"
|
? "bg-blue-50 text-blue-700 ring-blue-600/20"
|
||||||
: variant === "error"
|
: variant === "error"
|
||||||
? "bg-red-50 text-red-700 ring-red-600/20"
|
? "bg-red-50 text-red-700 ring-red-600/20"
|
||||||
: "bg-gray-50 text-gray-700 ring-gray-400/30";
|
: "bg-gray-50 text-gray-700 ring-gray-400/30";
|
||||||
|
|
||||||
const sizing =
|
const sizing =
|
||||||
size === "sm"
|
size === "sm"
|
||||||
? "px-2 py-0.5 text-xs"
|
? "px-2 py-0.5 text-xs"
|
||||||
: size === "lg"
|
: size === "lg"
|
||||||
? "px-4 py-1.5 text-sm"
|
? "px-4 py-1.5 text-sm"
|
||||||
: "px-3 py-1 text-xs";
|
: "px-3 py-1 text-xs";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
// Atoms - Basic building blocks
|
// Atoms - Basic building blocks
|
||||||
export * from "./atoms";
|
export * from "./atoms";
|
||||||
|
|
||||||
// Molecules - Combinations of atoms
|
// Molecules - Combinations of atoms
|
||||||
export * from "./molecules";
|
export * from "./molecules";
|
||||||
|
|
||||||
// Organisms - Complex UI sections
|
// Organisms - Complex UI sections
|
||||||
|
|||||||
@ -62,13 +62,15 @@ const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
|
|||||||
</Label>
|
</Label>
|
||||||
)}
|
)}
|
||||||
{children ? (
|
{children ? (
|
||||||
isValidElement(children)
|
isValidElement(children) ? (
|
||||||
? cloneElement(children, {
|
cloneElement(children, {
|
||||||
id,
|
id,
|
||||||
"aria-invalid": error ? "true" : undefined,
|
"aria-invalid": error ? "true" : undefined,
|
||||||
"aria-describedby": cn(errorId, helperTextId) || undefined,
|
"aria-describedby": cn(errorId, helperTextId) || undefined,
|
||||||
} as Record<string, unknown>)
|
} as Record<string, unknown>)
|
||||||
: children
|
) : (
|
||||||
|
children
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
@ -76,8 +78,7 @@ const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
|
|||||||
aria-invalid={error ? "true" : undefined}
|
aria-invalid={error ? "true" : undefined}
|
||||||
aria-describedby={cn(errorId, helperTextId) || undefined}
|
aria-describedby={cn(errorId, helperTextId) || undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
error &&
|
error && "border-red-500 focus-visible:ring-red-500 focus-visible:ring-offset-2",
|
||||||
"border-red-500 focus-visible:ring-red-500 focus-visible:ring-offset-2",
|
|
||||||
inputClassName,
|
inputClassName,
|
||||||
inputProps.className
|
inputProps.className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -10,17 +10,17 @@ interface RouteLoadingProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Shared route-level loading wrapper used by segment loading.tsx files
|
// 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
|
// Always use PageLayout with loading state for consistent skeleton loading
|
||||||
return (
|
return (
|
||||||
<PageLayout
|
<PageLayout icon={icon} title={title} description={description} loading={mode === "skeleton"}>
|
||||||
icon={icon}
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
loading={mode === "skeleton"}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ interface SectionHeaderProps {
|
|||||||
|
|
||||||
export function SectionHeader({ title, children, className }: SectionHeaderProps) {
|
export function SectionHeader({ title, children, className }: SectionHeaderProps) {
|
||||||
return (
|
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>
|
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@ -18,5 +18,3 @@ export function SectionHeader({ title, children, className }: SectionHeaderProps
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type { SectionHeaderProps };
|
export type { SectionHeaderProps };
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -29,13 +29,13 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
|||||||
|
|
||||||
override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
// Log to external error service in production
|
// 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.)
|
// TODO: Send to error tracking service (Sentry, LogRocket, etc.)
|
||||||
} else {
|
} else {
|
||||||
log.error("ErrorBoundary caught an error", {
|
log.error("ErrorBoundary caught an error", {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
stack: error.stack,
|
stack: error.stack,
|
||||||
componentStack: errorInfo.componentStack
|
componentStack: errorInfo.componentStack,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.props.onError?.(error, errorInfo);
|
this.props.onError?.(error, errorInfo);
|
||||||
|
|||||||
@ -24,5 +24,3 @@ export * from "./AnimatedCard/AnimatedCard";
|
|||||||
|
|
||||||
// Performance and lazy loading utilities
|
// Performance and lazy loading utilities
|
||||||
export { ErrorBoundary } from "./error-boundary";
|
export { ErrorBoundary } from "./error-boundary";
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -42,7 +42,7 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
const parsed = JSON.parse(saved) as unknown;
|
const parsed = JSON.parse(saved) as unknown;
|
||||||
if (Array.isArray(parsed) && parsed.every(x => typeof x === "string")) {
|
if (Array.isArray(parsed) && parsed.every(x => typeof x === "string")) {
|
||||||
setExpandedItems(prev => {
|
setExpandedItems(prev => {
|
||||||
const next = parsed as string[];
|
const next = parsed;
|
||||||
if (next.length === prev.length && next.every(v => prev.includes(v))) return prev;
|
if (next.length === prev.length && next.every(v => prev.includes(v))) return prev;
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -12,7 +12,9 @@ interface HeaderProps {
|
|||||||
|
|
||||||
export const Header = memo(function Header({ onMenuClick, user, profileReady }: HeaderProps) {
|
export const Header = memo(function Header({ onMenuClick, user, profileReady }: HeaderProps) {
|
||||||
const displayName = profileReady
|
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";
|
: user?.email?.split("@")[0] || "Account";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -29,7 +29,9 @@ export const Sidebar = memo(function Sidebar({
|
|||||||
<Logo size={20} />
|
<Logo size={20} />
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p className="text-xs text-[var(--cp-sidebar-text)]/60">Customer Portal</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -221,4 +223,3 @@ const NavigationItem = memo(function NavigationItem({
|
|||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -87,4 +87,3 @@ export function truncate(text: string, max: number): string {
|
|||||||
if (text.length <= max) return text;
|
if (text.length <= max) return text;
|
||||||
return text.slice(0, Math.max(0, max - 1)) + "…";
|
return text.slice(0, Math.max(0, max - 1)) + "…";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2
apps/portal/src/components/templates/AuthLayout/index.ts
Normal file
2
apps/portal/src/components/templates/AuthLayout/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { AuthLayout } from "./AuthLayout";
|
||||||
|
export type { AuthLayoutProps } from "./AuthLayout";
|
||||||
2
apps/portal/src/components/templates/PageLayout/index.ts
Normal file
2
apps/portal/src/components/templates/PageLayout/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { PageLayout } from "./PageLayout";
|
||||||
|
export type { BreadcrumbItem } from "./PageLayout";
|
||||||
@ -8,4 +8,3 @@ export type { AuthLayoutProps } from "./AuthLayout/AuthLayout";
|
|||||||
|
|
||||||
export { PageLayout } from "./PageLayout/PageLayout";
|
export { PageLayout } from "./PageLayout/PageLayout";
|
||||||
export type { BreadcrumbItem } from "./PageLayout/PageLayout";
|
export type { BreadcrumbItem } from "./PageLayout/PageLayout";
|
||||||
|
|
||||||
|
|||||||
@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { accountService } from "@/features/account/services/account.service";
|
import { accountService } from "@/features/account/services/account.service";
|
||||||
import {
|
import {
|
||||||
addressFormSchema,
|
addressFormSchema,
|
||||||
addressFormToRequest,
|
addressFormToRequest,
|
||||||
type AddressFormData
|
type AddressFormData,
|
||||||
} from "@customer-portal/domain";
|
} from "@customer-portal/domain";
|
||||||
import { useZodForm } from "@customer-portal/validation";
|
import { useZodForm } from "@customer-portal/validation";
|
||||||
|
|
||||||
|
|||||||
@ -3,10 +3,10 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { accountService } from "@/features/account/services/account.service";
|
import { accountService } from "@/features/account/services/account.service";
|
||||||
import { useAuthStore } from "@/features/auth/services/auth.store";
|
import { useAuthStore } from "@/features/auth/services/auth.store";
|
||||||
import {
|
import {
|
||||||
profileEditFormSchema,
|
profileEditFormSchema,
|
||||||
profileFormToRequest,
|
profileFormToRequest,
|
||||||
type ProfileEditFormData
|
type ProfileEditFormData,
|
||||||
} from "@customer-portal/domain";
|
} from "@customer-portal/domain";
|
||||||
import { useZodForm } from "@customer-portal/validation";
|
import { useZodForm } from "@customer-portal/validation";
|
||||||
|
|
||||||
@ -15,7 +15,7 @@ export function useProfileEdit(initial: ProfileEditFormData) {
|
|||||||
try {
|
try {
|
||||||
const requestData = profileFormToRequest(formData);
|
const requestData = profileFormToRequest(formData);
|
||||||
const updated = await accountService.updateProfile(requestData);
|
const updated = await accountService.updateProfile(requestData);
|
||||||
|
|
||||||
useAuthStore.setState(state => ({
|
useAuthStore.setState(state => ({
|
||||||
...state,
|
...state,
|
||||||
user: state.user ? { ...state.user, ...updated } : state.user,
|
user: state.user ? { ...state.user, ...updated } : state.user,
|
||||||
|
|||||||
@ -9,22 +9,22 @@ type ProfileUpdateInput = {
|
|||||||
|
|
||||||
export const accountService = {
|
export const accountService = {
|
||||||
async getProfile() {
|
async getProfile() {
|
||||||
const response = await apiClient.GET('/api/me');
|
const response = await apiClient.GET<UserProfile>("/api/me");
|
||||||
return getNullableData<UserProfile>(response);
|
return getNullableData<UserProfile>(response);
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateProfile(update: ProfileUpdateInput) {
|
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");
|
return getDataOrThrow<UserProfile>(response, "Failed to update profile");
|
||||||
},
|
},
|
||||||
|
|
||||||
async getAddress() {
|
async getAddress() {
|
||||||
const response = await apiClient.GET('/api/me/address');
|
const response = await apiClient.GET<Address>("/api/me/address");
|
||||||
return getNullableData<Address>(response);
|
return getNullableData<Address>(response);
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateAddress(address: Address) {
|
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");
|
return getDataOrThrow<Address>(response, "Failed to update address");
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,7 +3,13 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
|
import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
|
||||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
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 { useAuthStore } from "@/features/auth/services/auth.store";
|
||||||
import { accountService } from "@/features/account/services/account.service";
|
import { accountService } from "@/features/account/services/account.service";
|
||||||
import { useProfileEdit } from "@/features/account/hooks/useProfileEdit";
|
import { useProfileEdit } from "@/features/account/hooks/useProfileEdit";
|
||||||
@ -245,11 +251,14 @@ export default function ProfileContainer() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void profile.handleSubmit().then(() => {
|
void profile
|
||||||
setEditingProfile(false);
|
.handleSubmit()
|
||||||
}).catch(() => {
|
.then(() => {
|
||||||
// Error is handled by useZodForm
|
setEditingProfile(false);
|
||||||
});
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Error is handled by useZodForm
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
disabled={profile.isSubmitting}
|
disabled={profile.isSubmitting}
|
||||||
>
|
>
|
||||||
@ -299,12 +308,12 @@ export default function ProfileContainer() {
|
|||||||
country: address.values.country,
|
country: address.values.country,
|
||||||
}}
|
}}
|
||||||
onChange={a => {
|
onChange={a => {
|
||||||
address.setValue("street", a.street);
|
address.setValue("street", a.street ?? "");
|
||||||
address.setValue("streetLine2", a.streetLine2);
|
address.setValue("streetLine2", a.streetLine2 ?? "");
|
||||||
address.setValue("city", a.city);
|
address.setValue("city", a.city ?? "");
|
||||||
address.setValue("state", a.state);
|
address.setValue("state", a.state ?? "");
|
||||||
address.setValue("postalCode", a.postalCode);
|
address.setValue("postalCode", a.postalCode ?? "");
|
||||||
address.setValue("country", a.country);
|
address.setValue("country", a.country ?? "");
|
||||||
}}
|
}}
|
||||||
title="Mailing Address"
|
title="Mailing Address"
|
||||||
/>
|
/>
|
||||||
@ -321,11 +330,14 @@ export default function ProfileContainer() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void address.handleSubmit().then(() => {
|
void address
|
||||||
setEditingAddress(false);
|
.handleSubmit()
|
||||||
}).catch(() => {
|
.then(() => {
|
||||||
// Error is handled by useZodForm
|
setEditingAddress(false);
|
||||||
});
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Error is handled by useZodForm
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
disabled={address.isSubmitting}
|
disabled={address.isSubmitting}
|
||||||
>
|
>
|
||||||
@ -353,7 +365,9 @@ export default function ProfileContainer() {
|
|||||||
{address.values.street || address.values.city ? (
|
{address.values.street || address.values.city ? (
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
<div className="text-gray-900 space-y-1">
|
<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>}
|
{address.values.streetLine2 && <p>{address.values.streetLine2}</p>}
|
||||||
<p>
|
<p>
|
||||||
{[address.values.city, address.values.state, address.values.postalCode]
|
{[address.values.city, address.values.state, address.values.postalCode]
|
||||||
@ -378,5 +392,3 @@ export default function ProfileContainer() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -19,28 +19,25 @@ interface LinkWhmcsFormProps {
|
|||||||
export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormProps) {
|
export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormProps) {
|
||||||
const { linkWhmcs, loading, error, clearError } = useWhmcsLink();
|
const { linkWhmcs, loading, error, clearError } = useWhmcsLink();
|
||||||
|
|
||||||
const handleLink = useCallback(async (formData: LinkWhmcsFormData) => {
|
const handleLink = useCallback(
|
||||||
clearError();
|
async (formData: LinkWhmcsFormData) => {
|
||||||
try {
|
clearError();
|
||||||
const payload: LinkWhmcsRequestData = {
|
try {
|
||||||
email: formData.email,
|
const payload: LinkWhmcsRequestData = {
|
||||||
password: formData.password,
|
email: formData.email,
|
||||||
};
|
password: formData.password,
|
||||||
const result = await linkWhmcs(payload);
|
};
|
||||||
onTransferred?.({ ...result, email: formData.email });
|
const result = await linkWhmcs(payload);
|
||||||
} catch (err) {
|
onTransferred?.({ ...result, email: formData.email });
|
||||||
// Error is handled by useZodForm
|
} catch (err) {
|
||||||
throw err;
|
// Error is handled by useZodForm
|
||||||
}
|
throw err;
|
||||||
}, [linkWhmcs, onTransferred, clearError]);
|
}
|
||||||
|
},
|
||||||
|
[linkWhmcs, onTransferred, clearError]
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const { values, errors, isSubmitting, setValue, handleSubmit } = useZodForm({
|
||||||
values,
|
|
||||||
errors,
|
|
||||||
isSubmitting,
|
|
||||||
setValue,
|
|
||||||
handleSubmit,
|
|
||||||
} = useZodForm({
|
|
||||||
schema: linkWhmcsRequestSchema,
|
schema: linkWhmcsRequestSchema,
|
||||||
initialValues: {
|
initialValues: {
|
||||||
email: "",
|
email: "",
|
||||||
@ -53,56 +50,38 @@ export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormPr
|
|||||||
<div className={`w-full max-w-md mx-auto ${className}`}>
|
<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="bg-white shadow-sm rounded-lg border border-gray-200 p-6">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-2">
|
<h2 className="text-lg font-semibold text-gray-900 mb-2">Link Your WHMCS Account</h2>
|
||||||
Link Your WHMCS Account
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
Enter your existing WHMCS credentials to link your account and migrate your data.
|
Enter your existing WHMCS credentials to link your account and migrate your data.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<FormField
|
<FormField label="Email Address" error={errors.email} required>
|
||||||
label="Email Address"
|
|
||||||
error={errors.email}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
value={values.email}
|
value={values.email}
|
||||||
onChange={(e) => setValue("email", e.target.value)}
|
onChange={e => setValue("email", e.target.value)}
|
||||||
placeholder="Enter your WHMCS email"
|
placeholder="Enter your WHMCS email"
|
||||||
disabled={isSubmitting || loading}
|
disabled={isSubmitting || loading}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField
|
<FormField label="Password" error={errors.password} required>
|
||||||
label="Password"
|
|
||||||
error={errors.password}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={values.password}
|
value={values.password}
|
||||||
onChange={(e) => setValue("password", e.target.value)}
|
onChange={e => setValue("password", e.target.value)}
|
||||||
placeholder="Enter your WHMCS password"
|
placeholder="Enter your WHMCS password"
|
||||||
disabled={isSubmitting || loading}
|
disabled={isSubmitting || loading}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{error && (
|
{error && <ErrorMessage className="text-center">{error}</ErrorMessage>}
|
||||||
<ErrorMessage className="text-center">
|
|
||||||
{error}
|
|
||||||
</ErrorMessage>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
<Button type="submit" disabled={isSubmitting || loading} className="w-full">
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting || loading}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{isSubmitting || loading ? (
|
{isSubmitting || loading ? (
|
||||||
<>
|
<>
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
|
|||||||
@ -10,12 +10,9 @@ import Link from "next/link";
|
|||||||
import { Button, Input, ErrorMessage } from "@/components/atoms";
|
import { Button, Input, ErrorMessage } from "@/components/atoms";
|
||||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
import { FormField } from "@/components/molecules/FormField/FormField";
|
||||||
import { useLogin } from "../../hooks/use-auth";
|
import { useLogin } from "../../hooks/use-auth";
|
||||||
import {
|
import { loginFormSchema, loginFormToRequest, type LoginFormData } from "@customer-portal/domain";
|
||||||
loginFormSchema,
|
|
||||||
loginFormToRequest,
|
|
||||||
type LoginFormData
|
|
||||||
} from "@customer-portal/domain";
|
|
||||||
import { useZodForm } from "@customer-portal/validation";
|
import { useZodForm } from "@customer-portal/validation";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
interface LoginFormProps {
|
interface LoginFormProps {
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
@ -25,6 +22,12 @@ interface LoginFormProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loginSchema = loginFormSchema.extend({
|
||||||
|
rememberMe: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type LoginFormValues = z.infer<typeof loginSchema>;
|
||||||
|
|
||||||
export function LoginForm({
|
export function LoginForm({
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onError,
|
onError,
|
||||||
@ -34,18 +37,21 @@ export function LoginForm({
|
|||||||
}: LoginFormProps) {
|
}: LoginFormProps) {
|
||||||
const { login, loading, error, clearError } = useLogin();
|
const { login, loading, error, clearError } = useLogin();
|
||||||
|
|
||||||
const handleLogin = useCallback(async (formData: LoginFormData) => {
|
const handleLogin = useCallback(
|
||||||
clearError();
|
async ({ rememberMe: _rememberMe, ...formData }: LoginFormValues) => {
|
||||||
try {
|
clearError();
|
||||||
const requestData = loginFormToRequest(formData);
|
try {
|
||||||
await login(requestData);
|
const requestData = loginFormToRequest(formData);
|
||||||
onSuccess?.();
|
await login(requestData);
|
||||||
} catch (err) {
|
onSuccess?.();
|
||||||
const message = err instanceof Error ? err.message : "Login failed";
|
} catch (err) {
|
||||||
onError?.(message);
|
const message = err instanceof Error ? err.message : "Login failed";
|
||||||
throw err; // Re-throw to let useZodForm handle the error state
|
onError?.(message);
|
||||||
}
|
throw err; // Re-throw to let useZodForm handle the error state
|
||||||
}, [login, onSuccess, onError, clearError]);
|
}
|
||||||
|
},
|
||||||
|
[login, onSuccess, onError, clearError]
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
values,
|
values,
|
||||||
@ -56,8 +62,8 @@ export function LoginForm({
|
|||||||
setTouchedField,
|
setTouchedField,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
validateField,
|
validateField,
|
||||||
} = useZodForm({
|
} = useZodForm<LoginFormValues>({
|
||||||
schema: loginFormSchema,
|
schema: loginSchema,
|
||||||
initialValues: {
|
initialValues: {
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
@ -69,15 +75,11 @@ export function LoginForm({
|
|||||||
return (
|
return (
|
||||||
<div className={`w-full max-w-md mx-auto ${className}`}>
|
<div className={`w-full max-w-md mx-auto ${className}`}>
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<FormField
|
<FormField label="Email Address" error={touched.email ? errors.email : undefined} required>
|
||||||
label="Email Address"
|
|
||||||
error={touched.email ? errors.email : undefined}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
value={values.email}
|
value={values.email}
|
||||||
onChange={(e) => setValue("email", e.target.value)}
|
onChange={e => setValue("email", e.target.value)}
|
||||||
onBlur={() => setTouchedField("email")}
|
onBlur={() => setTouchedField("email")}
|
||||||
placeholder="Enter your email"
|
placeholder="Enter your email"
|
||||||
disabled={isSubmitting || loading}
|
disabled={isSubmitting || loading}
|
||||||
@ -85,15 +87,11 @@ export function LoginForm({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField
|
<FormField label="Password" error={touched.password ? errors.password : undefined} required>
|
||||||
label="Password"
|
|
||||||
error={touched.password ? errors.password : undefined}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={values.password}
|
value={values.password}
|
||||||
onChange={(e) => setValue("password", e.target.value)}
|
onChange={e => setValue("password", e.target.value)}
|
||||||
onBlur={() => setTouchedField("password")}
|
onBlur={() => setTouchedField("password")}
|
||||||
placeholder="Enter your password"
|
placeholder="Enter your password"
|
||||||
disabled={isSubmitting || loading}
|
disabled={isSubmitting || loading}
|
||||||
@ -108,7 +106,7 @@ export function LoginForm({
|
|||||||
name="remember-me"
|
name="remember-me"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={values.rememberMe}
|
checked={values.rememberMe}
|
||||||
onChange={(e) => setValue("rememberMe", e.target.checked)}
|
onChange={e => setValue("rememberMe", e.target.checked)}
|
||||||
disabled={isSubmitting || loading}
|
disabled={isSubmitting || loading}
|
||||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
/>
|
/>
|
||||||
@ -129,17 +127,9 @@ export function LoginForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && <ErrorMessage className="text-center">{error}</ErrorMessage>}
|
||||||
<ErrorMessage className="text-center">
|
|
||||||
{error}
|
|
||||||
</ErrorMessage>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
<Button type="submit" disabled={isSubmitting || loading} className="w-full">
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting || loading}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{isSubmitting || loading ? (
|
{isSubmitting || loading ? (
|
||||||
<>
|
<>
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,12 +11,13 @@ import { Button, Input, ErrorMessage } from "@/components/atoms";
|
|||||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
import { FormField } from "@/components/molecules/FormField/FormField";
|
||||||
import { usePasswordReset } from "../../hooks/use-auth";
|
import { usePasswordReset } from "../../hooks/use-auth";
|
||||||
import { useZodForm } from "@customer-portal/validation";
|
import { useZodForm } from "@customer-portal/validation";
|
||||||
import {
|
import {
|
||||||
passwordResetRequestFormSchema,
|
passwordResetRequestFormSchema,
|
||||||
passwordResetFormSchema,
|
passwordResetFormSchema,
|
||||||
type PasswordResetRequestFormData,
|
type PasswordResetRequestFormData,
|
||||||
type PasswordResetFormData
|
type PasswordResetFormData,
|
||||||
} from "@customer-portal/domain";
|
} from "@customer-portal/domain";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
interface PasswordResetFormProps {
|
interface PasswordResetFormProps {
|
||||||
mode: "request" | "reset";
|
mode: "request" | "reset";
|
||||||
@ -38,10 +39,10 @@ export function PasswordResetForm({
|
|||||||
const { requestPasswordReset, resetPassword, loading, error, clearError } = usePasswordReset();
|
const { requestPasswordReset, resetPassword, loading, error, clearError } = usePasswordReset();
|
||||||
|
|
||||||
// Zod form for password reset request
|
// Zod form for password reset request
|
||||||
const requestForm = useZodForm({
|
const requestForm = useZodForm<PasswordResetRequestFormData>({
|
||||||
schema: passwordResetRequestFormSchema,
|
schema: passwordResetRequestFormSchema,
|
||||||
initialValues: { email: "" },
|
initialValues: { email: "" },
|
||||||
onSubmit: async (data) => {
|
onSubmit: async data => {
|
||||||
try {
|
try {
|
||||||
await requestPasswordReset(data.email);
|
await requestPasswordReset(data.email);
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
@ -53,10 +54,26 @@ export function PasswordResetForm({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Zod form for password reset (with confirm password)
|
// Zod form for password reset (with confirm password)
|
||||||
const resetForm = useZodForm({
|
const resetSchema = passwordResetFormSchema
|
||||||
schema: 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: "" },
|
initialValues: { token: token || "", password: "", confirmPassword: "" },
|
||||||
onSubmit: async (data) => {
|
onSubmit: async ({ confirmPassword: _ignore, ...data }) => {
|
||||||
try {
|
try {
|
||||||
await resetPassword(data.token, data.password);
|
await resetPassword(data.token, data.password);
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
@ -95,27 +112,19 @@ export function PasswordResetForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={requestForm.handleSubmit} className="space-y-4">
|
<form onSubmit={requestForm.handleSubmit} className="space-y-4">
|
||||||
<FormField
|
<FormField label="Email address" error={requestForm.errors.email} required>
|
||||||
label="Email address"
|
|
||||||
error={requestForm.errors.email}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="Enter your email"
|
placeholder="Enter your email"
|
||||||
value={requestForm.values.email}
|
value={requestForm.values.email}
|
||||||
onChange={(e) => requestForm.setValue("email", e.target.value)}
|
onChange={e => requestForm.setValue("email", e.target.value)}
|
||||||
onBlur={() => requestForm.validate()}
|
onBlur={() => requestForm.validate()}
|
||||||
disabled={loading || requestForm.isSubmitting}
|
disabled={loading || requestForm.isSubmitting}
|
||||||
className={requestForm.errors.email ? "border-red-300" : ""}
|
className={requestForm.errors.email ? "border-red-300" : ""}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{error && (
|
{error && <ErrorMessage>{error}</ErrorMessage>}
|
||||||
<ErrorMessage>
|
|
||||||
{error}
|
|
||||||
</ErrorMessage>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
@ -129,10 +138,7 @@ export function PasswordResetForm({
|
|||||||
|
|
||||||
{showLoginLink && (
|
{showLoginLink && (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Link
|
<Link href="/login" className="text-sm text-blue-600 hover:text-blue-500 font-medium">
|
||||||
href="/login"
|
|
||||||
className="text-sm text-blue-600 hover:text-blue-500 font-medium"
|
|
||||||
>
|
|
||||||
Back to login
|
Back to login
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@ -146,49 +152,35 @@ export function PasswordResetForm({
|
|||||||
<div className={`space-y-6 ${className}`}>
|
<div className={`space-y-6 ${className}`}>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h2 className="text-2xl font-bold text-gray-900">Set new password</h2>
|
<h2 className="text-2xl font-bold text-gray-900">Set new password</h2>
|
||||||
<p className="mt-2 text-sm text-gray-600">
|
<p className="mt-2 text-sm text-gray-600">Enter your new password below.</p>
|
||||||
Enter your new password below.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={resetForm.handleSubmit} className="space-y-4">
|
<form onSubmit={resetForm.handleSubmit} className="space-y-4">
|
||||||
<FormField
|
<FormField label="New password" error={resetForm.errors.password} required>
|
||||||
label="New password"
|
|
||||||
error={resetForm.errors.password}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Enter new password"
|
placeholder="Enter new password"
|
||||||
value={resetForm.values.password}
|
value={resetForm.values.password}
|
||||||
onChange={(e) => resetForm.setValue("password", e.target.value)}
|
onChange={e => resetForm.setValue("password", e.target.value)}
|
||||||
onBlur={() => resetForm.validate()}
|
onBlur={() => resetForm.validate()}
|
||||||
disabled={loading || resetForm.isSubmitting}
|
disabled={loading || resetForm.isSubmitting}
|
||||||
className={resetForm.errors.password ? "border-red-300" : ""}
|
className={resetForm.errors.password ? "border-red-300" : ""}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField
|
<FormField label="Confirm password" error={resetForm.errors.confirmPassword} required>
|
||||||
label="Confirm password"
|
|
||||||
error={resetForm.errors.confirmPassword}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Confirm new password"
|
placeholder="Confirm new password"
|
||||||
value={resetForm.values.confirmPassword}
|
value={resetForm.values.confirmPassword}
|
||||||
onChange={(e) => resetForm.setValue("confirmPassword", e.target.value)}
|
onChange={e => resetForm.setValue("confirmPassword", e.target.value)}
|
||||||
onBlur={() => resetForm.validate()}
|
onBlur={() => resetForm.validate()}
|
||||||
disabled={loading || resetForm.isSubmitting}
|
disabled={loading || resetForm.isSubmitting}
|
||||||
className={resetForm.errors.confirmPassword ? "border-red-300" : ""}
|
className={resetForm.errors.confirmPassword ? "border-red-300" : ""}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{error && (
|
{error && <ErrorMessage>{error}</ErrorMessage>}
|
||||||
<ErrorMessage>
|
|
||||||
{error}
|
|
||||||
</ErrorMessage>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
@ -202,10 +194,7 @@ export function PasswordResetForm({
|
|||||||
|
|
||||||
{showLoginLink && (
|
{showLoginLink && (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Link
|
<Link href="/login" className="text-sm text-blue-600 hover:text-blue-500 font-medium">
|
||||||
href="/login"
|
|
||||||
className="text-sm text-blue-600 hover:text-blue-500 font-medium"
|
|
||||||
>
|
|
||||||
Back to login
|
Back to login
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -56,9 +56,7 @@ export function SessionTimeoutWarning({
|
|||||||
|
|
||||||
const warningTimeout = setTimeout(() => {
|
const warningTimeout = setTimeout(() => {
|
||||||
setShowWarning(true);
|
setShowWarning(true);
|
||||||
setTimeLeft(
|
setTimeLeft(Math.max(1, Math.ceil((expiryRef.current! - Date.now()) / (60 * 1000))));
|
||||||
Math.max(1, Math.ceil((expiryRef.current! - Date.now()) / (60 * 1000)))
|
|
||||||
);
|
|
||||||
}, timeUntilWarning);
|
}, timeUntilWarning);
|
||||||
|
|
||||||
return () => clearTimeout(warningTimeout);
|
return () => clearTimeout(warningTimeout);
|
||||||
@ -200,4 +198,3 @@ export function SessionTimeoutWarning({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,10 +11,8 @@ import { Button, Input, ErrorMessage } from "@/components/atoms";
|
|||||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
import { FormField } from "@/components/molecules/FormField/FormField";
|
||||||
import { useWhmcsLink } from "../../hooks/use-auth";
|
import { useWhmcsLink } from "../../hooks/use-auth";
|
||||||
import { useZodForm } from "@customer-portal/validation";
|
import { useZodForm } from "@customer-portal/validation";
|
||||||
import {
|
import { setPasswordFormSchema, type SetPasswordFormData } from "@customer-portal/domain";
|
||||||
setPasswordFormSchema,
|
import { z } from "zod";
|
||||||
type SetPasswordFormData
|
|
||||||
} from "@customer-portal/domain";
|
|
||||||
|
|
||||||
interface SetPasswordFormProps {
|
interface SetPasswordFormProps {
|
||||||
email?: string;
|
email?: string;
|
||||||
@ -34,14 +32,30 @@ export function SetPasswordForm({
|
|||||||
const { setPassword, loading, error, clearError } = useWhmcsLink();
|
const { setPassword, loading, error, clearError } = useWhmcsLink();
|
||||||
|
|
||||||
// Zod form with confirm password validation
|
// Zod form with confirm password validation
|
||||||
const form = useZodForm({
|
const formSchema = setPasswordFormSchema
|
||||||
schema: setPasswordFormSchema,
|
.extend({
|
||||||
initialValues: {
|
confirmPassword: z.string().min(1, "Please confirm your password"),
|
||||||
email,
|
})
|
||||||
password: "",
|
.superRefine((data, ctx) => {
|
||||||
confirmPassword: ""
|
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 {
|
try {
|
||||||
await setPassword(data.email, data.password);
|
await setPassword(data.email, data.password);
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
@ -76,59 +90,43 @@ export function SetPasswordForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={form.handleSubmit} className="space-y-4">
|
<form onSubmit={form.handleSubmit} className="space-y-4">
|
||||||
<FormField
|
<FormField label="Email address" error={form.errors.email} required>
|
||||||
label="Email address"
|
|
||||||
error={form.errors.email}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="Enter your email"
|
placeholder="Enter your email"
|
||||||
value={form.values.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)}
|
onBlur={() => form.setTouched("email", true)}
|
||||||
disabled={loading || form.isSubmitting}
|
disabled={loading || form.isSubmitting}
|
||||||
className={form.errors.email ? "border-red-300" : ""}
|
className={form.errors.email ? "border-red-300" : ""}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField
|
<FormField label="Password" error={form.errors.password} required>
|
||||||
label="Password"
|
|
||||||
error={form.errors.password}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Enter your password"
|
placeholder="Enter your password"
|
||||||
value={form.values.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)}
|
onBlur={() => form.setTouched("password", true)}
|
||||||
disabled={loading || form.isSubmitting}
|
disabled={loading || form.isSubmitting}
|
||||||
className={form.errors.password ? "border-red-300" : ""}
|
className={form.errors.password ? "border-red-300" : ""}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField
|
<FormField label="Confirm password" error={form.errors.confirmPassword} required>
|
||||||
label="Confirm password"
|
|
||||||
error={form.errors.confirmPassword}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Confirm your password"
|
placeholder="Confirm your password"
|
||||||
value={form.values.confirmPassword}
|
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)}
|
onBlur={() => form.setTouched("confirmPassword", true)}
|
||||||
disabled={loading || form.isSubmitting}
|
disabled={loading || form.isSubmitting}
|
||||||
className={form.errors.confirmPassword ? "border-red-300" : ""}
|
className={form.errors.confirmPassword ? "border-red-300" : ""}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{(error || form.errors._form) && (
|
{(error || form.errors._form) && <ErrorMessage>{form.errors._form || error}</ErrorMessage>}
|
||||||
<ErrorMessage>
|
|
||||||
{form.errors._form || error}
|
|
||||||
</ErrorMessage>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
@ -142,14 +140,11 @@ export function SetPasswordForm({
|
|||||||
|
|
||||||
{showLoginLink && (
|
{showLoginLink && (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Link
|
<Link href="/login" className="text-sm text-blue-600 hover:text-blue-500 font-medium">
|
||||||
href="/login"
|
|
||||||
className="text-sm text-blue-600 hover:text-blue-500 font-medium"
|
|
||||||
>
|
|
||||||
Back to login
|
Back to login
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,8 +8,8 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Input } from "@/components/atoms";
|
import { Input } from "@/components/atoms";
|
||||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
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 { FormErrors, FormTouched, UseZodFormReturn } from "@customer-portal/validation";
|
||||||
|
import type { SignupFormValues } from "./SignupForm";
|
||||||
|
|
||||||
const COUNTRIES = [
|
const COUNTRIES = [
|
||||||
{ code: "US", name: "United States" },
|
{ code: "US", name: "United States" },
|
||||||
@ -35,11 +35,11 @@ const COUNTRIES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
interface AddressStepProps {
|
interface AddressStepProps {
|
||||||
address: SignupFormData["address"];
|
address: SignupFormValues["address"];
|
||||||
errors: FormErrors<SignupFormData>;
|
errors: FormErrors<SignupFormValues>;
|
||||||
touched: FormTouched<SignupFormData>;
|
touched: FormTouched<SignupFormValues>;
|
||||||
onAddressChange: (address: SignupFormData["address"]) => void;
|
onAddressChange: (address: SignupFormValues["address"]) => void;
|
||||||
setTouchedField: UseZodFormReturn<SignupFormData>["setTouchedField"];
|
setTouchedField: UseZodFormReturn<SignupFormValues>["setTouchedField"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddressStep({
|
export function AddressStep({
|
||||||
@ -49,20 +49,26 @@ export function AddressStep({
|
|||||||
onAddressChange,
|
onAddressChange,
|
||||||
setTouchedField,
|
setTouchedField,
|
||||||
}: AddressStepProps) {
|
}: AddressStepProps) {
|
||||||
const updateAddressField = useCallback((field: keyof SignupFormData["address"], value: string) => {
|
const updateAddressField = useCallback(
|
||||||
onAddressChange({ ...address, [field]: value });
|
(field: keyof SignupFormValues["address"], value: string) => {
|
||||||
}, [address, onAddressChange]);
|
onAddressChange({ ...address, [field]: value });
|
||||||
|
},
|
||||||
|
[address, onAddressChange]
|
||||||
|
);
|
||||||
|
|
||||||
const getFieldError = useCallback((field: keyof SignupFormData["address"]) => {
|
const getFieldError = useCallback(
|
||||||
const fieldKey = `address.${field as string}`;
|
(field: keyof SignupFormValues["address"]) => {
|
||||||
const isTouched = touched[fieldKey] ?? touched.address;
|
const fieldKey = `address.${field as string}`;
|
||||||
|
const isTouched = touched[fieldKey] ?? touched.address;
|
||||||
|
|
||||||
if (!isTouched) {
|
if (!isTouched) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return errors[fieldKey] ?? errors[field as string] ?? errors.address;
|
return errors[fieldKey] ?? errors[field as string] ?? errors.address;
|
||||||
}, [errors, touched]);
|
},
|
||||||
|
[errors, touched]
|
||||||
|
);
|
||||||
|
|
||||||
const markTouched = useCallback(() => {
|
const markTouched = useCallback(() => {
|
||||||
setTouchedField("address");
|
setTouchedField("address");
|
||||||
@ -70,29 +76,22 @@ export function AddressStep({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<FormField
|
<FormField label="Street Address" error={getFieldError("street")} required>
|
||||||
label="Street Address"
|
|
||||||
error={getFieldError("street")}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={address.street}
|
value={address.street}
|
||||||
onChange={(e) => updateAddressField("street", e.target.value)}
|
onChange={e => updateAddressField("street", e.target.value)}
|
||||||
onBlur={markTouched}
|
onBlur={markTouched}
|
||||||
placeholder="Enter your street address"
|
placeholder="Enter your street address"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField
|
<FormField label="Address Line 2 (Optional)" error={getFieldError("streetLine2")}>
|
||||||
label="Address Line 2 (Optional)"
|
|
||||||
error={getFieldError("streetLine2")}
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={address.streetLine2 || ""}
|
value={address.streetLine2 || ""}
|
||||||
onChange={(e) => updateAddressField("streetLine2", e.target.value)}
|
onChange={e => updateAddressField("streetLine2", e.target.value)}
|
||||||
onBlur={markTouched}
|
onBlur={markTouched}
|
||||||
placeholder="Apartment, suite, etc."
|
placeholder="Apartment, suite, etc."
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@ -100,30 +99,22 @@ export function AddressStep({
|
|||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
<FormField
|
<FormField label="City" error={getFieldError("city")} required>
|
||||||
label="City"
|
|
||||||
error={getFieldError("city")}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={address.city}
|
value={address.city}
|
||||||
onChange={(e) => updateAddressField("city", e.target.value)}
|
onChange={e => updateAddressField("city", e.target.value)}
|
||||||
onBlur={markTouched}
|
onBlur={markTouched}
|
||||||
placeholder="Enter your city"
|
placeholder="Enter your city"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField
|
<FormField label="State/Province" error={getFieldError("state")} required>
|
||||||
label="State/Province"
|
|
||||||
error={getFieldError("state")}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={address.state}
|
value={address.state}
|
||||||
onChange={(e) => updateAddressField("state", e.target.value)}
|
onChange={e => updateAddressField("state", e.target.value)}
|
||||||
onBlur={markTouched}
|
onBlur={markTouched}
|
||||||
placeholder="Enter your state/province"
|
placeholder="Enter your state/province"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@ -132,34 +123,26 @@ export function AddressStep({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
<FormField
|
<FormField label="Postal Code" error={getFieldError("postalCode")} required>
|
||||||
label="Postal Code"
|
|
||||||
error={getFieldError("postalCode")}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={address.postalCode}
|
value={address.postalCode}
|
||||||
onChange={(e) => updateAddressField("postalCode", e.target.value)}
|
onChange={e => updateAddressField("postalCode", e.target.value)}
|
||||||
onBlur={markTouched}
|
onBlur={markTouched}
|
||||||
placeholder="Enter your postal code"
|
placeholder="Enter your postal code"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField
|
<FormField label="Country" error={getFieldError("country")} required>
|
||||||
label="Country"
|
|
||||||
error={getFieldError("country")}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<select
|
<select
|
||||||
value={address.country}
|
value={address.country}
|
||||||
onChange={(e) => updateAddressField("country", e.target.value)}
|
onChange={e => updateAddressField("country", e.target.value)}
|
||||||
onBlur={markTouched}
|
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"
|
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>
|
<option value="">Select a country</option>
|
||||||
{COUNTRIES.map((country) => (
|
{COUNTRIES.map(country => (
|
||||||
<option key={country.code} value={country.name}>
|
<option key={country.code} value={country.name}>
|
||||||
{country.name}
|
{country.name}
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@ -5,14 +5,16 @@
|
|||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Input, Checkbox } from "@/components/atoms";
|
import { Input } from "@/components/atoms";
|
||||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
import { FormField } from "@/components/molecules/FormField/FormField";
|
||||||
import { type SignupFormData } from "@customer-portal/domain";
|
|
||||||
import type { UseZodFormReturn } from "@customer-portal/validation";
|
import type { UseZodFormReturn } from "@customer-portal/validation";
|
||||||
|
import type { SignupFormValues } from "./SignupForm";
|
||||||
|
|
||||||
interface PasswordStepProps extends Pick<UseZodFormReturn<SignupFormData>,
|
interface PasswordStepProps
|
||||||
'values' | 'errors' | 'touched' | 'setValue' | 'setTouchedField'> {
|
extends Pick<
|
||||||
}
|
UseZodFormReturn<SignupFormValues>,
|
||||||
|
"values" | "errors" | "touched" | "setValue" | "setTouchedField"
|
||||||
|
> {}
|
||||||
|
|
||||||
export function PasswordStep({
|
export function PasswordStep({
|
||||||
values,
|
values,
|
||||||
@ -32,7 +34,7 @@ export function PasswordStep({
|
|||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={values.password}
|
value={values.password}
|
||||||
onChange={(e) => setValue("password", e.target.value)}
|
onChange={e => setValue("password", e.target.value)}
|
||||||
onBlur={() => setTouchedField("password")}
|
onBlur={() => setTouchedField("password")}
|
||||||
placeholder="Create a secure password"
|
placeholder="Create a secure password"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@ -47,7 +49,7 @@ export function PasswordStep({
|
|||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={values.confirmPassword}
|
value={values.confirmPassword}
|
||||||
onChange={(e) => setValue("confirmPassword", e.target.value)}
|
onChange={e => setValue("confirmPassword", e.target.value)}
|
||||||
onBlur={() => setTouchedField("confirmPassword")}
|
onBlur={() => setTouchedField("confirmPassword")}
|
||||||
placeholder="Confirm your password"
|
placeholder="Confirm your password"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@ -62,7 +64,7 @@ export function PasswordStep({
|
|||||||
name="accept-terms"
|
name="accept-terms"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={values.acceptTerms}
|
checked={values.acceptTerms}
|
||||||
onChange={(e) => setValue("acceptTerms", e.target.checked)}
|
onChange={e => setValue("acceptTerms", e.target.checked)}
|
||||||
onBlur={() => setTouchedField("acceptTerms")}
|
onBlur={() => setTouchedField("acceptTerms")}
|
||||||
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded"
|
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"
|
name="marketing-consent"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={values.marketingConsent}
|
checked={values.marketingConsent}
|
||||||
onChange={(e) => setValue("marketingConsent", e.target.checked)}
|
onChange={e => setValue("marketingConsent", e.target.checked)}
|
||||||
onBlur={() => setTouchedField("marketingConsent")}
|
onBlur={() => setTouchedField("marketingConsent")}
|
||||||
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded"
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,15 +7,15 @@
|
|||||||
|
|
||||||
import { Input } from "@/components/atoms";
|
import { Input } from "@/components/atoms";
|
||||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
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 { FormErrors, FormTouched, UseZodFormReturn } from "@customer-portal/validation";
|
||||||
|
import type { SignupFormValues } from "./SignupForm";
|
||||||
|
|
||||||
interface PersonalStepProps {
|
interface PersonalStepProps {
|
||||||
values: SignupFormData;
|
values: SignupFormValues;
|
||||||
errors: FormErrors<SignupFormData>;
|
errors: FormErrors<SignupFormValues>;
|
||||||
touched: FormTouched<SignupFormData>;
|
touched: FormTouched<SignupFormValues>;
|
||||||
setValue: UseZodFormReturn<SignupFormData>["setValue"];
|
setValue: UseZodFormReturn<SignupFormValues>["setValue"];
|
||||||
setTouchedField: UseZodFormReturn<SignupFormData>["setTouchedField"];
|
setTouchedField: UseZodFormReturn<SignupFormValues>["setTouchedField"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PersonalStep({
|
export function PersonalStep({
|
||||||
@ -25,37 +25,29 @@ export function PersonalStep({
|
|||||||
setValue,
|
setValue,
|
||||||
setTouchedField,
|
setTouchedField,
|
||||||
}: PersonalStepProps) {
|
}: PersonalStepProps) {
|
||||||
const getError = (field: keyof SignupFormData) => {
|
const getError = (field: keyof SignupFormValues) => {
|
||||||
return touched[field as string] ? errors[field as string] : undefined;
|
return touched[field as string] ? errors[field as string] : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
<FormField
|
<FormField label="First Name" error={getError("firstName")} required>
|
||||||
label="First Name"
|
|
||||||
error={getError("firstName")}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={values.firstName}
|
value={values.firstName}
|
||||||
onChange={(e) => setValue("firstName", e.target.value)}
|
onChange={e => setValue("firstName", e.target.value)}
|
||||||
onBlur={() => setTouchedField("firstName")}
|
onBlur={() => setTouchedField("firstName")}
|
||||||
placeholder="Enter your first name"
|
placeholder="Enter your first name"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField
|
<FormField label="Last Name" error={getError("lastName")} required>
|
||||||
label="Last Name"
|
|
||||||
error={getError("lastName")}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={values.lastName}
|
value={values.lastName}
|
||||||
onChange={(e) => setValue("lastName", e.target.value)}
|
onChange={e => setValue("lastName", e.target.value)}
|
||||||
onBlur={() => setTouchedField("lastName")}
|
onBlur={() => setTouchedField("lastName")}
|
||||||
placeholder="Enter your last name"
|
placeholder="Enter your last name"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@ -63,30 +55,22 @@ export function PersonalStep({
|
|||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormField
|
<FormField label="Email Address" error={getError("email")} required>
|
||||||
label="Email Address"
|
|
||||||
error={getError("email")}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
value={values.email}
|
value={values.email}
|
||||||
onChange={(e) => setValue("email", e.target.value)}
|
onChange={e => setValue("email", e.target.value)}
|
||||||
onBlur={() => setTouchedField("email")}
|
onBlur={() => setTouchedField("email")}
|
||||||
placeholder="Enter your email address"
|
placeholder="Enter your email address"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField
|
<FormField label="Phone Number" error={getError("phone")} required>
|
||||||
label="Phone Number"
|
|
||||||
error={getError("phone")}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
type="tel"
|
type="tel"
|
||||||
value={values.phone || ""}
|
value={values.phone || ""}
|
||||||
onChange={(e) => setValue("phone", e.target.value)}
|
onChange={e => setValue("phone", e.target.value)}
|
||||||
onBlur={() => setTouchedField("phone")}
|
onBlur={() => setTouchedField("phone")}
|
||||||
placeholder="+81 XX-XXXX-XXXX"
|
placeholder="+81 XX-XXXX-XXXX"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@ -102,21 +86,18 @@ export function PersonalStep({
|
|||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={values.sfNumber}
|
value={values.sfNumber}
|
||||||
onChange={(e) => setValue("sfNumber", e.target.value)}
|
onChange={e => setValue("sfNumber", e.target.value)}
|
||||||
onBlur={() => setTouchedField("sfNumber")}
|
onBlur={() => setTouchedField("sfNumber")}
|
||||||
placeholder="Enter your customer number"
|
placeholder="Enter your customer number"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField
|
<FormField label="Company (Optional)" error={getError("company")}>
|
||||||
label="Company (Optional)"
|
|
||||||
error={getError("company")}
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={values.company || ""}
|
value={values.company || ""}
|
||||||
onChange={(e) => setValue("company", e.target.value)}
|
onChange={e => setValue("company", e.target.value)}
|
||||||
onBlur={() => setTouchedField("company")}
|
onBlur={() => setTouchedField("company")}
|
||||||
placeholder="Enter your company name"
|
placeholder="Enter your company name"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
|||||||
@ -9,12 +9,9 @@ import { useState, useCallback, useMemo } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ErrorMessage } from "@/components/atoms";
|
import { ErrorMessage } from "@/components/atoms";
|
||||||
import { useSignup } from "../../hooks/use-auth";
|
import { useSignup } from "../../hooks/use-auth";
|
||||||
import {
|
import { signupFormSchema, signupFormToRequest, type SignupRequest } from "@customer-portal/domain";
|
||||||
signupFormSchema,
|
|
||||||
signupFormToRequest,
|
|
||||||
type SignupFormData
|
|
||||||
} from "@customer-portal/domain";
|
|
||||||
import { useZodForm } from "@customer-portal/validation";
|
import { useZodForm } from "@customer-portal/validation";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import { MultiStepForm, type FormStep } from "./MultiStepForm";
|
import { MultiStepForm, type FormStep } from "./MultiStepForm";
|
||||||
import { AddressStep } from "./AddressStep";
|
import { AddressStep } from "./AddressStep";
|
||||||
@ -28,6 +25,26 @@ interface SignupFormProps {
|
|||||||
className?: string;
|
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({
|
export function SignupForm({
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onError,
|
onError,
|
||||||
@ -37,18 +54,31 @@ export function SignupForm({
|
|||||||
const { signup, loading, error, clearError } = useSignup();
|
const { signup, loading, error, clearError } = useSignup();
|
||||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||||
|
|
||||||
const handleSignup = useCallback(async (formData: SignupFormData) => {
|
const handleSignup = useCallback(
|
||||||
clearError();
|
async ({
|
||||||
try {
|
confirmPassword: _confirm,
|
||||||
const requestData = signupFormToRequest(formData);
|
acceptTerms,
|
||||||
await signup(requestData);
|
marketingConsent,
|
||||||
onSuccess?.();
|
...formData
|
||||||
} catch (err) {
|
}: SignupFormValues) => {
|
||||||
const message = err instanceof Error ? err.message : "Signup failed";
|
clearError();
|
||||||
onError?.(message);
|
try {
|
||||||
throw err; // Re-throw to let useZodForm handle the error state
|
const baseRequest = signupFormToRequest(formData);
|
||||||
}
|
const request: SignupRequest = {
|
||||||
}, [signup, onSuccess, onError, clearError]);
|
...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 {
|
const {
|
||||||
values,
|
values,
|
||||||
@ -59,8 +89,8 @@ export function SignupForm({
|
|||||||
setTouchedField,
|
setTouchedField,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
validate,
|
validate,
|
||||||
} = useZodForm({
|
} = useZodForm<SignupFormValues>({
|
||||||
schema: signupFormSchema,
|
schema: signupSchema,
|
||||||
initialValues: {
|
initialValues: {
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
@ -88,12 +118,9 @@ export function SignupForm({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Handle step change with validation
|
// Handle step change with validation
|
||||||
const handleStepChange = useCallback(
|
const handleStepChange = useCallback((stepIndex: number) => {
|
||||||
(stepIndex: number) => {
|
setCurrentStepIndex(stepIndex);
|
||||||
setCurrentStepIndex(stepIndex);
|
}, []);
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Step field definitions (memoized for performance)
|
// Step field definitions (memoized for performance)
|
||||||
const stepFields = useMemo(
|
const stepFields = useMemo(
|
||||||
@ -107,15 +134,18 @@ export function SignupForm({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Validate specific step fields (optimized)
|
// Validate specific step fields (optimized)
|
||||||
const validateStep = useCallback((stepIndex: number): boolean => {
|
const validateStep = useCallback(
|
||||||
const fields = stepFields[stepIndex as keyof typeof stepFields] || [];
|
(stepIndex: number): boolean => {
|
||||||
|
const fields = stepFields[stepIndex as keyof typeof stepFields] || [];
|
||||||
// Mark fields as touched and check for errors
|
|
||||||
fields.forEach(field => setTouchedField(field));
|
// 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)]));
|
// Use the validate function to get current validation state
|
||||||
}, [stepFields, setTouchedField, validate, errors]);
|
return validate() || !fields.some(field => Boolean(errors[String(field)]));
|
||||||
|
},
|
||||||
|
[stepFields, setTouchedField, validate, errors]
|
||||||
|
);
|
||||||
|
|
||||||
const steps: FormStep[] = [
|
const steps: FormStep[] = [
|
||||||
{
|
{
|
||||||
@ -141,7 +171,7 @@ export function SignupForm({
|
|||||||
address={values.address}
|
address={values.address}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
touched={touched}
|
touched={touched}
|
||||||
onAddressChange={(address) => setValue("address", address)}
|
onAddressChange={address => setValue("address", address)}
|
||||||
setTouchedField={setTouchedField}
|
setTouchedField={setTouchedField}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@ -163,9 +193,10 @@ export function SignupForm({
|
|||||||
];
|
];
|
||||||
|
|
||||||
const currentStepFields = stepFields[currentStepIndex as keyof typeof stepFields] ?? [];
|
const currentStepFields = stepFields[currentStepIndex as keyof typeof stepFields] ?? [];
|
||||||
const canProceed = currentStepIndex === steps.length - 1
|
const canProceed =
|
||||||
? true
|
currentStepIndex === steps.length - 1
|
||||||
: currentStepFields.every(field => !errors[String(field)]);
|
? true
|
||||||
|
: currentStepFields.every(field => !errors[String(field)]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`w-full max-w-2xl mx-auto ${className}`}>
|
<div className={`w-full max-w-2xl mx-auto ${className}`}>
|
||||||
@ -200,11 +231,7 @@ export function SignupForm({
|
|||||||
canProceed={canProceed}
|
canProceed={canProceed}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error && (
|
{error && <ErrorMessage className="mt-4 text-center">{error}</ErrorMessage>}
|
||||||
<ErrorMessage className="mt-4 text-center">
|
|
||||||
{error}
|
|
||||||
</ErrorMessage>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showLoginLink && (
|
{showLoginLink && (
|
||||||
<div className="mt-6 text-center">
|
<div className="mt-6 text-center">
|
||||||
|
|||||||
@ -211,38 +211,22 @@ export function usePermissions() {
|
|||||||
|
|
||||||
const hasRole = useCallback(
|
const hasRole = useCallback(
|
||||||
(role: string) => {
|
(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(
|
const hasAnyRole = useCallback((roles: string[]) => roles.some(role => hasRole(role)), [hasRole]);
|
||||||
(resource: string, action: string) => {
|
|
||||||
return user?.permissions?.some(p => p.resource === resource && p.action === action) ?? false;
|
|
||||||
},
|
|
||||||
[user]
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasAnyRole = useCallback(
|
const hasPermission = useCallback(() => false, []);
|
||||||
(roles: string[]) => {
|
const hasAnyPermission = useCallback(() => false, []);
|
||||||
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]
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
roles: user?.roles || [],
|
role: user?.role,
|
||||||
permissions: user?.permissions || [],
|
|
||||||
hasRole,
|
hasRole,
|
||||||
hasPermission,
|
|
||||||
hasAnyRole,
|
hasAnyRole,
|
||||||
|
hasPermission,
|
||||||
hasAnyPermission,
|
hasAnyPermission,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { persist, createJSONStorage } from "zustand/middleware";
|
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 { getErrorInfo, handleAuthError } from "@/lib/utils/error-handling";
|
||||||
import logger from "@customer-portal/logging";
|
import logger from "@customer-portal/logging";
|
||||||
import type {
|
import type {
|
||||||
@ -44,7 +44,9 @@ interface AuthState {
|
|||||||
resetPassword: (token: string, password: string) => Promise<void>;
|
resetPassword: (token: string, password: string) => Promise<void>;
|
||||||
changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
|
changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
|
||||||
checkPasswordNeeded: (email: string) => Promise<{ needsPasswordSet: boolean }>;
|
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>;
|
setPassword: (email: string, password: string) => Promise<void>;
|
||||||
refreshUser: () => Promise<void>;
|
refreshUser: () => Promise<void>;
|
||||||
refreshTokens: () => Promise<void>;
|
refreshTokens: () => Promise<void>;
|
||||||
@ -72,10 +74,10 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
// Use shared API client with consistent configuration
|
// 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);
|
const parsed = authResponseSchema.safeParse(response.data);
|
||||||
if (!parsed.success) {
|
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;
|
const { user, tokens } = parsed.data;
|
||||||
@ -100,10 +102,10 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
signup: async (data: SignupRequest) => {
|
signup: async (data: SignupRequest) => {
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
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);
|
const parsed = authResponseSchema.safeParse(response.data);
|
||||||
if (!parsed.success) {
|
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;
|
const { user, tokens } = parsed.data;
|
||||||
@ -118,7 +120,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
loading: false,
|
loading: false,
|
||||||
error: error instanceof Error ? error.message : 'Signup failed',
|
error: error instanceof Error ? error.message : "Signup failed",
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -126,16 +128,19 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
const { tokens } = get();
|
const { tokens } = get();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (tokens?.accessToken) {
|
if (tokens?.accessToken) {
|
||||||
await apiClient.POST('/auth/logout', {
|
await apiClient.POST("/auth/logout", {
|
||||||
...withAuthHeaders(tokens.accessToken),
|
...withAuthHeaders(tokens.accessToken),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore logout errors - clear local state anyway
|
// 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({
|
set({
|
||||||
@ -149,19 +154,19 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
requestPasswordReset: async (email: string) => {
|
requestPasswordReset: async (email: string) => {
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.POST('/auth/request-password-reset', {
|
const response = await apiClient.POST("/auth/request-password-reset", {
|
||||||
body: { email }
|
body: { email },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.data) {
|
if (!response.data) {
|
||||||
throw new Error('Password reset request failed');
|
throw new Error("Password reset request failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
set({ loading: false });
|
set({ loading: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
loading: false,
|
loading: false,
|
||||||
error: error instanceof Error ? error.message : 'Password reset request failed',
|
error: error instanceof Error ? error.message : "Password reset request failed",
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -170,12 +175,12 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
resetPassword: async (token: string, password: string) => {
|
resetPassword: async (token: string, password: string) => {
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.POST('/auth/reset-password', {
|
const response = await apiClient.POST("/auth/reset-password", {
|
||||||
body: { token, password }
|
body: { token, password },
|
||||||
});
|
});
|
||||||
const parsed = authResponseSchema.safeParse(response.data);
|
const parsed = authResponseSchema.safeParse(response.data);
|
||||||
if (!parsed.success) {
|
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;
|
const { user, tokens } = parsed.data;
|
||||||
@ -190,7 +195,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
loading: false,
|
loading: false,
|
||||||
error: error instanceof Error ? error.message : 'Password reset failed',
|
error: error instanceof Error ? error.message : "Password reset failed",
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -198,24 +203,24 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
changePassword: async (currentPassword: string, newPassword: string) => {
|
changePassword: async (currentPassword: string, newPassword: string) => {
|
||||||
const { tokens } = get();
|
const { tokens } = get();
|
||||||
if (!tokens?.accessToken) throw new Error('Not authenticated');
|
if (!tokens?.accessToken) throw new Error("Not authenticated");
|
||||||
|
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.POST('/auth/change-password', {
|
const response = await apiClient.POST("/auth/change-password", {
|
||||||
...withAuthHeaders(tokens.accessToken),
|
...withAuthHeaders(tokens.accessToken),
|
||||||
body: { currentPassword, newPassword }
|
body: { currentPassword, newPassword },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.data) {
|
if (!response.data) {
|
||||||
throw new Error('Password change failed');
|
throw new Error("Password change failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
set({ loading: false });
|
set({ loading: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
loading: false,
|
loading: false,
|
||||||
error: error instanceof Error ? error.message : 'Password change failed',
|
error: error instanceof Error ? error.message : "Password change failed",
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -224,12 +229,12 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
checkPasswordNeeded: async (email: string) => {
|
checkPasswordNeeded: async (email: string) => {
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.POST('/auth/check-password-needed', {
|
const response = await apiClient.POST("/auth/check-password-needed", {
|
||||||
body: { email }
|
body: { email },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.data) {
|
if (!response.data) {
|
||||||
throw new Error('Check failed');
|
throw new Error("Check failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
set({ loading: false });
|
set({ loading: false });
|
||||||
@ -237,7 +242,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
loading: false,
|
loading: false,
|
||||||
error: error instanceof Error ? error.message : 'Check failed',
|
error: error instanceof Error ? error.message : "Check failed",
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -246,12 +251,12 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
linkWhmcs: async ({ email, password }: LinkWhmcsRequestData) => {
|
linkWhmcs: async ({ email, password }: LinkWhmcsRequestData) => {
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.POST('/auth/link-whmcs', {
|
const response = await apiClient.POST("/auth/link-whmcs", {
|
||||||
body: { email, password }
|
body: { email, password },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.data) {
|
if (!response.data) {
|
||||||
throw new Error('WHMCS link failed');
|
throw new Error("WHMCS link failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
set({ loading: false });
|
set({ loading: false });
|
||||||
@ -260,7 +265,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
loading: false,
|
loading: false,
|
||||||
error: error instanceof Error ? error.message : 'WHMCS link failed',
|
error: error instanceof Error ? error.message : "WHMCS link failed",
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -269,12 +274,12 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
setPassword: async (email: string, password: string) => {
|
setPassword: async (email: string, password: string) => {
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.POST('/auth/set-password', {
|
const response = await apiClient.POST("/auth/set-password", {
|
||||||
body: { email, password }
|
body: { email, password },
|
||||||
});
|
});
|
||||||
const parsed = authResponseSchema.safeParse(response.data);
|
const parsed = authResponseSchema.safeParse(response.data);
|
||||||
if (!parsed.success) {
|
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;
|
const { user, tokens } = parsed.data;
|
||||||
@ -289,7 +294,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
loading: false,
|
loading: false,
|
||||||
error: error instanceof Error ? error.message : 'Set password failed',
|
error: error instanceof Error ? error.message : "Set password failed",
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -300,17 +305,18 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
if (!tokens?.accessToken) return;
|
if (!tokens?.accessToken) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.GET('/me', {
|
const response = await apiClient.GET<UserProfile>("/me", {
|
||||||
...withAuthHeaders(tokens.accessToken),
|
...withAuthHeaders(tokens.accessToken),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.data) {
|
const profile = getNullableData<UserProfile>(response);
|
||||||
|
if (!profile) {
|
||||||
// Token might be expired, try to refresh
|
// Token might be expired, try to refresh
|
||||||
await get().refreshTokens();
|
await get().refreshTokens();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
set({ user: response.data });
|
set({ user: profile });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Token might be expired, try to refresh
|
// Token might be expired, try to refresh
|
||||||
handleAuthError(error, get().logout);
|
handleAuthError(error, get().logout);
|
||||||
@ -327,16 +333,16 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.POST('/auth/refresh', {
|
const response = await apiClient.POST("/auth/refresh", {
|
||||||
body: {
|
body: {
|
||||||
refreshToken: tokens.refreshToken,
|
refreshToken: tokens.refreshToken,
|
||||||
deviceId: localStorage.getItem('deviceId') || undefined,
|
deviceId: localStorage.getItem("deviceId") || undefined,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = authResponseSchema.safeParse(response.data);
|
const parsed = authResponseSchema.safeParse(response.data);
|
||||||
if (!parsed.success) {
|
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;
|
const { tokens: newTokens } = parsed.data;
|
||||||
@ -349,9 +355,9 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
checkAuth: async () => {
|
checkAuth: async () => {
|
||||||
const { tokens, isAuthenticated } = get();
|
const { tokens, isAuthenticated } = get();
|
||||||
|
|
||||||
set({ hasCheckedAuth: true });
|
set({ hasCheckedAuth: true });
|
||||||
|
|
||||||
if (!isAuthenticated || !tokens) {
|
if (!isAuthenticated || !tokens) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -360,7 +366,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
const expiryTime = new Date(tokens.expiresAt).getTime();
|
const expiryTime = new Date(tokens.expiresAt).getTime();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const fiveMinutes = 5 * 60 * 1000;
|
const fiveMinutes = 5 * 60 * 1000;
|
||||||
|
|
||||||
if (expiryTime - now < fiveMinutes) {
|
if (expiryTime - now < fiveMinutes) {
|
||||||
await get().refreshTokens();
|
await get().refreshTokens();
|
||||||
}
|
}
|
||||||
@ -381,9 +387,9 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'auth-store',
|
name: "auth-store",
|
||||||
storage: createJSONStorage(() => localStorage),
|
storage: createJSONStorage(() => localStorage),
|
||||||
partialize: (state) => ({
|
partialize: state => ({
|
||||||
user: state.user,
|
user: state.user,
|
||||||
tokens: state.tokens,
|
tokens: state.tokens,
|
||||||
isAuthenticated: state.isAuthenticated,
|
isAuthenticated: state.isAuthenticated,
|
||||||
@ -402,9 +408,7 @@ export const useAuthSession = () => {
|
|||||||
const isAuthenticated = useAuthStore(selectIsAuthenticated);
|
const isAuthenticated = useAuthStore(selectIsAuthenticated);
|
||||||
const user = useAuthStore(selectAuthUser);
|
const user = useAuthStore(selectAuthUser);
|
||||||
const hasValidToken = Boolean(
|
const hasValidToken = Boolean(
|
||||||
tokens?.accessToken &&
|
tokens?.accessToken && tokens?.expiresAt && new Date(tokens.expiresAt).getTime() > Date.now()
|
||||||
tokens?.expiresAt &&
|
|
||||||
new Date(tokens.expiresAt).getTime() > Date.now()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -3,4 +3,9 @@
|
|||||||
* Centralized exports for authentication services
|
* Centralized exports for authentication services
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { useAuthStore, selectAuthTokens, selectIsAuthenticated, selectAuthUser } from "./auth.store";
|
export {
|
||||||
|
useAuthStore,
|
||||||
|
selectAuthTokens,
|
||||||
|
selectIsAuthenticated,
|
||||||
|
selectAuthUser,
|
||||||
|
} from "./auth.store";
|
||||||
|
|||||||
@ -6,5 +6,3 @@ export function getPostLoginRedirect(searchParams: ReadonlyURLSearchParams): str
|
|||||||
if (dest.startsWith("http://") || dest.startsWith("https://")) return "/";
|
if (dest.startsWith("http://") || dest.startsWith("https://")) return "/";
|
||||||
return dest;
|
return dest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ export function ForgotPasswordView() {
|
|||||||
return (
|
return (
|
||||||
<AuthLayout
|
<AuthLayout
|
||||||
title="Forgot password"
|
title="Forgot password"
|
||||||
subtitle="Enter your email address and we'll send you a reset link"
|
subtitle="Enter your email address and we'll send you a reset link"
|
||||||
>
|
>
|
||||||
<PasswordResetForm mode="request" />
|
<PasswordResetForm mode="request" />
|
||||||
</AuthLayout>
|
</AuthLayout>
|
||||||
|
|||||||
@ -27,8 +27,8 @@ export function LinkWhmcsView() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="ml-3 text-sm text-blue-700 space-y-2">
|
<div className="ml-3 text-sm text-blue-700 space-y-2">
|
||||||
<p>
|
<p>
|
||||||
We've upgraded our customer portal. Use your existing Assist Solutions credentials
|
We've upgraded our customer portal. Use your existing Assist Solutions
|
||||||
to transfer your account and gain access to the new experience.
|
credentials to transfer your account and gain access to the new experience.
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc list-inside space-y-1">
|
<ul className="list-disc list-inside space-y-1">
|
||||||
<li>All of your services and billing history will come with you</li>
|
<li>All of your services and billing history will come with you</li>
|
||||||
|
|||||||
@ -12,10 +12,11 @@ function ResetPasswordContent() {
|
|||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return (
|
return (
|
||||||
<AuthLayout title="Reset your password" subtitle="We couldn't validate your reset link">
|
<AuthLayout title="Reset your password" subtitle="We couldn't validate your reset link">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-red-600">
|
<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>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/auth/forgot-password"
|
href="/auth/forgot-password"
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
export { BillingStatusBadge } from "./BillingStatusBadge";
|
||||||
@ -16,6 +16,10 @@ import type {
|
|||||||
InvoiceSsoLink,
|
InvoiceSsoLink,
|
||||||
PaymentMethodList,
|
PaymentMethodList,
|
||||||
} from "@customer-portal/domain";
|
} from "@customer-portal/domain";
|
||||||
|
import {
|
||||||
|
invoiceListSchema,
|
||||||
|
invoiceSchema as sharedInvoiceSchema,
|
||||||
|
} from "@customer-portal/domain/validation/shared/entities";
|
||||||
|
|
||||||
const emptyInvoiceList: InvoiceList = {
|
const emptyInvoiceList: InvoiceList = {
|
||||||
invoices: [],
|
invoices: [],
|
||||||
@ -50,7 +54,6 @@ type PaymentMethodsQueryOptions = Omit<
|
|||||||
"queryKey" | "queryFn"
|
"queryKey" | "queryFn"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
|
||||||
type SsoLinkMutationOptions = UseMutationOptions<
|
type SsoLinkMutationOptions = UseMutationOptions<
|
||||||
InvoiceSsoLink,
|
InvoiceSsoLink,
|
||||||
Error,
|
Error,
|
||||||
@ -58,13 +61,18 @@ type SsoLinkMutationOptions = UseMutationOptions<
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
async function fetchInvoices(params?: InvoiceQueryParams): Promise<InvoiceList> {
|
async function fetchInvoices(params?: InvoiceQueryParams): Promise<InvoiceList> {
|
||||||
const response = await apiClient.GET("/api/invoices", params ? { params: { query: params } } : undefined);
|
const response = await apiClient.GET(
|
||||||
return getDataOrDefault(response, emptyInvoiceList);
|
"/api/invoices",
|
||||||
|
params ? { params: { query: params } } : undefined
|
||||||
|
);
|
||||||
|
const data = getDataOrDefault(response, emptyInvoiceList);
|
||||||
|
return invoiceListSchema.parse(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchInvoice(id: string): Promise<Invoice> {
|
async function fetchInvoice(id: string): Promise<Invoice> {
|
||||||
const response = await apiClient.GET("/api/invoices/{id}", { params: { path: { id } } });
|
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> {
|
async function fetchPaymentMethods(): Promise<PaymentMethodList> {
|
||||||
@ -105,7 +113,6 @@ export function usePaymentMethods(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function useCreateInvoiceSsoLink(
|
export function useCreateInvoiceSsoLink(
|
||||||
options?: SsoLinkMutationOptions
|
options?: SsoLinkMutationOptions
|
||||||
): UseMutationResult<
|
): UseMutationResult<
|
||||||
@ -115,13 +122,13 @@ export function useCreateInvoiceSsoLink(
|
|||||||
> {
|
> {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ invoiceId, target }) => {
|
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: {
|
params: {
|
||||||
path: { id: invoiceId },
|
path: { id: invoiceId },
|
||||||
query: target ? { target } : undefined,
|
query: target ? { target } : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return getDataOrThrow(response, "Failed to create SSO link");
|
return getDataOrThrow<InvoiceSsoLink>(response, "Failed to create SSO link");
|
||||||
},
|
},
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,3 +1 @@
|
|||||||
export * from "./sso";
|
export * from "./sso";
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { logger } from "@customer-portal/logging";
|
|||||||
import { apiClient, getDataOrThrow } from "@/lib/api";
|
import { apiClient, getDataOrThrow } from "@/lib/api";
|
||||||
import { openSsoLink } from "@/features/billing/utils/sso";
|
import { openSsoLink } from "@/features/billing/utils/sso";
|
||||||
import { useInvoice, useCreateInvoiceSsoLink } from "@/features/billing/hooks";
|
import { useInvoice, useCreateInvoiceSsoLink } from "@/features/billing/hooks";
|
||||||
|
import type { InvoiceSsoLink } from "@customer-portal/domain";
|
||||||
import {
|
import {
|
||||||
InvoiceHeader,
|
InvoiceHeader,
|
||||||
InvoiceItems,
|
InvoiceItems,
|
||||||
@ -55,12 +56,12 @@ export function InvoiceDetailContainer() {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
setLoadingPaymentMethods(true);
|
setLoadingPaymentMethods(true);
|
||||||
try {
|
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" },
|
body: { path: "index.php?rp=/account/paymentmethods" },
|
||||||
});
|
});
|
||||||
const sso = getDataOrThrow<{ url: string }>(
|
const sso = getDataOrThrow<InvoiceSsoLink>(
|
||||||
response,
|
response,
|
||||||
'Failed to create payment methods SSO link'
|
"Failed to create payment methods SSO link"
|
||||||
);
|
);
|
||||||
openSsoLink(sso.url, { newTab: true });
|
openSsoLink(sso.url, { newTab: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user