- Introduced a new Notification model in the Prisma schema to manage in-app notifications for users. - Integrated the NotificationsModule into the BFF application, allowing for the handling of notifications related to user actions and events. - Updated the CatalogCdcSubscriber to create notifications for account eligibility and verification status changes, improving user engagement. - Enhanced the CheckoutRegistrationService to create opportunities for SIM orders, integrating with the new notifications system. - Refactored various modules to include the NotificationsModule, ensuring seamless interaction and notification handling across the application. - Updated the frontend to display notification alerts in the AppShell header, enhancing user experience and accessibility.
360 lines
12 KiB
TypeScript
360 lines
12 KiB
TypeScript
import { BadRequestException, Inject, Injectable, Optional } from "@nestjs/common";
|
|
import { Logger } from "nestjs-pino";
|
|
import * as argon2 from "argon2";
|
|
import { PrismaService } from "@bff/infra/database/prisma.service.js";
|
|
import { AuthTokenService } from "@bff/modules/auth/infra/token/token.service.js";
|
|
import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js";
|
|
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
|
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
|
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
|
import { WhmcsPaymentService } from "@bff/integrations/whmcs/services/whmcs-payment.service.js";
|
|
import { OpportunityMatchingService } from "@bff/modules/orders/services/opportunity-matching.service.js";
|
|
import type { OrderTypeValue } from "@customer-portal/domain/orders";
|
|
|
|
/**
|
|
* Request type for checkout registration
|
|
*/
|
|
interface CheckoutRegisterData {
|
|
email: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
phone: string;
|
|
phoneCountryCode: string;
|
|
password: string;
|
|
address: {
|
|
address1: string;
|
|
address2?: string;
|
|
city: string;
|
|
state: string;
|
|
postcode: string;
|
|
country: string;
|
|
};
|
|
/** Optional order type for Opportunity creation */
|
|
orderType?: OrderTypeValue;
|
|
}
|
|
|
|
/**
|
|
* Checkout Registration Service
|
|
*
|
|
* Orchestrates the multi-step registration flow during checkout:
|
|
* 1. Create Salesforce Account (generates SF_Account_No__c)
|
|
* 2. Create Salesforce Contact (linked to Account)
|
|
* 3. Create WHMCS Client (for billing)
|
|
* 4. Update SF Account with WH_Account__c
|
|
* 5. Create Portal User (with password hash)
|
|
* 6. Create ID Mapping (links all system IDs)
|
|
* 7. Generate auth tokens (auto-login user)
|
|
*/
|
|
@Injectable()
|
|
export class CheckoutRegistrationService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly tokenService: AuthTokenService,
|
|
private readonly salesforceAccountService: SalesforceAccountService,
|
|
private readonly whmcsClientService: WhmcsClientService,
|
|
private readonly whmcsPaymentService: WhmcsPaymentService,
|
|
private readonly mappingsService: MappingsService,
|
|
@Optional() private readonly opportunityMatchingService: OpportunityMatchingService | null,
|
|
@Inject(Logger) private readonly logger: Logger
|
|
) {}
|
|
|
|
/**
|
|
* Register a new customer during checkout
|
|
*
|
|
* CRITICAL: This creates accounts in ALL systems synchronously.
|
|
* If any step fails, we attempt rollback of previous steps.
|
|
*/
|
|
async registerForCheckout(data: CheckoutRegisterData): Promise<{
|
|
user: { id: string; email: string; firstname: string; lastname: string };
|
|
session: { expiresAt: string; refreshExpiresAt: string };
|
|
tokens?: { accessToken: string; refreshToken: string };
|
|
sfAccountNumber: string;
|
|
}> {
|
|
this.logger.log("Starting checkout registration", { email: data.email });
|
|
|
|
// Track created resources for rollback
|
|
let sfAccountId: string | null = null;
|
|
let sfContactId: string | null = null;
|
|
let sfAccountNumber: string | null = null;
|
|
let whmcsClientId: number | null = null;
|
|
let portalUserId: string | null = null;
|
|
|
|
try {
|
|
// Check for existing account by email
|
|
const existingAccount = await this.salesforceAccountService.findByEmail(data.email);
|
|
if (existingAccount) {
|
|
throw new BadRequestException(
|
|
"An account with this email already exists. Please sign in instead."
|
|
);
|
|
}
|
|
|
|
// Check for existing portal user
|
|
const existingUser = await this.prisma.user.findUnique({
|
|
where: { email: data.email.toLowerCase() },
|
|
});
|
|
if (existingUser) {
|
|
throw new BadRequestException(
|
|
"An account with this email already exists. Please sign in instead."
|
|
);
|
|
}
|
|
|
|
// Step 1: Create Salesforce Account
|
|
this.logger.log("Step 1: Creating Salesforce Account");
|
|
const sfAccount = await this.salesforceAccountService.createAccount({
|
|
firstName: data.firstName,
|
|
lastName: data.lastName,
|
|
email: data.email,
|
|
phone: this.formatPhone(data.phoneCountryCode, data.phone),
|
|
address: {
|
|
address1: data.address.address1,
|
|
address2: data.address.address2,
|
|
city: data.address.city,
|
|
state: data.address.state,
|
|
postcode: data.address.postcode,
|
|
country: data.address.country,
|
|
},
|
|
});
|
|
sfAccountId = sfAccount.accountId;
|
|
sfAccountNumber = sfAccount.accountNumber;
|
|
|
|
// Step 2: Create Salesforce Contact
|
|
this.logger.log("Step 2: Creating Salesforce Contact");
|
|
const sfContact = await this.salesforceAccountService.createContact({
|
|
accountId: sfAccountId,
|
|
firstName: data.firstName,
|
|
lastName: data.lastName,
|
|
email: data.email,
|
|
phone: this.formatPhone(data.phoneCountryCode, data.phone),
|
|
address: {
|
|
address1: data.address.address1,
|
|
address2: data.address.address2,
|
|
city: data.address.city,
|
|
state: data.address.state,
|
|
postcode: data.address.postcode,
|
|
country: data.address.country,
|
|
},
|
|
});
|
|
sfContactId = sfContact.contactId;
|
|
|
|
// Step 3: Create WHMCS Client
|
|
this.logger.log("Step 3: Creating WHMCS Client");
|
|
const whmcsResult = await this.whmcsClientService.addClient({
|
|
firstname: data.firstName,
|
|
lastname: data.lastName,
|
|
email: data.email,
|
|
phonenumber: this.formatPhone(data.phoneCountryCode, data.phone),
|
|
address1: data.address.address1,
|
|
city: data.address.city,
|
|
state: data.address.state,
|
|
postcode: data.address.postcode,
|
|
country: this.mapCountryToCode(data.address.country),
|
|
password2: data.password,
|
|
});
|
|
whmcsClientId = whmcsResult.clientId;
|
|
|
|
// Step 4: Update Salesforce Account with WHMCS ID
|
|
this.logger.log("Step 4: Linking Salesforce to WHMCS");
|
|
await this.salesforceAccountService.updatePortalFields(sfAccountId, {
|
|
whmcsAccountId: whmcsClientId,
|
|
status: "Active",
|
|
source: "Portal Checkout",
|
|
});
|
|
|
|
// Step 5 & 6: Create Portal User and ID Mapping in transaction
|
|
this.logger.log("Step 5: Creating Portal User");
|
|
const user = await this.prisma.$transaction(async tx => {
|
|
const passwordHash = await argon2.hash(data.password);
|
|
|
|
const newUser = await tx.user.create({
|
|
data: {
|
|
email: data.email.toLowerCase(),
|
|
passwordHash,
|
|
emailVerified: false,
|
|
},
|
|
});
|
|
|
|
// Step 6: Create ID Mapping
|
|
this.logger.log("Step 6: Creating ID Mapping");
|
|
await tx.idMapping.create({
|
|
data: {
|
|
userId: newUser.id,
|
|
whmcsClientId: whmcsClientId!,
|
|
sfAccountId: sfAccountId!,
|
|
// Note: sfContactId is not in schema, stored in Salesforce Contact record
|
|
},
|
|
});
|
|
|
|
return newUser;
|
|
});
|
|
portalUserId = user.id;
|
|
|
|
// Step 7: Create Opportunity for SIM orders
|
|
// Note: Internet orders create Opportunity during eligibility request, not registration
|
|
let opportunityId: string | null = null;
|
|
if (data.orderType === "SIM" && this.opportunityMatchingService && sfAccountId) {
|
|
this.logger.log("Step 7: Creating Opportunity for SIM checkout registration");
|
|
try {
|
|
opportunityId =
|
|
await this.opportunityMatchingService.createOpportunityForCheckoutRegistration(
|
|
sfAccountId
|
|
);
|
|
} catch (error) {
|
|
// Log but don't fail registration - Opportunity can be created later during order
|
|
this.logger.warn(
|
|
"Failed to create Opportunity during registration, will create during order",
|
|
{
|
|
error: getErrorMessage(error),
|
|
sfAccountId,
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
// Step 8: Generate auth tokens
|
|
this.logger.log("Step 8: Generating auth tokens");
|
|
const tokens = await this.tokenService.generateTokenPair({
|
|
id: user.id,
|
|
email: user.email,
|
|
});
|
|
|
|
this.logger.log("Checkout registration completed successfully", {
|
|
userId: user.id,
|
|
sfAccountId,
|
|
sfContactId,
|
|
sfAccountNumber,
|
|
whmcsClientId,
|
|
opportunityId,
|
|
});
|
|
|
|
return {
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
firstname: data.firstName,
|
|
lastname: data.lastName,
|
|
},
|
|
session: {
|
|
expiresAt: tokens.expiresAt,
|
|
refreshExpiresAt: tokens.refreshExpiresAt,
|
|
},
|
|
tokens: {
|
|
accessToken: tokens.accessToken,
|
|
refreshToken: tokens.refreshToken,
|
|
},
|
|
sfAccountNumber: sfAccountNumber,
|
|
};
|
|
} catch (error) {
|
|
this.logger.error("Checkout registration failed, initiating rollback", {
|
|
error: getErrorMessage(error),
|
|
sfAccountId,
|
|
sfContactId,
|
|
whmcsClientId,
|
|
portalUserId,
|
|
});
|
|
|
|
// Rollback in reverse order
|
|
await this.rollbackRegistration({
|
|
portalUserId,
|
|
whmcsClientId,
|
|
sfAccountId,
|
|
});
|
|
|
|
// Re-throw the original error
|
|
if (error instanceof BadRequestException) {
|
|
throw error;
|
|
}
|
|
throw new BadRequestException("Registration failed. Please try again or contact support.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if user has a valid payment method
|
|
*/
|
|
async getPaymentStatus(userId: string): Promise<{ hasPaymentMethod: boolean }> {
|
|
try {
|
|
const mapping = await this.mappingsService.findByUserId(userId);
|
|
if (!mapping?.whmcsClientId) {
|
|
return { hasPaymentMethod: false };
|
|
}
|
|
|
|
const paymentMethodList = await this.whmcsPaymentService.getPaymentMethods(
|
|
mapping.whmcsClientId,
|
|
userId
|
|
);
|
|
|
|
return {
|
|
hasPaymentMethod: paymentMethodList.totalCount > 0,
|
|
};
|
|
} catch (error) {
|
|
this.logger.error("Failed to check payment status", {
|
|
error: getErrorMessage(error),
|
|
userId,
|
|
});
|
|
return { hasPaymentMethod: false };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rollback registration - best effort cleanup
|
|
*/
|
|
private async rollbackRegistration(resources: {
|
|
portalUserId: string | null;
|
|
whmcsClientId: number | null;
|
|
sfAccountId: string | null;
|
|
}): Promise<void> {
|
|
// Portal user - can delete
|
|
if (resources.portalUserId) {
|
|
try {
|
|
await this.prisma.idMapping.deleteMany({
|
|
where: { userId: resources.portalUserId },
|
|
});
|
|
await this.prisma.user.delete({
|
|
where: { id: resources.portalUserId },
|
|
});
|
|
this.logger.log("Rollback: Deleted portal user", { userId: resources.portalUserId });
|
|
} catch (e) {
|
|
this.logger.error("Rollback failed: Portal user", { error: getErrorMessage(e) });
|
|
}
|
|
}
|
|
|
|
// WHMCS client - log for manual cleanup (WHMCS doesn't support API deletion)
|
|
if (resources.whmcsClientId) {
|
|
this.logger.warn("Rollback: WHMCS client created but not deleted (requires manual cleanup)", {
|
|
clientId: resources.whmcsClientId,
|
|
});
|
|
}
|
|
|
|
// Salesforce Account - intentionally NOT deleted
|
|
// It's better to have an orphaned SF Account that can be cleaned up
|
|
// than to lose potential customer data
|
|
if (resources.sfAccountId) {
|
|
this.logger.warn("Rollback: Salesforce Account not deleted (intentional)", {
|
|
sfAccountId: resources.sfAccountId,
|
|
action: "Manual cleanup may be required",
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format phone number with country code
|
|
*/
|
|
private formatPhone(countryCode: string, phone: string): string {
|
|
const cc = countryCode.replace(/\D/g, "");
|
|
const num = phone.replace(/\D/g, "");
|
|
return `+${cc}.${num}`;
|
|
}
|
|
|
|
/**
|
|
* Map country name to ISO code
|
|
*/
|
|
private mapCountryToCode(country: string): string {
|
|
const countryMap: Record<string, string> = {
|
|
japan: "JP",
|
|
"united states": "US",
|
|
"united kingdom": "GB",
|
|
// Add more as needed
|
|
};
|
|
return countryMap[country.toLowerCase()] || country.slice(0, 2).toUpperCase();
|
|
}
|
|
}
|