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:
barsa 2025-11-05 15:47:06 +09:00
parent 5d011c87be
commit d6f7c50e7b
40 changed files with 764 additions and 529 deletions

View File

@ -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();
}

View File

@ -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"
);
});
});

View File

@ -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, {

View File

@ -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> {

View File

@ -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);
});
});

View File

@ -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;
}
});
}

View File

@ -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);

View File

@ -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" };
}

View File

@ -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");
}
}

View File

@ -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";
}
}

View File

@ -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;

View File

@ -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();
}
}

View File

@ -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 {}

View File

@ -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;
}
}

View File

@ -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

View File

@ -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"];

View File

@ -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",

View File

@ -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;

View File

@ -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";

View File

@ -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";

View File

@ -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,

View File

@ -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[];

View File

@ -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 {

View File

@ -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);

View File

@ -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";

View File

@ -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() {

View File

@ -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>
);
}

View File

@ -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,
};
});
}

View File

@ -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 &amp; 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&apos;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&apos;ll always confirm with you before applying any
additional charges.
</p>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</>

View File

@ -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();

View File

@ -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;
}

View File

@ -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({

View File

@ -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);
}

View File

@ -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

View File

@ -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),
};

View File

@ -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");
},
};

View File

@ -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/*"]
},

View File

@ -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

View File

@ -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"

View File

@ -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() {