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 - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with:
version: 10.25.0
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4

View File

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

View File

@ -30,8 +30,6 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with:
version: "10.25.0"
- name: Get pnpm store directory - name: Get pnpm store directory
id: pnpm-cache id: pnpm-cache
@ -139,8 +137,6 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with:
version: "10.25.0"
- name: Check for outdated dependencies - name: Check for outdated dependencies
run: | 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 pnpm lint-staged
# Run type check
pnpm type-check pnpm type-check
# Security audit is enforced in CI (`.github/workflows/security.yml`). # 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"] "*.{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/signup` - Create portal user → WHMCS AddClient → SF upsert
- `POST /api/auth/login` - Portal authentication - `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 - `POST /api/auth/set-password` - Required after WHMCS link
### User Management ### User Management
@ -292,7 +292,7 @@ When running `pnpm dev:tools`, you get access to:
### Catalog & Orders ### 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 - `POST /api/orders` - WHMCS AddOrder with idempotency
### Invoices ### Invoices
@ -481,7 +481,7 @@ rm -rf node_modules && pnpm install
- **[Deployment Guide](docs/DEPLOY.md)** - Production deployment instructions - **[Deployment Guide](docs/DEPLOY.md)** - Production deployment instructions
- **[Architecture](docs/STRUCTURE.md)** - Code organization and conventions - **[Architecture](docs/STRUCTURE.md)** - Code organization and conventions
- **[Logging](docs/LOGGING.md)** - Logging configuration and best practices - **[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 ## Contributing

View File

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

View File

@ -38,6 +38,7 @@
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.9", "@nestjs/core": "^11.1.9",
"@nestjs/platform-express": "^11.1.9", "@nestjs/platform-express": "^11.1.9",
"@nestjs/schedule": "^6.1.0",
"@prisma/adapter-pg": "^7.1.0", "@prisma/adapter-pg": "^7.1.0",
"@prisma/client": "^7.1.0", "@prisma/client": "^7.1.0",
"@sendgrid/mail": "^8.1.6", "@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") updatedAt DateTime @updatedAt @map("updated_at")
auditLogs AuditLog[] auditLogs AuditLog[]
idMapping IdMapping? idMapping IdMapping?
residenceCardSubmission ResidenceCardSubmission?
notifications Notification[]
@@map("users") @@map("users")
} }
@ -91,6 +93,30 @@ enum AuditAction {
SYSTEM_MAINTENANCE 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 // Per-SIM daily usage snapshot used to build full-month charts
model SimUsageDaily { model SimUsageDaily {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
@ -191,3 +217,63 @@ model SimHistoryImport {
@@map("sim_history_imports") @@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 { APP_PIPE } from "@nestjs/core";
import { RouterModule } from "@nestjs/core"; import { RouterModule } from "@nestjs/core";
import { ConfigModule } from "@nestjs/config"; import { ConfigModule } from "@nestjs/config";
import { ScheduleModule } from "@nestjs/schedule";
import { ZodValidationPipe } from "nestjs-zod"; import { ZodValidationPipe } from "nestjs-zod";
// Configuration // Configuration
@ -27,14 +28,17 @@ import { SalesforceEventsModule } from "@bff/integrations/salesforce/events/even
// Feature Modules // Feature Modules
import { AuthModule } from "@bff/modules/auth/auth.module.js"; import { AuthModule } from "@bff/modules/auth/auth.module.js";
import { UsersModule } from "@bff/modules/users/users.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 { 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 { OrdersModule } from "@bff/modules/orders/orders.module.js";
import { InvoicesModule } from "@bff/modules/invoices/invoices.module.js"; import { InvoicesModule } from "@bff/modules/invoices/invoices.module.js";
import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.module.js"; import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.module.js";
import { CurrencyModule } from "@bff/modules/currency/currency.module.js"; import { CurrencyModule } from "@bff/modules/currency/currency.module.js";
import { SupportModule } from "@bff/modules/support/support.module.js"; import { SupportModule } from "@bff/modules/support/support.module.js";
import { RealtimeApiModule } from "@bff/modules/realtime/realtime.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 // System Modules
import { HealthModule } from "@bff/modules/health/health.module.js"; import { HealthModule } from "@bff/modules/health/health.module.js";
@ -55,6 +59,7 @@ import { HealthModule } from "@bff/modules/health/health.module.js";
imports: [ imports: [
// === CONFIGURATION === // === CONFIGURATION ===
ConfigModule.forRoot(appConfig), ConfigModule.forRoot(appConfig),
ScheduleModule.forRoot(),
// === INFRASTRUCTURE === // === INFRASTRUCTURE ===
LoggingModule, LoggingModule,
@ -77,14 +82,17 @@ import { HealthModule } from "@bff/modules/health/health.module.js";
// === FEATURE MODULES === // === FEATURE MODULES ===
AuthModule, AuthModule,
UsersModule, UsersModule,
MeStatusModule,
MappingsModule, MappingsModule,
CatalogModule, ServicesModule,
OrdersModule, OrdersModule,
InvoicesModule, InvoicesModule,
SubscriptionsModule, SubscriptionsModule,
CurrencyModule, CurrencyModule,
SupportModule, SupportModule,
RealtimeApiModule, RealtimeApiModule,
VerificationModule,
NotificationsModule,
// === SYSTEM MODULES === // === SYSTEM MODULES ===
HealthModule, HealthModule,

View File

@ -54,6 +54,20 @@ export const envSchema = z.object({
"Authentication service is temporarily unavailable for maintenance. Please try again later." "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(), DATABASE_URL: z.string().url(),
WHMCS_BASE_URL: z.string().url().optional(), WHMCS_BASE_URL: z.string().url().optional(),
@ -132,6 +146,25 @@ export const envSchema = z.object({
// Salesforce Field Mappings - Account // Salesforce Field Mappings - Account
ACCOUNT_INTERNET_ELIGIBILITY_FIELD: z.string().default("Internet_Eligibility__c"), 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"), ACCOUNT_CUSTOMER_NUMBER_FIELD: z.string().default("SF_Account_No__c"),
// Salesforce Field Mappings - Product // Salesforce Field Mappings - Product

View File

@ -1,8 +1,9 @@
import type { Routes } from "@nestjs/core"; import type { Routes } from "@nestjs/core";
import { AuthModule } from "@bff/modules/auth/auth.module.js"; import { AuthModule } from "@bff/modules/auth/auth.module.js";
import { UsersModule } from "@bff/modules/users/users.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 { 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 { OrdersModule } from "@bff/modules/orders/orders.module.js";
import { InvoicesModule } from "@bff/modules/invoices/invoices.module.js"; import { InvoicesModule } from "@bff/modules/invoices/invoices.module.js";
import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.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 { SecurityModule } from "@bff/core/security/security.module.js";
import { SupportModule } from "@bff/modules/support/support.module.js"; import { SupportModule } from "@bff/modules/support/support.module.js";
import { RealtimeApiModule } from "@bff/modules/realtime/realtime.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 = [ export const apiRoutes: Routes = [
{ {
@ -17,8 +20,9 @@ export const apiRoutes: Routes = [
children: [ children: [
{ path: "", module: AuthModule }, { path: "", module: AuthModule },
{ path: "", module: UsersModule }, { path: "", module: UsersModule },
{ path: "", module: MeStatusModule },
{ path: "", module: MappingsModule }, { path: "", module: MappingsModule },
{ path: "", module: CatalogModule }, { path: "", module: ServicesModule },
{ path: "", module: OrdersModule }, { path: "", module: OrdersModule },
{ path: "", module: InvoicesModule }, { path: "", module: InvoicesModule },
{ path: "", module: SubscriptionsModule }, { path: "", module: SubscriptionsModule },
@ -26,6 +30,8 @@ export const apiRoutes: Routes = [
{ path: "", module: SupportModule }, { path: "", module: SupportModule },
{ path: "", module: SecurityModule }, { path: "", module: SecurityModule },
{ path: "", module: RealtimeApiModule }, { 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/request-password-reset",
"/api/auth/reset-password", // Public auth endpoint for 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/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", "/api/health",
"/docs", "/docs",
"/api/webhooks", // Webhooks typically don't use CSRF "/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 │ │ Domain-Specific Cache Services │
│ - OrdersCacheService (CDC-driven, no TTL) │ - OrdersCacheService (CDC-driven)
│ - CatalogCacheService (CDC-driven, no TTL) │ - ServicesCacheService (CDC-driven + safety TTL)
│ - WhmcsCacheService (TTL-based) │ │ - WhmcsCacheService (TTL-based) │
│ │ │ │
│ Features: │ │ Features: │
@ -61,18 +61,26 @@ Redis-backed caching system with CDC (Change Data Capture) event-driven invalida
### 1. CDC-Driven (Orders, Catalog) ### 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:** **Pros:**
- Real-time invalidation when data changes - Real-time invalidation when data changes
- Zero stale data for customer-visible fields - Zero stale data for customer-visible fields
- Optimal for frequently read, infrequently changed data - Optimal for frequently read, infrequently changed data
**Example:** **Example:**
```typescript ```typescript
@Injectable() @Injectable()
export class OrdersCacheService { export class OrdersCacheService {
// No TTL = CDC-only invalidation // CDC invalidation + safety TTL (service-specific)
async getOrderSummaries( async getOrderSummaries(
sfAccountId: string, sfAccountId: string,
fetcher: () => Promise<OrderSummary[]> fetcher: () => Promise<OrderSummary[]>
@ -88,11 +96,13 @@ export class OrdersCacheService {
**Fixed TTL** - Cache expires after a set duration. **Fixed TTL** - Cache expires after a set duration.
**Pros:** **Pros:**
- Simple, predictable behavior - Simple, predictable behavior
- Good for external systems without CDC - Good for external systems without CDC
- Automatic cleanup of stale data - Automatic cleanup of stale data
**Example:** **Example:**
```typescript ```typescript
@Injectable() @Injectable()
export class WhmcsCacheService { export class WhmcsCacheService {
@ -152,7 +162,7 @@ All cache services track performance metrics:
```typescript ```typescript
{ {
catalog: { hits: 1250, misses: 48 }, services: { hits: 1250, misses: 48 },
static: { hits: 890, misses: 12 }, static: { hits: 890, misses: 12 },
volatile: { hits: 450, misses: 120 }, volatile: { hits: 450, misses: 120 },
invalidations: 15 invalidations: 15
@ -160,7 +170,8 @@ All cache services track performance metrics:
``` ```
Access via health endpoints: Access via health endpoints:
- `GET /health/catalog/cache`
- `GET /api/health/services/cache`
- `GET /health` - `GET /health`
## Creating a New Cache Service ## Creating a New Cache Service
@ -211,7 +222,7 @@ async getMyData(id: string, fetcher: () => Promise<MyData>): Promise<MyData> {
const fetchPromise = (async () => { const fetchPromise = (async () => {
try { try {
const fresh = await fetcher(); 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; return fresh;
} finally { } finally {
this.inflightRequests.delete(key); this.inflightRequests.delete(key);
@ -255,10 +266,11 @@ domain:type:identifier[:subkey]
``` ```
Examples: Examples:
- `orders:account:001xx000003EgI1AAK` - `orders:account:001xx000003EgI1AAK`
- `orders:detail:80122000000D4UGAA0` - `orders:detail:80122000000D4UGAA0`
- `catalog:internet:acc_001:jp` - `services:internet:acc_001:jp`
- `catalog:deps:product:01t22000003xABCAA2` - `services:deps:product:01t22000003xABCAA2`
- `mapping:userId:user_12345` - `mapping:userId:user_12345`
## Configuration ## Configuration
@ -287,8 +299,8 @@ Provides global `REDIS_CLIENT` using ioredis.
# Overall system health (includes Redis check) # Overall system health (includes Redis check)
GET /health GET /health
# Catalog cache metrics # Services cache metrics
GET /health/catalog/cache GET /api/health/services/cache
``` ```
### Response Format ### Response Format
@ -302,7 +314,7 @@ GET /health/catalog/cache
"invalidations": 15 "invalidations": 15
}, },
"ttl": { "ttl": {
"catalogSeconds": null, "servicesSeconds": null,
"staticSeconds": null, "staticSeconds": null,
"volatileSeconds": 60 "volatileSeconds": 60
} }
@ -357,4 +369,3 @@ console.log(`${count} keys using ${usage} bytes`);
- [Salesforce CDC Events](../../integrations/salesforce/events/README.md) - [Salesforce CDC Events](../../integrations/salesforce/events/README.md)
- [Order Fulfillment Flow](../../modules/orders/docs/FULFILLMENT.md) - [Order Fulfillment Flow](../../modules/orders/docs/FULFILLMENT.md)
- [Redis Configuration](../redis/README.md) - [Redis Configuration](../redis/README.md)

View File

@ -1,16 +1,17 @@
import { Global, Module } from "@nestjs/common"; import { Global, Module } from "@nestjs/common";
import { CacheService } from "./cache.service.js"; import { CacheService } from "./cache.service.js";
import { DistributedLockService } from "./distributed-lock.service.js";
/** /**
* Global cache module * Global cache module
* *
* Provides Redis-backed caching infrastructure for the entire application. * 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() @Global()
@Module({ @Module({
providers: [CacheService], providers: [CacheService, DistributedLockService],
exports: [CacheService], exports: [CacheService, DistributedLockService],
}) })
export class CacheModule {} export class CacheModule {}

View File

@ -74,7 +74,7 @@ export class CacheService {
/** /**
* Delete all keys matching a pattern * Delete all keys matching a pattern
* Uses SCAN for safe operation on large datasets * 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> { async delPattern(pattern: string): Promise<void> {
const pipeline = this.redis.pipeline(); 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. * Topic identifies which logical stream this event belongs to.
* Examples: * Examples:
* - orders:sf:801xx0000001234 * - orders:sf:801xx0000001234
* - catalog:eligibility:001xx000000abcd * - services:eligibility:001xx000000abcd
*/ */
topic: string; topic: string;
event: TEvent; 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 { ConfigModule } from "@nestjs/config";
import { IntegrationsModule } from "@bff/integrations/integrations.module.js"; import { IntegrationsModule } from "@bff/integrations/integrations.module.js";
import { OrdersModule } from "@bff/modules/orders/orders.module.js"; import { OrdersModule } from "@bff/modules/orders/orders.module.js";
import { CatalogModule } from "@bff/modules/catalog/catalog.module.js"; import { ServicesModule } from "@bff/modules/services/services.module.js";
import { CatalogCdcSubscriber } from "./catalog-cdc.subscriber.js"; import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
import { ServicesCdcSubscriber } from "./services-cdc.subscriber.js";
import { OrderCdcSubscriber } from "./order-cdc.subscriber.js"; import { OrderCdcSubscriber } from "./order-cdc.subscriber.js";
@Module({ @Module({
@ -11,10 +12,11 @@ import { OrderCdcSubscriber } from "./order-cdc.subscriber.js";
ConfigModule, ConfigModule,
forwardRef(() => IntegrationsModule), forwardRef(() => IntegrationsModule),
forwardRef(() => OrdersModule), forwardRef(() => OrdersModule),
forwardRef(() => CatalogModule), forwardRef(() => ServicesModule),
forwardRef(() => NotificationsModule),
], ],
providers: [ providers: [
CatalogCdcSubscriber, // CDC for catalog cache invalidation ServicesCdcSubscriber, // CDC for services cache invalidation + notifications
OrderCdcSubscriber, // CDC for order cache invalidation 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 type { OnModuleInit, OnModuleDestroy } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import PubSubApiClientPkg from "salesforce-pubsub-api-client"; import PubSubApiClientPkg from "salesforce-pubsub-api-client";
import { SalesforceConnection } from "../services/salesforce-connection.service.js"; 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 { RealtimeService } from "@bff/infra/realtime/realtime.service.js";
import { AccountNotificationHandler } from "@bff/modules/notifications/account-cdc-listener.service.js";
type PubSubCallback = ( type PubSubCallback = (
subscription: { topicName?: string }, subscription: { topicName?: string },
@ -27,7 +28,7 @@ type PubSubCtor = new (opts: {
}) => PubSubClient; }) => PubSubClient;
@Injectable() @Injectable()
export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy { export class ServicesCdcSubscriber implements OnModuleInit, OnModuleDestroy {
private client: PubSubClient | null = null; private client: PubSubClient | null = null;
private pubSubCtor: PubSubCtor | null = null; private pubSubCtor: PubSubCtor | null = null;
private productChannel: string | null = null; private productChannel: string | null = null;
@ -38,9 +39,10 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
constructor( constructor(
private readonly config: ConfigService, private readonly config: ConfigService,
private readonly sfConnection: SalesforceConnection, private readonly sfConnection: SalesforceConnection,
private readonly catalogCache: CatalogCacheService, private readonly catalogCache: ServicesCacheService,
private readonly realtime: RealtimeService, 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(); this.numRequested = this.resolveNumRequested();
} }
@ -192,9 +194,9 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
productIds, productIds,
} }
); );
await this.invalidateAllCatalogs(); await this.invalidateAllServices();
// Full invalidation already implies all clients should refetch catalog // Full invalidation already implies all clients should refetch services
this.realtime.publish("global:catalog", "catalog.changed", { this.realtime.publish("global:services", "services.changed", {
reason: "product.cdc.fallback_full_invalidation", reason: "product.cdc.fallback_full_invalidation",
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
@ -202,7 +204,7 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
} }
// Product changes can affect catalog results for all users // 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", reason: "product.cdc",
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
@ -246,15 +248,15 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
productId, productId,
} }
); );
await this.invalidateAllCatalogs(); await this.invalidateAllServices();
this.realtime.publish("global:catalog", "catalog.changed", { this.realtime.publish("global:services", "services.changed", {
reason: "pricebook.cdc.fallback_full_invalidation", reason: "pricebook.cdc.fallback_full_invalidation",
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
return; return;
} }
this.realtime.publish("global:catalog", "catalog.changed", { this.realtime.publish("global:services", "services.changed", {
reason: "pricebook.cdc", reason: "pricebook.cdc",
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
@ -269,9 +271,22 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
if (!this.isDataCallback(callbackType)) return; if (!this.isDataCallback(callbackType)) return;
const payload = this.extractPayload(data); const payload = this.extractPayload(data);
const accountId = this.extractStringField(payload, ["AccountId__c", "AccountId", "Id"]); const accountId = this.extractStringField(payload, ["AccountId__c", "AccountId", "Id"]);
const eligibility = this.extractStringField(payload, [ const eligibility = this.extractStringField(payload, ["Internet_Eligibility__c"]);
"Internet_Eligibility__c", const status = this.extractStringField(payload, ["Internet_Eligibility_Status__c"]);
"InternetEligibility__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) { if (!accountId) {
@ -288,19 +303,55 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
}); });
await this.catalogCache.invalidateEligibility(accountId); 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) // 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(), 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 { try {
await this.catalogCache.invalidateAllCatalogs(); await this.catalogCache.invalidateAllServices();
} catch (error) { } 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), 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 { SalesforceAccountService } from "./services/salesforce-account.service.js";
import { SalesforceOrderService } from "./services/salesforce-order.service.js"; import { SalesforceOrderService } from "./services/salesforce-order.service.js";
import { SalesforceCaseService } from "./services/salesforce-case.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 { OrderFieldConfigModule } from "@bff/modules/orders/config/order-field-config.module.js";
import { SalesforceReadThrottleGuard } from "./guards/salesforce-read-throttle.guard.js"; import { SalesforceReadThrottleGuard } from "./guards/salesforce-read-throttle.guard.js";
import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle.guard.js"; import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle.guard.js";
@ -17,6 +19,8 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle
SalesforceAccountService, SalesforceAccountService,
SalesforceOrderService, SalesforceOrderService,
SalesforceCaseService, SalesforceCaseService,
SalesforceOpportunityService,
OpportunityResolutionService,
SalesforceService, SalesforceService,
SalesforceReadThrottleGuard, SalesforceReadThrottleGuard,
SalesforceWriteThrottleGuard, SalesforceWriteThrottleGuard,
@ -25,8 +29,11 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle
QueueModule, QueueModule,
SalesforceService, SalesforceService,
SalesforceConnection, SalesforceConnection,
SalesforceAccountService,
SalesforceOrderService, SalesforceOrderService,
SalesforceCaseService, SalesforceCaseService,
SalesforceOpportunityService,
OpportunityResolutionService,
SalesforceReadThrottleGuard, SalesforceReadThrottleGuard,
SalesforceWriteThrottleGuard, 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, "\\'"); 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( async updatePortalFields(
accountId: string, accountId: string,
update: SalesforceAccountPortalUpdate update: SalesforceAccountPortalUpdate
@ -189,3 +342,40 @@ export interface SalesforceAccountPortalUpdate {
lastSignedInAt?: Date; lastSignedInAt?: Date;
whmcsAccountId?: string | number | null; 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) * Internal method to fetch case without account validation (for post-create lookup)
*/ */
@ -205,4 +258,148 @@ export class SalesforceCaseService {
return result.records?.[0] ?? null; 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. * SOQL query builders for Product2 services queries.
* Extracted from BaseCatalogService for consistency with order query builders. * Extracted from BaseServicesService for consistency with order query builders.
*/ */
import { sanitizeSoqlLiteral, assertSoqlFieldName } from "./soql.util.js"; 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, portalPricebookId: string,
portalCategoryField: string, portalCategoryField: string,
category: string, category: string,

View File

@ -42,11 +42,21 @@ export class WhmcsCacheService {
ttl: 600, // 10 minutes - individual subscriptions rarely change ttl: 600, // 10 minutes - individual subscriptions rarely change
tags: ["subscription", "services"], tags: ["subscription", "services"],
}, },
subscriptionInvoices: {
prefix: "whmcs:subscription:invoices",
ttl: 300, // 5 minutes
tags: ["subscription", "invoices"],
},
client: { client: {
prefix: "whmcs:client", prefix: "whmcs:client",
ttl: 1800, // 30 minutes - client data rarely changes ttl: 1800, // 30 minutes - client data rarely changes
tags: ["client", "user"], tags: ["client", "user"],
}, },
clientEmail: {
prefix: "whmcs:client:email",
ttl: 1800, // 30 minutes
tags: ["client", "email"],
},
sso: { sso: {
prefix: "whmcs:sso", prefix: "whmcs:sso",
ttl: 3600, // 1 hour - SSO tokens have their own expiry 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}`]); 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 * Get cached client data
* Returns WhmcsClient (type inferred from domain) * Returns WhmcsClient (type inferred from domain)
@ -161,6 +201,22 @@ export class WhmcsCacheService {
await this.set(key, data, "client", [`client:${clientId}`]); 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 * Invalidate all cache for a specific user
*/ */
@ -383,6 +439,18 @@ export class WhmcsCacheService {
return `${this.cacheConfigs.subscription.prefix}:${userId}:${subscriptionId}`; 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 * Build cache key for client data
*/ */
@ -390,6 +458,13 @@ export class WhmcsCacheService {
return `${this.cacheConfigs.client.prefix}:${clientId}`; 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 * Build cache key for payment methods
*/ */

View File

@ -33,7 +33,7 @@ import type {
} from "@customer-portal/domain/payments"; } from "@customer-portal/domain/payments";
import type { WhmcsGetClientsProductsParams } from "@customer-portal/domain/subscriptions"; import type { WhmcsGetClientsProductsParams } from "@customer-portal/domain/subscriptions";
import type { WhmcsProductListResponse } 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 { WhmcsErrorResponse } from "@customer-portal/domain/common";
import type { WhmcsRequestOptions, WhmcsConnectionStats } from "../types/connection.types.js"; 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 * Update client details
*/ */

View File

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

View File

@ -123,11 +123,40 @@ export class WhmcsSubscriptionService {
return cached; return cached;
} }
// Get all subscriptions and find the specific one // 2. Check if we have the FULL list cached.
const subscriptionList = await this.getSubscriptions(clientId, userId); // If we do, searching memory is faster than an API call.
const subscription = subscriptionList.subscriptions.find( const cachedList = await this.cacheService.getSubscriptionsList(userId);
(s: Subscription) => s.id === subscriptionId 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) { if (!subscription) {
throw new NotFoundException(`Subscription ${subscriptionId} not found`); 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 { WhmcsSsoService } from "./services/whmcs-sso.service.js";
import { WhmcsOrderService } from "./services/whmcs-order.service.js"; import { WhmcsOrderService } from "./services/whmcs-order.service.js";
import { WhmcsCurrencyService } from "./services/whmcs-currency.service.js"; import { WhmcsCurrencyService } from "./services/whmcs-currency.service.js";
import { WhmcsAccountDiscoveryService } from "./services/whmcs-account-discovery.service.js";
// Connection services // Connection services
import { WhmcsConnectionOrchestratorService } from "./connection/services/whmcs-connection-orchestrator.service.js"; import { WhmcsConnectionOrchestratorService } from "./connection/services/whmcs-connection-orchestrator.service.js";
import { WhmcsConfigService } from "./connection/config/whmcs-config.service.js"; import { WhmcsConfigService } from "./connection/config/whmcs-config.service.js";
@ -33,15 +34,18 @@ import { WhmcsErrorHandlerService } from "./connection/services/whmcs-error-hand
WhmcsSsoService, WhmcsSsoService,
WhmcsOrderService, WhmcsOrderService,
WhmcsCurrencyService, WhmcsCurrencyService,
WhmcsAccountDiscoveryService,
WhmcsService, WhmcsService,
], ],
exports: [ exports: [
WhmcsService, WhmcsService,
WhmcsConnectionOrchestratorService, WhmcsConnectionOrchestratorService,
WhmcsCacheService, WhmcsCacheService,
WhmcsClientService,
WhmcsOrderService, WhmcsOrderService,
WhmcsPaymentService, WhmcsPaymentService,
WhmcsCurrencyService, WhmcsCurrencyService,
WhmcsAccountDiscoveryService,
], ],
}) })
export class WhmcsModule {} 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 { WhmcsAddClientParams, WhmcsClientResponse } from "@customer-portal/domain/customer";
import type { WhmcsGetClientsProductsParams } from "@customer-portal/domain/subscriptions"; import type { WhmcsGetClientsProductsParams } from "@customer-portal/domain/subscriptions";
import type { WhmcsProductListResponse } 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"; import { Logger } from "nestjs-pino";
@Injectable() @Injectable()
@ -131,14 +131,6 @@ export class WhmcsService {
return this.clientService.getClientDetails(clientId); 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 * 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 { UsersFacade } from "@bff/modules/users/application/users.facade.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.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 { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js"; import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js"; import { getErrorMessage } from "@bff/core/utils/error.util.js";
@ -39,6 +40,7 @@ export class AuthFacade {
private readonly mappingsService: MappingsService, private readonly mappingsService: MappingsService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly whmcsService: WhmcsService, private readonly whmcsService: WhmcsService,
private readonly discoveryService: WhmcsAccountDiscoveryService,
private readonly salesforceService: SalesforceService, private readonly salesforceService: SalesforceService,
private readonly auditService: AuditService, private readonly auditService: AuditService,
private readonly tokenBlacklistService: TokenBlacklistService, private readonly tokenBlacklistService: TokenBlacklistService,
@ -418,14 +420,9 @@ export class AuthFacade {
if (mapped) { if (mapped) {
whmcsExists = true; whmcsExists = true;
} else { } else {
// Try a direct WHMCS lookup by email (best-effort) // Try a direct WHMCS lookup by email using discovery service (returns null if not found)
try { const client = await this.discoveryService.findClientByEmail(normalized);
const client = await this.whmcsService.getClientDetailsByEmail(normalized); whmcsExists = !!client;
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) });
}
} }
let state: "none" | "portal_only" | "whmcs_only" | "both_mapped" = "none"; 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 IS_PUBLIC_KEY = "isPublic";
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); 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 { import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
HttpStatus,
Inject, Inject,
Injectable, Injectable,
NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino"; 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 { UsersFacade } from "@bff/modules/users/application/users.facade.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.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 { 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 { PrismaService } from "@bff/infra/database/prisma.service.js";
import { AuthTokenService } from "../../token/token.service.js"; import { AuthTokenService } from "../../token/token.service.js";
import { AuthRateLimitService } from "../../rate-limiting/auth-rate-limit.service.js"; import { AuthRateLimitService } from "../../rate-limiting/auth-rate-limit.service.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js"; import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { DomainHttpException } from "@bff/core/http/domain-http.exception.js";
import { import {
signupRequestSchema, signupRequestSchema,
type SignupRequest, type SignupRequest,
type ValidateSignupRequest, type ValidateSignupRequest,
} from "@customer-portal/domain/auth"; } from "@customer-portal/domain/auth";
import { ErrorCode } from "@customer-portal/domain/common";
import { Providers as CustomerProviders } from "@customer-portal/domain/customer"; import { Providers as CustomerProviders } from "@customer-portal/domain/customer";
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js"; import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
import type { User as PrismaUser } from "@prisma/client"; import type { User as PrismaUser } from "@prisma/client";
@ -54,7 +58,9 @@ export class SignupWorkflowService {
private readonly usersFacade: UsersFacade, private readonly usersFacade: UsersFacade,
private readonly mappingsService: MappingsService, private readonly mappingsService: MappingsService,
private readonly whmcsService: WhmcsService, private readonly whmcsService: WhmcsService,
private readonly discoveryService: WhmcsAccountDiscoveryService,
private readonly salesforceService: SalesforceService, private readonly salesforceService: SalesforceService,
private readonly salesforceAccountService: SalesforceAccountService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly auditService: AuditService, private readonly auditService: AuditService,
@ -66,14 +72,30 @@ export class SignupWorkflowService {
async validateSignup(validateData: ValidateSignupRequest, request?: Request) { async validateSignup(validateData: ValidateSignupRequest, request?: Request) {
const { sfNumber } = validateData; 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 { try {
const accountSnapshot = await this.getAccountSnapshot(sfNumber); const accountSnapshot = await this.getAccountSnapshot(normalizedCustomerNumber);
if (!accountSnapshot) { if (!accountSnapshot) {
await this.auditService.logAuthEvent( await this.auditService.logAuthEvent(
AuditAction.SIGNUP, AuditAction.SIGNUP,
undefined, undefined,
{ sfNumber, reason: "SF number not found" }, { sfNumber: normalizedCustomerNumber, reason: "SF number not found" },
request, request,
false, false,
"Customer number not found in Salesforce" "Customer number not found in Salesforce"
@ -118,7 +140,7 @@ export class SignupWorkflowService {
await this.auditService.logAuthEvent( await this.auditService.logAuthEvent(
AuditAction.SIGNUP, AuditAction.SIGNUP,
undefined, undefined,
{ sfNumber, sfAccountId: accountSnapshot.id, step: "validation" }, { sfNumber: normalizedCustomerNumber, sfAccountId: accountSnapshot.id, step: "validation" },
request, request,
true true
); );
@ -136,7 +158,7 @@ export class SignupWorkflowService {
await this.auditService.logAuthEvent( await this.auditService.logAuthEvent(
AuditAction.SIGNUP, AuditAction.SIGNUP,
undefined, undefined,
{ sfNumber, error: getErrorMessage(error) }, { sfNumber: normalizedCustomerNumber, error: getErrorMessage(error) },
request, request,
false, false,
getErrorMessage(error) getErrorMessage(error)
@ -189,39 +211,100 @@ export class SignupWorkflowService {
const passwordHash = await argon2.hash(password); const passwordHash = await argon2.hash(password);
try { try {
const accountSnapshot = await this.getAccountSnapshot(sfNumber); const normalizedCustomerNumber = this.normalizeCustomerNumber(sfNumber);
if (!accountSnapshot) { let accountSnapshot: SignupAccountSnapshot;
throw new BadRequestException( let customerNumberForWhmcs: string | null = normalizedCustomerNumber;
`Salesforce account not found for Customer Number: ${sfNumber}`
);
}
if (accountSnapshot.WH_Account__c && accountSnapshot.WH_Account__c.trim() !== "") { if (normalizedCustomerNumber) {
throw new ConflictException( const resolved = await this.getAccountSnapshot(normalizedCustomerNumber);
"You already have an account. Please use the login page to access your existing account." 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 }; let whmcsClient: { clientId: number };
try { try {
try { // Check if a WHMCS client already exists for this email using discovery service
const existingWhmcs = await this.whmcsService.getClientDetailsByEmail(email); const existingWhmcs = await this.discoveryService.findClientByEmail(email);
if (existingWhmcs) { if (existingWhmcs) {
const existingMapping = await this.mappingsService.findByWhmcsClientId( const existingMapping = await this.mappingsService.findByWhmcsClientId(existingWhmcs.id);
existingWhmcs.id if (existingMapping) {
); throw new ConflictException("You already have an account. Please sign in.");
if (existingMapping) { }
throw new ConflictException("You already have an account. Please sign in.");
}
throw new ConflictException( throw new DomainHttpException(ErrorCode.LEGACY_ACCOUNT_EXISTS, HttpStatus.CONFLICT);
"We found an existing billing account for this email. Please link your account instead."
);
}
} catch (pre) {
if (!(pre instanceof NotFoundException)) {
throw pre;
}
} }
const customerNumberFieldId = this.configService.get<string>( const customerNumberFieldId = this.configService.get<string>(
@ -232,7 +315,9 @@ export class SignupWorkflowService {
const nationalityFieldId = this.configService.get<string>("WHMCS_NATIONALITY_FIELD_ID"); const nationalityFieldId = this.configService.get<string>("WHMCS_NATIONALITY_FIELD_ID");
const customfieldsMap: Record<string, string> = {}; const customfieldsMap: Record<string, string> = {};
if (customerNumberFieldId) customfieldsMap[customerNumberFieldId] = sfNumber; if (customerNumberFieldId && customerNumberForWhmcs) {
customfieldsMap[customerNumberFieldId] = customerNumberForWhmcs;
}
if (dobFieldId && dateOfBirth) customfieldsMap[dobFieldId] = dateOfBirth; if (dobFieldId && dateOfBirth) customfieldsMap[dobFieldId] = dateOfBirth;
if (genderFieldId && gender) customfieldsMap[genderFieldId] = gender; if (genderFieldId && gender) customfieldsMap[genderFieldId] = gender;
if (nationalityFieldId && nationality) customfieldsMap[nationalityFieldId] = nationality; if (nationalityFieldId && nationality) customfieldsMap[nationalityFieldId] = nationality;
@ -253,7 +338,12 @@ export class SignupWorkflowService {
throw new BadRequestException("Phone number is required for billing account creation"); 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({ whmcsClient = await this.whmcsService.addClient({
firstname: firstName, firstname: firstName,
@ -399,6 +489,7 @@ export class SignupWorkflowService {
async signupPreflight(signupData: SignupRequest) { async signupPreflight(signupData: SignupRequest) {
const { email, sfNumber } = signupData; const { email, sfNumber } = signupData;
const normalizedEmail = email.toLowerCase().trim(); const normalizedEmail = email.toLowerCase().trim();
const normalizedCustomerNumber = this.normalizeCustomerNumber(sfNumber);
const result: { const result: {
ok: boolean; ok: boolean;
@ -440,24 +531,9 @@ export class SignupWorkflowService {
return result; return result;
} }
const accountSnapshot = await this.getAccountSnapshot(sfNumber); if (!normalizedCustomerNumber) {
if (!accountSnapshot) { // Check for existing WHMCS client using discovery service (returns null if not found)
result.nextAction = "fix_input"; const client = await this.discoveryService.findClientByEmail(normalizedEmail);
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 (client) { if (client) {
result.whmcs.clientExists = true; result.whmcs.clientExists = true;
result.whmcs.clientId = client.id; result.whmcs.clientId = client.id;
@ -475,17 +551,68 @@ export class SignupWorkflowService {
result.nextAction = "link_whmcs"; result.nextAction = "link_whmcs";
result.messages.push( 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; return result;
} }
} catch (err) {
if (!(err instanceof NotFoundException)) { try {
this.logger.warn("WHMCS preflight check failed", { error: getErrorMessage(err) }); const existingSf = await this.salesforceAccountService.findByEmail(normalizedEmail);
result.messages.push("Unable to verify billing system. Please try again later."); if (existingSf) {
result.nextAction = "blocked"; result.nextAction = "blocked";
return result; 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; result.canProceed = true;
@ -494,7 +621,9 @@ export class SignupWorkflowService {
return result; return result;
} }
private async getAccountSnapshot(sfNumber: string): Promise<SignupAccountSnapshot | null> { private async getAccountSnapshot(
sfNumber?: string | null
): Promise<SignupAccountSnapshot | null> {
const normalized = this.normalizeCustomerNumber(sfNumber); const normalized = this.normalizeCustomerNumber(sfNumber);
if (!normalized) { if (!normalized) {
return null; return null;
@ -519,7 +648,7 @@ export class SignupWorkflowService {
return resolved; return resolved;
} }
private normalizeCustomerNumber(sfNumber: string): string | null { private normalizeCustomerNumber(sfNumber?: string | null): string | null {
if (typeof sfNumber !== "string") { if (typeof sfNumber !== "string") {
return null; return null;
} }

View File

@ -10,6 +10,7 @@ import { Logger } from "nestjs-pino";
import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.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 { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js"; import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js"; import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
@ -26,6 +27,7 @@ export class WhmcsLinkWorkflowService {
private readonly usersFacade: UsersFacade, private readonly usersFacade: UsersFacade,
private readonly mappingsService: MappingsService, private readonly mappingsService: MappingsService,
private readonly whmcsService: WhmcsService, private readonly whmcsService: WhmcsService,
private readonly discoveryService: WhmcsAccountDiscoveryService,
private readonly salesforceService: SalesforceService, private readonly salesforceService: SalesforceService,
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger
) {} ) {}
@ -51,21 +53,19 @@ export class WhmcsLinkWorkflowService {
try { try {
let clientDetails; // Type inferred from WHMCS service let clientDetails; // Type inferred from WHMCS service
try { try {
clientDetails = await this.whmcsService.getClientDetailsByEmail(email); clientDetails = await this.discoveryService.findClientByEmail(email);
} catch (error) { if (!clientDetails) {
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")) {
throw new BadRequestException( throw new BadRequestException(
"No billing account found with this email address. Please check your email or contact support." "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."); throw new BadRequestException("Unable to verify account. Please try again later.");
} }

View File

@ -230,11 +230,11 @@ export class AuthController {
} }
@Public() @Public()
@Post("link-whmcs") @Post("migrate")
@UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard) @UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard)
@RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP (industry standard) @RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP (industry standard)
@UsePipes(new ZodValidationPipe(linkWhmcsRequestSchema)) @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); const result = await this.authFacade.linkWhmcsUser(linkData);
return linkWhmcsResponseSchema.parse(result); return linkWhmcsResponseSchema.parse(result);
} }

View File

@ -5,7 +5,7 @@ import { Reflector } from "@nestjs/core";
import type { Request } from "express"; import type { Request } from "express";
import { TokenBlacklistService } from "../../../infra/token/token-blacklist.service.js"; 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 { getErrorMessage } from "@bff/core/utils/error.util.js";
import { JoseJwtService } from "../../../infra/token/jose-jwt.service.js"; import { JoseJwtService } from "../../../infra/token/jose-jwt.service.js";
import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
@ -45,8 +45,27 @@ export class GlobalAuthGuard implements CanActivate {
context.getHandler(), context.getHandler(),
context.getClass(), context.getClass(),
]); ]);
const isPublicNoSession = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_NO_SESSION_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) { 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}`); this.logger.debug(`Public route accessed: ${route}`);
return true; return true;
} }
@ -61,45 +80,7 @@ export class GlobalAuthGuard implements CanActivate {
throw new UnauthorizedException("Missing token"); throw new UnauthorizedException("Missing token");
} }
const payload = await this.jwtService.verify<{ sub?: string; email?: string; exp?: number }>( await this.attachUserFromToken(request, token, route);
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;
this.logger.debug(`Authenticated access to: ${route}`); this.logger.debug(`Authenticated access to: ${route}`);
return true; return true;
@ -168,4 +149,52 @@ export class GlobalAuthGuard implements CanActivate {
const normalized = path.endsWith("/") ? path.slice(0, -1) : path; const normalized = path.endsWith("/") ? path.slice(0, -1) : path;
return normalized === "/auth/logout" || normalized === "/api/auth/logout"; 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", "CreatedDate",
"LastModifiedDate", "LastModifiedDate",
"Pricebook2Id", "Pricebook2Id",
"OpportunityId", // Linked Opportunity for lifecycle tracking
order.activationType, order.activationType,
order.activationStatus, order.activationStatus,
order.activationScheduledAt, 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 { Logger } from "nestjs-pino";
import { ZodValidationPipe } from "nestjs-zod"; import { ZodValidationPipe } from "nestjs-zod";
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
import { CheckoutService } from "../services/checkout.service.js"; import { CheckoutService } from "../services/checkout.service.js";
import { CheckoutSessionService } from "../services/checkout-session.service.js";
import { import {
checkoutItemSchema,
checkoutCartSchema, checkoutCartSchema,
checkoutBuildCartRequestSchema, checkoutBuildCartRequestSchema,
checkoutBuildCartResponseSchema, checkoutBuildCartResponseSchema,
checkoutTotalsSchema,
} from "@customer-portal/domain/orders"; } from "@customer-portal/domain/orders";
import type { CheckoutCart, CheckoutBuildCartRequest } from "@customer-portal/domain/orders"; import type { CheckoutCart, CheckoutBuildCartRequest } from "@customer-portal/domain/orders";
import { apiSuccessResponseSchema } from "@customer-portal/domain/common"; 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"; import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
const validateCartResponseSchema = apiSuccessResponseSchema(z.object({ valid: z.boolean() })); 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") @Controller("checkout")
@Public() // Cart building and validation can be done without authentication
export class CheckoutController { export class CheckoutController {
constructor( constructor(
private readonly checkoutService: CheckoutService, private readonly checkoutService: CheckoutService,
private readonly checkoutSessions: CheckoutSessionService,
@Inject(Logger) private readonly logger: Logger @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") @Post("validate")
@UsePipes(new ZodValidationPipe(checkoutCartSchema)) @UsePipes(new ZodValidationPipe(checkoutCartSchema))
validateCart(@Body() cart: CheckoutCart) { validateCart(@Body() cart: CheckoutCart) {

View File

@ -29,12 +29,21 @@ import { Observable } from "rxjs";
import { OrderEventsService } from "./services/order-events.service.js"; import { OrderEventsService } from "./services/order-events.service.js";
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.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 { 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") @Controller("orders")
@UseGuards(RateLimitGuard) @UseGuards(RateLimitGuard)
export class OrdersController { export class OrdersController {
constructor( constructor(
private orderOrchestrator: OrderOrchestrator, private orderOrchestrator: OrderOrchestrator,
private readonly checkoutService: CheckoutService,
private readonly checkoutSessions: CheckoutSessionService,
private readonly orderEvents: OrderEventsService, private readonly orderEvents: OrderEventsService,
private readonly logger: Logger 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") @Get("user")
@UseGuards(SalesforceReadThrottleGuard) @UseGuards(SalesforceReadThrottleGuard)
async getUserOrders(@Request() req: RequestWithUser) { 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 { UsersModule } from "@bff/modules/users/users.module.js";
import { CoreConfigModule } from "@bff/core/config/config.module.js"; import { CoreConfigModule } from "@bff/core/config/config.module.js";
import { DatabaseModule } from "@bff/core/database/database.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 { 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 // Clean modular order services
import { OrderValidator } from "./services/order-validator.service.js"; 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 { OrderOrchestrator } from "./services/order-orchestrator.service.js";
import { PaymentValidatorService } from "./services/payment-validator.service.js"; import { PaymentValidatorService } from "./services/payment-validator.service.js";
import { CheckoutService } from "./services/checkout.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 { OrderEventsService } from "./services/order-events.service.js";
import { OrdersCacheService } from "./services/orders-cache.service.js"; import { OrdersCacheService } from "./services/orders-cache.service.js";
@ -36,8 +39,10 @@ import { OrderFieldConfigModule } from "./config/order-field-config.module.js";
UsersModule, UsersModule,
CoreConfigModule, CoreConfigModule,
DatabaseModule, DatabaseModule,
CatalogModule, ServicesModule,
CacheModule, CacheModule,
VerificationModule,
NotificationsModule,
OrderFieldConfigModule, OrderFieldConfigModule,
], ],
controllers: [OrdersController, CheckoutController], controllers: [OrdersController, CheckoutController],
@ -54,6 +59,7 @@ import { OrderFieldConfigModule } from "./config/order-field-config.module.js";
OrderOrchestrator, OrderOrchestrator,
OrdersCacheService, OrdersCacheService,
CheckoutService, CheckoutService,
CheckoutSessionService,
// Order fulfillment services (modular) // Order fulfillment services (modular)
OrderFulfillmentValidator, 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, SimCatalogProduct,
SimActivationFeeCatalogItem, SimActivationFeeCatalogItem,
VpnCatalogProduct, VpnCatalogProduct,
} from "@customer-portal/domain/catalog"; } from "@customer-portal/domain/services";
import { InternetCatalogService } from "@bff/modules/catalog/services/internet-catalog.service.js"; import { InternetServicesService } from "@bff/modules/services/services/internet-services.service.js";
import { SimCatalogService } from "@bff/modules/catalog/services/sim-catalog.service.js"; import { SimServicesService } from "@bff/modules/services/services/sim-services.service.js";
import { VpnCatalogService } from "@bff/modules/catalog/services/vpn-catalog.service.js"; import { VpnServicesService } from "@bff/modules/services/services/vpn-services.service.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js"; import { getErrorMessage } from "@bff/core/utils/error.util.js";
@Injectable() @Injectable()
export class CheckoutService { export class CheckoutService {
constructor( constructor(
@Inject(Logger) private readonly logger: Logger, @Inject(Logger) private readonly logger: Logger,
private readonly internetCatalogService: InternetCatalogService, private readonly internetCatalogService: InternetServicesService,
private readonly simCatalogService: SimCatalogService, private readonly simCatalogService: SimServicesService,
private readonly vpnCatalogService: VpnCatalogService private readonly vpnCatalogService: VpnServicesService
) {} ) {}
/** /**
@ -155,6 +155,14 @@ export class CheckoutService {
userId?: string userId?: string
): Promise<{ items: CheckoutItem[] }> { ): Promise<{ items: CheckoutItem[] }> {
const 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 const plans: InternetPlanCatalogItem[] = userId
? await this.internetCatalogService.getPlansForUser(userId) ? await this.internetCatalogService.getPlansForUser(userId)
: await this.internetCatalogService.getPlans(); : await this.internetCatalogService.getPlans();
@ -229,9 +237,11 @@ export class CheckoutService {
userId?: string userId?: string
): Promise<{ items: CheckoutItem[] }> { ): Promise<{ items: CheckoutItem[] }> {
const items: CheckoutItem[] = []; const items: CheckoutItem[] = [];
const plans: SimCatalogProduct[] = userId if (!userId) {
? await this.simCatalogService.getPlansForUser(userId) throw new BadRequestException("Please sign in to order SIM service.");
: await this.simCatalogService.getPlans(); }
const plans: SimCatalogProduct[] = await this.simCatalogService.getPlansForUser(userId);
const rawActivationFees: SimActivationFeeCatalogItem[] = const rawActivationFees: SimActivationFeeCatalogItem[] =
await this.simCatalogService.getActivationFees(); await this.simCatalogService.getActivationFees();
const activationFees = this.filterActivationFeesWithSku(rawActivationFees); const activationFees = this.filterActivationFeesWithSku(rawActivationFees);

View File

@ -1,6 +1,7 @@
import { Injectable, Inject } from "@nestjs/common"; import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js"; 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 { WhmcsOrderService } from "@bff/integrations/whmcs/services/whmcs-order.service.js";
import type { WhmcsOrderResult } 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"; 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 { getErrorMessage } from "@bff/core/utils/error.util.js";
import { OrderEventsService } from "./order-events.service.js"; import { OrderEventsService } from "./order-events.service.js";
import { OrdersCacheService } from "./orders-cache.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 { import {
type OrderDetails, type OrderDetails,
type OrderFulfillmentValidationResult, type OrderFulfillmentValidationResult,
Providers as OrderProviders, Providers as OrderProviders,
} from "@customer-portal/domain/orders"; } 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 { import {
OrderValidationException, OrderValidationException,
FulfillmentException, FulfillmentException,
@ -51,6 +57,7 @@ export class OrderFulfillmentOrchestrator {
constructor( constructor(
@Inject(Logger) private readonly logger: Logger, @Inject(Logger) private readonly logger: Logger,
private readonly salesforceService: SalesforceService, private readonly salesforceService: SalesforceService,
private readonly opportunityService: SalesforceOpportunityService,
private readonly whmcsOrderService: WhmcsOrderService, private readonly whmcsOrderService: WhmcsOrderService,
private readonly orderOrchestrator: OrderOrchestrator, private readonly orderOrchestrator: OrderOrchestrator,
private readonly orderFulfillmentValidator: OrderFulfillmentValidator, private readonly orderFulfillmentValidator: OrderFulfillmentValidator,
@ -58,7 +65,9 @@ export class OrderFulfillmentOrchestrator {
private readonly simFulfillmentService: SimFulfillmentService, private readonly simFulfillmentService: SimFulfillmentService,
private readonly distributedTransactionService: DistributedTransactionService, private readonly distributedTransactionService: DistributedTransactionService,
private readonly orderEvents: OrderEventsService, 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", source: "fulfillment",
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
await this.safeNotifyOrder({
type: NOTIFICATION_TYPE.ORDER_APPROVED,
sfOrderId,
accountId: context.validation?.sfOrder?.AccountId,
actionUrl: `/account/orders/${sfOrderId}`,
});
return result; return result;
}), }),
rollback: async () => { rollback: async () => {
@ -232,12 +247,16 @@ export class OrderFulfillmentOrchestrator {
`Provisioned from Salesforce Order ${sfOrderId}` `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({ const result = await this.whmcsOrderService.addOrder({
clientId: context.validation.clientId, clientId: context.validation.clientId,
items: mappingResult.whmcsItems, items: mappingResult.whmcsItems,
paymentMethod: "stripe", paymentMethod: "stripe",
promoCode: "1st Month Free (Monthly Plan)", promoCode: "1st Month Free (Monthly Plan)",
sfOrderId, sfOrderId,
sfOpportunityId, // Pass to WHMCS for bidirectional linking
notes: orderNotes, notes: orderNotes,
noinvoiceemail: true, noinvoiceemail: true,
noemail: true, noemail: true,
@ -336,6 +355,12 @@ export class OrderFulfillmentOrchestrator {
whmcsServiceIds: whmcsCreateResult?.serviceIds, whmcsServiceIds: whmcsCreateResult?.serviceIds,
}, },
}); });
await this.safeNotifyOrder({
type: NOTIFICATION_TYPE.ORDER_ACTIVATED,
sfOrderId,
accountId: context.validation?.sfOrder?.AccountId,
actionUrl: "/account/services",
});
return result; return result;
}), }),
rollback: async () => { rollback: async () => {
@ -346,6 +371,54 @@ export class OrderFulfillmentOrchestrator {
}, },
critical: true, 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}`, description: `Order fulfillment for ${sfOrderId}`,
@ -387,6 +460,12 @@ export class OrderFulfillmentOrchestrator {
} catch (error) { } catch (error) {
await this.invalidateOrderCaches(sfOrderId, context.validation?.sfOrder?.AccountId); await this.invalidateOrderCaches(sfOrderId, context.validation?.sfOrder?.AccountId);
await this.handleFulfillmentError(context, error as Error); 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, { this.orderEvents.publish(sfOrderId, {
orderId: sfOrderId, orderId: sfOrderId,
status: "Pending Review", 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 * Handle fulfillment errors and update Salesforce
*/ */

View File

@ -1,13 +1,16 @@
import { Injectable, Inject, NotFoundException } from "@nestjs/common"; import { Injectable, Inject, NotFoundException } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { SalesforceOrderService } from "@bff/integrations/salesforce/services/salesforce-order.service.js"; 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 { OrderValidator } from "./order-validator.service.js";
import { OrderBuilder } from "./order-builder.service.js"; import { OrderBuilder } from "./order-builder.service.js";
import { OrderItemBuilder } from "./order-item-builder.service.js"; import { OrderItemBuilder } from "./order-item-builder.service.js";
import type { OrderItemCompositePayload } from "./order-item-builder.service.js"; import type { OrderItemCompositePayload } from "./order-item-builder.service.js";
import { OrdersCacheService } from "./orders-cache.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 { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js";
import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity";
type OrderDetailsResponse = OrderDetails; type OrderDetailsResponse = OrderDetails;
type OrderSummaryResponse = OrderSummary; type OrderSummaryResponse = OrderSummary;
@ -21,6 +24,8 @@ export class OrderOrchestrator {
constructor( constructor(
@Inject(Logger) private readonly logger: Logger, @Inject(Logger) private readonly logger: Logger,
private readonly salesforceOrderService: SalesforceOrderService, private readonly salesforceOrderService: SalesforceOrderService,
private readonly opportunityService: SalesforceOpportunityService,
private readonly opportunityResolution: OpportunityResolutionService,
private readonly orderValidator: OrderValidator, private readonly orderValidator: OrderValidator,
private readonly orderBuilder: OrderBuilder, private readonly orderBuilder: OrderBuilder,
private readonly orderItemBuilder: OrderItemBuilder, private readonly orderItemBuilder: OrderItemBuilder,
@ -46,9 +51,18 @@ export class OrderOrchestrator {
"Order validation completed successfully" "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( const orderFields = await this.orderBuilder.buildOrderFields(
validatedBody, bodyWithOpportunity,
userMapping, userMapping,
pricebookId, pricebookId,
validatedBody.userId validatedBody.userId
@ -63,6 +77,7 @@ export class OrderOrchestrator {
orderType: validatedBody.orderType, orderType: validatedBody.orderType,
skuCount: validatedBody.skus.length, skuCount: validatedBody.skus.length,
orderItemCount: orderItemsPayload.length, orderItemCount: orderItemsPayload.length,
hasOpportunity: !!opportunityId,
}, },
"Order payload prepared" "Order payload prepared"
); );
@ -72,6 +87,27 @@ export class OrderOrchestrator {
orderItemsPayload 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) { if (userMapping.sfAccountId) {
await this.ordersCache.invalidateAccountOrders(userMapping.sfAccountId); await this.ordersCache.invalidateAccountOrders(userMapping.sfAccountId);
} }
@ -82,6 +118,7 @@ export class OrderOrchestrator {
orderId: created.id, orderId: created.id,
skuCount: validatedBody.skus.length, skuCount: validatedBody.skus.length,
orderItemCount: orderItemsPayload.length, orderItemCount: orderItemsPayload.length,
opportunityId,
}, },
"Order creation workflow completed successfully" "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 * Get order by ID with order items
*/ */

View File

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

View File

@ -13,9 +13,11 @@ import {
import type { Providers } from "@customer-portal/domain/subscriptions"; import type { Providers } from "@customer-portal/domain/subscriptions";
type WhmcsProduct = Providers.WhmcsRaw.WhmcsProductRaw; 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 { OrderPricebookService, type PricebookProductMeta } from "./order-pricebook.service.js";
import { PaymentValidatorService } from "./payment-validator.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 * Handles all order validation logic - both format and business rules
@ -30,8 +32,10 @@ export class OrderValidator {
private readonly mappings: MappingsService, private readonly mappings: MappingsService,
private readonly whmcs: WhmcsConnectionOrchestratorService, private readonly whmcs: WhmcsConnectionOrchestratorService,
private readonly pricebookService: OrderPricebookService, private readonly pricebookService: OrderPricebookService,
private readonly simCatalogService: SimCatalogService, private readonly simCatalogService: SimServicesService,
private readonly paymentValidator: PaymentValidatorService 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); const _productMeta = await this.validateSKUs(businessValidatedBody.skus, pricebookId);
if (businessValidatedBody.orderType === "SIM") { 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 activationFees = await this.simCatalogService.getActivationFees();
const activationSkus = new Set( const activationSkus = new Set(
activationFees activationFees
@ -297,6 +313,23 @@ export class OrderValidator {
// 4. Order-specific business validation // 4. Order-specific business validation
if (businessValidatedBody.orderType === "Internet") { 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); 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. // Avoid duplicate ready/heartbeat noise on the combined stream.
readyEvent: null, readyEvent: null,
heartbeatEvent: null, heartbeatEvent: null,
heartbeatMs: 0, heartbeatMs: 0,
}); });
return merge(accountStream, globalCatalogStream).pipe( return merge(accountStream, globalServicesStream).pipe(
finalize(() => { finalize(() => {
this.limiter.release(req.user.id); this.limiter.release(req.user.id);
this.logger.debug("Account realtime stream disconnected", { 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 { Controller, Get } from "@nestjs/common";
import { CatalogCacheService } from "./services/catalog-cache.service.js"; import { ServicesCacheService } from "./services/services-cache.service.js";
import type { CatalogCacheSnapshot } from "./services/catalog-cache.service.js"; import type { ServicesCacheSnapshot } from "./services/services-cache.service.js";
import { Public } from "@bff/modules/auth/decorators/public.decorator.js"; import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
interface CatalogCacheHealthResponse { interface ServicesCacheHealthResponse {
timestamp: string; timestamp: string;
metrics: CatalogCacheSnapshot; metrics: ServicesCacheSnapshot;
ttl: { ttl: {
catalogSeconds: number | null; servicesSeconds: number | null;
eligibilitySeconds: number | null; eligibilitySeconds: number | null;
staticSeconds: number | null; staticSeconds: number | null;
volatileSeconds: number; volatileSeconds: number;
}; };
} }
@Controller("health/catalog") @Controller("health/services")
@Public() @Public()
export class CatalogHealthController { export class ServicesHealthController {
constructor(private readonly catalogCache: CatalogCacheService) {} constructor(private readonly catalogCache: ServicesCacheService) {}
@Get("cache") @Get("cache")
getCacheMetrics(): CatalogCacheHealthResponse { getCacheMetrics(): ServicesCacheHealthResponse {
const ttl = this.catalogCache.getTtlConfiguration(); const ttl = this.catalogCache.getTtlConfiguration();
return { return {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),

View File

@ -1,9 +1,11 @@
import { Controller, Get, Request, UseGuards, Header } from "@nestjs/common"; import { Controller, Get, Request, UseGuards, Header } from "@nestjs/common";
import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.js"; import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.js";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
import { import {
parseInternetCatalog, parseInternetCatalog,
parseSimCatalog, parseSimCatalog,
parseVpnCatalog,
type InternetAddonCatalogItem, type InternetAddonCatalogItem,
type InternetInstallationCatalogItem, type InternetInstallationCatalogItem,
type InternetPlanCatalogItem, type InternetPlanCatalogItem,
@ -11,19 +13,21 @@ import {
type SimCatalogCollection, type SimCatalogCollection,
type SimCatalogProduct, type SimCatalogProduct,
type VpnCatalogProduct, type VpnCatalogProduct,
} from "@customer-portal/domain/catalog"; type VpnCatalogCollection,
import { InternetCatalogService } from "./services/internet-catalog.service.js"; } from "@customer-portal/domain/services";
import { SimCatalogService } from "./services/sim-catalog.service.js"; import { InternetServicesService } from "./services/internet-services.service.js";
import { VpnCatalogService } from "./services/vpn-catalog.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"; 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) @UseGuards(SalesforceReadThrottleGuard, RateLimitGuard)
export class CatalogController { export class ServicesController {
constructor( constructor(
private internetCatalog: InternetCatalogService, private internetCatalog: InternetServicesService,
private simCatalog: SimCatalogService, private simCatalog: SimServicesService,
private vpnCatalog: VpnCatalogService private vpnCatalog: VpnServicesService
) {} ) {}
@Get("internet/plans") @Get("internet/plans")
@ -98,8 +102,10 @@ export class CatalogController {
@Get("vpn/plans") @Get("vpn/plans")
@RateLimit({ limit: 20, ttl: 60 }) // 20 requests per minute @RateLimit({ limit: 20, ttl: 60 }) // 20 requests per minute
@Header("Cache-Control", "public, max-age=300, s-maxage=300") // 5 minutes @Header("Cache-Control", "public, max-age=300, s-maxage=300") // 5 minutes
async getVpnPlans(): Promise<VpnCatalogProduct[]> { async getVpnPlans(): Promise<VpnCatalogCollection> {
return this.vpnCatalog.getPlans(); // Backwards-compatible: return the full VPN catalog (plans + activation fees)
const catalog = await this.vpnCatalog.getCatalogData();
return parseVpnCatalog(catalog);
} }
@Get("vpn/activation-fees") @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"; } from "@bff/integrations/salesforce/utils/soql.util.js";
import { import {
buildProductQuery, buildProductQuery,
buildCatalogServiceQuery, buildServicesQuery,
} from "@bff/integrations/salesforce/utils/catalog-query-builder.js"; } from "@bff/integrations/salesforce/utils/services-query-builder.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js"; import { getErrorMessage } from "@bff/core/utils/error.util.js";
import type { import type {
SalesforceProduct2WithPricebookEntries, SalesforceProduct2WithPricebookEntries,
SalesforcePricebookEntryRecord, SalesforcePricebookEntryRecord,
} from "@customer-portal/domain/catalog"; } from "@customer-portal/domain/services";
import { Providers as CatalogProviders } from "@customer-portal/domain/catalog"; import { Providers as CatalogProviders } from "@customer-portal/domain/services";
import type { SalesforceResponse } from "@customer-portal/domain/common"; import type { SalesforceResponse } from "@customer-portal/domain/common";
@Injectable() @Injectable()
export class BaseCatalogService { export class BaseServicesService {
protected readonly portalPriceBookId: string; protected readonly portalPriceBookId: string;
protected readonly portalCategoryField: string; protected readonly portalCategoryField: string;
@ -41,7 +41,7 @@ export class BaseCatalogService {
): Promise<TRecord[]> { ): Promise<TRecord[]> {
try { try {
const res = (await this.sf.query(soql, { const res = (await this.sf.query(soql, {
label: `catalog:${context.replace(/\s+/g, "_").toLowerCase()}`, label: `services:${context.replace(/\s+/g, "_").toLowerCase()}`,
})) as SalesforceResponse<TRecord>; })) as SalesforceResponse<TRecord>;
return res.records ?? []; return res.records ?? [];
} catch (error: unknown) { } catch (error: unknown) {
@ -99,8 +99,8 @@ export class BaseCatalogService {
return []; return [];
} }
protected buildCatalogServiceQuery(category: string, additionalFields: string[] = []): string { protected buildServicesQuery(category: string, additionalFields: string[] = []): string {
return buildCatalogServiceQuery( return buildServicesQuery(
this.portalPriceBookId, this.portalPriceBookId,
this.portalCategoryField, this.portalCategoryField,
category, 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 { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { CacheService } from "@bff/infra/cache/cache.service.js"; import { CacheService } from "@bff/infra/cache/cache.service.js";
import type { CacheBucketMetrics, CacheDependencies } from "@bff/infra/cache/cache.types.js"; import type { CacheBucketMetrics, CacheDependencies } from "@bff/infra/cache/cache.types.js";
export interface CatalogCacheSnapshot { export interface ServicesCacheSnapshot {
catalog: CacheBucketMetrics; services: CacheBucketMetrics;
static: CacheBucketMetrics; static: CacheBucketMetrics;
volatile: CacheBucketMetrics; volatile: CacheBucketMetrics;
eligibility: CacheBucketMetrics; eligibility: CacheBucketMetrics;
invalidations: number; invalidations: number;
} }
export interface CatalogCacheOptions<T> { export interface ServicesCacheOptions<T> {
allowNull?: boolean; allowNull?: boolean;
resolveDependencies?: ( resolveDependencies?: (
value: T 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 * Uses CDC (Change Data Capture) for real-time cache invalidation with
* product dependency tracking for granular invalidation. * product dependency tracking for granular invalidation.
* *
* Features: * 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 * - Product dependency tracking: Granular invalidation by product IDs
* - Request coalescing: Prevents thundering herd on cache miss * - Request coalescing: Prevents thundering herd on cache miss
* - Metrics tracking: Monitors hits, misses, and invalidations * - Metrics tracking: Monitors hits, misses, and invalidations
* *
* Cache buckets: * Cache buckets:
* - catalog: Product catalog data (CDC-driven) * - catalog: Product catalog data (event-driven + safety TTL)
* - static: Static reference data (CDC-driven) * - static: Static reference data (event-driven + safety TTL)
* - eligibility: Account eligibility data (CDC-driven) * - eligibility: Account eligibility data (event-driven + safety TTL)
* - volatile: Frequently changing data (60s TTL) * - volatile: Frequently changing data (60s TTL)
*/ */
@Injectable() @Injectable()
export class CatalogCacheService { export class ServicesCacheService {
// CDC-driven invalidation: null TTL means cache persists until explicit invalidation // CDC-driven invalidation + safety TTL (self-heal if events are missed)
private readonly CATALOG_TTL: number | null = null; private readonly SERVICES_TTL: number | null;
private readonly STATIC_TTL: number | null = null; private readonly STATIC_TTL: number | null;
private readonly ELIGIBILITY_TTL: number | null = null; private readonly ELIGIBILITY_TTL: number | null;
private readonly VOLATILE_TTL = 60; // Volatile data still uses TTL private readonly VOLATILE_TTL = 60; // Volatile data still uses TTL
private readonly metrics: CatalogCacheSnapshot = { private readonly metrics: ServicesCacheSnapshot = {
catalog: { hits: 0, misses: 0 }, services: { hits: 0, misses: 0 },
static: { hits: 0, misses: 0 }, static: { hits: 0, misses: 0 },
volatile: { hits: 0, misses: 0 }, volatile: { hits: 0, misses: 0 },
eligibility: { hits: 0, misses: 0 }, eligibility: { hits: 0, misses: 0 },
@ -61,21 +63,32 @@ export class CatalogCacheService {
// request the same data after CDC invalidation // request the same data after CDC invalidation
private readonly inflightRequests = new Map<string, Promise<unknown>>(); 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;
/** // Apply to CDC-driven buckets (catalog + static + eligibility)
* Get or fetch catalog data (CDC-driven cache, no TTL) this.SERVICES_TTL = ttl;
*/ this.STATIC_TTL = ttl;
async getCachedCatalog<T>( this.ELIGIBILITY_TTL = ttl;
key: string,
fetchFn: () => Promise<T>,
options?: CatalogCacheOptions<T>
): Promise<T> {
return this.getOrSet("catalog", key, this.CATALOG_TTL, fetchFn, options);
} }
/** /**
* 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> { async getCachedStatic<T>(key: string, fetchFn: () => Promise<T>): Promise<T> {
return this.getOrSet("static", key, this.STATIC_TTL, fetchFn); 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> { async getCachedEligibility<T>(key: string, fetchFn: () => Promise<T>): Promise<T> {
return this.getOrSet("eligibility", key, this.ELIGIBILITY_TTL, fetchFn, { return this.getOrSet("eligibility", key, this.ELIGIBILITY_TTL, fetchFn, {
@ -100,20 +113,20 @@ export class CatalogCacheService {
/** /**
* Build cache key for catalog data * Build cache key for catalog data
*/ */
buildCatalogKey(catalogType: string, ...parts: string[]): string { buildServicesKey(serviceType: string, ...parts: string[]): string {
return `catalog:${catalogType}:${parts.join(":")}`; return `services:${serviceType}:${parts.join(":")}`;
} }
buildEligibilityKey(_catalogType: string, accountId: string): string { buildEligibilityKey(_catalogType: string, accountId: string): string {
return `catalog:eligibility:${accountId}`; return `services:eligibility:${accountId}`;
} }
/** /**
* Invalidate catalog cache by pattern * Invalidate catalog cache by pattern
*/ */
async invalidateCatalog(catalogType: string): Promise<void> { async invalidateServices(serviceType: string): Promise<void> {
this.metrics.invalidations++; this.metrics.invalidations++;
await this.cache.delPattern(`catalog:${catalogType}:*`); await this.cache.delPattern(`services:${serviceType}:*`);
await this.flushProductDependencyIndex(); await this.flushProductDependencyIndex();
} }
@ -129,9 +142,9 @@ export class CatalogCacheService {
/** /**
* Invalidate all catalog cache entries * Invalidate all catalog cache entries
*/ */
async invalidateAllCatalogs(): Promise<void> { async invalidateAllServices(): Promise<void> {
this.metrics.invalidations++; this.metrics.invalidations++;
await this.cache.delPattern("catalog:*"); await this.cache.delPattern("services:*");
await this.flushProductDependencyIndex(); await this.flushProductDependencyIndex();
} }
@ -139,13 +152,13 @@ export class CatalogCacheService {
* Get TTL configuration for monitoring * Get TTL configuration for monitoring
*/ */
getTtlConfiguration(): { getTtlConfiguration(): {
catalogSeconds: number | null; servicesSeconds: number | null;
eligibilitySeconds: number | null; eligibilitySeconds: number | null;
staticSeconds: number | null; staticSeconds: number | null;
volatileSeconds: number; volatileSeconds: number;
} { } {
return { return {
catalogSeconds: this.CATALOG_TTL ?? null, servicesSeconds: this.SERVICES_TTL ?? null,
eligibilitySeconds: this.ELIGIBILITY_TTL ?? null, eligibilitySeconds: this.ELIGIBILITY_TTL ?? null,
staticSeconds: this.STATIC_TTL ?? null, staticSeconds: this.STATIC_TTL ?? null,
volatileSeconds: this.VOLATILE_TTL, volatileSeconds: this.VOLATILE_TTL,
@ -155,9 +168,9 @@ export class CatalogCacheService {
/** /**
* Get cache metrics for monitoring * Get cache metrics for monitoring
*/ */
getMetrics(): CatalogCacheSnapshot { getMetrics(): ServicesCacheSnapshot {
return { return {
catalog: { ...this.metrics.catalog }, services: { ...this.metrics.services },
static: { ...this.metrics.static }, static: { ...this.metrics.static },
volatile: { ...this.metrics.volatile }, volatile: { ...this.metrics.volatile },
eligibility: { ...this.metrics.eligibility }, eligibility: { ...this.metrics.eligibility },
@ -173,10 +186,27 @@ export class CatalogCacheService {
eligibility: string | null | undefined eligibility: string | null | undefined
): Promise<void> { ): Promise<void> {
const key = this.buildEligibilityKey("", accountId); const key = this.buildEligibilityKey("", accountId);
const payload = const payload = {
typeof eligibility === "string" status: eligibility ? "eligible" : "not_requested",
? { Id: accountId, Internet_Eligibility__c: eligibility } eligibility: typeof eligibility === "string" ? eligibility : null,
: 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) { if (this.ELIGIBILITY_TTL === null) {
await this.cache.set(key, payload); await this.cache.set(key, payload);
} else { } else {
@ -185,11 +215,11 @@ export class CatalogCacheService {
} }
private async getOrSet<T>( private async getOrSet<T>(
bucket: "catalog" | "static" | "volatile" | "eligibility", bucket: "services" | "static" | "volatile" | "eligibility",
key: string, key: string,
ttlSeconds: number | null, ttlSeconds: number | null,
fetchFn: () => Promise<T>, fetchFn: () => Promise<T>,
options?: CatalogCacheOptions<T> options?: ServicesCacheOptions<T>
): Promise<T> { ): Promise<T> {
const allowNull = options?.allowNull ?? false; const allowNull = options?.allowNull ?? false;
@ -234,8 +264,8 @@ export class CatalogCacheService {
// Store and link dependencies separately // Store and link dependencies separately
if (dependencies) { if (dependencies) {
await this.storeDependencies(key, dependencies); await this.storeDependencies(key, dependencies, ttlSeconds);
await this.linkDependencies(key, dependencies); await this.linkDependencies(key, dependencies, ttlSeconds);
} }
return fresh; return fresh;
@ -264,8 +294,8 @@ export class CatalogCacheService {
} }
if (cached.dependencies) { if (cached.dependencies) {
await this.storeDependencies(key, cached.dependencies); await this.storeDependencies(key, cached.dependencies, ttlSeconds);
await this.linkDependencies(key, cached.dependencies); await this.linkDependencies(key, cached.dependencies, ttlSeconds);
} }
return normalizedValue; return normalizedValue;
@ -327,11 +357,19 @@ export class CatalogCacheService {
/** /**
* Store dependencies metadata for a cache key * 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); const normalized = this.normalizeDependencies(dependencies);
if (normalized) { if (normalized) {
const metaKey = this.buildDependencyMetaKey(key); 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)) }; 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); const normalized = this.normalizeDependencies(dependencies);
if (!normalized) { if (!normalized) {
return; return;
@ -371,7 +413,11 @@ export class CatalogCacheService {
if (!existing.includes(key)) { if (!existing.includes(key)) {
existing.push(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 { private buildProductDependencyKey(productId: string): string {
return `catalog:deps:product:${productId}`; return `services:deps:product:${productId}`;
} }
private async flushProductDependencyIndex(): Promise<void> { private async flushProductDependencyIndex(): Promise<void> {
await this.cache.delPattern("catalog:deps:product:*"); await this.cache.delPattern("services:deps:product:*");
} }
} }
export interface CatalogCacheOptions<T> { // (intentionally no duplicate options type; use ServicesCacheOptions<T> above)
allowNull?: boolean;
resolveDependencies?: (
value: T
) => CacheDependencies | Promise<CacheDependencies | undefined> | undefined;
}

View File

@ -1,38 +1,38 @@
import { Injectable, Inject } from "@nestjs/common"; import { Injectable, Inject } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { BaseCatalogService } from "./base-catalog.service.js"; import { BaseServicesService } from "./base-services.service.js";
import { CatalogCacheService } from "./catalog-cache.service.js"; import { ServicesCacheService } from "./services-cache.service.js";
import type { import type {
SalesforceProduct2WithPricebookEntries, SalesforceProduct2WithPricebookEntries,
SimCatalogProduct, SimCatalogProduct,
SimActivationFeeCatalogItem, SimActivationFeeCatalogItem,
} from "@customer-portal/domain/catalog"; } from "@customer-portal/domain/services";
import { Providers as CatalogProviders } from "@customer-portal/domain/catalog"; import { Providers as CatalogProviders } from "@customer-portal/domain/services";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { WhmcsConnectionOrchestratorService } from "@bff/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.js"; import { WhmcsConnectionOrchestratorService } from "@bff/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.js";
@Injectable() @Injectable()
export class SimCatalogService extends BaseCatalogService { export class SimServicesService extends BaseServicesService {
constructor( constructor(
sf: SalesforceConnection, sf: SalesforceConnection,
configService: ConfigService, configService: ConfigService,
@Inject(Logger) logger: Logger, @Inject(Logger) logger: Logger,
private mappingsService: MappingsService, private mappingsService: MappingsService,
private whmcs: WhmcsConnectionOrchestratorService, private whmcs: WhmcsConnectionOrchestratorService,
private catalogCache: CatalogCacheService private catalogCache: ServicesCacheService
) { ) {
super(sf, configService, logger); super(sf, configService, logger);
} }
async getPlans(): Promise<SimCatalogProduct[]> { 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, cacheKey,
async () => { async () => {
const soql = this.buildCatalogServiceQuery("SIM", [ const soql = this.buildServicesQuery("SIM", [
"SIM_Data_Size__c", "SIM_Data_Size__c",
"SIM_Plan_Type__c", "SIM_Plan_Type__c",
"SIM_Has_Family_Discount__c", "SIM_Has_Family_Discount__c",
@ -62,9 +62,9 @@ export class SimCatalogService extends BaseCatalogService {
} }
async getActivationFees(): Promise<SimActivationFeeCatalogItem[]> { 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, cacheKey,
async () => { async () => {
const soql = this.buildProductQuery("SIM", "Activation", [ const soql = this.buildProductQuery("SIM", "Activation", [
@ -115,9 +115,9 @@ export class SimCatalogService extends BaseCatalogService {
} }
async getAddons(): Promise<SimCatalogProduct[]> { 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, cacheKey,
async () => { async () => {
const soql = this.buildProductQuery("SIM", "Add-on", [ const soql = this.buildProductQuery("SIM", "Add-on", [
@ -184,22 +184,28 @@ export class SimCatalogService extends BaseCatalogService {
return false; return false;
} }
// Check WHMCS for existing SIM services const cacheKey = this.catalogCache.buildServicesKey(
const products = await this.whmcs.getClientsProducts({ clientid: mapping.whmcsClientId }); "sim",
const services = (products?.products?.product || []) as Array<{ "has-existing-sim",
groupname?: string; String(mapping.whmcsClientId)
status?: string;
}>;
// Look for active SIM services
const hasActiveSim = services.some(
service =>
String(service.groupname || "")
.toLowerCase()
.includes("sim") && String(service.status || "").toLowerCase() === "active"
); );
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) { } catch (error) {
this.logger.warn(`Failed to check existing SIM for user ${userId}`, error); this.logger.warn(`Failed to check existing SIM for user ${userId}`, error);
return false; // Default to no existing SIM 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 { SimScheduleService } from "./sim-schedule.service.js";
import { SimActionRunnerService } from "./sim-action-runner.service.js"; import { SimActionRunnerService } from "./sim-action-runner.service.js";
import { SimApiNotificationService } from "./sim-api-notification.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 { export interface CancellationMonth {
value: string; // YYYY-MM format value: string; // YYYY-MM format
@ -38,6 +40,7 @@ export class SimCancellationService {
private readonly simSchedule: SimScheduleService, private readonly simSchedule: SimScheduleService,
private readonly simActionRunner: SimActionRunnerService, private readonly simActionRunner: SimActionRunnerService,
private readonly apiNotification: SimApiNotificationService, private readonly apiNotification: SimApiNotificationService,
private readonly notifications: NotificationService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger
) {} ) {}
@ -254,6 +257,24 @@ export class SimCancellationService {
runDate, 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 // Send admin notification email
const adminEmailBody = this.apiNotification.buildCancellationAdminEmail({ const adminEmailBody = this.apiNotification.buildCancellationAdminEmail({
customerName, customerName,

View File

@ -12,8 +12,8 @@ import { SimScheduleService } from "./sim-schedule.service.js";
import { SimActionRunnerService } from "./sim-action-runner.service.js"; import { SimActionRunnerService } from "./sim-action-runner.service.js";
import { SimManagementQueueService } from "../queue/sim-management.queue.js"; import { SimManagementQueueService } from "../queue/sim-management.queue.js";
import { SimApiNotificationService } from "./sim-api-notification.service.js"; import { SimApiNotificationService } from "./sim-api-notification.service.js";
import { SimCatalogService } from "@bff/modules/catalog/services/sim-catalog.service.js"; import { SimServicesService } from "@bff/modules/services/services/sim-services.service.js";
import type { SimCatalogProduct } from "@customer-portal/domain/catalog"; import type { SimCatalogProduct } from "@customer-portal/domain/services";
// Mapping from Salesforce SKU to Freebit plan code // Mapping from Salesforce SKU to Freebit plan code
const SKU_TO_FREEBIT_PLAN_CODE: Record<string, string> = { const SKU_TO_FREEBIT_PLAN_CODE: Record<string, string> = {
@ -47,7 +47,7 @@ export class SimPlanService {
private readonly simActionRunner: SimActionRunnerService, private readonly simActionRunner: SimActionRunnerService,
private readonly simQueue: SimManagementQueueService, private readonly simQueue: SimManagementQueueService,
private readonly apiNotification: SimApiNotificationService, private readonly apiNotification: SimApiNotificationService,
private readonly simCatalog: SimCatalogService, private readonly simCatalog: SimServicesService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
@Inject(Logger) private readonly logger: Logger @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 { SimManagementProcessor } from "./queue/sim-management.processor.js";
import { SimVoiceOptionsService } from "./services/sim-voice-options.service.js"; import { SimVoiceOptionsService } from "./services/sim-voice-options.service.js";
import { SimCallHistoryService } from "./services/sim-call-history.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({ @Module({
imports: [ imports: [
@ -36,8 +37,9 @@ import { CatalogModule } from "@bff/modules/catalog/catalog.module.js";
SalesforceModule, SalesforceModule,
MappingsModule, MappingsModule,
EmailModule, EmailModule,
CatalogModule, ServicesModule,
SftpModule, SftpModule,
NotificationsModule,
], ],
providers: [ providers: [
// Core services that the SIM services depend on // Core services that the SIM services depend on

View File

@ -51,6 +51,12 @@ import {
type ReissueSimRequest, type ReissueSimRequest,
} from "./sim-management/services/esim-management.service.js"; } from "./sim-management/services/esim-management.service.js";
import { SimCallHistoryService } from "./sim-management/services/sim-call-history.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({ const subscriptionInvoiceQuerySchema = createPaginationSchema({
defaultLimit: 10, defaultLimit: 10,
@ -68,7 +74,8 @@ export class SubscriptionsController {
private readonly simPlanService: SimPlanService, private readonly simPlanService: SimPlanService,
private readonly simCancellationService: SimCancellationService, private readonly simCancellationService: SimCancellationService,
private readonly esimManagementService: EsimManagementService, private readonly esimManagementService: EsimManagementService,
private readonly simCallHistoryService: SimCallHistoryService private readonly simCallHistoryService: SimCallHistoryService,
private readonly internetCancellationService: InternetCancellationService
) {} ) {}
@Get() @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 ==================== // ==================== 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 { FreebitModule } from "@bff/integrations/freebit/freebit.module.js";
import { EmailModule } from "@bff/infra/email/email.module.js"; import { EmailModule } from "@bff/infra/email/email.module.js";
import { SimManagementModule } from "./sim-management/sim-management.module.js"; import { SimManagementModule } from "./sim-management/sim-management.module.js";
import { InternetManagementModule } from "./internet-management/internet-management.module.js";
@Module({ @Module({
imports: [WhmcsModule, MappingsModule, FreebitModule, EmailModule, SimManagementModule], imports: [
WhmcsModule,
MappingsModule,
FreebitModule,
EmailModule,
SimManagementModule,
InternetManagementModule,
],
controllers: [SubscriptionsController, SimOrdersController], controllers: [SubscriptionsController, SimOrdersController],
providers: [ providers: [
SubscriptionsService, SubscriptionsService,

View File

@ -12,6 +12,7 @@ import type {
} from "@customer-portal/domain/subscriptions"; } from "@customer-portal/domain/subscriptions";
import type { Invoice, InvoiceItem, InvoiceList } from "@customer-portal/domain/billing"; import type { Invoice, InvoiceItem, InvoiceList } from "@customer-portal/domain/billing";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js"; 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 { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import type { Providers } from "@customer-portal/domain/subscriptions"; import type { Providers } from "@customer-portal/domain/subscriptions";
@ -26,6 +27,7 @@ export interface GetSubscriptionsOptions {
export class SubscriptionsService { export class SubscriptionsService {
constructor( constructor(
private readonly whmcsService: WhmcsService, private readonly whmcsService: WhmcsService,
private readonly cacheService: WhmcsCacheService,
private readonly mappingsService: MappingsService, private readonly mappingsService: MappingsService,
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger
) {} ) {}
@ -316,6 +318,20 @@ export class SubscriptionsService {
const batchSize = Math.min(100, Math.max(limit, 25)); const batchSize = Math.min(100, Math.max(limit, 25));
try { 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 // Validate subscription exists and belongs to user
await this.getSubscriptionById(userId, subscriptionId); 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; return result;
} catch (error) { } catch (error) {
this.logger.error(`Failed to get invoices for subscription ${subscriptionId}`, { 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 { SupportService } from "./support.service.js";
import { ZodValidationPipe } from "nestjs-zod"; 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 { import {
supportCaseFilterSchema, supportCaseFilterSchema,
createCaseRequestSchema, createCaseRequestSchema,
@ -12,9 +26,23 @@ import {
} from "@customer-portal/domain/support"; } from "@customer-portal/domain/support";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; 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") @Controller("support")
export class SupportController { export class SupportController {
constructor(private readonly supportService: SupportService) {} constructor(
private readonly supportService: SupportService,
@Inject(Logger) private readonly logger: Logger
) {}
@Get("cases") @Get("cases")
async listCases( async listCases(
@ -41,4 +69,36 @@ export class SupportController {
): Promise<CreateCaseResponse> { ): Promise<CreateCaseResponse> {
return this.supportService.createCase(req.user.id, body); 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 * Get Salesforce account ID for a user
*/ */

View File

@ -204,9 +204,13 @@ export class UserProfileService {
return summary; return summary;
} }
const [subscriptionsData, invoicesData] = await Promise.allSettled([ const [subscriptionsData, invoicesData, unpaidInvoicesData] = await Promise.allSettled([
this.whmcsService.getSubscriptions(mapping.whmcsClientId, userId), 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; let activeSubscriptions = 0;
@ -256,12 +260,25 @@ export class UserProfileService {
paidDate?: string; paidDate?: string;
currency?: string | null; 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") { if (invoicesData.status === "fulfilled") {
const invoices: Invoice[] = invoicesData.value.invoices; const invoices: Invoice[] = invoicesData.value.invoices;
unpaidInvoices = invoices.filter( // Fallback if unpaid invoices call failed, though inaccurate for total count > 10
inv => inv.status === "Unpaid" || inv.status === "Overdue" if (unpaidInvoicesData.status === "rejected") {
).length; unpaidInvoices = invoices.filter(
inv => inv.status === "Unpaid" || inv.status === "Overdue"
).length;
}
const upcomingInvoices = invoices const upcomingInvoices = invoices
.filter(inv => (inv.status === "Unpaid" || inv.status === "Overdue") && inv.dueDate) .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", "rootDir": "./src",
"sourceMap": true "sourceMap": true
}, },
"include": ["src/**/*"], "include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts", "**/*.test.ts"] "exclude": ["node_modules", "dist", "test", "**/*.spec.ts", "**/*.test.ts"]
} }

View File

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

View File

@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <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 // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // 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 path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";

View File

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

View File

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

View File

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

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