Refactor user management and validation integration

- Replaced UsersService with UsersFacade across various modules for improved abstraction and consistency.
- Updated validation imports to utilize the new @customer-portal/validation package, enhancing modularity.
- Removed deprecated validation files and streamlined user-related logic in controllers and services.
- Enhanced order processing by integrating field mappings for Salesforce orders, improving maintainability.
- Improved error handling and response structures in authentication and user management workflows.
This commit is contained in:
barsa 2025-11-04 13:28:36 +09:00
parent b65a49bc2f
commit 1dc8fbf36d
51 changed files with 808 additions and 509 deletions

View File

@ -4,7 +4,7 @@ import { RouterModule } from "@nestjs/core";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { ThrottlerModule } from "@nestjs/throttler";
import { ZodValidationPipe } from "nestjs-zod";
import { ZodValidationExceptionFilter } from "@bff/core/validation";
import { ZodValidationExceptionFilter } from "@customer-portal/validation/nestjs";
// Configuration
import { appConfig } from "@bff/core/config/app.config";

View File

@ -1,7 +0,0 @@
/**
* CLEAN Validation Module
* Consolidated validation patterns using nestjs-zod
*/
export { ZodValidationPipe, createZodDto, ZodValidationException } from "nestjs-zod";
export { ZodValidationExceptionFilter } from "./zod-validation.filter";

View File

@ -20,7 +20,7 @@ type PrismaUserRaw = Parameters<typeof CustomerProviders.Portal.mapPrismaUserToU
* then uses the domain portal provider mapper to get UserAuth.
*
* NOTE: UserAuth contains ONLY auth state. Profile data comes from WHMCS.
* For complete user profile, use UsersService.getProfile() which fetches from WHMCS.
* For complete user profile, use UsersFacade.getProfile() which fetches from WHMCS.
*/
export function mapPrismaUserToDomain(user: PrismaUser): UserAuth {
// Convert @prisma/client User to domain PrismaUserRaw

View File

@ -95,7 +95,11 @@ export class SalesforceOrderService {
);
// Use domain mapper - single transformation!
return OrderProviders.Salesforce.transformSalesforceOrderDetails(order, orderItems);
return OrderProviders.Salesforce.transformSalesforceOrderDetails(
order,
orderItems,
this.orderFieldMap.fields
);
} catch (error: unknown) {
this.logger.error("Failed to fetch order with items", {
error: getErrorMessage(error),
@ -216,7 +220,8 @@ export class SalesforceOrderService {
.map(order =>
OrderProviders.Salesforce.transformSalesforceOrderSummary(
order,
itemsByOrder[order.Id] ?? []
itemsByOrder[order.Id] ?? [],
this.orderFieldMap.fields
)
);
} catch (error: unknown) {

View File

@ -2,7 +2,7 @@ import { Injectable, UnauthorizedException, BadRequestException, Inject } from "
import { JwtService } from "@nestjs/jwt";
import { ConfigService } from "@nestjs/config";
import * as bcrypt from "bcrypt";
import { UsersService } from "@bff/modules/users/users.service";
import { UsersFacade } from "@bff/modules/users/application/users.facade";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
@ -36,7 +36,7 @@ export class AuthFacade {
private readonly LOCKOUT_DURATION_MINUTES = 15;
constructor(
private readonly usersService: UsersService,
private readonly usersFacade: UsersFacade,
private readonly mappingsService: MappingsService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
@ -70,7 +70,7 @@ export class AuthFacade {
// Check database
try {
await this.usersService.findByEmail("health-check@test.com");
await this.usersFacade.findByEmail("health-check@test.com");
health.database = true;
} catch (error) {
this.logger.debug("Database health check failed", { error: getErrorMessage(error) });
@ -121,7 +121,7 @@ export class AuthFacade {
await this.authRateLimitService.clearLoginAttempts(request);
}
// Update last login time and reset failed attempts
await this.usersService.update(user.id, {
await this.usersFacade.update(user.id, {
lastLoginAt: new Date(),
failedLoginAttempts: 0,
lockedUntil: null,
@ -136,7 +136,7 @@ export class AuthFacade {
true
);
const prismaUser = await this.usersService.findByIdInternal(user.id);
const prismaUser = await this.usersFacade.findByIdInternal(user.id);
if (!prismaUser) {
throw new UnauthorizedException("User record missing");
}
@ -180,7 +180,7 @@ export class AuthFacade {
password: string,
_request?: Request
): Promise<{ id: string; email: string; role: string } | null> {
const user = await this.usersService.findByEmailInternal(email);
const user = await this.usersFacade.findByEmailInternal(email);
if (!user) {
await this.auditService.logAuthEvent(
@ -263,7 +263,7 @@ export class AuthFacade {
isAccountLocked = true;
}
await this.usersService.update(user.id, {
await this.usersFacade.update(user.id, {
failedLoginAttempts: newFailedAttempts,
lockedUntil,
});
@ -383,7 +383,7 @@ export class AuthFacade {
let needsPasswordSet = false;
try {
portalUser = await this.usersService.findByEmailInternal(normalized);
portalUser = await this.usersFacade.findByEmailInternal(normalized);
if (portalUser) {
mapped = await this.mappingsService.hasMapping(portalUser.id);
needsPasswordSet = !portalUser.passwordHash;

View File

@ -11,7 +11,7 @@ import { Logger } from "nestjs-pino";
import { randomBytes, createHash } from "crypto";
import type { AuthTokens } from "@customer-portal/domain/auth";
import type { User } from "@customer-portal/domain/customer";
import { UsersService } from "@bff/modules/users/users.service";
import { UsersFacade } from "@bff/modules/users/application/users.facade";
import { mapPrismaUserToDomain } from "@bff/infra/mappers";
export interface RefreshTokenPayload {
@ -53,7 +53,7 @@ export class AuthTokenService {
private readonly configService: ConfigService,
@Inject("REDIS_CLIENT") private readonly redis: Redis,
@Inject(Logger) private readonly logger: Logger,
private readonly usersService: UsersService
private readonly usersFacade: UsersFacade
) {
this.allowRedisFailOpen =
this.configService.get("AUTH_ALLOW_REDIS_TOKEN_FAILOPEN", "false") === "true";
@ -259,25 +259,20 @@ export class AuthTokenService {
}
// Get user info from database (using internal method to get role)
const prismaUser = await this.usersService.findByIdInternal(payload.userId);
if (!prismaUser) {
const user = await this.usersFacade.findByIdInternal(payload.userId);
if (!user) {
this.logger.warn("User not found during token refresh", { userId: payload.userId });
throw new UnauthorizedException("User not found");
}
// Convert to the format expected by generateTokenPair
const user = {
id: prismaUser.id,
email: prismaUser.email,
role: prismaUser.role || "USER",
};
const userProfile = mapPrismaUserToDomain(user);
// Invalidate current refresh token
await this.redis.del(`${this.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`);
// Generate new token pair
const newTokenPair = await this.generateTokenPair(user, deviceInfo);
const userProfile = mapPrismaUserToDomain(prismaUser);
this.logger.debug("Refreshed token pair", { userId: payload.userId });

View File

@ -4,7 +4,7 @@ import { JwtService } from "@nestjs/jwt";
import { Logger } from "nestjs-pino";
import * as bcrypt from "bcrypt";
import type { Request } from "express";
import { UsersService } from "@bff/modules/users/users.service";
import { UsersFacade } from "@bff/modules/users/application/users.facade";
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service";
import { EmailService } from "@bff/infra/email/email.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
@ -20,7 +20,7 @@ import { mapPrismaUserToDomain } from "@bff/infra/mappers";
@Injectable()
export class PasswordWorkflowService {
constructor(
private readonly usersService: UsersService,
private readonly usersFacade: UsersFacade,
private readonly auditService: AuditService,
private readonly configService: ConfigService,
private readonly emailService: EmailService,
@ -31,7 +31,7 @@ export class PasswordWorkflowService {
) {}
async checkPasswordNeeded(email: string) {
const user = await this.usersService.findByEmailInternal(email);
const user = await this.usersFacade.findByEmailInternal(email);
if (!user) {
return { needsPasswordSet: false, userExists: false };
}
@ -44,7 +44,7 @@ export class PasswordWorkflowService {
}
async setPassword(email: string, password: string) {
const user = await this.usersService.findByEmailInternal(email);
const user = await this.usersFacade.findByEmailInternal(email);
if (!user) {
throw new UnauthorizedException("User not found");
}
@ -57,8 +57,8 @@ export class PasswordWorkflowService {
const saltRounds =
typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig;
const passwordHash = await bcrypt.hash(password, saltRounds);
await this.usersService.update(user.id, { passwordHash });
const prismaUser = await this.usersService.findByIdInternal(user.id);
await this.usersFacade.update(user.id, { passwordHash });
const prismaUser = await this.usersFacade.findByIdInternal(user.id);
if (!prismaUser) {
throw new Error("Failed to load user after password setup");
}
@ -78,7 +78,7 @@ export class PasswordWorkflowService {
if (request) {
await this.authRateLimitService.consumePasswordReset(request);
}
const user = await this.usersService.findByEmailInternal(email);
const user = await this.usersFacade.findByEmailInternal(email);
if (!user) {
return;
}
@ -119,7 +119,7 @@ export class PasswordWorkflowService {
throw new BadRequestException("Invalid token");
}
const prismaUser = await this.usersService.findByIdInternal(payload.sub);
const prismaUser = await this.usersFacade.findByIdInternal(payload.sub);
if (!prismaUser) throw new BadRequestException("Invalid token");
const saltRoundsConfig = this.configService.get<string | number>("BCRYPT_ROUNDS", 12);
@ -127,8 +127,8 @@ export class PasswordWorkflowService {
typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig;
const passwordHash = await bcrypt.hash(newPassword, saltRounds);
await this.usersService.update(prismaUser.id, { passwordHash });
const freshUser = await this.usersService.findByIdInternal(prismaUser.id);
await this.usersFacade.update(prismaUser.id, { passwordHash });
const freshUser = await this.usersFacade.findByIdInternal(prismaUser.id);
if (!freshUser) {
throw new Error("Failed to load user after password reset");
}
@ -154,7 +154,7 @@ export class PasswordWorkflowService {
data: ChangePasswordRequest,
request?: Request
): Promise<PasswordChangeResult> {
const user = await this.usersService.findByIdInternal(userId);
const user = await this.usersFacade.findByIdInternal(userId);
if (!user) {
throw new UnauthorizedException("User not found");
@ -188,8 +188,8 @@ export class PasswordWorkflowService {
typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig;
const passwordHash = await bcrypt.hash(newPassword, saltRounds);
await this.usersService.update(user.id, { passwordHash });
const prismaUser = await this.usersService.findByIdInternal(user.id);
await this.usersFacade.update(user.id, { passwordHash });
const prismaUser = await this.usersFacade.findByIdInternal(user.id);
if (!prismaUser) {
throw new Error("Failed to load user after password change");
}

View File

@ -10,7 +10,7 @@ import { Logger } from "nestjs-pino";
import * as bcrypt from "bcrypt";
import type { Request } from "express";
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service";
import { UsersService } from "@bff/modules/users/users.service";
import { UsersFacade } from "@bff/modules/users/application/users.facade";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
@ -35,7 +35,7 @@ type _SanitizedPrismaUser = Omit<
@Injectable()
export class SignupWorkflowService {
constructor(
private readonly usersService: UsersService,
private readonly usersFacade: UsersFacade,
private readonly mappingsService: MappingsService,
private readonly whmcsService: WhmcsService,
private readonly salesforceService: SalesforceService,
@ -153,7 +153,7 @@ export class SignupWorkflowService {
gender,
} = signupData;
const existingUser = await this.usersService.findByEmailInternal(email);
const existingUser = await this.usersFacade.findByEmailInternal(email);
if (existingUser) {
const mapped = await this.mappingsService.hasMapping(existingUser.id);
const message = mapped
@ -330,7 +330,7 @@ export class SignupWorkflowService {
throw new BadRequestException(`Failed to create user account: ${getErrorMessage(dbError)}`);
}
const freshUser = await this.usersService.findByIdInternal(createdUserId);
const freshUser = await this.usersFacade.findByIdInternal(createdUserId);
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
@ -340,7 +340,7 @@ export class SignupWorkflowService {
true
);
const prismaUser = freshUser ?? (await this.usersService.findByIdInternal(createdUserId));
const prismaUser = freshUser ?? (await this.usersFacade.findByIdInternal(createdUserId));
if (!prismaUser) {
throw new Error("Failed to load created user");
@ -395,20 +395,20 @@ export class SignupWorkflowService {
whmcs: { clientExists: false },
};
const portalUser = await this.usersService.findByEmailInternal(normalizedEmail);
if (portalUser) {
const portalUserAuth = await this.usersFacade.findByEmailInternal(normalizedEmail);
if (portalUserAuth) {
result.portal.userExists = true;
const mapped = await this.mappingsService.hasMapping(portalUser.id);
const mapped = await this.mappingsService.hasMapping(portalUserAuth.id);
if (mapped) {
result.nextAction = "login";
result.messages.push("An account already exists. Please sign in.");
return result;
}
result.portal.needsPasswordSet = !portalUser.passwordHash;
result.nextAction = portalUser.passwordHash ? "login" : "fix_input";
result.portal.needsPasswordSet = !portalUserAuth.passwordHash;
result.nextAction = portalUserAuth.passwordHash ? "login" : "fix_input";
result.messages.push(
portalUser.passwordHash
portalUserAuth.passwordHash
? "An account exists without billing link. Please sign in to continue setup."
: "An account exists and needs password setup. Please set a password to continue."
);

View File

@ -6,7 +6,7 @@ import {
UnauthorizedException,
} from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { UsersService } from "@bff/modules/users/users.service";
import { UsersFacade } from "@bff/modules/users/application/users.facade";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
@ -18,7 +18,7 @@ import type { User } from "@customer-portal/domain/customer";
@Injectable()
export class WhmcsLinkWorkflowService {
constructor(
private readonly usersService: UsersService,
private readonly usersFacade: UsersFacade,
private readonly mappingsService: MappingsService,
private readonly whmcsService: WhmcsService,
private readonly salesforceService: SalesforceService,
@ -26,7 +26,7 @@ export class WhmcsLinkWorkflowService {
) {}
async linkWhmcsUser(email: string, password: string) {
const existingUser = await this.usersService.findByEmailInternal(email);
const existingUser = await this.usersFacade.findByEmailInternal(email);
if (existingUser) {
if (!existingUser.passwordHash) {
this.logger.log("User exists but has no password - allowing password setup to continue", {
@ -137,7 +137,7 @@ export class WhmcsLinkWorkflowService {
);
}
const createdUser = await this.usersService.create({
const createdUser = await this.usersFacade.create({
email,
passwordHash: null,
emailVerified: true,
@ -149,7 +149,7 @@ export class WhmcsLinkWorkflowService {
sfAccountId: sfAccount.id,
});
const prismaUser = await this.usersService.findByIdInternal(createdUser.id);
const prismaUser = await this.usersFacade.findByIdInternal(createdUser.id);
if (!prismaUser) {
throw new Error("Failed to load newly linked user");
}

View File

@ -18,7 +18,7 @@ import { AuthThrottleGuard } from "./guards/auth-throttle.guard";
import { FailedLoginThrottleGuard } from "./guards/failed-login-throttle.guard";
import { LoginResultInterceptor } from "./interceptors/login-result.interceptor";
import { Public } from "../../decorators/public.decorator";
import { ZodValidationPipe } from "@bff/core/validation";
import { ZodValidationPipe } from "@customer-portal/validation/nestjs";
// Import Zod schemas from domain
import {
@ -33,6 +33,8 @@ import {
ssoLinkRequestSchema,
checkPasswordNeededRequestSchema,
refreshTokenRequestSchema,
checkPasswordNeededResponseSchema,
linkWhmcsResponseSchema,
type SignupRequest,
type PasswordResetRequest,
type ResetPasswordRequest,
@ -216,7 +218,8 @@ export class AuthController {
@Throttle({ default: { limit: 5, ttl: 600 } }) // 5 attempts per 10 minutes per IP (industry standard)
@UsePipes(new ZodValidationPipe(linkWhmcsRequestSchema))
async linkWhmcs(@Body() linkData: LinkWhmcsRequest, @Req() _req: Request) {
return this.authFacade.linkWhmcsUser(linkData);
const result = await this.authFacade.linkWhmcsUser(linkData);
return linkWhmcsResponseSchema.parse(result);
}
@Public()
@ -239,7 +242,8 @@ export class AuthController {
@UsePipes(new ZodValidationPipe(checkPasswordNeededRequestSchema))
@HttpCode(200)
async checkPasswordNeeded(@Body() data: CheckPasswordNeededRequest) {
return this.authFacade.checkPasswordNeeded(data.email);
const response = await this.authFacade.checkPasswordNeeded(data.email);
return checkPasswordNeededResponseSchema.parse(response);
}
@Public()

View File

@ -3,7 +3,7 @@ import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { ConfigService } from "@nestjs/config";
import type { UserAuth } from "@customer-portal/domain/customer";
import { UsersService } from "@bff/modules/users/users.service";
import { UsersFacade } from "@bff/modules/users/application/users.facade";
import { mapPrismaUserToDomain } from "@bff/infra/mappers";
import type { Request } from "express";
@ -20,7 +20,7 @@ const cookieExtractor = (req: Request): string | null => {
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private configService: ConfigService,
private readonly usersService: UsersService
private readonly usersFacade: UsersFacade
) {
const jwtSecret = configService.get<string>("JWT_SECRET");
if (!jwtSecret) {
@ -65,7 +65,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
throw new UnauthorizedException("Token missing expiration claim");
}
const prismaUser = await this.usersService.findByIdInternal(payload.sub);
const prismaUser = await this.usersFacade.findByIdInternal(payload.sub);
if (!prismaUser) {
throw new UnauthorizedException("User not found");

View File

@ -13,7 +13,7 @@ import {
import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { ZodValidationPipe } from "@bff/core/validation";
import { ZodValidationPipe } from "@customer-portal/validation/nestjs";
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
import type {

View File

@ -1,120 +1,32 @@
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
export interface OrderFieldMap {
order: {
type: string;
activationType: string;
activationScheduledAt: string;
activationStatus: string;
activationErrorCode: string;
activationErrorMessage: string;
activationLastAttemptAt: string;
internetPlanTier: string;
installationType: string;
weekendInstall: string;
accessMode: string;
hikariDenwa: string;
vpnRegion: string;
simType: string;
simVoiceMail: string;
simCallWaiting: string;
eid: string;
whmcsOrderId: string;
addressChanged: string;
billingStreet: string;
billingCity: string;
billingState: string;
billingPostalCode: string;
billingCountry: string;
mnpApplication: string;
mnpReservation: string;
mnpExpiry: string;
mnpPhone: string;
mvnoAccountNumber: string;
portingDateOfBirth: string;
portingFirstName: string;
portingLastName: string;
portingFirstNameKatakana: string;
portingLastNameKatakana: string;
portingGender: string;
};
orderItem: {
billingCycle: string;
whmcsServiceId: string;
};
product: {
sku: string;
itemClass: string;
billingCycle: string;
whmcsProductId: string;
internetOfferingType: string;
internetPlanTier: string;
vpnRegion: string;
};
}
import {
createSalesforceOrderFieldMap,
defaultSalesforceOrderFieldMap,
type PartialSalesforceOrderFieldMap,
type SalesforceOrderFieldMap,
} from "@customer-portal/domain/orders";
const unique = <T>(values: T[]): T[] => Array.from(new Set(values));
const SECTION_PREFIX: Record<keyof SalesforceOrderFieldMap, string> = {
order: "ORDER",
orderItem: "ORDER_ITEM",
product: "PRODUCT",
};
@Injectable()
export class OrderFieldMapService {
readonly fields: OrderFieldMap;
readonly fields: SalesforceOrderFieldMap;
constructor(private readonly config: ConfigService) {
const resolve = (key: string) => this.config.get<string>(key, { infer: true }) ?? key;
this.fields = {
order: {
type: resolve("ORDER_TYPE_FIELD"),
activationType: resolve("ORDER_ACTIVATION_TYPE_FIELD"),
activationScheduledAt: resolve("ORDER_ACTIVATION_SCHEDULED_AT_FIELD"),
activationStatus: resolve("ORDER_ACTIVATION_STATUS_FIELD"),
activationErrorCode: resolve("ORDER_ACTIVATION_ERROR_CODE_FIELD"),
activationErrorMessage: resolve("ORDER_ACTIVATION_ERROR_MESSAGE_FIELD"),
activationLastAttemptAt: resolve("ORDER_ACTIVATION_LAST_ATTEMPT_AT_FIELD"),
internetPlanTier: resolve("ORDER_INTERNET_PLAN_TIER_FIELD"),
installationType: resolve("ORDER_INSTALLATION_TYPE_FIELD"),
weekendInstall: resolve("ORDER_WEEKEND_INSTALL_FIELD"),
accessMode: resolve("ORDER_ACCESS_MODE_FIELD"),
hikariDenwa: resolve("ORDER_HIKARI_DENWA_FIELD"),
vpnRegion: resolve("ORDER_VPN_REGION_FIELD"),
simType: resolve("ORDER_SIM_TYPE_FIELD"),
simVoiceMail: resolve("ORDER_SIM_VOICE_MAIL_FIELD"),
simCallWaiting: resolve("ORDER_SIM_CALL_WAITING_FIELD"),
eid: resolve("ORDER_EID_FIELD"),
whmcsOrderId: resolve("ORDER_WHMCS_ORDER_ID_FIELD"),
addressChanged: resolve("ORDER_ADDRESS_CHANGED_FIELD"),
billingStreet: resolve("ORDER_BILLING_STREET_FIELD"),
billingCity: resolve("ORDER_BILLING_CITY_FIELD"),
billingState: resolve("ORDER_BILLING_STATE_FIELD"),
billingPostalCode: resolve("ORDER_BILLING_POSTAL_CODE_FIELD"),
billingCountry: resolve("ORDER_BILLING_COUNTRY_FIELD"),
mnpApplication: resolve("ORDER_MNP_APPLICATION_FIELD"),
mnpReservation: resolve("ORDER_MNP_RESERVATION_FIELD"),
mnpExpiry: resolve("ORDER_MNP_EXPIRY_FIELD"),
mnpPhone: resolve("ORDER_MNP_PHONE_FIELD"),
mvnoAccountNumber: resolve("ORDER_MVNO_ACCOUNT_NUMBER_FIELD"),
portingDateOfBirth: resolve("ORDER_PORTING_DOB_FIELD"),
portingFirstName: resolve("ORDER_PORTING_FIRST_NAME_FIELD"),
portingLastName: resolve("ORDER_PORTING_LAST_NAME_FIELD"),
portingFirstNameKatakana: resolve("ORDER_PORTING_FIRST_NAME_KATAKANA_FIELD"),
portingLastNameKatakana: resolve("ORDER_PORTING_LAST_NAME_KATAKANA_FIELD"),
portingGender: resolve("ORDER_PORTING_GENDER_FIELD"),
},
orderItem: {
billingCycle: resolve("ORDER_ITEM_BILLING_CYCLE_FIELD"),
whmcsServiceId: resolve("ORDER_ITEM_WHMCS_SERVICE_ID_FIELD"),
},
product: {
sku: resolve("PRODUCT_SKU_FIELD"),
itemClass: resolve("PRODUCT_ITEM_CLASS_FIELD"),
billingCycle: resolve("PRODUCT_BILLING_CYCLE_FIELD"),
whmcsProductId: resolve("PRODUCT_WHMCS_PRODUCT_ID_FIELD"),
internetOfferingType: resolve("PRODUCT_INTERNET_OFFERING_TYPE_FIELD"),
internetPlanTier: resolve("PRODUCT_INTERNET_PLAN_TIER_FIELD"),
vpnRegion: resolve("PRODUCT_VPN_REGION_FIELD"),
},
const overrides: PartialSalesforceOrderFieldMap = {
order: this.resolveSection("order"),
orderItem: this.resolveSection("orderItem"),
product: this.resolveSection("product"),
};
this.fields = createSalesforceOrderFieldMap(overrides);
}
buildOrderSelectFields(additional: string[] = []): string[] {
@ -189,4 +101,32 @@ export class OrderFieldMapService {
return unique([...base, ...additional]);
}
private resolveSection<Section extends keyof SalesforceOrderFieldMap>(
section: Section
): Partial<SalesforceOrderFieldMap[Section]> {
const defaults = defaultSalesforceOrderFieldMap[section];
const resolvedEntries = Object.entries(defaults).map(([key, defaultValue]) => {
const envKey = buildEnvKey(section, key);
const resolved = this.config.get<string>(envKey, { infer: true });
return [key, resolved ?? defaultValue];
});
return Object.fromEntries(resolvedEntries) as Partial<SalesforceOrderFieldMap[Section]>;
}
}
function buildEnvKey<Section extends keyof SalesforceOrderFieldMap>(
section: Section,
key: string
): string {
const prefix = SECTION_PREFIX[section];
return `${prefix}_${toScreamingSnakeCase(key)}_FIELD`;
}
function toScreamingSnakeCase(value: string): string {
return value
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
.replace(/[-\s]+/g, "_")
.toUpperCase();
}

View File

@ -1,6 +1,6 @@
import { Body, Controller, Post, Request, UsePipes, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { ZodValidationPipe } from "@bff/core/validation";
import { ZodValidationPipe } from "@customer-portal/validation/nestjs";
import { CheckoutService } from "../services/checkout.service";
import {
CheckoutCart,

View File

@ -3,7 +3,7 @@ import { Throttle, ThrottlerGuard } from "@nestjs/throttler";
import { OrderOrchestrator } from "./services/order-orchestrator.service";
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
import { Logger } from "nestjs-pino";
import { ZodValidationPipe } from "@bff/core/validation";
import { ZodValidationPipe } from "@customer-portal/validation/nestjs";
import {
createOrderRequestSchema,
orderCreateResponseSchema,

View File

@ -1,7 +1,7 @@
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import type { OrderBusinessValidation, UserMapping } from "@customer-portal/domain/orders";
import { UsersService } from "@bff/modules/users/users.service";
import { UsersFacade } from "@bff/modules/users/application/users.facade";
import { OrderFieldMapService } from "@bff/modules/orders/config/order-field-map.service";
function assignIfString(target: Record<string, unknown>, key: string, value: unknown): void {
@ -17,7 +17,7 @@ function assignIfString(target: Record<string, unknown>, key: string, value: unk
export class OrderBuilder {
constructor(
@Inject(Logger) private readonly logger: Logger,
private readonly usersService: UsersService,
private readonly usersFacade: UsersFacade,
private readonly orderFieldMap: OrderFieldMapService
) {}
@ -121,7 +121,7 @@ export class OrderBuilder {
fieldNames: OrderFieldMapService["fields"]["order"]
): Promise<void> {
try {
const profile = await this.usersService.getProfile(userId);
const profile = await this.usersFacade.getProfile(userId);
const address = profile.address;
const orderAddress = (body.configurations as Record<string, unknown>)?.address as
| Record<string, unknown>

View File

@ -1,7 +1,7 @@
import { Body, Controller, Post, Request, UsePipes, Headers } from "@nestjs/common";
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
import { SimOrderActivationService } from "./sim-order-activation.service";
import { ZodValidationPipe } from "@bff/core/validation";
import { ZodValidationPipe } from "@customer-portal/validation/nestjs";
import {
simOrderActivationRequestSchema,
type SimOrderActivationRequest,

View File

@ -39,7 +39,7 @@ import {
type SimFeaturesRequest,
type SimReissueRequest,
} from "@customer-portal/domain/sim";
import { ZodValidationPipe } from "@bff/core/validation";
import { ZodValidationPipe } from "@customer-portal/validation/nestjs";
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
const subscriptionInvoiceQuerySchema = createPaginationSchema({

View File

@ -0,0 +1,123 @@
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import type { User as PrismaUser } from "@prisma/client";
import type { User } from "@customer-portal/domain/customer";
import type { Address } from "@customer-portal/domain/customer";
import type { DashboardSummary } from "@customer-portal/domain/dashboard";
import type { UpdateCustomerProfileRequest } from "@customer-portal/domain/auth";
import { UserAuthRepository } from "../infra/user-auth.repository";
import { UserProfileService } from "../infra/user-profile.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
type AuthUpdateData = Partial<
Pick<PrismaUser, "passwordHash" | "failedLoginAttempts" | "lastLoginAt" | "lockedUntil">
>;
@Injectable()
export class UsersFacade {
constructor(
private readonly authRepository: UserAuthRepository,
private readonly profileService: UserProfileService,
@Inject(Logger) private readonly logger: Logger
) {}
async findByEmail(email: string): Promise<User | null> {
const user = await this.authRepository.findByEmail(email);
if (!user) {
return null;
}
try {
return await this.profileService.getProfile(user.id);
} catch (error) {
this.logger.error("Failed to build profile by email", {
email,
error: getErrorMessage(error),
});
throw error;
}
}
async findByEmailInternal(email: string): Promise<PrismaUser | null> {
return this.authRepository.findByEmail(email);
}
async findById(id: string): Promise<User | null> {
return this.profileService.findById(id);
}
async findByIdInternal(id: string): Promise<PrismaUser | null> {
return this.authRepository.findById(id);
}
async getProfile(userId: string): Promise<User> {
return this.profileService.getProfile(userId);
}
async getAddress(userId: string): Promise<Address | null> {
return this.profileService.getAddress(userId);
}
async updateAddress(userId: string, update: Partial<Address>): Promise<Address> {
return this.profileService.updateAddress(userId, update);
}
async updateProfile(userId: string, update: UpdateCustomerProfileRequest): Promise<User> {
return this.profileService.updateProfile(userId, update);
}
async getUserSummary(userId: string): Promise<DashboardSummary> {
return this.profileService.getUserSummary(userId);
}
async create(userData: Partial<PrismaUser>): Promise<User> {
try {
const createdUser = await this.authRepository.create(userData);
return this.profileService.getProfile(createdUser.id);
} catch (error) {
this.logger.error("Failed to create user", {
error: getErrorMessage(error),
});
throw error;
}
}
async update(id: string, data: AuthUpdateData): Promise<User> {
const sanitized = this.sanitizeAuthUpdate(data);
try {
await this.authRepository.updateAuthState(id, sanitized);
return this.profileService.getProfile(id);
} catch (error) {
this.logger.error("Failed to update user auth state", {
userId: id,
error: getErrorMessage(error),
});
throw error;
}
}
private sanitizeAuthUpdate(data: AuthUpdateData): AuthUpdateData {
if (!data) {
throw new BadRequestException("Update payload is required");
}
const sanitized: AuthUpdateData = {};
if (data.passwordHash !== undefined) {
sanitized.passwordHash = data.passwordHash;
}
if (data.failedLoginAttempts !== undefined) {
sanitized.failedLoginAttempts = data.failedLoginAttempts;
}
if (data.lastLoginAt !== undefined) {
sanitized.lastLoginAt = data.lastLoginAt;
}
if (data.lockedUntil !== undefined) {
sanitized.lockedUntil = data.lockedUntil;
}
return sanitized;
}
}

View File

@ -0,0 +1,66 @@
import { Injectable, BadRequestException } from "@nestjs/common";
import type { User as PrismaUser } from "@prisma/client";
import { PrismaService } from "@bff/infra/database/prisma.service";
import { normalizeAndValidateEmail, validateUuidV4OrThrow } from "@customer-portal/domain/common";
import { getErrorMessage } from "@bff/core/utils/error.util";
type AuthUpdatableFields = Pick<
PrismaUser,
"passwordHash" | "failedLoginAttempts" | "lastLoginAt" | "lockedUntil"
>;
@Injectable()
export class UserAuthRepository {
constructor(private readonly prisma: PrismaService) {}
async findByEmail(email: string): Promise<PrismaUser | null> {
const normalized = normalizeAndValidateEmail(email);
try {
return await this.prisma.user.findUnique({ where: { email: normalized } });
} catch (error) {
throw new BadRequestException(`Unable to retrieve user by email: ${getErrorMessage(error)}`);
}
}
async findById(id: string): Promise<PrismaUser | null> {
const validId = validateUuidV4OrThrow(id);
try {
return await this.prisma.user.findUnique({ where: { id: validId } });
} catch (error) {
throw new BadRequestException(`Unable to retrieve user by id: ${getErrorMessage(error)}`);
}
}
async create(data: Partial<PrismaUser>): Promise<PrismaUser> {
if (!data.email) {
throw new BadRequestException("Email is required to create a user");
}
const normalizedEmail = normalizeAndValidateEmail(data.email);
try {
return await this.prisma.user.create({
data: {
...data,
email: normalizedEmail,
},
});
} catch (error) {
throw new BadRequestException(`Unable to create user: ${getErrorMessage(error)}`);
}
}
async updateAuthState(id: string, data: Partial<AuthUpdatableFields>): Promise<void> {
const validId = validateUuidV4OrThrow(id);
try {
await this.prisma.user.update({
where: { id: validId },
data,
});
} catch (error) {
throw new BadRequestException(`Unable to update user auth state: ${getErrorMessage(error)}`);
}
}
}

View File

@ -2,12 +2,6 @@ import { Injectable, Inject, NotFoundException, BadRequestException } from "@nes
import { Logger } from "nestjs-pino";
import type { User as PrismaUser } from "@prisma/client";
import { getErrorMessage } from "@bff/core/utils/error.util";
import { normalizeAndValidateEmail, validateUuidV4OrThrow } from "@customer-portal/domain/common";
import { PrismaService } from "@bff/infra/database/prisma.service";
import {
updateCustomerProfileRequestSchema,
type UpdateCustomerProfileRequest,
} from "@customer-portal/domain/auth";
import {
Providers as CustomerProviders,
addressSchema,
@ -15,146 +9,53 @@ import {
type Address,
type User,
} from "@customer-portal/domain/customer";
import {
updateCustomerProfileRequestSchema,
type UpdateCustomerProfileRequest,
} from "@customer-portal/domain/auth";
import type { Subscription } from "@customer-portal/domain/subscriptions";
import type { Invoice } from "@customer-portal/domain/billing";
import type { Activity, DashboardSummary, NextInvoice } from "@customer-portal/domain/dashboard";
import { dashboardSummarySchema } from "@customer-portal/domain/dashboard";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
// Use a subset of PrismaUser for auth-related updates only
type UserUpdateData = Partial<
Pick<PrismaUser, "passwordHash" | "failedLoginAttempts" | "lastLoginAt" | "lockedUntil">
>;
import { validateUuidV4OrThrow } from "@customer-portal/domain/common";
import { UserAuthRepository } from "./user-auth.repository";
@Injectable()
export class UsersService {
export class UserProfileService {
constructor(
private prisma: PrismaService,
private whmcsService: WhmcsService,
private salesforceService: SalesforceService,
private mappingsService: MappingsService,
private readonly userAuthRepository: UserAuthRepository,
private readonly mappingsService: MappingsService,
private readonly whmcsService: WhmcsService,
private readonly salesforceService: SalesforceService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Find user by email - returns authenticated user with full profile from WHMCS
*/
async findByEmail(email: string): Promise<User | null> {
const validEmail = normalizeAndValidateEmail(email);
try {
const user = await this.prisma.user.findUnique({
where: { email: validEmail },
});
if (!user) return null;
// Return full profile with WHMCS data
return this.getProfile(user.id);
} catch (error) {
this.logger.error("Failed to find user by email", {
error: getErrorMessage(error),
});
throw new BadRequestException("Unable to retrieve user profile");
}
}
// Internal method for auth service - returns raw user with sensitive fields
async findByEmailInternal(email: string): Promise<PrismaUser | null> {
const validEmail = normalizeAndValidateEmail(email);
try {
return await this.prisma.user.findUnique({
where: { email: validEmail },
});
} catch (error) {
this.logger.error("Failed to find user by email (internal)", {
error: getErrorMessage(error),
});
throw new BadRequestException("Unable to retrieve user information");
}
}
// Internal method for auth service - returns raw user by ID with sensitive fields
async findByIdInternal(id: string): Promise<PrismaUser | null> {
const validId = validateUuidV4OrThrow(id);
try {
return await this.prisma.user.findUnique({ where: { id: validId } });
} catch (error) {
this.logger.error("Failed to find user by ID (internal)", {
error: getErrorMessage(error),
});
throw new BadRequestException("Unable to retrieve user information");
}
}
/**
* Get user profile - primary method for fetching authenticated user with full WHMCS data
*/
async findById(id: string): Promise<User | null> {
const validId = validateUuidV4OrThrow(id);
try {
const user = await this.prisma.user.findUnique({
where: { id: validId },
});
if (!user) return null;
return await this.getProfile(validId);
} catch (error) {
this.logger.error("Failed to find user by ID", {
error: getErrorMessage(error),
});
throw new BadRequestException("Unable to retrieve user profile");
}
}
/**
* Get complete customer profile from WHMCS (single source of truth)
* Includes profile fields + address + auth state
*/
async getProfile(userId: string): Promise<User> {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new NotFoundException("User not found");
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) {
throw new NotFoundException("WHMCS client mapping not found");
}
try {
// Get WHMCS client data (source of truth for profile)
const whmcsClient = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
// Map Prisma user to UserAuth
const userAuth = CustomerProviders.Portal.mapPrismaUserToUserAuth(user);
return combineToUser(userAuth, whmcsClient);
} catch (error) {
this.logger.error("Failed to fetch client profile from WHMCS", {
error: getErrorMessage(error),
userId,
whmcsClientId: mapping.whmcsClientId,
});
throw new BadRequestException("Unable to retrieve customer profile from billing system");
}
}
/**
* Get only the customer's address information
*/
async getAddress(userId: string): Promise<Address | null> {
async findById(userId: string): Promise<User | null> {
const validId = validateUuidV4OrThrow(userId);
const profile = await this.getProfile(validId);
const user = await this.userAuthRepository.findById(validId);
if (!user) {
return null;
}
return this.getProfileForUser(user);
}
async getProfile(userId: string): Promise<User> {
const validId = validateUuidV4OrThrow(userId);
const user = await this.userAuthRepository.findById(validId);
if (!user) {
throw new NotFoundException("User not found");
}
return this.getProfileForUser(user);
}
async getAddress(userId: string): Promise<Address | null> {
const profile = await this.getProfile(userId);
return profile.address ?? null;
}
/**
* Update customer address in WHMCS
*/
async updateAddress(userId: string, addressUpdate: Partial<Address>): Promise<Address> {
const validId = validateUuidV4OrThrow(userId);
const parsed = addressSchema.partial().parse(addressUpdate ?? {});
@ -206,56 +107,6 @@ export class UsersService {
}
}
/**
* Create user (auth state only in portal DB)
*/
async create(userData: Partial<PrismaUser>): Promise<User> {
const validEmail = normalizeAndValidateEmail(userData.email!);
try {
const normalizedData = { ...userData, email: validEmail };
const createdUser = await this.prisma.user.create({
data: normalizedData,
});
// Return full profile from WHMCS
return this.getProfile(createdUser.id);
} catch (error) {
this.logger.error("Failed to create user", {
error: getErrorMessage(error),
});
throw new BadRequestException("Unable to create user account");
}
}
/**
* Update user auth state (password, login attempts, etc.)
* For profile updates, use updateProfile instead
*/
async update(id: string, userData: UserUpdateData): Promise<User> {
const validId = validateUuidV4OrThrow(id);
const sanitizedData = this.sanitizeUserData(userData);
try {
await this.prisma.user.update({
where: { id: validId },
data: sanitizedData,
});
// Return fresh profile from WHMCS
return this.getProfile(validId);
} catch (error) {
this.logger.error("Failed to update user", {
error: getErrorMessage(error),
});
throw new BadRequestException("Unable to update user information");
}
}
/**
* Update customer profile in WHMCS (single source of truth)
* Can update profile fields AND/OR address fields in one call
*/
async updateProfile(userId: string, update: UpdateCustomerProfileRequest): Promise<User> {
const validId = validateUuidV4OrThrow(userId);
const parsed = updateCustomerProfileRequestSchema.parse(update);
@ -266,19 +117,14 @@ export class UsersService {
throw new NotFoundException("User mapping not found");
}
// Update in WHMCS (all fields optional)
await this.whmcsService.updateClient(mapping.whmcsClientId, parsed);
this.logger.log({ userId: validId }, "Successfully updated customer profile in WHMCS");
// Return fresh profile
return this.getProfile(validId);
} catch (error) {
const msg = getErrorMessage(error);
this.logger.error(
{ userId: validId, error: msg },
"Failed to update customer profile in WHMCS"
);
this.logger.error({ userId: validId, error: msg }, "Failed to update customer profile in WHMCS");
if (msg.includes("WHMCS API Error")) {
throw new BadRequestException(msg.replace("WHMCS API Error: ", ""));
@ -289,38 +135,23 @@ export class UsersService {
if (msg.includes("Missing required WHMCS configuration")) {
throw new BadRequestException("Billing system not configured. Please contact support.");
}
throw new BadRequestException("Unable to update profile.");
}
}
private sanitizeUserData(userData: UserUpdateData): Partial<PrismaUser> {
const sanitized: Partial<PrismaUser> = {};
// Handle authentication-related fields only
if (userData.passwordHash !== undefined) sanitized.passwordHash = userData.passwordHash;
if (userData.failedLoginAttempts !== undefined)
sanitized.failedLoginAttempts = userData.failedLoginAttempts;
if (userData.lastLoginAt !== undefined) sanitized.lastLoginAt = userData.lastLoginAt;
if (userData.lockedUntil !== undefined) sanitized.lockedUntil = userData.lockedUntil;
return sanitized;
}
async getUserSummary(userId: string): Promise<DashboardSummary> {
try {
// Verify user exists
const user = await this.prisma.user.findUnique({ where: { id: userId } });
const user = await this.userAuthRepository.findById(userId);
if (!user) {
throw new NotFoundException("User not found");
}
// Check if user has WHMCS mapping
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) {
this.logger.warn(`No WHMCS mapping found for user ${userId}`);
// Get currency from WHMCS profile if available
let currency = "JPY"; // Default
let currency = "JPY";
try {
const profile = await this.getProfile(userId);
currency = profile.currency_code || currency;
@ -344,15 +175,11 @@ export class UsersService {
return summary;
}
// Fetch live data from WHMCS in parallel
const [subscriptionsData, invoicesData] = await Promise.allSettled([
this.whmcsService.getSubscriptions(mapping.whmcsClientId, userId),
this.whmcsService.getInvoices(mapping.whmcsClientId, userId, {
limit: 50,
}),
this.whmcsService.getInvoices(mapping.whmcsClientId, userId, { limit: 50 }),
]);
// Process subscriptions
let activeSubscriptions = 0;
let recentSubscriptions: Array<{
id: number;
@ -362,12 +189,10 @@ export class UsersService {
}> = [];
if (subscriptionsData.status === "fulfilled") {
const subscriptions: Subscription[] = subscriptionsData.value.subscriptions;
activeSubscriptions = subscriptions.filter(
(sub: Subscription) => sub.status === "Active"
).length;
activeSubscriptions = subscriptions.filter(sub => sub.status === "Active").length;
recentSubscriptions = subscriptions
.filter((sub: Subscription) => sub.status === "Active")
.sort((a: Subscription, b: Subscription) => {
.filter(sub => sub.status === "Active")
.sort((a, b) => {
const aTime = a.registrationDate
? new Date(a.registrationDate).getTime()
: Number.NEGATIVE_INFINITY;
@ -377,7 +202,7 @@ export class UsersService {
return bTime - aTime;
})
.slice(0, 3)
.map((sub: Subscription) => ({
.map(sub => ({
id: sub.id,
status: sub.status,
registrationDate: sub.registrationDate,
@ -390,7 +215,6 @@ export class UsersService {
);
}
// Process invoices
let unpaidInvoices = 0;
let nextInvoice: NextInvoice | null = null;
let recentInvoices: Array<{
@ -406,17 +230,13 @@ export class UsersService {
if (invoicesData.status === "fulfilled") {
const invoices: Invoice[] = invoicesData.value.invoices;
// Count unpaid invoices
unpaidInvoices = invoices.filter(
(inv: Invoice) => inv.status === "Unpaid" || inv.status === "Overdue"
inv => inv.status === "Unpaid" || inv.status === "Overdue"
).length;
// Find next due invoice
const upcomingInvoices = invoices
.filter(
(inv: Invoice) => (inv.status === "Unpaid" || inv.status === "Overdue") && inv.dueDate
)
.sort((a: Invoice, b: Invoice) => {
.filter(inv => (inv.status === "Unpaid" || inv.status === "Overdue") && inv.dueDate)
.sort((a, b) => {
const aTime = a.dueDate ? new Date(a.dueDate).getTime() : Number.POSITIVE_INFINITY;
const bTime = b.dueDate ? new Date(b.dueDate).getTime() : Number.POSITIVE_INFINITY;
return aTime - bTime;
@ -432,15 +252,14 @@ export class UsersService {
};
}
// Recent invoices for activity
recentInvoices = invoices
.sort((a: Invoice, b: Invoice) => {
.sort((a, b) => {
const aTime = a.issuedAt ? new Date(a.issuedAt).getTime() : Number.NEGATIVE_INFINITY;
const bTime = b.issuedAt ? new Date(b.issuedAt).getTime() : Number.NEGATIVE_INFINITY;
return bTime - aTime;
})
.slice(0, 5)
.map((inv: Invoice) => ({
.map(inv => ({
id: inv.id,
status: inv.status,
dueDate: inv.dueDate,
@ -455,16 +274,14 @@ export class UsersService {
});
}
// Build activity feed
const activities: Activity[] = [];
// Add invoice activities
recentInvoices.forEach(invoice => {
if (invoice.status === "Paid") {
const metadata = {
const metadata: Record<string, unknown> = {
amount: invoice.total,
currency: invoice.currency ?? "JPY",
} as Record<string, unknown>;
};
if (invoice.dueDate) metadata.dueDate = invoice.dueDate;
if (invoice.number) metadata.invoiceNumber = invoice.number;
activities.push({
@ -477,13 +294,13 @@ export class UsersService {
metadata,
});
} else if (invoice.status === "Unpaid" || invoice.status === "Overdue") {
const metadata = {
const metadata: Record<string, unknown> = {
amount: invoice.total,
currency: invoice.currency ?? "JPY",
} as Record<string, unknown>;
status: invoice.status,
};
if (invoice.dueDate) metadata.dueDate = invoice.dueDate;
if (invoice.number) metadata.invoiceNumber = invoice.number;
metadata.status = invoice.status;
activities.push({
id: `invoice-created-${invoice.id}`,
type: "invoice_created",
@ -496,14 +313,14 @@ export class UsersService {
}
});
// Add subscription activities
recentSubscriptions.forEach(subscription => {
const metadata = {
const metadata: Record<string, unknown> = {
productName: subscription.productName,
status: subscription.status,
} as Record<string, unknown>;
if (subscription.registrationDate)
};
if (subscription.registrationDate) {
metadata.registrationDate = subscription.registrationDate;
}
activities.push({
id: `service-activated-${subscription.id}`,
type: "service_activated",
@ -515,7 +332,6 @@ export class UsersService {
});
});
// Sort activities by date and take top 10
activities.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
const recentActivity = activities.slice(0, 10);
@ -526,8 +342,7 @@ export class UsersService {
hasNextInvoice: !!nextInvoice,
});
// Get currency from client data
let currency = "JPY"; // Default
let currency = "JPY";
try {
const client = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
const resolvedCurrency =
@ -548,7 +363,7 @@ export class UsersService {
stats: {
activeSubscriptions,
unpaidInvoices,
openCases: 0, // Support cases not implemented yet
openCases: 0,
currency,
},
nextInvoice,
@ -562,4 +377,25 @@ export class UsersService {
throw new BadRequestException("Unable to retrieve dashboard summary");
}
}
private async getProfileForUser(user: PrismaUser): Promise<User> {
const mapping = await this.mappingsService.findByUserId(user.id);
if (!mapping?.whmcsClientId) {
throw new NotFoundException("WHMCS client mapping not found");
}
try {
const whmcsClient = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
const userAuth = CustomerProviders.Portal.mapPrismaUserToUserAuth(user);
return combineToUser(userAuth, whmcsClient);
} catch (error) {
this.logger.error("Failed to fetch client profile from WHMCS", {
error: getErrorMessage(error),
userId: user.id,
whmcsClientId: mapping.whmcsClientId,
});
throw new BadRequestException("Unable to retrieve customer profile from billing system");
}
}
}

View File

@ -8,8 +8,8 @@ import {
ClassSerializerInterceptor,
UsePipes,
} from "@nestjs/common";
import { UsersService } from "./users.service";
import { ZodValidationPipe } from "@bff/core/validation";
import { UsersFacade } from "./application/users.facade";
import { ZodValidationPipe } from "@customer-portal/validation/nestjs";
import {
updateCustomerProfileRequestSchema,
type UpdateCustomerProfileRequest,
@ -20,7 +20,7 @@ import type { RequestWithUser } from "@bff/modules/auth/auth.types";
@Controller("me")
@UseInterceptors(ClassSerializerInterceptor)
export class UsersController {
constructor(private usersService: UsersService) {}
constructor(private usersFacade: UsersFacade) {}
/**
* GET /me - Get complete customer profile (includes address)
@ -28,7 +28,7 @@ export class UsersController {
*/
@Get()
async getProfile(@Req() req: RequestWithUser) {
return this.usersService.findById(req.user.id);
return this.usersFacade.findById(req.user.id);
}
/**
@ -36,7 +36,7 @@ export class UsersController {
*/
@Get("summary")
async getSummary(@Req() req: RequestWithUser) {
return this.usersService.getUserSummary(req.user.id);
return this.usersFacade.getUserSummary(req.user.id);
}
/**
@ -44,7 +44,7 @@ export class UsersController {
*/
@Get("address")
async getAddress(@Req() req: RequestWithUser): Promise<Address | null> {
return this.usersService.getAddress(req.user.id);
return this.usersFacade.getAddress(req.user.id);
}
/**
@ -56,7 +56,7 @@ export class UsersController {
@Req() req: RequestWithUser,
@Body() address: Partial<Address>
): Promise<Address> {
return this.usersService.updateAddress(req.user.id, address);
return this.usersFacade.updateAddress(req.user.id, address);
}
/**
@ -75,6 +75,6 @@ export class UsersController {
@Req() req: RequestWithUser,
@Body() updateData: UpdateCustomerProfileRequest
) {
return this.usersService.updateProfile(req.user.id, updateData);
return this.usersFacade.updateProfile(req.user.id, updateData);
}
}

View File

@ -1,5 +1,7 @@
import { Module } from "@nestjs/common";
import { UsersService } from "./users.service";
import { UsersFacade } from "./application/users.facade";
import { UserAuthRepository } from "./infra/user-auth.repository";
import { UserProfileService } from "./infra/user-profile.service";
import { UsersController } from "./users.controller";
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module";
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module";
@ -9,7 +11,7 @@ import { PrismaModule } from "@bff/infra/database/prisma.module";
@Module({
imports: [PrismaModule, WhmcsModule, SalesforceModule, MappingsModule],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
providers: [UsersFacade, UserAuthRepository, UserProfileService],
exports: [UsersFacade, UserAuthRepository, UserProfileService],
})
export class UsersModule {}

View File

@ -13,8 +13,15 @@
"@bff/core/*": ["src/core/*"],
"@bff/infra/*": ["src/infra/*"],
"@bff/modules/*": ["src/modules/*"],
"@bff/integrations/*": ["src/integrations/*"]
"@bff/integrations/*": ["src/integrations/*"],
"@customer-portal/validation": ["../../packages/validation/dist/index"],
"@customer-portal/validation/*": ["../../packages/validation/dist/*"]
},
"rootDirs": [
"src",
"../../packages/validation/dist",
"../../packages/validation/src"
],
// Type checking
"noEmit": true,

View File

@ -8,11 +8,12 @@ import {
profileFormToRequest,
type ProfileEditFormData,
} from "@customer-portal/domain/customer";
import { type UpdateCustomerProfileRequest } from "@customer-portal/domain/auth";
import { useZodForm } from "@customer-portal/validation";
export function useProfileEdit(initial: ProfileEditFormData) {
const handleSave = useCallback(async (formData: ProfileEditFormData) => {
const requestData = profileFormToRequest(formData);
const requestData: UpdateCustomerProfileRequest = profileFormToRequest(formData);
const updated = await accountService.updateProfile(requestData);
useAuthStore.setState(state => ({

View File

@ -1,32 +1,45 @@
import { apiClient, getDataOrThrow } from "@/lib/api";
import { getNullableData } from "@/lib/api/response-helpers";
import type { UserProfile } from "@customer-portal/domain/customer";
import type { Address } from "@customer-portal/domain/customer";
type ProfileUpdateInput = {
firstname?: string;
lastname?: string;
phonenumber?: string;
};
import {
userSchema,
addressSchema,
type UserProfile,
type Address,
} from "@customer-portal/domain/customer";
import {
updateCustomerProfileRequestSchema,
type UpdateCustomerProfileRequest,
} from "@customer-portal/domain/auth";
export const accountService = {
async getProfile() {
const response = await apiClient.GET<UserProfile>("/api/me");
return getNullableData<UserProfile>(response);
const data = getNullableData<UserProfile>(response);
if (!data) {
return null;
}
return userSchema.parse(data);
},
async updateProfile(update: ProfileUpdateInput) {
const response = await apiClient.PATCH<UserProfile>("/api/me", { body: update });
return getDataOrThrow<UserProfile>(response, "Failed to update profile");
async updateProfile(update: UpdateCustomerProfileRequest) {
const sanitized = updateCustomerProfileRequestSchema.parse(update);
const response = await apiClient.PATCH<UserProfile>("/api/me", { body: sanitized });
const data = getDataOrThrow<UserProfile>(response, "Failed to update profile");
return userSchema.parse(data);
},
async getAddress() {
const response = await apiClient.GET<Address>("/api/me/address");
return getNullableData<Address>(response);
const data = getNullableData<Address>(response);
if (!data) {
return null;
}
return addressSchema.parse(data);
},
async updateAddress(address: Address) {
const response = await apiClient.PATCH<Address>("/api/me/address", { body: address });
return getDataOrThrow<Address>(response, "Failed to update address");
const data = getDataOrThrow<Address>(response, "Failed to update address");
return addressSchema.parse(data);
},
};

View File

@ -4,13 +4,17 @@ import { useCallback } from "react";
import { Button, Input, ErrorMessage } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
import { useWhmcsLink } from "@/features/auth/hooks";
import { linkWhmcsRequestSchema, type LinkWhmcsRequest } from "@customer-portal/domain/auth";
import {
linkWhmcsRequestSchema,
type LinkWhmcsRequest,
type LinkWhmcsResponse,
} from "@customer-portal/domain/auth";
type LinkWhmcsFormData = LinkWhmcsRequest;
import { useZodForm } from "@customer-portal/validation";
interface LinkWhmcsFormProps {
onTransferred?: (result: { needsPasswordSet: boolean; email: string }) => void;
onTransferred?: (result: LinkWhmcsResponse) => void;
className?: string;
}
@ -25,7 +29,7 @@ export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormPr
password: formData.password,
};
const result = await linkWhmcs(payload);
onTransferred?.({ ...result, email: formData.email });
onTransferred?.(result);
},
[linkWhmcs, onTransferred, clearError]
);

View File

@ -8,13 +8,17 @@ 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 type {
AuthTokens,
LinkWhmcsRequest,
LoginRequest,
SignupRequest,
import {
authResponseSchema,
checkPasswordNeededResponseSchema,
linkWhmcsResponseSchema,
type AuthTokens,
type CheckPasswordNeededResponse,
type LinkWhmcsRequest,
type LinkWhmcsResponse,
type LoginRequest,
type SignupRequest,
} from "@customer-portal/domain/auth";
import { authResponseSchema } from "@customer-portal/domain/auth";
import type { AuthenticatedUser } from "@customer-portal/domain/customer";
import {
clearLogoutReason,
@ -42,8 +46,8 @@ export interface AuthState {
requestPasswordReset: (email: string) => Promise<void>;
resetPassword: (token: string, password: string) => Promise<void>;
changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
checkPasswordNeeded: (email: string) => Promise<{ needsPasswordSet: boolean }>;
linkWhmcs: (request: LinkWhmcsRequest) => Promise<{ needsPasswordSet: boolean; email: string }>;
checkPasswordNeeded: (email: string) => Promise<CheckPasswordNeededResponse>;
linkWhmcs: (request: LinkWhmcsRequest) => Promise<LinkWhmcsResponse>;
setPassword: (email: string, password: string) => Promise<void>;
refreshUser: () => Promise<void>;
refreshSession: () => Promise<void>;
@ -229,12 +233,13 @@ export const useAuthStore = create<AuthState>()((set, get) => {
body: { email },
});
if (!response.data) {
throw new Error("Check failed");
const parsed = checkPasswordNeededResponseSchema.safeParse(response.data);
if (!parsed.success) {
throw new Error(parsed.error.issues?.[0]?.message ?? "Check failed");
}
set({ loading: false });
return response.data as { needsPasswordSet: boolean };
return parsed.data;
} catch (error) {
set({
loading: false,
@ -244,20 +249,20 @@ export const useAuthStore = create<AuthState>()((set, get) => {
}
},
linkWhmcs: async ({ email, password }: LinkWhmcsRequest) => {
linkWhmcs: async (linkRequest: LinkWhmcsRequest) => {
set({ loading: true, error: null });
try {
const response = await apiClient.POST("/api/auth/link-whmcs", {
body: { email, password },
body: linkRequest,
});
if (!response.data) {
throw new Error("WHMCS link failed");
const parsed = linkWhmcsResponseSchema.safeParse(response.data);
if (!parsed.success) {
throw new Error(parsed.error.issues?.[0]?.message ?? "WHMCS link failed");
}
set({ loading: false });
const result = response.data as { needsPasswordSet: boolean };
return { ...result, email };
return parsed.data;
} catch (error) {
set({
loading: false,

View File

@ -40,8 +40,9 @@ export function LinkWhmcsView() {
</div>
<LinkWhmcsForm
onTransferred={({ needsPasswordSet, email }) => {
if (needsPasswordSet) {
onTransferred={result => {
const email = result.user.email;
if (result.needsPasswordSet) {
router.push(`/auth/set-password?email=${encodeURIComponent(email)}`);
return;
}

View File

@ -74,6 +74,7 @@ export type {
PasswordChangeResult,
SsoLinkResponse,
CheckPasswordNeededResponse,
LinkWhmcsResponse,
// Error types
AuthError,
} from './schema';

View File

@ -45,6 +45,7 @@ export type {
PasswordChangeResult,
SsoLinkResponse,
CheckPasswordNeededResponse,
LinkWhmcsResponse,
// Error types
AuthError,
} from "./contract";
@ -81,6 +82,7 @@ export {
passwordChangeResultSchema,
ssoLinkResponseSchema,
checkPasswordNeededResponseSchema,
linkWhmcsResponseSchema,
} from "./schema";
export { buildSignupRequest } from "./helpers";

View File

@ -185,6 +185,14 @@ export const checkPasswordNeededResponseSchema = z.object({
email: z.email().optional(),
});
/**
* Link WHMCS response
*/
export const linkWhmcsResponseSchema = z.object({
user: userSchema,
needsPasswordSet: z.boolean(),
});
// ============================================================================
// Inferred Types (Schema-First Approach)
// ============================================================================
@ -213,6 +221,7 @@ export type SignupResult = z.infer<typeof signupResultSchema>;
export type PasswordChangeResult = z.infer<typeof passwordChangeResultSchema>;
export type SsoLinkResponse = z.infer<typeof ssoLinkResponseSchema>;
export type CheckPasswordNeededResponse = z.infer<typeof checkPasswordNeededResponseSchema>;
export type LinkWhmcsResponse = z.infer<typeof linkWhmcsResponseSchema>;
// ============================================================================
// Error Types

View File

@ -79,3 +79,4 @@ export * as Providers from "./providers/index";
// Re-export provider types for convenience
export * from "./providers/whmcs/raw.types";
export * from "./providers/salesforce/raw.types";
export * from "./providers/salesforce/field-map";

View File

@ -4,6 +4,7 @@
import * as WhmcsMapper from "./whmcs/mapper";
import * as WhmcsRaw from "./whmcs/raw.types";
import * as SalesforceFieldMap from "./salesforce/field-map";
import * as SalesforceMapper from "./salesforce/mapper";
import * as SalesforceRaw from "./salesforce/raw.types";
@ -17,6 +18,7 @@ export const Salesforce = {
...SalesforceMapper,
mapper: SalesforceMapper,
raw: SalesforceRaw,
fieldMap: SalesforceFieldMap,
};
export {
@ -29,3 +31,4 @@ export * from "./whmcs/mapper";
export * from "./whmcs/raw.types";
export * from "./salesforce/mapper";
export * from "./salesforce/raw.types";
export * from "./salesforce/field-map";

View File

@ -0,0 +1,126 @@
import type { SalesforceProduct2WithPricebookEntries } from "../../../catalog/providers/salesforce/raw.types";
import type {
SalesforceOrderItemRecord,
SalesforceOrderRecord,
} from "./raw.types";
export interface SalesforceOrderFieldMap {
order: {
type: keyof SalesforceOrderRecord;
activationType: keyof SalesforceOrderRecord;
activationScheduledAt: keyof SalesforceOrderRecord;
activationStatus: keyof SalesforceOrderRecord;
activationErrorCode: keyof SalesforceOrderRecord;
activationErrorMessage: keyof SalesforceOrderRecord;
activationLastAttemptAt: keyof SalesforceOrderRecord;
internetPlanTier: keyof SalesforceOrderRecord;
installationType: keyof SalesforceOrderRecord;
weekendInstall: keyof SalesforceOrderRecord;
accessMode: keyof SalesforceOrderRecord;
hikariDenwa: keyof SalesforceOrderRecord;
vpnRegion: keyof SalesforceOrderRecord;
simType: keyof SalesforceOrderRecord;
simVoiceMail: keyof SalesforceOrderRecord;
simCallWaiting: keyof SalesforceOrderRecord;
eid: keyof SalesforceOrderRecord;
whmcsOrderId: keyof SalesforceOrderRecord;
addressChanged: keyof SalesforceOrderRecord;
billingStreet: keyof SalesforceOrderRecord;
billingCity: keyof SalesforceOrderRecord;
billingState: keyof SalesforceOrderRecord;
billingPostalCode: keyof SalesforceOrderRecord;
billingCountry: keyof SalesforceOrderRecord;
mnpApplication: keyof SalesforceOrderRecord;
mnpReservation: keyof SalesforceOrderRecord;
mnpExpiry: keyof SalesforceOrderRecord;
mnpPhone: keyof SalesforceOrderRecord;
mvnoAccountNumber: keyof SalesforceOrderRecord;
portingDateOfBirth: keyof SalesforceOrderRecord;
portingFirstName: keyof SalesforceOrderRecord;
portingLastName: keyof SalesforceOrderRecord;
portingFirstNameKatakana: keyof SalesforceOrderRecord;
portingLastNameKatakana: keyof SalesforceOrderRecord;
portingGender: keyof SalesforceOrderRecord;
};
orderItem: {
billingCycle: keyof SalesforceOrderItemRecord;
whmcsServiceId: keyof SalesforceOrderItemRecord;
};
product: {
sku: keyof SalesforceProduct2WithPricebookEntries;
itemClass: keyof SalesforceProduct2WithPricebookEntries;
billingCycle: keyof SalesforceProduct2WithPricebookEntries;
whmcsProductId: keyof SalesforceProduct2WithPricebookEntries;
internetOfferingType: keyof SalesforceProduct2WithPricebookEntries;
internetPlanTier: keyof SalesforceProduct2WithPricebookEntries;
vpnRegion: keyof SalesforceProduct2WithPricebookEntries;
};
}
export type PartialSalesforceOrderFieldMap = {
[Section in keyof SalesforceOrderFieldMap]?: Partial<SalesforceOrderFieldMap[Section]>;
};
export const defaultSalesforceOrderFieldMap: SalesforceOrderFieldMap = {
order: {
type: "Type",
activationType: "Activation_Type__c",
activationScheduledAt: "Activation_Scheduled_At__c",
activationStatus: "Activation_Status__c",
activationErrorCode: "Activation_Error_Code__c",
activationErrorMessage: "Activation_Error_Message__c",
activationLastAttemptAt: "Activation_Last_Attempt_At__c" as keyof SalesforceOrderRecord,
internetPlanTier: "Internet_Plan_Tier__c",
installationType: "Installment_Plan__c",
weekendInstall: "Weekend_Install__c",
accessMode: "Access_Mode__c",
hikariDenwa: "Hikari_Denwa__c",
vpnRegion: "VPN_Region__c",
simType: "SIM_Type__c",
simVoiceMail: "SIM_Voice_Mail__c",
simCallWaiting: "SIM_Call_Waiting__c",
eid: "EID__c",
whmcsOrderId: "WHMCS_Order_ID__c",
addressChanged: "Address_Changed__c",
billingStreet: "BillingStreet",
billingCity: "BillingCity",
billingState: "BillingState",
billingPostalCode: "BillingPostalCode",
billingCountry: "BillingCountry",
mnpApplication: "MNP_Application__c",
mnpReservation: "MNP_Reservation_Number__c",
mnpExpiry: "MNP_Expiry_Date__c",
mnpPhone: "MNP_Phone_Number__c",
mvnoAccountNumber: "MVNO_Account_Number__c",
portingDateOfBirth: "Porting_Date_Of_Birth__c",
portingFirstName: "Porting_First_Name__c",
portingLastName: "Porting_Last_Name__c",
portingFirstNameKatakana: "Porting_First_Name_Katakana__c",
portingLastNameKatakana: "Porting_Last_Name_Katakana__c",
portingGender: "Porting_Gender__c",
},
orderItem: {
billingCycle: "Billing_Cycle__c",
whmcsServiceId: "WHMCS_Service_ID__c",
},
product: {
sku: "StockKeepingUnit",
itemClass: "Item_Class__c",
billingCycle: "Billing_Cycle__c",
whmcsProductId: "WH_Product_ID__c",
internetOfferingType: "Internet_Offering_Type__c",
internetPlanTier: "Internet_Plan_Tier__c",
vpnRegion: "VPN_Region__c",
},
};
export function createSalesforceOrderFieldMap(
overrides: PartialSalesforceOrderFieldMap = {}
): SalesforceOrderFieldMap {
return {
order: { ...defaultSalesforceOrderFieldMap.order, ...overrides.order },
orderItem: { ...defaultSalesforceOrderFieldMap.orderItem, ...overrides.orderItem },
product: { ...defaultSalesforceOrderFieldMap.product, ...overrides.product },
};
}

View File

@ -12,6 +12,14 @@ import type {
} from "../../contract";
import { normalizeBillingCycle } from "../../helpers";
import { orderDetailsSchema, orderSummarySchema, orderItemDetailsSchema } from "../../schema";
import type {
SalesforceProduct2WithPricebookEntries,
SalesforcePricebookEntryRecord,
} from "../../../catalog/providers/salesforce/raw.types";
import {
defaultSalesforceOrderFieldMap,
type SalesforceOrderFieldMap,
} from "./field-map";
import type {
SalesforceOrderItemRecord,
SalesforceOrderRecord,
@ -21,34 +29,43 @@ import type {
* Transform a Salesforce OrderItem record into domain details + summary.
*/
export function transformSalesforceOrderItem(
record: SalesforceOrderItemRecord
record: SalesforceOrderItemRecord,
fieldMap: SalesforceOrderFieldMap = defaultSalesforceOrderFieldMap
): { details: OrderItemDetails; summary: OrderItemSummary } {
// PricebookEntry is unknown to avoid circular dependencies between domains
const pricebookEntry = record.PricebookEntry as Record<string, any> | null | undefined;
const product = pricebookEntry?.Product2 as Record<string, any> | undefined;
const productBillingCycle = product?.Billing_Cycle__c ?? undefined;
const billingCycleRaw = record.Billing_Cycle__c ?? productBillingCycle ?? undefined;
const billingCycle = billingCycleRaw
? normalizeBillingCycle(billingCycleRaw)
: undefined;
const pricebookEntry = (record.PricebookEntry ?? null) as
| SalesforcePricebookEntryRecord
| null;
const product = pricebookEntry?.Product2 as SalesforceProduct2WithPricebookEntries | undefined;
const orderItemFields = fieldMap.orderItem;
const productFields = fieldMap.product;
const billingCycleRaw =
record[orderItemFields.billingCycle] ??
(product ? (product[productFields.billingCycle] as unknown) : undefined);
const billingCycle =
billingCycleRaw !== undefined && billingCycleRaw !== null
? normalizeBillingCycle(billingCycleRaw)
: undefined;
const details = orderItemDetailsSchema.parse({
id: record.Id,
orderId: record.OrderId ?? "",
orderId: ensureString(record.OrderId) ?? "",
quantity: normalizeQuantity(record.Quantity),
unitPrice: coerceNumber(record.UnitPrice),
totalPrice: coerceNumber(record.TotalPrice),
billingCycle,
product: product
? {
id: product.Id ?? undefined,
name: product.Name ?? undefined,
sku: product.StockKeepingUnit ?? undefined,
itemClass: product.Item_Class__c ?? undefined,
whmcsProductId: product.WH_Product_ID__c ? String(product.WH_Product_ID__c) : undefined,
internetOfferingType: product.Internet_Offering_Type__c ?? undefined,
internetPlanTier: product.Internet_Plan_Tier__c ?? undefined,
vpnRegion: product.VPN_Region__c ?? undefined,
id: ensureString(product.Id),
name: ensureString(product.Name),
sku: ensureString(product[productFields.sku]) ?? undefined,
itemClass: ensureString(product[productFields.itemClass]) ?? undefined,
whmcsProductId: resolveWhmcsProductId(product[productFields.whmcsProductId]),
internetOfferingType:
ensureString(product[productFields.internetOfferingType]) ?? undefined,
internetPlanTier: ensureString(product[productFields.internetPlanTier]) ?? undefined,
vpnRegion: ensureString(product[productFields.vpnRegion]) ?? undefined,
}
: undefined,
});
@ -74,28 +91,30 @@ export function transformSalesforceOrderItem(
*/
export function transformSalesforceOrderDetails(
order: SalesforceOrderRecord,
itemRecords: SalesforceOrderItemRecord[]
itemRecords: SalesforceOrderItemRecord[],
fieldMap: SalesforceOrderFieldMap = defaultSalesforceOrderFieldMap
): OrderDetails {
const transformedItems = itemRecords.map(record =>
transformSalesforceOrderItem(record)
transformSalesforceOrderItem(record, fieldMap)
);
const items = transformedItems.map(item => item.details);
const itemsSummary = transformedItems.map(item => item.summary);
const summary = buildOrderSummary(order, itemsSummary);
const summary = buildOrderSummary(order, itemsSummary, fieldMap);
const orderFields = fieldMap.order;
return orderDetailsSchema.parse({
...summary,
accountId: order.AccountId ?? undefined,
accountName: typeof order.Account?.Name === "string" ? order.Account.Name : undefined,
pricebook2Id: order.Pricebook2Id ?? undefined,
activationType: order.Activation_Type__c ?? undefined,
accountId: ensureString(order.AccountId),
accountName: ensureString(order.Account?.Name),
pricebook2Id: ensureString(order.Pricebook2Id),
activationType: ensureString(order[orderFields.activationType]),
activationStatus: summary.activationStatus,
activationScheduledAt: order.Activation_Scheduled_At__c ?? undefined,
activationErrorCode: order.Activation_Error_Code__c ?? undefined,
activationErrorMessage: order.Activation_Error_Message__c ?? undefined,
activatedDate: typeof order.ActivatedDate === "string" ? order.ActivatedDate : undefined,
activationScheduledAt: ensureString(order[orderFields.activationScheduledAt]),
activationErrorCode: ensureString(order[orderFields.activationErrorCode]),
activationErrorMessage: ensureString(order[orderFields.activationErrorMessage]),
activatedDate: ensureString(order.ActivatedDate),
items,
});
}
@ -105,18 +124,21 @@ export function transformSalesforceOrderDetails(
*/
export function transformSalesforceOrderSummary(
order: SalesforceOrderRecord,
itemRecords: SalesforceOrderItemRecord[]
itemRecords: SalesforceOrderItemRecord[],
fieldMap: SalesforceOrderFieldMap = defaultSalesforceOrderFieldMap
): OrderSummary {
const itemsSummary = itemRecords.map(record =>
transformSalesforceOrderItem(record).summary
transformSalesforceOrderItem(record, fieldMap).summary
);
return buildOrderSummary(order, itemsSummary);
return buildOrderSummary(order, itemsSummary, fieldMap);
}
function buildOrderSummary(
order: SalesforceOrderRecord,
itemsSummary: OrderItemSummary[]
itemsSummary: OrderItemSummary[],
fieldMap: SalesforceOrderFieldMap
): OrderSummary {
const orderFields = fieldMap.order;
const effectiveDate =
ensureString(order.EffectiveDate) ??
ensureString(order.CreatedDate) ??
@ -129,13 +151,13 @@ function buildOrderSummary(
id: order.Id,
orderNumber: ensureString(order.OrderNumber) ?? order.Id,
status: ensureString(order.Status) ?? "Unknown",
orderType: order.Type ?? undefined,
orderType: ensureString(order[orderFields.type]) ?? undefined,
effectiveDate,
totalAmount: typeof totalAmount === "number" ? totalAmount : undefined,
createdDate,
lastModifiedDate,
whmcsOrderId: order.WHMCS_Order_ID__c ?? undefined,
activationStatus: order.Activation_Status__c ?? undefined,
whmcsOrderId: ensureString(order[orderFields.whmcsOrderId]) ?? undefined,
activationStatus: ensureString(order[orderFields.activationStatus]) ?? undefined,
itemsSummary,
});
}
@ -159,3 +181,16 @@ function normalizeQuantity(value: unknown): number {
}
return 1;
}
function resolveWhmcsProductId(value: unknown): string | undefined {
if (value === null || value === undefined) {
return undefined;
}
if (typeof value === "number") {
return Number.isFinite(value) ? String(value) : undefined;
}
if (typeof value === "string") {
return value;
}
return undefined;
}

View File

@ -50,6 +50,7 @@ export const salesforceOrderRecordSchema = z.object({
Activation_Scheduled_At__c: z.string().nullable().optional(),
Activation_Error_Code__c: z.string().nullable().optional(),
Activation_Error_Message__c: z.string().nullable().optional(),
Activation_Last_Attempt_At__c: z.string().nullable().optional(),
ActivatedDate: z.string().nullable().optional(),
// Internet fields

View File

@ -17,6 +17,10 @@
"./react": {
"types": "./dist/react/index.d.ts",
"default": "./dist/react/index.js"
},
"./nestjs": {
"types": "./dist/nestjs/index.d.ts",
"default": "./dist/nestjs/index.js"
}
},
"scripts": {
@ -50,6 +54,10 @@
"react": "19.1.1",
"typescript": "^5.9.2",
"jest": "^30.0.5",
"@types/jest": "^30.0.0"
"@types/jest": "^30.0.0",
"nestjs-zod": "^5.0.1",
"nestjs-pino": "^4.4.0",
"express": "^5.1.0",
"@types/express": "^5.0.3"
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,2 @@
export { ZodValidationPipe, createZodDto, ZodValidationException } from "nestjs-zod";
export { ZodValidationExceptionFilter } from "./zod-exception.filter";

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"composite": true,
"tsBuildInfoFile": "dist/.tsbuildinfo"
"tsBuildInfoFile": "dist/.tsbuildinfo",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*"],
"exclude": ["dist", "node_modules", "**/*.test.ts", "**/*.spec.ts"],

12
pnpm-lock.yaml generated
View File

@ -373,15 +373,27 @@ importers:
'@nestjs/common':
specifier: ^11.1.6
version: 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@types/express':
specifier: ^5.0.3
version: 5.0.3
'@types/jest':
specifier: ^30.0.0
version: 30.0.0
'@types/react':
specifier: ^19.1.10
version: 19.1.12
express:
specifier: ^5.1.0
version: 5.1.0
jest:
specifier: ^30.0.5
version: 30.1.3(@types/node@24.3.1)(ts-node@10.9.2(@types/node@24.3.1)(typescript@5.9.2))
nestjs-pino:
specifier: ^4.4.0
version: 4.4.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@10.5.0)(pino@9.9.5)(rxjs@7.8.2)
nestjs-zod:
specifier: ^5.0.1
version: 5.0.1(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.2.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.1.9)
react:
specifier: 19.1.1
version: 19.1.1

View File

@ -327,6 +327,7 @@ start_apps() {
# Build shared package first
log "🔨 Building shared package..."
pnpm --filter @customer-portal/domain build
pnpm --filter @customer-portal/validation build
# Build BFF before watch (ensures dist exists). Use Nest build for correct emit.
log "🔨 Building BFF for initial setup (ts emit)..."