From 1dc8fbf36d5b2cc04e21894a092917c33c27efd3 Mon Sep 17 00:00:00 2001 From: barsa Date: Tue, 4 Nov 2025 13:28:36 +0900 Subject: [PATCH] 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. --- apps/bff/src/app.module.ts | 2 +- apps/bff/src/core/validation/index.ts | 7 - apps/bff/src/infra/mappers/user.mapper.ts | 2 +- .../services/salesforce-order.service.ts | 9 +- .../modules/auth/application/auth.facade.ts | 16 +- .../modules/auth/infra/token/token.service.ts | 15 +- .../workflows/password-workflow.service.ts | 26 +- .../workflows/signup-workflow.service.ts | 22 +- .../workflows/whmcs-link-workflow.service.ts | 10 +- .../auth/presentation/http/auth.controller.ts | 10 +- .../presentation/strategies/jwt.strategy.ts | 6 +- .../modules/invoices/invoices.controller.ts | 2 +- .../orders/config/order-field-map.service.ts | 154 +++------ .../orders/controllers/checkout.controller.ts | 2 +- .../src/modules/orders/orders.controller.ts | 2 +- .../orders/services/order-builder.service.ts | 6 +- .../subscriptions/sim-orders.controller.ts | 2 +- .../subscriptions/subscriptions.controller.ts | 2 +- .../modules/users/application/users.facade.ts | 123 +++++++ .../users/infra/user-auth.repository.ts | 66 ++++ .../user-profile.service.ts} | 318 +++++------------- .../bff/src/modules/users/users.controller.ts | 16 +- apps/bff/src/modules/users/users.module.ts | 8 +- apps/bff/tsconfig.json | 9 +- .../features/account/hooks/useProfileEdit.ts | 3 +- .../account/services/account.service.ts | 41 ++- .../LinkWhmcsForm/LinkWhmcsForm.tsx | 10 +- .../src/features/auth/services/auth.store.ts | 39 ++- .../src/features/auth/views/LinkWhmcsView.tsx | 5 +- packages/domain/auth/contract.ts | 1 + packages/domain/auth/index.ts | 2 + packages/domain/auth/schema.ts | 9 + packages/domain/orders/index.ts | 1 + packages/domain/orders/providers/index.ts | 3 + .../orders/providers/salesforce/field-map.ts | 126 +++++++ .../orders/providers/salesforce/mapper.ts | 107 ++++-- .../orders/providers/salesforce/raw.types.ts | 1 + packages/validation/package.json | 10 +- packages/validation/src/nestjs/index.d.ts | 3 + packages/validation/src/nestjs/index.d.ts.map | 1 + packages/validation/src/nestjs/index.js | 10 + packages/validation/src/nestjs/index.js.map | 1 + packages/validation/src/nestjs/index.ts | 2 + .../src/nestjs/zod-exception.filter.d.ts | 11 + .../src/nestjs/zod-exception.filter.d.ts.map | 1 + .../src/nestjs/zod-exception.filter.js | 75 +++++ .../src/nestjs/zod-exception.filter.js.map | 1 + .../src/nestjs/zod-exception.filter.ts | 0 packages/validation/tsconfig.json | 6 +- pnpm-lock.yaml | 12 + scripts/dev/manage.sh | 1 + 51 files changed, 808 insertions(+), 509 deletions(-) delete mode 100644 apps/bff/src/core/validation/index.ts create mode 100644 apps/bff/src/modules/users/application/users.facade.ts create mode 100644 apps/bff/src/modules/users/infra/user-auth.repository.ts rename apps/bff/src/modules/users/{users.service.ts => infra/user-profile.service.ts} (63%) create mode 100644 packages/domain/orders/providers/salesforce/field-map.ts create mode 100644 packages/validation/src/nestjs/index.d.ts create mode 100644 packages/validation/src/nestjs/index.d.ts.map create mode 100644 packages/validation/src/nestjs/index.js create mode 100644 packages/validation/src/nestjs/index.js.map create mode 100644 packages/validation/src/nestjs/index.ts create mode 100644 packages/validation/src/nestjs/zod-exception.filter.d.ts create mode 100644 packages/validation/src/nestjs/zod-exception.filter.d.ts.map create mode 100644 packages/validation/src/nestjs/zod-exception.filter.js create mode 100644 packages/validation/src/nestjs/zod-exception.filter.js.map rename apps/bff/src/core/validation/zod-validation.filter.ts => packages/validation/src/nestjs/zod-exception.filter.ts (100%) diff --git a/apps/bff/src/app.module.ts b/apps/bff/src/app.module.ts index ffca441d..87759385 100644 --- a/apps/bff/src/app.module.ts +++ b/apps/bff/src/app.module.ts @@ -4,7 +4,7 @@ import { RouterModule } from "@nestjs/core"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { ThrottlerModule } from "@nestjs/throttler"; import { ZodValidationPipe } from "nestjs-zod"; -import { ZodValidationExceptionFilter } from "@bff/core/validation"; +import { ZodValidationExceptionFilter } from "@customer-portal/validation/nestjs"; // Configuration import { appConfig } from "@bff/core/config/app.config"; diff --git a/apps/bff/src/core/validation/index.ts b/apps/bff/src/core/validation/index.ts deleted file mode 100644 index 4e58c736..00000000 --- a/apps/bff/src/core/validation/index.ts +++ /dev/null @@ -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"; diff --git a/apps/bff/src/infra/mappers/user.mapper.ts b/apps/bff/src/infra/mappers/user.mapper.ts index 4a2f6ba6..f5c7a4ce 100644 --- a/apps/bff/src/infra/mappers/user.mapper.ts +++ b/apps/bff/src/infra/mappers/user.mapper.ts @@ -20,7 +20,7 @@ type PrismaUserRaw = Parameters OrderProviders.Salesforce.transformSalesforceOrderSummary( order, - itemsByOrder[order.Id] ?? [] + itemsByOrder[order.Id] ?? [], + this.orderFieldMap.fields ) ); } catch (error: unknown) { diff --git a/apps/bff/src/modules/auth/application/auth.facade.ts b/apps/bff/src/modules/auth/application/auth.facade.ts index 80b62a88..b9366490 100644 --- a/apps/bff/src/modules/auth/application/auth.facade.ts +++ b/apps/bff/src/modules/auth/application/auth.facade.ts @@ -2,7 +2,7 @@ import { Injectable, UnauthorizedException, BadRequestException, Inject } from " import { JwtService } from "@nestjs/jwt"; import { ConfigService } from "@nestjs/config"; import * as bcrypt from "bcrypt"; -import { UsersService } from "@bff/modules/users/users.service"; +import { UsersFacade } from "@bff/modules/users/application/users.facade"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service"; @@ -36,7 +36,7 @@ export class AuthFacade { private readonly LOCKOUT_DURATION_MINUTES = 15; constructor( - private readonly usersService: UsersService, + private readonly usersFacade: UsersFacade, private readonly mappingsService: MappingsService, private readonly jwtService: JwtService, private readonly configService: ConfigService, @@ -70,7 +70,7 @@ export class AuthFacade { // Check database try { - await this.usersService.findByEmail("health-check@test.com"); + await this.usersFacade.findByEmail("health-check@test.com"); health.database = true; } catch (error) { this.logger.debug("Database health check failed", { error: getErrorMessage(error) }); @@ -121,7 +121,7 @@ export class AuthFacade { await this.authRateLimitService.clearLoginAttempts(request); } // Update last login time and reset failed attempts - await this.usersService.update(user.id, { + await this.usersFacade.update(user.id, { lastLoginAt: new Date(), failedLoginAttempts: 0, lockedUntil: null, @@ -136,7 +136,7 @@ export class AuthFacade { true ); - const prismaUser = await this.usersService.findByIdInternal(user.id); + const prismaUser = await this.usersFacade.findByIdInternal(user.id); if (!prismaUser) { throw new UnauthorizedException("User record missing"); } @@ -180,7 +180,7 @@ export class AuthFacade { password: string, _request?: Request ): Promise<{ id: string; email: string; role: string } | null> { - const user = await this.usersService.findByEmailInternal(email); + const user = await this.usersFacade.findByEmailInternal(email); if (!user) { await this.auditService.logAuthEvent( @@ -263,7 +263,7 @@ export class AuthFacade { isAccountLocked = true; } - await this.usersService.update(user.id, { + await this.usersFacade.update(user.id, { failedLoginAttempts: newFailedAttempts, lockedUntil, }); @@ -383,7 +383,7 @@ export class AuthFacade { let needsPasswordSet = false; try { - portalUser = await this.usersService.findByEmailInternal(normalized); + portalUser = await this.usersFacade.findByEmailInternal(normalized); if (portalUser) { mapped = await this.mappingsService.hasMapping(portalUser.id); needsPasswordSet = !portalUser.passwordHash; diff --git a/apps/bff/src/modules/auth/infra/token/token.service.ts b/apps/bff/src/modules/auth/infra/token/token.service.ts index 43e59d43..56e9d7a5 100644 --- a/apps/bff/src/modules/auth/infra/token/token.service.ts +++ b/apps/bff/src/modules/auth/infra/token/token.service.ts @@ -11,7 +11,7 @@ import { Logger } from "nestjs-pino"; import { randomBytes, createHash } from "crypto"; import type { AuthTokens } from "@customer-portal/domain/auth"; import type { User } from "@customer-portal/domain/customer"; -import { UsersService } from "@bff/modules/users/users.service"; +import { UsersFacade } from "@bff/modules/users/application/users.facade"; import { mapPrismaUserToDomain } from "@bff/infra/mappers"; export interface RefreshTokenPayload { @@ -53,7 +53,7 @@ export class AuthTokenService { private readonly configService: ConfigService, @Inject("REDIS_CLIENT") private readonly redis: Redis, @Inject(Logger) private readonly logger: Logger, - private readonly usersService: UsersService + private readonly usersFacade: UsersFacade ) { this.allowRedisFailOpen = this.configService.get("AUTH_ALLOW_REDIS_TOKEN_FAILOPEN", "false") === "true"; @@ -259,25 +259,20 @@ export class AuthTokenService { } // Get user info from database (using internal method to get role) - const prismaUser = await this.usersService.findByIdInternal(payload.userId); - if (!prismaUser) { + const user = await this.usersFacade.findByIdInternal(payload.userId); + if (!user) { this.logger.warn("User not found during token refresh", { userId: payload.userId }); throw new UnauthorizedException("User not found"); } // Convert to the format expected by generateTokenPair - const user = { - id: prismaUser.id, - email: prismaUser.email, - role: prismaUser.role || "USER", - }; + const userProfile = mapPrismaUserToDomain(user); // Invalidate current refresh token await this.redis.del(`${this.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`); // Generate new token pair const newTokenPair = await this.generateTokenPair(user, deviceInfo); - const userProfile = mapPrismaUserToDomain(prismaUser); this.logger.debug("Refreshed token pair", { userId: payload.userId }); diff --git a/apps/bff/src/modules/auth/infra/workflows/workflows/password-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/workflows/password-workflow.service.ts index 119b43d5..2ee0be31 100644 --- a/apps/bff/src/modules/auth/infra/workflows/workflows/password-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/workflows/password-workflow.service.ts @@ -4,7 +4,7 @@ import { JwtService } from "@nestjs/jwt"; import { Logger } from "nestjs-pino"; import * as bcrypt from "bcrypt"; import type { Request } from "express"; -import { UsersService } from "@bff/modules/users/users.service"; +import { UsersFacade } from "@bff/modules/users/application/users.facade"; import { AuditService, AuditAction } from "@bff/infra/audit/audit.service"; import { EmailService } from "@bff/infra/email/email.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; @@ -20,7 +20,7 @@ import { mapPrismaUserToDomain } from "@bff/infra/mappers"; @Injectable() export class PasswordWorkflowService { constructor( - private readonly usersService: UsersService, + private readonly usersFacade: UsersFacade, private readonly auditService: AuditService, private readonly configService: ConfigService, private readonly emailService: EmailService, @@ -31,7 +31,7 @@ export class PasswordWorkflowService { ) {} async checkPasswordNeeded(email: string) { - const user = await this.usersService.findByEmailInternal(email); + const user = await this.usersFacade.findByEmailInternal(email); if (!user) { return { needsPasswordSet: false, userExists: false }; } @@ -44,7 +44,7 @@ export class PasswordWorkflowService { } async setPassword(email: string, password: string) { - const user = await this.usersService.findByEmailInternal(email); + const user = await this.usersFacade.findByEmailInternal(email); if (!user) { throw new UnauthorizedException("User not found"); } @@ -57,8 +57,8 @@ export class PasswordWorkflowService { const saltRounds = typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig; const passwordHash = await bcrypt.hash(password, saltRounds); - await this.usersService.update(user.id, { passwordHash }); - const prismaUser = await this.usersService.findByIdInternal(user.id); + await this.usersFacade.update(user.id, { passwordHash }); + const prismaUser = await this.usersFacade.findByIdInternal(user.id); if (!prismaUser) { throw new Error("Failed to load user after password setup"); } @@ -78,7 +78,7 @@ export class PasswordWorkflowService { if (request) { await this.authRateLimitService.consumePasswordReset(request); } - const user = await this.usersService.findByEmailInternal(email); + const user = await this.usersFacade.findByEmailInternal(email); if (!user) { return; } @@ -119,7 +119,7 @@ export class PasswordWorkflowService { throw new BadRequestException("Invalid token"); } - const prismaUser = await this.usersService.findByIdInternal(payload.sub); + const prismaUser = await this.usersFacade.findByIdInternal(payload.sub); if (!prismaUser) throw new BadRequestException("Invalid token"); const saltRoundsConfig = this.configService.get("BCRYPT_ROUNDS", 12); @@ -127,8 +127,8 @@ export class PasswordWorkflowService { typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig; const passwordHash = await bcrypt.hash(newPassword, saltRounds); - await this.usersService.update(prismaUser.id, { passwordHash }); - const freshUser = await this.usersService.findByIdInternal(prismaUser.id); + await this.usersFacade.update(prismaUser.id, { passwordHash }); + const freshUser = await this.usersFacade.findByIdInternal(prismaUser.id); if (!freshUser) { throw new Error("Failed to load user after password reset"); } @@ -154,7 +154,7 @@ export class PasswordWorkflowService { data: ChangePasswordRequest, request?: Request ): Promise { - const user = await this.usersService.findByIdInternal(userId); + const user = await this.usersFacade.findByIdInternal(userId); if (!user) { throw new UnauthorizedException("User not found"); @@ -188,8 +188,8 @@ export class PasswordWorkflowService { typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig; const passwordHash = await bcrypt.hash(newPassword, saltRounds); - await this.usersService.update(user.id, { passwordHash }); - const prismaUser = await this.usersService.findByIdInternal(user.id); + await this.usersFacade.update(user.id, { passwordHash }); + const prismaUser = await this.usersFacade.findByIdInternal(user.id); if (!prismaUser) { throw new Error("Failed to load user after password change"); } diff --git a/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts index 9e4f6dd5..18c297a2 100644 --- a/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts @@ -10,7 +10,7 @@ import { Logger } from "nestjs-pino"; import * as bcrypt from "bcrypt"; import type { Request } from "express"; import { AuditService, AuditAction } from "@bff/infra/audit/audit.service"; -import { UsersService } from "@bff/modules/users/users.service"; +import { UsersFacade } from "@bff/modules/users/application/users.facade"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service"; @@ -35,7 +35,7 @@ type _SanitizedPrismaUser = Omit< @Injectable() export class SignupWorkflowService { constructor( - private readonly usersService: UsersService, + private readonly usersFacade: UsersFacade, private readonly mappingsService: MappingsService, private readonly whmcsService: WhmcsService, private readonly salesforceService: SalesforceService, @@ -153,7 +153,7 @@ export class SignupWorkflowService { gender, } = signupData; - const existingUser = await this.usersService.findByEmailInternal(email); + const existingUser = await this.usersFacade.findByEmailInternal(email); if (existingUser) { const mapped = await this.mappingsService.hasMapping(existingUser.id); const message = mapped @@ -330,7 +330,7 @@ export class SignupWorkflowService { throw new BadRequestException(`Failed to create user account: ${getErrorMessage(dbError)}`); } - const freshUser = await this.usersService.findByIdInternal(createdUserId); + const freshUser = await this.usersFacade.findByIdInternal(createdUserId); await this.auditService.logAuthEvent( AuditAction.SIGNUP, @@ -340,7 +340,7 @@ export class SignupWorkflowService { true ); - const prismaUser = freshUser ?? (await this.usersService.findByIdInternal(createdUserId)); + const prismaUser = freshUser ?? (await this.usersFacade.findByIdInternal(createdUserId)); if (!prismaUser) { throw new Error("Failed to load created user"); @@ -395,20 +395,20 @@ export class SignupWorkflowService { whmcs: { clientExists: false }, }; - const portalUser = await this.usersService.findByEmailInternal(normalizedEmail); - if (portalUser) { + const portalUserAuth = await this.usersFacade.findByEmailInternal(normalizedEmail); + if (portalUserAuth) { result.portal.userExists = true; - const mapped = await this.mappingsService.hasMapping(portalUser.id); + const mapped = await this.mappingsService.hasMapping(portalUserAuth.id); if (mapped) { result.nextAction = "login"; result.messages.push("An account already exists. Please sign in."); return result; } - result.portal.needsPasswordSet = !portalUser.passwordHash; - result.nextAction = portalUser.passwordHash ? "login" : "fix_input"; + result.portal.needsPasswordSet = !portalUserAuth.passwordHash; + result.nextAction = portalUserAuth.passwordHash ? "login" : "fix_input"; result.messages.push( - portalUser.passwordHash + portalUserAuth.passwordHash ? "An account exists without billing link. Please sign in to continue setup." : "An account exists and needs password setup. Please set a password to continue." ); diff --git a/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts index 879cc6e2..784871aa 100644 --- a/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts @@ -6,7 +6,7 @@ import { UnauthorizedException, } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { UsersService } from "@bff/modules/users/users.service"; +import { UsersFacade } from "@bff/modules/users/application/users.facade"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service"; @@ -18,7 +18,7 @@ import type { User } from "@customer-portal/domain/customer"; @Injectable() export class WhmcsLinkWorkflowService { constructor( - private readonly usersService: UsersService, + private readonly usersFacade: UsersFacade, private readonly mappingsService: MappingsService, private readonly whmcsService: WhmcsService, private readonly salesforceService: SalesforceService, @@ -26,7 +26,7 @@ export class WhmcsLinkWorkflowService { ) {} async linkWhmcsUser(email: string, password: string) { - const existingUser = await this.usersService.findByEmailInternal(email); + const existingUser = await this.usersFacade.findByEmailInternal(email); if (existingUser) { if (!existingUser.passwordHash) { this.logger.log("User exists but has no password - allowing password setup to continue", { @@ -137,7 +137,7 @@ export class WhmcsLinkWorkflowService { ); } - const createdUser = await this.usersService.create({ + const createdUser = await this.usersFacade.create({ email, passwordHash: null, emailVerified: true, @@ -149,7 +149,7 @@ export class WhmcsLinkWorkflowService { sfAccountId: sfAccount.id, }); - const prismaUser = await this.usersService.findByIdInternal(createdUser.id); + const prismaUser = await this.usersFacade.findByIdInternal(createdUser.id); if (!prismaUser) { throw new Error("Failed to load newly linked user"); } diff --git a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts index 41fce33c..bd657c75 100644 --- a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts +++ b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts @@ -18,7 +18,7 @@ import { AuthThrottleGuard } from "./guards/auth-throttle.guard"; import { FailedLoginThrottleGuard } from "./guards/failed-login-throttle.guard"; import { LoginResultInterceptor } from "./interceptors/login-result.interceptor"; import { Public } from "../../decorators/public.decorator"; -import { ZodValidationPipe } from "@bff/core/validation"; +import { ZodValidationPipe } from "@customer-portal/validation/nestjs"; // Import Zod schemas from domain import { @@ -33,6 +33,8 @@ import { ssoLinkRequestSchema, checkPasswordNeededRequestSchema, refreshTokenRequestSchema, + checkPasswordNeededResponseSchema, + linkWhmcsResponseSchema, type SignupRequest, type PasswordResetRequest, type ResetPasswordRequest, @@ -216,7 +218,8 @@ export class AuthController { @Throttle({ default: { limit: 5, ttl: 600 } }) // 5 attempts per 10 minutes per IP (industry standard) @UsePipes(new ZodValidationPipe(linkWhmcsRequestSchema)) async linkWhmcs(@Body() linkData: LinkWhmcsRequest, @Req() _req: Request) { - return this.authFacade.linkWhmcsUser(linkData); + const result = await this.authFacade.linkWhmcsUser(linkData); + return linkWhmcsResponseSchema.parse(result); } @Public() @@ -239,7 +242,8 @@ export class AuthController { @UsePipes(new ZodValidationPipe(checkPasswordNeededRequestSchema)) @HttpCode(200) async checkPasswordNeeded(@Body() data: CheckPasswordNeededRequest) { - return this.authFacade.checkPasswordNeeded(data.email); + const response = await this.authFacade.checkPasswordNeeded(data.email); + return checkPasswordNeededResponseSchema.parse(response); } @Public() diff --git a/apps/bff/src/modules/auth/presentation/strategies/jwt.strategy.ts b/apps/bff/src/modules/auth/presentation/strategies/jwt.strategy.ts index 5680ccc1..cef1761d 100644 --- a/apps/bff/src/modules/auth/presentation/strategies/jwt.strategy.ts +++ b/apps/bff/src/modules/auth/presentation/strategies/jwt.strategy.ts @@ -3,7 +3,7 @@ import { PassportStrategy } from "@nestjs/passport"; import { ExtractJwt, Strategy } from "passport-jwt"; import { ConfigService } from "@nestjs/config"; import type { UserAuth } from "@customer-portal/domain/customer"; -import { UsersService } from "@bff/modules/users/users.service"; +import { UsersFacade } from "@bff/modules/users/application/users.facade"; import { mapPrismaUserToDomain } from "@bff/infra/mappers"; import type { Request } from "express"; @@ -20,7 +20,7 @@ const cookieExtractor = (req: Request): string | null => { export class JwtStrategy extends PassportStrategy(Strategy) { constructor( private configService: ConfigService, - private readonly usersService: UsersService + private readonly usersFacade: UsersFacade ) { const jwtSecret = configService.get("JWT_SECRET"); if (!jwtSecret) { @@ -65,7 +65,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { throw new UnauthorizedException("Token missing expiration claim"); } - const prismaUser = await this.usersService.findByIdInternal(payload.sub); + const prismaUser = await this.usersFacade.findByIdInternal(payload.sub); if (!prismaUser) { throw new UnauthorizedException("User not found"); diff --git a/apps/bff/src/modules/invoices/invoices.controller.ts b/apps/bff/src/modules/invoices/invoices.controller.ts index 4f5a4b72..0f96083d 100644 --- a/apps/bff/src/modules/invoices/invoices.controller.ts +++ b/apps/bff/src/modules/invoices/invoices.controller.ts @@ -13,7 +13,7 @@ import { import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; -import { ZodValidationPipe } from "@bff/core/validation"; +import { ZodValidationPipe } from "@customer-portal/validation/nestjs"; import type { RequestWithUser } from "@bff/modules/auth/auth.types"; import type { diff --git a/apps/bff/src/modules/orders/config/order-field-map.service.ts b/apps/bff/src/modules/orders/config/order-field-map.service.ts index 03cbab42..e191c81e 100644 --- a/apps/bff/src/modules/orders/config/order-field-map.service.ts +++ b/apps/bff/src/modules/orders/config/order-field-map.service.ts @@ -1,120 +1,32 @@ import { Injectable } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; - -export interface OrderFieldMap { - order: { - type: string; - activationType: string; - activationScheduledAt: string; - activationStatus: string; - activationErrorCode: string; - activationErrorMessage: string; - activationLastAttemptAt: string; - internetPlanTier: string; - installationType: string; - weekendInstall: string; - accessMode: string; - hikariDenwa: string; - vpnRegion: string; - simType: string; - simVoiceMail: string; - simCallWaiting: string; - eid: string; - whmcsOrderId: string; - addressChanged: string; - billingStreet: string; - billingCity: string; - billingState: string; - billingPostalCode: string; - billingCountry: string; - mnpApplication: string; - mnpReservation: string; - mnpExpiry: string; - mnpPhone: string; - mvnoAccountNumber: string; - portingDateOfBirth: string; - portingFirstName: string; - portingLastName: string; - portingFirstNameKatakana: string; - portingLastNameKatakana: string; - portingGender: string; - }; - orderItem: { - billingCycle: string; - whmcsServiceId: string; - }; - product: { - sku: string; - itemClass: string; - billingCycle: string; - whmcsProductId: string; - internetOfferingType: string; - internetPlanTier: string; - vpnRegion: string; - }; -} +import { + createSalesforceOrderFieldMap, + defaultSalesforceOrderFieldMap, + type PartialSalesforceOrderFieldMap, + type SalesforceOrderFieldMap, +} from "@customer-portal/domain/orders"; const unique = (values: T[]): T[] => Array.from(new Set(values)); +const SECTION_PREFIX: Record = { + order: "ORDER", + orderItem: "ORDER_ITEM", + product: "PRODUCT", +}; + @Injectable() export class OrderFieldMapService { - readonly fields: OrderFieldMap; + readonly fields: SalesforceOrderFieldMap; constructor(private readonly config: ConfigService) { - const resolve = (key: string) => this.config.get(key, { infer: true }) ?? key; - - this.fields = { - order: { - type: resolve("ORDER_TYPE_FIELD"), - activationType: resolve("ORDER_ACTIVATION_TYPE_FIELD"), - activationScheduledAt: resolve("ORDER_ACTIVATION_SCHEDULED_AT_FIELD"), - activationStatus: resolve("ORDER_ACTIVATION_STATUS_FIELD"), - activationErrorCode: resolve("ORDER_ACTIVATION_ERROR_CODE_FIELD"), - activationErrorMessage: resolve("ORDER_ACTIVATION_ERROR_MESSAGE_FIELD"), - activationLastAttemptAt: resolve("ORDER_ACTIVATION_LAST_ATTEMPT_AT_FIELD"), - internetPlanTier: resolve("ORDER_INTERNET_PLAN_TIER_FIELD"), - installationType: resolve("ORDER_INSTALLATION_TYPE_FIELD"), - weekendInstall: resolve("ORDER_WEEKEND_INSTALL_FIELD"), - accessMode: resolve("ORDER_ACCESS_MODE_FIELD"), - hikariDenwa: resolve("ORDER_HIKARI_DENWA_FIELD"), - vpnRegion: resolve("ORDER_VPN_REGION_FIELD"), - simType: resolve("ORDER_SIM_TYPE_FIELD"), - simVoiceMail: resolve("ORDER_SIM_VOICE_MAIL_FIELD"), - simCallWaiting: resolve("ORDER_SIM_CALL_WAITING_FIELD"), - eid: resolve("ORDER_EID_FIELD"), - whmcsOrderId: resolve("ORDER_WHMCS_ORDER_ID_FIELD"), - addressChanged: resolve("ORDER_ADDRESS_CHANGED_FIELD"), - billingStreet: resolve("ORDER_BILLING_STREET_FIELD"), - billingCity: resolve("ORDER_BILLING_CITY_FIELD"), - billingState: resolve("ORDER_BILLING_STATE_FIELD"), - billingPostalCode: resolve("ORDER_BILLING_POSTAL_CODE_FIELD"), - billingCountry: resolve("ORDER_BILLING_COUNTRY_FIELD"), - mnpApplication: resolve("ORDER_MNP_APPLICATION_FIELD"), - mnpReservation: resolve("ORDER_MNP_RESERVATION_FIELD"), - mnpExpiry: resolve("ORDER_MNP_EXPIRY_FIELD"), - mnpPhone: resolve("ORDER_MNP_PHONE_FIELD"), - mvnoAccountNumber: resolve("ORDER_MVNO_ACCOUNT_NUMBER_FIELD"), - portingDateOfBirth: resolve("ORDER_PORTING_DOB_FIELD"), - portingFirstName: resolve("ORDER_PORTING_FIRST_NAME_FIELD"), - portingLastName: resolve("ORDER_PORTING_LAST_NAME_FIELD"), - portingFirstNameKatakana: resolve("ORDER_PORTING_FIRST_NAME_KATAKANA_FIELD"), - portingLastNameKatakana: resolve("ORDER_PORTING_LAST_NAME_KATAKANA_FIELD"), - portingGender: resolve("ORDER_PORTING_GENDER_FIELD"), - }, - orderItem: { - billingCycle: resolve("ORDER_ITEM_BILLING_CYCLE_FIELD"), - whmcsServiceId: resolve("ORDER_ITEM_WHMCS_SERVICE_ID_FIELD"), - }, - product: { - sku: resolve("PRODUCT_SKU_FIELD"), - itemClass: resolve("PRODUCT_ITEM_CLASS_FIELD"), - billingCycle: resolve("PRODUCT_BILLING_CYCLE_FIELD"), - whmcsProductId: resolve("PRODUCT_WHMCS_PRODUCT_ID_FIELD"), - internetOfferingType: resolve("PRODUCT_INTERNET_OFFERING_TYPE_FIELD"), - internetPlanTier: resolve("PRODUCT_INTERNET_PLAN_TIER_FIELD"), - vpnRegion: resolve("PRODUCT_VPN_REGION_FIELD"), - }, + const overrides: PartialSalesforceOrderFieldMap = { + order: this.resolveSection("order"), + orderItem: this.resolveSection("orderItem"), + product: this.resolveSection("product"), }; + + this.fields = createSalesforceOrderFieldMap(overrides); } buildOrderSelectFields(additional: string[] = []): string[] { @@ -189,4 +101,32 @@ export class OrderFieldMapService { return unique([...base, ...additional]); } + + private resolveSection
( + section: Section + ): Partial { + const defaults = defaultSalesforceOrderFieldMap[section]; + const resolvedEntries = Object.entries(defaults).map(([key, defaultValue]) => { + const envKey = buildEnvKey(section, key); + const resolved = this.config.get(envKey, { infer: true }); + return [key, resolved ?? defaultValue]; + }); + + return Object.fromEntries(resolvedEntries) as Partial; + } +} + +function buildEnvKey
( + 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(); } diff --git a/apps/bff/src/modules/orders/controllers/checkout.controller.ts b/apps/bff/src/modules/orders/controllers/checkout.controller.ts index ebd2abac..ea3a9067 100644 --- a/apps/bff/src/modules/orders/controllers/checkout.controller.ts +++ b/apps/bff/src/modules/orders/controllers/checkout.controller.ts @@ -1,6 +1,6 @@ import { Body, Controller, Post, Request, UsePipes, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { ZodValidationPipe } from "@bff/core/validation"; +import { ZodValidationPipe } from "@customer-portal/validation/nestjs"; import { CheckoutService } from "../services/checkout.service"; import { CheckoutCart, diff --git a/apps/bff/src/modules/orders/orders.controller.ts b/apps/bff/src/modules/orders/orders.controller.ts index 938261ca..e3f70bac 100644 --- a/apps/bff/src/modules/orders/orders.controller.ts +++ b/apps/bff/src/modules/orders/orders.controller.ts @@ -3,7 +3,7 @@ import { Throttle, ThrottlerGuard } from "@nestjs/throttler"; import { OrderOrchestrator } from "./services/order-orchestrator.service"; import type { RequestWithUser } from "@bff/modules/auth/auth.types"; import { Logger } from "nestjs-pino"; -import { ZodValidationPipe } from "@bff/core/validation"; +import { ZodValidationPipe } from "@customer-portal/validation/nestjs"; import { createOrderRequestSchema, orderCreateResponseSchema, diff --git a/apps/bff/src/modules/orders/services/order-builder.service.ts b/apps/bff/src/modules/orders/services/order-builder.service.ts index edfcbcc7..ea7f9bdb 100644 --- a/apps/bff/src/modules/orders/services/order-builder.service.ts +++ b/apps/bff/src/modules/orders/services/order-builder.service.ts @@ -1,7 +1,7 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import type { OrderBusinessValidation, UserMapping } from "@customer-portal/domain/orders"; -import { UsersService } from "@bff/modules/users/users.service"; +import { UsersFacade } from "@bff/modules/users/application/users.facade"; import { OrderFieldMapService } from "@bff/modules/orders/config/order-field-map.service"; function assignIfString(target: Record, key: string, value: unknown): void { @@ -17,7 +17,7 @@ function assignIfString(target: Record, key: string, value: unk export class OrderBuilder { constructor( @Inject(Logger) private readonly logger: Logger, - private readonly usersService: UsersService, + private readonly usersFacade: UsersFacade, private readonly orderFieldMap: OrderFieldMapService ) {} @@ -121,7 +121,7 @@ export class OrderBuilder { fieldNames: OrderFieldMapService["fields"]["order"] ): Promise { try { - const profile = await this.usersService.getProfile(userId); + const profile = await this.usersFacade.getProfile(userId); const address = profile.address; const orderAddress = (body.configurations as Record)?.address as | Record diff --git a/apps/bff/src/modules/subscriptions/sim-orders.controller.ts b/apps/bff/src/modules/subscriptions/sim-orders.controller.ts index 25c76fa9..75e0624e 100644 --- a/apps/bff/src/modules/subscriptions/sim-orders.controller.ts +++ b/apps/bff/src/modules/subscriptions/sim-orders.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Post, Request, UsePipes, Headers } from "@nestjs/common"; import type { RequestWithUser } from "@bff/modules/auth/auth.types"; import { SimOrderActivationService } from "./sim-order-activation.service"; -import { ZodValidationPipe } from "@bff/core/validation"; +import { ZodValidationPipe } from "@customer-portal/validation/nestjs"; import { simOrderActivationRequestSchema, type SimOrderActivationRequest, diff --git a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts index bb74c5f4..2d7940ec 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts @@ -39,7 +39,7 @@ import { type SimFeaturesRequest, type SimReissueRequest, } from "@customer-portal/domain/sim"; -import { ZodValidationPipe } from "@bff/core/validation"; +import { ZodValidationPipe } from "@customer-portal/validation/nestjs"; import type { RequestWithUser } from "@bff/modules/auth/auth.types"; const subscriptionInvoiceQuerySchema = createPaginationSchema({ diff --git a/apps/bff/src/modules/users/application/users.facade.ts b/apps/bff/src/modules/users/application/users.facade.ts new file mode 100644 index 00000000..1ce2e2d6 --- /dev/null +++ b/apps/bff/src/modules/users/application/users.facade.ts @@ -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 +>; + +@Injectable() +export class UsersFacade { + constructor( + private readonly authRepository: UserAuthRepository, + private readonly profileService: UserProfileService, + @Inject(Logger) private readonly logger: Logger + ) {} + + async findByEmail(email: string): Promise { + 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 { + return this.authRepository.findByEmail(email); + } + + async findById(id: string): Promise { + return this.profileService.findById(id); + } + + async findByIdInternal(id: string): Promise { + return this.authRepository.findById(id); + } + + async getProfile(userId: string): Promise { + return this.profileService.getProfile(userId); + } + + async getAddress(userId: string): Promise
{ + return this.profileService.getAddress(userId); + } + + async updateAddress(userId: string, update: Partial
): Promise
{ + return this.profileService.updateAddress(userId, update); + } + + async updateProfile(userId: string, update: UpdateCustomerProfileRequest): Promise { + return this.profileService.updateProfile(userId, update); + } + + async getUserSummary(userId: string): Promise { + return this.profileService.getUserSummary(userId); + } + + async create(userData: Partial): Promise { + 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 { + 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; + } +} + diff --git a/apps/bff/src/modules/users/infra/user-auth.repository.ts b/apps/bff/src/modules/users/infra/user-auth.repository.ts new file mode 100644 index 00000000..390472b8 --- /dev/null +++ b/apps/bff/src/modules/users/infra/user-auth.repository.ts @@ -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 { + 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 { + 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): Promise { + 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): Promise { + 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)}`); + } + } +} + diff --git a/apps/bff/src/modules/users/users.service.ts b/apps/bff/src/modules/users/infra/user-profile.service.ts similarity index 63% rename from apps/bff/src/modules/users/users.service.ts rename to apps/bff/src/modules/users/infra/user-profile.service.ts index ab65fc5e..489f4e71 100644 --- a/apps/bff/src/modules/users/users.service.ts +++ b/apps/bff/src/modules/users/infra/user-profile.service.ts @@ -2,12 +2,6 @@ import { Injectable, Inject, NotFoundException, BadRequestException } from "@nes import { Logger } from "nestjs-pino"; import type { User as PrismaUser } from "@prisma/client"; import { getErrorMessage } from "@bff/core/utils/error.util"; -import { normalizeAndValidateEmail, validateUuidV4OrThrow } from "@customer-portal/domain/common"; -import { PrismaService } from "@bff/infra/database/prisma.service"; -import { - updateCustomerProfileRequestSchema, - type UpdateCustomerProfileRequest, -} from "@customer-portal/domain/auth"; import { Providers as CustomerProviders, addressSchema, @@ -15,146 +9,53 @@ import { type Address, type User, } from "@customer-portal/domain/customer"; +import { + updateCustomerProfileRequestSchema, + type UpdateCustomerProfileRequest, +} from "@customer-portal/domain/auth"; import type { Subscription } from "@customer-portal/domain/subscriptions"; import type { Invoice } from "@customer-portal/domain/billing"; import type { Activity, DashboardSummary, NextInvoice } from "@customer-portal/domain/dashboard"; import { dashboardSummarySchema } from "@customer-portal/domain/dashboard"; +import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service"; -import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; - -// Use a subset of PrismaUser for auth-related updates only -type UserUpdateData = Partial< - Pick ->; +import { validateUuidV4OrThrow } from "@customer-portal/domain/common"; +import { UserAuthRepository } from "./user-auth.repository"; @Injectable() -export class UsersService { +export class UserProfileService { constructor( - private prisma: PrismaService, - private whmcsService: WhmcsService, - private salesforceService: SalesforceService, - private mappingsService: MappingsService, + private readonly userAuthRepository: UserAuthRepository, + private readonly mappingsService: MappingsService, + private readonly whmcsService: WhmcsService, + private readonly salesforceService: SalesforceService, @Inject(Logger) private readonly logger: Logger ) {} - /** - * Find user by email - returns authenticated user with full profile from WHMCS - */ - async findByEmail(email: string): Promise { - 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 { - 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 { - 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 { - 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 { - 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
{ + async findById(userId: string): Promise { 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 { + 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
{ + const profile = await this.getProfile(userId); return profile.address ?? null; } - /** - * Update customer address in WHMCS - */ async updateAddress(userId: string, addressUpdate: Partial
): Promise
{ const validId = validateUuidV4OrThrow(userId); const parsed = addressSchema.partial().parse(addressUpdate ?? {}); @@ -206,56 +107,6 @@ export class UsersService { } } - /** - * Create user (auth state only in portal DB) - */ - async create(userData: Partial): Promise { - 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 { - 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 { const validId = validateUuidV4OrThrow(userId); const parsed = updateCustomerProfileRequestSchema.parse(update); @@ -266,19 +117,14 @@ export class UsersService { throw new NotFoundException("User mapping not found"); } - // Update in WHMCS (all fields optional) await this.whmcsService.updateClient(mapping.whmcsClientId, parsed); this.logger.log({ userId: validId }, "Successfully updated customer profile in WHMCS"); - // Return fresh profile return this.getProfile(validId); } catch (error) { const msg = getErrorMessage(error); - this.logger.error( - { userId: validId, error: msg }, - "Failed to update customer profile in WHMCS" - ); + this.logger.error({ userId: validId, error: msg }, "Failed to update customer profile in WHMCS"); if (msg.includes("WHMCS API Error")) { throw new BadRequestException(msg.replace("WHMCS API Error: ", "")); @@ -289,38 +135,23 @@ export class UsersService { if (msg.includes("Missing required WHMCS configuration")) { throw new BadRequestException("Billing system not configured. Please contact support."); } + throw new BadRequestException("Unable to update profile."); } } - private sanitizeUserData(userData: UserUpdateData): Partial { - const sanitized: Partial = {}; - - // 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 { try { - // Verify user exists - const user = await this.prisma.user.findUnique({ where: { id: userId } }); + const user = await this.userAuthRepository.findById(userId); if (!user) { throw new NotFoundException("User not found"); } - // Check if user has WHMCS mapping const mapping = await this.mappingsService.findByUserId(userId); if (!mapping?.whmcsClientId) { this.logger.warn(`No WHMCS mapping found for user ${userId}`); - // Get currency from WHMCS profile if available - let currency = "JPY"; // Default + let currency = "JPY"; try { const profile = await this.getProfile(userId); currency = profile.currency_code || currency; @@ -344,15 +175,11 @@ export class UsersService { return summary; } - // Fetch live data from WHMCS in parallel const [subscriptionsData, invoicesData] = await Promise.allSettled([ this.whmcsService.getSubscriptions(mapping.whmcsClientId, userId), - this.whmcsService.getInvoices(mapping.whmcsClientId, userId, { - limit: 50, - }), + this.whmcsService.getInvoices(mapping.whmcsClientId, userId, { limit: 50 }), ]); - // Process subscriptions let activeSubscriptions = 0; let recentSubscriptions: Array<{ id: number; @@ -362,12 +189,10 @@ export class UsersService { }> = []; if (subscriptionsData.status === "fulfilled") { const subscriptions: Subscription[] = subscriptionsData.value.subscriptions; - activeSubscriptions = subscriptions.filter( - (sub: Subscription) => sub.status === "Active" - ).length; + activeSubscriptions = subscriptions.filter(sub => sub.status === "Active").length; recentSubscriptions = subscriptions - .filter((sub: Subscription) => sub.status === "Active") - .sort((a: Subscription, b: Subscription) => { + .filter(sub => sub.status === "Active") + .sort((a, b) => { const aTime = a.registrationDate ? new Date(a.registrationDate).getTime() : Number.NEGATIVE_INFINITY; @@ -377,7 +202,7 @@ export class UsersService { return bTime - aTime; }) .slice(0, 3) - .map((sub: Subscription) => ({ + .map(sub => ({ id: sub.id, status: sub.status, registrationDate: sub.registrationDate, @@ -390,7 +215,6 @@ export class UsersService { ); } - // Process invoices let unpaidInvoices = 0; let nextInvoice: NextInvoice | null = null; let recentInvoices: Array<{ @@ -406,17 +230,13 @@ export class UsersService { if (invoicesData.status === "fulfilled") { const invoices: Invoice[] = invoicesData.value.invoices; - // Count unpaid invoices unpaidInvoices = invoices.filter( - (inv: Invoice) => inv.status === "Unpaid" || inv.status === "Overdue" + inv => inv.status === "Unpaid" || inv.status === "Overdue" ).length; - // Find next due invoice const upcomingInvoices = invoices - .filter( - (inv: Invoice) => (inv.status === "Unpaid" || inv.status === "Overdue") && inv.dueDate - ) - .sort((a: Invoice, b: Invoice) => { + .filter(inv => (inv.status === "Unpaid" || inv.status === "Overdue") && inv.dueDate) + .sort((a, b) => { const aTime = a.dueDate ? new Date(a.dueDate).getTime() : Number.POSITIVE_INFINITY; const bTime = b.dueDate ? new Date(b.dueDate).getTime() : Number.POSITIVE_INFINITY; return aTime - bTime; @@ -432,15 +252,14 @@ export class UsersService { }; } - // Recent invoices for activity recentInvoices = invoices - .sort((a: Invoice, b: Invoice) => { + .sort((a, b) => { const aTime = a.issuedAt ? new Date(a.issuedAt).getTime() : Number.NEGATIVE_INFINITY; const bTime = b.issuedAt ? new Date(b.issuedAt).getTime() : Number.NEGATIVE_INFINITY; return bTime - aTime; }) .slice(0, 5) - .map((inv: Invoice) => ({ + .map(inv => ({ id: inv.id, status: inv.status, dueDate: inv.dueDate, @@ -455,16 +274,14 @@ export class UsersService { }); } - // Build activity feed const activities: Activity[] = []; - // Add invoice activities recentInvoices.forEach(invoice => { if (invoice.status === "Paid") { - const metadata = { + const metadata: Record = { amount: invoice.total, currency: invoice.currency ?? "JPY", - } as Record; + }; if (invoice.dueDate) metadata.dueDate = invoice.dueDate; if (invoice.number) metadata.invoiceNumber = invoice.number; activities.push({ @@ -477,13 +294,13 @@ export class UsersService { metadata, }); } else if (invoice.status === "Unpaid" || invoice.status === "Overdue") { - const metadata = { + const metadata: Record = { amount: invoice.total, currency: invoice.currency ?? "JPY", - } as Record; + status: invoice.status, + }; if (invoice.dueDate) metadata.dueDate = invoice.dueDate; if (invoice.number) metadata.invoiceNumber = invoice.number; - metadata.status = invoice.status; activities.push({ id: `invoice-created-${invoice.id}`, type: "invoice_created", @@ -496,14 +313,14 @@ export class UsersService { } }); - // Add subscription activities recentSubscriptions.forEach(subscription => { - const metadata = { + const metadata: Record = { productName: subscription.productName, status: subscription.status, - } as Record; - if (subscription.registrationDate) + }; + if (subscription.registrationDate) { metadata.registrationDate = subscription.registrationDate; + } activities.push({ id: `service-activated-${subscription.id}`, type: "service_activated", @@ -515,7 +332,6 @@ export class UsersService { }); }); - // Sort activities by date and take top 10 activities.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); const recentActivity = activities.slice(0, 10); @@ -526,8 +342,7 @@ export class UsersService { hasNextInvoice: !!nextInvoice, }); - // Get currency from client data - let currency = "JPY"; // Default + let currency = "JPY"; try { const client = await this.whmcsService.getClientDetails(mapping.whmcsClientId); const resolvedCurrency = @@ -548,7 +363,7 @@ export class UsersService { stats: { activeSubscriptions, unpaidInvoices, - openCases: 0, // Support cases not implemented yet + openCases: 0, currency, }, nextInvoice, @@ -562,4 +377,25 @@ export class UsersService { throw new BadRequestException("Unable to retrieve dashboard summary"); } } + + private async getProfileForUser(user: PrismaUser): Promise { + 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"); + } + } } + diff --git a/apps/bff/src/modules/users/users.controller.ts b/apps/bff/src/modules/users/users.controller.ts index 41605dc4..93405ac3 100644 --- a/apps/bff/src/modules/users/users.controller.ts +++ b/apps/bff/src/modules/users/users.controller.ts @@ -8,8 +8,8 @@ import { ClassSerializerInterceptor, UsePipes, } from "@nestjs/common"; -import { UsersService } from "./users.service"; -import { ZodValidationPipe } from "@bff/core/validation"; +import { UsersFacade } from "./application/users.facade"; +import { ZodValidationPipe } from "@customer-portal/validation/nestjs"; import { updateCustomerProfileRequestSchema, type UpdateCustomerProfileRequest, @@ -20,7 +20,7 @@ import type { RequestWithUser } from "@bff/modules/auth/auth.types"; @Controller("me") @UseInterceptors(ClassSerializerInterceptor) export class UsersController { - constructor(private usersService: UsersService) {} + constructor(private usersFacade: UsersFacade) {} /** * GET /me - Get complete customer profile (includes address) @@ -28,7 +28,7 @@ export class UsersController { */ @Get() async getProfile(@Req() req: RequestWithUser) { - return this.usersService.findById(req.user.id); + return this.usersFacade.findById(req.user.id); } /** @@ -36,7 +36,7 @@ export class UsersController { */ @Get("summary") async getSummary(@Req() req: RequestWithUser) { - return this.usersService.getUserSummary(req.user.id); + return this.usersFacade.getUserSummary(req.user.id); } /** @@ -44,7 +44,7 @@ export class UsersController { */ @Get("address") async getAddress(@Req() req: RequestWithUser): Promise
{ - return this.usersService.getAddress(req.user.id); + return this.usersFacade.getAddress(req.user.id); } /** @@ -56,7 +56,7 @@ export class UsersController { @Req() req: RequestWithUser, @Body() address: Partial
): Promise
{ - return this.usersService.updateAddress(req.user.id, address); + return this.usersFacade.updateAddress(req.user.id, address); } /** @@ -75,6 +75,6 @@ export class UsersController { @Req() req: RequestWithUser, @Body() updateData: UpdateCustomerProfileRequest ) { - return this.usersService.updateProfile(req.user.id, updateData); + return this.usersFacade.updateProfile(req.user.id, updateData); } } diff --git a/apps/bff/src/modules/users/users.module.ts b/apps/bff/src/modules/users/users.module.ts index b7b71119..ab1a77b8 100644 --- a/apps/bff/src/modules/users/users.module.ts +++ b/apps/bff/src/modules/users/users.module.ts @@ -1,5 +1,7 @@ import { Module } from "@nestjs/common"; -import { UsersService } from "./users.service"; +import { UsersFacade } from "./application/users.facade"; +import { UserAuthRepository } from "./infra/user-auth.repository"; +import { UserProfileService } from "./infra/user-profile.service"; import { UsersController } from "./users.controller"; import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module"; import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module"; @@ -9,7 +11,7 @@ import { PrismaModule } from "@bff/infra/database/prisma.module"; @Module({ imports: [PrismaModule, WhmcsModule, SalesforceModule, MappingsModule], controllers: [UsersController], - providers: [UsersService], - exports: [UsersService], + providers: [UsersFacade, UserAuthRepository, UserProfileService], + exports: [UsersFacade, UserAuthRepository, UserProfileService], }) export class UsersModule {} diff --git a/apps/bff/tsconfig.json b/apps/bff/tsconfig.json index 5fa2f06b..329468f0 100644 --- a/apps/bff/tsconfig.json +++ b/apps/bff/tsconfig.json @@ -13,8 +13,15 @@ "@bff/core/*": ["src/core/*"], "@bff/infra/*": ["src/infra/*"], "@bff/modules/*": ["src/modules/*"], - "@bff/integrations/*": ["src/integrations/*"] + "@bff/integrations/*": ["src/integrations/*"], + "@customer-portal/validation": ["../../packages/validation/dist/index"], + "@customer-portal/validation/*": ["../../packages/validation/dist/*"] }, + "rootDirs": [ + "src", + "../../packages/validation/dist", + "../../packages/validation/src" + ], // Type checking "noEmit": true, diff --git a/apps/portal/src/features/account/hooks/useProfileEdit.ts b/apps/portal/src/features/account/hooks/useProfileEdit.ts index 934a47c8..1203e458 100644 --- a/apps/portal/src/features/account/hooks/useProfileEdit.ts +++ b/apps/portal/src/features/account/hooks/useProfileEdit.ts @@ -8,11 +8,12 @@ import { profileFormToRequest, type ProfileEditFormData, } from "@customer-portal/domain/customer"; +import { type UpdateCustomerProfileRequest } from "@customer-portal/domain/auth"; import { useZodForm } from "@customer-portal/validation"; export function useProfileEdit(initial: ProfileEditFormData) { const handleSave = useCallback(async (formData: ProfileEditFormData) => { - const requestData = profileFormToRequest(formData); + const requestData: UpdateCustomerProfileRequest = profileFormToRequest(formData); const updated = await accountService.updateProfile(requestData); useAuthStore.setState(state => ({ diff --git a/apps/portal/src/features/account/services/account.service.ts b/apps/portal/src/features/account/services/account.service.ts index 295e3cf7..fec01a87 100644 --- a/apps/portal/src/features/account/services/account.service.ts +++ b/apps/portal/src/features/account/services/account.service.ts @@ -1,32 +1,45 @@ import { apiClient, getDataOrThrow } from "@/lib/api"; import { getNullableData } from "@/lib/api/response-helpers"; -import type { UserProfile } from "@customer-portal/domain/customer"; -import type { Address } from "@customer-portal/domain/customer"; - -type ProfileUpdateInput = { - firstname?: string; - lastname?: string; - phonenumber?: string; -}; +import { + userSchema, + addressSchema, + type UserProfile, + type Address, +} from "@customer-portal/domain/customer"; +import { + updateCustomerProfileRequestSchema, + type UpdateCustomerProfileRequest, +} from "@customer-portal/domain/auth"; export const accountService = { async getProfile() { const response = await apiClient.GET("/api/me"); - return getNullableData(response); + const data = getNullableData(response); + if (!data) { + return null; + } + return userSchema.parse(data); }, - async updateProfile(update: ProfileUpdateInput) { - const response = await apiClient.PATCH("/api/me", { body: update }); - return getDataOrThrow(response, "Failed to update profile"); + async updateProfile(update: UpdateCustomerProfileRequest) { + const sanitized = updateCustomerProfileRequestSchema.parse(update); + const response = await apiClient.PATCH("/api/me", { body: sanitized }); + const data = getDataOrThrow(response, "Failed to update profile"); + return userSchema.parse(data); }, async getAddress() { const response = await apiClient.GET
("/api/me/address"); - return getNullableData
(response); + const data = getNullableData
(response); + if (!data) { + return null; + } + return addressSchema.parse(data); }, async updateAddress(address: Address) { const response = await apiClient.PATCH
("/api/me/address", { body: address }); - return getDataOrThrow
(response, "Failed to update address"); + const data = getDataOrThrow
(response, "Failed to update address"); + return addressSchema.parse(data); }, }; diff --git a/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx b/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx index 57415621..12931c7a 100644 --- a/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx +++ b/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx @@ -4,13 +4,17 @@ import { useCallback } from "react"; import { Button, Input, ErrorMessage } from "@/components/atoms"; import { FormField } from "@/components/molecules/FormField/FormField"; import { useWhmcsLink } from "@/features/auth/hooks"; -import { linkWhmcsRequestSchema, type LinkWhmcsRequest } from "@customer-portal/domain/auth"; +import { + linkWhmcsRequestSchema, + type LinkWhmcsRequest, + type LinkWhmcsResponse, +} from "@customer-portal/domain/auth"; type LinkWhmcsFormData = LinkWhmcsRequest; import { useZodForm } from "@customer-portal/validation"; interface LinkWhmcsFormProps { - onTransferred?: (result: { needsPasswordSet: boolean; email: string }) => void; + onTransferred?: (result: LinkWhmcsResponse) => void; className?: string; } @@ -25,7 +29,7 @@ export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormPr password: formData.password, }; const result = await linkWhmcs(payload); - onTransferred?.({ ...result, email: formData.email }); + onTransferred?.(result); }, [linkWhmcs, onTransferred, clearError] ); diff --git a/apps/portal/src/features/auth/services/auth.store.ts b/apps/portal/src/features/auth/services/auth.store.ts index a650a10d..fabea033 100644 --- a/apps/portal/src/features/auth/services/auth.store.ts +++ b/apps/portal/src/features/auth/services/auth.store.ts @@ -8,13 +8,17 @@ import { apiClient } from "@/lib/api"; import { getNullableData } from "@/lib/api/response-helpers"; import { getErrorInfo } from "@/lib/utils/error-handling"; import logger from "@customer-portal/logging"; -import type { - AuthTokens, - LinkWhmcsRequest, - LoginRequest, - SignupRequest, +import { + authResponseSchema, + checkPasswordNeededResponseSchema, + linkWhmcsResponseSchema, + type AuthTokens, + type CheckPasswordNeededResponse, + type LinkWhmcsRequest, + type LinkWhmcsResponse, + type LoginRequest, + type SignupRequest, } from "@customer-portal/domain/auth"; -import { authResponseSchema } from "@customer-portal/domain/auth"; import type { AuthenticatedUser } from "@customer-portal/domain/customer"; import { clearLogoutReason, @@ -42,8 +46,8 @@ export interface AuthState { requestPasswordReset: (email: string) => Promise; resetPassword: (token: string, password: string) => Promise; changePassword: (currentPassword: string, newPassword: string) => Promise; - checkPasswordNeeded: (email: string) => Promise<{ needsPasswordSet: boolean }>; - linkWhmcs: (request: LinkWhmcsRequest) => Promise<{ needsPasswordSet: boolean; email: string }>; + checkPasswordNeeded: (email: string) => Promise; + linkWhmcs: (request: LinkWhmcsRequest) => Promise; setPassword: (email: string, password: string) => Promise; refreshUser: () => Promise; refreshSession: () => Promise; @@ -229,12 +233,13 @@ export const useAuthStore = create()((set, get) => { body: { email }, }); - if (!response.data) { - throw new Error("Check failed"); + const parsed = checkPasswordNeededResponseSchema.safeParse(response.data); + if (!parsed.success) { + throw new Error(parsed.error.issues?.[0]?.message ?? "Check failed"); } set({ loading: false }); - return response.data as { needsPasswordSet: boolean }; + return parsed.data; } catch (error) { set({ loading: false, @@ -244,20 +249,20 @@ export const useAuthStore = create()((set, get) => { } }, - linkWhmcs: async ({ email, password }: LinkWhmcsRequest) => { + linkWhmcs: async (linkRequest: LinkWhmcsRequest) => { set({ loading: true, error: null }); try { const response = await apiClient.POST("/api/auth/link-whmcs", { - body: { email, password }, + body: linkRequest, }); - if (!response.data) { - throw new Error("WHMCS link failed"); + const parsed = linkWhmcsResponseSchema.safeParse(response.data); + if (!parsed.success) { + throw new Error(parsed.error.issues?.[0]?.message ?? "WHMCS link failed"); } set({ loading: false }); - const result = response.data as { needsPasswordSet: boolean }; - return { ...result, email }; + return parsed.data; } catch (error) { set({ loading: false, diff --git a/apps/portal/src/features/auth/views/LinkWhmcsView.tsx b/apps/portal/src/features/auth/views/LinkWhmcsView.tsx index b904d391..ee2e9e9b 100644 --- a/apps/portal/src/features/auth/views/LinkWhmcsView.tsx +++ b/apps/portal/src/features/auth/views/LinkWhmcsView.tsx @@ -40,8 +40,9 @@ export function LinkWhmcsView() { { - if (needsPasswordSet) { + onTransferred={result => { + const email = result.user.email; + if (result.needsPasswordSet) { router.push(`/auth/set-password?email=${encodeURIComponent(email)}`); return; } diff --git a/packages/domain/auth/contract.ts b/packages/domain/auth/contract.ts index a283f674..a028fa9f 100644 --- a/packages/domain/auth/contract.ts +++ b/packages/domain/auth/contract.ts @@ -74,6 +74,7 @@ export type { PasswordChangeResult, SsoLinkResponse, CheckPasswordNeededResponse, + LinkWhmcsResponse, // Error types AuthError, } from './schema'; diff --git a/packages/domain/auth/index.ts b/packages/domain/auth/index.ts index 734a7825..938664f6 100644 --- a/packages/domain/auth/index.ts +++ b/packages/domain/auth/index.ts @@ -45,6 +45,7 @@ export type { PasswordChangeResult, SsoLinkResponse, CheckPasswordNeededResponse, + LinkWhmcsResponse, // Error types AuthError, } from "./contract"; @@ -81,6 +82,7 @@ export { passwordChangeResultSchema, ssoLinkResponseSchema, checkPasswordNeededResponseSchema, + linkWhmcsResponseSchema, } from "./schema"; export { buildSignupRequest } from "./helpers"; diff --git a/packages/domain/auth/schema.ts b/packages/domain/auth/schema.ts index 70d910cf..353d04f9 100644 --- a/packages/domain/auth/schema.ts +++ b/packages/domain/auth/schema.ts @@ -185,6 +185,14 @@ export const checkPasswordNeededResponseSchema = z.object({ email: z.email().optional(), }); +/** + * Link WHMCS response + */ +export const linkWhmcsResponseSchema = z.object({ + user: userSchema, + needsPasswordSet: z.boolean(), +}); + // ============================================================================ // Inferred Types (Schema-First Approach) // ============================================================================ @@ -213,6 +221,7 @@ export type SignupResult = z.infer; export type PasswordChangeResult = z.infer; export type SsoLinkResponse = z.infer; export type CheckPasswordNeededResponse = z.infer; +export type LinkWhmcsResponse = z.infer; // ============================================================================ // Error Types diff --git a/packages/domain/orders/index.ts b/packages/domain/orders/index.ts index bdb7b206..baf1b8af 100644 --- a/packages/domain/orders/index.ts +++ b/packages/domain/orders/index.ts @@ -79,3 +79,4 @@ export * as Providers from "./providers/index"; // Re-export provider types for convenience export * from "./providers/whmcs/raw.types"; export * from "./providers/salesforce/raw.types"; +export * from "./providers/salesforce/field-map"; diff --git a/packages/domain/orders/providers/index.ts b/packages/domain/orders/providers/index.ts index 9503a0a6..cfcb8022 100644 --- a/packages/domain/orders/providers/index.ts +++ b/packages/domain/orders/providers/index.ts @@ -4,6 +4,7 @@ import * as WhmcsMapper from "./whmcs/mapper"; import * as WhmcsRaw from "./whmcs/raw.types"; +import * as SalesforceFieldMap from "./salesforce/field-map"; import * as SalesforceMapper from "./salesforce/mapper"; import * as SalesforceRaw from "./salesforce/raw.types"; @@ -17,6 +18,7 @@ export const Salesforce = { ...SalesforceMapper, mapper: SalesforceMapper, raw: SalesforceRaw, + fieldMap: SalesforceFieldMap, }; export { @@ -29,3 +31,4 @@ export * from "./whmcs/mapper"; export * from "./whmcs/raw.types"; export * from "./salesforce/mapper"; export * from "./salesforce/raw.types"; +export * from "./salesforce/field-map"; diff --git a/packages/domain/orders/providers/salesforce/field-map.ts b/packages/domain/orders/providers/salesforce/field-map.ts new file mode 100644 index 00000000..c8548062 --- /dev/null +++ b/packages/domain/orders/providers/salesforce/field-map.ts @@ -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; +}; + +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 }, + }; +} + diff --git a/packages/domain/orders/providers/salesforce/mapper.ts b/packages/domain/orders/providers/salesforce/mapper.ts index 3bdf8e7e..b61b821f 100644 --- a/packages/domain/orders/providers/salesforce/mapper.ts +++ b/packages/domain/orders/providers/salesforce/mapper.ts @@ -12,6 +12,14 @@ import type { } from "../../contract"; import { normalizeBillingCycle } from "../../helpers"; import { orderDetailsSchema, orderSummarySchema, orderItemDetailsSchema } from "../../schema"; +import type { + SalesforceProduct2WithPricebookEntries, + SalesforcePricebookEntryRecord, +} from "../../../catalog/providers/salesforce/raw.types"; +import { + defaultSalesforceOrderFieldMap, + type SalesforceOrderFieldMap, +} from "./field-map"; import type { SalesforceOrderItemRecord, SalesforceOrderRecord, @@ -21,34 +29,43 @@ import type { * Transform a Salesforce OrderItem record into domain details + summary. */ export function transformSalesforceOrderItem( - record: SalesforceOrderItemRecord + record: SalesforceOrderItemRecord, + fieldMap: SalesforceOrderFieldMap = defaultSalesforceOrderFieldMap ): { details: OrderItemDetails; summary: OrderItemSummary } { - // PricebookEntry is unknown to avoid circular dependencies between domains - const pricebookEntry = record.PricebookEntry as Record | null | undefined; - const product = pricebookEntry?.Product2 as Record | undefined; - const productBillingCycle = product?.Billing_Cycle__c ?? undefined; - const billingCycleRaw = record.Billing_Cycle__c ?? productBillingCycle ?? undefined; - const billingCycle = billingCycleRaw - ? normalizeBillingCycle(billingCycleRaw) - : undefined; + const pricebookEntry = (record.PricebookEntry ?? null) as + | SalesforcePricebookEntryRecord + | null; + const product = pricebookEntry?.Product2 as SalesforceProduct2WithPricebookEntries | undefined; + + const orderItemFields = fieldMap.orderItem; + const productFields = fieldMap.product; + + const billingCycleRaw = + record[orderItemFields.billingCycle] ?? + (product ? (product[productFields.billingCycle] as unknown) : undefined); + const billingCycle = + billingCycleRaw !== undefined && billingCycleRaw !== null + ? normalizeBillingCycle(billingCycleRaw) + : undefined; const details = orderItemDetailsSchema.parse({ id: record.Id, - orderId: record.OrderId ?? "", + orderId: ensureString(record.OrderId) ?? "", quantity: normalizeQuantity(record.Quantity), unitPrice: coerceNumber(record.UnitPrice), totalPrice: coerceNumber(record.TotalPrice), billingCycle, product: product ? { - id: product.Id ?? undefined, - name: product.Name ?? undefined, - sku: product.StockKeepingUnit ?? undefined, - itemClass: product.Item_Class__c ?? undefined, - whmcsProductId: product.WH_Product_ID__c ? String(product.WH_Product_ID__c) : undefined, - internetOfferingType: product.Internet_Offering_Type__c ?? undefined, - internetPlanTier: product.Internet_Plan_Tier__c ?? undefined, - vpnRegion: product.VPN_Region__c ?? undefined, + id: ensureString(product.Id), + name: ensureString(product.Name), + sku: ensureString(product[productFields.sku]) ?? undefined, + itemClass: ensureString(product[productFields.itemClass]) ?? undefined, + whmcsProductId: resolveWhmcsProductId(product[productFields.whmcsProductId]), + internetOfferingType: + ensureString(product[productFields.internetOfferingType]) ?? undefined, + internetPlanTier: ensureString(product[productFields.internetPlanTier]) ?? undefined, + vpnRegion: ensureString(product[productFields.vpnRegion]) ?? undefined, } : undefined, }); @@ -74,28 +91,30 @@ export function transformSalesforceOrderItem( */ export function transformSalesforceOrderDetails( order: SalesforceOrderRecord, - itemRecords: SalesforceOrderItemRecord[] + itemRecords: SalesforceOrderItemRecord[], + fieldMap: SalesforceOrderFieldMap = defaultSalesforceOrderFieldMap ): OrderDetails { const transformedItems = itemRecords.map(record => - transformSalesforceOrderItem(record) + transformSalesforceOrderItem(record, fieldMap) ); const items = transformedItems.map(item => item.details); const itemsSummary = transformedItems.map(item => item.summary); - const summary = buildOrderSummary(order, itemsSummary); + const summary = buildOrderSummary(order, itemsSummary, fieldMap); + const orderFields = fieldMap.order; return orderDetailsSchema.parse({ ...summary, - accountId: order.AccountId ?? undefined, - accountName: typeof order.Account?.Name === "string" ? order.Account.Name : undefined, - pricebook2Id: order.Pricebook2Id ?? undefined, - activationType: order.Activation_Type__c ?? undefined, + accountId: ensureString(order.AccountId), + accountName: ensureString(order.Account?.Name), + pricebook2Id: ensureString(order.Pricebook2Id), + activationType: ensureString(order[orderFields.activationType]), activationStatus: summary.activationStatus, - activationScheduledAt: order.Activation_Scheduled_At__c ?? undefined, - activationErrorCode: order.Activation_Error_Code__c ?? undefined, - activationErrorMessage: order.Activation_Error_Message__c ?? undefined, - activatedDate: typeof order.ActivatedDate === "string" ? order.ActivatedDate : undefined, + activationScheduledAt: ensureString(order[orderFields.activationScheduledAt]), + activationErrorCode: ensureString(order[orderFields.activationErrorCode]), + activationErrorMessage: ensureString(order[orderFields.activationErrorMessage]), + activatedDate: ensureString(order.ActivatedDate), items, }); } @@ -105,18 +124,21 @@ export function transformSalesforceOrderDetails( */ export function transformSalesforceOrderSummary( order: SalesforceOrderRecord, - itemRecords: SalesforceOrderItemRecord[] + itemRecords: SalesforceOrderItemRecord[], + fieldMap: SalesforceOrderFieldMap = defaultSalesforceOrderFieldMap ): OrderSummary { const itemsSummary = itemRecords.map(record => - transformSalesforceOrderItem(record).summary + transformSalesforceOrderItem(record, fieldMap).summary ); - return buildOrderSummary(order, itemsSummary); + return buildOrderSummary(order, itemsSummary, fieldMap); } function buildOrderSummary( order: SalesforceOrderRecord, - itemsSummary: OrderItemSummary[] + itemsSummary: OrderItemSummary[], + fieldMap: SalesforceOrderFieldMap ): OrderSummary { + const orderFields = fieldMap.order; const effectiveDate = ensureString(order.EffectiveDate) ?? ensureString(order.CreatedDate) ?? @@ -129,13 +151,13 @@ function buildOrderSummary( id: order.Id, orderNumber: ensureString(order.OrderNumber) ?? order.Id, status: ensureString(order.Status) ?? "Unknown", - orderType: order.Type ?? undefined, + orderType: ensureString(order[orderFields.type]) ?? undefined, effectiveDate, totalAmount: typeof totalAmount === "number" ? totalAmount : undefined, createdDate, lastModifiedDate, - whmcsOrderId: order.WHMCS_Order_ID__c ?? undefined, - activationStatus: order.Activation_Status__c ?? undefined, + whmcsOrderId: ensureString(order[orderFields.whmcsOrderId]) ?? undefined, + activationStatus: ensureString(order[orderFields.activationStatus]) ?? undefined, itemsSummary, }); } @@ -159,3 +181,16 @@ function normalizeQuantity(value: unknown): number { } return 1; } + +function resolveWhmcsProductId(value: unknown): string | undefined { + if (value === null || value === undefined) { + return undefined; + } + if (typeof value === "number") { + return Number.isFinite(value) ? String(value) : undefined; + } + if (typeof value === "string") { + return value; + } + return undefined; +} diff --git a/packages/domain/orders/providers/salesforce/raw.types.ts b/packages/domain/orders/providers/salesforce/raw.types.ts index 2ad4d01d..c6fbaaa4 100644 --- a/packages/domain/orders/providers/salesforce/raw.types.ts +++ b/packages/domain/orders/providers/salesforce/raw.types.ts @@ -50,6 +50,7 @@ export const salesforceOrderRecordSchema = z.object({ Activation_Scheduled_At__c: z.string().nullable().optional(), Activation_Error_Code__c: z.string().nullable().optional(), Activation_Error_Message__c: z.string().nullable().optional(), + Activation_Last_Attempt_At__c: z.string().nullable().optional(), ActivatedDate: z.string().nullable().optional(), // Internet fields diff --git a/packages/validation/package.json b/packages/validation/package.json index f0ecdaee..3a64de76 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -17,6 +17,10 @@ "./react": { "types": "./dist/react/index.d.ts", "default": "./dist/react/index.js" + }, + "./nestjs": { + "types": "./dist/nestjs/index.d.ts", + "default": "./dist/nestjs/index.js" } }, "scripts": { @@ -50,6 +54,10 @@ "react": "19.1.1", "typescript": "^5.9.2", "jest": "^30.0.5", - "@types/jest": "^30.0.0" + "@types/jest": "^30.0.0", + "nestjs-zod": "^5.0.1", + "nestjs-pino": "^4.4.0", + "express": "^5.1.0", + "@types/express": "^5.0.3" } } diff --git a/packages/validation/src/nestjs/index.d.ts b/packages/validation/src/nestjs/index.d.ts new file mode 100644 index 00000000..b15965ad --- /dev/null +++ b/packages/validation/src/nestjs/index.d.ts @@ -0,0 +1,3 @@ +export { ZodValidationPipe, createZodDto, ZodValidationException } from "nestjs-zod"; +export { ZodValidationExceptionFilter } from "./zod-exception.filter"; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/packages/validation/src/nestjs/index.d.ts.map b/packages/validation/src/nestjs/index.d.ts.map new file mode 100644 index 00000000..85395dfb --- /dev/null +++ b/packages/validation/src/nestjs/index.d.ts.map @@ -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"} \ No newline at end of file diff --git a/packages/validation/src/nestjs/index.js b/packages/validation/src/nestjs/index.js new file mode 100644 index 00000000..007363a1 --- /dev/null +++ b/packages/validation/src/nestjs/index.js @@ -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 \ No newline at end of file diff --git a/packages/validation/src/nestjs/index.js.map b/packages/validation/src/nestjs/index.js.map new file mode 100644 index 00000000..fa3e4d64 --- /dev/null +++ b/packages/validation/src/nestjs/index.js.map @@ -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"} \ No newline at end of file diff --git a/packages/validation/src/nestjs/index.ts b/packages/validation/src/nestjs/index.ts new file mode 100644 index 00000000..0a1596f0 --- /dev/null +++ b/packages/validation/src/nestjs/index.ts @@ -0,0 +1,2 @@ +export { ZodValidationPipe, createZodDto, ZodValidationException } from "nestjs-zod"; +export { ZodValidationExceptionFilter } from "./zod-exception.filter"; diff --git a/packages/validation/src/nestjs/zod-exception.filter.d.ts b/packages/validation/src/nestjs/zod-exception.filter.d.ts new file mode 100644 index 00000000..4a707e99 --- /dev/null +++ b/packages/validation/src/nestjs/zod-exception.filter.d.ts @@ -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 \ No newline at end of file diff --git a/packages/validation/src/nestjs/zod-exception.filter.d.ts.map b/packages/validation/src/nestjs/zod-exception.filter.d.ts.map new file mode 100644 index 00000000..6b08fc9f --- /dev/null +++ b/packages/validation/src/nestjs/zod-exception.filter.d.ts.map @@ -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"} \ No newline at end of file diff --git a/packages/validation/src/nestjs/zod-exception.filter.js b/packages/validation/src/nestjs/zod-exception.filter.js new file mode 100644 index 00000000..d4f2f6a2 --- /dev/null +++ b/packages/validation/src/nestjs/zod-exception.filter.js @@ -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 \ No newline at end of file diff --git a/packages/validation/src/nestjs/zod-exception.filter.js.map b/packages/validation/src/nestjs/zod-exception.filter.js.map new file mode 100644 index 00000000..e11a94b6 --- /dev/null +++ b/packages/validation/src/nestjs/zod-exception.filter.js.map @@ -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"} \ No newline at end of file diff --git a/apps/bff/src/core/validation/zod-validation.filter.ts b/packages/validation/src/nestjs/zod-exception.filter.ts similarity index 100% rename from apps/bff/src/core/validation/zod-validation.filter.ts rename to packages/validation/src/nestjs/zod-exception.filter.ts diff --git a/packages/validation/tsconfig.json b/packages/validation/tsconfig.json index ad8b27e3..42642eb1 100644 --- a/packages/validation/tsconfig.json +++ b/packages/validation/tsconfig.json @@ -1,15 +1,15 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "module": "ESNext", - "moduleResolution": "bundler", "outDir": "dist", "rootDir": "src", "declaration": true, "declarationMap": true, "sourceMap": true, "composite": true, - "tsBuildInfoFile": "dist/.tsbuildinfo" + "tsBuildInfoFile": "dist/.tsbuildinfo", + "experimentalDecorators": true, + "emitDecoratorMetadata": true }, "include": ["src/**/*"], "exclude": ["dist", "node_modules", "**/*.test.ts", "**/*.spec.ts"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99af578e..5538e13a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -373,15 +373,27 @@ importers: '@nestjs/common': specifier: ^11.1.6 version: 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@types/express': + specifier: ^5.0.3 + version: 5.0.3 '@types/jest': specifier: ^30.0.0 version: 30.0.0 '@types/react': specifier: ^19.1.10 version: 19.1.12 + express: + specifier: ^5.1.0 + version: 5.1.0 jest: specifier: ^30.0.5 version: 30.1.3(@types/node@24.3.1)(ts-node@10.9.2(@types/node@24.3.1)(typescript@5.9.2)) + nestjs-pino: + specifier: ^4.4.0 + version: 4.4.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@10.5.0)(pino@9.9.5)(rxjs@7.8.2) + nestjs-zod: + specifier: ^5.0.1 + version: 5.0.1(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.2.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.1.9) react: specifier: 19.1.1 version: 19.1.1 diff --git a/scripts/dev/manage.sh b/scripts/dev/manage.sh index 891eb245..5b39de6c 100755 --- a/scripts/dev/manage.sh +++ b/scripts/dev/manage.sh @@ -327,6 +327,7 @@ start_apps() { # Build shared package first log "🔨 Building shared package..." pnpm --filter @customer-portal/domain build + pnpm --filter @customer-portal/validation build # Build BFF before watch (ensures dist exists). Use Nest build for correct emit. log "🔨 Building BFF for initial setup (ts emit)..."