From 3030d121389d889aaef5231c1a2e6d86b47c396a Mon Sep 17 00:00:00 2001 From: barsa Date: Fri, 26 Dec 2025 15:07:47 +0900 Subject: [PATCH] Update Domain Import Practices and Enhance Documentation - Added a new script to check domain imports, promoting better import hygiene across the codebase. - Refactored multiple domain index files to remove unnecessary type re-exports, streamlining the module structure. - Expanded documentation on import patterns and validation processes to provide clearer guidance for developers. - Included an architecture diagram to illustrate the relationships between the Portal, BFF, and Domain packages. --- .cursorrules | 530 ++++++++++++++++++++++ docs/development/domain/import-hygiene.md | 46 ++ package.json | 1 + packages/domain/billing/index.ts | 18 - packages/domain/mappings/index.ts | 9 - packages/domain/orders/index.ts | 22 - packages/domain/payments/index.ts | 10 - packages/domain/services/index.ts | 23 - packages/domain/sim/index.ts | 57 --- packages/domain/subscriptions/index.ts | 19 - scripts/check-domain-imports.mjs | 146 ++++++ 11 files changed, 723 insertions(+), 158 deletions(-) create mode 100644 .cursorrules create mode 100644 scripts/check-domain-imports.mjs diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 00000000..f19cff72 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,530 @@ +# Customer Portal - AI Agent Coding Rules + +This document defines the coding standards, architecture patterns, and conventions for the Customer Portal project. AI agents (Cursor, Copilot, etc.) must follow these rules when generating or modifying code. + +--- + +## 📚 Documentation First + +**Before writing any code, read and understand the relevant documentation:** + +- Start from `docs/README.md` for navigation +- Check `docs/development/` for implementation patterns +- Review `docs/architecture/` for system design +- Check `docs/integrations/` for external API details + +**Never guess API response structures or endpoint signatures.** Always read the documentation or existing implementations first. + +--- + +## 🏗️ Monorepo Architecture + +``` +apps/ + portal/ # Next.js 15 frontend (React 19) + bff/ # NestJS 11 Backend-for-Frontend +packages/ + domain/ # Pure domain types/schemas/utils (isomorphic) +``` + +### Technology Stack + +- **Frontend**: Next.js 15, React 19, Tailwind CSS 4, shadcn/ui, TanStack Query, Zustand +- **Backend**: NestJS 11, Prisma 6, PostgreSQL 17, Redis 7, Pino +- **Integrations**: Salesforce (jsforce + Pub/Sub API), WHMCS, Freebit +- **Validation**: Zod (shared between frontend and backend) + +--- + +## 📦 Domain Package (`@customer-portal/domain`) + +The domain package is the **single source of truth** for all types and validation. + +### Structure + +``` +packages/domain/ +├── {domain}/ +│ ├── contract.ts # Normalized types (provider-agnostic) +│ ├── schema.ts # Zod schemas + derived types +│ ├── constants.ts # Domain constants +│ ├── providers/ # Provider-specific adapters (BFF only!) +│ │ └── {provider}/ +│ │ ├── raw.types.ts # Raw API response types +│ │ └── mapper.ts # Transform raw → domain +│ └── index.ts # Public exports +├── common/ # Shared types (ApiResponse, pagination) +└── toolkit/ # Utilities (formatting, validation helpers) +``` + +### Import Rules + +```typescript +// ✅ CORRECT: Import from domain module +import type { Invoice, InvoiceList } from "@customer-portal/domain/billing"; +import { invoiceSchema, invoiceListSchema } from "@customer-portal/domain/billing"; + +// ✅ CORRECT (BFF only): Import provider mappers +import { Providers } from "@customer-portal/domain/billing/providers"; + +// ❌ WRONG: Never import from domain root +import { Invoice } from "@customer-portal/domain"; + +// ❌ WRONG: Never deep-import internals +import { Invoice } from "@customer-portal/domain/billing/contract"; +import { mapper } from "@customer-portal/domain/billing/providers/whmcs/mapper"; + +// ❌ WRONG: Portal must NEVER import providers +// (in apps/portal/**) +import { Providers } from "@customer-portal/domain/billing/providers"; // FORBIDDEN +``` + +### Schema-First Approach + +Always define Zod schemas first, then derive TypeScript types: + +```typescript +// ✅ CORRECT: Schema-first +export const invoiceSchema = z.object({ + id: z.number(), + status: z.enum(["Paid", "Unpaid", "Cancelled"]), + total: z.number(), +}); +export type Invoice = z.infer; + +// ❌ WRONG: Type-only (no runtime validation) +export interface Invoice { + id: number; + status: string; + total: number; +} +``` + +--- + +## 🖥️ BFF (NestJS Backend) + +### Directory Structure + +``` +apps/bff/src/ +├── modules/ # Feature-aligned modules +│ └── {feature}/ +│ ├── {feature}.controller.ts +│ ├── {feature}.module.ts +│ └── services/ +│ ├── {feature}-orchestrator.service.ts +│ └── {feature}.service.ts +├── integrations/ # External system clients +│ └── {provider}/ +│ ├── services/ +│ │ ├── {provider}-connection.service.ts +│ │ └── {provider}-{entity}.service.ts +│ ├── utils/ +│ │ └── {entity}-query-builder.ts +│ └── {provider}.module.ts +├── core/ # Framework setup (guards, filters, config) +├── infra/ # Infrastructure (Redis, queues, logging) +└── main.ts +``` + +### Controller Pattern + +Controllers use Zod DTOs via `createZodDto()` and the global `ZodValidationPipe`: + +```typescript +import { Controller, Get, Query, Request } from "@nestjs/common"; +import { createZodDto, ZodResponse } from "nestjs-zod"; +import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; +import type { InvoiceList } from "@customer-portal/domain/billing"; +import { + invoiceListQuerySchema, + invoiceListSchema, +} from "@customer-portal/domain/billing"; + +class InvoiceListQueryDto extends createZodDto(invoiceListQuerySchema) {} +class InvoiceListDto extends createZodDto(invoiceListSchema) {} + +@Controller("invoices") +export class InvoicesController { + constructor(private readonly orchestrator: InvoicesOrchestratorService) {} + + @Get() + @ZodResponse({ description: "List invoices", type: InvoiceListDto }) + async getInvoices( + @Request() req: RequestWithUser, + @Query() query: InvoiceListQueryDto + ): Promise { + return this.orchestrator.getInvoices(req.user.id, query); + } +} +``` + +**Controller Rules:** +- ❌ Never import `zod` directly in controllers +- ✅ Use schemas from `@customer-portal/domain/{module}` +- ✅ Use `createZodDto(schema)` for DTO classes +- ✅ Delegate all business logic to orchestrator services + +### Integration Service Pattern + +Integration services handle external API communication: + +```typescript +import { Injectable } from "@nestjs/common"; +import { SalesforceConnection } from "./salesforce-connection.service"; +import { buildOrderSelectFields } from "../utils/order-query-builder"; +import { + Providers, + type OrderDetails, +} from "@customer-portal/domain/orders"; + +@Injectable() +export class SalesforceOrderService { + constructor(private readonly sf: SalesforceConnection) {} + + async getOrderById(orderId: string): Promise { + // 1. Build query (infrastructure concern) + const fields = buildOrderSelectFields().join(", "); + const soql = `SELECT ${fields} FROM Order WHERE Id = '${orderId}'`; + + // 2. Execute query + const result = await this.sf.query(soql); + if (!result.records?.[0]) return null; + + // 3. Transform with domain mapper (SINGLE transformation!) + return Providers.Salesforce.transformOrderDetails(result.records[0], []); + } +} +``` + +**Integration Service Rules:** +- ✅ Build queries in utils (query builders) +- ✅ Execute API calls +- ✅ Use domain mappers for transformation +- ✅ Return domain types +- ❌ Never add business logic +- ❌ Never create wrapper/transformer services +- ❌ Never transform data twice + +### Error Handling + +**Never expose sensitive information to customers:** + +```typescript +// ✅ CORRECT: Generic user-facing message +throw new HttpException( + "Unable to process your request. Please try again.", + HttpStatus.BAD_REQUEST +); + +// ❌ WRONG: Exposes internal details +throw new HttpException( + `WHMCS API error: ${error.message}`, + HttpStatus.BAD_REQUEST +); +``` + +Log detailed errors server-side, return generic messages to clients. + +--- + +## 🌐 Portal (Next.js Frontend) + +### Directory Structure + +``` +apps/portal/src/ +├── app/ # Next.js App Router (thin route wrappers) +│ ├── (public)/ # Marketing + auth routes +│ ├── (authenticated)/ # Signed-in portal routes +│ └── api/ # API routes +├── components/ # Shared UI (design system) +│ ├── ui/ # Atoms (Button, Input, Card) +│ ├── layout/ # Layout components +│ └── common/ # Molecules (DataTable, SearchBar) +├── features/ # Feature modules +│ └── {feature}/ +│ ├── components/ # Feature-specific UI +│ ├── hooks/ # React Query hooks +│ ├── services/ # API service functions +│ ├── views/ # Page-level views +│ └── index.ts # Public exports +├── lib/ # Core utilities +│ ├── api/ # HTTP client +│ ├── hooks/ # Shared hooks +│ ├── services/ # Shared services +│ └── utils/ # Utility functions +└── styles/ # Global styles +``` + +### Feature Module Pattern + +```typescript +// features/billing/hooks/use-invoices.ts +import { useQuery } from "@tanstack/react-query"; +import type { InvoiceList } from "@customer-portal/domain/billing"; +import { billingService } from "../services"; + +export function useInvoices(params?: { status?: string }) { + return useQuery({ + queryKey: ["invoices", params], + queryFn: () => billingService.getInvoices(params), + }); +} + +// features/billing/services/billing.service.ts +import { apiClient } from "@/lib/api"; +import type { InvoiceList } from "@customer-portal/domain/billing"; + +export const billingService = { + async getInvoices(params?: { status?: string }): Promise { + const response = await apiClient.get("/invoices", { params }); + return response.data; + }, +}; + +// features/billing/index.ts +export * from "./hooks"; +export * from "./components"; +``` + +### Page Component Rules + +Pages are thin shells that compose features: + +```typescript +// app/(authenticated)/billing/page.tsx +import { InvoicesView } from "@/features/billing"; + +export default function BillingPage() { + return ; +} +``` + +**Frontend Rules:** +- ✅ Pages delegate to feature views +- ✅ Data fetching lives in feature hooks +- ✅ Business logic lives in feature services +- ✅ Use `@/` path aliases +- ❌ Never call APIs directly in page components +- ❌ Never import provider types (only domain contracts) + +### Import Patterns + +```typescript +// Feature imports +import { LoginForm, useAuth } from "@/features/auth"; + +// Component imports +import { Button, Input } from "@/components/ui"; +import { DataTable } from "@/components/common"; + +// Type imports (domain types only!) +import type { Invoice } from "@customer-portal/domain/billing"; + +// Utility imports +import { apiClient } from "@/lib/api"; +``` + +--- + +## ✅ Validation Patterns + +### Query Parameters + +Use `z.coerce` for URL query strings: + +```typescript +// ✅ CORRECT: Coerce string to number +export const paginationSchema = z.object({ + page: z.coerce.number().int().positive().optional(), + limit: z.coerce.number().int().positive().max(100).optional(), +}); + +// ❌ WRONG: Will fail on URL strings +export const paginationSchema = z.object({ + page: z.number().optional(), // "1" !== 1 +}); +``` + +### Request Body Validation + +```typescript +// In domain schema +export const createOrderRequestSchema = z.object({ + items: z.array(orderItemSchema).min(1), + shippingAddressId: z.string().uuid(), +}); +export type CreateOrderRequest = z.infer; +``` + +### Form Validation (Frontend) + +```typescript +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { loginRequestSchema, type LoginRequest } from "@customer-portal/domain/auth"; + +function LoginForm() { + const form = useForm({ + resolver: zodResolver(loginRequestSchema), + }); + // ... +} +``` + +--- + +## 🎨 UI Design Guidelines + +Follow minimal, clean UI design principles: + +- **Avoid excessive animations** - Use subtle, purposeful transitions +- **Clean typography** - Use system fonts or carefully chosen web fonts +- **Consistent spacing** - Follow the design system tokens +- **Accessibility first** - Proper ARIA labels, keyboard navigation +- **Mobile responsive** - Test on all breakpoints + +Use shadcn/ui components and Tailwind CSS utilities. + +--- + +## 🔒 Security Rules + +1. **Never expose sensitive data** in error messages or responses +2. **Validate all inputs** at API boundaries (Zod schemas) +3. **Use parameterized queries** (Prisma handles this) +4. **Sanitize user content** before display +5. **Check authorization** in every endpoint + +--- + +## 📝 Naming Conventions + +### Files + +- **Components**: PascalCase (`InvoiceCard.tsx`) +- **Hooks**: camelCase with `use` prefix (`useInvoices.ts`) +- **Services**: kebab-case (`billing.service.ts`) +- **Types/Schemas**: kebab-case (`invoice.schema.ts`) +- **Utils**: kebab-case (`format-currency.ts`) + +### Code + +- **Types/Interfaces**: PascalCase (`Invoice`, `OrderDetails`) +- **Schemas**: camelCase with `Schema` suffix (`invoiceSchema`) +- **Constants**: SCREAMING_SNAKE_CASE (`INVOICE_STATUS`) +- **Functions**: camelCase (`getInvoices`, `transformOrder`) +- **React Components**: PascalCase (`InvoiceList`) + +### Services + +- ❌ Avoid `V2` suffix in service names +- ✅ Use clear, descriptive names (`InvoicesOrchestratorService`) + +--- + +## 🚫 Anti-Patterns to Avoid + +### Domain Layer + +- ❌ Framework-specific imports (no React/NestJS in domain) +- ❌ Circular dependencies +- ❌ Exposing raw provider types to application code + +### BFF + +- ❌ Business logic in controllers +- ❌ Direct Zod imports in controllers (use schemas from domain) +- ❌ Creating transformer/wrapper services +- ❌ Multiple data transformations +- ❌ Exposing internal error details + +### Portal + +- ❌ API calls in page components +- ❌ Business logic in UI components +- ❌ Importing provider types/mappers +- ❌ Duplicate type definitions +- ❌ Using `window.location` for navigation (use `next/link` or `useRouter`) + +### General + +- ❌ Using `any` type (especially in public APIs) +- ❌ Unsafe type assertions +- ❌ Console.log in production code (use proper logger) +- ❌ Guessing API response structures + +--- + +## 🔄 Data Flow Summary + +### Inbound (External API → Application) + +``` +External API Response + ↓ +Raw Provider Types (domain/*/providers/*/raw.types.ts) + ↓ +Provider Mapper (domain/*/providers/*/mapper.ts) [BFF only] + ↓ +Zod Schema Validation (domain/*/schema.ts) + ↓ +Domain Contract (domain/*/contract.ts) + ↓ +Application Code (BFF services, Portal hooks) +``` + +### Outbound (Application → External API) + +``` +Application Intent + ↓ +Domain Contract + ↓ +Provider Mapper (BFF only) + ↓ +Raw Provider Payload + ↓ +External API Request +``` + +--- + +## 📋 Checklist for New Features + +1. [ ] Define types in `packages/domain/{feature}/contract.ts` +2. [ ] Add Zod schemas in `packages/domain/{feature}/schema.ts` +3. [ ] Export from `packages/domain/{feature}/index.ts` +4. [ ] (If provider needed) Add raw types and mapper in `providers/{provider}/` +5. [ ] Create BFF module in `apps/bff/src/modules/{feature}/` +6. [ ] Create controller with Zod DTOs +7. [ ] Create orchestrator service +8. [ ] Create portal feature in `apps/portal/src/features/{feature}/` +9. [ ] Add hooks, services, and components +10. [ ] Create page in `apps/portal/src/app/` + +--- + +## 🛠️ Development Commands + +```bash +# Development +pnpm dev # Start all apps +pnpm dev:bff # Start BFF only +pnpm dev:portal # Start Portal only + +# Type checking +pnpm typecheck # Check all packages +pnpm lint # Run ESLint + +# Database +pnpm db:migrate # Run migrations +pnpm db:generate # Generate Prisma client +``` + +--- + +**Last Updated**: December 2025 + diff --git a/docs/development/domain/import-hygiene.md b/docs/development/domain/import-hygiene.md index 3aed39fc..0681693a 100644 --- a/docs/development/domain/import-hygiene.md +++ b/docs/development/domain/import-hygiene.md @@ -14,6 +14,44 @@ - **BFF-only (integration/infrastructure)**: - `@customer-portal/domain//providers` +## Quick Reference + +| Context | Import Pattern | Example | +| ----------------------- | ---------------------------------- | ------------------------------------------------------------------- | +| Portal/BFF domain types | `@customer-portal/domain/` | `import { Invoice } from "@customer-portal/domain/billing"` | +| BFF provider adapters | `...//providers` | `import { Whmcs } from "@customer-portal/domain/billing/providers"` | +| Toolkit utilities | `@customer-portal/domain/toolkit` | `import { Formatting } from "@customer-portal/domain/toolkit"` | + +## Architecture Diagram + +```mermaid +graph TD + subgraph Portal [Portal App] + PC[PortalComponents] + end + + subgraph BFF [BFF App] + BC[BFFControllers] + BI[BFFIntegrations] + end + + subgraph Domain [Domain Package] + DM[ModuleEntrypoints] + DP[ProviderEntrypoints] + DT[Toolkit] + end + + PC -->|allowed| DM + PC -->|allowed| DT + PC -.->|BLOCKED| DP + + BC -->|allowed| DM + BC -->|allowed| DT + BI -->|allowed| DM + BI -->|allowed| DP + BI -->|allowed| DT +``` + ### Never - `@customer-portal/domain//**` (anything deeper than the module entrypoint) @@ -51,3 +89,11 @@ Where to export it: - No `@customer-portal/domain/*/*` imports (except exact `...//providers` in BFF). - Portal has **zero** `.../providers` imports. - No wildcard subpath exports added to `packages/domain/package.json#exports`. + +## Validation + +After changes: + +1. Run `pnpm lint` +2. Run `pnpm type-check` +3. Run `pnpm build` diff --git a/package.json b/package.json index 1422bf79..f7c72230 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "format:check": "prettier -c .", "prepare": "husky", "type-check": "pnpm --filter @customer-portal/domain run type-check && pnpm --filter @customer-portal/bff --filter @customer-portal/portal run type-check", + "check:imports": "node scripts/check-domain-imports.mjs", "clean": "pnpm --recursive run clean", "dev:start": "./scripts/dev/manage.sh start", "dev:stop": "./scripts/dev/manage.sh stop", diff --git a/packages/domain/billing/index.ts b/packages/domain/billing/index.ts index b0c68045..7462f8bb 100644 --- a/packages/domain/billing/index.ts +++ b/packages/domain/billing/index.ts @@ -12,21 +12,3 @@ export * from "./constants.js"; // Schemas (includes derived types) export * from "./schema.js"; - -// Re-export types for convenience -export type { - Currency, - InvoiceStatus, - InvoiceItem, - Invoice, - InvoiceIdParam, - InvoicePagination, - InvoiceList, - InvoiceSsoLink, - PaymentInvoiceRequest, - BillingSummary, - InvoiceQueryParams, - InvoiceListQuery, - InvoiceSsoQuery, - InvoicePaymentLinkQuery, -} from "./schema.js"; diff --git a/packages/domain/mappings/index.ts b/packages/domain/mappings/index.ts index 83e253c9..85ace165 100644 --- a/packages/domain/mappings/index.ts +++ b/packages/domain/mappings/index.ts @@ -5,12 +5,3 @@ export * from "./contract.js"; export * from "./schema.js"; export * from "./validation.js"; - -// Re-export types for convenience -export type { - MappingSearchFilters, - MappingStats, - BulkMappingOperation, - BulkMappingResult, -} from "./schema.js"; -export type { MappingValidationResult } from "./contract.js"; diff --git a/packages/domain/orders/index.ts b/packages/domain/orders/index.ts index 3ee120c9..da73b87e 100644 --- a/packages/domain/orders/index.ts +++ b/packages/domain/orders/index.ts @@ -58,28 +58,6 @@ export { calculateOrderTotals, formatScheduledDate, } from "./helpers.js"; -// Re-export types for convenience -export type { - // Order item types - OrderItemSummary, - OrderItemDetails, - // Order types - OrderSummary, - OrderDetails, - // Query and creation types - OrderQueryParams, - OrderConfigurationsAddress, - OrderConfigurations, - CreateOrderRequest, - OrderBusinessValidation, - SfOrderIdParam, - OrderListResponse, - // Display types - OrderDisplayItem, - OrderDisplayItemCategory, - OrderDisplayItemCharge, - OrderDisplayItemChargeKind, -} from "./schema.js"; // Provider adapters // NOTE: Provider adapters are intentionally not exported from the module root. diff --git a/packages/domain/payments/index.ts b/packages/domain/payments/index.ts index 66343226..18de0bf4 100644 --- a/packages/domain/payments/index.ts +++ b/packages/domain/payments/index.ts @@ -11,13 +11,3 @@ export { PAYMENT_METHOD_TYPE, PAYMENT_GATEWAY_TYPE } from "./contract.js"; // Schemas (includes derived types) export * from "./schema.js"; - -// Re-export types for convenience -export type { - PaymentMethodType, - PaymentMethod, - PaymentMethodList, - PaymentGatewayType, - PaymentGateway, - PaymentGatewayList, -} from "./schema.js"; diff --git a/packages/domain/services/index.ts b/packages/domain/services/index.ts index 3408bc17..0210f704 100644 --- a/packages/domain/services/index.ts +++ b/packages/domain/services/index.ts @@ -11,28 +11,5 @@ export { type PricingTier, type CatalogPriceInfo } from "./contract.js"; // Schemas (includes derived types) export * from "./schema.js"; -// Re-export types for convenience -export type { - CatalogProductBase, - CatalogPricebookEntry, - // Internet products - InternetCatalogProduct, - InternetPlanTemplate, - InternetPlanCatalogItem, - InternetInstallationCatalogItem, - InternetAddonCatalogItem, - InternetEligibilityStatus, - InternetEligibilityDetails, - InternetEligibilityRequest, - InternetEligibilityRequestResponse, - // SIM products - SimCatalogProduct, - SimActivationFeeCatalogItem, - // VPN products - VpnCatalogProduct, - // Union type - CatalogProduct, -} from "./schema.js"; - // Utilities export * from "./utils.js"; diff --git a/packages/domain/sim/index.ts b/packages/domain/sim/index.ts index 8606fa61..7ecd9b67 100644 --- a/packages/domain/sim/index.ts +++ b/packages/domain/sim/index.ts @@ -25,62 +25,5 @@ export { getSimPlanLabel, buildSimFeaturesUpdatePayload, } from "./helpers.js"; - -// Re-export types for convenience -export type { - SimStatus, - SimType, - SimDetails, - RecentDayUsage, - SimUsage, - SimTopUpHistoryEntry, - SimTopUpHistory, - SimInfo, - // Portal-facing DTOs - SimAvailablePlan, - SimAvailablePlanArray, - SimCancellationMonth, - SimCancellationPreview, - SimReissueFullRequest, - SimCallHistoryPagination, - SimDomesticCallRecord, - SimDomesticCallHistoryResponse, - SimInternationalCallRecord, - SimInternationalCallHistoryResponse, - SimSmsRecord, - SimSmsHistoryResponse, - SimHistoryMonth, - SimHistoryAvailableMonths, - SimCallHistoryImportResult, - SimSftpFiles, - SimSftpListResult, - // Request types - SimTopUpRequest, - SimPlanChangeRequest, - SimCancelRequest, - SimTopUpHistoryRequest, - SimFeaturesUpdateRequest, - SimReissueRequest, - SimConfigureFormData, - SimCardType, - ActivationType, - MnpData, - // Enhanced request types - SimCancelFullRequest, - SimTopUpFullRequest, - SimChangePlanFullRequest, - SimHistoryQuery, - SimSftpListQuery, - SimCallHistoryImportQuery, - SimReissueEsimRequest, - // Activation types - SimOrderActivationRequest, - SimOrderActivationMnp, - SimOrderActivationAddons, - // Pricing types - SimTopUpPricing, - SimTopUpPricingPreviewRequest, - SimTopUpPricingPreviewResponse, -} from "./schema.js"; export type { SimPlanCode } from "./contract.js"; export type { SimPlanOption, SimFeatureToggleSnapshot } from "./helpers.js"; diff --git a/packages/domain/subscriptions/index.ts b/packages/domain/subscriptions/index.ts index f2a4205a..fb2af4cc 100644 --- a/packages/domain/subscriptions/index.ts +++ b/packages/domain/subscriptions/index.ts @@ -12,25 +12,6 @@ export { SUBSCRIPTION_STATUS, SUBSCRIPTION_CYCLE } from "./contract.js"; // Schemas (includes derived types) export * from "./schema.js"; -// Re-export types for convenience -export type { - SubscriptionStatus, - SubscriptionCycle, - Subscription, - SubscriptionArray, - SubscriptionList, - SubscriptionIdParam, - SubscriptionQueryParams, - SubscriptionQuery, - SubscriptionStats, - SimActionResponse, - SimPlanChangeResult, - // Internet cancellation types - InternetCancellationMonth, - InternetCancellationPreview, - InternetCancelRequest, -} from "./schema.js"; - // Re-export schemas for validation export { internetCancellationMonthSchema, diff --git a/scripts/check-domain-imports.mjs b/scripts/check-domain-imports.mjs new file mode 100644 index 00000000..a63805ca --- /dev/null +++ b/scripts/check-domain-imports.mjs @@ -0,0 +1,146 @@ +#!/usr/bin/env node +/** + * Domain Import Boundary Checker + * + * Validates: + * 1. No @customer-portal/domain (root) imports + * 2. No deep imports beyond module/providers + * 3. Portal has zero provider imports + */ + +import fs from "node:fs/promises"; +import path from "node:path"; + +const ROOT = process.cwd(); + +const APPS_DIR = path.join(ROOT, "apps"); +const BFF_SRC_DIR = path.join(APPS_DIR, "bff", "src"); +const PORTAL_SRC_DIR = path.join(APPS_DIR, "portal", "src"); + +const FILE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx"]); +const IGNORE_DIRS = new Set(["node_modules", "dist", ".next", ".turbo", ".cache"]); + +function toPos(text, idx) { + // 1-based line/column + let line = 1; + let col = 1; + for (let i = 0; i < idx; i += 1) { + if (text.charCodeAt(i) === 10) { + line += 1; + col = 1; + } else { + col += 1; + } + } + return { line, col }; +} + +async function* walk(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const e of entries) { + const p = path.join(dir, e.name); + if (e.isDirectory()) { + if (IGNORE_DIRS.has(e.name) || e.name.startsWith(".")) continue; + yield* walk(p); + continue; + } + if (!e.isFile()) continue; + if (!FILE_EXTS.has(path.extname(e.name))) continue; + yield p; + } +} + +function collectDomainImports(code) { + const results = []; + + const patterns = [ + { kind: "from", re: /\bfrom\s+['"]([^'"]+)['"]/g }, + { kind: "import", re: /\bimport\s+['"]([^'"]+)['"]/g }, + { kind: "dynamicImport", re: /\bimport\(\s*['"]([^'"]+)['"]\s*\)/g }, + { kind: "require", re: /\brequire\(\s*['"]([^'"]+)['"]\s*\)/g }, + ]; + + for (const { kind, re } of patterns) { + for (const m of code.matchAll(re)) { + const spec = m[1]; + if (!spec || !spec.startsWith("@customer-portal/domain")) continue; + const idx = typeof m.index === "number" ? m.index : 0; + results.push({ kind, spec, idx }); + } + } + + return results; +} + +function validateSpecifier({ spec, isPortal }) { + if (spec === "@customer-portal/domain") { + return "Do not import @customer-portal/domain (root). Use @customer-portal/domain/ instead."; + } + + if (spec.includes("/src/")) { + return "Import from @customer-portal/domain/ instead of internals."; + } + + if (spec.startsWith("@customer-portal/domain/toolkit/")) { + return "Do not deep-import toolkit internals. Import from @customer-portal/domain/toolkit only."; + } + + if (/^@customer-portal\/domain\/[^/]+\/providers\/.+/.test(spec)) { + return "Do not deep-import provider internals. Import from @customer-portal/domain//providers only."; + } + + if (/^@customer-portal\/domain\/[^/]+\/providers$/.test(spec)) { + if (isPortal) { + return "Portal must not import provider adapters/types. Import normalized domain models from @customer-portal/domain/ instead."; + } + return null; + } + + // Any 2+ segment import like @customer-portal/domain/a/b is illegal everywhere + // (except the explicit ...//providers entrypoint handled above). + if (/^@customer-portal\/domain\/[^/]+\/[^/]+/.test(spec)) { + return "No deep @customer-portal/domain imports. Use @customer-portal/domain/ (or BFF-only: ...//providers)."; + } + + return null; +} + +async function main() { + const errors = []; + + // Broad scan: both apps for root/deep imports + for (const baseDir of [BFF_SRC_DIR, PORTAL_SRC_DIR]) { + for await (const file of walk(baseDir)) { + const code = await fs.readFile(file, "utf8"); + const isPortal = file.startsWith(PORTAL_SRC_DIR + path.sep); + + for (const imp of collectDomainImports(code)) { + const message = validateSpecifier({ spec: imp.spec, isPortal }); + if (!message) continue; + const pos = toPos(code, imp.idx); + errors.push({ + file: path.relative(ROOT, file), + line: pos.line, + col: pos.col, + spec: imp.spec, + message, + }); + } + } + } + + if (errors.length > 0) { + console.error(`[domain] ERROR: illegal domain imports detected (${errors.length})`); + for (const e of errors) { + console.error(`[domain] ${e.file}:${e.line}:${e.col} ${e.spec}`); + console.error(` ${e.message}`); + } + process.exit(1); + } + + console.log("[domain] OK: import contract checks passed."); +} + +await main(); + +