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.
This commit is contained in:
parent
a3dbd07183
commit
3030d12138
530
.cursorrules
Normal file
530
.cursorrules
Normal file
@ -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<typeof invoiceSchema>;
|
||||||
|
|
||||||
|
// ❌ 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<InvoiceList> {
|
||||||
|
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<OrderDetails | null> {
|
||||||
|
// 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<InvoiceList>({
|
||||||
|
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<InvoiceList> {
|
||||||
|
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 <InvoicesView />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**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<typeof createOrderRequestSchema>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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<LoginRequest>({
|
||||||
|
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
|
||||||
|
|
||||||
@ -14,6 +14,44 @@
|
|||||||
- **BFF-only (integration/infrastructure)**:
|
- **BFF-only (integration/infrastructure)**:
|
||||||
- `@customer-portal/domain/<module>/providers`
|
- `@customer-portal/domain/<module>/providers`
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Context | Import Pattern | Example |
|
||||||
|
| ----------------------- | ---------------------------------- | ------------------------------------------------------------------- |
|
||||||
|
| Portal/BFF domain types | `@customer-portal/domain/<module>` | `import { Invoice } from "@customer-portal/domain/billing"` |
|
||||||
|
| BFF provider adapters | `.../<module>/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
|
### Never
|
||||||
|
|
||||||
- `@customer-portal/domain/<module>/**` (anything deeper than the module entrypoint)
|
- `@customer-portal/domain/<module>/**` (anything deeper than the module entrypoint)
|
||||||
@ -51,3 +89,11 @@ Where to export it:
|
|||||||
- No `@customer-portal/domain/*/*` imports (except exact `.../<module>/providers` in BFF).
|
- No `@customer-portal/domain/*/*` imports (except exact `.../<module>/providers` in BFF).
|
||||||
- Portal has **zero** `.../providers` imports.
|
- Portal has **zero** `.../providers` imports.
|
||||||
- No wildcard subpath exports added to `packages/domain/package.json#exports`.
|
- 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`
|
||||||
|
|||||||
@ -24,6 +24,7 @@
|
|||||||
"format:check": "prettier -c .",
|
"format:check": "prettier -c .",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"type-check": "pnpm --filter @customer-portal/domain run type-check && pnpm --filter @customer-portal/bff --filter @customer-portal/portal run type-check",
|
"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",
|
"clean": "pnpm --recursive run clean",
|
||||||
"dev:start": "./scripts/dev/manage.sh start",
|
"dev:start": "./scripts/dev/manage.sh start",
|
||||||
"dev:stop": "./scripts/dev/manage.sh stop",
|
"dev:stop": "./scripts/dev/manage.sh stop",
|
||||||
|
|||||||
@ -12,21 +12,3 @@ export * from "./constants.js";
|
|||||||
|
|
||||||
// Schemas (includes derived types)
|
// Schemas (includes derived types)
|
||||||
export * from "./schema.js";
|
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";
|
|
||||||
|
|||||||
@ -5,12 +5,3 @@
|
|||||||
export * from "./contract.js";
|
export * from "./contract.js";
|
||||||
export * from "./schema.js";
|
export * from "./schema.js";
|
||||||
export * from "./validation.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";
|
|
||||||
|
|||||||
@ -58,28 +58,6 @@ export {
|
|||||||
calculateOrderTotals,
|
calculateOrderTotals,
|
||||||
formatScheduledDate,
|
formatScheduledDate,
|
||||||
} from "./helpers.js";
|
} 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
|
// Provider adapters
|
||||||
// NOTE: Provider adapters are intentionally not exported from the module root.
|
// NOTE: Provider adapters are intentionally not exported from the module root.
|
||||||
|
|||||||
@ -11,13 +11,3 @@ export { PAYMENT_METHOD_TYPE, PAYMENT_GATEWAY_TYPE } from "./contract.js";
|
|||||||
|
|
||||||
// Schemas (includes derived types)
|
// Schemas (includes derived types)
|
||||||
export * from "./schema.js";
|
export * from "./schema.js";
|
||||||
|
|
||||||
// Re-export types for convenience
|
|
||||||
export type {
|
|
||||||
PaymentMethodType,
|
|
||||||
PaymentMethod,
|
|
||||||
PaymentMethodList,
|
|
||||||
PaymentGatewayType,
|
|
||||||
PaymentGateway,
|
|
||||||
PaymentGatewayList,
|
|
||||||
} from "./schema.js";
|
|
||||||
|
|||||||
@ -11,28 +11,5 @@ export { type PricingTier, type CatalogPriceInfo } from "./contract.js";
|
|||||||
// Schemas (includes derived types)
|
// Schemas (includes derived types)
|
||||||
export * from "./schema.js";
|
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
|
// Utilities
|
||||||
export * from "./utils.js";
|
export * from "./utils.js";
|
||||||
|
|||||||
@ -25,62 +25,5 @@ export {
|
|||||||
getSimPlanLabel,
|
getSimPlanLabel,
|
||||||
buildSimFeaturesUpdatePayload,
|
buildSimFeaturesUpdatePayload,
|
||||||
} from "./helpers.js";
|
} 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 { SimPlanCode } from "./contract.js";
|
||||||
export type { SimPlanOption, SimFeatureToggleSnapshot } from "./helpers.js";
|
export type { SimPlanOption, SimFeatureToggleSnapshot } from "./helpers.js";
|
||||||
|
|||||||
@ -12,25 +12,6 @@ export { SUBSCRIPTION_STATUS, SUBSCRIPTION_CYCLE } from "./contract.js";
|
|||||||
// Schemas (includes derived types)
|
// Schemas (includes derived types)
|
||||||
export * from "./schema.js";
|
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
|
// Re-export schemas for validation
|
||||||
export {
|
export {
|
||||||
internetCancellationMonthSchema,
|
internetCancellationMonthSchema,
|
||||||
|
|||||||
146
scripts/check-domain-imports.mjs
Normal file
146
scripts/check-domain-imports.mjs
Normal file
@ -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/<module> instead.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spec.includes("/src/")) {
|
||||||
|
return "Import from @customer-portal/domain/<module> 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/<module>/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/<module> instead.";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any 2+ segment import like @customer-portal/domain/a/b is illegal everywhere
|
||||||
|
// (except the explicit .../<module>/providers entrypoint handled above).
|
||||||
|
if (/^@customer-portal\/domain\/[^/]+\/[^/]+/.test(spec)) {
|
||||||
|
return "No deep @customer-portal/domain imports. Use @customer-portal/domain/<module> (or BFF-only: .../<module>/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();
|
||||||
|
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user