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",
|
"rxjs": "^7.8.2",
|
||||||
"salesforce-pubsub-api-client": "^5.5.1",
|
"salesforce-pubsub-api-client": "^5.5.1",
|
||||||
"ssh2-sftp-client": "^12.0.1",
|
"ssh2-sftp-client": "^12.0.1",
|
||||||
"swagger-ui-express": "^5.0.1",
|
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -70,11 +70,6 @@ export function extractErrorMessage(error: unknown): string {
|
|||||||
return "An unknown error occurred";
|
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
|
* PRODUCTION-SAFE: Get error message safe for client consumption
|
||||||
* Removes sensitive information and stack traces
|
* Removes sensitive information and stack traces
|
||||||
|
|||||||
@ -205,11 +205,6 @@ export const OPPORTUNITY_SIM_CANCELLATION_QUERY_FIELDS = [
|
|||||||
OPPORTUNITY_FIELD_MAP.whmcsServiceId,
|
OPPORTUNITY_FIELD_MAP.whmcsServiceId,
|
||||||
] as const;
|
] 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
|
// Opportunity Picklist References
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@ -16,7 +16,6 @@ export {
|
|||||||
OPPORTUNITY_DETAIL_QUERY_FIELDS,
|
OPPORTUNITY_DETAIL_QUERY_FIELDS,
|
||||||
OPPORTUNITY_INTERNET_CANCELLATION_QUERY_FIELDS,
|
OPPORTUNITY_INTERNET_CANCELLATION_QUERY_FIELDS,
|
||||||
OPPORTUNITY_SIM_CANCELLATION_QUERY_FIELDS,
|
OPPORTUNITY_SIM_CANCELLATION_QUERY_FIELDS,
|
||||||
OPPORTUNITY_CANCELLATION_QUERY_FIELDS,
|
|
||||||
OPPORTUNITY_STAGE_REFERENCE,
|
OPPORTUNITY_STAGE_REFERENCE,
|
||||||
APPLICATION_STAGE_REFERENCE,
|
APPLICATION_STAGE_REFERENCE,
|
||||||
CANCELLATION_NOTICE_REFERENCE,
|
CANCELLATION_NOTICE_REFERENCE,
|
||||||
|
|||||||
@ -10,10 +10,7 @@ import { Logger } from "nestjs-pino";
|
|||||||
import { SalesforceConnection } from "../salesforce-connection.service.js";
|
import { SalesforceConnection } from "../salesforce-connection.service.js";
|
||||||
import { assertSalesforceId } from "../../utils/soql.util.js";
|
import { assertSalesforceId } from "../../utils/soql.util.js";
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
import {
|
import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity";
|
||||||
type CancellationOpportunityData,
|
|
||||||
OPPORTUNITY_STAGE,
|
|
||||||
} from "@customer-portal/domain/opportunity";
|
|
||||||
import { OPPORTUNITY_FIELD_MAP } from "../../constants/index.js";
|
import { OPPORTUNITY_FIELD_MAP } from "../../constants/index.js";
|
||||||
import {
|
import {
|
||||||
type InternetCancellationOpportunityDataInput,
|
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
|
* Mark cancellation as complete by updating stage to Cancelled
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -20,7 +20,6 @@ import { Injectable } from "@nestjs/common";
|
|||||||
import {
|
import {
|
||||||
type OpportunityStageValue,
|
type OpportunityStageValue,
|
||||||
type OpportunityProductTypeValue,
|
type OpportunityProductTypeValue,
|
||||||
type CancellationOpportunityData,
|
|
||||||
type CreateOpportunityRequest,
|
type CreateOpportunityRequest,
|
||||||
type OpportunityRecord,
|
type OpportunityRecord,
|
||||||
} from "@customer-portal/domain/opportunity";
|
} from "@customer-portal/domain/opportunity";
|
||||||
@ -94,16 +93,6 @@ export class SalesforceOpportunityService {
|
|||||||
return this.cancellationService.updateSimCancellationData(opportunityId, data);
|
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
|
* Mark cancellation as complete
|
||||||
*/
|
*/
|
||||||
@ -183,15 +172,6 @@ export class SalesforceOpportunityService {
|
|||||||
return this.queryService.getSimCancellationStatusByOpportunityId(opportunityId);
|
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)
|
// 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
|
* 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({
|
export function SearchEmptyState({
|
||||||
searchTerm,
|
searchTerm,
|
||||||
onClearSearch,
|
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>
|
</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 { Spinner } from "./spinner";
|
||||||
export { LoadingOverlay } from "./loading-overlay";
|
export { LoadingOverlay } from "./loading-overlay";
|
||||||
|
|
||||||
export {
|
export { ErrorState } from "./error-state";
|
||||||
ErrorState,
|
|
||||||
NetworkErrorState,
|
|
||||||
NotFoundErrorState,
|
|
||||||
PermissionErrorState,
|
|
||||||
} from "./error-state";
|
|
||||||
|
|
||||||
export { EmptyState, NoDataEmptyState, SearchEmptyState, FilterEmptyState } from "./empty-state";
|
export { EmptyState, SearchEmptyState } from "./empty-state";
|
||||||
|
|
||||||
// Additional UI Components
|
// Additional UI Components
|
||||||
export { InlineToast } from "./inline-toast";
|
export { InlineToast } from "./inline-toast";
|
||||||
|
|||||||
@ -32,13 +32,6 @@ export const servicesService = {
|
|||||||
return getDataOrThrow(response, "Failed to load internet services");
|
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> {
|
async getPublicSimCatalog(): Promise<SimCatalogCollection> {
|
||||||
const response = await apiClient.GET<SimCatalogCollection>("/api/public/services/sim/plans");
|
const response = await apiClient.GET<SimCatalogCollection>("/api/public/services/sim/plans");
|
||||||
return getDataOrDefault(response, EMPTY_SIM_CATALOG);
|
return getDataOrDefault(response, EMPTY_SIM_CATALOG);
|
||||||
@ -86,13 +79,6 @@ export const servicesService = {
|
|||||||
return internetAddonCatalogItemSchema.array().parse(data);
|
return internetAddonCatalogItemSchema.array().parse(data);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use getPublicSimCatalog() or getAccountSimCatalog() for clear separation.
|
|
||||||
*/
|
|
||||||
async getSimCatalog(): Promise<SimCatalogCollection> {
|
|
||||||
return this.getPublicSimCatalog();
|
|
||||||
},
|
|
||||||
|
|
||||||
async getSimActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
|
async getSimActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
|
||||||
const response = await apiClient.GET<SimActivationFeeCatalogItem[]>(
|
const response = await apiClient.GET<SimActivationFeeCatalogItem[]>(
|
||||||
"/api/services/sim/activation-fees"
|
"/api/services/sim/activation-fees"
|
||||||
@ -107,13 +93,6 @@ export const servicesService = {
|
|||||||
return simCatalogProductSchema.array().parse(data);
|
return simCatalogProductSchema.array().parse(data);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use getPublicVpnCatalog() or getAccountVpnCatalog() for clear separation.
|
|
||||||
*/
|
|
||||||
async getVpnCatalog(): Promise<VpnCatalogCollection> {
|
|
||||||
return this.getPublicVpnCatalog();
|
|
||||||
},
|
|
||||||
|
|
||||||
async getVpnActivationFees(): Promise<VpnCatalogProduct[]> {
|
async getVpnActivationFees(): Promise<VpnCatalogProduct[]> {
|
||||||
const response = await apiClient.GET<VpnCatalogProduct[]>("/api/services/vpn/activation-fees");
|
const response = await apiClient.GET<VpnCatalogProduct[]>("/api/services/vpn/activation-fees");
|
||||||
const data = getDataOrDefault(response, []);
|
const data = getDataOrDefault(response, []);
|
||||||
|
|||||||
@ -7,6 +7,5 @@ export {
|
|||||||
export {
|
export {
|
||||||
JAPAN_PREFECTURES,
|
JAPAN_PREFECTURES,
|
||||||
formatJapanesePostalCode,
|
formatJapanesePostalCode,
|
||||||
isValidJapanesePostalCode,
|
|
||||||
type PrefectureOption,
|
type PrefectureOption,
|
||||||
} from "./japan-prefectures";
|
} from "./japan-prefectures";
|
||||||
|
|||||||
@ -79,10 +79,3 @@ export function formatJapanesePostalCode(value: string): string {
|
|||||||
|
|
||||||
return `${digits.slice(0, 3)}-${digits.slice(3, 7)}`;
|
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;
|
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
|
// Re-exports from domain package for convenience
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -14,8 +14,6 @@ export {
|
|||||||
parseError,
|
parseError,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
shouldLogout,
|
shouldLogout,
|
||||||
canRetry,
|
|
||||||
getErrorCode,
|
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
ErrorMessages,
|
ErrorMessages,
|
||||||
type ParsedError,
|
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
|
## 🗂️ Archive
|
||||||
|
|
||||||
Historical documents kept for reference:
|
Historical documents kept for reference:
|
||||||
|
|||||||
@ -19,13 +19,21 @@ Configuration
|
|||||||
|
|
||||||
Portal (Next.js)
|
Portal (Next.js)
|
||||||
|
|
||||||
- src/lib/ contains cross-feature utilities:
|
- `src/core/` contains app-wide infrastructure:
|
||||||
- api.ts: centralized HTTP client (typed, safe JSON parsing)
|
- `api/`: centralized HTTP client, query keys, response helpers
|
||||||
- env.ts: zod-validated runtime environment
|
- `logger/`: application logging utilities
|
||||||
- auth/: Zustand store and helpers
|
- `providers/`: React context providers (QueryProvider)
|
||||||
- Hooks live under src/hooks/ and should consume the centralized client
|
- `src/shared/` contains cross-feature utilities:
|
||||||
- Feature barrels are available under src/features/{billing,subscriptions,dashboard}/hooks for stable imports
|
- `hooks/`: generic React hooks (useLocalStorage, useDebounce, useMediaQuery)
|
||||||
- Page components keep UI and delegate data-fetching to hooks
|
- `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)
|
BFF (NestJS)
|
||||||
|
|
||||||
@ -265,6 +273,5 @@ Documentation is organized in subdirectories:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Last Updated**: $(date)
|
**Last Updated**: January 2026
|
||||||
**Status**: ✅ Clean and Organized
|
**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).
|
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
|
## Folder Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
@ -172,3 +187,58 @@ import axios from "axios"; // Use integrations instead
|
|||||||
3. **Integrations transform**: Fetch external data, map to domain types
|
3. **Integrations transform**: Fetch external data, map to domain types
|
||||||
4. **Infra abstracts**: Provide clean interfaces over infrastructure
|
4. **Infra abstracts**: Provide clean interfaces over infrastructure
|
||||||
5. **Core configures**: Set up the NestJS framework
|
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
|
# Domain-First Structure with Providers
|
||||||
|
|
||||||
**Date**: October 3, 2025
|
**Last Updated**: January 2026
|
||||||
**Status**: ✅ Implementing
|
**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
|
| Module | Key Types | Key Schemas | Provider(s) | Purpose |
|
||||||
- [ARCHITECTURE.md](./ARCHITECTURE.md) - Overall system architecture
|
| --------------- | ------------------------------------ | ------------------------- | ----------------- | --------------------- |
|
||||||
- [TYPE-CLEANUP-SUMMARY.md](./TYPE-CLEANUP-SUMMARY.md) - Implementation summary
|
| `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.
|
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
|
## Folder Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
@ -23,10 +43,12 @@ apps/portal/src/
|
|||||||
│ └── providers/ # React context providers (QueryProvider)
|
│ └── providers/ # React context providers (QueryProvider)
|
||||||
├── features/ # Feature-specific modules composed by routes
|
├── features/ # Feature-specific modules composed by routes
|
||||||
│ ├── account/
|
│ ├── account/
|
||||||
|
│ ├── address/ # Address management and Japan Post lookup
|
||||||
│ ├── auth/
|
│ ├── auth/
|
||||||
│ ├── billing/
|
│ ├── billing/
|
||||||
│ ├── checkout/
|
│ ├── checkout/
|
||||||
│ ├── dashboard/
|
│ ├── dashboard/
|
||||||
|
│ ├── get-started/ # Unified signup and eligibility flow
|
||||||
│ ├── landing-page/
|
│ ├── landing-page/
|
||||||
│ ├── marketing/
|
│ ├── marketing/
|
||||||
│ ├── notifications/
|
│ ├── notifications/
|
||||||
@ -42,8 +64,7 @@ apps/portal/src/
|
|||||||
│ ├── hooks/ # Generic React hooks
|
│ ├── hooks/ # Generic React hooks
|
||||||
│ └── utils/ # General utilities (cn, date, error-handling)
|
│ └── utils/ # General utilities (cn, date, error-handling)
|
||||||
├── styles/ # Global styles and design tokens
|
├── styles/ # Global styles and design tokens
|
||||||
├── config/ # Environment configuration
|
└── config/ # Environment configuration
|
||||||
└── lib/ # Legacy (deprecated, re-exports from core/shared)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Design Principles
|
## 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.
|
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 |
|
| Feature | API Service | Key Hooks | Purpose |
|
||||||
| ------------- | --------------------------------------------- | ------------------------------------------------------- |
|
| --------------- | --------------------------------------------- | ------------------------------------------------------- | ------------------------------ |
|
||||||
| auth | (via store) | `useAuthStore`, `useAuthSession` |
|
| `account` | `accountService` | `useProfileEdit`, `useAddressEdit` | Profile and address management |
|
||||||
| services | `servicesService` | `useInternetCatalog`, `useSimCatalog`, `useVpnCatalog` |
|
| `address` | `addressService` | `useAddressLookup` | Japan Post address lookup |
|
||||||
| billing | `billingService` | `useInvoices`, `usePaymentMethods`, `usePaymentRefresh` |
|
| `auth` | (via store) | `useAuthStore`, `useAuthSession` | Authentication state |
|
||||||
| orders | `ordersService` | `useOrdersList`, `useOrderDetail` |
|
| `billing` | `billingService` | `useInvoices`, `usePaymentMethods`, `usePaymentRefresh` | Invoices and payments |
|
||||||
| account | `accountService` | `useProfileEdit`, `useAddressEdit` |
|
| `checkout` | `checkoutService` | `useCheckoutStore` | Checkout flow |
|
||||||
| subscriptions | `simActionsService`, `internetActionsService` | `useSubscriptions` |
|
| `dashboard` | `getMeStatus` | `useMeStatus` | Dashboard data |
|
||||||
| notifications | `notificationService` | `useNotifications` |
|
| `get-started` | `getStartedService` | `useGetStartedFlow` | Unified signup flow |
|
||||||
| verification | `verificationService` | `useResidenceCardVerification` |
|
| `landing-page` | — | — | Marketing landing page |
|
||||||
| checkout | `checkoutService` | `useCheckoutStore` |
|
| `marketing` | — | — | Marketing components |
|
||||||
| dashboard | `getMeStatus` | `useMeStatus` |
|
| `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
|
## Benefits
|
||||||
|
|
||||||
|
|||||||
@ -4,13 +4,12 @@ _This document consolidates the complete ordering and provisioning specification
|
|||||||
|
|
||||||
**Related Documents:**
|
**Related Documents:**
|
||||||
|
|
||||||
- `ORDER-FULFILLMENT-COMPLETE-GUIDE.md` – **Complete implementation guide with examples**
|
- [Order Fulfillment](./order-fulfillment.md) – Complete order fulfillment workflow
|
||||||
- `SALESFORCE-WHMCS-MAPPING-REFERENCE.md` – **Comprehensive field mapping reference**
|
- [Salesforce Orders](../integrations/salesforce/orders.md) – Salesforce order communication
|
||||||
- `PORTAL-DATA-MODEL.md` – Field mappings and data structures
|
- [Salesforce Products](../integrations/salesforce/products.md) – Product catalog integration
|
||||||
- `PRODUCT-CATALOG-ARCHITECTURE.md` – SKU architecture and catalog implementation
|
- [WHMCS Mapping](../integrations/salesforce/whmcs-mapping.md) – Salesforce-WHMCS data mapping
|
||||||
- `SALESFORCE-PRODUCTS.md` – Complete product setup guide
|
- [Product Catalog Architecture](../architecture/product-catalog.md) – SKU architecture and catalog design
|
||||||
|
- [Modular Provisioning](../architecture/modular-provisioning.md) – Provisioning service architecture
|
||||||
> **📖 For complete implementation details, see `ORDER-FULFILLMENT-COMPLETE-GUIDE.md`**
|
|
||||||
|
|
||||||
- Backend: NestJS BFF (`apps/bff`) with existing integrations: WHMCS, Salesforce
|
- Backend: NestJS BFF (`apps/bff`) with existing integrations: WHMCS, Salesforce
|
||||||
- Frontend: Next.js portal (`apps/portal`)
|
- Frontend: Next.js portal (`apps/portal`)
|
||||||
|
|||||||
@ -55,41 +55,6 @@ export const VALID_INVOICE_STATUSES = [
|
|||||||
*/
|
*/
|
||||||
export const VALID_INVOICE_LIST_STATUSES = 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
|
// Type Exports
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -69,45 +69,3 @@ export function isValidEmail(email: string): boolean {
|
|||||||
export function isValidUuid(id: string): boolean {
|
export function isValidUuid(id: string): boolean {
|
||||||
return uuidSchema.safeParse(id).success;
|
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;
|
cancellationNotice: SimCancellationNoticeValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use InternetCancellationOpportunityData or SimCancellationOpportunityData
|
|
||||||
*/
|
|
||||||
export type CancellationOpportunityData = InternetCancellationOpportunityData;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Data to populate on Cancellation Case
|
* Data to populate on Cancellation Case
|
||||||
* This contains all customer-provided details
|
* 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";
|
} from "./contract.js";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Contract Types (business types, not validated)
|
// Contract Types (business types from contract.ts)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
OpportunityRecord as OpportunityRecordContract,
|
|
||||||
CreateOpportunityRequest as CreateOpportunityRequestContract,
|
|
||||||
UpdateOpportunityStageRequest as UpdateOpportunityStageRequestContract,
|
|
||||||
CancellationFormData as CancellationFormDataContract,
|
|
||||||
CancellationOpportunityData as CancellationOpportunityDataContract,
|
|
||||||
InternetCancellationOpportunityData,
|
InternetCancellationOpportunityData,
|
||||||
SimCancellationOpportunityData,
|
SimCancellationOpportunityData,
|
||||||
CancellationCaseData as CancellationCaseDataContract,
|
|
||||||
CancellationEligibility as CancellationEligibilityContract,
|
|
||||||
CancellationMonthOption as CancellationMonthOptionContract,
|
|
||||||
CancellationStatus as CancellationStatusContract,
|
|
||||||
OpportunityMatchResult as OpportunityMatchResultContract,
|
|
||||||
OpportunityLookupCriteria as OpportunityLookupCriteriaContract,
|
|
||||||
} from "./contract.js";
|
} from "./contract.js";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -118,30 +107,3 @@ export {
|
|||||||
type OpportunityLookupCriteria,
|
type OpportunityLookupCriteria,
|
||||||
type OpportunityMatchResult,
|
type OpportunityMatchResult,
|
||||||
} from "./schema.js";
|
} 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;
|
[K in keyof T]: T[K] extends U ? K : never;
|
||||||
}[keyof T];
|
}[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
|
* 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;
|
[K in keyof T]: T[K] extends V ? K : never;
|
||||||
}[keyof T]
|
}[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 "./guards.js";
|
||||||
export * from "./assertions.js";
|
|
||||||
export * from "./helpers.js";
|
export * from "./helpers.js";
|
||||||
|
|||||||
@ -6,151 +6,10 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
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
|
// Zod Schema Helpers
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a schema for a positive integer ID
|
|
||||||
*/
|
|
||||||
export const positiveIdSchema = z.number().int().positive();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a schema for pagination parameters
|
* 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
|
// Type Guards
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user