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