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,
|
ConflictException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
|
|
||||||
interface StandardErrorResponse {
|
interface StandardErrorResponse {
|
||||||
@ -32,21 +32,41 @@ export class AuthErrorFilter implements ExceptionFilter {
|
|||||||
) {
|
) {
|
||||||
const ctx = host.switchToHttp();
|
const ctx = host.switchToHttp();
|
||||||
const response = ctx.getResponse<Response>();
|
const response = ctx.getResponse<Response>();
|
||||||
const request = ctx.getRequest();
|
const request = ctx.getRequest<Request>();
|
||||||
|
|
||||||
const status = exception.getStatus();
|
const status = exception.getStatus() as HttpStatus;
|
||||||
const message = exception.message;
|
const responsePayload = exception.getResponse();
|
||||||
|
const payloadMessage =
|
||||||
|
typeof responsePayload === "string"
|
||||||
|
? responsePayload
|
||||||
|
: Array.isArray((responsePayload as { message?: unknown })?.message)
|
||||||
|
? (responsePayload as { message: unknown[] }).message.find(
|
||||||
|
(value): value is string => typeof value === "string"
|
||||||
|
)
|
||||||
|
: (responsePayload as { message?: unknown })?.message;
|
||||||
|
const exceptionMessage = typeof exception.message === "string" ? exception.message : undefined;
|
||||||
|
const messageText =
|
||||||
|
payloadMessage && typeof payloadMessage === "string"
|
||||||
|
? payloadMessage
|
||||||
|
: (exceptionMessage ?? "Authentication error");
|
||||||
|
|
||||||
// Map specific auth errors to user-friendly messages
|
// Map specific auth errors to user-friendly messages
|
||||||
const userMessage = this.getUserFriendlyMessage(message, status);
|
const userMessage = this.getUserFriendlyMessage(messageText, status);
|
||||||
const errorCode = this.getErrorCode(message, status);
|
const errorCode = this.getErrorCode(messageText, status);
|
||||||
|
|
||||||
// Log the error (without sensitive information)
|
// Log the error (without sensitive information)
|
||||||
|
const userAgentHeader = request.headers["user-agent"];
|
||||||
|
const userAgent =
|
||||||
|
typeof userAgentHeader === "string"
|
||||||
|
? userAgentHeader
|
||||||
|
: Array.isArray(userAgentHeader)
|
||||||
|
? userAgentHeader[0]
|
||||||
|
: undefined;
|
||||||
this.logger.warn("Authentication error", {
|
this.logger.warn("Authentication error", {
|
||||||
path: request.url,
|
path: request.url,
|
||||||
method: request.method,
|
method: request.method,
|
||||||
errorCode,
|
errorCode,
|
||||||
userAgent: request.headers["user-agent"],
|
userAgent,
|
||||||
ip: request.ip,
|
ip: request.ip,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -63,7 +83,7 @@ export class AuthErrorFilter implements ExceptionFilter {
|
|||||||
response.status(status).json(errorResponse);
|
response.status(status).json(errorResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getUserFriendlyMessage(message: string, status: number): string {
|
private getUserFriendlyMessage(message: string, status: HttpStatus): string {
|
||||||
// Production-safe error messages that don't expose sensitive information
|
// Production-safe error messages that don't expose sensitive information
|
||||||
if (status === HttpStatus.UNAUTHORIZED) {
|
if (status === HttpStatus.UNAUTHORIZED) {
|
||||||
if (
|
if (
|
||||||
@ -117,7 +137,7 @@ export class AuthErrorFilter implements ExceptionFilter {
|
|||||||
return "Authentication error. Please try again.";
|
return "Authentication error. Please try again.";
|
||||||
}
|
}
|
||||||
|
|
||||||
private getErrorCode(message: string, status: number): string {
|
private getErrorCode(message: string, status: HttpStatus): string {
|
||||||
if (status === HttpStatus.UNAUTHORIZED) {
|
if (status === HttpStatus.UNAUTHORIZED) {
|
||||||
if (message.includes("Invalid credentials") || message.includes("Invalid email or password"))
|
if (message.includes("Invalid credentials") || message.includes("Invalid email or password"))
|
||||||
return "INVALID_CREDENTIALS";
|
return "INVALID_CREDENTIALS";
|
||||||
|
|||||||
@ -9,10 +9,14 @@ import {
|
|||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import { getClientSafeErrorMessage } from "../utils/error.util";
|
import { getClientSafeErrorMessage } from "../utils/error.util";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
|
||||||
@Catch()
|
@Catch()
|
||||||
export class GlobalExceptionFilter implements ExceptionFilter {
|
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||||
constructor(@Inject(Logger) private readonly logger: Logger) {}
|
constructor(
|
||||||
|
@Inject(Logger) private readonly logger: Logger,
|
||||||
|
private readonly configService: ConfigService
|
||||||
|
) {}
|
||||||
|
|
||||||
catch(exception: unknown, host: ArgumentsHost): void {
|
catch(exception: unknown, host: ArgumentsHost): void {
|
||||||
const ctx = host.switchToHttp();
|
const ctx = host.switchToHttp();
|
||||||
@ -51,7 +55,9 @@ export class GlobalExceptionFilter implements ExceptionFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const clientSafeMessage =
|
const clientSafeMessage =
|
||||||
process.env.NODE_ENV === "production" ? getClientSafeErrorMessage(message) : message;
|
this.configService.get("NODE_ENV") === "production"
|
||||||
|
? getClientSafeErrorMessage(message)
|
||||||
|
: message;
|
||||||
|
|
||||||
const code = (error || "InternalServerError")
|
const code = (error || "InternalServerError")
|
||||||
.replace(/([a-z])([A-Z])/g, "$1_$2")
|
.replace(/([a-z])([A-Z])/g, "$1_$2")
|
||||||
|
|||||||
@ -11,7 +11,7 @@ export const uuidSchema = z.string().uuid();
|
|||||||
export function normalizeAndValidateEmail(email: string): string {
|
export function normalizeAndValidateEmail(email: string): string {
|
||||||
try {
|
try {
|
||||||
return emailSchema.parse(email);
|
return emailSchema.parse(email);
|
||||||
} catch (error) {
|
} catch {
|
||||||
throw new BadRequestException("Invalid email format");
|
throw new BadRequestException("Invalid email format");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -19,7 +19,7 @@ export function normalizeAndValidateEmail(email: string): string {
|
|||||||
export function validateUuidV4OrThrow(id: string): string {
|
export function validateUuidV4OrThrow(id: string): string {
|
||||||
try {
|
try {
|
||||||
return uuidSchema.parse(id);
|
return uuidSchema.parse(id);
|
||||||
} catch (error) {
|
} catch {
|
||||||
throw new Error("Invalid user ID format");
|
throw new Error("Invalid user ID format");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export function ZodPipe(schema: ZodSchema) {
|
|||||||
export function ZodPipeClass(schema: ZodSchema) {
|
export function ZodPipeClass(schema: ZodSchema) {
|
||||||
@Injectable()
|
@Injectable()
|
||||||
class ZodPipeClass implements PipeTransform {
|
class ZodPipeClass implements PipeTransform {
|
||||||
transform(value: any, metadata: ArgumentMetadata) {
|
transform(value: unknown, _metadata: ArgumentMetadata) {
|
||||||
const result = schema.safeParse(value);
|
const result = schema.safeParse(value);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { User, UserProfile } from "@customer-portal/domain";
|
import type { AuthenticatedUser, User } from "@customer-portal/domain";
|
||||||
import type { User as PrismaUser } from "@prisma/client";
|
import type { User as PrismaUser } from "@prisma/client";
|
||||||
|
|
||||||
export function mapPrismaUserToSharedUser(user: PrismaUser): User {
|
export function mapPrismaUserToSharedUser(user: PrismaUser): User {
|
||||||
@ -42,13 +42,15 @@ export function mapPrismaUserToEnhancedBase(user: PrismaUser): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapPrismaUserToUserProfile(user: PrismaUser): UserProfile {
|
export function mapPrismaUserToUserProfile(user: PrismaUser): AuthenticatedUser {
|
||||||
const shared = mapPrismaUserToSharedUser(user);
|
const shared = mapPrismaUserToSharedUser(user);
|
||||||
|
const normalizedRole = user.role?.toLowerCase() === "admin" ? "admin" : "user";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...shared,
|
...shared,
|
||||||
avatar: undefined,
|
avatar: undefined,
|
||||||
preferences: {},
|
preferences: {},
|
||||||
lastLoginAt: user.lastLoginAt ? user.lastLoginAt.toISOString() : undefined,
|
lastLoginAt: user.lastLoginAt ? user.lastLoginAt.toISOString() : undefined,
|
||||||
|
role: normalizedRole,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
import type {
|
import type {
|
||||||
FreebititConfig,
|
FreebititConfig,
|
||||||
FreebititAuthRequest,
|
FreebititAuthRequest,
|
||||||
@ -35,6 +36,14 @@ import type {
|
|||||||
SimTopUpHistory,
|
SimTopUpHistory,
|
||||||
} from "./interfaces/freebit.types";
|
} from "./interfaces/freebit.types";
|
||||||
|
|
||||||
|
interface FreebitResponseBase {
|
||||||
|
resultCode?: string | number;
|
||||||
|
status?: {
|
||||||
|
message?: string;
|
||||||
|
statusCode?: string | number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FreebititService {
|
export class FreebititService {
|
||||||
private readonly config: FreebititConfig;
|
private readonly config: FreebititConfig;
|
||||||
@ -120,18 +129,19 @@ export class FreebititService {
|
|||||||
this.authKeyCache = { token: data.authKey, expiresAt: Date.now() + 50 * 60 * 1000 };
|
this.authKeyCache = { token: data.authKey, expiresAt: Date.now() + 50 * 60 * 1000 };
|
||||||
this.logger.log("Successfully authenticated with Freebit API");
|
this.logger.log("Successfully authenticated with Freebit API");
|
||||||
return data.authKey;
|
return data.authKey;
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
this.logger.error("Failed to authenticate with Freebit API", { error: error.message });
|
const message = getErrorMessage(error);
|
||||||
|
this.logger.error("Failed to authenticate with Freebit API", { error: message });
|
||||||
throw new InternalServerErrorException("Failed to authenticate with Freebit API");
|
throw new InternalServerErrorException("Failed to authenticate with Freebit API");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async makeAuthenticatedRequest<T>(
|
private async makeAuthenticatedRequest<
|
||||||
endpoint: string,
|
TResponse extends FreebitResponseBase,
|
||||||
data: Record<string, unknown>
|
TPayload extends Record<string, unknown>,
|
||||||
): Promise<T> {
|
>(endpoint: string, payload: TPayload): Promise<TResponse> {
|
||||||
const authKey = await this.getAuthKey();
|
const authKey = await this.getAuthKey();
|
||||||
const requestData = { ...data, authKey };
|
const requestData: Record<string, unknown> = { ...payload, authKey };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `${this.config.baseUrl}${endpoint}`;
|
const url = `${this.config.baseUrl}${endpoint}`;
|
||||||
@ -146,7 +156,9 @@ export class FreebititService {
|
|||||||
try {
|
try {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
bodySnippet = text ? text.slice(0, 500) : undefined;
|
bodySnippet = text ? text.slice(0, 500) : undefined;
|
||||||
} catch {}
|
} catch {
|
||||||
|
// ignore body parse errors when logging
|
||||||
|
}
|
||||||
this.logger.error("Freebit API non-OK response", {
|
this.logger.error("Freebit API non-OK response", {
|
||||||
endpoint,
|
endpoint,
|
||||||
url,
|
url,
|
||||||
@ -157,35 +169,32 @@ export class FreebititService {
|
|||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const responseData = (await response.json()) as any;
|
const responseData = (await response.json()) as TResponse;
|
||||||
if (responseData && responseData.resultCode && responseData.resultCode !== "100") {
|
if (responseData.resultCode && responseData.resultCode !== "100") {
|
||||||
throw new FreebititErrorImpl(
|
throw new FreebititErrorImpl(
|
||||||
`API Error: ${responseData.status?.message || "Unknown error"}`,
|
`API Error: ${responseData.status?.message ?? "Unknown error"}`,
|
||||||
responseData.resultCode,
|
responseData.resultCode,
|
||||||
responseData.status?.statusCode,
|
responseData.status?.statusCode,
|
||||||
responseData.status?.message
|
responseData.status?.message ?? "Unknown error"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug("Freebit API Request Success", { endpoint });
|
this.logger.debug("Freebit API Request Success", { endpoint });
|
||||||
return responseData as T;
|
return responseData;
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
if (error instanceof FreebititErrorImpl) {
|
if (error instanceof FreebititErrorImpl) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
this.logger.error(`Freebit API request failed: ${endpoint}`, {
|
const message = getErrorMessage(error);
|
||||||
error: (error as any).message,
|
this.logger.error(`Freebit API request failed: ${endpoint}`, { error: message });
|
||||||
});
|
throw new InternalServerErrorException(`Freebit API request failed: ${message}`);
|
||||||
throw new InternalServerErrorException(
|
|
||||||
`Freebit API request failed: ${(error as any).message}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async makeAuthenticatedJsonRequest<T>(
|
private async makeAuthenticatedJsonRequest<
|
||||||
endpoint: string,
|
TResponse extends FreebitResponseBase,
|
||||||
payload: Record<string, unknown>
|
TPayload extends Record<string, unknown>,
|
||||||
): Promise<T> {
|
>(endpoint: string, payload: TPayload): Promise<TResponse> {
|
||||||
const url = `${this.config.baseUrl}${endpoint}`;
|
const url = `${this.config.baseUrl}${endpoint}`;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
@ -196,24 +205,23 @@ export class FreebititService {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
const responseData = (await response.json()) as any;
|
const responseData = (await response.json()) as TResponse;
|
||||||
if (responseData && responseData.resultCode && responseData.resultCode !== "100") {
|
if (responseData.resultCode && responseData.resultCode !== "100") {
|
||||||
throw new FreebititErrorImpl(
|
throw new FreebititErrorImpl(
|
||||||
`API Error: ${responseData.status?.message || "Unknown error"}`,
|
`API Error: ${responseData.status?.message ?? "Unknown error"}`,
|
||||||
responseData.resultCode,
|
responseData.resultCode,
|
||||||
responseData.status?.statusCode,
|
responseData.status?.statusCode,
|
||||||
responseData.status?.message
|
responseData.status?.message ?? "Unknown error"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.logger.debug("Freebit JSON API Request Success", { endpoint });
|
this.logger.debug("Freebit JSON API Request Success", { endpoint });
|
||||||
return responseData as T;
|
return responseData;
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
this.logger.error(`Freebit JSON API request failed: ${endpoint}`, {
|
this.logger.error(`Freebit JSON API request failed: ${endpoint}`, {
|
||||||
error: (error as any).message,
|
error: message,
|
||||||
});
|
});
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(`Freebit JSON API request failed: ${message}`);
|
||||||
`Freebit JSON API request failed: ${(error as any).message}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -253,56 +261,65 @@ export class FreebititService {
|
|||||||
if (ep !== candidates[0]) {
|
if (ep !== candidates[0]) {
|
||||||
this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`);
|
this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`);
|
||||||
}
|
}
|
||||||
response = await this.makeAuthenticatedRequest<FreebititAccountDetailsResponse>(
|
response = await this.makeAuthenticatedRequest<
|
||||||
ep,
|
FreebititAccountDetailsResponse,
|
||||||
request as any
|
typeof request
|
||||||
);
|
>(ep, request);
|
||||||
break;
|
break;
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
lastError = err;
|
lastError = err;
|
||||||
if (typeof err?.message === "string" && err.message.includes("HTTP 404")) {
|
if (getErrorMessage(err).includes("HTTP 404")) {
|
||||||
continue; // try next
|
continue; // try next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!response) {
|
if (!response) {
|
||||||
throw lastError || new Error("Failed to fetch account details");
|
if (lastError instanceof Error) {
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
throw new Error("Failed to fetch account details");
|
||||||
}
|
}
|
||||||
|
|
||||||
const resp = response.responseDatas as any;
|
const responseDatas = Array.isArray(response.responseDatas)
|
||||||
const simData = Array.isArray(resp)
|
? response.responseDatas
|
||||||
? resp.find(d => String(d.kind).toUpperCase() === "MVNO") || resp[0]
|
: [response.responseDatas];
|
||||||
: resp;
|
const simData =
|
||||||
|
responseDatas.find(detail => detail.kind.toUpperCase() === "MVNO") ?? responseDatas[0];
|
||||||
|
|
||||||
const size = String(simData.size || "").toLowerCase();
|
const size = String(simData.size ?? "").toLowerCase();
|
||||||
const isEsim = size === "esim" || !!simData.eid;
|
const isEsim = size === "esim" || !!simData.eid;
|
||||||
const planCode = String(simData.planCode || "");
|
const planCode = String(simData.planCode ?? "");
|
||||||
const status = this.mapSimStatus(String(simData.state || ""));
|
const status = this.mapSimStatus(String(simData.state ?? ""));
|
||||||
|
|
||||||
const remainingKb = Number(simData.quota) || 0;
|
const remainingKb = Number(simData.quota ?? 0);
|
||||||
const details: SimDetails = {
|
const details: SimDetails = {
|
||||||
account: String(simData.account || account),
|
account: String(simData.account ?? account),
|
||||||
msisdn: String(simData.account || account),
|
msisdn: String(simData.account ?? account),
|
||||||
iccid: simData.iccid ? String(simData.iccid) : undefined,
|
iccid: simData.iccid ? String(simData.iccid) : undefined,
|
||||||
imsi: simData.imsi ? String(simData.imsi) : undefined,
|
imsi: simData.imsi ? String(simData.imsi) : undefined,
|
||||||
eid: simData.eid ? String(simData.eid) : undefined,
|
eid: simData.eid ? String(simData.eid) : undefined,
|
||||||
planCode,
|
planCode,
|
||||||
status,
|
status,
|
||||||
simType: isEsim ? "esim" : "physical",
|
simType: isEsim ? "esim" : "physical",
|
||||||
size: (size as any) || (isEsim ? "esim" : "nano"),
|
size: size || (isEsim ? "esim" : "nano"),
|
||||||
hasVoice: Number(simData.talk) === 10,
|
hasVoice: Number(simData.talk ?? 0) === 10,
|
||||||
hasSms: Number(simData.sms) === 10,
|
hasSms: Number(simData.sms ?? 0) === 10,
|
||||||
remainingQuotaKb: remainingKb,
|
remainingQuotaKb: remainingKb,
|
||||||
remainingQuotaMb: Math.round((remainingKb / 1024) * 100) / 100,
|
remainingQuotaMb: Math.round((remainingKb / 1024) * 100) / 100,
|
||||||
startDate: simData.startDate ? String(simData.startDate) : undefined,
|
startDate: simData.startDate ? String(simData.startDate) : undefined,
|
||||||
ipv4: simData.ipv4,
|
ipv4: simData.ipv4,
|
||||||
ipv6: simData.ipv6,
|
ipv6: simData.ipv6,
|
||||||
voiceMailEnabled: Number(simData.voicemail ?? simData.voiceMail) === 10,
|
voiceMailEnabled: Number(simData.voicemail ?? simData.voiceMail ?? 0) === 10,
|
||||||
callWaitingEnabled: Number(simData.callwaiting ?? simData.callWaiting) === 10,
|
callWaitingEnabled: Number(simData.callwaiting ?? simData.callWaiting ?? 0) === 10,
|
||||||
internationalRoamingEnabled: Number(simData.worldwing ?? simData.worldWing) === 10,
|
internationalRoamingEnabled: Number(simData.worldwing ?? simData.worldWing ?? 0) === 10,
|
||||||
networkType: simData.contractLine || undefined,
|
networkType: simData.contractLine ?? undefined,
|
||||||
pendingOperations: simData.async
|
pendingOperations: simData.async
|
||||||
? [{ operation: String(simData.async.func), scheduledDate: String(simData.async.date) }]
|
? [
|
||||||
|
{
|
||||||
|
operation: String(simData.async.func),
|
||||||
|
scheduledDate: String(simData.async.date),
|
||||||
|
},
|
||||||
|
]
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -313,21 +330,22 @@ export class FreebititService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return details;
|
return details;
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
this.logger.error(`Failed to get SIM details for account ${account}`, {
|
this.logger.error(`Failed to get SIM details for account ${account}`, {
|
||||||
error: error.message,
|
error: message,
|
||||||
});
|
});
|
||||||
throw error;
|
throw error as Error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSimUsage(account: string): Promise<SimUsage> {
|
async getSimUsage(account: string): Promise<SimUsage> {
|
||||||
try {
|
try {
|
||||||
const request: Omit<FreebititTrafficInfoRequest, "authKey"> = { account };
|
const request: Omit<FreebititTrafficInfoRequest, "authKey"> = { account };
|
||||||
const response = await this.makeAuthenticatedRequest<FreebititTrafficInfoResponse>(
|
const response = await this.makeAuthenticatedRequest<
|
||||||
"/mvno/getTrafficInfo/",
|
FreebititTrafficInfoResponse,
|
||||||
request as any
|
typeof request
|
||||||
);
|
>("/mvno/getTrafficInfo/", request);
|
||||||
|
|
||||||
const todayUsageKb = parseInt(response.traffic.today, 10) || 0;
|
const todayUsageKb = parseInt(response.traffic.today, 10) || 0;
|
||||||
const recentDaysData = response.traffic.inRecentDays.split(",").map((usage, index) => ({
|
const recentDaysData = response.traffic.inRecentDays.split(",").map((usage, index) => ({
|
||||||
@ -374,11 +392,12 @@ export class FreebititService {
|
|||||||
|
|
||||||
const scheduled = !!options.scheduledAt;
|
const scheduled = !!options.scheduledAt;
|
||||||
const endpoint = scheduled ? "/mvno/eachQuota/" : "/master/addSpec/";
|
const endpoint = scheduled ? "/mvno/eachQuota/" : "/master/addSpec/";
|
||||||
if (scheduled) {
|
type TopUpPayload = typeof request & { runTime?: string };
|
||||||
(request as any).runTime = options.scheduledAt;
|
const payload: TopUpPayload = scheduled
|
||||||
}
|
? { ...request, runTime: options.scheduledAt }
|
||||||
|
: request;
|
||||||
|
|
||||||
await this.makeAuthenticatedRequest<FreebititTopUpResponse>(endpoint, request as any);
|
await this.makeAuthenticatedRequest<FreebititTopUpResponse, TopUpPayload>(endpoint, payload);
|
||||||
this.logger.log(`Successfully topped up SIM ${account}`, {
|
this.logger.log(`Successfully topped up SIM ${account}`, {
|
||||||
account,
|
account,
|
||||||
endpoint,
|
endpoint,
|
||||||
@ -386,13 +405,14 @@ export class FreebititService {
|
|||||||
quotaKb,
|
quotaKb,
|
||||||
scheduled,
|
scheduled,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
this.logger.error(`Failed to top up SIM ${account}`, {
|
this.logger.error(`Failed to top up SIM ${account}`, {
|
||||||
error: error.message,
|
error: message,
|
||||||
account,
|
account,
|
||||||
quotaMb,
|
quotaMb,
|
||||||
});
|
});
|
||||||
throw error;
|
throw error as Error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -403,10 +423,10 @@ export class FreebititService {
|
|||||||
): Promise<SimTopUpHistory> {
|
): Promise<SimTopUpHistory> {
|
||||||
try {
|
try {
|
||||||
const request: Omit<FreebititQuotaHistoryRequest, "authKey"> = { account, fromDate, toDate };
|
const request: Omit<FreebititQuotaHistoryRequest, "authKey"> = { account, fromDate, toDate };
|
||||||
const response = await this.makeAuthenticatedRequest<FreebititQuotaHistoryResponse>(
|
const response = await this.makeAuthenticatedRequest<
|
||||||
"/mvno/getQuotaHistory/",
|
FreebititQuotaHistoryResponse,
|
||||||
request as any
|
typeof request
|
||||||
);
|
>("/mvno/getQuotaHistory/", request);
|
||||||
|
|
||||||
const history: SimTopUpHistory = {
|
const history: SimTopUpHistory = {
|
||||||
account,
|
account,
|
||||||
@ -428,9 +448,10 @@ export class FreebititService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return history;
|
return history;
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
this.logger.error(`Failed to get SIM top-up history for account ${account}`, {
|
this.logger.error(`Failed to get SIM top-up history for account ${account}`, {
|
||||||
error: error.message,
|
error: message,
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -449,10 +470,10 @@ export class FreebititService {
|
|||||||
runTime: options.scheduledAt,
|
runTime: options.scheduledAt,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await this.makeAuthenticatedRequest<FreebititPlanChangeResponse>(
|
const response = await this.makeAuthenticatedRequest<
|
||||||
"/mvno/changePlan/",
|
FreebititPlanChangeResponse,
|
||||||
request as any
|
typeof request
|
||||||
);
|
>("/mvno/changePlan/", request);
|
||||||
|
|
||||||
this.logger.log(`Successfully changed SIM plan for account ${account}`, {
|
this.logger.log(`Successfully changed SIM plan for account ${account}`, {
|
||||||
account,
|
account,
|
||||||
@ -462,9 +483,10 @@ export class FreebititService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { ipv4: response.ipv4, ipv6: response.ipv6 };
|
return { ipv4: response.ipv4, ipv6: response.ipv6 };
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
this.logger.error(`Failed to change SIM plan for account ${account}`, {
|
this.logger.error(`Failed to change SIM plan for account ${account}`, {
|
||||||
error: error.message,
|
error: message,
|
||||||
account,
|
account,
|
||||||
newPlanCode,
|
newPlanCode,
|
||||||
});
|
});
|
||||||
@ -500,9 +522,9 @@ export class FreebititService {
|
|||||||
request.contractLine = features.networkType;
|
request.contractLine = features.networkType;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.makeAuthenticatedRequest<FreebititAddSpecResponse>(
|
await this.makeAuthenticatedRequest<FreebititAddSpecResponse, typeof request>(
|
||||||
"/master/addSpec/",
|
"/master/addSpec/",
|
||||||
request as any
|
request
|
||||||
);
|
);
|
||||||
this.logger.log(`Updated SIM features for account ${account}`, {
|
this.logger.log(`Updated SIM features for account ${account}`, {
|
||||||
account,
|
account,
|
||||||
@ -512,7 +534,7 @@ export class FreebititService {
|
|||||||
networkType: features.networkType,
|
networkType: features.networkType,
|
||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = getErrorMessage(error);
|
||||||
this.logger.error(`Failed to update SIM features for account ${account}`, {
|
this.logger.error(`Failed to update SIM features for account ${account}`, {
|
||||||
error: message,
|
error: message,
|
||||||
account,
|
account,
|
||||||
@ -527,17 +549,18 @@ export class FreebititService {
|
|||||||
account,
|
account,
|
||||||
runTime: scheduledAt,
|
runTime: scheduledAt,
|
||||||
};
|
};
|
||||||
await this.makeAuthenticatedRequest<FreebititCancelPlanResponse>(
|
await this.makeAuthenticatedRequest<FreebititCancelPlanResponse, typeof request>(
|
||||||
"/mvno/releasePlan/",
|
"/mvno/releasePlan/",
|
||||||
request as any
|
request
|
||||||
);
|
);
|
||||||
this.logger.log(`Successfully cancelled SIM for account ${account}`, {
|
this.logger.log(`Successfully cancelled SIM for account ${account}`, {
|
||||||
account,
|
account,
|
||||||
runTime: scheduledAt,
|
runTime: scheduledAt,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
this.logger.error(`Failed to cancel SIM for account ${account}`, {
|
this.logger.error(`Failed to cancel SIM for account ${account}`, {
|
||||||
error: error.message,
|
error: message,
|
||||||
account,
|
account,
|
||||||
});
|
});
|
||||||
throw error as Error;
|
throw error as Error;
|
||||||
@ -547,14 +570,15 @@ export class FreebititService {
|
|||||||
async reissueEsimProfile(account: string): Promise<void> {
|
async reissueEsimProfile(account: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const request: Omit<FreebititEsimReissueRequest, "authKey"> = { account };
|
const request: Omit<FreebititEsimReissueRequest, "authKey"> = { account };
|
||||||
await this.makeAuthenticatedRequest<FreebititEsimReissueResponse>(
|
await this.makeAuthenticatedRequest<FreebititEsimReissueResponse, typeof request>(
|
||||||
"/esim/reissueProfile/",
|
"/esim/reissueProfile/",
|
||||||
request as any
|
request
|
||||||
);
|
);
|
||||||
this.logger.log(`Successfully requested eSIM reissue for account ${account}`);
|
this.logger.log(`Successfully requested eSIM reissue for account ${account}`);
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
this.logger.error(`Failed to reissue eSIM profile for account ${account}`, {
|
this.logger.error(`Failed to reissue eSIM profile for account ${account}`, {
|
||||||
error: error.message,
|
error: message,
|
||||||
account,
|
account,
|
||||||
});
|
});
|
||||||
throw error as Error;
|
throw error as Error;
|
||||||
@ -576,9 +600,9 @@ export class FreebititService {
|
|||||||
planCode: options.planCode,
|
planCode: options.planCode,
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.makeAuthenticatedRequest<FreebititEsimAddAccountResponse>(
|
await this.makeAuthenticatedRequest<FreebititEsimAddAccountResponse, typeof request>(
|
||||||
"/mvno/esim/addAcnt/",
|
"/mvno/esim/addAcnt/",
|
||||||
request as any
|
request
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`Successfully reissued eSIM profile via addAcnt for account ${account}`, {
|
this.logger.log(`Successfully reissued eSIM profile via addAcnt for account ${account}`, {
|
||||||
@ -587,9 +611,10 @@ export class FreebititService {
|
|||||||
oldProductNumber: options.oldProductNumber,
|
oldProductNumber: options.oldProductNumber,
|
||||||
oldEid: options.oldEid,
|
oldEid: options.oldEid,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
this.logger.error(`Failed to reissue eSIM profile via addAcnt for account ${account}`, {
|
this.logger.error(`Failed to reissue eSIM profile via addAcnt for account ${account}`, {
|
||||||
error: error.message,
|
error: message,
|
||||||
account,
|
account,
|
||||||
newEid,
|
newEid,
|
||||||
});
|
});
|
||||||
@ -640,13 +665,13 @@ export class FreebititService {
|
|||||||
contractLine,
|
contractLine,
|
||||||
shipDate,
|
shipDate,
|
||||||
...(mnp ? { mnp } : {}),
|
...(mnp ? { mnp } : {}),
|
||||||
...(identity ? identity : {}),
|
...(identity ?? {}),
|
||||||
} as FreebititEsimAccountActivationRequest;
|
};
|
||||||
|
|
||||||
await this.makeAuthenticatedJsonRequest<FreebititEsimAccountActivationResponse>(
|
await this.makeAuthenticatedJsonRequest<
|
||||||
"/mvno/esim/addAcct/",
|
FreebititEsimAccountActivationResponse,
|
||||||
payload as unknown as Record<string, unknown>
|
FreebititEsimAccountActivationRequest
|
||||||
);
|
>("/mvno/esim/addAcct/", payload);
|
||||||
|
|
||||||
this.logger.log("Activated new eSIM account via PA05-41", {
|
this.logger.log("Activated new eSIM account via PA05-41", {
|
||||||
account,
|
account,
|
||||||
@ -661,8 +686,8 @@ export class FreebititService {
|
|||||||
try {
|
try {
|
||||||
await this.getAuthKey();
|
await this.getAuthKey();
|
||||||
return true;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
this.logger.error("Freebit API health check failed", { error: error.message });
|
this.logger.error("Freebit API health check failed", { error: getErrorMessage(error) });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,11 +18,32 @@ export interface FreebititAccountDetailsRequest {
|
|||||||
authKey: string;
|
authKey: string;
|
||||||
version?: string | number; // Docs recommend "2"
|
version?: string | number; // Docs recommend "2"
|
||||||
requestDatas: Array<{
|
requestDatas: Array<{
|
||||||
kind: "MASTER" | "MVNO" | string;
|
kind: "MASTER" | "MVNO";
|
||||||
account?: string | number;
|
account?: string | number;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FreebititAccountDetail {
|
||||||
|
kind: "MASTER" | "MVNO";
|
||||||
|
account: string | number;
|
||||||
|
state: "active" | "suspended" | "temporary" | "waiting" | "obsolete";
|
||||||
|
startDate?: string | number;
|
||||||
|
relationCode?: string;
|
||||||
|
resultCode?: string | number;
|
||||||
|
planCode?: string;
|
||||||
|
iccid?: string | number;
|
||||||
|
imsi?: string | number;
|
||||||
|
eid?: string;
|
||||||
|
contractLine?: string;
|
||||||
|
size?: "standard" | "nano" | "micro" | "esim";
|
||||||
|
sms?: number; // 10=active, 20=inactive
|
||||||
|
talk?: number; // 10=active, 20=inactive
|
||||||
|
ipv4?: string;
|
||||||
|
ipv6?: string;
|
||||||
|
quota?: number; // Remaining quota
|
||||||
|
async?: { func: string; date: string | number };
|
||||||
|
}
|
||||||
|
|
||||||
export interface FreebititAccountDetailsResponse {
|
export interface FreebititAccountDetailsResponse {
|
||||||
resultCode: string;
|
resultCode: string;
|
||||||
status: {
|
status: {
|
||||||
@ -30,47 +51,7 @@ export interface FreebititAccountDetailsResponse {
|
|||||||
statusCode: string | number;
|
statusCode: string | number;
|
||||||
};
|
};
|
||||||
masterAccount?: string;
|
masterAccount?: string;
|
||||||
responseDatas:
|
responseDatas: FreebititAccountDetail | FreebititAccountDetail[];
|
||||||
| {
|
|
||||||
kind: "MASTER" | "MVNO" | string;
|
|
||||||
account: string | number;
|
|
||||||
state: "active" | "suspended" | "temporary" | "waiting" | "obsolete" | string;
|
|
||||||
startDate?: string | number;
|
|
||||||
relationCode?: string;
|
|
||||||
resultCode?: string | number;
|
|
||||||
planCode?: string;
|
|
||||||
iccid?: string | number;
|
|
||||||
imsi?: string | number;
|
|
||||||
eid?: string;
|
|
||||||
contractLine?: string;
|
|
||||||
size?: "standard" | "nano" | "micro" | "esim" | string;
|
|
||||||
sms?: number; // 10=active, 20=inactive
|
|
||||||
talk?: number; // 10=active, 20=inactive
|
|
||||||
ipv4?: string;
|
|
||||||
ipv6?: string;
|
|
||||||
quota?: number; // Remaining quota
|
|
||||||
async?: { func: string; date: string | number };
|
|
||||||
}
|
|
||||||
| Array<{
|
|
||||||
kind: "MASTER" | "MVNO" | string;
|
|
||||||
account: string | number;
|
|
||||||
state: "active" | "suspended" | "temporary" | "waiting" | "obsolete" | string;
|
|
||||||
startDate?: string | number;
|
|
||||||
relationCode?: string;
|
|
||||||
resultCode?: string | number;
|
|
||||||
planCode?: string;
|
|
||||||
iccid?: string | number;
|
|
||||||
imsi?: string | number;
|
|
||||||
eid?: string;
|
|
||||||
contractLine?: string;
|
|
||||||
size?: "standard" | "nano" | "micro" | "esim" | string;
|
|
||||||
sms?: number;
|
|
||||||
talk?: number;
|
|
||||||
ipv4?: string;
|
|
||||||
ipv6?: string;
|
|
||||||
quota?: number;
|
|
||||||
async?: { func: string; date: string | number };
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FreebititTrafficInfoRequest {
|
export interface FreebititTrafficInfoRequest {
|
||||||
|
|||||||
@ -2,13 +2,11 @@ import { Module } from "@nestjs/common";
|
|||||||
import { SalesforceService } from "./salesforce.service";
|
import { SalesforceService } from "./salesforce.service";
|
||||||
import { SalesforceConnection } from "./services/salesforce-connection.service";
|
import { SalesforceConnection } from "./services/salesforce-connection.service";
|
||||||
import { SalesforceAccountService } from "./services/salesforce-account.service";
|
import { SalesforceAccountService } from "./services/salesforce-account.service";
|
||||||
import { SalesforceCaseService } from "./services/salesforce-case.service";
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
providers: [
|
||||||
SalesforceConnection,
|
SalesforceConnection,
|
||||||
SalesforceAccountService,
|
SalesforceAccountService,
|
||||||
SalesforceCaseService,
|
|
||||||
SalesforceService,
|
SalesforceService,
|
||||||
],
|
],
|
||||||
exports: [SalesforceService, SalesforceConnection],
|
exports: [SalesforceService, SalesforceConnection],
|
||||||
|
|||||||
@ -9,12 +9,6 @@ import {
|
|||||||
type AccountData,
|
type AccountData,
|
||||||
type UpsertResult,
|
type UpsertResult,
|
||||||
} from "./services/salesforce-account.service";
|
} from "./services/salesforce-account.service";
|
||||||
import {
|
|
||||||
SalesforceCaseService,
|
|
||||||
CaseQueryParams,
|
|
||||||
CreateCaseUserData,
|
|
||||||
} from "./services/salesforce-case.service";
|
|
||||||
import { SupportCase, CreateCaseRequest } from "@customer-portal/domain";
|
|
||||||
import type { SalesforceAccountRecord } from "@customer-portal/domain";
|
import type { SalesforceAccountRecord } from "@customer-portal/domain";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -24,8 +18,7 @@ import type { SalesforceAccountRecord } from "@customer-portal/domain";
|
|||||||
* - findAccountByCustomerNumber() - auth service (WHMCS linking)
|
* - findAccountByCustomerNumber() - auth service (WHMCS linking)
|
||||||
* - upsertAccount() - auth service (signup)
|
* - upsertAccount() - auth service (signup)
|
||||||
* - getAccount() - users service (profile enhancement)
|
* - getAccount() - users service (profile enhancement)
|
||||||
* - getCases() - future support functionality
|
* Support-case functionality has been deferred and is intentionally absent.
|
||||||
* - createCase() - future support functionality
|
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SalesforceService implements OnModuleInit {
|
export class SalesforceService implements OnModuleInit {
|
||||||
@ -33,7 +26,6 @@ export class SalesforceService implements OnModuleInit {
|
|||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private connection: SalesforceConnection,
|
private connection: SalesforceConnection,
|
||||||
private accountService: SalesforceAccountService,
|
private accountService: SalesforceAccountService,
|
||||||
private caseService: SalesforceCaseService,
|
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -86,26 +78,6 @@ export class SalesforceService implements OnModuleInit {
|
|||||||
return this.accountService.update(accountId, updates);
|
return this.accountService.update(accountId, updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
// === CASE METHODS (For Future Support Functionality) ===
|
|
||||||
|
|
||||||
async getCases(
|
|
||||||
accountId: string,
|
|
||||||
params: CaseQueryParams = {}
|
|
||||||
): Promise<{ cases: SupportCase[]; totalSize: number }> {
|
|
||||||
return this.caseService.getCases(accountId, params);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createCase(
|
|
||||||
userData: CreateCaseUserData,
|
|
||||||
caseRequest: CreateCaseRequest
|
|
||||||
): Promise<SupportCase> {
|
|
||||||
return this.caseService.createCase(userData, caseRequest);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateCase(caseId: string, updates: Record<string, unknown>): Promise<void> {
|
|
||||||
return this.caseService.updateCase(caseId, updates);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === ORDER METHODS (For Order Provisioning) ===
|
// === ORDER METHODS (For Order Provisioning) ===
|
||||||
|
|
||||||
async updateOrder(orderData: { Id: string; [key: string]: unknown }): Promise<void> {
|
async updateOrder(orderData: { Id: string; [key: string]: unknown }): Promise<void> {
|
||||||
|
|||||||
@ -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" &&
|
typeof errorResponse.message === "string" &&
|
||||||
errorResponse.message.toLowerCase().includes("client not found")
|
errorResponse.message.toLowerCase().includes("client not found")
|
||||||
) {
|
) {
|
||||||
const byEmail =
|
const emailParam = params["email"];
|
||||||
typeof (params as any).email === "string" ? (params as any).email : undefined;
|
if (typeof emailParam === "string") {
|
||||||
if (byEmail) {
|
throw new NotFoundException(`Client with email ${emailParam} not found`);
|
||||||
throw new NotFoundException(`Client with email ${byEmail} not found`);
|
|
||||||
}
|
}
|
||||||
const byId = (params as any).clientid;
|
|
||||||
throw new NotFoundException(
|
const clientIdParam = params["clientid"];
|
||||||
`Client ${typeof byId === "string" || typeof byId === "number" ? byId : ""} not found`
|
const identifier =
|
||||||
);
|
typeof clientIdParam === "string" || typeof clientIdParam === "number"
|
||||||
|
? clientIdParam
|
||||||
|
: "";
|
||||||
|
throw new NotFoundException(`Client ${identifier} not found`);
|
||||||
}
|
}
|
||||||
throw new Error(`WHMCS API Error: ${errorResponse.message}`);
|
throw new Error(`WHMCS API Error: ${errorResponse.message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -66,7 +66,7 @@ export class WhmcsDataTransformer {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return invoice;
|
return invoice;
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
this.logger.error(`Failed to transform invoice ${invoiceId}`, {
|
this.logger.error(`Failed to transform invoice ${invoiceId}`, {
|
||||||
error: getErrorMessage(error),
|
error: getErrorMessage(error),
|
||||||
whmcsData: this.sanitizeForLog(whmcsInvoice as unknown as Record<string, unknown>),
|
whmcsData: this.sanitizeForLog(whmcsInvoice as unknown as Record<string, unknown>),
|
||||||
@ -129,7 +129,7 @@ export class WhmcsDataTransformer {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return subscription;
|
return subscription;
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
this.logger.error(`Failed to transform subscription ${String(whmcsProduct.id)}`, {
|
this.logger.error(`Failed to transform subscription ${String(whmcsProduct.id)}`, {
|
||||||
error: getErrorMessage(error),
|
error: getErrorMessage(error),
|
||||||
whmcsData: this.sanitizeForLog(whmcsProduct as unknown as Record<string, unknown>),
|
whmcsData: this.sanitizeForLog(whmcsProduct as unknown as Record<string, unknown>),
|
||||||
|
|||||||
@ -7,27 +7,29 @@ let app: INestApplication | null = null;
|
|||||||
|
|
||||||
const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
|
const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
|
||||||
for (const signal of signals) {
|
for (const signal of signals) {
|
||||||
process.once(signal, async () => {
|
process.once(signal, () => {
|
||||||
logger.log(`Received ${signal}. Closing Nest application...`);
|
void (async () => {
|
||||||
|
logger.log(`Received ${signal}. Closing Nest application...`);
|
||||||
|
|
||||||
if (!app) {
|
if (!app) {
|
||||||
logger.warn("Nest application not initialized. Exiting immediately.");
|
logger.warn("Nest application not initialized. Exiting immediately.");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await app.close();
|
await app.close();
|
||||||
logger.log("Nest application closed gracefully.");
|
logger.log("Nest application closed gracefully.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const resolvedError = error as Error;
|
const resolvedError = error as Error;
|
||||||
logger.error(
|
logger.error(
|
||||||
`Error during Nest application shutdown: ${resolvedError.message}`,
|
`Error during Nest application shutdown: ${resolvedError.message}`,
|
||||||
resolvedError.stack
|
resolvedError.stack
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -105,7 +105,7 @@ export class AuthController {
|
|||||||
@ApiResponse({ status: 200, description: "Login successful" })
|
@ApiResponse({ status: 200, description: "Login successful" })
|
||||||
@ApiResponse({ status: 401, description: "Invalid credentials" })
|
@ApiResponse({ status: 401, description: "Invalid credentials" })
|
||||||
@ApiResponse({ status: 429, description: "Too many login attempts" })
|
@ApiResponse({ status: 429, description: "Too many login attempts" })
|
||||||
async login(@Req() req: Request & { user: { id: string; email: string; role?: string } }) {
|
async login(@Req() req: Request & { user: { id: string; email: string; role: string } }) {
|
||||||
return this.authService.login(req.user, req);
|
return this.authService.login(req.user, req);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -219,7 +219,7 @@ export class AuthController {
|
|||||||
|
|
||||||
@Get("me")
|
@Get("me")
|
||||||
@ApiOperation({ summary: "Get current authentication status" })
|
@ApiOperation({ summary: "Get current authentication status" })
|
||||||
getAuthStatus(@Req() req: Request & { user: { id: string; email: string; role?: string } }) {
|
getAuthStatus(@Req() req: Request & { user: { id: string; email: string; role: string } }) {
|
||||||
// Return basic auth info only - full profile should use /api/me
|
// Return basic auth info only - full profile should use /api/me
|
||||||
return {
|
return {
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { ConfigService } from "@nestjs/config";
|
|||||||
import { APP_GUARD } from "@nestjs/core";
|
import { APP_GUARD } from "@nestjs/core";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
import { AuthController } from "./auth-zod.controller";
|
import { AuthController } from "./auth-zod.controller";
|
||||||
|
import { AuthAdminController } from "./auth-admin.controller";
|
||||||
import { UsersModule } from "@bff/modules/users/users.module";
|
import { UsersModule } from "@bff/modules/users/users.module";
|
||||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module";
|
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module";
|
||||||
import { IntegrationsModule } from "@bff/integrations/integrations.module";
|
import { IntegrationsModule } from "@bff/integrations/integrations.module";
|
||||||
@ -33,7 +34,7 @@ import { WhmcsLinkWorkflowService } from "./services/workflows/whmcs-link-workfl
|
|||||||
IntegrationsModule,
|
IntegrationsModule,
|
||||||
EmailModule,
|
EmailModule,
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController, AuthAdminController],
|
||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
JwtStrategy,
|
JwtStrategy,
|
||||||
|
|||||||
@ -148,6 +148,7 @@ export class AuthService {
|
|||||||
{
|
{
|
||||||
id: profile.id,
|
id: profile.id,
|
||||||
email: profile.email,
|
email: profile.email,
|
||||||
|
role: prismaUser.role,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
userAgent: request?.headers["user-agent"],
|
userAgent: request?.headers["user-agent"],
|
||||||
@ -155,7 +156,10 @@ export class AuthService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: profile,
|
user: {
|
||||||
|
...profile,
|
||||||
|
role: prismaUser.role,
|
||||||
|
},
|
||||||
tokens,
|
tokens,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -176,7 +180,7 @@ export class AuthService {
|
|||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
_request?: Request
|
_request?: Request
|
||||||
): Promise<{ id: string; email: string; role?: string } | null> {
|
): Promise<{ id: string; email: string; role: string } | null> {
|
||||||
const user = await this.usersService.findByEmailInternal(email);
|
const user = await this.usersService.findByEmailInternal(email);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@ -224,7 +228,7 @@ export class AuthService {
|
|||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role || undefined,
|
role: user.role,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Increment failed login attempts
|
// Increment failed login attempts
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { UserProfile } from "@customer-portal/domain";
|
import type { AuthenticatedUser } from "@customer-portal/domain";
|
||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
|
|
||||||
export type RequestWithUser = Request & { user: UserProfile };
|
export type RequestWithUser = Request & { user: AuthenticatedUser };
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { ThrottlerGuard } from "@nestjs/throttler";
|
import { ThrottlerGuard } from "@nestjs/throttler";
|
||||||
|
import { createHash } from "crypto";
|
||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -16,9 +17,11 @@ export class AuthThrottleGuard extends ThrottlerGuard {
|
|||||||
"unknown";
|
"unknown";
|
||||||
|
|
||||||
const userAgent = req.headers["user-agent"] || "unknown";
|
const userAgent = req.headers["user-agent"] || "unknown";
|
||||||
const userAgentHash = Buffer.from(userAgent).toString("base64").slice(0, 16);
|
const userAgentHash = createHash("sha256").update(userAgent).digest("hex").slice(0, 16);
|
||||||
|
|
||||||
const resolvedIp = await Promise.resolve(ip);
|
const normalizedIp = ip.replace(/^::ffff:/, "");
|
||||||
|
|
||||||
|
const resolvedIp = await Promise.resolve(normalizedIp);
|
||||||
return `auth_${resolvedIp}_${userAgentHash}`;
|
return `auth_${resolvedIp}_${userAgentHash}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,15 +23,20 @@ export class TokenBlacklistService {
|
|||||||
|
|
||||||
// Use JwtService to safely decode and validate token
|
// Use JwtService to safely decode and validate token
|
||||||
try {
|
try {
|
||||||
const payload = this.jwtService.decode(token);
|
const decoded = this.jwtService.decode(token);
|
||||||
|
|
||||||
// Validate payload structure
|
if (!decoded || typeof decoded !== "object") {
|
||||||
if (!payload || !payload.sub || !payload.exp) {
|
|
||||||
this.logger.warn("Invalid JWT payload structure for blacklisting");
|
this.logger.warn("Invalid JWT payload structure for blacklisting");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiryTime = payload.exp * 1000; // Convert to milliseconds
|
const { sub, exp } = decoded as { sub?: unknown; exp?: unknown };
|
||||||
|
if (typeof sub !== "string" || typeof exp !== "number") {
|
||||||
|
this.logger.warn("Invalid JWT payload structure for blacklisting");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiryTime = exp * 1000; // Convert to milliseconds
|
||||||
const currentTime = Date.now();
|
const currentTime = Date.now();
|
||||||
const ttl = Math.max(0, Math.floor((expiryTime - currentTime) / 1000)); // Convert to seconds
|
const ttl = Math.max(0, Math.floor((expiryTime - currentTime) / 1000)); // Convert to seconds
|
||||||
|
|
||||||
@ -41,7 +46,7 @@ export class TokenBlacklistService {
|
|||||||
} else {
|
} else {
|
||||||
this.logger.debug("Token already expired, not blacklisting");
|
this.logger.debug("Token already expired, not blacklisting");
|
||||||
}
|
}
|
||||||
} catch (parseError) {
|
} catch (_parseError: unknown) {
|
||||||
// If we can't parse the token, blacklist it for the default JWT expiry time
|
// If we can't parse the token, blacklist it for the default JWT expiry time
|
||||||
try {
|
try {
|
||||||
const defaultTtl = parseJwtExpiry(this.configService.get("JWT_EXPIRES_IN", "7d"));
|
const defaultTtl = parseJwtExpiry(this.configService.get("JWT_EXPIRES_IN", "7d"));
|
||||||
|
|||||||
@ -12,6 +12,21 @@ export interface RefreshTokenPayload {
|
|||||||
tokenId: string;
|
tokenId: string;
|
||||||
deviceId?: string;
|
deviceId?: string;
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
|
type: "refresh";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredRefreshToken {
|
||||||
|
familyId: string;
|
||||||
|
userId: string;
|
||||||
|
valid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredRefreshTokenFamily {
|
||||||
|
userId: string;
|
||||||
|
tokenHash: string;
|
||||||
|
deviceId?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
createdAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -61,6 +76,7 @@ export class AuthTokenService {
|
|||||||
tokenId: familyId,
|
tokenId: familyId,
|
||||||
deviceId: deviceInfo?.deviceId,
|
deviceId: deviceInfo?.deviceId,
|
||||||
userAgent: deviceInfo?.userAgent,
|
userAgent: deviceInfo?.userAgent,
|
||||||
|
type: "refresh",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generate tokens
|
// Generate tokens
|
||||||
@ -76,41 +92,41 @@ export class AuthTokenService {
|
|||||||
const refreshTokenHash = this.hashToken(refreshToken);
|
const refreshTokenHash = this.hashToken(refreshToken);
|
||||||
const refreshExpirySeconds = this.parseExpiryToSeconds(this.REFRESH_TOKEN_EXPIRY);
|
const refreshExpirySeconds = this.parseExpiryToSeconds(this.REFRESH_TOKEN_EXPIRY);
|
||||||
|
|
||||||
if (this.redis.status !== "ready") {
|
if (this.redis.status === "ready") {
|
||||||
this.logger.error("Redis not ready for token issuance", { status: this.redis.status });
|
try {
|
||||||
throw new UnauthorizedException("Session service unavailable");
|
await this.redis.ping();
|
||||||
}
|
await this.redis.setex(
|
||||||
|
`${this.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`,
|
||||||
|
refreshExpirySeconds,
|
||||||
|
JSON.stringify({
|
||||||
|
userId: user.id,
|
||||||
|
tokenHash: refreshTokenHash,
|
||||||
|
deviceId: deviceInfo?.deviceId,
|
||||||
|
userAgent: deviceInfo?.userAgent,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
// Store individual refresh token
|
||||||
await this.redis.ping();
|
await this.redis.setex(
|
||||||
await this.redis.setex(
|
`${this.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`,
|
||||||
`${this.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`,
|
refreshExpirySeconds,
|
||||||
refreshExpirySeconds,
|
JSON.stringify({
|
||||||
JSON.stringify({
|
familyId,
|
||||||
|
userId: user.id,
|
||||||
|
valid: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Failed to store refresh token in Redis", {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
tokenHash: refreshTokenHash,
|
});
|
||||||
deviceId: deviceInfo?.deviceId,
|
}
|
||||||
userAgent: deviceInfo?.userAgent,
|
} else {
|
||||||
createdAt: new Date().toISOString(),
|
this.logger.warn("Redis not ready for token issuance; issuing non-rotating tokens", {
|
||||||
})
|
status: this.redis.status,
|
||||||
);
|
|
||||||
|
|
||||||
// Store individual refresh token
|
|
||||||
await this.redis.setex(
|
|
||||||
`${this.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`,
|
|
||||||
refreshExpirySeconds,
|
|
||||||
JSON.stringify({
|
|
||||||
familyId,
|
|
||||||
userId: user.id,
|
|
||||||
valid: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error("Failed to store refresh token in Redis", {
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
userId: user.id,
|
|
||||||
});
|
});
|
||||||
throw new UnauthorizedException("Unable to issue session tokens. Please try again.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessExpiresAt = new Date(
|
const accessExpiresAt = new Date(
|
||||||
@ -143,7 +159,15 @@ export class AuthTokenService {
|
|||||||
): Promise<AuthTokens> {
|
): Promise<AuthTokens> {
|
||||||
try {
|
try {
|
||||||
// Verify refresh token
|
// Verify refresh token
|
||||||
const payload = this.jwtService.verify(refreshToken);
|
const payload = this.jwtService.verify<RefreshTokenPayload>(refreshToken);
|
||||||
|
|
||||||
|
if (payload.type !== "refresh") {
|
||||||
|
this.logger.warn("Token presented to refresh endpoint is not a refresh token", {
|
||||||
|
tokenId: payload.tokenId,
|
||||||
|
});
|
||||||
|
throw new UnauthorizedException("Invalid refresh token");
|
||||||
|
}
|
||||||
|
|
||||||
const refreshTokenHash = this.hashToken(refreshToken);
|
const refreshTokenHash = this.hashToken(refreshToken);
|
||||||
|
|
||||||
// Check if refresh token exists and is valid
|
// Check if refresh token exists and is valid
|
||||||
@ -185,7 +209,7 @@ export class AuthTokenService {
|
|||||||
const user = {
|
const user = {
|
||||||
id: prismaUser.id,
|
id: prismaUser.id,
|
||||||
email: prismaUser.email,
|
email: prismaUser.email,
|
||||||
role: prismaUser.role.toLowerCase(), // Convert UserRole enum to lowercase string
|
role: prismaUser.role,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Invalidate current refresh token
|
// Invalidate current refresh token
|
||||||
@ -201,6 +225,33 @@ export class AuthTokenService {
|
|||||||
this.logger.error("Token refresh failed", {
|
this.logger.error("Token refresh failed", {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (this.redis.status !== "ready") {
|
||||||
|
this.logger.warn("Redis unavailable during token refresh; issuing fallback token pair");
|
||||||
|
const fallbackPayload = this.jwtService.decode(refreshToken) as
|
||||||
|
| RefreshTokenPayload
|
||||||
|
| null;
|
||||||
|
|
||||||
|
const fallbackUserId = fallbackPayload?.userId;
|
||||||
|
|
||||||
|
if (fallbackUserId) {
|
||||||
|
const fallbackUser = await this.usersService
|
||||||
|
.findByIdInternal(fallbackUserId)
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
if (fallbackUser) {
|
||||||
|
return this.generateTokenPair(
|
||||||
|
{
|
||||||
|
id: fallbackUser.id,
|
||||||
|
email: fallbackUser.email,
|
||||||
|
role: fallbackUser.role,
|
||||||
|
},
|
||||||
|
deviceInfo
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
throw new UnauthorizedException("Invalid refresh token");
|
throw new UnauthorizedException("Invalid refresh token");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,16 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
||||||
import { PassportStrategy } from "@nestjs/passport";
|
import { PassportStrategy } from "@nestjs/passport";
|
||||||
import { ExtractJwt, Strategy } from "passport-jwt";
|
import { ExtractJwt, Strategy } from "passport-jwt";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import type { UserProfile } from "@customer-portal/domain";
|
import type { AuthenticatedUser } from "@customer-portal/domain";
|
||||||
import { TokenBlacklistService } from "../services/token-blacklist.service";
|
import { UsersService } from "@bff/modules/users/users.service";
|
||||||
|
import { mapPrismaUserToUserProfile } from "@bff/infra/utils/user-mapper.util";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
constructor(
|
constructor(
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private tokenBlacklistService: TokenBlacklistService
|
private readonly usersService: UsersService
|
||||||
) {
|
) {
|
||||||
const jwtSecret = configService.get<string>("JWT_SECRET");
|
const jwtSecret = configService.get<string>("JWT_SECRET");
|
||||||
if (!jwtSecret) {
|
if (!jwtSecret) {
|
||||||
@ -31,28 +32,24 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
role: string;
|
role: string;
|
||||||
iat?: number;
|
iat?: number;
|
||||||
exp?: number;
|
exp?: number;
|
||||||
}): Promise<UserProfile> {
|
}): Promise<AuthenticatedUser> {
|
||||||
// Validate payload structure
|
// Validate payload structure
|
||||||
if (!payload.sub || !payload.email) {
|
if (!payload.sub || !payload.email) {
|
||||||
throw new Error("Invalid JWT payload");
|
throw new Error("Invalid JWT payload");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return user info - token blacklist is checked in GlobalAuthGuard
|
const prismaUser = await this.usersService.findByIdInternal(payload.sub);
|
||||||
// This separation allows us to avoid request object dependency here
|
|
||||||
return {
|
if (!prismaUser) {
|
||||||
id: payload.sub,
|
throw new UnauthorizedException("User not found");
|
||||||
email: payload.email,
|
}
|
||||||
firstName: undefined,
|
|
||||||
lastName: undefined,
|
if (prismaUser.email !== payload.email) {
|
||||||
company: undefined,
|
throw new UnauthorizedException("Token subject does not match user record");
|
||||||
phone: undefined,
|
}
|
||||||
mfaEnabled: false,
|
|
||||||
emailVerified: true,
|
const profile = mapPrismaUserToUserProfile(prismaUser);
|
||||||
createdAt: new Date(0).toISOString(),
|
|
||||||
updatedAt: new Date(0).toISOString(),
|
return profile;
|
||||||
avatar: undefined,
|
|
||||||
preferences: {},
|
|
||||||
lastLoginAt: undefined,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
|
|||||||
req: Request,
|
req: Request,
|
||||||
email: string,
|
email: string,
|
||||||
password: string
|
password: string
|
||||||
): Promise<{ id: string; email: string; role?: string }> {
|
): Promise<{ id: string; email: string; role: string }> {
|
||||||
const user = await this.authService.validateUser(email, password, req);
|
const user = await this.authService.validateUser(email, password, req);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException("Invalid credentials");
|
throw new UnauthorizedException("Invalid credentials");
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Injectable, Inject } from "@nestjs/common";
|
|||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
|
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
|
||||||
import { getSalesforceFieldMap } from "@bff/core/config/field-map";
|
import { getSalesforceFieldMap } from "@bff/core/config/field-map";
|
||||||
|
import { assertSalesforceId, sanitizeSoqlLiteral } from "@bff/integrations/salesforce/utils/soql.util";
|
||||||
import type {
|
import type {
|
||||||
SalesforceProduct2WithPricebookEntries,
|
SalesforceProduct2WithPricebookEntries,
|
||||||
SalesforceQueryResult,
|
SalesforceQueryResult,
|
||||||
@ -15,7 +16,8 @@ export class BaseCatalogService {
|
|||||||
protected readonly sf: SalesforceConnection,
|
protected readonly sf: SalesforceConnection,
|
||||||
@Inject(Logger) protected readonly logger: Logger
|
@Inject(Logger) protected readonly logger: Logger
|
||||||
) {
|
) {
|
||||||
this.portalPriceBookId = process.env.PORTAL_PRICEBOOK_ID || "01sTL000008eLVlYAM";
|
const portalPricebook = process.env.PORTAL_PRICEBOOK_ID || "01sTL000008eLVlYAM";
|
||||||
|
this.portalPriceBookId = assertSalesforceId(portalPricebook, "PORTAL_PRICEBOOK_ID");
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getFields() {
|
protected getFields() {
|
||||||
@ -66,12 +68,15 @@ export class BaseCatalogService {
|
|||||||
];
|
];
|
||||||
const allFields = [...baseFields, ...additionalFields].join(", ");
|
const allFields = [...baseFields, ...additionalFields].join(", ");
|
||||||
|
|
||||||
|
const safeCategory = sanitizeSoqlLiteral(category);
|
||||||
|
const safeItemClass = sanitizeSoqlLiteral(itemClass);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
SELECT ${allFields},
|
SELECT ${allFields},
|
||||||
(SELECT Id, UnitPrice, Pricebook2Id, Product2Id, IsActive FROM PricebookEntries WHERE Pricebook2Id = '${this.portalPriceBookId}' AND IsActive = true LIMIT 1)
|
(SELECT Id, UnitPrice, Pricebook2Id, Product2Id, IsActive FROM PricebookEntries WHERE Pricebook2Id = '${this.portalPriceBookId}' AND IsActive = true LIMIT 1)
|
||||||
FROM Product2
|
FROM Product2
|
||||||
WHERE ${fields.product.portalCategory} = '${category}'
|
WHERE ${fields.product.portalCategory} = '${safeCategory}'
|
||||||
AND ${fields.product.itemClass} = '${itemClass}'
|
AND ${fields.product.itemClass} = '${safeItemClass}'
|
||||||
AND ${fields.product.portalAccessible} = true
|
AND ${fields.product.portalAccessible} = true
|
||||||
${additionalConditions}
|
${additionalConditions}
|
||||||
ORDER BY ${fields.product.displayOrder} NULLS LAST, Name
|
ORDER BY ${fields.product.displayOrder} NULLS LAST, Name
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
|||||||
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
|
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
|
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util";
|
||||||
import {
|
import {
|
||||||
mapInternetPlan,
|
mapInternetPlan,
|
||||||
mapInternetInstallation,
|
mapInternetInstallation,
|
||||||
@ -116,7 +117,8 @@ export class InternetCatalogService extends BaseCatalogService {
|
|||||||
|
|
||||||
// Get customer's eligibility from Salesforce
|
// Get customer's eligibility from Salesforce
|
||||||
const fields = this.getFields();
|
const fields = this.getFields();
|
||||||
const soql = `SELECT Id, ${fields.account.internetEligibility} FROM Account WHERE Id = '${mapping.sfAccountId}' LIMIT 1`;
|
const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId");
|
||||||
|
const soql = `SELECT Id, ${fields.account.internetEligibility} FROM Account WHERE Id = '${sfAccountId}' LIMIT 1`;
|
||||||
const accounts = await this.executeQuery(soql, "Customer Eligibility");
|
const accounts = await this.executeQuery(soql, "Customer Eligibility");
|
||||||
|
|
||||||
if (accounts.length === 0) {
|
if (accounts.length === 0) {
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
CatalogProductBase,
|
CatalogProductBase,
|
||||||
CatalogPricebookEntry,
|
|
||||||
SalesforceProduct2WithPricebookEntries,
|
SalesforceProduct2WithPricebookEntries,
|
||||||
SalesforcePricebookEntryRecord,
|
SalesforcePricebookEntryRecord,
|
||||||
InternetCatalogProduct,
|
|
||||||
InternetPlanCatalogItem,
|
InternetPlanCatalogItem,
|
||||||
InternetInstallationCatalogItem,
|
InternetInstallationCatalogItem,
|
||||||
InternetAddonCatalogItem,
|
InternetAddonCatalogItem,
|
||||||
@ -18,12 +16,68 @@ const fieldMap = getSalesforceFieldMap();
|
|||||||
|
|
||||||
export type SalesforceCatalogProductRecord = SalesforceProduct2WithPricebookEntries;
|
export type SalesforceCatalogProductRecord = SalesforceProduct2WithPricebookEntries;
|
||||||
|
|
||||||
|
const DEFAULT_PLAN_TEMPLATE: InternetPlanTemplate = {
|
||||||
|
tierDescription: "Standard plan",
|
||||||
|
description: undefined,
|
||||||
|
features: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getTierTemplate(tier?: string): InternetPlanTemplate {
|
||||||
|
if (!tier) {
|
||||||
|
return DEFAULT_PLAN_TEMPLATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = tier.toLowerCase();
|
||||||
|
switch (normalized) {
|
||||||
|
case "gold":
|
||||||
|
return {
|
||||||
|
tierDescription: "Gold plan",
|
||||||
|
description: "Premium speed internet plan",
|
||||||
|
features: ["Highest bandwidth", "Priority support"],
|
||||||
|
};
|
||||||
|
case "silver":
|
||||||
|
return {
|
||||||
|
tierDescription: "Silver plan",
|
||||||
|
description: "Balanced performance plan",
|
||||||
|
features: ["Great value", "Reliable speeds"],
|
||||||
|
};
|
||||||
|
case "bronze":
|
||||||
|
return {
|
||||||
|
tierDescription: "Bronze plan",
|
||||||
|
description: "Entry level plan",
|
||||||
|
features: ["Essential connectivity"],
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
tierDescription: `${tier} plan`,
|
||||||
|
description: undefined,
|
||||||
|
features: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferInstallationTypeFromSku(sku: string): "One-time" | "12-Month" | "24-Month" {
|
||||||
|
const normalized = sku.toLowerCase();
|
||||||
|
if (normalized.includes("24")) return "24-Month";
|
||||||
|
if (normalized.includes("12")) return "12-Month";
|
||||||
|
return "One-time";
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferAddonTypeFromSku(
|
||||||
|
sku: string
|
||||||
|
): "hikari-denwa-service" | "hikari-denwa-installation" | "other" {
|
||||||
|
const normalized = sku.toLowerCase();
|
||||||
|
if (normalized.includes("installation")) return "hikari-denwa-installation";
|
||||||
|
if (normalized.includes("denwa")) return "hikari-denwa-service";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
function getProductField<T = unknown>(
|
function getProductField<T = unknown>(
|
||||||
product: SalesforceCatalogProductRecord,
|
product: SalesforceCatalogProductRecord,
|
||||||
fieldKey: keyof typeof fieldMap.product
|
fieldKey: keyof typeof fieldMap.product
|
||||||
): T | undefined {
|
): T | undefined {
|
||||||
const salesforceField = fieldMap.product[fieldKey];
|
const salesforceField = fieldMap.product[fieldKey] as keyof SalesforceCatalogProductRecord;
|
||||||
const value = (product as Record<string, unknown>)[salesforceField];
|
const value = product[salesforceField];
|
||||||
return value as T | undefined;
|
return value as T | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,28 +106,6 @@ function coerceNumber(value: unknown): number | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPricebookEntry(
|
|
||||||
entry?: SalesforcePricebookEntryRecord
|
|
||||||
): CatalogPricebookEntry | undefined {
|
|
||||||
if (!entry) return undefined;
|
|
||||||
return {
|
|
||||||
id: entry.Id,
|
|
||||||
name: entry.Name,
|
|
||||||
unitPrice: coerceNumber(entry.UnitPrice),
|
|
||||||
pricebook2Id: entry.Pricebook2Id ?? undefined,
|
|
||||||
product2Id: entry.Product2Id ?? undefined,
|
|
||||||
isActive: entry.IsActive ?? undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPricebookEntry(
|
|
||||||
product: SalesforceCatalogProductRecord
|
|
||||||
): CatalogPricebookEntry | undefined {
|
|
||||||
const nested = product.PricebookEntries?.records;
|
|
||||||
if (!Array.isArray(nested) || nested.length === 0) return undefined;
|
|
||||||
return buildPricebookEntry(nested[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function baseProduct(product: SalesforceCatalogProductRecord): CatalogProductBase {
|
function baseProduct(product: SalesforceCatalogProductRecord): CatalogProductBase {
|
||||||
const sku = getStringField(product, "sku") ?? "";
|
const sku = getStringField(product, "sku") ?? "";
|
||||||
const base: CatalogProductBase = {
|
const base: CatalogProductBase = {
|
||||||
@ -101,7 +133,7 @@ function parseFeatureList(product: SalesforceCatalogProductRecord): string[] | u
|
|||||||
}
|
}
|
||||||
if (typeof raw === "string") {
|
if (typeof raw === "string") {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
return Array.isArray(parsed)
|
return Array.isArray(parsed)
|
||||||
? parsed.filter((item): item is string => typeof item === "string")
|
? parsed.filter((item): item is string => typeof item === "string")
|
||||||
: undefined;
|
: undefined;
|
||||||
@ -119,8 +151,8 @@ function derivePrices(
|
|||||||
const billingCycle = getStringField(product, "billingCycle")?.toLowerCase();
|
const billingCycle = getStringField(product, "billingCycle")?.toLowerCase();
|
||||||
const unitPrice = pricebookEntry ? coerceNumber(pricebookEntry.UnitPrice) : undefined;
|
const unitPrice = pricebookEntry ? coerceNumber(pricebookEntry.UnitPrice) : undefined;
|
||||||
|
|
||||||
let monthlyPrice = undefined;
|
let monthlyPrice: number | undefined;
|
||||||
let oneTimePrice = undefined;
|
let oneTimePrice: number | undefined;
|
||||||
|
|
||||||
if (unitPrice !== undefined) {
|
if (unitPrice !== undefined) {
|
||||||
if (billingCycle === "monthly") {
|
if (billingCycle === "monthly") {
|
||||||
|
|||||||
@ -299,20 +299,22 @@ export class MappingsService {
|
|||||||
|
|
||||||
async getMappingStats(): Promise<MappingStats> {
|
async getMappingStats(): Promise<MappingStats> {
|
||||||
try {
|
try {
|
||||||
const [totalCount, whmcsCount, sfCount, completeCount] = await Promise.all([
|
const [totalCount, whmcsCount, sfCount, completeCount] = await Promise.all([
|
||||||
this.prisma.idMapping.count(),
|
this.prisma.idMapping.count(),
|
||||||
this.prisma.idMapping.count(),
|
this.prisma.idMapping.count({ where: { whmcsClientId: { not: null } } }),
|
||||||
this.prisma.idMapping.count({ where: { sfAccountId: { not: null } } }),
|
this.prisma.idMapping.count({ where: { sfAccountId: { not: null } } }),
|
||||||
this.prisma.idMapping.count({ where: { sfAccountId: { not: null } } }),
|
this.prisma.idMapping.count({ where: { whmcsClientId: { not: null }, sfAccountId: { not: null } } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const stats: MappingStats = {
|
const orphanedMappings = whmcsCount - completeCount;
|
||||||
totalMappings: totalCount,
|
|
||||||
whmcsMappings: whmcsCount,
|
const stats: MappingStats = {
|
||||||
salesforceMappings: sfCount,
|
totalMappings: totalCount,
|
||||||
completeMappings: completeCount,
|
whmcsMappings: whmcsCount,
|
||||||
orphanedMappings: 0,
|
salesforceMappings: sfCount,
|
||||||
};
|
completeMappings: completeCount,
|
||||||
|
orphanedMappings: orphanedMappings < 0 ? 0 : orphanedMappings,
|
||||||
|
};
|
||||||
this.logger.debug("Generated mapping statistics", stats);
|
this.logger.debug("Generated mapping statistics", stats);
|
||||||
return stats;
|
return stats;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -47,7 +47,10 @@ export class OrderItemBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!meta.unitPrice) {
|
if (!meta.unitPrice) {
|
||||||
this.logger.error({ sku: normalizedSkuValue, pbeId: meta.pricebookEntryId }, "PricebookEntry missing UnitPrice");
|
this.logger.error(
|
||||||
|
{ sku: normalizedSkuValue, pbeId: meta.pricebookEntryId },
|
||||||
|
"PricebookEntry missing UnitPrice"
|
||||||
|
);
|
||||||
throw new Error(`PricebookEntry for SKU ${normalizedSkuValue} has no UnitPrice set`);
|
throw new Error(`PricebookEntry for SKU ${normalizedSkuValue} has no UnitPrice set`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,7 +74,10 @@ export class OrderItemBuilder {
|
|||||||
|
|
||||||
this.logger.log({ orderId, sku: normalizedSkuValue }, "OrderItem created successfully");
|
this.logger.log({ orderId, sku: normalizedSkuValue }, "OrderItem created successfully");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error({ error, orderId, sku: normalizedSkuValue }, "Failed to create OrderItem");
|
this.logger.error(
|
||||||
|
{ error, orderId, sku: normalizedSkuValue },
|
||||||
|
"Failed to create OrderItem"
|
||||||
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,12 +7,12 @@ import { OrderItemBuilder } from "./order-item-builder.service";
|
|||||||
import {
|
import {
|
||||||
orderDetailsSchema,
|
orderDetailsSchema,
|
||||||
orderSummarySchema,
|
orderSummarySchema,
|
||||||
type OrderDetailsResponse,
|
z,
|
||||||
type OrderSummaryResponse,
|
|
||||||
type OrderItemSummary,
|
type OrderItemSummary,
|
||||||
type SalesforceOrderRecord,
|
type SalesforceOrderRecord,
|
||||||
type SalesforceOrderItemRecord,
|
type SalesforceOrderItemRecord,
|
||||||
type SalesforceQueryResult,
|
type SalesforceQueryResult,
|
||||||
|
type SalesforceProduct2Record,
|
||||||
} from "@customer-portal/domain";
|
} from "@customer-portal/domain";
|
||||||
import {
|
import {
|
||||||
getSalesforceFieldMap,
|
getSalesforceFieldMap,
|
||||||
@ -23,25 +23,30 @@ import { assertSalesforceId, buildInClause } from "@bff/integrations/salesforce/
|
|||||||
|
|
||||||
const fieldMap = getSalesforceFieldMap();
|
const fieldMap = getSalesforceFieldMap();
|
||||||
|
|
||||||
|
type OrderDetailsResponse = z.infer<typeof orderDetailsSchema>;
|
||||||
|
type OrderSummaryResponse = z.infer<typeof orderSummarySchema>;
|
||||||
|
|
||||||
function getOrderStringField(
|
function getOrderStringField(
|
||||||
order: SalesforceOrderRecord,
|
order: SalesforceOrderRecord,
|
||||||
key: keyof typeof fieldMap.order
|
key: keyof typeof fieldMap.order
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
const raw = (order as Record<string, unknown>)[fieldMap.order[key]];
|
const fieldName = fieldMap.order[key] as keyof SalesforceOrderRecord;
|
||||||
|
const raw = order[fieldName];
|
||||||
return typeof raw === "string" ? raw : undefined;
|
return typeof raw === "string" ? raw : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickProductString(
|
function pickProductString(
|
||||||
product: Record<string, unknown> | undefined,
|
product: SalesforceProduct2Record | null | undefined,
|
||||||
key: keyof typeof fieldMap.product
|
key: keyof typeof fieldMap.product
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
if (!product) return undefined;
|
if (!product) return undefined;
|
||||||
const raw = product[fieldMap.product[key]];
|
const fieldName = fieldMap.product[key] as keyof SalesforceProduct2Record;
|
||||||
|
const raw = product[fieldName];
|
||||||
return typeof raw === "string" ? raw : undefined;
|
return typeof raw === "string" ? raw : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapOrderItemRecord(record: SalesforceOrderItemRecord): ParsedOrderItemDetails {
|
function mapOrderItemRecord(record: SalesforceOrderItemRecord): ParsedOrderItemDetails {
|
||||||
const product = record.PricebookEntry?.Product2 as Record<string, unknown> | undefined;
|
const product = record.PricebookEntry?.Product2 ?? undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: record.Id ?? "",
|
id: record.Id ?? "",
|
||||||
@ -249,6 +254,7 @@ export class OrderOrchestrator {
|
|||||||
name: detail.product.name,
|
name: detail.product.name,
|
||||||
sku: detail.product.sku,
|
sku: detail.product.sku,
|
||||||
itemClass: detail.product.itemClass,
|
itemClass: detail.product.itemClass,
|
||||||
|
billingCycle: detail.billingCycle,
|
||||||
whmcsProductId: detail.product.whmcsProductId,
|
whmcsProductId: detail.product.whmcsProductId,
|
||||||
internetOfferingType: detail.product.internetOfferingType,
|
internetOfferingType: detail.product.internetOfferingType,
|
||||||
internetPlanTier: detail.product.internetPlanTier,
|
internetPlanTier: detail.product.internetPlanTier,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Injectable, BadRequestException, Inject } from "@nestjs/common";
|
import { Injectable, BadRequestException, Inject } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { ZodError } from "zod";
|
||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||||
import { WhmcsConnectionService } from "@bff/integrations/whmcs/services/whmcs-connection.service";
|
import { WhmcsConnectionService } from "@bff/integrations/whmcs/services/whmcs-connection.service";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
@ -242,10 +243,26 @@ export class OrderValidator {
|
|||||||
const validatedBody = this.validateRequestFormat(rawBody);
|
const validatedBody = this.validateRequestFormat(rawBody);
|
||||||
|
|
||||||
// 1b. Business validation (ensures userId-specific constraints)
|
// 1b. Business validation (ensures userId-specific constraints)
|
||||||
const businessValidatedBody = orderBusinessValidationSchema.parse({
|
let businessValidatedBody: OrderBusinessValidation;
|
||||||
...validatedBody,
|
try {
|
||||||
userId,
|
businessValidatedBody = orderBusinessValidationSchema.parse({
|
||||||
});
|
...validatedBody,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
const issues = error.issues.map(issue => {
|
||||||
|
const path = issue.path.join(".");
|
||||||
|
return path ? `${path}: ${issue.message}` : issue.message;
|
||||||
|
});
|
||||||
|
throw new BadRequestException({
|
||||||
|
message: "Order business validation failed",
|
||||||
|
errors: issues,
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
// 2. User and payment validation
|
// 2. User and payment validation
|
||||||
const userMapping = await this.validateUserMapping(userId);
|
const userMapping = await this.validateUserMapping(userId);
|
||||||
|
|||||||
@ -4,11 +4,7 @@ import {
|
|||||||
mapPrismaUserToSharedUser,
|
mapPrismaUserToSharedUser,
|
||||||
mapPrismaUserToEnhancedBase,
|
mapPrismaUserToEnhancedBase,
|
||||||
} from "@bff/infra/utils/user-mapper.util";
|
} from "@bff/infra/utils/user-mapper.util";
|
||||||
import type {
|
import type { UpdateAddressRequest, SalesforceContactRecord } from "@customer-portal/domain";
|
||||||
UpdateAddressRequest,
|
|
||||||
SalesforceAccountRecord,
|
|
||||||
SalesforceContactRecord,
|
|
||||||
} from "@customer-portal/domain";
|
|
||||||
import { Injectable, Inject, NotFoundException, BadRequestException } from "@nestjs/common";
|
import { Injectable, Inject, NotFoundException, BadRequestException } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { PrismaService } from "@bff/infra/database/prisma.service";
|
import { PrismaService } from "@bff/infra/database/prisma.service";
|
||||||
@ -20,9 +16,6 @@ import { SalesforceService } from "@bff/integrations/salesforce/salesforce.servi
|
|||||||
|
|
||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||||
|
|
||||||
// Salesforce Account interface based on the data model
|
|
||||||
interface SalesforceAccount extends SalesforceAccountRecord {}
|
|
||||||
|
|
||||||
// Use a subset of PrismaUser for updates
|
// Use a subset of PrismaUser for updates
|
||||||
type UserUpdateData = Partial<
|
type UserUpdateData = Partial<
|
||||||
Pick<
|
Pick<
|
||||||
@ -165,11 +158,11 @@ export class UsersService {
|
|||||||
try {
|
try {
|
||||||
const client = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
|
const client = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
|
||||||
if (client) {
|
if (client) {
|
||||||
firstName = (client as any).firstname || firstName;
|
firstName = client.firstname || firstName;
|
||||||
lastName = (client as any).lastname || lastName;
|
lastName = client.lastname || lastName;
|
||||||
company = (client as any).companyname || company;
|
company = client.companyname || company;
|
||||||
phone = (client as any).phonenumber || phone;
|
phone = client.phonenumber || phone;
|
||||||
email = (client as any).email || email;
|
email = client.email || email;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn("WHMCS client details unavailable for profile enrichment", {
|
this.logger.warn("WHMCS client details unavailable for profile enrichment", {
|
||||||
@ -181,12 +174,10 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check Salesforce health flag (do not override fields)
|
// Check Salesforce health flag (do not override fields)
|
||||||
let salesforceHealthy = true;
|
|
||||||
if (mapping?.sfAccountId) {
|
if (mapping?.sfAccountId) {
|
||||||
try {
|
try {
|
||||||
await this.salesforceService.getAccount(mapping.sfAccountId);
|
await this.salesforceService.getAccount(mapping.sfAccountId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
salesforceHealthy = false;
|
|
||||||
this.logger.error("Failed to fetch Salesforce account data", {
|
this.logger.error("Failed to fetch Salesforce account data", {
|
||||||
error: getErrorMessage(error),
|
error: getErrorMessage(error),
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@ -16,6 +16,8 @@ export function SessionTimeoutWarning({
|
|||||||
const [showWarning, setShowWarning] = useState(false);
|
const [showWarning, setShowWarning] = useState(false);
|
||||||
const [timeLeft, setTimeLeft] = useState<number>(0);
|
const [timeLeft, setTimeLeft] = useState<number>(0);
|
||||||
const expiryRef = useRef<number | null>(null);
|
const expiryRef = useRef<number | null>(null);
|
||||||
|
const dialogRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const previouslyFocusedElement = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated || !tokens?.expiresAt) {
|
if (!isAuthenticated || !tokens?.expiresAt) {
|
||||||
@ -65,6 +67,12 @@ export function SessionTimeoutWarning({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showWarning || !expiryRef.current) return undefined;
|
if (!showWarning || !expiryRef.current) return undefined;
|
||||||
|
|
||||||
|
previouslyFocusedElement.current = document.activeElement as HTMLElement | null;
|
||||||
|
|
||||||
|
const focusTimer = window.setTimeout(() => {
|
||||||
|
dialogRef.current?.focus();
|
||||||
|
}, 0);
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
const expiryTime = expiryRef.current;
|
const expiryTime = expiryRef.current;
|
||||||
if (!expiryTime) {
|
if (!expiryTime) {
|
||||||
@ -81,7 +89,53 @@ export function SessionTimeoutWarning({
|
|||||||
setTimeLeft(Math.max(1, Math.ceil(remaining / (60 * 1000))));
|
setTimeLeft(Math.max(1, Math.ceil(remaining / (60 * 1000))));
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
clearTimeout(focusTimer);
|
||||||
|
previouslyFocusedElement.current?.focus();
|
||||||
|
};
|
||||||
|
}, [showWarning, logout]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showWarning) return undefined;
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
setShowWarning(false);
|
||||||
|
void logout();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "Tab") {
|
||||||
|
const focusableElements = dialogRef.current?.querySelectorAll<HTMLElement>(
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!focusableElements || focusableElements.length === 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstElement = focusableElements[0];
|
||||||
|
const lastElement = focusableElements[focusableElements.length - 1];
|
||||||
|
|
||||||
|
if (!event.shiftKey && document.activeElement === lastElement) {
|
||||||
|
event.preventDefault();
|
||||||
|
firstElement.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.shiftKey && document.activeElement === firstElement) {
|
||||||
|
event.preventDefault();
|
||||||
|
lastElement.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
}, [showWarning, logout]);
|
}, [showWarning, logout]);
|
||||||
|
|
||||||
const handleExtendSession = () => {
|
const handleExtendSession = () => {
|
||||||
@ -107,14 +161,28 @@ export function SessionTimeoutWarning({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
<div
|
||||||
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4 shadow-xl">
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden={!showWarning}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="session-timeout-title"
|
||||||
|
aria-describedby="session-timeout-description"
|
||||||
|
tabIndex={-1}
|
||||||
|
className="bg-white rounded-lg p-6 max-w-md w-full mx-4 shadow-xl outline-none"
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<span className="text-yellow-500 text-xl">⚠️</span>
|
<span className="text-yellow-500 text-xl">⚠️</span>
|
||||||
<h2 className="text-lg font-semibold">Session Expiring Soon</h2>
|
<h2 id="session-timeout-title" className="text-lg font-semibold">
|
||||||
|
Session Expiring Soon
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-gray-600 mb-6">
|
<p id="session-timeout-description" className="text-gray-600 mb-6">
|
||||||
Your session will expire in{" "}
|
Your session will expire in{" "}
|
||||||
<strong>
|
<strong>
|
||||||
{timeLeft} minute{timeLeft !== 1 ? "s" : ""}
|
{timeLeft} minute{timeLeft !== 1 ? "s" : ""}
|
||||||
|
|||||||
@ -56,8 +56,15 @@ export interface SimActivationFeeCatalogItem extends SimCatalogProduct {
|
|||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VpnCatalogProduct extends CatalogProductBase {
|
export interface VpnCatalogProduct extends CatalogProductBase {
|
||||||
vpnRegion?: string;
|
vpnRegion?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CatalogPricebookEntry {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
unitPrice?: number;
|
||||||
|
pricebook2Id?: string;
|
||||||
|
product2Id?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@ -18,6 +18,10 @@ export interface SalesforceSObjectBase {
|
|||||||
LastModifiedDate?: IsoDateTimeString;
|
LastModifiedDate?: IsoDateTimeString;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SalesforceOrderStatus = string;
|
||||||
|
export type SalesforceOrderType = string;
|
||||||
|
export type SalesforceOrderItemStatus = string;
|
||||||
|
|
||||||
export interface SalesforceProduct2Record extends SalesforceSObjectBase {
|
export interface SalesforceProduct2Record extends SalesforceSObjectBase {
|
||||||
Name?: string;
|
Name?: string;
|
||||||
StockKeepingUnit?: string;
|
StockKeepingUnit?: string;
|
||||||
@ -53,8 +57,7 @@ export interface SalesforcePricebookEntryRecord extends SalesforceSObjectBase {
|
|||||||
Product2?: SalesforceProduct2Record | null;
|
Product2?: SalesforceProduct2Record | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SalesforceProduct2WithPricebookEntries
|
export interface SalesforceProduct2WithPricebookEntries extends SalesforceProduct2Record {
|
||||||
extends SalesforceProduct2Record {
|
|
||||||
PricebookEntries?: {
|
PricebookEntries?: {
|
||||||
records?: SalesforcePricebookEntryRecord[];
|
records?: SalesforcePricebookEntryRecord[];
|
||||||
};
|
};
|
||||||
@ -100,6 +103,7 @@ export interface SalesforceOrderRecord extends SalesforceSObjectBase {
|
|||||||
EffectiveDate?: IsoDateTimeString | null;
|
EffectiveDate?: IsoDateTimeString | null;
|
||||||
TotalAmount?: number | null;
|
TotalAmount?: number | null;
|
||||||
AccountId?: string | null;
|
AccountId?: string | null;
|
||||||
|
Account?: { Name?: string | null } | null;
|
||||||
Pricebook2Id?: string | null;
|
Pricebook2Id?: string | null;
|
||||||
Activation_Type__c?: string | null;
|
Activation_Type__c?: string | null;
|
||||||
Activation_Status__c?: string | null;
|
Activation_Status__c?: string | null;
|
||||||
@ -120,6 +124,25 @@ export interface SalesforceOrderRecord extends SalesforceSObjectBase {
|
|||||||
ActivatedDate?: IsoDateTimeString | null;
|
ActivatedDate?: IsoDateTimeString | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SalesforceOrderItemSummary {
|
||||||
|
productName?: string;
|
||||||
|
sku?: string;
|
||||||
|
status?: SalesforceOrderItemStatus;
|
||||||
|
billingCycle?: string;
|
||||||
|
}
|
||||||
|
export interface SalesforceOrderSummary {
|
||||||
|
id: string;
|
||||||
|
orderNumber: string;
|
||||||
|
status: SalesforceOrderStatus;
|
||||||
|
orderType?: SalesforceOrderType;
|
||||||
|
effectiveDate: IsoDateTimeString;
|
||||||
|
totalAmount?: number;
|
||||||
|
createdDate: IsoDateTimeString;
|
||||||
|
lastModifiedDate: IsoDateTimeString;
|
||||||
|
whmcsOrderId?: string;
|
||||||
|
itemsSummary: SalesforceOrderItemSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface SalesforceOrderItemRecord extends SalesforceSObjectBase {
|
export interface SalesforceOrderItemRecord extends SalesforceSObjectBase {
|
||||||
OrderId?: string | null;
|
OrderId?: string | null;
|
||||||
Quantity?: number | null;
|
Quantity?: number | null;
|
||||||
@ -130,7 +153,6 @@ export interface SalesforceOrderItemRecord extends SalesforceSObjectBase {
|
|||||||
Billing_Cycle__c?: string | null;
|
Billing_Cycle__c?: string | null;
|
||||||
WHMCS_Service_ID__c?: string | null;
|
WHMCS_Service_ID__c?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SalesforceAccountContactRecord extends SalesforceSObjectBase {
|
export interface SalesforceAccountContactRecord extends SalesforceSObjectBase {
|
||||||
AccountId?: string | null;
|
AccountId?: string | null;
|
||||||
ContactId?: string | null;
|
ContactId?: string | null;
|
||||||
@ -142,4 +164,3 @@ export interface SalesforceContactRecord extends SalesforceSObjectBase {
|
|||||||
Email?: string | null;
|
Email?: string | null;
|
||||||
Phone?: string | null;
|
Phone?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
// Export all entities - resolving conflicts explicitly
|
// Export all entities - resolving conflicts explicitly
|
||||||
export * from "./user";
|
export * from "./user";
|
||||||
export * from "./invoice";
|
export * from "./invoice";
|
||||||
|
export * from "./subscription";
|
||||||
export * from "./payment";
|
export * from "./payment";
|
||||||
export * from "./case";
|
export * from "./case";
|
||||||
export * from "./dashboard";
|
export * from "./dashboard";
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
// Invoice types from WHMCS
|
// Invoice types from WHMCS
|
||||||
export type {
|
import type { InvoiceSchema, InvoiceItemSchema, InvoiceListSchema } from "../validation";
|
||||||
InvoiceSchema as Invoice,
|
|
||||||
InvoiceItemSchema as InvoiceItem,
|
export type Invoice = InvoiceSchema;
|
||||||
InvoiceListSchema as InvoiceList,
|
export type InvoiceItem = InvoiceItemSchema;
|
||||||
} from "../validation";
|
export type InvoiceList = InvoiceListSchema;
|
||||||
|
|
||||||
export interface InvoiceSsoLink {
|
export interface InvoiceSsoLink {
|
||||||
url: string;
|
url: string;
|
||||||
|
|||||||
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;
|
lastLoginAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UserRole = "user" | "admin";
|
||||||
|
|
||||||
|
export interface AuthenticatedUser extends UserProfile {
|
||||||
|
role: UserRole;
|
||||||
|
}
|
||||||
|
|
||||||
// MNP (Mobile Number Portability) business entity
|
// MNP (Mobile Number Portability) business entity
|
||||||
export interface MnpDetails {
|
export interface MnpDetails {
|
||||||
currentProvider: string;
|
currentProvider: string;
|
||||||
|
|||||||
@ -1,13 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { userProfileSchema } from "../shared/entities";
|
import { userProfileSchema } from "../shared/entities";
|
||||||
import {
|
|
||||||
orderDetailItemSchema,
|
|
||||||
orderDetailItemProductSchema,
|
|
||||||
orderDetailsSchema,
|
|
||||||
orderSummaryItemSchema,
|
|
||||||
orderSummarySchema,
|
|
||||||
} from "../shared/order";
|
|
||||||
|
|
||||||
export const authResponseSchema = z.object({
|
export const authResponseSchema = z.object({
|
||||||
user: userProfileSchema,
|
user: userProfileSchema,
|
||||||
@ -22,7 +15,3 @@ export const authResponseSchema = z.object({
|
|||||||
|
|
||||||
export type AuthResponse = z.infer<typeof authResponseSchema>;
|
export type AuthResponse = z.infer<typeof authResponseSchema>;
|
||||||
export type AuthTokensSchema = AuthResponse["tokens"];
|
export type AuthTokensSchema = AuthResponse["tokens"];
|
||||||
|
|
||||||
export { orderDetailsSchema, orderSummarySchema };
|
|
||||||
export type OrderDetailsResponse = z.infer<typeof orderDetailsSchema>;
|
|
||||||
export type OrderSummaryResponse = z.infer<typeof orderSummarySchema>;
|
|
||||||
|
|||||||
@ -3,8 +3,6 @@
|
|||||||
* Frontend form schemas that extend API request schemas with UI-specific fields
|
* Frontend form schemas that extend API request schemas with UI-specific fields
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
loginRequestSchema,
|
loginRequestSchema,
|
||||||
signupRequestSchema,
|
signupRequestSchema,
|
||||||
@ -59,23 +57,31 @@ export const changePasswordFormToRequest = (
|
|||||||
|
|
||||||
// Import API types
|
// Import API types
|
||||||
import type {
|
import type {
|
||||||
LoginRequestInput as LoginRequestData,
|
LoginRequestInput,
|
||||||
SignupRequestInput as SignupRequestData,
|
SignupRequestInput,
|
||||||
PasswordResetRequestInput as PasswordResetRequestData,
|
PasswordResetRequestInput,
|
||||||
PasswordResetInput as PasswordResetData,
|
PasswordResetInput,
|
||||||
SetPasswordRequestInput as SetPasswordRequestData,
|
SetPasswordRequestInput,
|
||||||
ChangePasswordRequestInput as ChangePasswordRequestData,
|
ChangePasswordRequestInput,
|
||||||
LinkWhmcsRequestInput as LinkWhmcsRequestData,
|
LinkWhmcsRequestInput,
|
||||||
} from "../api/requests";
|
} from "../api/requests";
|
||||||
|
|
||||||
// Export form types
|
type LoginRequestData = LoginRequestInput;
|
||||||
export type LoginFormData = z.infer<typeof loginFormSchema>;
|
type SignupRequestData = SignupRequestInput;
|
||||||
export type SignupFormData = z.infer<typeof signupFormSchema>;
|
type PasswordResetRequestData = PasswordResetRequestInput;
|
||||||
export type PasswordResetRequestFormData = z.infer<typeof passwordResetRequestFormSchema>;
|
type PasswordResetData = PasswordResetInput;
|
||||||
export type PasswordResetFormData = z.infer<typeof passwordResetFormSchema>;
|
type SetPasswordRequestData = SetPasswordRequestInput;
|
||||||
export type SetPasswordFormData = z.infer<typeof setPasswordFormSchema>;
|
type ChangePasswordRequestData = ChangePasswordRequestInput;
|
||||||
export type ChangePasswordFormData = z.infer<typeof changePasswordFormSchema>;
|
type LinkWhmcsRequestData = LinkWhmcsRequestInput;
|
||||||
export type LinkWhmcsFormData = z.infer<typeof linkWhmcsFormSchema>;
|
|
||||||
|
// Export form types (aliases of API request types)
|
||||||
|
export type LoginFormData = LoginRequestInput;
|
||||||
|
export type SignupFormData = SignupRequestInput;
|
||||||
|
export type PasswordResetRequestFormData = PasswordResetRequestInput;
|
||||||
|
export type PasswordResetFormData = PasswordResetInput;
|
||||||
|
export type SetPasswordFormData = SetPasswordRequestInput;
|
||||||
|
export type ChangePasswordFormData = ChangePasswordRequestInput;
|
||||||
|
export type LinkWhmcsFormData = LinkWhmcsRequestInput;
|
||||||
|
|
||||||
// Re-export API types for convenience
|
// Re-export API types for convenience
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@ -62,9 +62,12 @@ export const contactFormToRequest = (formData: ContactFormData): ContactRequestD
|
|||||||
import type {
|
import type {
|
||||||
UpdateProfileRequest as UpdateProfileRequestData,
|
UpdateProfileRequest as UpdateProfileRequestData,
|
||||||
ContactRequest as ContactRequestData,
|
ContactRequest as ContactRequestData,
|
||||||
|
UpdateAddressRequest as UpdateAddressRequestData,
|
||||||
} from "../api/requests";
|
} from "../api/requests";
|
||||||
|
|
||||||
// Export form types and API request types
|
// Export form types and API request types
|
||||||
export type ProfileEditFormData = z.infer<typeof profileEditFormSchema>;
|
export type ProfileEditFormData = z.infer<typeof profileEditFormSchema>;
|
||||||
export type AddressFormData = z.infer<typeof addressFormSchema>;
|
export type AddressFormData = z.infer<typeof addressFormSchema>;
|
||||||
export type ContactFormData = z.infer<typeof contactFormSchema>;
|
export type ContactFormData = z.infer<typeof contactFormSchema>;
|
||||||
|
|
||||||
|
export type { UpdateProfileRequestData, UpdateAddressRequestData, ContactRequestData };
|
||||||
|
|||||||
@ -46,8 +46,11 @@ export {
|
|||||||
simChangePlanRequestSchema,
|
simChangePlanRequestSchema,
|
||||||
simFeaturesRequestSchema,
|
simFeaturesRequestSchema,
|
||||||
|
|
||||||
// Contact API schemas
|
// Contact & billing schemas
|
||||||
contactRequestSchema,
|
contactRequestSchema,
|
||||||
|
invoiceItemSchema,
|
||||||
|
invoiceSchema,
|
||||||
|
invoiceListSchema,
|
||||||
|
|
||||||
// API types
|
// API types
|
||||||
type LoginRequestInput,
|
type LoginRequestInput,
|
||||||
@ -74,13 +77,7 @@ export {
|
|||||||
} from "./api/requests";
|
} from "./api/requests";
|
||||||
|
|
||||||
// Form schemas (frontend) - explicit exports for better tree shaking
|
// Form schemas (frontend) - explicit exports for better tree shaking
|
||||||
export {
|
export { authResponseSchema, type AuthResponse, type AuthTokensSchema } from "./api/responses";
|
||||||
authResponseSchema,
|
|
||||||
type AuthResponse,
|
|
||||||
type AuthTokensSchema,
|
|
||||||
type OrderDetailsResponse,
|
|
||||||
type OrderSummaryResponse,
|
|
||||||
} from "./api/responses";
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
// Auth form schemas
|
// Auth form schemas
|
||||||
|
|||||||
@ -92,6 +92,7 @@ export const userProfileSchema = userSchema.extend({
|
|||||||
avatar: z.string().optional(),
|
avatar: z.string().optional(),
|
||||||
preferences: z.record(z.string(), z.unknown()).optional(),
|
preferences: z.record(z.string(), z.unknown()).optional(),
|
||||||
lastLoginAt: timestampSchema.optional(),
|
lastLoginAt: timestampSchema.optional(),
|
||||||
|
role: z.enum(["user", "admin"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const prismaUserProfileSchema = z.object({
|
export const prismaUserProfileSchema = z.object({
|
||||||
|
|||||||
@ -47,7 +47,30 @@ export {
|
|||||||
// Validation utilities and helpers
|
// Validation utilities and helpers
|
||||||
export * from "./utilities";
|
export * from "./utilities";
|
||||||
|
|
||||||
export { userSchema, userProfileSchema } from "./entities";
|
export {
|
||||||
|
userSchema,
|
||||||
|
userProfileSchema,
|
||||||
|
invoiceItemSchema,
|
||||||
|
invoiceSchema,
|
||||||
|
invoiceListSchema,
|
||||||
|
subscriptionSchema,
|
||||||
|
paymentMethodSchema,
|
||||||
|
paymentSchema,
|
||||||
|
caseCommentSchema,
|
||||||
|
supportCaseSchema,
|
||||||
|
} from "./entities";
|
||||||
|
export type {
|
||||||
|
UserSchema,
|
||||||
|
UserProfileSchema,
|
||||||
|
InvoiceItemSchema,
|
||||||
|
InvoiceSchema,
|
||||||
|
InvoiceListSchema,
|
||||||
|
SubscriptionSchema,
|
||||||
|
PaymentMethodSchema,
|
||||||
|
PaymentSchema,
|
||||||
|
CaseCommentSchema,
|
||||||
|
SupportCaseSchema,
|
||||||
|
} from "./entities";
|
||||||
export {
|
export {
|
||||||
orderItemProductSchema,
|
orderItemProductSchema,
|
||||||
orderDetailItemSchema,
|
orderDetailItemSchema,
|
||||||
|
|||||||
@ -62,3 +62,8 @@ export const orderSummarySchema = z.object({
|
|||||||
itemsSummary: z.array(orderSummaryItemSchema),
|
itemsSummary: z.array(orderSummaryItemSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type OrderItemProduct = z.infer<typeof orderItemProductSchema>;
|
||||||
|
export type OrderDetailItem = z.infer<typeof orderDetailItemSchema>;
|
||||||
|
export type OrderItemSummary = z.infer<typeof orderSummaryItemSchema>;
|
||||||
|
export type OrderDetailsResponse = z.infer<typeof orderDetailsSchema>;
|
||||||
|
export type OrderSummaryResponse = z.infer<typeof orderSummarySchema>;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user