Refactor error handling in various services to improve consistency and maintainability. Update user role management across authentication and user services, ensuring proper type definitions. Enhance validation schemas and streamline import/export practices in the domain and validation modules. Remove deprecated files and clean up unused code in the Salesforce integration and catalog services.

This commit is contained in:
barsa 2025-09-25 13:21:11 +09:00
parent e66e7a5884
commit 47a3de6919
46 changed files with 688 additions and 758 deletions

View File

@ -8,7 +8,7 @@ import {
ConflictException, ConflictException,
HttpStatus, HttpStatus,
} from "@nestjs/common"; } from "@nestjs/common";
import { Response } from "express"; import type { Request, Response } from "express";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
interface StandardErrorResponse { interface StandardErrorResponse {
@ -32,21 +32,41 @@ export class AuthErrorFilter implements ExceptionFilter {
) { ) {
const ctx = host.switchToHttp(); const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>(); const response = ctx.getResponse<Response>();
const request = ctx.getRequest(); const request = ctx.getRequest<Request>();
const status = exception.getStatus(); const status = exception.getStatus() as HttpStatus;
const message = exception.message; const responsePayload = exception.getResponse();
const payloadMessage =
typeof responsePayload === "string"
? responsePayload
: Array.isArray((responsePayload as { message?: unknown })?.message)
? (responsePayload as { message: unknown[] }).message.find(
(value): value is string => typeof value === "string"
)
: (responsePayload as { message?: unknown })?.message;
const exceptionMessage = typeof exception.message === "string" ? exception.message : undefined;
const messageText =
payloadMessage && typeof payloadMessage === "string"
? payloadMessage
: (exceptionMessage ?? "Authentication error");
// Map specific auth errors to user-friendly messages // Map specific auth errors to user-friendly messages
const userMessage = this.getUserFriendlyMessage(message, status); const userMessage = this.getUserFriendlyMessage(messageText, status);
const errorCode = this.getErrorCode(message, status); const errorCode = this.getErrorCode(messageText, status);
// Log the error (without sensitive information) // Log the error (without sensitive information)
const userAgentHeader = request.headers["user-agent"];
const userAgent =
typeof userAgentHeader === "string"
? userAgentHeader
: Array.isArray(userAgentHeader)
? userAgentHeader[0]
: undefined;
this.logger.warn("Authentication error", { this.logger.warn("Authentication error", {
path: request.url, path: request.url,
method: request.method, method: request.method,
errorCode, errorCode,
userAgent: request.headers["user-agent"], userAgent,
ip: request.ip, ip: request.ip,
}); });
@ -63,7 +83,7 @@ export class AuthErrorFilter implements ExceptionFilter {
response.status(status).json(errorResponse); response.status(status).json(errorResponse);
} }
private getUserFriendlyMessage(message: string, status: number): string { private getUserFriendlyMessage(message: string, status: HttpStatus): string {
// Production-safe error messages that don't expose sensitive information // Production-safe error messages that don't expose sensitive information
if (status === HttpStatus.UNAUTHORIZED) { if (status === HttpStatus.UNAUTHORIZED) {
if ( if (
@ -117,7 +137,7 @@ export class AuthErrorFilter implements ExceptionFilter {
return "Authentication error. Please try again."; return "Authentication error. Please try again.";
} }
private getErrorCode(message: string, status: number): string { private getErrorCode(message: string, status: HttpStatus): string {
if (status === HttpStatus.UNAUTHORIZED) { if (status === HttpStatus.UNAUTHORIZED) {
if (message.includes("Invalid credentials") || message.includes("Invalid email or password")) if (message.includes("Invalid credentials") || message.includes("Invalid email or password"))
return "INVALID_CREDENTIALS"; return "INVALID_CREDENTIALS";

View File

@ -9,10 +9,14 @@ import {
import { Request, Response } from "express"; import { Request, Response } from "express";
import { getClientSafeErrorMessage } from "../utils/error.util"; import { getClientSafeErrorMessage } from "../utils/error.util";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { ConfigService } from "@nestjs/config";
@Catch() @Catch()
export class GlobalExceptionFilter implements ExceptionFilter { export class GlobalExceptionFilter implements ExceptionFilter {
constructor(@Inject(Logger) private readonly logger: Logger) {} constructor(
@Inject(Logger) private readonly logger: Logger,
private readonly configService: ConfigService
) {}
catch(exception: unknown, host: ArgumentsHost): void { catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp(); const ctx = host.switchToHttp();
@ -51,7 +55,9 @@ export class GlobalExceptionFilter implements ExceptionFilter {
} }
const clientSafeMessage = const clientSafeMessage =
process.env.NODE_ENV === "production" ? getClientSafeErrorMessage(message) : message; this.configService.get("NODE_ENV") === "production"
? getClientSafeErrorMessage(message)
: message;
const code = (error || "InternalServerError") const code = (error || "InternalServerError")
.replace(/([a-z])([A-Z])/g, "$1_$2") .replace(/([a-z])([A-Z])/g, "$1_$2")

View File

@ -11,7 +11,7 @@ export const uuidSchema = z.string().uuid();
export function normalizeAndValidateEmail(email: string): string { export function normalizeAndValidateEmail(email: string): string {
try { try {
return emailSchema.parse(email); return emailSchema.parse(email);
} catch (error) { } catch {
throw new BadRequestException("Invalid email format"); throw new BadRequestException("Invalid email format");
} }
} }
@ -19,7 +19,7 @@ export function normalizeAndValidateEmail(email: string): string {
export function validateUuidV4OrThrow(id: string): string { export function validateUuidV4OrThrow(id: string): string {
try { try {
return uuidSchema.parse(id); return uuidSchema.parse(id);
} catch (error) { } catch {
throw new Error("Invalid user ID format"); throw new Error("Invalid user ID format");
} }
} }

View File

@ -19,7 +19,7 @@ export function ZodPipe(schema: ZodSchema) {
export function ZodPipeClass(schema: ZodSchema) { export function ZodPipeClass(schema: ZodSchema) {
@Injectable() @Injectable()
class ZodPipeClass implements PipeTransform { class ZodPipeClass implements PipeTransform {
transform(value: any, metadata: ArgumentMetadata) { transform(value: unknown, _metadata: ArgumentMetadata) {
const result = schema.safeParse(value); const result = schema.safeParse(value);
if (!result.success) { if (!result.success) {
throw new BadRequestException({ throw new BadRequestException({

View File

@ -1,4 +1,4 @@
import type { User, UserProfile } from "@customer-portal/domain"; import type { AuthenticatedUser, User } from "@customer-portal/domain";
import type { User as PrismaUser } from "@prisma/client"; import type { User as PrismaUser } from "@prisma/client";
export function mapPrismaUserToSharedUser(user: PrismaUser): User { export function mapPrismaUserToSharedUser(user: PrismaUser): User {
@ -42,13 +42,15 @@ export function mapPrismaUserToEnhancedBase(user: PrismaUser): {
}; };
} }
export function mapPrismaUserToUserProfile(user: PrismaUser): UserProfile { export function mapPrismaUserToUserProfile(user: PrismaUser): AuthenticatedUser {
const shared = mapPrismaUserToSharedUser(user); const shared = mapPrismaUserToSharedUser(user);
const normalizedRole = user.role?.toLowerCase() === "admin" ? "admin" : "user";
return { return {
...shared, ...shared,
avatar: undefined, avatar: undefined,
preferences: {}, preferences: {},
lastLoginAt: user.lastLoginAt ? user.lastLoginAt.toISOString() : undefined, lastLoginAt: user.lastLoginAt ? user.lastLoginAt.toISOString() : undefined,
role: normalizedRole,
}; };
} }

View File

@ -6,6 +6,7 @@ import {
} from "@nestjs/common"; } 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 type { import type {
FreebititConfig, FreebititConfig,
FreebititAuthRequest, FreebititAuthRequest,
@ -35,6 +36,14 @@ import type {
SimTopUpHistory, SimTopUpHistory,
} from "./interfaces/freebit.types"; } from "./interfaces/freebit.types";
interface FreebitResponseBase {
resultCode?: string | number;
status?: {
message?: string;
statusCode?: string | number;
};
}
@Injectable() @Injectable()
export class FreebititService { export class FreebititService {
private readonly config: FreebititConfig; private readonly config: FreebititConfig;
@ -120,18 +129,19 @@ export class FreebititService {
this.authKeyCache = { token: data.authKey, expiresAt: Date.now() + 50 * 60 * 1000 }; this.authKeyCache = { token: data.authKey, expiresAt: Date.now() + 50 * 60 * 1000 };
this.logger.log("Successfully authenticated with Freebit API"); this.logger.log("Successfully authenticated with Freebit API");
return data.authKey; return data.authKey;
} catch (error: any) { } catch (error: unknown) {
this.logger.error("Failed to authenticate with Freebit API", { error: error.message }); const message = getErrorMessage(error);
this.logger.error("Failed to authenticate with Freebit API", { error: message });
throw new InternalServerErrorException("Failed to authenticate with Freebit API"); throw new InternalServerErrorException("Failed to authenticate with Freebit API");
} }
} }
private async makeAuthenticatedRequest<T>( private async makeAuthenticatedRequest<
endpoint: string, TResponse extends FreebitResponseBase,
data: Record<string, unknown> TPayload extends Record<string, unknown>,
): Promise<T> { >(endpoint: string, payload: TPayload): Promise<TResponse> {
const authKey = await this.getAuthKey(); const authKey = await this.getAuthKey();
const requestData = { ...data, authKey }; const requestData: Record<string, unknown> = { ...payload, authKey };
try { try {
const url = `${this.config.baseUrl}${endpoint}`; const url = `${this.config.baseUrl}${endpoint}`;
@ -146,7 +156,9 @@ export class FreebititService {
try { try {
const text = await response.text(); const text = await response.text();
bodySnippet = text ? text.slice(0, 500) : undefined; bodySnippet = text ? text.slice(0, 500) : undefined;
} catch {} } catch {
// ignore body parse errors when logging
}
this.logger.error("Freebit API non-OK response", { this.logger.error("Freebit API non-OK response", {
endpoint, endpoint,
url, url,
@ -157,35 +169,32 @@ export class FreebititService {
throw new Error(`HTTP ${response.status}: ${response.statusText}`); throw new Error(`HTTP ${response.status}: ${response.statusText}`);
} }
const responseData = (await response.json()) as any; const responseData = (await response.json()) as TResponse;
if (responseData && responseData.resultCode && responseData.resultCode !== "100") { if (responseData.resultCode && responseData.resultCode !== "100") {
throw new FreebititErrorImpl( throw new FreebititErrorImpl(
`API Error: ${responseData.status?.message || "Unknown error"}`, `API Error: ${responseData.status?.message ?? "Unknown error"}`,
responseData.resultCode, responseData.resultCode,
responseData.status?.statusCode, responseData.status?.statusCode,
responseData.status?.message responseData.status?.message ?? "Unknown error"
); );
} }
this.logger.debug("Freebit API Request Success", { endpoint }); this.logger.debug("Freebit API Request Success", { endpoint });
return responseData as T; return responseData;
} catch (error) { } catch (error: unknown) {
if (error instanceof FreebititErrorImpl) { if (error instanceof FreebititErrorImpl) {
throw error; throw error;
} }
this.logger.error(`Freebit API request failed: ${endpoint}`, { const message = getErrorMessage(error);
error: (error as any).message, this.logger.error(`Freebit API request failed: ${endpoint}`, { error: message });
}); throw new InternalServerErrorException(`Freebit API request failed: ${message}`);
throw new InternalServerErrorException(
`Freebit API request failed: ${(error as any).message}`
);
} }
} }
private async makeAuthenticatedJsonRequest<T>( private async makeAuthenticatedJsonRequest<
endpoint: string, TResponse extends FreebitResponseBase,
payload: Record<string, unknown> TPayload extends Record<string, unknown>,
): Promise<T> { >(endpoint: string, payload: TPayload): Promise<TResponse> {
const url = `${this.config.baseUrl}${endpoint}`; const url = `${this.config.baseUrl}${endpoint}`;
try { try {
const response = await fetch(url, { const response = await fetch(url, {
@ -196,24 +205,23 @@ export class FreebititService {
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`); throw new Error(`HTTP ${response.status}: ${response.statusText}`);
} }
const responseData = (await response.json()) as any; const responseData = (await response.json()) as TResponse;
if (responseData && responseData.resultCode && responseData.resultCode !== "100") { if (responseData.resultCode && responseData.resultCode !== "100") {
throw new FreebititErrorImpl( throw new FreebititErrorImpl(
`API Error: ${responseData.status?.message || "Unknown error"}`, `API Error: ${responseData.status?.message ?? "Unknown error"}`,
responseData.resultCode, responseData.resultCode,
responseData.status?.statusCode, responseData.status?.statusCode,
responseData.status?.message responseData.status?.message ?? "Unknown error"
); );
} }
this.logger.debug("Freebit JSON API Request Success", { endpoint }); this.logger.debug("Freebit JSON API Request Success", { endpoint });
return responseData as T; return responseData;
} catch (error) { } catch (error: unknown) {
const message = getErrorMessage(error);
this.logger.error(`Freebit JSON API request failed: ${endpoint}`, { this.logger.error(`Freebit JSON API request failed: ${endpoint}`, {
error: (error as any).message, error: message,
}); });
throw new InternalServerErrorException( throw new InternalServerErrorException(`Freebit JSON API request failed: ${message}`);
`Freebit JSON API request failed: ${(error as any).message}`
);
} }
} }
@ -253,56 +261,65 @@ export class FreebititService {
if (ep !== candidates[0]) { if (ep !== candidates[0]) {
this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`); this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`);
} }
response = await this.makeAuthenticatedRequest<FreebititAccountDetailsResponse>( response = await this.makeAuthenticatedRequest<
ep, FreebititAccountDetailsResponse,
request as any typeof request
); >(ep, request);
break; break;
} catch (err: any) { } catch (err: unknown) {
lastError = err; lastError = err;
if (typeof err?.message === "string" && err.message.includes("HTTP 404")) { if (getErrorMessage(err).includes("HTTP 404")) {
continue; // try next continue; // try next
} }
} }
} }
if (!response) { if (!response) {
throw lastError || new Error("Failed to fetch account details"); if (lastError instanceof Error) {
throw lastError;
}
throw new Error("Failed to fetch account details");
} }
const resp = response.responseDatas as any; const responseDatas = Array.isArray(response.responseDatas)
const simData = Array.isArray(resp) ? response.responseDatas
? resp.find(d => String(d.kind).toUpperCase() === "MVNO") || resp[0] : [response.responseDatas];
: resp; const simData =
responseDatas.find(detail => detail.kind.toUpperCase() === "MVNO") ?? responseDatas[0];
const size = String(simData.size || "").toLowerCase(); const size = String(simData.size ?? "").toLowerCase();
const isEsim = size === "esim" || !!simData.eid; const isEsim = size === "esim" || !!simData.eid;
const planCode = String(simData.planCode || ""); const planCode = String(simData.planCode ?? "");
const status = this.mapSimStatus(String(simData.state || "")); const status = this.mapSimStatus(String(simData.state ?? ""));
const remainingKb = Number(simData.quota) || 0; const remainingKb = Number(simData.quota ?? 0);
const details: SimDetails = { const details: SimDetails = {
account: String(simData.account || account), account: String(simData.account ?? account),
msisdn: String(simData.account || account), msisdn: String(simData.account ?? account),
iccid: simData.iccid ? String(simData.iccid) : undefined, iccid: simData.iccid ? String(simData.iccid) : undefined,
imsi: simData.imsi ? String(simData.imsi) : undefined, imsi: simData.imsi ? String(simData.imsi) : undefined,
eid: simData.eid ? String(simData.eid) : undefined, eid: simData.eid ? String(simData.eid) : undefined,
planCode, planCode,
status, status,
simType: isEsim ? "esim" : "physical", simType: isEsim ? "esim" : "physical",
size: (size as any) || (isEsim ? "esim" : "nano"), size: size || (isEsim ? "esim" : "nano"),
hasVoice: Number(simData.talk) === 10, hasVoice: Number(simData.talk ?? 0) === 10,
hasSms: Number(simData.sms) === 10, hasSms: Number(simData.sms ?? 0) === 10,
remainingQuotaKb: remainingKb, remainingQuotaKb: remainingKb,
remainingQuotaMb: Math.round((remainingKb / 1024) * 100) / 100, remainingQuotaMb: Math.round((remainingKb / 1024) * 100) / 100,
startDate: simData.startDate ? String(simData.startDate) : undefined, startDate: simData.startDate ? String(simData.startDate) : undefined,
ipv4: simData.ipv4, ipv4: simData.ipv4,
ipv6: simData.ipv6, ipv6: simData.ipv6,
voiceMailEnabled: Number(simData.voicemail ?? simData.voiceMail) === 10, voiceMailEnabled: Number(simData.voicemail ?? simData.voiceMail ?? 0) === 10,
callWaitingEnabled: Number(simData.callwaiting ?? simData.callWaiting) === 10, callWaitingEnabled: Number(simData.callwaiting ?? simData.callWaiting ?? 0) === 10,
internationalRoamingEnabled: Number(simData.worldwing ?? simData.worldWing) === 10, internationalRoamingEnabled: Number(simData.worldwing ?? simData.worldWing ?? 0) === 10,
networkType: simData.contractLine || undefined, networkType: simData.contractLine ?? undefined,
pendingOperations: simData.async pendingOperations: simData.async
? [{ operation: String(simData.async.func), scheduledDate: String(simData.async.date) }] ? [
{
operation: String(simData.async.func),
scheduledDate: String(simData.async.date),
},
]
: undefined, : undefined,
}; };
@ -313,21 +330,22 @@ export class FreebititService {
}); });
return details; return details;
} catch (error: any) { } catch (error: unknown) {
const message = getErrorMessage(error);
this.logger.error(`Failed to get SIM details for account ${account}`, { this.logger.error(`Failed to get SIM details for account ${account}`, {
error: error.message, error: message,
}); });
throw error; throw error as Error;
} }
} }
async getSimUsage(account: string): Promise<SimUsage> { async getSimUsage(account: string): Promise<SimUsage> {
try { try {
const request: Omit<FreebititTrafficInfoRequest, "authKey"> = { account }; const request: Omit<FreebititTrafficInfoRequest, "authKey"> = { account };
const response = await this.makeAuthenticatedRequest<FreebititTrafficInfoResponse>( const response = await this.makeAuthenticatedRequest<
"/mvno/getTrafficInfo/", FreebititTrafficInfoResponse,
request as any typeof request
); >("/mvno/getTrafficInfo/", request);
const todayUsageKb = parseInt(response.traffic.today, 10) || 0; const todayUsageKb = parseInt(response.traffic.today, 10) || 0;
const recentDaysData = response.traffic.inRecentDays.split(",").map((usage, index) => ({ const recentDaysData = response.traffic.inRecentDays.split(",").map((usage, index) => ({
@ -374,11 +392,12 @@ export class FreebititService {
const scheduled = !!options.scheduledAt; const scheduled = !!options.scheduledAt;
const endpoint = scheduled ? "/mvno/eachQuota/" : "/master/addSpec/"; const endpoint = scheduled ? "/mvno/eachQuota/" : "/master/addSpec/";
if (scheduled) { type TopUpPayload = typeof request & { runTime?: string };
(request as any).runTime = options.scheduledAt; const payload: TopUpPayload = scheduled
} ? { ...request, runTime: options.scheduledAt }
: request;
await this.makeAuthenticatedRequest<FreebititTopUpResponse>(endpoint, request as any); await this.makeAuthenticatedRequest<FreebititTopUpResponse, TopUpPayload>(endpoint, payload);
this.logger.log(`Successfully topped up SIM ${account}`, { this.logger.log(`Successfully topped up SIM ${account}`, {
account, account,
endpoint, endpoint,
@ -386,13 +405,14 @@ export class FreebititService {
quotaKb, quotaKb,
scheduled, scheduled,
}); });
} catch (error: any) { } catch (error: unknown) {
const message = getErrorMessage(error);
this.logger.error(`Failed to top up SIM ${account}`, { this.logger.error(`Failed to top up SIM ${account}`, {
error: error.message, error: message,
account, account,
quotaMb, quotaMb,
}); });
throw error; throw error as Error;
} }
} }
@ -403,10 +423,10 @@ export class FreebititService {
): Promise<SimTopUpHistory> { ): Promise<SimTopUpHistory> {
try { try {
const request: Omit<FreebititQuotaHistoryRequest, "authKey"> = { account, fromDate, toDate }; const request: Omit<FreebititQuotaHistoryRequest, "authKey"> = { account, fromDate, toDate };
const response = await this.makeAuthenticatedRequest<FreebititQuotaHistoryResponse>( const response = await this.makeAuthenticatedRequest<
"/mvno/getQuotaHistory/", FreebititQuotaHistoryResponse,
request as any typeof request
); >("/mvno/getQuotaHistory/", request);
const history: SimTopUpHistory = { const history: SimTopUpHistory = {
account, account,
@ -428,9 +448,10 @@ export class FreebititService {
}); });
return history; return history;
} catch (error: any) { } catch (error: unknown) {
const message = getErrorMessage(error);
this.logger.error(`Failed to get SIM top-up history for account ${account}`, { this.logger.error(`Failed to get SIM top-up history for account ${account}`, {
error: error.message, error: message,
}); });
throw error; throw error;
} }
@ -449,10 +470,10 @@ export class FreebititService {
runTime: options.scheduledAt, runTime: options.scheduledAt,
}; };
const response = await this.makeAuthenticatedRequest<FreebititPlanChangeResponse>( const response = await this.makeAuthenticatedRequest<
"/mvno/changePlan/", FreebititPlanChangeResponse,
request as any typeof request
); >("/mvno/changePlan/", request);
this.logger.log(`Successfully changed SIM plan for account ${account}`, { this.logger.log(`Successfully changed SIM plan for account ${account}`, {
account, account,
@ -462,9 +483,10 @@ export class FreebititService {
}); });
return { ipv4: response.ipv4, ipv6: response.ipv6 }; return { ipv4: response.ipv4, ipv6: response.ipv6 };
} catch (error: any) { } catch (error: unknown) {
const message = getErrorMessage(error);
this.logger.error(`Failed to change SIM plan for account ${account}`, { this.logger.error(`Failed to change SIM plan for account ${account}`, {
error: error.message, error: message,
account, account,
newPlanCode, newPlanCode,
}); });
@ -500,9 +522,9 @@ export class FreebititService {
request.contractLine = features.networkType; request.contractLine = features.networkType;
} }
await this.makeAuthenticatedRequest<FreebititAddSpecResponse>( await this.makeAuthenticatedRequest<FreebititAddSpecResponse, typeof request>(
"/master/addSpec/", "/master/addSpec/",
request as any request
); );
this.logger.log(`Updated SIM features for account ${account}`, { this.logger.log(`Updated SIM features for account ${account}`, {
account, account,
@ -512,7 +534,7 @@ export class FreebititService {
networkType: features.networkType, networkType: features.networkType,
}); });
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error); const message = getErrorMessage(error);
this.logger.error(`Failed to update SIM features for account ${account}`, { this.logger.error(`Failed to update SIM features for account ${account}`, {
error: message, error: message,
account, account,
@ -527,17 +549,18 @@ export class FreebititService {
account, account,
runTime: scheduledAt, runTime: scheduledAt,
}; };
await this.makeAuthenticatedRequest<FreebititCancelPlanResponse>( await this.makeAuthenticatedRequest<FreebititCancelPlanResponse, typeof request>(
"/mvno/releasePlan/", "/mvno/releasePlan/",
request as any 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: any) { } catch (error: unknown) {
const message = getErrorMessage(error);
this.logger.error(`Failed to cancel SIM for account ${account}`, { this.logger.error(`Failed to cancel SIM for account ${account}`, {
error: error.message, error: message,
account, account,
}); });
throw error as Error; throw error as Error;
@ -547,14 +570,15 @@ export class FreebititService {
async reissueEsimProfile(account: string): Promise<void> { async reissueEsimProfile(account: string): Promise<void> {
try { try {
const request: Omit<FreebititEsimReissueRequest, "authKey"> = { account }; const request: Omit<FreebititEsimReissueRequest, "authKey"> = { account };
await this.makeAuthenticatedRequest<FreebititEsimReissueResponse>( await this.makeAuthenticatedRequest<FreebititEsimReissueResponse, typeof request>(
"/esim/reissueProfile/", "/esim/reissueProfile/",
request as any request
); );
this.logger.log(`Successfully requested eSIM reissue for account ${account}`); this.logger.log(`Successfully requested eSIM reissue for account ${account}`);
} catch (error: any) { } catch (error: unknown) {
const message = getErrorMessage(error);
this.logger.error(`Failed to reissue eSIM profile for account ${account}`, { this.logger.error(`Failed to reissue eSIM profile for account ${account}`, {
error: error.message, error: message,
account, account,
}); });
throw error as Error; throw error as Error;
@ -576,9 +600,9 @@ export class FreebititService {
planCode: options.planCode, planCode: options.planCode,
}; };
await this.makeAuthenticatedRequest<FreebititEsimAddAccountResponse>( await this.makeAuthenticatedRequest<FreebititEsimAddAccountResponse, typeof request>(
"/mvno/esim/addAcnt/", "/mvno/esim/addAcnt/",
request as any request
); );
this.logger.log(`Successfully reissued eSIM profile via addAcnt for account ${account}`, { this.logger.log(`Successfully reissued eSIM profile via addAcnt for account ${account}`, {
@ -587,9 +611,10 @@ export class FreebititService {
oldProductNumber: options.oldProductNumber, oldProductNumber: options.oldProductNumber,
oldEid: options.oldEid, oldEid: options.oldEid,
}); });
} catch (error: any) { } catch (error: unknown) {
const message = getErrorMessage(error);
this.logger.error(`Failed to reissue eSIM profile via addAcnt for account ${account}`, { this.logger.error(`Failed to reissue eSIM profile via addAcnt for account ${account}`, {
error: error.message, error: message,
account, account,
newEid, newEid,
}); });
@ -640,13 +665,13 @@ export class FreebititService {
contractLine, contractLine,
shipDate, shipDate,
...(mnp ? { mnp } : {}), ...(mnp ? { mnp } : {}),
...(identity ? identity : {}), ...(identity ?? {}),
} as FreebititEsimAccountActivationRequest; };
await this.makeAuthenticatedJsonRequest<FreebititEsimAccountActivationResponse>( await this.makeAuthenticatedJsonRequest<
"/mvno/esim/addAcct/", FreebititEsimAccountActivationResponse,
payload as unknown as Record<string, unknown> FreebititEsimAccountActivationRequest
); >("/mvno/esim/addAcct/", payload);
this.logger.log("Activated new eSIM account via PA05-41", { this.logger.log("Activated new eSIM account via PA05-41", {
account, account,
@ -661,8 +686,8 @@ export class FreebititService {
try { try {
await this.getAuthKey(); await this.getAuthKey();
return true; return true;
} catch (error: any) { } catch (error: unknown) {
this.logger.error("Freebit API health check failed", { error: error.message }); this.logger.error("Freebit API health check failed", { error: getErrorMessage(error) });
return false; return false;
} }
} }

View File

@ -18,11 +18,32 @@ export interface FreebititAccountDetailsRequest {
authKey: string; authKey: string;
version?: string | number; // Docs recommend "2" version?: string | number; // Docs recommend "2"
requestDatas: Array<{ requestDatas: Array<{
kind: "MASTER" | "MVNO" | string; kind: "MASTER" | "MVNO";
account?: string | number; account?: string | number;
}>; }>;
} }
export interface FreebititAccountDetail {
kind: "MASTER" | "MVNO";
account: string | number;
state: "active" | "suspended" | "temporary" | "waiting" | "obsolete";
startDate?: string | number;
relationCode?: string;
resultCode?: string | number;
planCode?: string;
iccid?: string | number;
imsi?: string | number;
eid?: string;
contractLine?: string;
size?: "standard" | "nano" | "micro" | "esim";
sms?: number; // 10=active, 20=inactive
talk?: number; // 10=active, 20=inactive
ipv4?: string;
ipv6?: string;
quota?: number; // Remaining quota
async?: { func: string; date: string | number };
}
export interface FreebititAccountDetailsResponse { export interface FreebititAccountDetailsResponse {
resultCode: string; resultCode: string;
status: { status: {
@ -30,47 +51,7 @@ export interface FreebititAccountDetailsResponse {
statusCode: string | number; statusCode: string | number;
}; };
masterAccount?: string; masterAccount?: string;
responseDatas: responseDatas: FreebititAccountDetail | FreebititAccountDetail[];
| {
kind: "MASTER" | "MVNO" | string;
account: string | number;
state: "active" | "suspended" | "temporary" | "waiting" | "obsolete" | string;
startDate?: string | number;
relationCode?: string;
resultCode?: string | number;
planCode?: string;
iccid?: string | number;
imsi?: string | number;
eid?: string;
contractLine?: string;
size?: "standard" | "nano" | "micro" | "esim" | string;
sms?: number; // 10=active, 20=inactive
talk?: number; // 10=active, 20=inactive
ipv4?: string;
ipv6?: string;
quota?: number; // Remaining quota
async?: { func: string; date: string | number };
}
| Array<{
kind: "MASTER" | "MVNO" | string;
account: string | number;
state: "active" | "suspended" | "temporary" | "waiting" | "obsolete" | string;
startDate?: string | number;
relationCode?: string;
resultCode?: string | number;
planCode?: string;
iccid?: string | number;
imsi?: string | number;
eid?: string;
contractLine?: string;
size?: "standard" | "nano" | "micro" | "esim" | string;
sms?: number;
talk?: number;
ipv4?: string;
ipv6?: string;
quota?: number;
async?: { func: string; date: string | number };
}>;
} }
export interface FreebititTrafficInfoRequest { export interface FreebititTrafficInfoRequest {

View File

@ -2,13 +2,11 @@ import { Module } from "@nestjs/common";
import { SalesforceService } from "./salesforce.service"; import { SalesforceService } from "./salesforce.service";
import { SalesforceConnection } from "./services/salesforce-connection.service"; import { SalesforceConnection } from "./services/salesforce-connection.service";
import { SalesforceAccountService } from "./services/salesforce-account.service"; import { SalesforceAccountService } from "./services/salesforce-account.service";
import { SalesforceCaseService } from "./services/salesforce-case.service";
@Module({ @Module({
providers: [ providers: [
SalesforceConnection, SalesforceConnection,
SalesforceAccountService, SalesforceAccountService,
SalesforceCaseService,
SalesforceService, SalesforceService,
], ],
exports: [SalesforceService, SalesforceConnection], exports: [SalesforceService, SalesforceConnection],

View File

@ -9,12 +9,6 @@ import {
type AccountData, type AccountData,
type UpsertResult, type UpsertResult,
} from "./services/salesforce-account.service"; } from "./services/salesforce-account.service";
import {
SalesforceCaseService,
CaseQueryParams,
CreateCaseUserData,
} from "./services/salesforce-case.service";
import { SupportCase, CreateCaseRequest } from "@customer-portal/domain";
import type { SalesforceAccountRecord } from "@customer-portal/domain"; import type { SalesforceAccountRecord } from "@customer-portal/domain";
/** /**
@ -24,8 +18,7 @@ import type { SalesforceAccountRecord } from "@customer-portal/domain";
* - findAccountByCustomerNumber() - auth service (WHMCS linking) * - findAccountByCustomerNumber() - auth service (WHMCS linking)
* - upsertAccount() - auth service (signup) * - upsertAccount() - auth service (signup)
* - getAccount() - users service (profile enhancement) * - getAccount() - users service (profile enhancement)
* - getCases() - future support functionality * Support-case functionality has been deferred and is intentionally absent.
* - createCase() - future support functionality
*/ */
@Injectable() @Injectable()
export class SalesforceService implements OnModuleInit { export class SalesforceService implements OnModuleInit {
@ -33,7 +26,6 @@ export class SalesforceService implements OnModuleInit {
private configService: ConfigService, private configService: ConfigService,
private connection: SalesforceConnection, private connection: SalesforceConnection,
private accountService: SalesforceAccountService, private accountService: SalesforceAccountService,
private caseService: SalesforceCaseService,
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger
) {} ) {}
@ -86,26 +78,6 @@ export class SalesforceService implements OnModuleInit {
return this.accountService.update(accountId, updates); return this.accountService.update(accountId, updates);
} }
// === CASE METHODS (For Future Support Functionality) ===
async getCases(
accountId: string,
params: CaseQueryParams = {}
): Promise<{ cases: SupportCase[]; totalSize: number }> {
return this.caseService.getCases(accountId, params);
}
async createCase(
userData: CreateCaseUserData,
caseRequest: CreateCaseRequest
): Promise<SupportCase> {
return this.caseService.createCase(userData, caseRequest);
}
async updateCase(caseId: string, updates: Record<string, unknown>): Promise<void> {
return this.caseService.updateCase(caseId, updates);
}
// === ORDER METHODS (For Order Provisioning) === // === ORDER METHODS (For Order Provisioning) ===
async updateOrder(orderData: { Id: string; [key: string]: unknown }): Promise<void> { async updateOrder(orderData: { Id: string; [key: string]: unknown }): Promise<void> {

View File

@ -1,274 +0,0 @@
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { getErrorMessage } from "@bff/core/utils/error.util";
import { SalesforceConnection } from "./salesforce-connection.service";
import { SupportCase, CreateCaseRequest, CaseType } from "@customer-portal/domain";
import { CaseStatus, CasePriority, CASE_STATUS, CASE_PRIORITY } from "@customer-portal/domain";
import type {
SalesforceCaseRecord,
SalesforceContactRecord,
SalesforceCreateResult,
SalesforceQueryResult,
} from "@customer-portal/domain";
export interface CaseQueryParams {
status?: string;
limit?: number;
offset?: number;
}
export interface CreateCaseUserData {
email: string;
firstName: string;
lastName: string;
accountId: string;
}
interface CaseData {
subject: string;
description: string;
accountId: string;
type?: string;
priority?: string;
origin?: string;
}
@Injectable()
export class SalesforceCaseService {
constructor(
private connection: SalesforceConnection,
@Inject(Logger) private readonly logger: Logger
) {}
async getCases(
accountId: string,
params: CaseQueryParams = {}
): Promise<{ cases: SupportCase[]; totalSize: number }> {
const validAccountId = this.validateId(accountId);
try {
let query = `
SELECT Id, CaseNumber, Subject, Description, Status, Priority, Type, Origin,
CreatedDate, LastModifiedDate, ClosedDate, ContactId, AccountId, OwnerId, Owner.Name
FROM Case
WHERE AccountId = '${validAccountId}'
`;
if (params.status) {
query += ` AND Status = '${this.safeSoql(params.status)}'`;
}
query += " ORDER BY CreatedDate DESC";
if (params.limit) {
query += ` LIMIT ${params.limit}`;
}
if (params.offset) {
query += ` OFFSET ${params.offset}`;
}
const result = (await this.connection.query(query)) as SalesforceQueryResult<
SalesforceCaseRecord & { Owner?: { Name?: string } }
>;
const cases = result.records.map(record => this.transformCase(record));
return { cases, totalSize: result.totalSize };
} catch (error) {
this.logger.error("Failed to get cases", {
error: getErrorMessage(error),
});
throw new Error("Failed to get cases");
}
}
async createCase(
userData: CreateCaseUserData,
caseRequest: CreateCaseRequest
): Promise<SupportCase> {
try {
// Create contact on-demand for case creation
const contactId = await this.findOrCreateContact(userData);
const caseData: CaseData = {
subject: caseRequest.subject,
description: caseRequest.description,
accountId: userData.accountId,
type: caseRequest.type || "Question",
priority: caseRequest.priority || "Medium",
origin: "Web",
};
const sfCase = await this.createSalesforceCase({
...caseData,
contactId,
});
return this.transformCase(sfCase);
} catch (error) {
this.logger.error("Failed to create case", { error: getErrorMessage(error) });
throw error;
}
}
async updateCase(caseId: string, updates: Record<string, unknown>): Promise<void> {
const validCaseId = this.validateId(caseId);
try {
const sobject = this.connection.sobject("Case") as unknown as {
update: (data: Record<string, unknown>) => Promise<unknown>;
};
await sobject.update({ Id: validCaseId, ...updates });
} catch (error) {
this.logger.error("Failed to update case", {
error: getErrorMessage(error),
});
throw new Error("Failed to update case");
}
}
private async findOrCreateContact(userData: CreateCaseUserData): Promise<string> {
try {
// Try to find existing contact
const existingContact = (await this.connection.query(`
SELECT Id FROM Contact
WHERE Email = '${this.safeSoql(userData.email)}'
AND AccountId = '${userData.accountId}'
LIMIT 1
`)) as SalesforceQueryResult<SalesforceContactRecord>;
if (existingContact.totalSize > 0) {
return existingContact.records[0]?.Id ?? "";
}
// Create new contact
const contactData = {
Email: userData.email,
FirstName: userData.firstName,
LastName: userData.lastName,
AccountId: userData.accountId,
};
const contactCreate = this.connection.sobject("Contact") as unknown as {
create: (data: Record<string, unknown>) => Promise<SalesforceCreateResult>;
};
const contactResult = await contactCreate.create(contactData);
return contactResult.id;
} catch (error) {
this.logger.error("Failed to find or create contact for case", {
error: getErrorMessage(error),
});
throw error;
}
}
private async createSalesforceCase(
caseData: CaseData & { contactId: string }
): Promise<SalesforceCaseRecord & { Owner?: { Name?: string } }> {
const validTypes = ["Question", "Problem", "Feature Request"];
const validPriorities = ["Low", "Medium", "High", "Critical"];
const sfData = {
Subject: caseData.subject.trim().substring(0, 255),
Description: caseData.description.trim().substring(0, 32000),
ContactId: caseData.contactId,
AccountId: caseData.accountId,
Type: validTypes.includes(caseData.type || "") ? caseData.type : "Question",
Priority: validPriorities.includes(caseData.priority || "") ? caseData.priority : "Medium",
Origin: caseData.origin || "Web",
};
const caseCreate = this.connection.sobject("Case") as unknown as {
create: (data: Record<string, unknown>) => Promise<SalesforceCreateResult>;
};
const caseResult = await caseCreate.create(sfData);
const createdCases = (await this.connection.query(`
SELECT Id, CaseNumber, Subject, Description, Status, Priority, Type, Origin,
CreatedDate, LastModifiedDate, ClosedDate, ContactId, AccountId, OwnerId, Owner.Name
FROM Case
WHERE Id = '${caseResult.id}'
`)) as SalesforceQueryResult<SalesforceCaseRecord & { Owner?: { Name?: string } }>;
return createdCases.records[0] ?? ({} as SalesforceCaseRecord);
}
private transformCase(sfCase: SalesforceCaseRecord & { Owner?: { Name?: string } }): SupportCase {
const status = sfCase.Status ?? "";
const priority = sfCase.Priority ?? "";
const type = sfCase.Type ?? "";
const createdDate = sfCase.CreatedDate ?? new Date().toISOString();
return {
id: sfCase.Id,
number: sfCase.CaseNumber ?? "",
subject: sfCase.Subject ?? "",
description: sfCase.Description ?? "",
status: this.mapSalesforceStatus(status),
priority: this.mapSalesforcePriority(priority),
type: this.mapSalesforceType(type),
createdDate,
lastModifiedDate: sfCase.LastModifiedDate ?? createdDate,
closedDate: sfCase.ClosedDate,
contactId: sfCase.ContactId ?? "",
accountId: sfCase.AccountId ?? "",
ownerId: sfCase.OwnerId ?? "",
ownerName: sfCase.Owner?.Name,
};
}
private mapSalesforceStatus(status: string): CaseStatus {
// Map Salesforce status values to our enum
switch (status) {
case "New":
return CASE_STATUS.NEW;
case "Working":
case "In Progress":
return CASE_STATUS.WORKING;
case "Escalated":
return CASE_STATUS.ESCALATED;
case "Closed":
return CASE_STATUS.CLOSED;
default:
return CASE_STATUS.NEW; // Default fallback
}
}
private mapSalesforcePriority(priority: string): CasePriority {
// Map Salesforce priority values to our enum
switch (priority) {
case "Low":
return CASE_PRIORITY.LOW;
case "Medium":
return CASE_PRIORITY.MEDIUM;
case "High":
return CASE_PRIORITY.HIGH;
case "Critical":
return CASE_PRIORITY.CRITICAL;
default:
return CASE_PRIORITY.MEDIUM; // Default fallback
}
}
private mapSalesforceType(type: string): CaseType {
// Map Salesforce type values to our enum
switch (type) {
case "Question":
return "Question";
case "Problem":
return "Problem";
case "Feature Request":
return "Feature Request";
default:
return "Question"; // Default fallback
}
}
private validateId(id: string): string {
const trimmed = id?.trim();
if (!trimmed || trimmed.length !== 18 || !/^[a-zA-Z0-9]{18}$/.test(trimmed)) {
throw new Error("Invalid Salesforce ID format");
}
return trimmed;
}
private safeSoql(input: string): string {
return input.replace(/'/g, "\\'");
}
}

View File

@ -1,39 +0,0 @@
import { BadRequestException } from "@nestjs/common";
import { assertSalesforceId, buildInClause, sanitizeSoqlLiteral } from "../soql.util";
describe("soql.util", () => {
describe("sanitizeSoqlLiteral", () => {
it("escapes single quotes and backslashes", () => {
const raw = "O'Reilly \\"books\\"";
const result = sanitizeSoqlLiteral(raw);
expect(result).toBe("O\\'Reilly \\\\"books\\\\"");
});
it("returns original string when no escaping needed", () => {
const raw = "Sample-Value";
expect(sanitizeSoqlLiteral(raw)).toBe(raw);
});
});
describe("assertSalesforceId", () => {
it("returns the id when valid", () => {
const id = "0015g00000N1ABC";
expect(assertSalesforceId(id, "context")).toBe(id);
});
it("throws BadRequestException when id is invalid", () => {
expect(() => assertSalesforceId("invalid", "context")).toThrow(BadRequestException);
});
});
describe("buildInClause", () => {
it("builds sanitized comma separated list", () => {
const clause = buildInClause(["abc", "O'Reilly"], "field");
expect(clause).toBe("'abc', 'O\\'Reilly'");
});
it("throws when no values provided", () => {
expect(() => buildInClause([], "field")).toThrow(BadRequestException);
});
});
});

View File

@ -1,22 +0,0 @@
import { BadRequestException } from "@nestjs/common";
const SALESFORCE_ID_PATTERN = /^[a-zA-Z0-9]{15}(?:[a-zA-Z0-9]{3})?$/;
export function sanitizeSoqlLiteral(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
}
export function assertSalesforceId(value: string | undefined | null, context: string): string {
if (!value || !SALESFORCE_ID_PATTERN.test(value)) {
throw new BadRequestException(`Invalid Salesforce ID for ${context}`);
}
return value;
}
export function buildInClause(values: string[], context: string): string {
if (values.length === 0) {
throw new BadRequestException(`At least one value required for ${context}`);
}
return values.map(raw => `'${sanitizeSoqlLiteral(raw)}'`).join(", ");
}

View File

@ -239,15 +239,17 @@ export class WhmcsConnectionService {
typeof errorResponse.message === "string" && typeof errorResponse.message === "string" &&
errorResponse.message.toLowerCase().includes("client not found") errorResponse.message.toLowerCase().includes("client not found")
) { ) {
const byEmail = const emailParam = params["email"];
typeof (params as any).email === "string" ? (params as any).email : undefined; if (typeof emailParam === "string") {
if (byEmail) { throw new NotFoundException(`Client with email ${emailParam} not found`);
throw new NotFoundException(`Client with email ${byEmail} not found`);
} }
const byId = (params as any).clientid;
throw new NotFoundException( const clientIdParam = params["clientid"];
`Client ${typeof byId === "string" || typeof byId === "number" ? byId : ""} not found` const identifier =
); typeof clientIdParam === "string" || typeof clientIdParam === "number"
? clientIdParam
: "";
throw new NotFoundException(`Client ${identifier} not found`);
} }
throw new Error(`WHMCS API Error: ${errorResponse.message}`); throw new Error(`WHMCS API Error: ${errorResponse.message}`);
} }

View File

@ -66,7 +66,7 @@ export class WhmcsDataTransformer {
}); });
return invoice; return invoice;
} catch (error) { } catch (error: unknown) {
this.logger.error(`Failed to transform invoice ${invoiceId}`, { this.logger.error(`Failed to transform invoice ${invoiceId}`, {
error: getErrorMessage(error), error: getErrorMessage(error),
whmcsData: this.sanitizeForLog(whmcsInvoice as unknown as Record<string, unknown>), whmcsData: this.sanitizeForLog(whmcsInvoice as unknown as Record<string, unknown>),
@ -129,7 +129,7 @@ export class WhmcsDataTransformer {
}); });
return subscription; return subscription;
} catch (error) { } catch (error: unknown) {
this.logger.error(`Failed to transform subscription ${String(whmcsProduct.id)}`, { this.logger.error(`Failed to transform subscription ${String(whmcsProduct.id)}`, {
error: getErrorMessage(error), error: getErrorMessage(error),
whmcsData: this.sanitizeForLog(whmcsProduct as unknown as Record<string, unknown>), whmcsData: this.sanitizeForLog(whmcsProduct as unknown as Record<string, unknown>),

View File

@ -7,27 +7,29 @@ let app: INestApplication | null = null;
const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"]; const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
for (const signal of signals) { for (const signal of signals) {
process.once(signal, async () => { process.once(signal, () => {
logger.log(`Received ${signal}. Closing Nest application...`); void (async () => {
logger.log(`Received ${signal}. Closing Nest application...`);
if (!app) { if (!app) {
logger.warn("Nest application not initialized. Exiting immediately."); logger.warn("Nest application not initialized. Exiting immediately.");
process.exit(0); process.exit(0);
return; return;
} }
try { try {
await app.close(); await app.close();
logger.log("Nest application closed gracefully."); logger.log("Nest application closed gracefully.");
} catch (error) { } catch (error) {
const resolvedError = error as Error; const resolvedError = error as Error;
logger.error( logger.error(
`Error during Nest application shutdown: ${resolvedError.message}`, `Error during Nest application shutdown: ${resolvedError.message}`,
resolvedError.stack resolvedError.stack
); );
} finally { } finally {
process.exit(0); process.exit(0);
} }
})();
}); });
} }

View File

@ -105,7 +105,7 @@ export class AuthController {
@ApiResponse({ status: 200, description: "Login successful" }) @ApiResponse({ status: 200, description: "Login successful" })
@ApiResponse({ status: 401, description: "Invalid credentials" }) @ApiResponse({ status: 401, description: "Invalid credentials" })
@ApiResponse({ status: 429, description: "Too many login attempts" }) @ApiResponse({ status: 429, description: "Too many login attempts" })
async login(@Req() req: Request & { user: { id: string; email: string; role?: string } }) { async login(@Req() req: Request & { user: { id: string; email: string; role: string } }) {
return this.authService.login(req.user, req); return this.authService.login(req.user, req);
} }
@ -219,7 +219,7 @@ export class AuthController {
@Get("me") @Get("me")
@ApiOperation({ summary: "Get current authentication status" }) @ApiOperation({ summary: "Get current authentication status" })
getAuthStatus(@Req() req: Request & { user: { id: string; email: string; role?: string } }) { getAuthStatus(@Req() req: Request & { user: { id: string; email: string; role: string } }) {
// Return basic auth info only - full profile should use /api/me // Return basic auth info only - full profile should use /api/me
return { return {
isAuthenticated: true, isAuthenticated: true,

View File

@ -5,6 +5,7 @@ import { ConfigService } from "@nestjs/config";
import { APP_GUARD } from "@nestjs/core"; import { APP_GUARD } from "@nestjs/core";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { AuthController } from "./auth-zod.controller"; import { AuthController } from "./auth-zod.controller";
import { AuthAdminController } from "./auth-admin.controller";
import { UsersModule } from "@bff/modules/users/users.module"; import { UsersModule } from "@bff/modules/users/users.module";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module"; import { MappingsModule } from "@bff/modules/id-mappings/mappings.module";
import { IntegrationsModule } from "@bff/integrations/integrations.module"; import { IntegrationsModule } from "@bff/integrations/integrations.module";
@ -33,7 +34,7 @@ import { WhmcsLinkWorkflowService } from "./services/workflows/whmcs-link-workfl
IntegrationsModule, IntegrationsModule,
EmailModule, EmailModule,
], ],
controllers: [AuthController], controllers: [AuthController, AuthAdminController],
providers: [ providers: [
AuthService, AuthService,
JwtStrategy, JwtStrategy,

View File

@ -148,6 +148,7 @@ export class AuthService {
{ {
id: profile.id, id: profile.id,
email: profile.email, email: profile.email,
role: prismaUser.role,
}, },
{ {
userAgent: request?.headers["user-agent"], userAgent: request?.headers["user-agent"],
@ -155,7 +156,10 @@ export class AuthService {
); );
return { return {
user: profile, user: {
...profile,
role: prismaUser.role,
},
tokens, tokens,
}; };
} }
@ -176,7 +180,7 @@ export class AuthService {
email: string, email: string,
password: string, password: string,
_request?: Request _request?: Request
): Promise<{ id: string; email: string; role?: string } | null> { ): Promise<{ id: string; email: string; role: string } | null> {
const user = await this.usersService.findByEmailInternal(email); const user = await this.usersService.findByEmailInternal(email);
if (!user) { if (!user) {
@ -224,7 +228,7 @@ export class AuthService {
return { return {
id: user.id, id: user.id,
email: user.email, email: user.email,
role: user.role || undefined, role: user.role,
}; };
} else { } else {
// Increment failed login attempts // Increment failed login attempts

View File

@ -1,4 +1,4 @@
import type { UserProfile } from "@customer-portal/domain"; import type { AuthenticatedUser } from "@customer-portal/domain";
import type { Request } from "express"; import type { Request } from "express";
export type RequestWithUser = Request & { user: UserProfile }; export type RequestWithUser = Request & { user: AuthenticatedUser };

View File

@ -1,5 +1,6 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { ThrottlerGuard } from "@nestjs/throttler"; import { ThrottlerGuard } from "@nestjs/throttler";
import { createHash } from "crypto";
import type { Request } from "express"; import type { Request } from "express";
@Injectable() @Injectable()
@ -16,9 +17,11 @@ export class AuthThrottleGuard extends ThrottlerGuard {
"unknown"; "unknown";
const userAgent = req.headers["user-agent"] || "unknown"; const userAgent = req.headers["user-agent"] || "unknown";
const userAgentHash = Buffer.from(userAgent).toString("base64").slice(0, 16); const userAgentHash = createHash("sha256").update(userAgent).digest("hex").slice(0, 16);
const resolvedIp = await Promise.resolve(ip); const normalizedIp = ip.replace(/^::ffff:/, "");
const resolvedIp = await Promise.resolve(normalizedIp);
return `auth_${resolvedIp}_${userAgentHash}`; return `auth_${resolvedIp}_${userAgentHash}`;
} }
} }

View File

@ -23,15 +23,20 @@ export class TokenBlacklistService {
// Use JwtService to safely decode and validate token // Use JwtService to safely decode and validate token
try { try {
const payload = this.jwtService.decode(token); const decoded = this.jwtService.decode(token);
// Validate payload structure if (!decoded || typeof decoded !== "object") {
if (!payload || !payload.sub || !payload.exp) {
this.logger.warn("Invalid JWT payload structure for blacklisting"); this.logger.warn("Invalid JWT payload structure for blacklisting");
return; return;
} }
const expiryTime = payload.exp * 1000; // Convert to milliseconds const { sub, exp } = decoded as { sub?: unknown; exp?: unknown };
if (typeof sub !== "string" || typeof exp !== "number") {
this.logger.warn("Invalid JWT payload structure for blacklisting");
return;
}
const expiryTime = exp * 1000; // Convert to milliseconds
const currentTime = Date.now(); const currentTime = Date.now();
const ttl = Math.max(0, Math.floor((expiryTime - currentTime) / 1000)); // Convert to seconds const ttl = Math.max(0, Math.floor((expiryTime - currentTime) / 1000)); // Convert to seconds
@ -41,7 +46,7 @@ export class TokenBlacklistService {
} else { } else {
this.logger.debug("Token already expired, not blacklisting"); this.logger.debug("Token already expired, not blacklisting");
} }
} catch (parseError) { } catch (_parseError: unknown) {
// If we can't parse the token, blacklist it for the default JWT expiry time // If we can't parse the token, blacklist it for the default JWT expiry time
try { try {
const defaultTtl = parseJwtExpiry(this.configService.get("JWT_EXPIRES_IN", "7d")); const defaultTtl = parseJwtExpiry(this.configService.get("JWT_EXPIRES_IN", "7d"));

View File

@ -12,6 +12,21 @@ export interface RefreshTokenPayload {
tokenId: string; tokenId: string;
deviceId?: string; deviceId?: string;
userAgent?: string; userAgent?: string;
type: "refresh";
}
interface StoredRefreshToken {
familyId: string;
userId: string;
valid: boolean;
}
interface StoredRefreshTokenFamily {
userId: string;
tokenHash: string;
deviceId?: string;
userAgent?: string;
createdAt?: string;
} }
@Injectable() @Injectable()
@ -61,6 +76,7 @@ export class AuthTokenService {
tokenId: familyId, tokenId: familyId,
deviceId: deviceInfo?.deviceId, deviceId: deviceInfo?.deviceId,
userAgent: deviceInfo?.userAgent, userAgent: deviceInfo?.userAgent,
type: "refresh",
}; };
// Generate tokens // Generate tokens
@ -76,41 +92,41 @@ export class AuthTokenService {
const refreshTokenHash = this.hashToken(refreshToken); const refreshTokenHash = this.hashToken(refreshToken);
const refreshExpirySeconds = this.parseExpiryToSeconds(this.REFRESH_TOKEN_EXPIRY); const refreshExpirySeconds = this.parseExpiryToSeconds(this.REFRESH_TOKEN_EXPIRY);
if (this.redis.status !== "ready") { if (this.redis.status === "ready") {
this.logger.error("Redis not ready for token issuance", { status: this.redis.status }); try {
throw new UnauthorizedException("Session service unavailable"); await this.redis.ping();
} await this.redis.setex(
`${this.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`,
refreshExpirySeconds,
JSON.stringify({
userId: user.id,
tokenHash: refreshTokenHash,
deviceId: deviceInfo?.deviceId,
userAgent: deviceInfo?.userAgent,
createdAt: new Date().toISOString(),
})
);
try { // Store individual refresh token
await this.redis.ping(); await this.redis.setex(
await this.redis.setex( `${this.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`,
`${this.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`, refreshExpirySeconds,
refreshExpirySeconds, JSON.stringify({
JSON.stringify({ familyId,
userId: user.id,
valid: true,
})
);
} catch (error) {
this.logger.error("Failed to store refresh token in Redis", {
error: error instanceof Error ? error.message : String(error),
userId: user.id, userId: user.id,
tokenHash: refreshTokenHash, });
deviceId: deviceInfo?.deviceId, }
userAgent: deviceInfo?.userAgent, } else {
createdAt: new Date().toISOString(), this.logger.warn("Redis not ready for token issuance; issuing non-rotating tokens", {
}) status: this.redis.status,
);
// Store individual refresh token
await this.redis.setex(
`${this.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`,
refreshExpirySeconds,
JSON.stringify({
familyId,
userId: user.id,
valid: true,
})
);
} catch (error) {
this.logger.error("Failed to store refresh token in Redis", {
error: error instanceof Error ? error.message : String(error),
userId: user.id,
}); });
throw new UnauthorizedException("Unable to issue session tokens. Please try again.");
} }
const accessExpiresAt = new Date( const accessExpiresAt = new Date(
@ -143,7 +159,15 @@ export class AuthTokenService {
): Promise<AuthTokens> { ): Promise<AuthTokens> {
try { try {
// Verify refresh token // Verify refresh token
const payload = this.jwtService.verify(refreshToken); const payload = this.jwtService.verify<RefreshTokenPayload>(refreshToken);
if (payload.type !== "refresh") {
this.logger.warn("Token presented to refresh endpoint is not a refresh token", {
tokenId: payload.tokenId,
});
throw new UnauthorizedException("Invalid refresh token");
}
const refreshTokenHash = this.hashToken(refreshToken); const refreshTokenHash = this.hashToken(refreshToken);
// Check if refresh token exists and is valid // Check if refresh token exists and is valid
@ -185,7 +209,7 @@ export class AuthTokenService {
const user = { const user = {
id: prismaUser.id, id: prismaUser.id,
email: prismaUser.email, email: prismaUser.email,
role: prismaUser.role.toLowerCase(), // Convert UserRole enum to lowercase string role: prismaUser.role,
}; };
// Invalidate current refresh token // Invalidate current refresh token
@ -201,6 +225,33 @@ export class AuthTokenService {
this.logger.error("Token refresh failed", { this.logger.error("Token refresh failed", {
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
}); });
if (this.redis.status !== "ready") {
this.logger.warn("Redis unavailable during token refresh; issuing fallback token pair");
const fallbackPayload = this.jwtService.decode(refreshToken) as
| RefreshTokenPayload
| null;
const fallbackUserId = fallbackPayload?.userId;
if (fallbackUserId) {
const fallbackUser = await this.usersService
.findByIdInternal(fallbackUserId)
.catch(() => null);
if (fallbackUser) {
return this.generateTokenPair(
{
id: fallbackUser.id,
email: fallbackUser.email,
role: fallbackUser.role,
},
deviceInfo
);
}
}
}
throw new UnauthorizedException("Invalid refresh token"); throw new UnauthorizedException("Invalid refresh token");
} }
} }

View File

@ -1,15 +1,16 @@
import { Injectable } from "@nestjs/common"; import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport"; import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt"; import { ExtractJwt, Strategy } from "passport-jwt";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import type { UserProfile } from "@customer-portal/domain"; import type { AuthenticatedUser } from "@customer-portal/domain";
import { TokenBlacklistService } from "../services/token-blacklist.service"; import { UsersService } from "@bff/modules/users/users.service";
import { mapPrismaUserToUserProfile } from "@bff/infra/utils/user-mapper.util";
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) { export class JwtStrategy extends PassportStrategy(Strategy) {
constructor( constructor(
private configService: ConfigService, private configService: ConfigService,
private tokenBlacklistService: TokenBlacklistService private readonly usersService: UsersService
) { ) {
const jwtSecret = configService.get<string>("JWT_SECRET"); const jwtSecret = configService.get<string>("JWT_SECRET");
if (!jwtSecret) { if (!jwtSecret) {
@ -31,28 +32,24 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
role: string; role: string;
iat?: number; iat?: number;
exp?: number; exp?: number;
}): Promise<UserProfile> { }): Promise<AuthenticatedUser> {
// Validate payload structure // Validate payload structure
if (!payload.sub || !payload.email) { if (!payload.sub || !payload.email) {
throw new Error("Invalid JWT payload"); throw new Error("Invalid JWT payload");
} }
// Return user info - token blacklist is checked in GlobalAuthGuard const prismaUser = await this.usersService.findByIdInternal(payload.sub);
// This separation allows us to avoid request object dependency here
return { if (!prismaUser) {
id: payload.sub, throw new UnauthorizedException("User not found");
email: payload.email, }
firstName: undefined,
lastName: undefined, if (prismaUser.email !== payload.email) {
company: undefined, throw new UnauthorizedException("Token subject does not match user record");
phone: undefined, }
mfaEnabled: false,
emailVerified: true, const profile = mapPrismaUserToUserProfile(prismaUser);
createdAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(), return profile;
avatar: undefined,
preferences: {},
lastLoginAt: undefined,
};
} }
} }

View File

@ -14,7 +14,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
req: Request, req: Request,
email: string, email: string,
password: string password: string
): Promise<{ id: string; email: string; role?: string }> { ): Promise<{ id: string; email: string; role: string }> {
const user = await this.authService.validateUser(email, password, req); const user = await this.authService.validateUser(email, password, req);
if (!user) { if (!user) {
throw new UnauthorizedException("Invalid credentials"); throw new UnauthorizedException("Invalid credentials");

View File

@ -2,6 +2,7 @@ import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
import { getSalesforceFieldMap } from "@bff/core/config/field-map"; import { getSalesforceFieldMap } from "@bff/core/config/field-map";
import { assertSalesforceId, sanitizeSoqlLiteral } from "@bff/integrations/salesforce/utils/soql.util";
import type { import type {
SalesforceProduct2WithPricebookEntries, SalesforceProduct2WithPricebookEntries,
SalesforceQueryResult, SalesforceQueryResult,
@ -15,7 +16,8 @@ export class BaseCatalogService {
protected readonly sf: SalesforceConnection, protected readonly sf: SalesforceConnection,
@Inject(Logger) protected readonly logger: Logger @Inject(Logger) protected readonly logger: Logger
) { ) {
this.portalPriceBookId = process.env.PORTAL_PRICEBOOK_ID || "01sTL000008eLVlYAM"; const portalPricebook = process.env.PORTAL_PRICEBOOK_ID || "01sTL000008eLVlYAM";
this.portalPriceBookId = assertSalesforceId(portalPricebook, "PORTAL_PRICEBOOK_ID");
} }
protected getFields() { protected getFields() {
@ -66,12 +68,15 @@ export class BaseCatalogService {
]; ];
const allFields = [...baseFields, ...additionalFields].join(", "); const allFields = [...baseFields, ...additionalFields].join(", ");
const safeCategory = sanitizeSoqlLiteral(category);
const safeItemClass = sanitizeSoqlLiteral(itemClass);
return ` return `
SELECT ${allFields}, SELECT ${allFields},
(SELECT Id, UnitPrice, Pricebook2Id, Product2Id, IsActive FROM PricebookEntries WHERE Pricebook2Id = '${this.portalPriceBookId}' AND IsActive = true LIMIT 1) (SELECT Id, UnitPrice, Pricebook2Id, Product2Id, IsActive FROM PricebookEntries WHERE Pricebook2Id = '${this.portalPriceBookId}' AND IsActive = true LIMIT 1)
FROM Product2 FROM Product2
WHERE ${fields.product.portalCategory} = '${category}' WHERE ${fields.product.portalCategory} = '${safeCategory}'
AND ${fields.product.itemClass} = '${itemClass}' AND ${fields.product.itemClass} = '${safeItemClass}'
AND ${fields.product.portalAccessible} = true AND ${fields.product.portalAccessible} = true
${additionalConditions} ${additionalConditions}
ORDER BY ${fields.product.displayOrder} NULLS LAST, Name ORDER BY ${fields.product.displayOrder} NULLS LAST, Name

View File

@ -10,6 +10,7 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
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 { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util";
import { import {
mapInternetPlan, mapInternetPlan,
mapInternetInstallation, mapInternetInstallation,
@ -116,7 +117,8 @@ export class InternetCatalogService extends BaseCatalogService {
// Get customer's eligibility from Salesforce // Get customer's eligibility from Salesforce
const fields = this.getFields(); const fields = this.getFields();
const soql = `SELECT Id, ${fields.account.internetEligibility} FROM Account WHERE Id = '${mapping.sfAccountId}' LIMIT 1`; const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId");
const soql = `SELECT Id, ${fields.account.internetEligibility} FROM Account WHERE Id = '${sfAccountId}' LIMIT 1`;
const accounts = await this.executeQuery(soql, "Customer Eligibility"); const accounts = await this.executeQuery(soql, "Customer Eligibility");
if (accounts.length === 0) { if (accounts.length === 0) {

View File

@ -1,9 +1,7 @@
import type { import type {
CatalogProductBase, CatalogProductBase,
CatalogPricebookEntry,
SalesforceProduct2WithPricebookEntries, SalesforceProduct2WithPricebookEntries,
SalesforcePricebookEntryRecord, SalesforcePricebookEntryRecord,
InternetCatalogProduct,
InternetPlanCatalogItem, InternetPlanCatalogItem,
InternetInstallationCatalogItem, InternetInstallationCatalogItem,
InternetAddonCatalogItem, InternetAddonCatalogItem,
@ -18,12 +16,68 @@ const fieldMap = getSalesforceFieldMap();
export type SalesforceCatalogProductRecord = SalesforceProduct2WithPricebookEntries; export type SalesforceCatalogProductRecord = SalesforceProduct2WithPricebookEntries;
const DEFAULT_PLAN_TEMPLATE: InternetPlanTemplate = {
tierDescription: "Standard plan",
description: undefined,
features: undefined,
};
function getTierTemplate(tier?: string): InternetPlanTemplate {
if (!tier) {
return DEFAULT_PLAN_TEMPLATE;
}
const normalized = tier.toLowerCase();
switch (normalized) {
case "gold":
return {
tierDescription: "Gold plan",
description: "Premium speed internet plan",
features: ["Highest bandwidth", "Priority support"],
};
case "silver":
return {
tierDescription: "Silver plan",
description: "Balanced performance plan",
features: ["Great value", "Reliable speeds"],
};
case "bronze":
return {
tierDescription: "Bronze plan",
description: "Entry level plan",
features: ["Essential connectivity"],
};
default:
return {
tierDescription: `${tier} plan`,
description: undefined,
features: undefined,
};
}
}
function inferInstallationTypeFromSku(sku: string): "One-time" | "12-Month" | "24-Month" {
const normalized = sku.toLowerCase();
if (normalized.includes("24")) return "24-Month";
if (normalized.includes("12")) return "12-Month";
return "One-time";
}
function inferAddonTypeFromSku(
sku: string
): "hikari-denwa-service" | "hikari-denwa-installation" | "other" {
const normalized = sku.toLowerCase();
if (normalized.includes("installation")) return "hikari-denwa-installation";
if (normalized.includes("denwa")) return "hikari-denwa-service";
return "other";
}
function getProductField<T = unknown>( function getProductField<T = unknown>(
product: SalesforceCatalogProductRecord, product: SalesforceCatalogProductRecord,
fieldKey: keyof typeof fieldMap.product fieldKey: keyof typeof fieldMap.product
): T | undefined { ): T | undefined {
const salesforceField = fieldMap.product[fieldKey]; const salesforceField = fieldMap.product[fieldKey] as keyof SalesforceCatalogProductRecord;
const value = (product as Record<string, unknown>)[salesforceField]; const value = product[salesforceField];
return value as T | undefined; return value as T | undefined;
} }
@ -52,28 +106,6 @@ function coerceNumber(value: unknown): number | undefined {
return undefined; return undefined;
} }
function buildPricebookEntry(
entry?: SalesforcePricebookEntryRecord
): CatalogPricebookEntry | undefined {
if (!entry) return undefined;
return {
id: entry.Id,
name: entry.Name,
unitPrice: coerceNumber(entry.UnitPrice),
pricebook2Id: entry.Pricebook2Id ?? undefined,
product2Id: entry.Product2Id ?? undefined,
isActive: entry.IsActive ?? undefined,
};
}
function getPricebookEntry(
product: SalesforceCatalogProductRecord
): CatalogPricebookEntry | undefined {
const nested = product.PricebookEntries?.records;
if (!Array.isArray(nested) || nested.length === 0) return undefined;
return buildPricebookEntry(nested[0]);
}
function baseProduct(product: SalesforceCatalogProductRecord): CatalogProductBase { function baseProduct(product: SalesforceCatalogProductRecord): CatalogProductBase {
const sku = getStringField(product, "sku") ?? ""; const sku = getStringField(product, "sku") ?? "";
const base: CatalogProductBase = { const base: CatalogProductBase = {
@ -101,7 +133,7 @@ function parseFeatureList(product: SalesforceCatalogProductRecord): string[] | u
} }
if (typeof raw === "string") { if (typeof raw === "string") {
try { try {
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed) return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === "string") ? parsed.filter((item): item is string => typeof item === "string")
: undefined; : undefined;
@ -119,8 +151,8 @@ function derivePrices(
const billingCycle = getStringField(product, "billingCycle")?.toLowerCase(); const billingCycle = getStringField(product, "billingCycle")?.toLowerCase();
const unitPrice = pricebookEntry ? coerceNumber(pricebookEntry.UnitPrice) : undefined; const unitPrice = pricebookEntry ? coerceNumber(pricebookEntry.UnitPrice) : undefined;
let monthlyPrice = undefined; let monthlyPrice: number | undefined;
let oneTimePrice = undefined; let oneTimePrice: number | undefined;
if (unitPrice !== undefined) { if (unitPrice !== undefined) {
if (billingCycle === "monthly") { if (billingCycle === "monthly") {

View File

@ -299,20 +299,22 @@ export class MappingsService {
async getMappingStats(): Promise<MappingStats> { async getMappingStats(): Promise<MappingStats> {
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(), this.prisma.idMapping.count({ where: { whmcsClientId: { not: null } } }),
this.prisma.idMapping.count({ where: { sfAccountId: { not: null } } }), this.prisma.idMapping.count({ where: { sfAccountId: { not: null } } }),
this.prisma.idMapping.count({ where: { sfAccountId: { not: null } } }), this.prisma.idMapping.count({ where: { whmcsClientId: { not: null }, sfAccountId: { not: null } } }),
]); ]);
const stats: MappingStats = { const orphanedMappings = whmcsCount - completeCount;
totalMappings: totalCount,
whmcsMappings: whmcsCount, const stats: MappingStats = {
salesforceMappings: sfCount, totalMappings: totalCount,
completeMappings: completeCount, whmcsMappings: whmcsCount,
orphanedMappings: 0, salesforceMappings: sfCount,
}; completeMappings: completeCount,
orphanedMappings: orphanedMappings < 0 ? 0 : orphanedMappings,
};
this.logger.debug("Generated mapping statistics", stats); this.logger.debug("Generated mapping statistics", stats);
return stats; return stats;
} catch (error) { } catch (error) {

View File

@ -47,7 +47,10 @@ export class OrderItemBuilder {
} }
if (!meta.unitPrice) { if (!meta.unitPrice) {
this.logger.error({ sku: normalizedSkuValue, pbeId: meta.pricebookEntryId }, "PricebookEntry missing UnitPrice"); this.logger.error(
{ sku: normalizedSkuValue, pbeId: meta.pricebookEntryId },
"PricebookEntry missing UnitPrice"
);
throw new Error(`PricebookEntry for SKU ${normalizedSkuValue} has no UnitPrice set`); throw new Error(`PricebookEntry for SKU ${normalizedSkuValue} has no UnitPrice set`);
} }
@ -71,7 +74,10 @@ export class OrderItemBuilder {
this.logger.log({ orderId, sku: normalizedSkuValue }, "OrderItem created successfully"); this.logger.log({ orderId, sku: normalizedSkuValue }, "OrderItem created successfully");
} catch (error) { } catch (error) {
this.logger.error({ error, orderId, sku: normalizedSkuValue }, "Failed to create OrderItem"); this.logger.error(
{ error, orderId, sku: normalizedSkuValue },
"Failed to create OrderItem"
);
throw error; throw error;
} }
} }

View File

@ -7,12 +7,12 @@ import { OrderItemBuilder } from "./order-item-builder.service";
import { import {
orderDetailsSchema, orderDetailsSchema,
orderSummarySchema, orderSummarySchema,
type OrderDetailsResponse, z,
type OrderSummaryResponse,
type OrderItemSummary, type OrderItemSummary,
type SalesforceOrderRecord, type SalesforceOrderRecord,
type SalesforceOrderItemRecord, type SalesforceOrderItemRecord,
type SalesforceQueryResult, type SalesforceQueryResult,
type SalesforceProduct2Record,
} from "@customer-portal/domain"; } from "@customer-portal/domain";
import { import {
getSalesforceFieldMap, getSalesforceFieldMap,
@ -23,25 +23,30 @@ import { assertSalesforceId, buildInClause } from "@bff/integrations/salesforce/
const fieldMap = getSalesforceFieldMap(); const fieldMap = getSalesforceFieldMap();
type OrderDetailsResponse = z.infer<typeof orderDetailsSchema>;
type OrderSummaryResponse = z.infer<typeof orderSummarySchema>;
function getOrderStringField( function getOrderStringField(
order: SalesforceOrderRecord, order: SalesforceOrderRecord,
key: keyof typeof fieldMap.order key: keyof typeof fieldMap.order
): string | undefined { ): string | undefined {
const raw = (order as Record<string, unknown>)[fieldMap.order[key]]; const fieldName = fieldMap.order[key] as keyof SalesforceOrderRecord;
const raw = order[fieldName];
return typeof raw === "string" ? raw : undefined; return typeof raw === "string" ? raw : undefined;
} }
function pickProductString( function pickProductString(
product: Record<string, unknown> | undefined, product: SalesforceProduct2Record | null | undefined,
key: keyof typeof fieldMap.product key: keyof typeof fieldMap.product
): string | undefined { ): string | undefined {
if (!product) return undefined; if (!product) return undefined;
const raw = product[fieldMap.product[key]]; const fieldName = fieldMap.product[key] as keyof SalesforceProduct2Record;
const raw = product[fieldName];
return typeof raw === "string" ? raw : undefined; return typeof raw === "string" ? raw : undefined;
} }
function mapOrderItemRecord(record: SalesforceOrderItemRecord): ParsedOrderItemDetails { function mapOrderItemRecord(record: SalesforceOrderItemRecord): ParsedOrderItemDetails {
const product = record.PricebookEntry?.Product2 as Record<string, unknown> | undefined; const product = record.PricebookEntry?.Product2 ?? undefined;
return { return {
id: record.Id ?? "", id: record.Id ?? "",
@ -249,6 +254,7 @@ export class OrderOrchestrator {
name: detail.product.name, name: detail.product.name,
sku: detail.product.sku, sku: detail.product.sku,
itemClass: detail.product.itemClass, itemClass: detail.product.itemClass,
billingCycle: detail.billingCycle,
whmcsProductId: detail.product.whmcsProductId, whmcsProductId: detail.product.whmcsProductId,
internetOfferingType: detail.product.internetOfferingType, internetOfferingType: detail.product.internetOfferingType,
internetPlanTier: detail.product.internetPlanTier, internetPlanTier: detail.product.internetPlanTier,

View File

@ -1,5 +1,6 @@
import { Injectable, BadRequestException, Inject } from "@nestjs/common"; import { Injectable, BadRequestException, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { ZodError } from "zod";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { WhmcsConnectionService } from "@bff/integrations/whmcs/services/whmcs-connection.service"; import { WhmcsConnectionService } from "@bff/integrations/whmcs/services/whmcs-connection.service";
import { getErrorMessage } from "@bff/core/utils/error.util"; import { getErrorMessage } from "@bff/core/utils/error.util";
@ -242,10 +243,26 @@ export class OrderValidator {
const validatedBody = this.validateRequestFormat(rawBody); const validatedBody = this.validateRequestFormat(rawBody);
// 1b. Business validation (ensures userId-specific constraints) // 1b. Business validation (ensures userId-specific constraints)
const businessValidatedBody = orderBusinessValidationSchema.parse({ let businessValidatedBody: OrderBusinessValidation;
...validatedBody, try {
userId, businessValidatedBody = orderBusinessValidationSchema.parse({
}); ...validatedBody,
userId,
});
} catch (error) {
if (error instanceof ZodError) {
const issues = error.issues.map(issue => {
const path = issue.path.join(".");
return path ? `${path}: ${issue.message}` : issue.message;
});
throw new BadRequestException({
message: "Order business validation failed",
errors: issues,
statusCode: 400,
});
}
throw error;
}
// 2. User and payment validation // 2. User and payment validation
const userMapping = await this.validateUserMapping(userId); const userMapping = await this.validateUserMapping(userId);

View File

@ -4,11 +4,7 @@ import {
mapPrismaUserToSharedUser, mapPrismaUserToSharedUser,
mapPrismaUserToEnhancedBase, mapPrismaUserToEnhancedBase,
} from "@bff/infra/utils/user-mapper.util"; } from "@bff/infra/utils/user-mapper.util";
import type { import type { UpdateAddressRequest, SalesforceContactRecord } from "@customer-portal/domain";
UpdateAddressRequest,
SalesforceAccountRecord,
SalesforceContactRecord,
} from "@customer-portal/domain";
import { Injectable, Inject, NotFoundException, BadRequestException } from "@nestjs/common"; import { Injectable, Inject, NotFoundException, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { PrismaService } from "@bff/infra/database/prisma.service"; import { PrismaService } from "@bff/infra/database/prisma.service";
@ -20,9 +16,6 @@ import { SalesforceService } from "@bff/integrations/salesforce/salesforce.servi
import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
// Salesforce Account interface based on the data model
interface SalesforceAccount extends SalesforceAccountRecord {}
// Use a subset of PrismaUser for updates // Use a subset of PrismaUser for updates
type UserUpdateData = Partial< type UserUpdateData = Partial<
Pick< Pick<
@ -165,11 +158,11 @@ export class UsersService {
try { try {
const client = await this.whmcsService.getClientDetails(mapping.whmcsClientId); const client = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
if (client) { if (client) {
firstName = (client as any).firstname || firstName; firstName = client.firstname || firstName;
lastName = (client as any).lastname || lastName; lastName = client.lastname || lastName;
company = (client as any).companyname || company; company = client.companyname || company;
phone = (client as any).phonenumber || phone; phone = client.phonenumber || phone;
email = (client as any).email || email; email = client.email || email;
} }
} catch (err) { } catch (err) {
this.logger.warn("WHMCS client details unavailable for profile enrichment", { this.logger.warn("WHMCS client details unavailable for profile enrichment", {
@ -181,12 +174,10 @@ export class UsersService {
} }
// Check Salesforce health flag (do not override fields) // Check Salesforce health flag (do not override fields)
let salesforceHealthy = true;
if (mapping?.sfAccountId) { if (mapping?.sfAccountId) {
try { try {
await this.salesforceService.getAccount(mapping.sfAccountId); await this.salesforceService.getAccount(mapping.sfAccountId);
} catch (error) { } catch (error) {
salesforceHealthy = false;
this.logger.error("Failed to fetch Salesforce account data", { this.logger.error("Failed to fetch Salesforce account data", {
error: getErrorMessage(error), error: getErrorMessage(error),
userId, userId,

View File

@ -16,6 +16,8 @@ export function SessionTimeoutWarning({
const [showWarning, setShowWarning] = useState(false); const [showWarning, setShowWarning] = useState(false);
const [timeLeft, setTimeLeft] = useState<number>(0); const [timeLeft, setTimeLeft] = useState<number>(0);
const expiryRef = useRef<number | null>(null); const expiryRef = useRef<number | null>(null);
const dialogRef = useRef<HTMLDivElement | null>(null);
const previouslyFocusedElement = useRef<HTMLElement | null>(null);
useEffect(() => { useEffect(() => {
if (!isAuthenticated || !tokens?.expiresAt) { if (!isAuthenticated || !tokens?.expiresAt) {
@ -65,6 +67,12 @@ export function SessionTimeoutWarning({
useEffect(() => { useEffect(() => {
if (!showWarning || !expiryRef.current) return undefined; if (!showWarning || !expiryRef.current) return undefined;
previouslyFocusedElement.current = document.activeElement as HTMLElement | null;
const focusTimer = window.setTimeout(() => {
dialogRef.current?.focus();
}, 0);
const interval = setInterval(() => { const interval = setInterval(() => {
const expiryTime = expiryRef.current; const expiryTime = expiryRef.current;
if (!expiryTime) { if (!expiryTime) {
@ -81,7 +89,53 @@ export function SessionTimeoutWarning({
setTimeLeft(Math.max(1, Math.ceil(remaining / (60 * 1000)))); setTimeLeft(Math.max(1, Math.ceil(remaining / (60 * 1000))));
}, 60000); }, 60000);
return () => clearInterval(interval); return () => {
clearInterval(interval);
clearTimeout(focusTimer);
previouslyFocusedElement.current?.focus();
};
}, [showWarning, logout]);
useEffect(() => {
if (!showWarning) return undefined;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
setShowWarning(false);
void logout();
}
if (event.key === "Tab") {
const focusableElements = dialogRef.current?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusableElements || focusableElements.length === 0) {
event.preventDefault();
return;
}
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [showWarning, logout]); }, [showWarning, logout]);
const handleExtendSession = () => { const handleExtendSession = () => {
@ -107,14 +161,28 @@ export function SessionTimeoutWarning({
} }
return ( return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"> <div
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4 shadow-xl"> className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
role="presentation"
aria-hidden={!showWarning}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="session-timeout-title"
aria-describedby="session-timeout-description"
tabIndex={-1}
className="bg-white rounded-lg p-6 max-w-md w-full mx-4 shadow-xl outline-none"
>
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<span className="text-yellow-500 text-xl"></span> <span className="text-yellow-500 text-xl"></span>
<h2 className="text-lg font-semibold">Session Expiring Soon</h2> <h2 id="session-timeout-title" className="text-lg font-semibold">
Session Expiring Soon
</h2>
</div> </div>
<p className="text-gray-600 mb-6"> <p id="session-timeout-description" className="text-gray-600 mb-6">
Your session will expire in{" "} Your session will expire in{" "}
<strong> <strong>
{timeLeft} minute{timeLeft !== 1 ? "s" : ""} {timeLeft} minute{timeLeft !== 1 ? "s" : ""}

View File

@ -56,8 +56,15 @@ export interface SimActivationFeeCatalogItem extends SimCatalogProduct {
isDefault: boolean; isDefault: boolean;
}; };
} }
export interface VpnCatalogProduct extends CatalogProductBase { export interface VpnCatalogProduct extends CatalogProductBase {
vpnRegion?: string; vpnRegion?: string;
} }
export interface CatalogPricebookEntry {
id?: string;
name?: string;
unitPrice?: number;
pricebook2Id?: string;
product2Id?: string;
isActive?: boolean;
}

View File

@ -18,6 +18,10 @@ export interface SalesforceSObjectBase {
LastModifiedDate?: IsoDateTimeString; LastModifiedDate?: IsoDateTimeString;
} }
export type SalesforceOrderStatus = string;
export type SalesforceOrderType = string;
export type SalesforceOrderItemStatus = string;
export interface SalesforceProduct2Record extends SalesforceSObjectBase { export interface SalesforceProduct2Record extends SalesforceSObjectBase {
Name?: string; Name?: string;
StockKeepingUnit?: string; StockKeepingUnit?: string;
@ -53,8 +57,7 @@ export interface SalesforcePricebookEntryRecord extends SalesforceSObjectBase {
Product2?: SalesforceProduct2Record | null; Product2?: SalesforceProduct2Record | null;
} }
export interface SalesforceProduct2WithPricebookEntries export interface SalesforceProduct2WithPricebookEntries extends SalesforceProduct2Record {
extends SalesforceProduct2Record {
PricebookEntries?: { PricebookEntries?: {
records?: SalesforcePricebookEntryRecord[]; records?: SalesforcePricebookEntryRecord[];
}; };
@ -100,6 +103,7 @@ export interface SalesforceOrderRecord extends SalesforceSObjectBase {
EffectiveDate?: IsoDateTimeString | null; EffectiveDate?: IsoDateTimeString | null;
TotalAmount?: number | null; TotalAmount?: number | null;
AccountId?: string | null; AccountId?: string | null;
Account?: { Name?: string | null } | null;
Pricebook2Id?: string | null; Pricebook2Id?: string | null;
Activation_Type__c?: string | null; Activation_Type__c?: string | null;
Activation_Status__c?: string | null; Activation_Status__c?: string | null;
@ -120,6 +124,25 @@ export interface SalesforceOrderRecord extends SalesforceSObjectBase {
ActivatedDate?: IsoDateTimeString | null; ActivatedDate?: IsoDateTimeString | null;
} }
export interface SalesforceOrderItemSummary {
productName?: string;
sku?: string;
status?: SalesforceOrderItemStatus;
billingCycle?: string;
}
export interface SalesforceOrderSummary {
id: string;
orderNumber: string;
status: SalesforceOrderStatus;
orderType?: SalesforceOrderType;
effectiveDate: IsoDateTimeString;
totalAmount?: number;
createdDate: IsoDateTimeString;
lastModifiedDate: IsoDateTimeString;
whmcsOrderId?: string;
itemsSummary: SalesforceOrderItemSummary[];
}
export interface SalesforceOrderItemRecord extends SalesforceSObjectBase { export interface SalesforceOrderItemRecord extends SalesforceSObjectBase {
OrderId?: string | null; OrderId?: string | null;
Quantity?: number | null; Quantity?: number | null;
@ -130,7 +153,6 @@ export interface SalesforceOrderItemRecord extends SalesforceSObjectBase {
Billing_Cycle__c?: string | null; Billing_Cycle__c?: string | null;
WHMCS_Service_ID__c?: string | null; WHMCS_Service_ID__c?: string | null;
} }
export interface SalesforceAccountContactRecord extends SalesforceSObjectBase { export interface SalesforceAccountContactRecord extends SalesforceSObjectBase {
AccountId?: string | null; AccountId?: string | null;
ContactId?: string | null; ContactId?: string | null;
@ -142,4 +164,3 @@ export interface SalesforceContactRecord extends SalesforceSObjectBase {
Email?: string | null; Email?: string | null;
Phone?: string | null; Phone?: string | null;
} }

View File

@ -1,6 +1,7 @@
// Export all entities - resolving conflicts explicitly // Export all entities - resolving conflicts explicitly
export * from "./user"; export * from "./user";
export * from "./invoice"; export * from "./invoice";
export * from "./subscription";
export * from "./payment"; export * from "./payment";
export * from "./case"; export * from "./case";
export * from "./dashboard"; export * from "./dashboard";

View File

@ -1,9 +1,9 @@
// Invoice types from WHMCS // Invoice types from WHMCS
export type { import type { InvoiceSchema, InvoiceItemSchema, InvoiceListSchema } from "../validation";
InvoiceSchema as Invoice,
InvoiceItemSchema as InvoiceItem, export type Invoice = InvoiceSchema;
InvoiceListSchema as InvoiceList, export type InvoiceItem = InvoiceItemSchema;
} from "../validation"; export type InvoiceList = InvoiceListSchema;
export interface InvoiceSsoLink { export interface InvoiceSsoLink {
url: string; url: string;

View File

@ -0,0 +1,8 @@
import type { SubscriptionSchema } from "../validation";
export type Subscription = SubscriptionSchema;
export interface SubscriptionList {
subscriptions: Subscription[];
totalCount: number;
}

View File

@ -109,6 +109,12 @@ export interface UserProfile extends User {
lastLoginAt?: string; lastLoginAt?: string;
} }
export type UserRole = "user" | "admin";
export interface AuthenticatedUser extends UserProfile {
role: UserRole;
}
// MNP (Mobile Number Portability) business entity // MNP (Mobile Number Portability) business entity
export interface MnpDetails { export interface MnpDetails {
currentProvider: string; currentProvider: string;

View File

@ -1,13 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { userProfileSchema } from "../shared/entities"; import { userProfileSchema } from "../shared/entities";
import {
orderDetailItemSchema,
orderDetailItemProductSchema,
orderDetailsSchema,
orderSummaryItemSchema,
orderSummarySchema,
} from "../shared/order";
export const authResponseSchema = z.object({ export const authResponseSchema = z.object({
user: userProfileSchema, user: userProfileSchema,
@ -22,7 +15,3 @@ export const authResponseSchema = z.object({
export type AuthResponse = z.infer<typeof authResponseSchema>; export type AuthResponse = z.infer<typeof authResponseSchema>;
export type AuthTokensSchema = AuthResponse["tokens"]; export type AuthTokensSchema = AuthResponse["tokens"];
export { orderDetailsSchema, orderSummarySchema };
export type OrderDetailsResponse = z.infer<typeof orderDetailsSchema>;
export type OrderSummaryResponse = z.infer<typeof orderSummarySchema>;

View File

@ -3,8 +3,6 @@
* Frontend form schemas that extend API request schemas with UI-specific fields * Frontend form schemas that extend API request schemas with UI-specific fields
*/ */
import { z } from "zod";
import { import {
loginRequestSchema, loginRequestSchema,
signupRequestSchema, signupRequestSchema,
@ -59,23 +57,31 @@ export const changePasswordFormToRequest = (
// Import API types // Import API types
import type { import type {
LoginRequestInput as LoginRequestData, LoginRequestInput,
SignupRequestInput as SignupRequestData, SignupRequestInput,
PasswordResetRequestInput as PasswordResetRequestData, PasswordResetRequestInput,
PasswordResetInput as PasswordResetData, PasswordResetInput,
SetPasswordRequestInput as SetPasswordRequestData, SetPasswordRequestInput,
ChangePasswordRequestInput as ChangePasswordRequestData, ChangePasswordRequestInput,
LinkWhmcsRequestInput as LinkWhmcsRequestData, LinkWhmcsRequestInput,
} from "../api/requests"; } from "../api/requests";
// Export form types type LoginRequestData = LoginRequestInput;
export type LoginFormData = z.infer<typeof loginFormSchema>; type SignupRequestData = SignupRequestInput;
export type SignupFormData = z.infer<typeof signupFormSchema>; type PasswordResetRequestData = PasswordResetRequestInput;
export type PasswordResetRequestFormData = z.infer<typeof passwordResetRequestFormSchema>; type PasswordResetData = PasswordResetInput;
export type PasswordResetFormData = z.infer<typeof passwordResetFormSchema>; type SetPasswordRequestData = SetPasswordRequestInput;
export type SetPasswordFormData = z.infer<typeof setPasswordFormSchema>; type ChangePasswordRequestData = ChangePasswordRequestInput;
export type ChangePasswordFormData = z.infer<typeof changePasswordFormSchema>; type LinkWhmcsRequestData = LinkWhmcsRequestInput;
export type LinkWhmcsFormData = z.infer<typeof linkWhmcsFormSchema>;
// Export form types (aliases of API request types)
export type LoginFormData = LoginRequestInput;
export type SignupFormData = SignupRequestInput;
export type PasswordResetRequestFormData = PasswordResetRequestInput;
export type PasswordResetFormData = PasswordResetInput;
export type SetPasswordFormData = SetPasswordRequestInput;
export type ChangePasswordFormData = ChangePasswordRequestInput;
export type LinkWhmcsFormData = LinkWhmcsRequestInput;
// Re-export API types for convenience // Re-export API types for convenience
export type { export type {

View File

@ -62,9 +62,12 @@ export const contactFormToRequest = (formData: ContactFormData): ContactRequestD
import type { import type {
UpdateProfileRequest as UpdateProfileRequestData, UpdateProfileRequest as UpdateProfileRequestData,
ContactRequest as ContactRequestData, ContactRequest as ContactRequestData,
UpdateAddressRequest as UpdateAddressRequestData,
} from "../api/requests"; } from "../api/requests";
// Export form types and API request types // Export form types and API request types
export type ProfileEditFormData = z.infer<typeof profileEditFormSchema>; export type ProfileEditFormData = z.infer<typeof profileEditFormSchema>;
export type AddressFormData = z.infer<typeof addressFormSchema>; export type AddressFormData = z.infer<typeof addressFormSchema>;
export type ContactFormData = z.infer<typeof contactFormSchema>; export type ContactFormData = z.infer<typeof contactFormSchema>;
export type { UpdateProfileRequestData, UpdateAddressRequestData, ContactRequestData };

View File

@ -46,8 +46,11 @@ export {
simChangePlanRequestSchema, simChangePlanRequestSchema,
simFeaturesRequestSchema, simFeaturesRequestSchema,
// Contact API schemas // Contact & billing schemas
contactRequestSchema, contactRequestSchema,
invoiceItemSchema,
invoiceSchema,
invoiceListSchema,
// API types // API types
type LoginRequestInput, type LoginRequestInput,
@ -74,13 +77,7 @@ export {
} from "./api/requests"; } from "./api/requests";
// Form schemas (frontend) - explicit exports for better tree shaking // Form schemas (frontend) - explicit exports for better tree shaking
export { export { authResponseSchema, type AuthResponse, type AuthTokensSchema } from "./api/responses";
authResponseSchema,
type AuthResponse,
type AuthTokensSchema,
type OrderDetailsResponse,
type OrderSummaryResponse,
} from "./api/responses";
export { export {
// Auth form schemas // Auth form schemas

View File

@ -92,6 +92,7 @@ export const userProfileSchema = userSchema.extend({
avatar: z.string().optional(), avatar: z.string().optional(),
preferences: z.record(z.string(), z.unknown()).optional(), preferences: z.record(z.string(), z.unknown()).optional(),
lastLoginAt: timestampSchema.optional(), lastLoginAt: timestampSchema.optional(),
role: z.enum(["user", "admin"]),
}); });
export const prismaUserProfileSchema = z.object({ export const prismaUserProfileSchema = z.object({

View File

@ -47,7 +47,30 @@ export {
// Validation utilities and helpers // Validation utilities and helpers
export * from "./utilities"; export * from "./utilities";
export { userSchema, userProfileSchema } from "./entities"; export {
userSchema,
userProfileSchema,
invoiceItemSchema,
invoiceSchema,
invoiceListSchema,
subscriptionSchema,
paymentMethodSchema,
paymentSchema,
caseCommentSchema,
supportCaseSchema,
} from "./entities";
export type {
UserSchema,
UserProfileSchema,
InvoiceItemSchema,
InvoiceSchema,
InvoiceListSchema,
SubscriptionSchema,
PaymentMethodSchema,
PaymentSchema,
CaseCommentSchema,
SupportCaseSchema,
} from "./entities";
export { export {
orderItemProductSchema, orderItemProductSchema,
orderDetailItemSchema, orderDetailItemSchema,

View File

@ -62,3 +62,8 @@ export const orderSummarySchema = z.object({
itemsSummary: z.array(orderSummaryItemSchema), itemsSummary: z.array(orderSummaryItemSchema),
}); });
export type OrderItemProduct = z.infer<typeof orderItemProductSchema>;
export type OrderDetailItem = z.infer<typeof orderDetailItemSchema>;
export type OrderItemSummary = z.infer<typeof orderSummaryItemSchema>;
export type OrderDetailsResponse = z.infer<typeof orderDetailsSchema>;
export type OrderSummaryResponse = z.infer<typeof orderSummarySchema>;