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