diff --git a/apps/bff/package.json b/apps/bff/package.json index 01bc3c09..d6fb81cc 100644 --- a/apps/bff/package.json +++ b/apps/bff/package.json @@ -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": { diff --git a/apps/bff/src/core/utils/error.util.ts b/apps/bff/src/core/utils/error.util.ts index 5fe4b941..ce5c73a9 100644 --- a/apps/bff/src/core/utils/error.util.ts +++ b/apps/bff/src/core/utils/error.util.ts @@ -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 diff --git a/apps/bff/src/integrations/salesforce/constants/field-maps.ts b/apps/bff/src/integrations/salesforce/constants/field-maps.ts index 3491590e..8d8cbb39 100644 --- a/apps/bff/src/integrations/salesforce/constants/field-maps.ts +++ b/apps/bff/src/integrations/salesforce/constants/field-maps.ts @@ -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 // ============================================================================= diff --git a/apps/bff/src/integrations/salesforce/constants/index.ts b/apps/bff/src/integrations/salesforce/constants/index.ts index 3d0ff99e..55fe9444 100644 --- a/apps/bff/src/integrations/salesforce/constants/index.ts +++ b/apps/bff/src/integrations/salesforce/constants/index.ts @@ -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, diff --git a/apps/bff/src/integrations/salesforce/services/opportunity/opportunity-cancellation.service.ts b/apps/bff/src/integrations/salesforce/services/opportunity/opportunity-cancellation.service.ts index 398f7cea..d352ae6e 100644 --- a/apps/bff/src/integrations/salesforce/services/opportunity/opportunity-cancellation.service.ts +++ b/apps/bff/src/integrations/salesforce/services/opportunity/opportunity-cancellation.service.ts @@ -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 { - return this.updateInternetCancellationData(opportunityId, data); - } - /** * Mark cancellation as complete by updating stage to Cancelled */ diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts index cc5dabf7..e37b8ff9 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts @@ -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 { - 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 { - return this.queryService.getInternetCancellationStatus(whmcsServiceId); - } - // ========================================================================== // Lifecycle Helpers (via MutationService) // ========================================================================== diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts index 915aabb9..710a87d5 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts @@ -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( - action: string, - params: Record = {}, - options: WhmcsRequestOptions = {} - ): Promise { - return this.makeRequest(action, params, { ...options, highPriority: true }); - } - /** * Get queue metrics for monitoring */ diff --git a/apps/bff/src/modules/services/utils/salesforce-product.pricing.ts b/apps/bff/src/modules/services/utils/salesforce-product.pricing.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/portal/src/components/atoms/empty-state.tsx b/apps/portal/src/components/atoms/empty-state.tsx index 9184f579..f1c6820f 100644 --- a/apps/portal/src/components/atoms/empty-state.tsx +++ b/apps/portal/src/components/atoms/empty-state.tsx @@ -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 ( - - ); -} - export function SearchEmptyState({ searchTerm, onClearSearch, @@ -110,21 +95,3 @@ export function SearchEmptyState({ /> ); } - -export function FilterEmptyState({ onClearFilters }: { onClearFilters?: () => void }) { - return ( - - ); -} diff --git a/apps/portal/src/components/atoms/error-state.tsx b/apps/portal/src/components/atoms/error-state.tsx index f533d06d..48a67c22 100644 --- a/apps/portal/src/components/atoms/error-state.tsx +++ b/apps/portal/src/components/atoms/error-state.tsx @@ -84,34 +84,3 @@ export function ErrorState({ ); } - -export function NetworkErrorState({ onRetry }: { onRetry?: () => void }) { - return ( - - ); -} - -export function NotFoundErrorState({ resourceName = "resource" }: { resourceName?: string }) { - return ( - - ); -} - -export function PermissionErrorState() { - return ( - - ); -} diff --git a/apps/portal/src/components/atoms/index.ts b/apps/portal/src/components/atoms/index.ts index 565cb1ea..80d66fc0 100644 --- a/apps/portal/src/components/atoms/index.ts +++ b/apps/portal/src/components/atoms/index.ts @@ -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"; diff --git a/apps/portal/src/features/services/api/services.api.ts b/apps/portal/src/features/services/api/services.api.ts index de1b49e9..5c05e1c4 100644 --- a/apps/portal/src/features/services/api/services.api.ts +++ b/apps/portal/src/features/services/api/services.api.ts @@ -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 { - return this.getPublicInternetCatalog(); - }, - async getPublicSimCatalog(): Promise { const response = await apiClient.GET("/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 { - return this.getPublicSimCatalog(); - }, - async getSimActivationFees(): Promise { const response = await apiClient.GET( "/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 { - return this.getPublicVpnCatalog(); - }, - async getVpnActivationFees(): Promise { const response = await apiClient.GET("/api/services/vpn/activation-fees"); const data = getDataOrDefault(response, []); diff --git a/apps/portal/src/shared/constants/index.ts b/apps/portal/src/shared/constants/index.ts index 8291520a..e0cfc827 100644 --- a/apps/portal/src/shared/constants/index.ts +++ b/apps/portal/src/shared/constants/index.ts @@ -7,6 +7,5 @@ export { export { JAPAN_PREFECTURES, formatJapanesePostalCode, - isValidJapanesePostalCode, type PrefectureOption, } from "./japan-prefectures"; diff --git a/apps/portal/src/shared/constants/japan-prefectures.ts b/apps/portal/src/shared/constants/japan-prefectures.ts index c7a5aa46..50d8e97c 100644 --- a/apps/portal/src/shared/constants/japan-prefectures.ts +++ b/apps/portal/src/shared/constants/japan-prefectures.ts @@ -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); -} diff --git a/apps/portal/src/shared/utils/error-handling.ts b/apps/portal/src/shared/utils/error-handling.ts index ce4464e9..4ccf17b4 100644 --- a/apps/portal/src/shared/utils/error-handling.ts +++ b/apps/portal/src/shared/utils/error-handling.ts @@ -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 // ============================================================================ diff --git a/apps/portal/src/shared/utils/index.ts b/apps/portal/src/shared/utils/index.ts index 1f7b0511..f8076281 100644 --- a/apps/portal/src/shared/utils/index.ts +++ b/apps/portal/src/shared/utils/index.ts @@ -14,8 +14,6 @@ export { parseError, getErrorMessage, shouldLogout, - canRetry, - getErrorCode, ErrorCode, ErrorMessages, type ParsedError, diff --git a/docs/README.md b/docs/README.md index c8970136..d513aa1f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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: diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index dec75c7f..58fd7ee6 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -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 diff --git a/docs/development/bff/architecture.md b/docs/development/bff/architecture.md index 489f403d..d5c52ecf 100644 --- a/docs/development/bff/architecture.md +++ b/docs/development/bff/architecture.md @@ -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//.controller.ts` | +| **Services** | `apps/bff/src/modules//services/` | +| **Integrations** | `apps/bff/src/integrations//` | +| **Domain mappers** | `import from "@customer-portal/domain//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 diff --git a/docs/development/domain/structure.md b/docs/development/domain/structure.md index 7635ad73..5faefeec 100644 --- a/docs/development/domain/structure.md +++ b/docs/development/domain/structure.md @@ -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//schema.ts` | +| **Provider mappers** | `packages/domain//providers//mapper.ts` | +| **Raw types** | `packages/domain//providers//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 diff --git a/docs/development/portal/architecture.md b/docs/development/portal/architecture.md index b94baad2..34de4a55 100644 --- a/docs/development/portal/architecture.md +++ b/docs/development/portal/architecture.md @@ -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/"` | + +**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 diff --git a/docs/how-it-works/ordering-provisioning.md b/docs/how-it-works/ordering-provisioning.md index 3ec5882a..023f0bd6 100644 --- a/docs/how-it-works/ordering-provisioning.md +++ b/docs/how-it-works/ordering-provisioning.md @@ -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`) diff --git a/packages/domain/billing/constants.ts b/packages/domain/billing/constants.ts index 03b683a3..b30f433a 100644 --- a/packages/domain/billing/constants.ts +++ b/packages/domain/billing/constants.ts @@ -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 // ============================================================================ diff --git a/packages/domain/common/validation.ts b/packages/domain/common/validation.ts index d827e42f..ef80a8f4 100644 --- a/packages/domain/common/validation.ts +++ b/packages/domain/common/validation.ts @@ -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; -} diff --git a/packages/domain/opportunity/contract.ts b/packages/domain/opportunity/contract.ts index 111f8629..e2dc2380 100644 --- a/packages/domain/opportunity/contract.ts +++ b/packages/domain/opportunity/contract.ts @@ -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 diff --git a/packages/domain/opportunity/helpers.ts b/packages/domain/opportunity/helpers.ts deleted file mode 100644 index 30b60b5e..00000000 --- a/packages/domain/opportunity/helpers.ts +++ /dev/null @@ -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"; -} diff --git a/packages/domain/opportunity/index.ts b/packages/domain/opportunity/index.ts index 795fc69b..fdb9d392 100644 --- a/packages/domain/opportunity/index.ts +++ b/packages/domain/opportunity/index.ts @@ -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"; diff --git a/packages/domain/toolkit/typing/assertions.ts b/packages/domain/toolkit/typing/assertions.ts deleted file mode 100644 index c8e297f4..00000000 --- a/packages/domain/toolkit/typing/assertions.ts +++ /dev/null @@ -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( - 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)}`); -} diff --git a/packages/domain/toolkit/typing/helpers.ts b/packages/domain/toolkit/typing/helpers.ts index efe30cc5..200ec301 100644 --- a/packages/domain/toolkit/typing/helpers.ts +++ b/packages/domain/toolkit/typing/helpers.ts @@ -26,16 +26,6 @@ export type KeysOfType = { [K in keyof T]: T[K] extends U ? K : never; }[keyof T]; -/** - * Ensure all keys of a type are present - */ -export function ensureKeys>( - obj: Partial, - 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 = Pick< [K in keyof T]: T[K] extends V ? K : never; }[keyof T] >; - -/** - * Safely access nested property - */ -export function getNestedProperty(obj: unknown, path: string, defaultValue?: T): T | undefined { - const keys = path.split("."); - let current: unknown = obj; - - const isIndexableObject = (value: unknown): value is Record => - 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 = - | { status: "idle" } - | { status: "loading" } - | { status: "success"; data: T } - | { status: "error"; error: E }; - -/** - * Create an idle state - */ -export function createIdleState(): AsyncState { - return { status: "idle" }; -} - -/** - * Create a loading state - */ -export function createLoadingState(): AsyncState { - return { status: "loading" }; -} - -/** - * Create a success state with data - */ -export function createSuccessState(data: T): AsyncState { - return { status: "success", data }; -} - -/** - * Create an error state - */ -export function createErrorState(error: E): AsyncState { - return { status: "error", error }; -} - -/** - * Type guard: check if state is idle - */ -export function isIdle(state: AsyncState): state is { status: "idle" } { - return state.status === "idle"; -} - -/** - * Type guard: check if state is loading - */ -export function isLoading(state: AsyncState): state is { status: "loading" } { - return state.status === "loading"; -} - -/** - * Type guard: check if state is success - */ -export function isSuccess(state: AsyncState): state is { status: "success"; data: T } { - return state.status === "success"; -} - -/** - * Type guard: check if state is error - */ -export function isError(state: AsyncState): state is { status: "error"; error: E } { - return state.status === "error"; -} diff --git a/packages/domain/toolkit/typing/index.ts b/packages/domain/toolkit/typing/index.ts index 20d258d0..b901885a 100644 --- a/packages/domain/toolkit/typing/index.ts +++ b/packages/domain/toolkit/typing/index.ts @@ -5,5 +5,4 @@ */ export * from "./guards.js"; -export * from "./assertions.js"; export * from "./helpers.js"; diff --git a/packages/domain/toolkit/validation/helpers.ts b/packages/domain/toolkit/validation/helpers.ts index 996611ba..4d462b9d 100644 --- a/packages/domain/toolkit/validation/helpers.ts +++ b/packages/domain/toolkit/validation/helpers.ts @@ -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>( - 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(value: unknown): value is T[] { - return Array.isArray(value) && value.length > 0; -} - -/** - * Check if all array items are unique - */ -export function hasUniqueItems(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 // ============================================================================