Refactor EditorConfig and improve code formatting across multiple files

- Replaced the existing .editorconfig file to establish consistent coding styles across editors.
- Cleaned up whitespace and improved formatting in various TypeScript files for better readability.
- Enhanced logging and error handling in Salesforce and WHMCS services to provide clearer insights during operations.
- Streamlined order processing and caching mechanisms, ensuring efficient handling of API requests and responses.
- Updated test cases to reflect changes in service methods and improve overall test coverage.
This commit is contained in:
barsa 2025-11-17 11:49:58 +09:00
parent b5533994c2
commit ff55639b2d
54 changed files with 645 additions and 811 deletions

View File

@ -1 +0,0 @@
config/.editorconfig

13
.editorconfig Normal file
View File

@ -0,0 +1,13 @@
# EditorConfig helps maintain consistent coding styles across editors
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false

View File

@ -740,7 +740,8 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest
getDegradationState(): SalesforceDegradationSnapshot { getDegradationState(): SalesforceDegradationSnapshot {
this.clearDegradeWindowIfElapsed(); this.clearDegradeWindowIfElapsed();
const usagePercent = this.dailyApiLimit > 0 ? this.metrics.dailyApiUsage / this.dailyApiLimit : 0; const usagePercent =
this.dailyApiLimit > 0 ? this.metrics.dailyApiUsage / this.dailyApiLimit : 0;
return { return {
degraded: this.degradeState.until !== null, degraded: this.degradeState.until !== null,
reason: this.degradeState.reason, reason: this.degradeState.reason,

View File

@ -47,11 +47,7 @@ describe("CsrfController", () => {
controller.getCsrfToken(req, res); controller.getCsrfToken(req, res);
expect(csrfService.generateToken).toHaveBeenCalledWith( expect(csrfService.generateToken).toHaveBeenCalledWith(undefined, "session-456", "user-123");
undefined,
"session-456",
"user-123"
);
}); });
it("defaults to anonymous user during refresh and still forwards identifiers correctly", () => { it("defaults to anonymous user during refresh and still forwards identifiers correctly", () => {

View File

@ -171,10 +171,13 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
const invalidated = await this.catalogCache.invalidateProducts(productIds); const invalidated = await this.catalogCache.invalidateProducts(productIds);
if (!invalidated) { if (!invalidated) {
this.logger.debug("No catalog cache entries were linked to product IDs; falling back to full invalidation", { this.logger.debug(
"No catalog cache entries were linked to product IDs; falling back to full invalidation",
{
channel, channel,
productIds, productIds,
}); }
);
await this.invalidateAllCatalogs(); await this.invalidateAllCatalogs();
} }
} }
@ -206,16 +209,17 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
productId, productId,
}); });
const invalidated = await this.catalogCache.invalidateProducts( const invalidated = await this.catalogCache.invalidateProducts(productId ? [productId] : []);
productId ? [productId] : []
);
if (!invalidated) { if (!invalidated) {
this.logger.debug("No catalog cache entries mapped to product from pricebook event; performing full invalidation", { this.logger.debug(
"No catalog cache entries mapped to product from pricebook event; performing full invalidation",
{
channel, channel,
pricebookId, pricebookId,
productId, productId,
}); }
);
await this.invalidateAllCatalogs(); await this.invalidateAllCatalogs();
} }
} }

View File

@ -68,15 +68,10 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
]); ]);
// Internal OrderItem fields - ignore these // Internal OrderItem fields - ignore these
private readonly INTERNAL_ORDER_ITEM_FIELDS = new Set([ private readonly INTERNAL_ORDER_ITEM_FIELDS = new Set(["WHMCS_Service_ID__c"]);
"WHMCS_Service_ID__c",
]);
// Statuses that trigger provisioning // Statuses that trigger provisioning
private readonly PROVISION_TRIGGER_STATUSES = new Set([ private readonly PROVISION_TRIGGER_STATUSES = new Set(["Approved", "Reactivate"]);
"Approved",
"Reactivate",
]);
constructor( constructor(
private readonly config: ConfigService, private readonly config: ConfigService,
@ -96,11 +91,9 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
} }
const orderChannel = const orderChannel =
this.config.get<string>("SF_ORDER_CDC_CHANNEL")?.trim() || this.config.get<string>("SF_ORDER_CDC_CHANNEL")?.trim() || "/data/OrderChangeEvent";
"/data/OrderChangeEvent";
const orderItemChannel = const orderItemChannel =
this.config.get<string>("SF_ORDER_ITEM_CDC_CHANNEL")?.trim() || this.config.get<string>("SF_ORDER_ITEM_CDC_CHANNEL")?.trim() || "/data/OrderItemChangeEvent";
"/data/OrderItemChangeEvent";
this.logger.log("Initializing Salesforce Order CDC subscriber", { this.logger.log("Initializing Salesforce Order CDC subscriber", {
orderChannel, orderChannel,
@ -148,7 +141,7 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
return this.client; return this.client;
} }
const ctor = await this.loadPubSubCtor(); const ctor = this.loadPubSubCtor();
await this.sfConnection.connect(); await this.sfConnection.connect();
const accessToken = this.sfConnection.getAccessToken(); const accessToken = this.sfConnection.getAccessToken();
@ -202,7 +195,7 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
} }
} }
private async loadPubSubCtor(): Promise<PubSubCtor> { private loadPubSubCtor(): PubSubCtor {
if (this.pubSubCtor) { if (this.pubSubCtor) {
return this.pubSubCtor; return this.pubSubCtor;
} }
@ -284,11 +277,14 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
const hasCustomerFacingChange = this.hasCustomerFacingChanges(changedFields); const hasCustomerFacingChange = this.hasCustomerFacingChanges(changedFields);
if (!hasCustomerFacingChange) { if (!hasCustomerFacingChange) {
this.logger.debug("Order CDC event contains only internal field changes; skipping cache invalidation", { this.logger.debug(
"Order CDC event contains only internal field changes; skipping cache invalidation",
{
channel, channel,
orderId, orderId,
changedFields: Array.from(changedFields), changedFields: Array.from(changedFields),
}); }
);
return; return;
} }
@ -336,11 +332,14 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
} }
if (status && !this.PROVISION_TRIGGER_STATUSES.has(status)) { if (status && !this.PROVISION_TRIGGER_STATUSES.has(status)) {
this.logger.debug("Activation status set to Activating but order status is not a provisioning trigger", { this.logger.debug(
"Activation status set to Activating but order status is not a provisioning trigger",
{
orderId, orderId,
activationStatus, activationStatus,
status, status,
}); }
);
return; return;
} }
@ -441,7 +440,7 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
// Remove internal fields from changed fields // Remove internal fields from changed fields
const customerFacingChanges = Array.from(changedFields).filter( const customerFacingChanges = Array.from(changedFields).filter(
(field) => !this.INTERNAL_FIELDS.has(field) field => !this.INTERNAL_FIELDS.has(field)
); );
return customerFacingChanges.length > 0; return customerFacingChanges.length > 0;
@ -456,7 +455,7 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
} }
const customerFacingChanges = Array.from(changedFields).filter( const customerFacingChanges = Array.from(changedFields).filter(
(field) => !this.INTERNAL_ORDER_ITEM_FIELDS.has(field) field => !this.INTERNAL_ORDER_ITEM_FIELDS.has(field)
); );
return customerFacingChanges.length > 0; return customerFacingChanges.length > 0;
@ -471,15 +470,16 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
const header = this.extractChangeEventHeader(payload); const header = this.extractChangeEventHeader(payload);
const headerChangedFields = Array.isArray(header?.changedFields) const headerChangedFields = Array.isArray(header?.changedFields)
? (header?.changedFields as unknown[]) ? (header?.changedFields as unknown[]).filter(
.filter((field): field is string => typeof field === "string" && field.length > 0) (field): field is string => typeof field === "string" && field.length > 0
)
: []; : [];
// CDC provides changed fields in different formats depending on API version // CDC provides changed fields in different formats depending on API version
// Try to extract from common locations // Try to extract from common locations
const changedFieldsArray = const changedFieldsArray =
(payload.changedFields as string[] | undefined) || (payload.changedFields as string[] | undefined) ||
((payload.changeOrigin as { changedFields?: string[] })?.changedFields) || (payload.changeOrigin as { changedFields?: string[] })?.changedFields ||
[]; [];
return new Set([ return new Set([
@ -523,14 +523,14 @@ export class OrderCdcSubscriber implements OnModuleInit, OnModuleDestroy {
return undefined; return undefined;
} }
private extractChangeEventHeader( private extractChangeEventHeader(payload: Record<string, unknown>):
payload: Record<string, unknown> | {
): {
changedFields?: unknown; changedFields?: unknown;
recordIds?: unknown; recordIds?: unknown;
entityName?: unknown; entityName?: unknown;
changeType?: unknown; changeType?: unknown;
} | undefined { }
| undefined {
const header = payload["ChangeEventHeader"]; const header = payload["ChangeEventHeader"];
if (header && typeof header === "object") { if (header && typeof header === "object") {
return header as { return header as {

View File

@ -37,4 +37,3 @@ export class SalesforceReadThrottleGuard implements CanActivate {
); );
} }
} }

View File

@ -37,4 +37,3 @@ export class SalesforceWriteThrottleGuard implements CanActivate {
); );
} }
} }

View File

@ -56,9 +56,7 @@ export class SalesforceAccountService {
} }
} }
async findWithDetailsByCustomerNumber( async findWithDetailsByCustomerNumber(customerNumber: string): Promise<{
customerNumber: string
): Promise<{
id: string; id: string;
Name?: string | null; Name?: string | null;
WH_Account__c?: string | null; WH_Account__c?: string | null;

View File

@ -9,10 +9,24 @@ describe("SalesforceConnection", () => {
get: jest.fn(), get: jest.fn(),
} as unknown as ConfigService; } as unknown as ConfigService;
const requestQueue: Partial<SalesforceRequestQueueService> = { const execute = jest.fn<
execute: jest.fn().mockImplementation(async (fn) => fn()), Promise<unknown>,
executeHighPriority: jest.fn().mockImplementation(async (fn) => fn()), [fn: () => Promise<unknown>, options?: Record<string, unknown>]
}; >(async (fn): Promise<unknown> => {
return await fn();
});
const executeHighPriority = jest.fn<
Promise<unknown>,
[fn: () => Promise<unknown>, options?: Record<string, unknown>]
>(async (fn): Promise<unknown> => {
return await fn();
});
const requestQueue = {
execute,
executeHighPriority,
} as unknown as SalesforceRequestQueueService;
const logger = { const logger = {
debug: jest.fn(), debug: jest.fn(),
@ -21,7 +35,7 @@ describe("SalesforceConnection", () => {
log: jest.fn(), log: jest.fn(),
} as unknown as Logger; } as unknown as Logger;
const service = new SalesforceConnection(configService, requestQueue as SalesforceRequestQueueService, logger); const service = new SalesforceConnection(configService, requestQueue, logger);
// Override internal connection with simple stubs // Override internal connection with simple stubs
const queryMock = jest.fn().mockResolvedValue("query-result"); const queryMock = jest.fn().mockResolvedValue("query-result");
@ -31,14 +45,18 @@ describe("SalesforceConnection", () => {
create: jest.fn().mockResolvedValue({ id: "001" }), create: jest.fn().mockResolvedValue({ id: "001" }),
update: jest.fn().mockResolvedValue({}), update: jest.fn().mockResolvedValue({}),
}), }),
} as unknown as typeof service["connection"]; } as unknown as (typeof service)["connection"];
jest.spyOn(service as unknown as { ensureConnected(): Promise<void> }, "ensureConnected").mockResolvedValue(); jest
.spyOn(service as unknown as { ensureConnected(): Promise<void> }, "ensureConnected")
.mockResolvedValue();
return { return {
service, service,
requestQueue, requestQueue,
queryMock, queryMock,
execute,
executeHighPriority,
}; };
}; };
@ -47,12 +65,12 @@ describe("SalesforceConnection", () => {
}); });
it("routes standard queries through the request queue with derived priority metadata", async () => { it("routes standard queries through the request queue with derived priority metadata", async () => {
const { service, requestQueue, queryMock } = createService(); const { service, execute, queryMock } = createService();
await service.query("SELECT Id FROM Account WHERE Id = '001'"); await service.query("SELECT Id FROM Account WHERE Id = '001'");
expect(requestQueue.execute).toHaveBeenCalledTimes(1); expect(execute).toHaveBeenCalledTimes(1);
const [, options] = (requestQueue.execute as jest.Mock).mock.calls[0]; const [, options] = execute.mock.calls[0];
expect(options).toMatchObject({ expect(options).toMatchObject({
priority: 8, priority: 8,
isLongRunning: false, isLongRunning: false,
@ -62,13 +80,13 @@ describe("SalesforceConnection", () => {
}); });
it("routes SObject create operations through the high-priority queue", async () => { it("routes SObject create operations through the high-priority queue", async () => {
const { service, requestQueue } = createService(); const { service, executeHighPriority } = createService();
const sobject = service.sobject("Order"); const sobject = service.sobject("Order");
await sobject.create({ Name: "Test" }); await sobject.create({ Name: "Test" });
expect(requestQueue.executeHighPriority).toHaveBeenCalledTimes(1); expect(executeHighPriority).toHaveBeenCalledTimes(1);
const [, options] = (requestQueue.executeHighPriority as jest.Mock).mock.calls[0]; const [, options] = executeHighPriority.mock.calls[0];
expect(options).toMatchObject({ label: "salesforce:sobject:Order:create" }); expect(options).toMatchObject({ label: "salesforce:sobject:Order:create" });
}); });
}); });

View File

@ -246,8 +246,7 @@ export class SalesforceConnection {
const isLongRunning = this.isLongRunningQuery(soql); const isLongRunning = this.isLongRunningQuery(soql);
const label = options.label ?? this.deriveQueryLabel(soql); const label = options.label ?? this.deriveQueryLabel(soql);
try { return this.requestQueue.execute(
return await this.requestQueue.execute(
async () => { async () => {
await this.ensureConnected(); await this.ensureConnected();
try { try {
@ -258,7 +257,9 @@ export class SalesforceConnection {
this.logger.warn("Salesforce session expired, attempting to re-authenticate", { this.logger.warn("Salesforce session expired, attempting to re-authenticate", {
originalError: getErrorMessage(error), originalError: getErrorMessage(error),
tokenIssuedAt: this.tokenIssuedAt ? new Date(this.tokenIssuedAt).toISOString() : null, tokenIssuedAt: this.tokenIssuedAt ? new Date(this.tokenIssuedAt).toISOString() : null,
tokenExpiresAt: this.tokenExpiresAt ? new Date(this.tokenExpiresAt).toISOString() : null, tokenExpiresAt: this.tokenExpiresAt
? new Date(this.tokenExpiresAt).toISOString()
: null,
}); });
try { try {
@ -286,9 +287,6 @@ export class SalesforceConnection {
}, },
{ priority, isLongRunning, label } { priority, isLongRunning, label }
); );
} catch (error: unknown) {
throw error;
}
} }
private isSessionExpiredError(error: unknown): boolean { private isSessionExpiredError(error: unknown): boolean {
@ -314,7 +312,8 @@ export class SalesforceConnection {
// Return a wrapper that handles session expiration for SObject operations // Return a wrapper that handles session expiration for SObject operations
return { return {
create: async (data: object) => { create: async (data: object) => {
return this.requestQueue.executeHighPriority(async () => { return this.requestQueue.executeHighPriority(
async () => {
try { try {
await this.ensureConnected(); await this.ensureConnected();
return await originalSObject.create(data); return await originalSObject.create(data);
@ -352,11 +351,14 @@ export class SalesforceConnection {
} }
throw error; throw error;
} }
}, { label: `salesforce:sobject:${type}:create` }); },
{ label: `salesforce:sobject:${type}:create` }
);
}, },
update: async (data: object & { Id: string }) => { update: async (data: object & { Id: string }) => {
return this.requestQueue.executeHighPriority(async () => { return this.requestQueue.executeHighPriority(
async () => {
try { try {
await this.ensureConnected(); await this.ensureConnected();
return await originalSObject.update(data); return await originalSObject.update(data);
@ -397,7 +399,9 @@ export class SalesforceConnection {
} }
throw error; throw error;
} }
}, { label: `salesforce:sobject:${type}:update` }); },
{ label: `salesforce:sobject:${type}:update` }
);
}, },
}; };
} }
@ -412,7 +416,8 @@ export class SalesforceConnection {
const path = this.buildCompositeTreePath(sobjectType, allOrNone); const path = this.buildCompositeTreePath(sobjectType, allOrNone);
const label = options.label ?? `salesforce:composite:${sobjectType}`; const label = options.label ?? `salesforce:composite:${sobjectType}`;
return this.requestQueue.execute(async () => { return this.requestQueue.execute(
async () => {
await this.ensureConnected(); await this.ensureConnected();
if (!body || typeof body !== "object") { if (!body || typeof body !== "object") {
@ -444,7 +449,9 @@ export class SalesforceConnection {
throw error; throw error;
} }
}, { priority, label }); },
{ priority, label }
);
} }
private buildCompositeTreePath(sobjectType: string, allOrNone: boolean): string { private buildCompositeTreePath(sobjectType: string, allOrNone: boolean): string {
@ -513,7 +520,8 @@ export class SalesforceConnection {
*/ */
async queryHighPriority(soql: string, options: { label?: string } = {}): Promise<unknown> { async queryHighPriority(soql: string, options: { label?: string } = {}): Promise<unknown> {
const label = options.label ?? `${this.deriveQueryLabel(soql)}:high`; const label = options.label ?? `${this.deriveQueryLabel(soql)}:high`;
return this.requestQueue.executeHighPriority(async () => { return this.requestQueue.executeHighPriority(
async () => {
await this.ensureConnected(); await this.ensureConnected();
try { try {
return await this.connection.query(soql); return await this.connection.query(soql);
@ -537,7 +545,9 @@ export class SalesforceConnection {
} }
throw error; throw error;
} }
}, { label }); },
{ label }
);
} }
/** /**

View File

@ -51,15 +51,13 @@ export class SalesforceOrderService {
// Build queries // Build queries
const orderQueryFields = this.orderFieldMap.buildOrderSelectFields(["Account.Name"]).join(", "); const orderQueryFields = this.orderFieldMap.buildOrderSelectFields(["Account.Name"]).join(", ");
const orderItemProduct2Fields = this.orderFieldMap.buildOrderItemProduct2Fields().map( const orderItemProduct2Fields = this.orderFieldMap
f => `PricebookEntry.Product2.${f}` .buildOrderItemProduct2Fields()
); .map(f => `PricebookEntry.Product2.${f}`);
const orderItemSelect = [ const orderItemSelect = [
...this.orderFieldMap.buildOrderItemSelectFields(), ...this.orderFieldMap.buildOrderItemSelectFields(),
...orderItemProduct2Fields, ...orderItemProduct2Fields,
].join( ].join(", ");
", "
);
const orderSoql = ` const orderSoql = `
SELECT ${orderQueryFields} SELECT ${orderQueryFields}
@ -222,15 +220,13 @@ export class SalesforceOrderService {
// Build queries // Build queries
const orderQueryFields = this.orderFieldMap.buildOrderSelectFields().join(", "); const orderQueryFields = this.orderFieldMap.buildOrderSelectFields().join(", ");
const orderItemProduct2Fields = this.orderFieldMap.buildOrderItemProduct2Fields().map( const orderItemProduct2Fields = this.orderFieldMap
f => `PricebookEntry.Product2.${f}` .buildOrderItemProduct2Fields()
); .map(f => `PricebookEntry.Product2.${f}`);
const orderItemSelect = [ const orderItemSelect = [
...this.orderFieldMap.buildOrderItemSelectFields(), ...this.orderFieldMap.buildOrderItemSelectFields(),
...orderItemProduct2Fields, ...orderItemProduct2Fields,
].join( ].join(", ");
", "
);
const ordersSoql = ` const ordersSoql = `
SELECT ${orderQueryFields} SELECT ${orderQueryFields}

View File

@ -133,7 +133,7 @@ export class WhmcsHttpClientService {
const timeoutId = setTimeout(() => controller.abort(), timeout); const timeoutId = setTimeout(() => controller.abort(), timeout);
try { try {
const requestBody = this.buildRequestBody(config, action, params, options); const requestBody = this.buildRequestBody(config, action, params);
const url = `${config.baseUrl}/includes/api.php`; const url = `${config.baseUrl}/includes/api.php`;
const response = await fetch(url, { const response = await fetch(url, {
@ -170,8 +170,7 @@ export class WhmcsHttpClientService {
private buildRequestBody( private buildRequestBody(
config: WhmcsApiConfig, config: WhmcsApiConfig,
action: string, action: string,
params: Record<string, unknown>, params: Record<string, unknown>
options: WhmcsRequestOptions
): string { ): string {
const formData = new URLSearchParams(); const formData = new URLSearchParams();
@ -221,11 +220,7 @@ export class WhmcsHttpClientService {
if (typeof value === "string") { if (typeof value === "string") {
return value; return value;
} }
if ( if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
typeof value === "number" ||
typeof value === "boolean" ||
typeof value === "bigint"
) {
return String(value); return String(value);
} }
if (value instanceof Date) { if (value instanceof Date) {
@ -273,14 +268,9 @@ export class WhmcsHttpClientService {
// Handle error responses according to WHMCS API documentation // Handle error responses according to WHMCS API documentation
if (parsedResponse.result === "error") { if (parsedResponse.result === "error") {
const errorMessage = this.toDisplayString( const { message, error, errorcode, ...additionalFields } = parsedResponse;
parsedResponse.message ?? parsedResponse.error, const errorMessage = this.toDisplayString(message ?? error, "Unknown WHMCS API error");
"Unknown WHMCS API error" const errorCode = this.toDisplayString(errorcode, "unknown");
);
const errorCode = this.toDisplayString(parsedResponse.errorcode, "unknown");
// Extract all additional fields from the response for debugging
const { result, message, error, errorcode, ...additionalFields } = parsedResponse;
this.logger.error(`WHMCS API returned error [${action}]`, { this.logger.error(`WHMCS API returned error [${action}]`, {
errorMessage, errorMessage,
@ -291,9 +281,10 @@ export class WhmcsHttpClientService {
}); });
// Include full context in the error for better debugging // Include full context in the error for better debugging
const errorContext = Object.keys(additionalFields).length > 0 const errorContext =
Object.keys(additionalFields).length > 0
? ` | Additional details: ${JSON.stringify(additionalFields)}` ? ` | Additional details: ${JSON.stringify(additionalFields)}`
: ''; : "";
throw new Error(`WHMCS API Error: ${errorMessage} (${errorCode})${errorContext}`); throw new Error(`WHMCS API Error: ${errorMessage} (${errorCode})${errorContext}`);
} }

View File

@ -60,9 +60,7 @@ export class WhmcsOrderService {
// Call WHMCS AddOrder API // Call WHMCS AddOrder API
// Note: The HTTP client throws errors automatically if result === "error" // Note: The HTTP client throws errors automatically if result === "error"
// So we only get here if the request was successful // So we only get here if the request was successful
const response = (await this.connection.addOrder( const response = (await this.connection.addOrder(addOrderPayload)) as WhmcsAddOrderResponse;
addOrderPayload
)) as WhmcsAddOrderResponse;
// Log the full response for debugging // Log the full response for debugging
this.logger.debug("WHMCS AddOrder response", { this.logger.debug("WHMCS AddOrder response", {

View File

@ -1,8 +1,6 @@
export const PORTAL_STATUS_ACTIVE = "Active" as const; export const PORTAL_STATUS_ACTIVE = "Active" as const;
export const PORTAL_STATUS_NOT_YET = "Not Yet" as const; export const PORTAL_STATUS_NOT_YET = "Not Yet" as const;
export type PortalStatus = export type PortalStatus = typeof PORTAL_STATUS_ACTIVE | typeof PORTAL_STATUS_NOT_YET;
| typeof PORTAL_STATUS_ACTIVE
| typeof PORTAL_STATUS_NOT_YET;
export const PORTAL_SOURCE_NEW_SIGNUP = "New Signup" as const; export const PORTAL_SOURCE_NEW_SIGNUP = "New Signup" as const;
export const PORTAL_SOURCE_MIGRATED = "Migrated" as const; export const PORTAL_SOURCE_MIGRATED = "Migrated" as const;

View File

@ -506,7 +506,8 @@ export class SignupWorkflowService {
return unwrapped.value; return unwrapped.value;
} }
const resolved = await this.salesforceService.findAccountWithDetailsByCustomerNumber(normalized); const resolved =
await this.salesforceService.findAccountWithDetailsByCustomerNumber(normalized);
await this.cache.set( await this.cache.set(
cacheKey, cacheKey,
this.wrapAccountCacheEntry(resolved ?? null), this.wrapAccountCacheEntry(resolved ?? null),
@ -528,9 +529,10 @@ export class SignupWorkflowService {
return `${this.accountCachePrefix}${customerNumber}`; return `${this.accountCachePrefix}${customerNumber}`;
} }
private unwrapAccountCacheEntry( private unwrapAccountCacheEntry(cached: SignupAccountCacheEntry | null): {
cached: SignupAccountCacheEntry | null hit: boolean;
): { hit: boolean; value: SignupAccountSnapshot | null } { value: SignupAccountSnapshot | null;
} {
if (!cached) { if (!cached) {
return { hit: false, value: null }; return { hit: false, value: null };
} }
@ -542,9 +544,7 @@ export class SignupWorkflowService {
return { hit: true, value: (cached as unknown as SignupAccountSnapshot) ?? null }; return { hit: true, value: (cached as unknown as SignupAccountSnapshot) ?? null };
} }
private wrapAccountCacheEntry( private wrapAccountCacheEntry(snapshot: SignupAccountSnapshot | null): SignupAccountCacheEntry {
snapshot: SignupAccountSnapshot | null
): SignupAccountCacheEntry {
return { return {
value: snapshot ?? null, value: snapshot ?? null,
__signupCache: true, __signupCache: true,

View File

@ -107,7 +107,10 @@ const calculateCookieMaxAge = (isoTimestamp: string): number => {
@Controller("auth") @Controller("auth")
export class AuthController { export class AuthController {
constructor(private authFacade: AuthFacade, private readonly jwtService: JwtService) {} constructor(
private authFacade: AuthFacade,
private readonly jwtService: JwtService
) {}
private setAuthCookies(res: Response, tokens: AuthTokens): void { private setAuthCookies(res: Response, tokens: AuthTokens): void {
const accessMaxAge = calculateCookieMaxAge(tokens.expiresAt); const accessMaxAge = calculateCookieMaxAge(tokens.expiresAt);
@ -211,7 +214,7 @@ export class AuthController {
if (payload?.sub) { if (payload?.sub) {
userId = payload.sub; userId = payload.sub;
} }
} catch (error) { } catch {
// Ignore verification errors we still want to clear client cookies. // Ignore verification errors we still want to clear client cookies.
} }
} }

View File

@ -27,10 +27,7 @@ export class FailedLoginThrottleGuard {
} }
} }
static applyRateLimitHeaders( static applyRateLimitHeaders(request: RequestWithRateLimit, response: Response): void {
request: RequestWithRateLimit,
response: Response
): void {
const outcome = request.__authRateLimit; const outcome = request.__authRateLimit;
if (!outcome) return; if (!outcome) return;

View File

@ -16,7 +16,8 @@ import { IS_PUBLIC_KEY } from "../../../decorators/public.decorator";
import { getErrorMessage } from "@bff/core/utils/error.util"; import { getErrorMessage } from "@bff/core/utils/error.util";
type CookieValue = string | undefined; type CookieValue = string | undefined;
type RequestWithCookies = Omit<Request, "cookies"> & { type RequestBase = Omit<Request, "cookies" | "route">;
type RequestWithCookies = RequestBase & {
cookies?: Record<string, CookieValue>; cookies?: Record<string, CookieValue>;
}; };
type RequestWithRoute = RequestWithCookies & { type RequestWithRoute = RequestWithCookies & {

View File

@ -23,11 +23,6 @@ import { CatalogCacheService } from "./services/catalog-cache.service";
VpnCatalogService, VpnCatalogService,
CatalogCacheService, CatalogCacheService,
], ],
exports: [ exports: [InternetCatalogService, SimCatalogService, VpnCatalogService, CatalogCacheService],
InternetCatalogService,
SimCatalogService,
VpnCatalogService,
CatalogCacheService,
],
}) })
export class CatalogModule {} export class CatalogModule {}

View File

@ -41,7 +41,7 @@ export class BaseCatalogService {
): Promise<TRecord[]> { ): Promise<TRecord[]> {
try { try {
const res = (await this.sf.query(soql, { const res = (await this.sf.query(soql, {
label: `catalog:${context.replace(/\s+/g, "_" ).toLowerCase()}`, label: `catalog:${context.replace(/\s+/g, "_").toLowerCase()}`,
})) as SalesforceResponse<TRecord>; })) as SalesforceResponse<TRecord>;
return res.records ?? []; return res.records ?? [];
} catch (error: unknown) { } catch (error: unknown) {

View File

@ -214,11 +214,7 @@ export class CatalogCacheService {
if (ttlSeconds === null) { if (ttlSeconds === null) {
await this.cache.set(key, this.wrapCachedValue(valueToStore, dependencies)); await this.cache.set(key, this.wrapCachedValue(valueToStore, dependencies));
} else { } else {
await this.cache.set( await this.cache.set(key, this.wrapCachedValue(valueToStore, dependencies), ttlSeconds);
key,
this.wrapCachedValue(valueToStore, dependencies),
ttlSeconds
);
} }
if (dependencies) { if (dependencies) {
@ -372,5 +368,7 @@ export class CatalogCacheService {
export interface CatalogCacheOptions<T> { export interface CatalogCacheOptions<T> {
allowNull?: boolean; allowNull?: boolean;
resolveDependencies?: (value: T) => CacheDependencies | Promise<CacheDependencies | undefined> | undefined; resolveDependencies?: (
value: T
) => CacheDependencies | Promise<CacheDependencies | undefined> | undefined;
} }

View File

@ -101,9 +101,7 @@ export class InternetCatalogService extends BaseCatalogService {
}, },
{ {
resolveDependencies: installations => ({ resolveDependencies: installations => ({
productIds: installations productIds: installations.map(item => item.id).filter((id): id is string => Boolean(id)),
.map(item => item.id)
.filter((id): id is string => Boolean(id)),
}), }),
} }
); );

View File

@ -80,9 +80,7 @@ export class SimCatalogService extends BaseCatalogService {
}, },
{ {
resolveDependencies: products => ({ resolveDependencies: products => ({
productIds: products productIds: products.map(product => product.id).filter((id): id is string => Boolean(id)),
.map(product => product.id)
.filter((id): id is string => Boolean(id)),
}), }),
} }
); );
@ -119,9 +117,7 @@ export class SimCatalogService extends BaseCatalogService {
}, },
{ {
resolveDependencies: products => ({ resolveDependencies: products => ({
productIds: products productIds: products.map(product => product.id).filter((id): id is string => Boolean(id)),
.map(product => product.id)
.filter((id): id is string => Boolean(id)),
}), }),
} }
); );

View File

@ -64,11 +64,6 @@ import { OrderFieldConfigModule } from "./config/order-field-config.module";
ProvisioningQueueService, ProvisioningQueueService,
ProvisioningProcessor, ProvisioningProcessor,
], ],
exports: [ exports: [OrderOrchestrator, CheckoutService, ProvisioningQueueService, OrdersCacheService],
OrderOrchestrator,
CheckoutService,
ProvisioningQueueService,
OrdersCacheService,
],
}) })
export class OrdersModule {} export class OrdersModule {}

View File

@ -61,12 +61,7 @@ describe("CheckoutService - personalized carts", () => {
}, },
}); });
await service.buildCart( await service.buildCart(ORDER_TYPE.INTERNET, { planSku: "PLAN-1" }, undefined, "user-123");
ORDER_TYPE.INTERNET,
{ planSku: "PLAN-1" },
undefined,
"user-123"
);
expect(internetCatalogService.getPlansForUser).toHaveBeenCalledWith("user-123"); expect(internetCatalogService.getPlansForUser).toHaveBeenCalledWith("user-123");
expect(internetCatalogService.getPlans).not.toHaveBeenCalled(); expect(internetCatalogService.getPlans).not.toHaveBeenCalled();

View File

@ -96,7 +96,11 @@ export class OrderBuilder {
assignIfString(orderFields, fieldNames.mvnoAccountNumber, config.mvnoAccountNumber); assignIfString(orderFields, fieldNames.mvnoAccountNumber, config.mvnoAccountNumber);
assignIfString(orderFields, fieldNames.portingLastName, config.portingLastName); assignIfString(orderFields, fieldNames.portingLastName, config.portingLastName);
assignIfString(orderFields, fieldNames.portingFirstName, config.portingFirstName); assignIfString(orderFields, fieldNames.portingFirstName, config.portingFirstName);
assignIfString(orderFields, fieldNames.portingLastNameKatakana, config.portingLastNameKatakana); assignIfString(
orderFields,
fieldNames.portingLastNameKatakana,
config.portingLastNameKatakana
);
assignIfString( assignIfString(
orderFields, orderFields,
fieldNames.portingFirstNameKatakana, fieldNames.portingFirstNameKatakana,

View File

@ -270,10 +270,13 @@ export class OrderFulfillmentOrchestrator {
description: "Accept/provision order in WHMCS", description: "Accept/provision order in WHMCS",
execute: this.createTrackedStep(context, "whmcs_accept", async () => { execute: this.createTrackedStep(context, "whmcs_accept", async () => {
if (!whmcsCreateResult?.orderId) { if (!whmcsCreateResult?.orderId) {
throw new WhmcsOperationException("WHMCS order ID missing before acceptance step", { throw new WhmcsOperationException(
"WHMCS order ID missing before acceptance step",
{
sfOrderId, sfOrderId,
step: "whmcs_accept_order", step: "whmcs_accept_order",
}); }
);
} }
await this.whmcsOrderService.acceptOrder(whmcsCreateResult.orderId, sfOrderId); await this.whmcsOrderService.acceptOrder(whmcsCreateResult.orderId, sfOrderId);

View File

@ -64,12 +64,12 @@ describe("OrderOrchestrator.getOrderForUser", () => {
sfAccountId: expectedOrder.accountId, sfAccountId: expectedOrder.accountId,
whmcsClientId: 42, whmcsClientId: 42,
}); });
jest.spyOn(orchestrator, "getOrder").mockResolvedValue(expectedOrder); const getOrderSpy = jest.spyOn(orchestrator, "getOrder").mockResolvedValue(expectedOrder);
const result = await orchestrator.getOrderForUser(expectedOrder.id, "user-1"); const result = await orchestrator.getOrderForUser(expectedOrder.id, "user-1");
expect(result).toBe(expectedOrder); expect(result).toBe(expectedOrder);
expect(orchestrator.getOrder).toHaveBeenCalledWith(expectedOrder.id); expect(getOrderSpy).toHaveBeenCalledWith(expectedOrder.id);
}); });
it("throws NotFound when the user mapping lacks a Salesforce account", async () => { it("throws NotFound when the user mapping lacks a Salesforce account", async () => {

View File

@ -120,4 +120,3 @@ export class UsersFacade {
return sanitized; return sanitized;
} }
} }

View File

@ -63,4 +63,3 @@ export class UserAuthRepository {
} }
} }
} }

View File

@ -124,7 +124,10 @@ export class UserProfileService {
return this.getProfile(validId); return this.getProfile(validId);
} catch (error) { } catch (error) {
const msg = getErrorMessage(error); const msg = getErrorMessage(error);
this.logger.error({ userId: validId, error: msg }, "Failed to update customer profile in WHMCS"); this.logger.error(
{ userId: validId, error: msg },
"Failed to update customer profile in WHMCS"
);
if (msg.includes("WHMCS API Error")) { if (msg.includes("WHMCS API Error")) {
throw new BadRequestException(msg.replace("WHMCS API Error: ", "")); throw new BadRequestException(msg.replace("WHMCS API Error: ", ""));
@ -398,4 +401,3 @@ export class UserProfileService {
} }
} }
} }

View File

@ -16,10 +16,7 @@ const nextConfig = {
output: process.env.NODE_ENV === "production" ? "standalone" : undefined, output: process.env.NODE_ENV === "production" ? "standalone" : undefined,
// Ensure workspace packages are transpiled correctly // Ensure workspace packages are transpiled correctly
transpilePackages: [ transpilePackages: ["@customer-portal/domain", "@customer-portal/validation"],
"@customer-portal/domain",
"@customer-portal/validation",
],
// Tell Next to NOT bundle these server-only libs // Tell Next to NOT bundle these server-only libs
serverExternalPackages: [ serverExternalPackages: [

View File

@ -22,7 +22,7 @@ export function usePaymentRefresh({
hasMethods, hasMethods,
}: UsePaymentRefreshOptions) { }: UsePaymentRefreshOptions) {
const { isAuthenticated } = useAuthSession(); const { isAuthenticated } = useAuthSession();
const hideToastTimeout = useRef<ReturnType<typeof setTimeout> | null>(null); const hideToastTimeout = useRef<number | null>(null);
const [toast, setToast] = useState<{ visible: boolean; text: string; tone: Tone }>({ const [toast, setToast] = useState<{ visible: boolean; text: string; tone: Tone }>({
visible: false, visible: false,
text: "", text: "",

View File

@ -13,10 +13,7 @@ import {
deriveOrderStatusDescriptor, deriveOrderStatusDescriptor,
getServiceCategory, getServiceCategory,
} from "@/features/orders/utils/order-presenters"; } from "@/features/orders/utils/order-presenters";
import { import { buildOrderDisplayItems } from "@/features/orders/utils/order-display";
buildOrderDisplayItems,
summarizeOrderDisplayItems,
} from "@/features/orders/utils/order-display";
import type { OrderSummary } from "@customer-portal/domain/orders"; import type { OrderSummary } from "@customer-portal/domain/orders";
import { cn } from "@/lib/utils/cn"; import { cn } from "@/lib/utils/cn";
@ -131,7 +128,9 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
<StatusPill label={statusDescriptor.label} variant={statusVariant} /> <StatusPill label={statusDescriptor.label} variant={statusVariant} />
</div> </div>
<div className="mt-1 flex items-center gap-2 text-xs text-gray-500"> <div className="mt-1 flex items-center gap-2 text-xs text-gray-500">
<span className="font-medium">#{order.orderNumber || String(order.id).slice(-8)}</span> <span className="font-medium">
#{order.orderNumber || String(order.id).slice(-8)}
</span>
<span></span> <span></span>
<span>{formattedCreatedDate || "—"}</span> <span>{formattedCreatedDate || "—"}</span>
</div> </div>
@ -155,7 +154,9 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
<div className="flex items-start gap-4 flex-shrink-0"> <div className="flex items-start gap-4 flex-shrink-0">
{totals.monthlyTotal > 0 && ( {totals.monthlyTotal > 0 && (
<div className="text-right"> <div className="text-right">
<p className="text-[10px] font-medium uppercase tracking-wider text-blue-600">Monthly</p> <p className="text-[10px] font-medium uppercase tracking-wider text-blue-600">
Monthly
</p>
<p className="text-xl font-bold text-gray-900"> <p className="text-xl font-bold text-gray-900">
¥{totals.monthlyTotal.toLocaleString()} ¥{totals.monthlyTotal.toLocaleString()}
</p> </p>
@ -163,7 +164,9 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
)} )}
{totals.oneTimeTotal > 0 && ( {totals.oneTimeTotal > 0 && (
<div className="text-right"> <div className="text-right">
<p className="text-[10px] font-medium uppercase tracking-wider text-blue-600">One-Time</p> <p className="text-[10px] font-medium uppercase tracking-wider text-blue-600">
One-Time
</p>
<p className="text-lg font-bold text-gray-900"> <p className="text-lg font-bold text-gray-900">
¥{totals.oneTimeTotal.toLocaleString()} ¥{totals.oneTimeTotal.toLocaleString()}
</p> </p>
@ -173,6 +176,7 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
)} )}
</div> </div>
</div> </div>
{footer && <div className="border-t border-slate-100 bg-slate-50 px-6 py-3">{footer}</div>}
</article> </article>
); );
} }

View File

@ -39,4 +39,3 @@ export function OrderCardSkeleton() {
} }
export default OrderCardSkeleton; export default OrderCardSkeleton;

View File

@ -31,7 +31,10 @@ type GetOrderByIdOptions = {
signal?: AbortSignal; signal?: AbortSignal;
}; };
async function getOrderById(orderId: string, options: GetOrderByIdOptions = {}): Promise<OrderDetails> { async function getOrderById(
orderId: string,
options: GetOrderByIdOptions = {}
): Promise<OrderDetails> {
const response = await apiClient.GET("/api/orders/{sfOrderId}", { const response = await apiClient.GET("/api/orders/{sfOrderId}", {
params: { path: { sfOrderId: orderId } }, params: { path: { sfOrderId: orderId } },
signal: options.signal, signal: options.signal,

View File

@ -1,7 +1,12 @@
import type { OrderItemSummary } from "@customer-portal/domain/orders"; import type { OrderItemSummary } from "@customer-portal/domain/orders";
import { normalizeBillingCycle } from "@/features/orders/utils/order-presenters"; import { normalizeBillingCycle } from "@/features/orders/utils/order-presenters";
export type OrderDisplayItemCategory = "service" | "installation" | "addon" | "activation" | "other"; export type OrderDisplayItemCategory =
| "service"
| "installation"
| "addon"
| "activation"
| "other";
export type OrderDisplayItemChargeKind = "monthly" | "one-time" | "other"; export type OrderDisplayItemChargeKind = "monthly" | "one-time" | "other";
@ -25,19 +30,6 @@ export interface OrderDisplayItem {
isBundle: boolean; isBundle: boolean;
} }
interface OrderItemGroup {
indices: number[];
items: OrderItemSummary[];
}
const CATEGORY_ORDER: OrderDisplayItemCategory[] = [
"service",
"installation",
"addon",
"activation",
"other",
];
const CHARGE_ORDER: Record<OrderDisplayItemChargeKind, number> = { const CHARGE_ORDER: Record<OrderDisplayItemChargeKind, number> = {
monthly: 0, monthly: 0,
"one-time": 1, "one-time": 1,
@ -46,8 +38,7 @@ const CHARGE_ORDER: Record<OrderDisplayItemChargeKind, number> = {
const MONTHLY_SUFFIX = "/ month"; const MONTHLY_SUFFIX = "/ month";
const normalizeItemClass = (itemClass?: string | null): string => const normalizeItemClass = (itemClass?: string | null): string => (itemClass ?? "").toLowerCase();
(itemClass ?? "").toLowerCase();
const resolveCategory = (item: OrderItemSummary): OrderDisplayItemCategory => { const resolveCategory = (item: OrderItemSummary): OrderDisplayItemCategory => {
const normalizedClass = normalizeItemClass(item.itemClass); const normalizedClass = normalizeItemClass(item.itemClass);
@ -65,20 +56,13 @@ const coerceNumber = (value: unknown): number => {
return 0; return 0;
}; };
const buildOrderItemId = (group: OrderItemGroup, fallbackIndex: number): string => { const aggregateCharges = (items: OrderItemSummary[]): OrderDisplayItemCharge[] => {
const identifiers = group.items const accumulator = new Map<
.map(item => item.productId || item.sku || item.name) string,
.filter((id): id is string => typeof id === "string" && id.length > 0); OrderDisplayItemCharge & { kind: OrderDisplayItemChargeKind }
if (identifiers.length === 0) { >();
return `order-item-${fallbackIndex}`;
}
return identifiers.join("|");
};
const aggregateCharges = (group: OrderItemGroup): OrderDisplayItemCharge[] => { for (const item of items) {
const accumulator = new Map<string, OrderDisplayItemCharge & { kind: OrderDisplayItemChargeKind }>();
for (const item of group.items) {
const amount = coerceNumber(item.totalPrice ?? item.unitPrice); const amount = coerceNumber(item.totalPrice ?? item.unitPrice);
const normalizedCycle = normalizeBillingCycle(item.billingCycle ?? undefined); const normalizedCycle = normalizeBillingCycle(item.billingCycle ?? undefined);
@ -117,81 +101,9 @@ const aggregateCharges = (group: OrderItemGroup): OrderDisplayItemCharge[] => {
}); });
}; };
const buildGroupName = (group: OrderItemGroup): string => {
// For bundles, combine the names
if (group.items.length > 1) {
return group.items
.map(item => item.productName || item.name || "Item")
.join(" + ");
}
const fallbackItem = group.items[0];
return fallbackItem?.productName || fallbackItem?.name || "Service item";
};
const determinePrimaryCategory = (group: OrderItemGroup): OrderDisplayItemCategory => {
const categories = group.items.map(resolveCategory);
for (const preferred of CATEGORY_ORDER) {
if (categories.includes(preferred)) {
return preferred;
}
}
return "other";
};
const collectCategories = (group: OrderItemGroup): OrderDisplayItemCategory[] => {
const unique = new Set<OrderDisplayItemCategory>();
group.items.forEach(item => unique.add(resolveCategory(item)));
return Array.from(unique);
};
const isIncludedGroup = (charges: OrderDisplayItemCharge[]): boolean => const isIncludedGroup = (charges: OrderDisplayItemCharge[]): boolean =>
charges.every(charge => charge.amount <= 0); charges.every(charge => charge.amount <= 0);
const buildOrderItemGroup = (items: OrderItemSummary[]): OrderItemGroup[] => {
const groups: OrderItemGroup[] = [];
const usedIndices = new Set<number>();
const productIndex = new Map<string, number[]>();
items.forEach((item, index) => {
const key = item.productId || item.sku;
if (!key) return;
const existing = productIndex.get(key);
if (existing) {
existing.push(index);
} else {
productIndex.set(key, [index]);
}
});
items.forEach((item, index) => {
if (usedIndices.has(index)) {
return;
}
if (item.isBundledAddon && item.bundledAddonId) {
const partnerCandidates = productIndex.get(item.bundledAddonId) ?? [];
const partnerIndex = partnerCandidates.find(candidate => candidate !== index && !usedIndices.has(candidate));
if (typeof partnerIndex === "number") {
const partner = items[partnerIndex];
if (partner) {
const orderedIndices = partnerIndex < index ? [partnerIndex, index] : [index, partnerIndex];
const groupItems = orderedIndices.map(i => items[i]);
groups.push({ indices: orderedIndices, items: groupItems });
usedIndices.add(index);
usedIndices.add(partnerIndex);
return;
}
}
}
groups.push({ indices: [index], items: [item] });
usedIndices.add(index);
});
return groups.sort((a, b) => a.indices[0] - b.indices[0]);
};
export function buildOrderDisplayItems( export function buildOrderDisplayItems(
items: OrderItemSummary[] | null | undefined items: OrderItemSummary[] | null | undefined
): OrderDisplayItem[] { ): OrderDisplayItem[] {
@ -201,7 +113,7 @@ export function buildOrderDisplayItems(
// Map items to display format - keep the order from the backend // Map items to display format - keep the order from the backend
return items.map((item, index) => { return items.map((item, index) => {
const charges = aggregateCharges({ indices: [index], items: [item] }); const charges = aggregateCharges([item]);
const isBundled = Boolean(item.isBundledAddon); const isBundled = Boolean(item.isBundledAddon);
return { return {
@ -219,10 +131,7 @@ export function buildOrderDisplayItems(
}); });
} }
export function summarizeOrderDisplayItems( export function summarizeOrderDisplayItems(items: OrderDisplayItem[], fallback: string): string {
items: OrderDisplayItem[],
fallback: string
): string {
if (items.length === 0) { if (items.length === 0) {
return fallback; return fallback;
} }
@ -234,5 +143,3 @@ export function summarizeOrderDisplayItems(
return `${primary.name} +${rest.length} more`; return `${primary.name} +${rest.length} more`;
} }

View File

@ -22,7 +22,10 @@ import {
import { StatusPill } from "@/components/atoms/status-pill"; import { StatusPill } from "@/components/atoms/status-pill";
import { Button } from "@/components/atoms/button"; import { Button } from "@/components/atoms/button";
import { ordersService } from "@/features/orders/services/orders.service"; import { ordersService } from "@/features/orders/services/orders.service";
import { useOrderUpdates, type OrderUpdateEventPayload } from "@/features/orders/hooks/useOrderUpdates"; import {
useOrderUpdates,
type OrderUpdateEventPayload,
} from "@/features/orders/hooks/useOrderUpdates";
import { import {
calculateOrderTotals, calculateOrderTotals,
deriveOrderStatusDescriptor, deriveOrderStatusDescriptor,
@ -209,8 +212,8 @@ export function OrderDetailContainer() {
} }
}, [data?.orderType, serviceCategory]); }, [data?.orderType, serviceCategory]);
const showFeeNotice = displayItems.some(item => const showFeeNotice = displayItems.some(
item.categories.includes("installation") || item.categories.includes("activation") item => item.categories.includes("installation") || item.categories.includes("activation")
); );
const fetchOrder = useCallback(async (): Promise<void> => { const fetchOrder = useCallback(async (): Promise<void> => {
@ -325,21 +328,28 @@ export function OrderDetailContainer() {
{/* Left: Title & Date */} {/* Left: Title & Date */}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-slate-100 text-slate-600">
{serviceIcon}
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold text-gray-900">{serviceLabel}</h2> <h2 className="text-2xl font-bold text-gray-900">{serviceLabel}</h2>
{statusDescriptor && ( {statusDescriptor && (
<StatusPill label={statusDescriptor.label} variant={statusPillVariant} /> <StatusPill label={statusDescriptor.label} variant={statusPillVariant} />
)} )}
</div> </div>
{placedDate && ( {placedDate && <p className="text-sm text-gray-500">{placedDate}</p>}
<p className="text-sm text-gray-500">{placedDate}</p> </div>
)} </div>
</div> </div>
{/* Right: Pricing Section */} {/* Right: Pricing Section */}
<div className="flex items-start gap-6 sm:gap-8"> <div className="flex items-start gap-6 sm:gap-8">
{totals.monthlyTotal > 0 && ( {totals.monthlyTotal > 0 && (
<div className="text-right"> <div className="text-right">
<p className="mb-1 text-xs font-medium uppercase tracking-wider text-blue-600">Monthly</p> <p className="mb-1 text-xs font-medium uppercase tracking-wider text-blue-600">
Monthly
</p>
<p className="text-3xl font-bold text-gray-900"> <p className="text-3xl font-bold text-gray-900">
{yenFormatter.format(totals.monthlyTotal)} {yenFormatter.format(totals.monthlyTotal)}
</p> </p>
@ -347,7 +357,9 @@ export function OrderDetailContainer() {
)} )}
{totals.oneTimeTotal > 0 && ( {totals.oneTimeTotal > 0 && (
<div className="text-right"> <div className="text-right">
<p className="mb-1 text-xs font-medium uppercase tracking-wider text-blue-600">One-Time</p> <p className="mb-1 text-xs font-medium uppercase tracking-wider text-blue-600">
One-Time
</p>
<p className="text-3xl font-bold text-gray-900"> <p className="text-3xl font-bold text-gray-900">
{yenFormatter.format(totals.oneTimeTotal)} {yenFormatter.format(totals.oneTimeTotal)}
</p> </p>
@ -361,7 +373,12 @@ export function OrderDetailContainer() {
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Order Items Section */} {/* Order Items Section */}
<div> <div>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-widest text-gray-700" style={{ letterSpacing: '0.1em' }}>Order Details</h3> <h3
className="mb-2 text-xs font-semibold uppercase tracking-widest text-gray-700"
style={{ letterSpacing: "0.1em" }}
>
Order Details
</h3>
<div className="space-y-4"> <div className="space-y-4">
{displayItems.length === 0 ? ( {displayItems.length === 0 ? (
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-4 py-8 text-center text-sm text-gray-500"> <div className="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-4 py-8 text-center text-sm text-gray-500">
@ -369,7 +386,8 @@ export function OrderDetailContainer() {
</div> </div>
) : ( ) : (
displayItems.map((item, itemIndex) => { displayItems.map((item, itemIndex) => {
const categoryConfig = CATEGORY_CONFIG[item.primaryCategory] ?? CATEGORY_CONFIG.other; const categoryConfig =
CATEGORY_CONFIG[item.primaryCategory] ?? CATEGORY_CONFIG.other;
const Icon = categoryConfig.icon; const Icon = categoryConfig.icon;
const style = getItemVisualStyle(item); const style = getItemVisualStyle(item);
@ -384,10 +402,12 @@ export function OrderDetailContainer() {
> >
{/* Icon + Title & Category | Price */} {/* Icon + Title & Category | Price */}
<div className="flex flex-1 items-start gap-3"> <div className="flex flex-1 items-start gap-3">
<div className={cn( <div
className={cn(
"flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-lg", "flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-lg",
style.icon style.icon
)}> )}
>
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
</div> </div>
<div className="flex flex-1 items-baseline justify-between gap-4 sm:items-center"> <div className="flex flex-1 items-baseline justify-between gap-4 sm:items-center">
@ -405,11 +425,16 @@ export function OrderDetailContainer() {
const descriptor = describeCharge(charge); const descriptor = describeCharge(charge);
if (charge.amount > 0) { if (charge.amount > 0) {
return ( return (
<div key={`${item.id}-charge-${index}`} className="whitespace-nowrap text-lg"> <div
key={`${item.id}-charge-${index}`}
className="whitespace-nowrap text-lg"
>
<span className="font-bold text-gray-900"> <span className="font-bold text-gray-900">
{yenFormatter.format(charge.amount)} {yenFormatter.format(charge.amount)}
</span> </span>
<span className="ml-2 text-xs font-medium text-gray-500">{descriptor}</span> <span className="ml-2 text-xs font-medium text-gray-500">
{descriptor}
</span>
</div> </div>
); );
} }
@ -441,9 +466,7 @@ export function OrderDetailContainer() {
<ClockIcon className="h-5 w-5 flex-shrink-0 text-blue-600" /> <ClockIcon className="h-5 w-5 flex-shrink-0 text-blue-600" />
<div> <div>
<p className="text-sm font-semibold text-blue-900">Next Steps</p> <p className="text-sm font-semibold text-blue-900">Next Steps</p>
<p className="mt-1 text-sm text-blue-800"> <p className="mt-1 text-sm text-blue-800">{statusDescriptor.nextAction}</p>
{statusDescriptor.nextAction}
</p>
</div> </div>
</div> </div>
</div> </div>
@ -454,9 +477,14 @@ export function OrderDetailContainer() {
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-amber-600" /> <ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-amber-600" />
<div> <div>
<p className="text-sm font-semibold text-amber-900">Installation Fee Notice</p> <p className="text-sm font-semibold text-amber-900">
Installation Fee Notice
</p>
<p className="mt-1 text-sm text-amber-800"> <p className="mt-1 text-sm text-amber-800">
Standard installation is included. Additional charges may apply for weekend scheduling, express service, or specialized equipment installation. Any extra fees will be discussed and approved by you before work begins. Standard installation is included. Additional charges may apply for
weekend scheduling, express service, or specialized equipment
installation. Any extra fees will be discussed and approved by you before
work begins.
</p> </p>
</div> </div>
</div> </div>
@ -476,4 +504,3 @@ export function OrderDetailContainer() {
} }
export default OrderDetailContainer; export default OrderDetailContainer;

View File

@ -41,7 +41,7 @@ export function SimFeatureToggles({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
const successTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const successTimerRef = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
setVm(initial.vm); setVm(initial.vm);

View File

@ -76,11 +76,10 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
setError(err instanceof Error ? err.message : "Failed to load SIM information"); setError(err instanceof Error ? err.message : "Failed to load SIM information");
} finally { } finally {
if (controller.signal.aborted || !isMountedRef.current) { if (!controller.signal.aborted && isMountedRef.current) {
return;
}
setLoading(false); setLoading(false);
} }
}
}, [subscriptionId]); }, [subscriptionId]);
useEffect(() => { useEffect(() => {

View File

@ -28,9 +28,11 @@ async function handleApiError(response: Response): Promise<void> {
// Dispatch a custom event that the auth system will listen to // Dispatch a custom event that the auth system will listen to
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:unauthorized", { window.dispatchEvent(
detail: { url: response.url, status: response.status } new CustomEvent("auth:unauthorized", {
})); detail: { url: response.url, status: response.status },
})
);
} }
} }

View File

@ -6,12 +6,7 @@ import { currencyService } from "@/lib/services/currency.service";
import { FALLBACK_CURRENCY, type WhmcsCurrency } from "@customer-portal/domain/billing"; import { FALLBACK_CURRENCY, type WhmcsCurrency } from "@customer-portal/domain/billing";
export function useCurrency() { export function useCurrency() {
const { const { data, isLoading, isError, error } = useQuery<WhmcsCurrency>({
data,
isLoading,
isError,
error,
} = useQuery<WhmcsCurrency>({
queryKey: queryKeys.currency.default(), queryKey: queryKeys.currency.default(),
queryFn: () => currencyService.getDefaultCurrency(), queryFn: () => currencyService.getDefaultCurrency(),
staleTime: 60 * 60 * 1000, // cache currency for 1 hour staleTime: 60 * 60 * 1000, // cache currency for 1 hour

View File

@ -78,7 +78,6 @@ export const logger = new Logger();
export const log = { export const log = {
info: (message: string, meta?: LogMeta) => logger.info(message, meta), info: (message: string, meta?: LogMeta) => logger.info(message, meta),
warn: (message: string, meta?: LogMeta) => logger.warn(message, meta), warn: (message: string, meta?: LogMeta) => logger.warn(message, meta),
error: (message: string, error?: unknown, meta?: LogMeta) => error: (message: string, error?: unknown, meta?: LogMeta) => logger.error(message, error, meta),
logger.error(message, error, meta),
debug: (message: string, meta?: LogMeta) => logger.debug(message, meta), debug: (message: string, meta?: LogMeta) => logger.debug(message, meta),
}; };

View File

@ -1,3 +0,0 @@
export { ZodValidationPipe, createZodDto, ZodValidationException } from "nestjs-zod";
export { ZodValidationExceptionFilter } from "./zod-exception.filter";
//# sourceMappingURL=index.d.ts.map

View File

@ -1 +0,0 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,YAAY,EAAE,sBAAsB,EAAE,MAAM,YAAY,CAAC;AACrF,OAAO,EAAE,4BAA4B,EAAE,MAAM,wBAAwB,CAAC"}

View File

@ -1,10 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ZodValidationExceptionFilter = exports.ZodValidationException = exports.createZodDto = exports.ZodValidationPipe = void 0;
var nestjs_zod_1 = require("nestjs-zod");
Object.defineProperty(exports, "ZodValidationPipe", { enumerable: true, get: function () { return nestjs_zod_1.ZodValidationPipe; } });
Object.defineProperty(exports, "createZodDto", { enumerable: true, get: function () { return nestjs_zod_1.createZodDto; } });
Object.defineProperty(exports, "ZodValidationException", { enumerable: true, get: function () { return nestjs_zod_1.ZodValidationException; } });
var zod_exception_filter_1 = require("./zod-exception.filter");
Object.defineProperty(exports, "ZodValidationExceptionFilter", { enumerable: true, get: function () { return zod_exception_filter_1.ZodValidationExceptionFilter; } });
//# sourceMappingURL=index.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;AAAA,yCAAqF;AAA5E,+GAAA,iBAAiB,OAAA;AAAE,0GAAA,YAAY,OAAA;AAAE,oHAAA,sBAAsB,OAAA;AAChE,+DAAsE;AAA7D,oIAAA,4BAA4B,OAAA"}

View File

@ -1,11 +0,0 @@
import { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { ZodValidationException } from "nestjs-zod";
export declare class ZodValidationExceptionFilter implements ExceptionFilter {
private readonly logger;
constructor(logger: Logger);
catch(exception: ZodValidationException, host: ArgumentsHost): void;
private isZodError;
private mapIssues;
}
//# sourceMappingURL=zod-exception.filter.d.ts.map

View File

@ -1 +0,0 @@
{"version":3,"file":"zod-exception.filter.d.ts","sourceRoot":"","sources":["zod-exception.filter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAS,eAAe,EAAsB,MAAM,gBAAgB,CAAC;AAE3F,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AACrC,OAAO,EAAE,sBAAsB,EAAE,MAAM,YAAY,CAAC;AASpD,qBACa,4BAA6B,YAAW,eAAe;IACtC,OAAO,CAAC,QAAQ,CAAC,MAAM;gBAAN,MAAM,EAAE,MAAM;IAE3D,KAAK,CAAC,SAAS,EAAE,sBAAsB,EAAE,IAAI,EAAE,aAAa,GAAG,IAAI;IAsCnE,OAAO,CAAC,UAAU;IAMlB,OAAO,CAAC,SAAS;CAOlB"}

View File

@ -1,75 +0,0 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ZodValidationExceptionFilter = void 0;
const common_1 = require("@nestjs/common");
const nestjs_pino_1 = require("nestjs-pino");
const nestjs_zod_1 = require("nestjs-zod");
let ZodValidationExceptionFilter = class ZodValidationExceptionFilter {
logger;
constructor(logger) {
this.logger = logger;
}
catch(exception, host) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
const rawZodError = exception.getZodError();
let issues = [];
if (!this.isZodError(rawZodError)) {
this.logger.error("ZodValidationException did not contain a ZodError", {
path: request.url,
method: request.method,
providedType: typeof rawZodError,
});
}
else {
issues = this.mapIssues(rawZodError.issues);
}
this.logger.warn("Request validation failed", {
path: request.url,
method: request.method,
issues,
});
response.status(common_1.HttpStatus.BAD_REQUEST).json({
success: false,
error: {
code: "VALIDATION_FAILED",
message: "Request validation failed",
details: {
issues,
timestamp: new Date().toISOString(),
path: request.url,
},
},
});
}
isZodError(error) {
return Boolean(error && typeof error === "object" && Array.isArray(error.issues));
}
mapIssues(issues) {
return issues.map(issue => ({
path: issue.path.join(".") || "root",
message: issue.message,
code: issue.code,
}));
}
};
exports.ZodValidationExceptionFilter = ZodValidationExceptionFilter;
exports.ZodValidationExceptionFilter = ZodValidationExceptionFilter = __decorate([
(0, common_1.Catch)(nestjs_zod_1.ZodValidationException),
__param(0, (0, common_1.Inject)(nestjs_pino_1.Logger)),
__metadata("design:paramtypes", [nestjs_pino_1.Logger])
], ZodValidationExceptionFilter);
//# sourceMappingURL=zod-exception.filter.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"zod-exception.filter.js","sourceRoot":"","sources":["zod-exception.filter.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,2CAA2F;AAE3F,6CAAqC;AACrC,2CAAoD;AAU7C,IAAM,4BAA4B,GAAlC,MAAM,4BAA4B;IACM;IAA7C,YAA6C,MAAc;QAAd,WAAM,GAAN,MAAM,CAAQ;IAAG,CAAC;IAE/D,KAAK,CAAC,SAAiC,EAAE,IAAmB;QAC1D,MAAM,GAAG,GAAG,IAAI,CAAC,YAAY,EAAE,CAAC;QAChC,MAAM,QAAQ,GAAG,GAAG,CAAC,WAAW,EAAY,CAAC;QAC7C,MAAM,OAAO,GAAG,GAAG,CAAC,UAAU,EAAW,CAAC;QAE1C,MAAM,WAAW,GAAG,SAAS,CAAC,WAAW,EAAE,CAAC;QAC5C,IAAI,MAAM,GAAuB,EAAE,CAAC;QAEpC,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,WAAW,CAAC,EAAE,CAAC;YAClC,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,mDAAmD,EAAE;gBACrE,IAAI,EAAE,OAAO,CAAC,GAAG;gBACjB,MAAM,EAAE,OAAO,CAAC,MAAM;gBACtB,YAAY,EAAE,OAAO,WAAW;aACjC,CAAC,CAAC;QACL,CAAC;aAAM,CAAC;YACN,MAAM,GAAG,IAAI,CAAC,SAAS,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;QAC9C,CAAC;QAED,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,2BAA2B,EAAE;YAC5C,IAAI,EAAE,OAAO,CAAC,GAAG;YACjB,MAAM,EAAE,OAAO,CAAC,MAAM;YACtB,MAAM;SACP,CAAC,CAAC;QAEH,QAAQ,CAAC,MAAM,CAAC,mBAAU,CAAC,WAAW,CAAC,CAAC,IAAI,CAAC;YAC3C,OAAO,EAAE,KAAc;YACvB,KAAK,EAAE;gBACL,IAAI,EAAE,mBAAmB;gBACzB,OAAO,EAAE,2BAA2B;gBACpC,OAAO,EAAE;oBACP,MAAM;oBACN,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;oBACnC,IAAI,EAAE,OAAO,CAAC,GAAG;iBAClB;aACF;SACF,CAAC,CAAC;IACL,CAAC;IAEO,UAAU,CAAC,KAAc;QAC/B,OAAO,OAAO,CACZ,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAE,KAA8B,CAAC,MAAM,CAAC,CAC5F,CAAC;IACJ,CAAC;IAEO,SAAS,CAAC,MAAkB;QAClC,OAAO,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;YAC1B,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,MAAM;YACpC,OAAO,EAAE,KAAK,CAAC,OAAO;YACtB,IAAI,EAAE,KAAK,CAAC,IAAI;SACjB,CAAC,CAAC,CAAC;IACN,CAAC;CACF,CAAA;AAtDY,oEAA4B;uCAA5B,4BAA4B;IADxC,IAAA,cAAK,EAAC,mCAAsB,CAAC;IAEf,WAAA,IAAA,eAAM,EAAC,oBAAM,CAAC,CAAA;qCAA0B,oBAAM;GADhD,4BAA4B,CAsDxC"}