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:
parent
e66e7a5884
commit
47a3de6919
@ -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";
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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, "\\'");
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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(", ");
|
||||
}
|
||||
@ -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}`);
|
||||
}
|
||||
|
||||
@ -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>),
|
||||
|
||||
@ -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);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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" : ""}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
|
||||
8
packages/domain/src/entities/subscription.ts
Normal file
8
packages/domain/src/entities/subscription.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import type { SubscriptionSchema } from "../validation";
|
||||
|
||||
export type Subscription = SubscriptionSchema;
|
||||
|
||||
export interface SubscriptionList {
|
||||
subscriptions: Subscription[];
|
||||
totalCount: number;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user