# Vertical Refactor — Plan 2: Blog Feature (Canonical Migration)

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Migrate the existing `content/articles` domain (currently inside `packages/core` and `packages/cms-core`) into a new vertical `packages/blog` feature package with the full canonical layered shape: entities → application → infrastructure → interface-adapters → di → integrations/cms → integrations/api → ui. Wire the feature's `/cms` export into `core-cms` composition and its `/api` export into `core-api` composition. Establish the per-feature InversifyJS container pattern. Pattern proven here will be replicated for `auth`, `media`, `marketing-pages`, and `navigation` in subsequent plans.

**Architecture:** One new package (`@repo/blog`) with the full canonical structure. Per-feature InversifyJS container holds the `IArticlesRepository` binding (mock by default for tests; payload-backed in production via `getPayload({ config })` from `@repo/core-cms`). tRPC procedures in `integrations/api/router.ts` call controllers, which Zod-parse and delegate to use-cases, which resolve the repo via the feature container. Articles collection moves from `cms-core` into `blog/integrations/cms/collections/`.

**Tech Stack:** InversifyJS 6.x with reflect-metadata, Vitest 3.x, Zod 3, tRPC 11, Payload 3.14 Local API.

**Plan position:** Plan 2 of 6 in the vertical-refactor sequence (revised from original 4 during execution to keep each plan digestible):
- Plan 1 ✅ Foundation — core-* package scaffolds + core-shared primitives + core-cms stub
- **Plan 2 (this doc):** Blog feature (canonical) — full layered shape, simplified schema (no cross-feature relationships)
- Plan 3: Auth + Media features + restore blog's `author` relationship and `featuredImage`
- Plan 4: Marketing-pages + Navigation features
- Plan 5: App + UI integration — core-trpc client/providers, route handlers, example pages
- Plan 6: Cleanup + boundary enforcement + Playwright + docs rewrite

**Spec reference:** `docs/superpowers/specs/2026-04-21-vertical-monorepo-refactor-design.md`

---

## Schema simplifications in this plan

The existing `Articles` collection in `packages/cms-core/src/collections/articles/fields.ts` declares two cross-feature references:
- `author: relationship → users` (users collection is owned by the auth feature, migrated in Plan 3)
- `featuredImage: upload → media` (media collection is owned by the media feature, migrated in Plan 3)

To keep Plan 2 atomic, the migrated `articles.ts` collection here uses **simplified placeholders**:
- `author` becomes a `text` field (string).
- `featuredImage` is omitted entirely.

**Plan 3 restores both fields to their relationship/upload form.** This is called out explicitly in those Plan 3 tasks. The simplification is also documented in a TODO comment in the new `articles.ts` so an engineer reading the file later understands.

The domain `Article.content` field is also widened: the existing entity has `content: z.string()` but Payload's actual storage is rich-text JSON. New entity uses `content: z.unknown()` to honestly model rich-text content (matches spec §11.9's `body: unknown` pattern). The mock repository's tests are adjusted accordingly.

---

## File Structure (this plan creates/modifies)

**Create — new `@repo/blog` package:**
- `packages/blog/{package.json,tsconfig.json,turbo.json,vitest.config.ts}`
- `packages/blog/src/index.ts` — barrel export
- `packages/blog/src/entities/article.ts` + `article.test.ts`
- `packages/blog/src/entities/errors.ts`
- `packages/blog/src/application/repositories/articles-repository.interface.ts`
- `packages/blog/src/application/use-cases/get-articles.use-case.ts` + `get-articles.use-case.test.ts`
- `packages/blog/src/application/use-cases/create-article.use-case.ts` + `create-article.use-case.test.ts`
- `packages/blog/src/infrastructure/repositories/mock-articles.repository.ts`
- `packages/blog/src/infrastructure/repositories/payload-articles.repository.ts` + `payload-articles.repository.test.ts`
- `packages/blog/src/interface-adapters/controllers/articles.controller.ts` + `articles.controller.test.ts`
- `packages/blog/src/di/symbols.ts`
- `packages/blog/src/di/module.ts`
- `packages/blog/src/di/container.ts` + `container.test.ts`
- `packages/blog/src/integrations/cms/collections/articles.ts`
- `packages/blog/src/integrations/cms/index.ts`
- `packages/blog/src/integrations/api/router.ts`
- `packages/blog/src/ui/query.ts`
- `packages/blog/tests/articles.feature.test.ts`

**Modify:**
- `packages/core-cms/src/payload.config.ts` — import + register `articles` from `@repo/blog/cms`
- `packages/core-api/src/root.ts` — import + register `blogRouter` from `@repo/blog/api`
- `tsconfig.base.json` — add `@repo/blog`, `@repo/blog/cms`, `@repo/blog/api` aliases
- `apps/cms/package.json` — add `@repo/blog` dependency (so the workspace resolves the schema import)

**Do NOT touch:**
- `packages/core/` (still owns auth domain — left intact for Plan 3)
- `packages/api/` (still wires the old content router; orphaned but unused after Plan 2 — actually used by ZERO consumers since reference apps are empty)
- `packages/cms-core/src/collections/articles/` — the OLD location. After Plan 2, both old and new exist; Plan 6 deletes `cms-core` entirely. Apps/cms reads from `core-cms` which only sees the NEW location.

---

## Phase 1: Blog package skeleton + entity layer

### Task 2.1: Scaffold @repo/blog package

**Files:**
- Create: `packages/blog/package.json`
- Create: `packages/blog/tsconfig.json`
- Create: `packages/blog/turbo.json`
- Create: `packages/blog/vitest.config.ts`
- Create: `packages/blog/src/index.ts`

- [ ] **Step 1: Create package.json**

```json
{
  "name": "@repo/blog",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "exports": {
    ".": "./src/index.ts",
    "./cms": "./src/integrations/cms/index.ts",
    "./api": "./src/integrations/api/router.ts"
  },
  "scripts": {
    "build": "tsc --noEmit",
    "lint": "eslint .",
    "test": "vitest run --passWithNoTests",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@repo/core-cms": "workspace:*",
    "@repo/core-shared": "workspace:*",
    "@trpc/server": "^11.0.0",
    "inversify": "^6.2.0",
    "payload": "^3.14.0",
    "reflect-metadata": "^0.2.2",
    "zod": "^3.24.0"
  },
  "devDependencies": {
    "@repo/core-eslint": "workspace:*",
    "@repo/core-typescript": "workspace:*",
    "@types/node": "^22.0.0",
    "vitest": "^3.1.0"
  }
}
```

- [ ] **Step 2: Create tsconfig.json**

```json
{
  "extends": "@repo/core-typescript/base.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src",
    "lib": ["ES2022", "DOM"],
    "jsx": "preserve",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src/**/*", "tests/**/*"],
  "exclude": ["node_modules", "dist"]
}
```

> Note: `@/*` path alias is defined here for use in **test files** only. Source files inside `src/` should use **relative imports** (`../../entities/article` etc.). Reason: when a downstream package like `@repo/core-api` typechecks and follows imports into `@repo/blog`, it descends into blog's source files but doesn't have blog's `@/` alias in its own tsconfig — causing TS2307 errors. Relative imports in source files avoid this. Tests stay in `tests/` or alongside source where they use the same package context, so `@/` works there. Same convention applies to all feature packages in subsequent plans.

- [ ] **Step 3: Create turbo.json**

```json
{
  "extends": ["//"],
  "tags": ["feature"]
}
```

- [ ] **Step 4: Create vitest.config.ts**

```typescript
import path from "node:path";
import { baseVitestConfig } from "@repo/core-typescript/vitest.base";

export default {
  ...baseVitestConfig,
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
};
```

> Note: Vitest does NOT automatically read tsconfig `paths`. The `@/` alias used in `src/**/*.ts` source/test files must be declared explicitly in vitest.config.ts via `resolve.alias`. Same pattern repeats for every feature package's vitest.config.ts.

- [ ] **Step 5: Create empty index.ts**

```typescript
export {};
```

- [ ] **Step 6: Add path aliases to root tsconfig.base.json**

Read the current `tsconfig.base.json`. Add three new entries to `compilerOptions.paths` (alphabetically — between `@repo/core-ui` and `@repo/core-shared` if existing list is sorted; otherwise just at the end of the paths object before the closing brace):

```json
"@repo/blog": ["packages/blog/src/index.ts"],
"@repo/blog/cms": ["packages/blog/src/integrations/cms/index.ts"],
"@repo/blog/api": ["packages/blog/src/integrations/api/router.ts"]
```

- [ ] **Step 7: Install + verify**

Run: `pnpm install`
Expected: `@repo/blog` registered in workspace; lockfile updated.

- [ ] **Step 8: Commit**

```bash
git add packages/blog tsconfig.base.json pnpm-lock.yaml
git commit -m "feat(blog): scaffold empty package with feature tag + path aliases"
```

---

### Task 2.2: Port Article entity (with content widened to unknown)

**Files:**
- Create: `packages/blog/src/entities/article.ts`
- Create: `packages/blog/src/entities/article.test.ts`
- Create: `packages/blog/src/entities/errors.ts`

- [ ] **Step 1: Write the failing test**

```typescript
// packages/blog/src/entities/article.test.ts
import { describe, expect, it } from "vitest";
import { articleSchema, articleStatusSchema, type Article } from "./article";

describe("articleSchema", () => {
  it("accepts a minimal valid article with default status", () => {
    const result = articleSchema.parse({
      id: "abc",
      title: "Hello",
      slug: "hello",
      content: { type: "doc", children: [] },
      authorId: "u1",
      createdAt: new Date(),
      updatedAt: new Date(),
    });
    expect(result.status).toBe("draft");
  });

  it("accepts unknown rich-text content", () => {
    const result = articleSchema.parse({
      id: "abc",
      title: "Hello",
      slug: "hello",
      content: "any string is also fine",
      authorId: "u1",
      createdAt: new Date(),
      updatedAt: new Date(),
    });
    expect(result.content).toBe("any string is also fine");
  });

  it("rejects empty title", () => {
    expect(() =>
      articleSchema.parse({
        id: "a",
        title: "",
        slug: "s",
        content: null,
        authorId: "u",
        createdAt: new Date(),
        updatedAt: new Date(),
      }),
    ).toThrow();
  });

  it("rejects title over 255 chars", () => {
    expect(() =>
      articleSchema.parse({
        id: "a",
        title: "x".repeat(256),
        slug: "s",
        content: null,
        authorId: "u",
        createdAt: new Date(),
        updatedAt: new Date(),
      }),
    ).toThrow();
  });
});

describe("articleStatusSchema", () => {
  it("accepts 'draft' and 'published'", () => {
    expect(articleStatusSchema.parse("draft")).toBe("draft");
    expect(articleStatusSchema.parse("published")).toBe("published");
  });

  it("rejects unknown status", () => {
    expect(() => articleStatusSchema.parse("archived")).toThrow();
  });
});

describe("Article type", () => {
  it("widens content to unknown", () => {
    const _example: Article = {
      id: "x",
      title: "t",
      slug: "s",
      content: { whatever: true },
      status: "draft",
      authorId: "u",
      createdAt: new Date(),
      updatedAt: new Date(),
    };
    expect(_example).toBeDefined();
  });
});
```

- [ ] **Step 2: Run — expect failure**

Run: `cd packages/blog && pnpm vitest run src/entities/article.test.ts`
Expected: FAIL — "Cannot find module './article'"

- [ ] **Step 3: Implement article entity**

```typescript
// packages/blog/src/entities/article.ts
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.unknown(),
  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>;
```

- [ ] **Step 4: Create errors.ts (domain-specific errors)**

```typescript
// packages/blog/src/entities/errors.ts
export class ArticleNotFoundError extends Error {
  constructor(message = "Article not found", options?: ErrorOptions) {
    super(message, options);
  }
}

export class InputParseError extends Error {
  constructor(message: string, options?: ErrorOptions) {
    super(message, options);
  }
}
```

> Note: `InputParseError` is duplicated here because the new architecture has each feature own its errors (no shared `@repo/core/entities/errors`). The class is small.

- [ ] **Step 5: Run — expect pass**

Run: `cd packages/blog && pnpm vitest run src/entities/article.test.ts`
Expected: PASS — 7 tests.

- [ ] **Step 6: Commit**

```bash
git add packages/blog/src/entities
git commit -m "feat(blog): add Article entity with rich-text content + domain errors"
```

---

## Phase 2: Application layer (use-cases + repository interface)

### Task 2.3: Define IArticlesRepository interface

**Files:**
- Create: `packages/blog/src/application/repositories/articles-repository.interface.ts`

- [ ] **Step 1: Implement** (no test — interface has no behavior; exercised through use-case tests)

```typescript
// packages/blog/src/application/repositories/articles-repository.interface.ts
import type { Article } from "@/entities/article";

export interface IArticlesRepository {
  getArticle(id: string): Promise<Article | undefined>;
  getArticleBySlug(slug: 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>;
}
```

> Note: `getArticleBySlug` is added beyond the original `packages/core` interface — needed by `articleBySlug` tRPC procedure (the canonical example query in spec §11.15).

- [ ] **Step 2: Verify it compiles**

Run: `cd packages/blog && pnpm typecheck`
Expected: PASS.

- [ ] **Step 3: Commit**

```bash
git add packages/blog/src/application/repositories
git commit -m "feat(blog): add IArticlesRepository interface"
```

---

### Task 2.4: Implement getArticles use-case + tests

**Files:**
- Create: `packages/blog/src/application/use-cases/get-articles.use-case.ts`
- Create: `packages/blog/src/application/use-cases/get-articles.use-case.test.ts`

- [ ] **Step 1: Write the failing test**

```typescript
// packages/blog/src/application/use-cases/get-articles.use-case.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import { blogContainer } from "@/di/container";
import { BLOG_SYMBOLS } from "@/di/symbols";
import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface";
import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository";
import { getArticlesUseCase } from "./get-articles.use-case";

describe("getArticlesUseCase", () => {
  let repo: MockArticlesRepository;

  beforeEach(() => {
    if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
      blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
    }
    repo = new MockArticlesRepository();
    blogContainer
      .bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
      .toConstantValue(repo);
  });

  it("returns all articles with no filters", async () => {
    const now = new Date();
    await repo.createArticle({
      id: "1",
      title: "A",
      slug: "a",
      content: null,
      status: "draft",
      authorId: "u1",
      createdAt: now,
      updatedAt: now,
    });
    const result = await getArticlesUseCase();
    expect(result).toHaveLength(1);
    expect(result[0]?.id).toBe("1");
  });

  it("filters by status", async () => {
    const now = new Date();
    await repo.createArticle({
      id: "1",
      title: "A",
      slug: "a",
      content: null,
      status: "draft",
      authorId: "u1",
      createdAt: now,
      updatedAt: now,
    });
    await repo.createArticle({
      id: "2",
      title: "B",
      slug: "b",
      content: null,
      status: "published",
      authorId: "u1",
      createdAt: now,
      updatedAt: now,
    });
    const result = await getArticlesUseCase({ status: "published" });
    expect(result).toHaveLength(1);
    expect(result[0]?.id).toBe("2");
  });
});
```

- [ ] **Step 2: Run — expect failure**

Run: `cd packages/blog && pnpm vitest run src/application/use-cases/get-articles.use-case.test.ts`
Expected: FAIL — multiple "Cannot find module" errors (the test imports from `@/di/container`, `@/di/symbols`, `@/infrastructure/repositories/mock-articles.repository`, none of which exist yet).

> This is intentional: TDD here means the use-case test exercises the full DI container shape AND the mock repo. Both will be implemented in subsequent tasks. For now, just write the use-case to satisfy its direct dependencies.

- [ ] **Step 3: Implement use-case**

```typescript
// packages/blog/src/application/use-cases/get-articles.use-case.ts
import type { Article } from "@/entities/article";
import { blogContainer } from "@/di/container";
import { BLOG_SYMBOLS } from "@/di/symbols";
import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface";

export async function getArticlesUseCase(options?: {
  status?: string;
  authorId?: string;
  limit?: number;
  offset?: number;
}): Promise<Article[]> {
  const repo = blogContainer.get<IArticlesRepository>(
    BLOG_SYMBOLS.IArticlesRepository,
  );
  return repo.getArticles(options);
}
```

> Note: This still won't compile or run because `@/di/container`, `@/di/symbols`, `@/infrastructure/repositories/mock-articles.repository` don't exist yet. Test will continue to fail. That's expected — Tasks 2.5-2.7 implement those, then this test passes.

- [ ] **Step 4: Commit (test + impl, both currently failing — establishes the contract)**

```bash
git add packages/blog/src/application/use-cases/get-articles.use-case.ts packages/blog/src/application/use-cases/get-articles.use-case.test.ts
git commit -m "feat(blog): add getArticlesUseCase (test red until DI + mock repo exist)"
```

---

### Task 2.5: Implement createArticle use-case + tests

**Files:**
- Create: `packages/blog/src/application/use-cases/create-article.use-case.ts`
- Create: `packages/blog/src/application/use-cases/create-article.use-case.test.ts`

- [ ] **Step 1: Write the failing test**

```typescript
// packages/blog/src/application/use-cases/create-article.use-case.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import { blogContainer } from "@/di/container";
import { BLOG_SYMBOLS } from "@/di/symbols";
import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface";
import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository";
import { createArticleUseCase } from "./create-article.use-case";

describe("createArticleUseCase", () => {
  let repo: MockArticlesRepository;

  beforeEach(() => {
    if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
      blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
    }
    repo = new MockArticlesRepository();
    blogContainer
      .bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
      .toConstantValue(repo);
  });

  it("creates an article in draft status with auto-generated slug", async () => {
    const result = await createArticleUseCase({
      title: "Hello World",
      content: "body",
      authorId: "u1",
    });
    expect(result.title).toBe("Hello World");
    expect(result.slug).toBe("hello-world");
    expect(result.status).toBe("draft");
    expect(result.id).toBeTruthy();

    const stored = await repo.getArticle(result.id);
    expect(stored).toBeDefined();
  });

  it("uses provided slug when supplied", async () => {
    const result = await createArticleUseCase({
      title: "Whatever",
      content: "body",
      authorId: "u1",
      slug: "custom-slug",
    });
    expect(result.slug).toBe("custom-slug");
  });
});
```

- [ ] **Step 2: Run — expect failure**

Run: `cd packages/blog && pnpm vitest run src/application/use-cases/create-article.use-case.test.ts`
Expected: FAIL — module-not-found errors.

- [ ] **Step 3: Implement use-case**

```typescript
// packages/blog/src/application/use-cases/create-article.use-case.ts
import type { Article } from "@/entities/article";
import { blogContainer } from "@/di/container";
import { BLOG_SYMBOLS } from "@/di/symbols";
import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface";

function generateSlug(title: string): string {
  return title
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "");
}

export async function createArticleUseCase(input: {
  title: string;
  content: unknown;
  authorId: string;
  slug?: string;
}): Promise<Article> {
  const repo = blogContainer.get<IArticlesRepository>(
    BLOG_SYMBOLS.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 repo.createArticle(article);
}
```

- [ ] **Step 4: Commit (test still failing — DI not yet implemented)**

```bash
git add packages/blog/src/application/use-cases/create-article.use-case.ts packages/blog/src/application/use-cases/create-article.use-case.test.ts
git commit -m "feat(blog): add createArticleUseCase (test red until DI + mock repo exist)"
```

---

## Phase 3: Infrastructure layer (mock + payload repos)

### Task 2.6: Mock articles repository

**Files:**
- Create: `packages/blog/src/infrastructure/repositories/mock-articles.repository.ts`

- [ ] **Step 1: Implement** (no separate test — exercised by use-case tests)

```typescript
// packages/blog/src/infrastructure/repositories/mock-articles.repository.ts
import "reflect-metadata";
import { injectable } from "inversify";

import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface";
import type { Article } from "@/entities/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 getArticleBySlug(slug: string): Promise<Article | undefined> {
    return this._articles.find((a) => a.slug === slug);
  }

  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;
    const current = this._articles[index];
    if (!current) return undefined;
    const updated: Article = { ...current, ...input, id: current.id };
    this._articles[index] = updated;
    return updated;
  }
}
```

- [ ] **Step 2: Verify it compiles** (use-case tests still won't pass — DI container not yet implemented)

Run: `cd packages/blog && pnpm typecheck`
Expected: PASS.

- [ ] **Step 3: Commit**

```bash
git add packages/blog/src/infrastructure/repositories/mock-articles.repository.ts
git commit -m "feat(blog): add MockArticlesRepository for tests"
```

---

### Task 2.7: Per-feature DI container (symbols, module, container)

**Files:**
- Create: `packages/blog/src/di/symbols.ts`
- Create: `packages/blog/src/di/module.ts`
- Create: `packages/blog/src/di/container.ts`
- Create: `packages/blog/src/di/container.test.ts`

- [ ] **Step 1: Write the failing test**

```typescript
// packages/blog/src/di/container.test.ts
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { blogContainer } from "./container";
import { BLOG_SYMBOLS } from "./symbols";
import { BlogModule } from "./module";
import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository";
import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface";

describe("blogContainer", () => {
  beforeEach(() => {
    blogContainer.unbindAll();
    blogContainer.load(BlogModule);
  });

  afterEach(() => {
    blogContainer.unbindAll();
  });

  it("resolves IArticlesRepository to MockArticlesRepository by default binding", () => {
    const repo = blogContainer.get<IArticlesRepository>(
      BLOG_SYMBOLS.IArticlesRepository,
    );
    expect(repo).toBeInstanceOf(MockArticlesRepository);
  });

  it("supports rebinding to a custom repo", () => {
    blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
    const custom = new MockArticlesRepository();
    blogContainer
      .bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
      .toConstantValue(custom);
    const resolved = blogContainer.get<IArticlesRepository>(
      BLOG_SYMBOLS.IArticlesRepository,
    );
    expect(resolved).toBe(custom);
  });
});
```

- [ ] **Step 2: Run — expect failure**

Run: `cd packages/blog && pnpm vitest run src/di/container.test.ts`
Expected: FAIL — module-not-found errors.

- [ ] **Step 3: Implement symbols.ts**

```typescript
// packages/blog/src/di/symbols.ts
export const BLOG_SYMBOLS = {
  IArticlesRepository: Symbol.for("blog:IArticlesRepository"),
} as const;
```

> Note: Symbol descriptions are namespaced (`blog:IArticlesRepository`) so debug output is clear and there's no collision with other features' symbols at the JavaScript level (each feature has its own container, so collisions can't actually happen — this is just hygiene).

- [ ] **Step 4: Implement module.ts**

```typescript
// packages/blog/src/di/module.ts
import { ContainerModule, type interfaces } from "inversify";

import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface";
import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository";
import { BLOG_SYMBOLS } from "./symbols";

export const BlogModule = new ContainerModule((bind: interfaces.Bind) => {
  bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository).to(
    MockArticlesRepository,
  );
});
```

> Note: Default binding is `MockArticlesRepository`. The payload-backed repo (Task 2.8) is bound at app boot (Plan 5) by rebinding before serving requests. Tests rebind per `beforeEach`. This keeps the package usable in isolation (e.g., for unit tests, Storybook dev) without requiring a database.

- [ ] **Step 5: Implement container.ts**

```typescript
// packages/blog/src/di/container.ts
import "reflect-metadata";
import { Container } from "inversify";
import { BlogModule } from "./module";

export const blogContainer = new Container({ defaultScope: "Singleton" });
blogContainer.load(BlogModule);
```

- [ ] **Step 6: Run — expect pass**

Run: `cd packages/blog && pnpm vitest run src/di/container.test.ts`
Expected: PASS — 2 tests.

- [ ] **Step 7: Run the previously-red use-case tests**

Run: `cd packages/blog && pnpm vitest run src/application/use-cases`
Expected: PASS — 4 tests (2 from `getArticles`, 2 from `createArticle`).

- [ ] **Step 8: Commit**

```bash
git add packages/blog/src/di
git commit -m "feat(blog): add per-feature InversifyJS container (symbols, module, container)"
```

---

### Task 2.8: Payload articles repository

**Files:**
- Create: `packages/blog/src/infrastructure/repositories/payload-articles.repository.ts`
- Create: `packages/blog/src/infrastructure/repositories/payload-articles.repository.test.ts`

- [ ] **Step 1: Write the failing test**

```typescript
// packages/blog/src/infrastructure/repositories/payload-articles.repository.test.ts
import { describe, expect, it, vi } from "vitest";
import { PayloadArticlesRepository } from "./payload-articles.repository";

// Mock the payload module so we don't need a real DB
vi.mock("payload", () => ({
  getPayload: vi.fn(),
}));

vi.mock("@repo/core-cms", () => ({
  default: {} as never,
}));

describe("PayloadArticlesRepository", () => {
  it("maps a Payload doc to a domain Article on getArticleBySlug", async () => {
    const { getPayload } = await import("payload");
    const findMock = vi.fn().mockResolvedValue({
      docs: [
        {
          id: "p-123",
          title: "Hello",
          slug: "hello",
          content: { type: "doc", children: [] },
          status: "published",
          author: "u1",
          createdAt: "2026-05-04T12:00:00.000Z",
          updatedAt: "2026-05-04T12:00:00.000Z",
        },
      ],
    });
    (getPayload as ReturnType<typeof vi.fn>).mockResolvedValue({
      find: findMock,
    });

    const repo = new PayloadArticlesRepository();
    const result = await repo.getArticleBySlug("hello");

    expect(findMock).toHaveBeenCalledWith({
      collection: "articles",
      where: { slug: { equals: "hello" } },
      limit: 1,
      overrideAccess: false,
    });
    expect(result?.id).toBe("p-123");
    expect(result?.slug).toBe("hello");
    expect(result?.status).toBe("published");
    expect(result?.authorId).toBe("u1");
    expect(result?.createdAt).toBeInstanceOf(Date);
  });

  it("returns undefined when slug is not found", async () => {
    const { getPayload } = await import("payload");
    (getPayload as ReturnType<typeof vi.fn>).mockResolvedValue({
      find: vi.fn().mockResolvedValue({ docs: [] }),
    });

    const repo = new PayloadArticlesRepository();
    const result = await repo.getArticleBySlug("missing");
    expect(result).toBeUndefined();
  });
});
```

- [ ] **Step 2: Run — expect failure**

Run: `cd packages/blog && pnpm vitest run src/infrastructure/repositories/payload-articles.repository.test.ts`
Expected: FAIL — "Cannot find module './payload-articles.repository'"

- [ ] **Step 3: Implement payload-articles repository**

```typescript
// packages/blog/src/infrastructure/repositories/payload-articles.repository.ts
import "reflect-metadata";
import { injectable } from "inversify";
import { getPayload } from "payload";

import config from "@repo/core-cms";
import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface";
import type { Article } from "@/entities/article";

type PayloadArticleDoc = {
  id: string | number;
  title?: string | null;
  slug?: string | null;
  content?: unknown;
  status?: string | null;
  author?: string | number | { id: string | number } | null;
  createdAt?: string | null;
  updatedAt?: string | null;
};

function mapDoc(doc: PayloadArticleDoc): Article {
  const authorId =
    typeof doc.author === "object" && doc.author !== null
      ? String(doc.author.id)
      : doc.author != null
        ? String(doc.author)
        : "";
  return {
    id: String(doc.id),
    title: doc.title ?? "",
    slug: doc.slug ?? "",
    content: doc.content ?? null,
    status: (doc.status === "published" ? "published" : "draft"),
    authorId,
    createdAt: doc.createdAt ? new Date(doc.createdAt) : new Date(0),
    updatedAt: doc.updatedAt ? new Date(doc.updatedAt) : new Date(0),
  };
}

@injectable()
export class PayloadArticlesRepository implements IArticlesRepository {
  async getArticle(id: string): Promise<Article | undefined> {
    const payload = await getPayload({ config });
    try {
      const doc = await payload.findByID({
        collection: "articles",
        id,
        overrideAccess: false,
      });
      return mapDoc(doc as PayloadArticleDoc);
    } catch {
      return undefined;
    }
  }

  async getArticleBySlug(slug: string): Promise<Article | undefined> {
    const payload = await getPayload({ config });
    const result = await payload.find({
      collection: "articles",
      where: { slug: { equals: slug } },
      limit: 1,
      overrideAccess: false,
    });
    const doc = result.docs[0] as PayloadArticleDoc | undefined;
    return doc ? mapDoc(doc) : undefined;
  }

  async getArticles(options?: {
    status?: string;
    authorId?: string;
    limit?: number;
    offset?: number;
  }): Promise<Article[]> {
    const payload = await getPayload({ config });
    const where: Record<string, unknown> = {};
    if (options?.status) where.status = { equals: options.status };
    if (options?.authorId) where.author = { equals: options.authorId };

    const result = await payload.find({
      collection: "articles",
      where,
      limit: options?.limit ?? 50,
      page: options?.offset
        ? Math.floor(options.offset / (options.limit ?? 50)) + 1
        : 1,
      overrideAccess: false,
    });
    return result.docs.map((d) => mapDoc(d as PayloadArticleDoc));
  }

  async createArticle(input: Article): Promise<Article> {
    const payload = await getPayload({ config });
    const created = await payload.create({
      collection: "articles",
      data: {
        title: input.title,
        slug: input.slug,
        content: input.content,
        status: input.status,
        author: input.authorId,
      } as never,
      overrideAccess: false,
    });
    return mapDoc(created as PayloadArticleDoc);
  }

  async updateArticle(
    id: string,
    input: Partial<Article>,
  ): Promise<Article | undefined> {
    const payload = await getPayload({ config });
    try {
      const updated = await payload.update({
        collection: "articles",
        id,
        data: {
          ...(input.title !== undefined && { title: input.title }),
          ...(input.slug !== undefined && { slug: input.slug }),
          ...(input.content !== undefined && { content: input.content }),
          ...(input.status !== undefined && { status: input.status }),
          ...(input.authorId !== undefined && { author: input.authorId }),
        } as never,
        overrideAccess: false,
      });
      return mapDoc(updated as PayloadArticleDoc);
    } catch {
      return undefined;
    }
  }
}
```

> Note: `as never` casts on the Payload `data` arguments are needed because Payload's generated types (which we don't have for the empty-collection core-cms config) would normally constrain these. With empty `collections: []`, the generated types don't include `'articles'`, so we cast through `never`. After Plan 3 wires articles into core-cms.collections AND `pnpm generate:types` runs, these casts can potentially be removed — but for Plan 2's purpose (decoupled feature with deferred wiring) the casts are correct.

> **Critical architectural note (revised after execution):** The repo takes `SanitizedConfig` via its constructor instead of `import config from '@repo/core-cms'` (which would create a workspace dependency cycle: blog ↔ core-cms). Constructor injection breaks the cycle at the package-graph level. The DI binding for production (Plan 5, app boot) supplies the config:
> ```ts
> import config from '@repo/core-cms'
> blogContainer.bind(BLOG_SYMBOLS.IArticlesRepository)
>   .toDynamicValue(() => new PayloadArticlesRepository(config))
> ```
> The blog `package.json` does NOT declare `@repo/core-cms` as a dependency. Apply this pattern to every payload-backed feature repository.

- [ ] **Step 4: Run — expect pass**

Run: `cd packages/blog && pnpm vitest run src/infrastructure/repositories/payload-articles.repository.test.ts`
Expected: PASS — 2 tests.

- [ ] **Step 5: Commit**

```bash
git add packages/blog/src/infrastructure/repositories/payload-articles.repository.ts packages/blog/src/infrastructure/repositories/payload-articles.repository.test.ts
git commit -m "feat(blog): add PayloadArticlesRepository with doc-to-entity mapping"
```

---

## Phase 4: Interface-adapters (controllers)

### Task 2.9: Articles controller + tests

**Files:**
- Create: `packages/blog/src/interface-adapters/controllers/articles.controller.ts`
- Create: `packages/blog/src/interface-adapters/controllers/articles.controller.test.ts`

- [ ] **Step 1: Write the failing test**

```typescript
// packages/blog/src/interface-adapters/controllers/articles.controller.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import { blogContainer } from "@/di/container";
import { BLOG_SYMBOLS } from "@/di/symbols";
import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository";
import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface";
import { InputParseError } from "@/entities/errors";
import {
  createArticleController,
  getArticlesController,
  getArticleBySlugController,
} from "./articles.controller";

describe("articles controller", () => {
  let repo: MockArticlesRepository;

  beforeEach(() => {
    if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
      blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
    }
    repo = new MockArticlesRepository();
    blogContainer
      .bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
      .toConstantValue(repo);
  });

  describe("createArticleController", () => {
    it("creates an article on valid input", async () => {
      const result = await createArticleController({
        title: "Hello",
        content: "body",
        authorId: "u1",
      });
      expect(result.title).toBe("Hello");
    });

    it("throws InputParseError on missing title", async () => {
      await expect(
        createArticleController({ content: "body", authorId: "u1" }),
      ).rejects.toBeInstanceOf(InputParseError);
    });
  });

  describe("getArticlesController", () => {
    it("returns array on valid input", async () => {
      const result = await getArticlesController({});
      expect(result).toEqual([]);
    });

    it("throws InputParseError on invalid input shape", async () => {
      await expect(
        getArticlesController({ limit: "not a number" } as unknown as Record<
          string,
          unknown
        >),
      ).rejects.toBeInstanceOf(InputParseError);
    });
  });

  describe("getArticleBySlugController", () => {
    it("returns undefined for missing slug", async () => {
      const result = await getArticleBySlugController({ slug: "nope" });
      expect(result).toBeUndefined();
    });

    it("throws InputParseError on missing slug", async () => {
      await expect(
        getArticleBySlugController({} as { slug: string }),
      ).rejects.toBeInstanceOf(InputParseError);
    });
  });
});
```

- [ ] **Step 2: Run — expect failure**

Run: `cd packages/blog && pnpm vitest run src/interface-adapters/controllers/articles.controller.test.ts`
Expected: FAIL — "Cannot find module './articles.controller'"

- [ ] **Step 3: Implement controller**

```typescript
// packages/blog/src/interface-adapters/controllers/articles.controller.ts
import { z } from "zod";

import { InputParseError } from "@/entities/errors";
import type { Article } from "@/entities/article";
import { blogContainer } from "@/di/container";
import { BLOG_SYMBOLS } from "@/di/symbols";
import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface";
import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case";
import { createArticleUseCase } from "@/application/use-cases/create-article.use-case";

const createInputSchema = z.object({
  title: z.string().min(1).max(255),
  content: z.unknown(),
  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(),
});

const getBySlugInputSchema = z.object({
  slug: z.string().min(1),
});

export async function createArticleController(
  input: Partial<z.infer<typeof createInputSchema>>,
): Promise<Article> {
  const parsed = createInputSchema.safeParse(input);
  if (!parsed.success) {
    throw new InputParseError("Invalid create-article input", {
      cause: parsed.error,
    });
  }
  return createArticleUseCase(parsed.data);
}

export async function getArticlesController(
  input: Partial<z.infer<typeof getInputSchema>>,
): Promise<Article[]> {
  const parsed = getInputSchema.safeParse(input);
  if (!parsed.success) {
    throw new InputParseError("Invalid get-articles input", {
      cause: parsed.error,
    });
  }
  return getArticlesUseCase(parsed.data);
}

export async function getArticleBySlugController(input: {
  slug: string;
}): Promise<Article | undefined> {
  const parsed = getBySlugInputSchema.safeParse(input);
  if (!parsed.success) {
    throw new InputParseError("Invalid get-article-by-slug input", {
      cause: parsed.error,
    });
  }
  const repo = blogContainer.get<IArticlesRepository>(
    BLOG_SYMBOLS.IArticlesRepository,
  );
  return repo.getArticleBySlug(parsed.data.slug);
}
```

> Note: `getArticleBySlugController` calls the repo directly rather than going through a separate `getArticleBySlug.use-case.ts`. This is acceptable per spec addendum v5 — for trivial pass-through reads, a use-case file would be empty ceremony. The pattern emerges if/when this read needs caching, authorization decisions, or other application logic. (Plan 3 leaves it as-is; create one in a later plan if behavior justifies it.)

- [ ] **Step 4: Run — expect pass**

Run: `cd packages/blog && pnpm vitest run src/interface-adapters/controllers/articles.controller.test.ts`
Expected: PASS — 6 tests.

- [ ] **Step 5: Commit**

```bash
git add packages/blog/src/interface-adapters
git commit -m "feat(blog): add articles controller with Zod validation"
```

---

## Phase 5: Integrations (CMS schema + tRPC API)

### Task 2.10: Articles collection (simplified — no cross-feature relations)

**Files:**
- Create: `packages/blog/src/integrations/cms/collections/articles.ts`
- Create: `packages/blog/src/integrations/cms/index.ts`

- [ ] **Step 1: Implement collection (simplified — see schema simplifications section)**

```typescript
// packages/blog/src/integrations/cms/collections/articles.ts
import type { CollectionConfig } from "payload";
import { slugifyIfMissing } from "@repo/core-shared/payload";

export const articles: CollectionConfig = {
  slug: "articles",
  admin: {
    useAsTitle: "title",
    defaultColumns: ["title", "status", "author", "updatedAt"],
  },
  hooks: {
    beforeChange: [slugifyIfMissing],
  },
  versions: {
    drafts: true,
  },
  fields: [
    {
      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",
      },
    },
    // TODO(plan-3): Restore as `relationship → users` once auth feature is migrated.
    {
      name: "author",
      type: "text",
      required: true,
      admin: {
        position: "sidebar",
        description:
          "Temporary text field; restored to users relationship in Plan 3.",
      },
    },
    // TODO(plan-3): Restore `featuredImage: upload → media` once media feature is migrated.
    {
      name: "publishedAt",
      type: "date",
      admin: {
        position: "sidebar",
        date: {
          pickerAppearance: "dayAndTime",
        },
      },
    },
  ],
};
```

> Note: `slugifyIfMissing` from `@repo/core-shared/payload` replaces the inline `autoGenerateSlug` hook from `packages/cms-core/src/collections/articles/hooks/before-change.ts`. Same behavior, generic helper.

- [ ] **Step 2: Implement integrations/cms barrel**

```typescript
// packages/blog/src/integrations/cms/index.ts
export { articles } from "./collections/articles";
```

- [ ] **Step 3: Verify it compiles**

Run: `cd packages/blog && pnpm typecheck`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add packages/blog/src/integrations/cms
git commit -m "feat(blog): add articles collection (simplified — no cross-feature refs)"
```

---

### Task 2.11: Wire articles into core-cms composition

**Files:**
- Modify: `packages/core-cms/src/payload.config.ts`
- Modify: `packages/core-cms/package.json` (add `@repo/blog` dependency)

- [ ] **Step 1: Add @repo/blog to core-cms dependencies**

Add inside `packages/core-cms/package.json`'s `dependencies` block:

```json
"@repo/blog": "workspace:*",
```

> Note: This is the spec §4.2 composition exception — `core-cms` may import feature `/cms` exports.

- [ ] **Step 2: Update payload.config.ts to register articles**

Read current `packages/core-cms/src/payload.config.ts`. Replace it with:

```typescript
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 { articles } from "@repo/blog/cms";

const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);

export default buildConfig({
  editor: lexicalEditor(),
  collections: [articles],
  globals: [],
  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, "generated-types.ts"),
  },
});
```

- [ ] **Step 3: Install (picks up workspace dep)**

Run: `pnpm install`
Expected: install completes; `@repo/blog` available to `@repo/core-cms`.

- [ ] **Step 4: Verify core-cms typechecks**

Run: `pnpm typecheck --filter @repo/core-cms --filter @repo/blog`
Expected: PASS for both.

- [ ] **Step 5: Regenerate Payload types** (now articles is in the config; types should include `Article`-shaped doc)

Run: `cd apps/cms && pnpm generate:types`
Expected: writes new `packages/core-cms/src/generated-types.ts` containing an `Articles` interface.

- [ ] **Step 6: Verify generated types compile**

Run: `pnpm typecheck --filter @repo/core-cms`
Expected: PASS.

- [ ] **Step 7: Commit**

```bash
git add packages/core-cms packages/blog pnpm-lock.yaml
git commit -m "feat(core-cms): compose @repo/blog/cms into payload config"
```

---

### Task 2.12: tRPC router (integrations/api)

**Files:**
- Create: `packages/blog/src/integrations/api/router.ts`
- Create: `packages/blog/src/integrations/api/router.test.ts`

- [ ] **Step 1: Write the failing test**

```typescript
// packages/blog/src/integrations/api/router.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import { blogContainer } from "@/di/container";
import { BLOG_SYMBOLS } from "@/di/symbols";
import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository";
import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface";
import { blogRouter } from "./router";

describe("blogRouter", () => {
  let repo: MockArticlesRepository;

  beforeEach(() => {
    if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
      blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
    }
    repo = new MockArticlesRepository();
    blogContainer
      .bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
      .toConstantValue(repo);
  });

  it("exposes articleBySlug, listArticles, createArticle procedures", () => {
    const procedureNames = Object.keys(blogRouter._def.procedures);
    expect(procedureNames).toContain("articleBySlug");
    expect(procedureNames).toContain("listArticles");
    expect(procedureNames).toContain("createArticle");
  });

  it("articleBySlug returns the article when present", async () => {
    const now = new Date();
    await repo.createArticle({
      id: "1",
      title: "T",
      slug: "t",
      content: null,
      status: "draft",
      authorId: "u1",
      createdAt: now,
      updatedAt: now,
    });

    const caller = blogRouter.createCaller({});
    const result = await caller.articleBySlug({ slug: "t" });
    expect(result?.id).toBe("1");
  });

  it("listArticles returns all articles when no input is given", async () => {
    const caller = blogRouter.createCaller({});
    const result = await caller.listArticles();
    expect(result).toEqual([]);
  });
});
```

- [ ] **Step 2: Run — expect failure**

Run: `cd packages/blog && pnpm vitest run src/integrations/api/router.test.ts`
Expected: FAIL — "Cannot find module './router'"

- [ ] **Step 3: Implement router**

```typescript
// packages/blog/src/integrations/api/router.ts
import { z } from "zod";
import { router, publicProcedure } from "@repo/core-shared/trpc/init";
import {
  createArticleController,
  getArticlesController,
  getArticleBySlugController,
} from "@/interface-adapters/controllers/articles.controller";

export const blogRouter = router({
  articleBySlug: publicProcedure
    .input(z.object({ slug: z.string().min(1) }))
    .query(({ input }) => getArticleBySlugController(input)),

  listArticles: publicProcedure
    .input(
      z
        .object({
          status: z.string().optional(),
          authorId: z.string().optional(),
          limit: z.number().optional(),
          offset: z.number().optional(),
        })
        .optional(),
    )
    .query(({ input }) => getArticlesController(input ?? {})),

  createArticle: publicProcedure
    .input(
      z.object({
        title: z.string().min(1).max(255),
        content: z.unknown(),
        authorId: z.string(),
        slug: z.string().optional(),
      }),
    )
    .mutation(({ input }) => createArticleController(input)),
});

export type BlogRouter = typeof blogRouter;
```

- [ ] **Step 4: Run — expect pass**

Run: `cd packages/blog && pnpm vitest run src/integrations/api/router.test.ts`
Expected: PASS — 3 tests.

- [ ] **Step 5: Commit**

```bash
git add packages/blog/src/integrations/api
git commit -m "feat(blog): add tRPC router with articleBySlug + listArticles + createArticle"
```

---

### Task 2.13: Wire blogRouter into core-api

**Files:**
- Modify: `packages/core-api/src/root.ts`
- Modify: `packages/core-api/package.json` (add `@repo/blog`)

- [ ] **Step 1: Add @repo/blog to core-api dependencies**

Add inside `packages/core-api/package.json`'s `dependencies` block:

```json
"@repo/blog": "workspace:*",
```

- [ ] **Step 2: Update root.ts**

Replace `packages/core-api/src/root.ts` with:

```typescript
import { router } from "@repo/core-shared/trpc/init";
import { blogRouter } from "@repo/blog/api";

export const appRouter = router({
  blog: blogRouter,
});

export type AppRouter = typeof appRouter;
```

- [ ] **Step 3: Install + verify**

Run: `pnpm install` then `pnpm typecheck --filter @repo/core-api --filter @repo/blog`
Expected: both PASS.

- [ ] **Step 4: Commit**

```bash
git add packages/core-api packages/blog pnpm-lock.yaml
git commit -m "feat(core-api): compose @repo/blog/api into appRouter under 'blog' namespace"
```

---

## Phase 6: UI helpers (deferred render — apps wire in Plan 5)

### Task 2.14: ui/query.ts helper

**Files:**
- Create: `packages/blog/src/ui/query.ts`

- [ ] **Step 1: Implement** (no test — exercised when apps consume in Plan 5)

```typescript
// packages/blog/src/ui/query.ts
// React Query option builders for blog feature procedures.
// Consumed by apps via the @repo/core-trpc client (wired in Plan 5).
//
// Example consumer (Plan 5):
//   import { useQuery } from '@tanstack/react-query'
//   import { trpc } from '@repo/core-trpc'
//   import { articleBySlugQuery } from '@repo/blog/src/ui/query'
//   const { data } = useQuery(articleBySlugQuery(trpc, slug))
//
// Kept framework-agnostic here: takes the typed `trpc` client as an argument
// rather than importing it. This avoids importing @repo/core-trpc (which is
// a frontend platform package — feature shouldn't depend on it).

type TrpcClient = {
  blog: {
    articleBySlug: {
      queryOptions: (input: { slug: string }) => unknown;
    };
    listArticles: {
      queryOptions: (input?: {
        status?: string;
        authorId?: string;
        limit?: number;
        offset?: number;
      }) => unknown;
    };
  };
};

export function articleBySlugQuery(client: TrpcClient, slug: string) {
  return client.blog.articleBySlug.queryOptions({ slug });
}

export function listArticlesQuery(
  client: TrpcClient,
  options?: { status?: string; authorId?: string; limit?: number; offset?: number },
) {
  return client.blog.listArticles.queryOptions(options);
}
```

> Note: `TrpcClient` is locally typed structurally because `@repo/core-trpc` doesn't ship the React-aware client until Plan 5. The structural type is sufficient: when Plan 5 lands and apps call `articleBySlugQuery(trpc, slug)` with the real typed client, structural compatibility holds. Plan 5 may revise this to import from `@repo/core-trpc` once that's available.

- [ ] **Step 2: Verify it compiles**

Run: `cd packages/blog && pnpm typecheck`
Expected: PASS.

- [ ] **Step 3: Commit**

```bash
git add packages/blog/src/ui
git commit -m "feat(blog): add ui/query.ts (framework-agnostic React Query option builders)"
```

---

## Phase 7: Wiring + barrel + feature test

### Task 2.15: Wire blog package barrel

**Files:**
- Modify: `packages/blog/src/index.ts`

- [ ] **Step 1: Replace empty index.ts**

```typescript
// packages/blog/src/index.ts
export type { Article, ArticleStatus } from "./entities/article";
export { ArticleNotFoundError, InputParseError } from "./entities/errors";
export { articleBySlugQuery, listArticlesQuery } from "./ui/query";
```

- [ ] **Step 2: Verify it compiles**

Run: `cd packages/blog && pnpm typecheck`
Expected: PASS.

- [ ] **Step 3: Commit**

```bash
git add packages/blog/src/index.ts
git commit -m "feat(blog): wire root barrel (entity types + ui query helpers)"
```

---

### Task 2.16: Feature-level test (cross-layer)

**Files:**
- Create: `packages/blog/tests/articles.feature.test.ts`

- [ ] **Step 1: Write the test**

```typescript
// packages/blog/tests/articles.feature.test.ts
//
// Feature-level test: exercises the full slice
//   tRPC procedure -> controller -> use-case -> mock repo
// without going through a network or the actual Payload Local API.
// Verifies that the layers are correctly wired through the per-feature DI container.

import { beforeEach, describe, expect, it } from "vitest";
import { blogContainer } from "@/di/container";
import { BLOG_SYMBOLS } from "@/di/symbols";
import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository";
import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface";
import { blogRouter } from "@/integrations/api/router";

describe("blog feature: article-by-slug end-to-end", () => {
  let repo: MockArticlesRepository;

  beforeEach(() => {
    if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
      blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
    }
    repo = new MockArticlesRepository();
    blogContainer
      .bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
      .toConstantValue(repo);
  });

  it("creates an article via tRPC, then fetches it back by slug", async () => {
    const caller = blogRouter.createCaller({});

    const created = await caller.createArticle({
      title: "The Vertical Refactor",
      content: { type: "doc", children: [] },
      authorId: "u1",
      slug: "vertical-refactor",
    });
    expect(created.id).toBeTruthy();
    expect(created.slug).toBe("vertical-refactor");

    const fetched = await caller.articleBySlug({ slug: "vertical-refactor" });
    expect(fetched?.id).toBe(created.id);
    expect(fetched?.title).toBe("The Vertical Refactor");
  });

  it("listArticles filters by status", async () => {
    const caller = blogRouter.createCaller({});
    await caller.createArticle({
      title: "Draft One",
      content: null,
      authorId: "u1",
    });
    const draftOnly = await caller.listArticles({ status: "draft" });
    expect(draftOnly).toHaveLength(1);

    const publishedOnly = await caller.listArticles({ status: "published" });
    expect(publishedOnly).toHaveLength(0);
  });
});
```

- [ ] **Step 2: Verify the path alias works for tests/ folder**

The test uses `@/` paths. Confirm `packages/blog/tsconfig.json` includes `"tests/**/*"` (it does per Task 2.1).

- [ ] **Step 3: Run**

Run: `cd packages/blog && pnpm vitest run tests/`
Expected: PASS — 2 tests.

- [ ] **Step 4: Commit**

```bash
git add packages/blog/tests
git commit -m "test(blog): add feature-level test exercising router → controller → use-case → repo"
```

---

### Task 2.17: Final verification + apps/cms boot smoke test

- [ ] **Step 1: Run all blog tests**

Run: `pnpm test --filter @repo/blog`
Expected: PASS — exact count: entities (7) + di container (2) + use-cases (2 + 2) + payload-articles repo (2) + controller (6) + router (3) + feature (2) = 26 tests.

- [ ] **Step 2: Run repo-wide typecheck**

Run: `pnpm typecheck`
Expected: PASS for all the packages we touched. Pre-existing failures in `@repo/api` and `@repo/ui` remain (will be addressed in Plans 5/6).

- [ ] **Step 3: Boot apps/cms to verify articles collection registers**

Ensure Postgres is running (`docker compose up -d postgres`).

Run: `pnpm dev --filter @repo/cms` (in background; wait ~10s for "Ready").

Run: `curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin`
Expected: `200`.

Run: `curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin/collections/articles`
Expected: `200`.

Stop the dev server.

> Note: When the dev server first connects, Payload may again offer to push schema changes (drop the OLD `users` and `media` columns since core-cms now only knows about `articles` — those collections come back in Plan 3). Answer N to the prompt or accept — for the smoke test we only care that admin responds 200, so the dev server can stay paused on the prompt.

- [ ] **Step 4: Regenerate types one more time and confirm it includes Articles**

Run: `cd apps/cms && pnpm generate:types`

Run: `grep -c "Article" packages/core-cms/src/generated-types.ts`
Expected: at least 5 occurrences (Articles interface, references in Config, etc.).

- [ ] **Step 5: Commit regenerated types**

```bash
git add packages/core-cms/src/generated-types.ts
git commit -m "feat(core-cms): regenerate types — now includes Articles"
```

---

## Plan 2 Done Criteria

- [ ] All 26 tests in `@repo/blog` pass
- [ ] `@repo/blog`, `@repo/core-cms`, `@repo/core-api` all typecheck
- [ ] `apps/cms` admin UI serves `/admin` and `/admin/collections/articles` with 200
- [ ] `pnpm generate:types` in `apps/cms` produces a `generated-types.ts` containing `Articles`
- [ ] `core-cms/src/payload.config.ts` references `@repo/blog/cms` (not the old `cms-core`'s articles)
- [ ] `core-api/src/root.ts` references `@repo/blog/api` and exposes `blog.*` procedures
- [ ] `tsconfig.base.json` includes the three `@repo/blog*` aliases
- [ ] No deletions yet — `packages/core/src/entities/models/article.ts` and friends still exist (drained in Plan 6)

**Next plan:** Plan 3 — Auth + Media features. Migrates the Users collection (auth feature) and Media collection (media feature). Restores `articles.author` to a `relationship → users` and adds back `featuredImage: upload → media`. Demonstrates that the per-feature DI pattern scales beyond one feature.
