Assist_Design/docs/portal/RECOMMENDED-LIB-STRUCTURE.md
barsa 9764ccfbad Update .gitignore to include SHA256 checksum files and refresh address handling in hooks
- Added '*.tar.gz.sha256' to .gitignore to exclude SHA256 checksum files from version control.
- Updated SHA256 checksums for the latest portal backend and frontend tar.gz files to reflect new builds.
- Enhanced address handling in `useAddressEdit`, `useProfileData`, and `AddressConfirmation` components to invalidate catalog queries upon address updates, ensuring accurate server-personalized results.
- Introduced new query key for catalog queries in the API to streamline cache management.
2025-12-15 10:32:07 +09:00

12 KiB

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

/**
 * 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

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

"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

/**
 * 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

/**
 * 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

// ✅ 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/)

// ✅ 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/)

// ✅ 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

// ❌ 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?

// ❌ 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:

// ✅ Correct
import { invoiceSchema } from "@customer-portal/domain/billing";

Let me check if this old validation path exists and needs cleanup.