diff --git a/.env.dev.example b/.env.dev.example index 391788c5..20084b04 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -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) # ============================================================================= diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..e61548f9 --- /dev/null +++ b/.env.example @@ -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 + + diff --git a/.env.production.example b/.env.production.example index 322147ce..d5ad59da 100644 --- a/.env.production.example +++ b/.env.production.example @@ -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 # ============================================================================= diff --git a/.lintstagedrc.json b/.lintstagedrc.json index 4d8bf20d..49351bb5 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -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"] } - diff --git a/apps/bff/package.json b/apps/bff/package.json index fc7fef4d..ea91285a 100644 --- a/apps/bff/package.json +++ b/apps/bff/package.json @@ -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" diff --git a/apps/bff/src/app.module.ts b/apps/bff/src/app.module.ts index 6e11ddd3..2dc70243 100644 --- a/apps/bff/src/app.module.ts +++ b/apps/bff/src/app.module.ts @@ -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, diff --git a/apps/bff/src/auth/auth.controller.ts b/apps/bff/src/auth/auth.controller.ts index 50eda68a..06c41d71 100644 --- a/apps/bff/src/auth/auth.controller.ts +++ b/apps/bff/src/auth/auth.controller.ts @@ -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" }) diff --git a/apps/bff/src/auth/auth.module.ts b/apps/bff/src/auth/auth.module.ts index 66bda2d4..2b1cd6e6 100644 --- a/apps/bff/src/auth/auth.module.ts +++ b/apps/bff/src/auth/auth.module.ts @@ -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], diff --git a/apps/bff/src/auth/auth.service.ts b/apps/bff/src/auth/auth.service.ts index bccc0842..2bbe77e7 100644 --- a/apps/bff/src/auth/auth.service.ts +++ b/apps/bff/src/auth/auth.service.ts @@ -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( + "WHMCS_CUSTOMER_NUMBER_FIELD_ID", + "198" + ); + const dobFieldId = this.configService.get("WHMCS_DOB_FIELD_ID"); + const genderFieldId = this.configService.get("WHMCS_GENDER_FIELD_ID"); + const nationalityFieldId = this.configService.get("WHMCS_NATIONALITY_FIELD_ID"); + + const customfields: Record = {}; + 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 { + 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("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: ` +

We received a request to reset your password.

+

Click here to reset your password. This link expires in 15 minutes.

+

If you didn't request this, you can safely ignore this email.

+ `, + }); + } + } + + 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; diff --git a/apps/bff/src/auth/auth.types.ts b/apps/bff/src/auth/auth.types.ts new file mode 100644 index 00000000..37ecf828 --- /dev/null +++ b/apps/bff/src/auth/auth.types.ts @@ -0,0 +1,9 @@ +import type { Request } from "express"; + +export interface AuthUser { + userId: string; + email: string; + roles: string[]; +} + +export type RequestWithUser = Request & { user: AuthUser }; diff --git a/apps/bff/src/auth/dto/request-password-reset.dto.ts b/apps/bff/src/auth/dto/request-password-reset.dto.ts new file mode 100644 index 00000000..c83a316b --- /dev/null +++ b/apps/bff/src/auth/dto/request-password-reset.dto.ts @@ -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; +} diff --git a/apps/bff/src/auth/dto/reset-password.dto.ts b/apps/bff/src/auth/dto/reset-password.dto.ts new file mode 100644 index 00000000..745462a9 --- /dev/null +++ b/apps/bff/src/auth/dto/reset-password.dto.ts @@ -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; +} diff --git a/apps/bff/src/auth/dto/signup.dto.ts b/apps/bff/src/auth/dto/signup.dto.ts index 140553ef..eb9ade93 100644 --- a/apps/bff/src/auth/dto/signup.dto.ts +++ b/apps/bff/src/auth/dto/signup.dto.ts @@ -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"; } diff --git a/apps/bff/src/catalog/catalog.service.ts b/apps/bff/src/catalog/catalog.service.ts index 1b0b36bd..a1ab1703 100644 --- a/apps/bff/src/catalog/catalog.service.ts +++ b/apps/bff/src/catalog/catalog.service.ts @@ -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 { + async getProducts(): Promise { const cacheKey = "catalog:products"; const ttl = 15 * 60; // 15 minutes - return this.cacheService.getOrSet( + return this.cacheService.getOrSet( 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 ); diff --git a/apps/bff/src/common/config/env.validation.ts b/apps/bff/src/common/config/env.validation.ts index 4db32f74..c3209865 100644 --- a/apps/bff/src/common/config/env.validation.ts +++ b/apps/bff/src/common/config/env.validation.ts @@ -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): Record { diff --git a/apps/bff/src/common/email/email.module.ts b/apps/bff/src/common/email/email.module.ts new file mode 100644 index 00000000..038ea618 --- /dev/null +++ b/apps/bff/src/common/email/email.module.ts @@ -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 {} diff --git a/apps/bff/src/common/email/email.service.ts b/apps/bff/src/common/email/email.service.ts new file mode 100644 index 00000000..2765b8b1 --- /dev/null +++ b/apps/bff/src/common/email/email.service.ts @@ -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; +} + +@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 { + 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); + } + } +} diff --git a/apps/bff/src/common/email/providers/sendgrid.provider.ts b/apps/bff/src/common/email/providers/sendgrid.provider.ts new file mode 100644 index 00000000..e12649aa --- /dev/null +++ b/apps/bff/src/common/email/providers/sendgrid.provider.ts @@ -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("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("EMAIL_FROM", "no-reply@example.com"); + this.fromName = this.config.get("EMAIL_FROM_NAME"); + } + + async send(options: SendEmailOptions): Promise { + 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; + } + } +} diff --git a/apps/bff/src/common/email/queue/email.processor.ts b/apps/bff/src/common/email/queue/email.processor.ts new file mode 100644 index 00000000..744007e2 --- /dev/null +++ b/apps/bff/src/common/email/queue/email.processor.ts @@ -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 { + await this.emailService.sendEmail(job.data); + this.logger.debug("Processed email job"); + } +} diff --git a/apps/bff/src/common/email/queue/email.queue.ts b/apps/bff/src/common/email/queue/email.queue.ts new file mode 100644 index 00000000..4d77733d --- /dev/null +++ b/apps/bff/src/common/email/queue/email.queue.ts @@ -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, + @Inject(Logger) private readonly logger: Logger + ) {} + + async enqueueEmail(data: EmailJobData): Promise { + 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, + }); + } +} diff --git a/apps/bff/src/common/logging/logging.config.ts b/apps/bff/src/common/logging/logging.config.ts index 525ff0dc..7edd9e69 100644 --- a/apps/bff/src/common/logging/logging.config.ts +++ b/apps/bff/src/common/logging/logging.config.ts @@ -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"}`; diff --git a/apps/bff/src/common/utils/error.util.ts b/apps/bff/src/common/utils/error.util.ts index 2248d295..b3339424 100644 --- a/apps/bff/src/common/utils/error.util.ts +++ b/apps/bff/src/common/utils/error.util.ts @@ -159,7 +159,8 @@ export function createDeferredPromise(): { // 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 { diff --git a/apps/bff/src/health/health.controller.ts b/apps/bff/src/health/health.controller.ts index 622dd5f7..38d807b5 100644 --- a/apps/bff/src/health/health.controller.ts +++ b/apps/bff/src/health/health.controller.ts @@ -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 { diff --git a/apps/bff/src/health/health.module.ts b/apps/bff/src/health/health.module.ts index 0052383c..59b886fe 100644 --- a/apps/bff/src/health/health.module.ts +++ b/apps/bff/src/health/health.module.ts @@ -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 {} diff --git a/apps/bff/src/mappings/types/mapping.types.ts b/apps/bff/src/mappings/types/mapping.types.ts index 25933bfb..571de25e 100644 --- a/apps/bff/src/mappings/types/mapping.types.ts +++ b/apps/bff/src/mappings/types/mapping.types.ts @@ -55,7 +55,7 @@ export interface BulkMappingResult { errors: Array<{ index: number; error: string; - data: any; + data: unknown; }>; } diff --git a/apps/bff/src/mappings/validation/mapping-validator.service.ts b/apps/bff/src/mappings/validation/mapping-validator.service.ts index cf11f116..3dc0b535 100644 --- a/apps/bff/src/mappings/validation/mapping-validator.service.ts +++ b/apps/bff/src/mappings/validation/mapping-validator.service.ts @@ -142,10 +142,10 @@ export class MappingValidatorService { /** * Check for potential conflicts */ - async validateNoConflicts( + validateNoConflicts( request: CreateMappingRequest, existingMappings: UserIdMapping[] - ): Promise { + ): MappingValidationResult { const errors: string[] = []; const warnings: string[] = []; diff --git a/apps/bff/src/orders/orders.controller.ts b/apps/bff/src/orders/orders.controller.ts index 616aae78..ba7a6c7d 100644 --- a/apps/bff/src/orders/orders.controller.ts +++ b/apps/bff/src/orders/orders.controller.ts @@ -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; +} @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); + } } diff --git a/apps/bff/src/orders/orders.service.ts b/apps/bff/src/orders/orders.service.ts index 7eec2256..325ddaac 100644 --- a/apps/bff/src/orders/orders.service.ts +++ b/apps/bff/src/orders/orders.service.ts @@ -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; + 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 { + 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 { + 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 { + 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 { + 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 { + // 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, + }); + } + } } diff --git a/apps/bff/src/subscriptions/subscriptions.controller.ts b/apps/bff/src/subscriptions/subscriptions.controller.ts index 8d862a17..86b2984e 100644 --- a/apps/bff/src/subscriptions/subscriptions.controller.ts +++ b/apps/bff/src/subscriptions/subscriptions.controller.ts @@ -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 { // 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 { - return this.subscriptionsService.getActiveSubscriptions(req.user.id); + async getActiveSubscriptions(@Request() req: RequestWithUser): Promise { + 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 { 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, }); diff --git a/apps/bff/src/users/users.controller.ts b/apps/bff/src/users/users.controller.ts index 914b5525..606de875 100644 --- a/apps/bff/src/users/users.controller.ts +++ b/apps/bff/src/users/users.controller.ts @@ -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"); } diff --git a/apps/bff/src/vendors/salesforce/salesforce.service.ts b/apps/bff/src/vendors/salesforce/salesforce.service.ts index e060b25e..92c8594a 100644 --- a/apps/bff/src/vendors/salesforce/salesforce.service.ts +++ b/apps/bff/src/vendors/salesforce/salesforce.service.ts @@ -96,7 +96,7 @@ export class SalesforceService implements OnModuleInit { // === HEALTH CHECK === - async healthCheck(): Promise { + healthCheck(): boolean { try { return this.connection.isConnected(); } catch (error) { diff --git a/apps/bff/src/vendors/whmcs/cache/whmcs-cache.service.ts b/apps/bff/src/vendors/whmcs/cache/whmcs-cache.service.ts index 2c8ac42d..44bdea39 100644 --- a/apps/bff/src/vendors/whmcs/cache/whmcs-cache.service.ts +++ b/apps/bff/src/vendors/whmcs/cache/whmcs-cache.service.ts @@ -387,10 +387,10 @@ export class WhmcsCacheService { /** * Get cache statistics */ - async getCacheStats(): Promise<{ + getCacheStats(): { totalKeys: number; keysByType: Record; - }> { + } { // This would require Redis SCAN or similar functionality // For now, return a placeholder return { diff --git a/apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts b/apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts index e0367ad5..33a7a897 100644 --- a/apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts +++ b/apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts @@ -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}`, { diff --git a/apps/bff/src/vendors/whmcs/transformers/whmcs-data.transformer.ts b/apps/bff/src/vendors/whmcs/transformers/whmcs-data.transformer.ts index 5a8f17d3..672fdc1f 100644 --- a/apps/bff/src/vendors/whmcs/transformers/whmcs-data.transformer.ts +++ b/apps/bff/src/vendors/whmcs/transformers/whmcs-data.transformer.ts @@ -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 */ diff --git a/apps/bff/src/webhooks/guards/webhook-signature.guard.ts b/apps/bff/src/webhooks/guards/webhook-signature.guard.ts index d596ea20..a52ce864 100644 --- a/apps/bff/src/webhooks/guards/webhook-signature.guard.ts +++ b/apps/bff/src/webhooks/guards/webhook-signature.guard.ts @@ -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(); - 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("WHMCS_WEBHOOK_SECRET") + : this.configService.get("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"); } diff --git a/apps/bff/src/webhooks/schemas/salesforce.ts b/apps/bff/src/webhooks/schemas/salesforce.ts new file mode 100644 index 00000000..e1dea05a --- /dev/null +++ b/apps/bff/src/webhooks/schemas/salesforce.ts @@ -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; diff --git a/apps/bff/src/webhooks/schemas/whmcs.ts b/apps/bff/src/webhooks/schemas/whmcs.ts new file mode 100644 index 00000000..79138115 --- /dev/null +++ b/apps/bff/src/webhooks/schemas/whmcs.ts @@ -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; diff --git a/apps/bff/src/webhooks/webhooks.controller.ts b/apps/bff/src/webhooks/webhooks.controller.ts index 5711abf9..6c1e5c55 100644 --- a/apps/bff/src/webhooks/webhooks.controller.ts +++ b/apps/bff/src/webhooks/webhooks.controller.ts @@ -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"); diff --git a/apps/bff/src/webhooks/webhooks.service.ts b/apps/bff/src/webhooks/webhooks.service.ts index a805cc1d..a1f06187 100644 --- a/apps/bff/src/webhooks/webhooks.service.ts +++ b/apps/bff/src/webhooks/webhooks.service.ts @@ -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 { + 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 { + 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"); } diff --git a/apps/portal/src/app/auth/forgot-password/page.tsx b/apps/portal/src/app/auth/forgot-password/page.tsx new file mode 100644 index 00000000..d3767ddf --- /dev/null +++ b/apps/portal/src/app/auth/forgot-password/page.tsx @@ -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; + +export default function ForgotPasswordPage() { + const { requestPasswordReset, isLoading } = useAuthStore(); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + 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 ( + +
{ + void handleSubmit(onSubmit)(e); + }} + className="space-y-6" + > + {message && ( +
+ {message} +
+ )} + {error && ( +
+ {error} +
+ )} + +
+ + + {errors.email &&

{errors.email.message}

} +
+ + +
+
+ ); +} diff --git a/apps/portal/src/app/auth/reset-password/page.tsx b/apps/portal/src/app/auth/reset-password/page.tsx new file mode 100644 index 00000000..b81da80c --- /dev/null +++ b/apps/portal/src/app/auth/reset-password/page.tsx @@ -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; + +export default function ResetPasswordPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { resetPassword, isLoading } = useAuthStore(); + const [error, setError] = useState(null); + + const token = searchParams.get("token") || ""; + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + 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 ( + +
{ + void handleSubmit(onSubmit)(e); + }} + className="space-y-6" + > + {error && ( +
+ {error} +
+ )} + +
+ + + {errors.password && ( +

{errors.password.message}

+ )} +
+ +
+ + + {errors.confirmPassword && ( +

{errors.confirmPassword.message}

+ )} +
+ + +
+
+ ); +} diff --git a/apps/portal/src/app/auth/signup/page.tsx b/apps/portal/src/app/auth/signup/page.tsx index 6cee5118..38f6341e 100644 --- a/apps/portal/src/app/auth/signup/page.tsx +++ b/apps/portal/src/app/auth/signup/page.tsx @@ -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; @@ -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() { + {/* Address */}
- + - {errors.email &&

{errors.email.message}

} +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + + {errors.phone &&

{errors.phone.message}

} +
+
+ + +
+
+ +
+
+ + +
+
+ + + {errors.sfNumber && ( +

{errors.sfNumber.message}

+ )} +
+
+
+
+ + + {errors.email &&

{errors.email.message}

} +
+
+ + + {errors.confirmEmail && ( +

{errors.confirmEmail.message}

+ )} +
@@ -140,22 +296,38 @@ export default function SignupPage() { {errors.phone &&

{errors.phone.message}

}
-
- - - {errors.password && ( -

{errors.password.message}

- )} -

- Must be at least 8 characters with uppercase, lowercase, number, and special character -

+
+
+ + + {errors.password && ( +

{errors.password.message}

+ )} +

+ Must be at least 8 characters with uppercase, lowercase, number, and special character +

+
+
+ + + {errors.confirmPassword && ( +

{errors.confirmPassword.message}

+ )} +
+
+ + + ); +} diff --git a/apps/portal/src/app/catalog/internet/page.tsx b/apps/portal/src/app/catalog/internet/page.tsx new file mode 100644 index 00000000..bb7059a6 --- /dev/null +++ b/apps/portal/src/app/catalog/internet/page.tsx @@ -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(null); + const [mode, setMode] = useState(null); + const [installPlan, setInstallPlan] = useState(null); + const [weekend, setWeekend] = useState(false); + const [activationType, setActivationType] = useState<"Immediate" | "Scheduled">("Immediate"); + const [scheduledAt, setScheduledAt] = useState(""); + const router = useRouter(); + + const canContinue = + tier && mode && installPlan && (activationType === "Immediate" || scheduledAt); + + return ( + } + title="Home Internet" + description="Select plan, access mode, and installation options" + > +
+
+

Step 1: Plan

+
+ + +
+
+ +
+

Step 2: Access Mode

+
+ + + +
+
+ +
+

Step 3: Installation Plan

+
+ + + +
+ +
+ +
+

Step 4: Activation

+
+ + + {activationType === "Scheduled" && ( + setScheduledAt(e.target.value)} + /> + )} +
+
+ +
+ +
+
+
+ ); +} diff --git a/apps/portal/src/app/catalog/page.tsx b/apps/portal/src/app/catalog/page.tsx new file mode 100644 index 00000000..bb085d88 --- /dev/null +++ b/apps/portal/src/app/catalog/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filter, setFilter] = useState("All"); + + useEffect(() => { + let mounted = true; + void (async () => { + setLoading(true); + setError(null); + try { + const res = await authenticatedApi.get("/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 ( + } + title="Add Service(s)" + description="Choose a service to continue" + > +
+ + +
+ + {loading &&
Loading catalog…
} + {error &&
{error}
} + +
+ {visible.map(p => ( + +
+

{p.name}

+

SKU: {p.sku}

+ + Select + +
+ + ))} +
+
+ ); +} + +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"; +} diff --git a/apps/portal/src/app/catalog/vpn/page.tsx b/apps/portal/src/app/catalog/vpn/page.tsx new file mode 100644 index 00000000..921cea70 --- /dev/null +++ b/apps/portal/src/app/catalog/vpn/page.tsx @@ -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(null); + const [activationType, setActivationType] = useState<"Immediate" | "Scheduled">("Immediate"); + const [scheduledAt, setScheduledAt] = useState(""); + + return ( + } + title="VPN Rental Router" + description="Select a region" + > +
+
+

Region

+
+ + +
+
+ +
+

Activation

+
+ + + {activationType === "Scheduled" && ( + setScheduledAt(e.target.value)} + /> + )} +
+
+ +
+ +
+
+
+ ); +} diff --git a/apps/portal/src/app/checkout/page.tsx b/apps/portal/src/app/checkout/page.tsx new file mode 100644 index 00000000..3f439ed2 --- /dev/null +++ b/apps/portal/src/app/checkout/page.tsx @@ -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(null); + + const orderType = params.get("orderType") || ""; + + const selections = useMemo(() => { + const obj: Record = {}; + 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 ( + } + title="Checkout" + description="Verify details and place your order" + > +
+
+

Summary

+
+            {JSON.stringify({ orderType, selections }, null, 2)}
+          
+
+ + {error &&
{error}
} + +
+ +
+
+
+ ); +} diff --git a/apps/portal/src/app/orders/[id]/page.tsx b/apps/portal/src/app/orders/[id]/page.tsx new file mode 100644 index 00000000..c3920ff8 --- /dev/null +++ b/apps/portal/src/app/orders/[id]/page.tsx @@ -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(null); + const [error, setError] = useState(null); + + useEffect(() => { + let mounted = true; + const fetchStatus = async () => { + try { + const res = await authenticatedApi.get(`/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 ( + } + title={`Order ${params.id}`} + description="We’ll update this page as your order progresses" + > + {error &&
{error}
} +
+
+ Order Status: {data?.status || "Loading..."} +
+
+ Activation Status: {data?.activationStatus || "-"} +
+
+ Activation Type: {data?.activationType || "-"} +
+
+ Scheduled At: {data?.scheduledAt || "-"} +
+
+ WHMCS Order ID: {data?.whmcsOrderId || "-"} +
+
+
+ ); +} diff --git a/apps/portal/src/features/dashboard/components/AccountStatusCard.tsx b/apps/portal/src/features/dashboard/components/AccountStatusCard.tsx index 000c617e..82668b2a 100644 --- a/apps/portal/src/features/dashboard/components/AccountStatusCard.tsx +++ b/apps/portal/src/features/dashboard/components/AccountStatusCard.tsx @@ -20,5 +20,3 @@ export function AccountStatusCard() { ); } - - diff --git a/apps/portal/src/lib/auth/api.ts b/apps/portal/src/lib/auth/api.ts index be663e9a..cf7fb66a 100644 --- a/apps/portal/src/lib/auth/api.ts +++ b/apps/portal/src/lib/auth/api.ts @@ -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 { + return this.request("/auth/reset-password", { + method: "POST", + body: JSON.stringify(data), + }); + } + async getProfile(token: string): Promise { return this.request("/me", { headers: { diff --git a/apps/portal/src/lib/auth/store.ts b/apps/portal/src/lib/auth/store.ts index b0285e62..cf859f32 100644 --- a/apps/portal/src/lib/auth/store.ts +++ b/apps/portal/src/lib/auth/store.ts @@ -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; linkWhmcs: (email: string, password: string) => Promise<{ needsPasswordSet: boolean }>; setPassword: (email: string, password: string) => Promise; + requestPasswordReset: (email: string) => Promise; + resetPassword: (token: string, password: string) => Promise; logout: () => Promise; checkAuth: () => Promise; } @@ -67,6 +81,18 @@ export const useAuthStore = create()( 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()( } }, + 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(); diff --git a/apps/portal/src/lib/logger.ts b/apps/portal/src/lib/logger.ts index 130f2c03..4d113c08 100644 --- a/apps/portal/src/lib/logger.ts +++ b/apps/portal/src/lib/logger.ts @@ -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 + } } } } diff --git a/docker/prod/docker-compose.yml b/docker/prod/docker-compose.yml index 19327696..3279224f 100644 --- a/docker/prod/docker-compose.yml +++ b/docker/prod/docker-compose.yml @@ -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: diff --git a/docs/PORTAL-DATA-MODEL.md b/docs/PORTAL-DATA-MODEL.md new file mode 100644 index 00000000..6164d6a6 --- /dev/null +++ b/docs/PORTAL-DATA-MODEL.md @@ -0,0 +1,235 @@ +# Portal – Data Model & Mappings + +This document lists the objects, custom fields, and mappings used across Salesforce, WHMCS, and the Portal BFF. + +## Object inventory (what's standard vs custom) + +- Salesforce standard objects + - `Product2` (catalog) + - `Pricebook2` / `PricebookEntry` (pricing) + - `Order` (header) + - `OrderItem` (line) + +- Salesforce custom fields + - Added on `Product2`, `Order`, and `OrderItem` only (listed below with proposed API names). + +- WHMCS + - Use native User (login identity), Client (billing profile), Order, Invoice, and Service (Product/Service) entities and API parameters. We do not create custom tables in WHMCS. + +## Salesforce + +### Product2 (catalog source of truth) + +- Visibility & display + - `Portal_Visible__c` (Checkbox) + - `Portal_Category__c` (Picklist: Internet | eSIM | VPN | Other) + - `Portal_Description__c` (Long Text) + - `Portal_Feature_Bullets__c` (Long Text) + - `Portal_Hero_Image_URL__c` (URL) + - `Portal_Tags__c` (Text) + - `Portal_Sort_Order__c` (Number) + - `Portal_Valid_From__c` (Date) + - `Portal_Valid_Until__c` (Date) + +- Eligibility (Internet) + - `Portal_Eligibility_Dwelling__c` (Picklist: Home | Apartment | Any) + - `Portal_Eligibility_Tier__c` (Picklist: 1G | 100Mb | Any) + - `Portal_Eligibility_Region__c` (Text) + +- Terms & options + - `Portal_Billing_Cycle__c` (Picklist) + - `Portal_Max_Quantity__c` (Number) + - `Portal_Requires_Payment_Method__c` (Checkbox) + - (Avoid using configurable options for pricing; see Replica Product Strategy below) + +- WHMCS mapping + - `WHMCS_Product_Id__c` (Number) + - `WHMCS_Notes_Template__c` (Long Text) + - `eSIM_Settings_JSON__c` (Long Text) + +### PricebookEntry + +- Use a dedicated “Portal” pricebook; ensure entries exist for all visible Product2 records + +### Order (header) + +- Required + - `AccountId` + - `EffectiveDate` + - `Status` (Pending Review) + - `Order_Type__c` (Picklist: Internet | eSIM | SIM | VPN | Other) + +- Billing/Shipping snapshot (set on create; no ongoing sync) + - `BillToContactId` (Lookup Contact) + - `BillToStreet` + - `BillToCity` + - `BillToState` + - `BillToPostalCode` + - `BillToCountry` + - `ShipToContactId` (Lookup Contact) + - `ShipToStreet` + - `ShipToCity` + - `ShipToState` + - `ShipToPostalCode` + - `ShipToCountry` + +- Service configuration (since one Order = one service) + - Source category → `Order_Type__c` is auto-set from `Product2.Portal_Category__c` of the main service SKU at checkout. + - eSIM/SIM + - `SIM_Type__c` (Picklist: Physical SIM | eSIM) + - `EID__c` (Text; masked; required when SIM_Type\_\_c = eSIM) + - MNP (port-in) + - `MNP_Application__c` (Checkbox) + - `MNP_Reservation_Number__c` (Text, 10) + - `MNP_Expiry_Date__c` (Date) + - `MNP_Phone_Number__c` (Text, 11) + - `Porting_LastName_Kanji__c` (Text) + - `Porting_FirstName_Kanji__c` (Text) + - `Porting_LastName_Katakana__c` (Text) + - `Porting_FirstName_Katakana__c` (Text) + - `Porting_Gender__c` (Picklist: Male | Female | Corporate/Other) + - `Porting_DateOfBirth__c` (Date) + - `MVNO_Account_Number__c` (Text) + - Internet (service line config) + - `Internet_Plan_Tier__c` (Picklist: Platinum_Gold | Silver) + - `Access_Mode__c` (Picklist: IPoE‑HGW | IPoE‑BYOR | PPPoE) + - `Service_Speed__c` (Text, e.g., "1Gbps") + - Installation (separate line derived from these) + - `Installment_Plan__c` (Picklist: One‑time | 12‑Month | 24‑Month) + - `Installment_Months__c` (Number: 0 | 12 | 24) + - `Weekend_Install__c` (Checkbox) + +- Provisioning & results + - `Activation_Status__c` (Not Started | Activating | Activated | Failed) + - `Activation_Type__c` (Picklist: Immediate | Scheduled) + - `Activation_Scheduled_At__c` (Date/Time; required when Activation_Type\_\_c = Scheduled) + - `Activation_Error_Code__c` (Text) + - `Activation_Error_Message__c` (Text) + - `WHMCS_Order_ID__c` (Text/Number) + - `Last_Activation_At__c` (Datetime) + - `Activation_Attempt_Count__c` (Number) + +### OrderItem (line) + +- Standard + - `OrderId` + - `Product2Id` + - `PricebookEntryId` + - `Quantity` + - `UnitPrice` + +- Custom + - `Billing_Cycle__c` (Picklist) + - `ConfigOptions_JSON__c` (Long Text) (optional; not used for pricing) + - `Item_Type__c` (Picklist: Service | Installation | Add‑on) + +Notes + +- Since one Order represents one service, all service configuration fields live on the Order header. OrderItems are generated from the header: + - Item_Type = Service: Product2 = main service SKU; Billing_Cycle\_\_c typically Monthly. + - Item_Type = Installation: Product2 = installation SKU selected from `Installment_Plan__c`/`Weekend_Install__c`; Billing_Cycle\_\_c = Onetime or Monthly (for installments). + - Item_Type = Add‑on (e.g., Hikari Denwa): separate OrderItem with its own Product2. + +## WHMCS + +### Users & Clients (created on signup) + +- Create WHMCS User (User-level identity and login) + - `firstname` ← portal signup `firstName` + - `lastname` ← portal signup `lastName` + - `email` ← portal signup `email` + - `password` ← portal signup `password` + +- Create WHMCS Client (billing profile) + - `firstname` ← portal signup `firstName` + - `lastname` ← portal signup `lastName` + - `email` ← portal signup `email` + - `phonenumber?` ← portal signup `phone?` + - `companyname?` ← portal signup `company?` + +- Link User ↔ Client + - `user_id` ← created User id + - `client_id` ← created Client id + +- Set Client address + - `address1` ← portal address `street` + - `address2` ← portal address `addressLine2` + - `city` ← portal address `city` + - `state` ← portal address `state` + - `postcode` ← portal address `postalCode` + - `country` (ISO 2-letter) ← portal address `country` + +- Set Client custom field + - `CustomerNumber` (id/name to confirm) ← Salesforce Account Customer Number (provided via SF Number) + +- Payment methods + - Managed in WHMCS UI; portal uses SSO and checks via `GetPayMethods`; no PAN stored in portal + +### Orders & provisioning + +- Replica Product Strategy (preferred) + - Each sellable SKU is its own Product2 and WHMCS product (e.g., Internet Platinum vs Silver, IPoE‑HGW vs BYOR, Installment plan variants, Hikari Denwa, etc.). + - Pricing and terms come from the chosen product; we avoid config option–based pricing. + +- AddOrder (parameters used) + - `clientid` ← portal mapping `sfAccountId` → `whmcsClientId` + - `pid[]` ← for each OrderItem (Service/Installation/Add‑on) `Product2.WHMCS_Product_Id__c` + - `billingcycle` ← OrderItem `Billing_Cycle__c` + - `promocode?` ← Salesforce `Order.Promo_Code__c` or line-level + - `notes` ← include `sfOrderId=` + - `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. diff --git a/docs/PORTAL-FLOW.md b/docs/PORTAL-FLOW.md new file mode 100644 index 00000000..755cc6bf --- /dev/null +++ b/docs/PORTAL-FLOW.md @@ -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. diff --git a/docs/PORTAL-ORDERING-PROVISIONING.md b/docs/PORTAL-ORDERING-PROVISIONING.md new file mode 100644 index 00000000..787b64bc --- /dev/null +++ b/docs/PORTAL-ORDERING-PROVISIONING.md @@ -0,0 +1,460 @@ +# Portal Ordering & Provisioning – Overview (Index) + +This is the high-level overview. The spec is now split into two focused documents: + +- `PORTAL-FLOW.md` – architecture and end-to-end flow +- `PORTAL-DATA-MODEL.md` – objects, fields, and mappings + +Below is a concise summary; see the two docs above for details. + +- Backend: NestJS BFF (`apps/bff`) with existing integrations: WHMCS, Salesforce +- Frontend: Next.js portal (`apps/portal`) +- Billing: WHMCS (invoices, payment methods, subscriptions) +- Control plane: Salesforce (review/approval, provisioning trigger) +- Logging: centralized logger "dino" – do not introduce alternate loggers + +We require a Customer Number (SF Number) at signup and gate checkout on the presence of a WHMCS payment method. Orchestration runs in the BFF; Salesforce reviews and triggers provisioning. + +## 0) Architecture at a Glance + +- Source of truth + - Salesforce: Catalog (Product2 + PricebookEntry with portal fields), eligibility, order review/trigger, reporting + - WHMCS: Customer profile, payment methods, invoices, subscriptions (authoritative) + - Portal BFF: Orchestration + ID mappings (no customer data authority) +- Salesforce data model (three-object pattern) + - `Product2` (+ `PricebookEntry`) with portal fields (catalog + WHMCS mapping) + - `Order` (header; one per checkout) + - `OrderItem` (child; one per selected product) → references `Product2` (and PricebookEntry) +- Provisioning + - Operator approves in Salesforce → Quick Action calls BFF → BFF reads Order + OrderItems, dereferences `Product2` portal fields, calls WHMCS `AddOrder` → `AcceptOrder`, then writes back WHMCS IDs to Order/OrderItems + +## 1) Customer Experience + +1. Signup (Customer Number required) + - User provides: Email, Confirm Email, Password, Confirm Password, First/Last Name, optional Company/Phone, and Customer Number (SF Number). + - Portal validates and links to the existing Salesforce Account using the SF Number; if email differs, proceed and auto-create a Salesforce Case for CS (details in data model doc). + - WHMCS client is always created on signup (no pre-existing clients expected). WHMCS custom field for Customer Number must be set to the SF Number. + - Mapping is stored: `portalUserId ↔ whmcsClientId ↔ sfAccountId`. + +2. Add payment method (required before checkout) + - Portal shows an “Add payment method” CTA that opens WHMCS payment methods via SSO (`POST /auth/sso-link` → `index.php?rp=/account/paymentmethods`). + - Portal checks `GET /billing/payment-methods/summary` to confirm presence before enabling checkout. + +3. Browse catalog and configure + - `/catalog` lists products from BFF `GET /catalog` (reads Salesforce Product2 via BFF). + - Product detail pages collect configurable options; checkout button disabled until payment method exists. + +4. Place order + - `POST /orders` creates a Salesforce Order (Pending Review) and stores orchestration state in BFF. Portal shows “Awaiting review”. + +5. Review & Provision (operator in Salesforce) + - Operator reviews/approves. Quick Action “Provision in WHMCS” invokes BFF `POST /orders/{sfOrderId}/provision`. + - BFF validates payment method, (for eSIM) calls activation API, then `AddOrder` and `AcceptOrder` in WHMCS, updates Salesforce Order fields/status. + +6. Completion + - Subscriptions and invoices appear in portal (`/subscriptions`, `/billing/invoices`). Pay via WHMCS SSO links. + +## 1.1 Email Notifications + +We will send operational emails at key events (no email validation step required at signup): + +- Signup success: send Welcome email to customer; CC support. +- eSIM activation: send Activation email to customer; CC support. +- Order provisioned: send Provisioned/Next steps email to customer. + +Implementation notes: + +- Reuse centralized logger; no sensitive data in logs. +- Add a lightweight `EmailService` abstraction in BFF using existing modules style; queue via BullMQ jobs for reliability (Jobs module already present). Transport to be configured (SMTP/SendGrid) via env. +- Templates stored server-side; configurable CC list via env. + +## 2) Backend Contracts (BFF) + +### 2.1 Auth & Identity + +- Modify `POST /auth/signup` (exists) to require `sfNumber: string` (Customer Number). + - Steps: + - Validate request; check portal user exists by email; if exists → error (prompt login/reset). + - Salesforce: find Account by Customer Number (SF Number). If not found → error. + - WHMCS: create client unconditionally for this flow. Set Customer Number custom field to SF Number. + - Create portal user and store mapping. + - Send Welcome email (to customer) with support on CC. + - If email mismatch is detected between inputs/systems (e.g., SF Account email vs signup email), automatically create a Salesforce Case to notify CS team and include both emails and Account reference. + - Security: sanitize errors; never log passwords; use centralized logger. + +- `POST /auth/claim` (optional future): If we ever separate claim flow from signup. + +### 2.2 Identity & Data Mapping (Portal ↔ Salesforce ↔ WHMCS) + +- Systems of Record + - Salesforce: Catalog (Product2/PricebookEntry) and process control (Order approvals/status), account eligibility fields. Read-only from portal at runtime except Order creations/updates. + - WHMCS: Customer details, payment methods, invoices, subscriptions, provisioning outcomes. Source of truth for customer contact/billing data. + - Portal (BFF): Orchestration state and ID mappings only. + +- Mapping Records (existing): + - `portalUserId ↔ whmcsClientId ↔ sfAccountId` stored in BFF mappings service. + - Customer Number (SF Number) is provided once at signup to create the mapping; we do not ask again. + +- Portal User → Salesforce Account (lookup only) + - Authoritative lookup key: Customer Number on Account (provided via SF Number at signup). + - We do not sync profile/address changes from portal to Salesforce. + +- Portal User → WHMCS Client (authoritative for customer profile) + - Email → `email` + - First/Last Name → `firstname`, `lastname` + - Company → `companyname` (optional) + - Phone → `phonenumber` + - Address: `address1`, `address2`, `city`, `state`, `postcode`, `country` (ISO 2-letter) + - Custom Field (Customer Number) → set to SF Number (id/name TBD; currently used in mapping) + - Notes (optional) → include correlation IDs / SF refs + +- Discrepancy handling + - If SF Account email differs from signup email, proceed and auto-create a Salesforce Case for CS with both emails and Account reference (for review). No sync/write to SF. + +### 2.3 Address Capture (WHMCS only) + +- Capture Requirements + - Required: `street`, `city`, `state`, `postalCode`, `country` + - Optional: `addressLine2`, `buildingName`, `roomNumber`, `phone` + - Validation: client-side + server-side; normalize country to ISO code. + +- UX Flow + - After signup, prompt to complete Address before catalog/checkout. + - Dashboard banner if address incomplete. + +- API Usage + - Extend `PATCH /api/me/billing` to update WHMCS address fields only. No write to Salesforce. + - Centralized logging; redact PII. + +### 2.4 Billing + +- `GET /billing/payment-methods/summary` (new) + - Returns `{ hasPaymentMethod: boolean }` using WHMCS `GetPayMethods` for mapped client. + +- `POST /auth/sso-link` (exists) + - Used to open WHMCS payment methods and invoice/pay pages. + +### 2.5 Catalog (Salesforce Product2 as Source of Truth) + +We will not expose WHMCS catalog directly. Instead, Salesforce `Product2` (with `PricebookEntry`) will be the catalog, augmented with a small set of custom fields used by the portal and BFF. + +Custom fields on `Product2` (proposal; confirm API names): + +- Identity & Display + - `Portal_Category__c` (Picklist): Internet | eSIM | VPN | Other + - `Portal_Description__c` (Long Text) + - `Portal_Feature_Bullets__c` (Long Text) + - `Portal_Hero_Image_URL__c` (URL) + - `Portal_Tags__c` (Text) + - `Portal_Sort_Order__c` (Number) + - `Portal_Visible__c` (Checkbox, default true) + - `Portal_Valid_From__c` / `Portal_Valid_Until__c` (Date) +- Terms/Options + - `Portal_Billing_Cycle__c` (Picklist): Monthly | Quarterly | Semiannually | Annually + - `Portal_Max_Quantity__c` (Number, default 1) + - `Portal_Requires_Payment_Method__c` (Checkbox, default true) + - `Portal_ConfigOptions_JSON__c` (Long Text) – defaults and allowed values +- Eligibility (Internet personalization) + - `Portal_Eligibility_Dwelling__c` (Picklist): Home | Apartment | Any + - `Portal_Eligibility_Tier__c` (Picklist): 1G | 100Mb | Any + - `Portal_Eligibility_Region__c` (Text) (optional) +- WHMCS Mapping + - `WHMCS_Product_Id__c` (Number) + - `WHMCS_Notes_Template__c` (Long Text) + - `eSIM_Settings_JSON__c` (Long Text) + +Endpoints (BFF) + +- `GET /catalog` (exists): return public offerings from `Product2` where `Portal_Visible__c = true` and within validity dates; price via `PricebookEntry` for the portal pricebook. +- `GET /catalog/personalized` (new): + - Authenticated: infer `sfAccountId` from mapping. We only check the SF Number once during signup to create the mapping. + - Query `Product2` filtered by `Portal_Visible__c` and validity, then apply eligibility filters using Account fields (e.g., dwelling/tier). eSIM/VPN are always included. + +- Caching & Invalidation + - Cache global catalog 15m; cache personalized results per `sfAccountId` 5m. + - Optional Salesforce webhook to bust cache on `Product_Offering__c` changes. + +### 2.6 Orders & Provisioning + +- `POST /orders` (new) + - Body: `{ items: { productId, billingCycle, configOptions?, notes? }[], promoCode?, notes? }` + - Server-side checks: require WHMCS mapping; require `hasPaymentMethod=true`. + - Actions: Create Salesforce Order (Pending Review), persist orchestration record (sfOrderId, items/config, status=Pending Review, idempotency), return `{ sfOrderId, status }`. + +- `GET /orders/:sfOrderId` (new) + - Returns orchestration status and relevant IDs; portal polls for updates. + +- `POST /orders/:sfOrderId/provision` (new; invoked from Salesforce only) + - Auth: Named Credentials + signed headers (HMAC with timestamp/nonce) + IP allowlisting; require `Idempotency-Key`. + - Steps: + - Re-check payment method; if missing: set SF `Provisioning_Status__c=Failed`, `Error=Payment Method Missing`; return 409. + - If eSIM: call activation API; on success store masked ICCID/EID; on failure: update SF as Failed and return 502. + - WHMCS `AddOrder` (include `sfOrderId` in notes); then `AcceptOrder` to provision and create invoice/subscription. + - Update Salesforce Order fields and status to Provisioned; persist WHMCS IDs in orchestration record; return summary. + - Send Activation/Provisioned email depending on product and step outcome. + +## 3) Salesforce + +### 3.1 Account matching + +- Personalization Fields (Internet Eligibility) + - Use the Account’s serviceability/plan eligibility field(s) to decide which Internet product variants to show. + - Examples (to confirm API names and values): + - `Dwelling_Type__c`: `Home` | `Apartment` + - `Internet_Tier__c`: `1G` | `100Mb` + - The BFF personalization endpoint maps these to curated catalog SKUs. + +- Customer Number (SF Number) is authoritative. Signup requires it. We find Account by that number. +- Mirror SF Number to WHMCS client custom field. +- If a discrepancy is found (e.g., Account has a different email than signup), create a Salesforce Case automatically with context so CS can triage; proceed with signup (no hard block), but flag the portal user for review. + +### 3.2 Order fields + +- Add the following fields to `Order`: + - `Provisioning_Status__c` (Pending Review, Approved, Activating, Provisioned, Failed) + - `Provisioning_Error_Code__c` (short) + - `Provisioning_Error_Message__c` (sanitized) + - `WHMCS_Order_ID__c` + - `ESIM_ICCID__c` (masked), `Last_Provisioning_At__c`, `Attempt_Count__c` + +#### 3.2.1 Salesforce Order API & Required Fields (to confirm) + +- Object: `Order` +- Required fields for creation (proposal): + - `AccountId` (from SF Number lookup) + - `EffectiveDate` (today) + - `Status` (set to "Pending Review") + - `Description` (optional: include product summary) + - Custom: `Provisioning_Status__c = Pending Review` + - Optional link: `OpportunityId` (if created/available) +- On updates during provisioning: + - Set `Provisioning_Status__c` → Activating → Provisioned/Failed + - Store `WHMCS_Order_ID__c` + - For eSIM: masked `ESIM_ICCID__c` + +#### 3.2.2 Order Line Representation (Salesforce-side, to confirm) + +Options (pick one): + +1. Use standard `OrderItem` with `Product2` and Pricebooks (recommended) + - Pros: native SF pricing and reporting; clean standard model + - Cons: maintain `Product2` and `PricebookEntry` for all offerings + - Fields per `OrderItem` (standard): + - `OrderId`, `Product2Id`, `PricebookEntryId`, `Quantity`, `UnitPrice` + - Custom fields to add on `OrderItem`: + - `Billing_Cycle__c` (Picklist) + - `ConfigOptions_JSON__c` (Long Text) + +2. Custom child object `Order_Offering__c` + - Not used; we standardize on `OrderItem`. + +Decision: Use standard `OrderItem` with `Product2` and portal fields for mapping. + +We will build the BFF payload for WHMCS from these line records plus the Order header. + +#### 3.2.3 Salesforce ↔ WHMCS Order Mapping + +- Header mapping + - SF `Order.Id` → included in WHMCS `notes` as `sfOrderId=` + - SF `AccountId` → via portal mapping to `whmcsClientId` → `AddOrder.clientid` + - SF `Promo_Code__c` (if on header) → `AddOrder.promocode` + - SF `Provisioning_Status__c` controls operator flow; not sent to WHMCS + +- Line mapping (per OrderItem) + - Product2 `WHMCS_Product_Id__c` → `AddOrder.pid[]` + - SF `Billing_Cycle__c` → `AddOrder.billingcycle` (string) + - SF `ConfigOptions_JSON__c` → `AddOrder.configoptions` + - Quantity → replicate product ID in `pid[]` or use config option/quantity if applicable + +- After `AddOrder`: + - Call `AcceptOrder` to provision; capture `orderid` from response + - Update SF `WHMCS_Order_ID__c`; set `Provisioning_Status__c = Provisioned` on success + - On error, set `Provisioning_Status__c = Failed` and write short, sanitized `Provisioning_Error_Code__c` / `Provisioning_Error_Message__c` + +### 3.3 Quick Action / Flow + +- Quick Action “Provision in WHMCS” calls BFF `POST /orders/{sfOrderId}/provision` with headers: + - `Authorization` (Named Credentials) + - `Idempotency-Key` (UUID) + - `X-Timestamp`, `X-Nonce`, `X-Signature` (HMAC of method+path+timestamp+nonce+body) + +### 3.4 UI + +### 3.5 Catalog → Order → Provisioning Linkage (Clean Mapping) + +- Single source of mapping truth: Product2 portal fields + - `WHMCS_Product_Id__c`, `Portal_ConfigOptions_JSON__c`, and `Provisioning_Flow__c` live on Product2. + - Do not duplicate these fields on `OrderItem`; each line references Product2 and price from PricebookEntry. + - Snapshot only what can change over time: `UnitPrice`, `Billing_Cycle__c`, and `Quantity` on the line. + +- Order construction (by portal at checkout) + - Create `Order` header with `Provisioning_Status__c = Pending Review`. + - For each cart item, create a line (either `OrderItem` with custom fields or `Order_Offering__c`) that includes: + - `Product2Id` and `PricebookEntryId` + - `Quantity`, `UnitPrice__c`, `Billing_Cycle__c` + - Optional overrides in `ConfigOptions_JSON__c` (e.g., size, add-ons) based on user selection + +- Provisioning (triggered from Salesforce) + - BFF receives `sfOrderId`, loads `Order` and its lines. + - For each line, dereference Product2 to fetch `WHMCS_Product_Id__c` and default config options, then merge with any line-level overrides in `ConfigOptions_JSON__c`. + - Build `AddOrder` payload using the mapping above; place `sfOrderId` in WHMCS `notes`. + - After `AcceptOrder`, write back: + - Header: `WHMCS_Order_ID__c` + - Header: `Provisioning_Status__c = Provisioned` on success; set error fields on failure (sanitized) + +- Subscriptions linkage + - The authoritative subscription record lives in WHMCS. + +This keeps the mapping clean and centralized in Product2 portal fields, while Orders/OrderItems act as a snapshot of the customer’s selection and price at time of checkout. + +## 3.5 Flow Sanity Check + +1. Catalog comes from Salesforce Product2 (filtered/personalized by Account eligibility). +2. Customer signs up with SF Number; portal creates WHMCS client and mapping; address/profile managed in WHMCS. +3. Checkout creates an SF `Order` and child lines (no provisioning yet). +4. Operator approves in SF and clicks Quick Action. +5. SF calls BFF to provision: BFF rechecks payment method (WHMCS), handles eSIM activation if needed, then `AddOrder` + `AcceptOrder` in WHMCS using mappings from Product2 portal fields referenced by the OrderItems. +6. BFF updates SF Order fields (`WHMCS_Order_ID__c`, etc.) and status; emails are sent as required. +7. Customer sees completed order; subscriptions/invoices appear from WHMCS data in the portal. + +- LWC on `Order` to display provisioning status, errors, WHMCS IDs, and a Retry button. + +## 4) Frontend (Portal) + +- Signup page: add `sfNumber` field; validation and error messages for missing/invalid SF Number. +- Payment banner: dashboard shows CTA to add a payment method if none. +- Catalog: `/catalog` page using existing BFF endpoint. +- Product detail + Checkout: + - Checkout button disabled until `hasPaymentMethod=true` (via `GET /billing/payment-methods/summary`). + - On submit, call `POST /orders` and redirect to order status page with polling. +- Order status page: shows statuses (Pending Review → Activating → Provisioned/Failed), with links to Subscriptions and Invoices. + +### 4.1 eSIM Self-service Actions (Service Detail) + +- Actions available on an active eSIM subscription: + - Reissue eSIM: triggers BFF endpoint to call activation provider for a new profile, updates WHMCS notes/custom fields, sends email to customer. + - Top-up: triggers BFF to call provider top-up API; invoice/charges handled via WHMCS (AddOrder for add-on or gateway charge depending on implementation), sends email confirmation. +- UI: buttons gated by subscription status; confirmations and progress states. + +## 5) Security, Idempotency, Observability + +- Secrets in env/KMS, HTTPS-only, strict timeouts and retries with backoff in BFF external calls. +- Signed Salesforce → BFF requests with short TTL; IP allowlisting of Salesforce egress ranges. +- Idempotency keys for order creation and provisioning; include `sfOrderId` marker in WHMCS order notes. +- Logging: use centralized logger "dino" only; redact sensitive values; no payment data. +- Metrics: activation latency, WHMCS API error rates, provisioning success/failure, retries; alerts on anomalies. + +## 6) Data Storage (minimal in BFF) + +- Orchestration record: `sfOrderId`, items/config, status, masked eSIM identifiers, WHMCS order/service IDs, timestamps, idempotency keys. +- Mappings: `userId ↔ whmcsClientId ↔ sfAccountId`. +- No PANs, CVVs, or gateway tokens stored. + +## 7) Work Items + +1. Auth: require `sfNumber` in `SignupDto` and signup flow; lookup SF Account by Customer Number; align WHMCS custom field. +2. Billing: add `GET /billing/payment-methods/summary` and frontend gating. +3. Catalog UI: `/catalog` + product details pages. +4. Orders API: implement `POST /orders`, `GET /orders/:sfOrderId`, `POST /orders/:sfOrderId/provision`. +5. Salesforce: fields, Quick Action/Flow, Named Credential + signing; LWC for status. +6. WHMCS: add wrappers for `AddOrder`, `AcceptOrder`, `GetPayMethods` (if not already exposed). +7. Observability: correlation IDs, metrics, alerts; webhook processing for cache busting (optional). +8. Email: implement `EmailService` with provider; add BullMQ jobs for async sending; add templates for Signup, eSIM Activation, Provisioned. +9. eSIM Actions: implement `POST /subscriptions/:id/reissue-esim` and `POST /subscriptions/:id/topup` endpoints with BFF provider calls and WHMCS updates. +10. Future: Cancellations form → Salesforce Cancellations object submission (no immediate service cancel by customer). + +## 8) Acceptance Criteria + +- Signup requires Customer Number (SF Number) and links to the correct Salesforce Account and WHMCS client. +- Portal blocks checkout until a WHMCS payment method exists; SSO to WHMCS to add card. +- Orders are created in Salesforce and provisioned via BFF after operator trigger; idempotent and retriable. +- Customer sees clear order status and resulting subscriptions/invoices; sensitive details are not exposed. + +## 9) WHMCS Field Mapping (Order Creation) + +- `AddOrder` parameters to use: + - `clientid`: from user mapping + - `pid[]`: array of WHMCS product IDs (map from our catalog selection) + - `billingcycle`: `monthly` | `quarterly` | `semiannually` | `annually` | etc. + - `configoptions`: key/value for configurable options (from product detail form) + - `customfields`: include Customer Number (SF Number) and any order-specific data + - `paymentmethod`: WHMCS gateway system name (optional if default) + - `promocode`: if provided + - `notes`: include `sfOrderId=` 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; 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. diff --git a/docs/PORTAL-ROADMAP.md b/docs/PORTAL-ROADMAP.md new file mode 100644 index 00000000..a85cd674 --- /dev/null +++ b/docs/PORTAL-ROADMAP.md @@ -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 diff --git a/eslint.config.mjs b/eslint.config.mjs index 2addceea..5ca23349 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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, }, }, }, diff --git a/package.json b/package.json index e3d5a0ae..5ec54bf6 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/packages/shared/src/skus.ts b/packages/shared/src/skus.ts new file mode 100644 index 00000000..ed93b981 --- /dev/null +++ b/packages/shared/src/skus.ts @@ -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> = { + 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 = { + "One-time": "INT-INSTALL-ONETIME", + "12-Month": "INT-INSTALL-12M", + "24-Month": "INT-INSTALL-24M", +}; + +const VPN_SKU: Record = { + "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"; +} diff --git a/packages/shared/src/user.ts b/packages/shared/src/user.ts index 21faccff..19c3affb 100644 --- a/packages/shared/src/user.ts +++ b/packages/shared/src/user.ts @@ -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 { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9d9aba8..ea5e58f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/scripts/prod/manage.sh b/scripts/prod/manage.sh index 9aa3113e..5b9c7dfc 100755 --- a/scripts/prod/manage.sh +++ b/scripts/prod/manage.sh @@ -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" }