Files
agentic-dev/docs/superpowers/plans/2026-05-04-plan-4-marketing-pages-navigation.md
Danijel Martinek 0972645ebb refactor: rename eslint-config + typescript-config to core-eslint + core-typescript
Aligns tooling packages with the core-* naming convention used by all
other foundation packages (core-shared, core-cms, core-api, core-trpc,
core-ui). Updates ~50 files: package.json names, devDependencies,
tsconfig extends, eslint.config imports, vitest.config imports, AGENTS.md
references, and the boundaries plugin patterns to match the new paths.

The tooling-specific patterns in boundaries/elements are now ordered BEFORE
the broader core-* pattern to ensure correct first-match-wins behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 10:37:07 +02:00

58 KiB

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
{
  "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
{
  "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
{
  "extends": ["//"],
  "tags": ["feature"]
}
  • Step 4: Create packages/marketing-pages/vitest.config.ts
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
export {};
  • Step 6: Add path aliases to tsconfig.base.json

Add to compilerOptions.paths:

"@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
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
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
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
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
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
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
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
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
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
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
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)
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
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
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)
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
{
  "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

"@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
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
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
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
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
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
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
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
export const NAVIGATION_SYMBOLS = {
  IHeaderRepository: Symbol.for("navigation:IHeaderRepository"),
} as const;
  • Step 8: Implement packages/navigation/src/di/module.ts
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
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
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
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
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
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
export { header } from "./globals/header";
  • Step 4: Write test packages/navigation/src/integrations/api/router.test.ts
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
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
type TrpcClient = {
  navigation: {
    header: { queryOptions: () => unknown };
  };
};

export function headerQuery(client: TrpcClient) {
  return client.navigation.header.queryOptions();
}
  • Step 7: Replace packages/navigation/src/index.ts
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
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:

"@repo/marketing-pages": "workspace:*",
"@repo/navigation": "workspace:*",
  • Step 2: Replace packages/core-cms/src/payload.config.ts
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
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
"@repo/marketing-pages": "workspace:*",
"@repo/navigation": "workspace:*",
  • Step 2: Replace packages/core-api/src/root.ts
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
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:

pnpm dev --filter @repo/cms &
DEV_PID=$!
sleep 15

Test endpoints:

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:

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:

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
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.