446 lines
12 KiB
Markdown
Raw Normal View History

# Recommended Portal Structure: `lib/` vs `features/`
## 🎯 Core Principle: Co-location
**Feature-specific code belongs WITH the feature, not in a centralized `lib/` folder.**
---
## 📂 Proposed Structure
```
apps/portal/src/
├── lib/ # ✅ ONLY truly generic utilities
│ ├── api/
│ │ ├── client.ts # API client instance & configuration
│ │ ├── query-keys.ts # React Query key factory
│ │ ├── helpers.ts # getDataOrThrow, getDataOrDefault
│ │ └── index.ts # Barrel export
│ │
│ ├── utils/
│ │ ├── cn.ts # Tailwind className utility (used everywhere)
│ │ ├── error-handling.ts # Generic error handling (used everywhere)
│ │ └── index.ts
│ │
│ ├── providers.tsx # Root-level React context providers
│ └── index.ts # Main barrel export
└── features/ # ✅ Feature-specific code lives here
├── billing/
│ ├── components/
│ │ ├── InvoiceList.tsx
│ │ └── InvoiceCard.tsx
│ ├── hooks/
│ │ └── useBilling.ts # ← Feature-specific hooks HERE
│ ├── utils/
│ │ └── invoice-helpers.ts # ← Feature-specific utilities HERE
│ └── index.ts
├── subscriptions/
│ ├── components/
│ ├── hooks/
│ │ └── useSubscriptions.ts
│ └── index.ts
├── orders/
│ ├── components/
│ ├── hooks/
│ │ └── useOrders.ts
│ └── index.ts
└── auth/
├── components/
├── hooks/
│ └── useAuth.ts
├── services/
│ └── auth.store.ts
└── index.ts
```
---
## 🎨 What Goes Where?
### ✅ `lib/` - Truly Generic, Reusable Across Features
| File | Purpose | Used By |
| ----------------------------- | ------------------------------------ | -------------- |
| `lib/api/client.ts` | API client instance | All features |
| `lib/api/query-keys.ts` | React Query keys factory | All features |
| `lib/api/helpers.ts` | `getDataOrThrow`, `getDataOrDefault` | All features |
| `lib/utils/cn.ts` | Tailwind className merger | All components |
| `lib/utils/error-handling.ts` | Generic error parsing | All features |
| `lib/providers.tsx` | Root providers (QueryClient, Theme) | App root |
### ✅ `features/*/hooks/` - Feature-Specific Hooks
| File | Purpose | Used By |
| -------------------------------------------------- | --------------------------- | ----------------------- |
| `features/billing/hooks/useBilling.ts` | Invoice queries & mutations | Billing pages only |
| `features/subscriptions/hooks/useSubscriptions.ts` | Subscription queries | Subscription pages only |
| `features/orders/hooks/useOrders.ts` | Order queries | Order pages only |
| `features/auth/hooks/useAuth.ts` | Auth state & actions | Auth-related components |
---
## ❌ Anti-Pattern: Centralized Feature Hooks
```
lib/
├── hooks/
│ ├── use-billing.ts # ❌ BAD - billing-specific
│ ├── use-subscriptions.ts # ❌ BAD - subscriptions-specific
│ └── use-orders.ts # ❌ BAD - orders-specific
```
**Why this is bad:**
- Hard to find (is it in `lib` or `features`?)
- Breaks feature encapsulation
- Harder to delete features
- Makes the `lib` folder bloated
---
## 📝 Implementation
### 1. **`lib/api/index.ts`** - Clean API Exports
```typescript
/**
* API Client Utilities
* Central export for all API-related functionality
*/
export { apiClient } from "./client";
export { queryKeys } from "./query-keys";
export { getDataOrThrow, getDataOrDefault, isApiError } from "./helpers";
// Re-export common types from generated client
export type { QueryParams, PathParams } from "./runtime/client";
```
### 2. **`lib/api/helpers.ts`** - API Helper Functions
```typescript
import type { ApiResponse } from "@customer-portal/domain/common";
export function getDataOrThrow<T>(
response: { data?: T; error?: unknown },
errorMessage: string
): T {
if (response.error || !response.data) {
throw new Error(errorMessage);
}
return response.data;
}
export function getDataOrDefault<T>(response: { data?: T; error?: unknown }, defaultValue: T): T {
return response.data ?? defaultValue;
}
export function isApiError(error: unknown): error is Error {
return error instanceof Error;
}
```
### 3. **`features/billing/hooks/useBilling.ts`** - Clean Hook Implementation
```typescript
"use client";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiClient, queryKeys, getDataOrThrow, getDataOrDefault } from "@/lib/api";
import type { QueryParams } from "@/lib/api";
// ✅ Single consolidated import from domain
import {
// Types
type Invoice,
type InvoiceList,
type InvoiceQueryParams,
type InvoiceSsoLink,
type PaymentMethodList,
// Schemas
invoiceSchema,
invoiceListSchema,
// Constants
INVOICE_STATUS,
type InvoiceStatus,
} from "@customer-portal/domain/billing";
// Constants
const EMPTY_INVOICE_LIST: InvoiceList = {
invoices: [],
pagination: { page: 1, totalItems: 0, totalPages: 0 },
};
const EMPTY_PAYMENT_METHODS: PaymentMethodList = {
paymentMethods: [],
totalCount: 0,
};
// Helper functions
function ensureInvoiceStatus(invoice: Invoice): Invoice {
return {
...invoice,
status: (invoice.status as InvoiceStatus) ?? INVOICE_STATUS.DRAFT,
};
}
function normalizeInvoiceList(list: InvoiceList): InvoiceList {
return {
...list,
invoices: list.invoices.map(ensureInvoiceStatus),
pagination: {
page: list.pagination?.page ?? 1,
totalItems: list.pagination?.totalItems ?? 0,
totalPages: list.pagination?.totalPages ?? 0,
nextCursor: list.pagination?.nextCursor,
},
};
}
function toQueryParams(params: InvoiceQueryParams): QueryParams {
return Object.entries(params).reduce((acc, [key, value]) => {
if (value !== undefined) {
acc[key] = value;
}
return acc;
}, {} as QueryParams);
}
// API functions (keep as internal implementation details)
async function fetchInvoices(params?: InvoiceQueryParams): Promise<InvoiceList> {
const query = params ? toQueryParams(params) : undefined;
const response = await apiClient.GET<InvoiceList>(
"/api/invoices",
query ? { params: { query } } : undefined
);
const data = getDataOrDefault(response, EMPTY_INVOICE_LIST);
const parsed = invoiceListSchema.parse(data);
return normalizeInvoiceList(parsed);
}
async function fetchInvoice(id: string): Promise<Invoice> {
const response = await apiClient.GET<Invoice>("/api/invoices/{id}", {
params: { path: { id } },
});
const invoice = getDataOrThrow(response, "Invoice not found");
const parsed = invoiceSchema.parse(invoice);
return ensureInvoiceStatus(parsed);
}
async function fetchPaymentMethods(): Promise<PaymentMethodList> {
const response = await apiClient.GET<PaymentMethodList>("/api/invoices/payment-methods");
return getDataOrDefault(response, EMPTY_PAYMENT_METHODS);
}
// Exported hooks
export function useInvoices(params?: InvoiceQueryParams) {
return useQuery({
queryKey: queryKeys.billing.invoices(params),
queryFn: () => fetchInvoices(params),
});
}
export function useInvoice(id: string, enabled = true) {
return useQuery({
queryKey: queryKeys.billing.invoice(id),
queryFn: () => fetchInvoice(id),
enabled: Boolean(id) && enabled,
});
}
export function usePaymentMethods() {
return useQuery({
queryKey: queryKeys.billing.paymentMethods(),
queryFn: fetchPaymentMethods,
});
}
export function useCreateInvoiceSsoLink() {
return useMutation({
mutationFn: async ({
invoiceId,
target,
}: {
invoiceId: number;
target?: "view" | "download" | "pay";
}) => {
const response = await apiClient.POST<InvoiceSsoLink>("/api/invoices/{id}/sso-link", {
params: {
path: { id: invoiceId },
query: target ? { target } : undefined,
},
});
return getDataOrThrow(response, "Failed to create SSO link");
},
});
}
export function useCreatePaymentMethodsSsoLink() {
return useMutation({
mutationFn: async () => {
const response = await apiClient.POST<InvoiceSsoLink>("/api/auth/sso-link", {
body: { destination: "index.php?rp=/account/paymentmethods" },
});
return getDataOrThrow(response, "Failed to create payment methods SSO link");
},
});
}
```
### 4. **`features/billing/index.ts`** - Feature Barrel Export
```typescript
/**
* Billing Feature Exports
*/
// Hooks
export * from "./hooks/useBilling";
// Components (if you want to export them)
export * from "./components/InvoiceList";
export * from "./components/InvoiceCard";
```
### 5. **`lib/index.ts`** - Generic Utilities Only
```typescript
/**
* Portal Library
* ONLY generic utilities used across features
*/
// API utilities (used by all features)
export * from "./api";
// Generic utilities (used by all features)
export * from "./utils";
// NOTE: Feature-specific hooks are NOT exported here
// Import them from their respective features:
// import { useInvoices } from "@/features/billing";
```
---
## 🎯 Usage After Restructure
### In Billing Pages/Components
```typescript
// ✅ Import from feature
import { useInvoices, useInvoice, usePaymentMethods } from "@/features/billing";
// Or direct import
import { useInvoices } from "@/features/billing/hooks/useBilling";
function InvoicesPage() {
const { data: invoices, isLoading } = useInvoices({ status: "Unpaid" });
// ...
}
```
### In Feature Hooks (within features/billing/hooks/)
```typescript
// ✅ Import generic utilities from lib
import { apiClient, queryKeys, getDataOrThrow } from "@/lib/api";
// ✅ Import domain types
import {
type Invoice,
type InvoiceList,
invoiceSchema,
type InvoiceQueryParams,
} from "@customer-portal/domain/billing";
export function useInvoices(params?: InvoiceQueryParams) {
// ...
}
```
### In Other Feature Hooks (features/subscriptions/hooks/)
```typescript
// ✅ Generic utilities from lib
import { apiClient, queryKeys, getDataOrThrow } from "@/lib/api";
// ✅ Domain types for subscriptions
import {
type Subscription,
subscriptionSchema,
type SubscriptionQueryParams,
} from "@customer-portal/domain/subscriptions";
export function useSubscriptions(params?: SubscriptionQueryParams) {
// ...
}
```
---
## 📋 Benefits of This Structure
### 1. **Clear Separation of Concerns**
- `api/` - HTTP client & infrastructure
- `hooks/` - React Query abstractions
- `utils/` - Helper functions
### 2. **Clean Imports**
```typescript
// ❌ Before: Messy
import { apiClient, queryKeys, getDataOrDefault, getDataOrThrow } from "@/lib/api";
import type { QueryParams } from "@/lib/api/runtime/client";
import { Invoice, InvoiceList } from "@customer-portal/domain/billing";
import { invoiceSchema } from "@customer-portal/domain/validation/shared/entities";
import { INVOICE_STATUS } from "@customer-portal/domain/billing";
// ✅ After: Clean
import { apiClient, queryKeys, getDataOrThrow, type QueryParams } from "@/lib/api";
import {
type Invoice,
type InvoiceList,
invoiceSchema,
INVOICE_STATUS,
} from "@customer-portal/domain/billing";
```
### 3. **Easy to Find Things**
- Need a query hook? → `lib/hooks/queries/`
- Need API utilities? → `lib/api/`
- Need to update a domain type? → `packages/domain/billing/`
### 4. **Testable**
Each piece can be tested independently:
- API helpers are pure functions
- Hooks can be tested with React Testing Library
- Domain logic is already in domain package
---
## 🔧 What About That Weird Validation Import?
```typescript
// ❌ This should NOT exist
import { invoiceSchema } from "@customer-portal/domain/validation/shared/entities";
```
This path suggests you might have old validation code. The schema should be:
```typescript
// ✅ Correct
import { invoiceSchema } from "@customer-portal/domain/billing";
```
Let me check if this old validation path exists and needs cleanup.