Refactor Salesforce request handling and improve logging
- Moved metrics tracking and logging from the queueing phase to the execution phase in SalesforceRequestQueueService for better accuracy. - Updated CSRF token generation in CsrfController to accept parameters in a more flexible manner. - Enhanced CacheService to handle immediate expiry requests without leaking stale values. - Improved error handling and re-authentication logic in SalesforceConnection for better resilience during session expiration. - Refactored logout functionality in AuthFacade to handle optional userId and improve logging during token revocation. - Updated AuthController to apply rate limit headers and improved type handling in various request contexts. - Streamlined imports and improved overall code organization across multiple modules for better maintainability.
This commit is contained in:
parent
5d011c87be
commit
d6f7c50e7b
@ -207,22 +207,22 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest
|
||||
const isLongRunning = options.isLongRunning || false;
|
||||
const queue = isLongRunning ? longRunningQueue : standardQueue;
|
||||
|
||||
this.metrics.totalRequests++;
|
||||
this.metrics.dailyApiUsage++;
|
||||
this.updateQueueMetrics();
|
||||
|
||||
this.logger.debug("Queueing Salesforce request", {
|
||||
requestId,
|
||||
isLongRunning,
|
||||
queueSize: queue.size,
|
||||
pending: queue.pending,
|
||||
priority: options.priority || 0,
|
||||
dailyUsage: this.metrics.dailyApiUsage,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = (await queue.add(
|
||||
async () => {
|
||||
this.metrics.totalRequests++;
|
||||
this.metrics.dailyApiUsage++;
|
||||
this.updateQueueMetrics();
|
||||
|
||||
this.logger.debug("Executing Salesforce request", {
|
||||
requestId,
|
||||
isLongRunning,
|
||||
queueSize: queue.size,
|
||||
pending: queue.pending,
|
||||
priority: options.priority || 0,
|
||||
dailyUsage: this.metrics.dailyApiUsage,
|
||||
});
|
||||
|
||||
const waitTime = Date.now() - startTime;
|
||||
this.recordWaitTime(waitTime);
|
||||
|
||||
@ -277,10 +277,6 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest
|
||||
)) as T;
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.metrics.failedRequests++;
|
||||
this.metrics.lastErrorTime = new Date();
|
||||
throw error;
|
||||
} finally {
|
||||
this.updateQueueMetrics();
|
||||
}
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { CsrfController } from "./csrf.controller";
|
||||
import type { CsrfService, CsrfTokenData } from "../services/csrf.service";
|
||||
|
||||
const createMockResponse = () => {
|
||||
const cookie = jest.fn();
|
||||
const json = jest.fn();
|
||||
return {
|
||||
cookie,
|
||||
json,
|
||||
} as unknown as Response;
|
||||
};
|
||||
|
||||
describe("CsrfController", () => {
|
||||
const csrfToken: CsrfTokenData = {
|
||||
token: "token-value",
|
||||
secret: "secret-value",
|
||||
expiresAt: new Date(),
|
||||
};
|
||||
|
||||
const makeController = (overrides: Partial<CsrfService> = {}) => {
|
||||
const csrfService: Partial<CsrfService> = {
|
||||
generateToken: jest.fn().mockReturnValue(csrfToken),
|
||||
invalidateUserTokens: jest.fn(),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
const logger = { debug: jest.fn() } as unknown as Logger;
|
||||
return {
|
||||
controller: new CsrfController(csrfService as CsrfService, logger),
|
||||
csrfService,
|
||||
};
|
||||
};
|
||||
|
||||
it("passes session and user identifiers to generateToken in the correct argument order", () => {
|
||||
const { controller, csrfService } = makeController();
|
||||
const res = createMockResponse();
|
||||
|
||||
const req = {
|
||||
user: { id: "user-123", sessionId: "session-456" },
|
||||
cookies: {},
|
||||
get: jest.fn(),
|
||||
ip: "127.0.0.1",
|
||||
} as unknown as Request;
|
||||
|
||||
controller.getCsrfToken(req, res);
|
||||
|
||||
expect(csrfService.generateToken).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
"session-456",
|
||||
"user-123"
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults to anonymous user during refresh and still forwards identifiers correctly", () => {
|
||||
const { controller, csrfService } = makeController();
|
||||
const res = createMockResponse();
|
||||
|
||||
const req = {
|
||||
cookies: { "connect.sid": "cookie-session" },
|
||||
get: jest.fn(),
|
||||
ip: "127.0.0.1",
|
||||
} as unknown as Request;
|
||||
|
||||
controller.refreshCsrfToken(req, res);
|
||||
|
||||
expect(csrfService.generateToken).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
"cookie-session",
|
||||
"anonymous"
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -23,7 +23,7 @@ export class CsrfController {
|
||||
const userId = req.user?.id;
|
||||
|
||||
// Generate new CSRF token
|
||||
const tokenData = this.csrfService.generateToken(sessionId, userId);
|
||||
const tokenData = this.csrfService.generateToken(undefined, sessionId, userId);
|
||||
|
||||
// Set CSRF secret in secure cookie
|
||||
res.cookie("csrf-secret", tokenData.secret, {
|
||||
@ -58,7 +58,7 @@ export class CsrfController {
|
||||
this.csrfService.invalidateUserTokens(userId);
|
||||
|
||||
// Generate new CSRF token
|
||||
const tokenData = this.csrfService.generateToken(sessionId, userId);
|
||||
const tokenData = this.csrfService.generateToken(undefined, sessionId, userId);
|
||||
|
||||
// Set CSRF secret in secure cookie
|
||||
res.cookie("csrf-secret", tokenData.secret, {
|
||||
|
||||
15
apps/bff/src/infra/cache/cache.service.ts
vendored
15
apps/bff/src/infra/cache/cache.service.ts
vendored
@ -26,11 +26,18 @@ export class CacheService {
|
||||
|
||||
async set(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
|
||||
const serialized = JSON.stringify(value);
|
||||
if (ttlSeconds) {
|
||||
await this.redis.setex(key, ttlSeconds, serialized);
|
||||
} else {
|
||||
await this.redis.set(key, serialized);
|
||||
if (ttlSeconds !== undefined) {
|
||||
const ttl = Math.max(0, Math.floor(ttlSeconds));
|
||||
if (ttl > 0) {
|
||||
await this.redis.set(key, serialized, "EX", ttl);
|
||||
} else {
|
||||
// Allow callers to request immediate expiry without leaking stale values
|
||||
await this.redis.set(key, serialized, "PX", 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await this.redis.set(key, serialized);
|
||||
}
|
||||
|
||||
async del(key: string): Promise<void> {
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
import type { Logger } from "nestjs-pino";
|
||||
import type { ConfigService } from "@nestjs/config";
|
||||
import { SalesforceConnection } from "./salesforce-connection.service";
|
||||
import type { SalesforceRequestQueueService } from "@bff/core/queue/services/salesforce-request-queue.service";
|
||||
|
||||
describe("SalesforceConnection", () => {
|
||||
const createService = () => {
|
||||
const configService = {
|
||||
get: jest.fn(),
|
||||
} as unknown as ConfigService;
|
||||
|
||||
const requestQueue: Partial<SalesforceRequestQueueService> = {
|
||||
execute: jest.fn().mockImplementation(async (fn) => fn()),
|
||||
executeHighPriority: jest.fn().mockImplementation(async (fn) => fn()),
|
||||
};
|
||||
|
||||
const logger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
log: jest.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
const service = new SalesforceConnection(configService, requestQueue as SalesforceRequestQueueService, logger);
|
||||
|
||||
// Override internal connection with simple stubs
|
||||
const queryMock = jest.fn().mockResolvedValue("query-result");
|
||||
service["connection"] = {
|
||||
query: queryMock,
|
||||
sobject: jest.fn().mockReturnValue({
|
||||
create: jest.fn().mockResolvedValue({ id: "001" }),
|
||||
update: jest.fn().mockResolvedValue({}),
|
||||
}),
|
||||
} as unknown as typeof service["connection"];
|
||||
|
||||
jest.spyOn(service as unknown as { ensureConnected(): Promise<void> }, "ensureConnected").mockResolvedValue();
|
||||
|
||||
return {
|
||||
service,
|
||||
requestQueue,
|
||||
queryMock,
|
||||
};
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("routes standard queries through the request queue with derived priority metadata", async () => {
|
||||
const { service, requestQueue, queryMock } = createService();
|
||||
|
||||
await service.query("SELECT Id FROM Account WHERE Id = '001'");
|
||||
|
||||
expect(requestQueue.execute).toHaveBeenCalledTimes(1);
|
||||
const [, options] = (requestQueue.execute as jest.Mock).mock.calls[0];
|
||||
expect(options).toMatchObject({ priority: 8, isLongRunning: false });
|
||||
expect(queryMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("routes SObject create operations through the high-priority queue", async () => {
|
||||
const { service, requestQueue } = createService();
|
||||
|
||||
const sobject = service.sobject("Order");
|
||||
await sobject.create({ Name: "Test" });
|
||||
|
||||
expect(requestQueue.executeHighPriority).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@ -242,42 +242,50 @@ export class SalesforceConnection {
|
||||
|
||||
// Expose connection methods with automatic re-authentication
|
||||
async query(soql: string): Promise<unknown> {
|
||||
const priority = this.getQueryPriority(soql);
|
||||
const isLongRunning = this.isLongRunningQuery(soql);
|
||||
|
||||
try {
|
||||
// Ensure we have a base URL and token
|
||||
await this.ensureConnected();
|
||||
return await this.connection.query(soql);
|
||||
return await this.requestQueue.execute(
|
||||
async () => {
|
||||
await this.ensureConnected();
|
||||
try {
|
||||
return await this.connection.query(soql);
|
||||
} catch (error: unknown) {
|
||||
if (this.isSessionExpiredError(error)) {
|
||||
const reAuthStartTime = Date.now();
|
||||
this.logger.warn("Salesforce session expired, attempting to re-authenticate", {
|
||||
originalError: getErrorMessage(error),
|
||||
tokenIssuedAt: this.tokenIssuedAt ? new Date(this.tokenIssuedAt).toISOString() : null,
|
||||
tokenExpiresAt: this.tokenExpiresAt ? new Date(this.tokenExpiresAt).toISOString() : null,
|
||||
});
|
||||
|
||||
try {
|
||||
await this.connect(true);
|
||||
|
||||
const reAuthDuration = Date.now() - reAuthStartTime;
|
||||
this.logger.debug("Retrying query after re-authentication", {
|
||||
reAuthDuration,
|
||||
});
|
||||
|
||||
return await this.connection.query(soql);
|
||||
} catch (retryError) {
|
||||
const reAuthDuration = Date.now() - reAuthStartTime;
|
||||
this.logger.error("Failed to re-authenticate or retry query", {
|
||||
originalError: getErrorMessage(error),
|
||||
retryError: getErrorMessage(retryError),
|
||||
reAuthDuration,
|
||||
});
|
||||
throw retryError;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{ priority, isLongRunning }
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
// Check if this is a session expiration error
|
||||
if (this.isSessionExpiredError(error)) {
|
||||
const reAuthStartTime = Date.now();
|
||||
this.logger.warn("Salesforce session expired, attempting to re-authenticate", {
|
||||
originalError: getErrorMessage(error),
|
||||
tokenIssuedAt: this.tokenIssuedAt ? new Date(this.tokenIssuedAt).toISOString() : null,
|
||||
tokenExpiresAt: this.tokenExpiresAt ? new Date(this.tokenExpiresAt).toISOString() : null,
|
||||
});
|
||||
|
||||
try {
|
||||
// Re-authenticate
|
||||
await this.connect(true);
|
||||
|
||||
const reAuthDuration = Date.now() - reAuthStartTime;
|
||||
this.logger.debug("Retrying query after re-authentication", {
|
||||
reAuthDuration,
|
||||
});
|
||||
|
||||
return await this.connection.query(soql);
|
||||
} catch (retryError) {
|
||||
const reAuthDuration = Date.now() - reAuthStartTime;
|
||||
this.logger.error("Failed to re-authenticate or retry query", {
|
||||
originalError: getErrorMessage(error),
|
||||
retryError: getErrorMessage(retryError),
|
||||
reAuthDuration,
|
||||
});
|
||||
throw retryError;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-throw other errors as-is
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -305,86 +313,90 @@ export class SalesforceConnection {
|
||||
// Return a wrapper that handles session expiration for SObject operations
|
||||
return {
|
||||
create: async (data: object) => {
|
||||
try {
|
||||
await this.ensureConnected();
|
||||
return await originalSObject.create(data);
|
||||
} catch (error: unknown) {
|
||||
if (this.isSessionExpiredError(error)) {
|
||||
const reAuthStartTime = Date.now();
|
||||
this.logger.warn(
|
||||
"Salesforce session expired during SObject create, attempting to re-authenticate",
|
||||
{
|
||||
sobjectType: type,
|
||||
originalError: getErrorMessage(error),
|
||||
return this.requestQueue.executeHighPriority(async () => {
|
||||
try {
|
||||
await this.ensureConnected();
|
||||
return await originalSObject.create(data);
|
||||
} catch (error: unknown) {
|
||||
if (this.isSessionExpiredError(error)) {
|
||||
const reAuthStartTime = Date.now();
|
||||
this.logger.warn(
|
||||
"Salesforce session expired during SObject create, attempting to re-authenticate",
|
||||
{
|
||||
sobjectType: type,
|
||||
originalError: getErrorMessage(error),
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await this.connect(true);
|
||||
const reAuthDuration = Date.now() - reAuthStartTime;
|
||||
this.logger.debug("Retrying SObject create after re-authentication", {
|
||||
sobjectType: type,
|
||||
reAuthDuration,
|
||||
});
|
||||
|
||||
const newSObject = this.connection.sobject(type);
|
||||
return await newSObject.create(data);
|
||||
} catch (retryError) {
|
||||
const reAuthDuration = Date.now() - reAuthStartTime;
|
||||
this.logger.error("Failed to re-authenticate or retry SObject create", {
|
||||
sobjectType: type,
|
||||
originalError: getErrorMessage(error),
|
||||
retryError: getErrorMessage(retryError),
|
||||
reAuthDuration,
|
||||
});
|
||||
throw retryError;
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await this.connect(true);
|
||||
const reAuthDuration = Date.now() - reAuthStartTime;
|
||||
this.logger.debug("Retrying SObject create after re-authentication", {
|
||||
sobjectType: type,
|
||||
reAuthDuration,
|
||||
});
|
||||
|
||||
const newSObject = this.connection.sobject(type);
|
||||
return await newSObject.create(data);
|
||||
} catch (retryError) {
|
||||
const reAuthDuration = Date.now() - reAuthStartTime;
|
||||
this.logger.error("Failed to re-authenticate or retry SObject create", {
|
||||
sobjectType: type,
|
||||
originalError: getErrorMessage(error),
|
||||
retryError: getErrorMessage(retryError),
|
||||
reAuthDuration,
|
||||
});
|
||||
throw retryError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
update: async (data: object & { Id: string }) => {
|
||||
try {
|
||||
await this.ensureConnected();
|
||||
return await originalSObject.update(data);
|
||||
} catch (error: unknown) {
|
||||
if (this.isSessionExpiredError(error)) {
|
||||
const reAuthStartTime = Date.now();
|
||||
this.logger.warn(
|
||||
"Salesforce session expired during SObject update, attempting to re-authenticate",
|
||||
{
|
||||
sobjectType: type,
|
||||
recordId: data.Id,
|
||||
originalError: getErrorMessage(error),
|
||||
return this.requestQueue.executeHighPriority(async () => {
|
||||
try {
|
||||
await this.ensureConnected();
|
||||
return await originalSObject.update(data);
|
||||
} catch (error: unknown) {
|
||||
if (this.isSessionExpiredError(error)) {
|
||||
const reAuthStartTime = Date.now();
|
||||
this.logger.warn(
|
||||
"Salesforce session expired during SObject update, attempting to re-authenticate",
|
||||
{
|
||||
sobjectType: type,
|
||||
recordId: data.Id,
|
||||
originalError: getErrorMessage(error),
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await this.connect(true);
|
||||
const reAuthDuration = Date.now() - reAuthStartTime;
|
||||
this.logger.debug("Retrying SObject update after re-authentication", {
|
||||
sobjectType: type,
|
||||
recordId: data.Id,
|
||||
reAuthDuration,
|
||||
});
|
||||
|
||||
const newSObject = this.connection.sobject(type);
|
||||
return await newSObject.update(data);
|
||||
} catch (retryError) {
|
||||
const reAuthDuration = Date.now() - reAuthStartTime;
|
||||
this.logger.error("Failed to re-authenticate or retry SObject update", {
|
||||
sobjectType: type,
|
||||
recordId: data.Id,
|
||||
originalError: getErrorMessage(error),
|
||||
retryError: getErrorMessage(retryError),
|
||||
reAuthDuration,
|
||||
});
|
||||
throw retryError;
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await this.connect(true);
|
||||
const reAuthDuration = Date.now() - reAuthStartTime;
|
||||
this.logger.debug("Retrying SObject update after re-authentication", {
|
||||
sobjectType: type,
|
||||
recordId: data.Id,
|
||||
reAuthDuration,
|
||||
});
|
||||
|
||||
const newSObject = this.connection.sobject(type);
|
||||
return await newSObject.update(data);
|
||||
} catch (retryError) {
|
||||
const reAuthDuration = Date.now() - reAuthStartTime;
|
||||
this.logger.error("Failed to re-authenticate or retry SObject update", {
|
||||
sobjectType: type,
|
||||
recordId: data.Id,
|
||||
originalError: getErrorMessage(error),
|
||||
retryError: getErrorMessage(retryError),
|
||||
reAuthDuration,
|
||||
});
|
||||
throw retryError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -440,7 +452,28 @@ export class SalesforceConnection {
|
||||
async queryHighPriority(soql: string): Promise<unknown> {
|
||||
return this.requestQueue.executeHighPriority(async () => {
|
||||
await this.ensureConnected();
|
||||
return await this.connection.query(soql);
|
||||
try {
|
||||
return await this.connection.query(soql);
|
||||
} catch (error: unknown) {
|
||||
if (this.isSessionExpiredError(error)) {
|
||||
const reAuthStartTime = Date.now();
|
||||
this.logger.warn(
|
||||
"Salesforce session expired during high-priority query, attempting to re-authenticate",
|
||||
{
|
||||
originalError: getErrorMessage(error),
|
||||
}
|
||||
);
|
||||
|
||||
await this.connect(true);
|
||||
const reAuthDuration = Date.now() - reAuthStartTime;
|
||||
this.logger.debug("Retrying high-priority query after re-authentication", {
|
||||
reAuthDuration,
|
||||
});
|
||||
|
||||
return await this.connection.query(soql);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -297,18 +297,22 @@ export class AuthFacade {
|
||||
}
|
||||
}
|
||||
|
||||
async logout(userId: string, token?: string, request?: Request): Promise<void> {
|
||||
async logout(userId?: string, token?: string, request?: Request): Promise<void> {
|
||||
if (token) {
|
||||
await this.tokenBlacklistService.blacklistToken(token);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.tokenService.revokeAllUserTokens(userId);
|
||||
} catch (error) {
|
||||
this.logger.warn("Failed to revoke refresh tokens during logout", {
|
||||
userId,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
if (userId) {
|
||||
try {
|
||||
await this.tokenService.revokeAllUserTokens(userId);
|
||||
} catch (error) {
|
||||
this.logger.warn("Failed to revoke refresh tokens during logout", {
|
||||
userId,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.logger.debug("Skipping refresh token revocation during logout – userId unavailable");
|
||||
}
|
||||
|
||||
await this.auditService.logAuthEvent(AuditAction.LOGOUT, userId, {}, request, true);
|
||||
|
||||
@ -12,13 +12,18 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import type { Request, Response } from "express";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { AuthFacade } from "@bff/modules/auth/application/auth.facade";
|
||||
import { LocalAuthGuard } from "./guards/local-auth.guard";
|
||||
import { AuthThrottleGuard } from "./guards/auth-throttle.guard";
|
||||
import { FailedLoginThrottleGuard } from "./guards/failed-login-throttle.guard";
|
||||
import {
|
||||
FailedLoginThrottleGuard,
|
||||
type RequestWithRateLimit,
|
||||
} from "./guards/failed-login-throttle.guard";
|
||||
import { LoginResultInterceptor } from "./interceptors/login-result.interceptor";
|
||||
import { Public } from "../../decorators/public.decorator";
|
||||
import { ZodValidationPipe } from "@customer-portal/validation/nestjs";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
|
||||
|
||||
// Import Zod schemas from domain
|
||||
import {
|
||||
@ -100,7 +105,7 @@ const calculateCookieMaxAge = (isoTimestamp: string): number => {
|
||||
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
constructor(private authFacade: AuthFacade) {}
|
||||
constructor(private authFacade: AuthFacade, private readonly jwtService: JwtService) {}
|
||||
|
||||
private setAuthCookies(res: Response, tokens: AuthTokens): void {
|
||||
const accessMaxAge = calculateCookieMaxAge(tokens.expiresAt);
|
||||
@ -121,6 +126,10 @@ export class AuthController {
|
||||
res.setSecureCookie("refresh_token", "", { maxAge: 0, path: "/" });
|
||||
}
|
||||
|
||||
private applyAuthRateLimitHeaders(req: RequestWithRateLimit, res: Response): void {
|
||||
FailedLoginThrottleGuard.applyRateLimitHeaders(req, res);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("validate-signup")
|
||||
@UseGuards(AuthThrottleGuard)
|
||||
@ -173,21 +182,41 @@ export class AuthController {
|
||||
@UseInterceptors(LoginResultInterceptor)
|
||||
@Post("login")
|
||||
async login(
|
||||
@Req() req: Request & { user: { id: string; email: string; role: string } },
|
||||
@Req() req: RequestWithUser & RequestWithRateLimit,
|
||||
@Res({ passthrough: true }) res: Response
|
||||
) {
|
||||
const result = await this.authFacade.login(req.user, req);
|
||||
this.setAuthCookies(res, result.tokens);
|
||||
this.applyAuthRateLimitHeaders(req, res);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("logout")
|
||||
async logout(
|
||||
@Req() req: RequestWithCookies & { user: { id: string } },
|
||||
@Req() req: RequestWithCookies & { user?: { id: string } },
|
||||
@Res({ passthrough: true }) res: Response
|
||||
) {
|
||||
const token = extractTokenFromRequest(req);
|
||||
await this.authFacade.logout(req.user.id, token, req as Request);
|
||||
let userId = req.user?.id;
|
||||
|
||||
if (!userId && token) {
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync<{ sub?: string }>(token, {
|
||||
ignoreExpiration: true,
|
||||
});
|
||||
|
||||
if (payload?.sub) {
|
||||
userId = payload.sub;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore verification errors – we still want to clear client cookies.
|
||||
}
|
||||
}
|
||||
|
||||
await this.authFacade.logout(userId, token, req as Request);
|
||||
|
||||
// Always clear cookies, even if session expired
|
||||
this.clearAuthCookies(res);
|
||||
return { message: "Logout successful" };
|
||||
}
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { Injectable, ExecutionContext } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import type { Request, Response } from "express";
|
||||
import {
|
||||
AuthRateLimitService,
|
||||
type RateLimitOutcome,
|
||||
} from "../../../infra/rate-limiting/auth-rate-limit.service";
|
||||
|
||||
type RequestWithRateLimit = Request & { __authRateLimit?: RateLimitOutcome };
|
||||
export type RequestWithRateLimit = Request & { __authRateLimit?: RateLimitOutcome };
|
||||
|
||||
@Injectable()
|
||||
export class FailedLoginThrottleGuard {
|
||||
@ -26,4 +26,16 @@ export class FailedLoginThrottleGuard {
|
||||
await this.authRateLimitService.clearLoginAttempts(request);
|
||||
}
|
||||
}
|
||||
|
||||
static applyRateLimitHeaders(
|
||||
request: RequestWithRateLimit,
|
||||
response: Response
|
||||
): void {
|
||||
const outcome = request.__authRateLimit;
|
||||
if (!outcome) return;
|
||||
|
||||
response.setHeader("X-RateLimit-Remaining", String(outcome.remainingPoints));
|
||||
response.setHeader("X-RateLimit-Reset", String(Math.ceil(outcome.msBeforeNext / 1000)));
|
||||
response.setHeader("X-Auth-Captcha", outcome.needsCaptcha ? "required" : "optional");
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,6 +49,7 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate {
|
||||
override async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = this.getRequest(context);
|
||||
const route = `${request.method} ${request.url}`;
|
||||
const isLogoutRoute = this.isLogoutRoute(request);
|
||||
|
||||
// Check if the route is marked as public
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
@ -65,6 +66,10 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate {
|
||||
// First, run the standard JWT authentication
|
||||
const canActivate = await super.canActivate(context);
|
||||
if (!canActivate) {
|
||||
if (isLogoutRoute) {
|
||||
this.logger.debug(`Allowing logout request without active session: ${route}`);
|
||||
return true;
|
||||
}
|
||||
this.logger.warn(`JWT authentication failed for route: ${route}`);
|
||||
return false;
|
||||
}
|
||||
@ -84,6 +89,10 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate {
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedException) {
|
||||
if (isLogoutRoute) {
|
||||
this.logger.debug(`Allowing logout request with expired or invalid token: ${route}`);
|
||||
return true;
|
||||
}
|
||||
const token = extractTokenFromRequest(request);
|
||||
const log =
|
||||
typeof token === "string"
|
||||
@ -116,4 +125,31 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate {
|
||||
(!candidate.route || typeof candidate.route === "object")
|
||||
);
|
||||
}
|
||||
|
||||
private isLogoutRoute(request: RequestWithRoute): boolean {
|
||||
if (!request || typeof request.method !== "string" || typeof request.url !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const routePath = request.route?.path;
|
||||
if (routePath && this.isLogoutPath(routePath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedUrl = request.url.split("?")[0] ?? "";
|
||||
return this.isLogoutPath(normalizedUrl);
|
||||
}
|
||||
|
||||
private isLogoutPath(path: string): boolean {
|
||||
if (typeof path !== "string" || path.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = path.endsWith("/") ? path.slice(0, -1) : path;
|
||||
return normalized === "/auth/logout" || normalized === "/api/auth/logout";
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,8 +7,11 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { Observable, defer } from "rxjs";
|
||||
import { tap, catchError } from "rxjs/operators";
|
||||
import { FailedLoginThrottleGuard } from "@bff/modules/auth/presentation/http/guards/failed-login-throttle.guard";
|
||||
import type { Request } from "express";
|
||||
import {
|
||||
FailedLoginThrottleGuard,
|
||||
type RequestWithRateLimit,
|
||||
} from "@bff/modules/auth/presentation/http/guards/failed-login-throttle.guard";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
@Injectable()
|
||||
export class LoginResultInterceptor implements NestInterceptor {
|
||||
@ -16,14 +19,16 @@ export class LoginResultInterceptor implements NestInterceptor {
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const rawRequest = context.switchToHttp().getRequest<unknown>();
|
||||
const response = context.switchToHttp().getResponse<Response>();
|
||||
if (!this.isExpressRequest(rawRequest)) {
|
||||
throw new UnauthorizedException("Invalid request context");
|
||||
}
|
||||
const request: Request = rawRequest;
|
||||
const request: RequestWithRateLimit = rawRequest as RequestWithRateLimit;
|
||||
|
||||
return next.handle().pipe(
|
||||
tap(() => {
|
||||
void this.failedLoginGuard.handleLoginResult(request, true);
|
||||
FailedLoginThrottleGuard.applyRateLimitHeaders(request, response);
|
||||
}),
|
||||
catchError(error =>
|
||||
defer(async () => {
|
||||
@ -37,6 +42,7 @@ export class LoginResultInterceptor implements NestInterceptor {
|
||||
|
||||
if (isAuthError) {
|
||||
await this.failedLoginGuard.handleLoginResult(request, false);
|
||||
FailedLoginThrottleGuard.applyRateLimitHeaders(request, response);
|
||||
}
|
||||
|
||||
throw error;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import { Public } from "@bff/modules/auth/decorators/public.decorator";
|
||||
import { WhmcsCurrencyService } from "../../integrations/whmcs/services/whmcs-currency.service";
|
||||
import type { WhmcsCurrency } from "@customer-portal/domain/billing";
|
||||
|
||||
@Controller("currency")
|
||||
export class CurrencyController {
|
||||
@ -8,20 +9,13 @@ export class CurrencyController {
|
||||
|
||||
@Public()
|
||||
@Get("default")
|
||||
getDefaultCurrency() {
|
||||
const defaultCurrency = this.currencyService.getDefaultCurrency();
|
||||
return {
|
||||
code: defaultCurrency.code,
|
||||
prefix: defaultCurrency.prefix,
|
||||
suffix: defaultCurrency.suffix,
|
||||
format: defaultCurrency.format,
|
||||
rate: defaultCurrency.rate,
|
||||
};
|
||||
getDefaultCurrency(): WhmcsCurrency {
|
||||
return this.currencyService.getDefaultCurrency();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get("all")
|
||||
getAllCurrencies() {
|
||||
getAllCurrencies(): WhmcsCurrency[] {
|
||||
return this.currencyService.getAllCurrencies();
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,9 +2,11 @@ import { Module } from "@nestjs/common";
|
||||
import { HealthController } from "./health.controller";
|
||||
import { PrismaModule } from "@bff/infra/database/prisma.module";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { QueueModule } from "@bff/core/queue/queue.module";
|
||||
import { QueueHealthController } from "@bff/core/health/queue-health.controller";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, ConfigModule],
|
||||
controllers: [HealthController],
|
||||
imports: [PrismaModule, ConfigModule, QueueModule],
|
||||
controllers: [HealthController, QueueHealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
|
||||
@ -95,7 +95,7 @@ export class OrderEventsService {
|
||||
});
|
||||
}
|
||||
|
||||
private buildEvent(event: string, data: Record<string, unknown>): MessageEvent {
|
||||
private buildEvent<T extends object>(event: string, data: T): MessageEvent {
|
||||
return {
|
||||
data: {
|
||||
event,
|
||||
@ -104,4 +104,3 @@ export class OrderEventsService {
|
||||
} satisfies MessageEvent;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -19,7 +19,6 @@ COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||
|
||||
# Copy package.json files for dependency resolution
|
||||
COPY packages/domain/package.json ./packages/domain/
|
||||
COPY packages/logging/package.json ./packages/logging/
|
||||
COPY packages/validation/package.json ./packages/validation/
|
||||
COPY apps/portal/package.json ./apps/portal/
|
||||
|
||||
@ -52,7 +51,6 @@ COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
# Build shared workspace packages first
|
||||
RUN pnpm --filter @customer-portal/domain build && \
|
||||
pnpm --filter @customer-portal/logging build && \
|
||||
pnpm --filter @customer-portal/validation build
|
||||
|
||||
# Build portal with standalone output
|
||||
|
||||
@ -18,7 +18,6 @@ const nextConfig = {
|
||||
// Ensure workspace packages are transpiled correctly
|
||||
transpilePackages: [
|
||||
"@customer-portal/domain",
|
||||
"@customer-portal/logging",
|
||||
"@customer-portal/validation",
|
||||
],
|
||||
|
||||
@ -119,7 +118,6 @@ const nextConfig = {
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
"@customer-portal/domain": path.join(workspaceRoot, "packages/domain"),
|
||||
"@customer-portal/logging": path.join(workspaceRoot, "packages/logging/src"),
|
||||
"@customer-portal/validation": path.join(workspaceRoot, "packages/validation/src"),
|
||||
};
|
||||
const preferredExtensions = [".ts", ".tsx", ".mts", ".cts"];
|
||||
|
||||
@ -19,7 +19,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@customer-portal/domain": "workspace:*",
|
||||
"@customer-portal/logging": "workspace:*",
|
||||
"@customer-portal/validation": "workspace:*",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Component, ReactNode, ErrorInfo } from "react";
|
||||
import { log } from "@customer-portal/logging";
|
||||
import { log } from "@/lib/logger";
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useAuthStore } from "@/features/auth/services/auth.store";
|
||||
import { accountService } from "@/features/account/services/account.service";
|
||||
import { logger } from "@customer-portal/logging";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
// Use centralized profile types
|
||||
import type { ProfileEditFormData, Address } from "@customer-portal/domain/customer";
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import { logger } from "@customer-portal/logging";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useAuthSession } from "@/features/auth/services/auth.store";
|
||||
|
||||
@ -7,7 +7,7 @@ import { create } from "zustand";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { getNullableData } from "@/lib/api/response-helpers";
|
||||
import { getErrorInfo } from "@/lib/utils/error-handling";
|
||||
import logger from "@customer-portal/logging";
|
||||
import { logger } from "@/lib/logger";
|
||||
import {
|
||||
authResponseSchema,
|
||||
checkPasswordNeededResponseSchema,
|
||||
|
||||
@ -20,7 +20,7 @@ const { formatCurrency } = Formatting;
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling";
|
||||
import { openSsoLink } from "@/features/billing/utils/sso";
|
||||
import { logger } from "@customer-portal/logging";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
interface InvoiceTableProps {
|
||||
invoices: Invoice[];
|
||||
|
||||
@ -7,7 +7,7 @@ import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
|
||||
import { ErrorState } from "@/components/atoms/error-state";
|
||||
import { CheckCircleIcon, DocumentTextIcon } from "@heroicons/react/24/outline";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { logger } from "@customer-portal/logging";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { openSsoLink } from "@/features/billing/utils/sso";
|
||||
import { useInvoice, useCreateInvoiceSsoLink } from "@/features/billing/hooks";
|
||||
import {
|
||||
|
||||
@ -19,7 +19,7 @@ import { InlineToast } from "@/components/atoms/inline-toast";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
|
||||
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
||||
import { logger } from "@customer-portal/logging";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export function PaymentMethodsContainer() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@ -7,7 +7,7 @@ import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { accountService } from "@/features/account/services/account.service";
|
||||
import { log } from "@customer-portal/logging";
|
||||
import { log } from "@/lib/logger";
|
||||
import { StatusPill } from "@/components/atoms/status-pill";
|
||||
import { MapPinIcon, PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { COUNTRY_OPTIONS, getCountryName } from "@/lib/constants/countries";
|
||||
|
||||
@ -26,7 +26,7 @@ import { StatCard, QuickAction, DashboardActivityItem } from "@/features/dashboa
|
||||
import { LoadingStats, LoadingTable } from "@/components/atoms";
|
||||
import { ErrorState } from "@/components/atoms/error-state";
|
||||
import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency";
|
||||
import { log } from "@customer-portal/logging";
|
||||
import { log } from "@/lib/logger";
|
||||
import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling";
|
||||
|
||||
export function DashboardView() {
|
||||
|
||||
@ -44,13 +44,6 @@ const SERVICE_ICON_STYLES = {
|
||||
default: "bg-slate-50 text-slate-600",
|
||||
} as const;
|
||||
|
||||
const STATUS_ACCENT_TONE_CLASSES = {
|
||||
success: "bg-green-400/70",
|
||||
info: "bg-blue-400/70",
|
||||
warning: "bg-amber-400/70",
|
||||
neutral: "bg-slate-200",
|
||||
} as const;
|
||||
|
||||
const renderServiceIcon = (orderType?: string): ReactNode => {
|
||||
const category = getServiceCategory(orderType);
|
||||
switch (category) {
|
||||
@ -115,7 +108,7 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
|
||||
<article
|
||||
key={String(order.id)}
|
||||
className={cn(
|
||||
"group relative overflow-hidden rounded-3xl border border-slate-200 bg-white px-6 pb-6 pt-7 shadow-sm transition-all focus-visible:outline-none",
|
||||
"group overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm transition-all focus-visible:outline-none",
|
||||
isInteractive &&
|
||||
"cursor-pointer hover:border-blue-200 hover:shadow-md focus-within:border-blue-300 focus-within:ring-2 focus-within:ring-blue-100",
|
||||
className
|
||||
@ -125,92 +118,86 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
|
||||
role={isInteractive ? "button" : undefined}
|
||||
tabIndex={isInteractive ? 0 : undefined}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-4 top-4 h-1 rounded-full",
|
||||
STATUS_ACCENT_TONE_CLASSES[statusDescriptor.tone]
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex min-w-0 flex-1 items-start gap-4">
|
||||
{/* Header */}
|
||||
<div className="border-b border-slate-100 bg-gradient-to-br from-white to-slate-50 px-6 py-4">
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-12 w-12 items-center justify-center rounded-xl border border-transparent",
|
||||
"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg",
|
||||
iconStyles
|
||||
)}
|
||||
>
|
||||
{serviceIcon}
|
||||
</div>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<h3 className="text-xl font-semibold text-gray-900 line-clamp-2">{serviceSummary}</h3>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500">
|
||||
<span className="font-medium uppercase tracking-[0.14em] text-gray-400">
|
||||
Order #{order.orderNumber || String(order.id).slice(-8)}
|
||||
</span>
|
||||
<span className="hidden text-gray-300 sm:inline">•</span>
|
||||
<span>{formattedCreatedDate ? `Placed ${formattedCreatedDate}` : "Created date —"}</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{serviceSummary}</h3>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-xs text-gray-500">
|
||||
<span className="font-medium">#{order.orderNumber || String(order.id).slice(-8)}</span>
|
||||
<span>•</span>
|
||||
<span>{formattedCreatedDate || "—"}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 line-clamp-2">{statusDescriptor.description}</p>
|
||||
{displayItems.length > 1 && (
|
||||
<div className="flex flex-wrap gap-2 pt-1 text-xs text-gray-500">
|
||||
{displayItems.slice(1, 4).map(item => (
|
||||
<span
|
||||
key={item.id}
|
||||
className="rounded-full bg-slate-100 px-2 py-0.5 font-medium text-slate-600"
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
))}
|
||||
{displayItems.length > 4 && (
|
||||
<span className="text-gray-400">+{displayItems.length - 4} more</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing */}
|
||||
{showPricing && (
|
||||
<div className="flex items-center gap-4">
|
||||
{totals.monthlyTotal > 0 && (
|
||||
<div className="text-right">
|
||||
<p className="text-[10px] font-medium uppercase tracking-wider text-blue-600">Monthly</p>
|
||||
<p className="text-xl font-bold text-gray-900">
|
||||
¥{totals.monthlyTotal.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{totals.oneTimeTotal > 0 && (
|
||||
<div className="text-right">
|
||||
<p className="text-[10px] font-medium uppercase tracking-wider text-blue-600">One-Time</p>
|
||||
<p className="text-lg font-bold text-gray-900">
|
||||
¥{totals.oneTimeTotal.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-3 text-right">
|
||||
<StatusPill label={statusDescriptor.label} variant={statusVariant} />
|
||||
<div className="text-sm text-gray-500">
|
||||
{showPricing ? (
|
||||
<div className="space-y-1">
|
||||
{totals.monthlyTotal > 0 ? (
|
||||
<p className="text-2xl font-semibold text-gray-900">
|
||||
¥{totals.monthlyTotal.toLocaleString()}
|
||||
<span className="ml-1 text-xs font-medium text-gray-500">/ month</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm font-semibold text-gray-500">No monthly charges</p>
|
||||
)}
|
||||
{totals.oneTimeTotal > 0 && (
|
||||
<p className="text-xs font-medium text-gray-500">
|
||||
¥{totals.oneTimeTotal.toLocaleString()} one-time
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm font-semibold text-gray-500">Included in plan</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(isInteractive || footer) && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-blue-600">
|
||||
{isInteractive ? (
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
View details
|
||||
<ChevronRightIcon className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-1" />
|
||||
{/* Body */}
|
||||
<div className="px-6 py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm text-gray-700">{statusDescriptor.description}</p>
|
||||
<StatusPill label={statusDescriptor.label} variant={statusVariant} />
|
||||
</div>
|
||||
{displayItems.length > 1 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{displayItems.slice(1, 4).map(item => (
|
||||
<span
|
||||
key={item.id}
|
||||
className="inline-flex items-center rounded-md bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-700"
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
))}
|
||||
{displayItems.length > 4 && (
|
||||
<span className="inline-flex items-center text-xs text-gray-400">
|
||||
+{displayItems.length - 4} more
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-500">{statusDescriptor.label}</span>
|
||||
)}
|
||||
{footer && <div className="text-gray-500">{footer}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{isInteractive && (
|
||||
<div className="border-t border-slate-100 bg-slate-50/30 px-6 py-3">
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-blue-600">
|
||||
View details
|
||||
<ChevronRightIcon className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-1" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@ -118,10 +118,14 @@ const aggregateCharges = (group: OrderItemGroup): OrderDisplayItemCharge[] => {
|
||||
};
|
||||
|
||||
const buildGroupName = (group: OrderItemGroup): string => {
|
||||
const monthlyItem = group.items.find(
|
||||
item => normalizeBillingCycle(item.billingCycle ?? undefined) === "monthly"
|
||||
);
|
||||
const fallbackItem = monthlyItem ?? group.items[0];
|
||||
// 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";
|
||||
};
|
||||
|
||||
@ -195,22 +199,22 @@ export function buildOrderDisplayItems(
|
||||
return [];
|
||||
}
|
||||
|
||||
const groups = buildOrderItemGroup(items);
|
||||
|
||||
return groups.map((group, groupIndex) => {
|
||||
const charges = aggregateCharges(group);
|
||||
// Don't group items - show each one separately
|
||||
return items.map((item, index) => {
|
||||
const charges = aggregateCharges({ indices: [index], items: [item] });
|
||||
const isBundled = Boolean(item.isBundledAddon);
|
||||
|
||||
return {
|
||||
id: buildOrderItemId(group, groupIndex),
|
||||
name: buildGroupName(group),
|
||||
quantity:
|
||||
group.items.length === 1 ? group.items[0]?.quantity ?? undefined : undefined,
|
||||
status: group.items.length === 1 ? group.items[0]?.status ?? undefined : undefined,
|
||||
primaryCategory: determinePrimaryCategory(group),
|
||||
categories: collectCategories(group),
|
||||
id: item.productId || item.sku || `order-item-${index}`,
|
||||
name: item.productName || item.name || "Service item",
|
||||
quantity: item.quantity ?? undefined,
|
||||
status: item.status ?? undefined,
|
||||
primaryCategory: resolveCategory(item),
|
||||
categories: [resolveCategory(item)],
|
||||
charges,
|
||||
included: isIncludedGroup(charges),
|
||||
sourceItems: group.items,
|
||||
isBundle: group.items.length > 1,
|
||||
sourceItems: [item],
|
||||
isBundle: isBundled,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
WrenchScrewdriverIcon,
|
||||
PuzzlePieceIcon,
|
||||
BoltIcon,
|
||||
ClockIcon,
|
||||
Squares2X2Icon,
|
||||
ExclamationTriangleIcon,
|
||||
ArrowLeftIcon,
|
||||
@ -63,43 +64,71 @@ const CATEGORY_CONFIG: Record<
|
||||
OrderDisplayItemCategory,
|
||||
{
|
||||
icon: typeof SparklesIcon;
|
||||
badgeClass: string;
|
||||
label: string;
|
||||
}
|
||||
> = {
|
||||
service: {
|
||||
icon: SparklesIcon,
|
||||
badgeClass: "bg-blue-50 text-blue-700",
|
||||
label: "Service",
|
||||
},
|
||||
installation: {
|
||||
icon: WrenchScrewdriverIcon,
|
||||
badgeClass: "bg-emerald-50 text-emerald-700",
|
||||
label: "Installation",
|
||||
},
|
||||
addon: {
|
||||
icon: PuzzlePieceIcon,
|
||||
badgeClass: "bg-violet-50 text-violet-700",
|
||||
label: "Add-on",
|
||||
},
|
||||
activation: {
|
||||
icon: BoltIcon,
|
||||
badgeClass: "bg-amber-50 text-amber-700",
|
||||
label: "Activation",
|
||||
},
|
||||
other: {
|
||||
icon: Squares2X2Icon,
|
||||
badgeClass: "bg-slate-100 text-slate-600",
|
||||
label: "Item",
|
||||
},
|
||||
};
|
||||
|
||||
const STATUS_ACCENT_TONE_CLASSES = {
|
||||
success: "bg-green-400/80",
|
||||
info: "bg-blue-400/80",
|
||||
warning: "bg-amber-400/80",
|
||||
neutral: "bg-slate-200",
|
||||
} as const;
|
||||
const ITEM_VISUAL_STYLES: Record<
|
||||
OrderDisplayItemCategory,
|
||||
{
|
||||
container: string;
|
||||
icon: string;
|
||||
}
|
||||
> = {
|
||||
service: {
|
||||
container: "border-blue-200 bg-white",
|
||||
icon: "bg-blue-50 text-blue-600",
|
||||
},
|
||||
installation: {
|
||||
container: "border-green-200 bg-white",
|
||||
icon: "bg-green-50 text-green-600",
|
||||
},
|
||||
addon: {
|
||||
container: "border-slate-200 bg-white",
|
||||
icon: "bg-slate-50 text-slate-600",
|
||||
},
|
||||
activation: {
|
||||
container: "border-slate-200 bg-white",
|
||||
icon: "bg-slate-50 text-slate-600",
|
||||
},
|
||||
other: {
|
||||
container: "border-slate-200 bg-white",
|
||||
icon: "bg-slate-50 text-slate-600",
|
||||
},
|
||||
};
|
||||
|
||||
const BUNDLE_VISUAL_STYLE = {
|
||||
container: "border-purple-200 bg-white",
|
||||
icon: "bg-purple-50 text-purple-600",
|
||||
};
|
||||
|
||||
const getItemVisualStyle = (item: OrderDisplayItem) => {
|
||||
if (item.isBundle) {
|
||||
return BUNDLE_VISUAL_STYLE;
|
||||
}
|
||||
return ITEM_VISUAL_STYLES[item.primaryCategory] ?? ITEM_VISUAL_STYLES.other;
|
||||
};
|
||||
|
||||
const describeCharge = (charge: OrderDisplayItemCharge): string => {
|
||||
if (typeof charge.suffix === "string" && charge.suffix.trim().length > 0) {
|
||||
@ -146,9 +175,6 @@ export function OrderDetailContainer() {
|
||||
|
||||
const serviceCategory = getServiceCategory(data?.orderType);
|
||||
const serviceIcon = renderServiceIcon(serviceCategory, "h-6 w-6");
|
||||
const accentTone = statusDescriptor
|
||||
? STATUS_ACCENT_TONE_CLASSES[statusDescriptor.tone]
|
||||
: STATUS_ACCENT_TONE_CLASSES.neutral;
|
||||
|
||||
const displayItems = useMemo<OrderDisplayItem[]>(() => {
|
||||
return buildOrderDisplayItems(data?.itemsSummary);
|
||||
@ -300,186 +326,164 @@ export function OrderDetailContainer() {
|
||||
|
||||
{data ? (
|
||||
<>
|
||||
<div className="relative overflow-hidden rounded-3xl border border-slate-200 bg-white px-6 pb-6 pt-8 shadow-sm sm:px-8">
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-6 top-6 h-1 rounded-full",
|
||||
accentTone
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-5 md:flex-row md:items-start md:justify-between">
|
||||
<div className="flex flex-1 items-start gap-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-blue-50 text-blue-600">
|
||||
{serviceIcon}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-semibold text-gray-900">{serviceLabel}</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Order #{data.orderNumber || String(data.id).slice(-8)}
|
||||
{placedDate ? ` • Placed ${placedDate}` : null}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm text-gray-500">
|
||||
<div className="space-y-1">
|
||||
{totals.monthlyTotal > 0 ? (
|
||||
<p className="text-2xl font-semibold text-gray-900">
|
||||
{yenFormatter.format(totals.monthlyTotal)}
|
||||
<span className="ml-1 text-xs font-medium text-gray-500">/ month</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm font-semibold text-gray-500">No monthly charges</p>
|
||||
)}
|
||||
{totals.oneTimeTotal > 0 && (
|
||||
<p className="text-xs font-medium text-gray-500">
|
||||
{yenFormatter.format(totals.oneTimeTotal)} one-time
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||
Your Services & Products
|
||||
</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
{displayItems.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-4 py-6 text-center text-sm text-gray-500">
|
||||
No items found on this order.
|
||||
<div className="rounded-3xl border border-slate-200 bg-white shadow-sm">
|
||||
{/* Header Section */}
|
||||
<div className="border-b border-slate-200 bg-gradient-to-br from-white to-slate-50 px-6 py-6 sm:px-8">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-blue-50 text-blue-600">
|
||||
{serviceIcon}
|
||||
</div>
|
||||
) : (
|
||||
displayItems.map(item => {
|
||||
const categoryConfig = CATEGORY_CONFIG[item.primaryCategory] ?? CATEGORY_CONFIG.other;
|
||||
const Icon = categoryConfig.icon;
|
||||
const categories = Array.from(new Set(item.categories));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex flex-col gap-3 rounded-2xl border border-slate-200 bg-white/80 px-4 py-4 shadow-sm sm:flex-row sm:items-center sm:justify-between sm:gap-6"
|
||||
>
|
||||
<div className="flex flex-1 items-start gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-slate-100 text-slate-600">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="flex flex-wrap items-baseline gap-2">
|
||||
<p className="text-base font-semibold text-gray-900">{item.name}</p>
|
||||
{typeof item.quantity === "number" && item.quantity > 1 && (
|
||||
<span className="text-xs font-medium text-gray-500">×{item.quantity}</span>
|
||||
)}
|
||||
{item.status && (
|
||||
<span className="text-[11px] uppercase tracking-wide text-gray-400">
|
||||
{item.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-500">
|
||||
{categories.map(category => {
|
||||
const badge = CATEGORY_CONFIG[category] ?? CATEGORY_CONFIG.other;
|
||||
return (
|
||||
<span
|
||||
key={`${item.id}-${category}`}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium",
|
||||
badge.badgeClass
|
||||
)}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{item.isBundle && (
|
||||
<span className="inline-flex items-center rounded-full bg-blue-50 px-2 py-0.5 text-[11px] font-medium text-blue-700">
|
||||
Bundle
|
||||
</span>
|
||||
)}
|
||||
{item.included && (
|
||||
<span className="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">
|
||||
Included
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1 text-sm text-gray-600 sm:items-end">
|
||||
{item.charges.map((charge, index) => {
|
||||
const descriptor = describeCharge(charge);
|
||||
if (charge.amount > 0) {
|
||||
return (
|
||||
<div key={`${item.id}-charge-${index}`} className="flex items-baseline gap-1">
|
||||
<span className="text-base font-semibold text-gray-900">
|
||||
{yenFormatter.format(charge.amount)}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-gray-500">{descriptor}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${item.id}-charge-${index}`}
|
||||
className="flex items-baseline gap-1 text-sm font-medium text-gray-500"
|
||||
>
|
||||
Included
|
||||
<span className="text-xs text-gray-400">{descriptor}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFeeNotice && (
|
||||
<div className="rounded-2xl border border-amber-200 bg-amber-50/80 px-4 py-3 text-amber-900">
|
||||
<div className="flex items-start gap-3">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-amber-500" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold">Additional fees may apply</p>
|
||||
<p className="text-xs leading-relaxed">
|
||||
Weekend installation, express setup, or specialised configuration work can
|
||||
add extra costs. We'll always confirm with you before applying any
|
||||
additional charges.
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">{serviceLabel}</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Order #{data.orderNumber || String(data.id).slice(-8)}
|
||||
{placedDate ? ` • ${placedDate}` : null}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{statusDescriptor && (
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-6">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Status
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-gray-900">
|
||||
{statusDescriptor.description}
|
||||
</p>
|
||||
{statusDescriptor && (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-blue-100 bg-blue-50/60 px-3 py-3 text-sm text-blue-900">
|
||||
<StatusPill label={statusDescriptor.label} variant={statusPillVariant} />
|
||||
<span className="font-semibold text-blue-900">{statusDescriptor.description}</span>
|
||||
{statusDescriptor.timeline && (
|
||||
<p className="text-sm text-gray-600">
|
||||
<span className="font-medium text-gray-700">Timeline: </span>
|
||||
<span className="flex items-center gap-1 text-xs text-blue-800">
|
||||
<ClockIcon className="h-4 w-4" aria-hidden />
|
||||
{statusDescriptor.timeline}
|
||||
</p>
|
||||
</span>
|
||||
)}
|
||||
{statusDescriptor.nextAction && (
|
||||
<span className="text-xs text-blue-700">
|
||||
Next: {statusDescriptor.nextAction}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<StatusPill label={statusDescriptor.label} variant={statusPillVariant} />
|
||||
</div>
|
||||
{statusDescriptor.nextAction && (
|
||||
<div className="mt-4 rounded-2xl border border-blue-100 bg-white p-4">
|
||||
<p className="text-sm font-semibold text-blue-900">Next steps</p>
|
||||
<p className="mt-1 text-sm text-blue-800">{statusDescriptor.nextAction}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pricing Section */}
|
||||
<div className="flex items-center gap-6">
|
||||
{totals.monthlyTotal > 0 && (
|
||||
<div className="text-right">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-blue-600">Monthly</p>
|
||||
<p className="text-3xl font-bold text-gray-900">
|
||||
{yenFormatter.format(totals.monthlyTotal)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{totals.oneTimeTotal > 0 && (
|
||||
<div className="text-right">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-blue-600">One-Time</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{yenFormatter.format(totals.oneTimeTotal)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-6 sm:px-8">
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Status Section */}
|
||||
{/* Order Items Section */}
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide text-gray-700">Order Details</h3>
|
||||
<div className="space-y-2">
|
||||
{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">
|
||||
No items found on this order.
|
||||
</div>
|
||||
) : (
|
||||
displayItems.map((item, itemIndex) => {
|
||||
const categoryConfig = CATEGORY_CONFIG[item.primaryCategory] ?? CATEGORY_CONFIG.other;
|
||||
const Icon = categoryConfig.icon;
|
||||
const prevItem = itemIndex > 0 ? displayItems[itemIndex - 1] : null;
|
||||
const showBundleStart = item.isBundle && (!prevItem || !prevItem.isBundle);
|
||||
const style = getItemVisualStyle(item);
|
||||
|
||||
return (
|
||||
<div key={item.id}>
|
||||
{showBundleStart && (
|
||||
<div className="mb-2 flex items-center gap-2 px-1">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-purple-600">
|
||||
Bundled
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-purple-200"></div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-3 rounded-xl border p-4 sm:flex-row sm:items-center sm:justify-between",
|
||||
style.container
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-1 items-start gap-3">
|
||||
<div className={cn(
|
||||
"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg",
|
||||
style.icon
|
||||
)}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h4 className="font-semibold text-gray-900">
|
||||
{item.name}
|
||||
</h4>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
{categoryConfig.label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1 text-sm text-gray-600 sm:items-end sm:text-right">
|
||||
{item.charges.map((charge, index) => {
|
||||
const descriptor = describeCharge(charge);
|
||||
if (charge.amount > 0) {
|
||||
return (
|
||||
<div key={`${item.id}-charge-${index}`} className="flex items-baseline gap-2">
|
||||
<span className="font-semibold text-gray-900">
|
||||
{yenFormatter.format(charge.amount)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">{descriptor}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${item.id}-charge-${index}`}
|
||||
className="text-xs font-medium text-gray-500"
|
||||
>
|
||||
Included {descriptor}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFeeNotice && (
|
||||
<div className="rounded-xl bg-amber-50 px-4 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-amber-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-900">Additional fees may apply</p>
|
||||
<p className="mt-0.5 text-xs leading-relaxed text-amber-800">
|
||||
Weekend installation, express setup, or specialised configuration work can
|
||||
add extra costs. We'll always confirm with you before applying any
|
||||
additional charges.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
InformationCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
import { logger } from "@customer-portal/logging";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export function NewSupportCaseView() {
|
||||
const router = useRouter();
|
||||
|
||||
@ -1,31 +0,0 @@
|
||||
/**
|
||||
* API Helper Functions
|
||||
* Generic utilities for working with API responses
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extract data from API response or throw error
|
||||
*/
|
||||
export function getDataOrThrow<T>(
|
||||
response: { data?: T; error?: unknown },
|
||||
errorMessage: string
|
||||
): T {
|
||||
if (response.error || !response.data) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract data from API response or return default value
|
||||
*/
|
||||
export function getDataOrDefault<T>(response: { data?: T; error?: unknown }, defaultValue: T): T {
|
||||
return response.data ?? defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if value is an API error
|
||||
*/
|
||||
export function isApiError(error: unknown): error is Error {
|
||||
return error instanceof Error;
|
||||
}
|
||||
@ -12,7 +12,7 @@ export { ApiError, isApiError } from "./runtime/client";
|
||||
export * from "./response-helpers";
|
||||
|
||||
// Import createClient for internal use
|
||||
import { createClient } from "./runtime/client";
|
||||
import { createClient, ApiError } from "./runtime/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
/**
|
||||
@ -54,7 +54,7 @@ async function handleApiError(response: Response): Promise<void> {
|
||||
// Ignore body parse errors
|
||||
}
|
||||
|
||||
throw new (await import("./runtime/client")).ApiError(message, response, body);
|
||||
throw new ApiError(message, response, body);
|
||||
}
|
||||
|
||||
export const apiClient = createClient({
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { currencyService, type CurrencyInfo } from "@/lib/services/currency.service";
|
||||
import { currencyService, FALLBACK_CURRENCY } from "@/lib/services/currency.service";
|
||||
import type { WhmcsCurrency } from "@customer-portal/domain/billing";
|
||||
|
||||
export function useCurrency() {
|
||||
const [defaultCurrency, setDefaultCurrency] = useState<CurrencyInfo | null>(null);
|
||||
const [defaultCurrency, setDefaultCurrency] = useState<WhmcsCurrency | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@ -18,13 +19,7 @@ export function useCurrency() {
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load currency");
|
||||
// Fallback to JPY if API fails
|
||||
setDefaultCurrency({
|
||||
code: "JPY",
|
||||
prefix: "¥",
|
||||
suffix: "",
|
||||
format: "1",
|
||||
rate: "1.00000",
|
||||
});
|
||||
setDefaultCurrency(FALLBACK_CURRENCY);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import logger from "@customer-portal/logging";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
/**
|
||||
* Hook for managing localStorage with SSR safety
|
||||
|
||||
@ -74,3 +74,11 @@ class Logger {
|
||||
}
|
||||
|
||||
export const logger = new Logger();
|
||||
|
||||
export const log = {
|
||||
info: (message: string, meta?: LogMeta) => logger.info(message, meta),
|
||||
warn: (message: string, meta?: LogMeta) => logger.warn(message, meta),
|
||||
error: (message: string, error?: unknown, meta?: LogMeta) =>
|
||||
logger.error(message, error, meta),
|
||||
debug: (message: string, meta?: LogMeta) => logger.debug(message, meta),
|
||||
};
|
||||
|
||||
@ -1,34 +1,23 @@
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { apiClient, getDataOrThrow } from "@/lib/api";
|
||||
import type { WhmcsCurrency } from "@customer-portal/domain/billing";
|
||||
|
||||
export interface CurrencyInfo {
|
||||
code: string;
|
||||
prefix: string;
|
||||
suffix: string;
|
||||
format: string;
|
||||
rate: string;
|
||||
}
|
||||
export const FALLBACK_CURRENCY: WhmcsCurrency = {
|
||||
id: 1,
|
||||
code: "JPY",
|
||||
prefix: "¥",
|
||||
suffix: "",
|
||||
format: "1",
|
||||
rate: "1.00000",
|
||||
};
|
||||
|
||||
export interface CurrencyService {
|
||||
getDefaultCurrency(): Promise<CurrencyInfo>;
|
||||
getAllCurrencies(): Promise<CurrencyInfo[]>;
|
||||
}
|
||||
export const currencyService = {
|
||||
async getDefaultCurrency(): Promise<WhmcsCurrency> {
|
||||
const response = await apiClient.GET<WhmcsCurrency>("/api/currency/default");
|
||||
return getDataOrThrow(response, "Failed to get default currency");
|
||||
},
|
||||
|
||||
class CurrencyServiceImpl implements CurrencyService {
|
||||
async getDefaultCurrency(): Promise<CurrencyInfo> {
|
||||
const response = await apiClient.GET("/api/currency/default");
|
||||
if (!response.data) {
|
||||
throw new Error("Failed to get default currency");
|
||||
}
|
||||
return response.data as CurrencyInfo;
|
||||
}
|
||||
|
||||
async getAllCurrencies(): Promise<CurrencyInfo[]> {
|
||||
const response = await apiClient.GET("/api/currency/all");
|
||||
if (!response.data) {
|
||||
throw new Error("Failed to get currencies");
|
||||
}
|
||||
return response.data as CurrencyInfo[];
|
||||
}
|
||||
}
|
||||
|
||||
export const currencyService = new CurrencyServiceImpl();
|
||||
async getAllCurrencies(): Promise<WhmcsCurrency[]> {
|
||||
const response = await apiClient.GET<WhmcsCurrency[]>("/api/currency/all");
|
||||
return getDataOrThrow(response, "Failed to get currencies");
|
||||
},
|
||||
};
|
||||
|
||||
@ -18,8 +18,6 @@
|
||||
"@/lib/*": ["./src/lib/*"],
|
||||
"@customer-portal/domain": ["../../packages/domain/index.ts"],
|
||||
"@customer-portal/domain/*": ["../../packages/domain/*"],
|
||||
"@customer-portal/logging": ["../../packages/logging/src"],
|
||||
"@customer-portal/logging/*": ["../../packages/logging/src/*"],
|
||||
"@customer-portal/validation": ["../../packages/validation/src"],
|
||||
"@customer-portal/validation/*": ["../../packages/validation/src/*"]
|
||||
},
|
||||
|
||||
21
env/portal-backend.env.sample
vendored
21
env/portal-backend.env.sample
vendored
@ -25,6 +25,12 @@ JWT_SECRET=CHANGE_ME
|
||||
JWT_EXPIRES_IN=7d
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
# CSRF Protection
|
||||
CSRF_TOKEN_EXPIRY=3600000
|
||||
CSRF_SECRET_KEY=CHANGE_ME_AT_LEAST_32_CHARACTERS_LONG
|
||||
CSRF_COOKIE_NAME=csrf-secret
|
||||
CSRF_HEADER_NAME=X-CSRF-Token
|
||||
|
||||
# CORS / Proxy
|
||||
CORS_ORIGIN=https://asolutions.jp
|
||||
TRUST_PROXY=true
|
||||
@ -34,6 +40,21 @@ RATE_LIMIT_TTL=60
|
||||
RATE_LIMIT_LIMIT=100
|
||||
AUTH_RATE_LIMIT_TTL=900
|
||||
AUTH_RATE_LIMIT_LIMIT=3
|
||||
AUTH_REFRESH_RATE_LIMIT_TTL=300
|
||||
AUTH_REFRESH_RATE_LIMIT_LIMIT=10
|
||||
LOGIN_RATE_LIMIT_TTL=900
|
||||
LOGIN_RATE_LIMIT_LIMIT=5
|
||||
LOGIN_CAPTCHA_AFTER_ATTEMPTS=3
|
||||
SIGNUP_RATE_LIMIT_TTL=900
|
||||
SIGNUP_RATE_LIMIT_LIMIT=5
|
||||
PASSWORD_RESET_RATE_LIMIT_TTL=900
|
||||
PASSWORD_RESET_RATE_LIMIT_LIMIT=5
|
||||
|
||||
# CAPTCHA Configuration
|
||||
AUTH_CAPTCHA_PROVIDER=none
|
||||
AUTH_CAPTCHA_SECRET=
|
||||
AUTH_CAPTCHA_THRESHOLD=0
|
||||
AUTH_CAPTCHA_ALWAYS_ON=false
|
||||
|
||||
# Validation error visibility (set true to show field-level errors to clients)
|
||||
EXPOSE_VALIDATION_ERRORS=false
|
||||
|
||||
@ -35,6 +35,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc -w --preserveWatchOutput",
|
||||
"clean": "rm -rf dist",
|
||||
"type-check": "NODE_OPTIONS=\"--max-old-space-size=2048 --max-semi-space-size=128\" tsc --project tsconfig.json --noEmit",
|
||||
"typecheck": "pnpm run type-check"
|
||||
|
||||
@ -357,7 +357,11 @@ start_apps() {
|
||||
# Prisma Studio can be started manually with: pnpm db:studio
|
||||
|
||||
# Run portal + bff in parallel with hot reload
|
||||
pnpm --parallel --filter @customer-portal/portal --filter @customer-portal/bff run dev
|
||||
pnpm --parallel \
|
||||
--filter @customer-portal/domain \
|
||||
--filter @customer-portal/validation \
|
||||
--filter @customer-portal/portal \
|
||||
--filter @customer-portal/bff run dev
|
||||
}
|
||||
|
||||
reset_env() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user