Merge pull request #41 from NTumurbars/Homepage

Homepage
This commit is contained in:
NTumurbars 2025-12-25 16:02:53 +09:00 committed by GitHub
commit 87766fb1d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
511 changed files with 31614 additions and 5979 deletions

View File

@ -0,0 +1,390 @@
---
name: Restructure to Account Portal
overview: Restructure the app to have public pages under (public)/ and all authenticated portal pages under /account/*, with auth-aware headers in public shells.
todos:
- id: auth-aware-public-shell
content: "Make PublicShell auth-aware: show 'My Account' for logged-in users, 'Sign in' for guests"
status: pending
- id: auth-aware-catalog-shell
content: Make CatalogShell auth-aware with same pattern
status: pending
- id: create-account-layout
content: Create account/layout.tsx with AppShell and auth guard redirect
status: pending
- id: move-dashboard-to-account
content: Move dashboard page to account/page.tsx
status: pending
- id: move-billing-to-account
content: Move billing pages to account/billing/*
status: pending
- id: move-subscriptions-to-services
content: Move subscriptions to account/services/*
status: pending
- id: move-orders-to-account
content: Move orders to account/orders/*
status: pending
- id: move-support-to-account
content: Move support cases to account/support/*
status: pending
- id: move-profile-to-settings
content: Move account/profile to account/settings/*
status: pending
- id: fix-shop-double-header
content: Fix shop layout to not create double header - add CatalogNav only
status: pending
- id: create-contact-route
content: Create (public)/contact/page.tsx for contact form
status: pending
- id: update-navigation
content: Update AppShell navigation.ts with /account/* paths
status: pending
- id: update-catalog-links
content: Replace all /catalog links with /shop
status: pending
- id: update-portal-links
content: Replace all old portal links with /account/* paths
status: pending
- id: remove-sfnumber
content: Remove sfNumber from domain schema and signup components
status: pending
- id: delete-old-authenticated
content: Delete (authenticated)/ directory after migration
status: pending
- id: rebuild-test
content: Rebuild domain package and test all routes
status: pending
---
# Restructure Portal to /account/\* Architecture
## Target Architecture
```mermaid
flowchart TB
subgraph public ["(public)/ - Public Pages"]
P1["/"] --> Home["Homepage"]
P2["/auth/*"] --> Auth["Login, Signup, etc"]
P3["/shop/*"] --> Shop["Product Catalog"]
P4["/help"] --> Help["FAQ & Knowledge Base"]
P5["/contact"] --> Contact["Contact Form"]
P6["/order/*"] --> Order["Checkout Flow"]
end
subgraph account ["/account/* - My Portal"]
A1["/account"] --> Dashboard["Dashboard"]
A2["/account/billing"] --> Billing["Invoices & Payments"]
A3["/account/services"] --> Services["My Subscriptions"]
A4["/account/orders"] --> Orders["Order History"]
A5["/account/support"] --> Support["My Tickets"]
A6["/account/settings"] --> Settings["Profile Settings"]
end
public -.->|"Auth-aware header"| account
```
---
## Phase 1: Make Shells Auth-Aware
### 1.1 Update PublicShell
**File:** `apps/portal/src/components/templates/PublicShell/PublicShell.tsx`
Add auth detection to header navigation:
```tsx
"use client";
import { useAuthStore } from "@/features/auth/services/auth.store";
export function PublicShell({ children }: PublicShellProps) {
const { isAuthenticated } = useAuthStore();
return (
<div className="min-h-screen...">
<header>
<nav>
<Link href="/shop">Services</Link>
<Link href="/help">Support</Link>
{isAuthenticated ? (
<Link href="/account" className="primary-button">
My Account
</Link>
) : (
<Link href="/auth/login" className="primary-button">
Sign in
</Link>
)}
</nav>
</header>
...
</div>
);
}
```
### 1.2 Update CatalogShell
**File:** `apps/portal/src/components/templates/CatalogShell/CatalogShell.tsx`
Same auth-aware pattern - show "My Account" or "Sign in" based on auth state.
---
## Phase 2: Create /account Route Structure
### 2.1 Create Account Layout with Auth Guard
**File:** `apps/portal/src/app/account/layout.tsx` (NEW)
```tsx
import { redirect } from "next/navigation";
import { cookies } from "next/headers";
import { AppShell } from "@/components/organisms/AppShell";
export default async function AccountLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies();
const hasAuthToken = cookieStore.has("access_token");
if (!hasAuthToken) {
redirect("/auth/login?redirect=/account");
}
return <AppShell>{children}</AppShell>;
}
```
### 2.2 Create Account Pages
Move and rename pages:
| Current Path | New Path | New File |
|--------------|----------|----------|
| `(authenticated)/dashboard/page.tsx` | `/account` | `account/page.tsx` |
| `(authenticated)/billing/*` | `/account/billing/*` | `account/billing/*` |
| `(authenticated)/subscriptions/*` | `/account/services/*` | `account/services/*` |
| `(authenticated)/orders/*` | `/account/orders/*` | `account/orders/*` |
| `(authenticated)/support/*` | `/account/support/*` | `account/support/*` |
| `(authenticated)/account/*` | `/account/settings/*` | `account/settings/*` |
---
## Phase 3: Update Navigation
### 3.1 Update AppShell Navigation
**File:** `apps/portal/src/components/organisms/AppShell/navigation.ts`
Update all paths to use `/account/*`:
```typescript
export const baseNavigation: NavigationItem[] = [
{ name: "Dashboard", href: "/account", icon: HomeIcon },
{ name: "Orders", href: "/account/orders", icon: ClipboardDocumentListIcon },
{
name: "Billing",
icon: CreditCardIcon,
children: [
{ name: "Invoices", href: "/account/billing/invoices" },
{ name: "Payment Methods", href: "/account/billing/payments" },
],
},
{
name: "My Services",
icon: ServerIcon,
children: [{ name: "All Services", href: "/account/services" }],
},
{ name: "Shop", href: "/shop", icon: Squares2X2Icon }, // Links to public shop
{
name: "Support",
icon: ChatBubbleLeftRightIcon,
children: [
{ name: "My Tickets", href: "/account/support" },
{ name: "New Ticket", href: "/account/support/new" },
],
},
{ name: "Settings", href: "/account/settings", icon: UserIcon },
{ name: "Log out", href: "#", icon: ArrowRightStartOnRectangleIcon, isLogout: true },
];
```
---
## Phase 4: Fix Public Routes
### 4.1 Fix Double Header in Shop
Remove the nested shell issue by having CatalogShell NOT render a full page wrapper, or by not nesting it under PublicShell.
**Option A:** Move shop out of (public) to its own route with CatalogShell only
**Option B:** Have (public)/shop/layout.tsx return just children with catalog nav (no shell)
Recommended: **Option B** - Keep shop under (public) but have shop layout add only catalog navigation, not a full shell.
**File:** `apps/portal/src/app/(public)/shop/layout.tsx`
```tsx
import { CatalogNav } from "@/components/templates/CatalogShell";
export default function ShopLayout({ children }: { children: React.ReactNode }) {
// Don't wrap with another shell - parent (public) layout already has PublicShell
return (
<>
<CatalogNav />
{children}
</>
);
}
```
**File:** `apps/portal/src/components/templates/CatalogShell/CatalogShell.tsx`
Split into two exports:
- `CatalogShell` - full shell (if ever needed standalone)
- `CatalogNav` - just the navigation bar
### 4.2 Create /contact Route
**File:** `apps/portal/src/app/(public)/contact/page.tsx` (NEW)
Move content from `(public)/help/contact/` to `(public)/contact/`.
---
## Phase 5: Delete Old Routes
### 5.1 Delete (authenticated) Directory
After moving all content to /account/:
- Delete entire `apps/portal/src/app/(authenticated)/` directory
### 5.2 Clean Up Unused Files
- Delete `(public)/help/contact/` (moved to /contact)
- Keep `(public)/help/page.tsx` for FAQ
---
## Phase 6: Update All Internal Links
### 6.1 Update /catalog to /shop Links
Replace in feature components (11 files, 27 occurrences):
```
/catalog → /shop
/catalog/internet → /shop/internet
/catalog/sim → /shop/sim
/catalog/vpn → /shop/vpn
```
### 6.2 Update Dashboard/Portal Links
Replace throughout codebase:
```
/dashboard → /account
/billing → /account/billing
/subscriptions → /account/services
/orders → /account/orders
/support/cases → /account/support
```
---
## Phase 7: Remove sfNumber from Signup
### 7.1 Update Domain Schema
**File:** `packages/domain/auth/schema.ts`
```typescript
// Line 44: Remove required sfNumber
// Before:
sfNumber: z.string().min(6, "Customer number must be at least 6 characters"),
// After:
sfNumber: z.string().optional(),
```
Also update `validateSignupRequestSchema` to not require sfNumber.
### 7.2 Update SignupForm Components
- `SignupForm.tsx` - Remove sfNumber from initialValues and validation
- `AccountStep.tsx` - Remove Customer Number form field
- `ReviewStep.tsx` - Remove Customer Number display
---
## Phase 8: Rebuild and Test
### 8.1 Rebuild Domain Package
```bash
pnpm --filter @customer-portal/domain build
```
### 8.2 Test Matrix
| Scenario | URL | Expected |
|----------|-----|----------|
| Public homepage | `/` | PublicShell, homepage content |
| Public shop | `/shop` | CatalogShell (auth-aware), products |
| Auth user in shop | `/shop` | "My Account" button, personalized pricing |
| Public help | `/help` | FAQ content |
| Public contact | `/contact` | Contact form, prefills if logged in |
| Login | `/auth/login` | Login form |
| Signup | `/auth/signup` | No sfNumber field |
| Account dashboard | `/account` | AppShell, dashboard (redirect if not auth) |
| My services | `/account/services` | Subscriptions list |
| My tickets | `/account/support` | Support cases |
| Checkout | `/order` | CheckoutShell, wizard |
---
## Files Summary
| Category | Action | Count |
|----------|--------|-------|
| New account/ routes | Create | ~15 files |
| Shell components | Modify | 2 (PublicShell, CatalogShell) |
| Shop layout | Modify | 1 |
| Navigation | Modify | 1 |
| Link updates | Modify | ~20 files |
| Domain schema | Modify | 1 |
| Signup components | Modify | 3 |
| Delete old routes | Delete | ~20 files |
**Total: ~60+ file operations**

View File

@ -23,8 +23,6 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.25.0
- name: Setup Node.js
uses: actions/setup-node@v4

View File

@ -22,8 +22,6 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: "10.25.0"
- name: Get pnpm store directory
id: pnpm-cache

View File

@ -30,8 +30,6 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: "10.25.0"
- name: Get pnpm store directory
id: pnpm-cache
@ -139,8 +137,6 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: "10.25.0"
- name: Check for outdated dependencies
run: |

View File

@ -1,4 +1,10 @@
# Stage all modified tracked files (includes manual fixes to already-staged files)
git add -u
# Run lint-staged on staged files (checks linting, formats with prettier, and re-stages)
pnpm lint-staged
# Run type check
pnpm type-check
# Security audit is enforced in CI (`.github/workflows/security.yml`).

View File

@ -1,4 +1,4 @@
{
"*.{ts,tsx,js,jsx}": ["eslint --fix", "prettier -w"],
"*.{ts,tsx,js,jsx}": ["eslint --fix --no-warn-ignored", "prettier -w"],
"*.{json,md,yml,yaml,css,scss}": ["prettier -w"]
}

View File

@ -280,7 +280,7 @@ When running `pnpm dev:tools`, you get access to:
- `POST /api/auth/signup` - Create portal user → WHMCS AddClient → SF upsert
- `POST /api/auth/login` - Portal authentication
- `POST /api/auth/link-whmcs` - OIDC callback or ValidateLogin
- `POST /api/auth/migrate` - Account migration from legacy portal
- `POST /api/auth/set-password` - Required after WHMCS link
### User Management
@ -292,7 +292,7 @@ When running `pnpm dev:tools`, you get access to:
### Catalog & Orders
- `GET /api/catalog` - WHMCS GetProducts (cached 5-15m)
- `GET /api/services/*` - Services catalog endpoints (internet/sim/vpn)
- `POST /api/orders` - WHMCS AddOrder with idempotency
### Invoices
@ -481,7 +481,7 @@ rm -rf node_modules && pnpm install
- **[Deployment Guide](docs/DEPLOY.md)** - Production deployment instructions
- **[Architecture](docs/STRUCTURE.md)** - Code organization and conventions
- **[Logging](docs/LOGGING.md)** - Logging configuration and best practices
- **Portal Guides** - High-level flow, data ownership, and error handling (`docs/portal-guides/README.md`)
- **Portal Guides** - High-level flow, data ownership, and error handling (`docs/how-it-works/README.md`)
## Contributing

View File

@ -119,8 +119,8 @@ Security audits are automatically run on:
### Internal Documentation
- [Environment Configuration](./docs/portal-guides/COMPLETE-GUIDE.md)
- [Deployment Guide](./docs/portal-guides/)
- [Environment Configuration](./docs/how-it-works/COMPLETE-GUIDE.md)
- [Deployment Guide](./docs/getting-started/)
### External Resources

View File

@ -38,6 +38,7 @@
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.9",
"@nestjs/platform-express": "^11.1.9",
"@nestjs/schedule": "^6.1.0",
"@prisma/adapter-pg": "^7.1.0",
"@prisma/client": "^7.1.0",
"@sendgrid/mail": "^8.1.6",

View File

@ -0,0 +1,27 @@
-- Add residence card verification storage
CREATE TYPE "ResidenceCardStatus" AS ENUM ('PENDING', 'VERIFIED', 'REJECTED');
CREATE TABLE "residence_card_submissions" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"status" "ResidenceCardStatus" NOT NULL DEFAULT 'PENDING',
"filename" TEXT NOT NULL,
"mime_type" TEXT NOT NULL,
"size_bytes" INTEGER NOT NULL,
"content" BYTEA NOT NULL,
"submitted_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"reviewed_at" TIMESTAMP(3),
"reviewer_notes" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "residence_card_submissions_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "residence_card_submissions_user_id_key" ON "residence_card_submissions"("user_id");
ALTER TABLE "residence_card_submissions"
ADD CONSTRAINT "residence_card_submissions_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,40 @@
-- CreateEnum
CREATE TYPE "NotificationType" AS ENUM ('ELIGIBILITY_ELIGIBLE', 'ELIGIBILITY_INELIGIBLE', 'VERIFICATION_VERIFIED', 'VERIFICATION_REJECTED', 'ORDER_APPROVED', 'ORDER_ACTIVATED', 'ORDER_FAILED', 'CANCELLATION_SCHEDULED', 'CANCELLATION_COMPLETE', 'PAYMENT_METHOD_EXPIRING', 'INVOICE_DUE', 'SYSTEM_ANNOUNCEMENT');
-- CreateEnum
CREATE TYPE "NotificationSource" AS ENUM ('SALESFORCE', 'WHMCS', 'PORTAL', 'SYSTEM');
-- AlterTable
ALTER TABLE "residence_card_submissions" ALTER COLUMN "updated_at" DROP DEFAULT;
-- CreateTable
CREATE TABLE "notifications" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"type" "NotificationType" NOT NULL,
"title" TEXT NOT NULL,
"message" TEXT,
"action_url" TEXT,
"action_label" TEXT,
"source" "NotificationSource" NOT NULL DEFAULT 'SALESFORCE',
"source_id" TEXT,
"read" BOOLEAN NOT NULL DEFAULT false,
"read_at" TIMESTAMP(3),
"dismissed" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "notifications_user_id_read_dismissed_idx" ON "notifications"("user_id", "read", "dismissed");
-- CreateIndex
CREATE INDEX "notifications_user_id_created_at_idx" ON "notifications"("user_id", "created_at");
-- CreateIndex
CREATE INDEX "notifications_expires_at_idx" ON "notifications"("expires_at");
-- AddForeignKey
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -36,6 +36,8 @@ model User {
updatedAt DateTime @updatedAt @map("updated_at")
auditLogs AuditLog[]
idMapping IdMapping?
residenceCardSubmission ResidenceCardSubmission?
notifications Notification[]
@@map("users")
}
@ -91,6 +93,30 @@ enum AuditAction {
SYSTEM_MAINTENANCE
}
enum ResidenceCardStatus {
PENDING
VERIFIED
REJECTED
}
model ResidenceCardSubmission {
id String @id @default(uuid())
userId String @unique @map("user_id")
status ResidenceCardStatus @default(PENDING)
filename String
mimeType String @map("mime_type")
sizeBytes Int @map("size_bytes")
content Bytes @db.ByteA
submittedAt DateTime @default(now()) @map("submitted_at")
reviewedAt DateTime? @map("reviewed_at")
reviewerNotes String? @map("reviewer_notes")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("residence_card_submissions")
}
// Per-SIM daily usage snapshot used to build full-month charts
model SimUsageDaily {
id Int @id @default(autoincrement())
@ -191,3 +217,63 @@ model SimHistoryImport {
@@map("sim_history_imports")
}
// =============================================================================
// Notifications - In-app notifications synced with Salesforce email triggers
// =============================================================================
model Notification {
id String @id @default(uuid())
userId String @map("user_id")
// Notification content
type NotificationType
title String
message String?
// Action (optional CTA button)
actionUrl String? @map("action_url")
actionLabel String? @map("action_label")
// Source tracking for deduplication
source NotificationSource @default(SALESFORCE)
sourceId String? @map("source_id") // SF Account ID, Order ID, etc.
// Status
read Boolean @default(false)
readAt DateTime? @map("read_at")
dismissed Boolean @default(false)
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime @map("expires_at") // 30 days from creation
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, read, dismissed])
@@index([userId, createdAt])
@@index([expiresAt])
@@map("notifications")
}
enum NotificationType {
ELIGIBILITY_ELIGIBLE
ELIGIBILITY_INELIGIBLE
VERIFICATION_VERIFIED
VERIFICATION_REJECTED
ORDER_APPROVED
ORDER_ACTIVATED
ORDER_FAILED
CANCELLATION_SCHEDULED
CANCELLATION_COMPLETE
PAYMENT_METHOD_EXPIRING
INVOICE_DUE
SYSTEM_ANNOUNCEMENT
}
enum NotificationSource {
SALESFORCE
WHMCS
PORTAL
SYSTEM
}

View File

@ -2,6 +2,7 @@ import { Module } from "@nestjs/common";
import { APP_PIPE } from "@nestjs/core";
import { RouterModule } from "@nestjs/core";
import { ConfigModule } from "@nestjs/config";
import { ScheduleModule } from "@nestjs/schedule";
import { ZodValidationPipe } from "nestjs-zod";
// Configuration
@ -27,14 +28,17 @@ import { SalesforceEventsModule } from "@bff/integrations/salesforce/events/even
// Feature Modules
import { AuthModule } from "@bff/modules/auth/auth.module.js";
import { UsersModule } from "@bff/modules/users/users.module.js";
import { MeStatusModule } from "@bff/modules/me-status/me-status.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { CatalogModule } from "@bff/modules/catalog/catalog.module.js";
import { ServicesModule } from "@bff/modules/services/services.module.js";
import { OrdersModule } from "@bff/modules/orders/orders.module.js";
import { InvoicesModule } from "@bff/modules/invoices/invoices.module.js";
import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.module.js";
import { CurrencyModule } from "@bff/modules/currency/currency.module.js";
import { SupportModule } from "@bff/modules/support/support.module.js";
import { RealtimeApiModule } from "@bff/modules/realtime/realtime.module.js";
import { VerificationModule } from "@bff/modules/verification/verification.module.js";
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
// System Modules
import { HealthModule } from "@bff/modules/health/health.module.js";
@ -55,6 +59,7 @@ import { HealthModule } from "@bff/modules/health/health.module.js";
imports: [
// === CONFIGURATION ===
ConfigModule.forRoot(appConfig),
ScheduleModule.forRoot(),
// === INFRASTRUCTURE ===
LoggingModule,
@ -77,14 +82,17 @@ import { HealthModule } from "@bff/modules/health/health.module.js";
// === FEATURE MODULES ===
AuthModule,
UsersModule,
MeStatusModule,
MappingsModule,
CatalogModule,
ServicesModule,
OrdersModule,
InvoicesModule,
SubscriptionsModule,
CurrencyModule,
SupportModule,
RealtimeApiModule,
VerificationModule,
NotificationsModule,
// === SYSTEM MODULES ===
HealthModule,

View File

@ -54,6 +54,20 @@ export const envSchema = z.object({
"Authentication service is temporarily unavailable for maintenance. Please try again later."
),
/**
* Services catalog/eligibility cache safety TTL.
*
* Primary invalidation is event-driven (Salesforce CDC / Platform Events).
* This TTL is a safety net to self-heal if events are missed.
*
* Set to 0 to disable safety TTL (pure event-driven).
*/
SERVICES_CACHE_SAFETY_TTL_SECONDS: z.coerce
.number()
.int()
.min(0)
.default(60 * 60 * 12),
DATABASE_URL: z.string().url(),
WHMCS_BASE_URL: z.string().url().optional(),
@ -132,6 +146,25 @@ export const envSchema = z.object({
// Salesforce Field Mappings - Account
ACCOUNT_INTERNET_ELIGIBILITY_FIELD: z.string().default("Internet_Eligibility__c"),
ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD: z.string().default("Internet_Eligibility_Status__c"),
ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD: z
.string()
.default("Internet_Eligibility_Request_Date_Time__c"),
ACCOUNT_INTERNET_ELIGIBILITY_CHECKED_AT_FIELD: z
.string()
.default("Internet_Eligibility_Checked_Date_Time__c"),
ACCOUNT_ID_VERIFICATION_STATUS_FIELD: z.string().default("Id_Verification_Status__c"),
ACCOUNT_ID_VERIFICATION_SUBMITTED_AT_FIELD: z
.string()
.default("Id_Verification_Submitted_Date_Time__c"),
ACCOUNT_ID_VERIFICATION_VERIFIED_AT_FIELD: z
.string()
.default("Id_Verification_Verified_Date_Time__c"),
ACCOUNT_ID_VERIFICATION_NOTE_FIELD: z.string().default("Id_Verification_Note__c"),
ACCOUNT_ID_VERIFICATION_REJECTION_MESSAGE_FIELD: z
.string()
.default("Id_Verification_Rejection_Message__c"),
ACCOUNT_CUSTOMER_NUMBER_FIELD: z.string().default("SF_Account_No__c"),
// Salesforce Field Mappings - Product

View File

@ -1,8 +1,9 @@
import type { Routes } from "@nestjs/core";
import { AuthModule } from "@bff/modules/auth/auth.module.js";
import { UsersModule } from "@bff/modules/users/users.module.js";
import { MeStatusModule } from "@bff/modules/me-status/me-status.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { CatalogModule } from "@bff/modules/catalog/catalog.module.js";
import { ServicesModule } from "@bff/modules/services/services.module.js";
import { OrdersModule } from "@bff/modules/orders/orders.module.js";
import { InvoicesModule } from "@bff/modules/invoices/invoices.module.js";
import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.module.js";
@ -10,6 +11,8 @@ import { CurrencyModule } from "@bff/modules/currency/currency.module.js";
import { SecurityModule } from "@bff/core/security/security.module.js";
import { SupportModule } from "@bff/modules/support/support.module.js";
import { RealtimeApiModule } from "@bff/modules/realtime/realtime.module.js";
import { VerificationModule } from "@bff/modules/verification/verification.module.js";
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
export const apiRoutes: Routes = [
{
@ -17,8 +20,9 @@ export const apiRoutes: Routes = [
children: [
{ path: "", module: AuthModule },
{ path: "", module: UsersModule },
{ path: "", module: MeStatusModule },
{ path: "", module: MappingsModule },
{ path: "", module: CatalogModule },
{ path: "", module: ServicesModule },
{ path: "", module: OrdersModule },
{ path: "", module: InvoicesModule },
{ path: "", module: SubscriptionsModule },
@ -26,6 +30,8 @@ export const apiRoutes: Routes = [
{ path: "", module: SupportModule },
{ path: "", module: SecurityModule },
{ path: "", module: RealtimeApiModule },
{ path: "", module: VerificationModule },
{ path: "", module: NotificationsModule },
],
},
];

View File

@ -51,7 +51,7 @@ export class CsrfMiddleware implements NestMiddleware {
"/api/auth/request-password-reset",
"/api/auth/reset-password", // Public auth endpoint for password reset
"/api/auth/set-password", // Public auth endpoint for setting password after WHMCS link
"/api/auth/link-whmcs", // Public auth endpoint for WHMCS account linking
"/api/auth/migrate", // Public auth endpoint for account migration
"/api/health",
"/docs",
"/api/webhooks", // Webhooks typically don't use CSRF

View File

@ -14,8 +14,8 @@ Redis-backed caching system with CDC (Change Data Capture) event-driven invalida
┌───────────────────────────▼─────────────────────────────────┐
│ Domain-Specific Cache Services │
│ - OrdersCacheService (CDC-driven, no TTL)
│ - CatalogCacheService (CDC-driven, no TTL)
│ - OrdersCacheService (CDC-driven)
│ - ServicesCacheService (CDC-driven + safety TTL)
│ - WhmcsCacheService (TTL-based) │
│ │
│ Features: │
@ -61,18 +61,26 @@ Redis-backed caching system with CDC (Change Data Capture) event-driven invalida
### 1. CDC-Driven (Orders, Catalog)
**No TTL** - Cache persists indefinitely until CDC event triggers invalidation.
**Event-driven invalidation + safety TTL** - Cache is invalidated on CDC events, and also expires after a long TTL as a safety net.
Why: CDC is the primary freshness mechanism, but a safety TTL helps self-heal if events are missed (deploy downtime, subscriber issues, replay gaps).
Config:
- `SERVICES_CACHE_SAFETY_TTL_SECONDS` (default: 12 hours, set to `0` to disable)
**Pros:**
- Real-time invalidation when data changes
- Zero stale data for customer-visible fields
- Optimal for frequently read, infrequently changed data
**Example:**
```typescript
@Injectable()
export class OrdersCacheService {
// No TTL = CDC-only invalidation
// CDC invalidation + safety TTL (service-specific)
async getOrderSummaries(
sfAccountId: string,
fetcher: () => Promise<OrderSummary[]>
@ -88,11 +96,13 @@ export class OrdersCacheService {
**Fixed TTL** - Cache expires after a set duration.
**Pros:**
- Simple, predictable behavior
- Good for external systems without CDC
- Automatic cleanup of stale data
**Example:**
```typescript
@Injectable()
export class WhmcsCacheService {
@ -152,7 +162,7 @@ All cache services track performance metrics:
```typescript
{
catalog: { hits: 1250, misses: 48 },
services: { hits: 1250, misses: 48 },
static: { hits: 890, misses: 12 },
volatile: { hits: 450, misses: 120 },
invalidations: 15
@ -160,7 +170,8 @@ All cache services track performance metrics:
```
Access via health endpoints:
- `GET /health/catalog/cache`
- `GET /api/health/services/cache`
- `GET /health`
## Creating a New Cache Service
@ -194,7 +205,7 @@ export class MyDomainCacheService {
```typescript
async getMyData(id: string, fetcher: () => Promise<MyData>): Promise<MyData> {
const key = `mydomain:${id}`;
// Check cache
const cached = await this.cache.get<MyData>(key);
if (cached) {
@ -211,7 +222,7 @@ async getMyData(id: string, fetcher: () => Promise<MyData>): Promise<MyData> {
const fetchPromise = (async () => {
try {
const fresh = await fetcher();
await this.cache.set(key, fresh); // No TTL = CDC-driven
await this.cache.set(key, fresh); // CDC-driven (TTL varies by domain)
return fresh;
} finally {
this.inflightRequests.delete(key);
@ -255,10 +266,11 @@ domain:type:identifier[:subkey]
```
Examples:
- `orders:account:001xx000003EgI1AAK`
- `orders:detail:80122000000D4UGAA0`
- `catalog:internet:acc_001:jp`
- `catalog:deps:product:01t22000003xABCAA2`
- `services:internet:acc_001:jp`
- `services:deps:product:01t22000003xABCAA2`
- `mapping:userId:user_12345`
## Configuration
@ -287,8 +299,8 @@ Provides global `REDIS_CLIENT` using ioredis.
# Overall system health (includes Redis check)
GET /health
# Catalog cache metrics
GET /health/catalog/cache
# Services cache metrics
GET /api/health/services/cache
```
### Response Format
@ -302,7 +314,7 @@ GET /health/catalog/cache
"invalidations": 15
},
"ttl": {
"catalogSeconds": null,
"servicesSeconds": null,
"staticSeconds": null,
"volatileSeconds": 60
}
@ -357,4 +369,3 @@ console.log(`${count} keys using ${usage} bytes`);
- [Salesforce CDC Events](../../integrations/salesforce/events/README.md)
- [Order Fulfillment Flow](../../modules/orders/docs/FULFILLMENT.md)
- [Redis Configuration](../redis/README.md)

View File

@ -1,16 +1,17 @@
import { Global, Module } from "@nestjs/common";
import { CacheService } from "./cache.service.js";
import { DistributedLockService } from "./distributed-lock.service.js";
/**
* Global cache module
*
* Provides Redis-backed caching infrastructure for the entire application.
* Exports CacheService for use in domain-specific cache services.
* Exports CacheService and DistributedLockService for use in domain services.
*/
@Global()
@Module({
providers: [CacheService],
exports: [CacheService],
providers: [CacheService, DistributedLockService],
exports: [CacheService, DistributedLockService],
})
export class CacheModule {}

View File

@ -74,7 +74,7 @@ export class CacheService {
/**
* Delete all keys matching a pattern
* Uses SCAN for safe operation on large datasets
* @param pattern Redis pattern (e.g., "orders:*", "catalog:product:*")
* @param pattern Redis pattern (e.g., "orders:*", "services:product:*")
*/
async delPattern(pattern: string): Promise<void> {
const pipeline = this.redis.pipeline();

View File

@ -0,0 +1,188 @@
/**
* Distributed Lock Service
*
* Redis-based distributed locking for preventing race conditions
* in operations that span multiple systems (e.g., Salesforce + Portal).
*
* Uses Redis SET NX PX pattern for atomic lock acquisition with TTL.
*/
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import type { Redis } from "ioredis";
const LOCK_PREFIX = "lock:";
const DEFAULT_TTL_MS = 30_000; // 30 seconds
const DEFAULT_RETRY_DELAY_MS = 100;
const DEFAULT_MAX_RETRIES = 50; // 5 seconds total with 100ms delay
export interface LockOptions {
/** Lock TTL in milliseconds (default: 30000) */
ttlMs?: number;
/** Delay between retry attempts in milliseconds (default: 100) */
retryDelayMs?: number;
/** Maximum number of retry attempts (default: 50) */
maxRetries?: number;
}
export interface Lock {
/** The lock key */
key: string;
/** Unique token for this lock instance */
token: string;
/** Release the lock */
release: () => Promise<void>;
}
@Injectable()
export class DistributedLockService {
constructor(
@Inject("REDIS_CLIENT") private readonly redis: Redis,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Acquire a distributed lock
*
* @param key - Unique key identifying the resource to lock
* @param options - Lock options
* @returns Lock object if acquired, null if unable to acquire
*/
async acquire(key: string, options?: LockOptions): Promise<Lock | null> {
const lockKey = LOCK_PREFIX + key;
const token = this.generateToken();
const ttlMs = options?.ttlMs ?? DEFAULT_TTL_MS;
const retryDelayMs = options?.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
// SET key token NX PX ttl - atomic set if not exists with TTL
const result = await this.redis.set(lockKey, token, "PX", ttlMs, "NX");
if (result === "OK") {
this.logger.debug("Lock acquired", { key: lockKey, attempt });
return {
key: lockKey,
token,
release: () => this.release(lockKey, token),
};
}
// Lock is held by someone else, wait and retry
if (attempt < maxRetries) {
await this.delay(retryDelayMs);
}
}
this.logger.warn("Failed to acquire lock after max retries", {
key: lockKey,
maxRetries,
});
return null;
}
/**
* Execute a function with a lock
*
* Automatically acquires lock before execution and releases after.
* If lock cannot be acquired, throws an error.
*
* @param key - Unique key identifying the resource to lock
* @param fn - Function to execute while holding the lock
* @param options - Lock options
* @returns Result of the function
*/
async withLock<T>(key: string, fn: () => Promise<T>, options?: LockOptions): Promise<T> {
const lock = await this.acquire(key, options);
if (!lock) {
throw new Error(`Unable to acquire lock for key: ${key}`);
}
try {
return await fn();
} finally {
await lock.release();
}
}
/**
* Try to execute a function with a lock
*
* Unlike withLock, this returns null if lock cannot be acquired
* instead of throwing an error.
*
* @param key - Unique key identifying the resource to lock
* @param fn - Function to execute while holding the lock
* @param options - Lock options
* @returns Result of the function, or null if lock not acquired
*/
async tryWithLock<T>(
key: string,
fn: () => Promise<T>,
options?: LockOptions
): Promise<{ success: true; result: T } | { success: false; result: null }> {
const lock = await this.acquire(key, {
...options,
maxRetries: 0, // Don't retry for try semantics
});
if (!lock) {
return { success: false, result: null };
}
try {
const result = await fn();
return { success: true, result };
} finally {
await lock.release();
}
}
/**
* Release a lock
*
* Uses a Lua script to ensure we only release our own lock.
*/
private async release(lockKey: string, token: string): Promise<void> {
// Lua script: only delete if the token matches
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
try {
const result = await this.redis.eval(script, 1, lockKey, token);
if (result === 1) {
this.logger.debug("Lock released", { key: lockKey });
} else {
this.logger.warn("Lock release failed - token mismatch or expired", {
key: lockKey,
});
}
} catch (error) {
this.logger.error("Error releasing lock", {
key: lockKey,
error: error instanceof Error ? error.message : String(error),
});
}
}
/**
* Generate a unique token for lock ownership
*/
private generateToken(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
}
/**
* Delay helper
*/
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}

View File

@ -8,7 +8,7 @@ export interface RealtimePubSubMessage<TEvent extends string = string, TData = u
* Topic identifies which logical stream this event belongs to.
* Examples:
* - orders:sf:801xx0000001234
* - catalog:eligibility:001xx000000abcd
* - services:eligibility:001xx000000abcd
*/
topic: string;
event: TEvent;

View File

@ -0,0 +1,368 @@
/**
* Opportunity Field Map Configuration
*
* Maps logical field names to Salesforce API field names.
* Uses existing Salesforce fields where available.
*
* @see docs/salesforce/OPPORTUNITY-LIFECYCLE-GUIDE.md for setup instructions
*/
// ============================================================================
// Standard Salesforce Opportunity Fields
// ============================================================================
export const OPPORTUNITY_STANDARD_FIELDS = {
/** Salesforce Opportunity ID */
id: "Id",
/** Opportunity name (auto-generated: "{Account Name} - {Product Type} Inquiry") */
name: "Name",
/** Related Account ID */
accountId: "AccountId",
/** Account name (via relationship query) */
accountName: "Account.Name",
/** Current stage in the lifecycle */
stage: "StageName",
/** Expected close date */
closeDate: "CloseDate",
/** Whether the Opportunity is closed (read-only, derived from stage) */
isClosed: "IsClosed",
/** Whether the Opportunity is won (read-only, derived from stage) */
isWon: "IsWon",
/** Opportunity description */
description: "Description",
/** Created date */
createdDate: "CreatedDate",
/** Last modified date */
lastModifiedDate: "LastModifiedDate",
} as const;
// ============================================================================
// Existing Custom Opportunity Fields
// ============================================================================
/**
* These fields already exist in Salesforce
*/
export const OPPORTUNITY_EXISTING_CUSTOM_FIELDS = {
// ---- Application Stage ----
/** Application process stage (INTRO-1, N/A, etc.) */
applicationStage: "Application_Stage__c",
// ---- Cancellation Fields (existing) ----
/** Scheduled cancellation date/time (end of month) */
scheduledCancellationDate: "ScheduledCancellationDateAndTime__c",
/** Cancellation notice status: 有 (received), 未 (not yet), 不要 (not required), 移転 (transfer) */
cancellationNotice: "CancellationNotice__c",
/** Line return status for rental equipment */
lineReturnStatus: "LineReturn__c",
} as const;
// ============================================================================
// Existing Custom Fields for Product Type
// ============================================================================
/**
* CommodityType field already exists in Salesforce
* Used to track product/service type
*/
export const OPPORTUNITY_COMMODITY_FIELD = {
/** Product/commodity type (existing field) */
commodityType: "CommodityType",
} as const;
// ============================================================================
// New Custom Fields (to be created in Salesforce)
// ============================================================================
/**
* New custom fields to be created in Salesforce.
*
* NOTE:
* - CommodityType already exists - no need to create Product_Type__c
* - Cases link TO Opportunity via Case.OpportunityId (no custom field on Opp)
* - Orders link TO Opportunity via Order.OpportunityId (standard field)
* - Alternative email and cancellation comments go on Case, not Opportunity
*
* TODO: Confirm with Salesforce admin and update API names after creation
*/
export const OPPORTUNITY_NEW_CUSTOM_FIELDS = {
// ---- Source Field (to be created) ----
/** Source of the Opportunity creation */
source: "Portal_Source__c",
// ---- Integration Fields (to be created) ----
/** WHMCS Service ID (populated after provisioning) */
whmcsServiceId: "WHMCS_Service_ID__c",
// NOTE: Cancellation comments and alternative email go on the Cancellation Case,
// not on the Opportunity. This keeps Opportunity clean and Case contains all details.
} as const;
// ============================================================================
// Combined Field Map
// ============================================================================
/**
* Complete Opportunity field map for portal operations
*/
export const OPPORTUNITY_FIELD_MAP = {
...OPPORTUNITY_STANDARD_FIELDS,
...OPPORTUNITY_EXISTING_CUSTOM_FIELDS,
...OPPORTUNITY_COMMODITY_FIELD,
...OPPORTUNITY_NEW_CUSTOM_FIELDS,
} as const;
export type OpportunityFieldMap = typeof OPPORTUNITY_FIELD_MAP;
// ============================================================================
// Query Field Sets
// ============================================================================
/**
* Fields to select when querying Opportunities for matching
*/
export const OPPORTUNITY_MATCH_QUERY_FIELDS = [
OPPORTUNITY_FIELD_MAP.id,
OPPORTUNITY_FIELD_MAP.name,
OPPORTUNITY_FIELD_MAP.accountId,
OPPORTUNITY_FIELD_MAP.stage,
OPPORTUNITY_FIELD_MAP.closeDate,
OPPORTUNITY_FIELD_MAP.isClosed,
OPPORTUNITY_FIELD_MAP.applicationStage,
OPPORTUNITY_FIELD_MAP.commodityType,
OPPORTUNITY_FIELD_MAP.source,
OPPORTUNITY_FIELD_MAP.createdDate,
] as const;
/**
* Fields to select when querying full Opportunity details
*/
export const OPPORTUNITY_DETAIL_QUERY_FIELDS = [
...OPPORTUNITY_MATCH_QUERY_FIELDS,
OPPORTUNITY_FIELD_MAP.whmcsServiceId,
OPPORTUNITY_FIELD_MAP.scheduledCancellationDate,
OPPORTUNITY_FIELD_MAP.cancellationNotice,
OPPORTUNITY_FIELD_MAP.lineReturnStatus,
OPPORTUNITY_FIELD_MAP.lastModifiedDate,
// NOTE: Cancellation comments and alternative email are on the Cancellation Case
] as const;
/**
* Fields to select for cancellation status display
*/
export const OPPORTUNITY_CANCELLATION_QUERY_FIELDS = [
OPPORTUNITY_FIELD_MAP.id,
OPPORTUNITY_FIELD_MAP.stage,
OPPORTUNITY_FIELD_MAP.commodityType,
OPPORTUNITY_FIELD_MAP.scheduledCancellationDate,
OPPORTUNITY_FIELD_MAP.cancellationNotice,
OPPORTUNITY_FIELD_MAP.lineReturnStatus,
OPPORTUNITY_FIELD_MAP.whmcsServiceId,
] as const;
// ============================================================================
// Stage Picklist Reference (Existing Values)
// ============================================================================
/**
* Opportunity stage picklist values (already exist in Salesforce)
*
* These stages track the complete service lifecycle.
* The portal uses these exact values.
*/
export const OPPORTUNITY_STAGE_REFERENCE = {
INTRODUCTION: {
value: "Introduction",
probability: 30,
forecastCategory: "Pipeline",
isClosed: false,
description: "Initial customer interest / eligibility pending",
},
WIKI: {
value: "WIKI",
probability: 10,
forecastCategory: "Omitted",
isClosed: false,
description: "Low priority / informational only",
},
READY: {
value: "Ready",
probability: 60,
forecastCategory: "Pipeline",
isClosed: false,
description: "Eligible and ready to order",
},
POST_PROCESSING: {
value: "Post Processing",
probability: 75,
forecastCategory: "Pipeline",
isClosed: false,
description: "Order placed, processing",
},
ACTIVE: {
value: "Active",
probability: 90,
forecastCategory: "Pipeline",
isClosed: false,
description: "Service is active",
},
CANCELLING: {
value: "△Cancelling",
probability: 100,
forecastCategory: "Pipeline",
isClosed: false,
description: "Cancellation requested, pending processing",
},
CANCELLED: {
value: "Cancelled",
probability: 100,
forecastCategory: "Closed",
isClosed: true,
isWon: true,
description: "Successfully cancelled",
},
COMPLETED: {
value: "Completed",
probability: 100,
forecastCategory: "Closed",
isClosed: true,
isWon: true,
description: "Service completed normally",
},
VOID: {
value: "Void",
probability: 0,
forecastCategory: "Omitted",
isClosed: true,
isWon: false,
description: "Lost / not eligible",
},
PENDING: {
value: "Pending",
probability: 0,
forecastCategory: "Omitted",
isClosed: true,
isWon: false,
description: "On hold / abandoned",
},
} as const;
// ============================================================================
// Application Stage Reference (Existing Values)
// ============================================================================
/**
* Application stage picklist values (already exist in Salesforce)
* Portal uses INTRO-1 for new opportunities
*/
export const APPLICATION_STAGE_REFERENCE = {
INTRO_1: { value: "INTRO-1", description: "Portal introduction (default)" },
NA: { value: "N/A", description: "Not applicable" },
} as const;
// ============================================================================
// Cancellation Notice Picklist Reference (Existing Values)
// ============================================================================
/**
* Cancellation notice picklist values (already exist in Salesforce)
*/
export const CANCELLATION_NOTICE_REFERENCE = {
RECEIVED: { value: "有", label: "Received", description: "Cancellation form received" },
NOT_YET: { value: "未", label: "Not Yet", description: "Not yet received (default)" },
NOT_REQUIRED: { value: "不要", label: "Not Required", description: "Not required" },
TRANSFER: { value: "移転", label: "Transfer", description: "Customer moving/transferring" },
} as const;
// ============================================================================
// Line Return Status Picklist Reference (Existing Values)
// ============================================================================
/**
* Line return status picklist values (already exist in Salesforce)
*/
export const LINE_RETURN_STATUS_REFERENCE = {
NOT_YET: { value: "NotYet", label: "Not Yet", description: "Return kit not sent" },
SENT_KIT: { value: "SentKit", label: "Kit Sent", description: "Return kit sent to customer" },
PICKUP_SCHEDULED: {
value: "AS/Pickup予定",
label: "Pickup Scheduled",
description: "Pickup scheduled",
},
RETURNED_1: { value: "Returned1", label: "Returned", description: "Equipment returned" },
RETURNED: { value: "Returned2", label: "Returned", description: "Equipment returned" },
NTT_DISPATCH: { value: "NTT派遣", label: "NTT Dispatch", description: "NTT handling return" },
COMPENSATED: {
value: "Compensated",
label: "Compensated",
description: "Compensation fee charged",
},
NA: { value: "N/A", label: "N/A", description: "No rental equipment" },
} as const;
// ============================================================================
// Commodity Type Reference (Existing Values)
// ============================================================================
/**
* CommodityType picklist values (already exist in Salesforce)
* Maps to simplified product types for portal logic
*/
export const COMMODITY_TYPE_REFERENCE = {
PERSONAL_HOME_INTERNET: {
value: "Personal SonixNet Home Internet",
portalProductType: "Internet",
description: "Personal home internet service",
},
CORPORATE_HOME_INTERNET: {
value: "Corporate SonixNet Home Internet",
portalProductType: "Internet",
description: "Corporate home internet service",
},
SIM: {
value: "SIM",
portalProductType: "SIM",
description: "SIM / mobile service",
},
VPN: {
value: "VPN",
portalProductType: "VPN",
description: "VPN service",
},
TECH_SUPPORT: {
value: "Onsite Support",
portalProductType: null,
description: "Tech support (not used by portal)",
},
} as const;
// ============================================================================
// New Picklist Values (to be created in Salesforce)
// ============================================================================
/**
* Source picklist values for Portal_Source__c field
* (needs to be created in Salesforce)
*/
export const PORTAL_SOURCE_PICKLIST = [
{ value: "Portal - Internet Eligibility Request", label: "Portal - Internet Eligibility" },
{ value: "Portal - SIM Checkout Registration", label: "Portal - SIM Checkout" },
{ value: "Portal - Order Placement", label: "Portal - Order Placement" },
{ value: "Agent Created", label: "Agent Created" },
] as const;

View File

@ -2,8 +2,9 @@ import { Module, forwardRef } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { IntegrationsModule } from "@bff/integrations/integrations.module.js";
import { OrdersModule } from "@bff/modules/orders/orders.module.js";
import { CatalogModule } from "@bff/modules/catalog/catalog.module.js";
import { CatalogCdcSubscriber } from "./catalog-cdc.subscriber.js";
import { ServicesModule } from "@bff/modules/services/services.module.js";
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
import { ServicesCdcSubscriber } from "./services-cdc.subscriber.js";
import { OrderCdcSubscriber } from "./order-cdc.subscriber.js";
@Module({
@ -11,10 +12,11 @@ import { OrderCdcSubscriber } from "./order-cdc.subscriber.js";
ConfigModule,
forwardRef(() => IntegrationsModule),
forwardRef(() => OrdersModule),
forwardRef(() => CatalogModule),
forwardRef(() => ServicesModule),
forwardRef(() => NotificationsModule),
],
providers: [
CatalogCdcSubscriber, // CDC for catalog cache invalidation
ServicesCdcSubscriber, // CDC for services cache invalidation + notifications
OrderCdcSubscriber, // CDC for order cache invalidation
],
})

View File

@ -1,11 +1,12 @@
import { Injectable, Inject } from "@nestjs/common";
import { Injectable, Inject, Optional } from "@nestjs/common";
import type { OnModuleInit, OnModuleDestroy } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import PubSubApiClientPkg from "salesforce-pubsub-api-client";
import { SalesforceConnection } from "../services/salesforce-connection.service.js";
import { CatalogCacheService } from "@bff/modules/catalog/services/catalog-cache.service.js";
import { ServicesCacheService } from "@bff/modules/services/services/services-cache.service.js";
import { RealtimeService } from "@bff/infra/realtime/realtime.service.js";
import { AccountNotificationHandler } from "@bff/modules/notifications/account-cdc-listener.service.js";
type PubSubCallback = (
subscription: { topicName?: string },
@ -27,7 +28,7 @@ type PubSubCtor = new (opts: {
}) => PubSubClient;
@Injectable()
export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
export class ServicesCdcSubscriber implements OnModuleInit, OnModuleDestroy {
private client: PubSubClient | null = null;
private pubSubCtor: PubSubCtor | null = null;
private productChannel: string | null = null;
@ -38,9 +39,10 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
constructor(
private readonly config: ConfigService,
private readonly sfConnection: SalesforceConnection,
private readonly catalogCache: CatalogCacheService,
private readonly catalogCache: ServicesCacheService,
private readonly realtime: RealtimeService,
@Inject(Logger) private readonly logger: Logger
@Inject(Logger) private readonly logger: Logger,
@Optional() private readonly accountNotificationHandler?: AccountNotificationHandler
) {
this.numRequested = this.resolveNumRequested();
}
@ -192,9 +194,9 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
productIds,
}
);
await this.invalidateAllCatalogs();
// Full invalidation already implies all clients should refetch catalog
this.realtime.publish("global:catalog", "catalog.changed", {
await this.invalidateAllServices();
// Full invalidation already implies all clients should refetch services
this.realtime.publish("global:services", "services.changed", {
reason: "product.cdc.fallback_full_invalidation",
timestamp: new Date().toISOString(),
});
@ -202,7 +204,7 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
}
// Product changes can affect catalog results for all users
this.realtime.publish("global:catalog", "catalog.changed", {
this.realtime.publish("global:services", "services.changed", {
reason: "product.cdc",
timestamp: new Date().toISOString(),
});
@ -246,15 +248,15 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
productId,
}
);
await this.invalidateAllCatalogs();
this.realtime.publish("global:catalog", "catalog.changed", {
await this.invalidateAllServices();
this.realtime.publish("global:services", "services.changed", {
reason: "pricebook.cdc.fallback_full_invalidation",
timestamp: new Date().toISOString(),
});
return;
}
this.realtime.publish("global:catalog", "catalog.changed", {
this.realtime.publish("global:services", "services.changed", {
reason: "pricebook.cdc",
timestamp: new Date().toISOString(),
});
@ -269,9 +271,22 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
if (!this.isDataCallback(callbackType)) return;
const payload = this.extractPayload(data);
const accountId = this.extractStringField(payload, ["AccountId__c", "AccountId", "Id"]);
const eligibility = this.extractStringField(payload, [
"Internet_Eligibility__c",
"InternetEligibility__c",
const eligibility = this.extractStringField(payload, ["Internet_Eligibility__c"]);
const status = this.extractStringField(payload, ["Internet_Eligibility_Status__c"]);
const requestedAt = this.extractStringField(payload, [
"Internet_Eligibility_Request_Date_Time__c",
]);
const checkedAt = this.extractStringField(payload, [
"Internet_Eligibility_Checked_Date_Time__c",
]);
// Note: Request ID field is not used in this environment
const requestId = undefined;
// Also extract ID verification fields for notifications
const verificationStatus = this.extractStringField(payload, ["Id_Verification_Status__c"]);
const verificationRejection = this.extractStringField(payload, [
"Id_Verification_Rejection_Message__c",
]);
if (!accountId) {
@ -288,19 +303,55 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
});
await this.catalogCache.invalidateEligibility(accountId);
await this.catalogCache.setEligibilityValue(accountId, eligibility ?? null);
const hasDetails = Boolean(status || eligibility || requestedAt || checkedAt || requestId);
if (hasDetails) {
await this.catalogCache.setEligibilityDetails(accountId, {
status: this.mapEligibilityStatus(status, eligibility),
eligibility: eligibility ?? null,
requestId: requestId ?? null,
requestedAt: requestedAt ?? null,
checkedAt: checkedAt ?? null,
notes: null, // Field not used
});
}
// Notify connected portals immediately (multi-instance safe via Redis pub/sub)
this.realtime.publish(`account:sf:${accountId}`, "catalog.eligibility.changed", {
this.realtime.publish(`account:sf:${accountId}`, "services.eligibility.changed", {
timestamp: new Date().toISOString(),
});
// Create in-app notifications for eligibility/verification status changes
if (this.accountNotificationHandler && (status || verificationStatus)) {
void this.accountNotificationHandler.processAccountEvent({
accountId,
eligibilityStatus: status,
eligibilityValue: eligibility,
verificationStatus,
verificationRejectionMessage: verificationRejection,
});
}
}
private async invalidateAllCatalogs(): Promise<void> {
private mapEligibilityStatus(
statusRaw: string | undefined,
eligibilityRaw: string | undefined
): "not_requested" | "pending" | "eligible" | "ineligible" {
const normalizedStatus = typeof statusRaw === "string" ? statusRaw.trim().toLowerCase() : "";
const eligibility = typeof eligibilityRaw === "string" ? eligibilityRaw.trim() : "";
if (normalizedStatus === "pending" || normalizedStatus === "checking") return "pending";
if (normalizedStatus === "eligible") return "eligible";
if (normalizedStatus === "ineligible" || normalizedStatus === "not available")
return "ineligible";
if (eligibility.length > 0) return "eligible";
return "not_requested";
}
private async invalidateAllServices(): Promise<void> {
try {
await this.catalogCache.invalidateAllCatalogs();
await this.catalogCache.invalidateAllServices();
} catch (error) {
this.logger.warn("Failed to invalidate catalog caches", {
this.logger.warn("Failed to invalidate services caches", {
error: error instanceof Error ? error.message : String(error),
});
}

View File

@ -6,6 +6,8 @@ import { SalesforceConnection } from "./services/salesforce-connection.service.j
import { SalesforceAccountService } from "./services/salesforce-account.service.js";
import { SalesforceOrderService } from "./services/salesforce-order.service.js";
import { SalesforceCaseService } from "./services/salesforce-case.service.js";
import { SalesforceOpportunityService } from "./services/salesforce-opportunity.service.js";
import { OpportunityResolutionService } from "./services/opportunity-resolution.service.js";
import { OrderFieldConfigModule } from "@bff/modules/orders/config/order-field-config.module.js";
import { SalesforceReadThrottleGuard } from "./guards/salesforce-read-throttle.guard.js";
import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle.guard.js";
@ -17,6 +19,8 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle
SalesforceAccountService,
SalesforceOrderService,
SalesforceCaseService,
SalesforceOpportunityService,
OpportunityResolutionService,
SalesforceService,
SalesforceReadThrottleGuard,
SalesforceWriteThrottleGuard,
@ -25,8 +29,11 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle
QueueModule,
SalesforceService,
SalesforceConnection,
SalesforceAccountService,
SalesforceOrderService,
SalesforceCaseService,
SalesforceOpportunityService,
OpportunityResolutionService,
SalesforceReadThrottleGuard,
SalesforceWriteThrottleGuard,
],

View File

@ -0,0 +1,144 @@
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
import { SalesforceOpportunityService } from "./salesforce-opportunity.service.js";
import { assertSalesforceId } from "../utils/soql.util.js";
import type { OrderTypeValue } from "@customer-portal/domain/orders";
import {
APPLICATION_STAGE,
OPPORTUNITY_PRODUCT_TYPE,
OPPORTUNITY_SOURCE,
OPPORTUNITY_STAGE,
OPPORTUNITY_MATCH_STAGES_INTERNET_ELIGIBILITY,
OPPORTUNITY_MATCH_STAGES_ORDER_PLACEMENT,
type OpportunityProductTypeValue,
} from "@customer-portal/domain/opportunity";
/**
* Opportunity Resolution Service
*
* Centralizes the "find or create" rules for Opportunities so eligibility, checkout,
* and other flows cannot drift over time.
*
* Key principle:
* - Eligibility can only match the initial Introduction opportunity.
* - Order placement can match Introduction/Ready. It must never match Active.
*/
@Injectable()
export class OpportunityResolutionService {
constructor(
private readonly opportunities: SalesforceOpportunityService,
private readonly lockService: DistributedLockService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Resolve (find or create) an Internet Opportunity for eligibility request.
*
* NOTE: The eligibility flow itself should ensure idempotency for Case creation.
* This method only resolves the Opportunity link.
*/
async findOrCreateForInternetEligibility(accountId: string): Promise<{
opportunityId: string;
wasCreated: boolean;
}> {
const safeAccountId = assertSalesforceId(accountId, "accountId");
const lockKey = `opportunity:eligibility:${safeAccountId}:Internet`;
return this.lockService.withLock(
lockKey,
async () => {
const existing = await this.opportunities.findOpenOpportunityForAccount(
safeAccountId,
OPPORTUNITY_PRODUCT_TYPE.INTERNET,
{ stages: OPPORTUNITY_MATCH_STAGES_INTERNET_ELIGIBILITY }
);
if (existing) {
return { opportunityId: existing, wasCreated: false };
}
const created = await this.opportunities.createOpportunity({
accountId: safeAccountId,
productType: OPPORTUNITY_PRODUCT_TYPE.INTERNET,
stage: OPPORTUNITY_STAGE.INTRODUCTION,
source: OPPORTUNITY_SOURCE.INTERNET_ELIGIBILITY,
applicationStage: APPLICATION_STAGE.INTRO_1,
});
return { opportunityId: created, wasCreated: true };
},
{ ttlMs: 10_000 }
);
}
/**
* Resolve (find or create) an Opportunity for order placement.
*
* - If an OpportunityId is already provided, use it as-is.
* - Otherwise, match only Introduction/Ready to avoid corrupting lifecycle tracking.
* - If none found, create a new Opportunity in Post Processing stage.
*/
async resolveForOrderPlacement(params: {
accountId: string | null;
orderType: OrderTypeValue;
existingOpportunityId?: string;
}): Promise<string | null> {
if (!params.accountId) return null;
const safeAccountId = assertSalesforceId(params.accountId, "accountId");
if (params.existingOpportunityId) {
return assertSalesforceId(params.existingOpportunityId, "existingOpportunityId");
}
const productType = this.mapOrderTypeToProductType(params.orderType);
const lockKey = `opportunity:order:${safeAccountId}:${productType}`;
return this.lockService.withLock(
lockKey,
async () => {
const existing = await this.opportunities.findOpenOpportunityForAccount(
safeAccountId,
productType,
{ stages: OPPORTUNITY_MATCH_STAGES_ORDER_PLACEMENT }
);
if (existing) {
return existing;
}
const created = await this.opportunities.createOpportunity({
accountId: safeAccountId,
productType,
stage: OPPORTUNITY_STAGE.POST_PROCESSING,
source: OPPORTUNITY_SOURCE.ORDER_PLACEMENT,
applicationStage: APPLICATION_STAGE.INTRO_1,
});
this.logger.log("Created new Opportunity for order placement", {
accountIdTail: safeAccountId.slice(-4),
opportunityIdTail: created.slice(-4),
productType,
orderType: params.orderType,
});
return created;
},
{ ttlMs: 10_000 }
);
}
private mapOrderTypeToProductType(orderType: OrderTypeValue): OpportunityProductTypeValue {
switch (orderType) {
case "Internet":
return OPPORTUNITY_PRODUCT_TYPE.INTERNET;
case "SIM":
return OPPORTUNITY_PRODUCT_TYPE.SIM;
case "VPN":
return OPPORTUNITY_PRODUCT_TYPE.VPN;
default:
return OPPORTUNITY_PRODUCT_TYPE.SIM;
}
}
}

View File

@ -132,6 +132,159 @@ export class SalesforceAccountService {
return input.replace(/'/g, "\\'");
}
// ============================================================================
// Account Creation Methods (for Checkout Registration)
// ============================================================================
/**
* Check if a Salesforce account exists with the given email
* Used to prevent duplicate account creation during checkout
*/
async findByEmail(email: string): Promise<{ id: string; accountNumber: string } | null> {
try {
// Search for Contact with matching email and get the associated Account
const result = (await this.connection.query(
`SELECT Account.Id, Account.SF_Account_No__c FROM Contact WHERE Email = '${this.safeSoql(email)}' LIMIT 1`,
{ label: "checkout:findAccountByEmail" }
)) as SalesforceResponse<{ Account: { Id: string; SF_Account_No__c: string } }>;
if (result.totalSize > 0 && result.records[0]?.Account) {
return {
id: result.records[0].Account.Id,
accountNumber: result.records[0].Account.SF_Account_No__c,
};
}
return null;
} catch (error) {
this.logger.error("Failed to find account by email", {
error: getErrorMessage(error),
});
return null;
}
}
/**
* Create a new Salesforce Account for a new customer
* Used when customer signs up through checkout (no existing sfNumber)
*
* @returns The created account ID and auto-generated account number
*/
async createAccount(
data: CreateSalesforceAccountRequest
): Promise<{ accountId: string; accountNumber: string }> {
this.logger.log("Creating new Salesforce Account", { email: data.email });
const accountPayload = {
Name: `${data.firstName} ${data.lastName}`,
BillingStreet:
data.address.address1 + (data.address.address2 ? `\n${data.address.address2}` : ""),
BillingCity: data.address.city,
BillingState: data.address.state,
BillingPostalCode: data.address.postcode,
BillingCountry: data.address.country,
Phone: data.phone,
// Portal tracking fields
[this.portalStatusField]: "Active",
[this.portalSourceField]: "Portal Checkout",
};
try {
const createMethod = this.connection.sobject("Account").create;
if (!createMethod) {
throw new Error("Salesforce create method not available");
}
const result = await createMethod(accountPayload);
if (!result || typeof result !== "object" || !("id" in result)) {
throw new Error("Salesforce Account creation failed - no ID returned");
}
const accountId = result.id as string;
// Query back the auto-generated account number
const accountRecord = (await this.connection.query(
`SELECT SF_Account_No__c FROM Account WHERE Id = '${this.safeSoql(accountId)}'`,
{ label: "checkout:getCreatedAccountNumber" }
)) as SalesforceResponse<{ SF_Account_No__c: string }>;
const accountNumber = accountRecord.records[0]?.SF_Account_No__c || "";
if (!accountNumber) {
this.logger.warn("Account number not found for newly created account", { accountId });
}
this.logger.log("Salesforce Account created", {
accountId,
accountNumber,
});
return {
accountId,
accountNumber,
};
} catch (error) {
this.logger.error("Failed to create Salesforce Account", {
error: getErrorMessage(error),
email: data.email,
});
throw new Error("Failed to create customer account in CRM");
}
}
/**
* Create a Contact associated with an Account
*/
async createContact(data: CreateSalesforceContactRequest): Promise<{ contactId: string }> {
this.logger.log("Creating Salesforce Contact", {
accountId: data.accountId,
email: data.email,
});
const contactPayload = {
AccountId: data.accountId,
FirstName: data.firstName,
LastName: data.lastName,
Email: data.email,
Phone: data.phone,
MailingStreet:
data.address.address1 + (data.address.address2 ? `\n${data.address.address2}` : ""),
MailingCity: data.address.city,
MailingState: data.address.state,
MailingPostalCode: data.address.postcode,
MailingCountry: data.address.country,
};
try {
const createMethod = this.connection.sobject("Contact").create;
if (!createMethod) {
throw new Error("Salesforce create method not available");
}
const result = await createMethod(contactPayload);
if (!result || typeof result !== "object" || !("id" in result)) {
throw new Error("Salesforce Contact creation failed - no ID returned");
}
const contactId = result.id as string;
this.logger.log("Salesforce Contact created", { contactId });
return { contactId };
} catch (error) {
this.logger.error("Failed to create Salesforce Contact", {
error: getErrorMessage(error),
accountId: data.accountId,
});
throw new Error("Failed to create customer contact in CRM");
}
}
// ============================================================================
// Portal Field Update Methods
// ============================================================================
async updatePortalFields(
accountId: string,
update: SalesforceAccountPortalUpdate
@ -189,3 +342,40 @@ export interface SalesforceAccountPortalUpdate {
lastSignedInAt?: Date;
whmcsAccountId?: string | number | null;
}
/**
* Request type for creating a new Salesforce Account
*/
export interface CreateSalesforceAccountRequest {
firstName: string;
lastName: string;
email: string;
phone: string;
address: {
address1: string;
address2?: string;
city: string;
state: string;
postcode: string;
country: string;
};
}
/**
* Request type for creating a new Salesforce Contact
*/
export interface CreateSalesforceContactRequest {
accountId: string;
firstName: string;
lastName: string;
email: string;
phone: string;
address: {
address1: string;
address2?: string;
city: string;
state: string;
postcode: string;
country: string;
};
}

View File

@ -185,6 +185,59 @@ export class SalesforceCaseService {
}
}
/**
* Create a Web-to-Case for public contact form submissions
* Does not require an Account - uses supplied contact info
*/
async createWebCase(params: {
subject: string;
description: string;
suppliedEmail: string;
suppliedName: string;
suppliedPhone?: string;
origin?: string;
priority?: string;
}): Promise<{ id: string; caseNumber: string }> {
this.logger.log("Creating Web-to-Case", { email: params.suppliedEmail });
const casePayload: Record<string, unknown> = {
Origin: params.origin ?? "Web",
Status: SALESFORCE_CASE_STATUS.NEW,
Priority: params.priority ?? SALESFORCE_CASE_PRIORITY.MEDIUM,
Subject: params.subject.trim(),
Description: params.description.trim(),
SuppliedEmail: params.suppliedEmail,
SuppliedName: params.suppliedName,
SuppliedPhone: params.suppliedPhone ?? null,
};
try {
const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string };
if (!created.id) {
throw new Error("Salesforce did not return a case ID");
}
// Fetch the created case to get the CaseNumber
const createdCase = await this.getCaseByIdInternal(created.id);
const caseNumber = createdCase?.CaseNumber ?? created.id;
this.logger.log("Web-to-Case created successfully", {
caseId: created.id,
caseNumber,
email: params.suppliedEmail,
});
return { id: created.id, caseNumber };
} catch (error: unknown) {
this.logger.error("Failed to create Web-to-Case", {
error: getErrorMessage(error),
email: params.suppliedEmail,
});
throw new Error("Failed to create contact request");
}
}
/**
* Internal method to fetch case without account validation (for post-create lookup)
*/
@ -205,4 +258,148 @@ export class SalesforceCaseService {
return result.records?.[0] ?? null;
}
// ==========================================================================
// Opportunity-Linked Cases
// ==========================================================================
/**
* Create an eligibility check case linked to an Opportunity
*
* @param params - Case parameters including Opportunity link
* @returns Created case ID
*/
async createEligibilityCase(params: {
accountId: string;
opportunityId: string;
subject: string;
description: string;
}): Promise<string> {
const safeAccountId = assertSalesforceId(params.accountId, "accountId");
const safeOpportunityId = assertSalesforceId(params.opportunityId, "opportunityId");
this.logger.log("Creating eligibility check case linked to Opportunity", {
accountIdTail: safeAccountId.slice(-4),
opportunityIdTail: safeOpportunityId.slice(-4),
});
const casePayload: Record<string, unknown> = {
Origin: SALESFORCE_CASE_ORIGIN.PORTAL_WEBSITE,
Status: SALESFORCE_CASE_STATUS.NEW,
Priority: SALESFORCE_CASE_PRIORITY.MEDIUM,
Subject: params.subject,
Description: params.description,
AccountId: safeAccountId,
// Link Case to Opportunity - this is a standard lookup field
OpportunityId: safeOpportunityId,
};
try {
const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string };
if (!created.id) {
throw new Error("Salesforce did not return a case ID");
}
this.logger.log("Eligibility case created and linked to Opportunity", {
caseId: created.id,
opportunityIdTail: safeOpportunityId.slice(-4),
});
return created.id;
} catch (error: unknown) {
this.logger.error("Failed to create eligibility case", {
error: getErrorMessage(error),
accountIdTail: safeAccountId.slice(-4),
});
throw new Error("Failed to create eligibility check case");
}
}
/**
* Create a cancellation request case linked to an Opportunity
*
* All customer-provided details (comments, alternative email) go here.
* The Opportunity only gets the core lifecycle fields (dates, status).
*
* @param params - Cancellation case parameters
* @returns Created case ID
*/
async createCancellationCase(params: {
accountId: string;
opportunityId?: string;
whmcsServiceId: number;
productType: string;
cancellationMonth: string;
cancellationDate: string;
alternativeEmail?: string;
comments?: string;
}): Promise<string> {
const safeAccountId = assertSalesforceId(params.accountId, "accountId");
const safeOpportunityId = params.opportunityId
? assertSalesforceId(params.opportunityId, "opportunityId")
: null;
this.logger.log("Creating cancellation request case", {
accountIdTail: safeAccountId.slice(-4),
opportunityId: safeOpportunityId ? safeOpportunityId.slice(-4) : "none",
whmcsServiceId: params.whmcsServiceId,
});
// Build description with all form data
const descriptionLines = [
`Cancellation Request from Portal`,
``,
`Product Type: ${params.productType}`,
`WHMCS Service ID: ${params.whmcsServiceId}`,
`Cancellation Month: ${params.cancellationMonth}`,
`Service End Date: ${params.cancellationDate}`,
``,
];
if (params.alternativeEmail) {
descriptionLines.push(`Alternative Contact Email: ${params.alternativeEmail}`);
}
if (params.comments) {
descriptionLines.push(``, `Customer Comments:`, params.comments);
}
descriptionLines.push(``, `Submitted: ${new Date().toISOString()}`);
const casePayload: Record<string, unknown> = {
Origin: "Portal",
Status: SALESFORCE_CASE_STATUS.NEW,
Priority: SALESFORCE_CASE_PRIORITY.HIGH,
Subject: `Cancellation Request - ${params.productType} (${params.cancellationMonth})`,
Description: descriptionLines.join("\n"),
AccountId: safeAccountId,
};
// Link to Opportunity if we have one
if (safeOpportunityId) {
casePayload.OpportunityId = safeOpportunityId;
}
try {
const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string };
if (!created.id) {
throw new Error("Salesforce did not return a case ID");
}
this.logger.log("Cancellation case created", {
caseId: created.id,
hasOpportunityLink: !!safeOpportunityId,
});
return created.id;
} catch (error: unknown) {
this.logger.error("Failed to create cancellation case", {
error: getErrorMessage(error),
accountIdTail: safeAccountId.slice(-4),
});
throw new Error("Failed to create cancellation request case");
}
}
}

View File

@ -0,0 +1,741 @@
/**
* Salesforce Opportunity Integration Service
*
* Manages Opportunity records for service lifecycle tracking.
* Opportunities track customer journeys from interest through cancellation.
*
* Key responsibilities:
* - Create Opportunities at interest triggers (eligibility request, registration)
* - Update Opportunity stages as orders progress
* - Link WHMCS services to Opportunities for cancellation workflows
* - Store cancellation form data on Opportunities
*
* Uses existing Salesforce stage values:
* - Introduction Ready Post Processing Active Cancelling Cancelled
*
* @see docs/salesforce/OPPORTUNITY-LIFECYCLE-GUIDE.md for complete documentation
*/
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "./salesforce-connection.service.js";
import { assertSalesforceId } from "../utils/soql.util.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
import type { SalesforceResponse } from "@customer-portal/domain/common";
import {
type OpportunityStageValue,
type OpportunityProductTypeValue,
type OpportunitySourceValue,
type ApplicationStageValue,
type CancellationNoticeValue,
type LineReturnStatusValue,
type CommodityTypeValue,
type CancellationOpportunityData,
type CreateOpportunityRequest,
type OpportunityRecord,
OPPORTUNITY_STAGE,
APPLICATION_STAGE,
OPEN_OPPORTUNITY_STAGES,
COMMODITY_TYPE,
OPPORTUNITY_PRODUCT_TYPE,
getDefaultCommodityType,
getCommodityTypeProductType,
} from "@customer-portal/domain/opportunity";
import {
OPPORTUNITY_FIELD_MAP,
OPPORTUNITY_MATCH_QUERY_FIELDS,
OPPORTUNITY_DETAIL_QUERY_FIELDS,
OPPORTUNITY_CANCELLATION_QUERY_FIELDS,
} from "../config/opportunity-field-map.js";
// ============================================================================
// Types
// ============================================================================
/**
* Raw Opportunity record from Salesforce query
*/
interface SalesforceOpportunityRecord {
Id: string;
Name: string;
AccountId: string;
StageName: string;
CloseDate: string;
IsClosed: boolean;
IsWon?: boolean;
CreatedDate: string;
LastModifiedDate: string;
// Existing custom fields
Application_Stage__c?: string;
CommodityType?: string; // Existing product type field
ScheduledCancellationDateAndTime__c?: string;
CancellationNotice__c?: string;
LineReturn__c?: string;
// New custom fields (to be created)
Portal_Source__c?: string;
WHMCS_Service_ID__c?: number;
// Note: Cases and Orders link TO Opportunity via their OpportunityId field
// Cancellation comments and alternative email are on the Cancellation Case
// Relationship fields
Account?: { Name?: string };
}
// ============================================================================
// Service
// ============================================================================
@Injectable()
export class SalesforceOpportunityService {
constructor(
private readonly sf: SalesforceConnection,
@Inject(Logger) private readonly logger: Logger
) {}
// ==========================================================================
// Core CRUD Operations
// ==========================================================================
/**
* Create a new Opportunity in Salesforce
*
* @param request - Opportunity creation parameters
* @returns The created Opportunity ID
*
* @example
* // Create for Internet eligibility request
* const oppId = await service.createOpportunity({
* accountId: 'SF_ACCOUNT_ID',
* productType: 'Internet',
* stage: 'Introduction',
* source: 'Portal - Internet Eligibility Request',
* });
*
* // Then create a Case linked to this Opportunity:
* await caseService.createCase({
* type: 'Eligibility Check',
* opportunityId: oppId, // Case links TO Opportunity
* ...
* });
*/
async createOpportunity(request: CreateOpportunityRequest): Promise<string> {
const safeAccountId = assertSalesforceId(request.accountId, "accountId");
this.logger.log("Creating Opportunity for service lifecycle tracking", {
accountId: safeAccountId,
productType: request.productType,
stage: request.stage,
source: request.source,
});
// Opportunity Name - Salesforce workflow will auto-generate the real name
// We provide a placeholder that includes product type for debugging
const opportunityName = `Portal - ${request.productType}`;
// Calculate close date (default: 30 days from now)
const closeDate =
request.closeDate ?? this.calculateCloseDate(request.productType, request.stage);
// Application stage defaults to INTRO-1 for portal
const applicationStage = request.applicationStage ?? APPLICATION_STAGE.INTRO_1;
// Get the CommodityType from the simplified product type
const commodityType = getDefaultCommodityType(request.productType);
const payload: Record<string, unknown> = {
[OPPORTUNITY_FIELD_MAP.name]: opportunityName,
[OPPORTUNITY_FIELD_MAP.accountId]: safeAccountId,
[OPPORTUNITY_FIELD_MAP.stage]: request.stage,
[OPPORTUNITY_FIELD_MAP.closeDate]: closeDate,
[OPPORTUNITY_FIELD_MAP.applicationStage]: applicationStage,
[OPPORTUNITY_FIELD_MAP.commodityType]: commodityType,
};
// Add optional custom fields (only if they exist in Salesforce)
if (request.source) {
payload[OPPORTUNITY_FIELD_MAP.source] = request.source;
}
// Note: Cases (eligibility, ID verification) link TO Opportunity via Case.OpportunityId
// Orders link TO Opportunity via Order.OpportunityId
try {
const createMethod = this.sf.sobject("Opportunity").create;
if (!createMethod) {
throw new Error("Salesforce Opportunity create method not available");
}
const result = (await createMethod(payload)) as { id?: string; success?: boolean };
if (!result?.id) {
throw new Error("Salesforce did not return Opportunity ID");
}
this.logger.log("Opportunity created successfully", {
opportunityId: result.id,
productType: request.productType,
stage: request.stage,
});
return result.id;
} catch (error) {
this.logger.error("Failed to create Opportunity", {
error: getErrorMessage(error),
accountId: safeAccountId,
productType: request.productType,
});
throw new Error("Failed to create service lifecycle record");
}
}
/**
* Update Opportunity stage
*
* @param opportunityId - Salesforce Opportunity ID
* @param stage - New stage value (must be valid Salesforce picklist value)
* @param reason - Optional reason for stage change (for audit)
*/
async updateStage(
opportunityId: string,
stage: OpportunityStageValue,
reason?: string
): Promise<void> {
const safeOppId = assertSalesforceId(opportunityId, "opportunityId");
this.logger.log("Updating Opportunity stage", {
opportunityId: safeOppId,
newStage: stage,
reason,
});
const payload: Record<string, unknown> = {
Id: safeOppId,
[OPPORTUNITY_FIELD_MAP.stage]: stage,
};
try {
const updateMethod = this.sf.sobject("Opportunity").update;
if (!updateMethod) {
throw new Error("Salesforce Opportunity update method not available");
}
await updateMethod(payload as Record<string, unknown> & { Id: string });
this.logger.log("Opportunity stage updated successfully", {
opportunityId: safeOppId,
stage,
});
} catch (error) {
this.logger.error("Failed to update Opportunity stage", {
error: getErrorMessage(error),
opportunityId: safeOppId,
stage,
});
throw new Error("Failed to update service lifecycle stage");
}
}
/**
* Update Opportunity with cancellation data from form submission
*
* Sets:
* - Stage to Cancelling
* - ScheduledCancellationDateAndTime__c
* - CancellationNotice__c to (received)
* - LineReturn__c to NotYet
*
* NOTE: Comments and alternative email go on the Cancellation Case, not Opportunity.
* The Case is created separately and linked to this Opportunity via Case.OpportunityId.
*
* @param opportunityId - Salesforce Opportunity ID
* @param data - Cancellation data (dates and status flags)
*/
async updateCancellationData(
opportunityId: string,
data: CancellationOpportunityData
): Promise<void> {
const safeOppId = assertSalesforceId(opportunityId, "opportunityId");
this.logger.log("Updating Opportunity with cancellation data", {
opportunityId: safeOppId,
scheduledDate: data.scheduledCancellationDate,
cancellationNotice: data.cancellationNotice,
});
const payload: Record<string, unknown> = {
Id: safeOppId,
[OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLING,
[OPPORTUNITY_FIELD_MAP.scheduledCancellationDate]: data.scheduledCancellationDate,
[OPPORTUNITY_FIELD_MAP.cancellationNotice]: data.cancellationNotice,
[OPPORTUNITY_FIELD_MAP.lineReturnStatus]: data.lineReturnStatus,
};
try {
const updateMethod = this.sf.sobject("Opportunity").update;
if (!updateMethod) {
throw new Error("Salesforce Opportunity update method not available");
}
await updateMethod(payload as Record<string, unknown> & { Id: string });
this.logger.log("Opportunity cancellation data updated successfully", {
opportunityId: safeOppId,
scheduledDate: data.scheduledCancellationDate,
});
} catch (error) {
this.logger.error("Failed to update Opportunity cancellation data", {
error: getErrorMessage(error),
opportunityId: safeOppId,
});
throw new Error("Failed to update cancellation information");
}
}
// ==========================================================================
// Lookup Operations
// ==========================================================================
/**
* Find an open Opportunity for an account by product type
*
* Used for matching orders to existing Opportunities
*
* @param accountId - Salesforce Account ID
* @param productType - Product type to match
* @returns Opportunity ID if found, null otherwise
*/
async findOpenOpportunityForAccount(
accountId: string,
productType: OpportunityProductTypeValue,
options?: { stages?: OpportunityStageValue[] }
): Promise<string | null> {
const safeAccountId = assertSalesforceId(accountId, "accountId");
// Get the CommodityType value(s) that match this product type
const commodityTypeValues = this.getCommodityTypesForProductType(productType);
const stages =
Array.isArray(options?.stages) && options?.stages.length > 0
? options.stages
: OPEN_OPPORTUNITY_STAGES;
this.logger.debug("Looking for open Opportunity", {
accountId: safeAccountId,
productType,
commodityTypes: commodityTypeValues,
stages,
});
// Build stage filter for open stages
const stageList = stages.map((s: OpportunityStageValue) => `'${s}'`).join(", ");
const commodityTypeList = commodityTypeValues.map(ct => `'${ct}'`).join(", ");
const soql = `
SELECT ${OPPORTUNITY_MATCH_QUERY_FIELDS.join(", ")}
FROM Opportunity
WHERE ${OPPORTUNITY_FIELD_MAP.accountId} = '${safeAccountId}'
AND ${OPPORTUNITY_FIELD_MAP.commodityType} IN (${commodityTypeList})
AND ${OPPORTUNITY_FIELD_MAP.stage} IN (${stageList})
AND ${OPPORTUNITY_FIELD_MAP.isClosed} = false
ORDER BY CreatedDate DESC
LIMIT 1
`;
try {
const result = (await this.sf.query(soql, {
label: "opportunity:findOpenForAccount",
})) as SalesforceResponse<SalesforceOpportunityRecord>;
const record = result.records?.[0];
if (record) {
this.logger.debug("Found open Opportunity", {
opportunityId: record.Id,
stage: record.StageName,
productType,
});
return record.Id;
}
this.logger.debug("No open Opportunity found", {
accountId: safeAccountId,
productType,
});
return null;
} catch (error) {
this.logger.error("Failed to find open Opportunity", {
error: getErrorMessage(error),
accountId: safeAccountId,
productType,
});
// Don't throw - return null to allow fallback to creation
return null;
}
}
/**
* Find Opportunity linked to an Order
*
* @param orderId - Salesforce Order ID
* @returns Opportunity ID if found, null otherwise
*/
async findOpportunityByOrderId(orderId: string): Promise<string | null> {
const safeOrderId = assertSalesforceId(orderId, "orderId");
this.logger.debug("Looking for Opportunity by Order ID", {
orderId: safeOrderId,
});
const soql = `
SELECT OpportunityId
FROM Order
WHERE Id = '${safeOrderId}'
LIMIT 1
`;
try {
const result = (await this.sf.query(soql, {
label: "opportunity:findByOrderId",
})) as SalesforceResponse<{ OpportunityId?: string }>;
const record = result.records?.[0];
const opportunityId = record?.OpportunityId;
if (opportunityId) {
this.logger.debug("Found Opportunity for Order", {
orderId: safeOrderId,
opportunityId,
});
return opportunityId;
}
return null;
} catch (error) {
this.logger.error("Failed to find Opportunity by Order ID", {
error: getErrorMessage(error),
orderId: safeOrderId,
});
return null;
}
}
/**
* Find Opportunity by WHMCS Service ID
*
* Used for cancellation workflows to find the Opportunity to update
*
* @param whmcsServiceId - WHMCS Service/Hosting ID
* @returns Opportunity ID if found, null otherwise
*/
async findOpportunityByWhmcsServiceId(whmcsServiceId: number): Promise<string | null> {
this.logger.debug("Looking for Opportunity by WHMCS Service ID", {
whmcsServiceId,
});
const soql = `
SELECT Id, ${OPPORTUNITY_FIELD_MAP.stage}
FROM Opportunity
WHERE ${OPPORTUNITY_FIELD_MAP.whmcsServiceId} = ${whmcsServiceId}
ORDER BY CreatedDate DESC
LIMIT 1
`;
try {
const result = (await this.sf.query(soql, {
label: "opportunity:findByWhmcsServiceId",
})) as SalesforceResponse<SalesforceOpportunityRecord>;
const record = result.records?.[0];
if (record) {
this.logger.debug("Found Opportunity for WHMCS Service", {
opportunityId: record.Id,
whmcsServiceId,
});
return record.Id;
}
return null;
} catch (error) {
this.logger.error("Failed to find Opportunity by WHMCS Service ID", {
error: getErrorMessage(error),
whmcsServiceId,
});
return null;
}
}
/**
* Get full Opportunity details by ID
*
* @param opportunityId - Salesforce Opportunity ID
* @returns Opportunity record or null if not found
*/
async getOpportunityById(opportunityId: string): Promise<OpportunityRecord | null> {
const safeOppId = assertSalesforceId(opportunityId, "opportunityId");
const soql = `
SELECT ${OPPORTUNITY_DETAIL_QUERY_FIELDS.join(", ")}
FROM Opportunity
WHERE Id = '${safeOppId}'
LIMIT 1
`;
try {
const result = (await this.sf.query(soql, {
label: "opportunity:getById",
})) as SalesforceResponse<SalesforceOpportunityRecord>;
const record = result.records?.[0];
if (!record) {
return null;
}
return this.transformToOpportunityRecord(record);
} catch (error) {
this.logger.error("Failed to get Opportunity by ID", {
error: getErrorMessage(error),
opportunityId: safeOppId,
});
return null;
}
}
/**
* Get cancellation status for display in portal
*
* @param whmcsServiceId - WHMCS Service ID
* @returns Cancellation status details or null
*/
async getCancellationStatus(whmcsServiceId: number): Promise<{
stage: OpportunityStageValue;
isPending: boolean;
isComplete: boolean;
scheduledEndDate?: string;
rentalReturnStatus?: LineReturnStatusValue;
} | null> {
const soql = `
SELECT ${OPPORTUNITY_CANCELLATION_QUERY_FIELDS.join(", ")}
FROM Opportunity
WHERE ${OPPORTUNITY_FIELD_MAP.whmcsServiceId} = ${whmcsServiceId}
ORDER BY CreatedDate DESC
LIMIT 1
`;
try {
const result = (await this.sf.query(soql, {
label: "opportunity:getCancellationStatus",
})) as SalesforceResponse<SalesforceOpportunityRecord>;
const record = result.records?.[0];
if (!record) return null;
const stage = record.StageName as OpportunityStageValue;
const isPending = stage === OPPORTUNITY_STAGE.CANCELLING;
const isComplete = stage === OPPORTUNITY_STAGE.CANCELLED;
return {
stage,
isPending,
isComplete,
scheduledEndDate: record.ScheduledCancellationDateAndTime__c,
rentalReturnStatus: record.LineReturn__c as LineReturnStatusValue | undefined,
};
} catch (error) {
this.logger.error("Failed to get cancellation status", {
error: getErrorMessage(error),
whmcsServiceId,
});
return null;
}
}
// ==========================================================================
// Lifecycle Helpers
// ==========================================================================
/**
* Link a WHMCS Service ID to an Opportunity
*
* Called after provisioning to enable cancellation workflows
*
* @param opportunityId - Salesforce Opportunity ID
* @param whmcsServiceId - WHMCS Service/Hosting ID
*/
async linkWhmcsServiceToOpportunity(
opportunityId: string,
whmcsServiceId: number
): Promise<void> {
const safeOppId = assertSalesforceId(opportunityId, "opportunityId");
this.logger.log("Linking WHMCS Service to Opportunity", {
opportunityId: safeOppId,
whmcsServiceId,
});
const payload: Record<string, unknown> = {
Id: safeOppId,
[OPPORTUNITY_FIELD_MAP.whmcsServiceId]: whmcsServiceId,
};
try {
const updateMethod = this.sf.sobject("Opportunity").update;
if (!updateMethod) {
throw new Error("Salesforce Opportunity update method not available");
}
await updateMethod(payload as Record<string, unknown> & { Id: string });
this.logger.log("WHMCS Service linked to Opportunity", {
opportunityId: safeOppId,
whmcsServiceId,
});
} catch (error) {
this.logger.error("Failed to link WHMCS Service to Opportunity", {
error: getErrorMessage(error),
opportunityId: safeOppId,
whmcsServiceId,
});
// Don't throw - this is a non-critical update
}
}
/**
* Link an Order to an Opportunity (update Order.OpportunityId)
*
* Note: This updates the Order record, not the Opportunity
*
* @param orderId - Salesforce Order ID
* @param opportunityId - Salesforce Opportunity ID
*/
async linkOrderToOpportunity(orderId: string, opportunityId: string): Promise<void> {
const safeOrderId = assertSalesforceId(orderId, "orderId");
const safeOppId = assertSalesforceId(opportunityId, "opportunityId");
this.logger.log("Linking Order to Opportunity", {
orderId: safeOrderId,
opportunityId: safeOppId,
});
try {
const updateMethod = this.sf.sobject("Order").update;
if (!updateMethod) {
throw new Error("Salesforce Order update method not available");
}
await updateMethod({
Id: safeOrderId,
OpportunityId: safeOppId,
});
this.logger.log("Order linked to Opportunity", {
orderId: safeOrderId,
opportunityId: safeOppId,
});
} catch (error) {
this.logger.error("Failed to link Order to Opportunity", {
error: getErrorMessage(error),
orderId: safeOrderId,
opportunityId: safeOppId,
});
// Don't throw - this is a non-critical update
}
}
/**
* Mark cancellation as complete
*
* @param opportunityId - Opportunity ID
*/
async markCancellationComplete(opportunityId: string): Promise<void> {
await this.updateStage(opportunityId, OPPORTUNITY_STAGE.CANCELLED, "Cancellation completed");
}
// ==========================================================================
// Private Helpers
// ==========================================================================
/**
* Calculate close date based on product type and stage
*/
private calculateCloseDate(
productType: OpportunityProductTypeValue,
stage: OpportunityStageValue
): string {
const today = new Date();
let daysToAdd: number;
// Different close date expectations based on stage/product
switch (stage) {
case OPPORTUNITY_STAGE.INTRODUCTION:
// Internet eligibility - may take 30 days
daysToAdd = 30;
break;
case OPPORTUNITY_STAGE.READY:
// Ready to order - expected soon
daysToAdd = 14;
break;
case OPPORTUNITY_STAGE.POST_PROCESSING:
// Order placed - expected within 7 days
daysToAdd = 7;
break;
default:
// Default: 30 days
daysToAdd = 30;
}
const closeDate = new Date(today);
closeDate.setDate(closeDate.getDate() + daysToAdd);
return closeDate.toISOString().slice(0, 10);
}
/**
* Get CommodityType values that match a simplified product type
* Used for querying opportunities by product category
*/
private getCommodityTypesForProductType(
productType: OpportunityProductTypeValue
): CommodityTypeValue[] {
switch (productType) {
case OPPORTUNITY_PRODUCT_TYPE.INTERNET:
return [COMMODITY_TYPE.PERSONAL_HOME_INTERNET, COMMODITY_TYPE.CORPORATE_HOME_INTERNET];
case OPPORTUNITY_PRODUCT_TYPE.SIM:
return [COMMODITY_TYPE.SIM];
case OPPORTUNITY_PRODUCT_TYPE.VPN:
return [COMMODITY_TYPE.VPN];
default:
return [];
}
}
/**
* Transform Salesforce record to domain OpportunityRecord
*/
private transformToOpportunityRecord(record: SalesforceOpportunityRecord): OpportunityRecord {
// Derive productType from CommodityType (existing Salesforce field)
const commodityType = record.CommodityType as CommodityTypeValue | undefined;
const productType = commodityType ? getCommodityTypeProductType(commodityType) : undefined;
return {
id: record.Id,
name: record.Name,
accountId: record.AccountId,
stage: record.StageName as OpportunityStageValue,
closeDate: record.CloseDate,
commodityType,
productType: productType ?? undefined,
source: record.Portal_Source__c as OpportunitySourceValue | undefined,
applicationStage: record.Application_Stage__c as ApplicationStageValue | undefined,
isClosed: record.IsClosed,
// Note: Related Cases and Orders are queried separately via their OpportunityId field
whmcsServiceId: record.WHMCS_Service_ID__c,
// Cancellation fields (updated by CS when processing cancellation Case)
scheduledCancellationDate: record.ScheduledCancellationDateAndTime__c,
cancellationNotice: record.CancellationNotice__c as CancellationNoticeValue | undefined,
lineReturnStatus: record.LineReturn__c as LineReturnStatusValue | undefined,
// NOTE: alternativeContactEmail and cancellationComments are on Cancellation Case
createdDate: record.CreatedDate,
lastModifiedDate: record.LastModifiedDate,
};
}
}

View File

@ -1,8 +1,8 @@
/**
* Salesforce Catalog Query Builders
* Salesforce Services Query Builders
*
* SOQL query builders for Product2 catalog queries.
* Extracted from BaseCatalogService for consistency with order query builders.
* SOQL query builders for Product2 services queries.
* Extracted from BaseServicesService for consistency with order query builders.
*/
import { sanitizeSoqlLiteral, assertSoqlFieldName } from "./soql.util.js";
@ -41,9 +41,9 @@ export function buildProductQuery(
}
/**
* Build catalog service query (Service items only)
* Build services query (Service items only)
*/
export function buildCatalogServiceQuery(
export function buildServicesQuery(
portalPricebookId: string,
portalCategoryField: string,
category: string,

View File

@ -42,11 +42,21 @@ export class WhmcsCacheService {
ttl: 600, // 10 minutes - individual subscriptions rarely change
tags: ["subscription", "services"],
},
subscriptionInvoices: {
prefix: "whmcs:subscription:invoices",
ttl: 300, // 5 minutes
tags: ["subscription", "invoices"],
},
client: {
prefix: "whmcs:client",
ttl: 1800, // 30 minutes - client data rarely changes
tags: ["client", "user"],
},
clientEmail: {
prefix: "whmcs:client:email",
ttl: 1800, // 30 minutes
tags: ["client", "email"],
},
sso: {
prefix: "whmcs:sso",
ttl: 3600, // 1 hour - SSO tokens have their own expiry
@ -144,6 +154,36 @@ export class WhmcsCacheService {
await this.set(key, data, "subscription", [`user:${userId}`, `subscription:${subscriptionId}`]);
}
/**
* Get cached subscription invoices
*/
async getSubscriptionInvoices(
userId: string,
subscriptionId: number,
page: number,
limit: number
): Promise<InvoiceList | null> {
const key = this.buildSubscriptionInvoicesKey(userId, subscriptionId, page, limit);
return this.get<InvoiceList>(key, "subscriptionInvoices");
}
/**
* Cache subscription invoices
*/
async setSubscriptionInvoices(
userId: string,
subscriptionId: number,
page: number,
limit: number,
data: InvoiceList
): Promise<void> {
const key = this.buildSubscriptionInvoicesKey(userId, subscriptionId, page, limit);
await this.set(key, data, "subscriptionInvoices", [
`user:${userId}`,
`subscription:${subscriptionId}`,
]);
}
/**
* Get cached client data
* Returns WhmcsClient (type inferred from domain)
@ -161,6 +201,22 @@ export class WhmcsCacheService {
await this.set(key, data, "client", [`client:${clientId}`]);
}
/**
* Get cached client ID by email
*/
async getClientIdByEmail(email: string): Promise<number | null> {
const key = this.buildClientEmailKey(email);
return this.get<number>(key, "clientEmail");
}
/**
* Cache client ID for email
*/
async setClientIdByEmail(email: string, clientId: number): Promise<void> {
const key = this.buildClientEmailKey(email);
await this.set(key, clientId, "clientEmail");
}
/**
* Invalidate all cache for a specific user
*/
@ -383,6 +439,18 @@ export class WhmcsCacheService {
return `${this.cacheConfigs.subscription.prefix}:${userId}:${subscriptionId}`;
}
/**
* Build cache key for subscription invoices
*/
private buildSubscriptionInvoicesKey(
userId: string,
subscriptionId: number,
page: number,
limit: number
): string {
return `${this.cacheConfigs.subscriptionInvoices.prefix}:${userId}:${subscriptionId}:${page}:${limit}`;
}
/**
* Build cache key for client data
*/
@ -390,6 +458,13 @@ export class WhmcsCacheService {
return `${this.cacheConfigs.client.prefix}:${clientId}`;
}
/**
* Build cache key for client email mapping
*/
private buildClientEmailKey(email: string): string {
return `${this.cacheConfigs.clientEmail.prefix}:${email.toLowerCase()}`;
}
/**
* Build cache key for payment methods
*/

View File

@ -33,7 +33,7 @@ import type {
} from "@customer-portal/domain/payments";
import type { WhmcsGetClientsProductsParams } from "@customer-portal/domain/subscriptions";
import type { WhmcsProductListResponse } from "@customer-portal/domain/subscriptions";
import type { WhmcsCatalogProductListResponse } from "@customer-portal/domain/catalog";
import type { WhmcsCatalogProductListResponse } from "@customer-portal/domain/services";
import type { WhmcsErrorResponse } from "@customer-portal/domain/common";
import type { WhmcsRequestOptions, WhmcsConnectionStats } from "../types/connection.types.js";

View File

@ -0,0 +1,93 @@
import { Injectable, NotFoundException, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service.js";
import { WhmcsCacheService } from "../cache/whmcs-cache.service.js";
import { Providers as CustomerProviders } from "@customer-portal/domain/customer";
import type { WhmcsClient } from "@customer-portal/domain/customer";
/**
* Service for discovering and verifying WHMCS accounts by email.
* Separated from WhmcsClientService to isolate "discovery" logic from "authenticated" logic.
*/
@Injectable()
export class WhmcsAccountDiscoveryService {
constructor(
private readonly connectionService: WhmcsConnectionOrchestratorService,
private readonly cacheService: WhmcsCacheService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Find a client by email address.
* This is a "discovery" operation used during signup/login flows.
* It uses a specialized cache to map Email -> Client ID.
*/
async findClientByEmail(email: string): Promise<WhmcsClient | null> {
try {
// 1. Try to find client ID by email from cache
const cachedClientId = await this.cacheService.getClientIdByEmail(email);
if (cachedClientId) {
this.logger.debug(`Cache hit for email-to-id: ${email} -> ${cachedClientId}`);
// If we have ID, fetch the full client data (which has its own cache)
return this.getClientDetailsById(cachedClientId);
}
// 2. If no mapping, fetch from API
// We use a try-catch here because the connection service might throw if not found
// or if the API returns a specific error for "no results"
const response = await this.connectionService.getClientDetailsByEmail(email);
if (!response || !response.client) {
// Not found is a valid state for discovery (return null)
return null;
}
const client = CustomerProviders.Whmcs.transformWhmcsClientResponse(response);
// 3. Cache both the data and the mapping
await Promise.all([
this.cacheService.setClientData(client.id, client),
this.cacheService.setClientIdByEmail(email, client.id),
]);
this.logger.log(`Discovered client by email: ${email}`);
return client;
} catch (error) {
// Handle "Not Found" specifically
if (
error instanceof NotFoundException ||
(error instanceof Error && error.message.includes("not found"))
) {
return null;
}
// Log other errors but don't crash - return null to indicate lookup failed safely
this.logger.warn(`Failed to discover client by email: ${email}`, {
error: getErrorMessage(error),
});
return null;
}
}
/**
* Helper to get details by ID, reusing the cache logic from ClientService logic
* We duplicate this small fetch to avoid circular dependency or tight coupling with WhmcsClientService
*/
private async getClientDetailsById(clientId: number): Promise<WhmcsClient> {
// Try cache first
const cached = await this.cacheService.getClientData(clientId);
if (cached) {
return cached;
}
const response = await this.connectionService.getClientDetails(clientId);
if (!response || !response.client) {
throw new NotFoundException(`Client ${clientId} not found`);
}
const client = CustomerProviders.Whmcs.transformWhmcsClientResponse(response);
await this.cacheService.setClientData(client.id, client);
return client;
}
}

View File

@ -86,35 +86,6 @@ export class WhmcsClientService {
}
}
/**
* Get client details by email
* Returns WhmcsClient (type inferred from domain mapper)
*/
async getClientDetailsByEmail(email: string): Promise<WhmcsClient> {
try {
const response = await this.connectionService.getClientDetailsByEmail(email);
if (!response || !response.client) {
this.logger.error(`WHMCS API did not return client data for email: ${email}`, {
hasResponse: !!response,
responseKeys: response ? Object.keys(response) : [],
});
throw new NotFoundException(`Client with email ${email} not found`);
}
const client = CustomerProviders.Whmcs.transformWhmcsClientResponse(response);
await this.cacheService.setClientData(client.id, client);
this.logger.log(`Fetched client details by email: ${email}`);
return client;
} catch (error) {
this.logger.error(`Failed to fetch client details by email: ${email}`, {
error: getErrorMessage(error),
});
throw error;
}
}
/**
* Update client details
*/

View File

@ -11,7 +11,7 @@ import type {
import {
Providers as CatalogProviders,
type WhmcsCatalogProductNormalized,
} from "@customer-portal/domain/catalog";
} from "@customer-portal/domain/services";
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service.js";
import { WhmcsCacheService } from "../cache/whmcs-cache.service.js";
import type { WhmcsCreateSsoTokenParams } from "@customer-portal/domain/customer";

View File

@ -123,11 +123,40 @@ export class WhmcsSubscriptionService {
return cached;
}
// Get all subscriptions and find the specific one
const subscriptionList = await this.getSubscriptions(clientId, userId);
const subscription = subscriptionList.subscriptions.find(
(s: Subscription) => s.id === subscriptionId
);
// 2. Check if we have the FULL list cached.
// If we do, searching memory is faster than an API call.
const cachedList = await this.cacheService.getSubscriptionsList(userId);
if (cachedList) {
const found = cachedList.subscriptions.find((s: Subscription) => s.id === subscriptionId);
if (found) {
this.logger.debug(
`Cache hit (via list) for subscription: user ${userId}, subscription ${subscriptionId}`
);
// Cache this individual item for faster direct access next time
await this.cacheService.setSubscription(userId, subscriptionId, found);
return found;
}
// If list is cached but item not found, it might be new or not in that list?
// Proceed to fetch single item.
}
// 3. Fetch ONLY this subscription from WHMCS (Optimized)
// Instead of fetching all products, use serviceid filter
const params: WhmcsGetClientsProductsParams = {
clientid: clientId,
serviceid: subscriptionId,
};
const rawResponse = await this.connectionService.getClientsProducts(params);
// Transform response
const defaultCurrency = this.currencyService.getDefaultCurrency();
const resultList = Providers.Whmcs.transformWhmcsSubscriptionListResponse(rawResponse, {
defaultCurrencyCode: defaultCurrency.code,
defaultCurrencySymbol: defaultCurrency.prefix || defaultCurrency.suffix,
});
const subscription = resultList.subscriptions.find(s => s.id === subscriptionId);
if (!subscription) {
throw new NotFoundException(`Subscription ${subscriptionId} not found`);

View File

@ -10,6 +10,7 @@ import { WhmcsPaymentService } from "./services/whmcs-payment.service.js";
import { WhmcsSsoService } from "./services/whmcs-sso.service.js";
import { WhmcsOrderService } from "./services/whmcs-order.service.js";
import { WhmcsCurrencyService } from "./services/whmcs-currency.service.js";
import { WhmcsAccountDiscoveryService } from "./services/whmcs-account-discovery.service.js";
// Connection services
import { WhmcsConnectionOrchestratorService } from "./connection/services/whmcs-connection-orchestrator.service.js";
import { WhmcsConfigService } from "./connection/config/whmcs-config.service.js";
@ -33,15 +34,18 @@ import { WhmcsErrorHandlerService } from "./connection/services/whmcs-error-hand
WhmcsSsoService,
WhmcsOrderService,
WhmcsCurrencyService,
WhmcsAccountDiscoveryService,
WhmcsService,
],
exports: [
WhmcsService,
WhmcsConnectionOrchestratorService,
WhmcsCacheService,
WhmcsClientService,
WhmcsOrderService,
WhmcsPaymentService,
WhmcsCurrencyService,
WhmcsAccountDiscoveryService,
],
})
export class WhmcsModule {}

View File

@ -20,7 +20,7 @@ import { WhmcsOrderService } from "./services/whmcs-order.service.js";
import type { WhmcsAddClientParams, WhmcsClientResponse } from "@customer-portal/domain/customer";
import type { WhmcsGetClientsProductsParams } from "@customer-portal/domain/subscriptions";
import type { WhmcsProductListResponse } from "@customer-portal/domain/subscriptions";
import type { WhmcsCatalogProductNormalized } from "@customer-portal/domain/catalog";
import type { WhmcsCatalogProductNormalized } from "@customer-portal/domain/services";
import { Logger } from "nestjs-pino";
@Injectable()
@ -131,14 +131,6 @@ export class WhmcsService {
return this.clientService.getClientDetails(clientId);
}
/**
* Get client details by email
* Returns internal WhmcsClient (type inferred)
*/
async getClientDetailsByEmail(email: string): Promise<WhmcsClient> {
return this.clientService.getClientDetailsByEmail(email);
}
/**
* Update client details in WHMCS
*/

View File

@ -4,6 +4,7 @@ import * as argon2 from "argon2";
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
@ -39,6 +40,7 @@ export class AuthFacade {
private readonly mappingsService: MappingsService,
private readonly configService: ConfigService,
private readonly whmcsService: WhmcsService,
private readonly discoveryService: WhmcsAccountDiscoveryService,
private readonly salesforceService: SalesforceService,
private readonly auditService: AuditService,
private readonly tokenBlacklistService: TokenBlacklistService,
@ -418,14 +420,9 @@ export class AuthFacade {
if (mapped) {
whmcsExists = true;
} else {
// Try a direct WHMCS lookup by email (best-effort)
try {
const client = await this.whmcsService.getClientDetailsByEmail(normalized);
whmcsExists = !!client;
} catch (e) {
// Treat not found as no; other errors as unknown (leave whmcsExists false)
this.logger.debug("Account status: WHMCS lookup", { error: getErrorMessage(e) });
}
// Try a direct WHMCS lookup by email using discovery service (returns null if not found)
const client = await this.discoveryService.findClientByEmail(normalized);
whmcsExists = !!client;
}
let state: "none" | "portal_only" | "whmcs_only" | "both_mapped" = "none";

View File

@ -2,3 +2,12 @@ import { SetMetadata } from "@nestjs/common";
export const IS_PUBLIC_KEY = "isPublic";
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
/**
* Marks a route/controller as public *and* disables optional session attachment.
*
* Why: some endpoints must be strictly non-personalized for caching/security correctness
* (e.g. public service catalogs). These endpoints should ignore cookies/tokens entirely.
*/
export const IS_PUBLIC_NO_SESSION_KEY = "isPublicNoSession";
export const PublicNoSession = () => SetMetadata(IS_PUBLIC_NO_SESSION_KEY, true);

View File

@ -1,9 +1,9 @@
import {
BadRequestException,
ConflictException,
HttpStatus,
Inject,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
@ -13,16 +13,20 @@ import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js";
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js";
import { PrismaService } from "@bff/infra/database/prisma.service.js";
import { AuthTokenService } from "../../token/token.service.js";
import { AuthRateLimitService } from "../../rate-limiting/auth-rate-limit.service.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { DomainHttpException } from "@bff/core/http/domain-http.exception.js";
import {
signupRequestSchema,
type SignupRequest,
type ValidateSignupRequest,
} from "@customer-portal/domain/auth";
import { ErrorCode } from "@customer-portal/domain/common";
import { Providers as CustomerProviders } from "@customer-portal/domain/customer";
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
import type { User as PrismaUser } from "@prisma/client";
@ -54,7 +58,9 @@ export class SignupWorkflowService {
private readonly usersFacade: UsersFacade,
private readonly mappingsService: MappingsService,
private readonly whmcsService: WhmcsService,
private readonly discoveryService: WhmcsAccountDiscoveryService,
private readonly salesforceService: SalesforceService,
private readonly salesforceAccountService: SalesforceAccountService,
private readonly configService: ConfigService,
private readonly prisma: PrismaService,
private readonly auditService: AuditService,
@ -66,14 +72,30 @@ export class SignupWorkflowService {
async validateSignup(validateData: ValidateSignupRequest, request?: Request) {
const { sfNumber } = validateData;
const normalizedCustomerNumber = this.normalizeCustomerNumber(sfNumber);
if (!normalizedCustomerNumber) {
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
undefined,
{ sfNumber: sfNumber ?? null, reason: "no_customer_number_provided" },
request,
true
);
return {
valid: true,
message: "Customer number is not required for signup",
};
}
try {
const accountSnapshot = await this.getAccountSnapshot(sfNumber);
const accountSnapshot = await this.getAccountSnapshot(normalizedCustomerNumber);
if (!accountSnapshot) {
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
undefined,
{ sfNumber, reason: "SF number not found" },
{ sfNumber: normalizedCustomerNumber, reason: "SF number not found" },
request,
false,
"Customer number not found in Salesforce"
@ -118,7 +140,7 @@ export class SignupWorkflowService {
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
undefined,
{ sfNumber, sfAccountId: accountSnapshot.id, step: "validation" },
{ sfNumber: normalizedCustomerNumber, sfAccountId: accountSnapshot.id, step: "validation" },
request,
true
);
@ -136,7 +158,7 @@ export class SignupWorkflowService {
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
undefined,
{ sfNumber, error: getErrorMessage(error) },
{ sfNumber: normalizedCustomerNumber, error: getErrorMessage(error) },
request,
false,
getErrorMessage(error)
@ -189,39 +211,100 @@ export class SignupWorkflowService {
const passwordHash = await argon2.hash(password);
try {
const accountSnapshot = await this.getAccountSnapshot(sfNumber);
if (!accountSnapshot) {
throw new BadRequestException(
`Salesforce account not found for Customer Number: ${sfNumber}`
);
}
const normalizedCustomerNumber = this.normalizeCustomerNumber(sfNumber);
let accountSnapshot: SignupAccountSnapshot;
let customerNumberForWhmcs: string | null = normalizedCustomerNumber;
if (accountSnapshot.WH_Account__c && accountSnapshot.WH_Account__c.trim() !== "") {
throw new ConflictException(
"You already have an account. Please use the login page to access your existing account."
);
if (normalizedCustomerNumber) {
const resolved = await this.getAccountSnapshot(normalizedCustomerNumber);
if (!resolved) {
throw new BadRequestException(
`Salesforce account not found for Customer Number: ${normalizedCustomerNumber}`
);
}
if (resolved.WH_Account__c && resolved.WH_Account__c.trim() !== "") {
throw new ConflictException(
"You already have an account. Please use the login page to access your existing account."
);
}
accountSnapshot = resolved;
} else {
const normalizedEmail = email.toLowerCase().trim();
const existingAccount = await this.salesforceAccountService.findByEmail(normalizedEmail);
if (existingAccount) {
throw new ConflictException(
"An account already exists for this email. Please sign in or transfer your account."
);
}
if (
!address?.address1 ||
!address?.city ||
!address?.state ||
!address?.postcode ||
!address?.country
) {
throw new BadRequestException(
"Complete address information is required for account creation"
);
}
if (!phone) {
throw new BadRequestException("Phone number is required for account creation");
}
const created = await this.salesforceAccountService.createAccount({
firstName,
lastName,
email: normalizedEmail,
phone,
address: {
address1: address.address1,
address2: address.address2 || undefined,
city: address.city,
state: address.state,
postcode: address.postcode,
country: address.country,
},
});
await this.salesforceAccountService.createContact({
accountId: created.accountId,
firstName,
lastName,
email: normalizedEmail,
phone,
address: {
address1: address.address1,
address2: address.address2 || undefined,
city: address.city,
state: address.state,
postcode: address.postcode,
country: address.country,
},
});
accountSnapshot = {
id: created.accountId,
Name: `${firstName} ${lastName}`,
WH_Account__c: null,
};
customerNumberForWhmcs = created.accountNumber;
}
let whmcsClient: { clientId: number };
try {
try {
const existingWhmcs = await this.whmcsService.getClientDetailsByEmail(email);
if (existingWhmcs) {
const existingMapping = await this.mappingsService.findByWhmcsClientId(
existingWhmcs.id
);
if (existingMapping) {
throw new ConflictException("You already have an account. Please sign in.");
}
// Check if a WHMCS client already exists for this email using discovery service
const existingWhmcs = await this.discoveryService.findClientByEmail(email);
if (existingWhmcs) {
const existingMapping = await this.mappingsService.findByWhmcsClientId(existingWhmcs.id);
if (existingMapping) {
throw new ConflictException("You already have an account. Please sign in.");
}
throw new ConflictException(
"We found an existing billing account for this email. Please link your account instead."
);
}
} catch (pre) {
if (!(pre instanceof NotFoundException)) {
throw pre;
}
throw new DomainHttpException(ErrorCode.LEGACY_ACCOUNT_EXISTS, HttpStatus.CONFLICT);
}
const customerNumberFieldId = this.configService.get<string>(
@ -232,7 +315,9 @@ export class SignupWorkflowService {
const nationalityFieldId = this.configService.get<string>("WHMCS_NATIONALITY_FIELD_ID");
const customfieldsMap: Record<string, string> = {};
if (customerNumberFieldId) customfieldsMap[customerNumberFieldId] = sfNumber;
if (customerNumberFieldId && customerNumberForWhmcs) {
customfieldsMap[customerNumberFieldId] = customerNumberForWhmcs;
}
if (dobFieldId && dateOfBirth) customfieldsMap[dobFieldId] = dateOfBirth;
if (genderFieldId && gender) customfieldsMap[genderFieldId] = gender;
if (nationalityFieldId && nationality) customfieldsMap[nationalityFieldId] = nationality;
@ -253,7 +338,12 @@ export class SignupWorkflowService {
throw new BadRequestException("Phone number is required for billing account creation");
}
this.logger.log("Creating WHMCS client", { email, firstName, lastName, sfNumber });
this.logger.log("Creating WHMCS client", {
email,
firstName,
lastName,
sfNumber: customerNumberForWhmcs,
});
whmcsClient = await this.whmcsService.addClient({
firstname: firstName,
@ -399,6 +489,7 @@ export class SignupWorkflowService {
async signupPreflight(signupData: SignupRequest) {
const { email, sfNumber } = signupData;
const normalizedEmail = email.toLowerCase().trim();
const normalizedCustomerNumber = this.normalizeCustomerNumber(sfNumber);
const result: {
ok: boolean;
@ -440,24 +531,9 @@ export class SignupWorkflowService {
return result;
}
const accountSnapshot = await this.getAccountSnapshot(sfNumber);
if (!accountSnapshot) {
result.nextAction = "fix_input";
result.messages.push("Customer number not found in Salesforce");
return result;
}
result.salesforce.accountId = accountSnapshot.id;
const existingMapping = await this.mappingsService.findBySfAccountId(accountSnapshot.id);
if (existingMapping) {
result.salesforce.alreadyMapped = true;
result.nextAction = "login";
result.messages.push("This customer number is already registered. Please sign in.");
return result;
}
try {
const client = await this.whmcsService.getClientDetailsByEmail(normalizedEmail);
if (!normalizedCustomerNumber) {
// Check for existing WHMCS client using discovery service (returns null if not found)
const client = await this.discoveryService.findClientByEmail(normalizedEmail);
if (client) {
result.whmcs.clientExists = true;
result.whmcs.clientId = client.id;
@ -475,17 +551,68 @@ export class SignupWorkflowService {
result.nextAction = "link_whmcs";
result.messages.push(
"We found an existing billing account for this email. Please link your account."
"We found an existing billing account for this email. Please transfer your account to continue."
);
return result;
}
} catch (err) {
if (!(err instanceof NotFoundException)) {
this.logger.warn("WHMCS preflight check failed", { error: getErrorMessage(err) });
result.messages.push("Unable to verify billing system. Please try again later.");
result.nextAction = "blocked";
return result;
try {
const existingSf = await this.salesforceAccountService.findByEmail(normalizedEmail);
if (existingSf) {
result.nextAction = "blocked";
result.messages.push(
"We found an existing customer record for this email. Please transfer your account or contact support."
);
return result;
}
} catch (sfErr) {
this.logger.warn("Salesforce preflight check failed", { error: getErrorMessage(sfErr) });
}
result.canProceed = true;
result.nextAction = "proceed_signup";
result.messages.push("All checks passed. Ready to create your account.");
return result;
}
const accountSnapshot = await this.getAccountSnapshot(normalizedCustomerNumber);
if (!accountSnapshot) {
result.nextAction = "fix_input";
result.messages.push("Customer number not found in Salesforce");
return result;
}
result.salesforce.accountId = accountSnapshot.id;
const existingMapping = await this.mappingsService.findBySfAccountId(accountSnapshot.id);
if (existingMapping) {
result.salesforce.alreadyMapped = true;
result.nextAction = "login";
result.messages.push("This customer number is already registered. Please sign in.");
return result;
}
// Check for existing WHMCS client using discovery service (returns null if not found)
const client = await this.discoveryService.findClientByEmail(normalizedEmail);
if (client) {
result.whmcs.clientExists = true;
result.whmcs.clientId = client.id;
try {
const mapped = await this.mappingsService.findByWhmcsClientId(client.id);
if (mapped) {
result.nextAction = "login";
result.messages.push("This billing account is already linked. Please sign in.");
return result;
}
} catch {
// ignore; treat as unmapped
}
result.nextAction = "link_whmcs";
result.messages.push(
"We found an existing billing account for this email. Please transfer your account to continue."
);
return result;
}
result.canProceed = true;
@ -494,7 +621,9 @@ export class SignupWorkflowService {
return result;
}
private async getAccountSnapshot(sfNumber: string): Promise<SignupAccountSnapshot | null> {
private async getAccountSnapshot(
sfNumber?: string | null
): Promise<SignupAccountSnapshot | null> {
const normalized = this.normalizeCustomerNumber(sfNumber);
if (!normalized) {
return null;
@ -519,7 +648,7 @@ export class SignupWorkflowService {
return resolved;
}
private normalizeCustomerNumber(sfNumber: string): string | null {
private normalizeCustomerNumber(sfNumber?: string | null): string | null {
if (typeof sfNumber !== "string") {
return null;
}

View File

@ -10,6 +10,7 @@ import { Logger } from "nestjs-pino";
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
@ -26,6 +27,7 @@ export class WhmcsLinkWorkflowService {
private readonly usersFacade: UsersFacade,
private readonly mappingsService: MappingsService,
private readonly whmcsService: WhmcsService,
private readonly discoveryService: WhmcsAccountDiscoveryService,
private readonly salesforceService: SalesforceService,
@Inject(Logger) private readonly logger: Logger
) {}
@ -51,21 +53,19 @@ export class WhmcsLinkWorkflowService {
try {
let clientDetails; // Type inferred from WHMCS service
try {
clientDetails = await this.whmcsService.getClientDetailsByEmail(email);
} catch (error) {
this.logger.error("WHMCS client lookup failed", {
error: getErrorMessage(error),
email, // Safe to log email for debugging since it's not sensitive
});
// Provide more specific error messages based on the error type
// Use BadRequestException (400) instead of UnauthorizedException (401)
// to avoid triggering "session expired" logic in the frontend
if (error instanceof Error && error.message.includes("not found")) {
clientDetails = await this.discoveryService.findClientByEmail(email);
if (!clientDetails) {
throw new BadRequestException(
"No billing account found with this email address. Please check your email or contact support."
);
}
} catch (error) {
if (error instanceof BadRequestException) throw error;
this.logger.error("WHMCS client lookup failed", {
error: getErrorMessage(error),
email, // Safe to log email for debugging since it's not sensitive
});
throw new BadRequestException("Unable to verify account. Please try again later.");
}

View File

@ -230,11 +230,11 @@ export class AuthController {
}
@Public()
@Post("link-whmcs")
@Post("migrate")
@UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard)
@RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP (industry standard)
@UsePipes(new ZodValidationPipe(linkWhmcsRequestSchema))
async linkWhmcs(@Body() linkData: LinkWhmcsRequest, @Req() _req: Request) {
async migrateAccount(@Body() linkData: LinkWhmcsRequest, @Req() _req: Request) {
const result = await this.authFacade.linkWhmcsUser(linkData);
return linkWhmcsResponseSchema.parse(result);
}

View File

@ -5,7 +5,7 @@ import { Reflector } from "@nestjs/core";
import type { Request } from "express";
import { TokenBlacklistService } from "../../../infra/token/token-blacklist.service.js";
import { IS_PUBLIC_KEY } from "../../../decorators/public.decorator.js";
import { IS_PUBLIC_KEY, IS_PUBLIC_NO_SESSION_KEY } from "../../../decorators/public.decorator.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { JoseJwtService } from "../../../infra/token/jose-jwt.service.js";
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
@ -45,8 +45,27 @@ export class GlobalAuthGuard implements CanActivate {
context.getHandler(),
context.getClass(),
]);
const isPublicNoSession = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_NO_SESSION_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
if (isPublicNoSession) {
this.logger.debug(`Strict public route accessed (no session attach): ${route}`);
return true;
}
const token = extractAccessTokenFromRequest(request);
if (token) {
try {
await this.attachUserFromToken(request, token);
this.logger.debug(`Authenticated session detected on public route: ${route}`);
} catch {
// Public endpoints should remain accessible even if the session is missing/expired/invalid.
this.logger.debug(`Ignoring invalid session on public route: ${route}`);
}
}
this.logger.debug(`Public route accessed: ${route}`);
return true;
}
@ -61,45 +80,7 @@ export class GlobalAuthGuard implements CanActivate {
throw new UnauthorizedException("Missing token");
}
const payload = await this.jwtService.verify<{ sub?: string; email?: string; exp?: number }>(
token
);
const tokenType = (payload as { type?: unknown }).type;
if (typeof tokenType === "string" && tokenType !== "access") {
throw new UnauthorizedException("Invalid access token");
}
if (!payload.sub || !payload.email) {
throw new UnauthorizedException("Invalid token payload");
}
// Explicit expiry buffer check to avoid tokens expiring mid-request
if (typeof payload.exp !== "number") {
throw new UnauthorizedException("Token missing expiration claim");
}
const nowSeconds = Math.floor(Date.now() / 1000);
if (payload.exp < nowSeconds + 60) {
throw new UnauthorizedException("Token expired or expiring soon");
}
// Then check token blacklist
const isBlacklisted = await this.tokenBlacklistService.isTokenBlacklisted(token);
if (isBlacklisted) {
this.logger.warn(`Blacklisted token attempted access to: ${route}`);
throw new UnauthorizedException("Token has been revoked");
}
const prismaUser = await this.usersFacade.findByIdInternal(payload.sub);
if (!prismaUser) {
throw new UnauthorizedException("User not found");
}
if (prismaUser.email !== payload.email) {
throw new UnauthorizedException("Token subject does not match user record");
}
const profile: UserAuth = mapPrismaUserToDomain(prismaUser);
(request as RequestWithRoute & { user?: UserAuth }).user = profile;
await this.attachUserFromToken(request, token, route);
this.logger.debug(`Authenticated access to: ${route}`);
return true;
@ -168,4 +149,52 @@ export class GlobalAuthGuard implements CanActivate {
const normalized = path.endsWith("/") ? path.slice(0, -1) : path;
return normalized === "/auth/logout" || normalized === "/api/auth/logout";
}
private async attachUserFromToken(
request: RequestWithRoute,
token: string,
route?: string
): Promise<void> {
const payload = await this.jwtService.verify<{ sub?: string; email?: string; exp?: number }>(
token
);
const tokenType = (payload as { type?: unknown }).type;
if (typeof tokenType === "string" && tokenType !== "access") {
throw new UnauthorizedException("Invalid access token");
}
if (!payload.sub || !payload.email) {
throw new UnauthorizedException("Invalid token payload");
}
// Explicit expiry buffer check to avoid tokens expiring mid-request
if (typeof payload.exp !== "number") {
throw new UnauthorizedException("Token missing expiration claim");
}
const nowSeconds = Math.floor(Date.now() / 1000);
if (payload.exp < nowSeconds + 60) {
throw new UnauthorizedException("Token expired or expiring soon");
}
// Then check token blacklist
const isBlacklisted = await this.tokenBlacklistService.isTokenBlacklisted(token);
if (isBlacklisted) {
if (route) {
this.logger.warn(`Blacklisted token attempted access to: ${route}`);
}
throw new UnauthorizedException("Token has been revoked");
}
const prismaUser = await this.usersFacade.findByIdInternal(payload.sub);
if (!prismaUser) {
throw new UnauthorizedException("User not found");
}
if (prismaUser.email !== payload.email) {
throw new UnauthorizedException("Token subject does not match user record");
}
const profile: UserAuth = mapPrismaUserToDomain(prismaUser);
(request as RequestWithRoute & { user?: UserAuth }).user = profile;
}
}

View File

@ -1,34 +0,0 @@
import { Module, forwardRef } from "@nestjs/common";
import { CatalogController } from "./catalog.controller.js";
import { CatalogHealthController } from "./catalog-health.controller.js";
import { IntegrationsModule } from "@bff/integrations/integrations.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { CoreConfigModule } from "@bff/core/config/config.module.js";
import { CacheModule } from "@bff/infra/cache/cache.module.js";
import { QueueModule } from "@bff/core/queue/queue.module.js";
import { BaseCatalogService } from "./services/base-catalog.service.js";
import { InternetCatalogService } from "./services/internet-catalog.service.js";
import { SimCatalogService } from "./services/sim-catalog.service.js";
import { VpnCatalogService } from "./services/vpn-catalog.service.js";
import { CatalogCacheService } from "./services/catalog-cache.service.js";
@Module({
imports: [
forwardRef(() => IntegrationsModule),
MappingsModule,
CoreConfigModule,
CacheModule,
QueueModule,
],
controllers: [CatalogController, CatalogHealthController],
providers: [
BaseCatalogService,
InternetCatalogService,
SimCatalogService,
VpnCatalogService,
CatalogCacheService,
],
exports: [InternetCatalogService, SimCatalogService, VpnCatalogService, CatalogCacheService],
})
export class CatalogModule {}

View File

@ -1,226 +0,0 @@
import { Injectable, Inject } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { BaseCatalogService } from "./base-catalog.service.js";
import { CatalogCacheService } from "./catalog-cache.service.js";
import type {
SalesforceProduct2WithPricebookEntries,
InternetPlanCatalogItem,
InternetInstallationCatalogItem,
InternetAddonCatalogItem,
} from "@customer-portal/domain/catalog";
import {
Providers as CatalogProviders,
enrichInternetPlanMetadata,
inferAddonTypeFromSku,
inferInstallationTermFromSku,
} from "@customer-portal/domain/catalog";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
import { Logger } from "nestjs-pino";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js";
import { buildAccountEligibilityQuery } from "@bff/integrations/salesforce/utils/catalog-query-builder.js";
interface SalesforceAccount {
Id: string;
Internet_Eligibility__c?: string;
}
@Injectable()
export class InternetCatalogService extends BaseCatalogService {
constructor(
sf: SalesforceConnection,
configService: ConfigService,
@Inject(Logger) logger: Logger,
private mappingsService: MappingsService,
private catalogCache: CatalogCacheService
) {
super(sf, configService, logger);
}
async getPlans(): Promise<InternetPlanCatalogItem[]> {
const cacheKey = this.catalogCache.buildCatalogKey("internet", "plans");
return this.catalogCache.getCachedCatalog(
cacheKey,
async () => {
const soql = this.buildCatalogServiceQuery("Internet", [
"Internet_Plan_Tier__c",
"Internet_Offering_Type__c",
"Catalog_Order__c",
]);
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql,
"Internet Plans"
);
return records.map(record => {
const entry = this.extractPricebookEntry(record);
const plan = CatalogProviders.Salesforce.mapInternetPlan(record, entry);
return enrichInternetPlanMetadata(plan);
});
},
{
resolveDependencies: plans => ({
productIds: plans.map(plan => plan.id).filter((id): id is string => Boolean(id)),
}),
}
);
}
async getInstallations(): Promise<InternetInstallationCatalogItem[]> {
const cacheKey = this.catalogCache.buildCatalogKey("internet", "installations");
return this.catalogCache.getCachedCatalog(
cacheKey,
async () => {
const soql = this.buildProductQuery("Internet", "Installation", [
"Billing_Cycle__c",
"Catalog_Order__c",
]);
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql,
"Internet Installations"
);
this.logger.log(`Found ${records.length} installation records`);
return records
.map(record => {
const entry = this.extractPricebookEntry(record);
const installation = CatalogProviders.Salesforce.mapInternetInstallation(record, entry);
return {
...installation,
catalogMetadata: {
...installation.catalogMetadata,
installationTerm: inferInstallationTermFromSku(installation.sku ?? ""),
},
};
})
.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
},
{
resolveDependencies: installations => ({
productIds: installations.map(item => item.id).filter((id): id is string => Boolean(id)),
}),
}
);
}
async getAddons(): Promise<InternetAddonCatalogItem[]> {
const cacheKey = this.catalogCache.buildCatalogKey("internet", "addons");
return this.catalogCache.getCachedCatalog(
cacheKey,
async () => {
const soql = this.buildProductQuery("Internet", "Add-on", [
"Billing_Cycle__c",
"Catalog_Order__c",
"Bundled_Addon__c",
"Is_Bundled_Addon__c",
]);
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql,
"Internet Add-ons"
);
this.logger.log(`Found ${records.length} addon records`);
return records
.map(record => {
const entry = this.extractPricebookEntry(record);
const addon = CatalogProviders.Salesforce.mapInternetAddon(record, entry);
return {
...addon,
catalogMetadata: {
...addon.catalogMetadata,
addonType: inferAddonTypeFromSku(addon.sku ?? ""),
},
};
})
.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
},
{
resolveDependencies: addons => ({
productIds: addons.map(addon => addon.id).filter((id): id is string => Boolean(id)),
}),
}
);
}
async getCatalogData() {
const [plans, installations, addons] = await Promise.all([
this.getPlans(),
this.getInstallations(),
this.getAddons(),
]);
return { plans, installations, addons };
}
async getPlansForUser(userId: string): Promise<InternetPlanCatalogItem[]> {
try {
// Get all plans first
const allPlans = await this.getPlans();
// Get user's Salesforce account mapping
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.sfAccountId) {
this.logger.warn(`No Salesforce mapping found for user ${userId}, returning all plans`);
return allPlans;
}
// Get customer's eligibility from Salesforce
const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId");
const eligibilityKey = this.catalogCache.buildEligibilityKey("internet", sfAccountId);
const account = await this.catalogCache.getCachedEligibility<SalesforceAccount | null>(
eligibilityKey,
async () => {
const soql = buildAccountEligibilityQuery(sfAccountId);
const accounts = await this.executeQuery(soql, "Customer Eligibility");
return accounts.length > 0 ? (accounts[0] as unknown as SalesforceAccount) : null;
}
);
if (!account) {
this.logger.warn(`No Salesforce account found for user ${userId}, returning all plans`);
return allPlans;
}
const eligibility = account.Internet_Eligibility__c;
if (!eligibility) {
this.logger.log(`No eligibility field for user ${userId}, filtering to Home 1G plans only`);
const homeGPlans = allPlans.filter(plan => plan.internetOfferingType === "Home 1G");
return homeGPlans;
}
// Filter plans based on eligibility
const eligiblePlans = allPlans.filter(plan => {
const isEligible = this.checkPlanEligibility(plan, eligibility);
if (!isEligible) {
this.logger.debug(
`Plan ${plan.name} (${plan.internetPlanTier ?? "Unknown"}) not eligible for user ${userId} with eligibility: ${eligibility}`
);
}
return isEligible;
});
this.logger.log(
`Filtered ${allPlans.length} plans to ${eligiblePlans.length} eligible plans for user ${userId} with eligibility: ${eligibility}`
);
return eligiblePlans;
} catch (error) {
this.logger.error(`Failed to get eligible plans for user ${userId}`, {
error: getErrorMessage(error),
});
// Fallback to all plans if there's an error
return this.getPlans();
}
}
private checkPlanEligibility(plan: InternetPlanCatalogItem, eligibility: string): boolean {
// Simple match: user's eligibility field must equal plan's Salesforce offering type
// e.g., eligibility "Home 1G" matches plan.internetOfferingType "Home 1G"
return plan.internetOfferingType === eligibility;
}
}

View File

@ -1,60 +0,0 @@
import { Injectable, Inject } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
import { BaseCatalogService } from "./base-catalog.service.js";
import type {
SalesforceProduct2WithPricebookEntries,
VpnCatalogProduct,
} from "@customer-portal/domain/catalog";
import { Providers as CatalogProviders } from "@customer-portal/domain/catalog";
@Injectable()
export class VpnCatalogService extends BaseCatalogService {
constructor(
sf: SalesforceConnection,
configService: ConfigService,
@Inject(Logger) logger: Logger
) {
super(sf, configService, logger);
}
async getPlans(): Promise<VpnCatalogProduct[]> {
const soql = this.buildCatalogServiceQuery("VPN", ["VPN_Region__c", "Catalog_Order__c"]);
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql,
"VPN Plans"
);
return records.map(record => {
const entry = this.extractPricebookEntry(record);
const product = CatalogProviders.Salesforce.mapVpnProduct(record, entry);
return {
...product,
description: product.description || product.name,
} satisfies VpnCatalogProduct;
});
}
async getActivationFees(): Promise<VpnCatalogProduct[]> {
const soql = this.buildProductQuery("VPN", "Activation", ["VPN_Region__c"]);
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql,
"VPN Activation Fees"
);
return records.map(record => {
const pricebookEntry = this.extractPricebookEntry(record);
const product = CatalogProviders.Salesforce.mapVpnProduct(record, pricebookEntry);
return {
...product,
description: product.description ?? product.name,
} satisfies VpnCatalogProduct;
});
}
async getCatalogData() {
const [plans, activationFees] = await Promise.all([this.getPlans(), this.getActivationFees()]);
return { plans, activationFees };
}
}

View File

@ -0,0 +1,16 @@
import { Controller, Get, Req, UseGuards } from "@nestjs/common";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
import { MeStatusService } from "./me-status.service.js";
import type { MeStatus } from "@customer-portal/domain/dashboard";
@Controller("me")
export class MeStatusController {
constructor(private readonly meStatus: MeStatusService) {}
@UseGuards(SalesforceReadThrottleGuard)
@Get("status")
async getStatus(@Req() req: RequestWithUser): Promise<MeStatus> {
return this.meStatus.getStatusForUser(req.user.id);
}
}

View File

@ -0,0 +1,27 @@
import { Module } from "@nestjs/common";
import { MeStatusController } from "./me-status.controller.js";
import { MeStatusService } from "./me-status.service.js";
import { UsersModule } from "@bff/modules/users/users.module.js";
import { OrdersModule } from "@bff/modules/orders/orders.module.js";
import { ServicesModule } from "@bff/modules/services/services.module.js";
import { VerificationModule } from "@bff/modules/verification/verification.module.js";
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js";
@Module({
imports: [
UsersModule,
OrdersModule,
ServicesModule,
VerificationModule,
WhmcsModule,
MappingsModule,
NotificationsModule,
SalesforceModule,
],
controllers: [MeStatusController],
providers: [MeStatusService],
})
export class MeStatusModule {}

View File

@ -0,0 +1,269 @@
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
import { OrderOrchestrator } from "@bff/modules/orders/services/order-orchestrator.service.js";
import { InternetServicesService } from "@bff/modules/services/services/internet-services.service.js";
import { ResidenceCardService } from "@bff/modules/verification/residence-card.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { WhmcsPaymentService } from "@bff/integrations/whmcs/services/whmcs-payment.service.js";
import { NotificationService } from "@bff/modules/notifications/notifications.service.js";
import {
meStatusSchema,
type DashboardSummary,
type DashboardTask,
type MeStatus,
type PaymentMethodsStatus,
} from "@customer-portal/domain/dashboard";
import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications";
import type { InternetEligibilityDetails } from "@customer-portal/domain/services";
import type { ResidenceCardVerification } from "@customer-portal/domain/customer";
import type { OrderSummary } from "@customer-portal/domain/orders";
@Injectable()
export class MeStatusService {
constructor(
private readonly users: UsersFacade,
private readonly orders: OrderOrchestrator,
private readonly internetCatalog: InternetServicesService,
private readonly residenceCards: ResidenceCardService,
private readonly mappings: MappingsService,
private readonly whmcsPayments: WhmcsPaymentService,
private readonly notifications: NotificationService,
@Inject(Logger) private readonly logger: Logger
) {}
async getStatusForUser(userId: string): Promise<MeStatus> {
try {
const [summary, internetEligibility, residenceCardVerification, orders] = await Promise.all([
this.users.getUserSummary(userId),
this.internetCatalog.getEligibilityDetailsForUser(userId),
this.residenceCards.getStatusForUser(userId),
this.safeGetOrders(userId),
]);
const paymentMethods = await this.safeGetPaymentMethodsStatus(userId);
const tasks = this.computeTasks({
summary,
paymentMethods,
internetEligibility,
residenceCardVerification,
orders,
});
await this.maybeCreateInvoiceDueNotification(userId, summary);
return meStatusSchema.parse({
summary,
paymentMethods,
internetEligibility,
residenceCardVerification,
tasks,
});
} catch (error) {
this.logger.error({ userId, err: error }, "Failed to get status for user");
throw error;
}
}
private async safeGetOrders(userId: string): Promise<OrderSummary[] | null> {
try {
const result = await this.orders.getOrdersForUser(userId);
return Array.isArray(result) ? result : [];
} catch (error) {
this.logger.warn(
{ userId, err: error instanceof Error ? error.message : String(error) },
"Failed to load orders for status payload"
);
return null;
}
}
private async safeGetPaymentMethodsStatus(userId: string): Promise<PaymentMethodsStatus> {
try {
const mapping = await this.mappings.findByUserId(userId);
if (!mapping?.whmcsClientId) {
return { totalCount: null };
}
const list = await this.whmcsPayments.getPaymentMethods(mapping.whmcsClientId, userId);
return { totalCount: typeof list?.totalCount === "number" ? list.totalCount : 0 };
} catch (error) {
this.logger.warn(
{ userId, err: error instanceof Error ? error.message : String(error) },
"Failed to load payment methods for status payload"
);
return { totalCount: null };
}
}
private computeTasks(params: {
summary: DashboardSummary;
paymentMethods: PaymentMethodsStatus;
internetEligibility: InternetEligibilityDetails;
residenceCardVerification: ResidenceCardVerification;
orders: OrderSummary[] | null;
}): DashboardTask[] {
const tasks: DashboardTask[] = [];
const { summary, paymentMethods, internetEligibility, residenceCardVerification, orders } =
params;
// Priority 1: next unpaid invoice
if (summary.nextInvoice) {
const dueDate = new Date(summary.nextInvoice.dueDate);
const isValid = !Number.isNaN(dueDate.getTime());
const isOverdue = isValid ? dueDate.getTime() < Date.now() : false;
const formattedAmount = new Intl.NumberFormat("ja-JP", {
style: "currency",
currency: summary.nextInvoice.currency,
maximumFractionDigits: 0,
}).format(summary.nextInvoice.amount);
const dueText = isValid
? dueDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
: "soon";
tasks.push({
id: `invoice-${summary.nextInvoice.id}`,
priority: 1,
type: "invoice",
title: isOverdue ? "Pay overdue invoice" : "Pay upcoming invoice",
description: `Invoice #${summary.nextInvoice.id} · ${formattedAmount} · Due ${dueText}`,
actionLabel: "Pay now",
detailHref: `/account/billing/invoices/${summary.nextInvoice.id}`,
requiresSsoAction: true,
tone: "critical",
metadata: {
invoiceId: summary.nextInvoice.id,
amount: summary.nextInvoice.amount,
currency: summary.nextInvoice.currency,
...(isValid ? { dueDate: dueDate.toISOString() } : {}),
},
});
}
// Priority 2: no payment method (only when we could verify)
if (paymentMethods.totalCount === 0) {
tasks.push({
id: "add-payment-method",
priority: 2,
type: "payment_method",
title: "Add a payment method",
description: "Required to place orders and process invoices",
actionLabel: "Add method",
detailHref: "/account/billing/payments",
requiresSsoAction: true,
tone: "warning",
});
}
// Priority 3: pending orders
if (orders && orders.length > 0) {
const pendingOrders = orders.filter(
o =>
o.status === "Draft" ||
o.status === "Pending" ||
(o.status === "Activated" && o.activationStatus !== "Completed")
);
if (pendingOrders.length > 0) {
const order = pendingOrders[0];
const statusText =
order.status === "Pending"
? "awaiting review"
: order.status === "Draft"
? "in draft"
: "being activated";
tasks.push({
id: `order-${order.id}`,
priority: 3,
type: "order",
title: "Order in progress",
description: `${order.orderType || "Your"} order is ${statusText}`,
actionLabel: "View details",
detailHref: `/account/orders/${order.id}`,
tone: "info",
metadata: { orderId: order.id },
});
}
}
// Priority 4: Internet eligibility review (only when explicitly pending)
if (internetEligibility.status === "pending") {
tasks.push({
id: "internet-eligibility-review",
priority: 4,
type: "internet_eligibility",
title: "Internet availability review",
description:
"Were verifying if our service is available at your residence. Well notify you when review is complete.",
actionLabel: "View status",
detailHref: "/account/services/internet",
tone: "info",
});
}
// Priority 4: ID verification rejected
if (residenceCardVerification.status === "rejected") {
tasks.push({
id: "id-verification-rejected",
priority: 4,
type: "id_verification",
title: "ID verification requires attention",
description: "We couldnt verify your ID. Please review the feedback and resubmit.",
actionLabel: "Resubmit",
detailHref: "/account/settings/verification",
tone: "warning",
});
}
// Priority 4: onboarding (only when no other tasks)
if (summary.stats.activeSubscriptions === 0 && tasks.length === 0) {
tasks.push({
id: "start-subscription",
priority: 4,
type: "onboarding",
title: "Start your first service",
description: "Browse our catalog and subscribe to internet, SIM, or VPN",
actionLabel: "Browse services",
detailHref: "/services",
tone: "neutral",
});
}
return tasks.sort((a, b) => a.priority - b.priority);
}
private async maybeCreateInvoiceDueNotification(
userId: string,
summary: DashboardSummary
): Promise<void> {
const invoice = summary.nextInvoice;
if (!invoice) return;
try {
const dueDate = new Date(invoice.dueDate);
if (Number.isNaN(dueDate.getTime())) return;
const daysUntilDue = (dueDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24);
// Notify when due within a week (or overdue).
if (daysUntilDue > 7) return;
await this.notifications.createNotification({
userId,
type: NOTIFICATION_TYPE.INVOICE_DUE,
source: NOTIFICATION_SOURCE.SYSTEM,
sourceId: `invoice:${invoice.id}`,
actionUrl: `/account/billing/invoices/${invoice.id}`,
});
} catch (error) {
this.logger.warn(
{ userId, err: error instanceof Error ? error.message : String(error) },
"Failed to create invoice due notification"
);
}
}
}

View File

@ -0,0 +1,167 @@
/**
* Account Notification Handler
*
* Processes Salesforce Account events and creates in-app notifications
* when eligibility or verification status changes.
*
* This is called by the ServicesCdcSubscriber when account
* events are received. Works alongside Salesforce's email notifications,
* providing both push (email) and pull (in-app) notification channels.
*/
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { NotificationService } from "./notifications.service.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { NOTIFICATION_TYPE, NOTIFICATION_SOURCE } from "@customer-portal/domain/notifications";
export interface AccountEventPayload {
accountId: string;
eligibilityStatus?: string | null;
eligibilityValue?: string | null;
verificationStatus?: string | null;
verificationRejectionMessage?: string | null;
}
@Injectable()
export class AccountNotificationHandler {
constructor(
private readonly mappingsService: MappingsService,
private readonly notificationService: NotificationService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Process an account event and create notifications if needed
*/
async processAccountEvent(payload: AccountEventPayload): Promise<void> {
try {
const {
accountId,
eligibilityStatus,
eligibilityValue,
verificationStatus,
verificationRejectionMessage,
} = payload;
// Find the portal user for this account
const mapping = await this.mappingsService.findBySfAccountId(accountId);
if (!mapping?.userId) {
this.logger.debug("No portal user for account, skipping notification", {
accountIdTail: accountId.slice(-4),
});
return;
}
// Process eligibility status change
if (eligibilityStatus) {
await this.processEligibilityChange(
mapping.userId,
accountId,
eligibilityStatus,
eligibilityValue ?? undefined
);
}
// Process verification status change
if (verificationStatus) {
await this.processVerificationChange(
mapping.userId,
accountId,
verificationStatus,
verificationRejectionMessage ?? undefined
);
}
} catch (error) {
this.logger.error("Error processing account event for notifications", {
error: getErrorMessage(error),
accountIdTail: payload.accountId.slice(-4),
});
}
}
/**
* Process eligibility status change
*/
private async processEligibilityChange(
userId: string,
accountId: string,
status: string,
eligibilityValue?: string
): Promise<void> {
const normalizedStatus = status.trim().toLowerCase();
// Only notify on final states, not "pending"
if (normalizedStatus === "pending" || normalizedStatus === "checking") {
return;
}
const isEligible = normalizedStatus === "eligible" || Boolean(eligibilityValue);
const notificationType = isEligible
? NOTIFICATION_TYPE.ELIGIBILITY_ELIGIBLE
: NOTIFICATION_TYPE.ELIGIBILITY_INELIGIBLE;
// Create customized message if we have the eligibility value
let message: string | undefined;
if (isEligible && eligibilityValue) {
message = `We've confirmed ${eligibilityValue} service is available at your address. You can now select a plan and complete your order.`;
}
await this.notificationService.createNotification({
userId,
type: notificationType,
message,
source: NOTIFICATION_SOURCE.SALESFORCE,
sourceId: accountId,
});
this.logger.log("Eligibility notification created", {
userId,
type: notificationType,
accountIdTail: accountId.slice(-4),
});
}
/**
* Process ID verification status change
*/
private async processVerificationChange(
userId: string,
accountId: string,
status: string,
rejectionMessage?: string
): Promise<void> {
const normalizedStatus = status.trim().toLowerCase();
// Only notify on final states
if (normalizedStatus !== "verified" && normalizedStatus !== "rejected") {
return;
}
const isVerified = normalizedStatus === "verified";
const notificationType = isVerified
? NOTIFICATION_TYPE.VERIFICATION_VERIFIED
: NOTIFICATION_TYPE.VERIFICATION_REJECTED;
// Include rejection reason in message
let message: string | undefined;
if (!isVerified && rejectionMessage) {
message = `We couldn't verify your ID: ${rejectionMessage}. Please resubmit a clearer image.`;
}
await this.notificationService.createNotification({
userId,
type: notificationType,
message,
source: NOTIFICATION_SOURCE.SALESFORCE,
sourceId: accountId,
});
this.logger.log("Verification notification created", {
userId,
type: notificationType,
accountIdTail: accountId.slice(-4),
});
}
}

View File

@ -0,0 +1,39 @@
/**
* Notification Cleanup Service
*
* Scheduled job to remove expired notifications from the database.
* Runs daily to clean up notifications older than 30 days.
*/
import { Injectable, Inject } from "@nestjs/common";
import { Cron, CronExpression } from "@nestjs/schedule";
import { Logger } from "nestjs-pino";
import { NotificationService } from "./notifications.service.js";
@Injectable()
export class NotificationCleanupService {
constructor(
private readonly notificationService: NotificationService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Clean up expired notifications daily at 3 AM
*/
@Cron(CronExpression.EVERY_DAY_AT_3AM)
async handleCleanup(): Promise<void> {
this.logger.debug("Starting notification cleanup job");
try {
const count = await this.notificationService.cleanupExpired();
if (count > 0) {
this.logger.log("Notification cleanup completed", { deletedCount: count });
}
} catch (error) {
this.logger.error("Notification cleanup job failed", {
error: error instanceof Error ? error.message : String(error),
});
}
}
}

View File

@ -0,0 +1,93 @@
/**
* Notifications Controller
*
* API endpoints for managing in-app notifications.
*/
import {
Controller,
Get,
Post,
Param,
Query,
Req,
UseGuards,
ParseIntPipe,
DefaultValuePipe,
ParseBoolPipe,
} from "@nestjs/common";
import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { NotificationService } from "./notifications.service.js";
import type { NotificationListResponse } from "@customer-portal/domain/notifications";
@Controller("notifications")
@UseGuards(RateLimitGuard)
export class NotificationsController {
constructor(private readonly notificationService: NotificationService) {}
/**
* Get notifications for the current user
*/
@Get()
@RateLimit({ limit: 60, ttl: 60 })
async getNotifications(
@Req() req: RequestWithUser,
@Query("limit", new DefaultValuePipe(20), ParseIntPipe) limit: number,
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
@Query("includeRead", new DefaultValuePipe(true), ParseBoolPipe)
includeRead: boolean
): Promise<NotificationListResponse> {
return this.notificationService.getNotifications(req.user.id, {
limit: Math.min(limit, 50), // Cap at 50
offset,
includeRead,
});
}
/**
* Get unread notification count for the current user
*/
@Get("unread-count")
@RateLimit({ limit: 120, ttl: 60 })
async getUnreadCount(@Req() req: RequestWithUser): Promise<{ count: number }> {
const count = await this.notificationService.getUnreadCount(req.user.id);
return { count };
}
/**
* Mark a specific notification as read
*/
@Post(":id/read")
@RateLimit({ limit: 60, ttl: 60 })
async markAsRead(
@Req() req: RequestWithUser,
@Param("id") notificationId: string
): Promise<{ success: boolean }> {
await this.notificationService.markAsRead(notificationId, req.user.id);
return { success: true };
}
/**
* Mark all notifications as read
*/
@Post("read-all")
@RateLimit({ limit: 10, ttl: 60 })
async markAllAsRead(@Req() req: RequestWithUser): Promise<{ success: boolean }> {
await this.notificationService.markAllAsRead(req.user.id);
return { success: true };
}
/**
* Dismiss a notification (hide from UI)
*/
@Post(":id/dismiss")
@RateLimit({ limit: 60, ttl: 60 })
async dismiss(
@Req() req: RequestWithUser,
@Param("id") notificationId: string
): Promise<{ success: boolean }> {
await this.notificationService.dismiss(notificationId, req.user.id);
return { success: true };
}
}

View File

@ -0,0 +1,24 @@
/**
* Notifications Module
*
* Provides in-app notification functionality:
* - NotificationService: CRUD operations for notifications
* - NotificationsController: API endpoints
* - AccountNotificationHandler: Creates notifications from SF events
* - NotificationCleanupService: Removes expired notifications
*/
import { Module, forwardRef } from "@nestjs/common";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { NotificationService } from "./notifications.service.js";
import { NotificationsController } from "./notifications.controller.js";
import { AccountNotificationHandler } from "./account-cdc-listener.service.js";
import { NotificationCleanupService } from "./notification-cleanup.service.js";
@Module({
imports: [forwardRef(() => MappingsModule)],
controllers: [NotificationsController],
providers: [NotificationService, AccountNotificationHandler, NotificationCleanupService],
exports: [NotificationService, AccountNotificationHandler],
})
export class NotificationsModule {}

View File

@ -0,0 +1,336 @@
/**
* Notification Service
*
* Manages in-app notifications stored in the portal database.
* Notifications are created in response to Salesforce CDC events
* and displayed alongside email notifications.
*/
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { PrismaService } from "@bff/infra/database/prisma.service.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
import {
NOTIFICATION_SOURCE,
NOTIFICATION_TEMPLATES,
type NotificationTypeValue,
type NotificationSourceValue,
type Notification,
type NotificationListResponse,
} from "@customer-portal/domain/notifications";
// Notification expiry in days
const NOTIFICATION_EXPIRY_DAYS = 30;
// Dedupe window (in hours) per notification type.
// Defaults to 1 hour when not specified.
const NOTIFICATION_DEDUPE_WINDOW_HOURS: Partial<Record<NotificationTypeValue, number>> = {
// These are often evaluated opportunistically (e.g., on dashboard load),
// so keep the dedupe window larger to avoid spam.
INVOICE_DUE: 24,
PAYMENT_METHOD_EXPIRING: 24,
SYSTEM_ANNOUNCEMENT: 24,
};
export interface CreateNotificationParams {
userId: string;
type: NotificationTypeValue;
title?: string;
message?: string;
actionUrl?: string;
actionLabel?: string;
source?: NotificationSourceValue;
sourceId?: string;
}
@Injectable()
export class NotificationService {
constructor(
private readonly prisma: PrismaService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Create a notification for a user
*/
async createNotification(params: CreateNotificationParams): Promise<Notification> {
const template = NOTIFICATION_TEMPLATES[params.type];
if (!template) {
throw new Error(`Unknown notification type: ${params.type}`);
}
// Calculate expiry date (30 days from now)
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + NOTIFICATION_EXPIRY_DAYS);
try {
// Check for duplicate notification (same type + sourceId within a short window)
if (params.sourceId) {
const dedupeHours = NOTIFICATION_DEDUPE_WINDOW_HOURS[params.type] ?? 1;
const since = new Date(Date.now() - dedupeHours * 60 * 60 * 1000);
const existingNotification = await this.prisma.notification.findFirst({
where: {
userId: params.userId,
type: params.type,
sourceId: params.sourceId,
createdAt: { gte: since },
},
});
if (existingNotification) {
this.logger.debug("Duplicate notification detected, skipping", {
userId: params.userId,
type: params.type,
sourceId: params.sourceId,
});
return this.mapToNotification(existingNotification);
}
}
const notification = await this.prisma.notification.create({
data: {
userId: params.userId,
type: params.type,
title: params.title ?? template.title,
message: params.message ?? template.message,
actionUrl: params.actionUrl ?? template.actionUrl ?? null,
actionLabel: params.actionLabel ?? template.actionLabel ?? null,
source: params.source ?? NOTIFICATION_SOURCE.SALESFORCE,
sourceId: params.sourceId ?? null,
expiresAt,
},
});
this.logger.log("Notification created", {
notificationId: notification.id,
userId: params.userId,
type: params.type,
});
return this.mapToNotification(notification);
} catch (error) {
this.logger.error("Failed to create notification", {
error: getErrorMessage(error),
userId: params.userId,
type: params.type,
});
throw new Error("Failed to create notification");
}
}
/**
* Get notifications for a user
*/
async getNotifications(
userId: string,
options?: {
limit?: number;
offset?: number;
includeRead?: boolean;
includeDismissed?: boolean;
}
): Promise<NotificationListResponse> {
const limit = options?.limit ?? 20;
const offset = options?.offset ?? 0;
const now = new Date();
const where = {
userId,
expiresAt: { gt: now },
...(options?.includeDismissed ? {} : { dismissed: false }),
// By default we include read notifications. If includeRead=false, filter them out.
...(options?.includeRead === false ? { read: false } : {}),
};
try {
const [notifications, total, unreadCount] = await Promise.all([
this.prisma.notification.findMany({
where,
orderBy: { createdAt: "desc" },
take: limit,
skip: offset,
}),
this.prisma.notification.count({ where }),
this.prisma.notification.count({
where: {
userId,
read: false,
dismissed: false,
expiresAt: { gt: now },
},
}),
]);
return {
notifications: notifications.map(n => this.mapToNotification(n)),
unreadCount,
total,
};
} catch (error) {
this.logger.error("Failed to get notifications", {
error: getErrorMessage(error),
userId,
});
throw new Error("Failed to get notifications");
}
}
/**
* Get unread notification count for a user
*/
async getUnreadCount(userId: string): Promise<number> {
const now = new Date();
try {
return await this.prisma.notification.count({
where: {
userId,
read: false,
dismissed: false,
expiresAt: { gt: now },
},
});
} catch (error) {
this.logger.error("Failed to get unread count", {
error: getErrorMessage(error),
userId,
});
return 0;
}
}
/**
* Mark a notification as read
*/
async markAsRead(notificationId: string, userId: string): Promise<void> {
try {
await this.prisma.notification.updateMany({
where: { id: notificationId, userId },
data: { read: true, readAt: new Date() },
});
this.logger.debug("Notification marked as read", {
notificationId,
userId,
});
} catch (error) {
this.logger.error("Failed to mark notification as read", {
error: getErrorMessage(error),
notificationId,
userId,
});
throw new Error("Failed to update notification");
}
}
/**
* Mark all notifications as read for a user
*/
async markAllAsRead(userId: string): Promise<void> {
try {
const result = await this.prisma.notification.updateMany({
where: { userId, read: false },
data: { read: true, readAt: new Date() },
});
this.logger.debug("All notifications marked as read", {
userId,
count: result.count,
});
} catch (error) {
this.logger.error("Failed to mark all notifications as read", {
error: getErrorMessage(error),
userId,
});
throw new Error("Failed to update notifications");
}
}
/**
* Dismiss a notification (hide from UI)
*/
async dismiss(notificationId: string, userId: string): Promise<void> {
try {
await this.prisma.notification.updateMany({
where: { id: notificationId, userId },
data: { dismissed: true, read: true, readAt: new Date() },
});
this.logger.debug("Notification dismissed", {
notificationId,
userId,
});
} catch (error) {
this.logger.error("Failed to dismiss notification", {
error: getErrorMessage(error),
notificationId,
userId,
});
throw new Error("Failed to dismiss notification");
}
}
/**
* Clean up expired notifications (called by scheduled job)
*/
async cleanupExpired(): Promise<number> {
try {
const result = await this.prisma.notification.deleteMany({
where: {
expiresAt: { lt: new Date() },
},
});
if (result.count > 0) {
this.logger.log("Cleaned up expired notifications", {
count: result.count,
});
}
return result.count;
} catch (error) {
this.logger.error("Failed to cleanup expired notifications", {
error: getErrorMessage(error),
});
return 0;
}
}
/**
* Map Prisma model to domain type
*/
private mapToNotification(record: {
id: string;
userId: string;
type: string;
title: string;
message: string | null;
actionUrl: string | null;
actionLabel: string | null;
source: string;
sourceId: string | null;
read: boolean;
readAt: Date | null;
dismissed: boolean;
createdAt: Date;
expiresAt: Date;
}): Notification {
return {
id: record.id,
userId: record.userId,
type: record.type as NotificationTypeValue,
title: record.title,
message: record.message,
actionUrl: record.actionUrl,
actionLabel: record.actionLabel,
source: record.source as NotificationSourceValue,
sourceId: record.sourceId,
read: record.read,
readAt: record.readAt?.toISOString() ?? null,
dismissed: record.dismissed,
createdAt: record.createdAt.toISOString(),
expiresAt: record.expiresAt.toISOString(),
};
}
}

View File

@ -42,6 +42,7 @@ export class OrderFieldMapService {
"CreatedDate",
"LastModifiedDate",
"Pricebook2Id",
"OpportunityId", // Linked Opportunity for lifecycle tracking
order.activationType,
order.activationStatus,
order.activationScheduledAt,

View File

@ -1,11 +1,25 @@
import { Body, Controller, Post, Request, UsePipes, Inject, UseGuards } from "@nestjs/common";
import {
Body,
Controller,
Get,
Param,
Post,
Request,
UseGuards,
UsePipes,
Inject,
} from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { ZodValidationPipe } from "nestjs-zod";
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
import { CheckoutService } from "../services/checkout.service.js";
import { CheckoutSessionService } from "../services/checkout-session.service.js";
import {
checkoutItemSchema,
checkoutCartSchema,
checkoutBuildCartRequestSchema,
checkoutBuildCartResponseSchema,
checkoutTotalsSchema,
} from "@customer-portal/domain/orders";
import type { CheckoutCart, CheckoutBuildCartRequest } from "@customer-portal/domain/orders";
import { apiSuccessResponseSchema } from "@customer-portal/domain/common";
@ -14,11 +28,28 @@ import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
const validateCartResponseSchema = apiSuccessResponseSchema(z.object({ valid: z.boolean() }));
const checkoutSessionIdParamSchema = z.object({ sessionId: z.string().uuid() });
const checkoutCartSummarySchema = z.object({
items: z.array(checkoutItemSchema),
totals: checkoutTotalsSchema,
});
const checkoutSessionResponseSchema = apiSuccessResponseSchema(
z.object({
sessionId: z.string().uuid(),
expiresAt: z.string(),
orderType: z.enum(["Internet", "SIM", "VPN", "Other"]),
cart: checkoutCartSummarySchema,
})
);
@Controller("checkout")
@Public() // Cart building and validation can be done without authentication
export class CheckoutController {
constructor(
private readonly checkoutService: CheckoutService,
private readonly checkoutSessions: CheckoutSessionService,
@Inject(Logger) private readonly logger: Logger
) {}
@ -53,6 +84,61 @@ export class CheckoutController {
}
}
/**
* Create a short-lived checkout session to avoid trusting client-side state.
* This returns a cart summary (items + totals) and stores the full request+cart server-side.
*/
@Post("session")
@UseGuards(SalesforceReadThrottleGuard)
@UsePipes(new ZodValidationPipe(checkoutBuildCartRequestSchema))
async createSession(@Request() req: RequestWithUser, @Body() body: CheckoutBuildCartRequest) {
this.logger.log("Creating checkout session", {
userId: req.user?.id,
orderType: body.orderType,
});
const cart = await this.checkoutService.buildCart(
body.orderType,
body.selections,
body.configuration,
req.user?.id
);
const session = await this.checkoutSessions.createSession(body, cart);
return checkoutSessionResponseSchema.parse({
success: true,
data: {
sessionId: session.sessionId,
expiresAt: session.expiresAt,
orderType: body.orderType,
cart: {
items: cart.items,
totals: cart.totals,
},
},
});
}
@Get("session/:sessionId")
@UseGuards(SalesforceReadThrottleGuard)
@UsePipes(new ZodValidationPipe(checkoutSessionIdParamSchema))
async getSession(@Param() params: { sessionId: string }) {
const session = await this.checkoutSessions.getSession(params.sessionId);
return checkoutSessionResponseSchema.parse({
success: true,
data: {
sessionId: params.sessionId,
expiresAt: session.expiresAt,
orderType: session.request.orderType,
cart: {
items: session.cart.items,
totals: session.cart.totals,
},
},
});
}
@Post("validate")
@UsePipes(new ZodValidationPipe(checkoutCartSchema))
validateCart(@Body() cart: CheckoutCart) {

View File

@ -29,12 +29,21 @@ import { Observable } from "rxjs";
import { OrderEventsService } from "./services/order-events.service.js";
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
import { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-write-throttle.guard.js";
import { CheckoutService } from "./services/checkout.service.js";
import { CheckoutSessionService } from "./services/checkout-session.service.js";
import { z } from "zod";
const checkoutSessionCreateOrderSchema = z.object({
checkoutSessionId: z.string().uuid(),
});
@Controller("orders")
@UseGuards(RateLimitGuard)
export class OrdersController {
constructor(
private orderOrchestrator: OrderOrchestrator,
private readonly checkoutService: CheckoutService,
private readonly checkoutSessions: CheckoutSessionService,
private readonly orderEvents: OrderEventsService,
private readonly logger: Logger
) {}
@ -71,6 +80,58 @@ export class OrdersController {
}
}
@Post("from-checkout-session")
@UseGuards(SalesforceWriteThrottleGuard)
@RateLimit({ limit: 5, ttl: 60 }) // 5 order creations per minute
@UsePipes(new ZodValidationPipe(checkoutSessionCreateOrderSchema))
async createFromCheckoutSession(
@Request() req: RequestWithUser,
@Body() body: { checkoutSessionId: string }
) {
this.logger.log(
{
userId: req.user?.id,
checkoutSessionId: body.checkoutSessionId,
},
"Order creation from checkout session request received"
);
const session = await this.checkoutSessions.getSession(body.checkoutSessionId);
const cart = await this.checkoutService.buildCart(
session.request.orderType,
session.request.selections,
session.request.configuration,
req.user?.id
);
const uniqueSkus = Array.from(
new Set(
cart.items
.map(item => item.sku)
.filter((sku): sku is string => typeof sku === "string" && sku.trim().length > 0)
)
);
if (uniqueSkus.length === 0) {
throw new NotFoundException("Checkout session contains no items");
}
const orderBody: CreateOrderRequest = {
orderType: session.request.orderType,
skus: uniqueSkus,
...(Object.keys(cart.configuration ?? {}).length > 0
? { configurations: cart.configuration }
: {}),
};
const result = await this.orderOrchestrator.createOrder(req.user.id, orderBody);
await this.checkoutSessions.deleteSession(body.checkoutSessionId);
return this.createOrderResponseSchema.parse({ success: true, data: result });
}
@Get("user")
@UseGuards(SalesforceReadThrottleGuard)
async getUserOrders(@Request() req: RequestWithUser) {

View File

@ -6,8 +6,10 @@ import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { UsersModule } from "@bff/modules/users/users.module.js";
import { CoreConfigModule } from "@bff/core/config/config.module.js";
import { DatabaseModule } from "@bff/core/database/database.module.js";
import { CatalogModule } from "@bff/modules/catalog/catalog.module.js";
import { ServicesModule } from "@bff/modules/services/services.module.js";
import { CacheModule } from "@bff/infra/cache/cache.module.js";
import { VerificationModule } from "@bff/modules/verification/verification.module.js";
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
// Clean modular order services
import { OrderValidator } from "./services/order-validator.service.js";
@ -17,6 +19,7 @@ import { OrderPricebookService } from "./services/order-pricebook.service.js";
import { OrderOrchestrator } from "./services/order-orchestrator.service.js";
import { PaymentValidatorService } from "./services/payment-validator.service.js";
import { CheckoutService } from "./services/checkout.service.js";
import { CheckoutSessionService } from "./services/checkout-session.service.js";
import { OrderEventsService } from "./services/order-events.service.js";
import { OrdersCacheService } from "./services/orders-cache.service.js";
@ -36,8 +39,10 @@ import { OrderFieldConfigModule } from "./config/order-field-config.module.js";
UsersModule,
CoreConfigModule,
DatabaseModule,
CatalogModule,
ServicesModule,
CacheModule,
VerificationModule,
NotificationsModule,
OrderFieldConfigModule,
],
controllers: [OrdersController, CheckoutController],
@ -54,6 +59,7 @@ import { OrderFieldConfigModule } from "./config/order-field-config.module.js";
OrderOrchestrator,
OrdersCacheService,
CheckoutService,
CheckoutSessionService,
// Order fulfillment services (modular)
OrderFulfillmentValidator,

View File

@ -0,0 +1,64 @@
import { Inject, Injectable, NotFoundException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { randomUUID } from "crypto";
import { CacheService } from "@bff/infra/cache/cache.service.js";
import type { CheckoutBuildCartRequest, CheckoutCart } from "@customer-portal/domain/orders";
type CheckoutSessionRecord = {
request: CheckoutBuildCartRequest;
cart: CheckoutCart;
createdAt: string;
expiresAt: string;
};
@Injectable()
export class CheckoutSessionService {
private readonly ttlSeconds = 2 * 60 * 60; // 2 hours
private readonly keyPrefix = "checkout-session";
constructor(
private readonly cache: CacheService,
@Inject(Logger) private readonly logger: Logger
) {}
async createSession(request: CheckoutBuildCartRequest, cart: CheckoutCart) {
const sessionId = randomUUID();
const createdAt = new Date();
const expiresAt = new Date(createdAt.getTime() + this.ttlSeconds * 1000);
const record: CheckoutSessionRecord = {
request,
cart,
createdAt: createdAt.toISOString(),
expiresAt: expiresAt.toISOString(),
};
const key = this.buildKey(sessionId);
await this.cache.set(key, record, this.ttlSeconds);
this.logger.debug("Checkout session created", { sessionId, expiresAt: record.expiresAt });
return {
sessionId,
expiresAt: record.expiresAt,
};
}
async getSession(sessionId: string): Promise<CheckoutSessionRecord> {
const key = this.buildKey(sessionId);
const record = await this.cache.get<CheckoutSessionRecord>(key);
if (!record) {
throw new NotFoundException("Checkout session not found");
}
return record;
}
async deleteSession(sessionId: string): Promise<void> {
const key = this.buildKey(sessionId);
await this.cache.del(key);
}
private buildKey(sessionId: string): string {
return `${this.keyPrefix}:${sessionId}`;
}
}

View File

@ -20,19 +20,19 @@ import type {
SimCatalogProduct,
SimActivationFeeCatalogItem,
VpnCatalogProduct,
} from "@customer-portal/domain/catalog";
import { InternetCatalogService } from "@bff/modules/catalog/services/internet-catalog.service.js";
import { SimCatalogService } from "@bff/modules/catalog/services/sim-catalog.service.js";
import { VpnCatalogService } from "@bff/modules/catalog/services/vpn-catalog.service.js";
} from "@customer-portal/domain/services";
import { InternetServicesService } from "@bff/modules/services/services/internet-services.service.js";
import { SimServicesService } from "@bff/modules/services/services/sim-services.service.js";
import { VpnServicesService } from "@bff/modules/services/services/vpn-services.service.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
@Injectable()
export class CheckoutService {
constructor(
@Inject(Logger) private readonly logger: Logger,
private readonly internetCatalogService: InternetCatalogService,
private readonly simCatalogService: SimCatalogService,
private readonly vpnCatalogService: VpnCatalogService
private readonly internetCatalogService: InternetServicesService,
private readonly simCatalogService: SimServicesService,
private readonly vpnCatalogService: VpnServicesService
) {}
/**
@ -155,6 +155,14 @@ export class CheckoutService {
userId?: string
): Promise<{ items: CheckoutItem[] }> {
const items: CheckoutItem[] = [];
if (userId) {
const eligibility = await this.internetCatalogService.getEligibilityForUser(userId);
if (typeof eligibility !== "string" || eligibility.trim().length === 0) {
throw new BadRequestException(
"Internet availability check required before ordering. Please request an availability check and try again once confirmed."
);
}
}
const plans: InternetPlanCatalogItem[] = userId
? await this.internetCatalogService.getPlansForUser(userId)
: await this.internetCatalogService.getPlans();
@ -229,9 +237,11 @@ export class CheckoutService {
userId?: string
): Promise<{ items: CheckoutItem[] }> {
const items: CheckoutItem[] = [];
const plans: SimCatalogProduct[] = userId
? await this.simCatalogService.getPlansForUser(userId)
: await this.simCatalogService.getPlans();
if (!userId) {
throw new BadRequestException("Please sign in to order SIM service.");
}
const plans: SimCatalogProduct[] = await this.simCatalogService.getPlansForUser(userId);
const rawActivationFees: SimActivationFeeCatalogItem[] =
await this.simCatalogService.getActivationFees();
const activationFees = this.filterActivationFeesWithSku(rawActivationFees);

View File

@ -1,6 +1,7 @@
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
import { WhmcsOrderService } from "@bff/integrations/whmcs/services/whmcs-order.service.js";
import type { WhmcsOrderResult } from "@bff/integrations/whmcs/services/whmcs-order.service.js";
import { OrderOrchestrator } from "./order-orchestrator.service.js";
@ -11,11 +12,16 @@ import { DistributedTransactionService } from "@bff/core/database/services/distr
import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { OrderEventsService } from "./order-events.service.js";
import { OrdersCacheService } from "./orders-cache.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { NotificationService } from "@bff/modules/notifications/notifications.service.js";
import {
type OrderDetails,
type OrderFulfillmentValidationResult,
Providers as OrderProviders,
} from "@customer-portal/domain/orders";
import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity";
import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications";
import { salesforceAccountIdSchema } from "@customer-portal/domain/common";
import {
OrderValidationException,
FulfillmentException,
@ -51,6 +57,7 @@ export class OrderFulfillmentOrchestrator {
constructor(
@Inject(Logger) private readonly logger: Logger,
private readonly salesforceService: SalesforceService,
private readonly opportunityService: SalesforceOpportunityService,
private readonly whmcsOrderService: WhmcsOrderService,
private readonly orderOrchestrator: OrderOrchestrator,
private readonly orderFulfillmentValidator: OrderFulfillmentValidator,
@ -58,7 +65,9 @@ export class OrderFulfillmentOrchestrator {
private readonly simFulfillmentService: SimFulfillmentService,
private readonly distributedTransactionService: DistributedTransactionService,
private readonly orderEvents: OrderEventsService,
private readonly ordersCache: OrdersCacheService
private readonly ordersCache: OrdersCacheService,
private readonly mappingsService: MappingsService,
private readonly notifications: NotificationService
) {}
/**
@ -171,6 +180,12 @@ export class OrderFulfillmentOrchestrator {
source: "fulfillment",
timestamp: new Date().toISOString(),
});
await this.safeNotifyOrder({
type: NOTIFICATION_TYPE.ORDER_APPROVED,
sfOrderId,
accountId: context.validation?.sfOrder?.AccountId,
actionUrl: `/account/orders/${sfOrderId}`,
});
return result;
}),
rollback: async () => {
@ -232,12 +247,16 @@ export class OrderFulfillmentOrchestrator {
`Provisioned from Salesforce Order ${sfOrderId}`
);
// Get OpportunityId from order details for WHMCS lifecycle linking
const sfOpportunityId = context.orderDetails?.opportunityId;
const result = await this.whmcsOrderService.addOrder({
clientId: context.validation.clientId,
items: mappingResult.whmcsItems,
paymentMethod: "stripe",
promoCode: "1st Month Free (Monthly Plan)",
sfOrderId,
sfOpportunityId, // Pass to WHMCS for bidirectional linking
notes: orderNotes,
noinvoiceemail: true,
noemail: true,
@ -336,6 +355,12 @@ export class OrderFulfillmentOrchestrator {
whmcsServiceIds: whmcsCreateResult?.serviceIds,
},
});
await this.safeNotifyOrder({
type: NOTIFICATION_TYPE.ORDER_ACTIVATED,
sfOrderId,
accountId: context.validation?.sfOrder?.AccountId,
actionUrl: "/account/services",
});
return result;
}),
rollback: async () => {
@ -346,6 +371,54 @@ export class OrderFulfillmentOrchestrator {
},
critical: true,
},
{
id: "opportunity_update",
description: "Update Opportunity with WHMCS Service ID and Active stage",
execute: this.createTrackedStep(context, "opportunity_update", async () => {
const opportunityId = context.orderDetails?.opportunityId;
const serviceId = whmcsCreateResult?.serviceIds?.[0];
if (!opportunityId) {
this.logger.debug("No Opportunity linked to order, skipping update", {
sfOrderId,
});
return { skipped: true as const };
}
try {
// Update Opportunity stage to Active and set WHMCS Service ID
await this.opportunityService.updateStage(
opportunityId,
OPPORTUNITY_STAGE.ACTIVE,
"Service activated via fulfillment"
);
if (serviceId) {
await this.opportunityService.linkWhmcsServiceToOpportunity(
opportunityId,
serviceId
);
}
this.logger.log("Opportunity updated with Active stage and WHMCS link", {
opportunityIdTail: opportunityId.slice(-4),
whmcsServiceId: serviceId,
sfOrderId,
});
return { opportunityId, whmcsServiceId: serviceId };
} catch (error) {
// Log but don't fail - Opportunity update is non-critical
this.logger.warn("Failed to update Opportunity after fulfillment", {
error: getErrorMessage(error),
opportunityId,
sfOrderId,
});
return { failed: true as const, error: getErrorMessage(error) };
}
}),
critical: false, // Opportunity update failure shouldn't rollback fulfillment
},
],
{
description: `Order fulfillment for ${sfOrderId}`,
@ -387,6 +460,12 @@ export class OrderFulfillmentOrchestrator {
} catch (error) {
await this.invalidateOrderCaches(sfOrderId, context.validation?.sfOrder?.AccountId);
await this.handleFulfillmentError(context, error as Error);
await this.safeNotifyOrder({
type: NOTIFICATION_TYPE.ORDER_FAILED,
sfOrderId,
accountId: context.validation?.sfOrder?.AccountId,
actionUrl: `/account/orders/${sfOrderId}`,
});
this.orderEvents.publish(sfOrderId, {
orderId: sfOrderId,
status: "Pending Review",
@ -446,6 +525,38 @@ export class OrderFulfillmentOrchestrator {
}
}
private async safeNotifyOrder(params: {
type: (typeof NOTIFICATION_TYPE)[keyof typeof NOTIFICATION_TYPE];
sfOrderId: string;
accountId?: unknown;
actionUrl: string;
}): Promise<void> {
try {
const sfAccountId = salesforceAccountIdSchema.safeParse(params.accountId);
if (!sfAccountId.success) return;
const mapping = await this.mappingsService.findBySfAccountId(sfAccountId.data);
if (!mapping?.userId) return;
await this.notifications.createNotification({
userId: mapping.userId,
type: params.type,
source: NOTIFICATION_SOURCE.SYSTEM,
sourceId: params.sfOrderId,
actionUrl: params.actionUrl,
});
} catch (error) {
this.logger.warn(
{
sfOrderId: params.sfOrderId,
type: params.type,
err: error instanceof Error ? error.message : String(error),
},
"Failed to create in-app order notification"
);
}
}
/**
* Handle fulfillment errors and update Salesforce
*/

View File

@ -1,13 +1,16 @@
import { Injectable, Inject, NotFoundException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { SalesforceOrderService } from "@bff/integrations/salesforce/services/salesforce-order.service.js";
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
import { OpportunityResolutionService } from "@bff/integrations/salesforce/services/opportunity-resolution.service.js";
import { OrderValidator } from "./order-validator.service.js";
import { OrderBuilder } from "./order-builder.service.js";
import { OrderItemBuilder } from "./order-item-builder.service.js";
import type { OrderItemCompositePayload } from "./order-item-builder.service.js";
import { OrdersCacheService } from "./orders-cache.service.js";
import type { OrderDetails, OrderSummary } from "@customer-portal/domain/orders";
import type { OrderDetails, OrderSummary, OrderTypeValue } from "@customer-portal/domain/orders";
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js";
import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity";
type OrderDetailsResponse = OrderDetails;
type OrderSummaryResponse = OrderSummary;
@ -21,6 +24,8 @@ export class OrderOrchestrator {
constructor(
@Inject(Logger) private readonly logger: Logger,
private readonly salesforceOrderService: SalesforceOrderService,
private readonly opportunityService: SalesforceOpportunityService,
private readonly opportunityResolution: OpportunityResolutionService,
private readonly orderValidator: OrderValidator,
private readonly orderBuilder: OrderBuilder,
private readonly orderItemBuilder: OrderItemBuilder,
@ -46,9 +51,18 @@ export class OrderOrchestrator {
"Order validation completed successfully"
);
// 2) Build order fields (includes address snapshot)
// 2) Resolve Opportunity for this order
const opportunityId = await this.resolveOpportunityForOrder(
validatedBody.orderType,
userMapping.sfAccountId ?? null,
validatedBody.opportunityId
);
// 3) Build order fields with Opportunity link
const bodyWithOpportunity = opportunityId ? { ...validatedBody, opportunityId } : validatedBody;
const orderFields = await this.orderBuilder.buildOrderFields(
validatedBody,
bodyWithOpportunity,
userMapping,
pricebookId,
validatedBody.userId
@ -63,6 +77,7 @@ export class OrderOrchestrator {
orderType: validatedBody.orderType,
skuCount: validatedBody.skus.length,
orderItemCount: orderItemsPayload.length,
hasOpportunity: !!opportunityId,
},
"Order payload prepared"
);
@ -72,6 +87,27 @@ export class OrderOrchestrator {
orderItemsPayload
);
// 4) Update Opportunity stage to Post Processing
if (opportunityId) {
try {
await this.opportunityService.updateStage(
opportunityId,
OPPORTUNITY_STAGE.POST_PROCESSING,
"Order placed via Portal"
);
this.logger.log("Opportunity stage updated to Post Processing", {
opportunityIdTail: opportunityId.slice(-4),
orderId: created.id,
});
} catch {
// Log but don't fail the order
this.logger.warn("Failed to update Opportunity stage after order", {
opportunityId,
orderId: created.id,
});
}
}
if (userMapping.sfAccountId) {
await this.ordersCache.invalidateAccountOrders(userMapping.sfAccountId);
}
@ -82,6 +118,7 @@ export class OrderOrchestrator {
orderId: created.id,
skuCount: validatedBody.skus.length,
orderItemCount: orderItemsPayload.length,
opportunityId,
},
"Order creation workflow completed successfully"
);
@ -93,6 +130,40 @@ export class OrderOrchestrator {
};
}
/**
* Resolve Opportunity for an order
*
* - If order already has an Opportunity ID, use it
* - Otherwise, find existing open Opportunity for this product type
* - If none found, create a new one with Post Processing stage
*/
private async resolveOpportunityForOrder(
orderType: OrderTypeValue,
sfAccountId: string | null,
existingOpportunityId?: string
): Promise<string | null> {
try {
const resolved = await this.opportunityResolution.resolveForOrderPlacement({
accountId: sfAccountId,
orderType,
existingOpportunityId,
});
if (resolved) {
this.logger.debug("Resolved Opportunity for order", {
opportunityIdTail: resolved.slice(-4),
orderType,
});
}
return resolved;
} catch {
const accountIdTail =
typeof sfAccountId === "string" && sfAccountId.length >= 4 ? sfAccountId.slice(-4) : "none";
this.logger.warn("Failed to resolve Opportunity for order", { orderType, accountIdTail });
// Don't fail the order if Opportunity resolution fails
return null;
}
}
/**
* Get order by ID with order items
*/

View File

@ -5,7 +5,7 @@ import { SalesforceConnection } from "@bff/integrations/salesforce/services/sale
import type {
SalesforceProduct2Record,
SalesforcePricebookEntryRecord,
} from "@customer-portal/domain/catalog";
} from "@customer-portal/domain/services";
import type { SalesforceResponse } from "@customer-portal/domain/common";
import {
assertSalesforceId,

View File

@ -13,9 +13,11 @@ import {
import type { Providers } from "@customer-portal/domain/subscriptions";
type WhmcsProduct = Providers.WhmcsRaw.WhmcsProductRaw;
import { SimCatalogService } from "@bff/modules/catalog/services/sim-catalog.service.js";
import { SimServicesService } from "@bff/modules/services/services/sim-services.service.js";
import { InternetServicesService } from "@bff/modules/services/services/internet-services.service.js";
import { OrderPricebookService, type PricebookProductMeta } from "./order-pricebook.service.js";
import { PaymentValidatorService } from "./payment-validator.service.js";
import { ResidenceCardService } from "@bff/modules/verification/residence-card.service.js";
/**
* Handles all order validation logic - both format and business rules
@ -30,8 +32,10 @@ export class OrderValidator {
private readonly mappings: MappingsService,
private readonly whmcs: WhmcsConnectionOrchestratorService,
private readonly pricebookService: OrderPricebookService,
private readonly simCatalogService: SimCatalogService,
private readonly paymentValidator: PaymentValidatorService
private readonly simCatalogService: SimServicesService,
private readonly internetCatalogService: InternetServicesService,
private readonly paymentValidator: PaymentValidatorService,
private readonly residenceCards: ResidenceCardService
) {}
/**
@ -269,6 +273,18 @@ export class OrderValidator {
const _productMeta = await this.validateSKUs(businessValidatedBody.skus, pricebookId);
if (businessValidatedBody.orderType === "SIM") {
const verification = await this.residenceCards.getStatusForUser(userId);
if (verification.status === "not_submitted") {
throw new BadRequestException(
"Residence card submission required for SIM orders. Please upload your residence card and try again."
);
}
if (verification.status === "rejected") {
throw new BadRequestException(
"Your residence card submission was rejected. Please resubmit your residence card and try again."
);
}
const activationFees = await this.simCatalogService.getActivationFees();
const activationSkus = new Set(
activationFees
@ -297,6 +313,23 @@ export class OrderValidator {
// 4. Order-specific business validation
if (businessValidatedBody.orderType === "Internet") {
const eligibility = await this.internetCatalogService.getEligibilityDetailsForUser(userId);
if (eligibility.status === "not_requested") {
throw new BadRequestException(
"Internet eligibility review is required before ordering. Please request an eligibility review from the Internet services page and try again."
);
}
if (eligibility.status === "pending") {
throw new BadRequestException(
"Internet eligibility review is still in progress. Please wait for review to complete and try again."
);
}
if (eligibility.status === "ineligible") {
throw new BadRequestException(
"Internet service is not available for your address. Please contact support if you believe this is incorrect."
);
}
await this.validateInternetDuplication(userId, userMapping.whmcsClientId);
}

View File

@ -67,14 +67,14 @@ export class RealtimeController {
}
);
const globalCatalogStream = this.realtime.subscribe("global:catalog", {
const globalServicesStream = this.realtime.subscribe("global:services", {
// Avoid duplicate ready/heartbeat noise on the combined stream.
readyEvent: null,
heartbeatEvent: null,
heartbeatMs: 0,
});
return merge(accountStream, globalCatalogStream).pipe(
return merge(accountStream, globalServicesStream).pipe(
finalize(() => {
this.limiter.release(req.user.id);
this.logger.debug("Account realtime stream disconnected", {

View File

@ -0,0 +1,61 @@
import { Controller, Get, Header, Request, UseGuards } from "@nestjs/common";
import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.js";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import {
parseInternetCatalog,
parseSimCatalog,
parseVpnCatalog,
type InternetCatalogCollection,
type SimCatalogCollection,
type VpnCatalogCollection,
} from "@customer-portal/domain/services";
import { InternetServicesService } from "./services/internet-services.service.js";
import { SimServicesService } from "./services/sim-services.service.js";
import { VpnServicesService } from "./services/vpn-services.service.js";
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
@Controller("account/services")
@UseGuards(SalesforceReadThrottleGuard, RateLimitGuard)
export class AccountServicesController {
constructor(
private readonly internetCatalog: InternetServicesService,
private readonly simCatalog: SimServicesService,
private readonly vpnCatalog: VpnServicesService
) {}
@Get("internet/plans")
@RateLimit({ limit: 60, ttl: 60 }) // account page refreshes are cheap; still bounded per IP+UA
@Header("Cache-Control", "private, no-store") // personalized
async getInternetCatalogForAccount(
@Request() req: RequestWithUser
): Promise<InternetCatalogCollection> {
const userId = req.user?.id;
const [plans, installations, addons] = await Promise.all([
this.internetCatalog.getPlansForUser(userId),
this.internetCatalog.getInstallations(),
this.internetCatalog.getAddons(),
]);
return parseInternetCatalog({ plans, installations, addons });
}
@Get("sim/plans")
@RateLimit({ limit: 60, ttl: 60 })
@Header("Cache-Control", "private, no-store") // personalized
async getSimCatalogForAccount(@Request() req: RequestWithUser): Promise<SimCatalogCollection> {
const userId = req.user?.id;
const [plans, activationFees, addons] = await Promise.all([
this.simCatalog.getPlansForUser(userId),
this.simCatalog.getActivationFees(),
this.simCatalog.getAddons(),
]);
return parseSimCatalog({ plans, activationFees, addons });
}
@Get("vpn/plans")
@RateLimit({ limit: 60, ttl: 60 })
@Header("Cache-Control", "private, no-store")
async getVpnCatalogForAccount(@Request() _req: RequestWithUser): Promise<VpnCatalogCollection> {
const catalog = await this.vpnCatalog.getCatalogData();
return parseVpnCatalog(catalog);
}
}

View File

@ -0,0 +1,53 @@
import { Body, Controller, Get, Header, Post, Req, UseGuards, UsePipes } from "@nestjs/common";
import { ZodValidationPipe } from "nestjs-zod";
import { z } from "zod";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js";
import { InternetServicesService } from "./services/internet-services.service.js";
import { addressSchema } from "@customer-portal/domain/customer";
import type { InternetEligibilityDetails } from "@customer-portal/domain/services";
const eligibilityRequestSchema = z.object({
notes: z.string().trim().max(2000).optional(),
address: addressSchema.partial().optional(),
});
type EligibilityRequest = z.infer<typeof eligibilityRequestSchema>;
/**
* Internet Eligibility Controller
*
* Authenticated endpoints for:
* - fetching current Salesforce eligibility value
* - requesting a (manual) eligibility/availability check
*
* Note: ServicesController is @Public, so we keep these endpoints in a separate controller
* to ensure GlobalAuthGuard enforces authentication.
*/
@Controller("services/internet")
@UseGuards(RateLimitGuard)
export class InternetEligibilityController {
constructor(private readonly internetCatalog: InternetServicesService) {}
@Get("eligibility")
@RateLimit({ limit: 60, ttl: 60 }) // 60/min per IP (cheap)
@Header("Cache-Control", "private, no-store")
async getEligibility(@Req() req: RequestWithUser): Promise<InternetEligibilityDetails> {
return this.internetCatalog.getEligibilityDetailsForUser(req.user.id);
}
@Post("eligibility-request")
@RateLimit({ limit: 5, ttl: 300 }) // 5 per 5 minutes per IP
@UsePipes(new ZodValidationPipe(eligibilityRequestSchema))
@Header("Cache-Control", "private, no-store")
async requestEligibility(
@Req() req: RequestWithUser,
@Body() body: EligibilityRequest
): Promise<{ requestId: string }> {
const requestId = await this.internetCatalog.requestEligibilityCheckForUser(req.user.id, {
email: req.user.email,
...body,
});
return { requestId };
}
}

View File

@ -0,0 +1,54 @@
import { Controller, Get, Header, UseGuards } from "@nestjs/common";
import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.js";
import { Public, PublicNoSession } from "@bff/modules/auth/decorators/public.decorator.js";
import {
parseInternetCatalog,
parseSimCatalog,
parseVpnCatalog,
type InternetCatalogCollection,
type SimCatalogCollection,
type VpnCatalogCollection,
} from "@customer-portal/domain/services";
import { InternetServicesService } from "./services/internet-services.service.js";
import { SimServicesService } from "./services/sim-services.service.js";
import { VpnServicesService } from "./services/vpn-services.service.js";
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
@Controller("public/services")
@Public()
@PublicNoSession()
@UseGuards(SalesforceReadThrottleGuard, RateLimitGuard)
export class PublicServicesController {
constructor(
private readonly internetCatalog: InternetServicesService,
private readonly simCatalog: SimServicesService,
private readonly vpnCatalog: VpnServicesService
) {}
@Get("internet/plans")
@RateLimit({ limit: 20, ttl: 60 }) // 20/min per IP+UA
@Header("Cache-Control", "public, max-age=300, s-maxage=300") // safe: strictly non-personalized
async getInternetCatalog(): Promise<InternetCatalogCollection> {
const catalog = await this.internetCatalog.getCatalogData();
return parseInternetCatalog(catalog);
}
@Get("sim/plans")
@RateLimit({ limit: 20, ttl: 60 }) // 20/min per IP+UA
@Header("Cache-Control", "public, max-age=300, s-maxage=300") // safe: strictly non-personalized
async getSimCatalog(): Promise<SimCatalogCollection> {
const catalog = await this.simCatalog.getCatalogData();
return parseSimCatalog({
...catalog,
plans: catalog.plans.filter(plan => !plan.simHasFamilyDiscount),
});
}
@Get("vpn/plans")
@RateLimit({ limit: 20, ttl: 60 }) // 20/min per IP+UA
@Header("Cache-Control", "public, max-age=300, s-maxage=300") // safe: strictly non-personalized
async getVpnCatalog(): Promise<VpnCatalogCollection> {
const catalog = await this.vpnCatalog.getCatalogData();
return parseVpnCatalog(catalog);
}
}

View File

@ -1,26 +1,26 @@
import { Controller, Get } from "@nestjs/common";
import { CatalogCacheService } from "./services/catalog-cache.service.js";
import type { CatalogCacheSnapshot } from "./services/catalog-cache.service.js";
import { ServicesCacheService } from "./services/services-cache.service.js";
import type { ServicesCacheSnapshot } from "./services/services-cache.service.js";
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
interface CatalogCacheHealthResponse {
interface ServicesCacheHealthResponse {
timestamp: string;
metrics: CatalogCacheSnapshot;
metrics: ServicesCacheSnapshot;
ttl: {
catalogSeconds: number | null;
servicesSeconds: number | null;
eligibilitySeconds: number | null;
staticSeconds: number | null;
volatileSeconds: number;
};
}
@Controller("health/catalog")
@Controller("health/services")
@Public()
export class CatalogHealthController {
constructor(private readonly catalogCache: CatalogCacheService) {}
export class ServicesHealthController {
constructor(private readonly catalogCache: ServicesCacheService) {}
@Get("cache")
getCacheMetrics(): CatalogCacheHealthResponse {
getCacheMetrics(): ServicesCacheHealthResponse {
const ttl = this.catalogCache.getTtlConfiguration();
return {
timestamp: new Date().toISOString(),

View File

@ -1,9 +1,11 @@
import { Controller, Get, Request, UseGuards, Header } from "@nestjs/common";
import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.js";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
import {
parseInternetCatalog,
parseSimCatalog,
parseVpnCatalog,
type InternetAddonCatalogItem,
type InternetInstallationCatalogItem,
type InternetPlanCatalogItem,
@ -11,19 +13,21 @@ import {
type SimCatalogCollection,
type SimCatalogProduct,
type VpnCatalogProduct,
} from "@customer-portal/domain/catalog";
import { InternetCatalogService } from "./services/internet-catalog.service.js";
import { SimCatalogService } from "./services/sim-catalog.service.js";
import { VpnCatalogService } from "./services/vpn-catalog.service.js";
type VpnCatalogCollection,
} from "@customer-portal/domain/services";
import { InternetServicesService } from "./services/internet-services.service.js";
import { SimServicesService } from "./services/sim-services.service.js";
import { VpnServicesService } from "./services/vpn-services.service.js";
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
@Controller("catalog")
@Controller("services")
@Public() // Allow public access - services can be browsed without authentication
@UseGuards(SalesforceReadThrottleGuard, RateLimitGuard)
export class CatalogController {
export class ServicesController {
constructor(
private internetCatalog: InternetCatalogService,
private simCatalog: SimCatalogService,
private vpnCatalog: VpnCatalogService
private internetCatalog: InternetServicesService,
private simCatalog: SimServicesService,
private vpnCatalog: VpnServicesService
) {}
@Get("internet/plans")
@ -98,8 +102,10 @@ export class CatalogController {
@Get("vpn/plans")
@RateLimit({ limit: 20, ttl: 60 }) // 20 requests per minute
@Header("Cache-Control", "public, max-age=300, s-maxage=300") // 5 minutes
async getVpnPlans(): Promise<VpnCatalogProduct[]> {
return this.vpnCatalog.getPlans();
async getVpnPlans(): Promise<VpnCatalogCollection> {
// Backwards-compatible: return the full VPN catalog (plans + activation fees)
const catalog = await this.vpnCatalog.getCatalogData();
return parseVpnCatalog(catalog);
}
@Get("vpn/activation-fees")

View File

@ -0,0 +1,43 @@
import { Module, forwardRef } from "@nestjs/common";
import { ServicesController } from "./services.controller.js";
import { ServicesHealthController } from "./services-health.controller.js";
import { InternetEligibilityController } from "./internet-eligibility.controller.js";
import { PublicServicesController } from "./public-services.controller.js";
import { AccountServicesController } from "./account-services.controller.js";
import { IntegrationsModule } from "@bff/integrations/integrations.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { CoreConfigModule } from "@bff/core/config/config.module.js";
import { CacheModule } from "@bff/infra/cache/cache.module.js";
import { QueueModule } from "@bff/core/queue/queue.module.js";
import { BaseServicesService } from "./services/base-services.service.js";
import { InternetServicesService } from "./services/internet-services.service.js";
import { SimServicesService } from "./services/sim-services.service.js";
import { VpnServicesService } from "./services/vpn-services.service.js";
import { ServicesCacheService } from "./services/services-cache.service.js";
@Module({
imports: [
forwardRef(() => IntegrationsModule),
MappingsModule,
CoreConfigModule,
CacheModule,
QueueModule,
],
controllers: [
ServicesController,
PublicServicesController,
AccountServicesController,
ServicesHealthController,
InternetEligibilityController,
],
providers: [
BaseServicesService,
InternetServicesService,
SimServicesService,
VpnServicesService,
ServicesCacheService,
],
exports: [InternetServicesService, SimServicesService, VpnServicesService, ServicesCacheService],
})
export class ServicesModule {}

View File

@ -8,18 +8,18 @@ import {
} from "@bff/integrations/salesforce/utils/soql.util.js";
import {
buildProductQuery,
buildCatalogServiceQuery,
} from "@bff/integrations/salesforce/utils/catalog-query-builder.js";
buildServicesQuery,
} from "@bff/integrations/salesforce/utils/services-query-builder.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
import type {
SalesforceProduct2WithPricebookEntries,
SalesforcePricebookEntryRecord,
} from "@customer-portal/domain/catalog";
import { Providers as CatalogProviders } from "@customer-portal/domain/catalog";
} from "@customer-portal/domain/services";
import { Providers as CatalogProviders } from "@customer-portal/domain/services";
import type { SalesforceResponse } from "@customer-portal/domain/common";
@Injectable()
export class BaseCatalogService {
export class BaseServicesService {
protected readonly portalPriceBookId: string;
protected readonly portalCategoryField: string;
@ -41,7 +41,7 @@ export class BaseCatalogService {
): Promise<TRecord[]> {
try {
const res = (await this.sf.query(soql, {
label: `catalog:${context.replace(/\s+/g, "_").toLowerCase()}`,
label: `services:${context.replace(/\s+/g, "_").toLowerCase()}`,
})) as SalesforceResponse<TRecord>;
return res.records ?? [];
} catch (error: unknown) {
@ -99,8 +99,8 @@ export class BaseCatalogService {
return [];
}
protected buildCatalogServiceQuery(category: string, additionalFields: string[] = []): string {
return buildCatalogServiceQuery(
protected buildServicesQuery(category: string, additionalFields: string[] = []): string {
return buildServicesQuery(
this.portalPriceBookId,
this.portalCategoryField,
category,

View File

@ -0,0 +1,7 @@
import type { Address } from "@customer-portal/domain/customer";
export type InternetEligibilityCheckRequest = {
email: string;
notes?: string;
address?: Partial<Address>;
};

View File

@ -0,0 +1,541 @@
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { BaseServicesService } from "./base-services.service.js";
import { ServicesCacheService } from "./services-cache.service.js";
import type {
SalesforceProduct2WithPricebookEntries,
InternetPlanCatalogItem,
InternetInstallationCatalogItem,
InternetAddonCatalogItem,
InternetEligibilityDetails,
InternetEligibilityStatus,
} from "@customer-portal/domain/services";
import {
Providers as CatalogProviders,
enrichInternetPlanMetadata,
inferAddonTypeFromSku,
inferInstallationTermFromSku,
internetEligibilityDetailsSchema,
} from "@customer-portal/domain/services";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
import { OpportunityResolutionService } from "@bff/integrations/salesforce/services/opportunity-resolution.service.js";
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
import { Logger } from "nestjs-pino";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js";
import { assertSoqlFieldName } from "@bff/integrations/salesforce/utils/soql.util.js";
import type { InternetEligibilityCheckRequest } from "./internet-eligibility.types.js";
import type { SalesforceResponse } from "@customer-portal/domain/common";
// (removed unused opportunity constants import)
@Injectable()
export class InternetServicesService extends BaseServicesService {
constructor(
sf: SalesforceConnection,
private readonly config: ConfigService,
@Inject(Logger) logger: Logger,
private mappingsService: MappingsService,
private catalogCache: ServicesCacheService,
private lockService: DistributedLockService,
private opportunityResolution: OpportunityResolutionService,
private caseService: SalesforceCaseService
) {
super(sf, config, logger);
}
async getPlans(): Promise<InternetPlanCatalogItem[]> {
const cacheKey = this.catalogCache.buildServicesKey("internet", "plans");
return this.catalogCache.getCachedServices(
cacheKey,
async () => {
const soql = this.buildServicesQuery("Internet", [
"Internet_Plan_Tier__c",
"Internet_Offering_Type__c",
"Catalog_Order__c",
]);
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql,
"Internet Plans"
);
const plans = records.map(record => {
const entry = this.extractPricebookEntry(record);
const plan = CatalogProviders.Salesforce.mapInternetPlan(record, entry);
return enrichInternetPlanMetadata(plan);
});
// Prefer ordering by offering type (for services UX) over Product2.Name.
// We still respect Catalog_Order__c (mapped to displayOrder) within each offering type.
return plans.sort(compareInternetPlansForServices);
},
{
resolveDependencies: plans => ({
productIds: plans.map(plan => plan.id).filter((id): id is string => Boolean(id)),
}),
}
);
}
async getInstallations(): Promise<InternetInstallationCatalogItem[]> {
const cacheKey = this.catalogCache.buildServicesKey("internet", "installations");
return this.catalogCache.getCachedServices(
cacheKey,
async () => {
const soql = this.buildProductQuery("Internet", "Installation", [
"Billing_Cycle__c",
"Catalog_Order__c",
]);
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql,
"Internet Installations"
);
this.logger.log(`Found ${records.length} installation records`);
return records
.map(record => {
const entry = this.extractPricebookEntry(record);
const installation = CatalogProviders.Salesforce.mapInternetInstallation(record, entry);
return {
...installation,
catalogMetadata: {
...installation.catalogMetadata,
installationTerm: inferInstallationTermFromSku(installation.sku ?? ""),
},
};
})
.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
},
{
resolveDependencies: installations => ({
productIds: installations.map(item => item.id).filter((id): id is string => Boolean(id)),
}),
}
);
}
async getAddons(): Promise<InternetAddonCatalogItem[]> {
const cacheKey = this.catalogCache.buildServicesKey("internet", "addons");
return this.catalogCache.getCachedServices(
cacheKey,
async () => {
const soql = this.buildProductQuery("Internet", "Add-on", [
"Billing_Cycle__c",
"Catalog_Order__c",
"Bundled_Addon__c",
"Is_Bundled_Addon__c",
]);
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql,
"Internet Add-ons"
);
this.logger.log(`Found ${records.length} addon records`);
return records
.map(record => {
const entry = this.extractPricebookEntry(record);
const addon = CatalogProviders.Salesforce.mapInternetAddon(record, entry);
return {
...addon,
catalogMetadata: {
...addon.catalogMetadata,
addonType: inferAddonTypeFromSku(addon.sku ?? ""),
},
};
})
.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
},
{
resolveDependencies: addons => ({
productIds: addons.map(addon => addon.id).filter((id): id is string => Boolean(id)),
}),
}
);
}
async getCatalogData() {
const [plans, installations, addons] = await Promise.all([
this.getPlans(),
this.getInstallations(),
this.getAddons(),
]);
return { plans, installations, addons };
}
async getPlansForUser(userId: string): Promise<InternetPlanCatalogItem[]> {
try {
// Get all plans first
const allPlans = await this.getPlans();
// Get user's Salesforce account mapping
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.sfAccountId) {
this.logger.warn(`No Salesforce mapping found for user ${userId}, returning all plans`);
return allPlans;
}
// Get customer's eligibility from Salesforce
const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId");
const eligibilityKey = this.catalogCache.buildEligibilityKey("internet", sfAccountId);
const details = await this.catalogCache.getCachedEligibility<InternetEligibilityDetails>(
eligibilityKey,
async () => this.queryEligibilityDetails(sfAccountId)
);
if (!details) {
this.logger.warn(`No Salesforce account found for user ${userId}, returning all plans`);
return allPlans;
}
const eligibility = details.eligibility;
if (!eligibility) {
this.logger.log(`No eligibility field for user ${userId}, filtering to Home 1G plans only`);
const homeGPlans = allPlans.filter(plan => plan.internetOfferingType === "Home 1G");
return homeGPlans;
}
// Filter plans based on eligibility
const eligiblePlans = allPlans.filter(plan => {
const isEligible = this.checkPlanEligibility(plan, eligibility);
if (!isEligible) {
this.logger.debug(
`Plan ${plan.name} (${plan.internetPlanTier ?? "Unknown"}) not eligible for user ${userId} with eligibility: ${eligibility}`
);
}
return isEligible;
});
this.logger.log(
`Filtered ${allPlans.length} plans to ${eligiblePlans.length} eligible plans for user ${userId} with eligibility: ${eligibility}`
);
return eligiblePlans;
} catch (error) {
this.logger.error(`Failed to get eligible plans for user ${userId}`, {
error: getErrorMessage(error),
});
// Fallback to all plans if there's an error
return this.getPlans();
}
}
async getEligibilityForUser(userId: string): Promise<string | null> {
const details = await this.getEligibilityDetailsForUser(userId);
return details.eligibility;
}
async getEligibilityDetailsForUser(userId: string): Promise<InternetEligibilityDetails> {
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.sfAccountId) {
return internetEligibilityDetailsSchema.parse({
status: "not_requested",
eligibility: null,
requestId: null,
requestedAt: null,
checkedAt: null,
notes: null,
});
}
const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId");
const eligibilityKey = this.catalogCache.buildEligibilityKey("internet", sfAccountId);
// Explicitly define the validator to handle potential malformed cache data
// If the cache returns undefined or missing fields, we treat it as a cache miss or malformed data
// and force a re-fetch or ensure safe defaults are applied.
return this.catalogCache
.getCachedEligibility<InternetEligibilityDetails>(eligibilityKey, async () =>
this.queryEligibilityDetails(sfAccountId)
)
.then(data => {
// Safety check: ensure the data matches the schema before returning.
// This protects against cache corruption (e.g. missing fields treated as undefined).
const result = internetEligibilityDetailsSchema.safeParse(data);
if (!result.success) {
this.logger.warn("Cached eligibility data was malformed, treating as cache miss", {
userId,
sfAccountId,
errors: result.error.format(),
});
// Invalidate bad cache and re-fetch
this.catalogCache.invalidateEligibility(sfAccountId).catch((error: unknown) =>
this.logger.error("Failed to invalidate malformed eligibility cache", {
error: getErrorMessage(error),
})
);
return this.queryEligibilityDetails(sfAccountId);
}
return result.data;
});
}
async requestEligibilityCheckForUser(
userId: string,
request: InternetEligibilityCheckRequest
): Promise<string> {
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.sfAccountId) {
throw new Error("No Salesforce mapping found for current user");
}
const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId");
if (
!request.address ||
!request.address.address1 ||
!request.address.city ||
!request.address.postcode
) {
throw new BadRequestException("Service address is required to request eligibility review.");
}
try {
const lockKey = `internet:eligibility:${sfAccountId}`;
const caseId = await this.lockService.withLock(
lockKey,
async () => {
// Idempotency: if we already have a pending request, do not create a new Case.
// The Case creation is a signal of interest; if status is pending, interest is already signaled/active.
const existing = await this.queryEligibilityDetails(sfAccountId);
if (existing.status === "pending") {
this.logger.log("Eligibility request already pending; skipping new case creation", {
userId,
sfAccountIdTail: sfAccountId.slice(-4),
});
// Try to find the existing open case to return its ID (best effort)
try {
const cases = await this.caseService.getCasesForAccount(sfAccountId);
const openCase = cases.find(
c => c.status !== "Closed" && c.subject.includes("Internet availability check")
);
if (openCase) {
return openCase.id;
}
} catch (error) {
this.logger.warn("Failed to lookup existing case for pending request", { error });
}
// If we can't find the case ID but status is pending, we return a placeholder or empty string.
// The frontend primarily relies on the status change.
return "";
}
// 1) Find or create Opportunity for Internet eligibility
const { opportunityId, wasCreated: opportunityCreated } =
await this.opportunityResolution.findOrCreateForInternetEligibility(sfAccountId);
// 2) Build case description
const subject = "Internet availability check request (Portal)";
const descriptionLines: string[] = [
"Portal internet availability check requested.",
"",
`UserId: ${userId}`,
`Email: ${request.email}`,
`SalesforceAccountId: ${sfAccountId}`,
`OpportunityId: ${opportunityId}`,
"",
request.notes ? `Notes: ${request.notes}` : "",
request.address ? `Address: ${formatAddressForLog(request.address)}` : "",
"",
`RequestedAt: ${new Date().toISOString()}`,
].filter(Boolean);
// 3) Create Case linked to Opportunity
const createdCaseId = await this.caseService.createEligibilityCase({
accountId: sfAccountId,
opportunityId,
subject,
description: descriptionLines.join("\n"),
});
// 4) Update Account eligibility status
await this.updateAccountEligibilityRequestState(sfAccountId);
await this.catalogCache.invalidateEligibility(sfAccountId);
this.logger.log("Created eligibility Case linked to Opportunity", {
userId,
sfAccountIdTail: sfAccountId.slice(-4),
caseIdTail: createdCaseId.slice(-4),
opportunityIdTail: opportunityId.slice(-4),
opportunityCreated,
});
return createdCaseId;
},
{ ttlMs: 10_000 }
);
return caseId;
} catch (error) {
this.logger.error("Failed to create eligibility request", {
userId,
sfAccountId,
error: getErrorMessage(error),
});
throw new Error("Failed to request availability check. Please try again later.");
}
}
private checkPlanEligibility(plan: InternetPlanCatalogItem, eligibility: string): boolean {
// Simple match: user's eligibility field must equal plan's Salesforce offering type
// e.g., eligibility "Home 1G" matches plan.internetOfferingType "Home 1G"
return plan.internetOfferingType === eligibility;
}
private async queryEligibilityDetails(sfAccountId: string): Promise<InternetEligibilityDetails> {
const eligibilityField = assertSoqlFieldName(
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_FIELD") ?? "Internet_Eligibility__c",
"ACCOUNT_INTERNET_ELIGIBILITY_FIELD"
);
const statusField = assertSoqlFieldName(
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD") ??
"Internet_Eligibility_Status__c",
"ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD"
);
const requestedAtField = assertSoqlFieldName(
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD") ??
"Internet_Eligibility_Request_Date_Time__c",
"ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD"
);
const checkedAtField = assertSoqlFieldName(
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_CHECKED_AT_FIELD") ??
"Internet_Eligibility_Checked_Date_Time__c",
"ACCOUNT_INTERNET_ELIGIBILITY_CHECKED_AT_FIELD"
);
// Note: Notes and Case ID fields removed as they are not present/needed in the Salesforce schema
const soql = `
SELECT Id, ${eligibilityField}, ${statusField}, ${requestedAtField}, ${checkedAtField}
FROM Account
WHERE Id = '${sfAccountId}'
LIMIT 1
`;
const res = (await this.sf.query(soql, {
label: "services:internet:eligibility_details",
})) as SalesforceResponse<Record<string, unknown>>;
const record = (res.records?.[0] as Record<string, unknown> | undefined) ?? undefined;
if (!record) {
return internetEligibilityDetailsSchema.parse({
status: "not_requested",
eligibility: null,
requestId: null,
requestedAt: null,
checkedAt: null,
notes: null,
});
}
const eligibilityRaw = record[eligibilityField];
const eligibility =
typeof eligibilityRaw === "string" && eligibilityRaw.trim().length > 0
? eligibilityRaw.trim()
: null;
const statusRaw = record[statusField];
const normalizedStatus = typeof statusRaw === "string" ? statusRaw.trim().toLowerCase() : "";
const status: InternetEligibilityStatus =
normalizedStatus === "pending" || normalizedStatus === "checking"
? "pending"
: normalizedStatus === "eligible"
? "eligible"
: normalizedStatus === "ineligible" || normalizedStatus === "not available"
? "ineligible"
: eligibility
? "eligible"
: "not_requested";
const requestedAtRaw = record[requestedAtField];
const checkedAtRaw = record[checkedAtField];
const requestedAt =
typeof requestedAtRaw === "string"
? requestedAtRaw
: requestedAtRaw instanceof Date
? requestedAtRaw.toISOString()
: null;
const checkedAt =
typeof checkedAtRaw === "string"
? checkedAtRaw
: checkedAtRaw instanceof Date
? checkedAtRaw.toISOString()
: null;
return internetEligibilityDetailsSchema.parse({
status,
eligibility,
requestId: null, // Always null as field is not used
requestedAt,
checkedAt,
notes: null, // Always null as field is not used
});
}
// Note: createEligibilityCaseOrTask was removed - now using this.caseService.createEligibilityCase()
// which links the Case to the Opportunity
private async updateAccountEligibilityRequestState(sfAccountId: string): Promise<void> {
const statusField = assertSoqlFieldName(
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD") ??
"Internet_Eligibility_Status__c",
"ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD"
);
const requestedAtField = assertSoqlFieldName(
this.config.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD") ??
"Internet_Eligibility_Request_Date_Time__c",
"ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD"
);
const update = this.sf.sobject("Account")?.update;
if (!update) {
throw new Error("Salesforce Account update method not available");
}
await update({
Id: sfAccountId,
[statusField]: "Pending",
[requestedAtField]: new Date().toISOString(),
});
}
}
function normalizeCatalogString(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function compareInternetPlansForServices(
a: InternetPlanCatalogItem,
b: InternetPlanCatalogItem
): number {
const aOffering = normalizeCatalogString(a.internetOfferingType);
const bOffering = normalizeCatalogString(b.internetOfferingType);
if (aOffering !== bOffering) return aOffering.localeCompare(bOffering);
const aOrder = typeof a.displayOrder === "number" ? a.displayOrder : Number.MAX_SAFE_INTEGER;
const bOrder = typeof b.displayOrder === "number" ? b.displayOrder : Number.MAX_SAFE_INTEGER;
if (aOrder !== bOrder) return aOrder - bOrder;
const aName = normalizeCatalogString(a.name);
const bName = normalizeCatalogString(b.name);
return aName.localeCompare(bName);
}
function formatAddressForLog(address: Record<string, unknown>): string {
const address1 = typeof address.address1 === "string" ? address.address1.trim() : "";
const address2 = typeof address.address2 === "string" ? address.address2.trim() : "";
const city = typeof address.city === "string" ? address.city.trim() : "";
const state = typeof address.state === "string" ? address.state.trim() : "";
const postcode = typeof address.postcode === "string" ? address.postcode.trim() : "";
const country = typeof address.country === "string" ? address.country.trim() : "";
return [address1, address2, city, state, postcode, country].filter(Boolean).join(", ");
}

View File

@ -1,16 +1,17 @@
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { CacheService } from "@bff/infra/cache/cache.service.js";
import type { CacheBucketMetrics, CacheDependencies } from "@bff/infra/cache/cache.types.js";
export interface CatalogCacheSnapshot {
catalog: CacheBucketMetrics;
export interface ServicesCacheSnapshot {
services: CacheBucketMetrics;
static: CacheBucketMetrics;
volatile: CacheBucketMetrics;
eligibility: CacheBucketMetrics;
invalidations: number;
}
export interface CatalogCacheOptions<T> {
export interface ServicesCacheOptions<T> {
allowNull?: boolean;
resolveDependencies?: (
value: T
@ -24,33 +25,34 @@ interface LegacyCatalogCachePayload<T> {
}
/**
* Catalog cache service
* Services cache service
*
* Uses CDC (Change Data Capture) for real-time cache invalidation with
* product dependency tracking for granular invalidation.
*
* Features:
* - CDC-driven invalidation: No TTL, cache persists until CDC event
* - Event-driven invalidation: CDC / Platform Events invalidate caches on change
* - Safety TTL: long TTL to self-heal if events are missed
* - Product dependency tracking: Granular invalidation by product IDs
* - Request coalescing: Prevents thundering herd on cache miss
* - Metrics tracking: Monitors hits, misses, and invalidations
*
* Cache buckets:
* - catalog: Product catalog data (CDC-driven)
* - static: Static reference data (CDC-driven)
* - eligibility: Account eligibility data (CDC-driven)
* - catalog: Product catalog data (event-driven + safety TTL)
* - static: Static reference data (event-driven + safety TTL)
* - eligibility: Account eligibility data (event-driven + safety TTL)
* - volatile: Frequently changing data (60s TTL)
*/
@Injectable()
export class CatalogCacheService {
// CDC-driven invalidation: null TTL means cache persists until explicit invalidation
private readonly CATALOG_TTL: number | null = null;
private readonly STATIC_TTL: number | null = null;
private readonly ELIGIBILITY_TTL: number | null = null;
export class ServicesCacheService {
// CDC-driven invalidation + safety TTL (self-heal if events are missed)
private readonly SERVICES_TTL: number | null;
private readonly STATIC_TTL: number | null;
private readonly ELIGIBILITY_TTL: number | null;
private readonly VOLATILE_TTL = 60; // Volatile data still uses TTL
private readonly metrics: CatalogCacheSnapshot = {
catalog: { hits: 0, misses: 0 },
private readonly metrics: ServicesCacheSnapshot = {
services: { hits: 0, misses: 0 },
static: { hits: 0, misses: 0 },
volatile: { hits: 0, misses: 0 },
eligibility: { hits: 0, misses: 0 },
@ -61,21 +63,32 @@ export class CatalogCacheService {
// request the same data after CDC invalidation
private readonly inflightRequests = new Map<string, Promise<unknown>>();
constructor(private readonly cache: CacheService) {}
constructor(
private readonly cache: CacheService,
private readonly config: ConfigService
) {
const raw = this.config.get<number>("SERVICES_CACHE_SAFETY_TTL_SECONDS", 60 * 60 * 12);
const ttl = typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : null;
/**
* Get or fetch catalog data (CDC-driven cache, no TTL)
*/
async getCachedCatalog<T>(
key: string,
fetchFn: () => Promise<T>,
options?: CatalogCacheOptions<T>
): Promise<T> {
return this.getOrSet("catalog", key, this.CATALOG_TTL, fetchFn, options);
// Apply to CDC-driven buckets (catalog + static + eligibility)
this.SERVICES_TTL = ttl;
this.STATIC_TTL = ttl;
this.ELIGIBILITY_TTL = ttl;
}
/**
* Get or fetch static catalog data (CDC-driven cache, no TTL)
* Get or fetch catalog data (CDC-driven cache with safety TTL)
*/
async getCachedServices<T>(
key: string,
fetchFn: () => Promise<T>,
options?: ServicesCacheOptions<T>
): Promise<T> {
return this.getOrSet("services", key, this.SERVICES_TTL, fetchFn, options);
}
/**
* Get or fetch static catalog data (CDC-driven cache with safety TTL)
*/
async getCachedStatic<T>(key: string, fetchFn: () => Promise<T>): Promise<T> {
return this.getOrSet("static", key, this.STATIC_TTL, fetchFn);
@ -89,7 +102,7 @@ export class CatalogCacheService {
}
/**
* Get or fetch eligibility data (CDC-driven cache, no TTL)
* Get or fetch eligibility data (event-driven cache with safety TTL)
*/
async getCachedEligibility<T>(key: string, fetchFn: () => Promise<T>): Promise<T> {
return this.getOrSet("eligibility", key, this.ELIGIBILITY_TTL, fetchFn, {
@ -100,20 +113,20 @@ export class CatalogCacheService {
/**
* Build cache key for catalog data
*/
buildCatalogKey(catalogType: string, ...parts: string[]): string {
return `catalog:${catalogType}:${parts.join(":")}`;
buildServicesKey(serviceType: string, ...parts: string[]): string {
return `services:${serviceType}:${parts.join(":")}`;
}
buildEligibilityKey(_catalogType: string, accountId: string): string {
return `catalog:eligibility:${accountId}`;
return `services:eligibility:${accountId}`;
}
/**
* Invalidate catalog cache by pattern
*/
async invalidateCatalog(catalogType: string): Promise<void> {
async invalidateServices(serviceType: string): Promise<void> {
this.metrics.invalidations++;
await this.cache.delPattern(`catalog:${catalogType}:*`);
await this.cache.delPattern(`services:${serviceType}:*`);
await this.flushProductDependencyIndex();
}
@ -129,9 +142,9 @@ export class CatalogCacheService {
/**
* Invalidate all catalog cache entries
*/
async invalidateAllCatalogs(): Promise<void> {
async invalidateAllServices(): Promise<void> {
this.metrics.invalidations++;
await this.cache.delPattern("catalog:*");
await this.cache.delPattern("services:*");
await this.flushProductDependencyIndex();
}
@ -139,13 +152,13 @@ export class CatalogCacheService {
* Get TTL configuration for monitoring
*/
getTtlConfiguration(): {
catalogSeconds: number | null;
servicesSeconds: number | null;
eligibilitySeconds: number | null;
staticSeconds: number | null;
volatileSeconds: number;
} {
return {
catalogSeconds: this.CATALOG_TTL ?? null,
servicesSeconds: this.SERVICES_TTL ?? null,
eligibilitySeconds: this.ELIGIBILITY_TTL ?? null,
staticSeconds: this.STATIC_TTL ?? null,
volatileSeconds: this.VOLATILE_TTL,
@ -155,9 +168,9 @@ export class CatalogCacheService {
/**
* Get cache metrics for monitoring
*/
getMetrics(): CatalogCacheSnapshot {
getMetrics(): ServicesCacheSnapshot {
return {
catalog: { ...this.metrics.catalog },
services: { ...this.metrics.services },
static: { ...this.metrics.static },
volatile: { ...this.metrics.volatile },
eligibility: { ...this.metrics.eligibility },
@ -173,10 +186,27 @@ export class CatalogCacheService {
eligibility: string | null | undefined
): Promise<void> {
const key = this.buildEligibilityKey("", accountId);
const payload =
typeof eligibility === "string"
? { Id: accountId, Internet_Eligibility__c: eligibility }
: null;
const payload = {
status: eligibility ? "eligible" : "not_requested",
eligibility: typeof eligibility === "string" ? eligibility : null,
requestId: null,
requestedAt: null,
checkedAt: null,
notes: null,
};
if (this.ELIGIBILITY_TTL === null) {
await this.cache.set(key, payload);
} else {
await this.cache.set(key, payload, this.ELIGIBILITY_TTL);
}
}
/**
* Set eligibility details payload for an account.
* Used by Salesforce Platform Events to push updates into the cache without re-querying Salesforce.
*/
async setEligibilityDetails(accountId: string, payload: unknown): Promise<void> {
const key = this.buildEligibilityKey("", accountId);
if (this.ELIGIBILITY_TTL === null) {
await this.cache.set(key, payload);
} else {
@ -185,11 +215,11 @@ export class CatalogCacheService {
}
private async getOrSet<T>(
bucket: "catalog" | "static" | "volatile" | "eligibility",
bucket: "services" | "static" | "volatile" | "eligibility",
key: string,
ttlSeconds: number | null,
fetchFn: () => Promise<T>,
options?: CatalogCacheOptions<T>
options?: ServicesCacheOptions<T>
): Promise<T> {
const allowNull = options?.allowNull ?? false;
@ -234,8 +264,8 @@ export class CatalogCacheService {
// Store and link dependencies separately
if (dependencies) {
await this.storeDependencies(key, dependencies);
await this.linkDependencies(key, dependencies);
await this.storeDependencies(key, dependencies, ttlSeconds);
await this.linkDependencies(key, dependencies, ttlSeconds);
}
return fresh;
@ -264,8 +294,8 @@ export class CatalogCacheService {
}
if (cached.dependencies) {
await this.storeDependencies(key, cached.dependencies);
await this.linkDependencies(key, cached.dependencies);
await this.storeDependencies(key, cached.dependencies, ttlSeconds);
await this.linkDependencies(key, cached.dependencies, ttlSeconds);
}
return normalizedValue;
@ -327,11 +357,19 @@ export class CatalogCacheService {
/**
* Store dependencies metadata for a cache key
*/
private async storeDependencies(key: string, dependencies: CacheDependencies): Promise<void> {
private async storeDependencies(
key: string,
dependencies: CacheDependencies,
ttlSeconds: number | null
): Promise<void> {
const normalized = this.normalizeDependencies(dependencies);
if (normalized) {
const metaKey = this.buildDependencyMetaKey(key);
await this.cache.set(metaKey, normalized);
if (ttlSeconds === null) {
await this.cache.set(metaKey, normalized);
} else {
await this.cache.set(metaKey, normalized, ttlSeconds);
}
}
}
@ -358,7 +396,11 @@ export class CatalogCacheService {
return { productIds: Array.from(new Set(productIds)) };
}
private async linkDependencies(key: string, dependencies: CacheDependencies): Promise<void> {
private async linkDependencies(
key: string,
dependencies: CacheDependencies,
ttlSeconds: number | null
): Promise<void> {
const normalized = this.normalizeDependencies(dependencies);
if (!normalized) {
return;
@ -371,7 +413,11 @@ export class CatalogCacheService {
if (!existing.includes(key)) {
existing.push(key);
}
await this.cache.set(indexKey, { keys: existing });
if (ttlSeconds === null) {
await this.cache.set(indexKey, { keys: existing });
} else {
await this.cache.set(indexKey, { keys: existing }, ttlSeconds);
}
}
}
}
@ -403,17 +449,12 @@ export class CatalogCacheService {
}
private buildProductDependencyKey(productId: string): string {
return `catalog:deps:product:${productId}`;
return `services:deps:product:${productId}`;
}
private async flushProductDependencyIndex(): Promise<void> {
await this.cache.delPattern("catalog:deps:product:*");
await this.cache.delPattern("services:deps:product:*");
}
}
export interface CatalogCacheOptions<T> {
allowNull?: boolean;
resolveDependencies?: (
value: T
) => CacheDependencies | Promise<CacheDependencies | undefined> | undefined;
}
// (intentionally no duplicate options type; use ServicesCacheOptions<T> above)

View File

@ -1,38 +1,38 @@
import { Injectable, Inject } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { BaseCatalogService } from "./base-catalog.service.js";
import { CatalogCacheService } from "./catalog-cache.service.js";
import { BaseServicesService } from "./base-services.service.js";
import { ServicesCacheService } from "./services-cache.service.js";
import type {
SalesforceProduct2WithPricebookEntries,
SimCatalogProduct,
SimActivationFeeCatalogItem,
} from "@customer-portal/domain/catalog";
import { Providers as CatalogProviders } from "@customer-portal/domain/catalog";
} from "@customer-portal/domain/services";
import { Providers as CatalogProviders } from "@customer-portal/domain/services";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
import { Logger } from "nestjs-pino";
import { WhmcsConnectionOrchestratorService } from "@bff/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.js";
@Injectable()
export class SimCatalogService extends BaseCatalogService {
export class SimServicesService extends BaseServicesService {
constructor(
sf: SalesforceConnection,
configService: ConfigService,
@Inject(Logger) logger: Logger,
private mappingsService: MappingsService,
private whmcs: WhmcsConnectionOrchestratorService,
private catalogCache: CatalogCacheService
private catalogCache: ServicesCacheService
) {
super(sf, configService, logger);
}
async getPlans(): Promise<SimCatalogProduct[]> {
const cacheKey = this.catalogCache.buildCatalogKey("sim", "plans");
const cacheKey = this.catalogCache.buildServicesKey("sim", "plans");
return this.catalogCache.getCachedCatalog(
return this.catalogCache.getCachedServices(
cacheKey,
async () => {
const soql = this.buildCatalogServiceQuery("SIM", [
const soql = this.buildServicesQuery("SIM", [
"SIM_Data_Size__c",
"SIM_Plan_Type__c",
"SIM_Has_Family_Discount__c",
@ -62,9 +62,9 @@ export class SimCatalogService extends BaseCatalogService {
}
async getActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
const cacheKey = this.catalogCache.buildCatalogKey("sim", "activation-fees");
const cacheKey = this.catalogCache.buildServicesKey("sim", "activation-fees");
return this.catalogCache.getCachedCatalog(
return this.catalogCache.getCachedServices(
cacheKey,
async () => {
const soql = this.buildProductQuery("SIM", "Activation", [
@ -115,9 +115,9 @@ export class SimCatalogService extends BaseCatalogService {
}
async getAddons(): Promise<SimCatalogProduct[]> {
const cacheKey = this.catalogCache.buildCatalogKey("sim", "addons");
const cacheKey = this.catalogCache.buildServicesKey("sim", "addons");
return this.catalogCache.getCachedCatalog(
return this.catalogCache.getCachedServices(
cacheKey,
async () => {
const soql = this.buildProductQuery("SIM", "Add-on", [
@ -184,22 +184,28 @@ export class SimCatalogService extends BaseCatalogService {
return false;
}
// Check WHMCS for existing SIM services
const products = await this.whmcs.getClientsProducts({ clientid: mapping.whmcsClientId });
const services = (products?.products?.product || []) as Array<{
groupname?: string;
status?: string;
}>;
// Look for active SIM services
const hasActiveSim = services.some(
service =>
String(service.groupname || "")
.toLowerCase()
.includes("sim") && String(service.status || "").toLowerCase() === "active"
const cacheKey = this.catalogCache.buildServicesKey(
"sim",
"has-existing-sim",
String(mapping.whmcsClientId)
);
return hasActiveSim;
// This is per-account and can be somewhat expensive (WHMCS call).
// Cache briefly to reduce repeat reads during account page refreshes.
return await this.catalogCache.getCachedVolatile(cacheKey, async () => {
const products = await this.whmcs.getClientsProducts({ clientid: mapping.whmcsClientId });
const services = (products?.products?.product || []) as Array<{
groupname?: string;
status?: string;
}>;
// Look for active SIM services
return services.some(service => {
const group = String(service.groupname || "").toLowerCase();
const status = String(service.status || "").toLowerCase();
return group.includes("sim") && status === "active";
});
});
} catch (error) {
this.logger.warn(`Failed to check existing SIM for user ${userId}`, error);
return false; // Default to no existing SIM

View File

@ -0,0 +1,86 @@
import { Injectable, Inject } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
import { BaseServicesService } from "./base-services.service.js";
import { ServicesCacheService } from "./services-cache.service.js";
import type {
SalesforceProduct2WithPricebookEntries,
VpnCatalogProduct,
} from "@customer-portal/domain/services";
import { Providers as CatalogProviders } from "@customer-portal/domain/services";
@Injectable()
export class VpnServicesService extends BaseServicesService {
constructor(
sf: SalesforceConnection,
configService: ConfigService,
@Inject(Logger) logger: Logger,
private readonly catalogCache: ServicesCacheService
) {
super(sf, configService, logger);
}
async getPlans(): Promise<VpnCatalogProduct[]> {
const cacheKey = this.catalogCache.buildServicesKey("vpn", "plans");
return this.catalogCache.getCachedServices(
cacheKey,
async () => {
const soql = this.buildServicesQuery("VPN", ["VPN_Region__c", "Catalog_Order__c"]);
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql,
"VPN Plans"
);
return records.map(record => {
const entry = this.extractPricebookEntry(record);
const product = CatalogProviders.Salesforce.mapVpnProduct(record, entry);
return {
...product,
description: product.description || product.name,
} satisfies VpnCatalogProduct;
});
},
{
resolveDependencies: plans => ({
productIds: plans.map(plan => plan.id).filter((id): id is string => Boolean(id)),
}),
}
);
}
async getActivationFees(): Promise<VpnCatalogProduct[]> {
const cacheKey = this.catalogCache.buildServicesKey("vpn", "activation-fees");
return this.catalogCache.getCachedServices(
cacheKey,
async () => {
const soql = this.buildProductQuery("VPN", "Activation", ["VPN_Region__c"]);
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql,
"VPN Activation Fees"
);
return records.map(record => {
const pricebookEntry = this.extractPricebookEntry(record);
const product = CatalogProviders.Salesforce.mapVpnProduct(record, pricebookEntry);
return {
...product,
description: product.description ?? product.name,
} satisfies VpnCatalogProduct;
});
},
{
resolveDependencies: fees => ({
productIds: fees.map(fee => fee.id).filter((id): id is string => Boolean(id)),
}),
}
);
}
async getCatalogData() {
const [plans, activationFees] = await Promise.all([this.getPlans(), this.getActivationFees()]);
return { plans, activationFees };
}
}

View File

@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { InternetCancellationService } from "./services/internet-cancellation.service.js";
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js";
import { EmailModule } from "@bff/infra/email/email.module.js";
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
@Module({
imports: [WhmcsModule, MappingsModule, SalesforceModule, EmailModule, NotificationsModule],
providers: [InternetCancellationService],
exports: [InternetCancellationService],
})
export class InternetManagementModule {}

View File

@ -0,0 +1,332 @@
/**
* Internet Cancellation Service
*
* Handles Internet service cancellation flows:
* - Preview available cancellation months
* - Submit cancellation requests (creates SF Case + updates Opportunity)
*
* Internet cancellation differs from SIM in that:
* - No Freebit/MVNO API calls needed
* - Cancellation is processed via Salesforce Case workflow
* - Equipment return may be required (ONU, router)
*/
import { Injectable, Inject, BadRequestException, NotFoundException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
import { EmailService } from "@bff/infra/email/email.service.js";
import { NotificationService } from "@bff/modules/notifications/notifications.service.js";
import type {
InternetCancellationPreview,
InternetCancellationMonth,
InternetCancelRequest,
} from "@customer-portal/domain/subscriptions";
import {
type CancellationOpportunityData,
CANCELLATION_NOTICE,
LINE_RETURN_STATUS,
} from "@customer-portal/domain/opportunity";
import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications";
@Injectable()
export class InternetCancellationService {
constructor(
private readonly whmcsService: WhmcsService,
private readonly mappingsService: MappingsService,
private readonly caseService: SalesforceCaseService,
private readonly opportunityService: SalesforceOpportunityService,
private readonly emailService: EmailService,
private readonly notifications: NotificationService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Generate available cancellation months (next 12 months)
* Following the 25th rule: if before 25th, current month is available
*/
private generateCancellationMonths(): InternetCancellationMonth[] {
const months: InternetCancellationMonth[] = [];
const today = new Date();
const dayOfMonth = today.getDate();
// Start from current month if before 25th, otherwise next month
const startOffset = dayOfMonth <= 25 ? 0 : 1;
for (let i = startOffset; i < startOffset + 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const year = date.getFullYear();
const month = date.getMonth() + 1;
const monthStr = String(month).padStart(2, "0");
months.push({
value: `${year}-${monthStr}`,
label: date.toLocaleDateString("en-US", { month: "long", year: "numeric" }),
});
}
return months;
}
/**
* Validate that the subscription belongs to the user and is an Internet service
*/
private async validateInternetSubscription(
userId: string,
subscriptionId: number
): Promise<{
whmcsClientId: number;
sfAccountId: string;
subscription: {
id: number;
productName: string;
amount: number;
nextDue?: string;
registrationDate?: string;
};
}> {
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId || !mapping?.sfAccountId) {
throw new BadRequestException("Account mapping not found");
}
// Get subscription from WHMCS
const productsResponse = await this.whmcsService.getClientsProducts({
clientid: mapping.whmcsClientId,
});
const productContainer = productsResponse.products?.product;
const products = Array.isArray(productContainer)
? productContainer
: productContainer
? [productContainer]
: [];
const subscription = products.find(
(p: { id?: number | string }) => Number(p.id) === subscriptionId
);
if (!subscription) {
throw new NotFoundException("Subscription not found");
}
// Verify it's an Internet service
// Match: "Internet", "SonixNet via NTT Optical Fiber", or any NTT-based fiber service
const productName = String(subscription.name || subscription.groupname || "");
const lowerName = productName.toLowerCase();
const isInternetService =
lowerName.includes("internet") ||
lowerName.includes("sonixnet") ||
(lowerName.includes("ntt") && lowerName.includes("fiber"));
if (!isInternetService) {
throw new BadRequestException("This endpoint is only for Internet subscriptions");
}
return {
whmcsClientId: mapping.whmcsClientId,
sfAccountId: mapping.sfAccountId,
subscription: {
id: Number(subscription.id),
productName: productName,
amount: parseFloat(String(subscription.amount || subscription.recurringamount || 0)),
nextDue: String(subscription.nextduedate || ""),
registrationDate: String(subscription.regdate || ""),
},
};
}
/**
* Get cancellation preview with available months and service details
*/
async getCancellationPreview(
userId: string,
subscriptionId: number
): Promise<InternetCancellationPreview> {
const { whmcsClientId, subscription } = await this.validateInternetSubscription(
userId,
subscriptionId
);
// Get customer info from WHMCS
const clientDetails = await this.whmcsService.getClientDetails(whmcsClientId);
const customerName =
`${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
const customerEmail = clientDetails.email || "";
return {
productName: subscription.productName,
billingAmount: subscription.amount,
nextDueDate: subscription.nextDue,
registrationDate: subscription.registrationDate,
availableMonths: this.generateCancellationMonths(),
customerEmail,
customerName,
};
}
/**
* Submit Internet cancellation request
*
* Creates a Salesforce Case and updates the Opportunity (if found)
*/
async submitCancellation(
userId: string,
subscriptionId: number,
request: InternetCancelRequest
): Promise<void> {
const { whmcsClientId, sfAccountId, subscription } = await this.validateInternetSubscription(
userId,
subscriptionId
);
// Validate confirmations
if (!request.confirmRead || !request.confirmCancel) {
throw new BadRequestException("You must confirm both checkboxes to proceed");
}
// Parse cancellation month and calculate end date
const [year, month] = request.cancellationMonth.split("-").map(Number);
if (!year || !month) {
throw new BadRequestException("Invalid cancellation month format");
}
// Cancellation date is end of selected month
const lastDayOfMonth = new Date(year, month, 0);
// Use local date components to avoid timezone shifts when converting to string
const cancellationDate = [
lastDayOfMonth.getFullYear(),
String(lastDayOfMonth.getMonth() + 1).padStart(2, "0"),
String(lastDayOfMonth.getDate()).padStart(2, "0"),
].join("-");
this.logger.log("Processing Internet cancellation request", {
userId,
subscriptionId,
cancellationMonth: request.cancellationMonth,
cancellationDate,
});
// Get customer info for notifications
const clientDetails = await this.whmcsService.getClientDetails(whmcsClientId);
const customerName =
`${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
const customerEmail = clientDetails.email || "";
// Find existing Opportunity for this subscription (by WHMCS Service ID)
let opportunityId: string | null = null;
try {
opportunityId = await this.opportunityService.findOpportunityByWhmcsServiceId(subscriptionId);
} catch {
// Opportunity lookup failure is not fatal - we'll create Case without link
this.logger.warn("Could not find Opportunity for subscription", { subscriptionId });
}
// Create Salesforce Case for cancellation
const caseId = await this.caseService.createCancellationCase({
accountId: sfAccountId,
opportunityId: opportunityId || undefined,
whmcsServiceId: subscriptionId,
productType: "Internet",
cancellationMonth: request.cancellationMonth,
cancellationDate,
alternativeEmail: request.alternativeEmail || undefined,
comments: request.comments,
});
this.logger.log("Cancellation case created", {
caseId,
opportunityId,
});
try {
await this.notifications.createNotification({
userId,
type: NOTIFICATION_TYPE.CANCELLATION_SCHEDULED,
source: NOTIFICATION_SOURCE.SYSTEM,
sourceId: caseId,
actionUrl: `/account/services/${subscriptionId}`,
});
} catch (error) {
this.logger.warn("Failed to create cancellation notification", {
userId,
subscriptionId,
caseId,
error: error instanceof Error ? error.message : String(error),
});
}
// Update Opportunity if found
if (opportunityId) {
try {
const cancellationData: CancellationOpportunityData = {
scheduledCancellationDate: `${cancellationDate}T23:59:59.000Z`,
cancellationNotice: CANCELLATION_NOTICE.RECEIVED,
lineReturnStatus: LINE_RETURN_STATUS.NOT_YET,
};
await this.opportunityService.updateCancellationData(opportunityId, cancellationData);
this.logger.log("Opportunity updated with cancellation data", {
opportunityId,
scheduledDate: cancellationDate,
});
} catch (error) {
// Log but don't fail - Case was already created
this.logger.error("Failed to update Opportunity cancellation data", {
error: error instanceof Error ? error.message : "Unknown error",
opportunityId,
});
}
}
// Send confirmation email to customer
const confirmationSubject = "SonixNet Internet Cancellation Confirmation";
const confirmationBody = `Dear ${customerName},
Your cancellation request for your Internet service has been received.
Service: ${subscription.productName}
Cancellation effective: End of ${request.cancellationMonth}
Our team will contact you regarding equipment return (ONU/router) if applicable.
If you have any questions, please contact us at info@asolutions.co.jp
With best regards,
Assist Solutions Customer Support
TEL: 0120-660-470 (Mon-Fri / 10AM-6PM)
Email: info@asolutions.co.jp`;
try {
await this.emailService.sendEmail({
to: customerEmail,
subject: confirmationSubject,
text: confirmationBody,
});
// Send to alternative email if provided
if (request.alternativeEmail && request.alternativeEmail !== customerEmail) {
await this.emailService.sendEmail({
to: request.alternativeEmail,
subject: confirmationSubject,
text: confirmationBody,
});
}
} catch (error) {
// Log but don't fail - Case was already created
this.logger.error("Failed to send cancellation confirmation email", {
error: error instanceof Error ? error.message : "Unknown error",
customerEmail,
});
}
this.logger.log("Internet cancellation request processed successfully", {
userId,
subscriptionId,
caseId,
cancellationMonth: request.cancellationMonth,
});
}
}

View File

@ -9,6 +9,8 @@ import type { SimCancelRequest, SimCancelFullRequest } from "@customer-portal/do
import { SimScheduleService } from "./sim-schedule.service.js";
import { SimActionRunnerService } from "./sim-action-runner.service.js";
import { SimApiNotificationService } from "./sim-api-notification.service.js";
import { NotificationService } from "@bff/modules/notifications/notifications.service.js";
import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications";
export interface CancellationMonth {
value: string; // YYYY-MM format
@ -38,6 +40,7 @@ export class SimCancellationService {
private readonly simSchedule: SimScheduleService,
private readonly simActionRunner: SimActionRunnerService,
private readonly apiNotification: SimApiNotificationService,
private readonly notifications: NotificationService,
private readonly configService: ConfigService,
@Inject(Logger) private readonly logger: Logger
) {}
@ -254,6 +257,24 @@ export class SimCancellationService {
runDate,
});
try {
await this.notifications.createNotification({
userId,
type: NOTIFICATION_TYPE.CANCELLATION_SCHEDULED,
source: NOTIFICATION_SOURCE.SYSTEM,
sourceId: `sim:${subscriptionId}:${runDate}`,
actionUrl: `/account/services/${subscriptionId}`,
});
} catch (error) {
this.logger.warn("Failed to create SIM cancellation notification", {
userId,
subscriptionId,
account,
runDate,
error: error instanceof Error ? error.message : String(error),
});
}
// Send admin notification email
const adminEmailBody = this.apiNotification.buildCancellationAdminEmail({
customerName,

View File

@ -12,8 +12,8 @@ import { SimScheduleService } from "./sim-schedule.service.js";
import { SimActionRunnerService } from "./sim-action-runner.service.js";
import { SimManagementQueueService } from "../queue/sim-management.queue.js";
import { SimApiNotificationService } from "./sim-api-notification.service.js";
import { SimCatalogService } from "@bff/modules/catalog/services/sim-catalog.service.js";
import type { SimCatalogProduct } from "@customer-portal/domain/catalog";
import { SimServicesService } from "@bff/modules/services/services/sim-services.service.js";
import type { SimCatalogProduct } from "@customer-portal/domain/services";
// Mapping from Salesforce SKU to Freebit plan code
const SKU_TO_FREEBIT_PLAN_CODE: Record<string, string> = {
@ -47,7 +47,7 @@ export class SimPlanService {
private readonly simActionRunner: SimActionRunnerService,
private readonly simQueue: SimManagementQueueService,
private readonly apiNotification: SimApiNotificationService,
private readonly simCatalog: SimCatalogService,
private readonly simCatalog: SimServicesService,
private readonly configService: ConfigService,
@Inject(Logger) private readonly logger: Logger
) {}

View File

@ -27,7 +27,8 @@ import { SimManagementQueueService } from "./queue/sim-management.queue.js";
import { SimManagementProcessor } from "./queue/sim-management.processor.js";
import { SimVoiceOptionsService } from "./services/sim-voice-options.service.js";
import { SimCallHistoryService } from "./services/sim-call-history.service.js";
import { CatalogModule } from "@bff/modules/catalog/catalog.module.js";
import { ServicesModule } from "@bff/modules/services/services.module.js";
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
@Module({
imports: [
@ -36,8 +37,9 @@ import { CatalogModule } from "@bff/modules/catalog/catalog.module.js";
SalesforceModule,
MappingsModule,
EmailModule,
CatalogModule,
ServicesModule,
SftpModule,
NotificationsModule,
],
providers: [
// Core services that the SIM services depend on

View File

@ -51,6 +51,12 @@ import {
type ReissueSimRequest,
} from "./sim-management/services/esim-management.service.js";
import { SimCallHistoryService } from "./sim-management/services/sim-call-history.service.js";
import { InternetCancellationService } from "./internet-management/services/internet-cancellation.service.js";
import {
internetCancelRequestSchema,
type InternetCancelRequest,
type SimActionResponse as SubscriptionActionResponse,
} from "@customer-portal/domain/subscriptions";
const subscriptionInvoiceQuerySchema = createPaginationSchema({
defaultLimit: 10,
@ -68,7 +74,8 @@ export class SubscriptionsController {
private readonly simPlanService: SimPlanService,
private readonly simCancellationService: SimCancellationService,
private readonly esimManagementService: EsimManagementService,
private readonly simCallHistoryService: SimCallHistoryService
private readonly simCallHistoryService: SimCallHistoryService,
private readonly internetCancellationService: InternetCancellationService
) {}
@Get()
@ -377,6 +384,41 @@ export class SubscriptionsController {
}
}
// ==================== Internet Management Endpoints ====================
/**
* Get Internet cancellation preview (available months, service details)
*/
@Get(":id/internet/cancellation-preview")
@Header("Cache-Control", "private, max-age=60")
async getInternetCancellationPreview(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number
) {
const preview = await this.internetCancellationService.getCancellationPreview(
req.user.id,
subscriptionId
);
return { success: true, data: preview };
}
/**
* Submit Internet cancellation request
*/
@Post(":id/internet/cancel")
@UsePipes(new ZodValidationPipe(internetCancelRequestSchema))
async cancelInternet(
@Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number,
@Body() body: InternetCancelRequest
): Promise<SubscriptionActionResponse> {
await this.internetCancellationService.submitCancellation(req.user.id, subscriptionId, body);
return {
success: true,
message: `Internet cancellation scheduled for end of ${body.cancellationMonth}`,
};
}
// ==================== Call/SMS History Endpoints ====================
/**

View File

@ -10,9 +10,17 @@ import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { FreebitModule } from "@bff/integrations/freebit/freebit.module.js";
import { EmailModule } from "@bff/infra/email/email.module.js";
import { SimManagementModule } from "./sim-management/sim-management.module.js";
import { InternetManagementModule } from "./internet-management/internet-management.module.js";
@Module({
imports: [WhmcsModule, MappingsModule, FreebitModule, EmailModule, SimManagementModule],
imports: [
WhmcsModule,
MappingsModule,
FreebitModule,
EmailModule,
SimManagementModule,
InternetManagementModule,
],
controllers: [SubscriptionsController, SimOrdersController],
providers: [
SubscriptionsService,

View File

@ -12,6 +12,7 @@ import type {
} from "@customer-portal/domain/subscriptions";
import type { Invoice, InvoiceItem, InvoiceList } from "@customer-portal/domain/billing";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
import { WhmcsCacheService } from "@bff/integrations/whmcs/cache/whmcs-cache.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { Logger } from "nestjs-pino";
import type { Providers } from "@customer-portal/domain/subscriptions";
@ -26,6 +27,7 @@ export interface GetSubscriptionsOptions {
export class SubscriptionsService {
constructor(
private readonly whmcsService: WhmcsService,
private readonly cacheService: WhmcsCacheService,
private readonly mappingsService: MappingsService,
@Inject(Logger) private readonly logger: Logger
) {}
@ -316,6 +318,20 @@ export class SubscriptionsService {
const batchSize = Math.min(100, Math.max(limit, 25));
try {
// Try cache first
const cached = await this.cacheService.getSubscriptionInvoices(
userId,
subscriptionId,
page,
limit
);
if (cached) {
this.logger.debug(
`Cache hit for subscription invoices: user ${userId}, subscription ${subscriptionId}`
);
return cached;
}
// Validate subscription exists and belongs to user
await this.getSubscriptionById(userId, subscriptionId);
@ -380,6 +396,9 @@ export class SubscriptionsService {
}
);
// Cache the result
await this.cacheService.setSubscriptionInvoices(userId, subscriptionId, page, limit, result);
return result;
} catch (error) {
this.logger.error(`Failed to get invoices for subscription ${subscriptionId}`, {

View File

@ -1,6 +1,20 @@
import { Controller, Get, Post, Query, Param, Body, Request } from "@nestjs/common";
import {
Controller,
Get,
Post,
Query,
Param,
Body,
Request,
Inject,
UseGuards,
} from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { SupportService } from "./support.service.js";
import { ZodValidationPipe } from "nestjs-zod";
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js";
import { z } from "zod";
import {
supportCaseFilterSchema,
createCaseRequestSchema,
@ -12,9 +26,23 @@ import {
} from "@customer-portal/domain/support";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
// Public contact form schema
const publicContactSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Valid email required"),
phone: z.string().optional(),
subject: z.string().min(1, "Subject is required"),
message: z.string().min(10, "Message must be at least 10 characters"),
});
type PublicContactRequest = z.infer<typeof publicContactSchema>;
@Controller("support")
export class SupportController {
constructor(private readonly supportService: SupportService) {}
constructor(
private readonly supportService: SupportService,
@Inject(Logger) private readonly logger: Logger
) {}
@Get("cases")
async listCases(
@ -41,4 +69,36 @@ export class SupportController {
): Promise<CreateCaseResponse> {
return this.supportService.createCase(req.user.id, body);
}
/**
* Public contact form endpoint
*
* Creates a Lead or Case in Salesforce for unauthenticated users.
* Rate limited to prevent spam.
*/
@Post("contact")
@Public()
@UseGuards(RateLimitGuard)
@RateLimit({ limit: 5, ttl: 300 }) // 5 requests per 5 minutes
async publicContact(
@Body(new ZodValidationPipe(publicContactSchema))
body: PublicContactRequest
): Promise<{ success: boolean; message: string }> {
this.logger.log("Public contact form submission", { email: body.email });
try {
await this.supportService.createPublicContactRequest(body);
return {
success: true,
message: "Your message has been received. We will get back to you within 24 hours.",
};
} catch (error) {
this.logger.error("Failed to process public contact form", {
error: error instanceof Error ? error.message : String(error),
email: body.email,
});
throw error;
}
}
}

View File

@ -129,6 +129,44 @@ export class SupportService {
}
}
/**
* Create a contact request from public form (no authentication required)
* Creates a Web-to-Case in Salesforce or sends an email notification
*/
async createPublicContactRequest(request: {
name: string;
email: string;
phone?: string;
subject: string;
message: string;
}): Promise<void> {
this.logger.log("Creating public contact request", { email: request.email });
try {
// Create a case without account association (Web-to-Case style)
await this.caseService.createWebCase({
subject: request.subject,
description: `Contact from: ${request.name}\nEmail: ${request.email}\nPhone: ${request.phone || "Not provided"}\n\n${request.message}`,
suppliedEmail: request.email,
suppliedName: request.name,
suppliedPhone: request.phone,
origin: "Web",
priority: "Medium",
});
this.logger.log("Public contact request created successfully", {
email: request.email,
});
} catch (error) {
this.logger.error("Failed to create public contact request", {
error: getErrorMessage(error),
email: request.email,
});
// Don't throw - we don't want to expose internal errors to public users
// In production, this should send a fallback email notification
}
}
/**
* Get Salesforce account ID for a user
*/

View File

@ -204,9 +204,13 @@ export class UserProfileService {
return summary;
}
const [subscriptionsData, invoicesData] = await Promise.allSettled([
const [subscriptionsData, invoicesData, unpaidInvoicesData] = await Promise.allSettled([
this.whmcsService.getSubscriptions(mapping.whmcsClientId, userId),
this.whmcsService.getInvoices(mapping.whmcsClientId, userId, { limit: 50 }),
this.whmcsService.getInvoices(mapping.whmcsClientId, userId, { limit: 10 }),
this.whmcsService.getInvoices(mapping.whmcsClientId, userId, {
status: "Unpaid",
limit: 1,
}),
]);
let activeSubscriptions = 0;
@ -256,12 +260,25 @@ export class UserProfileService {
paidDate?: string;
currency?: string | null;
}> = [];
// Process unpaid invoices count
if (unpaidInvoicesData.status === "fulfilled") {
unpaidInvoices = unpaidInvoicesData.value.pagination.totalItems;
} else {
this.logger.error(`Failed to fetch unpaid invoices count for user ${userId}`, {
reason: getErrorMessage(unpaidInvoicesData.reason),
});
}
if (invoicesData.status === "fulfilled") {
const invoices: Invoice[] = invoicesData.value.invoices;
unpaidInvoices = invoices.filter(
inv => inv.status === "Unpaid" || inv.status === "Overdue"
).length;
// Fallback if unpaid invoices call failed, though inaccurate for total count > 10
if (unpaidInvoicesData.status === "rejected") {
unpaidInvoices = invoices.filter(
inv => inv.status === "Unpaid" || inv.status === "Overdue"
).length;
}
const upcomingInvoices = invoices
.filter(inv => (inv.status === "Unpaid" || inv.status === "Overdue") && inv.dueDate)

View File

@ -0,0 +1,71 @@
import {
BadRequestException,
Controller,
Get,
Post,
Req,
UseGuards,
UseInterceptors,
UploadedFile,
} from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js";
import { ResidenceCardService } from "./residence-card.service.js";
import type { ResidenceCardVerification } from "@customer-portal/domain/customer";
const MAX_FILE_BYTES = 5 * 1024 * 1024; // 5MB
const ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/png", "application/pdf"]);
type UploadedResidenceCard = {
originalname: string;
mimetype: string;
size: number;
buffer: Buffer;
};
@Controller("verification/residence-card")
@UseGuards(RateLimitGuard)
export class ResidenceCardController {
constructor(private readonly residenceCards: ResidenceCardService) {}
@Get()
@RateLimit({ limit: 60, ttl: 60 })
async getStatus(@Req() req: RequestWithUser): Promise<ResidenceCardVerification> {
return this.residenceCards.getStatusForUser(req.user.id);
}
@Post()
@RateLimit({ limit: 3, ttl: 300 })
@UseInterceptors(
FileInterceptor("file", {
limits: { fileSize: MAX_FILE_BYTES },
fileFilter: (_req, file, cb) => {
if (!ALLOWED_MIME_TYPES.has(file.mimetype)) {
cb(
new BadRequestException("Unsupported file type. Please upload a JPG, PNG, or PDF."),
false
);
return;
}
cb(null, true);
},
})
)
async submit(
@Req() req: RequestWithUser,
@UploadedFile() file?: UploadedResidenceCard
): Promise<ResidenceCardVerification> {
if (!file) {
throw new BadRequestException("Missing file upload.");
}
return this.residenceCards.submitForUser({
userId: req.user.id,
filename: file.originalname || "residence-card",
mimeType: file.mimetype,
sizeBytes: file.size,
content: file.buffer as unknown as Uint8Array<ArrayBuffer>,
});
}
}

View File

@ -0,0 +1,281 @@
import { Injectable, Inject } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import {
assertSalesforceId,
assertSoqlFieldName,
} from "@bff/integrations/salesforce/utils/soql.util.js";
import type { SalesforceResponse } from "@customer-portal/domain/common";
import {
residenceCardVerificationSchema,
type ResidenceCardVerification,
type ResidenceCardVerificationStatus,
} from "@customer-portal/domain/customer";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { basename, extname } from "node:path";
function mapFileTypeToMime(fileType?: string | null): string | null {
const normalized = String(fileType || "")
.trim()
.toLowerCase();
if (normalized === "pdf") return "application/pdf";
if (normalized === "png") return "image/png";
if (normalized === "jpg" || normalized === "jpeg") return "image/jpeg";
return null;
}
@Injectable()
export class ResidenceCardService {
constructor(
private readonly sf: SalesforceConnection,
private readonly mappings: MappingsService,
private readonly config: ConfigService,
@Inject(Logger) private readonly logger: Logger
) {}
async getStatusForUser(userId: string): Promise<ResidenceCardVerification> {
const mapping = await this.mappings.findByUserId(userId);
const sfAccountId = mapping?.sfAccountId
? assertSalesforceId(mapping.sfAccountId, "sfAccountId")
: null;
if (!sfAccountId) {
return residenceCardVerificationSchema.parse({
status: "not_submitted",
filename: null,
mimeType: null,
sizeBytes: null,
submittedAt: null,
reviewedAt: null,
reviewerNotes: null,
});
}
const fields = this.getAccountFieldNames();
const soql = `
SELECT Id, ${fields.status}, ${fields.submittedAt}, ${fields.verifiedAt}, ${fields.note}, ${fields.rejectionMessage}
FROM Account
WHERE Id = '${sfAccountId}'
LIMIT 1
`;
const accountRes = (await this.sf.query(soql, {
label: "verification:residence_card:account",
})) as SalesforceResponse<Record<string, unknown>>;
const account = (accountRes.records?.[0] as Record<string, unknown> | undefined) ?? undefined;
const statusRaw = account ? account[fields.status] : undefined;
const statusText = typeof statusRaw === "string" ? statusRaw.trim().toLowerCase() : "";
const status: ResidenceCardVerificationStatus =
statusText === "verified"
? "verified"
: statusText === "rejected"
? "rejected"
: statusText === "submitted"
? "pending"
: statusText === "not submitted" || statusText === "not_submitted" || statusText === ""
? "not_submitted"
: "pending";
const submittedAtRaw = account ? account[fields.submittedAt] : undefined;
const verifiedAtRaw = account ? account[fields.verifiedAt] : undefined;
const noteRaw = account ? account[fields.note] : undefined;
const rejectionRaw = account ? account[fields.rejectionMessage] : undefined;
const submittedAt =
typeof submittedAtRaw === "string"
? submittedAtRaw
: submittedAtRaw instanceof Date
? submittedAtRaw.toISOString()
: null;
const reviewedAt =
typeof verifiedAtRaw === "string"
? verifiedAtRaw
: verifiedAtRaw instanceof Date
? verifiedAtRaw.toISOString()
: null;
const reviewerNotes =
typeof rejectionRaw === "string" && rejectionRaw.trim().length > 0
? rejectionRaw.trim()
: typeof noteRaw === "string" && noteRaw.trim().length > 0
? noteRaw.trim()
: null;
const fileMeta =
status === "not_submitted" ? null : await this.getLatestAccountFileMetadata(sfAccountId);
return residenceCardVerificationSchema.parse({
status,
filename: fileMeta?.filename ?? null,
mimeType: fileMeta?.mimeType ?? null,
sizeBytes: typeof fileMeta?.sizeBytes === "number" ? fileMeta.sizeBytes : null,
submittedAt: submittedAt ?? fileMeta?.submittedAt ?? null,
reviewedAt,
reviewerNotes,
});
}
async submitForUser(params: {
userId: string;
filename: string;
mimeType: string;
sizeBytes: number;
content: Uint8Array<ArrayBuffer>;
}): Promise<ResidenceCardVerification> {
const mapping = await this.mappings.findByUserId(params.userId);
if (!mapping?.sfAccountId) {
throw new Error("No Salesforce mapping found for current user");
}
const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId");
const fileBuffer = Buffer.from(params.content as unknown as Uint8Array);
const versionData = fileBuffer.toString("base64");
const extension = extname(params.filename || "").replace(/^\./, "");
const title = basename(params.filename || "residence-card", extension ? `.${extension}` : "");
const create = this.sf.sobject("ContentVersion")?.create;
if (!create) {
throw new Error("Salesforce ContentVersion create method not available");
}
try {
const result = await create({
Title: title || "residence-card",
PathOnClient: params.filename || "residence-card",
VersionData: versionData,
FirstPublishLocationId: sfAccountId,
});
const id = (result as { id?: unknown })?.id;
if (typeof id !== "string" || id.trim().length === 0) {
throw new Error("Salesforce did not return a ContentVersion id");
}
} catch (error) {
this.logger.error("Failed to upload residence card to Salesforce Files", {
userId: params.userId,
sfAccountIdTail: sfAccountId.slice(-4),
error: getErrorMessage(error),
});
throw new Error("Failed to submit residence card. Please try again later.");
}
const fields = this.getAccountFieldNames();
const update = this.sf.sobject("Account")?.update;
if (!update) {
throw new Error("Salesforce Account update method not available");
}
await update({
Id: sfAccountId,
[fields.status]: "Submitted",
[fields.submittedAt]: new Date().toISOString(),
[fields.rejectionMessage]: null,
[fields.note]: null,
});
return this.getStatusForUser(params.userId);
}
private getAccountFieldNames(): {
status: string;
submittedAt: string;
verifiedAt: string;
note: string;
rejectionMessage: string;
} {
return {
status: assertSoqlFieldName(
this.config.get<string>("ACCOUNT_ID_VERIFICATION_STATUS_FIELD") ??
"Id_Verification_Status__c",
"ACCOUNT_ID_VERIFICATION_STATUS_FIELD"
),
submittedAt: assertSoqlFieldName(
this.config.get<string>("ACCOUNT_ID_VERIFICATION_SUBMITTED_AT_FIELD") ??
"Id_Verification_Submitted_Date_Time__c",
"ACCOUNT_ID_VERIFICATION_SUBMITTED_AT_FIELD"
),
verifiedAt: assertSoqlFieldName(
this.config.get<string>("ACCOUNT_ID_VERIFICATION_VERIFIED_AT_FIELD") ??
"Id_Verification_Verified_Date_Time__c",
"ACCOUNT_ID_VERIFICATION_VERIFIED_AT_FIELD"
),
note: assertSoqlFieldName(
this.config.get<string>("ACCOUNT_ID_VERIFICATION_NOTE_FIELD") ?? "Id_Verification_Note__c",
"ACCOUNT_ID_VERIFICATION_NOTE_FIELD"
),
rejectionMessage: assertSoqlFieldName(
this.config.get<string>("ACCOUNT_ID_VERIFICATION_REJECTION_MESSAGE_FIELD") ??
"Id_Verification_Rejection_Message__c",
"ACCOUNT_ID_VERIFICATION_REJECTION_MESSAGE_FIELD"
),
};
}
private async getLatestAccountFileMetadata(accountId: string): Promise<{
filename: string | null;
mimeType: string | null;
sizeBytes: number | null;
submittedAt: string | null;
} | null> {
try {
const linkSoql = `
SELECT ContentDocumentId
FROM ContentDocumentLink
WHERE LinkedEntityId = '${accountId}'
ORDER BY SystemModstamp DESC
LIMIT 1
`;
const linkRes = (await this.sf.query(linkSoql, {
label: "verification:residence_card:latest_link",
})) as SalesforceResponse<{ ContentDocumentId?: string }>;
const documentId = linkRes.records?.[0]?.ContentDocumentId;
if (!documentId) return null;
const versionSoql = `
SELECT Title, FileExtension, FileType, ContentSize, CreatedDate
FROM ContentVersion
WHERE ContentDocumentId = '${documentId}'
ORDER BY CreatedDate DESC
LIMIT 1
`;
const versionRes = (await this.sf.query(versionSoql, {
label: "verification:residence_card:latest_version",
})) as SalesforceResponse<Record<string, unknown>>;
const version = (versionRes.records?.[0] as Record<string, unknown> | undefined) ?? undefined;
if (!version) return null;
const title = typeof version.Title === "string" ? version.Title.trim() : "";
const ext = typeof version.FileExtension === "string" ? version.FileExtension.trim() : "";
const fileType = typeof version.FileType === "string" ? version.FileType.trim() : "";
const sizeBytes = typeof version.ContentSize === "number" ? version.ContentSize : null;
const createdDateRaw = version.CreatedDate;
const submittedAt =
typeof createdDateRaw === "string"
? createdDateRaw
: createdDateRaw instanceof Date
? createdDateRaw.toISOString()
: null;
const filename = title
? ext && !title.toLowerCase().endsWith(`.${ext.toLowerCase()}`)
? `${title}.${ext}`
: title
: null;
return {
filename,
mimeType: mapFileTypeToMime(fileType) ?? mapFileTypeToMime(ext) ?? null,
sizeBytes,
submittedAt,
};
} catch (error) {
this.logger.warn("Failed to load residence card file metadata from Salesforce", {
accountIdTail: accountId.slice(-4),
error: getErrorMessage(error),
});
return null;
}
}
}

View File

@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { ResidenceCardController } from "./residence-card.controller.js";
import { ResidenceCardService } from "./residence-card.service.js";
import { IntegrationsModule } from "@bff/integrations/integrations.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { CoreConfigModule } from "@bff/core/config/config.module.js";
@Module({
imports: [IntegrationsModule, MappingsModule, CoreConfigModule],
controllers: [ResidenceCardController],
providers: [ResidenceCardService],
exports: [ResidenceCardService],
})
export class VerificationModule {}

View File

@ -6,6 +6,6 @@
"rootDir": "./src",
"sourceMap": true
},
"include": ["src/**/*"],
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts", "**/*.test.ts"]
}

View File

@ -15,6 +15,6 @@
"noEmit": true,
"types": ["node"]
},
"include": ["src/**/*"],
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "prisma", "test", "**/*.spec.ts", "**/*.test.ts"]
}

View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -1,4 +1,3 @@
/* eslint-env node */
import path from "node:path";
import { fileURLToPath } from "node:url";

View File

@ -5,8 +5,8 @@
"scripts": {
"predev": "node ./scripts/dev-prep.mjs",
"dev": "next dev -p ${NEXT_PORT:-3000}",
"build": "next build",
"build:webpack": "next build --webpack",
"build": "next build --webpack",
"build:turbo": "next build",
"build:analyze": "ANALYZE=true next build",
"analyze": "pnpm run build:analyze",
"start": "next start -p ${NEXT_PORT:-3000}",
@ -24,6 +24,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.562.0",
"next": "16.0.10",
"react": "19.2.1",
"react-dom": "19.2.1",

View File

@ -1,6 +1,4 @@
#!/usr/bin/env node
/* eslint-env node */
/**
* Bundle size monitoring script
* Analyzes bundle size and reports on performance metrics

View File

@ -1,6 +1,4 @@
#!/usr/bin/env node
/* eslint-env node */
// Ensure dev-time Next.js manifests exist to avoid noisy ENOENT errors
import { mkdirSync, existsSync, writeFileSync, rmSync } from "fs";
import { join } from "path";

Some files were not shown because too many files have changed in this diff Show More