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:
T. Narantuya 2025-08-23 17:24:37 +09:00
parent 0c912fc04f
commit 111bbc8c91
63 changed files with 3236 additions and 197 deletions

View File

@ -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
View 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

View File

@ -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
# =============================================================================

View File

@ -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"]
}

View File

@ -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"

View File

@ -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,

View File

@ -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" })

View File

@ -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],

View File

@ -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;

View 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 };

View 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;
}

View 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;
}

View File

@ -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";
}

View File

@ -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
);

View File

@ -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> {

View 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 {}

View 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);
}
}
}

View 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;
}
}
}

View 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");
}
}

View 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,
});
}
}

View File

@ -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"}`;

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {}

View File

@ -55,7 +55,7 @@ export interface BulkMappingResult {
errors: Array<{
index: number;
error: string;
data: any;
data: unknown;
}>;
}

View File

@ -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[] = [];

View File

@ -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);
}
}

View File

@ -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,
});
}
}
}

View File

@ -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,
});

View File

@ -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");
}

View File

@ -96,7 +96,7 @@ export class SalesforceService implements OnModuleInit {
// === HEALTH CHECK ===
async healthCheck(): Promise<boolean> {
healthCheck(): boolean {
try {
return this.connection.isConnected();
} catch (error) {

View File

@ -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 {

View File

@ -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}`, {

View File

@ -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
*/

View File

@ -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");
}

View 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>;

View 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>;

View File

@ -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");

View File

@ -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");
}

View 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>
);
}

View 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>
);
}

View File

@ -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}>

View 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>
);
}

View 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" : ""}`}
>
IPoEHGW
</button>
<button
onClick={() => setMode("IPoE-BYOR")}
className={`border rounded p-3 ${mode === "IPoE-BYOR" ? "ring-2 ring-blue-500" : ""}`}
>
IPoEBYOR
</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" : ""}`}
>
12Month Installment
</button>
<button
onClick={() => setInstallPlan("24-Month")}
className={`border rounded p-3 ${installPlan === "24-Month" ? "ring-2 ring-blue-500" : ""}`}
>
24Month 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>
);
}

View 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";
}

View 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>
);
}

View 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>
);
}

View 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="Well 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>
);
}

View File

@ -20,5 +20,3 @@ export function AccountStatusCard() {
</div>
);
}

View File

@ -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: {

View File

@ -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();

View File

@ -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
}
}
}
}

View File

@ -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
View 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: IPoEHGW | IPoEBYOR | PPPoE)
- `Service_Speed__c` (Text, e.g., "1Gbps")
- Installation (separate line derived from these)
- `Installment_Plan__c` (Picklist: Onetime | 12Month | 24Month)
- `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 | Addon)
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 = Addon (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, IPoEHGW vs BYOR, Installment plan variants, Hikari Denwa, etc.).
- Pricing and terms come from the chosen product; we avoid config optionbased pricing.
- AddOrder (parameters used)
- `clientid` ← portal mapping `sfAccountId``whmcsClientId`
- `pid[]` ← for each OrderItem (Service/Installation/Addon) `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
View 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.

View 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 Accounts 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 customers 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
View 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

View File

@ -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,
},
},
},

View File

@ -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"
}
}

View 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";
}

View File

@ -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
View File

@ -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

View File

@ -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"
}