# Vertical Refactor — Plan 4: Marketing-pages + Navigation

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Add `@repo/marketing-pages` (Pages collection + SiteSettings global + read use-case + tRPC router) and `@repo/navigation` (Header global + read use-case + tRPC router). Compose both into `core-cms` and `core-api`. Demonstrates that the canonical pattern scales smoothly to features that own globals (not just collections) and to features with read-only surfaces.

**Architecture:** Both features follow the proven canonical shape from Plans 2-3. Marketing-pages is comparable in size to blog. Navigation is smaller — globals are simpler than collections (no per-record CRUD, just one document). Both use the per-feature InversifyJS container + payload-backed repository (constructor-injected with `SanitizedConfig`) pattern.

**Tech Stack:** Same as Plans 2-3.

**Plan position:** Plan 4 of 6.
- Plan 1 ✅ Foundation
- Plan 2 ✅ Blog feature
- Plan 3 ✅ Auth + Media + restore blog relations
- **Plan 4 (this doc):** Marketing-pages + Navigation
- Plan 5: App + UI integration
- Plan 6: Cleanup + boundary enforcement + Playwright + docs rewrite

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

**All Plan 2/3 lessons apply:**
1. vitest.config.ts has `resolve.alias` for `@/`
2. tsconfig.json has NO `rootDir`
3. Source files use relative imports; tests use `@/`
4. Payload-backed repos take `SanitizedConfig` via constructor (no `@repo/core-cms` dep)
5. Feature `package.json` has NO `@repo/core-cms` dep
6. `z.unknown()` schemas should be `.optional()`

---

## Schema design (this plan introduces)

### Marketing-pages

`pages` collection — generic CMS page (about, contact, etc.):
- `title: text required`
- `slug: text unique` (sidebar, auto-generated via `slugifyIfMissing`)
- `hero: group { heading: text, subheading: textarea, image: upload→media (optional) }`
- `layout: blocks [cta]` (uses generic `cta` block from `@repo/core-shared/payload`)
- `status: select draft|published, default draft, required, sidebar`
- `publishedAt: date sidebar`
- `seo: group { title: text required, description: textarea }` (uses `seoFields` from `core-shared`)

`siteSettings` global:
- Port from `cms-core/src/globals/site-settings.ts` as-is
- Slug: `site-settings`
- Fields: `siteName: text required defaultValue "My App"`, `siteDescription: textarea`

### Navigation

`header` global:
- `logo: upload → media (optional)`
- `items: array { label: text required, href: text required, external: checkbox default false }`

---

## File Structure

**Create — `@repo/marketing-pages` package:**
- `packages/marketing-pages/{package.json,tsconfig.json,turbo.json,vitest.config.ts}`
- `packages/marketing-pages/src/index.ts`
- `packages/marketing-pages/src/entities/page.ts` + `.test.ts`
- `packages/marketing-pages/src/entities/site-settings.ts`
- `packages/marketing-pages/src/entities/errors.ts`
- `packages/marketing-pages/src/application/repositories/pages-repository.interface.ts`
- `packages/marketing-pages/src/application/repositories/site-settings-repository.interface.ts`
- `packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.ts` + `.test.ts`
- `packages/marketing-pages/src/application/use-cases/get-site-settings.use-case.ts` + `.test.ts`
- `packages/marketing-pages/src/infrastructure/repositories/mock-pages.repository.ts`
- `packages/marketing-pages/src/infrastructure/repositories/payload-pages.repository.ts`
- `packages/marketing-pages/src/infrastructure/repositories/mock-site-settings.repository.ts`
- `packages/marketing-pages/src/infrastructure/repositories/payload-site-settings.repository.ts`
- `packages/marketing-pages/src/di/{symbols.ts,module.ts,container.ts,container.test.ts}`
- `packages/marketing-pages/src/interface-adapters/controllers/pages.controller.ts` + `.test.ts`
- `packages/marketing-pages/src/integrations/cms/collections/pages.ts`
- `packages/marketing-pages/src/integrations/cms/globals/site-settings.ts`
- `packages/marketing-pages/src/integrations/cms/index.ts`
- `packages/marketing-pages/src/integrations/api/router.ts` + `.test.ts`
- `packages/marketing-pages/src/ui/query.ts`
- `packages/marketing-pages/tests/page-by-slug.feature.test.ts`

**Create — `@repo/navigation` package:**
- `packages/navigation/{package.json,tsconfig.json,turbo.json,vitest.config.ts}`
- `packages/navigation/src/index.ts`
- `packages/navigation/src/entities/header.ts`
- `packages/navigation/src/application/repositories/header-repository.interface.ts`
- `packages/navigation/src/application/use-cases/get-header.use-case.ts` + `.test.ts`
- `packages/navigation/src/infrastructure/repositories/mock-header.repository.ts`
- `packages/navigation/src/infrastructure/repositories/payload-header.repository.ts`
- `packages/navigation/src/di/{symbols.ts,module.ts,container.ts,container.test.ts}`
- `packages/navigation/src/interface-adapters/controllers/header.controller.ts`
- `packages/navigation/src/integrations/cms/globals/header.ts`
- `packages/navigation/src/integrations/cms/index.ts`
- `packages/navigation/src/integrations/api/router.ts` + `.test.ts`
- `packages/navigation/src/ui/query.ts`

**Modify:**
- `packages/core-cms/src/payload.config.ts` — register `pages` collection + `siteSettings` and `header` globals; add `@repo/marketing-pages` and `@repo/navigation` deps
- `packages/core-cms/package.json` — add deps
- `packages/core-api/src/root.ts` — compose `marketingPages` and `navigation` routers
- `packages/core-api/package.json` — add deps
- `tsconfig.base.json` — add aliases for both new packages

---

## Phase A: Marketing-pages feature

### Task 4.1: Scaffold @repo/marketing-pages package

**Files:** `packages/marketing-pages/{package.json,tsconfig.json,turbo.json,vitest.config.ts,src/index.ts}` + path aliases.

- [ ] **Step 1: Create `packages/marketing-pages/package.json`**

```json
{
  "name": "@repo/marketing-pages",
  "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-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 `packages/marketing-pages/tsconfig.json`**

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

- [ ] **Step 3: Create `packages/marketing-pages/turbo.json`**

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

- [ ] **Step 4: Create `packages/marketing-pages/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"),
    },
  },
};
```

- [ ] **Step 5: Create `packages/marketing-pages/src/index.ts`**

```typescript
export {};
```

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

Add to `compilerOptions.paths`:

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

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

Run: `pnpm install`
Verify: `pnpm list --recursive --depth=-1 | grep marketing-pages`

- [ ] **Step 8: Commit**

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

---

### Task 4.2: Page + SiteSettings entities + tests

- [ ] **Step 1: Write `packages/marketing-pages/src/entities/page.test.ts`**

```typescript
import { describe, expect, it } from "vitest";
import { pageSchema, pageStatusSchema } from "./page";

describe("pageSchema", () => {
  it("accepts a minimal valid page", () => {
    const result = pageSchema.parse({
      id: "p1",
      title: "About",
      slug: "about",
      hero: { heading: "About us" },
      layout: [],
      seo: { title: "About — My App" },
      createdAt: new Date(),
      updatedAt: new Date(),
    });
    expect(result.status).toBe("draft");
    expect(result.publishedAt).toBeNull();
  });

  it("accepts a published page with publishedAt", () => {
    const result = pageSchema.parse({
      id: "p1",
      title: "About",
      slug: "about",
      hero: { heading: "About us" },
      layout: [],
      status: "published",
      publishedAt: new Date(),
      seo: { title: "About" },
      createdAt: new Date(),
      updatedAt: new Date(),
    });
    expect(result.status).toBe("published");
    expect(result.publishedAt).toBeInstanceOf(Date);
  });

  it("rejects empty title", () => {
    expect(() =>
      pageSchema.parse({
        id: "p1",
        title: "",
        slug: "about",
        hero: { heading: "h" },
        layout: [],
        seo: { title: "x" },
        createdAt: new Date(),
        updatedAt: new Date(),
      }),
    ).toThrow();
  });
});

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

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

Run: `cd packages/marketing-pages && pnpm vitest run src/entities/page.test.ts`
Expected: FAIL — "Cannot find module './page'".

- [ ] **Step 3: Implement `packages/marketing-pages/src/entities/page.ts`**

```typescript
import { z } from "zod";

export const pageStatusSchema = z.enum(["draft", "published"]);

export const heroSchema = z.object({
  heading: z.string().min(1).max(255),
  subheading: z.string().optional(),
  imageId: z.string().optional(),
});

export const pageSchema = z.object({
  id: z.string(),
  title: z.string().min(1).max(255),
  slug: z.string().min(1).max(255),
  hero: heroSchema,
  layout: z.array(z.unknown()),
  status: pageStatusSchema.default("draft"),
  publishedAt: z.date().nullable().default(null),
  seo: z.object({
    title: z.string().min(1),
    description: z.string().optional(),
  }),
  createdAt: z.date(),
  updatedAt: z.date(),
});

export type Page = z.infer<typeof pageSchema>;
export type PageStatus = z.infer<typeof pageStatusSchema>;
export type Hero = z.infer<typeof heroSchema>;
```

- [ ] **Step 4: Implement `packages/marketing-pages/src/entities/site-settings.ts`**

```typescript
import { z } from "zod";

export const siteSettingsSchema = z.object({
  siteName: z.string().min(1),
  siteDescription: z.string().optional(),
});

export type SiteSettings = z.infer<typeof siteSettingsSchema>;
```

- [ ] **Step 5: Implement `packages/marketing-pages/src/entities/errors.ts`**

```typescript
export class PageNotFoundError extends Error {
  constructor(message = "Page not found", options?: ErrorOptions) {
    super(message, options);
  }
}

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

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

Run: `cd packages/marketing-pages && pnpm vitest run src/entities`
Expected: PASS — 4 tests.

- [ ] **Step 7: Commit**

```bash
git add packages/marketing-pages/src/entities
git commit -m "feat(marketing-pages): add Page + SiteSettings entities + errors"
```

---

### Task 4.3: Repository interfaces (pages + site-settings)

- [ ] **Step 1: Create `packages/marketing-pages/src/application/repositories/pages-repository.interface.ts`**

```typescript
import type { Page } from "../../entities/page";

export interface IPagesRepository {
  getPageBySlug(slug: string): Promise<Page | undefined>;
  getPages(options?: {
    status?: string;
    limit?: number;
    offset?: number;
  }): Promise<Page[]>;
}
```

- [ ] **Step 2: Create `packages/marketing-pages/src/application/repositories/site-settings-repository.interface.ts`**

```typescript
import type { SiteSettings } from "../../entities/site-settings";

export interface ISiteSettingsRepository {
  getSiteSettings(): Promise<SiteSettings>;
}
```

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

Run: `cd packages/marketing-pages && pnpm typecheck`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add packages/marketing-pages/src/application
git commit -m "feat(marketing-pages): add IPagesRepository + ISiteSettingsRepository interfaces"
```

---

### Task 4.4: Use-cases + tests (RED until DI lands)

- [ ] **Step 1: Write test `packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.test.ts`**

```typescript
import { beforeEach, describe, expect, it } from "vitest";
import { marketingPagesContainer } from "@/di/container";
import { MARKETING_PAGES_SYMBOLS } from "@/di/symbols";
import { MockPagesRepository } from "@/infrastructure/repositories/mock-pages.repository";
import type { IPagesRepository } from "@/application/repositories/pages-repository.interface";
import { getPageBySlugUseCase } from "./get-page-by-slug.use-case";

describe("getPageBySlugUseCase", () => {
  let repo: MockPagesRepository;

  beforeEach(() => {
    if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.IPagesRepository)) {
      marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.IPagesRepository);
    }
    repo = new MockPagesRepository();
    marketingPagesContainer
      .bind<IPagesRepository>(MARKETING_PAGES_SYMBOLS.IPagesRepository)
      .toConstantValue(repo);
  });

  it("returns the page when found", async () => {
    const result = await getPageBySlugUseCase("about");
    expect(result?.slug).toBe("about");
  });

  it("returns undefined when not found", async () => {
    const result = await getPageBySlugUseCase("missing-page");
    expect(result).toBeUndefined();
  });
});
```

- [ ] **Step 2: Implement `packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.ts`**

```typescript
import type { Page } from "../../entities/page";
import { marketingPagesContainer } from "../../di/container";
import { MARKETING_PAGES_SYMBOLS } from "../../di/symbols";
import type { IPagesRepository } from "../repositories/pages-repository.interface";

export async function getPageBySlugUseCase(
  slug: string,
): Promise<Page | undefined> {
  const repo = marketingPagesContainer.get<IPagesRepository>(
    MARKETING_PAGES_SYMBOLS.IPagesRepository,
  );
  return repo.getPageBySlug(slug);
}
```

- [ ] **Step 3: Commit (test RED)**

```bash
git add packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.ts packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.test.ts
git commit -m "feat(marketing-pages): add getPageBySlugUseCase (test red until DI lands)"
```

- [ ] **Step 4: Write test `packages/marketing-pages/src/application/use-cases/get-site-settings.use-case.test.ts`**

```typescript
import { beforeEach, describe, expect, it } from "vitest";
import { marketingPagesContainer } from "@/di/container";
import { MARKETING_PAGES_SYMBOLS } from "@/di/symbols";
import { MockSiteSettingsRepository } from "@/infrastructure/repositories/mock-site-settings.repository";
import type { ISiteSettingsRepository } from "@/application/repositories/site-settings-repository.interface";
import { getSiteSettingsUseCase } from "./get-site-settings.use-case";

describe("getSiteSettingsUseCase", () => {
  let repo: MockSiteSettingsRepository;

  beforeEach(() => {
    if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository)) {
      marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository);
    }
    repo = new MockSiteSettingsRepository();
    marketingPagesContainer
      .bind<ISiteSettingsRepository>(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository)
      .toConstantValue(repo);
  });

  it("returns the seeded site settings", async () => {
    const result = await getSiteSettingsUseCase();
    expect(result.siteName).toBe("My App");
  });
});
```

- [ ] **Step 5: Implement `packages/marketing-pages/src/application/use-cases/get-site-settings.use-case.ts`**

```typescript
import type { SiteSettings } from "../../entities/site-settings";
import { marketingPagesContainer } from "../../di/container";
import { MARKETING_PAGES_SYMBOLS } from "../../di/symbols";
import type { ISiteSettingsRepository } from "../repositories/site-settings-repository.interface";

export async function getSiteSettingsUseCase(): Promise<SiteSettings> {
  const repo = marketingPagesContainer.get<ISiteSettingsRepository>(
    MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository,
  );
  return repo.getSiteSettings();
}
```

- [ ] **Step 6: Commit (test RED)**

```bash
git add packages/marketing-pages/src/application/use-cases/get-site-settings.use-case.ts packages/marketing-pages/src/application/use-cases/get-site-settings.use-case.test.ts
git commit -m "feat(marketing-pages): add getSiteSettingsUseCase (test red until DI lands)"
```

---

### Task 4.5: Mock repositories (pages + site-settings)

- [ ] **Step 1: Create `packages/marketing-pages/src/infrastructure/repositories/mock-pages.repository.ts`**

```typescript
import "reflect-metadata";
import { injectable } from "inversify";

import type { IPagesRepository } from "../../application/repositories/pages-repository.interface";
import type { Page } from "../../entities/page";

const SEED_DATE = new Date("2026-01-01T00:00:00.000Z");

@injectable()
export class MockPagesRepository implements IPagesRepository {
  private _pages: Page[] = [
    {
      id: "p1",
      title: "About",
      slug: "about",
      hero: { heading: "About us" },
      layout: [],
      status: "published",
      publishedAt: SEED_DATE,
      seo: { title: "About — My App" },
      createdAt: SEED_DATE,
      updatedAt: SEED_DATE,
    },
  ];

  async getPageBySlug(slug: string): Promise<Page | undefined> {
    return this._pages.find((p) => p.slug === slug);
  }

  async getPages(options?: {
    status?: string;
    limit?: number;
    offset?: number;
  }): Promise<Page[]> {
    let result = [...this._pages];
    if (options?.status) {
      result = result.filter((p) => p.status === options.status);
    }
    const offset = options?.offset ?? 0;
    const limit = options?.limit ?? 50;
    return result.slice(offset, offset + limit);
  }
}
```

- [ ] **Step 2: Create `packages/marketing-pages/src/infrastructure/repositories/mock-site-settings.repository.ts`**

```typescript
import "reflect-metadata";
import { injectable } from "inversify";

import type { ISiteSettingsRepository } from "../../application/repositories/site-settings-repository.interface";
import type { SiteSettings } from "../../entities/site-settings";

@injectable()
export class MockSiteSettingsRepository implements ISiteSettingsRepository {
  async getSiteSettings(): Promise<SiteSettings> {
    return {
      siteName: "My App",
      siteDescription: "A vertical-feature monorepo template",
    };
  }
}
```

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

Run: `cd packages/marketing-pages && pnpm typecheck`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add packages/marketing-pages/src/infrastructure/repositories
git commit -m "feat(marketing-pages): add Mock pages + site-settings repositories"
```

---

### Task 4.6: Per-feature DI container + tests

- [ ] **Step 1: Write `packages/marketing-pages/src/di/container.test.ts`**

```typescript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { marketingPagesContainer } from "./container";
import { MARKETING_PAGES_SYMBOLS } from "./symbols";
import { MarketingPagesModule } from "./module";
import { MockPagesRepository } from "@/infrastructure/repositories/mock-pages.repository";
import { MockSiteSettingsRepository } from "@/infrastructure/repositories/mock-site-settings.repository";
import type { IPagesRepository } from "@/application/repositories/pages-repository.interface";
import type { ISiteSettingsRepository } from "@/application/repositories/site-settings-repository.interface";

describe("marketingPagesContainer", () => {
  beforeEach(() => {
    marketingPagesContainer.unbindAll();
    marketingPagesContainer.load(MarketingPagesModule);
  });

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

  it("resolves IPagesRepository to MockPagesRepository", () => {
    const repo = marketingPagesContainer.get<IPagesRepository>(
      MARKETING_PAGES_SYMBOLS.IPagesRepository,
    );
    expect(repo).toBeInstanceOf(MockPagesRepository);
  });

  it("resolves ISiteSettingsRepository to MockSiteSettingsRepository", () => {
    const repo = marketingPagesContainer.get<ISiteSettingsRepository>(
      MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository,
    );
    expect(repo).toBeInstanceOf(MockSiteSettingsRepository);
  });
});
```

- [ ] **Step 2: Implement `packages/marketing-pages/src/di/symbols.ts`**

```typescript
export const MARKETING_PAGES_SYMBOLS = {
  IPagesRepository: Symbol.for("marketing-pages:IPagesRepository"),
  ISiteSettingsRepository: Symbol.for("marketing-pages:ISiteSettingsRepository"),
} as const;
```

- [ ] **Step 3: Implement `packages/marketing-pages/src/di/module.ts`**

```typescript
import { ContainerModule, type interfaces } from "inversify";

import type { IPagesRepository } from "../application/repositories/pages-repository.interface";
import type { ISiteSettingsRepository } from "../application/repositories/site-settings-repository.interface";
import { MockPagesRepository } from "../infrastructure/repositories/mock-pages.repository";
import { MockSiteSettingsRepository } from "../infrastructure/repositories/mock-site-settings.repository";
import { MARKETING_PAGES_SYMBOLS } from "./symbols";

export const MarketingPagesModule = new ContainerModule(
  (bind: interfaces.Bind) => {
    bind<IPagesRepository>(MARKETING_PAGES_SYMBOLS.IPagesRepository).to(
      MockPagesRepository,
    );
    bind<ISiteSettingsRepository>(
      MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository,
    ).to(MockSiteSettingsRepository);
  },
);
```

- [ ] **Step 4: Implement `packages/marketing-pages/src/di/container.ts`**

```typescript
import "reflect-metadata";
import { Container } from "inversify";
import { MarketingPagesModule } from "./module";

export const marketingPagesContainer = new Container({
  defaultScope: "Singleton",
});
marketingPagesContainer.load(MarketingPagesModule);
```

- [ ] **Step 5: Run container test — expect 2 PASS**

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

- [ ] **Step 6: Run previously-RED use-case tests**

Run: `cd packages/marketing-pages && pnpm vitest run src/application/use-cases`
Expected: PASS — 3 tests (page-by-slug 2 + site-settings 1).

- [ ] **Step 7: Commit**

```bash
git add packages/marketing-pages/src/di
git commit -m "feat(marketing-pages): add per-feature InversifyJS container"
```

---

### Task 4.7: Payload-backed repositories

Both repositories take `SanitizedConfig` via constructor (per Plan 2 lesson #4).

- [ ] **Step 1: Implement `packages/marketing-pages/src/infrastructure/repositories/payload-pages.repository.ts`**

```typescript
import "reflect-metadata";
import { injectable } from "inversify";
import { getPayload } from "payload";
import type { SanitizedConfig } from "payload";

import type { IPagesRepository } from "../../application/repositories/pages-repository.interface";
import type { Page } from "../../entities/page";

type PayloadPageDoc = {
  id: string | number;
  title?: string | null;
  slug?: string | null;
  hero?:
    | {
        heading?: string | null;
        subheading?: string | null;
        image?: string | number | { id: string | number } | null;
      }
    | null;
  layout?: unknown[] | null;
  status?: string | null;
  publishedAt?: string | null;
  seo?: { title?: string | null; description?: string | null } | null;
  createdAt?: string | null;
  updatedAt?: string | null;
};

function mapDoc(doc: PayloadPageDoc): Page {
  const imageId =
    doc.hero && typeof doc.hero.image === "object" && doc.hero.image !== null
      ? String(doc.hero.image.id)
      : doc.hero?.image != null
        ? String(doc.hero.image)
        : undefined;

  return {
    id: String(doc.id),
    title: doc.title ?? "",
    slug: doc.slug ?? "",
    hero: {
      heading: doc.hero?.heading ?? "",
      subheading: doc.hero?.subheading ?? undefined,
      imageId,
    },
    layout: doc.layout ?? [],
    status: doc.status === "published" ? "published" : "draft",
    publishedAt: doc.publishedAt ? new Date(doc.publishedAt) : null,
    seo: {
      title: doc.seo?.title ?? "",
      description: doc.seo?.description ?? undefined,
    },
    createdAt: doc.createdAt ? new Date(doc.createdAt) : new Date(0),
    updatedAt: doc.updatedAt ? new Date(doc.updatedAt) : new Date(0),
  };
}

@injectable()
export class PayloadPagesRepository implements IPagesRepository {
  private config: SanitizedConfig;

  constructor(config: SanitizedConfig) {
    this.config = config;
  }

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

  async getPages(options?: {
    status?: string;
    limit?: number;
    offset?: number;
  }): Promise<Page[]> {
    const payload = await getPayload({ config: this.config });
    const where: Record<string, { equals: string }> = {};
    if (options?.status) where.status = { equals: options.status };
    const result = await payload.find({
      collection: "pages",
      where: where as never,
      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 PayloadPageDoc));
  }
}
```

- [ ] **Step 2: Implement `packages/marketing-pages/src/infrastructure/repositories/payload-site-settings.repository.ts`**

```typescript
import "reflect-metadata";
import { injectable } from "inversify";
import { getPayload } from "payload";
import type { SanitizedConfig } from "payload";

import type { ISiteSettingsRepository } from "../../application/repositories/site-settings-repository.interface";
import type { SiteSettings } from "../../entities/site-settings";

type PayloadSiteSettings = {
  siteName?: string | null;
  siteDescription?: string | null;
};

@injectable()
export class PayloadSiteSettingsRepository implements ISiteSettingsRepository {
  private config: SanitizedConfig;

  constructor(config: SanitizedConfig) {
    this.config = config;
  }

  async getSiteSettings(): Promise<SiteSettings> {
    const payload = await getPayload({ config: this.config });
    const doc = (await payload.findGlobal({
      slug: "site-settings",
      overrideAccess: false,
    })) as PayloadSiteSettings;
    return {
      siteName: doc.siteName ?? "My App",
      siteDescription: doc.siteDescription ?? undefined,
    };
  }
}
```

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

Run: `cd packages/marketing-pages && pnpm typecheck`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add packages/marketing-pages/src/infrastructure/repositories
git commit -m "feat(marketing-pages): add Payload-backed pages + site-settings repos (constructor-injected config)"
```

---

### Task 4.8: Pages controller + tests

- [ ] **Step 1: Write test `packages/marketing-pages/src/interface-adapters/controllers/pages.controller.test.ts`**

```typescript
import { beforeEach, describe, expect, it } from "vitest";
import { marketingPagesContainer } from "@/di/container";
import { MARKETING_PAGES_SYMBOLS } from "@/di/symbols";
import { MockPagesRepository } from "@/infrastructure/repositories/mock-pages.repository";
import { MockSiteSettingsRepository } from "@/infrastructure/repositories/mock-site-settings.repository";
import type { IPagesRepository } from "@/application/repositories/pages-repository.interface";
import type { ISiteSettingsRepository } from "@/application/repositories/site-settings-repository.interface";
import { InputParseError } from "@/entities/errors";
import {
  getPageBySlugController,
  getSiteSettingsController,
} from "./pages.controller";

describe("pages controller", () => {
  beforeEach(() => {
    if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.IPagesRepository)) {
      marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.IPagesRepository);
    }
    if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository)) {
      marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository);
    }
    marketingPagesContainer
      .bind<IPagesRepository>(MARKETING_PAGES_SYMBOLS.IPagesRepository)
      .toConstantValue(new MockPagesRepository());
    marketingPagesContainer
      .bind<ISiteSettingsRepository>(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository)
      .toConstantValue(new MockSiteSettingsRepository());
  });

  describe("getPageBySlugController", () => {
    it("returns the page when found", async () => {
      const result = await getPageBySlugController({ slug: "about" });
      expect(result?.slug).toBe("about");
    });

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

  describe("getSiteSettingsController", () => {
    it("returns site settings", async () => {
      const result = await getSiteSettingsController();
      expect(result.siteName).toBe("My App");
    });
  });
});
```

- [ ] **Step 2: Implement `packages/marketing-pages/src/interface-adapters/controllers/pages.controller.ts`**

```typescript
import { z } from "zod";

import { InputParseError } from "../../entities/errors";
import type { Page } from "../../entities/page";
import type { SiteSettings } from "../../entities/site-settings";
import { getPageBySlugUseCase } from "../../application/use-cases/get-page-by-slug.use-case";
import { getSiteSettingsUseCase } from "../../application/use-cases/get-site-settings.use-case";

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

export async function getPageBySlugController(input: {
  slug: string;
}): Promise<Page | undefined> {
  const parsed = getBySlugInputSchema.safeParse(input);
  if (!parsed.success) {
    throw new InputParseError("Invalid get-page-by-slug input", {
      cause: parsed.error,
    });
  }
  return getPageBySlugUseCase(parsed.data.slug);
}

export async function getSiteSettingsController(): Promise<SiteSettings> {
  return getSiteSettingsUseCase();
}
```

- [ ] **Step 3: Run — expect 3 PASS**

Run: `cd packages/marketing-pages && pnpm vitest run src/interface-adapters`
Expected: PASS — 3 tests.

- [ ] **Step 4: Commit**

```bash
git add packages/marketing-pages/src/interface-adapters
git commit -m "feat(marketing-pages): add pages controller (getBySlug + getSiteSettings)"
```

---

### Task 4.9: Pages collection + SiteSettings global

- [ ] **Step 1: Create `packages/marketing-pages/src/integrations/cms/collections/pages.ts`**

```typescript
import type { CollectionConfig } from "payload";
import {
  cta,
  seoFields,
  slugifyIfMissing,
} from "@repo/core-shared/payload";

export const pages: CollectionConfig = {
  slug: "pages",
  admin: {
    useAsTitle: "title",
    defaultColumns: ["title", "status", "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: "hero",
      type: "group",
      fields: [
        { name: "heading", type: "text", required: true },
        { name: "subheading", type: "textarea" },
        {
          name: "image",
          type: "upload",
          relationTo: "media",
        },
      ],
    },
    {
      name: "layout",
      type: "blocks",
      blocks: [cta],
    },
    {
      name: "status",
      type: "select",
      options: [
        { label: "Draft", value: "draft" },
        { label: "Published", value: "published" },
      ],
      defaultValue: "draft",
      required: true,
      admin: { position: "sidebar" },
    },
    {
      name: "publishedAt",
      type: "date",
      admin: {
        position: "sidebar",
        date: { pickerAppearance: "dayAndTime" },
      },
    },
    seoFields,
  ],
};
```

- [ ] **Step 2: Create `packages/marketing-pages/src/integrations/cms/globals/site-settings.ts`**

```typescript
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",
    },
  ],
};
```

- [ ] **Step 3: Create `packages/marketing-pages/src/integrations/cms/index.ts`**

```typescript
export { pages } from "./collections/pages";
export { siteSettings } from "./globals/site-settings";
```

- [ ] **Step 4: Verify compiles**

Run: `cd packages/marketing-pages && pnpm typecheck`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add packages/marketing-pages/src/integrations/cms
git commit -m "feat(marketing-pages): add pages collection + siteSettings global"
```

---

### Task 4.10: tRPC router + tests

- [ ] **Step 1: Write test `packages/marketing-pages/src/integrations/api/router.test.ts`**

```typescript
import { beforeEach, describe, expect, it } from "vitest";
import { marketingPagesContainer } from "@/di/container";
import { MARKETING_PAGES_SYMBOLS } from "@/di/symbols";
import { MockPagesRepository } from "@/infrastructure/repositories/mock-pages.repository";
import { MockSiteSettingsRepository } from "@/infrastructure/repositories/mock-site-settings.repository";
import type { IPagesRepository } from "@/application/repositories/pages-repository.interface";
import type { ISiteSettingsRepository } from "@/application/repositories/site-settings-repository.interface";
import { marketingPagesRouter } from "./router";

describe("marketingPagesRouter", () => {
  beforeEach(() => {
    if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.IPagesRepository)) {
      marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.IPagesRepository);
    }
    if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository)) {
      marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository);
    }
    marketingPagesContainer
      .bind<IPagesRepository>(MARKETING_PAGES_SYMBOLS.IPagesRepository)
      .toConstantValue(new MockPagesRepository());
    marketingPagesContainer
      .bind<ISiteSettingsRepository>(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository)
      .toConstantValue(new MockSiteSettingsRepository());
  });

  it("exposes pageBySlug + siteSettings procedures", () => {
    const names = Object.keys(marketingPagesRouter._def.procedures);
    expect(names).toContain("pageBySlug");
    expect(names).toContain("siteSettings");
  });

  it("pageBySlug returns the seeded About page", async () => {
    const caller = marketingPagesRouter.createCaller({});
    const result = await caller.pageBySlug({ slug: "about" });
    expect(result?.title).toBe("About");
  });

  it("siteSettings returns site name", async () => {
    const caller = marketingPagesRouter.createCaller({});
    const result = await caller.siteSettings();
    expect(result.siteName).toBe("My App");
  });
});
```

- [ ] **Step 2: Implement `packages/marketing-pages/src/integrations/api/router.ts`**

```typescript
import { z } from "zod";
import { router, publicProcedure } from "@repo/core-shared/trpc/init";
import {
  getPageBySlugController,
  getSiteSettingsController,
} from "../../interface-adapters/controllers/pages.controller";

export const marketingPagesRouter = router({
  pageBySlug: publicProcedure
    .input(z.object({ slug: z.string().min(1) }))
    .query(({ input }) => getPageBySlugController(input)),

  siteSettings: publicProcedure.query(() => getSiteSettingsController()),
});

export type MarketingPagesRouter = typeof marketingPagesRouter;
```

- [ ] **Step 3: Run — expect 3 PASS**

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

- [ ] **Step 4: Commit**

```bash
git add packages/marketing-pages/src/integrations/api
git commit -m "feat(marketing-pages): add tRPC router with pageBySlug + siteSettings"
```

---

### Task 4.11: ui/query + barrel + feature test

- [ ] **Step 1: Create `packages/marketing-pages/src/ui/query.ts`**

```typescript
type TrpcClient = {
  marketingPages: {
    pageBySlug: { queryOptions: (input: { slug: string }) => unknown };
    siteSettings: { queryOptions: () => unknown };
  };
};

export function pageBySlugQuery(client: TrpcClient, slug: string) {
  return client.marketingPages.pageBySlug.queryOptions({ slug });
}

export function siteSettingsQuery(client: TrpcClient) {
  return client.marketingPages.siteSettings.queryOptions();
}
```

- [ ] **Step 2: Replace `packages/marketing-pages/src/index.ts`**

```typescript
export type { Page, PageStatus, Hero } from "./entities/page";
export type { SiteSettings } from "./entities/site-settings";
export { PageNotFoundError, InputParseError } from "./entities/errors";
export { pageBySlugQuery, siteSettingsQuery } from "./ui/query";
```

- [ ] **Step 3: Create feature test `packages/marketing-pages/tests/page-by-slug.feature.test.ts`**

```typescript
import { beforeEach, describe, expect, it } from "vitest";
import { marketingPagesContainer } from "../src/di/container";
import { MARKETING_PAGES_SYMBOLS } from "../src/di/symbols";
import { MockPagesRepository } from "../src/infrastructure/repositories/mock-pages.repository";
import { MockSiteSettingsRepository } from "../src/infrastructure/repositories/mock-site-settings.repository";
import type { IPagesRepository } from "../src/application/repositories/pages-repository.interface";
import type { ISiteSettingsRepository } from "../src/application/repositories/site-settings-repository.interface";
import { marketingPagesRouter } from "../src/integrations/api/router";

describe("marketing-pages feature: page-by-slug end-to-end", () => {
  beforeEach(() => {
    if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.IPagesRepository)) {
      marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.IPagesRepository);
    }
    if (marketingPagesContainer.isBound(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository)) {
      marketingPagesContainer.unbind(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository);
    }
    marketingPagesContainer
      .bind<IPagesRepository>(MARKETING_PAGES_SYMBOLS.IPagesRepository)
      .toConstantValue(new MockPagesRepository());
    marketingPagesContainer
      .bind<ISiteSettingsRepository>(MARKETING_PAGES_SYMBOLS.ISiteSettingsRepository)
      .toConstantValue(new MockSiteSettingsRepository());
  });

  it("fetches a page by slug via tRPC and returns the domain entity", async () => {
    const caller = marketingPagesRouter.createCaller({});
    const page = await caller.pageBySlug({ slug: "about" });
    expect(page?.title).toBe("About");
    expect(page?.hero.heading).toBe("About us");
    expect(page?.status).toBe("published");
  });
});
```

- [ ] **Step 4: Run all marketing-pages tests**

Run: `cd packages/marketing-pages && pnpm test`
Expected: PASS — entities (4) + di (2) + use-cases (3) + controller (3) + router (3) + feature (1) = 16 tests.

- [ ] **Step 5: Commit**

```bash
git add packages/marketing-pages/src/ui packages/marketing-pages/src/index.ts packages/marketing-pages/tests
git commit -m "feat(marketing-pages): add ui/query + barrel + feature test"
```

---

## Phase B: Navigation feature

### Task 4.12: Scaffold @repo/navigation package

- [ ] **Step 1: Create `packages/navigation/package.json`**

```json
{
  "name": "@repo/navigation",
  "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-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 `packages/navigation/tsconfig.json`** (same shape as marketing-pages tsconfig from Task 4.1 step 2)

- [ ] **Step 3: Create `packages/navigation/turbo.json`** (same as Task 4.1 step 3)

- [ ] **Step 4: Create `packages/navigation/vitest.config.ts`** (same as Task 4.1 step 4)

- [ ] **Step 5: Create `packages/navigation/src/index.ts`** (`export {};`)

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

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

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

Run: `pnpm install`
Verify: `pnpm list --recursive --depth=-1 | grep navigation`

- [ ] **Step 8: Commit**

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

---

### Task 4.13: Header entity, repo interface, use-case + tests, mock repo, payload repo, DI

This task batches all the application + infrastructure + DI for navigation since it's small.

- [ ] **Step 1: Create `packages/navigation/src/entities/header.ts`**

```typescript
import { z } from "zod";

export const headerItemSchema = z.object({
  label: z.string().min(1).max(64),
  href: z.string().min(1),
  external: z.boolean().default(false),
});

export const headerSchema = z.object({
  logoId: z.string().optional(),
  items: z.array(headerItemSchema),
});

export type Header = z.infer<typeof headerSchema>;
export type HeaderItem = z.infer<typeof headerItemSchema>;
```

- [ ] **Step 2: Create `packages/navigation/src/application/repositories/header-repository.interface.ts`**

```typescript
import type { Header } from "../../entities/header";

export interface IHeaderRepository {
  getHeader(): Promise<Header>;
}
```

- [ ] **Step 3: Write test `packages/navigation/src/application/use-cases/get-header.use-case.test.ts`**

```typescript
import { beforeEach, describe, expect, it } from "vitest";
import { navigationContainer } from "@/di/container";
import { NAVIGATION_SYMBOLS } from "@/di/symbols";
import { MockHeaderRepository } from "@/infrastructure/repositories/mock-header.repository";
import type { IHeaderRepository } from "@/application/repositories/header-repository.interface";
import { getHeaderUseCase } from "./get-header.use-case";

describe("getHeaderUseCase", () => {
  beforeEach(() => {
    if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) {
      navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository);
    }
    navigationContainer
      .bind<IHeaderRepository>(NAVIGATION_SYMBOLS.IHeaderRepository)
      .toConstantValue(new MockHeaderRepository());
  });

  it("returns the seeded header items", async () => {
    const result = await getHeaderUseCase();
    expect(result.items.length).toBeGreaterThan(0);
    expect(result.items[0]?.label).toBe("Home");
  });
});
```

- [ ] **Step 4: Implement `packages/navigation/src/application/use-cases/get-header.use-case.ts`**

```typescript
import type { Header } from "../../entities/header";
import { navigationContainer } from "../../di/container";
import { NAVIGATION_SYMBOLS } from "../../di/symbols";
import type { IHeaderRepository } from "../repositories/header-repository.interface";

export async function getHeaderUseCase(): Promise<Header> {
  const repo = navigationContainer.get<IHeaderRepository>(
    NAVIGATION_SYMBOLS.IHeaderRepository,
  );
  return repo.getHeader();
}
```

- [ ] **Step 5: Implement `packages/navigation/src/infrastructure/repositories/mock-header.repository.ts`**

```typescript
import "reflect-metadata";
import { injectable } from "inversify";

import type { IHeaderRepository } from "../../application/repositories/header-repository.interface";
import type { Header } from "../../entities/header";

@injectable()
export class MockHeaderRepository implements IHeaderRepository {
  async getHeader(): Promise<Header> {
    return {
      items: [
        { label: "Home", href: "/", external: false },
        { label: "Blog", href: "/blog", external: false },
        { label: "About", href: "/about", external: false },
      ],
    };
  }
}
```

- [ ] **Step 6: Implement `packages/navigation/src/infrastructure/repositories/payload-header.repository.ts`**

```typescript
import "reflect-metadata";
import { injectable } from "inversify";
import { getPayload } from "payload";
import type { SanitizedConfig } from "payload";

import type { IHeaderRepository } from "../../application/repositories/header-repository.interface";
import type { Header, HeaderItem } from "../../entities/header";

type PayloadHeaderGlobal = {
  logo?: string | number | { id: string | number } | null;
  items?: Array<{
    label?: string | null;
    href?: string | null;
    external?: boolean | null;
  }> | null;
};

@injectable()
export class PayloadHeaderRepository implements IHeaderRepository {
  private config: SanitizedConfig;

  constructor(config: SanitizedConfig) {
    this.config = config;
  }

  async getHeader(): Promise<Header> {
    const payload = await getPayload({ config: this.config });
    const doc = (await payload.findGlobal({
      slug: "header",
      overrideAccess: false,
    })) as PayloadHeaderGlobal;

    const logoId =
      typeof doc.logo === "object" && doc.logo !== null
        ? String(doc.logo.id)
        : doc.logo != null
          ? String(doc.logo)
          : undefined;

    const items: HeaderItem[] = (doc.items ?? []).map((item) => ({
      label: item.label ?? "",
      href: item.href ?? "",
      external: item.external ?? false,
    }));

    return { logoId, items };
  }
}
```

- [ ] **Step 7: Implement `packages/navigation/src/di/symbols.ts`**

```typescript
export const NAVIGATION_SYMBOLS = {
  IHeaderRepository: Symbol.for("navigation:IHeaderRepository"),
} as const;
```

- [ ] **Step 8: Implement `packages/navigation/src/di/module.ts`**

```typescript
import { ContainerModule, type interfaces } from "inversify";

import type { IHeaderRepository } from "../application/repositories/header-repository.interface";
import { MockHeaderRepository } from "../infrastructure/repositories/mock-header.repository";
import { NAVIGATION_SYMBOLS } from "./symbols";

export const NavigationModule = new ContainerModule((bind: interfaces.Bind) => {
  bind<IHeaderRepository>(NAVIGATION_SYMBOLS.IHeaderRepository).to(
    MockHeaderRepository,
  );
});
```

- [ ] **Step 9: Implement `packages/navigation/src/di/container.ts`**

```typescript
import "reflect-metadata";
import { Container } from "inversify";
import { NavigationModule } from "./module";

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

- [ ] **Step 10: Write `packages/navigation/src/di/container.test.ts`**

```typescript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { navigationContainer } from "./container";
import { NAVIGATION_SYMBOLS } from "./symbols";
import { NavigationModule } from "./module";
import { MockHeaderRepository } from "@/infrastructure/repositories/mock-header.repository";
import type { IHeaderRepository } from "@/application/repositories/header-repository.interface";

describe("navigationContainer", () => {
  beforeEach(() => {
    navigationContainer.unbindAll();
    navigationContainer.load(NavigationModule);
  });

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

  it("resolves IHeaderRepository to MockHeaderRepository", () => {
    const repo = navigationContainer.get<IHeaderRepository>(
      NAVIGATION_SYMBOLS.IHeaderRepository,
    );
    expect(repo).toBeInstanceOf(MockHeaderRepository);
  });
});
```

- [ ] **Step 11: Run all tests**

Run: `cd packages/navigation && pnpm test`
Expected: PASS — 2 tests (use-case 1 + container 1).

- [ ] **Step 12: Commit**

```bash
git add packages/navigation/src
git commit -m "feat(navigation): add Header entity + use-case + mock/payload repos + DI container"
```

---

### Task 4.14: Header controller + tRPC router + tests + CMS global + barrel

- [ ] **Step 1: Create `packages/navigation/src/interface-adapters/controllers/header.controller.ts`**

```typescript
import type { Header } from "../../entities/header";
import { getHeaderUseCase } from "../../application/use-cases/get-header.use-case";

export async function getHeaderController(): Promise<Header> {
  return getHeaderUseCase();
}
```

> Note: No Zod input — `getHeader` takes no input. No InputParseError needed.

- [ ] **Step 2: Create `packages/navigation/src/integrations/cms/globals/header.ts`**

```typescript
import type { GlobalConfig } from "payload";

export const header: GlobalConfig = {
  slug: "header",
  admin: {
    group: "Navigation",
  },
  fields: [
    {
      name: "logo",
      type: "upload",
      relationTo: "media",
    },
    {
      name: "items",
      type: "array",
      fields: [
        { name: "label", type: "text", required: true },
        { name: "href", type: "text", required: true },
        { name: "external", type: "checkbox", defaultValue: false },
      ],
    },
  ],
};
```

- [ ] **Step 3: Create `packages/navigation/src/integrations/cms/index.ts`**

```typescript
export { header } from "./globals/header";
```

- [ ] **Step 4: Write test `packages/navigation/src/integrations/api/router.test.ts`**

```typescript
import { beforeEach, describe, expect, it } from "vitest";
import { navigationContainer } from "@/di/container";
import { NAVIGATION_SYMBOLS } from "@/di/symbols";
import { MockHeaderRepository } from "@/infrastructure/repositories/mock-header.repository";
import type { IHeaderRepository } from "@/application/repositories/header-repository.interface";
import { navigationRouter } from "./router";

describe("navigationRouter", () => {
  beforeEach(() => {
    if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) {
      navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository);
    }
    navigationContainer
      .bind<IHeaderRepository>(NAVIGATION_SYMBOLS.IHeaderRepository)
      .toConstantValue(new MockHeaderRepository());
  });

  it("exposes header procedure", () => {
    const names = Object.keys(navigationRouter._def.procedures);
    expect(names).toContain("header");
  });

  it("header returns 3 items", async () => {
    const caller = navigationRouter.createCaller({});
    const result = await caller.header();
    expect(result.items).toHaveLength(3);
  });
});
```

- [ ] **Step 5: Implement `packages/navigation/src/integrations/api/router.ts`**

```typescript
import { router, publicProcedure } from "@repo/core-shared/trpc/init";
import { getHeaderController } from "../../interface-adapters/controllers/header.controller";

export const navigationRouter = router({
  header: publicProcedure.query(() => getHeaderController()),
});

export type NavigationRouter = typeof navigationRouter;
```

- [ ] **Step 6: Create `packages/navigation/src/ui/query.ts`**

```typescript
type TrpcClient = {
  navigation: {
    header: { queryOptions: () => unknown };
  };
};

export function headerQuery(client: TrpcClient) {
  return client.navigation.header.queryOptions();
}
```

- [ ] **Step 7: Replace `packages/navigation/src/index.ts`**

```typescript
export type { Header, HeaderItem } from "./entities/header";
export { headerQuery } from "./ui/query";
```

- [ ] **Step 8: Run all navigation tests**

Run: `cd packages/navigation && pnpm test`
Expected: PASS — 4 tests (use-case 1 + container 1 + router 2).

- [ ] **Step 9: Commit**

```bash
git add packages/navigation/src
git commit -m "feat(navigation): add controller + tRPC router + header global + barrel"
```

---

## Phase C: Compose into core-cms + core-api

### Task 4.15: Wire pages + globals into core-cms

- [ ] **Step 1: Add `@repo/marketing-pages` and `@repo/navigation` to `packages/core-cms/package.json`**

Add to `dependencies`:

```json
"@repo/marketing-pages": "workspace:*",
"@repo/navigation": "workspace:*",
```

- [ ] **Step 2: Replace `packages/core-cms/src/payload.config.ts`**

```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 { users } from "@repo/auth/cms";
import { articles } from "@repo/blog/cms";
import { media } from "@repo/media/cms";
import { pages, siteSettings } from "@repo/marketing-pages/cms";
import { header } from "@repo/navigation/cms";

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

export default buildConfig({
  editor: lexicalEditor(),
  collections: [users, articles, pages, media],
  globals: [siteSettings, header],
  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 + typecheck**

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

- [ ] **Step 4: Commit**

```bash
git add packages/core-cms pnpm-lock.yaml
git commit -m "feat(core-cms): compose pages + siteSettings + header into payload config"
```

---

### Task 4.16: Wire marketingPagesRouter + navigationRouter into core-api

- [ ] **Step 1: Add to `packages/core-api/package.json` dependencies**

```json
"@repo/marketing-pages": "workspace:*",
"@repo/navigation": "workspace:*",
```

- [ ] **Step 2: Replace `packages/core-api/src/root.ts`**

```typescript
import { router } from "@repo/core-shared/trpc/init";
import { authRouter } from "@repo/auth/api";
import { blogRouter } from "@repo/blog/api";
import { marketingPagesRouter } from "@repo/marketing-pages/api";
import { navigationRouter } from "@repo/navigation/api";

export const appRouter = router({
  auth: authRouter,
  blog: blogRouter,
  marketingPages: marketingPagesRouter,
  navigation: navigationRouter,
});

export type AppRouter = typeof appRouter;
```

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

Run: `pnpm install` then `pnpm typecheck --filter @repo/core-api --filter @repo/marketing-pages --filter @repo/navigation`
Expected: ALL PASS.

- [ ] **Step 4: Commit**

```bash
git add packages/core-api pnpm-lock.yaml
git commit -m "feat(core-api): compose marketingPages + navigation routers into appRouter"
```

---

### Task 4.17: Final verification + cms smoke test

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

Run: `pnpm test --filter @repo/marketing-pages --filter @repo/navigation --filter @repo/blog --filter @repo/auth --filter @repo/core-shared`
Expected: PASS. Marketing-pages: 16, Navigation: 4, Blog: 26, Auth: 24, Core-shared: 26 = 96 total.

- [ ] **Step 2: Repo-wide typecheck**

Run: `pnpm typecheck`
Expected: PASS for all packages except pre-existing `@repo/api` and `@repo/ui` failures.

- [ ] **Step 3: CMS admin smoke test**

Verify postgres: `docker ps | grep postgres`. If not running: `docker compose up -d postgres`.

Start cms in background:
```bash
pnpm dev --filter @repo/cms &
DEV_PID=$!
sleep 15
```

Test endpoints:
```bash
curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin
curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin/collections/pages
curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin/globals/site-settings
curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin/globals/header
```
Expected: ALL return 200.

Kill:
```bash
kill $DEV_PID 2>/dev/null
pkill -f "next.*3001" 2>/dev/null
```

- [ ] **Step 4: Regenerate types**

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

Verify:
```bash
cd /Users/danijel/Documents/Projects/template-vertical/.worktrees/vertical-refactor
grep -E "Page|SiteSetting|Header" packages/core-cms/src/generated-types.ts | wc -l
```
Expected: at least 5 occurrences.

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

```bash
git add packages/core-cms/src/generated-types.ts
git diff --cached --quiet || git commit -m "feat(core-cms): regenerate types — now includes Pages, SiteSettings, Header"
```

---

## Plan 4 Done Criteria

- [ ] Marketing-pages tests pass (16)
- [ ] Navigation tests pass (4)
- [ ] All other features still pass (blog 26, auth 24)
- [ ] `apps/cms` admin serves `/admin/collections/pages`, `/admin/globals/site-settings`, `/admin/globals/header` — all 200
- [ ] `pnpm generate:types` produces a `generated-types.ts` containing Pages, SiteSettings, Header
- [ ] `core-cms/src/payload.config.ts` registers `pages` collection + `siteSettings` and `header` globals
- [ ] `core-api/src/root.ts` exposes 4 namespaces: `auth`, `blog`, `marketingPages`, `navigation`

**Next plan:** Plan 5 — App + UI integration. Wire `core-trpc` (client + per-framework providers), add tRPC route handlers in `apps/web-next` and `apps/web-tanstack`, build example pages (`/`, `/about`, `/blog/[slug]`) that consume the feature ui/query helpers. First plan that actually renders the features in a browser.
