diff --git a/docs/superpowers/plans/2026-05-04-plan-4-marketing-pages-navigation.md b/docs/superpowers/plans/2026-05-04-plan-4-marketing-pages-navigation.md new file mode 100644 index 0000000..536352e --- /dev/null +++ b/docs/superpowers/plans/2026-05-04-plan-4-marketing-pages-navigation.md @@ -0,0 +1,1874 @@ +# 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/eslint-config": "workspace:*", + "@repo/typescript-config": "workspace:*", + "@types/node": "^22.0.0", + "vitest": "^3.1.0" + } +} +``` + +- [ ] **Step 2: Create `packages/marketing-pages/tsconfig.json`** + +```json +{ + "extends": "@repo/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist", + "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/typescript-config/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; +export type PageStatus = z.infer; +export type Hero = z.infer; +``` + +- [ ] **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; +``` + +- [ ] **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; + getPages(options?: { + status?: string; + limit?: number; + offset?: number; + }): Promise; +} +``` + +- [ ] **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; +} +``` + +- [ ] **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(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 { + const repo = marketingPagesContainer.get( + 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(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 { + const repo = marketingPagesContainer.get( + 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 { + return this._pages.find((p) => p.slug === slug); + } + + async getPages(options?: { + status?: string; + limit?: number; + offset?: number; + }): Promise { + 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 { + 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( + MARKETING_PAGES_SYMBOLS.IPagesRepository, + ); + expect(repo).toBeInstanceOf(MockPagesRepository); + }); + + it("resolves ISiteSettingsRepository to MockSiteSettingsRepository", () => { + const repo = marketingPagesContainer.get( + 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(MARKETING_PAGES_SYMBOLS.IPagesRepository).to( + MockPagesRepository, + ); + bind( + 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 { + 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 { + const payload = await getPayload({ config: this.config }); + const where: Record = {}; + 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 { + 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(MARKETING_PAGES_SYMBOLS.IPagesRepository) + .toConstantValue(new MockPagesRepository()); + marketingPagesContainer + .bind(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 { + 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 { + 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(MARKETING_PAGES_SYMBOLS.IPagesRepository) + .toConstantValue(new MockPagesRepository()); + marketingPagesContainer + .bind(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(MARKETING_PAGES_SYMBOLS.IPagesRepository) + .toConstantValue(new MockPagesRepository()); + marketingPagesContainer + .bind(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/eslint-config": "workspace:*", + "@repo/typescript-config": "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; +export type HeaderItem = z.infer; +``` + +- [ ] **Step 2: Create `packages/navigation/src/application/repositories/header-repository.interface.ts`** + +```typescript +import type { Header } from "../../entities/header"; + +export interface IHeaderRepository { + getHeader(): Promise
; +} +``` + +- [ ] **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(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
{ + const repo = navigationContainer.get( + 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
{ + 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
{ + 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(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( + 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
{ + 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(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.