Add email functionality and update environment configurations
- Introduced email configuration for both development and production environments in `.env.dev.example` and `.env.production.example`. - Added SendGrid API key and email settings to support password reset and welcome emails. - Implemented password reset and request password reset endpoints in the AuthController. - Enhanced signup form to include additional fields such as Customer Number, address, nationality, date of birth, and gender. - Updated various services and controllers to integrate email functionality and handle new user data. - Refactored logging and error handling for improved clarity and maintainability. - Adjusted Docker configuration for production deployment.
This commit is contained in:
parent
0c912fc04f
commit
111bbc8c91
@ -8,6 +8,7 @@
|
|||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
APP_NAME=customer-portal-bff
|
APP_NAME=customer-portal-bff
|
||||||
BFF_PORT=4000
|
BFF_PORT=4000
|
||||||
|
APP_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 🔐 SECURITY CONFIGURATION (Development)
|
# 🔐 SECURITY CONFIGURATION (Development)
|
||||||
@ -79,6 +80,19 @@ NEXT_PUBLIC_ENABLE_DEVTOOLS=true
|
|||||||
# Node.js options for development
|
# Node.js options for development
|
||||||
NODE_OPTIONS=--no-deprecation
|
NODE_OPTIONS=--no-deprecation
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ✉️ EMAIL (SendGrid) - Development
|
||||||
|
# =============================================================================
|
||||||
|
SENDGRID_API_KEY=
|
||||||
|
EMAIL_FROM=no-reply@localhost.test
|
||||||
|
EMAIL_FROM_NAME=Assist Solutions (Dev)
|
||||||
|
EMAIL_ENABLED=true
|
||||||
|
EMAIL_USE_QUEUE=true
|
||||||
|
SENDGRID_SANDBOX=true
|
||||||
|
# Optional: dynamic template IDs (use {{resetUrl}} in reset template)
|
||||||
|
EMAIL_TEMPLATE_RESET=
|
||||||
|
EMAIL_TEMPLATE_WELCOME=
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 🚀 QUICK START (Development)
|
# 🚀 QUICK START (Development)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
68
.env.example
Normal file
68
.env.example
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# ====== Core ======
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# ====== Frontend (Next.js) ======
|
||||||
|
NEXT_PUBLIC_APP_NAME=Customer Portal
|
||||||
|
NEXT_PUBLIC_APP_VERSION=1.0.0
|
||||||
|
# If using Plesk single domain with /api proxied to backend, set to your main domain
|
||||||
|
# Example: https://portal.example.com or https://example.com
|
||||||
|
NEXT_PUBLIC_API_BASE=https://CHANGE_THIS
|
||||||
|
|
||||||
|
# ====== Backend (NestJS BFF) ======
|
||||||
|
BFF_PORT=4000
|
||||||
|
APP_BASE_URL=https://CHANGE_THIS
|
||||||
|
|
||||||
|
# ====== Database (PostgreSQL) ======
|
||||||
|
POSTGRES_DB=portal_prod
|
||||||
|
POSTGRES_USER=portal
|
||||||
|
POSTGRES_PASSWORD=CHANGE_THIS
|
||||||
|
|
||||||
|
# Prisma style DATABASE_URL for Postgres inside Compose network
|
||||||
|
# For Plesk Compose, hostname is the service name 'database'
|
||||||
|
DATABASE_URL=postgresql://portal:${POSTGRES_PASSWORD}@database:5432/${POSTGRES_DB}?schema=public
|
||||||
|
|
||||||
|
# ====== Redis ======
|
||||||
|
REDIS_URL=redis://cache:6379/0
|
||||||
|
|
||||||
|
# ====== Security ======
|
||||||
|
JWT_SECRET=CHANGE_THIS
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
BCRYPT_ROUNDS=12
|
||||||
|
|
||||||
|
# ====== CORS ======
|
||||||
|
# If portal: https://portal.example.com ; if root domain: https://example.com
|
||||||
|
CORS_ORIGIN=https://CHANGE_THIS
|
||||||
|
|
||||||
|
# ====== External APIs (optional) ======
|
||||||
|
WHMCS_BASE_URL=
|
||||||
|
WHMCS_API_IDENTIFIER=
|
||||||
|
WHMCS_API_SECRET=
|
||||||
|
SF_LOGIN_URL=
|
||||||
|
SF_CLIENT_ID=
|
||||||
|
SF_PRIVATE_KEY_PATH=/app/secrets/salesforce.key
|
||||||
|
SF_USERNAME=
|
||||||
|
|
||||||
|
# ====== Logging ======
|
||||||
|
LOG_LEVEL=info
|
||||||
|
LOG_FORMAT=json
|
||||||
|
|
||||||
|
# ====== Email (SendGrid) ======
|
||||||
|
# API key: https://app.sendgrid.com/settings/api_keys
|
||||||
|
SENDGRID_API_KEY=
|
||||||
|
# From address for outbound email
|
||||||
|
EMAIL_FROM=no-reply@yourdomain.com
|
||||||
|
EMAIL_FROM_NAME=Assist Solutions
|
||||||
|
# Master email switch
|
||||||
|
EMAIL_ENABLED=true
|
||||||
|
# Queue emails for async delivery (recommended)
|
||||||
|
EMAIL_USE_QUEUE=true
|
||||||
|
# Enable SendGrid sandbox mode (use true in non-prod to avoid delivery)
|
||||||
|
SENDGRID_SANDBOX=false
|
||||||
|
# Optional: dynamic template IDs (use {{resetUrl}} for reset template)
|
||||||
|
EMAIL_TEMPLATE_RESET=
|
||||||
|
EMAIL_TEMPLATE_WELCOME=
|
||||||
|
|
||||||
|
# ====== Node options ======
|
||||||
|
NODE_OPTIONS=--max-old-space-size=512
|
||||||
|
|
||||||
|
|
||||||
@ -8,6 +8,7 @@
|
|||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
APP_NAME=customer-portal-bff
|
APP_NAME=customer-portal-bff
|
||||||
BFF_PORT=4000
|
BFF_PORT=4000
|
||||||
|
APP_BASE_URL=https://portal.yourdomain.com
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 🔐 SECURITY CONFIGURATION (Production)
|
# 🔐 SECURITY CONFIGURATION (Production)
|
||||||
@ -79,6 +80,20 @@ NEXT_PUBLIC_ENABLE_DEVTOOLS=false
|
|||||||
# Node.js options for production
|
# Node.js options for production
|
||||||
NODE_OPTIONS=--max-old-space-size=2048
|
NODE_OPTIONS=--max-old-space-size=2048
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ✉️ EMAIL (SendGrid) - Production
|
||||||
|
# =============================================================================
|
||||||
|
# Create and store securely (e.g., KMS/Secrets Manager)
|
||||||
|
SENDGRID_API_KEY=
|
||||||
|
EMAIL_FROM=no-reply@yourdomain.com
|
||||||
|
EMAIL_FROM_NAME=Assist Solutions
|
||||||
|
EMAIL_ENABLED=true
|
||||||
|
EMAIL_USE_QUEUE=true
|
||||||
|
SENDGRID_SANDBOX=false
|
||||||
|
# Optional: Dynamic Template IDs (recommended)
|
||||||
|
EMAIL_TEMPLATE_RESET=
|
||||||
|
EMAIL_TEMPLATE_WELCOME=
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 🔒 PRODUCTION SECURITY CHECKLIST
|
# 🔒 PRODUCTION SECURITY CHECKLIST
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@ -1,10 +1,4 @@
|
|||||||
{
|
{
|
||||||
"*.{ts,tsx,js,jsx}": [
|
"*.{ts,tsx,js,jsx}": ["eslint --fix", "prettier -w"],
|
||||||
"eslint --fix",
|
"*.{json,md,yml,yaml,css,scss}": ["prettier -w"]
|
||||||
"prettier -w"
|
|
||||||
],
|
|
||||||
"*.{json,md,yml,yaml,css,scss}": [
|
|
||||||
"prettier -w"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -59,6 +59,7 @@
|
|||||||
"prisma": "^6.14.0",
|
"prisma": "^6.14.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
|
"@sendgrid/mail": "^8.1.3",
|
||||||
"speakeasy": "^2.0.0",
|
"speakeasy": "^2.0.0",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"zod": "^4.0.17"
|
"zod": "^4.0.17"
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
|
|||||||
import { VendorsModule } from "./vendors/vendors.module";
|
import { VendorsModule } from "./vendors/vendors.module";
|
||||||
import { JobsModule } from "./jobs/jobs.module";
|
import { JobsModule } from "./jobs/jobs.module";
|
||||||
import { HealthModule } from "./health/health.module";
|
import { HealthModule } from "./health/health.module";
|
||||||
|
import { EmailModule } from "./common/email/email.module";
|
||||||
import { PrismaModule } from "./common/prisma/prisma.module";
|
import { PrismaModule } from "./common/prisma/prisma.module";
|
||||||
import { AuditModule } from "./common/audit/audit.module";
|
import { AuditModule } from "./common/audit/audit.module";
|
||||||
import { RedisModule } from "./common/redis/redis.module";
|
import { RedisModule } from "./common/redis/redis.module";
|
||||||
@ -76,6 +77,7 @@ const envFilePath = [
|
|||||||
VendorsModule,
|
VendorsModule,
|
||||||
JobsModule,
|
JobsModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
|
EmailModule,
|
||||||
|
|
||||||
// Feature modules
|
// Feature modules
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import { JwtAuthGuard } from "./guards/jwt-auth.guard";
|
|||||||
import { AuthThrottleGuard } from "./guards/auth-throttle.guard";
|
import { AuthThrottleGuard } from "./guards/auth-throttle.guard";
|
||||||
import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger";
|
import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger";
|
||||||
import { SignupDto } from "./dto/signup.dto";
|
import { SignupDto } from "./dto/signup.dto";
|
||||||
|
import { RequestPasswordResetDto } from "./dto/request-password-reset.dto";
|
||||||
|
import { ResetPasswordDto } from "./dto/reset-password.dto";
|
||||||
|
|
||||||
import { LinkWhmcsDto } from "./dto/link-whmcs.dto";
|
import { LinkWhmcsDto } from "./dto/link-whmcs.dto";
|
||||||
import { SetPasswordDto } from "./dto/set-password.dto";
|
import { SetPasswordDto } from "./dto/set-password.dto";
|
||||||
@ -41,8 +43,13 @@ export class AuthController {
|
|||||||
@ApiOperation({ summary: "Logout user" })
|
@ApiOperation({ summary: "Logout user" })
|
||||||
@ApiResponse({ status: 200, description: "Logout successful" })
|
@ApiResponse({ status: 200, description: "Logout successful" })
|
||||||
async logout(@Req() req: Request & { user: { id: string } }) {
|
async logout(@Req() req: Request & { user: { id: string } }) {
|
||||||
const authHeader = req.headers["authorization"];
|
const authHeader = req.headers.authorization as string | string[] | undefined;
|
||||||
const bearer = Array.isArray(authHeader) ? authHeader[0] : authHeader;
|
let bearer: string | undefined;
|
||||||
|
if (typeof authHeader === "string") {
|
||||||
|
bearer = authHeader;
|
||||||
|
} else if (Array.isArray(authHeader) && authHeader.length > 0) {
|
||||||
|
bearer = authHeader[0];
|
||||||
|
}
|
||||||
const token = bearer?.startsWith("Bearer ") ? bearer.slice(7) : undefined;
|
const token = bearer?.startsWith("Bearer ") ? bearer.slice(7) : undefined;
|
||||||
await this.authService.logout(req.user.id, token ?? "", req);
|
await this.authService.logout(req.user.id, token ?? "", req);
|
||||||
return { message: "Logout successful" };
|
return { message: "Logout successful" };
|
||||||
@ -80,6 +87,23 @@ export class AuthController {
|
|||||||
return this.authService.checkPasswordNeeded(email);
|
return this.authService.checkPasswordNeeded(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("request-password-reset")
|
||||||
|
@Throttle({ default: { limit: 5, ttl: 900000 } })
|
||||||
|
@ApiOperation({ summary: "Request password reset email" })
|
||||||
|
@ApiResponse({ status: 200, description: "Reset email sent if account exists" })
|
||||||
|
async requestPasswordReset(@Body() body: RequestPasswordResetDto) {
|
||||||
|
await this.authService.requestPasswordReset(body.email);
|
||||||
|
return { message: "If an account exists, a reset email has been sent" };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("reset-password")
|
||||||
|
@Throttle({ default: { limit: 5, ttl: 900000 } })
|
||||||
|
@ApiOperation({ summary: "Reset password with token" })
|
||||||
|
@ApiResponse({ status: 200, description: "Password reset successful" })
|
||||||
|
async resetPassword(@Body() body: ResetPasswordDto) {
|
||||||
|
return this.authService.resetPassword(body.token, body.password);
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Get("me")
|
@Get("me")
|
||||||
@ApiOperation({ summary: "Get current authentication status" })
|
@ApiOperation({ summary: "Get current authentication status" })
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { VendorsModule } from "../vendors/vendors.module";
|
|||||||
import { JwtStrategy } from "./strategies/jwt.strategy";
|
import { JwtStrategy } from "./strategies/jwt.strategy";
|
||||||
import { LocalStrategy } from "./strategies/local.strategy";
|
import { LocalStrategy } from "./strategies/local.strategy";
|
||||||
import { TokenBlacklistService } from "./services/token-blacklist.service";
|
import { TokenBlacklistService } from "./services/token-blacklist.service";
|
||||||
|
import { EmailModule } from "../common/email/email.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -25,6 +26,7 @@ import { TokenBlacklistService } from "./services/token-blacklist.service";
|
|||||||
UsersModule,
|
UsersModule,
|
||||||
MappingsModule,
|
MappingsModule,
|
||||||
VendorsModule,
|
VendorsModule,
|
||||||
|
EmailModule,
|
||||||
],
|
],
|
||||||
controllers: [AuthController, AuthAdminController],
|
controllers: [AuthController, AuthAdminController],
|
||||||
providers: [AuthService, JwtStrategy, LocalStrategy, TokenBlacklistService],
|
providers: [AuthService, JwtStrategy, LocalStrategy, TokenBlacklistService],
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { LinkWhmcsDto } from "./dto/link-whmcs.dto";
|
|||||||
import { SetPasswordDto } from "./dto/set-password.dto";
|
import { SetPasswordDto } from "./dto/set-password.dto";
|
||||||
import { getErrorMessage } from "../common/utils/error.util";
|
import { getErrorMessage } from "../common/utils/error.util";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { EmailService } from "../common/email/email.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@ -34,11 +35,24 @@ export class AuthService {
|
|||||||
private salesforceService: SalesforceService,
|
private salesforceService: SalesforceService,
|
||||||
private auditService: AuditService,
|
private auditService: AuditService,
|
||||||
private tokenBlacklistService: TokenBlacklistService,
|
private tokenBlacklistService: TokenBlacklistService,
|
||||||
|
private emailService: EmailService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async signup(signupData: SignupDto, request?: unknown) {
|
async signup(signupData: SignupDto, request?: unknown) {
|
||||||
const { email, password, firstName, lastName, company, phone } = signupData;
|
const {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
company,
|
||||||
|
phone,
|
||||||
|
sfNumber,
|
||||||
|
address,
|
||||||
|
nationality,
|
||||||
|
dateOfBirth,
|
||||||
|
gender,
|
||||||
|
} = signupData;
|
||||||
|
|
||||||
// Enhanced input validation
|
// Enhanced input validation
|
||||||
this.validateSignupData(signupData);
|
this.validateSignupData(signupData);
|
||||||
@ -62,6 +76,14 @@ export class AuthService {
|
|||||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 0. Lookup Salesforce Account by Customer Number (SF Number)
|
||||||
|
const sfAccount = await this.salesforceService.findAccountByCustomerNumber(sfNumber);
|
||||||
|
if (!sfAccount) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Salesforce account not found for Customer Number: ${sfNumber}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Create user in portal
|
// 1. Create user in portal
|
||||||
const user = await this.usersService.create({
|
const user = await this.usersService.create({
|
||||||
email,
|
email,
|
||||||
@ -77,22 +99,37 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 2. Create client in WHMCS
|
// 2. Create client in WHMCS
|
||||||
|
// Prepare WHMCS custom fields (IDs configurable via env)
|
||||||
|
const customerNumberFieldId = this.configService.get<string>(
|
||||||
|
"WHMCS_CUSTOMER_NUMBER_FIELD_ID",
|
||||||
|
"198"
|
||||||
|
);
|
||||||
|
const dobFieldId = this.configService.get<string>("WHMCS_DOB_FIELD_ID");
|
||||||
|
const genderFieldId = this.configService.get<string>("WHMCS_GENDER_FIELD_ID");
|
||||||
|
const nationalityFieldId = this.configService.get<string>("WHMCS_NATIONALITY_FIELD_ID");
|
||||||
|
|
||||||
|
const customfields: Record<string, string> = {};
|
||||||
|
if (customerNumberFieldId) customfields[customerNumberFieldId] = sfNumber;
|
||||||
|
if (dobFieldId && dateOfBirth) customfields[dobFieldId] = dateOfBirth;
|
||||||
|
if (genderFieldId && gender) customfields[genderFieldId] = gender;
|
||||||
|
if (nationalityFieldId && nationality) customfields[nationalityFieldId] = nationality;
|
||||||
|
|
||||||
const whmcsClient = await this.whmcsService.addClient({
|
const whmcsClient = await this.whmcsService.addClient({
|
||||||
firstname: firstName,
|
firstname: firstName,
|
||||||
lastname: lastName,
|
lastname: lastName,
|
||||||
email,
|
email,
|
||||||
companyname: company || "",
|
companyname: company || "",
|
||||||
phonenumber: phone || "",
|
phonenumber: phone || "",
|
||||||
|
address1: address?.line1,
|
||||||
|
city: address?.city,
|
||||||
|
state: address?.state,
|
||||||
|
postcode: address?.postalCode,
|
||||||
|
country: address?.country,
|
||||||
password2: password, // WHMCS requires plain password for new clients
|
password2: password, // WHMCS requires plain password for new clients
|
||||||
|
customfields,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Create account in Salesforce (no Contact, just Account)
|
// 3. Store ID mappings
|
||||||
const sfAccount = await this.salesforceService.upsertAccount({
|
|
||||||
name: company || `${firstName} ${lastName}`,
|
|
||||||
phone: phone,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Store ID mappings
|
|
||||||
await this.mappingsService.createMapping({
|
await this.mappingsService.createMapping({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
whmcsClientId: whmcsClient.clientId,
|
whmcsClientId: whmcsClient.clientId,
|
||||||
@ -522,6 +559,69 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async requestPasswordReset(email: string): Promise<void> {
|
||||||
|
const user = await this.usersService.findByEmailInternal(email);
|
||||||
|
// Always act as if successful to avoid account enumeration
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a short-lived signed token (JWT) containing user id and purpose
|
||||||
|
const token = this.jwtService.sign(
|
||||||
|
{ sub: user.id, purpose: "password_reset" },
|
||||||
|
{ expiresIn: "15m" }
|
||||||
|
);
|
||||||
|
|
||||||
|
const appBase = this.configService.get("APP_BASE_URL", "http://localhost:3000");
|
||||||
|
const resetUrl = `${appBase}/auth/reset-password?token=${encodeURIComponent(token)}`;
|
||||||
|
const templateId = this.configService.get<string>("EMAIL_TEMPLATE_RESET");
|
||||||
|
|
||||||
|
if (templateId) {
|
||||||
|
await this.emailService.sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject: "Reset your password",
|
||||||
|
templateId,
|
||||||
|
dynamicTemplateData: { resetUrl },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.emailService.sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject: "Reset your Assist Solutions password",
|
||||||
|
html: `
|
||||||
|
<p>We received a request to reset your password.</p>
|
||||||
|
<p><a href="${resetUrl}">Click here to reset your password</a>. This link expires in 15 minutes.</p>
|
||||||
|
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetPassword(token: string, newPassword: string) {
|
||||||
|
try {
|
||||||
|
const payload = this.jwtService.verify<{ sub: string; purpose: string }>(token);
|
||||||
|
if (payload.purpose !== "password_reset") {
|
||||||
|
throw new BadRequestException("Invalid token");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.usersService.findById(payload.sub);
|
||||||
|
if (!user) throw new BadRequestException("Invalid token");
|
||||||
|
|
||||||
|
const saltRounds = this.configService.get("BCRYPT_ROUNDS", 12);
|
||||||
|
const passwordHash = await bcrypt.hash(newPassword, saltRounds);
|
||||||
|
|
||||||
|
const updatedUser = await this.usersService.update(user.id, { passwordHash });
|
||||||
|
const tokens = await this.generateTokens(updatedUser);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: this.sanitizeUser(updatedUser),
|
||||||
|
...tokens,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Reset password failed", { error: getErrorMessage(error) });
|
||||||
|
throw new BadRequestException("Invalid or expired token");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private validateSignupData(signupData: SignupDto) {
|
private validateSignupData(signupData: SignupDto) {
|
||||||
const { email, password, firstName, lastName } = signupData;
|
const { email, password, firstName, lastName } = signupData;
|
||||||
|
|
||||||
|
|||||||
9
apps/bff/src/auth/auth.types.ts
Normal file
9
apps/bff/src/auth/auth.types.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import type { Request } from "express";
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
roles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RequestWithUser = Request & { user: AuthUser };
|
||||||
8
apps/bff/src/auth/dto/request-password-reset.dto.ts
Normal file
8
apps/bff/src/auth/dto/request-password-reset.dto.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsEmail } from "class-validator";
|
||||||
|
|
||||||
|
export class RequestPasswordResetDto {
|
||||||
|
@ApiProperty({ example: "user@example.com" })
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
}
|
||||||
14
apps/bff/src/auth/dto/reset-password.dto.ts
Normal file
14
apps/bff/src/auth/dto/reset-password.dto.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsString, MinLength, Matches } from "class-validator";
|
||||||
|
|
||||||
|
export class ResetPasswordDto {
|
||||||
|
@ApiProperty({ description: "Password reset token" })
|
||||||
|
@IsString()
|
||||||
|
token!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: "SecurePassword123!" })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/)
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { IsEmail, IsString, MinLength, IsOptional, Matches } from "class-validator";
|
import { IsEmail, IsString, MinLength, IsOptional, Matches, IsIn } from "class-validator";
|
||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
|
||||||
export class SignupDto {
|
export class SignupDto {
|
||||||
@ -36,4 +36,34 @@ export class SignupDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: "CN-0012345", description: "Customer Number (SF Number)" })
|
||||||
|
@IsString()
|
||||||
|
sfNumber: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, description: "Address for WHMCS client" })
|
||||||
|
@IsOptional()
|
||||||
|
address?: {
|
||||||
|
line1: string;
|
||||||
|
line2?: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
postalCode: string;
|
||||||
|
country: string; // ISO 2-letter
|
||||||
|
};
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
nationality?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, example: "1990-01-01" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
dateOfBirth?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, enum: ["male", "female", "other"] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(["male", "female", "other"])
|
||||||
|
gender?: "male" | "female" | "other";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,24 +1,35 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
import type { Product } from "@customer-portal/shared";
|
import { Logger } from "nestjs-pino";
|
||||||
import { WhmcsService } from "../vendors/whmcs/whmcs.service";
|
|
||||||
import { CacheService } from "../common/cache/cache.service";
|
import { CacheService } from "../common/cache/cache.service";
|
||||||
|
import { SalesforceConnection } from "../vendors/salesforce/services/salesforce-connection.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CatalogService {
|
export class CatalogService {
|
||||||
constructor(
|
constructor(
|
||||||
private whmcsService: WhmcsService,
|
private cacheService: CacheService,
|
||||||
private cacheService: CacheService
|
private readonly sf: SalesforceConnection,
|
||||||
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getProducts(): Promise<Product[]> {
|
async getProducts(): Promise<any[]> {
|
||||||
const cacheKey = "catalog:products";
|
const cacheKey = "catalog:products";
|
||||||
const ttl = 15 * 60; // 15 minutes
|
const ttl = 15 * 60; // 15 minutes
|
||||||
|
|
||||||
return this.cacheService.getOrSet<Product[]>(
|
return this.cacheService.getOrSet<any[]>(
|
||||||
cacheKey,
|
cacheKey,
|
||||||
async () => {
|
async () => {
|
||||||
const result = await this.whmcsService.getProducts();
|
// Read Product2s visible in portal and include SKU__c
|
||||||
return result.products.map((p: any) => this.whmcsService.transformProduct(p));
|
const skuField = process.env.PRODUCT_SKU_FIELD || "SKU__c";
|
||||||
|
const soql = `SELECT Id, Name, ${skuField}, Portal_Category__c, Portal_Visible__c FROM Product2 WHERE Portal_Visible__c = true`;
|
||||||
|
const res = await this.sf.query(soql);
|
||||||
|
const products = (res.records || []).map((r: any) => ({
|
||||||
|
id: r.Id,
|
||||||
|
name: r.Name,
|
||||||
|
sku: r[skuField],
|
||||||
|
category: r.Portal_Category__c,
|
||||||
|
}));
|
||||||
|
this.logger.log({ count: products.length }, "Catalog loaded from Salesforce Product2");
|
||||||
|
return products;
|
||||||
},
|
},
|
||||||
ttl
|
ttl
|
||||||
);
|
);
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export const envSchema = z.object({
|
|||||||
JWT_SECRET: z.string().min(32, "JWT secret must be at least 32 characters"),
|
JWT_SECRET: z.string().min(32, "JWT secret must be at least 32 characters"),
|
||||||
JWT_EXPIRES_IN: z.string().default("7d"),
|
JWT_EXPIRES_IN: z.string().default("7d"),
|
||||||
BCRYPT_ROUNDS: z.coerce.number().int().min(10).max(16).default(12),
|
BCRYPT_ROUNDS: z.coerce.number().int().min(10).max(16).default(12),
|
||||||
|
APP_BASE_URL: z.string().url().default("http://localhost:3000"),
|
||||||
|
|
||||||
// CORS and Network Security
|
// CORS and Network Security
|
||||||
CORS_ORIGIN: z.string().url().optional(),
|
CORS_ORIGIN: z.string().url().optional(),
|
||||||
@ -40,6 +41,16 @@ export const envSchema = z.object({
|
|||||||
SF_CLIENT_ID: z.string().optional(),
|
SF_CLIENT_ID: z.string().optional(),
|
||||||
SF_PRIVATE_KEY_PATH: z.string().optional(),
|
SF_PRIVATE_KEY_PATH: z.string().optional(),
|
||||||
SF_WEBHOOK_SECRET: z.string().optional(),
|
SF_WEBHOOK_SECRET: z.string().optional(),
|
||||||
|
|
||||||
|
// Email / SendGrid
|
||||||
|
SENDGRID_API_KEY: z.string().optional(),
|
||||||
|
EMAIL_FROM: z.string().email().default("no-reply@example.com"),
|
||||||
|
EMAIL_FROM_NAME: z.string().default("Assist Solutions"),
|
||||||
|
EMAIL_ENABLED: z.enum(["true", "false"]).default("true"),
|
||||||
|
EMAIL_USE_QUEUE: z.enum(["true", "false"]).default("true"),
|
||||||
|
SENDGRID_SANDBOX: z.enum(["true", "false"]).default("false"),
|
||||||
|
EMAIL_TEMPLATE_RESET: z.string().optional(),
|
||||||
|
EMAIL_TEMPLATE_WELCOME: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function validateEnv(config: Record<string, unknown>): Record<string, unknown> {
|
export function validateEnv(config: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
|||||||
15
apps/bff/src/common/email/email.module.ts
Normal file
15
apps/bff/src/common/email/email.module.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ConfigModule } from "@nestjs/config";
|
||||||
|
import { EmailService } from "./email.service";
|
||||||
|
import { SendGridEmailProvider } from "./providers/sendgrid.provider";
|
||||||
|
import { LoggingModule } from "../logging/logging.module";
|
||||||
|
import { BullModule } from "@nestjs/bullmq";
|
||||||
|
import { EmailQueueService } from "./queue/email.queue";
|
||||||
|
import { EmailProcessor } from "./queue/email.processor";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule, LoggingModule, BullModule.registerQueue({ name: "email" })],
|
||||||
|
providers: [EmailService, SendGridEmailProvider, EmailQueueService, EmailProcessor],
|
||||||
|
exports: [EmailService, EmailQueueService],
|
||||||
|
})
|
||||||
|
export class EmailModule {}
|
||||||
41
apps/bff/src/common/email/email.service.ts
Normal file
41
apps/bff/src/common/email/email.service.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { SendGridEmailProvider } from "./providers/sendgrid.provider";
|
||||||
|
import { EmailQueueService, EmailJobData } from "./queue/email.queue";
|
||||||
|
|
||||||
|
export interface SendEmailOptions {
|
||||||
|
to: string | string[];
|
||||||
|
subject: string;
|
||||||
|
text?: string;
|
||||||
|
html?: string;
|
||||||
|
templateId?: string;
|
||||||
|
dynamicTemplateData?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EmailService {
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly provider: SendGridEmailProvider,
|
||||||
|
private readonly queue: EmailQueueService,
|
||||||
|
@Inject(Logger) private readonly logger: Logger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async sendEmail(options: SendEmailOptions): Promise<void> {
|
||||||
|
const enabled = this.config.get("EMAIL_ENABLED", "true") === "true";
|
||||||
|
if (!enabled) {
|
||||||
|
this.logger.log("Email sending disabled; skipping", {
|
||||||
|
to: options.to,
|
||||||
|
subject: options.subject,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const useQueue = this.config.get("EMAIL_USE_QUEUE", "true") === "true";
|
||||||
|
if (useQueue) {
|
||||||
|
await this.queue.enqueueEmail(options as EmailJobData);
|
||||||
|
} else {
|
||||||
|
await this.provider.send(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
apps/bff/src/common/email/providers/sendgrid.provider.ts
Normal file
51
apps/bff/src/common/email/providers/sendgrid.provider.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import sgMail from "@sendgrid/mail";
|
||||||
|
import type { SendEmailOptions } from "../email.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SendGridEmailProvider {
|
||||||
|
private readonly fromEmail: string;
|
||||||
|
private readonly fromName?: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
@Inject(Logger) private readonly logger: Logger
|
||||||
|
) {
|
||||||
|
const apiKey = this.config.get<string>("SENDGRID_API_KEY");
|
||||||
|
if (apiKey) {
|
||||||
|
sgMail.setApiKey(apiKey);
|
||||||
|
}
|
||||||
|
const sandbox = this.config.get("SENDGRID_SANDBOX", "false") === "true";
|
||||||
|
sgMail.setSubstitutionWrappers("{{", "}}");
|
||||||
|
(sgMail as any).setClient({ mailSettings: { sandboxMode: { enable: sandbox } } });
|
||||||
|
this.fromEmail = this.config.get<string>("EMAIL_FROM", "no-reply@example.com");
|
||||||
|
this.fromName = this.config.get<string>("EMAIL_FROM_NAME");
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(options: SendEmailOptions): Promise<void> {
|
||||||
|
const to = Array.isArray(options.to) ? options.to : [options.to];
|
||||||
|
|
||||||
|
const msg: sgMail.MailDataRequired = {
|
||||||
|
to,
|
||||||
|
from: this.fromName ? { email: this.fromEmail, name: this.fromName } : this.fromEmail,
|
||||||
|
subject: options.subject,
|
||||||
|
text: options.text,
|
||||||
|
html: options.html,
|
||||||
|
templateId: options.templateId,
|
||||||
|
dynamicTemplateData: options.dynamicTemplateData,
|
||||||
|
} as sgMail.MailDataRequired;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sgMail.send(msg);
|
||||||
|
this.logger.log("SendGrid email sent", { to, subject: options.subject });
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("SendGrid send failed", {
|
||||||
|
error:
|
||||||
|
error instanceof Error ? { name: error.name, message: error.message } : String(error),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
apps/bff/src/common/email/queue/email.processor.ts
Normal file
21
apps/bff/src/common/email/queue/email.processor.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { Processor, WorkerHost } from "@nestjs/bullmq";
|
||||||
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { EmailService } from "../email.service";
|
||||||
|
import type { EmailJobData } from "./email.queue";
|
||||||
|
|
||||||
|
@Processor("email")
|
||||||
|
@Injectable()
|
||||||
|
export class EmailProcessor extends WorkerHost {
|
||||||
|
constructor(
|
||||||
|
private readonly emailService: EmailService,
|
||||||
|
@Inject(Logger) private readonly logger: Logger
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async process(job: { data: EmailJobData }): Promise<void> {
|
||||||
|
await this.emailService.sendEmail(job.data);
|
||||||
|
this.logger.debug("Processed email job");
|
||||||
|
}
|
||||||
|
}
|
||||||
29
apps/bff/src/common/email/queue/email.queue.ts
Normal file
29
apps/bff/src/common/email/queue/email.queue.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
|
import { InjectQueue } from "@nestjs/bullmq";
|
||||||
|
import { Queue } from "bullmq";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import type { SendEmailOptions } from "../email.service";
|
||||||
|
|
||||||
|
export type EmailJobData = SendEmailOptions & { category?: string };
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EmailQueueService {
|
||||||
|
constructor(
|
||||||
|
@InjectQueue("email") private readonly queue: Queue<EmailJobData>,
|
||||||
|
@Inject(Logger) private readonly logger: Logger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async enqueueEmail(data: EmailJobData): Promise<void> {
|
||||||
|
await this.queue.add("send", data, {
|
||||||
|
removeOnComplete: 50,
|
||||||
|
removeOnFail: 50,
|
||||||
|
attempts: 3,
|
||||||
|
backoff: { type: "exponential", delay: 2000 },
|
||||||
|
});
|
||||||
|
this.logger.debug("Queued email", {
|
||||||
|
to: data.to,
|
||||||
|
subject: data.subject,
|
||||||
|
category: data.category,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -57,7 +57,12 @@ export class LoggingConfig {
|
|||||||
},
|
},
|
||||||
serializers: {
|
serializers: {
|
||||||
// Keep logs concise: omit headers by default
|
// Keep logs concise: omit headers by default
|
||||||
req: (req: { method?: string; url?: string; remoteAddress?: string; remotePort?: number }) => ({
|
req: (req: {
|
||||||
|
method?: string;
|
||||||
|
url?: string;
|
||||||
|
remoteAddress?: string;
|
||||||
|
remotePort?: number;
|
||||||
|
}) => ({
|
||||||
method: req.method,
|
method: req.method,
|
||||||
url: req.url,
|
url: req.url,
|
||||||
remoteAddress: req.remoteAddress,
|
remoteAddress: req.remoteAddress,
|
||||||
@ -66,7 +71,13 @@ export class LoggingConfig {
|
|||||||
res: (res: { statusCode: number }) => ({
|
res: (res: { statusCode: number }) => ({
|
||||||
statusCode: res.statusCode,
|
statusCode: res.statusCode,
|
||||||
}),
|
}),
|
||||||
err: (err: { constructor: { name: string }; message: string; stack?: string; code?: string; status?: number }) => ({
|
err: (err: {
|
||||||
|
constructor: { name: string };
|
||||||
|
message: string;
|
||||||
|
stack?: string;
|
||||||
|
code?: string;
|
||||||
|
status?: number;
|
||||||
|
}) => ({
|
||||||
type: err.constructor.name,
|
type: err.constructor.name,
|
||||||
message: err.message,
|
message: err.message,
|
||||||
stack: err.stack,
|
stack: err.stack,
|
||||||
@ -128,7 +139,9 @@ export class LoggingConfig {
|
|||||||
// Auto-generate correlation IDs
|
// Auto-generate correlation IDs
|
||||||
genReqId: (req: IncomingMessage, res: ServerResponse) => {
|
genReqId: (req: IncomingMessage, res: ServerResponse) => {
|
||||||
const existingIdHeader = req.headers["x-correlation-id"];
|
const existingIdHeader = req.headers["x-correlation-id"];
|
||||||
const existingId = Array.isArray(existingIdHeader) ? existingIdHeader[0] : existingIdHeader;
|
const existingId = Array.isArray(existingIdHeader)
|
||||||
|
? existingIdHeader[0]
|
||||||
|
: existingIdHeader;
|
||||||
if (existingId) return existingId;
|
if (existingId) return existingId;
|
||||||
|
|
||||||
const correlationId = LoggingConfig.generateCorrelationId();
|
const correlationId = LoggingConfig.generateCorrelationId();
|
||||||
@ -143,7 +156,11 @@ export class LoggingConfig {
|
|||||||
},
|
},
|
||||||
// Suppress success messages entirely
|
// Suppress success messages entirely
|
||||||
customSuccessMessage: () => "",
|
customSuccessMessage: () => "",
|
||||||
customErrorMessage: (req: IncomingMessage, res: ServerResponse, err: { message?: string }) => {
|
customErrorMessage: (
|
||||||
|
req: IncomingMessage,
|
||||||
|
res: ServerResponse,
|
||||||
|
err: { message?: string }
|
||||||
|
) => {
|
||||||
const method = req.method ?? "";
|
const method = req.method ?? "";
|
||||||
const url = req.url ?? "";
|
const url = req.url ?? "";
|
||||||
return `${method} ${url} ${res.statusCode} - ${err.message ?? "error"}`;
|
return `${method} ${url} ${res.statusCode} - ${err.message ?? "error"}`;
|
||||||
|
|||||||
@ -159,7 +159,8 @@ export function createDeferredPromise<T>(): {
|
|||||||
// Use native Promise.withResolvers if available (ES2024)
|
// Use native Promise.withResolvers if available (ES2024)
|
||||||
if (
|
if (
|
||||||
"withResolvers" in Promise &&
|
"withResolvers" in Promise &&
|
||||||
typeof (Promise as unknown as { withResolvers?: <_U>() => unknown }).withResolvers === "function"
|
typeof (Promise as unknown as { withResolvers?: <_U>() => unknown }).withResolvers ===
|
||||||
|
"function"
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
Promise as unknown as {
|
Promise as unknown as {
|
||||||
|
|||||||
@ -2,11 +2,18 @@ import { Controller, Get } from "@nestjs/common";
|
|||||||
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||||
import { PrismaService } from "../common/prisma/prisma.service";
|
import { PrismaService } from "../common/prisma/prisma.service";
|
||||||
import { getErrorMessage } from "../common/utils/error.util";
|
import { getErrorMessage } from "../common/utils/error.util";
|
||||||
|
import { InjectQueue } from "@nestjs/bullmq";
|
||||||
|
import { Queue } from "bullmq";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
|
||||||
@ApiTags("Health")
|
@ApiTags("Health")
|
||||||
@Controller("health")
|
@Controller("health")
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
@InjectQueue("email") private readonly emailQueue: Queue
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: "Health check endpoint" })
|
@ApiOperation({ summary: "Health check endpoint" })
|
||||||
@ -29,6 +36,13 @@ export class HealthController {
|
|||||||
// Test database connection
|
// Test database connection
|
||||||
await this.prisma.$queryRaw`SELECT 1`;
|
await this.prisma.$queryRaw`SELECT 1`;
|
||||||
|
|
||||||
|
const emailQueueInfo = await this.emailQueue.getJobCounts(
|
||||||
|
"waiting",
|
||||||
|
"active",
|
||||||
|
"failed",
|
||||||
|
"delayed"
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: "ok",
|
status: "ok",
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@ -36,6 +50,14 @@ export class HealthController {
|
|||||||
database: "connected",
|
database: "connected",
|
||||||
environment: process.env.NODE_ENV || "development",
|
environment: process.env.NODE_ENV || "development",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
|
queues: {
|
||||||
|
email: emailQueueInfo,
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
emailEnabled: this.config.get("EMAIL_ENABLED", "true") === "true",
|
||||||
|
emailQueued: this.config.get("EMAIL_USE_QUEUE", "true") === "true",
|
||||||
|
sendgridSandbox: this.config.get("SENDGRID_SANDBOX", "false") === "true",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { HealthController } from "./health.controller";
|
import { HealthController } from "./health.controller";
|
||||||
import { PrismaModule } from "../common/prisma/prisma.module";
|
import { PrismaModule } from "../common/prisma/prisma.module";
|
||||||
|
import { BullModule } from "@nestjs/bullmq";
|
||||||
|
import { ConfigModule } from "@nestjs/config";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [PrismaModule, ConfigModule, BullModule.registerQueue({ name: "email" })],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
})
|
})
|
||||||
export class HealthModule {}
|
export class HealthModule {}
|
||||||
|
|||||||
@ -55,7 +55,7 @@ export interface BulkMappingResult {
|
|||||||
errors: Array<{
|
errors: Array<{
|
||||||
index: number;
|
index: number;
|
||||||
error: string;
|
error: string;
|
||||||
data: any;
|
data: unknown;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -142,10 +142,10 @@ export class MappingValidatorService {
|
|||||||
/**
|
/**
|
||||||
* Check for potential conflicts
|
* Check for potential conflicts
|
||||||
*/
|
*/
|
||||||
async validateNoConflicts(
|
validateNoConflicts(
|
||||||
request: CreateMappingRequest,
|
request: CreateMappingRequest,
|
||||||
existingMappings: UserIdMapping[]
|
existingMappings: UserIdMapping[]
|
||||||
): Promise<MappingValidationResult> {
|
): MappingValidationResult {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,43 @@
|
|||||||
import { Controller } from "@nestjs/common";
|
import { Body, Controller, Get, Param, Post, UseGuards, Request } from "@nestjs/common";
|
||||||
import { OrdersService } from "./orders.service";
|
import { OrdersService } from "./orders.service";
|
||||||
import { ApiTags } from "@nestjs/swagger";
|
import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||||
|
import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard";
|
||||||
|
import { RequestWithUser } from "../auth/auth.types";
|
||||||
|
|
||||||
|
interface CreateOrderBody {
|
||||||
|
orderType: "Internet" | "eSIM" | "SIM" | "VPN" | "Other";
|
||||||
|
selections: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
@ApiTags("orders")
|
@ApiTags("orders")
|
||||||
@Controller("orders")
|
@Controller("orders")
|
||||||
export class OrdersController {
|
export class OrdersController {
|
||||||
constructor(private ordersService: OrdersService) {}
|
constructor(private ordersService: OrdersService) {}
|
||||||
|
|
||||||
// TODO: Implement order endpoints
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: "Create Salesforce Order (one service per order)" })
|
||||||
|
@ApiResponse({ status: 201 })
|
||||||
|
async create(@Request() req: RequestWithUser, @Body() body: CreateOrderBody) {
|
||||||
|
return this.ordersService.create(req.user.userId, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Get(":sfOrderId")
|
||||||
|
@ApiOperation({ summary: "Get order summary/status" })
|
||||||
|
@ApiParam({ name: "sfOrderId", type: String })
|
||||||
|
async get(@Request() req: RequestWithUser, @Param("sfOrderId") sfOrderId: string) {
|
||||||
|
return this.ordersService.get(req.user.userId, sfOrderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Post(":sfOrderId/provision")
|
||||||
|
@ApiOperation({ summary: "Trigger provisioning for an approved order" })
|
||||||
|
@ApiParam({ name: "sfOrderId", type: String })
|
||||||
|
async provision(@Request() req: RequestWithUser, @Param("sfOrderId") sfOrderId: string) {
|
||||||
|
return this.ordersService.provision(req.user.userId, sfOrderId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,339 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { BadRequestException, Injectable, NotFoundException, Inject } from "@nestjs/common";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { SalesforceConnection } from "../vendors/salesforce/services/salesforce-connection.service";
|
||||||
|
import { MappingsService } from "../mappings/mappings.service";
|
||||||
|
import { getErrorMessage } from "../common/utils/error.util";
|
||||||
|
import { WhmcsConnectionService } from "../vendors/whmcs/services/whmcs-connection.service";
|
||||||
|
|
||||||
|
interface CreateOrderBody {
|
||||||
|
orderType: "Internet" | "eSIM" | "SIM" | "VPN" | "Other";
|
||||||
|
selections: Record<string, any>;
|
||||||
|
opportunityId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrdersService {
|
export class OrdersService {
|
||||||
// TODO: Implement order business logic
|
constructor(
|
||||||
|
@Inject(Logger) private readonly logger: Logger,
|
||||||
|
private readonly sf: SalesforceConnection,
|
||||||
|
private readonly mappings: MappingsService,
|
||||||
|
private readonly whmcs: WhmcsConnectionService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(userId: string, body: CreateOrderBody) {
|
||||||
|
this.logger.log({ userId, orderType: body.orderType }, "Creating order request received");
|
||||||
|
|
||||||
|
// 1) Validate mapping
|
||||||
|
const mapping = await this.mappings.findByUserId(userId);
|
||||||
|
if (!mapping?.sfAccountId || !mapping?.whmcsClientId) {
|
||||||
|
this.logger.warn({ userId, mapping }, "Missing SF/WHMCS mapping for user");
|
||||||
|
throw new BadRequestException("User is not fully linked to Salesforce/WHMCS");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Guards: ensure payment method exists and single Internet per account (if Internet)
|
||||||
|
try {
|
||||||
|
// Check client has at least one payment method (best-effort; will be enforced again at provision time)
|
||||||
|
const pay = await this.whmcs.getPayMethods({ clientid: mapping.whmcsClientId });
|
||||||
|
if (
|
||||||
|
!pay?.paymethods ||
|
||||||
|
!Array.isArray(pay.paymethods.paymethod) ||
|
||||||
|
pay.paymethods.paymethod.length === 0
|
||||||
|
) {
|
||||||
|
this.logger.warn({ userId }, "No WHMCS payment method on file");
|
||||||
|
throw new BadRequestException("A payment method is required before ordering");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(
|
||||||
|
{ err: getErrorMessage(e) },
|
||||||
|
"Payment method check soft-failed; proceeding cautiously"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.orderType === "Internet") {
|
||||||
|
try {
|
||||||
|
const products = await this.whmcs.getClientsProducts({ clientid: mapping.whmcsClientId });
|
||||||
|
const existing = products?.products?.product || [];
|
||||||
|
const hasInternet = existing.some((p: any) =>
|
||||||
|
String(p.groupname || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.includes("internet")
|
||||||
|
);
|
||||||
|
if (hasInternet) {
|
||||||
|
throw new BadRequestException("An Internet service already exists for this account");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn({ err: getErrorMessage(e) }, "Internet duplicate check soft-failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Determine Portal pricebook
|
||||||
|
const pricebook = await this.findPortalPricebookId();
|
||||||
|
if (!pricebook) {
|
||||||
|
throw new NotFoundException("Portal pricebook not found or inactive");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Build Order fields from selections (header)
|
||||||
|
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||||||
|
const orderFields: any = {
|
||||||
|
AccountId: mapping.sfAccountId,
|
||||||
|
EffectiveDate: today,
|
||||||
|
Status: "Pending Review",
|
||||||
|
Pricebook2Id: pricebook,
|
||||||
|
Order_Type__c: body.orderType,
|
||||||
|
...(body.opportunityId ? { OpportunityId: body.opportunityId } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Activation
|
||||||
|
if (body.selections.activationType)
|
||||||
|
orderFields.Activation_Type__c = body.selections.activationType;
|
||||||
|
if (body.selections.scheduledAt)
|
||||||
|
orderFields.Activation_Scheduled_At__c = body.selections.scheduledAt;
|
||||||
|
orderFields.Activation_Status__c = "Not Started";
|
||||||
|
|
||||||
|
// Internet config
|
||||||
|
if (body.orderType === "Internet") {
|
||||||
|
if (body.selections.tier) orderFields.Internet_Plan_Tier__c = body.selections.tier;
|
||||||
|
if (body.selections.mode) orderFields.Access_Mode__c = body.selections.mode;
|
||||||
|
if (body.selections.speed) orderFields.Service_Speed__c = body.selections.speed;
|
||||||
|
if (body.selections.install) orderFields.Installment_Plan__c = body.selections.install;
|
||||||
|
if (typeof body.selections.weekend !== "undefined")
|
||||||
|
orderFields.Weekend_Install__c =
|
||||||
|
body.selections.weekend === "true" || body.selections.weekend === true;
|
||||||
|
if (body.selections.install === "12-Month") orderFields.Installment_Months__c = 12;
|
||||||
|
if (body.selections.install === "24-Month") orderFields.Installment_Months__c = 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SIM/eSIM config
|
||||||
|
if (body.orderType === "eSIM" || body.orderType === "SIM") {
|
||||||
|
if (body.selections.simType)
|
||||||
|
orderFields.SIM_Type__c = body.selections.simType === "eSIM" ? "eSIM" : "Physical SIM";
|
||||||
|
if (body.selections.eid) orderFields.EID__c = body.selections.eid;
|
||||||
|
if (body.selections.isMnp === "true" || body.selections.isMnp === true) {
|
||||||
|
orderFields.MNP_Application__c = true;
|
||||||
|
if (body.selections.mnpNumber)
|
||||||
|
orderFields.MNP_Reservation_Number__c = body.selections.mnpNumber;
|
||||||
|
if (body.selections.mnpExpiry) orderFields.MNP_Expiry_Date__c = body.selections.mnpExpiry;
|
||||||
|
if (body.selections.mnpPhone) orderFields.MNP_Phone_Number__c = body.selections.mnpPhone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Create Order in Salesforce
|
||||||
|
try {
|
||||||
|
const created = await this.sf.sobject("Order").create(orderFields);
|
||||||
|
if (!created?.id) {
|
||||||
|
throw new Error("Salesforce did not return Order Id");
|
||||||
|
}
|
||||||
|
this.logger.log({ orderId: created.id }, "Salesforce Order created");
|
||||||
|
|
||||||
|
// 6) Create OrderItems from header configuration
|
||||||
|
await this.createOrderItems(created.id, body);
|
||||||
|
|
||||||
|
return { sfOrderId: created.id, status: "Pending Review" };
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
{ err: getErrorMessage(error), orderFields },
|
||||||
|
"Failed to create Salesforce Order"
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(userId: string, sfOrderId: string) {
|
||||||
|
try {
|
||||||
|
const soql = `SELECT Id, Status, Activation_Status__c, Activation_Type__c, Activation_Scheduled_At__c, WHMCS_Order_ID__c FROM Order WHERE Id='${sfOrderId}'`;
|
||||||
|
const res = await this.sf.query(soql);
|
||||||
|
if (!res.records?.length) throw new NotFoundException("Order not found");
|
||||||
|
const o = res.records[0];
|
||||||
|
return {
|
||||||
|
sfOrderId: o.Id,
|
||||||
|
status: o.Status,
|
||||||
|
activationStatus: o.Activation_Status__c,
|
||||||
|
activationType: o.Activation_Type__c,
|
||||||
|
scheduledAt: o.Activation_Scheduled_At__c,
|
||||||
|
whmcsOrderId: o.WHMCS_Order_ID__c,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
{ err: getErrorMessage(error), sfOrderId },
|
||||||
|
"Failed to fetch order summary"
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async provision(userId: string, sfOrderId: string) {
|
||||||
|
this.logger.log({ userId, sfOrderId }, "Provision request received");
|
||||||
|
// 1) Fetch Order details from Salesforce
|
||||||
|
const soql = `SELECT Id, Status, AccountId, Activation_Type__c, Activation_Scheduled_At__c, Order_Type__c, WHMCS_Order_ID__c FROM Order WHERE Id='${sfOrderId}'`;
|
||||||
|
const res = await this.sf.query(soql);
|
||||||
|
if (!res.records?.length) throw new NotFoundException("Order not found");
|
||||||
|
const order = res.records[0];
|
||||||
|
|
||||||
|
// 2) Validate allowed state
|
||||||
|
if (
|
||||||
|
order.Status !== "Activated" &&
|
||||||
|
order.Status !== "Accepted" &&
|
||||||
|
order.Status !== "Pending Review"
|
||||||
|
) {
|
||||||
|
throw new BadRequestException("Order is not in a provisionable state");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Log and return a placeholder; actual WHMCS AddOrder/AcceptOrder will be wired by Flow trigger
|
||||||
|
this.logger.log(
|
||||||
|
{ sfOrderId, orderType: order.Order_Type__c },
|
||||||
|
"Provisioning not yet implemented; placeholder success"
|
||||||
|
);
|
||||||
|
return { sfOrderId, status: "Accepted", message: "Provisioning queued" };
|
||||||
|
}
|
||||||
|
private async findPortalPricebookId(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const name = process.env.PORTAL_PRICEBOOK_NAME || "Portal";
|
||||||
|
const soql = `SELECT Id, Name FROM Pricebook2 WHERE IsActive = true AND Name LIKE '%${name}%' LIMIT 1`;
|
||||||
|
const result = await this.sf.query(soql);
|
||||||
|
if (result.records?.length) return result.records[0].Id;
|
||||||
|
// fallback to Standard Price Book
|
||||||
|
const std = await this.sf.query(
|
||||||
|
"SELECT Id FROM Pricebook2 WHERE IsStandard = true AND IsActive = true LIMIT 1"
|
||||||
|
);
|
||||||
|
return std.records?.[0]?.Id || null;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error({ err: getErrorMessage(error) }, "Failed to find pricebook");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findPricebookEntryId(
|
||||||
|
pricebookId: string,
|
||||||
|
product2NameLike: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const soql = `SELECT Id FROM PricebookEntry WHERE Pricebook2Id='${pricebookId}' AND IsActive=true AND Product2.Name LIKE '%${product2NameLike.replace("'", "")}%' LIMIT 1`;
|
||||||
|
const res = await this.sf.query(soql);
|
||||||
|
return res.records?.[0]?.Id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findPricebookEntryBySku(pricebookId: string, sku: string): Promise<string | null> {
|
||||||
|
if (!sku) return null;
|
||||||
|
const skuField = process.env.PRODUCT_SKU_FIELD || "SKU__c";
|
||||||
|
const safeSku = sku.replace(/'/g, "\\'");
|
||||||
|
const soql = `SELECT Id FROM PricebookEntry WHERE Pricebook2Id='${pricebookId}' AND IsActive=true AND Product2.${skuField} = '${safeSku}' LIMIT 1`;
|
||||||
|
const res = await this.sf.query(soql);
|
||||||
|
return res.records?.[0]?.Id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createOpportunity(
|
||||||
|
accountId: string,
|
||||||
|
body: CreateOrderBody
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const name = `${body.orderType} Service ${now.toISOString().slice(0, 10)}`;
|
||||||
|
const opp = await this.sf.sobject("Opportunity").create({
|
||||||
|
Name: name,
|
||||||
|
AccountId: accountId,
|
||||||
|
StageName: "Qualification",
|
||||||
|
CloseDate: now.toISOString().slice(0, 10),
|
||||||
|
Description: `Created from portal for ${body.orderType}`,
|
||||||
|
});
|
||||||
|
return opp?.id || null;
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error({ err: getErrorMessage(e) }, "Failed to create Opportunity");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createOrderItems(orderId: string, body: CreateOrderBody): Promise<void> {
|
||||||
|
// Minimal SKU resolution using Product2.Name LIKE; in production, prefer Product2 external codes
|
||||||
|
const pricebookId = await this.findPortalPricebookId();
|
||||||
|
if (!pricebookId) return;
|
||||||
|
|
||||||
|
const items: Array<{
|
||||||
|
itemType: string;
|
||||||
|
productHint?: string;
|
||||||
|
sku?: string;
|
||||||
|
billingCycle: string;
|
||||||
|
quantity: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
if (body.orderType === "Internet") {
|
||||||
|
// Service line
|
||||||
|
const svcHint = `Internet ${body.selections.tier || ""} ${body.selections.mode || ""}`.trim();
|
||||||
|
items.push({
|
||||||
|
itemType: "Service",
|
||||||
|
productHint: svcHint,
|
||||||
|
sku: body.selections.skuService,
|
||||||
|
billingCycle: "monthly",
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
// Installation line
|
||||||
|
const install = body.selections.install as string;
|
||||||
|
if (install === "One-time") {
|
||||||
|
items.push({
|
||||||
|
itemType: "Installation",
|
||||||
|
productHint: "Installation Fee (Single)",
|
||||||
|
sku: body.selections.skuInstall,
|
||||||
|
billingCycle: "onetime",
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
} else if (install === "12-Month") {
|
||||||
|
items.push({
|
||||||
|
itemType: "Installation",
|
||||||
|
productHint: "Installation Fee (12-Month)",
|
||||||
|
sku: body.selections.skuInstall,
|
||||||
|
billingCycle: "monthly",
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
} else if (install === "24-Month") {
|
||||||
|
items.push({
|
||||||
|
itemType: "Installation",
|
||||||
|
productHint: "Installation Fee (24-Month)",
|
||||||
|
sku: body.selections.skuInstall,
|
||||||
|
billingCycle: "monthly",
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (body.orderType === "eSIM" || body.orderType === "SIM") {
|
||||||
|
items.push({
|
||||||
|
itemType: "Service",
|
||||||
|
productHint: `${body.orderType} Plan`,
|
||||||
|
sku: body.selections.skuService,
|
||||||
|
billingCycle: "monthly",
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
} else if (body.orderType === "VPN") {
|
||||||
|
items.push({
|
||||||
|
itemType: "Service",
|
||||||
|
productHint: `VPN ${body.selections.region || ""}`,
|
||||||
|
sku: body.selections.skuService,
|
||||||
|
billingCycle: "monthly",
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
itemType: "Installation",
|
||||||
|
productHint: "VPN Activation Fee",
|
||||||
|
sku: body.selections.skuInstall,
|
||||||
|
billingCycle: "onetime",
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const it of items) {
|
||||||
|
if (!it.sku) {
|
||||||
|
this.logger.warn({ itemType: it.itemType }, "Missing SKU for order item");
|
||||||
|
throw new BadRequestException("Missing SKU for order item");
|
||||||
|
}
|
||||||
|
const pbe = await this.findPricebookEntryBySku(pricebookId, it.sku);
|
||||||
|
if (!pbe) {
|
||||||
|
this.logger.error({ sku: it.sku }, "PricebookEntry not found for SKU");
|
||||||
|
throw new NotFoundException(`PricebookEntry not found for SKU ${it.sku}`);
|
||||||
|
}
|
||||||
|
await this.sf.sobject("OrderItem").create({
|
||||||
|
OrderId: orderId,
|
||||||
|
PricebookEntryId: pbe,
|
||||||
|
Quantity: it.quantity,
|
||||||
|
UnitPrice: null, // Salesforce will use the PBE price; null keeps pricebook price
|
||||||
|
Billing_Cycle__c: it.billingCycle.toLowerCase() === "onetime" ? "Onetime" : "Monthly",
|
||||||
|
Item_Type__c: it.itemType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
import { SubscriptionsService } from "./subscriptions.service";
|
import { SubscriptionsService } from "./subscriptions.service";
|
||||||
import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard";
|
import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard";
|
||||||
import { Subscription, SubscriptionList, InvoiceList } from "@customer-portal/shared";
|
import { Subscription, SubscriptionList, InvoiceList } from "@customer-portal/shared";
|
||||||
|
import { RequestWithUser } from "../auth/auth.types";
|
||||||
|
|
||||||
@ApiTags("subscriptions")
|
@ApiTags("subscriptions")
|
||||||
@Controller("subscriptions")
|
@Controller("subscriptions")
|
||||||
@ -44,7 +45,7 @@ export class SubscriptionsController {
|
|||||||
type: Object, // Would be SubscriptionList if we had proper DTO decorators
|
type: Object, // Would be SubscriptionList if we had proper DTO decorators
|
||||||
})
|
})
|
||||||
async getSubscriptions(
|
async getSubscriptions(
|
||||||
@Request() req: any,
|
@Request() req: RequestWithUser,
|
||||||
@Query("status") status?: string
|
@Query("status") status?: string
|
||||||
): Promise<SubscriptionList | Subscription[]> {
|
): Promise<SubscriptionList | Subscription[]> {
|
||||||
// Validate status if provided
|
// Validate status if provided
|
||||||
@ -54,13 +55,13 @@ export class SubscriptionsController {
|
|||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
const subscriptions = await this.subscriptionsService.getSubscriptionsByStatus(
|
const subscriptions = await this.subscriptionsService.getSubscriptionsByStatus(
|
||||||
req.user.id,
|
req.user.userId,
|
||||||
status
|
status
|
||||||
);
|
);
|
||||||
return subscriptions;
|
return subscriptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.subscriptionsService.getSubscriptions(req.user.id);
|
return this.subscriptionsService.getSubscriptions(req.user.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("active")
|
@Get("active")
|
||||||
@ -73,8 +74,8 @@ export class SubscriptionsController {
|
|||||||
description: "List of active subscriptions",
|
description: "List of active subscriptions",
|
||||||
type: [Object], // Would be Subscription[] if we had proper DTO decorators
|
type: [Object], // Would be Subscription[] if we had proper DTO decorators
|
||||||
})
|
})
|
||||||
async getActiveSubscriptions(@Request() req: any): Promise<Subscription[]> {
|
async getActiveSubscriptions(@Request() req: RequestWithUser): Promise<Subscription[]> {
|
||||||
return this.subscriptionsService.getActiveSubscriptions(req.user.id);
|
return this.subscriptionsService.getActiveSubscriptions(req.user.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("stats")
|
@Get("stats")
|
||||||
@ -87,14 +88,14 @@ export class SubscriptionsController {
|
|||||||
description: "Subscription statistics",
|
description: "Subscription statistics",
|
||||||
type: Object,
|
type: Object,
|
||||||
})
|
})
|
||||||
async getSubscriptionStats(@Request() req: any): Promise<{
|
async getSubscriptionStats(@Request() req: RequestWithUser): Promise<{
|
||||||
total: number;
|
total: number;
|
||||||
active: number;
|
active: number;
|
||||||
suspended: number;
|
suspended: number;
|
||||||
cancelled: number;
|
cancelled: number;
|
||||||
pending: number;
|
pending: number;
|
||||||
}> {
|
}> {
|
||||||
return this.subscriptionsService.getSubscriptionStats(req.user.id);
|
return this.subscriptionsService.getSubscriptionStats(req.user.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":id")
|
@Get(":id")
|
||||||
@ -110,14 +111,14 @@ export class SubscriptionsController {
|
|||||||
})
|
})
|
||||||
@ApiResponse({ status: 404, description: "Subscription not found" })
|
@ApiResponse({ status: 404, description: "Subscription not found" })
|
||||||
async getSubscriptionById(
|
async getSubscriptionById(
|
||||||
@Request() req: any,
|
@Request() req: RequestWithUser,
|
||||||
@Param("id", ParseIntPipe) subscriptionId: number
|
@Param("id", ParseIntPipe) subscriptionId: number
|
||||||
): Promise<Subscription> {
|
): Promise<Subscription> {
|
||||||
if (subscriptionId <= 0) {
|
if (subscriptionId <= 0) {
|
||||||
throw new BadRequestException("Subscription ID must be a positive number");
|
throw new BadRequestException("Subscription ID must be a positive number");
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.subscriptionsService.getSubscriptionById(req.user.id, subscriptionId);
|
return this.subscriptionsService.getSubscriptionById(req.user.userId, subscriptionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(":id/invoices")
|
@Get(":id/invoices")
|
||||||
@ -145,7 +146,7 @@ export class SubscriptionsController {
|
|||||||
})
|
})
|
||||||
@ApiResponse({ status: 404, description: "Subscription not found" })
|
@ApiResponse({ status: 404, description: "Subscription not found" })
|
||||||
async getSubscriptionInvoices(
|
async getSubscriptionInvoices(
|
||||||
@Request() req: any,
|
@Request() req: RequestWithUser,
|
||||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||||
@Query("page") page?: string,
|
@Query("page") page?: string,
|
||||||
@Query("limit") limit?: string
|
@Query("limit") limit?: string
|
||||||
@ -163,7 +164,7 @@ export class SubscriptionsController {
|
|||||||
throw new BadRequestException("Limit cannot exceed 100 items per page");
|
throw new BadRequestException("Limit cannot exceed 100 items per page");
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.subscriptionsService.getSubscriptionInvoices(req.user.id, subscriptionId, {
|
return this.subscriptionsService.getSubscriptionInvoices(req.user.userId, subscriptionId, {
|
||||||
page: pageNum,
|
page: pageNum,
|
||||||
limit: limitNum,
|
limit: limitNum,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard";
|
|||||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from "@nestjs/swagger";
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from "@nestjs/swagger";
|
||||||
import { UpdateUserDto } from "./dto/update-user.dto";
|
import { UpdateUserDto } from "./dto/update-user.dto";
|
||||||
import { UpdateBillingDto } from "./dto/update-billing.dto";
|
import { UpdateBillingDto } from "./dto/update-billing.dto";
|
||||||
|
import { RequestWithUser } from "../auth/auth.types";
|
||||||
|
|
||||||
@ApiTags("users")
|
@ApiTags("users")
|
||||||
@Controller("me")
|
@Controller("me")
|
||||||
@ -26,16 +27,16 @@ export class UsersController {
|
|||||||
@ApiOperation({ summary: "Get current user profile" })
|
@ApiOperation({ summary: "Get current user profile" })
|
||||||
@ApiResponse({ status: 200, description: "User profile retrieved successfully" })
|
@ApiResponse({ status: 200, description: "User profile retrieved successfully" })
|
||||||
@ApiResponse({ status: 401, description: "Unauthorized" })
|
@ApiResponse({ status: 401, description: "Unauthorized" })
|
||||||
async getProfile(@Req() req: any) {
|
async getProfile(@Req() req: RequestWithUser) {
|
||||||
return this.usersService.findById(req.user.id);
|
return this.usersService.findById(req.user.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("summary")
|
@Get("summary")
|
||||||
@ApiOperation({ summary: "Get user dashboard summary" })
|
@ApiOperation({ summary: "Get user dashboard summary" })
|
||||||
@ApiResponse({ status: 200, description: "User summary retrieved successfully" })
|
@ApiResponse({ status: 200, description: "User summary retrieved successfully" })
|
||||||
@ApiResponse({ status: 401, description: "Unauthorized" })
|
@ApiResponse({ status: 401, description: "Unauthorized" })
|
||||||
async getSummary(@Req() req: any) {
|
async getSummary(@Req() req: RequestWithUser) {
|
||||||
return this.usersService.getUserSummary(req.user.id);
|
return this.usersService.getUserSummary(req.user.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch()
|
@Patch()
|
||||||
@ -43,8 +44,8 @@ export class UsersController {
|
|||||||
@ApiResponse({ status: 200, description: "Profile updated successfully" })
|
@ApiResponse({ status: 200, description: "Profile updated successfully" })
|
||||||
@ApiResponse({ status: 400, description: "Invalid input data" })
|
@ApiResponse({ status: 400, description: "Invalid input data" })
|
||||||
@ApiResponse({ status: 401, description: "Unauthorized" })
|
@ApiResponse({ status: 401, description: "Unauthorized" })
|
||||||
async updateProfile(@Req() req: any, @Body() updateData: UpdateUserDto) {
|
async updateProfile(@Req() req: RequestWithUser, @Body() updateData: UpdateUserDto) {
|
||||||
return this.usersService.update(req.user.id, updateData);
|
return this.usersService.update(req.user.userId, updateData);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch("billing")
|
@Patch("billing")
|
||||||
@ -52,7 +53,7 @@ export class UsersController {
|
|||||||
@ApiResponse({ status: 200, description: "Billing information updated successfully" })
|
@ApiResponse({ status: 200, description: "Billing information updated successfully" })
|
||||||
@ApiResponse({ status: 400, description: "Invalid input data" })
|
@ApiResponse({ status: 400, description: "Invalid input data" })
|
||||||
@ApiResponse({ status: 401, description: "Unauthorized" })
|
@ApiResponse({ status: 401, description: "Unauthorized" })
|
||||||
async updateBilling(@Req() _req: any, @Body() _billingData: UpdateBillingDto) {
|
async updateBilling(@Req() _req: RequestWithUser, @Body() _billingData: UpdateBillingDto) {
|
||||||
// TODO: Sync to WHMCS custom fields
|
// TODO: Sync to WHMCS custom fields
|
||||||
throw new Error("Not implemented");
|
throw new Error("Not implemented");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -96,7 +96,7 @@ export class SalesforceService implements OnModuleInit {
|
|||||||
|
|
||||||
// === HEALTH CHECK ===
|
// === HEALTH CHECK ===
|
||||||
|
|
||||||
async healthCheck(): Promise<boolean> {
|
healthCheck(): boolean {
|
||||||
try {
|
try {
|
||||||
return this.connection.isConnected();
|
return this.connection.isConnected();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -387,10 +387,10 @@ export class WhmcsCacheService {
|
|||||||
/**
|
/**
|
||||||
* Get cache statistics
|
* Get cache statistics
|
||||||
*/
|
*/
|
||||||
async getCacheStats(): Promise<{
|
getCacheStats(): {
|
||||||
totalKeys: number;
|
totalKeys: number;
|
||||||
keysByType: Record<string, number>;
|
keysByType: Record<string, number>;
|
||||||
}> {
|
} {
|
||||||
// This would require Redis SCAN or similar functionality
|
// This would require Redis SCAN or similar functionality
|
||||||
// For now, return a placeholder
|
// For now, return a placeholder
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -28,19 +28,12 @@ export class WhmcsPaymentService {
|
|||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: GetPayMethods API might not exist in WHMCS
|
const response = await this.connectionService.getPayMethods({ clientid: clientId });
|
||||||
// For now, return empty list until we verify the correct API
|
const methods = (response.paymethods?.paymethod || []).map(pm =>
|
||||||
this.logger.warn(`GetPayMethods API not yet implemented for client ${clientId}`);
|
this.dataTransformer.transformPaymentMethod(pm)
|
||||||
|
);
|
||||||
const result: PaymentMethodList = {
|
const result: PaymentMethodList = { paymentMethods: methods, totalCount: methods.length };
|
||||||
paymentMethods: [],
|
|
||||||
totalCount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cache the empty result for now
|
|
||||||
await this.cacheService.setPaymentMethods(userId, result);
|
await this.cacheService.setPaymentMethods(userId, result);
|
||||||
|
|
||||||
this.logger.log(`Payment methods feature temporarily disabled for client ${clientId}`);
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to fetch payment methods for client ${clientId}`, {
|
this.logger.error(`Failed to fetch payment methods for client ${clientId}`, {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
WhmcsProduct,
|
WhmcsProduct,
|
||||||
WhmcsCustomFields,
|
WhmcsCustomFields,
|
||||||
WhmcsInvoiceItems,
|
WhmcsInvoiceItems,
|
||||||
|
WhmcsPaymentMethod,
|
||||||
WhmcsPaymentGateway,
|
WhmcsPaymentGateway,
|
||||||
} from "../types/whmcs-api.types";
|
} from "../types/whmcs-api.types";
|
||||||
|
|
||||||
@ -399,6 +400,46 @@ export class WhmcsDataTransformer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform WHMCS payment method to shared PaymentMethod interface
|
||||||
|
*/
|
||||||
|
transformPaymentMethod(whmcsPayMethod: WhmcsPaymentMethod): PaymentMethod {
|
||||||
|
try {
|
||||||
|
const transformed: PaymentMethod = {
|
||||||
|
id: whmcsPayMethod.id,
|
||||||
|
type: whmcsPayMethod.type,
|
||||||
|
description: whmcsPayMethod.description,
|
||||||
|
gatewayName: whmcsPayMethod.gateway_name,
|
||||||
|
lastFour: whmcsPayMethod.last_four,
|
||||||
|
expiryDate: whmcsPayMethod.expiry_date,
|
||||||
|
bankName: whmcsPayMethod.bank_name,
|
||||||
|
accountType: whmcsPayMethod.account_type,
|
||||||
|
remoteToken: whmcsPayMethod.remote_token,
|
||||||
|
ccType: whmcsPayMethod.cc_type,
|
||||||
|
cardBrand: whmcsPayMethod.cc_type,
|
||||||
|
billingContactId: whmcsPayMethod.billing_contact_id,
|
||||||
|
createdAt: whmcsPayMethod.created_at,
|
||||||
|
updatedAt: whmcsPayMethod.updated_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optional validation hook
|
||||||
|
if (!this.validatePaymentMethod(transformed)) {
|
||||||
|
this.logger.warn("Transformed payment method failed validation", {
|
||||||
|
id: transformed.id,
|
||||||
|
type: transformed.type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return transformed;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Failed to transform payment method", {
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
whmcsData: this.sanitizeForLog(whmcsPayMethod),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate payment method transformation result
|
* Validate payment method transformation result
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from "@nestjs/common";
|
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
import * as crypto from "crypto";
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WebhookSignatureGuard implements CanActivate {
|
export class WebhookSignatureGuard implements CanActivate {
|
||||||
@ -9,27 +9,30 @@ export class WebhookSignatureGuard implements CanActivate {
|
|||||||
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
canActivate(context: ExecutionContext): boolean {
|
||||||
const request = context.switchToHttp().getRequest<Request>();
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
const signature = request.headers["x-whmcs-signature"] || request.headers["x-sf-signature"];
|
const signatureHeader =
|
||||||
|
(request.headers["x-whmcs-signature"] as string | undefined) ||
|
||||||
|
(request.headers["x-sf-signature"] as string | undefined);
|
||||||
|
|
||||||
if (!signature) {
|
if (!signatureHeader) {
|
||||||
throw new UnauthorizedException("Webhook signature is required");
|
throw new UnauthorizedException("Webhook signature is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the appropriate secret based on the webhook type
|
// Get the appropriate secret based on the webhook type
|
||||||
const isWhmcs = request.headers["x-whmcs-signature"];
|
const isWhmcs = Boolean(request.headers["x-whmcs-signature"]);
|
||||||
const secret = isWhmcs
|
const secret = isWhmcs
|
||||||
? this.configService.get("WHMCS_WEBHOOK_SECRET")
|
? this.configService.get<string>("WHMCS_WEBHOOK_SECRET")
|
||||||
: this.configService.get("SF_WEBHOOK_SECRET");
|
: this.configService.get<string>("SF_WEBHOOK_SECRET");
|
||||||
|
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
throw new UnauthorizedException("Webhook secret not configured");
|
throw new UnauthorizedException("Webhook secret not configured");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify signature
|
// Verify signature
|
||||||
const payload = JSON.stringify(request.body);
|
const payload = Buffer.from(JSON.stringify(request.body), "utf8");
|
||||||
const expectedSignature = crypto.createHmac("sha256", secret).update(payload).digest("hex");
|
const key = Buffer.from(secret, "utf8");
|
||||||
|
const expectedSignature = crypto.createHmac("sha256", key).update(payload).digest("hex");
|
||||||
|
|
||||||
if (signature !== expectedSignature) {
|
if (signatureHeader !== expectedSignature) {
|
||||||
throw new UnauthorizedException("Invalid webhook signature");
|
throw new UnauthorizedException("Invalid webhook signature");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
8
apps/bff/src/webhooks/schemas/salesforce.ts
Normal file
8
apps/bff/src/webhooks/schemas/salesforce.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const SalesforceWebhookSchema = z.object({
|
||||||
|
event: z.object({ type: z.string() }).optional(),
|
||||||
|
sobject: z.object({ Id: z.string() }).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SalesforceWebhook = z.infer<typeof SalesforceWebhookSchema>;
|
||||||
8
apps/bff/src/webhooks/schemas/whmcs.ts
Normal file
8
apps/bff/src/webhooks/schemas/whmcs.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const WhmcsWebhookSchema = z.object({
|
||||||
|
action: z.string(),
|
||||||
|
client_id: z.coerce.number().int().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WhmcsWebhook = z.infer<typeof WhmcsWebhookSchema>;
|
||||||
@ -27,9 +27,9 @@ export class WebhooksController {
|
|||||||
@ApiResponse({ status: 400, description: "Invalid webhook data" })
|
@ApiResponse({ status: 400, description: "Invalid webhook data" })
|
||||||
@ApiResponse({ status: 401, description: "Invalid signature" })
|
@ApiResponse({ status: 401, description: "Invalid signature" })
|
||||||
@ApiHeader({ name: "X-WHMCS-Signature", description: "WHMCS webhook signature" })
|
@ApiHeader({ name: "X-WHMCS-Signature", description: "WHMCS webhook signature" })
|
||||||
async handleWhmcsWebhook(@Body() payload: any, @Headers("x-whmcs-signature") signature: string) {
|
handleWhmcsWebhook(@Body() payload: unknown, @Headers("x-whmcs-signature") signature: string) {
|
||||||
try {
|
try {
|
||||||
await this.webhooksService.processWhmcsWebhook(payload, signature);
|
this.webhooksService.processWhmcsWebhook(payload, signature);
|
||||||
return { success: true, message: "Webhook processed successfully" };
|
return { success: true, message: "Webhook processed successfully" };
|
||||||
} catch {
|
} catch {
|
||||||
throw new BadRequestException("Failed to process webhook");
|
throw new BadRequestException("Failed to process webhook");
|
||||||
@ -44,12 +44,9 @@ export class WebhooksController {
|
|||||||
@ApiResponse({ status: 400, description: "Invalid webhook data" })
|
@ApiResponse({ status: 400, description: "Invalid webhook data" })
|
||||||
@ApiResponse({ status: 401, description: "Invalid signature" })
|
@ApiResponse({ status: 401, description: "Invalid signature" })
|
||||||
@ApiHeader({ name: "X-SF-Signature", description: "Salesforce webhook signature" })
|
@ApiHeader({ name: "X-SF-Signature", description: "Salesforce webhook signature" })
|
||||||
async handleSalesforceWebhook(
|
handleSalesforceWebhook(@Body() payload: unknown, @Headers("x-sf-signature") signature: string) {
|
||||||
@Body() payload: any,
|
|
||||||
@Headers("x-sf-signature") signature: string
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
await this.webhooksService.processSalesforceWebhook(payload, signature);
|
this.webhooksService.processSalesforceWebhook(payload, signature);
|
||||||
return { success: true, message: "Webhook processed successfully" };
|
return { success: true, message: "Webhook processed successfully" };
|
||||||
} catch {
|
} catch {
|
||||||
throw new BadRequestException("Failed to process webhook");
|
throw new BadRequestException("Failed to process webhook");
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { Injectable, BadRequestException, Inject } from "@nestjs/common";
|
import { Injectable, BadRequestException, Inject } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { WhmcsWebhookSchema, WhmcsWebhook } from "./schemas/whmcs";
|
||||||
|
import { SalesforceWebhookSchema, SalesforceWebhook } from "./schemas/salesforce";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WebhooksService {
|
export class WebhooksService {
|
||||||
@ -9,11 +11,12 @@ export class WebhooksService {
|
|||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async processWhmcsWebhook(payload: any, signature: string): Promise<void> {
|
processWhmcsWebhook(payload: unknown, signature: string): void {
|
||||||
try {
|
try {
|
||||||
|
const data: WhmcsWebhook = WhmcsWebhookSchema.parse(payload);
|
||||||
this.logger.log("Processing WHMCS webhook", {
|
this.logger.log("Processing WHMCS webhook", {
|
||||||
webhookType: payload.action || "unknown",
|
webhookType: data.action,
|
||||||
clientId: payload.client_id,
|
clientId: data.client_id,
|
||||||
signatureLength: signature?.length || 0,
|
signatureLength: signature?.length || 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -28,17 +31,17 @@ export class WebhooksService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error("Failed to process WHMCS webhook", {
|
this.logger.error("Failed to process WHMCS webhook", {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
payload: payload.action || "unknown",
|
|
||||||
});
|
});
|
||||||
throw new BadRequestException("Failed to process WHMCS webhook");
|
throw new BadRequestException("Failed to process WHMCS webhook");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async processSalesforceWebhook(payload: any, signature: string): Promise<void> {
|
processSalesforceWebhook(payload: unknown, signature: string): void {
|
||||||
try {
|
try {
|
||||||
|
const data: SalesforceWebhook = SalesforceWebhookSchema.parse(payload);
|
||||||
this.logger.log("Processing Salesforce webhook", {
|
this.logger.log("Processing Salesforce webhook", {
|
||||||
webhookType: payload.event?.type || "unknown",
|
webhookType: data.event?.type || "unknown",
|
||||||
recordId: payload.sobject?.Id,
|
recordId: data.sobject?.Id,
|
||||||
signatureLength: signature?.length || 0,
|
signatureLength: signature?.length || 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -53,7 +56,6 @@ export class WebhooksService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error("Failed to process Salesforce webhook", {
|
this.logger.error("Failed to process Salesforce webhook", {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
payload: payload.event?.type || "unknown",
|
|
||||||
});
|
});
|
||||||
throw new BadRequestException("Failed to process Salesforce webhook");
|
throw new BadRequestException("Failed to process Salesforce webhook");
|
||||||
}
|
}
|
||||||
|
|||||||
77
apps/portal/src/app/auth/forgot-password/page.tsx
Normal file
77
apps/portal/src/app/auth/forgot-password/page.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { AuthLayout } from "@/components/auth/auth-layout";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useAuthStore } from "@/lib/auth/store";
|
||||||
|
|
||||||
|
const schema = z.object({ email: z.string().email("Please enter a valid email") });
|
||||||
|
type FormData = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
export default function ForgotPasswordPage() {
|
||||||
|
const { requestPasswordReset, isLoading } = useAuthStore();
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<FormData>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: FormData) => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
await requestPasswordReset(data.email);
|
||||||
|
setMessage("If an account exists, a reset email has been sent.");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to send reset email");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout title="Forgot password" subtitle="We'll send you a reset link if your email exists">
|
||||||
|
<form
|
||||||
|
onSubmit={e => {
|
||||||
|
void handleSubmit(onSubmit)(e);
|
||||||
|
}}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
{message && (
|
||||||
|
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="email">Email address</Label>
|
||||||
|
<Input
|
||||||
|
{...register("email")}
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="Enter your email"
|
||||||
|
/>
|
||||||
|
{errors.email && <p className="mt-1 text-sm text-red-600">{errors.email.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading ? "Sending..." : "Send reset link"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
apps/portal/src/app/auth/reset-password/page.tsx
Normal file
100
apps/portal/src/app/auth/reset-password/page.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { AuthLayout } from "@/components/auth/auth-layout";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useAuthStore } from "@/lib/auth/store";
|
||||||
|
|
||||||
|
const schema = z
|
||||||
|
.object({
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(8, "Password must be at least 8 characters")
|
||||||
|
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/),
|
||||||
|
confirmPassword: z.string(),
|
||||||
|
})
|
||||||
|
.refine(v => v.password === v.confirmPassword, {
|
||||||
|
message: "Passwords do not match",
|
||||||
|
path: ["confirmPassword"],
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
export default function ResetPasswordPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { resetPassword, isLoading } = useAuthStore();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const token = searchParams.get("token") || "";
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<FormData>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: FormData) => {
|
||||||
|
if (!token) {
|
||||||
|
setError("Invalid or missing token");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
await resetPassword(token, data.password);
|
||||||
|
router.push("/dashboard");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Reset failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout title="Reset your password" subtitle="Set a new password for your account">
|
||||||
|
<form
|
||||||
|
onSubmit={e => {
|
||||||
|
void handleSubmit(onSubmit)(e);
|
||||||
|
}}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="password">New password</Label>
|
||||||
|
<Input {...register("password")} id="password" type="password" className="mt-1" />
|
||||||
|
{errors.password && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="confirmPassword">Confirm password</Label>
|
||||||
|
<Input
|
||||||
|
{...register("confirmPassword")}
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
{errors.confirmPassword && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.confirmPassword.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading ? "Resetting..." : "Reset password"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -12,20 +12,41 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { useAuthStore } from "@/lib/auth/store";
|
import { useAuthStore } from "@/lib/auth/store";
|
||||||
|
|
||||||
const signupSchema = z.object({
|
const signupSchema = z
|
||||||
email: z.string().email("Please enter a valid email address"),
|
.object({
|
||||||
password: z
|
email: z.string().email("Please enter a valid email address"),
|
||||||
.string()
|
confirmEmail: z.string().email("Please confirm with a valid email"),
|
||||||
.min(8, "Password must be at least 8 characters")
|
password: z
|
||||||
.regex(
|
.string()
|
||||||
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
|
.min(8, "Password must be at least 8 characters")
|
||||||
"Password must contain uppercase, lowercase, number, and special character"
|
.regex(
|
||||||
),
|
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
|
||||||
firstName: z.string().min(1, "First name is required"),
|
"Password must contain uppercase, lowercase, number, and special character"
|
||||||
lastName: z.string().min(1, "Last name is required"),
|
),
|
||||||
company: z.string().optional(),
|
confirmPassword: z.string(),
|
||||||
phone: z.string().optional(),
|
firstName: z.string().min(1, "First name is required"),
|
||||||
});
|
lastName: z.string().min(1, "Last name is required"),
|
||||||
|
company: z.string().optional(),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
sfNumber: z.string().min(1, "Customer Number is required"),
|
||||||
|
addressLine1: z.string().optional(),
|
||||||
|
addressLine2: z.string().optional(),
|
||||||
|
city: z.string().optional(),
|
||||||
|
state: z.string().optional(),
|
||||||
|
postalCode: z.string().optional(),
|
||||||
|
country: z.string().optional(),
|
||||||
|
nationality: z.string().optional(),
|
||||||
|
dateOfBirth: z.string().optional(),
|
||||||
|
gender: z.enum(["male", "female", "other"]).optional(),
|
||||||
|
})
|
||||||
|
.refine(values => values.email === values.confirmEmail, {
|
||||||
|
message: "Emails do not match",
|
||||||
|
path: ["confirmEmail"],
|
||||||
|
})
|
||||||
|
.refine(values => values.password === values.confirmPassword, {
|
||||||
|
message: "Passwords do not match",
|
||||||
|
path: ["confirmPassword"],
|
||||||
|
});
|
||||||
|
|
||||||
type SignupForm = z.infer<typeof signupSchema>;
|
type SignupForm = z.infer<typeof signupSchema>;
|
||||||
|
|
||||||
@ -45,7 +66,29 @@ export default function SignupPage() {
|
|||||||
const onSubmit = async (data: SignupForm) => {
|
const onSubmit = async (data: SignupForm) => {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
await signup(data);
|
await signup({
|
||||||
|
email: data.email,
|
||||||
|
password: data.password,
|
||||||
|
firstName: data.firstName,
|
||||||
|
lastName: data.lastName,
|
||||||
|
company: data.company,
|
||||||
|
phone: data.phone,
|
||||||
|
sfNumber: data.sfNumber,
|
||||||
|
address:
|
||||||
|
data.addressLine1 || data.city || data.state || data.postalCode || data.country
|
||||||
|
? {
|
||||||
|
line1: data.addressLine1 || "",
|
||||||
|
line2: data.addressLine2 || undefined,
|
||||||
|
city: data.city || "",
|
||||||
|
state: data.state || "",
|
||||||
|
postalCode: data.postalCode || "",
|
||||||
|
country: data.country || "",
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
nationality: data.nationality || undefined,
|
||||||
|
dateOfBirth: data.dateOfBirth || undefined,
|
||||||
|
gender: data.gender || undefined,
|
||||||
|
});
|
||||||
router.push("/dashboard");
|
router.push("/dashboard");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Signup failed");
|
setError(err instanceof Error ? err.message : "Signup failed");
|
||||||
@ -101,17 +144,130 @@ export default function SignupPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Address */}
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="email">Email address</Label>
|
<Label htmlFor="addressLine1">Home Address #1</Label>
|
||||||
<Input
|
<Input
|
||||||
{...register("email")}
|
{...register("addressLine1")}
|
||||||
id="email"
|
id="addressLine1"
|
||||||
type="email"
|
type="text"
|
||||||
autoComplete="email"
|
autoComplete="address-line1"
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
placeholder="john@example.com"
|
placeholder="Street, number"
|
||||||
/>
|
/>
|
||||||
{errors.email && <p className="mt-1 text-sm text-red-600">{errors.email.message}</p>}
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="addressLine2">Home Address #2 (optional)</Label>
|
||||||
|
<Input
|
||||||
|
{...register("addressLine2")}
|
||||||
|
id="addressLine2"
|
||||||
|
type="text"
|
||||||
|
autoComplete="address-line2"
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="Apartment, suite, etc."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="city">City</Label>
|
||||||
|
<Input {...register("city")} id="city" type="text" className="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="state">Prefecture</Label>
|
||||||
|
<Input {...register("state")} id="state" type="text" className="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="postalCode">Postal Code</Label>
|
||||||
|
<Input {...register("postalCode")} id="postalCode" type="text" className="mt-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="country">Country</Label>
|
||||||
|
<Input {...register("country")} id="country" type="text" className="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="nationality">Nationality</Label>
|
||||||
|
<Input {...register("nationality")} id="nationality" type="text" className="mt-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="phone">Phone (optional)</Label>
|
||||||
|
<Input
|
||||||
|
{...register("phone")}
|
||||||
|
id="phone"
|
||||||
|
type="tel"
|
||||||
|
autoComplete="tel"
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="+81 90 1234 5678"
|
||||||
|
/>
|
||||||
|
{errors.phone && <p className="mt-1 text-sm text-red-600">{errors.phone.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="dateOfBirth">Date of Birth</Label>
|
||||||
|
<Input {...register("dateOfBirth")} id="dateOfBirth" type="date" className="mt-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="gender">Gender</Label>
|
||||||
|
<select
|
||||||
|
{...register("gender")}
|
||||||
|
id="gender"
|
||||||
|
className="mt-1 block w-full border border-gray-300 rounded-md p-2"
|
||||||
|
>
|
||||||
|
<option value="">Select</option>
|
||||||
|
<option value="male">Male</option>
|
||||||
|
<option value="female">Female</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="sfNumber">Customer Number</Label>
|
||||||
|
<Input
|
||||||
|
{...register("sfNumber")}
|
||||||
|
id="sfNumber"
|
||||||
|
type="text"
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="Your SF Number"
|
||||||
|
/>
|
||||||
|
{errors.sfNumber && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.sfNumber.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="email">Email address</Label>
|
||||||
|
<Input
|
||||||
|
{...register("email")}
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="john@example.com"
|
||||||
|
/>
|
||||||
|
{errors.email && <p className="mt-1 text-sm text-red-600">{errors.email.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="confirmEmail">Email address (confirm)</Label>
|
||||||
|
<Input
|
||||||
|
{...register("confirmEmail")}
|
||||||
|
id="confirmEmail"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="john@example.com"
|
||||||
|
/>
|
||||||
|
{errors.confirmEmail && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.confirmEmail.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -140,22 +296,38 @@ export default function SignupPage() {
|
|||||||
{errors.phone && <p className="mt-1 text-sm text-red-600">{errors.phone.message}</p>}
|
{errors.phone && <p className="mt-1 text-sm text-red-600">{errors.phone.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Label htmlFor="password">Password</Label>
|
<div>
|
||||||
<Input
|
<Label htmlFor="password">Password</Label>
|
||||||
{...register("password")}
|
<Input
|
||||||
id="password"
|
{...register("password")}
|
||||||
type="password"
|
id="password"
|
||||||
autoComplete="new-password"
|
type="password"
|
||||||
className="mt-1"
|
autoComplete="new-password"
|
||||||
placeholder="Create a secure password"
|
className="mt-1"
|
||||||
/>
|
placeholder="Create a secure password"
|
||||||
{errors.password && (
|
/>
|
||||||
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
|
{errors.password && (
|
||||||
)}
|
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
)}
|
||||||
Must be at least 8 characters with uppercase, lowercase, number, and special character
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
</p>
|
Must be at least 8 characters with uppercase, lowercase, number, and special character
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="confirmPassword">Password (confirm)</Label>
|
||||||
|
<Input
|
||||||
|
{...register("confirmPassword")}
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="Re-enter your password"
|
||||||
|
/>
|
||||||
|
{errors.confirmPassword && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.confirmPassword.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
|||||||
144
apps/portal/src/app/catalog/esim/page.tsx
Normal file
144
apps/portal/src/app/catalog/esim/page.tsx
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { PageLayout } from "@/components/layout/page-layout";
|
||||||
|
import { CreditCardIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export default function EsimProductPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [simType, setSimType] = useState<"eSIM" | "Physical">("eSIM");
|
||||||
|
const [eid, setEid] = useState("");
|
||||||
|
const [isMnp, setIsMnp] = useState(false);
|
||||||
|
const [mnp, setMnp] = useState({ number: "", expiry: "", phone: "" });
|
||||||
|
const [activationType, setActivationType] = useState<"Immediate" | "Scheduled">("Immediate");
|
||||||
|
const [scheduledAt, setScheduledAt] = useState<string>("");
|
||||||
|
|
||||||
|
const canContinue = simType === "Physical" || eid.trim().length > 8;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout
|
||||||
|
icon={<CreditCardIcon />}
|
||||||
|
title="SIM / eSIM"
|
||||||
|
description="Choose plan and SIM format"
|
||||||
|
>
|
||||||
|
<div className="space-y-8">
|
||||||
|
<section>
|
||||||
|
<h3 className="font-semibold mb-3">Step 1: SIM format</h3>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={simType === "Physical"}
|
||||||
|
onChange={() => setSimType("Physical")}
|
||||||
|
/>{" "}
|
||||||
|
Physical SIM
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={simType === "eSIM"}
|
||||||
|
onChange={() => setSimType("eSIM")}
|
||||||
|
/>{" "}
|
||||||
|
eSIM
|
||||||
|
</label>
|
||||||
|
{simType === "eSIM" && (
|
||||||
|
<input
|
||||||
|
value={eid}
|
||||||
|
onChange={e => setEid(e.target.value)}
|
||||||
|
placeholder="Enter EID"
|
||||||
|
className="border rounded p-2 w-80"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="font-semibold mb-3">Step 2: MNP (port-in)</h3>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" checked={isMnp} onChange={e => setIsMnp(e.target.checked)} />{" "}
|
||||||
|
This is an MNP application
|
||||||
|
</label>
|
||||||
|
{isMnp && (
|
||||||
|
<div className="mt-3 grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<input
|
||||||
|
className="border rounded p-2"
|
||||||
|
placeholder="MNP Reservation Number"
|
||||||
|
value={mnp.number}
|
||||||
|
onChange={e => setMnp({ ...mnp, number: e.target.value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="border rounded p-2"
|
||||||
|
placeholder="MNP Expiry (YYYY/MM/DD)"
|
||||||
|
value={mnp.expiry}
|
||||||
|
onChange={e => setMnp({ ...mnp, expiry: e.target.value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="border rounded p-2"
|
||||||
|
placeholder="Phone Number"
|
||||||
|
value={mnp.phone}
|
||||||
|
onChange={e => setMnp({ ...mnp, phone: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="font-semibold mb-3">Step 3: Activation</h3>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="act"
|
||||||
|
checked={activationType === "Immediate"}
|
||||||
|
onChange={() => setActivationType("Immediate")}
|
||||||
|
/>{" "}
|
||||||
|
Immediate
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="act"
|
||||||
|
checked={activationType === "Scheduled"}
|
||||||
|
onChange={() => setActivationType("Scheduled")}
|
||||||
|
/>{" "}
|
||||||
|
Scheduled
|
||||||
|
</label>
|
||||||
|
{activationType === "Scheduled" && (
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
className="border rounded p-2"
|
||||||
|
value={scheduledAt}
|
||||||
|
onChange={e => setScheduledAt(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<button
|
||||||
|
disabled={!canContinue}
|
||||||
|
className="px-4 py-2 rounded bg-blue-600 text-white disabled:opacity-50"
|
||||||
|
onClick={() => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
orderType: "eSIM",
|
||||||
|
simType,
|
||||||
|
eid,
|
||||||
|
isMnp: String(isMnp),
|
||||||
|
mnpNumber: mnp.number,
|
||||||
|
mnpExpiry: mnp.expiry,
|
||||||
|
mnpPhone: mnp.phone,
|
||||||
|
activationType,
|
||||||
|
scheduledAt,
|
||||||
|
skuService: simType === "eSIM" ? "SIM-ESIM-DEFAULT" : "SIM-PHYS-DEFAULT",
|
||||||
|
});
|
||||||
|
router.push(`/checkout?${params.toString()}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Continue to checkout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
apps/portal/src/app/catalog/internet/page.tsx
Normal file
160
apps/portal/src/app/catalog/internet/page.tsx
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { PageLayout } from "@/components/layout/page-layout";
|
||||||
|
import { ServerIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { getInternetInstallSku, getInternetServiceSku } from "@customer-portal/shared/src/skus";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
type Tier = "Platinum_Gold" | "Silver";
|
||||||
|
type AccessMode = "IPoE-HGW" | "IPoE-BYOR" | "PPPoE";
|
||||||
|
type InstallPlan = "One-time" | "12-Month" | "24-Month";
|
||||||
|
|
||||||
|
export default function InternetProductPage() {
|
||||||
|
const [tier, setTier] = useState<Tier | null>(null);
|
||||||
|
const [mode, setMode] = useState<AccessMode | null>(null);
|
||||||
|
const [installPlan, setInstallPlan] = useState<InstallPlan | null>(null);
|
||||||
|
const [weekend, setWeekend] = useState(false);
|
||||||
|
const [activationType, setActivationType] = useState<"Immediate" | "Scheduled">("Immediate");
|
||||||
|
const [scheduledAt, setScheduledAt] = useState<string>("");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const canContinue =
|
||||||
|
tier && mode && installPlan && (activationType === "Immediate" || scheduledAt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout
|
||||||
|
icon={<ServerIcon />}
|
||||||
|
title="Home Internet"
|
||||||
|
description="Select plan, access mode, and installation options"
|
||||||
|
>
|
||||||
|
<div className="space-y-8">
|
||||||
|
<section>
|
||||||
|
<h3 className="font-semibold mb-3">Step 1: Plan</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setTier("Platinum_Gold")}
|
||||||
|
className={`border rounded p-3 text-left ${tier === "Platinum_Gold" ? "ring-2 ring-blue-500" : ""}`}
|
||||||
|
>
|
||||||
|
Platinum & Gold (1Gbps)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTier("Silver")}
|
||||||
|
className={`border rounded p-3 text-left ${tier === "Silver" ? "ring-2 ring-blue-500" : ""}`}
|
||||||
|
>
|
||||||
|
Silver (1Gbps)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="font-semibold mb-3">Step 2: Access Mode</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setMode("IPoE-HGW")}
|
||||||
|
className={`border rounded p-3 ${mode === "IPoE-HGW" ? "ring-2 ring-blue-500" : ""}`}
|
||||||
|
>
|
||||||
|
IPoE‑HGW
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMode("IPoE-BYOR")}
|
||||||
|
className={`border rounded p-3 ${mode === "IPoE-BYOR" ? "ring-2 ring-blue-500" : ""}`}
|
||||||
|
>
|
||||||
|
IPoE‑BYOR
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMode("PPPoE")}
|
||||||
|
className={`border rounded p-3 ${mode === "PPPoE" ? "ring-2 ring-blue-500" : ""}`}
|
||||||
|
>
|
||||||
|
PPPoE
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="font-semibold mb-3">Step 3: Installation Plan</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setInstallPlan("One-time")}
|
||||||
|
className={`border rounded p-3 ${installPlan === "One-time" ? "ring-2 ring-blue-500" : ""}`}
|
||||||
|
>
|
||||||
|
Single Installment
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setInstallPlan("12-Month")}
|
||||||
|
className={`border rounded p-3 ${installPlan === "12-Month" ? "ring-2 ring-blue-500" : ""}`}
|
||||||
|
>
|
||||||
|
12‑Month Installment
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setInstallPlan("24-Month")}
|
||||||
|
className={`border rounded p-3 ${installPlan === "24-Month" ? "ring-2 ring-blue-500" : ""}`}
|
||||||
|
>
|
||||||
|
24‑Month Installment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label className="mt-3 flex items-center gap-2">
|
||||||
|
<input type="checkbox" checked={weekend} onChange={e => setWeekend(e.target.checked)} />
|
||||||
|
Weekend installation
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="font-semibold mb-3">Step 4: Activation</h3>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="act"
|
||||||
|
checked={activationType === "Immediate"}
|
||||||
|
onChange={() => setActivationType("Immediate")}
|
||||||
|
/>
|
||||||
|
Immediate
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="act"
|
||||||
|
checked={activationType === "Scheduled"}
|
||||||
|
onChange={() => setActivationType("Scheduled")}
|
||||||
|
/>
|
||||||
|
Scheduled
|
||||||
|
</label>
|
||||||
|
{activationType === "Scheduled" && (
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
className="border rounded p-2"
|
||||||
|
value={scheduledAt}
|
||||||
|
onChange={e => setScheduledAt(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<button
|
||||||
|
disabled={!canContinue}
|
||||||
|
onClick={() => {
|
||||||
|
// Navigate to checkout with selections encoded in query; real impl will use state/store
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
orderType: "Internet",
|
||||||
|
tier: tier || "",
|
||||||
|
mode: mode || "",
|
||||||
|
install: installPlan || "",
|
||||||
|
weekend: String(weekend),
|
||||||
|
activationType,
|
||||||
|
scheduledAt,
|
||||||
|
skuService: tier && mode ? getInternetServiceSku(tier, mode) : "",
|
||||||
|
skuInstall: installPlan ? getInternetInstallSku(installPlan) : "",
|
||||||
|
});
|
||||||
|
router.push(`/checkout?${params.toString()}`);
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 rounded bg-blue-600 text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Continue to checkout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
apps/portal/src/app/catalog/page.tsx
Normal file
100
apps/portal/src/app/catalog/page.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { PageLayout } from "@/components/layout/page-layout";
|
||||||
|
import { Squares2X2Icon } from "@heroicons/react/24/outline";
|
||||||
|
import { authenticatedApi } from "@/lib/api";
|
||||||
|
|
||||||
|
type Category = "All" | "Internet" | "eSIM" | "SIM" | "VPN";
|
||||||
|
|
||||||
|
type CatalogProduct = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
sku: string;
|
||||||
|
category: Category;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CatalogPage() {
|
||||||
|
const [products, setProducts] = useState<CatalogProduct[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [filter, setFilter] = useState<Category>("All");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
void (async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await authenticatedApi.get<CatalogProduct[]>("/catalog");
|
||||||
|
if (mounted) setProducts(res);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setError(e instanceof Error ? e.message : "Failed to load catalog");
|
||||||
|
} finally {
|
||||||
|
if (mounted) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const visible = useMemo(() => {
|
||||||
|
if (filter === "All") return products;
|
||||||
|
return products.filter(p => p.category === filter);
|
||||||
|
}, [products, filter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout
|
||||||
|
icon={<Squares2X2Icon />}
|
||||||
|
title="Add Service(s)"
|
||||||
|
description="Choose a service to continue"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<label className="text-sm text-gray-700">Filter</label>
|
||||||
|
<select
|
||||||
|
className="border rounded p-2 text-sm"
|
||||||
|
value={filter}
|
||||||
|
onChange={e => setFilter(e.target.value as Category)}
|
||||||
|
>
|
||||||
|
<option>All</option>
|
||||||
|
<option>Internet</option>
|
||||||
|
<option>SIM</option>
|
||||||
|
<option>eSIM</option>
|
||||||
|
<option>VPN</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <div className="text-sm text-gray-600">Loading catalog…</div>}
|
||||||
|
{error && <div className="text-sm text-red-600">{error}</div>}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{visible.map(p => (
|
||||||
|
<Link
|
||||||
|
key={p.id}
|
||||||
|
href={categoryToHref(p.category)}
|
||||||
|
className="block rounded-lg border bg-white shadow-sm hover:shadow-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition"
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">{p.name}</h3>
|
||||||
|
<p className="mt-2 text-xs text-gray-500">SKU: {p.sku}</p>
|
||||||
|
<span className="mt-4 inline-flex items-center text-sm font-medium text-blue-600">
|
||||||
|
Select
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryToHref(category: Category) {
|
||||||
|
const c = category.toLowerCase();
|
||||||
|
if (c.includes("internet")) return "/catalog/internet";
|
||||||
|
if (c === "esim" || c.includes("e-sim") || c.includes("e_sim")) return "/catalog/esim";
|
||||||
|
if (c.includes("sim")) return "/catalog/esim";
|
||||||
|
if (c.includes("vpn")) return "/catalog/vpn";
|
||||||
|
return "/catalog/internet";
|
||||||
|
}
|
||||||
94
apps/portal/src/app/catalog/vpn/page.tsx
Normal file
94
apps/portal/src/app/catalog/vpn/page.tsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { PageLayout } from "@/components/layout/page-layout";
|
||||||
|
import { GlobeAsiaAustraliaIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { getVpnActivationSku, getVpnServiceSku } from "@customer-portal/shared/src/skus";
|
||||||
|
|
||||||
|
export default function VpnProductPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [region, setRegion] = useState<string | null>(null);
|
||||||
|
const [activationType, setActivationType] = useState<"Immediate" | "Scheduled">("Immediate");
|
||||||
|
const [scheduledAt, setScheduledAt] = useState<string>("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout
|
||||||
|
icon={<GlobeAsiaAustraliaIcon />}
|
||||||
|
title="VPN Rental Router"
|
||||||
|
description="Select a region"
|
||||||
|
>
|
||||||
|
<div className="space-y-8">
|
||||||
|
<section>
|
||||||
|
<h3 className="font-semibold mb-3">Region</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setRegion("USA-SF")}
|
||||||
|
className={`border rounded p-3 ${region === "USA-SF" ? "ring-2 ring-blue-500" : ""}`}
|
||||||
|
>
|
||||||
|
USA (San Francisco)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setRegion("UK-London")}
|
||||||
|
className={`border rounded p-3 ${region === "UK-London" ? "ring-2 ring-blue-500" : ""}`}
|
||||||
|
>
|
||||||
|
UK (London)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="font-semibold mb-3">Activation</h3>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="act"
|
||||||
|
checked={activationType === "Immediate"}
|
||||||
|
onChange={() => setActivationType("Immediate")}
|
||||||
|
/>{" "}
|
||||||
|
Immediate
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="act"
|
||||||
|
checked={activationType === "Scheduled"}
|
||||||
|
onChange={() => setActivationType("Scheduled")}
|
||||||
|
/>{" "}
|
||||||
|
Scheduled
|
||||||
|
</label>
|
||||||
|
{activationType === "Scheduled" && (
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
className="border rounded p-2"
|
||||||
|
value={scheduledAt}
|
||||||
|
onChange={e => setScheduledAt(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<button
|
||||||
|
disabled={!region}
|
||||||
|
className="px-4 py-2 rounded bg-blue-600 text-white disabled:opacity-50"
|
||||||
|
onClick={() => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
orderType: "VPN",
|
||||||
|
region: region || "",
|
||||||
|
activationType,
|
||||||
|
scheduledAt,
|
||||||
|
skuService: region ? getVpnServiceSku(region) : "",
|
||||||
|
skuInstall: getVpnActivationSku(),
|
||||||
|
});
|
||||||
|
router.push(`/checkout?${params.toString()}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Continue to checkout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
apps/portal/src/app/checkout/page.tsx
Normal file
69
apps/portal/src/app/checkout/page.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
|
import { PageLayout } from "@/components/layout/page-layout";
|
||||||
|
import { ShieldCheckIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { authenticatedApi } from "@/lib/api";
|
||||||
|
|
||||||
|
export default function CheckoutPage() {
|
||||||
|
const params = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const orderType = params.get("orderType") || "";
|
||||||
|
|
||||||
|
const selections = useMemo(() => {
|
||||||
|
const obj: Record<string, string> = {};
|
||||||
|
params.forEach((v, k) => {
|
||||||
|
if (k !== "orderType") obj[k] = v;
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
|
}, [params]);
|
||||||
|
|
||||||
|
const placeOrder = async () => {
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await authenticatedApi.post<{ sfOrderId: string; status: string }>("/orders", {
|
||||||
|
orderType,
|
||||||
|
selections,
|
||||||
|
});
|
||||||
|
router.push(`/orders/${res.sfOrderId}`);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to place order");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout
|
||||||
|
icon={<ShieldCheckIcon />}
|
||||||
|
title="Checkout"
|
||||||
|
description="Verify details and place your order"
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-white border rounded p-4">
|
||||||
|
<h3 className="font-semibold">Summary</h3>
|
||||||
|
<pre className="mt-2 text-sm text-gray-600 overflow-x-auto">
|
||||||
|
{JSON.stringify({ orderType, selections }, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="text-red-600 text-sm">{error}</div>}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => void placeOrder()}
|
||||||
|
disabled={submitting}
|
||||||
|
className="px-4 py-2 rounded bg-blue-600 text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? "Placing order..." : "Place order"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
apps/portal/src/app/orders/[id]/page.tsx
Normal file
69
apps/portal/src/app/orders/[id]/page.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { PageLayout } from "@/components/layout/page-layout";
|
||||||
|
import { ClipboardDocumentCheckIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { authenticatedApi } from "@/lib/api";
|
||||||
|
|
||||||
|
interface OrderSummary {
|
||||||
|
sfOrderId: string;
|
||||||
|
status: string;
|
||||||
|
activationStatus?: string;
|
||||||
|
activationType?: string;
|
||||||
|
scheduledAt?: string;
|
||||||
|
whmcsOrderId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrderStatusPage() {
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const [data, setData] = useState<OrderSummary | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
const fetchStatus = async () => {
|
||||||
|
try {
|
||||||
|
const res = await authenticatedApi.get<OrderSummary>(`/orders/${params.id}`);
|
||||||
|
if (mounted) setData(res);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setError(e instanceof Error ? e.message : "Failed to load order");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void fetchStatus();
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
void fetchStatus();
|
||||||
|
}, 5000);
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [params.id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout
|
||||||
|
icon={<ClipboardDocumentCheckIcon />}
|
||||||
|
title={`Order ${params.id}`}
|
||||||
|
description="We’ll update this page as your order progresses"
|
||||||
|
>
|
||||||
|
{error && <div className="text-red-600 text-sm mb-4">{error}</div>}
|
||||||
|
<div className="bg-white border rounded p-4 space-y-2">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Order Status:</span> {data?.status || "Loading..."}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Activation Status:</span> {data?.activationStatus || "-"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Activation Type:</span> {data?.activationType || "-"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Scheduled At:</span> {data?.scheduledAt || "-"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">WHMCS Order ID:</span> {data?.whmcsOrderId || "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -20,5 +20,3 @@ export function AccountStatusCard() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,18 @@ export interface SignupData {
|
|||||||
lastName: string;
|
lastName: string;
|
||||||
company?: string;
|
company?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
sfNumber: string;
|
||||||
|
address?: {
|
||||||
|
line1: string;
|
||||||
|
line2?: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
postalCode: string;
|
||||||
|
country: string;
|
||||||
|
};
|
||||||
|
nationality?: string;
|
||||||
|
dateOfBirth?: string;
|
||||||
|
gender?: "male" | "female" | "other";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginData {
|
export interface LoginData {
|
||||||
@ -24,6 +36,15 @@ export interface SetPasswordData {
|
|||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RequestPasswordResetData {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResetPasswordData {
|
||||||
|
token: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
@ -101,6 +122,20 @@ class AuthAPI {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async requestPasswordReset(data: RequestPasswordResetData): Promise<{ message: string }> {
|
||||||
|
return this.request<{ message: string }>("/auth/request-password-reset", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetPassword(data: ResetPasswordData): Promise<AuthResponse> {
|
||||||
|
return this.request<AuthResponse>("/auth/reset-password", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async getProfile(token: string): Promise<AuthResponse["user"]> {
|
async getProfile(token: string): Promise<AuthResponse["user"]> {
|
||||||
return this.request<AuthResponse["user"]>("/me", {
|
return this.request<AuthResponse["user"]>("/me", {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@ -29,9 +29,23 @@ interface AuthState {
|
|||||||
lastName: string;
|
lastName: string;
|
||||||
company?: string;
|
company?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
sfNumber: string;
|
||||||
|
address?: {
|
||||||
|
line1: string;
|
||||||
|
line2?: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
postalCode: string;
|
||||||
|
country: string;
|
||||||
|
};
|
||||||
|
nationality?: string;
|
||||||
|
dateOfBirth?: string;
|
||||||
|
gender?: "male" | "female" | "other";
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
linkWhmcs: (email: string, password: string) => Promise<{ needsPasswordSet: boolean }>;
|
linkWhmcs: (email: string, password: string) => Promise<{ needsPasswordSet: boolean }>;
|
||||||
setPassword: (email: string, password: string) => Promise<void>;
|
setPassword: (email: string, password: string) => Promise<void>;
|
||||||
|
requestPasswordReset: (email: string) => Promise<void>;
|
||||||
|
resetPassword: (token: string, password: string) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
checkAuth: () => Promise<void>;
|
checkAuth: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@ -67,6 +81,18 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
lastName: string;
|
lastName: string;
|
||||||
company?: string;
|
company?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
sfNumber: string;
|
||||||
|
address?: {
|
||||||
|
line1: string;
|
||||||
|
line2?: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
postalCode: string;
|
||||||
|
country: string;
|
||||||
|
};
|
||||||
|
nationality?: string;
|
||||||
|
dateOfBirth?: string;
|
||||||
|
gender?: "male" | "female" | "other";
|
||||||
}) => {
|
}) => {
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
try {
|
try {
|
||||||
@ -111,6 +137,33 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
requestPasswordReset: async (email: string) => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
await authAPI.requestPasswordReset({ email });
|
||||||
|
set({ isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({ isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
resetPassword: async (token: string, password: string) => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const response = await authAPI.resetPassword({ token, password });
|
||||||
|
set({
|
||||||
|
user: response.user,
|
||||||
|
token: response.access_token,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
set({ isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
const { token } = get();
|
const { token } = get();
|
||||||
|
|
||||||
|
|||||||
@ -34,17 +34,28 @@ class Logger {
|
|||||||
const entry = this.formatMessage(level, message, data);
|
const entry = this.formatMessage(level, message, data);
|
||||||
|
|
||||||
if (this.isDevelopment) {
|
if (this.isDevelopment) {
|
||||||
// In development, use structured console logging
|
const safeData =
|
||||||
const consoleMethod = level === "debug" ? "log" : level;
|
data instanceof Error
|
||||||
|
? {
|
||||||
|
name: data.name,
|
||||||
|
message: data.message,
|
||||||
|
stack: data.stack,
|
||||||
|
}
|
||||||
|
: data;
|
||||||
|
|
||||||
const logData = {
|
const logData = {
|
||||||
timestamp: entry.timestamp,
|
timestamp: entry.timestamp,
|
||||||
level: entry.level.toUpperCase(),
|
level: entry.level.toUpperCase(),
|
||||||
service: entry.service,
|
service: entry.service,
|
||||||
message: entry.message,
|
message: entry.message,
|
||||||
...(data != null ? { data } : {}),
|
...(safeData != null ? { data: safeData } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
console[consoleMethod](logData);
|
try {
|
||||||
|
console.log(logData);
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// In production, structured logging for external services
|
// In production, structured logging for external services
|
||||||
const logData = {
|
const logData = {
|
||||||
@ -55,7 +66,11 @@ class Logger {
|
|||||||
// For production, you might want to send to a logging service
|
// For production, you might want to send to a logging service
|
||||||
// For now, only log errors and warnings to console
|
// For now, only log errors and warnings to console
|
||||||
if (level === "error" || level === "warn") {
|
if (level === "error" || level === "warn") {
|
||||||
console[level](JSON.stringify(logData));
|
try {
|
||||||
|
console[level](JSON.stringify(logData));
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,40 +2,6 @@
|
|||||||
# Complete containerized production setup
|
# Complete containerized production setup
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# Reverse Proxy - Nginx
|
|
||||||
proxy:
|
|
||||||
image: nginx:1.27-alpine
|
|
||||||
container_name: portal-proxy
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
- "443:443"
|
|
||||||
volumes:
|
|
||||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
|
||||||
- certbot-www:/var/www/certbot
|
|
||||||
- letsencrypt:/etc/letsencrypt
|
|
||||||
depends_on:
|
|
||||||
frontend:
|
|
||||||
condition: service_started
|
|
||||||
backend:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- app-network
|
|
||||||
labels:
|
|
||||||
- "prod.portal.service=proxy"
|
|
||||||
- "prod.portal.version=1.0.0"
|
|
||||||
|
|
||||||
# Certbot for TLS certificates (manual DNS/HTTP challenges)
|
|
||||||
certbot:
|
|
||||||
image: certbot/certbot:latest
|
|
||||||
container_name: portal-certbot
|
|
||||||
volumes:
|
|
||||||
- certbot-www:/var/www/certbot
|
|
||||||
- letsencrypt:/etc/letsencrypt
|
|
||||||
entrypoint: /bin/sh
|
|
||||||
command: -c "trap exit TERM; while :; do sleep 12h & wait $${!}; certbot renew --webroot -w /var/www/certbot --deploy-hook 'nginx -s reload'; done"
|
|
||||||
networks:
|
|
||||||
- app-network
|
|
||||||
# Frontend - Next.js Portal
|
# Frontend - Next.js Portal
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
@ -43,8 +9,8 @@ services:
|
|||||||
dockerfile: apps/portal/Dockerfile
|
dockerfile: apps/portal/Dockerfile
|
||||||
target: production
|
target: production
|
||||||
container_name: portal-frontend
|
container_name: portal-frontend
|
||||||
expose:
|
ports:
|
||||||
- "3000"
|
- "127.0.0.1:3000:3000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
@ -69,8 +35,8 @@ services:
|
|||||||
dockerfile: apps/bff/Dockerfile
|
dockerfile: apps/bff/Dockerfile
|
||||||
target: production
|
target: production
|
||||||
container_name: portal-backend
|
container_name: portal-backend
|
||||||
expose:
|
ports:
|
||||||
- "4000"
|
- "127.0.0.1:4000:4000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- BFF_PORT=${BFF_PORT:-4000}
|
- BFF_PORT=${BFF_PORT:-4000}
|
||||||
@ -187,10 +153,6 @@ volumes:
|
|||||||
driver: local
|
driver: local
|
||||||
labels:
|
labels:
|
||||||
- "prod.portal.volume=cache"
|
- "prod.portal.volume=cache"
|
||||||
certbot-www:
|
|
||||||
driver: local
|
|
||||||
letsencrypt:
|
|
||||||
driver: local
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
app-network:
|
app-network:
|
||||||
|
|||||||
235
docs/PORTAL-DATA-MODEL.md
Normal file
235
docs/PORTAL-DATA-MODEL.md
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
# Portal – Data Model & Mappings
|
||||||
|
|
||||||
|
This document lists the objects, custom fields, and mappings used across Salesforce, WHMCS, and the Portal BFF.
|
||||||
|
|
||||||
|
## Object inventory (what's standard vs custom)
|
||||||
|
|
||||||
|
- Salesforce standard objects
|
||||||
|
- `Product2` (catalog)
|
||||||
|
- `Pricebook2` / `PricebookEntry` (pricing)
|
||||||
|
- `Order` (header)
|
||||||
|
- `OrderItem` (line)
|
||||||
|
|
||||||
|
- Salesforce custom fields
|
||||||
|
- Added on `Product2`, `Order`, and `OrderItem` only (listed below with proposed API names).
|
||||||
|
|
||||||
|
- WHMCS
|
||||||
|
- Use native User (login identity), Client (billing profile), Order, Invoice, and Service (Product/Service) entities and API parameters. We do not create custom tables in WHMCS.
|
||||||
|
|
||||||
|
## Salesforce
|
||||||
|
|
||||||
|
### Product2 (catalog source of truth)
|
||||||
|
|
||||||
|
- Visibility & display
|
||||||
|
- `Portal_Visible__c` (Checkbox)
|
||||||
|
- `Portal_Category__c` (Picklist: Internet | eSIM | VPN | Other)
|
||||||
|
- `Portal_Description__c` (Long Text)
|
||||||
|
- `Portal_Feature_Bullets__c` (Long Text)
|
||||||
|
- `Portal_Hero_Image_URL__c` (URL)
|
||||||
|
- `Portal_Tags__c` (Text)
|
||||||
|
- `Portal_Sort_Order__c` (Number)
|
||||||
|
- `Portal_Valid_From__c` (Date)
|
||||||
|
- `Portal_Valid_Until__c` (Date)
|
||||||
|
|
||||||
|
- Eligibility (Internet)
|
||||||
|
- `Portal_Eligibility_Dwelling__c` (Picklist: Home | Apartment | Any)
|
||||||
|
- `Portal_Eligibility_Tier__c` (Picklist: 1G | 100Mb | Any)
|
||||||
|
- `Portal_Eligibility_Region__c` (Text)
|
||||||
|
|
||||||
|
- Terms & options
|
||||||
|
- `Portal_Billing_Cycle__c` (Picklist)
|
||||||
|
- `Portal_Max_Quantity__c` (Number)
|
||||||
|
- `Portal_Requires_Payment_Method__c` (Checkbox)
|
||||||
|
- (Avoid using configurable options for pricing; see Replica Product Strategy below)
|
||||||
|
|
||||||
|
- WHMCS mapping
|
||||||
|
- `WHMCS_Product_Id__c` (Number)
|
||||||
|
- `WHMCS_Notes_Template__c` (Long Text)
|
||||||
|
- `eSIM_Settings_JSON__c` (Long Text)
|
||||||
|
|
||||||
|
### PricebookEntry
|
||||||
|
|
||||||
|
- Use a dedicated “Portal” pricebook; ensure entries exist for all visible Product2 records
|
||||||
|
|
||||||
|
### Order (header)
|
||||||
|
|
||||||
|
- Required
|
||||||
|
- `AccountId`
|
||||||
|
- `EffectiveDate`
|
||||||
|
- `Status` (Pending Review)
|
||||||
|
- `Order_Type__c` (Picklist: Internet | eSIM | SIM | VPN | Other)
|
||||||
|
|
||||||
|
- Billing/Shipping snapshot (set on create; no ongoing sync)
|
||||||
|
- `BillToContactId` (Lookup Contact)
|
||||||
|
- `BillToStreet`
|
||||||
|
- `BillToCity`
|
||||||
|
- `BillToState`
|
||||||
|
- `BillToPostalCode`
|
||||||
|
- `BillToCountry`
|
||||||
|
- `ShipToContactId` (Lookup Contact)
|
||||||
|
- `ShipToStreet`
|
||||||
|
- `ShipToCity`
|
||||||
|
- `ShipToState`
|
||||||
|
- `ShipToPostalCode`
|
||||||
|
- `ShipToCountry`
|
||||||
|
|
||||||
|
- Service configuration (since one Order = one service)
|
||||||
|
- Source category → `Order_Type__c` is auto-set from `Product2.Portal_Category__c` of the main service SKU at checkout.
|
||||||
|
- eSIM/SIM
|
||||||
|
- `SIM_Type__c` (Picklist: Physical SIM | eSIM)
|
||||||
|
- `EID__c` (Text; masked; required when SIM_Type\_\_c = eSIM)
|
||||||
|
- MNP (port-in)
|
||||||
|
- `MNP_Application__c` (Checkbox)
|
||||||
|
- `MNP_Reservation_Number__c` (Text, 10)
|
||||||
|
- `MNP_Expiry_Date__c` (Date)
|
||||||
|
- `MNP_Phone_Number__c` (Text, 11)
|
||||||
|
- `Porting_LastName_Kanji__c` (Text)
|
||||||
|
- `Porting_FirstName_Kanji__c` (Text)
|
||||||
|
- `Porting_LastName_Katakana__c` (Text)
|
||||||
|
- `Porting_FirstName_Katakana__c` (Text)
|
||||||
|
- `Porting_Gender__c` (Picklist: Male | Female | Corporate/Other)
|
||||||
|
- `Porting_DateOfBirth__c` (Date)
|
||||||
|
- `MVNO_Account_Number__c` (Text)
|
||||||
|
- Internet (service line config)
|
||||||
|
- `Internet_Plan_Tier__c` (Picklist: Platinum_Gold | Silver)
|
||||||
|
- `Access_Mode__c` (Picklist: IPoE‑HGW | IPoE‑BYOR | PPPoE)
|
||||||
|
- `Service_Speed__c` (Text, e.g., "1Gbps")
|
||||||
|
- Installation (separate line derived from these)
|
||||||
|
- `Installment_Plan__c` (Picklist: One‑time | 12‑Month | 24‑Month)
|
||||||
|
- `Installment_Months__c` (Number: 0 | 12 | 24)
|
||||||
|
- `Weekend_Install__c` (Checkbox)
|
||||||
|
|
||||||
|
- Provisioning & results
|
||||||
|
- `Activation_Status__c` (Not Started | Activating | Activated | Failed)
|
||||||
|
- `Activation_Type__c` (Picklist: Immediate | Scheduled)
|
||||||
|
- `Activation_Scheduled_At__c` (Date/Time; required when Activation_Type\_\_c = Scheduled)
|
||||||
|
- `Activation_Error_Code__c` (Text)
|
||||||
|
- `Activation_Error_Message__c` (Text)
|
||||||
|
- `WHMCS_Order_ID__c` (Text/Number)
|
||||||
|
- `Last_Activation_At__c` (Datetime)
|
||||||
|
- `Activation_Attempt_Count__c` (Number)
|
||||||
|
|
||||||
|
### OrderItem (line)
|
||||||
|
|
||||||
|
- Standard
|
||||||
|
- `OrderId`
|
||||||
|
- `Product2Id`
|
||||||
|
- `PricebookEntryId`
|
||||||
|
- `Quantity`
|
||||||
|
- `UnitPrice`
|
||||||
|
|
||||||
|
- Custom
|
||||||
|
- `Billing_Cycle__c` (Picklist)
|
||||||
|
- `ConfigOptions_JSON__c` (Long Text) (optional; not used for pricing)
|
||||||
|
- `Item_Type__c` (Picklist: Service | Installation | Add‑on)
|
||||||
|
|
||||||
|
Notes
|
||||||
|
|
||||||
|
- Since one Order represents one service, all service configuration fields live on the Order header. OrderItems are generated from the header:
|
||||||
|
- Item_Type = Service: Product2 = main service SKU; Billing_Cycle\_\_c typically Monthly.
|
||||||
|
- Item_Type = Installation: Product2 = installation SKU selected from `Installment_Plan__c`/`Weekend_Install__c`; Billing_Cycle\_\_c = Onetime or Monthly (for installments).
|
||||||
|
- Item_Type = Add‑on (e.g., Hikari Denwa): separate OrderItem with its own Product2.
|
||||||
|
|
||||||
|
## WHMCS
|
||||||
|
|
||||||
|
### Users & Clients (created on signup)
|
||||||
|
|
||||||
|
- Create WHMCS User (User-level identity and login)
|
||||||
|
- `firstname` ← portal signup `firstName`
|
||||||
|
- `lastname` ← portal signup `lastName`
|
||||||
|
- `email` ← portal signup `email`
|
||||||
|
- `password` ← portal signup `password`
|
||||||
|
|
||||||
|
- Create WHMCS Client (billing profile)
|
||||||
|
- `firstname` ← portal signup `firstName`
|
||||||
|
- `lastname` ← portal signup `lastName`
|
||||||
|
- `email` ← portal signup `email`
|
||||||
|
- `phonenumber?` ← portal signup `phone?`
|
||||||
|
- `companyname?` ← portal signup `company?`
|
||||||
|
|
||||||
|
- Link User ↔ Client
|
||||||
|
- `user_id` ← created User id
|
||||||
|
- `client_id` ← created Client id
|
||||||
|
|
||||||
|
- Set Client address
|
||||||
|
- `address1` ← portal address `street`
|
||||||
|
- `address2` ← portal address `addressLine2`
|
||||||
|
- `city` ← portal address `city`
|
||||||
|
- `state` ← portal address `state`
|
||||||
|
- `postcode` ← portal address `postalCode`
|
||||||
|
- `country` (ISO 2-letter) ← portal address `country`
|
||||||
|
|
||||||
|
- Set Client custom field
|
||||||
|
- `CustomerNumber` (id/name to confirm) ← Salesforce Account Customer Number (provided via SF Number)
|
||||||
|
|
||||||
|
- Payment methods
|
||||||
|
- Managed in WHMCS UI; portal uses SSO and checks via `GetPayMethods`; no PAN stored in portal
|
||||||
|
|
||||||
|
### Orders & provisioning
|
||||||
|
|
||||||
|
- Replica Product Strategy (preferred)
|
||||||
|
- Each sellable SKU is its own Product2 and WHMCS product (e.g., Internet Platinum vs Silver, IPoE‑HGW vs BYOR, Installment plan variants, Hikari Denwa, etc.).
|
||||||
|
- Pricing and terms come from the chosen product; we avoid config option–based pricing.
|
||||||
|
|
||||||
|
- AddOrder (parameters used)
|
||||||
|
- `clientid` ← portal mapping `sfAccountId` → `whmcsClientId`
|
||||||
|
- `pid[]` ← for each OrderItem (Service/Installation/Add‑on) `Product2.WHMCS_Product_Id__c`
|
||||||
|
- `billingcycle` ← OrderItem `Billing_Cycle__c`
|
||||||
|
- `promocode?` ← Salesforce `Order.Promo_Code__c` or line-level
|
||||||
|
- `notes` ← include `sfOrderId=<Salesforce Order Id>`
|
||||||
|
- `noinvoice?` ← typically `0`
|
||||||
|
- `noemail?` ← typically `0`
|
||||||
|
- (Gateway) We do not pass `paymentmethod`; WHMCS uses the client's default gateway/payment method on file.
|
||||||
|
|
||||||
|
- AcceptOrder (no body fields; runs against created order)
|
||||||
|
|
||||||
|
- Results → Salesforce
|
||||||
|
- `orderid` → `Order.WHMCS_Order_ID__c`
|
||||||
|
|
||||||
|
### Invoices & pay methods
|
||||||
|
|
||||||
|
- GetInvoices (surface in portal)
|
||||||
|
- `clientid` ← portal mapping `whmcsClientId`
|
||||||
|
- filters as needed (date/status)
|
||||||
|
|
||||||
|
- GetPayMethods (gate checkout)
|
||||||
|
- `clientid` ← portal mapping `whmcsClientId`
|
||||||
|
|
||||||
|
- SSO links (open WHMCS UI)
|
||||||
|
- invoice view/pay/download, payment methods screen
|
||||||
|
|
||||||
|
## Portal BFF
|
||||||
|
|
||||||
|
- Mappings table
|
||||||
|
- `userId`, `whmcsClientId`, `sfAccountId`, timestamps
|
||||||
|
|
||||||
|
- Orchestration record (internal)
|
||||||
|
- `sfOrderId`, `status`, `items/config`, `whmcsOrderId?`, `whmcsServiceIds?`, idempotency keys, timestamps
|
||||||
|
|
||||||
|
## Mappings Summary
|
||||||
|
|
||||||
|
- Order header → WHMCS
|
||||||
|
- `sfOrderId` goes into WHMCS `notes` for idempotency tracing
|
||||||
|
- `AccountId` resolves to `clientid` via mapping table
|
||||||
|
|
||||||
|
- OrderItem line → WHMCS
|
||||||
|
- `Product2.WHMCS_Product_Id__c` → `pid[]`
|
||||||
|
- `OrderItem.Billing_Cycle__c` → `billingcycle`
|
||||||
|
- Identity/porting fields (EID, MNP, MVNO, etc.) live on Order and are used only for activation API, not stored in WHMCS
|
||||||
|
- Price mapping: Product2 encodes plan tier/access mode/apt type in the SKU; we select the corresponding `PricebookEntry` in the Portal pricebook.
|
||||||
|
|
||||||
|
- Post-provisioning write-back
|
||||||
|
- `WHMCS_Order_ID__c` on Order; `WHMCS_Service_ID__c` on each OrderItem
|
||||||
|
|
||||||
|
## Business Rules (data implications)
|
||||||
|
|
||||||
|
- Home Internet address
|
||||||
|
- Billing address equals service address; no separate service address fields in portal.
|
||||||
|
- Snapshot billing to Order BillTo\* at checkout; do not sync ongoing changes to Salesforce.
|
||||||
|
|
||||||
|
- Single Internet per account
|
||||||
|
- Enforced in BFF: before creating an Order with Internet Product2, check WHMCS `GetClientsProducts` for existing Internet services (active/pending/suspended).
|
||||||
|
- Optional SF guardrail: validation/Flow prevents OrderItem with Internet Product2 when account already has an Internet service (based on WHMCS write-backs or nightly sync).
|
||||||
|
|
||||||
|
- One fulfillment type per Order
|
||||||
|
- BFF groups cart items by fulfillment type and creates separate Orders (e.g., one for eSIM, one for Home Internet) so Order-level activation fields remain consistent.
|
||||||
105
docs/PORTAL-FLOW.md
Normal file
105
docs/PORTAL-FLOW.md
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
# Portal Ordering & Provisioning – Flow
|
||||||
|
|
||||||
|
This document explains the end-to-end customer and operator flow, systems involved, and the request/response choreography. For data model and field-level details, see `PORTAL-DATA-MODEL.md`.
|
||||||
|
|
||||||
|
## Architecture at a Glance
|
||||||
|
|
||||||
|
- Salesforce: Product2 + PricebookEntry (catalog & eligibility), Order/OrderItem (review/trigger), reporting
|
||||||
|
- WHMCS: customer profile (authoritative), payment methods, invoices, subscriptions, provisioning
|
||||||
|
- Portal BFF: orchestration and ID mappings; centralized logging (dino)
|
||||||
|
|
||||||
|
## Customer Flow
|
||||||
|
|
||||||
|
1. Signup
|
||||||
|
|
||||||
|
- Inputs: email, confirm email, password, confirm password, first/last name, optional company/phone, Customer Number (SF Number)
|
||||||
|
- Actions: create portal user, create WHMCS client with Customer Number custom field, create mapping (userId ↔ whmcsClientId ↔ sfAccountId)
|
||||||
|
- Email: Welcome (customer, CC support)
|
||||||
|
|
||||||
|
2. Add payment method (required)
|
||||||
|
|
||||||
|
- UI: CTA opens WHMCS payment methods via SSO
|
||||||
|
- API: BFF checks hasPaymentMethod via WHMCS GetPayMethods; gate checkout until true
|
||||||
|
|
||||||
|
3. Browse catalog and configure
|
||||||
|
|
||||||
|
- BFF serves Product2 catalog (portal-visible only), with personalization for Internet based on Account eligibility fields
|
||||||
|
- Product detail collects configurable options
|
||||||
|
|
||||||
|
4. Checkout (create Order)
|
||||||
|
|
||||||
|
- BFF: `POST /orders` → create Salesforce Order (Pending Review) + OrderItems (snapshots: Quantity, UnitPrice, Billing_Cycle, optional ConfigOptions)
|
||||||
|
- Portal shows “Awaiting review”
|
||||||
|
|
||||||
|
5. Review & Provision (operator)
|
||||||
|
|
||||||
|
- Operator approves in Salesforce and clicks “Provision in WHMCS” (Quick Action)
|
||||||
|
- Salesforce calls BFF `POST /orders/{sfOrderId}/provision` (signed & idempotent)
|
||||||
|
- BFF:
|
||||||
|
- Re-check hasPaymentMethod in WHMCS; if missing, set SF status Failed (Payment Required)
|
||||||
|
- eSIM: activate if applicable
|
||||||
|
- WHMCS: AddOrder → AcceptOrder
|
||||||
|
- Update Salesforce Order with WHMCS IDs and status (Provisioned or Failed)
|
||||||
|
- Emails: Activation/Provisioned
|
||||||
|
|
||||||
|
5a) Immediate vs Scheduled activation (eSIM example)
|
||||||
|
|
||||||
|
- Ops selects Activation Type on the Order:
|
||||||
|
- Immediate: BFF runs activation and WHMCS order right after approval.
|
||||||
|
- Scheduled: set Activation_Scheduled_At\_\_c; BFF runs at that time.
|
||||||
|
- BFF enqueues delayed jobs:
|
||||||
|
- Preflight (e.g., T-3 days): verify WHMCS payment method; notify if missing.
|
||||||
|
- Execute (at scheduled date): set Activation status → Activating; run eSIM activation; on success run WHMCS AddOrder → AcceptOrder; write back IDs and set Activation → Activated; on error set Activation → Failed and log error fields.
|
||||||
|
- Manual override “Activate Now” can trigger the same execute path immediately.
|
||||||
|
|
||||||
|
Scheduling options
|
||||||
|
|
||||||
|
- Option A: Salesforce Flow (no portal job)
|
||||||
|
- Record-Triggered Flow on Order with a Scheduled Path at `Activation_Scheduled_At__c` when `Status = Approved`, `Activation_Type__c = Scheduled`, `Activation_Status__c = Not Started`.
|
||||||
|
- Action: Flow HTTP Callout (Named Credential) → `POST /orders/{sfOrderId}/provision` with headers `Idempotency-Key`, `X-Timestamp`, `X-Nonce`.
|
||||||
|
- Alternative: Scheduled-Triggered Flow (every 15m) querying due Orders (NOW() ≥ Activation_Scheduled_At\_\_c) and invoking the same callout.
|
||||||
|
- Option B: Portal BFF delayed jobs (BullMQ)
|
||||||
|
- Use when you want retry/backoff control and consolidated observability in BFF.
|
||||||
|
|
||||||
|
Status interaction (business vs technical)
|
||||||
|
|
||||||
|
- Order Status: Pending Review → Approved → (Completed/Cancelled).
|
||||||
|
- Activation Status: Not Started → Activating → Activated/Failed.
|
||||||
|
- UI: Show Activation fields only when Order Status is Approved/Completed.
|
||||||
|
|
||||||
|
6. Completion
|
||||||
|
|
||||||
|
- Subscriptions and invoices show in portal from WHMCS via BFF endpoints
|
||||||
|
- Order status in portal reflects Salesforce status
|
||||||
|
|
||||||
|
## API Surface (BFF)
|
||||||
|
|
||||||
|
- Auth: `POST /auth/signup` (requires `sfNumber` = Customer Number)
|
||||||
|
- Billing: `GET /billing/payment-methods/summary`, `POST /auth/sso-link`
|
||||||
|
- Catalog: `GET /catalog`, `GET /catalog/personalized`
|
||||||
|
- Orders: `POST /orders`, `GET /orders/:sfOrderId`, `POST /orders/:sfOrderId/provision`
|
||||||
|
- eSIM actions: `POST /subscriptions/:id/reissue-esim`, `POST /subscriptions/:id/topup`
|
||||||
|
|
||||||
|
## Security & Reliability
|
||||||
|
|
||||||
|
- Salesforce → BFF: Named Credentials + HMAC headers; IP allowlisting; 5m TTL; Idempotency-Key
|
||||||
|
- WHMCS: timeouts, retries, circuit breakers; redact sensitive data in logs
|
||||||
|
- Idempotency: checkout keyed by cart hash; provisioning keyed by Idempotency-Key; include `sfOrderId` in WHMCS notes
|
||||||
|
- Observability: correlation IDs, metrics, alerts on failures/latency
|
||||||
|
|
||||||
|
## Business Rules
|
||||||
|
|
||||||
|
- Home Internet: billing address equals service address.
|
||||||
|
- Capture once after signup and save to WHMCS; at checkout only verify.
|
||||||
|
- Snapshot the same address onto the Salesforce Order (Bill To). No separate service address UI.
|
||||||
|
|
||||||
|
- Single Internet per account: disallow a second Home Internet order.
|
||||||
|
- Backend: `POST /orders` checks WHMCS client services (active/pending/suspended) for Internet; if present, return 409 and a manage link.
|
||||||
|
- Frontend: hide/disable Internet products when such a service exists; show “Manage/Upgrade” CTA.
|
||||||
|
- Salesforce (optional): validation/Flow to prevent Internet lines when the account already has an Internet service.
|
||||||
|
|
||||||
|
- One service per Order (1:1 with Opportunity)
|
||||||
|
- Cart is split so each selected service becomes its own Salesforce Order containing exactly one OrderItem.
|
||||||
|
- Each Order links to exactly one `Opportunity` (1 opportunity represents 1 service).
|
||||||
|
- Activation fields (`Activation_Type__c`, `Activation_Scheduled_At__c`, `Activation_Status__c`) apply to that single service.
|
||||||
|
- Result: for two eSIMs (even same date), we create two Orders, two Opportunities, two WHMCS Orders/Invoices/Services.
|
||||||
460
docs/PORTAL-ORDERING-PROVISIONING.md
Normal file
460
docs/PORTAL-ORDERING-PROVISIONING.md
Normal file
@ -0,0 +1,460 @@
|
|||||||
|
# Portal Ordering & Provisioning – Overview (Index)
|
||||||
|
|
||||||
|
This is the high-level overview. The spec is now split into two focused documents:
|
||||||
|
|
||||||
|
- `PORTAL-FLOW.md` – architecture and end-to-end flow
|
||||||
|
- `PORTAL-DATA-MODEL.md` – objects, fields, and mappings
|
||||||
|
|
||||||
|
Below is a concise summary; see the two docs above for details.
|
||||||
|
|
||||||
|
- Backend: NestJS BFF (`apps/bff`) with existing integrations: WHMCS, Salesforce
|
||||||
|
- Frontend: Next.js portal (`apps/portal`)
|
||||||
|
- Billing: WHMCS (invoices, payment methods, subscriptions)
|
||||||
|
- Control plane: Salesforce (review/approval, provisioning trigger)
|
||||||
|
- Logging: centralized logger "dino" – do not introduce alternate loggers
|
||||||
|
|
||||||
|
We require a Customer Number (SF Number) at signup and gate checkout on the presence of a WHMCS payment method. Orchestration runs in the BFF; Salesforce reviews and triggers provisioning.
|
||||||
|
|
||||||
|
## 0) Architecture at a Glance
|
||||||
|
|
||||||
|
- Source of truth
|
||||||
|
- Salesforce: Catalog (Product2 + PricebookEntry with portal fields), eligibility, order review/trigger, reporting
|
||||||
|
- WHMCS: Customer profile, payment methods, invoices, subscriptions (authoritative)
|
||||||
|
- Portal BFF: Orchestration + ID mappings (no customer data authority)
|
||||||
|
- Salesforce data model (three-object pattern)
|
||||||
|
- `Product2` (+ `PricebookEntry`) with portal fields (catalog + WHMCS mapping)
|
||||||
|
- `Order` (header; one per checkout)
|
||||||
|
- `OrderItem` (child; one per selected product) → references `Product2` (and PricebookEntry)
|
||||||
|
- Provisioning
|
||||||
|
- Operator approves in Salesforce → Quick Action calls BFF → BFF reads Order + OrderItems, dereferences `Product2` portal fields, calls WHMCS `AddOrder` → `AcceptOrder`, then writes back WHMCS IDs to Order/OrderItems
|
||||||
|
|
||||||
|
## 1) Customer Experience
|
||||||
|
|
||||||
|
1. Signup (Customer Number required)
|
||||||
|
- User provides: Email, Confirm Email, Password, Confirm Password, First/Last Name, optional Company/Phone, and Customer Number (SF Number).
|
||||||
|
- Portal validates and links to the existing Salesforce Account using the SF Number; if email differs, proceed and auto-create a Salesforce Case for CS (details in data model doc).
|
||||||
|
- WHMCS client is always created on signup (no pre-existing clients expected). WHMCS custom field for Customer Number must be set to the SF Number.
|
||||||
|
- Mapping is stored: `portalUserId ↔ whmcsClientId ↔ sfAccountId`.
|
||||||
|
|
||||||
|
2. Add payment method (required before checkout)
|
||||||
|
- Portal shows an “Add payment method” CTA that opens WHMCS payment methods via SSO (`POST /auth/sso-link` → `index.php?rp=/account/paymentmethods`).
|
||||||
|
- Portal checks `GET /billing/payment-methods/summary` to confirm presence before enabling checkout.
|
||||||
|
|
||||||
|
3. Browse catalog and configure
|
||||||
|
- `/catalog` lists products from BFF `GET /catalog` (reads Salesforce Product2 via BFF).
|
||||||
|
- Product detail pages collect configurable options; checkout button disabled until payment method exists.
|
||||||
|
|
||||||
|
4. Place order
|
||||||
|
- `POST /orders` creates a Salesforce Order (Pending Review) and stores orchestration state in BFF. Portal shows “Awaiting review”.
|
||||||
|
|
||||||
|
5. Review & Provision (operator in Salesforce)
|
||||||
|
- Operator reviews/approves. Quick Action “Provision in WHMCS” invokes BFF `POST /orders/{sfOrderId}/provision`.
|
||||||
|
- BFF validates payment method, (for eSIM) calls activation API, then `AddOrder` and `AcceptOrder` in WHMCS, updates Salesforce Order fields/status.
|
||||||
|
|
||||||
|
6. Completion
|
||||||
|
- Subscriptions and invoices appear in portal (`/subscriptions`, `/billing/invoices`). Pay via WHMCS SSO links.
|
||||||
|
|
||||||
|
## 1.1 Email Notifications
|
||||||
|
|
||||||
|
We will send operational emails at key events (no email validation step required at signup):
|
||||||
|
|
||||||
|
- Signup success: send Welcome email to customer; CC support.
|
||||||
|
- eSIM activation: send Activation email to customer; CC support.
|
||||||
|
- Order provisioned: send Provisioned/Next steps email to customer.
|
||||||
|
|
||||||
|
Implementation notes:
|
||||||
|
|
||||||
|
- Reuse centralized logger; no sensitive data in logs.
|
||||||
|
- Add a lightweight `EmailService` abstraction in BFF using existing modules style; queue via BullMQ jobs for reliability (Jobs module already present). Transport to be configured (SMTP/SendGrid) via env.
|
||||||
|
- Templates stored server-side; configurable CC list via env.
|
||||||
|
|
||||||
|
## 2) Backend Contracts (BFF)
|
||||||
|
|
||||||
|
### 2.1 Auth & Identity
|
||||||
|
|
||||||
|
- Modify `POST /auth/signup` (exists) to require `sfNumber: string` (Customer Number).
|
||||||
|
- Steps:
|
||||||
|
- Validate request; check portal user exists by email; if exists → error (prompt login/reset).
|
||||||
|
- Salesforce: find Account by Customer Number (SF Number). If not found → error.
|
||||||
|
- WHMCS: create client unconditionally for this flow. Set Customer Number custom field to SF Number.
|
||||||
|
- Create portal user and store mapping.
|
||||||
|
- Send Welcome email (to customer) with support on CC.
|
||||||
|
- If email mismatch is detected between inputs/systems (e.g., SF Account email vs signup email), automatically create a Salesforce Case to notify CS team and include both emails and Account reference.
|
||||||
|
- Security: sanitize errors; never log passwords; use centralized logger.
|
||||||
|
|
||||||
|
- `POST /auth/claim` (optional future): If we ever separate claim flow from signup.
|
||||||
|
|
||||||
|
### 2.2 Identity & Data Mapping (Portal ↔ Salesforce ↔ WHMCS)
|
||||||
|
|
||||||
|
- Systems of Record
|
||||||
|
- Salesforce: Catalog (Product2/PricebookEntry) and process control (Order approvals/status), account eligibility fields. Read-only from portal at runtime except Order creations/updates.
|
||||||
|
- WHMCS: Customer details, payment methods, invoices, subscriptions, provisioning outcomes. Source of truth for customer contact/billing data.
|
||||||
|
- Portal (BFF): Orchestration state and ID mappings only.
|
||||||
|
|
||||||
|
- Mapping Records (existing):
|
||||||
|
- `portalUserId ↔ whmcsClientId ↔ sfAccountId` stored in BFF mappings service.
|
||||||
|
- Customer Number (SF Number) is provided once at signup to create the mapping; we do not ask again.
|
||||||
|
|
||||||
|
- Portal User → Salesforce Account (lookup only)
|
||||||
|
- Authoritative lookup key: Customer Number on Account (provided via SF Number at signup).
|
||||||
|
- We do not sync profile/address changes from portal to Salesforce.
|
||||||
|
|
||||||
|
- Portal User → WHMCS Client (authoritative for customer profile)
|
||||||
|
- Email → `email`
|
||||||
|
- First/Last Name → `firstname`, `lastname`
|
||||||
|
- Company → `companyname` (optional)
|
||||||
|
- Phone → `phonenumber`
|
||||||
|
- Address: `address1`, `address2`, `city`, `state`, `postcode`, `country` (ISO 2-letter)
|
||||||
|
- Custom Field (Customer Number) → set to SF Number (id/name TBD; currently used in mapping)
|
||||||
|
- Notes (optional) → include correlation IDs / SF refs
|
||||||
|
|
||||||
|
- Discrepancy handling
|
||||||
|
- If SF Account email differs from signup email, proceed and auto-create a Salesforce Case for CS with both emails and Account reference (for review). No sync/write to SF.
|
||||||
|
|
||||||
|
### 2.3 Address Capture (WHMCS only)
|
||||||
|
|
||||||
|
- Capture Requirements
|
||||||
|
- Required: `street`, `city`, `state`, `postalCode`, `country`
|
||||||
|
- Optional: `addressLine2`, `buildingName`, `roomNumber`, `phone`
|
||||||
|
- Validation: client-side + server-side; normalize country to ISO code.
|
||||||
|
|
||||||
|
- UX Flow
|
||||||
|
- After signup, prompt to complete Address before catalog/checkout.
|
||||||
|
- Dashboard banner if address incomplete.
|
||||||
|
|
||||||
|
- API Usage
|
||||||
|
- Extend `PATCH /api/me/billing` to update WHMCS address fields only. No write to Salesforce.
|
||||||
|
- Centralized logging; redact PII.
|
||||||
|
|
||||||
|
### 2.4 Billing
|
||||||
|
|
||||||
|
- `GET /billing/payment-methods/summary` (new)
|
||||||
|
- Returns `{ hasPaymentMethod: boolean }` using WHMCS `GetPayMethods` for mapped client.
|
||||||
|
|
||||||
|
- `POST /auth/sso-link` (exists)
|
||||||
|
- Used to open WHMCS payment methods and invoice/pay pages.
|
||||||
|
|
||||||
|
### 2.5 Catalog (Salesforce Product2 as Source of Truth)
|
||||||
|
|
||||||
|
We will not expose WHMCS catalog directly. Instead, Salesforce `Product2` (with `PricebookEntry`) will be the catalog, augmented with a small set of custom fields used by the portal and BFF.
|
||||||
|
|
||||||
|
Custom fields on `Product2` (proposal; confirm API names):
|
||||||
|
|
||||||
|
- Identity & Display
|
||||||
|
- `Portal_Category__c` (Picklist): Internet | eSIM | VPN | Other
|
||||||
|
- `Portal_Description__c` (Long Text)
|
||||||
|
- `Portal_Feature_Bullets__c` (Long Text)
|
||||||
|
- `Portal_Hero_Image_URL__c` (URL)
|
||||||
|
- `Portal_Tags__c` (Text)
|
||||||
|
- `Portal_Sort_Order__c` (Number)
|
||||||
|
- `Portal_Visible__c` (Checkbox, default true)
|
||||||
|
- `Portal_Valid_From__c` / `Portal_Valid_Until__c` (Date)
|
||||||
|
- Terms/Options
|
||||||
|
- `Portal_Billing_Cycle__c` (Picklist): Monthly | Quarterly | Semiannually | Annually
|
||||||
|
- `Portal_Max_Quantity__c` (Number, default 1)
|
||||||
|
- `Portal_Requires_Payment_Method__c` (Checkbox, default true)
|
||||||
|
- `Portal_ConfigOptions_JSON__c` (Long Text) – defaults and allowed values
|
||||||
|
- Eligibility (Internet personalization)
|
||||||
|
- `Portal_Eligibility_Dwelling__c` (Picklist): Home | Apartment | Any
|
||||||
|
- `Portal_Eligibility_Tier__c` (Picklist): 1G | 100Mb | Any
|
||||||
|
- `Portal_Eligibility_Region__c` (Text) (optional)
|
||||||
|
- WHMCS Mapping
|
||||||
|
- `WHMCS_Product_Id__c` (Number)
|
||||||
|
- `WHMCS_Notes_Template__c` (Long Text)
|
||||||
|
- `eSIM_Settings_JSON__c` (Long Text)
|
||||||
|
|
||||||
|
Endpoints (BFF)
|
||||||
|
|
||||||
|
- `GET /catalog` (exists): return public offerings from `Product2` where `Portal_Visible__c = true` and within validity dates; price via `PricebookEntry` for the portal pricebook.
|
||||||
|
- `GET /catalog/personalized` (new):
|
||||||
|
- Authenticated: infer `sfAccountId` from mapping. We only check the SF Number once during signup to create the mapping.
|
||||||
|
- Query `Product2` filtered by `Portal_Visible__c` and validity, then apply eligibility filters using Account fields (e.g., dwelling/tier). eSIM/VPN are always included.
|
||||||
|
|
||||||
|
- Caching & Invalidation
|
||||||
|
- Cache global catalog 15m; cache personalized results per `sfAccountId` 5m.
|
||||||
|
- Optional Salesforce webhook to bust cache on `Product_Offering__c` changes.
|
||||||
|
|
||||||
|
### 2.6 Orders & Provisioning
|
||||||
|
|
||||||
|
- `POST /orders` (new)
|
||||||
|
- Body: `{ items: { productId, billingCycle, configOptions?, notes? }[], promoCode?, notes? }`
|
||||||
|
- Server-side checks: require WHMCS mapping; require `hasPaymentMethod=true`.
|
||||||
|
- Actions: Create Salesforce Order (Pending Review), persist orchestration record (sfOrderId, items/config, status=Pending Review, idempotency), return `{ sfOrderId, status }`.
|
||||||
|
|
||||||
|
- `GET /orders/:sfOrderId` (new)
|
||||||
|
- Returns orchestration status and relevant IDs; portal polls for updates.
|
||||||
|
|
||||||
|
- `POST /orders/:sfOrderId/provision` (new; invoked from Salesforce only)
|
||||||
|
- Auth: Named Credentials + signed headers (HMAC with timestamp/nonce) + IP allowlisting; require `Idempotency-Key`.
|
||||||
|
- Steps:
|
||||||
|
- Re-check payment method; if missing: set SF `Provisioning_Status__c=Failed`, `Error=Payment Method Missing`; return 409.
|
||||||
|
- If eSIM: call activation API; on success store masked ICCID/EID; on failure: update SF as Failed and return 502.
|
||||||
|
- WHMCS `AddOrder` (include `sfOrderId` in notes); then `AcceptOrder` to provision and create invoice/subscription.
|
||||||
|
- Update Salesforce Order fields and status to Provisioned; persist WHMCS IDs in orchestration record; return summary.
|
||||||
|
- Send Activation/Provisioned email depending on product and step outcome.
|
||||||
|
|
||||||
|
## 3) Salesforce
|
||||||
|
|
||||||
|
### 3.1 Account matching
|
||||||
|
|
||||||
|
- Personalization Fields (Internet Eligibility)
|
||||||
|
- Use the Account’s serviceability/plan eligibility field(s) to decide which Internet product variants to show.
|
||||||
|
- Examples (to confirm API names and values):
|
||||||
|
- `Dwelling_Type__c`: `Home` | `Apartment`
|
||||||
|
- `Internet_Tier__c`: `1G` | `100Mb`
|
||||||
|
- The BFF personalization endpoint maps these to curated catalog SKUs.
|
||||||
|
|
||||||
|
- Customer Number (SF Number) is authoritative. Signup requires it. We find Account by that number.
|
||||||
|
- Mirror SF Number to WHMCS client custom field.
|
||||||
|
- If a discrepancy is found (e.g., Account has a different email than signup), create a Salesforce Case automatically with context so CS can triage; proceed with signup (no hard block), but flag the portal user for review.
|
||||||
|
|
||||||
|
### 3.2 Order fields
|
||||||
|
|
||||||
|
- Add the following fields to `Order`:
|
||||||
|
- `Provisioning_Status__c` (Pending Review, Approved, Activating, Provisioned, Failed)
|
||||||
|
- `Provisioning_Error_Code__c` (short)
|
||||||
|
- `Provisioning_Error_Message__c` (sanitized)
|
||||||
|
- `WHMCS_Order_ID__c`
|
||||||
|
- `ESIM_ICCID__c` (masked), `Last_Provisioning_At__c`, `Attempt_Count__c`
|
||||||
|
|
||||||
|
#### 3.2.1 Salesforce Order API & Required Fields (to confirm)
|
||||||
|
|
||||||
|
- Object: `Order`
|
||||||
|
- Required fields for creation (proposal):
|
||||||
|
- `AccountId` (from SF Number lookup)
|
||||||
|
- `EffectiveDate` (today)
|
||||||
|
- `Status` (set to "Pending Review")
|
||||||
|
- `Description` (optional: include product summary)
|
||||||
|
- Custom: `Provisioning_Status__c = Pending Review`
|
||||||
|
- Optional link: `OpportunityId` (if created/available)
|
||||||
|
- On updates during provisioning:
|
||||||
|
- Set `Provisioning_Status__c` → Activating → Provisioned/Failed
|
||||||
|
- Store `WHMCS_Order_ID__c`
|
||||||
|
- For eSIM: masked `ESIM_ICCID__c`
|
||||||
|
|
||||||
|
#### 3.2.2 Order Line Representation (Salesforce-side, to confirm)
|
||||||
|
|
||||||
|
Options (pick one):
|
||||||
|
|
||||||
|
1. Use standard `OrderItem` with `Product2` and Pricebooks (recommended)
|
||||||
|
- Pros: native SF pricing and reporting; clean standard model
|
||||||
|
- Cons: maintain `Product2` and `PricebookEntry` for all offerings
|
||||||
|
- Fields per `OrderItem` (standard):
|
||||||
|
- `OrderId`, `Product2Id`, `PricebookEntryId`, `Quantity`, `UnitPrice`
|
||||||
|
- Custom fields to add on `OrderItem`:
|
||||||
|
- `Billing_Cycle__c` (Picklist)
|
||||||
|
- `ConfigOptions_JSON__c` (Long Text)
|
||||||
|
|
||||||
|
2. Custom child object `Order_Offering__c`
|
||||||
|
- Not used; we standardize on `OrderItem`.
|
||||||
|
|
||||||
|
Decision: Use standard `OrderItem` with `Product2` and portal fields for mapping.
|
||||||
|
|
||||||
|
We will build the BFF payload for WHMCS from these line records plus the Order header.
|
||||||
|
|
||||||
|
#### 3.2.3 Salesforce ↔ WHMCS Order Mapping
|
||||||
|
|
||||||
|
- Header mapping
|
||||||
|
- SF `Order.Id` → included in WHMCS `notes` as `sfOrderId=<Id>`
|
||||||
|
- SF `AccountId` → via portal mapping to `whmcsClientId` → `AddOrder.clientid`
|
||||||
|
- SF `Promo_Code__c` (if on header) → `AddOrder.promocode`
|
||||||
|
- SF `Provisioning_Status__c` controls operator flow; not sent to WHMCS
|
||||||
|
|
||||||
|
- Line mapping (per OrderItem)
|
||||||
|
- Product2 `WHMCS_Product_Id__c` → `AddOrder.pid[]`
|
||||||
|
- SF `Billing_Cycle__c` → `AddOrder.billingcycle` (string)
|
||||||
|
- SF `ConfigOptions_JSON__c` → `AddOrder.configoptions`
|
||||||
|
- Quantity → replicate product ID in `pid[]` or use config option/quantity if applicable
|
||||||
|
|
||||||
|
- After `AddOrder`:
|
||||||
|
- Call `AcceptOrder` to provision; capture `orderid` from response
|
||||||
|
- Update SF `WHMCS_Order_ID__c`; set `Provisioning_Status__c = Provisioned` on success
|
||||||
|
- On error, set `Provisioning_Status__c = Failed` and write short, sanitized `Provisioning_Error_Code__c` / `Provisioning_Error_Message__c`
|
||||||
|
|
||||||
|
### 3.3 Quick Action / Flow
|
||||||
|
|
||||||
|
- Quick Action “Provision in WHMCS” calls BFF `POST /orders/{sfOrderId}/provision` with headers:
|
||||||
|
- `Authorization` (Named Credentials)
|
||||||
|
- `Idempotency-Key` (UUID)
|
||||||
|
- `X-Timestamp`, `X-Nonce`, `X-Signature` (HMAC of method+path+timestamp+nonce+body)
|
||||||
|
|
||||||
|
### 3.4 UI
|
||||||
|
|
||||||
|
### 3.5 Catalog → Order → Provisioning Linkage (Clean Mapping)
|
||||||
|
|
||||||
|
- Single source of mapping truth: Product2 portal fields
|
||||||
|
- `WHMCS_Product_Id__c`, `Portal_ConfigOptions_JSON__c`, and `Provisioning_Flow__c` live on Product2.
|
||||||
|
- Do not duplicate these fields on `OrderItem`; each line references Product2 and price from PricebookEntry.
|
||||||
|
- Snapshot only what can change over time: `UnitPrice`, `Billing_Cycle__c`, and `Quantity` on the line.
|
||||||
|
|
||||||
|
- Order construction (by portal at checkout)
|
||||||
|
- Create `Order` header with `Provisioning_Status__c = Pending Review`.
|
||||||
|
- For each cart item, create a line (either `OrderItem` with custom fields or `Order_Offering__c`) that includes:
|
||||||
|
- `Product2Id` and `PricebookEntryId`
|
||||||
|
- `Quantity`, `UnitPrice__c`, `Billing_Cycle__c`
|
||||||
|
- Optional overrides in `ConfigOptions_JSON__c` (e.g., size, add-ons) based on user selection
|
||||||
|
|
||||||
|
- Provisioning (triggered from Salesforce)
|
||||||
|
- BFF receives `sfOrderId`, loads `Order` and its lines.
|
||||||
|
- For each line, dereference Product2 to fetch `WHMCS_Product_Id__c` and default config options, then merge with any line-level overrides in `ConfigOptions_JSON__c`.
|
||||||
|
- Build `AddOrder` payload using the mapping above; place `sfOrderId` in WHMCS `notes`.
|
||||||
|
- After `AcceptOrder`, write back:
|
||||||
|
- Header: `WHMCS_Order_ID__c`
|
||||||
|
- Header: `Provisioning_Status__c = Provisioned` on success; set error fields on failure (sanitized)
|
||||||
|
|
||||||
|
- Subscriptions linkage
|
||||||
|
- The authoritative subscription record lives in WHMCS.
|
||||||
|
|
||||||
|
This keeps the mapping clean and centralized in Product2 portal fields, while Orders/OrderItems act as a snapshot of the customer’s selection and price at time of checkout.
|
||||||
|
|
||||||
|
## 3.5 Flow Sanity Check
|
||||||
|
|
||||||
|
1. Catalog comes from Salesforce Product2 (filtered/personalized by Account eligibility).
|
||||||
|
2. Customer signs up with SF Number; portal creates WHMCS client and mapping; address/profile managed in WHMCS.
|
||||||
|
3. Checkout creates an SF `Order` and child lines (no provisioning yet).
|
||||||
|
4. Operator approves in SF and clicks Quick Action.
|
||||||
|
5. SF calls BFF to provision: BFF rechecks payment method (WHMCS), handles eSIM activation if needed, then `AddOrder` + `AcceptOrder` in WHMCS using mappings from Product2 portal fields referenced by the OrderItems.
|
||||||
|
6. BFF updates SF Order fields (`WHMCS_Order_ID__c`, etc.) and status; emails are sent as required.
|
||||||
|
7. Customer sees completed order; subscriptions/invoices appear from WHMCS data in the portal.
|
||||||
|
|
||||||
|
- LWC on `Order` to display provisioning status, errors, WHMCS IDs, and a Retry button.
|
||||||
|
|
||||||
|
## 4) Frontend (Portal)
|
||||||
|
|
||||||
|
- Signup page: add `sfNumber` field; validation and error messages for missing/invalid SF Number.
|
||||||
|
- Payment banner: dashboard shows CTA to add a payment method if none.
|
||||||
|
- Catalog: `/catalog` page using existing BFF endpoint.
|
||||||
|
- Product detail + Checkout:
|
||||||
|
- Checkout button disabled until `hasPaymentMethod=true` (via `GET /billing/payment-methods/summary`).
|
||||||
|
- On submit, call `POST /orders` and redirect to order status page with polling.
|
||||||
|
- Order status page: shows statuses (Pending Review → Activating → Provisioned/Failed), with links to Subscriptions and Invoices.
|
||||||
|
|
||||||
|
### 4.1 eSIM Self-service Actions (Service Detail)
|
||||||
|
|
||||||
|
- Actions available on an active eSIM subscription:
|
||||||
|
- Reissue eSIM: triggers BFF endpoint to call activation provider for a new profile, updates WHMCS notes/custom fields, sends email to customer.
|
||||||
|
- Top-up: triggers BFF to call provider top-up API; invoice/charges handled via WHMCS (AddOrder for add-on or gateway charge depending on implementation), sends email confirmation.
|
||||||
|
- UI: buttons gated by subscription status; confirmations and progress states.
|
||||||
|
|
||||||
|
## 5) Security, Idempotency, Observability
|
||||||
|
|
||||||
|
- Secrets in env/KMS, HTTPS-only, strict timeouts and retries with backoff in BFF external calls.
|
||||||
|
- Signed Salesforce → BFF requests with short TTL; IP allowlisting of Salesforce egress ranges.
|
||||||
|
- Idempotency keys for order creation and provisioning; include `sfOrderId` marker in WHMCS order notes.
|
||||||
|
- Logging: use centralized logger "dino" only; redact sensitive values; no payment data.
|
||||||
|
- Metrics: activation latency, WHMCS API error rates, provisioning success/failure, retries; alerts on anomalies.
|
||||||
|
|
||||||
|
## 6) Data Storage (minimal in BFF)
|
||||||
|
|
||||||
|
- Orchestration record: `sfOrderId`, items/config, status, masked eSIM identifiers, WHMCS order/service IDs, timestamps, idempotency keys.
|
||||||
|
- Mappings: `userId ↔ whmcsClientId ↔ sfAccountId`.
|
||||||
|
- No PANs, CVVs, or gateway tokens stored.
|
||||||
|
|
||||||
|
## 7) Work Items
|
||||||
|
|
||||||
|
1. Auth: require `sfNumber` in `SignupDto` and signup flow; lookup SF Account by Customer Number; align WHMCS custom field.
|
||||||
|
2. Billing: add `GET /billing/payment-methods/summary` and frontend gating.
|
||||||
|
3. Catalog UI: `/catalog` + product details pages.
|
||||||
|
4. Orders API: implement `POST /orders`, `GET /orders/:sfOrderId`, `POST /orders/:sfOrderId/provision`.
|
||||||
|
5. Salesforce: fields, Quick Action/Flow, Named Credential + signing; LWC for status.
|
||||||
|
6. WHMCS: add wrappers for `AddOrder`, `AcceptOrder`, `GetPayMethods` (if not already exposed).
|
||||||
|
7. Observability: correlation IDs, metrics, alerts; webhook processing for cache busting (optional).
|
||||||
|
8. Email: implement `EmailService` with provider; add BullMQ jobs for async sending; add templates for Signup, eSIM Activation, Provisioned.
|
||||||
|
9. eSIM Actions: implement `POST /subscriptions/:id/reissue-esim` and `POST /subscriptions/:id/topup` endpoints with BFF provider calls and WHMCS updates.
|
||||||
|
10. Future: Cancellations form → Salesforce Cancellations object submission (no immediate service cancel by customer).
|
||||||
|
|
||||||
|
## 8) Acceptance Criteria
|
||||||
|
|
||||||
|
- Signup requires Customer Number (SF Number) and links to the correct Salesforce Account and WHMCS client.
|
||||||
|
- Portal blocks checkout until a WHMCS payment method exists; SSO to WHMCS to add card.
|
||||||
|
- Orders are created in Salesforce and provisioned via BFF after operator trigger; idempotent and retriable.
|
||||||
|
- Customer sees clear order status and resulting subscriptions/invoices; sensitive details are not exposed.
|
||||||
|
|
||||||
|
## 9) WHMCS Field Mapping (Order Creation)
|
||||||
|
|
||||||
|
- `AddOrder` parameters to use:
|
||||||
|
- `clientid`: from user mapping
|
||||||
|
- `pid[]`: array of WHMCS product IDs (map from our catalog selection)
|
||||||
|
- `billingcycle`: `monthly` | `quarterly` | `semiannually` | `annually` | etc.
|
||||||
|
- `configoptions`: key/value for configurable options (from product detail form)
|
||||||
|
- `customfields`: include Customer Number (SF Number) and any order-specific data
|
||||||
|
- `paymentmethod`: WHMCS gateway system name (optional if default)
|
||||||
|
- `promocode`: if provided
|
||||||
|
- `notes`: include `sfOrderId=<Salesforce Order Id>` for idempotency tracing
|
||||||
|
- `noinvoice` / `noemail`: set to 0 to allow normal invoice + emails unless we handle emails ourselves
|
||||||
|
- After creation, call `AcceptOrder` to provision services and generate invoice/subscription as per WHMCS settings.
|
||||||
|
|
||||||
|
### 9.1 WHMCS Updates for eSIM Actions
|
||||||
|
|
||||||
|
- On Reissue:
|
||||||
|
- Update service custom fields (store masked new ICCID/EID if applicable), append to service notes with correlation ID and SF Order/Case references if any.
|
||||||
|
- Optionally create a zero-priced order for traceability or a billable add-on as business rules dictate.
|
||||||
|
- On Top-up:
|
||||||
|
- Create an add-on order or billable item/invoice through WHMCS; capture payment via existing payment method.
|
||||||
|
- Record top-up details in notes/custom fields.
|
||||||
|
|
||||||
|
## 10) Endpoint DTOs (Proposed)
|
||||||
|
|
||||||
|
- `POST /auth/signup`
|
||||||
|
- Request: `{ email, password, firstName, lastName, company?, phone?, sfNumber }`
|
||||||
|
- Response: `{ user, accessToken, refreshToken }`
|
||||||
|
|
||||||
|
- `GET /billing/payment-methods/summary`
|
||||||
|
- Response: `{ hasPaymentMethod: boolean }`
|
||||||
|
|
||||||
|
- `POST /orders`
|
||||||
|
- Request: `{ items: { productId: number; billingCycle: string; configOptions?: Record<string,string>; notes?: string }[]; promoCode?: string; notes?: string }`
|
||||||
|
- Response: `{ sfOrderId: string; status: 'Pending Review' }`
|
||||||
|
|
||||||
|
- `GET /orders/:sfOrderId`
|
||||||
|
- Response: `{ sfOrderId, status, whmcsOrderId?, whmcsServiceIds?: number[], lastUpdatedAt }`
|
||||||
|
|
||||||
|
- `POST /orders/:sfOrderId/provision` (SF only)
|
||||||
|
- Request headers: `Authorization`, `Idempotency-Key`, `X-Timestamp`, `X-Nonce`, `X-Signature`
|
||||||
|
- Response: `{ status: 'Provisioned' | 'Failed', whmcsOrderId?, whmcsServiceIds?: number[], errorCode?, errorMessage? }`
|
||||||
|
|
||||||
|
- `POST /subscriptions/:id/reissue-esim`
|
||||||
|
- Request: `{ reason?: string }`
|
||||||
|
- Response: `{ status: 'InProgress' | 'Completed' | 'Failed', activationRef?, maskedIccid?, errorMessage? }`
|
||||||
|
|
||||||
|
- `POST /subscriptions/:id/topup`
|
||||||
|
- Request: `{ amount?: number; packageCode?: string }`
|
||||||
|
- Response: `{ status: 'Completed' | 'Failed', invoiceId?, errorMessage? }`
|
||||||
|
|
||||||
|
## 11) Email Requirements
|
||||||
|
|
||||||
|
- Transport: configurable (SMTP/SendGrid) via env; no secrets logged.
|
||||||
|
- Events & templates (to be provided):
|
||||||
|
- Signup Welcome (customer, CC support)
|
||||||
|
- eSIM Activation (customer, CC support)
|
||||||
|
- Order Provisioned (customer)
|
||||||
|
- Include correlation ID and minimal order/service context; no sensitive values.
|
||||||
|
|
||||||
|
### 11.1 Email Provider Recommendation
|
||||||
|
|
||||||
|
- Primary: SendGrid API (robust deliverability, templates, analytics). Use API key via env; send via BullMQ job for resiliency.
|
||||||
|
- Fallback: SMTP (e.g., SES SMTP or company SMTP relay) for environments without SendGrid.
|
||||||
|
- Rationale: SendGrid simplifies templating and CC/BCC handling; API-based sending reduces SMTP variability. Keep centralized logging without leaking PII.
|
||||||
|
|
||||||
|
## 12) Open Questions (to confirm)
|
||||||
|
|
||||||
|
1. Salesforce
|
||||||
|
- Confirm the Customer Number field API name on `Account` used for lookup.
|
||||||
|
- Confirm the exact custom field API names on `Order` (`Provisioning_Status__c`, etc.).
|
||||||
|
- Should `OpportunityId` be mandatory for Orders we create?
|
||||||
|
2. WHMCS
|
||||||
|
- Confirm the custom field id/name for Customer Number (currently used in mapping; assumed id 198).
|
||||||
|
- Provide product ID mapping for Home Internet/eSIM/VPN and their configurable options keys.
|
||||||
|
- Preferred default `paymentmethod` gateway system name.
|
||||||
|
3. Email
|
||||||
|
- Preferred provider (SMTP vs SendGrid) and from/reply-to addresses.
|
||||||
|
- Support CC distribution list for ops; any BCC requirements?
|
||||||
|
- Provide or approve email templates (copy + branding).
|
||||||
|
4. eSIM Activation API
|
||||||
|
- Endpoint(s), auth scheme, required payload, success/failed response shapes.
|
||||||
|
- Which identifiers to store/mask (ICCID, EID, MSISDN) and masking rules.
|
||||||
|
5. Provisioning Trigger
|
||||||
|
- Manual only (Quick Action) or also auto on status change to Approved?
|
||||||
|
- Retry/backoff limits expected from SF side?
|
||||||
|
6. Cancellations
|
||||||
|
- Cancellation object API name in Salesforce; required fields; desired intake fields in portal form; who should be notified.
|
||||||
95
docs/PORTAL-ROADMAP.md
Normal file
95
docs/PORTAL-ROADMAP.md
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
# Portal – Development Roadmap (Step-by-Step)
|
||||||
|
|
||||||
|
This roadmap references `PORTAL-FLOW.md` (flows) and `PORTAL-DATA-MODEL.md` (objects/fields/mappings).
|
||||||
|
|
||||||
|
## Phase 1 – Foundations
|
||||||
|
|
||||||
|
1. Salesforce setup (Admin)
|
||||||
|
- Product2 custom fields: create all `Portal_*` and `WHMCS_*` fields listed in DATA MODEL.
|
||||||
|
- Pricebook: create “Portal” pricebook; add `PricebookEntry` records for visible Product2 items.
|
||||||
|
- Order fields: add `Provisioning_*`, `WHMCS_*`, `ESIM_ICCID__c`, `Attempt_Count__c`, `Last_Provisioning_At__c`.
|
||||||
|
- OrderItem fields: add `Billing_Cycle__c`, `ConfigOptions_JSON__c`, `WHMCS_Service_ID__c`.
|
||||||
|
- Quick Action: “Provision in WHMCS” to call BFF; configure Named Credentials + HMAC headers.
|
||||||
|
|
||||||
|
2. WHMCS setup (Admin)
|
||||||
|
- Create custom field on Client for Customer Number (note id/name).
|
||||||
|
- Confirm product IDs for Internet/eSIM/VPN and required config options.
|
||||||
|
- Confirm gateway system name for `paymentmethod`.
|
||||||
|
|
||||||
|
3. Portal BFF env & security
|
||||||
|
- Ensure env vars for Salesforce/WHMCS and logging are set; rotate secrets.
|
||||||
|
- Enable IP allowlisting for Salesforce → BFF; implement HMAC shared secret.
|
||||||
|
|
||||||
|
## Phase 2 – Identity & Billing
|
||||||
|
|
||||||
|
4. BFF: Signup requires SF Number
|
||||||
|
- Update `SignupDto` to require `sfNumber`.
|
||||||
|
- Flow: create portal user → create WHMCS User + Client → set Customer Number custom field → create mapping (userId, whmcsClientId, sfAccountId).
|
||||||
|
- On email discrepancy with Salesforce Account: create Salesforce Case (no block).
|
||||||
|
- Send Welcome email (EmailService via jobs).
|
||||||
|
|
||||||
|
5. Portal UI: Address & payment method
|
||||||
|
- Address step after signup; `PATCH /api/me/billing` to update WHMCS address fields.
|
||||||
|
- Payment methods page/button: `POST /auth/sso-link` to WHMCS payment methods; show banner on dashboard until `GET /billing/payment-methods/summary` is true.
|
||||||
|
|
||||||
|
## Phase 3 – Catalog
|
||||||
|
|
||||||
|
6. BFF: Catalog endpoints
|
||||||
|
- `GET /catalog`: read Product2 (Portal_Visible\_\_c & validity), price via PricebookEntry.
|
||||||
|
- `GET /catalog/personalized`: filter Product2 using Account eligibility fields.
|
||||||
|
|
||||||
|
7. Portal UI: Catalog & product detail
|
||||||
|
- Build `/catalog` listing; product detail pages for Internet/eSIM/VPN.
|
||||||
|
- Support configurable options via Product2 `Portal_ConfigOptions_JSON__c`.
|
||||||
|
|
||||||
|
## Phase 4 – Orders & Provisioning
|
||||||
|
|
||||||
|
8. BFF: Orders API
|
||||||
|
- `POST /orders`: create SF Order + OrderItems (snapshots: Quantity, UnitPrice, Billing_Cycle, ConfigOptions), status Pending Review; return `sfOrderId`.
|
||||||
|
- `GET /orders/:sfOrderId`: return orchestration status.
|
||||||
|
- `POST /orders/:sfOrderId/provision`: SF-only; recheck payment method; (eSIM) activate; WHMCS AddOrder → AcceptOrder; update SF with IDs/status; send emails.
|
||||||
|
|
||||||
|
9. Salesforce: Quick Action/Flow
|
||||||
|
- Implement button action to call BFF with Named Credentials + HMAC; pass Idempotency-Key.
|
||||||
|
|
||||||
|
10. Portal UI: Checkout & status
|
||||||
|
|
||||||
|
- Build checkout button gating on `hasPaymentMethod`; after order, show status page that polls `GET /orders/:sfOrderId`.
|
||||||
|
|
||||||
|
## Phase 5 – eSIM Extras & Emails
|
||||||
|
|
||||||
|
11. BFF: eSIM actions
|
||||||
|
|
||||||
|
- `POST /subscriptions/:id/reissue-esim`: call provider API; update WHMCS service notes/custom fields; email customer.
|
||||||
|
- `POST /subscriptions/:id/topup`: call provider API; create add-on or invoice in WHMCS; email customer.
|
||||||
|
|
||||||
|
12. Email templates & jobs
|
||||||
|
|
||||||
|
- Implement EmailService (SendGrid or SMTP) and queue jobs for: Signup Welcome, eSIM Activation, Order Provisioned.
|
||||||
|
|
||||||
|
## Phase 6 – Observability & Hardening
|
||||||
|
|
||||||
|
13. Observability
|
||||||
|
|
||||||
|
- Add correlation IDs across BFF, Salesforce calls, WHMCS calls.
|
||||||
|
- Metrics: provisioning latency, error rates, retries; alerts on anomalies.
|
||||||
|
|
||||||
|
14. Idempotency & resilience
|
||||||
|
|
||||||
|
- Cart hash idempotency for `POST /orders`.
|
||||||
|
- Idempotency-Key for `POST /orders/:sfOrderId/provision`.
|
||||||
|
- Include `sfOrderId` in WHMCS `notes` for duplicate protection.
|
||||||
|
|
||||||
|
15. Security reviews
|
||||||
|
|
||||||
|
- Confirm no PAN/PII leakage in logs; confirm TLS and secrets; rate limits on auth endpoints.
|
||||||
|
|
||||||
|
## Deliverables Checklist
|
||||||
|
|
||||||
|
- Salesforce fields created and secured (FLS/profiles)
|
||||||
|
- WHMCS Client custom field created; product IDs confirmed
|
||||||
|
- BFF endpoints implemented (auth/billing/catalog/orders/esim)
|
||||||
|
- Portal pages implemented (signup/address/catalog/detail/checkout/status)
|
||||||
|
- Quick Action wired and tested end-to-end
|
||||||
|
- Emails tested in dev/staging
|
||||||
|
- Monitoring and alerts configured
|
||||||
@ -3,6 +3,7 @@ import tseslint from "typescript-eslint";
|
|||||||
import prettier from "eslint-plugin-prettier";
|
import prettier from "eslint-plugin-prettier";
|
||||||
import { FlatCompat } from "@eslint/eslintrc";
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import globals from "globals";
|
||||||
|
|
||||||
// Use FlatCompat to consume Next.js' legacy shareable configs under apps/portal
|
// Use FlatCompat to consume Next.js' legacy shareable configs under apps/portal
|
||||||
const compat = new FlatCompat({ baseDirectory: path.resolve("apps/portal") });
|
const compat = new FlatCompat({ baseDirectory: path.resolve("apps/portal") });
|
||||||
@ -35,6 +36,12 @@ export default [
|
|||||||
...tseslint.configs.recommendedTypeChecked.map(config => ({
|
...tseslint.configs.recommendedTypeChecked.map(config => ({
|
||||||
...config,
|
...config,
|
||||||
files: ["**/*.ts", "**/*.tsx"],
|
files: ["**/*.ts", "**/*.tsx"],
|
||||||
|
languageOptions: {
|
||||||
|
...(config.languageOptions || {}),
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
},
|
||||||
|
},
|
||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
files: ["apps/bff/**/*.ts", "packages/shared/**/*.ts"],
|
files: ["apps/bff/**/*.ts", "packages/shared/**/*.ts"],
|
||||||
@ -84,6 +91,10 @@ export default [
|
|||||||
tsconfigRootDir: process.cwd(),
|
tsconfigRootDir: process.cwd(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
rules: {
|
||||||
|
// App Router: disable pages-directory specific rule
|
||||||
|
"@next/next/no-html-link-for-pages": "off",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// Node globals for Next config file
|
// Node globals for Next config file
|
||||||
@ -91,10 +102,7 @@ export default [
|
|||||||
files: ["apps/portal/next.config.mjs"],
|
files: ["apps/portal/next.config.mjs"],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: {
|
globals: {
|
||||||
process: "readonly",
|
...globals.node,
|
||||||
module: "readonly",
|
|
||||||
__dirname: "readonly",
|
|
||||||
require: "readonly",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
"lint:fix": "pnpm --recursive run lint:fix",
|
"lint:fix": "pnpm --recursive run lint:fix",
|
||||||
"format": "prettier -w .",
|
"format": "prettier -w .",
|
||||||
"format:check": "prettier -c .",
|
"format:check": "prettier -c .",
|
||||||
"prepare": "husky install",
|
"prepare": "husky",
|
||||||
"type-check": "pnpm --recursive run type-check",
|
"type-check": "pnpm --recursive run type-check",
|
||||||
"clean": "pnpm --recursive run clean",
|
"clean": "pnpm --recursive run clean",
|
||||||
"dev:start": "./scripts/dev/manage.sh start",
|
"dev:start": "./scripts/dev/manage.sh start",
|
||||||
@ -47,13 +47,18 @@
|
|||||||
"update:safe": "pnpm update --recursive && pnpm audit && pnpm type-check"
|
"update:safe": "pnpm update --recursive && pnpm audit && pnpm type-check"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.3.0",
|
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
|
"@types/node": "^24.3.0",
|
||||||
"eslint": "^9.33.0",
|
"eslint": "^9.33.0",
|
||||||
"eslint-config-next": "15.5.0",
|
"eslint-config-next": "15.5.0",
|
||||||
"eslint-plugin-prettier": "^5.5.4",
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
|
"globals": "^16.3.0",
|
||||||
|
"husky": "^9.1.7",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"typescript-eslint": "^8.40.0"
|
"typescript-eslint": "^8.40.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@sendgrid/mail": "^8.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
46
packages/shared/src/skus.ts
Normal file
46
packages/shared/src/skus.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
// Central SKU registry for Product2 <-> portal mappings.
|
||||||
|
// Replace the placeholder codes with your actual Product2.SKU__c values.
|
||||||
|
|
||||||
|
export type InternetTier = "Platinum_Gold" | "Silver";
|
||||||
|
export type AccessMode = "IPoE-HGW" | "IPoE-BYOR" | "PPPoE";
|
||||||
|
export type InstallPlan = "One-time" | "12-Month" | "24-Month";
|
||||||
|
|
||||||
|
const INTERNET_SKU: Record<InternetTier, Record<AccessMode, string>> = {
|
||||||
|
Platinum_Gold: {
|
||||||
|
"IPoE-HGW": "INT-1G-PLAT-HGW",
|
||||||
|
"IPoE-BYOR": "INT-1G-PLAT-BYOR",
|
||||||
|
PPPoE: "INT-1G-PLAT-PPPOE",
|
||||||
|
},
|
||||||
|
Silver: {
|
||||||
|
"IPoE-HGW": "INT-1G-SILV-HGW",
|
||||||
|
"IPoE-BYOR": "INT-1G-SILV-BYOR",
|
||||||
|
PPPoE: "INT-1G-SILV-PPPOE",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const INSTALL_SKU: Record<InstallPlan, string> = {
|
||||||
|
"One-time": "INT-INSTALL-ONETIME",
|
||||||
|
"12-Month": "INT-INSTALL-12M",
|
||||||
|
"24-Month": "INT-INSTALL-24M",
|
||||||
|
};
|
||||||
|
|
||||||
|
const VPN_SKU: Record<string, string> = {
|
||||||
|
"USA-SF": "VPN-USA-SF",
|
||||||
|
"UK-London": "VPN-UK-LON",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getInternetServiceSku(tier: InternetTier, mode: AccessMode): string {
|
||||||
|
return INTERNET_SKU[tier][mode];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInternetInstallSku(plan: InstallPlan): string {
|
||||||
|
return INSTALL_SKU[plan];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVpnServiceSku(region: string): string {
|
||||||
|
return VPN_SKU[region] || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVpnActivationSku(): string {
|
||||||
|
return "VPN-ACTIVATION";
|
||||||
|
}
|
||||||
@ -70,7 +70,11 @@ export interface SignupRequest {
|
|||||||
lastName: string;
|
lastName: string;
|
||||||
company?: string;
|
company?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
sfNumber: string; // Customer Number
|
||||||
address?: UserAddress;
|
address?: UserAddress;
|
||||||
|
nationality?: string;
|
||||||
|
dateOfBirth?: string; // ISO or locale string per frontend validation
|
||||||
|
gender?: "male" | "female" | "other";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LinkWhmcsRequest {
|
export interface LinkWhmcsRequest {
|
||||||
|
|||||||
107
pnpm-lock.yaml
generated
107
pnpm-lock.yaml
generated
@ -6,6 +6,10 @@ settings:
|
|||||||
|
|
||||||
importers:
|
importers:
|
||||||
.:
|
.:
|
||||||
|
dependencies:
|
||||||
|
"@sendgrid/mail":
|
||||||
|
specifier: ^8.1.5
|
||||||
|
version: 8.1.5
|
||||||
devDependencies:
|
devDependencies:
|
||||||
"@eslint/eslintrc":
|
"@eslint/eslintrc":
|
||||||
specifier: ^3.3.1
|
specifier: ^3.3.1
|
||||||
@ -22,6 +26,12 @@ importers:
|
|||||||
eslint-plugin-prettier:
|
eslint-plugin-prettier:
|
||||||
specifier: ^5.5.4
|
specifier: ^5.5.4
|
||||||
version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.33.0(jiti@2.5.1)))(eslint@9.33.0(jiti@2.5.1))(prettier@3.6.2)
|
version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.33.0(jiti@2.5.1)))(eslint@9.33.0(jiti@2.5.1))(prettier@3.6.2)
|
||||||
|
globals:
|
||||||
|
specifier: ^16.3.0
|
||||||
|
version: 16.3.0
|
||||||
|
husky:
|
||||||
|
specifier: ^9.1.7
|
||||||
|
version: 9.1.7
|
||||||
prettier:
|
prettier:
|
||||||
specifier: ^3.6.2
|
specifier: ^3.6.2
|
||||||
version: 3.6.2
|
version: 3.6.2
|
||||||
@ -67,6 +77,9 @@ importers:
|
|||||||
"@prisma/client":
|
"@prisma/client":
|
||||||
specifier: ^6.14.0
|
specifier: ^6.14.0
|
||||||
version: 6.14.0(prisma@6.14.0(typescript@5.9.2))(typescript@5.9.2)
|
version: 6.14.0(prisma@6.14.0(typescript@5.9.2))(typescript@5.9.2)
|
||||||
|
"@sendgrid/mail":
|
||||||
|
specifier: ^8.1.3
|
||||||
|
version: 8.1.5
|
||||||
"@types/jsonwebtoken":
|
"@types/jsonwebtoken":
|
||||||
specifier: ^9.0.10
|
specifier: ^9.0.10
|
||||||
version: 9.0.10
|
version: 9.0.10
|
||||||
@ -1837,6 +1850,27 @@ packages:
|
|||||||
integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==,
|
integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"@sendgrid/client@8.1.5":
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-Jqt8aAuGIpWGa15ZorTWI46q9gbaIdQFA21HIPQQl60rCjzAko75l3D1z7EyjFrNr4MfQ0StusivWh8Rjh10Cg==,
|
||||||
|
}
|
||||||
|
engines: { node: ">=12.*" }
|
||||||
|
|
||||||
|
"@sendgrid/helpers@8.0.0":
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-Ze7WuW2Xzy5GT5WRx+yEv89fsg/pgy3T1E3FS0QEx0/VvRmigMZ5qyVGhJz4SxomegDkzXv/i0aFPpHKN8qdAA==,
|
||||||
|
}
|
||||||
|
engines: { node: ">= 12.0.0" }
|
||||||
|
|
||||||
|
"@sendgrid/mail@8.1.5":
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-W+YuMnkVs4+HA/bgfto4VHKcPKLc7NiZ50/NH2pzO6UHCCFuq8/GNB98YJlLEr/ESDyzAaDr7lVE7hoBwFTT3Q==,
|
||||||
|
}
|
||||||
|
engines: { node: ">=12.*" }
|
||||||
|
|
||||||
"@sinclair/typebox@0.34.40":
|
"@sinclair/typebox@0.34.40":
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -3006,6 +3040,12 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=4" }
|
engines: { node: ">=4" }
|
||||||
|
|
||||||
|
axios@1.11.0:
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==,
|
||||||
|
}
|
||||||
|
|
||||||
axobject-query@4.1.0:
|
axobject-query@4.1.0:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -4446,6 +4486,18 @@ packages:
|
|||||||
integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==,
|
integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
follow-redirects@1.15.11:
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==,
|
||||||
|
}
|
||||||
|
engines: { node: ">=4.0" }
|
||||||
|
peerDependencies:
|
||||||
|
debug: "*"
|
||||||
|
peerDependenciesMeta:
|
||||||
|
debug:
|
||||||
|
optional: true
|
||||||
|
|
||||||
for-each@0.3.5:
|
for-each@0.3.5:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -4655,6 +4707,13 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=18" }
|
engines: { node: ">=18" }
|
||||||
|
|
||||||
|
globals@16.3.0:
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==,
|
||||||
|
}
|
||||||
|
engines: { node: ">=18" }
|
||||||
|
|
||||||
globalthis@1.0.4:
|
globalthis@1.0.4:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -4790,6 +4849,14 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=10.17.0" }
|
engines: { node: ">=10.17.0" }
|
||||||
|
|
||||||
|
husky@9.1.7:
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==,
|
||||||
|
}
|
||||||
|
engines: { node: ">=18" }
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
iconv-lite@0.6.3:
|
iconv-lite@0.6.3:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -6669,6 +6736,12 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">= 0.10" }
|
engines: { node: ">= 0.10" }
|
||||||
|
|
||||||
|
proxy-from-env@1.1.0:
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==,
|
||||||
|
}
|
||||||
|
|
||||||
pump@3.0.3:
|
pump@3.0.3:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -9275,6 +9348,24 @@ snapshots:
|
|||||||
|
|
||||||
"@scarf/scarf@1.4.0": {}
|
"@scarf/scarf@1.4.0": {}
|
||||||
|
|
||||||
|
"@sendgrid/client@8.1.5":
|
||||||
|
dependencies:
|
||||||
|
"@sendgrid/helpers": 8.0.0
|
||||||
|
axios: 1.11.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- debug
|
||||||
|
|
||||||
|
"@sendgrid/helpers@8.0.0":
|
||||||
|
dependencies:
|
||||||
|
deepmerge: 4.3.1
|
||||||
|
|
||||||
|
"@sendgrid/mail@8.1.5":
|
||||||
|
dependencies:
|
||||||
|
"@sendgrid/client": 8.1.5
|
||||||
|
"@sendgrid/helpers": 8.0.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- debug
|
||||||
|
|
||||||
"@sinclair/typebox@0.34.40": {}
|
"@sinclair/typebox@0.34.40": {}
|
||||||
|
|
||||||
"@sindresorhus/is@4.6.0": {}
|
"@sindresorhus/is@4.6.0": {}
|
||||||
@ -9993,6 +10084,14 @@ snapshots:
|
|||||||
|
|
||||||
axe-core@4.10.3: {}
|
axe-core@4.10.3: {}
|
||||||
|
|
||||||
|
axios@1.11.0:
|
||||||
|
dependencies:
|
||||||
|
follow-redirects: 1.15.11
|
||||||
|
form-data: 4.0.4
|
||||||
|
proxy-from-env: 1.1.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- debug
|
||||||
|
|
||||||
axobject-query@4.1.0: {}
|
axobject-query@4.1.0: {}
|
||||||
|
|
||||||
babel-jest@30.0.5(@babel/core@7.28.3):
|
babel-jest@30.0.5(@babel/core@7.28.3):
|
||||||
@ -11002,6 +11101,8 @@ snapshots:
|
|||||||
|
|
||||||
flatted@3.3.3: {}
|
flatted@3.3.3: {}
|
||||||
|
|
||||||
|
follow-redirects@1.15.11: {}
|
||||||
|
|
||||||
for-each@0.3.5:
|
for-each@0.3.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-callable: 1.2.7
|
is-callable: 1.2.7
|
||||||
@ -11156,6 +11257,8 @@ snapshots:
|
|||||||
|
|
||||||
globals@14.0.0: {}
|
globals@14.0.0: {}
|
||||||
|
|
||||||
|
globals@16.3.0: {}
|
||||||
|
|
||||||
globalthis@1.0.4:
|
globalthis@1.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
define-properties: 1.2.1
|
define-properties: 1.2.1
|
||||||
@ -11225,6 +11328,8 @@ snapshots:
|
|||||||
|
|
||||||
human-signals@2.1.0: {}
|
human-signals@2.1.0: {}
|
||||||
|
|
||||||
|
husky@9.1.7: {}
|
||||||
|
|
||||||
iconv-lite@0.6.3:
|
iconv-lite@0.6.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
@ -12502,6 +12607,8 @@ snapshots:
|
|||||||
forwarded: 0.2.0
|
forwarded: 0.2.0
|
||||||
ipaddr.js: 1.9.1
|
ipaddr.js: 1.9.1
|
||||||
|
|
||||||
|
proxy-from-env@1.1.0: {}
|
||||||
|
|
||||||
pump@3.0.3:
|
pump@3.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
end-of-stream: 1.4.5
|
end-of-stream: 1.4.5
|
||||||
|
|||||||
@ -8,7 +8,7 @@ set -e
|
|||||||
# Configuration
|
# Configuration
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
COMPOSE_FILE="$PROJECT_ROOT/docker/prod/docker-compose.yml"
|
COMPOSE_FILE="${COMPOSE_FILE:-$PROJECT_ROOT/docker/prod/docker-compose.yml}"
|
||||||
ENV_FILE="$PROJECT_ROOT/.env"
|
ENV_FILE="$PROJECT_ROOT/.env"
|
||||||
PROJECT_NAME="portal-prod"
|
PROJECT_NAME="portal-prod"
|
||||||
|
|
||||||
@ -95,9 +95,9 @@ deploy() {
|
|||||||
log "🔄 Running database migrations..."
|
log "🔄 Running database migrations..."
|
||||||
docker-compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" -p "$PROJECT_NAME" run --rm backend pnpm db:migrate || warn "Migration may have failed"
|
docker-compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" -p "$PROJECT_NAME" run --rm backend pnpm db:migrate || warn "Migration may have failed"
|
||||||
|
|
||||||
# Start application services (proxy + apps)
|
# Start application services (apps only; Plesk handles proxy)
|
||||||
log "🚀 Starting application services..."
|
log "🚀 Starting application services..."
|
||||||
docker-compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" -p "$PROJECT_NAME" up -d proxy frontend backend
|
docker-compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" -p "$PROJECT_NAME" up -d frontend backend
|
||||||
|
|
||||||
# Health checks
|
# Health checks
|
||||||
log "🏥 Performing health checks..."
|
log "🏥 Performing health checks..."
|
||||||
@ -137,7 +137,12 @@ status() {
|
|||||||
docker-compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" -p "$PROJECT_NAME" ps
|
docker-compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" -p "$PROJECT_NAME" ps
|
||||||
|
|
||||||
log "🏥 Health Status:"
|
log "🏥 Health Status:"
|
||||||
docker-compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" -p "$PROJECT_NAME" exec proxy wget --spider -q http://localhost/healthz && echo "✅ Proxy/Frontend healthy" || echo "❌ Proxy/Frontend unhealthy"
|
# If proxy service exists (non-Plesk mode), check it; otherwise, check frontend directly
|
||||||
|
if docker-compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" -p "$PROJECT_NAME" ps proxy >/dev/null 2>&1; then
|
||||||
|
docker-compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" -p "$PROJECT_NAME" exec proxy wget --spider -q http://localhost/healthz && echo "✅ Proxy/Frontend healthy" || echo "❌ Proxy/Frontend unhealthy"
|
||||||
|
else
|
||||||
|
docker-compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" -p "$PROJECT_NAME" exec frontend wget --spider -q http://localhost:3000/api/health && echo "✅ Frontend healthy" || echo "❌ Frontend unhealthy"
|
||||||
|
fi
|
||||||
docker-compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" -p "$PROJECT_NAME" exec backend wget --spider -q http://localhost:4000/health && echo "✅ Backend healthy" || echo "❌ Backend unhealthy"
|
docker-compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" -p "$PROJECT_NAME" exec backend wget --spider -q http://localhost:4000/health && echo "✅ Backend healthy" || echo "❌ Backend unhealthy"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user