commit
87766fb1d5
390
.cursor/plans/restructure_to_account_portal_efdb4b10.plan.md
Normal file
390
.cursor/plans/restructure_to_account_portal_efdb4b10.plan.md
Normal 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**
|
||||||
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@ -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
|
||||||
|
|||||||
2
.github/workflows/pr-checks.yml
vendored
2
.github/workflows/pr-checks.yml
vendored
@ -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
|
||||||
|
|||||||
4
.github/workflows/security.yml
vendored
4
.github/workflows/security.yml
vendored
@ -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: |
|
||||||
|
|||||||
@ -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`).
|
||||||
|
|||||||
@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
@ -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;
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
37
apps/bff/src/infra/cache/README.md
vendored
37
apps/bff/src/infra/cache/README.md
vendored
@ -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)
|
||||||
|
|
||||||
|
|||||||
7
apps/bff/src/infra/cache/cache.module.ts
vendored
7
apps/bff/src/infra/cache/cache.module.ts
vendored
@ -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 {}
|
||||||
|
|
||||||
|
|||||||
2
apps/bff/src/infra/cache/cache.service.ts
vendored
2
apps/bff/src/infra/cache/cache.service.ts
vendored
@ -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();
|
||||||
|
|||||||
188
apps/bff/src/infra/cache/distributed-lock.service.ts
vendored
Normal file
188
apps/bff/src/infra/cache/distributed-lock.service.ts
vendored
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -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,
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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";
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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`);
|
||||||
|
|||||||
@ -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 {}
|
||||||
|
|||||||
@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
16
apps/bff/src/modules/me-status/me-status.controller.ts
Normal file
16
apps/bff/src/modules/me-status/me-status.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
apps/bff/src/modules/me-status/me-status.module.ts
Normal file
27
apps/bff/src/modules/me-status/me-status.module.ts
Normal 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 {}
|
||||||
269
apps/bff/src/modules/me-status/me-status.service.ts
Normal file
269
apps/bff/src/modules/me-status/me-status.service.ts
Normal 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:
|
||||||
|
"We’re verifying if our service is available at your residence. We’ll 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 couldn’t 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
24
apps/bff/src/modules/notifications/notifications.module.ts
Normal file
24
apps/bff/src/modules/notifications/notifications.module.ts
Normal 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 {}
|
||||||
336
apps/bff/src/modules/notifications/notifications.service.ts
Normal file
336
apps/bff/src/modules/notifications/notifications.service.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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", {
|
||||||
|
|||||||
61
apps/bff/src/modules/services/account-services.controller.ts
Normal file
61
apps/bff/src/modules/services/account-services.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
54
apps/bff/src/modules/services/public-services.controller.ts
Normal file
54
apps/bff/src/modules/services/public-services.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(),
|
||||||
@ -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")
|
||||||
43
apps/bff/src/modules/services/services.module.ts
Normal file
43
apps/bff/src/modules/services/services.module.ts
Normal 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 {}
|
||||||
@ -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,
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
import type { Address } from "@customer-portal/domain/customer";
|
||||||
|
|
||||||
|
export type InternetEligibilityCheckRequest = {
|
||||||
|
email: string;
|
||||||
|
notes?: string;
|
||||||
|
address?: Partial<Address>;
|
||||||
|
};
|
||||||
@ -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(", ");
|
||||||
|
}
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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
|
||||||
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {}
|
||||||
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}`, {
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
281
apps/bff/src/modules/verification/residence-card.service.ts
Normal file
281
apps/bff/src/modules/verification/residence-card.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/bff/src/modules/verification/verification.module.ts
Normal file
14
apps/bff/src/modules/verification/verification.module.ts
Normal 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 {}
|
||||||
@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
2
apps/portal/next-env.d.ts
vendored
2
apps/portal/next-env.d.ts
vendored
@ -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.
|
||||||
|
|||||||
@ -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";
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
Loading…
x
Reference in New Issue
Block a user