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
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.25.0
|
||||
|
||||
- name: Setup Node.js
|
||||
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
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: "10.25.0"
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
|
||||
4
.github/workflows/security.yml
vendored
4
.github/workflows/security.yml
vendored
@ -30,8 +30,6 @@ jobs:
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: "10.25.0"
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
@ -139,8 +137,6 @@ jobs:
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: "10.25.0"
|
||||
|
||||
- name: Check for outdated dependencies
|
||||
run: |
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
# Stage all modified tracked files (includes manual fixes to already-staged files)
|
||||
git add -u
|
||||
|
||||
# Run lint-staged on staged files (checks linting, formats with prettier, and re-stages)
|
||||
pnpm lint-staged
|
||||
|
||||
# Run type check
|
||||
pnpm type-check
|
||||
|
||||
# Security audit is enforced in CI (`.github/workflows/security.yml`).
|
||||
|
||||
@ -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"]
|
||||
}
|
||||
|
||||
@ -280,7 +280,7 @@ When running `pnpm dev:tools`, you get access to:
|
||||
|
||||
- `POST /api/auth/signup` - Create portal user → WHMCS AddClient → SF upsert
|
||||
- `POST /api/auth/login` - Portal authentication
|
||||
- `POST /api/auth/link-whmcs` - OIDC callback or ValidateLogin
|
||||
- `POST /api/auth/migrate` - Account migration from legacy portal
|
||||
- `POST /api/auth/set-password` - Required after WHMCS link
|
||||
|
||||
### User Management
|
||||
@ -292,7 +292,7 @@ When running `pnpm dev:tools`, you get access to:
|
||||
|
||||
### Catalog & Orders
|
||||
|
||||
- `GET /api/catalog` - WHMCS GetProducts (cached 5-15m)
|
||||
- `GET /api/services/*` - Services catalog endpoints (internet/sim/vpn)
|
||||
- `POST /api/orders` - WHMCS AddOrder with idempotency
|
||||
|
||||
### Invoices
|
||||
@ -481,7 +481,7 @@ rm -rf node_modules && pnpm install
|
||||
- **[Deployment Guide](docs/DEPLOY.md)** - Production deployment instructions
|
||||
- **[Architecture](docs/STRUCTURE.md)** - Code organization and conventions
|
||||
- **[Logging](docs/LOGGING.md)** - Logging configuration and best practices
|
||||
- **Portal Guides** - High-level flow, data ownership, and error handling (`docs/portal-guides/README.md`)
|
||||
- **Portal Guides** - High-level flow, data ownership, and error handling (`docs/how-it-works/README.md`)
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@ -119,8 +119,8 @@ Security audits are automatically run on:
|
||||
|
||||
### Internal Documentation
|
||||
|
||||
- [Environment Configuration](./docs/portal-guides/COMPLETE-GUIDE.md)
|
||||
- [Deployment Guide](./docs/portal-guides/)
|
||||
- [Environment Configuration](./docs/how-it-works/COMPLETE-GUIDE.md)
|
||||
- [Deployment Guide](./docs/getting-started/)
|
||||
|
||||
### External Resources
|
||||
|
||||
|
||||
@ -38,6 +38,7 @@
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.1.9",
|
||||
"@nestjs/platform-express": "^11.1.9",
|
||||
"@nestjs/schedule": "^6.1.0",
|
||||
"@prisma/adapter-pg": "^7.1.0",
|
||||
"@prisma/client": "^7.1.0",
|
||||
"@sendgrid/mail": "^8.1.6",
|
||||
|
||||
@ -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")
|
||||
auditLogs AuditLog[]
|
||||
idMapping IdMapping?
|
||||
residenceCardSubmission ResidenceCardSubmission?
|
||||
notifications Notification[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@ -91,6 +93,30 @@ enum AuditAction {
|
||||
SYSTEM_MAINTENANCE
|
||||
}
|
||||
|
||||
enum ResidenceCardStatus {
|
||||
PENDING
|
||||
VERIFIED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
model ResidenceCardSubmission {
|
||||
id String @id @default(uuid())
|
||||
userId String @unique @map("user_id")
|
||||
status ResidenceCardStatus @default(PENDING)
|
||||
filename String
|
||||
mimeType String @map("mime_type")
|
||||
sizeBytes Int @map("size_bytes")
|
||||
content Bytes @db.ByteA
|
||||
submittedAt DateTime @default(now()) @map("submitted_at")
|
||||
reviewedAt DateTime? @map("reviewed_at")
|
||||
reviewerNotes String? @map("reviewer_notes")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("residence_card_submissions")
|
||||
}
|
||||
|
||||
// Per-SIM daily usage snapshot used to build full-month charts
|
||||
model SimUsageDaily {
|
||||
id Int @id @default(autoincrement())
|
||||
@ -191,3 +217,63 @@ model SimHistoryImport {
|
||||
|
||||
@@map("sim_history_imports")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Notifications - In-app notifications synced with Salesforce email triggers
|
||||
// =============================================================================
|
||||
|
||||
model Notification {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
|
||||
// Notification content
|
||||
type NotificationType
|
||||
title String
|
||||
message String?
|
||||
|
||||
// Action (optional CTA button)
|
||||
actionUrl String? @map("action_url")
|
||||
actionLabel String? @map("action_label")
|
||||
|
||||
// Source tracking for deduplication
|
||||
source NotificationSource @default(SALESFORCE)
|
||||
sourceId String? @map("source_id") // SF Account ID, Order ID, etc.
|
||||
|
||||
// Status
|
||||
read Boolean @default(false)
|
||||
readAt DateTime? @map("read_at")
|
||||
dismissed Boolean @default(false)
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
expiresAt DateTime @map("expires_at") // 30 days from creation
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, read, dismissed])
|
||||
@@index([userId, createdAt])
|
||||
@@index([expiresAt])
|
||||
@@map("notifications")
|
||||
}
|
||||
|
||||
enum NotificationType {
|
||||
ELIGIBILITY_ELIGIBLE
|
||||
ELIGIBILITY_INELIGIBLE
|
||||
VERIFICATION_VERIFIED
|
||||
VERIFICATION_REJECTED
|
||||
ORDER_APPROVED
|
||||
ORDER_ACTIVATED
|
||||
ORDER_FAILED
|
||||
CANCELLATION_SCHEDULED
|
||||
CANCELLATION_COMPLETE
|
||||
PAYMENT_METHOD_EXPIRING
|
||||
INVOICE_DUE
|
||||
SYSTEM_ANNOUNCEMENT
|
||||
}
|
||||
|
||||
enum NotificationSource {
|
||||
SALESFORCE
|
||||
WHMCS
|
||||
PORTAL
|
||||
SYSTEM
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { Module } from "@nestjs/common";
|
||||
import { APP_PIPE } from "@nestjs/core";
|
||||
import { RouterModule } from "@nestjs/core";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { ZodValidationPipe } from "nestjs-zod";
|
||||
|
||||
// Configuration
|
||||
@ -27,14 +28,17 @@ import { SalesforceEventsModule } from "@bff/integrations/salesforce/events/even
|
||||
// Feature Modules
|
||||
import { AuthModule } from "@bff/modules/auth/auth.module.js";
|
||||
import { UsersModule } from "@bff/modules/users/users.module.js";
|
||||
import { MeStatusModule } from "@bff/modules/me-status/me-status.module.js";
|
||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
||||
import { CatalogModule } from "@bff/modules/catalog/catalog.module.js";
|
||||
import { ServicesModule } from "@bff/modules/services/services.module.js";
|
||||
import { OrdersModule } from "@bff/modules/orders/orders.module.js";
|
||||
import { InvoicesModule } from "@bff/modules/invoices/invoices.module.js";
|
||||
import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.module.js";
|
||||
import { CurrencyModule } from "@bff/modules/currency/currency.module.js";
|
||||
import { SupportModule } from "@bff/modules/support/support.module.js";
|
||||
import { RealtimeApiModule } from "@bff/modules/realtime/realtime.module.js";
|
||||
import { VerificationModule } from "@bff/modules/verification/verification.module.js";
|
||||
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
|
||||
|
||||
// System Modules
|
||||
import { HealthModule } from "@bff/modules/health/health.module.js";
|
||||
@ -55,6 +59,7 @@ import { HealthModule } from "@bff/modules/health/health.module.js";
|
||||
imports: [
|
||||
// === CONFIGURATION ===
|
||||
ConfigModule.forRoot(appConfig),
|
||||
ScheduleModule.forRoot(),
|
||||
|
||||
// === INFRASTRUCTURE ===
|
||||
LoggingModule,
|
||||
@ -77,14 +82,17 @@ import { HealthModule } from "@bff/modules/health/health.module.js";
|
||||
// === FEATURE MODULES ===
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
MeStatusModule,
|
||||
MappingsModule,
|
||||
CatalogModule,
|
||||
ServicesModule,
|
||||
OrdersModule,
|
||||
InvoicesModule,
|
||||
SubscriptionsModule,
|
||||
CurrencyModule,
|
||||
SupportModule,
|
||||
RealtimeApiModule,
|
||||
VerificationModule,
|
||||
NotificationsModule,
|
||||
|
||||
// === SYSTEM MODULES ===
|
||||
HealthModule,
|
||||
|
||||
@ -54,6 +54,20 @@ export const envSchema = z.object({
|
||||
"Authentication service is temporarily unavailable for maintenance. Please try again later."
|
||||
),
|
||||
|
||||
/**
|
||||
* Services catalog/eligibility cache safety TTL.
|
||||
*
|
||||
* Primary invalidation is event-driven (Salesforce CDC / Platform Events).
|
||||
* This TTL is a safety net to self-heal if events are missed.
|
||||
*
|
||||
* Set to 0 to disable safety TTL (pure event-driven).
|
||||
*/
|
||||
SERVICES_CACHE_SAFETY_TTL_SECONDS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.default(60 * 60 * 12),
|
||||
|
||||
DATABASE_URL: z.string().url(),
|
||||
|
||||
WHMCS_BASE_URL: z.string().url().optional(),
|
||||
@ -132,6 +146,25 @@ export const envSchema = z.object({
|
||||
|
||||
// Salesforce Field Mappings - Account
|
||||
ACCOUNT_INTERNET_ELIGIBILITY_FIELD: z.string().default("Internet_Eligibility__c"),
|
||||
ACCOUNT_INTERNET_ELIGIBILITY_STATUS_FIELD: z.string().default("Internet_Eligibility_Status__c"),
|
||||
ACCOUNT_INTERNET_ELIGIBILITY_REQUESTED_AT_FIELD: z
|
||||
.string()
|
||||
.default("Internet_Eligibility_Request_Date_Time__c"),
|
||||
ACCOUNT_INTERNET_ELIGIBILITY_CHECKED_AT_FIELD: z
|
||||
.string()
|
||||
.default("Internet_Eligibility_Checked_Date_Time__c"),
|
||||
|
||||
ACCOUNT_ID_VERIFICATION_STATUS_FIELD: z.string().default("Id_Verification_Status__c"),
|
||||
ACCOUNT_ID_VERIFICATION_SUBMITTED_AT_FIELD: z
|
||||
.string()
|
||||
.default("Id_Verification_Submitted_Date_Time__c"),
|
||||
ACCOUNT_ID_VERIFICATION_VERIFIED_AT_FIELD: z
|
||||
.string()
|
||||
.default("Id_Verification_Verified_Date_Time__c"),
|
||||
ACCOUNT_ID_VERIFICATION_NOTE_FIELD: z.string().default("Id_Verification_Note__c"),
|
||||
ACCOUNT_ID_VERIFICATION_REJECTION_MESSAGE_FIELD: z
|
||||
.string()
|
||||
.default("Id_Verification_Rejection_Message__c"),
|
||||
ACCOUNT_CUSTOMER_NUMBER_FIELD: z.string().default("SF_Account_No__c"),
|
||||
|
||||
// Salesforce Field Mappings - Product
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import type { Routes } from "@nestjs/core";
|
||||
import { AuthModule } from "@bff/modules/auth/auth.module.js";
|
||||
import { UsersModule } from "@bff/modules/users/users.module.js";
|
||||
import { MeStatusModule } from "@bff/modules/me-status/me-status.module.js";
|
||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
||||
import { CatalogModule } from "@bff/modules/catalog/catalog.module.js";
|
||||
import { ServicesModule } from "@bff/modules/services/services.module.js";
|
||||
import { OrdersModule } from "@bff/modules/orders/orders.module.js";
|
||||
import { InvoicesModule } from "@bff/modules/invoices/invoices.module.js";
|
||||
import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.module.js";
|
||||
@ -10,6 +11,8 @@ import { CurrencyModule } from "@bff/modules/currency/currency.module.js";
|
||||
import { SecurityModule } from "@bff/core/security/security.module.js";
|
||||
import { SupportModule } from "@bff/modules/support/support.module.js";
|
||||
import { RealtimeApiModule } from "@bff/modules/realtime/realtime.module.js";
|
||||
import { VerificationModule } from "@bff/modules/verification/verification.module.js";
|
||||
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
|
||||
|
||||
export const apiRoutes: Routes = [
|
||||
{
|
||||
@ -17,8 +20,9 @@ export const apiRoutes: Routes = [
|
||||
children: [
|
||||
{ path: "", module: AuthModule },
|
||||
{ path: "", module: UsersModule },
|
||||
{ path: "", module: MeStatusModule },
|
||||
{ path: "", module: MappingsModule },
|
||||
{ path: "", module: CatalogModule },
|
||||
{ path: "", module: ServicesModule },
|
||||
{ path: "", module: OrdersModule },
|
||||
{ path: "", module: InvoicesModule },
|
||||
{ path: "", module: SubscriptionsModule },
|
||||
@ -26,6 +30,8 @@ export const apiRoutes: Routes = [
|
||||
{ path: "", module: SupportModule },
|
||||
{ path: "", module: SecurityModule },
|
||||
{ path: "", module: RealtimeApiModule },
|
||||
{ path: "", module: VerificationModule },
|
||||
{ path: "", module: NotificationsModule },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@ -51,7 +51,7 @@ export class CsrfMiddleware implements NestMiddleware {
|
||||
"/api/auth/request-password-reset",
|
||||
"/api/auth/reset-password", // Public auth endpoint for password reset
|
||||
"/api/auth/set-password", // Public auth endpoint for setting password after WHMCS link
|
||||
"/api/auth/link-whmcs", // Public auth endpoint for WHMCS account linking
|
||||
"/api/auth/migrate", // Public auth endpoint for account migration
|
||||
"/api/health",
|
||||
"/docs",
|
||||
"/api/webhooks", // Webhooks typically don't use CSRF
|
||||
|
||||
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 │
|
||||
│ - OrdersCacheService (CDC-driven, no TTL) │
|
||||
│ - CatalogCacheService (CDC-driven, no TTL) │
|
||||
│ - OrdersCacheService (CDC-driven) │
|
||||
│ - ServicesCacheService (CDC-driven + safety TTL) │
|
||||
│ - WhmcsCacheService (TTL-based) │
|
||||
│ │
|
||||
│ Features: │
|
||||
@ -61,18 +61,26 @@ Redis-backed caching system with CDC (Change Data Capture) event-driven invalida
|
||||
|
||||
### 1. CDC-Driven (Orders, Catalog)
|
||||
|
||||
**No TTL** - Cache persists indefinitely until CDC event triggers invalidation.
|
||||
**Event-driven invalidation + safety TTL** - Cache is invalidated on CDC events, and also expires after a long TTL as a safety net.
|
||||
|
||||
Why: CDC is the primary freshness mechanism, but a safety TTL helps self-heal if events are missed (deploy downtime, subscriber issues, replay gaps).
|
||||
|
||||
Config:
|
||||
|
||||
- `SERVICES_CACHE_SAFETY_TTL_SECONDS` (default: 12 hours, set to `0` to disable)
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Real-time invalidation when data changes
|
||||
- Zero stale data for customer-visible fields
|
||||
- Optimal for frequently read, infrequently changed data
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class OrdersCacheService {
|
||||
// No TTL = CDC-only invalidation
|
||||
// CDC invalidation + safety TTL (service-specific)
|
||||
async getOrderSummaries(
|
||||
sfAccountId: string,
|
||||
fetcher: () => Promise<OrderSummary[]>
|
||||
@ -88,11 +96,13 @@ export class OrdersCacheService {
|
||||
**Fixed TTL** - Cache expires after a set duration.
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Simple, predictable behavior
|
||||
- Good for external systems without CDC
|
||||
- Automatic cleanup of stale data
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class WhmcsCacheService {
|
||||
@ -152,7 +162,7 @@ All cache services track performance metrics:
|
||||
|
||||
```typescript
|
||||
{
|
||||
catalog: { hits: 1250, misses: 48 },
|
||||
services: { hits: 1250, misses: 48 },
|
||||
static: { hits: 890, misses: 12 },
|
||||
volatile: { hits: 450, misses: 120 },
|
||||
invalidations: 15
|
||||
@ -160,7 +170,8 @@ All cache services track performance metrics:
|
||||
```
|
||||
|
||||
Access via health endpoints:
|
||||
- `GET /health/catalog/cache`
|
||||
|
||||
- `GET /api/health/services/cache`
|
||||
- `GET /health`
|
||||
|
||||
## Creating a New Cache Service
|
||||
@ -211,7 +222,7 @@ async getMyData(id: string, fetcher: () => Promise<MyData>): Promise<MyData> {
|
||||
const fetchPromise = (async () => {
|
||||
try {
|
||||
const fresh = await fetcher();
|
||||
await this.cache.set(key, fresh); // No TTL = CDC-driven
|
||||
await this.cache.set(key, fresh); // CDC-driven (TTL varies by domain)
|
||||
return fresh;
|
||||
} finally {
|
||||
this.inflightRequests.delete(key);
|
||||
@ -255,10 +266,11 @@ domain:type:identifier[:subkey]
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
- `orders:account:001xx000003EgI1AAK`
|
||||
- `orders:detail:80122000000D4UGAA0`
|
||||
- `catalog:internet:acc_001:jp`
|
||||
- `catalog:deps:product:01t22000003xABCAA2`
|
||||
- `services:internet:acc_001:jp`
|
||||
- `services:deps:product:01t22000003xABCAA2`
|
||||
- `mapping:userId:user_12345`
|
||||
|
||||
## Configuration
|
||||
@ -287,8 +299,8 @@ Provides global `REDIS_CLIENT` using ioredis.
|
||||
# Overall system health (includes Redis check)
|
||||
GET /health
|
||||
|
||||
# Catalog cache metrics
|
||||
GET /health/catalog/cache
|
||||
# Services cache metrics
|
||||
GET /api/health/services/cache
|
||||
```
|
||||
|
||||
### Response Format
|
||||
@ -302,7 +314,7 @@ GET /health/catalog/cache
|
||||
"invalidations": 15
|
||||
},
|
||||
"ttl": {
|
||||
"catalogSeconds": null,
|
||||
"servicesSeconds": null,
|
||||
"staticSeconds": null,
|
||||
"volatileSeconds": 60
|
||||
}
|
||||
@ -357,4 +369,3 @@ console.log(`${count} keys using ${usage} bytes`);
|
||||
- [Salesforce CDC Events](../../integrations/salesforce/events/README.md)
|
||||
- [Order Fulfillment Flow](../../modules/orders/docs/FULFILLMENT.md)
|
||||
- [Redis Configuration](../redis/README.md)
|
||||
|
||||
|
||||
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 { CacheService } from "./cache.service.js";
|
||||
import { DistributedLockService } from "./distributed-lock.service.js";
|
||||
|
||||
/**
|
||||
* Global cache module
|
||||
*
|
||||
* Provides Redis-backed caching infrastructure for the entire application.
|
||||
* Exports CacheService for use in domain-specific cache services.
|
||||
* Exports CacheService and DistributedLockService for use in domain services.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [CacheService],
|
||||
exports: [CacheService],
|
||||
providers: [CacheService, DistributedLockService],
|
||||
exports: [CacheService, DistributedLockService],
|
||||
})
|
||||
export class CacheModule {}
|
||||
|
||||
|
||||
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
|
||||
* Uses SCAN for safe operation on large datasets
|
||||
* @param pattern Redis pattern (e.g., "orders:*", "catalog:product:*")
|
||||
* @param pattern Redis pattern (e.g., "orders:*", "services:product:*")
|
||||
*/
|
||||
async delPattern(pattern: string): Promise<void> {
|
||||
const pipeline = this.redis.pipeline();
|
||||
|
||||
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.
|
||||
* Examples:
|
||||
* - orders:sf:801xx0000001234
|
||||
* - catalog:eligibility:001xx000000abcd
|
||||
* - services:eligibility:001xx000000abcd
|
||||
*/
|
||||
topic: string;
|
||||
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 { IntegrationsModule } from "@bff/integrations/integrations.module.js";
|
||||
import { OrdersModule } from "@bff/modules/orders/orders.module.js";
|
||||
import { CatalogModule } from "@bff/modules/catalog/catalog.module.js";
|
||||
import { CatalogCdcSubscriber } from "./catalog-cdc.subscriber.js";
|
||||
import { ServicesModule } from "@bff/modules/services/services.module.js";
|
||||
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
|
||||
import { ServicesCdcSubscriber } from "./services-cdc.subscriber.js";
|
||||
import { OrderCdcSubscriber } from "./order-cdc.subscriber.js";
|
||||
|
||||
@Module({
|
||||
@ -11,10 +12,11 @@ import { OrderCdcSubscriber } from "./order-cdc.subscriber.js";
|
||||
ConfigModule,
|
||||
forwardRef(() => IntegrationsModule),
|
||||
forwardRef(() => OrdersModule),
|
||||
forwardRef(() => CatalogModule),
|
||||
forwardRef(() => ServicesModule),
|
||||
forwardRef(() => NotificationsModule),
|
||||
],
|
||||
providers: [
|
||||
CatalogCdcSubscriber, // CDC for catalog cache invalidation
|
||||
ServicesCdcSubscriber, // CDC for services cache invalidation + notifications
|
||||
OrderCdcSubscriber, // CDC for order cache invalidation
|
||||
],
|
||||
})
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Injectable, Inject, Optional } from "@nestjs/common";
|
||||
import type { OnModuleInit, OnModuleDestroy } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import PubSubApiClientPkg from "salesforce-pubsub-api-client";
|
||||
import { SalesforceConnection } from "../services/salesforce-connection.service.js";
|
||||
import { CatalogCacheService } from "@bff/modules/catalog/services/catalog-cache.service.js";
|
||||
import { ServicesCacheService } from "@bff/modules/services/services/services-cache.service.js";
|
||||
import { RealtimeService } from "@bff/infra/realtime/realtime.service.js";
|
||||
import { AccountNotificationHandler } from "@bff/modules/notifications/account-cdc-listener.service.js";
|
||||
|
||||
type PubSubCallback = (
|
||||
subscription: { topicName?: string },
|
||||
@ -27,7 +28,7 @@ type PubSubCtor = new (opts: {
|
||||
}) => PubSubClient;
|
||||
|
||||
@Injectable()
|
||||
export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
||||
export class ServicesCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
||||
private client: PubSubClient | null = null;
|
||||
private pubSubCtor: PubSubCtor | null = null;
|
||||
private productChannel: string | null = null;
|
||||
@ -38,9 +39,10 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly sfConnection: SalesforceConnection,
|
||||
private readonly catalogCache: CatalogCacheService,
|
||||
private readonly catalogCache: ServicesCacheService,
|
||||
private readonly realtime: RealtimeService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
@Optional() private readonly accountNotificationHandler?: AccountNotificationHandler
|
||||
) {
|
||||
this.numRequested = this.resolveNumRequested();
|
||||
}
|
||||
@ -192,9 +194,9 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
||||
productIds,
|
||||
}
|
||||
);
|
||||
await this.invalidateAllCatalogs();
|
||||
// Full invalidation already implies all clients should refetch catalog
|
||||
this.realtime.publish("global:catalog", "catalog.changed", {
|
||||
await this.invalidateAllServices();
|
||||
// Full invalidation already implies all clients should refetch services
|
||||
this.realtime.publish("global:services", "services.changed", {
|
||||
reason: "product.cdc.fallback_full_invalidation",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
@ -202,7 +204,7 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
// Product changes can affect catalog results for all users
|
||||
this.realtime.publish("global:catalog", "catalog.changed", {
|
||||
this.realtime.publish("global:services", "services.changed", {
|
||||
reason: "product.cdc",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
@ -246,15 +248,15 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
||||
productId,
|
||||
}
|
||||
);
|
||||
await this.invalidateAllCatalogs();
|
||||
this.realtime.publish("global:catalog", "catalog.changed", {
|
||||
await this.invalidateAllServices();
|
||||
this.realtime.publish("global:services", "services.changed", {
|
||||
reason: "pricebook.cdc.fallback_full_invalidation",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.realtime.publish("global:catalog", "catalog.changed", {
|
||||
this.realtime.publish("global:services", "services.changed", {
|
||||
reason: "pricebook.cdc",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
@ -269,9 +271,22 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
||||
if (!this.isDataCallback(callbackType)) return;
|
||||
const payload = this.extractPayload(data);
|
||||
const accountId = this.extractStringField(payload, ["AccountId__c", "AccountId", "Id"]);
|
||||
const eligibility = this.extractStringField(payload, [
|
||||
"Internet_Eligibility__c",
|
||||
"InternetEligibility__c",
|
||||
const eligibility = this.extractStringField(payload, ["Internet_Eligibility__c"]);
|
||||
const status = this.extractStringField(payload, ["Internet_Eligibility_Status__c"]);
|
||||
const requestedAt = this.extractStringField(payload, [
|
||||
"Internet_Eligibility_Request_Date_Time__c",
|
||||
]);
|
||||
const checkedAt = this.extractStringField(payload, [
|
||||
"Internet_Eligibility_Checked_Date_Time__c",
|
||||
]);
|
||||
|
||||
// Note: Request ID field is not used in this environment
|
||||
const requestId = undefined;
|
||||
|
||||
// Also extract ID verification fields for notifications
|
||||
const verificationStatus = this.extractStringField(payload, ["Id_Verification_Status__c"]);
|
||||
const verificationRejection = this.extractStringField(payload, [
|
||||
"Id_Verification_Rejection_Message__c",
|
||||
]);
|
||||
|
||||
if (!accountId) {
|
||||
@ -288,19 +303,55 @@ export class CatalogCdcSubscriber implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
|
||||
await this.catalogCache.invalidateEligibility(accountId);
|
||||
await this.catalogCache.setEligibilityValue(accountId, eligibility ?? null);
|
||||
const hasDetails = Boolean(status || eligibility || requestedAt || checkedAt || requestId);
|
||||
if (hasDetails) {
|
||||
await this.catalogCache.setEligibilityDetails(accountId, {
|
||||
status: this.mapEligibilityStatus(status, eligibility),
|
||||
eligibility: eligibility ?? null,
|
||||
requestId: requestId ?? null,
|
||||
requestedAt: requestedAt ?? null,
|
||||
checkedAt: checkedAt ?? null,
|
||||
notes: null, // Field not used
|
||||
});
|
||||
}
|
||||
|
||||
// Notify connected portals immediately (multi-instance safe via Redis pub/sub)
|
||||
this.realtime.publish(`account:sf:${accountId}`, "catalog.eligibility.changed", {
|
||||
this.realtime.publish(`account:sf:${accountId}`, "services.eligibility.changed", {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Create in-app notifications for eligibility/verification status changes
|
||||
if (this.accountNotificationHandler && (status || verificationStatus)) {
|
||||
void this.accountNotificationHandler.processAccountEvent({
|
||||
accountId,
|
||||
eligibilityStatus: status,
|
||||
eligibilityValue: eligibility,
|
||||
verificationStatus,
|
||||
verificationRejectionMessage: verificationRejection,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async invalidateAllCatalogs(): Promise<void> {
|
||||
private mapEligibilityStatus(
|
||||
statusRaw: string | undefined,
|
||||
eligibilityRaw: string | undefined
|
||||
): "not_requested" | "pending" | "eligible" | "ineligible" {
|
||||
const normalizedStatus = typeof statusRaw === "string" ? statusRaw.trim().toLowerCase() : "";
|
||||
const eligibility = typeof eligibilityRaw === "string" ? eligibilityRaw.trim() : "";
|
||||
|
||||
if (normalizedStatus === "pending" || normalizedStatus === "checking") return "pending";
|
||||
if (normalizedStatus === "eligible") return "eligible";
|
||||
if (normalizedStatus === "ineligible" || normalizedStatus === "not available")
|
||||
return "ineligible";
|
||||
if (eligibility.length > 0) return "eligible";
|
||||
return "not_requested";
|
||||
}
|
||||
|
||||
private async invalidateAllServices(): Promise<void> {
|
||||
try {
|
||||
await this.catalogCache.invalidateAllCatalogs();
|
||||
await this.catalogCache.invalidateAllServices();
|
||||
} catch (error) {
|
||||
this.logger.warn("Failed to invalidate catalog caches", {
|
||||
this.logger.warn("Failed to invalidate services caches", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
@ -6,6 +6,8 @@ import { SalesforceConnection } from "./services/salesforce-connection.service.j
|
||||
import { SalesforceAccountService } from "./services/salesforce-account.service.js";
|
||||
import { SalesforceOrderService } from "./services/salesforce-order.service.js";
|
||||
import { SalesforceCaseService } from "./services/salesforce-case.service.js";
|
||||
import { SalesforceOpportunityService } from "./services/salesforce-opportunity.service.js";
|
||||
import { OpportunityResolutionService } from "./services/opportunity-resolution.service.js";
|
||||
import { OrderFieldConfigModule } from "@bff/modules/orders/config/order-field-config.module.js";
|
||||
import { SalesforceReadThrottleGuard } from "./guards/salesforce-read-throttle.guard.js";
|
||||
import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle.guard.js";
|
||||
@ -17,6 +19,8 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle
|
||||
SalesforceAccountService,
|
||||
SalesforceOrderService,
|
||||
SalesforceCaseService,
|
||||
SalesforceOpportunityService,
|
||||
OpportunityResolutionService,
|
||||
SalesforceService,
|
||||
SalesforceReadThrottleGuard,
|
||||
SalesforceWriteThrottleGuard,
|
||||
@ -25,8 +29,11 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle
|
||||
QueueModule,
|
||||
SalesforceService,
|
||||
SalesforceConnection,
|
||||
SalesforceAccountService,
|
||||
SalesforceOrderService,
|
||||
SalesforceCaseService,
|
||||
SalesforceOpportunityService,
|
||||
OpportunityResolutionService,
|
||||
SalesforceReadThrottleGuard,
|
||||
SalesforceWriteThrottleGuard,
|
||||
],
|
||||
|
||||
@ -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, "\\'");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Account Creation Methods (for Checkout Registration)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if a Salesforce account exists with the given email
|
||||
* Used to prevent duplicate account creation during checkout
|
||||
*/
|
||||
async findByEmail(email: string): Promise<{ id: string; accountNumber: string } | null> {
|
||||
try {
|
||||
// Search for Contact with matching email and get the associated Account
|
||||
const result = (await this.connection.query(
|
||||
`SELECT Account.Id, Account.SF_Account_No__c FROM Contact WHERE Email = '${this.safeSoql(email)}' LIMIT 1`,
|
||||
{ label: "checkout:findAccountByEmail" }
|
||||
)) as SalesforceResponse<{ Account: { Id: string; SF_Account_No__c: string } }>;
|
||||
|
||||
if (result.totalSize > 0 && result.records[0]?.Account) {
|
||||
return {
|
||||
id: result.records[0].Account.Id,
|
||||
accountNumber: result.records[0].Account.SF_Account_No__c,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to find account by email", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Salesforce Account for a new customer
|
||||
* Used when customer signs up through checkout (no existing sfNumber)
|
||||
*
|
||||
* @returns The created account ID and auto-generated account number
|
||||
*/
|
||||
async createAccount(
|
||||
data: CreateSalesforceAccountRequest
|
||||
): Promise<{ accountId: string; accountNumber: string }> {
|
||||
this.logger.log("Creating new Salesforce Account", { email: data.email });
|
||||
|
||||
const accountPayload = {
|
||||
Name: `${data.firstName} ${data.lastName}`,
|
||||
BillingStreet:
|
||||
data.address.address1 + (data.address.address2 ? `\n${data.address.address2}` : ""),
|
||||
BillingCity: data.address.city,
|
||||
BillingState: data.address.state,
|
||||
BillingPostalCode: data.address.postcode,
|
||||
BillingCountry: data.address.country,
|
||||
Phone: data.phone,
|
||||
// Portal tracking fields
|
||||
[this.portalStatusField]: "Active",
|
||||
[this.portalSourceField]: "Portal Checkout",
|
||||
};
|
||||
|
||||
try {
|
||||
const createMethod = this.connection.sobject("Account").create;
|
||||
if (!createMethod) {
|
||||
throw new Error("Salesforce create method not available");
|
||||
}
|
||||
|
||||
const result = await createMethod(accountPayload);
|
||||
|
||||
if (!result || typeof result !== "object" || !("id" in result)) {
|
||||
throw new Error("Salesforce Account creation failed - no ID returned");
|
||||
}
|
||||
|
||||
const accountId = result.id as string;
|
||||
|
||||
// Query back the auto-generated account number
|
||||
const accountRecord = (await this.connection.query(
|
||||
`SELECT SF_Account_No__c FROM Account WHERE Id = '${this.safeSoql(accountId)}'`,
|
||||
{ label: "checkout:getCreatedAccountNumber" }
|
||||
)) as SalesforceResponse<{ SF_Account_No__c: string }>;
|
||||
|
||||
const accountNumber = accountRecord.records[0]?.SF_Account_No__c || "";
|
||||
|
||||
if (!accountNumber) {
|
||||
this.logger.warn("Account number not found for newly created account", { accountId });
|
||||
}
|
||||
|
||||
this.logger.log("Salesforce Account created", {
|
||||
accountId,
|
||||
accountNumber,
|
||||
});
|
||||
|
||||
return {
|
||||
accountId,
|
||||
accountNumber,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to create Salesforce Account", {
|
||||
error: getErrorMessage(error),
|
||||
email: data.email,
|
||||
});
|
||||
throw new Error("Failed to create customer account in CRM");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Contact associated with an Account
|
||||
*/
|
||||
async createContact(data: CreateSalesforceContactRequest): Promise<{ contactId: string }> {
|
||||
this.logger.log("Creating Salesforce Contact", {
|
||||
accountId: data.accountId,
|
||||
email: data.email,
|
||||
});
|
||||
|
||||
const contactPayload = {
|
||||
AccountId: data.accountId,
|
||||
FirstName: data.firstName,
|
||||
LastName: data.lastName,
|
||||
Email: data.email,
|
||||
Phone: data.phone,
|
||||
MailingStreet:
|
||||
data.address.address1 + (data.address.address2 ? `\n${data.address.address2}` : ""),
|
||||
MailingCity: data.address.city,
|
||||
MailingState: data.address.state,
|
||||
MailingPostalCode: data.address.postcode,
|
||||
MailingCountry: data.address.country,
|
||||
};
|
||||
|
||||
try {
|
||||
const createMethod = this.connection.sobject("Contact").create;
|
||||
if (!createMethod) {
|
||||
throw new Error("Salesforce create method not available");
|
||||
}
|
||||
|
||||
const result = await createMethod(contactPayload);
|
||||
|
||||
if (!result || typeof result !== "object" || !("id" in result)) {
|
||||
throw new Error("Salesforce Contact creation failed - no ID returned");
|
||||
}
|
||||
|
||||
const contactId = result.id as string;
|
||||
|
||||
this.logger.log("Salesforce Contact created", { contactId });
|
||||
return { contactId };
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to create Salesforce Contact", {
|
||||
error: getErrorMessage(error),
|
||||
accountId: data.accountId,
|
||||
});
|
||||
throw new Error("Failed to create customer contact in CRM");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Portal Field Update Methods
|
||||
// ============================================================================
|
||||
|
||||
async updatePortalFields(
|
||||
accountId: string,
|
||||
update: SalesforceAccountPortalUpdate
|
||||
@ -189,3 +342,40 @@ export interface SalesforceAccountPortalUpdate {
|
||||
lastSignedInAt?: Date;
|
||||
whmcsAccountId?: string | number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request type for creating a new Salesforce Account
|
||||
*/
|
||||
export interface CreateSalesforceAccountRequest {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: {
|
||||
address1: string;
|
||||
address2?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postcode: string;
|
||||
country: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Request type for creating a new Salesforce Contact
|
||||
*/
|
||||
export interface CreateSalesforceContactRequest {
|
||||
accountId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: {
|
||||
address1: string;
|
||||
address2?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postcode: string;
|
||||
country: string;
|
||||
};
|
||||
}
|
||||
|
||||
@ -185,6 +185,59 @@ export class SalesforceCaseService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Web-to-Case for public contact form submissions
|
||||
* Does not require an Account - uses supplied contact info
|
||||
*/
|
||||
async createWebCase(params: {
|
||||
subject: string;
|
||||
description: string;
|
||||
suppliedEmail: string;
|
||||
suppliedName: string;
|
||||
suppliedPhone?: string;
|
||||
origin?: string;
|
||||
priority?: string;
|
||||
}): Promise<{ id: string; caseNumber: string }> {
|
||||
this.logger.log("Creating Web-to-Case", { email: params.suppliedEmail });
|
||||
|
||||
const casePayload: Record<string, unknown> = {
|
||||
Origin: params.origin ?? "Web",
|
||||
Status: SALESFORCE_CASE_STATUS.NEW,
|
||||
Priority: params.priority ?? SALESFORCE_CASE_PRIORITY.MEDIUM,
|
||||
Subject: params.subject.trim(),
|
||||
Description: params.description.trim(),
|
||||
SuppliedEmail: params.suppliedEmail,
|
||||
SuppliedName: params.suppliedName,
|
||||
SuppliedPhone: params.suppliedPhone ?? null,
|
||||
};
|
||||
|
||||
try {
|
||||
const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string };
|
||||
|
||||
if (!created.id) {
|
||||
throw new Error("Salesforce did not return a case ID");
|
||||
}
|
||||
|
||||
// Fetch the created case to get the CaseNumber
|
||||
const createdCase = await this.getCaseByIdInternal(created.id);
|
||||
const caseNumber = createdCase?.CaseNumber ?? created.id;
|
||||
|
||||
this.logger.log("Web-to-Case created successfully", {
|
||||
caseId: created.id,
|
||||
caseNumber,
|
||||
email: params.suppliedEmail,
|
||||
});
|
||||
|
||||
return { id: created.id, caseNumber };
|
||||
} catch (error: unknown) {
|
||||
this.logger.error("Failed to create Web-to-Case", {
|
||||
error: getErrorMessage(error),
|
||||
email: params.suppliedEmail,
|
||||
});
|
||||
throw new Error("Failed to create contact request");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to fetch case without account validation (for post-create lookup)
|
||||
*/
|
||||
@ -205,4 +258,148 @@ export class SalesforceCaseService {
|
||||
|
||||
return result.records?.[0] ?? null;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Opportunity-Linked Cases
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Create an eligibility check case linked to an Opportunity
|
||||
*
|
||||
* @param params - Case parameters including Opportunity link
|
||||
* @returns Created case ID
|
||||
*/
|
||||
async createEligibilityCase(params: {
|
||||
accountId: string;
|
||||
opportunityId: string;
|
||||
subject: string;
|
||||
description: string;
|
||||
}): Promise<string> {
|
||||
const safeAccountId = assertSalesforceId(params.accountId, "accountId");
|
||||
const safeOpportunityId = assertSalesforceId(params.opportunityId, "opportunityId");
|
||||
|
||||
this.logger.log("Creating eligibility check case linked to Opportunity", {
|
||||
accountIdTail: safeAccountId.slice(-4),
|
||||
opportunityIdTail: safeOpportunityId.slice(-4),
|
||||
});
|
||||
|
||||
const casePayload: Record<string, unknown> = {
|
||||
Origin: SALESFORCE_CASE_ORIGIN.PORTAL_WEBSITE,
|
||||
Status: SALESFORCE_CASE_STATUS.NEW,
|
||||
Priority: SALESFORCE_CASE_PRIORITY.MEDIUM,
|
||||
Subject: params.subject,
|
||||
Description: params.description,
|
||||
AccountId: safeAccountId,
|
||||
// Link Case to Opportunity - this is a standard lookup field
|
||||
OpportunityId: safeOpportunityId,
|
||||
};
|
||||
|
||||
try {
|
||||
const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string };
|
||||
|
||||
if (!created.id) {
|
||||
throw new Error("Salesforce did not return a case ID");
|
||||
}
|
||||
|
||||
this.logger.log("Eligibility case created and linked to Opportunity", {
|
||||
caseId: created.id,
|
||||
opportunityIdTail: safeOpportunityId.slice(-4),
|
||||
});
|
||||
|
||||
return created.id;
|
||||
} catch (error: unknown) {
|
||||
this.logger.error("Failed to create eligibility case", {
|
||||
error: getErrorMessage(error),
|
||||
accountIdTail: safeAccountId.slice(-4),
|
||||
});
|
||||
throw new Error("Failed to create eligibility check case");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a cancellation request case linked to an Opportunity
|
||||
*
|
||||
* All customer-provided details (comments, alternative email) go here.
|
||||
* The Opportunity only gets the core lifecycle fields (dates, status).
|
||||
*
|
||||
* @param params - Cancellation case parameters
|
||||
* @returns Created case ID
|
||||
*/
|
||||
async createCancellationCase(params: {
|
||||
accountId: string;
|
||||
opportunityId?: string;
|
||||
whmcsServiceId: number;
|
||||
productType: string;
|
||||
cancellationMonth: string;
|
||||
cancellationDate: string;
|
||||
alternativeEmail?: string;
|
||||
comments?: string;
|
||||
}): Promise<string> {
|
||||
const safeAccountId = assertSalesforceId(params.accountId, "accountId");
|
||||
const safeOpportunityId = params.opportunityId
|
||||
? assertSalesforceId(params.opportunityId, "opportunityId")
|
||||
: null;
|
||||
|
||||
this.logger.log("Creating cancellation request case", {
|
||||
accountIdTail: safeAccountId.slice(-4),
|
||||
opportunityId: safeOpportunityId ? safeOpportunityId.slice(-4) : "none",
|
||||
whmcsServiceId: params.whmcsServiceId,
|
||||
});
|
||||
|
||||
// Build description with all form data
|
||||
const descriptionLines = [
|
||||
`Cancellation Request from Portal`,
|
||||
``,
|
||||
`Product Type: ${params.productType}`,
|
||||
`WHMCS Service ID: ${params.whmcsServiceId}`,
|
||||
`Cancellation Month: ${params.cancellationMonth}`,
|
||||
`Service End Date: ${params.cancellationDate}`,
|
||||
``,
|
||||
];
|
||||
|
||||
if (params.alternativeEmail) {
|
||||
descriptionLines.push(`Alternative Contact Email: ${params.alternativeEmail}`);
|
||||
}
|
||||
|
||||
if (params.comments) {
|
||||
descriptionLines.push(``, `Customer Comments:`, params.comments);
|
||||
}
|
||||
|
||||
descriptionLines.push(``, `Submitted: ${new Date().toISOString()}`);
|
||||
|
||||
const casePayload: Record<string, unknown> = {
|
||||
Origin: "Portal",
|
||||
Status: SALESFORCE_CASE_STATUS.NEW,
|
||||
Priority: SALESFORCE_CASE_PRIORITY.HIGH,
|
||||
Subject: `Cancellation Request - ${params.productType} (${params.cancellationMonth})`,
|
||||
Description: descriptionLines.join("\n"),
|
||||
AccountId: safeAccountId,
|
||||
};
|
||||
|
||||
// Link to Opportunity if we have one
|
||||
if (safeOpportunityId) {
|
||||
casePayload.OpportunityId = safeOpportunityId;
|
||||
}
|
||||
|
||||
try {
|
||||
const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string };
|
||||
|
||||
if (!created.id) {
|
||||
throw new Error("Salesforce did not return a case ID");
|
||||
}
|
||||
|
||||
this.logger.log("Cancellation case created", {
|
||||
caseId: created.id,
|
||||
hasOpportunityLink: !!safeOpportunityId,
|
||||
});
|
||||
|
||||
return created.id;
|
||||
} catch (error: unknown) {
|
||||
this.logger.error("Failed to create cancellation case", {
|
||||
error: getErrorMessage(error),
|
||||
accountIdTail: safeAccountId.slice(-4),
|
||||
});
|
||||
throw new Error("Failed to create cancellation request case");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
* Extracted from BaseCatalogService for consistency with order query builders.
|
||||
* SOQL query builders for Product2 services queries.
|
||||
* Extracted from BaseServicesService for consistency with order query builders.
|
||||
*/
|
||||
import { sanitizeSoqlLiteral, assertSoqlFieldName } from "./soql.util.js";
|
||||
|
||||
@ -41,9 +41,9 @@ export function buildProductQuery(
|
||||
}
|
||||
|
||||
/**
|
||||
* Build catalog service query (Service items only)
|
||||
* Build services query (Service items only)
|
||||
*/
|
||||
export function buildCatalogServiceQuery(
|
||||
export function buildServicesQuery(
|
||||
portalPricebookId: string,
|
||||
portalCategoryField: string,
|
||||
category: string,
|
||||
@ -42,11 +42,21 @@ export class WhmcsCacheService {
|
||||
ttl: 600, // 10 minutes - individual subscriptions rarely change
|
||||
tags: ["subscription", "services"],
|
||||
},
|
||||
subscriptionInvoices: {
|
||||
prefix: "whmcs:subscription:invoices",
|
||||
ttl: 300, // 5 minutes
|
||||
tags: ["subscription", "invoices"],
|
||||
},
|
||||
client: {
|
||||
prefix: "whmcs:client",
|
||||
ttl: 1800, // 30 minutes - client data rarely changes
|
||||
tags: ["client", "user"],
|
||||
},
|
||||
clientEmail: {
|
||||
prefix: "whmcs:client:email",
|
||||
ttl: 1800, // 30 minutes
|
||||
tags: ["client", "email"],
|
||||
},
|
||||
sso: {
|
||||
prefix: "whmcs:sso",
|
||||
ttl: 3600, // 1 hour - SSO tokens have their own expiry
|
||||
@ -144,6 +154,36 @@ export class WhmcsCacheService {
|
||||
await this.set(key, data, "subscription", [`user:${userId}`, `subscription:${subscriptionId}`]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached subscription invoices
|
||||
*/
|
||||
async getSubscriptionInvoices(
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
page: number,
|
||||
limit: number
|
||||
): Promise<InvoiceList | null> {
|
||||
const key = this.buildSubscriptionInvoicesKey(userId, subscriptionId, page, limit);
|
||||
return this.get<InvoiceList>(key, "subscriptionInvoices");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache subscription invoices
|
||||
*/
|
||||
async setSubscriptionInvoices(
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
page: number,
|
||||
limit: number,
|
||||
data: InvoiceList
|
||||
): Promise<void> {
|
||||
const key = this.buildSubscriptionInvoicesKey(userId, subscriptionId, page, limit);
|
||||
await this.set(key, data, "subscriptionInvoices", [
|
||||
`user:${userId}`,
|
||||
`subscription:${subscriptionId}`,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached client data
|
||||
* Returns WhmcsClient (type inferred from domain)
|
||||
@ -161,6 +201,22 @@ export class WhmcsCacheService {
|
||||
await this.set(key, data, "client", [`client:${clientId}`]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached client ID by email
|
||||
*/
|
||||
async getClientIdByEmail(email: string): Promise<number | null> {
|
||||
const key = this.buildClientEmailKey(email);
|
||||
return this.get<number>(key, "clientEmail");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache client ID for email
|
||||
*/
|
||||
async setClientIdByEmail(email: string, clientId: number): Promise<void> {
|
||||
const key = this.buildClientEmailKey(email);
|
||||
await this.set(key, clientId, "clientEmail");
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all cache for a specific user
|
||||
*/
|
||||
@ -383,6 +439,18 @@ export class WhmcsCacheService {
|
||||
return `${this.cacheConfigs.subscription.prefix}:${userId}:${subscriptionId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build cache key for subscription invoices
|
||||
*/
|
||||
private buildSubscriptionInvoicesKey(
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
page: number,
|
||||
limit: number
|
||||
): string {
|
||||
return `${this.cacheConfigs.subscriptionInvoices.prefix}:${userId}:${subscriptionId}:${page}:${limit}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build cache key for client data
|
||||
*/
|
||||
@ -390,6 +458,13 @@ export class WhmcsCacheService {
|
||||
return `${this.cacheConfigs.client.prefix}:${clientId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build cache key for client email mapping
|
||||
*/
|
||||
private buildClientEmailKey(email: string): string {
|
||||
return `${this.cacheConfigs.clientEmail.prefix}:${email.toLowerCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build cache key for payment methods
|
||||
*/
|
||||
|
||||
@ -33,7 +33,7 @@ import type {
|
||||
} from "@customer-portal/domain/payments";
|
||||
import type { WhmcsGetClientsProductsParams } from "@customer-portal/domain/subscriptions";
|
||||
import type { WhmcsProductListResponse } from "@customer-portal/domain/subscriptions";
|
||||
import type { WhmcsCatalogProductListResponse } from "@customer-portal/domain/catalog";
|
||||
import type { WhmcsCatalogProductListResponse } from "@customer-portal/domain/services";
|
||||
import type { WhmcsErrorResponse } from "@customer-portal/domain/common";
|
||||
import type { WhmcsRequestOptions, WhmcsConnectionStats } from "../types/connection.types.js";
|
||||
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -11,7 +11,7 @@ import type {
|
||||
import {
|
||||
Providers as CatalogProviders,
|
||||
type WhmcsCatalogProductNormalized,
|
||||
} from "@customer-portal/domain/catalog";
|
||||
} from "@customer-portal/domain/services";
|
||||
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service.js";
|
||||
import { WhmcsCacheService } from "../cache/whmcs-cache.service.js";
|
||||
import type { WhmcsCreateSsoTokenParams } from "@customer-portal/domain/customer";
|
||||
|
||||
@ -123,11 +123,40 @@ export class WhmcsSubscriptionService {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Get all subscriptions and find the specific one
|
||||
const subscriptionList = await this.getSubscriptions(clientId, userId);
|
||||
const subscription = subscriptionList.subscriptions.find(
|
||||
(s: Subscription) => s.id === subscriptionId
|
||||
);
|
||||
// 2. Check if we have the FULL list cached.
|
||||
// If we do, searching memory is faster than an API call.
|
||||
const cachedList = await this.cacheService.getSubscriptionsList(userId);
|
||||
if (cachedList) {
|
||||
const found = cachedList.subscriptions.find((s: Subscription) => s.id === subscriptionId);
|
||||
if (found) {
|
||||
this.logger.debug(
|
||||
`Cache hit (via list) for subscription: user ${userId}, subscription ${subscriptionId}`
|
||||
);
|
||||
// Cache this individual item for faster direct access next time
|
||||
await this.cacheService.setSubscription(userId, subscriptionId, found);
|
||||
return found;
|
||||
}
|
||||
// If list is cached but item not found, it might be new or not in that list?
|
||||
// Proceed to fetch single item.
|
||||
}
|
||||
|
||||
// 3. Fetch ONLY this subscription from WHMCS (Optimized)
|
||||
// Instead of fetching all products, use serviceid filter
|
||||
const params: WhmcsGetClientsProductsParams = {
|
||||
clientid: clientId,
|
||||
serviceid: subscriptionId,
|
||||
};
|
||||
|
||||
const rawResponse = await this.connectionService.getClientsProducts(params);
|
||||
|
||||
// Transform response
|
||||
const defaultCurrency = this.currencyService.getDefaultCurrency();
|
||||
const resultList = Providers.Whmcs.transformWhmcsSubscriptionListResponse(rawResponse, {
|
||||
defaultCurrencyCode: defaultCurrency.code,
|
||||
defaultCurrencySymbol: defaultCurrency.prefix || defaultCurrency.suffix,
|
||||
});
|
||||
|
||||
const subscription = resultList.subscriptions.find(s => s.id === subscriptionId);
|
||||
|
||||
if (!subscription) {
|
||||
throw new NotFoundException(`Subscription ${subscriptionId} not found`);
|
||||
|
||||
@ -10,6 +10,7 @@ import { WhmcsPaymentService } from "./services/whmcs-payment.service.js";
|
||||
import { WhmcsSsoService } from "./services/whmcs-sso.service.js";
|
||||
import { WhmcsOrderService } from "./services/whmcs-order.service.js";
|
||||
import { WhmcsCurrencyService } from "./services/whmcs-currency.service.js";
|
||||
import { WhmcsAccountDiscoveryService } from "./services/whmcs-account-discovery.service.js";
|
||||
// Connection services
|
||||
import { WhmcsConnectionOrchestratorService } from "./connection/services/whmcs-connection-orchestrator.service.js";
|
||||
import { WhmcsConfigService } from "./connection/config/whmcs-config.service.js";
|
||||
@ -33,15 +34,18 @@ import { WhmcsErrorHandlerService } from "./connection/services/whmcs-error-hand
|
||||
WhmcsSsoService,
|
||||
WhmcsOrderService,
|
||||
WhmcsCurrencyService,
|
||||
WhmcsAccountDiscoveryService,
|
||||
WhmcsService,
|
||||
],
|
||||
exports: [
|
||||
WhmcsService,
|
||||
WhmcsConnectionOrchestratorService,
|
||||
WhmcsCacheService,
|
||||
WhmcsClientService,
|
||||
WhmcsOrderService,
|
||||
WhmcsPaymentService,
|
||||
WhmcsCurrencyService,
|
||||
WhmcsAccountDiscoveryService,
|
||||
],
|
||||
})
|
||||
export class WhmcsModule {}
|
||||
|
||||
@ -20,7 +20,7 @@ import { WhmcsOrderService } from "./services/whmcs-order.service.js";
|
||||
import type { WhmcsAddClientParams, WhmcsClientResponse } from "@customer-portal/domain/customer";
|
||||
import type { WhmcsGetClientsProductsParams } from "@customer-portal/domain/subscriptions";
|
||||
import type { WhmcsProductListResponse } from "@customer-portal/domain/subscriptions";
|
||||
import type { WhmcsCatalogProductNormalized } from "@customer-portal/domain/catalog";
|
||||
import type { WhmcsCatalogProductNormalized } from "@customer-portal/domain/services";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
@Injectable()
|
||||
@ -131,14 +131,6 @@ export class WhmcsService {
|
||||
return this.clientService.getClientDetails(clientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client details by email
|
||||
* Returns internal WhmcsClient (type inferred)
|
||||
*/
|
||||
async getClientDetailsByEmail(email: string): Promise<WhmcsClient> {
|
||||
return this.clientService.getClientDetailsByEmail(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update client details in WHMCS
|
||||
*/
|
||||
|
||||
@ -4,6 +4,7 @@ import * as argon2 from "argon2";
|
||||
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
|
||||
import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js";
|
||||
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
|
||||
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
@ -39,6 +40,7 @@ export class AuthFacade {
|
||||
private readonly mappingsService: MappingsService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly whmcsService: WhmcsService,
|
||||
private readonly discoveryService: WhmcsAccountDiscoveryService,
|
||||
private readonly salesforceService: SalesforceService,
|
||||
private readonly auditService: AuditService,
|
||||
private readonly tokenBlacklistService: TokenBlacklistService,
|
||||
@ -418,14 +420,9 @@ export class AuthFacade {
|
||||
if (mapped) {
|
||||
whmcsExists = true;
|
||||
} else {
|
||||
// Try a direct WHMCS lookup by email (best-effort)
|
||||
try {
|
||||
const client = await this.whmcsService.getClientDetailsByEmail(normalized);
|
||||
whmcsExists = !!client;
|
||||
} catch (e) {
|
||||
// Treat not found as no; other errors as unknown (leave whmcsExists false)
|
||||
this.logger.debug("Account status: WHMCS lookup", { error: getErrorMessage(e) });
|
||||
}
|
||||
// Try a direct WHMCS lookup by email using discovery service (returns null if not found)
|
||||
const client = await this.discoveryService.findClientByEmail(normalized);
|
||||
whmcsExists = !!client;
|
||||
}
|
||||
|
||||
let state: "none" | "portal_only" | "whmcs_only" | "both_mapped" = "none";
|
||||
|
||||
@ -2,3 +2,12 @@ import { SetMetadata } from "@nestjs/common";
|
||||
|
||||
export const IS_PUBLIC_KEY = "isPublic";
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
|
||||
/**
|
||||
* Marks a route/controller as public *and* disables optional session attachment.
|
||||
*
|
||||
* Why: some endpoints must be strictly non-personalized for caching/security correctness
|
||||
* (e.g. public service catalogs). These endpoints should ignore cookies/tokens entirely.
|
||||
*/
|
||||
export const IS_PUBLIC_NO_SESSION_KEY = "isPublicNoSession";
|
||||
export const PublicNoSession = () => SetMetadata(IS_PUBLIC_NO_SESSION_KEY, true);
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
@ -13,16 +13,20 @@ import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js";
|
||||
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
|
||||
import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js";
|
||||
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
|
||||
import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js";
|
||||
import { PrismaService } from "@bff/infra/database/prisma.service.js";
|
||||
import { AuthTokenService } from "../../token/token.service.js";
|
||||
import { AuthRateLimitService } from "../../rate-limiting/auth-rate-limit.service.js";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { DomainHttpException } from "@bff/core/http/domain-http.exception.js";
|
||||
import {
|
||||
signupRequestSchema,
|
||||
type SignupRequest,
|
||||
type ValidateSignupRequest,
|
||||
} from "@customer-portal/domain/auth";
|
||||
import { ErrorCode } from "@customer-portal/domain/common";
|
||||
import { Providers as CustomerProviders } from "@customer-portal/domain/customer";
|
||||
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
|
||||
import type { User as PrismaUser } from "@prisma/client";
|
||||
@ -54,7 +58,9 @@ export class SignupWorkflowService {
|
||||
private readonly usersFacade: UsersFacade,
|
||||
private readonly mappingsService: MappingsService,
|
||||
private readonly whmcsService: WhmcsService,
|
||||
private readonly discoveryService: WhmcsAccountDiscoveryService,
|
||||
private readonly salesforceService: SalesforceService,
|
||||
private readonly salesforceAccountService: SalesforceAccountService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditService: AuditService,
|
||||
@ -66,14 +72,30 @@ export class SignupWorkflowService {
|
||||
|
||||
async validateSignup(validateData: ValidateSignupRequest, request?: Request) {
|
||||
const { sfNumber } = validateData;
|
||||
const normalizedCustomerNumber = this.normalizeCustomerNumber(sfNumber);
|
||||
|
||||
if (!normalizedCustomerNumber) {
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
undefined,
|
||||
{ sfNumber: sfNumber ?? null, reason: "no_customer_number_provided" },
|
||||
request,
|
||||
true
|
||||
);
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
message: "Customer number is not required for signup",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const accountSnapshot = await this.getAccountSnapshot(sfNumber);
|
||||
const accountSnapshot = await this.getAccountSnapshot(normalizedCustomerNumber);
|
||||
if (!accountSnapshot) {
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
undefined,
|
||||
{ sfNumber, reason: "SF number not found" },
|
||||
{ sfNumber: normalizedCustomerNumber, reason: "SF number not found" },
|
||||
request,
|
||||
false,
|
||||
"Customer number not found in Salesforce"
|
||||
@ -118,7 +140,7 @@ export class SignupWorkflowService {
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
undefined,
|
||||
{ sfNumber, sfAccountId: accountSnapshot.id, step: "validation" },
|
||||
{ sfNumber: normalizedCustomerNumber, sfAccountId: accountSnapshot.id, step: "validation" },
|
||||
request,
|
||||
true
|
||||
);
|
||||
@ -136,7 +158,7 @@ export class SignupWorkflowService {
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.SIGNUP,
|
||||
undefined,
|
||||
{ sfNumber, error: getErrorMessage(error) },
|
||||
{ sfNumber: normalizedCustomerNumber, error: getErrorMessage(error) },
|
||||
request,
|
||||
false,
|
||||
getErrorMessage(error)
|
||||
@ -189,39 +211,100 @@ export class SignupWorkflowService {
|
||||
const passwordHash = await argon2.hash(password);
|
||||
|
||||
try {
|
||||
const accountSnapshot = await this.getAccountSnapshot(sfNumber);
|
||||
if (!accountSnapshot) {
|
||||
throw new BadRequestException(
|
||||
`Salesforce account not found for Customer Number: ${sfNumber}`
|
||||
);
|
||||
}
|
||||
const normalizedCustomerNumber = this.normalizeCustomerNumber(sfNumber);
|
||||
let accountSnapshot: SignupAccountSnapshot;
|
||||
let customerNumberForWhmcs: string | null = normalizedCustomerNumber;
|
||||
|
||||
if (accountSnapshot.WH_Account__c && accountSnapshot.WH_Account__c.trim() !== "") {
|
||||
throw new ConflictException(
|
||||
"You already have an account. Please use the login page to access your existing account."
|
||||
);
|
||||
if (normalizedCustomerNumber) {
|
||||
const resolved = await this.getAccountSnapshot(normalizedCustomerNumber);
|
||||
if (!resolved) {
|
||||
throw new BadRequestException(
|
||||
`Salesforce account not found for Customer Number: ${normalizedCustomerNumber}`
|
||||
);
|
||||
}
|
||||
|
||||
if (resolved.WH_Account__c && resolved.WH_Account__c.trim() !== "") {
|
||||
throw new ConflictException(
|
||||
"You already have an account. Please use the login page to access your existing account."
|
||||
);
|
||||
}
|
||||
|
||||
accountSnapshot = resolved;
|
||||
} else {
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
const existingAccount = await this.salesforceAccountService.findByEmail(normalizedEmail);
|
||||
if (existingAccount) {
|
||||
throw new ConflictException(
|
||||
"An account already exists for this email. Please sign in or transfer your account."
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!address?.address1 ||
|
||||
!address?.city ||
|
||||
!address?.state ||
|
||||
!address?.postcode ||
|
||||
!address?.country
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"Complete address information is required for account creation"
|
||||
);
|
||||
}
|
||||
|
||||
if (!phone) {
|
||||
throw new BadRequestException("Phone number is required for account creation");
|
||||
}
|
||||
|
||||
const created = await this.salesforceAccountService.createAccount({
|
||||
firstName,
|
||||
lastName,
|
||||
email: normalizedEmail,
|
||||
phone,
|
||||
address: {
|
||||
address1: address.address1,
|
||||
address2: address.address2 || undefined,
|
||||
city: address.city,
|
||||
state: address.state,
|
||||
postcode: address.postcode,
|
||||
country: address.country,
|
||||
},
|
||||
});
|
||||
|
||||
await this.salesforceAccountService.createContact({
|
||||
accountId: created.accountId,
|
||||
firstName,
|
||||
lastName,
|
||||
email: normalizedEmail,
|
||||
phone,
|
||||
address: {
|
||||
address1: address.address1,
|
||||
address2: address.address2 || undefined,
|
||||
city: address.city,
|
||||
state: address.state,
|
||||
postcode: address.postcode,
|
||||
country: address.country,
|
||||
},
|
||||
});
|
||||
|
||||
accountSnapshot = {
|
||||
id: created.accountId,
|
||||
Name: `${firstName} ${lastName}`,
|
||||
WH_Account__c: null,
|
||||
};
|
||||
customerNumberForWhmcs = created.accountNumber;
|
||||
}
|
||||
|
||||
let whmcsClient: { clientId: number };
|
||||
try {
|
||||
try {
|
||||
const existingWhmcs = await this.whmcsService.getClientDetailsByEmail(email);
|
||||
if (existingWhmcs) {
|
||||
const existingMapping = await this.mappingsService.findByWhmcsClientId(
|
||||
existingWhmcs.id
|
||||
);
|
||||
if (existingMapping) {
|
||||
throw new ConflictException("You already have an account. Please sign in.");
|
||||
}
|
||||
// Check if a WHMCS client already exists for this email using discovery service
|
||||
const existingWhmcs = await this.discoveryService.findClientByEmail(email);
|
||||
if (existingWhmcs) {
|
||||
const existingMapping = await this.mappingsService.findByWhmcsClientId(existingWhmcs.id);
|
||||
if (existingMapping) {
|
||||
throw new ConflictException("You already have an account. Please sign in.");
|
||||
}
|
||||
|
||||
throw new ConflictException(
|
||||
"We found an existing billing account for this email. Please link your account instead."
|
||||
);
|
||||
}
|
||||
} catch (pre) {
|
||||
if (!(pre instanceof NotFoundException)) {
|
||||
throw pre;
|
||||
}
|
||||
throw new DomainHttpException(ErrorCode.LEGACY_ACCOUNT_EXISTS, HttpStatus.CONFLICT);
|
||||
}
|
||||
|
||||
const customerNumberFieldId = this.configService.get<string>(
|
||||
@ -232,7 +315,9 @@ export class SignupWorkflowService {
|
||||
const nationalityFieldId = this.configService.get<string>("WHMCS_NATIONALITY_FIELD_ID");
|
||||
|
||||
const customfieldsMap: Record<string, string> = {};
|
||||
if (customerNumberFieldId) customfieldsMap[customerNumberFieldId] = sfNumber;
|
||||
if (customerNumberFieldId && customerNumberForWhmcs) {
|
||||
customfieldsMap[customerNumberFieldId] = customerNumberForWhmcs;
|
||||
}
|
||||
if (dobFieldId && dateOfBirth) customfieldsMap[dobFieldId] = dateOfBirth;
|
||||
if (genderFieldId && gender) customfieldsMap[genderFieldId] = gender;
|
||||
if (nationalityFieldId && nationality) customfieldsMap[nationalityFieldId] = nationality;
|
||||
@ -253,7 +338,12 @@ export class SignupWorkflowService {
|
||||
throw new BadRequestException("Phone number is required for billing account creation");
|
||||
}
|
||||
|
||||
this.logger.log("Creating WHMCS client", { email, firstName, lastName, sfNumber });
|
||||
this.logger.log("Creating WHMCS client", {
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
sfNumber: customerNumberForWhmcs,
|
||||
});
|
||||
|
||||
whmcsClient = await this.whmcsService.addClient({
|
||||
firstname: firstName,
|
||||
@ -399,6 +489,7 @@ export class SignupWorkflowService {
|
||||
async signupPreflight(signupData: SignupRequest) {
|
||||
const { email, sfNumber } = signupData;
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
const normalizedCustomerNumber = this.normalizeCustomerNumber(sfNumber);
|
||||
|
||||
const result: {
|
||||
ok: boolean;
|
||||
@ -440,24 +531,9 @@ export class SignupWorkflowService {
|
||||
return result;
|
||||
}
|
||||
|
||||
const accountSnapshot = await this.getAccountSnapshot(sfNumber);
|
||||
if (!accountSnapshot) {
|
||||
result.nextAction = "fix_input";
|
||||
result.messages.push("Customer number not found in Salesforce");
|
||||
return result;
|
||||
}
|
||||
result.salesforce.accountId = accountSnapshot.id;
|
||||
|
||||
const existingMapping = await this.mappingsService.findBySfAccountId(accountSnapshot.id);
|
||||
if (existingMapping) {
|
||||
result.salesforce.alreadyMapped = true;
|
||||
result.nextAction = "login";
|
||||
result.messages.push("This customer number is already registered. Please sign in.");
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = await this.whmcsService.getClientDetailsByEmail(normalizedEmail);
|
||||
if (!normalizedCustomerNumber) {
|
||||
// Check for existing WHMCS client using discovery service (returns null if not found)
|
||||
const client = await this.discoveryService.findClientByEmail(normalizedEmail);
|
||||
if (client) {
|
||||
result.whmcs.clientExists = true;
|
||||
result.whmcs.clientId = client.id;
|
||||
@ -475,17 +551,68 @@ export class SignupWorkflowService {
|
||||
|
||||
result.nextAction = "link_whmcs";
|
||||
result.messages.push(
|
||||
"We found an existing billing account for this email. Please link your account."
|
||||
"We found an existing billing account for this email. Please transfer your account to continue."
|
||||
);
|
||||
return result;
|
||||
}
|
||||
} catch (err) {
|
||||
if (!(err instanceof NotFoundException)) {
|
||||
this.logger.warn("WHMCS preflight check failed", { error: getErrorMessage(err) });
|
||||
result.messages.push("Unable to verify billing system. Please try again later.");
|
||||
result.nextAction = "blocked";
|
||||
return result;
|
||||
|
||||
try {
|
||||
const existingSf = await this.salesforceAccountService.findByEmail(normalizedEmail);
|
||||
if (existingSf) {
|
||||
result.nextAction = "blocked";
|
||||
result.messages.push(
|
||||
"We found an existing customer record for this email. Please transfer your account or contact support."
|
||||
);
|
||||
return result;
|
||||
}
|
||||
} catch (sfErr) {
|
||||
this.logger.warn("Salesforce preflight check failed", { error: getErrorMessage(sfErr) });
|
||||
}
|
||||
|
||||
result.canProceed = true;
|
||||
result.nextAction = "proceed_signup";
|
||||
result.messages.push("All checks passed. Ready to create your account.");
|
||||
return result;
|
||||
}
|
||||
|
||||
const accountSnapshot = await this.getAccountSnapshot(normalizedCustomerNumber);
|
||||
if (!accountSnapshot) {
|
||||
result.nextAction = "fix_input";
|
||||
result.messages.push("Customer number not found in Salesforce");
|
||||
return result;
|
||||
}
|
||||
result.salesforce.accountId = accountSnapshot.id;
|
||||
|
||||
const existingMapping = await this.mappingsService.findBySfAccountId(accountSnapshot.id);
|
||||
if (existingMapping) {
|
||||
result.salesforce.alreadyMapped = true;
|
||||
result.nextAction = "login";
|
||||
result.messages.push("This customer number is already registered. Please sign in.");
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check for existing WHMCS client using discovery service (returns null if not found)
|
||||
const client = await this.discoveryService.findClientByEmail(normalizedEmail);
|
||||
if (client) {
|
||||
result.whmcs.clientExists = true;
|
||||
result.whmcs.clientId = client.id;
|
||||
|
||||
try {
|
||||
const mapped = await this.mappingsService.findByWhmcsClientId(client.id);
|
||||
if (mapped) {
|
||||
result.nextAction = "login";
|
||||
result.messages.push("This billing account is already linked. Please sign in.");
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
// ignore; treat as unmapped
|
||||
}
|
||||
|
||||
result.nextAction = "link_whmcs";
|
||||
result.messages.push(
|
||||
"We found an existing billing account for this email. Please transfer your account to continue."
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
result.canProceed = true;
|
||||
@ -494,7 +621,9 @@ export class SignupWorkflowService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private async getAccountSnapshot(sfNumber: string): Promise<SignupAccountSnapshot | null> {
|
||||
private async getAccountSnapshot(
|
||||
sfNumber?: string | null
|
||||
): Promise<SignupAccountSnapshot | null> {
|
||||
const normalized = this.normalizeCustomerNumber(sfNumber);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
@ -519,7 +648,7 @@ export class SignupWorkflowService {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private normalizeCustomerNumber(sfNumber: string): string | null {
|
||||
private normalizeCustomerNumber(sfNumber?: string | null): string | null {
|
||||
if (typeof sfNumber !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import { Logger } from "nestjs-pino";
|
||||
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
|
||||
import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js";
|
||||
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
|
||||
@ -26,6 +27,7 @@ export class WhmcsLinkWorkflowService {
|
||||
private readonly usersFacade: UsersFacade,
|
||||
private readonly mappingsService: MappingsService,
|
||||
private readonly whmcsService: WhmcsService,
|
||||
private readonly discoveryService: WhmcsAccountDiscoveryService,
|
||||
private readonly salesforceService: SalesforceService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
@ -51,21 +53,19 @@ export class WhmcsLinkWorkflowService {
|
||||
try {
|
||||
let clientDetails; // Type inferred from WHMCS service
|
||||
try {
|
||||
clientDetails = await this.whmcsService.getClientDetailsByEmail(email);
|
||||
} catch (error) {
|
||||
this.logger.error("WHMCS client lookup failed", {
|
||||
error: getErrorMessage(error),
|
||||
email, // Safe to log email for debugging since it's not sensitive
|
||||
});
|
||||
|
||||
// Provide more specific error messages based on the error type
|
||||
// Use BadRequestException (400) instead of UnauthorizedException (401)
|
||||
// to avoid triggering "session expired" logic in the frontend
|
||||
if (error instanceof Error && error.message.includes("not found")) {
|
||||
clientDetails = await this.discoveryService.findClientByEmail(email);
|
||||
if (!clientDetails) {
|
||||
throw new BadRequestException(
|
||||
"No billing account found with this email address. Please check your email or contact support."
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof BadRequestException) throw error;
|
||||
|
||||
this.logger.error("WHMCS client lookup failed", {
|
||||
error: getErrorMessage(error),
|
||||
email, // Safe to log email for debugging since it's not sensitive
|
||||
});
|
||||
|
||||
throw new BadRequestException("Unable to verify account. Please try again later.");
|
||||
}
|
||||
|
||||
@ -230,11 +230,11 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("link-whmcs")
|
||||
@Post("migrate")
|
||||
@UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard)
|
||||
@RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP (industry standard)
|
||||
@UsePipes(new ZodValidationPipe(linkWhmcsRequestSchema))
|
||||
async linkWhmcs(@Body() linkData: LinkWhmcsRequest, @Req() _req: Request) {
|
||||
async migrateAccount(@Body() linkData: LinkWhmcsRequest, @Req() _req: Request) {
|
||||
const result = await this.authFacade.linkWhmcsUser(linkData);
|
||||
return linkWhmcsResponseSchema.parse(result);
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ import { Reflector } from "@nestjs/core";
|
||||
import type { Request } from "express";
|
||||
|
||||
import { TokenBlacklistService } from "../../../infra/token/token-blacklist.service.js";
|
||||
import { IS_PUBLIC_KEY } from "../../../decorators/public.decorator.js";
|
||||
import { IS_PUBLIC_KEY, IS_PUBLIC_NO_SESSION_KEY } from "../../../decorators/public.decorator.js";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { JoseJwtService } from "../../../infra/token/jose-jwt.service.js";
|
||||
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
|
||||
@ -45,8 +45,27 @@ export class GlobalAuthGuard implements CanActivate {
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
const isPublicNoSession = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_NO_SESSION_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
if (isPublicNoSession) {
|
||||
this.logger.debug(`Strict public route accessed (no session attach): ${route}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const token = extractAccessTokenFromRequest(request);
|
||||
if (token) {
|
||||
try {
|
||||
await this.attachUserFromToken(request, token);
|
||||
this.logger.debug(`Authenticated session detected on public route: ${route}`);
|
||||
} catch {
|
||||
// Public endpoints should remain accessible even if the session is missing/expired/invalid.
|
||||
this.logger.debug(`Ignoring invalid session on public route: ${route}`);
|
||||
}
|
||||
}
|
||||
this.logger.debug(`Public route accessed: ${route}`);
|
||||
return true;
|
||||
}
|
||||
@ -61,45 +80,7 @@ export class GlobalAuthGuard implements CanActivate {
|
||||
throw new UnauthorizedException("Missing token");
|
||||
}
|
||||
|
||||
const payload = await this.jwtService.verify<{ sub?: string; email?: string; exp?: number }>(
|
||||
token
|
||||
);
|
||||
|
||||
const tokenType = (payload as { type?: unknown }).type;
|
||||
if (typeof tokenType === "string" && tokenType !== "access") {
|
||||
throw new UnauthorizedException("Invalid access token");
|
||||
}
|
||||
|
||||
if (!payload.sub || !payload.email) {
|
||||
throw new UnauthorizedException("Invalid token payload");
|
||||
}
|
||||
|
||||
// Explicit expiry buffer check to avoid tokens expiring mid-request
|
||||
if (typeof payload.exp !== "number") {
|
||||
throw new UnauthorizedException("Token missing expiration claim");
|
||||
}
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
if (payload.exp < nowSeconds + 60) {
|
||||
throw new UnauthorizedException("Token expired or expiring soon");
|
||||
}
|
||||
|
||||
// Then check token blacklist
|
||||
const isBlacklisted = await this.tokenBlacklistService.isTokenBlacklisted(token);
|
||||
if (isBlacklisted) {
|
||||
this.logger.warn(`Blacklisted token attempted access to: ${route}`);
|
||||
throw new UnauthorizedException("Token has been revoked");
|
||||
}
|
||||
|
||||
const prismaUser = await this.usersFacade.findByIdInternal(payload.sub);
|
||||
if (!prismaUser) {
|
||||
throw new UnauthorizedException("User not found");
|
||||
}
|
||||
if (prismaUser.email !== payload.email) {
|
||||
throw new UnauthorizedException("Token subject does not match user record");
|
||||
}
|
||||
|
||||
const profile: UserAuth = mapPrismaUserToDomain(prismaUser);
|
||||
(request as RequestWithRoute & { user?: UserAuth }).user = profile;
|
||||
await this.attachUserFromToken(request, token, route);
|
||||
|
||||
this.logger.debug(`Authenticated access to: ${route}`);
|
||||
return true;
|
||||
@ -168,4 +149,52 @@ export class GlobalAuthGuard implements CanActivate {
|
||||
const normalized = path.endsWith("/") ? path.slice(0, -1) : path;
|
||||
return normalized === "/auth/logout" || normalized === "/api/auth/logout";
|
||||
}
|
||||
|
||||
private async attachUserFromToken(
|
||||
request: RequestWithRoute,
|
||||
token: string,
|
||||
route?: string
|
||||
): Promise<void> {
|
||||
const payload = await this.jwtService.verify<{ sub?: string; email?: string; exp?: number }>(
|
||||
token
|
||||
);
|
||||
|
||||
const tokenType = (payload as { type?: unknown }).type;
|
||||
if (typeof tokenType === "string" && tokenType !== "access") {
|
||||
throw new UnauthorizedException("Invalid access token");
|
||||
}
|
||||
|
||||
if (!payload.sub || !payload.email) {
|
||||
throw new UnauthorizedException("Invalid token payload");
|
||||
}
|
||||
|
||||
// Explicit expiry buffer check to avoid tokens expiring mid-request
|
||||
if (typeof payload.exp !== "number") {
|
||||
throw new UnauthorizedException("Token missing expiration claim");
|
||||
}
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
if (payload.exp < nowSeconds + 60) {
|
||||
throw new UnauthorizedException("Token expired or expiring soon");
|
||||
}
|
||||
|
||||
// Then check token blacklist
|
||||
const isBlacklisted = await this.tokenBlacklistService.isTokenBlacklisted(token);
|
||||
if (isBlacklisted) {
|
||||
if (route) {
|
||||
this.logger.warn(`Blacklisted token attempted access to: ${route}`);
|
||||
}
|
||||
throw new UnauthorizedException("Token has been revoked");
|
||||
}
|
||||
|
||||
const prismaUser = await this.usersFacade.findByIdInternal(payload.sub);
|
||||
if (!prismaUser) {
|
||||
throw new UnauthorizedException("User not found");
|
||||
}
|
||||
if (prismaUser.email !== payload.email) {
|
||||
throw new UnauthorizedException("Token subject does not match user record");
|
||||
}
|
||||
|
||||
const profile: UserAuth = mapPrismaUserToDomain(prismaUser);
|
||||
(request as RequestWithRoute & { user?: UserAuth }).user = profile;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
"LastModifiedDate",
|
||||
"Pricebook2Id",
|
||||
"OpportunityId", // Linked Opportunity for lifecycle tracking
|
||||
order.activationType,
|
||||
order.activationStatus,
|
||||
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 { ZodValidationPipe } from "nestjs-zod";
|
||||
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
|
||||
import { CheckoutService } from "../services/checkout.service.js";
|
||||
import { CheckoutSessionService } from "../services/checkout-session.service.js";
|
||||
import {
|
||||
checkoutItemSchema,
|
||||
checkoutCartSchema,
|
||||
checkoutBuildCartRequestSchema,
|
||||
checkoutBuildCartResponseSchema,
|
||||
checkoutTotalsSchema,
|
||||
} from "@customer-portal/domain/orders";
|
||||
import type { CheckoutCart, CheckoutBuildCartRequest } from "@customer-portal/domain/orders";
|
||||
import { apiSuccessResponseSchema } from "@customer-portal/domain/common";
|
||||
@ -14,11 +28,28 @@ import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
|
||||
|
||||
const validateCartResponseSchema = apiSuccessResponseSchema(z.object({ valid: z.boolean() }));
|
||||
const checkoutSessionIdParamSchema = z.object({ sessionId: z.string().uuid() });
|
||||
|
||||
const checkoutCartSummarySchema = z.object({
|
||||
items: z.array(checkoutItemSchema),
|
||||
totals: checkoutTotalsSchema,
|
||||
});
|
||||
|
||||
const checkoutSessionResponseSchema = apiSuccessResponseSchema(
|
||||
z.object({
|
||||
sessionId: z.string().uuid(),
|
||||
expiresAt: z.string(),
|
||||
orderType: z.enum(["Internet", "SIM", "VPN", "Other"]),
|
||||
cart: checkoutCartSummarySchema,
|
||||
})
|
||||
);
|
||||
|
||||
@Controller("checkout")
|
||||
@Public() // Cart building and validation can be done without authentication
|
||||
export class CheckoutController {
|
||||
constructor(
|
||||
private readonly checkoutService: CheckoutService,
|
||||
private readonly checkoutSessions: CheckoutSessionService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
@ -53,6 +84,61 @@ export class CheckoutController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a short-lived checkout session to avoid trusting client-side state.
|
||||
* This returns a cart summary (items + totals) and stores the full request+cart server-side.
|
||||
*/
|
||||
@Post("session")
|
||||
@UseGuards(SalesforceReadThrottleGuard)
|
||||
@UsePipes(new ZodValidationPipe(checkoutBuildCartRequestSchema))
|
||||
async createSession(@Request() req: RequestWithUser, @Body() body: CheckoutBuildCartRequest) {
|
||||
this.logger.log("Creating checkout session", {
|
||||
userId: req.user?.id,
|
||||
orderType: body.orderType,
|
||||
});
|
||||
|
||||
const cart = await this.checkoutService.buildCart(
|
||||
body.orderType,
|
||||
body.selections,
|
||||
body.configuration,
|
||||
req.user?.id
|
||||
);
|
||||
|
||||
const session = await this.checkoutSessions.createSession(body, cart);
|
||||
|
||||
return checkoutSessionResponseSchema.parse({
|
||||
success: true,
|
||||
data: {
|
||||
sessionId: session.sessionId,
|
||||
expiresAt: session.expiresAt,
|
||||
orderType: body.orderType,
|
||||
cart: {
|
||||
items: cart.items,
|
||||
totals: cart.totals,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@Get("session/:sessionId")
|
||||
@UseGuards(SalesforceReadThrottleGuard)
|
||||
@UsePipes(new ZodValidationPipe(checkoutSessionIdParamSchema))
|
||||
async getSession(@Param() params: { sessionId: string }) {
|
||||
const session = await this.checkoutSessions.getSession(params.sessionId);
|
||||
return checkoutSessionResponseSchema.parse({
|
||||
success: true,
|
||||
data: {
|
||||
sessionId: params.sessionId,
|
||||
expiresAt: session.expiresAt,
|
||||
orderType: session.request.orderType,
|
||||
cart: {
|
||||
items: session.cart.items,
|
||||
totals: session.cart.totals,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@Post("validate")
|
||||
@UsePipes(new ZodValidationPipe(checkoutCartSchema))
|
||||
validateCart(@Body() cart: CheckoutCart) {
|
||||
|
||||
@ -29,12 +29,21 @@ import { Observable } from "rxjs";
|
||||
import { OrderEventsService } from "./services/order-events.service.js";
|
||||
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
|
||||
import { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-write-throttle.guard.js";
|
||||
import { CheckoutService } from "./services/checkout.service.js";
|
||||
import { CheckoutSessionService } from "./services/checkout-session.service.js";
|
||||
import { z } from "zod";
|
||||
|
||||
const checkoutSessionCreateOrderSchema = z.object({
|
||||
checkoutSessionId: z.string().uuid(),
|
||||
});
|
||||
|
||||
@Controller("orders")
|
||||
@UseGuards(RateLimitGuard)
|
||||
export class OrdersController {
|
||||
constructor(
|
||||
private orderOrchestrator: OrderOrchestrator,
|
||||
private readonly checkoutService: CheckoutService,
|
||||
private readonly checkoutSessions: CheckoutSessionService,
|
||||
private readonly orderEvents: OrderEventsService,
|
||||
private readonly logger: Logger
|
||||
) {}
|
||||
@ -71,6 +80,58 @@ export class OrdersController {
|
||||
}
|
||||
}
|
||||
|
||||
@Post("from-checkout-session")
|
||||
@UseGuards(SalesforceWriteThrottleGuard)
|
||||
@RateLimit({ limit: 5, ttl: 60 }) // 5 order creations per minute
|
||||
@UsePipes(new ZodValidationPipe(checkoutSessionCreateOrderSchema))
|
||||
async createFromCheckoutSession(
|
||||
@Request() req: RequestWithUser,
|
||||
@Body() body: { checkoutSessionId: string }
|
||||
) {
|
||||
this.logger.log(
|
||||
{
|
||||
userId: req.user?.id,
|
||||
checkoutSessionId: body.checkoutSessionId,
|
||||
},
|
||||
"Order creation from checkout session request received"
|
||||
);
|
||||
|
||||
const session = await this.checkoutSessions.getSession(body.checkoutSessionId);
|
||||
|
||||
const cart = await this.checkoutService.buildCart(
|
||||
session.request.orderType,
|
||||
session.request.selections,
|
||||
session.request.configuration,
|
||||
req.user?.id
|
||||
);
|
||||
|
||||
const uniqueSkus = Array.from(
|
||||
new Set(
|
||||
cart.items
|
||||
.map(item => item.sku)
|
||||
.filter((sku): sku is string => typeof sku === "string" && sku.trim().length > 0)
|
||||
)
|
||||
);
|
||||
|
||||
if (uniqueSkus.length === 0) {
|
||||
throw new NotFoundException("Checkout session contains no items");
|
||||
}
|
||||
|
||||
const orderBody: CreateOrderRequest = {
|
||||
orderType: session.request.orderType,
|
||||
skus: uniqueSkus,
|
||||
...(Object.keys(cart.configuration ?? {}).length > 0
|
||||
? { configurations: cart.configuration }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const result = await this.orderOrchestrator.createOrder(req.user.id, orderBody);
|
||||
|
||||
await this.checkoutSessions.deleteSession(body.checkoutSessionId);
|
||||
|
||||
return this.createOrderResponseSchema.parse({ success: true, data: result });
|
||||
}
|
||||
|
||||
@Get("user")
|
||||
@UseGuards(SalesforceReadThrottleGuard)
|
||||
async getUserOrders(@Request() req: RequestWithUser) {
|
||||
|
||||
@ -6,8 +6,10 @@ import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
||||
import { UsersModule } from "@bff/modules/users/users.module.js";
|
||||
import { CoreConfigModule } from "@bff/core/config/config.module.js";
|
||||
import { DatabaseModule } from "@bff/core/database/database.module.js";
|
||||
import { CatalogModule } from "@bff/modules/catalog/catalog.module.js";
|
||||
import { ServicesModule } from "@bff/modules/services/services.module.js";
|
||||
import { CacheModule } from "@bff/infra/cache/cache.module.js";
|
||||
import { VerificationModule } from "@bff/modules/verification/verification.module.js";
|
||||
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
|
||||
|
||||
// Clean modular order services
|
||||
import { OrderValidator } from "./services/order-validator.service.js";
|
||||
@ -17,6 +19,7 @@ import { OrderPricebookService } from "./services/order-pricebook.service.js";
|
||||
import { OrderOrchestrator } from "./services/order-orchestrator.service.js";
|
||||
import { PaymentValidatorService } from "./services/payment-validator.service.js";
|
||||
import { CheckoutService } from "./services/checkout.service.js";
|
||||
import { CheckoutSessionService } from "./services/checkout-session.service.js";
|
||||
import { OrderEventsService } from "./services/order-events.service.js";
|
||||
import { OrdersCacheService } from "./services/orders-cache.service.js";
|
||||
|
||||
@ -36,8 +39,10 @@ import { OrderFieldConfigModule } from "./config/order-field-config.module.js";
|
||||
UsersModule,
|
||||
CoreConfigModule,
|
||||
DatabaseModule,
|
||||
CatalogModule,
|
||||
ServicesModule,
|
||||
CacheModule,
|
||||
VerificationModule,
|
||||
NotificationsModule,
|
||||
OrderFieldConfigModule,
|
||||
],
|
||||
controllers: [OrdersController, CheckoutController],
|
||||
@ -54,6 +59,7 @@ import { OrderFieldConfigModule } from "./config/order-field-config.module.js";
|
||||
OrderOrchestrator,
|
||||
OrdersCacheService,
|
||||
CheckoutService,
|
||||
CheckoutSessionService,
|
||||
|
||||
// Order fulfillment services (modular)
|
||||
OrderFulfillmentValidator,
|
||||
|
||||
@ -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,
|
||||
SimActivationFeeCatalogItem,
|
||||
VpnCatalogProduct,
|
||||
} from "@customer-portal/domain/catalog";
|
||||
import { InternetCatalogService } from "@bff/modules/catalog/services/internet-catalog.service.js";
|
||||
import { SimCatalogService } from "@bff/modules/catalog/services/sim-catalog.service.js";
|
||||
import { VpnCatalogService } from "@bff/modules/catalog/services/vpn-catalog.service.js";
|
||||
} from "@customer-portal/domain/services";
|
||||
import { InternetServicesService } from "@bff/modules/services/services/internet-services.service.js";
|
||||
import { SimServicesService } from "@bff/modules/services/services/sim-services.service.js";
|
||||
import { VpnServicesService } from "@bff/modules/services/services/vpn-services.service.js";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
|
||||
@Injectable()
|
||||
export class CheckoutService {
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly internetCatalogService: InternetCatalogService,
|
||||
private readonly simCatalogService: SimCatalogService,
|
||||
private readonly vpnCatalogService: VpnCatalogService
|
||||
private readonly internetCatalogService: InternetServicesService,
|
||||
private readonly simCatalogService: SimServicesService,
|
||||
private readonly vpnCatalogService: VpnServicesService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -155,6 +155,14 @@ export class CheckoutService {
|
||||
userId?: string
|
||||
): Promise<{ items: CheckoutItem[] }> {
|
||||
const items: CheckoutItem[] = [];
|
||||
if (userId) {
|
||||
const eligibility = await this.internetCatalogService.getEligibilityForUser(userId);
|
||||
if (typeof eligibility !== "string" || eligibility.trim().length === 0) {
|
||||
throw new BadRequestException(
|
||||
"Internet availability check required before ordering. Please request an availability check and try again once confirmed."
|
||||
);
|
||||
}
|
||||
}
|
||||
const plans: InternetPlanCatalogItem[] = userId
|
||||
? await this.internetCatalogService.getPlansForUser(userId)
|
||||
: await this.internetCatalogService.getPlans();
|
||||
@ -229,9 +237,11 @@ export class CheckoutService {
|
||||
userId?: string
|
||||
): Promise<{ items: CheckoutItem[] }> {
|
||||
const items: CheckoutItem[] = [];
|
||||
const plans: SimCatalogProduct[] = userId
|
||||
? await this.simCatalogService.getPlansForUser(userId)
|
||||
: await this.simCatalogService.getPlans();
|
||||
if (!userId) {
|
||||
throw new BadRequestException("Please sign in to order SIM service.");
|
||||
}
|
||||
|
||||
const plans: SimCatalogProduct[] = await this.simCatalogService.getPlansForUser(userId);
|
||||
const rawActivationFees: SimActivationFeeCatalogItem[] =
|
||||
await this.simCatalogService.getActivationFees();
|
||||
const activationFees = this.filterActivationFeesWithSku(rawActivationFees);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
|
||||
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
|
||||
import { WhmcsOrderService } from "@bff/integrations/whmcs/services/whmcs-order.service.js";
|
||||
import type { WhmcsOrderResult } from "@bff/integrations/whmcs/services/whmcs-order.service.js";
|
||||
import { OrderOrchestrator } from "./order-orchestrator.service.js";
|
||||
@ -11,11 +12,16 @@ import { DistributedTransactionService } from "@bff/core/database/services/distr
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { OrderEventsService } from "./order-events.service.js";
|
||||
import { OrdersCacheService } from "./orders-cache.service.js";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { NotificationService } from "@bff/modules/notifications/notifications.service.js";
|
||||
import {
|
||||
type OrderDetails,
|
||||
type OrderFulfillmentValidationResult,
|
||||
Providers as OrderProviders,
|
||||
} from "@customer-portal/domain/orders";
|
||||
import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity";
|
||||
import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications";
|
||||
import { salesforceAccountIdSchema } from "@customer-portal/domain/common";
|
||||
import {
|
||||
OrderValidationException,
|
||||
FulfillmentException,
|
||||
@ -51,6 +57,7 @@ export class OrderFulfillmentOrchestrator {
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly salesforceService: SalesforceService,
|
||||
private readonly opportunityService: SalesforceOpportunityService,
|
||||
private readonly whmcsOrderService: WhmcsOrderService,
|
||||
private readonly orderOrchestrator: OrderOrchestrator,
|
||||
private readonly orderFulfillmentValidator: OrderFulfillmentValidator,
|
||||
@ -58,7 +65,9 @@ export class OrderFulfillmentOrchestrator {
|
||||
private readonly simFulfillmentService: SimFulfillmentService,
|
||||
private readonly distributedTransactionService: DistributedTransactionService,
|
||||
private readonly orderEvents: OrderEventsService,
|
||||
private readonly ordersCache: OrdersCacheService
|
||||
private readonly ordersCache: OrdersCacheService,
|
||||
private readonly mappingsService: MappingsService,
|
||||
private readonly notifications: NotificationService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -171,6 +180,12 @@ export class OrderFulfillmentOrchestrator {
|
||||
source: "fulfillment",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
await this.safeNotifyOrder({
|
||||
type: NOTIFICATION_TYPE.ORDER_APPROVED,
|
||||
sfOrderId,
|
||||
accountId: context.validation?.sfOrder?.AccountId,
|
||||
actionUrl: `/account/orders/${sfOrderId}`,
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
rollback: async () => {
|
||||
@ -232,12 +247,16 @@ export class OrderFulfillmentOrchestrator {
|
||||
`Provisioned from Salesforce Order ${sfOrderId}`
|
||||
);
|
||||
|
||||
// Get OpportunityId from order details for WHMCS lifecycle linking
|
||||
const sfOpportunityId = context.orderDetails?.opportunityId;
|
||||
|
||||
const result = await this.whmcsOrderService.addOrder({
|
||||
clientId: context.validation.clientId,
|
||||
items: mappingResult.whmcsItems,
|
||||
paymentMethod: "stripe",
|
||||
promoCode: "1st Month Free (Monthly Plan)",
|
||||
sfOrderId,
|
||||
sfOpportunityId, // Pass to WHMCS for bidirectional linking
|
||||
notes: orderNotes,
|
||||
noinvoiceemail: true,
|
||||
noemail: true,
|
||||
@ -336,6 +355,12 @@ export class OrderFulfillmentOrchestrator {
|
||||
whmcsServiceIds: whmcsCreateResult?.serviceIds,
|
||||
},
|
||||
});
|
||||
await this.safeNotifyOrder({
|
||||
type: NOTIFICATION_TYPE.ORDER_ACTIVATED,
|
||||
sfOrderId,
|
||||
accountId: context.validation?.sfOrder?.AccountId,
|
||||
actionUrl: "/account/services",
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
rollback: async () => {
|
||||
@ -346,6 +371,54 @@ export class OrderFulfillmentOrchestrator {
|
||||
},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
id: "opportunity_update",
|
||||
description: "Update Opportunity with WHMCS Service ID and Active stage",
|
||||
execute: this.createTrackedStep(context, "opportunity_update", async () => {
|
||||
const opportunityId = context.orderDetails?.opportunityId;
|
||||
const serviceId = whmcsCreateResult?.serviceIds?.[0];
|
||||
|
||||
if (!opportunityId) {
|
||||
this.logger.debug("No Opportunity linked to order, skipping update", {
|
||||
sfOrderId,
|
||||
});
|
||||
return { skipped: true as const };
|
||||
}
|
||||
|
||||
try {
|
||||
// Update Opportunity stage to Active and set WHMCS Service ID
|
||||
await this.opportunityService.updateStage(
|
||||
opportunityId,
|
||||
OPPORTUNITY_STAGE.ACTIVE,
|
||||
"Service activated via fulfillment"
|
||||
);
|
||||
|
||||
if (serviceId) {
|
||||
await this.opportunityService.linkWhmcsServiceToOpportunity(
|
||||
opportunityId,
|
||||
serviceId
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log("Opportunity updated with Active stage and WHMCS link", {
|
||||
opportunityIdTail: opportunityId.slice(-4),
|
||||
whmcsServiceId: serviceId,
|
||||
sfOrderId,
|
||||
});
|
||||
|
||||
return { opportunityId, whmcsServiceId: serviceId };
|
||||
} catch (error) {
|
||||
// Log but don't fail - Opportunity update is non-critical
|
||||
this.logger.warn("Failed to update Opportunity after fulfillment", {
|
||||
error: getErrorMessage(error),
|
||||
opportunityId,
|
||||
sfOrderId,
|
||||
});
|
||||
return { failed: true as const, error: getErrorMessage(error) };
|
||||
}
|
||||
}),
|
||||
critical: false, // Opportunity update failure shouldn't rollback fulfillment
|
||||
},
|
||||
],
|
||||
{
|
||||
description: `Order fulfillment for ${sfOrderId}`,
|
||||
@ -387,6 +460,12 @@ export class OrderFulfillmentOrchestrator {
|
||||
} catch (error) {
|
||||
await this.invalidateOrderCaches(sfOrderId, context.validation?.sfOrder?.AccountId);
|
||||
await this.handleFulfillmentError(context, error as Error);
|
||||
await this.safeNotifyOrder({
|
||||
type: NOTIFICATION_TYPE.ORDER_FAILED,
|
||||
sfOrderId,
|
||||
accountId: context.validation?.sfOrder?.AccountId,
|
||||
actionUrl: `/account/orders/${sfOrderId}`,
|
||||
});
|
||||
this.orderEvents.publish(sfOrderId, {
|
||||
orderId: sfOrderId,
|
||||
status: "Pending Review",
|
||||
@ -446,6 +525,38 @@ export class OrderFulfillmentOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
private async safeNotifyOrder(params: {
|
||||
type: (typeof NOTIFICATION_TYPE)[keyof typeof NOTIFICATION_TYPE];
|
||||
sfOrderId: string;
|
||||
accountId?: unknown;
|
||||
actionUrl: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const sfAccountId = salesforceAccountIdSchema.safeParse(params.accountId);
|
||||
if (!sfAccountId.success) return;
|
||||
|
||||
const mapping = await this.mappingsService.findBySfAccountId(sfAccountId.data);
|
||||
if (!mapping?.userId) return;
|
||||
|
||||
await this.notifications.createNotification({
|
||||
userId: mapping.userId,
|
||||
type: params.type,
|
||||
source: NOTIFICATION_SOURCE.SYSTEM,
|
||||
sourceId: params.sfOrderId,
|
||||
actionUrl: params.actionUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{
|
||||
sfOrderId: params.sfOrderId,
|
||||
type: params.type,
|
||||
err: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Failed to create in-app order notification"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle fulfillment errors and update Salesforce
|
||||
*/
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
import { Injectable, Inject, NotFoundException } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { SalesforceOrderService } from "@bff/integrations/salesforce/services/salesforce-order.service.js";
|
||||
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
|
||||
import { OpportunityResolutionService } from "@bff/integrations/salesforce/services/opportunity-resolution.service.js";
|
||||
import { OrderValidator } from "./order-validator.service.js";
|
||||
import { OrderBuilder } from "./order-builder.service.js";
|
||||
import { OrderItemBuilder } from "./order-item-builder.service.js";
|
||||
import type { OrderItemCompositePayload } from "./order-item-builder.service.js";
|
||||
import { OrdersCacheService } from "./orders-cache.service.js";
|
||||
import type { OrderDetails, OrderSummary } from "@customer-portal/domain/orders";
|
||||
import type { OrderDetails, OrderSummary, OrderTypeValue } from "@customer-portal/domain/orders";
|
||||
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js";
|
||||
import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity";
|
||||
|
||||
type OrderDetailsResponse = OrderDetails;
|
||||
type OrderSummaryResponse = OrderSummary;
|
||||
@ -21,6 +24,8 @@ export class OrderOrchestrator {
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly salesforceOrderService: SalesforceOrderService,
|
||||
private readonly opportunityService: SalesforceOpportunityService,
|
||||
private readonly opportunityResolution: OpportunityResolutionService,
|
||||
private readonly orderValidator: OrderValidator,
|
||||
private readonly orderBuilder: OrderBuilder,
|
||||
private readonly orderItemBuilder: OrderItemBuilder,
|
||||
@ -46,9 +51,18 @@ export class OrderOrchestrator {
|
||||
"Order validation completed successfully"
|
||||
);
|
||||
|
||||
// 2) Build order fields (includes address snapshot)
|
||||
// 2) Resolve Opportunity for this order
|
||||
const opportunityId = await this.resolveOpportunityForOrder(
|
||||
validatedBody.orderType,
|
||||
userMapping.sfAccountId ?? null,
|
||||
validatedBody.opportunityId
|
||||
);
|
||||
|
||||
// 3) Build order fields with Opportunity link
|
||||
const bodyWithOpportunity = opportunityId ? { ...validatedBody, opportunityId } : validatedBody;
|
||||
|
||||
const orderFields = await this.orderBuilder.buildOrderFields(
|
||||
validatedBody,
|
||||
bodyWithOpportunity,
|
||||
userMapping,
|
||||
pricebookId,
|
||||
validatedBody.userId
|
||||
@ -63,6 +77,7 @@ export class OrderOrchestrator {
|
||||
orderType: validatedBody.orderType,
|
||||
skuCount: validatedBody.skus.length,
|
||||
orderItemCount: orderItemsPayload.length,
|
||||
hasOpportunity: !!opportunityId,
|
||||
},
|
||||
"Order payload prepared"
|
||||
);
|
||||
@ -72,6 +87,27 @@ export class OrderOrchestrator {
|
||||
orderItemsPayload
|
||||
);
|
||||
|
||||
// 4) Update Opportunity stage to Post Processing
|
||||
if (opportunityId) {
|
||||
try {
|
||||
await this.opportunityService.updateStage(
|
||||
opportunityId,
|
||||
OPPORTUNITY_STAGE.POST_PROCESSING,
|
||||
"Order placed via Portal"
|
||||
);
|
||||
this.logger.log("Opportunity stage updated to Post Processing", {
|
||||
opportunityIdTail: opportunityId.slice(-4),
|
||||
orderId: created.id,
|
||||
});
|
||||
} catch {
|
||||
// Log but don't fail the order
|
||||
this.logger.warn("Failed to update Opportunity stage after order", {
|
||||
opportunityId,
|
||||
orderId: created.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (userMapping.sfAccountId) {
|
||||
await this.ordersCache.invalidateAccountOrders(userMapping.sfAccountId);
|
||||
}
|
||||
@ -82,6 +118,7 @@ export class OrderOrchestrator {
|
||||
orderId: created.id,
|
||||
skuCount: validatedBody.skus.length,
|
||||
orderItemCount: orderItemsPayload.length,
|
||||
opportunityId,
|
||||
},
|
||||
"Order creation workflow completed successfully"
|
||||
);
|
||||
@ -93,6 +130,40 @@ export class OrderOrchestrator {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Opportunity for an order
|
||||
*
|
||||
* - If order already has an Opportunity ID, use it
|
||||
* - Otherwise, find existing open Opportunity for this product type
|
||||
* - If none found, create a new one with Post Processing stage
|
||||
*/
|
||||
private async resolveOpportunityForOrder(
|
||||
orderType: OrderTypeValue,
|
||||
sfAccountId: string | null,
|
||||
existingOpportunityId?: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const resolved = await this.opportunityResolution.resolveForOrderPlacement({
|
||||
accountId: sfAccountId,
|
||||
orderType,
|
||||
existingOpportunityId,
|
||||
});
|
||||
if (resolved) {
|
||||
this.logger.debug("Resolved Opportunity for order", {
|
||||
opportunityIdTail: resolved.slice(-4),
|
||||
orderType,
|
||||
});
|
||||
}
|
||||
return resolved;
|
||||
} catch {
|
||||
const accountIdTail =
|
||||
typeof sfAccountId === "string" && sfAccountId.length >= 4 ? sfAccountId.slice(-4) : "none";
|
||||
this.logger.warn("Failed to resolve Opportunity for order", { orderType, accountIdTail });
|
||||
// Don't fail the order if Opportunity resolution fails
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get order by ID with order items
|
||||
*/
|
||||
|
||||
@ -5,7 +5,7 @@ import { SalesforceConnection } from "@bff/integrations/salesforce/services/sale
|
||||
import type {
|
||||
SalesforceProduct2Record,
|
||||
SalesforcePricebookEntryRecord,
|
||||
} from "@customer-portal/domain/catalog";
|
||||
} from "@customer-portal/domain/services";
|
||||
import type { SalesforceResponse } from "@customer-portal/domain/common";
|
||||
import {
|
||||
assertSalesforceId,
|
||||
|
||||
@ -13,9 +13,11 @@ import {
|
||||
import type { Providers } from "@customer-portal/domain/subscriptions";
|
||||
|
||||
type WhmcsProduct = Providers.WhmcsRaw.WhmcsProductRaw;
|
||||
import { SimCatalogService } from "@bff/modules/catalog/services/sim-catalog.service.js";
|
||||
import { SimServicesService } from "@bff/modules/services/services/sim-services.service.js";
|
||||
import { InternetServicesService } from "@bff/modules/services/services/internet-services.service.js";
|
||||
import { OrderPricebookService, type PricebookProductMeta } from "./order-pricebook.service.js";
|
||||
import { PaymentValidatorService } from "./payment-validator.service.js";
|
||||
import { ResidenceCardService } from "@bff/modules/verification/residence-card.service.js";
|
||||
|
||||
/**
|
||||
* Handles all order validation logic - both format and business rules
|
||||
@ -30,8 +32,10 @@ export class OrderValidator {
|
||||
private readonly mappings: MappingsService,
|
||||
private readonly whmcs: WhmcsConnectionOrchestratorService,
|
||||
private readonly pricebookService: OrderPricebookService,
|
||||
private readonly simCatalogService: SimCatalogService,
|
||||
private readonly paymentValidator: PaymentValidatorService
|
||||
private readonly simCatalogService: SimServicesService,
|
||||
private readonly internetCatalogService: InternetServicesService,
|
||||
private readonly paymentValidator: PaymentValidatorService,
|
||||
private readonly residenceCards: ResidenceCardService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -269,6 +273,18 @@ export class OrderValidator {
|
||||
const _productMeta = await this.validateSKUs(businessValidatedBody.skus, pricebookId);
|
||||
|
||||
if (businessValidatedBody.orderType === "SIM") {
|
||||
const verification = await this.residenceCards.getStatusForUser(userId);
|
||||
if (verification.status === "not_submitted") {
|
||||
throw new BadRequestException(
|
||||
"Residence card submission required for SIM orders. Please upload your residence card and try again."
|
||||
);
|
||||
}
|
||||
if (verification.status === "rejected") {
|
||||
throw new BadRequestException(
|
||||
"Your residence card submission was rejected. Please resubmit your residence card and try again."
|
||||
);
|
||||
}
|
||||
|
||||
const activationFees = await this.simCatalogService.getActivationFees();
|
||||
const activationSkus = new Set(
|
||||
activationFees
|
||||
@ -297,6 +313,23 @@ export class OrderValidator {
|
||||
|
||||
// 4. Order-specific business validation
|
||||
if (businessValidatedBody.orderType === "Internet") {
|
||||
const eligibility = await this.internetCatalogService.getEligibilityDetailsForUser(userId);
|
||||
if (eligibility.status === "not_requested") {
|
||||
throw new BadRequestException(
|
||||
"Internet eligibility review is required before ordering. Please request an eligibility review from the Internet services page and try again."
|
||||
);
|
||||
}
|
||||
if (eligibility.status === "pending") {
|
||||
throw new BadRequestException(
|
||||
"Internet eligibility review is still in progress. Please wait for review to complete and try again."
|
||||
);
|
||||
}
|
||||
if (eligibility.status === "ineligible") {
|
||||
throw new BadRequestException(
|
||||
"Internet service is not available for your address. Please contact support if you believe this is incorrect."
|
||||
);
|
||||
}
|
||||
|
||||
await this.validateInternetDuplication(userId, userMapping.whmcsClientId);
|
||||
}
|
||||
|
||||
|
||||
@ -67,14 +67,14 @@ export class RealtimeController {
|
||||
}
|
||||
);
|
||||
|
||||
const globalCatalogStream = this.realtime.subscribe("global:catalog", {
|
||||
const globalServicesStream = this.realtime.subscribe("global:services", {
|
||||
// Avoid duplicate ready/heartbeat noise on the combined stream.
|
||||
readyEvent: null,
|
||||
heartbeatEvent: null,
|
||||
heartbeatMs: 0,
|
||||
});
|
||||
|
||||
return merge(accountStream, globalCatalogStream).pipe(
|
||||
return merge(accountStream, globalServicesStream).pipe(
|
||||
finalize(() => {
|
||||
this.limiter.release(req.user.id);
|
||||
this.logger.debug("Account realtime stream disconnected", {
|
||||
|
||||
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 { CatalogCacheService } from "./services/catalog-cache.service.js";
|
||||
import type { CatalogCacheSnapshot } from "./services/catalog-cache.service.js";
|
||||
import { ServicesCacheService } from "./services/services-cache.service.js";
|
||||
import type { ServicesCacheSnapshot } from "./services/services-cache.service.js";
|
||||
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
|
||||
|
||||
interface CatalogCacheHealthResponse {
|
||||
interface ServicesCacheHealthResponse {
|
||||
timestamp: string;
|
||||
metrics: CatalogCacheSnapshot;
|
||||
metrics: ServicesCacheSnapshot;
|
||||
ttl: {
|
||||
catalogSeconds: number | null;
|
||||
servicesSeconds: number | null;
|
||||
eligibilitySeconds: number | null;
|
||||
staticSeconds: number | null;
|
||||
volatileSeconds: number;
|
||||
};
|
||||
}
|
||||
|
||||
@Controller("health/catalog")
|
||||
@Controller("health/services")
|
||||
@Public()
|
||||
export class CatalogHealthController {
|
||||
constructor(private readonly catalogCache: CatalogCacheService) {}
|
||||
export class ServicesHealthController {
|
||||
constructor(private readonly catalogCache: ServicesCacheService) {}
|
||||
|
||||
@Get("cache")
|
||||
getCacheMetrics(): CatalogCacheHealthResponse {
|
||||
getCacheMetrics(): ServicesCacheHealthResponse {
|
||||
const ttl = this.catalogCache.getTtlConfiguration();
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
@ -1,9 +1,11 @@
|
||||
import { Controller, Get, Request, UseGuards, Header } from "@nestjs/common";
|
||||
import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.js";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
|
||||
import {
|
||||
parseInternetCatalog,
|
||||
parseSimCatalog,
|
||||
parseVpnCatalog,
|
||||
type InternetAddonCatalogItem,
|
||||
type InternetInstallationCatalogItem,
|
||||
type InternetPlanCatalogItem,
|
||||
@ -11,19 +13,21 @@ import {
|
||||
type SimCatalogCollection,
|
||||
type SimCatalogProduct,
|
||||
type VpnCatalogProduct,
|
||||
} from "@customer-portal/domain/catalog";
|
||||
import { InternetCatalogService } from "./services/internet-catalog.service.js";
|
||||
import { SimCatalogService } from "./services/sim-catalog.service.js";
|
||||
import { VpnCatalogService } from "./services/vpn-catalog.service.js";
|
||||
type VpnCatalogCollection,
|
||||
} from "@customer-portal/domain/services";
|
||||
import { InternetServicesService } from "./services/internet-services.service.js";
|
||||
import { SimServicesService } from "./services/sim-services.service.js";
|
||||
import { VpnServicesService } from "./services/vpn-services.service.js";
|
||||
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
|
||||
|
||||
@Controller("catalog")
|
||||
@Controller("services")
|
||||
@Public() // Allow public access - services can be browsed without authentication
|
||||
@UseGuards(SalesforceReadThrottleGuard, RateLimitGuard)
|
||||
export class CatalogController {
|
||||
export class ServicesController {
|
||||
constructor(
|
||||
private internetCatalog: InternetCatalogService,
|
||||
private simCatalog: SimCatalogService,
|
||||
private vpnCatalog: VpnCatalogService
|
||||
private internetCatalog: InternetServicesService,
|
||||
private simCatalog: SimServicesService,
|
||||
private vpnCatalog: VpnServicesService
|
||||
) {}
|
||||
|
||||
@Get("internet/plans")
|
||||
@ -98,8 +102,10 @@ export class CatalogController {
|
||||
@Get("vpn/plans")
|
||||
@RateLimit({ limit: 20, ttl: 60 }) // 20 requests per minute
|
||||
@Header("Cache-Control", "public, max-age=300, s-maxage=300") // 5 minutes
|
||||
async getVpnPlans(): Promise<VpnCatalogProduct[]> {
|
||||
return this.vpnCatalog.getPlans();
|
||||
async getVpnPlans(): Promise<VpnCatalogCollection> {
|
||||
// Backwards-compatible: return the full VPN catalog (plans + activation fees)
|
||||
const catalog = await this.vpnCatalog.getCatalogData();
|
||||
return parseVpnCatalog(catalog);
|
||||
}
|
||||
|
||||
@Get("vpn/activation-fees")
|
||||
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";
|
||||
import {
|
||||
buildProductQuery,
|
||||
buildCatalogServiceQuery,
|
||||
} from "@bff/integrations/salesforce/utils/catalog-query-builder.js";
|
||||
buildServicesQuery,
|
||||
} from "@bff/integrations/salesforce/utils/services-query-builder.js";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import type {
|
||||
SalesforceProduct2WithPricebookEntries,
|
||||
SalesforcePricebookEntryRecord,
|
||||
} from "@customer-portal/domain/catalog";
|
||||
import { Providers as CatalogProviders } from "@customer-portal/domain/catalog";
|
||||
} from "@customer-portal/domain/services";
|
||||
import { Providers as CatalogProviders } from "@customer-portal/domain/services";
|
||||
import type { SalesforceResponse } from "@customer-portal/domain/common";
|
||||
|
||||
@Injectable()
|
||||
export class BaseCatalogService {
|
||||
export class BaseServicesService {
|
||||
protected readonly portalPriceBookId: string;
|
||||
protected readonly portalCategoryField: string;
|
||||
|
||||
@ -41,7 +41,7 @@ export class BaseCatalogService {
|
||||
): Promise<TRecord[]> {
|
||||
try {
|
||||
const res = (await this.sf.query(soql, {
|
||||
label: `catalog:${context.replace(/\s+/g, "_").toLowerCase()}`,
|
||||
label: `services:${context.replace(/\s+/g, "_").toLowerCase()}`,
|
||||
})) as SalesforceResponse<TRecord>;
|
||||
return res.records ?? [];
|
||||
} catch (error: unknown) {
|
||||
@ -99,8 +99,8 @@ export class BaseCatalogService {
|
||||
return [];
|
||||
}
|
||||
|
||||
protected buildCatalogServiceQuery(category: string, additionalFields: string[] = []): string {
|
||||
return buildCatalogServiceQuery(
|
||||
protected buildServicesQuery(category: string, additionalFields: string[] = []): string {
|
||||
return buildServicesQuery(
|
||||
this.portalPriceBookId,
|
||||
this.portalCategoryField,
|
||||
category,
|
||||
@ -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 { ConfigService } from "@nestjs/config";
|
||||
import { CacheService } from "@bff/infra/cache/cache.service.js";
|
||||
import type { CacheBucketMetrics, CacheDependencies } from "@bff/infra/cache/cache.types.js";
|
||||
|
||||
export interface CatalogCacheSnapshot {
|
||||
catalog: CacheBucketMetrics;
|
||||
export interface ServicesCacheSnapshot {
|
||||
services: CacheBucketMetrics;
|
||||
static: CacheBucketMetrics;
|
||||
volatile: CacheBucketMetrics;
|
||||
eligibility: CacheBucketMetrics;
|
||||
invalidations: number;
|
||||
}
|
||||
|
||||
export interface CatalogCacheOptions<T> {
|
||||
export interface ServicesCacheOptions<T> {
|
||||
allowNull?: boolean;
|
||||
resolveDependencies?: (
|
||||
value: T
|
||||
@ -24,33 +25,34 @@ interface LegacyCatalogCachePayload<T> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog cache service
|
||||
* Services cache service
|
||||
*
|
||||
* Uses CDC (Change Data Capture) for real-time cache invalidation with
|
||||
* product dependency tracking for granular invalidation.
|
||||
*
|
||||
* Features:
|
||||
* - CDC-driven invalidation: No TTL, cache persists until CDC event
|
||||
* - Event-driven invalidation: CDC / Platform Events invalidate caches on change
|
||||
* - Safety TTL: long TTL to self-heal if events are missed
|
||||
* - Product dependency tracking: Granular invalidation by product IDs
|
||||
* - Request coalescing: Prevents thundering herd on cache miss
|
||||
* - Metrics tracking: Monitors hits, misses, and invalidations
|
||||
*
|
||||
* Cache buckets:
|
||||
* - catalog: Product catalog data (CDC-driven)
|
||||
* - static: Static reference data (CDC-driven)
|
||||
* - eligibility: Account eligibility data (CDC-driven)
|
||||
* - catalog: Product catalog data (event-driven + safety TTL)
|
||||
* - static: Static reference data (event-driven + safety TTL)
|
||||
* - eligibility: Account eligibility data (event-driven + safety TTL)
|
||||
* - volatile: Frequently changing data (60s TTL)
|
||||
*/
|
||||
@Injectable()
|
||||
export class CatalogCacheService {
|
||||
// CDC-driven invalidation: null TTL means cache persists until explicit invalidation
|
||||
private readonly CATALOG_TTL: number | null = null;
|
||||
private readonly STATIC_TTL: number | null = null;
|
||||
private readonly ELIGIBILITY_TTL: number | null = null;
|
||||
export class ServicesCacheService {
|
||||
// CDC-driven invalidation + safety TTL (self-heal if events are missed)
|
||||
private readonly SERVICES_TTL: number | null;
|
||||
private readonly STATIC_TTL: number | null;
|
||||
private readonly ELIGIBILITY_TTL: number | null;
|
||||
private readonly VOLATILE_TTL = 60; // Volatile data still uses TTL
|
||||
|
||||
private readonly metrics: CatalogCacheSnapshot = {
|
||||
catalog: { hits: 0, misses: 0 },
|
||||
private readonly metrics: ServicesCacheSnapshot = {
|
||||
services: { hits: 0, misses: 0 },
|
||||
static: { hits: 0, misses: 0 },
|
||||
volatile: { hits: 0, misses: 0 },
|
||||
eligibility: { hits: 0, misses: 0 },
|
||||
@ -61,21 +63,32 @@ export class CatalogCacheService {
|
||||
// request the same data after CDC invalidation
|
||||
private readonly inflightRequests = new Map<string, Promise<unknown>>();
|
||||
|
||||
constructor(private readonly cache: CacheService) {}
|
||||
constructor(
|
||||
private readonly cache: CacheService,
|
||||
private readonly config: ConfigService
|
||||
) {
|
||||
const raw = this.config.get<number>("SERVICES_CACHE_SAFETY_TTL_SECONDS", 60 * 60 * 12);
|
||||
const ttl = typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : null;
|
||||
|
||||
/**
|
||||
* Get or fetch catalog data (CDC-driven cache, no TTL)
|
||||
*/
|
||||
async getCachedCatalog<T>(
|
||||
key: string,
|
||||
fetchFn: () => Promise<T>,
|
||||
options?: CatalogCacheOptions<T>
|
||||
): Promise<T> {
|
||||
return this.getOrSet("catalog", key, this.CATALOG_TTL, fetchFn, options);
|
||||
// Apply to CDC-driven buckets (catalog + static + eligibility)
|
||||
this.SERVICES_TTL = ttl;
|
||||
this.STATIC_TTL = ttl;
|
||||
this.ELIGIBILITY_TTL = ttl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or fetch static catalog data (CDC-driven cache, no TTL)
|
||||
* Get or fetch catalog data (CDC-driven cache with safety TTL)
|
||||
*/
|
||||
async getCachedServices<T>(
|
||||
key: string,
|
||||
fetchFn: () => Promise<T>,
|
||||
options?: ServicesCacheOptions<T>
|
||||
): Promise<T> {
|
||||
return this.getOrSet("services", key, this.SERVICES_TTL, fetchFn, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or fetch static catalog data (CDC-driven cache with safety TTL)
|
||||
*/
|
||||
async getCachedStatic<T>(key: string, fetchFn: () => Promise<T>): Promise<T> {
|
||||
return this.getOrSet("static", key, this.STATIC_TTL, fetchFn);
|
||||
@ -89,7 +102,7 @@ export class CatalogCacheService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or fetch eligibility data (CDC-driven cache, no TTL)
|
||||
* Get or fetch eligibility data (event-driven cache with safety TTL)
|
||||
*/
|
||||
async getCachedEligibility<T>(key: string, fetchFn: () => Promise<T>): Promise<T> {
|
||||
return this.getOrSet("eligibility", key, this.ELIGIBILITY_TTL, fetchFn, {
|
||||
@ -100,20 +113,20 @@ export class CatalogCacheService {
|
||||
/**
|
||||
* Build cache key for catalog data
|
||||
*/
|
||||
buildCatalogKey(catalogType: string, ...parts: string[]): string {
|
||||
return `catalog:${catalogType}:${parts.join(":")}`;
|
||||
buildServicesKey(serviceType: string, ...parts: string[]): string {
|
||||
return `services:${serviceType}:${parts.join(":")}`;
|
||||
}
|
||||
|
||||
buildEligibilityKey(_catalogType: string, accountId: string): string {
|
||||
return `catalog:eligibility:${accountId}`;
|
||||
return `services:eligibility:${accountId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate catalog cache by pattern
|
||||
*/
|
||||
async invalidateCatalog(catalogType: string): Promise<void> {
|
||||
async invalidateServices(serviceType: string): Promise<void> {
|
||||
this.metrics.invalidations++;
|
||||
await this.cache.delPattern(`catalog:${catalogType}:*`);
|
||||
await this.cache.delPattern(`services:${serviceType}:*`);
|
||||
await this.flushProductDependencyIndex();
|
||||
}
|
||||
|
||||
@ -129,9 +142,9 @@ export class CatalogCacheService {
|
||||
/**
|
||||
* Invalidate all catalog cache entries
|
||||
*/
|
||||
async invalidateAllCatalogs(): Promise<void> {
|
||||
async invalidateAllServices(): Promise<void> {
|
||||
this.metrics.invalidations++;
|
||||
await this.cache.delPattern("catalog:*");
|
||||
await this.cache.delPattern("services:*");
|
||||
await this.flushProductDependencyIndex();
|
||||
}
|
||||
|
||||
@ -139,13 +152,13 @@ export class CatalogCacheService {
|
||||
* Get TTL configuration for monitoring
|
||||
*/
|
||||
getTtlConfiguration(): {
|
||||
catalogSeconds: number | null;
|
||||
servicesSeconds: number | null;
|
||||
eligibilitySeconds: number | null;
|
||||
staticSeconds: number | null;
|
||||
volatileSeconds: number;
|
||||
} {
|
||||
return {
|
||||
catalogSeconds: this.CATALOG_TTL ?? null,
|
||||
servicesSeconds: this.SERVICES_TTL ?? null,
|
||||
eligibilitySeconds: this.ELIGIBILITY_TTL ?? null,
|
||||
staticSeconds: this.STATIC_TTL ?? null,
|
||||
volatileSeconds: this.VOLATILE_TTL,
|
||||
@ -155,9 +168,9 @@ export class CatalogCacheService {
|
||||
/**
|
||||
* Get cache metrics for monitoring
|
||||
*/
|
||||
getMetrics(): CatalogCacheSnapshot {
|
||||
getMetrics(): ServicesCacheSnapshot {
|
||||
return {
|
||||
catalog: { ...this.metrics.catalog },
|
||||
services: { ...this.metrics.services },
|
||||
static: { ...this.metrics.static },
|
||||
volatile: { ...this.metrics.volatile },
|
||||
eligibility: { ...this.metrics.eligibility },
|
||||
@ -173,10 +186,27 @@ export class CatalogCacheService {
|
||||
eligibility: string | null | undefined
|
||||
): Promise<void> {
|
||||
const key = this.buildEligibilityKey("", accountId);
|
||||
const payload =
|
||||
typeof eligibility === "string"
|
||||
? { Id: accountId, Internet_Eligibility__c: eligibility }
|
||||
: null;
|
||||
const payload = {
|
||||
status: eligibility ? "eligible" : "not_requested",
|
||||
eligibility: typeof eligibility === "string" ? eligibility : null,
|
||||
requestId: null,
|
||||
requestedAt: null,
|
||||
checkedAt: null,
|
||||
notes: null,
|
||||
};
|
||||
if (this.ELIGIBILITY_TTL === null) {
|
||||
await this.cache.set(key, payload);
|
||||
} else {
|
||||
await this.cache.set(key, payload, this.ELIGIBILITY_TTL);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set eligibility details payload for an account.
|
||||
* Used by Salesforce Platform Events to push updates into the cache without re-querying Salesforce.
|
||||
*/
|
||||
async setEligibilityDetails(accountId: string, payload: unknown): Promise<void> {
|
||||
const key = this.buildEligibilityKey("", accountId);
|
||||
if (this.ELIGIBILITY_TTL === null) {
|
||||
await this.cache.set(key, payload);
|
||||
} else {
|
||||
@ -185,11 +215,11 @@ export class CatalogCacheService {
|
||||
}
|
||||
|
||||
private async getOrSet<T>(
|
||||
bucket: "catalog" | "static" | "volatile" | "eligibility",
|
||||
bucket: "services" | "static" | "volatile" | "eligibility",
|
||||
key: string,
|
||||
ttlSeconds: number | null,
|
||||
fetchFn: () => Promise<T>,
|
||||
options?: CatalogCacheOptions<T>
|
||||
options?: ServicesCacheOptions<T>
|
||||
): Promise<T> {
|
||||
const allowNull = options?.allowNull ?? false;
|
||||
|
||||
@ -234,8 +264,8 @@ export class CatalogCacheService {
|
||||
|
||||
// Store and link dependencies separately
|
||||
if (dependencies) {
|
||||
await this.storeDependencies(key, dependencies);
|
||||
await this.linkDependencies(key, dependencies);
|
||||
await this.storeDependencies(key, dependencies, ttlSeconds);
|
||||
await this.linkDependencies(key, dependencies, ttlSeconds);
|
||||
}
|
||||
|
||||
return fresh;
|
||||
@ -264,8 +294,8 @@ export class CatalogCacheService {
|
||||
}
|
||||
|
||||
if (cached.dependencies) {
|
||||
await this.storeDependencies(key, cached.dependencies);
|
||||
await this.linkDependencies(key, cached.dependencies);
|
||||
await this.storeDependencies(key, cached.dependencies, ttlSeconds);
|
||||
await this.linkDependencies(key, cached.dependencies, ttlSeconds);
|
||||
}
|
||||
|
||||
return normalizedValue;
|
||||
@ -327,11 +357,19 @@ export class CatalogCacheService {
|
||||
/**
|
||||
* Store dependencies metadata for a cache key
|
||||
*/
|
||||
private async storeDependencies(key: string, dependencies: CacheDependencies): Promise<void> {
|
||||
private async storeDependencies(
|
||||
key: string,
|
||||
dependencies: CacheDependencies,
|
||||
ttlSeconds: number | null
|
||||
): Promise<void> {
|
||||
const normalized = this.normalizeDependencies(dependencies);
|
||||
if (normalized) {
|
||||
const metaKey = this.buildDependencyMetaKey(key);
|
||||
await this.cache.set(metaKey, normalized);
|
||||
if (ttlSeconds === null) {
|
||||
await this.cache.set(metaKey, normalized);
|
||||
} else {
|
||||
await this.cache.set(metaKey, normalized, ttlSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -358,7 +396,11 @@ export class CatalogCacheService {
|
||||
return { productIds: Array.from(new Set(productIds)) };
|
||||
}
|
||||
|
||||
private async linkDependencies(key: string, dependencies: CacheDependencies): Promise<void> {
|
||||
private async linkDependencies(
|
||||
key: string,
|
||||
dependencies: CacheDependencies,
|
||||
ttlSeconds: number | null
|
||||
): Promise<void> {
|
||||
const normalized = this.normalizeDependencies(dependencies);
|
||||
if (!normalized) {
|
||||
return;
|
||||
@ -371,7 +413,11 @@ export class CatalogCacheService {
|
||||
if (!existing.includes(key)) {
|
||||
existing.push(key);
|
||||
}
|
||||
await this.cache.set(indexKey, { keys: existing });
|
||||
if (ttlSeconds === null) {
|
||||
await this.cache.set(indexKey, { keys: existing });
|
||||
} else {
|
||||
await this.cache.set(indexKey, { keys: existing }, ttlSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -403,17 +449,12 @@ export class CatalogCacheService {
|
||||
}
|
||||
|
||||
private buildProductDependencyKey(productId: string): string {
|
||||
return `catalog:deps:product:${productId}`;
|
||||
return `services:deps:product:${productId}`;
|
||||
}
|
||||
|
||||
private async flushProductDependencyIndex(): Promise<void> {
|
||||
await this.cache.delPattern("catalog:deps:product:*");
|
||||
await this.cache.delPattern("services:deps:product:*");
|
||||
}
|
||||
}
|
||||
|
||||
export interface CatalogCacheOptions<T> {
|
||||
allowNull?: boolean;
|
||||
resolveDependencies?: (
|
||||
value: T
|
||||
) => CacheDependencies | Promise<CacheDependencies | undefined> | undefined;
|
||||
}
|
||||
// (intentionally no duplicate options type; use ServicesCacheOptions<T> above)
|
||||
@ -1,38 +1,38 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { BaseCatalogService } from "./base-catalog.service.js";
|
||||
import { CatalogCacheService } from "./catalog-cache.service.js";
|
||||
import { BaseServicesService } from "./base-services.service.js";
|
||||
import { ServicesCacheService } from "./services-cache.service.js";
|
||||
import type {
|
||||
SalesforceProduct2WithPricebookEntries,
|
||||
SimCatalogProduct,
|
||||
SimActivationFeeCatalogItem,
|
||||
} from "@customer-portal/domain/catalog";
|
||||
import { Providers as CatalogProviders } from "@customer-portal/domain/catalog";
|
||||
} from "@customer-portal/domain/services";
|
||||
import { Providers as CatalogProviders } from "@customer-portal/domain/services";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { WhmcsConnectionOrchestratorService } from "@bff/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.js";
|
||||
|
||||
@Injectable()
|
||||
export class SimCatalogService extends BaseCatalogService {
|
||||
export class SimServicesService extends BaseServicesService {
|
||||
constructor(
|
||||
sf: SalesforceConnection,
|
||||
configService: ConfigService,
|
||||
@Inject(Logger) logger: Logger,
|
||||
private mappingsService: MappingsService,
|
||||
private whmcs: WhmcsConnectionOrchestratorService,
|
||||
private catalogCache: CatalogCacheService
|
||||
private catalogCache: ServicesCacheService
|
||||
) {
|
||||
super(sf, configService, logger);
|
||||
}
|
||||
|
||||
async getPlans(): Promise<SimCatalogProduct[]> {
|
||||
const cacheKey = this.catalogCache.buildCatalogKey("sim", "plans");
|
||||
const cacheKey = this.catalogCache.buildServicesKey("sim", "plans");
|
||||
|
||||
return this.catalogCache.getCachedCatalog(
|
||||
return this.catalogCache.getCachedServices(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const soql = this.buildCatalogServiceQuery("SIM", [
|
||||
const soql = this.buildServicesQuery("SIM", [
|
||||
"SIM_Data_Size__c",
|
||||
"SIM_Plan_Type__c",
|
||||
"SIM_Has_Family_Discount__c",
|
||||
@ -62,9 +62,9 @@ export class SimCatalogService extends BaseCatalogService {
|
||||
}
|
||||
|
||||
async getActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
|
||||
const cacheKey = this.catalogCache.buildCatalogKey("sim", "activation-fees");
|
||||
const cacheKey = this.catalogCache.buildServicesKey("sim", "activation-fees");
|
||||
|
||||
return this.catalogCache.getCachedCatalog(
|
||||
return this.catalogCache.getCachedServices(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const soql = this.buildProductQuery("SIM", "Activation", [
|
||||
@ -115,9 +115,9 @@ export class SimCatalogService extends BaseCatalogService {
|
||||
}
|
||||
|
||||
async getAddons(): Promise<SimCatalogProduct[]> {
|
||||
const cacheKey = this.catalogCache.buildCatalogKey("sim", "addons");
|
||||
const cacheKey = this.catalogCache.buildServicesKey("sim", "addons");
|
||||
|
||||
return this.catalogCache.getCachedCatalog(
|
||||
return this.catalogCache.getCachedServices(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const soql = this.buildProductQuery("SIM", "Add-on", [
|
||||
@ -184,22 +184,28 @@ export class SimCatalogService extends BaseCatalogService {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check WHMCS for existing SIM services
|
||||
const products = await this.whmcs.getClientsProducts({ clientid: mapping.whmcsClientId });
|
||||
const services = (products?.products?.product || []) as Array<{
|
||||
groupname?: string;
|
||||
status?: string;
|
||||
}>;
|
||||
|
||||
// Look for active SIM services
|
||||
const hasActiveSim = services.some(
|
||||
service =>
|
||||
String(service.groupname || "")
|
||||
.toLowerCase()
|
||||
.includes("sim") && String(service.status || "").toLowerCase() === "active"
|
||||
const cacheKey = this.catalogCache.buildServicesKey(
|
||||
"sim",
|
||||
"has-existing-sim",
|
||||
String(mapping.whmcsClientId)
|
||||
);
|
||||
|
||||
return hasActiveSim;
|
||||
// This is per-account and can be somewhat expensive (WHMCS call).
|
||||
// Cache briefly to reduce repeat reads during account page refreshes.
|
||||
return await this.catalogCache.getCachedVolatile(cacheKey, async () => {
|
||||
const products = await this.whmcs.getClientsProducts({ clientid: mapping.whmcsClientId });
|
||||
const services = (products?.products?.product || []) as Array<{
|
||||
groupname?: string;
|
||||
status?: string;
|
||||
}>;
|
||||
|
||||
// Look for active SIM services
|
||||
return services.some(service => {
|
||||
const group = String(service.groupname || "").toLowerCase();
|
||||
const status = String(service.status || "").toLowerCase();
|
||||
return group.includes("sim") && status === "active";
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to check existing SIM for user ${userId}`, error);
|
||||
return false; // Default to no existing SIM
|
||||
@ -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 { SimActionRunnerService } from "./sim-action-runner.service.js";
|
||||
import { SimApiNotificationService } from "./sim-api-notification.service.js";
|
||||
import { NotificationService } from "@bff/modules/notifications/notifications.service.js";
|
||||
import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications";
|
||||
|
||||
export interface CancellationMonth {
|
||||
value: string; // YYYY-MM format
|
||||
@ -38,6 +40,7 @@ export class SimCancellationService {
|
||||
private readonly simSchedule: SimScheduleService,
|
||||
private readonly simActionRunner: SimActionRunnerService,
|
||||
private readonly apiNotification: SimApiNotificationService,
|
||||
private readonly notifications: NotificationService,
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
@ -254,6 +257,24 @@ export class SimCancellationService {
|
||||
runDate,
|
||||
});
|
||||
|
||||
try {
|
||||
await this.notifications.createNotification({
|
||||
userId,
|
||||
type: NOTIFICATION_TYPE.CANCELLATION_SCHEDULED,
|
||||
source: NOTIFICATION_SOURCE.SYSTEM,
|
||||
sourceId: `sim:${subscriptionId}:${runDate}`,
|
||||
actionUrl: `/account/services/${subscriptionId}`,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn("Failed to create SIM cancellation notification", {
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
runDate,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
// Send admin notification email
|
||||
const adminEmailBody = this.apiNotification.buildCancellationAdminEmail({
|
||||
customerName,
|
||||
|
||||
@ -12,8 +12,8 @@ import { SimScheduleService } from "./sim-schedule.service.js";
|
||||
import { SimActionRunnerService } from "./sim-action-runner.service.js";
|
||||
import { SimManagementQueueService } from "../queue/sim-management.queue.js";
|
||||
import { SimApiNotificationService } from "./sim-api-notification.service.js";
|
||||
import { SimCatalogService } from "@bff/modules/catalog/services/sim-catalog.service.js";
|
||||
import type { SimCatalogProduct } from "@customer-portal/domain/catalog";
|
||||
import { SimServicesService } from "@bff/modules/services/services/sim-services.service.js";
|
||||
import type { SimCatalogProduct } from "@customer-portal/domain/services";
|
||||
|
||||
// Mapping from Salesforce SKU to Freebit plan code
|
||||
const SKU_TO_FREEBIT_PLAN_CODE: Record<string, string> = {
|
||||
@ -47,7 +47,7 @@ export class SimPlanService {
|
||||
private readonly simActionRunner: SimActionRunnerService,
|
||||
private readonly simQueue: SimManagementQueueService,
|
||||
private readonly apiNotification: SimApiNotificationService,
|
||||
private readonly simCatalog: SimCatalogService,
|
||||
private readonly simCatalog: SimServicesService,
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
@ -27,7 +27,8 @@ import { SimManagementQueueService } from "./queue/sim-management.queue.js";
|
||||
import { SimManagementProcessor } from "./queue/sim-management.processor.js";
|
||||
import { SimVoiceOptionsService } from "./services/sim-voice-options.service.js";
|
||||
import { SimCallHistoryService } from "./services/sim-call-history.service.js";
|
||||
import { CatalogModule } from "@bff/modules/catalog/catalog.module.js";
|
||||
import { ServicesModule } from "@bff/modules/services/services.module.js";
|
||||
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -36,8 +37,9 @@ import { CatalogModule } from "@bff/modules/catalog/catalog.module.js";
|
||||
SalesforceModule,
|
||||
MappingsModule,
|
||||
EmailModule,
|
||||
CatalogModule,
|
||||
ServicesModule,
|
||||
SftpModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
providers: [
|
||||
// Core services that the SIM services depend on
|
||||
|
||||
@ -51,6 +51,12 @@ import {
|
||||
type ReissueSimRequest,
|
||||
} from "./sim-management/services/esim-management.service.js";
|
||||
import { SimCallHistoryService } from "./sim-management/services/sim-call-history.service.js";
|
||||
import { InternetCancellationService } from "./internet-management/services/internet-cancellation.service.js";
|
||||
import {
|
||||
internetCancelRequestSchema,
|
||||
type InternetCancelRequest,
|
||||
type SimActionResponse as SubscriptionActionResponse,
|
||||
} from "@customer-portal/domain/subscriptions";
|
||||
|
||||
const subscriptionInvoiceQuerySchema = createPaginationSchema({
|
||||
defaultLimit: 10,
|
||||
@ -68,7 +74,8 @@ export class SubscriptionsController {
|
||||
private readonly simPlanService: SimPlanService,
|
||||
private readonly simCancellationService: SimCancellationService,
|
||||
private readonly esimManagementService: EsimManagementService,
|
||||
private readonly simCallHistoryService: SimCallHistoryService
|
||||
private readonly simCallHistoryService: SimCallHistoryService,
|
||||
private readonly internetCancellationService: InternetCancellationService
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ -377,6 +384,41 @@ export class SubscriptionsController {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Internet Management Endpoints ====================
|
||||
|
||||
/**
|
||||
* Get Internet cancellation preview (available months, service details)
|
||||
*/
|
||||
@Get(":id/internet/cancellation-preview")
|
||||
@Header("Cache-Control", "private, max-age=60")
|
||||
async getInternetCancellationPreview(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number
|
||||
) {
|
||||
const preview = await this.internetCancellationService.getCancellationPreview(
|
||||
req.user.id,
|
||||
subscriptionId
|
||||
);
|
||||
return { success: true, data: preview };
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit Internet cancellation request
|
||||
*/
|
||||
@Post(":id/internet/cancel")
|
||||
@UsePipes(new ZodValidationPipe(internetCancelRequestSchema))
|
||||
async cancelInternet(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Body() body: InternetCancelRequest
|
||||
): Promise<SubscriptionActionResponse> {
|
||||
await this.internetCancellationService.submitCancellation(req.user.id, subscriptionId, body);
|
||||
return {
|
||||
success: true,
|
||||
message: `Internet cancellation scheduled for end of ${body.cancellationMonth}`,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Call/SMS History Endpoints ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -10,9 +10,17 @@ import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
||||
import { FreebitModule } from "@bff/integrations/freebit/freebit.module.js";
|
||||
import { EmailModule } from "@bff/infra/email/email.module.js";
|
||||
import { SimManagementModule } from "./sim-management/sim-management.module.js";
|
||||
import { InternetManagementModule } from "./internet-management/internet-management.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [WhmcsModule, MappingsModule, FreebitModule, EmailModule, SimManagementModule],
|
||||
imports: [
|
||||
WhmcsModule,
|
||||
MappingsModule,
|
||||
FreebitModule,
|
||||
EmailModule,
|
||||
SimManagementModule,
|
||||
InternetManagementModule,
|
||||
],
|
||||
controllers: [SubscriptionsController, SimOrdersController],
|
||||
providers: [
|
||||
SubscriptionsService,
|
||||
|
||||
@ -12,6 +12,7 @@ import type {
|
||||
} from "@customer-portal/domain/subscriptions";
|
||||
import type { Invoice, InvoiceItem, InvoiceList } from "@customer-portal/domain/billing";
|
||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
|
||||
import { WhmcsCacheService } from "@bff/integrations/whmcs/cache/whmcs-cache.service.js";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import type { Providers } from "@customer-portal/domain/subscriptions";
|
||||
@ -26,6 +27,7 @@ export interface GetSubscriptionsOptions {
|
||||
export class SubscriptionsService {
|
||||
constructor(
|
||||
private readonly whmcsService: WhmcsService,
|
||||
private readonly cacheService: WhmcsCacheService,
|
||||
private readonly mappingsService: MappingsService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
@ -316,6 +318,20 @@ export class SubscriptionsService {
|
||||
const batchSize = Math.min(100, Math.max(limit, 25));
|
||||
|
||||
try {
|
||||
// Try cache first
|
||||
const cached = await this.cacheService.getSubscriptionInvoices(
|
||||
userId,
|
||||
subscriptionId,
|
||||
page,
|
||||
limit
|
||||
);
|
||||
if (cached) {
|
||||
this.logger.debug(
|
||||
`Cache hit for subscription invoices: user ${userId}, subscription ${subscriptionId}`
|
||||
);
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Validate subscription exists and belongs to user
|
||||
await this.getSubscriptionById(userId, subscriptionId);
|
||||
|
||||
@ -380,6 +396,9 @@ export class SubscriptionsService {
|
||||
}
|
||||
);
|
||||
|
||||
// Cache the result
|
||||
await this.cacheService.setSubscriptionInvoices(userId, subscriptionId, page, limit, result);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get invoices for subscription ${subscriptionId}`, {
|
||||
|
||||
@ -1,6 +1,20 @@
|
||||
import { Controller, Get, Post, Query, Param, Body, Request } from "@nestjs/common";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Query,
|
||||
Param,
|
||||
Body,
|
||||
Request,
|
||||
Inject,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { SupportService } from "./support.service.js";
|
||||
import { ZodValidationPipe } from "nestjs-zod";
|
||||
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
|
||||
import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
supportCaseFilterSchema,
|
||||
createCaseRequestSchema,
|
||||
@ -12,9 +26,23 @@ import {
|
||||
} from "@customer-portal/domain/support";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||
|
||||
// Public contact form schema
|
||||
const publicContactSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
email: z.string().email("Valid email required"),
|
||||
phone: z.string().optional(),
|
||||
subject: z.string().min(1, "Subject is required"),
|
||||
message: z.string().min(10, "Message must be at least 10 characters"),
|
||||
});
|
||||
|
||||
type PublicContactRequest = z.infer<typeof publicContactSchema>;
|
||||
|
||||
@Controller("support")
|
||||
export class SupportController {
|
||||
constructor(private readonly supportService: SupportService) {}
|
||||
constructor(
|
||||
private readonly supportService: SupportService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
@Get("cases")
|
||||
async listCases(
|
||||
@ -41,4 +69,36 @@ export class SupportController {
|
||||
): Promise<CreateCaseResponse> {
|
||||
return this.supportService.createCase(req.user.id, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public contact form endpoint
|
||||
*
|
||||
* Creates a Lead or Case in Salesforce for unauthenticated users.
|
||||
* Rate limited to prevent spam.
|
||||
*/
|
||||
@Post("contact")
|
||||
@Public()
|
||||
@UseGuards(RateLimitGuard)
|
||||
@RateLimit({ limit: 5, ttl: 300 }) // 5 requests per 5 minutes
|
||||
async publicContact(
|
||||
@Body(new ZodValidationPipe(publicContactSchema))
|
||||
body: PublicContactRequest
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
this.logger.log("Public contact form submission", { email: body.email });
|
||||
|
||||
try {
|
||||
await this.supportService.createPublicContactRequest(body);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Your message has been received. We will get back to you within 24 hours.",
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to process public contact form", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
email: body.email,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -204,9 +204,13 @@ export class UserProfileService {
|
||||
return summary;
|
||||
}
|
||||
|
||||
const [subscriptionsData, invoicesData] = await Promise.allSettled([
|
||||
const [subscriptionsData, invoicesData, unpaidInvoicesData] = await Promise.allSettled([
|
||||
this.whmcsService.getSubscriptions(mapping.whmcsClientId, userId),
|
||||
this.whmcsService.getInvoices(mapping.whmcsClientId, userId, { limit: 50 }),
|
||||
this.whmcsService.getInvoices(mapping.whmcsClientId, userId, { limit: 10 }),
|
||||
this.whmcsService.getInvoices(mapping.whmcsClientId, userId, {
|
||||
status: "Unpaid",
|
||||
limit: 1,
|
||||
}),
|
||||
]);
|
||||
|
||||
let activeSubscriptions = 0;
|
||||
@ -256,12 +260,25 @@ export class UserProfileService {
|
||||
paidDate?: string;
|
||||
currency?: string | null;
|
||||
}> = [];
|
||||
|
||||
// Process unpaid invoices count
|
||||
if (unpaidInvoicesData.status === "fulfilled") {
|
||||
unpaidInvoices = unpaidInvoicesData.value.pagination.totalItems;
|
||||
} else {
|
||||
this.logger.error(`Failed to fetch unpaid invoices count for user ${userId}`, {
|
||||
reason: getErrorMessage(unpaidInvoicesData.reason),
|
||||
});
|
||||
}
|
||||
|
||||
if (invoicesData.status === "fulfilled") {
|
||||
const invoices: Invoice[] = invoicesData.value.invoices;
|
||||
|
||||
unpaidInvoices = invoices.filter(
|
||||
inv => inv.status === "Unpaid" || inv.status === "Overdue"
|
||||
).length;
|
||||
// Fallback if unpaid invoices call failed, though inaccurate for total count > 10
|
||||
if (unpaidInvoicesData.status === "rejected") {
|
||||
unpaidInvoices = invoices.filter(
|
||||
inv => inv.status === "Unpaid" || inv.status === "Overdue"
|
||||
).length;
|
||||
}
|
||||
|
||||
const upcomingInvoices = invoices
|
||||
.filter(inv => (inv.status === "Unpaid" || inv.status === "Overdue") && inv.dueDate)
|
||||
|
||||
@ -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",
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts", "**/*.test.ts"]
|
||||
}
|
||||
|
||||
@ -15,6 +15,6 @@
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"include": ["src/**/*.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/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
/* eslint-env node */
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
|
||||
@ -5,8 +5,8 @@
|
||||
"scripts": {
|
||||
"predev": "node ./scripts/dev-prep.mjs",
|
||||
"dev": "next dev -p ${NEXT_PORT:-3000}",
|
||||
"build": "next build",
|
||||
"build:webpack": "next build --webpack",
|
||||
"build": "next build --webpack",
|
||||
"build:turbo": "next build",
|
||||
"build:analyze": "ANALYZE=true next build",
|
||||
"analyze": "pnpm run build:analyze",
|
||||
"start": "next start -p ${NEXT_PORT:-3000}",
|
||||
@ -24,6 +24,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.0.10",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
/* eslint-env node */
|
||||
|
||||
/**
|
||||
* Bundle size monitoring script
|
||||
* Analyzes bundle size and reports on performance metrics
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
/* eslint-env node */
|
||||
|
||||
// Ensure dev-time Next.js manifests exist to avoid noisy ENOENT errors
|
||||
import { mkdirSync, existsSync, writeFileSync, rmSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user