chore: delete legacy packages (api, api-client, cms-client, cms-core, core, ui)
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,166 +0,0 @@
|
||||
# @repo/api-client -- Framework-Agnostic tRPC + React Query Provider
|
||||
|
||||
## Purpose
|
||||
|
||||
This package provides a framework-agnostic tRPC client and React Query provider that any frontend app (Next.js, TanStack Start, or future frameworks) can consume. It exposes `<ApiProvider>` for initialization and `useTRPC()` for fully typed data fetching. It contains zero business logic.
|
||||
|
||||
## Hard Rules
|
||||
|
||||
- **NEVER** import framework-specific code (no `next/`, no `@tanstack/start`, no `vinxi/`)
|
||||
- **NEVER** put business logic in this package
|
||||
- **NEVER** create custom hooks that duplicate what `useTRPC()` already provides
|
||||
- Both Next.js and TanStack Start apps use the same `<ApiProvider>` and `useTRPC()`
|
||||
- This package depends on `@repo/api` for the `AppRouter` type only (no runtime import)
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
packages/api-client/
|
||||
src/
|
||||
trpc.ts # Creates TRPCProvider + useTRPC via createTRPCContext<AppRouter>()
|
||||
query-client.ts # Singleton QueryClient factory (SSR-safe)
|
||||
provider.tsx # <ApiProvider> component: wires tRPC client + QueryClientProvider
|
||||
index.ts # Package entry: re-exports ApiProvider, useTRPC, getQueryClient
|
||||
package.json
|
||||
AGENTS.md
|
||||
```
|
||||
|
||||
## How Apps Consume This Package
|
||||
|
||||
### Step 1: Wrap your app with `<ApiProvider>`
|
||||
|
||||
The provider needs a `trpcUrl` pointing to the tRPC HTTP endpoint:
|
||||
|
||||
```tsx
|
||||
// apps/web-next/src/app/providers.tsx
|
||||
"use client";
|
||||
|
||||
import { ApiProvider } from "@repo/api-client";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <ApiProvider trpcUrl="/api/trpc">{children}</ApiProvider>;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// apps/web-next/src/app/layout.tsx
|
||||
import { Providers } from "./providers";
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
For TanStack Start, the provider goes in the root route:
|
||||
|
||||
```tsx
|
||||
// apps/web-tanstack/src/routes/__root.tsx
|
||||
import { Outlet, createRootRoute } from "@tanstack/react-router";
|
||||
import { ApiProvider } from "@repo/api-client";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: () => (
|
||||
<ApiProvider trpcUrl="http://localhost:3000/api/trpc">
|
||||
<Outlet />
|
||||
</ApiProvider>
|
||||
),
|
||||
});
|
||||
```
|
||||
|
||||
### Step 2: Use `useTRPC()` in components
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useTRPC } from "@repo/api-client";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
|
||||
export function ArticleList() {
|
||||
const trpc = useTRPC();
|
||||
|
||||
// Query -- reads data
|
||||
const { data, isLoading, error } = useQuery(
|
||||
trpc.content.listArticles.queryOptions({ status: "published", limit: 10 })
|
||||
);
|
||||
|
||||
// Mutation -- writes data
|
||||
const createArticle = useMutation(
|
||||
trpc.content.createArticle.mutationOptions()
|
||||
);
|
||||
|
||||
if (isLoading) return <p>Loading...</p>;
|
||||
if (error) return <p>Error: {error.message}</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ul>
|
||||
{data?.map((article) => (
|
||||
<li key={article.id}>{article.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
onClick={() =>
|
||||
createArticle.mutate({
|
||||
title: "New Article",
|
||||
content: "Hello world",
|
||||
authorId: "user-1",
|
||||
})
|
||||
}
|
||||
>
|
||||
Create Article
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## The `useTRPC()` Pattern
|
||||
|
||||
`useTRPC()` is created by `createTRPCContext<AppRouter>()` from `@trpc/tanstack-react-query`. It returns a proxy object that mirrors the router structure:
|
||||
|
||||
```
|
||||
useTRPC()
|
||||
.auth
|
||||
.signIn.mutationOptions()
|
||||
.signUp.mutationOptions()
|
||||
.signOut.mutationOptions()
|
||||
.content
|
||||
.listArticles.queryOptions({ ... })
|
||||
.createArticle.mutationOptions()
|
||||
```
|
||||
|
||||
You pass `.queryOptions()` to `useQuery()` and `.mutationOptions()` to `useMutation()` from `@tanstack/react-query`. This gives you full control over caching, refetching, optimistic updates, and all React Query features.
|
||||
|
||||
## Custom Hook Wrappers Are Optional
|
||||
|
||||
Since `useTRPC()` gives fully typed access to every procedure, you do **not** need to create wrapper hooks like `useArticles()`. Only create a custom hook if you have shared logic (e.g., combining multiple queries, adding retry logic, or transforming results) that would otherwise be duplicated across multiple components.
|
||||
|
||||
## QueryClient Configuration
|
||||
|
||||
The `getQueryClient()` factory in `query-client.ts` handles SSR correctly:
|
||||
- **Server-side:** Creates a new `QueryClient` per request (avoids cross-request data leaks)
|
||||
- **Client-side:** Returns a singleton `QueryClient` (reused across renders)
|
||||
- Default `staleTime` is 30 seconds
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
|---|---|
|
||||
| `@repo/api` | `AppRouter` type for end-to-end type safety (type-only import) |
|
||||
| `@trpc/client` | tRPC client with `httpBatchLink` |
|
||||
| `@trpc/tanstack-react-query` | `createTRPCContext` for React Query integration |
|
||||
| `@tanstack/react-query` | `QueryClient`, `QueryClientProvider` |
|
||||
| `react` | JSX runtime for provider component |
|
||||
|
||||
## Cross-References
|
||||
|
||||
- **Router types come from:** `packages/api/` -- see `packages/api/AGENTS.md`
|
||||
- **tRPC HTTP endpoint:** `apps/web-next/src/app/api/trpc/[trpc]/route.ts`
|
||||
- **Provider usage in Next.js:** `apps/web-next/src/app/providers.tsx`
|
||||
- **Provider usage in TanStack:** `apps/web-tanstack/src/routes/__root.tsx`
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "@repo/api-client",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"build": "echo 'typechecked by consuming app bundler'",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/api": "workspace:*",
|
||||
"@trpc/client": "^11.1.0",
|
||||
"@trpc/tanstack-react-query": "^11.1.0",
|
||||
"@tanstack/react-query": "^5.75.0",
|
||||
"react": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/react": "^19.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { ApiProvider } from "./provider";
|
||||
export { useTRPC } from "./trpc";
|
||||
export { getQueryClient } from "./query-client";
|
||||
@@ -1,26 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createTRPCClient, httpBatchLink } from "@trpc/client";
|
||||
import type { AppRouter } from "@repo/api";
|
||||
import { TRPCProvider } from "./trpc";
|
||||
import { getQueryClient } from "./query-client";
|
||||
|
||||
export function ApiProvider({
|
||||
children,
|
||||
trpcUrl,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
trpcUrl: string;
|
||||
}) {
|
||||
const queryClient = getQueryClient();
|
||||
const trpcClient = createTRPCClient<AppRouter>({
|
||||
links: [httpBatchLink({ url: trpcUrl })],
|
||||
});
|
||||
|
||||
return (
|
||||
<TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</TRPCProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
let clientQueryClient: QueryClient | undefined;
|
||||
|
||||
export function getQueryClient(): QueryClient {
|
||||
if (typeof window === "undefined") {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { staleTime: 30 * 1000 },
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!clientQueryClient) {
|
||||
clientQueryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { staleTime: 30 * 1000 },
|
||||
},
|
||||
});
|
||||
}
|
||||
return clientQueryClient;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { createTRPCContext } from "@trpc/tanstack-react-query";
|
||||
import type { AppRouter } from "@repo/api";
|
||||
|
||||
export const { TRPCProvider, useTRPC } = createTRPCContext<AppRouter>();
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
# @repo/api -- tRPC v11 Router Definitions
|
||||
|
||||
## Purpose
|
||||
|
||||
This package defines all tRPC v11 routers for the monorepo. Each router validates input with Zod and delegates execution to controllers in `@repo/core`. Routers are the **only** entry point for client-side RPC calls. They contain zero business logic.
|
||||
|
||||
## Hard Rules
|
||||
|
||||
- **NEVER** put business logic in routers -- always delegate to a controller from `@repo/core`
|
||||
- **NEVER** import from `@repo/core/infrastructure` or any `apps/*` package
|
||||
- Input validation uses Zod schemas inline on each procedure
|
||||
- Each domain gets its own `{domain}.router.ts` file
|
||||
- Use `.query()` for reads (GET-like), `.mutation()` for writes (POST/PUT/DELETE-like)
|
||||
- All procedures call exactly one controller function from `@repo/core`
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
packages/api/
|
||||
src/
|
||||
trpc.ts # tRPC initialization, exports router + publicProcedure
|
||||
router/
|
||||
index.ts # appRouter composition, exports AppRouter type
|
||||
auth.router.ts # Auth domain: signIn, signUp, signOut
|
||||
content.router.ts # Content domain: listArticles, createArticle
|
||||
index.ts # Package entry: re-exports appRouter + AppRouter type
|
||||
package.json
|
||||
AGENTS.md
|
||||
```
|
||||
|
||||
## How Procedures Map to Controllers
|
||||
|
||||
| Procedure type | HTTP equivalent | When to use | Example |
|
||||
|---|---|---|---|
|
||||
| `.query()` | GET | Fetching/reading data | `content.listArticles` |
|
||||
| `.mutation()` | POST/PATCH/DELETE | Creating, updating, deleting data | `auth.signIn`, `content.createArticle` |
|
||||
|
||||
Every procedure follows the same pattern:
|
||||
|
||||
```typescript
|
||||
myProcedure: publicProcedure
|
||||
.input(z.object({ /* Zod schema */ }))
|
||||
.query(async ({ input }) => { // or .mutation()
|
||||
return await myController(input); // delegate to @repo/core controller
|
||||
}),
|
||||
```
|
||||
|
||||
## Existing Routers
|
||||
|
||||
### auth.router.ts
|
||||
|
||||
| Procedure | Type | Input | Controller |
|
||||
|---|---|---|---|
|
||||
| `signIn` | mutation | `{ username: string, password: string }` | `signInController` |
|
||||
| `signUp` | mutation | `{ username: string, password: string, confirmPassword: string }` | `signUpController` |
|
||||
| `signOut` | mutation | `{ sessionId: string }` | `signOutController` |
|
||||
|
||||
### content.router.ts
|
||||
|
||||
| Procedure | Type | Input | Controller |
|
||||
|---|---|---|---|
|
||||
| `listArticles` | query | `{ status?, authorId?, limit?, offset? }` (optional) | `getArticlesController` |
|
||||
| `createArticle` | mutation | `{ title: string, content: string, authorId: string, slug?: string }` | `createArticleController` |
|
||||
|
||||
## Recipe: Adding a New tRPC Router
|
||||
|
||||
This example adds a `comments` domain router with `listComments` (query) and `createComment` (mutation).
|
||||
|
||||
### Step 1: Create the router file
|
||||
|
||||
Create `src/router/comments.router.ts`:
|
||||
|
||||
```typescript
|
||||
import { z } from "zod";
|
||||
import { router, publicProcedure } from "../trpc";
|
||||
import {
|
||||
getCommentsController,
|
||||
createCommentController,
|
||||
} from "@repo/core";
|
||||
|
||||
export const commentsRouter = router({
|
||||
listComments: publicProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
articleId: z.string(),
|
||||
limit: z.number().optional(),
|
||||
offset: z.number().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
return await getCommentsController(input);
|
||||
}),
|
||||
|
||||
createComment: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
articleId: z.string(),
|
||||
authorId: z.string(),
|
||||
body: z.string().min(1).max(2000),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
return await createCommentController(input);
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
### Step 2: Register in the appRouter
|
||||
|
||||
Edit `src/router/index.ts`:
|
||||
|
||||
```typescript
|
||||
import { router } from "../trpc";
|
||||
import { authRouter } from "./auth.router";
|
||||
import { contentRouter } from "./content.router";
|
||||
import { commentsRouter } from "./comments.router"; // <-- add import
|
||||
|
||||
export const appRouter = router({
|
||||
auth: authRouter,
|
||||
content: contentRouter,
|
||||
comments: commentsRouter, // <-- register here
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
```
|
||||
|
||||
### Step 3: Verify
|
||||
|
||||
The `AppRouter` type is automatically inferred. Any app using `@repo/api-client` will immediately see `trpc.comments.listComments.useQuery(...)` and `trpc.comments.createComment.useMutation(...)` with full type safety -- no code generation step needed.
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
|---|---|
|
||||
| `@repo/core` | Controllers that contain business logic |
|
||||
| `@trpc/server` | tRPC v11 server-side primitives |
|
||||
| `zod` | Runtime input validation schemas |
|
||||
|
||||
## Cross-References
|
||||
|
||||
- **Controllers live in:** `packages/core/src/interface-adapters/controllers/` -- see `packages/core/AGENTS.md`
|
||||
- **Client consumption:** `packages/api-client/` -- see `packages/api-client/AGENTS.md`
|
||||
- **HTTP endpoint:** `apps/web-next/src/app/api/trpc/[trpc]/route.ts` uses the fetch adapter to serve `appRouter`
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"name": "@repo/api",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"build": "echo 'typechecked by consuming app bundler'",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/core": "workspace:*",
|
||||
"@trpc/server": "^11.1.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/node": "^22.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { appRouter, type AppRouter } from "./router/index";
|
||||
@@ -1,38 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { router, publicProcedure } from "../trpc";
|
||||
import {
|
||||
signInController,
|
||||
signUpController,
|
||||
signOutController,
|
||||
} from "@repo/core";
|
||||
|
||||
export const authRouter = router({
|
||||
signIn: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
username: z.string().min(3).max(31),
|
||||
password: z.string().min(6).max(255),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
return await signInController(input);
|
||||
}),
|
||||
|
||||
signUp: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
username: z.string().min(3).max(31),
|
||||
password: z.string().min(6).max(255),
|
||||
confirmPassword: z.string().min(6).max(255),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
return await signUpController(input);
|
||||
}),
|
||||
|
||||
signOut: publicProcedure
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
return await signOutController(input.sessionId);
|
||||
}),
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { router, publicProcedure } from "../trpc";
|
||||
import { createArticleController, getArticlesController } from "@repo/core";
|
||||
|
||||
export const contentRouter = router({
|
||||
listArticles: publicProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
status: z.string().optional(),
|
||||
authorId: z.string().optional(),
|
||||
limit: z.number().optional(),
|
||||
offset: z.number().optional(),
|
||||
})
|
||||
.optional()
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
return await getArticlesController(input ?? {});
|
||||
}),
|
||||
|
||||
createArticle: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
title: z.string().min(1).max(255),
|
||||
content: z.string(),
|
||||
authorId: z.string(),
|
||||
slug: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
return await createArticleController(input);
|
||||
}),
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
import { router } from "../trpc";
|
||||
import { authRouter } from "./auth.router";
|
||||
import { contentRouter } from "./content.router";
|
||||
|
||||
export const appRouter = router({
|
||||
auth: authRouter,
|
||||
content: contentRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
@@ -1,6 +0,0 @@
|
||||
import { initTRPC } from "@trpc/server";
|
||||
|
||||
const t = initTRPC.create();
|
||||
|
||||
export const router = t.router;
|
||||
export const publicProcedure = t.procedure;
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
# @repo/cms-client -- Dual-Mode Payload Client
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides a typed, uniform interface for accessing Payload CMS data via either the Local API (direct in-process calls) or the HTTP REST API (network requests). The consuming app chooses the mode at startup and injects the Payload instance -- this package never imports it.
|
||||
|
||||
## **NEVER import from `@repo/cms-core`, `@repo/core`, or any `apps/*` package.**
|
||||
|
||||
This package is completely standalone. The Payload instance is INJECTED at app startup, never imported by this package.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
packages/cms-client/
|
||||
src/
|
||||
types.ts # FindOptions, PayloadClientResult, PayloadClient interface
|
||||
client.ts # createPayloadClient() factory -- returns Local or HTTP client
|
||||
local-client.ts # LocalPayloadClient class -- wraps Payload Local API
|
||||
http-client.ts # HTTPPayloadClient class -- wraps Payload REST API via fetch
|
||||
index.ts # Package entry: re-exports factory, classes, and types
|
||||
package.json
|
||||
AGENTS.md
|
||||
```
|
||||
|
||||
## Dual-Mode Initialization
|
||||
|
||||
### Local Mode (primary -- used for server-side code with direct DB access)
|
||||
|
||||
Local mode wraps the Payload Local API. It requires a live `Payload` instance, which is obtained via `getPayload()` in the consuming app:
|
||||
|
||||
```typescript
|
||||
// Example: apps/web-next/src/lib/payload.ts
|
||||
import { getPayload } from "payload";
|
||||
import config from "@repo/cms-core/src/payload.config";
|
||||
import { createPayloadClient } from "@repo/cms-client";
|
||||
|
||||
const payload = await getPayload({ config });
|
||||
const client = createPayloadClient({ mode: "local", payload });
|
||||
|
||||
// Now use client.find(), client.create(), etc.
|
||||
const articles = await client.find("articles", {
|
||||
where: { status: { equals: "published" } },
|
||||
sort: "-publishedAt",
|
||||
limit: 10,
|
||||
});
|
||||
```
|
||||
|
||||
### HTTP Mode (fallback -- used for external services without direct DB access)
|
||||
|
||||
HTTP mode makes REST calls to the Payload API. It only needs the base URL:
|
||||
|
||||
```typescript
|
||||
import { createPayloadClient } from "@repo/cms-client";
|
||||
|
||||
const client = createPayloadClient({
|
||||
mode: "http",
|
||||
baseURL: "http://localhost:3001",
|
||||
});
|
||||
|
||||
// Same API surface as local mode
|
||||
const articles = await client.find("articles", {
|
||||
where: { status: { equals: "published" } },
|
||||
limit: 10,
|
||||
});
|
||||
```
|
||||
|
||||
## Initialization Table
|
||||
|
||||
| App / Context | Mode | How initialized | Why this mode |
|
||||
|---|---|---|---|
|
||||
| `apps/cms` (server-side) | Local | `getPayload({ config })` from `@repo/cms-core` | Same process as Payload, direct DB access |
|
||||
| `apps/web-next` (server components/actions) | Local | `getPayload({ config })` from `@repo/cms-core` | Server-side rendering needs fast DB access |
|
||||
| `apps/web-tanstack` (server loaders) | Local | `getPayload({ config })` from `@repo/cms-core` | Server-side data loading needs fast DB access |
|
||||
| Client-side (browser) | N/A | Does not use this package directly | Browser goes through tRPC, server handles CMS access |
|
||||
| External services / microservices | HTTP | `createPayloadClient({ mode: "http", baseURL })` | No access to Payload instance, only REST API |
|
||||
|
||||
## PayloadClient API Reference
|
||||
|
||||
All methods are available on both Local and HTTP clients via the `PayloadClient` interface.
|
||||
|
||||
### `find<T>(collection, options?): Promise<PayloadClientResult<T>>`
|
||||
|
||||
Paginated query for documents in a collection.
|
||||
|
||||
```typescript
|
||||
const result = await client.find<Article>("articles", {
|
||||
where: { status: { equals: "published" } },
|
||||
sort: "-publishedAt",
|
||||
limit: 10,
|
||||
page: 1,
|
||||
depth: 2,
|
||||
locale: "en",
|
||||
});
|
||||
// result.docs, result.totalDocs, result.totalPages, etc.
|
||||
```
|
||||
|
||||
### `findByID<T>(collection, id, options?): Promise<T>`
|
||||
|
||||
Fetch a single document by ID.
|
||||
|
||||
```typescript
|
||||
const article = await client.findByID<Article>("articles", "abc123", {
|
||||
depth: 2,
|
||||
});
|
||||
```
|
||||
|
||||
### `create<T>(collection, data, options?): Promise<T>`
|
||||
|
||||
Create a new document.
|
||||
|
||||
```typescript
|
||||
const newArticle = await client.create<Article>("articles", {
|
||||
title: "My Article",
|
||||
content: "...",
|
||||
author: "user-id-123",
|
||||
status: "draft",
|
||||
}, { depth: 1 });
|
||||
```
|
||||
|
||||
### `update<T>(collection, id, data, options?): Promise<T>`
|
||||
|
||||
Update an existing document (partial update).
|
||||
|
||||
```typescript
|
||||
const updated = await client.update<Article>("articles", "abc123", {
|
||||
status: "published",
|
||||
publishedAt: new Date().toISOString(),
|
||||
});
|
||||
```
|
||||
|
||||
### `delete<T>(collection, id): Promise<T>`
|
||||
|
||||
Delete a document by ID.
|
||||
|
||||
```typescript
|
||||
const deleted = await client.delete<Article>("articles", "abc123");
|
||||
```
|
||||
|
||||
## FindOptions Interface
|
||||
|
||||
```typescript
|
||||
interface FindOptions {
|
||||
where?: Record<string, unknown>; // Payload query operators ({ field: { equals: value } })
|
||||
sort?: string; // Field name, prefix with "-" for descending
|
||||
limit?: number; // Max documents per page (default: 10)
|
||||
page?: number; // Page number (1-based)
|
||||
depth?: number; // Relationship population depth (default: 1)
|
||||
locale?: string; // Locale for localized fields
|
||||
}
|
||||
```
|
||||
|
||||
## PayloadClientResult Interface
|
||||
|
||||
```typescript
|
||||
interface PayloadClientResult<T> {
|
||||
docs: T[]; // Array of documents for current page
|
||||
totalDocs: number; // Total matching documents across all pages
|
||||
limit: number; // Max docs per page (as requested)
|
||||
totalPages: number; // Total number of pages
|
||||
page: number; // Current page number (1-based)
|
||||
pagingCounter: number; // Index of first doc on current page
|
||||
hasPrevPage: boolean; // Whether a previous page exists
|
||||
hasNextPage: boolean; // Whether a next page exists
|
||||
prevPage: number | null; // Previous page number, or null
|
||||
nextPage: number | null; // Next page number, or null
|
||||
}
|
||||
```
|
||||
|
||||
## App Startup Pattern
|
||||
|
||||
The Payload instance is always created in the consuming app, then injected into the client:
|
||||
|
||||
```typescript
|
||||
// apps/web-next/src/lib/payload.ts
|
||||
import { getPayload } from "payload";
|
||||
import config from "@repo/cms-core/src/payload.config";
|
||||
import { createPayloadClient, type PayloadClient } from "@repo/cms-client";
|
||||
|
||||
let cachedClient: PayloadClient | null = null;
|
||||
|
||||
export async function getPayloadClient(): Promise<PayloadClient> {
|
||||
if (cachedClient) return cachedClient;
|
||||
|
||||
const payload = await getPayload({ config });
|
||||
cachedClient = createPayloadClient({ mode: "local", payload });
|
||||
return cachedClient;
|
||||
}
|
||||
```
|
||||
|
||||
This pattern ensures:
|
||||
1. The Payload instance is created once and reused
|
||||
2. The cms-client package never imports config or Payload itself
|
||||
3. Each app controls its own initialization
|
||||
|
||||
## Type Generation
|
||||
|
||||
Payload generates TypeScript types from your collection/global definitions:
|
||||
|
||||
```bash
|
||||
cd apps/cms && pnpm generate:types
|
||||
# Runs: payload generate:types
|
||||
# Outputs to: packages/cms-core/src/payload-types.ts (configured in payload.config.ts)
|
||||
```
|
||||
|
||||
After adding or modifying collections/globals in `@repo/cms-core`, re-run type generation to keep types in sync.
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
|---|---|
|
||||
| `payload` | `Payload` type for the Local API client constructor (type-only at build time) |
|
||||
|
||||
## Cross-References
|
||||
|
||||
- **CMS configuration:** `packages/cms-core/` -- see `packages/cms-core/AGENTS.md`
|
||||
- **CMS app (where getPayload is called):** `apps/cms/` -- see `apps/cms/AGENTS.md`
|
||||
- **Core use cases (consumers of this client):** `packages/core/` -- see `packages/core/AGENTS.md`
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "@repo/cms-client",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"payload": "^3.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/node": "^22.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { Payload } from "payload";
|
||||
import type { PayloadClient } from "./types";
|
||||
import { LocalPayloadClient } from "./local-client";
|
||||
import { HTTPPayloadClient } from "./http-client";
|
||||
|
||||
type PayloadClientOptions =
|
||||
| { mode: "local"; payload: Payload }
|
||||
| { mode: "http"; baseURL: string };
|
||||
|
||||
export function createPayloadClient(
|
||||
options: PayloadClientOptions
|
||||
): PayloadClient {
|
||||
if (options.mode === "local") {
|
||||
return new LocalPayloadClient(options.payload);
|
||||
}
|
||||
return new HTTPPayloadClient(options.baseURL);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import type {
|
||||
FindOptions,
|
||||
PayloadClient,
|
||||
PayloadClientResult,
|
||||
} from "./types";
|
||||
|
||||
export class HTTPPayloadClient implements PayloadClient {
|
||||
constructor(private baseURL: string) {}
|
||||
|
||||
private async request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${this.baseURL}${path}`, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...options,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Payload API error: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async find<T = Record<string, unknown>>(
|
||||
collection: string,
|
||||
options?: FindOptions
|
||||
): Promise<PayloadClientResult<T>> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.limit) params.set("limit", String(options.limit));
|
||||
if (options?.page) params.set("page", String(options.page));
|
||||
if (options?.sort) params.set("sort", options.sort);
|
||||
if (options?.depth) params.set("depth", String(options.depth));
|
||||
if (options?.where) params.set("where", JSON.stringify(options.where));
|
||||
const query = params.toString();
|
||||
return this.request<PayloadClientResult<T>>(
|
||||
`/api/${collection}${query ? `?${query}` : ""}`
|
||||
);
|
||||
}
|
||||
|
||||
async findByID<T = Record<string, unknown>>(
|
||||
collection: string,
|
||||
id: string,
|
||||
options?: { depth?: number }
|
||||
): Promise<T> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.depth) params.set("depth", String(options.depth));
|
||||
const query = params.toString();
|
||||
return this.request<T>(
|
||||
`/api/${collection}/${id}${query ? `?${query}` : ""}`
|
||||
);
|
||||
}
|
||||
|
||||
async create<T = Record<string, unknown>>(
|
||||
collection: string,
|
||||
data: Record<string, unknown>,
|
||||
options?: { depth?: number }
|
||||
): Promise<T> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.depth) params.set("depth", String(options.depth));
|
||||
const query = params.toString();
|
||||
return this.request<T>(
|
||||
`/api/${collection}${query ? `?${query}` : ""}`,
|
||||
{ method: "POST", body: JSON.stringify(data) }
|
||||
);
|
||||
}
|
||||
|
||||
async update<T = Record<string, unknown>>(
|
||||
collection: string,
|
||||
id: string,
|
||||
data: Record<string, unknown>,
|
||||
options?: { depth?: number }
|
||||
): Promise<T> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.depth) params.set("depth", String(options.depth));
|
||||
const query = params.toString();
|
||||
return this.request<T>(
|
||||
`/api/${collection}/${id}${query ? `?${query}` : ""}`,
|
||||
{ method: "PATCH", body: JSON.stringify(data) }
|
||||
);
|
||||
}
|
||||
|
||||
async delete<T = Record<string, unknown>>(
|
||||
collection: string,
|
||||
id: string
|
||||
): Promise<T> {
|
||||
return this.request<T>(`/api/${collection}/${id}`, { method: "DELETE" });
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export { createPayloadClient } from "./client";
|
||||
export { LocalPayloadClient } from "./local-client";
|
||||
export { HTTPPayloadClient } from "./http-client";
|
||||
export type {
|
||||
PayloadClient,
|
||||
PayloadClientResult,
|
||||
FindOptions,
|
||||
} from "./types";
|
||||
@@ -1,78 +0,0 @@
|
||||
import type { Payload } from "payload";
|
||||
import type {
|
||||
FindOptions,
|
||||
PayloadClient,
|
||||
PayloadClientResult,
|
||||
} from "./types";
|
||||
|
||||
export class LocalPayloadClient implements PayloadClient {
|
||||
constructor(private payload: Payload) {}
|
||||
|
||||
async find<T = Record<string, unknown>>(
|
||||
collection: string,
|
||||
options?: FindOptions
|
||||
): Promise<PayloadClientResult<T>> {
|
||||
const result = await this.payload.find({
|
||||
collection: collection as any,
|
||||
where: options?.where as any,
|
||||
sort: options?.sort,
|
||||
limit: options?.limit,
|
||||
page: options?.page,
|
||||
depth: options?.depth,
|
||||
locale: options?.locale as any,
|
||||
});
|
||||
return result as unknown as PayloadClientResult<T>;
|
||||
}
|
||||
|
||||
async findByID<T = Record<string, unknown>>(
|
||||
collection: string,
|
||||
id: string,
|
||||
options?: { depth?: number }
|
||||
): Promise<T> {
|
||||
const result = await this.payload.findByID({
|
||||
collection: collection as any,
|
||||
id,
|
||||
depth: options?.depth,
|
||||
});
|
||||
return result as unknown as T;
|
||||
}
|
||||
|
||||
async create<T = Record<string, unknown>>(
|
||||
collection: string,
|
||||
data: Record<string, unknown>,
|
||||
options?: { depth?: number }
|
||||
): Promise<T> {
|
||||
const result = await this.payload.create({
|
||||
collection: collection as any,
|
||||
data: data as any,
|
||||
depth: options?.depth,
|
||||
});
|
||||
return result as unknown as T;
|
||||
}
|
||||
|
||||
async update<T = Record<string, unknown>>(
|
||||
collection: string,
|
||||
id: string,
|
||||
data: Record<string, unknown>,
|
||||
options?: { depth?: number }
|
||||
): Promise<T> {
|
||||
const result = await this.payload.update({
|
||||
collection: collection as any,
|
||||
id,
|
||||
data: data as any,
|
||||
depth: options?.depth,
|
||||
});
|
||||
return result as unknown as T;
|
||||
}
|
||||
|
||||
async delete<T = Record<string, unknown>>(
|
||||
collection: string,
|
||||
id: string
|
||||
): Promise<T> {
|
||||
const result = await this.payload.delete({
|
||||
collection: collection as any,
|
||||
id,
|
||||
});
|
||||
return result as unknown as T;
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
export interface FindOptions {
|
||||
where?: Record<string, unknown>;
|
||||
sort?: string;
|
||||
limit?: number;
|
||||
page?: number;
|
||||
depth?: number;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export interface PayloadClientResult<T> {
|
||||
docs: T[];
|
||||
totalDocs: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
page: number;
|
||||
pagingCounter: number;
|
||||
hasPrevPage: boolean;
|
||||
hasNextPage: boolean;
|
||||
prevPage: number | null;
|
||||
nextPage: number | null;
|
||||
}
|
||||
|
||||
export interface PayloadClient {
|
||||
find<T = Record<string, unknown>>(
|
||||
collection: string,
|
||||
options?: FindOptions
|
||||
): Promise<PayloadClientResult<T>>;
|
||||
|
||||
findByID<T = Record<string, unknown>>(
|
||||
collection: string,
|
||||
id: string,
|
||||
options?: { depth?: number }
|
||||
): Promise<T>;
|
||||
|
||||
create<T = Record<string, unknown>>(
|
||||
collection: string,
|
||||
data: Record<string, unknown>,
|
||||
options?: { depth?: number }
|
||||
): Promise<T>;
|
||||
|
||||
update<T = Record<string, unknown>>(
|
||||
collection: string,
|
||||
id: string,
|
||||
data: Record<string, unknown>,
|
||||
options?: { depth?: number }
|
||||
): Promise<T>;
|
||||
|
||||
delete<T = Record<string, unknown>>(
|
||||
collection: string,
|
||||
id: string
|
||||
): Promise<T>;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
# @repo/cms-core -- ALL Payload CMS Configuration
|
||||
|
||||
## Purpose
|
||||
|
||||
This package contains **all** Payload CMS configuration: `payload.config.ts`, collections, globals, hooks, and access control. The `apps/cms` application is a thin shell that imports from this package -- it contains no CMS logic of its own.
|
||||
|
||||
## Hard Rules
|
||||
|
||||
- **ALL** collections, globals, hooks, and access patterns live here, **never** in `apps/cms`
|
||||
- Can import from `@repo/core/application` (for use case delegation in hooks)
|
||||
- **NEVER** import from `@repo/core/infrastructure`
|
||||
- **NEVER** import from any `apps/*` package
|
||||
- Keep hooks thin (5-10 lines max) -- delegate to use cases for business logic
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
packages/cms-core/
|
||||
src/
|
||||
payload.config.ts # Main Payload config (db, editor, collections, globals)
|
||||
collections/
|
||||
users/
|
||||
index.ts # Users CollectionConfig (auth: true)
|
||||
articles/
|
||||
index.ts # Articles CollectionConfig (versions, hooks)
|
||||
fields.ts # Article field definitions
|
||||
hooks/
|
||||
before-change.ts # Auto-generate slug from title
|
||||
media/
|
||||
index.ts # Media CollectionConfig (upload)
|
||||
globals/
|
||||
site-settings.ts # SiteSettings GlobalConfig
|
||||
index.ts # Package entry: exports all configs
|
||||
package.json
|
||||
AGENTS.md
|
||||
```
|
||||
|
||||
## Existing Collections
|
||||
|
||||
### Users (auth collection)
|
||||
|
||||
- **Slug:** `users`
|
||||
- **Auth:** `true` (provides login, password hashing, session management)
|
||||
- **Fields:** `displayName` (text), `role` (select: admin/editor/author, default: author)
|
||||
- **Admin title:** email
|
||||
|
||||
### Articles (content with versioning)
|
||||
|
||||
- **Slug:** `articles`
|
||||
- **Versions:** `{ drafts: true }` -- enables draft/published workflow
|
||||
- **Hooks:** `beforeChange: [autoGenerateSlug]` -- generates slug from title if slug is empty
|
||||
- **Fields:** title (text, required), slug (text, unique, sidebar), content (richText), status (select: draft/published, default: draft), author (relationship to users, required), featuredImage (upload to media), publishedAt (date)
|
||||
- **Admin title:** title, default columns: title, status, author, updatedAt
|
||||
|
||||
### Media (file uploads)
|
||||
|
||||
- **Slug:** `media`
|
||||
- **Upload:** Accepts `image/*` and `application/pdf`
|
||||
- **Fields:** `alt` (text, required)
|
||||
- **Admin title:** filename
|
||||
|
||||
## Existing Globals
|
||||
|
||||
### SiteSettings
|
||||
|
||||
- **Slug:** `site-settings`
|
||||
- **Admin group:** Settings
|
||||
- **Fields:** `siteName` (text, required, default: "My App"), `siteDescription` (textarea)
|
||||
|
||||
## Hook Rules
|
||||
|
||||
| Category | Where it lives | Description | Examples |
|
||||
|---|---|---|---|
|
||||
| CMS-operational | Stays in the hook file | Logic tied to CMS data shaping, not business rules | Slug generation, image resizing, setting default values, formatting fields |
|
||||
| Business logic | Delegated to `@repo/core` use case | Logic that enforces a business rule or triggers side effects | Sending notifications, validating against external data, cross-domain updates |
|
||||
|
||||
### DO
|
||||
|
||||
- Keep hooks to 5-10 lines max
|
||||
- Import use cases from `@repo/core/application` (application layer only)
|
||||
- Map Payload hook arguments (`data`, `operation`, `req`) to use case input types
|
||||
- Return `data` from `beforeChange` / `beforeValidate` hooks
|
||||
|
||||
### DON'T
|
||||
|
||||
- Import from `@repo/core/infrastructure` -- violates Clean Architecture
|
||||
- Put business validation logic directly in hooks
|
||||
- Call external services (email, analytics, APIs) directly from hooks
|
||||
- Duplicate logic that already exists in a use case
|
||||
- Access `req.payload` for cross-collection operations (delegate to use case instead)
|
||||
|
||||
**Rule of thumb:** If deleting the hook would break a business requirement, the logic must live in a use case in `@repo/core`.
|
||||
|
||||
## Recipe: Adding a New Collection
|
||||
|
||||
This example adds a `Tags` collection.
|
||||
|
||||
### Step 1: Create the collection folder and fields
|
||||
|
||||
Create `src/collections/tags/fields.ts`:
|
||||
|
||||
```typescript
|
||||
import type { Field } from "payload";
|
||||
|
||||
export const tagFields: Field[] = [
|
||||
{
|
||||
name: "name",
|
||||
type: "text",
|
||||
required: true,
|
||||
unique: true,
|
||||
maxLength: 100,
|
||||
},
|
||||
{
|
||||
name: "slug",
|
||||
type: "text",
|
||||
unique: true,
|
||||
admin: {
|
||||
position: "sidebar",
|
||||
description: "Auto-generated from name if left empty",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
type: "textarea",
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### Step 2: Create the CollectionConfig
|
||||
|
||||
Create `src/collections/tags/index.ts`:
|
||||
|
||||
```typescript
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { tagFields } from "./fields";
|
||||
import { autoGenerateSlug } from "./hooks/before-change";
|
||||
|
||||
export const Tags: CollectionConfig = {
|
||||
slug: "tags",
|
||||
admin: {
|
||||
useAsTitle: "name",
|
||||
defaultColumns: ["name", "slug", "updatedAt"],
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [autoGenerateSlug],
|
||||
},
|
||||
fields: tagFields,
|
||||
};
|
||||
```
|
||||
|
||||
### Step 3: Add a CMS-operational hook (slug generation)
|
||||
|
||||
Create `src/collections/tags/hooks/before-change.ts`:
|
||||
|
||||
```typescript
|
||||
import type { CollectionBeforeChangeHook } from "payload";
|
||||
|
||||
function generateSlug(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
export const autoGenerateSlug: CollectionBeforeChangeHook = ({
|
||||
data,
|
||||
operation,
|
||||
}) => {
|
||||
if (operation === "create" || operation === "update") {
|
||||
if (data && data.name && !data.slug) {
|
||||
data.slug = generateSlug(data.name);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
};
|
||||
```
|
||||
|
||||
### Step 4: (Optional) Add a business-logic hook delegating to a use case
|
||||
|
||||
Create `src/collections/tags/hooks/after-change.ts`:
|
||||
|
||||
```typescript
|
||||
import type { CollectionAfterChangeHook } from "payload";
|
||||
import { syncTagToSearchIndex } from "@repo/core/application";
|
||||
|
||||
export const syncTagAfterChange: CollectionAfterChangeHook = async ({
|
||||
doc,
|
||||
operation,
|
||||
}) => {
|
||||
// Delegate to use case -- this hook is just a thin bridge
|
||||
await syncTagToSearchIndex({
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
slug: doc.slug,
|
||||
operation,
|
||||
});
|
||||
return doc;
|
||||
};
|
||||
```
|
||||
|
||||
### Step 5: (Optional) Add access control
|
||||
|
||||
Create `src/collections/tags/access/is-admin.ts`:
|
||||
|
||||
```typescript
|
||||
import type { Access } from "payload";
|
||||
|
||||
export const isAdmin: Access = ({ req: { user } }) => {
|
||||
return user?.role === "admin";
|
||||
};
|
||||
```
|
||||
|
||||
Then reference it in the CollectionConfig:
|
||||
|
||||
```typescript
|
||||
export const Tags: CollectionConfig = {
|
||||
slug: "tags",
|
||||
access: {
|
||||
create: isAdmin,
|
||||
update: isAdmin,
|
||||
delete: isAdmin,
|
||||
// read is open by default
|
||||
},
|
||||
// ...rest
|
||||
};
|
||||
```
|
||||
|
||||
### Step 6: Register in payload.config.ts
|
||||
|
||||
Edit `src/payload.config.ts`:
|
||||
|
||||
```typescript
|
||||
import { Tags } from "./collections/tags";
|
||||
|
||||
export default buildConfig({
|
||||
collections: [Users, Articles, Media, Tags], // <-- add Tags
|
||||
// ...rest
|
||||
});
|
||||
```
|
||||
|
||||
### Step 7: Export from package entry
|
||||
|
||||
Edit `src/index.ts`:
|
||||
|
||||
```typescript
|
||||
export { Tags } from "./collections/tags"; // <-- add export
|
||||
```
|
||||
|
||||
## Recipe: Adding a New Global
|
||||
|
||||
This example adds a `Navigation` global.
|
||||
|
||||
### Step 1: Create the GlobalConfig
|
||||
|
||||
Create `src/globals/navigation.ts`:
|
||||
|
||||
```typescript
|
||||
import type { GlobalConfig } from "payload";
|
||||
|
||||
export const Navigation: GlobalConfig = {
|
||||
slug: "navigation",
|
||||
admin: {
|
||||
group: "Settings",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "mainMenu",
|
||||
type: "array",
|
||||
fields: [
|
||||
{
|
||||
name: "label",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "url",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
### Step 2: Register in payload.config.ts
|
||||
|
||||
```typescript
|
||||
import { Navigation } from "./globals/navigation";
|
||||
|
||||
export default buildConfig({
|
||||
globals: [SiteSettings, Navigation], // <-- add Navigation
|
||||
// ...rest
|
||||
});
|
||||
```
|
||||
|
||||
### Step 3: Export from package entry
|
||||
|
||||
```typescript
|
||||
export { Navigation } from "./globals/navigation"; // <-- add export
|
||||
```
|
||||
|
||||
## Payload Config Overview
|
||||
|
||||
The `payload.config.ts` uses:
|
||||
- **Database:** `@payloadcms/db-postgres` (PostgreSQL via `DATABASE_URL` env var)
|
||||
- **Editor:** `@payloadcms/richtext-lexical` (Lexical rich text editor)
|
||||
- **Secret:** `PAYLOAD_SECRET` env var (required for production)
|
||||
- **TypeScript output:** Generates `payload-types.ts` in this package's `src/` directory
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
|---|---|
|
||||
| `payload` | Payload CMS core |
|
||||
| `@payloadcms/db-postgres` | PostgreSQL database adapter |
|
||||
| `@payloadcms/richtext-lexical` | Lexical rich text editor |
|
||||
|
||||
## Cross-References
|
||||
|
||||
- **CMS app shell:** `apps/cms/` -- see `apps/cms/AGENTS.md`
|
||||
- **Use cases for hooks:** `packages/core/src/application/use-cases/` -- see `packages/core/AGENTS.md`
|
||||
- **CMS client for querying:** `packages/cms-client/` -- see `packages/cms-client/AGENTS.md`
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"name": "@repo/cms-core",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"payload": "^3.14.0",
|
||||
"@payloadcms/db-postgres": "^3.14.0",
|
||||
"@payloadcms/richtext-lexical": "^3.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/node": "^22.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { Field } from "payload";
|
||||
|
||||
export const articleFields: Field[] = [
|
||||
{
|
||||
name: "title",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 255,
|
||||
},
|
||||
{
|
||||
name: "slug",
|
||||
type: "text",
|
||||
unique: true,
|
||||
admin: {
|
||||
position: "sidebar",
|
||||
description: "Auto-generated from title if left empty",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "content",
|
||||
type: "richText",
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Draft", value: "draft" },
|
||||
{ label: "Published", value: "published" },
|
||||
],
|
||||
defaultValue: "draft",
|
||||
required: true,
|
||||
admin: {
|
||||
position: "sidebar",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "author",
|
||||
type: "relationship",
|
||||
relationTo: "users",
|
||||
required: true,
|
||||
admin: {
|
||||
position: "sidebar",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "featuredImage",
|
||||
type: "upload",
|
||||
relationTo: "media",
|
||||
},
|
||||
{
|
||||
name: "publishedAt",
|
||||
type: "date",
|
||||
admin: {
|
||||
position: "sidebar",
|
||||
date: {
|
||||
pickerAppearance: "dayAndTime",
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { CollectionBeforeChangeHook } from "payload";
|
||||
|
||||
function generateSlug(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
export const autoGenerateSlug: CollectionBeforeChangeHook = ({
|
||||
data,
|
||||
operation,
|
||||
}) => {
|
||||
if (operation === "create" || operation === "update") {
|
||||
if (data && data.title && !data.slug) {
|
||||
data.slug = generateSlug(data.title);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
|
||||
import { articleFields } from "./fields";
|
||||
import { autoGenerateSlug } from "./hooks/before-change";
|
||||
|
||||
export const Articles: CollectionConfig = {
|
||||
slug: "articles",
|
||||
admin: {
|
||||
useAsTitle: "title",
|
||||
defaultColumns: ["title", "status", "author", "updatedAt"],
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [autoGenerateSlug],
|
||||
},
|
||||
versions: {
|
||||
drafts: true,
|
||||
},
|
||||
fields: articleFields,
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
|
||||
export const Media: CollectionConfig = {
|
||||
slug: "media",
|
||||
upload: {
|
||||
mimeTypes: ["image/*", "application/pdf"],
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: "filename",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "alt",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: "users",
|
||||
auth: true,
|
||||
admin: {
|
||||
useAsTitle: "email",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "displayName",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
name: "role",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Admin", value: "admin" },
|
||||
{ label: "Editor", value: "editor" },
|
||||
{ label: "Author", value: "author" },
|
||||
],
|
||||
defaultValue: "author",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { GlobalConfig } from "payload";
|
||||
|
||||
export const SiteSettings: GlobalConfig = {
|
||||
slug: "site-settings",
|
||||
admin: {
|
||||
group: "Settings",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "siteName",
|
||||
type: "text",
|
||||
required: true,
|
||||
defaultValue: "My App",
|
||||
},
|
||||
{
|
||||
name: "siteDescription",
|
||||
type: "textarea",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
export { Users } from "./collections/users";
|
||||
export { Articles } from "./collections/articles";
|
||||
export { Media } from "./collections/media";
|
||||
export { SiteSettings } from "./globals/site-settings";
|
||||
export { default as config } from "./payload.config";
|
||||
@@ -1,425 +0,0 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* This file was automatically generated by Payload.
|
||||
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
|
||||
* and re-run `payload generate:types` to regenerate this file.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Supported timezones in IANA format.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "supportedTimezones".
|
||||
*/
|
||||
export type SupportedTimezones =
|
||||
| 'Pacific/Midway'
|
||||
| 'Pacific/Niue'
|
||||
| 'Pacific/Honolulu'
|
||||
| 'Pacific/Rarotonga'
|
||||
| 'America/Anchorage'
|
||||
| 'Pacific/Gambier'
|
||||
| 'America/Los_Angeles'
|
||||
| 'America/Tijuana'
|
||||
| 'America/Denver'
|
||||
| 'America/Phoenix'
|
||||
| 'America/Chicago'
|
||||
| 'America/Guatemala'
|
||||
| 'America/New_York'
|
||||
| 'America/Bogota'
|
||||
| 'America/Caracas'
|
||||
| 'America/Santiago'
|
||||
| 'America/Buenos_Aires'
|
||||
| 'America/Sao_Paulo'
|
||||
| 'Atlantic/South_Georgia'
|
||||
| 'Atlantic/Azores'
|
||||
| 'Atlantic/Cape_Verde'
|
||||
| 'Europe/London'
|
||||
| 'Europe/Berlin'
|
||||
| 'Africa/Lagos'
|
||||
| 'Europe/Athens'
|
||||
| 'Africa/Cairo'
|
||||
| 'Europe/Moscow'
|
||||
| 'Asia/Riyadh'
|
||||
| 'Asia/Dubai'
|
||||
| 'Asia/Baku'
|
||||
| 'Asia/Karachi'
|
||||
| 'Asia/Tashkent'
|
||||
| 'Asia/Calcutta'
|
||||
| 'Asia/Dhaka'
|
||||
| 'Asia/Almaty'
|
||||
| 'Asia/Jakarta'
|
||||
| 'Asia/Bangkok'
|
||||
| 'Asia/Shanghai'
|
||||
| 'Asia/Singapore'
|
||||
| 'Asia/Tokyo'
|
||||
| 'Asia/Seoul'
|
||||
| 'Australia/Brisbane'
|
||||
| 'Australia/Sydney'
|
||||
| 'Pacific/Guam'
|
||||
| 'Pacific/Noumea'
|
||||
| 'Pacific/Auckland'
|
||||
| 'Pacific/Fiji';
|
||||
|
||||
export interface Config {
|
||||
auth: {
|
||||
users: UserAuthOperations;
|
||||
};
|
||||
blocks: {};
|
||||
collections: {
|
||||
users: User;
|
||||
articles: Article;
|
||||
media: Media;
|
||||
'payload-kv': PayloadKv;
|
||||
'payload-locked-documents': PayloadLockedDocument;
|
||||
'payload-preferences': PayloadPreference;
|
||||
'payload-migrations': PayloadMigration;
|
||||
};
|
||||
collectionsJoins: {};
|
||||
collectionsSelect: {
|
||||
users: UsersSelect<false> | UsersSelect<true>;
|
||||
articles: ArticlesSelect<false> | ArticlesSelect<true>;
|
||||
media: MediaSelect<false> | MediaSelect<true>;
|
||||
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||
};
|
||||
db: {
|
||||
defaultIDType: number;
|
||||
};
|
||||
fallbackLocale: null;
|
||||
globals: {
|
||||
'site-settings': SiteSetting;
|
||||
};
|
||||
globalsSelect: {
|
||||
'site-settings': SiteSettingsSelect<false> | SiteSettingsSelect<true>;
|
||||
};
|
||||
locale: null;
|
||||
widgets: {
|
||||
collections: CollectionsWidget;
|
||||
};
|
||||
user: User;
|
||||
jobs: {
|
||||
tasks: unknown;
|
||||
workflows: unknown;
|
||||
};
|
||||
}
|
||||
export interface UserAuthOperations {
|
||||
forgotPassword: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
login: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
registerFirstUser: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
unlock: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users".
|
||||
*/
|
||||
export interface User {
|
||||
id: number;
|
||||
displayName?: string | null;
|
||||
role: 'admin' | 'editor' | 'author';
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
email: string;
|
||||
resetPasswordToken?: string | null;
|
||||
resetPasswordExpiration?: string | null;
|
||||
salt?: string | null;
|
||||
hash?: string | null;
|
||||
loginAttempts?: number | null;
|
||||
lockUntil?: string | null;
|
||||
sessions?:
|
||||
| {
|
||||
id: string;
|
||||
createdAt?: string | null;
|
||||
expiresAt: string;
|
||||
}[]
|
||||
| null;
|
||||
password?: string | null;
|
||||
collection: 'users';
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "articles".
|
||||
*/
|
||||
export interface Article {
|
||||
id: number;
|
||||
title: string;
|
||||
/**
|
||||
* Auto-generated from title if left empty
|
||||
*/
|
||||
slug?: string | null;
|
||||
content?: {
|
||||
root: {
|
||||
type: string;
|
||||
children: {
|
||||
type: any;
|
||||
version: number;
|
||||
[k: string]: unknown;
|
||||
}[];
|
||||
direction: ('ltr' | 'rtl') | null;
|
||||
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
|
||||
indent: number;
|
||||
version: number;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
} | null;
|
||||
status: 'draft' | 'published';
|
||||
author: number | User;
|
||||
featuredImage?: (number | null) | Media;
|
||||
publishedAt?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
_status?: ('draft' | 'published') | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "media".
|
||||
*/
|
||||
export interface Media {
|
||||
id: number;
|
||||
alt: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
url?: string | null;
|
||||
thumbnailURL?: string | null;
|
||||
filename?: string | null;
|
||||
mimeType?: string | null;
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-kv".
|
||||
*/
|
||||
export interface PayloadKv {
|
||||
id: number;
|
||||
key: string;
|
||||
data:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-locked-documents".
|
||||
*/
|
||||
export interface PayloadLockedDocument {
|
||||
id: number;
|
||||
document?:
|
||||
| ({
|
||||
relationTo: 'users';
|
||||
value: number | User;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'articles';
|
||||
value: number | Article;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'media';
|
||||
value: number | Media;
|
||||
} | null);
|
||||
globalSlug?: string | null;
|
||||
user: {
|
||||
relationTo: 'users';
|
||||
value: number | User;
|
||||
};
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-preferences".
|
||||
*/
|
||||
export interface PayloadPreference {
|
||||
id: number;
|
||||
user: {
|
||||
relationTo: 'users';
|
||||
value: number | User;
|
||||
};
|
||||
key?: string | null;
|
||||
value?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-migrations".
|
||||
*/
|
||||
export interface PayloadMigration {
|
||||
id: number;
|
||||
name?: string | null;
|
||||
batch?: number | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users_select".
|
||||
*/
|
||||
export interface UsersSelect<T extends boolean = true> {
|
||||
displayName?: T;
|
||||
role?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
email?: T;
|
||||
resetPasswordToken?: T;
|
||||
resetPasswordExpiration?: T;
|
||||
salt?: T;
|
||||
hash?: T;
|
||||
loginAttempts?: T;
|
||||
lockUntil?: T;
|
||||
sessions?:
|
||||
| T
|
||||
| {
|
||||
id?: T;
|
||||
createdAt?: T;
|
||||
expiresAt?: T;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "articles_select".
|
||||
*/
|
||||
export interface ArticlesSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
slug?: T;
|
||||
content?: T;
|
||||
status?: T;
|
||||
author?: T;
|
||||
featuredImage?: T;
|
||||
publishedAt?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
_status?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "media_select".
|
||||
*/
|
||||
export interface MediaSelect<T extends boolean = true> {
|
||||
alt?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
url?: T;
|
||||
thumbnailURL?: T;
|
||||
filename?: T;
|
||||
mimeType?: T;
|
||||
filesize?: T;
|
||||
width?: T;
|
||||
height?: T;
|
||||
focalX?: T;
|
||||
focalY?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-kv_select".
|
||||
*/
|
||||
export interface PayloadKvSelect<T extends boolean = true> {
|
||||
key?: T;
|
||||
data?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-locked-documents_select".
|
||||
*/
|
||||
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
|
||||
document?: T;
|
||||
globalSlug?: T;
|
||||
user?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-preferences_select".
|
||||
*/
|
||||
export interface PayloadPreferencesSelect<T extends boolean = true> {
|
||||
user?: T;
|
||||
key?: T;
|
||||
value?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-migrations_select".
|
||||
*/
|
||||
export interface PayloadMigrationsSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
batch?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "site-settings".
|
||||
*/
|
||||
export interface SiteSetting {
|
||||
id: number;
|
||||
siteName: string;
|
||||
siteDescription?: string | null;
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "site-settings_select".
|
||||
*/
|
||||
export interface SiteSettingsSelect<T extends boolean = true> {
|
||||
siteName?: T;
|
||||
siteDescription?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
globalType?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "collections_widget".
|
||||
*/
|
||||
export interface CollectionsWidget {
|
||||
data?: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
width: 'full';
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "auth".
|
||||
*/
|
||||
export interface Auth {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
declare module 'payload' {
|
||||
export interface GeneratedTypes extends Config {}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { buildConfig } from "payload";
|
||||
import { postgresAdapter } from "@payloadcms/db-postgres";
|
||||
import { lexicalEditor } from "@payloadcms/richtext-lexical";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { Users } from "./collections/users";
|
||||
import { Articles } from "./collections/articles";
|
||||
import { Media } from "./collections/media";
|
||||
import { SiteSettings } from "./globals/site-settings";
|
||||
|
||||
const filename = fileURLToPath(import.meta.url);
|
||||
const dirname = path.dirname(filename);
|
||||
|
||||
export default buildConfig({
|
||||
editor: lexicalEditor(),
|
||||
collections: [Users, Articles, Media],
|
||||
globals: [SiteSettings],
|
||||
secret: process.env.PAYLOAD_SECRET || "default-secret-change-me",
|
||||
db: postgresAdapter({
|
||||
pool: {
|
||||
connectionString:
|
||||
process.env.DATABASE_URL ||
|
||||
"postgresql://postgres:postgres@localhost:5432/template",
|
||||
},
|
||||
}),
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, "payload-types.ts"),
|
||||
},
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
# @repo/core -- Clean Architecture Core Package
|
||||
|
||||
This is the central business logic package. All domain entities, use cases, repository/service interfaces, controllers, and the InversifyJS DI container live here. Nothing in this package depends on any web framework (Next.js, TanStack, Payload). It is portable and testable in isolation.
|
||||
|
||||
**Package location:** `packages/core`
|
||||
**Entry point:** `src/index.ts`
|
||||
**Test runner:** Vitest (`pnpm vitest run` from this directory)
|
||||
|
||||
---
|
||||
|
||||
## Layer Diagram
|
||||
|
||||
Dependencies point inward (right to left). Outer layers depend on inner layers, never the reverse.
|
||||
|
||||
```
|
||||
+------------------------------------------------------------------+
|
||||
| di/ |
|
||||
| (wires everything together -- imports ALL internal layers) |
|
||||
+------------------------------------------------------------------+
|
||||
| | | |
|
||||
v v v v
|
||||
+----------------+ +----------------+ +----------------+ +-----------+
|
||||
| interface- | | infrastructure/| | application/ | | entities/ |
|
||||
| adapters/ | | (implements | | (use cases, | | (models, |
|
||||
| controllers/ | | interfaces | | repo/service | | errors) |
|
||||
| (validates | | with concrete | | interfaces) | | |
|
||||
| input, calls | | code) | | | | INNERMOST |
|
||||
| use cases) | | | | imports: | | zero deps |
|
||||
| | | imports: | | entities/ | | |
|
||||
| imports: | | application/ | | ONLY | | |
|
||||
| application/ | | entities/ | | | | |
|
||||
| entities/ | | @repo/ | | | | |
|
||||
| | | cms-client | | | | |
|
||||
+----------------+ +----------------+ +----------------+ +-----------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Import Rules
|
||||
|
||||
| Layer | Can Import From | NEVER Import From |
|
||||
|---|---|---|
|
||||
| `entities/` | Nothing (Zod is the only external dependency) | `application/`, `infrastructure/`, `interface-adapters/`, `di/`, any `@repo/*`, any framework |
|
||||
| `application/` | `entities/` only | `infrastructure/`, `interface-adapters/`, `di/` (except `getInjection` from `di/container` in use cases) |
|
||||
| `interface-adapters/` | `application/` (use cases), `entities/` (types, errors) | `infrastructure/`, any `@repo/*` except via DI |
|
||||
| `infrastructure/` | `application/` (interfaces to implement), `entities/` (types), `@repo/cms-client` | `interface-adapters/`, apps/*, Next.js, TanStack |
|
||||
| `di/` | All internal layers (it wires them together) | apps/*, any framework package |
|
||||
|
||||
Note: Use cases in `application/` import `getInjection` from `di/container` to resolve dependencies at runtime. This is the one controlled exception to the "application never imports di" rule -- `getInjection` is a lookup function, not a concrete implementation.
|
||||
|
||||
---
|
||||
|
||||
## DI Resolution Table
|
||||
|
||||
| Symbol Key | Interface | Production Implementation | Mock Implementation |
|
||||
|---|---|---|---|
|
||||
| `IUsersRepository` | `IUsersRepository` (getUser, getUserByUsername, createUser) | PayloadUsersRepository (future -- will use `@repo/cms-client`) | `MockUsersRepository` (`infrastructure/repositories/mock-users.repository.ts`) |
|
||||
| `IArticlesRepository` | `IArticlesRepository` (getArticle, getArticles, createArticle, updateArticle) | PayloadArticlesRepository (future -- will use `@repo/cms-client`) | `MockArticlesRepository` (`infrastructure/repositories/mock-articles.repository.ts`) |
|
||||
| `IAuthenticationService` | `IAuthenticationService` (generateUserId, hashPassword, verifyPassword, validateSession, createSession, invalidateSession) | BetterAuthService (future) | `MockAuthenticationService` (`infrastructure/services/mock-auth.service.ts`) |
|
||||
| `ITelemetryService` | `ITelemetryService` (startSpan) | OTelSentryService (future) | `MockTelemetryService` (`infrastructure/services/mock-telemetry.service.ts`) |
|
||||
|
||||
Currently all modules bind mock implementations. When production implementations are added, the modules will conditionally bind based on environment or configuration.
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
| Type | Pattern | Example |
|
||||
|---|---|---|
|
||||
| Entity model | `{name}.ts` | `article.ts`, `user.ts` |
|
||||
| Entity error | `{domain}.ts` | `auth.ts`, `common.ts` |
|
||||
| Repository interface | `{name}.repository.interface.ts` | `users.repository.interface.ts` |
|
||||
| Service interface | `{name}.service.interface.ts` | `auth.service.interface.ts` |
|
||||
| Use case | `{verb}-{noun}.use-case.ts` | `sign-in.use-case.ts`, `create-article.use-case.ts` |
|
||||
| Controller | `{noun}.controller.ts` (may export multiple functions) | `articles.controller.ts` |
|
||||
| Production impl | `{provider}-{name}.repository.ts` | `payload-users.repository.ts` |
|
||||
| Mock impl | `mock-{name}.repository.ts` or `mock-{name}.service.ts` | `mock-users.repository.ts` |
|
||||
| DI module | `{domain}.module.ts` | `auth.module.ts`, `content.module.ts` |
|
||||
| Test file | `{name}.test.ts` matching the source file name | `sign-in.use-case.test.ts` |
|
||||
|
||||
---
|
||||
|
||||
## How to Add a New Dependency (5-Step Recipe)
|
||||
|
||||
This recipe adds a new repository or service interface to the DI container. For a complete feature, also follow the root `AGENTS.md` end-to-end recipe.
|
||||
|
||||
### Step 1: Define the interface in `application/`
|
||||
|
||||
Create `src/application/repositories/{name}.repository.interface.ts` or `src/application/services/{name}.service.interface.ts`:
|
||||
|
||||
```typescript
|
||||
import type { MyEntity } from "@/entities/models/my-entity";
|
||||
|
||||
export interface IMyRepository {
|
||||
findById(id: string): Promise<MyEntity | undefined>;
|
||||
findAll(): Promise<MyEntity[]>;
|
||||
create(input: MyEntity): Promise<MyEntity>;
|
||||
}
|
||||
```
|
||||
|
||||
Export from the appropriate barrel: `src/application/repositories/index.ts` or `src/application/services/index.ts`.
|
||||
|
||||
### Step 2: Add the DI symbol to `di/types.ts`
|
||||
|
||||
```typescript
|
||||
import type { IMyRepository } from "@/application/repositories/my.repository.interface";
|
||||
|
||||
export const DI_SYMBOLS = {
|
||||
// ...existing symbols...
|
||||
IMyRepository: Symbol.for("IMyRepository"),
|
||||
};
|
||||
|
||||
export interface DI_RETURN_TYPES {
|
||||
// ...existing types...
|
||||
IMyRepository: IMyRepository;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Create mock implementation in `infrastructure/`
|
||||
|
||||
```typescript
|
||||
import { injectable } from "inversify";
|
||||
import type { IMyRepository } from "@/application/repositories/my.repository.interface";
|
||||
import type { MyEntity } from "@/entities/models/my-entity";
|
||||
|
||||
@injectable()
|
||||
export class MockMyRepository implements IMyRepository {
|
||||
private _items: MyEntity[] = [];
|
||||
|
||||
async findById(id: string): Promise<MyEntity | undefined> {
|
||||
return this._items.find((item) => item.id === id);
|
||||
}
|
||||
|
||||
async findAll(): Promise<MyEntity[]> {
|
||||
return [...this._items];
|
||||
}
|
||||
|
||||
async create(input: MyEntity): Promise<MyEntity> {
|
||||
this._items.push(input);
|
||||
return input;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Create or update DI module in `di/modules/`
|
||||
|
||||
```typescript
|
||||
import { ContainerModule, interfaces } from "inversify";
|
||||
import type { IMyRepository } from "@/application/repositories/my.repository.interface";
|
||||
import { MockMyRepository } from "@/infrastructure/repositories/mock-my.repository";
|
||||
import { DI_SYMBOLS } from "../types";
|
||||
|
||||
const initializeModule = (bind: interfaces.Bind) => {
|
||||
bind<IMyRepository>(DI_SYMBOLS.IMyRepository).to(MockMyRepository);
|
||||
};
|
||||
|
||||
export const MyModule = new ContainerModule(initializeModule);
|
||||
```
|
||||
|
||||
### Step 5: Load module in `di/container.ts`
|
||||
|
||||
```typescript
|
||||
import { MyModule } from "./modules/my.module";
|
||||
|
||||
export const initializeContainer = () => {
|
||||
// ...existing modules...
|
||||
ApplicationContainer.load(MyModule);
|
||||
};
|
||||
|
||||
export const destroyContainer = () => {
|
||||
// ...existing modules...
|
||||
ApplicationContainer.unload(MyModule);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Pattern
|
||||
|
||||
All tests follow this lifecycle pattern:
|
||||
|
||||
```typescript
|
||||
import "reflect-metadata";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { destroyContainer, initializeContainer } from "@/di/container";
|
||||
|
||||
beforeEach(() => {
|
||||
initializeContainer();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
destroyContainer();
|
||||
});
|
||||
|
||||
describe("myUseCase", () => {
|
||||
it("does something", async () => {
|
||||
const result = await myUseCase(/* ... */);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Critical requirements:
|
||||
- `import "reflect-metadata"` MUST be the first import in every test file. InversifyJS decorators rely on runtime metadata reflection.
|
||||
- `initializeContainer()` loads all DI modules (binding mock implementations).
|
||||
- `destroyContainer()` unloads all modules, ensuring clean state between tests. Without this, Singleton-scoped mocks retain state across tests.
|
||||
|
||||
Test file locations mirror source structure:
|
||||
- `tests/unit/use-cases/{domain}/{name}.use-case.test.ts`
|
||||
- `tests/unit/controllers/{domain}/{name}.controller.test.ts`
|
||||
|
||||
---
|
||||
|
||||
## tsconfig Requirements
|
||||
|
||||
These settings in `tsconfig.json` are MANDATORY. Do not remove them:
|
||||
|
||||
| Setting | Why |
|
||||
|---|---|
|
||||
| `experimentalDecorators: true` | InversifyJS uses TypeScript decorators (`@injectable()`, `@inject()`) |
|
||||
| `emitDecoratorMetadata: true` | InversifyJS reads parameter type metadata at runtime for constructor injection |
|
||||
| `types: ["reflect-metadata", "node"]` | `reflect-metadata` polyfill must be globally available for decorator metadata |
|
||||
| Path alias `@/*` -> `./src/*` | All internal imports use `@/` prefix (e.g., `@/entities/models/user`) |
|
||||
|
||||
The base config comes from `@repo/typescript-config/base.json` which already includes `experimentalDecorators` and `emitDecoratorMetadata`. The core `tsconfig.json` extends it and adds the path alias and types.
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
cd packages/core
|
||||
pnpm vitest run # Run all tests once
|
||||
pnpm vitest # Run in watch mode
|
||||
pnpm vitest run --reporter=verbose # Verbose output
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cross-References
|
||||
|
||||
- `src/entities/AGENTS.md` -- Entity models and error classes
|
||||
- `src/application/AGENTS.md` -- Use cases, repository/service interfaces
|
||||
- `src/infrastructure/AGENTS.md` -- Concrete implementations with `@injectable()`
|
||||
- `src/interface-adapters/controllers/AGENTS.md` -- Controller patterns
|
||||
- `src/di/AGENTS.md` -- DI container configuration and lifecycle
|
||||
- `src/application/use-cases/auth/AGENTS.md` -- Auth domain business rules
|
||||
- `src/application/use-cases/content/AGENTS.md` -- Content domain business rules
|
||||
- Root `AGENTS.md` -- Full end-to-end feature recipe and monorepo map
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "@repo/core",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"inversify": "^6.2.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/node": "^25.5.2",
|
||||
"vitest": "^3.1.0"
|
||||
}
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
# Application Layer -- Use Cases + Interfaces
|
||||
|
||||
**Path:** `packages/core/src/application/`
|
||||
**Role:** Define the business logic (use cases) and the abstract contracts (repository and service interfaces) that the infrastructure layer implements. This is the second-innermost layer of Clean Architecture.
|
||||
|
||||
---
|
||||
|
||||
## Rules
|
||||
|
||||
1. Imports from `entities/` ONLY (for types, schemas, and error classes).
|
||||
2. **NEVER** imports from `infrastructure/` -- use cases depend on interfaces, not implementations.
|
||||
3. **NEVER** imports from `interface-adapters/` -- controllers call use cases, not the reverse.
|
||||
4. Use cases obtain dependencies via `getInjection()` from `di/container` at runtime. This is the single allowed cross-layer dependency.
|
||||
5. All repository and service interfaces are pure TypeScript interfaces (no decorators, no classes).
|
||||
6. All methods on interfaces return Promises (data access is always async).
|
||||
|
||||
---
|
||||
|
||||
## Existing Repository Interfaces
|
||||
|
||||
| Interface | File | Methods |
|
||||
|---|---|---|
|
||||
| `IUsersRepository` | `repositories/users.repository.interface.ts` | `getUser(id)`, `getUserByUsername(username)`, `createUser(input)` |
|
||||
| `IArticlesRepository` | `repositories/articles.repository.interface.ts` | `getArticle(id)`, `getArticles(options?)`, `createArticle(input)`, `updateArticle(id, input)` |
|
||||
|
||||
## Existing Service Interfaces
|
||||
|
||||
| Interface | File | Methods |
|
||||
|---|---|---|
|
||||
| `IAuthenticationService` | `services/auth.service.interface.ts` | `generateUserId()`, `hashPassword(password)`, `verifyPassword(hash, password)`, `validateSession(sessionId)`, `createSession(user)`, `invalidateSession(sessionId)` |
|
||||
| `ITelemetryService` | `services/telemetry.service.interface.ts` | `startSpan(name, fn)` |
|
||||
|
||||
## Existing Use Cases
|
||||
|
||||
| Use Case | File | Domain | What It Does |
|
||||
|---|---|---|---|
|
||||
| `signInUseCase` | `use-cases/auth/sign-in.use-case.ts` | Auth | Looks up user by username, verifies password, creates session |
|
||||
| `signUpUseCase` | `use-cases/auth/sign-up.use-case.ts` | Auth | Checks username uniqueness, hashes password, creates user + session |
|
||||
| `signOutUseCase` | `use-cases/auth/sign-out.use-case.ts` | Auth | Invalidates session, returns blank cookie |
|
||||
| `createArticleUseCase` | `use-cases/content/create-article.use-case.ts` | Content | Generates slug from title, creates draft article |
|
||||
| `getArticlesUseCase` | `use-cases/content/get-articles.use-case.ts` | Content | Retrieves articles with optional filtering and pagination |
|
||||
|
||||
---
|
||||
|
||||
## Repository Interface Template
|
||||
|
||||
```typescript
|
||||
// src/application/repositories/{name}.repository.interface.ts
|
||||
import type { MyEntity } from "@/entities/models/my-entity";
|
||||
|
||||
export interface IMyRepository {
|
||||
/**
|
||||
* Find a single entity by ID.
|
||||
* Returns undefined if not found (do NOT throw -- let the use case decide).
|
||||
*/
|
||||
findById(id: string): Promise<MyEntity | undefined>;
|
||||
|
||||
/**
|
||||
* Find all entities matching optional filters.
|
||||
*/
|
||||
findAll(options?: {
|
||||
status?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<MyEntity[]>;
|
||||
|
||||
/**
|
||||
* Create a new entity. Returns the created entity.
|
||||
*/
|
||||
create(input: MyEntity): Promise<MyEntity>;
|
||||
|
||||
/**
|
||||
* Update an existing entity. Returns the updated entity or undefined if not found.
|
||||
*/
|
||||
update(id: string, input: Partial<MyEntity>): Promise<MyEntity | undefined>;
|
||||
}
|
||||
```
|
||||
|
||||
Key conventions:
|
||||
- Return `undefined` for "not found" cases, not `null`, and do not throw.
|
||||
- The use case decides what to do when something is not found (throw `NotFoundError`, return default, etc.).
|
||||
- Always return the entity after create/update so the caller has the final state.
|
||||
|
||||
---
|
||||
|
||||
## Service Interface Template
|
||||
|
||||
```typescript
|
||||
// src/application/services/{name}.service.interface.ts
|
||||
import type { SomeEntity } from "@/entities/models/some-entity";
|
||||
|
||||
export interface IMyService {
|
||||
/**
|
||||
* Service methods define external capabilities the domain needs
|
||||
* but does not implement itself (email, auth, telemetry, etc.)
|
||||
*/
|
||||
doSomething(input: string): Promise<SomeEntity>;
|
||||
}
|
||||
```
|
||||
|
||||
The distinction between repositories and services:
|
||||
- **Repositories** abstract data storage (CRUD operations on entities).
|
||||
- **Services** abstract external capabilities (authentication, email, telemetry, file storage).
|
||||
|
||||
---
|
||||
|
||||
## Use Case Template
|
||||
|
||||
```typescript
|
||||
// src/application/use-cases/{domain}/{verb}-{noun}.use-case.ts
|
||||
import type { MyEntity } from "@/entities/models/my-entity";
|
||||
import { NotFoundError } from "@/entities/errors/common";
|
||||
import { getInjection } from "@/di/container";
|
||||
|
||||
export async function myUseCase(input: {
|
||||
id: string;
|
||||
// ... other input fields
|
||||
}): Promise<MyEntity> {
|
||||
// 1. Get dependencies from DI container
|
||||
const myRepository = getInjection("IMyRepository");
|
||||
|
||||
// 2. Execute business logic using entities and interfaces
|
||||
const existing = await myRepository.findById(input.id);
|
||||
if (!existing) {
|
||||
throw new NotFoundError("Entity not found");
|
||||
}
|
||||
|
||||
// 3. Return result (entity types from entities/ layer)
|
||||
return existing;
|
||||
}
|
||||
```
|
||||
|
||||
Critical pattern: `getInjection("IMyRepository")` returns a fully typed instance. The string key must match a key in `DI_SYMBOLS` (see `di/types.ts`). TypeScript will enforce the return type via the `DI_RETURN_TYPES` mapping.
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Repository Interface (5-Step Recipe)
|
||||
|
||||
### Step 1: Create the interface file
|
||||
|
||||
Create `src/application/repositories/{name}.repository.interface.ts` following the template above.
|
||||
|
||||
### Step 2: Export from the barrel
|
||||
|
||||
Add to `src/application/repositories/index.ts`:
|
||||
|
||||
```typescript
|
||||
export type { IMyRepository } from "./{name}.repository.interface";
|
||||
```
|
||||
|
||||
### Step 3: Create mock implementation
|
||||
|
||||
In `src/infrastructure/repositories/mock-{name}.repository.ts`:
|
||||
|
||||
```typescript
|
||||
import { injectable } from "inversify";
|
||||
import type { IMyRepository } from "@/application/repositories/{name}.repository.interface";
|
||||
import type { MyEntity } from "@/entities/models/my-entity";
|
||||
|
||||
@injectable()
|
||||
export class MockMyRepository implements IMyRepository {
|
||||
private _items: MyEntity[] = [];
|
||||
|
||||
async findById(id: string): Promise<MyEntity | undefined> {
|
||||
return this._items.find((item) => item.id === id);
|
||||
}
|
||||
|
||||
async findAll(): Promise<MyEntity[]> {
|
||||
return [...this._items];
|
||||
}
|
||||
|
||||
async create(input: MyEntity): Promise<MyEntity> {
|
||||
this._items.push(input);
|
||||
return input;
|
||||
}
|
||||
|
||||
async update(id: string, input: Partial<MyEntity>): Promise<MyEntity | undefined> {
|
||||
const index = this._items.findIndex((item) => item.id === id);
|
||||
if (index === -1) return undefined;
|
||||
this._items[index] = { ...this._items[index]!, ...input };
|
||||
return this._items[index];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Register in DI
|
||||
|
||||
Add symbol to `di/types.ts`, create or update module in `di/modules/`, load in `di/container.ts`. See `di/AGENTS.md` for full details.
|
||||
|
||||
### Step 5: Verify
|
||||
|
||||
Write a use case that calls `getInjection("IMyRepository")` and a test that exercises it with the mock.
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Service Interface (Recipe)
|
||||
|
||||
Same as repository interface, but files go in `src/application/services/` and `src/infrastructure/services/`. The naming convention is `{name}.service.interface.ts` for the interface and `mock-{name}.service.ts` for the mock.
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Use Case (TDD Recipe)
|
||||
|
||||
### Step 1: Write the test FIRST
|
||||
|
||||
Create `tests/unit/use-cases/{domain}/{verb}-{noun}.use-case.test.ts`:
|
||||
|
||||
```typescript
|
||||
import "reflect-metadata";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
destroyContainer,
|
||||
initializeContainer,
|
||||
} from "@/di/container";
|
||||
import { myNewUseCase } from "@/application/use-cases/{domain}/{verb}-{noun}.use-case";
|
||||
|
||||
beforeEach(() => {
|
||||
initializeContainer();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
destroyContainer();
|
||||
});
|
||||
|
||||
describe("myNewUseCase", () => {
|
||||
it("succeeds with valid input", async () => {
|
||||
const result = await myNewUseCase({ /* valid input */ });
|
||||
expect(result).toBeDefined();
|
||||
// ... assert specific properties
|
||||
});
|
||||
|
||||
it("throws correct error for invalid state", async () => {
|
||||
await expect(
|
||||
myNewUseCase({ /* input that triggers error */ })
|
||||
).rejects.toBeInstanceOf(SomeError);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Step 2: Implement the use case
|
||||
|
||||
Create `src/application/use-cases/{domain}/{verb}-{noun}.use-case.ts` following the template above. Run the test to verify.
|
||||
|
||||
### Step 3: Export from core
|
||||
|
||||
Add to `src/index.ts`:
|
||||
|
||||
```typescript
|
||||
export { myNewUseCase } from "./application/use-cases/{domain}/{verb}-{noun}.use-case";
|
||||
```
|
||||
|
||||
### Step 4: Create a controller (if needed)
|
||||
|
||||
Controllers go in `interface-adapters/controllers/`. See `interface-adapters/controllers/AGENTS.md`.
|
||||
|
||||
---
|
||||
|
||||
## Test Template (Full Example)
|
||||
|
||||
```typescript
|
||||
import "reflect-metadata";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
destroyContainer,
|
||||
initializeContainer,
|
||||
} from "@/di/container";
|
||||
import { createArticleUseCase } from "@/application/use-cases/content/create-article.use-case";
|
||||
|
||||
beforeEach(() => {
|
||||
initializeContainer();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
destroyContainer();
|
||||
});
|
||||
|
||||
describe("createArticleUseCase", () => {
|
||||
it("creates an article with generated slug and draft status", async () => {
|
||||
const result = await createArticleUseCase({
|
||||
title: "My First Article",
|
||||
content: "Hello world",
|
||||
authorId: "1",
|
||||
});
|
||||
expect(result.title).toBe("My First Article");
|
||||
expect(result.slug).toBe("my-first-article");
|
||||
expect(result.status).toBe("draft");
|
||||
expect(result.authorId).toBe("1");
|
||||
expect(result.id).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses provided slug if given", async () => {
|
||||
const result = await createArticleUseCase({
|
||||
title: "Another Article",
|
||||
content: "Content here",
|
||||
authorId: "1",
|
||||
slug: "custom-slug",
|
||||
});
|
||||
expect(result.slug).toBe("custom-slug");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
application/
|
||||
AGENTS.md
|
||||
repositories/
|
||||
index.ts <-- barrel: exports all repository interfaces
|
||||
users.repository.interface.ts
|
||||
articles.repository.interface.ts
|
||||
services/
|
||||
index.ts <-- barrel: exports all service interfaces
|
||||
auth.service.interface.ts
|
||||
telemetry.service.interface.ts
|
||||
use-cases/
|
||||
auth/
|
||||
AGENTS.md <-- auth domain rules and recipes
|
||||
sign-in.use-case.ts
|
||||
sign-up.use-case.ts
|
||||
sign-out.use-case.ts
|
||||
content/
|
||||
AGENTS.md <-- content domain rules and recipes
|
||||
create-article.use-case.ts
|
||||
get-articles.use-case.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cross-References
|
||||
|
||||
- `entities/AGENTS.md` -- The only layer this layer can import from
|
||||
- `infrastructure/AGENTS.md` -- Implements the interfaces defined here
|
||||
- `di/AGENTS.md` -- Where interfaces are bound to implementations
|
||||
- `use-cases/auth/AGENTS.md` -- Auth domain business rules
|
||||
- `use-cases/content/AGENTS.md` -- Content domain business rules
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { Article } from "@/entities/models/article";
|
||||
|
||||
export interface IArticlesRepository {
|
||||
getArticle(id: string): Promise<Article | undefined>;
|
||||
getArticles(options?: {
|
||||
status?: string;
|
||||
authorId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<Article[]>;
|
||||
createArticle(input: Article): Promise<Article>;
|
||||
updateArticle(
|
||||
id: string,
|
||||
input: Partial<Article>
|
||||
): Promise<Article | undefined>;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export type { IUsersRepository } from "./users.repository.interface";
|
||||
export type { IArticlesRepository } from "./articles.repository.interface";
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { User } from "@/entities/models/user";
|
||||
|
||||
export interface IUsersRepository {
|
||||
getUser(id: string): Promise<User | undefined>;
|
||||
getUserByUsername(username: string): Promise<User | undefined>;
|
||||
createUser(input: User): Promise<User>;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { Cookie } from "@/entities/models/cookie";
|
||||
import type { Session } from "@/entities/models/session";
|
||||
import type { User } from "@/entities/models/user";
|
||||
|
||||
export interface IAuthenticationService {
|
||||
generateUserId(): string;
|
||||
hashPassword(password: string): Promise<string>;
|
||||
verifyPassword(hash: string, password: string): Promise<boolean>;
|
||||
validateSession(
|
||||
sessionId: string
|
||||
): Promise<{ user: User; session: Session }>;
|
||||
createSession(user: User): Promise<{ session: Session; cookie: Cookie }>;
|
||||
invalidateSession(sessionId: string): Promise<{ blankCookie: Cookie }>;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export type { IAuthenticationService } from "./auth.service.interface";
|
||||
export type { ITelemetryService } from "./telemetry.service.interface";
|
||||
@@ -1,3 +0,0 @@
|
||||
export interface ITelemetryService {
|
||||
startSpan<T>(name: string, fn: () => T | Promise<T>): Promise<T>;
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
# Auth Domain -- Use Cases
|
||||
|
||||
**Path:** `packages/core/src/application/use-cases/auth/`
|
||||
**Domain Responsibility:** Authentication and authorization -- sign-in, sign-up, sign-out, session management. This domain owns the user identity lifecycle from account creation through session invalidation.
|
||||
|
||||
---
|
||||
|
||||
## Complete Business Rules
|
||||
|
||||
1. **Passwords are always hashed.** Plain-text passwords never enter the repository. The `IAuthenticationService.hashPassword()` method handles hashing before storage.
|
||||
2. **Usernames must be unique.** Sign-up checks for existing username via `IUsersRepository.getUserByUsername()` before creating a new user.
|
||||
3. **Sign-in requires valid credentials.** The user must exist AND the password must verify against the stored hash.
|
||||
4. **Sessions are created on successful sign-in or sign-up.** `IAuthenticationService.createSession()` returns both a `Session` object and a `Cookie` for the client.
|
||||
5. **Sessions expire after 7 days.** This is the current mock implementation default (`Date.now() + 86400000 * 7`). Production implementations may use different expiry logic.
|
||||
6. **Sign-out invalidates the session.** `IAuthenticationService.invalidateSession()` removes the session and returns a blank cookie (empty value) to clear the client-side cookie.
|
||||
7. **User IDs are generated by the auth service.** `IAuthenticationService.generateUserId()` produces the ID, not the repository. This allows the auth provider (e.g., Better Auth) to control ID format.
|
||||
|
||||
---
|
||||
|
||||
## Error Cases
|
||||
|
||||
| Error Class | When Thrown | Use Case |
|
||||
|---|---|---|
|
||||
| `AuthenticationError` | User does not exist (sign-in) | `signInUseCase` |
|
||||
| `AuthenticationError` | Incorrect password (sign-in) | `signInUseCase` |
|
||||
| `AuthenticationError` | Username already taken (sign-up) | `signUpUseCase` |
|
||||
| `UnauthenticatedError` | Session ID not found or expired (session validation) | `IAuthenticationService.validateSession()` |
|
||||
| `InputParseError` | Invalid input fields (handled by controller, not use case) | `signInController`, `signUpController` |
|
||||
|
||||
Note: Use cases throw `AuthenticationError` for credential failures. They intentionally do NOT distinguish between "user not found" and "wrong password" in the error message exposed to callers -- this prevents username enumeration attacks. The sign-in use case throws `"Incorrect username or password"` for wrong passwords and `"User does not exist"` internally, but callers should treat both as authentication failures.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
### IUsersRepository
|
||||
|
||||
Provides user data access. Methods used by auth use cases:
|
||||
|
||||
| Method | Signature | Used By |
|
||||
|---|---|---|
|
||||
| `getUser` | `(id: string) => Promise<User \| undefined>` | `MockAuthenticationService.validateSession()` (indirectly) |
|
||||
| `getUserByUsername` | `(username: string) => Promise<User \| undefined>` | `signInUseCase`, `signUpUseCase` |
|
||||
| `createUser` | `(input: User) => Promise<User>` | `signUpUseCase` |
|
||||
|
||||
### IAuthenticationService
|
||||
|
||||
Provides authentication operations. All methods:
|
||||
|
||||
| Method | Signature | Used By |
|
||||
|---|---|---|
|
||||
| `generateUserId` | `() => string` | `signUpUseCase` |
|
||||
| `hashPassword` | `(password: string) => Promise<string>` | `signUpUseCase` |
|
||||
| `verifyPassword` | `(hash: string, password: string) => Promise<boolean>` | `signInUseCase` |
|
||||
| `validateSession` | `(sessionId: string) => Promise<{ user: User; session: Session }>` | (future use cases needing auth context) |
|
||||
| `createSession` | `(user: User) => Promise<{ session: Session; cookie: Cookie }>` | `signInUseCase`, `signUpUseCase` |
|
||||
| `invalidateSession` | `(sessionId: string) => Promise<{ blankCookie: Cookie }>` | `signOutUseCase` |
|
||||
|
||||
---
|
||||
|
||||
## Existing Use Cases
|
||||
|
||||
### signInUseCase
|
||||
|
||||
**File:** `sign-in.use-case.ts`
|
||||
**Input:** `{ username: string; password: string }`
|
||||
**Output:** `{ session: Session; cookie: Cookie }`
|
||||
**Logic:**
|
||||
1. Look up user by username via `IUsersRepository.getUserByUsername()`.
|
||||
2. If not found, throw `AuthenticationError("User does not exist")`.
|
||||
3. Verify password via `IAuthenticationService.verifyPassword()`.
|
||||
4. If invalid, throw `AuthenticationError("Incorrect username or password")`.
|
||||
5. Create session via `IAuthenticationService.createSession()`.
|
||||
6. Return session and cookie.
|
||||
|
||||
### signUpUseCase
|
||||
|
||||
**File:** `sign-up.use-case.ts`
|
||||
**Input:** `{ username: string; password: string }`
|
||||
**Output:** `{ session: Session; cookie: Cookie; user: Pick<User, "id" | "username"> }`
|
||||
**Logic:**
|
||||
1. Check if username exists via `IUsersRepository.getUserByUsername()`.
|
||||
2. If exists, throw `AuthenticationError("Username taken")`.
|
||||
3. Hash password via `IAuthenticationService.hashPassword()`.
|
||||
4. Generate user ID via `IAuthenticationService.generateUserId()`.
|
||||
5. Create user via `IUsersRepository.createUser()`.
|
||||
6. Create session via `IAuthenticationService.createSession()`.
|
||||
7. Return session, cookie, and safe user info (id + username, no passwordHash).
|
||||
|
||||
### signOutUseCase
|
||||
|
||||
**File:** `sign-out.use-case.ts`
|
||||
**Input:** `sessionId: string`
|
||||
**Output:** `{ blankCookie: Cookie }`
|
||||
**Logic:**
|
||||
1. Invalidate session via `IAuthenticationService.invalidateSession()`.
|
||||
2. Return blank cookie (empty value clears the client-side session cookie).
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Auth Use Case (Complete Recipe with Test-First Example)
|
||||
|
||||
Example: adding a `validateSessionUseCase` that checks if a session is valid and returns the authenticated user.
|
||||
|
||||
### Step 1: Write the test FIRST
|
||||
|
||||
Create `tests/unit/use-cases/auth/validate-session.use-case.test.ts`:
|
||||
|
||||
```typescript
|
||||
import "reflect-metadata";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
destroyContainer,
|
||||
initializeContainer,
|
||||
} from "@/di/container";
|
||||
import { signUpUseCase } from "@/application/use-cases/auth/sign-up.use-case";
|
||||
import { validateSessionUseCase } from "@/application/use-cases/auth/validate-session.use-case";
|
||||
import { UnauthenticatedError } from "@/entities/errors/auth";
|
||||
|
||||
beforeEach(() => {
|
||||
initializeContainer();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
destroyContainer();
|
||||
});
|
||||
|
||||
describe("validateSessionUseCase", () => {
|
||||
it("returns user and session for valid session", async () => {
|
||||
// First create a user and get a session
|
||||
const signUpResult = await signUpUseCase({
|
||||
username: "testuser",
|
||||
password: "testpassword",
|
||||
});
|
||||
|
||||
const result = await validateSessionUseCase(
|
||||
signUpResult.session.id
|
||||
);
|
||||
|
||||
expect(result.user.username).toBe("testuser");
|
||||
expect(result.session.id).toBe(signUpResult.session.id);
|
||||
});
|
||||
|
||||
it("throws UnauthenticatedError for invalid session", async () => {
|
||||
await expect(
|
||||
validateSessionUseCase("nonexistent-session-id")
|
||||
).rejects.toBeInstanceOf(UnauthenticatedError);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Step 2: Implement the use case
|
||||
|
||||
Create `src/application/use-cases/auth/validate-session.use-case.ts`:
|
||||
|
||||
```typescript
|
||||
import type { Session } from "@/entities/models/session";
|
||||
import type { User } from "@/entities/models/user";
|
||||
import { getInjection } from "@/di/container";
|
||||
|
||||
export async function validateSessionUseCase(
|
||||
sessionId: string
|
||||
): Promise<{ user: User; session: Session }> {
|
||||
const authService = getInjection("IAuthenticationService");
|
||||
return await authService.validateSession(sessionId);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Export from core
|
||||
|
||||
Add to `packages/core/src/index.ts`:
|
||||
|
||||
```typescript
|
||||
export { validateSessionUseCase } from "./application/use-cases/auth/validate-session.use-case";
|
||||
```
|
||||
|
||||
### Step 4: Run tests
|
||||
|
||||
```bash
|
||||
cd packages/core && pnpm vitest run
|
||||
```
|
||||
|
||||
### Step 5: Create controller and tRPC procedure (if needed)
|
||||
|
||||
Follow the controller recipe in `interface-adapters/controllers/AGENTS.md`.
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Passwords are always hashed before storage.** The `signUpUseCase` calls `authService.hashPassword()` and stores only the hash. The mock uses a simple `hashed_` prefix; production implementations must use bcrypt, argon2, or similar.
|
||||
2. **Sessions expire.** The mock sets a 7-day expiry. Production implementations should enforce this with database TTLs or cleanup jobs.
|
||||
3. **Blank cookie on sign-out.** Setting the cookie value to `""` tells the browser to clear it. The cookie name is defined in `config.ts` as `SESSION_COOKIE = "session"`.
|
||||
4. **No password in responses.** `signUpUseCase` returns `Pick<User, "id" | "username">`, explicitly excluding `passwordHash`.
|
||||
5. **Error messages should not leak information.** In production, consider using a generic "Invalid credentials" message for both "user not found" and "wrong password" scenarios to prevent username enumeration.
|
||||
|
||||
---
|
||||
|
||||
## Cross-References
|
||||
|
||||
- `application/AGENTS.md` -- General use case patterns and interfaces
|
||||
- `entities/errors/auth.ts` -- `AuthenticationError`, `UnauthenticatedError`, `UnauthorizedError`
|
||||
- `entities/models/user.ts` -- `User` type
|
||||
- `entities/models/session.ts` -- `Session` type
|
||||
- `entities/models/cookie.ts` -- `Cookie` type
|
||||
- `di/AGENTS.md` -- How `IUsersRepository` and `IAuthenticationService` are resolved
|
||||
- `infrastructure/services/mock-auth.service.ts` -- Mock auth implementation details
|
||||
@@ -1,27 +0,0 @@
|
||||
import { AuthenticationError } from "@/entities/errors/auth";
|
||||
import type { Cookie } from "@/entities/models/cookie";
|
||||
import type { Session } from "@/entities/models/session";
|
||||
import { getInjection } from "@/di/container";
|
||||
|
||||
export async function signInUseCase(input: {
|
||||
username: string;
|
||||
password: string;
|
||||
}): Promise<{ session: Session; cookie: Cookie }> {
|
||||
const usersRepository = getInjection("IUsersRepository");
|
||||
const authService = getInjection("IAuthenticationService");
|
||||
|
||||
const existingUser = await usersRepository.getUserByUsername(input.username);
|
||||
if (!existingUser) {
|
||||
throw new AuthenticationError("User does not exist");
|
||||
}
|
||||
|
||||
const validPassword = await authService.verifyPassword(
|
||||
existingUser.passwordHash,
|
||||
input.password
|
||||
);
|
||||
if (!validPassword) {
|
||||
throw new AuthenticationError("Incorrect username or password");
|
||||
}
|
||||
|
||||
return await authService.createSession(existingUser);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { Cookie } from "@/entities/models/cookie";
|
||||
import { getInjection } from "@/di/container";
|
||||
|
||||
export async function signOutUseCase(
|
||||
sessionId: string
|
||||
): Promise<{ blankCookie: Cookie }> {
|
||||
const authService = getInjection("IAuthenticationService");
|
||||
return await authService.invalidateSession(sessionId);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { AuthenticationError } from "@/entities/errors/auth";
|
||||
import type { Cookie } from "@/entities/models/cookie";
|
||||
import type { Session } from "@/entities/models/session";
|
||||
import type { User } from "@/entities/models/user";
|
||||
import { getInjection } from "@/di/container";
|
||||
|
||||
export async function signUpUseCase(input: {
|
||||
username: string;
|
||||
password: string;
|
||||
}): Promise<{
|
||||
session: Session;
|
||||
cookie: Cookie;
|
||||
user: Pick<User, "id" | "username">;
|
||||
}> {
|
||||
const usersRepository = getInjection("IUsersRepository");
|
||||
const authService = getInjection("IAuthenticationService");
|
||||
|
||||
const existingUser = await usersRepository.getUserByUsername(input.username);
|
||||
if (existingUser) {
|
||||
throw new AuthenticationError("Username taken");
|
||||
}
|
||||
|
||||
const passwordHash = await authService.hashPassword(input.password);
|
||||
const userId = authService.generateUserId();
|
||||
|
||||
const newUser = await usersRepository.createUser({
|
||||
id: userId,
|
||||
username: input.username,
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
const { cookie, session } = await authService.createSession(newUser);
|
||||
|
||||
return {
|
||||
cookie,
|
||||
session,
|
||||
user: { id: newUser.id, username: newUser.username },
|
||||
};
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
# Content Domain -- Use Cases
|
||||
|
||||
**Path:** `packages/core/src/application/use-cases/content/`
|
||||
**Domain Responsibility:** Article management -- creation, retrieval, filtering, and publishing workflow. This domain owns the content lifecycle from draft creation through publication.
|
||||
|
||||
---
|
||||
|
||||
## Complete Business Rules
|
||||
|
||||
1. **Articles must have a title and content.** These are required fields validated at the controller level via Zod schemas.
|
||||
2. **Slugs are auto-generated from the title if not provided.** The `generateSlug()` function in `createArticleUseCase` handles this.
|
||||
3. **New articles default to "draft" status.** The `status` field is set to `"draft"` on creation. There is no way to create a published article directly.
|
||||
4. **Articles have an author.** The `authorId` field links to a user. The use case does not verify the author exists (that responsibility belongs to a future authorization layer).
|
||||
5. **Article IDs are UUIDs.** Generated via `crypto.randomUUID()` at creation time.
|
||||
6. **Timestamps are set at creation.** Both `createdAt` and `updatedAt` are set to `new Date()` when the article is created.
|
||||
7. **Filtering is supported.** `getArticlesUseCase` accepts optional `status`, `authorId`, `limit`, and `offset` parameters.
|
||||
8. **Default pagination is 50 items.** The mock repository defaults `limit` to 50 if not specified.
|
||||
|
||||
---
|
||||
|
||||
## Error Cases
|
||||
|
||||
| Error Class | When Thrown | Use Case |
|
||||
|---|---|---|
|
||||
| `InputParseError` | Missing or invalid input fields (title, content, authorId) | `createArticleController` (controller level) |
|
||||
| `InputParseError` | Invalid filter parameters | `getArticlesController` (controller level) |
|
||||
| `NotFoundError` | Article not found by ID (future: update/delete operations) | Future use cases |
|
||||
| `UnauthorizedError` | User cannot edit/delete another user's article (future) | Future use cases |
|
||||
|
||||
Note: Current use cases do not throw domain errors directly. Validation happens in controllers. Future use cases (update, delete, publish) will introduce `NotFoundError` and `UnauthorizedError`.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
### IArticlesRepository
|
||||
|
||||
Provides article data access. All methods:
|
||||
|
||||
| Method | Signature | Used By |
|
||||
|---|---|---|
|
||||
| `getArticle` | `(id: string) => Promise<Article \| undefined>` | (future use cases) |
|
||||
| `getArticles` | `(options?: { status?, authorId?, limit?, offset? }) => Promise<Article[]>` | `getArticlesUseCase` |
|
||||
| `createArticle` | `(input: Article) => Promise<Article>` | `createArticleUseCase` |
|
||||
| `updateArticle` | `(id: string, input: Partial<Article>) => Promise<Article \| undefined>` | (future use cases) |
|
||||
|
||||
---
|
||||
|
||||
## Existing Use Cases
|
||||
|
||||
### createArticleUseCase
|
||||
|
||||
**File:** `create-article.use-case.ts`
|
||||
**Input:** `{ title: string; content: string; authorId: string; slug?: string }`
|
||||
**Output:** `Article`
|
||||
**Logic:**
|
||||
1. Get `IArticlesRepository` via `getInjection("IArticlesRepository")`.
|
||||
2. Generate UUID for article ID via `crypto.randomUUID()`.
|
||||
3. Generate slug from title if not provided (see Slug Generation below).
|
||||
4. Set `status` to `"draft"`.
|
||||
5. Set `createdAt` and `updatedAt` to current time.
|
||||
6. Call `articlesRepository.createArticle()` with the complete article object.
|
||||
7. Return the created article.
|
||||
|
||||
### getArticlesUseCase
|
||||
|
||||
**File:** `get-articles.use-case.ts`
|
||||
**Input:** `{ status?: string; authorId?: string; limit?: number; offset?: number }` (all optional)
|
||||
**Output:** `Article[]`
|
||||
**Logic:**
|
||||
1. Get `IArticlesRepository` via `getInjection("IArticlesRepository")`.
|
||||
2. Pass options directly to `articlesRepository.getArticles()`.
|
||||
3. Return the result array.
|
||||
|
||||
---
|
||||
|
||||
## Slug Generation Logic
|
||||
|
||||
The `generateSlug()` function lives inside `create-article.use-case.ts` (private to the module, not exported):
|
||||
|
||||
```typescript
|
||||
function generateSlug(title: string): string {
|
||||
return title
|
||||
.toLowerCase() // "My First Article" -> "my first article"
|
||||
.replace(/[^a-z0-9]+/g, "-") // "my first article" -> "my-first-article"
|
||||
.replace(/^-|-$/g, ""); // trim leading/trailing hyphens
|
||||
}
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- `"My First Article"` -> `"my-first-article"`
|
||||
- `"Hello, World! #1"` -> `"hello-world-1"`
|
||||
- `" Leading Spaces "` -> `"leading-spaces"`
|
||||
- `"UPPER CASE"` -> `"upper-case"`
|
||||
|
||||
The same slug generation logic is duplicated in `packages/cms-core/src/collections/articles/hooks/before-change.ts` for the Payload CMS side. If you change the algorithm, update BOTH locations.
|
||||
|
||||
If a `slug` is explicitly provided in the input, it is used as-is without any transformation.
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Content Use Case (Recipe)
|
||||
|
||||
Example: adding a `publishArticleUseCase` that changes an article's status from "draft" to "published".
|
||||
|
||||
### Step 1: Write the test FIRST
|
||||
|
||||
Create `tests/unit/use-cases/content/publish-article.use-case.test.ts`:
|
||||
|
||||
```typescript
|
||||
import "reflect-metadata";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
destroyContainer,
|
||||
initializeContainer,
|
||||
} from "@/di/container";
|
||||
import { createArticleUseCase } from "@/application/use-cases/content/create-article.use-case";
|
||||
import { publishArticleUseCase } from "@/application/use-cases/content/publish-article.use-case";
|
||||
import { NotFoundError } from "@/entities/errors/common";
|
||||
|
||||
beforeEach(() => {
|
||||
initializeContainer();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
destroyContainer();
|
||||
});
|
||||
|
||||
describe("publishArticleUseCase", () => {
|
||||
it("changes article status to published", async () => {
|
||||
const article = await createArticleUseCase({
|
||||
title: "Draft Article",
|
||||
content: "Content",
|
||||
authorId: "1",
|
||||
});
|
||||
expect(article.status).toBe("draft");
|
||||
|
||||
const published = await publishArticleUseCase(article.id);
|
||||
expect(published.status).toBe("published");
|
||||
expect(published.id).toBe(article.id);
|
||||
});
|
||||
|
||||
it("throws NotFoundError for non-existent article", async () => {
|
||||
await expect(
|
||||
publishArticleUseCase("non-existent-id")
|
||||
).rejects.toBeInstanceOf(NotFoundError);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Step 2: Implement the use case
|
||||
|
||||
Create `src/application/use-cases/content/publish-article.use-case.ts`:
|
||||
|
||||
```typescript
|
||||
import type { Article } from "@/entities/models/article";
|
||||
import { NotFoundError } from "@/entities/errors/common";
|
||||
import { getInjection } from "@/di/container";
|
||||
|
||||
export async function publishArticleUseCase(
|
||||
articleId: string
|
||||
): Promise<Article> {
|
||||
const articlesRepository = getInjection("IArticlesRepository");
|
||||
|
||||
const updated = await articlesRepository.updateArticle(articleId, {
|
||||
status: "published",
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundError("Article not found");
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Export from core
|
||||
|
||||
Add to `packages/core/src/index.ts`:
|
||||
|
||||
```typescript
|
||||
export { publishArticleUseCase } from "./application/use-cases/content/publish-article.use-case";
|
||||
```
|
||||
|
||||
### Step 4: Create controller
|
||||
|
||||
Create `src/interface-adapters/controllers/content/` (add to `articles.controller.ts` or create a new file):
|
||||
|
||||
```typescript
|
||||
import { z } from "zod";
|
||||
import { InputParseError } from "@/entities/errors/common";
|
||||
import { publishArticleUseCase } from "@/application/use-cases/content/publish-article.use-case";
|
||||
|
||||
const publishInputSchema = z.object({
|
||||
articleId: z.string(),
|
||||
});
|
||||
|
||||
export async function publishArticleController(
|
||||
input: Partial<z.infer<typeof publishInputSchema>>
|
||||
) {
|
||||
const { data, error: inputParseError } = publishInputSchema.safeParse(input);
|
||||
if (inputParseError) {
|
||||
throw new InputParseError("Invalid data", { cause: inputParseError });
|
||||
}
|
||||
return await publishArticleUseCase(data.articleId);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Add tRPC procedure
|
||||
|
||||
In `packages/api/src/router/content.router.ts`:
|
||||
|
||||
```typescript
|
||||
import { publishArticleController } from "@repo/core";
|
||||
|
||||
publishArticle: publicProcedure
|
||||
.input(z.object({ articleId: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
return await publishArticleController(input);
|
||||
}),
|
||||
```
|
||||
|
||||
### Step 6: Run tests
|
||||
|
||||
```bash
|
||||
cd packages/core && pnpm vitest run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cross-References
|
||||
|
||||
- `application/AGENTS.md` -- General use case patterns and the `IArticlesRepository` interface
|
||||
- `entities/models/article.ts` -- `Article` type, `articleSchema`, `articleStatusSchema`
|
||||
- `entities/errors/common.ts` -- `NotFoundError`, `InputParseError`
|
||||
- `infrastructure/repositories/mock-articles.repository.ts` -- Mock implementation with in-memory storage
|
||||
- `di/AGENTS.md` -- How `IArticlesRepository` is resolved (currently via `content.module.ts`)
|
||||
- `packages/cms-core/src/collections/articles/` -- Payload CMS Articles collection (parallel data model)
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { Article } from "@/entities/models/article";
|
||||
import { getInjection } from "@/di/container";
|
||||
|
||||
function generateSlug(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
export async function createArticleUseCase(input: {
|
||||
title: string;
|
||||
content: string;
|
||||
authorId: string;
|
||||
slug?: string;
|
||||
}): Promise<Article> {
|
||||
const articlesRepository = getInjection("IArticlesRepository");
|
||||
|
||||
const now = new Date();
|
||||
const article: Article = {
|
||||
id: crypto.randomUUID(),
|
||||
title: input.title,
|
||||
slug: input.slug ?? generateSlug(input.title),
|
||||
content: input.content,
|
||||
status: "draft",
|
||||
authorId: input.authorId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
return await articlesRepository.createArticle(article);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { Article } from "@/entities/models/article";
|
||||
import { getInjection } from "@/di/container";
|
||||
|
||||
export async function getArticlesUseCase(options?: {
|
||||
status?: string;
|
||||
authorId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<Article[]> {
|
||||
const articlesRepository = getInjection("IArticlesRepository");
|
||||
return await articlesRepository.getArticles(options);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export const SESSION_COOKIE = "session";
|
||||
@@ -1,246 +0,0 @@
|
||||
# DI -- InversifyJS Dependency Injection Container
|
||||
|
||||
**Path:** `packages/core/src/di/`
|
||||
**Role:** Wire together all abstract interfaces and their concrete implementations using InversifyJS. The container is the root composition point -- it knows about every layer and resolves dependencies at runtime. All other code accesses dependencies through `getInjection()`, never by importing implementations directly.
|
||||
|
||||
---
|
||||
|
||||
## Complete Resolution Table
|
||||
|
||||
| Symbol Key | Interface | Production Implementation | Mock Implementation | DI Module |
|
||||
|---|---|---|---|---|
|
||||
| `IUsersRepository` | `IUsersRepository` (getUser, getUserByUsername, createUser) | PayloadUsersRepository (future) | `MockUsersRepository` (`infrastructure/repositories/mock-users.repository.ts`) | `auth.module.ts` |
|
||||
| `IArticlesRepository` | `IArticlesRepository` (getArticle, getArticles, createArticle, updateArticle) | PayloadArticlesRepository (future) | `MockArticlesRepository` (`infrastructure/repositories/mock-articles.repository.ts`) | `content.module.ts` |
|
||||
| `IAuthenticationService` | `IAuthenticationService` (generateUserId, hashPassword, verifyPassword, validateSession, createSession, invalidateSession) | BetterAuthService (future) | `MockAuthenticationService` (`infrastructure/services/mock-auth.service.ts`) | `auth.module.ts` |
|
||||
| `ITelemetryService` | `ITelemetryService` (startSpan) | OTelSentryService (future) | `MockTelemetryService` (`infrastructure/services/mock-telemetry.service.ts`) | `auth.module.ts` |
|
||||
|
||||
---
|
||||
|
||||
## How to Register a New Dependency (Full Recipe)
|
||||
|
||||
### Step 1: Add symbol and return type to `types.ts`
|
||||
|
||||
```typescript
|
||||
// di/types.ts
|
||||
import type { IMyRepository } from "@/application/repositories/my.repository.interface";
|
||||
|
||||
export const DI_SYMBOLS = {
|
||||
IAuthenticationService: Symbol.for("IAuthenticationService"),
|
||||
ITelemetryService: Symbol.for("ITelemetryService"),
|
||||
IUsersRepository: Symbol.for("IUsersRepository"),
|
||||
IArticlesRepository: Symbol.for("IArticlesRepository"),
|
||||
IMyRepository: Symbol.for("IMyRepository"), // <-- ADD THIS
|
||||
};
|
||||
|
||||
export interface DI_RETURN_TYPES {
|
||||
IAuthenticationService: IAuthenticationService;
|
||||
ITelemetryService: ITelemetryService;
|
||||
IUsersRepository: IUsersRepository;
|
||||
IArticlesRepository: IArticlesRepository;
|
||||
IMyRepository: IMyRepository; // <-- ADD THIS
|
||||
}
|
||||
```
|
||||
|
||||
The `DI_SYMBOLS` object maps string keys to unique `Symbol` values (used by InversifyJS for binding). The `DI_RETURN_TYPES` interface provides TypeScript type safety for `getInjection()`.
|
||||
|
||||
### Step 2: Create the DI module file
|
||||
|
||||
Create `di/modules/{domain}.module.ts` (or add to an existing one):
|
||||
|
||||
```typescript
|
||||
// di/modules/my-domain.module.ts
|
||||
import { ContainerModule, interfaces } from "inversify";
|
||||
|
||||
import type { IMyRepository } from "@/application/repositories/my.repository.interface";
|
||||
import { MockMyRepository } from "@/infrastructure/repositories/mock-my.repository";
|
||||
import { DI_SYMBOLS } from "../types";
|
||||
|
||||
const initializeModule = (bind: interfaces.Bind) => {
|
||||
bind<IMyRepository>(DI_SYMBOLS.IMyRepository).to(MockMyRepository);
|
||||
};
|
||||
|
||||
export const MyDomainModule = new ContainerModule(initializeModule);
|
||||
```
|
||||
|
||||
For modules with multiple bindings (like `auth.module.ts`):
|
||||
|
||||
```typescript
|
||||
const initializeModule = (bind: interfaces.Bind) => {
|
||||
bind<IUsersRepository>(DI_SYMBOLS.IUsersRepository).to(MockUsersRepository);
|
||||
bind<IAuthenticationService>(DI_SYMBOLS.IAuthenticationService).to(
|
||||
MockAuthenticationService
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Step 3: Load and unload the module in `container.ts`
|
||||
|
||||
```typescript
|
||||
// di/container.ts
|
||||
import { MyDomainModule } from "./modules/my-domain.module";
|
||||
|
||||
export const initializeContainer = () => {
|
||||
ApplicationContainer.load(AuthModule);
|
||||
ApplicationContainer.load(ContentModule);
|
||||
ApplicationContainer.load(MyDomainModule); // <-- ADD THIS
|
||||
};
|
||||
|
||||
export const destroyContainer = () => {
|
||||
ApplicationContainer.unload(AuthModule);
|
||||
ApplicationContainer.unload(ContentModule);
|
||||
ApplicationContainer.unload(MyDomainModule); // <-- ADD THIS
|
||||
};
|
||||
```
|
||||
|
||||
Both `load` and `unload` must be updated. Forgetting `unload` causes test isolation failures.
|
||||
|
||||
---
|
||||
|
||||
## Container Lifecycle
|
||||
|
||||
### Production (non-test environments)
|
||||
|
||||
```typescript
|
||||
if (process.env.NODE_ENV !== "test") {
|
||||
initializeContainer();
|
||||
}
|
||||
```
|
||||
|
||||
The container auto-initializes when the module is first imported. All bindings are available immediately.
|
||||
|
||||
### Test environments
|
||||
|
||||
Tests manually control the container lifecycle:
|
||||
|
||||
```typescript
|
||||
import "reflect-metadata";
|
||||
import { afterEach, beforeEach } from "vitest";
|
||||
import { destroyContainer, initializeContainer } from "@/di/container";
|
||||
|
||||
beforeEach(() => {
|
||||
initializeContainer(); // Load all modules, bind all implementations
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
destroyContainer(); // Unload all modules, clear all bindings
|
||||
});
|
||||
```
|
||||
|
||||
Why manual control in tests:
|
||||
- Each test gets a fresh container with fresh Singleton instances.
|
||||
- In-memory mock data does not leak between tests.
|
||||
- Forgetting `destroyContainer` in `afterEach` causes state pollution: the mock repositories retain data from previous tests, causing flaky tests.
|
||||
|
||||
---
|
||||
|
||||
## Scoping
|
||||
|
||||
The container defaults to **Singleton** scope:
|
||||
|
||||
```typescript
|
||||
const ApplicationContainer = new Container({
|
||||
defaultScope: "Singleton",
|
||||
});
|
||||
```
|
||||
|
||||
This means each call to `getInjection("IUsersRepository")` within a container lifecycle returns the same instance. This is correct for:
|
||||
- Repositories (stateful mocks, connection pools in production)
|
||||
- Services (auth sessions, telemetry clients)
|
||||
|
||||
When to use **Transient** scope (a new instance per resolution):
|
||||
- Stateless utility services
|
||||
- Per-request scoped objects
|
||||
|
||||
To override for a specific binding:
|
||||
|
||||
```typescript
|
||||
bind<IMyService>(DI_SYMBOLS.IMyService)
|
||||
.to(MyService)
|
||||
.inTransientScope();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `getInjection()` Usage in Use Cases
|
||||
|
||||
```typescript
|
||||
// In a use case file:
|
||||
import { getInjection } from "@/di/container";
|
||||
|
||||
export async function myUseCase(input: { id: string }) {
|
||||
// The string key is type-safe: it must match a key in DI_SYMBOLS.
|
||||
// The return type is automatically inferred from DI_RETURN_TYPES.
|
||||
const usersRepository = getInjection("IUsersRepository");
|
||||
// ^-- TypeScript infers: IUsersRepository
|
||||
|
||||
const authService = getInjection("IAuthenticationService");
|
||||
// ^-- TypeScript infers: IAuthenticationService
|
||||
|
||||
const user = await usersRepository.getUser(input.id);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
The `getInjection` function signature:
|
||||
|
||||
```typescript
|
||||
export function getInjection<K extends keyof typeof DI_SYMBOLS>(
|
||||
symbol: K
|
||||
): DI_RETURN_TYPES[K] {
|
||||
return ApplicationContainer.get(DI_SYMBOLS[symbol]);
|
||||
}
|
||||
```
|
||||
|
||||
This provides full type safety: if you pass `"IUsersRepository"`, the return type is `IUsersRepository`. If you pass an invalid key, TypeScript reports a compile error.
|
||||
|
||||
---
|
||||
|
||||
## DO NOT
|
||||
|
||||
| Do Not | Why |
|
||||
|---|---|
|
||||
| Import from `apps/*` or framework packages (Next.js, TanStack) | DI is framework-agnostic. Framework code lives in apps. |
|
||||
| Use the container outside of `@repo/core` | All external access goes through `getInjection()` or exported use case / controller functions. |
|
||||
| Call `ApplicationContainer.get()` directly from use cases | Use `getInjection()` instead -- it provides type safety and a consistent API. |
|
||||
| Forget to unload modules in `destroyContainer()` | Causes test state pollution (mock data leaks between tests). |
|
||||
| Remove `import "reflect-metadata"` from `container.ts` | InversifyJS uses runtime reflection to read constructor parameter types. Without this import, `@inject()` decorators silently fail. |
|
||||
|
||||
---
|
||||
|
||||
## tsconfig Requirements
|
||||
|
||||
These settings are required for InversifyJS and MUST NOT be removed:
|
||||
|
||||
| Setting | Where | Why |
|
||||
|---|---|---|
|
||||
| `experimentalDecorators: true` | `@repo/typescript-config/base.json` | Enables `@injectable()` and `@inject()` decorator syntax used by InversifyJS |
|
||||
| `emitDecoratorMetadata: true` | `@repo/typescript-config/base.json` | Emits runtime type metadata that InversifyJS reads to auto-resolve constructor parameter types |
|
||||
| `types: ["reflect-metadata", "node"]` | `packages/core/tsconfig.json` | Makes `reflect-metadata` type definitions globally available. Required for `emitDecoratorMetadata` to function |
|
||||
| `import "reflect-metadata"` | Top of `container.ts` and every test file | Polyfills the `Reflect.metadata` API at runtime. Without this, decorator metadata is not stored and `@inject()` silently injects `undefined` |
|
||||
|
||||
If any of these are removed, InversifyJS will throw errors like:
|
||||
- `"No matching bindings found"` (metadata not emitted)
|
||||
- `"Missing required @injectable annotation"` (decorators not enabled)
|
||||
- `"Cannot read properties of undefined"` (reflect-metadata not imported)
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
di/
|
||||
AGENTS.md
|
||||
types.ts <-- DI_SYMBOLS + DI_RETURN_TYPES
|
||||
container.ts <-- ApplicationContainer, initializeContainer, destroyContainer, getInjection
|
||||
modules/
|
||||
auth.module.ts <-- Binds IUsersRepository, IAuthenticationService, ITelemetryService
|
||||
content.module.ts <-- Binds IArticlesRepository
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cross-References
|
||||
|
||||
- `application/AGENTS.md` -- Defines the interfaces that are bound here
|
||||
- `infrastructure/AGENTS.md` -- Provides the implementations that are bound here
|
||||
- Root `AGENTS.md` -- Shows how DI fits into the full feature recipe
|
||||
@@ -1,32 +0,0 @@
|
||||
import "reflect-metadata";
|
||||
import { Container } from "inversify";
|
||||
|
||||
import { AuthModule } from "./modules/auth.module";
|
||||
import { ContentModule } from "./modules/content.module";
|
||||
import { DI_RETURN_TYPES, DI_SYMBOLS } from "./types";
|
||||
|
||||
const ApplicationContainer = new Container({
|
||||
defaultScope: "Singleton",
|
||||
});
|
||||
|
||||
export const initializeContainer = () => {
|
||||
ApplicationContainer.load(AuthModule);
|
||||
ApplicationContainer.load(ContentModule);
|
||||
};
|
||||
|
||||
export const destroyContainer = () => {
|
||||
ApplicationContainer.unload(AuthModule);
|
||||
ApplicationContainer.unload(ContentModule);
|
||||
};
|
||||
|
||||
if (process.env.NODE_ENV !== "test") {
|
||||
initializeContainer();
|
||||
}
|
||||
|
||||
export function getInjection<K extends keyof typeof DI_SYMBOLS>(
|
||||
symbol: K
|
||||
): DI_RETURN_TYPES[K] {
|
||||
return ApplicationContainer.get(DI_SYMBOLS[symbol]);
|
||||
}
|
||||
|
||||
export { ApplicationContainer };
|
||||
@@ -1,16 +0,0 @@
|
||||
import { ContainerModule, interfaces } from "inversify";
|
||||
|
||||
import type { IUsersRepository } from "@/application/repositories/users.repository.interface";
|
||||
import type { IAuthenticationService } from "@/application/services/auth.service.interface";
|
||||
import { MockUsersRepository } from "@/infrastructure/repositories/mock-users.repository";
|
||||
import { MockAuthenticationService } from "@/infrastructure/services/mock-auth.service";
|
||||
import { DI_SYMBOLS } from "../types";
|
||||
|
||||
const initializeModule = (bind: interfaces.Bind) => {
|
||||
bind<IUsersRepository>(DI_SYMBOLS.IUsersRepository).to(MockUsersRepository);
|
||||
bind<IAuthenticationService>(DI_SYMBOLS.IAuthenticationService).to(
|
||||
MockAuthenticationService
|
||||
);
|
||||
};
|
||||
|
||||
export const AuthModule = new ContainerModule(initializeModule);
|
||||
@@ -1,13 +0,0 @@
|
||||
import { ContainerModule, interfaces } from "inversify";
|
||||
|
||||
import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface";
|
||||
import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository";
|
||||
import { DI_SYMBOLS } from "../types";
|
||||
|
||||
const initializeModule = (bind: interfaces.Bind) => {
|
||||
bind<IArticlesRepository>(DI_SYMBOLS.IArticlesRepository).to(
|
||||
MockArticlesRepository
|
||||
);
|
||||
};
|
||||
|
||||
export const ContentModule = new ContainerModule(initializeModule);
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { IAuthenticationService } from "@/application/services/auth.service.interface";
|
||||
import type { ITelemetryService } from "@/application/services/telemetry.service.interface";
|
||||
import type { IUsersRepository } from "@/application/repositories/users.repository.interface";
|
||||
import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface";
|
||||
|
||||
export const DI_SYMBOLS = {
|
||||
IAuthenticationService: Symbol.for("IAuthenticationService"),
|
||||
ITelemetryService: Symbol.for("ITelemetryService"),
|
||||
IUsersRepository: Symbol.for("IUsersRepository"),
|
||||
IArticlesRepository: Symbol.for("IArticlesRepository"),
|
||||
};
|
||||
|
||||
export interface DI_RETURN_TYPES {
|
||||
IAuthenticationService: IAuthenticationService;
|
||||
ITelemetryService: ITelemetryService;
|
||||
IUsersRepository: IUsersRepository;
|
||||
IArticlesRepository: IArticlesRepository;
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
# Entities Layer -- Innermost, Zero Dependencies
|
||||
|
||||
**Path:** `packages/core/src/entities/`
|
||||
**Role:** Define the pure domain types (models) and domain error classes. This is the innermost layer of Clean Architecture. Nothing here has side effects, I/O, or async behavior.
|
||||
|
||||
---
|
||||
|
||||
## Rules
|
||||
|
||||
1. **NEVER** import from `application/`, `infrastructure/`, `interface-adapters/`, or `di/`.
|
||||
2. **NEVER** import external libraries except `zod` (for schema validation in models).
|
||||
3. Everything is **pure** -- no side effects, no I/O, no `async`, no `fetch`, no database calls.
|
||||
4. Models are Zod schemas with inferred TypeScript types.
|
||||
5. Errors are custom `Error` subclasses with domain-specific semantics.
|
||||
6. Error classes always accept `(message: string, options?: ErrorOptions)` to support error chaining via `cause`.
|
||||
|
||||
---
|
||||
|
||||
## Existing Models
|
||||
|
||||
| Model | File | Schema | Fields |
|
||||
|---|---|---|---|
|
||||
| User | `models/user.ts` | `userSchema` | `id`, `username`, `passwordHash` |
|
||||
| Article | `models/article.ts` | `articleSchema` | `id`, `title`, `slug`, `content`, `status` ("draft"/"published"), `authorId`, `createdAt`, `updatedAt` |
|
||||
| Session | `models/session.ts` | `sessionSchema` | `id`, `userId`, `expiresAt` |
|
||||
| Cookie | `models/cookie.ts` | (plain type, no Zod) | `name`, `value`, `attributes` (secure, path, domain, sameSite, httpOnly, maxAge, expires) |
|
||||
|
||||
---
|
||||
|
||||
## Existing Errors
|
||||
|
||||
| Error Class | File | When Thrown |
|
||||
|---|---|---|
|
||||
| `AuthenticationError` | `errors/auth.ts` | Wrong credentials during sign-in, or username already taken during sign-up |
|
||||
| `UnauthenticatedError` | `errors/auth.ts` | Invalid or expired session when validating authentication |
|
||||
| `UnauthorizedError` | `errors/auth.ts` | User lacks permission for the requested operation (future use) |
|
||||
| `NotFoundError` | `errors/common.ts` | Requested resource does not exist (future use in update/delete operations) |
|
||||
| `InputParseError` | `errors/common.ts` | Controller Zod validation fails. The `cause` property contains the `ZodError` for detailed field-level messages |
|
||||
|
||||
---
|
||||
|
||||
## Complete Model Template
|
||||
|
||||
Use this template when creating a new entity model:
|
||||
|
||||
```typescript
|
||||
// src/entities/models/{name}.ts
|
||||
import { z } from "zod";
|
||||
|
||||
// 1. Define the Zod schema with all validation rules
|
||||
export const {name}Schema = z.object({
|
||||
id: z.string(),
|
||||
// ... add fields with Zod validators
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
});
|
||||
|
||||
// 2. Infer the TypeScript type from the schema
|
||||
export type {Name} = z.infer<typeof {name}Schema>;
|
||||
|
||||
// 3. If you have enum-like fields, define them separately for reuse:
|
||||
// export const {name}StatusSchema = z.enum(["active", "inactive"]);
|
||||
// export type {Name}Status = z.infer<typeof {name}StatusSchema>;
|
||||
```
|
||||
|
||||
Real example from `article.ts`:
|
||||
|
||||
```typescript
|
||||
import { z } from "zod";
|
||||
|
||||
export const articleStatusSchema = z.enum(["draft", "published"]);
|
||||
|
||||
export const articleSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string().min(1).max(255),
|
||||
slug: z.string().min(1).max(255),
|
||||
content: z.string(),
|
||||
status: articleStatusSchema.default("draft"),
|
||||
authorId: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
});
|
||||
|
||||
export type Article = z.infer<typeof articleSchema>;
|
||||
export type ArticleStatus = z.infer<typeof articleStatusSchema>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Error Template
|
||||
|
||||
Use this template when creating a new error class:
|
||||
|
||||
```typescript
|
||||
// src/entities/errors/{domain}.ts
|
||||
export class {Name}Error extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Real example from `auth.ts`:
|
||||
|
||||
```typescript
|
||||
export class AuthenticationError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthenticatedError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `options?: ErrorOptions` parameter is important -- it enables error chaining. Controllers pass Zod errors as `cause`:
|
||||
|
||||
```typescript
|
||||
throw new InputParseError("Invalid data", { cause: zodError });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Model (Step-by-Step)
|
||||
|
||||
### Step 1: Create the model file
|
||||
|
||||
Create `src/entities/models/{name}.ts` following the template above.
|
||||
|
||||
### Step 2: Export from the models barrel
|
||||
|
||||
Edit `src/entities/models/index.ts` and add:
|
||||
|
||||
```typescript
|
||||
export { {name}Schema, type {Name} } from "./{name}";
|
||||
```
|
||||
|
||||
### Step 3: Verify the export chain
|
||||
|
||||
The chain `models/index.ts` -> `entities/index.ts` -> `core/src/index.ts` uses `export *` at each level, so you only need to update `models/index.ts`. The exports will automatically flow through:
|
||||
|
||||
- `src/entities/index.ts` contains: `export * from "./models/index";`
|
||||
- `src/index.ts` contains: `export * from "./entities/index";`
|
||||
|
||||
### Step 4: Use in application layer
|
||||
|
||||
The model type is now available in repository interfaces, use cases, and controllers via:
|
||||
|
||||
```typescript
|
||||
import type { {Name} } from "@/entities/models/{name}";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Error (Step-by-Step)
|
||||
|
||||
### Step 1: Create or edit the error file
|
||||
|
||||
If adding to an existing domain (e.g., auth), edit `src/entities/errors/auth.ts`.
|
||||
If creating a new domain, create `src/entities/errors/{domain}.ts`.
|
||||
|
||||
### Step 2: Export from the errors barrel
|
||||
|
||||
Edit `src/entities/errors/index.ts`:
|
||||
|
||||
```typescript
|
||||
export { {Name}Error } from "./{domain}";
|
||||
```
|
||||
|
||||
### Step 3: Verify the export chain
|
||||
|
||||
Same chain as models: `errors/index.ts` -> `entities/index.ts` -> `core/src/index.ts`. You only need to update `errors/index.ts`.
|
||||
|
||||
### Step 4: Use in use cases and controllers
|
||||
|
||||
```typescript
|
||||
import { {Name}Error } from "@/entities/errors/{domain}";
|
||||
|
||||
// In a use case:
|
||||
if (somethingWrong) {
|
||||
throw new {Name}Error("Descriptive message");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
entities/
|
||||
AGENTS.md
|
||||
index.ts <-- re-exports models/* and errors/*
|
||||
models/
|
||||
index.ts <-- barrel: exports all models
|
||||
user.ts
|
||||
article.ts
|
||||
session.ts
|
||||
cookie.ts
|
||||
errors/
|
||||
index.ts <-- barrel: exports all errors
|
||||
auth.ts <-- AuthenticationError, UnauthenticatedError, UnauthorizedError
|
||||
common.ts <-- NotFoundError, InputParseError
|
||||
```
|
||||
@@ -1,17 +0,0 @@
|
||||
export class AuthenticationError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthenticatedError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export class NotFoundError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
}
|
||||
}
|
||||
|
||||
export class InputParseError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export {
|
||||
AuthenticationError,
|
||||
UnauthenticatedError,
|
||||
UnauthorizedError,
|
||||
} from "./auth";
|
||||
export { NotFoundError, InputParseError } from "./common";
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./models/index";
|
||||
export * from "./errors/index";
|
||||
@@ -1,17 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const articleStatusSchema = z.enum(["draft", "published"]);
|
||||
|
||||
export const articleSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string().min(1).max(255),
|
||||
slug: z.string().min(1).max(255),
|
||||
content: z.string(),
|
||||
status: articleStatusSchema.default("draft"),
|
||||
authorId: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
});
|
||||
|
||||
export type Article = z.infer<typeof articleSchema>;
|
||||
export type ArticleStatus = z.infer<typeof articleStatusSchema>;
|
||||
@@ -1,15 +0,0 @@
|
||||
type CookieAttributes = {
|
||||
secure?: boolean;
|
||||
path?: string;
|
||||
domain?: string;
|
||||
sameSite?: "lax" | "strict" | "none";
|
||||
httpOnly?: boolean;
|
||||
maxAge?: number;
|
||||
expires?: Date;
|
||||
};
|
||||
|
||||
export type Cookie = {
|
||||
name: string;
|
||||
value: string;
|
||||
attributes: CookieAttributes;
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
export { userSchema, type User } from "./user";
|
||||
export {
|
||||
articleSchema,
|
||||
articleStatusSchema,
|
||||
type Article,
|
||||
type ArticleStatus,
|
||||
} from "./article";
|
||||
export { sessionSchema, type Session } from "./session";
|
||||
export type { Cookie } from "./cookie";
|
||||
@@ -1,9 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const sessionSchema = z.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
expiresAt: z.date(),
|
||||
});
|
||||
|
||||
export type Session = z.infer<typeof sessionSchema>;
|
||||
@@ -1,9 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const userSchema = z.object({
|
||||
id: z.string(),
|
||||
username: z.string().min(3).max(31),
|
||||
passwordHash: z.string().min(6).max(255),
|
||||
});
|
||||
|
||||
export type User = z.infer<typeof userSchema>;
|
||||
@@ -1,22 +0,0 @@
|
||||
// @repo/core — Clean Architecture core package
|
||||
export * from "./entities/index";
|
||||
export * from "./application/repositories/index";
|
||||
export * from "./application/services/index";
|
||||
export { signInUseCase } from "./application/use-cases/auth/sign-in.use-case";
|
||||
export { signUpUseCase } from "./application/use-cases/auth/sign-up.use-case";
|
||||
export { signOutUseCase } from "./application/use-cases/auth/sign-out.use-case";
|
||||
export { createArticleUseCase } from "./application/use-cases/content/create-article.use-case";
|
||||
export { getArticlesUseCase } from "./application/use-cases/content/get-articles.use-case";
|
||||
export { signInController } from "./interface-adapters/controllers/auth/sign-in.controller";
|
||||
export { signUpController } from "./interface-adapters/controllers/auth/sign-up.controller";
|
||||
export { signOutController } from "./interface-adapters/controllers/auth/sign-out.controller";
|
||||
export {
|
||||
createArticleController,
|
||||
getArticlesController,
|
||||
} from "./interface-adapters/controllers/content/articles.controller";
|
||||
export {
|
||||
getInjection,
|
||||
initializeContainer,
|
||||
destroyContainer,
|
||||
} from "./di/container";
|
||||
export { DI_SYMBOLS } from "./di/types";
|
||||
@@ -1,261 +0,0 @@
|
||||
# Infrastructure Layer -- Concrete Implementations
|
||||
|
||||
**Path:** `packages/core/src/infrastructure/`
|
||||
**Role:** Provide concrete implementations of the abstract interfaces defined in `application/`. Every repository interface and service interface gets at least one implementation here. All implementations use the `@injectable()` decorator for InversifyJS DI.
|
||||
|
||||
---
|
||||
|
||||
## Rules
|
||||
|
||||
1. Implements interfaces from `application/` (repository or service interfaces).
|
||||
2. Imports from `application/` (interfaces) and `entities/` (types, errors).
|
||||
3. **NEVER** imported by `application/` or `entities/`. The dependency arrow points inward: infrastructure depends on application, not the reverse.
|
||||
4. **NEVER** imported by `interface-adapters/` (controllers). Controllers use interfaces via DI.
|
||||
5. Can import external libraries (Payload client, Better Auth, Sentry, etc.).
|
||||
6. Can import `@repo/cms-client` -- this is the bridge to Payload CMS data. Note: `@repo/cms-client` is standalone with zero monorepo dependencies.
|
||||
7. Every implementation class MUST have the `@injectable()` decorator. Without it, InversifyJS cannot construct the class.
|
||||
8. Always provide a **mock implementation** for every real implementation. Tests run with mocks; production runs with real implementations.
|
||||
|
||||
---
|
||||
|
||||
## Existing Implementations
|
||||
|
||||
| Implementation | File | Implements | Type |
|
||||
|---|---|---|---|
|
||||
| `MockUsersRepository` | `repositories/mock-users.repository.ts` | `IUsersRepository` | Mock (in-memory array, pre-seeded with alice + bob) |
|
||||
| `MockArticlesRepository` | `repositories/mock-articles.repository.ts` | `IArticlesRepository` | Mock (in-memory array, starts empty) |
|
||||
| `MockAuthenticationService` | `services/mock-auth.service.ts` | `IAuthenticationService` | Mock (hashed_password prefix, in-memory session store) |
|
||||
| `MockTelemetryService` | `services/mock-telemetry.service.ts` | `ITelemetryService` | Mock (no-op, just calls the wrapped function) |
|
||||
|
||||
The `MockAuthenticationService` is notable because it uses constructor injection:
|
||||
|
||||
```typescript
|
||||
@injectable()
|
||||
export class MockAuthenticationService implements IAuthenticationService {
|
||||
constructor(
|
||||
@inject(DI_SYMBOLS.IUsersRepository)
|
||||
private _usersRepository: IUsersRepository
|
||||
) {}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
This demonstrates how InversifyJS resolves nested dependencies automatically. When the container creates `MockAuthenticationService`, it first resolves `IUsersRepository` and injects it.
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
| Type | Pattern | Example |
|
||||
|---|---|---|
|
||||
| Production repository | `{provider}-{name}.repository.ts` | `payload-users.repository.ts` |
|
||||
| Mock repository | `mock-{name}.repository.ts` | `mock-users.repository.ts` |
|
||||
| Production service | `{provider}-{name}.service.ts` | `better-auth.service.ts`, `otel-telemetry.service.ts` |
|
||||
| Mock service | `mock-{name}.service.ts` | `mock-auth.service.ts` |
|
||||
|
||||
The `{provider}` prefix identifies the external system: `payload`, `better`, `otel`, `sentry`, etc.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Template (Repository)
|
||||
|
||||
```typescript
|
||||
// src/infrastructure/repositories/{provider}-{name}.repository.ts
|
||||
import { injectable } from "inversify";
|
||||
|
||||
import type { IMyRepository } from "@/application/repositories/my.repository.interface";
|
||||
import type { MyEntity } from "@/entities/models/my-entity";
|
||||
|
||||
@injectable()
|
||||
export class ProviderMyRepository implements IMyRepository {
|
||||
// Constructor can accept injected dependencies if needed:
|
||||
// constructor(
|
||||
// @inject(DI_SYMBOLS.ISomeDep) private someDep: ISomeDep
|
||||
// ) {}
|
||||
|
||||
async findById(id: string): Promise<MyEntity | undefined> {
|
||||
// Call external system (database, API, etc.)
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
async findAll(): Promise<MyEntity[]> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
async create(input: MyEntity): Promise<MyEntity> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
async update(id: string, input: Partial<MyEntity>): Promise<MyEntity | undefined> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mock Implementation Template
|
||||
|
||||
```typescript
|
||||
// src/infrastructure/repositories/mock-{name}.repository.ts
|
||||
import { injectable } from "inversify";
|
||||
|
||||
import type { IMyRepository } from "@/application/repositories/my.repository.interface";
|
||||
import type { MyEntity } from "@/entities/models/my-entity";
|
||||
|
||||
@injectable()
|
||||
export class MockMyRepository implements IMyRepository {
|
||||
private _items: MyEntity[] = [];
|
||||
|
||||
async findById(id: string): Promise<MyEntity | undefined> {
|
||||
return this._items.find((item) => item.id === id);
|
||||
}
|
||||
|
||||
async findAll(): Promise<MyEntity[]> {
|
||||
return [...this._items];
|
||||
}
|
||||
|
||||
async create(input: MyEntity): Promise<MyEntity> {
|
||||
this._items.push(input);
|
||||
return input;
|
||||
}
|
||||
|
||||
async update(id: string, input: Partial<MyEntity>): Promise<MyEntity | undefined> {
|
||||
const index = this._items.findIndex((item) => item.id === id);
|
||||
if (index === -1) return undefined;
|
||||
this._items[index] = { ...this._items[index]!, ...input };
|
||||
return this._items[index];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Mock pattern notes:
|
||||
- Use `private _items: MyEntity[] = []` for in-memory storage.
|
||||
- Spread arrays (`[...this._items]`) to avoid returning mutable references.
|
||||
- Use `Array.find` for single lookups, `Array.filter` for queries.
|
||||
- Use `Array.findIndex` + splice/spread for updates.
|
||||
- Pre-seed data in the array initializer if tests need existing records (see `MockUsersRepository` which pre-seeds alice and bob).
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Implementation (Recipe)
|
||||
|
||||
### Step 1: Create the implementation file
|
||||
|
||||
Follow the implementation template above. Place it in:
|
||||
- `repositories/` for data access implementations
|
||||
- `services/` for external service implementations
|
||||
|
||||
### Step 2: Ensure `@injectable()` is present
|
||||
|
||||
```typescript
|
||||
import { injectable } from "inversify";
|
||||
|
||||
@injectable()
|
||||
export class MyImplementation implements IMyInterface {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
If the class needs another DI dependency injected via constructor, use `@inject()`:
|
||||
|
||||
```typescript
|
||||
import { inject, injectable } from "inversify";
|
||||
import { DI_SYMBOLS } from "@/di/types";
|
||||
|
||||
@injectable()
|
||||
export class MyImplementation implements IMyInterface {
|
||||
constructor(
|
||||
@inject(DI_SYMBOLS.IOtherDependency)
|
||||
private _otherDep: IOtherDependency
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Create or verify the mock implementation
|
||||
|
||||
If this is a production implementation, verify that a corresponding mock already exists. If adding a new interface, create the mock at the same time.
|
||||
|
||||
### Step 4: Register in the DI module
|
||||
|
||||
Update the appropriate module in `di/modules/` to bind the implementation:
|
||||
|
||||
```typescript
|
||||
// For development/mock:
|
||||
bind<IMyInterface>(DI_SYMBOLS.IMyInterface).to(MockMyImplementation);
|
||||
|
||||
// For production (when adding real implementations):
|
||||
// if (process.env.NODE_ENV === "production") {
|
||||
// bind<IMyInterface>(DI_SYMBOLS.IMyInterface).to(RealMyImplementation);
|
||||
// } else {
|
||||
// bind<IMyInterface>(DI_SYMBOLS.IMyInterface).to(MockMyImplementation);
|
||||
// }
|
||||
```
|
||||
|
||||
### Step 5: Test via use cases
|
||||
|
||||
Infrastructure implementations are tested indirectly through use case tests. The DI container wires mock implementations in test mode. Run `pnpm vitest run` to verify.
|
||||
|
||||
---
|
||||
|
||||
## Note on @repo/cms-client
|
||||
|
||||
The `@repo/cms-client` package is standalone. It provides a `PayloadClient` interface with two modes:
|
||||
|
||||
- **Local mode**: Direct Payload SDK access (for server-side code co-located with Payload)
|
||||
- **HTTP mode**: REST API calls (for code running separately from Payload)
|
||||
|
||||
Infrastructure implementations can import and use `@repo/cms-client` to bridge Payload CMS data into Clean Architecture:
|
||||
|
||||
```typescript
|
||||
import { injectable } from "inversify";
|
||||
import { createPayloadClient, type PayloadClient } from "@repo/cms-client";
|
||||
import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface";
|
||||
import type { Article } from "@/entities/models/article";
|
||||
|
||||
@injectable()
|
||||
export class PayloadArticlesRepository implements IArticlesRepository {
|
||||
private client: PayloadClient;
|
||||
|
||||
constructor() {
|
||||
this.client = createPayloadClient({
|
||||
mode: "http",
|
||||
baseURL: process.env.CMS_URL ?? "http://localhost:3001",
|
||||
});
|
||||
}
|
||||
|
||||
async getArticles(options?: { status?: string }): Promise<Article[]> {
|
||||
const result = await this.client.find<Article>("articles", {
|
||||
where: options?.status ? { status: { equals: options.status } } : undefined,
|
||||
});
|
||||
return result.docs;
|
||||
}
|
||||
|
||||
// ... other methods
|
||||
}
|
||||
```
|
||||
|
||||
This is the intended pattern for production implementations. The infrastructure layer owns the translation between Payload's data format and the core entity types.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
infrastructure/
|
||||
AGENTS.md
|
||||
repositories/
|
||||
mock-users.repository.ts
|
||||
mock-articles.repository.ts
|
||||
services/
|
||||
mock-auth.service.ts
|
||||
mock-telemetry.service.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cross-References
|
||||
|
||||
- `application/AGENTS.md` -- Defines the interfaces that implementations here must satisfy
|
||||
- `entities/AGENTS.md` -- Types used in implementation method signatures
|
||||
- `di/AGENTS.md` -- Where implementations are bound to their interface symbols
|
||||
@@ -1,46 +0,0 @@
|
||||
import { injectable } from "inversify";
|
||||
|
||||
import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface";
|
||||
import type { Article } from "@/entities/models/article";
|
||||
|
||||
@injectable()
|
||||
export class MockArticlesRepository implements IArticlesRepository {
|
||||
private _articles: Article[] = [];
|
||||
|
||||
async getArticle(id: string): Promise<Article | undefined> {
|
||||
return this._articles.find((a) => a.id === id);
|
||||
}
|
||||
|
||||
async getArticles(options?: {
|
||||
status?: string;
|
||||
authorId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<Article[]> {
|
||||
let result = [...this._articles];
|
||||
if (options?.status) {
|
||||
result = result.filter((a) => a.status === options.status);
|
||||
}
|
||||
if (options?.authorId) {
|
||||
result = result.filter((a) => a.authorId === options.authorId);
|
||||
}
|
||||
const offset = options?.offset ?? 0;
|
||||
const limit = options?.limit ?? 50;
|
||||
return result.slice(offset, offset + limit);
|
||||
}
|
||||
|
||||
async createArticle(input: Article): Promise<Article> {
|
||||
this._articles.push(input);
|
||||
return input;
|
||||
}
|
||||
|
||||
async updateArticle(
|
||||
id: string,
|
||||
input: Partial<Article>
|
||||
): Promise<Article | undefined> {
|
||||
const index = this._articles.findIndex((a) => a.id === id);
|
||||
if (index === -1) return undefined;
|
||||
this._articles[index] = { ...this._articles[index]!, ...input };
|
||||
return this._articles[index];
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { injectable } from "inversify";
|
||||
|
||||
import type { IUsersRepository } from "@/application/repositories/users.repository.interface";
|
||||
import type { User } from "@/entities/models/user";
|
||||
|
||||
@injectable()
|
||||
export class MockUsersRepository implements IUsersRepository {
|
||||
private _users: User[] = [
|
||||
{ id: "1", username: "alice", passwordHash: "hashed_password_alice" },
|
||||
{ id: "2", username: "bob", passwordHash: "hashed_password_bob" },
|
||||
];
|
||||
|
||||
async getUser(id: string): Promise<User | undefined> {
|
||||
return this._users.find((u) => u.id === id);
|
||||
}
|
||||
|
||||
async getUserByUsername(username: string): Promise<User | undefined> {
|
||||
return this._users.find((u) => u.username === username);
|
||||
}
|
||||
|
||||
async createUser(input: User): Promise<User> {
|
||||
this._users.push(input);
|
||||
return input;
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { inject, injectable } from "inversify";
|
||||
|
||||
import type { IAuthenticationService } from "@/application/services/auth.service.interface";
|
||||
import type { IUsersRepository } from "@/application/repositories/users.repository.interface";
|
||||
import { UnauthenticatedError } from "@/entities/errors/auth";
|
||||
import { sessionSchema, type Session } from "@/entities/models/session";
|
||||
import type { Cookie } from "@/entities/models/cookie";
|
||||
import type { User } from "@/entities/models/user";
|
||||
import { DI_SYMBOLS } from "@/di/types";
|
||||
import { SESSION_COOKIE } from "@/config";
|
||||
|
||||
@injectable()
|
||||
export class MockAuthenticationService implements IAuthenticationService {
|
||||
private _sessions: Record<string, { session: Session; user: User }> = {};
|
||||
|
||||
constructor(
|
||||
@inject(DI_SYMBOLS.IUsersRepository)
|
||||
private _usersRepository: IUsersRepository
|
||||
) {}
|
||||
|
||||
generateUserId(): string {
|
||||
return (Math.random() + 1).toString(36).substring(7);
|
||||
}
|
||||
|
||||
async hashPassword(password: string): Promise<string> {
|
||||
return `hashed_${password}`;
|
||||
}
|
||||
|
||||
async verifyPassword(hash: string, password: string): Promise<boolean> {
|
||||
return hash === `hashed_${password}`;
|
||||
}
|
||||
|
||||
async validateSession(
|
||||
sessionId: string
|
||||
): Promise<{ user: User; session: Session }> {
|
||||
const result = this._sessions[sessionId];
|
||||
if (!result) {
|
||||
throw new UnauthenticatedError("Unauthenticated");
|
||||
}
|
||||
const user = await this._usersRepository.getUser(result.user.id);
|
||||
if (!user) {
|
||||
throw new UnauthenticatedError("Unauthenticated");
|
||||
}
|
||||
return { user, session: result.session };
|
||||
}
|
||||
|
||||
async createSession(
|
||||
user: User
|
||||
): Promise<{ session: Session; cookie: Cookie }> {
|
||||
const session = sessionSchema.parse({
|
||||
id: "session_" + user.id,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 86400000 * 7),
|
||||
});
|
||||
const cookie: Cookie = {
|
||||
name: SESSION_COOKIE,
|
||||
value: session.id,
|
||||
attributes: {},
|
||||
};
|
||||
this._sessions[session.id] = { session, user };
|
||||
return { session, cookie };
|
||||
}
|
||||
|
||||
async invalidateSession(
|
||||
sessionId: string
|
||||
): Promise<{ blankCookie: Cookie }> {
|
||||
delete this._sessions[sessionId];
|
||||
return {
|
||||
blankCookie: { name: SESSION_COOKIE, value: "", attributes: {} },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { injectable } from "inversify";
|
||||
|
||||
import type { ITelemetryService } from "@/application/services/telemetry.service.interface";
|
||||
|
||||
@injectable()
|
||||
export class MockTelemetryService implements ITelemetryService {
|
||||
async startSpan<T>(_name: string, fn: () => T | Promise<T>): Promise<T> {
|
||||
return fn();
|
||||
}
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
# Controllers -- Interface Adapters Layer
|
||||
|
||||
**Path:** `packages/core/src/interface-adapters/controllers/`
|
||||
**Role:** Controllers are the outermost layer within `@repo/core`. They validate external input using Zod schemas, delegate to use cases for business logic, and return results. Controllers are called by tRPC router procedures in `@repo/api`.
|
||||
|
||||
---
|
||||
|
||||
## Rules
|
||||
|
||||
1. Controllers validate input with Zod `safeParse` and throw `InputParseError` on failure.
|
||||
2. Controllers call use cases -- they **NEVER** contain business logic themselves.
|
||||
3. Controllers import from `application/` (use cases) and `entities/` (types, errors) only.
|
||||
4. Controllers **NEVER** import from `infrastructure/`. They must not know about concrete data access.
|
||||
5. Controllers **NEVER** import from `di/` directly. They call use case functions, which internally use `getInjection()`.
|
||||
6. Each controller function is a plain `async function`, not a class. This keeps them lightweight and easy to call from any transport layer (tRPC, REST, GraphQL).
|
||||
7. Input parameters use `Partial<z.infer<typeof schema>>` to accept potentially incomplete input, then validate with `safeParse`.
|
||||
|
||||
---
|
||||
|
||||
## Existing Controllers
|
||||
|
||||
| Controller | File | Functions | Calls |
|
||||
|---|---|---|---|
|
||||
| Sign In | `auth/sign-in.controller.ts` | `signInController(input)` | `signInUseCase` |
|
||||
| Sign Up | `auth/sign-up.controller.ts` | `signUpController(input)` | `signUpUseCase` |
|
||||
| Sign Out | `auth/sign-out.controller.ts` | `signOutController(sessionId)` | `signOutUseCase` |
|
||||
| Articles | `content/articles.controller.ts` | `createArticleController(input)`, `getArticlesController(input)` | `createArticleUseCase`, `getArticlesUseCase` |
|
||||
|
||||
---
|
||||
|
||||
## Complete Controller Template
|
||||
|
||||
```typescript
|
||||
// src/interface-adapters/controllers/{domain}/{name}.controller.ts
|
||||
import { z } from "zod";
|
||||
|
||||
import { InputParseError } from "@/entities/errors/common";
|
||||
import type { MyEntity } from "@/entities/models/my-entity";
|
||||
import { myUseCase } from "@/application/use-cases/{domain}/my.use-case";
|
||||
|
||||
// 1. Define input validation schema
|
||||
const inputSchema = z.object({
|
||||
field1: z.string().min(1).max(255),
|
||||
field2: z.number().positive(),
|
||||
optionalField: z.string().optional(),
|
||||
});
|
||||
|
||||
// 2. Export the controller function
|
||||
export async function myController(
|
||||
input: Partial<z.infer<typeof inputSchema>>
|
||||
): Promise<MyEntity> {
|
||||
// 3. Validate input with safeParse
|
||||
const { data, error: inputParseError } = inputSchema.safeParse(input);
|
||||
|
||||
// 4. Throw InputParseError if validation fails
|
||||
if (inputParseError) {
|
||||
throw new InputParseError("Invalid data", { cause: inputParseError });
|
||||
}
|
||||
|
||||
// 5. Delegate to use case and return result
|
||||
return await myUseCase(data);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Mapping Pattern
|
||||
|
||||
Controllers handle two categories of errors:
|
||||
|
||||
### 1. Input Validation Errors (thrown by the controller itself)
|
||||
|
||||
When Zod `safeParse` fails, the controller throws `InputParseError` with the `ZodError` as `cause`:
|
||||
|
||||
```typescript
|
||||
const { data, error: inputParseError } = inputSchema.safeParse(input);
|
||||
if (inputParseError) {
|
||||
throw new InputParseError("Invalid data", { cause: inputParseError });
|
||||
}
|
||||
```
|
||||
|
||||
The tRPC router (or any other caller) can inspect `error.cause` to extract field-level validation messages.
|
||||
|
||||
### 2. Business Logic Errors (thrown by use cases, pass through the controller)
|
||||
|
||||
Use cases throw domain errors (`AuthenticationError`, `NotFoundError`, etc.). Controllers do NOT catch these -- they propagate to the caller. The tRPC error handler maps them to appropriate HTTP status codes.
|
||||
|
||||
### Error Flow
|
||||
|
||||
```
|
||||
Controller
|
||||
|
|
||||
+--> InputParseError (Zod validation failed) -> 400 Bad Request
|
||||
|
|
||||
+--> Use Case
|
||||
|
|
||||
+--> AuthenticationError -> 401 Unauthorized
|
||||
+--> UnauthenticatedError -> 401 Unauthorized
|
||||
+--> UnauthorizedError -> 403 Forbidden
|
||||
+--> NotFoundError -> 404 Not Found
|
||||
+--> (unexpected) -> 500 Internal Server Error
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Real Examples
|
||||
|
||||
### Simple validation (sign-in.controller.ts)
|
||||
|
||||
```typescript
|
||||
import { z } from "zod";
|
||||
import { InputParseError } from "@/entities/errors/common";
|
||||
import type { Cookie } from "@/entities/models/cookie";
|
||||
import { signInUseCase } from "@/application/use-cases/auth/sign-in.use-case";
|
||||
|
||||
const inputSchema = z.object({
|
||||
username: z.string().min(3).max(31),
|
||||
password: z.string().min(6).max(255),
|
||||
});
|
||||
|
||||
export async function signInController(
|
||||
input: Partial<z.infer<typeof inputSchema>>
|
||||
): Promise<Cookie> {
|
||||
const { data, error: inputParseError } = inputSchema.safeParse(input);
|
||||
|
||||
if (inputParseError) {
|
||||
throw new InputParseError("Invalid data", { cause: inputParseError });
|
||||
}
|
||||
|
||||
const { cookie } = await signInUseCase(data);
|
||||
return cookie;
|
||||
}
|
||||
```
|
||||
|
||||
### Cross-field validation (sign-up.controller.ts)
|
||||
|
||||
```typescript
|
||||
const inputSchema = z
|
||||
.object({
|
||||
username: z.string().min(3).max(31),
|
||||
password: z.string().min(6).max(255),
|
||||
confirmPassword: z.string().min(6).max(255),
|
||||
})
|
||||
.superRefine(({ password, confirmPassword }, ctx) => {
|
||||
if (confirmPassword !== password) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "The passwords did not match",
|
||||
path: ["password"],
|
||||
});
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "The passwords did not match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Multiple functions in one file (articles.controller.ts)
|
||||
|
||||
When a domain has related operations, group them in one controller file:
|
||||
|
||||
```typescript
|
||||
const createInputSchema = z.object({ title: z.string().min(1), /* ... */ });
|
||||
const getInputSchema = z.object({ status: z.string().optional(), /* ... */ });
|
||||
|
||||
export async function createArticleController(input) { /* ... */ }
|
||||
export async function getArticlesController(input) { /* ... */ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Controller (Recipe)
|
||||
|
||||
### Step 1: Create the controller file
|
||||
|
||||
Create `src/interface-adapters/controllers/{domain}/{name}.controller.ts` following the template.
|
||||
|
||||
### Step 2: Define the Zod input schema
|
||||
|
||||
Define validation rules that match what the tRPC router will pass in. Use `.safeParse()`, not `.parse()`.
|
||||
|
||||
### Step 3: Implement the controller function
|
||||
|
||||
Validate -> delegate to use case -> return result. No business logic.
|
||||
|
||||
### Step 4: Export from core
|
||||
|
||||
Add to `src/index.ts`:
|
||||
|
||||
```typescript
|
||||
export { myController } from "./interface-adapters/controllers/{domain}/{name}.controller";
|
||||
```
|
||||
|
||||
### Step 5: Create tRPC router procedure
|
||||
|
||||
In `packages/api/src/router/{domain}.router.ts`, import the controller from `@repo/core` and wire it:
|
||||
|
||||
```typescript
|
||||
import { myController } from "@repo/core";
|
||||
|
||||
myProcedure: publicProcedure
|
||||
.input(z.object({ /* same shape as controller schema */ }))
|
||||
.mutation(async ({ input }) => {
|
||||
return await myController(input);
|
||||
}),
|
||||
```
|
||||
|
||||
### Step 6: Write tests
|
||||
|
||||
Create `tests/unit/controllers/{domain}/{name}.controller.test.ts`:
|
||||
|
||||
```typescript
|
||||
import "reflect-metadata";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
destroyContainer,
|
||||
initializeContainer,
|
||||
} from "@/di/container";
|
||||
import { myController } from "@/interface-adapters/controllers/{domain}/{name}.controller";
|
||||
import { InputParseError } from "@/entities/errors/common";
|
||||
|
||||
beforeEach(() => {
|
||||
initializeContainer();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
destroyContainer();
|
||||
});
|
||||
|
||||
describe("myController", () => {
|
||||
it("succeeds with valid input", async () => {
|
||||
const result = await myController({
|
||||
field1: "valid",
|
||||
field2: 42,
|
||||
});
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it("throws InputParseError for invalid input", async () => {
|
||||
await expect(
|
||||
myController({ field1: "" }) // missing required fields
|
||||
).rejects.toBeInstanceOf(InputParseError);
|
||||
});
|
||||
|
||||
it("propagates domain errors from use case", async () => {
|
||||
await expect(
|
||||
myController({ /* input that triggers domain error */ })
|
||||
).rejects.toBeInstanceOf(SomeDomainError);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
interface-adapters/
|
||||
controllers/
|
||||
AGENTS.md
|
||||
auth/
|
||||
sign-in.controller.ts
|
||||
sign-up.controller.ts
|
||||
sign-out.controller.ts
|
||||
content/
|
||||
articles.controller.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cross-References
|
||||
|
||||
- `application/AGENTS.md` -- Use cases that controllers delegate to
|
||||
- `entities/AGENTS.md` -- Error classes and entity types used in controllers
|
||||
- Root `AGENTS.md` -- How controllers fit into the full data flow
|
||||
- `packages/api/AGENTS.md` -- tRPC routers that call controllers
|
||||
@@ -1,23 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { InputParseError } from "@/entities/errors/common";
|
||||
import type { Cookie } from "@/entities/models/cookie";
|
||||
import { signInUseCase } from "@/application/use-cases/auth/sign-in.use-case";
|
||||
|
||||
const inputSchema = z.object({
|
||||
username: z.string().min(3).max(31),
|
||||
password: z.string().min(6).max(255),
|
||||
});
|
||||
|
||||
export async function signInController(
|
||||
input: Partial<z.infer<typeof inputSchema>>
|
||||
): Promise<Cookie> {
|
||||
const { data, error: inputParseError } = inputSchema.safeParse(input);
|
||||
|
||||
if (inputParseError) {
|
||||
throw new InputParseError("Invalid data", { cause: inputParseError });
|
||||
}
|
||||
|
||||
const { cookie } = await signInUseCase(data);
|
||||
return cookie;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { InputParseError } from "@/entities/errors/common";
|
||||
import type { Cookie } from "@/entities/models/cookie";
|
||||
import { signOutUseCase } from "@/application/use-cases/auth/sign-out.use-case";
|
||||
|
||||
export async function signOutController(
|
||||
sessionId: string | undefined
|
||||
): Promise<Cookie> {
|
||||
if (!sessionId) {
|
||||
throw new InputParseError("Must provide a session ID");
|
||||
}
|
||||
|
||||
const { blankCookie } = await signOutUseCase(sessionId);
|
||||
return blankCookie;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { InputParseError } from "@/entities/errors/common";
|
||||
import { signUpUseCase } from "@/application/use-cases/auth/sign-up.use-case";
|
||||
|
||||
const inputSchema = z
|
||||
.object({
|
||||
username: z.string().min(3).max(31),
|
||||
password: z.string().min(6).max(255),
|
||||
confirmPassword: z.string().min(6).max(255),
|
||||
})
|
||||
.superRefine(({ password, confirmPassword }, ctx) => {
|
||||
if (confirmPassword !== password) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "The passwords did not match",
|
||||
path: ["password"],
|
||||
});
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "The passwords did not match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export async function signUpController(
|
||||
input: Partial<z.infer<typeof inputSchema>>
|
||||
): Promise<ReturnType<typeof signUpUseCase>> {
|
||||
const { data, error: inputParseError } = inputSchema.safeParse(input);
|
||||
|
||||
if (inputParseError) {
|
||||
throw new InputParseError("Invalid data", { cause: inputParseError });
|
||||
}
|
||||
|
||||
return await signUpUseCase(data);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { InputParseError } from "@/entities/errors/common";
|
||||
import type { Article } from "@/entities/models/article";
|
||||
import { createArticleUseCase } from "@/application/use-cases/content/create-article.use-case";
|
||||
import { getArticlesUseCase } from "@/application/use-cases/content/get-articles.use-case";
|
||||
|
||||
const createInputSchema = z.object({
|
||||
title: z.string().min(1).max(255),
|
||||
content: z.string(),
|
||||
authorId: z.string(),
|
||||
slug: z.string().optional(),
|
||||
});
|
||||
|
||||
const getInputSchema = z.object({
|
||||
status: z.string().optional(),
|
||||
authorId: z.string().optional(),
|
||||
limit: z.number().optional(),
|
||||
offset: z.number().optional(),
|
||||
});
|
||||
|
||||
export async function createArticleController(
|
||||
input: Partial<z.infer<typeof createInputSchema>>
|
||||
): Promise<Article> {
|
||||
const { data, error: inputParseError } = createInputSchema.safeParse(input);
|
||||
|
||||
if (inputParseError) {
|
||||
throw new InputParseError("Invalid data", { cause: inputParseError });
|
||||
}
|
||||
|
||||
return await createArticleUseCase(data);
|
||||
}
|
||||
|
||||
export async function getArticlesController(
|
||||
input: Partial<z.infer<typeof getInputSchema>>
|
||||
): Promise<Article[]> {
|
||||
const { data, error: inputParseError } = getInputSchema.safeParse(input);
|
||||
|
||||
if (inputParseError) {
|
||||
throw new InputParseError("Invalid data", { cause: inputParseError });
|
||||
}
|
||||
|
||||
return await getArticlesUseCase(data);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import "reflect-metadata";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
destroyContainer,
|
||||
initializeContainer,
|
||||
} from "@/di/container";
|
||||
import { signInController } from "@/interface-adapters/controllers/auth/sign-in.controller";
|
||||
import { InputParseError } from "@/entities/errors/common";
|
||||
import { AuthenticationError } from "@/entities/errors/auth";
|
||||
|
||||
beforeEach(() => {
|
||||
initializeContainer();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
destroyContainer();
|
||||
});
|
||||
|
||||
describe("signInController", () => {
|
||||
it("returns cookie for valid input", async () => {
|
||||
const cookie = await signInController({
|
||||
username: "alice",
|
||||
password: "password_alice",
|
||||
});
|
||||
expect(cookie).toHaveProperty("name");
|
||||
expect(cookie).toHaveProperty("value");
|
||||
});
|
||||
|
||||
it("throws InputParseError for invalid input", async () => {
|
||||
await expect(
|
||||
signInController({ username: "ab", password: "short" })
|
||||
).rejects.toBeInstanceOf(InputParseError);
|
||||
});
|
||||
|
||||
it("throws AuthenticationError for wrong credentials", async () => {
|
||||
await expect(
|
||||
signInController({ username: "alice", password: "wrongpassword" })
|
||||
).rejects.toBeInstanceOf(AuthenticationError);
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import "reflect-metadata";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
destroyContainer,
|
||||
initializeContainer,
|
||||
} from "@/di/container";
|
||||
import { signOutController } from "@/interface-adapters/controllers/auth/sign-out.controller";
|
||||
import { InputParseError } from "@/entities/errors/common";
|
||||
|
||||
beforeEach(() => {
|
||||
initializeContainer();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
destroyContainer();
|
||||
});
|
||||
|
||||
describe("signOutController", () => {
|
||||
it("returns blank cookie for valid session", async () => {
|
||||
const cookie = await signOutController("some-session-id");
|
||||
expect(cookie.value).toBe("");
|
||||
});
|
||||
|
||||
it("throws InputParseError when no session ID provided", async () => {
|
||||
await expect(signOutController(undefined)).rejects.toBeInstanceOf(
|
||||
InputParseError
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import "reflect-metadata";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
destroyContainer,
|
||||
initializeContainer,
|
||||
} from "@/di/container";
|
||||
import { signUpController } from "@/interface-adapters/controllers/auth/sign-up.controller";
|
||||
import { InputParseError } from "@/entities/errors/common";
|
||||
|
||||
beforeEach(() => {
|
||||
initializeContainer();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
destroyContainer();
|
||||
});
|
||||
|
||||
describe("signUpController", () => {
|
||||
it("returns session, cookie, and user for valid input", async () => {
|
||||
const result = await signUpController({
|
||||
username: "newuser",
|
||||
password: "securepassword",
|
||||
confirmPassword: "securepassword",
|
||||
});
|
||||
expect(result).toHaveProperty("session");
|
||||
expect(result).toHaveProperty("cookie");
|
||||
expect(result).toHaveProperty("user");
|
||||
});
|
||||
|
||||
it("throws InputParseError when passwords don't match", async () => {
|
||||
await expect(
|
||||
signUpController({
|
||||
username: "newuser",
|
||||
password: "password1",
|
||||
confirmPassword: "password2",
|
||||
})
|
||||
).rejects.toBeInstanceOf(InputParseError);
|
||||
});
|
||||
|
||||
it("throws InputParseError for missing fields", async () => {
|
||||
await expect(signUpController({})).rejects.toBeInstanceOf(InputParseError);
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import "reflect-metadata";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
destroyContainer,
|
||||
initializeContainer,
|
||||
} from "@/di/container";
|
||||
import {
|
||||
createArticleController,
|
||||
getArticlesController,
|
||||
} from "@/interface-adapters/controllers/content/articles.controller";
|
||||
import { InputParseError } from "@/entities/errors/common";
|
||||
|
||||
beforeEach(() => {
|
||||
initializeContainer();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
destroyContainer();
|
||||
});
|
||||
|
||||
describe("createArticleController", () => {
|
||||
it("creates an article with valid input", async () => {
|
||||
const result = await createArticleController({
|
||||
title: "Test Article",
|
||||
content: "Some content",
|
||||
authorId: "1",
|
||||
});
|
||||
expect(result.title).toBe("Test Article");
|
||||
expect(result.slug).toBe("test-article");
|
||||
});
|
||||
|
||||
it("throws InputParseError for missing title", async () => {
|
||||
await expect(
|
||||
createArticleController({ content: "content", authorId: "1" } as any)
|
||||
).rejects.toBeInstanceOf(InputParseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getArticlesController", () => {
|
||||
it("returns articles", async () => {
|
||||
await createArticleController({
|
||||
title: "Article",
|
||||
content: "Content",
|
||||
authorId: "1",
|
||||
});
|
||||
const result = await getArticlesController({});
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import "reflect-metadata";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
destroyContainer,
|
||||
initializeContainer,
|
||||
} from "@/di/container";
|
||||
import { signInUseCase } from "@/application/use-cases/auth/sign-in.use-case";
|
||||
import { AuthenticationError } from "@/entities/errors/auth";
|
||||
|
||||
beforeEach(() => {
|
||||
initializeContainer();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
destroyContainer();
|
||||
});
|
||||
|
||||
describe("signInUseCase", () => {
|
||||
it("returns session and cookie for valid credentials", async () => {
|
||||
const result = await signInUseCase({
|
||||
username: "alice",
|
||||
password: "password_alice",
|
||||
});
|
||||
expect(result).toHaveProperty("session");
|
||||
expect(result).toHaveProperty("cookie");
|
||||
expect(result.session.userId).toBe("1");
|
||||
});
|
||||
|
||||
it("throws AuthenticationError for non-existing user", async () => {
|
||||
await expect(
|
||||
signInUseCase({ username: "non-existing", password: "any" })
|
||||
).rejects.toBeInstanceOf(AuthenticationError);
|
||||
});
|
||||
|
||||
it("throws AuthenticationError for wrong password", async () => {
|
||||
await expect(
|
||||
signInUseCase({ username: "alice", password: "wrong" })
|
||||
).rejects.toBeInstanceOf(AuthenticationError);
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
import "reflect-metadata";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
destroyContainer,
|
||||
initializeContainer,
|
||||
} from "@/di/container";
|
||||
import { signOutUseCase } from "@/application/use-cases/auth/sign-out.use-case";
|
||||
|
||||
beforeEach(() => {
|
||||
initializeContainer();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
destroyContainer();
|
||||
});
|
||||
|
||||
describe("signOutUseCase", () => {
|
||||
it("returns a blank cookie", async () => {
|
||||
const result = await signOutUseCase("some-session-id");
|
||||
expect(result).toHaveProperty("blankCookie");
|
||||
expect(result.blankCookie.value).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import "reflect-metadata";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
destroyContainer,
|
||||
initializeContainer,
|
||||
} from "@/di/container";
|
||||
import { signUpUseCase } from "@/application/use-cases/auth/sign-up.use-case";
|
||||
import { AuthenticationError } from "@/entities/errors/auth";
|
||||
|
||||
beforeEach(() => {
|
||||
initializeContainer();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
destroyContainer();
|
||||
});
|
||||
|
||||
describe("signUpUseCase", () => {
|
||||
it("creates user and returns session, cookie, and user info", async () => {
|
||||
const result = await signUpUseCase({
|
||||
username: "newuser",
|
||||
password: "securepassword",
|
||||
});
|
||||
expect(result).toHaveProperty("session");
|
||||
expect(result).toHaveProperty("cookie");
|
||||
expect(result).toHaveProperty("user");
|
||||
expect(result.user.username).toBe("newuser");
|
||||
});
|
||||
|
||||
it("throws AuthenticationError if username is taken", async () => {
|
||||
await expect(
|
||||
signUpUseCase({ username: "alice", password: "anypassword" })
|
||||
).rejects.toBeInstanceOf(AuthenticationError);
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import "reflect-metadata";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
destroyContainer,
|
||||
initializeContainer,
|
||||
} from "@/di/container";
|
||||
import { createArticleUseCase } from "@/application/use-cases/content/create-article.use-case";
|
||||
|
||||
beforeEach(() => {
|
||||
initializeContainer();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
destroyContainer();
|
||||
});
|
||||
|
||||
describe("createArticleUseCase", () => {
|
||||
it("creates an article with generated slug and draft status", async () => {
|
||||
const result = await createArticleUseCase({
|
||||
title: "My First Article",
|
||||
content: "Hello world",
|
||||
authorId: "1",
|
||||
});
|
||||
expect(result.title).toBe("My First Article");
|
||||
expect(result.slug).toBe("my-first-article");
|
||||
expect(result.status).toBe("draft");
|
||||
expect(result.authorId).toBe("1");
|
||||
expect(result.id).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses provided slug if given", async () => {
|
||||
const result = await createArticleUseCase({
|
||||
title: "Another Article",
|
||||
content: "Content here",
|
||||
authorId: "1",
|
||||
slug: "custom-slug",
|
||||
});
|
||||
expect(result.slug).toBe("custom-slug");
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import "reflect-metadata";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
destroyContainer,
|
||||
initializeContainer,
|
||||
} from "@/di/container";
|
||||
import { createArticleUseCase } from "@/application/use-cases/content/create-article.use-case";
|
||||
import { getArticlesUseCase } from "@/application/use-cases/content/get-articles.use-case";
|
||||
|
||||
beforeEach(() => {
|
||||
initializeContainer();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
destroyContainer();
|
||||
});
|
||||
|
||||
describe("getArticlesUseCase", () => {
|
||||
it("returns empty array when no articles exist", async () => {
|
||||
const result = await getArticlesUseCase();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns created articles", async () => {
|
||||
await createArticleUseCase({
|
||||
title: "Article One",
|
||||
content: "Content one",
|
||||
authorId: "1",
|
||||
});
|
||||
await createArticleUseCase({
|
||||
title: "Article Two",
|
||||
content: "Content two",
|
||||
authorId: "1",
|
||||
});
|
||||
|
||||
const result = await getArticlesUseCase();
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("filters by status", async () => {
|
||||
await createArticleUseCase({
|
||||
title: "Draft Article",
|
||||
content: "Draft",
|
||||
authorId: "1",
|
||||
});
|
||||
|
||||
const result = await getArticlesUseCase({ status: "published" });
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"types": ["reflect-metadata", "node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reportsDirectory: "./tests/coverage",
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,345 +0,0 @@
|
||||
# @repo/ui -- Atomic Design Component Library
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared UI component library built with shadcn/ui patterns, Tailwind CSS v4, and organized by Atomic Design levels. All components have co-located Storybook stories. This package is consumed by all frontend apps (`apps/web-next`, `apps/web-tanstack`).
|
||||
|
||||
## Atomic Design Classification Guide
|
||||
|
||||
| Level | Definition | Examples | Import Rules |
|
||||
|---|---|---|---|
|
||||
| Atom | Single HTML element, cannot be broken down further | Button, Input, Label, Badge, Separator, Icon | Can import: `lib/`, `styles/`. NEVER import: molecules, organisms, templates |
|
||||
| Molecule | 2-3 atoms composed together, single responsibility | FormField, SearchBar, Tooltip, Select, Dropdown | Can import: `atoms/`, `lib/`. NEVER import: organisms, templates |
|
||||
| Organism | Complex self-contained section with multiple molecules/atoms | DataTable, Dialog, Header, Sidebar, Card, Navbar | Can import: `atoms/`, `molecules/`, `lib/`. NEVER import: templates |
|
||||
| Template | Page-level layout shell, content-agnostic (uses children/slots) | DashboardLayout, AuthLayout, MarketingLayout | Can import: `atoms/`, `molecules/`, `organisms/`, `lib/` |
|
||||
| Page | Template filled with real data | **LIVES IN `apps/`, NOT IN THIS PACKAGE** | N/A |
|
||||
|
||||
## Import Rules Table
|
||||
|
||||
| Level | Can import from | NEVER import from |
|
||||
|---|---|---|
|
||||
| Atoms | `lib/`, `hooks/`, `styles/` | `molecules/`, `organisms/`, `templates/` |
|
||||
| Molecules | `atoms/`, `lib/`, `hooks/` | `organisms/`, `templates/` |
|
||||
| Organisms | `atoms/`, `molecules/`, `lib/`, `hooks/` | `templates/` |
|
||||
| Templates | `atoms/`, `molecules/`, `organisms/`, `lib/`, `hooks/` | (nothing above -- top level) |
|
||||
|
||||
## Component Rules Per Level
|
||||
|
||||
- **Atoms:** No margins or positioning (consumer controls layout). No internal state. No business logic. Accept `className` prop for composition. Use `forwardRef` for DOM elements.
|
||||
- **Molecules:** Single responsibility. Minimal controlled state (e.g., open/closed). Compose atoms only. Accept `className` for outer container.
|
||||
- **Organisms:** Can have internal state and sub-components. Can fetch context. Self-contained sections of a page.
|
||||
- **Templates:** Use `children` or named slots (`sidebar`, `header`, etc.) for content injection. NEVER hard-code content or data.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
packages/ui/
|
||||
src/
|
||||
lib/
|
||||
utils.ts # cn() utility (clsx + twMerge)
|
||||
styles/
|
||||
globals.css # Tailwind v4 @theme tokens, @import "tailwindcss"
|
||||
atoms/
|
||||
button/
|
||||
button.tsx # Button component (5 variants, 3 sizes)
|
||||
button.stories.tsx # Storybook stories
|
||||
index.ts # Barrel export
|
||||
input/
|
||||
input.tsx # Input component
|
||||
input.stories.tsx
|
||||
index.ts
|
||||
label/
|
||||
label.tsx # Label component
|
||||
index.ts
|
||||
index.ts # Barrel: re-exports all atoms
|
||||
molecules/
|
||||
form-field/
|
||||
form-field.tsx # FormField = Label + Input + error/description
|
||||
form-field.stories.tsx
|
||||
index.ts
|
||||
index.ts # Barrel: re-exports all molecules
|
||||
organisms/
|
||||
index.ts # Barrel (empty -- no organisms yet)
|
||||
templates/
|
||||
index.ts # Barrel (empty -- no templates yet)
|
||||
index.ts # Package entry: re-exports cn + all levels
|
||||
package.json
|
||||
AGENTS.md
|
||||
```
|
||||
|
||||
## Existing Components
|
||||
|
||||
### Button (Atom)
|
||||
|
||||
- **Variants:** `default`, `secondary`, `destructive`, `outline`, `ghost`
|
||||
- **Sizes:** `sm` (h-9), `default` (h-10), `lg` (h-11)
|
||||
- **Props:** Extends `ButtonHTMLAttributes<HTMLButtonElement>` plus `variant` and `size`
|
||||
- **Uses:** `forwardRef`, `cn()` for class merging
|
||||
|
||||
### Input (Atom)
|
||||
|
||||
- **Props:** Extends `InputHTMLAttributes<HTMLInputElement>`
|
||||
- **Uses:** `forwardRef`, `cn()` for class merging
|
||||
- Full styling: border, focus ring, disabled state, file input support
|
||||
|
||||
### Label (Atom)
|
||||
|
||||
- **Props:** Extends `LabelHTMLAttributes<HTMLLabelElement>`
|
||||
- **Uses:** `forwardRef`, `cn()` for class merging
|
||||
- Handles `peer-disabled` state
|
||||
|
||||
### FormField (Molecule)
|
||||
|
||||
- **Props:** Extends `InputProps` plus `label` (string), `error?` (string), `description?` (string)
|
||||
- **Composes:** Label + Input
|
||||
- Auto-generates `id` from label text if not provided
|
||||
|
||||
## cn() Utility
|
||||
|
||||
The `cn()` function merges Tailwind classes safely using `clsx` + `tailwind-merge`:
|
||||
|
||||
```typescript
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
```
|
||||
|
||||
Usage in components:
|
||||
|
||||
```tsx
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md", // base classes
|
||||
variantStyles[variant], // variant classes
|
||||
sizeStyles[size], // size classes
|
||||
className // consumer override
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
`cn()` ensures that consumer-provided classes properly override component defaults (e.g., `cn("bg-red-500", "bg-blue-500")` yields `"bg-blue-500"`).
|
||||
|
||||
## Tailwind v4 CSS-First Configuration
|
||||
|
||||
This project uses **Tailwind CSS v4**, which replaces `tailwind.config.ts` with CSS-first configuration. There is no `tailwind.config.ts` file. All design tokens are defined in `src/styles/globals.css` using the `@theme` directive:
|
||||
|
||||
```css
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-background: hsl(0 0% 100%);
|
||||
--color-foreground: hsl(240 10% 3.9%);
|
||||
--color-primary: hsl(240 5.9% 10%);
|
||||
--color-primary-foreground: hsl(0 0% 98%);
|
||||
--color-secondary: hsl(240 4.8% 95.9%);
|
||||
--color-destructive: hsl(0 84.2% 60.2%);
|
||||
--color-border: hsl(240 5.9% 90%);
|
||||
--color-input: hsl(240 5.9% 90%);
|
||||
--color-ring: hsl(240 5.9% 10%);
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
}
|
||||
```
|
||||
|
||||
To add new tokens, add them inside the `@theme { }` block in `globals.css`. Classes like `bg-primary`, `text-destructive`, `rounded-lg` reference these tokens automatically.
|
||||
|
||||
## shadcn/ui Workflow
|
||||
|
||||
When adding a new shadcn/ui component:
|
||||
|
||||
1. **Install:** `pnpm dlx shadcn@latest add [component]` -- it lands in `atoms/` by default
|
||||
2. **Classify:** Check the Atomic Design classification table above
|
||||
3. **Relocate:** If the component is not an atom, move it to the correct level directory
|
||||
4. **Story:** Create a `.stories.tsx` file next to the component
|
||||
5. **Export:** Add to the level's `index.ts` barrel file
|
||||
|
||||
## Storybook MCP Integration
|
||||
|
||||
Before creating any new UI component, query the Storybook MCP to check for existing components:
|
||||
|
||||
- **`list-all-documentation`** -- discover all existing components and their stories
|
||||
- **`get-documentation`** -- understand existing component props, variants, and usage
|
||||
- **`run-story-tests`** -- validate your new story renders correctly after creation
|
||||
|
||||
Storybook MCP is available at `http://localhost:6006/mcp` when Storybook is running.
|
||||
|
||||
## Complete Story File Template
|
||||
|
||||
```tsx
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { MyComponent } from "./my-component";
|
||||
|
||||
const meta = {
|
||||
title: "{Level}/{ComponentName}", // e.g., "Atoms/Button", "Molecules/FormField"
|
||||
component: MyComponent,
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
// Define controls for interactive props
|
||||
variant: {
|
||||
control: "select",
|
||||
options: ["default", "secondary"],
|
||||
},
|
||||
size: {
|
||||
control: "select",
|
||||
options: ["sm", "default", "lg"],
|
||||
},
|
||||
disabled: { control: "boolean" },
|
||||
},
|
||||
} satisfies Meta<typeof MyComponent>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: "Default",
|
||||
},
|
||||
};
|
||||
|
||||
export const AnotherVariant: Story = {
|
||||
args: {
|
||||
children: "Another Variant",
|
||||
variant: "secondary",
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Recipe: Adding a New Component (8 Steps)
|
||||
|
||||
This example adds a `Badge` atom component.
|
||||
|
||||
### Step 1: Check Storybook MCP for existing components
|
||||
|
||||
Query `list-all-documentation` to confirm no Badge component exists.
|
||||
|
||||
### Step 2: Classify the component
|
||||
|
||||
Badge is a single HTML element displaying a short label -- it is an **Atom**.
|
||||
|
||||
### Step 3: Create the component directory
|
||||
|
||||
```
|
||||
src/atoms/badge/
|
||||
badge.tsx
|
||||
badge.stories.tsx
|
||||
index.ts
|
||||
```
|
||||
|
||||
### Step 4: Write the component
|
||||
|
||||
`src/atoms/badge/badge.tsx`:
|
||||
|
||||
```tsx
|
||||
import { type HTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
variant?: "default" | "secondary" | "destructive" | "outline";
|
||||
}
|
||||
|
||||
const variantStyles: Record<NonNullable<BadgeProps["variant"]>, string> = {
|
||||
default: "bg-primary text-primary-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground",
|
||||
destructive: "bg-destructive text-destructive-foreground",
|
||||
outline: "border border-input bg-background text-foreground",
|
||||
};
|
||||
|
||||
export function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors",
|
||||
variantStyles[variant],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Create the barrel export
|
||||
|
||||
`src/atoms/badge/index.ts`:
|
||||
|
||||
```typescript
|
||||
export { Badge, type BadgeProps } from "./badge";
|
||||
```
|
||||
|
||||
### Step 6: Write the story
|
||||
|
||||
`src/atoms/badge/badge.stories.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Badge } from "./badge";
|
||||
|
||||
const meta = {
|
||||
title: "Atoms/Badge",
|
||||
component: Badge,
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: "select",
|
||||
options: ["default", "secondary", "destructive", "outline"],
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Badge>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { children: "Badge" },
|
||||
};
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: { children: "Secondary", variant: "secondary" },
|
||||
};
|
||||
|
||||
export const Destructive: Story = {
|
||||
args: { children: "Error", variant: "destructive" },
|
||||
};
|
||||
|
||||
export const Outline: Story = {
|
||||
args: { children: "Outline", variant: "outline" },
|
||||
};
|
||||
```
|
||||
|
||||
### Step 7: Add to atoms barrel
|
||||
|
||||
Edit `src/atoms/index.ts`:
|
||||
|
||||
```typescript
|
||||
export { Button, type ButtonProps } from "./button/index";
|
||||
export { Input, type InputProps } from "./input/index";
|
||||
export { Label, type LabelProps } from "./label/index";
|
||||
export { Badge, type BadgeProps } from "./badge/index"; // <-- add
|
||||
```
|
||||
|
||||
### Step 8: Validate
|
||||
|
||||
Run `run-story-tests` via Storybook MCP to confirm the stories render correctly.
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Purpose |
|
||||
|---|---|
|
||||
| `react` | JSX runtime |
|
||||
| `clsx` | Conditional class string builder |
|
||||
| `tailwind-merge` | Intelligent Tailwind class merging (deduplication) |
|
||||
| `tailwindcss` (devDep) | Tailwind CSS v4 engine |
|
||||
|
||||
## Cross-References
|
||||
|
||||
- **Storybook app:** `apps/storybook/` -- see `apps/storybook/AGENTS.md`
|
||||
- **Consumed by Next.js:** `apps/web-next/` -- see `apps/web-next/AGENTS.md`
|
||||
- **Consumed by TanStack:** `apps/web-tanstack/` -- see `apps/web-tanstack/AGENTS.md`
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"name": "@repo/ui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"build": "echo 'typechecked by consuming app bundler'",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.0",
|
||||
"react": "^19.0.0",
|
||||
"tailwind-merge": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/react": "^19.0.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"vitest": "^3.1.0"
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Button } from "./button";
|
||||
|
||||
const meta = {
|
||||
title: "Atoms/Button",
|
||||
component: Button,
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: "select",
|
||||
options: ["default", "secondary", "destructive", "outline", "ghost"],
|
||||
},
|
||||
size: { control: "select", options: ["sm", "default", "lg"] },
|
||||
},
|
||||
} satisfies Meta<typeof Button>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { children: "Button", variant: "default" },
|
||||
};
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: { children: "Secondary", variant: "secondary" },
|
||||
};
|
||||
|
||||
export const Destructive: Story = {
|
||||
args: { children: "Destructive", variant: "destructive" },
|
||||
};
|
||||
|
||||
export const Outline: Story = {
|
||||
args: { children: "Outline", variant: "outline" },
|
||||
};
|
||||
|
||||
export const Ghost: Story = {
|
||||
args: { children: "Ghost", variant: "ghost" },
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "default" | "secondary" | "destructive" | "outline" | "ghost";
|
||||
size?: "sm" | "default" | "lg";
|
||||
}
|
||||
|
||||
const variantStyles: Record<NonNullable<ButtonProps["variant"]>, string> = {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
};
|
||||
|
||||
const sizeStyles: Record<NonNullable<ButtonProps["size"]>, string> = {
|
||||
sm: "h-9 px-3 text-sm",
|
||||
default: "h-10 px-4 py-2",
|
||||
lg: "h-11 px-8 text-lg",
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = "default", size = "default", ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
variantStyles[variant],
|
||||
sizeStyles[size],
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
@@ -1 +0,0 @@
|
||||
export { Button, type ButtonProps } from "./button";
|
||||
@@ -1,3 +0,0 @@
|
||||
export { Button, type ButtonProps } from "./button/index";
|
||||
export { Input, type InputProps } from "./input/index";
|
||||
export { Label, type LabelProps } from "./label/index";
|
||||
@@ -1 +0,0 @@
|
||||
export { Input, type InputProps } from "./input";
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Input } from "./input";
|
||||
|
||||
const meta = {
|
||||
title: "Atoms/Input",
|
||||
component: Input,
|
||||
tags: ["autodocs"],
|
||||
} satisfies Meta<typeof Input>;
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { placeholder: "Enter text..." },
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { placeholder: "Disabled", disabled: true },
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user