chore: update repository structure documentation for clarity and organization
This commit is contained in:
parent
aa77f23d85
commit
37ef51cc82
@ -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": {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
// =============================================================================
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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)
|
||||
// ==========================================================================
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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, []);
|
||||
|
||||
@ -7,6 +7,5 @@ export {
|
||||
export {
|
||||
JAPAN_PREFECTURES,
|
||||
formatJapanesePostalCode,
|
||||
isValidJapanesePostalCode,
|
||||
type PrefectureOption,
|
||||
} from "./japan-prefectures";
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
// ============================================================================
|
||||
|
||||
@ -14,8 +14,6 @@ export {
|
||||
parseError,
|
||||
getErrorMessage,
|
||||
shouldLogout,
|
||||
canRetry,
|
||||
getErrorCode,
|
||||
ErrorCode,
|
||||
ErrorMessages,
|
||||
type ParsedError,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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`)
|
||||
|
||||
@ -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
|
||||
// ============================================================================
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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";
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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)}`);
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
|
||||
@ -5,5 +5,4 @@
|
||||
*/
|
||||
|
||||
export * from "./guards.js";
|
||||
export * from "./assertions.js";
|
||||
export * from "./helpers.js";
|
||||
|
||||
@ -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
|
||||
// ============================================================================
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user