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 { ConfigModule, ConfigService } from "@nestjs/config";
import { ThrottlerModule } from "@nestjs/throttler"; import { ThrottlerModule } from "@nestjs/throttler";
import { ZodValidationPipe } from "nestjs-zod"; import { ZodValidationPipe } from "nestjs-zod";
import { ZodValidationExceptionFilter } from "@bff/core/validation"; import { ZodValidationExceptionFilter } from "@customer-portal/validation/nestjs";
// Configuration // Configuration
import { appConfig } from "@bff/core/config/app.config"; 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. * then uses the domain portal provider mapper to get UserAuth.
* *
* NOTE: UserAuth contains ONLY auth state. Profile data comes from WHMCS. * 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 { export function mapPrismaUserToDomain(user: PrismaUser): UserAuth {
// Convert @prisma/client User to domain PrismaUserRaw // Convert @prisma/client User to domain PrismaUserRaw

View File

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

View File

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

View File

@ -11,7 +11,7 @@ import { Logger } from "nestjs-pino";
import { randomBytes, createHash } from "crypto"; import { randomBytes, createHash } from "crypto";
import type { AuthTokens } from "@customer-portal/domain/auth"; import type { AuthTokens } from "@customer-portal/domain/auth";
import type { User } from "@customer-portal/domain/customer"; 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"; import { mapPrismaUserToDomain } from "@bff/infra/mappers";
export interface RefreshTokenPayload { export interface RefreshTokenPayload {
@ -53,7 +53,7 @@ export class AuthTokenService {
private readonly configService: ConfigService, private readonly configService: ConfigService,
@Inject("REDIS_CLIENT") private readonly redis: Redis, @Inject("REDIS_CLIENT") private readonly redis: Redis,
@Inject(Logger) private readonly logger: Logger, @Inject(Logger) private readonly logger: Logger,
private readonly usersService: UsersService private readonly usersFacade: UsersFacade
) { ) {
this.allowRedisFailOpen = this.allowRedisFailOpen =
this.configService.get("AUTH_ALLOW_REDIS_TOKEN_FAILOPEN", "false") === "true"; 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) // Get user info from database (using internal method to get role)
const prismaUser = await this.usersService.findByIdInternal(payload.userId); const user = await this.usersFacade.findByIdInternal(payload.userId);
if (!prismaUser) { if (!user) {
this.logger.warn("User not found during token refresh", { userId: payload.userId }); this.logger.warn("User not found during token refresh", { userId: payload.userId });
throw new UnauthorizedException("User not found"); throw new UnauthorizedException("User not found");
} }
// Convert to the format expected by generateTokenPair // Convert to the format expected by generateTokenPair
const user = { const userProfile = mapPrismaUserToDomain(user);
id: prismaUser.id,
email: prismaUser.email,
role: prismaUser.role || "USER",
};
// Invalidate current refresh token // Invalidate current refresh token
await this.redis.del(`${this.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`); await this.redis.del(`${this.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`);
// Generate new token pair // Generate new token pair
const newTokenPair = await this.generateTokenPair(user, deviceInfo); const newTokenPair = await this.generateTokenPair(user, deviceInfo);
const userProfile = mapPrismaUserToDomain(prismaUser);
this.logger.debug("Refreshed token pair", { userId: payload.userId }); 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 { Logger } from "nestjs-pino";
import * as bcrypt from "bcrypt"; import * as bcrypt from "bcrypt";
import type { Request } from "express"; 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 { AuditService, AuditAction } from "@bff/infra/audit/audit.service";
import { EmailService } from "@bff/infra/email/email.service"; import { EmailService } from "@bff/infra/email/email.service";
import { getErrorMessage } from "@bff/core/utils/error.util"; import { getErrorMessage } from "@bff/core/utils/error.util";
@ -20,7 +20,7 @@ import { mapPrismaUserToDomain } from "@bff/infra/mappers";
@Injectable() @Injectable()
export class PasswordWorkflowService { export class PasswordWorkflowService {
constructor( constructor(
private readonly usersService: UsersService, private readonly usersFacade: UsersFacade,
private readonly auditService: AuditService, private readonly auditService: AuditService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly emailService: EmailService, private readonly emailService: EmailService,
@ -31,7 +31,7 @@ export class PasswordWorkflowService {
) {} ) {}
async checkPasswordNeeded(email: string) { async checkPasswordNeeded(email: string) {
const user = await this.usersService.findByEmailInternal(email); const user = await this.usersFacade.findByEmailInternal(email);
if (!user) { if (!user) {
return { needsPasswordSet: false, userExists: false }; return { needsPasswordSet: false, userExists: false };
} }
@ -44,7 +44,7 @@ export class PasswordWorkflowService {
} }
async setPassword(email: string, password: string) { async setPassword(email: string, password: string) {
const user = await this.usersService.findByEmailInternal(email); const user = await this.usersFacade.findByEmailInternal(email);
if (!user) { if (!user) {
throw new UnauthorizedException("User not found"); throw new UnauthorizedException("User not found");
} }
@ -57,8 +57,8 @@ export class PasswordWorkflowService {
const saltRounds = const saltRounds =
typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig; typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig;
const passwordHash = await bcrypt.hash(password, saltRounds); const passwordHash = await bcrypt.hash(password, saltRounds);
await this.usersService.update(user.id, { passwordHash }); await this.usersFacade.update(user.id, { passwordHash });
const prismaUser = await this.usersService.findByIdInternal(user.id); const prismaUser = await this.usersFacade.findByIdInternal(user.id);
if (!prismaUser) { if (!prismaUser) {
throw new Error("Failed to load user after password setup"); throw new Error("Failed to load user after password setup");
} }
@ -78,7 +78,7 @@ export class PasswordWorkflowService {
if (request) { if (request) {
await this.authRateLimitService.consumePasswordReset(request); await this.authRateLimitService.consumePasswordReset(request);
} }
const user = await this.usersService.findByEmailInternal(email); const user = await this.usersFacade.findByEmailInternal(email);
if (!user) { if (!user) {
return; return;
} }
@ -119,7 +119,7 @@ export class PasswordWorkflowService {
throw new BadRequestException("Invalid token"); 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"); if (!prismaUser) throw new BadRequestException("Invalid token");
const saltRoundsConfig = this.configService.get<string | number>("BCRYPT_ROUNDS", 12); const saltRoundsConfig = this.configService.get<string | number>("BCRYPT_ROUNDS", 12);
@ -127,8 +127,8 @@ export class PasswordWorkflowService {
typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig; typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig;
const passwordHash = await bcrypt.hash(newPassword, saltRounds); const passwordHash = await bcrypt.hash(newPassword, saltRounds);
await this.usersService.update(prismaUser.id, { passwordHash }); await this.usersFacade.update(prismaUser.id, { passwordHash });
const freshUser = await this.usersService.findByIdInternal(prismaUser.id); const freshUser = await this.usersFacade.findByIdInternal(prismaUser.id);
if (!freshUser) { if (!freshUser) {
throw new Error("Failed to load user after password reset"); throw new Error("Failed to load user after password reset");
} }
@ -154,7 +154,7 @@ export class PasswordWorkflowService {
data: ChangePasswordRequest, data: ChangePasswordRequest,
request?: Request request?: Request
): Promise<PasswordChangeResult> { ): Promise<PasswordChangeResult> {
const user = await this.usersService.findByIdInternal(userId); const user = await this.usersFacade.findByIdInternal(userId);
if (!user) { if (!user) {
throw new UnauthorizedException("User not found"); throw new UnauthorizedException("User not found");
@ -188,8 +188,8 @@ export class PasswordWorkflowService {
typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig; typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig;
const passwordHash = await bcrypt.hash(newPassword, saltRounds); const passwordHash = await bcrypt.hash(newPassword, saltRounds);
await this.usersService.update(user.id, { passwordHash }); await this.usersFacade.update(user.id, { passwordHash });
const prismaUser = await this.usersService.findByIdInternal(user.id); const prismaUser = await this.usersFacade.findByIdInternal(user.id);
if (!prismaUser) { if (!prismaUser) {
throw new Error("Failed to load user after password change"); 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 * as bcrypt from "bcrypt";
import type { Request } from "express"; import type { Request } from "express";
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service"; 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 { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
@ -35,7 +35,7 @@ type _SanitizedPrismaUser = Omit<
@Injectable() @Injectable()
export class SignupWorkflowService { export class SignupWorkflowService {
constructor( constructor(
private readonly usersService: UsersService, private readonly usersFacade: UsersFacade,
private readonly mappingsService: MappingsService, private readonly mappingsService: MappingsService,
private readonly whmcsService: WhmcsService, private readonly whmcsService: WhmcsService,
private readonly salesforceService: SalesforceService, private readonly salesforceService: SalesforceService,
@ -153,7 +153,7 @@ export class SignupWorkflowService {
gender, gender,
} = signupData; } = signupData;
const existingUser = await this.usersService.findByEmailInternal(email); const existingUser = await this.usersFacade.findByEmailInternal(email);
if (existingUser) { if (existingUser) {
const mapped = await this.mappingsService.hasMapping(existingUser.id); const mapped = await this.mappingsService.hasMapping(existingUser.id);
const message = mapped const message = mapped
@ -330,7 +330,7 @@ export class SignupWorkflowService {
throw new BadRequestException(`Failed to create user account: ${getErrorMessage(dbError)}`); 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( await this.auditService.logAuthEvent(
AuditAction.SIGNUP, AuditAction.SIGNUP,
@ -340,7 +340,7 @@ export class SignupWorkflowService {
true true
); );
const prismaUser = freshUser ?? (await this.usersService.findByIdInternal(createdUserId)); const prismaUser = freshUser ?? (await this.usersFacade.findByIdInternal(createdUserId));
if (!prismaUser) { if (!prismaUser) {
throw new Error("Failed to load created user"); throw new Error("Failed to load created user");
@ -395,20 +395,20 @@ export class SignupWorkflowService {
whmcs: { clientExists: false }, whmcs: { clientExists: false },
}; };
const portalUser = await this.usersService.findByEmailInternal(normalizedEmail); const portalUserAuth = await this.usersFacade.findByEmailInternal(normalizedEmail);
if (portalUser) { if (portalUserAuth) {
result.portal.userExists = true; result.portal.userExists = true;
const mapped = await this.mappingsService.hasMapping(portalUser.id); const mapped = await this.mappingsService.hasMapping(portalUserAuth.id);
if (mapped) { if (mapped) {
result.nextAction = "login"; result.nextAction = "login";
result.messages.push("An account already exists. Please sign in."); result.messages.push("An account already exists. Please sign in.");
return result; return result;
} }
result.portal.needsPasswordSet = !portalUser.passwordHash; result.portal.needsPasswordSet = !portalUserAuth.passwordHash;
result.nextAction = portalUser.passwordHash ? "login" : "fix_input"; result.nextAction = portalUserAuth.passwordHash ? "login" : "fix_input";
result.messages.push( result.messages.push(
portalUser.passwordHash portalUserAuth.passwordHash
? "An account exists without billing link. Please sign in to continue setup." ? "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." : "An account exists and needs password setup. Please set a password to continue."
); );

View File

@ -6,7 +6,7 @@ import {
UnauthorizedException, UnauthorizedException,
} from "@nestjs/common"; } from "@nestjs/common";
import { Logger } from "nestjs-pino"; 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 { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
@ -18,7 +18,7 @@ import type { User } from "@customer-portal/domain/customer";
@Injectable() @Injectable()
export class WhmcsLinkWorkflowService { export class WhmcsLinkWorkflowService {
constructor( constructor(
private readonly usersService: UsersService, private readonly usersFacade: UsersFacade,
private readonly mappingsService: MappingsService, private readonly mappingsService: MappingsService,
private readonly whmcsService: WhmcsService, private readonly whmcsService: WhmcsService,
private readonly salesforceService: SalesforceService, private readonly salesforceService: SalesforceService,
@ -26,7 +26,7 @@ export class WhmcsLinkWorkflowService {
) {} ) {}
async linkWhmcsUser(email: string, password: string) { async linkWhmcsUser(email: string, password: string) {
const existingUser = await this.usersService.findByEmailInternal(email); const existingUser = await this.usersFacade.findByEmailInternal(email);
if (existingUser) { if (existingUser) {
if (!existingUser.passwordHash) { if (!existingUser.passwordHash) {
this.logger.log("User exists but has no password - allowing password setup to continue", { 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, email,
passwordHash: null, passwordHash: null,
emailVerified: true, emailVerified: true,
@ -149,7 +149,7 @@ export class WhmcsLinkWorkflowService {
sfAccountId: sfAccount.id, sfAccountId: sfAccount.id,
}); });
const prismaUser = await this.usersService.findByIdInternal(createdUser.id); const prismaUser = await this.usersFacade.findByIdInternal(createdUser.id);
if (!prismaUser) { if (!prismaUser) {
throw new Error("Failed to load newly linked user"); 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 { FailedLoginThrottleGuard } from "./guards/failed-login-throttle.guard";
import { LoginResultInterceptor } from "./interceptors/login-result.interceptor"; import { LoginResultInterceptor } from "./interceptors/login-result.interceptor";
import { Public } from "../../decorators/public.decorator"; 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 Zod schemas from domain
import { import {
@ -33,6 +33,8 @@ import {
ssoLinkRequestSchema, ssoLinkRequestSchema,
checkPasswordNeededRequestSchema, checkPasswordNeededRequestSchema,
refreshTokenRequestSchema, refreshTokenRequestSchema,
checkPasswordNeededResponseSchema,
linkWhmcsResponseSchema,
type SignupRequest, type SignupRequest,
type PasswordResetRequest, type PasswordResetRequest,
type ResetPasswordRequest, type ResetPasswordRequest,
@ -216,7 +218,8 @@ export class AuthController {
@Throttle({ default: { limit: 5, ttl: 600 } }) // 5 attempts per 10 minutes per IP (industry standard) @Throttle({ default: { limit: 5, ttl: 600 } }) // 5 attempts per 10 minutes per IP (industry standard)
@UsePipes(new ZodValidationPipe(linkWhmcsRequestSchema)) @UsePipes(new ZodValidationPipe(linkWhmcsRequestSchema))
async linkWhmcs(@Body() linkData: LinkWhmcsRequest, @Req() _req: Request) { 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() @Public()
@ -239,7 +242,8 @@ export class AuthController {
@UsePipes(new ZodValidationPipe(checkPasswordNeededRequestSchema)) @UsePipes(new ZodValidationPipe(checkPasswordNeededRequestSchema))
@HttpCode(200) @HttpCode(200)
async checkPasswordNeeded(@Body() data: CheckPasswordNeededRequest) { async checkPasswordNeeded(@Body() data: CheckPasswordNeededRequest) {
return this.authFacade.checkPasswordNeeded(data.email); const response = await this.authFacade.checkPasswordNeeded(data.email);
return checkPasswordNeededResponseSchema.parse(response);
} }
@Public() @Public()

View File

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

View File

@ -13,7 +13,7 @@ import {
import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service"; import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.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 { RequestWithUser } from "@bff/modules/auth/auth.types";
import type { import type {

View File

@ -1,120 +1,32 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import {
export interface OrderFieldMap { createSalesforceOrderFieldMap,
order: { defaultSalesforceOrderFieldMap,
type: string; type PartialSalesforceOrderFieldMap,
activationType: string; type SalesforceOrderFieldMap,
activationScheduledAt: string; } from "@customer-portal/domain/orders";
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;
};
}
const unique = <T>(values: T[]): T[] => Array.from(new Set(values)); 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() @Injectable()
export class OrderFieldMapService { export class OrderFieldMapService {
readonly fields: OrderFieldMap; readonly fields: SalesforceOrderFieldMap;
constructor(private readonly config: ConfigService) { constructor(private readonly config: ConfigService) {
const resolve = (key: string) => this.config.get<string>(key, { infer: true }) ?? key; const overrides: PartialSalesforceOrderFieldMap = {
order: this.resolveSection("order"),
this.fields = { orderItem: this.resolveSection("orderItem"),
order: { product: this.resolveSection("product"),
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"),
},
}; };
this.fields = createSalesforceOrderFieldMap(overrides);
} }
buildOrderSelectFields(additional: string[] = []): string[] { buildOrderSelectFields(additional: string[] = []): string[] {
@ -189,4 +101,32 @@ export class OrderFieldMapService {
return unique([...base, ...additional]); 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 { Body, Controller, Post, Request, UsePipes, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino"; 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 { CheckoutService } from "../services/checkout.service";
import { import {
CheckoutCart, CheckoutCart,

View File

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

View File

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

View File

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

View File

@ -39,7 +39,7 @@ import {
type SimFeaturesRequest, type SimFeaturesRequest,
type SimReissueRequest, type SimReissueRequest,
} from "@customer-portal/domain/sim"; } 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"; import type { RequestWithUser } from "@bff/modules/auth/auth.types";
const subscriptionInvoiceQuerySchema = createPaginationSchema({ 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 { Logger } from "nestjs-pino";
import type { User as PrismaUser } from "@prisma/client"; import type { User as PrismaUser } from "@prisma/client";
import { getErrorMessage } from "@bff/core/utils/error.util"; 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 { import {
Providers as CustomerProviders, Providers as CustomerProviders,
addressSchema, addressSchema,
@ -15,146 +9,53 @@ import {
type Address, type Address,
type User, type User,
} from "@customer-portal/domain/customer"; } from "@customer-portal/domain/customer";
import {
updateCustomerProfileRequestSchema,
type UpdateCustomerProfileRequest,
} from "@customer-portal/domain/auth";
import type { Subscription } from "@customer-portal/domain/subscriptions"; import type { Subscription } from "@customer-portal/domain/subscriptions";
import type { Invoice } from "@customer-portal/domain/billing"; import type { Invoice } from "@customer-portal/domain/billing";
import type { Activity, DashboardSummary, NextInvoice } from "@customer-portal/domain/dashboard"; import type { Activity, DashboardSummary, NextInvoice } from "@customer-portal/domain/dashboard";
import { dashboardSummarySchema } 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 { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { validateUuidV4OrThrow } from "@customer-portal/domain/common";
import { UserAuthRepository } from "./user-auth.repository";
// Use a subset of PrismaUser for auth-related updates only
type UserUpdateData = Partial<
Pick<PrismaUser, "passwordHash" | "failedLoginAttempts" | "lastLoginAt" | "lockedUntil">
>;
@Injectable() @Injectable()
export class UsersService { export class UserProfileService {
constructor( constructor(
private prisma: PrismaService, private readonly userAuthRepository: UserAuthRepository,
private whmcsService: WhmcsService, private readonly mappingsService: MappingsService,
private salesforceService: SalesforceService, private readonly whmcsService: WhmcsService,
private mappingsService: MappingsService, private readonly salesforceService: SalesforceService,
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger
) {} ) {}
/** async findById(userId: string): Promise<User | null> {
* 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> {
const validId = validateUuidV4OrThrow(userId); 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; return profile.address ?? null;
} }
/**
* Update customer address in WHMCS
*/
async updateAddress(userId: string, addressUpdate: Partial<Address>): Promise<Address> { async updateAddress(userId: string, addressUpdate: Partial<Address>): Promise<Address> {
const validId = validateUuidV4OrThrow(userId); const validId = validateUuidV4OrThrow(userId);
const parsed = addressSchema.partial().parse(addressUpdate ?? {}); 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> { async updateProfile(userId: string, update: UpdateCustomerProfileRequest): Promise<User> {
const validId = validateUuidV4OrThrow(userId); const validId = validateUuidV4OrThrow(userId);
const parsed = updateCustomerProfileRequestSchema.parse(update); const parsed = updateCustomerProfileRequestSchema.parse(update);
@ -266,19 +117,14 @@ export class UsersService {
throw new NotFoundException("User mapping not found"); throw new NotFoundException("User mapping not found");
} }
// Update in WHMCS (all fields optional)
await this.whmcsService.updateClient(mapping.whmcsClientId, parsed); await this.whmcsService.updateClient(mapping.whmcsClientId, parsed);
this.logger.log({ userId: validId }, "Successfully updated customer profile in WHMCS"); this.logger.log({ userId: validId }, "Successfully updated customer profile in WHMCS");
// Return fresh profile
return this.getProfile(validId); return this.getProfile(validId);
} catch (error) { } catch (error) {
const msg = getErrorMessage(error); const msg = getErrorMessage(error);
this.logger.error( this.logger.error({ userId: validId, error: msg }, "Failed to update customer profile in WHMCS");
{ userId: validId, error: msg },
"Failed to update customer profile in WHMCS"
);
if (msg.includes("WHMCS API Error")) { if (msg.includes("WHMCS API Error")) {
throw new BadRequestException(msg.replace("WHMCS API Error: ", "")); throw new BadRequestException(msg.replace("WHMCS API Error: ", ""));
@ -289,38 +135,23 @@ export class UsersService {
if (msg.includes("Missing required WHMCS configuration")) { if (msg.includes("Missing required WHMCS configuration")) {
throw new BadRequestException("Billing system not configured. Please contact support."); throw new BadRequestException("Billing system not configured. Please contact support.");
} }
throw new BadRequestException("Unable to update profile."); 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> { async getUserSummary(userId: string): Promise<DashboardSummary> {
try { try {
// Verify user exists const user = await this.userAuthRepository.findById(userId);
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) { if (!user) {
throw new NotFoundException("User not found"); throw new NotFoundException("User not found");
} }
// Check if user has WHMCS mapping
const mapping = await this.mappingsService.findByUserId(userId); const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) { if (!mapping?.whmcsClientId) {
this.logger.warn(`No WHMCS mapping found for user ${userId}`); this.logger.warn(`No WHMCS mapping found for user ${userId}`);
// Get currency from WHMCS profile if available let currency = "JPY";
let currency = "JPY"; // Default
try { try {
const profile = await this.getProfile(userId); const profile = await this.getProfile(userId);
currency = profile.currency_code || currency; currency = profile.currency_code || currency;
@ -344,15 +175,11 @@ export class UsersService {
return summary; return summary;
} }
// Fetch live data from WHMCS in parallel
const [subscriptionsData, invoicesData] = await Promise.allSettled([ const [subscriptionsData, invoicesData] = await Promise.allSettled([
this.whmcsService.getSubscriptions(mapping.whmcsClientId, userId), this.whmcsService.getSubscriptions(mapping.whmcsClientId, userId),
this.whmcsService.getInvoices(mapping.whmcsClientId, userId, { this.whmcsService.getInvoices(mapping.whmcsClientId, userId, { limit: 50 }),
limit: 50,
}),
]); ]);
// Process subscriptions
let activeSubscriptions = 0; let activeSubscriptions = 0;
let recentSubscriptions: Array<{ let recentSubscriptions: Array<{
id: number; id: number;
@ -362,12 +189,10 @@ export class UsersService {
}> = []; }> = [];
if (subscriptionsData.status === "fulfilled") { if (subscriptionsData.status === "fulfilled") {
const subscriptions: Subscription[] = subscriptionsData.value.subscriptions; const subscriptions: Subscription[] = subscriptionsData.value.subscriptions;
activeSubscriptions = subscriptions.filter( activeSubscriptions = subscriptions.filter(sub => sub.status === "Active").length;
(sub: Subscription) => sub.status === "Active"
).length;
recentSubscriptions = subscriptions recentSubscriptions = subscriptions
.filter((sub: Subscription) => sub.status === "Active") .filter(sub => sub.status === "Active")
.sort((a: Subscription, b: Subscription) => { .sort((a, b) => {
const aTime = a.registrationDate const aTime = a.registrationDate
? new Date(a.registrationDate).getTime() ? new Date(a.registrationDate).getTime()
: Number.NEGATIVE_INFINITY; : Number.NEGATIVE_INFINITY;
@ -377,7 +202,7 @@ export class UsersService {
return bTime - aTime; return bTime - aTime;
}) })
.slice(0, 3) .slice(0, 3)
.map((sub: Subscription) => ({ .map(sub => ({
id: sub.id, id: sub.id,
status: sub.status, status: sub.status,
registrationDate: sub.registrationDate, registrationDate: sub.registrationDate,
@ -390,7 +215,6 @@ export class UsersService {
); );
} }
// Process invoices
let unpaidInvoices = 0; let unpaidInvoices = 0;
let nextInvoice: NextInvoice | null = null; let nextInvoice: NextInvoice | null = null;
let recentInvoices: Array<{ let recentInvoices: Array<{
@ -406,17 +230,13 @@ export class UsersService {
if (invoicesData.status === "fulfilled") { if (invoicesData.status === "fulfilled") {
const invoices: Invoice[] = invoicesData.value.invoices; const invoices: Invoice[] = invoicesData.value.invoices;
// Count unpaid invoices
unpaidInvoices = invoices.filter( unpaidInvoices = invoices.filter(
(inv: Invoice) => inv.status === "Unpaid" || inv.status === "Overdue" inv => inv.status === "Unpaid" || inv.status === "Overdue"
).length; ).length;
// Find next due invoice
const upcomingInvoices = invoices const upcomingInvoices = invoices
.filter( .filter(inv => (inv.status === "Unpaid" || inv.status === "Overdue") && inv.dueDate)
(inv: Invoice) => (inv.status === "Unpaid" || inv.status === "Overdue") && inv.dueDate .sort((a, b) => {
)
.sort((a: Invoice, b: Invoice) => {
const aTime = a.dueDate ? new Date(a.dueDate).getTime() : Number.POSITIVE_INFINITY; const aTime = a.dueDate ? new Date(a.dueDate).getTime() : Number.POSITIVE_INFINITY;
const bTime = b.dueDate ? new Date(b.dueDate).getTime() : Number.POSITIVE_INFINITY; const bTime = b.dueDate ? new Date(b.dueDate).getTime() : Number.POSITIVE_INFINITY;
return aTime - bTime; return aTime - bTime;
@ -432,15 +252,14 @@ export class UsersService {
}; };
} }
// Recent invoices for activity
recentInvoices = invoices recentInvoices = invoices
.sort((a: Invoice, b: Invoice) => { .sort((a, b) => {
const aTime = a.issuedAt ? new Date(a.issuedAt).getTime() : Number.NEGATIVE_INFINITY; const aTime = a.issuedAt ? new Date(a.issuedAt).getTime() : Number.NEGATIVE_INFINITY;
const bTime = b.issuedAt ? new Date(b.issuedAt).getTime() : Number.NEGATIVE_INFINITY; const bTime = b.issuedAt ? new Date(b.issuedAt).getTime() : Number.NEGATIVE_INFINITY;
return bTime - aTime; return bTime - aTime;
}) })
.slice(0, 5) .slice(0, 5)
.map((inv: Invoice) => ({ .map(inv => ({
id: inv.id, id: inv.id,
status: inv.status, status: inv.status,
dueDate: inv.dueDate, dueDate: inv.dueDate,
@ -455,16 +274,14 @@ export class UsersService {
}); });
} }
// Build activity feed
const activities: Activity[] = []; const activities: Activity[] = [];
// Add invoice activities
recentInvoices.forEach(invoice => { recentInvoices.forEach(invoice => {
if (invoice.status === "Paid") { if (invoice.status === "Paid") {
const metadata = { const metadata: Record<string, unknown> = {
amount: invoice.total, amount: invoice.total,
currency: invoice.currency ?? "JPY", currency: invoice.currency ?? "JPY",
} as Record<string, unknown>; };
if (invoice.dueDate) metadata.dueDate = invoice.dueDate; if (invoice.dueDate) metadata.dueDate = invoice.dueDate;
if (invoice.number) metadata.invoiceNumber = invoice.number; if (invoice.number) metadata.invoiceNumber = invoice.number;
activities.push({ activities.push({
@ -477,13 +294,13 @@ export class UsersService {
metadata, metadata,
}); });
} else if (invoice.status === "Unpaid" || invoice.status === "Overdue") { } else if (invoice.status === "Unpaid" || invoice.status === "Overdue") {
const metadata = { const metadata: Record<string, unknown> = {
amount: invoice.total, amount: invoice.total,
currency: invoice.currency ?? "JPY", currency: invoice.currency ?? "JPY",
} as Record<string, unknown>; status: invoice.status,
};
if (invoice.dueDate) metadata.dueDate = invoice.dueDate; if (invoice.dueDate) metadata.dueDate = invoice.dueDate;
if (invoice.number) metadata.invoiceNumber = invoice.number; if (invoice.number) metadata.invoiceNumber = invoice.number;
metadata.status = invoice.status;
activities.push({ activities.push({
id: `invoice-created-${invoice.id}`, id: `invoice-created-${invoice.id}`,
type: "invoice_created", type: "invoice_created",
@ -496,14 +313,14 @@ export class UsersService {
} }
}); });
// Add subscription activities
recentSubscriptions.forEach(subscription => { recentSubscriptions.forEach(subscription => {
const metadata = { const metadata: Record<string, unknown> = {
productName: subscription.productName, productName: subscription.productName,
status: subscription.status, status: subscription.status,
} as Record<string, unknown>; };
if (subscription.registrationDate) if (subscription.registrationDate) {
metadata.registrationDate = subscription.registrationDate; metadata.registrationDate = subscription.registrationDate;
}
activities.push({ activities.push({
id: `service-activated-${subscription.id}`, id: `service-activated-${subscription.id}`,
type: "service_activated", 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()); activities.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
const recentActivity = activities.slice(0, 10); const recentActivity = activities.slice(0, 10);
@ -526,8 +342,7 @@ export class UsersService {
hasNextInvoice: !!nextInvoice, hasNextInvoice: !!nextInvoice,
}); });
// Get currency from client data let currency = "JPY";
let currency = "JPY"; // Default
try { try {
const client = await this.whmcsService.getClientDetails(mapping.whmcsClientId); const client = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
const resolvedCurrency = const resolvedCurrency =
@ -548,7 +363,7 @@ export class UsersService {
stats: { stats: {
activeSubscriptions, activeSubscriptions,
unpaidInvoices, unpaidInvoices,
openCases: 0, // Support cases not implemented yet openCases: 0,
currency, currency,
}, },
nextInvoice, nextInvoice,
@ -562,4 +377,25 @@ export class UsersService {
throw new BadRequestException("Unable to retrieve dashboard summary"); 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, ClassSerializerInterceptor,
UsePipes, UsePipes,
} from "@nestjs/common"; } from "@nestjs/common";
import { UsersService } from "./users.service"; import { UsersFacade } from "./application/users.facade";
import { ZodValidationPipe } from "@bff/core/validation"; import { ZodValidationPipe } from "@customer-portal/validation/nestjs";
import { import {
updateCustomerProfileRequestSchema, updateCustomerProfileRequestSchema,
type UpdateCustomerProfileRequest, type UpdateCustomerProfileRequest,
@ -20,7 +20,7 @@ import type { RequestWithUser } from "@bff/modules/auth/auth.types";
@Controller("me") @Controller("me")
@UseInterceptors(ClassSerializerInterceptor) @UseInterceptors(ClassSerializerInterceptor)
export class UsersController { export class UsersController {
constructor(private usersService: UsersService) {} constructor(private usersFacade: UsersFacade) {}
/** /**
* GET /me - Get complete customer profile (includes address) * GET /me - Get complete customer profile (includes address)
@ -28,7 +28,7 @@ export class UsersController {
*/ */
@Get() @Get()
async getProfile(@Req() req: RequestWithUser) { 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") @Get("summary")
async getSummary(@Req() req: RequestWithUser) { 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") @Get("address")
async getAddress(@Req() req: RequestWithUser): Promise<Address | null> { 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, @Req() req: RequestWithUser,
@Body() address: Partial<Address> @Body() address: Partial<Address>
): Promise<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, @Req() req: RequestWithUser,
@Body() updateData: UpdateCustomerProfileRequest @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 { 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 { UsersController } from "./users.controller";
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module"; import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module";
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module"; import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module";
@ -9,7 +11,7 @@ import { PrismaModule } from "@bff/infra/database/prisma.module";
@Module({ @Module({
imports: [PrismaModule, WhmcsModule, SalesforceModule, MappingsModule], imports: [PrismaModule, WhmcsModule, SalesforceModule, MappingsModule],
controllers: [UsersController], controllers: [UsersController],
providers: [UsersService], providers: [UsersFacade, UserAuthRepository, UserProfileService],
exports: [UsersService], exports: [UsersFacade, UserAuthRepository, UserProfileService],
}) })
export class UsersModule {} export class UsersModule {}

View File

@ -13,8 +13,15 @@
"@bff/core/*": ["src/core/*"], "@bff/core/*": ["src/core/*"],
"@bff/infra/*": ["src/infra/*"], "@bff/infra/*": ["src/infra/*"],
"@bff/modules/*": ["src/modules/*"], "@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 // Type checking
"noEmit": true, "noEmit": true,

View File

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

View File

@ -1,32 +1,45 @@
import { apiClient, getDataOrThrow } from "@/lib/api"; import { apiClient, getDataOrThrow } from "@/lib/api";
import { getNullableData } from "@/lib/api/response-helpers"; import { getNullableData } from "@/lib/api/response-helpers";
import type { UserProfile } from "@customer-portal/domain/customer"; import {
import type { Address } from "@customer-portal/domain/customer"; userSchema,
addressSchema,
type ProfileUpdateInput = { type UserProfile,
firstname?: string; type Address,
lastname?: string; } from "@customer-portal/domain/customer";
phonenumber?: string; import {
}; updateCustomerProfileRequestSchema,
type UpdateCustomerProfileRequest,
} from "@customer-portal/domain/auth";
export const accountService = { export const accountService = {
async getProfile() { async getProfile() {
const response = await apiClient.GET<UserProfile>("/api/me"); 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) { async updateProfile(update: UpdateCustomerProfileRequest) {
const response = await apiClient.PATCH<UserProfile>("/api/me", { body: update }); const sanitized = updateCustomerProfileRequestSchema.parse(update);
return getDataOrThrow<UserProfile>(response, "Failed to update profile"); 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() { async getAddress() {
const response = await apiClient.GET<Address>("/api/me/address"); 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) { async updateAddress(address: Address) {
const response = await apiClient.PATCH<Address>("/api/me/address", { body: 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 { Button, Input, ErrorMessage } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField"; import { FormField } from "@/components/molecules/FormField/FormField";
import { useWhmcsLink } from "@/features/auth/hooks"; 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; type LinkWhmcsFormData = LinkWhmcsRequest;
import { useZodForm } from "@customer-portal/validation"; import { useZodForm } from "@customer-portal/validation";
interface LinkWhmcsFormProps { interface LinkWhmcsFormProps {
onTransferred?: (result: { needsPasswordSet: boolean; email: string }) => void; onTransferred?: (result: LinkWhmcsResponse) => void;
className?: string; className?: string;
} }
@ -25,7 +29,7 @@ export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormPr
password: formData.password, password: formData.password,
}; };
const result = await linkWhmcs(payload); const result = await linkWhmcs(payload);
onTransferred?.({ ...result, email: formData.email }); onTransferred?.(result);
}, },
[linkWhmcs, onTransferred, clearError] [linkWhmcs, onTransferred, clearError]
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,6 +17,10 @@
"./react": { "./react": {
"types": "./dist/react/index.d.ts", "types": "./dist/react/index.d.ts",
"default": "./dist/react/index.js" "default": "./dist/react/index.js"
},
"./nestjs": {
"types": "./dist/nestjs/index.d.ts",
"default": "./dist/nestjs/index.js"
} }
}, },
"scripts": { "scripts": {
@ -50,6 +54,10 @@
"react": "19.1.1", "react": "19.1.1",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"jest": "^30.0.5", "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", "extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "dist", "outDir": "dist",
"rootDir": "src", "rootDir": "src",
"declaration": true, "declaration": true,
"declarationMap": true, "declarationMap": true,
"sourceMap": true, "sourceMap": true,
"composite": true, "composite": true,
"tsBuildInfoFile": "dist/.tsbuildinfo" "tsBuildInfoFile": "dist/.tsbuildinfo",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["dist", "node_modules", "**/*.test.ts", "**/*.spec.ts"], "exclude": ["dist", "node_modules", "**/*.test.ts", "**/*.spec.ts"],

12
pnpm-lock.yaml generated
View File

@ -373,15 +373,27 @@ importers:
'@nestjs/common': '@nestjs/common':
specifier: ^11.1.6 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) 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': '@types/jest':
specifier: ^30.0.0 specifier: ^30.0.0
version: 30.0.0 version: 30.0.0
'@types/react': '@types/react':
specifier: ^19.1.10 specifier: ^19.1.10
version: 19.1.12 version: 19.1.12
express:
specifier: ^5.1.0
version: 5.1.0
jest: jest:
specifier: ^30.0.5 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)) 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: react:
specifier: 19.1.1 specifier: 19.1.1
version: 19.1.1 version: 19.1.1

View File

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