chore: update repository structure documentation for clarity and organization

This commit is contained in:
barsa 2026-01-15 16:31:15 +09:00
parent aa77f23d85
commit 37ef51cc82
31 changed files with 207 additions and 1031 deletions

View File

@ -61,7 +61,6 @@
"rxjs": "^7.8.2",
"salesforce-pubsub-api-client": "^5.5.1",
"ssh2-sftp-client": "^12.0.1",
"swagger-ui-express": "^5.0.1",
"zod": "catalog:"
},
"devDependencies": {

View File

@ -70,11 +70,6 @@ export function extractErrorMessage(error: unknown): string {
return "An unknown error occurred";
}
/**
* @deprecated Use extractErrorMessage instead. This alias exists for backwards compatibility.
*/
export const getErrorMessage = extractErrorMessage;
/**
* PRODUCTION-SAFE: Get error message safe for client consumption
* Removes sensitive information and stack traces

View File

@ -205,11 +205,6 @@ export const OPPORTUNITY_SIM_CANCELLATION_QUERY_FIELDS = [
OPPORTUNITY_FIELD_MAP.whmcsServiceId,
] as const;
/**
* @deprecated Use OPPORTUNITY_INTERNET_CANCELLATION_QUERY_FIELDS or OPPORTUNITY_SIM_CANCELLATION_QUERY_FIELDS
*/
export const OPPORTUNITY_CANCELLATION_QUERY_FIELDS = OPPORTUNITY_INTERNET_CANCELLATION_QUERY_FIELDS;
// =============================================================================
// Opportunity Picklist References
// =============================================================================

View File

@ -16,7 +16,6 @@ export {
OPPORTUNITY_DETAIL_QUERY_FIELDS,
OPPORTUNITY_INTERNET_CANCELLATION_QUERY_FIELDS,
OPPORTUNITY_SIM_CANCELLATION_QUERY_FIELDS,
OPPORTUNITY_CANCELLATION_QUERY_FIELDS,
OPPORTUNITY_STAGE_REFERENCE,
APPLICATION_STAGE_REFERENCE,
CANCELLATION_NOTICE_REFERENCE,

View File

@ -10,10 +10,7 @@ import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "../salesforce-connection.service.js";
import { assertSalesforceId } from "../../utils/soql.util.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import {
type CancellationOpportunityData,
OPPORTUNITY_STAGE,
} from "@customer-portal/domain/opportunity";
import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity";
import { OPPORTUNITY_FIELD_MAP } from "../../constants/index.js";
import {
type InternetCancellationOpportunityDataInput,
@ -148,16 +145,6 @@ export class OpportunityCancellationService {
}
}
/**
* @deprecated Use updateInternetCancellationData or updateSimCancellationData
*/
async updateCancellationData(
opportunityId: string,
data: CancellationOpportunityData
): Promise<void> {
return this.updateInternetCancellationData(opportunityId, data);
}
/**
* Mark cancellation as complete by updating stage to Cancelled
*/

View File

@ -20,7 +20,6 @@ import { Injectable } from "@nestjs/common";
import {
type OpportunityStageValue,
type OpportunityProductTypeValue,
type CancellationOpportunityData,
type CreateOpportunityRequest,
type OpportunityRecord,
} from "@customer-portal/domain/opportunity";
@ -94,16 +93,6 @@ export class SalesforceOpportunityService {
return this.cancellationService.updateSimCancellationData(opportunityId, data);
}
/**
* @deprecated Use updateInternetCancellationData or updateSimCancellationData
*/
async updateCancellationData(
opportunityId: string,
data: CancellationOpportunityData
): Promise<void> {
return this.cancellationService.updateCancellationData(opportunityId, data);
}
/**
* Mark cancellation as complete
*/
@ -183,15 +172,6 @@ export class SalesforceOpportunityService {
return this.queryService.getSimCancellationStatusByOpportunityId(opportunityId);
}
/**
* @deprecated Use getInternetCancellationStatus or getSimCancellationStatus
*/
async getCancellationStatus(
whmcsServiceId: number
): Promise<InternetCancellationStatusResult | null> {
return this.queryService.getInternetCancellationStatus(whmcsServiceId);
}
// ==========================================================================
// Lifecycle Helpers (via MutationService)
// ==========================================================================

View File

@ -124,18 +124,6 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit {
});
}
/**
* Make a high-priority request to WHMCS API (jumps queue)
* @deprecated Use makeRequest with { highPriority: true } instead
*/
async makeHighPriorityRequest<T>(
action: string,
params: Record<string, unknown> = {},
options: WhmcsRequestOptions = {}
): Promise<T> {
return this.makeRequest(action, params, { ...options, highPriority: true });
}
/**
* Get queue metrics for monitoring
*/

View File

@ -72,21 +72,6 @@ export function EmptyState({
);
}
// Specific empty states for common use cases
export function NoDataEmptyState({
title = "No data available",
description = "There's no data to display at the moment.",
className,
}: {
title?: string;
description?: string;
className?: string;
}) {
return (
<EmptyState title={title} description={description} className={className} variant="compact" />
);
}
export function SearchEmptyState({
searchTerm,
onClearSearch,
@ -110,21 +95,3 @@ export function SearchEmptyState({
/>
);
}
export function FilterEmptyState({ onClearFilters }: { onClearFilters?: () => void }) {
return (
<EmptyState
title="No results match your filters"
description="Try adjusting or clearing your filters to see more results."
action={
onClearFilters
? {
label: "Clear filters",
onClick: onClearFilters,
}
: undefined
}
variant="compact"
/>
);
}

View File

@ -84,34 +84,3 @@ export function ErrorState({
</div>
);
}
export function NetworkErrorState({ onRetry }: { onRetry?: () => void }) {
return (
<ErrorState
title="Connection Error"
message="Unable to connect to the server. Please check your internet connection and try again."
onRetry={onRetry}
retryLabel="Reconnect"
/>
);
}
export function NotFoundErrorState({ resourceName = "resource" }: { resourceName?: string }) {
return (
<ErrorState
title="Not Found"
message={`The ${resourceName} you're looking for could not be found.`}
variant="page"
/>
);
}
export function PermissionErrorState() {
return (
<ErrorState
title="Access Denied"
message="You don't have permission to access this resource."
variant="page"
/>
);
}

View File

@ -30,14 +30,9 @@ export type { BadgeProps } from "./badge";
export { Spinner } from "./spinner";
export { LoadingOverlay } from "./loading-overlay";
export {
ErrorState,
NetworkErrorState,
NotFoundErrorState,
PermissionErrorState,
} from "./error-state";
export { ErrorState } from "./error-state";
export { EmptyState, NoDataEmptyState, SearchEmptyState, FilterEmptyState } from "./empty-state";
export { EmptyState, SearchEmptyState } from "./empty-state";
// Additional UI Components
export { InlineToast } from "./inline-toast";

View File

@ -32,13 +32,6 @@ export const servicesService = {
return getDataOrThrow(response, "Failed to load internet services");
},
/**
* @deprecated Use getPublicInternetCatalog() or getAccountInternetCatalog() for clear separation.
*/
async getInternetCatalog(): Promise<InternetCatalogCollection> {
return this.getPublicInternetCatalog();
},
async getPublicSimCatalog(): Promise<SimCatalogCollection> {
const response = await apiClient.GET<SimCatalogCollection>("/api/public/services/sim/plans");
return getDataOrDefault(response, EMPTY_SIM_CATALOG);
@ -86,13 +79,6 @@ export const servicesService = {
return internetAddonCatalogItemSchema.array().parse(data);
},
/**
* @deprecated Use getPublicSimCatalog() or getAccountSimCatalog() for clear separation.
*/
async getSimCatalog(): Promise<SimCatalogCollection> {
return this.getPublicSimCatalog();
},
async getSimActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
const response = await apiClient.GET<SimActivationFeeCatalogItem[]>(
"/api/services/sim/activation-fees"
@ -107,13 +93,6 @@ export const servicesService = {
return simCatalogProductSchema.array().parse(data);
},
/**
* @deprecated Use getPublicVpnCatalog() or getAccountVpnCatalog() for clear separation.
*/
async getVpnCatalog(): Promise<VpnCatalogCollection> {
return this.getPublicVpnCatalog();
},
async getVpnActivationFees(): Promise<VpnCatalogProduct[]> {
const response = await apiClient.GET<VpnCatalogProduct[]>("/api/services/vpn/activation-fees");
const data = getDataOrDefault(response, []);

View File

@ -7,6 +7,5 @@ export {
export {
JAPAN_PREFECTURES,
formatJapanesePostalCode,
isValidJapanesePostalCode,
type PrefectureOption,
} from "./japan-prefectures";

View File

@ -79,10 +79,3 @@ export function formatJapanesePostalCode(value: string): string {
return `${digits.slice(0, 3)}-${digits.slice(3, 7)}`;
}
/**
* Validate Japanese postal code format (XXX-XXXX)
*/
export function isValidJapanesePostalCode(value: string): boolean {
return /^\d{3}-\d{4}$/.test(value);
}

View File

@ -147,20 +147,6 @@ export function shouldLogout(error: unknown): boolean {
return parseError(error).shouldLogout;
}
/**
* Check if error can be retried
*/
export function canRetry(error: unknown): boolean {
return parseError(error).shouldRetry;
}
/**
* Get error code from any error
*/
export function getErrorCode(error: unknown): ErrorCodeType {
return parseError(error).code;
}
// ============================================================================
// Re-exports from domain package for convenience
// ============================================================================

View File

@ -14,8 +14,6 @@ export {
parseError,
getErrorMessage,
shouldLogout,
canRetry,
getErrorCode,
ErrorCode,
ErrorMessages,
type ParsedError,

View File

@ -163,6 +163,23 @@ Feature guides explaining how the portal functions:
---
## 📐 Architecture Decisions
Key architectural decisions and their rationale:
| Decision | Summary |
| --------------------------------------------------------------------------------- | -------------------------------------------------- |
| [Platform Events over Webhooks](./decisions/001-platform-events-over-webhooks.md) | Why we use SF Platform Events for provisioning |
| [Zod-First Validation](./decisions/002-zod-first-validation.md) | Why Zod schemas are the source of truth |
| [Map Once, Use Everywhere](./decisions/003-map-once-use-everywhere.md) | Why single transformation in domain mappers |
| [Domain Provider Isolation](./decisions/004-domain-provider-isolation.md) | Why provider code is isolated in domain/providers/ |
| [Feature Module Pattern](./decisions/005-feature-module-pattern.md) | Why Portal uses feature modules |
| [Thin Controllers](./decisions/006-thin-controllers.md) | Why controllers handle HTTP only |
See [all decisions](./decisions/README.md) for the complete index and ADR template.
---
## 🗂️ Archive
Historical documents kept for reference:

View File

@ -19,13 +19,21 @@ Configuration
Portal (Next.js)
- src/lib/ contains cross-feature utilities:
- api.ts: centralized HTTP client (typed, safe JSON parsing)
- env.ts: zod-validated runtime environment
- auth/: Zustand store and helpers
- Hooks live under src/hooks/ and should consume the centralized client
- Feature barrels are available under src/features/{billing,subscriptions,dashboard}/hooks for stable imports
- Page components keep UI and delegate data-fetching to hooks
- `src/core/` contains app-wide infrastructure:
- `api/`: centralized HTTP client, query keys, response helpers
- `logger/`: application logging utilities
- `providers/`: React context providers (QueryProvider)
- `src/shared/` contains cross-feature utilities:
- `hooks/`: generic React hooks (useLocalStorage, useDebounce, useMediaQuery)
- `utils/`: general utilities (cn, date formatting, error handling)
- `constants/`: data constants (countries, prefectures)
- `src/features/` contains feature modules with consistent structure:
- `api/`: data fetching layer
- `hooks/`: React Query hooks
- `stores/`: Zustand state management
- `components/`: feature-specific UI
- `views/`: page-level views
- Page components in `src/app/` are thin wrappers that import views from features
BFF (NestJS)
@ -265,6 +273,5 @@ Documentation is organized in subdirectories:
---
**Last Updated**: $(date)
**Last Updated**: January 2026
**Status**: ✅ Clean and Organized
**Redundancy**: ❌ None Found

View File

@ -2,6 +2,21 @@
This document outlines the folder structure and architectural boundaries in the BFF (Backend for Frontend).
---
## Quick Reference
| Aspect | Location |
| ------------------ | ---------------------------------------------------------- |
| **Controllers** | `apps/bff/src/modules/<module>/<module>.controller.ts` |
| **Services** | `apps/bff/src/modules/<module>/services/` |
| **Integrations** | `apps/bff/src/integrations/<provider>/` |
| **Domain mappers** | `import from "@customer-portal/domain/<module>/providers"` |
**Key pattern**: Controllers → Services → Integration Services → Domain Mappers → Domain Types
---
## Folder Structure
```
@ -172,3 +187,58 @@ import axios from "axios"; // Use integrations instead
3. **Integrations transform**: Fetch external data, map to domain types
4. **Infra abstracts**: Provide clean interfaces over infrastructure
5. **Core configures**: Set up the NestJS framework
---
## API Endpoint Inventory
### Public Endpoints (No Auth Required)
| Method | Path | Module | Description |
| ------ | --------------------------- | -------- | ------------------------- |
| GET | `/api/health` | health | Health check |
| GET | `/api/catalog` | services | Public product catalog |
| GET | `/api/catalog/:category` | services | Products by category |
| POST | `/api/auth/login` | auth | User login |
| POST | `/api/auth/signup` | auth | User registration |
| POST | `/api/auth/get-started` | auth | Unified get-started flow |
| POST | `/api/auth/refresh` | auth | Refresh access token |
| POST | `/api/auth/forgot-password` | auth | Request password reset |
| POST | `/api/auth/reset-password` | auth | Complete password reset |
| GET | `/api/address/lookup` | address | Japan Post address lookup |
### Account Endpoints (Auth Required)
| Method | Path | Module | Description |
| ------ | ---------------------------------- | ------------- | ------------------------ |
| GET | `/api/me` | me-status | Current user status |
| GET | `/api/me/profile` | users | User profile |
| PATCH | `/api/me/profile` | users | Update profile |
| PATCH | `/api/me/address` | users | Update address |
| GET | `/api/invoices` | billing | List invoices |
| GET | `/api/invoices/:id` | billing | Invoice detail |
| GET | `/api/payment-methods` | billing | Payment methods |
| GET | `/api/payment-methods/summary` | billing | Has payment method check |
| POST | `/api/auth/sso-link` | auth | WHMCS SSO link |
| GET | `/api/orders` | orders | List orders |
| GET | `/api/orders/:id` | orders | Order detail |
| POST | `/api/orders` | orders | Create order |
| GET | `/api/subscriptions` | subscriptions | List subscriptions |
| GET | `/api/subscriptions/:id` | subscriptions | Subscription detail |
| POST | `/api/subscriptions/:id/actions` | subscriptions | Subscription actions |
| GET | `/api/cases` | support | List support cases |
| GET | `/api/cases/:id` | support | Case detail |
| POST | `/api/cases` | support | Create case |
| POST | `/api/cases/:id/messages` | support | Add case message |
| GET | `/api/notifications` | notifications | User notifications |
| PATCH | `/api/notifications/:id/read` | notifications | Mark as read |
| POST | `/api/verification/residence-card` | verification | Submit ID verification |
---
## Related Documentation
- [Integration Patterns](./integration-patterns.md) - How integrations work
- [DB Mappers](./db-mappers.md) - Database mapping patterns
- [Validation](./validation.md) - Zod validation setup
- [Architecture Decisions](../../decisions/README.md) - Why these patterns exist

View File

@ -1,7 +1,30 @@
# Domain-First Structure with Providers
**Date**: October 3, 2025
**Status**: ✅ Implementing
**Last Updated**: January 2026
**Status**: ✅ Implemented
---
## Quick Reference
| Aspect | Location |
| -------------------- | ------------------------------------------------------------ |
| **Domain package** | `packages/domain/` |
| **Schemas** | `packages/domain/<module>/schema.ts` |
| **Provider mappers** | `packages/domain/<module>/providers/<provider>/mapper.ts` |
| **Raw types** | `packages/domain/<module>/providers/<provider>/raw.types.ts` |
**Import patterns**:
```typescript
// App/Portal code - domain types only
import { Invoice, invoiceSchema } from "@customer-portal/domain/billing";
// BFF integration code - includes provider mappers
import { transformWhmcsInvoice } from "@customer-portal/domain/billing/providers";
```
**Key principle**: Transform once in domain mappers, use domain types everywhere else.
---
@ -356,12 +379,34 @@ const invoice = transformStripeInvoice(stripeData);
---
## 📚 Related Documentation
## 📋 Domain Module Inventory
- [TYPE-CLEANUP-GUIDE.md](./TYPE-CLEANUP-GUIDE.md) - Migration guide
- [ARCHITECTURE.md](./ARCHITECTURE.md) - Overall system architecture
- [TYPE-CLEANUP-SUMMARY.md](./TYPE-CLEANUP-SUMMARY.md) - Implementation summary
| Module | Key Types | Key Schemas | Provider(s) | Purpose |
| --------------- | ------------------------------------ | ------------------------- | ----------------- | --------------------- |
| `auth` | `AuthenticatedUser`, `Session` | `authenticatedUserSchema` | — | Authentication types |
| `address` | `Address`, `JapanAddress` | `addressSchema` | japanpost | Address management |
| `billing` | `Invoice`, `InvoiceItem` | `invoiceSchema` | whmcs | Invoice management |
| `checkout` | `CheckoutSession`, `CartItem` | `checkoutSchema` | — | Checkout flow |
| `common` | `Money`, `ApiResponse` | `moneySchema` | — | Shared utilities |
| `customer` | `Customer`, `CustomerProfile` | `customerSchema` | salesforce | Customer data |
| `dashboard` | `DashboardStats` | `dashboardSchema` | — | Dashboard data |
| `get-started` | `GetStartedFlow`, `EligibilityCheck` | `getStartedSchema` | — | Signup flow |
| `notifications` | `Notification` | `notificationSchema` | — | Notifications |
| `opportunity` | `Opportunity` | `opportunitySchema` | salesforce | Opportunity lifecycle |
| `orders` | `Order`, `OrderDetails`, `OrderItem` | `orderSchema` | salesforce, whmcs | Order management |
| `payments` | `PaymentMethod`, `PaymentGateway` | `paymentMethodSchema` | whmcs | Payment methods |
| `services` | `CatalogProduct`, `ServiceCategory` | `catalogProductSchema` | salesforce | Product catalog |
| `sim` | `SimDetails`, `SimUsage` | `simDetailsSchema` | freebit | SIM management |
| `subscriptions` | `Subscription`, `SubscriptionStatus` | `subscriptionSchema` | whmcs, salesforce | Active services |
| `support` | `Case`, `CaseMessage` | `caseSchema` | salesforce | Support cases |
| `toolkit` | `Formatting`, `AsyncState` | — | — | Utilities |
---
**Status**: Implementation in progress. See TODO list for remaining work.
## 📚 Related Documentation
- [Import Hygiene](./import-hygiene.md) - Import rules and ESLint enforcement
- [Domain Packages](./packages.md) - Package internal structure
- [Domain Types](./types.md) - Unified type system
- [BFF Integration Patterns](../bff/integration-patterns.md) - How BFF uses domain mappers
- [Architecture Decisions](../../decisions/README.md) - Why these patterns exist

View File

@ -2,6 +2,26 @@
This document outlines the feature-driven architecture implemented for the customer portal.
---
## Quick Reference
| Aspect | Location |
| --------------------- | ----------------------------------------------------------- |
| **Feature modules** | `apps/portal/src/features/` |
| **Shared components** | `apps/portal/src/components/` (atomic design) |
| **API client** | `apps/portal/src/core/api/index.ts` |
| **Query keys** | `apps/portal/src/core/api/index.ts``queryKeys` |
| **Domain types** | `import type { X } from "@customer-portal/domain/<module>"` |
**Key patterns**:
- Pages are thin wrappers → import views from features
- Features contain: `api/`, `hooks/`, `stores/`, `components/`, `views/`
- Use React Query hooks from features (e.g., `useInvoices` from `@/features/billing`)
---
## Folder Structure
```
@ -23,10 +43,12 @@ apps/portal/src/
│ └── providers/ # React context providers (QueryProvider)
├── features/ # Feature-specific modules composed by routes
│ ├── account/
│ ├── address/ # Address management and Japan Post lookup
│ ├── auth/
│ ├── billing/
│ ├── checkout/
│ ├── dashboard/
│ ├── get-started/ # Unified signup and eligibility flow
│ ├── landing-page/
│ ├── marketing/
│ ├── notifications/
@ -42,8 +64,7 @@ apps/portal/src/
│ ├── hooks/ # Generic React hooks
│ └── utils/ # General utilities (cn, date, error-handling)
├── styles/ # Global styles and design tokens
├── config/ # Environment configuration
└── lib/ # Legacy (deprecated, re-exports from core/shared)
└── config/ # Environment configuration
```
## Design Principles
@ -180,20 +201,26 @@ This ensures pages remain declarative and the feature layer encapsulates logic.
Only `layout.tsx`, `page.tsx`, and `loading.tsx` files live inside the route groups.
## Current Feature API/Hooks
## Feature Module Inventory
| Feature | API Service | Hooks |
| ------------- | --------------------------------------------- | ------------------------------------------------------- |
| auth | (via store) | `useAuthStore`, `useAuthSession` |
| services | `servicesService` | `useInternetCatalog`, `useSimCatalog`, `useVpnCatalog` |
| billing | `billingService` | `useInvoices`, `usePaymentMethods`, `usePaymentRefresh` |
| orders | `ordersService` | `useOrdersList`, `useOrderDetail` |
| account | `accountService` | `useProfileEdit`, `useAddressEdit` |
| subscriptions | `simActionsService`, `internetActionsService` | `useSubscriptions` |
| notifications | `notificationService` | `useNotifications` |
| verification | `verificationService` | `useResidenceCardVerification` |
| checkout | `checkoutService` | `useCheckoutStore` |
| dashboard | `getMeStatus` | `useMeStatus` |
| Feature | API Service | Key Hooks | Purpose |
| --------------- | --------------------------------------------- | ------------------------------------------------------- | ------------------------------ |
| `account` | `accountService` | `useProfileEdit`, `useAddressEdit` | Profile and address management |
| `address` | `addressService` | `useAddressLookup` | Japan Post address lookup |
| `auth` | (via store) | `useAuthStore`, `useAuthSession` | Authentication state |
| `billing` | `billingService` | `useInvoices`, `usePaymentMethods`, `usePaymentRefresh` | Invoices and payments |
| `checkout` | `checkoutService` | `useCheckoutStore` | Checkout flow |
| `dashboard` | `getMeStatus` | `useMeStatus` | Dashboard data |
| `get-started` | `getStartedService` | `useGetStartedFlow` | Unified signup flow |
| `landing-page` | — | — | Marketing landing page |
| `marketing` | — | — | Marketing components |
| `notifications` | `notificationService` | `useNotifications` | User notifications |
| `orders` | `ordersService` | `useOrdersList`, `useOrderDetail` | Order management |
| `realtime` | — | `useRealtimeEvents` | SSE/WebSocket events |
| `services` | `servicesService` | `useInternetCatalog`, `useSimCatalog`, `useVpnCatalog` | Product catalog |
| `subscriptions` | `simActionsService`, `internetActionsService` | `useSubscriptions` | Active service management |
| `support` | `supportService` | `useCases`, `useCreateCase` | Support tickets |
| `verification` | `verificationService` | `useResidenceCardVerification` | ID verification |
## Benefits

View File

@ -4,13 +4,12 @@ _This document consolidates the complete ordering and provisioning specification
**Related Documents:**
- `ORDER-FULFILLMENT-COMPLETE-GUIDE.md` **Complete implementation guide with examples**
- `SALESFORCE-WHMCS-MAPPING-REFERENCE.md` **Comprehensive field mapping reference**
- `PORTAL-DATA-MODEL.md` Field mappings and data structures
- `PRODUCT-CATALOG-ARCHITECTURE.md` SKU architecture and catalog implementation
- `SALESFORCE-PRODUCTS.md` Complete product setup guide
> **📖 For complete implementation details, see `ORDER-FULFILLMENT-COMPLETE-GUIDE.md`**
- [Order Fulfillment](./order-fulfillment.md) Complete order fulfillment workflow
- [Salesforce Orders](../integrations/salesforce/orders.md) Salesforce order communication
- [Salesforce Products](../integrations/salesforce/products.md) Product catalog integration
- [WHMCS Mapping](../integrations/salesforce/whmcs-mapping.md) Salesforce-WHMCS data mapping
- [Product Catalog Architecture](../architecture/product-catalog.md) SKU architecture and catalog design
- [Modular Provisioning](../architecture/modular-provisioning.md) Provisioning service architecture
- Backend: NestJS BFF (`apps/bff`) with existing integrations: WHMCS, Salesforce
- Frontend: Next.js portal (`apps/portal`)

View File

@ -55,41 +55,6 @@ export const VALID_INVOICE_STATUSES = [
*/
export const VALID_INVOICE_LIST_STATUSES = VALID_INVOICE_STATUSES;
// ============================================================================
// Validation Helpers
// ============================================================================
/**
* Check if a status string is valid for invoices
*/
export function isValidInvoiceStatus(status: string): status is ValidInvoiceStatus {
return (VALID_INVOICE_STATUSES as readonly string[]).includes(status);
}
/**
* Check if pagination limit is within bounds
*/
export function isValidPaginationLimit(limit: number): boolean {
return limit >= INVOICE_PAGINATION.MIN_LIMIT && limit <= INVOICE_PAGINATION.MAX_LIMIT;
}
/**
* Sanitize pagination limit to be within bounds
*/
export function sanitizePaginationLimit(limit: number): number {
return Math.max(
INVOICE_PAGINATION.MIN_LIMIT,
Math.min(INVOICE_PAGINATION.MAX_LIMIT, Math.floor(limit))
);
}
/**
* Sanitize pagination page to be >= 1
*/
export function sanitizePaginationPage(page: number): number {
return Math.max(INVOICE_PAGINATION.DEFAULT_PAGE, Math.floor(page));
}
// ============================================================================
// Type Exports
// ============================================================================

View File

@ -69,45 +69,3 @@ export function isValidEmail(email: string): boolean {
export function isValidUuid(id: string): boolean {
return uuidSchema.safeParse(id).success;
}
/**
* URL validation schema
*/
export const urlSchema = z.string().url();
/**
* Validate a URL
*
* This is a convenience wrapper that throws on invalid input.
* For validation without throwing, use the urlSchema directly with .safeParse()
*
* @throws Error if URL format is invalid
*/
export function validateUrlOrThrow(url: string): string {
try {
return urlSchema.parse(url);
} catch {
throw new Error("Invalid URL format");
}
}
/**
* Validate a URL (non-throwing)
*
* Returns validation result with errors if any.
* Prefer using urlSchema.safeParse() directly for more control.
*/
export function validateUrl(url: string): { isValid: boolean; errors: string[] } {
const result = urlSchema.safeParse(url);
return {
isValid: result.success,
errors: result.success ? [] : result.error.issues.map(i => i.message),
};
}
/**
* Check if a string is a valid URL (non-throwing)
*/
export function isValidUrl(url: string): boolean {
return urlSchema.safeParse(url).success;
}

View File

@ -397,11 +397,6 @@ export interface SimCancellationOpportunityData {
cancellationNotice: SimCancellationNoticeValue;
}
/**
* @deprecated Use InternetCancellationOpportunityData or SimCancellationOpportunityData
*/
export type CancellationOpportunityData = InternetCancellationOpportunityData;
/**
* Data to populate on Cancellation Case
* This contains all customer-provided details

View File

@ -1,381 +0,0 @@
/**
* Opportunity Domain - Helpers
*
* Utility functions for cancellation date calculations and validation.
* Implements the "25th rule" and rental return deadline logic.
*/
import {
CANCELLATION_DEADLINE_DAY,
RENTAL_RETURN_DEADLINE_DAY,
CANCELLATION_NOTICE,
LINE_RETURN_STATUS,
type CancellationFormData,
type CancellationOpportunityData,
type CancellationEligibility,
type CancellationMonthOption,
} from "./contract.js";
// ============================================================================
// Date Utilities
// ============================================================================
/**
* Get the last day of a month
* @param year - Full year (e.g., 2025)
* @param month - Month (1-12)
* @returns Date string in YYYY-MM-DD format
*/
export function getLastDayOfMonth(year: number, month: number): string {
// Day 0 of next month = last day of current month
const lastDay = new Date(year, month, 0).getDate();
const monthStr = String(month).padStart(2, "0");
const dayStr = String(lastDay).padStart(2, "0");
return `${year}-${monthStr}-${dayStr}`;
}
/**
* Get the rental return deadline (10th of following month)
* @param year - Year of cancellation month
* @param month - Month of cancellation (1-12)
* @returns Date string in YYYY-MM-DD format
*/
export function getRentalReturnDeadline(year: number, month: number): string {
// Move to next month
let nextYear = year;
let nextMonth = month + 1;
if (nextMonth > 12) {
nextMonth = 1;
nextYear += 1;
}
const monthStr = String(nextMonth).padStart(2, "0");
const dayStr = String(RENTAL_RETURN_DEADLINE_DAY).padStart(2, "0");
return `${nextYear}-${monthStr}-${dayStr}`;
}
/**
* Parse YYYY-MM format to year and month
*/
export function parseYearMonth(value: string): { year: number; month: number } | null {
const match = value.match(/^(\d{4})-(0[1-9]|1[0-2])$/);
if (!match || !match[1] || !match[2]) return null;
return {
year: Number.parseInt(match[1], 10),
month: Number.parseInt(match[2], 10),
};
}
/**
* Format a date as YYYY-MM
*/
export function formatYearMonth(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
return `${year}-${month}`;
}
/**
* Format a month for display (e.g., "January 2025")
*/
export function formatMonthLabel(year: number, month: number): string {
const date = new Date(year, month - 1, 1);
return date.toLocaleDateString("en-US", { year: "numeric", month: "long" });
}
/**
* Format a date for display (e.g., "January 31, 2025")
*/
export function formatDateLabel(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}
// ============================================================================
// Cancellation Deadline Logic
// ============================================================================
/**
* Check if current date is before the cancellation deadline for a given month
*
* Rule: Cancellation form must be received by the 25th of the cancellation month
*
* @param cancellationYear - Year of cancellation
* @param cancellationMonth - Month of cancellation (1-12)
* @param today - Current date (for testing, defaults to now)
* @returns true if can still cancel for this month
*/
export function isBeforeCancellationDeadline(
cancellationYear: number,
cancellationMonth: number,
today: Date = new Date()
): boolean {
const deadlineDate = new Date(
cancellationYear,
cancellationMonth - 1,
CANCELLATION_DEADLINE_DAY,
23,
59,
59
);
return today <= deadlineDate;
}
/**
* Get the cancellation deadline date for a month
*
* @param year - Year
* @param month - Month (1-12)
* @returns Date string in YYYY-MM-DD format
*/
export function getCancellationDeadline(year: number, month: number): string {
const monthStr = String(month).padStart(2, "0");
const dayStr = String(CANCELLATION_DEADLINE_DAY).padStart(2, "0");
return `${year}-${monthStr}-${dayStr}`;
}
/**
* Calculate the earliest month that can be selected for cancellation
*
* If today is on or after the 25th, earliest is next month.
* Otherwise, earliest is current month.
*
* @param today - Current date (for testing, defaults to now)
* @returns YYYY-MM format string
*/
export function getEarliestCancellationMonth(today: Date = new Date()): string {
const year = today.getFullYear();
const month = today.getMonth() + 1; // 1-indexed
const day = today.getDate();
if (day > CANCELLATION_DEADLINE_DAY) {
// After 25th - earliest is next month
let nextMonth = month + 1;
let nextYear = year;
if (nextMonth > 12) {
nextMonth = 1;
nextYear += 1;
}
return `${nextYear}-${String(nextMonth).padStart(2, "0")}`;
} else {
// On or before 25th - can cancel this month
return `${year}-${String(month).padStart(2, "0")}`;
}
}
/**
* Generate available cancellation months
*
* Returns 12 months starting from the earliest available month.
*
* @param today - Current date (for testing)
* @returns Array of month options
*/
export function generateCancellationMonthOptions(
today: Date = new Date()
): CancellationMonthOption[] {
const earliestMonth = getEarliestCancellationMonth(today);
const parsed = parseYearMonth(earliestMonth);
if (!parsed) return [];
let { year, month } = parsed;
const currentYearMonth = formatYearMonth(today);
const options: CancellationMonthOption[] = [];
for (let i = 0; i < 12; i++) {
const value = `${year}-${String(month).padStart(2, "0")}`;
const serviceEndDate = getLastDayOfMonth(year, month);
const rentalReturnDeadline = getRentalReturnDeadline(year, month);
const isCurrentMonth = value === currentYearMonth;
options.push({
value,
label: formatMonthLabel(year, month),
serviceEndDate,
rentalReturnDeadline,
isCurrentMonth,
});
// Move to next month
month += 1;
if (month > 12) {
month = 1;
year += 1;
}
}
return options;
}
/**
* Get full cancellation eligibility information
*
* @param today - Current date (for testing)
* @returns Cancellation eligibility details
*/
export function getCancellationEligibility(today: Date = new Date()): CancellationEligibility {
const availableMonths = generateCancellationMonthOptions(today);
const earliestMonth = getEarliestCancellationMonth(today);
const day = today.getDate();
// Check if current month is still available
const canCancelThisMonth = day <= CANCELLATION_DEADLINE_DAY;
const currentMonthDeadline = canCancelThisMonth
? getCancellationDeadline(today.getFullYear(), today.getMonth() + 1)
: null;
return {
canCancel: true,
earliestCancellationMonth: earliestMonth,
availableMonths,
currentMonthDeadline,
};
}
/**
* Validate that a selected cancellation month is allowed
*
* @param selectedMonth - YYYY-MM format
* @param today - Current date (for testing)
* @returns Validation result
*/
export function validateCancellationMonth(
selectedMonth: string,
today: Date = new Date()
): { valid: boolean; error?: string } {
const parsed = parseYearMonth(selectedMonth);
if (!parsed) {
return { valid: false, error: "Invalid month format" };
}
const earliestMonth = getEarliestCancellationMonth(today);
const earliestParsed = parseYearMonth(earliestMonth);
if (!earliestParsed) {
return { valid: false, error: "Unable to determine earliest month" };
}
// Compare dates
const selectedDate = new Date(parsed.year, parsed.month - 1, 1);
const earliestDate = new Date(earliestParsed.year, earliestParsed.month - 1, 1);
if (selectedDate < earliestDate) {
const deadline = CANCELLATION_DEADLINE_DAY;
return {
valid: false,
error: `Cancellation requests for this month must be submitted by the ${deadline}th. The earliest available month is ${formatMonthLabel(earliestParsed.year, earliestParsed.month)}.`,
};
}
return { valid: true };
}
// ============================================================================
// Data Transformation
// ============================================================================
/**
* Transform cancellation form data to Opportunity update data
*
* @param formData - Customer form submission
* @returns Data to update on Opportunity
*/
export function transformCancellationFormToOpportunityData(
formData: CancellationFormData
): CancellationOpportunityData {
const parsed = parseYearMonth(formData.cancellationMonth);
if (!parsed) {
throw new Error("Invalid cancellation month format");
}
const scheduledCancellationDate = getLastDayOfMonth(parsed.year, parsed.month);
// NOTE: alternativeEmail and comments go to the Cancellation Case, not to Opportunity
return {
scheduledCancellationDate,
cancellationNotice: CANCELLATION_NOTICE.RECEIVED,
lineReturnStatus: LINE_RETURN_STATUS.NOT_YET,
};
}
/**
* Calculate rental return deadline from scheduled cancellation date
*
* @param scheduledCancellationDate - End of cancellation month (YYYY-MM-DD)
* @returns Rental return deadline (10th of following month)
*/
export function calculateRentalReturnDeadline(scheduledCancellationDate: string): string {
const date = new Date(scheduledCancellationDate);
const year = date.getFullYear();
const month = date.getMonth() + 1; // 1-indexed
return getRentalReturnDeadline(year, month);
}
// ============================================================================
// Display Helpers
// ============================================================================
/**
* Get human-readable status for line return
*/
export function getLineReturnStatusLabel(status: string | undefined): {
label: string;
description: string;
} {
switch (status) {
case LINE_RETURN_STATUS.NOT_YET:
return {
label: "Return Pending",
description: "A return kit will be sent to you",
};
case LINE_RETURN_STATUS.SENT_KIT:
return {
label: "Return Kit Sent",
description: "Please return equipment using the provided kit",
};
case LINE_RETURN_STATUS.PICKUP_SCHEDULED:
return {
label: "Pickup Scheduled",
description: "Equipment pickup has been scheduled",
};
case LINE_RETURN_STATUS.RETURNED:
case "Returned1":
return {
label: "Returned",
description: "Equipment has been returned successfully",
};
case LINE_RETURN_STATUS.NTT_DISPATCH:
return {
label: "NTT Dispatch",
description: "NTT will handle equipment return",
};
case LINE_RETURN_STATUS.COMPENSATED:
return {
label: "Compensated",
description: "Compensation fee has been charged for unreturned equipment",
};
case LINE_RETURN_STATUS.NA:
return {
label: "Not Applicable",
description: "No rental equipment to return",
};
default:
return {
label: "Unknown",
description: "",
};
}
}
/**
* Check if rental equipment is applicable for a product type
*/
export function hasRentalEquipment(productType: string): boolean {
// Internet typically has rental equipment (router, modem)
// SIM and VPN do not
return productType === "Internet";
}

View File

@ -68,23 +68,12 @@ export {
} from "./contract.js";
// ============================================================================
// Contract Types (business types, not validated)
// Contract Types (business types from contract.ts)
// ============================================================================
export type {
OpportunityRecord as OpportunityRecordContract,
CreateOpportunityRequest as CreateOpportunityRequestContract,
UpdateOpportunityStageRequest as UpdateOpportunityStageRequestContract,
CancellationFormData as CancellationFormDataContract,
CancellationOpportunityData as CancellationOpportunityDataContract,
InternetCancellationOpportunityData,
SimCancellationOpportunityData,
CancellationCaseData as CancellationCaseDataContract,
CancellationEligibility as CancellationEligibilityContract,
CancellationMonthOption as CancellationMonthOptionContract,
CancellationStatus as CancellationStatusContract,
OpportunityMatchResult as OpportunityMatchResultContract,
OpportunityLookupCriteria as OpportunityLookupCriteriaContract,
} from "./contract.js";
// ============================================================================
@ -118,30 +107,3 @@ export {
type OpportunityLookupCriteria,
type OpportunityMatchResult,
} from "./schema.js";
// ============================================================================
// Helpers
// ============================================================================
export {
// Date utilities
getLastDayOfMonth,
getRentalReturnDeadline,
parseYearMonth,
formatYearMonth,
formatMonthLabel,
formatDateLabel,
// Cancellation deadline logic
isBeforeCancellationDeadline,
getCancellationDeadline,
getEarliestCancellationMonth,
generateCancellationMonthOptions,
getCancellationEligibility,
validateCancellationMonth,
// Data transformation
transformCancellationFormToOpportunityData,
calculateRentalReturnDeadline,
// Display helpers
getLineReturnStatusLabel,
hasRentalEquipment,
} from "./helpers.js";

View File

@ -1,64 +0,0 @@
/**
* Toolkit - Type Assertions
*
* Runtime assertion utilities for type safety.
*/
export class AssertionError extends Error {
constructor(message: string) {
super(message);
this.name = "AssertionError";
}
}
/**
* Assert that a value is truthy
*/
export function assert(condition: unknown, message = "Assertion failed"): asserts condition {
if (!condition) {
throw new AssertionError(message);
}
}
/**
* Assert that a value is defined (not null or undefined)
*/
export function assertDefined<T>(
value: T | null | undefined,
message = "Value must be defined"
): asserts value is T {
if (value === null || value === undefined) {
throw new AssertionError(message);
}
}
/**
* Assert that a value is a string
*/
export function assertString(
value: unknown,
message = "Value must be a string"
): asserts value is string {
if (typeof value !== "string") {
throw new AssertionError(message);
}
}
/**
* Assert that a value is a number
*/
export function assertNumber(
value: unknown,
message = "Value must be a number"
): asserts value is number {
if (typeof value !== "number" || Number.isNaN(value)) {
throw new AssertionError(message);
}
}
/**
* Assert that a value is never reached (exhaustiveness check)
*/
export function assertNever(value: never, message = "Unexpected value"): never {
throw new AssertionError(`${message}: ${JSON.stringify(value)}`);
}

View File

@ -26,16 +26,6 @@ export type KeysOfType<T, U> = {
[K in keyof T]: T[K] extends U ? K : never;
}[keyof T];
/**
* Ensure all keys of a type are present
*/
export function ensureKeys<T extends Record<string, unknown>>(
obj: Partial<T>,
keys: (keyof T)[]
): obj is T {
return keys.every(key => key in obj);
}
/**
* Pick properties by value type
*/
@ -45,96 +35,3 @@ export type PickByValue<T, V> = Pick<
[K in keyof T]: T[K] extends V ? K : never;
}[keyof T]
>;
/**
* Safely access nested property
*/
export function getNestedProperty<T>(obj: unknown, path: string, defaultValue?: T): T | undefined {
const keys = path.split(".");
let current: unknown = obj;
const isIndexableObject = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
for (const key of keys) {
if (!isIndexableObject(current)) {
return defaultValue;
}
current = current[key];
}
if (current === undefined || current === null) {
return defaultValue;
}
return current as T;
}
// ============================================================================
// Async State Management
// ============================================================================
/**
* Generic async state type for handling loading/success/error states
*/
export type AsyncState<T, E = Error> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: E };
/**
* Create an idle state
*/
export function createIdleState<T, E = Error>(): AsyncState<T, E> {
return { status: "idle" };
}
/**
* Create a loading state
*/
export function createLoadingState<T, E = Error>(): AsyncState<T, E> {
return { status: "loading" };
}
/**
* Create a success state with data
*/
export function createSuccessState<T, E = Error>(data: T): AsyncState<T, E> {
return { status: "success", data };
}
/**
* Create an error state
*/
export function createErrorState<T, E = Error>(error: E): AsyncState<T, E> {
return { status: "error", error };
}
/**
* Type guard: check if state is idle
*/
export function isIdle<T, E>(state: AsyncState<T, E>): state is { status: "idle" } {
return state.status === "idle";
}
/**
* Type guard: check if state is loading
*/
export function isLoading<T, E>(state: AsyncState<T, E>): state is { status: "loading" } {
return state.status === "loading";
}
/**
* Type guard: check if state is success
*/
export function isSuccess<T, E>(state: AsyncState<T, E>): state is { status: "success"; data: T } {
return state.status === "success";
}
/**
* Type guard: check if state is error
*/
export function isError<T, E>(state: AsyncState<T, E>): state is { status: "error"; error: E } {
return state.status === "error";
}

View File

@ -5,5 +5,4 @@
*/
export * from "./guards.js";
export * from "./assertions.js";
export * from "./helpers.js";

View File

@ -6,151 +6,10 @@
import { z } from "zod";
// ============================================================================
// ID Validation
// ============================================================================
/**
* Validate that an ID is a positive integer
*/
export function isValidPositiveId(id: number): boolean {
return Number.isInteger(id) && id > 0;
}
/**
* Validate Salesforce ID (15 or 18 characters, alphanumeric)
*/
export function isValidSalesforceId(id: string): boolean {
return /^[A-Za-z0-9]{15,18}$/.test(id);
}
// ============================================================================
// Pagination Validation
// ============================================================================
/**
* Validate and sanitize pagination parameters
*/
export function sanitizePagination(options: {
page?: number;
limit?: number;
minLimit?: number;
maxLimit?: number;
defaultLimit?: number;
}): {
page: number;
limit: number;
} {
const { page = 1, limit = options.defaultLimit ?? 10, minLimit = 1, maxLimit = 100 } = options;
return {
page: Math.max(1, Math.floor(page)),
limit: Math.max(minLimit, Math.min(maxLimit, Math.floor(limit))),
};
}
/**
* Check if pagination offset is valid
*/
export function isValidPaginationOffset(offset: number): boolean {
return Number.isInteger(offset) && offset >= 0;
}
// ============================================================================
// String Validation
// ============================================================================
/**
* Check if string is non-empty after trimming
*/
export function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
/**
* Check if value is a valid enum member
*/
export function isValidEnumValue<T extends Record<string, string | number>>(
value: unknown,
enumObj: T
): value is T[keyof T] {
return Object.values(enumObj).includes(value as string | number);
}
// ============================================================================
// Array Validation
// ============================================================================
/**
* Check if array is non-empty
*/
export function isNonEmptyArray<T>(value: unknown): value is T[] {
return Array.isArray(value) && value.length > 0;
}
/**
* Check if all array items are unique
*/
export function hasUniqueItems<T>(items: T[]): boolean {
return new Set(items).size === items.length;
}
// ============================================================================
// Number Validation
// ============================================================================
/**
* Check if number is within range (inclusive)
*/
export function isInRange(value: number, min: number, max: number): boolean {
return value >= min && value <= max;
}
/**
* Check if number is a valid positive integer
*/
export function isPositiveInteger(value: unknown): value is number {
return typeof value === "number" && Number.isInteger(value) && value > 0;
}
/**
* Check if number is a valid non-negative integer
*/
export function isNonNegativeInteger(value: unknown): value is number {
return typeof value === "number" && Number.isInteger(value) && value >= 0;
}
// ============================================================================
// Date Validation
// ============================================================================
/**
* Check if string is a valid ISO date time
*/
export function isValidIsoDateTime(value: string): boolean {
const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/;
if (!isoRegex.test(value)) return false;
const date = new Date(value);
return !Number.isNaN(date.getTime());
}
/**
* Check if string is a valid date in YYYYMMDD format
*/
export function isValidYYYYMMDD(value: string): boolean {
return /^\d{8}$/.test(value);
}
// ============================================================================
// Zod Schema Helpers
// ============================================================================
/**
* Create a schema for a positive integer ID
*/
export const positiveIdSchema = z.number().int().positive();
/**
* Create a schema for pagination parameters
*/
@ -168,14 +27,6 @@ export function createPaginationSchema(options?: {
});
}
/**
* Create a schema for sortable queries
*/
export const sortableQuerySchema = z.object({
sortBy: z.string().optional(),
sortOrder: z.enum(["asc", "desc"]).optional(),
});
// ============================================================================
// Type Guards
// ============================================================================