Refactor user management and validation integration
- Replaced UsersService with UsersFacade across various modules for improved abstraction and consistency. - Updated validation imports to utilize the new @customer-portal/validation package, enhancing modularity. - Removed deprecated validation files and streamlined user-related logic in controllers and services. - Enhanced order processing by integrating field mappings for Salesforce orders, improving maintainability. - Improved error handling and response structures in authentication and user management workflows.
This commit is contained in:
parent
b65a49bc2f
commit
1dc8fbf36d
@ -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";
|
||||
|
||||
@ -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";
|
||||
@ -20,7 +20,7 @@ type PrismaUserRaw = Parameters<typeof CustomerProviders.Portal.mapPrismaUserToU
|
||||
* then uses the domain portal provider mapper to get UserAuth.
|
||||
*
|
||||
* NOTE: UserAuth contains ONLY auth state. Profile data comes from WHMCS.
|
||||
* For complete user profile, use UsersService.getProfile() which fetches from WHMCS.
|
||||
* For complete user profile, use UsersFacade.getProfile() which fetches from WHMCS.
|
||||
*/
|
||||
export function mapPrismaUserToDomain(user: PrismaUser): UserAuth {
|
||||
// Convert @prisma/client User to domain PrismaUserRaw
|
||||
|
||||
@ -95,7 +95,11 @@ export class SalesforceOrderService {
|
||||
);
|
||||
|
||||
// Use domain mapper - single transformation!
|
||||
return OrderProviders.Salesforce.transformSalesforceOrderDetails(order, orderItems);
|
||||
return OrderProviders.Salesforce.transformSalesforceOrderDetails(
|
||||
order,
|
||||
orderItems,
|
||||
this.orderFieldMap.fields
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
this.logger.error("Failed to fetch order with items", {
|
||||
error: getErrorMessage(error),
|
||||
@ -216,7 +220,8 @@ export class SalesforceOrderService {
|
||||
.map(order =>
|
||||
OrderProviders.Salesforce.transformSalesforceOrderSummary(
|
||||
order,
|
||||
itemsByOrder[order.Id] ?? []
|
||||
itemsByOrder[order.Id] ?? [],
|
||||
this.orderFieldMap.fields
|
||||
)
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 });
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import { JwtService } from "@nestjs/jwt";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import * as bcrypt from "bcrypt";
|
||||
import type { Request } from "express";
|
||||
import { UsersService } from "@bff/modules/users/users.service";
|
||||
import { UsersFacade } from "@bff/modules/users/application/users.facade";
|
||||
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service";
|
||||
import { EmailService } from "@bff/infra/email/email.service";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
@ -20,7 +20,7 @@ import { mapPrismaUserToDomain } from "@bff/infra/mappers";
|
||||
@Injectable()
|
||||
export class PasswordWorkflowService {
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly usersFacade: UsersFacade,
|
||||
private readonly auditService: AuditService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly emailService: EmailService,
|
||||
@ -31,7 +31,7 @@ export class PasswordWorkflowService {
|
||||
) {}
|
||||
|
||||
async checkPasswordNeeded(email: string) {
|
||||
const user = await this.usersService.findByEmailInternal(email);
|
||||
const user = await this.usersFacade.findByEmailInternal(email);
|
||||
if (!user) {
|
||||
return { needsPasswordSet: false, userExists: false };
|
||||
}
|
||||
@ -44,7 +44,7 @@ export class PasswordWorkflowService {
|
||||
}
|
||||
|
||||
async setPassword(email: string, password: string) {
|
||||
const user = await this.usersService.findByEmailInternal(email);
|
||||
const user = await this.usersFacade.findByEmailInternal(email);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException("User not found");
|
||||
}
|
||||
@ -57,8 +57,8 @@ export class PasswordWorkflowService {
|
||||
const saltRounds =
|
||||
typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig;
|
||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||
await this.usersService.update(user.id, { passwordHash });
|
||||
const prismaUser = await this.usersService.findByIdInternal(user.id);
|
||||
await this.usersFacade.update(user.id, { passwordHash });
|
||||
const prismaUser = await this.usersFacade.findByIdInternal(user.id);
|
||||
if (!prismaUser) {
|
||||
throw new Error("Failed to load user after password setup");
|
||||
}
|
||||
@ -78,7 +78,7 @@ export class PasswordWorkflowService {
|
||||
if (request) {
|
||||
await this.authRateLimitService.consumePasswordReset(request);
|
||||
}
|
||||
const user = await this.usersService.findByEmailInternal(email);
|
||||
const user = await this.usersFacade.findByEmailInternal(email);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
@ -119,7 +119,7 @@ export class PasswordWorkflowService {
|
||||
throw new BadRequestException("Invalid token");
|
||||
}
|
||||
|
||||
const prismaUser = await this.usersService.findByIdInternal(payload.sub);
|
||||
const prismaUser = await this.usersFacade.findByIdInternal(payload.sub);
|
||||
if (!prismaUser) throw new BadRequestException("Invalid token");
|
||||
|
||||
const saltRoundsConfig = this.configService.get<string | number>("BCRYPT_ROUNDS", 12);
|
||||
@ -127,8 +127,8 @@ export class PasswordWorkflowService {
|
||||
typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig;
|
||||
const passwordHash = await bcrypt.hash(newPassword, saltRounds);
|
||||
|
||||
await this.usersService.update(prismaUser.id, { passwordHash });
|
||||
const freshUser = await this.usersService.findByIdInternal(prismaUser.id);
|
||||
await this.usersFacade.update(prismaUser.id, { passwordHash });
|
||||
const freshUser = await this.usersFacade.findByIdInternal(prismaUser.id);
|
||||
if (!freshUser) {
|
||||
throw new Error("Failed to load user after password reset");
|
||||
}
|
||||
@ -154,7 +154,7 @@ export class PasswordWorkflowService {
|
||||
data: ChangePasswordRequest,
|
||||
request?: Request
|
||||
): Promise<PasswordChangeResult> {
|
||||
const user = await this.usersService.findByIdInternal(userId);
|
||||
const user = await this.usersFacade.findByIdInternal(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException("User not found");
|
||||
@ -188,8 +188,8 @@ export class PasswordWorkflowService {
|
||||
typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig;
|
||||
const passwordHash = await bcrypt.hash(newPassword, saltRounds);
|
||||
|
||||
await this.usersService.update(user.id, { passwordHash });
|
||||
const prismaUser = await this.usersService.findByIdInternal(user.id);
|
||||
await this.usersFacade.update(user.id, { passwordHash });
|
||||
const prismaUser = await this.usersFacade.findByIdInternal(user.id);
|
||||
if (!prismaUser) {
|
||||
throw new Error("Failed to load user after password change");
|
||||
}
|
||||
|
||||
@ -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."
|
||||
);
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -3,7 +3,7 @@ import { PassportStrategy } from "@nestjs/passport";
|
||||
import { ExtractJwt, Strategy } from "passport-jwt";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import type { UserAuth } from "@customer-portal/domain/customer";
|
||||
import { UsersService } from "@bff/modules/users/users.service";
|
||||
import { UsersFacade } from "@bff/modules/users/application/users.facade";
|
||||
import { mapPrismaUserToDomain } from "@bff/infra/mappers";
|
||||
import type { Request } from "express";
|
||||
|
||||
@ -20,7 +20,7 @@ const cookieExtractor = (req: Request): string | null => {
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private readonly usersService: UsersService
|
||||
private readonly usersFacade: UsersFacade
|
||||
) {
|
||||
const jwtSecret = configService.get<string>("JWT_SECRET");
|
||||
if (!jwtSecret) {
|
||||
@ -65,7 +65,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
throw new UnauthorizedException("Token missing expiration claim");
|
||||
}
|
||||
|
||||
const prismaUser = await this.usersService.findByIdInternal(payload.sub);
|
||||
const prismaUser = await this.usersFacade.findByIdInternal(payload.sub);
|
||||
|
||||
if (!prismaUser) {
|
||||
throw new UnauthorizedException("User not found");
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -1,120 +1,32 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
export interface OrderFieldMap {
|
||||
order: {
|
||||
type: string;
|
||||
activationType: string;
|
||||
activationScheduledAt: string;
|
||||
activationStatus: string;
|
||||
activationErrorCode: string;
|
||||
activationErrorMessage: string;
|
||||
activationLastAttemptAt: string;
|
||||
internetPlanTier: string;
|
||||
installationType: string;
|
||||
weekendInstall: string;
|
||||
accessMode: string;
|
||||
hikariDenwa: string;
|
||||
vpnRegion: string;
|
||||
simType: string;
|
||||
simVoiceMail: string;
|
||||
simCallWaiting: string;
|
||||
eid: string;
|
||||
whmcsOrderId: string;
|
||||
addressChanged: string;
|
||||
billingStreet: string;
|
||||
billingCity: string;
|
||||
billingState: string;
|
||||
billingPostalCode: string;
|
||||
billingCountry: string;
|
||||
mnpApplication: string;
|
||||
mnpReservation: string;
|
||||
mnpExpiry: string;
|
||||
mnpPhone: string;
|
||||
mvnoAccountNumber: string;
|
||||
portingDateOfBirth: string;
|
||||
portingFirstName: string;
|
||||
portingLastName: string;
|
||||
portingFirstNameKatakana: string;
|
||||
portingLastNameKatakana: string;
|
||||
portingGender: string;
|
||||
};
|
||||
orderItem: {
|
||||
billingCycle: string;
|
||||
whmcsServiceId: string;
|
||||
};
|
||||
product: {
|
||||
sku: string;
|
||||
itemClass: string;
|
||||
billingCycle: string;
|
||||
whmcsProductId: string;
|
||||
internetOfferingType: string;
|
||||
internetPlanTier: string;
|
||||
vpnRegion: string;
|
||||
};
|
||||
}
|
||||
import {
|
||||
createSalesforceOrderFieldMap,
|
||||
defaultSalesforceOrderFieldMap,
|
||||
type PartialSalesforceOrderFieldMap,
|
||||
type SalesforceOrderFieldMap,
|
||||
} from "@customer-portal/domain/orders";
|
||||
|
||||
const unique = <T>(values: T[]): T[] => Array.from(new Set(values));
|
||||
|
||||
const SECTION_PREFIX: Record<keyof SalesforceOrderFieldMap, string> = {
|
||||
order: "ORDER",
|
||||
orderItem: "ORDER_ITEM",
|
||||
product: "PRODUCT",
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class OrderFieldMapService {
|
||||
readonly fields: OrderFieldMap;
|
||||
readonly fields: SalesforceOrderFieldMap;
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
const resolve = (key: string) => this.config.get<string>(key, { infer: true }) ?? key;
|
||||
|
||||
this.fields = {
|
||||
order: {
|
||||
type: resolve("ORDER_TYPE_FIELD"),
|
||||
activationType: resolve("ORDER_ACTIVATION_TYPE_FIELD"),
|
||||
activationScheduledAt: resolve("ORDER_ACTIVATION_SCHEDULED_AT_FIELD"),
|
||||
activationStatus: resolve("ORDER_ACTIVATION_STATUS_FIELD"),
|
||||
activationErrorCode: resolve("ORDER_ACTIVATION_ERROR_CODE_FIELD"),
|
||||
activationErrorMessage: resolve("ORDER_ACTIVATION_ERROR_MESSAGE_FIELD"),
|
||||
activationLastAttemptAt: resolve("ORDER_ACTIVATION_LAST_ATTEMPT_AT_FIELD"),
|
||||
internetPlanTier: resolve("ORDER_INTERNET_PLAN_TIER_FIELD"),
|
||||
installationType: resolve("ORDER_INSTALLATION_TYPE_FIELD"),
|
||||
weekendInstall: resolve("ORDER_WEEKEND_INSTALL_FIELD"),
|
||||
accessMode: resolve("ORDER_ACCESS_MODE_FIELD"),
|
||||
hikariDenwa: resolve("ORDER_HIKARI_DENWA_FIELD"),
|
||||
vpnRegion: resolve("ORDER_VPN_REGION_FIELD"),
|
||||
simType: resolve("ORDER_SIM_TYPE_FIELD"),
|
||||
simVoiceMail: resolve("ORDER_SIM_VOICE_MAIL_FIELD"),
|
||||
simCallWaiting: resolve("ORDER_SIM_CALL_WAITING_FIELD"),
|
||||
eid: resolve("ORDER_EID_FIELD"),
|
||||
whmcsOrderId: resolve("ORDER_WHMCS_ORDER_ID_FIELD"),
|
||||
addressChanged: resolve("ORDER_ADDRESS_CHANGED_FIELD"),
|
||||
billingStreet: resolve("ORDER_BILLING_STREET_FIELD"),
|
||||
billingCity: resolve("ORDER_BILLING_CITY_FIELD"),
|
||||
billingState: resolve("ORDER_BILLING_STATE_FIELD"),
|
||||
billingPostalCode: resolve("ORDER_BILLING_POSTAL_CODE_FIELD"),
|
||||
billingCountry: resolve("ORDER_BILLING_COUNTRY_FIELD"),
|
||||
mnpApplication: resolve("ORDER_MNP_APPLICATION_FIELD"),
|
||||
mnpReservation: resolve("ORDER_MNP_RESERVATION_FIELD"),
|
||||
mnpExpiry: resolve("ORDER_MNP_EXPIRY_FIELD"),
|
||||
mnpPhone: resolve("ORDER_MNP_PHONE_FIELD"),
|
||||
mvnoAccountNumber: resolve("ORDER_MVNO_ACCOUNT_NUMBER_FIELD"),
|
||||
portingDateOfBirth: resolve("ORDER_PORTING_DOB_FIELD"),
|
||||
portingFirstName: resolve("ORDER_PORTING_FIRST_NAME_FIELD"),
|
||||
portingLastName: resolve("ORDER_PORTING_LAST_NAME_FIELD"),
|
||||
portingFirstNameKatakana: resolve("ORDER_PORTING_FIRST_NAME_KATAKANA_FIELD"),
|
||||
portingLastNameKatakana: resolve("ORDER_PORTING_LAST_NAME_KATAKANA_FIELD"),
|
||||
portingGender: resolve("ORDER_PORTING_GENDER_FIELD"),
|
||||
},
|
||||
orderItem: {
|
||||
billingCycle: resolve("ORDER_ITEM_BILLING_CYCLE_FIELD"),
|
||||
whmcsServiceId: resolve("ORDER_ITEM_WHMCS_SERVICE_ID_FIELD"),
|
||||
},
|
||||
product: {
|
||||
sku: resolve("PRODUCT_SKU_FIELD"),
|
||||
itemClass: resolve("PRODUCT_ITEM_CLASS_FIELD"),
|
||||
billingCycle: resolve("PRODUCT_BILLING_CYCLE_FIELD"),
|
||||
whmcsProductId: resolve("PRODUCT_WHMCS_PRODUCT_ID_FIELD"),
|
||||
internetOfferingType: resolve("PRODUCT_INTERNET_OFFERING_TYPE_FIELD"),
|
||||
internetPlanTier: resolve("PRODUCT_INTERNET_PLAN_TIER_FIELD"),
|
||||
vpnRegion: resolve("PRODUCT_VPN_REGION_FIELD"),
|
||||
},
|
||||
const overrides: PartialSalesforceOrderFieldMap = {
|
||||
order: this.resolveSection("order"),
|
||||
orderItem: this.resolveSection("orderItem"),
|
||||
product: this.resolveSection("product"),
|
||||
};
|
||||
|
||||
this.fields = createSalesforceOrderFieldMap(overrides);
|
||||
}
|
||||
|
||||
buildOrderSelectFields(additional: string[] = []): string[] {
|
||||
@ -189,4 +101,32 @@ export class OrderFieldMapService {
|
||||
|
||||
return unique([...base, ...additional]);
|
||||
}
|
||||
|
||||
private resolveSection<Section extends keyof SalesforceOrderFieldMap>(
|
||||
section: Section
|
||||
): Partial<SalesforceOrderFieldMap[Section]> {
|
||||
const defaults = defaultSalesforceOrderFieldMap[section];
|
||||
const resolvedEntries = Object.entries(defaults).map(([key, defaultValue]) => {
|
||||
const envKey = buildEnvKey(section, key);
|
||||
const resolved = this.config.get<string>(envKey, { infer: true });
|
||||
return [key, resolved ?? defaultValue];
|
||||
});
|
||||
|
||||
return Object.fromEntries(resolvedEntries) as Partial<SalesforceOrderFieldMap[Section]>;
|
||||
}
|
||||
}
|
||||
|
||||
function buildEnvKey<Section extends keyof SalesforceOrderFieldMap>(
|
||||
section: Section,
|
||||
key: string
|
||||
): string {
|
||||
const prefix = SECTION_PREFIX[section];
|
||||
return `${prefix}_${toScreamingSnakeCase(key)}_FIELD`;
|
||||
}
|
||||
|
||||
function toScreamingSnakeCase(value: string): string {
|
||||
return value
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
||||
.replace(/[-\s]+/g, "_")
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import type { OrderBusinessValidation, UserMapping } from "@customer-portal/domain/orders";
|
||||
import { UsersService } from "@bff/modules/users/users.service";
|
||||
import { UsersFacade } from "@bff/modules/users/application/users.facade";
|
||||
import { OrderFieldMapService } from "@bff/modules/orders/config/order-field-map.service";
|
||||
|
||||
function assignIfString(target: Record<string, unknown>, key: string, value: unknown): void {
|
||||
@ -17,7 +17,7 @@ function assignIfString(target: Record<string, unknown>, key: string, value: unk
|
||||
export class OrderBuilder {
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly usersFacade: UsersFacade,
|
||||
private readonly orderFieldMap: OrderFieldMapService
|
||||
) {}
|
||||
|
||||
@ -121,7 +121,7 @@ export class OrderBuilder {
|
||||
fieldNames: OrderFieldMapService["fields"]["order"]
|
||||
): Promise<void> {
|
||||
try {
|
||||
const profile = await this.usersService.getProfile(userId);
|
||||
const profile = await this.usersFacade.getProfile(userId);
|
||||
const address = profile.address;
|
||||
const orderAddress = (body.configurations as Record<string, unknown>)?.address as
|
||||
| Record<string, unknown>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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({
|
||||
|
||||
123
apps/bff/src/modules/users/application/users.facade.ts
Normal file
123
apps/bff/src/modules/users/application/users.facade.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import type { User as PrismaUser } from "@prisma/client";
|
||||
import type { User } from "@customer-portal/domain/customer";
|
||||
import type { Address } from "@customer-portal/domain/customer";
|
||||
import type { DashboardSummary } from "@customer-portal/domain/dashboard";
|
||||
import type { UpdateCustomerProfileRequest } from "@customer-portal/domain/auth";
|
||||
import { UserAuthRepository } from "../infra/user-auth.repository";
|
||||
import { UserProfileService } from "../infra/user-profile.service";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
|
||||
type AuthUpdateData = Partial<
|
||||
Pick<PrismaUser, "passwordHash" | "failedLoginAttempts" | "lastLoginAt" | "lockedUntil">
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
export class UsersFacade {
|
||||
constructor(
|
||||
private readonly authRepository: UserAuthRepository,
|
||||
private readonly profileService: UserProfileService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
const user = await this.authRepository.findByEmail(email);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.profileService.getProfile(user.id);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to build profile by email", {
|
||||
email,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findByEmailInternal(email: string): Promise<PrismaUser | null> {
|
||||
return this.authRepository.findByEmail(email);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<User | null> {
|
||||
return this.profileService.findById(id);
|
||||
}
|
||||
|
||||
async findByIdInternal(id: string): Promise<PrismaUser | null> {
|
||||
return this.authRepository.findById(id);
|
||||
}
|
||||
|
||||
async getProfile(userId: string): Promise<User> {
|
||||
return this.profileService.getProfile(userId);
|
||||
}
|
||||
|
||||
async getAddress(userId: string): Promise<Address | null> {
|
||||
return this.profileService.getAddress(userId);
|
||||
}
|
||||
|
||||
async updateAddress(userId: string, update: Partial<Address>): Promise<Address> {
|
||||
return this.profileService.updateAddress(userId, update);
|
||||
}
|
||||
|
||||
async updateProfile(userId: string, update: UpdateCustomerProfileRequest): Promise<User> {
|
||||
return this.profileService.updateProfile(userId, update);
|
||||
}
|
||||
|
||||
async getUserSummary(userId: string): Promise<DashboardSummary> {
|
||||
return this.profileService.getUserSummary(userId);
|
||||
}
|
||||
|
||||
async create(userData: Partial<PrismaUser>): Promise<User> {
|
||||
try {
|
||||
const createdUser = await this.authRepository.create(userData);
|
||||
return this.profileService.getProfile(createdUser.id);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to create user", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, data: AuthUpdateData): Promise<User> {
|
||||
const sanitized = this.sanitizeAuthUpdate(data);
|
||||
|
||||
try {
|
||||
await this.authRepository.updateAuthState(id, sanitized);
|
||||
return this.profileService.getProfile(id);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to update user auth state", {
|
||||
userId: id,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeAuthUpdate(data: AuthUpdateData): AuthUpdateData {
|
||||
if (!data) {
|
||||
throw new BadRequestException("Update payload is required");
|
||||
}
|
||||
|
||||
const sanitized: AuthUpdateData = {};
|
||||
|
||||
if (data.passwordHash !== undefined) {
|
||||
sanitized.passwordHash = data.passwordHash;
|
||||
}
|
||||
if (data.failedLoginAttempts !== undefined) {
|
||||
sanitized.failedLoginAttempts = data.failedLoginAttempts;
|
||||
}
|
||||
if (data.lastLoginAt !== undefined) {
|
||||
sanitized.lastLoginAt = data.lastLoginAt;
|
||||
}
|
||||
if (data.lockedUntil !== undefined) {
|
||||
sanitized.lockedUntil = data.lockedUntil;
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
66
apps/bff/src/modules/users/infra/user-auth.repository.ts
Normal file
66
apps/bff/src/modules/users/infra/user-auth.repository.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { Injectable, BadRequestException } from "@nestjs/common";
|
||||
import type { User as PrismaUser } from "@prisma/client";
|
||||
import { PrismaService } from "@bff/infra/database/prisma.service";
|
||||
import { normalizeAndValidateEmail, validateUuidV4OrThrow } from "@customer-portal/domain/common";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
|
||||
type AuthUpdatableFields = Pick<
|
||||
PrismaUser,
|
||||
"passwordHash" | "failedLoginAttempts" | "lastLoginAt" | "lockedUntil"
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
export class UserAuthRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findByEmail(email: string): Promise<PrismaUser | null> {
|
||||
const normalized = normalizeAndValidateEmail(email);
|
||||
try {
|
||||
return await this.prisma.user.findUnique({ where: { email: normalized } });
|
||||
} catch (error) {
|
||||
throw new BadRequestException(`Unable to retrieve user by email: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<PrismaUser | null> {
|
||||
const validId = validateUuidV4OrThrow(id);
|
||||
try {
|
||||
return await this.prisma.user.findUnique({ where: { id: validId } });
|
||||
} catch (error) {
|
||||
throw new BadRequestException(`Unable to retrieve user by id: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async create(data: Partial<PrismaUser>): Promise<PrismaUser> {
|
||||
if (!data.email) {
|
||||
throw new BadRequestException("Email is required to create a user");
|
||||
}
|
||||
|
||||
const normalizedEmail = normalizeAndValidateEmail(data.email);
|
||||
|
||||
try {
|
||||
return await this.prisma.user.create({
|
||||
data: {
|
||||
...data,
|
||||
email: normalizedEmail,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new BadRequestException(`Unable to create user: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async updateAuthState(id: string, data: Partial<AuthUpdatableFields>): Promise<void> {
|
||||
const validId = validateUuidV4OrThrow(id);
|
||||
|
||||
try {
|
||||
await this.prisma.user.update({
|
||||
where: { id: validId },
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new BadRequestException(`Unable to update user auth state: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,12 +2,6 @@ import { Injectable, Inject, NotFoundException, BadRequestException } from "@nes
|
||||
import { Logger } from "nestjs-pino";
|
||||
import type { User as PrismaUser } from "@prisma/client";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import { normalizeAndValidateEmail, validateUuidV4OrThrow } from "@customer-portal/domain/common";
|
||||
import { PrismaService } from "@bff/infra/database/prisma.service";
|
||||
import {
|
||||
updateCustomerProfileRequestSchema,
|
||||
type UpdateCustomerProfileRequest,
|
||||
} from "@customer-portal/domain/auth";
|
||||
import {
|
||||
Providers as CustomerProviders,
|
||||
addressSchema,
|
||||
@ -15,146 +9,53 @@ import {
|
||||
type Address,
|
||||
type User,
|
||||
} from "@customer-portal/domain/customer";
|
||||
import {
|
||||
updateCustomerProfileRequestSchema,
|
||||
type UpdateCustomerProfileRequest,
|
||||
} from "@customer-portal/domain/auth";
|
||||
import type { Subscription } from "@customer-portal/domain/subscriptions";
|
||||
import type { Invoice } from "@customer-portal/domain/billing";
|
||||
import type { Activity, DashboardSummary, NextInvoice } from "@customer-portal/domain/dashboard";
|
||||
import { dashboardSummarySchema } from "@customer-portal/domain/dashboard";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
||||
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||
|
||||
// Use a subset of PrismaUser for auth-related updates only
|
||||
type UserUpdateData = Partial<
|
||||
Pick<PrismaUser, "passwordHash" | "failedLoginAttempts" | "lastLoginAt" | "lockedUntil">
|
||||
>;
|
||||
import { validateUuidV4OrThrow } from "@customer-portal/domain/common";
|
||||
import { UserAuthRepository } from "./user-auth.repository";
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
export class UserProfileService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private whmcsService: WhmcsService,
|
||||
private salesforceService: SalesforceService,
|
||||
private mappingsService: MappingsService,
|
||||
private readonly userAuthRepository: UserAuthRepository,
|
||||
private readonly mappingsService: MappingsService,
|
||||
private readonly whmcsService: WhmcsService,
|
||||
private readonly salesforceService: SalesforceService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Find user by email - returns authenticated user with full profile from WHMCS
|
||||
*/
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
const validEmail = normalizeAndValidateEmail(email);
|
||||
|
||||
try {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email: validEmail },
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
// Return full profile with WHMCS data
|
||||
return this.getProfile(user.id);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to find user by email", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw new BadRequestException("Unable to retrieve user profile");
|
||||
}
|
||||
}
|
||||
|
||||
// Internal method for auth service - returns raw user with sensitive fields
|
||||
async findByEmailInternal(email: string): Promise<PrismaUser | null> {
|
||||
const validEmail = normalizeAndValidateEmail(email);
|
||||
|
||||
try {
|
||||
return await this.prisma.user.findUnique({
|
||||
where: { email: validEmail },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to find user by email (internal)", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw new BadRequestException("Unable to retrieve user information");
|
||||
}
|
||||
}
|
||||
|
||||
// Internal method for auth service - returns raw user by ID with sensitive fields
|
||||
async findByIdInternal(id: string): Promise<PrismaUser | null> {
|
||||
const validId = validateUuidV4OrThrow(id);
|
||||
|
||||
try {
|
||||
return await this.prisma.user.findUnique({ where: { id: validId } });
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to find user by ID (internal)", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw new BadRequestException("Unable to retrieve user information");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user profile - primary method for fetching authenticated user with full WHMCS data
|
||||
*/
|
||||
async findById(id: string): Promise<User | null> {
|
||||
const validId = validateUuidV4OrThrow(id);
|
||||
|
||||
try {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: validId },
|
||||
});
|
||||
if (!user) return null;
|
||||
|
||||
return await this.getProfile(validId);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to find user by ID", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw new BadRequestException("Unable to retrieve user profile");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get complete customer profile from WHMCS (single source of truth)
|
||||
* Includes profile fields + address + auth state
|
||||
*/
|
||||
async getProfile(userId: string): Promise<User> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new NotFoundException("User not found");
|
||||
|
||||
const mapping = await this.mappingsService.findByUserId(userId);
|
||||
if (!mapping?.whmcsClientId) {
|
||||
throw new NotFoundException("WHMCS client mapping not found");
|
||||
}
|
||||
|
||||
try {
|
||||
// Get WHMCS client data (source of truth for profile)
|
||||
const whmcsClient = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
|
||||
|
||||
// Map Prisma user to UserAuth
|
||||
const userAuth = CustomerProviders.Portal.mapPrismaUserToUserAuth(user);
|
||||
|
||||
return combineToUser(userAuth, whmcsClient);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to fetch client profile from WHMCS", {
|
||||
error: getErrorMessage(error),
|
||||
userId,
|
||||
whmcsClientId: mapping.whmcsClientId,
|
||||
});
|
||||
throw new BadRequestException("Unable to retrieve customer profile from billing system");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only the customer's address information
|
||||
*/
|
||||
async getAddress(userId: string): Promise<Address | null> {
|
||||
async findById(userId: string): Promise<User | null> {
|
||||
const validId = validateUuidV4OrThrow(userId);
|
||||
const profile = await this.getProfile(validId);
|
||||
const user = await this.userAuthRepository.findById(validId);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
return this.getProfileForUser(user);
|
||||
}
|
||||
|
||||
async getProfile(userId: string): Promise<User> {
|
||||
const validId = validateUuidV4OrThrow(userId);
|
||||
const user = await this.userAuthRepository.findById(validId);
|
||||
if (!user) {
|
||||
throw new NotFoundException("User not found");
|
||||
}
|
||||
return this.getProfileForUser(user);
|
||||
}
|
||||
|
||||
async getAddress(userId: string): Promise<Address | null> {
|
||||
const profile = await this.getProfile(userId);
|
||||
return profile.address ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update customer address in WHMCS
|
||||
*/
|
||||
async updateAddress(userId: string, addressUpdate: Partial<Address>): Promise<Address> {
|
||||
const validId = validateUuidV4OrThrow(userId);
|
||||
const parsed = addressSchema.partial().parse(addressUpdate ?? {});
|
||||
@ -206,56 +107,6 @@ export class UsersService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create user (auth state only in portal DB)
|
||||
*/
|
||||
async create(userData: Partial<PrismaUser>): Promise<User> {
|
||||
const validEmail = normalizeAndValidateEmail(userData.email!);
|
||||
|
||||
try {
|
||||
const normalizedData = { ...userData, email: validEmail };
|
||||
const createdUser = await this.prisma.user.create({
|
||||
data: normalizedData,
|
||||
});
|
||||
|
||||
// Return full profile from WHMCS
|
||||
return this.getProfile(createdUser.id);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to create user", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw new BadRequestException("Unable to create user account");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user auth state (password, login attempts, etc.)
|
||||
* For profile updates, use updateProfile instead
|
||||
*/
|
||||
async update(id: string, userData: UserUpdateData): Promise<User> {
|
||||
const validId = validateUuidV4OrThrow(id);
|
||||
const sanitizedData = this.sanitizeUserData(userData);
|
||||
|
||||
try {
|
||||
await this.prisma.user.update({
|
||||
where: { id: validId },
|
||||
data: sanitizedData,
|
||||
});
|
||||
|
||||
// Return fresh profile from WHMCS
|
||||
return this.getProfile(validId);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to update user", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw new BadRequestException("Unable to update user information");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update customer profile in WHMCS (single source of truth)
|
||||
* Can update profile fields AND/OR address fields in one call
|
||||
*/
|
||||
async updateProfile(userId: string, update: UpdateCustomerProfileRequest): Promise<User> {
|
||||
const validId = validateUuidV4OrThrow(userId);
|
||||
const parsed = updateCustomerProfileRequestSchema.parse(update);
|
||||
@ -266,19 +117,14 @@ export class UsersService {
|
||||
throw new NotFoundException("User mapping not found");
|
||||
}
|
||||
|
||||
// Update in WHMCS (all fields optional)
|
||||
await this.whmcsService.updateClient(mapping.whmcsClientId, parsed);
|
||||
|
||||
this.logger.log({ userId: validId }, "Successfully updated customer profile in WHMCS");
|
||||
|
||||
// Return fresh profile
|
||||
return this.getProfile(validId);
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error);
|
||||
this.logger.error(
|
||||
{ userId: validId, error: msg },
|
||||
"Failed to update customer profile in WHMCS"
|
||||
);
|
||||
this.logger.error({ userId: validId, error: msg }, "Failed to update customer profile in WHMCS");
|
||||
|
||||
if (msg.includes("WHMCS API Error")) {
|
||||
throw new BadRequestException(msg.replace("WHMCS API Error: ", ""));
|
||||
@ -289,38 +135,23 @@ export class UsersService {
|
||||
if (msg.includes("Missing required WHMCS configuration")) {
|
||||
throw new BadRequestException("Billing system not configured. Please contact support.");
|
||||
}
|
||||
|
||||
throw new BadRequestException("Unable to update profile.");
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeUserData(userData: UserUpdateData): Partial<PrismaUser> {
|
||||
const sanitized: Partial<PrismaUser> = {};
|
||||
|
||||
// Handle authentication-related fields only
|
||||
if (userData.passwordHash !== undefined) sanitized.passwordHash = userData.passwordHash;
|
||||
if (userData.failedLoginAttempts !== undefined)
|
||||
sanitized.failedLoginAttempts = userData.failedLoginAttempts;
|
||||
if (userData.lastLoginAt !== undefined) sanitized.lastLoginAt = userData.lastLoginAt;
|
||||
if (userData.lockedUntil !== undefined) sanitized.lockedUntil = userData.lockedUntil;
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
async getUserSummary(userId: string): Promise<DashboardSummary> {
|
||||
try {
|
||||
// Verify user exists
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
const user = await this.userAuthRepository.findById(userId);
|
||||
if (!user) {
|
||||
throw new NotFoundException("User not found");
|
||||
}
|
||||
|
||||
// Check if user has WHMCS mapping
|
||||
const mapping = await this.mappingsService.findByUserId(userId);
|
||||
if (!mapping?.whmcsClientId) {
|
||||
this.logger.warn(`No WHMCS mapping found for user ${userId}`);
|
||||
|
||||
// Get currency from WHMCS profile if available
|
||||
let currency = "JPY"; // Default
|
||||
let currency = "JPY";
|
||||
try {
|
||||
const profile = await this.getProfile(userId);
|
||||
currency = profile.currency_code || currency;
|
||||
@ -344,15 +175,11 @@ export class UsersService {
|
||||
return summary;
|
||||
}
|
||||
|
||||
// Fetch live data from WHMCS in parallel
|
||||
const [subscriptionsData, invoicesData] = await Promise.allSettled([
|
||||
this.whmcsService.getSubscriptions(mapping.whmcsClientId, userId),
|
||||
this.whmcsService.getInvoices(mapping.whmcsClientId, userId, {
|
||||
limit: 50,
|
||||
}),
|
||||
this.whmcsService.getInvoices(mapping.whmcsClientId, userId, { limit: 50 }),
|
||||
]);
|
||||
|
||||
// Process subscriptions
|
||||
let activeSubscriptions = 0;
|
||||
let recentSubscriptions: Array<{
|
||||
id: number;
|
||||
@ -362,12 +189,10 @@ export class UsersService {
|
||||
}> = [];
|
||||
if (subscriptionsData.status === "fulfilled") {
|
||||
const subscriptions: Subscription[] = subscriptionsData.value.subscriptions;
|
||||
activeSubscriptions = subscriptions.filter(
|
||||
(sub: Subscription) => sub.status === "Active"
|
||||
).length;
|
||||
activeSubscriptions = subscriptions.filter(sub => sub.status === "Active").length;
|
||||
recentSubscriptions = subscriptions
|
||||
.filter((sub: Subscription) => sub.status === "Active")
|
||||
.sort((a: Subscription, b: Subscription) => {
|
||||
.filter(sub => sub.status === "Active")
|
||||
.sort((a, b) => {
|
||||
const aTime = a.registrationDate
|
||||
? new Date(a.registrationDate).getTime()
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
@ -377,7 +202,7 @@ export class UsersService {
|
||||
return bTime - aTime;
|
||||
})
|
||||
.slice(0, 3)
|
||||
.map((sub: Subscription) => ({
|
||||
.map(sub => ({
|
||||
id: sub.id,
|
||||
status: sub.status,
|
||||
registrationDate: sub.registrationDate,
|
||||
@ -390,7 +215,6 @@ export class UsersService {
|
||||
);
|
||||
}
|
||||
|
||||
// Process invoices
|
||||
let unpaidInvoices = 0;
|
||||
let nextInvoice: NextInvoice | null = null;
|
||||
let recentInvoices: Array<{
|
||||
@ -406,17 +230,13 @@ export class UsersService {
|
||||
if (invoicesData.status === "fulfilled") {
|
||||
const invoices: Invoice[] = invoicesData.value.invoices;
|
||||
|
||||
// Count unpaid invoices
|
||||
unpaidInvoices = invoices.filter(
|
||||
(inv: Invoice) => inv.status === "Unpaid" || inv.status === "Overdue"
|
||||
inv => inv.status === "Unpaid" || inv.status === "Overdue"
|
||||
).length;
|
||||
|
||||
// Find next due invoice
|
||||
const upcomingInvoices = invoices
|
||||
.filter(
|
||||
(inv: Invoice) => (inv.status === "Unpaid" || inv.status === "Overdue") && inv.dueDate
|
||||
)
|
||||
.sort((a: Invoice, b: Invoice) => {
|
||||
.filter(inv => (inv.status === "Unpaid" || inv.status === "Overdue") && inv.dueDate)
|
||||
.sort((a, b) => {
|
||||
const aTime = a.dueDate ? new Date(a.dueDate).getTime() : Number.POSITIVE_INFINITY;
|
||||
const bTime = b.dueDate ? new Date(b.dueDate).getTime() : Number.POSITIVE_INFINITY;
|
||||
return aTime - bTime;
|
||||
@ -432,15 +252,14 @@ export class UsersService {
|
||||
};
|
||||
}
|
||||
|
||||
// Recent invoices for activity
|
||||
recentInvoices = invoices
|
||||
.sort((a: Invoice, b: Invoice) => {
|
||||
.sort((a, b) => {
|
||||
const aTime = a.issuedAt ? new Date(a.issuedAt).getTime() : Number.NEGATIVE_INFINITY;
|
||||
const bTime = b.issuedAt ? new Date(b.issuedAt).getTime() : Number.NEGATIVE_INFINITY;
|
||||
return bTime - aTime;
|
||||
})
|
||||
.slice(0, 5)
|
||||
.map((inv: Invoice) => ({
|
||||
.map(inv => ({
|
||||
id: inv.id,
|
||||
status: inv.status,
|
||||
dueDate: inv.dueDate,
|
||||
@ -455,16 +274,14 @@ export class UsersService {
|
||||
});
|
||||
}
|
||||
|
||||
// Build activity feed
|
||||
const activities: Activity[] = [];
|
||||
|
||||
// Add invoice activities
|
||||
recentInvoices.forEach(invoice => {
|
||||
if (invoice.status === "Paid") {
|
||||
const metadata = {
|
||||
const metadata: Record<string, unknown> = {
|
||||
amount: invoice.total,
|
||||
currency: invoice.currency ?? "JPY",
|
||||
} as Record<string, unknown>;
|
||||
};
|
||||
if (invoice.dueDate) metadata.dueDate = invoice.dueDate;
|
||||
if (invoice.number) metadata.invoiceNumber = invoice.number;
|
||||
activities.push({
|
||||
@ -477,13 +294,13 @@ export class UsersService {
|
||||
metadata,
|
||||
});
|
||||
} else if (invoice.status === "Unpaid" || invoice.status === "Overdue") {
|
||||
const metadata = {
|
||||
const metadata: Record<string, unknown> = {
|
||||
amount: invoice.total,
|
||||
currency: invoice.currency ?? "JPY",
|
||||
} as Record<string, unknown>;
|
||||
status: invoice.status,
|
||||
};
|
||||
if (invoice.dueDate) metadata.dueDate = invoice.dueDate;
|
||||
if (invoice.number) metadata.invoiceNumber = invoice.number;
|
||||
metadata.status = invoice.status;
|
||||
activities.push({
|
||||
id: `invoice-created-${invoice.id}`,
|
||||
type: "invoice_created",
|
||||
@ -496,14 +313,14 @@ export class UsersService {
|
||||
}
|
||||
});
|
||||
|
||||
// Add subscription activities
|
||||
recentSubscriptions.forEach(subscription => {
|
||||
const metadata = {
|
||||
const metadata: Record<string, unknown> = {
|
||||
productName: subscription.productName,
|
||||
status: subscription.status,
|
||||
} as Record<string, unknown>;
|
||||
if (subscription.registrationDate)
|
||||
};
|
||||
if (subscription.registrationDate) {
|
||||
metadata.registrationDate = subscription.registrationDate;
|
||||
}
|
||||
activities.push({
|
||||
id: `service-activated-${subscription.id}`,
|
||||
type: "service_activated",
|
||||
@ -515,7 +332,6 @@ export class UsersService {
|
||||
});
|
||||
});
|
||||
|
||||
// Sort activities by date and take top 10
|
||||
activities.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
const recentActivity = activities.slice(0, 10);
|
||||
|
||||
@ -526,8 +342,7 @@ export class UsersService {
|
||||
hasNextInvoice: !!nextInvoice,
|
||||
});
|
||||
|
||||
// Get currency from client data
|
||||
let currency = "JPY"; // Default
|
||||
let currency = "JPY";
|
||||
try {
|
||||
const client = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
|
||||
const resolvedCurrency =
|
||||
@ -548,7 +363,7 @@ export class UsersService {
|
||||
stats: {
|
||||
activeSubscriptions,
|
||||
unpaidInvoices,
|
||||
openCases: 0, // Support cases not implemented yet
|
||||
openCases: 0,
|
||||
currency,
|
||||
},
|
||||
nextInvoice,
|
||||
@ -562,4 +377,25 @@ export class UsersService {
|
||||
throw new BadRequestException("Unable to retrieve dashboard summary");
|
||||
}
|
||||
}
|
||||
|
||||
private async getProfileForUser(user: PrismaUser): Promise<User> {
|
||||
const mapping = await this.mappingsService.findByUserId(user.id);
|
||||
if (!mapping?.whmcsClientId) {
|
||||
throw new NotFoundException("WHMCS client mapping not found");
|
||||
}
|
||||
|
||||
try {
|
||||
const whmcsClient = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
|
||||
const userAuth = CustomerProviders.Portal.mapPrismaUserToUserAuth(user);
|
||||
return combineToUser(userAuth, whmcsClient);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to fetch client profile from WHMCS", {
|
||||
error: getErrorMessage(error),
|
||||
userId: user.id,
|
||||
whmcsClientId: mapping.whmcsClientId,
|
||||
});
|
||||
throw new BadRequestException("Unable to retrieve customer profile from billing system");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,8 +8,8 @@ import {
|
||||
ClassSerializerInterceptor,
|
||||
UsePipes,
|
||||
} from "@nestjs/common";
|
||||
import { UsersService } from "./users.service";
|
||||
import { ZodValidationPipe } from "@bff/core/validation";
|
||||
import { UsersFacade } from "./application/users.facade";
|
||||
import { ZodValidationPipe } from "@customer-portal/validation/nestjs";
|
||||
import {
|
||||
updateCustomerProfileRequestSchema,
|
||||
type UpdateCustomerProfileRequest,
|
||||
@ -20,7 +20,7 @@ import type { RequestWithUser } from "@bff/modules/auth/auth.types";
|
||||
@Controller("me")
|
||||
@UseInterceptors(ClassSerializerInterceptor)
|
||||
export class UsersController {
|
||||
constructor(private usersService: UsersService) {}
|
||||
constructor(private usersFacade: UsersFacade) {}
|
||||
|
||||
/**
|
||||
* GET /me - Get complete customer profile (includes address)
|
||||
@ -28,7 +28,7 @@ export class UsersController {
|
||||
*/
|
||||
@Get()
|
||||
async getProfile(@Req() req: RequestWithUser) {
|
||||
return this.usersService.findById(req.user.id);
|
||||
return this.usersFacade.findById(req.user.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -36,7 +36,7 @@ export class UsersController {
|
||||
*/
|
||||
@Get("summary")
|
||||
async getSummary(@Req() req: RequestWithUser) {
|
||||
return this.usersService.getUserSummary(req.user.id);
|
||||
return this.usersFacade.getUserSummary(req.user.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -44,7 +44,7 @@ export class UsersController {
|
||||
*/
|
||||
@Get("address")
|
||||
async getAddress(@Req() req: RequestWithUser): Promise<Address | null> {
|
||||
return this.usersService.getAddress(req.user.id);
|
||||
return this.usersFacade.getAddress(req.user.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -56,7 +56,7 @@ export class UsersController {
|
||||
@Req() req: RequestWithUser,
|
||||
@Body() address: Partial<Address>
|
||||
): Promise<Address> {
|
||||
return this.usersService.updateAddress(req.user.id, address);
|
||||
return this.usersFacade.updateAddress(req.user.id, address);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -75,6 +75,6 @@ export class UsersController {
|
||||
@Req() req: RequestWithUser,
|
||||
@Body() updateData: UpdateCustomerProfileRequest
|
||||
) {
|
||||
return this.usersService.updateProfile(req.user.id, updateData);
|
||||
return this.usersFacade.updateProfile(req.user.id, updateData);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 => ({
|
||||
|
||||
@ -1,32 +1,45 @@
|
||||
import { apiClient, getDataOrThrow } from "@/lib/api";
|
||||
import { getNullableData } from "@/lib/api/response-helpers";
|
||||
import type { UserProfile } from "@customer-portal/domain/customer";
|
||||
import type { Address } from "@customer-portal/domain/customer";
|
||||
|
||||
type ProfileUpdateInput = {
|
||||
firstname?: string;
|
||||
lastname?: string;
|
||||
phonenumber?: string;
|
||||
};
|
||||
import {
|
||||
userSchema,
|
||||
addressSchema,
|
||||
type UserProfile,
|
||||
type Address,
|
||||
} from "@customer-portal/domain/customer";
|
||||
import {
|
||||
updateCustomerProfileRequestSchema,
|
||||
type UpdateCustomerProfileRequest,
|
||||
} from "@customer-portal/domain/auth";
|
||||
|
||||
export const accountService = {
|
||||
async getProfile() {
|
||||
const response = await apiClient.GET<UserProfile>("/api/me");
|
||||
return getNullableData<UserProfile>(response);
|
||||
const data = getNullableData<UserProfile>(response);
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
return userSchema.parse(data);
|
||||
},
|
||||
|
||||
async updateProfile(update: ProfileUpdateInput) {
|
||||
const response = await apiClient.PATCH<UserProfile>("/api/me", { body: update });
|
||||
return getDataOrThrow<UserProfile>(response, "Failed to update profile");
|
||||
async updateProfile(update: UpdateCustomerProfileRequest) {
|
||||
const sanitized = updateCustomerProfileRequestSchema.parse(update);
|
||||
const response = await apiClient.PATCH<UserProfile>("/api/me", { body: sanitized });
|
||||
const data = getDataOrThrow<UserProfile>(response, "Failed to update profile");
|
||||
return userSchema.parse(data);
|
||||
},
|
||||
|
||||
async getAddress() {
|
||||
const response = await apiClient.GET<Address>("/api/me/address");
|
||||
return getNullableData<Address>(response);
|
||||
const data = getNullableData<Address>(response);
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
return addressSchema.parse(data);
|
||||
},
|
||||
|
||||
async updateAddress(address: Address) {
|
||||
const response = await apiClient.PATCH<Address>("/api/me/address", { body: address });
|
||||
return getDataOrThrow<Address>(response, "Failed to update address");
|
||||
const data = getDataOrThrow<Address>(response, "Failed to update address");
|
||||
return addressSchema.parse(data);
|
||||
},
|
||||
};
|
||||
|
||||
@ -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]
|
||||
);
|
||||
|
||||
@ -8,13 +8,17 @@ import { apiClient } from "@/lib/api";
|
||||
import { getNullableData } from "@/lib/api/response-helpers";
|
||||
import { getErrorInfo } from "@/lib/utils/error-handling";
|
||||
import logger from "@customer-portal/logging";
|
||||
import type {
|
||||
AuthTokens,
|
||||
LinkWhmcsRequest,
|
||||
LoginRequest,
|
||||
SignupRequest,
|
||||
import {
|
||||
authResponseSchema,
|
||||
checkPasswordNeededResponseSchema,
|
||||
linkWhmcsResponseSchema,
|
||||
type AuthTokens,
|
||||
type CheckPasswordNeededResponse,
|
||||
type LinkWhmcsRequest,
|
||||
type LinkWhmcsResponse,
|
||||
type LoginRequest,
|
||||
type SignupRequest,
|
||||
} from "@customer-portal/domain/auth";
|
||||
import { authResponseSchema } from "@customer-portal/domain/auth";
|
||||
import type { AuthenticatedUser } from "@customer-portal/domain/customer";
|
||||
import {
|
||||
clearLogoutReason,
|
||||
@ -42,8 +46,8 @@ export interface AuthState {
|
||||
requestPasswordReset: (email: string) => Promise<void>;
|
||||
resetPassword: (token: string, password: string) => Promise<void>;
|
||||
changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
|
||||
checkPasswordNeeded: (email: string) => Promise<{ needsPasswordSet: boolean }>;
|
||||
linkWhmcs: (request: LinkWhmcsRequest) => Promise<{ needsPasswordSet: boolean; email: string }>;
|
||||
checkPasswordNeeded: (email: string) => Promise<CheckPasswordNeededResponse>;
|
||||
linkWhmcs: (request: LinkWhmcsRequest) => Promise<LinkWhmcsResponse>;
|
||||
setPassword: (email: string, password: string) => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
refreshSession: () => Promise<void>;
|
||||
@ -229,12 +233,13 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
||||
body: { email },
|
||||
});
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error("Check failed");
|
||||
const parsed = checkPasswordNeededResponseSchema.safeParse(response.data);
|
||||
if (!parsed.success) {
|
||||
throw new Error(parsed.error.issues?.[0]?.message ?? "Check failed");
|
||||
}
|
||||
|
||||
set({ loading: false });
|
||||
return response.data as { needsPasswordSet: boolean };
|
||||
return parsed.data;
|
||||
} catch (error) {
|
||||
set({
|
||||
loading: false,
|
||||
@ -244,20 +249,20 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
||||
}
|
||||
},
|
||||
|
||||
linkWhmcs: async ({ email, password }: LinkWhmcsRequest) => {
|
||||
linkWhmcs: async (linkRequest: LinkWhmcsRequest) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await apiClient.POST("/api/auth/link-whmcs", {
|
||||
body: { email, password },
|
||||
body: linkRequest,
|
||||
});
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error("WHMCS link failed");
|
||||
const parsed = linkWhmcsResponseSchema.safeParse(response.data);
|
||||
if (!parsed.success) {
|
||||
throw new Error(parsed.error.issues?.[0]?.message ?? "WHMCS link failed");
|
||||
}
|
||||
|
||||
set({ loading: false });
|
||||
const result = response.data as { needsPasswordSet: boolean };
|
||||
return { ...result, email };
|
||||
return parsed.data;
|
||||
} catch (error) {
|
||||
set({
|
||||
loading: false,
|
||||
|
||||
@ -40,8 +40,9 @@ export function LinkWhmcsView() {
|
||||
</div>
|
||||
|
||||
<LinkWhmcsForm
|
||||
onTransferred={({ needsPasswordSet, email }) => {
|
||||
if (needsPasswordSet) {
|
||||
onTransferred={result => {
|
||||
const email = result.user.email;
|
||||
if (result.needsPasswordSet) {
|
||||
router.push(`/auth/set-password?email=${encodeURIComponent(email)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -74,6 +74,7 @@ export type {
|
||||
PasswordChangeResult,
|
||||
SsoLinkResponse,
|
||||
CheckPasswordNeededResponse,
|
||||
LinkWhmcsResponse,
|
||||
// Error types
|
||||
AuthError,
|
||||
} from './schema';
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -185,6 +185,14 @@ export const checkPasswordNeededResponseSchema = z.object({
|
||||
email: z.email().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Link WHMCS response
|
||||
*/
|
||||
export const linkWhmcsResponseSchema = z.object({
|
||||
user: userSchema,
|
||||
needsPasswordSet: z.boolean(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Inferred Types (Schema-First Approach)
|
||||
// ============================================================================
|
||||
@ -213,6 +221,7 @@ export type SignupResult = z.infer<typeof signupResultSchema>;
|
||||
export type PasswordChangeResult = z.infer<typeof passwordChangeResultSchema>;
|
||||
export type SsoLinkResponse = z.infer<typeof ssoLinkResponseSchema>;
|
||||
export type CheckPasswordNeededResponse = z.infer<typeof checkPasswordNeededResponseSchema>;
|
||||
export type LinkWhmcsResponse = z.infer<typeof linkWhmcsResponseSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Error Types
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
126
packages/domain/orders/providers/salesforce/field-map.ts
Normal file
126
packages/domain/orders/providers/salesforce/field-map.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import type { SalesforceProduct2WithPricebookEntries } from "../../../catalog/providers/salesforce/raw.types";
|
||||
import type {
|
||||
SalesforceOrderItemRecord,
|
||||
SalesforceOrderRecord,
|
||||
} from "./raw.types";
|
||||
|
||||
export interface SalesforceOrderFieldMap {
|
||||
order: {
|
||||
type: keyof SalesforceOrderRecord;
|
||||
activationType: keyof SalesforceOrderRecord;
|
||||
activationScheduledAt: keyof SalesforceOrderRecord;
|
||||
activationStatus: keyof SalesforceOrderRecord;
|
||||
activationErrorCode: keyof SalesforceOrderRecord;
|
||||
activationErrorMessage: keyof SalesforceOrderRecord;
|
||||
activationLastAttemptAt: keyof SalesforceOrderRecord;
|
||||
internetPlanTier: keyof SalesforceOrderRecord;
|
||||
installationType: keyof SalesforceOrderRecord;
|
||||
weekendInstall: keyof SalesforceOrderRecord;
|
||||
accessMode: keyof SalesforceOrderRecord;
|
||||
hikariDenwa: keyof SalesforceOrderRecord;
|
||||
vpnRegion: keyof SalesforceOrderRecord;
|
||||
simType: keyof SalesforceOrderRecord;
|
||||
simVoiceMail: keyof SalesforceOrderRecord;
|
||||
simCallWaiting: keyof SalesforceOrderRecord;
|
||||
eid: keyof SalesforceOrderRecord;
|
||||
whmcsOrderId: keyof SalesforceOrderRecord;
|
||||
addressChanged: keyof SalesforceOrderRecord;
|
||||
billingStreet: keyof SalesforceOrderRecord;
|
||||
billingCity: keyof SalesforceOrderRecord;
|
||||
billingState: keyof SalesforceOrderRecord;
|
||||
billingPostalCode: keyof SalesforceOrderRecord;
|
||||
billingCountry: keyof SalesforceOrderRecord;
|
||||
mnpApplication: keyof SalesforceOrderRecord;
|
||||
mnpReservation: keyof SalesforceOrderRecord;
|
||||
mnpExpiry: keyof SalesforceOrderRecord;
|
||||
mnpPhone: keyof SalesforceOrderRecord;
|
||||
mvnoAccountNumber: keyof SalesforceOrderRecord;
|
||||
portingDateOfBirth: keyof SalesforceOrderRecord;
|
||||
portingFirstName: keyof SalesforceOrderRecord;
|
||||
portingLastName: keyof SalesforceOrderRecord;
|
||||
portingFirstNameKatakana: keyof SalesforceOrderRecord;
|
||||
portingLastNameKatakana: keyof SalesforceOrderRecord;
|
||||
portingGender: keyof SalesforceOrderRecord;
|
||||
};
|
||||
orderItem: {
|
||||
billingCycle: keyof SalesforceOrderItemRecord;
|
||||
whmcsServiceId: keyof SalesforceOrderItemRecord;
|
||||
};
|
||||
product: {
|
||||
sku: keyof SalesforceProduct2WithPricebookEntries;
|
||||
itemClass: keyof SalesforceProduct2WithPricebookEntries;
|
||||
billingCycle: keyof SalesforceProduct2WithPricebookEntries;
|
||||
whmcsProductId: keyof SalesforceProduct2WithPricebookEntries;
|
||||
internetOfferingType: keyof SalesforceProduct2WithPricebookEntries;
|
||||
internetPlanTier: keyof SalesforceProduct2WithPricebookEntries;
|
||||
vpnRegion: keyof SalesforceProduct2WithPricebookEntries;
|
||||
};
|
||||
}
|
||||
|
||||
export type PartialSalesforceOrderFieldMap = {
|
||||
[Section in keyof SalesforceOrderFieldMap]?: Partial<SalesforceOrderFieldMap[Section]>;
|
||||
};
|
||||
|
||||
export const defaultSalesforceOrderFieldMap: SalesforceOrderFieldMap = {
|
||||
order: {
|
||||
type: "Type",
|
||||
activationType: "Activation_Type__c",
|
||||
activationScheduledAt: "Activation_Scheduled_At__c",
|
||||
activationStatus: "Activation_Status__c",
|
||||
activationErrorCode: "Activation_Error_Code__c",
|
||||
activationErrorMessage: "Activation_Error_Message__c",
|
||||
activationLastAttemptAt: "Activation_Last_Attempt_At__c" as keyof SalesforceOrderRecord,
|
||||
internetPlanTier: "Internet_Plan_Tier__c",
|
||||
installationType: "Installment_Plan__c",
|
||||
weekendInstall: "Weekend_Install__c",
|
||||
accessMode: "Access_Mode__c",
|
||||
hikariDenwa: "Hikari_Denwa__c",
|
||||
vpnRegion: "VPN_Region__c",
|
||||
simType: "SIM_Type__c",
|
||||
simVoiceMail: "SIM_Voice_Mail__c",
|
||||
simCallWaiting: "SIM_Call_Waiting__c",
|
||||
eid: "EID__c",
|
||||
whmcsOrderId: "WHMCS_Order_ID__c",
|
||||
addressChanged: "Address_Changed__c",
|
||||
billingStreet: "BillingStreet",
|
||||
billingCity: "BillingCity",
|
||||
billingState: "BillingState",
|
||||
billingPostalCode: "BillingPostalCode",
|
||||
billingCountry: "BillingCountry",
|
||||
mnpApplication: "MNP_Application__c",
|
||||
mnpReservation: "MNP_Reservation_Number__c",
|
||||
mnpExpiry: "MNP_Expiry_Date__c",
|
||||
mnpPhone: "MNP_Phone_Number__c",
|
||||
mvnoAccountNumber: "MVNO_Account_Number__c",
|
||||
portingDateOfBirth: "Porting_Date_Of_Birth__c",
|
||||
portingFirstName: "Porting_First_Name__c",
|
||||
portingLastName: "Porting_Last_Name__c",
|
||||
portingFirstNameKatakana: "Porting_First_Name_Katakana__c",
|
||||
portingLastNameKatakana: "Porting_Last_Name_Katakana__c",
|
||||
portingGender: "Porting_Gender__c",
|
||||
},
|
||||
orderItem: {
|
||||
billingCycle: "Billing_Cycle__c",
|
||||
whmcsServiceId: "WHMCS_Service_ID__c",
|
||||
},
|
||||
product: {
|
||||
sku: "StockKeepingUnit",
|
||||
itemClass: "Item_Class__c",
|
||||
billingCycle: "Billing_Cycle__c",
|
||||
whmcsProductId: "WH_Product_ID__c",
|
||||
internetOfferingType: "Internet_Offering_Type__c",
|
||||
internetPlanTier: "Internet_Plan_Tier__c",
|
||||
vpnRegion: "VPN_Region__c",
|
||||
},
|
||||
};
|
||||
|
||||
export function createSalesforceOrderFieldMap(
|
||||
overrides: PartialSalesforceOrderFieldMap = {}
|
||||
): SalesforceOrderFieldMap {
|
||||
return {
|
||||
order: { ...defaultSalesforceOrderFieldMap.order, ...overrides.order },
|
||||
orderItem: { ...defaultSalesforceOrderFieldMap.orderItem, ...overrides.orderItem },
|
||||
product: { ...defaultSalesforceOrderFieldMap.product, ...overrides.product },
|
||||
};
|
||||
}
|
||||
|
||||
@ -12,6 +12,14 @@ import type {
|
||||
} from "../../contract";
|
||||
import { normalizeBillingCycle } from "../../helpers";
|
||||
import { orderDetailsSchema, orderSummarySchema, orderItemDetailsSchema } from "../../schema";
|
||||
import type {
|
||||
SalesforceProduct2WithPricebookEntries,
|
||||
SalesforcePricebookEntryRecord,
|
||||
} from "../../../catalog/providers/salesforce/raw.types";
|
||||
import {
|
||||
defaultSalesforceOrderFieldMap,
|
||||
type SalesforceOrderFieldMap,
|
||||
} from "./field-map";
|
||||
import type {
|
||||
SalesforceOrderItemRecord,
|
||||
SalesforceOrderRecord,
|
||||
@ -21,34 +29,43 @@ import type {
|
||||
* Transform a Salesforce OrderItem record into domain details + summary.
|
||||
*/
|
||||
export function transformSalesforceOrderItem(
|
||||
record: SalesforceOrderItemRecord
|
||||
record: SalesforceOrderItemRecord,
|
||||
fieldMap: SalesforceOrderFieldMap = defaultSalesforceOrderFieldMap
|
||||
): { details: OrderItemDetails; summary: OrderItemSummary } {
|
||||
// PricebookEntry is unknown to avoid circular dependencies between domains
|
||||
const pricebookEntry = record.PricebookEntry as Record<string, any> | null | undefined;
|
||||
const product = pricebookEntry?.Product2 as Record<string, any> | undefined;
|
||||
const productBillingCycle = product?.Billing_Cycle__c ?? undefined;
|
||||
const billingCycleRaw = record.Billing_Cycle__c ?? productBillingCycle ?? undefined;
|
||||
const billingCycle = billingCycleRaw
|
||||
? normalizeBillingCycle(billingCycleRaw)
|
||||
: undefined;
|
||||
const pricebookEntry = (record.PricebookEntry ?? null) as
|
||||
| SalesforcePricebookEntryRecord
|
||||
| null;
|
||||
const product = pricebookEntry?.Product2 as SalesforceProduct2WithPricebookEntries | undefined;
|
||||
|
||||
const orderItemFields = fieldMap.orderItem;
|
||||
const productFields = fieldMap.product;
|
||||
|
||||
const billingCycleRaw =
|
||||
record[orderItemFields.billingCycle] ??
|
||||
(product ? (product[productFields.billingCycle] as unknown) : undefined);
|
||||
const billingCycle =
|
||||
billingCycleRaw !== undefined && billingCycleRaw !== null
|
||||
? normalizeBillingCycle(billingCycleRaw)
|
||||
: undefined;
|
||||
|
||||
const details = orderItemDetailsSchema.parse({
|
||||
id: record.Id,
|
||||
orderId: record.OrderId ?? "",
|
||||
orderId: ensureString(record.OrderId) ?? "",
|
||||
quantity: normalizeQuantity(record.Quantity),
|
||||
unitPrice: coerceNumber(record.UnitPrice),
|
||||
totalPrice: coerceNumber(record.TotalPrice),
|
||||
billingCycle,
|
||||
product: product
|
||||
? {
|
||||
id: product.Id ?? undefined,
|
||||
name: product.Name ?? undefined,
|
||||
sku: product.StockKeepingUnit ?? undefined,
|
||||
itemClass: product.Item_Class__c ?? undefined,
|
||||
whmcsProductId: product.WH_Product_ID__c ? String(product.WH_Product_ID__c) : undefined,
|
||||
internetOfferingType: product.Internet_Offering_Type__c ?? undefined,
|
||||
internetPlanTier: product.Internet_Plan_Tier__c ?? undefined,
|
||||
vpnRegion: product.VPN_Region__c ?? undefined,
|
||||
id: ensureString(product.Id),
|
||||
name: ensureString(product.Name),
|
||||
sku: ensureString(product[productFields.sku]) ?? undefined,
|
||||
itemClass: ensureString(product[productFields.itemClass]) ?? undefined,
|
||||
whmcsProductId: resolveWhmcsProductId(product[productFields.whmcsProductId]),
|
||||
internetOfferingType:
|
||||
ensureString(product[productFields.internetOfferingType]) ?? undefined,
|
||||
internetPlanTier: ensureString(product[productFields.internetPlanTier]) ?? undefined,
|
||||
vpnRegion: ensureString(product[productFields.vpnRegion]) ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
@ -74,28 +91,30 @@ export function transformSalesforceOrderItem(
|
||||
*/
|
||||
export function transformSalesforceOrderDetails(
|
||||
order: SalesforceOrderRecord,
|
||||
itemRecords: SalesforceOrderItemRecord[]
|
||||
itemRecords: SalesforceOrderItemRecord[],
|
||||
fieldMap: SalesforceOrderFieldMap = defaultSalesforceOrderFieldMap
|
||||
): OrderDetails {
|
||||
const transformedItems = itemRecords.map(record =>
|
||||
transformSalesforceOrderItem(record)
|
||||
transformSalesforceOrderItem(record, fieldMap)
|
||||
);
|
||||
|
||||
const items = transformedItems.map(item => item.details);
|
||||
const itemsSummary = transformedItems.map(item => item.summary);
|
||||
|
||||
const summary = buildOrderSummary(order, itemsSummary);
|
||||
const summary = buildOrderSummary(order, itemsSummary, fieldMap);
|
||||
const orderFields = fieldMap.order;
|
||||
|
||||
return orderDetailsSchema.parse({
|
||||
...summary,
|
||||
accountId: order.AccountId ?? undefined,
|
||||
accountName: typeof order.Account?.Name === "string" ? order.Account.Name : undefined,
|
||||
pricebook2Id: order.Pricebook2Id ?? undefined,
|
||||
activationType: order.Activation_Type__c ?? undefined,
|
||||
accountId: ensureString(order.AccountId),
|
||||
accountName: ensureString(order.Account?.Name),
|
||||
pricebook2Id: ensureString(order.Pricebook2Id),
|
||||
activationType: ensureString(order[orderFields.activationType]),
|
||||
activationStatus: summary.activationStatus,
|
||||
activationScheduledAt: order.Activation_Scheduled_At__c ?? undefined,
|
||||
activationErrorCode: order.Activation_Error_Code__c ?? undefined,
|
||||
activationErrorMessage: order.Activation_Error_Message__c ?? undefined,
|
||||
activatedDate: typeof order.ActivatedDate === "string" ? order.ActivatedDate : undefined,
|
||||
activationScheduledAt: ensureString(order[orderFields.activationScheduledAt]),
|
||||
activationErrorCode: ensureString(order[orderFields.activationErrorCode]),
|
||||
activationErrorMessage: ensureString(order[orderFields.activationErrorMessage]),
|
||||
activatedDate: ensureString(order.ActivatedDate),
|
||||
items,
|
||||
});
|
||||
}
|
||||
@ -105,18 +124,21 @@ export function transformSalesforceOrderDetails(
|
||||
*/
|
||||
export function transformSalesforceOrderSummary(
|
||||
order: SalesforceOrderRecord,
|
||||
itemRecords: SalesforceOrderItemRecord[]
|
||||
itemRecords: SalesforceOrderItemRecord[],
|
||||
fieldMap: SalesforceOrderFieldMap = defaultSalesforceOrderFieldMap
|
||||
): OrderSummary {
|
||||
const itemsSummary = itemRecords.map(record =>
|
||||
transformSalesforceOrderItem(record).summary
|
||||
transformSalesforceOrderItem(record, fieldMap).summary
|
||||
);
|
||||
return buildOrderSummary(order, itemsSummary);
|
||||
return buildOrderSummary(order, itemsSummary, fieldMap);
|
||||
}
|
||||
|
||||
function buildOrderSummary(
|
||||
order: SalesforceOrderRecord,
|
||||
itemsSummary: OrderItemSummary[]
|
||||
itemsSummary: OrderItemSummary[],
|
||||
fieldMap: SalesforceOrderFieldMap
|
||||
): OrderSummary {
|
||||
const orderFields = fieldMap.order;
|
||||
const effectiveDate =
|
||||
ensureString(order.EffectiveDate) ??
|
||||
ensureString(order.CreatedDate) ??
|
||||
@ -129,13 +151,13 @@ function buildOrderSummary(
|
||||
id: order.Id,
|
||||
orderNumber: ensureString(order.OrderNumber) ?? order.Id,
|
||||
status: ensureString(order.Status) ?? "Unknown",
|
||||
orderType: order.Type ?? undefined,
|
||||
orderType: ensureString(order[orderFields.type]) ?? undefined,
|
||||
effectiveDate,
|
||||
totalAmount: typeof totalAmount === "number" ? totalAmount : undefined,
|
||||
createdDate,
|
||||
lastModifiedDate,
|
||||
whmcsOrderId: order.WHMCS_Order_ID__c ?? undefined,
|
||||
activationStatus: order.Activation_Status__c ?? undefined,
|
||||
whmcsOrderId: ensureString(order[orderFields.whmcsOrderId]) ?? undefined,
|
||||
activationStatus: ensureString(order[orderFields.activationStatus]) ?? undefined,
|
||||
itemsSummary,
|
||||
});
|
||||
}
|
||||
@ -159,3 +181,16 @@ function normalizeQuantity(value: unknown): number {
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
function resolveWhmcsProductId(value: unknown): string | undefined {
|
||||
if (value === null || value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? String(value) : undefined;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
3
packages/validation/src/nestjs/index.d.ts
vendored
Normal file
3
packages/validation/src/nestjs/index.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
export { ZodValidationPipe, createZodDto, ZodValidationException } from "nestjs-zod";
|
||||
export { ZodValidationExceptionFilter } from "./zod-exception.filter";
|
||||
//# sourceMappingURL=index.d.ts.map
|
||||
1
packages/validation/src/nestjs/index.d.ts.map
Normal file
1
packages/validation/src/nestjs/index.d.ts.map
Normal file
@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,YAAY,EAAE,sBAAsB,EAAE,MAAM,YAAY,CAAC;AACrF,OAAO,EAAE,4BAA4B,EAAE,MAAM,wBAAwB,CAAC"}
|
||||
10
packages/validation/src/nestjs/index.js
Normal file
10
packages/validation/src/nestjs/index.js
Normal file
@ -0,0 +1,10 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ZodValidationExceptionFilter = exports.ZodValidationException = exports.createZodDto = exports.ZodValidationPipe = void 0;
|
||||
var nestjs_zod_1 = require("nestjs-zod");
|
||||
Object.defineProperty(exports, "ZodValidationPipe", { enumerable: true, get: function () { return nestjs_zod_1.ZodValidationPipe; } });
|
||||
Object.defineProperty(exports, "createZodDto", { enumerable: true, get: function () { return nestjs_zod_1.createZodDto; } });
|
||||
Object.defineProperty(exports, "ZodValidationException", { enumerable: true, get: function () { return nestjs_zod_1.ZodValidationException; } });
|
||||
var zod_exception_filter_1 = require("./zod-exception.filter");
|
||||
Object.defineProperty(exports, "ZodValidationExceptionFilter", { enumerable: true, get: function () { return zod_exception_filter_1.ZodValidationExceptionFilter; } });
|
||||
//# sourceMappingURL=index.js.map
|
||||
1
packages/validation/src/nestjs/index.js.map
Normal file
1
packages/validation/src/nestjs/index.js.map
Normal file
@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;AAAA,yCAAqF;AAA5E,+GAAA,iBAAiB,OAAA;AAAE,0GAAA,YAAY,OAAA;AAAE,oHAAA,sBAAsB,OAAA;AAChE,+DAAsE;AAA7D,oIAAA,4BAA4B,OAAA"}
|
||||
2
packages/validation/src/nestjs/index.ts
Normal file
2
packages/validation/src/nestjs/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { ZodValidationPipe, createZodDto, ZodValidationException } from "nestjs-zod";
|
||||
export { ZodValidationExceptionFilter } from "./zod-exception.filter";
|
||||
11
packages/validation/src/nestjs/zod-exception.filter.d.ts
vendored
Normal file
11
packages/validation/src/nestjs/zod-exception.filter.d.ts
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
import { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { ZodValidationException } from "nestjs-zod";
|
||||
export declare class ZodValidationExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger;
|
||||
constructor(logger: Logger);
|
||||
catch(exception: ZodValidationException, host: ArgumentsHost): void;
|
||||
private isZodError;
|
||||
private mapIssues;
|
||||
}
|
||||
//# sourceMappingURL=zod-exception.filter.d.ts.map
|
||||
@ -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"}
|
||||
75
packages/validation/src/nestjs/zod-exception.filter.js
Normal file
75
packages/validation/src/nestjs/zod-exception.filter.js
Normal file
@ -0,0 +1,75 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||
};
|
||||
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
||||
return function (target, key) { decorator(target, key, paramIndex); }
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ZodValidationExceptionFilter = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const nestjs_pino_1 = require("nestjs-pino");
|
||||
const nestjs_zod_1 = require("nestjs-zod");
|
||||
let ZodValidationExceptionFilter = class ZodValidationExceptionFilter {
|
||||
logger;
|
||||
constructor(logger) {
|
||||
this.logger = logger;
|
||||
}
|
||||
catch(exception, host) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse();
|
||||
const request = ctx.getRequest();
|
||||
const rawZodError = exception.getZodError();
|
||||
let issues = [];
|
||||
if (!this.isZodError(rawZodError)) {
|
||||
this.logger.error("ZodValidationException did not contain a ZodError", {
|
||||
path: request.url,
|
||||
method: request.method,
|
||||
providedType: typeof rawZodError,
|
||||
});
|
||||
}
|
||||
else {
|
||||
issues = this.mapIssues(rawZodError.issues);
|
||||
}
|
||||
this.logger.warn("Request validation failed", {
|
||||
path: request.url,
|
||||
method: request.method,
|
||||
issues,
|
||||
});
|
||||
response.status(common_1.HttpStatus.BAD_REQUEST).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "VALIDATION_FAILED",
|
||||
message: "Request validation failed",
|
||||
details: {
|
||||
issues,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
isZodError(error) {
|
||||
return Boolean(error && typeof error === "object" && Array.isArray(error.issues));
|
||||
}
|
||||
mapIssues(issues) {
|
||||
return issues.map(issue => ({
|
||||
path: issue.path.join(".") || "root",
|
||||
message: issue.message,
|
||||
code: issue.code,
|
||||
}));
|
||||
}
|
||||
};
|
||||
exports.ZodValidationExceptionFilter = ZodValidationExceptionFilter;
|
||||
exports.ZodValidationExceptionFilter = ZodValidationExceptionFilter = __decorate([
|
||||
(0, common_1.Catch)(nestjs_zod_1.ZodValidationException),
|
||||
__param(0, (0, common_1.Inject)(nestjs_pino_1.Logger)),
|
||||
__metadata("design:paramtypes", [nestjs_pino_1.Logger])
|
||||
], ZodValidationExceptionFilter);
|
||||
//# sourceMappingURL=zod-exception.filter.js.map
|
||||
@ -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"}
|
||||
@ -1,15 +1,15 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "dist/.tsbuildinfo"
|
||||
"tsBuildInfoFile": "dist/.tsbuildinfo",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist", "node_modules", "**/*.test.ts", "**/*.spec.ts"],
|
||||
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -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)..."
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user