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,
HttpStatus,
} from "@nestjs/common";
import { Response } from "express";
import type { Request, Response } from "express";
import { Logger } from "nestjs-pino";
interface StandardErrorResponse {
@ -32,21 +32,41 @@ export class AuthErrorFilter implements ExceptionFilter {
) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
const message = exception.message;
const status = exception.getStatus() as HttpStatus;
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
const userMessage = this.getUserFriendlyMessage(message, status);
const errorCode = this.getErrorCode(message, status);
const userMessage = this.getUserFriendlyMessage(messageText, status);
const errorCode = this.getErrorCode(messageText, status);
// 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", {
path: request.url,
method: request.method,
errorCode,
userAgent: request.headers["user-agent"],
userAgent,
ip: request.ip,
});
@ -63,7 +83,7 @@ export class AuthErrorFilter implements ExceptionFilter {
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
if (status === HttpStatus.UNAUTHORIZED) {
if (
@ -117,7 +137,7 @@ export class AuthErrorFilter implements ExceptionFilter {
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 (message.includes("Invalid credentials") || message.includes("Invalid email or password"))
return "INVALID_CREDENTIALS";

View File

@ -9,10 +9,14 @@ import {
import { Request, Response } from "express";
import { getClientSafeErrorMessage } from "../utils/error.util";
import { Logger } from "nestjs-pino";
import { ConfigService } from "@nestjs/config";
@Catch()
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 {
const ctx = host.switchToHttp();
@ -51,7 +55,9 @@ export class GlobalExceptionFilter implements ExceptionFilter {
}
const clientSafeMessage =
process.env.NODE_ENV === "production" ? getClientSafeErrorMessage(message) : message;
this.configService.get("NODE_ENV") === "production"
? getClientSafeErrorMessage(message)
: message;
const code = (error || "InternalServerError")
.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 {
try {
return emailSchema.parse(email);
} catch (error) {
} catch {
throw new BadRequestException("Invalid email format");
}
}
@ -19,7 +19,7 @@ export function normalizeAndValidateEmail(email: string): string {
export function validateUuidV4OrThrow(id: string): string {
try {
return uuidSchema.parse(id);
} catch (error) {
} catch {
throw new Error("Invalid user ID format");
}
}

View File

@ -19,7 +19,7 @@ export function ZodPipe(schema: ZodSchema) {
export function ZodPipeClass(schema: ZodSchema) {
@Injectable()
class ZodPipeClass implements PipeTransform {
transform(value: any, metadata: ArgumentMetadata) {
transform(value: unknown, _metadata: ArgumentMetadata) {
const result = schema.safeParse(value);
if (!result.success) {
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";
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 normalizedRole = user.role?.toLowerCase() === "admin" ? "admin" : "user";
return {
...shared,
avatar: undefined,
preferences: {},
lastLoginAt: user.lastLoginAt ? user.lastLoginAt.toISOString() : undefined,
role: normalizedRole,
};
}

View File

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

View File

@ -18,11 +18,32 @@ export interface FreebititAccountDetailsRequest {
authKey: string;
version?: string | number; // Docs recommend "2"
requestDatas: Array<{
kind: "MASTER" | "MVNO" | string;
kind: "MASTER" | "MVNO";
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 {
resultCode: string;
status: {
@ -30,47 +51,7 @@ export interface FreebititAccountDetailsResponse {
statusCode: string | number;
};
masterAccount?: string;
responseDatas:
| {
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 };
}>;
responseDatas: FreebititAccountDetail | FreebititAccountDetail[];
}
export interface FreebititTrafficInfoRequest {

View File

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

View File

@ -9,12 +9,6 @@ import {
type AccountData,
type UpsertResult,
} 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";
/**
@ -24,8 +18,7 @@ import type { SalesforceAccountRecord } from "@customer-portal/domain";
* - findAccountByCustomerNumber() - auth service (WHMCS linking)
* - upsertAccount() - auth service (signup)
* - getAccount() - users service (profile enhancement)
* - getCases() - future support functionality
* - createCase() - future support functionality
* Support-case functionality has been deferred and is intentionally absent.
*/
@Injectable()
export class SalesforceService implements OnModuleInit {
@ -33,7 +26,6 @@ export class SalesforceService implements OnModuleInit {
private configService: ConfigService,
private connection: SalesforceConnection,
private accountService: SalesforceAccountService,
private caseService: SalesforceCaseService,
@Inject(Logger) private readonly logger: Logger
) {}
@ -86,26 +78,6 @@ export class SalesforceService implements OnModuleInit {
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) ===
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" &&
errorResponse.message.toLowerCase().includes("client not found")
) {
const byEmail =
typeof (params as any).email === "string" ? (params as any).email : undefined;
if (byEmail) {
throw new NotFoundException(`Client with email ${byEmail} not found`);
const emailParam = params["email"];
if (typeof emailParam === "string") {
throw new NotFoundException(`Client with email ${emailParam} not found`);
}
const byId = (params as any).clientid;
throw new NotFoundException(
`Client ${typeof byId === "string" || typeof byId === "number" ? byId : ""} not found`
);
const clientIdParam = params["clientid"];
const identifier =
typeof clientIdParam === "string" || typeof clientIdParam === "number"
? clientIdParam
: "";
throw new NotFoundException(`Client ${identifier} not found`);
}
throw new Error(`WHMCS API Error: ${errorResponse.message}`);
}

View File

@ -66,7 +66,7 @@ export class WhmcsDataTransformer {
});
return invoice;
} catch (error) {
} catch (error: unknown) {
this.logger.error(`Failed to transform invoice ${invoiceId}`, {
error: getErrorMessage(error),
whmcsData: this.sanitizeForLog(whmcsInvoice as unknown as Record<string, unknown>),
@ -129,7 +129,7 @@ export class WhmcsDataTransformer {
});
return subscription;
} catch (error) {
} catch (error: unknown) {
this.logger.error(`Failed to transform subscription ${String(whmcsProduct.id)}`, {
error: getErrorMessage(error),
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"];
for (const signal of signals) {
process.once(signal, async () => {
logger.log(`Received ${signal}. Closing Nest application...`);
process.once(signal, () => {
void (async () => {
logger.log(`Received ${signal}. Closing Nest application...`);
if (!app) {
logger.warn("Nest application not initialized. Exiting immediately.");
process.exit(0);
return;
}
if (!app) {
logger.warn("Nest application not initialized. Exiting immediately.");
process.exit(0);
return;
}
try {
await app.close();
logger.log("Nest application closed gracefully.");
} catch (error) {
const resolvedError = error as Error;
logger.error(
`Error during Nest application shutdown: ${resolvedError.message}`,
resolvedError.stack
);
} finally {
process.exit(0);
}
try {
await app.close();
logger.log("Nest application closed gracefully.");
} catch (error) {
const resolvedError = error as Error;
logger.error(
`Error during Nest application shutdown: ${resolvedError.message}`,
resolvedError.stack
);
} finally {
process.exit(0);
}
})();
});
}

View File

@ -105,7 +105,7 @@ export class AuthController {
@ApiResponse({ status: 200, description: "Login successful" })
@ApiResponse({ status: 401, description: "Invalid credentials" })
@ApiResponse({ status: 429, description: "Too many login attempts" })
async login(@Req() req: Request & { user: { id: string; email: string; role?: string } }) {
async login(@Req() req: Request & { user: { id: string; email: string; role: string } }) {
return this.authService.login(req.user, req);
}
@ -219,7 +219,7 @@ export class AuthController {
@Get("me")
@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 {
isAuthenticated: true,

View File

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

View File

@ -148,6 +148,7 @@ export class AuthService {
{
id: profile.id,
email: profile.email,
role: prismaUser.role,
},
{
userAgent: request?.headers["user-agent"],
@ -155,7 +156,10 @@ export class AuthService {
);
return {
user: profile,
user: {
...profile,
role: prismaUser.role,
},
tokens,
};
}
@ -176,7 +180,7 @@ export class AuthService {
email: string,
password: string,
_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);
if (!user) {
@ -224,7 +228,7 @@ export class AuthService {
return {
id: user.id,
email: user.email,
role: user.role || undefined,
role: user.role,
};
} else {
// 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";
export type RequestWithUser = Request & { user: UserProfile };
export type RequestWithUser = Request & { user: AuthenticatedUser };

View File

@ -1,5 +1,6 @@
import { Injectable } from "@nestjs/common";
import { ThrottlerGuard } from "@nestjs/throttler";
import { createHash } from "crypto";
import type { Request } from "express";
@Injectable()
@ -16,9 +17,11 @@ export class AuthThrottleGuard extends ThrottlerGuard {
"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}`;
}
}

View File

@ -23,15 +23,20 @@ export class TokenBlacklistService {
// Use JwtService to safely decode and validate token
try {
const payload = this.jwtService.decode(token);
const decoded = this.jwtService.decode(token);
// Validate payload structure
if (!payload || !payload.sub || !payload.exp) {
if (!decoded || typeof decoded !== "object") {
this.logger.warn("Invalid JWT payload structure for blacklisting");
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 ttl = Math.max(0, Math.floor((expiryTime - currentTime) / 1000)); // Convert to seconds
@ -41,7 +46,7 @@ export class TokenBlacklistService {
} else {
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
try {
const defaultTtl = parseJwtExpiry(this.configService.get("JWT_EXPIRES_IN", "7d"));

View File

@ -12,6 +12,21 @@ export interface RefreshTokenPayload {
tokenId: string;
deviceId?: 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()
@ -61,6 +76,7 @@ export class AuthTokenService {
tokenId: familyId,
deviceId: deviceInfo?.deviceId,
userAgent: deviceInfo?.userAgent,
type: "refresh",
};
// Generate tokens
@ -76,41 +92,41 @@ export class AuthTokenService {
const refreshTokenHash = this.hashToken(refreshToken);
const refreshExpirySeconds = this.parseExpiryToSeconds(this.REFRESH_TOKEN_EXPIRY);
if (this.redis.status !== "ready") {
this.logger.error("Redis not ready for token issuance", { status: this.redis.status });
throw new UnauthorizedException("Session service unavailable");
}
if (this.redis.status === "ready") {
try {
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 {
await this.redis.ping();
await this.redis.setex(
`${this.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`,
refreshExpirySeconds,
JSON.stringify({
// 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,
tokenHash: refreshTokenHash,
deviceId: deviceInfo?.deviceId,
userAgent: deviceInfo?.userAgent,
createdAt: new Date().toISOString(),
})
);
// 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,
});
}
} else {
this.logger.warn("Redis not ready for token issuance; issuing non-rotating tokens", {
status: this.redis.status,
});
throw new UnauthorizedException("Unable to issue session tokens. Please try again.");
}
const accessExpiresAt = new Date(
@ -143,7 +159,15 @@ export class AuthTokenService {
): Promise<AuthTokens> {
try {
// 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);
// Check if refresh token exists and is valid
@ -185,7 +209,7 @@ export class AuthTokenService {
const user = {
id: prismaUser.id,
email: prismaUser.email,
role: prismaUser.role.toLowerCase(), // Convert UserRole enum to lowercase string
role: prismaUser.role,
};
// Invalidate current refresh token
@ -201,6 +225,33 @@ export class AuthTokenService {
this.logger.error("Token refresh failed", {
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");
}
}

View File

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

View File

@ -14,7 +14,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
req: Request,
email: 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);
if (!user) {
throw new UnauthorizedException("Invalid credentials");

View File

@ -2,6 +2,7 @@ import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
import { getSalesforceFieldMap } from "@bff/core/config/field-map";
import { assertSalesforceId, sanitizeSoqlLiteral } from "@bff/integrations/salesforce/utils/soql.util";
import type {
SalesforceProduct2WithPricebookEntries,
SalesforceQueryResult,
@ -15,7 +16,8 @@ export class BaseCatalogService {
protected readonly sf: SalesforceConnection,
@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() {
@ -66,12 +68,15 @@ export class BaseCatalogService {
];
const allFields = [...baseFields, ...additionalFields].join(", ");
const safeCategory = sanitizeSoqlLiteral(category);
const safeItemClass = sanitizeSoqlLiteral(itemClass);
return `
SELECT ${allFields},
(SELECT Id, UnitPrice, Pricebook2Id, Product2Id, IsActive FROM PricebookEntries WHERE Pricebook2Id = '${this.portalPriceBookId}' AND IsActive = true LIMIT 1)
FROM Product2
WHERE ${fields.product.portalCategory} = '${category}'
AND ${fields.product.itemClass} = '${itemClass}'
WHERE ${fields.product.portalCategory} = '${safeCategory}'
AND ${fields.product.itemClass} = '${safeItemClass}'
AND ${fields.product.portalAccessible} = true
${additionalConditions}
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 { Logger } from "nestjs-pino";
import { getErrorMessage } from "@bff/core/utils/error.util";
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util";
import {
mapInternetPlan,
mapInternetInstallation,
@ -116,7 +117,8 @@ export class InternetCatalogService extends BaseCatalogService {
// Get customer's eligibility from Salesforce
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");
if (accounts.length === 0) {

View File

@ -1,9 +1,7 @@
import type {
CatalogProductBase,
CatalogPricebookEntry,
SalesforceProduct2WithPricebookEntries,
SalesforcePricebookEntryRecord,
InternetCatalogProduct,
InternetPlanCatalogItem,
InternetInstallationCatalogItem,
InternetAddonCatalogItem,
@ -18,12 +16,68 @@ const fieldMap = getSalesforceFieldMap();
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>(
product: SalesforceCatalogProductRecord,
fieldKey: keyof typeof fieldMap.product
): T | undefined {
const salesforceField = fieldMap.product[fieldKey];
const value = (product as Record<string, unknown>)[salesforceField];
const salesforceField = fieldMap.product[fieldKey] as keyof SalesforceCatalogProductRecord;
const value = product[salesforceField];
return value as T | undefined;
}
@ -52,28 +106,6 @@ function coerceNumber(value: unknown): number | 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 {
const sku = getStringField(product, "sku") ?? "";
const base: CatalogProductBase = {
@ -101,7 +133,7 @@ function parseFeatureList(product: SalesforceCatalogProductRecord): string[] | u
}
if (typeof raw === "string") {
try {
const parsed = JSON.parse(raw);
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === "string")
: undefined;
@ -119,8 +151,8 @@ function derivePrices(
const billingCycle = getStringField(product, "billingCycle")?.toLowerCase();
const unitPrice = pricebookEntry ? coerceNumber(pricebookEntry.UnitPrice) : undefined;
let monthlyPrice = undefined;
let oneTimePrice = undefined;
let monthlyPrice: number | undefined;
let oneTimePrice: number | undefined;
if (unitPrice !== undefined) {
if (billingCycle === "monthly") {

View File

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

View File

@ -47,7 +47,10 @@ export class OrderItemBuilder {
}
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`);
}
@ -71,7 +74,10 @@ export class OrderItemBuilder {
this.logger.log({ orderId, sku: normalizedSkuValue }, "OrderItem created successfully");
} 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;
}
}

View File

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

View File

@ -1,5 +1,6 @@
import { Injectable, BadRequestException, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { ZodError } from "zod";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { WhmcsConnectionService } from "@bff/integrations/whmcs/services/whmcs-connection.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
@ -242,10 +243,26 @@ export class OrderValidator {
const validatedBody = this.validateRequestFormat(rawBody);
// 1b. Business validation (ensures userId-specific constraints)
const businessValidatedBody = orderBusinessValidationSchema.parse({
...validatedBody,
userId,
});
let businessValidatedBody: OrderBusinessValidation;
try {
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
const userMapping = await this.validateUserMapping(userId);

View File

@ -4,11 +4,7 @@ import {
mapPrismaUserToSharedUser,
mapPrismaUserToEnhancedBase,
} from "@bff/infra/utils/user-mapper.util";
import type {
UpdateAddressRequest,
SalesforceAccountRecord,
SalesforceContactRecord,
} from "@customer-portal/domain";
import type { UpdateAddressRequest, SalesforceContactRecord } from "@customer-portal/domain";
import { Injectable, Inject, NotFoundException, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { PrismaService } from "@bff/infra/database/prisma.service";
@ -20,9 +16,6 @@ import { SalesforceService } from "@bff/integrations/salesforce/salesforce.servi
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
type UserUpdateData = Partial<
Pick<
@ -165,11 +158,11 @@ export class UsersService {
try {
const client = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
if (client) {
firstName = (client as any).firstname || firstName;
lastName = (client as any).lastname || lastName;
company = (client as any).companyname || company;
phone = (client as any).phonenumber || phone;
email = (client as any).email || email;
firstName = client.firstname || firstName;
lastName = client.lastname || lastName;
company = client.companyname || company;
phone = client.phonenumber || phone;
email = client.email || email;
}
} catch (err) {
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)
let salesforceHealthy = true;
if (mapping?.sfAccountId) {
try {
await this.salesforceService.getAccount(mapping.sfAccountId);
} catch (error) {
salesforceHealthy = false;
this.logger.error("Failed to fetch Salesforce account data", {
error: getErrorMessage(error),
userId,

View File

@ -16,6 +16,8 @@ export function SessionTimeoutWarning({
const [showWarning, setShowWarning] = useState(false);
const [timeLeft, setTimeLeft] = useState<number>(0);
const expiryRef = useRef<number | null>(null);
const dialogRef = useRef<HTMLDivElement | null>(null);
const previouslyFocusedElement = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isAuthenticated || !tokens?.expiresAt) {
@ -65,6 +67,12 @@ export function SessionTimeoutWarning({
useEffect(() => {
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 expiryTime = expiryRef.current;
if (!expiryTime) {
@ -81,7 +89,53 @@ export function SessionTimeoutWarning({
setTimeLeft(Math.max(1, Math.ceil(remaining / (60 * 1000))));
}, 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]);
const handleExtendSession = () => {
@ -107,14 +161,28 @@ export function SessionTimeoutWarning({
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4 shadow-xl">
<div
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">
<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>
<p className="text-gray-600 mb-6">
<p id="session-timeout-description" className="text-gray-600 mb-6">
Your session will expire in{" "}
<strong>
{timeLeft} minute{timeLeft !== 1 ? "s" : ""}

View File

@ -56,8 +56,15 @@ export interface SimActivationFeeCatalogItem extends SimCatalogProduct {
isDefault: boolean;
};
}
export interface VpnCatalogProduct extends CatalogProductBase {
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;
}
export type SalesforceOrderStatus = string;
export type SalesforceOrderType = string;
export type SalesforceOrderItemStatus = string;
export interface SalesforceProduct2Record extends SalesforceSObjectBase {
Name?: string;
StockKeepingUnit?: string;
@ -53,8 +57,7 @@ export interface SalesforcePricebookEntryRecord extends SalesforceSObjectBase {
Product2?: SalesforceProduct2Record | null;
}
export interface SalesforceProduct2WithPricebookEntries
extends SalesforceProduct2Record {
export interface SalesforceProduct2WithPricebookEntries extends SalesforceProduct2Record {
PricebookEntries?: {
records?: SalesforcePricebookEntryRecord[];
};
@ -100,6 +103,7 @@ export interface SalesforceOrderRecord extends SalesforceSObjectBase {
EffectiveDate?: IsoDateTimeString | null;
TotalAmount?: number | null;
AccountId?: string | null;
Account?: { Name?: string | null } | null;
Pricebook2Id?: string | null;
Activation_Type__c?: string | null;
Activation_Status__c?: string | null;
@ -120,6 +124,25 @@ export interface SalesforceOrderRecord extends SalesforceSObjectBase {
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 {
OrderId?: string | null;
Quantity?: number | null;
@ -130,7 +153,6 @@ export interface SalesforceOrderItemRecord extends SalesforceSObjectBase {
Billing_Cycle__c?: string | null;
WHMCS_Service_ID__c?: string | null;
}
export interface SalesforceAccountContactRecord extends SalesforceSObjectBase {
AccountId?: string | null;
ContactId?: string | null;
@ -142,4 +164,3 @@ export interface SalesforceContactRecord extends SalesforceSObjectBase {
Email?: string | null;
Phone?: string | null;
}

View File

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

View File

@ -1,9 +1,9 @@
// Invoice types from WHMCS
export type {
InvoiceSchema as Invoice,
InvoiceItemSchema as InvoiceItem,
InvoiceListSchema as InvoiceList,
} from "../validation";
import type { InvoiceSchema, InvoiceItemSchema, InvoiceListSchema } from "../validation";
export type Invoice = InvoiceSchema;
export type InvoiceItem = InvoiceItemSchema;
export type InvoiceList = InvoiceListSchema;
export interface InvoiceSsoLink {
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;
}
export type UserRole = "user" | "admin";
export interface AuthenticatedUser extends UserProfile {
role: UserRole;
}
// MNP (Mobile Number Portability) business entity
export interface MnpDetails {
currentProvider: string;

View File

@ -1,13 +1,6 @@
import { z } from "zod";
import { userProfileSchema } from "../shared/entities";
import {
orderDetailItemSchema,
orderDetailItemProductSchema,
orderDetailsSchema,
orderSummaryItemSchema,
orderSummarySchema,
} from "../shared/order";
export const authResponseSchema = z.object({
user: userProfileSchema,
@ -22,7 +15,3 @@ export const authResponseSchema = z.object({
export type AuthResponse = z.infer<typeof authResponseSchema>;
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
*/
import { z } from "zod";
import {
loginRequestSchema,
signupRequestSchema,
@ -59,23 +57,31 @@ export const changePasswordFormToRequest = (
// Import API types
import type {
LoginRequestInput as LoginRequestData,
SignupRequestInput as SignupRequestData,
PasswordResetRequestInput as PasswordResetRequestData,
PasswordResetInput as PasswordResetData,
SetPasswordRequestInput as SetPasswordRequestData,
ChangePasswordRequestInput as ChangePasswordRequestData,
LinkWhmcsRequestInput as LinkWhmcsRequestData,
LoginRequestInput,
SignupRequestInput,
PasswordResetRequestInput,
PasswordResetInput,
SetPasswordRequestInput,
ChangePasswordRequestInput,
LinkWhmcsRequestInput,
} from "../api/requests";
// Export form types
export type LoginFormData = z.infer<typeof loginFormSchema>;
export type SignupFormData = z.infer<typeof signupFormSchema>;
export type PasswordResetRequestFormData = z.infer<typeof passwordResetRequestFormSchema>;
export type PasswordResetFormData = z.infer<typeof passwordResetFormSchema>;
export type SetPasswordFormData = z.infer<typeof setPasswordFormSchema>;
export type ChangePasswordFormData = z.infer<typeof changePasswordFormSchema>;
export type LinkWhmcsFormData = z.infer<typeof linkWhmcsFormSchema>;
type LoginRequestData = LoginRequestInput;
type SignupRequestData = SignupRequestInput;
type PasswordResetRequestData = PasswordResetRequestInput;
type PasswordResetData = PasswordResetInput;
type SetPasswordRequestData = SetPasswordRequestInput;
type ChangePasswordRequestData = ChangePasswordRequestInput;
type LinkWhmcsRequestData = LinkWhmcsRequestInput;
// 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
export type {

View File

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

View File

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

View File

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

View File

@ -47,7 +47,30 @@ export {
// Validation utilities and helpers
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 {
orderItemProductSchema,
orderDetailItemSchema,

View File

@ -62,3 +62,8 @@ export const orderSummarySchema = z.object({
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>;