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
|
||||
APP_NAME=customer-portal-bff
|
||||
BFF_PORT=4000
|
||||
APP_BASE_URL=http://localhost:3000
|
||||
|
||||
# =============================================================================
|
||||
# 🔐 SECURITY CONFIGURATION (Development)
|
||||
@ -79,6 +80,19 @@ NEXT_PUBLIC_ENABLE_DEVTOOLS=true
|
||||
# Node.js options for development
|
||||
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)
|
||||
# =============================================================================
|
||||
|
||||
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
|
||||
APP_NAME=customer-portal-bff
|
||||
BFF_PORT=4000
|
||||
APP_BASE_URL=https://portal.yourdomain.com
|
||||
|
||||
# =============================================================================
|
||||
# 🔐 SECURITY CONFIGURATION (Production)
|
||||
@ -79,6 +80,20 @@ NEXT_PUBLIC_ENABLE_DEVTOOLS=false
|
||||
# Node.js options for production
|
||||
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
|
||||
# =============================================================================
|
||||
|
||||
@ -1,10 +1,4 @@
|
||||
{
|
||||
"*.{ts,tsx,js,jsx}": [
|
||||
"eslint --fix",
|
||||
"prettier -w"
|
||||
],
|
||||
"*.{json,md,yml,yaml,css,scss}": [
|
||||
"prettier -w"
|
||||
]
|
||||
"*.{ts,tsx,js,jsx}": ["eslint --fix", "prettier -w"],
|
||||
"*.{json,md,yml,yaml,css,scss}": ["prettier -w"]
|
||||
}
|
||||
|
||||
|
||||
@ -59,6 +59,7 @@
|
||||
"prisma": "^6.14.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"@sendgrid/mail": "^8.1.3",
|
||||
"speakeasy": "^2.0.0",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^4.0.17"
|
||||
|
||||
@ -14,6 +14,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
|
||||
import { VendorsModule } from "./vendors/vendors.module";
|
||||
import { JobsModule } from "./jobs/jobs.module";
|
||||
import { HealthModule } from "./health/health.module";
|
||||
import { EmailModule } from "./common/email/email.module";
|
||||
import { PrismaModule } from "./common/prisma/prisma.module";
|
||||
import { AuditModule } from "./common/audit/audit.module";
|
||||
import { RedisModule } from "./common/redis/redis.module";
|
||||
@ -76,6 +77,7 @@ const envFilePath = [
|
||||
VendorsModule,
|
||||
JobsModule,
|
||||
HealthModule,
|
||||
EmailModule,
|
||||
|
||||
// Feature modules
|
||||
AuthModule,
|
||||
|
||||
@ -7,6 +7,8 @@ import { JwtAuthGuard } from "./guards/jwt-auth.guard";
|
||||
import { AuthThrottleGuard } from "./guards/auth-throttle.guard";
|
||||
import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger";
|
||||
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 { SetPasswordDto } from "./dto/set-password.dto";
|
||||
@ -41,8 +43,13 @@ export class AuthController {
|
||||
@ApiOperation({ summary: "Logout user" })
|
||||
@ApiResponse({ status: 200, description: "Logout successful" })
|
||||
async logout(@Req() req: Request & { user: { id: string } }) {
|
||||
const authHeader = req.headers["authorization"];
|
||||
const bearer = Array.isArray(authHeader) ? authHeader[0] : authHeader;
|
||||
const authHeader = req.headers.authorization as string | string[] | undefined;
|
||||
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;
|
||||
await this.authService.logout(req.user.id, token ?? "", req);
|
||||
return { message: "Logout successful" };
|
||||
@ -80,6 +87,23 @@ export class AuthController {
|
||||
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)
|
||||
@Get("me")
|
||||
@ApiOperation({ summary: "Get current authentication status" })
|
||||
|
||||
@ -11,6 +11,7 @@ import { VendorsModule } from "../vendors/vendors.module";
|
||||
import { JwtStrategy } from "./strategies/jwt.strategy";
|
||||
import { LocalStrategy } from "./strategies/local.strategy";
|
||||
import { TokenBlacklistService } from "./services/token-blacklist.service";
|
||||
import { EmailModule } from "../common/email/email.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -25,6 +26,7 @@ import { TokenBlacklistService } from "./services/token-blacklist.service";
|
||||
UsersModule,
|
||||
MappingsModule,
|
||||
VendorsModule,
|
||||
EmailModule,
|
||||
],
|
||||
controllers: [AuthController, AuthAdminController],
|
||||
providers: [AuthService, JwtStrategy, LocalStrategy, TokenBlacklistService],
|
||||
|
||||
@ -19,6 +19,7 @@ import { LinkWhmcsDto } from "./dto/link-whmcs.dto";
|
||||
import { SetPasswordDto } from "./dto/set-password.dto";
|
||||
import { getErrorMessage } from "../common/utils/error.util";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { EmailService } from "../common/email/email.service";
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@ -34,11 +35,24 @@ export class AuthService {
|
||||
private salesforceService: SalesforceService,
|
||||
private auditService: AuditService,
|
||||
private tokenBlacklistService: TokenBlacklistService,
|
||||
private emailService: EmailService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
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
|
||||
this.validateSignupData(signupData);
|
||||
@ -62,6 +76,14 @@ export class AuthService {
|
||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||
|
||||
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
|
||||
const user = await this.usersService.create({
|
||||
email,
|
||||
@ -77,22 +99,37 @@ export class AuthService {
|
||||
});
|
||||
|
||||
// 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({
|
||||
firstname: firstName,
|
||||
lastname: lastName,
|
||||
email,
|
||||
companyname: company || "",
|
||||
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
|
||||
customfields,
|
||||
});
|
||||
|
||||
// 3. Create account in Salesforce (no Contact, just Account)
|
||||
const sfAccount = await this.salesforceService.upsertAccount({
|
||||
name: company || `${firstName} ${lastName}`,
|
||||
phone: phone,
|
||||
});
|
||||
|
||||
// 4. Store ID mappings
|
||||
// 3. Store ID mappings
|
||||
await this.mappingsService.createMapping({
|
||||
userId: user.id,
|
||||
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) {
|
||||
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";
|
||||
|
||||
export class SignupDto {
|
||||
@ -36,4 +36,34 @@ export class SignupDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
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 type { Product } from "@customer-portal/shared";
|
||||
import { WhmcsService } from "../vendors/whmcs/whmcs.service";
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { CacheService } from "../common/cache/cache.service";
|
||||
import { SalesforceConnection } from "../vendors/salesforce/services/salesforce-connection.service";
|
||||
|
||||
@Injectable()
|
||||
export class CatalogService {
|
||||
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 ttl = 15 * 60; // 15 minutes
|
||||
|
||||
return this.cacheService.getOrSet<Product[]>(
|
||||
return this.cacheService.getOrSet<any[]>(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const result = await this.whmcsService.getProducts();
|
||||
return result.products.map((p: any) => this.whmcsService.transformProduct(p));
|
||||
// Read Product2s visible in portal and include SKU__c
|
||||
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
|
||||
);
|
||||
|
||||
@ -11,6 +11,7 @@ export const envSchema = z.object({
|
||||
JWT_SECRET: z.string().min(32, "JWT secret must be at least 32 characters"),
|
||||
JWT_EXPIRES_IN: z.string().default("7d"),
|
||||
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_ORIGIN: z.string().url().optional(),
|
||||
@ -40,6 +41,16 @@ export const envSchema = z.object({
|
||||
SF_CLIENT_ID: z.string().optional(),
|
||||
SF_PRIVATE_KEY_PATH: 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> {
|
||||
|
||||
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: {
|
||||
// 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,
|
||||
url: req.url,
|
||||
remoteAddress: req.remoteAddress,
|
||||
@ -66,7 +71,13 @@ export class LoggingConfig {
|
||||
res: (res: { statusCode: number }) => ({
|
||||
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,
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
@ -128,7 +139,9 @@ export class LoggingConfig {
|
||||
// Auto-generate correlation IDs
|
||||
genReqId: (req: IncomingMessage, res: ServerResponse) => {
|
||||
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;
|
||||
|
||||
const correlationId = LoggingConfig.generateCorrelationId();
|
||||
@ -143,7 +156,11 @@ export class LoggingConfig {
|
||||
},
|
||||
// Suppress success messages entirely
|
||||
customSuccessMessage: () => "",
|
||||
customErrorMessage: (req: IncomingMessage, res: ServerResponse, err: { message?: string }) => {
|
||||
customErrorMessage: (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
err: { message?: string }
|
||||
) => {
|
||||
const method = req.method ?? "";
|
||||
const url = req.url ?? "";
|
||||
return `${method} ${url} ${res.statusCode} - ${err.message ?? "error"}`;
|
||||
|
||||
@ -159,7 +159,8 @@ export function createDeferredPromise<T>(): {
|
||||
// Use native Promise.withResolvers if available (ES2024)
|
||||
if (
|
||||
"withResolvers" in Promise &&
|
||||
typeof (Promise as unknown as { withResolvers?: <_U>() => unknown }).withResolvers === "function"
|
||||
typeof (Promise as unknown as { withResolvers?: <_U>() => unknown }).withResolvers ===
|
||||
"function"
|
||||
) {
|
||||
return (
|
||||
Promise as unknown as {
|
||||
|
||||
@ -2,11 +2,18 @@ import { Controller, Get } from "@nestjs/common";
|
||||
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||
import { PrismaService } from "../common/prisma/prisma.service";
|
||||
import { getErrorMessage } from "../common/utils/error.util";
|
||||
import { InjectQueue } from "@nestjs/bullmq";
|
||||
import { Queue } from "bullmq";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
@ApiTags("Health")
|
||||
@Controller("health")
|
||||
export class HealthController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: ConfigService,
|
||||
@InjectQueue("email") private readonly emailQueue: Queue
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "Health check endpoint" })
|
||||
@ -29,6 +36,13 @@ export class HealthController {
|
||||
// Test database connection
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
|
||||
const emailQueueInfo = await this.emailQueue.getJobCounts(
|
||||
"waiting",
|
||||
"active",
|
||||
"failed",
|
||||
"delayed"
|
||||
);
|
||||
|
||||
return {
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
@ -36,6 +50,14 @@ export class HealthController {
|
||||
database: "connected",
|
||||
environment: process.env.NODE_ENV || "development",
|
||||
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) {
|
||||
return {
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { HealthController } from "./health.controller";
|
||||
import { PrismaModule } from "../common/prisma/prisma.module";
|
||||
import { BullModule } from "@nestjs/bullmq";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
imports: [PrismaModule, ConfigModule, BullModule.registerQueue({ name: "email" })],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
|
||||
@ -55,7 +55,7 @@ export interface BulkMappingResult {
|
||||
errors: Array<{
|
||||
index: number;
|
||||
error: string;
|
||||
data: any;
|
||||
data: unknown;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
||||
@ -142,10 +142,10 @@ export class MappingValidatorService {
|
||||
/**
|
||||
* Check for potential conflicts
|
||||
*/
|
||||
async validateNoConflicts(
|
||||
validateNoConflicts(
|
||||
request: CreateMappingRequest,
|
||||
existingMappings: UserIdMapping[]
|
||||
): Promise<MappingValidationResult> {
|
||||
): MappingValidationResult {
|
||||
const errors: 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 { 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")
|
||||
@Controller("orders")
|
||||
export class OrdersController {
|
||||
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()
|
||||
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 { JwtAuthGuard } from "../auth/guards/jwt-auth.guard";
|
||||
import { Subscription, SubscriptionList, InvoiceList } from "@customer-portal/shared";
|
||||
import { RequestWithUser } from "../auth/auth.types";
|
||||
|
||||
@ApiTags("subscriptions")
|
||||
@Controller("subscriptions")
|
||||
@ -44,7 +45,7 @@ export class SubscriptionsController {
|
||||
type: Object, // Would be SubscriptionList if we had proper DTO decorators
|
||||
})
|
||||
async getSubscriptions(
|
||||
@Request() req: any,
|
||||
@Request() req: RequestWithUser,
|
||||
@Query("status") status?: string
|
||||
): Promise<SubscriptionList | Subscription[]> {
|
||||
// Validate status if provided
|
||||
@ -54,13 +55,13 @@ export class SubscriptionsController {
|
||||
|
||||
if (status) {
|
||||
const subscriptions = await this.subscriptionsService.getSubscriptionsByStatus(
|
||||
req.user.id,
|
||||
req.user.userId,
|
||||
status
|
||||
);
|
||||
return subscriptions;
|
||||
}
|
||||
|
||||
return this.subscriptionsService.getSubscriptions(req.user.id);
|
||||
return this.subscriptionsService.getSubscriptions(req.user.userId);
|
||||
}
|
||||
|
||||
@Get("active")
|
||||
@ -73,8 +74,8 @@ export class SubscriptionsController {
|
||||
description: "List of active subscriptions",
|
||||
type: [Object], // Would be Subscription[] if we had proper DTO decorators
|
||||
})
|
||||
async getActiveSubscriptions(@Request() req: any): Promise<Subscription[]> {
|
||||
return this.subscriptionsService.getActiveSubscriptions(req.user.id);
|
||||
async getActiveSubscriptions(@Request() req: RequestWithUser): Promise<Subscription[]> {
|
||||
return this.subscriptionsService.getActiveSubscriptions(req.user.userId);
|
||||
}
|
||||
|
||||
@Get("stats")
|
||||
@ -87,14 +88,14 @@ export class SubscriptionsController {
|
||||
description: "Subscription statistics",
|
||||
type: Object,
|
||||
})
|
||||
async getSubscriptionStats(@Request() req: any): Promise<{
|
||||
async getSubscriptionStats(@Request() req: RequestWithUser): Promise<{
|
||||
total: number;
|
||||
active: number;
|
||||
suspended: number;
|
||||
cancelled: number;
|
||||
pending: number;
|
||||
}> {
|
||||
return this.subscriptionsService.getSubscriptionStats(req.user.id);
|
||||
return this.subscriptionsService.getSubscriptionStats(req.user.userId);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@ -110,14 +111,14 @@ export class SubscriptionsController {
|
||||
})
|
||||
@ApiResponse({ status: 404, description: "Subscription not found" })
|
||||
async getSubscriptionById(
|
||||
@Request() req: any,
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number
|
||||
): Promise<Subscription> {
|
||||
if (subscriptionId <= 0) {
|
||||
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")
|
||||
@ -145,7 +146,7 @@ export class SubscriptionsController {
|
||||
})
|
||||
@ApiResponse({ status: 404, description: "Subscription not found" })
|
||||
async getSubscriptionInvoices(
|
||||
@Request() req: any,
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Query("page") page?: string,
|
||||
@Query("limit") limit?: string
|
||||
@ -163,7 +164,7 @@ export class SubscriptionsController {
|
||||
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,
|
||||
limit: limitNum,
|
||||
});
|
||||
|
||||
@ -13,6 +13,7 @@ import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard";
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from "@nestjs/swagger";
|
||||
import { UpdateUserDto } from "./dto/update-user.dto";
|
||||
import { UpdateBillingDto } from "./dto/update-billing.dto";
|
||||
import { RequestWithUser } from "../auth/auth.types";
|
||||
|
||||
@ApiTags("users")
|
||||
@Controller("me")
|
||||
@ -26,16 +27,16 @@ export class UsersController {
|
||||
@ApiOperation({ summary: "Get current user profile" })
|
||||
@ApiResponse({ status: 200, description: "User profile retrieved successfully" })
|
||||
@ApiResponse({ status: 401, description: "Unauthorized" })
|
||||
async getProfile(@Req() req: any) {
|
||||
return this.usersService.findById(req.user.id);
|
||||
async getProfile(@Req() req: RequestWithUser) {
|
||||
return this.usersService.findById(req.user.userId);
|
||||
}
|
||||
|
||||
@Get("summary")
|
||||
@ApiOperation({ summary: "Get user dashboard summary" })
|
||||
@ApiResponse({ status: 200, description: "User summary retrieved successfully" })
|
||||
@ApiResponse({ status: 401, description: "Unauthorized" })
|
||||
async getSummary(@Req() req: any) {
|
||||
return this.usersService.getUserSummary(req.user.id);
|
||||
async getSummary(@Req() req: RequestWithUser) {
|
||||
return this.usersService.getUserSummary(req.user.userId);
|
||||
}
|
||||
|
||||
@Patch()
|
||||
@ -43,8 +44,8 @@ export class UsersController {
|
||||
@ApiResponse({ status: 200, description: "Profile updated successfully" })
|
||||
@ApiResponse({ status: 400, description: "Invalid input data" })
|
||||
@ApiResponse({ status: 401, description: "Unauthorized" })
|
||||
async updateProfile(@Req() req: any, @Body() updateData: UpdateUserDto) {
|
||||
return this.usersService.update(req.user.id, updateData);
|
||||
async updateProfile(@Req() req: RequestWithUser, @Body() updateData: UpdateUserDto) {
|
||||
return this.usersService.update(req.user.userId, updateData);
|
||||
}
|
||||
|
||||
@Patch("billing")
|
||||
@ -52,7 +53,7 @@ export class UsersController {
|
||||
@ApiResponse({ status: 200, description: "Billing information updated successfully" })
|
||||
@ApiResponse({ status: 400, description: "Invalid input data" })
|
||||
@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
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
@ -96,7 +96,7 @@ export class SalesforceService implements OnModuleInit {
|
||||
|
||||
// === HEALTH CHECK ===
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
healthCheck(): boolean {
|
||||
try {
|
||||
return this.connection.isConnected();
|
||||
} catch (error) {
|
||||
|
||||
@ -387,10 +387,10 @@ export class WhmcsCacheService {
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
async getCacheStats(): Promise<{
|
||||
getCacheStats(): {
|
||||
totalKeys: number;
|
||||
keysByType: Record<string, number>;
|
||||
}> {
|
||||
} {
|
||||
// This would require Redis SCAN or similar functionality
|
||||
// For now, return a placeholder
|
||||
return {
|
||||
|
||||
@ -28,19 +28,12 @@ export class WhmcsPaymentService {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// TODO: GetPayMethods API might not exist in WHMCS
|
||||
// For now, return empty list until we verify the correct API
|
||||
this.logger.warn(`GetPayMethods API not yet implemented for client ${clientId}`);
|
||||
|
||||
const result: PaymentMethodList = {
|
||||
paymentMethods: [],
|
||||
totalCount: 0,
|
||||
};
|
||||
|
||||
// Cache the empty result for now
|
||||
const response = await this.connectionService.getPayMethods({ clientid: clientId });
|
||||
const methods = (response.paymethods?.paymethod || []).map(pm =>
|
||||
this.dataTransformer.transformPaymentMethod(pm)
|
||||
);
|
||||
const result: PaymentMethodList = { paymentMethods: methods, totalCount: methods.length };
|
||||
await this.cacheService.setPaymentMethods(userId, result);
|
||||
|
||||
this.logger.log(`Payment methods feature temporarily disabled for client ${clientId}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch payment methods for client ${clientId}`, {
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
WhmcsProduct,
|
||||
WhmcsCustomFields,
|
||||
WhmcsInvoiceItems,
|
||||
WhmcsPaymentMethod,
|
||||
WhmcsPaymentGateway,
|
||||
} 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
|
||||
*/
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Request } from "express";
|
||||
import * as crypto from "crypto";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
@Injectable()
|
||||
export class WebhookSignatureGuard implements CanActivate {
|
||||
@ -9,27 +9,30 @@ export class WebhookSignatureGuard implements CanActivate {
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
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");
|
||||
}
|
||||
|
||||
// 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
|
||||
? this.configService.get("WHMCS_WEBHOOK_SECRET")
|
||||
: this.configService.get("SF_WEBHOOK_SECRET");
|
||||
? this.configService.get<string>("WHMCS_WEBHOOK_SECRET")
|
||||
: this.configService.get<string>("SF_WEBHOOK_SECRET");
|
||||
|
||||
if (!secret) {
|
||||
throw new UnauthorizedException("Webhook secret not configured");
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
const payload = JSON.stringify(request.body);
|
||||
const expectedSignature = crypto.createHmac("sha256", secret).update(payload).digest("hex");
|
||||
const payload = Buffer.from(JSON.stringify(request.body), "utf8");
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
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: 401, description: "Invalid 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 {
|
||||
await this.webhooksService.processWhmcsWebhook(payload, signature);
|
||||
this.webhooksService.processWhmcsWebhook(payload, signature);
|
||||
return { success: true, message: "Webhook processed successfully" };
|
||||
} catch {
|
||||
throw new BadRequestException("Failed to process webhook");
|
||||
@ -44,12 +44,9 @@ export class WebhooksController {
|
||||
@ApiResponse({ status: 400, description: "Invalid webhook data" })
|
||||
@ApiResponse({ status: 401, description: "Invalid signature" })
|
||||
@ApiHeader({ name: "X-SF-Signature", description: "Salesforce webhook signature" })
|
||||
async handleSalesforceWebhook(
|
||||
@Body() payload: any,
|
||||
@Headers("x-sf-signature") signature: string
|
||||
) {
|
||||
handleSalesforceWebhook(@Body() payload: unknown, @Headers("x-sf-signature") signature: string) {
|
||||
try {
|
||||
await this.webhooksService.processSalesforceWebhook(payload, signature);
|
||||
this.webhooksService.processSalesforceWebhook(payload, signature);
|
||||
return { success: true, message: "Webhook processed successfully" };
|
||||
} catch {
|
||||
throw new BadRequestException("Failed to process webhook");
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { Injectable, BadRequestException, Inject } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { WhmcsWebhookSchema, WhmcsWebhook } from "./schemas/whmcs";
|
||||
import { SalesforceWebhookSchema, SalesforceWebhook } from "./schemas/salesforce";
|
||||
|
||||
@Injectable()
|
||||
export class WebhooksService {
|
||||
@ -9,11 +11,12 @@ export class WebhooksService {
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async processWhmcsWebhook(payload: any, signature: string): Promise<void> {
|
||||
processWhmcsWebhook(payload: unknown, signature: string): void {
|
||||
try {
|
||||
const data: WhmcsWebhook = WhmcsWebhookSchema.parse(payload);
|
||||
this.logger.log("Processing WHMCS webhook", {
|
||||
webhookType: payload.action || "unknown",
|
||||
clientId: payload.client_id,
|
||||
webhookType: data.action,
|
||||
clientId: data.client_id,
|
||||
signatureLength: signature?.length || 0,
|
||||
});
|
||||
|
||||
@ -28,17 +31,17 @@ export class WebhooksService {
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to process WHMCS webhook", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
payload: payload.action || "unknown",
|
||||
});
|
||||
throw new BadRequestException("Failed to process WHMCS webhook");
|
||||
}
|
||||
}
|
||||
|
||||
async processSalesforceWebhook(payload: any, signature: string): Promise<void> {
|
||||
processSalesforceWebhook(payload: unknown, signature: string): void {
|
||||
try {
|
||||
const data: SalesforceWebhook = SalesforceWebhookSchema.parse(payload);
|
||||
this.logger.log("Processing Salesforce webhook", {
|
||||
webhookType: payload.event?.type || "unknown",
|
||||
recordId: payload.sobject?.Id,
|
||||
webhookType: data.event?.type || "unknown",
|
||||
recordId: data.sobject?.Id,
|
||||
signatureLength: signature?.length || 0,
|
||||
});
|
||||
|
||||
@ -53,7 +56,6 @@ export class WebhooksService {
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to process Salesforce webhook", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
payload: payload.event?.type || "unknown",
|
||||
});
|
||||
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 { useAuthStore } from "@/lib/auth/store";
|
||||
|
||||
const signupSchema = z.object({
|
||||
email: z.string().email("Please enter a valid email address"),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.regex(
|
||||
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
|
||||
"Password must contain uppercase, lowercase, number, and special character"
|
||||
),
|
||||
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(),
|
||||
});
|
||||
const signupSchema = z
|
||||
.object({
|
||||
email: z.string().email("Please enter a valid email address"),
|
||||
confirmEmail: z.string().email("Please confirm with a valid email"),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.regex(
|
||||
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
|
||||
"Password must contain uppercase, lowercase, number, and special character"
|
||||
),
|
||||
confirmPassword: z.string(),
|
||||
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>;
|
||||
|
||||
@ -45,7 +66,29 @@ export default function SignupPage() {
|
||||
const onSubmit = async (data: SignupForm) => {
|
||||
try {
|
||||
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");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Signup failed");
|
||||
@ -101,17 +144,130 @@ export default function SignupPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div>
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Label htmlFor="addressLine1">Home Address #1</Label>
|
||||
<Input
|
||||
{...register("email")}
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
{...register("addressLine1")}
|
||||
id="addressLine1"
|
||||
type="text"
|
||||
autoComplete="address-line1"
|
||||
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>
|
||||
@ -140,22 +296,38 @@ export default function SignupPage() {
|
||||
{errors.phone && <p className="mt-1 text-sm text-red-600">{errors.phone.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
{...register("password")}
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className="mt-1"
|
||||
placeholder="Create a secure password"
|
||||
/>
|
||||
{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>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
{...register("password")}
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className="mt-1"
|
||||
placeholder="Create a secure password"
|
||||
/>
|
||||
{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>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -7,6 +7,18 @@ export interface SignupData {
|
||||
lastName: string;
|
||||
company?: 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 {
|
||||
@ -24,6 +36,15 @@ export interface SetPasswordData {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RequestPasswordResetData {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface ResetPasswordData {
|
||||
token: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
user: {
|
||||
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"]> {
|
||||
return this.request<AuthResponse["user"]>("/me", {
|
||||
headers: {
|
||||
|
||||
@ -29,9 +29,23 @@ interface AuthState {
|
||||
lastName: string;
|
||||
company?: 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>;
|
||||
linkWhmcs: (email: string, password: string) => Promise<{ needsPasswordSet: boolean }>;
|
||||
setPassword: (email: string, password: string) => Promise<void>;
|
||||
requestPasswordReset: (email: string) => Promise<void>;
|
||||
resetPassword: (token: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
checkAuth: () => Promise<void>;
|
||||
}
|
||||
@ -67,6 +81,18 @@ export const useAuthStore = create<AuthState>()(
|
||||
lastName: string;
|
||||
company?: 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 });
|
||||
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 () => {
|
||||
const { token } = get();
|
||||
|
||||
|
||||
@ -34,17 +34,28 @@ class Logger {
|
||||
const entry = this.formatMessage(level, message, data);
|
||||
|
||||
if (this.isDevelopment) {
|
||||
// In development, use structured console logging
|
||||
const consoleMethod = level === "debug" ? "log" : level;
|
||||
const safeData =
|
||||
data instanceof Error
|
||||
? {
|
||||
name: data.name,
|
||||
message: data.message,
|
||||
stack: data.stack,
|
||||
}
|
||||
: data;
|
||||
|
||||
const logData = {
|
||||
timestamp: entry.timestamp,
|
||||
level: entry.level.toUpperCase(),
|
||||
service: entry.service,
|
||||
message: entry.message,
|
||||
...(data != null ? { data } : {}),
|
||||
...(safeData != null ? { data: safeData } : {}),
|
||||
};
|
||||
|
||||
console[consoleMethod](logData);
|
||||
try {
|
||||
console.log(logData);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
} else {
|
||||
// In production, structured logging for external services
|
||||
const logData = {
|
||||
@ -55,7 +66,11 @@ class Logger {
|
||||
// For production, you might want to send to a logging service
|
||||
// For now, only log errors and warnings to console
|
||||
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
|
||||
|
||||
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:
|
||||
build:
|
||||
@ -43,8 +9,8 @@ services:
|
||||
dockerfile: apps/portal/Dockerfile
|
||||
target: production
|
||||
container_name: portal-frontend
|
||||
expose:
|
||||
- "3000"
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
@ -69,8 +35,8 @@ services:
|
||||
dockerfile: apps/bff/Dockerfile
|
||||
target: production
|
||||
container_name: portal-backend
|
||||
expose:
|
||||
- "4000"
|
||||
ports:
|
||||
- "127.0.0.1:4000:4000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- BFF_PORT=${BFF_PORT:-4000}
|
||||
@ -187,10 +153,6 @@ volumes:
|
||||
driver: local
|
||||
labels:
|
||||
- "prod.portal.volume=cache"
|
||||
certbot-www:
|
||||
driver: local
|
||||
letsencrypt:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
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 { FlatCompat } from "@eslint/eslintrc";
|
||||
import path from "node:path";
|
||||
import globals from "globals";
|
||||
|
||||
// Use FlatCompat to consume Next.js' legacy shareable configs under apps/portal
|
||||
const compat = new FlatCompat({ baseDirectory: path.resolve("apps/portal") });
|
||||
@ -35,6 +36,12 @@ export default [
|
||||
...tseslint.configs.recommendedTypeChecked.map(config => ({
|
||||
...config,
|
||||
files: ["**/*.ts", "**/*.tsx"],
|
||||
languageOptions: {
|
||||
...(config.languageOptions || {}),
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
})),
|
||||
{
|
||||
files: ["apps/bff/**/*.ts", "packages/shared/**/*.ts"],
|
||||
@ -84,6 +91,10 @@ export default [
|
||||
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
|
||||
@ -91,10 +102,7 @@ export default [
|
||||
files: ["apps/portal/next.config.mjs"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
process: "readonly",
|
||||
module: "readonly",
|
||||
__dirname: "readonly",
|
||||
require: "readonly",
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
"lint:fix": "pnpm --recursive run lint:fix",
|
||||
"format": "prettier -w .",
|
||||
"format:check": "prettier -c .",
|
||||
"prepare": "husky install",
|
||||
"prepare": "husky",
|
||||
"type-check": "pnpm --recursive run type-check",
|
||||
"clean": "pnpm --recursive run clean",
|
||||
"dev:start": "./scripts/dev/manage.sh start",
|
||||
@ -47,13 +47,18 @@
|
||||
"update:safe": "pnpm update --recursive && pnpm audit && pnpm type-check"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.0",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@types/node": "^24.3.0",
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-config-next": "15.5.0",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"globals": "^16.3.0",
|
||||
"husky": "^9.1.7",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5.9.2",
|
||||
"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;
|
||||
company?: string;
|
||||
phone?: string;
|
||||
sfNumber: string; // Customer Number
|
||||
address?: UserAddress;
|
||||
nationality?: string;
|
||||
dateOfBirth?: string; // ISO or locale string per frontend validation
|
||||
gender?: "male" | "female" | "other";
|
||||
}
|
||||
|
||||
export interface LinkWhmcsRequest {
|
||||
|
||||
107
pnpm-lock.yaml
generated
107
pnpm-lock.yaml
generated
@ -6,6 +6,10 @@ settings:
|
||||
|
||||
importers:
|
||||
.:
|
||||
dependencies:
|
||||
"@sendgrid/mail":
|
||||
specifier: ^8.1.5
|
||||
version: 8.1.5
|
||||
devDependencies:
|
||||
"@eslint/eslintrc":
|
||||
specifier: ^3.3.1
|
||||
@ -22,6 +26,12 @@ importers:
|
||||
eslint-plugin-prettier:
|
||||
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)
|
||||
globals:
|
||||
specifier: ^16.3.0
|
||||
version: 16.3.0
|
||||
husky:
|
||||
specifier: ^9.1.7
|
||||
version: 9.1.7
|
||||
prettier:
|
||||
specifier: ^3.6.2
|
||||
version: 3.6.2
|
||||
@ -67,6 +77,9 @@ importers:
|
||||
"@prisma/client":
|
||||
specifier: ^6.14.0
|
||||
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":
|
||||
specifier: ^9.0.10
|
||||
version: 9.0.10
|
||||
@ -1837,6 +1850,27 @@ packages:
|
||||
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":
|
||||
resolution:
|
||||
{
|
||||
@ -3006,6 +3040,12 @@ packages:
|
||||
}
|
||||
engines: { node: ">=4" }
|
||||
|
||||
axios@1.11.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==,
|
||||
}
|
||||
|
||||
axobject-query@4.1.0:
|
||||
resolution:
|
||||
{
|
||||
@ -4446,6 +4486,18 @@ packages:
|
||||
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:
|
||||
resolution:
|
||||
{
|
||||
@ -4655,6 +4707,13 @@ packages:
|
||||
}
|
||||
engines: { node: ">=18" }
|
||||
|
||||
globals@16.3.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==,
|
||||
}
|
||||
engines: { node: ">=18" }
|
||||
|
||||
globalthis@1.0.4:
|
||||
resolution:
|
||||
{
|
||||
@ -4790,6 +4849,14 @@ packages:
|
||||
}
|
||||
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:
|
||||
resolution:
|
||||
{
|
||||
@ -6669,6 +6736,12 @@ packages:
|
||||
}
|
||||
engines: { node: ">= 0.10" }
|
||||
|
||||
proxy-from-env@1.1.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==,
|
||||
}
|
||||
|
||||
pump@3.0.3:
|
||||
resolution:
|
||||
{
|
||||
@ -9275,6 +9348,24 @@ snapshots:
|
||||
|
||||
"@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": {}
|
||||
|
||||
"@sindresorhus/is@4.6.0": {}
|
||||
@ -9993,6 +10084,14 @@ snapshots:
|
||||
|
||||
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: {}
|
||||
|
||||
babel-jest@30.0.5(@babel/core@7.28.3):
|
||||
@ -11002,6 +11101,8 @@ snapshots:
|
||||
|
||||
flatted@3.3.3: {}
|
||||
|
||||
follow-redirects@1.15.11: {}
|
||||
|
||||
for-each@0.3.5:
|
||||
dependencies:
|
||||
is-callable: 1.2.7
|
||||
@ -11156,6 +11257,8 @@ snapshots:
|
||||
|
||||
globals@14.0.0: {}
|
||||
|
||||
globals@16.3.0: {}
|
||||
|
||||
globalthis@1.0.4:
|
||||
dependencies:
|
||||
define-properties: 1.2.1
|
||||
@ -11225,6 +11328,8 @@ snapshots:
|
||||
|
||||
human-signals@2.1.0: {}
|
||||
|
||||
husky@9.1.7: {}
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
@ -12502,6 +12607,8 @@ snapshots:
|
||||
forwarded: 0.2.0
|
||||
ipaddr.js: 1.9.1
|
||||
|
||||
proxy-from-env@1.1.0: {}
|
||||
|
||||
pump@3.0.3:
|
||||
dependencies:
|
||||
end-of-stream: 1.4.5
|
||||
|
||||
@ -8,7 +8,7 @@ set -e
|
||||
# Configuration
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && 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"
|
||||
PROJECT_NAME="portal-prod"
|
||||
|
||||
@ -95,9 +95,9 @@ deploy() {
|
||||
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"
|
||||
|
||||
# Start application services (proxy + apps)
|
||||
# Start application services (apps only; Plesk handles proxy)
|
||||
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
|
||||
log "🏥 Performing health checks..."
|
||||
@ -137,7 +137,12 @@ status() {
|
||||
docker-compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" -p "$PROJECT_NAME" ps
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user